Callgrinder profiles a command with Valgrind's callgrind tool and reports where the
time goes: a per-category table (with both inclusive and self cost) and a table of the hottest individual
routines, as CEst (cycle estimation). It runs three ways — a GitHub Action, a reusable workflow for
distributed fan-out, and a local ./callgrinder command — and, like ThreadScale, it is a
zero-dependency Node.js 24 project.
It is application-independent: the categories you care about are supplied as a small JSON config, so the same Action profiles any C/C++ program compiled with debug symbols.
- A Linux runner (or container) with
valgrindinstalled (it providescallgrind_annotate). - Node.js 24+.
- Your application built with debug symbols — otherwise hot routines appear as unresolved
0x…addresses.
Callgrinder does not build or prepare your app; the caller does that (build, generate inputs, set the working directory) and passes a ready-to-run command, exactly like ThreadScale.
- uses: gemc/Callgrinder@v1
with:
command: 'build/bin/myapp input.dat -n {events}'
name: my-workload
events: '100'
config: ci/callgrinder.json
working-directory: .The step profiles the command, writes callgrind.out.my-workload, and appends the category and top-routines
tables to the Job Summary.
./callgrinder 'build/bin/myapp input.dat -n {events}' --name my-workload --events 100 --config ci/callgrinder.jsonThe command may contain {events}, {name}, and {run} placeholders. Quote the whole command as one
argument, and quote inner arguments that contain spaces:
./callgrinder 'gemc card.yaml -n {events} -gsystem="[{name: det, factory: ascii}]"' --name det --events 100Callgrinder runs the command as valgrind --tool=callgrind … bash -c "exec <command>", so exec makes the
shell hand its process to your binary and callgrind profiles the binary directly rather than the shell.
If you would rather run valgrind yourself — for example to pass complex, space-containing arguments as a
shell array with no re-quoting — hand Callgrinder the resulting file and it only summarizes:
callgrinder --from-callgrind callgrind.out.my-workload --name my-workload --config ci/callgrinder.jsonCategories are JSON (native and zero-dependency, and it quotes regex backslashes cleanly). Each category is
either fixed (match names one entry symbol) or discovered (discover captures a class in group 1 and
reports one row per class found — e.g. every plugin of a kind):
{
"cost": "CEst",
"top_routines": 10,
"categories": [
{ "label": "Track swimming", "match": "G4PropagatorInField::ComputeStep" },
{ "family": "Digitization", "discover": "([A-Za-z_]\\w*)::digitizeHit" },
{ "family": "Field", "discover": "(GField_[A-Za-z0-9_]*)::GetFieldValue" }
]
}match— a regex naming one entry function; its inclusive and self cost become one row.discover— a regex whose group 1 captures a class; every matching class becomes its own row, labelled"<family>: <class>". Add"method": "…"to control the displayed symbol whendiscoveruses alternation.
config accepts a file path or inline JSON. See examples/gemc3.json and examples/gemc2.json.
- Category table — for each category: inclusive
CEst (Mcycles),% of run(inclusive: entry + callees, overlaps and does not sum to 100%), andSelf %(cycles executed directly in the entry function(s), non-overlapping). - Top routines — the hottest individual routines by self cost, with callgrind call counts stripped and unresolved addresses labelled with their object.
- Cost is CEst (
Ir + 10·L1_misses + 100·LL_misses), matching qcachegrind's cycle estimation. The report ends with a short qcachegrind reading guide.
profile(default) — run one command under callgrind; writecallgrind.out.<name>, a partial JSON, and a Job-Summary section.report— merge partial JSON files frominput-dirintosummary.md,categories.csv, and an aggregated JSON.discover— turn a JSONbenchmarksarray into a job matrix (one job per profile) for fan-out.
The reusable workflow .github/workflows/callgrinder.yml wires discover → profile (matrix) → report.
npm run check # node --check on the entry points
npm test # node --testNo build step and no npm dependencies — keep it that way. See releases/ for per-version notes.