Skip to content

Thread placement on one CCD, and the default that is two per core - #44

Merged
marcobambini merged 1 commit into
sqliteai:mainfrom
mfethe1:docs/x86-thread-placement
Aug 23, 2026
Merged

Thread placement on one CCD, and the default that is two per core#44
marcobambini merged 1 commit into
sqliteai:mainfrom
mfethe1:docs/x86-thread-placement

Conversation

@mfethe1

@mfethe1 mfethe1 commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

docs/ENGINE.md's "Thread placement" measures --cpus on a two-CCD Ryzen 9 9900X, where crossing the die costs 25% and reproduces to 0.5%. This adds the case one step in — a single-CCD part — and the answer turns out to be different in kind, not just in size.

Ryzen 7 3700X, Zen 2, one CCD holding two 4-core CCXs with separate 16 MB L3. Windows 11, MSYS2 UCRT64, gcc 16.2.0. Kimi-Linear, cache saturated at --budget 18G so every arm reports the same hit rate and the same bytes read, WASTE_XPAR=1, three repeats per arm, medians.

Crossing a CCX: no measurable effect, reported as a bound

8 threads on median tok/s
wherever the OS puts them 3.72
0-7, one CCX 3.10
8-15, the other CCX 2.42
0-3,8-11, split 4+4 2.81

I am not claiming a penalty from this table, and I want to be explicit about why.

The grid carried two arms that were the same configuration, as a control. They came back 3.38 and 3.72 — so anything under about 10–12% is this machine's noise. And a second run of the identical grid put the two CCXs in the opposite order. A number that flips sign between runs is not a finding.

What the data does bound is the size. Whatever crossing a CCX costs on a single-die part, it is not the 25% that crossing a die costs, and it is below what this host can resolve. Practical reading: the option is worth reaching for on a multi-CCD machine; on one CCD, leaving placement to the OS is not leaving anything on the table.

I have written it into the doc that way — as a bound with the noise floor stated — rather than as a result.

Thread count moves much more, and the default is the wrong end of it

threads median tok/s
16 — the default here 2.31
8 3.38–3.72
6 3.76

--threads 0 is one per CPU the pool may use, and waste_cpu_count() counts logical CPUs — GetActiveProcessorCount(ALL_PROCESSOR_GROUPS) (src/platform.h:221) on Windows, sysconf(_SC_NPROCESSORS_ONLN) (src/platform.h:326) on Linux. So on an 8-core part with SMT on, the default pool is 16 threads, two per core, and lands ~1.6× below the 6–8 plateau.

Mechanism, consistent with the profile: the apply is a dependent load → address → load chain, so two siblings on one core split its L1 and load/store ports and add no memory-level parallelism. This is docs/LEARNED.md §25's "the apply saturates at six threads" arriving on x86 from the other side.

6 and 8 are one plateau — inside each other's noise. Only the default is clearly off it. I have not proposed changing the default: §47 already establishes that the best setting inverts between models, so that is a judgement for the project, not something one machine should decide.

Evidence

The CLI help said 0 (default) is one per core. It is one per logical CPU, which is the whole reason the default lands where it does — so it now says that.

Before After
help text reading one per core help text reading one per logical CPU, so two per core where SMT is on

One observation, offered rather than concluded

The first repeat of every arm is the slowest — 1.81 before 2.71/2.31 at 16 threads, 2.60 before 3.38/3.42 at 8. #37 reports the same shape on a Strix Halo and reads it as warming rather than as an outlier. Two machines, same pattern. It is why the numbers above are medians over three repeats rather than a best-of, and it seemed worth recording that it reproduces off that host.

On the measurement hygiene

An earlier run of this exact grid was discarded: a torrent was pre-allocating on the same disk, and the duplicate-arm control came back with a 2× spread on byte-identical work. Everything quoted here was taken with nothing else touching the disk or the CPU. Mentioning it because the control is the only reason I noticed — without it, that run produced a tidy and completely false story about one CCX being faster.

Context

From standing up a native Windows x86 measurement box for #37/#38 work. Companions: #42 (the suite reporting missing tools as engine failures) and #43 (diskbench truncating its offset through a 32-bit off_t), both on the same machine.

Happy to re-run any arm, add arms, or drop the CCX table entirely if you would rather not have a bound in the docs.

🤖 Generated with Claude Code

https://claude.ai/code/session_01B5eVMiauR4Lkt8Dhc67MNb

`--cpus` was measured on a two-CCD Ryzen 9 9900X, where crossing the die
costs 25% and reproduces to 0.5%. This adds the case one step in: a
Ryzen 7 3700X, Zen 2, one CCD holding two 4-core CCXs with separate
16 MB L3. Kimi-Linear, cache saturated at --budget 18G so every arm
reports identical hit rate and identical bytes, WASTE_XPAR=1, three
repeats per arm.

Crossing a CCX does not reproduce the die result, and the honest reading
is "below what this host can resolve" rather than "cheaper". The grid
carried two arms that were the same configuration as a control; they came
back 3.38 and 3.72, so anything under ~10-12% is noise here. A second run
of the identical grid ordered the two CCXs the other way round. What the
data bounds is the size: not 25%, and not measurable at this precision.
Left as a bound, because a number that flips sign between runs is not a
finding.

Thread count moves much more on the same machine, and the default is the
wrong end of it:

    16 threads (the default here)   2.31 tok/s
     8 threads                      3.38-3.72
     6 threads                      3.76

`waste_cpu_count()` counts logical CPUs — GetActiveProcessorCount on
Windows, _SC_NPROCESSORS_ONLN on Linux — so `--threads 0` on an 8-core
part with SMT is 16, two per core, and ~1.6x slower than 6-8. The apply
is a dependent load -> address -> load chain: two siblings on one core
split its L1 and load/store ports and add no memory-level parallelism.
That is §47's "saturates at six threads" reaching x86 from the other
side. 6 and 8 are one plateau, inside each other's noise; only the
default is off it.

The CLI help said `0 (default) is one per core`. It is one per logical
CPU, which is the whole reason the default lands where it does, so it now
says that.

Also recorded, as an observation rather than a conclusion: the first
repeat of every arm is the slowest. sqliteai#37 reports the same shape on a Strix
Halo and reads it as warming. Two machines, same pattern -- hence medians
over three repeats here rather than a best-of.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5eVMiauR4Lkt8Dhc67MNb
@marcobambini

Copy link
Copy Markdown
Member

Merged. The help text was plainly wrong — waste_cpu_count() is
GetActiveProcessorCount(ALL_PROCESSOR_GROUPS) on Windows and
sysconf(_SC_NPROCESSORS_ONLN) on Linux, both logical — and it is the line
that explains why the default lands where it does, so it is the half of this
PR I would have taken on its own.

Writing the CCX table as a bound with the noise floor stated rather than as
a result is the right call and is why it went in unedited. The duplicate-arm
control coming back 3.38 and 3.72, and the two CCXs swapping order between
runs, is the part most reports would have left out — and the run you discarded
because a torrent was pre-allocating on the same disk is exactly the discipline
docs/LEARNED.md §38 exists to enforce. A tidy false story about one CCX being
faster is precisely what that control caught.

The thread-count table I have left as your measurement and not adopted as a
default, which is what you asked for. §47 already establishes the best setting
inverts between models, and #37 has now added that it inverts between expert
formats on one machine, by more than either. That is three axes, so a default
change needs to be a decision rather than a table.

Verified here before merging: --threads 0 behaviour unchanged, and a forward
pass byte-identical to the merge-base.

@marcobambini
marcobambini merged commit 3045844 into sqliteai:main Aug 23, 2026
9 checks passed
marcobambini added a commit that referenced this pull request Aug 24, 2026
Almost none of this is the engine. It is Windows, real containers, and the four
feasibility gates that were still open, and most of it was found by people
running hardware this project does not own. GATES.md has no open gate for the
first time, and two of the three that closed this cycle closed against the
change they were proposing.

No ABI move: src/waste.h changed only in a comment this cycle, unlike 0.6.8.
waste_kernels gained vq_rows_p6 but that lives in src/waste_backend.h and is
not public.

Added: the VQ4P apply behind a dispatch slot with an AVX-512 VBMI kernel beside
it (#41), bit-identical on ARM against a real index_bits 6 container and still
never executed on any machine here; WASTE_DUMP_SCORES (#45) and
WASTE_CCR_LAMBDA (#46); the working set printed by `waste plan`, which was
quoted by the line below it and never shown; and a test for the fp8 block-scale
mapping whose only stub had replaced it with the identity (#40).

Fixed: the five gaps of #36 and the two that survived the first attempt —
VirtualLock bounded by the minimum working set rather than the maximum, so
WASTE_MLOCK wired nothing on Windows twice over; diskbench truncating its
offset through a 32-bit off_t at its own default file size, where the quiet
half read the wrong place successfully and kept the working set inside an SSD's
SLC cache; container JSON through Python text mode, which made the same
conversion produce byte-different containers on Windows; a missing cmp
reporting `expert cache changes results` on a clean checkout (#42); plus #35,
#30, and the CLI help that said one thread per core when it is one per logical
CPU (#44).

Recorded as measured and not adopted, which is the half of this file that is
easy to drop: the budget resolver's quantum stands after Gate 7 (§63);
cache-conditional routing stays a knob because it clears README's KL bar and
not its continuation bar; the thread default did not move despite 16 threads
measuring 1.6x below the plateau; no budget policy fixes two K3 opens on 64 GB
(§64, #31 closed, #49 opened for the residue); and VQ4P is not a throughput
upgrade over VQ3R on a GB10 despite complete and exact CUDA coverage (§62).

53 passed, 0 failed, 6 skipped against Kimi-Linear and K3.
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