Skip to content

perf(android): convert strings directly through JNI - #1851

Merged
mrousavy merged 1 commit into
mrousavy/v1-android-direct-converter-infrastructurefrom
mrousavy/v1-android-jni-strings
Oct 21, 2026
Merged

mrousavy merged 1 commit into
mrousavy/v1-android-direct-converter-infrastructurefrom
mrousavy/v1-android-jni-strings

Conversation

@mrousavy

@mrousavy mrousavy commented Oct 20, 2026 •

Copy link
Copy Markdown
Member

Converts strings directly between JSI and JNI with getStringData and ASCII/UTF-16 copies. Avoids std::string on the direct path and preserves embedded NUL and lone surrogate code units.

Validation: Nitrogen typecheck and full spec generation. Generated output is included.

@vercel

vercel Bot commented Oct 20, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
nitro-docs Ready Ready Preview Oct 21, 2026 1:33pm UTC

Request Review

@mrousavy
mrousavy added this pull request to stack #1853 October 20, 2026 23:30
@mrousavy

mrousavy commented Oct 20, 2026 •

Copy link
Copy Markdown
Member Author

Update: #1858 adds the short-string optimization and new benchmarks, including cached and uncached JNI environments. The short ASCII sample below is NitroModules12345, which has 17 UTF-16 units; its original 16-unit label was incorrect. The historical timings are unchanged.

Large and Unicode strings improve here. Short ASCII strings regress, including 17-character strings in both directions and empty strings from JNI to JS. JSON results depend on payload size and direction.

Measured on an arm64 Android emulator with Hermes, a Release instrumentation APK, and native RelWithDebInfo (-O2 -DNDEBUG). The frozen STL path from 6efaeec2a and direct path run in the same binary and runtime. There are 3 independent process runs, each with 3 warmup batches and 9 measured pairs per case and direction; the order alternates and GC requests happen outside the timed batches. Times are medians of the 3 per-run medians. Ratios are medians of the per-run ratios; values below 1 mean a regression. These measurements cover conversion calls; full method latency and physical-device performance remain unmeasured.

All 24 non-timed validation groups passed in all 3 runs. The old path fails exact roundtrips for embedded NUL and lone surrogates, so those rows have no performance ratio. The direct path preserves both. Primitive scalar controls measured about 0.7-1 ns/op, too close to the loop floor for a performance claim.

Value Direction STL ns/op Direct ns/op STL / direct Range across runs
string-empty js-to-jni 84.9 80.2 1.05x 1.05-1.18x
string-empty jni-to-js 52.8 74.6 0.71x 0.70-0.71x
string-ascii-17 js-to-jni 106.1 118.1 0.90x 0.90-0.95x
string-ascii-17 jni-to-js 71.0 84.2 0.84x 0.84-0.86x
string-ascii-256 js-to-jni 870.5 271.5 3.18x 3.14-3.25x
string-ascii-256 jni-to-js 304.2 236.2 1.28x 1.26-1.29x
string-ascii-4096 js-to-jni 14220.7 3181.1 4.42x 4.41-4.50x
string-ascii-4096 jni-to-js 3093.2 2321.7 1.34x 1.33-1.34x
string-unicode-bmp js-to-jni 1127.3 143.9 7.88x 7.54-8.33x
string-unicode-bmp jni-to-js 383.5 81.6 4.70x 4.69-4.76x
string-surrogate-pairs js-to-jni 1087.3 160.3 6.95x 6.78-7.06x
string-surrogate-pairs jni-to-js 346.5 78.4 4.21x 4.14-4.42x
string-nul js-to-jni Invalid roundtrip 150.4 - -
string-nul jni-to-js Invalid roundtrip 79.1 - -
string-lone-surrogate js-to-jni Invalid roundtrip 124.3 - -
string-lone-surrogate jni-to-js Invalid roundtrip 80.6 - -
json-null js-to-jni 497.1 284.0 1.71x 1.64-1.75x
json-null jni-to-js 181.7 204.7 0.89x 0.82-0.96x
json-number js-to-jni 480.6 300.6 1.61x 1.48-1.77x
json-number jni-to-js 185.7 221.2 0.93x 0.84-0.94x
json-string-unicode js-to-jni 2131.8 597.5 4.33x 3.44-4.80x
json-string-unicode jni-to-js 644.4 258.4 2.50x 2.49-2.52x
json-object-small js-to-jni 776.0 534.8 1.64x 1.34-1.97x
json-object-small jni-to-js 458.9 460.4 0.96x 0.93-1.00x
json-object-nested js-to-jni 11952.5 12751.7 0.98x 0.92-1.00x
json-object-nested jni-to-js 8650.4 7124.6 1.22x 1.20-1.22x

@mrousavy

Copy link
Copy Markdown
Member Author

I compared cached JSON.parse with UTF-16 -> simdutf -> jsi::Value::createFromJsonUtf8. Keeping the current parser was faster for 16 of the 17 inputs. This uses the updated string converter from #1858 (adcecac20), so these timings supersede the earlier AnyJSON measurements for the current stack.

Three fresh process runs on an ARM64 Android emulator, with all implementations in the same APK. NDK 29, -O2 -DNDEBUG, no LTO; frozen Release Hermes/fbjni libraries. Each run has 3 warmup batches and 8 measured batches with balanced implementation order. No physical device was tested.

JNI-to-JSI conversion times with a cached JNI environment, including the AnyJSON getter, string conversion, parsing, and handle destruction:

JSON input Cached JSON.parse simdutf + native UTF-8 parse Current / candidate
null 79.6 ns 86.7 ns 0.916x
String, 16 ASCII characters 91.5 ns 114.2 ns 0.802x
String, 4096 ASCII characters 2519.8 ns 5648.1 ns 0.447x
String, 128 UTF-16 units, CJK + emoji 114.9 ns 436.9 ns 0.263x
String, 4096 UTF-16 units, CJK + emoji 892.8 ns 10943.5 ns 0.082x
Small ASCII object 330.0 ns 318.6 ns 1.047x
Nested ASCII object 8035.1 ns 8537.5 ns 0.947x
Nested Unicode object 7848.5 ns 12128.2 ns 0.642x

Below 1 means the candidate is slower. Times are medians of per-process median times; ratios are medians of paired batch ratios. An attached thread without fbjni's JNI environment cache showed the same overall result. Adding an ASCII scan/copy before SIMD did not help overall either.

The parser matters more than the removed function call here. Hermes's JSON.parse entry point selects its raw ASCII or UTF-16 lexer, which has SIMD scanning. The native UTF-8 entry point selects the streaming UTF-8 lexer. For the 4096-character ASCII string, parsing prebuilt input measured 256 ns through cached JSON.parse versus 4496 ns through the native UTF-8 API. Those supporting measurements exclude JNI and transcoding and run in fixed component order; they are not an additive breakdown of the total timings.

Correctness covered 66 inputs per context/run, including every JSON root, NUL, BOM, malformed tails, raw/escaped surrogates, and buffer boundaries. The current path and both SIMD candidates passed all comparisons. SIMD falls back to the complete original UTF-16 text on invalid UTF-16: JSON strings can contain raw lone surrogates, so replacing them would change the value. The separate fbjni toStdString() control failed five surrogate cases, including accepting valid JSON followed by malformed UTF-8 after conversion.

A JSI UTF-16 overload backed by Hermes's existing raw UTF-16 parser would avoid the temporary JS string and function call without this UTF-8 round trip. No simdutf dependency or converter change was added. The benchmark sources and results remain outside the repository.

@mrousavy
mrousavy force-pushed the mrousavy/v1-android-jni-strings branch from 22c28bc to b2e8454 Compare October 21, 2026 11:41
@mrousavy
mrousavy force-pushed the mrousavy/v1-android-jni-strings branch from c84a311 to 919d05e Compare October 21, 2026 12:30
@mrousavy
mrousavy removed this pull request from stack #1853 October 21, 2026 12:30
@mrousavy
mrousavy changed the base branch from mrousavy/v1-android-jni-types to mrousavy/v1-android-jni-converter-names October 21, 2026 12:30
@mrousavy
mrousavy added this pull request to stack #1862 October 21, 2026 12:30
@mrousavy
mrousavy force-pushed the mrousavy/v1-android-jni-strings branch from 919d05e to 93351a0 Compare October 21, 2026 12:48
@mrousavy
mrousavy force-pushed the mrousavy/v1-android-jni-strings branch from 93351a0 to 639d554 Compare October 21, 2026 13:32
@mrousavy
mrousavy removed this pull request from stack #1862 October 21, 2026 13:32
@mrousavy mrousavy changed the title perf(android): convert strings and AnyJSON directly through JNI perf(android): convert strings directly through JNI Oct 21, 2026
@mrousavy
mrousavy changed the base branch from mrousavy/v1-android-jni-converter-names to mrousavy/v1-android-direct-converter-infrastructure October 21, 2026 13:33
@mrousavy
mrousavy added this pull request to stack #1893 October 21, 2026 13:36
@mrousavy

Copy link
Copy Markdown
Member Author

Checked the JNI rules and benchmarked the string assembly alternatives before changing the implementation.

The GetStringCritical comment is too broad. JNI forbids arbitrary JNI calls and blocking inside a critical region, not every native allocation. Hermes string creation can enter a GC allocation path that acquires the GC mutex, so it should stay outside that region. The accurate rationale is to avoid holding an ART critical region across potentially blocking Hermes runtime work. Sources: JNI specification, Hermes allocation slow path.

The JS-to-JNI path already passes contiguous UTF-16 straight to NewString(data, length). jchar is a 16-bit UTF-16 code unit. ASCII is widened, preserving embedded NUL. JNI's NewStringUTF takes NUL-terminated modified UTF-8, without a length argument.

Measured in a separate Android API 36 arm64 emulator app, -O2 -DNDEBUG, with CheckJNI verified off through the JNI function table. Three fresh processes, seven interleaved samples each after warmup. Median ns per conversion:

JS-to-JNI input #1851 vector #1858 stack/heap Java concat StringBuilder
ASCII, 16 units 37 36 37 174
ASCII, 4,096 units 2,720 2,718 2,722 6,388
UTF-16, 4,096 units 1,336 1,374 1,375 5,564
Synthetic mixed, 16 fragments 321 288 3,868 2,427
Synthetic mixed, 64 fragments 1,450 1,177 47,606 9,285

Concat adopts the first fragment directly and only calls concat for later fragments. Every actual Hermes string tested, including repeated JS concatenation, delivered one callback. Multipart rows exercise the portable JSI contract synthetically; they are not Hermes ropes. The one-callback concat path is effectively the same NewString construction. StringBuilder timings include creating the builder even for one fragment; its fragmented rows are the relevant comparison here.

A valid JNI-to-JSI Critical alternative allocated scratch before acquiring, copied, released, then created the JSI string. Scratch allocation is included in timing:

Input GetStringChars Critical copy/release GetStringRegion
ASCII, 16 units 39 39 24
UTF-16, 16 units 34 35 23
ASCII, 4,096 units 2,239 2,335 2,265
UTF-16, 4,096 units 336 363 355

All 285 bit-exact checks passed, including embedded NUL, lone surrogates, and pairs split across synthetic fragments. Raw JNI was used equally for the candidates to isolate assembly costs; this is not an end-to-end Nitro/fbjni benchmark. These are emulator results, not physical-device latency estimates.

The results support keeping native buffer assembly for segmented input. They do not establish a general JS-to-JNI vector-to-stack speedup. Short JNI-to-JSI region copying does help here and is already in #1858. No implementation changes were made from this experiment. Benchmark sources and raw samples remain outside the repository.

@mrousavy
mrousavy force-pushed the mrousavy/v1-android-jni-strings branch from 639d554 to 0349b0a Compare October 21, 2026 15:20
@mrousavy
mrousavy removed this pull request from stack #1893 October 21, 2026 15:20
@mrousavy
mrousavy added this pull request to stack #1913 October 21, 2026 15:21
@mrousavy
mrousavy merged commit 8552b04 into v1 Oct 21, 2026
0 of 20 checks passed

This branch was successfully deployed

1 active deployment
Preview — 0349b0a3 Deployed Oct 21, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant