Skip to content

perf: 11 allocation trims from hot-path audit - #570

Merged
jlucaso1 merged 1 commit into
mainfrom
perf/allocation-audit-pass
Apr 18, 2026
Merged

perf: 11 allocation trims from hot-path audit#570
jlucaso1 merged 1 commit into
mainfrom
perf/allocation-audit-pass

Conversation

@jlucaso1

Copy link
Copy Markdown
Collaborator

Motivation

Follow-up audit sweep after PR #569 surfaced two reference wins (assemble_status_participants + resolve_skdm_targets). A background agent mapped the same class of patterns across the rest of the workspace; 11 of those hits landed here. Every finding is one of:

  • Eager .to_non_ad() / .clone() followed by a branch that discards it.
  • Clone for a consumer that only needs a borrow.
  • HashMap::new() where capacity is knowable.
  • Loop-invariant allocation inside a tight loop.

No behavior change. All wins are mechanical.

Findings, by hot-path frequency

Per inbound message

Where Fix
src/message.rs::parse_message_info Stop cloning the entire Device to read pn + lid; read via the RwLock guard, mirroring get_pn(). Saves a full Device clone (CompactStrings + Jids + keys + AdvSignedDeviceIdentity) per stanza.
src/message.rs (batch skmsg decrypt loop) Hoist sender_for_sk / sender_address / sender_key_name out of for payload in payloads — all three are loop-invariant. Saves 3 allocations per extra payload in a batch (common for group / status fanouts).

Per send

Where Fix
src/send.rs (DM fanout dedup) Replace HashSet::insert(j.clone()) retain with sort_dedup_by_device. participant_list_hash sorts internally, so reordering is safe — zero allocations now.
wacore/src/send.rs (force-SKDM distribution) Don't pre-allocate own_jid_to_check before the presence check; compare by &str user, allocate only on the push branch. Saves one Jid on the common "own already in list" path.

Per retry

Where Fix
src/retry.rs (processing key) Drop the second processing_key.clone() — move it into the scopeguard closure instead.
src/retry.rs (sender_user) Replace info.requester.user.clone() with a direct borrow; the only consumer is has_device(&str).
src/retry.rs (recipient chat) Replace recipient.clone().unwrap().to_non_ad() with an .as_ref() pattern match.

Public getters

Where Fix
src/client.rs::get_push_name / get_lid Were cloning the entire device snapshot to return one field; now read via persistence_manager.get_device_arc().read().await.<field>.clone() like get_pn() already does.
src/client/sender_keys.rs::set_sender_key_status_for_devices Borrow own_lid_user / own_pn_user as &str into the filter closure; snapshot stays alive via a binding for the duration of .collect().

Group / poll paths

Where Fix
src/features/groups.rs::query_info Collapse two iterations over group.participants into a single move-based loop. Saves one Jid clone per participant plus one CompactString per LID entry with a PN mapping.
src/features/polls.rs::vote Replace my_jid.to_non_ad() == poll_creator_jid.to_non_ad() with is_same_user_as (the reference-PR shape). Cache my_base = my_jid.to_non_ad() to share one allocation between the voter string and the equality check.
src/features/polls.rs::aggregate_votes Inline the decryption helper so creator_str = poll_creator_jid.to_non_ad().to_string() is computed once instead of per voter. Pre-allocate latest_votes with votes.len() capacity.

Map capacity

Where Fix
wacore/src/prekeys.rs::parse_prekeys_response HashMap::with_capacity(children.len()). Rehash was possible up to N users per prekey fetch.
src/sender_key_device_cache.rs::from_db_rows HashMap::with_capacity(rows.len()) + HashSet::with_capacity(rows.len() / 4).

Verification

  • cargo fmt --all
  • cargo clippy --all --tests --exclude e2e-tests — clean
  • cargo test --workspace --exclude e2e-tests --exclude bench-integration — 552 wacore lib tests + all per-crate suites green

Not included

Flagged by the audit but left alone pending human review or verified load-bearing:

  • wacore/src/store/signal_cache.rs flush uses .iter().cloned().collect() three times per flush; drain() would avoid the Arc bumps but changes failure semantics (current code clears only successful writes) — needs intent confirmation.
  • wacore/src/iq/usync.rs:429 (fields.jid.clone()), src/portable_cache.rs:139 ((k, _)|k.clone()), wacore/src/appstate_sync.rs:35 ((**arc).clone()) — verified necessary and/or cold paths.

Audit pass across the send, receive, retry, and signal paths — pattern
matches the same shape as the previous PR's win (eager to_non_ad() /
clone() then discarded, map without with_capacity, clone for consumer
that only borrows). No behavior change in any of the spots.

Per-message / per-receive:

- message.rs::parse_message_info: stop cloning the entire `Device` just
  to read .pn and .lid. Read the two fields through the RwLock guard
  directly, mirroring the get_pn() getter pattern.
- message.rs (batch skmsg decrypt loop): hoist sender_for_sk,
  sender_address, and sender_key_name out of `for payload in payloads`
  — all three are loop-invariant. Saves 3 allocations per extra
  payload in a batch (common for group status).

Per-send:

- send.rs (DM fanout): replace the `HashSet::insert(j.clone())`
  dedup-retain with `sort_dedup_by_device`. participant_list_hash
  sorts internally, so reordering is safe; now zero allocations for
  dedup.
- wacore/src/send.rs (force-SKDM distribution): don't pre-allocate
  own_jid_to_check before checking presence in the list — compare
  by `&str` user, allocate only on the push branch. Saves 1 Jid per
  send on the "own already present" path (the common case).

Per-retry:

- retry.rs: drop the second processing_key.clone() — move it into the
  scopeguard closure instead of cloning twice.
- retry.rs: replace `info.requester.user.clone()` with a direct
  borrow where the only consumer is `has_device(&str)`.
- retry.rs: turn `recipient.clone().unwrap().to_non_ad()` into
  `recipient.as_ref()` + pattern match.

Getters:

- client.rs: get_push_name and get_lid were cloning the whole
  device_snapshot. Match get_pn: read the one field via
  persistence_manager.get_device_arc().read().await.<field>.clone().
- sender_keys.rs: borrow `own_lid_user` / `own_pn_user` as `&str`
  into the filter closure instead of cloning each `CompactString`
  up-front — the snapshot stays alive for the .collect() call.

Other:

- features/groups.rs::query_info: collapse two iterations of
  `group.participants` into a single move-based loop — saves one Jid
  clone per participant, plus one CompactString clone per LID entry
  with a PN mapping.
- features/polls.rs::vote: replace
  `my_jid.to_non_ad() == poll_creator_jid.to_non_ad()` with
  `is_same_user_as` (the same fix shape from the reference PR). Cache
  `my_base = my_jid.to_non_ad()` so the voter_jid_str derivation and
  the equality check share one allocation.
- features/polls.rs::aggregate_votes: inline the decryption helper to
  cache `creator_str` once instead of recomputing
  `poll_creator_jid.to_non_ad().to_string()` per voter. Added
  HashMap::with_capacity(votes.len()) while we're here.
- wacore/src/prekeys.rs::parse_prekeys_response:
  HashMap::with_capacity(children.len()).
- sender_key_device_cache.rs::from_db_rows:
  HashMap::with_capacity(rows.len()), HashSet ditto.

552 wacore lib tests + all per-crate suites remain green.
@coderabbitai

coderabbitai Bot commented Apr 18, 2026

Copy link
Copy Markdown
📝 Walkthrough

Summary by CodeRabbit

  • Refactor
    • Optimized internal data access patterns for improved performance
    • Simplified device state handling with reduced memory allocations
    • Enhanced efficiency in message processing and poll aggregation logic

Note: These are internal improvements with no visible changes to user-facing functionality.

Walkthrough

This PR refactors data access patterns across multiple modules to optimize state management and loop iterations. Changes include replacing snapshot cloning with RwLock-guarded shared state access, precomputing invariant values outside loops, simplifying tuple constructions, and leveraging wacore helper functions for deduplication. No public API signatures are altered.

Changes

Cohort / File(s) Summary
Data Access Path Optimization
src/client.rs, src/client/sender_keys.rs, src/message.rs
Refactored to read device state through persistence_manager.get_device_arc().await.read().await (async RwLock) instead of cloning snapshots. In sender_keys.rs, optional snapshot references replace tuple cloning, with Option<&str> comparisons using is_some_and(). Device field cloning replaced with direct reference extraction.
Loop and Iterator Optimization
src/features/groups.rs, src/features/polls.rs, src/send.rs, src/sender_key_device_cache.rs, wacore/src/prekeys.rs
Consolidated multi-pass iteration patterns into single loops with preallocated collections. In polls.rs, precomputed creator_str and encryption keys outside voter loops; changed to explicit key derivation with warning-on-fail logic. In send.rs, replaced explicit HashSet dedup with wacore::sort_dedup_by_device(). HashMap and HashSet capacity hints applied based on input sizes.
Retry and Device Resolution Logic
src/retry.rs, wacore/src/send.rs
Reduced cloning of optional recipient and processing keys via borrowing. In wacore/send.rs, device "self" resolution now computes phone-number mapping for LID senders (own_pn_mapping), derives own_check_user conditionally, and pushes resolved JID only when user not already present in fanout list.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 44.44% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title 'perf: 11 allocation trims from hot-path audit' directly and clearly describes the main focus of the changeset—removing unnecessary allocations across hot paths.
Description check ✅ Passed The description provides comprehensive context about the allocation audit, specific fixes by category, and verification steps—all directly related to the changeset.

✏️ 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/allocation-audit-pass

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: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/client.rs`:
- Around line 3584-3590: The code is reading Device state via
self.persistence_manager.get_device_arc().read().await which violates the read
contract; change both occurrences (the getter that accesses push_name and the
other at lines ~3604-3610) to use
self.persistence_manager.get_device_snapshot().await so reads use the snapshot
accessor, and keep any mutations routed through DeviceCommand +
PersistenceManager::process_command(); locate uses of get_device_arc(), replace
with get_device_snapshot().await (or an approved accessor) and ensure no direct
Device state mutation occurs outside DeviceCommand/ProcessCommand.

In `@src/message.rs`:
- Around line 1101-1107: The code is incorrectly stripping device scope by
calling to_non_ad() on info.source.sender before generating the sender key name,
which can cause missing device-scoped sender-key state; remove the to_non_ad()
call and build the protocol address from the original device-scoped sender (use
info.source.sender.to_protocol_address()) so that sender_address and the call to
make_sender_key_name(&info.source.chat, &sender_address) use the device-scoped
LID protocol address instead of a non-AD normalized one.
🪄 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: 813a0e67-8f1d-4a9e-8855-2d8107818a8b

📥 Commits

Reviewing files that changed from the base of the PR and between 5b05e22 and bfbf200.

📒 Files selected for processing (10)
  • src/client.rs
  • src/client/sender_keys.rs
  • src/features/groups.rs
  • src/features/polls.rs
  • src/message.rs
  • src/retry.rs
  • src/send.rs
  • src/sender_key_device_cache.rs
  • wacore/src/prekeys.rs
  • wacore/src/send.rs

Comment thread src/client.rs
Comment on lines +3584 to +3590
self.persistence_manager
.get_device_arc()
.await
.read()
.await
.push_name
.clone()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

We need to keep device reads on the snapshot path

Line 3584 and Line 3604 now read through get_device_arc().read(). That breaks the repo’s state-access contract for reads. Move both getters back to get_device_snapshot().await (or an explicitly approved accessor that preserves the same contract).

Suggested fix
 pub async fn get_push_name(&self) -> String {
     self.persistence_manager
-        .get_device_arc()
-        .await
-        .read()
+        .get_device_snapshot()
         .await
         .push_name
         .clone()
 }
@@
 pub async fn get_lid(&self) -> Option<Jid> {
     self.persistence_manager
-        .get_device_arc()
-        .await
-        .read()
+        .get_device_snapshot()
         .await
         .lid
         .clone()
 }

As per coding guidelines: Never modify Device state directly; always use DeviceCommand + PersistenceManager::process_command() for state mutations and get_device_snapshot() for reading state.

Also applies to: 3604-3610

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/client.rs` around lines 3584 - 3590, The code is reading Device state via
self.persistence_manager.get_device_arc().read().await which violates the read
contract; change both occurrences (the getter that accesses push_name and the
other at lines ~3604-3610) to use
self.persistence_manager.get_device_snapshot().await so reads use the snapshot
accessor, and keep any mutations routed through DeviceCommand +
PersistenceManager::process_command(); locate uses of get_device_arc(), replace
with get_device_snapshot().await (or an approved accessor) and ensure no direct
Device state mutation occurs outside DeviceCommand/ProcessCommand.

Comment thread src/message.rs
Comment on lines +1101 to +1107
// Always use bare sender for sender key operations. Real WA delivers
// skmsg with bare participant but pkmsg (SKDM) with device-qualified
// participant — normalizing to bare ensures consistent lookup.
// Hoisted out of the payload loop: all three are loop-invariant.
let sender_for_sk = info.source.sender.to_non_ad();
let sender_address = sender_for_sk.to_protocol_address();
let sender_key_name = make_sender_key_name(&info.source.chat, &sender_address);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Stop stripping device scope from sender-key addresses here.

This path normalizes info.source.sender with to_non_ad(), which can collapse device-qualified LID sender identities and miss sender-key state keyed by the device-scoped address.

Suggested fix
-        let sender_for_sk = info.source.sender.to_non_ad();
-        let sender_address = sender_for_sk.to_protocol_address();
+        let sender_address = info.source.sender.to_protocol_address();
         let sender_key_name = make_sender_key_name(&info.source.chat, &sender_address);

Based on learnings: sender-key naming in this codebase should use device-scoped LID protocol addresses (to_protocol_address()), not to_non_ad().to_protocol_address().

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Always use bare sender for sender key operations. Real WA delivers
// skmsg with bare participant but pkmsg (SKDM) with device-qualified
// participant — normalizing to bare ensures consistent lookup.
// Hoisted out of the payload loop: all three are loop-invariant.
let sender_for_sk = info.source.sender.to_non_ad();
let sender_address = sender_for_sk.to_protocol_address();
let sender_key_name = make_sender_key_name(&info.source.chat, &sender_address);
// Always use bare sender for sender key operations. Real WA delivers
// skmsg with bare participant but pkmsg (SKDM) with device-qualified
// participant — normalizing to bare ensures consistent lookup.
// Hoisted out of the payload loop: all three are loop-invariant.
let sender_address = info.source.sender.to_protocol_address();
let sender_key_name = make_sender_key_name(&info.source.chat, &sender_address);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/message.rs` around lines 1101 - 1107, The code is incorrectly stripping
device scope by calling to_non_ad() on info.source.sender before generating the
sender key name, which can cause missing device-scoped sender-key state; remove
the to_non_ad() call and build the protocol address from the original
device-scoped sender (use info.source.sender.to_protocol_address()) so that
sender_address and the call to make_sender_key_name(&info.source.chat,
&sender_address) use the device-scoped LID protocol address instead of a non-AD
normalized one.

@github-actions

Copy link
Copy Markdown

Benchmark Results

59 unchanged benchmark(s)
Benchmark Current Baseline Change
reporting_token_benchmark::content_extraction_group::bench_content_extraction simple:setup_simple_message() 3,933 3,933 +0.0%
reporting_token_benchmark::content_extraction_group::bench_content_extraction extended:setup_extended_message() 12,038 12,038 +0.0%
reporting_token_benchmark::key_derivation_group::bench_key_derivation 43,514 43,514 +0.0%
reporting_token_benchmark::token_calculation_group::bench_token_calculation 19,365 19,365 +0.0%
reporting_token_benchmark::full_generation_group::bench_full_token_generation simple:setup_full_gen_simple() 68,579 68,579 +0.0%
reporting_token_benchmark::full_generation_group::bench_full_token_generation extended:setup_full_gen_extended() 76,679 76,679 +0.0%
reporting_token_benchmark::message_encoding_group::bench_message_encoding simple:setup_simple_message() 2,230 2,230 +0.0%
reporting_token_benchmark::message_encoding_group::bench_message_encoding extended:setup_extended_message() 5,988 5,988 +0.0%
send_receive_benchmark::dm_send::bench_dm_send text:setup_dm_send() 169,095 169,079 +0.0%
send_receive_benchmark::dm_recv::bench_dm_recv text:setup_dm_recv() 190,986 190,846 +0.1%
send_receive_benchmark::group_send::bench_group_send group_10:setup_group_send_10() 875,196 874,433 +0.1%
send_receive_benchmark::group_send::bench_group_send group_50:setup_group_send_50() 966,225 966,362 -0.0%
send_receive_benchmark::group_send::bench_group_send group_256:setup_group_send_256() 1,453,188 1,453,183 +0.0%
send_receive_benchmark::group_send_skdm::bench_group_send_skdm skdm_10:setup_group_skdm_10() 2,575,082 2,569,139 +0.2%
send_receive_benchmark::group_send_skdm::bench_group_send_skdm skdm_50:setup_group_skdm_50() 9,375,106 9,375,810 -0.0%
send_receive_benchmark::group_send_skdm::bench_group_send_skdm skdm_256:setup_group_skdm_256() 44,280,729 44,460,605 -0.4%
send_receive_benchmark::group_recv::bench_group_recv text:setup_group_recv() 12,393,053 12,376,458 +0.1%
binary_benchmark::marshal_group::bench_marshal_allocating 71,247 71,247 +0.0%
binary_benchmark::marshal_group::bench_marshal_auto_allocating 71,300 71,300 +0.0%
binary_benchmark::marshal_group::bench_marshal_exact_allocating 98,367 98,367 +0.0%
binary_benchmark::marshal_group::bench_marshal_reusing_buffer 78,801 78,801 +0.0%
binary_benchmark::marshal_group::bench_marshal_reusing_buffer_vec_writer 71,347 71,347 +0.0%
binary_benchmark::marshal_group::bench_marshal_long_string 7,518 7,518 +0.0%
binary_benchmark::marshal_group::bench_marshal_auto_long_string 7,561 7,561 +0.0%
binary_benchmark::marshal_group::bench_marshal_exact_long_string 9,273 9,273 +0.0%
binary_benchmark::marshal_group::bench_marshal_huge_bytes_allocating 530,504 530,504 +0.0%
binary_benchmark::marshal_group::bench_marshal_auto_huge_bytes_allocating 530,072 530,072 +0.0%
binary_benchmark::marshal_group::bench_marshal_exact_huge_bytes_allocating 531,427 531,427 +0.0%
binary_benchmark::marshal_group::bench_marshal_many_children_allocating 8,506,160 8,506,160 +0.0%
binary_benchmark::marshal_group::bench_marshal_auto_many_children_allocating 8,450,412 8,450,412 +0.0%
binary_benchmark::marshal_group::bench_marshal_exact_many_children_allocating 19,677,947 19,677,947 +0.0%
binary_benchmark::unmarshal_group::bench_unmarshal small:setup_small_marshaled() 2,468 2,468 +0.0%
binary_benchmark::unmarshal_group::bench_unmarshal large:setup_large_marshaled() 33,558 33,558 +0.0%
binary_benchmark::unpack_group::bench_unpack_uncompressed 787 787 +0.0%
binary_benchmark::unpack_group::bench_unpack_compressed 526,732 526,732 +0.0%
binary_benchmark::attr_parser_group::bench_attr_parser attr_lookup:setup_attr_marshaled() 4,986 4,986 +0.0%
binary_benchmark::roundtrip_group::bench_roundtrip small:setup_small_marshaled() 5,315 5,315 +0.0%
binary_benchmark::roundtrip_group::bench_roundtrip large:setup_large_marshaled() 61,874 61,874 +0.0%
binary_benchmark::roundtrip_group::bench_roundtrip_auto small:setup_small_marshaled() 5,347 5,347 +0.0%
binary_benchmark::roundtrip_group::bench_roundtrip_auto large:setup_large_marshaled() 61,942 61,942 +0.0%
binary_benchmark::roundtrip_group::bench_roundtrip_exact small:setup_small_marshaled() 6,734 6,734 +0.0%
binary_benchmark::roundtrip_group::bench_roundtrip_exact large:setup_large_marshaled() 85,564 85,564 +0.0%
binary_benchmark::child_iteration_group::bench_get_children_by_tag 477,570 477,570 +0.0%
binary_benchmark::jid_optimization_group::bench_jid_to_owned_access jid_access:setup_jid_heavy_marshaled() 11,563 11,563 +0.0%
libsignal_benchmark::dm_group::bench_dm_session_establishment setup:setup_dm_users() 17,223,553 17,261,538 -0.2%
libsignal_benchmark::dm_group::bench_dm_encrypt_first_message first_msg:setup_dm_session() 157,113 157,113 +0.0%
libsignal_benchmark::dm_group::bench_dm_decrypt_first_message decrypt_prekey:setup_dm_with_first_message() 5,510,200 5,510,200 +0.0%
libsignal_benchmark::dm_group::bench_dm_encrypt_subsequent_message subsequent:setup_established_dm_session() 157,827 157,827 +0.0%
libsignal_benchmark::group_messaging_group::bench_group_create_distribution_message create:setup_group_sender() 296,767 296,767 +0.0%
libsignal_benchmark::group_messaging_group::bench_group_encrypt_message encrypt:setup_group_with_distribution() 706,282 706,282 +0.0%
libsignal_benchmark::group_messaging_group::bench_group_decrypt_message decrypt:setup_group_with_encrypted_message() 12,651,340 12,690,401 -0.3%
libsignal_benchmark::conversation_group::bench_full_dm_conversation full:setup_conversation_data() 27,355,018 27,551,371 -0.7%
libsignal_benchmark::signature_group::bench_signature_creation sign:setup_keypair_with_message() 3,467,011 3,467,011 +0.0%
libsignal_benchmark::signature_group::bench_signature_verification verify:setup_keypair_with_message() 126,503,083 127,005,363 -0.4%
libsignal_benchmark::signature_group::bench_key_generation keygen 2,830,452 2,830,452 +0.0%
libsignal_benchmark::session_optimization_group::bench_decrypt_with_previous_session previous_session:setup_with_archived_sessions() 46,003 46,003 +0.0%
libsignal_benchmark::session_optimization_group::bench_out_of_order_decryption out_of_order:setup_out_of_order_messages() 5,072,844 5,072,844 +0.0%
libsignal_benchmark::session_optimization_group::bench_promote_matching_session promote:setup_promote_matching_session() 316,083 316,083 +0.0%
libsignal_benchmark::session_optimization_group::bench_message_key_eviction eviction:setup_message_key_eviction() 14,255,917 14,255,917 +0.0%
No significant changes detected.

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.

1 participant