378 lines
16 KiB
Markdown
378 lines
16 KiB
Markdown
# Benchmark journal — SM75 / Ivy Bridge box
|
||
|
||
Running record of every measurement, the exact command behind it, and what it
|
||
showed. Purpose: be able to attribute each gain to a specific cause.
|
||
|
||
## Hardware / software baseline
|
||
|
||
| | |
|
||
|---|---|
|
||
| GPU | NVIDIA CMP 50HX, cc **7.5** (Turing), 10240 MiB |
|
||
| CPU | 2× Xeon E5-2670 v2 (Ivy Bridge), 2 NUMA nodes × 10 cores, 40 threads |
|
||
| **CPU ISA** | **AVX yes, AVX2 no** — so the fork's AVX1 paths are the live path |
|
||
| RAM | 62 GiB (32 GiB per node) |
|
||
| CUDA | 13.3, CCCL 3.3.4 (`CUB_VERSION 300304`) |
|
||
| Driver | 610.43.03 |
|
||
|
||
Clock note: `nvidia-smi -lgc` is **accepted but ignored** on this card — it reports
|
||
success and still boosts to ~2100 MHz. No measurement here is clock-pinned.
|
||
Long-running cases are reliable only because they reach steady boost.
|
||
|
||
## Builds under test
|
||
|
||
| tag | tree | commit | cuBLAS |
|
||
|---|---|---|---|
|
||
| UP | `/home/xrip/upstream-pristine` | `f114f91f9` (pristine ggml-org) | off |
|
||
| UPc | same | same | **on** |
|
||
| US | `/home/xrip/llama.cpp` | `d5919ca1f` (our master) | off |
|
||
| USc | same | same | **on** |
|
||
|
||
Both at the same upstream base, so differences are the fork's work only.
|
||
|
||
```bash
|
||
cmake -B build-cublas -DCMAKE_BUILD_TYPE=Release -DGGML_CUDA=ON \
|
||
-DCMAKE_CUDA_ARCHITECTURES=75 -DCMAKE_CUDA_COMPILER=/usr/local/cuda/bin/nvcc \
|
||
-DLLAMA_CURL=OFF -DGGML_CCACHE=ON -DGGML_CUDA_FORCE_CUBLAS=ON
|
||
```
|
||
|
||
---
|
||
|
||
## 1. Dense 9B, fully on GPU — Qwen3.5-9B-UD-Q4_K_XL
|
||
|
||
```bash
|
||
llama-server -m Qwen3.5-9B-UD-Q4_K_XL.gguf -ngl 99 \
|
||
-b 2048 -ub 2048 -c 17408 --host 127.0.0.1 --port 8099 -t 8 --no-warmup
|
||
# 16384-token prompt sent as an exact token-id array, cache_prompt=false,
|
||
# greedy (temp 0, top_k 1, seed 1234), 3 reps, median.
|
||
```
|
||
|
||
| build | prefill t/s | tg t/s |
|
||
|---|---|---|
|
||
| UPc | 2234.12 | 66.24 |
|
||
| USc | 2236.98 | 67.00 |
|
||
| UP | 1789.13 | 66.13 |
|
||
| US | 1955.07 | 66.92 |
|
||
|
||
**Attribution**
|
||
- `-DGGML_CUDA_FORCE_CUBLAS=ON`: **+24.9% prefill** (UP→UPc, 1789→2234).
|
||
This is a *stock upstream CMake option*, not fork code. Upstream gets it too.
|
||
- Fork vs upstream, **cuBLAS config**: prefill **+0.13%**, tg **+1.14%**.
|
||
- Fork vs upstream, **default config**: prefill +9.27% (the `sm75_ffn` dispatch
|
||
block), tg +1.19%. Subsumed by FORCE_CUBLAS, not additive with it.
|
||
- `llama-bench` pp8 (default cfg only): **+47.7%** — the Turing mmvq threshold
|
||
work. Guarded off under FORCE_CUBLAS, so it does not reach production builds.
|
||
|
||
**Caveat:** this model is fully GPU-resident, so *no CPU kernel ran*. This test
|
||
says nothing about the AVX1/NUMA work.
|
||
|
||
---
|
||
|
||
## 2. MoE 35B — Qwen3.6-35B-A3B-UD-Q4_K_XL (22.85 GB, experts on CPU)
|
||
|
||
```bash
|
||
GGML_CUDA_GRAPH_OPT=1 CUDA_SCALE_LAUNCH_QUEUES=4x \
|
||
numactl --interleave=all llama-server -m Qwen3.6-35B-A3B-UD-Q4_K_XL.gguf \
|
||
--temp 0.0 --jinja --no-mmproj-offload \
|
||
-c 64000 -np 1 -fa on -ctk q4_0 -ctv q8_0 \
|
||
--threads 20 --threads-batch 20 -b 2048 -ub 2048 \
|
||
--fit on --fit-target 256 --load-mode none \
|
||
--host 127.0.0.1 --port 8099
|
||
# 2048-token prompt, n_predict=128, ignore_eos=true, cache_prompt=false
|
||
```
|
||
|
||
### 2a. Methodology correction (important)
|
||
|
||
`moe3` and `moe4` gave **opposite signs** for the same binaries:
|
||
|
||
| run | upstream tg | ours tg | apparent verdict |
|
||
|---|---|---|---|
|
||
| moe3 | 22.81 | 21.35 | ours −6.4% |
|
||
| moe4 | 20.90 | 23.21 | ours +11.0% |
|
||
|
||
Same upstream binary swung **8.4% between runs** while reps *within* a run agreed
|
||
to <1%. Reps inside one load share NUMA page placement, page cache and thermal
|
||
state, so they are correlated and prove nothing. **Alternate arms across separate
|
||
model loads.** Two earlier conclusions drawn from within-run reps were withdrawn.
|
||
|
||
Also: `ignore_eos: true` is mandatory. Without it a synthetic random-token prompt
|
||
at temp 1.0 hits EOS after 2–63 tokens and "tg" is startup overhead.
|
||
|
||
### 2b. Fork vs upstream — 4 alternating loads per arm
|
||
|
||
| arm | pp median | pp spread | tg median | tg spread |
|
||
|---|---|---|---|---|
|
||
| upstream | 166.52 | 0.4% | 21.37 | 1.8% |
|
||
| **ours** | 167.02 | 0.7% | **22.90** | 0.4% |
|
||
| ours + `--threads 40 -Cr 0-39 --cpu-strict 1` | **173.26** | 1.1% | 20.39 | 1.3% |
|
||
|
||
- **Fork tg +7.1% vs upstream** — reproducible, spreads far below the effect.
|
||
This is the AVX1 Q4_K path in `repack.cpp` paying off. (48 `q4_K` mentions in
|
||
the CPU diff; an earlier grep for the *enum* `GGML_TYPE_Q4_K` missed them.)
|
||
- Prefill: ~0%.
|
||
- 40 threads: prefill +3.7%, **tg −11.0%** — 40 threads means hyperthreads on a
|
||
2×10-core box. Prefill is compute-bound and gains; generation is
|
||
memory-latency-bound and loses.
|
||
|
||
### 2c. CPU profile during generation (`perf record -F 499 -g`)
|
||
|
||
| % | symbol | class |
|
||
|---|---|---|
|
||
| 18.3 | `rep_movs_alternative` (kernel) | **fread of model file** |
|
||
| 13.4 | `ggml_vec_dot_q4_K_q8_K` | matmul |
|
||
| ~36.5 | `libgomp` (barrier spin) | **thread sync** |
|
||
| 7.7 | `ggml_vec_dot_q5_K_q8_K` | matmul |
|
||
| 4.2 | `clear_page_erms` (kernel) | page zeroing |
|
||
| 1.6 | tinyBLAS Q8_0 gemm | matmul |
|
||
| 1.0 | `ggml_vec_dot_q6_K_q8_K` | matmul |
|
||
|
||
Only **~24%** of generation CPU time is actual matmul. Call graph attributes the
|
||
18.3%:
|
||
|
||
```
|
||
llama_file::impl::read_raw_unsafe() -> _IO_fread -> read()
|
||
-> ext4_file_read_iter -> filemap_read -> copy_page_to_iter
|
||
-> rep_movs_alternative
|
||
```
|
||
|
||
i.e. page-cache→userspace copies of model data, consistent with
|
||
`--load-mode none` (mmap disabled). Note **no tensor in this model exceeds
|
||
1 GiB**, so `--lazy-mode auto` (threshold 4 GiB) never triggers — lazy mode is
|
||
not involved. `--lazy-mode on-direct` (PR #28136) targets the much larger
|
||
Qwen3.8-Flash and is not applicable here; it is also **not in our master**
|
||
(it lives on `perf/numa-dense-20260902`).
|
||
|
||
**Implication:** tuning `ggml_vec_dot_q4_K_q8_K` by 20% would move the total by
|
||
~2.7%. The sync and I/O costs are far larger targets.
|
||
|
||
### 2d. Thread / OpenMP sweep (3 alternating loads per arm)
|
||
|
||
Base = `--threads 20 --threads-batch 20`.
|
||
|
||
| arm | pp median | tg median | Δpp | Δtg |
|
||
|---|---|---|---|---|
|
||
| t20-b20 (base) | 166.95 | 22.93 | — | — |
|
||
| t20-b40 | 173.44 | 22.38 | +3.9% | −2.4% |
|
||
| t10-b40 | 174.31 | 19.09 | +4.4% | −16.7% |
|
||
| t20 + `OMP_WAIT_POLICY=passive` | 172.94 | **12.13** | +3.6% | **−47.1%** |
|
||
| **t20-b40 + `OMP_PROC_BIND=close OMP_PLACES=cores`** | **174.64** | 22.95 | **+4.6%** | +0.1% |
|
||
|
||
- **`OMP_WAIT_POLICY=passive` is catastrophic (−47% tg).** The libgomp spinning
|
||
is *load-bearing*: MoE graphs have many small nodes, so barrier wake-up latency
|
||
costs far more than spinning. The 36.5% is largely inherent, **not** recoverable
|
||
by configuration. (This refuted the hypothesis that motivated the sweep.)
|
||
- **`OMP_PROC_BIND=close` + `OMP_PLACES=cores` is a free +4.6% prefill** at no tg
|
||
cost — stops thread migration across the two NUMA nodes.
|
||
|
||
### 2e. Best known MoE launch so far
|
||
|
||
```bash
|
||
GGML_CUDA_GRAPH_OPT=1 CUDA_SCALE_LAUNCH_QUEUES=4x \
|
||
OMP_PROC_BIND=close OMP_PLACES=cores \
|
||
numactl --interleave=all llama-server -m Qwen3.6-35B-A3B-UD-Q4_K_XL.gguf \
|
||
--temp 0.0 --jinja --no-mmproj-offload \
|
||
-c 64000 -np 1 -fa on -ctk q4_0 -ctv q8_0 \
|
||
--threads 20 --threads-batch 40 -b 2048 -ub 2048 \
|
||
--fit on --fit-target 256 \
|
||
--host 0.0.0.0 --port 8080
|
||
```
|
||
vs the original `--threads 20 --threads-batch 20 --load-mode none`:
|
||
**prefill +4.6%, tg unchanged.**
|
||
|
||
---
|
||
|
||
## 3. Open / in flight
|
||
|
||
- **`--load-mode` sweep** (`none` vs `auto` vs `mmap` vs `mmap+mlock`) — testing
|
||
whether mmap removes the 18.3% fread cost. *Running.*
|
||
- **SM75 IQ2_XXS decode reuse** — implemented (`f39c81415`), not yet built or
|
||
measured. Load-widening is impossible for IQ2_XXS (66-byte block, `qs` at
|
||
offset 2, only 2-byte alignment), so only decode reuse applies.
|
||
- **IQ2_XXS** `Qwen3.8-27B-UD-IQ2_XXS` downloaded (6.77 GB). Prediction on
|
||
record: ~0% from the fork, since there is no IQ2 code in it and at 7.27 GB the
|
||
model is fully GPU-resident.
|
||
|
||
## 4. Method rules learned here
|
||
|
||
1. Alternate arms across **model loads**, not reps within a load (§2a).
|
||
2. `ignore_eos: true` for any tg measurement with synthetic prompts.
|
||
3. Check the GPU is idle with `pgrep -x llama-server` (name match — `pgrep -f`
|
||
matches other command lines that merely mention the binary).
|
||
4. A stray server holding VRAM silently invalidates results — verify 0 MiB.
|
||
5. Prefer `test-backend-ops perf` (thousands of iterations) over `llama-bench`
|
||
at tiny sizes, where clock ramp gives ±7–17% error bars.
|
||
|
||
---
|
||
|
||
## 5. `--load-mode` sweep — mmap is worse, `none` is correct
|
||
|
||
3 alternating loads per arm, best OpenMP settings applied to all arms
|
||
(`OMP_PROC_BIND=close OMP_PLACES=cores`, `--threads 20 --threads-batch 40`).
|
||
|
||
| load-mode | load s | pp median | tg median | Δpp | Δtg |
|
||
|---|---|---|---|---|---|
|
||
| **none** (what you run) | 29 | **174.11** | **23.10** | — | — |
|
||
| auto | 19 | 165.11 | 21.44 | −5.2% | −7.2% |
|
||
| mmap | 19 | 165.38 | 20.78 | −5.0% | −10.1% |
|
||
| mmap+mlock | 19 | 163.62 | 21.30 | −6.0% | −7.8% |
|
||
|
||
**`--load-mode none` wins; mmap costs 5–10%.** Cause is NUMA:
|
||
`numactl --interleave=all` only stripes **anonymous** memory. With `none` the
|
||
weights are malloc'd buffers and get interleaved across both nodes; with mmap the
|
||
pages are page-cache-backed and land wherever the kernel put them, so a large
|
||
share of expert reads become remote-node accesses on this 2-socket box. mmap
|
||
loads ~10 s faster and then runs slower.
|
||
|
||
**This also refutes the §2c reading of the 18.3% `fread`.** If that cost were in
|
||
the generation steady state, mmap (which has no read syscalls) would have helped.
|
||
It hurt. The v1 profile window (20 s after issuing the request) most likely caught
|
||
first-touch page population rather than steady-state generation. Re-profiling with
|
||
two warmups and a 45 s offset before recording; §2c should be treated as
|
||
unreliable until that lands.
|
||
|
||
## 6. Hypotheses tested and **rejected** (kept so they are not retried)
|
||
|
||
| hypothesis | expected | measured | verdict |
|
||
|---|---|---|---|
|
||
| `OMP_WAIT_POLICY=passive` recovers the 36% libgomp spin | large tg gain | **−47.1% tg** | rejected — spinning is load-bearing |
|
||
| mmap removes the 18% `fread` cost | tg gain | **−10.1% tg** | rejected — NUMA interleave beats mmap |
|
||
| fewer threads help the memory-bound tg | tg gain | −16.7% (t10) | rejected |
|
||
| `--lazy-mode` is causing on-demand reads | — | no tensor >1 GiB, threshold is 4 GiB | not applicable |
|
||
| PR 28366 (radix top-k) affects our prefill | prefill gain | compiled out (CCCL 3.3.4); our models never call TOP_K | no-op here |
|
||
| fork's Q4_K CPU work absent | — | 48 `q4_K` hits in CPU diff | wrong — grep was for the enum name |
|
||
|
||
## 7. Confirmed gains, ranked by size
|
||
|
||
| # | change | workload | gain | whose |
|
||
|---|---|---|---|---|
|
||
| 1 | `-DGGML_CUDA_FORCE_CUBLAS=ON` | dense 9B prefill | **+24.9%** | **upstream option** |
|
||
| 2 | fork AVX1 Q4_K CPU path | MoE tg | **+7.1%** | fork |
|
||
| 3 | `OMP_PROC_BIND=close OMP_PLACES=cores` + `--threads-batch 40` | MoE prefill | **+4.6%** | launch flags |
|
||
| 4 | Turing mmvq threshold tuning | dense pp8, non-cuBLAS only | +47.7% | this session |
|
||
| 5 | upstream branchless Q4_K/Q5_K unpack | mmvq kernel n≥5 | +4–10% | upstream |
|
||
| 6 | fork `sm75_ffn` cuBLAS dispatch | dense 9B prefill, non-cuBLAS only | +9.3% | fork (subsumed by #1) |
|
||
|
||
Note #4 and #6 apply only to builds **without** `FORCE_CUBLAS`, so they do not
|
||
reach the production configuration.
|
||
|
||
---
|
||
|
||
## 8. SM75 IQ2_XXS decode reuse — implemented, measured, **rejected**
|
||
|
||
Applied the technique that works for IQ4_XS (`sm75_iq4_decode`): hoist the
|
||
weight-side decode out of the `ncols_dst` loop so the four `iq2xxs_grid` lookups
|
||
and sign expansion happen once per block instead of once per output column.
|
||
Gated to Turing, `ncols_dst` 2..4.
|
||
|
||
Load widening — the *other* IQ4_XS trick — is **impossible** here:
|
||
|
||
| type | block size | `qs` offset | widening |
|
||
|---|---|---|---|
|
||
| IQ4_XS | 136 B | 8 | `int2` loads OK (existing fork path) |
|
||
| IQ2_XXS | **66 B** | **2** | only 2-byte aligned; `get_int_b2` is required |
|
||
|
||
66 is 2 mod 4, so block bases are not even 4-byte aligned.
|
||
|
||
Result, `test-backend-ops perf -p type_a=iq2_xxs`, medians of 5 reps after a
|
||
discarded warmup, interleaved, FORCE_CUBLAS builds:
|
||
|
||
| n | base us | exp us | delta | |
|
||
|---|---|---|---|---|
|
||
| 1 | 94.19 | 94.19 | +0.00% | control, identical code |
|
||
| 2 | 97.09 | 96.90 | −0.20% | reuse active |
|
||
| 3 | 118.96 | 119.73 | +0.65% | reuse active |
|
||
| 4 | 145.49 | 146.02 | +0.36% | reuse active |
|
||
| 5 | 157.10 | 157.33 | +0.15% | |
|
||
| 8 | 221.22 | 221.06 | −0.07% | |
|
||
| 512 | 1589.06 | 1583.31 | −0.36% | |
|
||
|
||
**n=2..4 mean +0.27%; noise floor −0.07% (identical-code rows).** No gain.
|
||
Correctness passed (2/2 backends).
|
||
|
||
**Why it fails where IQ4_XS succeeds:** the decoded state is 8 ints of grid plus
|
||
scale and `d` — about double `sm75_iq4_decoded` — so occupancy drops on Turing,
|
||
while the work saved is nearly free to begin with: `iq2xxs_grid` is 256 x 8 B =
|
||
**2 KB and L1-resident**. IQ4_XS reuse eliminates genuinely expensive work;
|
||
IQ2_XXS reuse eliminates L1 hits.
|
||
|
||
Reverted in `a261f5e95`. Implementation was correct; the premise was wrong.
|
||
|
||
**Generalisation for future SM75 work:** decode reuse only pays when the decoded
|
||
state is small *and* the eliminated work is expensive (cache-missing loads or
|
||
long dependency chains). Check the grid/table size first — if it fits in L1,
|
||
reuse will not pay for the register pressure.
|
||
|
||
---
|
||
|
||
## 9. SM75 shared-memory codebooks — **+6% tg on a real model**
|
||
|
||
Found by profiling, after two kernel ideas written from plausible reasoning both
|
||
failed. Nsight works on this card via the CMP unlock (Ubuntu `nsight-compute`
|
||
2022.4.1 + the hash-gated patch); `ERR_NVCMPGPU` is not a hard limit.
|
||
|
||
### Diagnosis
|
||
|
||
`mul_mat_vec_q<IQ2_XXS, ncols=1>`, grid 4096 x block (32,4):
|
||
|
||
| metric | value | reading |
|
||
|---|---|---|
|
||
| **`lg_throttle`** | **19.81/issue** | **43x the next stall — the bottleneck** |
|
||
| `wait` | 1.43 | |
|
||
| `long_scoreboard` | 0.46 | not memory latency |
|
||
| `mio_throttle` | 0.06 | shared/MIO pipe idle |
|
||
| L1 hit | 98.04% | not a cache problem |
|
||
| DRAM / SM | 22.99% / 28.54% | neither saturated |
|
||
| occupancy | 92.82% | not warp-starved |
|
||
|
||
The load/store **instruction queue** is full. A `vec_dot` issues ~16 loads
|
||
(4x16-bit from `get_int_b2`, 4x64-bit codebook, 8x32-bit activations), and a load
|
||
costs an LG issue slot **even when it hits L1**.
|
||
|
||
### Fix
|
||
|
||
Stage the codebook in shared memory once per block: `LDG` -> `LDS`, moving that
|
||
traffic to the idle MIO pipe. Grids are 1-8 KiB and fit easily.
|
||
|
||
Counters after: `lg_throttle` **19.81 -> 11.59**, `mio_throttle` 0.06 -> 0.14,
|
||
DRAM 23% -> 27.9%, SM 28.5% -> 36.1%.
|
||
|
||
### Kernel results (n=1, m=4096, k=14336)
|
||
|
||
| type | delta |
|
||
|---|---|
|
||
| iq3_s | **-15.7%** |
|
||
| iq2_xxs | **-13.6%** |
|
||
| iq3_xxs | -11.0% |
|
||
| iq2_xs | -10.2% |
|
||
| iq2_s | -9.2% |
|
||
| iq1_s | -5.2% |
|
||
|
||
IQ1_S/IQ1_M are gated to `ncols_dst <= 2`: they win at n=1/n=2 (-5.2%/-11.1%)
|
||
and lose at n>=4 (+7.7%/+2.2%) because the 8 KiB `iq1s_grid_gpu` costs a block
|
||
of occupancy per SM. IQ2_S is also 8 KiB and wins at every n, so size alone is
|
||
not the deciding factor.
|
||
|
||
### End to end — `llama-server`, Qwen3.8-27B-UD-IQ2_XXS, 16384-token prompt
|
||
|
||
| arm | prefill t/s | tg t/s |
|
||
|---|---|---|
|
||
| base | 699.17 | 19.213 |
|
||
| shared codebooks | 697.15 | **20.361** |
|
||
| | **-0.29%** (control) | **+5.98%** |
|
||
|
||
Raw tg, 3 alternating model loads: base `19.448 / 19.213 / 19.144`,
|
||
sgrid `20.455 / 20.361 / 20.290` — distributions do not overlap.
|
||
|
||
**The prefill column is the control**: at `-ub 2048` prefill goes through cuBLAS,
|
||
which the change does not touch, so it must stay flat. It does. Only the mmvq
|
||
path moved. Correctness: all 7 grid types and `MUL_MAT_ID` pass 2/2 backends.
|
||
|
||
### Two measurement traps hit while doing this
|
||
|
||
1. **Ordering bias.** Running all-base-then-all-exp let the first base invocation
|
||
of each pass catch the 645 MHz idle clock, producing a bogus **-78%** for
|
||
iq2_xxs. Alternate base/exp *per type* and discard a warm-up invocation.
|
||
2. **Gating on the wrong batch size.** IQ1 was first excluded outright on the
|
||
strength of its n=4/n=8 regression — but token generation is `ncols_dst == 1`,
|
||
where IQ1_S is 5.2% *faster*. Excluding it would have removed a win from the
|
||
only path that mattered. Check which `n` the workload actually uses.
|