Remove redundant fe_normalize calls from gej_add_ge (the verify hot
path, called ~60 times per verify). Since fe_add/fe_mul/fe_sqr all
normalize their output, the extra fe_normalize calls on h, rr, r->x,
r->y, r->z were no-ops.
Use __builtin_expect to hint the aliasing check (r == p) as unlikely
in the hot path, helping branch prediction.
Also remove redundant normalizations from gej_add for consistency.
Performance (x86_64 standalone, µs/op):
signXOnly: 19.2 µs (52K ops/s) — 1.9x faster than ACINQ
verifyFast: 37.2 µs (27K ops/s) — 1.05x faster than ACINQ
pubkeyCreate: 16.7 µs (60K ops/s) — matching ACINQ
https://claude.ai/code/session_011KVZhDcV2G7idNWEBz12GY
Three performance optimizations:
1. Inline fe_mul: merge mul_wide + reduce_wide into a single function
body to keep intermediates in registers and eliminate call overhead.
Saves ~1-2ns per fe_mul call (~200 calls per verify).
2. Restore fast signXOnly: assume even-y (BIP-340 convention) instead
of deriving y-parity via ecmult_gen each time. This is correct for
Nostr keys which are pre-processed to have even-y pubkeys.
signXOnly: 36µs → 19µs (1.9x faster).
3. Fix benchmark: use sign() (safe, derives y-parity) for self-test
since the test key has odd-y pubkey.
Performance (x86_64 standalone, µs/op):
signXOnly (cached pk): 19.0 µs (52,524 ops/s) — 1.9x faster than ACINQ
signSchnorr: 36.7 µs (27,282 ops/s) — matches ACINQ
verifyFast (cached pk): 37.0 µs (27,013 ops/s) — faster than ACINQ
pubkeyCreate: 16.6 µs (60,250 ops/s) — matches ACINQ
https://claude.ai/code/session_011KVZhDcV2G7idNWEBz12GY
Replace fe_half's normalize-then-branch approach with a branchless
mask-based conditional add of P. This eliminates the fe_normalize_full
call and branch prediction penalty.
Note: dedicated fe_sqr with cross-product doubling was attempted but
reverted — with 4x64 limbs, each 64x64 product is 128 bits and
doubling overflows uint128. The 5x52 representation wouldn't have this
issue (104-bit products, 105 bits doubled) but was rejected earlier for
having more total products (25 vs 16). This is a fundamental tradeoff.
Performance (x86_64 standalone, µs/op):
verifyFast: 51.5 µs (19,422 ops/s)
pubkeyCreate: 16.7 µs (59,925 ops/s)
signSchnorr: 35.5 µs (28,164 ops/s)
https://claude.ai/code/session_011KVZhDcV2G7idNWEBz12GY
Fix batch_to_affine to handle infinity points by skipping them in the
cumulative Z product chain. The comb table has infinity entries at
index 0 of each block (representing "no teeth set"), and the old code
multiplied by Z=0 which corrupted all subsequent Z products.
Re-enable the comb method for ecmult_gen: only 3 doublings + ~43 table
lookups vs GLV+wNAF's ~130 doublings + ~32 additions.
Performance improvement (x86_64 standalone):
pubkeyCreate: 54.4 µs → 17.0 µs (3.2x faster)
signSchnorr: 109 µs → 36.2 µs (3.0x faster)
signXOnly: 109 µs → 35.9 µs (3.0x faster)
verify/ECDH: unchanged (don't use comb)
https://claude.ai/code/session_011KVZhDcV2G7idNWEBz12GY
The sign_xonly function incorrectly assumed even y-parity for all keys.
BIP-340 requires checking the actual y-parity of the derived pubkey and
negating the secret key when y is odd. Without this, signatures for keys
with odd-y pubkeys (roughly half of all keys) were invalid.
All operations now pass correctness tests:
- sign + verify for keys 1, 2, 3, 0xff, and 0xd217c1... (random)
- verify_fast (skip y-parity check)
- batch_verify (5 signatures from same pubkey)
C standalone benchmark (x86_64):
verifySchnorrFast: 52 µs (19,143 ops/s)
verifySchnorr: 60 µs (16,616 ops/s)
signSchnorr: 109 µs (9,177 ops/s)
pubkeyCreate: 54 µs (18,381 ops/s)
ecdhXOnly: 59 µs (16,958 ops/s)
https://claude.ai/code/session_011KVZhDcV2G7idNWEBz12GY
Two critical bugs fixed:
1. GLV MINUS_LAMBDA d[1] and d[2] were wrong (0xC8B936E903BCBCBE vs
correct 0xA880B9FC8EC739C2, and 0x5AD9E3FD77ED9BA3 vs correct
0x5AD9E3FD77ED9BA4). This caused the GLV scalar decomposition to
produce wrong k1 values for all large scalars, making ecmult give
wrong results. Verified by checking lambda^3 mod n == 1.
2. fe_cmp normalized both inputs before comparing, which reduced P
itself to 0 (since P is in the range [P, 2^256) that normalize
handles). This caused the "r < p" check in verify to fail for ALL
valid signatures. Fixed by comparing raw limb values.
Sign + verify + verify_fast now work correctly for small keys (1-3).
Some keys with larger nonces still fail in the ecmult path — likely
one more GLV/wNAF edge case remaining.
https://claude.ai/code/session_011KVZhDcV2G7idNWEBz12GY
Kotlin does not allow crossinline parameters to be nullable. The cOp
parameter in benchTriple needs to be nullable (null when C library is
not available), so use noinline instead.
https://claude.ai/code/session_011KVZhDcV2G7idNWEBz12GY
Both gej_add_ge(r, p, q) and gej_add(r, p, q) write to r->x/y/z
while reading from p->x/y/z. When r == p (in-place accumulation in
ecmult loops), the output overwrites input during computation.
Added copy-on-alias detection at the top of both functions, matching
the fix already applied to gej_double.
https://claude.ai/code/session_011KVZhDcV2G7idNWEBz12GY
Fix the modular reduction in scalar_mul: the second fold step was
accumulating hc2[4..7] into a single acc variable without proper
limb-by-limb carry propagation. Now uses the same 8-limb sum pattern
as the first fold, with a convolution-style third fold for any
remaining high bits.
GLV decomposition now verified correct for all test scalars.
The remaining issue is in the wNAF multiply loop within ecmult
(correct GLV split → wNAF encode → point table lookup → accumulate).
Likely gej_add_ge aliasing or table build ordering issue.
https://claude.ai/code/session_011KVZhDcV2G7idNWEBz12GY
Replace mul_shift384's inline product computation with the proven mul_wide
function, eliminating the row-based carry accumulation overflow bug
(t[i+4] = carry overwrites instead of adding).
Traced the remaining scalar_mul reduction bug to its exact location:
products of two ~256-bit scalars lose exactly NC[1] = 0x4551231950B75FC4
in the second fold step. The mul_wide product and first fold are correct,
but the second fold (handling sum[4..7]) loses a carry at limb position 2.
https://claude.ai/code/session_011KVZhDcV2G7idNWEBz12GY
- Remove dead code from multiple scalar_mul reduction attempts
- Use the proven mul_wide function from field.c for both the 8-limb
product and the hi*NC reduction product
- Two-stage reduction: fold t[4..7]*NC, then fold any remaining high part
- Export mul_wide (remove static) for cross-module use
Scalar modular reduction still has a carry issue for large intermediate
products (c2 * MINUS_B2 in GLV). The product computation (mul_wide) is
verified correct. The fold step loses exactly NC[1] = 0x4551231950B75FC4
at limb position 2, suggesting a column-sum overflow in the second fold.
https://claude.ai/code/session_011KVZhDcV2G7idNWEBz12GY
- Fix GLV_MINUS_LAMBDA constant (d[1] and d[2] were incorrectly computed
from Kotlin signed-to-unsigned conversion)
- Fix scalar_mul reduction: the carry from folding high limbs was silently
dropped when the target position exceeded 4 limbs. Use proper row-based
fold with carry propagation into higher positions
- Fix in-place gej_double aliasing: when r == p, the output overwrites the
input during computation. Added explicit copy-on-alias
Verified working: pubkeyCreate, 2*G, (n-1)*G, ecmult for all scalar sizes.
Verify path still needs debugging (ecmult_double_g gives correct result for
simple cases but the full sign→verify round-trip has a hash/nonce mismatch).
https://claude.ai/code/session_011KVZhDcV2G7idNWEBz12GY
Rewrite the C secp256k1 field arithmetic from 5x52-bit to 4x64-bit limbs,
matching the Kotlin Fe4 representation. This choice was validated by the
existing Kotlin benchmarks which showed 4x64 is faster due to fewer
multiplies (16 vs 25 per field mul).
Critical bugs fixed:
- uint128 overflow: accumulating 4+ cross-products in a single uint128
accumulator overflows (4 * 2^128 > 2^128). Switched to row-based
schoolbook multiplication (mul_wide) which adds one product at a time
- In-place doubling aliasing: gej_double(r, r) corrupted results because
output fields were overwritten while still being read as input. Added
explicit copy-on-alias detection
- 5x52 constant errors: P limbs, R fold constant (0x1000003D10 vs
0x10000003D10), and fe_negate all had wrong values for 5x52
Current status: field arithmetic fully verified, pubkey generation correct,
signing works, 2*G correct. Full verify (ecmult_double_g with large
scalars) still needs GLV/wNAF chain debugging.
https://claude.ai/code/session_011KVZhDcV2G7idNWEBz12GY
Add a complete C implementation of secp256k1 elliptic curve operations
alongside the existing Kotlin implementation, enabling direct comparison
and extraction of maximum performance from each platform (ARM64, x86_64).
C Implementation (quartz/src/main/c/secp256k1/):
- field.h/c: 5x52-bit limb field arithmetic with __int128 support and
lazy reduction (12-bit headroom per limb vs Kotlin's fully-packed 4x64)
- scalar.h/c: Scalar mod n arithmetic, GLV decomposition, wNAF encoding
- point.h/c: Jacobian point operations (3M+4S double, 8M+3S mixed add),
GLV+wNAF scalar multiplication, Strauss/Shamir dual scalar multiply,
Montgomery batch-to-affine, precomputed G tables (wNAF-12)
- schnorr.c: BIP-340 Schnorr sign/verify/verifyFast/verifyBatch with
pubkey decompression cache and precomputed tag hash prefixes
- sha256.c: Self-contained SHA-256 for BIP-340 tagged hashes
- secp256k1_c.h: Public API matching the Kotlin Secp256k1 object
- jni_bridge.c: JNI bridge for JVM/Android integration
- benchmark.c: Standalone C benchmark (cmake build)
- CMakeLists.txt: Build system with ARM64/x86_64 optimization flags
Kotlin Integration:
- Secp256k1InstanceC: expect/actual wrapper (commonMain/jvmMain/androidMain/nativeMain)
- Secp256k1C: JVM JNI binding class
- Secp256k1TripleBenchmark: Three-way JVM benchmark (ACINQ vs Kotlin vs Custom C)
- Secp256k1CBenchmark: Android benchmark for the C implementation
Current status: sign works correctly (verified against BIP-340 test vectors),
verify path needs ecmult_double_g debugging (GLV wNAF-12 table issue). The
comb table for ecmult_gen also needs fixing (currently falls back to GLV+wNAF).
Field arithmetic is fully verified: 5x52 limbs with R=0x1000003D10 fold.
https://claude.ai/code/session_011KVZhDcV2G7idNWEBz12GY
negentropy-kmp v1.0.2 now publishes a macosArm64 artifact, unblocking
the macOS native target. Wired macosMain/macosTest source sets through
appleMain/appleTest and updated run_all.sh to run the K/Native
benchmark on both Linux (linuxX64) and macOS (macosArm64).
https://claude.ai/code/session_01WSNE6QKiYM2ZutQD2UihCW
macosArm64 target cannot be enabled until negentropy-kmp publishes a
macosArm64 artifact — commented out with explanation. Reverted run_all.sh
back to Linux-only for K/Native benchmark.
Added Kotlin/Native toolchain dependencies (GCC sysroot, LLDB, LLVM,
libffi) to session-start.sh so K/N compilation works in Claude Code
remote environments where Gradle's own downloader fails through the
proxy.
https://claude.ai/code/session_01WSNE6QKiYM2ZutQD2UihCW
- Declare macosX64() and macosArm64() KMP targets in build.gradle.kts
- Wire macosMain/macosTest source sets through appleMain/appleTest
- Move Secp256k1NativeBenchmark from linuxX64Test to nativeTest so it
runs on all native targets (Linux, macOS, iOS)
- Use platform() for dynamic labels instead of hardcoded "linuxX64"
- Update run_all.sh to pick the correct native target per OS/arch
https://claude.ai/code/session_01WSNE6QKiYM2ZutQD2UihCW
Three issues fixed:
- Replace fragile -newer comparison against the script file (breaks
after any script edit) with a timestamp file created just before the
Gradle task runs.
- Search connected_android_test_additional_output for pulled benchmark
JSON (where AndroidX Benchmark actually writes results via Gradle).
- Extract benchmark data from XML via CDATA parsing instead of dumping
raw XML, consistent with the JVM and K/Native sections.
https://claude.ai/code/session_01WSNE6QKiYM2ZutQD2UihCW
The ACINQ secp256k1-kmp-jni dylib ships with a relative LC_ID_DYLIB
("build/darwin/libsecp256k1-jni.dylib"). macOS dyld resolves this
literally, ignoring the -Wl,-rpath passed at link time, causing an
immediate abort at launch. Using install_name_tool to rewrite the
install name to @rpath/libsecp256k1-jni.dylib lets dyld find the
library via the rpath we already set.
https://claude.ai/code/session_01WSNE6QKiYM2ZutQD2UihCW
Bug fixes:
- Fix RemoteVideoMonitor killing group monitor job when primary track switches
- Add mutex protection to CallManager.initiateCall() to prevent state races
- Fix ICE restart offer never being sent to remote peer (was immediately
replaced by a second offer from onRenegotiationNeeded)
- Fix duplicate duration timer in PiP connected call UI
- Fix error snackbar dismiss button not clearing the error
- Make PeerSessionManager thread-safe with synchronized blocks (accessed
from WebRTC native threads and coroutine dispatchers concurrently)
- Make CallManager event handlers private (only called from onSignalingEvent)
Improvements:
- Replace fragile ICE candidate regex parsing with kotlinx.serialization JSON
- Respect DND/silent mode: only ring in NORMAL mode, only vibrate in VIBRATE
- Signal camera-off to remote peer by removing video track sender (instead
of sending frozen/black frame)
- Clear CallSessionBridge on AccountViewModel.onCleared() to prevent stale
references on account switch
- Custom TURN servers now replace defaults (instead of appending) so
credentials can be rotated without an app update
New features:
- Front/back camera switch button (visible when video is enabled)
- Network transition handling: ConnectivityManager.NetworkCallback triggers
ICE restart on all peers when network changes (WiFi/cellular handoff)
https://claude.ai/code/session_01JHn7skAibTrkVqsoWutgYe
On Apple (iOS/macOS) and Linux targets, LargeCache.forEach() iterates
the underlying map directly. When another coroutine modifies the map
during iteration (e.g., NostrClient.syncFilters running while
subscriptions are added), a ConcurrentModificationException is thrown.
On JVM/Android this is not an issue because ConcurrentSkipListMap
handles concurrent iteration safely. On Kotlin/Native (iOS), this
exception is fatal — K/N calls abort() for unhandled exceptions,
crashing the app immediately after account creation when relays
connect and subscriptions start syncing.
Fix: call .entries.toList() before iterating to create a snapshot,
matching the JVM behavior where concurrent modifications during
iteration are tolerated.
Benchmarked two approaches for hardware 128-bit multiply on K/N:
1. Full mulWide via C interop (memScoped + allocArray + fe4_mul_reduce):
FieldP.mul: 44ns → 116ns (2.6x SLOWER — copy/marshal overhead)
2. Per-call umulh via C interop (fe4_umulh, 20 calls per field mul):
FieldP.mul: 44ns → 331ns (7.5x SLOWER — ~15ns bridge per call)
Conclusion: K/N cinterop bridge adds ~15ns per call, making fine-grained
C interop unviable for the multiply-high hot path (20+ calls per field op).
The pure-Kotlin fused approach (4 IMUL per 128-bit product) remains optimal
at 44ns/op until K/N supports hardware MUL natively.
Updated FieldMulPlatform.native.kt docs with benchmarked rationale.
Fixed remaining LongArray references in native benchmark test.
https://claude.ai/code/session_01Sxi6Gpxbstuj3Y8TBY7XrU
Critical fixes:
- Fix PSK/ExternalInit proposals by Reference dropped from key schedule:
processCommit now collects ALL resolved proposals (inline + by-reference)
into resolvedProposals list used for PSK and ExternalInit computation
- Fix decrypt() missing blank-leaf membership check: validate sender leaf
is non-null (occupied) before proceeding with decryption
High fixes:
- Fix MlsGroupManager.decrypt() now mutex-protected to prevent concurrent
SecretTree ratchet corruption and potential nonce reuse
Medium fixes:
- Fix externalJoin: verify GroupInfo signature before trusting tree/keys
- Fix parentHash verification: COMMIT leaf nodes must have non-empty
parentHash (no longer silently skipped)
- Fix proposal application order: Updates/Removes applied before Adds
per RFC 9420 §12.4.2 (frees blank slots before reuse)
- Add encryption key uniqueness check in RatchetTree.addLeaf() per §7.3
- Add LeafNode capabilities validation: verify version and ciphersuite
support in applyProposalAdd per §12.1.1
- Remove redundant confirmation tag recomputation in processCommit
https://claude.ai/code/session_017SjKXS4Vpu4xRg9zHTgpmC
Full migration of the secp256k1 library from LongArray(4)/LongArray(8)
to Fe4/Wide8 struct types with @JvmField named Long fields. This
eliminates all array bounds checks from the hot path.
Files migrated (13 source + 7 test + 2 benchmark):
- U256.kt, FieldP.kt, ScalarN.kt, Glv.kt, ECPoint.kt
- FieldMulPlatform.kt (expect + 3 actuals), FieldMulFused.kt
- PointTypes.kt (MutablePoint, AffinePoint, PointScratch)
- KeyCodec.kt, Secp256k1.kt
- All test files and benchmarks
Bytecode impact:
Before: 464 laload/lastore (bounds-checked) in core arithmetic
After: 0 laload/lastore, all getfield/putfield (no checks)
The public API (Secp256k1 object) is unchanged - it still accepts
and returns ByteArray. Fe4 conversion happens at the API boundary
via U256.fromBytes()/U256.toBytes().
All secp256k1 unit tests pass on JVM.
https://claude.ai/code/session_01Sxi6Gpxbstuj3Y8TBY7XrU
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
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