-
-
Notifications
You must be signed in to change notification settings - Fork 7.1k
version-compat CI: fake CPU training runs for SFT/GRPO/DPO #6965
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
5005998
version-compat CI: fake CPU training runs for SFT/GRPO/DPO
danielhanchen 6ed663a
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] dd2986b
cpu fake-train: force adamw_torch + disable dynamo for CPU runner
danielhanchen 1ac8376
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] fab5a2b
cpu fake-train: write checkpoints under pytest tmp_path
danielhanchen d901e5d
version-compat CI: disable dynamo at process level for the fake-run job
danielhanchen File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,285 @@ | ||
| # SPDX-License-Identifier: AGPL-3.0-only | ||
| # Copyright 2026-present the Unsloth AI Inc. team. | ||
| """Fake CPU training runs for the Unsloth-patched SFT / GRPO / DPO trainers. | ||
|
|
||
| The patch-run canary (test_trl_grpo_fake_run.py) only compiles + inspects the | ||
| generated trainer source. This goes one layer deeper: it actually runs | ||
| `trainer.train()` for a couple of steps on a CPU-only runner, under the CUDA | ||
| spoof, wrapping a plain (tiny, random-weight) HF model in the Unsloth-patched | ||
| trainer. That exercises the real train() loop at runtime -- data collation, | ||
| generation (GRPO), the injected `_get_per_token_logps_and_entropies`, loss, | ||
| backward, optimizer -- so a TRL or transformers change that breaks the loop | ||
| (not just the source structure) surfaces here. No GPU, no meaningful numerics. | ||
|
|
||
| What it does NOT cover: Unsloth's Triton/GPU-optimized model kernels (the | ||
| FastLanguageModel fast path) cannot run on CPU, so this validates the | ||
| trainer-transform + orchestration layer with a standard forward, not the | ||
| optimized kernels. | ||
| """ | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import os | ||
|
|
||
| # CPU-only: no torch.compile / dynamo (it reaches into the CUDA accelerator), no | ||
| # Unsloth kernel compile, no mixed precision. Must be set before torch/unsloth. | ||
| os.environ.setdefault("UNSLOTH_COMPILE_DISABLE", "1") | ||
| os.environ.setdefault("TORCHDYNAMO_DISABLE", "1") | ||
| os.environ.setdefault("TORCH_COMPILE_DISABLE", "1") | ||
| os.environ.setdefault("ACCELERATE_MIXED_PRECISION", "no") | ||
|
|
||
| import importlib | ||
| import importlib.util | ||
| import sys | ||
| from pathlib import Path | ||
|
|
||
| import pytest | ||
|
|
||
|
|
||
| # torch is needed for everything below (daily-fresh-fetch collects this dir with | ||
| # only pytest installed); skip the whole module cleanly when it is absent. | ||
| if importlib.util.find_spec("torch") is None: | ||
| pytest.skip( | ||
| "torch not installed; fake CPU train needs the real runtime", allow_module_level = True | ||
| ) | ||
|
|
||
| # Apply the CUDA spoof before any unsloth-touching import. | ||
| _SPOOF_DIR = Path(__file__).resolve().parents[1] | ||
| sys.path.insert(0, str(_SPOOF_DIR)) | ||
| import _zoo_aggressive_cuda_spoof as _spoof # noqa: E402 | ||
|
|
||
| _spoof.apply() | ||
|
|
||
| import torch # noqa: E402 | ||
|
|
||
|
|
||
| # The generated GRPO trainer hard-decorates hot functions with @torch.compile, | ||
| # which dynamo processes even under the disable env vars, reaching into | ||
| # torch.accelerator (real CUDA) on a GPU-less box. Make torch.compile an eager | ||
| # passthrough before unsloth generates/imports the trainer -- same logic, no | ||
| # dynamo. (An eager CPU run is exactly what we want here.) | ||
| def _eager_compile( | ||
| model = None, | ||
| *args, | ||
| **kwargs, | ||
| ): | ||
| if callable(model): | ||
| return model | ||
| return lambda fn: fn | ||
|
|
||
|
|
||
| torch.compile = _eager_compile | ||
|
|
||
| # Belt-and-suspenders: if any @torch.compile still routes through dynamo, let it | ||
| # fall back to eager instead of crashing, and stop its stream-capture probe from | ||
| # reaching torch.accelerator -> real CUDA on a GPU-less box. | ||
| try: | ||
| import torch._dynamo # noqa: E402 | ||
| torch._dynamo.config.suppress_errors = True | ||
| except Exception: | ||
| pass | ||
| if hasattr(torch, "accelerator"): | ||
| torch.accelerator.is_available = lambda *a, **k: False | ||
|
|
||
|
|
||
| # Redirect any `device="cuda"` tensor allocation / `.to("cuda")` / `.cuda()` to | ||
| # CPU. The aggressive spoof deliberately keeps real allocators, but a fake CPU | ||
| # train needs cuda-targeted ops (e.g. inductor's init_gpu_context does | ||
| # `torch.empty(1, device="cuda")`) to land on CPU instead of erroring. | ||
| def _is_cuda_dev(d): | ||
| try: | ||
| return d is not None and torch.device(d).type == "cuda" | ||
| except Exception: | ||
| return False | ||
|
|
||
|
|
||
| for _name in ( | ||
| "empty", | ||
| "zeros", | ||
| "ones", | ||
| "full", | ||
| "tensor", | ||
| "arange", | ||
| "randn", | ||
| "rand", | ||
| "randint", | ||
| "empty_like", | ||
| "zeros_like", | ||
| "ones_like", | ||
| ): | ||
| _orig = getattr(torch, _name, None) | ||
| if _orig is None: | ||
| continue | ||
|
|
||
| def _redir( | ||
| *args, | ||
| _orig = _orig, | ||
| **kwargs, | ||
| ): | ||
| if _is_cuda_dev(kwargs.get("device")): | ||
| kwargs["device"] = "cpu" | ||
| return _orig(*args, **kwargs) | ||
|
|
||
| setattr(torch, _name, _redir) | ||
|
|
||
| _orig_to = torch.Tensor.to | ||
|
|
||
|
|
||
| def _to_cpu(self, *args, **kwargs): | ||
| args = tuple("cpu" if _is_cuda_dev(a) else a for a in args) | ||
| if _is_cuda_dev(kwargs.get("device")): | ||
| kwargs["device"] = "cpu" | ||
| return _orig_to(self, *args, **kwargs) | ||
|
|
||
|
|
||
| torch.Tensor.to = _to_cpu | ||
| torch.Tensor.cuda = lambda self, *a, **k: self | ||
|
|
||
| # Extra CUDA stubs the aggressive spoof lacks, needed to walk a real train(): | ||
| # Adam's _cuda_graph_capture_health_check() probes stream capture. | ||
| torch.cuda.is_current_stream_capturing = lambda *a, **k: False | ||
| try: | ||
| import torch.cuda.graphs as _cg # noqa: E402 | ||
| _cg._cuda_isCurrentStreamCapturing = lambda *a, **k: False | ||
| except Exception: | ||
| pass | ||
|
|
||
| # A broken libmlx.so in the shared site-packages crashes transformers' Mac-only | ||
| # is_mlx_array probe on Linux; disable it. | ||
| try: | ||
| import transformers.utils.generic as _g # noqa: E402 | ||
| _g._is_mlx_available = False | ||
| except Exception: | ||
| pass | ||
|
|
||
|
|
||
| # Dense (non-MoE) tiny model on purpose: MoE models route through Unsloth's | ||
| # grouped_gemm Triton kernel, which is CUDA-only and cannot run on a CPU runner. | ||
| _MODEL = "hf-internal-testing/tiny-random-LlamaForCausalLM" | ||
|
|
||
|
|
||
| def _load_plain(): | ||
| """Tiny plain HF model + tokenizer on CPU. Skips (not fails) if the model | ||
| cannot be fetched -- that is a network/hub issue, not an unsloth regression.""" | ||
| from transformers import AutoModelForCausalLM, AutoTokenizer | ||
|
|
||
| try: | ||
| tok = AutoTokenizer.from_pretrained(_MODEL) | ||
| model = AutoModelForCausalLM.from_pretrained(_MODEL, dtype = torch.float32) | ||
| except OSError as e: # hub unreachable / model missing | ||
| pytest.skip(f"could not fetch {_MODEL} (network/hub): {str(e)[:150]}") | ||
| if tok.pad_token is None: | ||
| tok.pad_token = tok.eos_token | ||
| # Unsloth's GRPO path calls model.for_training()/for_inference() (added by | ||
| # FastLanguageModel). A plain HF model lacks them; supply minimal train/eval | ||
| # equivalents so the loop proceeds without the optimized wrapper. | ||
| if not hasattr(model, "for_training"): | ||
| model.for_training = lambda *a, **k: model.train() | ||
| if not hasattr(model, "for_inference"): | ||
| model.for_inference = lambda *a, **k: model.eval() | ||
| return model.to("cpu"), tok | ||
|
|
||
|
|
||
| @pytest.fixture(autouse = True) | ||
| def _require_stack(): | ||
| global torch # the `import torch._dynamo` below would otherwise shadow it as local | ||
| if importlib.util.find_spec("unsloth") is None or importlib.util.find_spec("trl") is None: | ||
| pytest.skip("unsloth or trl not installed") | ||
| # A real import failure is a regression we want to surface, so do not guard it. | ||
| import unsloth # noqa: F401 -- patches TRL trainers to the Unsloth variants | ||
|
|
||
| # `import unsloth` reinstalls the real torch.compile (overwriting the eager | ||
| # passthrough set at module load), so the GRPO hot path (chunked_selective_ | ||
| # log_softmax) would really compile -- and inductor picks the spoofed CUDA | ||
| # device, crashing on device props (`gcnArchName`). Re-apply the eager | ||
| # passthrough and flip dynamo's call-time kill switch so every @torch.compile | ||
| # runs eager regardless of when it was decorated. CPU eager is what we want. | ||
| torch.compile = _eager_compile | ||
| try: | ||
| import torch._dynamo # noqa: E402 | ||
| torch._dynamo.config.disable = True | ||
| except Exception: | ||
| pass | ||
|
|
||
|
|
||
| def test_sft_trains_on_cpu(tmp_path): | ||
| from datasets import Dataset | ||
| from trl import SFTConfig, SFTTrainer | ||
|
|
||
| assert SFTTrainer.__name__ == "UnslothSFTTrainer", "SFT patch did not apply" | ||
| model, tok = _load_plain() | ||
| ds = Dataset.from_list([{"text": "The quick brown fox jumps over the lazy dog."}] * 8) | ||
| cfg = SFTConfig( | ||
| output_dir = str(tmp_path / "ci_sft"), | ||
| per_device_train_batch_size = 2, | ||
| max_steps = 2, | ||
| logging_steps = 1, | ||
| report_to = "none", | ||
| save_strategy = "no", | ||
| use_cpu = True, | ||
| max_length = None, | ||
| padding_free = False, | ||
| dataset_text_field = "text", | ||
| fp16 = False, | ||
| bf16 = False, | ||
| optim = "adamw_torch", | ||
| ) | ||
| SFTTrainer(model = model, processing_class = tok, args = cfg, train_dataset = ds).train() | ||
|
|
||
|
|
||
| def test_grpo_trains_on_cpu(tmp_path): | ||
| from datasets import Dataset | ||
| from trl import GRPOConfig, GRPOTrainer | ||
|
|
||
| assert GRPOTrainer.__name__ == "UnslothGRPOTrainer", "GRPO patch did not apply" | ||
| model, tok = _load_plain() | ||
| ds = Dataset.from_list([{"prompt": "hi there"}] * 4) | ||
| cfg = GRPOConfig( | ||
| output_dir = str(tmp_path / "ci_grpo"), | ||
| per_device_train_batch_size = 2, | ||
| num_generations = 2, | ||
| max_steps = 2, | ||
| max_completion_length = 8, | ||
| logging_steps = 1, | ||
| report_to = "none", | ||
| temperature = 1.0, | ||
| beta = 0.0, | ||
| save_strategy = "no", | ||
| use_cpu = True, | ||
| use_vllm = False, | ||
| fp16 = False, | ||
| bf16 = False, | ||
| optim = "adamw_torch", | ||
| ) | ||
| GRPOTrainer( | ||
| model = model, | ||
| processing_class = tok, | ||
| reward_funcs = [lambda completions, **k: [float(len(c)) for c in completions]], | ||
| args = cfg, | ||
| train_dataset = ds, | ||
| ).train() | ||
|
|
||
|
|
||
| def test_dpo_trains_on_cpu(tmp_path): | ||
| from datasets import Dataset | ||
| from trl import DPOConfig, DPOTrainer | ||
|
|
||
| assert DPOTrainer.__name__ == "UnslothDPOTrainer", "DPO patch did not apply" | ||
| model, tok = _load_plain() | ||
| ds = Dataset.from_list( | ||
| [{"prompt": "Hi", "chosen": " hello friend", "rejected": " go away"}] * 8 | ||
| ) | ||
| cfg = DPOConfig( | ||
| output_dir = str(tmp_path / "ci_dpo"), | ||
| per_device_train_batch_size = 2, | ||
| max_steps = 2, | ||
| logging_steps = 1, | ||
| report_to = "none", | ||
| save_strategy = "no", | ||
| use_cpu = True, | ||
| beta = 0.1, | ||
| fp16 = False, | ||
| bf16 = False, | ||
| optim = "adamw_torch", | ||
| ) | ||
| DPOTrainer(model = model, processing_class = tok, args = cfg, train_dataset = ds).train() | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In this new pytest job,
tests/conftest.pyis loaded before this module and it importsunslothwhen torch is installed. If that import succeeds, the TRL trainers are generated before this assignment replacestorch.compile, so the GRPO hot functions can already be real Dynamo-compiled and the CPU fake-train can still hit CUDA/accelerator probes despite the shim. Set the env/compile shim in the workflow or conftest before the earlyunslothimport, or disable that import for this job.Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The ordering observation is correct (conftest.py does import unsloth before this module's shim), so I added TORCHDYNAMO_DISABLE / TORCH_COMPILE_DISABLE to the fake-run step env, which is set before any import. To be precise though, the predicted failure did not actually occur before this: the workflow already sets UNSLOTH_COMPILE_DISABLE=1 in the step env before conftest imports unsloth, and the autouse fixture flips torch._dynamo.config.disable=True, a call-time kill switch that forces eager even on functions decorated earlier with the real torch.compile (verified: a function decorated pre-flag runs eager post-flag, grads intact). The suite passes on a real CPU-build runner. The new env vars are defense in depth.