Skip to content

milnor_gpu: drop the global device mutex (thread-local resident, per-thread streams) - #15

Draft
JoeyBF wants to merge 13 commits into
hpcfrom
claude/nassau-gpu-concurrent
Draft

milnor_gpu: drop the global device mutex (thread-local resident, per-thread streams)#15
JoeyBF wants to merge 13 commits into
hpcfrom
claude/nassau-gpu-concurrent

Conversation

@JoeyBF

@JoeyBF JoeyBF commented Jul 22, 2026

Copy link
Copy Markdown
Owner

See commit message: removes the RESIDENT mutex + stream-0 pin; thread-local resident store, per-thread CUDA streams. Stem-130: 193s/2.6 cores -> 44-49s/5.6 cores (CPU-only: 142s/10). Verified bit-identical (VERIFY at stem 80 all-builds and stem 130 default-gate, concurrent). Note: verify at 16 workers exceeded a ~48GB cgroup (normal runs fit); bound RAYON_NUM_THREADS if memory-constrained. Includes the local guard-relaxation commits not yet on origin/hpc.

🤖 Generated with Claude Code

JoeyBF and others added 7 commits July 20, 2026 01:13
The relaxed wavefront keeps many bidegrees in flight at once, so at any
instant it is likely that some job is inside a linear-algebra critical
section (ParallelGuard). The scheduler re-spawned a bounced job
immediately, which just re-checked is_in_parallel, found it still busy,
and bounced again — spawning a whole rayon job per re-check and pegging
every core on a retry storm that does no useful work.

Instead the receiver checks the flag itself (a cheap atomic load) and
parks a bidegree only when the section is genuinely busy. A job acquires
and releases its guards many times and spends most of its time outside
them, so the section frees far more often than jobs complete; parked
bidegrees are therefore re-checked via a short recv_timeout while
anything is parked, and re-spawned as soon as the section frees.
Incoming messages are still handled the instant they arrive.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MCUtWj6P6suSZqvATCdg6d
is_in_parallel was a global count of active par_iter critical sections,
so a step_resolution job bounced whenever *any* thread was in one. Under
the relaxed wavefront many bidegrees are in flight, so that flag is
almost always set and nearly every job bounced, producing the retry
churn the parking mitigation only softened.

The priority inversion the guard exists to prevent is narrower: a worker
that initiated a par_iter blocks in the join and work-steals, and if it
steals another (heavy, nested-parallel) resolution step, that step
stalls the section the worker is blocked on. A stolen job runs on the
stealer's own OS thread, so a thread-local depth counter reports exactly
whether *this* worker is a blocked guard holder. Jobs picked up by a
free worker read zero and run, letting independent bidegrees resolve
concurrently instead of serializing behind any single critical section.

The scheduler thread never holds a guard, so it can no longer read the
flag to sense saturation; park bounced bidegrees and retry them on each
completion or a short recv_timeout tick. Bounces are now rare (only a
genuine steal-onto-a-blocked-holder), so the parking path barely
engages. The classical scheduler shares the guard and benefits the same
way, so its immediate-respawn no longer storms.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MCUtWj6P6suSZqvATCdg6d
Pins the invariant the previous commit relies on — a ParallelGuard held
on one thread reads as absent on another — so a future change that
reverts to a shared counter fails loudly instead of silently
reintroducing the retry storm.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MCUtWj6P6suSZqvATCdg6d
…ead-local

The batched multiply serialized every launch behind one RESIDENT mutex held
across the whole marshal+upload+kernel+readback section, and additionally
pinned all work to CUDA stream 0. With the relaxed dependency graph exposing
~max_s-wide bidegree parallelism, that lock collapsed a ~12-core CPU wavefront
to ~2.6 busy cores and left the GPU idle 80% of the time — making NASSAU_GPU=1
a net 1.4x slowdown over CPU-only at stem 130 (193s vs 142s).

cubecl 0.10 does not need the lock: a per-device runner thread already
serializes all server access (concurrent client calls are memory-safe), and
memory pools are per-stream. So:

- RESIDENT becomes a thread_local RefCell: each rayon worker keeps its own
  admissible cache and cs/mk device handles, created and consumed only on the
  thread (and thus the default per-thread CUDA stream) that owns them, so no
  handle ever crosses threads and no cross-stream event sync fires.
- The GPU_STREAM{value:0}.executes pin is removed; each worker launches on its
  own default stream, so independent bidegrees marshal and execute
  concurrently. memory_cleanup now trims only the calling worker's pool.

Stem 130 (S_2, s<=152, 16-core H200 box): 193s/2.6 cores (old mutex GPU) and
142s/10 cores (CPU-only) -> 44-49s/5.6 cores. Verified bit-identical to the
CPU path with NASSAU_GPU_VERIFY=1 at stem 80 (MIN_WORK=0, every build) and
stem 130 (default gate, all offloaded/chunked launches, concurrent workers).

Note: concurrency raises peak host memory (concurrent marshal buffers across
workers); a 16-worker VERIFY run at stem 130 exceeded a ~48GB cgroup, while
normal runs fit comfortably. Bound RAYON_NUM_THREADS if memory-constrained.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… resident master)

The mutex-removal commit let many workers run device sections concurrently, which
exposed three unbounded memory consumers at record stems (>100GB host AND device
by stem 150, measured):

1. Unbounded launch transients: the all-rows reuse build allocated its full output
   in one shot, per in-flight worker. Fixed by splitting builds into row blocks
   bounded by NASSAU_GPU_BLOCK_MB (default 512MB) of output AND GPU_PAIR_CHUNK
   kernel threads — one launch per block, subsuming the former pair-chunk loop
   (rows are independent, so blocks concatenate exactly).

2. Unbounded stream count: every worker thread got its own CUDA stream, and each
   stream's pool retains freed slabs indefinitely. Fixed by NASSAU_GPU_CONCURRENCY
   (default 8) permits that double as stream slots: at most 8 device sections run
   at once, on 8 fixed streams. A permit must never be held across a rayon parallel
   section (par_iter chunks execute on guard-free threads that can steal a bidegree
   job which then parks on acquire — observed deadlock); it is acquired only for
   the strictly sequential layout+device section. Do NOT raise to 16: measured
   catastrophic (>30x) slowdown from cross-stream sync churn.

3. Per-thread resident duplication: the thread-local resident store copied the
   admissible-matrix master (~8.5GB at stem 150, growing with degree) once per
   worker, on host and device. Fixed by re-sharing it: host master behind an
   RwLock (enumeration outside the write lock), one device mirror behind a small
   mutex, handles shared across threads/slots (cubecl event-syncs cross-stream
   reuse). Re-uploads are needs-based — only when a launch dereferences past the
   uploaded prefix — since re-uploading on mere growth serialized multi-GB copies
   on nearly every frontier launch (measured 1.5x wall regression).

Stem 150 (S_2, s<=152, 16-core H200 box), verified bit-identical to CPU at
stem 80 (every build, forced multi-block) and stem 130 (all offloaded launches):

                         wall   cores   host RSS   device
  before this commit     682s    3.3     137 GB    140 GB (full card)
  after  (32 workers)    721s    3.8      65 GB     37 GB
  CPU-only reference     771s   10.3     4.4 GB       —

Verdict: at record stems the GPU path now merely ties CPU-only while using far
more memory — the CPU path (no row-reuse matrix, per-signature builds) is both
frugal and wavefront-parallel. Recommend CPU-only for the stem-300 production
run; the GPU path remains correct, memory-bounded, and a real win at mid stems
(3x at stem 130).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@JoeyBF

JoeyBF commented Jul 23, 2026

Copy link
Copy Markdown
Owner Author

Follow-up commit f1cc8fa bounds the GPU-path memory the mutex removal exposed (>100GB on host and device by stem 150): row-block chunking (subsumes the pair-chunk loop), 8 launch permits doubling as fixed stream slots, and a re-shared resident master with needs-based uploads. Verified bit-identical at stem 80 (every build, forced multi-block) and stem 130.

Stem-150 result: 137GB/140GB -> 65GB/37GB at comparable wall time (721s vs 682s). CPU-only reference: 771s at 4.4GB.

Verdict: at record stems the GPU path ties CPU-only while using 15x the memory — recommend CPU-only (drop NASSAU_GPU=1) for the stem-300 production run. GPU stays a real win at mid stems (3x at stem 130). Do not raise NASSAU_GPU_CONCURRENCY past 8 (measured >30x collapse at 16).

Replaces the count-based launch cap (NASSAU_GPU_CONCURRENCY=8 exclusive
sections) with two decoupled controls:

- NASSAU_GPU_MEM_BUDGET_MB (default 4096): admission weighted by a launch's
  output bytes, so dozens of small low-stem launches run concurrently again
  (the count cap throttled exactly the region that never had a memory problem)
  while the frontier stays bounded to ~budget/block-size in flight.
- NASSAU_GPU_STREAMS (default 8): fixed CUDA stream slots, round-robin and
  SHARED (small launches serialize on a stream rather than demanding an
  exclusive one), so stream/pool count is bounded independently of concurrency.

Master device uploads are now prefix-only with doubling: a launch ships
max(need, 2*uploaded) entries, not the whole master, so frontier launches (which
append new high-degree R each t) no longer re-ship gigabytes of untouched tail.

Stem 130 improved 187s -> 150s; stem 150 memory 65/37 -> 68/30 GB, verified
bit-identical (stem 80 all-builds, stem 130 all offloaded). But a slots x budget
sweep is FLAT (8/4G=150s, 16/8G=170s, 32/16G=160s, 64/32G=201s): concurrency
knobs are not the ceiling. The ceiling is Amdahl — the GPU accelerates only the
Milnor multiply (~17% of frontier wall time; row_reduce/signature_matrix/readback
dominate and are CPU/serial through cubecl's single runner thread), so the
end-to-end GPU:CPU ratio is flat ~1.13x across the 130-150 heavy bands, not
widening. Widening it would require offloading row_reduce (PR SpectralSequences#274's RREF).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@JoeyBF

JoeyBF commented Jul 23, 2026

Copy link
Copy Markdown
Owner Author

Per-band analysis (S_2, s<=152, current bounded binary, w=32):

band GPU Δ CPU Δ ratio
0→130 165s 142s 0.86 (GPU loses)
130→140 182s 206s 1.13
140→150 376s 423s 1.11

GPU crosses over above stem 130 but the ratio is flat ~1.13x, not widening. Slots×budget sweep at stem 130 is flat (8/4G=150s … 64/32G=201s), so concurrency knobs are not the ceiling. The ceiling is Amdahl: the GPU accelerates only the Milnor multiply (~17% of frontier wall time; row_reduce + signature_matrix + readback dominate and are CPU/serial through cubecl's single runner). Arithmetic intensity rises with stem but the multiply's share of wall time doesn't, so the end-to-end ratio stays flat.

Revised verdict: at bounded memory the GPU path is ~1.13x on heavy bands, a loss on light ones — modest. Widening it needs row_reduce offloaded (PR SpectralSequences#274 RREF). For stem-300 production, CPU-only remains the safe default (frugal, wavefront-parallel); GPU is worth enabling only if the ~12% frontier win justifies the memory + tuning.

JoeyBF and others added 5 commits July 23, 2026 16:09
…tiply path

The zero-signature image matrix (d_s applied to the zero-sig source basis,
column-masked to the zero-sig target) was the last per-bidegree Milnor multiply
still on the CPU — a serial per-row apply_to_basis_element_restricted, ~17% of
frontier wall time in the perf profile. But it is the *same* restricted multiply
as the QI-source `full_matrix` already built via restricted_partial_matrix_maybe_gpu,
just on d_s = differentials[b.s()] instead of d_{s-1}, and its target
mask/dimension are exactly the `target_mask`/`target_dim` already computed for the
bidegree (d_s and d_{s-1} share the target module modules[b.s()-1]). So route it
through the same GPU-offloaded, work-gated, already-verified path and apply the
column mask on CPU; drop the serial `signature_matrix` method. (Reinstates the
"signature_matrix offload" win from the original nassau_gpu branch, lost in the
SpectralSequences#272 relaxed-graph merge.) row_reduce stays on CPU — the signature-masked matrices
are very flat (~100 x 100000), a poor RREF target for the GPU.

Correctness: GPU Ext chart byte-identical to CPU-only through (100,152);
NASSAU_GPU_VERIFY passes at stem 130.

This shrinks the serial tail that Amdahl-capped the GPU:CPU ratio, so the
arithmetic-intensity advantage finally shows through and the gap WIDENS with stem
(S_2, s<=152, 16-core H200 box, w=32):

  band       GPU    CPU    ratio
  130->140   159s   206s   1.30x
  140->150   278s   423s   1.52x
  cum 0->150 596s   771s   1.29x   (was 723s, a near-tie)

Memory stays bounded by the same byte-budget/block machinery (this path reuses
multiply_batch_on_gpu). Next lever: the full-reuse-matrix readback.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… a host zero buffer

Per-thread stack sampling at stem 145 showed the wavefront's serial stalls were a
rayon worker pegged in __memcpy_ssse3 inside create_from_slice — host-side upload
marshaling, NOT readback (cubecl 0.10 already does async D2H off pinned memory with
the event wait on the worker thread, so the runner is free during the copy). The
dominant offender: the batched multiply allocated + zeroed a host `vec![0u32;
out_len]` (hundreds of MB at the frontier) and memcpy'd it up as the kernel's XOR
accumulator, every launch/block.

Allocate out_h uninitialized (client.empty) and zero it with a trivial on-device
kernel (zero_u32), same stream as the multiply so it is ordered before it. Removes
the host memset, the non-pinned host->device copy, and the transfer itself; on-device
zeroing is memory-bound (microseconds on an H200).

Verified: GPU Ext chart byte-identical to CPU through (100,152); NASSAU_GPU_VERIFY
passes at stem 130.

Bands (S_2, s<=152, 16-core H200 box, w=32), vs the prior signature-offload binary:
  0->130  159 -> 141s   (ties CPU-only 142; was a 0.89x loss)
  0->140  318 -> 245s
  130->140 marginal 104s vs CPU 206s = 1.98x  (was 1.30x)
peak RSS 46GB, GPU 28GB (both down). Next serial upload to check: term_pparts / the
per-product record arrays.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The shared admissible master reaches ~3GB by stem 138 (cs 1.2GB + mk 1.9GB),
and every launch took the RESIDENT_DEV mutex to read its device handles — with a
growth-triggering launch doing a multi-GB create_from_slice re-upload *while
holding that mutex*. Per-thread stack sampling showed the frontier collapsing to
a single thread memcpy-ing gigabytes while every other bidegree blocked on the
lock (upload byte-size instrumentation under NASSAU_GPU_DEBUG confirmed the
master, not term data or the seqno table, as the giant upload).

Make handle reads lock-free (RESIDENT_DEV: Mutex -> RwLock) and move the upload
memcpy outside that lock, serialized only among uploaders by a separate
RESIDENT_UPLOAD mutex with a re-check that coalesces a burst of growth-needing
launches into one upload. A launch whose R's are already resident proceeds
without ever blocking on someone else's upload.

Verified GPU chart byte-identical to CPU through (100,152); NASSAU_GPU_VERIFY
passes at stem 130. Removes the lock-held-across-copy stall, but occupancy only
rose ~3.5->4.5 cores: the dominant limiter is upstream (thin GPU bidegrees +
wavefront width), not this lock. Kept because it is correct and matters more at
stem 300 where the master is larger. Also adds a per-buffer upload-size line to
NASSAU_GPU_DEBUG.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…er-product alloc storm

A frontier launch has ~1e5-1e6 products, and the marshal built term data as
`Vec<(Vec<u16>, Vec<u32>)>` — two heap allocations per product (~1e6 tiny allocs
per launch) — then extend-copied them into the flat upload buffers. Per-thread
profiling of the GPU path showed this as a dominant chunk of the per-bidegree CPU
"envelope" (~16% _int_malloc/_int_free plus the marshal copy) that wraps each
(fast) kernel and, because the wavefront is only ~10-15 bidegrees wide, cannot be
hidden — so the GPU sits idle between brief spikes.

Precompute the term-count prefix sum (`term_off`), size the flat `term_pparts`/
`term_lens` once, and parallel-fill each product's disjoint slice in place (unsafe
but sound: prefix-sum ranges never alias). The later layout loop just reads
`term_off[pi]` for `prod_term_start` — no per-product allocation, no concat copy.

Verified GPU chart byte-identical to CPU through (100,152). Same GPU results, far
less allocation and marshal work per launch. (The remaining per-product
`GpuProduct.term_indices: Vec<usize>` built in extract is the next alloc to flatten.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…er-splitting

The row-block splitter caps a launch at GPU_PAIR_CHUNK thread-pairs (the kernel
indexes threads by u32 ABSOLUTE_POS, ceiling 2^32). It was set to 1<<30 (~1.07e9),
~4x below the real ceiling — so every billion-pair giant was chopped into ~4
launches, each a separate upload + kernel + BLOCKING readback round-trip, even
though its output is only ~350 MB (well under gpu_block_bytes). Debug confirmed the
giants pegged at 1.07e9 pairs; this, not the byte budget, was the binding split,
which is why a NASSAU_GPU_BLOCK_MB sweep was flat.

Raise it to 3.9e9 (leaves ~0.39e9 headroom under 2^32; the splitter always takes
>=1 row and a lone row past 2^32 still trips the per-block u32::try_from assert;
grid stays ~1.5e7 cubes, far under 2^31). Giants now run as a single launch (max
total_pairs observed 3.90e9), collapsing 4 round-trips to 1.

GPU 0->140 (w=100): ~245-288s -> 216s. Chart byte-identical to CPU through
(100,152).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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.

2 participants