Skip to content

Commit 373b79d

Browse files
committed
Add on-device sampling to the Gemma 4 31B MLX runner
1 parent 825bd30 commit 373b79d

4 files changed

Lines changed: 232 additions & 15 deletions

File tree

examples/models/gemma4_31b/export.py

Lines changed: 53 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}."
@@ -306,11 +309,30 @@ def _export_cuda(
306309
print("Done.")
307310

308311

312+
class _MLXSampleWrapper(nn.Module):
313+
"""Wrap the model so ``forward`` returns a sampled token id.
314+
315+
The MLX source transforms make ``forward`` return last-token logits
316+
``(B, vocab)``, so sample directly. Temperature, top_p, and seed are runtime
317+
scalar inputs so the same .pte serves any sampling request; the runner
318+
increments the seed per token.
319+
"""
320+
321+
def __init__(self, model: nn.Module):
322+
super().__init__()
323+
self.model = model
324+
325+
def forward(self, tokens, input_pos, temperature, top_p, seed):
326+
logits = self.model(tokens, input_pos)
327+
return torch.ops.mlx.sample(logits, temperature, top_p, seed)
328+
329+
309330
def _export_mlx(
310331
model: Gemma4_31B,
311332
config: Gemma4_31BConfig,
312333
output_dir: str,
313334
use_turboquant: bool = False,
335+
sample: bool = False,
314336
) -> None:
315337
"""Export to .pte via torch.export + MLX backend.
316338
@@ -358,15 +380,28 @@ def _export_mlx(
358380

359381
seq_dim = Dim("seq_len", min=1, max=max_prefill)
360382

383+
example_tokens = torch.tensor([[0, 1]], dtype=torch.long)
384+
example_input_pos = torch.tensor([0, 1], dtype=torch.long)
385+
if sample:
386+
model = _MLXSampleWrapper(model)
387+
example_args = (
388+
example_tokens,
389+
example_input_pos,
390+
torch.tensor(1.0, dtype=torch.float32),
391+
torch.tensor(1.0, dtype=torch.float32),
392+
torch.tensor(0, dtype=torch.int64),
393+
)
394+
dynamic_shapes = ({1: seq_dim}, {0: seq_dim}, None, None, None)
395+
else:
396+
example_args = (example_tokens, example_input_pos)
397+
dynamic_shapes = ({1: seq_dim}, {0: seq_dim})
398+
361399
print(f"Exporting (T in [1, {max_prefill}])...")
362400
with torch.no_grad():
363401
exported = export(
364402
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}),
403+
example_args,
404+
dynamic_shapes=dynamic_shapes,
370405
strict=True,
371406
)
372407

@@ -390,6 +425,7 @@ def _export_mlx(
390425
"use_kv_cache": True,
391426
"use_sdpa_with_kv_cache": False,
392427
"enable_dynamic_shape": True,
428+
"use_sampling": sample,
393429
},
394430
)
395431

@@ -474,11 +510,21 @@ def main() -> None:
474510
"sliding layers keep their default cache. Supported on both "
475511
"--backend mlx and --backend cuda.",
476512
)
513+
parser.add_argument(
514+
"--sample",
515+
action="store_true",
516+
help="MLX only: sample the next token on-device (Gumbel-max with "
517+
"temperature/top_p/seed runtime inputs) instead of returning logits "
518+
"for host-side sampling.",
519+
)
477520
args = parser.parse_args()
478521

479522
if args.backend == "cuda" and not torch.cuda.is_available():
480523
parser.error("CUDA is required for the cuda backend.")
481524

525+
if args.sample and args.backend != "mlx":
526+
parser.error("--sample is only supported with --backend mlx")
527+
482528
if args.prequantized:
483529
model, config = load_prequantized_model(
484530
args.prequantized,
@@ -505,6 +551,7 @@ def main() -> None:
505551
args.output_dir,
506552
backend=args.backend,
507553
use_turboquant=args.turboquant,
554+
sample=args.sample,
508555
)
509556

510557

examples/models/gemma4_31b/gemma4_31b_engine.cpp

Lines changed: 105 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,9 @@ Result<uint64_t> read_sampled_token(
98101
static_cast<int>(output.scalar_type()));
99102
return Error::InvalidArgument;
100103
#else
104+
if (use_sampling) {
105+
return static_cast<uint64_t>(output.const_data_ptr<int64_t>()[0]);
106+
}
101107
return static_cast<uint64_t>(
102108
logits_to_token(output, temperature < 0.0f ? 0.0f : temperature));
103109
#endif
@@ -257,6 +263,17 @@ class Gemma4_31BSession : public LLMSession {
257263
auto temp_host =
258264
from_blob(&temp_val_, {1}, executorch::aten::ScalarType::Float);
259265
temp_tensor_dev_ = clone_tensor_ptr_to(temp_host, cuda_device_);
266+
#endif
267+
#ifdef EXECUTORCH_BUILD_MLX
268+
if (auto it = metadata_.find(kUseSampling); it != metadata_.end()) {
269+
use_sampling_ = it->second != 0;
270+
}
271+
temp_tensor_mlx_ =
272+
from_blob(&temp_val_mlx_, {}, executorch::aten::ScalarType::Float);
273+
top_p_tensor_ =
274+
from_blob(&top_p_val_, {}, executorch::aten::ScalarType::Float);
275+
seed_tensor_ =
276+
from_blob(&seed_val_, {}, executorch::aten::ScalarType::Long);
260277
#endif
261278
}
262279

@@ -278,15 +295,27 @@ class Gemma4_31BSession : public LLMSession {
278295
}
279296
float first_token_temp = temperature_;
280297
if (initial_sampling != nullptr) {
281-
if (initial_sampling->top_p != 1.0f || initial_sampling->top_k != 0 ||
282-
initial_sampling->seed != 0) {
298+
if (initial_sampling->top_k != 0) {
299+
ET_LOG(Error, "prefill_tokens: top_k is not implemented");
300+
return Error::NotSupported;
301+
}
302+
if (!use_sampling_ &&
303+
(initial_sampling->top_p != 1.0f || initial_sampling->seed != 0)) {
283304
ET_LOG(
284305
Error,
285-
"Gemma4_31BSession: only temperature is supported; top_p/top_k/seed "
286-
"are not implemented");
306+
"prefill_tokens: top_p/seed require a sampling model "
307+
"(export with --sample); only temperature is supported otherwise");
287308
return Error::NotSupported;
288309
}
289310
first_token_temp = initial_sampling->temperature;
311+
if (use_sampling_) {
312+
if (!valid_top_p(initial_sampling->top_p)) {
313+
ET_LOG(Error, "prefill_tokens: top_p must be in (0, 1]");
314+
return Error::InvalidArgument;
315+
}
316+
top_p_ = initial_sampling->top_p;
317+
seed_ = initial_sampling->seed;
318+
}
290319
}
291320
if (!valid_temperature(first_token_temp)) {
292321
ET_LOG(Error, "prefill_tokens: temperature must be -1 or in [0, 2]");
@@ -326,15 +355,24 @@ class Gemma4_31BSession : public LLMSession {
326355
offset += chunk;
327356
}
328357
prev_decode_token_ = tokens.back();
358+
#ifdef EXECUTORCH_BUILD_MLX
359+
if (use_sampling_) {
360+
seed_ += 1;
361+
}
362+
#endif
329363
return Error::Ok;
330364
}
331365

332366
Result<DecodeResult> decode_one(const SamplingConfig& sampling) override {
333-
if (sampling.top_p != 1.0f || sampling.top_k != 0 || sampling.seed != 0) {
367+
if (sampling.top_k != 0) {
368+
ET_LOG(Error, "Gemma4_31BSession: top_k is not implemented");
369+
return Error::NotSupported;
370+
}
371+
if (!use_sampling_ && (sampling.top_p != 1.0f || sampling.seed != 0)) {
334372
ET_LOG(
335373
Error,
336-
"Gemma4_31BSession: only temperature is supported; top_p/top_k/seed "
337-
"are not implemented");
374+
"Gemma4_31BSession: top_p/seed require a sampling model "
375+
"(export with --sample); only temperature is supported otherwise");
338376
return Error::NotSupported;
339377
}
340378
if (!valid_temperature(sampling.temperature)) {
@@ -346,6 +384,13 @@ class Gemma4_31BSession : public LLMSession {
346384
InvalidState,
347385
"decode_one requires a pending token; call prefill_tokens() first");
348386
temperature_ = sampling.temperature;
387+
if (use_sampling_) {
388+
if (!valid_top_p(sampling.top_p)) {
389+
ET_LOG(Error, "decode_one: top_p must be in (0, 1]");
390+
return Error::InvalidArgument;
391+
}
392+
top_p_ = sampling.top_p;
393+
}
349394

350395
if (stop_.load(std::memory_order_relaxed)) {
351396
return DecodeResult{0, "", /*is_eos=*/false, /*is_terminal=*/true};
@@ -393,13 +438,26 @@ class Gemma4_31BSession : public LLMSession {
393438
#else
394439
inputs.push_back(EValue(decode_tokens_));
395440
inputs.push_back(EValue(decode_pos_));
441+
#ifdef EXECUTORCH_BUILD_MLX
442+
if (use_sampling_) {
443+
set_sampling_inputs(temperature_, top_p_, seed_);
444+
inputs.push_back(EValue(temp_tensor_mlx_));
445+
inputs.push_back(EValue(top_p_tensor_));
446+
inputs.push_back(EValue(seed_tensor_));
447+
}
448+
#endif
396449
#endif
397450
auto sampled =
398451
run_locked(kDecodeMethod, inputs, temperature_, /*sync_after=*/false);
399452
ET_CHECK_OK_OR_RETURN_ERROR(sampled.error());
400453
pending_ = sampled.get();
401454
prev_decode_token_ = token;
402455
pos_ += 1;
456+
#ifdef EXECUTORCH_BUILD_MLX
457+
if (use_sampling_) {
458+
seed_ += 1;
459+
}
460+
#endif
403461
return DecodeResult{
404462
token, std::move(text_piece), /*is_eos=*/false, /*is_terminal=*/false};
405463
}
@@ -425,6 +483,18 @@ class Gemma4_31BSession : public LLMSession {
425483
return temperature == -1.0f || (temperature >= 0.0f && temperature <= 2.0f);
426484
}
427485

486+
static bool valid_top_p(float top_p) {
487+
return top_p > 0.0f && top_p <= 1.0f;
488+
}
489+
490+
#ifdef EXECUTORCH_BUILD_MLX
491+
void set_sampling_inputs(float temp, float top_p, uint64_t seed) {
492+
temp_val_mlx_ = (temp < 0.0f) ? 0.0f : temp;
493+
top_p_val_ = top_p;
494+
seed_val_ = static_cast<int64_t>(seed);
495+
}
496+
#endif
497+
428498
Result<uint64_t>
429499
run_prefill_chunk(const uint64_t* tokens, int64_t T, float temperature) {
430500
std::vector<int64_t> token_data(tokens, tokens + T);
@@ -457,6 +527,14 @@ class Gemma4_31BSession : public LLMSession {
457527
(T >= min_prefill_chunk_) ? kPrefillMethod : kDecodeMethod;
458528
#else
459529
const char* method = kPrefillMethod;
530+
#endif
531+
#ifdef EXECUTORCH_BUILD_MLX
532+
if (use_sampling_) {
533+
set_sampling_inputs(temperature, top_p_, seed_);
534+
inputs.push_back(EValue(temp_tensor_mlx_));
535+
inputs.push_back(EValue(top_p_tensor_));
536+
inputs.push_back(EValue(seed_tensor_));
537+
}
460538
#endif
461539
return run_locked(method, inputs, temperature, /*sync_after=*/true);
462540
}
@@ -560,7 +638,7 @@ class Gemma4_31BSession : public LLMSession {
560638
: module_->execute(method, inputs);
561639
ET_CHECK_OK_OR_RETURN_ERROR(res.error());
562640
const auto& out_tensor = res.get()[0].toTensor();
563-
auto sampled = read_sampled_token(out_tensor, temperature);
641+
auto sampled = read_sampled_token(out_tensor, temperature, use_sampling_);
564642
ET_CHECK_OK_OR_RETURN_ERROR(sampled.error());
565643
#ifdef EXECUTORCH_BUILD_CUDA
566644
ET_CHECK_OK_OR_RETURN_ERROR(
@@ -592,6 +670,10 @@ class Gemma4_31BSession : public LLMSession {
592670
float temperature_ = -1.0f;
593671
std::atomic<bool> stop_{false};
594672

673+
bool use_sampling_ = false;
674+
float top_p_ = 1.0f;
675+
uint64_t seed_ = 0;
676+
595677
int64_t decode_token_data_[1] = {0};
596678
int64_t decode_pos_data_[1] = {0};
597679
TensorPtr decode_tokens_;
@@ -609,6 +691,14 @@ class Gemma4_31BSession : public LLMSession {
609691
TensorPtr decode_pos_dev_;
610692
TensorPtr temp_tensor_dev_;
611693
#endif
694+
#ifdef EXECUTORCH_BUILD_MLX
695+
float temp_val_mlx_ = 0.0f;
696+
float top_p_val_ = 1.0f;
697+
int64_t seed_val_ = 0;
698+
TensorPtr temp_tensor_mlx_;
699+
TensorPtr top_p_tensor_;
700+
TensorPtr seed_tensor_;
701+
#endif
612702
};
613703

614704
} // namespace
@@ -655,6 +745,12 @@ Result<std::unique_ptr<Gemma4_31BEngine>> Gemma4_31BEngine::create(
655745
metadata[kMaxPrefillChunk] = max_prefill_chunk;
656746
}
657747

748+
#ifdef EXECUTORCH_BUILD_MLX
749+
if (auto get_result = meta_module->get(kUseSampling); get_result.ok()) {
750+
metadata[kUseSampling] = get_result->toScalar().to<int64_t>();
751+
}
752+
#endif
753+
658754
int64_t min_prefill_chunk = 1;
659755
#ifdef EXECUTORCH_BUILD_CUDA
660756
min_prefill_chunk = 5;

examples/models/gemma4_31b/main.cpp

Lines changed: 26 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,17 @@ 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_double(
54+
top_p,
55+
1.0,
56+
"Nucleus sampling top_p in (0, 1]; 1.0 = off. Requires a model exported "
57+
"with --sample (MLX on-device sampling).");
58+
DEFINE_int64(
59+
seed,
60+
-1,
61+
"Base RNG seed for on-device sampling; the runner increments it per token. "
62+
"-1 (default) draws a random seed each run; set a value for reproducible "
63+
"output. Requires a model exported with --sample.");
5264
DEFINE_int32(max_new_tokens, 128, "Maximum tokens to generate.");
5365
DEFINE_int32(bos_id, 2, "BOS token id to prepend (Gemma convention: 2).");
5466
DEFINE_int32(eos_id, 1, "EOS token id (Gemma convention: 1).");
@@ -162,6 +174,20 @@ int main(int argc, char** argv) {
162174

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

0 commit comments

Comments
 (0)