Media FEC: one table lookup per shard byte in combine
Soul measured the owned math at 1.7-2.6x the crate's time at realistic sizes
(16+4 x 1200 B encode 64k -> 122k ns), because combine called gf_mul per byte:
two zero checks and three lookups. combine now tabulates each coefficient's
256 products once and spends one lookup and one XOR per byte; coefficient 0
is skipped and 1 is a plain XOR. The construction and every wire byte are
unchanged: the known-answer fixture passes untouched.
tests/media_fec_bench.rs is an ignored, dependency-free timing test over the
public surface (4+2 at 160 and 1300 B, 16+4 at 1200 B, 255+1 at 1300 B):
cargo test --release --test media_fec_bench -- --ignored --nocapture
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
d391770abb
commit
7c1a792a28
2 changed files with 104 additions and 2 deletions
|
|
@ -337,11 +337,25 @@ fn encoding_rows(k: usize, rows: impl Iterator<Item = usize>) -> Vec<Vec<u8>> {
|
|||
}
|
||||
|
||||
/// Byte `j` of the result is the sum over `i` of `coefficients[i] * shards[i][j]`.
|
||||
///
|
||||
/// The hot loop of both encode and recovery: each coefficient's 256 products
|
||||
/// are tabulated once, so a shard byte costs one lookup and one XOR.
|
||||
fn combine(coefficients: &[u8], shards: &[&[u8]], shard_bytes: usize) -> Vec<u8> {
|
||||
let mut out = vec![0_u8; shard_bytes];
|
||||
for (&coefficient, shard) in coefficients.iter().zip(shards) {
|
||||
for (byte, &input) in out.iter_mut().zip(shard.iter()) {
|
||||
*byte ^= gf_mul(coefficient, input);
|
||||
match coefficient {
|
||||
0 => {}
|
||||
1 => {
|
||||
for (byte, &input) in out.iter_mut().zip(shard.iter()) {
|
||||
*byte ^= input;
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
let products: [u8; 256] = std::array::from_fn(|x| gf_mul(coefficient, x as u8));
|
||||
for (byte, &input) in out.iter_mut().zip(shard.iter()) {
|
||||
*byte ^= products[usize::from(input)];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
|
|
|
|||
88
packages/cultnet-rs/tests/media_fec_bench.rs
Normal file
88
packages/cultnet-rs/tests/media_fec_bench.rs
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
//! Timings for the media FEC codec, without a bench framework.
|
||||
//!
|
||||
//! Ignored by default; run in release, where the numbers mean something:
|
||||
//!
|
||||
//! ```text
|
||||
//! cargo test --release --test media_fec_bench -- --ignored --nocapture
|
||||
//! ```
|
||||
//!
|
||||
//! Each line is nanoseconds per call through the public surface, so record
|
||||
//! validation and cloning are included. The geometries are the ones the codec
|
||||
//! meets: standard audio, the widest standard video block at a datagram, and
|
||||
//! the worst recovery the policy admits (255 + 1).
|
||||
|
||||
use std::hint::black_box;
|
||||
use std::time::Instant;
|
||||
|
||||
use cultnet_rs::{
|
||||
GAMECULT_MEDIA_MAX_WIRE_BYTES, GameCultMediaAudioPacketRecord, MediaFecPolicy,
|
||||
MediaWireProvenance, protect_audio_block, recover_audio_block,
|
||||
};
|
||||
|
||||
fn provenance() -> MediaWireProvenance<'static> {
|
||||
MediaWireProvenance {
|
||||
stored_at: "unix:1700000000000",
|
||||
runtime_id: "bench",
|
||||
role: "bench.media",
|
||||
producer: "bench",
|
||||
}
|
||||
}
|
||||
|
||||
fn packets(k: usize, len: usize) -> Vec<GameCultMediaAudioPacketRecord> {
|
||||
let mut state = 0x2545_f491_u32;
|
||||
(0..k as u64)
|
||||
.map(|index| GameCultMediaAudioPacketRecord {
|
||||
stream_id: "bench".to_string(),
|
||||
session_id: "bench".to_string(),
|
||||
packet_id: index,
|
||||
codec: "opus".to_string(),
|
||||
pts_ticks: 960 * index as i64,
|
||||
duration_ticks: 960,
|
||||
timebase_num: 1,
|
||||
timebase_den: 48_000,
|
||||
deadline_ticks: 960 * index as i64 + 5_000,
|
||||
payload: (0..len)
|
||||
.map(|_| {
|
||||
state ^= state << 13;
|
||||
state ^= state >> 17;
|
||||
state ^= state << 5;
|
||||
state as u8
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Mean nanoseconds per call over at least `min_ms`, after three warm-up calls.
|
||||
fn time(mut f: impl FnMut(), min_ms: u128) -> f64 {
|
||||
for _ in 0..3 {
|
||||
f();
|
||||
}
|
||||
let start = Instant::now();
|
||||
let mut calls = 0_u64;
|
||||
while start.elapsed().as_millis() < min_ms {
|
||||
f();
|
||||
calls += 1;
|
||||
}
|
||||
start.elapsed().as_nanos() as f64 / calls as f64
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "timing, not a test: run in release with --ignored --nocapture"]
|
||||
fn media_fec_timings() {
|
||||
for (k, m, len) in [(4, 2, 160), (4, 2, 1300), (16, 4, 1200), (255, 1, 1300)] {
|
||||
let policy = MediaFecPolicy {
|
||||
audio_data_shards: k as u16,
|
||||
audio_parity_shards: m as u16,
|
||||
max_wire_bytes: GAMECULT_MEDIA_MAX_WIRE_BYTES,
|
||||
..MediaFecPolicy::STANDARD
|
||||
};
|
||||
let block = packets(k, len);
|
||||
let encode = time(|| drop(black_box(protect_audio_block(black_box(&block), &policy, provenance()))), 300);
|
||||
let parity = protect_audio_block(&block, &policy, provenance()).unwrap();
|
||||
let lost = m.min(k);
|
||||
let survivors = &block[lost..];
|
||||
let decode = time(|| drop(black_box(recover_audio_block(black_box(&parity), survivors))), 300);
|
||||
println!("k={k:3} m={m:3} len={len:5}: protect {encode:12.0} ns | recover {lost} lost {decode:12.0} ns");
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue