loom-optimize
Skill4 starsChanged 6 days agoMITView on GitHub
Make a Loom LLM port faster (decode, prefill, long context, concurrency) while proving it stays correct — the measure/ablate/change/verify loop, correctness gates (kernel A/B tests, llama.cpp golden, concurrency consistency), and a catalog of optimizations that worked (and ones that didn't) on RDNA3/Strix Halo. Use for "speed up", "beat llama.cpp", "optimize prefill/decode" on a Loom model.
In this repository8 other files it gives its agents.
- loom-benchmarkSkill
- loom-demo-uiSkill
- loom-improveSkill
- loom-orgSkill
- loom-port-llmSkill
- loom-rooflineSkill
- loom-setupSkill
- loomSkill
The file
We split this skill into the rules it gives an agent
7 rules. About 0 are common, 1 is distinctive, and 6 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈1
Distinctive · estimate
This author's own choices. Worth a look.
6
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Distinctive: line 8
Before you start you need:
- Very specific: line 18
Start from the reference: Before measuring anything, read what the closest category A loom-fun package already settled …
- Very specific: line 39
Measure: Run perf.py (short) and perf.py --long (8k/16k/32k), plus c4_steady.py 4.
- Very specific: line 50
Keep the old variant behind an env switch (like GEMM_SKIP_PADDING, GEMM_N_FASTEST, FLASH2_KT.
- Very specific: line 61
Run it with loom-test (see the cookbook in loom-port-llm/reference/).
- Very specific: line 89
When you change the starter's generators, copy starter/ into the work dir first and edit the copy.
- Very specific: line 118
Prefix caching: Start every timed prompt with a nonce, as perf.py does.
≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
Optimize a Loom port without breaking it
Before you start you need:
- a package that passes the golden gate (
loom-port-llm+loom-benchmark); - a baseline row in
perf_log.jsonl; - llama.cpp ROCm numbers on the same GGUF and workloads;
- targets from
loom-roofline(bench/roofline.json;qualify.shwrites it in an end-to-end job). If the user said "optimize this" with no target or time, runloom-rooflinefirst, announce the targets and the budget (section 0 there), then loop here under its rate controller. Don't ask the user to pick a number, and don't stop after the first win.
Start from the reference. Before measuring anything, read what the closest category A loom-fun package already
settled (loom-port-llm/find_reference.py show WORK/reference.json, written by port.sh; or show loom-fun/REPO):
its drafting choice and measured table, execution settings (prefill chunk), llama.cpp baseline arguments, margins
per metric and gates. Try its kept settings first and skip A/Bs it already decided for the same architecture; its
choices are a starting point to re-measure on this model, never a result to copy onto the card.
Fast iteration (the budget is short).
- Build each variant into its own package dir with its own
--model-id, and serve the baseline and the variant in ONE loom-serve (serve.sh NAME PORT amdgpu MEM a/model.json b/model.json), then alternate requests: an interleaved A/B is both faster and more reliable than restarting servers between runs. Use--context 8192for A/B packages (faster to serve, same kernels) and rebuild the winner at full context for the gates. Give that server headroom:--memory_byteswell above the sum of build.py's per-package figures (a tight budget skewed an A/B by model order). - Generator switches (env vars) make a variant a rebuild, not an edit: try those first.
perf.pytakes 1–3 min; for a decision on one metric, time just that metric (e.g.bench_common.stream()on the decode prompt, 3–5 interleaved rounds) and run the fullperf.pyonly on the build you keep.
The loop (one change at a time)
- Measure. Run
perf.py(short) andperf.py --long(8k/16k/32k), plusc4_steady.py 4. Work on the headline metric furthest below itsloom-rooflinetarget, measured in milliseconds per token of gap, not in percent. - Attribute by ablation. Hardware counters are unavailable (
loom-benchmarkcounters are UNIMPLEMENTED on gfx1151). Instead, launch a kernel twice (or skip it) and measure end to end:${CLAUDE_SKILL_DIR}/ablate.py PKG pkg_abl '@attn_decode'writes the copy with its own model id; serve both in one loom-serve and runab_perf.py URL <id> <id>-abl-<name>. The delta is that kernel's cost. Wrong outputs are fine for an ablation build; never ship one. - Change one thing in the generator: kernel shape, fusion, tiling or dispatch.
Keep the old variant behind an env switch (like
GEMM_SKIP_PADDING,GEMM_N_FASTEST,FLASH2_KT; both GEMM switches are on by default,=0turns one off). - Kernel A/B test.
python ${CLAUDE_SKILL_DIR}/ab_kernel.py OLD/program/llm.loom NEW/program/llm.loom KERNEL ab.loom-testputs the old kernel next to the new one asKERNEL_old; its docstring has a case skeleton. Launch both on identical inputs in acheck.caseand compare withcheck.expect.approximate. Include a negative control: an assertion that the output is not the initial fill, because two kernels that both write nothing agree. Put it in a separate case that is expected to fail and runloom-test ab.loom-test --device=amdgpu --xfail=@ab_negative_control=EXPECT/003; the skeleton in ab_kernel.py shows it. Use the real shapes, including edge counts (t=1, t=4, t=63/64, a partial last tile, mixed-sequence tiles, the full context). Run it withloom-test(see the cookbook inloom-port-llm/reference/). - End-to-end gates.
LEVEL=dev gates.sh PKG URL GGUF BENCH_DIR(loom-benchmark):consistency.py: concurrent outputs must equal sequential outputs, and a prompt repeated alone must repeat exactly. This catches mixed-tile, padding and race bugs that single requests never hit (3 of the 8 real bugs the gates ever caught).agent_golden.py: the multi-turn tool loop (rendering, history reuse, stop tokens; thinking on and off). 3–4 of the 8.compare.pyon 12 short prompts and the 1k/2k goldens. Every divergence must be a near-tie (the compare rule,bench_common.near_tie). A new divergence with a large gap is a bug even if the text "looks fine".- a light touch of the rest: 2 images, 5–6 short drafted-vs-undrafted requests, sampling
semantics, a 16k needle.
The llama.cpp goldens come from the cache
qualify.shfilled, so this costs only the Loom side (a few minutes;loom-benchmarkhas the measured times). Kernel tests (step 4) for every new or changed kernel andchat_probe --verifyfor a chat-glue change come first: they are cheaper and more specific. Run the gates only on a build you intend to keep (it won on perf): a reverted build needs no gates. Don't runLEVEL=quick/fullin the loop: they run nightly on the published cards (a 24-runquickloop cost 6.6 GPU-hours and caught nothingdevmisses).
Measure again on the same workloads. Run
perf.pyfrombench/with a label for the build (add--revertedif you discard it). It logs the result and prints the controller verdict by itself. Keep the change only if it wins on the target metric and doesn't regress the others by more than noise (±2–3 %; run twice when it's close). Commit it with the numbers in the message. Keep the work dir (cfg.json, any generator copies you modified, the bench/ logs) under git: rungit initonce in the work dir, and gitignore everything big or regenerated:pkg*/ ktest*/ gguf/ tok/ llamacpp*/ state/ bench/llama_cache/(a trial staged 5 GB of ktest/ and llama.cpp builds andgit addhung). If the user doesn't want commits, skip git and rely onbench/decisions.jsonlplus a copy of each kept generator (starter_<label>/);perf.pythen logs commit-. When you change the starter's generators, copystarter/into the work dir first and edit the copy. Act on the verdict thatperf.pyprints:- CONTINUE: keep going on this path;
- REVIEW: re-ablate, then pivot or stop. Forking from an earlier decision is an option;
- BUDGET: wrap up;
- DONE: move to the next metric.
Record design choices, including the rejected options and their numbers, in
bench/decisions.jsonl, one JSON object per line (report.py renders it):{"ts": "...", "decision": "prefill chunk", "chosen": "2048", "rejected": [{"option": "4096", "measured": "1k -12 %", "why": "slower short prefill"}]}.
Measurement pitfalls
- A busy GPU makes single runs meaningless (check
pgrep -a 'loom-serve|llama-server'): the same build swings 30–50 %. A/B interleaved (Fast iteration, above) with${CLAUDE_SKILL_DIR}/ab_perf.py URL MODEL_A MODEL_B --metrics decode,prefill_1k,prefill_2k --rounds 5: medians, B/A and a WINS/LOSES/TIE verdict against the noise band, logged toab_log.jsonl. Always include the shortest size you report (a chunk size that won at 2k/8k lost 12 % at 1k on flint). Measure llama.cpp right next to the final Loom run, and say in the report that the GPU was shared. - Microbenchmarks lie through the 32 MB MALL. A kernel benchmark whose weights or KV fit in 32 MB shows 2–5× the DRAM bandwidth. Use full-size tensors (all layers) or end-to-end numbers.
- ...and through constant data. Strix Halo's GPU is power-limited: WMMA on constant operands runs
52–57 TOPS, on random operands ~40 (wave32) / ~43 (wave64), for f16 and int8 alike. A prefill GEMM fed
check.generate.fillconstants read 36 TFLOP/s; with the GGUF's real weights and random activations 30 (the model measured 28). Feed compute-bound benchmarks real weights (check.file.read.npy) and random activations before trusting a kernel A/B. - Prefix caching. Start every timed prompt with a nonce, as
perf.pydoes. - Lazy weight load, stale servers on the port, server- vs client-side timing and near-tie
noise: see the
loomhard rules andloom-benchmark.
Catalog: what worked (Strix Halo gfx1151, measured end to end)
reference/optimization-catalog.md has each lever with its rationale and numbers. Decode (bandwidth-
bound): multi-token matvec, adaptive rows per workgroup, Q4_K scale/min folding, fused QKV and paired-row
gate/up, argmax head, adaptive split-K attention, F16 KV. Prefill (compute/attention-bound): WMMA GEMMs on
row-scaled f16 (N-fastest order: A/B it per port), padding-free tiles, flash2, listed fallback, per-token
RoPE + KV store, fused norm + row scale, prefill chunk size. Apply them in catalog order.
Settled for starter ports (dense / Qwen3.5 hybrid, ≤ 4B): don't re-run these A/Bs. Fresh-agent
trials re-tested them on Qwen3.5-0.8B and the defaults won or tied every time:
GEMM_N_FASTEST=0 (slower prefill, 3 trials), MV_WG1=1024 (no decode win), --prefill 4096
(short prefill −20–37 %; 2048 ≈ 1024 within noise). On a starter port, spend the budget on fusion
(fewer launches per layer: fused QKV or norm fusion, which you write in a copy of the generator; pairing Qwen3.5's tiny GDN alpha/beta matvecs
measured neutral) or on the long
prefill path. Bigger or MoE models: the chunk size is worth one A/B measured at 1k too (4096 helped
one MoE port; 2048 and 4096 both cost 12-18 % at 1k on flint).
Folded into the starter from trials (already on; don't redo): the gated delta-net conv and
history-commit kernels spread channels over GDN_CB workgroups per token when fewer than 64
tokens are in flight (Qwen3.5-0.8B decode +18 %, identical output; GDN_CB=1 restores the old grid).
Tried and did not help (don't repeat without a new reason): see the catalog's "Did not help".
Speculative decoding (on by default when the GGUF has an MTP block or a DFlash drafter exists, and n-gram
prompt lookup is a candidate for every model; loom-port-llm/starter/draft/README.md). qualify.sh already chose the method and depth by measurement
(bench/draft_tune.json) and gave the llama.cpp baseline its own best drafting
(bench/llamacpp_spec.json), so decode targets are drafting vs drafting; re-run draft/tune.py WORKDIR PORT MEMORY_GIB MODEL_ID after a change that moves
the decode/verify balance (it rebuilds pkg). On a bandwidth-bound model it is the biggest decode lever
(Qwen3.8-27B Q4_K_M: 12.8 -> 21.0 tok/s with MTP depth 3); on small launch-bound models it loses (Qwen3.5-0.8B:
262 -> 208) and tune.py keeps it off. The multi-token target step that used to cap it (issue #21: f32 activations
in the 4-token matvec, 192+ VGPRs per lane) is fixed in the starter: decode-shaped linears and the head use int8
activations with integer dot4 contractions (ACT_Q8, llama.cpp's mul_mat_vec_q numerics), with 1-/4-/8-/16-token
variants that round each token like 1-token decode (draft/README.md "Limits"). Remaining levers: verify epochs
of 17+ tokens (many drafting streams) take the WMMA GEMM; long-context verify pays decode attention per token (draft/README.md), drafter catch-up cost at prefill, and the MoE expert
matvecs (starter gen_moe.py, the lfm2moe kit), which still use f32 activations. Every change
here must keep the spec gate exact.
Where the remaining gap usually is
- Prefill GEMM efficiency. Q4_K GEMMs run ~28 TFLOP/s in the model (~30 on real data in isolation), ~70 %
of the real-data WMMA ceiling (~40–43 TOPS, see above); gufo (an engine tuned for this chip) reaches ~80 %
with int8 (W8A8 / K-quant×Q8_1) wave64 GEMMs, which halve LDS and activation traffic, and fused
norm/SwiGLU→Q8_1 epilogues. int8 WMMA is not faster per instruction here (same ~40 TOPS on real data), so
the gain is energy: fewer bytes moved per MAC. Tried here and slower on real data: a wave64 port of the f16
GEMM through transposed fragment loads (27.6 vs 30.5 TFLOP/s) and llama.cpp's mmq formulation
(
gen_gemm.gemm_q8: Q8_1 activations x Q4_K codes on int8 WMMA, 20.7; 26.2 even with the per-block scaling removed): the f16 kernel's staging pipeline is the better one; an int8 kernel would need gufo's deeper pipelining to pay off. - Gated delta-net prefill (hybrids): the register recurrence steps through the chunk token by token (Qwen3.8-27B: 0.14 ms/token of 2.3, all GDN kernels 0.27); a chunked (WY / parallel-scan) form is the lever.
- Small models (≤ 1B), for example Qwen3-0.6B: decode at 2k context ran 179 vs 193 tok/s for llama.cpp. There, the attention and launch overhead per layer dominates rather than weight bandwidth. Fewer, fused launches per layer, and fewer splits when context is short, are the levers.
- MoE decode is bound by the expert gather: batch the active experts' matvecs per token group.
Reporting a win
Report only numbers from the same machine, the same GGUF, the same workloads and
the same session, with llama.cpp ROCm beside them, plus the correctness gate
summary. See loom-benchmark. For each metric, include its target, its % of
ceiling and whether it was met. Add the result to
loom-roofline/reference/calibration.md.
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

