Skip to content
33 changes: 21 additions & 12 deletions wacore/benches/send_receive_benchmark.rs
Original file line number Diff line number Diff line change
Expand Up @@ -655,7 +655,16 @@ fn setup_dm_recv() -> DmRecvData {
struct GrpSendData {
alice: User,
group_jid: Jid,
participants: Vec<Jid>,
/// Built in setup, not per iteration: production resolves the group once
/// and holds the result behind an `Arc` across sends (`ensure_self_in_group`
/// hands the same `Arc` straight back whenever we are already a member, the
/// steady state), so a send never constructs or drops a participant list.
/// Building it in the measured body charged every group send an
/// N-participant construct + teardown that no send performs — 26.8K
/// instructions at 512 members, and the entire reason this benchmark
/// appeared to scale with group size while `prepare_group_stanza` itself is
/// flat (334.0K at 8 members, 334.1K at 512).
group_info: GroupInfo,
/// Warm-send fixture: the resolved set with its phash memo pre-warmed in
/// setup, like the per-group device memo serves production repeat sends.
resolved_for_phash: Option<std::sync::Arc<wacore::send::ResolvedGroupDevices>>,
Expand Down Expand Up @@ -705,10 +714,18 @@ fn setup_group_send(n: usize) -> GrpSendData {
.phash(&alice.jid)
.expect("phash must warm in setup");

// Self-append happens once here for the same reason production does it once
// per resolution: `prepare_group_stanza` expects the sender in the list.
let own_base = alice.jid.to_non_ad();
if !participants.iter().any(|p| p.is_same_user_as(&own_base)) {
participants.push(own_base);
}
let group_info = GroupInfo::new(participants, AddressingMode::Pn);

GrpSendData {
alice,
group_jid,
participants,
group_info,
resolved_for_phash: Some(resolved),
force_skdm: false,
resolver: MockResolver(devices),
Expand Down Expand Up @@ -1045,15 +1062,7 @@ fn run_group_send(d: &mut GrpSendData) {
// only emits a phash if it gets the full device set. Mirror the real
// warm-send caller by passing it; the cold/force_skdm path resolves the set
// itself and keeps None.
let mut group_info = GroupInfo::new(std::mem::take(&mut d.participants), AddressingMode::Pn);
let own_base = own_jid.to_non_ad();
if !group_info
.participants
.iter()
.any(|p| p.is_same_user_as(&own_base))
{
group_info.participants.push(own_base);
}
let group_info = &d.group_info;
let mut stores = SignalStores {
sender_key_store: &mut d.alice.sender_keys,
session_store: &mut d.alice.sessions,
Expand All @@ -1067,7 +1076,7 @@ fn run_group_send(d: &mut GrpSendData) {
&mut stores,
&d.resolver,
GroupStanzaRequest {
group: &group_info,
group: group_info,
own_jid: &own_jid,
own_lid: &own_jid,
account: Some(&d.account),
Expand Down
26 changes: 25 additions & 1 deletion wacore/binary/benches/binary_benchmark.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,17 @@ fn create_ack_node() -> Node {
/// A device fanout. Device-qualified JIDs encode as `AD_JID`, so what repeats
/// per child is the packed decode and the AD path, not `read_jid_pair`.
fn create_fanout_node() -> Node {
let devices: Vec<Node> = (0..8)
create_fanout_node_of_width(8)
}

/// The `<participants>` shape a sender-key distribution puts on the wire: one
/// `<to jid=…>` per recipient device, each wrapping its own `<enc>`. This is
/// the only group stanza whose encode cost is proportional to the participant
/// count — a steady-state group send distributes no keys and so carries none
/// of this (pinned by `warm_group_stanza_carries_no_per_participant_data` in
/// `wacore`), which is why the width is swept here rather than assumed.
fn create_fanout_node_of_width(width: usize) -> Node {
let devices: Vec<Node> = (0..width)
.map(|i| {
NodeBuilder::new("to")
.attr("jid", format!("5511999990000:{i}@s.whatsapp.net"))

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 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 👍 / 👎.

Expand Down Expand Up @@ -228,6 +238,20 @@ fn bench_marshal_auto_many_children(bencher: divan::Bencher) {
.bench_refs(|node| black_box(marshal_auto(black_box(node)).unwrap()));
}

// Group sender-key distribution, swept across the recipient count reported for
// real groups. Marshalling is linear in the fan-out width, so this is what a
// cold group send (or a redistribution after a membership change) pays in the
// encoder; the steady-state send that follows carries no `<participants>` at
// all. Keeping both facts measurable is what tells a group-size regression
// ("the warm stanza grew a per-participant node") apart from a group that is
// merely redistributing.
#[divan::bench(args = [8, 32, 128, 512])]
fn bench_marshal_auto_group_fanout(bencher: divan::Bencher, width: usize) {
bencher
.with_inputs(|| create_fanout_node_of_width(width))
.bench_refs(|node| black_box(marshal_auto(black_box(node)).unwrap()));
}

// The exact (plan + hint replay) strategy is the production send path for
// message plaintext, so its worst case — many children, JID-heavy — gets its
// own pin.
Expand Down
127 changes: 127 additions & 0 deletions wacore/src/send/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3921,6 +3921,133 @@ mod mark_full_distribution_list {
not aborting the whole cohort"
);
}

/// Nothing in a steady-state group stanza is per-participant.
///
/// `<participants>` exists only to carry sender-key distributions, and a
/// warm send distributes none; the `phash` that covers the whole device set
/// is a fixed-width digest ("2:" + 8 base64 chars) memoized on the resolved
/// set. So the encoded stanza is byte-for-byte the same size for a group of
/// 8 and a group of 512, and the encoder cost of a repeat group send does
/// not scale with the group — there is no per-participant encoding to
/// cache between sends.
///
/// Pinned as a test rather than left to the group benchmarks because the
/// claim is about the *shape* of the stanza: a future change that folded
/// participant state into it would still benchmark fine on a small group.
#[tokio::test]
async fn warm_group_stanza_carries_no_per_participant_data() {
async fn warm_stanza(members: usize) -> Node {
let own_jid: Jid = "12025550111:0@s.whatsapp.net".parse().unwrap();
let own_lid: Jid = "100000000000001:0@lid".parse().unwrap();
let group: Jid = "120363000000000001@g.us".parse().unwrap();

let participants: Vec<Jid> = (0..members)
.map(|i| {
format!("{}@s.whatsapp.net", 12025550200u64 + i as u64)
.parse()
.unwrap()
})
.collect();

let mut rng = rand::make_rng::<rand::rngs::StdRng>();
let mut sks = MemSenderKeyStore::default();
// A warm send never creates the chain, so seed it exactly as the
// first (cold) send to this group would have.
let sk_name = make_sender_key_name(&group, &own_jid.to_protocol_address());
crate::libsignal::protocol::create_sender_key_distribution_message(
&sk_name, &mut sks, &mut rng,
)
.await
.expect("seed the sender key chain");

let mut ss = MemSessionStore::default();
let mut is = MemIdentityStore {
pair: IdentityKeyPair::generate(&mut rng),
reg_id: 7,
known: Default::default(),
};
let mut pks = UnusedPreKeyStore;
let spks = UnusedSignedPreKeyStore;
let mut stores = SignalStores {
sender_key_store: &mut sks,
session_store: &mut ss,
identity_store: &mut is,
prekey_store: &mut pks,
signed_prekey_store: &spks,
};

let mut group_participants = participants.clone();
group_participants.push(own_jid.to_non_ad());
let group_info = GroupInfo::new(group_participants, AddressingMode::Pn);
// The full resolved device set the warm send hashes into `phash`.
let resolved = ResolvedGroupDevices::new(participants);
Comment thread
jlucaso1 marked this conversation as resolved.
Outdated
let msg = wa::Message {
conversation: Some("steady state".into()),
..Default::default()
};

prepare_group_stanza(
&TokioTestRuntime,
&mut stores,
&MockSendContextResolver::new(),
GroupStanzaRequest {
group: &group_info,
own_jid: &own_jid,
own_lid: &own_lid,
account: None,
to: &group,
message: &msg,
message_id: "WARMGROUPSCALE1",
force_distribution: false,
distribution_targets: None,
distribution_policy: SenderKeyDistributionPolicy::BestEffort,
phash_devices: Some(&resolved),
edit: None,
extra_nodes: &[],
Comment thread
greptile-apps[bot] marked this conversation as resolved.
pre_encoded: None,
},
)
.await
.expect("warm group send")
.node
}

let small = warm_stanza(8).await;
let large = warm_stanza(512).await;

for (label, node) in [("8-member", &small), ("512-member", &large)] {
assert!(
node.get_optional_child("participants").is_none(),
"{label} warm send must distribute no sender keys"
);
assert_eq!(
node.attrs().optional_string("phash").map(|p| p.len()),
Some(10),
"{label} phash is a fixed-width digest"
);
}

let small_children: Vec<&str> = small
.children()
.unwrap_or(&[])
.iter()
.map(|c| c.tag.as_ref())
.collect();
let large_children: Vec<&str> = large
.children()
.unwrap_or(&[])
.iter()
.map(|c| c.tag.as_ref())
.collect();
assert_eq!(small_children, large_children, "same stanza shape");

assert_eq!(
wacore_binary::marshal::marshal(&small).unwrap().len(),
wacore_binary::marshal::marshal(&large).unwrap().len(),
"the encoded warm group stanza is the same size at 8 and 512 members"
);
}
}

/// Item 3 — phash device-set construction. The set hashed is the full
Expand Down
Loading