Skip to content

perf(signal): share the sender-key message backlog behind an Arc - #881

Merged
jlucaso1 merged 1 commit into
mainfrom
perf/sender-key-shared-backlog
Jun 16, 2026
Merged

perf(signal): share the sender-key message backlog behind an Arc#881
jlucaso1 merged 1 commit into
mainfrom
perf/sender-key-shared-backlog

Conversation

@jlucaso1

@jlucaso1 jlucaso1 commented Jun 16, 2026

Copy link
Copy Markdown
Collaborator

What

Every group decrypt loads the SenderKeyRecord from the cache, and because the cache keeps its own Arc, load_sender_key clones the inner record (Arc::unwrap_or_clone). That deep-copies each state's skipped-key backlog (sender_message_keys, up to MAX_MESSAGE_KEYS entries). Once a receiver falls behind and catches up, the backlog stays populated, so every later in-order message pays the full copy even though the in-order path never reads the backlog at all (it only advances the chain key).

This moves the backlog into an Arc<Vec<...>> so a load is a refcount bump instead of a deep copy. The in-order path leaves the Arc shared; a mutation (skip-ahead caching or an out-of-order removal) pays exactly one copy-on-write via Arc::make_mut, which leaves any sharing clone (the cache's copy) untouched. The protobuf state.sender_message_keys is kept empty in memory and reassembled only at as_protobuf (serialize), so nothing on the persistence path changes.

Why Arc-COW and not a persistent (structural-sharing) structure

I deliberately did not reach for an immutable/persistent collection (e.g. rpds). Adding a dependency to this crate is expensive here: it is security-critical crypto, it is under the binary-size gate, and it is built for wasm32 and esp32. A persistent structure would additionally make the rare out-of-order catch-up worst case cheaper, but that path is a synthetic worst case, not the recurring cost. The dep-free Arc-COW split captures the common recurring win (in-order decrypts while a backlog exists) at zero dependency cost, and the only thing it gives up is speeding up that rare catch-up, which stays exactly as it is today.

Measurements

New bench bench_group_in_order_decrypt_with_backlog: a receiver with a ~2000-key backlog decrypting one in-order message on top of it. A/B on the same machine (stashing only the impl):

before (deep clone): median 97us, mean 110us, with a long allocator tail (slowest 600us)
after (Arc-COW): median 33us, mean 33us, tight (slowest 38us)

So roughly 3x faster on the median and the allocation jitter tail disappears, which is the ~82KB backlog Vec allocation no longer happening per decrypt.

No regression elsewhere: the out-of-order worst-case bench stays flat (the load-time clone simply becomes one make_mut clone of the same size when the backlog is mutated), and the empty-backlog decrypt is unchanged (cloning an empty Vec was already free).

CodSpeed will track the new bench going forward and should show the existing group benches flat.

Safety / tests

The correctness property the whole change rests on is that mutating the loaded copy must never touch the cache's copy. Two new tests cover it:

backlog_mutation_after_clone_is_isolated clones a state (mirroring the cache keeping its copy while the loaded record is handed out), then adds and removes keys through the loaded copy and asserts the original is unaffected.
serialize_roundtrip_preserves_message_key_backlog proves the split survives persistence: it builds a backlog, asserts the in-memory protobuf copy stays empty, serializes, deserializes, and recovers every key.

A debug_assert in as_protobuf guards the "protobuf copy stays empty" invariant. sender_message_keys is only accessed inside sender_keys.rs, so the invariant is fully contained.

cargo test -p wacore-libsignal (117 tests) and cargo test -p whatsapp-rust --lib (827 tests) pass; cargo clippy -p wacore-libsignal --all-targets -- -D warnings is clean. The change was also verified to build and pass in isolation from other unrelated work in the tree.

Review in cubic

A group decrypt loads the SenderKeyRecord and the load clones it (unwrap_or_clone over the cache's Arc), deep-copying the per-state skipped-key backlog (up to MAX_MESSAGE_KEYS entries). After a receiver falls behind and catches up the backlog stays populated, so every later in-order message pays the full copy even though the in-order path never reads it.

Move the backlog into an Arc<Vec> so a load is a refcount bump: the in-order path leaves it shared, and a mutation (skip-ahead caching or an out-of-order removal) pays one copy-on-write via Arc::make_mut, leaving the cache's copy intact. The protobuf copy is kept empty in memory and reassembled only at serialize.

Dep-free Arc-COW rather than a persistent (structural-sharing) structure: a dependency in this security-critical, size-gated, wasm/esp32-built crate isn't justified to also speed up the rare out-of-order catch-up worst case; Arc-COW captures the common recurring in-order win at zero dependency cost.

New bench bench_group_in_order_decrypt_with_backlog: median ~97us -> ~33us (~3x). The out-of-order worst-case bench stays flat (the load clone becomes one make_mut clone of the same size) and the empty-backlog decrypt is unchanged.
@coderabbitai

coderabbitai Bot commented Jun 16, 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: 12caf6de-5bb2-48a9-a9fb-6efae43e508c

📥 Commits

Reviewing files that changed from the base of the PR and between a03672e and 38cced9.

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

📝 Walkthrough

Summary by CodeRabbit

  • Performance

    • Improved efficiency of group message decryption through optimized backlog handling.
  • Tests

    • Added benchmark testing for group message decryption scenarios with message backlogs.
    • Expanded test coverage for message key management and serialization consistency.

Walkthrough

SenderKeyState is refactored to keep its skipped-key backlog in an Arc<Vec<SenderMessageKey>> (message_keys) rather than directly in the protobuf-backed state.sender_message_keys. The protobuf field is kept empty in memory and repopulated only at serialization. A new benchmark measures group_decrypt cost for an in-order message when a pre-filled backlog is present.

Changes

SenderKeyState Arc Backlog Refactor and Benchmark

Layer / File(s) Summary
SenderKeyState Arc backlog field and construction
wacore/libsignal/src/protocol/sender_keys.rs
Adds message_keys: Arc<Vec<SenderMessageKey>> to SenderKeyState with COW/serialization docs; updates Debug to report Arc backlog length; new initializes the Arc as empty, from_protobuf extracts the protobuf backlog via mem::take into the Arc.
COW mutations, serialization, and tests
wacore/libsignal/src/protocol/sender_keys.rs
as_protobuf adds a debug_assert enforcing empty in-memory invariant, then rehydrates from the Arc for serialization. add_sender_message_key writes via Arc::make_mut; remove_sender_message_key skips COW clone on misses. Two new tests verify serialize/deserialize roundtrip and clone isolation.
In-order group decrypt benchmark with backlog
wacore/libsignal/benches/libsignal_benchmark.rs
setup_group_in_order_decrypt_with_backlog fills the receiver backlog by decrypting newest-first, then returns the next in-order ciphertext. bench_group_in_order_decrypt_with_backlog benchmarks group_decrypt on that ciphertext to measure Arc-clone cost in load_sender_key.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

  • oxidezap/whatsapp-rust#858: Directly extends the same benchmark file with group decrypt scenarios for sender-key backlogs, including the worst-case out-of-order case that this PR builds upon.
  • oxidezap/whatsapp-rust#713: Targets the same load_sender_key cloning cost that this PR's benchmark is explicitly measuring, optimizing it by returning Arc<SenderKeyRecord>.
  • oxidezap/whatsapp-rust#224: Also adds group decryption benchmark coverage to libsignal_benchmark.rs, part of the same benchmark evolution lineage.

Suggested labels

performance

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title 'perf(signal): share the sender-key message backlog behind an Arc' directly and clearly describes the main optimization: moving the backlog into an Arc to improve performance.
Description check ✅ Passed The description provides comprehensive context on the problem (unnecessary deep copies), the solution (Arc-COW structure), design rationale, and includes measurements and test coverage details—all directly relevant to the changeset.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% 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-shared-backlog

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.

@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.

No issues found across 2 files

Re-trigger cubic

@github-actions

Copy link
Copy Markdown

📦 Binary size report

Metric main PR Δ
bin size (stripped) 10.61 MiB 10.61 MiB +1.25 KiB (+0.01%) 🔺
bin .text 8.74 MiB 8.74 MiB +1.00 KiB (+0.01%) 🔺
bin allocated (text+data+bss) 10.61 MiB 10.61 MiB -8 B (-0.00%) 🔽
llvm-lines wacore 645,616 645,636 +20 (+0.00%) 🔺
llvm-lines wacore copies 17,669 17,671 +2 (+0.01%) 🔺
llvm-lines whatsapp-rust lib 657,082 657,120 +38 (+0.01%) 🔺
llvm-lines whatsapp-rust lib copies 20,006 20,008 +2 (+0.01%) 🔺
deps crates (Cargo.lock) 354 354 0
.text per crate
Crate main PR Δ
.text whatsapp_rust 1.48 MiB 1.48 MiB -7 B (-0.00%) 🔽
.text wacore 544.63 KiB 544.63 KiB 0
.text wacore_binary 158.83 KiB 158.83 KiB 0
.text wacore_libsignal 170.47 KiB 170.85 KiB +390 B (+0.22%) 🔺
.text wacore_appstate 35.26 KiB 35.26 KiB 0
.text wacore_noise 30.68 KiB 30.68 KiB 0
.text waproto 895.34 KiB 895.34 KiB 0
.text whatsapp_rust_sqlite_storage 206.21 KiB 206.21 KiB 0
.text whatsapp_rust_tokio_transport 33.09 KiB 33.09 KiB 0
.text whatsapp_rust_ureq_http_client 6.19 KiB 6.19 KiB 0
.text std 1.13 MiB 1.14 MiB +618 B (+0.05%) 🔺
.text other deps 4.02 MiB 4.02 MiB 0
Top movers (cargo-bloat attribution)
Crate main PR Δ
regex_automata 2.03 KiB 4.16 KiB +2.13 KiB (+104.86%)
prost 466.39 KiB 464.26 KiB -2.13 KiB (-0.46%)

Baseline: a03672ee5 (latest main run) · Head: bc202e0ab · Graphs

@codspeed-hq

codspeed-hq Bot commented Jun 16, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 174 untouched benchmarks
🆕 2 new benchmarks

Performance Changes

Mode Benchmark BASE HEAD Efficiency
🆕 Memory bench_group_in_order_decrypt_with_backlog N/A 1.5 KB N/A
🆕 Simulation bench_group_in_order_decrypt_with_backlog N/A 181.4 µs N/A

Comparing perf/sender-key-shared-backlog (38cced9) with main (a03672e)

Open in CodSpeed

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