Skip to content

Cache interop invokers process-wide instead of per-Engine - #2743

Merged
lahma merged 1 commit into
sebastienros:mainfrom
lahma:interop-static-invoker-cache
Jul 23, 2026
Merged

Cache interop invokers process-wide instead of per-Engine#2743
lahma merged 1 commit into
sebastienros:mainfrom
lahma:interop-static-invoker-cache

Conversation

@lahma

@lahma lahma commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator

Summary

Cache interop invokers process-wide by MethodBase so a new Engine does not recompile them.

Details

The compiled interop invoker (#2733) builds a System.Linq.Expressions delegate that binds and invokes a CLR method without the object?[] parameter array, the argument boxes, the boxed return and the return-mapper lookup. It was cached in a field on MethodDescriptor — but MethodDescriptor instances are per-Engine, because TypeResolver.GetAccessor caches the ReflectionAccessor that owns them in Engine._reflectionAccessors.

So every new engine re-ran Expression.Compile() — and re-emitted a BCL invoke stub — for every host method it called. In the fresh-engine-per-operation pattern that the interop benchmarks measure, and that many embeddings use, that compilation dominated the row. An ETW profile attributed 21% of interop-method-calls and 35% of interop-string-passing to CompiledMethodInvoker.TryBuildExpression.CompileMethodDesc::DoPrestub, with thousands of distinct lambda_method symbols in a 22-second capture.

The compiled invoker and the BCL MethodInvoker/ConstructorInvoker now live in static dictionaries keyed by the reflected member. The existing per-instance fields are kept as an L1 cache, so the steady-state per-call path is still a plain field read and only a descriptor's first miss consults the shared dictionary.

Why keying on MethodBase is sound. Everything CompiledMethodInvoker.TryBuild consumes is derived purely from the MethodBase (Parameters is method.GetParameters(); HasParams / ParameterDefaultValuesCount come from those parameters' attributes; IsGenericMethod and IsExtensionMethod likewise), and the delegate it produces closes over nothing engine-specific — only the open delegate created from the same MethodBase, the JsValue.Undefined / JsValue.Null singletons, and public JsValue operators. The engine-affine policy — registered object converters, a custom ITypeConverter, the receiver type check — is applied at the call site in MethodInfoFunction.Call and gates use of the invoker, never its construction. One engine's policy therefore cannot leak into another through the cache; the tests assert exactly that, in both creation orders.

Trade-off. A static cache keyed by MethodBase pins that member, and hence its declaring assembly, for the process lifetime. That matches the precedent already set by TypeDescriptor._cache, TypeReference._memberAccessors, JintBinaryExpression._knownOperators and DefaultTypeConverter._knownCastOperators.

Benchmark numbers

EngineComparisonInteropBenchmark, Jint lane, default job:

Script Before After Δ Allocated
interop-string-passing 907.9 µs 578.7 µs −36.3% 317.09 → 304.91 KB
interop-method-calls 2180.4 µs 1689.5 µs −22.5% 347.24 → 329.26 KB
interop-property-access 1891.8 µs 1840.2 µs −2.7% (noise) unchanged
interop-collection-traversal 1559.8 µs 1605.4 µs +2.9% (noise) unchanged
Measurement protocol

All arms measured in one session on one machine (AMD Ryzen 9 5950X, .NET 10.0.10, default BenchmarkDotNet job). The baseline figure is the mean of two baseline runs executed first and last in the sequence as a drift check; they agree within 1.2–4.9%, which is this session's noise floor — deltas smaller than that are reported as noise above.

Every arm ran against a pre-built worktree after a discarded warm-up pass. That matters: in a first pass where BenchmarkDotNet still had to build its generated projects, runs took 5–8.5 min instead of 1.6–2.6 min, baseline-vs-baseline disagreed by up to 74%, and BenchmarkDotNet flagged MultimodalDistribution (mValue = 3.84). Those results were discarded.

This is one of three related interop-performance changes; combined they move interop-string-passing −37%, interop-method-calls −29%, interop-property-access −15% (−59% allocation) and interop-collection-traversal −6%. Measured with every engine in the same session, that makes Jint the fastest engine on interop-string-passing (544 µs vs YantraJS 643 and NiL.JS 861), and closes the gap to NiL.JS on interop-method-calls from 71% to 17%.

The Jint_ParsedScript script suite was re-measured as a regression net and is flat (aggregate ≈ +0.4% across 12 scripts, allocations byte-identical).

Linked issue

None — found by profiling the interop benchmark suite.

Test plan

  • Added or updated unit tests in Jint.Tests
  • Ran dotnet test --configuration Release locally (Jint.Tests 3813 passed, Jint.Tests.PublicInterface 114 passed)
  • For ECMAScript spec changes: ran Jint.Tests.Test262 and confirmed no regressions — n/a, no spec behaviour touched
  • For interop changes: covered in Jint.Tests/Runtime/Interop
  • For perf changes: included before/after numbers from Jint.Benchmark

New tests cover two engines sharing the cache with differing interop policies (custom ITypeConverter, registered object converters) in both creation orders, throwing host methods, methods ineligible for the compiled lane (params, optional arguments, generic, struct receiver, enum and custom-class parameters — exercising the "known ineligible" sentinel), overload resolution and the constructor invoker.

Breaking change?

No. No public API change; behaviour is identical, only the caching scope changes.

Related

Part of a three-PR interop series, each independently useful and mergeable in any order:

I verified all three merged together: Jint.Tests 3910 passed, Jint.Tests.PublicInterface 114, Jint.Tests.CommonScripts 28. One caveat if you take both #2743 and #2745: they each append tests to Jint.Tests/Runtime/InteropCompiledInvokerTests.cs, which conflicts textually — the resolution is simply to keep both blocks, as neither side's tests overlap. MethodDescriptor.cs, which both also touch, merges cleanly.

🤖 Generated with Claude Code

MethodDescriptor instances are per-Engine (TypeResolver.GetAccessor caches
the owning ReflectionAccessor in Engine._reflectionAccessors), so caching the
compiled invoker and the BCL MethodInvoker/ConstructorInvoker in per-instance
fields made every new Engine re-emit an invoke stub and re-compile the whole
expression tree for every host method it called. In the common
fresh-Engine-per-operation embedding pattern that dominates the profile:
Expression.Compile plus the resulting DoPrestub accounted for 21% of the
interop-method-calls row and 35% of interop-string-passing.

Move all three caches into static ConcurrentDictionary instances keyed by the
reflected member, with the existing per-instance fields kept as an L1 cache so
the steady-state per-call path stays a plain field read and only a descriptor's
first miss probes the shared dictionary. A null value in the compiled-invoker
dictionary is the "known ineligible" sentinel, so an ineligible method is never
re-probed.

Keying on MethodBase is sound: every input CompiledMethodInvoker.TryBuild
consumes is derived purely from the MethodBase (Method, GetParameters(),
the params/optional/generic/extension parameter attributes), and the delegate
it builds closes over nothing Engine-specific - only the open invocation
delegate created from the same MethodBase, the JsValue.Undefined/JsValue.Null
singletons and public JsValue operators. The Engine-affine policy decisions
(custom object converters, a custom ITypeConverter, receiver type checks) live
at the call site in MethodInfoFunction.Call and gate use of the invoker, never
its construction. RuntimeFeature.IsDynamicCodeCompiled gating is unchanged, so
AOT and interpreted-lambda runtimes keep the reflection path.

The trade-off - a static cache pins the MethodBase and its assembly for the
process lifetime - matches the precedent already set by TypeDescriptor._cache,
TypeReference._memberAccessors, JintBinaryExpression._knownOperators and
DefaultTypeConverter._knownCastOperators.

Adds cross-engine regression tests covering shared entries between two
independent engines, custom ITypeConverter and object-converter policies in
both creation orders, throwing host methods, the ineligible sentinel (params,
optional, generic, struct receiver, enum and class parameters), overload
resolution and the shared constructor invoker.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@lahma
lahma merged commit a446e57 into sebastienros:main Jul 23, 2026
4 checks passed
@lahma
lahma deleted the interop-static-invoker-cache branch July 23, 2026 06:05
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.

1 participant