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:
Meta Vens 2026-09-30 14:53:53 +02:00
commit 7c1a792a28
2 changed files with 104 additions and 2 deletions

View file

@ -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

View 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");
}
}