Skip to content

perf(send): memoize the group phash on the device-list memo entry - #840

Merged
jlucaso1 merged 2 commits into
mainfrom
perf/phash-memo
Jun 11, 2026
Merged

perf(send): memoize the group phash on the device-list memo entry#840
jlucaso1 merged 2 commits into
mainfrom
perf/phash-memo

Conversation

@jlucaso1

Copy link
Copy Markdown
Collaborator

Problem

The CodSpeed flamegraph for bench_group_send_256 (post-#838) shows build_group_phash_set + participant_list_hash at ~212 µs of a ~475 µs warm send: one Jid clone per device, a hosted filter, a sort-dedup, then another sort and a SHA-256 over the whole set, recomputed on every send. But both inputs are already pinned: the device set is exactly the per-group device-list memo entry from #824, and the sending jid is stable per group.

Change

ResolvedGroupDevices (new, in wacore::send) bundles the resolved device set with a lazily computed phash in a OnceLock that lives behind the memo entry's Arc:

  • Invalidation is inherited, not invented. Any topology change produces a new memo entry, so the phash starts cold with it; the perf(send): memoize the per-group device list behind a topology generation #824 re-stamp path returns the same Arc, so the warm phash survives write storms on unrelated groups. No new write path, no new rule: the five perf(send): memoize the per-group device list behind a topology generation #824 invariants are untouched (the entry value changed shape; the tracker, chokepoints and stamping logic are exactly as before, and all anchor tests pass against the new type).
  • The cell pins the sending jid (the only other input), so a sending-identity change recomputes without serving or overwriting a stale hash.
  • Zero-allocation serve. A phash is 10 bytes ("2:" + 8 base64 chars), inline in CompactString, and the stanza attr accepts CompactString directly: a warm send costs one inline copy. The cold (force_skdm) and broadcast paths keep the direct computation.

The send/receive bench fixture now builds the resolved set in setup and pre-warms the memo, so the benches measure the warm steady state the way production runs it (same lesson as #838/#839: lazy warms are invisible to fresh-fixture benches).

Expected effect

bench_group_send_256/50/10 simulation should drop substantially (the phash share grows as group size does; ~212 µs of the 256-member window). Production: every warm group send skips N clones, two sorts and a SHA-256.

Tests

  • New unit test pins: memo == direct computation, warm on first use, different-sender bypass without poisoning the pinned entry.
  • All perf(send): memoize the per-group device list behind a topology generation #824 anchor tests (hit-is-hit, scope, re-stamp, aliases, mutators) pass against the new entry type, with hit assertions strengthened from content equality to Arc::ptr_eq.
  • Full workspace suite, strict clippy, wasm32 builds green. (ack_miss_path_does_not_heap_allocate flaked once under parallel execution and passes in isolation and on rerun; unrelated path.)

Every warm group send rebuilt the phash set (one clone per device,
hosted filter, sort-dedup) and re-hashed it (sort + SHA-256), ~45% of
the post-#838 warm-send window, even though the inputs are pinned by
the per-group device memo from #824: the device set IS the memo entry
and the sending jid is stable per group.

ResolvedGroupDevices bundles the resolved set with a lazily computed
phash in a OnceLock behind the entry's Arc. The memo inherits the
device memo's invalidation for free: any topology change produces a new
entry (cold phash), re-stamps keep the same Arc (warm phash survives
write storms on unrelated groups), and no new write path or
invalidation rule is introduced, leaving the #824 invariants untouched.
The cell pins the sending jid, so a sending-identity change recomputes
instead of serving a stale hash.

A phash is 10 bytes, inline in CompactString end to end (the stanza
attr accepts it directly): a warm send costs one pointer-free copy,
zero allocations, instead of N jid clones + two sorts + a hash.

The cold/broadcast paths are unchanged. The bench fixture now builds
the resolved set in setup and pre-warms it, measuring the warm steady
state like production.
@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: c71e3132-1dbb-475b-971f-be2824864695

📥 Commits

Reviewing files that changed from the base of the PR and between 52bab19 and 03d9041.

📒 Files selected for processing (2)
  • wacore/benches/send_receive_benchmark.rs
  • wacore/src/send/resolved_devices.rs

📝 Walkthrough

Summary by CodeRabbit

  • Refactor

    • Improved group-device caching to return shared resolved-device objects, reducing rebuilding/cloning on cache hits and preserving identity for memoized entries.
    • Introduced per-sender cached group hash to avoid repeated computation during warm group sends, improving consistency and performance.
  • Tests / Benchmarks

    • Updated unit tests and send benchmarks to align with the new resolved-device representation.

Walkthrough

This refactor adds ResolvedGroupDevices (device list + sender-pinned, lazily cached phash) and swaps memo storage/returns to Arc, updating SKDM resolution, group stanza phash paths, tests, and benchmark pre-warming to use the new wrapper.

Changes

ResolvedGroupDevices memoization refactor

Layer / File(s) Summary
ResolvedGroupDevices struct and phash memoization
wacore/src/send/resolved_devices.rs, wacore/src/send.rs
New ResolvedGroupDevices struct wraps device list and OnceLock-cached phash keyed by sending JID; phash returns cached value when sender matches first cached sender, recomputes without overwriting when called with different sender; includes compute helper, Debug impl, and unit tests; CompactString import and re-export added.
Device registry memo storage
src/client/device_registry.rs
GroupDevicesMemo field type updated to Arc<ResolvedGroupDevices>; memo-disabled and recompute paths wrap Vec<Jid> results into ResolvedGroupDevices before returning/inserting.
Device registry test assertions
src/client/device_registry.rs
Unit tests updated to access device counts via devices().len() and validate memo identity/warmth using Arc::ptr_eq on the new return type across memo hits, recomputes, and invalidation scenarios.
SKDM target resolution
src/send.rs
resolve_skdm_targets and resolve_skdm_targets_memoized return Option<(Arc<ResolvedGroupDevices>, Vec<Jid>)>; SKDM filtering and needs_skdm use all_devices.devices(); all_devices_for_phash retyped accordingly and tests updated to use devices() accessor.
Group stanza phash derivation
wacore/src/send/group.rs
prepare_group_stanza parameter all_devices_for_phash retyped to Option<Arc<ResolvedGroupDevices>>; phash_for_stanza retyped to Option<CompactString>; warm path derives phash via resolved.phash(&own_sending_jid); fallback wraps computed hash as CompactString.
Benchmark pre-warming
wacore/benches/send_receive_benchmark.rs
GrpSendData fixture adds optional resolved_for_phash field; setup_group_send constructs and pre-warms ResolvedGroupDevices by calling phash once; SKDM-first fixtures disable warming by setting None; run_group_send uses pre-warmed fixture field as all_devices_for_phash.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • oxidezap/whatsapp-rust#824: Modifies the same GroupDevicesMemo and resolve_group_devices_memoized memoization logic and will intersect with this memo type change.
  • oxidezap/whatsapp-rust#545: Changes prepare_group_stanza recipient handling on the same signature refactored here for ResolvedGroupDevices phash use.
  • oxidezap/whatsapp-rust#678: Modifies the SKDM/group send plumbing around resolve_skdm_targets and all_devices_for_phash, overlapping the area refactored in this PR.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main change: memoizing the group phash computation on the device-list memo entry for performance improvement.
Description check ✅ Passed The description clearly explains the problem (phash recomputation overhead), the solution (ResolvedGroupDevices wrapper with OnceLock), and expected benefits.
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/phash-memo

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

🤖 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/benches/send_receive_benchmark.rs`:
- Around line 631-636: The warm-up call to ResolvedGroupDevices::phash is
currently discarding its Option return (let _ = resolved.phash(&alice.jid);) so
a failed warm will be ignored; change this to validate success by unwrapping or
expecting the Option (e.g. resolved.phash(&alice.jid).expect("phash warming
failed")) so any error in phash (which returns Option<CompactString>) surfaces
during benchmark setup and ensures the steady-state path is actually warmed.

In `@wacore/src/send/resolved_devices.rs`:
- Around line 50-53: The comment about a benign race is misleading: change it to
clarify that concurrent callers may compute different phashes if their
own_sending_jid differs, and that the implementation still returns the correct
phash for each caller while the first writer wins the shared memo slot; update
the comment near Self::compute, own_sending_jid, and self.phash.set to state
"benign race: concurrent callers may compute different values when
own_sending_jid differs; first writer wins the memo slot but each caller returns
its correct computed phash."
🪄 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: d631c7a0-1a9b-46a0-8fe1-3f1cffa377f9

📥 Commits

Reviewing files that changed from the base of the PR and between e5c660f and 52bab19.

📒 Files selected for processing (6)
  • src/client/device_registry.rs
  • src/send.rs
  • wacore/benches/send_receive_benchmark.rs
  • wacore/src/send.rs
  • wacore/src/send/group.rs
  • wacore/src/send/resolved_devices.rs

Comment thread wacore/benches/send_receive_benchmark.rs Outdated
Comment thread wacore/src/send/resolved_devices.rs
@codspeed-hq

codspeed-hq Bot commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will improve performance by 54.55%

⚠️ 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
❌ 1 (👁 1) regressed benchmark
✅ 136 untouched benchmarks

Performance Changes

Mode Benchmark BASE HEAD Efficiency
Memory bench_group_send_256 53.1 KB 18.9 KB ×2.8
Simulation bench_group_send_256 476.8 µs 248.7 µs +91.76%
Memory bench_group_send_50 9.4 KB 6 KB +55.58%
Simulation bench_group_send_50 279.2 µs 232.3 µs +20.19%
👁 Simulation bench_unpad_message_ref 191.9 ns 219.7 ns -12.64%

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/phash-memo (03d9041) with main (e5c660f)

Open in CodSpeed

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

Re-trigger cubic

@jlucaso1

Copy link
Copy Markdown
Collaborator Author

On the flagged bench_unpad_message_ref regression (-11.5%, +25 ns on a ~200 ns nanobench): this PR does not touch the unpad path at all (changes are send/group + device-registry + the bench fixture). It is the known codegen-unit repartition flap on sub-microsecond benches, same class as the bench_group_send_skdm_10 false positive documented in #836: changing any wacore module reshuffles inlining across the thin-LTO bench binary. Safe to acknowledge; the codegen-units=1 bench-profile follow-up would eliminate this class.

CodeRabbit: the fixture now expects the phash warm to succeed (a silent
failure would leave the bench measuring the cold path while claiming
warm), and the race comment states precisely what happens when racing
firsts carry different sending jids.
@jlucaso1

Copy link
Copy Markdown
Collaborator Author

Verified the bench_unpad_message_ref regression frame-by-frame via the CodSpeed MCP flamegraphs (main e5c660f vs PR 67e0e1f). The measured function is byte-identical in cost: unpad_message_ref self time 97.5 -> 96.7 ns (instructions 10 -> 9.2 ns, caches and memory equal). The entire +28 ns delta sits in the harness's black_box<&[u8]> frame, whose memory component went 27.8 -> 55.6 ns: exactly one extra memory-access quantum in the simulator's cost model. The new binary materializes the black_box spill with one more stack access, a thin-LTO regalloc/layout artifact of the wacore crate changing at all; neither the bench file nor messages.rs is touched by this PR. It reproduces across runs because the same crate content yields the same codegen partition, so it is deterministic per binary but non-causal to the change. Safe to acknowledge; the codegen-units=1 bench-profile follow-up eliminates this class.

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