Skip to content

fsdp2_scripts: one multi-GPU FSDP2 launcher per python_scripts file, generated by update_all_notebooks.py - #416

Merged
danielhanchen merged 31 commits into
mainfrom
dh/fsdp2-scripts
Oct 31, 2026
Merged

danielhanchen merged 31 commits into
mainfrom
dh/fsdp2-scripts

Conversation

@danielhanchen

@danielhanchen danielhanchen commented Oct 28, 2026 •

Copy link
Copy Markdown
Member

Summary

Adds a generated fsdp2_scripts/ folder: one launcher per python_scripts/<name>.py (467 today, AMD-, Kaggle- and HuggingFace Course- copies included) that runs the same script on every visible GPU with Unsloth's native FSDP2:

python "fsdp2_scripts/Qwen3_(4B)-GRPO.py"                          # every visible GPU
CUDA_VISIBLE_DEVICES=0,1 python "fsdp2_scripts/Qwen3_(4B)-GRPO.py" # pick GPUs
python "fsdp2_scripts/Qwen3_(4B)-GRPO.py" --nproc-per-node 4
UNSLOTH_TP_SIZE=2 python "fsdp2_scripts/Qwen3_(4B)-GRPO.py"        # tensor parallel x FSDP2
python "fsdp2_scripts/Qwen3_(4B)-GRPO.py" --dry-run                # print the command only

Until now multi-GPU meant remembering UNSLOTH_FSDP2=1 torchrun --standalone --nproc-per-node N "python_scripts/<name>.py", and 83 of the 467 mirrors still carry live get_ipython() calls (!wget, %%capture, %%bash, %%file, %env, %cd), so they stop with NameError: name 'get_ipython' is not defined under plain python or torchrun.

How it works

  • scripts/fsdp2_launch.py (stdlib only, hand maintained) is the single implementation. The generator copies it to fsdp2_scripts/unsloth_fsdp2_launch.py and writes one ~12 line launcher per script that calls main(__file__, "<name>.py").
  • Outside torchrun the launcher counts visible GPUs in a short child process (so the parent never imports torch), then replaces itself with python -m torch.distributed.run --standalone --nproc-per-node=N <launcher> [args] and UNSLOTH_FSDP2=1 (unless the caller set it; UNSLOTH_FSDP2=0 gives plain DDP). An argv list, no shell, so names with spaces and parentheses are safe. More processes than visible GPUs is refused; 0 or 1 GPU runs the script single process.
  • Inside torchrun (including a user's own multi-node torchrun) it runs python_scripts/<name>.py unchanged through runpy as __main__, with the working directory, sys.argv[0] and sys.path[0] as for python "python_scripts/<name>.py".
  • A small get_ipython stand-in covers exactly the calls the mirrors use (system, x = !cmd, %%capture, %%bash, %%file, %env, %cd). Shell commands, file writes and !cmd captures run once per node on local rank 0 after every local rank has reached the same step; the others then read its return code, output or error, and a rank that reached a different command fails with a clear divergence error instead of pairing the wrong steps. %env / %cd apply on every rank. Unknown magics raise instead of being skipped. Step markers live in a private (0700, owner checked) per-launch directory, so they are never reused across runs or writable by other users.
  • Package installs: by default an install line is skipped only when everything it asks for is already installed (version pins checked), otherwise it runs once per node; installs into another environment (--python, --target, --prefix, uv venv) always run. UNSLOTH_FSDP2_RUN_INSTALLS=1 always runs them, 0 always skips them. Diagnostics print the command template with URLs, tokens and index / header values redacted.
  • Scripts that are not safe on several processes (they start a server on a fixed port, clone or install through Python, or share temp files) are listed in FSDP2_SINGLE_PROCESS in update_all_notebooks.py with a one-line reason each: NeMo-Gym, Meta-Synthetic-Data, Synthetic_Data_Hackathon, Deepseek_OCR evaluation, Llama3 Ollama and Gemma3N inference (17 launchers with their AMD- / Kaggle- copies). Their launchers run single process with that reason printed; UNSLOTH_FSDP2_FORCE=1 overrides. A scan test flags any new mirror that starts a server or shells out from Python without being on the list.
  • Safer launch detection: a leftover LOCAL_RANK in the shell no longer hangs a plain run, SLURM / MPI multi-task steps are refused instead of nesting torchrun groups, %run from IPython uses a subprocess instead of replacing the kernel, spawn / forkserver children re-run the target with the stand-in, and marker waits have a bounded timeout (UNSLOTH_FSDP2_WAIT_TIMEOUT).
  • Saves, push_to_hub, generation and prints are left alone: Unsloth's own collective saves pick the writer rank, and a rank guard around them would hang the collective.

Generator

  • update_all_notebooks.py: generate_fsdp2_scripts() runs right after both convert_folder(..., "python_scripts", ...) calls (full pass, and the AMD-only pass scoped to AMD-), under the same --disable_convert_to_script switch. It stages into a temp dir, writes only changed files, removes stale launchers in scope, and _assert_fsdp2_mirror() checks the name sets in both directions.
  • fsdp2_scripts/README.md is generated too (usage, env vars, effective batch size, install skipping).

Tests (all wired into notebooks-tests-ci.yml)

  • tests/test_fsdp2_scripts_mirror.py (47): name sets equal both ways, each launcher equals the generator's render, helper copy equals its source byte for byte, idempotent regeneration, stale cleanup, AMD-only scope preserved.
  • tests/test_fsdp2_launch.py (CPU torch, 104 tests, own CI job): command construction, flag precedence, refusal above the visible GPU count, no second launch inside torchrun, 0 / 1 GPU single process, UNSLOTH_FSDP2 default and explicit 0, --dry-run, plus real two-process torch.distributed.run runs on CPU checking that every shell step ran exactly once, both ranks saw the file / env var / cwd, return codes reach both ranks, unknown magics raise, installs are skipped by default and run once with the env var, and a failing rank fails the launch.
  • tests/test_fsdp2_adapter_covers_scripts.py (no torch): every live get_ipython() call in python_scripts/, including inside the 30 live %%capture cells, maps to a supported form, so a new notebook with a new magic fails here instead of at run time.

scripts/lint_exec_literals.py passes; the stand-in's one eval (IPython style {expr} expansion) and one exec (a %%capture cell body) are recorded in the baseline with reasons.

Not covered yet

  • A multi-GPU run through the launchers (2 x T4 A/B against plain torchrun on the same scripts) is still to do; this is a draft until it lands.
  • 27 base notebooks keep their install %%capture cell live in python_scripts/ because remove_unwanted_section needs a later ### Unsloth heading. The launcher only runs those installs when something is missing; fixing the mirrors themselves is a separate change.
  • Native Windows without bash is refused with a clear error; real Windows, ROCm GPU counting, real SLURM / MPI and elastic restarts were not exercised.

update_all_notebooks.py gains generate_fsdp2_scripts and render_fsdp2_launcher.
After each python_scripts conversion (the full pass and the AMD-only pass) it
writes fsdp2_scripts/<name>.py for every python_scripts/<name>.py, copies
scripts/fsdp2_launch.py to fsdp2_scripts/unsloth_fsdp2_launch.py and renders
fsdp2_scripts/README.md. Files are staged before anything is replaced, only
changed files are written, and launchers with no script are removed (scoped to
AMD-* in the AMD-only pass). _assert_fsdp2_mirror checks both directions.

tests/test_fsdp2_scripts_mirror.py gates the committed tree against the
generator and exercises idempotence, stale cleanup and the AMD-only scope.
fsdp2_scripts is added to the exec-literal lint targets, the CI paths filter
and the CODEOWNERS notes.
tests/test_fsdp2_launch.py needs CPU torch, so it runs in the api-drift job;
tests/test_fsdp2_adapter_covers_scripts.py runs with the no-torch static checks.
scripts/fsdp2_launch.py runs a python_scripts/<name>.py mirror on every
visible GPU with Unsloth native FSDP2: it counts GPUs in a child process,
re-execs itself under torch.distributed.run --standalone for 2+ ranks and
runs the target in-process for 0 or 1. Inside torchrun the target runs via
runpy with a narrow get_ipython stand-in: shell steps and file writes run
once per node on local rank 0 while the other local ranks wait on a marker
carrying the return code, %env and %cd apply on every rank, and package
installs are skipped unless UNSLOTH_FSDP2_RUN_INSTALLS=1.

tests/test_fsdp2_launch.py covers the argv, count precedence, refusal,
worker mode and a real two-rank CPU torchrun. tests/test_fsdp2_adapter_covers_scripts.py
checks every live get_ipython() call in python_scripts/ is supported.
@danielhanchen danielhanchen self-assigned this Oct 28, 2026
…ction

- Marker folders are unique per launch: the parent sets UNSLOTH_FSDP2_RUN_ID
  before starting torchrun, other launchers fall back to the parent pid.
  Local rank 0 removes the folder at exit once the other ranks have read
  the last marker or exited.
- A LOCAL_RANK left in the shell without WORLD_SIZE / LOCAL_WORLD_SIZE is
  ignored, shell steps only synchronise with 2+ local ranks, and a waiting
  rank gives up after UNSLOTH_FSDP2_WAIT_TIMEOUT (default 6 hours) with a
  clear error.
- Installs: by default a pip / uv pip / apt-get / ollama install is skipped
  only when everything it asks for is already present (versions checked),
  otherwise it runs once per node. Installs into another environment always
  run. UNSLOTH_FSDP2_RUN_INSTALLS=1 / 0 force either way, and the single
  process path follows the same rules.
- Refuse to start torchrun from each of several SLURM / MPI tasks.
- Under IPython / Jupyter (%run) torchrun runs as a child process.
- multiprocessing spawn / forkserver children re-import the script with
  get_ipython defined and without repeating its shell steps.
- %cd prints the error and continues on a missing folder, like IPython.
- The single-process note names what was requested, and a failing GPU
  count prints the child's error.
… job

- Replacing or removing a read-only launcher clears the read-only bit and
  retries, as _rmtree_robust does.
- Unchanged launchers still get their permissions reset.
- The generate_fsdp2_scripts docstring says what a failure leaves behind.
- Ignore the .fsdp2_scripts.staging-* folder.
- The git diff summary counts only the root README.md as a README change.
- tests/test_fsdp2_launch.py runs once in its own CPU torch job instead of
  in every api-drift matrix cell.
- fsdp2_scripts/README.md describes the new install rules and variables.
…rors

- The marker folder is created 0700 and refused when it is a symlink, owned
  by another uid, or group / world writable. Markers are written to an
  exclusive, no-follow temp file and then os.replace'd, and their JSON
  content is validated before it is believed.
- Every shell / file step is a handshake: the other local ranks record their
  arrival with a hash of the unexpanded step, local rank 0 waits for all of
  them, then records its return code, output, install decision and any
  error. Ranks that reach different steps fail with a divergence error.
- x = !cmd runs once per node under the same install rules and every rank
  gets local rank 0's output. Only local rank 0 decides whether an install
  runs; the others follow its record.
- %%file errors on local rank 0 are raised on every rank.
- Workers always use the adapter, even where an IPython get_ipython exists.
- Log lines name a step by its unexpanded text with URL user info, index
  URLs, header values and token-like strings masked.
- Shell steps refuse to run without bash, and on Windows refuse WSL's bash.
- main() takes single_process / reason: such a script runs single process
  with a note, and more processes are refused unless UNSLOTH_FSDP2_FORCE=1.
- --dry-run names the get_ipython adapter in use.
…k safe

- FSDP2_SINGLE_PROCESS in update_all_notebooks.py names, by base name across
  the AMD- / Kaggle- / HuggingFace Course- variants, the scripts that start
  servers on fixed ports, clone or install through Python subprocess calls,
  or share temporary files between ranks, each with its reason. Their
  launchers pass single_process=True and say so in their header.
- A static scan of python_scripts/ flags subprocess.Popen, os.system, vllm
  serve and --port outside get_ipython() calls in any script missing from
  the list, and stale entries in the list.
- Launchers import os and sys on separate lines.
- _assert_fsdp2_mirror also requires README.md.
- The README documents shell step ordering, single-process scripts and
  UNSLOTH_FSDP2_FORCE.
@danielhanchen

Copy link
Copy Markdown
Member Author

@codex review

@danielhanchen

Copy link
Copy Markdown
Member Author

@codex security review

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 28, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ⚠️ Failed 2026-10-31T01:18:42.940363Z
We couldn’t complete this review. Comment @codex review to try again.
ab83e62 Manual request
🔒 Security Review ✅ Completed 2026-10-31T01:24:09.537600Z ab83e62 Draft marked ready
ℹ️ 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" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

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

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread fsdp2_scripts/Oute_TTS_(1B).py Outdated
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from unsloth_fsdp2_launch import main
if __name__ == "__main__":
main(__file__, 'Oute_TTS_(1B).py')

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Run OuteTTS in a single process until its file deletions are safe

With two or more visible GPUs, this launcher runs the installation capture cell in python_scripts/Oute_TTS_(1B).py:38 on every rank. Although the adapter coordinates the git clone shell step, the subsequent three os.remove(...) calls execute independently on every rank against the same checkout. Once one rank deletes a file, another raises FileNotFoundError, aborting the distributed run before model loading. Add this script to the generator's single-process list, or coordinate those Python-side deletions as well.

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.

Guarded in adb30ae. Oute_TTS_(1B) and its AMD- and Kaggle- copies are now in FSDP2_SINGLE_PROCESS. The reason given is that the install cell clones OuteTTS and then deletes files from that single checkout with os.remove. Their launchers run the script in one process and print that reason. More than one process is refused unless UNSLOTH_FSDP2_FORCE=1.

The static scan in tests/test_fsdp2_scripts_mirror.py now reads live %%capture bodies as well as top-level code. Besides servers and Python-side process launches, it flags os.remove, os.unlink, shutil.rmtree, shutil.move and os.rename. It found no other script that is not already on the list.

In the committed python_scripts/ the install cell of all three Oute_TTS mirrors is commented out, so the removes are not live there today. The list entry covers the cell if it becomes live again.

Comment thread scripts/fsdp2_launch.py
Comment on lines +192 to +193
path = re.split(r"[?#]", spec, 1)[0].rstrip("/")
last = path.rsplit("/", 1)[-1]

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 Avoid inferring subdirectory distributions from repository names

When the other dependencies are already installed, the smart install check can incorrectly skip a required triton_kernels installation. The live installation cells in scripts such as gpt_oss_(20B)_Reinforcement_Learning_2048_Game.py:27 request git+https://github.com/triton-lang/triton.git@...#subdirectory=python/triton_kernels, but this parser discards the fragment and identifies the distribution as triton. Consequently, an installed triton satisfies the check even when triton_kernels is absent, and the entire install command is skipped. Treat subdirectory URLs with unknown distribution metadata as uncheckable rather than assuming the repository basename is the package name.

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.

Fixed in adb30ae. The install check no longer guesses a distribution from a repository URL.

  • A requirement with #subdirectory= cannot be checked, so the install runs. That covers the triton python/triton_kernels line in gpt_oss_(20B)_Reinforcement_Learning_2048_Game.py.
  • Any other URL without an explicit name (#egg= or name @ url) cannot be checked either, so the install runs.
  • Only the unslothai/unsloth and unslothai/unsloth-zoo repositories map to their names, and only without a subdirectory.

tests/test_fsdp2_launch.py::test_url_requirements_need_an_explicit_name covers these cases. It checks the exact triton URL, which a second test confirms is still in the script.

@chatgpt-codex-connector

Copy link
Copy Markdown

🛡️ Codex Security Review

Security review completed. No security issues were found in this pull request.

Reviewed commit: b1e96d0059

View security finding report

Only the user who started this review can view the report in Codex.

ℹ️ About Codex security reviews in GitHub

This is an experimental Codex feature. Security reviews are triggered when:

  • You comment "@codex security review"
  • A regular code review gets triggered (for example, "@codex review" or when a PR is opened), and you’re opted in so security review runs alongside code review

Once complete, Codex will leave suggestions, or a comment if no findings are found.

- Oute_TTS_(1B) and its AMD- / Kaggle- copies join FSDP2_SINGLE_PROCESS:
  its install cell clones OuteTTS and then deletes files from that one
  checkout with os.remove, which fails on the second rank.
- The single-process scan now also reads live %%capture bodies and flags
  os.remove / os.unlink / shutil.rmtree / shutil.move / os.rename, besides
  servers and Python-side process launches.
- The install check no longer guesses a distribution from a repository URL.
  A #subdirectory= requirement (for example triton's python/triton_kernels)
  or a URL without an explicit name (#egg= or name @ url) cannot be checked,
  so the install runs. Only the unslothai/unsloth and unsloth-zoo repos map
  to their names, and only without a subdirectory.
@danielhanchen

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Bravo.

Reviewed commit: adb30aef41

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

@oobabooga

Copy link
Copy Markdown
Member

I tested this PR on a machine with two GPUs (RTX 6000 Ada 48 GB and RTX 3090 24 GB). The training path works. I found a few problems in how the launcher handles one script and package installs, and fixed them in four commits. They are in a mirror PR on my fork, and they apply cleanly on top of this PR's current head (0944fa2): oobabooga#1

What I ran on two GPUs

Each run used the generated launcher in fsdp2_scripts/, with the scripts left unmodified.

  • Llama3.2_(1B_and_3B)-Conversational: trained 60 steps with native FSDP2, ran the generation cells and saved the LoRA, exit code 0. I also ran the same script with UNSLOTH_FSDP2=1 torchrun directly, without the launcher. Both runs reached the same final train_loss (0.8408), and their per-step losses differed by at most 0.0008.
  • Falcon_H1-Alpaca: when I tested it, this script still had live %%capture and !pip install cells (python_scripts: end the Installation section without an Unsloth heading so 38 mirrors no longer start with a live get_ipython call #417 has since commented them out). Through the launcher it trained to completion, exit code 0. With torchrun directly, it stops at the first of those cells with NameError: name 'get_ipython' is not defined.
  • Qwen3_(4B)-GRPO with vLLM 0.32.0: each GPU ran its own vLLM engine alongside native FSDP2 training, and the GRPO steps logged rewards. A full run takes about an hour, so I stopped it at step 4 of 100. The final save was not reached.

The four commits

  1. 0faf739: Meta_Synthetic_Data_Llama3_2_(3B) now runs as a single process. The script calls SyntheticDataKit.from_pretrained, which starts a vllm serve process on port 8000 by itself. With two GPUs, both processes try to start that server on the same port, and the second one fails. The 8B version of this notebook is already in FSDP2_SINGLE_PROCESS for the same reason. I added the 3B one there, and the test in tests/test_fsdp2_scripts_mirror.py that looks for server-starting calls now also catches SyntheticDataKit.from_pretrained.

  2. 588e516: Install commands now run in the same Python environment the launcher checked. The launcher skips an install when the package is already installed, and it checks that in the Python environment that is running the launcher. But the !pip command itself ran whichever pip came first on PATH, which can belong to a different environment. For example, when I started Falcon H1 with a venv's Python by its path (without activating the venv), the launcher checked the venv but sent pip uninstall unsloth -y to the system's /usr/bin/pip. Now pip, pip3, python and python3 in shell steps always mean the launcher's own Python. When that Python is in a venv, VIRTUAL_ENV also points to it, so uv pip installs there too.

  3. de9feb7: unsloth install-kernels had the same problem and is fixed the same way. Also, if one of PIP_TARGET, PIP_PREFIX, PIP_ROOT, PIP_PYTHON, UV_PYTHON or UV_SYSTEM_PYTHON is set, the install may go to a different location than the one the launcher checked, so the launcher now always runs it instead of skipping it. It already did this for the matching command-line flags such as --target.

  4. 1cee870: The launcher now skips an install only when it is certain the install would go into the environment it checked. In every other case it runs the install. These cases now always run:

    • uv pip started from a Python that is not in a venv. uv then picks its own target (VIRTUAL_ENV, CONDA_PREFIX, or a .venv folder), which may not be the launcher's Python.
    • Commands that set a variable inline, such as PIP_TARGET=dir pip install x.
    • Commands that contain export, cd, source, sudo, env or conda.
    • pip or python called by version or full path, such as pip3.12 or /other/env/bin/pip.

    None of the current notebook scripts use the last three forms. The one visible change is that the AMD scripts' uv pip install --system lines now run on every launch when the launcher's Python is not in a venv.

All tests pass on the branch, and the new tests fail on the previous launcher. For each case where the old code wrongly skipped an install, I reproduced it with real installs into throwaway environments and confirmed the package now installs in the right place. I did not test ROCm, Windows, multi-node, or a full GRPO run with its final save.

@oobabooga

Copy link
Copy Markdown
Member

FSDP2 results on two GPUs (RTX 6000 Ada 48 GB + RTX 3090 24 GB, connected over PCIe through the host bridge, no NVLink). Torch 2.12.1, unsloth 2026.10.10. LoRA r=16 on alpaca-cleaned, batch 2, sequence length 1024, global batch 8 in every run (1 GPU uses gradient accumulation 4, 2 GPUs use 2). All runs went through the launcher.

The default layout copies the frozen base model to every GPU and shards only the LoRA weights. Unsloth picks it whenever the model fits on one GPU. The split layout (UNSLOTH_FSDP2_LAYOUT=sharded) shards the base weights across the GPUs.

Llama-3.2-3B-Instruct, 30 steps

Run Final loss Largest per-step loss difference vs 1 GPU Peak memory per GPU during training Training time
4-bit, 1 GPU 1.3636 2.84 GiB 24 s
4-bit, 2 GPUs, default layout 1.3637 0.0010 2.85 / 2.71 GiB 19 s
4-bit, 2 GPUs, split layout 1.3637 0.0008 2.98 / 2.98 GiB 83 s
16-bit, 1 GPU 1.3518 6.58 GiB 13 s
16-bit, 2 GPUs, default layout 1.3518 0.0017 6.60 / 6.41 GiB 14 s
16-bit, 2 GPUs, split layout 1.3518 0.0018 5.09 / 5.09 GiB 248 s
16-bit, 2 GPUs, split layout + UNSLOTH_FSDP2_SHARDED_LOAD=1 1.3519 0.0014 5.09 / 5.09 GiB 266 s

In every run, both GPUs generated the same text after training and the LoRA adapter was saved. UNSLOTH_FSDP2_SHARDED_LOAD=1 lowers the peak memory after loading from 6.0 to 3.4 GiB per GPU.

Qwen3-14B 16-bit (about 28 GB of weights), 10 steps

  • RTX 3090 alone: the model does not fit. Part of it is offloaded, and SFTTrainer fails with NotImplementedError: Cannot copy out of meta tensor.
  • RTX 6000 Ada alone: trains, with 28.5 GiB peak memory.
  • 2 GPUs with default settings: the 3090 runs out of memory inside from_pretrained, because every GPU loads the full model before it is sharded.
  • 2 GPUs with UNSLOTH_FSDP2_SHARDED_LOAD=1: Unsloth switches to the split layout by itself (40 blocks). Training works. The per-step losses differ from the Ada-alone run by at most 0.0018, peak memory is 21.3 GiB per GPU, and training takes 38.5 s per step (1.7 s per step on the Ada alone). save_pretrained works: the saved adapter loads on one GPU and generates normally. model.generate runs out of memory on the 3090, because under native FSDP2 generate gathers the whole model onto every GPU (native_fsdp2_unsharded in unsloth/models/fsdp2_native.py).

Notebook launcher

fsdp2_scripts/Llama3.2_(1B_and_3B)-Conversational.py with UNSLOTH_FSDP2_LAYOUT=sharded trains 60 steps in the split layout (28 blocks), runs the generation cells, saves llama_lora and exits with code 0. Training takes 439 s, against 208 s in the default layout.

Summary

  • Loss on 2 GPUs matches 1 GPU in both layouts, for 4-bit and 16-bit.
  • The launchers do not set UNSLOTH_FSDP2_SHARDED_LOAD=1, and the README does not mention it. Without it, a 16-bit model larger than the smallest GPU runs out of memory while loading.
  • With it, such a model trains and saves, but model.generate after training runs out of memory, and every notebook calls generate after training.

oobabooga and others added 5 commits October 29, 2026 08:36
…reter; document sharded load and generate memory

The exported python / python3 / pip / pip3 / unsloth functions call
$UNSLOTH_FSDP2_PYTHON. When that is a bash wrapper (sys.executable is the
wrapper under `exec -a "$0"`, as in Nix-style wrappers) which runs `python3`
or `pip` by name, the wrapper imports the exported functions and calls
itself again without end. The pinned call now sets _UNSLOTH_FSDP2_PINNED,
and below it the names resolve to PATH. _shell_env() clears the variable so
a launcher started from another launcher's shell step pins again.

README: document UNSLOTH_FSDP2_SHARDED_LOAD (read by Unsloth, 16-bit only)
and that model.generate under native FSDP2 gathers the full model on every
process, so it can run out of memory on a small GPU after training.
@danielhanchen

Copy link
Copy Markdown
Member Author

@oobabooga Thanks for testing this on two GPUs and for the four commits. I cherry-picked all of them onto the PR branch with your authorship kept (git cherry-pick -x), so they show up as your commits here.

What I checked before taking them:

  • In and outside a venv: with a venv's Python started by path (not activated), python, python3, pip, pip3 and uv pip in a shell step all resolve to that venv, including from a nested bash -c. With the system Python, they resolve to it, and uv pip is never skipped. I ran this from a venv whose path has spaces and parentheses, and the quoting holds.
  • export -f needs bash. Shell steps always run through FSDP2Shell._bash(), which picks /bin/bash (Git Bash on Windows) and never sh, so this is fine. Under /bin/sh (dash here) the preamble would fail, but nothing runs it that way.
  • _target_doubt on the real corpus: I scanned every ! / %%bash / %%capture shell line in all 467 python_scripts/*.py, live and commented out. In a venv, every live install is still trusted, so nothing that used to be skipped now runs. Outside a venv the only change is the live uv pip install --python litert_env/bin/python ... line, which already always ran because of --python. The commented-out AMD install cells set ROCM_TAG inline, so they would always run if they became live again. That is conservative, not wrong.

One problem I found and fixed on top in 237e076: if $UNSLOTH_FSDP2_PYTHON is itself a bash wrapper that runs python3 or pip by name, the wrapper imports the exported functions and calls itself again without end. That happens when sys.executable is the wrapper, for example a wrapper that uses exec -a "$0", as Nix-style wrappers do. I reproduced it. The pinned call now sets _UNSLOTH_FSDP2_PINNED, and below it the names fall back to PATH. _shell_env() clears it, so a launcher started from another launcher's shell step pins its own interpreter again. tests/test_fsdp2_launch.py::test_wrapper_interpreter_does_not_recurse covers this, and it fails on your four commits alone.

A side effect worth knowing: pip is now python -m pip, so in a venv created without pip (the uv default), !pip install fails with No module named pip instead of reaching some other environment's pip. I think that is the right failure. Before, it could install into the wrong place.

The launcher and fsdp2_scripts/unsloth_fsdp2_launch.py are still byte-equal after regenerating. tests/test_fsdp2_*.py (205) and the regen / mirror parity tests (1726) pass.

@danielhanchen

Copy link
Copy Markdown
Member Author

@oobabooga Thanks, these numbers are very useful, especially the Qwen3-14B run on the mixed 48 GB + 24 GB pair.

I added both points to the README in 237e076. The template is in update_all_notebooks.py, and fsdp2_scripts/README.md is regenerated from it:

  • UNSLOTH_FSDP2_SHARDED_LOAD now has a row in the environment variables table. It says that Unsloth reads it, not the launchers. With 1, under native FSDP2, each process reads only its own slice of the nn.Linear weights while loading (the output head and the other weights are read whole), instead of every process loading the full model first. It is for 16-bit models larger than the smallest GPU. Unsloth refuses 4-bit / 8-bit loads with it, so a notebook with load_in_4bit = True needs that changed. It also cannot be combined with UNSLOTH_TP_SIZE. All of this comes from unsloth/models/fsdp2_load.py and tp_env.py on current main.
  • A new "Things to know" entry says that under native FSDP2, generate runs inside native_fsdp2_unsharded, which gathers the full weights onto every process until it returns. So a model that only fits sharded can train and save its adapter, and still run out of memory on the smaller GPU in the generation cells after training. That matches what you saw on the 3090.

I left the launchers unchanged: they still do not set UNSLOTH_FSDP2_SHARDED_LOAD=1. Most notebooks load in 4-bit, which sharded loading refuses, so turning it on by default would break them. Making generate work on a model that does not fit on one GPU is a change for Unsloth itself, not for this folder.

@danielhanchen

Copy link
Copy Markdown
Member Author

@codex review

@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: 237e076bad

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread fsdp2_scripts/Llasa_TTS_(1B).py Outdated
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from unsloth_fsdp2_launch import main
if __name__ == "__main__":
main(__file__, 'Llasa_TTS_(1B).py')

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 Run Llasa preprocessing in a single process

With multiple visible GPUs, every rank runs preprocess_and_save in python_scripts/Llasa_TTS_(1B).py, opening the same processed_data_memmap/input_ids.memmap with mode w+ and overwriting input_ids_shape.npy after every example. These Python-side writes bypass the shell adapter's coordination. A rank finishing preprocessing can therefore load a shape file while another rank truncates and rewrites it, causing np.load to fail or giving ranks different dataset lengths before distributed training. The 3B script and AMD/Kaggle variants use the same implementation. Add both Llasa base names to the generator's single-process list until their preprocessing writes and subsequent reads are coordinated.

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.

Fixed in ab83e62. Llasa_TTS_(1B) and Llasa_TTS_(3B) (and their AMD- and Kaggle- copies) are now in FSDP2_SINGLE_PROCESS, so their launchers run one process and refuse --nproc-per-node above 1 unless UNSLOTH_FSDP2_FORCE=1. The mirror test now flags np.memmap(..., mode = 'w+'), so a new script that writes a shared memmap from every rank has to be listed too (4 failures on the previous head, 210 / 210 passing now across the fsdp2 test files). --dry-run on the 1B launcher prints the single-process note with the reason.

The notebook builds its outer model with OmniVoice.from_pretrained and then
calls model.merge_and_unload() and omnivoice.save_pretrained() directly, so the
export never goes through Unsloth's collective FSDP2 path. On several ranks the
merge fails with "aten.add_.Tensor got mixed torch.Tensor and DTensor" when the
model fits one GPU, and the save fails with "invalid python storage" when it is
sharded. Run it (and its Kaggle variant) in one process, and teach the mirror
test to flag OmniVoice.from_pretrained so a new script with the same pattern
must be listed.
@danielhanchen

Copy link
Copy Markdown
Member Author

Pushed 898a06c: OmniVoice_(0.6B)-TTS (and its Kaggle variant) now runs single process under fsdp2_scripts/.

Why: the notebook builds its outer model with OmniVoice.from_pretrained and then calls model.merge_and_unload() and omnivoice.save_pretrained() directly. That export never goes through Unsloth's collective FSDP2 path, so on several ranks it breaks after training:

Layout (2 ranks, CPU / gloo, tiny Qwen3 LoRA inside the real OmniVoice class) Forward / backward Merge Save
Unsharded pass pass pass
FSDP2 sharded pass, loss matches unsharded pass Attempted to access the data pointer on an invalid python storage (rank 0)
FSDP2, model fits one GPU pass, loss matches unsharded aten.add_.Tensor got mixed torch.Tensor and DTensor (both ranks) fails too

Training was numerically fine on every arm (loss 2.788172960281372 on all three). Only the merge and save fail. A collective export would mean changing the notebook's save cell and depending on an Unsloth export path that OmniVoice-built models do not use today. Single process is the same treatment Meta_Synthetic_Data_Llama3_2_(3B) got.

Change:

  • update_all_notebooks.py: OmniVoice_(0.6B)-TTS.py added to FSDP2_SINGLE_PROCESS with its reason. The README list was updated too, and both launchers and fsdp2_scripts/README.md were regenerated.
  • tests/test_fsdp2_scripts_mirror.py: the unsafe-pattern scan now also flags OmniVoice.from_pretrained(, and the two OmniVoice launchers were added to the single-process launcher test.

Evidence:

  • Before the generator change, the updated tests fail: test_scripts_that_start_servers_or_processes_are_single_process and test_single_process_launchers_say_so for both OmniVoice launchers (3 failed, 53 passed). After it, tests/test_fsdp2_scripts_mirror.py passes 56/56, and all three fsdp2 test files pass 207/207.
  • python "fsdp2_scripts/OmniVoice_(0.6B)-TTS.py" --dry-run now plans a single process with the reason. With --nproc-per-node 2 it is refused unless UNSLOTH_FSDP2_FORCE=1. The Kaggle launcher behaves the same way.

preprocess_and_save opens processed_data_memmap/input_ids.memmap with mode w+ and
rewrites input_ids_shape.npy after every example. On several ranks each process
truncates and rewrites the same files while another may already be reading the
shape back, so np.load can fail or ranks can see different dataset lengths. Run
both scripts (and their AMD- and Kaggle- copies) in one process, and teach the
mirror test to flag np.memmap opened for writing so a new script with the same
pattern must be listed.
@danielhanchen
danielhanchen marked this pull request as ready for review October 31, 2026 01:18
@danielhanchen

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, add credits to your account and enable them for code reviews in your settings.

1 similar comment
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, add credits to your account and enable them for code reviews in your settings.

On Windows os.execv starts a detached process, joins the arguments
without quoting (a path with a space splits) and exits with code 0 at
once, so the shell got its prompt back while torchrun ran on and never
saw its exit code. Use the same subprocess path as the IPython case
there.
The recursion guard read $_UNSLOTH_FSDP2_PINNED bare, so a !cmd or
%%bash step that turns on nounset (set -u, set -euo pipefail) and then
calls python, pip or unsloth stopped with 'unbound variable' (exit 127),
where the notebook itself runs fine. Read it as ${...:-}.
…ocess

EmbeddingGemma2_(300M)-Audio, -Image_Text and -Multimodal_Search write their media
files with open(path, "wb").write(...) and read them back for training or
encoding. On several ranks each process truncates and rewrites the same paths, and
Multimodal_Search's MSR-VTT loop skips a video that already exists, so a rank can
decode a file another rank has created but not yet filled (zero bytes, "moov atom
not found"). Run the three scripts and their Kaggle copies in one process, and teach
the mirror test to flag open(..., "wb").write(...) so a new script with the same
pattern must be listed.
_launcher_env_problem() ran only when two or more processes were about to
be launched. In a SLURM step with one visible GPU per task (or with
--nproc-per-node 1, no GPU, or a single-process script) every task fell
through to a plain single-process run, so SLURM_STEP_NUM_TASKS=2 without
torchrun rank variables quietly started independent training runs that
write the same output paths. Check the scheduler environment once the
process is known not to be a worker, before the single-process branch and
the GPU-count choice. Workers with LOCAL_RANK and WORLD_SIZE /
LOCAL_WORLD_SIZE still run.
A sweep of every python_scripts base for plain-Python writes that all ranks
make to one path found two more groups:

- Deepseek_OCR_(3B), Deepseek_OCR_(3B)-Eval and Deepseek_OCR_2_(3B) save a
  dataset image to your_image.jpg and pass that path to model.infer. One
  process can open the JPEG while another is truncating it, and the model's
  loader then fails with AttributeError.
- OpenEnv_gpt_oss_(20B)_Reinforcement_Learning_2048_Game and its _BF16 copy
  start a uvicorn OpenEnv server through launch_openenv on a random port.
  The RNG is seeded the same on every rank, so all ranks pick the same
  port; the later ones fail to bind, pass the health check against the
  first rank's server and play its single game.

Run them and their AMD- / Kaggle- copies in one process. The mirror test
now flags launch_openenv( and PIL saves to a fixed image path, and a new
AST check flags any literal path written from Python (open w/a/x, .save,
sf.write, to_json, copy, ...) that the same run names again, leaving out
Unsloth's collective saves and rewrites of the same path, so a new script
that writes a file and reads it back must be listed.
install_decision() ignored every segment that was not an install, so a step such as
"pip install x" followed by printf, git clone, wget, mkdir or python -c was skipped
whole once x was installed, and its other commands never ran. Now a step is skipped
only when the rest is read-only (apt's index refresh, `command -v`, an output filter
like tail / grep, or ollama's own curl | sh); pip subcommands other than install /
uninstall / list / show / freeze / check also keep it running.
Every process generates its own dataset through the OpenAI API and then writes it to
raft_train.jsonl with Dataset.to_json. The exports can differ in length, so
interleaved writes can leave bytes of a longer export after a shorter one and an
invalid JSONL file. Run it (and its AMD- and Kaggle- copies) in one process, and
teach the mirror test to flag to_json / to_csv to a fixed path.
…aunchers

datasets' Dataset.map(num_proc = N) starts its workers through the multiprocess
package, whose spawn module was not patched, so a spawned child re-imported the
script without get_ipython and died with NameError. Patch multiprocess.spawn the
same way as multiprocessing.spawn when it is installed.

The node-local process count fell back to the global WORLD_SIZE when
LOCAL_WORLD_SIZE was missing, so under DeepSpeed's launcher (LOCAL_SIZE) on two
nodes with one GPU each the first shell step waited for a local rank that does not
exist. Read LOCAL_SIZE and the Open MPI / MPICH local counts first.
… are here

The install check parsed `pkg[extra]` down to `pkg`, so `pip install "requests[socks]"`
was skipped whenever requests was installed, even without PySocks. Read each requested
extra's dependencies from the installed distribution's metadata and skip only when every
one of them is installed at a matching version; an extra the distribution does not
declare, or metadata that cannot be read, keeps the step running.
…nd pip

The pinned python / python3 / pip / pip3 / unsloth functions always ran the launcher's
interpreter, also after a step activated another virtualenv or conda environment, so
`source other/bin/activate; pip install ...` installed into the launcher's environment
(Unsloth Studio's setup script activates its own venv this way). The functions now fall
back to PATH once VIRTUAL_ENV or CONDA_PREFIX differs from the launcher's, in the step
and in any bash it starts.
…e process

Under native FSDP2 model.generate runs inside native_fsdp2_unsharded, which all-gathers on
every rank, so every rank has to make the same number of generate calls. A sweep of every
python_scripts base for loops and retries whose generate count follows each rank's own
output found four scripts that break that:

- gpt_oss_(20B)_Reinforcement_Learning_GRPO_Minesweeper_Game_BF16: the eval callback and
  play_full_game sample a move per turn and stop when a rank's move does not parse or its
  game ends. GRPO seeds per device, so ranks stop after different numbers of calls.
- Spark_TTS_(0_5B): samples again after a codec shape error, which can hit one rank only.
- Openenv_wordle_grpo: play_wordle and the training rollouts stop when each rank's own
  Wordle game is done.
- Qwen3_(4B)-Tool_Calling and Qwen3_(4B)-GRPO-Tool_Use: run_tools samples a reply and
  returns once a rank's reply has no tool call.

Sesame_CSM_(1B)-TTS and Spark_TTS_(0_5B) also write each rank's own sampled speech to the
same WAV files with sf.write, which can mix samples from several ranks.

Run them and their AMD- / Kaggle- / HuggingFace Course- copies in one process. The mirror
test gains two AST checks: one flags audio / image writes to a path the script spells out
(literal, f-string or a name bound to one; in-memory buffers are left out), and one flags a
generate, fast_generate, infer or generate_rollout_completions call, followed through the
script's own helpers, inside a while loop, a loop with a break / continue / return, or a
try whose handler generates again. A for loop over a fixed list with no early exit stays
allowed. Each check has its own self-test.
@danielhanchen
danielhanchen merged commit d85d113 into main Oct 31, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants