Skip to content

Commit 035b45a

Browse files
Add top-k support to MLX sample (#20564)
Fixes #20548 ### Summary - Thread optional `top_k` through `SamplingHead` and `mlx::sample`. - Apply top-k filtering as an additional threshold mask that composes with the existing top-p nucleus mask. - Add eager, export, end-to-end, and MLX lowering coverage for top-k sampling. ### Test plan - `PYTHONPATH=src:. python3 -m unittest executorch.backends.mlx.test.test_sample.TestSampleOp executorch.backends.mlx.test.test_sample.TestSampleExport` - `python3 -m compileall -q backends/mlx/llm/sampling.py backends/mlx/custom_ops.py backends/mlx/ops.py backends/mlx/test/test_sample.py backends/mlx/test/test_ops.py` --------- Co-authored-by: Scott Roy <scroy@meta.com>
1 parent b331ebd commit 035b45a

5 files changed

Lines changed: 223 additions & 20 deletions

File tree

backends/mlx/custom_ops.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -397,15 +397,19 @@ 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,
402403
) -> Tensor:
403404
"""
404-
Gumbel-max sampling from softmax(logits / temperature), with top-p (nucleus).
405+
Gumbel-max sampling from softmax(logits / temperature), with top-k and
406+
top-p (nucleus) filtering.
405407
logits: [B, vocab]
406408
temperature: scalar float tensor (runtime input). temperature <= 0 is
407409
greedy: return argmax(logits) directly (matches the device,
408410
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.
409413
top_p: scalar float tensor in (0, 1]. top_p=1.0 keeps every
410414
token, i.e. it is off.
411415
seed: scalar int tensor or None
@@ -422,6 +426,14 @@ def sample(
422426
return torch.argmax(logits, dim=-1)
423427
# whole chain in fp32 to match the lowered graph (bf16 sums mis-rank ties).
424428
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.
425437
probs = torch.softmax(scaled, dim=-1)
426438
s_probs, _ = torch.sort(probs, dim=-1, descending=True)
427439
cum = torch.cumsum(s_probs, dim=-1)
@@ -440,5 +452,5 @@ def sample(
440452

441453

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

backends/mlx/llm/sampling.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,9 @@ class SamplingHead(nn.Module):
1919
2020
temperature: scalar float tensor, e.g. torch.tensor(0.8). Must be >= 0;
2121
temperature=0 is greedy (returns argmax, no division).
22-
top_k: not implemented yet (reserved); must be None.
22+
top_k: scalar int tensor or int; keeps only the k most likely tokens.
23+
None uses the max int default, which is clipped to the vocab
24+
size and keeps every token.
2325
top_p: scalar float tensor in (0, 1] for nucleus sampling. top_p=1.0
2426
(the default) keeps every token, i.e. no filtering. Pass it
2527
as a runtime input to tune per request.
@@ -31,10 +33,12 @@ def __init__(self, model: nn.Module):
3133
self.model = model
3234

3335
def forward(self, *args, temperature, top_k=None, top_p=1.0, seed=None, **kwargs):
34-
if top_k is not None:
35-
raise NotImplementedError("top_k sampling is not implemented")
3636
logits = self.model(*args, **kwargs) # [B, S, vocab]
3737
last = logits[:, -1, :] # [B, vocab]
3838
if not isinstance(top_p, torch.Tensor):
3939
top_p = torch.tensor(float(top_p))
40-
return torch.ops.mlx.sample(last, temperature, top_p, seed)
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):
43+
top_k = torch.tensor(int(top_k), dtype=torch.int64)
44+
return torch.ops.mlx.sample(last, temperature, top_k, top_p, seed)

backends/mlx/ops.py

Lines changed: 78 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3528,10 +3528,10 @@ def _sample_handler(P: MLXProgramBuilder, n: Node) -> Slot:
35283528
skipping the sampling chain (so 0 is exact, not the small-epsilon approx).
35293529
"""
35303530
args = P.args(n)
3531-
require_args(args, 3, 4, "mlx.sample")
3531+
require_args(args, 4, 5, "mlx.sample")
35323532
require_kwargs(P.kwargs(n), set(), "mlx.sample")
3533-
logits, temperature, top_p = args[0], args[1], args[2]
3534-
seed = args[3] if len(args) > 3 and args[3] 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
35353535

35363536
temp_dt = n.args[1].meta["val"].dtype
35373537
out = P.make_or_get_slot(n)
@@ -3612,8 +3612,82 @@ def emit_sample():
36123612
)
36133613
)
36143614
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(x=P.slot_to_tid(clipped_top_k), out=P.slot_to_vid(top_k_val))
3632+
)
3633+
_, top_k_index = P.make_tmp_value_slot()
3634+
P.emit(
3635+
SubtractIntNode(
3636+
a=P.to_int_or_vid(top_k_val),
3637+
b=IntOrVid.from_literal(1),
3638+
out=P.slot_to_vid(top_k_index),
3639+
)
3640+
)
3641+
3642+
_, sorted_scaled = P.make_tmp_slot()
3643+
P.emit(NegNode(x=P.slot_to_tid(scaled), out=P.slot_to_tid(sorted_scaled)))
3644+
P.emit(
3645+
SortNode(
3646+
x=P.slot_to_tid(sorted_scaled),
3647+
out=P.slot_to_tid(sorted_scaled),
3648+
axis=-1,
3649+
)
3650+
)
3651+
P.emit(
3652+
NegNode(x=P.slot_to_tid(sorted_scaled), out=P.slot_to_tid(sorted_scaled))
3653+
)
3654+
_, top_k_thresh = P.make_tmp_slot()
3655+
P.emit(
3656+
TakeNode(
3657+
x=P.slot_to_tid(sorted_scaled),
3658+
index=P.to_int_or_vid_or_tid(top_k_index),
3659+
out=P.slot_to_tid(top_k_thresh),
3660+
axis=-1,
3661+
)
3662+
)
3663+
P.emit(
3664+
ExpandDimsNode(
3665+
x=P.slot_to_tid(top_k_thresh),
3666+
out=P.slot_to_tid(top_k_thresh),
3667+
axis=-1,
3668+
)
3669+
)
3670+
_, drop_k = P.make_tmp_slot()
3671+
P.emit(
3672+
LessNode(
3673+
a=P.slot_to_tid(scaled),
3674+
b=P.slot_to_tid(top_k_thresh),
3675+
out=P.slot_to_tid(drop_k),
3676+
)
3677+
)
3678+
_, top_k_scaled = P.make_tmp_slot()
3679+
P.emit(
3680+
WhereNode(
3681+
condition=P.slot_to_tid(drop_k),
3682+
x=P.slot_to_tid(neg_inf),
3683+
y=P.slot_to_tid(scaled),
3684+
out=P.slot_to_tid(top_k_scaled),
3685+
)
3686+
)
3687+
scaled = top_k_scaled
36153688

3616-
# top-p nucleus mask; SortNode is ascending-only, so sort -probs for descending.
3689+
# Top-p nucleus mask on probabilities renormalized over the top-k set.
3690+
# SortNode is ascending-only, so sort -probs for descending.
36173691
# probs is read twice (neg_p below and the drop comparison), keep separate.
36183692
_, probs = P.make_tmp_slot()
36193693
P.emit(SoftmaxNode(x=P.slot_to_tid(scaled), out=P.slot_to_tid(probs), axis=-1))
@@ -3674,7 +3748,6 @@ def emit_sample():
36743748
out=P.slot_to_tid(drop),
36753749
)
36763750
)
3677-
neg_inf = emit_lifted_constant(P, float("-inf"), torch.float32)
36783751
# masked = where(drop, -inf, scaled); then add gumbel noise in place.
36793752
_, masked = P.make_tmp_slot()
36803753
P.emit(

backends/mlx/test/test_ops.py

Lines changed: 55 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7676,6 +7676,17 @@ def forward(self, logits, temperature, seed, top_p):
76767676
return self.head(logits, temperature=temperature, seed=seed, top_p=top_p)
76777677

76787678

7679+
class TopKSampleModel(nn.Module):
7680+
"""SamplingHead with temperature, seed, and top_k as runtime inputs."""
7681+
7682+
def __init__(self):
7683+
super().__init__()
7684+
self.head = SamplingHead(_LogitsPassthrough())
7685+
7686+
def forward(self, logits, temperature, seed, top_k):
7687+
return self.head(logits, temperature=temperature, seed=seed, top_k=top_k)
7688+
7689+
76797690
@register_test
76807691
class SampleSeededTest(OpTestCase):
76817692
"""Seeded sample lowers to one MLX segment; seed threads in via ItemIntNode."""
@@ -7686,12 +7697,14 @@ class SampleSeededTest(OpTestCase):
76867697
"IfNode": 1, # temperature==0 greedy branch
76877698
"RandomBitsNode": 1,
76887699
"ArgmaxNode": 2, # sampling branch + greedy branch
7689-
"ItemIntNode": 2, # seed + temperature>0 condition
7700+
"ItemIntNode": 3, # seed + top_k + temperature>0 condition
76907701
"SoftmaxNode": 1, # top-p nucleus chain
7691-
"SortNode": 1,
7702+
"SortNode": 2, # top-k threshold + top-p nucleus chain
76927703
"CumsumNode": 1,
76937704
"MinNode": 1,
7694-
"WhereNode": 2,
7705+
"TakeNode": 2, # last-token slice + top-k threshold gather
7706+
"ExpandDimsNode": 1,
7707+
"WhereNode": 3,
76957708
}
76967709

76977710
def create_model(self) -> nn.Module:
@@ -7715,7 +7728,7 @@ class SampleUnseededTest(OpTestCase):
77157728
"IfNode": 1,
77167729
"RandomBitsNode": 1,
77177730
"ArgmaxNode": 2,
7718-
"ItemIntNode": 1, # temperature>0 condition only (no seed)
7731+
"ItemIntNode": 2, # top_k + temperature>0 condition only (no seed)
77197732
"SoftmaxNode": 1, # top-p nucleus chain (top_p defaults to 1.0)
77207733
}
77217734

@@ -7736,12 +7749,14 @@ class SampleTopPTest(OpTestCase):
77367749
"IfNode": 1,
77377750
"RandomBitsNode": 1,
77387751
"ArgmaxNode": 2,
7739-
"ItemIntNode": 2,
7752+
"ItemIntNode": 3,
77407753
"SoftmaxNode": 1,
7741-
"SortNode": 1,
7754+
"SortNode": 2,
77427755
"CumsumNode": 1,
77437756
"MinNode": 1,
7744-
"WhereNode": 2,
7757+
"TakeNode": 2, # last-token slice + top-k threshold gather
7758+
"ExpandDimsNode": 1,
7759+
"WhereNode": 3,
77457760
}
77467761

77477762
def create_model(self) -> nn.Module:
@@ -7756,6 +7771,39 @@ def create_inputs(self) -> Tuple[torch.Tensor, ...]:
77567771
)
77577772

77587773

7774+
@register_test
7775+
class SampleTopKTest(OpTestCase):
7776+
"""Top-k sample emits the threshold before the top-p nucleus chain."""
7777+
7778+
name = "sample_top_k"
7779+
skip_comparison = True # sampling RNG is not host/device bit-identical
7780+
expected_node_counts = {
7781+
"IfNode": 1,
7782+
"RandomBitsNode": 1,
7783+
"ArgmaxNode": 2,
7784+
"ItemIntNode": 3, # seed + top_k + temperature>0 condition
7785+
"SoftmaxNode": 1,
7786+
"SortNode": 2,
7787+
"CumsumNode": 1,
7788+
"MinNode": 1,
7789+
"TakeNode": 2, # last-token slice + top-k threshold gather
7790+
"ExpandDimsNode": 1,
7791+
"LogicalOrNode": 0,
7792+
"WhereNode": 3,
7793+
}
7794+
7795+
def create_model(self) -> nn.Module:
7796+
return TopKSampleModel()
7797+
7798+
def create_inputs(self) -> Tuple[torch.Tensor, ...]:
7799+
return (
7800+
torch.randn(1, 4, 256),
7801+
torch.tensor(0.8),
7802+
torch.tensor(0, dtype=torch.int64),
7803+
torch.tensor(2, dtype=torch.int64),
7804+
)
7805+
7806+
77597807
@register_test
77607808
class SampleGreedyTest(OpTestCase):
77617809
"""Greedy argmax(logits) is bit-exact host/device, so verify the token with the

backends/mlx/test/test_sample.py

Lines changed: 68 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,17 @@ def forward(self, logits, temperature, seed, top_p):
6363
return self.head(logits, temperature=temperature, seed=seed, top_p=top_p)
6464

6565

66+
class TopKSampleModel(nn.Module):
67+
"""SamplingHead with temperature, seed, and top_k as runtime inputs."""
68+
69+
def __init__(self):
70+
super().__init__()
71+
self.head = SamplingHead(_LogitsPassthrough())
72+
73+
def forward(self, logits, temperature, seed, top_k):
74+
return self.head(logits, temperature=temperature, seed=seed, top_k=top_k)
75+
76+
6677
def _ref_gumbel_max(logits: torch.Tensor, temperature: float, seed: int):
6778
"""Independent Gumbel-max reference using the same torch RNG as the op."""
6879
gen = torch.Generator().manual_seed(seed)
@@ -76,11 +87,21 @@ def _tv_distance(p: torch.Tensor, q: torch.Tensor) -> float:
7687
return 0.5 * torch.abs(p - q).sum().item()
7788

7889

79-
def _sample(logits, temperature, seed: Optional[int], top_p: float = 1.0):
90+
def _sample(
91+
logits,
92+
temperature,
93+
seed: Optional[int],
94+
top_p: float = 1.0,
95+
top_k: Optional[int] = None,
96+
):
8097
t = torch.tensor(float(temperature))
8198
s = None if seed is None else torch.tensor(int(seed), dtype=torch.int64)
8299
p = torch.tensor(float(top_p)) # 1.0 = off
83-
return torch.ops.mlx.sample(logits, t, p, s)
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)
84105

85106

86107
class TestSampleOp(unittest.TestCase):
@@ -142,6 +163,33 @@ def test_top_p_one_keeps_all(self):
142163
tokens = _sample(base.expand(20000, 4), 1.0, seed=0, top_p=1.0)
143164
self.assertTrue((tokens == 3).any())
144165

166+
def test_top_k_restricts_to_top_k(self):
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]))
169+
tokens = _sample(base.expand(5000, 4), 1.0, seed=0, top_k=2)
170+
self.assertTrue(torch.isin(tokens, torch.tensor([1, 3])).all())
171+
self.assertEqual(set(tokens.tolist()), {1, 3})
172+
173+
def test_top_k_default_keeps_all(self):
174+
# top_k=None -> no filtering; the tail token (index 3) is reachable.
175+
base = torch.log(torch.tensor([0.5, 0.3, 0.15, 0.05]))
176+
tokens = _sample(base.expand(20000, 4), 1.0, seed=0, top_k=None)
177+
self.assertTrue((tokens == 3).any())
178+
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+
185+
def test_top_k_and_top_p_compose(self):
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}.
188+
base = torch.log(torch.tensor([0.5, 0.3, 0.15, 0.05]))
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})
192+
145193

146194
class TestSampleExport(unittest.TestCase):
147195
"""Runtime-input semantics that survive export: temperature and seed stay
@@ -218,6 +266,24 @@ def test_top_p_end_to_end(self):
218266
(token,) = load_tensors_from_bin(out_bin)
219267
self.assertIn(int(token), {0, 1, 2}) # tail token (index 3) excluded
220268

269+
def test_top_k_end_to_end(self):
270+
# On-device top-k: probs [0.5,0.3,0.15,0.05], top_k=2 -> token in {0,1}.
271+
logits = torch.log(torch.tensor([0.5, 0.3, 0.15, 0.05])).view(1, 1, 4)
272+
inputs = (
273+
logits,
274+
torch.tensor(1.0),
275+
torch.tensor(0, dtype=torch.int64),
276+
torch.tensor(2, dtype=torch.int64),
277+
)
278+
tmp = Path(self._tmp)
279+
pte, in_bin, out_bin = tmp / "topk.pte", tmp / "in.bin", tmp / "out.bin"
280+
export_model_to_pte(TopKSampleModel(), inputs, pte)
281+
save_tensors_to_bin(list(inputs), in_bin)
282+
283+
self.assertTrue(run_cpp_test_runner(pte, in_bin, out_bin))
284+
(token,) = load_tensors_from_bin(out_bin)
285+
self.assertIn(int(token), {0, 1}) # tail tokens excluded
286+
221287

222288
if __name__ == "__main__":
223289
unittest.main()

0 commit comments

Comments
 (0)