Skip to content

Commit 20944fd

Browse files
[MLX] Make SamplingHead directly exportable; drop sampler wrapper; wire runtime top-k (#20612)
Summary SamplingHead.forward now takes the sampling params as trailing positional args (temperature, top_k, top_p, seed), so torch.export drives it directly and the per-model _MLXSampleWrapper is removed. As part of going positional, top_k becomes a runtime input threaded through export, run.py, and the C++ engine; qwen now supports per-request top-k, and the op-level "not implemented" rejections are dropped. Changes - backends/mlx/llm/sampling.py — SamplingHead.forward(self, *args) unpacks *model_args, temperature, top_k, top_p, seed; samples (B) from (B, vocab). No wrapper needed (the params are positional, so it's directly exportable). - examples/models/qwen3_5_moe/export.py — delete _MLXSampleWrapper; model = SamplingHead(model); top_k added to example_args; dynamic_shapes nested to mirror the single *args parameter. - examples/models/qwen3_5_moe/run.py — _sampling_scalars feeds top_k (<=0 → keep-all maxint); --top-k flag; the non-sample guard rejects top_k/top_p/seed. - backends/mlx/test/test_ops.py, test_sample.py — fixtures call SamplingHead positionally, baking _KEEP_ALL_TOP_K/_TOP_P_OFF constants for params they don't expose as runtime inputs. - examples/models/qwen3_5_moe/main.cpp — --top_k flag → SamplingConfig.top_k. - examples/models/qwen3_5_moe/qwen35_moe_engine.cpp — top_k scalar wired through prefill/decode (fed between temp and top_p, matching the new forward order); 0→INT64_MAX keep-all mapping; the two "top_k is not implemented" rejections removed. Testing - Re-exported qwen, greedy output matches a pre-refactor baseline, seeded runs are reproducible, and --top-k restricts sampling. - MLX sample op tests pass; test_sample.py passes (incl. top-k end-to-end). - C++ MLX runner builds and runs the sample path; seeded reproducible and --top_k works on-device.
1 parent db5f1ba commit 20944fd

7 files changed

Lines changed: 92 additions & 69 deletions

File tree

backends/mlx/llm/sampling.py

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -15,30 +15,30 @@ class SamplingHead(nn.Module):
1515
Wraps a model that returns last-token logits ``(B, vocab)`` and samples a
1616
token id ``(B)`` on-device.
1717
18-
forward(*model_args, temperature, top_k=None, top_p=1.0, seed=None,
19-
**model_kwargs) -> token_id
18+
forward(*model_args, temperature, top_k, top_p, seed) -> token_id
19+
20+
The sampling params are trailing positional args so the head is directly
21+
exportable (``torch.export`` drives positional inputs) without a per-model
22+
wrapper.
2023
2124
temperature: scalar float tensor, e.g. torch.tensor(0.8). Must be >= 0;
2225
temperature=0 is greedy (returns argmax, no division).
23-
top_k: scalar int tensor or int; keeps only the k most likely tokens.
24-
None uses the max int default, which is clipped to the vocab
25-
size and keeps every token.
26+
top_k: scalar int tensor; keeps only the k most likely tokens. Use
27+
the max int (clipped to the vocab size) to keep every token.
2628
top_p: scalar float tensor in (0, 1] for nucleus sampling. top_p=1.0
27-
(the default) keeps every token, i.e. no filtering. Pass it
28-
as a runtime input to tune per request.
29+
keeps every token, i.e. no filtering.
2930
seed: scalar int tensor (seeded) or None (unseeded export)
3031
"""
3132

3233
def __init__(self, model: nn.Module):
3334
super().__init__()
3435
self.model = model
3536

36-
def forward(self, *args, temperature, top_k=None, top_p=1.0, seed=None, **kwargs):
37-
logits = self.model(*args, **kwargs) # [B, vocab]
37+
def forward(self, *args):
38+
*model_args, temperature, top_k, top_p, seed = args
39+
logits = self.model(*model_args) # [B, vocab]
40+
if not isinstance(top_k, torch.Tensor):
41+
top_k = torch.tensor(int(top_k), dtype=torch.int64)
3842
if not isinstance(top_p, torch.Tensor):
3943
top_p = torch.tensor(float(top_p))
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)
4444
return torch.ops.mlx.sample(logits, temperature, top_k, top_p, seed)

backends/mlx/test/test_ops.py

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7700,6 +7700,12 @@ def forward(self, logits: torch.Tensor) -> torch.Tensor:
77007700
return logits
77017701

77027702

7703+
# Baked constants for sampling params a fixture does not expose as a runtime
7704+
# input: keep-all top_k and top_p=off.
7705+
_KEEP_ALL_TOP_K = torch.tensor(torch.iinfo(torch.int64).max, dtype=torch.int64)
7706+
_TOP_P_OFF = torch.tensor(1.0)
7707+
7708+
77037709
class SeededSampleModel(nn.Module):
77047710
"""SamplingHead with temperature AND seed as runtime forward inputs."""
77057711

@@ -7708,7 +7714,7 @@ def __init__(self):
77087714
self.head = SamplingHead(_LogitsPassthrough())
77097715

77107716
def forward(self, logits, temperature, seed):
7711-
return self.head(logits, temperature=temperature, seed=seed)
7717+
return self.head(logits, temperature, _KEEP_ALL_TOP_K, _TOP_P_OFF, seed)
77127718

77137719

77147720
class UnseededSampleModel(nn.Module):
@@ -7719,7 +7725,7 @@ def __init__(self):
77197725
self.head = SamplingHead(_LogitsPassthrough())
77207726

77217727
def forward(self, logits, temperature):
7722-
return self.head(logits, temperature=temperature)
7728+
return self.head(logits, temperature, _KEEP_ALL_TOP_K, _TOP_P_OFF, None)
77237729

77247730

77257731
class TopPSampleModel(nn.Module):
@@ -7730,7 +7736,7 @@ def __init__(self):
77307736
self.head = SamplingHead(_LogitsPassthrough())
77317737

77327738
def forward(self, logits, temperature, seed, top_p):
7733-
return self.head(logits, temperature=temperature, seed=seed, top_p=top_p)
7739+
return self.head(logits, temperature, _KEEP_ALL_TOP_K, top_p, seed)
77347740

77357741

77367742
class TopKSampleModel(nn.Module):
@@ -7741,7 +7747,7 @@ def __init__(self):
77417747
self.head = SamplingHead(_LogitsPassthrough())
77427748

77437749
def forward(self, logits, temperature, seed, top_k):
7744-
return self.head(logits, temperature=temperature, seed=seed, top_k=top_k)
7750+
return self.head(logits, temperature, top_k, _TOP_P_OFF, seed)
77457751

77467752

77477753
@register_test

backends/mlx/test/test_sample.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,12 @@ def forward(self, logits: torch.Tensor) -> torch.Tensor:
4141
return logits
4242

4343

44+
# Baked constants for sampling params a fixture does not expose as a runtime
45+
# input: keep-all top_k and top_p=off.
46+
_KEEP_ALL_TOP_K = torch.tensor(torch.iinfo(torch.int64).max, dtype=torch.int64)
47+
_TOP_P_OFF = torch.tensor(1.0)
48+
49+
4450
class SeededSampleModel(nn.Module):
4551
"""SamplingHead with temperature AND seed as runtime forward inputs."""
4652

@@ -49,7 +55,7 @@ def __init__(self):
4955
self.head = SamplingHead(_LogitsPassthrough())
5056

5157
def forward(self, logits, temperature, seed):
52-
return self.head(logits, temperature=temperature, seed=seed)
58+
return self.head(logits, temperature, _KEEP_ALL_TOP_K, _TOP_P_OFF, seed)
5359

5460

5561
class TopPSampleModel(nn.Module):
@@ -60,7 +66,7 @@ def __init__(self):
6066
self.head = SamplingHead(_LogitsPassthrough())
6167

6268
def forward(self, logits, temperature, seed, top_p):
63-
return self.head(logits, temperature=temperature, seed=seed, top_p=top_p)
69+
return self.head(logits, temperature, _KEEP_ALL_TOP_K, top_p, seed)
6470

6571

6672
class TopKSampleModel(nn.Module):
@@ -71,7 +77,7 @@ def __init__(self):
7177
self.head = SamplingHead(_LogitsPassthrough())
7278

7379
def forward(self, logits, temperature, seed, top_k):
74-
return self.head(logits, temperature=temperature, seed=seed, top_k=top_k)
80+
return self.head(logits, temperature, top_k, _TOP_P_OFF, seed)
7581

7682

7783
def _ref_gumbel_max(logits: torch.Tensor, temperature: float, seed: int):

examples/models/qwen3_5_moe/export.py

Lines changed: 8 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -733,25 +733,6 @@ def _clean_forward(self, tokens, input_pos):
733733
model.forward = types.MethodType(_clean_forward, model)
734734

735735

736-
class _MLXSampleWrapper(nn.Module):
737-
"""Wrap the logits-producing model so ``forward`` returns a sampled token.
738-
739-
Temperature, top_p, and seed are runtime scalar inputs so the same .pte
740-
serves any sampling request; the runner increments the seed per token.
741-
"""
742-
743-
def __init__(self, model: nn.Module):
744-
super().__init__()
745-
from executorch.backends.mlx.llm.sampling import SamplingHead
746-
747-
self.head = SamplingHead(model)
748-
749-
def forward(self, tokens, input_pos, temperature, top_p, seed):
750-
return self.head(
751-
tokens, input_pos, temperature=temperature, top_p=top_p, seed=seed
752-
)
753-
754-
755736
def _export_mlx(model, config, args):
756737
"""Export model to .pte via torch.export + MLX backend."""
757738
import gc
@@ -775,17 +756,22 @@ def _export_mlx(model, config, args):
775756
seq_dim = Dim("seq_len", min=1, max=config.max_seq_len - 1)
776757

777758
if sample:
778-
# forward(tokens, input_pos, temperature, top_p, seed) -> token id.
759+
# forward(tokens, input_pos, temperature, top_k, top_p, seed) -> token id.
779760
# Scalars are static (None in dynamic_shapes); only the seq dim is dynamic.
780-
model = _MLXSampleWrapper(model)
761+
from executorch.backends.mlx.llm.sampling import SamplingHead
762+
763+
model = SamplingHead(model)
781764
example_args = (
782765
example_tokens,
783766
example_input_pos,
784767
torch.tensor(1.0, dtype=torch.float32),
768+
torch.tensor(torch.iinfo(torch.int64).max, dtype=torch.int64),
785769
torch.tensor(1.0, dtype=torch.float32),
786770
torch.tensor(0, dtype=torch.int64),
787771
)
788-
dynamic_shapes = ({1: seq_dim}, {0: seq_dim}, None, None, None)
772+
# SamplingHead.forward takes ``*args``; dynamic_shapes mirrors that single
773+
# variadic parameter as one nested tuple over the positional inputs.
774+
dynamic_shapes = (({1: seq_dim}, {0: seq_dim}, None, None, None, None),)
789775
else:
790776
example_args = (example_tokens, example_input_pos)
791777
dynamic_shapes = ({1: seq_dim}, {0: seq_dim})

examples/models/qwen3_5_moe/main.cpp

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,11 @@ DEFINE_string(
3838
"",
3939
"Path to file containing prompt text (overrides --prompt).");
4040
DEFINE_double(temperature, 0.8, "Sampling temperature (0 = greedy).");
41+
DEFINE_int32(
42+
top_k,
43+
0,
44+
"Top-k sampling; keep the k most likely tokens. 0 (default) = off (keep "
45+
"all). Requires a model exported with --sample (MLX on-device sampling).");
4146
DEFINE_double(
4247
top_p,
4348
1.0,
@@ -161,6 +166,7 @@ int main(int argc, char** argv) {
161166
// printed only on the first iteration (coherence check).
162167
llm::SamplingConfig sampling;
163168
sampling.temperature = static_cast<float>(FLAGS_temperature);
169+
sampling.top_k = FLAGS_top_k;
164170
sampling.top_p = static_cast<float>(FLAGS_top_p);
165171
// Only a --sample model uses the seed; randomize an unset seed for those and
166172
// leave non-sample models at 0 so they don't trip the top_p/seed guard.

examples/models/qwen3_5_moe/qwen35_moe_engine.cpp

Lines changed: 20 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,8 @@ class Qwen35MoESession : public LLMSession {
229229
// across calls; only the backing scalar is rewritten per token.
230230
temp_tensor_mlx_ =
231231
from_blob(&temp_val_mlx_, {}, executorch::aten::ScalarType::Float);
232+
top_k_tensor_ =
233+
from_blob(&top_k_val_, {}, executorch::aten::ScalarType::Long);
232234
top_p_tensor_ =
233235
from_blob(&top_p_val_, {}, executorch::aten::ScalarType::Float);
234236
seed_tensor_ =
@@ -256,15 +258,12 @@ class Qwen35MoESession : public LLMSession {
256258
}
257259
float first_token_temp = temperature_;
258260
if (initial_sampling != nullptr) {
259-
if (initial_sampling->top_k != 0) {
260-
ET_LOG(Error, "prefill_tokens: top_k is not implemented");
261-
return Error::NotSupported;
262-
}
263261
if (!use_sampling_ &&
264-
(initial_sampling->top_p != 1.0f || initial_sampling->seed != 0)) {
262+
(initial_sampling->top_k != 0 || initial_sampling->top_p != 1.0f ||
263+
initial_sampling->seed != 0)) {
265264
ET_LOG(
266265
Error,
267-
"prefill_tokens: top_p/seed require a sampling model "
266+
"prefill_tokens: top_k/top_p/seed require a sampling model "
268267
"(export with --sample); only temperature is supported otherwise");
269268
return Error::NotSupported;
270269
}
@@ -274,6 +273,7 @@ class Qwen35MoESession : public LLMSession {
274273
ET_LOG(Error, "prefill_tokens: top_p must be in (0, 1]");
275274
return Error::InvalidArgument;
276275
}
276+
top_k_ = initial_sampling->top_k;
277277
top_p_ = initial_sampling->top_p;
278278
seed_ = initial_sampling->seed; // base seed for the kept (token 0) draw
279279
}
@@ -347,8 +347,9 @@ class Qwen35MoESession : public LLMSession {
347347
#endif
348348
#ifdef EXECUTORCH_BUILD_MLX
349349
if (use_sampling_) {
350-
set_sampling_inputs(first_token_temp, top_p_, seed_);
350+
set_sampling_inputs(first_token_temp, top_k_, top_p_, seed_);
351351
inputs.push_back(EValue(temp_tensor_mlx_));
352+
inputs.push_back(EValue(top_k_tensor_));
352353
inputs.push_back(EValue(top_p_tensor_));
353354
inputs.push_back(EValue(seed_tensor_));
354355
}
@@ -370,14 +371,11 @@ class Qwen35MoESession : public LLMSession {
370371
}
371372

372373
Result<DecodeResult> decode_one(const SamplingConfig& sampling) override {
373-
if (sampling.top_k != 0) {
374-
ET_LOG(Error, "Qwen35MoESession: top_k is not implemented");
375-
return Error::NotSupported;
376-
}
377-
if (!use_sampling_ && (sampling.top_p != 1.0f || sampling.seed != 0)) {
374+
if (!use_sampling_ &&
375+
(sampling.top_k != 0 || sampling.top_p != 1.0f || sampling.seed != 0)) {
378376
ET_LOG(
379377
Error,
380-
"Qwen35MoESession: top_p/seed require a sampling model "
378+
"Qwen35MoESession: top_k/top_p/seed require a sampling model "
381379
"(export with --sample); only temperature is supported otherwise");
382380
return Error::NotSupported;
383381
}
@@ -395,6 +393,7 @@ class Qwen35MoESession : public LLMSession {
395393
ET_LOG(Error, "decode_one: top_p must be in (0, 1]");
396394
return Error::InvalidArgument;
397395
}
396+
top_k_ = sampling.top_k;
398397
top_p_ = sampling.top_p;
399398
}
400399

@@ -444,8 +443,9 @@ class Qwen35MoESession : public LLMSession {
444443
#endif
445444
#ifdef EXECUTORCH_BUILD_MLX
446445
if (use_sampling_) {
447-
set_sampling_inputs(temperature_, top_p_, seed_);
446+
set_sampling_inputs(temperature_, top_k_, top_p_, seed_);
448447
inputs.push_back(EValue(temp_tensor_mlx_));
448+
inputs.push_back(EValue(top_k_tensor_));
449449
inputs.push_back(EValue(top_p_tensor_));
450450
inputs.push_back(EValue(seed_tensor_));
451451
}
@@ -499,8 +499,10 @@ class Qwen35MoESession : public LLMSession {
499499
#ifdef EXECUTORCH_BUILD_MLX
500500
// Rewrite the backing scalars for the reused 0-dim sampling input tensors.
501501
// The -1 temperature sentinel maps to 0 (greedy).
502-
void set_sampling_inputs(float temp, float top_p, uint64_t seed) {
502+
void
503+
set_sampling_inputs(float temp, int64_t top_k, float top_p, uint64_t seed) {
503504
temp_val_mlx_ = (temp < 0.0f) ? 0.0f : temp;
505+
top_k_val_ = (top_k <= 0) ? INT64_MAX : top_k; // 0/neg = keep all
504506
top_p_val_ = top_p;
505507
seed_val_ = static_cast<int64_t>(seed);
506508
}
@@ -553,6 +555,7 @@ class Qwen35MoESession : public LLMSession {
553555
// the seed increments per generated token for decorrelated, reproducible
554556
// draws.
555557
bool use_sampling_ = false;
558+
int64_t top_k_ = 0; // 0 = off (keep all); mapped to INT64_MAX on-device
556559
float top_p_ = 1.0f;
557560
uint64_t seed_ = 0;
558561

@@ -570,9 +573,11 @@ class Qwen35MoESession : public LLMSession {
570573
#endif
571574
#ifdef EXECUTORCH_BUILD_MLX
572575
float temp_val_mlx_ = 0.0f;
576+
int64_t top_k_val_ = INT64_MAX;
573577
float top_p_val_ = 1.0f;
574578
int64_t seed_val_ = 0;
575579
TensorPtr temp_tensor_mlx_;
580+
TensorPtr top_k_tensor_;
576581
TensorPtr top_p_tensor_;
577582
TensorPtr seed_tensor_;
578583
#endif

0 commit comments

Comments
 (0)