Cache interop invokers process-wide instead of per-Engine - #2743
Merged
Conversation
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>
This was referenced Jul 22, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Cache interop invokers process-wide by
MethodBaseso a newEnginedoes not recompile them.Details
The compiled interop invoker (#2733) builds a
System.Linq.Expressionsdelegate that binds and invokes a CLR method without theobject?[]parameter array, the argument boxes, the boxed return and the return-mapper lookup. It was cached in a field onMethodDescriptor— butMethodDescriptorinstances are per-Engine, becauseTypeResolver.GetAccessorcaches theReflectionAccessorthat owns them inEngine._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% ofinterop-method-callsand 35% ofinterop-string-passingtoCompiledMethodInvoker.TryBuild→Expression.Compile→MethodDesc::DoPrestub, with thousands of distinctlambda_methodsymbols in a 22-second capture.The compiled invoker and the BCL
MethodInvoker/ConstructorInvokernow 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
MethodBaseis sound. EverythingCompiledMethodInvoker.TryBuildconsumes is derived purely from theMethodBase(Parametersismethod.GetParameters();HasParams/ParameterDefaultValuesCountcome from those parameters' attributes;IsGenericMethodandIsExtensionMethodlikewise), and the delegate it produces closes over nothing engine-specific — only the open delegate created from the sameMethodBase, theJsValue.Undefined/JsValue.Nullsingletons, and publicJsValueoperators. The engine-affine policy — registered object converters, a customITypeConverter, the receiver type check — is applied at the call site inMethodInfoFunction.Calland 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
MethodBasepins that member, and hence its declaring assembly, for the process lifetime. That matches the precedent already set byTypeDescriptor._cache,TypeReference._memberAccessors,JintBinaryExpression._knownOperatorsandDefaultTypeConverter._knownCastOperators.Benchmark numbers
EngineComparisonInteropBenchmark,Jintlane, default job: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) andinterop-collection-traversal−6%. Measured with every engine in the same session, that makes Jint the fastest engine oninterop-string-passing(544 µs vs YantraJS 643 and NiL.JS 861), and closes the gap to NiL.JS oninterop-method-callsfrom 71% to 17%.The
Jint_ParsedScriptscript 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
Jint.Testsdotnet test --configuration Releaselocally (Jint.Tests3813 passed,Jint.Tests.PublicInterface114 passed)Jint.Tests.Test262and confirmed no regressions — n/a, no spec behaviour touchedJint.Tests/Runtime/InteropJint.BenchmarkNew 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:
interop-method-callsandinterop-string-passing)interop-property-accessand its allocation)I verified all three merged together:
Jint.Tests3910 passed,Jint.Tests.PublicInterface114,Jint.Tests.CommonScripts28. One caveat if you take both #2743 and #2745: they each append tests toJint.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