Other

Before replacing LINQ, read what it becomes

Daniel Marbach walks through optimizing a short LINQ predicate in the Azure Service Bus AMQP receiver and finds the real cost in the generated delegate allocation. His first change won on every input shape, while a later collection-aware rewrite helped sets and lists but slowed lazy enumerables; the lesson is measuring what the query compiles into instead of abandoning LINQ.

Daniel Marbach (planetgeek) Daniel Marbach
Published
Reading time
8 min read
agent run #26
agent run #26
Go to original planetgeek.ch

Report this story

Is something wrong with the summary, or do you own this content? Your message comes straight to my inbox.

Type of message

Key points

  • A lambda that touches an instance field can force a fresh `Func<Guid, bool>` allocation on every call, as decompiled `Any` output shows.
  • Replacing only the predicate with a `foreach` loop improved every shape: a single-element array went from 129 ns and 240 bytes to 93 ns and 144 bytes.
  • The collection-aware version cut a four-element `HashSet<string>` from 225 ns and 280 bytes to 80 ns and 128 bytes, but an empty lazy enumerable rose from 20 ns to 31 ns.
  • Never read a LINQ benchmark without its target framework; .NET 10 specializes chains such as `OrderBy(...).Contains(...)` and skips work entirely.
  • A decompiler plus an allocation profiler answers most questions; assembly only matters for bounds checks, vectorization or inlining decisions.
  • Smaller wins come first: `Array.Empty<T>()`, `Enumerable.Empty<T>()`, pre-sized capacity and `Enumerable.TryGetNonEnumeratedCount`.

The article argues that a few lines on a path executed for every message are worth investigating. The receiver converted string lock tokens into Guid values and used `Any` to check membership in `_lockedMessages`. Decompiled output showed the `Select` delegate was cached, while the instance-bound `Func<Guid, bool>` passed to `Any` was allocated on every call — the compiler transformation known as lowering.

Replacing just the predicate with a `foreach` loop removed that allocation and improved every input shape. A second attempt accepted `IReadOnlyCollection<string>` so it could size the array once; sets and lists gained, but lazy enumerables and larger arrays paid for the copy. Marbach stresses that LINQ in .NET 10 fuses and specializes many chains, so benchmark numbers only mean something next to the target framework and the callers' real inputs.

Real-world use

  • Deciding where to start when micro-optimizing a queue receiver that settles every incoming message
  • Reading an allocation profiler on a high-traffic API endpoint to tell which LINQ chain actually costs money
  • Designing an SDK with a broad `IEnumerable<T>` public API while keeping concrete collection types internally
  • Auditing hand-written LINQ replacements in a codebase that migrated from an older runtime to .NET 10

Why it matters

Rewriting LINQ blindly can trade optimizations the runtime now performs for hand-written code you have to maintain; profile, decompile and benchmark on the target framework first.

This summary was generated by the agent from the original post on Daniel Marbach (planetgeek). The full text and all rights belong to its author and publisher. Go to original

Related stories

Feedback

Found a bug or have an idea? Your message lands straight in my inbox.

Type of message