Skip to content

perf(libsignal): memoize the sender signing key with a pre-warmed XEdDSA cache - #838

Merged
jlucaso1 merged 2 commits into
mainfrom
perf/sender-key-signing-cache
Jun 11, 2026
Merged

perf(libsignal): memoize the sender signing key with a pre-warmed XEdDSA cache#838
jlucaso1 merged 2 commits into
mainfrom
perf/sender-key-signing-cache

Conversation

@jlucaso1

Copy link
Copy Markdown
Collaborator

Problem

The CodSpeed flamegraph for bench_group_send_256 (pulled via the new MCP integration) showed OnceLock<EdwardsCacheData>::initialize inside SenderKeyMessage::new at 18% of the measured window. Tracing the path confirmed it is a real production cost, not a bench artifact: group_encrypt calls SenderKeyState::signing_key_private() on every group message, which runs PrivateKey::deserialize on the record's protobuf bytes, producing a fresh key with a cold XEdDSA cache. The signature then re-derives the cache (scalar reduction + a full Edwards basepoint multiplication) for every single send. The moka record cache keeps the record object alive across sends, but the warm key never survived because it was rebuilt from bytes each time.

Change

SenderKeyState memoizes the parsed PrivateKey in a OnceLock, and crucially pre-warms the signing cache BEFORE memoizing: the caller receives a clone, and clones of an initialized OnceLock carry its contents, so a warm memo hands out warm clones while a cold one would make every clone re-derive. Key creation (SenderKeyState::new) seeds the memo directly from the already-parsed key. The memo is never persisted (the protobuf surface is unchanged) and rebuilds lazily after a cold load from the database; since the record cache stores the live object back after every send, the memo effectively lives for the cache lifetime.

PrivateKey::precompute_signing_cache() is the new (public, side-effect-only) entry point for the pre-warm.

Measurements

2000-iteration group_encrypt loop, core-pinned perf stat, A/B against main:

main this PR
wall per send 24.7 µs 14.0 µs (-43%)
instructions (whole loop) 868.1M 460.9M (-47%)

The Edwards re-derivation was nearly half of a warm group encrypt. On the CodSpeed benches expect bench_group_send_* (and skdm_*) simulation numbers to drop accordingly.

Tests

New test pins the contract: memo populated at creation and on first use, state clones carry the warm cache, protobuf roundtrips reset it, and the memoized key still produces verifiable signatures. Full workspace suite, strict clippy, and both wasm32 CI builds pass. Verified empirically (init-counting instrumentation) that cache derivations move out of the per-send path: same total count, all at setup/creation time.

Notes

group_decrypt verifies with the public key and is unaffected. Session (DM) messages authenticate via HMAC, not per-message XEdDSA, so this path is specific to sender-key (group) sends. If a signing-key setter is ever added to SenderKeyState, it must reset the memo (documented on the field).

…DSA cache

Every group send re-deserialized the sender signing key from the
record's protobuf bytes, so the lazily derived XEdDSA cache (scalar +
Edwards point, a full basepoint multiplication) was recomputed for every
SenderKeyMessage signature: nearly half the cost of a warm group_encrypt
(found via the CodSpeed flamegraph, 18% of the group-send bench window).

SenderKeyState now memoizes the parsed PrivateKey in a OnceLock,
pre-warming the signing cache before memoizing so the clone handed to
each signature carries the warm cache (clones of a cold key would each
re-derive it). The record cache stores the live object back after every
send, so the memo persists for the cache lifetime; it is never
persisted and rebuilds lazily after a cold load. Key creation pre-warms
too, since the parsed key is already in hand.

Measured on a 2000-send group_encrypt loop (core-pinned perf stat):
24.7 -> 14.0 us/send wall, 868.1M -> 460.9M instructions (-47%).

Contract pinned by tests: memo populated at creation and on first use,
clones carry the warm cache, protobuf roundtrips reset it, signatures
still verify.
@coderabbitai

coderabbitai Bot commented Jun 11, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: b8df9390-af3a-44ad-ac10-4a3a34817d94

📥 Commits

Reviewing files that changed from the base of the PR and between c694d76 and d3ea6e7.

📒 Files selected for processing (1)
  • wacore/libsignal/src/protocol/sender_keys.rs

📝 Walkthrough

Summary by CodeRabbit

  • New Features

    • Pre-warming of the signing cache to reduce latency for initial signatures and improve signing performance.
  • Refactor

    • Memoized private signing key handling to avoid repeated deserialization and re-warming; signing material is now redacted from debug output.
  • Tests

    • New unit tests covering cache warm-up, persistence across clones, and rebuilds after serialization roundtrips.

Walkthrough

This PR adds PrivateKey::precompute_signing_cache() to eagerly warm the XEdDSA signing cache and introduces a OnceLock memo in SenderKeyState to cache the deserialized signing key (warmed on new(), lazy-warmed on first use, and tested across clone/roundtrip).

Changes

Signing Key Memoization and Cache Warming

Layer / File(s) Summary
PrivateKey cache precomputation API
wacore/libsignal/src/core/curve.rs
PrivateKey gains precompute_signing_cache() to eagerly warm the XEdDSA signing cache and has_warm_signing_cache() test helper to observe cache state.
SenderKeyState memoization field and Debug impl
wacore/libsignal/src/protocol/sender_keys.rs
SenderKeyState adds signing_key_memo: OnceLock<PrivateKey>, removes auto-derived Debug, and implements manual Debug that redacts the signing key.
SenderKeyState constructors: warm and cold states
wacore/libsignal/src/protocol/sender_keys.rs
SenderKeyState::new pre-warms and stores the provided signing key in the memo; from_protobuf produces a cold state with an uninitialized memo.
signing_key_private() lazy warm, memo check, and tests
wacore/libsignal/src/protocol/sender_keys.rs
signing_key_private() returns the memoized key or deserializes and precomputes the signing cache before storing it in the memo. Adds signing_key_memo_initialized() test helper and a unit test validating warm-on-first-use, clone survival, and rebuild after protobuf roundtrip.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • oxidezap/whatsapp-rust#242: Related changes to PrivateKey signing/caching behavior; this PR adds an eager precompute API used by the sender-key memoization.

Suggested labels

api-design

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately describes the main change: memoization of sender signing key with pre-warmed XEdDSA cache, which is the core performance optimization in this PR.
Description check ✅ Passed The description is comprehensive and directly addresses the changeset, explaining the problem, the solution, measurements, and tests related to the memoization and cache pre-warming changes.
Docstring Coverage ✅ Passed Docstring coverage is 87.50% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch perf/sender-key-signing-cache

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@wacore/libsignal/src/protocol/sender_keys.rs`:
- Around line 182-188: The Debug impl for SenderKeyState currently exposes
secret material by printing self.state and the code later stores the serialized
signing private key into sender_signing_key.private; update the Debug for
SenderKeyState to only emit non-secret metadata (e.g., key IDs, version, flags)
and redact or omit any private key bytes instead of including self.state, and
refactor the code that assigns sender_signing_key.private (in the signing key
creation/serialization path) to avoid storing raw private key bytes—store only a
public key, identifier, or a redacted marker. Ensure any Debug/Display
implementations for types used inside SenderKeyState also avoid revealing secret
fields.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 5c50bc64-2ddc-403f-8806-de5a851f7f8b

📥 Commits

Reviewing files that changed from the base of the PR and between b9e0941 and c694d76.

📒 Files selected for processing (2)
  • wacore/libsignal/src/core/curve.rs
  • wacore/libsignal/src/protocol/sender_keys.rs

Comment thread wacore/libsignal/src/protocol/sender_keys.rs

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 2 files

Reply with feedback, questions, or to request a fix.

Fix all with cubic | Re-trigger cubic

Comment thread wacore/libsignal/src/protocol/sender_keys.rs Outdated
@codspeed-hq

codspeed-hq Bot commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

⚠️ Different runtime environments detected

Some benchmarks with significant performance changes were compared across different runtime environments,
which may affect the accuracy of the results.

Open the report in CodSpeed to investigate

⚡ 5 improved benchmarks
❌ 2 (👁 2) regressed benchmarks
✅ 134 untouched benchmarks

Performance Changes

Mode Benchmark BASE HEAD Efficiency
Simulation bench_group_encrypt_message 246.6 µs 156.6 µs +57.54%
Simulation bench_group_send_10 325.6 µs 243.8 µs +33.54%
Simulation bench_group_send_50 361.8 µs 279.6 µs +29.42%
Simulation bench_group_send_256 558.6 µs 476.6 µs +17.2%
Simulation bench_group_send_skdm_10 734.7 µs 653.8 µs +12.38%
👁 Memory bench_group_create_distribution_message 1.2 KB 1.8 KB -31.01%
👁 Simulation bench_group_create_distribution_message 110.4 µs 195.4 µs -43.52%

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/sender-key-signing-cache (d3ea6e7) with main (b9e0941)

Open in CodSpeed

@jlucaso1

Copy link
Copy Markdown
Collaborator Author

About the two flagged regressions on bench_group_create_distribution_message (Simulation -43.5%, Memory -31%): both are the deliberate flip side of this change, not accidental cost. SenderKeyState::new now pre-warms the XEdDSA cache at key creation, so the Edwards derivation that previously ran on the FIRST send after every rotation moved into creation (+85 µs once per rotation), and the memoized key retains the ~600 B cache for the record's cache lifetime. In exchange, every warm send drops it: group_encrypt +57.5%, group_send_10/50/256 +34/+29/+18%, skdm_10 +12%. Net work is zero or negative globally (creation happens once per rotation; sends happen constantly), so these two entries are safe to acknowledge.

CodeRabbit/cubic review: the protobuf state embeds the serialized
private signing key, and Debug-printing it (logs, panics) leaked raw key
material. This predates the PR (the old derive printed it too); the new
manual impl shows chain metadata and an explicit redaction marker.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant