Skip to content

Parallel eval improvements#534

Draft
edolstra wants to merge 13 commits into
sync-2.35from
parallel-eval-improvements
Draft

Parallel eval improvements#534
edolstra wants to merge 13 commits into
sync-2.35from
parallel-eval-improvements

Conversation

@edolstra

@edolstra edolstra commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator

Motivation

Various vibe-coded parallel eval improvements. Together they give a significant improvement to elapsed time and user/kernel time (on nix search nixpkgs):

eval-cores-compare

Context

edolstra added 7 commits July 3, 2026 13:53
The previous implementation used a std::map with traceable_allocator,
which costs one GC_MALLOC_UNCOLLECTABLE() and one GC_FREE() per
distinct attribute name. Uncollectable allocations always take the
global GC allocation lock, making this map a major source of mutex
contention during parallel evaluation (~12% of all futex calls in a
16-core 'nix search' run, plus the corresponding GC_free traffic).

Instead, collect the (name, value) pairs into a plain std::vector and
group them by stable-sorting on the name, which yields the same
attribute order and per-attribute value order as the map. The vector
doesn't need to be visible to the GC since the values it points to
are kept alive by the attrsets in args[1] for the duration of the
call.

At 16 eval cores (with GC disabled via GC_INITIAL_HEAP_SIZE=40G),
this cuts kernel time from ~21s to ~8-9s, context switches from 2.6M
to 1.5M, and elapsed time from ~6.2s to ~4.9s for 'nix search nixpkgs
--no-eval-cache fizzbuzz'. Single-core performance is unchanged.

Assisted-by: Claude Fable 5 <noreply@anthropic.com>
Previously every RootValue was created with
std::allocate_shared<Value *>(traceable_allocator<Value *>()), which
costs a GC_MALLOC_UNCOLLECTABLE() / GC_FREE() pair per root value.
Uncollectable allocations always take the global GC allocation lock,
making this a significant source of mutex contention during parallel
evaluation: the eval cache allocates a root value per AttrCursor
(i.e. per attribute visited by 'nix search'), and the parallel
evaluator allocates one per queued work item.

Instead, carve root slots out of large uncollectable slabs (which are
permanently part of the GC root set) and recycle them through a free
list protected by a plain mutex, whose critical section is a few
instructions rather than the entire GC allocator. Slots are cleared
on release so they don't keep values alive; liveness semantics are
unchanged. The slabs are never freed, so pool memory is bounded by
the peak number of simultaneously live root values (8 bytes per
slot).

At 16 eval cores (with GC disabled via GC_INITIAL_HEAP_SIZE=40G),
this cuts kernel time from ~8-9s to ~7s, context switches from 1.46M
to 0.99M, and elapsed time from ~4.9s to ~4.3-4.5s for 'nix search
nixpkgs --no-eval-cache fizzbuzz'.

Assisted-by: Claude Fable 5 <noreply@anthropic.com>
This makes a huge performance impact once GC kicks in (e.g. it speeds
up a 12-core `nix search nixpkgs` from 10.5 to 6.5 seconds).
Add a patch to boehmgc that makes GC_generic_malloc_many() hand out up
to GC_many_blocks heap blocks worth of objects per acquisition of the
global GC allocation lock, instead of exactly one 4 KiB block. All
evaluator threads replenish their thread-local free lists (Values,
Envs, Bindings) through this function, so during parallel evaluation
they otherwise serialize on the allocation lock: at 12 eval cores,
batching 64 blocks reduces futex syscalls by 6x (3.58M to 0.58M) for
'nix search nixpkgs --no-eval-cache fizzbuzz', and reduces both
kernel time and elapsed time at higher core counts.

The batch size defaults to GC_MANY_BLOCKS_DEFAULT (set to 64 here)
and can be overridden at runtime via the GC_MALLOC_MANY_BLOCKS
environment variable (1-64, where 1 restores the upstream behavior).

Assisted-by: Claude Fable 5 <noreply@anthropic.com>
Off-CPU profiling showed that ~2 of 12 worker threads were idle on
average during the parallel phase of 'nix search', because work items
were only spawned after fully enumerating each attrset: while one
thread enumerated legacyPackages.x86_64-linux (~120k attributes), and
later each large package subset (pythonPackages, perlPackages, ...),
the other workers had nothing new to pick up.

Two changes:

* Spawn work items incrementally (every 256 attributes) during
  enumeration instead of in one batch at the end, so idle workers can
  start on the first attributes while enumeration continues.

* Executor::spawn(): Generate queue keys with a thread-local
  mt19937_64 instead of calling std::random_device per work item,
  which costs hundreds of cycles per call (RDRAND / /dev/urandom).
  The key only needs to spread same-priority items around the queue,
  not be cryptographically random.

At 12 eval cores (with GC disabled via GC_INITIAL_HEAP_SIZE=40G),
this reduces elapsed time from ~4.6s to ~4.3s, user CPU from ~28s to
~26s, and kernel time from ~5s to ~3.9s for 'nix search nixpkgs
--no-eval-cache fizzbuzz'.

Assisted-by: Claude Fable 5 <noreply@anthropic.com>
Previously every work item had its own std::promise/std::future pair.
Fulfilling a promise performs an unconditional futex wake syscall even
when nobody is blocked on the future (which is almost always the case
here, since FutureVector only awaits completion in aggregate), and the
shared state is a heap allocation, freed from many threads and thus
contending on the glibc malloc arena locks. With hundreds of
thousands of work items per evaluation, this accounted for ~100k
futex syscalls per 'nix search' run.

Instead, work items now share a single Completion object per
FutureVector, consisting of an atomic pending counter, a list of
exceptions thrown by work items, and a condition variable that is
only signalled when the counter drops to zero. finishAll() waits for
that and then rethrows the first recorded exception, preserving the
previous semantics (log all but the first, unless interrupted).
Spawns without a FutureVector (e.g. builtins.parallel) pass a null
completion and remain fire-and-forget.

At 12 eval cores (with GC disabled via GC_INITIAL_HEAP_SIZE=40G and
GC_MALLOC_MANY_BLOCKS=64), this halves context switches (650-770k to
273-420k) and removes ~100k futex calls for 'nix search nixpkgs
--no-eval-cache fizzbuzz'. Elapsed time is unchanged on 12 physical
cores; the reduced lock traffic is headroom for higher core counts.

Assisted-by: Claude Fable 5 <noreply@anthropic.com>
@edolstra edolstra added the flake-regression-test Run the flake regressions test suite on this PR label Jul 6, 2026
@coderabbitai

coderabbitai Bot commented Jul 6, 2026

Copy link
Copy Markdown

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: e9ea2518-4394-46bb-b7b5-14cc169e8a46

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch parallel-eval-improvements

Comment @coderabbitai help to get the list of available commands.

edolstra added 3 commits July 6, 2026 16:32
Evaluating an empty attrset literal wrote its position into
Bindings::emptyBindings, the shared static object that
EvalMemory::allocBindings() returns for zero-capacity bindings. Under
parallel evaluation this is a data race (the position of every '{ }'
was whichever one was evaluated last), and 'perf c2c' on an Intel
i7-1260P showed it also causes false sharing: emptyBindings happens
to share a cache line with Counter::enabled, which is read by every
thread in allocValue()/callFunction()/maybeThunk(), so each write
invalidated that line across all cores.

Skip the write for the shared empty bindings; '{ }' now has a
deterministic (undefined) position instead of a racy one, and the
cache line stays clean. Verified with perf c2c that the line no
longer appears among contended cache lines.

Assisted-by: Claude Fable 5 <noreply@anthropic.com>
perf c2c on an Intel i7-1260P showed contention on the cache line at
the start of SourceAccessor objects: make_ref/make_shared co-locates
the std::shared_ptr control block with the object, so the atomic
reference count (updated by every SourcePath copy, on every thread)
shared a cache line with the object's vtable pointer and `number`
field, which are read by every virtual accessor call
(maybeLstat/resolveSymlinks/isAllowed/...) and every SourcePath hash.
Each refcount update thus invalidated the line needed for all file
access dispatch on all other cores.

Aligning SourceAccessor to the cache line size pushes the object onto
its own line(s), leaving only the control block on the shared line.
Verified with perf c2c that accessor field/vtable reads no longer
appear on contended lines; the remaining (much smaller) contention is
the reference count itself.

Assisted-by: Claude Fable 5 <noreply@anthropic.com>
resolveExprPath() constructed three SourcePath temporaries per loop
iteration (parent(), resolveSymlinks(), operator/), and
parseExprFromFile() one per file, each copying the accessor ref
(an atomic refcount update on a cache line shared between all
threads) and the path string. Work on the accessor and CanonPaths
directly instead.

Assisted-by: Claude Fable 5 <noreply@anthropic.com>
edolstra added 2 commits July 6, 2026 17:49
Apply the same optimization to builtins.groupBy as to
builtins.zipAttrsWith (162a34378c): instead of accumulating the groups
in a std::map<Symbol, ValueVector> with traceable_allocator - which
costs a GC_MALLOC_UNCOLLECTABLE() per distinct name plus traceable
growth allocations for every per-group vector, all taking the global
GC allocation lock - collect the (name, value) pairs into a plain
std::vector and group them by stable-sorting on the name. The vector
doesn't need to be visible to the GC since the values it points to
are kept alive by the list in args[1] for the duration of the call.

The shared machinery (the NameValue type, sorting/counting the names,
and iterating over the per-name runs) is factored out into helpers
used by both primops.

This removes the last user of ValueVectorMap, so drop that typedef.

Assisted-by: Claude Fable 5 <noreply@anthropic.com>
RootValue (std::shared_ptr<Value *>) adds avoidable overhead per GC
root: a malloc'd control block, atomic reference count updates on
copies (a measured source of cache line contention during parallel
evaluation), and 16 bytes per handle. Most users never copy their
roots, so introduce UniqueRootValue, a move-only RAII wrapper around
a pointer to a root slot that returns the slot to the pool on
destruction.

The pool internals move from allocRootValue() into
allocRootValueSlot() / freeRootValueSlot(), shared by both handle
types, and the whole root value machinery moves into new files
root-value.{hh,cc}. The non-Boehm fallback now also goes through the
slot functions (new/delete) instead of a make_shared special case.

Converted to UniqueRootValue: ValMap, fileEvalCache, internalPrimOps,
genericClosure's work/result lists, EvalCache::value,
AttrCursor::_value, InstallableAttrPath::v, and the JSON parser
state. RootValue remains (documented as such) for roots captured in
std::function-backed lambdas, which require copyability.

Assisted-by: Claude Fable 5 <noreply@anthropic.com>
Replace the Sync<std::vector<Value **>> free slot pool with an
intrusive linked list: each slot is a union of Value * (in use) and a
pointer to the next free slot, with a Sync<Slot *> head. Allocation
pops the head; freeing pushes in O(1).

This avoids the auxiliary vector memory (8 bytes per free slot), the
4096-iteration push_back loop when carving a new slab, and most
importantly the possibility of a vector reallocation (a malloc) while
holding the pool lock on the free path.

GC safety is unchanged: slabs remain uncollectable and conservatively
scanned. In-use slots contain a Value * and root the value; free
slots contain a pointer into a slab (or null), which is scanned
harmlessly since slabs are never freed anyway. Writing the next
pointer on free overwrites the Value *, which doubles as the "stop
rooting the value" step.

Assisted-by: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

flake-regression-test Run the flake regressions test suite on this PR

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant