Skip to content

Commit 6386cef

Browse files
[MLX] Qwen return last-token logits from forward; make SamplingHead operate on (B, vocab) (#20604)
Summary Qwen's MLX forward now returns last-token logits (B, vocab) instead of (B, S, vocab), so lm_head runs on one position per prefill instead of the whole sequence. SamplingHead correspondingly drops its internal last-token slice and samples directly from (B, vocab), making it model-agnostic. Changes - backends/mlx/llm/sampling.py — SamplingHead drops its internal logits[:, -1, :] slice; samples a token (B) directly from (B, vocab), removing the (B, S, vocab) path. - examples/models/qwen3_5_moe/export.py — _clean_forward returns lm_head(x[:, -1, :]) → (B, vocab) instead of all S positions, so lm_head runs once per prefill (chunk) instead of over the whole sequence. - run.py + test_chunked_prefill.py — consumers of qwen's forward output, both dropping the [0, -1, :] indexing. - test_ops.py + test_sample.py — op-level tests of SamplingHead/sample shape + node counts (upstream's top-k PR). - No C++ changes — the runtime sampler already handles 2D; chunked prefill result is identical. Testing - MLX sample op tests pass on-device. - Prefill ~19% faster (1505-token prompt): ~1352 → ~1612 tok/s; decode unchanged (~75 tok/s).
1 parent a2c0011 commit 6386cef

6 files changed

Lines changed: 23 additions & 23 deletions

File tree

backends/mlx/llm/sampling.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@
1212

1313
class SamplingHead(nn.Module):
1414
"""
15-
Wraps a model that returns logits and samples a token id on-device.
15+
Wraps a model that returns last-token logits ``(B, vocab)`` and samples a
16+
token id ``(B)`` on-device.
1617
1718
forward(*model_args, temperature, top_k=None, top_p=1.0, seed=None,
1819
**model_kwargs) -> token_id
@@ -33,12 +34,11 @@ def __init__(self, model: nn.Module):
3334
self.model = model
3435

3536
def forward(self, *args, temperature, top_k=None, top_p=1.0, seed=None, **kwargs):
36-
logits = self.model(*args, **kwargs) # [B, S, vocab]
37-
last = logits[:, -1, :] # [B, vocab]
37+
logits = self.model(*args, **kwargs) # [B, vocab]
3838
if not isinstance(top_p, torch.Tensor):
3939
top_p = torch.tensor(float(top_p))
4040
if top_k is None:
4141
top_k = torch.tensor(torch.iinfo(torch.int64).max, dtype=torch.int64)
4242
elif not isinstance(top_k, torch.Tensor):
4343
top_k = torch.tensor(int(top_k), dtype=torch.int64)
44-
return torch.ops.mlx.sample(last, temperature, top_k, top_p, seed)
44+
return torch.ops.mlx.sample(logits, temperature, top_k, top_p, seed)

backends/mlx/test/test_ops.py

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7694,7 +7694,7 @@ def create_inputs(self) -> Tuple[torch.Tensor, ...]:
76947694

76957695

76967696
class _LogitsPassthrough(nn.Module):
7697-
"""Stand-in for a model returning logits [B, S, vocab]."""
7697+
"""Stand-in for a model returning logits [B, vocab]."""
76987698

76997699
def forward(self, logits: torch.Tensor) -> torch.Tensor:
77007700
return logits
@@ -7759,7 +7759,7 @@ class SampleSeededTest(OpTestCase):
77597759
"SortNode": 2, # top-k threshold + top-p nucleus chain
77607760
"CumsumNode": 1,
77617761
"MinNode": 1,
7762-
"TakeNode": 2, # last-token slice + top-k threshold gather
7762+
"TakeNode": 1, # top-k threshold gather
77637763
"ExpandDimsNode": 1,
77647764
"WhereNode": 3,
77657765
}
@@ -7769,7 +7769,7 @@ def create_model(self) -> nn.Module:
77697769

77707770
def create_inputs(self) -> Tuple[torch.Tensor, ...]:
77717771
return (
7772-
torch.randn(1, 4, 256),
7772+
torch.randn(1, 256),
77737773
torch.tensor(0.8),
77747774
torch.tensor(0, dtype=torch.int64),
77757775
)
@@ -7793,7 +7793,7 @@ def create_model(self) -> nn.Module:
77937793
return UnseededSampleModel()
77947794

77957795
def create_inputs(self) -> Tuple[torch.Tensor, ...]:
7796-
return (torch.randn(1, 4, 256), torch.tensor(0.8))
7796+
return (torch.randn(1, 256), torch.tensor(0.8))
77977797

77987798

77997799
@register_test
@@ -7811,7 +7811,7 @@ class SampleTopPTest(OpTestCase):
78117811
"SortNode": 2,
78127812
"CumsumNode": 1,
78137813
"MinNode": 1,
7814-
"TakeNode": 2, # last-token slice + top-k threshold gather
7814+
"TakeNode": 1, # top-k threshold gather
78157815
"ExpandDimsNode": 1,
78167816
"WhereNode": 3,
78177817
}
@@ -7821,7 +7821,7 @@ def create_model(self) -> nn.Module:
78217821

78227822
def create_inputs(self) -> Tuple[torch.Tensor, ...]:
78237823
return (
7824-
torch.randn(1, 4, 256),
7824+
torch.randn(1, 256),
78257825
torch.tensor(0.8),
78267826
torch.tensor(0, dtype=torch.int64),
78277827
torch.tensor(0.9),
@@ -7843,7 +7843,7 @@ class SampleTopKTest(OpTestCase):
78437843
"SortNode": 2,
78447844
"CumsumNode": 1,
78457845
"MinNode": 1,
7846-
"TakeNode": 2, # last-token slice + top-k threshold gather
7846+
"TakeNode": 1, # top-k threshold gather
78477847
"ExpandDimsNode": 1,
78487848
"LogicalOrNode": 0,
78497849
"WhereNode": 3,
@@ -7854,7 +7854,7 @@ def create_model(self) -> nn.Module:
78547854

78557855
def create_inputs(self) -> Tuple[torch.Tensor, ...]:
78567856
return (
7857-
torch.randn(1, 4, 256),
7857+
torch.randn(1, 256),
78587858
torch.tensor(0.8),
78597859
torch.tensor(0, dtype=torch.int64),
78607860
torch.tensor(2, dtype=torch.int64),
@@ -7895,9 +7895,9 @@ def create_model(self) -> nn.Module:
78957895
return SeededSampleModel()
78967896

78977897
def create_inputs(self) -> Tuple[torch.Tensor, ...]:
7898-
logits = torch.randn(self.batch, 4, 1024, dtype=self.dtype)
7898+
logits = torch.randn(self.batch, 1024, dtype=self.dtype)
78997899
if self.dtype == torch.bfloat16:
7900-
logits[0, -1, 512] = 50.0 # dominant -> unambiguous bf16 argmax
7900+
logits[0, 512] = 50.0 # dominant -> unambiguous bf16 argmax
79017901
return (
79027902
logits,
79037903
torch.tensor(self.temperature),

backends/mlx/test/test_sample.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@
3535

3636

3737
class _LogitsPassthrough(nn.Module):
38-
"""Stand-in for a model returning logits [B, S, vocab]."""
38+
"""Stand-in for a model returning logits [B, vocab]."""
3939

4040
def forward(self, logits: torch.Tensor) -> torch.Tensor:
4141
return logits
@@ -205,7 +205,7 @@ def test_runtime_temperature_single_export(self):
205205
batch = 256
206206
torch.manual_seed(0)
207207
row = torch.randn(vocab)
208-
logits = row.expand(batch, 1, vocab).contiguous() # [B, S=1, vocab]
208+
logits = row.expand(batch, vocab).contiguous() # [B, vocab]
209209
seed = torch.tensor(0, dtype=torch.int64)
210210

211211
run = torch.export.export(
@@ -224,7 +224,7 @@ def test_seeded_export_reproducible_no_host_rng(self):
224224
# exported program, independent of host RNG state (the seed is a graph
225225
# input, not host-side stateful RNG). Different seed -> different draws.
226226
torch.manual_seed(0)
227-
logits = torch.randn(128, 1, 64)
227+
logits = torch.randn(128, 64)
228228
seed = torch.tensor(123, dtype=torch.int64)
229229

230230
run = torch.export.export(
@@ -250,7 +250,7 @@ def setUp(self):
250250

251251
def test_top_p_end_to_end(self):
252252
# On-device nucleus: probs [0.5,0.3,0.15,0.05], top_p=0.9 -> token in {0,1,2}.
253-
logits = torch.log(torch.tensor([0.5, 0.3, 0.15, 0.05])).view(1, 1, 4)
253+
logits = torch.log(torch.tensor([0.5, 0.3, 0.15, 0.05])).view(1, 4)
254254
inputs = (
255255
logits,
256256
torch.tensor(1.0),
@@ -268,7 +268,7 @@ def test_top_p_end_to_end(self):
268268

269269
def test_top_k_end_to_end(self):
270270
# 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)
271+
logits = torch.log(torch.tensor([0.5, 0.3, 0.15, 0.05])).view(1, 4)
272272
inputs = (
273273
logits,
274274
torch.tensor(1.0),

examples/models/qwen3_5_moe/export.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -728,7 +728,7 @@ def _clean_forward(self, tokens, input_pos):
728728
for layer in self.layers:
729729
x = layer(x, input_pos)
730730
x = self.norm(x)
731-
return self.lm_head(x)
731+
return self.lm_head(x[:, -1, :])
732732

733733
model.forward = types.MethodType(_clean_forward, model)
734734

examples/models/qwen3_5_moe/run.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ def _next_token(outputs, use_sampling: bool, temperature: float) -> int:
5151
"""A --sample model returns the token id directly; else sample from logits."""
5252
if use_sampling:
5353
return int(outputs[0].reshape(-1)[0].item())
54-
logits = outputs[0][0, -1, :]
54+
logits = outputs[0][0]
5555
if temperature > 0:
5656
return int(torch.multinomial(torch.softmax(logits / temperature, dim=-1), 1))
5757
return int(torch.argmax(logits))

examples/models/qwen3_5_moe/test_chunked_prefill.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,8 +52,8 @@ def _scalar_metadata(program, name, default):
5252

5353

5454
def _last_logits(outputs):
55-
# forward returns logits shaped (1, T, vocab); take the final position.
56-
return outputs[0][0, -1, :]
55+
# forward returns last-token logits shaped (1, vocab).
56+
return outputs[0][0]
5757

5858

5959
class TestChunkedPrefill(unittest.TestCase):

0 commit comments

Comments
 (0)