perf(callgrind): memoize object/file name lookups - #39
Conversation
CLG_(get_obj_node)() and CLG_(get_file_node)() are called for every newly translated basic block and for every address resolved while dumping. Both are dominated by string work rather than by the lookup itself: get_file_node rebuilds the full path on each call and hashes it with str_hash(), which does a modulo per character, before walking a hash chain with VG_(strcmp); get_obj_node hashes the object's full path every time. Consecutive basic blocks -- and consecutive cost lines of a dump -- almost always belong to the same source file of the same object, so this work recomputes the same answer over and over. Add a one-entry memo cache to each lookup, keyed on pointer identity of the arguments: (DiEpoch, DebugInfo*) for objects and (DiEpoch, obj_node*, dir, file) for files. The names are owned by the debuginfo reader and are stable for a given DebugInfo, so identical pointers imply identical strings. On a hit, the path construction, the hashing and the chain walk are skipped entirely. The debuginfo epoch is part of the key: it changes whenever debuginfo is discarded, which is the only way the memoized pointers could later be reused for different strings, so the cache stays sound across dlclose/unload.
Merging this PR will not alter performance
Comparing Footnotes
|
Greptile SummaryThe PR adds one-entry pointer-identity memo caches for Callgrind object and source-file lookups, avoiding repeated path construction, hashing, and string comparison on consecutive lookups.
Confidence Score: 5/5The PR appears safe to merge, with cache keys and node lifetimes aligned with the debuginfo lifecycle. Debuginfo names remain immutable while their pointers are valid, epoch changes invalidate entries across debuginfo lifecycle transitions, and memoized Callgrind nodes are not freed or reset.
|
| Filename | Overview |
|---|---|
| callgrind/fn.c | Adds epoch-scoped one-entry caches to object and file node lookups; inspected lifetimes, reset behavior, pointer stability, and epoch semantics without finding an actionable defect. |
Flowchart
%%{init: {'theme': 'neutral'}}%%
flowchart TD
A[Object or file lookup] --> B[Read current debuginfo epoch]
B --> C{Epoch and pointer keys match?}
C -->|Yes| D[Return memoized node]
C -->|No| E[Build or read name]
E --> F[Hash and search node table]
F --> G[Create node if absent]
G --> H[Update one-entry memo]
H --> I[Return node]
Reviews (1): Last reviewed commit: "perf(callgrind): memoize object/file nam..." | Re-trigger Greptile
Problem
CLG_(get_obj_node)()andCLG_(get_file_node)()sit on two hot paths:CLG_(get_fn_node)→get_fn_node_inseg), andget_debug_posindump.c, on each debug-cache miss).Both are dominated by string work rather than by the lookup itself:
CLG_(get_file_node)rebuilds the full path on every call (strlen×2,strcpy,strcat×2 into a VLA), hashes that whole path withstr_hash()— which does a modulo per character — and then walks a hash chain comparing strings withVG_(strcmp).CLG_(get_obj_node)likewise hashes the object's full path on every call.All of that work recomputes the same answer: consecutive basic blocks, and consecutive cost lines of a dump, almost always belong to the same source file of the same object.
Solution
Add a one-entry memo cache to each lookup, keyed on pointer identity of the arguments:
CLG_(get_obj_node):(DiEpoch, DebugInfo*)→obj_node*CLG_(get_file_node):(DiEpoch, obj_node*, dir, file)→file_node*The
dir/file/object-name strings are owned by the debuginfo reader and are stable for a givenDebugInfo, so identical pointers imply identical strings. On a hit, the path construction, the hashing and the chain walk are skipped entirely.The debuginfo epoch is part of the key: it changes whenever debuginfo is discarded, which is the only situation in which the memoized string pointers could later be reused for different contents. This keeps the cache sound across
dlclose/unload.Only
callgrind/fn.cis touched (+56/−1); there is no change to the output format or to any tool option.Validation
Correctness
perl tests/vg_regtest callgrindon the patched build).--compress-strings=no --read-inline-info=yes, ~2.6 MB dump): the full set ofob=/fl=/fi=/fe=/fn=/c*=name records is byte-identical between the baseline and patched builds, apart from the two lines naming the build directory itself (/tmp/vg-base/...vs/tmp/vg-head/...).Performance
Measured through the CodSpeed walltime harness. The sandbox available for this work shows run-to-run drift of ~1% between whole runs, so the baseline and patched builds were interleaved inside a single CodSpeed run (each config run back-to-back for both builds) to cancel that drift.
stress-ng,take_stringsandllsc_tzconvert_benchcould not be used locally (Git LFS payloads / package unavailable), so apython3 <script>startup workload andechowere used across all six Callgrind configs.12 of 12 benchmarks improved, with the largest gains on the translation- and dump-heavy Python startup workload:
python3 small.pyinlinepython3 small.pyno-inlinepython3 small.pyfull-with-inline-with-cycle-estimationpython3 small.pycycle-estimationpython3 small.pyfull-with-inlinepython3 small.pyfull-no-inlineecho Hello, World!no-inlineecho Hello, World!cycle-estimationecho Hello, World!inlineecho Hello, World!full-no-inlineecho Hello, World!full-with-inline-with-cycle-estimationecho Hello, World!full-with-inlineA separate interleaved A/B timing of a heavier Python startup (
python3 -c "import json,re,os,collections,argparse",--compress-strings=no --read-inline-info=yes) showed −2.1% (min) / −2.5% (median) over 12 alternating pairs.The gain is largest where translation and dumping dominate the run, which is exactly where these two lookups are called most often; long-running workloads that amortise translation see proportionally less. The CodSpeed job on this PR runs the full benchmark matrix on the macro runners and is the authoritative measurement.