metal: fix the recurrent-state snapshot ring's CPU fallback and wide-row set_rows - #118
Conversation
06131c2 to
a376425
Compare
There was a problem hiding this comment.
Pull request overview
Improves Metal recurrent-state snapshot performance and adds rollback validation harnesses.
Changes:
- Fixes Metal IM2COL selection and adds wide-row
SET_ROWS. - Keeps recurrent convolution snapshots GPU-resident.
- Adds single- and multi-sequence rollback harnesses.
Reviewed changes
Copilot reviewed 11 out of 11 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
tests/test-backend-ops.cpp |
Adds wide-row and 1D IM2COL cases. |
src/models/delta-net-base.cpp |
Makes snapshot inputs contiguous. |
ggml/src/ggml-metal/ggml-metal.metal |
Adds wide-row Metal kernels. |
ggml/src/ggml-metal/ggml-metal-ops.cpp |
Dispatches wide-row kernels. |
ggml/src/ggml-metal/ggml-metal-impl.h |
Defines wide-row kernel arguments. |
ggml/src/ggml-metal/ggml-metal-device.h |
Declares pipeline lookup. |
ggml/src/ggml-metal/ggml-metal-device.cpp |
Registers pipelines and adjusts IM2COL selection. |
examples/rs-rollback/rs-rollback.cpp |
Adds single-sequence rollback harness. |
examples/rs-rollback/rs-rollback-multi.cpp |
Adds multi-sequence rollback harness. |
examples/rs-rollback/CMakeLists.txt |
Builds rollback executables. |
examples/CMakeLists.txt |
Registers the new examples. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
|
|
||
| const int64_t KHW = is_2D ? ne00*ne01 : ne00; | ||
|
|
||
| if (KHW <= 1024) { |
There was a problem hiding this comment.
This was already addressed in d451930, which landed after the commit this review ran against (a376425). The fixed 1024 threshold is gone. ggml_metal_op_im2col now builds the normal pipeline first, asks it for its own limit via ggml_metal_pipeline_max_theads_per_threadgroup, and derives use_ext from that, then passes the flag into ggml_metal_library_get_pipeline_im2col. The kernel choice and the dispatch geometry come from one device-aware decision, so they cannot disagree on a device whose limit is below 1024.
| n_pos++; | ||
| since++; | ||
|
|
||
| if (with_rollback && since >= stride && (int) out.size() + rewind < n_predict) { |
There was a problem hiding this comment.
Also fixed in d451930. rs-rollback.cpp now counts rollbacks and fails when none happened, rather than reporting PASS on a run that never entered the branch. The failure message names the condition, that n_predict has to exceed stride plus rewind.
| } | ||
| since++; | ||
|
|
||
| if (with_rollback && since >= stride && (int) out[0].size() + rewind + 2 < n_predict) { |
There was a problem hiding this comment.
Same fix in d451930. The multi harness tracks n_rollbacks, and the result is now (n_rollbacks > 0) and (total_mismatch == 0), so a run too short to roll back reports FAIL instead of a vacuous PASS.
| if (n_seqs > (int) prompts.size()) { | ||
| n_seqs = (int) prompts.size(); | ||
| } |
There was a problem hiding this comment.
Fixed in d451930. The argument check now rejects n_seqs <= 0, rewind <= 0, stride <= 0 and n_predict <= 0 up front, before the prompt and output vectors are built, so run() can no longer index out[0] on an empty vector.
khosravipasha
left a comment
There was a problem hiding this comment.
Could you rebase this onto prism-v7 and retarget it there? The earlier base switch without a rebase pulled the entire old prism history into the diff, which is why it was flipped back for now.
From a quick check the fixes are still needed on prism-v7: supports_op still requires a contiguous im2col src1 (ggml-metal-device.m, GGML_OP_IM2COL case), and the delta-net conv-state write path is unchanged, so the ring-on CPU fallback should reproduce there. Heads up that the Metal shaders moved to per-op files under ggml/src/ggml-metal/kernels/ on that branch, so the ggml-metal.metal hunks need the same relanding treatment #121 went through.
Once it is on prism-v7 we will test on M5 Max and the CUDA box and merge.
…_rows Rebased onto prism-v7. The previous branch was based on prism, and a base switch without a rebase pulled the old prism history into the diff. im2col: the kernel choice and the dispatch geometry disagreed. The normal kernel is dispatched with an (N, KH, KW) threadgroup and the ext kernel with the CHW-folded grid, but the two decisions were made independently, one against a fixed 1024 and one against the pipeline's own threadgroup limit. On a device whose limit is below 1024 that pairs the normal kernel with the ext dispatch. The caller now derives the branch once from the pipeline's own limit and passes it in. set_rows: wide f32 rows, such as recurrent-state snapshots, gave one threadgroup per row, which cannot saturate bandwidth for rows of 100k+ elements. Adds kernel_set_rows_f32_wide, which tiles each row across threadgroups and copies with float4. Guarded on alignment and contiguity. Also adds two rollback correctness harnesses under examples/rs-rollback. They count rollbacks and fail when none happened, so a run too short to roll back cannot report a vacuous pass. Porting notes for this branch: - the Metal shaders moved to per-op files, so kernel_set_rows_f32_wide was relanded in kernels/quantize.metal rather than cherry-picked - get_pipeline_set_rows now takes the op, so the wide variant matches it - test_set_rows gained a src_type parameter, so the new wide cases use it The delta-net conv-state change from the old branch is NOT included. It needs the rotating snapshot ring in llama_memory_recurrent, which this branch does not have. See the PR discussion. test-backend-ops on M5: SET_ROWS 175/175, IM2COL 93/93, 3/3 backends. The wide kernel is confirmed to compile and run on the 4096-wide cases.
d451930 to
ca48b80
Compare
|
Rebased onto prism-v7 and retargeted. The diff is now +607/-13 across 10 files instead of the old prism history. Relanding notes, as you predicted: One thing did not come across, and it is worth flagging because you expected it to. The delta-net conv-state change is not included. It writes only the snapshots this ubatch produced, and that is only correct because the ring head rotated backwards, which needs Related: the im2col stranding you expected to reproduce there does not arise the same way. The conv-state write path on prism-v7 uses Testing on M5: SET_ROWS 175/175, IM2COL 93/93, 3/3 backends. I also checked that The old branch tip is kept at |
khosravipasha
left a comment
There was a problem hiding this comment.
LGTM to merge now. We can test/optimize further after we get all change in.
What
Three commits that make the recurrent-state snapshot ring (
n_rs_seq > 0) usable on theMetal backend, plus two correctness harnesses for the rollback path.
Why
With the snapshot ring enabled, decode on a 64-layer hybrid gated-delta-net model collapsed
by about 44 percent (22.4 tok/s against a 40.5 tok/s ring-disabled baseline). Profiling with
GGML_SCHED_DEBUG=2showed the run was CPU bound, not GPU bound: 87s of user CPU time for a17.7s wall-clock run, with about 5 cores busy. The graph dump explained it. With the ring off,
one node runs on the CPU backend. With the ring on, 49 do, and 48 of those are
IM2COL, oneper recurrent layer. Graph split markers went from 16 to 784.
There turned out to be two independent causes, and fixing either one alone leaves most of the
regression in place.
How
1.
metal: fix 1D im2col kernel/dispatch mismatch + wide-row f32 set_rowsTwo kernel-level bugs. The im2col pipeline was selected by
ne00*ne01 <= 1024while thedispatch geometry was chosen by
KH*KW. For 1D im2colne01is IC rather than KH, so any 1Dim2col with
KW*IC > 1024pairedkernel_im2col_extwith the wrong dispatch and producedgarbage. Separately, the generic
set_rowskernel assigns one threadgroup per destination row,which cannot saturate bandwidth for the very wide rows the ring writes.
2.
metal: keep the recurrent conv-state write on the GPUbuild_conv_state()materializes then_writeoverlapping conv windows with a singleggml_im2colovertail, a stridedggml_view_3dofconv_input. The Metal backend acceptsim2col only when its input is contiguous:
so the op fell to the CPU backend, one node per recurrent layer, splitting the graph at every
one of them. Making the input contiguous with
ggml_contfirst keeps it on the GPU. At decoden_writeismin(n_seq_tokens, n_rs_seq + 1) == 1, where im2col would only repackage thatone window, so the reshape is taken directly.
CPU-resident nodes with the ring enabled go from 49 to 1, and graph split markers from 784
back to 16.
3.
examples: add recurrent-state rollback correctness harnessesA forward-only generation never reads the snapshot slots back, so it cannot catch a bad
snapshot write.
llama-rs-rollbackgenerates a reference stream, then regenerates whileperiodically over-generating tokens, calling
llama_memory_seq_rm()to discard them, andcontinuing.
llama-rs-rollback-multidoes the same across concurrent sequences where onlysequence 0 rolls back, which covers the failure mode a single-sequence test cannot see, since
the ring's snapshot rows are seq-major.
Numbers
Metal, M5 Pro, 64-layer hybrid gated-delta-net model, with the ring enabled (
n_rs_seq = 7)against the ring-disabled baseline.
Decode, isolating the two kernel fixes (baseline 40.5 tok/s):
The two are strongly super additive. Removing the CPU fallback is what lets the faster
set_rowskernel actually pay off.Full picture with both applied:
Prefill is back to parity. The residual
pp8gap is inherent snapshot work rather than afallback: an 8 token batch writes 8 snapshots, one per token, where
pp512amortizes the same8 across 512 tokens.
Testing
test-backend-ops test -b MTL0passes for IM2COL, SET_ROWS, GATED_DELTA_NET, CONT, CPY andSSM_CONV. Commit 1 adds a 1D im2col case (KW=3, IC=1024) that fails before it.
quantizations, at
n_rs_seqof 1 and 7.As a control, at
n_rs_seq = 0the partial rollback is refused, so the tests are notpassing vacuously.
zero and 100 percent same-top-1, which is about ten times below the evaluation noise floor.
Note this needs
-ub 1, since a default perplexity run hasn_write > 1and would exercisethe im2col path instead.
Notes for review
The remaining
pp8overhead is the real cost of keeping a rollback ring, not a bug: a batch of8 writes 8 snapshots. For block-verify speculative decoding at k=8 that puts break-even at
about 55 percent acceptance, down from about 62 percent before these commits.
Nothing in CI exercises this. The
ubuntuandwindowsjobs have no Metal runner, so thekernels here are built and tested nowhere in the matrix, and the two new harnesses are
examples/that only build on macOS. All the evidence above is from a local M5 Pro run.