ab-qwen-profiling
Run a controlled A/B between llama-server variants on the local Qwen MTP stack — pick the right workload, switch between screening/sustained/end-to-end workloads, get medians with cold restarts and in
SKILL.mdab-qwen-profiling/SKILL.md
GitHubA/B profiling for the Qwen MTP stack
Answer "is variant B faster than variant A, and by how much" in a way that
survives scrutiny. When the answer is "slower," hand the traces to
rocm-kernel-trace to find out which kernels are responsible.
Division of labour:
| Skill | Answers |
|---|---|
benchmark-qwen |
How fast is the deployed service? Metric definitions, Pi end-to-end. |
ab-qwen-profiling (this) |
Is variant B faster than A? Which workload proves it? |
rocm-kernel-trace |
Why — which kernel categories moved, in ms/token. |
1. Pick the workload
All workloads use the same calibrated 20,000-token prompt
(assets/qwen-prompt-20000.txt). Only the output length changes, and it changes
the answer — decode TPS at 128 tokens is not decode TPS at 1024.
| Workload | --tokens |
Time/sample | Use it for |
|---|---|---|---|
| Screening | 128 | ~40 s | Fast go/no-go. A loser here is dead; a winner is not yet proven. |
| Sustained | 1024 | ~90 s | The acceptance gate. Report medians from this. |
| Trace-matched | 512 | ~60 s | Only when pairing with a rocm-kernel-trace run — must match the trace's output length. |
| Pi end-to-end | — | ~2 min | Final realism gate. Use benchmark-qwen's benchmark-pi-agent.sh. |
Short runs flatter a candidate: in the Q4 KV study the same change measured +10.66% at 128 tokens but +21.59% at 1024. Never accept a change on a 128-token result alone.
Switching workload
Just change --tokens. Everything else — prompt, temperature 0, seed 42,
cold restart — stays fixed:
scripts/ab-run.sh --root /tmp/ab-screen --tokens 128 --samples 3 --variant ...
scripts/ab-run.sh --root /tmp/ab-sustained --tokens 1024 --samples 3 --variant ...
[!TIP] Evaluating Pure Decode Performance: When the primary objective is evaluating decode throughput / latency, enable prompt cache (
cache_prompt=trueor warm up prompt cache). This eliminates the prefill latency and GPU power/thermal noise during prefill, while preserving the full 20,000-token KV cache memory footprint and attention workload for every generated decode token.
A trace-matched run is mandatory before scaling any trace. compare_traces.py
needs real ms/token from a benchmark of the same output length as the trace. A
512-token trace scaled by a 1024-token TPS is simply wrong.
If the prompt asset is missing or the model changed
assets/qwen-prompt-20000.txt is text, not token ids, so a tokenizer, model, or
chat-template change requires recalibration:
python3 scripts/bench_20k_prompt.py calibrate 8101 qwen3.6-35b-q4 /tmp/new-prompt.txt
It iterates until usage.prompt_tokens == 20000 exactly. Verify that on every run
regardless — summarize_ab.py rejects the comparison if token counts differ.
2. Define the variants
scripts/ab-run.sh --root /tmp/my-ab --tokens 1024 --samples 3 \
--variant 'f16prod=/home/chihmin/llama-mtp-deploy/gfx1151-moe-down-weight-07689bc/bin/llama-server' \
--variant 'q4tiled=/tmp/llama-q4kv-clean/build/bin/llama-server|kv=q4_0|env=GGML_CUDA_EXPERIMENTAL_GFX1151_Q4_KV_TILED=1'
Spec format: label=binary[|kv=TYPE][|env=VAR=VAL][|extra=ARGS]. First variant is
the baseline.
The driver handles: performance profile, stopping qwen-mtp/gemma-mtp,
isolated port 8101, cold restart per sample, ten consecutive idle-GPU samples
before timing, interleaved run order, and restoring both services on exit.
FlashHead variants
FlashHead lives in the gguf (three extra tensors) and in the binary (GGML_OP_MUL_MAT_ROWS),
so a variant spec has to name both, plus the probe count:
M=/home/chihmin/models/Qwen3.6-35B-A3B-selective-Q4_0-proof
scripts/ab-run.sh --root /tmp/ab-fh --tokens 512 --samples 3 \
--variant "A_orig=/home/chihmin/llama-mtp-opt/build/bin/llama-server|model=$M/Qwen3.6-35B-A3B-UD-Q4_K_M-selective-Q4_0.gguf" \
--variant "B_flash=/home/chihmin/llama-mtp-opt/build/bin/llama-server|model=$M/Qwen3.6-35B-A3B-UD-Q4_K_M-selective-Q4_0-flashhead.gguf|env=LLAMA_FLASHHEAD_PROBES=256"
Keep the binary and the KV type identical on both sides. The two ggufs are byte-identical
apart from the FlashHead tables, and the graph falls back to the dense head when they are
absent, so this spec isolates FlashHead and nothing else. Comparing the flashhead gguf against
the deployed llama-mtp-deploy/... binary instead mixes in the research branch and the KV
type - that version measured +6.34% where the clean one measured +10.84%.
Measured 2026-08-04, 512 tokens, 3 interleaved cold samples:
variant decode tps runs median ms/tok MTP% vs A
A_orig 64.83 61.75 64.14 64.14 15.591 96.764 +0.00%
B_flash 70.00 71.20 71.09 71.09 14.067 98.129 +10.84%
Acceptance rises slightly (0.9716 -> 0.9842) because only the draft head is tailored; if a FlashHead variant ever shows acceptance falling, the target head is being approximated too and the comparison is measuring something else.
Confirm the feature actually engaged before trusting any FlashHead number - the tables are optional, so a wrong binary or a missing env var degrades silently to the dense head:
grep -m1 'FlashHead tables found' /tmp/ab-fh/B_flash-1.server.log
LLAMA_FLASHHEAD_PROBES is read at graph build time, so sweeping it needs one variant per
value - it cannot be changed on a live server.
3. Build a candidate binary safely
Never git checkout the research worktree — it holds uncommitted work.
git -C /home/chihmin/llama-mtp-opt worktree add --detach /tmp/llama-cand <commit>
git -C /home/chihmin/llama-mtp-opt diff <commit> <branch> -- path/to/only.cu > /tmp/cand.patch
cd /tmp/llama-cand && git apply /tmp/cand.patch
cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release -DGGML_HIP=ON \
-DAMDGPU_TARGETS=gfx1151 -DGGML_HIP_GRAPHS=ON -DGGML_HIP_NO_VMM=ON \
-DGGML_BUILD_TESTS=OFF -DLLAMA_BUILD_TESTS=OFF -DLLAMA_BUILD_EXAMPLES=OFF \
-DLLAMA_BUILD_SERVER=ON \
-DCMAKE_HIP_COMPILER=/opt/rocm-7.2.2/llvm/bin/clang++ \
-DCMAKE_HIP_FLAGS="-D__AMDGCN_WAVEFRONT_SIZE=32 -DHIP_ENABLE_WARP_SYNC_BUILTINS"
cmake --build build -j 16 --target llama-server
Both -DCMAKE_HIP_* flags are required; without them the build fails with
use of undeclared identifier '__AMDGCN_WAVEFRONT_SIZE'.
Isolate the patch. Applying only the files you want beats inheriting a whole
dirty branch: a snapshot branch carrying an unrelated broken change produced a
binary that aborted at warmup (mmvq.cu:1061 GGML_ASSERT(!ids || dst->ne[2] == 1))
for every KV type, wasting a full benchmark round. ab-run.sh surfaces the first
GGML_ASSERT line when a server dies during load.
Prefer an immutable artifact under /home/chihmin/llama-mtp-deploy/<name>/ when
one already exists — more reproducible than rebuilding an old commit today.
4. Read the result
python3 scripts/summarize_ab.py /tmp/my-ab
Median is the comparison statistic, never best-of. The summary also prints the
--variant label=<trace_dir>:<ms/tok> lines that rocm-kernel-trace needs.
Reject or rerun when: prompt tokens differ across variants; the sustained run does
not finish at max_tokens; MTP acceptance differs materially while raw TPS is
being used as proof; another GPU workload was live; or services were not restored.
5. Hand off to kernel attribution
A slower variant is a question, not a conclusion. Trace both sides at 512 tokens and attribute the difference:
T=~/.claude/skills/rocm-kernel-trace/scripts
$T/trace-decode.sh --label f16 --root /tmp/tr --bin BIN_A
$T/trace-decode.sh --label q4tiled --root /tmp/tr --bin BIN_B --kv q4_0 --env VAR=1
# ...analyze_trace_db.py + categorize_kernels.py per side, then:
python3 $T/compare_traces.py \
--variant f16=/tmp/tr/f16:15.444 --variant q4tiled=/tmp/tr/q4tiled:16.945
Read rocm-kernel-trace's SKILL.md before running it — rocprofv3 hangs
llama-server on shutdown, and killing it the obvious way destroys the trace.
Worked example — Q4_0 KV cache, 2026-08-02
Two variants, selective-Q4_0 model, commit 0985f20ae, sustained 1024-token
workload, 3 interleaved cold samples each:
variant KV size decode median vs F16
f16prod 5588 MiB 63.55 tok/s —
q4tiled 1572 MiB 59.93 tok/s -5.7%
The A/B established what: Q4_0 KV saves 4,016 MiB and the tiled FA kernel recovers most of its cost. The trace established why: at 512 tokens, Flash Attention with the tiled candidate returned to baseline (−0.017), leaving the ncols=3/4 global dequant (+0.339) as the largest remaining item.
Caveat carried from the source work and not re-verified here: the tiled path is approximate, not bit-exact — output diverges after ~100 events at 20K/1024. Speed A/B is not a correctness gate; run exactness checks separately before deploying:
- Bit-Exact / Logprob Check: Verify logprobs/tokens match baseline.
- Cosine Similarity Fallback: If bit-exact match cannot be achieved due to accumulation differences in quantized/tiled kernels, verify that the kernel output / logit A/B Cosine Similarity $\ge 0.99$.
Files
| Path | Purpose |
|---|---|
scripts/ab-run.sh |
Interleaved cold-start A/B driver across variants |
scripts/bench_20k_prompt.py |
One timed request; also calibrate mode for the prompt |
scripts/summarize_ab.py |
Medians, percent change, trace scale references, validation |
assets/qwen-prompt-20000.txt |
Calibrated exactly-20,000-token prompt |