export: a kilobyte less a route advertised with all_paths; memory accounted for
ci / check (push) Canceled after 0s
ci / docs (push) Canceled after 0s
ci / interop (push) Canceled after 0s
ci / fuzz (push) Canceled after 0s

A router feeding a collector every path (all_paths) was 4.8 GB resident
with a table that should take a third of that. A heap profile of the
daemon in the Linux VM, with four full tables and such a collector,
showed where it went: the exporter kept, for every route it had
advertised, the route's outgoing form, the parsed attribute list it was
dressed with, through an `Arc<Dressed>` in the per-route record. One
such form lived for every distinct attribute set advertised, about a
kilobyte each, for the life of the session, on top of the exporter's
cache of 262,144 of them. The record needs only to tell whether a route
changed, and the table's own attribute set, which is in memory anyway,
tells it that: the outgoing form is a function of the set, the peer the
route came from and the policy.

This change:

* `Sent` holds an `Arc<AttrSet>` instead of an `Arc<Dressed>`, and a
  route is sent again when its set differs. `Verdict::All` carries both
  while a flush is on.
* `DRESSED_KEPT` goes from 262,144 to 65,536: a kilobyte a set, so 64 MB
  rather than 256 MB a session at most; what a flush is sending holds
  its own.
* The status page's header shows the daemon's memory as jemalloc
  accounts for it (allocated and resident), `/api/status` has the six
  figures (`memory`: allocated, active, metadata, resident, mapped,
  retained), and `bgp_memory_bytes{kind}` exports them. jemalloc is built
  with statistics for this, and `bgp-web` reads them through
  `tikv-jemalloc-ctl`.
* `scripts/profile/full-table.sh` gains `COLLECTOR=1`, a collector
  session over IPv6 that takes both families with ADD-PATH, and `HEAP=1`,
  which builds the daemon with jemalloc's heap profiler (the new
  `heap-profiling` feature of the daemon crate, not for production) and
  turns the profile into a flame graph with jeprof; and it reports
  memory with the table in and again 30 s later. The feeder
  (`crates/bench/examples/feed.rs`) plays the collector with
  `FEED_COLLECTOR=1`.

Measured in the VM, four peers' full tables and a collector sent every
path (4.9 million routes advertised): 1,723 MiB resident before, 1,286
MiB after, with jemalloc's allocated figure from 1,500 MiB to 946 MiB.
Without the collector the same tables take 630 MiB, so the collector's
bookkeeping is now about 50 bytes a route advertised. Of the resident
figure, some 400 MiB was pages jemalloc had freed and not yet returned:
it returns them on allocator activity only, and a router that has its
tables in allocates little. jemalloc is now built with its background
thread on (the `background_threads` feature), which returns them on
time: 1,020 MiB resident 30 s after the load, against 1,286 MiB without
it.

Documentation: `memory` in the HTTP API reference, `bgp_memory_bytes`
in the metrics reference and the monitoring guide, the script's new
settings in the testing guide.

Testing: the ADD-PATH integration tests pass, including the collector
ones; the heap profile and the figures above are from
`PEERS=4 COLLECTOR=1 HEAP=1 scripts/profile/full-table.sh` in the VM.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Rayhaan JaufeerallyandClaude Fable 5.1 committed 2026-10-09 22:51:11 +02:00
1 parent d7d7d39bb9
commit b0dd357986
17 files changed
+281 -26

No files matched your search

+66 -1
View File
@@ -17,6 +17,12 @@
//! follow on from it.
//! - `FEED_HOLD_SECS`: how long the sessions stay open after the table is
//! sent (default 30).
//! - `FEED_COLLECTOR=1`: before the peers, one more session, as a route
//! collector would have: over IPv6, to `FEED_COLLECTOR_TO` (default
//! `[::1]:1790`) from `FEED_COLLECTOR_FROM` if set, offering to receive
//! several routes for a prefix (ADD-PATH) for both families and IPv4
//! routes with IPv6 next hops, and reading and dropping whatever it is
//! sent. Its AS number is 212232.
//!
//! What is printed: when the sessions are up and how long the writes took.
//! The writes end when the kernel's socket buffers have taken the last
@@ -28,6 +34,9 @@ use std::time::{Duration, Instant};
use bgp_bench::{Prefixes, Shape, announcements, encoded, env};
use bgp_integration_tests::test_peer::TestPeer;
use bgp_packet::open::{AddPathEntry, Capability, ExtendedNextHopEntry, SendReceive};
use bgp_primitives::constants::{Afi, AfiSafi, Safi};
use bgp_server::family::SUPPORTED;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpSocket;
use tokio::net::tcp::OwnedWriteHalf;
@@ -81,6 +90,61 @@ async fn main() {
started.elapsed().as_secs_f64()
);
// The collector first, so that it is there as the table arrives, as a
// router's collector session is after a restart.
let collector = env("FEED_COLLECTOR", 0) == 1;
let mut draining = Vec::new();
let mut collector_writer = None;
if collector {
let to: SocketAddr = std::env::var("FEED_COLLECTOR_TO")
.unwrap_or_else(|_| "[::1]:1790".into())
.parse()
.expect("FEED_COLLECTOR_TO is an IPv6 address and port");
let socket = TcpSocket::new_v6().expect("a socket");
if let Ok(from) = std::env::var("FEED_COLLECTOR_FROM") {
let address: std::net::Ipv6Addr = from.parse().expect("an IPv6 address");
socket
.bind(SocketAddr::new(address.into(), 0))
.expect("the collector's address is ours to connect from");
}
let stream = socket.connect(to).await.expect("the daemon listens");
let mut session = TestPeer::from_stream(stream);
let mut capabilities = vec![Capability::FourByteAsn { asn: 212_232 }];
capabilities.extend(SUPPORTED.into_iter().map(Capability::multiprotocol));
capabilities.push(Capability::AddPath {
entries: [AfiSafi::IPV4_UNICAST, AfiSafi::IPV6_UNICAST]
.into_iter()
.map(|family| AddPathEntry {
afi: family.afi,
safi: family.safi,
send_recv: SendReceive::Receive,
})
.collect(),
});
capabilities.push(Capability::ExtendedNextHop {
entries: vec![ExtendedNextHopEntry {
afi: Afi::IPV4,
safi: Safi::UNICAST,
nh_afi: Afi::IPV6,
}],
});
session
.handshake_with(212_232, Ipv4Addr::new(10, 0, 0, 200), 90, capabilities)
.await
.expect("the daemon takes the collector's session");
let (mut reader, writer) = session.into_stream().into_split();
draining.push(tokio::spawn(async move {
let mut sink = [0u8; 65536];
while let Ok(n) = reader.read(&mut sink).await {
if n == 0 {
break;
}
}
}));
println!("collector session established");
collector_writer = Some(writer);
}
let mut sessions = Vec::new();
for peer in 1..=peers {
let socket = TcpSocket::new_v4().expect("a socket");
@@ -129,7 +193,7 @@ async fn main() {
})
})
.collect();
let mut writers: Vec<OwnedWriteHalf> = Vec::new();
let mut writers: Vec<OwnedWriteHalf> = collector_writer.into_iter().collect();
for (index, sender) in senders.into_iter().enumerate() {
let (writer, took) = sender.await.expect("the sender runs");
println!(
@@ -155,4 +219,5 @@ async fn main() {
}
}
println!("sessions closed after {} s", hold.as_secs());
drop(draining);
}
+16 -11
View File
@@ -253,8 +253,9 @@ const COALESCE: Duration = Duration::from_millis(50);
const RESYNCS_AT_ONCE: usize = 4;
/// How many routes ready to go out are remembered, by the attributes they
/// came with. A table has a few hundred thousand distinct sets.
const DRESSED_KEPT: usize = 262_144;
/// came with. A table has a few hundred thousand distinct sets, each a
/// kilobyte or so in this form; what a flush is sending holds its own.
const DRESSED_KEPT: usize = 65_536;
fn version_index(version: IpVersion) -> usize {
match version {
@@ -305,8 +306,11 @@ struct Sent {
/// The path identifier it went out with (ADD-PATH, RFC 7911), which
/// stays its own for as long as it is announced.
id: u32,
/// As it went out, to tell whether it has to go out again.
dressed: Arc<Dressed>,
/// The attributes it went out with, as the table has them: what it
/// goes out as follows from those, so a route whose set is the same
/// has not changed. The table holds the set anyway; the form it goes
/// out in is a kilobyte a set and is not kept here.
attrs: Arc<AttrSet>,
}
/// What the peer is to end up with for a prefix.
@@ -314,9 +318,10 @@ enum Verdict {
/// The best route as it goes out, or `None` for a prefix the peer is
/// not to have.
Best(Option<Arc<Dressed>>),
/// Every route that goes out, with what tells it apart; empty for a
/// prefix the peer is not to have.
All(Vec<(PathKey, Arc<Dressed>)>),
/// Every route that goes out, with what tells it apart and the
/// attributes it came with; empty for a prefix the peer is not to
/// have.
All(Vec<(PathKey, Arc<AttrSet>, Arc<Dressed>)>),
}
/// What the peer is to end up with for each prefix heard of.
@@ -826,7 +831,7 @@ impl Exporter {
};
if let Some(dressed) = learned.dressed(&attrs, source, version, &egress)
{
wanted.push(((peer, path_id), dressed));
wanted.push(((peer, path_id), attrs, dressed));
}
}
}
@@ -898,11 +903,11 @@ impl Exporter {
// again only if it changed; a new one gets the lowest
// identifier not in use for the prefix.
let mut has: Vec<Sent> = Vec::with_capacity(wanted.len());
for (key, dressed) in wanted {
for (key, attrs, dressed) in wanted {
let id = match had.iter().position(|sent| sent.key == key) {
Some(at) => {
let sent = had.swap_remove(at);
if Arc::ptr_eq(&sent.dressed, &dressed) {
if Arc::ptr_eq(&sent.attrs, &attrs) {
has.push(sent);
continue;
}
@@ -919,7 +924,7 @@ impl Exporter {
path_id: id,
},
);
has.push(Sent { key, id, dressed });
has.push(Sent { key, id, attrs });
}
withdrawn.extend(had.iter().map(|sent| Nlri {
prefix,
+12
View File
@@ -211,6 +211,17 @@ pub static PEER_TCP: LazyLock<PeerTcp> = LazyLock::new(|| {
}
});
/// The allocator's account of the daemon's memory, by `kind`: allocated,
/// active, metadata, resident, mapped, retained (jemalloc's `stats.*`).
pub static MEMORY: LazyLock<IntGaugeVec> = LazyLock::new(|| {
register_int_gauge_vec!(
"bgp_memory_bytes",
"The daemon's memory as its allocator accounts for it, by kind",
&["kind"]
)
.expect("metric registration")
});
pub static PEER_MAX_PREFIXES: LazyLock<IntGaugeVec> = LazyLock::new(|| {
register_int_gauge_vec!(
"bgp_peer_max_prefixes",
@@ -372,6 +383,7 @@ pub fn init() {
LazyLock::force(&PEER_UP);
LazyLock::force(&PEER_ADVERTISED);
LazyLock::force(&PEER_TCP);
LazyLock::force(&MEMORY);
BUILD_INFO
.with_label_values(&[env!("CARGO_PKG_VERSION")])
.set(1);
+5
View File
@@ -24,5 +24,10 @@ tokio-stream.workspace = true
tracing.workspace = true
nix = { workspace = true, features = ["hostname"] }
# The allocator's own account of memory, for the status page and the
# metrics. The allocator itself is the daemon's choice (bin/bgp).
[target.'cfg(not(target_env = "msvc"))'.dependencies]
tikv-jemalloc-ctl.workspace = true
[lints]
workspace = true
+4
View File
@@ -39,6 +39,9 @@ fn unix_secs(t: SystemTime) -> u64 {
#[derive(Debug, Serialize)]
pub struct StatusView {
/// The allocator's account of the daemon's memory; `None` where the
/// allocator keeps none.
pub memory: Option<crate::memory::MemoryUse>,
pub router_id: String,
pub local_asn: u32,
pub version: String,
@@ -606,6 +609,7 @@ impl IntoResponse for Unavailable {
pub async fn status(State(state): State<AppState>) -> Result<Json<StatusView>, Unavailable> {
Ok(Json(StatusView {
memory: crate::memory::memory_use(),
router_id: state.info.router_id.to_string(),
local_asn: state.info.local_asn,
version: state.info.version.clone(),
+1
View File
@@ -7,6 +7,7 @@
mod api;
mod events;
mod memory;
mod metrics;
use std::net::Ipv4Addr;
+46
View File
@@ -0,0 +1,46 @@
//! What the allocator says the daemon holds: jemalloc's own statistics,
//! for the status page and the metrics, where the kernel's resident size
//! alone would not say what is in use and what the allocator keeps back.
use serde::Serialize;
/// jemalloc's account of memory, in octets.
#[derive(Debug, Clone, Copy, Serialize)]
pub struct MemoryUse {
/// Octets in allocations the program holds.
pub allocated: u64,
/// Octets in the pages those allocations are in: `allocated` plus
/// the fragmentation within pages.
pub active: u64,
/// Octets of the allocator's own bookkeeping.
pub metadata: u64,
/// Octets in pages mapped and physically resident, as far as the
/// allocator knows: `active`, the metadata, and pages freed but not
/// yet returned to the kernel.
pub resident: u64,
/// Octets mapped, resident or not.
pub mapped: u64,
/// Octets of address space kept back after being unmapped, which
/// cost nothing physical.
pub retained: u64,
}
#[cfg(not(target_env = "msvc"))]
pub fn memory_use() -> Option<MemoryUse> {
use tikv_jemalloc_ctl::{epoch, stats};
// The statistics are refreshed only when the epoch is advanced.
epoch::advance().ok()?;
Some(MemoryUse {
allocated: stats::allocated::read().ok()? as u64,
active: stats::active::read().ok()? as u64,
metadata: stats::metadata::read().ok()? as u64,
resident: stats::resident::read().ok()? as u64,
mapped: stats::mapped::read().ok()? as u64,
retained: stats::retained::read().ok()? as u64,
})
}
#[cfg(target_env = "msvc")]
pub fn memory_use() -> Option<MemoryUse> {
None
}
+15 -1
View File
@@ -5,7 +5,8 @@ use axum::extract::State;
use axum::http::{StatusCode, header};
use axum::response::IntoResponse;
use bgp_server::metrics::{
LOCAL_ROUTES, PEER_TCP, RIB_ATTRIBUTE_SETS, RIB_JOURNAL_ENTRIES, RIB_PREFIXES, RIB_ROUTES,
LOCAL_ROUTES, MEMORY, PEER_TCP, RIB_ATTRIBUTE_SETS, RIB_JOURNAL_ENTRIES, RIB_PREFIXES,
RIB_ROUTES,
};
use prometheus::{Encoder, TextEncoder};
@@ -25,6 +26,19 @@ pub async fn metricz(State(state): State<AppState>) -> impl IntoResponse {
RIB_ATTRIBUTE_SETS.set(gauge(state.rib.attrs().len()));
RIB_JOURNAL_ENTRIES.set(gauge(state.rib.journal_len()));
sample_tcp(&state).await;
if let Some(m) = crate::memory::memory_use() {
let int = |n: u64| i64::try_from(n).unwrap_or(i64::MAX);
for (kind, octets) in [
("allocated", m.allocated),
("active", m.active),
("metadata", m.metadata),
("resident", m.resident),
("mapped", m.mapped),
("retained", m.retained),
] {
MEMORY.with_label_values(&[kind]).set(int(octets));
}
}
let mut buf = Vec::new();
let encoder = TextEncoder::new();
+3 -1
View File
@@ -165,7 +165,7 @@ input, select, button { color-scheme: light dark; }
<div>
<div class="lefthand">
<b id="s-hostname">...</b>: router ID <b id="s-router-id">...</b>, AS <b id="s-asn">...</b>, Ferrix <span id="s-version">...</span><br>
Started <span id="s-started">...</span>, up <span id="s-uptime">...</span><br>
Started <span id="s-started">...</span>, up <span id="s-uptime">...</span><span id="s-memory"></span><br>
<span class="dim">On this page: <a href="#peers">peers</a> · <a href="#rpki">RPKI</a> · <a href="#local">originated</a> · <a href="#kernel">kernel</a> · <a href="#received">received, and looking routes up</a> · <a href="#rejected">rejected</a> · <a href="#events">events</a></span><br>
</div>
<div class="righthand">
@@ -410,6 +410,8 @@ from <select id="lookup-peer"><option value="">any peer</option></select>
$('s-asn').className = 'asn'; $('s-asn').dataset.asn = s.local_asn;
$('s-version').textContent = s.version; $('s-hostname').textContent = s.hostname;
$('s-started').textContent = new Date(s.started_at_unix * 1000).toString().replace(/ \(.*\)$/, '');
const m = s.memory;
setHTML($('s-memory'), m ? ` · memory <span class="mono" title="jemalloc: allocated / active / resident / mapped / retained">${fmtOctets(m.allocated)} allocated, ${fmtOctets(m.resident)} resident</span>` : '');
tick();
}