GV

GeezerVison

The World Wide Web, restored to 800 pixels of glory.

Home|About|Gaming cabinet|Archives|How it works|Starter sites

On this site

  • Sign in
  • ActionsCI/CD and automation
  • CodespacesCloud development environments
  • All Features
  • Documentation
  • Training
  • Certifications
  • Build with Copilot
  • Customize Copilot
  • Integrations
  • Trending repos
  • GitHub Sponsors
  • Developer articles
  • Enterprises

This view

  • Original page
  • New address

GeezerVison is showing github.com — perf(runtime): drop per-call struct, size and swizzle lookups around ffi_call by edusperoni · Pull Request #511 · NativeScript/ios

Conversation

@edusperoni

@edusperoni edusperoni commented Oct 13, 2026 •
edited
Loading

Uh oh!

There was an error while loading. Please reload this page.

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.

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

All reactions
@edusperoni
perf(runtime): drop per-call struct, size and swizzle lookups around …
c245bc8
…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
  • X
  • Mastodon
  • Reddit
  • LinkedIn

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

All reactions

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

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

Reviewers

No reviews

Uh oh!

There was an error while loading. Please reload this page.

Assignees

No one assigned

Labels

None yet

Projects

None yet

Milestone

No milestone

Development

Successfully merging this pull request may close these issues.

Uh oh!

There was an error while loading. Please reload this page.

1 participant

@edusperoni
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.
You are visitor 01402503 — GeezerVison does not store the pages you look up.
Best viewed with Netscape Navigator 4.7 or Internet Explorer 6 at 800×600. No cookies required.