Skip to content

Commit ae11946

Browse files
committed
perf(memtrack): batch ring-buffer events over the channel
The poll thread sent one item per channel message, and std's mpsc allocates a 31-slot block per 31 messages, so a run that captured 704k events also allocated 22.7k blocks purely to hand them over. The callback now fills a shared 1024-item buffer and sends it whole. Partial batches are flushed after every poll, after the drain-path consume before its ack, and on the shutdown consume, so `drain()` keeps promising that all pending entries sit in the channel once it returns. Consumers take `Vec<T>` and flatten: the encode pipeline keeps its `IntoIterator<Item = MemtrackEvent>` contract unchanged.
1 parent 6f06c0d commit ae11946

7 files changed

Lines changed: 168 additions & 34 deletions

File tree

‎.github/workflows/ci.yml‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -159,8 +159,8 @@ jobs:
159159
fail-fast: false
160160
matrix:
161161
mode: [walltime, memory]
162-
# One config file per workload so each job stays short; both physical
163-
# variants of a workload live in the same file to be measured back to back.
162+
# Both physical variants of a workload live in the same file, so they
163+
# are measured back to back.
164164
workload: [ls, dd, tar]
165165
env:
166166
CODSPEED_REV: ${{ github.event.pull_request.head.sha || github.sha }}

‎crates/memtrack/src/ebpf/attach_worker.rs‎

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,7 @@ impl Drop for AttachWorker {
121121

122122
struct Worker {
123123
poller: RingBufferPoller,
124-
rx: mpsc::Receiver<AttachRequest>,
124+
rx: mpsc::Receiver<Vec<AttachRequest>>,
125125
bpf: Arc<Mutex<MemtrackBpf>>,
126126
shutdown: Arc<AtomicBool>,
127127
fatal: Arc<Mutex<Option<String>>>,
@@ -139,7 +139,7 @@ impl Worker {
139139
self.record_fatal(e);
140140
return;
141141
}
142-
let mut batch: Vec<AttachRequest> = self.rx.try_iter().collect();
142+
let mut batch: Vec<AttachRequest> = self.rx.try_iter().flatten().collect();
143143
if batch.is_empty() {
144144
break;
145145
}
@@ -151,12 +151,14 @@ impl Worker {
151151
}
152152

153153
let first = match self.rx.recv_timeout(RECV_TIMEOUT) {
154-
Ok(req) => req,
154+
Ok(reqs) => reqs,
155155
Err(RecvTimeoutError::Timeout) => continue,
156156
Err(RecvTimeoutError::Disconnected) => return,
157157
};
158-
let mut batch: Vec<AttachRequest> =
159-
std::iter::once(first).chain(self.rx.try_iter()).collect();
158+
let mut batch: Vec<AttachRequest> = first
159+
.into_iter()
160+
.chain(self.rx.try_iter().flatten())
161+
.collect();
160162

161163
if let Err(e) = self.process_batch(&mut batch, &mut known) {
162164
self.record_fatal(e);
@@ -195,7 +197,7 @@ impl Worker {
195197

196198
// Every producer is stopped, so a synchronous drain is complete.
197199
self.poller.drain()?;
198-
batch.extend(self.rx.try_iter());
200+
batch.extend(self.rx.try_iter().flatten());
199201
}
200202

201203
let mut seen: HashSet<(u64, u64)> = HashSet::new();

‎crates/memtrack/src/ebpf/memtrack/mod.rs‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -218,7 +218,7 @@ impl MemtrackBpf {
218218
pub fn poll_events_with_channel(
219219
&self,
220220
poll_interval_ms: u64,
221-
tx: std::sync::mpsc::Sender<runner_shared::artifacts::MemtrackEvent>,
221+
tx: std::sync::mpsc::Sender<Vec<runner_shared::artifacts::MemtrackEvent>>,
222222
) -> Result<RingBufferPoller> {
223223
with_skel!(self, skel => RingBufferPoller::new(
224224
&skel.maps.events,
@@ -233,7 +233,7 @@ impl MemtrackBpf {
233233
pub(crate) fn poll_attach_with_channel(
234234
&self,
235235
poll_interval_ms: u64,
236-
tx: std::sync::mpsc::Sender<crate::ebpf::events::AttachRequest>,
236+
tx: std::sync::mpsc::Sender<Vec<crate::ebpf::events::AttachRequest>>,
237237
) -> Result<RingBufferPoller> {
238238
with_skel!(self, skel => RingBufferPoller::new(
239239
&skel.maps.attach_requests,

‎crates/memtrack/src/ebpf/poller.rs‎

Lines changed: 150 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,58 @@
11
use anyhow::{Context, Result};
22
use libbpf_rs::{MapCore, RingBufferBuilder};
3+
use parking_lot::Mutex;
4+
use std::sync::Arc;
35
use std::sync::mpsc::{self, RecvTimeoutError, Sender};
46
use std::thread::JoinHandle;
57
use std::time::Duration;
68

9+
/// Items buffered before a channel send. `std::sync::mpsc` allocates a block
10+
/// every 31 messages, so sending one item at a time makes that allocation
11+
/// dominate the pipeline; batching amortizes it over a whole batch.
12+
const BATCH_ITEMS: usize = 1024;
13+
14+
/// The lock is released before the send so a slow consumer never blocks the
15+
/// ring-buffer callback.
16+
fn flush_batch<T>(batch: &Mutex<Vec<T>>, tx: &Sender<Vec<T>>) {
17+
let mut buf = batch.lock();
18+
if buf.is_empty() {
19+
return;
20+
}
21+
let items = std::mem::replace(&mut *buf, Vec::with_capacity(BATCH_ITEMS));
22+
drop(buf);
23+
let _ = tx.send(items);
24+
}
25+
26+
fn poll_iteration<T>(
27+
control: std::result::Result<Sender<()>, RecvTimeoutError>,
28+
consume: impl FnOnce(),
29+
poll: impl FnOnce(),
30+
batch: &Mutex<Vec<T>>,
31+
tx: &Sender<Vec<T>>,
32+
) -> bool {
33+
match control {
34+
Ok(ack) => {
35+
consume();
36+
// `drain` promises pending entries are in the channel before returning.
37+
flush_batch(batch, tx);
38+
let _ = ack.send(());
39+
true
40+
}
41+
Err(RecvTimeoutError::Timeout) => {
42+
poll();
43+
flush_batch(batch, tx);
44+
true
45+
}
46+
Err(RecvTimeoutError::Disconnected) => {
47+
consume();
48+
flush_batch(batch, tx);
49+
false
50+
}
51+
}
52+
}
53+
754
/// Polls a BPF ring buffer in a background thread, parsing raw entries with a
8-
/// user-supplied closure and forwarding them to an mpsc channel.
55+
/// user-supplied closure and forwarding them to an mpsc channel in batches.
956
///
1057
/// The poll thread runs until the poller is dropped, doing a final full
1158
/// `consume()` on shutdown so no buffered entries are lost.
@@ -15,17 +62,36 @@ pub struct RingBufferPoller {
1562
}
1663

1764
impl RingBufferPoller {
18-
pub fn new<M, T, F>(rb_map: &M, parse: F, tx: Sender<T>, poll_interval_ms: u64) -> Result<Self>
65+
pub fn new<M, T, F>(
66+
rb_map: &M,
67+
parse: F,
68+
tx: Sender<Vec<T>>,
69+
poll_interval_ms: u64,
70+
) -> Result<Self>
1971
where
2072
M: MapCore,
2173
T: Send + 'static,
2274
F: Fn(&[u8]) -> Option<T> + Send + 'static,
2375
{
76+
// `Arc<Mutex<_>>` rather than `Rc<RefCell<_>>`: the built `RingBuffer` moves
77+
// into the poll thread, so the callback must be `Send`.
78+
let batch = Arc::new(Mutex::new(Vec::with_capacity(BATCH_ITEMS)));
79+
let cb_batch = Arc::clone(&batch);
80+
let cb_tx = tx.clone();
81+
2482
let mut builder = RingBufferBuilder::new();
2583
builder.add(rb_map, move |data| {
26-
if let Some(item) = parse(data) {
27-
let _ = tx.send(item);
84+
let Some(item) = parse(data) else {
85+
return 0;
86+
};
87+
let mut buf = cb_batch.lock();
88+
buf.push(item);
89+
if buf.len() < BATCH_ITEMS {
90+
return 0;
2891
}
92+
let items = std::mem::replace(&mut *buf, Vec::with_capacity(BATCH_ITEMS));
93+
drop(buf);
94+
let _ = cb_tx.send(items);
2995
0
3096
})?;
3197
let ringbuf = builder.build()?;
@@ -35,21 +101,17 @@ impl RingBufferPoller {
35101
// poll tick, and disconnection is the shutdown signal.
36102
let (ctl, ctl_rx) = mpsc::channel::<Sender<()>>();
37103
let poll_thread = std::thread::spawn(move || {
38-
loop {
39-
match ctl_rx.recv_timeout(Duration::from_millis(poll_interval_ms)) {
40-
Ok(ack) => {
41-
let _ = ringbuf.consume();
42-
let _ = ack.send(());
43-
}
44-
Err(RecvTimeoutError::Timeout) => {
45-
let _ = ringbuf.poll(Duration::ZERO);
46-
}
47-
Err(RecvTimeoutError::Disconnected) => {
48-
let _ = ringbuf.consume();
49-
break;
50-
}
51-
}
52-
}
104+
while poll_iteration(
105+
ctl_rx.recv_timeout(Duration::from_millis(poll_interval_ms)),
106+
|| {
107+
let _ = ringbuf.consume();
108+
},
109+
|| {
110+
let _ = ringbuf.poll(Duration::ZERO);
111+
},
112+
&batch,
113+
&tx,
114+
) {}
53115
});
54116

55117
Ok(Self {
@@ -77,3 +139,72 @@ impl Drop for RingBufferPoller {
77139
}
78140
}
79141
}
142+
143+
#[cfg(test)]
144+
mod tests {
145+
use super::*;
146+
use std::cell::Cell;
147+
148+
fn partial_batch() -> Vec<u8> {
149+
vec![42; BATCH_ITEMS - 1]
150+
}
151+
152+
#[test]
153+
fn timeout_flushes_partial_batch() {
154+
let expected = partial_batch();
155+
let batch = Mutex::new(expected.clone());
156+
let (tx, rx) = mpsc::channel();
157+
let polled = Cell::new(false);
158+
159+
assert!(poll_iteration(
160+
Err(RecvTimeoutError::Timeout),
161+
|| unreachable!(),
162+
|| polled.set(true),
163+
&batch,
164+
&tx,
165+
));
166+
167+
assert!(polled.get());
168+
assert_eq!(rx.recv().unwrap(), expected);
169+
}
170+
171+
#[test]
172+
fn drain_flushes_partial_batch() {
173+
let expected = partial_batch();
174+
let batch = Mutex::new(expected.clone());
175+
let (tx, rx) = mpsc::channel();
176+
let (ack_tx, ack_rx) = mpsc::channel();
177+
let consumed = Cell::new(false);
178+
179+
assert!(poll_iteration(
180+
Ok(ack_tx),
181+
|| consumed.set(true),
182+
|| unreachable!(),
183+
&batch,
184+
&tx,
185+
));
186+
187+
assert!(consumed.get());
188+
assert_eq!(rx.recv().unwrap(), expected);
189+
ack_rx.recv().unwrap();
190+
}
191+
192+
#[test]
193+
fn shutdown_flushes_partial_batch() {
194+
let expected = partial_batch();
195+
let batch = Mutex::new(expected.clone());
196+
let (tx, rx) = mpsc::channel();
197+
let consumed = Cell::new(false);
198+
199+
assert!(!poll_iteration(
200+
Err(RecvTimeoutError::Disconnected),
201+
|| consumed.set(true),
202+
|| unreachable!(),
203+
&batch,
204+
&tx,
205+
));
206+
207+
assert!(consumed.get());
208+
assert_eq!(rx.recv().unwrap(), expected);
209+
}
210+
}

‎crates/memtrack/src/main.rs‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,8 @@ fn track_command(
130130
.map(|n| n.get().saturating_sub(2).max(1))
131131
.unwrap_or(4);
132132

133-
let pipeline_thread = thread::spawn(move || encode_events(event_rx, out_file, n_workers));
133+
let pipeline_thread =
134+
thread::spawn(move || encode_events(event_rx.into_iter().flatten(), out_file, n_workers));
134135

135136
// Wait for the command to complete
136137
let status = session.wait().context("Failed to wait for command")?;

‎crates/memtrack/src/session.rs‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,14 @@ use std::sync::mpsc::Receiver;
88
/// stays alive as long as the session does; dropping it stops event delivery.
99
pub struct Session {
1010
child: Child,
11-
events: Option<Receiver<MemtrackEvent>>,
11+
events: Option<Receiver<Vec<MemtrackEvent>>>,
1212
_poller: RingBufferPoller,
1313
}
1414

1515
impl Session {
1616
pub(crate) fn new(
1717
child: Child,
18-
events: Receiver<MemtrackEvent>,
18+
events: Receiver<Vec<MemtrackEvent>>,
1919
poller: RingBufferPoller,
2020
) -> Self {
2121
Self {
@@ -30,7 +30,7 @@ impl Session {
3030
}
3131

3232
/// Take ownership of the event receiver. Can only be taken once.
33-
pub fn take_events(&mut self) -> Result<Receiver<MemtrackEvent>> {
33+
pub fn take_events(&mut self) -> Result<Receiver<Vec<MemtrackEvent>>> {
3434
self.events.take().context("events already taken")
3535
}
3636

‎crates/memtrack/tests/shared.rs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -361,7 +361,7 @@ fn run_tracked<T>(
361361
// Dropping the session does a final ring buffer drain and closes the
362362
// channel, so collecting terminates without a silence timeout.
363363
drop(session);
364-
let events: Vec<Event> = rx.iter().collect();
364+
let events: Vec<Event> = rx.into_iter().flatten().collect();
365365

366366
tracker.finish()?;
367367

0 commit comments

Comments
 (0)