Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Appearance settings

feat(mlx): true grad-norm reporting and compiled global-norm clipping with gradient accumulation#922

Merged
danielhanchen merged 3 commits into
unslothai:mainunslothai/unsloth-zoo:mainfrom
Lyxot:fix/mlx-grad-norm-clip-compileLyxot/unsloth-zoo:fix/mlx-grad-norm-clip-compileCopy head branch name to clipboard
Jul 23, 2026
Merged

feat(mlx): true grad-norm reporting and compiled global-norm clipping with gradient accumulation#922
danielhanchen merged 3 commits into
unslothai:mainunslothai/unsloth-zoo:mainfrom
Lyxot:fix/mlx-grad-norm-clip-compileLyxot/unsloth-zoo:fix/mlx-grad-norm-clip-compileCopy head branch name to clipboard

Conversation

@Lyxot

@Lyxot Lyxot commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

Summary

Two changes to the MLX trainer's gradient-norm handling, one commit each:

  1. Report the true gradient norm, behind an opt-in flag when global-norm clipping is off. For non-global-clip runs, grad_norm was previously reconstructed from Adam's second-moment state. That reconstruction is exact only for uninterrupted Adam/AdamW and has several defects: it measures the wrong quantity (the gradient after value/leaf clipping and LoRA+/embedding-LR scaling, not the pre-clip norm global clipping reports); it is wrong on the first step after resume_from_checkpoint (its closure-local previous-v accumulator restarts at zero while the restored optimizer state does not); it silently reports nothing for Lion (which exposes betas but has no v state); it skips Adafactor/SGD/Muon with a one-time warning; and it forces an extra host-side evaluation every logging step. Measured impact: under default leaf clipping it understates the true pre-clip norm by 49-69% (median 59%; same-trajectory Gemma3-270M run, per-step aligned); on the first step after a resume it overshoots 2.1x-6.4x depending on accumulated optimizer state; where no clipping binds the two agree to float noise (max 8.7e-7 relative). See the cross-backend section for the CUDA-anchored measurements. This PR deletes the reconstruction and computes the true fp32 global L2 norm in-graph inside the update path.
  2. Enable mx.compile for global-norm clipping with gradient accumulation. The trainer disabled compilation whenever max_grad_norm > 0 was combined with gradient_accumulation_steps > 1. That guard predates the current clipping implementation: git log -S shows it landed with the original MLX training path, while the lazy fp32 _clip_grad_norm_fp32 (pure MLX ops, no host sync) arrived a month later — and clipped single-step training already ran compiled. The guard is removed; compiled clip+accum is validated below. After merging current main, this also removes the finite-text shape planner's compile_ineligible_global_norm branch (and its pinning test case): Fix MLX Metal resource-limit crash in finite text training via CPU batch plans and bounded compile signatures #918 added that branch to mirror the old guard, and keeping it would have silently kept single-process clipped accumulation eager. The e2e tests inject a FiniteTextBatchPlan so compiled runs stay compile-eligible under Fix MLX Metal resource-limit crash in finite text training via CPU batch plans and bounded compile signatures #918's shape guard, and the compiled clip+accum bitwise test now exercises the planned path end to end.

Behavior changes (please read before reviewing)

  • Runs without global-norm clipping no longer display a grad-norm — console output, train/grad_norm in W&B/TensorBoard, and the grad-norm history — unless global-norm clipping is the resolved clip mode or the user sets the new MLXTrainingConfig(report_grad_norm=True). Standard TRL-driven runs (the notebooks) are unaffected: TRL's SFTConfig defaults max_grad_norm=1.0, which forwards to the MLX trainer and keeps them on the global-clip path — verified on the Gemma3-270M notebook settings (byte-identical losses, gradient norms, adapter weights, and log format vs main). The absence applies where max_grad_norm resolves to 0: direct MLXTrainingConfig(...) construction with defaults, or the Unsloth Studio worker, which currently passes 0. The legacy 9-argument step callback keeps its exact signature and receives None as its final argument in that case. Rationale: the old fallback value was misleading in the ways listed above, and HF Trainer likewise reports no gradient norm when norm clipping is off. The flag defaults off because the reporting reduction has the same class of peak-memory cost as global clipping itself.
  • Clip+accum users now train compiled. Loss curves will not bit-match previous eager runs: eager and compiled execution produce different, individually deterministic rounding trajectories in low precision — a pre-existing property visible at multi-billion-parameter scale. compile=False reproduces the previous eager behavior exactly.

Design notes

  • report_grad_norm is captured as a Python constant before the step closures are built, so each run has exactly one compiled step graph (a runtime condition would add a trace signature per reporting state). With the flag off and no global clipping there is no reporting reduction, scalar output, or evaluation in the graph at all — default leaf/value/no-clip training is numerically and memory-wise unchanged.
  • Reporting never changes update numerics. The grad_accum == 1 fast path keeps its exact update on raw gradients and derives the reported value from a norm-only copy that reproduces the accumulated path's token multiply/divide rounding, so the value is numerically identical to the norm global clipping computes for bf16/fp32 models (fp16 leaves are weighted in fp32 because fp16 cannot represent token counts >= 65520).
  • The reported quantity is the norm of the token-normalized gradient: after the accumulation divide, DDP reduction, and LoRA+/embedding-LR ratios; before any clipping and before weight decay — matching what the global-clip path already reported. Under DDP it is computed after the cross-rank reduction, so all ranks report the same value.
  • The norm is evaluated in the same mx.eval boundary as model/optimizer state (previously a separate early evaluation); .item() happens only at the logging boundary.
  • The field is appended last in MLXTrainingConfig, so every pre-existing field keeps its positional index in the custom __init__; the wholesale-config-copy detection treats dumps that predate the field as complete copies, keeping warmup_ratio precedence unchanged for older serialized configs.

Validation

Unit/regression tests (Apple Silicon, real MLX) in tests/test_mlx_training_e2e_metal.py: a bitwise update-invariance test (the flag changes no parameter or optimizer-state bit; compiled, bf16, dtypes pinned); an independent-oracle norm test covering value and placement across clip modes, optimizers (including Lion, and SGD with nonzero coupled weight decay to pin that decay is excluded), eager and compiled execution, and accumulation 1-3; a bitwise eager-vs-compiled parity test for clipped accumulation on tiny models; a regression asserting clip+accum compiles at full-step scope without the old disable message; and an injected-failure test pinning that an evaluation-time error after the compiled step propagates rather than triggering a silent eager retry.

Production-scale validation (Qwen/Qwen2.5-VL-3B-Instruct, full BF16, LoRA r=16, chunked-CE loss, sequence lengths cycling 1024/1536/2048 with two 448x448 images per sample, batch 1, accumulation 4, fresh process per run, two repetitions per variant):

  • Reporting off: peak allocator memory in default leaf, value-clip, and no-clip modes unchanged vs the pre-change trainer (raw byte ratios at most 1.0000016).
  • Reporting on: peak memory within 2.8% of what compiled global clipping itself costs (the documented equal-cost intent); throughput unchanged at 3B (identical steady-state tokens/sec). The cost inverts at small scale: a 270M LoRA run costs no peak memory but about 7% throughput (interleaved repetitions, tight variance) — per-leaf reduction launch overhead against a fast step, another reason the flag defaults off.
  • Compiled clip+accum halves peak memory vs eager clipping (12.3 vs 22.5 GiB) and adds zero compile trace signatures after warm-up vs compiled unclipped training (equal trace counts, stable through the measured window).
  • Compiled runs are bit-deterministic across fresh processes (losses, reported norms, SHA-256 digests of every parameter and optimizer-state leaf), and strict vs best-effort compile modes are bit-identical.
  • Eager-vs-compiled loss divergence for clipped accumulation stays below the untouched unclipped control pair's (max per-step relative delta 0.73x the control's; median 2.25x, within the 3x bound) — the guard removal adds no divergence beyond pre-existing eager/compiled rounding. A simulated corruption (clipping dropped) exceeds the control envelope by 7.8x, so the comparison discriminates real errors.

Notebook parity: the Gemma3-270M notebook's exact settings (public FastModel API, 16-bit load, LoRA r=128 on q/k/v/o/gate/up/down, batch 4, adamw_8bit, seed 3407, 10 steps on a fixed dataset) produce byte-identical loss history, gradient norms, and adapter-tensor SHA-256 digests between main and this branch — and byte-identical again with report_grad_norm=True enabled.

# focused
python -m pytest tests/test_mlx_training_e2e_metal.py -q
# broader selection
python -m pytest tests/ -q -k "mlx and (grad or clip or norm or trainer)"

Cross-backend validation against CUDA

The reported quantity and the deleted value's defects were measured against the CUDA path as an external reference. In global-clip mode — the path the notebooks use — 30 steps of the same Gemma3-270M LoRA configuration (uniform-length data, max_grad_norm=1.0 on both backends) put the MLX-reported norm within 4.1% of CUDA's HF-reported median grad-norm with 0.996 curve correlation (loss correlation 0.99995).

The two defect measurements used 64 identical dataset rows (verified identical token IDs and batch composition on both backends), removing batch-content and data-order differences so per-step cross-backend comparison is meaningful; backend rounding still compounds across updates, so early steps carry the strongest signal (MLX adamw, CUDA the matched adamw_torch). CUDA ran max_grad_norm=1e9 — never binding — so the HF trainer logs its standard pre-clip global norm.

  • Leaf-clip understatement. MLX trained in its default mode (resolved 1.0 per-leaf L2 clip); CUDA replicated it with an on_pre_optimizer_step callback rescaling each parameter gradient to L2 <= 1.0, after HF's norm logging and before the optimizer step (ordering validated in-run to 6.2e-8 max relative error). The branch's norm tracks CUDA's pre-clip norm to a 2.6% median gap over 12 steps (2.3% over steps 1-6); the old reconstruction sits 53.5% below CUDA at the first-six-step median (up to 68.6% on the earliest steps), converging late once few leaves still exceed the threshold. Direct wrong-quantity confirmation: over those early steps the old value matches CUDA's post-leaf-clip global norm to a 0.5% median gap — it reported the clipped-gradient scale, while this branch and CUDA report the pre-clip norm.
  • Resume overshoot. All variants unclipped, 12 steps, checkpoint at step 6, then resumed. Boundary continuity (step-7 norm over the mean of steps 6 and 8): CUDA 1.02, this branch 1.04, old reconstruction 6.43 (62.17 against a ~9.7 neighborhood). The branch's resumed norms exactly equal its uninterrupted baseline at every post-resume step and sit within 1.1% of CUDA at the boundary; CUDA shows no boundary artifact. The spike is confined to reporting — old and new loss histories are bitwise-equal through and after the resume. The 2.1x end of the summary's range came from a small controlled model; here it is 6.4x — the magnitude grows with the second-moment state accumulated before the checkpoint.

Both defect experiments produced bitwise-identical main-vs-branch MLX loss histories (update-invariance on real runs, not only in the unit test), and the CUDA reference ran stock HF/TRL semantics — pre-clip norm, no resume artifact — the semantics this PR adopts.

Known pre-existing test-suite issues (not addressed here)

Combined MLX test runs order-couple a few failures that also occur on main: tests/test_mlx_save_lora_adapters_filter.py installs the torch-backed simulation shim only when real MLX is not already imported, so its torch mocks can reach the real mx.save_safetensors and raise std::bad_cast; tests/test_mlx_adapter_metadata_persistence.py installs the shim without teardown, which can hijack later MLX imports in real-runtime modules (why the new tests bind their MLX imports at module scope). Fixture repair is left to a follow-up so this PR stays scoped; this branch introduces no new failures relative to main (failure sets verified name-identical).

Coordination

The HF-callbacks rework in #873 touches the same logging loop; whichever lands second should rebase, and the norm-reporting behavior here (computed every optimizer update when active, surfaced at logging boundaries) is the cadence that rebase should preserve. Related user demand for richer MLX training telemetry: unslothai/unsloth#5138.

Copilot AI review requested due to automatic review settings July 20, 2026 13:51

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code Review

This pull request introduces true global gradient norm reporting in the MLX trainer when global-norm clipping is disabled, controlled by a new report_grad_norm configuration option. It refactors the gradient norm calculation into a unified _global_grad_norm_fp32 helper, removes the legacy, approximate Adam-state-based norm estimation, and updates both standard and fast-path (grad_accum == 1) update steps to support exact norm reporting. Additionally, it enables compilation (mx.compile) for global norm clipping with gradient accumulation and consolidates evaluation boundaries to prevent redundant graph executions. Comprehensive tests have been added to verify gradient norm reporting and compilation behavior. There are no review comments, so I have no feedback to provide.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Note

Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-code-review.yml file specifying one with the runs-on attribute. See the docs for more details.

This PR updates MLX trainer gradient-norm handling to (1) optionally report the true pre-clip global grad norm when global-norm clipping is off, and (2) allow mx.compile to remain enabled for global-norm clipping with gradient accumulation.

Changes:

  • Add in-graph fp32 global grad-norm reduction for accurate reporting (opt-in when not using global-norm clipping).
  • Remove the compilation disable-guard for max_grad_norm > 0 with gradient_accumulation_steps > 1.
  • Extend/adjust MLX Metal and DDP tests to validate reporting semantics and compiled clip+accum behavior.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 3 comments.

File Description
unsloth_zoo/mlx/trainer.py Adds _global_grad_norm_fp32, optional reporting path, and removes compile disable-guard for clip+accum; unifies eval boundary.
tests/test_mlx_training_e2e_metal.py Adds end-to-end Metal tests for grad-norm reporting matrix, numeric invariance, compiled clip+accum parity, and eval-failure propagation.
tests/test_mlx_trainer_internals.py Updates internals test to validate the new _global_grad_norm_fp32 helper.
tests/test_mlx_ddp_metal.py Updates mlx.launch invocation format for newer launcher behavior.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread unsloth_zoo/mlx/trainer.py Outdated
Comment thread unsloth_zoo/mlx/trainer.py Outdated
Comment thread unsloth_zoo/mlx/trainer.py Outdated
@Lyxot

Lyxot commented Jul 20, 2026

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. You're on a roll.

Reviewed commit: 63b67a12c5

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

@Lyxot
Lyxot force-pushed the fix/mlx-grad-norm-clip-compile branch from 63b67a1 to 7be14ef Compare July 20, 2026 15:54

@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: 7be14eff04

ℹ️ 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 thread unsloth_zoo/mlx/trainer.py Outdated
@Lyxot
Lyxot force-pushed the fix/mlx-grad-norm-clip-compile branch from 7be14ef to 63b67a1 Compare July 20, 2026 16:06
Lyxot added 2 commits July 21, 2026 00:06
Replace the Adam-state norm reconstruction (wrong norm-space, broken after
resume, silent for Lion, absent for Adafactor/SGD/Muon, forced host eval)
with the true fp32 scaled-data-gradient norm computed in-graph: always when
global-norm clipping is the resolved mode, and behind the new construction-
time report_grad_norm config (default False - no reporting reduction in the
graph) otherwise. The accum-1 fast path keeps its exact update and derives
the reported value from a norm-only copy reproducing the accumulated path's
rounding (bf16/fp32 bitwise cross-mode contract; fp16 leaves weighted in
fp32). Norm evaluation joins the single mx.eval boundary. Repair the
mlx.launch DDP test invocation for MLX 0.32.
Remove the guard that disabled mx.compile when max_grad_norm > 0 with
grad_accum > 1. It predated the lazy fp32 clip implementation: the clipping
math is pure lazy MLX ops, and the production gate on a 3B BF16 VLM shows
compiled clip+accum is bitwise-deterministic across processes, adds zero
compile signatures after warm-up versus unclipped compiled training, tracks
eager within the pre-existing eager-vs-compiled envelope (max 0.73x the
unclipped control's divergence), and halves peak memory versus eager
(12.3 GiB vs 22.5 GiB). Tiny-model regressions pin bitwise eager/compiled
parity, trace equality, guard absence, and evaluation-time failure
propagation without eager retry.
@Lyxot
Lyxot force-pushed the fix/mlx-grad-norm-clip-compile branch from 63b67a1 to 2775572 Compare July 20, 2026 16:07
@Lyxot

Lyxot commented Jul 21, 2026

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Hooray!

Reviewed commit: 277557249e

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

Semantic resolutions beyond textual conflicts:

- Take main's mlx.launch invocation in tests/test_mlx_ddp_metal.py verbatim (unslothai#918 independently fixed the MLX 0.32 launcher, so this branch no longer carries its own variant).
- Remove the finite-text shape planner's compile_ineligible_global_norm branch: it mirrored the clip+accum compile guard this PR removes and would have kept single-process clipped accumulation eager. The trainer-internals case pinning that reason becomes a case pinning the opposite: clipped accumulation plans and stays compile-eligible.
- Add compile_max_variants (new config field from unslothai#918) to the appended-field set of the wholesale-config-copy detection so serialized configs predating it keep their warmup_steps/warmup_ratio precedence.
- Build the metal e2e grad-norm test batches as a FiniteTextBatchPlan instead of a raw list so single-process compiled runs stay compile-eligible under the new shape guard.
Copilot AI review requested due to automatic review settings July 22, 2026 06:35

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

@Lyxot

Lyxot commented Jul 22, 2026

Copy link
Copy Markdown
Contributor Author

staging CI pass in Lyxot#4
图片

@danielhanchen
danielhanchen merged commit 1d91646 into unslothai:main Jul 23, 2026
1 of 11 checks passed
Lyxot added a commit to Lyxot/unsloth-zoo that referenced this pull request Jul 24, 2026
…unslothai#931/unslothai#933/others)

Merge origin/main into the streaming branch. Only unsloth_zoo/mlx/trainer.py
conflicted, in MLXTrainingConfig.__init__'s wholesale-copy detection: unslothai#922
(true grad-norm reporting + compiled global-norm clipping) replaced this
branch's _MLX_CONFIG_OPTIONAL_COPY_FIELDS-based check with its own
_appended_fields set {compile_max_variants, report_grad_norm}, and neither set
alone covers the merged config. Reconciled to the union: warmup copy-detection
now tolerates every appended field (the positional optional-copy suffix plus
the mid-order compile_max_variants). unslothai#922's report_grad_norm landed as the new
last field, so it joins _MLX_CONFIG_OPTIONAL_COPY_FIELDS to keep every
post-image_size field copy-optional and preserve the positional-copy suffix
invariant. Validation on the merged tree: 242 unit + 16 metal + 25
recorded-validation checks pass (grad-norm and streaming/prefetch loops
coexist).
Lyxot added a commit to Lyxot/unsloth-zoo that referenced this pull request Jul 24, 2026
The main merges left this branch's streaming config fields (max_eval_batches, streaming_text_length_window_batches, streaming_prefetch_batches) before label_smoothing_factor (unslothai#930) and report_grad_norm (unslothai#922), which each intentionally declared themselves last to preserve positional indices. A positional copy from a main-lineage config therefore bound its label_smoothing_factor / report_grad_norm values to this branch's fields, silently disabling label smoothing or failing lazy-eval validation. Move the streaming fields to the very end so every pre-existing (including main-appended) field keeps its positional index; _MLX_CONFIG_OPTIONAL_COPY_FIELDS again holds exactly this branch's trailing suffix, while the wholesale-copy warmup detection tolerates all appended fields (compile_max_variants, label_smoothing_factor, report_grad_norm) so legacy dumps missing them are still recognized.
BardiaKoopah added a commit to BardiaKoopah/unsloth-zoo that referenced this pull request Jul 24, 2026
Resolve the trainer.py conflict in the gradient-norm reporting region.

The branch carried the optimizer-state grad-norm helpers from the original
MLX path (_grad_norm_from_optimizer_state / _optimizer_v_total, unslothai#634), which
recover ||g|| from an Adam-family optimizer's second moment. main's unslothai#922
replaced that approach with true grad-norm reporting computed directly from
the gradient tree (_global_grad_norm_fp32 / report_grad_norm), which is
optimizer-agnostic and covers the rmsprop/adamax/adagrad/adadelta optimizers
this PR adds. Took main's side: the branch's call sites were already removed
by main's changes in the auto-merged region, so dropping the helper
definitions leaves no orphaned callers, and all four new optimizers still
build.

Note for @danielhanchen: your 35e8a10 helper _mlx_optimizer_state_norm_supported
and its test test_optimizer_state_norm_supported_excludes_adamax are left in
place but are now unused in production, since unslothai#922's direct grad-norm reporting
supersedes the optimizer-state path they gate. Left untouched rather than
deleting your work -- happy to remove them if you'd prefer, or keep them for a
future optimizer-state reporting path.
danielhanchen added a commit to Lyxot/unsloth-zoo that referenced this pull request Jul 26, 2026
Resolve the trainer conflicts against the batch-plan preflight (unslothai#918) and
the true grad-norm reporting rework (unslothai#922):

- union the dataclasses import (field plus replace)
- keep main's preflight / _resolve_training_steps structure and re-derive
  _epoch_stop_total_microbatches from batches after grad_accum is known
- keep the committed/pending metric window split and fold it into main's
  single mx.eval boundary, dropping the optimizer-state grad-norm fallback
  that main removed
- keep both appended test blocks in test_mlx_trainer_internals.py
danielhanchen added a commit that referenced this pull request Jul 27, 2026
…-grouped batching, and background prefetch (#927)

* feat(mlx): lazily batch streaming text rows

* feat(mlx): define text stream replay semantics

* fix(mlx): preserve lazy text tokenization state

* fix(mlx): manage lazy stream iterator lifecycle

* fix(mlx): validate lazy text stream boundaries

* feat(mlx): prepare lazy response-masked streams

* feat(mlx): stream text eval and sized epochs

* test(mlx): cover streaming eval contracts

* feat(mlx): dispatch lazy text streams from rank zero

* feat(mlx): expose prepared streaming text parity

* feat(mlx): length-group lazy text stream batches in a bounded window

* feat(mlx): rebuild lazy VLM streaming on the text lifecycle contracts

* refactor(mlx): stage lazy text batches host-side behind one MLX finalizer

* feat(mlx): prefetch host-staged text stream batches on a producer thread

* refactor(mlx): stage VLM label placement host-side behind the finalizer

* feat(mlx): prefetch host-staged VLM stream batches on the producer thread

* fix(mlx): stage processor-owned MLX outputs opaquely for VLM prefetch

* fix(mlx): materialize opaque VLM payloads and stage prompt/completion opaquely

Lazy MLX graphs cannot be evaluated from another thread, so opaque processor-owned payloads now cross the prefetch boundary through a producer-side materialization barrier (mx.eval of the processor's own pending graphs on the thread that issued them; Unsloth still introduces no new MLX computation producer-side). Prompt/completion streams from MLX-returning processors previously rejected in producer mode; they now stage via a pc_opaque carrier whose concat/truncation/completion-mask/label decisions all run in the consumer-thread finalizer through the extracted _combine_vlm_prompt_completion_inputs helper, bit-identical to the synchronous legacy route.

* fix(mlx): reject MLX-valued text rows before producer-side parsing

Raw dataset rows and formatter results carrying MLX arrays previously reached truthiness probes and row parsing before any provenance guard, so a prefetch producer crashed with a misleading conversion error (an Unsloth-issued MLX evaluation off the consumer thread). In producer mode the lazy text row iterator now scans each row on entry and each formatter result immediately, rejecting with the actionable streaming_prefetch_batches=0 remedy before anything can touch the values. Synchronous behavior is unchanged.

* fix(mlx): carry tokenizer scalars instead of the live processor in pc_opaque

The pc_opaque carrier stored the live processor and the consumer finalizer read its tokenizer padding side and pad id, racing the producer's next processor call (the queue slot is released before finalization), which violated the producer's exclusive processor ownership. The collator now snapshots flush_side and pad_id immediately after the processor calls while the producer still exclusively owns the processor, and the combine helper consumes those immutable scalars without ever dereferencing the processor; a poisoned-tokenizer regression locks the non-dereference. Host-valued non-integer ids also get their own diagnostic instead of the misleading MLX-array provenance message, and the opaque finalize comment now describes the permitted materialization barrier instead of claiming no producer-side MLX op ran.

* test(mlx): route the P/C parity regression through the unsized producer path

The prompt/completion parity block fed a plain list, which routes to the sized synchronous branch where prefetch_batches is ignored, so the committed test never crossed the pc_opaque producer/consumer boundary it claimed to lock. The block now streams a replayable unsized source (verified to create the prefetcher), covers both completion_only_loss=None and False with label-dtype parity, and keeps the poisoned-tokenizer ownership assertion.

* fix(mlx): harden streaming eval metadata sync and iterator teardown

Rank-0 metadata resolution now synchronizes any BaseException (KeyboardInterrupt and SystemExit included) through the status collective before re-raising on rank 0, so peer ranks cannot hang in the all_sum when rank 0 is interrupted mid-resolve. Infinite-stream detection reads the private datasets attributes (ex_iterables, stopping_strategy) with getattr fallbacks so a rename in a future release degrades detection instead of failing eval for finite streams whose wrapper class matches by name. The lazy eval batch view closes its underlying iterator in a finally, releasing the pass's owned source cursors when max_eval_batches truncates or the consumer exits early.

* test(mlx): pin the DDP probe interpreter via launch argv

The ring launcher bash-execs the command array verbatim and does not apply --python to it, so the probe relied on a shebang plus chmod to select the venv interpreter. Passing sys.executable as the command's first element removes that dependency and handles interpreter paths the shebang mechanism cannot (spaces, length limits).

* fix(mlx): synchronize owner-dispatch failures for any BaseException

The rank-0 contract check and the per-batch owner pull both guarded only Exception, so a KeyboardInterrupt or SystemExit raised by the source, formatter, or tokenizer on rank 0 unwound past the dispatch collective while peer ranks blocked in all_sum indefinitely. Both handlers now capture BaseException, broadcast the failure status through the collective, and re-raise the original error on rank 0 — the same contract the rank-0 metadata resolver already follows.

* fix(mlx): reject non-integer max_eval_batches values

int() coercion silently accepted fractional values and booleans (1.9 and True both became 1), changing how much eval data is scored while the error contract promises a positive integer. Non-integral values now reject with the existing actionable message; exact integral values (including integer-valued floats and numpy integers) still pass.

* fix(mlx): keep interrupted ranks inside the failure consensus

Rank-local work that feeds the rank-wide failure-status collectives caught only Exception, so a KeyboardInterrupt or SystemExit on one rank unwound past the consensus while surviving ranks blocked in it. All consensus-feeding handlers now capture BaseException: batch fetching, evaluation fetch and loss, resume fast-forward, the DDP local-step pair, best-model/checkpoint/final saves, and compiled-step setup (where ordinary failures keep the eager fallback while interrupts abort through the consensus instead of silently downgrading the run). Best-model restore gains an unconditional consensus before the diagnostics collective so an interrupt there cannot strand peers either. The consensus raiser re-raises interrupts unwrapped on the failing rank without setting stop_requested (a reused trainer must not inherit a stop request from an interrupt); peers still get the context-carrying RuntimeError, and a committed regression locks both behaviors. Single-process behavior is unchanged.

* fix(mlx): resolve completion mode from prompt/completion rows before locking

With completion_only_loss=None, a lazy stream that emitted a plain-text row before a prompt/completion row locked the inherited full-sequence mode, silently training prompt tokens, while the reverse order rejected — behavior depended on row order. A prompt/completion row now always resolves the default completion mask from its own shape before the mode locks, so the existing schema and label validators reject plain/prompt-completion mixes consistently in both orders. Homogeneous streams and explicit completion_only_loss settings are unchanged.

* fix(mlx): abort streaming eval before the first pull when stopped

A stop requested just before a scheduled evaluation (for example a step callback firing on an eval step) still entered the eval loop, which pulled the first batch before consulting the stop state. For an unsized eval source whose next row blocks, cancellation could never take effect. The eval accumulator now performs one rank-synchronized stop check before constructing the iterator, so every rank returns together without a blocking pull. A stopped evaluation therefore returns empty totals without building its iterator for sized sources too (previously it pulled one already-materialized batch before breaking); the accumulated result is identical. Non-stopped evaluation is unchanged.

* fix(mlx): drain queued prefetch batches once the producer is dead

Terminal close stopped the producer but left staged batches in the envelope queue, and the consumer generator kept the prefetcher alive until the training run's outer teardown. The queued payloads therefore survived the final model save; for VLM processors that emit opaque MLX arrays those are materialized device tensors, adding avoidable memory pressure at the peak-memory save.

Close now runs the join, orphan classification, and drain under one lock and returns this caller's own post-join observation, so a True return guarantees the queue was drained under that lock before being reported — concurrent closers cannot observe it half-drained. Every close() caller gates on that outcome rather than a separate liveness read: the terminal-save path refuses to serialize when not clean, and the run-teardown path persists a not-clean producer as an orphan in a finally so a propagating signal cannot bypass tracking. The orphaned flag is set on entry, so a signal raised anywhere in close() leaves a conservative orphan rather than a falsely clean producer; interrupts propagate (the abort is honored) rather than being swallowed. Persisted orphans that have since terminated are closed (and thus drained) when cleared, releasing their queues deterministically at the next gate. The mid-training pause/resume path is untouched (it never closes).

* fix(mlx): treat horizontally-concatenated streaming eval as finite when any child is

_mlx_stream_declares_infinite classified both vertical and horizontal HF concatenations with any(child_infinite), but a horizontally-concatenated iterable advances its children in lockstep and terminates with the shortest — so it is bounded whenever any child is finite. A streaming eval built from one infinite and one finite child was therefore wrongly rejected as infinite unless max_eval_batches was set. Horizontal concat now requires all children to be infinite; vertical (sequential) concat keeps the any() rule.

* fix(mlx): keep label_smoothing_factor copy-optional so the config suffix invariant holds

The main merge added label_smoothing_factor (a post-original config field) after this branch's streaming fields, so the _MLX_CONFIG_OPTIONAL_COPY_FIELDS entries were no longer the declaration suffix and a positional copy from an older config mismapped its trailing argument (image_size). Add label_smoothing_factor to the optional-copy set — it is likewise a field added after the original public surface — restoring the invariant that every post-image_size field is copy-optional.

* fix(mlx): append streaming config fields after main's trailing fields

The main merges left this branch's streaming config fields (max_eval_batches, streaming_text_length_window_batches, streaming_prefetch_batches) before label_smoothing_factor (#930) and report_grad_norm (#922), which each intentionally declared themselves last to preserve positional indices. A positional copy from a main-lineage config therefore bound its label_smoothing_factor / report_grad_norm values to this branch's fields, silently disabling label smoothing or failing lazy-eval validation. Move the streaming fields to the very end so every pre-existing (including main-appended) field keeps its positional index; _MLX_CONFIG_OPTIONAL_COPY_FIELDS again holds exactly this branch's trailing suffix, while the wholesale-copy warmup detection tolerates all appended fields (compile_max_variants, label_smoothing_factor, report_grad_norm) so legacy dumps missing them are still recognized.

* Treat horizontally concatenated streaming eval as infinite when any child is

HorizontallyConcatenatedMultiSourcesExamplesIterable.__iter__ removes each
child from its live iterator list as that child raises StopIteration and
breaks only once the list is empty, padding the dropped columns. It therefore
ends with the longest child, not the shortest, so an axis=1 concat holding one
infinite child never terminates. Requiring every child to be infinite made the
detector return False for it, the lazy eval guard was skipped, and evaluation
ran unbounded. Cover it with a real datasets-backed test alongside the mocks.

Verified against datasets 3.6.0 and 4.3.0; the method body is unchanged from
3.4.1 through main.

* Tighten comments in the MLX streaming changes

* Tighten docstrings in the MLX streaming changes

---------

Co-authored-by: Daniel Han <danielhanchen@gmail.com>
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.

3 participants

Morty Proxy This is a proxified and sanitized view of the page, visit original site.