Kotlin/JVM Performance
Finding
The Kotlin/JVM facade has clear structural gains but the plain-loop crypto latency remains advisory. Identity import is 14.73% faster, event parsing and signing are faster, cached public-key access removes a JNA call, and 64-KiB crypto is neutral. The 32-byte and 1-KiB encryption measurements, plus 32-byte decryption, remain slower and have wide GC/JIT-sensitive tails.
This does not reproduce in native Rust or on the production Android JNI path. Both direct-payload and aggregate-record JVM crossings were tested during the optimization pass; their small-payload ranking moved between hosted runs. Do not set a JVM release threshold from this loop. Replace it with forked JMH measurements and allocation profiling first.
The final audit used three paired JVM processes on a hosted Ubuntu 24.04 x86_64 runner with Temurin Java 17.0.19. Values are nanoseconds per authored Kotlin call. Negative deltas mean the current SDK is faster.
| Operation | Bytes | Old median ns | Current median ns | Delta | Current p95 ns | p95 delta |
|---|---|---|---|---|---|---|
| Import identity | 32 | 198,689 | 169,432 | -14.73% | 384,613 | -9.78% |
| Read cached public key | 0 | 101,181 | 5 | -100.00% | 5 | -100.00% |
| NIP-44 encrypt | 32 | 211,476 | 274,097 | +29.61% | 538,004 | +6.85% |
| NIP-44 decrypt | 32 | 142,170 | 208,493 | +46.65% | 380,252 | +27.92% |
| NIP-44 encrypt | 1,024 | 112,725 | 171,717 | +52.33% | 300,648 | +27.02% |
| NIP-44 decrypt | 1,024 | 101,678 | 99,590 | -2.05% | 257,585 | +33.47% |
| NIP-44 encrypt | 64,000 | 452,967 | 445,990 | -1.54% | 478,146 | -27.77% |
| NIP-44 decrypt | 64,000 | 508,228 | 510,611 | +0.47% | 559,425 | +2.61% |
| Sign NIP-01 event | 32 | 212,960 | 204,906 | -3.78% | 259,817 | -4.77% |
| Parse and verify event | 374 | 86,604 | 84,743 | -2.15% | 131,131 | +7.82% |
The 5-ns getter is an API-shape check rather than an absolute latency claim.
Current-only baseline
| Operation | Bytes | Median ns | p95 ns |
|---|---|---|---|
| Create NIP-59 rumor | 15 | 153,025 | 214,991 |
| Create NIP-17 text message | 15 | 235,957 | 631,869 |
| Create durable gift wrap | 15 | 963,734 | 1,412,039 |
| Authenticate and unwrap | 15 | 285,722 | 432,626 |
| Stream-encrypt attachment | 64,000 | 502,816 | 851,059 |
Optimizations retained
SoftchatIdentity.importreturns the native handle and immutable public metadata in one call;publicKeyis then a Kotlin-owned value.- The Android JNI registry is absent from plain JVM native builds.
- JVM encryption below 4,096 characters and decryption below an 8,192-character encoded payload use the aggregate crossing; larger payloads avoid the extra aggregate copy. These thresholds are implementation details, not API limits.
- Android does not use either JVM branch: it selects the measured JNI byte-array path once an identity is imported.
- All public event, envelope, relay, and media values stay in the authored package; generated JNA records remain internal.
Footprint
| Artifact set | Old bytes | Current bytes | Delta |
|---|---|---|---|
| Authored and generated JARs | 209,822 | 773,170 | +268.49% |
JAR growth reflects the complete generated protocol binding and authored facade. The native library ships separately; see Rust performance. JAR size is not JVM RSS, and the current loop does not produce a trustworthy binding-specific memory number.
Developer guidance
- Import one identity per signed-in session and retain its cached
publicKey. - Run signing, encryption, parsing, wrapping, reconciliation, and attachment work on a bounded CPU dispatcher, not a latency-sensitive coroutine or the Android main thread.
- Pass complete bounded values across the facade. Avoid repeated generated calls for fields or tags.
- Use
com.softcose.softchat.protocol; never couple application code tocom.softcose.softchat.internal.ffior the Android JNI bridge. - Keep database transactions, socket lifecycle, WorkManager, HTTP, and files in platform code. Persist delivery intent before asking the relay session to send.
- Call
erase()only when identity authority must end early; normal ownership follows the authenticated session.
Use the physical Android report for Android migration decisions. Use the Performance Benchmarks source guide for the current JVM loop, but treat it as diagnostic only.
Open work
Add a JMH module with multiple forks, warm-up and measurement iterations,
Blackhole, allocation profiling, and a documented GC. Only then decide
whether the remaining small-payload signal belongs to JNA dispatch, record
lifting, allocation/GC, or the harness itself. No cryptographic cache or
secret-derived optimization is justified by the current data.