Repository navigation
perf(runtime): drop per-call struct, size and swizzle lookups around ffi_call - #511
Draft
edusperoni wants to merge 1 commit into
Draft
edusperoni wants to merge 1 commit into
edusperoni wants to merge 1 commit into
Conversation
…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.
|
Important Draft PR not reviewedDraft PRs are not automatically reviewed by default.
To automatically review draft PRs, update your CodeRabbit configuration: reviews:
auto_review:
drafts: true
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. Comment |
This branch has not been deployed
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.
Motivation
ffi_callitself 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:StructInfofrom scratch: astd::stringbuilt from the declaration name,ArgConverter::GetMetataking astd::mutexand hashing it, thenFFICall::GetStructInfocopying the name again, taking a spinlock and hashing it a second time. Anonymous structs rebuilt their cache key through astd::stringstreamon every call.ffi_type(heap-allocating for constant arrays and vectors) only to read itssizeand dispose it again: null/undefined arguments, by-reference out parameters, constant-array arguments, fields and returns.__NativeScript__<sel>under a spinlock and sentrespondsToSelector:for it, although only selectors the runtime swizzled (assigning to a native class's prototype) can ever have that twin.NSError**calloc/freed a pointer-sized slot per call; closure callbacks heap-allocated astd::vectorfor their JS arguments.Changes
FFICall::GetStructInfo(const TypeEncoding*): resolves aStructDeclarationReferenceorAnonymousStructEncodingonce and caches theStructInfo*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 byTypeEncoding*) makes. It resolves exactly asGetResultand the cif'sffi_typealready did (meta looked up by the encoding's jsName), so struct objects keep the sameStructInfoidentity and per-type template as before.FFICall::GetTypeSize(encoding, isStructMember): the sizeGetArgumentType(...)->sizereports, without building or disposing anything. Replaces every build-read-dispose pair.Interop::RegisterSwizzledSelectoris called by the three swizzle sites inMetadataBuilderbeforeclass_replaceMethod;GetSwizzledMethodSelectorreturnsnullptrfor any selector never registered, and returns without taking the lock while nothing has been swizzled at all (process-wide atomic flag). TherespondsToSelector:probe still runs, per receiver, for registered selectors, so a swizzle on a superclass, anNSKVONotifying_*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 throughobjc_msgSend.NSError**slot is a zero-initialized stack variable.ArgConverter::MethodCallbackcollects 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/runtimeat 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%.view.framescreen.boundsscreen.bounds.size.widthUIScreen.mainScreen.bounds.size.widthview.frame = {...}NSValue.valueWithCGRect({...})view.frame = rectNSValue.valueWithCGRect(null)TNSTestNativeCallbacks.recordsNestedAnonymousStruct)api.methodError(0)enumerateObjectsUsingBlock(noop)arr.countarr.indexOfObject(obj)obj.isKindOfClass(NSObject)view.setNeedsLayout()screen.nativeScalePlain 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 ownTNSLog/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, inbench/of the main checkout, like the ones for #496.Testing
./run_tests.sh(Debug, dedicated simulator): 1835 specs, 0 failures, 11 skipped../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)
ArgConverter::Invoke): every instance call hashesobject_getClassName(target)intoClassPrototypesand copies theCachesshared_ptr(atomic inc/dec). Caching this perClasspointer looks right, because a class is extended at creation. ButClassPrototypesis per isolate, and how HMR re-extension names and replaces classes needs checking first.CacheItem:ParametrizedCall::GetandMethodMeta::selector()are a spinlocked hash lookup each, on every call. Hoisting them needs plumbing throughObjCMethodCall/Invoke; perf(runtime): cache StructInfo, struct prototypes and the Class on the native call path #496 estimated this at ~5%.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.ffi_typeper return (Interop::GetResult, ExtVector branch). The wrapper owns it, andObjectManagerdisposes it. Sharing one per encoding means changing that ownership, so it's left alone here.MetadataBuilderstruct branch) keysStructInfoby the native name, while returns and the cif key it by jsName. For a struct whose jsName differs, the two get distinctStructInfos, and so distinct prototypes andinstanceofresults. This predates this PR, which keeps the jsName side. Unifying the two is a behavior change.MethodCallbackstill copies the callback'sshared_ptrand theCachesshared_ptrper invocation. Dropping those copies needs a lifetime argument: the wrapper or block can be disposed while the JS callback runs.Classreturns (GetResult,ClassEncoding) callGetMetaandObjectCtorInitializerper return. That path wasn't measured here.ffi_callitself with directobjc_msgSendstubs is feat(runtime): AOT direct calls — app-compiled objc_msgSend stubs bound in place of libffi #366.