Skip to content

Commit 6c5a9ce

Browse files
committed
MLX sample: seed as optional Vid; greedy/bf16 as OpTestCase; negative-temp parity; move unittest tests to test_sample.py
1 parent 20af908 commit 6c5a9ce

8 files changed

Lines changed: 146 additions & 186 deletions

File tree

.github/workflows/mlx.yml

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -74,16 +74,13 @@ jobs:
7474
echo "::endgroup::"
7575
7676
echo "::group::Run Python unit tests"
77-
# test_ops.py is listed for its unittest classes; run_all_tests.py only
78-
# runs its OpTestCase classes.
7977
${CONDA_RUN} python -m pytest \
8078
backends/mlx/test/test_passes.py \
8179
backends/mlx/test/test_pattern_utils.py \
8280
backends/mlx/test/test_partitioner.py \
8381
backends/mlx/test/test_serialization_dedup.py \
8482
backends/mlx/test/test_slot_recycling.py \
8583
backends/mlx/test/test_sample.py \
86-
backends/mlx/test/test_ops.py \
8784
examples/models/gemma4_31b/quant/tests/test_pack_mlx.py \
8885
examples/models/gemma4_31b/tests/test_mlx_pipeline.py \
8986
-v

backends/mlx/custom_ops.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -403,8 +403,9 @@ def sample(
403403
"""
404404
Gumbel-max sampling from softmax(logits / temperature), with top-p (nucleus).
405405
logits: [B, vocab]
406-
temperature: scalar float tensor (runtime input). temperature == 0 is
407-
greedy: return argmax(logits) directly.
406+
temperature: scalar float tensor (runtime input). temperature <= 0 is
407+
greedy: return argmax(logits) directly (matches the device,
408+
which branches on temperature > 0).
408409
top_p: scalar float tensor in (0, 1]. top_p=1.0 keeps every
409410
token, i.e. it is off.
410411
seed: scalar int tensor or None
@@ -417,7 +418,7 @@ def sample(
417418
RNG (plain torch.rand, no uint32/nextafter uniform) while the delegate uses
418419
MLX RNG, so a given seed does not reproduce the same tokens host vs. device.
419420
"""
420-
if float(temperature) == 0:
421+
if float(temperature) <= 0: # matches the device cond (temperature > 0)
421422
return torch.argmax(logits, dim=-1)
422423
# whole chain in fp32 to match the lowered graph (bf16 sums mis-rank ties).
423424
scaled = logits.float() / temperature

backends/mlx/ops.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3496,7 +3496,7 @@ def emit_sample():
34963496
if seed is not None:
34973497
_, seed_val = P.make_tmp_value_slot()
34983498
P.emit(ItemIntNode(x=P.slot_to_tid(seed), out=P.slot_to_vid(seed_val)))
3499-
seed_field = P.to_int_or_vid(seed_val)
3499+
seed_field = P.slot_to_vid(seed_val)
35003500

35013501
# uniform u in [0, 1): bits/uint32_max, clamped just below 1 (random.cpp:95)
35023502
_, bits = P.make_tmp_slot()

backends/mlx/runtime/MLXInterpreter.h

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1708,7 +1708,8 @@ inline void exec_random_bits(
17081708
check_allocation_bounded(shape, uint32, "random_bits");
17091709
std::optional<array> key = std::nullopt;
17101710
if (n.seed.has_value()) {
1711-
key = random::key(static_cast<uint64_t>(resolve_int(n.seed.value(), st)));
1711+
key = random::key(
1712+
static_cast<uint64_t>(st.const_value_ref<int32_t>(n.seed.value())));
17121713
}
17131714
st.set_tensor(n.out, random::bits(shape, n.width, key, s));
17141715
}

backends/mlx/serialization/generate.py

Lines changed: 14 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -572,7 +572,7 @@ def generate_python_serializers(schema: FBSSchema) -> str:
572572
"",
573573
"from __future__ import annotations",
574574
"",
575-
"from typing import Dict, List, Optional, Tuple",
575+
"from typing import List, Tuple, Dict",
576576
"",
577577
"import flatbuffers",
578578
"",
@@ -640,10 +640,8 @@ def generate_python_serializers(schema: FBSSchema) -> str:
640640
"class GeneratedOpBuilders:",
641641
' """Mixin class with auto-generated op builder methods."""',
642642
"",
643-
" def _build_int_or_vid(self, builder: flatbuffers.Builder, iov: IntOrVid) -> Optional[int]:",
644-
' """Build an IntOrVid table (None -> absent field, like _shared_string)."""',
645-
" if iov is None:",
646-
" return None",
643+
" def _build_int_or_vid(self, builder: flatbuffers.Builder, iov: IntOrVid) -> int:",
644+
' """Build an IntOrVid table."""',
647645
" from executorch.backends.mlx.serialization._generated.mlx_delegate import IntOrVid as FBIntOrVidModule",
648646
" from executorch.backends.mlx.serialization._generated.mlx_delegate.Vid import CreateVid",
649647
"",
@@ -655,10 +653,8 @@ def generate_python_serializers(schema: FBSSchema) -> str:
655653
" FBIntOrVidModule.AddVid(builder, CreateVid(builder, iov.vid.idx))",
656654
" return FBIntOrVidModule.End(builder)",
657655
"",
658-
" def _build_float_or_vid(self, builder: flatbuffers.Builder, fov: FloatOrVid) -> Optional[int]:",
659-
' """Build a FloatOrVid table (None -> absent field)."""',
660-
" if fov is None:",
661-
" return None",
656+
" def _build_float_or_vid(self, builder: flatbuffers.Builder, fov: FloatOrVid) -> int:",
657+
' """Build a FloatOrVid table."""',
662658
" from executorch.backends.mlx.serialization._generated.mlx_delegate import FloatOrVid as FBFloatOrVidModule",
663659
" from executorch.backends.mlx.serialization._generated.mlx_delegate.Vid import CreateVid",
664660
"",
@@ -669,10 +665,8 @@ def generate_python_serializers(schema: FBSSchema) -> str:
669665
" FBFloatOrVidModule.AddVid(builder, CreateVid(builder, fov.vid.idx))",
670666
" return FBFloatOrVidModule.End(builder)",
671667
"",
672-
" def _build_vid_or_tid(self, builder: flatbuffers.Builder, vot: VidOrTid) -> Optional[int]:",
673-
' """Build a TidOrVid table (None -> absent field)."""',
674-
" if vot is None:",
675-
" return None",
668+
" def _build_vid_or_tid(self, builder: flatbuffers.Builder, vot: VidOrTid) -> int:",
669+
' """Build a TidOrVid table."""',
676670
" from executorch.backends.mlx.serialization._generated.mlx_delegate import VidOrTid as FBVidOrTidModule",
677671
" from executorch.backends.mlx.serialization._generated.mlx_delegate.Tid import CreateTid",
678672
" from executorch.backends.mlx.serialization._generated.mlx_delegate.Vid import CreateVid",
@@ -685,10 +679,8 @@ def generate_python_serializers(schema: FBSSchema) -> str:
685679
" FBVidOrTidModule.AddVid(builder, CreateVid(builder, vot.vid.idx))",
686680
" return FBVidOrTidModule.End(builder)",
687681
"",
688-
" def _build_int_or_vid_or_tid(self, builder: flatbuffers.Builder, ivt: IntOrVidOrTid) -> Optional[int]:",
689-
' """Build an IntOrVidOrTid table (None -> absent field)."""',
690-
" if ivt is None:",
691-
" return None",
682+
" def _build_int_or_vid_or_tid(self, builder: flatbuffers.Builder, ivt: IntOrVidOrTid) -> int:",
683+
' """Build an IntOrVidOrTid table."""',
692684
" from executorch.backends.mlx.serialization._generated.mlx_delegate import IntOrVidOrTid as FBIntOrVidOrTidModule",
693685
" from executorch.backends.mlx.serialization._generated.mlx_delegate.Tid import CreateTid",
694686
" from executorch.backends.mlx.serialization._generated.mlx_delegate.Vid import CreateVid",
@@ -863,12 +855,7 @@ def _emit_py_add(
863855
return [f" {add}(builder, op.{n})"]
864856
# Pre-built offsets (string, compound types)
865857
if kind in ("str", "int_or_vid", "float_or_vid", "vid_or_tid", "int_or_vid_or_tid"):
866-
if fld.required:
867-
return [f" {add}(builder, {n}_off)"]
868-
return [
869-
f" if {n}_off is not None:",
870-
f" {add}(builder, {n}_off)",
871-
]
858+
return [f" {add}(builder, {n}_off)"]
872859
# Pre-built vectors (required vs optional)
873860
if kind in (
874861
"list_int",
@@ -1069,8 +1056,6 @@ def _fbs_type_to_cpp(
10691056
return "std::optional<Tid>"
10701057
if fbs_type == "Vid":
10711058
return "std::optional<Vid>"
1072-
if fbs_type in ("IntOrVid", "FloatOrVid", "VidOrTid", "IntOrVidOrTid"):
1073-
return f"std::optional<{cpp_type}>"
10741059
if fld is not None and fld.default == "null" and fbs_type in FBS_TO_CPP:
10751060
return f"std::optional<{cpp_type}>"
10761061

@@ -1155,7 +1140,7 @@ def _generate_loader_case(table: FBSTable) -> List[str]:
11551140

11561141
fb_field_name = fld.name
11571142
kind = _get_field_kind(fld, table)
1158-
load_lines = _emit_cpp_load(kind, fld.name, fb_field_name, table, fld)
1143+
load_lines = _emit_cpp_load(kind, fld.name, fb_field_name, table)
11591144
if load_lines is None:
11601145
raise ValueError(
11611146
f"Unhandled field kind '{kind}' for field '{fld.name}' in table '{table.name}'. "
@@ -1187,24 +1172,16 @@ def _generate_loader_case(table: FBSTable) -> List[str]:
11871172
}
11881173

11891174

1190-
def _emit_cpp_load( # noqa: C901
1191-
kind: str, name: str, fb_name: str, table=None, fld=None
1175+
def _emit_cpp_load(
1176+
kind: str, name: str, fb_name: str, table=None
11921177
) -> "List[str] | None":
11931178
"""Emit C++ load lines for a field kind, or None if kind is unrecognized."""
11941179
# Interned string fields share one std::string via the load-time pool.
11951180
if _is_interned_str(table, name) and kind in ("str", "optional_str"):
11961181
return [f" node.{name} = strpool.intern(fb->{fb_name}());"]
1197-
# Struct / compound via converter
1182+
# Required struct / compound via converter
11981183
if kind in _CPP_CONVERTER:
11991184
conv = _CPP_CONVERTER[kind]
1200-
# Optional compound fields (e.g. an optional IntOrVid) must be
1201-
# presence-guarded; convert_* throws on a null FlatBuffer pointer.
1202-
if fld is not None and not fld.required:
1203-
return [
1204-
f" if (fb->{fb_name}()) {{",
1205-
f" node.{name} = {conv}(fb->{fb_name}());",
1206-
" }",
1207-
]
12081185
return [f" node.{name} = {conv}(fb->{fb_name}());"]
12091186
# Scalars (direct value)
12101187
if kind in ("int", "float", "bool"):

backends/mlx/serialization/schema.fbs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -988,7 +988,7 @@ table IfNode {
988988
table RandomBitsNode {
989989
out: Tid (required);
990990
shape: [IntOrVid] (required);
991-
seed: IntOrVid; // OPTIONAL: present -> random::key(seed);
991+
seed: Vid; // OPTIONAL: present -> random::key(seed);
992992
// absent -> MLX global KeySequence
993993
width: int32 = 4; // bytes per element (4 -> uint32)
994994
}

backends/mlx/test/test_ops.py

Lines changed: 40 additions & 93 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,6 @@
2525
"""
2626

2727
import os
28-
import unittest
2928
from typing import Callable, Dict, List, Optional, Tuple
3029

3130
import torch
@@ -7703,95 +7702,43 @@ def create_inputs(self) -> Tuple[torch.Tensor, ...]:
77037702
)
77047703

77057704

7706-
def _ref_gumbel_max(logits: torch.Tensor, temperature: float, seed: int):
7707-
"""Independent Gumbel-max reference using the same torch RNG as the op."""
7708-
gen = torch.Generator().manual_seed(seed)
7709-
u = torch.rand(logits.shape, generator=gen)
7710-
gumbel = -torch.log(-torch.log(u))
7711-
return torch.argmax(logits / temperature + gumbel, dim=-1)
7712-
7713-
7714-
def _tv_distance(p: torch.Tensor, q: torch.Tensor) -> float:
7715-
"""Total-variation distance between two discrete distributions."""
7716-
return 0.5 * torch.abs(p - q).sum().item()
7717-
7718-
7719-
def _sample(logits, temperature, seed: Optional[int], top_p: float = 1.0):
7720-
t = torch.tensor(float(temperature))
7721-
s = None if seed is None else torch.tensor(int(seed), dtype=torch.int64)
7722-
p = torch.tensor(float(top_p)) # 1.0 = off
7723-
return torch.ops.mlx.sample(logits, t, p, s)
7724-
7725-
7726-
class TestSampleOp(unittest.TestCase):
7727-
"""Eager reference behavior of mlx::sample (no export / no runtime)."""
7728-
7729-
def test_greedy_parity_small_temperature(self):
7730-
# Small temperature -> Gumbel-max collapses to argmax(logits).
7731-
torch.manual_seed(0)
7732-
logits = torch.randn(8, 1024)
7733-
token = _sample(logits, 1e-4, seed=0)
7734-
self.assertTrue(torch.equal(token, torch.argmax(logits, dim=-1)))
7735-
7736-
def test_greedy_temperature_zero(self):
7737-
# temperature == 0 is exact greedy: argmax(logits), no RNG, no division.
7738-
torch.manual_seed(0)
7739-
logits = torch.randn(8, 1024)
7740-
token = _sample(logits, 0.0, seed=0)
7741-
self.assertTrue(torch.equal(token, torch.argmax(logits, dim=-1)))
7742-
7743-
def test_matches_independent_gumbel_reference(self):
7744-
# Same seed -> bit-identical token vs an independent Gumbel-max impl.
7745-
torch.manual_seed(1)
7746-
logits = torch.randn(8, 512)
7747-
for seed in (0, 1, 7, 42):
7748-
got = _sample(logits, 0.8, seed=seed)
7749-
expected = _ref_gumbel_max(logits, 0.8, seed)
7750-
self.assertTrue(torch.equal(got, expected), f"mismatch at seed={seed}")
7751-
7752-
def test_distribution_matches_softmax(self):
7753-
# Empirical token frequencies match softmax(logits / T).
7754-
vocab = 5
7755-
temperature = 1.0
7756-
torch.manual_seed(0)
7757-
base = torch.randn(vocab)
7758-
n = 20000
7759-
tokens = _sample(base.expand(n, vocab), temperature, seed=0)
7760-
7761-
empirical = torch.bincount(tokens, minlength=vocab).float() / n
7762-
target = torch.softmax(base / temperature, dim=-1)
7763-
tv = _tv_distance(empirical, target)
7764-
self.assertLess(tv, 0.05, f"TV distance {tv:.4f} too large")
7765-
7766-
def test_determinism_seeded(self):
7767-
# Same seed -> identical draws; different seed -> different draws.
7768-
torch.manual_seed(0)
7769-
logits = torch.randn(256, 64)
7770-
a = _sample(logits, 1.0, seed=123)
7771-
b = _sample(logits, 1.0, seed=123)
7772-
c = _sample(logits, 1.0, seed=124)
7773-
self.assertTrue(torch.equal(a, b))
7774-
self.assertFalse(torch.equal(a, c))
7775-
7776-
def test_unseeded_varies_across_calls(self):
7777-
# seed=None uses the global RNG -> draws vary, tokens stay in range.
7778-
torch.manual_seed(0)
7779-
logits = torch.randn(256, 64)
7780-
a = _sample(logits, 1.0, seed=None)
7781-
b = _sample(logits, 1.0, seed=None)
7782-
self.assertFalse(torch.equal(a, b))
7783-
self.assertGreaterEqual(int(a.min()), 0)
7784-
self.assertLess(int(a.max()), 64)
7785-
7786-
def test_top_p_restricts_to_nucleus(self):
7787-
# probs [0.5, 0.3, 0.15, 0.05]; top_p=0.9 keeps {0,1,2}, drops index 3.
7788-
base = torch.log(torch.tensor([0.5, 0.3, 0.15, 0.05]))
7789-
tokens = _sample(base.expand(5000, 4), 1.0, seed=0, top_p=0.9)
7790-
self.assertTrue((tokens != 3).all()) # tail token never drawn
7791-
self.assertEqual(set(tokens.tolist()), {0, 1, 2}) # nucleus covered
7792-
7793-
def test_top_p_one_keeps_all(self):
7794-
# top_p=1.0 -> no filtering; the tail token (index 3) is reachable.
7795-
base = torch.log(torch.tensor([0.5, 0.3, 0.15, 0.05]))
7796-
tokens = _sample(base.expand(20000, 4), 1.0, seed=0, top_p=1.0)
7797-
self.assertTrue((tokens == 3).any())
7705+
@register_test
7706+
class SampleGreedyTest(OpTestCase):
7707+
"""Greedy argmax(logits) is bit-exact host/device, so verify the token with the
7708+
normal compare. Covers temperature=0, tiny temperature, and bf16 logits."""
7709+
7710+
name = "sample_greedy"
7711+
7712+
def __init__(self, temperature: float = 0.0, dtype: torch.dtype = torch.float32):
7713+
self.temperature = temperature
7714+
self.dtype = dtype
7715+
if dtype == torch.bfloat16:
7716+
self.name = "sample_greedy_bf16"
7717+
elif temperature < 0:
7718+
self.name = "sample_greedy_neg"
7719+
elif temperature == 0.0:
7720+
self.name = "sample_greedy"
7721+
else:
7722+
self.name = "sample_greedy_eps"
7723+
7724+
@classmethod
7725+
def get_test_configs(cls) -> List["SampleGreedyTest"]:
7726+
return [
7727+
cls(temperature=0.0),
7728+
cls(temperature=1e-4),
7729+
cls(temperature=-1.0), # negative -> greedy on both paths (consistent)
7730+
cls(temperature=1e-4, dtype=torch.bfloat16),
7731+
]
7732+
7733+
def create_model(self) -> nn.Module:
7734+
return SeededSampleModel()
7735+
7736+
def create_inputs(self) -> Tuple[torch.Tensor, ...]:
7737+
logits = torch.randn(1, 4, 1024, dtype=self.dtype)
7738+
if self.dtype == torch.bfloat16:
7739+
logits[0, -1, 512] = 50.0 # dominant -> unambiguous bf16 argmax
7740+
return (
7741+
logits,
7742+
torch.tensor(self.temperature),
7743+
torch.tensor(0, dtype=torch.int64),
7744+
)

0 commit comments

Comments
 (0)