Skip to content

Commit 398270e

Browse files
committed
fix(training): preserve packed row masks in stepper
1 parent 146b553 commit 398270e

3 files changed

Lines changed: 143 additions & 3 deletions

File tree

cppmega_mlx/training/compiled.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,10 @@
6161

6262
STABLE_BATCH_KEYS = (
6363
"tokens",
64+
"target_tokens",
6465
"attention_mask",
66+
"loss_mask",
67+
"document_ids",
6568
"structure_ids",
6669
"dep_levels",
6770
"ast_depth_ids",
@@ -538,8 +541,8 @@ def normalize_compiled_batch(
538541
mx.compile keys off the Python input structure as well as array shapes
539542
and dtypes. Keep every optional side channel present in the dict and use
540543
None for absent fields so callers do not alternate between different
541-
dict key sets when switching between plain token batches and structured
542-
batches.
544+
dict key sets when switching among plain token batches, packed-row
545+
training batches, and structured batches.
543546
"""
544547

545548
batch_dict = ensure_lm_batch(batch).as_dict()

tests/test_compiled_train.py

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
from mlx.utils import tree_flatten
1111
import pytest
1212

13-
from cppmega_mlx.data.batch import synthetic_token_batch
13+
from cppmega_mlx.data.batch import LMTokenBatch, synthetic_token_batch
1414
from cppmega_mlx.models.hybrid_lm import HybridTinyConfig, HybridTinyLM
1515
from cppmega_mlx.models.tiny_lm import TinyLM, TinyLMConfig
1616
from cppmega_mlx.training.compiled import (
@@ -355,12 +355,59 @@ def test_normalize_compiled_batch_uses_fixed_keys_for_optional_fields() -> None:
355355
plain_tokens = plain_dict["tokens"]
356356
assert plain_tokens is not None
357357
assert plain_tokens.shape == (2, 8)
358+
assert plain_dict["target_tokens"] is None
358359
assert plain_dict["attention_mask"] is None
360+
assert plain_dict["loss_mask"] is None
361+
assert plain_dict["document_ids"] is None
359362
assert plain_dict["structure_ids"] is None
363+
assert structured_dict["target_tokens"] is None
360364
assert structured_dict["attention_mask"] is not None
365+
assert structured_dict["loss_mask"] is None
366+
assert structured_dict["document_ids"] is None
361367
assert structured_dict["structure_ids"] is not None
362368

363369

370+
def test_normalize_compiled_batch_preserves_packed_row_training_fields() -> None:
371+
tokens = mx.array([[1, 2, 3, 0]], dtype=mx.int32)
372+
target_tokens = mx.array([[2, 3, 0, 0]], dtype=mx.int32)
373+
loss_mask = mx.array([[1, 0, 0, 0]], dtype=mx.float32)
374+
document_ids = mx.array([[0, 0, 1, 1]], dtype=mx.int32)
375+
batch = LMTokenBatch(
376+
tokens=tokens,
377+
target_tokens=target_tokens,
378+
loss_mask=loss_mask,
379+
document_ids=document_ids,
380+
)
381+
382+
normalized = normalize_compiled_batch(batch.as_dict())
383+
384+
assert tuple(normalized) == STABLE_BATCH_KEYS
385+
assert normalized["tokens"] is tokens
386+
assert normalized["target_tokens"] is target_tokens
387+
assert normalized["loss_mask"] is loss_mask
388+
assert normalized["document_ids"] is document_ids
389+
390+
391+
@pytest.mark.parametrize("compile_step", [False, True])
392+
def test_pretraining_step_honors_packed_row_loss_mask(compile_step: bool) -> None:
393+
mx.random.seed(911)
394+
model = TinyLM(_tiny_config())
395+
optimizer = optim.AdamW(learning_rate=1e-3, weight_decay=0.0)
396+
batch = LMTokenBatch(
397+
tokens=mx.array([[1, 2, 3, 4]], dtype=mx.int32),
398+
target_tokens=mx.array([[2, 3, 4, 0]], dtype=mx.int32),
399+
loss_mask=mx.array([[1, 0, 0, 0]], dtype=mx.float32),
400+
)
401+
402+
metrics = CompiledPretrainingStep(model, optimizer, compile=compile_step)(batch)
403+
404+
assert metrics.compiled is compile_step
405+
assert metrics.ntokens == 1
406+
assert metrics.trained_tokens == 1
407+
assert math.isfinite(metrics.loss)
408+
assert metrics.loss > 0
409+
410+
364411
def test_normalize_compiled_batch_does_not_mutate_mapping_inputs() -> None:
365412
batch = synthetic_token_batch(
366413
batch_size=2,

tests/test_train_hybrid_tiny_script.py

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,27 @@ def _copy_real_parquet_head(
110110
pq.write_table(pa.Table.from_batches([batch]), sample_path)
111111

112112

113+
def _write_packed_row_parquet(path: Path) -> None:
114+
pa = pytest.importorskip("pyarrow")
115+
pq = pytest.importorskip("pyarrow.parquet")
116+
117+
table = pa.table(
118+
{
119+
"input_ids": pa.array([[1, 2, 3, 4]], type=pa.large_list(pa.int32())),
120+
"target_ids": pa.array([[2, 3, 4, 0]], type=pa.large_list(pa.int32())),
121+
"loss_mask": pa.array(
122+
[[1.0, 0.0, 0.0, 0.0]],
123+
type=pa.large_list(pa.float32()),
124+
),
125+
"doc_ids": pa.array([[0, 0, 1, 1]], type=pa.large_list(pa.int32())),
126+
"pack_id": pa.array([7], type=pa.int32()),
127+
"valid_token_count": pa.array([4], type=pa.int32()),
128+
"num_docs": pa.array([2], type=pa.int32()),
129+
}
130+
)
131+
pq.write_table(table, path)
132+
133+
113134
def _tiny_route_args(symbol: str, *, steps: int = 1) -> list[str]:
114135
args = [
115136
"--json",
@@ -868,6 +889,75 @@ def test_real_gb10_parquet_cli_smoke_trains_token_only_after_retokenize(
868889
assert payload["step_metrics"][0]["ntokens"] == 63
869890

870891

892+
def test_packed_row_parquet_cli_smoke_honors_explicit_targets_and_loss_mask(
893+
tmp_path: Path,
894+
) -> None:
895+
parquet_path = tmp_path / "packed_rows.parquet"
896+
_write_packed_row_parquet(parquet_path)
897+
898+
result = run_script(
899+
str(parquet_path),
900+
"--json",
901+
"--data-format",
902+
"parquet",
903+
"--token-key",
904+
"input_ids",
905+
"--batch-size",
906+
"1",
907+
"--seq-len",
908+
"4",
909+
"--steps",
910+
"1",
911+
"--hidden-size",
912+
"8",
913+
"--num-attention-heads",
914+
"1",
915+
"--pattern",
916+
"A",
917+
"--depth",
918+
"1",
919+
"--vocab-size",
920+
"16",
921+
"--no-compile",
922+
)
923+
924+
payload = _load_json_result(result)
925+
926+
assert payload["status"] == "ok"
927+
assert payload["synthetic_npz"] is False
928+
assert payload["dataset"]["path"] == str(parquet_path)
929+
assert payload["dataset"]["metadata"]["source_format"] == "parquet"
930+
assert payload["dataset"]["token_key"] == "input_ids"
931+
assert payload["dataset"]["num_samples"] == 1
932+
assert payload["dataset"]["num_batches"] == 1
933+
assert payload["tokens_per_step"] == 1
934+
assert payload["trained_tokens"] == 1
935+
assert payload["step_metrics"][0]["ntokens"] == 1
936+
assert payload["step_metrics"][0]["trained_tokens"] == 1
937+
assert payload["final_loss"] > 0
938+
939+
receipt = payload["dataset"]["dataset_receipt"]["parquet_receipt"]
940+
assert receipt["token_source"] == {
941+
"mode": "token_column",
942+
"column": "input_ids",
943+
"type": "large_list<element: int32>",
944+
}
945+
assert receipt["training_column_sources"] == {
946+
"target_tokens": {
947+
"column": "target_ids",
948+
"type": "large_list<element: int32>",
949+
},
950+
"loss_mask": {
951+
"column": "loss_mask",
952+
"type": "large_list<element: float>",
953+
},
954+
"document_ids": {
955+
"column": "doc_ids",
956+
"type": "large_list<element: int32>",
957+
},
958+
}
959+
960+
871961
def test_dry_run_json_accepts_explicit_no_compile() -> None:
872962
result = run_script(
873963
"--dry-run-json",

0 commit comments

Comments
 (0)