Download source/src/deg/memplan.py from gcoderw/DEG-5B-E2B-1018: direct link, hf CLI and curl.
- Browser
- Download file 17.9 kB
-
https://huggingface.co/gcoderw/DEG-5B-E2B-1018/resolve/main/source/src/deg/memplan.py
- Command line
-
hf download hf://gcoderw/DEG-5B-E2B-1018/source/src/deg/memplan.py
-
curl -L -o memplan.py https://huggingface.co/gcoderw/DEG-5B-E2B-1018/resolve/main/source/src/deg/memplan.py
17.9 kB
| """Training memory planner: per-micro-batch peak memory from its shape (SPEC.md §12). | |
| A micro-batch is one padded trunk forward (``B`` records, padded width ``W`` context | |
| tokens) and fork groups (:func:`group_forks`: at most ``fork_rows`` rows and, unless a | |
| single row is wider, ``fork_tokens`` padded tokens each), every fork row continuing its | |
| record's trunk state of width ``W``; then the heads and the LM cross-entropy. With | |
| gradient checkpointing (one saved hidden per layer) and the in-checkpoint prefix | |
| selection of ``Backbone.forward(rows=...)``, its activation memory is modelled as | |
| persistent = trunk_tok * B*W + trunk_row * B*K + trunk_mask * B*W^2 [dense] | |
| + trunk_chunk_tok * B*W [chunked] | |
| + fork_tok * sum_i l_i + fork_mask * sum_i l_i*(W+l_i) | |
| + head_pair * sum_items T_item + lm_saved * lm_targets | |
| transient = max(trunk_layer * B*Wc + trunk_row_layer * B + attn_pair * B*Wc*W, | |
| fork_layer * G_tok + fork_prefix * G_rows*(W+l_max) | |
| + fork_row_layer * G_rows + attn_pair * G_tok*(W+l_max), | |
| lm_peak * lm_targets) | |
| activation = base + scale * (persistent + transient) | |
| over the fork rows ``i`` of query length ``l_i`` (``n`` rows, longest ``l_max``). A trunk | |
| of ``W <= DENSE_PREFILL_MAX`` tokens is read in one shot (``K = 1``, ``Wc = W``, its | |
| ``[B, W, W]`` mask is a checkpoint input); a longer one (training) is read in ``K = | |
| ceil(W / PREFILL_CHUNK)`` checkpointed chunks (``Backbone._forward_chunked_ckpt``): | |
| recurrent state saved per chunk, the chunks' K/V concatenated once more at the end | |
| (``trunk_chunk_tok``), no saved mask, and one layer's recompute covers one chunk | |
| (``Wc = PREFILL_CHUNK``) against all ``W`` keys. The largest fork group is bounded by | |
| ``G_rows = min(n, fork_rows)`` rows and ``G_tok = min(n*l_max, max(fork_tokens, l_max))`` | |
| padded tokens (``transient``: one layer's | |
| recompute + backward, or the LM logits; they are never live together). Fork sums use | |
| real lengths (the ~25% group padding is in the coefficients), and the group bound does | |
| not depend on how the rows happen to group, so with every coefficient >= 0 the estimate | |
| is monotone in the number of field rows and every query's tokens, and in the context | |
| tokens within each trunk path (it drops where the chunked path takes over). | |
| The default coefficients are analytic for Qwen3.5-0.8B in bf16 (24 layers: 18 gated | |
| delta-net + 6 GQA attention with 2 K/V heads x 256, hidden 1024, MLP 3584, vocab | |
| 248,320; SDPA runs the memory-efficient kernel, so attention is quadratic only through | |
| its boolean mask). ``scale`` is the calibration: the trainer measures each micro-batch's | |
| real peak on the device and :meth:`MemPlanner.observe` keeps ``scale`` at the largest | |
| measured/estimated ratio over a recent window (a measured linear correction: raised at | |
| once by any under-estimate, lowered only from :attr:`MemPlanner.min_samples` samples, | |
| clamped to [:attr:`MemPlanner.min_scale`, :attr:`MemPlanner.max_scale`]). | |
| ``--mem-coef FILE`` overrides coefficients with a JSON object of the same names (e.g. a | |
| least-squares refit of the trainer's ``mem.jsonl``: features + measured peak per part). | |
| The device budget for activations is ``mem_target - static`` where ``static`` is the | |
| resident model + optimizer state + backbone gradients (bf16, plus the fp32 accumulators | |
| when a step runs several parts), measured on the device once it exists. | |
| """ | |
| from __future__ import annotations | |
| import dataclasses | |
| import json | |
| from collections import deque | |
| from dataclasses import dataclass, field | |
| from pathlib import Path | |
| from typing import Any, Protocol, Sequence | |
| KIB = 1024 | |
| MIB = 1024 * KIB | |
| GIB = 1024 * MIB | |
| class _ItemLike(Protocol): | |
| kind: str | |
| seq: tuple[int, ...] | |
| prompt_len: int | |
| def group_forks(forks: list[tuple[int, Any]], fork_tokens: int, fork_rows: int | |
| ) -> list[list[tuple[int, Any]]]: | |
| """Fork groups of one micro-batch, as the trainer runs them: sorted by length; a group | |
| closes at the token/row budget or when padding would exceed ~25%.""" | |
| forks = sorted(forks, key=lambda x: len(x[1].seq)) | |
| groups: list[list[tuple[int, Any]]] = [] | |
| cur: list[tuple[int, Any]] = [] | |
| for f in forks: | |
| width = len(f[1].seq) | |
| if cur and ((len(cur) + 1) * width > fork_tokens or len(cur) >= fork_rows | |
| or width > 1.25 * len(cur[0][1].seq) + 8): | |
| groups.append(cur) | |
| cur = [] | |
| cur.append(f) | |
| if cur: | |
| groups.append(cur) | |
| return groups | |
| class MemCoef: | |
| """Bytes per unit of each feature (see the module docstring).""" | |
| base: float = 512 * MIB # per micro-batch: cuBLAS/compile workspaces, fragmentation | |
| # persistent until the backward reaches them | |
| trunk_tok: float = 84 * KIB # saved hidden per layer (48K) + K/V (12K) + its grad (12K) | |
| # + final hidden / head projections (~12K) | |
| trunk_row: float = 36 * MIB # recurrent state [16,128,128] fp32 x 18 layers + its grad | |
| # (per row and per chunk of a chunked trunk) | |
| trunk_chunk_tok: float = 24 * KIB # chunked trunk: concatenated K/V copy + its grad | |
| trunk_mask: float = 1.0 # [B, W, W] bool causal mask (checkpoint input) | |
| fork_tok: float = 70 * KIB # saved hidden per layer + final hidden (x1.25 padding) | |
| fork_mask: float = 1.25 # [R, L, W+L] bool mask per group (checkpoint input) | |
| head_pair: float = 48.0 # span/anchor logits per (item, context token), fp32 + grads | |
| lm_saved: float = 4.0 * 248_320 # log-softmax output kept for the backward (fp32) | |
| # transient: one layer recomputed with grad + its backward | |
| trunk_layer: float = 352 * KIB # projections, conv, delta-rule chunks, MLP, + grads | |
| trunk_row_layer: float = 8 * MIB # one layer's recurrent state copies + grads | |
| fork_layer: float = 352 * KIB | |
| fork_prefix: float = 8 * KIB # selected + concatenated prefix K/V and their grads | |
| fork_row_layer: float = 8 * MIB | |
| attn_pair: float = 6.0 # bool mask -> additive bias for the efficient SDPA kernel | |
| lm_peak: float = 14.0 * 248_320 # bf16 logits + fp32 copy + log-softmax + grads | |
| def for_backbone(cls, backbone: str | None = None) -> MemCoef: | |
| """Analytic defaults per backbone (``deg.model.BACKBONES``): the class defaults | |
| are Qwen3.5-0.8B; ``qwen3.5-2b`` scales its width terms; ``lfm2.5-1.2b`` (16 layers: 10 short-conv + 6 GQA attention with | |
| 8 K/V heads x 64, hidden 2048, MLP 8192, vocab 65,536) saves a 2048-wide hidden | |
| per layer (64K/token over 16 layers vs 48K), the same 12K/token of K/V, no | |
| recurrent state (conv tails only: 2 tokens x 2048 per conv layer), a ~2.3x wider | |
| layer recompute (hidden 2x, MLP 8192 vs 3584) and a 3.8x smaller vocabulary. The | |
| trainer's measured ``scale`` corrects the remaining error either way.""" | |
| if backbone in (None, "qwen3.5-0.8b"): | |
| return cls() | |
| if backbone == "qwen3.5-2b": | |
| # same 24-layer layout, recurrent state and K/V as the 0.8B; hidden 2048 (2x) | |
| # doubles the saved hidden per layer (96K/token) and MLP 6144 (1.7x) with the | |
| # 2x hidden makes a layer's recompute ~1.9x wider | |
| return cls(trunk_tok=144 * KIB, fork_tok=120 * KIB, trunk_layer=680 * KIB, | |
| fork_layer=680 * KIB) | |
| if backbone in ("gemma-4-e2b-it", "gemma-4-e2b"): | |
| # Gemma 4 E2B (35 attention layers, hidden 1536, MLP 6144 / 12288 in the 20 | |
| # K/V-sharing layers, 15 K/V slots: 3 global x 1 head x 512 + 12 sliding x 256, | |
| # PLE 35 x 256 per token, vocab 262,144), derived per token in bf16: saved | |
| # hidden per layer 35 x 3K = 105K, the PLE inputs (17.5K) and the projected PLE | |
| # before its norm (17.5K), global K/V 6K + the sharing source's sliding K/V 1K | |
| # and their grads, final hidden / heads ~12K -> ~172K; no recurrent state, a | |
| # sliding tail per row of 12 slots x 2 x 511 x 256 (6 MiB) + grad; one layer's | |
| # recompute + backward: GeGLU at 12288 (gate, up, gelu, product + grads, ~200K), | |
| # q at 8 x 512, norms and residuals at 1536 (~10 x 3K), PLE gate (~2K) -> | |
| # ~640K/token with allocator slack; the logits carry the soft-cap tanh (fp32) | |
| # beside the log-softmax. The measured ``scale`` corrects the rest. | |
| V = 262_144 | |
| return cls(trunk_tok=172 * KIB, trunk_row=12 * MIB, trunk_chunk_tok=12 * KIB, | |
| fork_tok=150 * KIB, fork_prefix=6 * KIB, lm_saved=8.0 * V, | |
| trunk_layer=640 * KIB, trunk_row_layer=1 * MIB, fork_layer=640 * KIB, | |
| fork_row_layer=1 * MIB, lm_peak=22.0 * V, | |
| attn_pair=30.0) # the A100 smoke ran (and calibrated, scale 1.54) at 30 | |
| if backbone == "lfm2.5-1.2b": | |
| return cls(trunk_tok=112 * KIB, trunk_row=1 * MIB, fork_tok=96 * KIB, | |
| lm_saved=4.0 * 65_536, trunk_layer=800 * KIB, trunk_row_layer=1 * MIB, | |
| fork_layer=800 * KIB, fork_row_layer=1 * MIB, lm_peak=14.0 * 65_536) | |
| raise ValueError(f"no memory coefficients for backbone {backbone!r}") | |
| def load(cls, path: Path | str | None, backbone: str | None = None) -> MemCoef: | |
| """The backbone's defaults (:meth:`for_backbone`), overridden by the JSON at | |
| ``path`` when given.""" | |
| base = cls.for_backbone(backbone) | |
| if path is None: | |
| return base | |
| data = json.loads(Path(path).read_text()) | |
| names = {f.name for f in dataclasses.fields(cls)} | |
| bad = sorted(set(data) - names) | |
| if bad: | |
| raise ValueError(f"unknown memory coefficients {bad} in {path}") | |
| if any(float(v) < 0 for v in data.values()): | |
| raise ValueError(f"memory coefficients must be >= 0: {path}") | |
| return dataclasses.replace(base, **{k: float(v) for k, v in data.items()}) | |
| class MemFeatures: | |
| trunk_rows: int = 0 # B | |
| width: int = 0 # W | |
| trunk_tok: int = 0 # B * W (padded) | |
| trunk_sq: int = 0 # B * W^2 (dense trunk mask; 0 for a chunked trunk) | |
| trunk_chunks: int = 0 # B * K (chunks per row; K = 1 dense) | |
| trunk_chunk_tok: int = 0 # B * W for a chunked trunk, else 0 | |
| trunk_layer_tok: int = 0 # B * Wc: tokens of one layer's recompute | |
| trunk_attn: int = 0 # B * Wc * W: one layer's attention pairs | |
| fork_rows: int = 0 # n | |
| fork_tok: int = 0 # sum_i l_i | |
| fork_sq: int = 0 # sum_i l_i * (W + l_i) | |
| fork_lmax: int = 0 # l_max | |
| group_rows: int = 0 # G_rows | |
| group_tok: int = 0 # G_tok | |
| head_pairs: int = 0 # sum over extract/anchor items of their record's context tokens | |
| lm_targets: int = 0 | |
| def as_dict(self) -> dict[str, Any]: | |
| return dataclasses.asdict(self) | |
| def features(examples: Sequence[Any], fork_tokens: int, fork_rows: int, | |
| dense_max: int | None = None, chunk: int | None = None) -> MemFeatures: | |
| """Shape features of one micro-batch of :class:`deg.train.Example` (anything with | |
| ``ctx.ids``, ``ctx.offsets`` and ``items``). ``dense_max`` / ``chunk`` default to | |
| ``deg.model.DENSE_PREFILL_MAX`` / ``PREFILL_CHUNK`` (read at call time).""" | |
| if not examples: | |
| return MemFeatures() | |
| if dense_max is None or chunk is None: | |
| from deg import model as dm | |
| dense_max = dm.DENSE_PREFILL_MAX if dense_max is None else dense_max | |
| chunk = dm.PREFILL_CHUNK if chunk is None else chunk | |
| B = len(examples) | |
| W = max(len(ex.ctx.ids) for ex in examples) | |
| dense = W <= dense_max | |
| K = 1 if dense else -(-W // chunk) | |
| Wc = W if dense else chunk | |
| lens = [len(it.seq) for ex in examples for it in ex.items] | |
| n, lmax = len(lens), max(lens, default=0) | |
| lm = sum(len(it.seq) - it.prompt_len for ex in examples for it in ex.items | |
| if it.kind == "generate") | |
| pairs = sum(len(ex.ctx.offsets) for ex in examples for it in ex.items | |
| if it.kind in ("extract", "anchor")) | |
| return MemFeatures(B, W, B * W, B * W * W if dense else 0, B * K, 0 if dense else B * W, | |
| B * Wc, B * Wc * W, n, sum(lens), sum(x * (W + x) for x in lens), | |
| lmax, min(n, fork_rows), min(n * lmax, max(fork_tokens, lmax)), | |
| pairs, lm) | |
| def estimate(f: MemFeatures, c: MemCoef, scale: float = 1.0) -> float: | |
| """Activation bytes of a micro-batch with features ``f`` (module docstring).""" | |
| if not f.trunk_rows: | |
| return 0.0 | |
| W = f.width | |
| persistent = (c.trunk_tok * f.trunk_tok + c.trunk_row * f.trunk_chunks | |
| + c.trunk_mask * f.trunk_sq + c.trunk_chunk_tok * f.trunk_chunk_tok | |
| + c.fork_tok * f.fork_tok | |
| + c.fork_mask * f.fork_sq + c.head_pair * f.head_pairs | |
| + c.lm_saved * f.lm_targets) | |
| trunk = (c.trunk_layer * f.trunk_layer_tok + c.trunk_row_layer * f.trunk_rows | |
| + c.attn_pair * f.trunk_attn) | |
| fork = (c.fork_layer * f.group_tok + c.fork_prefix * f.group_rows * (W + f.fork_lmax) | |
| + c.fork_row_layer * f.group_rows + c.attn_pair * f.group_tok * (W + f.fork_lmax)) | |
| transient = max(trunk, fork, c.lm_peak * f.lm_targets) | |
| return c.base + scale * (persistent + transient) | |
| def static_bytes(trainable_backbone: int, heads: int, weights: int, | |
| accumulate: bool) -> float: | |
| """Resident bytes outside the micro-batch activations: bf16 weights (``weights`` | |
| bytes as stored), fp32 master + AdamW moments of the trainable backbone and of the | |
| heads, the backbone's bf16 gradients while a backward runs, and (``accumulate``, | |
| several parts per step) the fp32 gradient accumulators.""" | |
| return (weights + 12.0 * trainable_backbone + 2.0 * trainable_backbone | |
| + 12.0 * heads + (4.0 * (trainable_backbone + heads) if accumulate else 0.0)) | |
| class MemPlanner: | |
| """Caps micro-batches to ``target`` bytes of device memory. | |
| ``static`` = resident bytes outside activations for a one-part step, | |
| ``accum_extra`` = the extra fp32 gradient accumulators a multi-part step keeps.""" | |
| target: float | |
| static: float | |
| accum_extra: float | |
| fork_tokens: int | |
| fork_rows: int | |
| coef: MemCoef = field(default_factory=MemCoef) | |
| scale: float = 1.0 | |
| min_scale: float = 0.5 | |
| max_scale: float = 4.0 | |
| window: int = 200 | |
| min_samples: int = 20 | |
| ratios: deque = field(default_factory=deque) | |
| def budget(self, accumulate: bool = False) -> float: | |
| return self.target - self.static - (self.accum_extra if accumulate else 0.0) | |
| def estimate(self, examples: Sequence[Any]) -> float: | |
| return estimate(features(examples, self.fork_tokens, self.fork_rows), self.coef, | |
| self.scale) | |
| def fits(self, examples: Sequence[Any], accumulate: bool = False) -> bool: | |
| return self.estimate(examples) <= self.budget(accumulate) | |
| def observe(self, examples: Sequence[Any], measured: float, min_bytes: float = GIB | |
| ) -> None: | |
| """Calibrate: ``measured`` = the micro-batch's activation peak on the device | |
| (peak allocated minus allocated before it). Micro-batches estimated under | |
| ``min_bytes`` are dominated by workspace noise and are not used.""" | |
| raw = estimate(features(examples, self.fork_tokens, self.fork_rows), self.coef) | |
| net = raw - self.coef.base | |
| if raw < min_bytes or net <= 0: | |
| return | |
| self.ratios.append(max(0.0, measured - self.coef.base) / net) | |
| while len(self.ratios) > self.window: | |
| self.ratios.popleft() | |
| top = max(self.ratios) | |
| if top > self.scale or len(self.ratios) >= self.min_samples: | |
| # raise at once on an under-estimate; lower only from a full-enough window | |
| self.scale = min(self.max_scale, max(self.min_scale, top)) | |
| def split_rows(self, ex: Any, accumulate: bool = True) -> list[Any]: | |
| """``ex`` alone if it fits, else copies of it over consecutive chunks of its items | |
| (same context object; the trainer runs them on one trunk pass, ``run_chunks``), | |
| each chunk fitting the multi-part budget when one item can. Chunks partition the | |
| items in order.""" | |
| if self.fits([ex]) or len(ex.items) <= 1: | |
| return [ex] | |
| lim = self.budget(accumulate) | |
| out: list[Any] = [] | |
| cur: list[Any] = [] | |
| for it in ex.items: | |
| if cur and self.estimate([dataclasses.replace(ex, items=cur + [it])]) > lim: | |
| out.append(dataclasses.replace(ex, items=cur)) | |
| cur = [] | |
| cur.append(it) | |
| if cur: | |
| out.append(dataclasses.replace(ex, items=cur)) | |
| return out | |
| def plan(self, batch: Sequence[Any]) -> list[list[Any]]: | |
| """A batch -> the micro-batches (parts) of one optimizer step: one part when the | |
| whole batch fits; else records packed greedily in order into parts that fit the | |
| multi-part budget, a record that does not fit alone split by rows | |
| (:meth:`split_rows`).""" | |
| batch = list(batch) | |
| if not batch or self.fits(batch): | |
| return [batch] | |
| lim = self.budget(True) | |
| parts: list[list[Any]] = [] | |
| cur: list[Any] = [] | |
| for ex in batch: | |
| if cur and self.estimate(cur + [ex]) <= lim: | |
| cur.append(ex) | |
| continue | |
| if cur: | |
| parts.append(cur) | |
| cur = [] | |
| if self.estimate([ex]) <= lim: | |
| cur = [ex] | |
| else: | |
| parts.extend([chunk] for chunk in self.split_rows(ex)) | |
| if cur: | |
| parts.append(cur) | |
| return parts | |