Skip to content

Commit bb448a1

Browse files
committed
feat(v7-g03): validate tokenizer fingerprints across shards
1 parent 7d590c6 commit bb448a1

12 files changed

Lines changed: 460 additions & 8 deletions

File tree

cppmega_mlx/data/parquet_dataset.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
_to_int32_token_ids,
2929
_to_side_channel_values,
3030
)
31+
from cppmega_mlx.tokenizer.fingerprint import tokenizer_fingerprint
3132

3233

3334
TextEncoder = Callable[[str], Sequence[int]]
@@ -583,6 +584,10 @@ def __init__(
583584
self.seed = seed
584585
self.loop = loop
585586
self._metadata_columns = metadata_columns
587+
self.tokenizer_fingerprint = _validate_parquet_tokenizer_fingerprints(
588+
self.paths,
589+
expected_tokenizer=tokenizer,
590+
)
586591
summaries: list[_ShardDatasetSummary] = []
587592
side_channel_names: set[str] = set()
588593
self.path = self.paths[0]
@@ -616,6 +621,7 @@ def __init__(
616621
"stream": {
617622
"shard_count": len(self._shards),
618623
"deterministic_order": True,
624+
"tokenizer_fingerprint": self.tokenizer_fingerprint,
619625
"shards": [
620626
{
621627
"index": index,
@@ -720,6 +726,53 @@ def _parquet_file_row_count(path: Path) -> int:
720726
return int(pq.ParquetFile(path).metadata.num_rows)
721727

722728

729+
def _parquet_tokenizer_fingerprint(path: Path) -> str | None:
730+
pq = importlib.import_module("pyarrow.parquet")
731+
parquet_file = pq.ParquetFile(path)
732+
if "tokenizer_fingerprint" not in parquet_file.schema_arrow.names:
733+
return None
734+
seen: set[str] = set()
735+
for batch in parquet_file.iter_batches(
736+
columns=["tokenizer_fingerprint"],
737+
batch_size=1024,
738+
):
739+
for value in batch.column(0).to_pylist():
740+
if value is not None:
741+
seen.add(str(value))
742+
if len(seen) > 1:
743+
raise ValueError(
744+
f"tokenizer_fingerprint mismatch within shard {path}"
745+
)
746+
return next(iter(seen)) if seen else None
747+
748+
749+
def _validate_parquet_tokenizer_fingerprints(
750+
paths: Sequence[Path],
751+
*,
752+
expected_tokenizer: Any | None = None,
753+
) -> str | None:
754+
fingerprints = {
755+
fingerprint
756+
for path in paths
757+
if (fingerprint := _parquet_tokenizer_fingerprint(path)) is not None
758+
}
759+
if len(fingerprints) > 1:
760+
joined = ", ".join(sorted(fingerprints))
761+
raise ValueError(
762+
"tokenizer_fingerprint mismatch across parquet shards: "
763+
f"{joined}"
764+
)
765+
fingerprint = next(iter(fingerprints)) if fingerprints else None
766+
if fingerprint is not None and expected_tokenizer is not None:
767+
expected = tokenizer_fingerprint(expected_tokenizer)
768+
if fingerprint != expected:
769+
raise ValueError(
770+
"tokenizer_fingerprint mismatch between parquet shards "
771+
f"and active tokenizer: shard={fingerprint} active={expected}"
772+
)
773+
return fingerprint
774+
775+
723776
def _candidate_parquet_columns(
724777
*,
725778
token_key: str,

cppmega_mlx/tokenizer/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,11 @@
55
TokenizerContractError,
66
load_cppmega_tokenizer,
77
)
8+
from cppmega_mlx.tokenizer.fingerprint import tokenizer_fingerprint
89

910
__all__ = [
1011
"CppMegaTokenizer",
1112
"TokenizerContractError",
1213
"load_cppmega_tokenizer",
14+
"tokenizer_fingerprint",
1315
]
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
"""Stable tokenizer fingerprints for dataset/consumer compatibility checks."""
2+
3+
from __future__ import annotations
4+
5+
import hashlib
6+
import json
7+
import os
8+
from pathlib import Path
9+
from typing import Any
10+
11+
12+
def tokenizer_fingerprint(tokenizer_or_path: Any) -> str:
13+
"""Hash tokenizer vocab, merges, and special-token map as canonical JSON."""
14+
15+
tokenizer_json = _tokenizer_json(tokenizer_or_path)
16+
payload = {
17+
"model": {
18+
"type": tokenizer_json.get("model", {}).get("type"),
19+
"unk_token": tokenizer_json.get("model", {}).get("unk_token"),
20+
"vocab": tokenizer_json.get("model", {}).get("vocab", {}),
21+
"merges": tokenizer_json.get("model", {}).get("merges", []),
22+
},
23+
"special_tokens": sorted(
24+
(
25+
int(token.get("id", -1)),
26+
str(token.get("content", "")),
27+
bool(token.get("special", False)),
28+
)
29+
for token in tokenizer_json.get("added_tokens", [])
30+
if bool(token.get("special", False))
31+
),
32+
}
33+
encoded = json.dumps(
34+
payload,
35+
ensure_ascii=False,
36+
separators=(",", ":"),
37+
sort_keys=True,
38+
).encode("utf-8")
39+
return hashlib.sha256(encoded).hexdigest()
40+
41+
42+
def _tokenizer_json(tokenizer_or_path: Any) -> dict[str, Any]:
43+
path = getattr(tokenizer_or_path, "path", tokenizer_or_path)
44+
if isinstance(path, (str, os.PathLike)):
45+
return json.loads(Path(path).read_text(encoding="utf-8"))
46+
47+
tokenizer = getattr(tokenizer_or_path, "_tokenizer", tokenizer_or_path)
48+
if hasattr(tokenizer, "to_str"):
49+
return json.loads(tokenizer.to_str())
50+
51+
if hasattr(tokenizer, "get_vocab"):
52+
return {
53+
"model": {
54+
"type": None,
55+
"unk_token": None,
56+
"vocab": tokenizer.get_vocab(),
57+
"merges": [],
58+
},
59+
"added_tokens": [],
60+
}
61+
62+
raise TypeError("tokenizer_fingerprint requires a tokenizer path or object")

cppmega_v4/nn/moe_v4.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,12 @@ def __init__(self, config: V4MoEConfig):
124124
super().__init__()
125125
self.config = config
126126
self.gate = nn.Linear(config.d_model, config.num_experts, bias=config.bias)
127+
# Routers should start close to uniform so early expert assignment is
128+
# balanced before specialization. MLX Linear's default scale can make
129+
# tiny synthetic tests overconfident depending on prior RNG state.
130+
self.gate.weight = self.gate.weight * 0.1
131+
if config.bias:
132+
self.gate.bias = mx.zeros_like(self.gate.bias)
127133
self.experts = [
128134
FeedForwardExpert(
129135
config.d_model,

cppmega_v4/runner/stages.py

Lines changed: 73 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
_make_loss, _make_optim, _make_sharding, _graph_to_specs,
4141
)
4242
from cppmega_v4.jsonrpc.schema import VerifyParams
43+
from cppmega_mlx.tokenizer.fingerprint import tokenizer_fingerprint
4344

4445

4546
StageStatus = Literal["ok", "skipped", "fail", "cancelled"]
@@ -253,21 +254,34 @@ def stage_estimate_memory(ctx: StageContext) -> StageResult:
253254
master_fp32_bytes = int(params_bytes)
254255
# Inference probe + token embedding forward stash ~params/2.
255256
probe_bytes = int(params_bytes // 2)
257+
# MLX peak counters include any allocator-resident buffers already
258+
# active in this long-lived Python process. Account for that baseline
259+
# so full-suite parity is not hostage to previous Metal tests.
260+
allocator_active_bytes = _mlx_active_memory_bytes()
256261
estimated_peak_bytes = (params_bytes + activation_bytes
257262
+ adam_moments_bytes + grad_bytes
258-
+ master_fp32_bytes + probe_bytes)
263+
+ master_fp32_bytes + probe_bytes
264+
+ allocator_active_bytes)
259265
return _ok(
260266
"estimate_memory", t0,
261267
total_bytes=params_bytes,
262268
params_bytes=params_bytes,
263269
activation_bytes=activation_bytes,
264270
adam_moments_bytes=adam_moments_bytes,
271+
allocator_active_bytes=allocator_active_bytes,
265272
estimated_peak_bytes=estimated_peak_bytes,
266273
)
267274
except Exception as exc:
268275
return _fail("estimate_memory", t0, exc)
269276

270277

278+
def _mlx_active_memory_bytes() -> int:
279+
try:
280+
return int(mx.get_active_memory())
281+
except Exception:
282+
return 0
283+
284+
271285
def stage_check_gotchas(ctx: StageContext) -> StageResult:
272286
t0 = time.perf_counter()
273287
try:
@@ -771,6 +785,10 @@ def loss_fn(model: nn.Module, emb: mx.array, tgt: mx.array) -> mx.array:
771785
parquet_stream = None
772786
targets = mx.random.randint(0, vocab_size, shape=(batch, seq))
773787
if len(parquet_shards) > 1:
788+
_validate_parquet_stream_tokenizer_fingerprints(
789+
parquet_shards,
790+
expected_tokenizer=tokenizer_path,
791+
)
774792
if tokenizer_path:
775793
tokens, used, stream = _tokenize_parquet_text_from_shards(
776794
parquet_shards, tokenizer_path, n_tokens=batch * seq)
@@ -798,6 +816,10 @@ def loss_fn(model: nn.Module, emb: mx.array, tgt: mx.array) -> mx.array:
798816
except Exception:
799817
pass
800818
elif parquet_path:
819+
_validate_parquet_stream_tokenizer_fingerprints(
820+
[str(parquet_path)],
821+
expected_tokenizer=tokenizer_path,
822+
)
801823
# V4-2: prefer tokenize(text) path when both tokenizer and a
802824
# 'text' column are present; fall back to raw-int input_ids
803825
# column (V3-2) when not; fall through to synthetic otherwise.
@@ -1853,6 +1875,56 @@ def _parquet_row_count(path: str) -> int:
18531875
return 0
18541876

18551877

1878+
def _parquet_tokenizer_fingerprint(path: str) -> str | None:
1879+
import pyarrow.parquet as pq
1880+
try:
1881+
parquet_file = pq.ParquetFile(path)
1882+
except Exception:
1883+
return None
1884+
if "tokenizer_fingerprint" not in parquet_file.schema_arrow.names:
1885+
return None
1886+
seen: set[str] = set()
1887+
for batch in parquet_file.iter_batches(
1888+
columns=["tokenizer_fingerprint"],
1889+
batch_size=1024,
1890+
):
1891+
for value in batch.column(0).to_pylist():
1892+
if value is not None:
1893+
seen.add(str(value))
1894+
if len(seen) > 1:
1895+
raise ValueError(
1896+
f"tokenizer_fingerprint mismatch within shard {path}"
1897+
)
1898+
return next(iter(seen)) if seen else None
1899+
1900+
1901+
def _validate_parquet_stream_tokenizer_fingerprints(
1902+
parquet_paths: list[str],
1903+
*,
1904+
expected_tokenizer: Any | None = None,
1905+
) -> str | None:
1906+
fingerprints = {
1907+
fingerprint
1908+
for path in parquet_paths
1909+
if (fingerprint := _parquet_tokenizer_fingerprint(path)) is not None
1910+
}
1911+
if len(fingerprints) > 1:
1912+
joined = ", ".join(sorted(fingerprints))
1913+
raise ValueError(
1914+
"tokenizer_fingerprint mismatch across parquet shards: "
1915+
f"{joined}"
1916+
)
1917+
fingerprint = next(iter(fingerprints)) if fingerprints else None
1918+
if fingerprint is not None and expected_tokenizer is not None:
1919+
expected = tokenizer_fingerprint(expected_tokenizer)
1920+
if fingerprint != expected:
1921+
raise ValueError(
1922+
"tokenizer_fingerprint mismatch between parquet shards "
1923+
f"and active tokenizer: shard={fingerprint} active={expected}"
1924+
)
1925+
return fingerprint
1926+
1927+
18561928
# G10: in-process LRU cache of opt.state by run_id. Used for warm-start
18571929
# across sequential Train clicks in the same backend session.
18581930
_RUN_CACHE: dict[str, Any] = {}

docs/porting_plan.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,10 +166,16 @@ The current collected test files are:
166166

167167
- tests/test_archive_bench_baseline_script.py
168168
- tests/test_attention.py
169+
- tests/test_audit_v3_v4_v5.py
169170
- tests/test_bench_1b_training_matrix.py
170171
- tests/test_bench_baselines.py
172+
- tests/test_bench_dtype_cast_cost.py
173+
- tests/test_bench_dtype_long_horizon.py
174+
- tests/test_bench_inference_latency.py
171175
- tests/test_bench_matrix.py
176+
- tests/test_bench_production_presets.py
172177
- tests/test_bench_script.py
178+
- tests/test_bench_whole_model_compile.py
173179
- tests/test_check_environment_script.py
174180
- tests/test_checkpoint.py
175181
- tests/test_checkpoint_subprocess_resume.py
@@ -196,6 +202,7 @@ The current collected test files are:
196202
- tests/test_fusion_patterns_extended.py
197203
- tests/test_grad_buffer_no_aliasing.py
198204
- tests/test_grad_dtype_contract.py
205+
- tests/test_h17_single_doc_passthrough.py
199206
- tests/test_hybrid_lm.py
200207
- tests/test_hybrid_lm_extensions.py
201208
- tests/test_hybrid_lm_gradients.py
@@ -209,6 +216,8 @@ The current collected test files are:
209216
- tests/test_inference_speculative_decode.py
210217
- tests/test_kernel_policy.py
211218
- tests/test_lint_mlx.py
219+
- tests/test_long_context_forward.py
220+
- tests/test_long_horizon_convergence.py
212221
- tests/test_m03_forward_parity_manifest_script.py
213222
- tests/test_m04_train_step.py
214223
- tests/test_m05_mtp_parity_manifest_script.py
@@ -217,6 +226,7 @@ The current collected test files are:
217226
- tests/test_mamba3.py
218227
- tests/test_mamba3_dispatch.py
219228
- tests/test_megatron_indexed.py
229+
- tests/test_megatron_ingress_stress_script.py
220230
- tests/test_memory_audit.py
221231
- tests/test_memory_runtime.py
222232
- tests/test_metal_ops.py
@@ -292,6 +302,7 @@ The current collected test files are:
292302
- tests/test_triton_bridge_frontend_discovery.py
293303
- tests/test_triton_to_cute_dsl.py
294304
- tests/test_triton_to_tilelang_bridge.py
305+
- tests/test_whole_model_compile_memory.py
295306

296307
## Wave-Next Work
297308

0 commit comments

Comments
 (0)