Skip to content

Drop the row-major weight duplicate for Q8_0 (blocked on fused decode still reading it) #401

Description

@kkokosa

Follow-up to #397 (Part C). #398, #399 and #400 are merged; this is the remaining piece.

Update: the unfused R4 decode approach has been measured and rejected (+16.56% on decode, 3/9 paired wins) — the dispatch fusion is worth ~17%. A separate finding means byte-identical output is not achievable by any decode-side approach. Revised acceptance criteria and the llama.cpp comparison are in this comment, which supersedes the criteria below.

The approaches below were originally numbered "option 1/2/3"; that earlier comment still uses those numbers. The mapping is: option 1 = R4 fused decode, option 2 = unfused R4 decode, option 3 = partial retention.

Goal

Stop holding Q8_0 linear weights in two layouts at once. Today every repackable weight exists as both the memory-mapped row-major original and an R4-interleaved copy in committed memory, roughly doubling peak footprint.

Measured on bartowski/Llama-3.2-1B-Instruct-GGUF Q8_0 (1,260 MiB file), sampling the process every 25 ms:

reported Weights (mapped) 1,252 MiB
reported Repacked (R4, committed) 1,252 MiB
OS peak private 1,414 MiB
OS peak working set 2,690 MiB

Dropping the row-major copy for Q8_0 would take peak working set to roughly 1.4 GB — about half.

Where things stand after #400

#400 routed Q8_0 prefill through GemmR4TiledQ8_0, so multi-token work no longer touches row-major. That was the prerequisite for this issue, but it did not finish the job.

Auditing every consumer of the row-major pointers in TransformerModel:

Projection Prefill Decode
Q, K, V R4 (#400) row-majorFusedQkvDecode -> MatMul.FusedDecodeGemv3
Gate, Up R4 (#400) row-majorFusedGateUpDecode -> MatMul.FusedDecodeGemv2
O, Down, LM head R4 R4 (GemmInterleaved, n=1)

So 5 of 7 projections per layer still read row-major on every decode step. The fused decode kernels exist to batch three (or two) GEMVs into a single pool.Dispatch(), saving dispatch overhead per layer — and they take row-major pointers (MatMulFusedDecode.cs, e.g. case QuantizationType.Q8_0: GemvQ8_0(weights, ...)).

The row-major copy therefore cannot be dropped today. Removing it would break decode.

Candidate approaches

R4 fused decode — the target

Port FusedDecodeGemv3/FusedDecodeGemv2 to consume the interleaved layout, dispatching to ComputeRowsQ8_0Interleaved per projection inside one pool dispatch. Keeps the fusion benefit and frees row-major. Most work, best outcome.

Unfused R4 decode — measured, rejected

Route Q/K/V/Gate/Up decode through the existing GemmInterleaved n=1 path, which is already R4, giving up the fused dispatch. Much smaller change. Measured at +16.56% on decode (3/9 paired wins) — not viable. Its value was diagnostic: it established that the dispatch batching is worth ~17%, which is the bar the R4 fused decode has to clear.

Partial retention — not recommended

Keep row-major only for the tensors the fused path needs and drop it for the rest. Saves little and complicates the loader; listed for completeness.

Also still holding row-major

Independent of decode, these keep row-major alive regardless:

  • Rows below InterleavedMinRowBytes (1024) fall back in both GemmInterleaved and GemvInterleaved. For Q8_0 that is roughly K < 964.
  • K-quants and Q5_0 use row-major for prefill entirely — GemmR4TiledQ8_0 is Q8_0-only, and the K-quant interleaved wrappers were a measured regression in Step 26 (Step 26: Outer-product tiled matmul — kernels + investigation (blocked on AVX2) #61). Porting the perf(cpu): route Q8_0 prefill through an L2-tiled R4 GEMM #400 tiling to ComputeRowsQ4_KInterleaved and friends is the equivalent follow-up for those formats and would benefit Q4_K_M models, which is what most users run.
  • token_embd.weight is deliberately excluded from repacking (random row access) and should stay mapped. For this model it is ~266 MiB and a 611-token prompt touches at most 611 of 128,256 rows.

Note that for tied-embedding models (TransformerWeights.cs, LM head aliases token_embd.weight) that tensor is currently resident in both layouts.

Acceptance criteria

  • For a Q8_0 model where every linear weight is repackable and above the row-stride threshold, no forward-pass path reads the row-major linear weights.
  • The repacked buffers are built without requiring the row-major copy to stay resident afterwards — i.e. the mapping may be released or left cold for those tensors.
  • Peak working set for Llama-3.2-1B-Instruct Q8_0 drops materially against the 2,690 MiB baseline above; before/after recorded in the PR using the same sampling method.
  • dotllm run --json memory reporting (added in fix(cli): count R4 repacked buffers in reported memory #398) stays accurate after the change.
  • Decode tok/s does not regress — must be measured, paired and interleaved, on both .NET 10 and .NET 11. The unfused R4 decode approach failed this by +16.56%.
  • Prefill tok/s does not regress against post-perf(cpu): route Q8_0 prefill through an L2-tiled R4 GEMM #400 numbers.
  • Byte-identical generated output for a fixed-seed greedy run. Disproven — not achievable by any decode-side option, because the two layouts require kernels with different accumulation order and decode is autoregressive. See the linked comment for the replacement criterion.
  • Existing kernel tests pass; new coverage for whichever decode path is introduced.

References

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions