Studio: read every launcher world size when resolving a step-capped run's passes - #9000
Conversation
…un's passes `_configure_online_tokenization` resolved how many passes a `max_steps` run makes from `WORLD_SIZE` alone, while `dataset_bounds.world_size_from_env()` already reads eight launcher variables plus the MLX rank files. mpirun, a per-node torchrun and mlx.launch's NCCL backend therefore read as single-process, and a genuinely multi-pass run got the lazy re-tokenizing view. The same expression also raised on a non-numeric value. The enclosing `except` swallowed it, left the pass count unresolved, and an unresolved step-capped run reads as infinite passes, so a junk `WORLD_SIZE` silently disabled online tokenization on a run that qualifies. Deliberately not `worker.py`'s `_data_parallel_world_size`: that one bounds a row subset where over-counting is free, this one feeds a veto where both directions are wrong, and Studio's own multi-GPU load is `device_map="balanced"`, which transformers pins to `_n_gpu = 1`. Also deletes a duplicated line garbling the `world_size_from_rank_files` docstring, and corrects two stale comments in `unsloth/utils/packing.py` that justified leaving boundary labels unmasked by the wrong rationale. No behaviour change from either.
for more information, see https://pre-commit.ci
…d cap the rank-file read in bytes Three follow-ups from simulating the world-size read. The pass count now depends on eight variables rather than one, and a size left behind by an earlier mpirun or inherited from an interactive srun reads as a multi-rank launch on a machine running a single process. The number is left alone, because the merged row bound already reads exactly these variables and already decides which rows the run trains on, so disagreeing here is worse than agreeing. What was missing is that the verdict was silent, so log the world size and the variables that produced it whenever the environment raises it above 1. The window where this changes a decision at all is narrow: with S = max_steps x batch x accum, only a corpus of between max(10000, S) and S x world_size rows, since anything larger is cut to 4 x S x world_size by the row bound and lands on a constant 0.25 passes either way. world_size_from_rank_files read its bounded prefix from a text handle, where read(n) counts characters, so a rank file of four-byte codepoints could pull four times MAX_WORLD_SIZE_FILE_BYTES off disk. Read in binary and the constant means what its name says; json.loads takes bytes and still raises a ValueError on anything that is not UTF-8. Corrected the launcher comments against the upstream sources. No MPI sets WORLD_SIZE. torchrun sets WORLD_SIZE and LOCAL_WORLD_SIZE both, so the latter is defensive rather than the only per-node count. Nothing sets PMIX_SIZE or MPI_WORLD_SIZE; PMIx answers job size through PMIx_Get. PMI_SIZE comes from MPICH and Intel MPI via Hydra, and from srun only under the pmi2 plugin. MV2_COMM_WORLD_SIZE is an mpirun_rsh variable. mlx.launch has five backends, and MLX_HOSTFILE names a file holding one address list per rank, which is now the shape the test fixture writes.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 46524b4172
ℹ️ About Codex in GitHub
Codex has been enabled to automatically 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 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| # balanced 4-GPU run draws the same rows per step as one GPU, so | ||
| # counting devices here would report 4x the passes and veto a | ||
| # qualifying run with a fabricated reason. | ||
| world_size = world_size_from_env() |
There was a problem hiding this comment.
Require rank evidence before multiplying launcher size
When a launcher size variable is stale or supplied without its matching rank variable, world_size_from_env() still treats the current process as distributed and can inflate resolved_epochs past 1, incorrectly disabling online tokenization. This is especially plausible for inherited OMPI_COMM_WORLD_SIZE, PMI_SIZE, or LOCAL_WORLD_SIZE; actual launchers set corresponding rank/global variables, and the repository's detection in routes/inference.py lines 95-118 deliberately requires those pairs. The helper was designed for row-bound sizing where over-counting is harmless, but this call drives a veto, so it should use evidence of an active distributed launch rather than accepting a bare size.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Checked this against the code and the launcher docs, and I am leaving it as it is.
The pairing precedent does not carry over. _mlx_distributed_launch_detected in routes/inference.py is a boolean whose only caller refuses distributed MLX inference with a 400; it never returns a count, it does not read WORLD_SIZE or LOCAL_WORLD_SIZE at all, and its own MLX branch returns True on a bare MLX_HOSTFILE with no size variable set. Adopting that table at this call site would read a real 8 rank torchrun launch as single process, which is the case this PR exists to fix, and LOCAL_WORLD_SIZE has no rank partner defined anywhere in the repo.
On legitimate input the proposed check is a no-op. torchrun hands every worker RANK, LOCAL_RANK, WORLD_SIZE and LOCAL_WORLD_SIZE as one set, and Open MPI's mpirun exports OMPI_COMM_WORLD_RANK alongside OMPI_COMM_WORLD_SIZE. So it would only change the stale inherited variable case, and there it puts this call in disagreement with the bound that is already merged: max_steps_dataset_rows falls back to the same bare world_size_from_env(), and worker.py sizes max_train_rows off it. That one physically truncates the dataset, which is the consumer worth arguing about; this one only picks a tokenization path.
When a stale size does read high, decide_online_tokenization vetoes and the run takes the eager Arrow map path, which is what every run took before online tokenization existed. Same loss, same checkpoints, one lost optimisation. The actionable half of your point is already in the diff: when the environment raises the world size above 1, the trainer now logs which variable said so.
https://docs.pytorch.org/docs/stable/elastic/run.html
https://docs.open-mpi.org/en/main/tuning-apps/environment-var.html
Cross-platform CI found these tests asserting about the runner rather than about the wiring. resolve_worker_count sizes itself from CPU affinity and the cgroup quota and returns 0 when the host cannot spare MIN_ONLINE_WORKERS, and that gate sits ahead of the pass-count veto, so on a small runner every case in the file got back "not enough CPU workers to stay ahead of the GPU" instead of the reason it was written to check. Reproduced locally with taskset -c 0. At the merge base, before any of this branch: 8 failed, 14 passed. At this branch without the pin: 18 failed, 15 passed. With the pin: 33 passed. So the failure is pre-existing and the macos-14 leg of the staging run was hitting it, not anything this branch changed. _run already pins the TRL feature detector for the same reason. The worker gate keeps its own coverage in test_online_tokenization.py.
…iables inference.py does not read

The bug
UnslothTrainer._configure_online_tokenizationresolves how many passes amax_stepsrun makes over the split, and hands that number to the online-tokenization gate, whose only use of it is theepochs > 1.0veto. It read the process count like this:dataset_bounds.world_size_from_env()already reads eight launcher variables plus the MLX rank files, and both loaders use it for the row bound. Reading one variable here means these launches resolve as single-process and get the lazy re-tokenizing view on a run that makes more than one pass, which is the case the veto exists to keep on the Arrow cache:OMPI_COMM_WORLD_SIZEand neverWORLD_SIZE(Open MPI env vars)srunwith the pmi2 plugin, which setPMI_SIZE(mpich hydra, slurm pmi2)LOCAL_WORLD_SIZEand lost the globalWORLD_SIZEThis is not an Apple silicon fix and does not claim to be one.
worker.pyroutes Apple silicon torun_mlx_training_processand returns beforeUnslothTrainer()is ever constructed, so this method is unreachable there. The rank-file branch is here for consistency withworker.pyandunsloth_cli/_inference.py, not because that path is reachable today.A second bug in the same expression
int("auto")andint("")raise. The surroundingtryswallows it, the pass count is left unresolved, and an unresolved step-capped run reads as infinite passes, so the gate vetoes. A junkWORLD_SIZEtherefore silently disabled online tokenization on a run that qualifies.world_size_from_envcoerces anything unusable to 1, so such a run now qualifies again. This is the direction that fails loudest on revert.Exhaustively:
auto,"",3.5,1e3,0x8,None,trueall used to veto and now enable." 8 ","8\n","+8","08"read as 8.0and-2read as 1 under both expressions.The fix
world_size = world_size_from_env(), withdataset_boundsalready imported one line above.Deliberately not
worker.py's_data_parallel_world_size, which also counts visible CUDA devices. The two call sites want different numbers. That one bounds a row subset, where over-counting costs nothing and under-counting recycles rows, so taking the max of env, process group and device count is right. This one feeds a veto, where both directions are wrong, and Studio's own multi-GPU load isdevice_map="balanced", which transformers treats as model-parallel and pins to_n_gpu = 1: a balanced 4-GPU run draws the same rows per step as one GPU, so counting devices here would report 4x the passes and veto a qualifying run with a fabricated reason. The comment at the call site records this so the two do not get "unified" later.Also in this PR:
dataset_bounds.py:world_size_from_rank_filesread its bounded prefix from a text handle, whereread(n)counts characters, so a rank file of four-byte codepoints could pull four timesMAX_WORLD_SIZE_FILE_BYTESoff disk. Now read in binary, so the constant means what its name says;json.loadstakes bytes and still raises aValueErroron anything that is not UTF-8.dataset_bounds.py: deletes a duplicated line that had garbled theworld_size_from_rank_filesdocstring mid-sentence.unsloth/utils/packing.py: two comments justified leaving boundary labels unmasked by saying unsloth_zoo "already subtracts the N-1 boundary targets". They now describe the idempotent contract instead, which is what the paired unsloth-zoo change establishes. The code is unchanged and was correct either way.I checked whether any other site needs the same change. Inside
studio/backend/core/trainingthis was the only narrower reader; the other two areworld_size_from_envitself and_data_parallel_world_size, and the latter is intentionally wider.resolved_max_steps_epochshas exactly one producer and one consumer in the tree. Nothing else computes an epoch count frommax_steps * batch * accum * world_size.The risk this introduces, quantified
The pass count now depends on eight variables rather than one, so a size variable that is set but not truthful raises
resolved_epochsand can veto a run that used to qualify. That is a behaviour change for an existing user on unchanged hardware, and it deserves a number rather than a shrug.When it can trigger. With
S = max_steps * batch * accumandRthe rows the trainer sees, the old verdict isS/R <= 1and the new oneS*W/R > 1, so the window isS <= R < S*W. The online-tokenization floor addsR >= 10_000. And the merged row bound, which runs earlier in the same worker process and reads the same variables, cuts any larger corpus to exactly4*S*Wrows, at which point the new pass count is a constant0.25and the verdict cannot change at all. So the whole window is a corpus of betweenmax(10_000, S)andS*Wrows. With Studio's defaults and a 200-step run under an eight-rank variable, that is 10_000 to 12_799 rows.What it costs when it triggers. The run takes the eager Arrow map, which is the path that shipped before #8960. No correctness, loss, gradient or checkpoint difference. The opposite direction, under-counting, engages a lazy view that re-tokenizes on every extra pass, so the over-count direction is the safer of the two.
How realistic a stale variable is. I could find no evidence of a login dotfile, module file or mainstream HPC container image exporting these unconditionally. What is real and structural: Slurm's pmi2 and cray_shasta plugins put
PMI_SIZEin every task environment andsrundefaults to--export=ALL, so anything started from an interactivesrunshell inherits it with no MPI in sight.mpirun -np 1 anythingsetsOMPI_COMM_WORLD_SIZE=1, which is harmless because 1 is the answer either way.What I did about it. The number is left alone. Narrowing it here, for instance by requiring the RANK partner as
routes/inference.pydoes, would put this call site back into disagreement with the row bound that already reads exactly these variables and already decides which rows the run trains on, and that disagreement is the thing this PR exists to remove. What was actually missing is that the verdict was silent, so the trainer now logs the world size and the variables behind it whenever the environment raises it above 1:Nothing is logged on a single-process run.
Launcher contracts, checked against upstream
Several of the comments in
dataset_boundswere wrong or imprecise and are corrected here:WORLD_SIZEis set by MPILOCAL_WORLD_SIZEis the only per-node count torchrun setsWORLD_SIZEtoo, plusGROUP_WORLD_SIZEPMIX_SIZEis a launcher variablePMIX_RANKand answers job size throughPMIx_GetMPI_WORLD_SIZEis a launcher variablesrunexportsPMI_SIZEMV2_COMM_WORLD_SIZEmpirun_rshMLX_WORLD_SIZEis NCCL-only and CUDA-onlyMLX_HOSTFILEis a bare list of"host:port"MLX_IBV_DEVICESis an N x N RDMA matrix, one row per rankPMIX_SIZEandMPI_WORLD_SIZEstay in the tuple, becauseroutes/inference.pyalready lists them and the two must not disagree about what counts as a launcher, but the comments no longer claim anything sets them. The MLX hostfile test fixture now writes the real shape.Evidence 1: the decision log line
The observable consequence is a log line, and the trigger cannot be produced from the UI, so there is no screenshot or recording here on purpose. Instead
core.training.worker.run_training_processwas driven directly, four arms, Qwen3-0.6B LoRA 4bit onunsloth/OpenMathReasoningcot(192,523 rows),max_steps=5000, batch 2, accum 4. The run is killed the moment the line is emitted, so no optimizer step is taken.OMPI_COMM_WORLD_SIZE=8is injected into the worker's environment before the backend is imported, exactly as mpirun would have left it.Online tokenization: on (plain-text single-pass SFT run); workers=4, prefetch=4, prewarm=16 microbatchesOMPI_COMM_WORLD_SIZE=8Online tokenization: on (plain-text single-pass SFT run); workers=4, prefetch=4, prewarm=16 microbatchesOnline tokenization: on (plain-text single-pass SFT run); workers=4, prefetch=4, prewarm=16 microbatchesOMPI_COMM_WORLD_SIZE=8Online tokenization: off (more than one pass over the data (1.66214 epochs))Row two is the bug: an eight-rank mpirun launch drawing 320,000 rows over a 192,523-row split, reported as a single pass. Row three is the no-regression control on the same steps and the same split.
Evidence 2: an old install upgrading, on a single-process run
The proof that matters most for an existing user is that nothing moves when no launcher variable is set. Merge base
27326c4d8checked out in its own worktree, versus this branch, same config as above, stopped after three optimizer steps:on (plain-text single-pass SFT run); workers=4, prefetch=4, prewarm=16 microbatchesIdentical gate decision, identical decision line, identical loss.
grad_normdiffers in the fifth decimal, and the third row is why that is not a finding: two runs of the same code differ by more than the two arms do, which is ordinary nondeterministic reduction order on the GPU. Nothing on this branch can touch the arithmetic anyway.Tests
studio/backend/tests/test_online_tokenization_wiring.pygains an autouse fixture that clears every launcher variable, using the same constant tuples and the same_single_process_launchhelper astest_training_preflight.py. That also retires this file's existing dependence on the ambient environment: the pass count is read out of the environment, so before this a case's result depended on what the runner's shell happened to export. Proved load-bearing by exportingWORLD_SIZE=8 OMPI_COMM_WORLD_SIZE=4and flipping the fixture toautouse = False: 1 failed, 30 passed.New cases:
OMPI_COMM_WORLD_SIZE=8) vetoesLOCAL_WORLD_SIZE=8alone vetoestmp_path, in the real per-rank-list shape, vetoes{"hosts": [...]}object form, vetoesWORLD_SIZE(auto, empty,eight) now enables, one parametrised case eachresolved_max_steps_epochsnumber handed to the gate, not just its verdictPlus, in
test_training_preflight.py, the byte cap on a rank-file read (a file with fewer characters than the cap but more bytes than it, which a text handle would have read whole) and the launcher report's own contract, including that it never raises on a hostile mapping.0and-4were deliberately left out of case 5: the oldmax(1, int(...))already answered 1 for those, so they would pass against the bug and belong withdataset_bounds' own coercion tests.Every case was proven discriminating by restoring the old expression and confirming it fails, in the default environment and in two others (python 3.10 / torch 2.6.0 / transformers 4.57.6 / TRL 0.22.2, and python 3.13 / torch 2.13.0 / transformers 5.15.0 / TRL 1.10.0), with an identical failure set each time:
test_a_single_process_launch_still_qualifiescorrectly stays green there: it is the control, world size 1 either way. Reverting the byte-cap hunk failstest_a_rank_file_read_is_capped_in_bytes_not_characters, and removing the launcher report fails its own case.With the fix in place, and unchanged with
CUDA_VISIBLE_DEVICES="":File order was permuted three ways and the same three files were run under
pytest-randomlyseeds 11, 22 and 33 in two separate interpreters: 172 passed every time at the point that was measured, so the new autouse fixture leaks nothing into its neighbours and nothing leaks into it.A pre-existing test defect this surfaced
The first cross-platform staging run failed on macos-14 and windows-latest with
not enough CPU workers to stay ahead of the GPU.resolve_worker_countsizes itself from CPU affinity and the cgroup quota and returns 0 when the host cannot spareMIN_ONLINE_WORKERS, and that gate sits ahead of the pass-count veto, so on a small runner every case in the file was asserting about the runner rather than about the wiring.Reproduced locally with
taskset -c 0, which is the same condition:27326c4d8, before any of this branchSo the failure predates this branch; it is simply that this branch adds cases to a file that was already host-dependent.
_runnow pinsresolve_worker_countfor the same reason it already pins the TRL feature detector. The worker gate keeps its own coverage intest_online_tokenization.py.Compatibility, and what was actually executed
dataset_bounds.pystays torch-free, as its module docstring requires; asserted mechanically by importing it in a bare interpreter and checking that none of torch, transformers, trl, datasets, unsloth, unsloth_zoo or numpy entersys.modules. The changed code uses onlyos,jsonand builtins, so no library version can change its answer; the version exposure is the test file, which imports the real trainer.Eight stack combinations were executed on Linux, covering python 3.10 / 3.11 / 3.12 / 3.13, transformers 4.57.6 and 5.15.0, TRL 0.22.2 / 0.25.1 / 1.10.0, and torch 2.6.0 / 2.9.1 / 2.13.0, arranged so that all four
(transformers, TRL)combinations appear and python and torch are crossed rather than held constant. Every cell reported a real pass count with no module-level skip and noimportorskipfiring, checked by probing thatdatasets,torchand the realtrlall imported and that the livetrl_supports_skip_prepare_dataset()returned True on every TRL. Note that transformers 5.x and torch 2.13 sit outside the rangestudio-backend-ci.ymldeclares, so those cells are future-proofing rather than evidence about the CI job.The
os.path.isfileon an arbitrary environment string was exercised against a directory, a fifo, a symlink loop, a dangling symlink, a mode-000 file as a non-root user, a 2 GB file, invalid UTF-8,/dev/zero,/dev/null, a missing path and a path containing spaces and non-ASCII characters. All return 1 without raising, the slowest case in the whole matrix is 2.7 ms, and nothing blocked. Payloads covered the bare list, the{"hosts": [...]}object, an empty list, a JSON string, a JSON number,null, a truncated payload, an inline payload above the cap and two rank-file variables disagreeing.Honest limits. Windows, macOS and WSL were exercised as
sys.platformfakes and through cross-platform staging CI, not as native development runs; on both of those platforms the online-tokenization platform gate vetoes before the pass count is read at all, so the changed expression cannot alter a decision there. A UNC path on Windows and a hung NFS mount on Linux are the one residual risk I could not clear: the block would be in thestat, which nothing here bounds, and I will not stage a fake hang and call it evidence. AMD/ROCm was not executed; the decision path reads no device state, which is an argument rather than a run. No realtorchrun,mpirunormlx.launchwas involved; every world size in the tests is an injected variable or a temp-file payload. No browser is involved anywhere in this diff, so there is no browser testing to report.Cross-platform staging CI,
test_online_tokenization_wiring.py+test_training_preflight.pywithtorch>=2.4 datasets trl>=0.24installed so the tests cannotimportorskipthemselves into a vacuous exit 5: