Skip to content

[parallel] Pin spawned jobs to their submitter's LLC domain - #4510

Open
roberto-bayardo wants to merge 15 commits into
parallel-adaptive-offloadfrom
spawn-affinity
Open

[parallel] Pin spawned jobs to their submitter's LLC domain#4510
roberto-bayardo wants to merge 15 commits into
parallel-adaptive-offloadfrom
spawn-affinity

Conversation

@roberto-bayardo

@roberto-bayardo roberto-bayardo commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator

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_list of the
highest-level Data or Unified leaf under its cache/index* directory in
sysfs (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, kind
any::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:

run 1 2 3 4 5 6 7 8
p50 (ms) 26.6 26.6 18.0 27.2 18.3 27.2 19.0 26.8

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:

mode instructions cycles IPC
fast (p50 ~18.5) 31.5B 31.0B to 33.4B ~1.00
slow (p50 ~26.9) 31.6B 34.5B to 36.8B ~0.88

The same eight-run experiment with this change:

run 1 2 3 4 5 6 7 8
p50 (ms) 15.2 18.1 19.2 17.1 16.4 16.9 19.3 15.4

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

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Aug 15, 2026

Copy link
Copy Markdown

Deploying monorepo with  Cloudflare Pages  Cloudflare Pages

Latest commit: 2f8c0da
Status: ✅  Deploy successful!
Preview URL: https://f14596f6.monorepo-eu0.pages.dev
Branch Preview URL: https://spawn-affinity.monorepo-eu0.pages.dev

View logs

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Aug 15, 2026

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
✅ Deployment successful!
View logs
commonware-mcp 2f8c0da Aug 19 2026, 12:39 AM

@github-actions

github-actions Bot commented Aug 15, 2026

Copy link
Copy Markdown

Benchmark results

Regressions: 0.

✅ `qmdb::merkleize/v=any::unordered::fixed::mmr k=10000 ch=false s=true cc=true` (2/2 gates passed)
Field Value
Package commonware-storage
Benchmark target qmdb_gungraun
Variant qmdb::merkleize/v=any::unordered::fixed::mmr k=10000 ch=false s=true cc=true
Filter *::bench_merkleize::any_unordered_fixed_mmr
Baseline suite commonware-storage
Gates EstimatedCycles should decrease; tolerance 10.00%; blob_reads should decrease; tolerance 10.00%
Cargo flags --features test-traits
Metric Baseline Current Delta Gate
Ir 13,146,560 13,233,018 +0.66% -
L1hits 16,829,245 16,933,893 +0.62% -
LLhits 90,653 92,681 +2.24% -
RamHits 10,889 10,684 -1.88% -
TotalRW 16,930,787 17,037,258 +0.63% -
EstimatedCycles 17,663,625 17,771,238 +0.61% EstimatedCycles should decrease; tolerance 10.00%
blob_reads 158 158 +0.00% blob_reads should decrease; tolerance 10.00%
✅ `qmdb::merkleize/v=current::ordered::fixed::mmb chunk=256 k=10000 ch=false s=true cc=true` (2/2 gates passed)
Field Value
Package commonware-storage
Benchmark target qmdb_gungraun
Variant qmdb::merkleize/v=current::ordered::fixed::mmb chunk=256 k=10000 ch=false s=true cc=true
Filter *::bench_merkleize::current_ordered_fixed_mmb_chunk_256
Baseline suite commonware-storage
Gates EstimatedCycles should decrease; tolerance 10.00%; blob_reads should decrease; tolerance 10.00%
Cargo flags --features test-traits
Metric Baseline Current Delta Gate
Ir 15,460,423 15,540,878 +0.52% -
L1hits 20,275,049 20,360,868 +0.42% -
LLhits 139,655 136,469 -2.28% -
RamHits 14,618 18,411 +25.95% -
TotalRW 20,429,322 20,515,748 +0.42% -
EstimatedCycles 21,484,954 21,687,598 +0.94% EstimatedCycles should decrease; tolerance 10.00%
blob_reads 235 235 +0.00% blob_reads should decrease; tolerance 10.00%

Baseline commit(s): 6e39079bd101

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Fix All in Cursor

❌ 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.

Comment thread parallel/src/topology.rs Outdated
Comment thread parallel/src/topology.rs
/// Detects the LLC topology; `None` when unreadable or single-domain.
fn detect() -> Self {
Self::from_sysfs().unwrap_or(Self {
cpu_to_domain: Box::new([]),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess my only fear is pinning somehow gets in the way of concurrency? but assume it is smarter than that.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let me see if I can add something to avoid over-pinning to the same domain should it ever arise...

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@codecov

codecov Bot commented Aug 16, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 86.40351% with 62 lines in your changes missing coverage. Please review.
✅ Project coverage is 95.47%. Comparing base (c65cc73) to head (97dcf88).

Files with missing lines Patch % Lines
parallel/src/topology.rs 86.34% 36 Missing and 26 partials ⚠️
@@                      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     
Files with missing lines Coverage Δ
parallel/src/lib.rs 94.05% <100.00%> (+0.13%) ⬆️
parallel/src/topology.rs 86.34% <86.34%> (ø)

... and 12 files with indirect coverage changes


Continue to review full report in Codecov by Harness.

Legend - Click here to learn more
Δ = absolute <relative> (impact), ø = not affected, ? = missing data
Powered by Codecov. Last update c65cc73...97dcf88. Read the comment docs.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

roberto-bayardo and others added 15 commits August 18, 2026 17:34
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: In Progress

Development

Successfully merging this pull request may close these issues.

[runtime] consider thread affinity in thread spawning

2 participants