Skip to content

Commit c944a5a

Browse files
committed
Add top-k support to MLX sample
1 parent 6fd28cf commit c944a5a

5 files changed

Lines changed: 159 additions & 9 deletions

File tree

backends/mlx/custom_ops.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -399,9 +399,11 @@ def sample(
399399
temperature: Tensor,
400400
top_p: Tensor,
401401
seed: Optional[Tensor] = None,
402+
top_k: 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-p (nucleus)
406+
and optional top-k filtering.
405407
logits: [B, vocab]
406408
temperature: scalar float tensor (runtime input). temperature <= 0 is
407409
greedy: return argmax(logits) directly (matches the device,
@@ -411,6 +413,8 @@ def sample(
411413
seed: scalar int tensor or None
412414
- tensor -> deterministic, keyed RNG (random::key(seed))
413415
- 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.
414418
-> token_id: [B] int64
415419
416420
Host/CPU reference used for export (shape/meta) and distributional checks
@@ -424,6 +428,10 @@ def sample(
424428
scaled = logits.float() / temperature
425429
probs = torch.softmax(scaled, dim=-1)
426430
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")))
427435
cum = torch.cumsum(s_probs, dim=-1)
428436
keep = (cum - s_probs) <= top_p
429437
thresh = torch.where(keep, s_probs, s_probs.new_tensor(float("inf"))).amin(
@@ -440,5 +448,5 @@ def sample(
440448

441449

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

backends/mlx/llm/sampling.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,8 @@ 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 disables top-k filtering.
2324
top_p: scalar float tensor in (0, 1] for nucleus sampling. top_p=1.0
2425
(the default) keeps every token, i.e. no filtering. Pass it
2526
as a runtime input to tune per request.
@@ -31,10 +32,10 @@ def __init__(self, model: nn.Module):
3132
self.model = model
3233

3334
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")
3635
logits = self.model(*args, **kwargs) # [B, S, vocab]
3736
last = logits[:, -1, :] # [B, vocab]
3837
if not isinstance(top_p, torch.Tensor):
3938
top_p = torch.tensor(float(top_p))
40-
return torch.ops.mlx.sample(last, temperature, top_p, seed)
39+
if top_k is not None and not isinstance(top_k, torch.Tensor):
40+
top_k = torch.tensor(int(top_k), dtype=torch.int64)
41+
return torch.ops.mlx.sample(last, temperature, top_p, seed, top_k)

backends/mlx/ops.py

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
emit_lifted_constant,
2525
emit_quantized_biases,
2626
emit_shape,
27+
emit_sub_int,
2728
parse_dequant_node,
2829
to_mlx_qparams,
2930
torch_dtype_to_scalar_type,
@@ -3528,10 +3529,11 @@ def _sample_handler(P: MLXProgramBuilder, n: Node) -> Slot:
35283529
skipping the sampling chain (so 0 is exact, not the small-epsilon approx).
35293530
"""
35303531
args = P.args(n)
3531-
require_args(args, 3, 4, "mlx.sample")
3532+
require_args(args, 3, 5, "mlx.sample")
35323533
require_kwargs(P.kwargs(n), set(), "mlx.sample")
35333534
logits, temperature, top_p = args[0], args[1], args[2]
35343535
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
35353537

35363538
temp_dt = n.args[1].meta["val"].dtype
35373539
out = P.make_or_get_slot(n)
@@ -3674,6 +3676,46 @@ def emit_sample():
36743676
out=P.slot_to_tid(drop),
36753677
)
36763678
)
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+
)
36773719
neg_inf = emit_lifted_constant(P, float("-inf"), torch.float32)
36783720
# masked = where(drop, -inf, scaled); then add gumbel noise in place.
36793721
_, masked = P.make_tmp_slot()

backends/mlx/test/test_ops.py

Lines changed: 44 additions & 0 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."""
@@ -7756,6 +7767,39 @@ def create_inputs(self) -> Tuple[torch.Tensor, ...]:
77567767
)
77577768

77587769

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

backends/mlx/test/test_sample.py

Lines changed: 57 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,18 @@ 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 = 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)
84102

85103

86104
class TestSampleOp(unittest.TestCase):
@@ -142,6 +160,25 @@ def test_top_p_one_keeps_all(self):
142160
tokens = _sample(base.expand(20000, 4), 1.0, seed=0, top_p=1.0)
143161
self.assertTrue((tokens == 3).any())
144162

163+
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]))
166+
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})
169+
170+
def test_top_k_none_keeps_all(self):
171+
# top_k=None -> no filtering; the tail token (index 3) is reachable.
172+
base = torch.log(torch.tensor([0.5, 0.3, 0.15, 0.05]))
173+
tokens = _sample(base.expand(20000, 4), 1.0, seed=0, top_k=None)
174+
self.assertTrue((tokens == 3).any())
175+
176+
def test_top_k_and_top_p_compose(self):
177+
# top_p=0.7 keeps {0,1}; top_k=1 intersects that to {0}.
178+
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})
181+
145182

146183
class TestSampleExport(unittest.TestCase):
147184
"""Runtime-input semantics that survive export: temperature and seed stay
@@ -218,6 +255,24 @@ def test_top_p_end_to_end(self):
218255
(token,) = load_tensors_from_bin(out_bin)
219256
self.assertIn(int(token), {0, 1, 2}) # tail token (index 3) excluded
220257

258+
def test_top_k_end_to_end(self):
259+
# On-device top-k: probs [0.5,0.3,0.15,0.05], top_k=2 -> token in {0,1}.
260+
logits = torch.log(torch.tensor([0.5, 0.3, 0.15, 0.05])).view(1, 1, 4)
261+
inputs = (
262+
logits,
263+
torch.tensor(1.0),
264+
torch.tensor(0, dtype=torch.int64),
265+
torch.tensor(2, dtype=torch.int64),
266+
)
267+
tmp = Path(self._tmp)
268+
pte, in_bin, out_bin = tmp / "topk.pte", tmp / "in.bin", tmp / "out.bin"
269+
export_model_to_pte(TopKSampleModel(), inputs, pte)
270+
save_tensors_to_bin(list(inputs), in_bin)
271+
272+
self.assertTrue(run_cpp_test_runner(pte, in_bin, out_bin))
273+
(token,) = load_tensors_from_bin(out_bin)
274+
self.assertIn(int(token), {0, 1}) # tail tokens excluded
275+
221276

222277
if __name__ == "__main__":
223278
unittest.main()

0 commit comments

Comments
 (0)