Skip to content

Commit 7ede2d5

Browse files
committed
Address MLX top-k sampling review
1 parent 45b4430 commit 7ede2d5

5 files changed

Lines changed: 136 additions & 81 deletions

File tree

backends/mlx/custom_ops.py

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -397,24 +397,24 @@ def gather_qmm_fake(
397397
def sample(
398398
logits: Tensor,
399399
temperature: Tensor,
400+
top_k: Tensor,
400401
top_p: Tensor,
401402
seed: Optional[Tensor] = None,
402-
top_k: Optional[Tensor] = None,
403403
) -> Tensor:
404404
"""
405-
Gumbel-max sampling from softmax(logits / temperature), with top-p (nucleus)
406-
and optional top-k filtering.
405+
Gumbel-max sampling from softmax(logits / temperature), with top-k and
406+
top-p (nucleus) filtering.
407407
logits: [B, vocab]
408408
temperature: scalar float tensor (runtime input). temperature <= 0 is
409409
greedy: return argmax(logits) directly (matches the device,
410410
which branches on temperature > 0).
411+
top_k: scalar int tensor. It is clipped to the vocab size; using the
412+
max int default keeps every token.
411413
top_p: scalar float tensor in (0, 1]. top_p=1.0 keeps every
412414
token, i.e. it is off.
413415
seed: scalar int tensor or None
414416
- tensor -> deterministic, keyed RNG (random::key(seed))
415417
- None -> MLX global KeySequence (non-deterministic)
416-
top_k: optional scalar int tensor. None keeps every token; otherwise
417-
sampling is restricted to the k most likely tokens.
418418
-> token_id: [B] int64
419419
420420
Host/CPU reference used for export (shape/meta) and distributional checks
@@ -426,12 +426,16 @@ def sample(
426426
return torch.argmax(logits, dim=-1)
427427
# whole chain in fp32 to match the lowered graph (bf16 sums mis-rank ties).
428428
scaled = logits.float() / temperature
429+
430+
k = min(int(top_k.item()), scaled.shape[-1])
431+
s_scaled, _ = torch.sort(scaled, dim=-1, descending=True)
432+
kth = s_scaled[..., k - 1 : k]
433+
scaled = torch.where(scaled >= kth, scaled, scaled.new_tensor(float("-inf")))
434+
435+
# Apply top-p after top-k so the probabilities are renormalized over the
436+
# top-k subset.
429437
probs = torch.softmax(scaled, dim=-1)
430438
s_probs, _ = torch.sort(probs, dim=-1, descending=True)
431-
if top_k is not None:
432-
k = int(top_k.item())
433-
kth = s_probs[..., k - 1 : k]
434-
scaled = torch.where(probs >= kth, scaled, scaled.new_tensor(float("-inf")))
435439
cum = torch.cumsum(s_probs, dim=-1)
436440
keep = (cum - s_probs) <= top_p
437441
thresh = torch.where(keep, s_probs, s_probs.new_tensor(float("inf"))).amin(
@@ -448,5 +452,5 @@ def sample(
448452

449453

450454
@torch.library.register_fake("mlx::sample")
451-
def sample_fake(logits, temperature, top_p, seed=None, top_k=None):
455+
def sample_fake(logits, temperature, top_k, top_p, seed=None):
452456
return logits.new_empty(logits.shape[:-1], dtype=torch.long)

backends/mlx/llm/sampling.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,8 @@ class SamplingHead(nn.Module):
2020
temperature: scalar float tensor, e.g. torch.tensor(0.8). Must be >= 0;
2121
temperature=0 is greedy (returns argmax, no division).
2222
top_k: scalar int tensor or int; keeps only the k most likely tokens.
23-
None disables top-k filtering.
23+
None uses the max int default, which is clipped to the vocab
24+
size and keeps every token.
2425
top_p: scalar float tensor in (0, 1] for nucleus sampling. top_p=1.0
2526
(the default) keeps every token, i.e. no filtering. Pass it
2627
as a runtime input to tune per request.
@@ -36,6 +37,8 @@ def forward(self, *args, temperature, top_k=None, top_p=1.0, seed=None, **kwargs
3637
last = logits[:, -1, :] # [B, vocab]
3738
if not isinstance(top_p, torch.Tensor):
3839
top_p = torch.tensor(float(top_p))
39-
if top_k is not None and not isinstance(top_k, torch.Tensor):
40+
if top_k is None:
41+
top_k = torch.tensor(torch.iinfo(torch.int64).max, dtype=torch.int64)
42+
elif not isinstance(top_k, torch.Tensor):
4043
top_k = torch.tensor(int(top_k), dtype=torch.int64)
41-
return torch.ops.mlx.sample(last, temperature, top_p, seed, top_k)
44+
return torch.ops.mlx.sample(last, temperature, top_k, top_p, seed)

backends/mlx/ops.py

Lines changed: 80 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,6 @@
2424
emit_lifted_constant,
2525
emit_quantized_biases,
2626
emit_shape,
27-
emit_sub_int,
2827
parse_dequant_node,
2928
to_mlx_qparams,
3029
torch_dtype_to_scalar_type,
@@ -3529,11 +3528,10 @@ def _sample_handler(P: MLXProgramBuilder, n: Node) -> Slot:
35293528
skipping the sampling chain (so 0 is exact, not the small-epsilon approx).
35303529
"""
35313530
args = P.args(n)
3532-
require_args(args, 3, 5, "mlx.sample")
3531+
require_args(args, 4, 5, "mlx.sample")
35333532
require_kwargs(P.kwargs(n), set(), "mlx.sample")
3534-
logits, temperature, top_p = args[0], args[1], args[2]
3535-
seed = args[3] if len(args) > 3 and args[3] is not None else None
3536-
top_k = args[4] if len(args) > 4 and args[4] is not None else None
3533+
logits, temperature, top_k, top_p = args[0], args[1], args[2], args[3]
3534+
seed = args[4] if len(args) > 4 and args[4] is not None else None
35373535

35383536
temp_dt = n.args[1].meta["val"].dtype
35393537
out = P.make_or_get_slot(n)
@@ -3614,8 +3612,84 @@ def emit_sample():
36143612
)
36153613
)
36163614
scaled = logits_f
3615+
neg_inf = emit_lifted_constant(P, float("-inf"), torch.float32)
3616+
3617+
# Top-k first, on scaled logits. Clip k to vocab size so the default
3618+
# max-int sentinel selects every token.
3619+
vocab_size = int(n.args[0].meta["val"].shape[-1])
3620+
vocab = emit_lifted_constant(P, vocab_size, torch.int64)
3621+
_, clipped_top_k = P.make_tmp_slot()
3622+
P.emit(
3623+
MinimumNode(
3624+
a=P.slot_to_tid(top_k),
3625+
b=P.slot_to_tid(vocab),
3626+
out=P.slot_to_tid(clipped_top_k),
3627+
)
3628+
)
3629+
_, top_k_val = P.make_tmp_value_slot()
3630+
P.emit(
3631+
ItemIntNode(
3632+
x=P.slot_to_tid(clipped_top_k), out=P.slot_to_vid(top_k_val)
3633+
)
3634+
)
3635+
_, top_k_index = P.make_tmp_value_slot()
3636+
P.emit(
3637+
SubtractIntNode(
3638+
a=P.to_int_or_vid(top_k_val),
3639+
b=IntOrVid.from_literal(1),
3640+
out=P.slot_to_vid(top_k_index),
3641+
)
3642+
)
36173643

3618-
# top-p nucleus mask; SortNode is ascending-only, so sort -probs for descending.
3644+
_, sorted_scaled = P.make_tmp_slot()
3645+
P.emit(NegNode(x=P.slot_to_tid(scaled), out=P.slot_to_tid(sorted_scaled)))
3646+
P.emit(
3647+
SortNode(
3648+
x=P.slot_to_tid(sorted_scaled),
3649+
out=P.slot_to_tid(sorted_scaled),
3650+
axis=-1,
3651+
)
3652+
)
3653+
P.emit(
3654+
NegNode(x=P.slot_to_tid(sorted_scaled), out=P.slot_to_tid(sorted_scaled))
3655+
)
3656+
_, top_k_thresh = P.make_tmp_slot()
3657+
P.emit(
3658+
TakeNode(
3659+
x=P.slot_to_tid(sorted_scaled),
3660+
index=P.to_int_or_vid_or_tid(top_k_index),
3661+
out=P.slot_to_tid(top_k_thresh),
3662+
axis=-1,
3663+
)
3664+
)
3665+
P.emit(
3666+
ExpandDimsNode(
3667+
x=P.slot_to_tid(top_k_thresh),
3668+
out=P.slot_to_tid(top_k_thresh),
3669+
axis=-1,
3670+
)
3671+
)
3672+
_, drop_k = P.make_tmp_slot()
3673+
P.emit(
3674+
LessNode(
3675+
a=P.slot_to_tid(scaled),
3676+
b=P.slot_to_tid(top_k_thresh),
3677+
out=P.slot_to_tid(drop_k),
3678+
)
3679+
)
3680+
_, top_k_scaled = P.make_tmp_slot()
3681+
P.emit(
3682+
WhereNode(
3683+
condition=P.slot_to_tid(drop_k),
3684+
x=P.slot_to_tid(neg_inf),
3685+
y=P.slot_to_tid(scaled),
3686+
out=P.slot_to_tid(top_k_scaled),
3687+
)
3688+
)
3689+
scaled = top_k_scaled
3690+
3691+
# Top-p nucleus mask on probabilities renormalized over the top-k set.
3692+
# SortNode is ascending-only, so sort -probs for descending.
36193693
# probs is read twice (neg_p below and the drop comparison), keep separate.
36203694
_, probs = P.make_tmp_slot()
36213695
P.emit(SoftmaxNode(x=P.slot_to_tid(scaled), out=P.slot_to_tid(probs), axis=-1))
@@ -3676,47 +3750,6 @@ def emit_sample():
36763750
out=P.slot_to_tid(drop),
36773751
)
36783752
)
3679-
if top_k is not None:
3680-
_, top_k_val = P.make_tmp_value_slot()
3681-
P.emit(ItemIntNode(x=P.slot_to_tid(top_k), out=P.slot_to_vid(top_k_val)))
3682-
top_k_iov = P.to_int_or_vid(top_k_val)
3683-
top_k_index = emit_sub_int(P, top_k_iov, IntOrVid.from_literal(1))
3684-
_, top_k_thresh = P.make_tmp_slot()
3685-
P.emit(
3686-
TakeNode(
3687-
x=P.slot_to_tid(sorted_p),
3688-
index=(
3689-
IntOrVidOrTid.from_vid(top_k_index.vid)
3690-
if top_k_index.is_vid
3691-
else IntOrVidOrTid.from_literal(top_k_index.literal)
3692-
),
3693-
out=P.slot_to_tid(top_k_thresh),
3694-
axis=-1,
3695-
)
3696-
)
3697-
P.emit(
3698-
ExpandDimsNode(
3699-
x=P.slot_to_tid(top_k_thresh),
3700-
out=P.slot_to_tid(top_k_thresh),
3701-
axis=-1,
3702-
)
3703-
)
3704-
_, drop_k = P.make_tmp_slot()
3705-
P.emit(
3706-
LessNode(
3707-
a=P.slot_to_tid(probs),
3708-
b=P.slot_to_tid(top_k_thresh),
3709-
out=P.slot_to_tid(drop_k),
3710-
)
3711-
)
3712-
P.emit(
3713-
LogicalOrNode(
3714-
a=P.slot_to_tid(drop),
3715-
b=P.slot_to_tid(drop_k),
3716-
out=P.slot_to_tid(drop),
3717-
)
3718-
)
3719-
neg_inf = emit_lifted_constant(P, float("-inf"), torch.float32)
37203753
# masked = where(drop, -inf, scaled); then add gumbel noise in place.
37213754
_, masked = P.make_tmp_slot()
37223755
P.emit(

backends/mlx/test/test_ops.py

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -7697,12 +7697,14 @@ class SampleSeededTest(OpTestCase):
76977697
"IfNode": 1, # temperature==0 greedy branch
76987698
"RandomBitsNode": 1,
76997699
"ArgmaxNode": 2, # sampling branch + greedy branch
7700-
"ItemIntNode": 2, # seed + temperature>0 condition
7700+
"ItemIntNode": 3, # seed + top_k + temperature>0 condition
77017701
"SoftmaxNode": 1, # top-p nucleus chain
7702-
"SortNode": 1,
7702+
"SortNode": 2, # top-k threshold + top-p nucleus chain
77037703
"CumsumNode": 1,
77047704
"MinNode": 1,
7705-
"WhereNode": 2,
7705+
"TakeNode": 1,
7706+
"ExpandDimsNode": 1,
7707+
"WhereNode": 3,
77067708
}
77077709

77087710
def create_model(self) -> nn.Module:
@@ -7726,7 +7728,7 @@ class SampleUnseededTest(OpTestCase):
77267728
"IfNode": 1,
77277729
"RandomBitsNode": 1,
77287730
"ArgmaxNode": 2,
7729-
"ItemIntNode": 1, # temperature>0 condition only (no seed)
7731+
"ItemIntNode": 2, # top_k + temperature>0 condition only (no seed)
77307732
"SoftmaxNode": 1, # top-p nucleus chain (top_p defaults to 1.0)
77317733
}
77327734

@@ -7747,12 +7749,14 @@ class SampleTopPTest(OpTestCase):
77477749
"IfNode": 1,
77487750
"RandomBitsNode": 1,
77497751
"ArgmaxNode": 2,
7750-
"ItemIntNode": 2,
7752+
"ItemIntNode": 3,
77517753
"SoftmaxNode": 1,
7752-
"SortNode": 1,
7754+
"SortNode": 2,
77537755
"CumsumNode": 1,
77547756
"MinNode": 1,
7755-
"WhereNode": 2,
7757+
"TakeNode": 1,
7758+
"ExpandDimsNode": 1,
7759+
"WhereNode": 3,
77567760
}
77577761

77587762
def create_model(self) -> nn.Module:
@@ -7769,7 +7773,7 @@ def create_inputs(self) -> Tuple[torch.Tensor, ...]:
77697773

77707774
@register_test
77717775
class SampleTopKTest(OpTestCase):
7772-
"""Top-k sample emits the extra threshold and combined mask nodes."""
7776+
"""Top-k sample emits the threshold before the top-p nucleus chain."""
77737777

77747778
name = "sample_top_k"
77757779
skip_comparison = True # sampling RNG is not host/device bit-identical
@@ -7779,13 +7783,13 @@ class SampleTopKTest(OpTestCase):
77797783
"ArgmaxNode": 2,
77807784
"ItemIntNode": 3, # seed + top_k + temperature>0 condition
77817785
"SoftmaxNode": 1,
7782-
"SortNode": 1,
7786+
"SortNode": 2,
77837787
"CumsumNode": 1,
77847788
"MinNode": 1,
77857789
"TakeNode": 1,
77867790
"ExpandDimsNode": 1,
7787-
"LogicalOrNode": 1,
7788-
"WhereNode": 2,
7791+
"LogicalOrNode": 0,
7792+
"WhereNode": 3,
77897793
}
77907794

77917795
def create_model(self) -> nn.Module:

backends/mlx/test/test_sample.py

Lines changed: 21 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -97,8 +97,11 @@ def _sample(
9797
t = torch.tensor(float(temperature))
9898
s = None if seed is None else torch.tensor(int(seed), dtype=torch.int64)
9999
p = torch.tensor(float(top_p)) # 1.0 = off
100-
k = None if top_k is None else torch.tensor(int(top_k), dtype=torch.int64)
101-
return torch.ops.mlx.sample(logits, t, p, s, k)
100+
k = torch.tensor(
101+
torch.iinfo(torch.int64).max if top_k is None else int(top_k),
102+
dtype=torch.int64,
103+
)
104+
return torch.ops.mlx.sample(logits, t, k, p, s)
102105

103106

104107
class TestSampleOp(unittest.TestCase):
@@ -161,23 +164,31 @@ def test_top_p_one_keeps_all(self):
161164
self.assertTrue((tokens == 3).any())
162165

163166
def test_top_k_restricts_to_top_k(self):
164-
# probs [0.5, 0.3, 0.15, 0.05]; top_k=2 keeps {0,1}.
165-
base = torch.log(torch.tensor([0.5, 0.3, 0.15, 0.05]))
167+
# Non-sorted probs [0.15, 0.5, 0.05, 0.3]; top_k=2 keeps {1,3}.
168+
base = torch.log(torch.tensor([0.15, 0.5, 0.05, 0.3]))
166169
tokens = _sample(base.expand(5000, 4), 1.0, seed=0, top_k=2)
167-
self.assertTrue(torch.isin(tokens, torch.tensor([0, 1])).all())
168-
self.assertEqual(set(tokens.tolist()), {0, 1})
170+
self.assertTrue(torch.isin(tokens, torch.tensor([1, 3])).all())
171+
self.assertEqual(set(tokens.tolist()), {1, 3})
169172

170-
def test_top_k_none_keeps_all(self):
173+
def test_top_k_default_keeps_all(self):
171174
# top_k=None -> no filtering; the tail token (index 3) is reachable.
172175
base = torch.log(torch.tensor([0.5, 0.3, 0.15, 0.05]))
173176
tokens = _sample(base.expand(20000, 4), 1.0, seed=0, top_k=None)
174177
self.assertTrue((tokens == 3).any())
175178

179+
def test_top_k_clips_to_vocab_size(self):
180+
# top_k > vocab is clipped to vocab size, so every token is reachable.
181+
base = torch.log(torch.tensor([0.5, 0.3, 0.15, 0.05]))
182+
tokens = _sample(base.expand(20000, 4), 1.0, seed=0, top_k=999)
183+
self.assertEqual(set(tokens.tolist()), {0, 1, 2, 3})
184+
176185
def test_top_k_and_top_p_compose(self):
177-
# top_p=0.7 keeps {0,1}; top_k=1 intersects that to {0}.
186+
# top_k is applied before top_p, so top_p sees renormalized top-k probs.
187+
# top_k=3 -> [0.526, 0.316, 0.158]; top_p=0.83 keeps {0,1}.
178188
base = torch.log(torch.tensor([0.5, 0.3, 0.15, 0.05]))
179-
tokens = _sample(base.expand(5000, 4), 1.0, seed=0, top_p=0.7, top_k=1)
180-
self.assertEqual(set(tokens.tolist()), {0})
189+
tokens = _sample(base.expand(5000, 4), 1.0, seed=0, top_p=0.83, top_k=3)
190+
self.assertTrue(torch.isin(tokens, torch.tensor([0, 1])).all())
191+
self.assertEqual(set(tokens.tolist()), {0, 1})
181192

182193

183194
class TestSampleExport(unittest.TestCase):

0 commit comments

Comments
 (0)