From 9f581494af364b3cd424e5b1c61ce4aab777cf12 Mon Sep 17 00:00:00 2001 From: Marc Rousavy Date: Wed, 9 Sep 2026 23:46:31 +0200 Subject: [PATCH 1/3] perf: experiment with bound HybridObject functions --- apps/example/src/getTests.ts | 38 ++++++ .../cpp/core/HybridFunction.hpp | 109 ++++++++++++------ .../cpp/core/HybridObject.cpp | 8 +- .../cpp/prototype/HybridObjectPrototype.cpp | 47 ++++++++ .../cpp/prototype/HybridObjectPrototype.hpp | 6 + 5 files changed, 170 insertions(+), 38 deletions(-) diff --git a/apps/example/src/getTests.ts b/apps/example/src/getTests.ts index e6dbf5de82..18926b0cde 100644 --- a/apps/example/src/getTests.ts +++ b/apps/example/src/getTests.ts @@ -309,6 +309,44 @@ export function getTests( .equals(true) ), + // Bound HybridObject performance experiment + createTest('HybridObject methods are bound to their native receiver', () => + it(() => { + const { equals } = testObject + const other = testObject.newTestObject() + return equals(testObject) && !equals.call(other, other) + }) + .didNotThrow() + .equals(true) + ), + createTest( + 'HybridObject accessors are bound to their native receiver', + () => + it(() => { + const other = testObject.newTestObject() + other.numberValue = 100 + const descriptor = Object.getOwnPropertyDescriptor( + testObject, + 'numberValue' + )! + descriptor.set!.call(other, 42) + return descriptor.get!.call(other) === 42 && other.numberValue === 100 + }) + .didNotThrow() + .equals(true) + ), + createTest('HybridObject methods have per-instance identity', () => + it(() => { + const other = testObject.newTestObject() + return ( + Object.prototype.hasOwnProperty.call(testObject, 'simpleFunc') && + testObject.simpleFunc !== other.simpleFunc + ) + }) + .didNotThrow() + .equals(true) + ), + // Test Primitives (getters & setters) createTest('set numberValue to 13', () => it(() => (testObject.numberValue = 13)).didNotThrow() diff --git a/packages/react-native-nitro-modules/cpp/core/HybridFunction.hpp b/packages/react-native-nitro-modules/cpp/core/HybridFunction.hpp index c6c88aa21b..2b1e735fb9 100644 --- a/packages/react-native-nitro-modules/cpp/core/HybridFunction.hpp +++ b/packages/react-native-nitro-modules/cpp/core/HybridFunction.hpp @@ -49,7 +49,10 @@ using RawInstanceMethod = InstanceMethod< */ class HybridFunction final { private: + using BindFunction = std::function&)>; + jsi::HostFunctionType _function; + BindFunction _bindFunction; size_t _paramCount; std::string _name; @@ -72,9 +75,15 @@ class HybridFunction final { _function); } + // Performance experiment: resolve the native type once, then capture it in the HostFunction. + inline jsi::Function toBoundJSFunction(jsi::Runtime& runtime, const std::shared_ptr& instance) const { + return jsi::Function::createFromHostFunction(runtime, PropNameIDCache::get(runtime, _name), static_cast(_paramCount), + _bindFunction(instance)); + } + private: - HybridFunction(jsi::HostFunctionType&& function, size_t paramCount, const std::string& name) - : _function(std::move(function)), _paramCount(paramCount), _name(name) {} + HybridFunction(jsi::HostFunctionType&& function, BindFunction&& bindFunction, size_t paramCount, const std::string& name) + : _function(std::move(function)), _bindFunction(std::move(bindFunction)), _paramCount(paramCount), _name(name) {} public: /** @@ -94,43 +103,19 @@ class HybridFunction final { // 1. Get actual `HybridObject` instance from `thisValue` (it's stored as `NativeState`) std::shared_ptr hybridInstance = getHybridObjectNativeState(runtime, thisValue, kind, name); - // 2. Make sure the given arguments match, either with a static size, or with potentially optional arguments size. - constexpr size_t optionalArgsCount = trailing_optionals_count_v; - constexpr size_t maxArgsCount = sizeof...(Args); - constexpr size_t minArgsCount = maxArgsCount - optionalArgsCount; - bool isWithinArgsRange = (count >= minArgsCount && count <= maxArgsCount); - if (!isWithinArgsRange) [[unlikely]] { - // invalid amount of arguments passed! - std::string funcName = getHybridFuncFullName(kind, name, hybridInstance.get()); - if constexpr (minArgsCount == maxArgsCount) { - // min and max args length is the same, so we don't have any optional parameters. fixed count - throw jsi::JSError(runtime, "`" + funcName + "` expected " + std::to_string(maxArgsCount) + " arguments, but received " + - std::to_string(count) + "!"); - } else { - // min and max args length are different, so we have optional parameters - variable length arguments. - throw jsi::JSError(runtime, "`" + funcName + "` expected between " + std::to_string(minArgsCount) + " and " + - std::to_string(maxArgsCount) + " arguments, but received " + std::to_string(count) + "!"); - } - } + return callWithArguments(hybridInstance.get(), method, kind, name, runtime, args, count); + }; - try { - // 3. Actually call the method with JSI values as arguments and return a JSI value again. - // Internally, this method converts the JSI values to C++ values using `JSIConverter`. - return callMethod(hybridInstance.get(), method, runtime, args, count, std::index_sequence_for{}); - } catch (const std::exception& exception) { - // Some exception was thrown - add method name information and re-throw as `JSError`. - std::string funcName = getHybridFuncFullName(kind, name, hybridInstance.get()); - std::string message = exception.what(); - throw jsi::JSError(runtime, funcName + ": " + message); - } catch (...) { - // Some unknown exception was thrown - add method name information and re-throw as `JSError`. - std::string funcName = getHybridFuncFullName(kind, name, hybridInstance.get()); - std::string errorName = TypeInfo::getCurrentExceptionName(); - throw jsi::JSError(runtime, "`" + funcName + "` threw an unknown " + errorName + " error."); - } + BindFunction bindFunction = [name, method, kind](const std::shared_ptr& instance) -> jsi::HostFunctionType { + auto hybridInstance = std::dynamic_pointer_cast(instance); + return [hybridInstance = std::move(hybridInstance), name, method, kind](jsi::Runtime& runtime, const jsi::Value&, + const jsi::Value* args, size_t count) -> jsi::Value { + // No JS receiver lookup, RTTI, or shared_ptr copy on the call path. + return callWithArguments(hybridInstance.get(), method, kind, name, runtime, args, count); + }; }; - return HybridFunction(std::move(hostFunction), sizeof...(Args), name); + return HybridFunction(std::move(hostFunction), std::move(bindFunction), sizeof...(Args), name); } /** @@ -154,7 +139,57 @@ class HybridFunction final { return (pointer->*method)(runtime, thisValue, args, count); }; - return HybridFunction(std::move(hostFunction), expectedArgumentsCount, name); + BindFunction bindFunction = [method](const std::shared_ptr& instance) -> jsi::HostFunctionType { + auto hybridInstance = std::dynamic_pointer_cast(instance); + return [hybridInstance = std::move(hybridInstance), method](jsi::Runtime& runtime, const jsi::Value& thisValue, + const jsi::Value* args, size_t count) -> jsi::Value { + // Raw methods still receive the caller's JS thisValue, but use the captured native instance. + return (hybridInstance.get()->*method)(runtime, thisValue, args, count); + }; + }; + + return HybridFunction(std::move(hostFunction), std::move(bindFunction), expectedArgumentsCount, name); + } + +private: + template + static inline jsi::Value callWithArguments(THybrid* NON_NULL hybridInstance, InstanceMethod method, + FunctionKind kind, const std::string& name, jsi::Runtime& runtime, + const jsi::Value* NON_NULL args, size_t count) { + // 2. Make sure the given arguments match, either with a static size, or with potentially optional arguments size. + constexpr size_t optionalArgsCount = trailing_optionals_count_v; + constexpr size_t maxArgsCount = sizeof...(Args); + constexpr size_t minArgsCount = maxArgsCount - optionalArgsCount; + bool isWithinArgsRange = (count >= minArgsCount && count <= maxArgsCount); + if (!isWithinArgsRange) [[unlikely]] { + // invalid amount of arguments passed! + std::string funcName = getHybridFuncFullName(kind, name, hybridInstance); + if constexpr (minArgsCount == maxArgsCount) { + // min and max args length is the same, so we don't have any optional parameters. fixed count + throw jsi::JSError(runtime, "`" + funcName + "` expected " + std::to_string(maxArgsCount) + " arguments, but received " + + std::to_string(count) + "!"); + } else { + // min and max args length are different, so we have optional parameters - variable length arguments. + throw jsi::JSError(runtime, "`" + funcName + "` expected between " + std::to_string(minArgsCount) + " and " + + std::to_string(maxArgsCount) + " arguments, but received " + std::to_string(count) + "!"); + } + } + + try { + // 3. Actually call the method with JSI values as arguments and return a JSI value again. + // Internally, this method converts the JSI values to C++ values using `JSIConverter`. + return callMethod(hybridInstance, method, runtime, args, count, std::index_sequence_for{}); + } catch (const std::exception& exception) { + // Some exception was thrown - add method name information and re-throw as `JSError`. + std::string funcName = getHybridFuncFullName(kind, name, hybridInstance); + std::string message = exception.what(); + throw jsi::JSError(runtime, funcName + ": " + message); + } catch (...) { + // Some unknown exception was thrown - add method name information and re-throw as `JSError`. + std::string funcName = getHybridFuncFullName(kind, name, hybridInstance); + std::string errorName = TypeInfo::getCurrentExceptionName(); + throw jsi::JSError(runtime, "`" + funcName + "` threw an unknown " + errorName + " error."); + } } private: diff --git a/packages/react-native-nitro-modules/cpp/core/HybridObject.cpp b/packages/react-native-nitro-modules/cpp/core/HybridObject.cpp index 65c294ea39..e0fd9722bb 100644 --- a/packages/react-native-nitro-modules/cpp/core/HybridObject.cpp +++ b/packages/react-native-nitro-modules/cpp/core/HybridObject.cpp @@ -86,7 +86,13 @@ jsi::Value HybridObject::toObject(jsi::Runtime& runtime) { jsi::Object object = CommonGlobals::Object::create(runtime, prototype); // 4. Assign NativeState to the object so the prototype can resolve the native methods - object.setNativeState(runtime, shared()); + auto instance = shared(); + object.setNativeState(runtime, instance); + + // Performance experiment: shadow every prototype member with a HostFunction bound to this instance. + // Remove this call to compare against the shared-prototype/NativeState dispatch baseline. + // Bound functions retain the native object even after dispose() clears its NativeState. + bindHybridFunctions(runtime, object, instance); // 5. Set memory size so Hermes GC knows about actual memory object.setExternalMemoryPressure(runtime, getExternalMemorySize()); diff --git a/packages/react-native-nitro-modules/cpp/prototype/HybridObjectPrototype.cpp b/packages/react-native-nitro-modules/cpp/prototype/HybridObjectPrototype.cpp index 62ab82a6a9..77e867d51a 100644 --- a/packages/react-native-nitro-modules/cpp/prototype/HybridObjectPrototype.cpp +++ b/packages/react-native-nitro-modules/cpp/prototype/HybridObjectPrototype.cpp @@ -10,6 +10,7 @@ #include "NitroDefines.hpp" #include "NitroLogger.hpp" #include "NitroTypeInfo.hpp" +#include namespace margelo::nitro { @@ -127,4 +128,50 @@ jsi::Value HybridObjectPrototype::getPrototype(jsi::Runtime& runtime) { return createPrototype(runtime, _prototypeChain.getPrototype()); } +void HybridObjectPrototype::bindHybridFunctions(jsi::Runtime& runtime, const jsi::Object& object, + const std::shared_ptr& instance) { + ensureInitialized(); + auto descriptors = CommonGlobals::Object::create(runtime, jsi::Value::null()); + std::unordered_set installedNames; + + // Visit derived prototypes first so their own descriptors shadow the entire base property. + for (auto prototype = _prototypeChain.getPrototype(); prototype != nullptr; prototype = prototype->getBase()) { + for (const auto& [name, method] : prototype->getMethods()) { + if (!installedNames.insert(name).second) { + continue; + } + auto descriptor = CommonGlobals::Object::create(runtime, jsi::Value::null()); + descriptor.setProperty(runtime, "enumerable", true); + descriptor.setProperty(runtime, "value", method.toBoundJSFunction(runtime, instance)); + descriptors.setProperty(runtime, name.c_str(), std::move(descriptor)); + } + + auto addProperty = [&](const std::string& name) { + if (!installedNames.insert(name).second) { + return; + } + auto descriptor = CommonGlobals::Object::create(runtime, jsi::Value::null()); + descriptor.setProperty(runtime, "enumerable", true); + auto getter = prototype->getGetters().find(name); + if (getter != prototype->getGetters().end()) { + descriptor.setProperty(runtime, "get", getter->second.toBoundJSFunction(runtime, instance)); + } + auto setter = prototype->getSetters().find(name); + if (setter != prototype->getSetters().end()) { + descriptor.setProperty(runtime, "set", setter->second.toBoundJSFunction(runtime, instance)); + } + descriptors.setProperty(runtime, name.c_str(), std::move(descriptor)); + }; + for (const auto& [name, getter] : prototype->getGetters()) { + addProperty(name); + } + for (const auto& [name, setter] : prototype->getSetters()) { + addProperty(name); + } + } + + auto defineProperties = runtime.global().getPropertyAsObject(runtime, "Object").getPropertyAsFunction(runtime, "defineProperties"); + defineProperties.call(runtime, object, std::move(descriptors)); +} + } // namespace margelo::nitro diff --git a/packages/react-native-nitro-modules/cpp/prototype/HybridObjectPrototype.hpp b/packages/react-native-nitro-modules/cpp/prototype/HybridObjectPrototype.hpp index 522f991428..0d64780a28 100644 --- a/packages/react-native-nitro-modules/cpp/prototype/HybridObjectPrototype.hpp +++ b/packages/react-native-nitro-modules/cpp/prototype/HybridObjectPrototype.hpp @@ -46,6 +46,12 @@ class HybridObjectPrototype { static std::unordered_map _prototypeCache; protected: + /** + * Performance experiment: install own functions/accessors that retain their native receiver. + * The shared prototype remains available, but calls through the instance bypass NativeState lookup. + */ + void bindHybridFunctions(jsi::Runtime& runtime, const jsi::Object& object, const std::shared_ptr& instance); + /** * Loads all Hybrid Methods that will be initialized in this Prototype. * This will only be called once for the first time the Prototype will be created, From 4e33f53c3fea0214efe44accf69c36f241be1799 Mon Sep 17 00:00:00 2001 From: Marc Rousavy Date: Thu, 10 Sep 2026 00:15:02 +0200 Subject: [PATCH 2/3] fix: keep bound HybridObject experiment tests responsive --- apps/example/src/getTests.ts | 117 ++++++++++++++++-------------- apps/example/src/utils.ts | 15 ++-- scripts/performance/run-device.ts | 4 +- 3 files changed, 74 insertions(+), 62 deletions(-) diff --git a/apps/example/src/getTests.ts b/apps/example/src/getTests.ts index 18926b0cde..a1644b8de9 100644 --- a/apps/example/src/getTests.ts +++ b/apps/example/src/getTests.ts @@ -47,6 +47,7 @@ export interface TestRunner { // 1) It's a lot of allocations and any VM (JS, JVM) will likely trigger GC // 2) In JVM, 51_200 is the limit for `jni::global_ref`s, then the app crashes - this intentionally exhausts that const MEMORY_LEAK_TEST_ALLOCATION_COUNT = 55_000 +const MEMORY_LEAK_TEST_TIMEOUT = 120_000 const EXTERNAL_MEMORY_TEST_SIZE = 1024 * 1024 const PARALLEL_HYBRID_OBJECT_TEST_TIMEOUT = 120_000 @@ -1735,67 +1736,77 @@ export function getTests( ), createTest( 'HybridObjects do not leak memory when automatically reclaimed by JS GC', - () => - it(() => { - const baselineAllocations = - NitroModules.debug_getTotalAllocatedHybridObjects() - const BATCH_SIZE = 1000 - - const objects: Array = [] - for (let i = 0; i < MEMORY_LEAK_TEST_ALLOCATION_COUNT; i++) { - const object = testObject.newTestObject() - object.numberValue = i - objects.push(object) - - if (objects.length >= BATCH_SIZE) { - objects.length = 0 - gc() + async () => + ( + await it(async () => { + const baselineAllocations = + NitroModules.debug_getTotalAllocatedHybridObjects() + const BATCH_SIZE = 1000 + + const objects: Array = [] + for (let i = 0; i < MEMORY_LEAK_TEST_ALLOCATION_COUNT; i++) { + const object = testObject.newTestObject() + object.numberValue = i + objects.push(object) + + if (objects.length >= BATCH_SIZE) { + objects.length = 0 + gc() + // Binding allocates functions per object; yield so Harness can send heartbeats. + await new Promise((resolve) => setTimeout(resolve, 0)) + } } - } - objects.length = 0 - gc() - gc() - gc() - - const currentAllocations = - NitroModules.debug_getTotalAllocatedHybridObjects() - const remainingAllocations = currentAllocations - baselineAllocations - // make sure that less than 10% of the total allocations are remaining, indicating GC ran for most of it. - const didDeleteMostObjects = - remainingAllocations < MEMORY_LEAK_TEST_ALLOCATION_COUNT * 0.1 - const result: { - baselineAllocations: number - currentAllocations: number - isEqual?: boolean - } = { - baselineAllocations: baselineAllocations, - currentAllocations: currentAllocations, - isEqual: didDeleteMostObjects, - } - if (!didDeleteMostObjects) { - delete result.isEqual - } - return result - }) + objects.length = 0 + gc() + gc() + gc() + + const currentAllocations = + NitroModules.debug_getTotalAllocatedHybridObjects() + const remainingAllocations = + currentAllocations - baselineAllocations + // make sure that less than 10% of the total allocations are remaining, indicating GC ran for most of it. + const didDeleteMostObjects = + remainingAllocations < MEMORY_LEAK_TEST_ALLOCATION_COUNT * 0.1 + const result: { + baselineAllocations: number + currentAllocations: number + isEqual?: boolean + } = { + baselineAllocations: baselineAllocations, + currentAllocations: currentAllocations, + isEqual: didDeleteMostObjects, + } + if (!didDeleteMostObjects) { + delete result.isEqual + } + return result + }, MEMORY_LEAK_TEST_TIMEOUT) + ) .didNotThrow() .toContain('isEqual') ), - createTest('HybridObjects dont leak memory with manual dispose()', () => - it(() => { - const BATCH_SIZE = 1000 + createTest( + 'HybridObjects dont leak memory with manual dispose()', + async () => + ( + await it(async () => { + const BATCH_SIZE = 1000 - for (let i = 0; i < MEMORY_LEAK_TEST_ALLOCATION_COUNT; i++) { - const object = testObject.newTestObject() - object.dispose() + for (let i = 0; i < MEMORY_LEAK_TEST_ALLOCATION_COUNT; i++) { + const object = testObject.newTestObject() + object.dispose() - if ((i + 1) % BATCH_SIZE === 0) { - gc() - } - } + if ((i + 1) % BATCH_SIZE === 0) { + gc() + await new Promise((resolve) => setTimeout(resolve, 0)) + } + } - gc() - }).didNotThrow() + gc() + }, MEMORY_LEAK_TEST_TIMEOUT) + ).didNotThrow() ), createTest('callWithOptional(undefined)', async () => ( diff --git a/apps/example/src/utils.ts b/apps/example/src/utils.ts index a517d22333..14bdaaa85a 100644 --- a/apps/example/src/utils.ts +++ b/apps/example/src/utils.ts @@ -20,7 +20,7 @@ export function findPrototypeWhere( } const HybridObjectPrototype = findPrototypeWhere( - NitroModules, + Object.getPrototypeOf(NitroModules), (obj) => Object.hasOwn(obj, 'toString') && Object.hasOwn(obj, 'equals') && @@ -30,13 +30,12 @@ if (HybridObjectPrototype == null) { throw new Error(`Failed to find HybridObject root prototype!`) } function isHybridObjectSubclass(obj: object): boolean { - if (!(obj.toString instanceof Function)) { - // it doesn't haven have .toString - return false - } - // If its .toString function is the same as the HybridObject prototype's - // .toString function, it means it is inheriting from HybridObject. - return obj.toString === HybridObjectPrototype?.toString + // Bound instances have their own functions. Identify the shared prototype + // by ancestry so we also recognize prototype objects without invoking getters. + return ( + obj === HybridObjectPrototype || + Object.prototype.isPrototypeOf.call(HybridObjectPrototype, obj) + ) } export function stringify(value: unknown): string { diff --git a/scripts/performance/run-device.ts b/scripts/performance/run-device.ts index 5bb71012d3..aefb0eb3c5 100644 --- a/scripts/performance/run-device.ts +++ b/scripts/performance/run-device.ts @@ -79,7 +79,9 @@ export async function runDeviceCase( '--benchmark-index', String(index), '--timeout-ms', - '120000', + // This experiment creates hundreds of HostFunctions per HybridObject. Keep + // the identical fixed workloads and all samples, but allow slow cases to finish. + String(20 * 60 * 1000), ], { stdout: 'inherit', From 18e254bfa521c3fc419f1fdddc12f70dfdeaa6e7 Mon Sep 17 00:00:00 2001 From: Marc Rousavy Date: Thu, 10 Sep 2026 00:32:18 +0200 Subject: [PATCH 3/3] test: collect garbage before external memory baselines --- apps/example/src/getTests.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/example/src/getTests.ts b/apps/example/src/getTests.ts index a1644b8de9..b73d29f77f 100644 --- a/apps/example/src/getTests.ts +++ b/apps/example/src/getTests.ts @@ -2659,6 +2659,8 @@ export function getTests( NitroModules.updateMemorySize(testObject) testObject.stringValue = 'x'.repeat(EXTERNAL_MEMORY_TEST_SIZE) + // Exclude unrelated, unreachable allocations from the global counter delta. + gc() const externalBytesBefore = getHermesExternalMemorySize() NitroModules.updateMemorySize(testObject) const externalBytesAfter = getHermesExternalMemorySize() @@ -2689,6 +2691,8 @@ export function getTests( () => it(() => { const size = EXTERNAL_MEMORY_TEST_SIZE + // Allocation can trigger GC, so collect earlier tests' buffers before taking the baseline. + gc() const externalBytesBefore = getHermesExternalMemorySize() const buffer = NitroModules.createNativeArrayBuffer(size) const externalBytesAfter = getHermesExternalMemorySize()