Studio: read every launcher world size when resolving a step-capped run's passes by danielhanchen · Pull Request #9000 · unslothai/unsloth · GitHub
Skip to content

Studio: read every launcher world size when resolving a step-capped run's passes - #9000

Merged
danielhanchen merged 5 commits into
mainfrom
studio/online-tokenization-world-size
Aug 17, 2026
Merged

Studio: read every launcher world size when resolving a step-capped run's passes#9000
danielhanchen merged 5 commits into
mainfrom
studio/online-tokenization-world-size

Conversation

@danielhanchen

@danielhanchen danielhanchen commented Aug 16, 2026

Copy link
Copy Markdown
Member

The bug

UnslothTrainer._configure_online_tokenization resolves how many passes a max_steps run makes over the split, and hands that number to the online-tokenization gate, whose only use of it is the epochs > 1.0 veto. It read the process count like this:

world_size = max(1, int(os.environ.get("WORLD_SIZE", "1")))

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:

  • mpirun, which sets OMPI_COMM_WORLD_SIZE and never WORLD_SIZE (Open MPI env vars)
  • MPICH and Intel MPI under Hydra, and Slurm's srun with the pmi2 plugin, which set PMI_SIZE (mpich hydra, slurm pmi2)
  • an environment that kept torchrun's LOCAL_WORLD_SIZE and lost the global WORLD_SIZE
  • mlx.launch's NCCL backend, which is CUDA-only and so does reach this path

This is not an Apple silicon fix and does not claim to be one. worker.py routes Apple silicon to run_mlx_training_process and returns before UnslothTrainer() is ever constructed, so this method is unreachable there. The rank-file branch is here for consistency with worker.py and unsloth_cli/_inference.py, not because that path is reachable today.

A second bug in the same expression

int("auto") and int("") raise. The surrounding try swallows it, the pass count is left unresolved, and an unresolved step-capped run reads as infinite passes, so the gate vetoes. A junk WORLD_SIZE therefore silently disabled online tokenization on a run that qualifies. world_size_from_env coerces 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, true all used to veto and now enable. " 8 ", "8\n", "+8", "08" read as 8. 0 and -2 read as 1 under both expressions.

The fix

world_size = world_size_from_env(), with dataset_bounds already 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 is device_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_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. Now read in binary, so the constant means what its name says; json.loads takes bytes and still raises a ValueError on anything that is not UTF-8.
  • dataset_bounds.py: deletes a duplicated line that had garbled the world_size_from_rank_files docstring 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/training this was the only narrower reader; the other two are world_size_from_env itself and _data_parallel_world_size, and the latter is intentionally wider. resolved_max_steps_epochs has exactly one producer and one consumer in the tree. Nothing else computes an epoch count from max_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_epochs and 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 * accum and R the rows the trainer sees, the old verdict is S/R <= 1 and the new one S*W/R > 1, so the window is S <= R < S*W. The online-tokenization floor adds R >= 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 exactly 4*S*W rows, at which point the new pass count is a constant 0.25 and the verdict cannot change at all. So the whole window is a corpus of between max(10_000, S) and S*W rows. 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_SIZE in every task environment and srun defaults to --export=ALL, so anything started from an interactive srun shell inherits it with no MPI in sight. mpirun -np 1 anything sets OMPI_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.py does, 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:

Launcher environment reports 8 data-parallel processes (OMPI_COMM_WORLD_SIZE=8); a step-capped run consumes that many times the rows per step

Nothing is logged on a single-process run.

Launcher contracts, checked against upstream

Several of the comments in dataset_bounds were wrong or imprecise and are corrected here:

claim verdict source
WORLD_SIZE is set by MPI no, torchrun / accelerate / deepspeed only Open MPI
LOCAL_WORLD_SIZE is the only per-node count torchrun sets no, it sets WORLD_SIZE too, plus GROUP_WORLD_SIZE torch elastic, local_elastic_agent.py
PMIX_SIZE is a launcher variable no such variable; PMIx exports PMIX_RANK and answers job size through PMIx_Get pmix_server.c
MPI_WORLD_SIZE is a launcher variable not documented by any MPI checked as above
srun exports PMI_SIZE only under the pmi2 and cray_shasta plugins, not pmix slurm mpi guide
MV2_COMM_WORLD_SIZE MVAPICH2, and only under mpirun_rsh MVAPICH2 guide
MLX_WORLD_SIZE is NCCL-only and CUDA-only correct launch.py, no_nccl.cpp
MLX_HOSTFILE is a bare list of "host:port" no, it names a file holding one address list per rank ring.cpp
MLX_IBV_DEVICES is an N x N RDMA matrix, one row per rank correct, and it too names a file jaccl README

PMIX_SIZE and MPI_WORLD_SIZE stay in the tuple, because routes/inference.py already 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_process was driven directly, four arms, Qwen3-0.6B LoRA 4bit on unsloth/OpenMathReasoning cot (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=8 is injected into the worker's environment before the backend is imported, exactly as mpirun would have left it.

arm injected env decision line
before none Online tokenization: on (plain-text single-pass SFT run); workers=4, prefetch=4, prewarm=16 microbatches
before OMPI_COMM_WORLD_SIZE=8 Online tokenization: on (plain-text single-pass SFT run); workers=4, prefetch=4, prewarm=16 microbatches
after none Online tokenization: on (plain-text single-pass SFT run); workers=4, prefetch=4, prewarm=16 microbatches
after OMPI_COMM_WORLD_SIZE=8 Online 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 27326c4d8 checked out in its own worktree, versus this branch, same config as above, stopped after three optimizer steps:

decision line step 1 loss step 2 loss step 1 grad_norm
merge base on (plain-text single-pass SFT run); workers=4, prefetch=4, prewarm=16 microbatches 0.7147 0.8064 0.38770782947540283
this branch identical 0.7147 0.8064 0.3877256214618683
this branch, repeat identical 0.7147 0.8064 0.38774922490119934

Identical gate decision, identical decision line, identical loss. grad_norm differs 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.py gains an autouse fixture that clears every launcher variable, using the same constant tuples and the same _single_process_launch helper as test_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 exporting WORLD_SIZE=8 OMPI_COMM_WORLD_SIZE=4 and flipping the fixture to autouse = False: 1 failed, 30 passed.

New cases:

  1. mpirun (OMPI_COMM_WORLD_SIZE=8) vetoes
  2. torchrun's LOCAL_WORLD_SIZE=8 alone vetoes
  3. MLX ring hostfile written to tmp_path, in the real per-rank-list shape, vetoes
  4. the same payload inline in the variable, in the {"hosts": [...]} object form, vetoes
  5. junk WORLD_SIZE (auto, empty, eight) now enables, one parametrised case each
  6. a direct assertion on the resolved_max_steps_epochs number handed to the gate, not just its verdict
  7. a single-process control at the same step count, which must still qualify
  8. the launcher report names the variable, and says nothing at all on a single-process run

Plus, 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.

0 and -4 were deliberately left out of case 5: the old max(1, int(...)) already answered 1 for those, so they would pass against the bug and belong with dataset_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:

$ git apply -R <the trainer.py hunk>
$ PYTHONPATH=studio/backend python -m pytest studio/backend/tests/test_online_tokenization_wiring.py -q
FAILED test_an_mpirun_launch_scales_the_rows_a_step_consumes
FAILED test_a_per_node_torchrun_scales_the_rows_a_step_consumes
FAILED test_an_mlx_hostfile_scales_the_rows_a_step_consumes
FAILED test_an_inline_hosts_payload_scales_the_rows_a_step_consumes
FAILED test_a_junk_world_size_no_longer_disables_online_tokenization[auto]
FAILED test_a_junk_world_size_no_longer_disables_online_tokenization[]
FAILED test_a_junk_world_size_no_longer_disables_online_tokenization[eight]
FAILED test_the_resolved_pass_count_handed_to_the_gate_is_the_arithmetic
8 failed, 23 passed

test_a_single_process_launch_still_qualifies correctly stays green there: it is the control, world size 1 either way. Reverting the byte-cap hunk fails test_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="":

test_online_tokenization_wiring.py + test_training_preflight.py + test_online_tokenization.py
176 passed

File order was permuted three ways and the same three files were run under pytest-randomly seeds 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_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 was asserting about the runner rather than about the wiring.

Reproduced locally with taskset -c 0, which is the same condition:

tree result under one CPU
merge base 27326c4d8, before any of this branch 8 failed, 14 passed
this branch, before the fix 18 failed, 15 passed
this branch, after pinning the worker count 33 passed

So the failure predates this branch; it is simply that this branch adds cases to a file that was already host-dependent. _run now pins resolve_worker_count for the same reason it already pins the TRL feature detector. The worker gate keeps its own coverage in test_online_tokenization.py.

Compatibility, and what was actually executed

dataset_bounds.py stays 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 enter sys.modules. The changed code uses only os, json and 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 no importorskip firing, checked by probing that datasets, torch and the real trl all imported and that the live trl_supports_skip_prepare_dataset() returned True on every TRL. Note that transformers 5.x and torch 2.13 sit outside the range studio-backend-ci.yml declares, so those cells are future-proofing rather than evidence about the CI job.

The os.path.isfile on 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.platform fakes 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 the stat, 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 real torchrun, mpirun or mlx.launch was 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.py with torch>=2.4 datasets trl>=0.24 installed so the tests cannot importorskip themselves into a vacuous exit 5:

leg before the worker-count pin after
ubuntu-latest 115 passed 115 passed
windows-latest 18 failed, 97 passed (pre-existing, see above) 115 passed
macos-14 failed on the same pre-existing gate 115 passed

…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.
danielhanchen added a commit to danielhanchen/unsloth-staging-2 that referenced this pull request Aug 16, 2026
pre-commit-ci Bot and others added 2 commits August 16, 2026 13:33
…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.
danielhanchen added a commit to danielhanchen/unsloth-staging-2 that referenced this pull request Aug 16, 2026

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 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()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

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.
danielhanchen added a commit to danielhanchen/unsloth-staging-2 that referenced this pull request Aug 16, 2026
@chatgpt-codex-connector

Copy link
Copy Markdown

@danielhanchen
danielhanchen merged commit def58ea into main Aug 17, 2026
55 of 61 checks passed
@danielhanchen
danielhanchen deleted the studio/online-tokenization-world-size branch August 17, 2026 01:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant