[FlyDSL] [Feature] Tiered persistent radix-select decode Top-K + arch/shape dispatcher - #4355
JH-Leon-KIM-AMD wants to merge 112 commits into
Conversation
Introduce FlyDSL decode TopK implementations and benchmarking coverage so K=512 graph-captured paths can choose measured runtime tiers while preserving correctness against reference TopK behavior. Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
…pace API. Make the tiered persistent radix-select the sole decode TopK path for every supported K (256/512/1024/2048) by lifting top_k to a compile-time parameter, and drop the single-CTA fallback. Callers now manage scratch through flydsl_top_k_per_row_decode_workspace_size and an optional workspace= argument, with a batch-aware tier config and a deadlock guard that clamps cooperating workgroups (or forces the barrier-free short tier) to keep the non-cooperative row barrier safe.
…dule instead of redefining.
…pecific tiering modes
…uce the number of compilations. Empirically confirmed perf neutral. Rename _compile_launcher -> _build_launcher, because the function doesn't actually do the compilation.
…ap) onto Robin base
… (bpp==11) Prerequisite for batch_coresident_cap (grid=1 fold). Kernel previously rejected blocks_per_row < 2; now allows 1 when the barrier-free single- workgroup short tier exists (tier_mode auto/short + bits_per_pass==11), keeping the >=2 floor for mid/long forced modes and bpp==10. Also parameterize the vec-block ceil-div shift on LOAD_VEC (LOAD_VEC_LOG2) instead of a hardcoded 2 (no-op at LOAD_VEC=4). Dispatch output unchanged (_kernel_config identical to Phase A); this only opens the path for Phase B2. gfx942 unaffected. Co-authored-by: Cursor <cursoragent@cursor.com>
Cap each row's cooperating workgroups by its actual coverage need
(ceil(row_len / items_per_block), floored at 2) in the mid/long tiers,
undoing the pow2 round-up of blocks_per_row for rows whose coverage is
not a power of two. Only reduces active parts (a min), so results are
identical.
Kernel: add row_proportional_parts param + _rpp name tag + the row_cover
cap after mid/long_parts. Dispatch: emit row_proportional_parts in the
config (gfx950 default on, env FLYDSL_TOPK_TIERED_RPP override, gfx942
frozen), passed through the existing **kernel_config spread.
Verified: correctness lossless (random/ties/10LSBits x rows{8,32} x
L{49k,120k,256k} all PASS); perf rows=1 L=49152 22.44->22.20us (-1.1%,
parts 16->12), L=98304 28.64->27.84us (-2.8%, 32->24); null cell
unchanged. blocks_per_row unchanged; gfx942 frozen.
Co-authored-by: Cursor <cursoragent@cursor.com>
Cap the launch grid width so blocks_per_row*num_rows stays within one
co-resident wave (envelope = CU*occ), so the persistent barrier does not
serialize rows into sequential waves. When even a width-2 grid overflows
(budget<2), collapse to grid=(1, num_rows): every row runs the
barrier-free single-workgroup short tier (needs the B0 bpr==1 kernel
path). Also fold the all-short case (max_model_len<=short_max) to grid=1.
- Add _CDNA_OCCUPANCY / _COCAP_OCC2_MAX_ROWS (occ=2 up to 32 rows, else 1).
- Make tiered_mid_max a local so the fold can raise it with short_max
(kernel requires mid_max >= short_max when force_single_wg lifts
short_max to L).
- gfx950 only (env FLYDSL_TOPK_TIERED_BATCH_CAP / _OCC override);
gfx942 frozen.
Verified: correctness lossless (rows{64,128,256} x L{8k,65k,120k} x
{random,ties} all PASS); perf rows=256 L=120000 364.11->101.52us (-72%),
rows=128 L=65536 117.56->78.56us (-33%); folded config builds; gfx942
frozen (arch-patch check).
Co-authored-by: Cursor <cursoragent@cursor.com>
Bring the enhanced correctness harness from df2c666 onto the robin-based port branch (production kernel/dispatch already ported in A/B): - regime B (--seq-rand-min-frac): per-row random causal seq_len with per-row poison tail, compared against torch.topk over each row's valid region only (decode causal geometry, next_n slots) - _has_built_flydsl() guard so a source-only FlyDSL checkout cannot shadow the installed runtime - --k accepts multiple values; total/regime-labelled output Verified on gfx950: regime A 54/54 and regime B 54/54 across random/ties/10LSBits x rows{1,4,16} x L{8192,65536,131072} x k{512,2048}. Co-authored-by: Cursor <cursoragent@cursor.com>
Reapply ddaeffa on the robin-based port branch: time_kernel allocated single-element start/end event lists but indexed them per iteration, so any --iters > 1 raised IndexError and recorded every cell as an error. Size the event lists to iters. Verified on gfx950: --kernels aiter_hip --iters 5 completes the CUDA-event path (41.8+/-3.8 us over 5 samples), no IndexError. Co-authored-by: Cursor <cursoragent@cursor.com>
Kernel (topk_per_row_decode_tiered.py):
- add early_stop param + _es kernel-name tag (cache separation)
- add early-stop write helpers (process_loaded_early_vec /
early_write_vec_block / early_write_all): take the boundary bucket whole
via prefix_for_key(key, prev_start_bit) <= kth_bits (HIP-mb
previous_bits <= kth_value_bits), same 4x-staged unroll as the normal
last-pass write
- persistent loop: when early_stop, run passes[0..n-2], and if the
boundary bucket is taken whole (local_len == local_k) skip the final
radix pass and write directly; else run it. The early flag is computed
from the merged histogram so it is identical across a row's blocks ->
the final row_barrier is entered/skipped by all blocks together (no
deadlock).
Dispatch (topk_per_row_decode.py):
- es_on = _env_int(FLYDSL_TOPK_TIERED_ES, 1 if gfx950 else 0)
- early_stop = bool(es_on) and num_rows <= 1 (gated: pays off only in the
single-sequence long tier; neutral/loss at rows>=2)
Verified gfx950: correctness 768/768 vs torch.topk (ES on/off x regime
A+B x rows{1,4,16,32} x L{8k,65k,120k,256k} x k{256..2048} x
random/ties/10LSBits, poison over-scan checked). Perf (rocprofv3 min):
rows=1 -14..17% (L120k k2048 29.76->25.68us), rows=4 (gated off) 0
regression.
Co-authored-by: Cursor <cursoragent@cursor.com>
C3 lossless A/B (port vs our original topk-robin branch) surfaced a
regression at rows=128 L=65536: port 78.6us vs ours 49.6us. Root cause:
the A3 midbatch_coord_cap port omitted the first rule (rows>63, L<=65536
-> cap=1) and floored the cap at 2, because the kernel had no bpr=1
single-workgroup path at the time. Phase B (B0 bpr=1 support + B2 fold)
added that path, so restore the rule and lower the floor to 1.
The rule caps blocks_per_row to 1 WITHOUT force_single_wg (short_max
stays < L), so the row runs the mid tier with a single cooperating block
(barrier-free, HIP one-block shape) instead of a width-2 cooperative grid
that serializes a wide batch into extra co-resident waves.
Verified gfx950:
- config parity vs ours across rows{1..256} x L{8k..256k}: identical
except the known benign pow2 rounding at rows=1 L=120000 (30->32,
perf-neutral per robin's pow2 commit).
- correctness 24/24 (regime A+B, random/ties/10LSBits) on the newly
folded rows{64,128,256} L=65536.
- perf: rows=128 L=65536 78.6->49.8us (== ours 49.9), rows=64 47.4us
(== ours 47.5); ~5x faster than robin (251us).
Co-authored-by: Cursor <cursoragent@cursor.com>
…ocstring Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
…699-gfx950-port # Conflicts: # aiter/aot/flydsl/common.py # aiter/ops/flydsl/__init__.py
🏷️ CI GuideRuns automatically on every PR:
Extended tests (opt-in via labels):
|
The shipped gfx950 window was read off a sweep that called the HIP arm through
its raw binding while FlyDSL went through the public wrapper, so HIP carried
host cost the comparison then credited to us. Re-running both arms through
top_k_per_row_decode over 1276 cells, twice independently, moves every field
except min_width.
Under graph replay the row bands decay monotonically and cross zero at 14, so
the cap drops from 16 to 12 rather than sitting on the crossing. All four
AOT-precompiled k values land within +286 to +463us of each other and none is
separable, so the window widens from {2048} to all four. The rows==2 carve-out
goes with them: sampling rows 3 shows 1-2-3-4 decaying monotonically with rows 2
above the line, so the dip the old table encoded was an artifact of the eager
column that host cost had inflated. excluded_rows had no other user, so the
field is dropped from _DecodeGate.
The gate comment loses its measurement narrative. Per-cell figures, noise floors
and cell counts go stale on the next re-run and cannot be re-verified from the
source file, so they live in the investigation instead; what stays is why the
window exists, why width has to stand in for request length, and where to read
the numbers.
These files accumulated block comments that retold how each tuning knob was arrived at: what the grid looked like before the trim, which batch sizes were measured, the worked example behind the deadlock guard's threshold. None of it is checkable from the source file, and the figures in it go stale silently the next time the kernel is re-measured on different hardware. What each comment keeps is the part a maintainer gets wrong without it: that a cap is a min and therefore cannot change results, that blocks_per_row == 1 needs the kernel's single-workgroup launch path, that mid_max must stay above short_max, that the early-stop flag is derived from the merged histogram so every block of a row agrees on it and the last barrier stays deadlock-free. Comments only. The three kernel modules parse to an identical AST with docstrings stripped, and the dispatch suite still passes 24/24.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 10 out of 10 changed files in this pull request and generated no new comments.
Suppressed comments (1)
Previously missed (1) — in code that hasn't changed since the last review.
aiter/aot/flydsl/common.py:175
- In
_compile_one_config_for,OpKind.GROUPED_MOEis handled twice: first by importingfrom .grouped_moe import compile_one_config, and then again by returning a stub lambda. The second branch is unreachable and the comment contradicts the actual behavior (grouped_moe AOT appears wired up). This should be de-duplicated (either remove the stub branch, or change it to the intended missing kind).
def _compile_one_config_for(kind: OpKind) -> Callable[..., dict[str, Any]]:
if kind is OpKind.MOE:
from .moe import compile_one_config
elif kind is OpKind.MXFP4_MOE:
from .mxfp4_moe import compile_one_config
elif kind is OpKind.GEMM:
from .gemm import compile_one_config
elif kind is OpKind.GROUPED_MOE:
from .grouped_moe import compile_one_config
elif kind is OpKind.CHUNK_GDN_H:
from .chunk_gdn_h import compile_one_config
elif kind is OpKind.TOPK:
from .topk import compile_one_config
elif kind is OpKind.GROUPED_MOE:
# grouped_moe AOT not wired up yet (no jobs are ever collected); keep a
# trivial stub so the dispatch is total.
return lambda **_kw: {}
else:
raise ValueError(f"unknown FlyDSL AOT kind: {kind!r}")
The rows threshold is not one number: it moves with the width of the score
buffer. A wider buffer means more work per row, so the multi-block kernel's
cooperation keeps paying for more concurrent rows before the batch alone fills
the machine. A single cap is a compromise between two crossings and loses at
both ends -- it refuses rows the wide band still wins, and admits rows the
narrow band has already lost.
_DecodeGate therefore carries (min width, max rows) bands rather than a scalar
min_width and max_rows. A call is capped by the first band whose width it
reaches and one that reaches none is refused, so a single-band table reproduces
the old behaviour exactly.
Crossings read off graph replay at repeat 5, with an independent repeat-3 run
reproducing every sign:
width 163840 width 131072
gfx950 17 rows 11 rows
gfx942 20 rows 13 rows
Each step sits two rows below its own crossing, as the scalar caps did, giving
gfx950 ((163840, 15), (131072, 9)) and gfx942 ((163840, 18), (131072, 11)).
Summed over every measured cell in the window that is +158us on gfx950
(2204.7 -> 2362.8) and +199us on gfx942 (2895.7 -> 3094.3), with the worst cell
moving -9.9 -> -12.9 and -11.7 -> -11.9 respectively.
The dispatch tests straddle all four steps and pin that one row count is refused
at 131072 and admitted at 163840, which a table flattened back to a single cap
would fail while every other case stayed green.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 10 out of 10 changed files in this pull request and generated no new comments.
Suppressed comments (2)
Previously missed (2) — in code that hasn't changed since the last review.
aiter/ops/topk.py:598
- _should_use_flydsl_decode() calls get_gfx_runtime() without guarding exceptions. If rocminfo probing fails or the live arch is not in the known map, get_gfx_runtime raises and the whole top_k_per_row_decode op will error out instead of safely falling back to the HIP kernel (this gate is an optimization, so it should be fail-open). Suggestion: wrap the get_gfx() call (and possibly the gate lookup) in try/except and return False on any exception so decode remains functional even when arch detection is unavailable.
arch = get_gfx()
if arch not in _FLYDSL_TOPK_DECODE_ARCHS:
return False
gate = _FLYDSL_TOPK_DECODE_GATES.get(arch)
if gate is None:
aiter/ops/flydsl/kernels/topk_per_row_decode_tiered.py:3
- This new kernel module is missing the standard SPDX license header / copyright header used throughout aiter’s FlyDSL kernel sources (e.g., other files in aiter/ops/flydsl/kernels start with
# SPDX-License-Identifier: MIT). Please add the SPDX header at the top of the file for consistency and license compliance automation.
"""FlyDSL decode TopK-per-row kernel (tiered persistent multi-block radix-select)
Computes an unordered Top-K index set per decode row, fusing a single-workgroup and
…host's The AOT CSV names its target twice: cu_num picks the arch string, and the same number is the CU count that target runs. Only the first was used. Both _kernel_config and _build_launcher were called without cu_count, so each resolved it from torch.cuda.current_device() -- the machine running the build, which for a cross-compiled row is the wrong device entirely. The count is not cosmetic. It selects bits_per_pass, and it scales the co-residency envelope that sets blocks_per_row and the tier caps. On a 256 CU host the gfx942 rows (cu_num=304) enumerate 38 configs where a 304 CU runtime asks for 36, so 8 of 232 jobs compile something nothing loads. Below 128 CU it is not waste but a miss: bits_per_pass drops to 10 and all 36 gfx942 configs differ from what runtime selects, so every AOT kernel for that arch is dead and the first call of each shape pays a JIT compile. Nothing in the shipped window changes. Within the gate's row cap the config sets were already identical, which is why this never showed up in testing.
Every other kernel module under aiter/ops/flydsl/kernels carries one, including the non-tiered sibling this file was split from. The docstring stays the module docstring: the header is a comment above it, not a replacement for it.
FlyDSL validates dtype and device; the HIP one-block kernel casts to float* and int* and reads whatever is there. With the check left to the branches, one caller bug raises on a gated shape and returns a wrong answer on the next width over, so the diagnosis depends on which kernel the gate happened to pick. Checking once up front makes it the same error either way. The other contracts the review raised -- packed seqLens, indices stride (k, 1), stride0 matching the tensor -- are already screened: the gate refuses those shapes and they fall back to HIP exactly as they did before this branch. Only dtype and device were unscreened. The two checks cost 0.35 us of host time against the op's 41 us, measured at rows=4, width=163840, k=2048 over 40,000 calls; the full block would have cost 1.5 us, more than the per-cell margin the row caps are set by.
stable=True now routes through the same gate bands and forwards ordered emit to FlyDSL with histogram-fused placement, so tensor-parallel ranks get identical ascending index lists without falling back to HIP. Co-authored-by: Cursor <cursoragent@cursor.com>
The CI pre-check pins psf/black@stable, now 26.5.1, which wraps the assert condition rather than its message. Formatting only. Co-authored-by: Cursor <cursoragent@cursor.com>
The compact candidate-buffer path for the gfx950 decode top-k, measured over steps 17 to 20 of the port. Passes behind the fill read a buffer of survivors instead of re-scanning the row, and histogram_certificate lets a pass settle its digit by proving the merge finished rather than waiting at the row barrier. Includes the workgroup barrier at the end of flush_local_histogram. Without it the certified path runs the flush straight into load_global_histogram, which writes the same s_hist the flush is still reading and barriers only at its end, so a slower thread reads back the merged global count and adds that instead of its own contribution. The fill pass then waits on a total the corrupt histogram cannot reach and spins CERTIFICATE_MAX_SPINS, which is 4.28 s. Reading both histograms back out of the workspace after a stalled launch and counting the same row on the host is what identified it: pass 0 matches all 2048 bins exactly every time, and the fill histogram is off in both directions, -28 to +15, which only an added foreign count explains. 0 freezes in 200,000 launches with the barrier against 4 within the first 546 without, at no cost -- 257.7 us against 257.1 at 128x1M unordered. certificate_publish_total, certificate_fill_pass and certificate_trace default to off or neutral. The first two were fixes for the freeze written before the cause was known and are superseded by the barrier; publish_total is still worth measuring on its own merits. certificate_trace is diagnostic only and costs nothing when off.
There was a problem hiding this comment.
🟡 Changes recommended
The public dispatch path in aiter/ops/topk.py has two verified fallback/stride-handling bugs that can cause unexpected exceptions or incorrect results instead of clean HIP fallback.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
- Files reviewed: 10/11 changed files
- Comments generated: 2
- Review effort level: Lite
| if stride1 != 1 or logits.stride(1) != 1: | ||
| logits = logits.contiguous() | ||
| stride0, stride1 = logits.stride() | ||
|
|
| try: | ||
| from .flydsl.utils import is_flydsl_available as _is_flydsl_available | ||
| except ImportError: | ||
| return False | ||
| return _is_flydsl_available() |
Agent review: AITER PR #4355 — tiered persistent decode Top-KReviewed: #4355 This document is a read-only review. I inspected VerdictDo not merge this revision. The kernels can return indices outside the logical row when a device sequence length exceeds the logits width. The new compact kernel is also disconnected from runtime dispatch, AOT, and tests. The public wrapper has useful dtype, device, stride, workspace, and stream checks. The host-only dispatcher boundary tests pass. The change still needs one correctness fix and material integration, layout, and test cleanup. Review context
Changed files:
FlyDSL 0.3.2 provides the APIs used in the layout recommendations below. I verified FindingsFindings are ordered by severity. Each finding has a location, a trigger, a reason, and a proposed fix. P1 — Sequence lengths beyond the logical width select data from outside the rowWhere: Trigger: A Reason: Each kernel clamps Proposed fix: Pass P2 — The new compact kernel is not part of the operationWhere: Trigger: Every public call, direct FlyDSL call, AOT build, and new test in this revision. Reason: Repository-wide reference search finds the compact symbols only in their defining file. The latest commit adds 2810 lines of kernel and tuning code, but no caller can select or compile it. The standalone builder in Proposed fix: Decide which implementation defines the current operation. If the compact kernel is the replacement, connect its shape policy to the public wrapper, workspace sizing, cache key, AOT jobs, and canonical behavior test. Prove correctness and matched performance first. Then delete the superseded tiered and standalone implementations, their selectors, and stale configuration. If the compact kernel is not ready, remove it from this PR and land it only with its integration and verification. P2 — The new kernels bypass FlyDSL layout ownership for their core mappingsWhere: Trigger: All radix scans, histogram scans, output writes, and compact candidate scans. Reason: These new kernels use Proposed fix: Rewrite one retained kernel around these exact mappings:
Compile each mapping with FlyDSL 0.3.2. Compare frontend IR, post-layout IR, vector load/store ISA, LDS atomics, registers, LDS, scratch, occupancy, numerical results, and matched latency before removing the manual form. P2 — Architecture selection is process-global instead of tensor-device-specificExtra note: Using e.g. Where: Trigger: A process has devices of different supported architectures, or the current device architecture differs from Reason: The selected architecture comes from process-global current-device detection. The wrapper then keys the launcher and target-specific options with that architecture. Proposed fix: Derive the architecture from P2 — The tests do not follow the AITER operation-test contractNote: Do verify first before reading this comment if extra Where: Trigger: Normal operation-test maintenance, a tuning-table update, or the standard operation benchmark workflow. Reason: The AITER test guidance requires one canonical Proposed fix: Create one canonical operation test at P3 — New-file metadata and comments do not meet repository guidanceWhere: Trigger: Source distribution, generated notices, or reuse of these comments outside the original development context. Reason: Nearby new AITER files use the current copyright convention. The cleanup guidance also requires comments to explain the technical property without external ticket or review-history references. Proposed fix: Add the repository's current copyright line to retained new files. Replace external references with the measured width, row-count, architecture, and graph-replay facts that justify each threshold. Remove historical tuning narratives that do not define a maintained invariant. Verification gaps
|
Tiered persistent radix-select decode Top-K + arch/shape dispatcher
Tickets: SILOTIGER-699 (gfx950 decode Top-K) ·
SILOTIGER-629 /
SILOTIGER-683 (the kernel this builds on) —
epic SILOTIGER-606 (GLM-5 MXFP4, MI355X perf uplift)
What this is
for the sparse-attention indexer, which calls it ~78×/step. The HIP path it replaces
(
radix_topk_one_block<12,1024>) is an O(N_blocks) radix sort: 369 µs @ 8K → 1,165 µs @ 120K,~15× slower than B200's
persistent_topk_kernel. This one is O(topK·log topK).aiter.top_k_per_row_decode, routing there only where it is measured towin. Without it the kernel ships unreachable: the public op still calls HIP.
torch.topk, both arch sweepsUngated, this kernel loses on that grid; the gate is what turns it into a win — a routing result as
much as a kernel result.
Lineage
Sami's and Robin's commits ride along because that base is not in
mainyet. Base branchRElbers/aiter@samremes/topk-per-row.row_lentiers, row-barrier optimization, long-row cap48f0a93555569b72c77455152d4dtop_kto a compile-time parameter; workspace API, batch-aware tier config, deadlock guard, gfx950 params, non-pow2 K,tier_moderefactor, AOT entryb3a0d97c0b690901741ab755df3bc06b8db135ebcac278797f42bb804abc985c6c30d8← basebuffer_ops/vectorshims after #4501The generic tiering serialized wide decode batches into sequential co-resident waves. The switches
that fix it are gfx950-only, since gfx942 runs the frozen configuration:
short_max+dead_block_trim+ midbatch cap(
d2d2ad65383b16d783300),blocks_per_rowfloor 1 for the barrier-free short tier (2fc5ddc),row_proportional_parts(a87146d),batch_coresident_cap(197eda0),early_stop(d839939), and a rows>63 / L≤65536fold back to bpr=1 (
e7dde59).The gate
Five checks, cheapest first, all on host values. Per-row lengths live in
seqLenson the device,and reading them would sync every decode step.
AITER_DISABLE_FLYDSL_TOPK_DECODEk in ks, andnumRowsinside the row cap thatlogits.shape[1]earnsstride1 == 1,stride0 == logits.stride(0),next_n >= 1,logits.ndim == 2flydslimportableStep 4 is a contract check, not a filter: FlyDSL raises where HIP accepts.
The first band whose width a call reaches sets its cap, and a call reaching none is refused. Each
cap sits two rows below its measured crossing.
A wider buffer is more work per row, so cooperation keeps paying for more rows. One number would
refuse a wide band it wins, or admit a narrow band it loses.
Width asks "long-context model", not "long request". Callers size the buffer to the model's max
context — vLLM's indexer builds
logitsas(batch * next_n, max_model_len)— and under graphcapture the request length does not exist yet. Sweeping width at fixed real length moves the margin
under 2.2 µs, so the check works only because a request cannot outrun its buffer.
Thresholds come from graph replay only. Eager charges HIP 11.7 µs of host work per call against
FlyDSL's 2.6 µs, mostly an un-memoized
topk_ob_workspace_sizequery; its kinder column (in-windowmedian 1.135×, 349/360 won) is that artifact.
AITER_DISABLE_FLYDSL_TOPK_DECODE=1AITER_FLYDSL_TOPK_ARCHSAITER_FLYDSL_TOPK_MAX_ROWSAITER_FLYDSL_TOPK_MIN_WIDTHAITER_FLYDSL_TOPK_COUNT=1Why the gate lives in AITER. The alternative duplicates arch detection, availability checks,
shape thresholds and the HIP fallback in every calling framework.
gemm_op_a8w8.pydoes the same.Host cost is the same order as the kernel win, so two things came along:
workspaceis a parameter, with a buffer cached on(device, stream, size rounded to pow2)—worth ~2.4 µs, and returning nothing under graph capture.
workspace.zero_()is skipped when no stream was asked for.Measurements
--boost-ms 500 --warmup 10 --iters 40gfx950 vs HIP.
hip/ours > 1.00is faster. B tracks A, so the win is not a padding artifact.gfx950 through the dispatcher, width 163840, 200 back-to-back calls with one sync at the end.
hip / dispatch:10 win, 1 tie, 1 marginal (0.97 is ~0.5 µs, inside run-to-run noise). The
rows=16line predatesthe staircase; the cap here is 15, so it routes to HIP now.
gfx942 length curve, graph replay, median over k × rows, in the window that shipped when this
was measured (width ≥ 131072, rows ≤ 16). The advantage is a function of the real request length,
which the gate cannot see.
seq_lenmost cells win. Break-even needs long requests to be 25.6% of traffic.
both measured as gains (+2.41 and +1.99 µs per cell). The shipped window scores no worse, but this
table has not been re-run.
Near-threshold cells were repeated in three independent runs.
vs Robin's gfx950 base, informational only — that base was not tuned for wide batch and this
path trails HIP there anyway: rows=1 1.15–1.19×, rows 4–8 parity, rows ≥ 16 2.0×–10.5×. Full sweep
in the SILOTIGER-699 results comment.
Correctness
Buffers are padded past the real length with the tail poisoned, so a kernel ignoring
seqLensfailsloudly instead of passing by accident.
Unit tests on gfx950: 195/195 (dispatcher + kernel, including the stable exact-order cases). Two of the 33 pin the staircase —
the bands descend with width, and one row count is refused at 131072 and admitted at 163840.
Run:
pytest op_tests/test_topk_decode_dispatch.py,pytest op_tests/flydsl_tests/test_flydsl_topk_per_row_decode.py,python op_tests/topk_decode_correctness.py, andpython op_tests/benchmark_topk_per_row_decode.pyunder rocprofv3.stable=True (ordered emit)
Previously
stable=Trueforced HIP: FlyDSL returned an unordered set. This commitadds an ordered emit to the tiered kernel and routes
stable=Truethroughthe same gate bands as the unordered path (
ordered=stableforwarded toFlyDSL).
Implementation highlights:
(
s_own_hist) — no extra row read for a count pass.ordered=Truedisables the short-tier atomic scatter andearly_stop(both assume unordered output); persistent path covers all tiers.
ordered(different kernel).Correctness contract: exact index list equality, ascending with
smallest-index tie-break on the kth value — not set equivalence. Tie-dense
inputs in tests.
pytest op_tests/flydsl_tests/test_flydsl_topk_per_row_decode.py+op_tests/test_topk_decode_dispatch.pypython op_tests/test_topk_per_row_stable.pyPerformance (stable, k=2048, 3-rep median µs, vs #5011 ordered / HIP):
Gate region (26–32 × 700K–1M): 5011 parity (0.99–1.05×), HIP 7–8×.
Stable overhead vs unordered: 1.12–1.18× in that region. Only regression:
rows=8 × 700K/1M ~1.11× vs 5011 (gate-outside; HIP ~92/118 µs there).
Raw:
JHK_TASK/.../results_this_node/final/stable_fused_rep{1,2,3}.jsonl.Follow-ups
zero_()is 3.7 µs of launch latency per call, the largest item leftstride0, this gate's information problemSee also