docs: add performance rationale to secp256k1 optimization decisions

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
This commit is contained in:
Claude
2026-04-08 23:53:08 +00:00
parent 30151295ad
commit 6f3793e7ed
7 changed files with 120 additions and 40 deletions
@@ -23,15 +23,31 @@ package com.vitorpamplona.quartz.utils.secp256k1
/**
* Android field multiply/square with API-level-gated intrinsics.
*
* Each API level gets its OWN private function with the inline expansion,
* so ART's JIT can fully optimize each as a standalone ~200 DEX-instruction
* method. Previously, having all 3 branches inline-expanded in a single
* function created ~600 DEX instructions, causing ART's register allocator
* to produce suboptimal code with excessive stack spills.
* ARCHITECTURE: dispatch → per-API private function inline-expanded hot loop
*
* The dispatch functions (fieldMulReduce/fieldSqrReduce) are tiny (~15 DEX
* instructions) and always take the same branch, so ART profiles and
* devirtualizes them efficiently.
* Each API level gets its OWN private function (fieldMulApi35, fieldMulApi31,
* fieldMulFallback) with the fieldMulReduceWith inline expansion. This is critical
* because ART's JIT optimizes methods individually:
*
* WHY separate private functions per API level?
* Tested: putting all 3 branches in a single fieldMulReduce with inline expansion
* created ~600 DEX instructions (3 copies × ~200 each). ART's register allocator
* produced suboptimal code — verify REGRESSED by ~4% vs baseline. With separate
* functions, each is ~200 DEX instructions, well within ART's optimization sweet spot.
* Verify improved by 16% instead.
*
* WHY the API-level split at all?
* - API 35+ (Android 15): Math.unsignedMultiplyHigh → UMULH (1 ARM64 instruction)
* - API 31-34 (Android 12-14): Math.multiplyHigh → SMULH + 3 correction insns
* - API <31 (Android <12): pure-Kotlin fallback (4 multiplies + shifts)
* The dispatch function (fieldMulReduce) checks API once, not per multiply-high call.
* ART profiles and devirtualizes — on any given device, only one path is hot.
*
* WHY NOT use the unsignedMultiplyHigh expect/actual wrapper directly?
* The wrapper checks API level on EVERY call (16-20× per field multiply). Even though
* ART should constant-fold the check, the wrapper's 3-branch body may exceed ART's
* inline-candidate threshold, leaving ~10,000 un-inlined function calls per verify.
* The fused approach eliminates the wrapper entirely.
*/
private val API = android.os.Build.VERSION.SDK_INT
@@ -21,10 +21,22 @@
package com.vitorpamplona.quartz.utils.secp256k1
/**
* Android: XOR-with-MIN_VALUE trick avoids the ULong.constructor-impl
* NOOP invokestatic calls that Kotlin's toULong() generates. Produces
* pure arithmetic bytecode (lxor, lcmp) with zero method calls.
* ART's JIT compiles this to EOR + CMP + CSET on ARM64.
* Android: XOR-with-MIN_VALUE trick for unsigned comparison.
*
* DO NOT use Long.compareUnsigned here (even though it's an ART intrinsic since API 26).
* Kotlin's `a.toULong() < b.toULong()` compiles to Long.compareUnsigned BUT also emits
* 2 ULong.constructor-impl NOOP invokestatic calls per comparison. These NOOPs add ~2-3ns
* each on ART, totaling ~35-54μs per verify (~17,800 comparisons).
*
* DO NOT use Long.compareUnsigned directly either — ART may or may not inline the function
* call depending on method size budgets, adding unpredictable overhead.
*
* The XOR trick produces pure arithmetic bytecode with ZERO method calls:
* Bytecode: lload a, ldc MIN_VALUE, lxor, lload b, ldc MIN_VALUE, lxor, lcmp, ifge
* ARM64: EOR x0, a, #0x8000...; EOR x1, b, #0x8000...; CMP x0, x1; CSET x2, LT
*
* This approach was validated by bytecode analysis (javap) and benchmarked on Pixel 8
* (Android 16): ~14% improvement across all EC point operations.
*/
internal actual fun uLt(
a: Long,