Skip to content

Commit 2f83ab0

Browse files
committed
Path C: merged-grad runtime overlays bank-resident fused grads onto eager tree
Step 1 + step 2 of the Path C parameter-bank residency closure: in-region trainable parameters now live as zero-copy views into the model-owned physical ABI bank, and the mixed-mode training runtime overlays the fused artifact's bank-resident gradients onto the trainer's eager gradient tree so every in-region grad reaches the optimizer through one explicit bank-storage path instead of being produced twice (once by eager autograd, once thrown away by the warmup-only fused pass). Changes: - cppmega_mlx/runtime/path_c_physical_abi.py: add `logical_bank_view` and `write_into_bank_slot` helpers - zero-copy slice-into-bank read and explicit slice-assignment write, no hidden allocation, no bank rebuild. - cppmega_mlx/models/hybrid_lm.py: add `path_c_fused_in_region_parameter_bank_aliases`, `bind_path_c_in_region_parameter_views_into_bank`, and `sync_path_c_in_region_parameters_into_bank` on `HybridTinyLM`. The binder walks the generated PrimFunc's ABI map, picks the parameters that own bank slots AND have matching `*_grad` slots, copies their values into the bank slots in place, and replaces the model attributes with bank views (reshaped to the logical shape). The syncer reads the parameter tensor and writes it back into the bank slot so optimizer.update's replacement tensors propagate to the bank without any hidden allocation. - cppmega_mlx/training/compiled.py: `PathCFusedPlusEagerTrainingRuntime` gains `in_region_parameter_bank_aliases` and `model_bank_sync_callable` constructor args. When aliases are present, `value_and_grad` switches to merged mode: sync params into bank, call `artifact.value_and_grad` (bank-resident grads), call the eager closure (full-model grads), overwrite the in-region entries of the eager tree with the fused bank views, return one merged grad tree. Warmup-mode behaviour is preserved when no aliases are supplied. The contract surfaces `parameter_bank_residency_active`, `merged_bank_resident_parameter_count`, and `merged_bank_resident_parameter_names` so receipt scripts can see whether the merge is live. - scripts/m04_train_step.py: extend `_path_c_fused_train_block_training_runtime_from_artifact` to accept `model` + `sequence_length`. When the model exposes the bank-residency surface, the factory now binds the in-region parameter views into the bank and threads the alias map + sync callable into the runtime so merged mode activates automatically. Falls back to warmup mode when the model lacks the surface. - tests/test_hybrid_lm_path_c_physical_abi_bank_owner.py: cover the three new model methods - alias discovery, bank-view binding, and in-place sync. - tests/test_path_c_fused_plus_eager_runtime.py: add unit tests that pin merged-mode behaviour (bank-resident grads replace eager ones for in-region params, missing fused grads keep the eager entry, telemetry payloads, contract fields), update the existing live integration test so it accepts the merged-mode telemetry payload. Live verification on `local_gb10_quarter` tiny smoke (`scripts/m04_train_step.py` route): runtime: PathCFusedPlusEagerTrainingRuntime in_region_parameter_bank_aliases: 27 / 27 (status=ok) vg_payload.merged_parameter_count: 27 vg_payload.missing_parameter_names: () contract.parameter_bank_residency_active: True contract.merged_bank_resident_parameter_count: 27 Tests: 16 / 16 in tests/test_path_c_fused_plus_eager_runtime.py + 8 / 8 in tests/test_hybrid_lm_path_c_physical_abi_bank_owner.py + 6 / 6 in tests/test_m04_train_step.py -k fused_train_block + all 66 / 66 in tests/test_m04_train_step.py -k path_c. Hard constraints respected: no monkeypatch, no Python shim, no hidden allocation; explicit slice-assignment writes into a pre-allocated bank; zero-copy DLPack-style bank views for parameter attributes; merged grads are bank-storage references the artifact already populated. Next gap (step 3 of the handoff): short-circuit eager autograd for in-region layers via `mx.custom_function` so the eager forward / backward stops at the fused region entry, which is where the speed / memory win over Path B comes from. This commit closes the gradient-source gap; the compute-cost gap is a separate change.
1 parent 1fc1d36 commit 2f83ab0

6 files changed

Lines changed: 1138 additions & 46 deletions

File tree

cppmega_mlx/models/hybrid_lm.py

Lines changed: 326 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,9 @@
4646
from cppmega_mlx.runtime.path_c_physical_abi import (
4747
PathCLogicalBufferOwner,
4848
PathCPhysicalAbiBankOwner,
49+
logical_bank_view,
4950
make_physical_abi_bank_owner,
51+
write_into_bank_slot,
5052
)
5153
from cppmega_mlx.runtime.path_c_taps import emit_and_tap_path_c_tensor
5254
from cppmega_mlx.training.mtp import MinimalMTPHead, MTPLossConfig
@@ -1200,6 +1202,330 @@ def make_path_c_physical_abi_bank_owner(
12001202
bank_buffers,
12011203
)
12021204

1205+
def path_c_fused_in_region_parameter_bank_aliases(
1206+
self,
1207+
*,
1208+
sequence_length: int | None = None,
1209+
) -> dict[str, dict[str, Any]]:
1210+
"""Return a parameter-name → bank-binding map for in-region trainables.
1211+
1212+
For every MLX trainable parameter that maps onto a logical buffer in
1213+
the generated fused-train-block PrimFunc AND has a matching ``*_grad``
1214+
slot in the same physical ABI map, this method returns a dict whose
1215+
values describe the bank residency for the parameter::
1216+
1217+
{
1218+
"logical_name": <logical name actually placed in the bank>,
1219+
"logical_grad_name": <matching *_grad logical name>,
1220+
"bank": <bank buffer name>,
1221+
"dtype": <bank dtype>,
1222+
"offset": <int>,
1223+
"size": <int>,
1224+
"logical_shape": tuple[int, ...],
1225+
}
1226+
1227+
Only parameters that are *both* bank-resident and gradient-producing
1228+
are returned; parameters whose logical name lives in the bank but has
1229+
no ``*_grad`` slot (for example pure inputs) are skipped.
1230+
"""
1231+
1232+
prim_func = self.path_c_fused_train_block_prim_func(
1233+
sequence_length=sequence_length,
1234+
)
1235+
if prim_func is None:
1236+
return {}
1237+
abi_map = dict(
1238+
getattr(prim_func, "_cppmega_path_c_physical_buffer_abi_map", {})
1239+
or {}
1240+
)
1241+
if not abi_map:
1242+
return {}
1243+
grad_logical_names = frozenset(
1244+
name for name in abi_map if name.endswith("_grad")
1245+
)
1246+
aliases = self.path_c_parameter_logical_aliases()
1247+
out: dict[str, dict[str, Any]] = {}
1248+
for parameter_name, logical_candidates in aliases.items():
1249+
for logical_name in logical_candidates:
1250+
grad_name = f"{logical_name}_grad"
1251+
if logical_name not in abi_map:
1252+
continue
1253+
if grad_name not in grad_logical_names:
1254+
continue
1255+
info = abi_map[logical_name]
1256+
if not isinstance(info, Mapping):
1257+
continue
1258+
out[parameter_name] = {
1259+
"logical_name": str(logical_name),
1260+
"logical_grad_name": str(grad_name),
1261+
"bank": str(info.get("bank", "")),
1262+
"dtype": str(info.get("dtype", "")),
1263+
"offset": int(info.get("offset", 0) or 0),
1264+
"size": int(info.get("size", 1) or 1),
1265+
"logical_shape": tuple(
1266+
int(dim)
1267+
for dim in tuple(
1268+
info.get("logical_shape", info.get("shape", ()))
1269+
)
1270+
),
1271+
}
1272+
break
1273+
return out
1274+
1275+
def _path_c_lookup_parameter_holder(
1276+
self,
1277+
parameter_name: str,
1278+
) -> tuple[Any, str]:
1279+
"""Walk dotted attribute path; return (holder, leaf_attr_name)."""
1280+
1281+
parts = parameter_name.split(".")
1282+
if not parts:
1283+
raise ValueError("parameter_name must be non-empty")
1284+
holder: Any = self
1285+
for part in parts[:-1]:
1286+
if part.isdigit() and isinstance(holder, (list, tuple)):
1287+
holder = holder[int(part)]
1288+
continue
1289+
inner = getattr(holder, part, None)
1290+
if inner is None and isinstance(holder, Mapping):
1291+
inner = holder[part]
1292+
if inner is None:
1293+
raise AttributeError(
1294+
f"path_c_bank residency cannot resolve {parameter_name!r} "
1295+
f"at {part!r}"
1296+
)
1297+
holder = inner
1298+
return holder, parts[-1]
1299+
1300+
def _path_c_get_parameter_tensor(
1301+
self,
1302+
parameter_name: str,
1303+
) -> mx.array | None:
1304+
try:
1305+
holder, leaf = self._path_c_lookup_parameter_holder(parameter_name)
1306+
except AttributeError:
1307+
return None
1308+
value = getattr(holder, leaf, None)
1309+
if not isinstance(value, mx.array):
1310+
return None
1311+
return value
1312+
1313+
def _path_c_set_parameter_tensor(
1314+
self,
1315+
parameter_name: str,
1316+
value: mx.array,
1317+
) -> None:
1318+
holder, leaf = self._path_c_lookup_parameter_holder(parameter_name)
1319+
setattr(holder, leaf, value)
1320+
1321+
def sync_path_c_in_region_parameters_into_bank(
1322+
self,
1323+
bank_owner: Any,
1324+
*,
1325+
in_region_aliases: Mapping[str, Mapping[str, Any]] | None = None,
1326+
sequence_length: int | None = None,
1327+
) -> dict[str, Any]:
1328+
"""Copy current model param values into pre-allocated bank slots.
1329+
1330+
This is the explicit caller-visible bridge from the MLX parameter tree
1331+
to the model-owned physical-ABI bank slots. It is invoked once per
1332+
training step (typically immediately before launching the fused
1333+
kernel) so optimizer-replaced parameter tensors propagate into the
1334+
bank without any hidden allocation. The bank itself is mutated in
1335+
place via slice assignment; no new bank is created.
1336+
1337+
Returns a report describing how many parameters were synced and how
1338+
many were skipped (and why).
1339+
"""
1340+
1341+
if bank_owner is None:
1342+
return {
1343+
"status": "skipped",
1344+
"reason": "bank_owner is None",
1345+
"synced": [],
1346+
"skipped": [],
1347+
}
1348+
buffers = bank_owner if isinstance(bank_owner, Mapping) else None
1349+
if buffers is None:
1350+
buffers = getattr(bank_owner, "buffers", None)
1351+
if not isinstance(buffers, Mapping):
1352+
return {
1353+
"status": "blocked",
1354+
"reason": "bank_owner has no buffers mapping",
1355+
"synced": [],
1356+
"skipped": [],
1357+
}
1358+
aliases = (
1359+
in_region_aliases
1360+
if in_region_aliases is not None
1361+
else self.path_c_fused_in_region_parameter_bank_aliases(
1362+
sequence_length=sequence_length,
1363+
)
1364+
)
1365+
if not aliases:
1366+
return {
1367+
"status": "noop",
1368+
"reason": "no in-region parameter bank aliases",
1369+
"synced": [],
1370+
"skipped": [],
1371+
}
1372+
prim_func = self.path_c_fused_train_block_prim_func(
1373+
sequence_length=sequence_length,
1374+
)
1375+
abi_map = dict(
1376+
getattr(prim_func, "_cppmega_path_c_physical_buffer_abi_map", {})
1377+
or {}
1378+
)
1379+
synced: list[str] = []
1380+
skipped: list[tuple[str, str]] = []
1381+
for parameter_name, info in sorted(aliases.items()):
1382+
tensor = self._path_c_get_parameter_tensor(parameter_name)
1383+
if tensor is None:
1384+
skipped.append((parameter_name, "parameter_not_in_model_tree"))
1385+
continue
1386+
logical_name = str(info.get("logical_name", ""))
1387+
try:
1388+
write_into_bank_slot(
1389+
abi_map,
1390+
buffers,
1391+
logical_name,
1392+
tensor,
1393+
)
1394+
synced.append(parameter_name)
1395+
except Exception as exc:
1396+
skipped.append(
1397+
(parameter_name, f"{type(exc).__name__}: {exc}")
1398+
)
1399+
return {
1400+
"status": "ok" if synced and not skipped else (
1401+
"partial" if synced else "blocked"
1402+
),
1403+
"reason": (
1404+
"in-region parameters synced into bank slots"
1405+
if synced and not skipped
1406+
else (
1407+
"some in-region parameters could not be synced"
1408+
if synced
1409+
else "no in-region parameter was synced"
1410+
)
1411+
),
1412+
"synced": synced,
1413+
"skipped": [
1414+
{"parameter_name": name, "reason": reason}
1415+
for name, reason in skipped
1416+
],
1417+
}
1418+
1419+
def bind_path_c_in_region_parameter_views_into_bank(
1420+
self,
1421+
bank_owner: Any,
1422+
*,
1423+
sequence_length: int | None = None,
1424+
) -> dict[str, Any]:
1425+
"""Bind in-region trainable parameters as zero-copy views into the bank.
1426+
1427+
For every parameter discovered by
1428+
:py:meth:`path_c_fused_in_region_parameter_bank_aliases`, this method:
1429+
1430+
1. Writes the current parameter value into its bank slot (initial sync).
1431+
2. Replaces the parameter attribute on the model's submodule with a
1432+
bank-resident view (a slice of the bank reshaped to the logical
1433+
shape). The view is the same MLX array that the fused kernel
1434+
reads / writes, so subsequent ``model.trainable_parameters()``
1435+
reports the view and downstream eager autograd sees the same
1436+
storage backing.
1437+
1438+
After ``optimizer.update(model, grads)`` replaces the parameter
1439+
attribute with a fresh tensor, call
1440+
:py:meth:`sync_path_c_in_region_parameters_into_bank` before the next
1441+
forward pass so the new value lands in the bank again. The bank
1442+
itself is mutated in place via slice assignment; no copy of the
1443+
full bank ever happens.
1444+
1445+
Returns a report mirroring
1446+
:py:meth:`sync_path_c_in_region_parameters_into_bank` plus a
1447+
``bound`` list of parameters now backed by bank views.
1448+
"""
1449+
1450+
if bank_owner is None:
1451+
return {
1452+
"status": "skipped",
1453+
"reason": "bank_owner is None",
1454+
"bound": [],
1455+
"skipped": [],
1456+
"in_region_parameter_count": 0,
1457+
}
1458+
buffers = bank_owner if isinstance(bank_owner, Mapping) else None
1459+
if buffers is None:
1460+
buffers = getattr(bank_owner, "buffers", None)
1461+
if not isinstance(buffers, Mapping):
1462+
return {
1463+
"status": "blocked",
1464+
"reason": "bank_owner has no buffers mapping",
1465+
"bound": [],
1466+
"skipped": [],
1467+
"in_region_parameter_count": 0,
1468+
}
1469+
aliases = self.path_c_fused_in_region_parameter_bank_aliases(
1470+
sequence_length=sequence_length,
1471+
)
1472+
if not aliases:
1473+
return {
1474+
"status": "noop",
1475+
"reason": "no in-region parameter bank aliases",
1476+
"bound": [],
1477+
"skipped": [],
1478+
"in_region_parameter_count": 0,
1479+
}
1480+
prim_func = self.path_c_fused_train_block_prim_func(
1481+
sequence_length=sequence_length,
1482+
)
1483+
abi_map = dict(
1484+
getattr(prim_func, "_cppmega_path_c_physical_buffer_abi_map", {})
1485+
or {}
1486+
)
1487+
bound: list[str] = []
1488+
skipped: list[tuple[str, str]] = []
1489+
for parameter_name, info in sorted(aliases.items()):
1490+
tensor = self._path_c_get_parameter_tensor(parameter_name)
1491+
if tensor is None:
1492+
skipped.append((parameter_name, "parameter_not_in_model_tree"))
1493+
continue
1494+
logical_name = str(info.get("logical_name", ""))
1495+
try:
1496+
write_into_bank_slot(abi_map, buffers, logical_name, tensor)
1497+
view = logical_bank_view(abi_map, buffers, logical_name)
1498+
self._path_c_set_parameter_tensor(parameter_name, view)
1499+
bound.append(parameter_name)
1500+
except Exception as exc:
1501+
skipped.append(
1502+
(parameter_name, f"{type(exc).__name__}: {exc}")
1503+
)
1504+
self._path_c_in_region_parameter_bank_aliases = dict(aliases)
1505+
self._path_c_in_region_parameter_bound_names = tuple(sorted(bound))
1506+
return {
1507+
"status": "ok" if bound and not skipped else (
1508+
"partial" if bound else "blocked"
1509+
),
1510+
"reason": (
1511+
"in-region parameters bound as bank views"
1512+
if bound and not skipped
1513+
else (
1514+
"some in-region parameters could not be bank-bound"
1515+
if bound
1516+
else "no in-region parameter was bank-bound"
1517+
)
1518+
),
1519+
"bound": bound,
1520+
"skipped": [
1521+
{"parameter_name": name, "reason": reason}
1522+
for name, reason in skipped
1523+
],
1524+
"in_region_parameter_count": len(bound),
1525+
"in_region_parameter_names": tuple(sorted(bound)),
1526+
"in_region_parameter_bank_aliases": dict(aliases),
1527+
}
1528+
12031529
def __call__(
12041530
self,
12051531
input_ids: mx.array,

0 commit comments

Comments
 (0)