hop 03, notes

What a Go TLS 1.3 handshake costs, and what actually moves it

host
epyc host
cpu
AMD EPYC 7642, 48 cores, 16 NUMA nodes, SMT off, boost disabled, fixed 2.3 GHz
go
go1.26.5 and go1.27rc2 linux/amd64
governor
performance

Everyone knows TLS handshakes cost CPU. Fewer people know which part, or by how much. I measured a Go server on the box it deploys to: server pinned to 6 cores, load generator pinned to a different 12, generator never above 38% busy so it was never the bottleneck. The method, and the three checks that caught real errors in it, are at the end.

Three levers move the number, and they are nowhere near the same size:

leverworthcost to you
certificate key typeup to 27xnone, if you control issuance
post-quantum key exchange25%a security decision, not a performance one
fixed-base X25519 keygen9-15%merged upstream, but a patched toolchain until Go ships it

Everything else tried, GOMAXPROCS tuning, SO_REUSEPORT sharding, GOAMD64=v3, was worth nothing or negative.

Certificate key type is the largest single lever Same server, same key exchange. TLS 1.3 handshakes per second; higher is better. ECDSA P-256 ECDSA P-256: 14,040 14,040 RSA-2048 RSA-2048: 2,713 2,713 5.2x slower RSA-3072 RSA-3072: 1,037 1,037 14x slower RSA-4096 RSA-4096: 520 520 27x slower

One RSA-4096 host burns more CPU than twenty P-256 hosts. Run a multi-tenant edge where tenants supply their own certificates, and this dominates everything else here. You may not even know which one you are serving.

The key exchange Go picks for you

Go 1.26 changed defaultCurvePreferences(). Unless CurvePreferences is set explicitly, the order is now:

X25519MLKEM768, SecP256r1MLKEM768, SecP384r1MLKEM1024, X25519, CurveP256, CurveP384, CurveP521

so any modern client negotiates the post-quantum hybrid by default. Checked rather than assumed: openssl s_client against a default-configuration server reports Negotiated TLS1.3 group: X25519MLKEM768.

That costs 25.4% of handshake throughput: 14,387 against 10,729 handshakes/sec, n=8 interleaved, zero errors, both arms at 98% CPU.

Where one handshake's CPU goes, and what the patch moves Microseconds of server CPU per handshake, Go default key exchange. Lower is better. X25519 keygenX25519 ECDHML-KEM-768 encap, parse, wireeverything else stock stock · X25519 keygen: 80 µs 80 stock · X25519 ECDH: 82 µs 82 stock · ML-KEM-768 encap, parse, wire: 142 µs 142 stock · everything else: 248 µs 248 552 µs patched patched · X25519 keygen: 31 µs 31 patched · X25519 ECDH: 82 µs 82 patched · ML-KEM-768 encap, parse, wire: 142 µs 142 patched · everything else: 248 µs 248 503 µs

The hybrid adds 142.5 µs of server CPU per handshake. Isolated microbenchmarks account for 115 µs of that: 77.2 µs to encapsulate and 37.8 µs to parse the client’s 1184-byte encapsulation key, allocating 6.4 KiB. The remaining 27 µs is wire bytes and framing, and “everything else” in the chart is the remainder of the measured end-to-end total, so both are derived rather than measured directly.

For anything sitting in front of a DDoS filter, the parsing number is the interesting one. An unauthenticated ClientHello now forces roughly 115 µs of ML-KEM work and 6.4 KiB of allocation before anything is validated. The hybrid roughly doubles the pre-authentication CPU a spoofed handshake can buy.

Whether that trade is worth it is not a performance question. Post-quantum protection here is about harvest-now-decrypt-later against traffic recorded today. 25% is simply the price on this hardware. Now it is a number instead of a guess.

Resumption does not avoid it

A common assumption, and wrong for Go: handshake_server_tls13.go accepts resumption only when the client offers pskModeDHE, and only issues tickets under that same condition. Every resumed handshake still runs the full key exchange. Resumption saves the certificate signature and the certificate bytes, worth a lot on an RSA host and very little on a P-256 one, but never the ECDH or the KEM.

Fixed-base key generation

crypto/ecdh/x25519.go derived the public key by running the generic variable-base Montgomery ladder against the basepoint:

x25519Basepoint := [32]byte{9}
x25519ScalarMult(publicKey, key, x25519Basepoint[:])

so generating a key cost exactly as much as a full ECDH. A fixed-base multiplication was already in the tree, and mapping its result to the Montgomery u-coordinate costs one inversion:

X25519 keygen, variable-base ladder      80.06 µs    0 allocs
X25519 keygen, fixed-base + map          31.13 µs    0 allocs     -61.1%
  edwards25519.ScalarBaseMult            25.28 µs
  birational map, u = (Z+Y)/(Z-Y)         5.57 µs

End to end, patch applied to the toolchain, nothing changed in the application:

TLS 1.3 handshakes per second, 6 pinned cores ECDSA P-256 certificate, no resumption, n=8 interleaved. Higher is better. stock Go 1.27rc2fixed-base keygen patch X25519MLKEM768 Go's default X25519MLKEM768 · stock Go 1.27rc2: 10,748 10,748 X25519MLKEM768 · fixed-base keygen patch: 11,728 11,728 X25519 X25519 · stock Go 1.27rc2: 14,252 14,252 X25519 · fixed-base keygen patch: 16,417 16,417

That is +9.1% on the default configuration and +15.2% on plain X25519. The two levers are additive: the patch saves a fixed ~49 µs of keygen whether or not ML-KEM is running, so it is worth proportionally more once the hybrid is out of the way. Corner to corner, post-quantum stock to plain-X25519 patched, is +52.7%.

I reported this as golang/go#80858, and the fix is upstream: CL 814601, crypto/ecdh: use edwards25519 for X25519 key generation, written by Filippo Valsorda and merged to master on 2026-08-13. That is after the 1.27 freeze, so no released toolchain carries it yet. I filed two causes and only this one held up; the other is below, under what did not work. His version also adds a runtime type assertion so crypto/ecdh keeps working against a frozen older FIPS 140-3 module snapshot that predates BytesMontgomery, and lays out why clamping-then-reducing is safe here: the basepoint sits in the prime-order subgroup, so no cofactor clearing is needed, and it is not on the twist, so edwards25519 never has to represent a point it cannot.

What did not work

triedresult
GOAMD64=v3nothing. The compiler emits no MULX/ADCX/ADOX at any level; feMul/feSquare disassemble identically at v1 and v3 (911 lines, 82 MULQ, zero ADX). Deltas under 1% are layout.
GOMAXPROCS above core count-14%. stdlib peaks at GOMAXPROCS = cores.
SO_REUSEPORT sharding+9% with boost disabled, ~0 at full clock: an artifact of the reduced clock, not a real effect.
ADX field arithmeticproposed as the issue’s second cause, then withdrawn once I measured it. The roughly 2x available at field level (Multiply 28.79 ns against CIRCL’s 14.85, Square 23.06 against 11.82) comes from the limb representation and not from missing instructions: Go uses 5x51 unsaturated limbs where deferred carries are the point, and ADX exists to accelerate saturated 4x64 arithmetic. Collecting it means replacing the representation and every operation on it rather than adding an assembly variant, and that regresses every non-amd64 architecture sharing fe_generic.go.

Method, and what it caught

Both arms of every comparison are compiled before any run starts, from identical source, differing only in the toolchain or the flag under test. Arms are interleaved within one session with the starting arm rotated each repetition, and an 8-second settle gap between measurements. The rotation is not ceremony: two byte-identical stdlib servers differed by 45% in an early run purely by measurement position, because the arm measured last was competing with its predecessors’ connection teardown and GC. Without rotation that reads as a real result.

Every campaign also carries an untouched control server whose configuration never changes. Across the 72-run campaign its coefficient of variation was 1.6%. If the control moves with the target, the delta is the machine and not the change.

No number is reported unless five checks pass first, and three of them caught something:

  • Negotiated parameters, verified with openssl s_client for every arm. This caught a run where the X25519-only arms were silently taking a HelloRetryRequest: an extra round trip, so a different handshake shape than the one being claimed.
  • Certificate identity: subject CN, key algorithm, key size and wire chain depth, for all 18 arm-by-certificate combinations. This invalidated an entire 78-minute campaign: one server was serving an auto-generated P-256 certificate instead of the configured one, so all six certificate configurations produced identical numbers.
  • Generator headroom. An early configuration had the generator at 99.6% CPU while the servers idled at 61-75%, throughput flat across an 8x concurrency sweep. Every rate figure from that setup was measuring the generator’s OpenSSL. Client-side ECDSA verification costs more than the server’s signature, so the generator needs several cores per server core.
  • Errors and accept-queue overflow must both be zero.
  • CPU is charged across the pinned range from /proc/stat, not per-process: on loopback a large share of the work is softirq, which /proc/PID/stat never sees.

An RSA-4096 arm runs in every certificate campaign as a negative control. Both implementations call the same crypto/rsa, so at 4096 bits the signature swamps everything else and the arms have to converge. When they did not, the harness was wrong rather than the server, which is what exposed the certificate-loading bug above. A “+2604%” result at RSA-4096 was never a finding.

What this does not measure

Handshakes only. The generator resets each connection immediately, so nothing here covers the record layer, HTTP parsing, routing or proxying. It is loopback, so no NIC, driver or interrupt behaviour: the ratios should transfer, the absolute numbers will not. Single certificate per server, no SNI fan-out, no session resumption, TLS 1.3 only.

Most importantly: none of this says handshakes are your bottleneck. Profile the actual binary under real traffic before spending anything on the numbers above.

© 2026 Tumult1337 RSS