From 3dd8a9d6cfe932e1a4141c9abfb3968255efd797 Mon Sep 17 00:00:00 2001 From: Guillaume Lagrange Date: Thu, 10 Sep 2026 11:43:18 -0400 Subject: [PATCH 1/3] fix(memtrack): attach lifecycle and syscall hooks without perf_event_open Classic `tracepoint/*` programs attach through tracefs and perf_event_open, which a delegated BPF token cannot authorize: the token only covers bpf(). Inside a token-delegated user namespace every tracepoint attach failed on the first one, sched_process_exec, so memtrack could not run at all there. Move the seven classic tracepoints to BTF-typed programs that attach via bpf() alone: - sched_process_exec/exit -> tp_btf (same tracepoint, raw attach) - sys_enter/exit_mmap, sys_enter_munmap, sys_enter/exit_brk -> fentry/fexit on the arch syscall wrapper, `__x64_sys_*` or `__arm64_sys_*` selected from `__TARGET_ARCH_*` Hook points and observed values are unchanged: fexit receives the same return value as sys_exit's `ret`, and the register-based args match sys_enter's `args[]`. As a side effect the run no longer flips every task onto the syscall tracepoint slow path, which classic `syscalls/*` tracepoints do system-wide. fentry is now required even with allocator tracking disabled; the x86_64 floor stays at 5.5 while aarch64 needs 6.4 for BPF trampolines. Known limitation: `tracepoint/kmem/rss_stat` (physical mode) is still a classic tracepoint. Its TP_fast_assign-derived fields (mm_id, curr, size) have no tp_btf equivalent, so where perf_event_open is denied it fails to attach and only logs a warning. Physical RSS then comes from the folio rmap hooks alone: complete on kernels >= 6.15, missing PUD-mapped THP on 6.8-6.14, and absent below 6.8. Refs COD-3492 Co-Authored-By: Claude --- crates/memtrack/src/ebpf/c/allocator.h | 42 ++++++++++++------- .../src/ebpf/c/process_tracking.bpf.h | 8 ++-- 2 files changed, 30 insertions(+), 20 deletions(-) diff --git a/crates/memtrack/src/ebpf/c/allocator.h b/crates/memtrack/src/ebpf/c/allocator.h index 9a4cc2387..c4c78c598 100644 --- a/crates/memtrack/src/ebpf/c/allocator.h +++ b/crates/memtrack/src/ebpf/c/allocator.h @@ -5,6 +5,16 @@ #include "utils/map_helpers.h" #include "utils/process_tracking.h" +/* Syscall entry points are arch-prefixed wrappers around the SYSCALL_DEFINE + * body (`__x64_sys_mmap`, `__arm64_sys_mmap`), and fentry targets a symbol. */ +#if defined(__TARGET_ARCH_x86) +#define SYSCALL_SYM(name) "__x64_sys_" #name +#elif defined(__TARGET_ARCH_arm64) +#define SYSCALL_SYM(name) "__arm64_sys_" #name +#else +#error "unsupported target arch for syscall fentry hooks" +#endif + #define UPROBE_ARG_RET(name, arg_expr, submit_block) \ BPF_HASH_MAP(name##_arg, __u64, __u64, 10000); \ SEC(UPROBE_SEC) \ @@ -159,20 +169,20 @@ static __always_inline void store_mmap_args(__u64 addr, __u64 len) { } } -SEC("tracepoint/syscalls/sys_enter_mmap") -int tracepoint_sys_enter_mmap(struct trace_event_raw_sys_enter* ctx) { - store_mmap_args(ctx->args[0], ctx->args[1]); +SEC("fentry/" SYSCALL_SYM(mmap)) +int BPF_PROG(tracepoint_sys_enter_mmap, struct pt_regs* regs) { + store_mmap_args(PT_REGS_PARM1_CORE_SYSCALL(regs), PT_REGS_PARM2_CORE_SYSCALL(regs)); return 0; } -SEC("tracepoint/syscalls/sys_exit_mmap") -int tracepoint_sys_exit_mmap(struct trace_event_raw_sys_exit* ctx) { +SEC("fexit/" SYSCALL_SYM(mmap)) +int BPF_PROG(tracepoint_sys_exit_mmap, struct pt_regs* regs, long retval) { struct mmap_args* args = (struct mmap_args*)take_param(&mmap_temp); if (!args) { return 0; } - __s64 ret = ctx->ret; + __s64 ret = retval; if (ret <= 0) { return 0; } @@ -180,10 +190,10 @@ int tracepoint_sys_exit_mmap(struct trace_event_raw_sys_exit* ctx) { return submit_mmap_event((__u64)ret, args->len, EVENT_TYPE_MMAP); } -SEC("tracepoint/syscalls/sys_enter_munmap") -int tracepoint_sys_enter_munmap(struct trace_event_raw_sys_enter* ctx) { - __u64 addr = ctx->args[0]; - __u64 len = ctx->args[1]; +SEC("fentry/" SYSCALL_SYM(munmap)) +int BPF_PROG(tracepoint_sys_enter_munmap, struct pt_regs* regs) { + __u64 addr = PT_REGS_PARM1_CORE_SYSCALL(regs); + __u64 len = PT_REGS_PARM2_CORE_SYSCALL(regs); if (addr == 0 || len == 0) { return 0; @@ -194,20 +204,20 @@ int tracepoint_sys_enter_munmap(struct trace_event_raw_sys_enter* ctx) { BPF_HASH_MAP(brk_temp, __u64, __u64, 10000); -SEC("tracepoint/syscalls/sys_enter_brk") -int tracepoint_sys_enter_brk(struct trace_event_raw_sys_enter* ctx) { - store_param(&brk_temp, ctx->args[0]); +SEC("fentry/" SYSCALL_SYM(brk)) +int BPF_PROG(tracepoint_sys_enter_brk, struct pt_regs* regs) { + store_param(&brk_temp, PT_REGS_PARM1_CORE_SYSCALL(regs)); return 0; } -SEC("tracepoint/syscalls/sys_exit_brk") -int tracepoint_sys_exit_brk(struct trace_event_raw_sys_exit* ctx) { +SEC("fexit/" SYSCALL_SYM(brk)) +int BPF_PROG(tracepoint_sys_exit_brk, struct pt_regs* regs, long retval) { __u64* requested_brk = take_param(&brk_temp); if (!requested_brk) { return 0; } - __u64 new_brk = ctx->ret; + __u64 new_brk = retval; __u64 req_brk = *requested_brk; if (req_brk == 0 || new_brk <= 0) { diff --git a/crates/memtrack/src/ebpf/c/process_tracking.bpf.h b/crates/memtrack/src/ebpf/c/process_tracking.bpf.h index 7b339b3b6..b82b53d6a 100644 --- a/crates/memtrack/src/ebpf/c/process_tracking.bpf.h +++ b/crates/memtrack/src/ebpf/c/process_tracking.bpf.h @@ -40,8 +40,8 @@ int BPF_PROG(tracepoint_sched_process_fork, struct task_struct* parent, struct t SUBMIT_EVENT_AS(child_pid, EVENT_TYPE_FORK, { e->data.fork.parent_pid = parent_pid; }); } -SEC("tracepoint/sched/sched_process_exec") -int tracepoint_sched_process_exec(void* ctx) { +SEC("tp_btf/sched_process_exec") +int BPF_PROG(tracepoint_sched_process_exec) { __u32 pid = current_tgid(); if (!is_tracked(pid)) { return 0; @@ -58,8 +58,8 @@ int tracepoint_sched_process_exec(void* ctx) { SUBMIT_EVENT_AS(pid, EVENT_TYPE_EXEC, {}); } -SEC("tracepoint/sched/sched_process_exit") -int tracepoint_sched_process_exit(void* ctx) { +SEC("tp_btf/sched_process_exit") +int BPF_PROG(tracepoint_sched_process_exit) { __u32 pid = current_tgid(); if (!is_tracked(pid)) { return 0; From aa056be3c3fd874b79e91730c7bfdb91ddafe217 Mon Sep 17 00:00:00 2001 From: Guillaume Lagrange Date: Wed, 16 Sep 2026 12:00:42 +0200 Subject: [PATCH 2/3] refactor(memtrack): drop the unused mmap/munmap/brk events Nothing reads them: they were emitted into the artifact and no consumer ever modelled them, so the syscall hooks only cost overhead. Refs COD-3492 --- crates/memtrack/AGENTS.md | 2 +- crates/memtrack/src/ebpf/c/allocator.h | 84 ------------------- crates/memtrack/src/ebpf/c/event.h | 19 ++--- .../memtrack/src/ebpf/c/utils/event_helpers.h | 7 -- crates/memtrack/src/ebpf/events.rs | 18 ---- crates/memtrack/src/ebpf/memtrack/tracking.rs | 10 --- crates/memtrack/tests/c_tests.rs | 8 +- crates/memtrack/tests/shared.rs | 4 +- .../runner-shared/benches/memtrack_writer.rs | 31 ++----- .../src/artifacts/memtrack/mod.rs | 12 --- 10 files changed, 19 insertions(+), 176 deletions(-) diff --git a/crates/memtrack/AGENTS.md b/crates/memtrack/AGENTS.md index 0c8d86d41..a4c1a1d16 100644 --- a/crates/memtrack/AGENTS.md +++ b/crates/memtrack/AGENTS.md @@ -4,7 +4,7 @@ ## Project Overview -Attaches uprobes/uretprobes to allocator functions (`malloc`/`free`/`calloc`/`realloc`/`aligned_alloc`/`memalign`) and tracepoints to `mmap`/`munmap`/`brk` + `sched_process_fork` in a target process tree, streams allocation events through a BPF ring buffer to userspace, and writes them to a `MemtrackArtifact` file. Ships a CLI binary `codspeed-memtrack track`. +Attaches uprobes/uretprobes to allocator functions (`malloc`/`free`/`calloc`/`realloc`/`aligned_alloc`/`memalign`) and BTF tracepoints to `sched_process_fork`/`exec`/`exit` in a target process tree, streams allocation events through a BPF ring buffer to userspace, and writes them to a `MemtrackArtifact` file. Ships a CLI binary `codspeed-memtrack track`. ## Architecture & Data Flow diff --git a/crates/memtrack/src/ebpf/c/allocator.h b/crates/memtrack/src/ebpf/c/allocator.h index c4c78c598..a7eaeae18 100644 --- a/crates/memtrack/src/ebpf/c/allocator.h +++ b/crates/memtrack/src/ebpf/c/allocator.h @@ -5,16 +5,6 @@ #include "utils/map_helpers.h" #include "utils/process_tracking.h" -/* Syscall entry points are arch-prefixed wrappers around the SYSCALL_DEFINE - * body (`__x64_sys_mmap`, `__arm64_sys_mmap`), and fentry targets a symbol. */ -#if defined(__TARGET_ARCH_x86) -#define SYSCALL_SYM(name) "__x64_sys_" #name -#elif defined(__TARGET_ARCH_arm64) -#define SYSCALL_SYM(name) "__arm64_sys_" #name -#else -#error "unsupported target arch for syscall fentry hooks" -#endif - #define UPROBE_ARG_RET(name, arg_expr, submit_block) \ BPF_HASH_MAP(name##_arg, __u64, __u64, 10000); \ SEC(UPROBE_SEC) \ @@ -153,78 +143,4 @@ int uretprobe_posix_memalign(struct pt_regs* ctx) { return submit_aligned_alloc_event(a.size, addr); } -struct mmap_args { - __u64 addr; - __u64 len; -}; - -BPF_HASH_MAP(mmap_temp, __u64, struct mmap_args, 10000); - -static __always_inline void store_mmap_args(__u64 addr, __u64 len) { - struct task_ids ids = current_task_ids(); - __u64 tid = ids.tid; - if (is_tracked(ids.tgid)) { - struct mmap_args args = {.addr = addr, .len = len}; - bpf_map_update_elem(&mmap_temp, &tid, &args, BPF_ANY); - } -} - -SEC("fentry/" SYSCALL_SYM(mmap)) -int BPF_PROG(tracepoint_sys_enter_mmap, struct pt_regs* regs) { - store_mmap_args(PT_REGS_PARM1_CORE_SYSCALL(regs), PT_REGS_PARM2_CORE_SYSCALL(regs)); - return 0; -} - -SEC("fexit/" SYSCALL_SYM(mmap)) -int BPF_PROG(tracepoint_sys_exit_mmap, struct pt_regs* regs, long retval) { - struct mmap_args* args = (struct mmap_args*)take_param(&mmap_temp); - if (!args) { - return 0; - } - - __s64 ret = retval; - if (ret <= 0) { - return 0; - } - - return submit_mmap_event((__u64)ret, args->len, EVENT_TYPE_MMAP); -} - -SEC("fentry/" SYSCALL_SYM(munmap)) -int BPF_PROG(tracepoint_sys_enter_munmap, struct pt_regs* regs) { - __u64 addr = PT_REGS_PARM1_CORE_SYSCALL(regs); - __u64 len = PT_REGS_PARM2_CORE_SYSCALL(regs); - - if (addr == 0 || len == 0) { - return 0; - } - - return submit_mmap_event(addr, len, EVENT_TYPE_MUNMAP); -} - -BPF_HASH_MAP(brk_temp, __u64, __u64, 10000); - -SEC("fentry/" SYSCALL_SYM(brk)) -int BPF_PROG(tracepoint_sys_enter_brk, struct pt_regs* regs) { - store_param(&brk_temp, PT_REGS_PARM1_CORE_SYSCALL(regs)); - return 0; -} - -SEC("fexit/" SYSCALL_SYM(brk)) -int BPF_PROG(tracepoint_sys_exit_brk, struct pt_regs* regs, long retval) { - __u64* requested_brk = take_param(&brk_temp); - if (!requested_brk) { - return 0; - } - - __u64 new_brk = retval; - __u64 req_brk = *requested_brk; - - if (req_brk == 0 || new_brk <= 0) { - return 0; - } - - return submit_mmap_event(new_brk, 0, EVENT_TYPE_BRK); -} - #endif /* __ALLOCATOR_H__ */ diff --git a/crates/memtrack/src/ebpf/c/event.h b/crates/memtrack/src/ebpf/c/event.h index bf0677c93..c6a91165a 100644 --- a/crates/memtrack/src/ebpf/c/event.h +++ b/crates/memtrack/src/ebpf/c/event.h @@ -6,14 +6,11 @@ #define EVENT_TYPE_CALLOC 3 #define EVENT_TYPE_REALLOC 4 #define EVENT_TYPE_ALIGNED_ALLOC 5 -#define EVENT_TYPE_MMAP 6 -#define EVENT_TYPE_MUNMAP 7 -#define EVENT_TYPE_BRK 8 -#define EVENT_TYPE_FORK 9 -#define EVENT_TYPE_EXEC 10 -#define EVENT_TYPE_EXIT 11 -#define EVENT_TYPE_RSS 12 -#define EVENT_TYPE_RMAP 13 +#define EVENT_TYPE_FORK 6 +#define EVENT_TYPE_EXEC 7 +#define EVENT_TYPE_EXIT 8 +#define EVENT_TYPE_RSS 9 +#define EVENT_TYPE_RMAP 10 /* Common header shared by all event types */ struct event_header { @@ -45,12 +42,6 @@ struct event { uint64_t size; /* new size requested */ } realloc; - /* Memory mapping events (mmap, munmap, brk) */ - struct { - uint64_t addr; /* address of mapping */ - uint64_t size; /* size of mapping */ - } mmap; - /* Process lifecycle events (fork carries the parent; exec/exit have no payload) */ struct { uint32_t parent_pid; diff --git a/crates/memtrack/src/ebpf/c/utils/event_helpers.h b/crates/memtrack/src/ebpf/c/utils/event_helpers.h index ca5969a9a..0fc06833b 100644 --- a/crates/memtrack/src/ebpf/c/utils/event_helpers.h +++ b/crates/memtrack/src/ebpf/c/utils/event_helpers.h @@ -121,11 +121,4 @@ static __always_inline int submit_realloc_event(__u64 old_addr, __u64 new_addr, }); } -static __always_inline int submit_mmap_event(__u64 addr, __u64 size, __u8 event_type) { - SUBMIT_GATED_EVENT(event_type, { - e->data.mmap.addr = addr; - e->data.mmap.size = size; - }); -} - #endif /* __EVENT_HELPERS_H__ */ diff --git a/crates/memtrack/src/ebpf/events.rs b/crates/memtrack/src/ebpf/events.rs index 4ed422a58..16c1a87a8 100644 --- a/crates/memtrack/src/ebpf/events.rs +++ b/crates/memtrack/src/ebpf/events.rs @@ -56,24 +56,6 @@ pub fn parse_event(data: &[u8]) -> Option { size: event.data.alloc.size, }, ), - EVENT_TYPE_MMAP => ( - event.data.mmap.addr, - MemtrackEventKind::Mmap { - size: event.data.mmap.size, - }, - ), - EVENT_TYPE_MUNMAP => ( - event.data.mmap.addr, - MemtrackEventKind::Munmap { - size: event.data.mmap.size, - }, - ), - EVENT_TYPE_BRK => ( - event.data.mmap.addr, - MemtrackEventKind::Brk { - size: event.data.mmap.size, - }, - ), EVENT_TYPE_FORK => ( 0, MemtrackEventKind::Fork { diff --git a/crates/memtrack/src/ebpf/memtrack/tracking.rs b/crates/memtrack/src/ebpf/memtrack/tracking.rs index 99a4fc3a0..0054d9903 100644 --- a/crates/memtrack/src/ebpf/memtrack/tracking.rs +++ b/crates/memtrack/src/ebpf/memtrack/tracking.rs @@ -7,21 +7,11 @@ impl MemtrackBpf { attach_tracepoint!(sched_process_fork); attach_tracepoint!(sched_process_exec); attach_tracepoint!(sched_process_exit); - attach_tracepoint!(sys_enter_mmap); - attach_tracepoint!(sys_exit_mmap); - attach_tracepoint!(sys_enter_munmap); - attach_tracepoint!(sys_enter_brk); - attach_tracepoint!(sys_exit_brk); pub fn attach_tracepoints(&mut self) -> Result<()> { self.attach_sched_process_fork()?; self.attach_sched_process_exec()?; self.attach_sched_process_exit()?; - self.attach_sys_enter_mmap()?; - self.attach_sys_exit_mmap()?; - self.attach_sys_enter_munmap()?; - self.attach_sys_enter_brk()?; - self.attach_sys_exit_brk()?; if self.physical { if let Err(e) = self.attach_rss_stat() { warn!("Failed to attach rss_stat tracepoint, RSS collection disabled: {e:#}"); diff --git a/crates/memtrack/tests/c_tests.rs b/crates/memtrack/tests/c_tests.rs index db5fe6439..3ff45cbe4 100644 --- a/crates/memtrack/tests/c_tests.rs +++ b/crates/memtrack/tests/c_tests.rs @@ -96,12 +96,12 @@ fn test_track_allocators_disabled_skips_allocations() -> Result<(), Box = events diff --git a/crates/memtrack/tests/shared.rs b/crates/memtrack/tests/shared.rs index 0d27b6a46..c3bb37bc2 100644 --- a/crates/memtrack/tests/shared.rs +++ b/crates/memtrack/tests/shared.rs @@ -22,9 +22,7 @@ macro_rules! assert_events_snapshot { use runner_shared::artifacts::MemtrackEventKind; use std::mem::discriminant; - // Keep only allocator events. mmap/munmap/brk sizes reflect allocator - // arena reservations that vary per run, so including them here would - // make these snapshots nondeterministic. + // Keep only allocator events. let formatted_events: Vec = $events .iter() .filter(|e| { diff --git a/crates/runner-shared/benches/memtrack_writer.rs b/crates/runner-shared/benches/memtrack_writer.rs index a6c610e8e..700f4db8c 100644 --- a/crates/runner-shared/benches/memtrack_writer.rs +++ b/crates/runner-shared/benches/memtrack_writer.rs @@ -13,7 +13,7 @@ fn generate_events(n: usize) -> Vec { let mut events = Vec::with_capacity(n); for _ in 0..n { let size = rng.gen_range(8..8192); - let kind = match rng.gen_range(0..10) { + let kind = match rng.gen_range(0..7) { 0 => MemtrackEventKind::Malloc { size }, 1 => MemtrackEventKind::Free, 2 => MemtrackEventKind::Realloc { @@ -22,14 +22,11 @@ fn generate_events(n: usize) -> Vec { }, 3 => MemtrackEventKind::Calloc { size }, 4 => MemtrackEventKind::AlignedAlloc { size }, - 5 => MemtrackEventKind::Mmap { size }, - 6 => MemtrackEventKind::Munmap { size }, - 7 => MemtrackEventKind::Brk { size }, - 8 => MemtrackEventKind::Rss { + 5 => MemtrackEventKind::Rss { member: rng.gen_range(0..4), size, }, - 9 => MemtrackEventKind::Rmap { + 6 => MemtrackEventKind::Rmap { member: rng.gen_range(0..4), delta: rng.gen_range(-1024..1024), }, @@ -67,7 +64,6 @@ fn generate_realistic_events(n: usize) -> Vec { let mut rng = StdRng::seed_from_u64(42); let mut events = Vec::with_capacity(n); let mut live_heap: Vec = Vec::new(); - let mut live_mmap: Vec<(u64, u64)> = Vec::new(); let mut free_list: Vec = Vec::new(); let mut next_addr: u64 = 0x5555_5555_0000; let mut ts: u64 = 1_700_000_000_000_000_000; @@ -91,26 +87,15 @@ fn generate_realistic_events(n: usize) -> Vec { }); let kind = match rng.gen_range(0..20) { 0 => MemtrackEventKind::Calloc { size }, - 1 => MemtrackEventKind::Mmap { size }, _ => MemtrackEventKind::Malloc { size }, }; - if let MemtrackEventKind::Mmap { size } = kind { - live_mmap.push((addr, size)); - } else { - live_heap.push(addr); - } + live_heap.push(addr); (addr, kind) } else if roll < 90 { - let idx = rng.gen_range(0..live_heap.len() + live_mmap.len()); - if idx < live_heap.len() { - let addr = live_heap.swap_remove(idx); - free_list.push(addr); - (addr, MemtrackEventKind::Free) - } else { - let (addr, size) = live_mmap.swap_remove(idx - live_heap.len()); - free_list.push(addr); - (addr, MemtrackEventKind::Munmap { size }) - } + let idx = rng.gen_range(0..live_heap.len()); + let addr = live_heap.swap_remove(idx); + free_list.push(addr); + (addr, MemtrackEventKind::Free) } else { let idx = rng.gen_range(0..live_heap.len()); let old_addr = live_heap[idx]; diff --git a/crates/runner-shared/src/artifacts/memtrack/mod.rs b/crates/runner-shared/src/artifacts/memtrack/mod.rs index b082a7c67..131eb6b2d 100644 --- a/crates/runner-shared/src/artifacts/memtrack/mod.rs +++ b/crates/runner-shared/src/artifacts/memtrack/mod.rs @@ -69,15 +69,6 @@ pub enum MemtrackEventKind { AlignedAlloc { size: u64, }, - Mmap { - size: u64, - }, - Munmap { - size: u64, - }, - Brk { - size: u64, - }, Fork { parent_pid: pid_t, }, @@ -179,9 +170,6 @@ mod tests { }, MemtrackEventKind::Calloc { size: 9 }, MemtrackEventKind::AlignedAlloc { size: 9 }, - MemtrackEventKind::Mmap { size: 9 }, - MemtrackEventKind::Munmap { size: 9 }, - MemtrackEventKind::Brk { size: 9 }, ]; for kind in kinds { From af3867e523bf69895630aacd46731ba9fa84301d Mon Sep 17 00:00:00 2001 From: Guillaume Lagrange Date: Wed, 16 Sep 2026 12:26:56 +0200 Subject: [PATCH 3/3] test(exec-harness): allow a 1ms shortfall on the sleep round timing The round clock starts after the spawn, so a preempted parent can miss the start of the child's sleep. --- crates/exec-harness/src/walltime/tests.rs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/crates/exec-harness/src/walltime/tests.rs b/crates/exec-harness/src/walltime/tests.rs index c85feba87..605b8cd30 100644 --- a/crates/exec-harness/src/walltime/tests.rs +++ b/crates/exec-harness/src/walltime/tests.rs @@ -180,11 +180,13 @@ fn test_with_sleep_command() -> Result<()> { // Should run exactly 3 times assert_eq!(times.len(), 3, "Expected exactly 3 iterations"); - // Each iteration should take at least 10ms (10_000_000 ns) + // The round clock starts after the child is spawned, so a preempted parent + // can miss the beginning of the sleep. Allow a small shortfall. + const MIN_ROUND_NS: u128 = 9_800_000; for (i, &time_ns) in times.iter().enumerate() { assert!( - time_ns >= 10_000_000, - "Iteration {i} took only {time_ns}ns, expected at least 10ms" + time_ns >= MIN_ROUND_NS, + "Iteration {i} took only {time_ns}ns, expected at least {MIN_ROUND_NS}ns" ); }