Skip to content

perf(runtime): drop per-call struct, size and swizzle lookups around ffi_call - #511

Draft
edusperoni wants to merge 1 commit into
mainfrom
perf/ffi-call-overhead
Draft

edusperoni wants to merge 1 commit into
mainfrom
perf/ffi-call-overhead

Conversation

@edusperoni

@edusperoni edusperoni commented Oct 13, 2026 •

Copy link
Copy Markdown
Collaborator

Motivation

ffi_call itself is a small part of a native call from JS (~12-18 ns with a cached cif, measured in #496/#366). Reading the call paths around it showed per-call work whose answer never changes for a given method or type:

  • every by-value struct argument, return and nested field resolved its StructInfo from scratch: a std::string built from the declaration name, ArgConverter::GetMeta taking a std::mutex and hashing it, then FFICall::GetStructInfo copying the name again, taking a spinlock and hashing it a second time. Anonymous structs rebuilt their cache key through a std::stringstream on every call.
  • several sites built an ffi_type (heap-allocating for constant arrays and vectors) only to read its size and dispose it again: null/undefined arguments, by-reference out parameters, constant-array arguments, fields and returns.
  • every instance method call looked up __NativeScript__<sel> under a spinlock and sent respondsToSelector: for it, although only selectors the runtime swizzled (assigning to a native class's prototype) can ever have that twin.
  • methods with an omitted NSError** calloc/freed a pointer-sized slot per call; closure callbacks heap-allocated a std::vector for their JS arguments.

Changes

  • FFICall::GetStructInfo(const TypeEncoding*): resolves a StructDeclarationReference or AnonymousStructEncoding once and caches the StructInfo* by encoding address. Metadata encodings live for the process; the runtime-built ones (Interop::CreateEncoding, ClassBuilder's exposed methods) are never freed and never describe structs, the same assumption the existing cif cache (ParametrizedCall, keyed by TypeEncoding*) makes. It resolves exactly as GetResult and the cif's ffi_type already did (meta looked up by the encoding's jsName), so struct objects keep the same StructInfo identity and per-type template as before.
  • FFICall::GetTypeSize(encoding, isStructMember): the size GetArgumentType(...)->size reports, without building or disposing anything. Replaces every build-read-dispose pair.
  • Interop::RegisterSwizzledSelector is called by the three swizzle sites in MetadataBuilder before class_replaceMethod; GetSwizzledMethodSelector returns nullptr for any selector never registered, and returns without taking the lock while nothing has been swizzled at all (process-wide atomic flag). The respondsToSelector: probe still runs, per receiver, for registered selectors, so a swizzle on a superclass, an NSKVONotifying_* subclass or a class created later is still seen. Third-party swizzling (method_exchangeImplementations) never produced a __NativeScript__ twin, and is still honoured because the call goes through objc_msgSend.
  • The NSError** slot is a zero-initialized stack variable.
  • ArgConverter::MethodCallback collects up to 8 JS arguments in an inline array (heap fallback above that).

Measurements

Release TestRunner, iPhone 16 Pro simulator (iOS 26.2) on an M4 Pro. Both builds come from the same worktree (main = NativeScript/runtime at 20935bd), and only the bench specs were run, with no test suite before them. Each run takes the best of 5 × 100k per case; the table shows the median of 5 runs per build, alternating main/branch. Run-to-run noise is about 3%. Specs that don't touch changed code (class methods, C functions, JS controls) moved ±3%.

case main branch delta
struct return: view.frame 274 232 -16%
screen.bounds 279 229 -18%
screen.bounds.size.width 515 428 -17%
UIScreen.mainScreen.bounds.size.width 656 574 -12%
struct arg (plain object): view.frame = {...} 717 550 -23%
struct arg (plain object): NSValue.valueWithCGRect({...}) 1498 1263 -16%
struct arg (struct object): view.frame = rect 205 198 -3%
struct arg (null): NSValue.valueWithCGRect(null) 990 934 -6%
anonymous struct round trip (TNSTestNativeCallbacks.recordsNestedAnonymousStruct) 1649 1281 -22%
NSError out-param omitted: api.methodError(0) 559 536 -4%
block callback, per item: enumerateObjectsUsingBlock(noop) 466 423 -9%
arr.count 86 83 -3%
arr.indexOfObject(obj) 122 118 -4%
obj.isKindOfClass(NSObject) 196 190 -3%
view.setNeedsLayout() 110 106 -4%
screen.nativeScale 146 142 -3%

Plain instance calls gain only a few ns from skipping respondsToSelector:, since that probe hits the method cache. The fixture-based cases include the fixture's own TNSLog/stringWithFormat: work, so their absolute numbers overstate the runtime's share.

The bench specs and harness (Release build, a minimal runner replacing app/index.js, results read from the app container) live outside the tree, in bench/ of the main checkout, like the ones for #496.

Testing

  • ./run_tests.sh (Debug, dedicated simulator): 1835 specs, 0 failures, 11 skipped.
  • ASan run: ./run_tests.sh -a (AddressSanitizer): 1835 specs, 0 failures, 16 skipped, no ASan reports.

Follow-ups not in this PR (riskier, or need more analysis)

  1. Is the receiver's class JS-extended (ArgConverter::Invoke): every instance call hashes object_getClassName(target) into ClassPrototypes and copies the Caches shared_ptr (atomic inc/dec). Caching this per Class pointer looks right, because a class is extended at creation. But ClassPrototypes is per isolate, and how HMR re-extension names and replaces classes needs checking first.
  2. Hoisting the cif and selector lookups into CacheItem: ParametrizedCall::Get and MethodMeta::selector() are a spinlocked hash lookup each, on every call. Hoisting them needs plumbing through ObjCMethodCall/Invoke; perf(runtime): cache StructInfo, struct prototypes and the Class on the native call path #496 estimated this at ~5%.
  3. Lock-free swizzle check after the first swizzle: once any selector is swizzled, every instance call takes the registry spinlock again, as on main. A per-selector generation stamp cached at the call site would make this lock-free. That only matters for apps that patch native prototypes.
  4. Vector (simd) returns still allocate an ffi_type per return (Interop::GetResult, ExtVector branch). The wrapper owns it, and ObjectManager disposes it. Sharing one per encoding means changing that ownership, so it's left alone here.
  5. Struct identity for renamed structs: the global constructor (MetadataBuilder struct branch) keys StructInfo by the native name, while returns and the cif key it by jsName. For a struct whose jsName differs, the two get distinct StructInfos, and so distinct prototypes and instanceof results. This predates this PR, which keeps the jsName side. Unifying the two is a behavior change.
  6. Closure callback lifetimes: MethodCallback still copies the callback's shared_ptr and the Caches shared_ptr per invocation. Dropping those copies needs a lifetime argument: the wrapper or block can be disposed while the JS callback runs.
  7. Class returns (GetResult, ClassEncoding) call GetMeta and ObjectCtorInitializer per return. That path wasn't measured here.
  8. cifs keyed by encoding address, not by signature: identical signatures never share a cif. This costs memory and first-call time only, not throughput.
  9. Replacing ffi_call itself with direct objc_msgSend stubs is feat(runtime): AOT direct calls — app-compiled objc_msgSend stubs bound in place of libffi #366.

…ffi_call

- Resolve StructInfo once per struct TypeEncoding (FFICall::GetStructInfo
  overload keyed by encoding address) instead of rebuilding the name string,
  taking ArgConverter::GetMeta's mutex and hashing the name twice on every
  struct argument, return and nested field; anonymous structs no longer
  rebuild their stringstream key per call.
- FFICall::GetTypeSize replaces the GetArgumentType + DisposeFFIType pairs
  that built (and, for arrays/vectors, heap-allocated) an ffi_type only to
  read its size: null/undefined arguments, by-reference out parameters,
  constant arrays.
- Selectors the runtime swizzles are registered up front; method calls skip
  the respondsToSelector: probe for every other selector, and skip the
  registry lock entirely until the first swizzle.
- The NSError out-parameter slot lives on the stack instead of calloc/free.
- Closure callbacks collect up to 8 JS arguments in an inline array instead
  of a heap-allocated vector.
@coderabbitai

coderabbitai Bot commented Oct 13, 2026

Copy link
Copy Markdown

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

This branch has not been deployed

No deployments
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