MLX: bound compiled VLM training signatures with a finite batch plan and shape guard#940
MLX: bound compiled VLM training signatures with a finite batch plan and shape guard#940Lyxot wants to merge 21 commits intounslothai:mainunslothai/unsloth-zoo:mainfrom Lyxot:feat/mlx-vlm-shape-guardLyxot/unsloth-zoo:feat/mlx-vlm-shape-guardCopy head branch name to clipboard
Conversation
Replace the eager batch list built during VLM data preparation with FiniteVLMBatchPlan: a CPU-backed schedule that resolves visits through the same finite-plan protocol as the text path, formats only scheduled rows (exactly once each, in first-visit order), and builds batches on demand behind a most-recently-used cache so compile-failure retries reuse the already-resolved batch instead of rebuilding it. The public create_vlm_batches wrapper keeps its eager list contract by materializing the plan. The all-masked supervision check now consumes construction-time per-row supervision metadata, so its distributed all-reduce runs without triggering per-rank batch materialization and a processor failure on one rank cannot strand peers. Trainer fetch, retry, and resume gates generalize from the text plan type to the shared finite-plan tuple. Behavior parity with the previous eager builder is pinned by frozen full-pytree goldens (structure, dtypes, exact values, order) across schedule modes, DDP cycles, and representative architecture classes, including prepare-time image-token expansion, plus end-to-end eager and compiled trainer runs.
…te VLM plan Add a process-stable serializer mapping an arbitrary prepared VLM batch pytree to its mx.compile cache-key family, mirroring the compile walk exactly where it can be proven equivalent (raw list indexing vs Python tuple iteration under one shared sequence marker, dict entries in insertion order, exact-type constants by value with bools as ints and floats by bit pattern, int64-range enforcement, genuine mx.array leaves by rank/shape/dtype) and diverging only toward more distinction. Anything unprovable — subclass constants and keys with overridable protocols, hash-by-identity keys, non-dict mappings, unsupported leaf types — carries a stable unstable/mapping/opaque tag, and _vlm_family_is_plannable excludes such families from shape planning entirely so one family can never silently span several compile keys. Array metadata is read through descriptors captured and probe-validated at import (dtype names by captured equality against captured singletons); invalid captures degrade array leaves to unplannable instead of guessing. Type tags are first-read-wins with weakly-held types, so hostile or varying metaclass introspection cannot desynchronize a surveyed family from a later drift walk and per-batch dynamic types are not retained. FiniteVLMBatchPlan gains ensure_descriptors(), an idempotent one-at-a-time survey that invalidates the plan-owned cache up front and owns at most one tensor-bearing batch at any moment, plus survey_stats counters, batch_family(index), and the check_family_drift seam that hard-fails naming the batch and the divergence path. No trainer caller is introduced yet. The central invariant — equal plannable families imply one compile trace — is tested against observed mx.compile cache behavior (families computed in survey-then-compile order), alongside fresh-process fingerprint determinism, survey laziness/idempotence with weakref tensor-lifetime tracking, and a drift matrix spanning added keys, dtype, shape, key order, non-first leaves, and cross-index confusion.
Split _prepare_vlm_batch_for_compile into a content phase (sidecar normalization and the prepare-time expansions that rebuild text arrays with data-dependent lengths) and a position-recording phase (rope position ids, absolute positional constants), with the public single-call contract unchanged. On planned builds the new _finalize_vlm_batch_width runs between response masking and position recording: it right-pads the pipeline-owned text-aligned arrays (pad id / zero mask / -100 labels / zero token types) to an admitted endpoint, so recorded absolute positions always refer to the preserved content prefix and width-derived sidecars are born at the final width. Unplanned builds keep the historical single-pass order byte for byte, including failure ordering. Admission is decided by a width survey sharing the family serializer's exact view: recursive raw-container traversal, captured-descriptor metadata reads, real-type dispatch, serializer-coherent constant rules. It declines — leaving exact families and unpadded materialization — for left- or interior-padded masks, any non-mx shape carrier, nested or unknown arrays sharing the text width, and position data the pipeline will not regenerate. Config-derived disposable keys (rope position ids for the qwen/glm families, image position constants for phi3_v) are exempt in any representation because the position phase overwrites them wholesale, and the survey and planned finalization consult the same set so admission cannot disagree between them; the pipeline-private generated-ids marker is stripped from processor output. Untouched-array extents are exported as forbidden widths and a colliding endpoint fails hard, as does one below a batch's prepared width; max_seq_length is never consulted. The survey stores symbolic families for padable batches plus per-index widths and flags; the plan cache keys by (index, width) and drift checking treats a uniform width change as legal while any inconsistent extent hard-fails. Verified by a representative padded-parity matrix (plain, qwen sidecars with position ids regenerated at the target width, phi3_v positional constants, multi_modality expansion beyond max_seq_length, prompt-completion): content prefixes byte-equal, tails inert, image arrays untouched, token counts and per-token CE exactly equal; plus decline, forbidden-endpoint, six-representation disposable-provenance, hostile-value, and failure-ordering gates. The 11-mode golden oracle passes unchanged.
Replace the shape guard's blanket VLM opt-out with real planning for finite VLM plans at world size one on compile-qualified architectures. The is_vlm branch of the single-process planner now delegates to a VLM planner that runs strictly after compile qualification and only when it could matter: streaming, eager-policy, distributed, unqualified, known-ineligible global-norm-with-accumulation, and pad-id-less runs all return before any survey work (the descriptor survey materializes every batch once, so categorical ineligibility must not pay it; without a tokenizer pad id no endpoint above a raw width can materialize while symbolic families remain bucketable, so those runs degrade to eager). Surveyed batches whose compile-key family is not provably stable force eager fallback — grouping such a family could span several compile keys — and strict mode raises in every fallback case. Padable batches take the shared rounded width policy capped at the surveyed maximum final width, bumped off the union of untouched-array extents; declined batches participate with exact families at raw widths. Events are enumerated through the same visit mapping the runtime fetch uses and feed the existing text planners unchanged (automatic frontier/budget or fixed-cap buckets). FiniteVLMBatchPlan gains set_shape_plan (validates the plan action, records each batch's planned event width, and invalidates the cache) and phase-aware materialize: an installed plan resolves the endpoint, enforces phase admission, and hard-fails structural drift before a batch can reach a compiled call. Cache entries carry a drift-checked bit so a batch cached by an explicit-width fetch is verified before a planned fetch may return it. The trainer's compiled fetch goes through the shared finite-plan protocol; a plan without an installed shape plan materializes unpadded exactly as before, and text-path planning is untouched. Verified by planner gates covering every fallback with no-survey proofs, install/admission/catalog/drift binding (independent signature-set equality under gradient accumulation, one-visit truncation, cache-provenance tampering, cap-1 bucketing), and a real-runtime training parity test: a tiny VLM trains planned-compiled versus unplanned-eager with equal tokens, matching loss, compiled input widths exactly the admitted endpoints, and one compiled invocation per planned step (zero when eager).
Wire finite-VLM shape planning into a coordinated preflight that runs after compile qualification and auto-tuning and before any optimizer or compiled-callable setup. The planner itself now supports multi-rank runs: the local-grad compile scope with its accumulation phases, the text planner's world-size-scoped eligibility gates, and rank-local endpoint maps built from each rank's own surveyed catalog through the new plan-owned planned_event_widths() (deterministic from immutable survey state, so planning and post-coordination installation derive identical widths). The preflight executes one fixed, fully enumerable collective schedule so every exit is paired and no rank can strand a peer: preparation runs collective-free (the all-masked check is deferred and its global-count reduction runs strictly after the preparation-status reduction), then eligibility, benign-state, and planning-state reductions synchronize rank-divergent compile decisions before the existing coordinator reuses its failure flag and shared-automatic-cap re-materialization verbatim on VLM frontiers, and a final mandatory-abort reduction makes a should_raise decision — reachable through per-architecture strict overrides even under a best-effort base mode, checked before every applicability class including streaming — abort every rank together. Genuinely mixed states (divergent qualification hiding inside any uniform applicability class) discard plans and disable compile on every rank so the later compiled-setup collectives stay symmetric; uniform args-derived states keep their legacy unplanned behavior. Setup between the decision block and the preflight is rank-deterministic and needs no collectives of its own; strict aborts unwind through the existing restoration. Verified by rank-local endpoint gates over asymmetric surveyed catalogs with installed DDP-phase fetch admission, a peer-state coordination matrix (larger shared cap adopted with install, benign / ineligible / failing / abort peers each forcing the symmetric outcome, strict aborting before the inner loop), collective-tape gates asserting the exact healthy and coordinated-failure schedules with pairing across rank outcomes, a production-seam proof that deferred preparation performs no collective, distributed pad-slot inertness across a genuinely widened planner endpoint, and accumulation-boundary resume parity with absolute-visit phases.
The all-masked supervision checker now reads finite-plan construction metadata unconditionally: since every finite VLM training path builds a plan and the coordinated preflight owns the checker's collective slot, the eager batch-dict iteration branch was unreachable and is deleted. Its two list-mode tests convert to the class contract — response-mask filtering removes all-masked rows outright, so an all-checked-rows-bad plan arises only from rows that bypass or defeat that filter (distributed pad slots, or fully-masked prompt/completion rows, which skip response masking) — and a call-path assertion pins the metadata-only contract. Also removed: the per-survey counter bookkeeping (survey_stats and its array-leaf counting) and the batch_padable/batch_forbidden_widths accessors, none of which had production consumers once planning moved to planned_event_widths() over the plan's private survey state; the planner report carries the runtime diagnostics instead. Tests read the private survey state directly, as they already do for the cache and planned widths.
…ceiling Budget compression eliminates too few VLM signatures to pay for its recurring padded compute: every media family needs its own endpoints, so at the same ~5% padded-work budget a VLM catalog compresses 19-29% versus 76-94% for text, and on Qwen3.5-2B the padded steps cost 6-12% training time for resource headroom that was never needed (42-54 signatures is ~10% of the Metal resource limit). Automatic VLM planning now stays exact — canonical event widths, zero planner padding, truthful cap_selection="exact" telemetry — whenever the surveyed catalog fits the 128-signature automatic ceiling, and compresses only once the cap genuinely binds. select_text_shape_padding_budget gains exact_signature_threshold, which may only widen the exact fast path: narrower values could never be honored because the frontier holds no bounded points for catalogs the default already keeps exact. The text planner does not pass it and keeps byte-identical behavior; fixed-cap (compile_max_variants) semantics are untouched and remain the operator dial for aggressive resource squeezing. In DDP coordination an exact rank's catalog size is descriptive, not a cap prescription: the VLM preflight passes keep_exact_local so exact ranks contribute a neutral value to the shared-cap collective and keep their local exact plan, while budget-bucketed peers (including above-ceiling ranks) coordinate among their own caps exactly as before; every rank still executes the identical collective sequence. Measured on Qwen3.5-2B adjacent fresh-process pairs: bucket_long_tail moves from +5.9% (both passes slower) to -1.3% versus baseline and bucket_clustered from +11.9% mean to +5.1%, with trained tokens exact and observed widths equal to admitted endpoints; the remaining ~4-5% is serial in-loop batch materialization, addressed separately.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: a45ab3b774
ℹ️ 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".
| ) | ||
| return None, report, False, None | ||
|
|
||
| if batches.pad_token_id is None: |
There was a problem hiding this comment.
Require a pad token only when the plan widens a batch
For a processor without pad_token_id, this unconditional check disables compilation even when padding is unnecessary—for example, a finite schedule whose batches all have one fixed width, or whose width surveys all decline padding, can use exact raw-width signatures without writing any padding tokens. Best-effort mode now falls back to eager and strict mode aborts for those otherwise materializable plans; survey and compute the planned endpoints first, then require a pad ID only if an endpoint exceeds its batch's raw width.
Useful? React with 👍 / 👎.
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.
Extends the finite compiled-shape guard from text training to VLM training by introducing a lazy finite VLM batch plan, compile-key family surveying, and width endpoint planning to cap compiled signatures under Metal’s resource limits.
Changes:
- Added a finite, lazily materialized
FiniteVLMBatchPlanand VLM compile-key family serializer/survey with drift detection. - Split VLM batch preparation into content vs position phases and added planned right-padding (
target_width) before position sidecars are generated. - Updated trainer preflight/coordination to plan VLM shapes (single-process and DDP) and expanded tests to cover parity, laziness, and coordination.
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
unsloth_zoo/mlx/utils.py |
Implements finite VLM batch plan, family serialization/survey, and planned-width padding + two-phase VLM prepare. |
unsloth_zoo/mlx/trainer.py |
Adds VLM shape-plan preflight/coordination and routes finite-plan batches through phase-aware admission during compiled training. |
unsloth_zoo/mlx/shape_guard.py |
Extends padding-budget selector with an adjustable “keep exact below threshold” knob used by VLM planning. |
tests/test_mlx_vlm_label_masks.py |
Adjusts for new _filter_trainable_vlm_indices return signature. |
tests/test_mlx_training_e2e_metal.py |
Adds metal-only e2e parity test ensuring planned VLM compile uses only admitted endpoints. |
tests/test_mlx_trainer_internals.py |
Adds DDP coordination tests and updates VLM checker tests to consume plan metadata. |
tests/test_mlx_shape_guard.py |
Adds tests for the new exact_signature_threshold domain and boundary behavior. |
tests/test_mlx_batching_and_decay.py |
Adds extensive fixtures validating VLM plan laziness, drift checks, serializer invariants, and checker behavior. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| width = _finite_text_pad_width( | ||
| raw_width, | ||
| pad_to_multiple=32, | ||
| minimum_width=2, | ||
| max_seq_length=surveyed_max, | ||
| ) | ||
| while width in forbidden: | ||
| width += 1 |
| labels = batch_dict.get("labels") | ||
| if labels is None: | ||
| checker_good = True | ||
| else: | ||
| first_row = labels.tolist()[0] | ||
| checker_good = any(int(x) != -100 for x in first_row) |
| attention_mask = batch_dict.get("attention_mask") | ||
| if type(attention_mask) is _MX_ARRAY_TYPE and ( | ||
| len(dims_of(attention_mask)) == 2 | ||
| ): | ||
| mask_np = np.asarray(attention_mask) | ||
| for row in (mask_np != 0).astype(np.int8): | ||
| content = int(row.sum()) | ||
| if content and not bool(row[:content].all()): | ||
| return width, None, False, frozenset(forbidden) |
| if len(_VLM_TYPE_TAG_CACHE) >= 256: | ||
| for key in [ | ||
| key | ||
| for key, (type_ref, _tag) in _VLM_TYPE_TAG_CACHE.items() | ||
| if type_ref() is None | ||
| ]: | ||
| del _VLM_TYPE_TAG_CACHE[key] | ||
| try: | ||
| _VLM_TYPE_TAG_CACHE[id(node_type)] = (weakref.ref(node_type), tag) | ||
| except TypeError: | ||
| pass |
|
Reviewed this end to end. Merging as is; no code changes needed from me. Four things below I would like you to address in the description or a follow-up, none of them blocking. Method: MLX runs on Linux CPU ( The bug is real and the fix worksCounting actual
Head reports The mechanism checks out at the source. No correctness regressions
Four things for the description
One more, minor: the headline resource numbers were sampled via MLX's private allocator counter, which has no Python binding, so they cannot be reproduced through any public API. The compile-trace count above is reproducible and makes the same point. Review threadThumbs down on the pad-token thread. The premise is right in isolation and I reproduced it: with I also went through the Copilot threads and rejected all four: the |
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: a45ab3b774
ℹ️ 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".
| ) | ||
| return None, report, False, None | ||
|
|
||
| if batches.pad_token_id is None: |
There was a problem hiding this comment.
Allow exact-width plans without a pad token
When every scheduled batch already has the same final text width, no padding is required: planned_event_widths() retains that raw width, an exact plan uses the same endpoint, and _finalize_vlm_batch_width() returns before reading the pad ID. This unconditional pre-survey check nevertheless disables best-effort compilation—and makes strict compilation fail—for processors that legitimately omit pad_token_id, even though the resulting plan is fully materializable. Defer this requirement until the selected plan actually widens at least one batch.
Useful? React with 👍 / 👎.
| if issubclass(node_type, (list, tuple)): | ||
| if issubclass(node_type, tuple) and node_type is not tuple: | ||
| # The serializer marks tuple subclasses unstable; padding | ||
| # judgement must agree. | ||
| declined = True | ||
| return | ||
| walk = ( | ||
| list.__iter__(node) | ||
| if issubclass(node_type, list) | ||
| else tuple.__iter__(node) | ||
| ) | ||
| for item in walk: | ||
| visit_untouched(item) |
There was a problem hiding this comment.
Decline list-backed position metadata before widening
When a non-Qwen processor returns sequence-aligned position_ids as a Python list (for example, a custom processor that ignores return_tensors="np"), _to_mx_vlm_batch() leaves that field as a list and this traversal treats all of its integers as harmless constants. The batch is therefore admitted for widening even though _finalize_vlm_batch_width() only pads MLX arrays, leaving position_ids at the old width while input_ids becomes wider; the family drift check also misses this because the list remains unchanged. Explicitly decline processor-authored position data outside the disposable/generated model set regardless of its representation.
Useful? React with 👍 / 👎.
| if formatting_func is not None and formatted_items is None: | ||
| # Consume formatting exactly once per referenced row, in first-visit | ||
| # order, so lazy re-materialization never re-invokes user code and | ||
| # unscheduled rows never reach the formatter (legacy parity: the | ||
| # eager builder only formatted rows it actually batched). | ||
| formatted_items = {idx: formatting_func(dataset[idx]) for idx in used} |
There was a problem hiding this comment.
Preserve formatting per scheduled occurrence
When response_mask_fn is absent and a finite schedule revisits a dataset index—such as num_batches spanning multiple epochs or distributed padding—the previous eager builder invoked formatting_func for every scheduled occurrence while constructing each batch. Deduplicating by dataset index here invokes it only once and replays that single result, so stateful or stochastic formatters lose later augmentations and create_vlm_batches() no longer reproduces its prior outputs or callback semantics. Store formatted rows per scheduled occurrence, while still avoiding formatter calls during later lazy rematerialization.
Useful? React with 👍 / 👎.
|
Second round adjudicated. All three rejected, verdict unchanged at MERGE, still no code changes needed from me. Exact-width plans without a pad token. This restates the earlier thread with a new framing (an exact plan needs no padding, so it should not need a pad id) but no new reachable path. The framing does not hold, because transformers rejects on the padding strategy, not on whether padding is actually needed. Measured on real GPT-2 tokenization with the pad token removed: The equal-length case is exactly the scenario the comment is built on, and it still raises. Since List-backed position metadata. The mechanism is real but the path is not reachable. Three separate checks: Formatting per scheduled occurrence. The description of the diff is accurate but the framing as a bug is not. Measured over 4 unique rows and 3 epochs: So the base was already split-brained, and the branch that already deduped is the The red MLX check on this PR is not yours
The test came in with #684 and lives on For the record, on a macos-14 runner with the rest of this PR's tests, |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 5a8fcfdb37
ℹ️ 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".
| "eager", "vlm_unplannable_family", cap, compile_scope, | ||
| cap_selection="not_applicable", | ||
| ) | ||
| if compile_policy.mode == "strict": |
There was a problem hiding this comment.
Honor effective strict overrides during shape fallback
When the base compile_mode is best_effort but compile_arch_overrides or compile_backend_overrides selects strict for a qualified VLM, resolve_training_compile() returns an enabled decision with fallback disabled, yet this and the other shape-planning failure branches consult only the base compile_policy.mode. An unplannable family, exhausted signature budget, or unavailable materialization support therefore silently switches the run to eager instead of enforcing the user's effective strict policy; use the resolved decision's effective mode or fallback_allowed for these checks and coordination.
Useful? React with 👍 / 👎.
An arch or backend override can select strict for a qualified VLM while the base compile_mode stays best_effort. The shape-planning failure branches and the DDP coordination step read the base mode only, so those runs silently degraded to eager instead of aborting. Use the resolved decision's mode.
|
Genuine, and fixed in An arch or backend override can select
So a user who asked for strict on this architecture silently got an eager run instead of the abort they configured. Fixing the four planner branches alone would not have been enough on the DDP path: On newness: the base-mode pattern does predate this PR in the text planner and in Test: |
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: a275ea9910
ℹ️ 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".
| if batches.pad_token_id is None: | ||
| # Widening writes the tokenizer pad id into input_ids tails, so without | ||
| # one no endpoint above a batch's raw width can materialize and no width | ||
| # plan holds. Known before any survey work: degrade to eager for free. |
There was a problem hiding this comment.
Check whether padding is needed before requiring a pad token
For a processor without pad_token_id, this unconditionally disables compiled VLM training (or aborts strict mode), even when the eventual plan never widens a batch—for example, a finite schedule whose batches all have the same prepared width, or batches the survey marks unpadable and therefore keeps at their exact widths. Those plans never write a padded tail and can compile without a pad token, so the requirement should be applied only after surveying and only when an admitted endpoint exceeds a padable batch's raw width.
Useful? React with 👍 / 👎.
|
Reversing my position on the pad-token guard, which I had rejected twice. The strict-abort one still does not hold up. Reversed: allow exact VLM plans without a pad token (
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 4e0f2e322a
ℹ️ 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".
| for batch in schedule: | ||
| for idx in batch: | ||
| if idx not in seen_used: | ||
| seen_used.add(idx) | ||
| used.append(idx) |
There was a problem hiding this comment.
Preserve per-occurrence dataset sampling
When formatting_func is absent and the finite schedule revisits an index, this deduplication stores only one result from dataset[idx] and reuses it for every occurrence. The previous eager builder accessed dataset[idx] while constructing each scheduled batch, so map-style datasets that implement stochastic __getitem__ augmentation or return visit-dependent data now freeze the first sample and silently change training inputs; store a retrieved row for each scheduled slot, as the formatter branch already does.
Useful? React with 👍 / 👎.
|
Splitting this into its three assertions, because they do not all land the same way. One is a regression I introduced and have fixed, one I am declining with measurements, and one I am conceding outright. Genuine, and my fault: later formatter calls rewrote earlier visits (
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 45e3a364e6
ℹ️ 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".
| without this a later visit's mutation would rewrite earlier stored visits. | ||
| Only the container is copied, never the payloads it points at.""" | ||
| if isinstance(item, dict): | ||
| return copy.copy(item) |
There was a problem hiding this comment.
Snapshot nested formatted rows recursively
When an in-place formatting_func mutates nested payloads—such as appending to a row's messages list—this shallow copy preserves references to those payloads, so formatting a later scheduled occurrence still rewrites the content stored for earlier occurrences before any batch is processed. The resulting training inputs no longer match the eager per-batch behavior and can silently collapse visit-dependent formatting. Fresh evidence in the final tree is that _snapshot_formatted_vlm_row() copies only the outer container; retain an independent nested snapshot per occurrence or restore interleaved formatting and processing.
Useful? React with 👍 / 👎.
|
Both genuine, fixed in Genuine: preserve per-occurrence dataset samplingLiterally correct. The compact-remap branch read At batch size 2 over 4 rows, base is Blame is not my recent commits: It also reaches a path the item does not mention. With One case is correctly deduped and stays that way: Genuine, and mine: snapshot nested formatted rows recursively
So every stored slot in both epochs read epoch 2's values, which is the exact bug The snapshot now rebuilds nested Folding these two together also let the branch derive its supervision flag again rather than hardcoding Verification
One existing test was asserting the bug: The previous head verified green on Apple Silicon (31/31 batching, 25/25 shape guard, 41/41 VLM label masks, 118/118 trainer internals, 10/10 e2e Metal, 1/1 DDP, nothing skipped); re-running that for this head. |
|
@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". |
Integrates the staged VLM collation path from unslothai#927 with this branch's shape-plan width finalization. - _finalize_vlm_batch gains a `phase` kwarg forwarded verbatim to the terminal compile preparation at each of its four exits, so a planned batch can stop at the width seam while staging, the prefetch producer path and the response-mask rejection stay untouched. - _build_response_masked_vlm_batch keeps main's staged control flow and adds `target_width`: the finalizer runs its content phase, response masking runs on the unpadded content, the planned pad lands next, and the position phase runs at the final width. - A planned width under `yield_host_staged` is rejected: a staged batch defers conversion, so it has no width to pad. The sized planner owns every endpoint and never stages, so the pairing is unreachable. - Re-froze the default_epoch_replay plan golden: the staged finalizer no longer emits the private raw-input-ids carrier. Verified the new digest against main's own eager builder.
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 74dea78868
ℹ️ 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".
| comm_group=comm_group, | ||
| ) | ||
| if _vlm_mask_fn is not None and batches: | ||
| batches = [] if plan is None else plan |
There was a problem hiding this comment.
Preserve processor RNG progress when resuming finite VLM training
When resuming a finite VLM run whose processor performs random augmentation, returning this lazy plan means the processor is never called for the _resume_step * gradient_accumulation_steps batches skipped by batch_idx; the first resumed batch therefore receives the initial augmentation draw rather than the draw an uninterrupted run used. The same deferral lets eagerly created eval batches at trainer.py:3864-3918 consume processor RNG before the first training batch. Previously create_vlm_batches processed the entire schedule eagerly, including skipped batches, so both cases preserved processor-call order; the lazy path needs to fast-forward preprocessing or otherwise preserve that RNG progression.
Useful? React with 👍 / 👎.
|
Both halves confirmed and fixed in I ran the real training loop on this head and on the merge base with a processor that augments from the process-global RNGs and stamps its draw into the ids it returns, so a batch's contents show which position of the preprocessing stream built it. Sweep: ResumeOn the merge base a resumed run reproduced the uninterrupted run byte for byte in 24 of 24 configurations, because
Row selection is correct in every case; only the augmentation draws are rewound. At Eval orderingAlso confirmed. VLM eval batches are built eagerly before the loop, so with a lazy train plan those processor calls now land ahead of the first training batch. With an eval split of 4 rows, 2 eval batches, this head's first training batch is shifted forward by exactly 2 draws relative to the merge base, in 12 of the 12 configurations that set The survey is cleanWorth recording, since it is the obvious suspect and it is not the culprit: The fix
VLM eval batch construction is wrapped in Trade-off stated up front: with that wrap, eval batches now draw from stream positions VerificationTwo regression tests in
One harness note for anyone reproducing this: the first batch build lazily imports |
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 1f0fc18875
ℹ️ 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".
| batch_dict = _build_response_masked_vlm_batch( | ||
| batch_items, | ||
| self._processor, | ||
| self._config, |
There was a problem hiding this comment.
Separate lazy preprocessing from the training RNG
When a VLM processor draws from mx.random and the model uses stochastic operations such as dropout, running the processor here during each batch fetch changes the shared RNG order from the previous eager sequence (processor(batch 1), processor(batch 2), …, train) to an interleaved sequence (processor(batch 1), train, processor(batch 2), …). _preserved_preprocessing_rng only protects the survey and eval construction, not these runtime builds, so the same seed produces different later augmentations and model randomness; runtime preprocessing needs an isolated RNG stream or equivalent state handling.
Useful? React with 👍 / 👎.
| batch_dict = self._build_batch(index, target_width=target_width) | ||
| if check_drift: | ||
| self.check_family_drift(index, batch_dict) | ||
| self._mru = ((index, target_width), batch_dict, check_drift) |
There was a problem hiding this comment.
Release the previous batch before constructing the next
On consecutive visits to different schedule entries, self._mru still owns the previous batch while _build_batch constructs the new one, and the trainer's batch_data assignment also retains the previous value until that call returns. Thus two complete image/text batches coexist at every transition, contradicting the plan's one-batch residency goal and potentially causing avoidable peak-memory failures for large image batches; discard the prior cache/caller reference before starting the next build while retaining it only for same-step fallback.
Useful? React with 👍 / 👎.
| if failed_any: | ||
| if compile_policy.mode == "strict": | ||
| if strict_mode: | ||
| error = RuntimeError( | ||
| "Unsloth: strict mx.compile finite text shape planning " | ||
| "failed on at least one DDP rank." |
There was a problem hiding this comment.
Synchronize resolved strictness before raising
When DDP ranks resolve different compile decisions—for example, one rank receives a strict architecture override with a planner/qualification failure while a peer remains best-effort—failed_any is true everywhere but only the strict rank raises here. The best-effort peer returns and reaches the later any_decision_abort collective at lines 3060-3063 after the strict rank has exited, leaving it hung; resolved strictness or the abort status must be reduced across ranks before any rank raises.
Useful? React with 👍 / 👎.
| with _preserved_preprocessing_rng(): | ||
| return create_vlm_batches( | ||
| dataset=eval_dataset, |
There was a problem hiding this comment.
Preserve RNG once across all evaluation splits
When eval_dataset is a dict and its processor performs stochastic augmentation using Python, NumPy, Torch, or MLX global RNG state, this context is entered and restored separately for every split. Consequently each split starts from the identical RNG snapshot and receives the same augmentation draw sequence, unlike the previous sequential construction where later splits received later draws; wrap preservation around the entire split-building loop rather than each individual split.
Useful? React with 👍 / 👎.
Summary
Follow-up to #918, which bounded compiled text training signatures under the Metal resource limit. This PR extends the same protection to vision-language training and closes unslothai/unsloth#6990.
Compiled VLM training previously produced one
mx.compilesignature per distinct batch shape with no upper bound. On long-tail or many-aspect-ratio image schedules the retained-signature set grew until it breached the Metal 499,000-resource limit and training aborted. This PR makes the compiled VLM path plan a finite set of shapes: batches are materialized lazily through a plan, their compile-key families are surveyed once, and an automatic policy caps the number of compiled signatures — while explicitly avoiding padding work that would not pay for itself.What changed
mx.compile's cache-key walk to group batches into(family, width, phase)signatures; a width-finalize phase pads text-aligned arrays up to planned endpoints (Qwen/GLM position ids and phi3_v image positions are regenerated at the padded width) so a bounded endpoint set covers many raw widths.compile_max_variantsremains the explicit dial for aggressive resource squeezing.Text planning is unchanged and byte-identical: the selector gains a widen-only
exact_signature_thresholdthat only the VLM caller passes.Performance
Benchmarked on Apple M3 Max (128 GB), MLX 0.32.0 / mlx-vlm 0.4.4, 4-bit QLoRA rank 4, strict full-step compile, CCE, gradient checkpointing, two counterbalanced fresh-process passes per cell. Metal resources are sampled at optimizer-step boundaries via the private MLX allocator counter. Because absolute host speed drifts up to ~10% between fresh processes, runtime is reported as direction and paired deltas rather than a precise percentage; the resource-count reduction is the robust result.
Image+text VLM (Qwen3.5-0.8B):
Below the ceiling the guard adds no padding and stays exact. The 160-row schedule crosses the ceiling and is compressed 145 → 102 signatures; every guard run used fewer sampled resources than every baseline run, and mean peak fell from 25.49% to 15.68% of the Metal limit (below-ceiling peak is 4.60%).
mx.get_peak_memory()barely moved (2.305 → 2.277 GB), confirming the reduction is in Metal resource count, not allocated bytes.Text-only through a VLM wrapper (Qwen3.5-2B,
text_only=True) routes to the finite-text path and gets the full #918 benefit:Correctness
Across every benchmarked cell: identical fixture digest, example order, labels, seed, and trained-token series between baseline and guard; exact trained-token totals; identical automatic plans across both passes; observed runtime signatures equal to the admitted endpoint set. Every paired loss difference was ≤ 0.495%, within the ~0.8% fresh-process variation of repeating the same effective configuration on this stack (independently characterized as a qwen3_5+CCE cross-process noise floor, not a guard effect).
The change was developed in reviewable stages, each gated by multi-reviewer review; the repository suite covers the data-path golden oracle, the compile-key invariant against live
mx.compiletraces, survey laziness and per-batch tensor lifetime, family drift detection, the fake-rank collective gate, DDP coordination, and a planned-vs-unplanned training-parity end-to-end test. Focused MLX suites pass (213 tests).Notes and follow-ups
Closes unslothai/unsloth#6990.