Skip to content

feat(taint): allocator-backed heap taint bits - #52

Draft
RomainMuller wants to merge 23 commits into
mainfrom
romain.marcadier/allocator-taint-bits
Draft

RomainMuller wants to merge 23 commits into
mainfrom
romain.marcadier/allocator-taint-bits

Conversation

@RomainMuller

@RomainMuller RomainMuller commented Sep 29, 2026 •

Copy link
Copy Markdown
Collaborator

Adds allocator-backed heap taint bits: one taint bit for each heap byte, stored next to the allocator metadata. With these bits, the question "does this value have taint?" is a few loads, without a map lookup.

Why

The taint tracking needs a fast check on hot paths (Any is called on many values that never have taint). Maps keyed by address are slow under contention (about 85 ns for each parallel lookup) and must be cleaned when objects die. When the bits are in the runtime, the GC clears them for dead objects in the sweep, and the check stays at 3–5 ns (0.3 ns in parallel).

What

  • internal/taint/heapbits/orchestrion.yml: Orchestrion aspects that add code to the Go runtime package:
    • storage in 16 KiB chunks (one for each 128 KiB of heap), a directory for each heap arena, and 1 MiB slabs mapped directly, in a bounded budget (default 64 MiB, at most 1 GiB, heapbits.SetBudget). When the budget is used, a Set drops the taint. It never waits and never fails the host program;
    • a flag on each mspan: the sweep hook runs only for spans that got taint. It clears the bits of dead objects, recycles the whole chunks of dead large objects, and keeps the work of each span bounded (about 20 µs or less);
    • hooks for freegc (GOEXPERIMENT=runtimefreegc), and correct handling of objects that finalizers revive;
    • nosplit entry points that refuse stack, global, user-arena and non-Go memory, and noinline workers with preemption checkpoints.
  • internal/taint/heapbits/heapbits.go: the Go API (Enabled, Set*, Clear*, Any*, Next, NextClean, Copy, SetBudget). The feature is on for linux and darwin on amd64 and arm64 (on arm64, LSE atomics are necessary), and not with asan/msan. On other targets, the calls do nothing.
  • Tests for the GC and flag protocol (forced interleavings), storage and recycling (child processes), finalizers, freegc, stack moves, Copy/Next, a random model test, negative controls, generated-code checks (nosplit, no panics), a sweep-hook latency gate, and TestRuntimeDrift with one golden inventory for each Go minor version. The inventory detects changes in the runtime internals that the aspects use.
  • Benchmarks (microbench, budgetbench, and worst cases in heapbits), and a third active variant in benchmarks/overhead with HeapBits* workloads and gate.txt. The gates fail the run only with -gate; in CI they are only shown in the summary, because shared runners give too much noise.
  • CI: heapbits (Go oldstable/stable × amd64/arm64 × build modes, and macOS), heapbits-feature-off (cross builds, js/wasm, -asan), and a non-required heapbits-prerelease job (gotip).

Notes for review

  • The design, the critic rounds, and the notes for each step are in _docs/plans/allocator-taint-bits.md; it will be removed before merge. The proof of concept is in the history of the first commit only (it had its own go.mod, which the license check reads).
  • Accepted risk: the aspects use unexported runtime internals. TestRuntimeDrift and the pre-release job are there to find breakage early.
  • Known limits: a value in a 16-byte tiny-allocator slot must be given with its exact range (caller contract); tainted values on the stack are refused (phase 2); origins and marks are not stored yet (phase 3).
  • The pre-release job (gotip, not required) is red today: no released Orchestrion can build the development runtime (v1.13.0 does not know the -exportfd compiler flag; v1.13.1 knows it, but its x/tools cannot read the export data version 5 of gotip). Its drift step passes (golden for go1.28-devel).

Plan and proof of concept for a taint bitmap with one bit for each heap
byte, stored next to the Go allocator metadata. Orchestrion aspects add
the bitmap to `runtime.heapArena`, add a flag to `runtime.mspan`, and
clear the bits of dead objects in `(*sweepLocked).sweep` and `freegc`.
With this, the check "does this value have taint?" needs no lock and
works for sub-strings.

The proof of concept is in `_docs/plans/allocator-taint-bits-poc/`. Its
tests pass on go1.26.6, go1.26.8 and go1.27.1 (default flags, -race,
-N -l, checklinkname, and 3 GOEXPERIMENT variants), and also on
linux/amd64 and linux/arm64. Its benchmarks show no measurable overhead
when no value is tainted.

The phase 1 plan went through 4 critic rounds and the user approved it.
It also records the decisions for the next phases: keep tainted data on
the heap (stack values), runtime specials (origins and marks), and
`main` as the base branch.
Add `internal/taint/heapbits`: one taint bit for each heap byte, stored
next to the Go allocator metadata. Orchestrion aspects on package
`runtime` add a lazily allocated bitmap to `heapArena`, a flag to
`mspan`, and hooks in `(*sweepLocked).sweep` and `freegc` that clear
the bits of dead objects, so that new objects at the same address do
not get old taint. The root `orchestrion.tool.go` imports the package.

This is step 1 of the plan in `_docs/plans/allocator-taint-bits.md`:
the proof of concept moves into the repository. The review of this
step also fixed these problems:

- `Set` reported success when the feature was off or when no bitmap
  could be allocated. It now allocates all bitmaps first and returns
  false with no bit changed.
- `Set` and `Clear` now refuse a range that crosses into a neighbour
  allocation slot. Inside a 16-byte tiny slot, the runtime cannot see
  the bounds of each value, so the caller must give the range of one
  value (documented; the string and slice helpers always do).
- The `freegc` hook clears the whole slot, and runs only when `freegc`
  can free the object.
- The test knobs (`nosweep`, `freegc`) do nothing unless the program
  links `heapbitstest`, so production binaries cannot use them.
- The feature is on only on linux and darwin on amd64 and arm64,
  without ASan or MSan. Other targets compile the woven runtime with
  the feature off.

The tests now prove that addresses are really used again, and a
negative control (sweep hook off) must show stale taint for each size.
Tests pass on go1.26.6 and go1.27.1, with -race, -N -l, checklinkname,
GOEXPERIMENT=runtimefreegc and nogreenteagc, in linux/arm64 and
linux/amd64 containers, and on js/wasm (feature off). The woven runtime
compiles for 20 targets.
The Go wrappers pass `uintptr` values to the runtime, so that checked
values do not escape to the heap. A `uintptr` is not updated when the
goroutine stack moves: a stack address could become stale between the
conversion and the check, and then point to unrelated memory.

Each runtime entry point (`set`, `clear`, `any`) is now `nosplit` up
to the "is this heap memory?" decision, with a nosplit arena lookup.
A stack address is refused before the stack can move. Then a
`noinline` worker does the long work. Workers count work units (bitmap
words and arenas) and run a checkpoint every 512 units that calls
`Gosched` when the scheduler asks for it, because runtime code is not
an async safe point. `Any` has a nosplit fast path for up to 16 bitmap
words; its slow path stops at the end of the span.

Test knobs (active only when the program links `heapbitstest`): a
stack probe at classification, a worker probe, a forced stack move
before the classifier (negative control), forced yields, and a forced
slow path. New tests:

- the stack probe test and its negative control, meaningful with
  `-gcflags=all=-d=maymorestack=runtime.mayMoreStackMove`;
- `TestNoWorkerForStackAddress`: no worker runs for a long stack
  range;
- `TestGeneratedCode`: rebuilds the woven runtime with `-S` (default
  and `-N -l`) and checks the nosplit and call rules;
- checkpoint, fast-path boundary and fast-path/worker equivalence
  tests, and micro benchmarks.

Plan: step 2 of `_docs/plans/allocator-taint-bits.md`.
The proof of concept used one flat 8 MiB bitmap for each heap arena,
with no limit: clearing could touch every page, the memory was never
charged to the runtime memory limit, and racing first writers could
each allocate a bitmap.

The bits now live in 16 KiB chunks (128 KiB of heap each). Each arena
gets a directory (one chunk) on first use. Chunks come from 1 MiB slabs
that the runtime maps with the non-fatal direct mmap. A global budget
of mapped bytes (default 64 MiB, maximum 1 GiB, `heapbits.SetBudget`
before the first Set) is reserved before each mmap. One goroutine at a
time maps a slab; others drop and never wait. Slots are claimed before
the allocation and given back on every failure. Set is all or nothing.
Clear and the sweep hook skip missing chunks and write only words that
have bits to clear. A successful mapping is charged to `other_sys` and
`mappedReady` (the memory limit sees it); a failure charges nothing.

Tests (in child processes with their own budget): exact accounting,
mmap failure with chunk and directory slot rollback, a parked refill
owner, a claimed slot, scan-then-refill (no second mapping), concurrent
refills with a high-water mark, the budget limit with all-or-nothing in
one arena and across arenas, and the default and zero budgets.

Plan: step 3 of `_docs/plans/allocator-taint-bits.md`.
Chunks were never given back: a large tainted object that died kept
its chunks, and the sweep hook cleared all their words, which bounded
neither the memory in use nor the sweep work.

The sweep hook now gives back each chunk that is fully inside a dead
object: it removes the chunk from the directory, then sets its dirty
bit and its free bit on the slab descriptor (two Or64, no loop). The
goroutine that takes a dirty chunk zeroes it (non-zero words only, with
checkpoints) before it publishes it. A chunk at the edge of a dead
object is only cleared, because a live neighbour can use it.

Set now refuses a range in a span larger than 64 MiB (a Span drop).
This bounds the sweep work for one span to 513 chunk slots and two
partial chunks.

The feature is now off on arm64 CPUs without LSE atomics (the plan
required it): there, Or64 and And64 are LL/SC loops with no progress
bound, and the sweep hook cannot yield.

Tests: recycling and zeroing, a live neighbour in an edge chunk, the
span limit (and the recycling of a dead 64 MiB object), and a stress
test where large tainted objects die and their addresses are used again
while the GC runs.

Plan: step 4 of `_docs/plans/allocator-taint-bits.md`.
…work

The flag protocol of the sweep hook (flag = 0, clear the dead objects,
check the live ones, flag = 1 again if one has taint) needs ordered
accesses. The bitmap words used plain loads and stores, and the sweep
prologue read the span flag with a plain load.

- All bitmap word accesses are atomic (Load64, Store64, Or64, And64),
  also the zeroing of recycled chunks; the sweep prologue uses Load8.
  On arm64 (LDAR), Any on 4 KiB is 20-30% slower; 16 B and 256 B do not
  change.
- The live check of the sweep hook has a work limit of 1024 units
  (chunks and words). At the limit, the flag stays 1, which is always
  safe.
- The finalizer check walks the sorted specials list once for each
  pass (a cursor), not from the head for each object.
- TestGeneratedCode also checks that no injected runtime function calls
  a panic or throw function when built with optimizations.

Tests: a dead neighbour in the same bitmap word while the live one gets
Set calls during GC; forced interleavings of Set with the sweep hook of
one span (after the flag reset, and after the live check; a mutation
that writes the flag last fails); Set during continuous GC; the bounded
flag reset (many live objects: the flag stays 1; few: it goes to 0).

Plan: step 5 of `_docs/plans/allocator-taint-bits.md`.
Record the user decision on the cost of atomic loads (step 5): all
bitmap accesses stay atomic. The cost is only for long values on arm64,
and the step 10 benchmarks check it again.
The propagation hooks of phase 4 need to read the tainted ranges of a
value and to copy bits from inputs to results.

- Next and NextClean find the first tainted or clean byte from an
  offset. Together they give the tainted ranges of a value without
  allocation. Memory that is not heap memory is never tainted, and the
  bytes after the end of the span of the value count as clean.
- Copy makes the bits of a destination equal to the bits of a source,
  with memmove semantics (overlap is correct). It classifies both
  pointers before any stack move. A source that is not in one heap span
  counts as clean. A clean source makes Copy a clear, which needs no
  storage. Else all destination chunks are obtained first (all or
  nothing); the destination words are written in the memmove order,
  full words with Store64 and partial words with a Cas64 loop, then the
  span flag is set. Copy has the same span limit as Set.

Tests: model-based Next/NextClean and Copy (all 64x64 alignments, both
overlap directions, across chunks for source and destination), edge
cases, a Copy-only destination that dies and is reused, a concurrent
neighbour writer, all or nothing with a failing mmap, and a progress
test with asynchronous preemption off (longest wait to stop the world:
0.2 ms, limit 10 ms). The stack probe, worker probe, checkpoint and
generated-code tests cover the new entry points.

Plan: step 6 of `_docs/plans/allocator-taint-bits.md`.
…aint bits

- TestRandomModel runs 10000 random operations (allocate objects of
  1 B to 1 MiB, Set, Clear, Copy with and without overlap, drop, GC, and
  checks with Any and Next/NextClean) against a model of the expected
  bits. Every new object must be clean. The seed and the number of
  operations can be set with HEAPBITS_MODEL_SEED and HEAPBITS_MODEL_OPS.
  It finds these mutations: no sweep hook, a forward-only Copy, and a
  clear that writes a whole partial word.
- New negative controls, with test knobs that only work in programs
  that link heapbitstest: the freegc hook off (1000 of 1000 reuses keep
  the taint with GOEXPERIMENT=runtimefreegc), and the finalizer check off
  (100 of 100 revived objects lose their taint). In a runtimefreegc
  build, the freegc tests fail (not skip) if freegc frees nothing.
- The Go functions that call the runtime are go:noinline: their stack
  check is a synchronous preemption point, because the runtime entry
  points are nosplit. TestGeneratedCode checks that the stack check is
  present.
- TestCopyProgress failed on a loaded machine, where the OS also
  delayed plain Go code by tens of milliseconds. It now alternates
  rounds of a plain Go baseline and of the Copy work, keeps the absolute
  10 ms limit for the rounds with a fast baseline, and skips when fewer
  than 3 of 5 rounds are conclusive.

Plan: step 7 of `_docs/plans/allocator-taint-bits.md`.
The runtime aspects depend on unexported runtime internals. Compile-time
assertions catch a signature or type change, but not a change of
behavior, such as a new path that frees heap memory without one of the
hooks.

TestRuntimeDrift (plain go test, no weaving) reads the runtime source of
the toolchain in use and writes an inventory: the call sites of the
functions that free memory or use it again, the writes of freeindex and
allocBits, the special kinds, the mmap definitions, and the full code of
the functions whose behavior the hooks depend on (mark bits, span
lookup, sysAlloc, the freegc reuse path, the whole sweep, the order of
the specials list, mmap). It compares the inventory with a golden file
for the Go minor version, and shows an ordered diff. A new minor
version, or a change, fails the test until a person reviews the runtime
code and runs it with -update-drift.

The golden files for go1.26 and go1.27 were reviewed: no path frees
heap memory without one of the hooks. go1.26.6 and go1.26.8 give the
same inventory.

Plan: step 8 of `_docs/plans/allocator-taint-bits.md`.
The runtime aspects of internal/taint/heapbits use unexported runtime
internals, so they must be tested on each supported Go minor version
and with the build modes that change the runtime or the generated code.

- heapbits: oldstable and stable, on linux/amd64 and linux/arm64, with
  default flags (and -checklinkname=1), -race, -N -l, maymorestack,
  GOEXPERIMENT=nogreenteagc, GOEXPERIMENT=runtimefreegc and
  CGO_ENABLED=0, plus darwin/arm64 (29 cells). GOTOOLCHAIN=local, so that
  a cell tests its own Go version.
- heapbits-feature-off: woven cross builds for 10 targets where the
  feature is off, js/wasm tests under Node.js, and -asan tests.
- heapbits-prerelease (gotip, not required): the drift test without
  weaving, then the woven tests. A failure gives a warning and a line in
  the job summary.

The first two jobs are required in green-ci. The drift test now reads the
Go version with go env GOVERSION when there is no VERSION file (a gotip
build), and names development versions go1.N-devel.

Plan: step 9 of `_docs/plans/allocator-taint-bits.md`.
Add the benchmarks and the latency gate of Phase 1 step 10 (plan
_docs/plans/allocator-taint-bits.md, sections 5.5, 7.1 and 7.2):

- microbench (no test knobs linked): Any, Set, Next, Copy, fresh chunk,
  first-chunk contention, and the map baseline for comparison;
- budgetbench (own binary, 4 MiB budget): Set with a full budget and
  quota saturation with recovery;
- worst-case sweep benchmarks and allocation latency in package heapbits,
  with Set cost and GC cost measured separately;
- TestSweepHookLatency: 8 worst-case scenarios, each in a child process,
  p99 of the sweep hook at most 20 µs;
- overhead runner: a third 'active' variant, HeapBits workloads, a
  three-column comparison and gate.txt (enforced with -gate only; the CI
  job shows it as information, shared runners are too noisy).

The latency gate found that 2 sweepers that recycle the chunks of large
dead objects contended on the slab words and the counter (22-27 µs per
hook). The recycle now batches the updates for each run of chunks of the
same slab, and adds to the counter once per call (2-6 µs).
Fix the findings of the Phase 1 full review (six reviewers, two
follow-up rounds):

- a Set or Copy that failed after it got a whole chunk inside the object
  did not set the span flag, so the chunk was never recycled; a live
  large object with a whole chunk inside now keeps the flag without
  taint, for the same reason;
- the live check of the sweep hook reads each run of live objects in one
  walk (one chunk lookup for each chunk, not for each object): a span of
  small live clean objects took 12-38 us in a Linux VM, now about 1 us;
- the sweep hook stops at once for a span larger than 64 MiB, which can
  have a stale flag on a reused mspan;
- the overhead runner refuses -gate with fewer than 4 samples, and its
  variant check uses the arm64 LSE condition of the runtime;
- more tests (failed Set and full budget recycle, Copy partial failure,
  large slots with one live slot) and more robust ones (Copy neighbour
  scheduling, freegc address reuse, negative control reuse);
- CI cells for cgo off with runtimefreegc, and macOS race and -N -l.
@datadog-prod-us1-4

This comment has been minimized.

The new recycle case of the full review failed in CI (go1.26, -N -l):
the Set of the last free chunk needed a directory too (a new arena),
and a GC during the fill gave chunks of dead objects back. fillBudget
now uses regions whose arena has a directory when a Set of a fresh
region fails, and fills again after a GC until the budget stays full.
Many heap taint bit tests run in child processes of the test binary
(storage, recycle, latency, negative controls). Their coverage was lost,
so the code that only the children run counted as not covered. The
children now get the coverage directory of the parent
(-test.gocoverdir), and go test adds their counters to the profile.
The proof of concept has its own go.mod (module example.com/allocbits,
and github.com/puzpuzpuz/xsync/v3 for its baseline). The license check
reads all go.mod files, so LICENSE-3rdparty.csv was out of date. Phase 1
replaces the proof of concept; it stays in the history of the plan
commit.
The woven test run cannot reach the code for a runtime without the
aspects (the 'feature off' paths of internal/taint/heapbits and of its
test knobs). The unit test job now also runs the tests without
Orchestrion and adds the counts of both profiles. New tests cover the
knobs without the woven runtime and a budget above MaxBudget.
On GitHub runners, the sweep hook of the 64 MiB scenarios took 24 to
29 us (99th percentile), against 3 to 9 us on a developer machine: the
runners are slower and shared. The limit of the plan (20 us) stays for
developer machines; with CI set, the limit is 50 us, so that the gate
still finds large regressions.
fillBudgetButOne failed in CI (linux/amd64, nogreenteagc): with 2 free
chunks, a Set in a new arena took both (the directory and the bits), so
the state with exactly one free chunk never came. With 2 free chunks, it
now takes only regions whose arena has a directory.
The previous commit also held changes to go.mod, go.sum and
orchestrion.tool.go: an old Orchestrion binary, used for a local cross
build, added dd-trace-go/orchestrion/all/v2 and its dependencies. This
restores the three files.
The pre-release CI job failed because TestRuntimeDrift had no golden
for the Go development version. I read the changes against go1.27:
riscv64 is added to the build tags of the cgo mmap (not a supported
platform of the feature), ensureSwept has a fast path for spans that
are already swept, and specialFindSplicePoint is rewritten with the
same order of the specials (offset, then kind). None of them changes
what the aspects depend on.
The previous fix skipped fresh regions in arenas without a directory
when 2 chunks were free. In CI (cgo off), the fresh regions went to new
arenas every time, and the loop ended before it reached one free chunk.
fillBudgetButOne now makes 2 spare regions in an arena with a directory
before the budget is used, and uses one of them in this state.
Add a README section on the internal heapbits storage: how the woven runtime keeps one taint bit per heap byte, and how the bits relate to arenas, spans, chunks and slabs. Diagrams show the address-to-bit mapping, the memory budget and the life cycle of a bit through GC sweep.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant