The script was hardcoded for Linux (searching for .so files and
linux-x86_64 paths). Add platform detection via uname so it finds
the correct native library on macOS (darwin .dylib) and Linux
(aarch64 and x86_64). Skip K/Native benchmark on non-Linux since
only linuxX64 target exists.
https://claude.ai/code/session_01JZbyrS9xZEtJ9Y4yfsmnz1
run_all.sh runs all secp256k1 benchmarks and produces a formatted
comparison table. Runs C native, Kotlin/Native, JVM (always), and
Android (only if device/emulator connected via adb).
Output includes:
- ops/sec with comma-separated numbers
- libsecp256k1 vs Quartz column headers
- verifySchnorrFast and signSchnorr (cached pk) as Quartz-only rows
- Ratio table: C vs K/Native, JNI vs JVM Kotlin (apples-to-apples)
- Android column and ratios when device is connected
Run from repo root: ./quartz/benchmarks/run_all.sh
https://claude.ai/code/session_015CtM5k88rF7WFgX8o2AGNR
Standalone C program that links against the ACINQ secp256k1-kmp-jni
.so to benchmark raw C libsecp256k1 performance without any JVM, JNI,
or ART overhead. Uses the same test vectors as the Kotlin benchmarks.
Useful as a baseline when comparing Kotlin/Native or Android results
against the C library on the same hardware.
https://claude.ai/code/session_015CtM5k88rF7WFgX8o2AGNR
Add optional PointScratch parameter to ECPoint.mul and ECPoint.mulG
(default to scratch.get() for backward compat). All callers in
Secp256k1.kt now pass their already-fetched scratch through.
From the ECDH trace: ECPoint.mul was calling scratch.get() (ThreadLocal)
redundantly — the caller already had the scratch. On ART, each
ThreadLocal.get costs ~6µs (hash table probe), and ECDH had 10 calls
totaling 64µs.
With this change, all hot-path EC operations (verify, sign, ECDH,
pubkey create, tweak mul) fetch the scratch once at the entry point
and pass it through the entire call chain.
https://claude.ai/code/session_015CtM5k88rF7WFgX8o2AGNR
Replace every non-inline uLt() call with uLtInline() across FieldP.kt,
U256.kt, and ScalarN.kt. The expect/actual uLt() can't be inline
(KMP limitation), costing ~84ns per call on ART as a real function
dispatch. From the trace: 12,394 uLt calls × 84ns = 1.035ms per
verify (1.2% of total).
uLtInline uses the same XOR-with-MIN_VALUE trick but as a package-level
inline function — zero dispatch overhead.
Also inline isInfinity() body directly: was delegating to U256.isZero()
(double dispatch), now computes (z[0] or z[1] or z[2] or z[3]) == 0L
directly. 190 calls × 347ns = 66µs saved.
https://claude.ai/code/session_015CtM5k88rF7WFgX8o2AGNR
Pass PointScratch through mulDoubleG instead of re-fetching from
ThreadLocal. The verify path was calling ThreadLocal.get() 2x:
once in verifySchnorrFast and again in mulDoubleG. On ART, each
ThreadLocal.get costs ~41µs (hash table probe), totaling ~83µs
per verify (~1% of total).
mulDoubleG now accepts an optional PointScratch parameter
(defaults to scratch.get() for backward compat). The verify
path passes its already-fetched scratch through.
Also fix all remaining LongArray copyInto calls with default params:
- MutablePoint.copyFrom: 3 calls per copy (x, y, z)
- MutablePoint.setAffine: 2 calls (x, y)
- mulDoubleG P-table build: 2 calls per table entry
- mul P-table build: 2 calls per table entry
- batchToAffine: 2 calls
- U256.copyInto: was delegating with defaults
Each copyInto$default adds a bitmask check + 3 branches + arraylength
per call. With ~13 LongArray copies per verify, this eliminates ~52
extra branch instructions from the hot path.
https://claude.ai/code/session_015CtM5k88rF7WFgX8o2AGNR
Replace U256.fromBytes (allocates LongArray(4)) with
U256.fromBytesInto (writes to pre-allocated scratch) across
all key operations:
- pubkeyCreate: scalar → sc.scalarTmp1 (safe: mulG doesn't use it)
- signSchnorrWithPubKey: d0 → sc.zInv (safe: signSchnorrInternal
doesn't use it; d0 is read at lines 322-324 and 391 before zInv
is needed)
- signSchnorrWithXOnlyPubKey: same pattern with sc.zInv
- pubKeyTweakMul: scalar → sc.scalarTmp1 (safe: ECPoint.mul
doesn't use scalarTmp fields)
- ecdhXOnly: k → sc.scalarTmp1 (same)
- privKeyTweakAdd: already fixed in previous commit
signSchnorr keeps its allocation because d0 must survive through
mulG (which destroys splitK*) AND signSchnorrInternal (which uses
scalarTmp*). The single LongArray(4) alloc is negligible vs mulG
cost (~100μs).
Each eliminated allocation saves ~10-20ns of GC pressure on ART
and ~5ns on K/Native. For signSchnorrWithPubKey (the cached-pk
fast path), this removes the only unnecessary allocation.
https://claude.ai/code/session_015CtM5k88rF7WFgX8o2AGNR
Use thread-local scratch LongArrays instead of allocating 2
intermediate LongArray(4) via U256.fromBytes. The old path did:
fromBytes(seckey) → alloc LongArray(4)
fromBytes(tweak) → alloc LongArray(4)
ScalarN.add(a, b) → alloc LongArray(4)
U256.toBytes(r) → alloc ByteArray(32)
= 4 heap allocations
New path uses pre-allocated scratch from PointScratch:
fromBytesInto(scratch, seckey) → zero alloc
fromBytesInto(scratch, tweak) → zero alloc
ScalarN.addTo(scratch, a, b) → zero alloc
U256.toBytes(r) → 1 alloc (unavoidable, return value)
K/Native: 163 → 88 ns (-46%, from 6.3x to 3.4x vs C)
JVM: now faster than native C via JNI (Kotlin 6.5M ops/s vs C 4.8M ops/s)
https://claude.ai/code/session_015CtM5k88rF7WFgX8o2AGNR
Add fieldMulReduceFused and fieldSqrReduceFused that compute both lo
and hi of each 64×64→128 multiply from 4 shared sub-products.
Previous: lo = a * b (1 hardware mul) + hi = umulh(a,b) (4 imul) = 5
Fused: 4 shared sub-products compute both lo and hi = 4 multiplies
The inline lambda consumer pattern avoids allocation:
mulFull(a, b) { lo, hi -> ... } // everything inlined at call site
K/Native benchmark improvement (linuxX64):
verifySchnorrFast: 101,637 → 96,699 ns (-5%)
signSchnorr(cached): 50,206 → 45,037 ns (-10%)
compressedPubKeyFor: 40,906 → 37,075 ns (-9%)
Native instruction count: 95 → 79 multiplies per field mul
Android keeps the unfused path because ART's single-cycle MUL for
a*b is faster than manually constructing lo from sub-products.
The fused approach saves 1 multiply but adds ~5 shift/and/or ops —
net regression on ART (80,756 vs 74,898 ns for verify).
https://claude.ai/code/session_015CtM5k88rF7WFgX8o2AGNR
Remove crypto-intrinsics module and API-tiered dispatch files.
Revert to the pure-Kotlin fallback for Android field multiply.
Three approaches to reach hardware UMULH were tested on Pixel 8
(Android 16, API 36) and all failed:
1. Direct Math.unsignedMultiplyHigh from Kotlin:
D8 replaces with pure-Java backport when app minSdk=26 < 35.
2. MethodHandle.invokeExact via Java helper module:
Bytecode correct (invoke-polymorphic JJ→J, zero boxing), but
ART can't inline invoke-polymorphic. 25ns/call → 2.2x regression.
3. Full fieldMulReduce in Java, module with minSdk=35:
D8 runs at APP level with app's minSdk=26, not library's minSdk=35.
Trace confirmed: FieldMulIntrinsic$$ExternalSyntheticBackport0.m
still generated. Plus Long.compareUnsigned (282ns/call) is slower
than Kotlin's inlined XOR trick (~0ns).
The Kotlin inline+crossinline pattern remains optimal for ART:
- unsignedMultiplyHighFallback inlined at each call site
- uLtInline uses XOR+compare (no method call)
- Fused function fits ART's inlining budget
UMULH on Android requires raising app minSdk to 35.
https://claude.ai/code/session_015CtM5k88rF7WFgX8o2AGNR
Add 3-file dispatch structure for fieldMulReduce on Android:
- FieldMulApi35: placeholder for Math.unsignedMultiplyHigh (API 35+)
- FieldMulApi31: placeholder for Math.multiplyHigh (API 31-34)
- Fallback: pure-Kotlin 4-imul (API <31, unchanged)
All three currently use the pure-Kotlin fallback because both paths
to the hardware UMULH intrinsic are blocked:
1. Direct Math.unsignedMultiplyHigh call: D8 replaces with synthetic
backport (ExternalSyntheticBackport0.m) when minSdk=26 < 35.
Verified via dexdump: backport is pure-Java 4-imul, never reaches
hardware UMULH even on API 36 devices.
2. MethodHandle.invokeExact: Kotlin compiles as regular invokevirtual
with Object[] boxing (3 allocs per call), not type-exact
invoke-polymorphic (JJ)J. Only Java's javac has @PolymorphicSignature
support. KMP androidMain doesn't support Java sources.
TO UNLOCK: Add a Java helper class (MulHighInvoker.java) in a separate
Android library module that calls MethodHandle.invokeExact(long, long)
with zero boxing. This produces invoke-polymorphic that D8 cannot
desugar and ART intrinsifies to UMULH on ARM64.
https://claude.ai/code/session_015CtM5k88rF7WFgX8o2AGNR
Convert gOddTable, gLamTable, and combTable from `by lazy` to eager
initialization. These tables are accessed on every verify (mulDoubleG)
and sign (mulG) call.
On K/Native, each `by lazy` access goes through
SynchronizedLazyImpl.getValue() — a thread-safe lock check + memory
barrier. The native disassembly showed 2 SynchronizedLazyImpl calls
per mulDoubleG invocation.
On JVM, Kotlin's lazy delegates use double-checked locking via
SynchronizedLazyImpl, adding interface dispatch + volatile read on
every access.
Move the `scratch` (ScratchLocal) field declaration before the table
fields to satisfy initialization order — buildGOddTable() and
buildCombTable() use doublePoint/addPoints which call scratch.get().
https://claude.ai/code/session_015CtM5k88rF7WFgX8o2AGNR
LiveChessGameChallengeEvent (kind 30064) represents game offers/challenges
that haven't ended yet. Remove them from both the home feed filter and
the relay subscription so only completed games (kind 64) and game end
events (kind 30067) appear.
https://claude.ai/code/session_01XiWkVxXQBnLPTbeswL3y4c
Three bytecode-level optimizations to the Schnorr verify path:
1. Replace `by lazy` delegates with direct field init for tag hash
prefixes (CHALLENGE_PREFIX, AUX_PREFIX, NONCE_PREFIX). Eliminates
Lazy.getValue() interface dispatch + checkcast on every call.
2. Use explicit copyInto parameters everywhere, eliminating the
copyInto$default bridge method (bitmask + 3 branches + arraylength
per call). 4 calls per verify × 3 using defaults = 12 extra branches
removed from the hot path.
3. Extract shared verify computation into verifySchnorrCore(), called
by both verifySchnorr() and verifySchnorrFast(). Previously, two
~400-bytecode near-identical methods competed for JIT optimization.
Now one hot method gets compiled, and both public methods are thin
wrappers (34 and 130 bytecodes).
JVM benchmark before: verifySchnorrFast was 5% SLOWER than verifySchnorr
(60,930 vs 57,876 ns) due to JIT warmup ordering bias.
JVM benchmark after: verifySchnorrFast is 11% faster (50,345 vs 56,143 ns),
matching the expected ~14% savings from skipping the field inversion.
https://claude.ai/code/session_015CtM5k88rF7WFgX8o2AGNR
Add verifySchnorrBatch to Secp256k1InstanceOurs wrapper and add
Android benchmark tests for batch(8) and batch(16).
To get per-event cost: ns/op ÷ batchSize.
JVM benchmark showed 4-7× speedup over individual verify.
Android results TBD.
https://claude.ai/code/session_01EMY5RnXb9rnsyU2KbXrSaY
The previous commit replaced ALL uLt calls with uLtInline (XOR trick),
which regressed JVM verify from 1.6× to 1.9× vs native C. The XOR trick
is slower than Long.compareUnsigned on HotSpot.
Fix: uLtInline is used ONLY inside the fused FieldMulPlatform.kt inline
functions (which JVM doesn't use — it uses the unfused path). All other
code (U256.addTo/subTo, FieldP.add/sub/half, ScalarN, Glv) keeps the
expect/actual uLt which uses Long.compareUnsigned on JVM.
JVM: verifySchnorrFast 1.3× (restored, improved)
Android: uLt overhead remains for non-fused paths (~11K calls/verify)
but the fused mul/sqr path (the dominant cost) is fully inlined.
https://claude.ai/code/session_01EMY5RnXb9rnsyU2KbXrSaY
Trace profiling showed unsignedMultiplyHighFallback at 16.9% of verify
time (32,524 calls × 82ns = 2.674ms). Although called from inside an
inline crossinline lambda, the function itself was a regular dispatch.
Adding @Suppress("NOTHING_TO_INLINE") inline makes the Kotlin compiler
embed the 4-multiply arithmetic directly at each call site, eliminating
all function dispatch overhead.
https://claude.ai/code/session_01EMY5RnXb9rnsyU2KbXrSaY
Trace profiling on Pixel 8 revealed two massive overhead sources:
1. ExternalSyntheticBackport0.m — 17.5% of verify (3.45ms)
D8/R8 desugaring wraps Math.unsignedMultiplyHigh in a synthetic
backport because minSdk=26 < 35. The backport adds 139ns per call
× 24,875 calls. The actual UMULH intrinsic is ~1ns, but the
wrapper adds ~138ns.
FIX: Use pure-Kotlin unsignedMultiplyHighFallback on Android instead
of Math.unsignedMultiplyHigh. The fallback (4 Long multiplies +
shifts, ~10-20ns) is FASTER than the backported intrinsic (139ns).
Removed all API-level dispatch — a single fallback path for all
Android versions.
2. uLt function calls — 20.7% of verify (4.08ms)
The expect/actual uLt (can't be inline) was called 49,924 times
per verify at 82ns each. Most calls came from the fused
fieldMulReduceWith/fieldSqrReduceWith inline expansions.
FIX: Add uLtInline — a private inline function using the XOR trick
directly. Since it's @Suppress("NOTHING_TO_INLINE") inline, the
Kotlin compiler inlines it at every call site (not the JIT).
Replaces all 55 uLt calls in the fused inline functions.
JVM is unaffected (uses unfused path with Long.compareUnsigned).
Combined: eliminates ~38% of verify overhead on Android.
https://claude.ai/code/session_01EMY5RnXb9rnsyU2KbXrSaY
Wire verifySchnorrFast (x-check only, no y-parity inversion) into:
- Secp256k1InstanceOurs wrapper for app-level usage
- Android benchmark as verifySchnorrFastOurs for Pixel 8 measurement
Expected: ~15% faster than verifySchnorrOurs (~100μs vs ~120μs on Pixel 8)
https://claude.ai/code/session_01EMY5RnXb9rnsyU2KbXrSaY
The conversion pipeline is overwhelmingly CPU/GPU bound (Movie decode,
GL rendering, MediaCodec encode) and can run for several seconds on a
large GIF. Running it on Dispatchers.IO occupies a thread from the
large IO pool with no kernel wait, which can starve legitimate IO
coroutines when multiple uploads run in parallel.
Switching to Dispatchers.Default caps concurrent conversions to the
CPU count, which is also desirable given the hardware encoder
contention that multiple simultaneous MediaCodec instances would
cause.
convertInternal remains a plain (non-suspending) function, so EGL
thread-affinity is still preserved for the lifetime of the call.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Covers the bounds-checking fixes from the previous commit with 13
pure-JVM unit tests (no Android dependencies, no Robolectric):
- Image Descriptor (0x2C) packed-byte offset and length precheck
- skipSubBlocks clamp against oversized block lengths
- Local and Global Color Table skipping
- Delay normalization (0 and 1 centisecond → 100 ms)
- Multi-frame parsing with variable delays
- Non-GCE extension blocks (Application Extension) skipped safely
- Truncated inputs do not crash
parseGifFrameDelays is exposed as `internal` with
@VisibleForTesting(otherwise = PRIVATE) so production callers still
see it as private.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Address issues found in Kotlin code review:
HIGH
- Rethrow CancellationException in convert() to preserve structured
concurrency; prior broad catch silently swallowed cancellation.
- Cap drainEncoder's post-EOS loop at 500 iterations to prevent an
unresponsive hardware encoder from hanging the IO thread forever.
MEDIUM
- Read GIF with a 20 MB size cap via bounded buffer to avoid OOM on
malformed or adversarial inputs.
- Check glCompileShader / glLinkProgram status and throw with
glGetShaderInfoLog / glGetProgramInfoLog on failure, instead of
silently producing blank frames on driver errors.
- Verify numConfigs[0] > 0 after eglChooseConfig and use requireNotNull
on configs[0] with a clear message (avoids NPE from !!).
- Fix Image Descriptor (0x2C) parsing: precheck pos + 10 <= bytes.size
and read the packed byte at the correct offset; bounds-check after
Local Color Table skip.
- Use uris.hasVideo() instead of uris.first().media.isVideo() for the
privacy-toggle visibility so mixed-media selections (image + video)
hide the toggle correctly.
LOW
- skipSubBlocks now clamps pos with minOf(pos + blockSize, bytes.size)
to preserve the invariant that pos is always a valid index.
- Document the function-level @Suppress("deprecation") on
convertInternal (android.graphics.Movie has no modern replacement).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The GIF converter already produces a well-compressed H.264 MP4, so
re-compressing via VideoCompressionHelper is wasteful and can inflate
file size. Hide the quality slider in the upload UI when GIF-to-MP4
is enabled and skip the video compression step in MediaCompressor.
https://claude.ai/code/session_018sKCM3VNWtfPMZSYVWWD4w
When a GIF is selected for upload, a new "Convert GIF to MP4" switch
appears in the upload settings dialog. This converts animated GIFs to
MP4 video using Android's Movie decoder and MediaCodec/MediaMuxer
encoder, resulting in smaller file sizes and better playback
compatibility. The converted video can optionally be further compressed
using the existing video compression pipeline.
https://claude.ai/code/session_018sKCM3VNWtfPMZSYVWWD4w
BIP-340 verification checks both R.x == r AND R.y is even. The y-parity
check requires a full field inversion (~270 field ops, ~14% of verify).
verifySchnorrFast skips the y-parity check, verifying only the
x-coordinate in Jacobian coordinates (2 field ops, no inversion).
WHY THIS IS SAFE FOR NOSTR:
For a given x on secp256k1, there are exactly 2 points: (x, y_even) and
(x, y_odd). A signature producing the correct x but wrong y-parity would
require solving the discrete log — equivalent to forging the signature.
The y-parity check is defense-in-depth, not a distinct security boundary.
DO NOT use for Bitcoin/financial protocols — use verifySchnorr for strict
BIP-340 compliance.
JVM benchmark:
verifySchnorrFast: 20,706 ops/s (1.4× vs native C)
verifySchnorr: 18,038 ops/s (1.6× vs native C)
Improvement: ~15% faster
https://claude.ai/code/session_01EMY5RnXb9rnsyU2KbXrSaY
Allocation audit from Android benchmark showed 19 allocs in signSchnorr
and 4 in verifySchnorr. Most were intermediate ByteArray/LongArray that
can be replaced with pre-allocated scratch buffers.
Changes:
- Add sha256Into() (expect/actual) that writes digest into existing buffer
instead of allocating a new ByteArray(32) per call. Uses
MessageDigest.digest(buf,off,len) on JVM/Android, CC_SHA256 on Apple.
- Add scratch byte buffers to PointScratch: hashBuf(256), bytesTmp1/2(32),
scalarTmp1/2/3 for intermediate scalar results.
- Add ScalarN.reduceTo() allocation-free variant.
- Rewrite signSchnorrInternal to reuse scratch buffers for:
- dBytes serialization (bytesTmp1 instead of U256.toBytes alloc)
- AUX_PREFIX+auxrand hash (hashBuf instead of array concatenation)
- auxHash XOR (scalarTmp1/2/3 instead of U256.fromBytes allocs)
- nonce/challenge hash inputs (hashBuf instead of ByteArray alloc)
- nonce scalar (scalarTmp1 instead of ScalarN.reduce alloc)
- challenge scalar (scalarTmp3 instead of allocs)
- e*d and k+e*d (splitK1/entryTmp2 instead of ScalarN.mul/add allocs)
- Rewrite verifySchnorr to use hashBuf and sha256Into for challenge hash.
Only the 64-byte output signature is allocated per sign call.
Verify allocates nothing for 32-byte messages (hashBuf is large enough).
https://claude.ai/code/session_01EMY5RnXb9rnsyU2KbXrSaY
BIP-340 public keys always have even y, so the y-parity prefix byte
(0x02) is redundant when the caller already has the 32-byte x-only
pubkey. This new overload takes the x-only pubkey directly, avoiding:
1. The expensive G multiplication to derive the pubkey (~20μs on Android)
2. The 33→32 byte array copy that signSchnorrWithPubKey does internally
Added to:
- Secp256k1.signSchnorrWithXOnlyPubKey (core implementation)
- Secp256k1Instance (expect/actual, falls back to C lib's signSchnorr
since the native C lib always derives pubkey internally)
- Secp256k1InstanceOurs (uses the optimized pure-Kotlin path)
- Nip01Crypto.signWithPubKey (app-level convenience)
The app's KeyPair already stores the 32-byte x-only pubkey. Callers
like EventAssembler.hashAndSign can pass it through to skip the
G multiplication entirely.
https://claude.ai/code/session_01EMY5RnXb9rnsyU2KbXrSaY
Kotlin generates Intrinsics.checkNotNullParameter at the entry of every
function taking non-null reference types. Bytecode audit showed:
Before: 128 checkNotNullParameter calls across secp256k1 classes
After: 8 (only expression-value checks in non-hot paths)
Per Schnorr verify, this eliminates ~4,000+ invokestatic calls.
On ART (~2-3ns each): saves ~8-12μs per verify.
On HotSpot: neutral (C2 already optimizes null checks to fast branches).
These flags are safe for this module: all internal secp256k1 functions
use non-null LongArray/MutablePoint parameters that are never null.
Applied at the module level (compilerOptions) so all targets benefit.
https://claude.ai/code/session_01EMY5RnXb9rnsyU2KbXrSaY
Two optimizations for verifySchnorr:
1. Jacobian x-coordinate check before toAffine:
Instead of converting to affine first (1 inversion = ~270 field ops),
check X == r·Z² in Jacobian coordinates (1 sqr + 1 mul = 2 ops).
Invalid signatures (x mismatch) are rejected immediately without
paying the inversion cost. Valid signatures still need inversion for
the y-parity check. For Nostr, ~all sigs are valid so this mainly
helps adversarial/spam rejection.
2. Inline wNAF zero-checks in mulDoubleG inner loop:
~70% of wNAF digits are zero. Previously, each still called
addWnafMixedPP (function call overhead: null checks, frame setup).
Now the zero-check is inlined before the call, avoiding ~364
function calls per verify. On ART (5-8ns per call), this saves
~2-3μs. On HotSpot, the JIT already optimized this — no change.
https://claude.ai/code/session_01EMY5RnXb9rnsyU2KbXrSaY
The fused fieldMulReduceWith + crossinline lambda was designed for ART
which struggles with deep call chains. On HotSpot C2, it creates a
2351-bytecode method (exceeding FreqInlineSize=325) that can't be
inlined into FieldP.mul, and wastes 180 bytecodes on lambda param
shuffling that C2 must clean up.
The unfused path (U256.mulWide + FieldP.reduceWide) produces a tiny
40-bytecode fieldMulReduce that HotSpot easily inlines. HotSpot's 8+
level inlining depth handles the full chain down to the
Math.unsignedMultiplyHigh intrinsic (single MULQ on x86-64).
Benchmark on JVM 21 x86-64: equivalent performance (1.5× verify,
0.9× sign-cached vs native C). Cleaner bytecode with no lambda waste.
https://claude.ai/code/session_01EMY5RnXb9rnsyU2KbXrSaY
Document WHY each approach was chosen, what alternatives were tested,
and what the measured impact was — so future contributors don't
accidentally revert optimizations or repeat failed experiments.
Key decisions documented:
- uLt() expect/actual: why XOR on Android, Long.compareUnsigned on JVM,
and why a shared inline fun in commonMain caused 30% JVM regression
- fieldMulReduceWith: why fused mul+reduce, why inline+crossinline,
why NOT 5x52 limbs, why NOT single-method with all API branches
- @JvmField: why it's required on MutablePoint/AffinePoint/PointScratch,
with bytecode counts showing ~7,450 + ~2,000 virtual getter calls
eliminated per verify
https://claude.ai/code/session_01EMY5RnXb9rnsyU2KbXrSaY
Bytecode analysis showed MutablePoint.x/y/z, AffinePoint.x/y, and all
PointScratch properties compile to invokevirtual getter calls instead
of direct field reads (getfield). Per verify:
- MutablePoint getX/Y/Z: ~7,450 invokevirtual → getfield
- PointScratch getT/getW/etc: ~2,000+ invokevirtual → getfield
Each invokevirtual has ~3-5ns overhead on ART vs ~1ns for getfield.
@JvmField eliminates the getter method entirely, compiling property
access to a direct field read. On non-JVM targets (iOS), @JvmField
is silently ignored.
https://claude.ai/code/session_01EMY5RnXb9rnsyU2KbXrSaY
Bytecode analysis revealed that every `a.toULong() < b.toULong()`
comparison generates 2 invokestatic calls to ULong.constructor-impl
(NOOPs that return the input unchanged) plus Long.compareUnsigned.
Across all secp256k1 hot paths, this produced 554 NOOP invokestatic
calls in the bytecode, translating to ~18,000 wasted calls per verify.
Replace all toULong() comparisons with an inline uLt() helper that
uses the XOR-with-MIN_VALUE trick directly:
(a xor Long.MIN_VALUE) < (b xor Long.MIN_VALUE)
This produces pure arithmetic bytecode (lxor, lcmp, ifge) with ZERO
method calls, eliminating all ULong.constructor-impl overhead.
Before: lload, invokestatic ULong.constructor-impl, lload,
invokestatic ULong.constructor-impl, invokestatic
Long.compareUnsigned, ifge (6 bytecodes, 3 method calls)
After: lload, ldc MIN_VALUE, lxor, lload, ldc MIN_VALUE, lxor,
lcmp, ifge (8 bytecodes, 0 method calls)
https://claude.ai/code/session_01EMY5RnXb9rnsyU2KbXrSaY
The previous approach inlined all 3 API-level branches into a single
fieldMulReduce method (~600 DEX instructions). ART's register allocator
produces suboptimal code for such large methods, causing stack spills
that negate the benefit of eliminating wrapper calls.
Split each API-level path into its own private function (~200 DEX
instructions each). ART JIT-compiles only the hot one (fieldMulApi35
on API 35+) with full optimization, while the tiny dispatch function
(fieldMulReduce) is easily devirtualized and inlined.
https://claude.ai/code/session_01EMY5RnXb9rnsyU2KbXrSaY