Add a compiled-invoker fast lane for single-candidate interop method calls#2733
Merged
Merged
Conversation
Cache MethodBase.IsGenericMethod on MethodDescriptor (computed in the ctor) so the per-call argument-binding path no longer pays the RuntimeMethodHandle::HasMethodInstantiation reflection cost in MethodInfoFunction.ResolveMethod/TryCall and in descriptor prioritization. This helps every path, including AOT. Add CompiledMethodInvoker: a lazily-built, strongly-typed delegate for the dominant interop shape (host.add(int,int) etc.). For single-candidate call sites whose parameters are exact-type primitives (int/long/double/bool/string) or a pass-through JsValue, and whose return is void/int/long/double/bool/string or a JsValue, the delegate binds and invokes without the per-call object?[] parameter array, argument boxes, boxed return value, and return-mapper lookup. Design notes: - Built only when RuntimeFeature.IsDynamicCodeCompiled is true (under AOT and interpreted-only Expression.Compile the cached MethodInvoker path is faster), and only on NET8_0_OR_GREATER (matching the existing fast-invoker gating). - Numeric argument conversion replicates InteropHelper.TryConvertNumberFast bit-for-bit (integral + range checks); any non-exact argument (fractional to int, out-of-range, wrong JS type) makes the delegate decline so the caller falls back to the full TryCall machinery, preserving today's behavior. - Returns are produced via the public JsValue implicit operators, which match DefaultObjectConverter exactly (void maps to JS null, as today). - The method is invoked through a cached open delegate (Expression.Invoke) rather than a direct Expression.Call: a direct call lets the JIT inline a small host method into the compiled lambda, erasing its frame from a thrown exception's stack trace. The delegate keeps the frame (reflection never inlines) so bubbling CLR exceptions look identical, while staying allocation free and fully typed. - The fast lane is skipped when custom object converters are registered, since those must observe primitive return values. - Exceptions thrown by the target propagate and are normalized at the call site to TargetInvocationException, matching the reflection path's error shape. Adds targeted tests covering fast-lane hits (int/long/double/bool/string/JsValue args and returns, void, static), fallbacks (overloaded, params, optional, fractional/string-to-int, out-of-range), host-exception fidelity, and the custom-converter bypass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
lahma
added a commit
that referenced
this pull request
Jul 22, 2026
… foreign receivers (#2737) Two behavior-parity gaps in the #2733 exact-type fast lane found in pre-release review: - The lane's gate checked custom IObjectConverters but not the engine's user-replaceable ITypeConverter. The reflection path consults the converter for some exact-type argument conversions (e.g. bool under default value coercion), so a custom converter installed via SetTypeConverter was silently bypassed. The lane now requires the exact DefaultTypeConverter. - An extracted instance method invoked with a wrong-typed this (f.call(foreignObject)) surfaced InvalidCastException from the compiled receiver cast instead of the reflection path's TargetException, which host code and Interop.ExceptionHandler predicates key on. The lane now declines when the receiver is not an instance of the declaring type so the slow path surfaces the original exception shape. Both new tests fail without the gate changes. Claude-Session: https://claude.ai/code/session_0115tQFNyyQqc1HQGPLUgZND Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This was referenced Jul 22, 2026
5 tasks
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.
For the dominant interop shape — a single-candidate method whose parameters are exact-type primitives — the binding path still pays four allocations per call (
object?[]parameter array, per-argument boxes, boxed return) plus theMethodInvokerindirection and the return-mapper dictionary lookup. #2719 removed the reflection re-derivation; this PR removes the rest for the hot case.Changes
MethodDescriptorcachesIsGenericMethod(the reflection property resolves throughRuntimeMethodHandle::HasMethodInstantiation— ~1% of the interop-method-calls row on its own) and uses it in binding and overload ordering. This part benefits every target including AOT.MethodDescriptor(same benign-race lazy-field idiom as_methodInvoker). Eligibility is conservative: single candidate, public non-generic instance/static method on a visible reference type, no params/optional arguments, parameters in {int, long, double, bool, string, JsValue}, return in {void, int, long, double, bool, string, JsValue-assignable}. The delegate handles exact-type argument hits only; anything else (fractional number to int, out-of-range, wrong JS type, custom object converters registered) declines and falls back to the existingTryCallmachinery, so conversion behavior is preserved bit-for-bit — the int/long guards replicateTryConvertNumberFastexactly, including the exclusive 2^63 upper bound.RuntimeFeature.IsDynamicCodeCompiled: under AOT (or interpretedExpression.Compile) an interpreted lambda would be slower than the cachedMethodInvoker, so those targets keep the current path unchanged.Expression.Call: a direct call lets the JIT inline small host methods into the lambda, which erases the host method's frame from thrown exceptions' stack traces (caught by the existing error tests). The delegate invoke keeps frames identical to the reflection path while staying allocation-free. Exception normalization (TargetInvocationException) and the host-boundary constraint checks sit at the same points as the reflection path.Benchmarks (same machine, adjacent same-base A/B, default job)
InteropMethodDispatchBenchmark:EngineComparisonInteropBenchmark(Jint lane):Gates: full Jint.Tests on net10.0 (fast lane active) and net472 (lane compiled out — proves fallback identity), Jint.Tests.PublicInterface both TFMs, Test262 99,431 passed / 0 failed. 15 new targeted tests cover fast-lane hits per type, fallback cases (overloads, params, optional args, coercions), host exceptions, and void returns.
🤖 Generated with Claude Code