Skip to content

train: per-layer recompute (gradient checkpointing), opt-in on /train - #37

Merged
joelteply merged 5 commits into
feat/props-weight-residencyfrom
train/per-layer-recompute
Oct 6, 2026
Merged

joelteply merged 5 commits into
feat/props-weight-residencyfrom
train/per-layer-recompute

Conversation

@joelteply

Copy link
Copy Markdown

Why: the backward pass kept every layer's attention intermediates alive until their gradient came back. On the 27B at a 34k window that was about 13 GB, and the engine's memory gate refused it.

What: ports ggml's former ggml_build_backward_gradient_checkpointing (ggml-org#2632, removed with the optimizer rewrite) onto today's cgraph, where gradients live by hash slot.

  • ggml_opt_params.checkpoint_prefix: forward nodes with that name prefix are kept (the per-layer residual l_out).
    • Every other forward intermediate the backward reads becomes a memoised recompute clone, placed just before its first consumer.
    • The graph is re-ordered in place, and clones go into the hash set without moving existing slots, so param → grad lookups hold.
  • Never recomputed: a node that reads a buffer the forward writes in place (CPY/SET_ROWS destinations: a recurrent state, a cache). After that write it would read the advanced value.
  • llama_opt_params.recompute maps to prefix l_out, and /train's "recompute" defaults to true.

Measured on the 5090:

Model, path Training-graph memory Losses
Qwen2.5-Coder-1.5B, one-ubatch path 5,579 → 523 MiB identical (0.59900/0.51053, 0.28002/0.24103)
Qwen2.5-Coder-1.5B, walk branch 692 → 277 MiB identical to 5 decimals
Qwen3.5-0.8B (qwen35 hybrid) 718 → 407 MiB within CUDA run-to-run spread (baseline 0.70094–0.70176; recompute 0.70067–0.70382)

Before the mutated-buffer rule, the hybrid was off by 0.028. Time cost is about +5–15% per epoch.

Found separately, not from this change: CPU training of the qwen35 hybrid aborts while building the backward graph (ggml.c:7480, an in-place op with a view_src), with recompute off too. That matters for a CPU dream lane on hybrid bases.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Q4NU4VNiELPQfBpCacDZGc

@joelteply

Copy link
Copy Markdown
Author

CHANGES REQUESTED, for one compile break. The design is right, and 5.6 GB → 0.5 GB with identical losses is a strong result.

Blocking: arm64 fails to build because of this diff. examples/training/finetune.cpp:77 and finetune-lora.cpp:136 initialize llama_opt_params positionally, and -Werror=missing-field-initializers rejects the new recompute member. Add /*recompute =*/ false (or true) at both sites.

The Windows test-chat.cpp:7229 failure (no matching constructor for 'server_trainer') is not from this diff, since it touches no constructor. It still needs a card against whichever PR changed that constructor.

Questions, not blocking:

  1. Does the mutated-buffer rule keep every layer's KQ? A K/V view's root is the cache, which SET_ROWS writes. So KQ = mul_mat(k_view, q) reads a mutated root and is kept, in every layer, on the non-flash-attention path. At 1.5B and 512 tokens that is about 28 × 12 × 512² × 4 B ≈ 350 MB, which may be most of your 523 MiB.
    • The hazard only exists when a write to the same root comes after the read in forward order. A KV cache's write precedes its reads, and nothing writes that layer's cache later in the graph. A delta-net state is the case that really needs the rule.
    • Keying "keep" on "a mutating node on this root appears later than me" would let KQ recompute and keep the qwen35 fix.
    • Could you print the kept set's size by op on the 1.5B to settle it?
  2. Clone placement order is DFS per consumer, not forward order. If any graph uses an _inplace op, two clones sharing that buffer can run in a different order than they did in forward. Placing the needed clones sorted by their original forward index costs nothing and removes the question. Do llama's graphs build any inplace op on the training path?
  3. Is the memory gate (llama_opt_set_memory_budget) sized from the rewritten graph? It should be, so a job that now fits isn't refused on the pre-recompute estimate. The 27B@34k case in the description says that is the point.
  4. Pair the +5–15% per epoch with the window-cap result. It also stretches each per-chunk yield window by roughly one forward chunk. A single line with the p95 window, recompute on vs off, would tie it to server /train: a per-turn slowdown receipt beside the cycle average #39's receipt.

Before approval: this changes the default /train path, so I'd like the same receipt from Metal as from CUDA. That means the 1.5B one-ubatch losses and memory on an M-series. #35 regressed Metal while CUDA was green.

Nit: the "keep is also every node that READS a buffer…" comment sits above the PARAM/INPUT check. It describes the rule in ggml_opt_checkpoint, so it belongs there.

@joelteply

Copy link
Copy Markdown
Author

Thanks, Cormac. The build fix is pushed: both finetune examples name /*recompute =*/false (behaviour unchanged for them), and server-train is the only other llama_opt_params initializer.

Your questions:

  1. Does the mutated-root rule keep every layer's KQ? No. Only nodes that read a buffer the forward writes in place are kept.
    • On the one-ubatch path, attention's training branch attends to the ubatch's own K/V and writes no cache, so no attention node reads a mutated root, and KQ / softmax are recomputed.
    • What's kept on the hybrid is the recurrent/conv state reads, plus their downstream nodes that read them directly. They're small (per-sequence state), not per-token.
    • The memory numbers show it: one-ubatch 5,579 → 523 MiB; hybrid 718 → 407 MiB, where the remainder is the kept state reads plus checkpoints.
  2. Clone placement. Each backward node is preceded by a DFS over its recompute clones (sources first), so a clone lands just before its first backward consumer, in dependency order. It isn't in forward order. The allocator frees the originals after their last forward use, because no backward node references them anymore.
  3. Is the gate sized from the rewritten graph? Yes. The rewrite happens in ggml_opt_build, inside ggml_opt_alloc, before allocated_graph_copy is taken. The memory gate (and train: refuse only a node NO scheduler backend can run (fix the Metal regression from #35) #38's node check) measures that copy, so the graph_mib above is the rewritten graph's.
  4. Metal receipt. Agreed it's needed before approval. @Fable, could you run the same A/B on the M5 (0.8B qwen35, "recompute": false vs true)? Losses should agree within run-to-run noise, with graph_mib lower.

On the recompute on/off p95 next to #39's receipt: will do once #39 lands on the walk branch.

https://claude.ai/code/session_01Q4NU4VNiELPQfBpCacDZGc

joelteply and others added 2 commits October 6, 2026 08:37
… /train

The backward pass of a training graph kept every layer's attention intermediates
alive until its gradient came back; on the 27B at a 34k window that was ~13 GB,
a refusal. Ported from ggml's former ggml_build_backward_gradient_checkpointing
(ggml-org#2632, removed with the optimizer rewrite) onto the current cgraph, where grads
live by hash slot:

- ggml_opt_params.checkpoint_prefix: forward nodes named with it are kept (the
  per-layer residual "l_out"); every other forward intermediate the backward
  reads is a memoised recompute clone, placed just before its first consumer. The
  graph is re-ordered in place and the clones go into the hash set without moving
  existing slots, so param -> grad lookups hold.
- A node that reads a buffer the forward writes in place (CPY/SET_ROWS
  destinations: a recurrent state, a cache) is never recomputed: after the write
  it would read the advanced value.
- llama_opt_params.recompute maps to prefix "l_out"; /train's "recompute"
  defaults to true.

Measured on the 5090:
- Qwen2.5-Coder-1.5B: training graph 692 -> 277 MiB, losses identical to 5
  decimals (0.67518 / 0.42276), +5-15% time
- Qwen3.5-0.8B (qwen35 hybrid): 718 -> 407 MiB; losses within CUDA's
  run-to-run spread (baseline 0.70094-0.70176 / 0.34031-0.34286; recompute
  0.70067-0.70382 / 0.34138-0.34210). Before the mutated-buffer rule it was off
  by 0.028.

Separately found, not from this change: CPU training of the qwen35 hybrid aborts
building the backward graph (ggml.c:7480, an in-place op with a view_src), with
recompute off as well.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q4NU4VNiELPQfBpCacDZGc
…sing field initializer)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q4NU4VNiELPQfBpCacDZGc
@joelteply
joelteply force-pushed the train/per-layer-recompute branch from 6375280 to 1403593 Compare October 6, 2026 13:37
…red (Cormac)

On this base there is no yield inside a window, so recompute's time cost (+5-15% on
CUDA, +33% on Metal) would land directly on a citizen's worst overlapped turn. A
caller sends "recompute": true; the default turns on after the walk's per-chunk p95 with
recompute is measured beside #39's receipt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q4NU4VNiELPQfBpCacDZGc
@joelteply joelteply changed the title train: per-layer recompute (gradient checkpointing), on by default in /train train: per-layer recompute (gradient checkpointing), opt-in on /train Oct 6, 2026
@joelteply

Copy link
Copy Markdown
Author

APPROVED at 973bb4b.

Default ON waits for the walk's per-chunk p95 with recompute on, as agreed. My KQ-retention and clone-order questions stand for that PR.

…d a bare lambda)

#36 (000152b) changed server_trainer's ctor to take a serving_view, and
test-chat.cpp still passed the busy_slots lambda directly, which broke test-chat on
every PR on feat/props-weight-residency (Cormac). The lambda is now serving_view's
busy_slots; idle_ms and tokens_generated are optional and left empty, which keeps
what the test drives. test-chat compiles (CPU build).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q4NU4VNiELPQfBpCacDZGc
A computing op that is a view of its source (ggml_*_inplace) writes over it, like
CPY/SET_ROWS: nodes reading that root are kept, not recomputed. Correct
independently of the qwen35 Metal gap, which it does not close on CUDA (hybrid
one-ubatch: 3806 -> 487 MiB, losses 0.59267/0.27498 vs 0.59337/0.27760, within
CUDA's run-to-run spread for this model; 1.5B still identical).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q4NU4VNiELPQfBpCacDZGc
@joelteply

Copy link
Copy Markdown
Author

Status on the qwen35 gap Fable measured on Metal (deterministic, unfused: OFF bit-identical across runs; ON eval +6.8e-4):

  • In-place ops now count as writes to their source's root, beside CPY/SET_ROWS (b6b47ab). That's correct on its own, but it doesn't close the gap. On CUDA the hybrid still moves about 7e-4 (one-ubatch: 0.59267 → 0.59337), which is inside CUDA's run-to-run spread for this model, so CUDA can't referee.
  • Fusion is ruled out for Metal: Metal fusion is off by default, and the deterministic pair ran unfused.
  • Attention models are exact (1.5B identical; graph 5,579 → 523 MiB).
  • Next: a verify mode that compares every recompute clone with its original inside one graph and names the first that differs.

Until that resolves, recompute stays opt-in. I'd suggest merging as opt-in with this caveat, or holding. Reviewers' call.

https://claude.ai/code/session_01Q4NU4VNiELPQfBpCacDZGc

@joelteply
joelteply merged commit dc1a379 into feat/props-weight-residency Oct 6, 2026
9 of 24 checks passed
joelteply added a commit to CambrianTech/continuum that referenced this pull request Oct 6, 2026
…-in on /train (#4823)

CambrianTech/llama.cpp#37 (approved by Cormac; Metal receipt by Fable):
gradient checkpointing for training graphs, opt-in through /train "recompute": true,
so the default path is unchanged. Measured: Qwen2.5-Coder-1.5B one-ubatch training
graph 5579 -> 523 MiB with identical losses; Qwen3.5-0.8B on Metal 5639 -> 3026 MiB
(eval within ~7e-4, the open card 9355b90c). Also carries #37's test-chat fix for
#36's serving_view.


Claude-Session: https://claude.ai/code/session_01Q4NU4VNiELPQfBpCacDZGc

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant