Skip to content

[CK] [FlyDSL] Retune Kimi-K3 a16w4 MoE tile geometry - #5118

Merged
valarLip merged 15 commits into
ROCm:mainfrom
amd-wsung102:kimi_k3_tile_m_block
Sep 2, 2026
Merged

valarLip merged 15 commits into
ROCm:mainfrom
amd-wsung102:kimi_k3_tile_m_block

Conversation

@amd-wsung102

@amd-wsung102 amd-wsung102 commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

Summary

This PR retunes the tile geometry of the Kimi-K3 a16w4 (bf16 activation × MXFP4 weight) SiTUv2 expert GEMMs, on a different axis for each end of the token range:

  • Decode (token <= 512): halve the M-block, 32 → 16. With 896 experts and top-16 routing a decode batch spreads ~2k routed rows over ~800 distinct experts — about 2.5 real rows per expert. moe_sorting pads every expert up to a full block_m, so at block_m=32 roughly 92% of the rows the two GEMMs process are padding. Halving the M quantum halves that waste.
  • Prefill (token >= 2048): widen the N-tile. Pick the largest gemm1 tile_n that divides inter_dim exactly, up to 4 accumulator groups — 192 for inter_dim=384, 256 for 512 — collapsing NUM_N_BLOCKS 3→2 and 4→2 and cutting both the A-gather traffic and the CTA count. gemm2 moves 128 → 256.

Performance improvement:

  • E2E Kimi-K3 at 8k/1k, TP=8 — 2.35% lower median TTFT, 2.41% lower median TPOT, +2.45% total throughput.
  • Kernel-level 1.18–1.35× on the MoE GEMM pair at decode and 1.05–1.10× at prefill
  • Layer-wise 1.18–1.21× decode and 1.04–1.06× prefill on the whole MoE layer.

Relevant Files

  • aiter/configs/model_configs/kimik3_fp4_tuned_fmoe.csvblock_m 32→16 on the 11 inter_dim=384, token<=512 rows; N-tiles widened on the 8 token>=2048 rows of both inter_dim families; 25 inert _w{n} and 21 inert _persist suffixes dropped
  • aiter/ops/flydsl/moe_kernels.py — register tile_m=16 and tile_n 192/256 stage-1 names for a16w4; let tile_m=16 share tile_m=32's tile-N and k-wave sets on the a16w port; forward waves_per_eu instead of hardcoding None; tighten the int4 registry's wave-partition filter
  • aiter/ops/flydsl/kernels/moe_2stage_a16wmix/gemm1.py — assert the per-wave column count is a multiple of 16 (+8 lines)
  • aiter/ops/flydsl/kernels/moe_2stage_a16wmix/gemm2.py — assert TILE_N is a multiple of 64 (+6 lines)

Optimizations Applied

Decode — M-block 32 → 16 on token <= 512

  • The split is placed at the measured crossover, not a guessed batch size. block_m=16 wins for every bucket from 1 to 512 tokens (1.17–1.38× on the isolated GEMMs) and loses from 1024 up (0.85–0.98×). Because block_m is a per-bucket CSV field there is no runtime coupling between the decode and prefill configurations.
  • Registered t16 stage-1 names for a16w4. tile_ms gains 16 when the weight is fp4 and the activation is bf16, matching the existing fp8 case. Strictly additive: the a16w4 stage-1 registry grows, fp4×fp4 / fp8×fp8 / fp8×fp4 are bit-identical, and every kernel name in the shipped CSVs still resolves.
  • tile_m=16 shares tile_m=32's tile-N and k-wave sets on the a16w port. The tiny-batch decode configs use narrow N-tiles (t*x32 / t*x64) and k_wave 2/4; both are legal at BM=16 (m_repeat <= 2 either way, and the port takes tile_n as given) and they are needed to name buckets 1–4. Guarded to is_a16w4.

Prefill — N-tile widening on token >= 2048

  • Largest tile_n that divides inter_dim, capped at 4 accumulator groups. pick_flydsl_stage1_tile_n only ever returns 256 or 128, and for a non-256-aligned inter_dim it silently forces 128 while warning that 256 "is NOT tunable for such shapes" — so the values that divide exactly were never considered. Legal only on the a16w port, which takes tile_n as given; the other fp4 dtypes route through resolve_flydsl_stage1_tile_n, which would force the tile back to 128 and make the kernel name a lie. Registered for a16w4 only.
  • The cap is measured, not assumed. tile_n=384 (6 accumulator groups) and 512 (8) both lose to register pressure — 512 is 0.56× (2448.1 vs 1361.6 µs at token 8192). The occupancy loss swamps the A-traffic saving.
  • Every axis the tuner did search is reconfirmed at the new tile, so only tile_n changes: xcd_swizzle=1 beats 0 and 4 (1057.8 vs 1182.4 / 1066.8 µs), b_nt=0 beats 2 (1057.8 vs 1221.8 µs), and block_m=32 remains optimal at prefill.

Performance on ATOM

Measured on MI355X (gfx950, 256 CU). Kimi-K3 routed-expert geometry at TP=8: model_dim=3584, inter_dim=384, E=896, top-16, SiTUv2, bf16 × MXFP4. kv_cache_dtype=fp8, block-size=128, prefix caching off. 8k/1k, 128 prompts, concurrency 128.

Kernel-level

token owner block_m gemm1 before → after gemm2 before → after gemm1 + gemm2 speedup
1 M-block 32→16 14.8 → 10.1 9.1 → 9.2 23.9 → 19.3 1.241×
8 M-block 32→16 50.3 → 35.4 21.7 → 17.8 72.0 → 53.2 1.353×
32 M-block 32→16 142.4 → 116.2 67.1 → 56.3 209.5 → 172.6 1.214×
128 M-block 32→16 267.5 → 223.7 135.4 → 113.6 403.0 → 337.3 1.195×
512 M-block 32→16 284.2 → 234.2 154.5 → 137.8 438.7 → 372.0 1.179×
1024 neither 32→32 298.8 → 299.6 172.0 → 171.4 470.8 → 471.0 1.000×
2048 N-tile 32→32 452.5 → 438.3 292.7 → 269.4 745.2 → 707.6 1.053×
4096 N-tile 32→32 680.2 → 637.4 464.0 → 404.4 1144.1 → 1041.8 1.098×
8192 N-tile 32→32 1094.8 → 1040.3 825.1 → 791.8 1919.9 → 1832.1 1.048×
16384 N-tile 32→32 1983.8 → 1868.9 1589.6 → 1524.2 3573.4 → 3393.1 1.053×

MoE layer

token before (µs) after (µs) speedup bucket changed
1 110.2 110.0 1.002× yes (M-block)
8 113.1 111.4 1.015× yes (M-block)
32 215.5 180.6 1.193× yes (M-block)
128 408.6 338.1 1.209× yes (M-block)
512 472.0 400.7 1.178× yes (M-block)
1024 500.9 502.6 0.997× no — control
2048 752.5 711.5 1.058× yes (N-tile)
4096 1166.7 1108.1 1.053× yes (N-tile)
8192 1989.3 1887.9 1.054× yes (N-tile)
16384 3714.0 3573.3 1.039× yes (N-tile)

Kimi-K3 end-to-end benchmark

Metric main kimi_k3_tile_m_block Delta Change
Mean TTFT (ms) 36,509.57 35,754.91 -754.66 -2.07%
Median TTFT (ms) 36,708.27 35,847.02 -861.26 -2.35%
P99 TTFT (ms) 70,993.58 69,623.92 -1,369.66 -1.93%
Mean TPOT (ms) 83.57 81.46 -2.11 -2.53%
Median TPOT (ms) 83.38 81.37 -2.01 -2.41%
P99 TPOT (ms) 117.48 114.64 -2.84 -2.42%
Mean ITL (ms) 83.49 81.38 -2.11 -2.53%
Output throughput (tok/s) 1,074.17 1,100.47 +26.30 +2.45%
Total throughput (tok/s) 9,667.49 9,904.23 +236.73 +2.45%
Duration (s) 122.02 119.11 -2.92 -2.39%
image

Performance on vLLM

Measured on MI355X (gfx950, 256 CU). Kimi-K3 routed-expert geometry at TP=8: model_dim=3584, inter_dim=383, E=896, top-16, SiTUv2, bf16 × MXFP4. kv_cache_dtype=fp8, block-size=128, prefix caching off. 8k/1k, 128 prompts, concurrency 128

Kernel-level

Tokens Baseline (µs) Optimized (µs) Delta (µs) Speedup
1 35.68 30.70 -4.98 1.16x
2 41.62 32.58 -9.04 1.28x
4 55.53 45.48 -10.05 1.22x
8 80.59 65.68 -14.91 1.23x
16 131.28 101.99 -29.29 1.29x
32 217.82 180.45 -37.37 1.21x
64 312.95 251.02 -61.93 1.25x
128 419.02 335.97 -83.04 1.25x
256 465.50 382.59 -82.91 1.22x
512 482.66 401.94 -80.72 1.20x
1,024 511.87 507.48 -4.39 1.01x
2,048 775.15 732.38 -42.76 1.06x
4,096 1198.52 1124.79 -73.73 1.07x
8,192 2071.09 1955.71 -115.38 1.06x
16,384 3856.22 3635.15 -221.07 1.06x

MoE layer

token before (µs) after (µs) speedup bucket changed
1 33.1 32 1.034× yes (M-block)
2 38.4 34.5 1.113× yes (M-block)
4 53.5 47 1.137× yes (M-block)
8 78.9 65.7 1.200× yes (M-block)
16 117 101.6 1.152× yes (M-block)
32 191.1 161.3 1.185× yes (M-block)
64 291.8 231.3 1.261× yes (M-block)
128 395.5 311.7 1.269× yes (M-block)
256 434.2 345.3 1.258× yes (M-block)
512 448.1 375.9 1.192× yes (M-block)
1024 476.8 477.5 0.999× no — control
2048 742.1 698.9 1.062× yes (N-tile)
4096 1155.7 1094.3 1.056× yes (N-tile)
8192 2001.1 1897.3 1.055× yes (N-tile)
16384 3747.4 3588.4 1.044× yes (N-tile)

Kimi-K3 end-to-end benchmark

Metric main kimi_k3_tile_m_block Delta Change
Mean TTFT (ms) 36,473.83 36,009.27 -464.56 -1.27%
Median TTFT (ms) 36,432.35 35,969.82 -462.53 -1.27%
P99 TTFT (ms) 70,995.21 70,058.45 -936.76 -1.32%
Mean TPOT (ms) 86.6 86.22 -0.38 -0.44%
Median TPOT (ms) 86.74 86.35 -0.38 -0.44%
P99 TPOT (ms) 118.91 118.09 -0.82 -0.69%
Mean ITL (ms) 86.6 86.22 -0.38 -0.44%
Output throughput (tok/s) 1,038.89 1,046.17 +7.28 +0.70%
Total throughput (tok/s) 9,350.01 9,415.54 +65.52 +0.70%
Duration (s) 126.17 125.29 -0.88 -0.70%

Accuracy

The N-tile half is bitwise identical. tile_n only changes how the N axis is blocked across workgroups and waves; it does not change the K-reduction order. Cross-checking every legal tile_n against 128 at inter_dim=384:

tile_n cols/wave num_acc_n covered bitwise identical rel err
64 16 1 16/16 yes 0
128 32 2 32/32 yes 0
192 48 3 48/48 yes 0
384 96 6 96/96 yes 0

End-to-end through the production get_2stage_cfgsfused_moe_2stages dispatch against a bf16 SiTUv2 torch reference, cos/logits_diff, baseline vs this branch:

inter_dim token baseline this branch
384 2048 1.524e-05 1.524e-05
384 4096 1.521e-05 1.521e-05
512 2048 1.526e-05 1.526e-05
512 4096 1.523e-05 1.523e-05

Identical to four significant figures at every prefill point.

  • Unit tests: 12/12 op_tests/flydsl_tests/test_flydsl_moe_a16wfp4.py pass (2 shapes × 3 token counts × 2 SiTUv2 beta pairs) at cos_diff < 1e-2.
  • All 64 CSV kernelName1/kernelName2 entries parse against the registry; no residual _w{n} or _persist names remain.
  • get_2stage_cfgs verified to select the intended block_m and tiles at every bucket, including the unchanged t32x128x128 at 1024.
  • The new TILE_N guards verified to reject 96 (gemm1) and 112 (gemm2), accept 192/256.

Submission Checklist

@github-actions

Copy link
Copy Markdown
Contributor

🏷️ CI Guide

Runs automatically on every PR:

  • ✅ Pre-checks (submodule verification, code formatting)
  • ✅ Aiter op tests (gfx942 + gfx950)
  • ✅ Triton tests on MI35X (only when aiter/ops/triton/** or related paths are changed)

Extended tests (opt-in via labels):

Label Tests
ci:gfx1250-ffm-triton Run the five-shard gfx1250 FFM Triton test suite
ci:triton-300x Run an additional Triton test job on MI300X in PRs; main branch always runs both MI35X and MI300X
multigpu Aiter multi-GPU tests on the 8-GPU runner
ci:sglang SGLang integration tests: DeepSeek-R1-MXFP4 accuracy, Qwen 3.5 accuracy
ci:atom ATOM benchmark: DeepSeek-R1-0528, GPT-OSS-120B
ci:atom_full ATOM accuracy suite for PR and main models from ATOM models_accuracy.json
ci:vllm vLLM benchmark: GPT-OSS-120B, DeepSeek-R1-0528, Kimi-K2.5
ci:all All standard extended tests (excludes ci:atom_full)

Only add ci:atom_full for FlyDSL or Triton upgrades.
Add labels via the sidebar or gh pr edit 5118 --add-label <label>

PR title tags:
Component tags ([Triton/Gluon], [HIP], [CK], [ASM], ...) are added to the PR title automatically from the changed files and re-synced on every push — change-type tags like [fix]/[Perf] and op tags like [MLA] are left untouched. Add the no-auto-title label to opt this PR out of title tagging.

@amd-wsung102 amd-wsung102 changed the title [FlyDSL] [Tune] Retune the Kimi-K3 a16w4 MoE tile geometry per token bucket [FlyDSL] [Tune] Retune the Kimi-K3 a16w4 MoE tile geometry Sep 1, 2026
@amd-wsung102 amd-wsung102 changed the title [FlyDSL] [Tune] Retune the Kimi-K3 a16w4 MoE tile geometry [FlyDSL] [Tune] Retune Kimi-K3 a16w4 MoE tile geometry Sep 1, 2026
@amd-wsung102
amd-wsung102 marked this pull request as ready for review September 1, 2026 04:21
@amd-wsung102
amd-wsung102 requested a review from a team September 1, 2026 04:21
@github-actions github-actions Bot added the FlyDSL label Sep 1, 2026
coderfeli
coderfeli previously approved these changes Sep 1, 2026

@valarLip valarLip left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review

The tile_m=16 decode work and the widened a16w4 tile space look like the right direction, and I confirmed two things up front so they don't get re-litigated: all 64 kernel names in the new CSV resolve as exact registry keys (stage-2 tile_m=16 was already registered for fp4, so the decode rows are safe), and the CSV rename does not break config loading — both loaders glob (jit/core.py:427, moe_recipes.py:129). The _persist/_w{n} suffix strips are genuinely behavior-preserving under the old code, and the CSV passes the duplicate-shape check against every other tuned CSV.

The problems below are mostly about blast radius outside a16w4 and about the provenance of the new CSV rows.

Blocking

1. aiter/ops/flydsl/moe_kernels.py:1529 — the new _g1_waves_per_eu gate silently switches the a16wi4 (int4) port from waves_per_eu=None to 3.
get_flydsl_stage1_kernels_int4_bf16._emit (~line 516) never writes a waves_per_eu key — verified: get_flydsl_kernel_params('flydsl_moe1_abf16_wint4_bf16_t32x128x256') returns no such key. aiter/fused_moe.py:1482 then does waves_per_eu=parsed.get("waves_per_eu", 3), so (3 is not None and int(3) > 1) is True and 3 is forwarded. gemm1.py:615 attaches rocdl.waves_per_eu=3 (a real minimum-occupancy / VGPR clamp) and gemm1.py:477 appends _w3 to the JIT cache key. All 88 rows of aiter/configs/model_configs/kimik2_i4_tuned_fmoe.csv were tuned with no such attribute and now run a differently-named, occupancy-capped kernel — a silent perf change plus a guaranteed cold JIT on a model that has nothing to do with this PR. Any of these would fix it: gate on b_dtype == 'fp4', register waves_per_eu: 1 in the int4 _emit, or default the stage-1 dispatchers to None the way stage 2 already does (fused_moe.py:1552).

2. aiter/configs/model_configs/kimik3_a16w4_tuned_fmoe.csv:14 — 8 rows change tile_n but keep byte-identical timings from the deleted file.
Joining old (merge-base d8a10bf) and new on (inter_dim, token): inter=512, tokens 2048/4096/8192/16384 move t32x128x128*t32x256x128_bnt0_xcd1 (stage 1) and t32x256x128_atomic (stage 2), while us1 stays 472.3426 / 701.4285 / 1161.1315 / 2114.6148 to four decimals. inter=384, tokens 2048-16384 move to t32x192x128_bnt0_xcd1 / t32x256x128_atomic with us1 still 362.3881 / 534.9361 / 878.3311 / 1595.7209 (token 4096 additionally flips xcd4→xcd1). tile_n is a real constexpr — at inter=512 it halves NUM_N_BLOCKS from 4 to 2 — so identical microsecond timings are not possible. The 11 new tile_m=16 rows by contrast all carry fresh 2-decimal numbers, which is what a real tuning run looks like. As written, these 8 rows ship an unvalidated kernel choice (and copied err%) for the highest-throughput shapes, and jit/core.py's merge dedup picks the lowest us per shape using exactly these numbers. Could you re-run the sweep for those 8 shapes and paste the real numbers?

3. csrc/ck_gemm_moe_2stages_codegen/gemm_moe_tune.py:3475 — the a16w4 tuner hard-filters blockM=16, so the new tile_m=16 rows cannot be reproduced.
gen_flydsl_2stages_task — the only consumer of get_flydsl_stage1_kernels('bf16','fp4',...) — does if blockM not in [32, 64, 128] or not use_g1u1: continue, then if s1_tile_m != blockM: continue (3512); the v2 path at 3822 has the same gate. blockMs = [16, 32, 64, 128] at 4882 and the int4 sibling at 4301 already allows 16, so this looks like an oversight rather than a deliberate exclusion. Consequences: 384 of the 960 registered a16w4 names are unreachable, and any retune of kimik3 3584/384 silently regenerates block_m=32 rows, discarding the decode win this PR is built around. The 11 shipped block_m=16 rows have out-of-tree provenance that nobody can audit.

4. gemm_moe_tune.py:3676 — the 8 new flydsl_moe2_abf16_wfp4_bf16_t32x256x128_atomic rows name a stage-2 config the tuner explicitly refuses to compile.
if a_dtype_str == "bf16" and (s2_tile_m != blockM or kparams["tile_n"] == 256 or inter_dim % kparams["tile_k"] != 0): continue, with the comment directly above: "tile_n=256 over-allocates LDS at large tile_m (compile failure that takes the worker pool down); tile_n=128 covers the shape." No tuner run could have produced these rows. Either the comment/filter is stale and should be relaxed in this PR with evidence, or these rows point at a config that will hit the documented LDS over-allocation inside the JIT on the first token>=2048 request — at serving time instead of at tune time.

Should fix before merge

5. aiter/aot/flydsl/moe.py:881 — AOT still hardcodes "waves_per_eu": None, and its docstring's invariant is now false.
The docstring (~line 855) asserts "the cache key then matches runtime by construction (waves_per_eu=None ... are all applied inside the launcher)". After this PR the launcher no longer forces None — the decision moved to the caller (moe_kernels.py:1529) on the runtime side only. AOT builds ..._bm32_tn128 while runtime asks for ..._w3 (every a16wi4 row today) or ..._w{2,3,4}. That's a 100% AOT cache miss with the .pkl present on disk — precisely the failure mode aiter/aot/flydsl/moe.py:222-229 documents — i.e. a multi-second first-call JIT stall. Both the value and the docstring should change here.

6. moe_kernels.py:218tile_n=192/256 is added to a shape-independent registry, and the a16w port is the one path that bypasses the tile_n clamp.
tile_ns = [32, 64, 128, 192, 256] if is_a16w4 is keyed only on dtype, so t32x192 is offered to every a16w4 shape — including the inter_dim=512 rows in this same CSV (512 % 192 = 128). _flydsl_moe_stage1_impl returns from the _is_a16w_port branch at 1575, before the resolve_flydsl_stage1_tile_n(inter_dim, tile_n) clamp at 1581 — which the PR's own comment concedes ("that port takes tile_n as given"). The tuner's a16w4 filter (3479-3515) checks k_batch/k_wave divisibility and num_acc_n >= 1 but has no inter_dim % tile_n check, so tuning inter_dim=512 or any gptoss_a16w4 shape dispatches t*x192 candidates straight into assert _INTER % TILE_N == 0 (gemm1.py:436) / NotImplementedError, aborting the sweep. Also stale: the comment at 1576-1579 still says the OOB downgrade "Applies to both a16w4 and a8w4".

7. moe_kernels.py:216 — the % 16 filter was added to the int4 registry only, and this PR doubles the unbuildable a16w4 set.
For tn=32, kw=1 the kernel computes TILE_N // (4 // 1) = 8, tripping the pre-existing >= 16 assert at gemm1.py:441 — whose comment names this exact case ("silent all-zero output that times fast (e.g. TILE_N=32, k_wave=1)"). Adding tile_m=16 grows the a16w4 unbuildable set from 96 to 192 names. This PR fixed the class in get_flydsl_stage1_kernels_int4_bf16 by tightening the generator (541), but on the a16w4 side only added an assert. Any consumer that enumerates the registry without replicating the tuner's ad-hoc filter (AOT sweep, a new autotuner, a hand-written CSV) gets an AssertionError instead of a skip — and under python -O the assert vanishes, making num_acc_n=0 a fast-timing no-op the tuner would happily select.

8. gemm_moe_tune.py:3505 — third copy of the num_acc_n invariant, still < 16 while the kernel assert and the int4 registry moved to % 16.
if a_dtype_str == "bf16": _n_waves = max(1, 4 // _kw); if (kparams["tile_n"] // _n_waves) < 16: continue. The registered set {32,64,128,192,256} × kw{1,2,4} divides cleanly today, so the gap is latent — but the next tile_n like 96 or 160 passes this filter and then AssertionErrors inside a compile worker mid-sweep. Worth noting separately: the int4 filter change this PR advertises (< 16% 16) is a provable no-op — enumerated over tile_ns = [16,32,64,128], the name count is 76 before and 76 after, zero added or removed.

9. aiter/ops/flydsl/kernels/moe_2stage_a16wmix/gemm2.py:347 — the new assert's message doesn't describe its condition.
It says "TILE_N must be a multiple of 64" but tests (TILE_N // 4) % 16 == 0. 193 // 4 == 48, 48 % 16 == 0 → the assert passes for a TILE_N that is not a multiple of 64, nor even of 4. The condition should be TILE_N % 64 == 0. gemm1.py:445 has the same floor-division gap for k_wave > 1. No registered tile_n trips it today, but this assert exists specifically to be the backstop against exactly this, and a reader trusting the message either rejects 192 (legal) or accepts 193 (illegal).

10. moe_kernels.py:341_persist was stripped from the CSV but the registry still emits it for a16w4.
get_flydsl_stage2_kernels sets persist: True for every (a=bf16, b=fp4) config, but the a16w branch of _flydsl_moe_stage2_impl (~2059) returns via flydsl_a16w4_gemm2 without ever forwarding persist (first read is at 2154), so _persist=False always. get_flydsl_stage2_kernels_int4_bf16's docstring (560) documents exactly this hazard and refuses to register _persist for int4 — the fp4 twin was never given the same treatment. The tuner therefore times persist and non-persist as identical duplicates and picks one at random, recording a us2 under a name that never runs. Same for mode="reduce", which the a16w branch also ignores while the registry advertises it.

11. aiter/configs/model_configs/kimik3_fp4_untuned_fmoe.csv — the tuned file was renamed to kimik3_a16w4_* but its untuned twin wasn't.
All 30 sibling fmoe pairs share a prefix (gptoss_a16w4_tuned/untuned, kimik3_a4w4_tuned/untuned, kimik3_a8w4_tuned/untuned, …). The orphan still lists exactly the 32 shapes the new tuned file covers, so a retune driven off it writes results back to the name-derived kimik3_fp4_tuned_fmoe.csv, resurrecting the deleted file next to the new one with 32 identical shape keys — the next AITER_CONFIG_FMOE merge then raises "duplicate shape entries during merge" (jit/core.py:365) and refuses to start. Runtime today is unaffected (both loaders glob and exclude untuned), and op_tests/tuning_tests/test_csv_validation.py checks only a fixed 7-file list, so CI won't catch it.

Minor

12. moe_kernels.py:249 — the a16w4 k_wave gate is computed against a wave count the kernel doesn't use.
The registry computes num_n_waves = min(4, tn // 32) and rejects on num_n_waves * kw > 8, but compile_gemm1_a16w4_port always partitions exactly 4 waves as (4 // k_wave) N-waves (gemm1.py:85, known_block_size=[256,1,1]) — never 8, never a function of tn. The "≤8 total waves (≤512 threads)" comment this diff edits describes a different kernel's threading model. Meanwhile get_flydsl_stage1_kernels_int4_bf16 filters on _gemm1_lds_bytes(...) > _MAX_LDS_BYTES (544) and the a16w4 branch doesn't, though both feed the same port. Max LDS over the widened a16w4 set is 128 KiB < 160 KiB so nothing breaks today, but the next tile widening will register unbuildable names that AOT silently drops.

13. moe_kernels.py:200 — dead pre-loop tile_ns, and the inventory comment is missing the family this PR is about.
tile_ns = [32, 64, 128] if is_fp4_b else [128] is unconditionally overwritten on the first iteration of for tm in tile_ms: (both branches at 216-220 assign it), so this diff turned a 2-place duplication into a 3-place one with the pre-loop copy now inconsistent with the new a16w4 set. Separately, the comment at 195-197 was edited to (t32x{64,128,192,256}x128 / _xcd1) in the same hunk that adds the entire t16x* family the new CSV mostly relies on (t16x32x256_xcd4_kw2, t16x32x128_kw4, t16x64x256_kw2, t16x128x256_xcd1, t16x128x128_xcd4) — so the widest part of the new search space is missing from its own inventory comment, while t32x64x128 is listed and appears in no CSV.

14. moe_kernels.py:202 and :247 — two comments state rules the code doesn't implement.
(a) 202 claims "the decode win at E=896 (1.17-1.38x, token≤512)", but the PR's own CSV gives 1.02x-1.65x on the us column (token 1: 26.7689 → 17.57 = 1.52x; token 512: 381.3451 → 374.05 = 1.02x), and inter_dim=512 gets no tile_m=16 rows at all — so the claim isn't true of "E=896" generally, and someone benchmarking at token=512 sees 1.02x and suspects a regression. (b) 247 says k_wave applies to "small-M tiles (tile_m in {16,32})", but _small_m = tm == 32 or (tm == 16 and is_a16w4) excludes tile_m=16 for fp8xfp4, which line 205 also registers — someone adding a8w4 k_wave tuning will assume t16x*_kw* names exist and hit "Invalid FlyDSL kernel name".

15. moe_kernels.py:534 — the int4 registry comment was half-updated.
It now reads "each N-wave covers tn/(4/kw) cols, a multiple of 16 for the 16x16 MMA (kw=1 therefore requires tn >= 64)". Under the new (tn // num_n_waves) % 16 condition, kw=1 requires tn to be a multiple of 64, not merely ≥ 64 — tn=80 and tn=112 are ≥ 64 and silently skipped. Someone extending tile_ns beyond [16,32,64,128] will read "≥ 64" as the rule, add a tn the loop drops, and then debug a kernel name the registry never emitted.

The int4 stage 1 registry does not emit a waves_per_eu key, so
_flydsl_stage1_wrapper's numeric default of 3 was the value that reached the
a16w port. That was harmless while the port hardcoded waves_per_eu=None, but
now that the value is forwarded it clamps occupancy on a path this work does
not touch: all 88 tuned kimik2_i4 rows were measured with no such attribute,
and rocdl.waves_per_eu=3 also appends _w3 to the JIT cache key, so every one
of them would take a cold rebuild and run a differently scheduled kernel.

Default to None instead, which is what stage 2 already does. Every other
stage 1 family sets waves_per_eu explicitly in the registry, so the default
only applies to int4 and the fp4 families keep the axis live.
…ith measured ones

The eight token>=2048 rows changed tile_n but kept the us1/us2/us/tflops/bw
values from the pre-retile file, so they described a kernel that is no longer
the one named. That is worse than cosmetic: update_config_files sorts by us
and keeps the lowest per shape when merging tuned CSVs, so those numbers can
decide which row wins.

Re-measured each row at its own parsed config, block_m, tile_n, tile_k, b_nt,
xcd_swizzle, k_wave and waves_per_eu taken from its own kernelName, median of
3 repeats at 200 iterations. us is us1+us2 and tflops/bw are recomputed with
gemm_moe_tune.py's own formulas, which reproduce every unchanged row in this
file exactly.

The new values run 15 to 19 percent above the replaced ones because this host
is capped at 1000 W against a 1400 W TBP, the same host the block_m=16 rows
were measured on. No other tuned CSV covers these eight shape keys, so the
merge dedup has nothing to compare them against.

err1 and err2 are left as they are. tile_n does not change gemm1's
K-reduction order, so its output is bitwise identical across 64, 128, 192 and
384, and err1 genuinely does not move. gemm2 changes only the order of its
bf16 atomic scatter, which cannot shift a value printed to one decimal place.

Dispatch is untouched: block_m, ksplit, kernelName1, kernelName2, run_1stage,
xbf16 and flat are byte-identical for all 32 shape keys.
Both asserts checked the per-wave column count after a floor division, so a
TILE_N of 64k+1 to 64k+3 slipped through: 193 // 4 is 48 and 48 % 16 == 0, so
the gemm2 assert passed for a TILE_N that is not a multiple of 64, which is
exactly what its message claimed to enforce. A reader trusting the message
would either reject 192, which is legal, or accept 193, which is not.

Test the divisibility directly instead. gemm2 wants TILE_N % 64 == 0; gemm1
partitions 4 // k_wave N-waves, so it wants TILE_N % (16 * (4 // k_wave)) == 0,
which is 64 at k_wave=1, 32 at 2 and 16 at 4.

No registered tile_n changes verdict: of every a16w4 and a16wi4 stage 1 name
that clears the pre-existing >= 16 assert, zero pass the old condition and
fail the new one, and the stage 2 set of 128 and 256 is unaffected. Verified
reachable with D_INTER = 193 * 128, where the divisibility and 256-alignment
asserts both pass and TILE_N=193 now fails the wave-partition check.
The tuned config became kimik3_a16w4_tuned_fmoe.csv but its untuned input kept
the old kimik3_fp4_ prefix, leaving the only unpaired fmoe config in the
directory; every other pair shares a prefix.

That is a trap rather than a cosmetic issue. The orphan lists exactly the 32
shape keys the tuned file covers, and the tuner derives its output name from
the input name, so a retune driven off it writes kimik3_fp4_tuned_fmoe.csv and
recreates the deleted file beside the new one with all 32 keys duplicated. The
next config merge then hits the duplicate-shape check and refuses to start.

No runtime effect either way: both loaders glob and exclude any name
containing untuned. test_csv_validation.py checks a fixed seven-file list
under configs/, not model_configs/, so CI would not have caught this.
Three filters in gen_flydsl_2stages_task made the shipped kimik3 a16w4 rows
unreproducible, so a retune would silently regenerate different ones.

blockM was gated to 32, 64 and 128, so no block_m=16 candidate was ever timed
even though the a16w4 and a8w4 stage-1 registries emit t16 names and the int4
sibling already allows 16. Any retune of 3584/384 would have discarded the
decode configs this work is built around. Added 16; the s1_tile_m != blockM
filter below still drops the dtypes with no t16 names, so a4w4 gains nothing
and a8w4 gains the 32 candidates its registry already advertises.

Stage 2 refused tile_n=256 for bf16 outright, which is why no run could have
produced the eight t32x256x128_atomic rows. The comment was right that the
hazard is LDS over-allocation at large tile_m, but the code did not implement
that. The port requests tile_m*(tile_k*2 + tile_n*4), so check the bound
directly. Confirmed at both sides of it by forcing real codegen, since
compile_gemm2_a16w4_port only builds the launcher: tile_m=128/tile_n=256/
tile_k=128 is 163840 bytes and runs, while tile_k=256 is 196608 and fails with
"local memory (196608) exceeds limit (163840)" -- the predicted figure exactly.
The shipped tile_m=32 config is 40 KiB.

The num_acc_n filter used a floor divide, so it would pass a tile_n like 96 or
160 to a compile worker that then hits the kernel assert mid-sweep. Made it
the same divisibility test the kernel now uses.

The tuner would now generate the config named by all 32 rows of the CSV, up
from 21. The v2 path's identical blockM gate is left alone: it returns early
unless the activation is fp4 or fp8, so a16w4 never reaches it.
The k_wave comment said the option set applies to "tile_m in {16,32}", but the
guard is tm == 32 or (tm == 16 and is_a16w4), so fp8xfp4 gets k_wave only at
tile_m=32 even though its registry does emit 32 t16 names, none of them with a
_kw suffix. Someone adding a8w4 k_wave tuning would assume t16 _kw names exist
and hit "Invalid FlyDSL kernel name".

The int4 comment still said kw=1 "requires tn >= 64" after the filter moved to
a divisibility test. Under that test tn of 80, 96, 112 and 160 are all >= 64
and silently dropped, so anyone extending tile_ns past 16, 32, 64, 128 would
read the wrong rule and debug a name the registry never emitted.

Comments only; the parsed AST is unchanged.
The comment attributed the win to E=896 generally, which reads as covering both
tuned inter_dim families. Only inter=384 has block_m=16 rows; inter=512 was
never swept for it and all 16 of its rows stay at 32. It also gave no basis for
the figure, so a reader comparing it against the us column of this CSV lands on
1.02x to 1.65x instead, because those rows were measured on two different hosts.

State the measurement instead: 1.18-1.35x on the isolated GEMM pair at E=896
inter=384 for token<=512, which is the same-host A/B both this branch and the
original sweep produced, and note that inter=512 is excluded.

Comment only; the parsed AST is unchanged.
@github-actions github-actions Bot changed the title [FlyDSL] [Tune] Retune Kimi-K3 a16w4 MoE tile geometry [CK] [FlyDSL] [Tune] Retune Kimi-K3 a16w4 MoE tile geometry Sep 1, 2026
@github-actions github-actions Bot added the CK label Sep 1, 2026
Eight of the comments this branch adds ran to two, three or four lines. Each is
now a single line, and the two that sat inside pre-existing multi-line comments
are back to the original wording with only the one clause that the code change
invalidated rewritten, so the diff shows one added line rather than the whole
paragraph.

The whole branch now adds 14 comment lines across five files, none adjacent,
longest 88 characters. Comments only; the parsed AST of every file is unchanged.
@amd-wsung102

Copy link
Copy Markdown
Contributor Author

Hi @valarLip, thank you for reviewing this PR! I have addressed each point with a commit.

Addressed

Point Commit What changed
1 c268b4054 Stage-1 waves_per_eu default 3None, matching stage 2. Only the int4 registry omits the key, so a16wi4 is back to no attribute and no _w3 cache key; the fp4 families are unaffected.
2 be4bb2227 Re-measured the 8 rows at their own configs (median of 3, 200 iters). us/tflops/bw recomputed with the tuner's own formulas. Numbers below.
3, 4, 8 1e89f0468 blockM gate now includes 16; stage-2 checks the actual LDS bound instead of refusing tile_n=256; num_acc_n filter uses divisibility. The tuner now generates the config named by all 32 rows, up from 21.
9 7b6b2ba1a Asserts test TILE_N % 64 == 0 (gemm2) and TILE_N % (16 * (4 // k_wave)) == 0 (gemm1), so 193 no longer passes. No registered tile_n changes verdict.
11 175d35183 kimik3_fp4_untuned_fmoe.csvkimik3_a16w4_untuned_fmoe.csv (100% rename).
14a f49fb092d Claim scoped to the isolated GEMM pair at inter=384, token<=512, and notes inter=512 stays at 32.
14b, 15 5200b91d8 k_wave comment no longer implies fp8×fp4 gets tile_m=16; int4 comment says tn % 64 == 0 rather than tn >= 64.

Point 2 — measured numbers

inter_dim token kernelName1 us1 us2 us
384 2048 t32x192x128_bnt0_xcd1 429.8417 255.0910 684.9327
384 4096 t32x192x128_bnt0_xcd1 638.6140 407.5472 1046.1613
384 8192 t32x192x128_bnt0_xcd1 1038.8538 789.3389 1828.1927
384 16384 t32x192x128_bnt0_xcd1 1882.9619 1504.1563 3387.1182
512 2048 t32x256x128_bnt0_xcd1 552.6688 309.4969 862.1657
512 4096 t32x256x128_bnt0_xcd1 823.6266 499.2724 1322.8991
512 8192 t32x256x128_bnt0_xcd1 1353.7099 881.6882 2235.3980
512 16384 t32x256x128_bnt0_xcd1 2429.8860 1657.9878 4087.8738

Point 4 — why the filter was relaxed rather than removed

You're right about the mechanism, but the code was over-broad by 4×. Forcing real codegen (compile_gemm2_a16w4_port only builds the launcher) gives:

  • tile_m=128, tile_n=256, tile_k=128 → 163840 bytes, builds and runs
  • tile_m=128, tile_n=256, tile_k=256local memory (196608) exceeds limit (163840)

which is exactly what tile_m*(tile_k*2 + tile_n*4) predicts, so the bound is now checked directly. The shipped tile_m=32 config is 40 KiB.

I left the v2 path's identical blockM gate alone - it returns early unless the activation is fp4 or fp8, so a16w4 never reaches it. a4w4 gains 0 candidates from the blockM change; a8w4 gains the 32 its registry already advertises.

Deferred with reasons

  • 5 is not applicable after point 1: AOT hardcodes None and the runtime now also resolves None for int4 and for every no-_w name. No config in tree triggers the miss. Happy to fix the stale docstring line if you want it here.
  • 6, 7, 10, 12, and 13 are all pre-existing classes that this PR makes more visible rather than causes. 7 and 10 in particular would remove registry names (192 unbuildable, 256 _persist + 256 _reduce), which can invalidate CSVs outside this PR. I can do those as a focused follow-up than widen a tuning change.

@amd-wsung102 amd-wsung102 changed the title [CK] [FlyDSL] [Tune] Retune Kimi-K3 a16w4 MoE tile geometry [CK] [FlyDSL] Retune Kimi-K3 a16w4 MoE tile geometry Sep 1, 2026
@zufayu
zufayu requested a review from yadaish September 2, 2026 01:22

@valarLip valarLip left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@valarLip
valarLip merged commit 0f060d2 into ROCm:main Sep 2, 2026
55 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants