perf(libsignal): cache the verify-side Edwards derivations per sender key - #839
Conversation
… key The receive-side mirror of the signing memo: every incoming group message verified its SKMSG signature by re-deriving the signer's Edwards form from the Montgomery public key (a field inversion in to_edwards plus another in compressing A), because signing_key_public() rebuilt the key from bytes per message. Found via the CodSpeed flamegraph of bench_signature_verification: the two derivations are ~24% of a verify and depend only on (key, sign bit), both stable per sender. PreparedVerifyingKey caches (-A, A_compressed) per sign bit behind OnceLocks; the verify equation moves to a shared verify_signature_prepared so the plain and cached paths cannot drift. SenderKeyState memoizes the verifier next to the signing memo (seeded at creation, lazily rebuilt after a cold load, clones carry warm entries), and group_decrypt verifies through it. Measured on a 2000-message group_decrypt loop (core-pinned perf stat): 45.0 -> 33.9 us/decrypt wall, 682K -> 571K instructions per decrypt (-16.2%). A differential test pins prepared == plain across valid signatures under both sign bits, corrupted signatures/messages, flipped sign bits, wrong-length signatures and garbage keys; the state memo lifecycle is covered next to the signing memo's test.
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Repository UI Review profile: ASSERTIVE Plan: Pro Plus Run ID: 📒 Files selected for processing (6)
📝 WalkthroughSummary by CodeRabbit
WalkthroughThis PR optimizes XEdDSA signature verification by introducing PreparedVerifyingKey, which caches expensive per-sign-bit Edwards curve transformations. The core verification logic is refactored into a reusable helper, integrated into SenderKeyState's memoization lifecycle, and applied to group message decryption. ChangesXEdDSA Signature Verification Caching
Sequence DiagramsequenceDiagram
participant App
participant GroupCipher
participant SenderKeyState
participant PreparedVerifyingKey
participant curve25519
App->>GroupCipher: group_decrypt(encrypted_msg)
GroupCipher->>SenderKeyState: signing_key_verifier()
activate SenderKeyState
alt verifying_key_memo populated
SenderKeyState->>SenderKeyState: return cached PreparedVerifyingKey
else first access
SenderKeyState->>SenderKeyState: derive from signing_key_public
SenderKeyState->>SenderKeyState: store in verifying_key_memo
end
deactivate SenderKeyState
GroupCipher->>PreparedVerifyingKey: verify_signature_prepared(signature)
activate PreparedVerifyingKey
PreparedVerifyingKey->>PreparedVerifyingKey: check sign_bit cache entry
alt cache hit
PreparedVerifyingKey->>PreparedVerifyingKey: use cached Edwards point
else cache miss
PreparedVerifyingKey->>PreparedVerifyingKey: Montgomery→Edwards conversion
PreparedVerifyingKey->>PreparedVerifyingKey: compress and cache result
end
PreparedVerifyingKey->>curve25519: verify_signature_prepared(precomputed_data, sig)
curve25519-->>PreparedVerifyingKey: bool result
deactivate PreparedVerifyingKey
PreparedVerifyingKey-->>GroupCipher: bool result
GroupCipher-->>App: decrypted message or error
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes This requires careful inspection of Edwards curve math correctness, constant-time comparison guarantees, OnceLock benign-race semantics in multi-threaded contexts, cache invalidation across protobuf reloads, and integration consistency across three distinct call sites. The refactored verification logic must be traced through all paths to confirm no regressions. Possibly related PRs
Suggested labels
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Merging this PR will not alter performance
|
| Mode | Benchmark | BASE |
HEAD |
Efficiency | |
|---|---|---|---|---|---|
| ⚡ | Simulation | bench_group_decrypt_message |
229 µs | 186.4 µs | +22.82% |
| ⚡ | Simulation | bench_group_recv |
242.4 µs | 202.7 µs | +19.62% |
| 👁 | Memory | bench_group_create_distribution_message |
1.8 KB | 2 KB | -11.73% |
Tip
Curious why this is faster? Comment @codspeedbot explain why this is faster on this PR, or directly use the CodSpeed MCP with your agent.
Comparing perf/verify-edwards-cache (b69ecb3) with main (6e73467)
The CodSpeed memory instrument flagged the first design: the Edwards entries lived inline in each SenderKeyState, so every record clone in the send/receive cycle copied ~460 B (and the create path several times over). The entries now sit behind an Arc shared by all clones: 8 bytes per state, one allocation per key, and a warm entry propagates to every holder including the cached master. The simulation instrument also showed no improvement on the decrypt benches: their fixtures are fresh per iteration, so the lazy warm landed inside the measured window (production amortizes it across the record cache lifetime, as the 2000-message loop shows). Receive-side states (no private key) now pre-derive both sign-bit entries at SKDM processing, once per sender rotation, mirroring the creation pre-warm of the signing memo. Send-side states keep them cold; they never verify their own messages. 2000-message decrypt loop: 1363.5M (main) -> 1140.8M instructions, with no per-clone allocation.
|
Pushed a06540f addressing the first CodSpeed report: the Edwards entries now live behind an Arc shared by all clones (8 bytes per state, one allocation per key, warmth propagates to every holder including the cached master), which removes the ~460 B-per-clone memory cost the instrument flagged across the group benches. Receive-side states also pre-derive both sign-bit entries at SKDM processing (once per sender rotation), so the decrypt benches measure the warm steady state like production does instead of paying the first-use derivation inside their fresh-fixture window. Local 2000-message decrypt loop: 1363.5M (main) -> 1140.8M instructions. Also investigated going further with dalek's VartimeEdwardsPrecomputation (per-key NAF8 tables for [-A, B]): measured 2.8x SLOWER than the specialized vartime_double_scalar_mul_basepoint (6.5 -> 18.1 us/op), so the specialized routine stays. The remaining verify core is irreducible. |
The remaining CodSpeed memory flag on create_distribution was the verifier's Arc allocated for the sender's own state, which never verifies its own messages. Receive-side creation still builds and pre-warms it; send-side builds lazily only if ever asked. Test updated to pin the asymmetry.
Problem
The receive-side twin of #838. The CodSpeed flamegraph for
bench_signature_verification(pulled via the MCP) showsMontgomeryPoint::to_edwardsat 267.7 µs and the compression of A at ~120 µs per verification, both built on field inversions, together ~24% of a verify. Both derive solely from the signer's public key and the signature's sign bit, and a given signer always produces the same sign bit. Butgroup_decryptcalledsigning_key_public()per message, rebuilding thePublicKeyfrom the record's protobuf bytes, so every incoming group message re-derived them.Change
PreparedVerifyingKey: a verifying view of aPublicKeycaching(-A, A_compressed)per sign bit behindOnceLocks (clones carry initialized entries). Lives incore::curve, exported fromprotocol.verify_signature_preparedin curve25519.rs; the existingPrivateKey::verify_signaturenow derives its inputs and delegates, so the plain and cached paths share one implementation and cannot drift.SenderKeyStatememoizes the verifier next to the signing-key memo from perf(libsignal): memoize the sender signing key with a pre-warmed XEdDSA cache #838 (same lifecycle: seeded at creation, never persisted, lazily rebuilt after a cold load, persists for the record-cache lifetime), exposed assigning_key_verifier()returning a reference (no clone per message).group_decryptverifies viaSenderKeyMessage::verify_signature_prepared. The plainverify_signature(&PublicKey)remains for other callers.Measurements
2000-message
group_decryptloop, core-pinnedperf stat, A/B against main:Expect
bench_group_decrypt_messageandbench_group_recvto drop on the CodSpeed report.Tests
prepared == plainacross: valid signatures under BOTH sign bits (corpus asserts both were exercised), corrupted signatures, corrupted messages, flipped sign bits, wrong-length signatures, garbage keys, and warmed clones.Notes
The verify hot core (
vartime_double_scalar_mul_basepoint, ~59% of a verify) is irreducible crypto and untouched. Per-signature data (R, s, the message hash and the final compression of R') is computed per call as before.