Skip to content

Fix expression compiler compatibility - #558

Draft
jogibear9988 wants to merge 188 commits into
dadhi:masterfrom
jogibear9988:fix-runtime-expression-compatibility
Draft

jogibear9988 wants to merge 188 commits into
dadhi:masterfrom
jogibear9988:fix-runtime-expression-compatibility

Conversation

@jogibear9988

@jogibear9988 jogibear9988 commented Oct 18, 2026 •

Copy link
Copy Markdown
Contributor

This adds the remaining expression compiler compatibility fixes and regression coverage from the imported dotnet/runtime expression tests. Compilation stays in FEC's existing emitter and closure layout.

Current base and history

Rebased on fetched dadhi/master ca552c9f9b8ac95dbc3e6b58534347aa979ad51e (27 October 2026). The branch is linear, with 188 commits and no merge commits above master.

Master now implements several changes originally carried here. Their duplicate implementations have been removed, while retaining the additional lifted arithmetic, unary operator, and TypeEqual regression tests. Master's unary emitters, selected-operator fast paths, TypeIs/TypeEqual implementation, and LightExpression lifting metadata are preserved. The now-redundant reference-comparison refactor commit is folded into its original fix. Rebase integration corrections are included in their owning fixes rather than separate merge-fix commits.

The rebase also adapts address/value handling to master's unary helpers, keeps explicit comparison methods on the common arithmetic path, and corrects checked floating-point arithmetic to use floating-point opcodes. Mixed nullable shift metadata is checked against System's metadata, alongside value, null, and evaluation-order assertions.

Remaining changes

  • Numeric, nullable, selected-operator, conversion, reference-identity, boxed-constant, decimal-bit, and byref character-width semantics.
  • Ref argument storage, readonly copies, writable unbox receivers, array/member/indexer assignments, covariance, multidimensional arrays, and copyback behavior.
  • Exception-safe operand spills, exception filters and cleanup, labels, lexical jump/rethrow validation, and physical method-tail returns.
  • Nested member/list initialization, mutation and evaluation order, including non-void Add methods.
  • Invocation of runtime expression-tree values through emitted compilation/invocation, without falling back to System compilation for the outer lambda.
  • Dynamic expressions with per-occurrence call sites and current target dispatch, including independent nested sites.
  • Quotes and RuntimeVariables sharing typed cells in existing closure slots, independent lambda activations, and rejection of captures that cannot live on the heap.
  • Custom reduction validation, abstract constructor rejection, and method-argument validation.

The native regressions cover both System expressions and LightExpression. The imported runtime suite exercises strict Standard FEC compilation with fallback disabled.

Validation on this rebase

Release builds on Windows ARM64, SDK 10.0.301:

Check Result
Full solution build Passed
Native Standard/Light, .NET 10 All registered TestX cases and 1,749 legacy cases passed
Native Standard/Light, .NET 8 All registered TestX cases and 1,748 legacy cases passed
.NET Framework 4.7.2 1,045 TestX and 1,632 legacy cases passed
Imported runtime suite, .NET 10 35,088 passed; 59 existing skips; 0 failures; 0 crashes

The Framework runner is invoked with en-US culture because existing Issue261 assertions expect English exception-message text. Production code and those assertions are unchanged for this workaround.

Fresh logs, source hashes, the commit mapping, and cleanup details are in the 27 October rebase report.

Limits

The 59 documented exclusions remain, including deferred deep-tree stress and tests tied to System's exact IL/layout or interpreter-specific behavior. Behavior proofs cover the alternative supported IL paths; no new skips were added for this rebase. Unsupported nested exception handling in filters remains rejected, as by System's IL compiler. NativeAOT and trimming publication have not been validated.

This rebase does not make a new timing or allocation claim. Earlier benchmark records remain historical evidence and are not measurements of this head. Performance changes in stacked PR #559 are outside this PR; #559 was not rewritten by this rebase.

@dadhi

dadhi commented Oct 18, 2026

Copy link
Copy Markdown
Owner

Maaaaaan, a lot of Visitors and seems a lot of allocations too.
I need to go over those things. May be I can steer the agent properly, let's see.

@jogibear9988

Copy link
Copy Markdown
Contributor Author

Allocation/performance measurements and independent review

Following @dadhi's concern about visitors and allocations, I compared the current patch against both its parent and current upstream, and reviewed the production code independently of the earlier test-fixing work. The allocation concern is justified. I recommend keeping this draft while the common-path regressions and the newly reproduced correctness issue below are addressed. Passing the imported test suite alone is not sufficient evidence for merging this change.

Revisions: upstream ff907e1986700a30f05a56e5cee28e952faee363; parent #557 e4c232da108d98790f0ca73f23ec90756da0cad7; reviewed head b8f8b38bc44367f129b49a55b2b2043e81b04ccb. #559 performance changes are not included. Standard compiler source is identical in upstream and parent; their Light source differs only in the existing #555 null-conversion round-trip fix. Both baselines were nevertheless measured separately.

Main observations

  • Compilation of captures allocates substantially more. Standard/.NET 10, one immutable integer capture: approximately 3,000 -> 6,525 B/compile, 3.34 -> 5.75 us. The capture8 case (eight integer block locals plus the outer integer parameter, all read by the returned delegate): 5,040 -> 37,988 B/compile, 7.60 -> 26.21 us. Light has the same allocation pattern, approximately 5,043 -> 37,991 B. Repeated StrongBox type/field/constructor reflection and new hoisting metadata deserve attention; no allocation-stack profile was collected, so these totals must not all be attributed to one call site.
  • Immutable reference capture also adds runtime allocation. A block-local Box reference assigned a pre-existing constant, captured by () => box.Value: 160 -> 184 B per outer invocation in both libraries/runtimes, an extra 24-byte cell. The outer invocation creates and executes the returned delegate in this workload. Integer capture runtime allocations stay at 184 B (capture1) / 440 B (capture8): baseline already boxed those values. Shared mutable storage needs cells; this immutable-reference example does not establish that all captures need the same representation.
  • Label-free scope tracking adds compilation allocations. For 8/32/128 nested variable-free blocks, before is 872 B/compile; after is 1,128 / 2,104 / 5,960 B. No jump, label, quote, dynamic node, or nested lambda is involved. Persistent LabelScope objects are lazy, but the scope-frame stack grows even here.
  • Ordinary delegate Invoke adds 32 B/compile: 1,064 -> 1,096 B. The emitter calls GetParameters() twice for the same Invoke signature. This is direct duplicated work, independently confirmed in source.
  • Most plain workloads keep the same managed allocations, including addition (872 B), conditional (1,064 B), constant-object call (1,056 B), array access (992 B), and object construction/member access (968 B). Warm invocation remains 0 B for these examples. Some common-path compile timings worsen; very small timing differences on this virtualized host are not reliable optimization claims.
  • Exception-method compile ratios need the first-call control. try_catch compile-only: 0.90 -> 17.44 us; compile + first execution: 16.57 -> 17.95 us. try_finally: 0.84 -> 12.32 us compile-only, 10.57 -> 11.32 us with first execution. Anonymous DynamicMethod hosting performs eager JIT work that was previously deferred. The ~19x/~15x compile-only ratios do not mean total first-use cost increased that much. By contrast capture8 compile + first execution rises 235.95 -> 368.23 us, so its regression is not explained away by that hosting distinction.
  • The noninterned-string example adds 112 B/compile (904 -> 1,016 B) because the original string is now closure-bound. That has a documented identity-correctness purpose; removing the binding just to recover the old allocation number would be wrong. The benchmark checks string length, an observable behavior that works on both baselines, rather than using a previously incorrect identity case as a performance baseline.

Independent review findings

  1. P2 correctness: fresh extension reduction + Dynamic throws during compilation. An extension with CanReduce == true whose Reduce() creates a fresh Convert(Dynamic(AddBinder, x, 2), int) each time is valid. At head, strict CompileFast(true) throws NullReferenceException on .NET 8 and .NET 10, under both Default and NoInvocationLambdaInlining. System returns 7 for input 5. A direct Dynamic and an extension caching the same reduced node both return 7 with FEC. Parent/upstream reject the new Dynamic shape with null rather than this exception. This is a new-feature composition defect, not a claim that this input was previously supported.

    Collection and emission reduce the custom node separately; Dynamic metadata is keyed by the first node's identity. Emission gets a different node. At the Dynamic lookup, the missing-map reference is copied before the foundDynamic check, producing the NRE. Preserve/reuse the collected reduction and make missing metadata follow FEC's supported failure contract. This reproduction is outside the earlier 35,133-case baseline, so the earlier zero-failure baseline does not cover it.

  2. P2 performance: duplicate Invoke reflection. Reuse invokeParameters rather than obtaining a second parameter array. The measured +32 B matches the extra one-argument array. This fix can stay inside the existing emitter.

  3. P2 performance/design: label-free scope-stack growth. Avoid making label-free, tail-collected block chains allocate O(depth) validation state. Preserve lexical validation without introducing another unconditional visitor pass. The 872 -> 5,960 B result above makes this a demonstrated cost rather than a speculative concern.

  4. P2 performance/design: capture costs. Reuse per-compilation hoisted-storage metadata rather than repeating generic type/field/constructor reflection. Separately consider a direct-value representation for provably immutable reference captures, while retaining shared cells for mutation, quote/runtime-variable exposure, and by-ref uses. Representation selection is a broader change and needs its own correctness checks; it should not be rushed merely to match old allocation counts.

  5. Integration note, inherited from the branch base: current head and parent still throw on the Light round-trip of decimal AddAssign with a method and null conversion. Current upstream succeeds, via Fix LightExpression compound assignment round trips without a conversion #555's Conversion?.ToLambdaExpression() fix; head has the old unguarded call. System and direct Light/FEC execution return 3 for input 2, while head's Light -> System conversion throws NRE. This was not introduced by the compatibility-layer commits; preserve Fix LightExpression compound assignment round trips without a conversion #555 when rebasing/merging rather than treating it as a new regression from this layer. It is excluded from the working-on-both performance comparisons.

The added visitor families are feature-gated. ArgumentSpillScope scans runtime Expression invocation/Dynamic arguments; QuoteVariableScope examines quotes, and QuoteBinder rebuilds captured quotes at execution. They are not all run on every ordinary CompileFast call. There is still duplicated feature-specific traversal, linear bound/free-variable searches, and quote rebuilding allocation. Unconditional scope bookkeeping and broad capture representation have measurable costs even where those visitors do not run. Exception-region objects are also allocated for handler trees without jumps. A blanket visitor rewrite is not justified by this review; remove duplicate work and isolate common-path costs first.

Source locations at the reviewed head:

Measurement scope and method

Windows ARM64 virtual machine (Apple Silicon host, six exposed logical processors), SDK 10.0.301; .NET 10.0.9 and 8.0.28, Release, native ARM64. Production source snapshots were compiled unchanged in isolated dependency-free library projects with the appropriate target-framework and LIGHT_EXPRESSION symbols. The main checkout and PR head were not changed.

31 scenario shapes: 28 work on both baselines/head, and 3 newly supported shapes (closed/captured quotes and RuntimeVariables) are reported without a before/after ratio. Each backend's observable outputs are checked against System compilation for inputs -2, 0 and 3 before timing; returned captures/quotes are executed during verification and RuntimeVariables get/set is checked. This is a targeted control, not exhaustive semantic equivalence. Strict FEC is used with no outer fallback.

Measured separately: tree construction (there is no text parser here), compilation of a reused tree, compilation plus first outer execution, and warm invocation. For escaping captures, first-use/warm measurements also create and call the returned delegate; for quotes/RuntimeVariables, they create/return the object, while returned quote compilation is only part of correctness verification. System uses its IL compiler, not the interpreter. Light trees are constructed natively, and equivalent System trees are prepared before System compilation timing; Light -> System conversion is excluded from that comparison. The RuntimeVariables Light factory is unavailable publicly, so that one construction case uses System construction + ToLightExpression and is explicitly not a native-Light construction comparison.

Two fresh-process rounds per version, forward then reverse version order, seven warmed samples per phase, adaptive batches targeting ~8ms (caps 2,000 compile/construction or 1,000,000 invocation operations). TieredCompilation and ReadyToRun are disabled consistently. GC is forced outside each timed sample. Allocations use GC.GetAllocatedBytesForCurrentThread; GC generation collection counts are recorded. 26,432 samples / 1,888 version-scenario-phase groups. Medians are descriptive, not confidence intervals. Harness loop/delegate dispatch overhead is included; nanosecond-level deltas and the supplemental cases' noisier timings should not be called statistically established speedups/regressions. Allocation changes are the strongest evidence here.

This measures managed allocation volume, not retained heap size or native/JIT memory; static initialization and application cold startup are warmed away. First execution means the newly created method's first call in a warmed process. It does not claim all possible expression graphs, Framework/x64, or production workload performance were measured. No whole imported correctness rerun was needed for this read-only review; the independent reproduction above adds a known issue beyond that historical baseline.

Complete measured phase results

In the tables, B -> A is parent -> head, S is System, and U is current upstream. Time units are shown in each column; allocation units are bytes/op. n/a means that backend rejected or could not construct the feature, never a zero-allocation success. Full construction and first-use figures are included to avoid interpreting just compile-only speed.

.NET 10.0.9, standard: all 31 scenarios, all four phases
Scenario Construct ns / B: B -> A Compile us / B: U ; B ; A ; S Compile + first us / B: B -> A Warm ns / B: B -> A ; S
add 143.30/440 -> 142.51/440 0.70/872 ; 0.75/872 ; 0.91/872 ; 10.78/4248 10.12/872 -> 9.92/872 1.27/0 -> 1.31/0 ; 1.27/0
arithmetic64 1674.24/6488 -> 1741.64/6488 19.45/1056 ; 19.32/1056 ; 21.53/1057 ; 25.54/12425 34.28/1057 -> 38.64/1057 1.26/0 -> 1.28/0 ; 1.30/0
array 156.11/552 -> 173.04/552 0.84/992 ; 0.86/992 ; 1.12/992 ; 33.75/4825 24.39/993 -> 24.64/993 1.03/0 -> 1.04/0 ; 1.88/0
balanced256 4898.68/12585 -> 4990.99/12585 21.96/1377 ; 22.22/1376 ; 27.47/1377 ; 240.56/37253 223.13/1381 -> 232.37/1381 11.34/0 -> 11.56/0 ; 10.94/0
blocks128 25139.30/78364 -> 26218.97/78364 70.16/14977 ; 70.33/14977 ; 89.33/14978 ; 124.54/188289 105.31/14978 -> 118.67/14979 1.28/0 -> 1.31/0 ; 1.29/0
blocks32 6066.14/19945 -> 6221.84/19945 14.16/4272 ; 14.27/4272 ; 18.66/4272 ; 40.13/49659 30.43/4273 -> 34.12/4273 1.28/0 -> 1.28/0 ; 1.32/0
capture1 181.97/464 -> 190.16/464 4.01/3000 ; 3.34/3000 ; 5.75/6525 ; 60.74/8770 99.93/3186 -> 92.59/6753 20.81/184 -> 17.93/184 ; 195.53/152
capture8 1308.65/2856 -> 1212.50/2856 7.24/5040 ; 7.60/5040 ; 26.21/37988 ; 164.59/20607 235.95/5486 -> 368.23/38623 44.74/440 -> 41.57/440 ; 199.54/408
capture_reference 694.04/944 -> 1300.71/944 5.48/2872 ; 6.56/2872 ; 10.40/3720 ; 190.76/10204 178.13/3032 -> 237.98/3904 36.25/160 -> 57.95/184 ; 329.48/152
conditional 174.99/520 -> 241.95/520 0.88/1064 ; 0.87/1064 ; 1.15/1064 ; 15.27/4560 13.74/1064 -> 14.52/1064 1.27/0 -> 1.29/0 ; 1.28/0
constant 117.96/392 -> 121.72/392 0.63/872 ; 0.66/872 ; 0.68/872 ; 9.55/3904 8.70/872 -> 8.62/872 1.29/0 -> 1.28/0 ; 1.30/0
constant_object 183.27/464 -> 191.01/464 0.81/1056 ; 0.84/1056 ; 1.03/1056 ; 30.63/4808 20.65/1056 -> 20.73/1056 1.05/0 -> 1.06/0 ; 1.03/0
goto 162.49/536 -> 175.45/536 0.73/872 ; 0.74/872 ; 0.88/1032 ; 10.53/4938 8.40/872 -> 9.85/1032 1.29/0 -> 1.27/0 ; 1.28/0
interned_string 181.97/440 -> 230.28/496 0.74/904 ; 0.65/904 ; 0.96/904 ; 10.44/3944 9.02/904 -> 9.79/904 1.28/0 -> 1.31/0 ; 1.30/0
invoke_delegate 177.96/440 -> 186.59/440 0.82/1064 ; 0.84/1064 ; 0.99/1096 ; 29.34/4824 20.85/1064 -> 19.67/1096 1.72/0 -> 1.76/0 ; 1.98/0
invoke_literal 313.55/832 -> 328.44/832 1.27/1376 ; 1.36/1376 ; 1.61/1376 ; 13.14/6456 11.29/1376 -> 11.83/1376 1.34/0 -> 1.35/0 ; 1.29/0
invoke_try_argument 343.54/912 -> 345.27/912 1.67/1976 ; 1.50/1976 ; 12.62/2048 ; 14.85/7544 12.14/1976 -> 13.39/2048 1.27/0 -> 1.29/0 ; 1.26/0
loop 365.81/1200 -> 410.44/1200 1.31/1072 ; 1.38/1072 ; 1.67/1432 ; 20.96/8720 16.78/1072 -> 17.50/1432 2.85/0 -> 2.89/0 ; 2.57/0
nested_blocks128 8933.55/11641 -> 8906.17/11640 10.22/872 ; 9.75/872 ; 15.86/5960 ; 560.52/9240 32.42/872 -> 40.21/5960 2.27/0 -> 1.74/0 ; 2.37/0
nested_blocks32 2183.59/3192 -> 2436.28/3192 4.20/872 ; 3.65/872 ; 4.42/2104 ; 61.95/5400 23.35/872 -> 25.71/2104 2.31/0 -> 2.21/0 ; 2.00/0
nested_blocks8 883.23/1072 -> 800.62/1072 1.95/872 ; 2.65/872 ; 2.36/1128 ; 34.05/4440 28.23/872 -> 20.96/1128 2.34/0 -> 2.00/0 ; 1.78/0
new_array 187.36/544 -> 201.21/544 1.01/1320 ; 0.87/1320 ; 1.08/1320 ; 21.75/4289 21.45/1320 -> 20.73/1320 1.28/0 -> 1.29/0 ; 1.02/0
new_member 262.73/664 -> 282.11/664 0.73/968 ; 0.81/968 ; 0.87/968 ; 16.09/4296 14.53/968 -> 14.92/968 1.30/0 -> 1.32/0 ; 1.33/0
noninterned_string 180.52/536 -> 246.40/592 0.67/904 ; 0.67/904 ; 0.99/1016 ; 10.20/3944 8.94/904 -> 17.87/1016 1.30/0 -> 1.05/0 ; 1.29/0
nullable 232.20/552 -> 252.83/552 0.94/1368 ; 0.92/1368 ; 1.18/1368 ; 20.63/4953 17.44/1368 -> 18.42/1369 1.28/0 -> 1.28/0 ; 1.28/0
quote_capture n/a -> 170.53/416 n/a ; n/a ; 2.85/3491 ; 63.83/6452 n/a -> 67.76/3916 n/a -> 328.68/384 ; 109.45/240
quote_closed n/a -> 176.89/464 n/a ; n/a ; 1.18/1336 ; 24.79/5161 n/a -> 17.25/1336 n/a -> 2.05/0 ; 2.53/0
runtime_variables n/a -> 145.15/456 n/a ; n/a ; 2.22/2900 ; 50.14/5154 n/a -> 35.86/2996 n/a -> 8.78/80 ; 10.28/88
switch 310.64/1056 -> 312.31/1056 0.98/1144 ; 0.99/1144 ; 1.21/1208 ; 18.34/5720 17.54/1144 -> 17.51/1208 1.27/0 -> 1.28/0 ; 1.03/0
try_catch 212.84/712 -> 211.65/712 0.91/1504 ; 0.90/1504 ; 17.44/1568 ; 17.82/5400 16.57/1504 -> 17.95/1568 1.07/0 -> 0.92/0 ; 0.89/0
try_finally 160.55/520 -> 165.03/520 0.85/1472 ; 0.84/1472 ; 12.32/1536 ; 13.20/5336 10.57/1472 -> 11.32/1536 1.30/0 -> 1.29/0 ; 1.26/0
.NET 10.0.9, light: all 31 scenarios, all four phases
Scenario Construct ns / B: B -> A Compile us / B: U ; B ; A ; S Compile + first us / B: B -> A Warm ns / B: B -> A ; S
add 44.59/144 -> 45.34/144 0.85/872 ; 0.68/872 ; 0.82/872 ; 12.57/4248 10.28/872 -> 10.39/872 1.26/0 -> 1.29/0 ; 1.31/0
arithmetic64 5828.90/7152 -> 5710.37/7152 27.08/1057 ; 26.08/1057 ; 27.41/1057 ; 33.77/12425 41.94/1057 -> 42.82/1057 1.27/0 -> 1.29/0 ; 1.31/0
array 55.96/288 -> 64.34/288 0.87/992 ; 0.86/992 ; 1.13/992 ; 35.43/4826 23.85/993 -> 25.62/992 1.03/0 -> 1.05/0 ; 1.91/0
balanced256 3365.25/10280 -> 3341.71/10280 23.08/1376 ; 22.91/1376 ; 26.21/1377 ; 258.71/37254 226.49/1381 -> 230.81/1381 11.43/0 -> 11.36/0 ; 11.51/0
blocks128 8183.97/40122 -> 8781.00/40122 69.59/14978 ; 61.08/14977 ; 72.83/14978 ; 121.19/188289 100.06/14978 -> 107.47/14978 1.30/0 -> 1.29/0 ; 1.33/0
blocks32 1902.28/10120 -> 2072.29/10120 12.72/4272 ; 12.03/4272 ; 15.72/4272 ; 40.83/49658 28.01/4273 -> 39.52/4273 1.31/0 -> 1.29/0 ; 1.31/0
capture1 63.70/168 -> 62.66/168 3.25/3000 ; 3.22/3000 ; 5.62/6525 ; 61.61/8770 96.41/3186 -> 95.99/6763 19.82/184 -> 17.84/184 ; 171.05/152
capture8 638.30/2000 -> 594.38/2000 5.83/5040 ; 5.38/5043 ; 24.15/37991 ; 173.31/20608 246.88/5485 -> 350.21/38581 47.31/440 -> 41.32/440 ; 201.80/408
capture_reference 130.66/368 -> 100.79/368 5.41/2872 ; 5.22/2872 ; 5.93/3720 ; 106.91/10202 105.17/3032 -> 119.71/3904 19.37/160 -> 19.89/184 ; 168.21/152
conditional 46.56/216 -> 44.32/216 0.98/1064 ; 0.92/1064 ; 0.94/1064 ; 18.31/4560 14.02/1064 -> 13.93/1064 1.28/0 -> 1.27/0 ; 1.33/0
constant 40.72/152 -> 47.10/152 0.75/872 ; 0.75/872 ; 0.98/872 ; 10.80/3904 8.33/872 -> 8.47/872 1.28/0 -> 1.29/0 ; 1.31/0
constant_object 56.84/168 -> 56.97/168 0.94/1056 ; 0.82/1056 ; 0.88/1056 ; 30.25/4808 23.60/1056 -> 21.51/1056 1.08/0 -> 1.07/0 ; 1.06/0
goto 50.55/240 -> 51.53/240 0.73/872 ; 0.75/872 ; 0.89/992 ; 12.12/4939 9.79/872 -> 11.49/992 1.31/0 -> 1.27/0 ; 1.30/0
interned_string 74.62/176 -> 75.16/176 0.68/904 ; 0.67/904 ; 0.89/904 ; 11.71/3944 9.53/904 -> 9.27/904 1.30/0 -> 1.28/0 ; 1.31/0
invoke_delegate 56.85/144 -> 62.79/144 0.93/1064 ; 0.89/1064 ; 1.02/1096 ; 30.93/4824 22.31/1064 -> 20.03/1096 1.78/0 -> 1.71/0 ; 2.00/0
invoke_literal 79.90/280 -> 80.72/280 1.08/992 ; 0.97/992 ; 1.03/992 ; 13.29/6456 10.78/992 -> 11.21/992 1.30/0 -> 1.27/0 ; 1.32/0
invoke_try_argument 84.38/320 -> 83.91/320 1.37/1592 ; 1.15/1592 ; 11.83/1664 ; 15.53/7544 12.51/1592 -> 11.85/1664 1.31/0 -> 1.28/0 ; 1.30/0
loop 86.28/576 -> 85.18/576 1.20/1072 ; 1.25/1072 ; 1.75/1360 ; 21.93/8725 17.45/1072 -> 20.57/1360 2.90/0 -> 2.93/0 ; 2.67/0
nested_blocks128 885.69/6256 -> 900.77/6256 6.30/872 ; 4.10/872 ; 6.01/5960 ; 252.11/9248 13.78/872 -> 13.95/5960 1.30/0 -> 1.32/0 ; 1.30/0
nested_blocks32 212.94/1648 -> 207.74/1648 2.06/872 ; 1.87/872 ; 2.53/2104 ; 27.77/5408 16.37/872 -> 11.43/2104 1.32/0 -> 1.33/0 ; 1.31/0
nested_blocks8 70.71/488 -> 69.55/488 1.19/872 ; 1.00/872 ; 1.05/1128 ; 12.96/4440 11.40/872 -> 9.91/1128 1.32/0 -> 1.31/0 ; 1.29/0
new_array 46.74/160 -> 48.88/160 1.03/1320 ; 0.89/1320 ; 0.90/1320 ; 22.83/4288 21.33/1320 -> 22.16/1320 1.32/0 -> 1.31/0 ; 1.08/0
new_member 116.61/304 -> 112.27/304 0.75/968 ; 0.74/968 ; 0.77/968 ; 16.13/4296 16.31/968 -> 14.66/968 1.33/0 -> 1.28/0 ; 1.31/0
noninterned_string 78.32/272 -> 77.10/272 0.72/904 ; 0.70/904 ; 0.95/1016 ; 12.19/3944 10.20/904 -> 17.14/1016 1.29/0 -> 1.06/0 ; 1.30/0
nullable 85.36/256 -> 82.19/256 1.07/1368 ; 0.95/1368 ; 1.00/1368 ; 25.82/4952 17.40/1368 -> 18.33/1368 1.30/0 -> 1.30/0 ; 1.33/0
quote_capture n/a -> 120.79/208 n/a ; n/a ; 2.72/3659 ; 67.16/6453 n/a -> 64.90/4076 n/a -> 253.72/384 ; 110.69/240
quote_closed n/a -> 129.40/280 n/a ; n/a ; 1.22/1504 ; 27.90/5161 n/a -> 17.49/1504 n/a -> 2.04/0 ; 2.62/0
runtime_variables n/a -> 344.14/736 n/a ; n/a ; 3.19/2900 ; 68.89/5139 n/a -> 57.13/3004 n/a -> 12.44/80 ; 13.75/88
switch 76.29/512 -> 76.41/512 0.91/1144 ; 0.88/1144 ; 1.20/1208 ; 20.85/5721 18.02/1144 -> 20.69/1208 1.26/0 -> 1.29/0 ; 1.30/0
try_catch 62.10/328 -> 63.25/328 0.98/1504 ; 0.90/1504 ; 16.56/1568 ; 19.50/5400 17.37/1504 -> 18.45/1569 1.08/0 -> 0.91/0 ; 0.91/0
try_finally 51.61/184 -> 51.11/184 0.92/1472 ; 0.84/1472 ; 11.95/1536 ; 14.15/5336 11.97/1472 -> 12.09/1536 1.29/0 -> 1.26/0 ; 1.31/0
.NET 8.0.28, standard: all 31 scenarios, all four phases
Scenario Construct ns / B: B -> A Compile us / B: U ; B ; A ; S Compile + first us / B: B -> A Warm ns / B: B -> A ; S
add 174.95/440 -> 149.16/440 1.08/872 ; 0.87/872 ; 0.88/872 ; 10.12/4248 9.97/872 -> 7.81/872 1.28/0 -> 1.28/0 ; 1.29/0
arithmetic64 1678.60/6488 -> 1541.88/6488 24.52/1056 ; 22.59/1056 ; 22.63/1056 ; 23.05/12425 36.12/1057 -> 36.47/1057 1.29/0 -> 1.29/0 ; 1.27/0
array 189.90/552 -> 160.26/552 0.89/992 ; 0.93/992 ; 0.99/992 ; 32.03/4825 23.68/993 -> 22.76/992 1.04/0 -> 1.03/0 ; 1.29/0
balanced256 5441.70/12585 -> 5060.31/12585 28.56/1377 ; 23.66/1376 ; 27.50/1377 ; 231.76/37253 240.57/1381 -> 228.04/1381 11.76/0 -> 9.57/0 ; 8.55/0
blocks128 46176.56/78365 -> 30149.28/78364 82.87/14978 ; 97.65/14979 ; 88.00/14978 ; 126.45/188287 160.01/14980 -> 122.16/14978 1.32/0 -> 1.28/0 ; 1.29/0
blocks32 12179.42/19946 -> 7159.22/19945 15.33/4272 ; 25.77/4273 ; 19.72/4272 ; 36.88/49658 61.76/4273 -> 35.79/4273 1.93/0 -> 1.28/0 ; 1.29/0
capture1 215.82/464 -> 236.09/520 3.79/3000 ; 3.47/3000 ; 8.75/6561 ; 61.61/8827 85.22/3186 -> 111.00/6817 20.61/184 -> 19.59/184 ; 172.97/152
capture8 1285.09/2912 -> 1384.53/2968 6.76/5040 ; 7.53/5040 ; 42.98/38279 ; 237.60/20842 232.85/5484 -> 367.07/38892 47.30/440 -> 48.60/440 ; 204.36/408
capture_reference 397.63/944 -> 416.76/944 3.58/2872 ; 3.47/2872 ; 4.95/3720 ; 91.46/10258 76.82/3032 -> 100.19/3904 18.46/160 -> 22.67/184 ; 167.88/152
conditional 181.88/520 -> 203.53/520 0.97/1064 ; 1.09/1064 ; 1.06/1064 ; 12.86/4560 12.47/1064 -> 11.39/1064 1.31/0 -> 1.29/0 ; 1.27/0
constant 135.47/392 -> 127.83/392 0.89/872 ; 0.85/872 ; 0.79/872 ; 8.02/3904 10.47/872 -> 7.76/872 1.31/0 -> 1.28/0 ; 1.30/0
constant_object 202.02/464 -> 201.74/464 0.98/1056 ; 1.07/1056 ; 0.97/1056 ; 29.54/4808 20.87/1056 -> 19.25/1056 1.03/0 -> 1.02/0 ; 1.13/0
goto 279.02/536 -> 189.25/536 0.94/872 ; 1.05/872 ; 1.03/1032 ; 9.45/4944 14.15/872 -> 8.64/1032 1.98/0 -> 1.31/0 ; 1.27/0
interned_string 193.59/440 -> 195.65/440 0.69/904 ; 0.87/904 ; 0.87/904 ; 9.20/3944 7.94/904 -> 7.96/904 1.29/0 -> 1.27/0 ; 1.27/0
invoke_delegate 352.39/440 -> 206.34/440 1.04/1064 ; 0.94/1064 ; 1.35/1096 ; 34.33/4824 28.89/1064 -> 25.91/1096 1.59/0 -> 1.57/0 ; 1.59/0
invoke_literal 419.71/832 -> 349.72/832 2.01/1432 ; 2.39/1432 ; 1.65/1432 ; 15.91/6456 14.08/1432 -> 10.42/1432 1.30/0 -> 1.31/0 ; 1.31/0
invoke_try_argument 414.48/912 -> 446.12/912 1.67/2032 ; 2.15/2032 ; 12.89/2104 ; 14.72/7544 12.43/2032 -> 13.32/2104 1.35/0 -> 1.31/0 ; 1.29/0
loop 546.82/1200 -> 379.21/1200 1.53/1072 ; 1.82/1072 ; 1.88/1432 ; 19.39/8720 33.66/1073 -> 16.59/1432 4.24/0 -> 2.87/0 ; 2.61/0
nested_blocks128 4884.89/11641 -> 5027.94/11640 5.43/872 ; 5.67/872 ; 7.37/5960 ; 319.62/9248 12.94/872 -> 14.41/5960 1.29/0 -> 1.29/0 ; 1.29/0
nested_blocks32 1363.67/3192 -> 1420.16/3192 1.91/872 ; 1.90/872 ; 2.93/2104 ; 30.12/5408 8.84/872 -> 9.58/2104 1.29/0 -> 1.30/0 ; 1.32/0
nested_blocks8 460.59/1072 -> 494.66/1072 1.05/872 ; 1.03/872 ; 1.31/1128 ; 10.85/4440 9.51/872 -> 8.47/1128 1.30/0 -> 1.30/0 ; 1.29/0
new_array 216.68/544 -> 217.39/544 0.91/1320 ; 0.96/1320 ; 1.02/1320 ; 19.40/4288 18.05/1360 -> 18.10/1360 3.19/40 -> 3.09/40 ; 2.98/40
new_member 297.97/664 -> 295.73/664 0.78/968 ; 0.80/968 ; 0.87/968 ; 17.64/4296 18.72/992 -> 15.83/992 3.80/24 -> 3.53/24 ; 4.14/24
noninterned_string 197.80/536 -> 203.02/536 0.99/904 ; 0.85/904 ; 0.93/1016 ; 8.73/3944 7.81/904 -> 18.49/1016 1.30/0 -> 1.06/0 ; 1.29/0
nullable 243.09/552 -> 239.46/552 1.03/1368 ; 1.15/1368 ; 1.06/1368 ; 17.97/4952 17.46/1368 -> 15.66/1368 1.28/0 -> 1.26/0 ; 1.28/0
quote_capture n/a -> 203.00/416 n/a ; n/a ; 4.58/3510 ; 69.00/6508 n/a -> 70.39/3928 n/a -> 285.38/384 ; 132.43/240
quote_closed n/a -> 195.33/464 n/a ; n/a ; 1.30/1336 ; 32.90/5161 n/a -> 18.14/1336 n/a -> 2.07/0 ; 2.88/0
runtime_variables n/a -> 166.96/456 n/a ; n/a ; 3.34/2916 ; 55.64/5210 n/a -> 40.28/3014 n/a -> 9.69/80 ; 10.55/88
switch 353.36/1056 -> 340.09/1056 1.05/1144 ; 1.14/1144 ; 1.42/1208 ; 16.11/5724 16.83/1144 -> 18.51/1208 1.09/0 -> 1.06/0 ; 1.02/0
try_catch 244.94/712 -> 233.35/712 1.16/1504 ; 1.10/1504 ; 18.19/1568 ; 17.43/5400 18.02/1504 -> 19.65/1568 1.04/0 -> 0.77/0 ; 0.76/0
try_finally 178.70/520 -> 169.32/520 1.03/1472 ; 0.94/1472 ; 10.16/1536 ; 10.43/5336 9.67/1472 -> 10.55/1536 1.30/0 -> 1.30/0 ; 1.29/0
.NET 8.0.28, light: all 31 scenarios, all four phases
Scenario Construct ns / B: B -> A Compile us / B: U ; B ; A ; S Compile + first us / B: B -> A Warm ns / B: B -> A ; S
add 47.48/144 -> 49.19/144 0.83/872 ; 0.82/872 ; 1.30/872 ; 9.10/4248 8.52/872 -> 11.64/872 1.31/0 -> 1.34/0 ; 1.30/0
arithmetic64 5947.36/7152 -> 7178.13/7152 30.68/1057 ; 27.35/1057 ; 35.97/1057 ; 24.51/12425 43.15/1057 -> 47.91/1057 1.29/0 -> 1.32/0 ; 1.30/0
array 59.25/288 -> 88.86/288 1.29/992 ; 0.86/992 ; 1.18/992 ; 32.57/4825 23.87/992 -> 30.68/993 1.03/0 -> 1.03/0 ; 1.30/0
balanced256 3514.11/10280 -> 4337.86/10280 29.28/1377 ; 22.76/1376 ; 30.59/1377 ; 233.06/37253 229.23/1381 -> 308.55/1381 11.43/0 -> 11.44/0 ; 8.64/0
blocks128 9865.71/40122 -> 10220.74/40122 67.08/14977 ; 77.19/14978 ; 87.41/14978 ; 117.98/188288 146.71/14978 -> 120.81/14983 1.39/0 -> 1.32/0 ; 1.27/0
blocks32 2133.83/10120 -> 2148.46/10120 11.95/4272 ; 13.91/4272 ; 19.14/4272 ; 38.28/49658 28.75/4273 -> 34.30/4273 1.31/0 -> 1.42/0 ; 1.30/0
capture1 80.42/168 -> 71.27/168 3.51/3000 ; 4.23/3000 ; 9.05/6558 ; 62.35/8826 123.93/3187 -> 107.01/6812 28.30/184 -> 20.37/184 ; 161.34/152
capture8 702.82/2000 -> 703.38/2000 5.07/5040 ; 6.66/5040 ; 42.04/38265 ; 215.89/20840 286.08/5490 -> 396.18/38974 50.78/440 -> 51.49/440 ; 198.30/408
capture_reference 110.22/368 -> 110.10/368 3.61/2872 ; 3.54/2872 ; 4.21/3720 ; 87.62/10258 74.37/3032 -> 91.34/3904 18.23/160 -> 20.32/184 ; 181.99/152
conditional 49.51/216 -> 55.71/216 1.66/1064 ; 0.91/1064 ; 2.06/1064 ; 12.79/4560 12.15/1064 -> 17.55/1064 1.31/0 -> 1.30/0 ; 1.28/0
constant 63.77/152 -> 46.38/152 0.72/872 ; 0.81/872 ; 1.06/872 ; 7.95/3904 8.00/872 -> 10.54/872 1.33/0 -> 1.32/0 ; 1.30/0
constant_object 61.14/168 -> 64.04/168 1.12/1056 ; 0.88/1056 ; 1.37/1056 ; 30.29/4808 20.10/1056 -> 25.86/1056 1.06/0 -> 1.08/0 ; 1.03/0
goto 54.61/240 -> 52.58/240 0.77/872 ; 0.77/872 ; 1.15/992 ; 9.24/4944 8.21/872 -> 9.10/992 1.31/0 -> 1.32/0 ; 1.29/0
interned_string 73.73/176 -> 101.56/176 0.72/904 ; 0.74/904 ; 1.06/904 ; 8.83/3944 8.11/904 -> 9.36/904 1.30/0 -> 1.33/0 ; 1.28/0
invoke_delegate 84.31/144 -> 68.51/144 0.97/1064 ; 1.56/1064 ; 1.27/1096 ; 29.51/4824 26.46/1064 -> 23.60/1096 1.60/0 -> 1.59/0 ; 1.77/0
invoke_literal 106.83/280 -> 87.58/280 1.14/992 ; 2.40/992 ; 1.48/992 ; 11.11/6456 12.46/993 -> 11.46/992 1.30/0 -> 1.31/0 ; 1.27/0
invoke_try_argument 143.20/320 -> 121.62/320 1.30/1592 ; 1.45/1592 ; 15.82/1664 ; 12.43/7544 16.37/1592 -> 14.08/1664 1.33/0 -> 1.31/0 ; 1.28/0
loop 87.64/576 -> 103.80/576 1.28/1072 ; 1.36/1072 ; 1.78/1360 ; 18.32/8728 16.38/1072 -> 17.20/1360 3.06/0 -> 2.95/0 ; 2.73/0
nested_blocks128 922.44/6256 -> 901.22/6256 4.54/872 ; 4.14/872 ; 6.30/5960 ; 262.40/9248 10.97/872 -> 12.52/5960 1.31/0 -> 1.31/0 ; 1.30/0
nested_blocks32 248.35/1648 -> 216.46/1648 1.67/872 ; 1.54/872 ; 2.70/2104 ; 25.21/5404 8.41/872 -> 9.63/2104 1.30/0 -> 1.30/0 ; 1.29/0
nested_blocks8 72.52/488 -> 74.36/488 0.91/872 ; 0.87/872 ; 1.10/1128 ; 11.71/4440 8.04/872 -> 7.95/1128 1.31/0 -> 1.31/0 ; 1.30/0
new_array 50.16/160 -> 50.41/160 0.96/1320 ; 0.91/1320 ; 1.18/1320 ; 18.89/4288 18.37/1360 -> 20.39/1360 3.11/40 -> 3.46/40 ; 2.96/40
new_member 121.36/304 -> 132.45/304 1.00/968 ; 0.95/968 ; 1.19/968 ; 17.53/4296 17.71/992 -> 22.82/992 3.62/24 -> 4.26/24 ; 3.72/24
noninterned_string 78.04/272 -> 84.53/272 0.79/904 ; 0.78/904 ; 1.26/1016 ; 8.64/3944 7.93/904 -> 23.30/1016 1.28/0 -> 1.08/0 ; 1.26/0
nullable 88.26/256 -> 85.69/256 1.89/1368 ; 1.10/1368 ; 1.55/1368 ; 18.06/4952 18.39/1368 -> 18.17/1368 1.32/0 -> 1.33/0 ; 1.29/0
quote_capture n/a -> 144.53/208 n/a ; n/a ; 4.12/3675 ; 59.86/6508 n/a -> 71.63/4100 n/a -> 318.29/384 ; 123.60/240
quote_closed n/a -> 164.91/280 n/a ; n/a ; 1.66/1504 ; 26.82/5161 n/a -> 19.04/1504 n/a -> 2.09/0 ; 2.83/0
runtime_variables n/a -> 296.43/736 n/a ; n/a ; 3.57/2916 ; 59.06/5194 n/a -> 39.21/3017 n/a -> 11.54/80 ; 15.03/88
switch 80.41/512 -> 86.26/512 0.93/1144 ; 1.07/1144 ; 1.46/1208 ; 15.69/5728 16.05/1144 -> 19.27/1208 1.07/0 -> 1.08/0 ; 1.10/0
try_catch 66.10/328 -> 65.12/328 1.06/1504 ; 1.00/1504 ; 20.34/1568 ; 17.87/5400 17.12/1504 -> 22.49/1568 1.04/0 -> 0.79/0 ; 0.77/0
try_finally 53.30/184 -> 53.42/184 1.05/1472 ; 0.88/1472 ; 10.64/1536 ; 10.84/5336 9.25/1472 -> 11.96/1536 1.32/0 -> 1.32/0 ; 1.28/0

Reproductions

Fresh Dynamic reduction reproduction (.NET 8/10, Standard FEC)
using System;
using System.Dynamic;
using System.Linq.Expressions;
using FastExpressionCompiler;
class FreshDynamic : Expression {
 public int Reductions;
 readonly Expression argument;
 readonly bool cache; Expression cached;
 public FreshDynamic(Expression a,bool cache=false){argument=a;this.cache=cache;}
 public override Type Type=>typeof(int);
 public override ExpressionType NodeType=>ExpressionType.Extension;
 public override bool CanReduce=>true;
 public override Expression Reduce(){Reductions++;if(cache&&cached!=null)return cached;return cached=Convert(Dynamic(new AddBinder(),typeof(object),argument,Constant(2)),typeof(int));}
}
class AddBinder : BinaryOperationBinder {
 public AddBinder():base(ExpressionType.Add){}
 public override DynamicMetaObject FallbackBinaryOperation(DynamicMetaObject target,DynamicMetaObject arg,DynamicMetaObject errorSuggestion)=>new DynamicMetaObject(Expression.Convert(Expression.Add(Expression.Convert(target.Expression,typeof(int)),Expression.Convert(arg.Expression,typeof(int))),typeof(object)),BindingRestrictions.GetTypeRestriction(target.Expression,typeof(int)).Merge(BindingRestrictions.GetTypeRestriction(arg.Expression,typeof(int))));
}
class Program {
 static void Main(){
  foreach(var flags in new[]{CompilerFlags.Default,CompilerFlags.NoInvocationLambdaInlining}) {
   var x=Expression.Parameter(typeof(int));var extension=new FreshDynamic(x);var tree=Expression.Lambda<Func<int,int>>(extension,x);
   try {var f=tree.CompileFast(true,flags);Console.WriteLine($"FEC {flags}: {(f==null?"null":f(5).ToString())}; reductions={extension.Reductions}");}catch(Exception e){Console.WriteLine($"FEC {flags}: reductions={extension.Reductions}; "+e);}
   Console.WriteLine("System: "+tree.Compile()(5));
  }
  var p=Expression.Parameter(typeof(int));var direct=Expression.Lambda<Func<int,int>>(Expression.Convert(Expression.Dynamic(new AddBinder(),typeof(object),p,Expression.Constant(2)),typeof(int)),p);
  var result=direct.CompileFast(true);Console.WriteLine("Direct Dynamic FEC: "+(result==null?"null":result(5).ToString()));
  var stable=Expression.Lambda<Func<int,int>>(new FreshDynamic(p,true),p).CompileFast(true);
  Console.WriteLine("Cached reduction FEC: "+(stable==null?"null":stable(5).ToString()));
 }
}

Head: NRE at Dynamic metadata lookup, reductions=2, under both flags. System/direct/cached controls return 7. Parent/upstream: null, not an exception.

Inherited #555 integration control (Light)
using System;
using System.Linq.Expressions;
using FastExpressionCompiler.LightExpression;
using E=System.Linq.Expressions.Expression;
class Program {
 static void Main(){
  var p=E.Parameter(typeof(decimal));
  var tree=E.Lambda<Func<decimal,decimal>>(E.AddAssign(p,E.Constant(1m),typeof(decimal).GetMethod("op_Addition",new[]{typeof(decimal),typeof(decimal)}),null),p);
  Console.WriteLine("System: "+tree.Compile()(2m));
  var light=tree.ToLightExpression();Console.WriteLine("FEC direct: "+light.CompileFast(true)(2m));
  try {var roundtrip=(System.Linq.Expressions.Expression<Func<decimal,decimal>>)light.ToExpression();Console.WriteLine("Roundtrip: "+roundtrip.Compile()(2m));}catch(Exception e){Console.WriteLine("Roundtrip: "+e);}
 }
}

Head/parent: System=3, direct FEC=3, round-trip=NRE. Upstream: all three return 3.

Reproducing the measurements

The source harness below is shared across versions. For Standard, compile it against the four production .cs files in src/FastExpressionCompiler; for Light, compile those files with LIGHT_EXPRESSION, plus Expression.cs, ExpressionVisitor.cs, and FlatExpression.cs from the Light project, into a separate library. Compile the harness with LIGHT for that library. Library/harness use Release, Optimize=true, LangVersion=latest and net8.0/net10.0. Do not define DEBUG. The setup/build scripts below export each exact commit and generate the projects; edit repo to point at a clone containing all three commits.

Set DOTNET_TieredCompilation=0 and DOTNET_ReadyToRun=0; run each snapshot harness with arguments upstream|parent|head standard|light 0|1. For System use the head harness with system as the first argument. Run versions in forward then reverse order, sequentially. Save JSON stdout and correctness/error stderr separately. To reproduce the feature-only Light RuntimeVariables controls, use the final harness included here; they are supported at head and rejected during baseline conversion.

Benchmark harness and snapshot build scripts
using System;
using System.Linq;
using System.Collections.Generic;
using System.Diagnostics;
using System.Runtime.CompilerServices;
using System.Text.Json;
#if LIGHT
using FastExpressionCompiler.LightExpression;
using E = FastExpressionCompiler.LightExpression.Expression;
using X = FastExpressionCompiler.LightExpression.Expression;
using L = FastExpressionCompiler.LightExpression.LambdaExpression;
#else
using FastExpressionCompiler;
using E = System.Linq.Expressions.Expression;
using X = System.Linq.Expressions.Expression;
using L = System.Linq.Expressions.LambdaExpression;
#endif
public class Box { public int Value; public Box(int x) { Value=x; } public int Add(int x)=>Value+x; }
public static class Program {
 static object sink; static long checksum;
#if LIGHT
 static L systemSource;
 static System.Linq.Expressions.LambdaExpression systemPrepared;
#endif
 record Scenario(string Name, Func<L> Build);
 static X Balanced(X x,int depth) => depth==0?x:E.Add(Balanced(x,depth-1),Balanced(x,depth-1));
 static L Build(string name) {
  var x=E.Parameter(typeof(int),"x"); X body=x;
  switch(name) {
   case "constant": body=E.Constant(42);break;
   case "add": body=E.Add(x,E.Constant(1));break;
   case "arithmetic64": for(int i=0;i<64;i++)body=E.Add(body,E.Constant(i));break;
   case "balanced256":body=Balanced(x,8);break;
   case "conditional":body=E.Condition(E.LessThan(x,E.Constant(0)),E.Negate(x),x);break;
   case "nullable":body=E.Coalesce(E.Convert(x,typeof(int?)),E.Constant(7));break;
   case "new_member":body=E.Field(E.New(typeof(Box).GetConstructor(new[]{typeof(int)}),x),"Value");break;
   case "constant_object":body=E.Call(E.Constant(new Box(7)),typeof(Box).GetMethod("Add"),x);break;
   case "array":body=E.ArrayIndex(E.Constant(new[]{2,3,5,7}),E.And(x,E.Constant(3)));break;
   case "new_array":body=E.ArrayLength(E.NewArrayInit(typeof(int),x,x,x));break;
   case "interned_string":body=E.Property(E.Constant("fixed literal"),"Length");break;
   case "noninterned_string":body=E.Property(E.Constant(new string('z',37)),"Length");break;
   case "try_catch":body=E.TryCatch(E.Divide(E.Constant(42),x),E.Catch(typeof(DivideByZeroException),E.Constant(-1)));break;
   case "try_finally":body=E.TryFinally(E.Add(x,E.Constant(1)),E.Empty());break;
   case "switch":body=E.Switch(x,E.Constant(8),E.SwitchCase(E.Constant(3),E.Constant(0)),E.SwitchCase(E.Constant(4),E.Constant(1)));break;
   case "goto":{var t=E.Label(typeof(int));body=E.Block(E.Return(t,x),E.Label(t,E.Constant(0)));break;}
   case "loop":{var v=E.Variable(typeof(int));var t=E.Label(typeof(int));body=E.Block(new[]{v},E.Assign(v,E.Constant(0)),E.Loop(E.IfThenElse(E.LessThan(v,E.Constant(8)),E.Block(E.PostIncrementAssign(v),E.Empty()),E.Break(t,v)),t));break;}
   case "blocks32":case "blocks128":{int n=name=="blocks32"?32:128;var arr=new List<X>();for(int i=0;i<n;i++){var v=E.Variable(typeof(int),"v"+i);arr.Add(E.Block(new[]{v},E.Assign(v,x),E.Add(v,E.Constant(i))));}body=E.Block(arr);break;}
   case "invoke_literal":{var p=E.Parameter(typeof(int));body=E.Invoke(E.Lambda<Func<int,int>>(E.Add(p,E.Constant(2)),p),x);break;}
   case "invoke_delegate":body=E.Invoke(E.Constant((Func<int,int>)(v=>v+2)),x);break;
   case "invoke_try_argument":{var p=E.Parameter(typeof(int));body=E.Invoke(E.Lambda<Func<int,int>>(E.Add(p,E.Constant(2)),p),E.TryFinally(x,E.Empty()));break;}
   case "capture1": return E.Lambda<Func<int,Func<int>>>(E.Lambda<Func<int>>(E.Add(x,E.Constant(1))),x);
   case "capture_reference":{var v=E.Variable(typeof(Box),"box");return E.Lambda<Func<int,Func<int>>>(E.Block(new[]{v},E.Assign(v,E.Constant(new Box(7))),E.Lambda<Func<int>>(E.Field(v,"Value"))),x);}
   case "nested_blocks8":case "nested_blocks32":case "nested_blocks128":{int count=int.Parse(name.Substring("nested_blocks".Length));for(int i=0;i<count;i++)body=E.Block(body);break;}
   case "capture8":{var vs=Enumerable.Range(0,8).Select(i=>E.Variable(typeof(int),"v"+i)).ToArray();X sum=x;foreach(var v in vs)sum=E.Add(sum,v);return E.Lambda<Func<int,Func<int>>>(E.Block(vs,vs.Select(v=>(X)E.Assign(v,x)).Concat(new X[]{E.Lambda<Func<int>>(sum)})),x);}
   case "quote_closed":return E.Lambda<Func<int,System.Linq.Expressions.Expression<Func<int>>>>(E.Quote(E.Lambda<Func<int>>(E.Constant(9))),x);
   case "quote_capture":return E.Lambda<Func<int,System.Linq.Expressions.Expression<Func<int>>>>(E.Quote(E.Lambda<Func<int>>(x)),x);
   case "runtime_variables":
#if LIGHT
    var sp=System.Linq.Expressions.Expression.Parameter(typeof(int));
    return System.Linq.Expressions.Expression.Lambda<Func<int,IRuntimeVariables>>(System.Linq.Expressions.Expression.RuntimeVariables(sp),sp).ToLightExpression();
#else
    return E.Lambda<Func<int,IRuntimeVariables>>(E.RuntimeVariables(x),x);
#endif
   default:throw new Exception(name);
  }
  return E.Lambda<Func<int,int>>(body,x);
 }
 static Delegate Compile(L tree,bool system) {
  if(system) {
#if LIGHT
   if(!ReferenceEquals(systemSource,tree)){systemSource=tree;systemPrepared=(System.Linq.Expressions.LambdaExpression)tree.ToExpression();}
   return systemPrepared.Compile();
#else
   return tree.Compile();
#endif
  }
  return tree.CompileFast(true)??throw new NotSupportedException("strict FEC returned null");
 }
 static string Verify(Delegate d) {
  var parts=new List<string>();
  foreach(int v in new[]{-2,0,3}) {
   var result=d.DynamicInvoke(v);
   if(result is Func<int> nested) result=nested();
   if(result is System.Linq.Expressions.Expression<Func<int>> quote) result=quote.Compile()();
   if(result is IRuntimeVariables vars) {var initial=vars[0];vars[0]=17;result=initial+":"+vars[0];}
   parts.Add(result?.ToString()??"null");
  }
  return string.Join("|",parts);
 }
 static Action<int> Driver(Delegate d) {
  if(d is Func<int,int> f)return n=>{long s=0;for(int i=0;i<n;i++)s+=f(3);checksum=s;};
  if(d is Func<int,Func<int>> f2)return n=>{long s=0;for(int i=0;i<n;i++){var v=f2(3);sink=v;s+=v();}checksum=s;};
  if(d is Func<int,System.Linq.Expressions.Expression<Func<int>>> fq)return n=>{for(int i=0;i<n;i++)sink=fq(3);};
  if(d is Func<int,IRuntimeVariables> fr)return n=>{for(int i=0;i<n;i++)sink=fr(3);};
  throw new Exception("driver "+d.GetType());
 }
 record Sample(string Version,string Runtime,string Flavor,string Scenario,string Phase,int Round,int SampleIndex,int Operations,double NsPerOp,double BytesPerOp,int Gen0,int Gen1,int Gen2);
 static void Measure(string version,string flavor,string scenario,string phase,int round,Action<int> op) {
  op(30);
  int n=phase=="invoke"?10000:20;
  var sw=Stopwatch.StartNew();op(n);sw.Stop();
  n=(int)Math.Clamp(n*8.0/Math.Max(sw.Elapsed.TotalMilliseconds,0.01),phase=="invoke"?1000:10,phase=="invoke"?1000000:2000);
  for(int k=0;k<7;k++) {
   GC.Collect();GC.WaitForPendingFinalizers();GC.Collect();
   long allocated=GC.GetAllocatedBytesForCurrentThread();int g0=GC.CollectionCount(0),g1=GC.CollectionCount(1),g2=GC.CollectionCount(2);
   long start=Stopwatch.GetTimestamp();op(n);long end=Stopwatch.GetTimestamp();
   long bytes=GC.GetAllocatedBytesForCurrentThread()-allocated;
   Console.WriteLine(JsonSerializer.Serialize(new Sample(version,Environment.Version.ToString(),flavor,scenario,phase,round,k,n,(end-start)*1e9/Stopwatch.Frequency/n,bytes/(double)n,GC.CollectionCount(0)-g0,GC.CollectionCount(1)-g1,GC.CollectionCount(2)-g2)));
  }
 }
 public static void Main(string[] args) {
  string version=args[0],flavor=args[1];int round=int.Parse(args[2]);bool system=version=="system";
  string[] names={"constant","add","arithmetic64","balanced256","conditional","nullable","new_member","constant_object","array","new_array","interned_string","noninterned_string","try_catch","try_finally","switch","goto","loop","blocks32","blocks128","invoke_literal","invoke_delegate","invoke_try_argument","capture1","capture8","quote_closed","quote_capture","runtime_variables"};
  names=names.Concat(new[]{"capture_reference","nested_blocks8","nested_blocks32","nested_blocks128"}).ToArray();
  if(args.Length>3)names=names.Where(n=>args.Skip(3).Contains(n)).ToArray();
  foreach(string name in names) {
   try {
    var tree=Build(name);var oracle=Compile(tree,true);var d=Compile(tree,system);var expected=Verify(oracle);var actual=Verify(d);
    if(expected!=actual)throw new Exception("behavior mismatch "+actual+" expected "+expected);
    Console.Error.WriteLine("PASS "+version+" "+flavor+" "+name+" "+actual);
    Action<int> build=n=>{for(int i=0;i<n;i++)sink=Build(name);};
    Action<int> compile=n=>{for(int i=0;i<n;i++)sink=Compile(tree,system);};
    Action<int> first=n=>{for(int i=0;i<n;i++){var c=Compile(tree,system);if(c is Func<int,int> f)checksum=f(3);else if(c is Func<int,Func<int>> ff){var nested=ff(3);sink=nested;checksum=nested();}else if(c is Func<int,IRuntimeVariables> fr)sink=fr(3);else sink=((Func<int,System.Linq.Expressions.Expression<Func<int>>>)c)(3);}};
    Measure(version,flavor,name,"construct",round,build);
    Measure(version,flavor,name,"compile",round,compile);
    Measure(version,flavor,name,"compile_first",round,first);
    Measure(version,flavor,name,"invoke",round,Driver(d));
   } catch(Exception e) {Console.Error.WriteLine("FAIL "+version+" "+flavor+" "+name+" "+e);}
  }
  GC.KeepAlive(sink);GC.KeepAlive(checksum);
 }
}

setup.py:

from pathlib import Path
import subprocess,json
root=Path(__file__).resolve().parent
repo=Path(r'C:\Data\Git\Github\FastExpressionCompiler')
commits={'upstream':'ff907e1986700a30f05a56e5cee28e952faee363','parent':'e4c232da108d98790f0ca73f23ec90756da0cad7','head':'b8f8b38bc44367f129b49a55b2b2043e81b04ccb'}
for name,commit in commits.items():
    for kind in ('standard','light'):
        dest=root/name/kind;dest.mkdir(parents=True,exist_ok=True)
        paths=['src/FastExpressionCompiler/'+f for f in ['FastExpressionCompiler.cs','ImTools.cs','ILReader.cs','TestTools.cs']]
        if kind=='light': paths+=['src/FastExpressionCompiler.LightExpression/'+f for f in ['Expression.cs','ExpressionVisitor.cs','FlatExpression.cs']]
        for p in paths:
            (dest/Path(p).name).write_bytes(subprocess.check_output(['git','show',commit+':'+p],cwd=repo))
        defines='<DefineConstants>LIGHT_EXPRESSION</DefineConstants>' if kind=='light' else ''
        (dest/'Library.csproj').write_text('<Project Sdk="Microsoft.NET.Sdk"><PropertyGroup><TargetFrameworks>net8.0;net10.0</TargetFrameworks><LangVersion>latest</LangVersion><AssemblyName>FecSnapshot.'+kind+'</AssemblyName><Optimize>true</Optimize>'+defines+'</PropertyGroup></Project>')
(root/'commits.json').write_text(json.dumps(commits,indent=2))

build.py:

from pathlib import Path
import subprocess
root=Path(__file__).resolve().parent
for version in ('upstream','parent','head'):
 for flavor in ('standard','light'):
  dest=root/version/flavor
  (dest/'Harness.csproj').write_text('<Project Sdk="Microsoft.NET.Sdk"><PropertyGroup><OutputType>Exe</OutputType><TargetFrameworks>net8.0;net10.0</TargetFrameworks><LangVersion>latest</LangVersion><EnableDefaultCompileItems>false</EnableDefaultCompileItems><Optimize>true</Optimize>'+('<DefineConstants>LIGHT</DefineConstants>' if flavor=='light' else '')+'</PropertyGroup><ItemGroup><Compile Include="../../Program.cs"/><ProjectReference Include="Library.csproj"/></ItemGroup></Project>')
  # Exclude harness from library default items (harness source is outside project).
  with (dest/'build.log').open('w') as log:
   p=subprocess.run(['dotnet','build',str(dest/'Harness.csproj'),'-c','Release','--nologo','-v:q'],stdout=log,stderr=subprocess.STDOUT)
  print(version,flavor,p.returncode,flush=True)
  if p.returncode: print((dest/'build.log').read_text());raise SystemExit(1)

No production fixes, new commits, pushes, or changes to #557/#559 were made as part of this review. The PR remains a draft. Suggested next steps: address the Dynamic reduction failure, remove duplicated Invoke reflection, reduce capture metadata/reflection allocations, and eliminate label-free scope-stack heap growth; then rerun the same paired measurements and correctness controls. Shared-cell semantics should be preserved throughout.

@jogibear9988

Copy link
Copy Markdown
Contributor Author

Follow-up: regression repairs and measured results

Pushed 46 logical source/test repair commits plus the evidence commit, through 6487c9b2380d24709ddf2b31361374773c6b3ecf. The measured source is 6676ab49; controls are the pre-PR parent e4c232da, reviewed head b8f8b38b, and System.Linq.Expressions. #557 and #559 are unchanged, and this PR remains a draft.

@dadhi, the allocation concern was valid. The worst common-path allocation regressions are repaired, but before-PR performance parity is still not achieved.

What improved

Representative Standard/.NET 10 managed B/op from the latest broad run (rounded):

Scenario / phase Pre-PR Reviewed PR Repaired
One integer capture / compile 3,000 6,526 2,808
Eight-local capture / compile 5,040 38,014 4,720
Immutable reference capture / activation 160 184 160
128 terminal variable-free blocks / compile 872 5,960 872
Delegate Invoke / compile 1,064 1,096 1,064

The mechanisms are per-compilation metadata/signature reuse, conservative capture storage selection, lazy quote/runtime-variable metadata, and bounded/compressed scope buffers. There is no cross-compilation user-type cache or outer System compiler fallback. The direct emitter and object[] closure layout are preserved. Quote/runtime-variable exposure and shared mutation retain the required live storage.

The final compact scope pool saves 8,104 B in the deep-scope cold causal control; measured warm/reentrant diagnostics show no new scope-buffer misses. Deferring unrelated quote metadata removes the 880 B (.NET 8) / 888 B (.NET 10) common cold overhead in the causal control. Light private array capture is 32 B below pre-PR on both runtimes.

The independently reproduced fresh-extension-reduction + Dynamic failure is fixed by preserving collected reductions; regression coverage also includes Quote composition. Framework readonly reference loads now carry the required types in verified anonymous methods. The previous capture_try verification failure is fixed in both flavors and both benchmark rounds.

What is still worse

Standard, 41 common shapes, 101 alternating pairs .NET 8 .NET 10
Compile allocations: lower / same / higher 14 / 27 / 0 14 / 27 / 0
Median per-shape compile-time ratio 1.1144x 1.0982x
Median per-shape compile + first-invocation ratio 0.9970x 1.0135x

Ratios above one mean slower. Compile time is still about 11.4% / 9.8% worse by this descriptive median. These medians do not promise parity in each scenario; the report lists every timing residual, including runtime phases.

  • Cold allocations: deep mixed scopes remain +10,328 B; try/catch and try/finally remain +1,248 B on .NET 8 / +1,224 B on .NET 10, for compile + first invocation. These are the three allocation increases among ten cold shapes per runtime.
  • Cold time: observed median scenario compile + first ratios are 1.485x (.NET 8) / 1.569x (.NET 10). This includes JIT/initialization and host variability; all nine fresh-process repetitions and allocation/time ranges are retained. This is not a paired causal estimate.
  • Framework capture compilation: mutable capture +896 B, eight-local capture approximately +289-291 B, reference/call capture +144 B, capture with try/finally +184 B, array capture +48 B. The implementation keeps typed cells where needed for Framework verification.
  • Framework EH hosting: a same-assembly constructor control isolates +16 B for the restricted anonymous host's captured security context. This is separate from, and much smaller than, the remaining capture costs. No security-policy workaround was added.

No total native/JIT or retained-memory parity claim is made. Matching finalized managed IL in a capture control also does not prove identical native code or invalidate an observed timing residual. The known deep-tree crash remains deferred as requested.

Validation and reproduction

  • 35,064 imported tests pass, zero failures/crashes; existing 69 exclusions unchanged.
  • Native .NET 10 1,712, .NET 8 1,711; Framework 1,595 legacy + 1,001 TestX, all passing.
  • All configured production targets build. Strict final measurement analysis: zero errors, no missing samples.
  • Full modern matrix: 47 shapes, both flavors/runtimes, before/reviewed/repaired/System, two opposite-order process rounds with seven samples per phase. Construction, compilation, compile + first execution, warmed execution, and capture activation/nested execution are separated.
  • Cold: 540 fresh processes, nine repetitions per shape/version/runtime. Framework: 188 fresh repaired processes; before/System controls are retained from the earlier run, explicitly identified, so its timing ratios are descriptive rather than paired causal estimates.
  • Windows ARM64, Release, .NET 8.0.28 / 10.0.9, Framework 4.8.1 targeting net472; tiering/ReadyToRun disabled. The virtualized shared host has observable timing variability. Only managed allocation counters are measured.

Evidence directory · Decisions and limitations · Exact remaining costs · Validation · Repair commits.

The directory contains the interactive report, complete phase CSV/JSON (including System), raw latest/historical archives, reproduction scripts, source/assembly hashes, and causal controls. Historical failures and rejected experiments remain preserved separately. The PR description has been updated with the same results and limitations. This remains a draft with unresolved performance regressions.

@jogibear9988

Copy link
Copy Markdown
Contributor Author

Further allocation/compilation repairs

@dadhi, pushed 7 additional logical fixes and their full measurement/validation evidence. Measured source: db640cba760028d1c49ad5e4abf67f38fe683bbf; evidence head: 42f9847843cec8edaaf11c00cb97a5dd576b82cf. #557/#559 unchanged; this remains a draft.

Framework compilation, B/op (200 operations/sample) Pre-PR Last published #558 Current
Mutable integer capture 4,376.00 5,272.00 4,520.00
Readonly reference capture 4,264.00 4,408.00 4,256.00
Eight-local capture 7,544.80 7,832.80 7,536.80
Reference capture with try/finally 4,912.00 5,096.00 5,096.00

The five Framework stages separately reduce constructor reflection (-192 B in the mutable control), reuse typed rented signatures (-64/-120 B), restore owned boxed cells (-320 B), and omit proven redundant delegate/closure casts (-32 B and -120 to -264 B). Runtime allocations do not increase in these causal controls. Compared with pre-PR, mutable nested invocation improves 24 B -> 0 B (factory plus invocation 208 B -> 184 B), while compilation still costs more.

The other repairs avoid unused validator JIT and bypass the large collector for eligible binary expressions without a second pass. Full per-commit observations and cold controls are included; no uniformly faster CPU claim is made.

Common shapes vs pre-PR, descriptive median ratios Compile Compile + first Factory/scalar call
Standard .NET 8 1.2243x 1.1988x 1.0103x
Light .NET 8 1.5483x 1.3258x 1.0289x
Standard .NET 10 1.2639x 1.1669x 1.0050x
Light .NET 10 1.2781x 1.1347x 1.0159x
Standard Framework 1.8854x 1.5225x 1.1438x
Light Framework 1.3230x 1.1041x 1.0126x

Measured compilation medians remain above pre-PR. These samples do not establish CPU parity; every scenario and phase remains in the full results.

Allocation lower/same/higher counts for 41 common Standard shapes: .NET 8 14/27/0; .NET 10 14/27/0. The full table includes every runtime/activation phase, both libraries, Framework and System controls.

Small fractional allocation differences in the adaptive runs did not reproduce with equal operation counts: none of 100 targeted current-versus-published phase comparisons allocate more (52 modern, 48 Framework). Empty observer windows allocate 0 bytes; no counter overhead is subtracted. The original results and modern/Framework confirmations are both retained. The allocation table above uses these equal-count controls.

Still open: deep mixed128 cold scope allocation cost, small-EH cold initialization costs, protected/typed Framework costs and the timing residuals listed in the report. Shared-VM timings have substantial variation; total native/JIT/retained memory was not measured. Universal pre-PR parity is not established. Rejected experiments are retained, including wrapper cleanup with a consistently higher sampled cold allocation mode.

Validation: 35,064 imported passes, 0 failures/crashes among executed cases, existing 69 exclusions unchanged; full native .NET 8/.NET 10 and Framework suites plus all configured production builds pass. Fresh 47-shape matrices cover before/published/current/System on Standard/Light modern runtimes and Framework. Each phase has two opposite-order process rounds and 7 samples; causal microexperiments retain separate raw/reversed/cold observations.

Report and complete evidence | Per-commit measurements | Exact remaining costs | Validation | Commits | Decisions.

// Framework prohibits using the anonymous module with the module overload.
return new DynamicMethod(string.Empty, returnType, parameterTypes, restrictedSkipVisibility: true);
#else
// Preserve the anonymous module's non-Exception catch policy, without the

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jogibear9988 The hell is that hack? :)
Anyway, I am already feeling the boost in my knowledge of .NET internals.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From where should I know?
I now wanted to let AI finish this so we get nearly the same perf. and know it can work. Then we can use this as a base when you want to do it error by error.

@jogibear9988 jogibear9988 Oct 19, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes — this is a runtime-specific workaround, and your objection is fair. Looking more closely at the runtime source, I should remove this version.

The reason for changing the host is observable exception behavior: the host assembly's WrapNonExceptionThrows policy affects matching of raw CLR throws (e.g. a string) and RuntimeWrappedException. FEC's ordinary owner-hosted method and System's anonymous method do not have the same policy. The regression tests exercise raw/wrapped payload identity, catch matching, filters/rethrow, and private-member access; simply returning to the ordinary host for these methods would undo that behavior fix.

The workaround tries to get both properties: reuse the anonymous module for its exception policy, but use the module constructor to avoid the restricted anonymous constructor's eager JIT in CreateDelegate. However, .NET's initialization code explicitly asserts that this anonymous module must not be passed by callers. It works in the tested .NET 8/10 release runtimes, but that is not a supported contract. Framework already rejects this combination, hence the conditional fallback.

I removed the module-reuse workaround in 70f297cb and now use the supported anonymous DynamicMethod(..., restrictedSkipVisibility: true) constructor for exception-aware methods, retaining ordinary owner hosting elsewhere. .NET explicitly compiles that restricted method in CreateDelegate, so the measurements report compile-only, compile + first execution, steady-state calls, and allocations separately. Moving JIT cost into compilation must not be described as an equivalent increase in total first-use cost.

The correction, validation and measurements are pushed, and the description is updated. Complete results and remaining regressions. The PR remains a draft: universal pre-PR performance/allocation parity is not established.

@dadhi dadhi left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here is my random findings.

@@ -645,6 +779,184 @@ public struct CompilerContext
internal SmallMap4<Expression, NoValue, RefEq<Expression>> ArgsContainingComplexExpression;

internal bool HasComplexExpression;
internal bool HasExceptionBlock;

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

combine those and follow up bools into the bit flags

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tried packing the nine internal booleans into a ushort flag field, keeping the public LastEmitIsAddress field and existing ClosureStatus unchanged. It saves 8 bytes in CompilerContext on every tested target. I also tested a compact single-load setter form: each setter's IL shrinks by 5 bytes, with the same representation and semantics.

The correctness checks pass, but I have held both versions under the requirement not to introduce performance regressions. In the bounded matched tests, the compact version's Framework Light capture8 compilation increased from 6896.625 to 7088.625 B, and compile+first from 7304 to 7496 B: +192 B in each phase. The exact allocation mechanism is unresolved. Some compilation controls also slowed, despite a small improvement in the median across scenarios. The smaller context alone is therefore not enough to claim this change is an improvement. The original sources, both candidates, raw samples, IL evidence and limitations are preserved in the report; the production flags remain unchanged.

Measurements, held patches, raw samples and decisions.

// Frames follow the existing collector traversal; persistent ancestry is allocated
// only for expressions that actually contain a label or a jump.
internal SmallList<LabelScopeFrame, Stack4<LabelScopeFrame>, ScopeArrayPool<LabelScopeFrame>> LabelScopes;
internal SmallList<(LabelTarget Target, int Scope), Stack4<(LabelTarget Target, int Scope)>, NoArrayPool<(LabelTarget Target, int Scope)>> ScopedLabels;

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need whole int for the Scope?
Maybe for the compactness and usability we can put Target and Scope into struct with strict layout?
(same for the Target Region pair above)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I checked the actual layout: the existing target+int tuples occupy 16 bytes on all six tested Standard/Light runtime configurations. Named sequential structs keep that reference-alignment footprint, so replacing the tuples alone does not produce the proposed saving.

Scope and Region are ancestry indices with no established ushort bound; the 1024 pool-retention limit does not limit valid input size. I kept full-width IDs and the existing named tuple fields rather than adding an input limit or an unaligned-reference layout. The separate one-word region-frame experiment is reported below, including its costs.

Measurements, held patches, raw samples and decisions.

return;
}
}
PushNewLabelScopeFrame(kind);

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

wut? so here you specifically prevent inlining?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, deliberately. PushLabelScope is aggressively inlined: repeated adjacent scope kinds update the last frame and return. The separate helper keeps the append/list-growth path outlined, instead of pulling that path into the common wrapper. This is a performance choice, not required for correctness or added just for measuring.

I tested removing only NoInlining against 8f, leaving the automatic JIT policy to decide. All 252 flag/version/shape checks passed, but the bounded matched benchmark did not establish a consistent win: 42 compilation comparisons had a median paired ratio 1.015, with controls ranging 0.921–1.149. For example Light .NET 8 label/goto compilation was 14.9% slower and Light .NET 10 balanced compilation 9.5% slower in that run. These are observations, not proof that this attribute is universally optimal, and unchanged generated-delegate runtime differences should not be attributed to the compiler helper. I retained the outline and added a source comment explaining it in ed431c7. The raw samples and limitations are in the report.

Measurements, held patches, raw samples and decisions.

Comment thread src/FastExpressionCompiler/FastExpressionCompiler.cs
Comment thread src/FastExpressionCompiler/FastExpressionCompiler.cs Outdated
internal int Region;
}

// These scalar scope lists grow their heap part as 4, 9, 19, 39, ... .

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why logarithmic? Why it helps?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The logarithm reduces the pool's bookkeeping array, not the size of the rented buffers. These lists grow their heap storage as 4, 9, 19, 39, ... (2*n+1), so each normal growth length has a different floor(log2(length)) bucket. The 1024-element retention limit therefore needs 11 array-reference slots, instead of a table indexed by every possible length (1024 or 1025 slots). This avoids allocating about 8 KB of bookkeeping at the first overflow on 64-bit runtimes.

Different arbitrary lengths can still share a bucket. That can evict a reusable buffer, but cannot give the caller the wrong size: exact rents check equality, minimum rents check the actual length and search larger buckets, and a miss allocates the requested length. Interlocked.Exchange transfers ownership; the 1024 retention bound is unchanged. I clarified this in the source comment in ed431c7; the pool policy is unchanged.

Measurements, held patches, raw samples and decisions.

internal SmallList<LightAndSysExpr> Converted;
}
#endif
private class QuoteVariableScope : System.Linq.Expressions.ExpressionVisitor

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Those are visitors, each for the specific case, but there is already manual visiting traversal in CollectAndValidate. Can we move this code there?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some shared traversal is possible, but these visitors have different stages and semantics. QuoteBinder runs at execution time with the current activation's live capture cells, which CollectAndValidate does not have. Quoted syntax is returned data: executable collection would prune branches, lower Dynamic expressions, collect nested lambdas, or reduce opaque extensions that must be preserved/rejected without callbacks. The existing lexical scope handling is already shared between quote analysis and binding.

ArgumentSpillScope also deliberately sees Try/Goto in constant-pruned branches. That controls whether runtime-expression compilation and Dynamic target reads happen before or after argument effects/jumps; the ordinary collector prunes those branches. Re-running collection would also risk duplicate Dynamic reductions and extension callbacks. I added boundary tests for opaque quoted extensions and pruned Try/Goto argument ordering in 35622f3, comparing the latter with System.

I measured three bounded cleanups rather than merging the traversals blindly: a lazy free-variable list (32/40 B less per captured-quote activation), a separate compile-only quote collector (40/48 B less), and a shared lazy argument scanner (24 B less terminal-argument compilation). They pass the correctness controls, but the quote revisions slowed compile+first-use, and argument timings were mixed. I held these changes to respect the no-regression requirement. The report retains their sources, benefits, costs and limitations; no visitor change is included in this update.

Measurements, held patches, raw samples and decisions.

@jogibear9988

Copy link
Copy Markdown
Contributor Author

@dadhi, the anonymous-module workaround is removed in 70f297cb, following your review. Exception-aware methods now use the supported restricted anonymous constructor; ordinary methods keep FEC's existing owner host. This remains a draft: universal pre-PR performance/allocation parity is not established.

Four logical commits:

  • 44ee17d7: remove unnecessary arithmetic spills under EH using the existing atomic-right check. No additional traversal/cache; nullable lifting, ordering and Light callbacks remain covered.
  • 6d397bc6: materialize Try results correctly before taking struct receiver addresses; preserve genuine byref results.
  • 3be541d3: fix the preexisting SmallMap16 promotion hang, with collision/growth/reference-stability tests. The interrupted earlier validation was this unchanged helper bug.
  • 70f297cb: remove anonymous-module reuse and use the supported constructor.

Validation: 35,064 imported passes, zero failures/crashes among executed cases; all production/native Standard/Light builds and suites pass. The existing 69 exclusions, including deferred deep-tree modes, are unchanged.

The final matrix compares the last pushed 42f98478 with production source 70f297cb and System: 9 shapes × 6 configurations, 12,474 samples / 198 phase groups. There are 21 rotated samples per compiler/phase. Compile-only uses independently calibrated counts; other phases share FEC counts, with System calibrated separately. All samples are retained, including 109 below 20 ms. These are machine-specific observations, not application-wide speedups.

.NET 10 Standard operation Final System Final/System
Addition: compile 2.50 us 30.99 us 0.081x
Addition: call 3.14 ns 2.93 ns 1.072x
One capture: create delegate 16.41 ns 168.72 ns 0.097x
One capture: call existing delegate 2.05 ns 1.02 ns 2.002x
Eight-local capture: call existing delegate 5.97 ns 8.43 ns 0.709x
Mutable capture: call existing delegate 2.36 ns 1.05 ns 2.237x
Decimal chain/EH: compile + first call 997.59 us 1007.09 us 0.991x
Decimal chain/EH: compile only 1025.31 us 1034.40 us 0.991x

capture8 contains eight block locals plus the outer parameter. Ratios above 1 mean slower. FEC compiles faster than System in 52/54 selected shape/configurations; compile + first call is faster in 47/54. Delegate-call performance is mixed.

The decimal-chain first-use cost improves 14–35% versus the last pushed revision across the six configurations. Warm compilation removes 5,832 B on modern .NET / 14,048 B on Framework. However, modern EH compile-only now includes eager JIT: the .NET 10 chain's compile-only time is 57.3x its previously deferred-JIT baseline, while compile + first is 0.703x that baseline. The phase boundary matters.

Identical emitted IL alone does not establish identical call performance. The try/catch and real-finally controls have matching canonical instructions/locals/EH but different host flags; .NET 10 Standard try/catch call time changes approximately 2.29 -> 1.87 ns. This does not prove a particular native-entry mechanism. Separate same-compiler A/A and native-code diagnostics are included; they are not used to erase observed costs.

Remaining costs, retained in full:

  • All 90 runtime/activation allocation medians are unchanged versus 42f. One-capture creation still allocates 184 B versus System's 152 B; this is distinct from a zero-allocation child call.
  • Primary compilation/first-use medians: 28 higher, 139 equal, 31 lower. Separate equal-count confirmation (5 samples × 200 operations, exact frozen libraries; 2,970 rows) gives 8 higher / 164 equal / 26 lower. Six Framework Light capture phases retain approximately +192 B/compile after prior EH history; two Framework Standard phases show +0.8 B medians. None of the original 22 tiny positive median differences remain positive in that separate check. Both datasets remain; the exact allocator is unresolved.
  • Timing costs remain visible, e.g. Standard .NET 10 capture8 child calls are 1.082x the last pushed median. The map check has equal allocation medians but a worst SmallMap8 timing ratio of 1.112x; the map repair is not universally cost-free.
  • Historical pre-PR regressions and cold allocation modes remain separately documented. There is no final-70f fresh-process cold matrix, and total native/JIT/retained memory was not measured.

The restore-guard experiment was rejected and empty-finally remains held. The unsupported supplied-ref-operator and preexisting pending-operand/outward-goto families remain documented; no new imported exclusions were added. Earlier pre-PR measurements retain their original source identities rather than being relabeled as final results.

Report, every phase comparison, allocation confirmation, reproduction sources/hashes, and raw validation.

@jogibear9988

Copy link
Copy Markdown
Contributor Author

Reviewed all seven comments in review 5577873439 and replied individually with the decisions and measurements.

Pushed three commits: ed431c7f explains scope-pool buckets and intentional outlining; 35622f32 adds quote-callback and pruned-argument ordering tests; 187b9b8f records the experiments and validation.

The proposed packing/visitor changes remain held. They have real memory benefits, but the measured costs prevent claiming an improvement under the requested performance constraint:

Experiment Benefit Observed cost / decision
Packed flags Context −8 B V2 Framework Light capture compile/first +192 B, time about +19%/+15%; hold both versions.
Region sign bit Frame 8→4 B, context −16 B, typically −48 B cold allocation Mixed compile/first timings; retain raw .NET 10 Light cold allocation mode; hold.
Remove NoInlining No consistent win Label/goto compile up to +14.9% in selected controls; retain outline.
Quote storage/split Activation −32/40 B (V1), −40/48 B (V2), modern/Framework First-use slowdowns remain, including 15.7–25.0% in five V2 configurations; hold.
Shared lazy argument scan Terminal compile/first −24 B Mixed timing, including slower complex-argument controls; hold.

Full validation: 35,064 imported cases pass, zero executed failures or crashes, 69 existing exclusions unchanged. Both production builds and .NET 8/10 native suites pass; Framework passes 1,041 TestX and 1,595 legacy tests. No emitter or production behavior change is included in this follow-up.

Report, exact sources, all 13,596 warm observations, 60 cold allocation observations, hashes and validation logs. Compilation, first invocation, activation and nested invocation are separate; these are bounded managed-allocation/time measurements, not a claim of universal CPU parity. Review threads are left for the maintainer to resolve. This PR stays draft; #557 and #559 were not changed.

@jogibear9988
jogibear9988 force-pushed the fix-runtime-expression-compatibility branch from 187b9b8 to ae4d9a5 Compare October 19, 2026 22:37
@jogibear9988

Copy link
Copy Markdown
Contributor Author

Rebased #558 onto current dadhi/master 3aefb5e5, including the merged #557, and pushed with an explicit lease against the previous PR head.

The conflict resolutions preserve upstream's map-to-set cleanup and the existing compatibility fixes. Both expression membership trackers now use SmallSet4; the renamed map helper is reflected in Issue476. The duplicate upstream Light conversion patch was dropped. The source comparison confirms the core compiler differs from the former PR head only by the intended set type/member substitutions.

Fresh validation at 5ce561748e4eaf53f63242e50050bdf8429a518b:

  • 35,064 imported cases pass, zero executed failures or crashes; 69 existing exclusions unchanged.
  • Both production builds and .NET 8/10 native suites pass, including 1,714/1,713 legacy cases.
  • Framework: 1,041 TestX + 1,597 legacy cases pass.

Rebase mapping, integration diff, hashes and full validation logs. Earlier performance data retains its original source hashes; no new timing or performance-parity claim is made for this base. Draft status is retained, and #559 was not changed.

@jogibear9988

Copy link
Copy Markdown
Contributor Author

Merged current dadhi/master 5473b427 (including merged #562 and your SmallMap update c00fe0ac) into this PR, then applied the related operand-classification improvement to #558's shared emitter.

  • 700053a1: merge master. Keep upstream ImTools.cs exactly; retain Fix expression compiler compatibility #558's method-aware comparison/arithmetic path and reference-comparison behavior, rather than introducing a duplicate standalone Fix non-Boolean comparison operator results (#561) #562 emitter. Include Fix non-Boolean comparison operator results (#561) #562's fixture and runner registration.
  • 884ed766: reuse IsParamOrConstantOrDefault in IsAtomicArithmeticOperand, recognizing ordinary conversions. Explicitly stop at intrinsic emitters anywhere in a LightExpression conversion chain. Added converted parameter/constant/default cases inside try/finally, for both arithmetic and selected comparison methods; extended the intrinsic conversion regression to arithmetic as well.
  • be03023f: validation and measurement evidence.

Validation at 884ed766: both production projects build; full .NET 10 ARM64 and .NET 8 ARM64 native suites pass (1,714 / 1,713 legacy cases plus registered regressions); Framework passes 1,041 TestX and 1,597 legacy cases. All 21 imported batches pass on their first attempts: 35,064 passes, zero executed failures/crashes, the same 69 exclusions. Targeted #561 methods: 11 Standard / 12 Light pass across .NET 8/10 and ARM64/x64. Deferred deep-tree cases remain deferred.

Narrow before/after measurements: merged source 700053a1 versus 884ed766, Windows ARM64 .NET 8/10, Standard/Light, System controls. Three process rounds with reversed order, 21 samples per phase/configuration; prebuilt trees, warmed compilation/delegate creation and warmed execution measured separately. For converted EH operands, emitted IL drops two temporary locals and four store/load instructions. Converted-addition compilation allocates 8 B less in all four configurations; selected-method and scalar allocations are unchanged. Delegate invocation allocates 0 B/op throughout.

Timing medians are mixed: converted-shape compilation ranges from 8.9% faster to 2.8% slower. Slower cases are Light net8 addition (+2.8%) and Light net10 selected method (+2.7%). Runtime medians range from about 0.2% faster to 2.5% slower, with the largest absolute increase about 0.036 ns. These small synthetic measurements do not establish universal speedup or parity; cold/first-use, tree construction, Framework/x64 timing and application workloads were not remeasured. Historical reports remain tied to their original revisions.

Exact results, all raw samples, hashes, emitted IL, scripts and full validation logs. This remains draft. #559 is unchanged.

Comment thread src/FastExpressionCompiler.LightExpression/Expression.cs Outdated
@jogibear9988

Copy link
Copy Markdown
Contributor Author

Follow-up merge is pushed: 7949ee5d integrates dadhi/master 233ed5ad; aaa79fe7 records its validation. The conflict retains #558's shared comparison/arithmetic path and reference-comparison handling, and adopts upstream's conversion-helper updates.

Fresh checks pass: 35,064 imported cases, zero executed failures/crashes, 69 unchanged exclusions; both production builds and full .NET 8/10 ARM64 / Framework native suites pass. Exact counters, hashes and full logs. The runtime-value source clarification and review reply are included in f089ff5b. No new performance claim for this merge; the PR remains draft.

dadhi added a commit that referenced this pull request Oct 24, 2026
…sTrue, Quote; opti for Invocation(Lambda(..))
dadhi added a commit that referenced this pull request Oct 24, 2026
dadhi added a commit that referenced this pull request Oct 24, 2026
dadhi added a commit that referenced this pull request Oct 24, 2026
dadhi added a commit that referenced this pull request Oct 24, 2026
@jogibear9988
jogibear9988 force-pushed the fix-runtime-expression-compatibility branch from aaa79fe to dd98968 Compare October 25, 2026 10:42
@jogibear9988 jogibear9988 changed the title Fix expression compiler compatibility (depends on #557) Fix expression compiler compatibility Oct 25, 2026
jogibear9988 added a commit to jogibear9988/FastExpressionCompiler that referenced this pull request Oct 25, 2026
Freeze master f49acef, locally merged dadhi#558 at 593c72e2, and optimized dadhi#559 source ef577910. Record 44 workloads, Standard/Light, .NET 8/10, construction/compilation/warm invocation plus a separate compilation-and-first-invocation pass. Retain 34496 main/reference samples, per-commit allocation/timing evidence, actual root and nested IL strings, source/binary hashes, rejected candidates and validation logs.

Compile-only medians improve 0.7-2.2% versus dadhi#558 but remain 8.2-9.9% above master. Compilation plus first invocation remains 0.1-1.5% above master on median workload ratios. The apparent nested-exception compile-only outlier shifts JIT work into CreateDelegate for the correctness-required anonymous host; the end-to-end pass accounts for it. Keep slower System comparison cases and unchanged-code allocation controls explicit.

Correct the historical IL claim: its serializer compared StringBuilder metadata. The new actual-instruction check matches all 176 root/nested bundles between dadhi#558 and dadhi#559. Production and native suites, Framework 1051 TestX plus 1601 legacy tests, and all 35064 executed imported tests pass. Existing exclusions unchanged; production source unchanged by this evidence commit.
Replace assembly identity trust with exact built-in intrinsic types. Sealed constructor nodes and exact conversion types expose their operands; arbitrary derived user intrinsics remain conservative even when FEC sources are embedded into the user assembly. Reject Extension before structural matching because reduction may reveal additional quotes or runtime-variable storage uses; analysis never executes user reduction.

Add a custom reducible Unary Extension hiding a quote alongside the existing custom intrinsic case. Native net10 Std/Light1712 tests and registered regression fixtures pass807ms. Standalone source-embedded net472/net10 probe proves both same-assembly custom node forms retain typed cells. No timing performed.
Add a captured mutable struct instance method call with a try/finally argument. Assert repeated calls mutate the same shared value. The native net10 Std/Light test passes; a source-embedded net472 probe exposes a VerificationException from caching the unbox pointer in a local. Framework emitter adaptation is pending in a separate fix.
Add a third distinct reference capture type to the success/failure/concurrent compilation fixture. Modern integer captures no longer require StrongBox metadata, so int/string/object alone fits the two inline cache entries and stopped exercising overflow storage.

Require an overflow array to exist after return, then recursively assert its entries and inline slots retain no metadata references. Fresh native Release net10 Std/Light runner passes1712 legacy tests and registered fixtures, zero warnings/errors.
Keep the original tree and every lexical ancestor while collecting empty-variable block chains in a single pass. Stop at custom Light intrinsics. Native Standard and Light net10 tests pass. Paired compile samples show roughly neutral to four percent improvements on deep block chains, with unchanged allocation and generated runtime behavior; raw trials are retained for the repair report.
Avoid a second virtual NodeType read on recursive collector entry; tail iterations still read their next node and preserve null/error handling. Native Standard and Light net10 tests pass. Collection-only paired samples improve balanced trees by about seven percent on net10 and three percent on net8; integrated compilation and runtime measurements follow in the repair report.
Quote and RuntimeVariables hoists now mark the root as requiring typed cells before any existing-hoist early return, then skip the ordinary mutation scan. Ordinary capture requests still analyze the entire root before choosing boxed storage, so any earlier nested body has already detected quotes, runtime variables or unknown storage uses and retained typed cells. Later ordinary analysis preserves the explicit typed flag.

Native net10 Std/Light1712 legacy tests and regression fixtures pass, including independent Quote-only/RV-only/mixed earlier-child cases. Source-embedded net472/net10 probes pass same-assembly custom intrinsic and reducible Extension hidden-quote cases plus receiver/EH behavior. Both builds have zero warnings/errors. No timing or cold-start measurements performed.
Initialize CaptureAnalysisRoot at the public collection boundary before terminal intrinsic callbacks, rather than after the private terminal fast return. An opaque root intrinsic can collect a mutable nested lambda and then a quote through separate public calls; the old path analyzed only the first child, compiled boxed access, then changed the owner's storage to StrongBox and threw InvalidCastException.

All validated roots and public custom collection calls funnel through the boundary, while recursive private helpers inherit the initialized root. Remove the repeated nonnull root probe from recursive collection. Add a root Constant intrinsic regression collecting both children separately and checking shared typed storage under default, no-inline and debug flags.

Isolated publishedb8 control passes8/10/472; pre-fix09050 reproduces InvalidCastException8/10 and passes472; fixed passes all3 runtimes. Native net10 Std/Light1712 legacy tests and registered regressions pass1135ms, zero warnings/errors. No timing performed.
Use the existing argument-provider helpers when examining NewArray operands, matching other capture-analysis traversals. Light small-array Expressions getters allocate a new array on every access, so the previous Count/index/Count loop allocated three operand arrays per visit for a one-element array.

This only changes access to the same operands; the private-reference safety proof and closure layout are unchanged. Prepared source-only while the frozen20d benchmark runs continue. Native correctness and targeted before/after allocation/performance validation remain pending; no measured parity claim is made.
Framework anonymous EH methods verify object[] slot loads as object. Cast readonly nonpassed reference values to their declared type before field/method access or typed returns, both in the normal captured-value emitter and the public reference-slot helper. Include UserProvided contexts because collection-skipping APIs also use anonymous hosting without a collected HasExceptionBlock flag. Owned ordinary methods and modern emitters retain their existing IL.

Regular closure constants already cast on Framework; also type the declaring object before a Light public refField constant load on verified contexts. No closure representation, hosting, factory or security policy changes.

Cover readonly local/parameter field and method captures across EH, typed/null/interface/array captures, ordinary/precreated constants, the public UserProvided slot helper, and Light field-backed constants under default/no-inline/debug flags. Focused CapturedValueRef fixture passes Std29/Light32 tests on net472/net8/net10, all6 builds clean. Isolated beforee4 returns7, unfixed20d reproduces VerificationException, and fixed returns7 with castclass Box in the same anonymous host. No benchmark timing performed.
Keep the three helper MethodInfo caches in one precisely initialized nested
holder. Plain expressions avoid their reflection initialization, while quote
and runtime-variable compilation resolves all three together. This preserves
the old feature initialization point and does not move merge metadata work to
the first quoted-runtime-variable invocation. Existing emitted calls, closure
layout, and object.GetType metadata remain unchanged.

Allocation-only validation retained all 216 samples: nine fresh processes for
each scenario/runtime/version comparison on .NET 8 and .NET 10. Add and
balanced compilation medians save 880/888 bytes relative to 0fa003b and match
e4c232d. Quote/runtime-variable cold allocation envelopes are unchanged.
First-invocation and warm compile/invoke min, median, and max allocations never
exceed the current baseline; normal values match. All cold bimodality and
occasional baseline noise remain recorded. No timing claim is made.
The nested holder does not inherit the surrounding compiler's RequiresUnreferencedCode context. Apply the same contract to its reflection initializers instead of disabling analysis. Normal native and analyzer-enabled Standard/Light production builds pass.
Replace the 1024-entry exact-length tables used only by lexical and exception scope metadata with a lazy internal 11-bucket pool. Preserve SmallList growth, requested allocation lengths, global sharing, atomic exclusive leases, and the 1024-element retention limit. The used heap sizes 4,9,19,39,79,159,319,639 occupy separate buckets, retaining reentrant size classes without one-largest-buffer eviction. No traversal or validation changes.

Allocation-only causal controls versus 0ec7a13 on .NET 8 and 10: cold nested_mixed128 saves 8104 bytes in all three fresh-process repetitions; cold add and balanced256 remain identical. Twelve warmed scenarios retain the same allocation floors. Instrumented scratch-only repeats (5x1000 operations on locals, mixed scopes, capture8 and reentrant nested compilation per runtime) report zero new scope-buffer misses; small 160/168-byte runtime allocation events occur without pool misses. Full Standard and Light native .NET 10 runner: all 1712 tests pass with normal analyzers, including concurrent scope-overflow compilation. No timing claim.
Guard the post-collection validators with their existing goto-list counts.
ValidateLexicalJumps only traverses ScopedGotos and ValidateExceptionJumps only
traverses ExceptionGotos; when both lists are empty neither has work or side
effects. Preserve the existing scope-info cleanup and all validation for trees
with jumps. This adds no prescan, cache, visitor or closure layout change.

EventPipe JIT controls confirm the two unused validators are no longer compiled
on the first ordinary addition. Nine alternating fresh-process controls for
five shapes per runtime keep all managed allocation medians identical. Cold
compile-plus-first ratios versus 6487c9b are 0.907-0.974 on .NET 8 and
0.964-1.013 on .NET 10; these include host/process variability and are not a
universal speedup claim. Warm compilation controls are mostly within a few
percent with unchanged allocation medians. Runtime controls and their variability
are retained alongside the cold samples, not treated as an emitter change.

Full native Standard/Light suites pass: .NET 10 1712, .NET 8 1711, including
lexical/exception jump rejection and valid control-flow regressions.
CLR 4 allocates 144 bytes for each closed generic constructor resolved with
GetMethodFromHandle. Its ordinary empty constructor lookup allocates nothing,
and value constructor lookup needs 96 bytes with the existing rented Type array.
Use those APIs for Framework only, and avoid initializing unused open handles.
Keep modern constructor handles and all closure representations unchanged.

Allocation-only causal probe: per-type constructor lookups 288 -> 96 bytes.
Separate-process warmed mutable-capture compilation: 5272 -> 5080 bytes (-192).
Activation stays 184 bytes, nested invocation 0, factory/invoke 184. Readonly
capture8/reference bytes stay unchanged. Forward/reverse fresh-process timing
medians observe mutable compilation ratio 0.927; unchanged-path timing variation
is retained and is not attributed to this metadata-only change.

Validation: normal Standard and Light production builds pass every configured
target with zero warnings/errors. CapturedValueRefRegressionTests passes 29
Standard and 32 Light cases on net472, net8.0 and net10.0. Parsing/compilation,
activation, nested runtime and factory/runtime measurements captured separately
for Framework baseline/current/candidate and modern current/candidate controls.
Scratch reproduction and raw data: work/pr558-repair/frame-capture-round2/.
For Framework nested lambdas with user parameters, declare the hidden argument
as ArrayClosureWithNonPassedParams. Existing Curry<C,...> already supports this
type, so capture accesses no longer need repeated closure casts. Record the
private method's exact signature in a status bit; public manual emitter contexts
keep their original cast behavior. Keep zero-user-argument signatures unchanged:
a separate cached array caused a measured cold allocation regression and was
rejected. No closure fields or runtime allocation layout changes.

Add regression coverage for private reference captures under exception handling,
zero-argument parent signature reuse, Action<T> currying and shared mutation,
debug flags, ordinary parent signature rentals and concurrent compilation.

Framework warmed and first-use allocation: Func<T> and EH Func<T> compile -120 B;
Action<T> -64 B; constant-folded dead capture unchanged. Runtime activation,
nested invocation and factory/invoke allocation unchanged. Modern control bytes
unchanged. Parsing/runtime timings are captured independently; mixed compiler
ratios and VM variability do not establish a CPU guarantee. No extra first-use
signature array is allocated by this conservative version.

Validation: normal Standard and Light production builds pass all configured
targets without warnings/errors. CapturedValueRefRegressionTests: Standard30,
Light33 on net472/net8/net10. The broader typed prototype also passed full
Framework1003 TestX and1595 legacy tests before zero-argument paths were narrowed
back to the existing implementation. Scratch source/raw measurements and the
rejected no-argument cold tradeoff remain under work/pr558-repair/frame-capture-*.
Collect eligible root binary operands directly through the existing terminal
collector. This avoids JIT-compiling the large scoped visitor for a simple root,
without adding a prepass, changing closure storage, or bypassing jump checks on
complex trees. Preserve left-to-right failures, constant identity and public
cleanup. Trust ordinary Light nodes as requested; explicit intrinsic roots or
operands retain their existing collection callbacks and scopes.

Measured Standard add warm compile ratios: 0.961 (.NET 8), 0.927 (.NET 10).
Nine fresh-process add compile+first medians: -21% / -34%, identical bytes.
Framework add cold compile+first: 156.40 -> 118.90 ms; 27080 -> 27040 B.
Trusted Light paired add warm compile ratios: 0.961 / 0.920, 872 B unchanged.
All measured warmed allocation medians are unchanged, including invocation.
Cold and fallback timing controls varied substantially with host load; retain
all raw results rather than claim universal CPU parity or speedups. The rejected
Light generic-type-provenance prototype added 160/168 B cold and is not included.

Add public collector error-order and bound-constant identity regressions, plus
Light intrinsic callback/scope regressions. Full native Standard/Light suites:
1712 passing on .NET 10, 1711 on .NET 8. Separate Framework harness checks agree
with System results. Evidence: workspace work/pr558-round2/trivial-binary*, to be
published with the integrated round-2 report after final validation.
Enable the existing native boxed value-cell representation for nonnullable
Framework captures only in the normal fully collecting root entry. Carry
that root eligibility through nested contexts and require completed capture
analysis. Any exception region anywhere in the root, quote, runtime variables,
or unknown node retains the StrongBox representation throughout the root.
Manual and collection-skipping APIs never establish owned-root eligibility,
including their privately compiled children. Hosting/security policy and
the existing object[] closure layout are unchanged.

Add controls for sibling order, outer exception regions, flags/debug mode,
shared identity, and manual default/UserProvided collection. Reuse existing
struct, byref, enum, nullable, quote, runtime-variable and unknown-node tests.

Framework measurements against 7a871c4d, separate one-compiler processes:
- Mutable capture compile: 5080 -> 4760 B (-320 B). Pre-PR e4c232d is
  4376 B, so the remaining compile allocation residual is explicitly +384 B.
- Activation/invoke stay 184 B, nested invocation stays 0 B.
- Fresh first compile+invoke: 40272 -> 37888 B (-2384 B).
- Capture8/reference/protected capture compile and runtime bytes unchanged.
- All 32 modern runtime/scenario/phase allocation controls unchanged.
- Descriptive two-order warm medians: mutable compile 43.92 -> 41.92 us,
  activation 31.80 -> 30.23 ns, nested invocation 6.064 -> 5.993 ns.
  Three fresh cold medians: compile+first 246.84 -> 221.64 ms.
  Unchanged controls show substantial host timing variation; these timing
  observations do not establish universal CPU parity or causal speedups.

Validation: normal Standard/Light production builds all configured targets,
zero warnings/errors; focused Standard/Light fixture 32/35 on Framework,
31/34 on net8 and net10, all passing; full Framework en-US runner passes
1007 TestX and 1595 legacy tests. Initial German-culture run had only existing
English-message assertion failures. Independent source review found no
actionable correctness defect. Raw evidence: work/pr558-repair/frame-owned-boxed.
Restore FEC's original nested-delegate slot load only when the normal root
has completed capture analysis and proves owned hosting throughout without
Try, Quote, runtime variables or unknown nodes. Manual/UserProvided and
anonymous exception-hosted methods retain the exact verifier cast. Hosting,
closure layout and delegate/Curry construction are unchanged.

Against 25955bb5, four-shape one-compiler Framework probes show -32 B compile
for mutable, capture8 and reference captures, warm and cold; protected
capture_try and all runtime allocations are unchanged. Mutable compile is
4728 B versus pre-PR 4376 B, leaving +352 B explicitly unresolved.
Two opposite process-order warm rounds and three fresh cold reps measure
mutable compile 40.81 -> 36.21 us, activation 26.02 -> 24.88 ns,
nested invocation 5.467 -> 5.483 ns, compile+first 185.55 -> 183.44 ms.
Host timing variation in unchanged controls prevents universal CPU claims.

Normal Standard/Light builds all targets: zero warnings/errors. Focused
fixtures pass on Framework/net8/net10 (32/31/31 Standard, 35/34/34 Light).
Full Framework en-US runner: 1007 TestX +1595 legacy passing. Independent
source audit confirms the conservative owned-host guard. Evidence:
work/pr558-repair/frame-owned-delegate/measurements.json and raw logs.
Omit the hidden closure cast only under the same completed ordinary owned-root
proof as the nested delegate cast. Typed signatures keep their existing fast
path; all anonymous/manual/UserProvided/EH/Quote/RV/unknown cases keep exact
typing. No signature arrays, metadata caches, hosting or layout changes.

Against 3320b13e, Framework warm/cold compilation saves 208 B for mutable,
120 B for readonly reference and 264 B for capture8; protected capture_try
and runtime allocations are unchanged. Mutable compile is now 4520 B versus
pre-PR 4376 (+144 B); reference is 4256 versus 4264 (-8 B).
Two opposite process-order warm rounds and three fresh cold repetitions:
mutable compile 42.83 -> 38.82 us, nested invocation 5.730 -> 4.963 ns,
compile+first 239.07 -> 204.11 ms. Activation 25.67 -> 28.55 ns despite its
unchanged emitted code illustrates host noise; no universal CPU claim.

Normal Standard/Light builds all targets pass with zero warnings/errors.
Focused fixtures pass on Framework/net8/net10 (32/31/31 Standard,
35/34/34 Light), including protected siblings, manual contexts and shared
mutation. Combined final full suite is scheduled by the parent task.
Evidence: work/pr558-repair/frame-owned-closure/measurements.json and raw logs.
Reuse the existing atomic-operand predicate for non-lifted operands. Keep
complex right operands, nullable lifting, custom Light intrinsics and
operator evaluation order on their existing paths. No new metadata query,
cache, analysis pass or closure layout is introduced.

For 64 decimal additions under real finally blocks, isolated compilation
allocations fall from 11496 to 5664 B on modern .NET and 21136 to 7088 B on
Framework. Warm compile improves 16-30%, compile plus first execution
12-44%, and invocation 7-19% across Standard/Light on .NET 8/10/Framework.
Native fixture checks pass in all six configurations. Cold controls and
material timing outliers remain in the evidence; universal parity is not
established. Full combined validation follows this logical commit.
Emit non-byref try body and catch results with the existing value-parent
mask before storing the typed result temporary. Receiver, lvalue and
duplication flags must not cause a managed address to be stored as a
struct value. Preserve enclosing EH/inlining context and genuine byref
Try results; mark a loaded ordinary result as a value.

Fixes the introduced Light new-struct receiver regression and existing
variable/field/ref-parameter receiver forms, preserving mutation-copy
semantics. Register seven native regression methods for Standard/Light
on modern .NET and Framework. All six focused configurations pass40cases;
modern byref-return aliases remain correct. No hosting, metadata cache,
closure layout or empty-finally elimination is changed.

Performance controls retain unchanged runtime/activation allocations.
A genuine Framework Light compilation-history allocation residual of
192B in two capture controls remains documented; isolated controls match.
This correctness repair does not claim universal performance parity.
Full combined native/imported validation follows this logical commit.
Retain collision and stable-reference tests against the master implementation.
…ware methods

Address dadhi review discussion_r4318926508: passing the runtime anonymous module into the module overload violates an explicit CoreCLR assertion even though tested release runtimes accept it. Remove that module-reuse workaround and its lazy holder. Use the public restricted anonymous constructor on every target; ordinary owner-hosted methods are unchanged.

Keep raw/wrapped non-Exception catch matching and private-member access semantics. The supported constructor eagerly JITs at CreateDelegate, so compile-only timings must be reported separately from compile plus first execution. NonExceptionThrowPolicy, ExceptionRegionValidation and TryResultValue fixtures pass Standard/Light on net8.0, net10.0 and net472.
@jogibear9988
jogibear9988 force-pushed the fix-runtime-expression-compatibility branch from dd98968 to 02a794a Compare October 27, 2026 11:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants