Skip to content

perf: trim per-message copies on the crypto, cache, registry and storage paths - #1388

Merged
jlucaso1 merged 1 commit into
mainfrom
claude/performance-memory-optimization-r30cjd
Sep 2, 2026
Merged

perf: trim per-message copies on the crypto, cache, registry and storage paths#1388
jlucaso1 merged 1 commit into
mainfrom
claude/performance-memory-optimization-r30cjd

Conversation

@jlucaso1

@jlucaso1 jlucaso1 commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

Summary

A sweep of the per-message paths for work that is paid on every frame, decrypt or store write and buys nothing: a memset the next line overwrites, a scratch buffer copied once more on success, a key cloned only to be deleted by, an attribute parsed twice, N events built for zero subscribers. Every change keeps output and public API byte-identical; the one signature that moves is pub(crate). Measured on the release demo example under the pinned toolchain:

metric main this PR delta
bin size (stripped) 10,937,432 10,922,776 −14,656 (−14.3 KiB)
bin .text 10,690,126 10,675,422 −14,704 (−14.4 KiB)

Audit

Confirmed, and each one verified against the surrounding code rather than the pattern alone:

  • RustCryptoProvider::aes_256_cbc_encrypt (wacore/libsignal/src/crypto/provider.rs) did out.resize(start + encrypted_size, 0) and then copy_from_slice(plaintext) over all but the ≤16 padding bytes. One full-message memset per Signal 1:1 encrypt (session_cipher.rs), group encrypt (group_cipher.rs) and app-state mutation encode.
  • aes_256_gcm_decrypt allocated ct.to_vec(), decrypted the scratch, verified, then extend_from_slice into out: one allocation and two full-size copies per call. NoiseState::decrypt already sizes its own Vec exactly, so the pair cost two allocations for one plaintext.
  • GcmGhash::update (aes_gcm.rs) fed ghash.update one 16-byte Block per iteration over the full-block run. The AAD (from_keyed) and the tail (finalize) two lines away already go through update_padded. UniversalHash::update_padded on a block-multiple slice is exactly update(blocks) with no padding step.
  • generate_index_mac / generate_content_mac (wacore/appstate/src/hash.rs) built a CryptographicMac::new("HmacSha256" | "HmacSha512", key): a string compare chain and an enum whose largest variant is HmacReset<Sha512>, materialized on the stack per decoded record. keys.rs and lthash.rs in the same crate already use Hmac<Sha256> directly, and hmac/sha2 are already direct dependencies.
  • validate_index_mac heap-allocated a 32-byte Vec per record to compare it against expected_mac. decode.rs went out of its way to make RecordMacs.value_mac a [u8; 32] for exactly this reason.
  • process_patch (processor.rs) sized added_macs (SET only) and removed_index_macs (REMOVE only) both to patch.mutations.len(); they are disjoint and a patch is almost always all one operation, so one allocation was pure waste.
  • NoiseState::new is pub fn new(pattern: impl AsRef<[u8]>, ..) with one production caller passing &[u8] and several test/doc callers passing &[u8; N] and [u8; 32], so the body (SHA-256 branch, state construction, authenticate) was monomorphized once per argument type.
  • PortableCache (src/portable_cache.rs): remove, invalidate, get_or_update's expiry branch and get's expiry branch all called find_key to clone an owned K (String, Jid, Arc<str>) so that remove_key(&K) could delete it. remove_key's order side is keyed by the entry's own seq, so nothing there ever needed the owned key.
  • is_from_known_device (src/client/device_registry.rs), which runs on every successful group decrypt and again on NoSenderKeyState, threw the Jid away and called has_device(&sender.user, ..), the two-probe resolve_lookup_keys path. resolve_lookup_keys_for_jid exists precisely to skip the blind second lid_pn_cache probe when the namespace is known; its doc says so and get_devices_from_registry already relies on it. Same for load_device_record(&user_list.user.user) in usync.rs (per user of a device-list response) and has_device(&info.requester.user, ..) in retry.rs.
  • <enc> classification (src/message/receive.rs) read optional_string("type"), checked EncType::from_wire(..).is_none() and discarded the parsed value, then EncPayload::from_parts (src/message.rs) re-scanned the attributes for "type", re-ran from_wire, and built three separate AttrParsers over the same node for v, state and session_type.
  • Aggregated receipts (src/receipt.rs): the <participants> fan-out built one Receipt per <user> (a Vec<String>, a String clone of the id, a MessageSource with a Jid clone) and handed each to dispatch, which drops it on the floor at event_bus.dispatch when nothing subscribes to EventKind::Receipt. The loop is pure event production: no state mutation, no acks, and retry/enc_rekey_retry never use the aggregated shape, as the comment above it already states.
  • put_session_for_device, put_identity_for_device, store_prekey, store_signed_prekey (storages/sqlite-storage/src/sqlite_store.rs): each retry loop did record.to_vec() once and then .clone() of the Vec and the address String inside the loop, so the happy path (attempt 0) paid a full copy of a multi-KiB session record and the address on every Signal encrypt/decrypt. put_sessions_batch in the same file already shares an Arc across attempts.

Refuted or deliberately left out, so nobody re-audits them:

  • The dependency tree. The duplicate crates in the default build (base64 0.22/0.23, hashbrown 0.15/0.17, getrandom 0.2/0.4) come from buffa and ring; nothing in this workspace can collapse them. Feature sets on tokio, rustls, serde_json, chrono are already minimal for what is used.
  • AppStateMutationMAC.value_mac: Vec<u8> re-heaps the [u8; 32] that RecordMacs un-heaped, at both process_patch call sites. Fixing it changes a public struct and its serde shape; that is a minor-bump change, not this PR.
  • HashState.index_value_map is never written by production code and is cloned into every ProcessedSnapshot/PatchProcessingResult, but removing it is a storage-format migration.
  • The archived-session validation in SessionRecord decodes every archived session in full and drops it. The comment above it and agent_docs/signal_durability.md explain why the validation must stay; a non-allocating structural walk would need a buffa view API I could not confirm exists.
  • execute_request(request.clone()) in src/download.rs clones the request per host attempt for one error log. A handful of attempts per media download, dwarfed by the transfer; not worth the diff.
  • Everything the earlier passes already did: batched frame writes, per-chat lanes instead of per-message spawns, borrowed snapshot fields, reusable ProtocolAddress, thread-local crypto buffers, the hint-tape encoder, SWAR ltHash, cached HKDF-extract HMACs, exact reserves on the session serializer.

Design

Crypto. CBC now does reserve, extend_from_slice(plaintext), then resize for the padding tail only; the buffer handed to encrypt_padded has identical contents. GCM decrypt appends ct to out, decrypts out[start..] in place, and on a bad tag truncates back to start, so the documented "failures leave out untouched" contract holds by observable length rather than by a scratch buffer. GHASH's full-block run is one update_padded call, letting the clmul/pmull backend batch.

App state. The three per-record MACs use Hmac<Sha256>/Hmac<Sha512> typed directly; generate_index_mac keeps its Vec<u8> signature and forwards to a private index_mac_array, which validate_index_mac compares on the stack. process_patch counts SET mutations once and sizes the two lists from that.

Noise. new keeps its generic signature and forwards to a private new_inner(&[u8], &[u8]), so the body is compiled once. Handshake-only, so this is a size change, not a runtime one.

Cache. CacheInner::remove_key becomes remove_key<Q>(&Q) where K: Borrow<Q>, the same bound every public method already carries, and find_key is deleted. get's expiry path loses the clone-before-drop dance; the write-side re-check on (seq, inserted_at) is unchanged.

Registry. has_device_for_jid and load_device_record_for_jid take the single-probe lookup and share the rest of the body with the &str forms through has_device_in / load_device_record_in. is_from_known_device, usync.rs and retry.rs switch to them. The bare-user has_device has no production caller left and is now #[cfg(test)]; the tests that probe a user under both namespaces still use it.

Enc parsing. receive.rs binds the parsed EncType with let ... else and passes it into from_owned_node / from_parts, which read v, state and session_type through one AttrParser. The test-only from_node_ref parses type itself so its callers are unchanged.

Receipts. One has_handler_for(EventKind::Receipt) check before the fan-out loop, the same gate dispatch applies per event and the same idiom node_io.rs and retry.rs already use.

Storage. The four write paths copy the record into Bytes and the address into Arc<str> once, and each attempt refcount-clones them. Diesel binds take as_ref() slices, as put_sessions_batch already does.

Compatibility

No public API changes. EncPayload::from_owned_node / from_parts gain an EncType parameter and are pub(crate). Client::has_device was pub(crate) and is now test-only. NoiseState::new keeps its generic signature.

Validation

cargo fmt --all --check
cargo test -p wacore-libsignal -p wacore-appstate -p wacore-noise --lib   # 117 + 241 + 44 passed
cargo test -p whatsapp-rust --lib                                          # 1859 passed, 1 ignored
cargo check -p whatsapp-rust-sqlite-storage --lib --tests
cargo clippy -p whatsapp-rust -p wacore-libsignal -p wacore-appstate -p wacore-noise -p whatsapp-rust-sqlite-storage --all-targets -- -D warnings

The binary-size numbers above are from two cargo build --release --example demo runs with CARGO_PROFILE_RELEASE_STRIP=false on the pinned nightly, strip --strip-all on a copy and size on the original, the same measurement scripts/ci/measure_binary_size.py takes. Full matrix, Miri and the size gate left to CI.

🤖 Generated with Claude Code

https://claude.ai/code/session_01JfVpS69EnM2Nw8UUvEQc1c


Generated by Claude Code

…age paths

Measured on the release `demo` example: stripped size -14.3 KiB, .text
-14.7 KiB. Every change keeps behaviour and public API byte-identical.

Crypto (wacore-libsignal):
- AES-CBC encrypt appended the plaintext after zero-filling the whole
  output span; now only the padding tail is zeroed.
- AES-GCM decrypt decrypted into a scratch Vec and copied on success; it now
  decrypts in place on `out`'s tail and truncates back on a bad tag, so the
  "failure leaves `out` untouched" guarantee holds without the extra
  allocation and copy.
- GHASH fed the carryless-multiply backend one 16-byte block per call; the
  full-block run is now a single `update_padded`, as the AAD and tail
  already were.

App state (wacore-appstate):
- Per-record index/content MACs used the string-dispatched
  `CryptographicMac` (name compare chain plus a Sha512-sized stack object);
  they use typed `Hmac<Sha256>`/`Hmac<Sha512>` directly.
- `validate_index_mac` allocated a Vec just to compare 32 bytes; it compares
  a stack array.
- `process_patch` sized both the SET and REMOVE MAC lists to the full
  mutation count although they are disjoint.

Noise (wacore-noise): `NoiseState::new` is generic over the pattern; the
body now lives in one non-generic inner fn instead of one copy per call
site argument type.

Client:
- `PortableCache` removal/invalidation/expiry cloned the owned key just to
  delete by it; removal now takes the borrowed key.
- `is_from_known_device` (every group decrypt) and the device-list load in
  usync/retry threw the JID away and took the two-probe user lookup; they
  use the namespace-aware single-probe variant that group send already uses.
- The `<enc>` type attribute was parsed and validated twice per node; the
  parsed value is passed through, and the remaining attributes are read
  with one parser.
- Aggregated receipt fan-out built N Receipt events even with no
  subscriber; it checks for a handler once before the loop, the same gate
  `dispatch` applies per event.

SQLite storage: the session/identity/prekey write retry loops cloned the
record (a session is several KiB) and address on every attempt including
the first; they are now `Bytes`/`Arc<str>` shared across attempts.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JfVpS69EnM2Nw8UUvEQc1c
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@coderabbitai

coderabbitai Bot commented Sep 2, 2026

Copy link
Copy Markdown

Warning

Review limit reached

Next included review available in 10 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used all 4 included reviews currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Repository UI (base), Organization UI (inherited)

Review profile: ASSERTIVE

Plan: Team

Run ID: 94afcc80-a68c-4ead-9f25-ca29c18b4c2b

📥 Commits

Reviewing files that changed from the base of the PR and between 3896d9c and aa6f7c6.

📒 Files selected for processing (13)
  • src/client/device_registry.rs
  • src/message.rs
  • src/message/receive.rs
  • src/portable_cache.rs
  • src/receipt.rs
  • src/retry.rs
  • src/usync.rs
  • storages/sqlite-storage/src/sqlite_store.rs
  • wacore/appstate/src/hash.rs
  • wacore/appstate/src/processor.rs
  • wacore/libsignal/src/crypto/aes_gcm.rs
  • wacore/libsignal/src/crypto/provider.rs
  • wacore/noise/src/state.rs

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.

@greptile-apps

greptile-apps Bot commented Sep 2, 2026

Copy link
Copy Markdown

Greptile Summary

This PR reduces repeated allocations, copies, parsing, and unnecessary event construction across per-message crypto, cache, registry, receipt, app-state, and SQLite storage paths while preserving existing APIs and failure behavior.

  • Decrypts AES-GCM data directly into output buffers and batches GHASH updates.
  • Reuses parsed encryption attributes and performs namespace-aware device-registry lookups.
  • Removes owned-key clones from cache deletion and refcount-shares SQLite retry inputs.
  • Avoids constructing aggregated receipt events when no receipt subscriber exists.
  • Uses typed HMAC implementations and operation-specific app-state capacities.
  • Moves the generic Noise constructor body behind a borrowed internal helper.

Confidence Score: 5/5

The PR appears safe to merge; no concrete correctness, security, storage, or protocol regression remains.

The optimized paths retain the existing cryptographic inputs and rollback behavior, mapped registry aliases, cache expiry checks, device-scoped storage writes, parsing decisions, and event-dispatch semantics.

Important Files Changed

Filename Overview
wacore/libsignal/src/crypto/provider.rs Reuses caller-owned output storage for CBC encryption and authenticated GCM decryption while restoring the original output length on authentication failure.
src/client/device_registry.rs Adds full-JID lookup entry points that retain mapped PN/LID aliases while avoiding an unnecessary opposite-direction mapping probe.
src/portable_cache.rs Generalizes map removal to borrowed keys and eliminates owned-key cloning without changing expiry revalidation.
storages/sqlite-storage/src/sqlite_store.rs Shares addresses and byte records across retry closures through Arc and Bytes while preserving device-scoped Diesel writes.
src/message/receive.rs Passes the already-validated EncType into payload construction to avoid parsing the type attribute twice.
src/receipt.rs Skips pure aggregated receipt-event construction when the event bus has no interested handler.
wacore/appstate/src/hash.rs Replaces string-dispatched MAC wrappers with equivalent typed HMAC implementations and validates index MACs without allocating.
wacore/appstate/src/processor.rs Sizes disjoint SET and REMOVE MAC collections according to their actual operation counts.
wacore/noise/src/state.rs Moves Noise initialization into a non-generic helper without changing protocol-name hashing or authentication.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart LR
  Frames[Incoming and outgoing frames] --> Crypto[Crypto buffers and GHASH]
  Frames --> Parsing[Encryption attribute parsing]
  Parsing --> Registry[Device registry lookup]
  Registry --> Cache[Portable cache]
  Crypto --> Storage[SQLite Signal-state writes]
  Parsing --> Receipts[Receipt event fan-out]
  Frames --> AppState[App-state MAC and patch processing]
Loading

Reviews (1): Last reviewed commit: "perf: trim per-message copies on the cry..." | Re-trigger Greptile

@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 13 files

Confidence score: 5/5

  • Automated review surfaced no issues in the provided summaries.
  • No files require special attention.

Auto-approved: This is a bounded performance refactor that removes redundant allocations, copies, parsing, and event construction while preserving outputs and public APIs. The focused in-place crypto and lookup changes retain failure and lookup semantics.

Re-trigger cubic

@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown

📦 Binary size report

Metric main PR Δ
bin size (stripped) 10.45 MiB 10.42 MiB -25.16 KiB (-0.24%) 🔽
bin .text 8.38 MiB 8.36 MiB -23.69 KiB (-0.28%) 🔽
bin allocated (text+data+bss) 10.44 MiB 10.42 MiB -24.51 KiB (-0.23%) 🔽
llvm-lines wacore 565,731 565,731 0
llvm-lines wacore copies 18,526 18,526 0
llvm-lines whatsapp-rust lib 822,685 820,732 -1,953 (-0.24%) 🔽
llvm-lines whatsapp-rust lib copies 25,512 25,513 +1 (+0.00%) 🔺
deps crates (Cargo.lock) 468 468 0
.text per crate
Crate main PR Δ
.text whatsapp_rust 1.98 MiB 1.96 MiB -12.64 KiB (-0.62%) 🔽
.text wacore 741.53 KiB 741.53 KiB +3 B (+0.00%) 🔺
.text wacore_binary 81.21 KiB 81.21 KiB 0
.text wacore_libsignal 186.69 KiB 185.56 KiB -1.13 KiB (-0.60%) 🔽
.text wacore_appstate 29.31 KiB 28.34 KiB -996 B (-3.32%) 🎉
.text wacore_noise 20.92 KiB 20.92 KiB 0
.text waproto 1.79 MiB 1.79 MiB 0
.text whatsapp_rust_sqlite_storage 555.18 KiB 546.07 KiB -9.11 KiB (-1.64%) 🎉
.text whatsapp_rust_tokio_transport 40.57 KiB 40.57 KiB 0
.text whatsapp_rust_ureq_http_client 12.75 KiB 12.75 KiB 0
.text std 1.00 MiB 1.00 MiB -576 B (-0.05%) 🔽
.text other deps 1.94 MiB 1.94 MiB +710 B (+0.03%) 🔺
Top movers (cargo-bloat attribution)
Crate main PR Δ
whatsapp_rust 1.98 MiB 1.96 MiB -12.64 KiB (-0.62%)
whatsapp_rust_sqlite_storage 555.18 KiB 546.07 KiB -9.11 KiB (-1.64%)
wacore_libsignal 186.69 KiB 185.56 KiB -1.13 KiB (-0.60%)

Baseline: 3896d9ca4 (latest main run) · Head: 3cfcdde27 · Graphs

@codspeed-hq

codspeed-hq Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will improve performance by 23.42%

⚠️ 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

⚡ 4 improved benchmarks
✅ 446 untouched benchmarks
⏩ 12 skipped benchmarks1

Performance Changes

Mode Benchmark BASE HEAD Efficiency
Simulation bench_frame_decrypt_in_place[65536] 411.7 µs 267.4 µs +53.95%
Simulation bench_frame_encrypt_in_place[65536] 726.2 µs 582.4 µs +24.69%
Simulation bench_frame_decrypt_in_place[1500] 25.7 µs 23.2 µs +10.89%
Simulation bench_frame_encrypt_in_place[1500] 30.6 µs 28 µs +8.99%

Tip

Curious why performance improved? Comment @codspeedbot explain why performance improved on this PR, or directly use the CodSpeed MCP with your agent.


Comparing claude/performance-memory-optimization-r30cjd (aa6f7c6) with main (3896d9c)

Open in CodSpeed

Footnotes

  1. 12 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

@jlucaso1
jlucaso1 merged commit ec72862 into main Sep 2, 2026
38 of 39 checks passed
@jlucaso1
jlucaso1 deleted the claude/performance-memory-optimization-r30cjd branch September 2, 2026 13:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants