Skip to content

spec : the MTP draft head as a view of the reordered output head, without memory of its own - #46

Merged
Piggidragon merged 2 commits into
mainfrom
llama/draft-head-view
Oct 19, 2026
Merged

Piggidragon merged 2 commits into
mainfrom
llama/draft-head-view

Conversation

@Piggidragon

@Piggidragon Piggidragon commented Oct 19, 2026 •

Copy link
Copy Markdown
Owner

The MTP draft head of --spec-draft-vocab (#36) was a copy of the list's rows of the output head: 168 / 100 MiB on the 4070 / 3060 for the 66k-token list of Qwen3.8 27B. With 262k context in two slots that did not fit: a 95k-token prompt next to a generating slot ran the 3060 out of memory (cudaGraphInstantiate), so the +7% could not be deployed. This makes the draft head a view of the output head, without memory of its own.

How

  • At load the rows of the output head are written again in an order where the list's tokens come first in each device's slice (the tokens of each kind in ascending order, the list spread over the devices like the head's rows, so both GPUs keep their share of the draft head). The draft head is then the first rows of each slice.
  • ggml-backend-meta: ggml_backend_meta_tensor_n_slices / ggml_backend_meta_tensor_slice give the device tensors of a split weight, and ggml_backend_meta_tensor_init_row_views makes a weight whose slice j is a view of the first rows[j] rows of another weight's slice j (the split state callback reports its rows per device). Without split mode tensor the draft head is a plain ggml_view_2d.
  • The target's logits now come in the head's row order. llama_context puts them in token order when they are first read (llama_get_logits_ith / llama_get_logits, all rows at once so a row pointer is followed by the next rows as before), into a buffer of its own (~60 us per row of 248k tokens); a decode whose logits nobody reads costs nothing, and the samplers are unchanged.
  • The view needs the head as a tensor of its own in the plain buffer type of a device whose buffers keep a weight's rows as they are: CUDA, ROCm, MUSA, Vulkan, Metal, or a meta device over them under -sm tensor. Tied heads, heads in host memory, row-split buffers and OpenCL/CANN/SYCL (they store some weights transformed as a whole) keep the copy.
  • Under -sm tensor tokens move between the GPUs (the list is spread like the head's rows, so both keep their share of the draft head). On GPUs of different types the logits of moved tokens differ from those without the list in the last bits: up to ~2e-6 on the 4070 + 3060, argmax unchanged; on one GPU they are bitwise equal.
  • Guards: backend samplers are not used with such a model (their device logits are in row order), LoRA adapters of the output head are refused, and the model saver asserts (the rows in the buffers are reordered).
  • A context with llama_context_params::draft_vocab no longer has to be an MTP context (its output head projects onto the draft vocabulary); the test uses that.
  • ggml-meta fix found by the new test: a tensor with one element along its split dimension kept the full stride on a device without elements, and reading it went past its end (draft logits of a one-token list under -sm tensor).

Tried and dropped: reordering every row in place when the context synchronizes (~90 us per row, -1.4% against the copy), and reporting the rows in head order with their tokens through the sampled-candidates API (no reorder at all, but -11%: the penalties sampler's fast path needs the candidates in vocabulary order).

Results (Qwen3.8 27B UD-Q4_K_S, 4070 + 3060, -sm tensor, MTP depth 2, production settings)

mtp-bench (single slot, 384 tokens, 2 reps; mean over write-code, edit-code, explain, list), two runs each for view and copy:

production sampling greedy
no list 59.2 t/s 63.2
copy (#36) 63.7 66.6
view 63.6 66.7

German letter (production sampling): no list 47.2 -> view 50.0 t/s. Acceptance of view and copy is identical in every task.

  • Greedy outputs identical with the copy, the view and no list on these prompts (English, German, Japanese - the last drafts over the whole vocabulary through the fallback); the MTP draft candidates of copy and view identical (1332 of 1332 lines).
  • VRAM after the requests: view 11177 / 11365 MiB = no list; copy 11345 / 11465.
  • 98.6k-token prompt next to a generating slot (production preset with the list): min free 291 / 120 MiB, the same as without the list; the copy ran out of memory here.
  • test-llama-archs -a '^(llama|qwen35|qwen35moe)$' (CPU, each GPU, the Meta configs): all 64 passed, with a new check: a model loaded with a draft vocabulary gives the logits of the same model without it (bitwise on one device, nmse under -sm tensor), and a draft_vocab context gives the full head's logits at the list's tokens, for a list of a third of the vocabulary and one of a single token.
  • After the review fixes: view 63.3 / 66.7 t/s (unchanged), outputs and candidates as above; the reviewer's probes: rows walked from one llama_get_logits_ith pointer now match (before: rows 2.. were zeros).

🤖 Generated with Claude Code

…hout memory of its own

The draft head of --spec-draft-vocab was a copy of the list's rows of the output head (168 / 100 MiB on a 4070 / 3060
for 66k tokens of Qwen3.8 27B), which did not fit next to a 95k-token prompt at 262k context. Now the rows of the
output head are written again at load in an order where the list's tokens come first in each device's slice (spread
over the devices like the head's rows), and the draft head is a view of the first rows of each slice:

- ggml-backend-meta: ggml_backend_meta_tensor_n_slices / _slice give the device tensors of a split weight,
  ggml_backend_meta_tensor_init_row_views makes a weight of views of the first rows of each slice of another one
  (its split state from the llama callback); without split mode tensor a plain view
- llama_context: the target's logits come in the head's row order; a row is put in token order when it is read
  (llama_get_logits_ith / llama_get_logits), into a buffer of its own, so unread rows cost nothing
- only for a head of its own in the plain buffer type of a device (the meta buffer type under -sm tensor), else the
  copy as before; backend samplers are not used with such a model, LoRA adapters of the output head are refused,
  the model saver asserts
- test-llama-archs: a model loaded with a draft vocabulary gives bitwise the logits of the same model without it

Qwen3.8 27B, 4070 + 3060, -sm tensor, MTP 2: generation as with the copy (63.6 vs 63.7 t/s, no list 59.2), greedy
outputs and MTP draft candidates identical to the copy, VRAM = no list; a 98.6k prompt next to a generating slot keeps
the min free VRAM of the config without the list (291 / 120 MiB).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…thout elements

Review of #46:

- The view is taken only where device buffers keep a weight's rows as they are (CUDA, ROCm, MUSA, Vulkan, Metal, and a
  meta device over them). OpenCL, CANN and SYCL store some weights transformed as a whole, so a partial write or a
  view of the first rows would be wrong there; they keep the copy.
- The logits API puts all rows in token order on its first use instead of only the requested one: a row pointer of
  llama_get_logits_ith is followed by the next rows again, as documented (perplexity and imatrix walk rows from one
  pointer). The buffer only grows, no zero fill per decode.
- Under split mode tensor tokens move between the devices; on GPUs of different types (4070 + 3060) the logits of
  moved tokens differ in the last bits (up to ~2e-6, argmax unchanged in the probes). Documented; the test compares
  with a tolerance there and bitwise on one device.
- test-llama-archs: a context with draft_vocab checks the draft head's logits against the full head at the list's
  tokens, also with a one-token list (no draft head rows on the first device under split mode tensor). For that a
  draft_vocab context no longer has to be an MTP context: its output head projects onto the draft vocabulary.

ggml-meta: the slices of a tensor take the strides of the dimensions outside the split one scaled by their share. With
one element along the split dimension the next dimension has the same stride and was not scaled, so a slice without
elements kept the full stride and a read of it went past its end (the new test: draft logits of a one-token list
under split mode tensor). Dimensions after the split one with the same stride are scaled now.

ggml: ggml_backend_meta_dev_n_simple / ggml_backend_meta_dev_simple give the simple devices of a meta device.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Piggidragon

Copy link
Copy Markdown
Owner Author

Review fixes in 26544da: (1) the -sm tensor rounding on mixed GPUs is documented and the test tolerates it, (2) the logits API orders all rows at once, so a row pointer walks the next rows again, (3) the view only on CUDA/ROCm/MUSA/Vulkan/Metal (and meta over them), (4) the test now multiplies with the draft head itself; it found a ggml-meta stride bug for slices without elements, fixed in the same commit. test-llama-archs llama|qwen35|qwen35moe: 64/64.

🤖 Generated with Claude Code

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.

1 participant