Repository navigation
Look up each TurboModule method's signature once on iOS - #59394
hryhoriiK97 wants to merge 3 commits into
Conversation
ObjCTurboModule::createMethodInvocation called methodSignatureForSelector: on every native call. The lookup walks the module's protocol list and costs about a quarter of invokeObjCMethod. Keep the signatures in a per-instance map, so each method is looked up once. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
| @@ -141,6 +142,11 @@ class JSI_EXPORT ObjCTurboModule : public TurboModule { | |||
| NSMutableDictionary<NSString *, NSMutableArray *> *methodArgConversionSelectors_; | |||
| NSDictionary<NSString *, NSArray<NSString *> *> *methodArgumentTypeNames_; | |||
There was a problem hiding this comment.
Can we combine this with the method-metadata here? Maybe create a single struct for each method?
There was a problem hiding this comment.
@javache good idea, done. Each method now has one MethodInfo struct (the signature, the argument type names and the RCTCxxConvert selectors) in a single map keyed by the method name, so the two NSDictionarys are gone. I re-ran the benchmark and updated the numbers in the description.
Replace methodArgConversionSelectors_, methodArgumentTypeNames_ and the signature map with a single MethodInfo per method, keyed by its JS name. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
| @@ -686,18 +685,19 @@ TraceSection s( | |||
| } | |||
|
|
|||
| NSString *normalizedOtherMethodName = [otherMethodName componentsSeparatedByString:@":"][0]; | |||
There was a problem hiding this comment.
nit: compute normalizedOtherMethodName as a C++ string view directly and avoid componentsSeparatedByString etc
| [methodArgConversionSelectors_[methodName] addObject:(id)kCFNull]; | ||
| } | ||
| std::lock_guard<std::mutex> lock(methodInfosMutex_); | ||
| std::vector<SEL> &selectors = findOrCreateMethodInfo(methodName.UTF8String).argConversionSelectors; |
There was a problem hiding this comment.
nit: call methodName.UTF8String once
Compute the normalized method name as a std::string_view instead of splitting the NSString, and read methodName.UTF8String once in setMethodArgConversionSelector. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
@javache thanks, both done! |
|
@javache has imported this pull request. If you are a Meta employee, you can view this in D127179102. |
|
This pull request was successfully merged by @hryhoriiK97 in 1d1fade When will my fix make it into a release? | How to file a pick request? |
## Summary: This is the Android counterpart of #59394. On Android, every call from JS to a Java TurboModule method goes through `JavaTurboModule::invokeJavaMethod` (`ReactCommon/react/nativemodule/core/platform/android/ReactCommon/JavaTurboModule.cpp`). On every call it parsed the method's JNI signature into a new `std::vector<std::string>` of argument types (`getMethodArgTypesFromSignature`), and for methods that return a number or a boolean it cut the return type out of the signature again with `substr`. Long type names such as `Lcom/facebook/react/bridge/ReadableMap;` don't fit in the small-string buffer, so each of them was a heap allocation on every call. A method's signature doesn't change, so this change parses it on the method's first call and keeps the result. Each `JavaTurboModule` now has one `MethodInfo` per method (the argument types and the return type), keyed by the method's `jmethodID`, which is already resolved at that point. A mutex guards the map. Entries are never removed while the module is alive, and `std::unordered_map` doesn't move its elements, so the returned reference stays valid for the rest of the call. The members are private, so there is no API change. Memory: one entry per method that is actually called, about 150–300 bytes each (the type strings and the map node). The same strings were already allocated on every call and freed right after. One side effect: `JavaTurboModule` now holds a `std::mutex`, so it can't be copied or moved. Copying a module was already deprecated (it has a user-declared destructor) and nothing in the repository copies one; generated modules are created with `std::make_shared`. ## Changelog: [ANDROID] [CHANGED] - Parse each TurboModule method's JNI signature once instead of on every call ## Test Plan: - `clang-format --dry-run --Werror` (the repository's pinned version) on the changed files passes. - RNTester built with Gradle in Release (`assembleRelease`, arm64-v8a) and run on a physical Samsung Galaxy A14 (Android 15). In the TurboModule example, "Run function call tests" returns the same values for all 18 methods with and without this change, including callbacks, a resolved and a rejected promise, and ArrayBuffers. - To run `SampleTurboModule` in a Release build I had to change two things locally, in the sample module only and in both builds: - In Release, creating it fails with `A launcher is already registered for key 'com.facebook.fbreact.specs.SampleTurboModule:e2'`. The launcher key is built from the contract class names, and R8 seems to merge the contract classes, so the three launchers get the same key. I passed an explicit key to each `registerForActivityResult` call. - It shows a `Toast` on every call, so I turned that off for the measurement. - **Measurement.** RNTester Playground calling `NativeSampleTurboModule` methods in a loop (20,000 calls each for the sync methods, 5,000 for the callback), timed with `performance.now()`. Two Release APKs, before and after this change, installed in the order before, after, after, before, 4 launches each after a warm-up launch. The presses were driven by [Argent](https://github.com/software-mansion/argent) on the physical phone. Mean time per call: | Call | before | this PR | change | |---|--:|--:|--:| | `getString` | 1.93 µs | 1.66 µs | −14% | | `getValue` (number, string, object) | 18.96 µs | 17.62 µs | −7% | | `getValueWithCallback` | 11.46 µs | 10.66 µs | −7% | | `getArray` (3 items) | 15.84 µs | 14.83 µs | −6% | | `getObject` (small) | 14.02 µs | 13.19 µs | −6% | For these rows, the ranges of the two arms don't overlap. `getNumber` (1.28 → 1.14 µs), `getBool`, the 20-key `getObject`, `voidFunc` and promises were within noise in this run. An earlier run of the same change behind a switch in one APK gave −5% to −20% for these calls and `getNumber` (−20%). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Differential Revision: D128040903 Pull Request resolved: #59414
Summary:
On iOS, every call from JavaScript to an Objective-C TurboModule method goes through
ObjCTurboModule::createMethodInvocation(ReactCommon/react/nativemodule/core/platform/ios/ReactCommon/RCTTurboModule.mm), which asks the module for the method's signature withmethodSignatureForSelector:. For methods declared in a protocol, the runtime looks the selector up in the module's protocol list (class_copyProtocolList,protocol_getMethodDescription) and builds a newNSMethodSignatureeach time. In a Time Profiler trace of a loop of sync calls on an iPhone 15 Pro, this lookup was about 25% ofinvokeObjCMethod.A method's signature doesn't change, so this change looks it up on the method's first call and keeps it. Each
ObjCTurboModulenow has oneMethodInfostruct per method, keyed by the method's JS name, which holds the signature together with the metadata that was already kept per method: the argument types fromRCT_EXPORT_METHODand theRCTCxxConvertselectors set by the generated code. This replacesmethodArgConversionSelectors_andmethodArgumentTypeNames_, two dictionaries keyed by anNSStringmethod name. The map is looked up with astd::string_view, so no string is allocated on a call. The cache is per instance and still callsmethodSignatureForSelector:on the module instance, so it keeps the behavior from #51366: a mock module is a different instance and gets its own cache. A mutex guards the map. The members are private, so there is no API change.Memory: one entry per method that is actually called. Running RNTester's TurboModule, Linking, AppState, Clipboard, Vibration, Share and Alert examples filled 40 entries across 12 modules. An
NSMethodSignaturefor a method with 4–6 arguments takes about 620 bytes, so this is about 25 KB. The signatures were already created on every call and released right after.Changelog:
[IOS] [CHANGED] - Look up each TurboModule method's signature once instead of on every call
Test Plan:
clang-format --dry-run --Werror(the repository's pinned version) on the changed files passes.RNTesterUnitTestslocally: with Xcode 26.3 the test build fails to linkRNTesterIntegrationTests(__swift_FORCE_LOAD_$_swiftCompatibility56not found), which is unrelated to this change.RCTTurboModuleArrayBufferTestscallsinvokeObjCMethoddirectly, so I'm relying on CI for it; the same paths are covered by the TurboModule example below.xcodebuildin Release and run on a physical iPhone 15 Pro (iOS 26.6.1). In the TurboModule example, "Run function call tests" returns the expected value for all 18 methods, including callbacks, a resolved and a rejected promise, and ArrayBuffers. The Alert example, whosealertWithArgsargument goes through anRCTCxxConvertselector, shows its alerts as before.NativeSampleTurboModulemethods in a loop (20,000 calls each for the sync methods, 5,000 forvoidFuncandgetValueWithCallback), measured withperformance.now(). Two Release builds, before and after this change, installed in the order before, after, after, before, 4 launches each. The presses were driven by Argent on the physical iPhone. Mean time per call:getNumbergetBoolgetStringgetArray(3 items)getObject(small)getObject(20 keys)getValuevoidFunc(async)getValueWithCallbackFor every row except
getNumber, the ranges of the two arms don't overlap.🤖 Generated with Claude Code