Skip to content

Compile the Laya encoder for decision model training - #12778

Merged
danielhanchen merged 94 commits into
mainfrom
laya-decision-compile
Oct 6, 2026
Merged

danielhanchen merged 94 commits into
mainfrom
laya-decision-compile

Conversation

@danielhanchen

@danielhanchen danielhanchen commented Oct 5, 2026 •

Copy link
Copy Markdown
Member

Builds on #12585 (decision model fine-tuning for Laya and Clef), which is now merged. It now also carries @Etherll's #12824 (merged here with its commits kept), so the Laya training speedups are one PR.

Laya training is launch bound: at the default recipe (LoRA, 8 decisions of about 300 tokens per micro-batch) the GPU is busy for about 0.14 s of a 1.6 s step on a B200, and the time goes to many small ModernBERT kernels. Compiling each encoder layer fuses them.

What changes

  • DecisionTrainer.train() compiles each Laya encoder layer in place (nn.Module.compile(dynamic = True)) for the run and puts the layers back to eager afterwards, so calibration, evaluation and saving run eagerly and parameter names, LoRA merging and saving are unchanged.
  • Before training, one compiled forward and backward runs as a warm-up (random state and gradients left untouched). If Inductor cannot serve the platform, the layers go back to eager and the run trains as before instead of failing.
  • On by default for GPU runs of at least 4,000 forward passes, where the 30 to 90 s compile pays for itself. UNSLOTH_DECISION_COMPILE=0 turns it off, UNSLOTH_DECISION_COMPILE=1 forces it on. Clef models are not affected (their head already compiles in Studio: fine-tune Laya and Cloudflare Clef decision models and serve them #12585).

Also from #12824

  • Short decisions (at most 1,024 tokens, which covers Laya's data) compile with static 64-token shape buckets instead of dynamic shapes; padded positions are masked keys, so the loss is unchanged. Evaluation stays eager.
  • The compiled layers use transformers' plain SDPA call (unsloth_zoo's attention wrapper made dynamo fall back on torch 2.11), and cuDNN SDPA is kept out of the decision forward, backward recompute and scoring (it rebuilds its plan for every new sequence length).
  • Plain LoRA layers (one adapter, no dropout, no DoRA) skip PEFT's per-call checks and dtype round trip; identical to PEFT in fp32 and in the autocast dtype, and PEFT's own forward is used for everything else. Checked on PEFT 0.18.0 and 0.21.2.
  • Scoring batches similar lengths together, so calibration and evaluation pad less.
  • Compiled layers checkpoint with torch's own non-reentrant checkpoint: Unsloth's offloaded reentrant checkpoint gave compiled bf16 layers wrong gradients. torch's checkpoint is found even after unsloth_zoo patches torch.utils.checkpoint.checkpoint.

On one B200, LoRA r64 and full fine-tune at 2 decisions x 16 accumulation, interleaved runs: #12824's additions are about 1.12x (LoRA, 1.25 to 1.08 s/step) and 1.10x (full, 0.71 to 0.65 s/step) faster than the dynamic-shape compile alone, same peak memory, 0 graph breaks.

Results

Colab L4, one card per run, arms interleaved ABBA, fresh process and compile cache per arm, 24 steps, median s/step (identical base runs differed by about 1%):

Laya Multilingual Eager s/step Compiled s/step Speedup First step (compile) Peak memory
LoRA (default) 1.55 to 1.63 1.25 to 1.33 about 1.23x 32 to 91 s 1.53 GB, unchanged
Full fine-tune 1.36 1.23 about 1.10x 22 to 57 s 5.6 GB, unchanged
  • Over a full epoch: 0 graph breaks, 4 graphs, 0 recompiles.
  • Full fine-tune gradients in float32 match eager (cosine 1.0); in bfloat16 both are equally close to the float32 result (cosine 0.9994 compiled, 0.9993 eager).
  • Train, calibrate, merged save and reload through the vendored Laya: served answers agree with the trained model on 200/200 (LoRA) and 199/200 (full fine-tune), largest probability difference 0.013.

Merged with #12824: speed and accuracy per GPU

Pre-merge (7052f9d) vs merged (16c55e2), ABBA order, fresh process and compile cache per run, steady s/step from step 3:

GPU LoRA 2x16 Full 2x16 LoRA 8x8 Full 8x8
T4 (fp16) 1.17x 1.06x 1.05x 1.04x
L4 1.16x 1.08x 1.14x 1.07x
A100 40GB 1.15x 1.12x 1.16x 1.11x
RTX PRO 6000 1.16x 1.06x 1.20x 1.09x

Same-arm spread is at most 3% (9% on the RTX PRO 6000). Full fine-tune peak memory is unchanged; LoRA at 8x8 allocates 2.6 to 5% more from bucket padding.

Gradients vs an fp64 eager step, 30 to 40 batches per GPU: in bf16 the merged compile is level with eager (A100 0.99x, L4 1.10x, B200 tie; RTX PRO 6000 1.22x, CI [1.02, 1.51]); in fp32 compiled matches fp64 on all GPUs. A single batch can show a large bf16 gap in either direction (eager was the worst arm on L4 and A100 for one batch): it comes from bf16 rounding in layers 0 to 10, and each compiled layer alone is as accurate as eager. Holdout loss after 60 steps (3 seeds per arm, B200 and L4) moved by at most 0.015 CE, well inside the 0.03 to 0.05 seed spread.

Not included

  • Compiling the whole training step (forward plus loss, fullgraph=True, no graph breaks) was another 1.13x over this, but takes 13 to 17 minutes to compile on an L4 and cannot trace Unsloth's offloaded gradient checkpointing, so it needs 3x the memory. Not worth it at Laya's typical run length.
  • Turning gradient checkpointing off is 1.37x on top of this, at 1.53 GB to 4.6 GB peak. The default stays on.
  • Fused AdamW and compiled autograd gave nothing measurable.

Tests

tests/test_decision_model.py covers the run-length threshold, both overrides, the return to eager, and a compile that raises (training still finishes, eagerly). The decision suite passes on CPU and on GPU with the compile on auto and forced on.

NilayYadav and others added 30 commits October 3, 2026 04:15
Clef and Clef-flash repos (config.json plus joint_head.*) are detected as
decision models next to Laya. Training goes through FastDecisionModel with
QLoRA allowed and the Qwen3.5 fast-path kernel setup the worker skips for
Laya. The Decision API lists clef and clef-flash and Clef fine-tunes, and
serves them from a spawn child that loads the backbone through Unsloth, so
unloading returns the GPU memory. Clef waits for the GPU while a run trains
instead of loading on CPU.
# Conflicts:
#	studio/backend/core/training/worker.py

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 5b37a35370

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +442 to 446
# The decision trainer resolves its own Laya cache, which holds no config.json to pin.
if is_local_path(model_name) or config.get("is_decision"):
config["actual_model_repo_id"] = None
config["model_snapshot_path"] = None
config["model_revision"] = None

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Retain validated snapshot pins for remote Clef models

When a remote Clef model is selected from a validated cached snapshot, this blanket decision-model exemption clears its snapshot path and revision even though Clef uses a normal root-level config.json and can be pinned like other Hugging Face models. The decision worker subsequently resolves model_name again, so a moved branch can train different weights than those inspected during preflight, and a cached private model may require a new Hub lookup instead of loading the authorized snapshot. Limit this exemption to Laya's subfolder layout and make the Clef download/load path consume the retained snapshot.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not changing: decision runs skip the cache-pin machinery on purpose and the loader resolves its own snapshot; pinning remote Clef snapshots through the decision path is a feature, not a defect here.

Comment on lines +202 to +204
start, end = config.get("dataset_slice_start"), config.get("dataset_slice_end")
if start is not None or end is not None:
rows = rows[start or 0 : (len(rows) if end is None else end + 1)]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Apply the requested dataset slice before materializing rows

For a Hub decision dataset that is not loaded through the exact-cache row limiter, _from_arrow converts every row into Python dictionaries before the requested dataset_slice_start/dataset_slice_end is applied here. Selecting a small slice of a multi-million-row dataset can therefore consume memory and time proportional to the full dataset, potentially exhausting the worker before training starts. Select the requested Arrow range before calling _from_arrow (and similarly avoid fully materializing other sliceable inputs).

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not changing: slicing before materialising is a memory optimisation for very large Hub decision datasets; the slice applied today is correct, only its cost differs.

@unslothai unslothai deleted a comment from chatgpt-codex-connector Bot Oct 6, 2026
@danielhanchen

Copy link
Copy Markdown
Member Author

@codex review

1 similar comment
@danielhanchen

Copy link
Copy Markdown
Member Author

@codex review

@danielhanchen

Copy link
Copy Markdown
Member Author

@codex review

1 similar comment
@danielhanchen

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 6595226285

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Initiates training in the background and returns immediately. Use /status
to check progress.
"""
await asyncio.to_thread(_validate_decision_request, request, via_api_key)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Replay idempotent starts before decision validation

For a retry carrying an already-resolved start_request_id, this validation runs before the stored outcome is checked at lines 1624–1630. If the original request was an accepted Clef QLoRA start but the checkpoint metadata is now temporarily unavailable or the local checkpoint was moved, decision_layout() can fall back to Laya and reject the still-explicit load_in_4bit=true, so the retry returns a new 400 instead of replaying the accepted result as the idempotency comment promises. Peek and return the existing start record before performing decision-model validation.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not changing this: a retry only reaches decision validation again if the checkpoint it validated against disappeared between the first start and the retry, and in that case refusing the retry is the right outcome rather than replaying a run whose model is gone.

chatgpt-codex-connector[bot]

This comment was marked as resolved.

@unslothai unslothai deleted a comment from chatgpt-codex-connector Bot Oct 6, 2026
@unslothai unslothai deleted a comment from chatgpt-codex-connector Bot Oct 6, 2026
@unslothai unslothai deleted a comment from chatgpt-codex-connector Bot Oct 6, 2026
@unslothai unslothai deleted a comment from chatgpt-codex-connector Bot Oct 6, 2026
@danielhanchen

Copy link
Copy Markdown
Member Author

@codex review

1 similar comment
@danielhanchen

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Keep them coming!

Reviewed commit: dffa311fe5

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@danielhanchen
danielhanchen merged commit cb78293 into main Oct 6, 2026
@danielhanchen
danielhanchen deleted the laya-decision-compile branch October 6, 2026 15:39
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.

3 participants