Skip to content

perf(cpu): balanced work partitioning across compute threads (#402) - #420

Open
jamesburton wants to merge 2 commits into
kkokosa:mainfrom
jamesburton:issue/402-balanced-partition
Open

perf(cpu): balanced work partitioning across compute threads (#402)#420
jamesburton wants to merge 2 commits into
kkokosa:mainfrom
jamesburton:issue/402-balanced-partition

Conversation

@jamesburton

Copy link
Copy Markdown

Adds ComputeThreadPool.PartitionRange and routes all 12 worker sites through it, replacing the
ceiling-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.

configuration balanced ceiling delta paired t
--threads 24 (the awkward case) 121.44 119.73 +1.43% 3.86 (n=10)
--threads 32 (default, no imbalance) 119.06 118.55 +0.43% 0.48 (n=8)

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 24 also 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) covers N < T, N == T, N == T + 1, N a multiple of T, and
N just under a multiple, asserting:

  • no thread receives an empty range while N >= T
  • per-thread counts differ by at most 1
  • the ranges tile [0, N) exactly once, contiguously and in order
  • with N < T, exactly N threads get one item each and none reads past the end (the granularity
    limit you noted, kept distinct from the defect)

Kernel numerics tests pass unchanged (112 in the touched areas).

Note on the helper

PartitionRange is public static on ComputeThreadPool rather than a private helper, since the
callers live in DotLLM.Cpu.Kernels. Happy to make it internal instead if you would rather it not
be surface area — it is only meaningful to kernel authors.

Closes #402

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.
Copilot AI review requested due to automatic review settings July 31, 2026 04:54

Copilot AI 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.

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, balanced floor/ceil split 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, and N<T granularity 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.
@jamesburton

Copy link
Copy Markdown
Author

Both addressed in 27d4e5b — both agreed.

PartitionRange visibility (now internal). Right, and it answers the open question I left in
the PR description. Its only callers are the kernels in this assembly, DotLLM.Cpu already grants
InternalsVisibleTo to the test project, and a work-partitioning helper means nothing outside
kernel code — so nothing is lost and the shipped API surface does not grow.

Test location (moved). Also right. It is now at tests/DotLLM.Tests.Unit/Cpu/Threading/ in the
DotLLM.Tests.Unit.Cpu.Threading namespace, alongside ComputeThreadPoolTests and
NumaTopologyTests. It was the only thing under a top-level Threading namespace, which is exactly
the discoverability problem you describe — the duplication this PR removes spread the same way.

No behaviour change; the 21 partition cases pass unchanged.

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.

ComputeThreadPool workers idle threads: ceiling-division partitioning leaves up to ~47% of the pool unused

2 participants