From d4adac6f7d11c03917eb00d081ec392c258c0237 Mon Sep 17 00:00:00 2001 From: Guillaume Lagrange Date: Wed, 22 Jul 2026 16:47:38 +0200 Subject: [PATCH 1/3] feat(callgrind): record spawned subprocesses per dump part When a benchmark spawns a subprocess, the resulting profile had no way to attribute that subprocess back to the exact benchmark (dump part) that spawned it, so the spawn tree could not be rebuilt. Track fork edges and emit them in the dump: on fork the parent records the new child pid against the part currently being measured, and each part's header lists the children spawned during it as "desc: Spawned pid:" lines (a desc field so kcachegrind and callgrind_annotate ignore it). A fork child restarts its part counter at 1 and drops the inherited edges so it only reports children it spawns itself, and zeroes cost so work before the fork stays attributed to the parent. The new child's pid is not passed to the atfork hooks, so it is read from the fork syscall's result: the core announces a fork through the atfork pre hook, and callgrind's syscall wrapper picks the pid up when that syscall returns. This keeps the fork sites (Linux, FreeBSD, Solaris, generic) untouched and reuses the core's own fork detection. The wrapper is therefore always registered, not only under --collect-systime. The child hook also re-stamps the in-flight fork syscall's start time from the child's clocks, since the thread CPU clock restarts in the child and the exit delta would otherwise underflow. Closes COD-2349 --- callgrind/Makefile.am | 1 + callgrind/dump.c | 18 ++++++++ callgrind/global.h | 9 ++++ callgrind/main.c | 27 ++++++++++-- callgrind/subprocess.c | 99 ++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 150 insertions(+), 4 deletions(-) create mode 100644 callgrind/subprocess.c diff --git a/callgrind/Makefile.am b/callgrind/Makefile.am index 4aa85f927..1e5bcb97d 100644 --- a/callgrind/Makefile.am +++ b/callgrind/Makefile.am @@ -53,6 +53,7 @@ CALLGRIND_SOURCES_COMMON = \ jumps.c \ main.c \ sim.c \ + subprocess.c \ threads.c # We sneakily include "cg_branchpred.c" and "cg_arch.c" from cachegrind diff --git a/callgrind/dump.c b/callgrind/dump.c index d077bcd3b..b1e7eab53 100644 --- a/callgrind/dump.c +++ b/callgrind/dump.c @@ -59,6 +59,15 @@ Int CLG_(get_dump_counter)(void) return out_counter; } +/* Restart part numbering in a fork child: its dumps go to a fresh per-pid file + * whose parts start at 1, like an exec'd child. init_dumps() would reset the + * counter anyway on the pid change, but only at the first dump - too late for + * the part number this process forwards to its own children in the meantime. */ +void CLG_(reset_dump_counter)(void) +{ + out_counter = 0; +} + /*------------------------------------------------------------*/ /*--- Output file related stuff ---*/ /*------------------------------------------------------------*/ @@ -1369,6 +1378,11 @@ static VgFile *new_dumpfile(thread_info* ti, const HChar* trigger) } VG_(fprintf)(fp, "\npart: %d\n", out_counter); + + /* Per-part, not in the once-per-file header, so a child spawned during a + * later part is still recorded. */ + CLG_(print_spawned_children)(fp); + if (CLG_(clo).separate_threads) { const HChar* tname = CLG_(thread_name)(ti); @@ -1684,6 +1698,10 @@ static void print_bbccs(const HChar* trigger, Bool only_current_thread) CLG_(forall_threads_incl_exited)(print_one_empty_section); } + /* All of these have just been emitted under the part being dumped; a later + * part cannot reference them. */ + CLG_(forget_spawned_children)(); + free_dump_array(); } diff --git a/callgrind/global.h b/callgrind/global.h index 6ec236352..8939287ea 100644 --- a/callgrind/global.h +++ b/callgrind/global.h @@ -730,6 +730,8 @@ void CLG_(zero_all_cost)(Bool only_current_thread); * an already-unwound or empty state. */ void CLG_(unwind_thread)(thread_info* t); Int CLG_(get_dump_counter)(void); +void CLG_(reset_dump_counter)(void); +void CLG_(restamp_syscall_time)(ThreadId tid); void CLG_(fini)(Int exitcode); /* from bb.c */ @@ -825,6 +827,13 @@ void CLG_(run_post_signal_on_call_stack_bottom)(void); /* from dump.c */ void CLG_(init_dumps)(void); +/* from subprocess.c */ +void CLG_(init_subprocess)(void); +/* Fed the result of every syscall, to spot the ones spawning a process. */ +void CLG_(syscall_return)(SysRes res); +void CLG_(print_spawned_children)(VgFile* fp); +void CLG_(forget_spawned_children)(void); + /*------------------------------------------------------------*/ /*--- Exported global variables ---*/ /*------------------------------------------------------------*/ diff --git a/callgrind/main.c b/callgrind/main.c index b777f55c0..1aa10bde6 100644 --- a/callgrind/main.c +++ b/callgrind/main.c @@ -1779,12 +1779,22 @@ void collect_time (struct vki_timespec *systime, struct vki_timespec *syscputime } } +/* Stamp the start time of the syscall a thread is entering. Also used to + re-stamp it, when what was stamped no longer applies to the clocks the delta + will be computed against. */ +void CLG_(restamp_syscall_time)(ThreadId tid) +{ + if (CLG_(clo).collect_systime == systime_no) return; + + collect_time(&syscalltime[tid], + CLG_(clo).collect_systime == systime_nsec ? &syscallcputime[tid] : NULL); +} + static void CLG_(pre_syscalltime)(ThreadId tid, UInt syscallno, UWord* args, UInt nArgs) { - collect_time(&syscalltime[tid], - CLG_(clo).collect_systime == systime_nsec ? &syscallcputime[tid] : NULL); + CLG_(restamp_syscall_time)(tid); } /* Returns "after - before" in the unit as specified by --collect-systime. @@ -1814,6 +1824,11 @@ static void CLG_(post_syscalltime)(ThreadId tid, UInt syscallno, UWord* args, UInt nArgs, SysRes res) { + /* The result of a fork is the only place its child's pid appears. */ + CLG_(syscall_return)(res); + + if (CLG_(clo).collect_systime == systime_no) return; + if (CLG_(current_state).bbcc) { Int o; struct vki_timespec ts_now; @@ -2088,9 +2103,11 @@ void CLG_(post_clo_init)(void) "sp-at-mem-access\n"); } + /* Always needed: the wrapper is also how a fork's child pid is picked up. */ + VG_(needs_syscall_wrapper)(CLG_(pre_syscalltime), + CLG_(post_syscalltime)); + if (CLG_(clo).collect_systime != systime_no) { - VG_(needs_syscall_wrapper)(CLG_(pre_syscalltime), - CLG_(post_syscalltime)); syscalltime = CLG_MALLOC("cl.main.pci.1", VG_N_THREADS * sizeof syscalltime[0]); for (UInt i = 0; i < VG_N_THREADS; ++i) { @@ -2212,6 +2229,8 @@ void CLG_(pre_clo_init)(void) VG_(track_post_deliver_signal)( & CLG_(post_signal) ); VG_(track_pre_thread_ll_exit) ( & CLG_(pre_thread_ll_exit) ); + CLG_(init_subprocess)(); + CLG_(set_clo_defaults)(); } diff --git a/callgrind/subprocess.c b/callgrind/subprocess.c new file mode 100644 index 000000000..c43baac45 --- /dev/null +++ b/callgrind/subprocess.c @@ -0,0 +1,99 @@ +/* Everything about processes other than this one: recording the children it + * spawns, and what a spawned process inherits from its spawner. + */ + +#include "global.h" + +/*------------------------------------------------------------*/ +/*--- Children spawned by this process ---*/ +/*------------------------------------------------------------*/ + +/* Children spawned since the last dump, listed in the header of the part they + * were spawned during, which is what attributes a child to the exact part of + * its parent that spawned it. */ +static Int* spawned = 0; +static Int n_spawned = 0; +static Int spawned_capacity = 0; + +static void record_spawned_child(Int child_pid) +{ + if (n_spawned == spawned_capacity) { + spawned_capacity = spawned_capacity ? spawned_capacity * 2 : 8; + spawned = VG_(realloc)("cl.subprocess.spawn.1", spawned, + spawned_capacity * sizeof(Int)); + } + spawned[n_spawned++] = child_pid; +} + +/* "desc:" lines so other consumers of the format (kcachegrind, + * callgrind_annotate) ignore them. */ +void CLG_(print_spawned_children)(VgFile* fp) +{ + Int i; + + for (i = 0; i < n_spawned; i++) + VG_(fprintf)(fp, "desc: Spawned pid: %d\n", spawned[i]); +} + +void CLG_(forget_spawned_children)(void) +{ + n_spawned = 0; +} + +/*------------------------------------------------------------*/ +/*--- Fork ---*/ +/*------------------------------------------------------------*/ + +/* Whether the syscall currently in flight is a fork, as announced by the core + * through the atfork pre hook. The hooks do not carry the new child's pid, but + * the syscall it is announcing returns it in the parent, so the pid is picked + * up when that syscall returns. */ +static Bool forking = False; + +static void clg_atfork_pre(ThreadId tid) +{ + forking = True; +} + +/* Costs accumulated before the fork belong to - and are dumped by - the + * parent; zero everything so this process only reports its own work. Threads + * other than the forking one do not exist in the child; zeroing their copied + * state means they are skipped at dump time (zero delta). The part counter + * restarts at 1, matching an exec'd child. + */ +static void clg_atfork_child(ThreadId tid) +{ + forking = False; + CLG_(reset_dump_counter)(); + /* the inherited edges are the parent's; this process only reports the + * children it spawns itself */ + CLG_(forget_spawned_children)(); + + CLG_(zero_all_cost)(False); + + /* The fork itself is a syscall in flight: its start time was stamped in the + * parent, but the thread CPU clock restarts in the child, so the delta + * computed at the syscall exit would underflow. */ + CLG_(restamp_syscall_time)(tid); +} + +void CLG_(syscall_return)(SysRes res) +{ + if (!forking) return; + forking = False; + + /* A failed fork spawned nothing. A zero result is the child returning from + * the fork, which cleared the flag in its atfork hook already. */ + if (sr_isError(res) || (Int)sr_Res(res) <= 0) return; + + record_spawned_child((Int)sr_Res(res)); +} + +void CLG_(init_subprocess)(void) +{ + VG_(atfork)(clg_atfork_pre, NULL, clg_atfork_child); +} + +/*--------------------------------------------------------------------*/ +/*--- end ---*/ +/*--------------------------------------------------------------------*/ From 20acc3d755caeeebbe8186953a1de793f67527f8 Mon Sep 17 00:00:00 2001 From: Guillaume Lagrange Date: Mon, 27 Jul 2026 16:17:05 +0200 Subject: [PATCH 2/3] feat(callgrind): inherit instrumentation state across traced exec A process spawned via exec under --trace-children=yes gets a fresh valgrind whose instrumentation state resets to --instr-atstart, so a benchmark reached through an exec chain (e.g. `cargo run`) was measured from the wrong point. A plain fork inherits the state with the address space, but an exec does not. Add --instr-atstart=inherit: like "no", except each process advertises its current instrumentation state by keeping /callgrind-instr- in existence while enabled, and a fresh valgrind adopts the state advertised for its own PID. An exec keeps the PID, so the file maintained by the pre-exec image hands the state over; a fork child inherits the state with the address space and republishes it under its new PID. Closes COD-2349 --- callgrind/clo.c | 13 +++++++--- callgrind/global.h | 17 ++++++++++++- callgrind/main.c | 16 +++++++++++- callgrind/subprocess.c | 57 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 98 insertions(+), 5 deletions(-) diff --git a/callgrind/clo.c b/callgrind/clo.c index 90bd6c4ff..911051a27 100644 --- a/callgrind/clo.c +++ b/callgrind/clo.c @@ -429,7 +429,12 @@ Bool CLG_(process_cmd_line_option)(const HChar* arg) else if VG_BOOL_CLO(arg, "--collect-atstart", CLG_(clo).collect_atstart) {} - else if VG_BOOL_CLO(arg, "--instr-atstart", CLG_(clo).instrument_atstart) {} + else if VG_XACT_CLO(arg, "--instr-atstart=no", + CLG_(clo).instrument_atstart, instr_atstart_no) {} + else if VG_XACT_CLO(arg, "--instr-atstart=yes", + CLG_(clo).instrument_atstart, instr_atstart_yes) {} + else if VG_XACT_CLO(arg, "--instr-atstart=inherit", + CLG_(clo).instrument_atstart, instr_atstart_inherit) {} else if VG_BOOL_CLO(arg, "--separate-threads", CLG_(clo).separate_threads) {} @@ -604,7 +609,9 @@ void CLG_(print_usage)(void) #endif "\n data collection options:\n" -" --instr-atstart=no|yes Do instrumentation at callgrind start [yes]\n" +" --instr-atstart=no|yes|inherit Do instrumentation at callgrind start [yes]\n" +" inherit Like 'no', but inherit the instrumentation state across\n" +" a traced exec.\n" " --collect-atstart=no|yes Collect at process/thread start [yes]\n" " --toggle-collect= Toggle collection on enter/leave function\n" " --collect-jumps=no|yes Collect jumps? [no]\n" @@ -693,7 +700,7 @@ void CLG_(set_clo_defaults)(void) CLG_(clo).skip_direct_recursion = False; /* Instrumentation */ - CLG_(clo).instrument_atstart = True; + CLG_(clo).instrument_atstart = instr_atstart_yes; CLG_(clo).simulate_cache = False; CLG_(clo).simulate_branch = False; CLG_(clo).cycle_estimation = False; diff --git a/callgrind/global.h b/callgrind/global.h index 8939287ea..373b33305 100644 --- a/callgrind/global.h +++ b/callgrind/global.h @@ -78,6 +78,17 @@ typedef enum { systime_nsec } Collect_Systime; +/* Whether to instrument from the start. + instr_atstart_no : start with instrumentation off + instr_atstart_yes : start with instrumentation on + instr_atstart_inherit : like "no", but adopt the state advertised by this + PID's pre-exec image, and advertise ours likewise */ +typedef enum { + instr_atstart_no, + instr_atstart_yes, + instr_atstart_inherit +} Instr_Atstart; + typedef struct _CommandLineOptions CommandLineOptions; struct _CommandLineOptions { @@ -113,7 +124,7 @@ struct _CommandLineOptions { Bool collect_bus; /* Collect global bus events */ /* Instrument options */ - Bool instrument_atstart; /* Instrument at start? */ + Instr_Atstart instrument_atstart; /* Instrument at start? */ Bool simulate_cache; /* Call into cache simulator ? */ Bool simulate_branch; /* Call into branch prediction simulator ? */ Bool cycle_estimation; /* Estimate per-instruction cycles (Ct/Cl) ? */ @@ -833,6 +844,10 @@ void CLG_(init_subprocess)(void); void CLG_(syscall_return)(SysRes res); void CLG_(print_spawned_children)(VgFile* fp); void CLG_(forget_spawned_children)(void); +/* Advertise the instrumentation state this process is in to a process + * inheriting from it, and adopt what was advertised to this one. */ +void CLG_(publish_instr_state)(Bool on); +Bool CLG_(inherited_instr_state)(void); /*------------------------------------------------------------*/ /*--- Exported global variables ---*/ diff --git a/callgrind/main.c b/callgrind/main.c index 1aa10bde6..3d7d28891 100644 --- a/callgrind/main.c +++ b/callgrind/main.c @@ -1481,6 +1481,9 @@ void CLG_(set_instrument_state)(const HChar* reason, Bool state) reason, state ? "ON" : "OFF"); return; } + /* advertise before switching: the advertised state must never lag behind + * the state this process is actually in */ + CLG_(publish_instr_state)(state); CLG_(instrument_state) = state; CLG_DEBUG(2, "%s: Switching instrumentation %s ...\n", reason, state ? "ON" : "OFF"); @@ -2069,6 +2072,9 @@ void finish(void) void CLG_(fini)(Int exitcode) { finish(); + /* only runs at process exit, not on exec: the advertised state must + * survive a traced exec for the next image to adopt it */ + CLG_(publish_instr_state)(False); } @@ -2182,7 +2188,15 @@ void CLG_(post_clo_init)(void) CLG_(init_threads)(); CLG_(run_thread)(1); - CLG_(instrument_state) = CLG_(clo).instrument_atstart; + switch (CLG_(clo).instrument_atstart) { + case instr_atstart_yes: CLG_(instrument_state) = True; break; + case instr_atstart_no: CLG_(instrument_state) = False; break; + case instr_atstart_inherit: + /* the adopted state is already the advertised one, so nothing to + * publish here: the file this image found is the file it keeps */ + CLG_(instrument_state) = CLG_(inherited_instr_state)(); + break; + } if (VG_(clo_verbosity) > 0) { VG_(message)(Vg_UserMsg, diff --git a/callgrind/subprocess.c b/callgrind/subprocess.c index c43baac45..b4974629d 100644 --- a/callgrind/subprocess.c +++ b/callgrind/subprocess.c @@ -4,6 +4,59 @@ #include "global.h" +/*------------------------------------------------------------*/ +/*--- Instrumentation state handover (--instr-atstart=inherit)---*/ +/*------------------------------------------------------------*/ + +/* A traced exec replaces the image (and resets valgrind) but keeps the PID, so + * runtime state can be handed over through a file whose name is derived from + * the PID alone: each process advertises "instrumentation is on" by keeping + * /callgrind-instr- in existence, and a fresh valgrind started + * with --instr-atstart=inherit adopts the state advertised for its own PID. A + * fork child inherits the in-memory state with the address space and just + * republishes it under its new PID. + * + * The file is empty: its existence is the whole message. + */ + +static void instr_state_path(HChar* buf, Int size) +{ + VG_(snprintf)(buf, size, "%s/callgrind-instr-%d", + VG_(tmpdir)(), VG_(getpid)()); +} + +void CLG_(publish_instr_state)(Bool on) +{ + HChar path[256]; + SysRes res; + + if (CLG_(clo).instrument_atstart != instr_atstart_inherit) return; + + instr_state_path(path, sizeof path); + if (!on) { + VG_(unlink)(path); + return; + } + res = VG_(open)(path, VKI_O_CREAT|VKI_O_WRONLY, + VKI_S_IRUSR|VKI_S_IWUSR); + if (sr_isError(res)) { + VG_(message)(Vg_UserMsg, + "warning: cannot create instrumentation state file %s\n", + path); + return; + } + VG_(close)(sr_Res(res)); +} + +Bool CLG_(inherited_instr_state)(void) +{ + HChar path[256]; + struct vg_stat st; + + instr_state_path(path, sizeof path); + return !sr_isError(VG_(stat)(path, &st)); +} + /*------------------------------------------------------------*/ /*--- Children spawned by this process ---*/ /*------------------------------------------------------------*/ @@ -69,6 +122,10 @@ static void clg_atfork_child(ThreadId tid) * children it spawns itself */ CLG_(forget_spawned_children)(); + /* the state file of the parent's PID belongs to the parent; advertise the + * inherited in-memory state under our own PID */ + CLG_(publish_instr_state)(CLG_(instrument_state)); + CLG_(zero_all_cost)(False); /* The fork itself is a syscall in flight: its start time was stamped in the From ae6bf150fe853f374244311fa92ad88fa5855528 Mon Sep 17 00:00:00 2001 From: Guillaume Lagrange Date: Thu, 30 Jul 2026 11:39:18 +0200 Subject: [PATCH 3/3] feat: inerhit dynamically ignored objects from parent valgrind We forward the objects through argv as if they've been provided by argv in the first place. Multiple --obj-skip are cumulative. --- callgrind/global.h | 3 +++ callgrind/main.c | 1 + callgrind/subprocess.c | 18 ++++++++++++++++++ 3 files changed, 22 insertions(+) diff --git a/callgrind/global.h b/callgrind/global.h index 373b33305..1b41a4711 100644 --- a/callgrind/global.h +++ b/callgrind/global.h @@ -848,6 +848,9 @@ void CLG_(forget_spawned_children)(void); * inheriting from it, and adopt what was advertised to this one. */ void CLG_(publish_instr_state)(Bool on); Bool CLG_(inherited_instr_state)(void); +/* Forward an object added via CALLGRIND_ADD_OBJ_SKIP to child valgrind + * processes, as if it had been passed on the command line. */ +void CLG_(publish_obj_skip)(const HChar* obj_name); /*------------------------------------------------------------*/ /*--- Exported global variables ---*/ diff --git a/callgrind/main.c b/callgrind/main.c index 3d7d28891..44b9e6d3c 100644 --- a/callgrind/main.c +++ b/callgrind/main.c @@ -1717,6 +1717,7 @@ Bool CLG_(handle_client_request)(ThreadId tid, UWord *args, UWord *ret) case VG_USERREQ__ADD_OBJ_SKIP: { const HChar* path = (const HChar*)args[1]; CLG_(add_obj_to_skip)(path); + CLG_(publish_obj_skip)(path); *ret = 0; break; } diff --git a/callgrind/subprocess.c b/callgrind/subprocess.c index b4974629d..b88b0c819 100644 --- a/callgrind/subprocess.c +++ b/callgrind/subprocess.c @@ -57,6 +57,24 @@ Bool CLG_(inherited_instr_state)(void) return !sr_isError(VG_(stat)(path, &st)); } +/*------------------------------------------------------------*/ +/*--- --obj-skip handover for objects added at runtime ---*/ +/*------------------------------------------------------------*/ + +/* CALLGRIND_ADD_OBJ_SKIP() adds an object to skip after startup, so unlike an + * --obj-skip on the original command line it is not something a traced exec + * would replay to the child. Appending it to VG_(args_for_valgrind) folds it + * into the same argv the core already reconstructs for exec'd children, so + * the child picks it up as an ordinary --obj-skip option. + */ +void CLG_(publish_obj_skip)(const HChar* obj_name) +{ + HChar* arg = VG_(malloc)("cl.subprocess.objskip.1", + VG_(strlen)(obj_name) + sizeof("--obj-skip=")); + VG_(sprintf)(arg, "--obj-skip=%s", obj_name); + VG_(addToXA)(VG_(args_for_valgrind), &arg); +} + /*------------------------------------------------------------*/ /*--- Children spawned by this process ---*/ /*------------------------------------------------------------*/