perf(cpu): balanced work partitioning across compute threads (#402) - #420
perf(cpu): balanced work partitioning across compute threads (#402)#420jamesburton wants to merge 2 commits into
Conversation
Adds `ComputeThreadPool.PartitionRange` and routes all 12 worker sites through it, replacing the ceiling-division split each had copied. The old form gave every thread the rounded-up share, so the work ran out early and the tail threads got an empty range. Severity tracked how close the item count was to the thread count, which is why it was invisible on the matmul workers (hundreds of tiles over 32 threads) and acute in attention, where the items are heads. Measured on Zen 5 (Ryzen AI Max+ 395, 16C/32T), Llama-3.2-1B-Instruct Q8_0, 12 KB prompt, prefill tok/s. A/B within one build via a temporary env override, ABBA-ordered so the two arms sit at equal mean position — without that, clock drift across a run swamps the effect (the raw series falls 136 -> 113 tok/s regardless of arm): --threads 24 (32 heads, the awkward case) 121.44 vs 119.73 +1.43% paired t=3.86, n=10 --threads 32 (default, no imbalance) 119.06 vs 118.55 +0.43% paired t=0.48, n=8 So: a real gain where the counts do not divide, and no regression on the default configuration — as expected, since 32 heads over 32 threads was never imbalanced. Results are unchanged, not merely close. Ranges stay contiguous, disjoint and in thread order, so each thread owns the same kind of output slice as before; this redistributes work without reassociating any arithmetic. `PartitionRangeTests` covers N < T, N == T, N == T+1, N a multiple of T, and N just under one, asserting that no thread idles while N >= T, that per-thread counts differ by at most 1, and that the ranges tile the items exactly once.
There was a problem hiding this comment.
Pull request overview
Introduces a shared balanced range-partitioning helper for CPU compute threads and applies it across kernel worker sites to reduce idle threads when the work item count doesn’t divide thread count (notably for attention head partitioning).
Changes:
- Added
ComputeThreadPool.PartitionRange(...)implementing a contiguous, balancedfloor/ceilsplit across threads. - Updated CPU kernel workers (attention + multiple matmul variants) to use the shared partition helper instead of per-site ceiling-division logic.
- Added unit tests (
PartitionRangeTests) covering tiling, balance, andN<Tgranularity behavior.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/DotLLM.Tests.Unit/Threading/PartitionRangeTests.cs | Adds coverage for the new balanced partition helper across edge cases. |
| src/DotLLM.Cpu/Threading/ComputeThreadPool.cs | Adds PartitionRange helper for balanced contiguous work distribution. |
| src/DotLLM.Cpu/Kernels/MatMulQ5_0.cs | Routes Q5_0 workers’ tile/group partitioning through PartitionRange. |
| src/DotLLM.Cpu/Kernels/MatMulKQuants.cs | Routes K-quant workers’ tile/token partitioning through PartitionRange. |
| src/DotLLM.Cpu/Kernels/MatMul.cs | Routes multiple matmul worker partition sites through PartitionRange. |
| src/DotLLM.Cpu/Kernels/Attention.cs | Routes attention head partitioning through PartitionRange to reduce idle threads on awkward --threads values. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| using DotLLM.Cpu.Threading; | ||
| using Xunit; | ||
|
|
||
| namespace DotLLM.Tests.Unit.Threading; |
| /// <param name="end">Exclusive end of this thread's range. Equals <paramref name="start"/> | ||
| /// when there is no work for this thread.</param> | ||
| [MethodImpl(MethodImplOptions.AggressiveInlining)] | ||
| public static void PartitionRange( |
From review feedback; both points agreed. - `PartitionRange` is `internal` rather than `public`. Its only callers are the kernels in this assembly, and `DotLLM.Cpu` already grants `InternalsVisibleTo` to the test project, so nothing is lost and the shipped API surface does not grow for a partitioning helper that means nothing outside kernel code. (I had flagged this as an open question in the PR description; the answer is internal.) - `PartitionRangeTests` moves to `tests/DotLLM.Tests.Unit/Cpu/Threading/` and the `DotLLM.Tests.Unit.Cpu.Threading` namespace, alongside `ComputeThreadPoolTests` and `NumaTopologyTests`. It was the odd one out under a top-level `Threading` namespace. No behaviour change; the 21 partition cases pass unchanged.
|
Both addressed in 27d4e5b — both agreed.
Test location (moved). Also right. It is now at No behaviour change; the 21 partition cases pass unchanged. |
Adds
ComputeThreadPool.PartitionRangeand routes all 12 worker sites through it, replacing theceiling-division split each had copied.
Measurement
Zen 5 (Ryzen AI Max+ 395, 16C/32T), Llama-3.2-1B-Instruct Q8_0 (32 heads), 12 KB prompt,
prefill tok/s. A/B within one build via a temporary env override — comparing two builds would
confound the partitioning with everything else that differs.
--threads 24(the awkward case)--threads 32(default, no imbalance)Both acceptance criteria: a measured improvement where the counts do not divide, and no regression
on the default configuration — which is what you would expect, since 32 heads over 32 threads was
never imbalanced.
Ordering matters more than the effect here, so the design controls for it. Runs were ABBA-ordered
so each arm sits at equal mean position, and compared pairwise. Without that the result is not
meaningful: the raw series falls from 136 to 113 tok/s across a session regardless of arm (clock
drift on this part), which is ~15% — an order of magnitude larger than the effect being measured.
A naive alternating A/B returns whichever answer its ordering implies. I hit exactly that on #415
earlier tonight, in the opposite direction.
The +1.43% is larger than attention's ~1% share of compute would allow on its own, which fits:
--threads 24also leaves the matmul tile/group partitions non-dividing, and those are the ~53%.Correctness
Results are unchanged, not merely close. Ranges stay contiguous, disjoint and in thread order, so
each thread owns the same kind of output slice as before — this redistributes work without
reassociating any arithmetic.
PartitionRangeTests(21 cases) coversN < T,N == T,N == T + 1,Na multiple ofT, andNjust under a multiple, asserting:N >= T[0, N)exactly once, contiguously and in orderN < T, exactlyNthreads get one item each and none reads past the end (the granularitylimit you noted, kept distinct from the defect)
Kernel numerics tests pass unchanged (112 in the touched areas).
Note on the helper
PartitionRangeispublic staticonComputeThreadPoolrather than a private helper, since thecallers live in
DotLLM.Cpu.Kernels. Happy to make itinternalinstead if you would rather it notbe surface area — it is only meaningful to kernel authors.
Closes #402