Skip to content

Add a compiled-invoker fast lane for single-candidate interop method calls#2733

Merged
lahma merged 1 commit into
sebastienros:mainfrom
lahma:perf/interop-compiled-invoker
Jul 22, 2026
Merged

Add a compiled-invoker fast lane for single-candidate interop method calls#2733
lahma merged 1 commit into
sebastienros:mainfrom
lahma:perf/interop-compiled-invoker

Conversation

@lahma

@lahma lahma commented Jul 21, 2026

Copy link
Copy Markdown
Collaborator

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 the MethodInvoker indirection and the return-mapper dictionary lookup. #2719 removed the reflection re-derivation; this PR removes the rest for the hot case.

Changes

  • MethodDescriptor caches IsGenericMethod (the reflection property resolves through RuntimeMethodHandle::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.
  • net8+: a lazily compiled, strongly-typed invoker delegate per eligible 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 existing TryCall machinery, so conversion behavior is preserved bit-for-bit — the int/long guards replicate TryConvertNumberFast exactly, including the exclusive 2^63 upper bound.
  • The compiled lane is runtime-gated on RuntimeFeature.IsDynamicCodeCompiled: under AOT (or interpreted Expression.Compile) an interpreted lambda would be slower than the cached MethodInvoker, so those targets keep the current path unchanged.
  • The target method is invoked through a cached open delegate rather than a direct 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:

Row main PR delta
SingleOverload_OneIntArg 2.740 µs / 2.01 KB 2.496 µs / 1.93 KB −8.9%
SingleOverload_TwoArgs_IntString 3.083 µs 2.742 µs −11.1%
SingleOverload_OneDoubleArg / OneStringArg 2.76 µs / 2.75 µs 2.60 µs / 2.58 µs −6%
Overloaded_MixedArgs 8.835 µs 8.836 µs flat (ineligible, fallback intact)
ParamsMethod_Spread 3.405 µs 3.432 µs flat

EngineComparisonInteropBenchmark (Jint lane):

Row main PR delta
interop-method-calls 2.257 ms / 1,424.5 KB 2.049 ms / 347.0 KB −9.2% / −75.6%
interop-string-passing 1.061 ms / 384.3 KB 0.860 ms / 316.9 KB −18.9% / −17.5%
interop-property-access / collection-traversal flat (allocation byte-identical)

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

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
lahma merged commit fed894d into sebastienros:main Jul 22, 2026
4 checks passed
@lahma
lahma deleted the perf/interop-compiled-invoker branch July 22, 2026 06:39
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>
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