Skip to content

Commit 222bd9f

Browse files
committed
Path C: suffix-bypass via mx.custom_function short-circuits eager autograd
Step 3 of the parameter-bank residency closure: when both the model's bank-residency surface (steps 1-2) is bound and a fused-suffix custom function is attached, the mixed-mode training runtime replaces the trainer's eager loss_and_grad closure with a single ``nn.value_and_grad(model, fused_suffix_loss_fn)`` pass. The prefix (embedding + side-channel embeddings + layers before the fused region) runs eagerly; the fused TileLang artifact + final norm + lm_head + loss reduction run inside an ``mx.custom_function`` whose VJP returns bank-resident cotangents. MLX autograd therefore never traces the in-region layers, so the eager forward / backward compute drops to the prefix only - which is where Path C wins compute / memory over Path B at scale. Changes: - cppmega_mlx/training/path_c_fused_suffix.py (new): build the ``mx.custom_function`` bridge. Forward writes (hidden_entry, target_ids, target_mask, *in_region_params) into bank slots via explicit slice-assignment, launches ``artifact.forward(bank_owner)`` (the fused PrimFunc was compiled with ``include_backward=True`` so the grad slots are populated as a side effect), and returns ``(bank_view(loss), bank_view(ntokens))``. VJP returns bank-view cotangents in the same primal order, with zero cotangents for integer-typed inputs that have no gradient flow. All bank writes / reads use the helpers from ``cppmega_mlx.runtime.path_c_physical_abi``; no large staging tensors, no hidden allocation. - cppmega_mlx/models/hybrid_lm.py: * ``decoder_hidden_states`` gains an optional ``stop_layer_index`` + ``apply_final_norm`` so the same code path runs full forward, prefix-only, or prefix-without-final-norm; the default behaviour is unchanged. * ``path_c_fused_first_in_region_layer_index`` discovers the smallest ``layers.<index>`` index that owns a bank-bound trainable parameter. * ``attach_path_c_fused_suffix_custom_function`` / ``detach_path_c_fused_suffix_custom_function`` / ``path_c_fused_suffix_custom_function_available`` / ``path_c_fused_suffix_loss`` install and dispatch the bridge. The loss helper runs the prefix, gathers in-region parameters from the model tree in canonical order, and calls the attached fused suffix function; ``loss`` and ``ntokens`` are returned in the standard ``next_token_cross_entropy`` shape. - cppmega_mlx/training/compiled.py: ``PathCFusedPlusEagerTrainingRuntime.__init__`` accepts an optional ``fused_suffix_loss_fn`` callable. ``value_and_grad`` checks for suffix-bypass mode (aliases + callable both present); if active, it syncs the bank, builds ``nn.value_and_grad(model, fused_loss_fn)``, and runs it once. The trainer's eager loss_and_grad closure is intentionally dropped in this mode because every gradient (prefix plus in-region) comes from one autograd pass that consumes the fused suffix as a custom function. New telemetry field ``suffix_bypass_active`` lands in ``last_fused_value_and_grad_payload``; the value-and-grad contract exposes ``suffix_bypass_available`` so receipts can see whether the structural bypass is wired. - scripts/m04_train_step.py: ``_build_path_c_fused_suffix_loss_fn_for_model`` composes the ``mx.custom_function``, attaches it to the model via the ``attach_path_c_fused_suffix_custom_function`` surface, and returns a ``(model, batch) -> (loss, ntokens)`` callable bound to the model's ``path_c_fused_suffix_loss`` method. ``_path_c_fused_train_block_training_runtime_from_artifact`` calls the builder when bank residency activates and threads the resulting callable into the runtime constructor. Falls back to merged-mode (without bypass) when the model lacks the suffix-bridge surface or the ABI map does not declare the required fused-suffix inputs / outputs. - tests/test_path_c_fused_plus_eager_runtime.py: * pin contract field ``suffix_bypass_available`` against attach state; * unit-test that suffix-bypass actually skips the trainer's eager closure and returns the fused closure's outputs; * update the live integration test to use ``seq_len=127`` matching ``path_c_training_sequence_length(args)`` and supply ``target_tokens`` so the prefix output shape lines up with the fused suffix bank slot. - tests/test_path_c_fused_suffix_custom_function.py (new): cover the custom-function bridge in isolation - forward writes inputs into bank slots and returns loss / ntokens views, VJP delivers bank-view cotangents for hidden_entry and every in-region parameter, primal-count mismatches raise. Live verification on ``local_gb10_quarter`` tiny smoke (``scripts/m04_train_step.py`` route, seq_len=127): status: m04_path_c_training_route_available runtime: PathCFusedPlusEagerTrainingRuntime suffix_bypass_available: True bypass_run.completed: True bypass_run.merged_parameter_count: 27 bypass_run.missing_parameter_names: () loss = 5.33134, ntokens = 127 grad tree size: 163 params (covers embedding, prefix layers, in-region fused params, final norm, and lm_head - exactly the full model tree) Tests: 20 / 20 in tests/test_path_c_fused_plus_eager_runtime.py + 3 / 3 in tests/test_path_c_fused_suffix_custom_function.py + 8 / 8 in tests/test_hybrid_lm_path_c_physical_abi_bank_owner.py + all 66 / 66 in tests/test_m04_train_step.py -k path_c. Hard constraints respected: no monkeypatch, no Python shim, no hidden allocation. The custom function writes primals into pre-allocated bank slots via explicit slice-assignment; the fused kernel runs once per training step; cotangents are bank-storage references the kernel already populated. Eager autograd skips the entire in-region suffix because the suffix is a single custom function call rather than a chain of nn.Module __call__s. Compute / memory win materializes at 1B scale where the eager backward through the in-region layers dominates per-step cost. On the tiny smoke profile the fused TileLang kernel's own per-call dispatch overhead (~500-700 ms) exceeds the eager-only baseline (~30 ms), as expected for a kernel sized for production batches. The path_c_warm vs path_b matrix run (steps 4-5 of the handoff) remains the verification gate for the actual win; this commit closes the structural prerequisite.
1 parent 2f83ab0 commit 222bd9f

6 files changed

Lines changed: 957 additions & 25 deletions

File tree

cppmega_mlx/models/hybrid_lm.py

Lines changed: 200 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1526,6 +1526,195 @@ def bind_path_c_in_region_parameter_views_into_bank(
15261526
"in_region_parameter_bank_aliases": dict(aliases),
15271527
}
15281528

1529+
def path_c_fused_first_in_region_layer_index(
1530+
self,
1531+
*,
1532+
sequence_length: int | None = None,
1533+
) -> int | None:
1534+
"""Return the layer index where the fused Path C region starts.
1535+
1536+
Returned value is the smallest ``layers.<index>`` index that owns a
1537+
bank-bound trainable parameter. ``None`` indicates the model has no
1538+
fused-region parameters; callers fall back to the standard
1539+
eager forward in that case.
1540+
"""
1541+
1542+
aliases = self.path_c_fused_in_region_parameter_bank_aliases(
1543+
sequence_length=sequence_length,
1544+
)
1545+
if not aliases:
1546+
return None
1547+
indices = [
1548+
int(name.split(".", 2)[1])
1549+
for name in aliases
1550+
if name.startswith("layers.") and name.split(".", 2)[1].isdigit()
1551+
]
1552+
if not indices:
1553+
return None
1554+
return min(indices)
1555+
1556+
def attach_path_c_fused_suffix_custom_function(
1557+
self,
1558+
fused_suffix: Any,
1559+
*,
1560+
parameter_order: Sequence[str],
1561+
first_in_region_layer_index: int,
1562+
) -> None:
1563+
"""Attach a fused-suffix custom function for the loss helper to use.
1564+
1565+
The runtime composes the function (binding artifact + bank owner)
1566+
once per install and registers it here so the loss helper can dispatch
1567+
without re-discovering the wiring on every step.
1568+
"""
1569+
1570+
if not callable(fused_suffix):
1571+
raise TypeError("fused_suffix must be callable")
1572+
if first_in_region_layer_index < 0:
1573+
raise ValueError("first_in_region_layer_index must be non-negative")
1574+
self._path_c_fused_suffix_custom_function = fused_suffix
1575+
self._path_c_fused_suffix_parameter_order = tuple(parameter_order)
1576+
self._path_c_fused_suffix_first_in_region_layer_index = int(
1577+
first_in_region_layer_index
1578+
)
1579+
1580+
def detach_path_c_fused_suffix_custom_function(self) -> None:
1581+
for attr in (
1582+
"_path_c_fused_suffix_custom_function",
1583+
"_path_c_fused_suffix_parameter_order",
1584+
"_path_c_fused_suffix_first_in_region_layer_index",
1585+
):
1586+
if hasattr(self, attr):
1587+
delattr(self, attr)
1588+
1589+
def path_c_fused_suffix_custom_function_available(self) -> bool:
1590+
return callable(
1591+
getattr(self, "_path_c_fused_suffix_custom_function", None)
1592+
)
1593+
1594+
def path_c_fused_suffix_loss(
1595+
self,
1596+
batch: Mapping[str, mx.array],
1597+
) -> tuple[mx.array, mx.array]:
1598+
"""Compute (loss, ntokens) via the fused-suffix custom function.
1599+
1600+
The prefix (embedding + side-channel embeddings + layers before
1601+
the fused region) runs eagerly in MLX; layers inside the fused
1602+
region, the final norm, the ``lm_head`` projection and the loss
1603+
reduction are handed off to the fused TileLang artifact via the
1604+
attached :py:meth:`attach_path_c_fused_suffix_custom_function`
1605+
callable. MLX autograd therefore never traces through the
1606+
in-region layers: the fused custom function's VJP returns
1607+
bank-resident cotangents for every in-region trainable
1608+
parameter, and the prefix continues backward from
1609+
``hidden_entry_grad`` to the embedding and prefix layers.
1610+
1611+
Returns ``(loss, ntokens)`` as MLX scalars; the trainer uses
1612+
these the same way it would the output of
1613+
:func:`cppmega_mlx.training.loss.next_token_cross_entropy`.
1614+
"""
1615+
1616+
fused_suffix = getattr(
1617+
self, "_path_c_fused_suffix_custom_function", None
1618+
)
1619+
parameter_order: tuple[str, ...] = tuple(
1620+
cast("Sequence[str]",
1621+
getattr(self, "_path_c_fused_suffix_parameter_order", ()))
1622+
)
1623+
first_in_region_layer_index = getattr(
1624+
self,
1625+
"_path_c_fused_suffix_first_in_region_layer_index",
1626+
None,
1627+
)
1628+
if (
1629+
not callable(fused_suffix)
1630+
or not parameter_order
1631+
or first_in_region_layer_index is None
1632+
):
1633+
raise ValueError(
1634+
"path_c_fused_suffix_loss requires "
1635+
"attach_path_c_fused_suffix_custom_function to be installed"
1636+
)
1637+
1638+
from cppmega_mlx.data.batch import ensure_lm_batch
1639+
1640+
lm_batch = ensure_lm_batch(batch)
1641+
input_ids = lm_batch.inputs
1642+
targets = lm_batch.targets
1643+
target_mask = lm_batch.target_mask
1644+
# decoder_hidden_states accepts the side-channel tensors and the
1645+
# document_ids stream; the prefix needs both because the masking
1646+
# and structure / platform embeddings are inputs to the prefix
1647+
# layers. The fused suffix bridge does not need a kv_cache and
1648+
# does not consume the side-channel kwargs directly.
1649+
side_channel_kwargs = dict(lm_batch.model_kwargs())
1650+
document_ids = lm_batch.input_document_ids
1651+
1652+
# Targets are (B, S); the fused kernel consumes a flat 1-D
1653+
# target_ids buffer of length S. The custom function writes them
1654+
# into the bank slot; we forward the per-step batch tensor.
1655+
if targets.ndim != 2:
1656+
raise ValueError(
1657+
f"path_c_fused_suffix_loss expects targets shaped (B, S); "
1658+
f"got {targets.shape}"
1659+
)
1660+
if targets.shape[0] != 1:
1661+
raise NotImplementedError(
1662+
"path_c_fused_suffix_loss currently expects batch_size=1 "
1663+
"(matching the tiny smoke profile); broader shapes need "
1664+
"the fused kernel ABI to expand its target_ids buffer."
1665+
)
1666+
if target_mask.shape != targets.shape:
1667+
raise ValueError(
1668+
"target_mask shape must match targets; got "
1669+
f"{target_mask.shape} vs {targets.shape}"
1670+
)
1671+
flat_target_ids = mx.reshape(
1672+
targets.astype(mx.int32), (targets.shape[1],)
1673+
)
1674+
flat_target_mask = mx.reshape(
1675+
target_mask.astype(mx.float32), (target_mask.shape[1],)
1676+
)
1677+
1678+
hidden_entry = self.decoder_hidden_states(
1679+
input_ids,
1680+
structure_ids=side_channel_kwargs.get("structure_ids"),
1681+
dep_levels=side_channel_kwargs.get("dep_levels"),
1682+
ast_depth_ids=side_channel_kwargs.get("ast_depth_ids"),
1683+
sibling_index_ids=side_channel_kwargs.get("sibling_index_ids"),
1684+
node_type_ids=side_channel_kwargs.get("node_type_ids"),
1685+
platform_ids=side_channel_kwargs.get("platform_ids"),
1686+
document_ids=document_ids,
1687+
kv_cache=None,
1688+
stop_layer_index=int(first_in_region_layer_index),
1689+
apply_final_norm=False,
1690+
)
1691+
1692+
params = tuple(
1693+
self._path_c_get_parameter_tensor(name)
1694+
for name in parameter_order
1695+
)
1696+
if any(p is None for p in params):
1697+
missing = [
1698+
name
1699+
for name, p in zip(parameter_order, params, strict=True)
1700+
if p is None
1701+
]
1702+
raise ValueError(
1703+
"path_c_fused_suffix_loss could not resolve in-region "
1704+
f"parameters: {missing}"
1705+
)
1706+
result: Any = fused_suffix(
1707+
hidden_entry,
1708+
flat_target_ids,
1709+
flat_target_mask,
1710+
*params,
1711+
)
1712+
loss, ntokens = cast(tuple[mx.array, mx.array], result)
1713+
# ntokens lives in the bank as float32; convert back to the standard
1714+
# uint32 reporting form used by other loss helpers.
1715+
ntokens = ntokens.astype(mx.uint32)
1716+
return loss, ntokens
1717+
15291718
def __call__(
15301719
self,
15311720
input_ids: mx.array,
@@ -1565,6 +1754,8 @@ def decoder_hidden_states(
15651754
platform_ids: mx.array | None = None,
15661755
document_ids: mx.array | None = None,
15671756
kv_cache: ContiguousKVCache | None = None,
1757+
stop_layer_index: int | None = None,
1758+
apply_final_norm: bool = True,
15681759
) -> mx.array:
15691760
if input_ids.ndim != 2:
15701761
raise ValueError(f"input_ids must be shaped (B, S), got {input_ids.shape}")
@@ -1682,7 +1873,9 @@ def decoder_hidden_states(
16821873
expand_heads=True,
16831874
)
16841875
if self.config.grad_checkpoint:
1685-
for layer in self.layers:
1876+
for layer_index, layer in enumerate(self.layers):
1877+
if stop_layer_index is not None and layer_index >= stop_layer_index:
1878+
break
16861879
if layer.backend == "engram" and document_ids is not None:
16871880
hidden_states = mx.checkpoint(layer)(
16881881
hidden_states, mask, doc_ids=document_ids
@@ -1691,7 +1884,9 @@ def decoder_hidden_states(
16911884
hidden_states = mx.checkpoint(layer)(hidden_states, mask)
16921885
else:
16931886
attention_layer_idx = 0
1694-
for layer in self.layers:
1887+
for layer_index, layer in enumerate(self.layers):
1888+
if stop_layer_index is not None and layer_index >= stop_layer_index:
1889+
break
16951890
if layer.backend == "attention":
16961891
hidden_states = layer(
16971892
hidden_states,
@@ -1706,7 +1901,9 @@ def decoder_hidden_states(
17061901
)
17071902
else:
17081903
hidden_states = layer(hidden_states, mask)
1709-
return self.norm(hidden_states)
1904+
if apply_final_norm:
1905+
return self.norm(hidden_states)
1906+
return hidden_states
17101907

17111908

17121909
def _validate_side_channel_shape(

0 commit comments

Comments
 (0)