Guidelines for AI agents working in this codebase.
Training Hub is an algorithm-focused interface for common LLM training, continual learning, and reinforcement learning techniques. The goal is to expose common training algorithms in an intuitive and easy-to-use way, abstracting away backend complexity. Training Hub is designed to support as many backends as necessary—the current implementations are just the starting point.
- Language: Python 3.11+
- License: Apache-2.0
- Primary Author: Red Hat AI Innovation Team
For the current list of supported algorithms, backends, and dependencies, see:
pyproject.toml- Dependencies and optional extrassrc/training_hub/__init__.py- Public API exportsREADME.md- User-facing documentation and support matrix
# Install in editable mode (development)
pip install -e .
# Install with LoRA support
pip install -e .[lora]
# Install with GRPO support (includes ART + verl backends)
pip install -e .[grpo,lora]
# Install with CUDA support (install sequentially after other extras)
pip install -e .[cuda] --no-build-isolation
# Install with development dependencies
pip install -e .[dev]
# Run tests
pytest tests/
# Serve documentation locally (requires docsify-cli)
cd docs && docsify serveSee pyproject.toml for the full list of optional dependency groups.
src/training_hub/
├── __init__.py # Public API exports
├── hub_core.py # Core utilities
├── utils.py # Shared utilities (torchrun params, type formatting)
├── visualization.py # plot_loss() for training curves
├── algorithms/
│ ├── __init__.py # Base classes: Algorithm, Backend, AlgorithmRegistry
│ ├── sft.py # Supervised Fine-Tuning
│ ├── osft.py # Orthogonal Subspace Fine-Tuning
│ ├── lora.py # LoRA + SFT
│ ├── lora_grpo.py # LoRA + GRPO and GRPO (ART backend, algorithm, convenience fns)
│ ├── lora_grpo_verl.py # verl backend for LoRA + GRPO and GRPO
│ ├── rewards.py # Reward functions (tool_call_reward, binary_reward)
│ ├── verl_tool_agent.py # Custom verl agent loop for tool-call training
│ └── peft_extender.py # PEFT parameter handling for LoRA
└── profiling/
└── memory_estimator.py # GPU memory estimation for training
To see current algorithms and backends, check AlgorithmRegistry usage in src/training_hub/algorithms/*.py.
The codebase follows a Strategy + Registry pattern:
- Algorithm (abstract base class): Defines
train(),get_required_params(),get_optional_params() - Backend (abstract base class): Defines
execute_training(params)- actual training implementation - AlgorithmRegistry: Maps algorithm names to classes, and backends to algorithms
- Convenience functions: Top-level functions like
sft(),osft()wrap the registry
See src/training_hub/algorithms/__init__.py for the base class definitions.
- Create algorithm class inheriting from
Algorithminsrc/training_hub/algorithms/ - Create backend class inheriting from
Backend - Register both with
AlgorithmRegistry:AlgorithmRegistry.register_algorithm('my_algo', MyAlgorithm) AlgorithmRegistry.register_backend('my_algo', 'my-backend', MyBackend)
- Add convenience function wrapper
- Export in
__init__.py - Add documentation in
docs/algorithms/anddocs/api/
Follow existing implementations in sft.py, osft.py, or lora.py as templates.
Training Hub is designed to support multiple backends per algorithm:
- Create backend class inheriting from
Backend - Implement
execute_training(algorithm_params: dict) -> Any - Register with existing algorithm:
AlgorithmRegistry.register_backend('sft', 'new-backend', NewBackend) - Users can then select your backend via the
backendparameter:sft(..., backend='new-backend')
- Files:
snake_case.py - Classes:
PascalCase(e.g.,SFTAlgorithm,MiniTrainerOSFTBackend) - Functions:
snake_case(e.g.,sft(),osft(),lora_sft(),lora_grpo(),grpo()) - Constants:
UPPER_SNAKE_CASE(e.g.,FLOAT32_BYTES_N) - Backend names: kebab-case strings (e.g.,
"instructlab-training","mini-trainer","verl","art") - Algorithm names: lowercase (e.g.,
"sft","osft","lora_sft","lora_grpo","grpo")
- Follow Google Python Style Guide
- Type hints throughout (Python 3.11+ style:
list[str]notList[str]) - Use
Optional[T]for optional parameters with None default - Docstrings use Google format with Args/Returns sections
- Training parameters use keyword-only syntax with defaults
Training data is expected in JSONL format. See docs/api/data-formats.md for full documentation.
Common formats:
- Messages format:
{"messages": [{"role": "user", "content": "..."}, {"role": "assistant", "content": "..."}]} - Pretraining:
{"document": "Raw text content..."} - Alpaca (LoRA):
{"instruction": "...", "input": "...", "output": "..."}
Different backends use different parameter names. Translation happens in each backend's execute_training() method.
To understand parameter mappings, read the relevant backend class:
- SFT:
InstructLabTrainingSFTBackendinsrc/training_hub/algorithms/sft.py - OSFT:
MiniTrainerOSFTBackendinsrc/training_hub/algorithms/osft.py - LoRA:
UnslothLoRABackendinsrc/training_hub/algorithms/lora.py
Each backend class has a renames dict or inline translation showing the mapping.
Multi-GPU/multi-node training uses torchrun. See utils.get_torchrun_params() in src/training_hub/utils.py for:
- Precedence handling: args dict > environment variables > defaults
- Mutual exclusivity between
master_addrandrdzv_endpoint - Validation of
nproc_per_nodevalues
The profiling/memory_estimator.py module provides VRAM estimation. See the module docstrings for usage:
from training_hub import estimate
low, mid, high = estimate(
training_method="osft", # Check module for supported methods
model_path="...",
num_gpus=8,
...
)See src/training_hub/visualization.py for the plot_loss() function:
from training_hub import plot_loss
plot_loss("./checkpoints") # Single run
plot_loss(["./run1", "./run2"], labels=["A", "B"], ema=True) # Compare runsEach algorithm has validation logic in its train() method. Read the method docstrings and validation code for current constraints:
- OSFT: See
OSFTAlgorithm.train()insrc/training_hub/algorithms/osft.py - LoRA: See
LoRASFTAlgorithm.train()insrc/training_hub/algorithms/lora.py - SFT: See
SFTAlgorithm.train()insrc/training_hub/algorithms/sft.py
Install extras sequentially — [grpo] and [cuda] have conflicting transitive
dependencies that the solver can resolve when installed in order:
pip install -e .[grpo,lora] # GRPO backends (ART + verl)
pip install -e .[cuda] --no-build-isolation # CUDA kernels (after grpo)- Manual testing with example scripts in
examples/scripts/ - Jupyter notebooks in
examples/notebooks/for interactive testing
docs/
├── README.md # Home page
├── _sidebar.md # Navigation sidebar
├── algorithms/ # Algorithm overviews
├── api/ # API reference (functions, classes, backends)
├── guides/ # How-to guides
└── examples/ # Examples overview
- Uses Docsify
- Use absolute paths for internal links:
/api/functions/sftnot../functions/sft.md - See
docs/DEVELOPING.mdfor documentation contribution guidelines
See .github/workflows/pypi.yaml for the current GitHub CI pipeline:
- Build and package validation
- Test PyPI publishing on main branch pushes
- Production PyPI publishing on GitHub releases
- Sigstore package signing
Uses setuptools_scm for automatic versioning from git tags. See pyproject.toml [tool.setuptools_scm] section for configuration.
Example scripts are in examples/scripts/. All examples accept --help for argument documentation.
See examples/README.md for an overview of available examples.
These are hard-won lessons from development on this codebase. Follow them carefully.
- Use
uvfor venv creation and package management when available. Preferuv venv,uv pip install, anduv pip compileoverpython -m venvandpip install.uvis significantly faster and has better dependency resolution. Only fall back to basicpython -m venv/pipifuvis not installed. - Track which venv you are using. This project may have multiple venvs (e.g.,
.venv,.venv-test) for different purposes. Before running anything, confirm you are in the correct one. If you create a new venv for a workstream, note it explicitly so future sessions know it exists and what it's for. - The main branch has correctly resolving dependency versions unless something upstream has changed. Do not preemptively downgrade packages to "fix" version conflicts. If something breaks after a new install, the problem is more likely a build artifact issue (see flash-attn below) than a fundamental version incompatibility.
- Test install order in a fresh venv when adding or changing dependencies. Development iteration can mask dependency resolution issues — packages may already be installed from prior work, hiding the fact that a clean
pip installwould fail. Always verify the install path documented in README/CLAUDE.md works from scratch before merging dependency changes.
- Read the actual error before acting. When a build or import fails, read the full traceback and identify the root cause. Do not guess based on the package name alone.
- flash-attn must be rebuilt when torch changes. If you see flash-attn errors after a torch upgrade, the fix is
uv pip install flash-attn --no-build-isolation --force-reinstall(rebuilding against the new torch), NOT downgrading torch. The versions are not incompatible — the compiled extension just needs to match the torch it was built with. Runuv cache clean flash-attnfirst if needed. - Do not downgrade packages as a first resort. If something worked before and breaks now, investigate what changed rather than assuming version incompatibility. Common actual causes: stale build cache, wrong venv, missing rebuild of compiled extensions, or something else already running on the GPU.
- Check what is already running on GPUs before launching work AND after any failure. Run
nvidia-smi --query-compute-apps=pid,used_memory,name --format=csv,noheaderfirst. If GPUs are occupied, identify whether the processes are active work or leftover dead processes before deciding what to do. This applies to every GPU error — not just explicit OOM. NCCL errors, vLLM engine core crashes,pure virtual method calledaborts, and FSDP init failures can all be caused by insufficient memory from stale processes, even when the error message doesn't mention OOM. ray stopis unreliable for cleanup. It often fails to kill worker subprocesses (vLLM workers, FSDP workers). After callingray stop, always verify withnvidia-smithat GPU memory is actually freed. If processes remain, kill them directly:nvidia-smi --query-compute-apps=pid --format=csv,noheader | sort -u | xargs -r kill -9.- Never run broad kill commands like
pkill -f vllmorkill -9on process patterns without first checking what matches. Other users or workstreams may have active processes. Use targeted kills on specific PIDs after confirming they are yours and no longer needed. - When you see any GPU error, check GPU state first. An OOM, NCCL error, or engine crash on a training run may not be the run's fault — another process (or a zombie from a prior run) may already be consuming GPU memory. Check
nvidia-smibefore changing configs, downgrading packages, or reducing batch sizes.
- Run long-running training in the background so the user can provide input and you can do other work. Use
run_in_background=trueon Bash commands for training launches. - Always do a short initial check (30-60 seconds) to verify a run has started successfully before switching to a longer monitoring cadence. Check for: process is alive, GPU utilization is nonzero, no immediate crash in logs. Do not sleep for 5-10 minutes only to discover the run failed on startup.
- After confirming startup, check periodically but not excessively. Read the last few lines of the log file rather than sleeping blindly. If the run produces metrics to a file (e.g.,
training_metrics.jsonl), read that for progress.
- Check for known supply chain compromises when relaxing dependency version ranges. litellm 1.82.7-1.82.8 were compromised by the TeamPCP attack (March 24, 2026) — they harvest credentials at import time. The upper cap
<1.82.7exists specifically for this. When widening version ranges, search for CVEs and supply chain advisories in the expanded range before merging. - When a dependency is transitive (not imported directly), match the floor to the library that imports it. Training Hub doesn't import litellm —
openpipe-artdoes. The floor should match ART's declared minimum (>=1.71.1), not an arbitrary recent version. - Test dependency floors against RHOAI AIPCC indexes. RHOAI ships pinned package versions (e.g., litellm 1.74.15/1.75.8, numba 0.61.2). If training_hub's floor is higher than what RHOAI ships, pip resolution fails at install time. When setting minimum versions, check what the target deployment platform actually provides.
- New algorithms must emit
max_stepsintraining_metrics.jsonl. Without it, the Kubeflow SDK reports progress as "unknown" (None) because it calculatesprogressPercentagefrommax_steps. For GRPO, setmax_stepstonum_iterationsin both rollout and train phase entries.
- Track speed and quality baselines. Before making changes, note the current performance (e.g., reward values, training time per step, memory usage). After changes, verify these haven't degraded. If a "fix" makes things slower or reduces reward, the fix introduced a regression.
- Build quick test harnesses for bug fixes and new features. Write a small script that exercises the specific broken or new path, run it, and confirm the fix. Also run (or at minimum read) any existing tests to make sure prior functionality isn't broken.
- Save test results and note what was tested. When a test passes, record: what was run, what config, what the output was, what commit it was on. This creates a reference point for future work. Use memory files or commit messages for this, not just conversation context.
These are process lessons — not what broke, but what could have been done better to catch problems earlier.
- Reproduce the bug before writing the fix. When a bug is reported, the first step is to build a minimal reproduction on the current code — not to start coding the fix. If you can't reproduce the bug, you can't verify the fix. If the "fix" passes tests that also pass without the fix, the tests prove nothing. Use fresh output directories (not leftover state from prior runs), match the reporter's environment as closely as possible (same model, same ART/vLLM versions), and confirm you see the same error. Only then write the fix, and verify the reproduction now passes.
- Do not create PRs before functionally testing the change. Code review and syntax checks are not sufficient. If a fix touches a runtime code path (especially one involving subprocesses, GPU operations, or third-party libraries), run it end-to-end before opening a PR. A fix that passes
python -c "import ast; ast.parse(...)"but crashes at runtime wastes reviewer time and erodes trust. Set up a test environment first, then write the fix, then test, then PR. - Read how the downstream library actually consumes your inputs. When passing config to a third-party library (ART, vLLM, PEFT), read the library's source to understand which config dict it reads from and how. Do not assume two config sections (
init_argsvsengine_args) are merged. Do not assume a parameter set in one place is forwarded to another.grepfor the parameter name in the library's installed source (site-packages/art/, etc.) to trace how it flows. - When a subprocess fails opaquely, find the real error before trying fixes.
RuntimeError: Engine core initialization failed. Failed core proc(s): {}tells you nothing. Do not start upgrading/downgrading packages or changing configs until you've found the actual exception. For vLLM, grep full output forEngineCore_DP0.*ERROR. For ART subprocess errors, read the.training_errorfile. Time spent on wrong fixes compounds — each attempt changes the environment and may introduce new problems. - Test with the actual return format, not assumed keys. Before writing test assertions about a function's return value, call the function once and inspect what it actually returns. Writing assertions like
result["algorithm"]based on reading an intermediate internal dict (not the return value) wastes a test cycle. - Test serialization of third-party objects before saving them. When saving configs or state dicts from libraries like PEFT, verify the object is JSON/safetensors-serializable before building a pipeline around it.
peft_config.to_dict()returning asetfortarget_modulesis not obvious from the API — only a quickjson.dumps(config.to_dict())test reveals it.
- lora_grpo has two backends (verl and art) and three data modes (tool_call, generic, custom). Changes to shared code paths (the
LoRAGRPOAlgorithm.train()method, the convenience function, parameter forwarding) must be verified against both backends. A parameter added to the API must be forwarded inoptional_paramsANDget_optional_params(). - verl backend launches training via torchrun subprocess. Parameters are passed as Hydra-style CLI overrides. If you add a new parameter, it must be appended to the
cmdlist inVeRLLoRAGRPOBackend.execute_training(). - Batch divisibility matters for verl.
prompt_batch_size * group_sizemust be divisible byn_gpus * nnodes * micro_batch_size.ppo_mini_batch_sizemust be divisible byn_gpus * nnodes. Validation exists but verify it covers new configurations. - ART backend runs in-process. It uses
asyncio.run()andLocalBackend(in_process=True). Errors propagate directly. Theos._exit()issue in shutdown was fixed — do not re-introduce it, as it kills multi-iteration training silently. - ART subprocess requires
os._exit()to prevent vLLM 0.21+ hang. vLLM V1's EngineCore and APIServer spawn non-daemon threads that preventasyncio.run()from completing its shutdown. Withoutos._exit(),proc.join()in the parent hangs indefinitely after training completes. The fix usesos._exit(0)on success (in_run_training's finally block) andos._exit(1)on failure (in_subprocess_entry's except block), with anexit_codevariable to track which path was taken. Results are saved to disk before exit, so the parent reads them viatraining_results.json/.training_errorfiles afterproc.join()returns. - ART post-training cleanup errors can mask successful runs. ART's background health-check task throws
ConnectionRefusedErrorafter vLLM shuts down at end of training. If not handled, this writes toerror_pathand causes Kubeflow to restart the pod even though training succeeded. Check ifresults_pathexists before treating a post-training exception as a failure — if training completed, log the error as a warning instead. - ART backend runs in a spawned subprocess.
execute_training()usesmp.get_context("spawn"). Functions passed as parameters (e.g.,reward_fn,rollout_fn,iteration_callback) must be picklable — they must be defined at module level, not as closures or lambdas. Local functions defined inside test methods orif __name__ == "__main__"blocks will fail withAttributeError: Can't get attribute .... The same applies topython -cscripts: define functions in importable files instead. - ART has two sets of config:
init_args(Unsloth) andengine_args(vLLM). Parameters that affect vLLM (likegpu_memory_utilization) must be set inengine_args, not justinit_args. ART readsengine_argsdirectly when constructingAsyncEngineArgsfor vLLM. If a parameter is missing fromengine_args, vLLM uses its default (e.g.,gpu_memory_utilization=0.9), which will OOM when Unsloth already holds model weights on the same GPU. - vLLM V1 is the only engine in vLLM 0.15+. The
VLLM_USE_V1=0env var was removed in vLLM ~0.12. Do not attempt to force the V0 engine — it does not exist. vLLM V1 eagerly validates LoRA adapter paths onadd_lora; if a checkpoint doesn't exist yet, create a seed adapter (see_create_seed_lora_checkpoint). - vLLM engine core errors are hidden in subprocess logs. When
AsyncLLM.from_engine_argsfails withRuntimeError: Engine core initialization failed. See root cause above. Failed core proc(s): {}, the actual error is in the EngineCore subprocess stderr, NOT the parent process. To find it, grep forEngineCore_DP0.*ERRORin the full output. Common causes: flashinfer version mismatch, GPU memory exhaustion, incompatible torch version for compiled extensions.
These are real issues encountered during development. They illustrate why the rules above exist.
- flash-attn "wrong torch" failure: After torch was upgraded, flash-attn import failed. The attempted fix was downgrading torch, which would have broken other things. Actual fix:
uv cache clean flash-attn && uv pip install flash-attn --no-build-isolation --force-reinstall. The extension just needed rebuilding. - ART multi-iteration silent failure:
os._exit(0)in_shutdown_art_backendwas killing the process after iteration 1. ANameErroronart_project(removed from method signature thinking closure would capture it — closures don't work across method boundaries) was the root cause, butos._exitmasked the error. Multi-iteration training had worked before; the question was "what changed?" not "is this possible?" - Eval regression from history truncation: A change to truncate observation history (
h_obs[:80]) during an eval refactor caused scores to drop from ~43% to ~16%. The truncation was introduced incidentally during a different change and wasn't caught until explicit comparison with prior results. - Broad
ray stopkilled other users: Runningray stopto clean up after training killed another user's active Ray cluster on the shared machine. Should have checkedps auxfirst and killed specific PIDs. - OOM blamed on new workload: Training OOM'd and the response was to reduce batch size. Actual cause: stale GPU processes from a prior run were still consuming memory.
nvidia-smiwould have shown this immediately. - NCCL error misdiagnosed as driver incompatibility: FSDP init failed with
ncclUnhandledCudaError: Cuda failure 'out of memory'. This was misread as a CUDA driver/toolkit version mismatch, leading to attempted torch downgrade from cu128 to cu126. Actual cause: zombie ray/vLLM workers from prior failed runs were holding 79GB across all GPUs.ray stophad been called between runs but didn't kill the worker subprocesses. Fix:nvidia-smi --query-compute-apps=pid --format=csv,noheader | sort -u | xargs -r kill -9. Lesson: any GPU error — not just OOM — should start with checking what's on the GPUs. NCCL errors are often memory errors in disguise. nnodessilently dropped: When hiding ART-internal params from the public API,nnodeswas accidentally removed from theoptional_paramsdict. Multi-node training silently fell back to 1 node. Caught by CodeRabbit review. Lesson: when adding parameters to the convenience function, they must also appear inoptional_paramsintrain()AND inget_optional_params().- Install order dependency:
[grpo]and[cuda]extras must be installed sequentially. In development this worked because packages accumulated over time; in a fresh venv the solver couldn't resolve both simultaneously. Only caught when testing in a clean environment. VLLM_USE_V1=0became a no-op: The env var was set in_subprocess_entryto force vLLM's legacy V0 engine. vLLM ~0.12 removed the env var, and ART 0.5.17 pins vLLM 0.17.0 which only has V1. vLLM V1 eagerly validates LoRA adapter paths onadd_lora, but ART callsadd_lorabefore training creates the first checkpoint — causingFileNotFoundErroronadapter_config.json. Lesson: env var workarounds for third-party behavior silently break when the dependency upgrades. Prefer structural fixes (creating the expected files) over environment hacks.- Seed checkpoint placed before
model.register()was wiped by ART: The initial fix created a seed LoRA checkpoint at the expected path before callingmodel.register(backend). This worked locally because ART'ssave_modelhappened to produce valid adapter files for Qwen2.5-7B — so the seed was irrelevant and the test passed for the wrong reason. On RHOAI with Qwen3-4B, ART's registration recreated the project directory structure (deleting the seed), and thensave_modeldid not produce a validadapter_config.json. The fix had to be moved to a monkey-patch onconvert_checkpoint_if_needed, which runs AFTER ART's directory setup +save_modelbut BEFORE vLLM'sadd_lora. Lesson: when a test passes, verify it's passing because of your fix, not because the bug doesn't reproduce in your environment. Reproduce the bug first, then fix it. gpu_memory_utilizationnot forwarded to vLLM: The parameter was passed ininit_args(Unsloth initialization) but not inengine_args(vLLM engine args). ART readsengine_argswhen constructingAsyncEngineArgs, so vLLM defaulted to 0.9 and OOM'd because Unsloth already held ~14GB of model weights on the same GPU. Worse,engine_argswas conditionally omitted entirely (**({"engine_args": ...} if engine_kwargs else {})) — whenmax_lora_rankwasn't set, no engine args were forwarded at all. Fix: always passengine_argswith at leastgpu_memory_utilization. Lesson: read how the downstream library (ART) actually consumes config — don't assumeinit_argsandengine_argsare merged.- PEFT
target_modulesreturned as a set:peft_config.to_dict()returnstarget_modulesas a Pythonset, whichjson.dumpcannot serialize. This crashed the seed checkpoint creation withTypeError: Object of type set is not JSON serializable. Fix: convert to sorted list before serializing. Lesson: always test serialization of configs from third-party libraries — they may use non-JSON-safe types internally. - flashinfer-cubin version mismatch: vLLM 0.17.0 installed
flashinfer-python==0.6.4butflashinfer-cubin==0.6.8.post1was already present from a prior install. The version mismatch caused the vLLM engine core subprocess to crash silently withRuntimeError: flashinfer-cubin version (0.6.8.post1) does not match flashinfer version (0.6.4). The parent process only showedEngine core initialization failed. Failed core proc(s): {}with no root cause. Fix:uv pip install flashinfer-cubin==0.6.4. Lesson: when vLLM engine core fails silently, grep forEngineCore_DP0.*ERRORto find the real error in subprocess logs. - vLLM engine core error invisible in spawned subprocess: When ART runs inside Training Hub's
mp.spawnsubprocess, vLLM launches its engine core as a sub-subprocess. Errors in that sub-subprocess are printed to stderr but not captured by the parent. The parent only seesRuntimeError: Engine core initialization failedwith an empty proc set. Spent significant time trying to install compatible versions and upgrade torch before discovering the actual error (flashinfer mismatch, then GPU memory). Lesson: always capture and grep the full stderr, including lines prefixed with(EngineCore_DP0 pid=...), before trying to fix the problem. - ART cleanup
ConnectionRefusedErrorrestarted Kubeflow pods after successful training: ART's background health-check task tried to reach vLLM after it had already shut down, throwingConnectionRefusedError. The exception handler wrote toerror_pathunconditionally, which Kubeflow interpreted as a training failure and restarted the pod. Fix: check ifresults_pathexists (indicating training completed) before writing toerror_path; if training succeeded, log the cleanup error as a warning. Lesson: post-training cleanup errors should not overwrite successful results — always check completion state before marking a run as failed. - litellm supply chain attack (TeamPCP, March 2026): Attackers compromised litellm's CI/CD pipeline and published malicious versions 1.82.7 and 1.82.8 that harvested credentials at import time. Upper cap
<1.82.7inpyproject.tomlprevents accidental upgrade. Lesson: when relaxing dependency version ranges, always check for known supply chain compromises and CVEs in the expanded range. - vLLM 0.21+ subprocess hang after successful training: With vLLM 0.21+, the lora_grpo training subprocess hung indefinitely after training completed. The V1 engine's EngineCore and APIServer spawn non-daemon threads that prevent
asyncio.run()from completing shutdown, blocking the parent atproc.join(). This was a progression from theConnectionRefusedErrorfix (#78) — the failure mode changed from an exception to a silent hang. Fix: addos._exit(0)after results are saved and backend shutdown is attempted, withos._exit(1)on the error path soproc.exitcodecorrectly reflects failure. Lesson: vLLM's shutdown behavior changes across versions — when upgrading vLLM, verify that training subprocesses still exit cleanly after completion.
There are no automated tests checked into the repo yet. When testing ART GRPO locally:
- Use generic data mode (not tool-call) for quick validation. Create a JSONL file with
{"question": "...", "ground_truth": "..."}entries and pass areward_fnthat checks ifground_truthappears in the model's response. This avoids downloading the Toucan dataset. - Make questions hard enough to produce reward variance. If the model gets 100% reward on trivial questions (e.g., "What is 2+2?"), ART skips the GRPO training step ("Skipping tuning as there is no suitable data") because all trajectories have the same reward and there's no advantage signal. Use questions the model sometimes gets wrong to exercise the actual training path.
- Use minimal configs for speed:
num_iterations=1,group_size=2,prompt_batch_size=5,lora_r=8,max_tokens=64,gpu_memory_utilization=0.3. A single iteration takes ~90s on H100. reward_fnmust be picklable. Define it at module level in an importable file. Do not define it as a closure, lambda, or insidepython -c. The ART subprocess usesmp.get_context("spawn")which pickles all parameters.- Set
CUDA_VISIBLE_DEVICES=0to constrain to a single GPU (ART is single-GPU only). Without this, vLLM may try to use all visible GPUs. - Install vllm separately after
[grpo].openpipe-artdeclares vllm as a dependency but the prebuilt wheel may need manual installation to match CUDA/torch versions. After install, verify:python -c "from vllm import _C; print('OK')". If_Cimport fails, the wheel was built for a different torch version. - Verify flashinfer versions match:
uv pip list | grep flashinfer—flashinfer-pythonandflashinfer-cubinmust be the same version. Mismatches crash the vLLM engine core subprocess silently.
- Set
TMPDIRto a large filesystem (e.g., NVMe) before launching training. Flash-attn and flashinfer JIT compilation write hundreds of MB of temp files to/tmp. If/tmpis on a small root partition, the build fills the disk silently and the compilation fails with no useful error. Also setRAY_TMPDIRfor Ray socket paths (AF_UNIX paths have a 107-byte limit — deep NVMe paths can exceed this, use a short path like/mnt/nvme0n1/tmp). - Clear flashinfer JIT cache when changing CUDA toolkit versions. The cache at
~/.cache/flashinfer/contains compiled kernels for a specific CUDA version. After upgrading nvcc, delete the cache:rm -rf ~/.cache/flashinfer/. - Clear uv and pip caches periodically. They can grow to 60-100GB on root.
uv cache cleanandrm -rf ~/.cache/pip/. - vLLM compile cache at
~/.cache/vllm/torch_compile_cache/can also grow large. Safe to delete between runs.
These lessons apply to models larger than Qwen3-4B on 80GB H100s with co-located FSDP+vLLM.
- Use
load_format=dummyinstead ofsafetensorsfor the verl rollout config. Withsafetensors, vLLM loads the full model from disk during init on top of FSDP's copy, doubling GPU memory. Withdummy, vLLM allocates empty tensors for profiling and gets real weights via the checkpoint engine's weight sync. This is verl's own default — we were overriding it unnecessarily. - Increase
update_weights_bucket_megabytesto 3072. The default 2048MB bucket can't fit the embedding weight for models with large vocabularies (e.g., Qwen3's 152K vocab × 4096 hidden × 4 bytes = 2.36GB in float32). Without this, the FSDP→vLLM weight sync fails. load_format=safetensorsis needed for LoRA on very large models (9B+). Withdummyformat, the first weight sync callsFSDP.summon_full_paramswhich reconstructs the full unsharded model on each GPU — OOM for 9B+. Withsafetensors, vLLM preloads base weights from disk, so subsequent syncs only transfer LoRA params (base_sync_done=True). Use withlayered_summon=Truefor efficient LoRA-only syncs.- Full fine-tuning works by setting
lora_r=0. verl treatslora_rank=0as "no LoRA, train all parameters." No other changes needed — the same FSDP+vLLM pipeline handles full fine-tuning. Full FT is faster per step (no LoRA adapter management overhead) but uses more memory and may overfit earlier. - verl's FSDP→vLLM weight sync is the memory bottleneck. During
update_weights, FSDP gathers the full model in float32 onto each GPU to sync to vLLM. For 9B models in float32 this is ~36GB per GPU on top of FSDP's shards. This is why Qwen3-8B (8.2B params, 152K vocab) barely fits but Qwen3.5-9B (9.5B params, 248K vocab) doesn't withoutsafetensors+layered_summon.
- AdamW leaks into the frozen subspace without post-step re-projection. AdamW's element-wise moment rescaling (
m̂_t / (√v̂_t + ε)) destroys the orthogonal structure of projected gradients, causing parameter updates to drift into the frozen subspace over time. The fix (mini_trainer PR #91) addsproject_parameters()after each optimizer step. If you see gradual quality degradation during OSFT training with AdamW, check whether parameter re-projection is active. - OSFT V-projection uses factored form (not Gram matrix). The original
dV -= dV @ (V_high^T @ V_high)was replaced withdV -= (dV @ V_high^T) @ V_high(mini_trainer PR #74). Under FSDP2, this replaces an (M, M) all-reduce with a (k_high, M) all-gather — up to 25% speedup on 8B models. Optional caching of the all-gathered V_high is available viaOSFT_CACHE_V=1(off by default). - OSFT supports on-demand checkpointing and auto-resume. With
on_demand_checkpointing=True, the training loop responds to termination signals (SIGTERM, SIGUSR1/2, etc.) by saving full state viatorch.distributed.checkpoint. On restart, if no explicitresume_from_full_state_checkpointis provided, it auto-detects the latest valid checkpoint from{output_dir}/full_state_checkpoints/step_*/training_state.pt. - The checkpoint trigger file is now product-agnostic. Changed from
mini_trainer_checkpoint_triggertocheckpoint_requested. Customizable viaCHECKPOINT_TRIGGER_FILENAMEenv var.
Qwen3.5 uses a hybrid architecture: 75% GatedDeltaNet linear attention layers + 25% full attention. This requires specific setup:
- CUDA toolkit >= 12.5 required. flashinfer's GDN kernels use PTX intrinsics (
tensormap_replace_global_dim) added in CUDA 12.5. CUDA 12.4 fails at JIT compile time. Install the toolkit only — no driver upgrade needed:sudo dnf install -y cuda-toolkit-12-8. - Install
flash-linear-attentionandtilelang. Without FLA's fused Triton kernels, GDN layers decompose into 15-20 separate GPU ops per step — 10-50x slower.tilelangis required on Hopper GPUs with Triton >= 3.4.0 to avoid incorrect gradients. Setuse_fused_kernels=Truein the verl model config. - vLLM >= 0.19.0 required. Older versions don't support
Qwen3_5ForConditionalGeneration. - Pass
language_model_only=True. Qwen3.5-9B is text-only but registers as a multimodal architecture. Without this flag, vLLM loads the full VL model with vision encoder. Installqwen-vl-utilsas well (imported during model init even in text-only mode). - Do NOT use
tensor_parallel_size > 1. Qwen3.5 has known TP issues with vLLM 0.19 (NCCL hangs during inference). - Set
VLLM_EXECUTE_MODEL_TIMEOUT_SECONDS=1200. Default is 300s. GDN inference on long prompts (20K+ tokens) with TP=1 can exceed 5 minutes, causingTimeoutError: RPC call to sample_tokens timed out. - Qwen3.5 chat template requires dict arguments in tool_calls. Unlike Qwen3, the Qwen3.5 Jinja template calls
.items()on tool call arguments. Ifargumentsis a JSON string instead of a dict, tokenization fails with"Can only get item pairs from a mapping". The data prep code mustjson.loads()string arguments before passing them to the tokenizer. - transformers >= 4.58 required for the
qwen3_5model type. Also upgradehuggingface_hub >= 1.0. - Force-reinstall
nvidia-nccl-cu12after installing vLLM 0.19. vLLM may leave a stale cu13 NCCL.sofile even though pip shows cu12. Verify withpython -c "import ctypes; lib=ctypes.CDLL('path/to/libnccl.so.2'); v=ctypes.c_int(); lib.ncclGetVersion(ctypes.byref(v)); print(v.value)".
- Root filesystem filled by flash-attn JIT compilation. CUDA compilation of flash-attn wrote hundreds of 125-140MB temp files to
/tmpon a 100GB root partition. The build failed silently with "Error compiling objects for extension" and no disk space error. Fix: setTMPDIR=/mnt/nvme0n1/tmpbefore building. Lesson: always set TMPDIR to a large filesystem for CUDA compilation. - Stale
libnccl.soversion after package reinstall.pip listshowednvidia-nccl-cu12==2.27.5but the actual.sofile was version 2.28.9 (from a prior cu13 install). Caused NCCL errors at runtime. Fix:pip install nvidia-nccl-cu12==2.27.5 --force-reinstall. Lesson: pip metadata and actual library files can diverge — verify with ctypes or ldd when debugging NCCL issues. - vLLM
sample_tokenstimeout on GDN models. Qwen3.5-9B with 29K+ token prompts exceeded the 300s defaultVLLM_EXECUTE_MODEL_TIMEOUT_SECONDS. The vLLM worker appeared to hang and the engine reportedEngineDeadError. Fix: increase timeout to 1200s. Lesson: GDN linear attention is slower per token than standard attention — timeout values calibrated for transformer models may be too low. - FLA fused kernels crash without
tilelangon Hopper.RuntimeError: Triton >= 3.4.0 on Hopper GPUs produces incorrect results for gated chunk_bwd_dqkwg (see #640). Please install tilelang. The error message is clear and actionable — justpip install tilelang. - 21 min/step without FLA vs 3.3 min/step with FLA. The
[transformers] The fast path is not available because one of the required library is not installedwarning is easy to miss. Without FLA, GDN layers use a decomposed fallback that is 10-50x slower. Always installflash-linear-attentionwhen training GDN/Qwen3.5 models.
These are known-good configuration values from validated training runs. Use as a starting point when debugging or configuring new runs.
# Validated on 1x H100, Qwen2.5-7B-Instruct, generic Q&A data (ART backend)
lora_r=8, lora_alpha=4
gpu_memory_utilization=0.3
num_iterations=1, group_size=2, prompt_batch_size=5
max_tokens=64, concurrency=4
Result: successful completion, 10 rollouts, reward=1.0 (trivial data)
Training time: ~90s total (including model load + vLLM warmup)
Environment: openpipe-art==0.5.17, vllm==0.17.0, torch==2.10.0
# Validated on 4x H100, Qwen3-4B, OpenShift tool-call data (1886 traces)
lora_r=128, lora_alpha=256
gpu_memory_utilization=0.3
micro_batch_size=2 (needed for large vocab models — seq_len * 152K vocab causes OOM on logits)
max_prompt_length=32768
prompt_batch_size=50, group_size=8
learning_rate=5e-6
Step 1 reward: 0.154 (baseline reference for OpenShift v5 data)
Training time: ~7 min/step
# Validated on 4x H100, Qwen3-4B, AIME math data (custom reward)
lora_r=16, lora_alpha=8
gpu_memory_utilization=0.3
num_iterations=2, group_size=8, prompt_batch_size=50
learning_rate=1e-5
Result: successful completion, both iterations
# Validated on 8x H100, Qwen3-8B, OpenShift v4 data (LoRA)
lora_r=128, lora_alpha=256
gpu_memory_utilization=0.5, load_format=dummy
update_weights_bucket_megabytes=3072
prompt_batch_size=48, group_size=8
learning_rate=5e-6, max_prompt_length=32768
Final reward: 0.80 avg, 0.89 peak (8 epochs, 41 hours)
# Validated on 8x H100, Qwen3-8B, OpenShift v4 data (full fine-tuning)
lora_r=0 (full fine-tuning)
gpu_memory_utilization=0.5, load_format=dummy
Peak reward: 0.804 at epoch 5, then overfitting (8 epochs, 26.5 hours)
# Validated on 8x H100, Qwen3.5-9B, OpenShift v4 data (LoRA)
lora_r=128, lora_alpha=256
gpu_memory_utilization=0.3, load_format=safetensors, layered_summon=True
language_model_only=True, use_fused_kernels=True
VLLM_EXECUTE_MODEL_TIMEOUT_SECONDS=1200
Requires: vllm==0.19.0, flash-linear-attention, tilelang, CUDA toolkit 12.5+
Step time: ~3.3 min/step, reward 0.85+ by epoch 2