Skip to content

test(send): pin that a warm group send encodes nothing per recipient - #1281

Merged
jlucaso1 merged 2 commits into
mainfrom
perf/group-encoding
Aug 11, 2026
Merged

test(send): pin that a warm group send encodes nothing per recipient#1281
jlucaso1 merged 2 commits into
mainfrom
perf/group-encoding

Conversation

@jlucaso1

@jlucaso1 jlucaso1 commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator

Summary

Profiling a client reported the binary encoder growing with group size — classify_string_hint +6.5K Ir/msg, Encoder::write_node +4.8K, node_encoded_size_with_cache +5.2K going from 8 to 512 members — and named the recipient list as the thing being serialized on every message. GcmGhash::update calls rising 60 → 217 while no key operation moved was read as confirmation: more bytes authenticated, same crypto.

On the warm path that is not what happens, and this closes the target as a refutation. The warm group stanza is 133 bytes at 8 members and 133 bytes at 512. What grows is the distributing send, and it grows for a reason no cache can remove.

The tests are the deliverable: the property was true but unstated, so the claim was neither confirmable nor falsifiable without re-reading prepare_group_stanza end to end — and nothing stopped a future change from making it false.

Changes

File Change
wacore/src/send/tests.rs new warm_group_send_encoding_scale module — two tests, +301 lines

No production code changes.

Cost

Nothing to trade: this PR adds tests only. The measurements are what closes the target.

Encoded stanza size, warm sendmarshal(stanza).len() minus the skmsg ciphertext. That payload cannot be compared directly: pad_with_context_from_encoded appends a random 1..16-byte pad by design, so two encodes of the same message differ in length on purpose. Everything else in the stanza is deterministic, and everything else is what "does the recipient list reach the wire" actually asks.

group size 8 32 128 512
stanza bytes, ex-ciphertext 133 133 133 133

Zero bytes per recipient, across a 64× range. Allocation counts are unchanged; no allocating code is touched.

Why it is flat. A warm send takes the distribution_list == None path in prepare_group_stanza, so:

What reaches the wire is one <enc type="skmsg"> for the whole group plus a reporting token. Neither knows the member count.

Why the distributing send is inherently linear. Each device needs its own copy of the sender key under its own ratcheting Signal session, so <participants> carries one pairwise <enc> per target by construction. There is no cache to add: a stale participant list is a message delivered to a device that should not have it, or withheld from one that should — a correctness bug wearing a performance change's clothes. mark_full_distribution_list already covers that side.

Checked and not changed

  • node_encoded_size_with_cache is not a cross-message cache, so there is no miss to fix. The name invites the reading that a cache is thrashing. It is the sizing pass of a two-pass encoder, and its StringHintCache is a replay tape: the plan pass classifies each string once and appends the hint, the write pass consumes them in the same write_node order (debug-asserted, with fully_consumed() checked after). It is scoped to a single encode and never outlives one. Nothing to bound, invalidate, or grow.
  • No new cache added. The constraint in the brief — no cache without a ceiling and correct invalidation — is met by not needing one.
  • The phash was the obvious suspect and is cleared by the second test: it is derived from every participant, yet fixed-width at every group size, and different between sizes (so the fixed width is proving something rather than measuring a constant).
  • resolve_skdm_targets_memoized untouched — its semantics are correctness, not performance.
  • Per-iteration Ir on bench_group_send_* was rejected as the instrument. Divan regenerates inputs per iteration and setup_group_send(N) establishes N Signal sessions, so the measurement comes out at ~54M Ir/iter against a ~45 µs send — the fixture is inherently O(N) and swamps the thing under test by two orders of magnitude. Encoded stanza size is deterministic and measures the actual question.
  • Wall-clock was rejected too: this 4-vCPU host showed rep-to-rep spread up to 116% inside a single arm on the same benches.
  • Nothing on the wire changes — no format, no node order, nothing persisted.

Validation

  • cargo test -p wacore --lib warm_group_send_encoding_scale — 2 passed, 0 failed.
  • cargo fmt --all, cargo clippy -p wacore --all-targets — clean.
  • The session/identity/prekey stores in the new module are unreachable!() rather than stubs, on purpose: a warm send reaching for a pairwise session is precisely the regression these tests exist to catch, and it should fail loudly instead of being quietly absorbed by a stub answer.

Profiling a client reported the binary encoder growing with group size --
`classify_string_hint`, `write_node`, and `node_encoded_size_with_cache` all
rising from 8 to 512 members -- and named the recipient list as the thing
being serialized on every message. On the warm path it is not, and nothing in
the tree said so, so the claim was neither confirmable nor falsifiable without
re-reading `prepare_group_stanza` end to end.

A warm send -- no sender-key distribution, which is what a group in ordinary
conversation does for every message between topology changes -- takes the
`distribution_list == None` path: no `<participants>` fan-out is built, the
phash is served from its memo as a fixed-width hash, and `stale_users_for`
returns empty without walking anything. What reaches the wire is one
`<enc type="skmsg">` for the whole group plus a reporting token, and neither
knows the member count.

These tests hold that shut at 8, 32, 128 and 512 members: the marshalled
stanza is byte-identical across all four once the skmsg ciphertext is
discounted (it cannot be compared directly -- `pad_with_context_from_encoded`
appends a random 1..16-byte pad by design), plus the phash is present and
fixed-width at every size. The session/identity/prekey stores are
`unreachable!()` rather than stubs: a warm send reaching for a pairwise
session is the regression this is here to catch, and it should fail loudly
instead of being absorbed.

What grows is the *distributing* send, and inherently: each device needs its
own copy of the sender key under its own ratcheting session, so `<participants>`
carries one pairwise `<enc>` per target and there is no cache to add --
a stale participant list is a message delivered to the wrong device, not a
slow one. `mark_full_distribution_list` already covers that side.

No production code changes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019JXjxfWyfvxB6WEgiCLVF3
@coderabbitai

coderabbitai Bot commented Aug 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 (base), Organization UI (inherited)

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 8f6d690e-a6e8-4e65-886e-1df671f46f46

📥 Commits

Reviewing files that changed from the base of the PR and between 8f2beb7 and a4a00e6.

📒 Files selected for processing (1)
  • wacore/src/send/tests.rs

📝 Walkthrough

Summary by CodeRabbit

  • Tests
    • Added coverage for efficient group-message serialization.
    • Verified serialized message size remains consistent as group membership changes.
    • Confirmed messages include valid participant hashes and avoid unnecessary recipient details.

Walkthrough

The PR adds warm group-send tests. The tests use sender-key-only state, reject pairwise-store access, verify omitted recipient fan-out, compare serialized sizes across group sizes, and validate phash output.

Changes

Warm group-send serialization coverage

Layer / File(s) Summary
Warm stanza test setup
wacore/src/send/tests.rs
The tests build sender-key-only state with pre-warmed participant hashing and unreachable pairwise stores.
Serialization and phash assertions
wacore/src/send/tests.rs
The tests verify that warm stanzas omit <participants> and <device-identity>, keep serialized size constant from 8 to 512 members, and produce fixed-width, distinct phash values for different participant sets.

Estimated code review effort: 2 (Simple) | ~10 minutes

Possibly related PRs

Suggested labels: performance

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the added test and its purpose: verifying that warm group sends do not encode per-recipient data.
Description check ✅ Passed The description directly explains the tests, motivation, measured results, validation, and absence of production changes.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
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.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch perf/group-encoding

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 Aug 11, 2026

Copy link
Copy Markdown

Greptile Summary

This PR adds tests intended to establish that encoded warm group stanzas remain constant in size as group membership grows.

  • Adds an in-memory sender-key fixture for warm group sends.
  • Compares stanza sizes across groups containing 8 through 512 members.
  • Verifies that participant hashes remain fixed-width and vary with membership.

Confidence Score: 5/5

The PR appears safe to merge because no blocking failure remains.

No blocking failure remains.

Important Files Changed

Filename Overview
wacore/src/send/tests.rs Adds isolated group-send encoding-scale fixtures and assertions without changing production behavior.

Reviews (2): Last reviewed commit: "test(send): warm the phash memo in the f..." | Re-trigger Greptile

Comment thread wacore/src/send/tests.rs

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 91696f46be

ℹ️ 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".

Comment thread wacore/src/send/tests.rs
Comment on lines +5319 to +5320
let group_info = GroupInfo::new(members.clone(), AddressingMode::Pn);
let resolved = std::sync::Arc::new(ResolvedGroupDevices::new(members));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Pre-warm the phash memo before exercising the warm path

Each helper invocation constructs a fresh ResolvedGroupDevices and immediately passes it to prepare_group_stanza, so its OnceLock is empty and phash() performs the O(member_count) computation during the supposedly warm send. Production repeat sends—and the existing setup_group_send benchmark—reuse an already-populated memo, so this fixture neither exercises that path nor catches a regression that recomputes the phash on every send; call resolved.phash(&own) during setup before preparing the stanza.

Useful? React with 👍 / 👎.

The helper built a fresh `ResolvedGroupDevices` and handed it straight to
`prepare_group_stanza`, so its `OnceLock` was empty and the first send paid the
O(member_count) hash inside the path the test calls warm. That measured the
cold path under a warm name, and it left the regression it should catch --
recomputing the phash on every send -- invisible, since a cold memo looks the
same as no memo.

`setup_group_send` in the benchmark already warms and asserts it; this matches.
The size assertions were unaffected either way (the phash is fixed-width), but
the fixture now exercises what it claims to.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019JXjxfWyfvxB6WEgiCLVF3
@jlucaso1
jlucaso1 merged commit 5fdf3cc into main Aug 11, 2026
24 of 25 checks passed
@jlucaso1
jlucaso1 deleted the perf/group-encoding branch August 11, 2026 17:27
@github-actions

Copy link
Copy Markdown

📦 Binary size report

Metric main PR Δ
bin size (stripped) 10.04 MiB 10.04 MiB 0
bin .text 8.05 MiB 8.05 MiB 0
bin allocated (text+data+bss) 10.04 MiB 10.04 MiB 0
llvm-lines wacore 533,670 533,670 0
llvm-lines wacore copies 17,422 17,422 0
llvm-lines whatsapp-rust lib 762,401 762,401 0
llvm-lines whatsapp-rust lib copies 23,771 23,771 0
deps crates (Cargo.lock) 462 462 0
.text per crate
Crate main PR Δ
.text whatsapp_rust 1.84 MiB 1.84 MiB 0
.text wacore 692.69 KiB 692.69 KiB 0
.text wacore_binary 81.61 KiB 81.61 KiB 0
.text wacore_libsignal 178.98 KiB 178.98 KiB 0
.text wacore_appstate 22.35 KiB 22.35 KiB 0
.text wacore_noise 20.94 KiB 20.94 KiB 0
.text waproto 1.74 MiB 1.74 MiB 0
.text whatsapp_rust_sqlite_storage 540.56 KiB 540.56 KiB 0
.text whatsapp_rust_tokio_transport 40.49 KiB 40.49 KiB 0
.text whatsapp_rust_ureq_http_client 12.68 KiB 12.68 KiB 0
.text std 995.62 KiB 995.62 KiB 0
.text other deps 1.90 MiB 1.90 MiB 0

Baseline: 8f2beb702 (latest main run) · Head: d13189cdb · Graphs

jlucaso1 pushed a commit that referenced this pull request Aug 11, 2026
Brings in #1281, whose `warm_group_send_encoding_scale` this section cites.
The reference dangled while this branch sat on a pre-merge base.
jlucaso1 pushed a commit that referenced this pull request Aug 11, 2026
Three more from review, all confirmed against the fixtures.

The cited regression test was not in this branch's tree. `perf/group-allocs`
was cut before #1281 landed, so `warm_group_send_encoding_scale` existed only on
main and the reference dangled for anyone reading the PR. Merged main in.

The distributing row is a first-message fan-out, not a steady rotation.
`establish_session` runs `process_prekey_bundle` alone -- unlike
`establish_bidirectional` it never completes the round trip -- so every session
still carries its `pending_pre_key` and each SKDM encryption emits a `pkmsg`,
with prekey wrapping and device-identity serialization attached. 6,816 is that
shape. A later rotation or reset over acknowledged sessions emits plain
`SignalMessage`s and costs less; nothing here measures it. Labelled in the table
and in the prose, replacing a paragraph that implied the figure covered
redistribution generally.

The extrapolation was in the wrong unit. `setup_group_send(n)` creates exactly
one device per member, while SKDM fan-out scales with resolved devices -- a real
group resolves to more targets than members. The ~3.4K figure is now stated per
128 *targets*, and the comparison notes that the external number is quoted in
members whose device count is unknown, which is one more reason the
decomposition is not claimable from here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019JXjxfWyfvxB6WEgiCLVF3
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.

2 participants