@@ -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+
6677def _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
86104class 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
146183class 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
222277if __name__ == "__main__" :
223278 unittest .main ()
0 commit comments