Skip to content

Commit 102c84f

Browse files
committed
feat(training): wire side-channel residual scales
1 parent cdece2f commit 102c84f

4 files changed

Lines changed: 326 additions & 38 deletions

File tree

cppmega_mlx/models/hybrid_lm.py

Lines changed: 82 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@
77

88
from __future__ import annotations
99

10-
from dataclasses import asdict, dataclass
10+
import math
11+
from dataclasses import asdict, dataclass, field
1112
from typing import (
1213
TYPE_CHECKING,
1314
Any,
@@ -58,6 +59,8 @@
5859
| EngramBranch
5960
| ConceptBlock
6061
)
62+
63+
HYBRID_SIDE_CHANNEL_RESIDUAL_SCALE_FAMILIES = ("platform", "structure", "syntax")
6164
PathCActivationProbe = Callable[[Mapping[str, Any]], None]
6265

6366
_ROUTE_SYMBOL_BACKENDS: dict[str, HybridBackend] = {
@@ -137,6 +140,39 @@ def _path_c_capture_event_metadata(event: Mapping[str, Any]) -> Mapping[str, Any
137140
return metadata
138141

139142

143+
def _normalize_side_channel_residual_scale(
144+
policy: Mapping[str, float],
145+
) -> dict[str, float]:
146+
normalized: dict[str, float] = {}
147+
allowed = set(HYBRID_SIDE_CHANNEL_RESIDUAL_SCALE_FAMILIES)
148+
for raw_family, raw_scale in policy.items():
149+
family = str(raw_family).strip()
150+
if not family:
151+
raise ValueError("side_channel_residual_scale family names must be non-empty")
152+
if family not in allowed:
153+
allowed_list = ", ".join(HYBRID_SIDE_CHANNEL_RESIDUAL_SCALE_FAMILIES)
154+
raise ValueError(
155+
"side_channel_residual_scale only supports model-consumed "
156+
f"families: {allowed_list}; got {family!r}"
157+
)
158+
scale = float(raw_scale)
159+
if not math.isfinite(scale) or scale < 0.0:
160+
raise ValueError(
161+
f"side_channel_residual_scale for {family!r} must be finite and >= 0"
162+
)
163+
normalized[family] = scale
164+
if (
165+
"structure" in normalized
166+
and "syntax" in normalized
167+
and not math.isclose(normalized["structure"], normalized["syntax"])
168+
):
169+
raise ValueError(
170+
"structure and syntax currently share a single structure residual; "
171+
"set one side_channel_residual_scale value or matching values"
172+
)
173+
return dict(sorted(normalized.items()))
174+
175+
140176
@dataclass(frozen=True)
141177
class HybridTinyConfig:
142178
"""Tiny local hybrid-LM config.
@@ -196,6 +232,7 @@ class HybridTinyConfig:
196232
ngram_hash_embed_dim: int = 16
197233
ngram_hash_dropout: float = 0.0
198234
ngram_hash_seed: int | None = None
235+
side_channel_residual_scale: dict[str, float] = field(default_factory=dict)
199236
mhc_enabled: bool = False
200237
grad_checkpoint: bool = False
201238
# Attention mode default applied to A-layers that did not get DSA routing.
@@ -257,6 +294,11 @@ def __post_init__(self) -> None:
257294
f"attention_mode must be one of 'mla', 'dsa', 'full', 'gqa', "
258295
f"got {self.attention_mode!r}"
259296
)
297+
object.__setattr__(
298+
self,
299+
"side_channel_residual_scale",
300+
_normalize_side_channel_residual_scale(self.side_channel_residual_scale),
301+
)
260302
self.attention_config(self.attention_mode)
261303
# Always also validate the dsa mode contract because dsa_a_layer_ranks
262304
# may pin specific A-layers to dsa regardless of attention_mode.
@@ -371,6 +413,14 @@ def platform_embedding_config(self) -> dict[str, int]:
371413
"max_ids": self.platform_max_ids,
372414
}
373415

416+
def residual_scale_for(self, family: str) -> float:
417+
if family == "structure":
418+
return self.side_channel_residual_scale.get(
419+
"structure",
420+
self.side_channel_residual_scale.get("syntax", 1.0),
421+
)
422+
return self.side_channel_residual_scale.get(family, 1.0)
423+
374424
def ngram_hash_config(self) -> dict[str, object] | None:
375425
if not self.ngram_hash_enabled:
376426
return None
@@ -1083,37 +1133,56 @@ def decoder_hidden_states(
10831133
if self.ngram_hash_embedding is not None:
10841134
hidden_states = hidden_states + self.ngram_hash_embedding(input_ids)
10851135

1086-
structure_embeddings = self.structure_embedding(
1087-
structure_ids=_validate_side_channel_shape(
1136+
structure_inputs = {
1137+
"structure_ids": _validate_side_channel_shape(
10881138
"structure_ids", structure_ids, batch_size, seq_length
10891139
),
1090-
dep_levels=_validate_side_channel_shape(
1140+
"dep_levels": _validate_side_channel_shape(
10911141
"dep_levels", dep_levels, batch_size, seq_length
10921142
),
1093-
ast_depth_ids=_validate_side_channel_shape(
1143+
"ast_depth_ids": _validate_side_channel_shape(
10941144
"ast_depth_ids", ast_depth_ids, batch_size, seq_length
10951145
),
1096-
sibling_index_ids=_validate_side_channel_shape(
1146+
"sibling_index_ids": _validate_side_channel_shape(
10971147
"sibling_index_ids", sibling_index_ids, batch_size, seq_length
10981148
),
1099-
node_type_ids=_validate_side_channel_shape(
1149+
"node_type_ids": _validate_side_channel_shape(
11001150
"node_type_ids", node_type_ids, batch_size, seq_length
11011151
),
1102-
target_dtype=hidden_states.dtype,
1103-
)
1104-
if structure_embeddings.ndim == hidden_states.ndim:
1105-
hidden_states = hidden_states + structure_embeddings
1152+
}
1153+
structure_residual_scale = self.config.residual_scale_for("structure")
1154+
if structure_residual_scale != 0.0:
1155+
structure_embeddings = self.structure_embedding(
1156+
**structure_inputs,
1157+
target_dtype=hidden_states.dtype,
1158+
)
1159+
if structure_embeddings.ndim == hidden_states.ndim:
1160+
if structure_residual_scale == 1.0:
1161+
hidden_states = hidden_states + structure_embeddings
1162+
else:
1163+
hidden_states = hidden_states + (
1164+
structure_embeddings
1165+
* mx.array(structure_residual_scale, dtype=hidden_states.dtype)
1166+
)
11061167

11071168
platform_ids = _validate_platform_ids(
11081169
platform_ids,
11091170
batch_size=batch_size,
11101171
seq_length=seq_length,
11111172
)
1112-
if platform_ids is not None:
1113-
hidden_states = hidden_states + self.platform_embedding(
1173+
platform_residual_scale = self.config.residual_scale_for("platform")
1174+
if platform_ids is not None and platform_residual_scale != 0.0:
1175+
platform_residual = self.platform_embedding(
11141176
platform_ids,
11151177
target_dtype=hidden_states.dtype,
11161178
)
1179+
if platform_residual_scale == 1.0:
1180+
hidden_states = hidden_states + platform_residual
1181+
else:
1182+
hidden_states = hidden_states + (
1183+
platform_residual
1184+
* mx.array(platform_residual_scale, dtype=hidden_states.dtype)
1185+
)
11171186

11181187
document_ids = _validate_document_ids(
11191188
document_ids,

scripts/train_hybrid_tiny.py

Lines changed: 94 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,11 @@
3131
import mlx.core as mx # noqa: E402
3232

3333
from cppmega_mlx.data.token_dataset import TokenBatchDataset, open_token_dataset # noqa: E402
34-
from cppmega_mlx.models.hybrid_lm import HybridTinyConfig, HybridTinyLM # noqa: E402
34+
from cppmega_mlx.models.hybrid_lm import ( # noqa: E402
35+
HYBRID_SIDE_CHANNEL_RESIDUAL_SCALE_FAMILIES,
36+
HybridTinyConfig,
37+
HybridTinyLM,
38+
)
3539
from cppmega_mlx.runtime.memory import ( # noqa: E402
3640
DEFAULT_METAL_RATIO,
3741
DEFAULT_WIRED_RATIO,
@@ -131,6 +135,7 @@ class TrainHybridTinyConfig:
131135
ngram_hash_seed: int | None = None
132136
side_channel_dropout: dict[str, float] = field(default_factory=dict)
133137
side_channel_dropout_seed: int | None = None
138+
side_channel_residual_scale: dict[str, float] = field(default_factory=dict)
134139
include_structure: bool = True
135140
grad_checkpoint: bool = False
136141
shuffle: bool = False
@@ -361,6 +366,14 @@ def build_parser() -> argparse.ArgumentParser:
361366
default=TrainHybridTinyConfig.side_channel_dropout_seed,
362367
help="Optional deterministic seed for side-channel family dropout.",
363368
)
369+
parser.add_argument(
370+
"--side-channel-residual-scale",
371+
default="",
372+
help=(
373+
"Comma-separated family=scale model residual policy for consumed "
374+
"families, e.g. platform=1,structure=0.5."
375+
),
376+
)
364377
parser.add_argument("--token-key", default=TrainHybridTinyConfig.token_key)
365378
parser.add_argument("--seed", type=int, default=TrainHybridTinyConfig.seed)
366379
parser.add_argument("--shuffle", action="store_true")
@@ -496,6 +509,52 @@ def parse_side_channel_dropout(value: str | None) -> dict[str, float]:
496509
return policy
497510

498511

512+
def parse_side_channel_residual_scale(value: str | None) -> dict[str, float]:
513+
if value is None or not value.strip():
514+
return {}
515+
policy: dict[str, float] = {}
516+
allowed = set(HYBRID_SIDE_CHANNEL_RESIDUAL_SCALE_FAMILIES)
517+
for item in value.split(","):
518+
item = item.strip()
519+
if not item:
520+
continue
521+
if "=" not in item:
522+
raise argparse.ArgumentTypeError(
523+
"side_channel_residual_scale entries must be family=scale pairs"
524+
)
525+
family, raw_scale = item.split("=", 1)
526+
family = family.strip()
527+
if not family:
528+
raise argparse.ArgumentTypeError(
529+
"side_channel_residual_scale family names must be non-empty"
530+
)
531+
if family not in allowed:
532+
allowed_list = ", ".join(HYBRID_SIDE_CHANNEL_RESIDUAL_SCALE_FAMILIES)
533+
raise argparse.ArgumentTypeError(
534+
"side_channel_residual_scale only supports model-consumed "
535+
f"families: {allowed_list}; got {family!r}"
536+
)
537+
try:
538+
scale = float(raw_scale.strip())
539+
except ValueError as exc:
540+
raise argparse.ArgumentTypeError(str(exc)) from exc
541+
if not math.isfinite(scale) or scale < 0.0:
542+
raise argparse.ArgumentTypeError(
543+
f"side_channel_residual_scale for {family!r} must be finite and >= 0"
544+
)
545+
policy[family] = scale
546+
if (
547+
"structure" in policy
548+
and "syntax" in policy
549+
and not math.isclose(policy["structure"], policy["syntax"])
550+
):
551+
raise argparse.ArgumentTypeError(
552+
"structure and syntax currently share a single structure residual; "
553+
"set one side_channel_residual_scale value or matching values"
554+
)
555+
return policy
556+
557+
499558
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
500559
return build_parser().parse_args(argv)
501560

@@ -560,6 +619,9 @@ def config_from_args(args: argparse.Namespace) -> TrainHybridTinyConfig:
560619
ngram_hash_seed=args.ngram_hash_seed,
561620
side_channel_dropout=parse_side_channel_dropout(args.side_channel_dropout),
562621
side_channel_dropout_seed=args.side_channel_dropout_seed,
622+
side_channel_residual_scale=parse_side_channel_residual_scale(
623+
args.side_channel_residual_scale
624+
),
563625
include_structure=not args.no_structure,
564626
grad_checkpoint=args.grad_checkpoint,
565627
shuffle=args.shuffle,
@@ -676,6 +738,32 @@ def validate_config(config: TrainHybridTinyConfig) -> None:
676738
and config.side_channel_dropout_seed < 0
677739
):
678740
raise ValueError("side_channel_dropout_seed must be >= 0")
741+
allowed_residual_families = set(HYBRID_SIDE_CHANNEL_RESIDUAL_SCALE_FAMILIES)
742+
for family, scale in config.side_channel_residual_scale.items():
743+
if not isinstance(family, str) or not family.strip():
744+
raise ValueError("side_channel_residual_scale family names must be non-empty")
745+
if family not in allowed_residual_families:
746+
allowed = ", ".join(HYBRID_SIDE_CHANNEL_RESIDUAL_SCALE_FAMILIES)
747+
raise ValueError(
748+
"side_channel_residual_scale only supports model-consumed "
749+
f"families: {allowed}; got {family!r}"
750+
)
751+
if not math.isfinite(float(scale)) or float(scale) < 0.0:
752+
raise ValueError(
753+
f"side_channel_residual_scale for {family!r} must be finite and >= 0"
754+
)
755+
if (
756+
"structure" in config.side_channel_residual_scale
757+
and "syntax" in config.side_channel_residual_scale
758+
and not math.isclose(
759+
config.side_channel_residual_scale["structure"],
760+
config.side_channel_residual_scale["syntax"],
761+
)
762+
):
763+
raise ValueError(
764+
"structure and syntax currently share a single structure residual; "
765+
"set one side_channel_residual_scale value or matching values"
766+
)
679767
if config.memory_limit_total_bytes is not None and config.memory_limit_total_bytes <= 0:
680768
raise ValueError("memory_limit_total_bytes must be positive")
681769
memory_limit_plan(
@@ -836,6 +924,7 @@ def hybrid_model_config(
836924
ngram_hash_embed_dim=config.ngram_hash_embed_dim,
837925
ngram_hash_dropout=config.ngram_hash_dropout,
838926
ngram_hash_seed=config.ngram_hash_seed,
927+
side_channel_residual_scale=dict(config.side_channel_residual_scale),
839928
grad_checkpoint=config.grad_checkpoint,
840929
)
841930

@@ -975,9 +1064,13 @@ def safe_training_optimizer_payload(config: TrainHybridTinyConfig) -> dict[str,
9751064

9761065
def training_loss_payload(config: TrainHybridTinyConfig) -> dict[str, Any]:
9771066
side_channel_dropout = dict(sorted(config.side_channel_dropout.items()))
1067+
side_channel_residual_scale = dict(
1068+
sorted(config.side_channel_residual_scale.items())
1069+
)
9781070
side_channel_receipt = {
9791071
"side_channel_dropout": side_channel_dropout,
9801072
"side_channel_dropout_seed": config.side_channel_dropout_seed,
1073+
"side_channel_residual_scale": side_channel_residual_scale,
9811074
}
9821075
if config.loss_backend == "cross_entropy":
9831076
return {

0 commit comments

Comments
 (0)