Eval Worker Setup¶
Flake evaluations take place in a pool of --eval-subprocess processes driving the embedded Nix C API. One evaluation is split into shards and spread across the pool. The pool is sized to fit the host's memory. Results land in a persistent eval cache shared by the fleet. A repeat evaluation of the same locked flake is mostly cache hits.
flowchart LR
worker[gradient-worker] -->|rkyv over stdin/stdout| p1[eval subprocess]
worker --> p2[eval subprocess]
p1 --> cache[(eval cache)]
p2 --> cache
cache <-->|upload / download| server[Server]
Subprocess IPC¶
- Frames: a
u32little-endian length prefix plus an rkyv payload (gradient-eval/src/ipc.rs). - Version byte: subprocesses write
EVAL_IPC_VERSION(currently 10) before the first frame. A binary swapped mid-evaluation can no longer pass the handshake and send undecodable frames. - Responses: request and response pair up.
Statsticks and build requests can arrive before the response.Listresponses hold the derivation path of each attribute found, resolved in the subprocess that forced the attribute. - Shards:
Planwill split each include at its first wildcard.- A wildcard followed by more segments will yield one sub-pattern per child (
packages.*.hello->packages.x86_64-linux.hello). - A trailing wildcard will yield the unchanged pattern plus its child names (
only), read without forcing any child. - The parent can list those names in batches of
names / (pool * 4)(at most 64). Listwill limit the first wildcard to the batch.
- A wildcard followed by more segments will yield one sub-pattern per child (
- Deferred sets:
Listwill never force the children of a nested set under a trailing*(all checks of one system).- The set will come back in
deferredas a#shard over its children. - The parent can queue those names in batches.
- One heavy set will spread across the pool instead of keeping one subprocess busy while the rest sit idle.
- The set will come back in
- Warm walker: A subprocess can keep one walker (locked flake plus open eval cache) across consecutive requests for the same repository. A Plan / List sequence can pay for the lock and the cache open only once.
Import From Derivation¶
An import from a derivation (IFD) needs a build in the middle of an evaluation. Subprocesses never build through the local daemon. The parent must request a Gradient build from the server instead.
| Frame | Direction | Content |
|---|---|---|
NeedsBuild |
subprocess -> parent | derived_paths: .drv paths with their outputs (/nix/store/<hash>-x.drv^out) |
BuildDone |
parent -> subprocess | error: empty after a successful build |
- The patched Nix of Gradient can hand an import to a realise hook instead of the local daemon.
- The hook in the subprocess must send
NeedsBuildand block untilBuildDonearrives. - A different request during the wait is a protocol error. Subprocesses then stop reading and exit.
- Nix can raise the
errorofBuildDoneas the error of the import. Later attributes with the same import receive the same error.
Import Builder (gradient-worker/src/executor/import.rs)¶
- Record the closure, with the imported derivation as the entry point
other.<system>.<name>andifdset. Repeated names within a job end with-<first 8 hash characters>. - Send
ImportRequestand wait forImportResult. - Pull the outputs and their closure from the Gradient cache.
-
Answer
BuildDone, withimport from derivation '<name>' failed: build <id> <status>after a failure. -
Requests for the same
.drvfrom different subprocesses share the wait and the answer. - Fingerprint, checkpoint, input fetch and the
--eval-driverharness refuse imports withimport from derivation is not available during <call>.
Parent Side¶
Files under gradient-worker/src/worker_pool/.
| File | Role |
|---|---|
transport.rs |
Subprocess handle, frame wire, typed requests. Setting oom_score_adj |
pool.rs |
Checkout and return with test-on-borrow |
memory.rs |
Free-RAM guard, reaper |
resolver.rs |
Pooled fan-out and crash isolation |
eval_stats.rs |
Per-attribute evaluation statistics |
driver.rs |
The hidden --eval-driver <file> harness: JSONL requests through the real transport, JSON responses out. The NixOS VM test can drive both sides of the wire through the harness from Python |
Memory Safety¶
Two layers bound evaluation memory.
| Layer | Option | Behavior |
|---|---|---|
| Recycling | worker.eval.maxRss (2 GiB) |
Subprocesses over the limit will restart between calls. Pool size: worker.eval.forkWorkers, independent of the limit |
| Free-RAM reaper | worker.system.minFreeRamMb (0 = 10% of RAM, clamped to 128 MiB - 1 GiB) |
Sampling MemAvailable every 500 ms. A shortfall below the margin will trigger a SIGKILL of the largest evaluation subprocess with resident memory covering the whole shortfall. A 5 s wait will follow |
- The recycle check will happen after a call. One unit (a large aggregate, IFD chains, runaway recursion) can grow the Boehm heap past the cap within a call. The reaper is the guard against that peak.
- Nothing is killed when no evaluation is large enough to cover the shortfall. The pressure is then coming from elsewhere.
- A fixed 1 GiB floor on a 2 GiB host killed evaluations that were never the cause (#579).
- A killed subprocess will close its pipe. The parent will retry the call once, then fail only that attribute.
- One bounded failure will replace a host OOM that could kill the worker and strand the job. The server will only register a clean disconnect.
- The idle subprocesses shut down once discovery is over. Their heaps are not sitting through the rest of the closure walk.
- The next evaluation will start fresh subprocesses.
acquirewill serialise evaluations under sustained pressure, always letting one proceed.- Evaluation subprocesses are running with
oom_score_adj = 600as the kernel's last resort.
Compared to nix-eval-jobs¶
| Gradient | nix-eval-jobs | |
|---|---|---|
| Parallelism | Long-lived pool. One evaluation sharded across the pool | Short-lived children forked from a warm parent |
| Warmth across evaluations | Persistent eval cache per flake fingerprint | None. Copy-on-write warmth lasting one evaluation |
| Across machines | Fleet-shared <fp>.sqlite cache (pull and push). Staging a pulled blob will drop the previous local -wal / -shm sidecars |
None |
| Concurrent writers | Shards writing a shared cache, each write in an immediate transaction of its own, and a checkpoint at the end | Not applicable |
| Memory | Automatic pool sizing. A many-system flake can degrade to one shard and still complete | Manual --workers and --max-memory-size |
| Pipeline | Discovery writes rows, and build assignment starts mid-evaluation. The closure walk skips server-known derivations | JSON job stream for the consumer (Hydra and similar) |
| Failure isolation | A bad attribute will become an error, and the evaluation will go on. A crashed batch comes back attribute by attribute, down to the attribute that sank the subprocess | Per-job errors through the fork boundary |
| Compute across machines | Single-host pool today | Single host |
Related¶
- Evaluation Metrics: the pool's per-evaluation records
- Transfer: upload of the eval cache (
UploadObject::EvalCache) - Configuration: every worker option