Whatever acts on the best routes (export to peers, the forwarding table, the status page) reads their changes from a per-shard journal through a table::Reader. Nothing uses it yet. - The journal records that a prefix's best route changed, not what to. A reader that comes to an entry is given the route as it is then. A prefix that changes a thousand times between reads is one event, and the journal never holds more live entries than the table has prefixes. - Each row keeps the sequence number of its latest change; an entry with another number is dead and is dropped when the journal is tidied. - Entries go once every reader has passed them. A reader that does not read cannot hold them back for ever: past a limit the oldest half is dropped, and that reader is given the whole table again (Reset, every best route, Synced). A new reader starts the same way. - With no reader nothing is recorded. - Reader::changed waits for there to be something to read. Tested against a mirror kept from the events: after any interleaving of announcements, withdrawals, session flaps, reads with small budgets and readers coming and going, with journals small enough to be tidied and overrun constantly, a reader that has caught up holds exactly the table's best routes. Also with writers on four threads and a reader on a fifth. The property test found one fault (a pass that resumed part-way claimed to have caught up) and catches three more put in on purpose. On the AMS-IX dump, 12 threads: loading takes 2.29 s with a follower reading throughout against 2.17 s with none, the journal peaking at 170,000 entries for 1.39 million prefixes. With a follower that does not read, the journal ends at 1.74 million entries, and the follower then catches up in 0.08 s. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
552 lines
19 KiB
Rust
552 lines
19 KiB
Rust
//! The same questions as `full_table`, asked of a real table dump: every
|
|
//! route that the peers of a RIPE RIS route collector had announced to it.
|
|
//!
|
|
//! Needs a dump in `crates/bench/data/` (see the README there) or at the
|
|
//! path in `BENCH_MRT`. Without one it says so and does nothing, so that
|
|
//! `cargo bench` works on a fresh checkout. `BENCH_PEERS` limits the run to
|
|
//! the peers with the most routes (default: all of them).
|
|
|
|
use std::collections::HashSet;
|
|
use std::net::{Ipv4Addr, SocketAddr};
|
|
use std::path::PathBuf;
|
|
use std::sync::Arc;
|
|
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
|
use std::time::{Duration, Instant, SystemTime};
|
|
|
|
use bgp_bench::memory::Region;
|
|
use bgp_bench::mrt::{Dump, parse_attributes};
|
|
use bgp_packet::message::{NlriNextHop, PathAttribute};
|
|
use bgp_primitives::ip_prefix::IpPrefix;
|
|
use bgp_rib::table::{Event, PeerIndex, PeerInfo, RibTable};
|
|
use bgp_rib::{AdjRibIn, PeerId, ReceivedUpdate, RibEntry};
|
|
|
|
/// Bytes allocated and not freed since `region` began; zero when memory
|
|
/// is not being counted (see `bgp_bench::memory`).
|
|
fn retained(region: &Region) -> i64 {
|
|
region.held().unwrap_or(0)
|
|
}
|
|
|
|
fn mib(bytes: i64) -> f64 {
|
|
bytes as f64 / (1024.0 * 1024.0)
|
|
}
|
|
|
|
fn per_second(count: usize, elapsed: Duration) -> String {
|
|
format!("{:.2} M/s", count as f64 / elapsed.as_secs_f64() / 1e6)
|
|
}
|
|
|
|
fn row(what: &str, elapsed: Duration, detail: String) {
|
|
println!("{what:<44} {:>9.3} s {detail}", elapsed.as_secs_f64());
|
|
}
|
|
|
|
fn dump_path() -> Option<PathBuf> {
|
|
if let Ok(path) = std::env::var("BENCH_MRT") {
|
|
return Some(path.into());
|
|
}
|
|
let data = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("data");
|
|
let mut dumps: Vec<PathBuf> = std::fs::read_dir(data)
|
|
.ok()?
|
|
.filter_map(|entry| Some(entry.ok()?.path()))
|
|
.filter(|path| path.extension().is_some_and(|e| e == "gz"))
|
|
.collect();
|
|
dumps.sort();
|
|
dumps.pop()
|
|
}
|
|
|
|
type Attributes = (Arc<[PathAttribute]>, Option<NlriNextHop>);
|
|
|
|
fn main() {
|
|
if Region::begin().held().is_none() {
|
|
println!(
|
|
"Memory is not counted in this run: sizes print as 0. For sizes (and slower\ntimings) add --features count-memory.\n"
|
|
);
|
|
}
|
|
let Some(path) = dump_path() else {
|
|
println!("No table dump found; see crates/bench/data/README.md. Nothing to do.");
|
|
return;
|
|
};
|
|
println!("== Reading {}", path.display());
|
|
let started = Instant::now();
|
|
let dump = Dump::read(&path).expect("a readable dump");
|
|
println!(
|
|
"{} peers, {} routes, {} distinct attribute sets, read in {:.0} s",
|
|
dump.peers.len(),
|
|
dump.route_count(),
|
|
dump.attribute_sets.len(),
|
|
started.elapsed().as_secs_f64()
|
|
);
|
|
|
|
let limit = std::env::var("BENCH_PEERS")
|
|
.ok()
|
|
.and_then(|v| v.parse().ok())
|
|
.unwrap_or(dump.peers.len());
|
|
let peers = dump.largest_peers(limit);
|
|
let peers: Vec<usize> = peers
|
|
.into_iter()
|
|
.filter(|&peer| !dump.routes[peer].is_empty())
|
|
.collect();
|
|
describe(&dump, &peers);
|
|
|
|
// Parsed once, outside the timings: reading messages is measured by
|
|
// `full_table`, and here the attributes arrive already parsed.
|
|
let region = Region::begin();
|
|
let parsed: Vec<Option<Attributes>> = dump
|
|
.attribute_sets
|
|
.iter()
|
|
.map(|wire| parse_attributes(wire).map(|(a, next_hop)| (Arc::from(a), next_hop)))
|
|
.collect();
|
|
let parsed_bytes = retained(®ion);
|
|
println!(
|
|
"\nattribute sets, parsed (Arc<[PathAttribute]>) {:.0} MiB = {:.0} bytes a set",
|
|
mib(parsed_bytes),
|
|
parsed_bytes as f64 / parsed.len() as f64
|
|
);
|
|
let unparsed = parsed.iter().filter(|a| a.is_none()).count();
|
|
if unparsed > 0 {
|
|
println!("{unparsed} attribute sets could not be parsed; their routes are left out");
|
|
}
|
|
|
|
let runtime = tokio::runtime::Builder::new_multi_thread()
|
|
.enable_all()
|
|
.build()
|
|
.expect("a runtime");
|
|
current_table(&runtime, &dump, &peers, &parsed);
|
|
drop(runtime);
|
|
|
|
let threads = std::thread::available_parallelism().map_or(1, |n| n.get());
|
|
sharded_table(&dump, &peers, &parsed, 1);
|
|
sharded_table(&dump, &peers, &parsed, threads);
|
|
followed_table(&dump, &peers, &parsed, threads);
|
|
}
|
|
|
|
/// What the table looks like, as far as it decides what a RIB costs.
|
|
fn describe(dump: &Dump, peers: &[usize]) {
|
|
let routes: usize = peers.iter().map(|&p| dump.routes[p].len()).sum();
|
|
let full = peers
|
|
.iter()
|
|
.filter(|&&p| dump.routes[p].len() > 800_000)
|
|
.count();
|
|
let prefixes: HashSet<IpPrefix> = peers
|
|
.iter()
|
|
.flat_map(|&p| dump.routes[p].iter().map(|(prefix, _)| *prefix))
|
|
.collect();
|
|
let mut sets_used = HashSet::new();
|
|
let mut sets_per_peer = 0usize;
|
|
for &peer in peers {
|
|
let own: HashSet<u32> = dump.routes[peer].iter().map(|(_, set)| *set).collect();
|
|
sets_per_peer += own.len();
|
|
sets_used.extend(own);
|
|
}
|
|
let attribute_bytes: usize = sets_used
|
|
.iter()
|
|
.map(|&set| dump.attribute_sets[set as usize].len())
|
|
.sum();
|
|
println!("\n== The table ({} peers used)", peers.len());
|
|
println!("routes {routes:>12}");
|
|
println!("peers with more than 800,000 routes {full:>12}");
|
|
println!(
|
|
"distinct prefixes {:>12}",
|
|
prefixes.len()
|
|
);
|
|
println!(
|
|
"routes per prefix {:>12.1}",
|
|
routes as f64 / prefixes.len() as f64
|
|
);
|
|
println!(
|
|
"distinct attribute sets {:>12}",
|
|
sets_used.len()
|
|
);
|
|
println!(
|
|
" if each peer's were kept apart {sets_per_peer:>12} ({:.2}x: sets shared between peers)",
|
|
sets_per_peer as f64 / sets_used.len() as f64
|
|
);
|
|
println!(
|
|
"routes per attribute set {:>12.1}",
|
|
routes as f64 / sets_used.len() as f64
|
|
);
|
|
println!(
|
|
"attribute set on the wire, mean {:>12.0} bytes",
|
|
attribute_bytes as f64 / sets_used.len() as f64
|
|
);
|
|
}
|
|
|
|
fn peer_id(dump: &Dump, peer: usize) -> PeerId {
|
|
PeerId {
|
|
// The port tells apart peers that share an address in the dump.
|
|
addr: SocketAddr::new(dump.peers[peer].address, peer as u16),
|
|
asn: dump.peers[peer].asn,
|
|
}
|
|
}
|
|
|
|
/// A peer's routes as the UPDATEs that would have announced them: routes
|
|
/// that share their attributes arrive together.
|
|
fn as_updates(
|
|
dump: &Dump,
|
|
peer: usize,
|
|
parsed: &[Option<Attributes>],
|
|
received_at: SystemTime,
|
|
) -> Vec<ReceivedUpdate> {
|
|
let mut routes = dump.routes[peer].clone();
|
|
routes.sort_by_key(|(_, set)| *set);
|
|
routes
|
|
.chunk_by(|a, b| a.1 == b.1)
|
|
.filter_map(|group| {
|
|
let (attributes, next_hop) = parsed[group[0].1 as usize].as_ref()?;
|
|
Some(ReceivedUpdate {
|
|
withdrawn: vec![],
|
|
announced: group
|
|
.iter()
|
|
.map(|(prefix, _)| {
|
|
(
|
|
*prefix,
|
|
RibEntry {
|
|
path_attributes: Arc::clone(attributes),
|
|
next_hop: next_hop.clone(),
|
|
received_at,
|
|
},
|
|
)
|
|
})
|
|
.collect(),
|
|
})
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn current_table(
|
|
runtime: &tokio::runtime::Runtime,
|
|
dump: &Dump,
|
|
peers: &[usize],
|
|
parsed: &[Option<Attributes>],
|
|
) {
|
|
println!("\n== Adj-RIB-In as it is today (the actor, fed from one task)");
|
|
// From before the routes exist to the filled table: what it holds,
|
|
// apart from the attribute sets, which were parsed above.
|
|
let region = Region::begin();
|
|
let now = SystemTime::now();
|
|
let updates: Vec<(PeerId, Vec<ReceivedUpdate>)> = peers
|
|
.iter()
|
|
.map(|&peer| (peer_id(dump, peer), as_updates(dump, peer, parsed, now)))
|
|
.collect();
|
|
let routes: usize = updates
|
|
.iter()
|
|
.flat_map(|(_, updates)| updates)
|
|
.map(|update| update.announced.len())
|
|
.sum();
|
|
let messages: usize = updates.iter().map(|(_, updates)| updates.len()).sum();
|
|
println!(
|
|
"{routes} routes in {messages} UPDATEs, {:.1} routes each",
|
|
routes as f64 / messages as f64
|
|
);
|
|
let first = updates[0].0;
|
|
let first_routes: usize = updates[0].1.iter().map(|u| u.announced.len()).sum();
|
|
|
|
runtime.block_on(async {
|
|
let rib = AdjRibIn::spawn();
|
|
let started = Instant::now();
|
|
for (peer, updates) in updates {
|
|
for update in updates {
|
|
rib.apply(peer, update).await.expect("the actor runs");
|
|
}
|
|
}
|
|
let stored = rib.len().await.expect("the actor runs");
|
|
let elapsed = started.elapsed();
|
|
let held = retained(®ion);
|
|
row(
|
|
"insert every route",
|
|
elapsed,
|
|
format!(
|
|
"{} routes; {stored} stored; {:.0} MiB = {:.0} bytes a route, attribute sets not counted",
|
|
per_second(routes, elapsed),
|
|
mib(held),
|
|
held as f64 / stored as f64
|
|
),
|
|
);
|
|
|
|
let before = Region::begin();
|
|
let started = Instant::now();
|
|
let snapshot = rib.snapshot().await.expect("the actor runs");
|
|
row(
|
|
"snapshot",
|
|
started.elapsed(),
|
|
format!("copies {:.0} MiB", mib(retained(&before))),
|
|
);
|
|
drop(snapshot);
|
|
|
|
let started = Instant::now();
|
|
let removed = rib.remove_peer(first).await.expect("the actor runs");
|
|
let elapsed = started.elapsed();
|
|
assert_eq!(removed, first_routes);
|
|
row(
|
|
"remove one peer (session down)",
|
|
elapsed,
|
|
per_second(removed, elapsed) + " routes",
|
|
);
|
|
});
|
|
}
|
|
|
|
/// The sharded table, filled by `threads` threads. Each takes whole peers,
|
|
/// as sessions would, and for every UPDATE interns the attributes and then
|
|
/// announces the prefixes: everything between a decoded message and a
|
|
/// chosen best route.
|
|
fn sharded_table(dump: &Dump, peers: &[usize], parsed: &[Option<Attributes>], threads: usize) {
|
|
println!("\n== The sharded table, {threads} thread(s)");
|
|
let updates = grouped(dump, peers, parsed);
|
|
let routes: usize = updates
|
|
.iter()
|
|
.flatten()
|
|
.map(|(_, prefixes)| prefixes.len())
|
|
.sum();
|
|
|
|
let region = Region::begin();
|
|
let table = Arc::new(RibTable::new(threads * 16));
|
|
let indices = add_peers(&table, dump, peers);
|
|
let started = Instant::now();
|
|
let best_changes = load(&table, dump, &updates, &indices, threads);
|
|
let elapsed = started.elapsed();
|
|
assert_eq!(table.len(), routes);
|
|
let held = retained(®ion);
|
|
row(
|
|
"intern attributes, insert, choose best",
|
|
elapsed,
|
|
format!(
|
|
"{} routes; {:.0} MiB = {:.0} bytes a route, attribute sets included",
|
|
per_second(routes, elapsed),
|
|
mib(held),
|
|
held as f64 / routes as f64
|
|
),
|
|
);
|
|
println!(
|
|
" {} prefixes, {} attribute sets, best route changed {} times",
|
|
table.prefix_count(),
|
|
table.attrs().len(),
|
|
best_changes
|
|
);
|
|
|
|
let started = Instant::now();
|
|
let mut best = 0usize;
|
|
table.for_each_best(|prefix, _, _| {
|
|
best += usize::from(std::hint::black_box(prefix).length() > 0)
|
|
});
|
|
row(
|
|
"read every best route",
|
|
started.elapsed(),
|
|
format!("{best} of them, in place, table not stopped"),
|
|
);
|
|
|
|
// Reading has to look at each route for the timing to mean anything.
|
|
let started = Instant::now();
|
|
let mut all = 0usize;
|
|
let mut hops = 0u64;
|
|
table.for_each_route(|_, _, attrs, _| {
|
|
all += 1;
|
|
hops += u64::from(attrs.key().as_path_len);
|
|
});
|
|
row(
|
|
"read every route and its attributes",
|
|
started.elapsed(),
|
|
format!(
|
|
"{all} of them, mean AS path {:.1}",
|
|
hops as f64 / all as f64
|
|
),
|
|
);
|
|
|
|
let started = Instant::now();
|
|
let outcome = table.remove_peer(indices[0]);
|
|
let elapsed = started.elapsed();
|
|
row(
|
|
"remove one peer (session down)",
|
|
elapsed,
|
|
format!(
|
|
"{} routes; best route changed for {} prefixes",
|
|
per_second(outcome.removed, elapsed),
|
|
outcome.best_changed
|
|
),
|
|
);
|
|
let started = Instant::now();
|
|
let swept = table.attrs().sweep();
|
|
row(
|
|
"drop attribute sets no longer used",
|
|
started.elapsed(),
|
|
format!("{swept} dropped"),
|
|
);
|
|
}
|
|
|
|
/// A peer's routes grouped as UPDATEs: the attribute set and the prefixes
|
|
/// announced with it.
|
|
type Updates = Vec<Vec<(u32, Vec<IpPrefix>)>>;
|
|
|
|
fn grouped(dump: &Dump, peers: &[usize], parsed: &[Option<Attributes>]) -> Updates {
|
|
peers
|
|
.iter()
|
|
.map(|&peer| {
|
|
let mut routes = dump.routes[peer].clone();
|
|
routes.sort_by_key(|(_, set)| *set);
|
|
routes
|
|
.chunk_by(|a, b| a.1 == b.1)
|
|
.filter(|group| parsed[group[0].1 as usize].is_some())
|
|
.map(|group| {
|
|
(
|
|
group[0].1,
|
|
group.iter().map(|(prefix, _)| *prefix).collect(),
|
|
)
|
|
})
|
|
.collect()
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn add_peers(table: &RibTable, dump: &Dump, peers: &[usize]) -> Vec<PeerIndex> {
|
|
peers
|
|
.iter()
|
|
.map(|&peer| {
|
|
table
|
|
.add_peer(PeerInfo {
|
|
asn: dump.peers[peer].asn,
|
|
router_id: Ipv4Addr::from(peer as u32 + 1),
|
|
address: dump.peers[peer].address,
|
|
ibgp: false,
|
|
})
|
|
.expect("room for the peer")
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// Announces everything in `updates` on `threads` threads, each taking
|
|
/// whole peers. Returns how many times a best route changed.
|
|
fn load(
|
|
table: &RibTable,
|
|
dump: &Dump,
|
|
updates: &Updates,
|
|
indices: &[PeerIndex],
|
|
threads: usize,
|
|
) -> usize {
|
|
let next = AtomicUsize::new(0);
|
|
let best_changes = AtomicUsize::new(0);
|
|
std::thread::scope(|scope| {
|
|
for _ in 0..threads {
|
|
scope.spawn(|| {
|
|
let mut changes = 0;
|
|
loop {
|
|
let at = next.fetch_add(1, Ordering::Relaxed);
|
|
let Some(updates) = updates.get(at) else {
|
|
break;
|
|
};
|
|
for (set, prefixes) in updates {
|
|
// From the bytes, as a dump has them and as a
|
|
// session could hand them over without parsing.
|
|
let Some(attrs) = table
|
|
.attrs()
|
|
.intern_wire(&dump.attribute_sets[*set as usize])
|
|
else {
|
|
continue;
|
|
};
|
|
changes += table.announce(indices[at], &attrs, prefixes).best_changed;
|
|
}
|
|
}
|
|
best_changes.fetch_add(changes, Ordering::Relaxed);
|
|
});
|
|
}
|
|
});
|
|
best_changes.load(Ordering::Relaxed)
|
|
}
|
|
|
|
/// The same load with someone following the best routes through the
|
|
/// journal: once reading as fast as it can while the table fills, once not
|
|
/// reading at all until the table is full.
|
|
fn followed_table(dump: &Dump, peers: &[usize], parsed: &[Option<Attributes>], threads: usize) {
|
|
println!("\n== The sharded table with a follower, {threads} thread(s)");
|
|
let updates = grouped(dump, peers, parsed);
|
|
let routes: usize = updates
|
|
.iter()
|
|
.flatten()
|
|
.map(|(_, prefixes)| prefixes.len())
|
|
.sum();
|
|
let count = |counts: &mut (usize, usize, usize), event: Event<'_>| match event {
|
|
Event::Best { attrs, .. } => {
|
|
// Look at the route, as a follower would.
|
|
counts.0 += usize::from(attrs.key().as_path_len > 0);
|
|
}
|
|
Event::Withdrawn { .. } => counts.1 += 1,
|
|
Event::Reset | Event::Synced => counts.2 += 1,
|
|
};
|
|
|
|
// Reading while the table fills.
|
|
let table = Arc::new(RibTable::new(threads * 16));
|
|
let indices = add_peers(&table, dump, peers);
|
|
let done = AtomicBool::new(false);
|
|
let started = Instant::now();
|
|
let (best_changes, counts, longest, after_load) = std::thread::scope(|scope| {
|
|
let follower = scope.spawn(|| {
|
|
let mut reader = table.reader();
|
|
let mut counts = (0, 0, 0);
|
|
let mut longest = 0;
|
|
while !done.load(Ordering::Acquire) {
|
|
reader.poll(4096, |event| count(&mut counts, event));
|
|
longest = longest.max(table.journal_len());
|
|
}
|
|
let finishing = Instant::now();
|
|
while !reader
|
|
.poll(4096, |event| count(&mut counts, event))
|
|
.caught_up
|
|
{}
|
|
(counts, longest, finishing.elapsed())
|
|
});
|
|
let best_changes = load(&table, dump, &updates, &indices, threads);
|
|
done.store(true, Ordering::Release);
|
|
let (counts, longest, after_load) = follower.join().expect("the follower runs");
|
|
(best_changes, counts, longest, after_load)
|
|
});
|
|
let elapsed = started.elapsed();
|
|
assert_eq!(table.len(), routes);
|
|
row(
|
|
"load, follower reading throughout",
|
|
elapsed,
|
|
format!(
|
|
"{} routes; caught up {:.3} s after the last route",
|
|
per_second(routes, elapsed),
|
|
after_load.as_secs_f64()
|
|
),
|
|
);
|
|
println!(
|
|
" best route changed {best_changes} times; the follower was given {} events for it; \n journal at most {longest} entries for {} prefixes",
|
|
counts.0 + counts.1,
|
|
table.prefix_count()
|
|
);
|
|
drop(table);
|
|
|
|
// Not reading: the journal has to stay small on its own account.
|
|
let table = Arc::new(RibTable::new(threads * 16));
|
|
let indices = add_peers(&table, dump, peers);
|
|
let mut reader = table.reader();
|
|
let mut counts = (0, 0, 0);
|
|
reader.poll(usize::MAX, |event| count(&mut counts, event));
|
|
let started = Instant::now();
|
|
load(&table, dump, &updates, &indices, threads);
|
|
let elapsed = started.elapsed();
|
|
row(
|
|
"load, follower not reading",
|
|
elapsed,
|
|
format!(
|
|
"{} routes; journal holds {} entries for {} prefixes",
|
|
per_second(routes, elapsed),
|
|
table.journal_len(),
|
|
table.prefix_count()
|
|
),
|
|
);
|
|
let started = Instant::now();
|
|
let mut counts = (0, 0, 0);
|
|
while !reader
|
|
.poll(4096, |event| count(&mut counts, event))
|
|
.caught_up
|
|
{}
|
|
let elapsed = started.elapsed();
|
|
row(
|
|
"the follower then catches up",
|
|
elapsed,
|
|
format!(
|
|
"{} events, {} of them a reset",
|
|
counts.0 + counts.1,
|
|
counts.2
|
|
),
|
|
);
|
|
}
|