傻逼环境
模型训练时,Python 解释器、PyTorch 与设备驱动、vLLM 的模型支持、Ray 子进程、训练框架和共享存储都要协同工作。我们先在普通 GPU 上用小模型跑通训练,再把这套经验迁移到 A100 和 PPU。SFT、GRPO 和辅助推理服务各自使用独立环境。
环境分成三层:
- 最底层是机器提供的驱动、CUDA 或厂商 SDK、设备通信库。我作为训练用户没有权限更换它们。
- 第二层是 Python 训练栈。SFT 使用 LlamaFactory,在 9B 全参数阶段配合 DeepSpeed。RL 使用 VERL、Ray 和 vLLM。两套环境分别安装。
- 第三层是训练任务自己的数据、模型、VERL 补丁和启动配置,随着实验迭代,但不能污染前两层。
下面记录普通 GPU、A100 和 PPU 的环境构建过程。
机器原状
在改动 Python 环境前,先记录设备、解释器和存储。GPU 可以用 nvidia-smi 查询,PPU 则使用厂商提供的设备查询工具。df -hT 能区分本地盘、NAS 和 FUSE 挂载。后面安排 Ray 临时文件与 checkpoint 时,这些信息会直接影响路径选择。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
export PROJECT_ROOT=/path/to/project
export LOCAL_SCRATCH=/path/to/local-fast-disk
export MODEL_DIR=/path/to/model
command -v python
python --version
python -m pip --version
nvidia-smi # PPU 机器换成厂商设备查询命令
df -hT "${LOCAL_SCRATCH}" "${MODEL_DIR}"
python - <<'PY'
import sys
print('executable:', sys.executable)
print('sys.path:', *sys.path, sep='\n ')
try:
import torch
print('torch:', torch.__version__, torch.__file__)
print('torch CUDA:', torch.version.cuda)
print('device available:', torch.cuda.is_available())
except ImportError as exc:
print('torch not installed:', exc)
PY
这里要看的是实际导入的文件位置,仅看 pip show 不够。曾遇到 Python 3.11 环境继承旧 Python 3.10 的 PYTHONPATH,包版本看似正确,Ray worker 中的 vLLM EngineCore 却启动失败。使用独立环境时,可以先清理注入变量,再用绝对路径调用解释器:
1
2
3
4
5
6
7
8
9
unset PYTHONHOME PYTHONPATH
export PYTHONNOUSERSITE=1
export TRAIN_PY=/path/to/rl-env/bin/python
"${TRAIN_PY}" - <<'PY'
import sys, torch
print(sys.executable)
print(torch.__version__, torch.__file__)
PY
这次只在训练 shell 中清理环境变量,并继续核对启动器、torchrun、Ray worker 和 vLLM 子进程实际使用的 Python 环境。
普通 GPU:先打通小模型 GRPO
本地环境探索分了几个阶段。最初的 Qwen3.5-0.8B + GSM8K 单步 GRPO smoke 使用一张 24 GB RTX 4090。后来接入更长上下文和工具调用的真实任务时,0.8B GRPO smoke 使用两张 24 GB RTX A5500。下面的安装过程以 RTX 4090 环境为例,当时 nvidia-smi 显示 CUDA 13.2。
版本选择从 Qwen3.5 的模型支持开始。模型配置中的 model_type: qwen3_5 无法被试用的 transformers==4.57.6 识别,所以 Transformers 要换成支持该架构的实现。vLLM 的 Qwen3.5-9B 部署 recipe 把 0.17.0 列为推理起点,还需要通过 VERL 做在线 GRPO。查看当时的 VERL Qwen3.5 FSDP 训练脚本 后,选定了包含该训练入口的 0.9.0.dev0 源码提交,并按脚本注明的组合使用 vllm==0.18.0 和指定的 Transformers 提交。
再看 vLLM 0.18.0 的 CUDA 依赖,它固定了 torch==2.10.0。因此我们安装对应的 2.10.0+cu128 wheel。Python 3.10 曾在这个 VERL 版本的 Ray worker 中因缺少 enum.StrEnum 而报错,于是改用 Python 3.11。Ray 选用跑通 smoke 的 2.56.0。当时 VERL 的通用 [vllm] 安装项仍限制 vllm<=0.12.0,与它自己的 Qwen3.5 训练示例不同步。安装时便先用 .[math] 安装 VERL,再单独安装 vLLM 0.18.0。经过这些选择,模型加载、rollout、log-prob 计算和 actor 更新都有了对应的软件版本。
安装时先建独立的 Python 环境并安装 PyTorch CUDA 12.8 wheel,再安装固定提交的 VERL、vLLM 和它需要的 TransferQueue,最后补上支持 Qwen3.5 的 Transformers:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
export TORCH_WHEEL_INDEX=https://download.pytorch.org/whl/cu128
mkdir -p "${LOCAL_SCRATCH}/envs" "${LOCAL_SCRATCH}/src"
conda create -p "${LOCAL_SCRATCH}/envs/verl-py311" python=3.11 pip -y
export TRAIN_PY="${LOCAL_SCRATCH}/envs/verl-py311/bin/python"
"${TRAIN_PY}" -m pip install --index-url "${TORCH_WHEEL_INDEX}" \
'torch==2.10.0' 'torchvision==0.25.0' 'torchaudio==2.10.0'
export VERL_COMMIT=1bf9d2d76c244238e8219531c582819a6b407d47
git clone --depth 1 https://github.com/verl-project/verl.git \
"${LOCAL_SCRATCH}/src/verl"
cd "${LOCAL_SCRATCH}/src/verl"
git fetch --depth 1 origin "${VERL_COMMIT}"
git switch --detach "${VERL_COMMIT}"
git rev-parse HEAD
"${TRAIN_PY}" -m pip install -e '.[math]'
"${TRAIN_PY}" -m pip install 'vllm==0.18.0' 'TransferQueue==0.1.8'
接着安装已验证的 Transformers 提交。vLLM 0.18 的包元数据仍声明 transformers<5,所以这里用 --no-deps 保留所需的 Qwen3.5 实现:
1
2
3
4
5
6
7
8
9
10
11
12
13
"${TRAIN_PY}" -m pip install --no-deps \
'git+https://github.com/huggingface/transformers.git@cc7ab9be508ce6ed3637bba9e50367b29b742dc6'
"${TRAIN_PY}" -m pip install 'huggingface-hub>=1.3.0'
"${TRAIN_PY}" - <<'PY'
import os
import torch, transformers, vllm, ray
from transformers import AutoConfig
print('torch', torch.__version__, torch.version.cuda)
print('transformers', transformers.__version__)
print('vllm', vllm.__version__, 'ray', ray.__version__)
print('model_type', AutoConfig.from_pretrained(os.environ['MODEL_DIR']).model_type)
PY
${MODEL_DIR} 指向已下载的 Qwen3.5-0.8B 目录。此时 pip check 仍会报告 vLLM 与 Transformers 的版本声明冲突。我们记录下这个冲突,接着实际加载模型,并分别运行生成和训练测试。这样才能知道这组包在要用的路径上是否可以一起工作。
模型配置能够读取后,用公开的 GSM8K 准备第一个 GRPO smoke。VERL 的快速入门提供了预处理入口,可生成训练所需的 Parquet。
1
2
3
4
5
6
7
cd "${LOCAL_SCRATCH}/src/verl"
"${TRAIN_PY}" examples/data_preprocess/gsm8k.py --help
"${TRAIN_PY}" examples/data_preprocess/gsm8k.py \
--local_dir "${LOCAL_SCRATCH}/data/gsm8k"
test -s "${LOCAL_SCRATCH}/data/gsm8k/train.parquet"
test -s "${LOCAL_SCRATCH}/data/gsm8k/test.parquet"
这里先单独用 vLLM 对同一个模型完成一次短生成,再在 Ray actor 中重复。将下段保存为 ${LOCAL_SCRATCH}/probes/vllm_probe.py。
测试脚本要保存为真实的
.py文件,并提供__main__入口。vLLM 子进程使用spawn时,需要重新导入主模块。从 stdin 执行的代码可能在这一步失败。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
import os
import sys
def generate():
import torch
import vllm
from vllm import LLM, SamplingParams
print('python:', sys.executable)
print('torch:', torch.__file__, 'vllm:', vllm.__file__)
llm = LLM(
model=os.environ['MODEL_DIR'],
tensor_parallel_size=1,
max_model_len=512,
enforce_eager=True,
)
output = llm.generate(['2 + 3 = ?'], SamplingParams(max_tokens=8), use_tqdm=False)
return output[0].outputs[0].text
if __name__ == '__main__':
if len(sys.argv) > 1 and sys.argv[1] == '--ray':
import ray
@ray.remote(num_gpus=1)
def worker():
return generate()
ray.init()
print(ray.get(worker.remote(), timeout=600))
ray.shutdown()
else:
print(generate())
1
2
3
4
mkdir -p "${LOCAL_SCRATCH}/probes" /tmp/ray_q35
export RAY_TMPDIR=/tmp/ray_q35
CUDA_VISIBLE_DEVICES=0 "${TRAIN_PY}" "${LOCAL_SCRATCH}/probes/vllm_probe.py"
CUDA_VISIBLE_DEVICES=0 "${TRAIN_PY}" "${LOCAL_SCRATCH}/probes/vllm_probe.py" --ray
普通 Python 和 Ray actor 都完成短生成后,我们启动 GSM8K 的单步 GRPO。第一次训练在 Qwen3.5 的 packed-sequence 路径上遇到形状错误。关闭 use_remove_padding 和动态 batch,再把 batch 与 mini-batch 从 1 调到 2 后,actor 完成了更新。完整启动过程可参考之前的博客。做单步验收时,将 TOTAL_STEPS 设为 1。
验收时,我们在日志中逐项查看模型和 worker 初始化、真实 rollout、reward、loss、gradient,以及 training/global_step:1。还要确认训练进程正常退出。接下来,RL 路线继续推进工具任务、两卡运行和 checkpoint 恢复。SFT 路线则另建环境。
SFT 独立环境
GRPO 的训练链跑通后,我为 SFT 建了另一套环境。普通 GPU 上使用两张 24 GB RTX A5500,以 Qwen3.5-0.8B 验证工具轨迹的监督训练。这条训练链要确认模型模板能正确处理多轮 function_call 和 observation,还要确认 loss mask 只覆盖需要学习的 assistant 内容。它不需要 vLLM 和 Ray。
框架版本同样从模型支持倒推。LlamaFactory v0.9.5 发布说明明确加入 Qwen3.5 和 Transformers v5 支持,因此我们固定该 release 的源码提交,使用内置的 qwen3_5_nothink 模板。训练时的实际组合是 Python 3.11、PyTorch 2.10.0+cu128、Transformers 5.3.0.dev0、PEFT 0.18.1 和 Liger Kernel 0.8.1。PyTorch wheel 沿用前面在普通 GPU 上验证过的版本。LlamaFactory 与 Transformers 安装在独立环境中,不会改动 VERL 的依赖。
下面是安装入口。LlamaFactory 固定到发布版的源码提交。Transformers 先按这一版本支持的 v5 范围安装,再检查实际装入的版本。随后运行模型加载、模板编码和 loss mask 检查,确认 SFT 训练要用的环节都能工作。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
conda create -p "${LOCAL_SCRATCH}/envs/sft-py311" python=3.11 pip -y
export SFT_PY="${LOCAL_SCRATCH}/envs/sft-py311/bin/python"
export SFT_BIN="${LOCAL_SCRATCH}/envs/sft-py311/bin"
"${SFT_PY}" -m pip install --index-url "${TORCH_WHEEL_INDEX}" \
'torch==2.10.0' 'torchvision==0.25.0' 'torchaudio==2.10.0'
git clone --depth 1 --branch v0.9.5 \
https://github.com/hiyouga/LLaMA-Factory.git \
"${LOCAL_SCRATCH}/src/LLaMA-Factory"
git -C "${LOCAL_SCRATCH}/src/LLaMA-Factory" rev-parse HEAD
"${SFT_PY}" -m pip install -e "${LOCAL_SCRATCH}/src/LLaMA-Factory"
"${SFT_PY}" -m pip install \
'transformers>=5.0,<5.6' 'peft==0.18.1' 'liger-kernel==0.8.1'
export PATH="${SFT_BIN}:${PATH}"
"${SFT_BIN}/llamafactory-cli" version
command -v torchrun
"${SFT_PY}" - <<'PY'
import torch, transformers, peft
print('torch', torch.__version__, torch.__file__)
print('transformers', transformers.__version__, transformers.__file__)
print('peft', peft.__version__)
PY
多卡启动前检查 command -v torchrun:曾把 SFT 包安装进新环境,却从 PATH 找到了 base 环境的启动器,结果各训练进程用了错误的 Python。环境确认后,先用目标 tokenizer 和 qwen3_5_nothink 模板检查样本编码,确认 system、human、observation 的 label 为 -100,function call 与最终回答参与监督,再做 0.8B 的两卡 LoRA 短训练和 checkpoint 恢复。
A100 迁移
A100 机器有 8 张 80 GB 卡,驱动是 470 系列,内核是 4.9。我没有权限升级这些系统组件,也只能从内网镜像安装 Python 包。普通 GPU 环境使用的 PyTorch 是 2.10.0+cu128。把它搬到这台老驱动机器上,首先需要回答一个问题:不升级驱动,训练还能不能运行?
我们先新建 Python 环境,从内网镜像安装 torch==2.10.0。这个镜像的包名不带 CUDA 后缀,安装后导入的版本却显示为 2.10.0+cu128。接着检查 GPU 是否可见,并在 A100 上实际执行一次 BF16 矩阵乘法。这证明 PyTorch 能完成基本的 GPU 计算。然后再让 Qwen3.5 做 forward、backward 和单步训练,确认模型实际会用到的计算路径也能运行。
Ray 和 vLLM 的选择也经历过几轮试错。起初使用过内网现成的厂商版包。安装 VERL 和 TransferQueue 时,pip 又拉入标准 Ray。标准版与厂商版交替安装后,ray 目录里留下了不完整的文件,import ray 虽然成功,却找不到 ray.remote。继续在这个环境里补包很难确定哪个文件来自哪个版本,所以我们重新建立环境,只安装标准 Ray 和标准 vLLM。
新环境里还出现过另一种失败:普通 Python 可以使用 vLLM,放进 Ray worker 后,EngineCore 却崩溃了。检查加载的动态库后,发现进程用到了 pyarrow 25 的 libarrow.so.2500。我们将 pyarrow 固定到已验证的 24.0.0,再重复 Ray actor 内的真实生成测试。最终固定的核心组合是 Ray 2.56.1、vLLM 0.18.0、pyarrow==24.0.0 和 numpy==1.26.4。vLLM 0.18.0 对 Transformers 的包声明仍限制 <5,而 Qwen3.5 需要更新的实现。安装时单独固定这两个包,随后以模型加载、生成和训练结果检验这个组合。
安装时始终用新环境的 Python 调用 pip。后装的依赖可能重新升级 PyTorch、Ray 或
pyarrow,所以每批安装后都检查版本。下面的${VERL_WORK}是提前传入的固定 VERL 源码副本,${TRAIN_PY}是 A100 新环境的 Python。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 export VERL_WORK=/path/to/transferred/verl-at-fixed-commit test -f "${VERL_WORK}/pyproject.toml" conda create -p "${LOCAL_SCRATCH}/envs/verl-a100-py311" python=3.11 pip -y export TRAIN_PY="${LOCAL_SCRATCH}/envs/verl-a100-py311/bin/python" unset PYTHONHOME PYTHONPATH export PYTHONNOUSERSITE=1 "${TRAIN_PY}" -m pip install -e "${VERL_WORK}[math]" "${TRAIN_PY}" -m pip install 'torch==2.10.0' 'ray[default]==2.56.1' \ 'pyarrow==24.0.0' 'numpy==1.26.4' "${TRAIN_PY}" -m pip install --no-deps 'vllm==0.18.0' 'transformers==5.14.1' cat > "${LOCAL_SCRATCH}/rl-core-constraints.txt" <<'EOF' torch==2.10.0 ray==2.56.1 vllm==0.18.0 transformers==5.14.1 pyarrow==24.0.0 numpy==1.26.4 EOF "${TRAIN_PY}" -m pip install \ -c "${LOCAL_SCRATCH}/rl-core-constraints.txt" \ --upgrade-strategy only-if-needed \ 'flashinfer-python==0.6.6' 'flashinfer-cubin==0.6.6' \ 'llguidance==1.3.0' 'compressed-tensors==0.13.0' \ 'outlines_core==0.2.11' 'TransferQueue==0.1.8' "${TRAIN_PY}" -m pip install \ -c "${LOCAL_SCRATCH}/rl-core-constraints.txt" \ --upgrade-strategy only-if-needed \ opentelemetry-exporter-otlp opentelemetry-semantic-conventions-ai \ cbor2 depyf anthropic gguf pybase64 uvicorn uvloop fastapi \ cachetools blake3 codetiming einops hydra-core pylatexenc \ tensorboard torchdata accelerate peft multiprocess dill \ datasets pandas pandarallel xxhash "${TRAIN_PY}" - <<'PY' import sys, torch, ray, vllm, pyarrow, transformers print(sys.executable, torch.__version__, torch.version.cuda) print(ray.__version__, vllm.__version__, pyarrow.__version__, transformers.__version__) print(ray.__file__, vllm.__file__) assert torch.cuda.is_available() and torch.cuda.is_bf16_supported() assert not any('python3.10/site-packages' in p for p in sys.path) x = torch.ones((16, 16), device='cuda', dtype=torch.bfloat16) print('bf16 matmul:', (x @ x).float().sum().item()) PY
安装 flash_attn 时又遇到一处阻碍。这个 Python 3.11、PyTorch 2.10、CUDA 12.8 组合没有找到可用的预编译 wheel。普通安装在隔离构建阶段找不到 torch,关闭隔离后又编译失败。我们因此改用 PyTorch SDPA。actor 可以通过配置选择 attn_implementation=sdpa,但 VERL 的 ref/value-head 加载代码里仍有硬编码的 FlashAttention。我们修改了这一处,让它读取相同的后端设置,并把改动保存在 VERL 补丁中。
定位时先运行
rg -n 'flash_attention_2' "${VERL_WORK}/verl"。actor 启动参数加入+actor_rollout_ref.model.override_config.attn_implementation=sdpa。如果 ref/value-head 加载点仍硬编码flash_attention_2,就在独立 VERL 副本的verl/utils/model.py中改为读取os.environ.get("VERL_ATTN_IMPLEMENTATION", "sdpa"),并把VERL_ATTN_IMPLEMENTATION加入 Ray 环境变量白名单。启动前执行export VERL_ATTN_IMPLEMENTATION=sdpa,再用单步 smoke 确认 actor 和 ref 都完成计算。
排查 Ray/vLLM 时,我们先在普通 Python 进程中创建
LLM并短生成,再把同一段代码放进ray.remote(num_gpus=1)的 actor。测试代码保存为带if __name__ == '__main__'的.py文件,供 vLLM 的spawn子进程重新导入。如果普通进程生成成功而 Ray actor 失败,就分别打印两侧的sys.executable、sys.path、ray.__file__和vllm.__file__。那次检查发现 worker 继承了全局 Python 3.10 路径。清理旧PYTHONPATH后,Ray actor 内的 EngineCore 才完成生成。
1 2 3 4 5 6 7 8 9 10 unset PYTHONHOME PYTHONPATH export PYTHONNOUSERSITE=1 export PYTHONPATH="${VERL_WORK}:${PROJECT_ROOT}" "${TRAIN_PY}" - <<'PY' import sys, ray, vllm print(sys.executable, ray.__file__, vllm.__file__) print(*sys.path, sep='\n') PY CUDA_VISIBLE_DEVICES=0 "${TRAIN_PY}" "${LOCAL_SCRATCH}/probes/vllm_probe.py" CUDA_VISIBLE_DEVICES=0 "${TRAIN_PY}" "${LOCAL_SCRATCH}/probes/vllm_probe.py" --ray如果进程启动后仍出现
libarrow.so.2500、旧版 Ray 路径或 SIGSEGV,先停止 smoke,检查pyarrow.__version__和实际加载路径。若失败栈明确指向 FlashAttention,则核对 actor 配置中的 SDPA override、ref/value-head 加载点及 Ray worker 是否收到VERL_ATTN_IMPLEMENTATION=sdpa。这台机器还有一次
unset PYTHONPATH后,sys.path里依然出现旧 Python 3.10 的全局包目录。这时先检查新环境中的.pth、sitecustomize.py和usercustomize.py。如果路径由机器级启动逻辑注入,就在新环境自己的site-packages/sitecustomize.py中过滤旧python3.10/site-packages路径,再重新运行导入检查。训练脚本也继续保持不继承旧PYTHONPATH。
包和进程环境稳定后,训练测试才逐渐扩大。先读取 0.8B 的 AutoConfig,确认当前 Transformers 能识别 Qwen3.5。再分别在普通 Python 和 Ray actor 内用 vLLM 生成短文本。随后使用公开 GSM8K 数据完成 0.8B 的一个 GRPO step,检查 reward、loss、梯度和 training/global_step:1。
接入实际任务时,先让 0.8B 分别跑 Update 和 Retrieval 单步,确认两个 AgentLoop 都能调用正确的工具并计算奖励。然后换成 9B,重复 Update 和 Retrieval 单步。9B Retrieval 的第一次训练在 FSDP offload 与 fused logits 路径上发生 CPU/CUDA tensor 设备不一致。关闭 USE_FUSED_KERNELS 后才通过。单任务都通过后,我们继续做多步训练和 Update/Retrieval 混合 batch,最后检查权重同步与 checkpoint 恢复。
每层测试都有自己的通过条件。配置检查应输出
model_type=qwen3_5。vLLM 测试应实际返回文本。单步 GRPO 应完成 actor 更新,日志里只有 worker 初始化还不够。0.8B GSM8K smoke 使用use_remove_padding=False、actor SDPA,并将 train batch 和 PPO mini-batch 都设为 2,因为当前 Qwen3.5/VERL 栈的 packed-sequence 路径曾在 actor log-prob 计算中报形状错误。任务 smoke 还要记录 AgentLoop 路由、工具调用、reward 方差、advantage、grad norm,并确认下一步 rollout 使用了更新后的权重。最初 9B Retrieval 单样本的 8 个 rollout 得到相同 reward,advantage 和梯度都是零。这一批没有提供 GRPO 对比信号,因此我们继续用混合 batch 和多步 smoke 检查有效更新。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
unset PYTHONHOME PYTHONPATH
export PYTHONNOUSERSITE=1
export CUDA_VISIBLE_DEVICES=0
"${TRAIN_PY}" - <<'PY'
import sys, torch, ray, vllm, transformers
print(sys.executable)
for package in (torch, ray, vllm, transformers):
print(package.__name__, package.__version__, package.__file__)
print('bf16:', torch.cuda.is_bf16_supported())
PY
mkdir -p "${LOCAL_SCRATCH}/logs"
"${TRAIN_PY}" -m pip freeze > "${LOCAL_SCRATCH}/logs/rl-freeze.txt"
SFT 沿用普通 GPU 上验证过的 LlamaFactory v0.9.5 源码提交。我们在 A100 上重新创建独立环境,从内网镜像安装 PyTorch、Transformers v5 和 DeepSpeed 0.18.4。正式启动前,还要确认 torchrun 来自这个 SFT 环境:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
conda create -p "${LOCAL_SCRATCH}/envs/sft-a100-py311" python=3.11 pip -y
export SFT_PY="${LOCAL_SCRATCH}/envs/sft-a100-py311/bin/python"
export SFT_BIN="${LOCAL_SCRATCH}/envs/sft-a100-py311/bin"
"${SFT_PY}" -m pip install \
'torch==2.10.0' 'torchvision==0.25.0' 'torchaudio==2.10.0'
export LF_WORK=/path/to/transferred/LLaMA-Factory-v0.9.5
test -f "${LF_WORK}/pyproject.toml"
"${SFT_PY}" -m pip install -e "${LF_WORK}"
"${SFT_PY}" -m pip install \
'transformers==5.6.0' 'peft==0.18.1' 'liger-kernel==0.8.1' \
'deepspeed==0.18.4'
export PATH="${SFT_BIN}:${PATH}"
command -v torchrun
"${SFT_PY}" -m pip show torch transformers deepspeed llamafactory
验收从小模型开始。我们先检查 0.8B 数据的模板编码和 loss mask,再用两张 A100 跑两步 LoRA,确认训练进程会使用刚安装的环境。接着换成 9B,用 DeepSpeed ZeRO-3 做全参数一步和三步 smoke。三步 smoke 最初因 Liger Kernel 序列化错误中断,关闭 enable_liger_kernel 后才完成。最后从 checkpoint 3 恢复到 step 5,检查 optimizer、scheduler 状态是否加载,以及 dataloader 是否跳过了已经消费的样本。这些测试通过后,才进入更长的训练。
长训练暴露了驱动 470 的另一处限制。反向传播分配显存时,ExpandableSegment::map() 调用 cuMemMap(),随后报 CUDA_ERROR_INVALID_VALUE。我们原本已经在启动 shell 设置了 expandable_segments:False,但 LlamaFactory v0.9.5 的 launcher 又向 torchrun 子进程写入 PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True。两次运行都在同一个 step 的同一个逻辑 rank 出错。交换两张物理卡后,错误仍跟随逻辑 rank。这个结果让我们转而检查子进程实际收到的 allocator 配置。
1
2
3
unset PYTORCH_CUDA_ALLOC_CONF
export PYTORCH_ALLOC_CONF='backend:native,expandable_segments:False,max_split_size_mb:512'
export OPTIM_TORCH=0
OPTIM_TORCH=0会避开该版 LlamaFactory launcher 默认开启 expandable segments 的分支。先运行rg -n 'PYTORCH_CUDA_ALLOC_CONF|OPTIM_TORCH' "${LF_WORK}/src/llamafactory",找到它在哪里构造torchrun的环境。然后检查子进程实际看到的两个 allocator 变量。如果 launcher 仍强制写入expandable_segments:True,就在独立的 LlamaFactory 工作副本中修改src/llamafactory/launcher.py。使用 v1 启动器时,还要检查src/llamafactory/v1/launcher.py。修改的内容是清除旧变量,并在复制父进程环境后设置PYTORCH_ALLOC_CONF=backend:native,expandable_segments:False,max_split_size_mb:512。保存补丁,再从失败前的 checkpoint 重跑。当时修复后通过了此前反复崩溃的 step 24。
虽然关闭 expandable segments 解决了 cuMemMap 错误,训练仍在几十步后发生 OOM。日志显示 allocator 已保留十几 GB 尚未分配的显存,却无法满足新的连续分配请求。定期清缓存减轻了碎片化,但几次重试仍未跑完。
最终我们在 ZeRO-3 中把 optimizer state offload 到 CPU,给每张卡释放了约 13 GB 显存。启用 offload 后,DeepSpeed 改用 DeepSpeedCPUAdam,又遇到参数组缺少 bias_correction 的错误。修复这个兼容问题后,才从 checkpoint 继续完成训练。
这几次失败的错误栈各不相同。看到
cuMemMap的invalid argument,先查启动器是否又开启了 expandable segments。看到CUDA out of memory且 reserved-but-unallocated 持续增长,再查显存碎片和 optimizer state 的占用。看到KeyError: bias_correction,则查 DeepSpeedCPUAdam 收到的参数组。原记录使用zero_optimization.stage=3,并设置offload_optimizer.device=cpu、pin_memory=true。对于当时使用的 DeepSpeed 版本,我们在隔离环境的deepspeed/ops/adam/cpu_adam.py中,让缺失的bias_correction默认取True。单测通过后才恢复训练,并保存了这处 site-packages 补丁。checkpoint 还遇到了存储问题。9B 全参数 checkpoint 约 94 GB,直接向共享的 FUSE 挂载写约 18 GB 的
model.safetensors时曾中途报 I/O error,而目录显示的剩余容量仍然充足。此后训练先把 checkpoint 写到本地盘,检查权重和各 rank 的 optimizer shard 是否齐全。需要长期保存时,再逐文件复制到持久目录并比对 SHA256。恢复 smoke 则核对 global step、optimizer、scheduler 和 dataloader 的进度,确认训练从保存的位置继续。
PPU 迁移
最初接触的 PPU 机器是 8 张 96 GiB 的 PPU-ZW810。厂商镜像已经装好了驱动、HGGC、PyTorch、vLLM、Triton 和通信库。环境调查记录的版本是驱动 1.5.3-5239a4、HGGC 13.0、Python 3.12.3、PyTorch 2.9.0+ppu2.0.0.oe、vLLM 0.15.0+ppu2.0.0.post1.oe、Transformers 5.2.0 和 Ray 2.46.0。PyTorch 的 torch.version.cuda 输出 12.9,通过 torch.cuda API 识别到的设备名称则是 PPU-ZW810。
这改变了前面在普通 GPU 和 A100 上的版本选择方式。那两种机器使用标准 PyTorch 2.10 和 vLLM 0.18。PPU 上先保留厂商的 PyTorch 2.9 和 vLLM 0.15,因为设备算子、通信和推理内核都依赖厂商构建。接下来逐项验证 Qwen3.5-9B 能否加载、BF16 能否计算并反传、多卡能否通信,以及 vLLM 能否实际生成。
在 PPU 上先查厂商栈的实际来源。下面的路径只是示意,
${TRAIN_PY}要指向厂商镜像中的 Python。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 export PPU_SDK=/path/to/vendor/sdk export TRAIN_PY=/path/to/vendor/python "${PPU_SDK}/ppu-smi/bin/ppu-smi" "${TRAIN_PY}" - <<'PY' import sys, torch, transformers, ray, vllm print('python', sys.version.split()[0], sys.executable) print('torch', torch.__version__, torch.__file__) print('reported CUDA', torch.version.cuda) print('device', torch.cuda.get_device_name(0)) print('bf16', torch.cuda.is_bf16_supported()) x = torch.ones((16, 16), device='cuda', dtype=torch.bfloat16, requires_grad=True) (x @ x).float().sum().backward() print('bf16 forward/backward', bool(torch.isfinite(x.grad).all())) print('transformers', transformers.__version__) print('ray', ray.__version__, 'vllm', vllm.__version__) PY
Python 包保留了厂商版本,VERL 源码则需要单独选择。厂商提供的 VERL 0.8.0.dev0、提交 28b106e6 能作为 PPU 设备接口的参考,但它缺少当前任务在普通 GPU 上验证过的 TransferQueue 多 segment 训练链。若直接换到这份源码,AgentLoop 的多段数据、padding、advantage 和 teacher routing 都要一起重写,训练行为也会变动。因此我们选用已经跑通过这些训练流程的 VERL 0.9.0.dev0、提交 1bf9d2d,在本地创建独立工作副本,再加入 PPU 设备适配和训练功能补丁。厂商提供的 VERL 原目录保持不动。
这个补丁也不能照搬 A100 版本。A100 为旧驱动加入过 cuMemMap、expandable segments 和 SDPA 的处理。PPU 有自己的驱动和内核实现,这些 A100 专项修改在 PPU 副本中被排除。PPU 的 vLLM 则需要关闭 custom all-reduce,梯度 probe 的设备与随机数操作也要走 VERL 的设备抽象。每次移植先检查补丁能否应用,再运行 CPU 单测、语法检查和设备 smoke,最后才运行训练。
PPU 内网无法访问外部 Git。我们在可联网机器上把固定 VERL 提交打成 Git bundle,传入内网后离线克隆到本地盘。9B SFT 权重同样来自此前 GPU 路线,这一阶段只搭建和验证 RL 环境:
1
2
3
4
5
6
7
8
9
10
11
12
# 可联网机器:先确认源码含经验证的 VERL 提交,再制作离线 bundle
git -C "${VERL_SOURCE}" cat-file -e "${VERL_COMMIT}^{commit}"
git -C "${VERL_SOURCE}" bundle create "${VERL_BUNDLE}" --all
sha256sum "${VERL_BUNDLE}" > "${VERL_BUNDLE}.sha256"
# 目标机器:只在本地盘创建新工作副本
sha256sum -c "${VERL_BUNDLE}.sha256"
git bundle list-heads "${VERL_BUNDLE}"
git clone "${VERL_BUNDLE}" "${LOCAL_SCRATCH}/src/verl-device"
git -C "${LOCAL_SCRATCH}/src/verl-device" checkout "${VERL_COMMIT}"
git -C "${LOCAL_SCRATCH}/src/verl-device" apply --check "${PATCH_FILE}"
git -C "${LOCAL_SCRATCH}/src/verl-device" apply "${PATCH_FILE}"
厂商镜像里还缺少这条多 segment 训练链使用的 TransferQueue。它是需要补装的少数包之一。我们先记录核心包版本,再用 --no-deps 安装训练基线使用的 TransferQueue==0.1.8,最后检查 PyTorch、vLLM、Triton 和 FlashAttention 是否保持原样。如果安装改变了厂商核心包,先恢复环境,再继续训练测试。
1
2
3
"${TRAIN_PY}" -m pip show torch vllm triton TransferQueue
"${TRAIN_PY}" -m pip install --no-deps 'TransferQueue==0.1.8'
"${TRAIN_PY}" -m pip show torch vllm triton TransferQueue
若奖励或评测需要 embedding,服务使用单独环境。PPU 上我们用 venv --system-site-packages 复用厂商 PyTorch,再安装所需的 sentence-transformers,避免训练环境被社区 wheel 改写。服务启动后,先请求 /health,再发送一次真实的 embedding 请求。请求里的模型名、维度和 endpoint 都要与训练配置一致。
1
2
3
4
5
6
7
8
"${TRAIN_PY}" -m venv --system-site-packages "${LOCAL_SCRATCH}/envs/embedding"
"${LOCAL_SCRATCH}/envs/embedding/bin/python" -m pip install --no-deps sentence-transformers
"${LOCAL_SCRATCH}/envs/embedding/bin/python" - <<'PY'
import torch, sentence_transformers
print('torch:', torch.__version__, torch.__file__)
print('sentence-transformers:', sentence_transformers.__version__, sentence_transformers.__file__)
PY
PPU 还碰到一个在交互式终端里不容易发现的问题。通过 nohup 启动训练后,部分 Ray actor 和 vLLM 子进程拿不到 SDK 所需的 PPU_SDK、CUDA_PATH、CUDA_HOME、PATH、LD_LIBRARY_PATH。我们先在启动脚本中加载厂商 profile,并在实际报错的子进程里查看这些变量。如果多级进程启动仍丢失变量,才考虑给该镜像添加 Python 启动 bootstrap。这样的 .pth 或 site-packages 注入会影响每个 Python 进程,需要记录安装和回退方式。
Python 的
spawn子进程通常会继承创建它的进程环境。排查时,要沿着 Ray actor、vLLM server、EngineCore、WorkerProc 逐级找出变量从哪里开始缺失。遇到os.path.join(os.environ.get("CUDA_PATH"), ...)报错,或发现PPU_SDK为None,先用ps找到报错进程的 PID。然后令WORKER_PID为该 PID,执行tr '\0' '\n' <"/proc/${WORKER_PID}/environ" | rg '^(PPU_SDK|CUDA_PATH|CUDA_HOME|LD_LIBRARY_PATH)='。修复启动脚本后,重新创建 Ray session 和 vLLM worker,再检查新进程的环境。
1
2
3
4
5
6
7
8
9
10
11
12
export PPU_PROFILE=/path/to/vendor/ppu_env.sh
source "${PPU_PROFILE}"
export PPU_SDK=/path/to/vendor/sdk
export CUDA_PATH="${PPU_SDK}/CUDA_SDK"
export CUDA_HOME="${CUDA_PATH}"
test -n "${LD_LIBRARY_PATH:-}"
"${TRAIN_PY}" - <<'PY'
import os
for name in ('PPU_SDK', 'CUDA_PATH', 'CUDA_HOME', 'LD_LIBRARY_PATH'):
print(name, repr(os.environ.get(name)))
PY
Ray 会在临时目录后继续拼接 session 与 socket 文件名。曾有运行因路径过长触及 Unix domain socket 的长度限制,因此把 RAY_TMPDIR 放在短的本地路径,并避免在路径里重复完整实验名:
1
2
3
export RAY_TMPDIR="${LOCAL_SCRATCH}/r/short_run"
export TMPDIR="${LOCAL_SCRATCH}/t/short_run"
mkdir -p "${RAY_TMPDIR}" "${TMPDIR}"
安装完成后,我们没有立即启动 9B 长跑。先在单卡上确认 BF16 前向和反向计算,再用 8 个进程做 all-reduce。每个 rank 提交自己的序号,8 卡都应得到 1+2+…+8=36。这一步检查厂商通信库与 PyTorch 分布式接口是否能协同工作。然后检查 9B 模型的配置、tokenizer 和权重分片,并在单卡 vLLM 上做一次短生成。此前使用过的标准 vLLM 0.18 能生成,并不能证明厂商 vLLM 0.15 也能生成,所以这一关需要在 PPU 上重新做。
接着启动 embedding 服务,检查健康状态和真实向量请求,再做训练数据与 AgentLoop 路由预检。训练门禁只取一个确定性的 mixed 小批次,先把新增损失的系数设为零,运行完整的一步 GRPO。我们在这一步检查了 8 卡 FSDP、rollout、reward、actor 到 vLLM 的权重同步、有限的 loss 和梯度,以及八个 rank 的 checkpoint 分片。第二个单步再打开新增训练路径,核对它确实参与 loss 和更新。这样可以把基础框架故障与新增训练代码故障分开定位。
VERL 补丁怎样管理
需要修改 VERL 时,我们保留可回溯的上游基线,在独立工作副本中应用补丁。先用 git apply --check 检查补丁能否应用。应用后,再做语法检查、相关 CPU 单测、模型导入和单步 smoke。基线、设备迁移修改和研究功能修改分别留档,排错时就能确定变动来自哪一层。
1
2
3
4
5
6
7
8
9
export VERL_WORK="${LOCAL_SCRATCH}/src/verl-device"
git -C "${VERL_WORK}" rev-parse HEAD
git -C "${VERL_WORK}" diff --check
"${TRAIN_PY}" -m compileall -q "${VERL_WORK}/verl"
"${TRAIN_PY}" -m pytest -q /path/to/relevant_cpu_tests
"${TRAIN_PY}" -m pip freeze > "${LOCAL_SCRATCH}/logs/rl-freeze-after-patch.txt"
sha256sum "${PATCH_FILE}" > "${LOCAL_SCRATCH}/logs/patch.sha256"
修改还要按用途区分。Python 路径、SDK 环境、socket 目录和旧驱动不支持的分配器,都是让训练程序适应机器的改动。上下文窗口、embedding 模型、奖励、数据过滤、rollout 数量和训练损失会影响实验本身。改变这些设置时,我们另做对照,避免把实验结果的变化归因于环境迁移。
用一条 Smoke 阶梯结束环境搭建
我们现在用下列顺序判断环境是否可交付。每一步只扩大一个维度,失败时保留命令、配置与首个有效错误栈。
- 静态检查:记录解释器与包的实际路径,读模型
config.json和 tokenizer,确认设备可见、bf16 与多卡通信。 - 独立服务:分别测试 vLLM 短生成和需要的 embedding 请求。端口已监听只能说明进程开始提供服务,还要用真实请求确认模型能返回结果。
- 框架进程:在 Ray actor 中重复一次模型加载/生成,确认子进程拿到了相同的 Python 包与设备环境。
- 训练最小步:先跑公开任务的小模型单步,再跑目标规模单步。检查真实 rollout、reward、有限的 loss/gradient、显存与各阶段耗时。
- 恢复与交付:保存 checkpoint、从 checkpoint 恢复一个短 run、合并 FSDP 权重并用推理服务验证输出。
FSDP checkpoint 合并成 Hugging Face 目录的通用命令如下。${CKPT_DIR} 应指向实际 global_step_*,且分片必须齐全:
1
2
3
4
5
6
7
8
"${TRAIN_PY}" -m verl.model_merger merge \
--backend fsdp \
--local_dir "${CKPT_DIR}/actor" \
--target_dir "${LOCAL_SCRATCH}/merged_model"
CUDA_VISIBLE_DEVICES=0 vllm serve "${LOCAL_SCRATCH}/merged_model" \
--served-model-name smoke-model \
--host 127.0.0.1 --port 8000
长跑时,Ray 临时目录、缓存、sandbox 和正在写入的 checkpoint 优先使用本地盘。我们遇到过共享/FUSE 后端在多 rank 并发写大 checkpoint 时失败,当时容量仍显示充足。此后先验证本地 checkpoint 的文件和分片齐全,再复制到持久目录并校验。df、du、文件分片清单和恢复日志都需要一起看,单有一个 checkpoint 目录并不能证明可以恢复。
最终留存一份简洁的环境证据:设备与驱动信息、pip freeze、模型与数据版本、VERL 基线提交、补丁校验值、启动时的 resolved config、单步日志,以及 checkpoint 恢复和推理结果。它们让下一次迁移有明确起点,也能避免把曾经有效的临时排错措施误当成长期通用配置。
人-Agent 协作
这些环境并非由一个人在一台机器上从头装到尾。普通 GPU 阶段,我和外部的 Codex 一起查版本、改脚本、跑小模型 smoke,把成功的命令和失败的原因记成文档。进入内网 a100 和 ppu 环境后,一开始我把文档带到训练服务器,最初尝试与内网的 Claude Code 搭配国产模型继续排查。国产模型可以执行命令、收集日志,但面对旧驱动、厂商 PPU 栈、VERL 补丁和多进程启动问题同时出现的情况,经过约一天的反复尝试,仍很难独立确定下一步该改什么。
后来调整了分工。我把内网机器的错误栈和已完成的测试结果交给外部 Codex,结合这些证据,查询公开文档和源码,写出下一轮的检查顺序、具体命令、预期结果与停止条件。内网模型按计划操作,记录实际输出。若结果与预期不符,就先停在当前步骤,把首个有效错误和环境信息带回来,再修改计划。这种往返持续了多轮,最终打通了训练环境。
这套协作最有用的部分,是让每次交接都带着可以核对的证据。计划要说明要在哪个环境执行、改动哪些文件、成功时应看到什么。执行记录要保留真实命令、包版本、进程环境和错误栈。这样即使外部模型不能直接登录内网服务器,内网模型也无法访问外部资料,双方仍能围绕同一份事实逐步推进。