docs: document C interop benchmarks and K/N MUL limitation

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
This commit is contained in:
Claude
2026-04-10 08:33:58 +00:00
parent 28b76f4320
commit 2bb72ac43d
2 changed files with 25 additions and 9 deletions
@@ -220,35 +220,35 @@ class Secp256k1NativeBenchmark {
// out of the loop (dead code elimination risk with constant inputs).
val a = U256.fromBytes(hexToBytes("79BE667EF9DCBBAC55A06295CE870B07029BFCDB2DCE28D959F2815B16F81798"))
val b = U256.fromBytes(hexToBytes("483ADA7726A3C4655DA4FBFC0E1108A8FD17B448A68554199C47D08FFB10D4B8"))
val out = LongArray(4)
val w = LongArray(8)
val out = Fe4()
val w = Wide8()
val results = mutableListOf<BenchResult>()
// --- FieldP.mul (the hottest operation) ---
// Uses `out` as both output and next input to create a data dependency chain.
a.copyInto(out)
out.copyFrom(a)
results +=
bench("FieldP.mul", 5000, 100000) {
FieldP.mul(out, out, b, w)
}
// --- FieldP.sqr ---
a.copyInto(out)
out.copyFrom(a)
results +=
bench("FieldP.sqr", 5000, 100000) {
FieldP.sqr(out, out, w)
}
// --- FieldP.add ---
a.copyInto(out)
out.copyFrom(a)
results +=
bench("FieldP.add", 5000, 500000) {
FieldP.add(out, out, b)
}
// --- FieldP.sub ---
a.copyInto(out)
out.copyFrom(a)
results +=
bench("FieldP.sub", 5000, 500000) {
FieldP.sub(out, out, b)
@@ -277,7 +277,7 @@ class Secp256k1NativeBenchmark {
printResults("secp256k1 Field Micro-Benchmarks: Kotlin/Native (LLVM AOT) on linuxX64", results)
// Use sinks to prevent dead code elimination of the entire benchmark
assertTrue(hiSink != Long.MIN_VALUE || ltSink != Long.MIN_VALUE || out[0] != Long.MIN_VALUE)
assertTrue(hiSink != Long.MIN_VALUE || ltSink != Long.MIN_VALUE || out.l0 != Long.MIN_VALUE)
}
private fun hexToBytes(hex: String): ByteArray {
@@ -21,8 +21,24 @@
package com.vitorpamplona.quartz.utils.secp256k1
/**
* Native (iOS/macOS) fused field multiply/square using pure-Kotlin fallback.
* Kotlin/Native compiles ahead-of-time, so inline + direct call is optimal.
* Native fused field multiply/square using pure-Kotlin fallback.
*
* Uses fieldMulReduceFused which computes both lo and hi from shared 32-bit
* sub-products (4 IMUL per 128-bit product instead of 5). This is the optimal
* approach for Kotlin/Native because:
*
* 1. K/N has no way to emit the hardware MUL instruction (64×64→128) from
* Kotlin code. Long * Long only gives the lower 64 bits (IMUL).
*
* 2. C interop was benchmarked and adds ~15ns per call through the K/N bridge
* layer. With 20 multiply-high calls per field multiply, the bridge overhead
* (~300ns) far exceeds the savings from hardware MUL (~150ns saved).
*
* 3. The fused mulFull approach (FieldMulFused.kt) computes both lo and hi
* from 4 shared sub-products, saving 1 IMUL per product vs separate
* lo (IMUL) + hi (4 IMUL fallback) = 5 IMUL.
*
* Measured: 44 ns/op on linuxX64 (pure Kotlin fused) vs 331 ns/op (C interop).
*/
internal actual fun fieldMulReduce(
out: Fe4,