Files
ferrix/crates/bench/benches/full_table.rs
T
Rayhaan JaufeerallyandClaude Fable 5.1 a8aba52562 Review pass on the codec and the table: bugs fixed, encoders simplified
A review of the wire codec and the routing table, with the fixes.

Bugs in the codec:

- A NOTIFICATION with a code or subcode we have no name for (subcode 0,
  "unspecific", is common) failed to decode and was answered as a bad
  message. Every pair now reads, as BgpError::Other.
- Optional parameters in the extended form of RFC 9072 were read and
  written with one-octet parameter lengths; they are two.
- An ORF capability listing more than one address family made the whole
  OPEN malformed.
- Bytes after an OPEN's optional parameters were accepted.
- Extended communities were split into fields by rules that were wrong
  for the common types. They are kept as their eight octets.
- Several length fields were computed in u16 and could wrap on encode.

Simplifications in the codec:

- Lengths are filled in after the value is written, for messages,
  attributes, capabilities and the UPDATE's two fields. The parallel
  wire_len functions, which had to be kept in step by hand, are gone.
  Message::to_wire no longer encodes into a second buffer.
- Type codes for messages, attributes and capabilities are each defined
  once. The strum discriminant enums and their hand-written conversions
  are removed.
- One function splits an attribute off a buffer; the UPDATE decoder, the
  plain attribute parser and the table's key reader all use it.
- The UPDATE decoder's 185-line loop is an AttributeReader with one
  method that decides what becomes of an attribute. Duplicate detection
  is a bitset, not a HashSet allocated per UPDATE.
- ParserContext::four_octet_asn is a bool. The "context not set" error
  path, which treated a programming error as a malformed route, is gone.
- Codec functions take a context only if they read it, and return a
  Result only if they can fail.
- MP_REACH_NLRI and MP_UNREACH_NLRI hold an AfiSafi.
- AddressFamilyId is IpVersion, which is what it was.
- Deserialize is no longer derived on codec types: nothing used it, and
  it bypassed IpPrefix's invariants. IpPrefix::to_wire, which was
  broken and unused, is removed.

Bugs in the table:

- The index of a row's best route was sixteen bits. A peer using
  ADD-PATH could send more routes for a prefix than that counts, and
  the index wrapped. It is 32 bits, at no cost in row size.
- Shards and attribute-store stripes were picked from the top bits of
  the hash, which are the bits hashbrown keeps as the tag it compares
  first. With 64 shards each shard's table had 2 of 128 tag values.
  They are now picked from bits the table does not use.
- remove_peer held the peer list's lock for the whole scan of the
  table, so no session could establish while a peer was removed.
- When more prefixes went at once than were left, the forced trim
  dropped a fixed few entries, reset every reader, and re-ran the tidy
  on every later write. It drops the older half.
- Readers were woken by every change, including a repeated announcement
  and changes their journal does not record.
- Reader::poll with a budget of zero never made progress.

Simplifications in the table:

- The two journals of a shard are one Journals value written through
  three calls; the five places that kept them in step by hand are gone.
  Whether an entry is still live is decided in one place.
- The shortcuts of route selection moved to select.rs, next to the
  invariant they depend on, which is now written down.
- announce and withdraw take routes with or without path identifiers;
  the _paths variants are removed.
- The selection key is read off the wire form only; the second
  implementation over parsed attributes is removed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-02 10:32:27 +02:00

216 lines
7.9 KiB
Rust

//! What it costs to take in the Internet routing table, from the bytes on
//! the wire to a chosen best route.
//!
//! Run with `cargo bench -p bgp-bench --bench full_table`. Set
//! `BENCH_PEERS` (default 4) and `BENCH_SCALE_DOWN` (default 1; 10 makes the
//! table ten times smaller) to change the size. The table is synthetic (see
//! the crate documentation); `real_table` runs on a real one.
//!
//! This is a stopwatch around whole operations at full size, not a
//! statistical micro-benchmark: the questions are "how long until a full
//! table is in" and "how much memory does it take", and their answers are
//! seconds and gigabytes.
use std::hint::black_box;
use std::net::Ipv4Addr;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::{Duration, Instant};
use bgp_bench::memory::{Region, describe};
use bgp_bench::{Prefixes, Shape, announcements, encoded, wire_context};
use bgp_integration_tests::test_peer::TestPeer;
use bgp_integration_tests::test_server::TestServer;
use bgp_packet::message::{Message, PathAttribute};
use bgp_primitives::ip_prefix::IpPrefix;
use bgp_rib::table::{PeerIndex, PeerInfo, RibTable};
use bytes::BytesMut;
fn env(name: &str, default: usize) -> usize {
std::env::var(name)
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(default)
}
fn mib(bytes: usize) -> f64 {
bytes as f64 / (1024.0 * 1024.0)
}
fn per_second(count: usize, elapsed: Duration) -> String {
let rate = count as f64 / elapsed.as_secs_f64();
if rate >= 1e6 {
format!("{:.2} M/s", rate / 1e6)
} else {
format!("{:.0} k/s", rate / 1e3)
}
}
fn row(what: &str, elapsed: Duration, detail: String) {
println!("{what:<44} {:>9.3} s {detail}", elapsed.as_secs_f64());
}
fn main() {
let peers = env("BENCH_PEERS", 4) as u32;
let shape = Shape::INTERNET.scaled_down(env("BENCH_SCALE_DOWN", 1));
let threads = std::thread::available_parallelism().map_or(1, |n| n.get());
println!(
"== Table: {} IPv4 + {} IPv6 prefixes, from each of {peers} peers",
shape.ipv4_prefixes, shape.ipv6_prefixes
);
println!(
"(IpPrefix is {} bytes, PathAttribute {})",
size_of::<IpPrefix>(),
size_of::<PathAttribute>()
);
let prefixes = Prefixes::generate(shape);
let wire: Vec<Vec<BytesMut>> = (1..=peers)
.map(|peer| encoded(&announcements(&prefixes, shape, peer)))
.collect();
let updates: usize = wire.iter().map(Vec::len).sum();
let routes = shape.prefixes() * peers as usize;
let wire_bytes: usize = wire.iter().flatten().map(BytesMut::len).sum();
println!(
"{updates} UPDATE messages, {:.1} prefixes each, {:.1} MiB on the wire, {routes} routes",
routes as f64 / updates as f64,
mib(wire_bytes),
);
println!("\n== Reading the messages (one thread)");
let ctx = wire_context();
let started = Instant::now();
for message in wire.iter().flatten() {
black_box(Message::decode(&ctx, message).expect("what we wrote is well formed"));
}
let elapsed = started.elapsed();
row(
"decode UPDATEs",
elapsed,
format!(
"{} routes, {} messages, {:.0} MiB/s",
per_second(routes, elapsed),
per_second(updates, elapsed),
mib(wire_bytes) / elapsed.as_secs_f64()
),
);
println!("\n== From bytes to best routes, without the network");
for threads in [1, threads] {
into_table(&wire, routes, threads);
}
println!("\n== End to end: {peers} peers send the table over TCP at once");
let runtime = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.expect("a runtime");
runtime.block_on(async {
let server = TestServer::start(65000).await.expect("a server");
let mut senders = Vec::new();
for (index, messages) in wire.iter().enumerate() {
let peer = index as u32 + 1;
let mut session = TestPeer::connect(server.addr).await.expect("a connection");
session
.handshake(64512 + peer, Ipv4Addr::new(10, 0, 0, peer as u8), 90)
.await
.expect("a session");
let stream: Vec<u8> = messages.iter().flat_map(|m| m.iter().copied()).collect();
senders.push((session, stream));
}
let started = Instant::now();
let sending: Vec<_> = senders
.into_iter()
.map(|(mut session, stream)| {
tokio::spawn(async move {
session.send_raw(&stream).await.expect("the server reads");
// Held open until the table is in.
session
})
})
.collect();
while server.rib.len() < routes {
tokio::time::sleep(Duration::from_millis(2)).await;
}
let elapsed = started.elapsed();
row(
"sessions, state machine, table",
elapsed,
format!(
"{} routes; {} prefixes with a best route",
per_second(routes, elapsed),
server.rib.prefix_count()
),
);
for session in sending {
drop(session.await);
}
});
}
/// What a session does with each message, without the session: decode it,
/// find its attribute set, put its routes in the table, choose the best.
/// `threads` threads each take whole peers.
fn into_table(wire: &[Vec<BytesMut>], routes: usize, threads: usize) {
let region = Region::begin();
let table = RibTable::new(threads * 16);
let peers: Vec<PeerIndex> = (0..wire.len())
.map(|peer| {
table
.add_peer(PeerInfo {
asn: 64512 + peer as u32 + 1,
router_id: Ipv4Addr::from(peer as u32 + 1),
address: Ipv4Addr::new(192, 0, 2, peer as u8 + 1).into(),
ibgp: false,
})
.expect("room for the peer")
})
.collect();
let ctx = wire_context();
let next = AtomicUsize::new(0);
let started = Instant::now();
std::thread::scope(|scope| {
for _ in 0..threads {
scope.spawn(|| {
loop {
let at = next.fetch_add(1, Ordering::Relaxed);
let Some(messages) = wire.get(at) else {
break;
};
for message in messages {
let decoded =
Message::decode_with_report(&ctx, message).expect("well formed");
let Message::Update(update) = decoded.message else {
continue;
};
let wire = decoded.attributes_wire.as_deref().expect("kept");
if !update.announced_routes.is_empty() {
let attrs = table.attrs().intern_received(wire, None).expect("valid");
table.announce(peers[at], &attrs, &update.announced_routes);
}
for attribute in &update.path_attributes {
if let PathAttribute::MpReachNlri(mp) = attribute {
let attrs = table
.attrs()
.intern_received(wire, Some(&mp.next_hop))
.expect("valid");
table.announce(peers[at], &attrs, &mp.prefixes);
}
}
}
}
});
}
});
let elapsed = started.elapsed();
assert_eq!(table.len(), routes);
row(
&format!("decode, intern, insert, choose: {threads} thread(s)"),
elapsed,
format!(
"{} routes; {}",
per_second(routes, elapsed),
describe(region.held(), routes, "route")
),
);
}