Skip to content

Performance and Benchmarks

s edited this page Sep 4, 2026 · 5 revisions

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.

What should I choose?

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.

Historical results at a glance

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.

Small calls are not the problem

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.

Batch a collection once

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.

Strings and bytes

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.

Async and backpressure

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.

Memory observations

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.

How this was tested

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.

Clone this wiki locally