[parallel] Pin spawned jobs to their submitter's LLC domain - #4510
[parallel] Pin spawned jobs to their submitter's LLC domain#4510roberto-bayardo wants to merge 15 commits into
Conversation
Deploying monorepo with
|
| Latest commit: |
2f8c0da
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://f14596f6.monorepo-eu0.pages.dev |
| Branch Preview URL: | https://spawn-affinity.monorepo-eu0.pages.dev |
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ✅ Deployment successful! View logs |
commonware-mcp | 2f8c0da | Aug 19 2026, 12:39 AM |
Benchmark resultsRegressions: ✅ `qmdb::merkleize/v=any::unordered::fixed::mmr k=10000 ch=false s=true cc=true` (2/2 gates passed)
✅ `qmdb::merkleize/v=current::ordered::fixed::mmb chunk=256 k=10000 ch=false s=true cc=true` (2/2 gates passed)
Baseline commit(s): |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Want fixes drafted automatically? Bugbot Autofix can create code changes for findings. A team admin can enable Autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit a145bd2. Configure here.
092ab87 to
e57a70f
Compare
| /// Detects the LLC topology; `None` when unreadable or single-domain. | ||
| fn detect() -> Self { | ||
| Self::from_sysfs().unwrap_or(Self { | ||
| cpu_to_domain: Box::new([]), |
There was a problem hiding this comment.
I guess my only fear is pinning somehow gets in the way of concurrency? but assume it is smarter than that.
There was a problem hiding this comment.
The pin is scoped pretty conservatively: it targets a whole L3 domain (4 cores here) rather than a core, it only applies to the one worker executing the spawned job, and the mask is restored the moment the job completes, so nothing accumulates across jobs.
The bigger safety net is that only policy approved offloads ever pin, and the spawn policy keeps measuring each callsite's offload wall clock. If pinning ever crowded a domain enough to hurt, that callsite's samples degrade and the policy steers it back to inline on its own.
And for all our larger-scale benchmarks for far (eth replay, db-gen, constantinople) we see no degradation.
There was a problem hiding this comment.
Let me see if I can add something to avoid over-pinning to the same domain should it ever arise...
There was a problem hiding this comment.
Done in a660e98: each domain now accepts at most its width minus one live pins (a per domain counter the guard releases along with the mask), so concurrent pinned jobs can never crowd a domain or evict the caller working there. Past the cap a job just runs unpinned wherever the scheduler likes. That makes over pinning structurally impossible rather than something the spawn policy has to learn to avoid. Re-validated on the branch tip afterwards: eight consecutive benchmark runs all land in the fast band (14.3 to 19.9ms p50) with no slow mode draws.
d4c67fc to
97dcf88
Compare
Codecov Report❌ Patch coverage is
@@ Coverage Diff @@
## parallel-adaptive-offload #4510 +/- ##
=============================================================
- Coverage 95.49% 95.47% -0.02%
=============================================================
Files 606 607 +1
Lines 273435 273891 +456
Branches 6582 6622 +40
=============================================================
+ Hits 261113 261496 +383
- Misses 10143 10186 +43
- Partials 2179 2209 +30
... and 12 files with indirect coverage changes Continue to review full report in Codecov by Harness.
🚀 New features to boost your workflow:
|
97dcf88 to
db28f67
Compare
A spawned job usually has a data relationship with its submitter: it consumes what the caller just built and the caller consumes what it produces. When the executing thread sits in a different last-level cache domain than the submitter, that exchange pays cross-domain latency per cache line for the job's lifetime, and the scheduler's thread placement is a sticky per-process lottery it never revisits (measured as bimodal 18.5ms and 26.5ms commit medians on identical instruction counts, decided once per process). The executor now pins itself to the domain the submitter occupied at spawn time, restoring its previous mask when the job ends (including unwinds). The first such pin migrates the worker once, tens of microseconds; afterwards the restored-wide worker tends to stay put, so repeat pins move nothing. Inline spawns are unaffected, and the size-aware spawn policy already keeps sub-threshold jobs inline where a migration could matter relative to the work. Domains come from the kernel's cache topology, never vendor core layout assumptions. Non-Linux platforms and miri compile the whole mechanism to a no-op. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q4BFdANo2LhVEr2ko3FK9n
sched_setaffinity replaces the mask, so pinning to the full domain set could grant a worker CPUs an operator had excluded via taskset, numactl, or isolcpus. The pin now narrows to the intersection of the domain and the thread's current allowance, and is skipped entirely when that intersection is empty. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q4BFdANo2LhVEr2ko3FK9n
Address three review findings. The last level cache is not index3 on every architecture: each CPU's leaves are now enumerated and the highest level Data or Unified leaf selected, so a machine whose LLC sits elsewhere pins to the right domain instead of an instruction or smaller data cache. A CPU the snapshot does not cover (hot-added, or a failed getcpu) previously mapped to domain zero, which could migrate a job to an unrelated domain since numbering is an iteration-order artifact; unknown now means no pin. Lint cleanliness: manifest style for the libc dependency, const stubs, and the domain interning restructured per the workspace clippy configuration.
The undocumented unsafe blocks lint requires the comment immediately above each block; two test asserts had a statement in between.
Concurrent spawned jobs pinning into one domain could crowd it (and the submitting caller working there). Each domain now accepts at most width minus one live pins, tracked by a per-domain counter that the guard releases with the mask; past the cap a job simply runs unpinned wherever the scheduler likes. This makes crowding structurally impossible rather than a condition the spawn policy must learn.
fetch_update is deprecated on current nightly (renamed try_update), which the deny warnings build rejects, and the new name does not exist on the workspace MSRV. A plain compare exchange loop is portable across both toolchains.
The skip list predated the operator restriction intersection and the per domain pin cap.
The pin cap now derives from the intersection of the domain with the thread's allowed mask rather than the sysfs domain width, and pinning is skipped outright when fewer than two CPUs remain. On drop, the saved mask is restored before the slot is released, and a failed restore keeps the slot occupied so the counter never advertises capacity for a still-pinned worker. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q4BFdANo2LhVEr2ko3FK9n
spawn_domain() can succeed while pin() legitimately returns None, e.g. a taskset restriction leaving fewer than two allowed CPUs in the domain. The leak test now establishes that a pin can succeed before asserting that a hundred pin/release cycles leave a slot available. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q4BFdANo2LhVEr2ko3FK9n
…gies Four fixes from an adversarial review of the pin path: Drop now restores the saved mask only when the thread's current mask is still within the one the pin applied (equal, or clamped to a subset by the kernel while a CPU is offline). A mask straying outside the pin means an operator or the kernel rewrote it mid-job, and that newer placement wins instead of being overwritten by the pin-time snapshot. The pin slot and thread pin are always released: the syscalls proved viable at pin time, so a failure can only mean a concurrent external rewrite that already removed the confinement. A thread-local flag refuses nested pins. A pinned worker that picks up nested jobs occupies one CPU regardless of depth, so a second guard would burn another cap slot and cap-reject a genuinely distinct worker. Topology detection reports no domains when the kernel's possible-CPU count exceeds cpu_set_t's capacity (probed once with sched_getaffinity, which fails outright there even when every sysfs-visible CPU id is small) or when a CPU id past CPU_SETSIZE appears in the sharing lists. Pinning could never engage on such machines, so this avoids charging every spawn for a doomed syscall sequence. The widen test bounds-checks sched_getcpu's result before CPU_SET: the libc crate's implementation indexes without a range guard, unlike the glibc macro. AffinityGuard::pin also splits into a thin wrapper over pin_in, which takes an explicit topology so tests can drive the full pin protocol on synthetic domains; the guards carry their topology reference for that. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q4BFdANo2LhVEr2ko3FK9n
On single-domain machines, which include most CI runners, spawn_domain returns None and every pin-path test silently degrades to a no-op, so the intersection, cap accounting, syscall application, and restore logic only ever ran on multi-domain hardware. These tests instead build a leaked two-domain topology by splitting the process's allowed CPUs in half, which exists on any Linux machine with four allowed CPUs and, because each instance has private pin counters, lets tests assert pin success and exact counter values without racing concurrently running tests. Covered: the pin narrows the mask to exactly the domain intersection and restores it on drop, repeated pin and release leaves no slot consumed, a nested pin is refused and re-enabled by the outer drop, the counter sits at the cap with further pins refused while cap guards are live and returns exactly to zero after, and a multi-threaded stress burst never drives the counter past the cap. The cap test keeps all assertions on the main thread and releases its workers from a drop guard, so a failing assertion fails the test rather than deadlocking the scope join. Also covered with a synthetic sysfs tree: llc_list picks the highest-level Data or Unified leaf rather than the first directory entry or a fixed index, and ignores Instruction-only caches. Table construction gets a case for realistic discontiguous SMT sharing lists. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q4BFdANo2LhVEr2ko3FK9n
The synthetic split now uses at most eight allowed CPUs, so the cap and stress tests spawn a handful of threads on any machine instead of hundreds on a wide builder. The slot-release comment in Drop documents the fail-open tradeoff rather than overclaiming that a failed cleanup call proves the confinement is gone, and a stale direction reference in the nested-pin test comment is removed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q4BFdANo2LhVEr2ko3FK9n
From a holistic review of the integrated module: the module doc's no-pin list gains the mask read and apply failures, the cleanup comment acknowledges a failed cleanup may leave the worker narrowed for the rest of the process rather than claiming transience, the nested-pin test also asserts refusal against the other synthetic domain to back its comment, and Topology with its helpers drop to module-private so the non-Linux stub exactly mirrors the crate-visible surface. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q4BFdANo2LhVEr2ko3FK9n
db28f67 to
2f8c0da
Compare

What
Spawned jobs now pin their executing thread to the last level cache (L3) domain
the submitter occupied at spawn time, restoring the previous affinity mask when
the job completes (including unwinds), unless the mask was rewritten mid-job
to CPUs outside the pin. Domains are discovered once per process from the
kernel's cache topology: for each CPU, the
shared_cpu_listof thehighest-level Data or Unified leaf under its
cache/index*directory insysfs (the leaf index varies by architecture, so it is discovered rather than
assumed). Non-Linux platforms and miri compile the mechanism to a no-op.
Closes #4540.
Why
A spawned job usually exchanges data with its submitter: it reads what the
caller just built and the caller later reads what it wrote. If the executor
runs in a different L3 domain than the submitter, every shared cache line pays
a cross-domain transfer for the job's lifetime. The OS places threads once per
process, effectively at random, and never revisits the choice.
The effect is easiest to see on a benchmark whose commit path hands index
application to a single spawned job (
constantinople, kindany::unordered::fixed::mmb: 1M keys, 32k reads and 32k updates per iteration,p50 commit latency over 100 iterations, 8 threads). Machine: AMD EPYC 9354P,
32 cores, SMT off, eight 4-core L3 domains per the kernel.
Eight consecutive runs of the same binary on the same input, before this
change:
Each process lands in one of two modes and stays there for its lifetime. The
mode is set by where the scheduler seated the job's executor relative to the
submitting thread. Hardware counters show the slow mode is pure memory
stalling, not extra work:
The same eight-run experiment with this change:
The lottery is gone, and the median (16.9ms) is better than the old lucky mode
because the pin also keeps the executor from drifting away mid-run. For
reference, the same benchmark without the spawned index job at all runs at
23.5ms, and single-threaded at 26.7ms.
Cost
The first pin migrates the worker once, costing tens of microseconds.
Afterwards the restored-wide worker tends to stay where it ran, so repeat pins
move nothing. The spawn round-trip microbenchmark measures no change (82.5us
with the pin versus 83.7us without, within run-to-run noise). Jobs below the
size threshold are unaffected: the adaptive spawn policy from the base PR keeps
them inline, so the pin only executes for jobs large enough that a one-time
migration is noise. Concurrent pinned jobs cannot crowd a domain: the cap on live
pins is the domain's effective width (the CPUs the pinning thread may actually
use there) minus one, reserving one for the submitting caller. Past the cap, or
when fewer than two CPUs are allowed, a job simply runs unpinned.
A worker already pinned by an enclosing job does not pin again: a thread-local
marks the thread as pinned, so nested jobs stay within the outer confinement
instead of burning a second cap slot for one CPU of occupancy. Machines whose
possible-CPU count exceeds
cpu_set_t's capacity report no domains at all,disabling the feature up front rather than charging every spawn for affinity
syscalls that could never engage.
Restore defers to external rewrites: a mask moved outside the pin mid-job
(taskset -pa, a cgroup cpuset migration) is left in place, since writing the
pin-time snapshot back would resurrect an allowance the operator may have just
revoked. Cleanup is best effort: the pin slot is always released, so the worst
case may leave a worker narrowed with its slot freed rather than stranding
domain capacity for the process lifetime.
Testing
Tests drive the full pin protocol (intersection, cap accounting, apply, and
restore) against synthetic two-domain topologies built by splitting the
process's allowed CPUs in half, with private pin counters per test. This makes
the coverage real on CI runners, which expose a single L3 domain and would
otherwise skip every pin path. Topology parsing is likewise exercised against
synthetic sysfs cache directories, SMT-style discontiguous sharing lists, and
CPUs past
cpu_set_t's capacity.🤖 Generated with Claude Code
https://claude.ai/code/session_01Q4BFdANo2LhVEr2ko3FK9n