Skip to content

Commit b04fbbb

Browse files
committed
Path C: fail closed on unverified bank-grad overlay
Close the correctness holes found in the parameter-bank residency follow-up. Root cause 1: bank residency alone is not enough to consume fused gradients. The fused train-block needs runtime inputs (hidden_entry, target_ids, target_mask) written into the ABI banks before artifact.value_and_grad runs. Without the suffix-bypass custom-function surface, merged mode ran the kernel against stale/zero inputs and then overwrote correct eager in-region grads with bogus bank values. PathCFusedPlusEagerTrainingRuntime now fails closed: when suffix-bypass is absent, it keeps the old warmup+eager behaviour and never calls artifact.value_and_grad or overlays bank grad slots. Root cause 2: the current fused suffix ABI advertises pending loss codegen. The m04 installer now refuses to attach the suffix-bypass loss function when _cppmega_path_c_train_step_suffix_loss_input_abi.reason contains pending, or when the scalar output ABI does not report computed outputs. This keeps the route available while preventing unverified suffix numerics from entering the training critical path. Also tighten write_into_bank_slot: dtype must match the generated ABI dtype before slice assignment. MLX silently coerces float32 into int32 bank slots; this now raises instead of hiding a dtype/layout bug. Verification: - ruff on touched files - pyright on cppmega_mlx/runtime/path_c_physical_abi.py, cppmega_mlx/training/compiled.py, cppmega_mlx/training/path_c_fused_suffix.py; checked no new pyright errors in the touched m04 factory window - pytest tests/test_path_c_physical_abi.py tests/test_path_c_fused_plus_eager_runtime.py tests/test_path_c_fused_suffix_custom_function.py tests/test_hybrid_lm_path_c_physical_abi_bank_owner.py tests/test_compiled_train.py -q: 91 passed - pytest tests/test_m04_train_step.py -k "path_c or fused_train_block" -q: 66 passed - pytest tests/v4/test_fusion_stage_{a,b,c,d,e,f}.py tests/v4/test_fusion_roadmap_gaps.py -q: 401 passed - live local_gb10_quarter route: status=m04_path_c_training_route_available, suffix_bypass_available=False, bank_grad_overlay_active=False, merged_bank_resident_parameter_count=0, layers.10.block.D eager grad norm non-zero Remaining work: re-enable suffix-bypass only after the TileLang generated suffix loss/gradient code passes numerical parity against the eager path. Until then Path C stays honest warmup/eager instead of silently training the wrong model.
1 parent 222bd9f commit b04fbbb

6 files changed

Lines changed: 140 additions & 69 deletions

File tree

cppmega_mlx/runtime/path_c_physical_abi.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -633,6 +633,13 @@ def write_into_bank_slot(
633633
bank = bank_buffers[bank_name]
634634
offset = int(info.get("offset", 0) or 0)
635635
size = int(info.get("size", 1) or 1)
636+
expected_dtype = str(info.get("dtype", ""))
637+
actual_dtype = _dtype_of(value)
638+
if expected_dtype and actual_dtype != expected_dtype:
639+
raise ValueError(
640+
f"value for {logical_name!r} has dtype {actual_dtype!r}, "
641+
f"expected {expected_dtype!r}"
642+
)
636643
# Flatten the value into a 1-D array of length ``size``.
637644
expected_size = int(prod(tuple(int(dim) for dim in tuple(getattr(value, "shape", ())))))
638645
if expected_size != size:

cppmega_mlx/training/compiled.py

Lines changed: 47 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -710,7 +710,15 @@ def value_and_grad(
710710
self.last_fused_value_and_grad_payload = vg_payload
711711
raise
712712

713-
merged_mode = bool(self.in_region_parameter_bank_aliases)
713+
# Bank residency alone is not enough to consume fused gradients:
714+
# the fused train-block also needs per-batch runtime inputs
715+
# (hidden_entry, target_ids, target_mask) written into the ABI banks.
716+
# Without the suffix-bypass custom-function surface, the artifact's
717+
# value_and_grad would run against stale/zero runtime inputs and would
718+
# silently overwrite correct eager grads with bogus bank values. Fail
719+
# closed to the original warmup+eager behavior unless suffix-bypass
720+
# handled the step above.
721+
merged_mode = False
714722

715723
# In merged mode, push optimizer-replaced parameters into bank slots
716724
# before the fused kernel reads them. The first step's sync is a no-op
@@ -745,6 +753,7 @@ def value_and_grad(
745753
"missing_parameter_names": (),
746754
"bank_sync_status": bank_sync_status,
747755
"error": None,
756+
"suffix_bypass_active": False,
748757
}
749758

750759
fused_grads: Any | None = None
@@ -832,19 +841,40 @@ def value_and_grad_contract(self) -> Mapping[str, Any]:
832841
inner = dict(self.artifact.value_and_grad_contract())
833842
inner_coverage = inner.get("full_model_gradient_coverage")
834843
coverage = dict(inner_coverage) if isinstance(inner_coverage, Mapping) else {}
835-
merged_mode_active = bool(self.in_region_parameter_bank_aliases)
844+
bank_residency_ready = bool(self.in_region_parameter_bank_aliases)
845+
suffix_bypass_available = bool(
846+
self.fused_suffix_loss_fn is not None
847+
and self.in_region_parameter_bank_aliases
848+
)
836849
reason = (
837-
"mixed-mode training runtime returns full-model grads via merged "
838-
"bank-resident fused grads + eager residual grads: in-region "
839-
"parameters are bank views and the fused artifact populates "
840-
"their gradients into the same bank storage"
841-
if merged_mode_active
850+
"mixed-mode training runtime returns full-model grads via "
851+
"suffix-bypass: in-region parameters and hidden-entry cotangents "
852+
"come from the fused custom-function VJP, residual/prefix grads "
853+
"come from MLX eager autograd"
854+
if suffix_bypass_available
842855
else (
843-
"mixed-mode training runtime returns full-model grads: "
844-
"fused in-region warmup is observable, residual grads "
845-
"come from the trainer's eager value_and_grad closure"
856+
"mixed-mode training runtime returns full-model grads from "
857+
"the trainer's eager value_and_grad closure; bank-resident "
858+
"fused gradients are disabled because no verified runtime "
859+
"input writer / suffix-bypass bridge is active"
860+
if bank_residency_ready
861+
else (
862+
"mixed-mode training runtime returns full-model grads: "
863+
"fused in-region warmup is observable, residual grads "
864+
"come from the trainer's eager value_and_grad closure"
865+
)
846866
)
847867
)
868+
overlay_parameter_count = (
869+
len(self.in_region_parameter_bank_aliases)
870+
if suffix_bypass_available
871+
else 0
872+
)
873+
overlay_parameter_names = (
874+
tuple(sorted(self.in_region_parameter_bank_aliases))
875+
if suffix_bypass_available
876+
else ()
877+
)
848878
coverage.update(
849879
{
850880
"full_model_gradient_tree_ready": True,
@@ -858,17 +888,10 @@ def value_and_grad_contract(self) -> Mapping[str, Any]:
858888
"fused_in_region_parameter_count": len(
859889
self.in_region_parameter_names
860890
),
861-
"parameter_bank_residency_active": merged_mode_active,
862-
"merged_bank_resident_parameter_count": (
863-
len(self.in_region_parameter_bank_aliases)
864-
if merged_mode_active
865-
else 0
866-
),
867-
"merged_bank_resident_parameter_names": (
868-
tuple(sorted(self.in_region_parameter_bank_aliases))
869-
if merged_mode_active
870-
else ()
871-
),
891+
"parameter_bank_residency_active": bank_residency_ready,
892+
"bank_grad_overlay_active": suffix_bypass_available,
893+
"merged_bank_resident_parameter_count": overlay_parameter_count,
894+
"merged_bank_resident_parameter_names": overlay_parameter_names,
872895
}
873896
)
874897
merged = {
@@ -893,13 +916,9 @@ def value_and_grad_contract(self) -> Mapping[str, Any]:
893916
"parameter_bank_residency_active": bool(
894917
self.in_region_parameter_bank_aliases
895918
),
896-
"merged_bank_resident_parameter_count": len(
897-
self.in_region_parameter_bank_aliases
898-
),
899-
"suffix_bypass_available": bool(
900-
self.fused_suffix_loss_fn is not None
901-
and self.in_region_parameter_bank_aliases
902-
),
919+
"bank_grad_overlay_active": suffix_bypass_available,
920+
"merged_bank_resident_parameter_count": overlay_parameter_count,
921+
"suffix_bypass_available": suffix_bypass_available,
903922
"runtime_class": type(self).__name__,
904923
}
905924
return merged

scripts/m04_train_step.py

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3109,6 +3109,25 @@ def _build_path_c_fused_suffix_loss_fn_for_model(
31093109
required_inputs = ("target_ids", "target_mask", "loss", "ntokens")
31103110
if not all(name in abi_map for name in required_inputs):
31113111
return None
3112+
suffix_input_abi = getattr(
3113+
prim_func,
3114+
"_cppmega_path_c_train_step_suffix_loss_input_abi",
3115+
{},
3116+
)
3117+
suffix_input_reason = ""
3118+
if isinstance(suffix_input_abi, Mapping):
3119+
suffix_input_reason = str(suffix_input_abi.get("reason", ""))
3120+
if "pending" in suffix_input_reason.lower():
3121+
return None
3122+
output_abi = getattr(
3123+
prim_func,
3124+
"_cppmega_path_c_train_step_output_abi",
3125+
{},
3126+
)
3127+
if isinstance(output_abi, Mapping) and not bool(
3128+
output_abi.get("outputs_computed", False)
3129+
):
3130+
return None
31123131

31133132
first_in_region_layer_index_fn = getattr(
31143133
model, "path_c_fused_first_in_region_layer_index", None
@@ -3120,6 +3139,7 @@ def _build_path_c_fused_suffix_loss_fn_for_model(
31203139
)
31213140
if first_in_region_layer_index is None:
31223141
return None
3142+
first_in_region_layer_index_int = int(cast(int, first_in_region_layer_index))
31233143

31243144
parameter_order = tuple(sorted(in_region_parameter_bank_aliases.keys()))
31253145
try:
@@ -3140,7 +3160,7 @@ def _build_path_c_fused_suffix_loss_fn_for_model(
31403160
model.attach_path_c_fused_suffix_custom_function(
31413161
fused_suffix,
31423162
parameter_order=parameter_order,
3143-
first_in_region_layer_index=int(first_in_region_layer_index),
3163+
first_in_region_layer_index=first_in_region_layer_index_int,
31443164
)
31453165

31463166
def _loss_fn(loss_model: Any, batch: Mapping[str, Any]) -> tuple[mx.array, mx.array]:

tests/test_m04_train_step.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4809,6 +4809,10 @@ def fake_lowerer(func_or_mod: Any, *, target: str, **kwargs: Any) -> Any:
48094809
assert coverage["full_model_gradient_tree_ready"] is True
48104810
assert coverage["gradient_scope"] == "full_model_via_mixed_mode"
48114811
assert coverage["missing_parameter_count"] == 0
4812+
assert value_and_grad_contract["suffix_bypass_available"] is False
4813+
assert value_and_grad_contract["bank_grad_overlay_active"] is False
4814+
assert value_and_grad_contract["merged_bank_resident_parameter_count"] == 0
4815+
assert coverage["bank_grad_overlay_active"] is False
48124816
assert value_and_grad_contract["delegates_to_eager_loss_and_grad"] is False
48134817
assert value_and_grad_contract["hidden_packing_performed"] is False
48144818
inner = model.path_c_fused_train_block_artifact.value_and_grad_contract()

tests/test_path_c_fused_plus_eager_runtime.py

Lines changed: 37 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -565,7 +565,7 @@ def __init__(self) -> None:
565565
}
566566

567567

568-
def test_merged_mode_overlays_bank_resident_grads_onto_eager_tree() -> None:
568+
def test_bank_residency_without_suffix_bypass_fails_closed_to_eager_grads() -> None:
569569
art = _FakeFusedArtifactWithBankResidentGrads()
570570
# Two in-region params, each with a unique sentinel grad value the runtime
571571
# must surface to the trainer (replacing whatever the eager closure
@@ -631,39 +631,34 @@ def closure(model: Any, batch: Mapping[str, Any]) -> Any:
631631
batch={},
632632
loss_and_grad=closure,
633633
)
634-
# Sync callable was invoked exactly once.
635-
assert len(sync_calls) == 1
636-
# The fused artifact value_and_grad was invoked with bank_owner threaded in.
637-
assert len(art.value_and_grad_calls) == 1
638-
assert (
639-
art.value_and_grad_calls[-1]["kwargs"].get("bank_owner") is bank_owner
640-
)
641-
# In-region grads now come from the fused tree (sentinel values), not the
642-
# eager closure (which produced 99s).
634+
# Without suffix-bypass / runtime-input writing, the runtime must not sync
635+
# params into banks, must not call artifact.value_and_grad, and must not
636+
# overlay fused grad slots. That path would run against stale hidden/target
637+
# bank inputs and corrupt the eager gradients.
638+
assert len(sync_calls) == 0
639+
assert len(art.value_and_grad_calls) == 0
640+
assert len(art.forward_calls) == 1
643641
flat_grads = dict(tree_flatten(grads))
644-
assert flat_grads["layers.10.block.D"].tolist() == [1.0, 2.0, 3.0, 4.0]
645-
assert flat_grads["layers.11.block.D"].tolist() == [5.0, 6.0, 7.0, 8.0]
642+
assert flat_grads["layers.10.block.D"].tolist() == [99.0, 99.0, 99.0, 99.0]
643+
assert flat_grads["layers.11.block.D"].tolist() == [99.0, 99.0, 99.0, 99.0]
646644
# Out-of-region grads are unchanged (eager wins).
647645
assert flat_grads["layers.0.block.q_proj.weight"].shape == (16, 16)
648646
assert flat_grads["lm_head.weight"].shape == (256, 16)
649-
# Telemetry reflects merged-mode behaviour.
650-
assert runtime.last_fused_warmup_payload["attempted"] is False
647+
# Telemetry reflects fail-closed warmup+eager behaviour.
648+
assert runtime.last_fused_warmup_payload["attempted"] is True
651649
vg = runtime.last_fused_value_and_grad_payload
652-
assert vg["attempted"] is True
653-
assert vg["completed"] is True
654-
assert vg["elapsed_ns"] >= 0
655-
assert vg["merged_parameter_count"] == 2
656-
assert vg["merged_parameter_names"] == (
657-
"layers.10.block.D",
658-
"layers.11.block.D",
659-
)
650+
assert vg["attempted"] is False
651+
assert vg["completed"] is False
652+
assert vg["merged_parameter_count"] == 0
653+
assert vg["merged_parameter_names"] == ()
660654
assert vg["missing_parameter_names"] == ()
661-
assert vg["bank_sync_status"] == {"status": "ok", "synced": ["x"], "skipped": []}
655+
assert vg["bank_sync_status"] is None
656+
assert vg["suffix_bypass_active"] is False
662657

663658

664-
def test_merged_mode_records_missing_bank_grads_without_dropping_coverage() -> None:
665-
"""If the fused tree is missing a bank-bound parameter, the merger keeps
666-
the eager grad in place and records the missing parameter for telemetry."""
659+
def test_bank_residency_without_suffix_bypass_never_reads_partial_bank_grads() -> None:
660+
"""Partial fused grad trees are ignored until suffix-bypass supplies
661+
verified runtime inputs for the kernel."""
667662

668663
art = _FakeFusedArtifactWithBankResidentGrads()
669664
# Only one of the two in-region params has a bank-resident grad.
@@ -696,14 +691,15 @@ def closure(model: Any, batch: Mapping[str, Any]) -> Any:
696691
loss_and_grad=closure,
697692
)
698693
flat_grads = dict(tree_flatten(grads))
699-
# Bank-resident grad replaced the eager one.
700-
assert flat_grads["layers.10.block.D"].tolist() == [1.0, 2.0, 3.0, 4.0]
701-
# Missing-from-fused grad kept the eager value.
694+
# Even the available bank-resident grad is ignored in fail-closed mode;
695+
# eager remains the source of truth for all parameters.
696+
assert flat_grads["layers.10.block.D"].tolist() == [0.0, 0.0, 0.0, 0.0]
702697
assert flat_grads["layers.11.block.D"].tolist() == [42.0, 42.0, 42.0, 42.0]
703698
vg = runtime.last_fused_value_and_grad_payload
704-
assert vg["merged_parameter_count"] == 1
705-
assert vg["merged_parameter_names"] == ("layers.10.block.D",)
706-
assert vg["missing_parameter_names"] == ("layers.11.block.D",)
699+
assert vg["merged_parameter_count"] == 0
700+
assert vg["merged_parameter_names"] == ()
701+
assert vg["missing_parameter_names"] == ()
702+
assert vg["suffix_bypass_active"] is False
707703

708704

709705
def test_merged_mode_contract_exposes_bank_residency_signals() -> None:
@@ -719,16 +715,14 @@ def test_merged_mode_contract_exposes_bank_residency_signals() -> None:
719715
)
720716
contract = runtime.value_and_grad_contract()
721717
assert contract["parameter_bank_residency_active"] is True
722-
assert contract["merged_bank_resident_parameter_count"] == 2
718+
assert contract["bank_grad_overlay_active"] is False
719+
assert contract["merged_bank_resident_parameter_count"] == 0
723720
coverage = contract["full_model_gradient_coverage"]
724721
assert coverage["parameter_bank_residency_active"] is True
725-
assert coverage["merged_bank_resident_parameter_count"] == 2
726-
assert coverage["merged_bank_resident_parameter_names"] == (
727-
"layers.10.block.D",
728-
"layers.11.block.D",
729-
)
730-
# Reason includes the merged-mode phrasing.
731-
assert "bank-resident" in coverage["reason"]
722+
assert coverage["bank_grad_overlay_active"] is False
723+
assert coverage["merged_bank_resident_parameter_count"] == 0
724+
assert coverage["merged_bank_resident_parameter_names"] == ()
725+
assert "disabled" in coverage["reason"]
732726

733727

734728
def test_warmup_mode_contract_clears_bank_residency_signals() -> None:
@@ -741,9 +735,11 @@ def test_warmup_mode_contract_clears_bank_residency_signals() -> None:
741735
)
742736
contract = runtime.value_and_grad_contract()
743737
assert contract["parameter_bank_residency_active"] is False
738+
assert contract["bank_grad_overlay_active"] is False
744739
assert contract["merged_bank_resident_parameter_count"] == 0
745740
coverage = contract["full_model_gradient_coverage"]
746741
assert coverage["parameter_bank_residency_active"] is False
742+
assert coverage["bank_grad_overlay_active"] is False
747743
assert coverage["merged_bank_resident_parameter_count"] == 0
748744
assert coverage["merged_bank_resident_parameter_names"] == ()
749745

@@ -777,6 +773,7 @@ def _stub_loss(model: Any, batch: Mapping[str, Any]) -> tuple[mx.array, mx.array
777773
runtime_with_bypass.value_and_grad_contract()["suffix_bypass_available"]
778774
is True
779775
)
776+
assert runtime_with_bypass.value_and_grad_contract()["bank_grad_overlay_active"] is True
780777

781778

782779
def test_suffix_bypass_uses_fused_suffix_loss_fn_and_skips_eager_closure() -> None:

tests/test_path_c_physical_abi.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import annotations
22

33
import pytest
4+
import mlx.core as mx
45

56
from cppmega_mlx.runtime.path_c_physical_abi import (
67
PathCLogicalBufferOwner,
@@ -13,6 +14,7 @@
1314
plan_physical_abi_runtime_bridge,
1415
validate_physical_abi_map,
1516
validate_physical_abi_runtime_bindings,
17+
write_into_bank_slot,
1618
)
1719

1820

@@ -436,3 +438,25 @@ def test_runtime_binding_rejects_logical_tensor_substitutes_for_banked_abi() ->
436438
assert payload["missing_bank_buffers"] == ["path_c_float32_abi_bank"]
437439
assert payload["unexpected_buffers"] == ["hidden", "out"]
438440
assert any("missing caller-owned bank buffer" in error for error in payload["errors"])
441+
442+
443+
def test_write_into_bank_slot_rejects_dtype_mismatch() -> None:
444+
mapping = {
445+
"target_ids": {
446+
"bank": "path_c_int32_abi_bank",
447+
"dtype": "int32",
448+
"offset": 0,
449+
"shape": (4,),
450+
"logical_shape": (4,),
451+
"size": 4,
452+
},
453+
}
454+
buffers = {"path_c_int32_abi_bank": mx.zeros((4,), dtype=mx.int32)}
455+
456+
with pytest.raises(ValueError, match="dtype 'float32'.*expected 'int32'"):
457+
write_into_bank_slot(
458+
mapping,
459+
buffers,
460+
"target_ids",
461+
mx.array([1.5, 2.5, 3.5, 4.5], dtype=mx.float32),
462+
)

0 commit comments

Comments
 (0)