# round 30 track a: mm_down3<Q2_0> decode + mm_gather_h sizing

## result

- patch: `/tmp/opus_r30_moemm/opus_r30_moemm.patch` (sha256 `befa6043a59aa31b7eff35d4d90bb1d0a85312dab3ef33d7a860b15a263d7d79`), `git -C vendor/strata apply --check` exits 0. touches only `W4<T_Q2_0>` (moe_mm.metal 519-533) and the gather sweep in `moe_mm_parity.cpp`. window.cpp unchanged.
- **ship: fp16 exponent-trick Q2_0 decode.** down GEMM `mm_down3<Q2_0,16,64,32,2>` latency **-10.5% at T=2048, -8.3% at T=1024, -2.3% at T=512**; bonus: Q2_0 gate/up (`--gu Q2_0 --gu-bench`, T=2048) -8.1%.
- **bitwise:** HS/YE/Y memcmp PASS vs frozen baseline at T=512/1024/2048; plus an exhaustive GPU check of the new decode against the old fp32 one over all 65536 fp16 `d` (subnormals, ±0, inf, NaN included) × 256 code bytes: 0 / 16,777,216 mismatches (`/tmp/opus_r30_moemm/q2chk/`). CPU-reference parity (`-T 16`, gu Q2_0 and IQ2_S) PASS.
- the task's scale-hoisting idea (variants A/B/C) is **not** a win: the compiler already CSEs the repeated `d` load; hoisting across `op.run` loses to register pressure.
- **mm_gather_h: no change.** it's write-bandwidth bound; all threadgroup sizes are within ~1% of each other.

## the change

```
static inline half4 dec(uint q, half d) {
    const uint2 c = (uint2(q | (q << 14), (q >> 4) | (q << 10)) & 0x00030003u) | 0x64006400u;
    return (as_type<half4>(c) - 1025.0h) * d;
}
```
the four 2-bit codes are spread into four ushort lanes and OR-ed into the fp16 pattern of 1024 + c, so `- 1025` gives c - 1 exactly, and (c - 1) ∈ {-1,0,1,2} times an fp16 d is exact in fp16 (or overflows to the same inf the fp32 → fp16 path gives). that drops 4 int→float + 1 float4→half4 conversion per half4 and does the math in 16-bit. the old comment "fp16-math decode measured slower" was about a different fp16 form; this one wins. runtime compiles with safe math (mtl_rt), and the exhaustive check used that same compile path.

## measurements (apple m5, `moe_mm_parity --v3 --gu IQ2_S --dn Q2_0 --tile 16,32,32,4 --dtile 16,64,32,2 -T N --ab <baseline> --dn-bench`, A = candidate, B = baseline, µs median of interleaved pairs)

| variant | T | A | B | latency change |
|---|---:|---:|---:|---:|
| A: share `d` per row in-loop | 2048 | 7918 | 7915 | 0.0% (already CSE'd) |
| B: hoist d + 4 code bytes for both halves | 2048 | 8392 | 7932 | +5.8% (worse) |
| C: B + prefetch next block over 2nd op.run | 2048 | 8920 | 7949 | +12.2% (worse) |
| **D: fp16 exponent decode (shipped)** | 512 | 3459 | 3537 | **-2.2%** |
| **D** | 1024 | 4487 | 4891 | **-8.3%** |
| **D** | 2048 | 7113 | 7950 | **-10.5%** |
| F: D + full unroll when n_ff == 640 | 2048 | 64234 | 7131 (D) | 9x worse (spills) |
| H: D + hoisted half d / codes | 2048 | 7157 | 7122 (D) | +0.5% (worse) |

final-source numbers repeated twice with the same result (2048: 7120/7967, 7113/7950; 1024: 4498/4903, 4487/4891). one early D run hit heavy gpu contention (server traffic): absolute numbers inflated, but the interleaved ratio still pointed the same way. kprof scaling (312.4 ms × ~0.9-0.1) guesses ~25-33 ms of prefill GPU time, not measured end to end (no full-model runs per the rules).

## mm_gather_h (target 2)

`--gather-bench T` (sweep now 32/64/80/128/160/256, 7 interleaved samples), candidate vs baseline kernel, µs per dispatch:

| T (rows) | 32 | 64 | 80 | 128 | 160 | 256 |
|---|---:|---:|---:|---:|---:|---:|
| 512 (8432) | 286 | 274 | 277 | 273 | 275 | 273 |
| 1024 (14352) | 505 | 497 | 506 | 497 | 500 | 497 |
| 2048 (24576) | 1205 | 1198 | 1204 | 1198 | 1197 | 1194 |

XS memcmp PASS everywhere. 24576 rows × 5120 B = 126 MB written in ~1.19 ms ≈ 105 GB/s plus reads, which is the write roofline here; the "idle 75% of the second wave" doesn't matter because the threads are waiting on memory either way. tg 256 is as good as anything, so window.cpp keeps it. the real win would be to skip the copy (gate/up reading xh through srt_tok), which is outside this track.

## not done / next

- other Q2_0 decoders outside my lines (e.g. any W8<T_Q2_0> or the moe.metal / gemv Q2_0 paths) may take the same trick; worth a follow-up with the same exhaustive check.
- each invocation stayed under 10 s (the max was 7.4 s), except one CPU-reference parity run at `-T 256` that I killed at 30 s; I reran it at `-T 16` (1.8 s). no :8080 or strata_window_cli.
