Skip to content

Valgrind (memcheck / helgrind / DRD)

The survey's no-recompile tool family: memcheck, helgrind, and DRD find memory and threading defects in an unmodified LDC, GDC, or DMD binary, with D names demangled and no sanitizer flag anywhere in the build — which makes Valgrind the only dynamic verification path DMD has at all.

FieldValue
Toolsmemcheck (memory errors + definedness), helgrind / DRD (data races), plus Nulgrind/cachegrind/… out of scope
Instrumentation locusDynamic binary translation (DBI) — the VEX JIT re-translates every instruction; nothing is recompiled
Version3.26.0 (runtime-tested, nixpkgs) / source read at valgrind@218cee2f (tag VALGRIND_3_26_0)
License / RepositoryGPL-2.0 · sourceware valgrind.git
DocumentationValgrind manual (memcheck / helgrind / DRD chapters)
D toolchainsLDC = GDC = DMD (compiler-independent by construction — it instruments the binary, not the IR); DMD's only path — see d-toolchain.md
druntime readdmd@e6baf474 (upstream druntime: GC, etc.valgrind, fibers); LDC fork ldc@f4d2f831 (same etc/valgrind/ shape, no fiber hooks)
Verification[hw-verified: x86_64-linux] — Experiments E1–E9, both LDC 1.41.0 and DMD 2.112.1

NOTE

Everything on this page was recorded on Linux 6.18.26, an AMD Ryzen 9 7940HX (Zen 4, 16c/32t), valgrind 3.26.0 (nixpkgs, via nix shell nixpkgs#valgrind), LDC 1.41.0 and DMD 2.112.1. Every experiment was run on both compilers' binaries and produced identical verdicts unless noted. The three runnable probes (valgrind-memcheck-catch.d, valgrind-client-requests.d, valgrind-attribution.d) lock the load-bearing behaviours into CI.


Overview

What it detects

memcheck shadows every byte of the address space with two kinds of metadata and checks them on every access: addressability (may the program touch this byte) and definedness (is each value bit initialized). From that it reports InvalidRead/InvalidWrite (unaddressable access — heap use-after-free, out-of-bounds past a malloc block, a freed stack), UninitValue and UninitCondition (a computation or branch on an undefined value), InvalidFree, MismatchedFree, Overlap (overlapping memcpy), SyscallParam (a syscall handed unaddressable/undefined memory), ClientCheck, and the Leak_DefinitelyLost/Leak_PossiblyLost/Leak_IndirectlyLost/Leak_StillReachable taxonomy from its exit-time leak scan. helgrind and DRD detect data races, and additionally lock-ordering (helgrind) or lock contention (DRD) and pthread-API misuse.

The definedness axis is the capability ASan structurally lacks: ASan's shadow encodes addressability only, so it can never report an uninitialized read, while memcheck's per-bit V-bits can — this is the definedness-vs-addressability split the survey draws between the two. The mirror-image weakness: memcheck has no redzones, so a small overrun that stays inside a malloc block (or steps into a valid neighbour) is invisible where ASan would catch it.

Design philosophy: shadow every byte, recompile nothing

memcheck's model is stated plainly at the top of its shadow engine (memcheck/mc_main.c:100):

Conceptually, every byte value has 8 V bits, which track whether Memcheck thinks the corresponding value bit is defined. And every memory byte has an A bit, which tracks whether Memcheck thinks the program can access it safely (ie. it's mapped, and has at least one of the RWX permission bits set). So every N-bit register is shadowed with N V bits, and every memory byte is shadowed with 8 V bits and one A bit.

[source-verified] This is the whole model: 8 V-bits + 1 A-bit per byte, carried through registers as well as memory. It is richer than ASan on the definedness axis and — because it is applied by re-translating machine code rather than by an instrumentation pass — it needs no recompilation and no special flags, which is exactly what puts it within DMD's reach where every LLVM-IR-pass tool is not. The cost side of the same coin is the headline slowdown: memcheck "adds code to check every memory access and every value computed, making it run 10-50 times slower than natively" (docs/xml/manual-core.xml:59). [source-verified]


How it works

The V-bit / A-bit shadow

The 9-bits-per-byte model is compressed two ways (memcheck/mc_main.c:100, encodings :238). Each byte's addressability + definedness collapses to 2 bitsVA_BITS2_NOACCESS, VA_BITS2_UNDEFINED, VA_BITS2_DEFINED, VA_BITS2_PARTDEFINED — and only a partially defined byte spends its exact 8 V-bits in a secondary table. On 64-bit the shadow is a 2²⁰-entry primary map over the bottom 64 GB (address bits 16..35) with a sparse auxiliary table above, and three distinguished secondary maps (noaccess/undefined/defined) that every uniform 64 KB chunk shares by pointer instead of materializing (mc_main.c:120). [source-verified]

One deliberate imprecision is worth stating because it bounds what memcheck can model (mc_main.c:157):

Aside: the V+A bits are less precise than they could be -- we have no way of marking memory as read-only.

[source-verified] There is no read-only state (a fifth VA_BITSn_READONLY would need 2.3 bits, which does not pack), so makeMemNoAccess is all-or-nothing: memory is addressable or it is not.

Dynamic binary translation and the client-request trapdoor

Valgrind is a DBI framework: the VEX JIT disassembles each basic block to an IR, the active tool instruments that IR, and the block is re-emitted and run — so all code is covered, including libc and every dynamically-linked library the program pulls in. The one channel a program has to talk back to the tool without being recompiled is the client request: a magic no-op instruction sequence the JIT recognizes byte-for-byte. On amd64 it is a 16-byte preamble of four rolq rotations on %rdi (3, 13, 61, 51 — a net 128-bit no-op) followed by xchgq %rbx,%rbx (include/valgrind.h.in:422); VEX matches the exact bytes at translation time and emits an Ijk_ClientReq exit (VEX/priv/guest_amd64_toIR.c:32270), and the scheduler dispatches on the request code with the argument block addressed by %rax and the result in %rdx (coregrind/m_scheduler/scheduler.c:2040). Outside Valgrind the same bytes execute as harmless arithmetic and the default result flows through, so the annotation is safe to leave compiled into production code. [source-verified] This trapdoor is what makes VALGRIND_PRINTF markers, MAKE_MEM_*, STACK_REGISTER, and RUNNING_ON_VALGRIND reachable from an unmodified binary — the mechanism the D probes drive.


The seven concerns

The concern order is fixed across the survey; where one does not apply, the absence is the finding.

Defect classes: memcheck kinds, races, and the blind spots

memcheck's error taxonomy is enumerated above; the survey's structural point is the definedness capability (def-vs-addr) — UninitValue / UninitCondition come from the V-bits and have no ASan analog — set against three blind spots that recur on every memory tool here:

  • GC use-after-free is invisible. D's GC allocates pools with mmap, not malloc, so a GC.free'd block never passes through memcheck's malloc replacement and its A-bits are never cleared. A GC.malloc → GC.free → read produced zero Invalid* reports under stock memcheck — only conservative-scan noise. This is the GC memory blind spot, and it is the one half of it that is closable without a druntime rebuild (see the GC interaction). [hw-verified: x86_64-linux]
  • No redzones, no small-overflow catch. With no redzone between allocations, an overrun that stays inside a malloc block is not caught the way ASan catches it. memcheck catches the overrun only once it crosses into unaddressable memory (past the block, into a freed region). [source-verified]
  • Serialization orders races away. helgrind/DRD share the false-negative class of every happens-before detector: a serialized scheduler can impose an ordering a real race doesn't have. Verified below and contrasted with TSan, which catches the same program.

helgrind detects three classes — pthread-API misuse, lock-ordering (potential deadlock), and data races (helgrind/docs/hg-manual.xml:28); DRD detects races, lock contention (--exclusive-threshold), and API misuse, but has no lock-order detector. Both are vector-clock happens-before engines: helgrind's libhb ("a library for implementing and checking the happens-before relationship", helgrind/libhb_core.c:4) with per-thread VTS clocks; DRD's per-thread segment lists with vector clocks (drd/drd_thread.h:67). [source-verified]

Instrumentation model: dynamic binary translation, no recompile

The instrumentation locus is DBI, and the practical consequences are the spine of Valgrind's fit for a D runner:

  • Nothing is recompiled; -g buys only file:line. The same source built by LDC and DMD with no special flags produced byte-equivalent verdicts (InvalidRead at uaf.d:10, exit 99). Without -g, frames still carry demangled D function names from the ELF symbol table; -g adds file and line. This is DMD's only sanitizer path, at full fidelity — DMD 2.112.1 has zero -fsanitize flags, and every experiment here ran identically on its binaries. [hw-verified: x86_64-linux]
  • --track-origins is the definedness upgrade, at a measured cost. An origin tag is 32 bits — a 30-bit execontext id plus a 2-bit kind (HEAP/STACK/USER/UNKNOWN, memcheck/mc_include.h:179) — so a UninitValue report can name where the undefined bytes were born. The manual is blunt about the price: "It halves Memcheck's speed and increases memory use by a minimum of 100MB" (memcheck/docs/mc-manual.xml:1100); measured here it was 1.126 s → 1.602 s on a CPU-bound fixture (a 1.4× marginal add). [source-verified] [hw-verified: x86_64-linux]
  • The client-request channel is the recompile-free annotation seam, and a targeted -debug=VALGRIND recompile — of the GC into the app, not the whole world — is the one fidelity upgrade that closes the GC blind spot. Both are covered under the D interaction.

The D and druntime interaction

This is where Valgrind stops being generic and starts being D-specific. Three sub-stories: the etc.valgrind client-request wrappers, the GC's shadow interaction, and fibers.

Client requests: driving the A/V bits from D

druntime ships a D wrapper for the client API, etc/valgrind/valgrind.d, gated debug(VALGRIND):, whose module doc is the recipe (:1):

D wrapper for the Valgrind client API. Note that you must include this file into your program's compilation and compile with -debug=VALGRIND to access the declarations below.

[source-verified] It wraps exactly seven memcheck requests — makeMemNoAccess/makeMemUndefined/makeMemDefined, get/setVBits, disable/enableAddrReportingInRange — and nothing else: no RUNNING_ON_VALGRIND, no STACK_REGISTER, no VALGRIND_PRINTF, no DO_LEAK_CHECK. The mechanism is subtle enough that the research brief's hypothesis was wrong on it:

NOTE

Discrepancy resolved: the D module does not carry the request macros. The naive assumption was that importing etc.valgrind compiles the client-request assembly into the caller. It does not — the D wrappers only declare extern(C) _d_valgrind_*; the real VALGRIND_* macro expansions live in etc/valgrind/valgrind.c, which is compiled unconditionally into the shipped runtime (druntime/Makefile:391, OBJS+=…valgrind$(DOTOBJ)). Verified by nm: libdruntime-ldc.a's valgrind.c.o defines all seven _d_valgrind_* T symbols, while the D module's object (built without -debug=VALGRIND) holds only __ModuleInfo; DMD's libphobos2.a likewise. So a consumer must compile the D wrapper bodies itself (-i=etc.valgrind) while the extern(C) implementations resolve from the shipped runtime. [hw-verified: x86_64-linux]

The working user-code recipe, no druntime rebuild required, is therefore ldc2 --d-debug=VALGRIND -i=etc.valgrind app.d (dub: debugVersions "VALGRIND"

  • dflags "-i=etc.valgrind") — verified to compile, link, run, and drive memcheck's A/V bits from both compilers, whose import trees both ship etc/valgrind/valgrind.d. valgrind-client-requests.d is that recipe in CI: it marks a live block NOACCESS and reads it (InvalidRead), marks initialized memory UNDEFINED and branches on it (UninitCondition), and uses getVBits's return value as a RUNNING_ON_VALGRIND substitute (druntime wraps no such request). [hw-verified: x86_64-linux]

WARNING

etc.valgrind breaks under DMD's shared Phobos — the configuration sparkles' linux-dmd unittests use. dmd -debug=VALGRIND -i=etc.valgrind -defaultlib=libphobos2.so fails to link (undefined reference to _d_valgrind_make_mem_noaccess): DMD's shared libphobos2.so exports zero dynamic _d_valgrind_* symbols (they are T in the static libphobos2.a only). sparkles' unittest configs pass exactly -defaultlib=libphobos2.so on linux-dmd, so etc.valgrind is unusable there without a workaround: hand-rolled requests (below), compiling valgrind.c's ~38 lines into the build, or static Phobos. LDC's --link-defaultlib-shared is unaffected — its libdruntime-ldc-shared.so does export the symbols. [hw-verified: x86_64-linux]

The escape hatch is a hand-rolled client request in ~20 lines of D inline asm — the amd64 preamble plus xchg, args through %rax, result through %rdx — which works under DMD and LDC alike on x86_64. valgrind-attribution.d uses it to implement RUNNING_ON_VALGRIND (0x1001) and VALGRIND_PRINTF (0x1403). Two traps a hand-roller must know:

  • The deprecated VG_USERREQ__PRINTF (0x1401) hard-aborts on amd64: the scheduler runs if (sizeof(va_list) != sizeof(UWord)) goto va_list_casting_error (scheduler.c:2049). Use PRINTF_VALIST_BY_REF (0x1403) instead.
  • On linux-x86_64 D's va_list is already the pointer to the __va_list_tag record, so 0x1403 wants ap, not &ap — passing &ap truncated the XML mid-record and aborted the run with exit 1. [hw-verified: x86_64-linux]

The GC interaction: noise, the blind-spot fix, and a dead-code bug

druntime carries debug(VALGRIND)-gated memcheck hooks in the conservative GC (makeMemUndefined/makeMemDefined on alloc paths, sentinel NOACCESS, and disable/enableAddrReportingInRange around scans — gc.d:69,616,2511,5165,5482), added by 7cdae6e3bb ("Add Valgrind GC integration", 2023-06-15). They are not in any shipped runtime (same gate as the D wrappers). Three findings:

  • GC noise on a real suite is tiny. memcheck over the whole :base unittest binary (278 tests, -t 1) produced exactly 6 errors / 6 contexts: one UninitCondition in Gcx.mark (the conservative scanner reading uninitialized stack words via thread_scanAllfullcollectgc_term), four Leak_PossiblyLost + one Leak_DefinitelyLost (three 1056-byte defaultTraceHandler allocations from _d_throw_exception in tests that deliberately throw, plus a 32-byte GC-init malloc). The "GC noise" story is really a "parallel-mode noise" story — see the runner concern. [hw-verified: x86_64-linux]

  • A 3-entry suppression file cleans the whole suite. --gen-suppressions=all emits ready-to-paste blocks (in mangled fun: frames); hand-minimized with wildcards to three entries:

    {
       druntime-gc-conservative-mark-uninit
       Memcheck:Cond
       fun:_D4core8internal2gc4impl12conservative*3Gcx*4mark*
    }
    {
       druntime-throwable-traceinfo-leak
       Memcheck:Leak
       fun:malloc
       fun:_D4core7runtime19defaultTraceHandlerFPvZC6object9Throwable9TraceInfo
    }
    {
       druntime-gc-initialize-leak
       Memcheck:Leak
       fun:malloc
       fun:_D4core8internal2gc4impl12conservative*initializeFZ*
    }

    This yields ERROR SUMMARY: 0 errors from 0 contexts (suppressed: 29 from 6) and exit 0 under --error-exitcode=99 --leak-check=full. (Fiber-heavy programs add Memcheck:Value8 in the same Gcx.mark frames plus GCBits.set/test; a 5-entry file covers those.) --undef-value-errors=no also gives a clean run but disables all V-bit checking — the whole point of memcheck — so suppressions are the right tool. [hw-verified: x86_64-linux]

  • The GC use-after-free blind spot is closable with no druntime rebuild. Compiling the shipped gc.d + valgrind.d sources into the app (ldc2 --d-debug=VALGRIND uafgc.d $INC/core/internal/gc/impl/conservative/gc.d $INC/etc/valgrind/valgrind.d) lets the app's objects win over the archive members at link; the identical GC.malloc → GC.free → read program then reports exactly one error — Invalid read of size 4 … uafgc.d:12, the real bug — and the conservative-scan noise disappears. This is the same trick druntime's own test/valgrind harness uses. Caveat: LDC's include tree ships gc.d but not rt/lifetime.d, so the array-stomping hooks can't be added this way from the shipped tree alone. [hw-verified: x86_64-linux]

NOTE

Upstream bug candidate: gc.d:3907 is dead code. The pool-baseAddr poisoning version (VALGRIND) makeMemNoAccess(baseAddr[0..poolsize]); gates on version while everything else in the file — including the import that makes makeMemNoAccess visible — uses debug (VALGRIND). druntime's own test harness sets only -debug=VALGRIND (druntime/test/valgrind/Makefile:26), never -version=VALGRIND, so this line never compiles anywhere; compiling with only -version=VALGRIND would be an error (no import). Pool-baseAddr marking is therefore effectively unimplemented. [source-verified]

Fibers: warnings, the 2 MB heuristic, and optional registration

Upstream druntime never registers fiber stacks with Valgrind — a grep over core/thread/ for VALGRIND/STACK_REGISTER is empty, and the LDC fork's fiber SupportSanitizers machinery is ASan-only. Yet a core.thread.fiber.Fiber program runs correctly under memcheck: no crash, no user-frame false positives. The whole cost is cosmetic — Valgrind prints Warning: client switching stacks? at most three times (a hard-coded rate limit, coregrind/m_stacks.c:368 static Int complaints = 3) plus the same GC-scan noise. [hw-verified: x86_64-linux]

The heuristic behind the warning is why registration is optional here. An SP delta beyond --max-stackframe (default 2,000,000 — m_options.c:189) is read as a stack switch, and Valgrind deliberately leaves permissions alone (m_stacks.c:359):

if a stack switch happens, it seems best not to mess at all with memory permissions … Really the only remaining difficulty is knowing exactly when a stack switch is happening.

[source-verified] Valgrind auto-registers only the main stack (m_stacks.c:84: "No other stacks are automatically registered by Valgrind, however."), and VALGRIND_STACK_REGISTER from user code works — a hand-rolled request returned stack id 1, and the registered fiber's switches stopped warning while an unregistered fiber kept warning (identical under DMD).

WARNING

The <2 MB-adjacency hazard is real but source-derived, not observed. If two fiber stacks are mmap'd within 2 MB of each other, an unregistered switch would look like ordinary stack growth and Valgrind would rewrite the other fiber's A/V shadow (die_mem_stack on the way up, m_stacks.c logic). This is precisely what VALGRIND_STACK_REGISTER exists to prevent; no shipped druntime calls it, and the survey did not reproduce a corruption (all observed switches had >2 MB deltas). Registration is the only robust guard for the sub-2 MB regime. [source-verified]

-betterC is N/A with a reason: a -betterC build links no druntime, so there is no GC, no fiber machinery, and no etc.valgrind module — memcheck just works on the bare binary, and none of this concern applies.

Runtime control and report capture

Valgrind is configured entirely by CLI flags — there is no ASAN_OPTIONS analog steering it from the environment ($VALGRIND_OPTS exists but flags are the story), which is one fewer runner-owned surface than the LLVM tools. The weak-hook control surface the compiler-rt tools expose is replaced here by the CLI plus client requests. The flags a --valgrind runner mode drives:

  • --xml=yes --xml-file=<path> emits the machine-readable protocol-4 stream (below) — a stronger transport than regex-over-text.
  • --error-exitcode=N turns "any error was reported" into exit code N; the default 0 passes the child's own code through, and — the load-bearing detail — suppressed errors do not trigger it (a clean suppressed run exits 0 with --error-exitcode=99 set). --exit-on-first-error=yes is the fail-fast switch. [hw-verified: x86_64-linux]
  • --suppressions=<file> (repeatable) + --gen-suppressions=yes|all (emits ready-to-paste, mangled blocks) is the noise-management loop demonstrated above.
  • --track-origins=yes, --undef-value-errors=no, --leak-check=full, --fair-sched=yes (see the runner concern), --max-stackframe=N tune definedness depth, the leak scan, and the fiber/scheduler behaviour.
  • Client requests add in-band control an unmodified binary can issue: VALGRIND_PRINTF (stream markers), VALGRIND_DO_LEAK_CHECK, VALGRIND_COUNT_ERRORS, and the MAKE_MEM_* family.

Unlike ASan's default halt, memcheck is report-and-continue — the halt-vs-recover policy is always "recover" here: the use-after-free child printed its read-after-free value and ran to a normal exit, with every finding of the run collected in one pass. The end-to-end no-recompile pipeline — XML output plus --error-exitcode, on an uninstrumented binary — is what valgrind-memcheck-catch.d drives and asserts (exit 99, <kind>InvalidRead</kind> at the right <file>/<line>, and the child surviving past the defect). [hw-verified: x86_64-linux]

Symbolization and report quality

Valgrind carries its own DWARF reader and its own D demangler — no llvm-symbolizer, no ddemangle, nothing external in the pipeline. D main and core.internal.gc.impl.conservative.gc.Gcx.mark!(…).mark(…) appear demangled in reports with no tooling, and <frame> records in the XML carry {ip, [obj], [fn], [dir], [file], [line]} (xml-output-protocol4.txt:230). This is a categorical improvement over the LLVM runtimes, whose GCC-libsanitizer fallback self-symbolizes via libbacktrace but never demangles D. The one place mangled names return is suppressions: --gen-suppressions writes fun: frames in mangled form, so a D suppression glob must target mangled text (as the 3-entry file above does).

The protocol-4 XML is the report format a runner parses: one <valgrindoutput> stream per process, <protocolversion>4 + <protocoltool>memcheck|helgrind|drd, preamble, then — inside the RUNNING window — "Zero or more of (either ERRORCOUNTS, TOOLSPECIFIC, or CLIENTMSG)" (docs/internals/xml-output-protocol4.txt:192), then FINISHED, the post-run leak errors, and SUPPCOUNTS. Each <error> is {unique, tid, [threadname], kind, what/xwhat, STACK, auxwhats, [suppression]} (:400); memcheck's <kind> enum includes UninitValue, UninitCondition, InvalidRead, InvalidWrite, SyscallParam, ClientCheck, and the Leak_* set, and helgrind's includes Race. [source-verified][hw-verified: x86_64-linux]

Runner integration semantics

The process is the isolation unit: a --valgrind mode wraps the test binary, it does not link anything into it — the wrapper-and-parse design, over Valgrind's XML rather than regex-over-text. Three semantics follow.

Per-test attribution works via marker windows. VALGRIND_PRINTF / VALGRIND_PRINTF_BACKTRACE become <clientmsg> records that interleave with <error> records in program order (xml-output-protocol4.txt:679), so a marker emitted before each test segments that process's error stream: everything between marker N and marker N+1 belongs to test N. valgrind-attribution.d proves it — clientmsg(test=1) at stream offset m1 < error(InvalidRead) at e1 < clientmsg(test=2) at m2 < error(InvalidWrite) at e2 — and locks it in CI. This is the survey's in-process report-windowing design realized over Valgrind's transport, and it is the same pattern pytest-valgrind uses. Two caveats a runner must own: (i) Valgrind deduplicates by error context, so a repeat of an already-reported error emits no new <error> (only end-of-run <errorcounts> totals) — marker-window attribution sees each context's first occurrence only; (ii) there are no error timestamps (status records carry a human <time>, errors do not), so the markers are the only segmentation available — in a parallel run the windows must be kept per <tid>, which is moot once the mode forces -t 1. [hw-verified: x86_64-linux]

The mode must force -t 1 and pass --fair-sched=yes. sparkles' in-process TaskPool runner is pathological under Valgrind's default scheduler, which serializes all threads on one unfair pipe-based lock that starves the worker actually holding the queue:

Configurationnativememcheck+originshelgrind
:base suite -t 10.004 s1.156 s0.867 s
:base suite -t auto (32 threads)0.007 s156.5 s37.0 s
:base -t auto + --fair-sched=yes1.268 s
cpuwork (0.200 s CPU-bound)0.200 s1.126 s1.602 s0.624 s
startup (trivial void main)0.001 s0.255 s0.332 s0.209 s
marginal ratio (cpuwork)4.4×6.4×2.1×

The -t auto memcheck run was not merely slow but pathologically variable (spread 12.5–180 s on a 4 ms suite); --fair-sched=yes (round-robin ticket lock) collapses it to 1.27 s and drops -t 4 from 12.3 s to 1.17 s. The tiny-suite figures are startup-dominated (~0.25 s fixed); the marginal ratios (hot ALU loop) flatter the manual's 10-50× headline — both should be reported. [hw-verified: x86_64-linux]

Thread tools are clean only at -t 1. helgrind on :base at -t 1 = 0 errors, 0 suppressed (perfectly clean); at -t 4 = 3,249 errors from 142 contexts (+22,045 suppressed occurrences), and DRD at -t 4 = 15,863 errors. The top noise frames are Gcx.smallAlloc, SpinLock.lock/unlock, and Pool.setBits: the GC's core.internal.spinlock-built, atomics-based SpinLock is invisible to helgrind/DRD, which model pthread primitives not raw atomics (vector-clocks), so everything under the GC lock looks racy. Both tools also miss a genuine short race — a two-thread counter++ with no rendezvous reported zero races from both, because Valgrind's serialization plus druntime's global thread-start/exit lock created a real happens-before edge covering all accesses; adding a two-way atomic rendezvous made both report it, and TSan catches the no-rendezvous program because its threads genuinely overlap. This is a structural false-negative class for any serialized-scheduler detector on short tests.

WARNING

nixpkgs' default.supp blankets all of libc for helgrind. The generated entry helgrind-glibc2X-005 is Helgrind:Race with a single frame obj:*/lib*/libc.so.6. The upstream template targeted @GLIBC_LIBPTHREAD_PATH@ (glibc-2.X-helgrind.supp.in:75), but since glibc 2.34 merged libpthread into libc.so.6, the pattern now suppresses any race whose innermost frame is anywhere in libc — real memcpy/memmove races on user buffers included. Upstream carries a 2009 FIXME (glibc-2.X-helgrind.supp.in:4): "helgrind-glibc2X-005 overlaps with a lot of other stuff. They should be removed." Observed eating 65 occurrences / 17 contexts on the race fixture. [hw-verified: x86_64-linux] [source-verified]

Between the two thread tools: DRD is intrinsically quieter on druntime's primitives (1 suppressed context vs helgrind's 17 on a correct-Mutex fixture) and needs no glibc blanket, but floods per-access counts on a real race (200,000 instances from 2 contexts). helgrind dedups tersely (2 contexts with both stacks) and adds a lock-order class. The proposal's recommendation: helgrind as default (terse dedup, lock ordering, same XML), DRD as a second opinion, -t 1 forced for both, and neither replacing TSan for short races.

Platform and toolchain coverage

Valgrind is compiler-independent by construction: it instruments the binary, so LDC, GDC, and DMD are all first-class, and DMD — with no -fsanitize support of any kind — reaches its only dynamic memory/threading verification path here, at full fidelity (see d-toolchain.md). Every claim on this page is [hw-verified: x86_64-linux] on both LDC 1.41 and DMD 2.112; the overhead is the table in the runner concern (fixed ~0.25 s startup, marginal 4.4×/6.4×/2.1× for memcheck/+origins/helgrind on a hot loop).

macOS is the gap. Stock upstream Valgrind's configure.ac hard-errors on any Darwin newer than 17.x — macOS 10.13 High Sierra, 2017 — (configure.ac:476: AC_MSG_ERROR([Valgrind works on Darwin 10.x … 17.x (Mac OS X 10.6/7/8/9/10/11 and macOS 10.12/13)])), so it does not build on any current macOS and has no Apple-Silicon port at all; the community LouisBrunner/valgrind-macos fork carries macOS support forward but is outside nixpkgs and outside the survey's aarch64-darwin bed. [source-verified] Full treatment — and the Windows story, where Dr. Memory is the no-recompile analog — is in macos-windows.md.


Strengths

  • No recompilation, no flags, every compiler. An unmodified LDC/GDC/DMD binary is checked as-is; -g only adds file:line. This is what makes it DMD's only path and a zero-build-change runner mode.
  • Definedness that ASan cannot reach. Per-bit V-bits catch UninitValue/UninitCondition — a whole error class outside ASan's addressability-only shadow.
  • Built-in D demangling and DWARF reading. Reports and stacks are readable with no llvm-symbolizer/ddemangle — a categorical win over the D-blind LLVM runtimes.
  • Machine-readable protocol-4 XML with in-stream <clientmsg> markers gives clean, parseable, per-test attribution — a stronger transport than regex-over-log.
  • Report-and-continue collects every finding of a run in one pass, with an opt-in --error-exitcode for a cheap pass/fail signal.
  • The GC blind spot is closable without a druntime rebuild — compile the shipped gc.d + valgrind.d with -debug=VALGRIND.

Weaknesses

  • 10-50× headline slowdown (though marginal ratios on hot code are far lower), and a pathological in-process-parallel interaction — 156 s vs 1.16 s on a 4 ms suite — that forces -t 1 + --fair-sched=yes.
  • No redzones: a small overrun inside a malloc block is missed where ASan catches it.
  • GC use-after-free is invisible by default (the mmap'd-pool blind spot), and the shipped GC hooks are dead (debug-gated, plus the gc.d:3907version bug).
  • etc.valgrind breaks under DMD's shared Phobos — the sparkles linux-dmd unittest configuration — needing a hand-rolled or static-Phobos workaround.
  • helgrind/DRD are unusable at -t > 1 on druntime (GC SpinLock invisible) and miss short serialized races that TSan catches; nixpkgs' helgrind default suppressions over-blanket libc.
  • No environment-variable control surface and no error timestamps — CLI flags and stream markers are the only levers.
  • Effectively dead on macOS (configure hard-error past 2017; no Apple-Silicon port outside a community fork).

Key design decisions and trade-offs

DecisionRationaleTrade-off
Dynamic binary translation (VEX JIT) over an instrumentation passWorks on any unmodified binary from any compiler; DMD's only path; covers libc and all libraries10-50× headline slowdown; every instruction is re-translated
8 V-bits + 1 A-bit per byte, compressed to 2 bitsTracks definedness per bit, not just addressability — the UninitCondition class ASan can't report9-bits-of-metadata cost; no read-only state; no redzones (small in-block overruns missed)
Client requests via a magic no-op instruction sequenceAn unmodified binary can annotate the tool (markers, MAKE_MEM_*, stack registration)The D etc.valgrind wrappers ship source-only + C-prebuilt, and break under DMD shared Phobos
Report-and-continue, opt-in --error-exitcodeAll findings in one pass; a cheap pass/fail signal on demandA runner must decide the exit-code policy; suppressed errors deliberately don't trigger the code
Serialize all threads on one scheduler lock (default unfair)Highest throughput for sequential-thread workloadsPathological + wildly variable for an in-process parallel runner; forces -t 1 + --fair-sched=yes
Model pthread primitives in helgrind/DRDCatches the common mutex/lock-order/API-misuse bugsBlind to core.atomic/SpinLock — everything under the GC lock is a false race at -t > 1
Built-in DWARF reader + D demanglerReadable reports with no external symbolizerSuppressions still use mangled fun: frames (what --gen-suppressions emits)

Sources