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:
| lever | worth | cost to you |
|---|---|---|
| certificate key type | up to 27x | none, if you control issuance |
| post-quantum key exchange | 25% | a security decision, not a performance one |
| fixed-base X25519 keygen | 9-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.
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.
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:
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
| tried | result |
|---|---|
GOAMD64=v3 | nothing. 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 arithmetic | proposed 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_clientfor 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/statnever 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.