perf(marmot): square by symmetry, and stop trusting the CPU profile

A JFR profile of create_group put 75% of all execution samples in
car25519. Rewriting car25519 to drop its modulo and its branch then changed
nothing measurable — which is the profile telling on itself. JFR's execution
sampler is safepoint-biased and the counted loops in the field arithmetic
carry no safepoint polls, so samples land on whichever method follows the
poll rather than the one burning the time.

Time the primitives end to end instead. That needs no profiler to be
believed, and multiplying by how many of them an operation performs says how
much of it is curve work:

    x25519_dh        579us      ed25519_sign     1004us
    x25519_base      582us      ed25519_verify   2102us

create_group/0 is ~4.0ms, about seven scalar multiplications; create_group/1
is ~14ms, about twenty-four. The curve primitive is essentially the whole
cost, so that is the only place a create_group speedup can come from.

Square by symmetry: in a*a every off-diagonal pair is computed twice, so
taking each once and doubling turns 256 multiplications into 136. Worth a
measured 6.6% on the scalar multiplication (579us to 541us). Ed25519 is
unchanged, as extended-coordinate point addition contains no squarings.

The car25519 simplification is kept but explicitly claims no speedup: C2 was
already strength-reducing what it removes, and ART is the target that might
not. Its KDoc now records that honestly, and warns the next reader off
profiling this file.

Adds the primitive benchmarks and a --only= filter to marmotBench, which is
what made the profiler's story falsifiable in the first place.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kCuA6tc4JQzHPCDd39GHq
This commit is contained in:
Claude
2026-09-10 14:17:01 +00:00
parent 58e684273e
commit e22b200e63
4 changed files with 176 additions and 12 deletions
@@ -30,13 +30,18 @@ private fun kb(bytes: Long) = bytes / 1024.0
fun main(args: Array<String>) {
val json = args.contains("--json")
// `--only=<substring>` 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("[")
@@ -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<BenchResult> =
/**
* 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<Pair<String, () -> 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<BenchResult> =
ALL
.filter { (name, _) -> only == null || name.contains(only) }
.map { (_, run) -> run() }
@@ -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<Pair<String, () -> BenchResult>> =
listOf(
"x25519_dh" to { benchX25519Dh() },
"x25519_base" to { benchX25519Base() },
"ed25519_sign" to { benchEd25519Sign() },
"ed25519_verify" to { benchEd25519Verify() },
)
@@ -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 {