Performance

Hyperbee.Expressions.Compiler is benchmarked against the System expression compiler (SEC) and FastExpressionCompiler (FEC).

Benchmarks run on .NET 9, BenchmarkDotNet, 20 iterations, 8 warmup iterations. All tiers in a table come from one run, so the ratios are comparable even though absolute times drift between runs.

Two notes on method, both from mistakes worth keeping in view.

The Closure tier once built Add( parameter, Constant( capturedValue ) ), which every compiler folds into the instruction stream. It measured a constant add, not a closure, so the closure path went unmeasured in both the benchmarks and the docs. The tier is now a nested lambda that captures the enclosing parameter.

Execution was measured one call at a time, at three iterations. A body like a + b runs in less time than the harness spends reaching it, so those tiers reported a couple of nanoseconds with error bars several times their means, and this page drew a conclusion they could not support – see Execution Speed. Execution is now measured over a thousand calls per operation, which lifts the body above that floor.


Compilation Speed

Expression System FEC HEC vs System vs FEC
Simple 75.7 us 8.1 us 7.8 us 9.7x faster 0.96x
Closure 63.8 us 6.2 us 8.3 us 7.7x faster 1.34x
TryCatch 103.3 us 8.0 us 12.0 us 8.6x faster 1.50x
Complex 271.4 us 7.3 us 9.8 us 27.7x faster 1.35x
Loop 129.6 us 8.9 us 13.5 us 9.6x faster 1.53x
Switch 121.5 us 7.4 us 10.8 us 11.2x faster 1.46x

HEC compiles 8-28x faster than the System compiler and within 0.96-1.50x of FEC.

Simple is the one tier where HEC compiles faster than FEC, and the reason is on FEC’s side rather than ours: FEC 5.4.1 takes 8.1 us there where 5.3.0 took 5.9. Treat that as one tier moving in one release, not as a general result.

The spread against FEC is not fixed overhead – Simple is the closest tier, not the furthest. FEC walks the expression tree and emits IL. HEC lowers to an IR, runs three passes over it, and then emits, which is what pays for the tighter IL, the coroutine support and the patterns FEC declines. Simple compiles at near parity because that pipeline has almost nothing to do; Switch and TryCatch are the tiers where it does the most.


Memory Allocations (per Compile call)

Expression System FEC HEC vs System vs FEC
Simple 4,335 B 903 B 2,095 B 52% fewer 2.3x
Closure 5,678 B 894 B 3,456 B 39% fewer 3.9x
TryCatch 5,897 B 1,516 B 4,085 B 31% fewer 2.7x
Complex 4,741 B 1,390 B 2,479 B 48% fewer 1.8x
Loop 6,718 B 1,110 B 4,255 B 37% fewer 3.8x
Switch 6,272 B 1,304 B 3,840 B 39% fewer 2.9x

HEC allocates 31-52% less memory than the System compiler, and 1.8-3.9x more than FEC.


Execution Speed

Delegates compiled by HEC, SEC and FEC execute at equivalent speed. Nothing allocates per call.

The difference is a fixed cost of about a nanosecond per call – a few clock cycles – and it does not grow with the body, so it is a property of reaching the delegate rather than of the code inside it. On a two-instruction body that shows up as a ratio worth naming; on anything doing real work it is around two percent. HEC sits inside FEC’s own margin.

Measured over a thousand calls per operation, in nanoseconds per call:

Expression System FEC HEC HEC - SEC
Simple 2.36 ns 2.98 ns 3.03 ns +0.67 ns
TryCatch 2.76 ns 3.73 ns 3.60 ns +0.84 ns
Switch 4.66 ns 5.04 ns 5.06 ns +0.40 ns
Complex 63.01 ns 55.08 ns 54.79 ns -8.22 ns
Loop 84.47 ns 90.40 ns 84.15 ns -0.32 ns

FEC used to fail the Loop tier outright. It compiles and runs it as of 5.4.1.

The last column is the figure to read. A ratio taken here is sensitive to how much harness overhead sits in the denominator – the loop and the delegate dispatch are in all three numbers equally – which compresses it on the slow tiers and inflates it on the fast ones. The difference itself does not move.

This was not always true

The page said the same thing before, but the thing it described was twice as bad. It read the per-call tiers – all under two nanoseconds, with error bars several times their means – as “roughly equivalent”, and what those tiers were hiding was a two nanosecond gap, not a one nanosecond one: HEC was 1.75-1.92x the System compiler per call on small bodies.

The conclusion was close to right for the wrong reason, drawn from measurements that could not support it either way. It is kept here because the difference between a claim that holds and a claim that happens to land is the measurement behind it.

The cause was the shape of the delegate rather than the code in it. HEC bound nothing to the delegate unless the body needed a constants array, so most compiled delegates were open static ones. Delegate.Invoke passes a target in the first slot, and a delegate over a static method with nothing bound has no target to put there, so the runtime inserts a thunk that shifts every argument down one on the way through. Measured on its own, same a + b body, no compiler involved: 4.11 ns through an open static delegate against 3.03 ns through one closed over a leading argument.

Every compiled delegate is now closed over its constants array, empty when there is nothing to read – the shape the System compiler uses, which is why its IL for a two-parameter lambda reads ldarg.1 / ldarg.2. The emitted IL was never the problem: for Simple and Switch it is identical in shape to SEC’s, and for Simple two bytes shorter.


Invoked lambdas

Expression.Invoke( lambda, args ) – a lambda invoked in place rather than passed around – is inlined at the call site. The body runs in the calling frame with its parameters bound as block variables, which removes a second compilation and removes the capture: an enclosing variable the body reads becomes an ordinary local read, so nothing is boxed and nothing is allocated per call. The System compiler does the same.

Before inlining was added, the Closure tier compiled in 10.9 us / 7,831 B and executed in 8.0 ns with 24 bytes allocated per call.

A lambda used as a value – assigned to a variable or field rather than invoked in place – still has to be materialized per evaluation, in any compiler. Measured on the same harness that costs ~416 ns with SEC and ~463 ns with HEC: near parity, and not specific to HEC.


When to Use HEC

Scenario Recommendation
BlockAsync or BlockEnumerable HEC – 15-19x faster to run, half the allocation
Hot compilation path (many lambdas compiled at runtime) HEC – 8-28x faster than SEC
Memory-constrained environment HEC – 31-52% fewer allocations than SEC
All expression patterns including those FEC doesn’t support HEC
Static method IL emission (CompileToMethod) HEC only
Fastest compilation and fewest allocations, no coroutines needed FEC
Maximum compatibility, no extra dependency SEC (lambda.Compile())

FEC compiles faster than HEC on every tier here and allocates less on every tier. Where HEC wins is coroutines, which FEC does not support, and the patterns FEC declines.


Coroutine bodies

Execution, per call.

Expression System HEC vs System
BlockAsync, no captures 1,096 ns / 232 B 72 ns / 168 B 15.3x faster
BlockAsync, captures an enclosing variable 1,092 ns / 240 B 70 ns / 120 B 15.5x faster
BlockEnumerable, no captures 1,016 ns / 120 B 54 ns / 56 B 18.7x faster
BlockEnumerable, captures an enclosing variable 1,093 ns / 200 B 57 ns / 80 B 19.3x faster

A coroutine body is compiled once and embedded as a constant delegate. A body that captures an enclosing variable used to be a lambda-as-value that had to be materialized on every call, which put it at parity with SEC; the captured variables are now hoisted into cells that the state machine carries by field, so such a body is compiled once as well.

Where MoveNext lives

CompileToMethod was never ported past .NET Framework, so SEC cannot emit a body into a MethodBuilder. Its state machine holds MoveNext as a delegate in a field and invokes it on every resume. HEC can emit into one, so the body becomes the machine’s own method:

MoveNext form Execution Cold compile
BlockAsync, emitted into the type 72 ns 1,663 us / 64.1 KB
BlockAsync, delegate field 84 ns 1,307 us / 63.2 KB
BlockEnumerable, emitted into the type 54 ns 768 us / 36.5 KB
BlockEnumerable, delegate field 48 ns 995 us / 42.0 KB

Allocation per call is identical either way – the delegate is built once, not per call.

MoveNext is entered once per suspension, not once per await, so an async body whose awaits complete synchronously enters it exactly once no matter how many awaits it has, and a body that does suspend pays scheduling that dwarfs an indirection. That bounds what this can be worth, and the two forms are close enough on the enumerable tiers to trade places between runs.

A DynamicMethod is created with visibility checks skipped while a MethodBuilder is not, so a body reaching a non-public member keeps the delegate form. Emitting into the type is an optimization and must never narrow what compiles. ExpressionRuntimeOptions.EmitMoveNextIntoType forces it off.

Coroutine compilation

Cold compile, one invocation per iteration – a coroutine block caches its reduction, so a second compile of the same instance is not a compile.

Expression System HEC vs System
BlockAsync, no captures 2,434 us / 65.7 KB 1,663 us / 64.1 KB 0.68x / 0.98x
BlockAsync, captures 2,142 us / 66.5 KB 1,405 us / 72.6 KB 0.66x / 1.09x
BlockEnumerable, no captures 1,506 us / 44.0 KB 768 us / 36.5 KB 0.51x / 0.83x
BlockEnumerable, captures 1,590 us / 45.2 KB 853 us / 43.1 KB 0.54x / 0.95x
NestedClosure 277 us / 7.3 KB 63 us / 3.4 KB 0.23x / 0.47x

Both compilers are dominated here by TypeBuilder.CreateType(), which is why these are milliseconds against microseconds everywhere else in this document.

BlockAsync with a capture is the one tier where HEC allocates more than SEC to compile. That is the closure rewriter hoisting the captured variable into a cell, and it buys the 15x execution figure above: a few kilobytes once, against an order of magnitude on every call.

BlockEnumerable used to be the outlier, at 2.0x the System compiler’s time and 2.1x its allocation. Reducing a coroutine block builds a state machine type, and the pipeline reduces a node more than once – BlockAsync cached that and BlockEnumerable did not, so one compile emitted three state machine types and used the last. It also lacked a VisitChildren override, so the base implementation reduced the block and visited the state machine rather than the block’s own children: merely walking the tree built a type.

The generated enumerable type also derives from EnumerableStateMachineBase<TResult> rather than implementing IEnumerable<T>, IEnumerator<T> and IDisposable itself. Two GetEnumerator overloads, two Current accessors, Reset and Dispose were identical for every state machine and were emitted per machine.


Optimization Passes

HEC runs three passes over the IR before emission:

Pass Effect
StackSpillPass Empties the evaluation stack at exception boundaries, and converts branches leaving a protected region into leave
PeepholePass Constant folding, branch simplification, load/store elimination, redundant-cast removal
DeadCodePass Removes instructions after unconditional branches and unreachable label sequences

Invoked lambdas are inlined during lowering, before these passes run.

These passes are why HEC produces tighter IL than SEC, which interprets and re-emits the full expression tree, while staying within striking distance of FEC, which does similar peephole work. They are also most of why HEC compiles more slowly than FEC. Those are the same fact, not two.


© Stillpoint Software.

Hyperbee Expressions Docs