feat(mlx): true grad-norm reporting and compiled global-norm clipping with gradient accumulation#922
feat(mlx): true grad-norm reporting and compiled global-norm clipping with gradient accumulation#922danielhanchen merged 3 commits intounslothai: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
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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 > 0withgradient_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.
|
@codex review |
|
Codex Review: Didn't find any major issues. You're on a roll. Reviewed commit: ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
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". |
63b67a1 to
7be14ef
Compare
There was a problem hiding this comment.
💡 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".
7be14ef to
63b67a1
Compare
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.
63b67a1 to
2775572
Compare
|
@codex review |
|
Codex Review: Didn't find any major issues. Hooray! Reviewed commit: ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
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.
|
staging CI pass in Lyxot#4 |
…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).
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.
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.
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
…-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>

Summary
Two changes to the MLX trainer's gradient-norm handling, one commit each:
grad_normwas 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 afterresume_from_checkpoint(its closure-local previous-vaccumulator restarts at zero while the restored optimizer state does not); it silently reports nothing for Lion (which exposesbetasbut has novstate); 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.mx.compilefor global-norm clipping with gradient accumulation. The trainer disabled compilation whenevermax_grad_norm > 0was combined withgradient_accumulation_steps > 1. That guard predates the current clipping implementation:git log -Sshows 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 currentmain, this also removes the finite-text shape planner'scompile_ineligible_global_normbranch (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 aFiniteTextBatchPlanso 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)
train/grad_normin W&B/TensorBoard, and the grad-norm history — unless global-norm clipping is the resolved clip mode or the user sets the newMLXTrainingConfig(report_grad_norm=True). Standard TRL-driven runs (the notebooks) are unaffected: TRL'sSFTConfigdefaultsmax_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 vsmain). The absence applies wheremax_grad_normresolves to 0: directMLXTrainingConfig(...)construction with defaults, or the Unsloth Studio worker, which currently passes 0. The legacy 9-argument step callback keeps its exact signature and receivesNoneas 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.compile=Falsereproduces the previous eager behavior exactly.Design notes
report_grad_normis 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.grad_accum == 1fast 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).mx.evalboundary as model/optimizer state (previously a separate early evaluation);.item()happens only at the logging boundary.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, keepingwarmup_ratioprecedence 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):
Notebook parity: the Gemma3-270M notebook's exact settings (public
FastModelAPI, 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 betweenmainand this branch — and byte-identical again withreport_grad_norm=Trueenabled.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.0on 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 matchedadamw_torch). CUDA ranmax_grad_norm=1e9— never binding — so the HF trainer logs its standard pre-clip global norm.on_pre_optimizer_stepcallback 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.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.pyinstalls the torch-backed simulation shim only when real MLX is not already imported, so its torch mocks can reach the realmx.save_safetensorsand raisestd::bad_cast;tests/test_mlx_adapter_metadata_persistence.pyinstalls 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 tomain(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.