perf(send): pin the warm group stanza as flat in group size - #1279
Conversation
An external profile of a 128-member group send reported ~736 instructions
of growth per additional member per message, attributing part of it to the
encoder. Measured in-process, `prepare_group_stanza` is flat: 333,963
instructions at 8 members and 334,099 at 512 (+136 over +504 members). A
steady-state group send carries no per-participant data at all, so there is
no encoded participant node to cache between sends.
The reason the group benchmarks looked like they scaled is the fixture:
`run_group_send` built and dropped an N-participant `GroupInfo` inside the
measured body. Production resolves the group once and holds it behind an
`Arc` across sends (`ensure_self_in_group` returns the same `Arc` whenever
we are already a member, the steady state), so no send pays that. At 512
members it charged 26.8K instructions per send, 7.2% of the measurement,
and was the whole of the apparent group-size growth.
Hoist it into setup, pin the shape claim as a test (the encoded warm
stanza is the same size for a group of 8 and a group of 512), and sweep
the one group stanza whose encode really is proportional to participants —
the sender-key distribution fan-out — so a regression that folds
per-participant state into the warm stanza is distinguishable from a group
that is merely redistributing.
Per warm group send, by callgrind, before -> after:
8 members 341,312 -> 340,670
32 members 342,958 -> 339,932
128 members 349,612 -> 340,914
512 members 374,812 -> 340,890
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TeyUovZnNhUUR2bNhL3QhY
📝 WalkthroughSummary by CodeRabbit
WalkthroughThe PR caches ChangesWarm Group Fanout
Estimated code review effort: 3 (Moderate) | ~25 minutes Possibly related PRs
Suggested labels: Suggested reviewers: 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
|
| Filename | Overview |
|---|---|
| wacore/benches/send_receive_benchmark.rs | Hoists group participant construction and self-inclusion into benchmark setup. |
| wacore/src/send/tests.rs | Extends warm-group coverage to single-device and companion-device account shapes. |
| wacore/binary/benches/group_fanout_benchmark.rs | Adds a standalone typed-JID fanout marshalling benchmark. |
| wacore/binary/Cargo.toml | Registers the new standalone benchmark target. |
Reviews (6): Last reviewed commit: "test(send): make the warm fixture actual..." | Re-trigger Greptile
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 2fef0272eb
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| let devices: Vec<Node> = (0..width) | ||
| .map(|i| { | ||
| NodeBuilder::new("to") | ||
| .attr("jid", format!("5511999990000:{i}@s.whatsapp.net")) |
There was a problem hiding this comment.
Keep fanout device IDs within the AD_JID range
At the 512-recipient benchmark width, i reaches 256, but parse_jid_meta accepts device components only as u8; recipients 256–511 therefore stop using the intended AD_JID encoding and are encoded as JID_PAIR values with the colon embedded in the user. This makes the largest measurement mix two encoding paths and no longer represent a real sender-key fanout, undermining the scaling result the new benchmark is meant to track. Generate additional users while keeping every device ID at or below 255.
Useful? React with 👍 / 👎.
…size Three fixes to the new fixtures, none touching production code. The size assertion was flaky and CI caught it: every plaintext is padded by a uniform random 1..=16 bytes, so the skmsg ciphertext length differs run to run (CI hit 274 vs 291). Normalise every byte payload to a fixed size before comparing — the claim under test is the stanza's structure and attributes, not the ciphertext. 40/40 stable locally after the change. The warm fixture also over-claimed. A steady-state send distributes no sender key to group *members*, but it does re-distribute to our own companions on every send, because own devices are never memoized warm (WA Web `!isMeDevice`). A blanket "no <participants>" assertion therefore pinned only the single-device shape. The test now runs both steady states — 0 and 2 own companions — at 8 and at 512 members, and asserts the distributed count equals the companion count in each. That is the real invariant: the stanza tracks our own device count, never the group's. The fan-out marshal sweep modelled recipients as one user numbered 0..width, so above 255 the device component overflowed `parse_jid_meta`'s `u8` and those recipients silently encoded as JID_PAIR — the 512-wide measurement was averaging two wire shapes. Recipients are now a typed `Jid` per device (as `build_participant_node` passes them, skipping a string classifier production never runs) spread over distinct users with 4 devices each, so the whole sweep stays on the AD_JID path. `create_fanout_node` keeps its original width-8 body, leaving `bench_unmarshal_fanout`'s baseline untouched. Sweep after the fix: 1.55 / 4.81 / 17.7 / 88.3 µs at widths 8 / 32 / 128 / 512 — still linear, ~0.17 µs per recipient. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TeyUovZnNhUUR2bNhL3QhY
Merging this PR will improve performance by 99.95%
|
| Mode | Benchmark | BASE |
HEAD |
Efficiency | |
|---|---|---|---|---|---|
| ⚡ | Memory | bench_group_send_256 |
19.2 KB | 3.2 KB | ×6.1 |
| ⚡ | Memory | bench_group_send_50 |
6.3 KB | 3.2 KB | +99.19% |
| ⚡ | Memory | bench_group_send_10 |
3.8 KB | 3.1 KB | +19.94% |
| ⚡ | Simulation | bench_group_send_256 |
225.8 µs | 205.2 µs | +10.05% |
| 🆕 | Memory | bench_marshal_exact_group_fanout[128] |
N/A | 29.4 KB | N/A |
| 🆕 | Memory | bench_marshal_exact_group_fanout[32] |
N/A | 7.4 KB | N/A |
| 🆕 | Memory | bench_marshal_exact_group_fanout[512] |
N/A | 117.4 KB | N/A |
| 🆕 | Memory | bench_marshal_exact_group_fanout[8] |
N/A | 1.9 KB | N/A |
| 🆕 | Simulation | bench_marshal_exact_group_fanout[128] |
N/A | 187.9 µs | N/A |
| 🆕 | Simulation | bench_marshal_exact_group_fanout[32] |
N/A | 56.7 µs | N/A |
| 🆕 | Simulation | bench_marshal_exact_group_fanout[512] |
N/A | 711.1 µs | N/A |
| 🆕 | Simulation | bench_marshal_exact_group_fanout[8] |
N/A | 23.2 µs | N/A |
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 claude/group-send-perf-analysis-if3q1x (d14a123) with main (5c9e4dc)
Footnotes
-
2 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. ↩
|
@codspeedbot why the benchmark bench_attr_parser regressed? |
The 8.08% on Where the 0.69 µs is
+0.69 µs in Reproduced locallyBuilt the
Same binary, same inputs: 8.57 µs when the rest of the suite ran first, 7.95 µs on its own. Restricting the run to the new fan-out benches plus Why it is already fixedThe commit on top of the one CodSpeed measured ( Two caveats worth having on record:
No code changes made; nothing to fix beyond the commit already on the branch. Summary
Learnings updated. See what CodSpeed remembers about this repository. Was this response helpful? React with 👍 or 👎 to help us improve CodSpeedBot. |
|
It didn't — the base and head ran on different CPUs, and CodSpeed's own comparison names Base run (
57 other benchmarks crossed the same Intel→AMD swap and came back unchanged, so the swap mostly cancels; The run on 77bf9ab will re-measure it; if it lands on a matching runner the delta should disappear. I'll flag it if it doesn't. Generated by Claude Code |
CodSpeed flagged `bench_attr_parser` as +369 Ir/iteration, and it was real — not the Intel→AMD runner swap I first attributed it to. Measured locally with callgrind, base 8,605.4 Ir/iteration against head 8,906.3. None of it is library code. The fan-out fixture instantiates the typed-JID attribute path (`NodeBuilder::attr::<Jid>`), and adding it to that crate root shifted inlining enough that `create_attr_node`'s builder stopped pre-sizing its `SmallVec` and grew through an extra realloc instead: the delta is `Attrs::insert` +261.7, `SmallVec::try_grow` +254.7, `realloc` +255 — the owned-node path, which `bench_attr_parser` reaches only from its setup, never from the `unmarshal_ref` + attr-parse body it measures. Moving the sweep to its own crate root puts `bench_attr_parser` back at 8,605.4 — exactly the base figure — and leaves `binary_benchmark.rs` byte-identical to main, so no baseline in it can move. A new fixture should not be able to shift an unrelated benchmark. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TeyUovZnNhUUR2bNhL3QhY
|
Correcting myself: I was wrong above. The runner did differ, but that is not what caused this — the regression is real, and it's mine. Fixed in 527d245. Measured locally with callgrind, same binary, same machine, both built from
+300.9, which lines up with the +369 the wizard measured. Cause. No library path got slower. The fan-out fixture I added instantiates the typed-JID attribute path (
Fix. The sweep moved to its own bench target, The four Generated by Claude Code |
|
@codspeedbot's analysis is right and mine was wrong on the where. Correcting the record, since I posted a specific attribution above. I said the extra ~300 Ir sat in the owned-builder path ( Both of the fixes for it are on the branch and they are independent:
Generated by Claude Code |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 527d2454a8
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
…y use `Client::marshal_node_for_send` routes every outbound stanza through the two-pass `marshal_exact`; the sweep was calling one-pass `marshal_auto`. The two differ in exactly what this benchmark measures — one-pass reserves and grows, two-pass plans the size and replays a hint tape — so presenting the result as the encoder cost of a cold group send tracked a path no group send takes. `binary_benchmark` already pins `marshal_exact` as the production strategy for the same reason. Still linear, at the two-pass rate: 1.79 / 5.44 / 20.2 / 104.2 µs at widths 8 / 32 / 128 / 512, ~0.20 µs per recipient against 0.17 one-pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TeyUovZnNhUUR2bNhL3QhY
Dismissed because a newer commit was pushed; Greptile will re-review the current head.
📦 Binary size report
.text per crate
Baseline: |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: e9a46cd797
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
The multi-device fixture passed the two own companions as `distribution_targets` while handing `ResolvedGroupDevices` only the group members, so `<participants>` named devices the `phash` did not cover — a shape no send produces. Production filters the SKDM targets out of that very set (`filter_skdm_targets` over `all_devices_for_phash`), and the server validates the phash against every recipient device, so the targets are always a subset of what is hashed. The companions now go into the resolved set the phash is computed over. None of the assertions move: the count still matches the companions, the phash is still fixed-width, and the encoded sizes still match at 8 and 512. What changes is that the fixture now stands for a stanza the server would accept. Re-ran 25× to confirm the padding-normalised comparison is stable. Also states what the fan-out sweep's payload models. `type="msg"` is the redistribution shape, for devices that already hold a pairwise session; first contact emits `type="pkmsg"`, whose PreKeySignalMessage adds an identity key, a base key and the registration id — roughly twice the bytes. Marshalling is linear in payload size, so that case rides the same slope from a higher intercept, and the per-recipient term this sweep exists to pin is unmoved. Building real ciphertexts is not available here in any case: wacore-binary does not depend on libsignal. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TeyUovZnNhUUR2bNhL3QhY
Dismissed because a newer commit was pushed; Greptile will re-review the current head.
The fixture doc was corrected to name the `type="msg"` redistribution shape, but the bench comment above it still opened with "cold group send" — the exact wording that was wrong. Both now agree. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TeyUovZnNhUUR2bNhL3QhY
There was a problem hiding this comment.
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/src/send/tests.rs`:
- Around line 4065-4071: The stanza-shape helper and assertions in the relevant
test should compare the full node hierarchy recursively, including each node’s
tag and descendants, so changes inside <to> or <enc> are detected. Strengthen
the phash assertion to require the “2:” prefix and preserve the ten-character
length requirement.
🪄 Autofix
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 (base), Organization UI (inherited)
Review profile: ASSERTIVE
Plan: Pro Plus
Run ID: ddd36af0-fe34-4ecc-8704-d5e40180ba16
📒 Files selected for processing (3)
wacore/binary/Cargo.tomlwacore/binary/benches/group_fanout_benchmark.rswacore/src/send/tests.rs
`child_tags` only looked at the root's direct children, so a `<to>` or `<enc>` subtree that grew with the group could slip past it — and a length-neutral change (a renamed attribute) would slip past the size comparison too. The shape is now walked recursively: tag, sorted attribute keys, then children. Attribute *keys* only, since the values legitimately differ — the phash digests two different device sets. Confirmed it bites by mutating a nested `<enc>` on the 512-member stanza alone: the old assertion passed, the new one fails with the full hierarchy in the message. The phash assertion accepted any ten-character value while the doc claimed "2:" plus 8 base64 chars. It now asserts the prefix as well, which is what makes it the phash the server expects rather than some other attribute that happens to be ten characters wide. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TeyUovZnNhUUR2bNhL3QhY
Dismissed because a newer commit was pushed; Greptile will re-review the current head.
There was a problem hiding this comment.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
wacore/src/send/tests.rs (1)
4021-4037: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick winVerify the actual fan-out targets, not only the target count.
The request supplies
distribution_targetsfromown_companionsbeforeprepare_group_stanzaruns. This bypasses target-selection logic if that logic lives upstream. The assertion then checks only the number of<participants>children. A wrong list containing two group members passes.If
prepare_group_stanzais only the serializer, move this regression to the caller that computesdistribution_targets. Otherwise, exercise the production selection path. In both cases, compare the emittedjidvalues with the expected companion JIDs.Also applies to: 4084-4093
🤖 Prompt for 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. In `@wacore/src/send/tests.rs` around lines 4021 - 4037, Update the regression tests around prepare_group_stanza to exercise the production distribution-target selection path instead of prepopulating distribution_targets from own_companions. Assert the emitted participants’ jid values match the expected companion JIDs, not merely the participant count; if prepare_group_stanza only serializes targets, move the test to its caller where distribution_targets is computed.
🤖 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.
Outside diff comments:
In `@wacore/src/send/tests.rs`:
- Around line 4021-4037: Update the regression tests around prepare_group_stanza
to exercise the production distribution-target selection path instead of
prepopulating distribution_targets from own_companions. Assert the emitted
participants’ jid values match the expected companion JIDs, not merely the
participant count; if prepare_group_stanza only serializes targets, move the
test to its caller where distribution_targets is computed.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI (base), Organization UI (inherited)
Review profile: ASSERTIVE
Plan: Pro Plus
Run ID: 82b71d91-5f1f-4712-9263-8aaf6cd9ee20
📒 Files selected for processing (1)
wacore/src/send/tests.rs
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: b4b54e9fe9
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
The fixture claimed the companions' SKDM encrypts to `msg`, but
`process_prekey_bundle` alone leaves the session holding a pending pre-key,
so it was emitting `pkmsg` — first contact, not the steady state the test
is named for. Adding an `enc type` assertion proved it: `left: Some("pkmsg")`.
Clearing the pending key is what the companion's reply does in production,
so the fixture now does that too and the assertion holds it there. Also
compares the `<to jid>` values against the expected companions rather than
counting them, so a list of the right length addressing group members would
fail — which is the regression this test exists to catch.
Fixes a wrong claim in the fan-out sweep's doc as well. It said the larger
`pkmsg` payload "rides the same slope from a higher intercept". It does not:
`marshal_exact` copies every payload through the writer, so those bytes are
paid once per recipient and raise the slope. The doc now says the sweep does
not characterize a first-contact fan-out and must not be extrapolated to it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TeyUovZnNhUUR2bNhL3QhY
Dismissed because a newer commit was pushed; Greptile will re-review the current head.
Summary
Target: encoding proportional to the participant count on the group send path. An external profile of a 128-member
group-sendreported ~736 instructions of growth per additional member per message, withclassify_string_hint/write_node/node_encoded_size_with_cachenamed as +16.5K Ir / +155 calls of it, and suggested the encoded participant node was a candidate for caching between sends.Refused: there is nothing per-member to cache. Measured in-process,
prepare_group_stanzacosts 333,963 instructions at 8 members and 334,099 at 512 — +136 instructions over +504 members.<participants>carries sender-key distributions only, and a steady-state send distributes none to members; thephashcovering the whole device set is a fixed-width digest memoized on the resolved set. The encoded warm stanza is the same size at 8 and at 512 members.node_encoded_size_with_cache's recursion mark in the external profile is not a missing cache —StringHintCacheis a single-marshal plan→encode replay tape, by construction, not a memo across sends.The one thing a warm send does fan out is a fresh SKDM to our own companion devices, every send, because own devices are never memoized warm (WA Web
!isMeDevice, seeupdate_sender_key_devices). So the stanza's per-recipient content tracks our own device count and never the group's. Both steady states are pinned below.What actually scaled in here was the benchmark.
run_group_sendbuilt and dropped an N-participantGroupInfoinside the measured body. Production resolves the group once and holds it behind anArcacross sends —ensure_self_in_grouphands the sameArcstraight back whenever we are already a member, which is the steady state — so no send pays that. At 512 members the fixture charged 26.8K instructions per send, 7.2% of the measurement, and it was the whole of the apparent group-size growth.Changes
wacore/benches/send_receive_benchmark.rs— hoistGroupInfoconstruction (and the one-time self-append) out ofrun_group_sendintosetup_group_send, matching how production holds the resolved group across sends.bench_group_send_*now measures the send.wacore/src/send/tests.rs—warm_group_stanza_size_tracks_own_devices_not_group_size: prepares a warm group stanza at 8 and at 512 members, for both a single-device account and a two-companion one, and asserts the<to jid>values are exactly our companions (not merely how many), that each carriesenc type="msg", that thephashis a2:-prefixed fixed-width digest, that the whole node hierarchy matches recursively, and that the encoded sizes match. Pinned as a test, not a bench, because the claim is about the shape of the stanza — a change that folded member state into it would still benchmark fine on a small group. Sizes are compared with byte payloads normalised, since every plaintext is padded by a uniform random 1..=16 bytes and the ciphertext length therefore differs run to run.wacore/binary/benches/group_fanout_benchmark.rs(new target) —bench_marshal_exact_group_fanoutover widths[8, 32, 128, 512], sweeping the one group stanza whose encode really is proportional to its recipients (the SKDM fan-out). Keeping both facts measurable is what distinguishes "the warm stanza grew a per-member node" from "this account has companions" or "this group is redistributing". Recipients are a typedJidper device spread over distinct users, asbuild_participant_nodeproduces them, and the sweep marshals throughmarshal_exactbecause that is whatClient::marshal_node_for_sendpicks for every outbound stanza. Its ciphertexts aretype="msg", so it characterizes redistribution and explicitly not a first-contact fan-out. It is its own crate root rather than a section ofbinary_benchmarkbecause the fixture perturbed an unrelated benchmark there — see below.binary_benchmark.rsis byte-identical tomain.No production code changed.
Cost
Method: callgrind (valgrind 3.22) over a driver built from the same bench fixture, running K warm sends after one setup. Per-send instructions are
(Ir at K=51 − Ir at K=1) / 50, so fixture setup cancels exactly. Allocations fromdivan::AllocProfiler. Times fromcargo bench(profile.bench).Per warm group send, instructions:
Before, 8 → 512 grew +9.8%; after it is flat within ±0.3% with no trend.
prepare_group_stanzaitself is unchanged by this PR and was already flat (333,963 → 334,099).The encoder, per warm group send — identical at 8 and at 512 members:
marshalwrite_nodeNode::encode_attrsclassify_string_hintwrite_string_with_hintparse_jid_metaMarshalling is 1.5% of a warm group send and does not move with the group.
Allocation, per warm group send: 22 allocations / 3.58 KB, identical at 10, 50 and 256 members.
malloc+free+realloc≈ 6.0K Ir = 1.8% of the send.Hashing, per warm group send, inside
wacore:hash_one722 Ir / 2 calls +sip::Hasher::write518 Ir / 6 calls = 1,240 Ir = 0.37%, identical at 8 and at 512.Where a warm group send actually goes (inclusive, 8 members, 334K total):
SenderKeyMessageOne signature per message is the protocol's cost, not ours. (The external profile's "Ed25519 verify 40.8%" is the same operation seen from the receive side; on the send side it is
calculate_signature.)Fan-out marshal sweep, wall time: 1.79 µs (8) / 5.44 µs (32) / 20.2 µs (128) / 104.2 µs (512) — ~0.20 µs per recipient, linear, for the
msgredistribution shape. A first-contact fan-out carries the largerpkmsgpayload once per recipient, which raises that per-recipient term; this sweep is a lower bound there and is documented as not characterizing it.CodSpeed against the local numbers
Latest run against
main(5c9e4dc): 0 regressions, 4 improved, 238 untouched, 8 new.BASEHEADbench_group_send_256bench_group_send_256bench_group_send_50bench_group_send_10Divergences from what I measured locally, all expected:
Vec— 256Jids, ~16 KB — that the fixture allocated and dropped every iteration and production never does. The post-fix figure is flat at 3.2 KB across 10/50/256, matching the 3.58 KB flat myAllocProfilerrun measured (the two instruments account differently; the flatness is the claim).The fan-out sweep is on record in CI as linear in both instruments — 23.2 / 56.7 / 187.9 / 711.1 µs and 1.9 / 7.4 / 29.4 / 117.4 KB at widths 8 / 32 / 128 / 512. Binary size is unchanged (every metric 0).
bench_attr_parser, +300 Ir — found and removed. CodSpeed flagged it, and my first reading (runner variance) was wrong; it reproduced locally at 8,605.4 → 8,906.3 Ir/iteration on one machine. My second reading was also wrong on the where: I put it in the owned-builder path in setup, but the flamegraph shows the singlemallocinDecoder::read_attributestaking glibc'sunlink_chunkslow path. No library code changed; the cause was the 512 transientformat!-built JID strings the first fan-out fixture allocated per iteration. Two commits close it independently — the typed-Jidswitch (which removed those allocations, and landed for theu8device-id finding) and the move to a separate crate root, which returns it to exactly 8,605.4 and makesbinary_benchmark.rsbyte-identical tomainso no future fixture there can re-trigger it. It is back in CodSpeed's untouched bucket.Checked and not changed
Target 1 — SipHash on the hot path. Inside
wacore's group send, hashing is 1,240 Ir = 0.37% and does not grow with the group; there is nothing here to win. Independently of that, per-map key control, which is the binding question:SenderKeyDeviceMap.devices(src/sender_key_device_cache.rs)RandomStateSignalStoreCachesessions / identities / sender_keys / sender_key_locks (wacore/src/store/signal_cache.rs)user.device,group::addressRandomStateGroupDevicesMemo.members, the set behindresolve_skdm_targets_memoized(src/client/device_registry.rs)RandomStateLidPnCache'sTypedCache<Arc<str>, …>(src/lid_pn_cache.rs)RandomStateSignalStoreCache::removed_prekeysu32)Every map the profile named is fed by the group participant list or by peer JIDs. That is exactly the case the DoS-resistant default exists for, and a group's membership is the one input an adversary can influence directly. A blanket default-hasher swap is not on the table either.
Target 3 — allocation. 22 allocations / 3.58 KB per warm group send in
wacore, flat in group size, ~1.8% of the send in instructions — the same order as the 0.43% the DM batch measured and declined. The external 387 allocs / 87.7 KiB is a whole-client number;wacore's stanza path is ~6% of it, and the named growth site (ensure_sessions_for_devices) is on the SKDM path, which a steady-state send takes only for own companions. Not attacked here, and it would be a different PR regardless.A
pkmsgfan-out sweep. The cold path's per-recipient cost is genuinely higher, so it cannot be inferred from themsgnumbers. Rather than assert a slope I have not measured, the benchmark documents that it does not cover that case. Adding the sweep is worth its own change.Divergence from the external profile. ~66 of the reported 736 Ir/member (9%) is reachable from
wacore, and this PR shows all of it was the fixture. The remaining ~670 Ir/member lives in thewhatsapp-rustclient crate, which has no benchmarks and is not in a CodSpeed shard —filter_skdm_targetsis one hash lookup per device, memoized byskdm_warm_memoonly while the(devices, sender-key-map)Arcpair, generation and sending identity all hold. Measuring that needs client-level benchmarks that do not exist yet; nothing in this PR claims to have measured it.Which group sizes this is about. The fixture overhead removed here is 2.8K Ir at 8 members and 26.8K at 512, so the measurement only changes meaningfully at 128 and above. The flatness claim holds at every size.
Validation
cargo fmt --allcargo clippy --workspace --all-targets -- -D warnings— cleancargo test -p wacore -p wacore-binary -p wacore-libsignal -p wacore-appstate -p wacore-noise -p whatsapp-rust— all green (wacore1418 passed,whatsapp-rust1608 passed);voip-cliskipped locally, itsalsa-sysbuild dependency is not installed in this container<enc>on the 512-member stanza alone fails it, where the previous direct-children comparison passedbench_attr_parsermeasured under callgrind atmain, at this branch, and after the bench split, to confirm the CodSpeed regression is gone rather than merely re-rolledcargo bench -p wacore --bench send_receive_benchmark -- group_sendandcargo bench -p wacore-binary --bench group_fanout_benchmarkboth run"Semver Checks (informational)" is red. It is
continue-on-error: trueby design ("advisory only … this job exists to make the break visible in the summary, not to block the PR") and compares the API against the last published release, not this PR's base; this diff adds no public surface at all — bench files and one#[cfg(test)]module.Caveats carried over from the profile this batch came from, since the numbers above are compared against it: its cycle sweep is not monotonic (361K, 446K, 354K, 441K) — only instructions grow cleanly, so the wall-time effect is smaller and noisier than the instruction effect, which is what I see here too (instructions −9.0% at 512, wall time ~−1% and inside the noise). Its per-item attribution in the time profile is ~3 samples per row and comes from callgrind, not perf; the 39.9% aggregate is the solid part. Its allocation numbers already exclude the benchmark's own
build_bot_asynchandler.