refactor: organize direct FFI backends#43
Open
DjDeveloperr wants to merge 289 commits into
Open
Conversation
…property lookups)
…undant JS root property lookup)
…kJS/Hermes hot paths
…e interceptor (skips per-access metadata discovery for JSI engines)
Squashed Android C++ runtime refactor and Apple platform organization work after CI passed.
Merge Android runtime history into refactor without squashing.
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.