opus r28 track a: mtp multi-row fc_hidden batching, mtp.metal unrolls, gm_cat audit #
patch: /tmp/opus_r28_mtp/opus_r28_mtp.patch (git -C vendor/strata apply --check passes against the current dirty tree).
files: metal/src/engine/window.cpp (mtp_encode, the fc_hidden block + CombNormArgs only), metal/src/engine/msl/mtp.metal,
metal/tests/comb_fold_parity.cpp. mtp_chain.metal is untouched: every fixed-trip loop in it already has
unroll(full) / unroll_count(4). scratch tree + build: /tmp/opus_r28_mtp/{strata,build}. all numbers are from Apple M5
(the 24 GB air) with the live :8080 server up, so expect some noise.
1. fc_hidden batching (the change) #
before: for n > 1 (catch-up of a accepted drafts, and mtp_prefill chunks), mtp_encode ran one fc_hidden GEMV
per stream c (HC = 4 of them), each with n rows, plus 3 mtp_xprep dispatches. so the 6.95 MB Q8_0 weight got
read 4 times whatever n was. h was laid out [c][t], with h_stride = MAXT*N.
after: the n*HC streams become GEMV rows in [t][c] order, up to MAXT = 8 per GEMV.
mtp_rms_xprepalways splits now. it prepares the firstplane_tok(= MAXT) stream rows and skips the rest (new guardt >= plane_tok), and it keeps the normalized rows inmhn_only whenn*HC > MAXT.- after that, each further 8-row slice is one
mtp_xprep(stride N, frommhn_) plus one GEMV. mtp_combine_gnormreadsh + (t*hc + c)*n. h, R and xn now share one layout, andh_strideis gone from CombNormArgs (on both host and kernel).
| n | GEMVs before → after | weight reads | dispatches (rms+xp+gemv+comb) | cold GPU time, hidden branch |
|---|---|---|---|---|
| 1 (draft step) | 1 (4 rows) → 1 (4 rows) | 1 → 1 | 3 → 3 | 70.4 → 70.1 µs (same) |
| 2 (a=2) | 4 × 2 rows → 1 × 8 rows | 4 → 1 | 9 → 3 | 211.6 → 101.4 µs (−52%) |
| 3 (a=3) | 4 × 3 rows → 8 + 4 rows | 4 → 2 | 9 → 5 | 220.1 → 155.7 µs (−29%) |
| 4 | 4 × 4 → 2 × 8 | 4 → 2 | 9 → 5 | 228.9 → 190.0 µs (−17%) |
| 8 (prefill chunk) | 4 × 8 → 4 × 8 | 4 → 4 | 9 → 9 | 365.1 → 366.1 µs (same) |
comb_fold_parity 0 mtp bench measures this: the weights cycle through 16 copies so they come from DRAM, the
dispatches run in a barrier chain as in the window, and each number is the median of 9 alternated pairs. two runs
agreed to within 1%. you can't get n = 8 below 4 GEMVs without a GEMV that takes more than 8 rows: gemv.metal's NTOK
goes up to 8. so the "zero xprep" n = 8 idea from the brief isn't possible at MAXT = 8. its dispatch count stays the
same (3 xp + 4 gemv as before).
tiles (gemv_parity --tiles, Q8_0 2560→2560, cold) #
| rows | 32,2,4,1 | 16,1,4,1 | 16,2,4,1 | 32,1,4,1 | 8,1,4,1 |
|---|---|---|---|---|---|
| 4 | 59.9 | 57.2 | 62.0 | 70.7 | 59.0 |
| 8 | 113.9 | 86.0 | 109.7 | 124.8 | 86.5 |
| 3 | 56.0 / 66.0 | 54.7 / 58.7 | 58.7 / 71.0 | ||
| 2 | 54.6 / 56.9 | 54.8 / 56.1 | 55.5 / 57.2 |
the existing tile() choice (rows >= 4 → 16,1,4,1) is already the fastest at 4 and 8 rows, so tile() is unchanged.
bitwise (comb_fold_parity 0 mtp [baseline], ALL PASS) #
- in every case n = 1..8, the batched R and xn are bitwise equal to n separate n = 1 chains. every fc_hidden GEMV
now has ≥ 4 rows, which puts it on one tile (16,1,4,1, never
gemv_direct_x), so a stream's h no longer depends on n. - at n = 1 and n = 4..8, the output is bitwise equal to the old per-stream chain, because it's the same tile.
- at n = 2 and 3 the output differs from the old chain by max |Δxn| 9.5e-7. the old chain ran 2–3 rows on 32,2,4,1, and now these n match the n = 1 draft path. that's the one intended numeric change, and it only affects the catch-up K/V of the accepted cells.
- with the original
mtp.metalas baseline (/tmp/opus_r28_mtp/orig/metal/src/engine/msl/mtp.metal), the per-stream chain on the old kernels with the old [c][t] layout is bitwise equal to the same chain on the new kernels for every n. so the kernel edits (the split guard, the combine index, the unrolls) don't change any value.
not run: a full-model check, which the brief forbade (:8080 is live). strata_metal_kernels and strata_window_cli
compile and link in the scratch build with no new warnings. the expected end-to-end effect is about 110 µs per verify
round with a = 2 and about 64 µs with a = 3, so a few tenths of a percent of tok/s at ~47 ms/token. someone needs to
confirm it in a window_cli A/B once :8080 is free.
2. mtp.metal unrolls #
added #pragma clang loop unroll(full) to the 8-way part[] sums in mtp_rms_xprep and mtp_combine_gnorm. the
summation order is the same, and the baseline A/B above confirms it's bitwise neutral. this is hygiene only: no
measurable time change, since clang very likely unrolled the 8-trip loop already.
3. gm_cat audit (comb_fold_parity 0 mtpcat, Q8_0 2560→N+N, one token, bitwise PASS on every tile) #
| shape | tile | cold: 2 gemv → cat | hot: 2 gemv → cat |
|---|---|---|---|
| k+v 512+512 | 32,1,4,4 (window) | 21.48 → 21.38 (−0.4%) / 22.61 → 22.51 (−0.4%) | +0.2% / +0.4% |
| k+v 512+512 | 32,1,4,1 | +0.9% / +0.8% | +3.4% / +1.5% |
| gate+up 640+640 | 32,1,4,4 (window) | 27.59 → 27.41 (−0.6%) / −0.2% | −0.1% / +0.1% |
| gate+up 640+640 | 32,1,4,1 | +0.6% / +0.8% | +0.1% / +0.3% |
gemv_parity --cat (K=1) agrees: 512 hot +3.3%, cold −0.0%; 640 hot +0.3%, cold +0.2%.
a correction to the brief: shared gate+up 2560→640 at rows = 1 runs at 32,1,4,4 (gemv_catk), not 32,1,4,1. tile()
line 2591 sends every n_out >= 512 to K=4. the r27 "+2.4% hot" only shows up with the K=1 tile, and only hot. at the
window's actual K=4 tile, cat is neutral or slightly ahead, cold and hot. verdict: keep gm_cat as it is. (some
hot numbers drift 18→64 µs between runs, probably GPU clock state with the live server, so trust the cold columns.)
4. tile() check on the other MTP Q8_0 shapes (rows = 1, cold, µs) #
- 2560→512: 32,1,4,4 11.67 · 32,1,4,1 11.73 · 16,1,4,1 12.07 · 16,1,8,4 12.27 · 32,2,4,2 12.37
- 2560→640: 32,1,4,4 15.06 · 32,1,4,1 15.14 · 16,1,4,1 15.19 · 32,2,4,2 15.81 · 16,1,8,4 15.89
- 2560→2560: 32,1,4,4 54.2 · 16,1,4,1 56.7
- 2560→12288 (q_proj): 32,1,4,4 261.8 · 32,1,8,2 267.4 · 32,2,4,1 271.1 · 16,1,4,1 272.1 · 32,2,4,2 281.6
- 6144→2560 (o_proj): 32,1,8,8 120.5 · 32,1,4,4 124.0 · 32,1,8,2 124.9 · 32,2,4,2 126.0 · 16,1,8,4 127.5
- 640→2560 (shared down): 32,1,4,1 13.9 · 32,2,4,1 14.5 · 16,1,4,1 14.6 · 8,1,4,1 14.9 · 4,1,4,1 15.4
tile() already picks the fastest tile on every shape, so nothing changes. one thing left open: fc_embedding at rows = 3 (a = 3 catch-up) uses 32,2,4,1, and 16,1,4,1 was 1–7 µs faster in two runs. but that GEMV runs in the same wave as fc_hidden, so most of the gain is probably hidden. too small and noisy to change without an in-window A/B.
repro #
cmake -S /tmp/opus_r28_mtp/strata/metal -B /tmp/opus_r28_mtp/build -G Ninja -DCMAKE_BUILD_TYPE=Release \
-DSTRATA_GGML_DIR=$PWD/vendor/llama.cpp -DMETAL_CPP_DIR=/tmp/opus_r28_mtp/strata/third_party/metal-cpp
ninja -C /tmp/opus_r28_mtp/build comb_fold_parity gemv_parity strata_metal_kernels
comb_fold_parity 0 mtp /tmp/opus_r28_mtp/orig/metal/src/engine/msl/mtp.metal # parity incl. baseline kernels
comb_fold_parity 0 mtp bench # cold paired timings
comb_fold_parity 0 mtpcat # gm_cat audit
GEMV_AB_COLD=1 gemv_parity --tiles Q8_0 2560 2560 8 16,1,4,1 32,2,4,1 16,2,4,1 32,1,4,1 8,1,4,1