Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 78 additions & 1 deletion wacore/appstate/benches/appstate_benchmark.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ use divan::black_box;
use std::collections::HashMap;
use std::sync::Arc;
use wacore_appstate::{
WAPATCH_INTEGRITY, encode_record, expand_app_state_keys, hash::HashState, process_patch,
WAPATCH_INTEGRITY, decode_record, encode_record, expand_app_state_keys, hash::HashState,
process_patch,
};
use waproto::whatsapp as wa;

Expand Down Expand Up @@ -140,3 +141,79 @@ fn bench_process_patch_50_validated(bencher: divan::Bencher) {
);
});
}

/// One encrypted app-state record built the way the server delivers it, so
/// `decode_record` runs its full inbound cost: AES-256-CBC decrypt, the content
/// HMAC-SHA512 and index HMAC-SHA256 validation, the prost decode of the action
/// value, and the serde_json parse of the index array. This runs once per
/// mutation, up to ~1000× per resume patch; `process_patch` covers the whole
/// loop but never isolates this inner per-record cost.
fn setup_record(
shape: &str,
) -> (
wa::SyncdRecord,
wacore_appstate::ExpandedAppStateKeys,
Vec<u8>,
) {
let keys = expand_app_state_keys(&[0x07u8; 32]);
let key_id = b"AAAA".to_vec();
let (index, value) = match shape {
// A starred-message mutation: 5-part index (the real STAR schema),
// tiny action value.
"star" => (
"[\"star\",\"5511000000000@s.whatsapp.net\",\"3EB0123456789ABCDEF01234\",\"1\",\"0\"]"
.to_string(),
wa::SyncActionValue {
timestamp: Some(1_700_000_000),
star_action: Some(wa::sync_action_value::StarAction {
starred: Some(true),
}),
..Default::default()
},
),
// A contact mutation: longer index plus a name-carrying value, the
// larger-payload end of the AES/HMAC cost.
"contact" => (
"[\"contact\",\"5511999998888@s.whatsapp.net\"]".to_string(),
wa::SyncActionValue {
timestamp: Some(1_700_000_000),
contact_action: Some(wa::sync_action_value::ContactAction {
full_name: Some("Benchmark Contact Full Name".to_string()),
first_name: Some("Benchmark".to_string()),
..Default::default()
}),
..Default::default()
},
),
other => unreachable!("unknown shape {other}"),
};
let (mutation, _value_mac) = encode_record(
wa::syncd_mutation::SyncdOperation::Set,
index.as_bytes(),
&value,
&keys,
&key_id,
&[0x11u8; 16],
1,
);
let record = mutation.record.expect("encoded record");
(record, keys, key_id)
}

#[divan::bench(args = ["star", "contact"])]
fn bench_decode_record(bencher: divan::Bencher, shape: &str) {
bencher
.with_inputs(|| setup_record(shape))
.bench_refs(|(record, keys, key_id)| {
black_box(
decode_record(
wa::syncd_mutation::SyncdOperation::Set,
black_box(record),
black_box(keys),
black_box(key_id),
true,
)
.unwrap(),
)
});
}
93 changes: 93 additions & 0 deletions wacore/benches/message_utils_benchmark.rs
Original file line number Diff line number Diff line change
Expand Up @@ -173,3 +173,96 @@ fn bench_dm_send_encode_work(bencher: divan::Bencher, shape: &str) {
black_box((plaintexts, retry_bytes, reporting_result))
});
}

/// A `<message>` stanza with the attributes each inbound shape carries, so
/// `parse_message_info` exercises every arm of its addressing-mode dispatch.
/// Each carries an `<enc>` child like every real message, so the
/// `get_optional_child` scans run instead of returning instant-None.
fn msg_info_node(shape: &str) -> wacore_binary::node::Node {
use wacore_binary::builder::NodeBuilder;
let enc = || {
NodeBuilder::new("enc")
.attr("type", "msg")
.attr("v", "2")
.bytes(vec![0u8; 64])
.build()
};
match shape {
// Plain 1:1 DM from a PN peer; sender_lid carries the LID fallback a
// LID-migrated peer sends, exercising the self/other-DM sender_alt arm.
"dm_pn" => NodeBuilder::new("message")
.attr("from", "5511888887777@s.whatsapp.net")
.attr("type", "text")
.attr("id", "3EB0BENCH000000000001")
.attr("t", "1777415965")
.attr("notify", "Bench Peer")
.attr("sender_lid", "100000012345678@lid")
.children([enc()])
.build(),
// LID-addressed group message: participant is a LID JID, participant_pn
// carries the phone fallback the LID-PN cache re-warms from.
"group_lid" => NodeBuilder::new("message")
.attr("from", "120363000000000001@g.us")
.attr("type", "text")
.attr("id", "3EB0BENCH000000000002")
.attr("t", "1777415965")
.attr("addressing_mode", "lid")
.attr("participant", "100000012345678@lid")
.attr("participant_pn", "5511888887777@s.whatsapp.net")
.attr("notify", "Bench Member")
.children([enc()])
.build(),
// Status broadcast: from=status@broadcast, participant is the author,
// participant_lid warms the LID-PN cache.
"status_broadcast" => NodeBuilder::new("message")
.attr("from", "status@broadcast")
.attr("type", "media")
.attr("id", "3EB0BENCH000000000003")
.attr("t", "1777415965")
.attr("participant", "5511999990001@s.whatsapp.net")
.attr("participant_lid", "100000011111111@lid")
.children([enc()])
.build(),
// Self-sent message (from == own JID): recipient drives chat resolution.
"self_sent" => NodeBuilder::new("message")
.attr("from", "5511999990000:7@s.whatsapp.net")
.attr("recipient", "5511888887777@s.whatsapp.net")
.attr("type", "text")
.attr("id", "3EB0BENCH000000000004")
.attr("t", "1777415965")
.children([enc()])
.build(),
other => unreachable!("unknown shape {other}"),
}
}

/// Stanza-to-MessageInfo parse: the metadata extraction that runs once per
/// inbound message before any Signal work. Exercises typed-JID attribute
/// materialization and the addressing-mode dispatch. The input is a marshal
/// round-trip decoded back into an `OwnedNodeRef` (untimed in `with_inputs`),
/// so attributes arrive wire-typed (`ValueRef::Jid`) exactly as the decoder
/// hands them to the receive path — not string-parsed, which production never
/// does. Only the parse is measured; the whole `MessageInfo` is observed.
#[divan::bench(args = ["dm_pn", "group_lid", "status_broadcast", "self_sent"])]
fn bench_parse_message_info(bencher: divan::Bencher, shape: &str) {
use wacore_binary::node::OwnedNodeRef;
let own_jid: Jid = "5511999990000@s.whatsapp.net".parse().unwrap();
let own_lid: Jid = "100000000000000@lid".parse().unwrap();

bencher
.with_inputs(|| {
let bytes = wacore_binary::marshal::marshal(&msg_info_node(shape)).unwrap();
// marshal prefixes a flag byte; the receive path strips it in unpack.
OwnedNodeRef::new(bytes[1..].to_vec()).unwrap()
})
.bench_refs(|owned| {
black_box(
wacore::messages::parse_message_info(
black_box(owned.get()),
&own_jid,
Some(&own_lid),
)
.unwrap(),
)
});
}
132 changes: 127 additions & 5 deletions wacore/libsignal/benches/libsignal_benchmark.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,11 @@ fn main() {
use wacore_libsignal::protocol::{
ChainKey, CiphertextMessage, Direction, GenericSignedPreKey, IdentityChange, IdentityKey,
IdentityKeyPair, IdentityKeyStore, KeyPair, MessageKeyGenerator, PreKeyBundle, PreKeyId,
PreKeyRecord, PreKeyStore, ProtocolAddress, RootKey, SenderKeyRecord, SenderKeyStore,
SessionRecord, SessionState, SessionStore, SignedPreKeyId, SignedPreKeyRecord,
SignedPreKeyStore, Timestamp, UsePQRatchet, consts, create_sender_key_distribution_message,
group_decrypt, group_encrypt, message_decrypt, message_encrypt, process_prekey_bundle,
process_sender_key_distribution_message,
PreKeyRecord, PreKeyStore, ProtocolAddress, RootKey, SenderKeyDistributionMessage,
SenderKeyRecord, SenderKeyStore, SessionRecord, SessionState, SessionStore, SignedPreKeyId,
SignedPreKeyRecord, SignedPreKeyStore, Timestamp, UsePQRatchet, consts,
create_sender_key_distribution_message, group_decrypt, group_encrypt, message_decrypt,
message_encrypt, process_prekey_bundle, process_sender_key_distribution_message,
};
use wacore_libsignal::store::sender_key_name::SenderKeyName;

Expand Down Expand Up @@ -1284,3 +1284,125 @@ fn bench_message_key_eviction(bencher: divan::Bencher) {
black_box(state);
});
}

/// Worst-case group out-of-order decrypt: a receiver that fell behind by ~2000
/// messages buffers that many skipped sender-message-keys, and each late
/// message consumes one via `SenderKeyState::remove_sender_message_key`, an
/// O(n) linear scan over the backlog (gap-analysis data-structures-22). The
/// expensive backlog fill runs once per iteration in `with_inputs` (untimed);
/// only the worst-case decrypt — the one whose key sits at the tail of the
/// backlog, forcing a full scan — is measured.
///
/// This is the full out-of-order decrypt, not a scan-isolating microbenchmark:
/// the backlog-sized `SenderKeyRecord` clone in `load_sender_key` and the
/// signature check are the bulk of it, with the linear scan a smaller slice. Use
/// it as the out-of-order decrypt baseline; the clone is itself backlog-proportional.
fn setup_group_out_of_order_worst_case() -> (User, SenderKeyName, Vec<u8>) {
// A fill just under MAX_MESSAGE_KEYS: eviction only starts past
// MAX_MESSAGE_KEYS + MESSAGE_KEY_PRUNE_THRESHOLD, so the whole backlog
// survives intact for the scan.
const N: u32 = (consts::MAX_MESSAGE_KEYS - 1) as u32;

let (mut alice, mut bob, sender_key_name) = setup_group_with_distribution();

let bob_sender_key_name = SenderKeyName::new(
sender_key_name.group_id().to_string(),
alice.address.name().to_string(),
);

let worst_case_ct = futures::executor::block_on(async {
let mut rng = rand::make_rng::<rand::rngs::StdRng>();
let mut ciphertexts: Vec<Vec<u8>> = Vec::with_capacity((N + 1) as usize);
for i in 0..=N {
let skm = group_encrypt(
&mut alice.sender_key_store,
&sender_key_name,
format!("group msg {i}").as_bytes(),
&mut rng,
)
.await
.expect("group encrypt");
ciphertexts.push(skm.serialized().to_vec());
}

// Decrypting the latest message first ratchets the chain forward over
// iterations 0..N-1, buffering N skipped keys in arrival order.
group_decrypt(
&ciphertexts[N as usize],
&mut bob.sender_key_store,
&bob_sender_key_name,
)
.await
.expect("group decrypt newest");

// Iteration N-1 sits at the tail of the buffer: its lookup scans every
// entry, the worst case for the linear `position()`.
ciphertexts[(N - 1) as usize].clone()
});

(bob, bob_sender_key_name, worst_case_ct)
}

#[divan::bench(sample_count = 30)]
fn bench_group_out_of_order_decrypt_worst_case(bencher: divan::Bencher) {
bencher
.with_inputs(setup_group_out_of_order_worst_case)
.bench_refs(|(bob, sender_key_name, ciphertext)| {
let plaintext = futures::executor::block_on(async {
group_decrypt(
black_box(ciphertext.as_slice()),
&mut bob.sender_key_store,
sender_key_name,
)
.await
.expect("group decrypt worst case")
});
black_box(plaintext);
});
}

/// SKDM ingest: installing a sender's distribution message into a fresh
/// receiver store, the work done on the first group message from each new
/// sender and on every sender-key rotation. The SKDM build and the fresh empty
/// store are prepared in `with_inputs`; only the ingest is measured.
fn setup_skdm_ingest() -> (
SenderKeyDistributionMessage,
InMemorySenderKeyStore,
SenderKeyName,
) {
let (mut alice, sender_key_name) = setup_group_sender();
let receiver_key_name = SenderKeyName::new(
sender_key_name.group_id().to_string(),
alice.address.name().to_string(),
);
let skdm = futures::executor::block_on(async {
let mut rng = rand::make_rng::<rand::rngs::StdRng>();
create_sender_key_distribution_message(
&sender_key_name,
&mut alice.sender_key_store,
&mut rng,
)
.await
.expect("skdm")
});
(skdm, InMemorySenderKeyStore::new(), receiver_key_name)
}

#[divan::bench]
fn bench_process_sender_key_distribution_message(bencher: divan::Bencher) {
bencher.with_inputs(setup_skdm_ingest).bench_refs(
|(skdm, receiver_store, receiver_key_name)| {
futures::executor::block_on(async {
process_sender_key_distribution_message(
receiver_key_name,
black_box(skdm),
receiver_store,
)
.await
.expect("process skdm")
});
// Observe the store so the store_sender_key write is not elided.
black_box(&*receiver_store);
},
);
}
Loading