-
Notifications
You must be signed in to change notification settings - Fork 0
Performance and Benchmarks
The practical rule is simple:
Small scalar calls are fine. If you already have a collection, send it as one batch. This matters most when C++ would otherwise call Kotlin or Java once for every item.
Large strings and byte arrays cost more because the generated boundary copies them. Work that can wait or take noticeable time should be async so it does not block JavaScript.
| Your work | Good choice |
|---|---|
| One number or small state query | A normal C++ or Kotlin/Java export is fine. |
| Android API or JVM library call | Use Kotlin/Java. The extra scalar JNI hop was about one microsecond in these tests. |
| Existing C/C++ library or persistent native object | Use C++. Direct JSI had the lowest fixed call cost. |
| One live pen, touch, or UI event | An individual scalar call is reasonable. Do not invent a byte buffer for one point. |
| A collection already exists | Send one batch and run the loop in the implementation language. |
| C++ must send many values to Kotlin/Java | Cross generated JNI once for the batch. Do not call the internal JVM route once per item. |
| Large string or byte buffer | Keep it on the side that uses it when possible. Return a small result instead of echoing the data. |
| File, database, network, or long computation | Make it async so it does not block JavaScript. |
| Hundreds of async operations | Limit concurrency and handle RESOURCE_EXHAUSTED. |
Choose the language that fits the work first. A tiny bridge difference matters less than using the right Android API, library, data owner, or threading model.
sn-module-gen 0.1.1 does not claim a new public benchmark run. The
measurements below are retained pre-public V1/V2 development evidence from the
same underlying implementation lineage. They are useful for scale and
boundary-design guidance, but they are not fresh 0.1.1 measurements.
These are rounded results from the earlier V2 benchmark fixtures on one physical Supernote Nomad. They remain useful scale guidance for inherited scalar/copy routes, but they are not a claim that every current object or composite route has the same measured cost.
| Test | Measured result | What it tells you |
|---|---|---|
| Small number through V2 C++ | about 1.8-2.3 us per call | Normal UI-sized calls are cheap. |
| Small number through V2 Kotlin/Java | about 2.6-3.7 us per call | JNI adds a small fixed cost for scalar calls. |
| 16-character string | C++ 2.8 us; JVM about 52 us | Repeated string conversion is more visible on the JVM route. |
| 4,096 C++ -> Kotlin calls, one point each | 78.0 ms | Do not cross generated JNI inside a large native loop. |
| The same 4,096-point operation, one JVM call | 0.07 ms control; 3.45 ms with a real packed 32 KiB copy | One real batch was still about 23 times faster than 4,096 calls. |
| 1 MiB byte round trip | C++ 113 ms; Kotlin 185 ms | Large copied values dominate the bridge cost. |
| Worker-backed async scheduling | about 0.3-0.5 ms per operation | Use async for behavior, not for a faster trivial call. |
The rest of this page shows where those numbers came from and where they do and do not apply.
For a trivial double -> double operation, one complete run measured:
| Generated route | Median per call |
|---|---|
| V2 JavaScript -> JSI -> C++ | 1.805 us |
| V2 JavaScript -> JSI -> JNI -> Kotlin | 2.582 us |
| V2 JavaScript -> JSI -> JNI -> Java | 2.604 us |
| V1 JavaScript -> JSI -> C++ | 2.944 us |
| V1 Native Module -> Kotlin, sequential Promise | 729.377 us |
| V1 JNI -> C++, sequential Promise | 725.512 us |
The legacy V1 Promise routes and the V2 synchronous routes do not expose the same behavior, so this is not a Kotlin-versus-C++ language comparison. It shows the cost a caller actually paid through each generated API.
A second fixture measured about 2.3 us for C++ and 3.6-3.7 us for Kotlin/Java. At those rates:
1,000 C++ scalar calls about 2.3 ms
1,000 Kotlin scalar calls about 3.6 ms
10,000 C++ scalar calls about 23 ms
10,000 Kotlin scalar calls about 36 ms
One call for each live input event is reasonable. A loop containing thousands of synchronous calls can still pause the plugin because JavaScript cannot do anything else until that loop finishes.
The clearest problem appeared when C++ called an internal Kotlin route once for every point:
C++ loop C++
-> generated JNI per point -> one generated JNI call
-> Kotlin runs the loop
| Points | One JNI call per point | One JVM call for the operation |
|---|---|---|
| 4 | 83.3 us | 24.8 us |
| 64 | 1.216 ms | 25.2 us |
| 1,024 | 19.670 ms | 36.9 us |
| 4,096 | 78.049 ms | 69.6 us |
Each generated internal call checks the feature session, obtains the JNI environment and cached route, converts values, checks JVM exceptions, and maps failures. Those checks are useful. Repeating them thousands of times inside a tight loop is not.
The one-call column is a cheap control where Kotlin could produce the points itself. A more realistic test packed C++-owned coordinates into one 32 KiB buffer, copied it to Kotlin, and decoded it there. That took about 3.45 ms, still about 23 times faster than 4,096 separate calls.
For JavaScript-side points, a prebuilt packed buffer became faster somewhere between 4 and 16 points in this fixture. Packing has its own cost, so do it for a real collection—not for one number or one point.
String conversion becomes visible sooner on the JVM route:
| String | C++ | Kotlin | Java |
|---|---|---|---|
| 16 characters | 2.759 us | 51.904 us | 51.637 us |
| 4 KiB | 92.477 us | 609.667 us | 614.421 us |
This does not mean you should avoid Kotlin strings. If Kotlin owns the text and does the useful work, leave it there and return the result instead of sending the same text back and forth.
The current byte boundary deliberately uses copied ownership. The historical
benchmark copied the visible Uint8Array into the implementation, returned
it, and verified a newly owned JavaScript array:
| Visible bytes | C++ round trip | Kotlin round trip |
|---|---|---|
| 4 KiB | 0.472 ms | 0.767 ms |
| 64 KiB | 7.057 ms | 11.689 ms |
| 1 MiB | 112.834 ms | 184.737 ms |
A separate C++ run measured about 454.6 ms at 4 MiB. For a large file, image, page, or stroke buffer:
- keep the data on the side that already owns it;
- return a checksum, status, dimensions, or another small result when that is all the caller needs;
- cross once when the data really must move; and
- make copying plus processing async when it could block JavaScript.
These are binding-copy results, not file I/O results. They do not tell you how quickly the tablet can read from storage.
The worker-backed fixture reached the shared worker and returned a tiny byte array. Scheduling and completion cost about:
sequential calls 0.49 ms per operation
bursts of 16-256 0.27-0.30 ms per operation
The Kotlin suspend fixture included delay(1), so it is not directly
comparable to the no-delay worker fixture. Both routes still use the same
Promise, error, cancellation, and teardown rules.
Async is a behavior choice. Use it when work can block or wait, even though it costs more than a trivial synchronous call.
All benchmark bursts up to 256 operations completed. A separate pressure test included a 512-coroutine burst and mixed worker/coroutine fan-out. Across seven runs:
- one launch was partly rejected with
RESOURCE_EXHAUSTED; - four mixed bursts kept JavaScript too busy for a coarse 5 ms heartbeat until the burst finished; and
- there was no fatal exception, native crash, or out-of-memory failure.
The queue is bounded on purpose. It rejects excess work instead of growing
forever, but your plugin should still limit concurrency rather than submitting
hundreds of operations blindly. See Error Handling for how
to handle RESOURCE_EXHAUSTED.
PluginHost proportional set size grew by 18.6 MiB, then 6.2 MiB, then 4.3 MiB over three complete benchmark runs without forced garbage collection. A later pressure sequence ended below its original pre-pressure snapshot.
That did not show simple monotonic retention, but short PSS snapshots cannot separate caches, allocator high-water marks, garbage waiting to be collected, and real leaks. The covered stress runs completed without a fatal generated-runtime failure; they are not a long-running leak proof.
The final runs used one physical Supernote Nomad on August 13, 2026:
Android: 11 / API 30 / arm64-v8a
React Native: 0.79.2
Build type: debug
CPU governor: interactive
The fixtures timed the complete JavaScript call, including generated validation, conversion, JSI, JNI, and result conversion. They warmed each route, checked returned values, collected multiple samples, and completed three full runs in one PluginHost process.
Use the results to choose a sensible boundary, then measure your real plugin on its target device. Different firmware, release optimization, background load, data layout, and actual implementation work can change the result.
For the generated native-to-JVM call flow and detailed internal façade, read Using Both Environments.