Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
170 changes: 170 additions & 0 deletions atom/model_ops/bonus_sample.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
# SPDX-License-Identifier: MIT
# Copyright (C) 2024-2026, Advanced Micro Devices, Inc. All rights reserved.
"""`ATOM_SPEC_BONUS_FUSED`: the unfiltered temperature sample in two vocab-tiled
launches instead of five.

`Sampler._temperature_sample` + `_apply_greedy_tokens` run, per call,
`mixed_sample_outer_exponential` (one program per row walking the whole vocab),
then `topk_select` (two launches), `<=` and `where`. Here:

1. `_bonus_tile_kernel` [(row, vocab tile)]: the tile's max of x = logit / T and
its best race key exp(x - tile_max) / (E + eps) with the lowest id among equal
keys, plus the tile's raw-logit argmax (lowest id) for greedy rows.
2. `_bonus_pick_kernel` [(row,)]: greedy rows (T <= the sampler's greedy
epsilon) take the global raw-logit argmax, lowest id on ties -- exactly
`topk_select(tie="low")`; sampling rows rescale every tile key to the global
max and take the best.

The noise is the same `exponential_` draw `_temperature_sample` makes (same
generator, same tensor shape), and the race is the same: argmax_i p_i / (E_i + eps)
with p = softmax(logit / max(T, 1e-5)) (aiter's clamp), so the drawn token has
exactly the same distribution. The fp32 rounding of the keys differs from the
aiter kernel's per-thread running-max rescale, so on a near-exact tie of the two
best keys (relative gap ~1e-7) the pick can differ; greedy rows are bit-identical.
"""

import torch
import triton
import triton.language as tl

_BONUS_BLOCK_V = 4096


@triton.jit
def _bonus_tile_kernel(
Comment thread
chelnnexy marked this conversation as resolved.
Resolved

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

this is a second impl of aiter's mixed_sample_outer_exponential.. why not put it in aiter and call here?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Agreed, and it's moved: ROCm/aiter#7416 adds it to aiter as fused_mixed_sample (Triton, in aiter/ops/triton/fusions/, with configs tuned per arch, a unit test and a benchmark).

It uses the same race and the same noise as mixed_sample_outer_exponential (that's why the agreement check is exact), but it isn't the same kernel: aiter's kernel runs one workgroup per row over the whole vocab, while this one tiles each row across the grid and folds in the greedy override (topk_select + <= + where), so the draw takes 2 launches instead of 5. In the aiter version, greedy rows also skip the race and the noise read.

With the tuned configs, the aiter version is faster than the HIP path at every row count we measured, including 256 rows (−33% on MI355X, −27% on MI300X), where the kernel here is break-even.

#2914 doesn't need to wait for it: the kernel here stays behind the default-off flag. Once a release with fused_mixed_sample is in ATOM's image, a small follow-up switches sampler.py to it and deletes atom/model_ops/bonus_sample.py.

Related: the existing aiter kernel's out-of-range ids at small vocab sizes, from the PR description, were fixed today in ROCm/aiter#7373.

logits_ptr,
stride_l,
exp_ptr,
stride_e,
temps_ptr,
eps,
part_m_ptr, # [n_tiles, rows] fp32
part_key_ptr, # [n_tiles, rows] fp32
part_idx_ptr, # [n_tiles, rows] int32
part_gmax_ptr, # [n_tiles, rows] fp32
part_gidx_ptr, # [n_tiles, rows] int32
rows,
vocab,
BLOCK_V: tl.constexpr,
):
r = tl.program_id(0)
t = tl.program_id(1)
offs = t * BLOCK_V + tl.arange(0, BLOCK_V)
mask = offs < vocab
x = tl.load(
logits_ptr + r.to(tl.int64) * stride_l + offs, mask=mask, other=-float("inf")
).to(tl.float32)
temp = tl.load(temps_ptr + r).to(tl.float32)
inv_t = 1.0 / tl.maximum(temp, 1e-5)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

all -inf tile here gives -inf - -inf = NaN.. reachable with required tool_choice, please guard m == -inf and add a test

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Confirmed on MI355X (gfx950) and MI300X (gfx942): the current kernel gives the same NaN result on both. The fix follows @yitingw1's version: guard the tile max and remove the clamp. Full results are in the top-level comment.

Before the fix (PR head 680d7359), gpu_check.py:

[1] NaN partial keys on a forced row: 78 / 80   (unpatched: >0 expected = bug present; patched: must be 0)
[2] forced rows wrong: 0 / 7 
[3] masked rows: 0 unexplained mismatches, 0 near-ties, 0 out-of-range ids, over 1600 rows
[4] all -inf rows out of range: 0 / 4 [163839, 163839, 0, 163839]
FAIL (1 check(s))

There are NaN keys in 78 of 80 tiles for a forced row. The forced id still wins only because tl.max drops NaN. Separately, on a row that is -inf throughout, the sampling result is 163839: that is the vocab sentinel after tl.minimum(tok, vocab - 1), which shows the clamp is what's hiding it.

Fix:

@@ _bonus_tile_kernel
-    key = tl.where(mask, tl.exp(xs - m) / e, -1.0)
+    m_safe = tl.where(m == -float("inf"), 0.0, m)
+    key = tl.where(mask, tl.exp(xs - m_safe) / e, -1.0)
@@ _bonus_pick_kernel
         mm = tl.max(m, axis=0)
+        mm = tl.where(mm == -float("inf"), 0.0, mm)
         ks = tl.where(tm, k * tl.exp(m - mm), -1.0)
@@
-    tok = tl.minimum(tok, vocab - 1)
  • Cost: one scalar where per program in each kernel. The per-element work is unchanged.
  • Rows with no -inf logits: results are unchanged. Checked against the current kernel on 80 rows at vocab 1000 to 163840.
  • All--inf tile: keys every id at 0, so it loses to any tile with a finite logit, and its idx stays in range.
  • Pick kernel: the mm guard covers a row that is -inf throughout. That row now returns id 0 instead of the clamped sentinel.
  • The clamp: with the guard in place it can no longer change a result, so it's removed. Any future escape will now show up as an out-of-range id instead of a plausible but wrong token.

Tests in tests/test_bonus_sample.py, with RuntimeWarning turned into an error:

  • test_a_forced_channel_row_samples_the_forced_id: forced id at 5 / 100000 / 163837, for both T=1.0 and greedy rows.
  • test_masked_ids_never_win_and_every_id_is_in_range: whole -inf tiles plus scattered -inf ids, compared against the float64 race, plus one row that is -inf throughout.

Results under the interpreter: the current kernel fails 4 of these, and with the fix the file gives 13 passed, 1 skipped. black and ruff report nothing.

After the fix, gpu_check.py:

MI355X (gfx950):

[1] NaN partial keys on a forced row: 0 / 80   (unpatched: >0 expected = bug present; patched: must be 0)
[2] forced rows wrong: 0 / 7 
[3] masked rows: 0 unexplained mismatches, 0 near-ties, 0 out-of-range ids, over 1600 rows
[4] all -inf rows out of range: 0 / 4 [0, 0, 0, 0]
PASS

MI300X (gfx942):

[1] NaN partial keys on a forced row: 0 / 80   (unpatched: >0 expected = bug present; patched: must be 0)
[2] forced rows wrong: 0 / 7 
[3] masked rows: 0 unexplained mismatches, 0 near-ties, 0 out-of-range ids, over 1600 rows
[4] all -inf rows out of range: 0 / 4 [0, 0, 0, 0]
PASS

Pushed as e34da58. The branch is also rebased onto main, which brings in the loader fix from #2936.

xs = x * inv_t
m = tl.max(xs, axis=0)
e = tl.load(exp_ptr + r.to(tl.int64) * stride_e + offs, mask=mask, other=1.0) + eps
# A tile the required channel masked to -inf has m = -inf, and
# exp(-inf - -inf) is NaN. Shifting by 0 instead keys every id 0: the tile
# loses to any tile with a finite logit and its idx stays in range.
m_safe = tl.where(m == -float("inf"), 0.0, m)
key = tl.where(mask, tl.exp(xs - m_safe) / e, -1.0)
best = tl.max(key, axis=0)
idx = tl.min(tl.where((key == best) & mask, offs, vocab), axis=0)
gmax = tl.max(x, axis=0)
gidx = tl.min(tl.where((x == gmax) & mask, offs, vocab), axis=0)
o = t * rows + r
tl.store(part_m_ptr + o, m)
tl.store(part_key_ptr + o, best)
tl.store(part_idx_ptr + o, idx)
tl.store(part_gmax_ptr + o, gmax)
tl.store(part_gidx_ptr + o, gidx)


@triton.jit
def _bonus_pick_kernel(
out_ptr, # [rows] int32
temps_ptr,
greedy_eps,
part_m_ptr,
part_key_ptr,
part_idx_ptr,
part_gmax_ptr,
part_gidx_ptr,
rows,
n_tiles,
vocab,
BLOCK_T: tl.constexpr,
):
r = tl.program_id(0)
ti = tl.arange(0, BLOCK_T)
tm = ti < n_tiles
o = ti * rows + r
temp = tl.load(temps_ptr + r).to(tl.float32)
if temp <= greedy_eps:
g = tl.load(part_gmax_ptr + o, mask=tm, other=-float("inf"))
gi = tl.load(part_gidx_ptr + o, mask=tm, other=vocab)
gm = tl.max(g, axis=0)
tok = tl.min(tl.where((g == gm) & tm, gi, vocab), axis=0)
else:
m = tl.load(part_m_ptr + o, mask=tm, other=-float("inf"))
k = tl.load(part_key_ptr + o, mask=tm, other=-1.0)
ki = tl.load(part_idx_ptr + o, mask=tm, other=vocab)
mm = tl.max(m, axis=0)
# An all -inf row would otherwise compute exp(-inf - -inf) here too.
mm = tl.where(mm == -float("inf"), 0.0, mm)
ks = tl.where(tm, k * tl.exp(m - mm), -1.0)
best = tl.max(ks, axis=0)
tok = tl.min(tl.where((ks == best) & tm, ki, vocab), axis=0)
tl.store(out_ptr + r, tok.to(tl.int32))


def bonus_temperature_sample(
logits: torch.Tensor, # [rows, V]
temperatures: torch.Tensor, # [rows] fp32, pre-clamped to SAMPLER_EPS
exponential: torch.Tensor, # [rows, V] fp32 Exp(1) noise (unit inner stride)
eps: float,
greedy_eps: float,
) -> torch.Tensor:
rows, vocab = logits.shape
out = torch.empty(rows, dtype=torch.int, device=logits.device)
if rows == 0:
return out
assert logits.stride(1) == 1 and exponential.stride(1) == 1
n_tiles = triton.cdiv(vocab, _BONUS_BLOCK_V)
f32 = {"dtype": torch.float32, "device": logits.device}
i32 = {"dtype": torch.int32, "device": logits.device}
part_m = torch.empty((n_tiles, rows), **f32)
Comment thread
chelnnexy marked this conversation as resolved.
Resolved

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

still allocate 5 buffers per call on the hot path.. cache them keyed by (n_tiles, rows)?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I tried it and measured it, and kept torch.empty.

A cached buffer, keyed by (device, stream) and grown on demand, made the draw slower: about +2.2 µs per call at every row count on MI355X. The main path stayed within ±0.4 µs between the two runs, so this is not run-to-run noise. The cost is in Python: looking up the current stream builds a Stream object on every call, and slicing views out of the buffer creates as many tensor objects as torch.empty does. The memory itself is not allocated per call. PyTorch's caching allocator already hands back the same blocks, so there is nothing to save there.

MI355X, median µs of 300 calls:

rows main path fused, torch.empty fused, cached buffer
1 60.08 → 59.64 44.76 46.88 (+2.12)
4 61.42 → 61.08 44.60 46.80 (+2.20)
8 62.36 → 62.12 44.68 46.96 (+2.28)
16 62.60 → 62.24 44.64 46.66 (+2.02)
32 64.08 → 64.12 45.04 47.36 (+2.32)
64 66.88 → 66.52 43.16 45.36 (+2.20)
128 70.84 → 70.66 54.06 57.08 (+3.02)
256 78.28 → 78.28 78.80 81.44 (+2.64)

So the five torch.empty calls stay.

part_key = torch.empty((n_tiles, rows), **f32)
part_idx = torch.empty((n_tiles, rows), **i32)
part_gmax = torch.empty((n_tiles, rows), **f32)
part_gidx = torch.empty((n_tiles, rows), **i32)
temps = temperatures.contiguous()
_bonus_tile_kernel[(rows, n_tiles)](
logits,
logits.stride(0),
exponential,
exponential.stride(0),
temps,
eps,
part_m,
part_key,
part_idx,
part_gmax,
part_gidx,
rows,
vocab,
BLOCK_V=_BONUS_BLOCK_V,
num_warps=8,
)
_bonus_pick_kernel[(rows,)](
out,
temps,
greedy_eps,
part_m,
part_key,
part_idx,
part_gmax,
part_gidx,
rows,
n_tiles,
vocab,
BLOCK_T=max(16, triton.next_power_of_2(n_tiles)),
num_warps=1,
)
return out
19 changes: 19 additions & 0 deletions atom/model_ops/sampler.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
from aiter.ops.triton.topk import topk
from torch import nn

from atom.utils import envs

# Try to import aiter top-k/top-p sampling ops
try:
import aiter.ops.sampling # noqa: F401
Expand Down Expand Up @@ -65,6 +67,13 @@ class Sampler(nn.Module):
def __init__(self):
super().__init__()
self.eps = SAMPLER_EPS
# `ATOM_SPEC_BONUS_FUSED`: read once, so it must be set before the
# server starts. None keeps the aiter spelling in `forward`.
self._fused_temperature_sample = None
if envs.ATOM_SPEC_BONUS_FUSED:
from atom.model_ops.bonus_sample import bonus_temperature_sample

self._fused_temperature_sample = bonus_temperature_sample

def sample_verification_tokens(
self,
Expand Down Expand Up @@ -144,6 +153,16 @@ def forward(

# No Top-K Top-P parameters, perform temperature-based sampling
if not self._needs_filtering(top_ks, top_ps):
if self._fused_temperature_sample is not None and needs_independent_noise:
# The same noise draw as `_temperature_sample`, then the race
# and the greedy override in two vocab-tiled launches.
num_tokens, vocab_size = logits.shape
exponential = torch.empty(
Comment thread
chelnnexy marked this conversation as resolved.
Resolved

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

same exponential_ draw spelled twice, fused path relies on same rng.. one helper for both?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done in 6a9bc8f. get_per_row_exponential(num_tokens, vocab_size, device) in sampler.py is now the only place the per-row draw happens. _temperature_sample and the fused branch both call it, so they can't drift apart and they consume the RNG identically.

(num_tokens, vocab_size), dtype=torch.float, device=logits.device
).exponential_(1)
return self._fused_temperature_sample(
logits, temperatures, exponential, self.eps, SAMPLER_EPS
)
sampled = self._temperature_sample(
logits, temperatures, needs_independent_noise=needs_independent_noise
)
Expand Down
14 changes: 14 additions & 0 deletions atom/utils/envs.py
Original file line number Diff line number Diff line change
Expand Up @@ -1015,6 +1015,20 @@ def _finite_float_env(name: str, default: float, *, allow_zero: bool) -> float:
"ATOM_ENABLE_RELAXED_MTP": lambda: (
os.getenv("ATOM_ENABLE_RELAXED_MTP", "0").lower() == "1"
),
# "1": an unfiltered, per-row-noise draw (the speculative bonus token, and
# every draft-conditioned verification row of a non-greedy batch) runs as
# two vocab-tiled Triton launches (`model_ops/bonus_sample.py`) instead of
# `mixed_sample_outer_exponential` + `topk_select` + `<=` + `where` (five
# launches, the first with one program per row). It draws the same
# `exponential_` noise and runs the same race, argmax_i p_i / (E_i + eps),
# so the sample has the same distribution; greedy rows (temperature at or
# below the sampler's epsilon) are bit-identical, lowest id on ties.
# Sampling rows can differ from the aiter kernel only when the two best
# race keys sit within ~1e-7 relative of each other (fp32 rounding order).
# Rows with top-k/top-p, and batches without per-row noise, keep the aiter
# path. Read when the `Sampler` is built, so it must be set before the
# server starts.
"ATOM_SPEC_BONUS_FUSED": lambda: os.getenv("ATOM_SPEC_BONUS_FUSED", "0") == "1",

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

why default off? the sibling ATOM_DSPARK_FUSED_MARKOV_SAMPLE is on by default..

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

It stays off on purpose. The reason is now in the flag's comment and in environment_variables.md (c70d5bf):

  • Not bit-exact: sampling rows can differ from the aiter kernel on fp32 near-ties (~1e-7 relative).
  • The gain shrinks with verify rows: on MI355X it's about −25% up to 128 rows, but nothing at 256 (about Kimi-K3 DSpark at concurrency 64).

ATOM_DSPARK_FUSED_MARKOV_SAMPLE doesn't have that trade-off. The tuned aiter version (see the aiter thread) is faster at 256 rows too; switching to it is the point to revisit the default.

# --- Atomesh ---
# Build atomesh when installing ATOM from source.
"ATOM_MESH_BUILD": lambda: os.getenv("ATOM_MESH_BUILD", "0") == "1",
Expand Down
1 change: 1 addition & 0 deletions docs/environment_variables.md
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,7 @@ that ever changes, so a fusion left inert by an unrecognised layout says so.
| Variable | Type | Default | Description |
|----------|------|---------|-------------|
| **ATOM_ENABLE_RELAXED_MTP** | bool | 0 (false) | Accept a draft token when it lands in the target's top 10 within 0.6 of the top logit, instead of requiring the argmax. Intended for quantized MTP heads, whose drafts are right about the region and wrong about the exact winner often enough that strict acceptance throws away usable tokens. Read once at `rejection_sampler` import, so it must be set before the server starts. |
| **ATOM_SPEC_BONUS_FUSED** | bool | 0 (false) | Draw the unfiltered, per-row-noise speculative sample (the bonus token, and the verification rows of a non-greedy batch) in two vocab-tiled Triton launches (`model_ops/bonus_sample.py`) instead of `mixed_sample_outer_exponential` + `topk_select` + `<=` + `where` (five launches). Same `exponential_` noise and same race, argmax_i p_i / (E_i + eps), so the distribution is unchanged; greedy rows are bit-identical (lowest id on ties); a sampling row can differ from the aiter kernel only if its two best race keys are within ~1e-7 relative (fp32 rounding order). Rows with top-k/top-p, and batches without per-row noise, keep the aiter path. Read when the `Sampler` is built, so set it before the server starts. |

## Engram (DeepSeek-V4.1)

Expand Down
3 changes: 3 additions & 0 deletions docs/model_ops_guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -496,6 +496,8 @@ def forward(self, logits, temperatures) -> sampled_tokens:
- `greedy_sample()`: `aiter.ops.triton.topk.topk(logits, 1)`
- `random_sample()`: `aiter.ops.triton.softmax.softmax(logits)` followed by exponential sampling and `topk`.

**Fused unfiltered draw (`ATOM_SPEC_BONUS_FUSED=1`, default off).** For an unfiltered batch with per-row noise (the speculative bonus token, and the verification rows of a non-greedy batch), `bonus_temperature_sample` (`bonus_sample.py`) replaces `mixed_sample_outer_exponential` + `topk_select` + `<=` + `where` with two vocab-tiled launches: a `(row, tile)` kernel keeps each tile's best race key and raw-logit argmax, and a `(row,)` kernel combines the tiles. The noise is the same `exponential_` draw and the race is the same, so the distribution is unchanged; greedy rows are bit-identical (lowest id on ties); a sampling row can differ only when its two best race keys are within ~1e-7 relative. Rows with top-k/top-p keep the aiter path.

### Required channel (`required_channel.py`)

Holds a request with a `RequiredChannel` (`atom/sampling_params.py`; today only
Expand Down Expand Up @@ -572,6 +574,7 @@ ATOM uses fused kernels to reduce memory traffic by combining multiple operation
| `topK.py` | `rocm_aiter_topk_softmax`, `rocm_aiter_grouped_topk`, `init_aiter_topK_meta_data` |
| `triton_topk_softmax.py` | `triton_topk_softmax` -- renormalized softmax top-k, bit-identical to AITER's `topk_softmax` |
| `sampler.py` | `Sampler` — unified greedy/random sampling |
| `bonus_sample.py` | `bonus_temperature_sample` — the unfiltered per-row-noise draw in two vocab-tiled Triton launches (`ATOM_SPEC_BONUS_FUSED`) |
| `rejection_sampler.py` | `RejectionSampler` — speculative decoding rejection sampling |
| `required_channel.py` | `constrain_logits`, `advance_states` — `tool_choice="required"` channel mask (Kimi-K3) |
| `base_config.py` | `QuantizeMethodBase` abstract class |
Expand Down
Loading