Derive warmup steps from a model default that gives a ratio by vineethsaivs · Pull Request #8723 · unslothai/unsloth · GitHub
Skip to content

Derive warmup steps from a model default that gives a ratio - #8723

Merged
oobabooga merged 3 commits into
unslothai:mainfrom
vineethsaivs:fix/model-defaults-warmup-ratio
Aug 17, 2026
Merged

Derive warmup steps from a model default that gives a ratio#8723
oobabooga merged 3 commits into
unslothai:mainfrom
vineethsaivs:fix/model-defaults-warmup-ratio

Conversation

@vineethsaivs

Copy link
Copy Markdown
Contributor

Problem

model_defaults may express warmup either as warmup_steps or as warmup_ratio, and the backend accepts both. studio/backend/core/training/worker.py is explicit about it:

# Warmup: prefer warmup_steps; fall back to warmup_ratio
warmup_steps = config.get("warmup_steps")
warmup_ratio = config.get("warmup_ratio")
if warmup_steps is None and warmup_ratio is not None:
    warmup_steps = int(round(warmup_ratio * max_steps))

The form only ever read warmup_steps:

const warmupSteps = toNumber(training?.warmup_steps);
if (warmupSteps !== undefined) patch.warmupSteps = warmupSteps;

and BackendTrainingDefaults did not declare warmup_ratio at all, so there was no way to read it. A config that gives only the ratio therefore never reaches the Warmup Steps field, which keeps the generic default of 5 from src/config/training.ts.

Ten of the 78 shipped configs are in exactly that shape, and none of them also sets warmup_steps, so there is no second source the value could arrive from:

config warmup_ratio max_steps intended steps what the form showed
default.yaml 0.1 30 3 5
gemma/unsloth_gemma-3-4b-pt.yaml 0.03 30 1 5
gemma/unsloth_gemma-3n-E4B.yaml 0.03 30 1 5
other/unsloth_tinyllama-bnb-4bit.yaml 0.1 30 3 5
qwen/unsloth_Qwen2.5-1.5B-Instruct.yaml 0.1 30 3 5
embedding/*.yaml (5 files) 0.03 30 1 5

default.yaml is the fallback for every model that has no specific config, so this is not limited to the four named models.

The embedding five are the mildest case, because mappers.ts separately hardcodes warmup_ratio: isEmbedding ? 0.03 : null into the start payload, so the backend ends up with the right number regardless of what the field showed. The other five have nothing filling the gap.

Fix

Read warmup_ratio when warmup_steps is absent, and convert it the way the worker already does. max_steps is read first so the conversion has it.

const warmupSteps = toNumber(training?.warmup_steps);
if (warmupSteps !== undefined) {
  patch.warmupSteps = warmupSteps;
} else {
  const warmupRatio = toNumber(training?.warmup_ratio);
  if (warmupRatio !== undefined && maxSteps !== undefined && maxSteps > 0) {
    patch.warmupSteps = Math.round(warmupRatio * maxSteps);
  }
}

An explicit warmup_steps still wins, matching the worker's precedence. A ratio with no usable max_steps leaves the field alone rather than writing a zero, since a zero would be a worse default than the existing 5.

Deliberately not done: adding a Warmup Ratio field to the UI. That is a bigger change and a product decision, and the shipped configs only need the value to arrive.

Test

tests/training-model-defaults-warmup-ratio.test.ts, four cases. The last one walks all 78 shipped configs and asserts that any config declaring a warmup in either spelling produces a numeric warmupSteps, so a future config written with a ratio cannot regress silently. It also asserts it found more than 50 configs, so a move or rename fails loudly rather than leaving the test checking nothing.

                                                       before        after
a warmup_ratio default reaches the form as steps       FAILED        passed
                                                       (undefined,
                                                        expected 3)
an explicit warmup_steps still wins over a ratio       passed        passed
a ratio with no usable max_steps leaves warmup alone   passed        passed
every shipped model default carries its warmup ...     FAILED        passed
                                                       (default.yaml
                                                        declares a warmup
                                                        but none reached
                                                        the patch)

Two of the four pass either way on purpose: they pin the behaviour the change could have broken (precedence, and not writing a zero) rather than reproducing the bug.

Whole suite, npm test: 2277 passed / 0 failed before, 2281 passed / 0 failed after, so the four new cases are the entire delta. npm run typecheck is clean. biome check on the two changed source files reports the same 40 warnings before and after, and the new test file carries the same noNodejsModules and useNamingConvention warnings the neighbouring test files already do, with formatting applied.

model_defaults may express warmup either way, and the backend accepts both:
core/training/worker.py prefers warmup_steps and falls back to
round(warmup_ratio * max_steps). The form only ever read warmup_steps, and
BackendTrainingDefaults did not even declare warmup_ratio, so a config that
gives only the ratio left the Warmup Steps field on its generic default.

Ten of the shipped configs are in that shape, none of which also set
warmup_steps:

    default.yaml                                warmup_ratio 0.1
    gemma/unsloth_gemma-3-4b-pt.yaml            warmup_ratio 0.03
    gemma/unsloth_gemma-3n-E4B.yaml             warmup_ratio 0.03
    other/unsloth_tinyllama-bnb-4bit.yaml       warmup_ratio 0.1
    qwen/unsloth_Qwen2.5-1.5B-Instruct.yaml     warmup_ratio 0.1
    embedding/*.yaml (5 files)                  warmup_ratio 0.03

default.yaml is the fallback for every model without a specific config, so
this is not limited to the four named ones.

Read warmup_ratio when warmup_steps is absent and convert it the same way the
worker does. An explicit warmup_steps still wins, and a ratio with no usable
max_steps leaves the field alone rather than writing a zero.
@oobabooga

Copy link
Copy Markdown
Member

@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: 1cd39eb21b

ℹ️ 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".

// studio/backend/core/training/worker.py does when it falls back.
const warmupRatio = toNumber(training?.warmup_ratio);
if (warmupRatio !== undefined && maxSteps !== undefined && maxSteps > 0) {
patch.warmupSteps = Math.round(warmupRatio * maxSteps);

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 Match the backend's tie rounding for warmup steps

When a model or imported YAML produces a half-step value, such as warmup_ratio: 0.05 with max_steps: 50, Math.round derives 3 steps, while the referenced backend fallback in worker.py uses Python's int(round(...)) and derives 2 because Python rounds ties to even. Since the frontend submits the derived value as warmup_steps, this silently changes the effective training configuration; use rounding semantics that match the backend.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Declining this one. The int(round(...)) at studio/backend/core/training/worker.py:2979 runs only when warmup_steps is None and warmup_ratio is not None, and it sits inside _run_mlx_training, which rejects embedding runs outright at worker.py:2612. mappers.ts:121-122 sends warmup_steps: isEmbedding ? null : config.warmupSteps and warmup_ratio: isEmbedding ? 0.03 : null, with config.warmupSteps a non-nullable number defaulting to 5 (src/config/training.ts:83). Every run that can reach that branch therefore arrives with a concrete warmup_steps and a null ratio, so the condition is never satisfied.

The comparison also assumes the alternative outcome is 2 steps. Without this change the field carries the generic default 5, not 2, which is the bug being fixed, so there is no competing derivation to diverge from.

Where the backend does own a ratio, the operative semantics are ceil, not round. Embedding runs send warmup_ratio (mappers.ts:122 into worker.py:5208), and trainer.py:3790 prefers warmup_ratio over warmup_steps when both are present. Both hand it to TrainingArguments.get_warmup_steps, which is math.ceil(num_training_steps * ratio). ceil(2.5) is 3 and agrees with Math.round, while int(round(2.5)) is 2 and is the outlier, so matching it would move the derived value away from what the backend actually applies to a ratio.

No shipped config produces a tie either: 0.1 * 30 is 3.0 and 0.03 * 30 is 0.8999999999999999.

@oobabooga

Copy link
Copy Markdown
Member

@codex review

1 similar comment
@oobabooga

Copy link
Copy Markdown
Member

@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: 1cd39eb21b

ℹ️ 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".

// studio/backend/core/training/worker.py does when it falls back.
const warmupRatio = toNumber(training?.warmup_ratio);
if (warmupRatio !== undefined && maxSteps !== undefined && maxSteps > 0) {
patch.warmupSteps = Math.round(warmupRatio * maxSteps);

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 Use ceiling semantics when materializing a warmup ratio

For a non-embedding CUDA config where warmup_ratio * max_steps is below a half-integer (for example, 0.01 * 30), this writes warmupSteps: 0, and mappers.ts then submits that concrete step count instead of the ratio. The CUDA _train_worker passes a ratio to SFTConfig, whose Hugging Face TrainingArguments derives warmup with ceil, so the same config should have one warmup step rather than none. This is fresh evidence beyond the earlier MLX-only rounding example: it affects custom/imported ratios on the generic CUDA training path.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Correct, and fixed in e18856c. Math.round writes 0 whenever the product is under 0.5, so an imported config such as warmup_ratio: 0.03 with max_steps: 10 asked for warmup and would have trained with none. That is the same outcome the max_steps guard on the branch below already exists to avoid, so the two halves of the derivation disagreed with each other.

Math.ceil is what TrainingArguments.get_warmup_steps uses to materialize a ratio, which is the semantics every backend path applies wherever it owns one, and it produces identical values for all 78 shipped configs (0.1 * 30 is 3 and 0.03 * 30 is 1 under either operator). An explicit warmup_ratio: 0 still maps to 0.

One correction on the route you describe: for a non-embedding run mappers.ts:137 sends warmup_ratio: null, so _train_worker takes the warmup_steps branch at trainer.py:4044 and passes the literal 0 rather than a ratio. The conclusion is unaffected, since 0 is wrong on its own terms.

Regression test added in tests/training-model-defaults-warmup-ratio.test.ts, "a ratio too small to reach one step still gets one". It fails against the previous Math.round with actual: 0, expected: 1.

@oobabooga

Copy link
Copy Markdown
Member

Notes on the red board, since none of it came from this change.

  • Repo tests (CPU) and the (Python 3.10/3.11/3.12/3.13) matrix: Backend CI is red on main as well. Its latest run on main (def58ea6) fails at tests/studio/test_multi_chat_prompt_queue_contract.py::test_composer_only_queues_behind_the_current_chat on assert 'startHydratedPromptQueue(' in submit, which has nothing to do with warmup. These stay red here until that is fixed on main.
  • Core (HF=...) x3 and pip scan-packages :: hf-stack / :: studio: both workflows are green on current main. The runs here were from 2026-08-13, against a base that had fallen 107 commits behind. pip scan-packages :: studio also stopped at exactly 30m0s, so it hit the job timeout rather than finding anything.
  • Chat UI Tests: that run's conclusion is cancelled, not failed, which is concurrency.cancel-in-progress superseding it. The same workflow shows cancelled on main whenever a newer push lands.

I merged current main into the branch so the next run reports against a current base, and pushed e18856c, which switches the ratio derivation from Math.round to Math.ceil. Reasoning is in the thread on model-defaults.ts.

On the merged tree the frontend suite is 3417 passed / 0 failed, npm run typecheck is clean, and biome reports no new rule on the two changed files (the six added warnings on the test file are all useNamingConvention, from the snake_case backend keys the existing cases already use).

@oobabooga

Copy link
Copy Markdown
Member

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. 🚀

Reviewed commit: e18856c031

ℹ️ 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".

@oobabooga

Copy link
Copy Markdown
Member

CI finished on the merged base: 31 of 34 green, and all three reds trace off this change.

Repo tests (CPU) is main's. It reports 54 failures, in tests/studio/test_model_picker_contracts.py (2) and tests/studio/test_new_chat_context_recount.py (52). I ran both files against a clean detached checkout of 2819e2947, the exact main commit merged in here, with none of this PR's content present:

                                    clean main 2819e2947    this branch e18856c03
test_model_picker_contracts.py       2 failed, 183 passed    2 failed, 183 passed
test_new_chat_context_recount.py    52 failed,   1 passed   52 failed,   1 passed

Identical, and 52 + 2 is the 54 CI reports. Both files assert on the source text of studio/frontend/src/features/chat/..., which this change does not touch. (test_model_picker_contracts.py:99 does read features/training/api/models-api.ts, but that is test_models_api_sends_token_via_header_not_query, and it passes.)

(Python 3.10) and Chat UI Tests are not failures at all. Both are cancelled at a job-level timeout-minutes: 30 (studio-backend-ci.yml:64, studio-ui-smoke.yml:62), which gh pr checks renders as fail:

  • The Python matrix in that run went 3.13 at 27m00s, 3.12 at 27m59s, 3.11 at 29m36s, all green, and 3.10 at 30m10s. It lost the cap by ten seconds.
  • Chat UI Tests logged [ui] permission-only run passed on all three engines, [indicator] 38/38 checks passed three times, and [banner] 1432 checks, 0 failed followed by 221 checks, 0 failed twice, with the cancel landing right after the last one. No failed check before the cap. The comment above that timeout already records the job overrunning its previous 25 minutes, so it is running close to the line by design.

One thing worth separating out: Unsloth UI CI has no completed run on main in its last 100 runs, every one superseded by concurrency.cancel-in-progress, so that workflow currently has no baseline verdict on main to compare a PR against.

My earlier note named test_composer_only_queues_behind_the_current_chat from main's run at def58ea6. main's specific failure set has moved on since then; the breakage has not.

@oobabooga

Copy link
Copy Markdown
Member

@oobabooga
oobabooga merged commit a4fa451 into unslothai:main Aug 17, 2026
31 of 34 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants