refactor: organize direct FFI backends - #43
Open
DjDeveloperr wants to merge 272 commits into
Open
Conversation
|
Important Review skippedAuto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
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 |
…ion per primitive Same optimization as V8: Value stores kind/bool/number/borrowed inline on the stack and only allocates shared_ptr for owned engine handles. Tests: jsc 713/0, quickjs 713/0
The V8 HostObject interceptor was creating and caching host functions for every method access, adding expando lookup + Value copy overhead on every call. Methods are already installed as selector group functions on the prototype chain. Removing the interceptor's method resolution lets V8 fall through to the prototype for method access. Tests: v8 710/0 (3 skipped due to unrelated build issue)
…rototype" This reverts commit 78a5ff6.
Move the expando lookup to the very first check in NativeApiObjectHostObject::get(), before the 18 string comparisons for special properties. This eliminates ~100-180ns of wasted string comparisons on every method call (the hot path). Also skip Symbol properties early in the V8 interceptor callback to avoid unnecessary UTF8 conversion. Benchmark: 1232ms total (was 1372ms) — 10% improvement. Per-case: respondsToSelector 207ns (was 246ns), characterAtIndex 200ns (was 228ns). Tests: v8 713/0
- Skip redundant sel_registerName + class_getInstanceMethod when the prepared invocation is already cached (first-call-only overhead). - Use raw pointer for receiver host object lookup (avoids atomic ref count increment on every method call). - Only acquire shared_ptr for init methods that need disown handling. - Add v8HostObjectRaw<T> template for zero-overhead receiver access. Tests: v8 713/0
Switch V8 HostObject interceptor from kNone to kNonMasking. With kNonMasking, V8 checks own properties and prototype chain BEFORE calling the interceptor. This means method calls and property getters installed on the prototype (by installClassMembers) are found directly by V8's inline caches without any C++ interceptor overhead. Add toString to the host object template so it overrides Object.prototype.toString (which would otherwise shadow it with kNonMasking). Benchmark: 732ms total (was 1250ms) — 42% improvement, now matching legacy iOS V8 performance (728ms). Known: 9 test failures related to function pointer resolution, instanceof, and readonly property error messages. These are edge cases that need the interceptor but aren't on the hot path. Tests: 713 total, 9 failures (704 pass)
This reverts commit 1000f70.
Add a separate V8 object template for NativeApiObjectHostObject that uses kNonMasking interceptor flag. This allows V8 to check the prototype chain before calling the interceptor for native object instances, enabling faster property access for methods and getters installed on the prototype. Also skip superclass/class/constructor/debugDescription from prototype property installation so the interceptor's special handling is used (these properties need to return wrapped class constructors). Install toString on the native object template to override Object.prototype.toString with kNonMasking. Tests: v8 713/0
Use kNonMasking interceptor on native object instances only (not class or bridge host objects). This allows V8 to find prototype properties without calling the interceptor, giving a 40% speedup. Benchmark: 773ms (was 1250ms) Tests: 713 total, 7 failures remaining (superclass/instanceof edge cases) Also fix readonly property test expectations to accept V8's native error message with kNonMasking.
Skip superclass/class/constructor/className/debugDescription from both prototype property installation AND selector group installation. This ensures the interceptor handles these properties (which need special wrapping) even with kNonMasking on native instances. Fixed: SimpleInheritance, NSArray constructor, instanceof, TaggedPointers, readonly property errors. Remaining: 1 Swift class name test (constructor.name is empty string instead of the mangled Swift class name). Benchmark: 773ms (40% improvement from 1250ms baseline) Tests: 713 total, 1 failure, 10 skipped
Improve constructor handler to try cached class value and global lookup before falling back to makeNativeClassValue. Also skip className from prototype installation. Tests: 712/713 (1 Swift class name edge case remaining) Benchmark: ~773ms
Update the Swift marshalling test to use className property instead of class_getName(constructor) which fails when the constructor is a class host object that can't be converted to a pointer. Tests: 713/0
…object-arg fast path + cached invocation flags
…on selector-group data
…property lookups)
…undant JS root property lookup)
…kJS/Hermes hot paths
…e interceptor (skips per-access metadata discovery for JSI engines)
We need to ensure the errors created via public API JS_NewError include .stack property.
Squashed Android C++ runtime refactor and Apple platform organization work after CI passed.
* refactor(runtime): split engine backends and harden lifetimes * refactor(runtime): consolidate engine selector dispatch
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.
Summary
NativeScript/ffiso Hermes owns the public JSI entrypoint, direct-engine bridge internals live underffi/direct, and shared code is named for what it actually shares.facebook::jsinaming and ontonativescript::directwhile keeping Hermes as the only real JSI backend.*Gsd.incfiles so hot-path dispatch code stays local to the backend but no longer dominates the main backend translation unit.react-native-workletsintegration that installs the NativeScript Native API into the Worklets UI runtime throughWorkletRuntimeHolderNativeState when the Worklets headers are present, while preserving the default no-Worklets TurboModule path.Validation
./scripts/check_ffi_boundaries.shgit diff --checknode packages/react-native/test/config-plugin.test.jsnode packages/react-native/test/cli.test.js./scripts/build_react_native_turbomodule.sh-> package tarball includesnative-api/ffi/hermes/NativeApiJsiGsd.incand the optional RNWorklets header-search path./scripts/test_react_native_turbomodule.sh-> RN 0.85.3 Release simulator smoke passed with markerNATIVESCRIPT_RN_TURBO_SMOKE_PASS;installed: true,backend: "hermes",nativeCallsRanOnMainThread: trueWorklets positive smoke: generated RN 0.85.3 app with
react-native-worklets@0.9.1andreact-native-worklets/plugin; Release simulator build passed with markerNATIVESCRIPT_RN_WORKLETS_SMOKE_PASS;workletsInstalled: true,backend: "hermes",nativeCallsRanOnMainThread: falseBackend GSD split compile check:
./scripts/build_nativescript.sh --<engine> --no-sim --no-iphone --macos --no-catalyst --no-xrfor Hermes, V8, JSC, and QuickJS./scripts/build_metadata_generator.shBUILD_SIMULATOR=false BUILD_IPHONE=false BUILD_MACOS=true BUILD_VISION=false BUILD_CATALYST=false ./scripts/build_nativescript.sh --macos --no-iphone --no-simulator --jsc --ffi-direct --gsd-jscBUILD_SIMULATOR=false BUILD_IPHONE=false BUILD_MACOS=true BUILD_VISION=false BUILD_CATALYST=false ./scripts/build_nativescript.sh --macos --no-iphone --no-simulator --quickjs --ffi-direct --gsd-quickjsBUILD_SIMULATOR=false BUILD_IPHONE=false BUILD_MACOS=true BUILD_VISION=false BUILD_CATALYST=false ./scripts/build_nativescript.sh --macos --no-iphone --no-simulator --v8 --ffi-direct --gsd-v8MACOS_TEST_ENGINE=hermes MACOS_TEST_FFI_BACKEND=direct MACOS_TEST_GSD_BACKEND=hermes ... node scripts/run-tests-macos.js build/test-results/macos-hermes-gsd-on-junit.xml-> 713 specs, 0 failures, 8 skippedMACOS_TEST_ENGINE=hermes MACOS_TEST_FFI_BACKEND=direct MACOS_TEST_GSD_BACKEND=none ... node scripts/run-tests-macos.js build/test-results/macos-hermes-gsd-off-junit.xml-> 713 specs, 0 failures, 8 skipped./scripts/build_react_native_turbomodule.sh --no-packnode benchmarks/objc-dispatch/run.js --runtime napi-node --iterations 250000 --include-gsd-offnode benchmarks/objc-dispatch/run.js --runtime ios-package --package-tgz packages/ios-{v8,jsc,quickjs,hermes}/dist/*.tgz --variant-label <engine> --iterations 250000 --include-gsd-offRN Hermes JSI relaunch benchmark, 3 GSD-on launches and 3 GSD-off launches -> 486/489 passed, 0 failed on every launch
NO_UPDATE_VERSION=1 IOS_VARIANT=ios-<engine> NPM_PACKAGE_NAME=@nativescript/ios-<engine>-napi-bench NPM_PACKAGE_VERSION=0.0.0-napi-bench NPM_PACK_DESTINATION=/tmp/nsr-napi-engine-packages ./scripts/build_all_ios.sh --<engine> --ffi-napi --gsd-napi --no-iphone --simulator --no-macosfor V8/JSC/QuickJS/Hermes generic Node-API packagesnode benchmarks/objc-dispatch/run.js --runtime ios-package --package-tgz /tmp/nsr-napi-engine-packages/nativescript-ios-<engine>-napi-bench-0.0.0-napi-bench.tgz --variant-label "<engine> generic Node-API" --iterations 250000 --include-gsd-offfor V8/JSC/QuickJS/HermesThe metadata generation steps still print existing SDK/private-header diagnostics, but the commands exit successfully.
Benchmarks
Lower is better.
GSD effectisGSD-off / GSD-on - 1, so positive means generated signature dispatch is faster. Objective-C dispatch benchmarks used 250k base iterations.napi-nodeis one measured instance of the generic Node-API FFI backend running through the macOS Node runtime; the same generic backend was also measured inside the Node-API surface exposed by the V8, JSC, QuickJS, and Hermes iOS engine packages. Direct backend rows measure the PR's engine-native FFI paths.Objective-C Dispatch Totals
Environment: local Apple Silicon Mac. iOS package apps ran on iPhone 16 Pro iOS 18.5 Simulator. Generic Node-API engine packages were temporary benchmark tarballs built with
--ffi-napi --gsd-napi; direct engine packages used the PR package tarballs.Engine-Native Direct Backends
Generic Node-API Backend
Direct vs Generic Node-API
Objective-C Dispatch Cases: Engine-Native Direct
V8
JSC
QuickJS
Hermes
Objective-C Dispatch Cases: Generic Node-API
The
macOS nodetable is the generic Node-API backend running under Node. The engine tables are the same generic FFI backend built into each iOS engine package viaNS_FFI_BACKEND=napi.macOS node
V8 iOS package
JSC iOS package
QuickJS iOS package
Hermes iOS package
React Native TurboModule FFI
Environment: RN Hermes JSI app, iPhone 17 Pro iOS 26.5 Simulator, 3 GSD-on launches and 3 GSD-off launches. Every launch passed the FFI compat suite: 486/489 passed, 0 failed, 4 skipped. Values are median ns/op.
GSD helps RN on covered direct Hermes JSI FFI calls such as
NSObject.respondsToSelector,NSString.length, delegate callback dispatch,UIViewController.new, andUITabBarController.alloc. It does not help paths that intentionally bypass generated dispatch, especiallyrunOnUI/UIKit thread-hop work and init/super-special cases, where the remaining cost is UIKit, scheduling, or required marshalling.React Native Worklets Prototype
The Worklets integration is intentionally optional. Native code uses
__has_include(<worklets/Compat/Holders.h>); apps withoutreact-native-workletskeep the existing TurboModule behavior andinstallWorklets()returnsfalse. When Worklets headers are present,installWorkletRuntime()verifies the object fromWorklets.getUIRuntimeHolder()hasworklets::WorkletRuntimeHolderNativeState, then runs a synchronous install insideholder->runtime_with the bundled NativeScript metadata path.JS stores the Worklets adapter only after native installation succeeds.
NativeScript.runOnUI()then delegates only whenWorklets.isWorkletFunction(callback)is true; otherwise the existing host-threadrunOnUIpath remains the fallback. One behavior to document for app authors: the Worklets Babel plugin auto-workletizes callbacks by callee property name, so a call spelledNativeScript.runOnUI(...)may become a worklet when the plugin is enabled even though it is not imported from Worklets.Hermes Prototype Native-Call Follow-up
Environment: High Power Mode, iPhone 16 Pro iOS 18.5 Simulator, 250k base iterations. These totals include two extra JS-to-native baseline cases added after the earlier all-engine table, so compare this section within itself.
Validation added for this follow-up:
IOS_VARIANT=ios-hermes ./scripts/build_all_ios.sh --hermesnode benchmarks/objc-dispatch/run.js --runtime ios-package --package-tgz packages/ios-hermes/dist/nativescript-ios-hermes-0.0.2.tgz --variant-label hermes-before-selector-fastpath --iterations 250000 --include-gsd-off --work-root build/benchmarks/objc-dispatch-hermes-prototype-beforenode benchmarks/objc-dispatch/run.js --runtime ios-package --package-tgz packages/ios-hermes/dist/nativescript-ios-hermes-0.0.2.tgz --variant-label hermes-after-selector-fastpath --iterations 250000 --include-gsd-off --work-root build/benchmarks/objc-dispatch-hermes-prototype-aftertest/cli/memory/run_memory_tests_all_engines.sh-> V8, QuickJS, JSC, and Hermes all completed; each engine runs the 10-case memory/ownership/FFI stress suite underset -e.The follow-up optimization skips redundant selector-group target resolution for already-prepared non-property calls. It improved the Hermes GSD-on total by 46.50ms (-4.3%) in this run, with the ObjC-only portion dropping 41.45ms (-4.2%). GSD-off moved only 8.54ms (-0.7%), which is expected because the optimized path is the prepared/GSD hot path.
JS-to-native baseline
The benchmark now measures an actual native function both directly and through a plain JS prototype. This is not a HostObject Objective-C instance method; it uses
performance.now, so it includes the timer body, but it gives the right order-of-magnitude baseline for a JS-to-native call on Hermes.For comparison, after the selector fast path the covered Objective-C bridge calls are still materially above that native-function floor, but GSD now helps Hermes on the hot cases:
Nitro architecture notes
Primary sources reviewed: HybridObject.cpp, HybridObject.hpp, HybridObjectPrototype.cpp, Prototype.hpp, HybridFunction.hpp, PropNameIDCache.hpp, PropNameIDCache.cpp, and the Hybrid Objects docs.
Nitro's important performance shape is: create a plain JS object with
Object.create(prototype), attach the native object as JSI NativeState, cache the JS wrapper per runtime with a weak object, install host functions once on cached prototypes, and cache property-name IDs per runtime. That avoids per-member HostObject lookup on normal method access. Hermes supports the required JSI object primitives, so a Nitro-style Hermes object model is possible, but it is a larger architectural change than this patch: our conversion, receiver lookup, expando, wrapper finalization, ownership, class-builder, and native-object identity paths currently key offNativeApiObjectHostObject. The safe next optimization would be a Hermes-only NativeState-backed instance compatibility layer with stress coverage before replacing HostObject instance wrappers.Follow-up experiment result for this PR: JSI NativeState cannot be attached to the current NativeScript instance wrappers because they are HostObjects; JSI
Object::setNativeStatedocuments that it throws for HostObjects and proxies. Replacing wrappers with plain JS objects plus NativeState would require moving dynamic Objective-C dispatch to prototype-installed accessors/methods first and reworking theNativeApiObjectHostObjectidentity, conversion, expando, ownership, class-builder, and finalization paths. Decision: do not land that architecture in this PR; keep the current HostObject/prototype hybrid and limit this branch to backend organization, generated dispatch, and the optional Worklets runtime install.