Skip to content

Commit 63b2b5c

Browse files
[MLX] Gemma4-31B ondevice sampling (#20561)
Summary Lets the MLX-exported Gemma 4 31B model sample the next token on-device instead of returning logits for host-side sampling. Sampling is opt-in at export (--sample); temperature, top_k, top_p, and seed are runtime inputs, and the runner increments the seed per token. Changes - export.py — --sample wraps the model in SamplingHead so forward(tokens, input_pos, temperature, top_k, top_p, seed) → int64 token; records a use_sampling constant-method flag. Non-sample export unchanged. - gemma4_31b_engine.cpp — reads use_sampling from metadata; when set, consumes the int64 token directly instead of logits_to_token, feeds the scalar inputs (temperature, top_k, top_p, seed) in prefill/decode (across the min/max prefill chunking), and manages the per-token seed schedule. top_k (0 = keep all → INT64_MAX on-device) and top_p are now supported; top_p is range-checked to (0, 1]; top_k/top_p/seed are rejected on non-sample models. - main.cpp — --top_k / --top_p / --seed flags wired into SamplingConfig; an unset seed is randomized only for sampling models. - tests/test_mlx_pipeline.py — test_export_to_pte_with_sampling: tiny-model MLX export with --sample, asserting the use_sampling flag, int64 token output, and same-seed reproducibility.
1 parent e11953e commit 63b2b5c

4 files changed

Lines changed: 242 additions & 15 deletions

File tree

examples/models/gemma4_31b/export.py

Lines changed: 43 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -147,12 +147,15 @@ def export_and_lower(
147147
output_dir: str,
148148
backend: str = "cuda",
149149
use_turboquant: bool = False,
150+
sample: bool = False,
150151
) -> None:
151152
"""Export and lower the model to ExecuTorch for the given backend."""
152153
if backend == "cuda":
153154
_export_cuda(model, config, output_dir, use_turboquant=use_turboquant)
154155
elif backend == "mlx":
155-
_export_mlx(model, config, output_dir, use_turboquant=use_turboquant)
156+
_export_mlx(
157+
model, config, output_dir, use_turboquant=use_turboquant, sample=sample
158+
)
156159
else:
157160
raise ValueError(
158161
f"Unsupported backend: {backend!r}. Supported: {_SUPPORTED_BACKENDS}."
@@ -311,6 +314,7 @@ def _export_mlx(
311314
config: Gemma4_31BConfig,
312315
output_dir: str,
313316
use_turboquant: bool = False,
317+
sample: bool = False,
314318
) -> None:
315319
"""Export to .pte via torch.export + MLX backend.
316320
@@ -358,15 +362,36 @@ def _export_mlx(
358362

359363
seq_dim = Dim("seq_len", min=1, max=max_prefill)
360364

365+
example_tokens = torch.tensor([[0, 1]], dtype=torch.long)
366+
example_input_pos = torch.tensor([0, 1], dtype=torch.long)
367+
if sample:
368+
# forward(tokens, input_pos, temperature, top_k, top_p, seed) -> token id.
369+
# gemma's MLX forward already returns last-token logits (B, vocab), so
370+
# SamplingHead is used directly with no per-model wrapper.
371+
from executorch.backends.mlx.llm.sampling import SamplingHead
372+
373+
model = SamplingHead(model)
374+
example_args = (
375+
example_tokens,
376+
example_input_pos,
377+
torch.tensor(1.0, dtype=torch.float32),
378+
torch.tensor(torch.iinfo(torch.int64).max, dtype=torch.int64),
379+
torch.tensor(1.0, dtype=torch.float32),
380+
torch.tensor(0, dtype=torch.int64),
381+
)
382+
# SamplingHead.forward takes ``*args``; dynamic_shapes mirrors that single
383+
# variadic parameter as one nested tuple over the positional inputs.
384+
dynamic_shapes = (({1: seq_dim}, {0: seq_dim}, None, None, None, None),)
385+
else:
386+
example_args = (example_tokens, example_input_pos)
387+
dynamic_shapes = ({1: seq_dim}, {0: seq_dim})
388+
361389
print(f"Exporting (T in [1, {max_prefill}])...")
362390
with torch.no_grad():
363391
exported = export(
364392
model,
365-
(
366-
torch.tensor([[0, 1]], dtype=torch.long),
367-
torch.tensor([0, 1], dtype=torch.long),
368-
),
369-
dynamic_shapes=({1: seq_dim}, {0: seq_dim}),
393+
example_args,
394+
dynamic_shapes=dynamic_shapes,
370395
strict=True,
371396
)
372397

@@ -390,6 +415,7 @@ def _export_mlx(
390415
"use_kv_cache": True,
391416
"use_sdpa_with_kv_cache": False,
392417
"enable_dynamic_shape": True,
418+
"use_sampling": sample,
393419
},
394420
)
395421

@@ -474,11 +500,21 @@ def main() -> None:
474500
"sliding layers keep their default cache. Supported on both "
475501
"--backend mlx and --backend cuda.",
476502
)
503+
parser.add_argument(
504+
"--sample",
505+
action="store_true",
506+
help="MLX only: sample the next token on-device (Gumbel-max with "
507+
"temperature/top_p/seed runtime inputs) instead of returning logits "
508+
"for host-side sampling.",
509+
)
477510
args = parser.parse_args()
478511

479512
if args.backend == "cuda" and not torch.cuda.is_available():
480513
parser.error("CUDA is required for the cuda backend.")
481514

515+
if args.sample and args.backend != "mlx":
516+
parser.error("--sample is only supported with --backend mlx")
517+
482518
if args.prequantized:
483519
model, config = load_prequantized_model(
484520
args.prequantized,
@@ -505,6 +541,7 @@ def main() -> None:
505541
args.output_dir,
506542
backend=args.backend,
507543
use_turboquant=args.turboquant,
544+
sample=args.sample,
508545
)
509546

510547

examples/models/gemma4_31b/gemma4_31b_engine.cpp

Lines changed: 114 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -54,12 +54,15 @@ constexpr const char* kDecodeMethod = "decode";
5454

5555
constexpr const char* kMaxPrefillChunk = "get_max_prefill_chunk";
5656
constexpr const char* kMinPrefillChunk = "get_min_prefill_chunk";
57+
constexpr const char* kUseSampling = "use_sampling";
5758

5859
Result<uint64_t> read_sampled_token(
5960
const executorch::aten::Tensor& output,
60-
float temperature) {
61+
float temperature,
62+
bool use_sampling) {
6163
#ifdef EXECUTORCH_BUILD_CUDA
6264
(void)temperature;
65+
(void)use_sampling;
6366
const void* ptr = output.const_data_ptr();
6467
cudaPointerAttributes attrs{};
6568
const bool on_device = cudaPointerGetAttributes(&attrs, ptr) == cudaSuccess &&
@@ -98,6 +101,13 @@ Result<uint64_t> read_sampled_token(
98101
static_cast<int>(output.scalar_type()));
99102
return Error::InvalidArgument;
100103
#else
104+
if (use_sampling) {
105+
ET_CHECK_OR_RETURN_ERROR(
106+
output.scalar_type() == executorch::aten::ScalarType::Long,
107+
InvalidProgram,
108+
"read_sampled_token: use_sampling set but forward output is not Long");
109+
return static_cast<uint64_t>(output.const_data_ptr<int64_t>()[0]);
110+
}
101111
return static_cast<uint64_t>(
102112
logits_to_token(output, temperature < 0.0f ? 0.0f : temperature));
103113
#endif
@@ -291,6 +301,19 @@ class Gemma4_31BSession : public LLMSession {
291301
auto temp_host =
292302
from_blob(&temp_val_, {1}, executorch::aten::ScalarType::Float);
293303
temp_tensor_dev_ = clone_tensor_ptr_to(temp_host, cuda_device_);
304+
#endif
305+
#ifdef EXECUTORCH_BUILD_MLX
306+
if (auto it = metadata_.find(kUseSampling); it != metadata_.end()) {
307+
use_sampling_ = it->second != 0;
308+
}
309+
temp_tensor_mlx_ =
310+
from_blob(&temp_val_mlx_, {}, executorch::aten::ScalarType::Float);
311+
top_k_tensor_ =
312+
from_blob(&top_k_val_, {}, executorch::aten::ScalarType::Long);
313+
top_p_tensor_ =
314+
from_blob(&top_p_val_, {}, executorch::aten::ScalarType::Float);
315+
seed_tensor_ =
316+
from_blob(&seed_val_, {}, executorch::aten::ScalarType::Long);
294317
#endif
295318
}
296319

@@ -312,15 +335,25 @@ class Gemma4_31BSession : public LLMSession {
312335
}
313336
float first_token_temp = temperature_;
314337
if (initial_sampling != nullptr) {
315-
if (initial_sampling->top_p != 1.0f || initial_sampling->top_k != 0 ||
316-
initial_sampling->seed != 0) {
338+
if (!use_sampling_ &&
339+
(initial_sampling->top_k != 0 || initial_sampling->top_p != 1.0f ||
340+
initial_sampling->seed != 0)) {
317341
ET_LOG(
318342
Error,
319-
"Gemma4_31BSession: only temperature is supported; top_p/top_k/seed "
320-
"are not implemented");
343+
"prefill_tokens: top_k/top_p/seed require a sampling model "
344+
"(export with --sample); only temperature is supported otherwise");
321345
return Error::NotSupported;
322346
}
323347
first_token_temp = initial_sampling->temperature;
348+
if (use_sampling_) {
349+
if (!valid_top_p(initial_sampling->top_p)) {
350+
ET_LOG(Error, "prefill_tokens: top_p must be in (0, 1]");
351+
return Error::InvalidArgument;
352+
}
353+
top_k_ = initial_sampling->top_k;
354+
top_p_ = initial_sampling->top_p;
355+
seed_ = initial_sampling->seed;
356+
}
324357
}
325358
if (!valid_temperature(first_token_temp)) {
326359
ET_LOG(Error, "prefill_tokens: temperature must be -1 or in [0, 2]");
@@ -360,15 +393,21 @@ class Gemma4_31BSession : public LLMSession {
360393
offset += chunk;
361394
}
362395
prev_decode_token_ = tokens.back();
396+
#ifdef EXECUTORCH_BUILD_MLX
397+
if (use_sampling_) {
398+
seed_ += 1;
399+
}
400+
#endif
363401
return Error::Ok;
364402
}
365403

366404
Result<DecodeResult> decode_one(const SamplingConfig& sampling) override {
367-
if (sampling.top_p != 1.0f || sampling.top_k != 0 || sampling.seed != 0) {
405+
if (!use_sampling_ &&
406+
(sampling.top_k != 0 || sampling.top_p != 1.0f || sampling.seed != 0)) {
368407
ET_LOG(
369408
Error,
370-
"Gemma4_31BSession: only temperature is supported; top_p/top_k/seed "
371-
"are not implemented");
409+
"Gemma4_31BSession: top_k/top_p/seed require a sampling model "
410+
"(export with --sample); only temperature is supported otherwise");
372411
return Error::NotSupported;
373412
}
374413
if (!valid_temperature(sampling.temperature)) {
@@ -380,6 +419,14 @@ class Gemma4_31BSession : public LLMSession {
380419
InvalidState,
381420
"decode_one requires a pending token; call prefill_tokens() first");
382421
temperature_ = sampling.temperature;
422+
if (use_sampling_) {
423+
if (!valid_top_p(sampling.top_p)) {
424+
ET_LOG(Error, "decode_one: top_p must be in (0, 1]");
425+
return Error::InvalidArgument;
426+
}
427+
top_k_ = sampling.top_k;
428+
top_p_ = sampling.top_p;
429+
}
383430

384431
if (stop_.load(std::memory_order_relaxed)) {
385432
return DecodeResult{0, "", /*is_eos=*/false, /*is_terminal=*/true};
@@ -427,13 +474,27 @@ class Gemma4_31BSession : public LLMSession {
427474
#else
428475
inputs.push_back(EValue(decode_tokens_));
429476
inputs.push_back(EValue(decode_pos_));
477+
#ifdef EXECUTORCH_BUILD_MLX
478+
if (use_sampling_) {
479+
set_sampling_inputs(temperature_, top_k_, top_p_, seed_);
480+
inputs.push_back(EValue(temp_tensor_mlx_));
481+
inputs.push_back(EValue(top_k_tensor_));
482+
inputs.push_back(EValue(top_p_tensor_));
483+
inputs.push_back(EValue(seed_tensor_));
484+
}
485+
#endif
430486
#endif
431487
auto sampled =
432488
run_locked(kDecodeMethod, inputs, temperature_, /*sync_after=*/false);
433489
ET_CHECK_OK_OR_RETURN_ERROR(sampled.error());
434490
pending_ = sampled.get();
435491
prev_decode_token_ = token;
436492
pos_ += 1;
493+
#ifdef EXECUTORCH_BUILD_MLX
494+
if (use_sampling_) {
495+
seed_ += 1;
496+
}
497+
#endif
437498
return DecodeResult{
438499
token, std::move(text_piece), /*is_eos=*/false, /*is_terminal=*/false};
439500
}
@@ -459,6 +520,20 @@ class Gemma4_31BSession : public LLMSession {
459520
return temperature == -1.0f || (temperature >= 0.0f && temperature <= 2.0f);
460521
}
461522

523+
static bool valid_top_p(float top_p) {
524+
return top_p > 0.0f && top_p <= 1.0f;
525+
}
526+
527+
#ifdef EXECUTORCH_BUILD_MLX
528+
void
529+
set_sampling_inputs(float temp, int64_t top_k, float top_p, uint64_t seed) {
530+
temp_val_mlx_ = (temp < 0.0f) ? 0.0f : temp;
531+
top_k_val_ = (top_k <= 0) ? INT64_MAX : top_k; // 0/neg = keep all
532+
top_p_val_ = top_p;
533+
seed_val_ = static_cast<int64_t>(seed);
534+
}
535+
#endif
536+
462537
Result<uint64_t>
463538
run_prefill_chunk(const uint64_t* tokens, int64_t T, float temperature) {
464539
std::vector<int64_t> token_data(tokens, tokens + T);
@@ -491,6 +566,15 @@ class Gemma4_31BSession : public LLMSession {
491566
(T >= min_prefill_chunk_) ? kPrefillMethod : kDecodeMethod;
492567
#else
493568
const char* method = kPrefillMethod;
569+
#endif
570+
#ifdef EXECUTORCH_BUILD_MLX
571+
if (use_sampling_) {
572+
set_sampling_inputs(temperature, top_k_, top_p_, seed_);
573+
inputs.push_back(EValue(temp_tensor_mlx_));
574+
inputs.push_back(EValue(top_k_tensor_));
575+
inputs.push_back(EValue(top_p_tensor_));
576+
inputs.push_back(EValue(seed_tensor_));
577+
}
494578
#endif
495579
return run_locked(method, inputs, temperature, /*sync_after=*/true);
496580
}
@@ -594,7 +678,7 @@ class Gemma4_31BSession : public LLMSession {
594678
: module_->execute(method, inputs);
595679
ET_CHECK_OK_OR_RETURN_ERROR(res.error());
596680
const auto& out_tensor = res.get()[0].toTensor();
597-
auto sampled = read_sampled_token(out_tensor, temperature);
681+
auto sampled = read_sampled_token(out_tensor, temperature, use_sampling_);
598682
ET_CHECK_OK_OR_RETURN_ERROR(sampled.error());
599683
#ifdef EXECUTORCH_BUILD_CUDA
600684
ET_CHECK_OK_OR_RETURN_ERROR(
@@ -626,6 +710,11 @@ class Gemma4_31BSession : public LLMSession {
626710
float temperature_ = -1.0f;
627711
std::atomic<bool> stop_{false};
628712

713+
bool use_sampling_ = false;
714+
int64_t top_k_ = 0; // 0 = off (keep all); mapped to INT64_MAX on-device
715+
float top_p_ = 1.0f;
716+
uint64_t seed_ = 0;
717+
629718
int64_t decode_token_data_[1] = {0};
630719
int64_t decode_pos_data_[1] = {0};
631720
TensorPtr decode_tokens_;
@@ -643,6 +732,16 @@ class Gemma4_31BSession : public LLMSession {
643732
TensorPtr decode_pos_dev_;
644733
TensorPtr temp_tensor_dev_;
645734
#endif
735+
#ifdef EXECUTORCH_BUILD_MLX
736+
float temp_val_mlx_ = 0.0f;
737+
int64_t top_k_val_ = INT64_MAX;
738+
float top_p_val_ = 1.0f;
739+
int64_t seed_val_ = 0;
740+
TensorPtr temp_tensor_mlx_;
741+
TensorPtr top_k_tensor_;
742+
TensorPtr top_p_tensor_;
743+
TensorPtr seed_tensor_;
744+
#endif
646745
};
647746

648747
} // namespace
@@ -689,6 +788,12 @@ Result<std::unique_ptr<Gemma4_31BEngine>> Gemma4_31BEngine::create(
689788
metadata[kMaxPrefillChunk] = max_prefill_chunk;
690789
}
691790

791+
#ifdef EXECUTORCH_BUILD_MLX
792+
if (auto get_result = meta_module->get(kUseSampling); get_result.ok()) {
793+
metadata[kUseSampling] = get_result->toScalar().to<int64_t>();
794+
}
795+
#endif
796+
692797
int64_t min_prefill_chunk = 1;
693798
#ifdef EXECUTORCH_BUILD_CUDA
694799
min_prefill_chunk = 5;

examples/models/gemma4_31b/main.cpp

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
#include <cstdio>
1818
#include <fstream>
1919
#include <optional>
20+
#include <random>
2021
#include <string>
2122
#include <vector>
2223

@@ -49,6 +50,22 @@ DEFINE_string(
4950
"",
5051
"Path to file containing prompt text (overrides --prompt).");
5152
DEFINE_double(temperature, 0.8, "Sampling temperature (0 = near-greedy).");
53+
DEFINE_int32(
54+
top_k,
55+
0,
56+
"Top-k sampling; keep the k most likely tokens. 0 (default) = off (keep "
57+
"all). Requires a model exported with --sample (MLX on-device sampling).");
58+
DEFINE_double(
59+
top_p,
60+
1.0,
61+
"Nucleus sampling top_p in (0, 1]; 1.0 = off. Requires a model exported "
62+
"with --sample (MLX on-device sampling).");
63+
DEFINE_int64(
64+
seed,
65+
-1,
66+
"Base RNG seed for on-device sampling; the runner increments it per token. "
67+
"-1 (default) draws a random seed each run; set a value for reproducible "
68+
"output. Requires a model exported with --sample.");
5269
DEFINE_int32(max_new_tokens, 128, "Maximum tokens to generate.");
5370
DEFINE_int32(bos_id, 2, "BOS token id to prepend (Gemma convention: 2).");
5471
DEFINE_int32(eos_id, 1, "EOS token id (Gemma convention: 1).");
@@ -162,6 +179,21 @@ int main(int argc, char** argv) {
162179

163180
llm::SamplingConfig sampling;
164181
sampling.temperature = static_cast<float>(FLAGS_temperature);
182+
sampling.top_k = FLAGS_top_k;
183+
sampling.top_p = static_cast<float>(FLAGS_top_p);
184+
// Only a --sample model uses the seed; randomize an unset seed for those and
185+
// leave non-sample models at 0 so they don't trip the top_p/seed guard.
186+
const auto& md = engine->metadata();
187+
const auto us_it = md.find("use_sampling");
188+
const bool model_samples = us_it != md.end() && us_it->second != 0;
189+
uint64_t base_seed = FLAGS_seed < 0 ? 0 : static_cast<uint64_t>(FLAGS_seed);
190+
if (model_samples && FLAGS_seed < 0) {
191+
base_seed = static_cast<uint64_t>(std::random_device{}());
192+
}
193+
sampling.seed = base_seed;
194+
if (model_samples) {
195+
printf("Sampling base seed: %" PRIu64 "\n", base_seed);
196+
}
165197
stats.inference_start_ms = llm::time_in_ms();
166198
if (session->prefill_tokens(prompt_tokens, &sampling) != Error::Ok) {
167199
ET_LOG(Error, "Prefill failed");

0 commit comments

Comments
 (0)