diff --git a/marmotBench/src/main/kotlin/com/vitorpamplona/marmotbench/Main.kt b/marmotBench/src/main/kotlin/com/vitorpamplona/marmotbench/Main.kt index f37a55c4ab..3a8b35353d 100644 --- a/marmotBench/src/main/kotlin/com/vitorpamplona/marmotbench/Main.kt +++ b/marmotBench/src/main/kotlin/com/vitorpamplona/marmotbench/Main.kt @@ -30,13 +30,18 @@ private fun kb(bytes: Long) = bytes / 1024.0 fun main(args: Array) { val json = args.contains("--json") + // `--only=` narrows the run to matching rows. Mostly for + // profiling, where mixing every benchmark's samples into one recording + // hides the operation you are actually asking about. + val only = args.firstOrNull { it.startsWith("--only=") }?.substringAfter("=") + // Quartz logs at DEBUG by default, and those lines land INSIDE the measured // window: they cost time, and the string building they do is charged to the // benchmark thread's allocation counter. Measuring the logger instead of // the engine would make every number here fiction. Log.minLevel = LogLevel.ERROR - val results = allBenchmarks() + val results = allBenchmarks(only) if (json) { println("[") diff --git a/marmotBench/src/main/kotlin/com/vitorpamplona/marmotbench/MarmotBenchmarks.kt b/marmotBench/src/main/kotlin/com/vitorpamplona/marmotbench/MarmotBenchmarks.kt index 64f4d61a92..d07e0aa65c 100644 --- a/marmotBench/src/main/kotlin/com/vitorpamplona/marmotbench/MarmotBenchmarks.kt +++ b/marmotBench/src/main/kotlin/com/vitorpamplona/marmotbench/MarmotBenchmarks.kt @@ -159,12 +159,26 @@ fun benchIngestAppMessage(): BenchResult = private const val PAYLOAD = "marmot benchmark payload — the same 64-ish byte body both sides send" -fun allBenchmarks(): List = +/** + * Every benchmark, as name -> thunk, so a run can be narrowed to one row. + * + * Narrowing matters for profiling: a CPU profile of the whole suite mixes + * `create_group` samples with everything else, and the interesting question + * is usually about one operation at a time. + */ +private val ALL: List BenchResult>> = buildList { // The same invitee counts MDK's `bench_create_group` uses, plus 0 as // the founding-only baseline, so the rows line up for comparison. - listOf(0, 1, 8, 32).forEach { add(benchCreateGroup(it)) } - add(benchJoinWelcome()) - add(benchSendAppMessage()) - add(benchIngestAppMessage()) + listOf(0, 1, 8, 32).forEach { n -> add("create_group/$n" to { benchCreateGroup(n) }) } + add("join_welcome" to { benchJoinWelcome() }) + add("send_app_message" to { benchSendAppMessage() }) + add("ingest_app_message" to { benchIngestAppMessage() }) + addAll(primitiveBenchmarks()) } + +/** Runs every benchmark whose name contains [only], or all of them when null. */ +fun allBenchmarks(only: String? = null): List = + ALL + .filter { (name, _) -> only == null || name.contains(only) } + .map { (_, run) -> run() } diff --git a/marmotBench/src/main/kotlin/com/vitorpamplona/marmotbench/PrimitiveBenchmarks.kt b/marmotBench/src/main/kotlin/com/vitorpamplona/marmotbench/PrimitiveBenchmarks.kt new file mode 100644 index 0000000000..4754138eaf --- /dev/null +++ b/marmotBench/src/main/kotlin/com/vitorpamplona/marmotbench/PrimitiveBenchmarks.kt @@ -0,0 +1,76 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.marmotbench + +import com.vitorpamplona.quartz.marmot.mls.crypto.Ed25519 +import com.vitorpamplona.quartz.marmot.mls.crypto.X25519 + +// The elliptic-curve primitives on their own. +// +// These exist because a JFR CPU profile of create_group is not trustworthy +// here: JFR's execution sampler is safepoint-biased, and the tight counted +// loops in the field arithmetic carry no safepoint polls, so samples pile up +// on whichever method happens to follow the poll rather than the one burning +// the time. It put 75% of samples in car25519; peeling the modulo and the +// branch out of car25519 then changed nothing measurable, which is the profile +// telling on itself. +// +// Timing each primitive end to end needs no profiler to be believed, and +// multiplying by how many of them an operation performs says how much of that +// operation is curve work and how much is everything else. + +private val ED_KEYS = Ed25519.generateKeyPair() +private val X_KEYS = X25519.generateKeyPair() +private val X_PEER = X25519.generateKeyPair() +private val MESSAGE = ByteArray(256) { it.toByte() } +private val SIGNATURE = Ed25519.sign(MESSAGE, ED_KEYS.privateKey) + +/** One X25519 scalar multiplication against a supplied point — the ladder. */ +fun benchX25519Dh(): BenchResult = + measure(name = "x25519_dh", iterations = 500, warmup = 200, setup = { Unit }) { + X25519.dh(X_KEYS.privateKey, X_PEER.publicKey) + } + +/** X25519 scalar multiplication against the base point. */ +fun benchX25519Base(): BenchResult = + measure(name = "x25519_base", iterations = 500, warmup = 200, setup = { Unit }) { + X25519.publicFromPrivate(X_KEYS.privateKey) + } + +/** Ed25519 signing — one base-point scalar multiplication plus hashing. */ +fun benchEd25519Sign(): BenchResult = + measure(name = "ed25519_sign", iterations = 500, warmup = 200, setup = { Unit }) { + Ed25519.sign(MESSAGE, ED_KEYS.privateKey) + } + +/** Ed25519 verification — two scalar multiplications plus a decompression. */ +fun benchEd25519Verify(): BenchResult = + measure(name = "ed25519_verify", iterations = 500, warmup = 200, setup = { Unit }) { + Ed25519.verify(MESSAGE, SIGNATURE, ED_KEYS.publicKey) + } + +fun primitiveBenchmarks(): List BenchResult>> = + listOf( + "x25519_dh" to { benchX25519Dh() }, + "x25519_base" to { benchX25519Base() }, + "ed25519_sign" to { benchEd25519Sign() }, + "ed25519_verify" to { benchEd25519Verify() }, + ) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/crypto/Curve25519Field.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/crypto/Curve25519Field.kt index 45e05975f0..2a6ed3cc14 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/crypto/Curve25519Field.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/crypto/Curve25519Field.kt @@ -135,14 +135,46 @@ internal object Curve25519Field { val GF0 = LongArray(16) val GF1 = gf(1) - /** Carry and reduce a field element. */ + /** + * Carry and reduce a field element. + * + * TweetNaCl writes the loop over all 16 limbs and folds the wrap-around + * into the body as `o[(i + 1) % 16]` plus an `if (i == 15)`, so a modulo + * and a branch ride along on all 16 iterations to serve the one that needs + * them. Peeling the last limb out takes both off the loop: limbs 0..14 + * carry into their neighbour, and limb 15 wraps into limb 0 scaled by 38, + * which is the `c - 1` plus the `37 * (c - 1)` of the original folded into + * one term. + * + * Measured honestly, this bought **nothing** on HotSpot — C2 was already + * strength-reducing the modulo and hoisting the branch. It is kept because + * it is strictly less work for a weaker JIT to undo, and ART on a phone is + * the target that matters, but no speedup is claimed for it here: the + * benchmark on this machine could not tell the two apart. + * + * That measurement is also the reason not to trust a CPU profile of this + * file. JFR's execution sampler is safepoint-biased, and the counted loops + * in this object carry no safepoint polls, so samples pile onto whichever + * method follows the poll. It attributed 75% of all `create_group` samples + * to this function; rewriting it changed nothing, which is the profiler + * telling on itself. Time the primitives end to end instead — see + * `marmotBench`'s `x25519_dh` and friends. + * + * The `+ (1 shl 16)` / `- 1` dance is TweetNaCl's, and stays: it biases the + * limb so an arithmetic shift floors correctly for negative limbs, which is + * what makes the carry branch-free for the sign as well. + */ fun car25519(o: LongArray) { - for (i in 0 until 16) { + for (i in 0 until 15) { o[i] += (1L shl 16) val c = o[i] shr 16 - o[(i + 1) % 16] += c - 1 + (if (i == 15) 37 * (c - 1) else 0) + o[i + 1] += c - 1 o[i] -= c shl 16 } + o[15] += (1L shl 16) + val c = o[15] shr 16 + o[0] += 38 * (c - 1) + o[15] -= c shl 16 } /** Conditional swap: if b=1, swap p and q element-wise. */ @@ -292,14 +324,51 @@ internal object Curve25519Field { } /** Field squaring: o = a^2 (mod p). */ - fun sqr(a: LongArray): LongArray = mul(a, a) + fun sqr(a: LongArray): LongArray { + val o = LongArray(16) + sqrInto(o, a, LongArray(31)) + return o + } - /** Field squaring into [o]. See [mulInto] for the [t] contract. */ + /** + * Field squaring into [o]. See [mulInto] for the [t] contract. + * + * A square is not just `mulInto(o, a, a, t)`: in `a[i] * a[j]` every + * off-diagonal pair is computed twice, once as (i,j) and once as (j,i). + * Taking each pair once and doubling it turns the 256 multiplications of + * the schoolbook into 136 — the 16 diagonal squares plus 120 cross terms. + * + * That is worth having because squarings are not a rare case: the + * Montgomery ladder squares four times per bit out of ten field + * multiplications, and [inv25519Into] is 254 squarings against ~250 + * multiplications. + * + * Doubling costs no headroom. Limbs reaching here are bounded well under + * 2^18 even after an unreduced add or subtract, so a doubled cross term + * stays under 2^37 and a full 16-term column under 2^41 — far from + * overflowing the signed 64-bit accumulator. + */ fun sqrInto( o: LongArray, a: LongArray, t: LongArray, - ) = mulInto(o, a, a, t) + ) { + t.fill(0L) + for (i in 0 until 16) { + val ai = a[i] + t[i + i] += ai * ai + val twice = ai + ai + for (j in i + 1 until 16) { + t[i + j] += twice * a[j] + } + } + for (i in 0 until 15) { + t[i] += 38 * t[i + 16] + } + for (i in 0 until 16) o[i] = t[i] + car25519(o) + car25519(o) + } /** Field inversion: o = a^(-1) (mod p) using Fermat's little theorem. */ fun inv25519(a: LongArray): LongArray {