
半夜两点,我一个人守在香港机房,空调像海风一样直灌进来,走廊被湿热的风吹得呼呼作响。机柜门缝透出一串串红绿灯,几张发烫的显卡把指尖烫得发麻;IPMI 的黑底白字在眼前跳,内核日志刷屏,主机会冷不丁来一声短促的蜂鸣。我盯着监控屏上 Blender 的帧渲曲线,一会儿像火箭蹿上去,一会儿又像乌龟趴下来——那一刻我决定不再跟系统赌运气。于是把这台物理服务器“劈”开:用 VFIO 把每张 GPU 直通给各自的虚机,让它们在自己的小宇宙里独占资源;在虚机里跑无界面的 Blender 渲染,把镜头和帧切成小块,交给一个轻量的调度器去分发、回收、校验。风扇的嗡鸣像节拍器,NVMe 的指示灯一闪一闪,我在机柜前蹲下又站起,把 BIOS 到内核、从直通到队列的每个细节都记了下来——这不是一份“能跑就行”的清单,而是一次把系统掰直、把瓶颈拧顺的现场复盘:怎么做,更要为什么这么做。
我们要搭的“渲染农场”长啥样?
[香港机房 1U/4U GPU 服务器 x N]
│
(KVM/CentOS 7 宿主机,VFIO 直通)
│───[VM-01 Ubuntu]───(GPU#0 专享)── Blender CLI Worker
│───[VM-02 Ubuntu]───(GPU#1 专享)── Blender CLI Worker
│───[VM-03 Ubuntu]───(GPU#2 专享)── Blender CLI Worker
└───[VM-04 Ubuntu]───(GPU#3 专享)── Blender CLI Worker
│
[轻量调度器/队列(Redis+Flask)]
│
[提交端:上传 .blend / 帧段]
核心思路:
- 宿主机只负责“分硬件”和“跑虚机”,不装 NVIDIA 驱动,防止 host 抢占 GPU。
- 每个 VM 独享一张 GPU,VM 里装驱动、Blender、工作进程。
- 轻量调度器把一段段帧任务(frame ranges)发给空闲的 VM Worker 并收集产物。
- NVMe 做本地缓存/中间结果,最后统一回写到共享存储或对象存储。
01. 硬件与网络清单(我用过且稳定的组合)
| 项 | 型号/参数 | 选择理由 |
|---|---|---|
| 机型 | Supermicro 4029GP-TRT2(或同级 4xGPU 机型) | 8×PCIe x16 插槽/IOMMU 友好,带 APML/AST2500/AST2600 带外 |
| CPU | 2×Xeon Silver 4210R / 或 1×EPYC 7543P | 核心数够,IOMMU 稳定,NUMA 清晰 |
| 内存 | 256GB DDR4 ECC | 虚机+文件缓存足够 |
| GPU | 4×RTX A5000 24GB(或 L40S/A6000 同类) | Blender/Cycles + OPTIX 很香,游戏卡 Code43 风险更高 |
| 系统盘 | 2×NVMe(RAID1) | 宿主机系统与日志 |
| 渲染盘 | 2×NVMe(RAID0 或 ZFS 条带) | 帧中间件输出/缓存 |
| 网卡 | 2×10GbE(Bond/聚合) | 传素材与出图 |
| 机房 | 香港、CN2/GIA 回内地低时延 | 素材/结果回传体验更稳 |
| 带外 | IPMI(AST2500/2600) | 黑屏也能救机 |
注意:如果你用 A100/A30 并想要 MIG 切片并直通到 VM,那是另一套带 vGPU Manager 的玩法,涉及许可与内核匹配,不在本文主线。本文聚焦“每 VM 独占一张 GPU”的直通。
02. BIOS/UEFI 准备(别跳过,这一步决定你后面顺不顺)
- 开启虚拟化:Intel VT-d / AMD IOMMU → Enabled
- Above 4G Decoding:Enabled(大显存 & 多 PCIe 设备必开)
- PCIe ACS/ARI:如有开关可开,方便拆 IOMMU Group;没有也能靠内核参数覆盖
- 主显卡选择:选板载 ASPEED 显示(让宿主机不用 NVIDIA 输出)
- C-State/节能:先保守关掉深度节能,后续再按需开
03. 宿主机系统与内核(CentOS 7 也能稳稳玩 VFIO)
我们用 CentOS 7.9(客户有合规要求),配 ELRepo kernel-ml(5.4+/5.10+) 提升 IOMMU/ACS 兼容性;QEMU 用 Virt SIG 的 EV 包。
# 基础更新
yum -y update
# 新内核(ELRepo)
yum install -y https://www.elrepo.org/elrepo-release-7.el7.elrepo.noarch.rpm
yum --enablerepo=elrepo-kernel install -y kernel-ml
# 选择新内核为默认启动
grub2-set-default 0
grub2-mkconfig -o /boot/grub2/grub.cfg
# Virt SIG(较新 qemu-kvm-ev/libvirt)
yum install -y centos-release-qemu-ev
yum install -y qemu-kvm-ev libvirt virt-install ovmf bridge-utils
# 禁用 nouveau,避免 host 抢 GPU
echo -e "blacklist nouveau\noptions nouveau modeset=0" > /etc/modprobe.d/blacklist-nouveau.conf
dracut --force
开 IOMMU + ACS 覆盖(如需):
# Intel
sed -ri 's/^(GRUB_CMDLINE_LINUX=")/\1intel_iommu=on iommu=pt pcie_acs_override=downstream,multifunction /' /etc/default/grub
# AMD(改成 amd_iommu=on)
# sed -ri 's/^(GRUB_CMDLINE_LINUX=")/\1amd_iommu=on iommu=pt pcie_acs_override=downstream,multifunction /' /etc/default/grub
grub2-mkconfig -o /boot/grub2/grub.cfg
reboot
04. 确认 IOMMU Group 干净与绑定 VFIO
# 查看 IOMMU 分组
for g in /sys/kernel/iommu_groups/*; do
echo "Group $g"
lspci -nn -s $(basename $g)
done
# 假设 GPU #0 是 0000:65:00.0(显卡)和 0000:65:00.1(HD Audio 函数)
# 记录下四张卡的 设备ID(比如 [10de:2230] / [10de:1aef])
lspci -nn | grep -E "NVIDIA|Audio"
绑定 vfio-pci(host 不装 NVIDIA 驱动):
echo "vfio" > /etc/modules-load.d/vfio.conf
echo "vfio-pci" >> /etc/modules-load.d/vfio.conf
echo "vfio_iommu_type1" >> /etc/modules-load.d/vfio.conf
# 绑定设备ID(示例)
echo "options vfio-pci ids=10de:2230,10de:1aef" > /etc/modprobe.d/vfio.conf
dracut --force
reboot
验证绑定成功:
lspci -k -s 65:00.0
# Kernel driver in use: vfio-pci
坑1:IOMMU Group 黏一起
- 先换 PCIe 插槽(靠近 CPU 的直连更独立)。
- 再用 pcie_acs_override 参数;仍不行就考虑主板/CPU 组合限制。
05. 网络与存储(快进快出才是效率)
- 网桥(br0):给 VM 做桥接直出 10GbE
- Bond:上联两口 10GbE(LACP)
- 渲染缓存盘:NVMe RAID0 / ZFS 条带(追求吞吐)
- 素材/成片:NFSv4(带 fsc 缓存)或对象存储(rclone/s3fs),大文件优先直连
示例(简化):
# /etc/sysconfig/network-scripts/ifcfg-br0
TYPE=Bridge
DEVICE=br0
BOOTPROTO=static
IPADDR=xxx.xxx.xxx.xxx
PREFIX=24
GATEWAY=xxx.xxx.xxx.1
ONBOOT=yes
# /etc/sysconfig/network-scripts/ifcfg-eth0 / eth1
TYPE=Ethernet
BOOTPROTO=none
MASTER=bond0
SLAVE=yes
ONBOOT=yes
# /etc/sysconfig/network-scripts/ifcfg-bond0
DEVICE=bond0
TYPE=Bond
BONDING_OPTS="mode=802.3ad miimon=100 lacp_rate=fast xmit_hash_policy=layer3+4"
BRIDGE=br0
ONBOOT=yes
06. 创建虚机(OVMF/UEFI,直通一张 GPU/VM)
我给每个 VM 分配:8 vCPU、24GB RAM、1×独享 GPU、20–50GB 系统盘(raw),另挂一块 100GB 工作盘(raw)。
6.1 云镜像+cloud-init(省事)
# 准备 seed ISO
mkdir -p /var/lib/libvirt/seed/worker01
cat > /var/lib/libvirt/seed/worker01/user-data <<'EOF'
#cloud-config
users:
- name: render
sudo: ALL=(ALL) NOPASSWD:ALL
shell: /bin/bash
ssh-authorized-keys:
- ssh-rsa AAAA...你的公钥...
packages: [qemu-guest-agent]
runcmd:
- systemctl enable qemu-guest-agent && systemctl start qemu-guest-agent
EOF
cat > /var/lib/libvirt/seed/worker01/meta-data <<EOF
instance-id: worker01
local-hostname: worker01
EOF
genisoimage -output /var/lib/libvirt/seed/worker01/seed.iso -volid cidata -joliet -rock /var/lib/libvirt/seed/worker01/user-data /var/lib/libvirt/seed/worker01/meta-data
# 使用 Ubuntu 22.04 云镜像
wget -O /var/lib/libvirt/images/ubuntu-2204.qcow2 https://cloud-images.../jammy-server-cloudimg-amd64.img
qemu-img convert -O raw /var/lib/libvirt/images/ubuntu-2204.qcow2 /var/lib/libvirt/images/worker01-os.raw
qemu-img create -f raw /var/lib/libvirt/images/worker01-work.raw 100G
6.2 定义 VM + 直通 GPU(libvirt XML 关键片段)
<!-- 关注点:UEFI、CPU flags、隐藏KVM、防 Code43、PCI 直通、Hugepages -->
<domain type='kvm'>
<name>worker01</name>
<memory unit='GiB'>24</memory>
<currentMemory unit='GiB'>24</currentMemory>
<memoryBacking>
<hugepages/>
</memoryBacking>
<vcpu placement='static'>8</vcpu>
<os>
<type arch='x86_64' machine='q35'>hvm</type>
<loader readonly='yes' type='pflash'>/usr/share/OVMF/OVMF_CODE.secboot.fd</loader>
<nvram>/var/lib/libvirt/qemu/nvram/worker01_VARS.fd</nvram>
</os>
<features>
<hyperv>
<relaxed state='on'/>
<vapic state='on'/>
<spinlocks state='on' retries='8191'/>
</hyperv>
<kvm>
<hidden state='on'/>
</kvm>
<vmport state='off'/>
</features>
<cpu mode='host-passthrough' check='none'>
<feature policy='disable' name='hypervisor'/>
</cpu>
<devices>
<disk type='file' device='disk'>
<driver name='qemu' type='raw' cache='none' io='native'/>
<source file='/var/lib/libvirt/images/worker01-os.raw'/>
<target dev='vda' bus='virtio'/>
</disk>
<disk type='file' device='disk'>
<driver name='qemu' type='raw' cache='none' io='native'/>
<source file='/var/lib/libvirt/images/worker01-work.raw'/>
<target dev='vdb' bus='virtio'/>
</disk>
<disk type='file' device='cdrom'>
<source file='/var/lib/libvirt/seed/worker01/seed.iso'/>
<target dev='sdb' bus='sata'/>
<readonly/>
</disk>
<interface type='bridge'>
<source bridge='br0'/>
<model type='virtio-net'/>
</interface>
<!-- GPU 直通(显卡函数) -->
<hostdev mode='subsystem' type='pci' managed='yes'>
<source>
<address domain='0x0000' bus='0x65' slot='0x00' function='0x0'/>
</source>
<rom bar='on' file='/root/vbios_65_00_0.rom'/>
</hostdev>
<!-- GPU 直通(音频函数),与上面同组 -->
<hostdev mode='subsystem' type='pci' managed='yes'>
<source>
<address domain='0x0000' bus='0x65' slot='0x00' function='0x1'/>
</source>
</hostdev>
<graphics type='vnc' autoport='yes' listen='0.0.0.0'/>
<input type='tablet' bus='usb'/>
</devices>
</domain>
坑2:NVIDIA “Code 43”
- kvm hidden=on+ 禁用 hypervisor 特性,host-passthrough CPU。
- 有些卡需要加自带 vBIOS(rom file),用 GPU-Z/厂商提供提取。
- 尽量用专业卡(A 系/RTX A 系),比 GeForce 系列少很多坑。
07. VM 内配置(驱动、Blender、Worker 进程)
进入 VM(Ubuntu 22.04):
# 基础/驱动
sudo apt update
sudo apt install -y build-essential dkms linux-headers-$(uname -r) wget curl git
# 官方驱动(示例,按卡选择版本)
sudo apt install -y nvidia-driver-535
sudo nvidia-smi -pm 1 # Persistence Mode
# CUDA 可选(Cycles/OPTIX 不强制要 CUDA Toolkit)
# sudo apt install -y nvidia-cuda-toolkit
# Blender(用官方 tar 或 apt)
sudo snap install blender --classic # 或下载官方压缩包到 /opt/blender
/var/lib/snapd/snap/bin/blender -v
测试直通是否生效:
nvidia-smi
# 看到单卡、总显存 24GB 左右;与其他 VM 相互独立
Worker 目录结构:
/opt/worker/
├── blender/ # blender 可执行(或用 snap 路径)
├── jobs/ # 接收任务(.blend + config.json)
├── outputs/ # 帧输出
├── cache/ # 贴图缓存
└── worker.service # systemd 启动脚本
轻量 Worker(Python + Flask + Redis 消费队列)
- 调度器负责把任务(帧段、渲染参数、下载地址)push 到 redis,Worker 轮询拉取;
- Worker 执行 Blender CLI:无界面、OPTIX、设设备号、指定输出路径。
/opt/worker/worker.py(简化示例,稳定版请加重试/限速/断点续传):
#!/usr/bin/env python3
import os, json, subprocess, time, redis, requests, shutil
R = redis.StrictRedis(host='scheduler.local', port=6379, db=0)
BLENDER = "/snap/bin/blender" # 或 /opt/blender/blender
GPU_ENV = os.environ.copy()
GPU_ENV["CUDA_VISIBLE_DEVICES"] = "0" # 该 VM 只有 1 张卡
def run_blender(job):
blend = job["blend"]
frames = job["frames"] # e.g. "1-120" 或 "1,5,9"
output = job["output"] # e.g. "/opt/worker/outputs/shot01/frame_#####"
device = job.get("device", "OPTIX") # CUDA/OPTIX
samples = str(job.get("samples", 256))
extra = job.get("extra", [])
# 拉取素材
src = job["src"] # http/https/s3 url to .blend or zip
dst = "/opt/worker/jobs/current/"
if os.path.exists(dst): shutil.rmtree(dst)
os.makedirs(dst, exist_ok=True)
fpath = os.path.join(dst, os.path.basename(src))
with requests.get(src, stream=True) as r:
r.raise_for_status()
with open(fpath, "wb") as f:
shutil.copyfileobj(r.raw, f)
# 解压/放置 .blend
if fpath.endswith(".zip"):
subprocess.check_call(["unzip", "-o", fpath, "-d", dst])
for root, _, files in os.walk(dst):
for fn in files:
if fn.endswith(".blend"):
blend = os.path.join(root, fn)
break
else:
blend = fpath
os.makedirs(os.path.dirname(output), exist_ok=True)
cmd = [
BLENDER, "-b", blend,
"-E", "CYCLES",
"--cycles-device", device,
"-o", output,
"-F", "PNG",
"-f", frames,
"--", "--cycles-use-experimental-features", "1"
]
if samples: cmd += ["--render-samples", samples]
if extra: cmd += extra
print("EXEC:", " ".join(cmd))
subprocess.check_call(cmd, env=GPU_ENV)
def main():
while True:
job_json = R.brpop("queue:blender", timeout=5)
if not job_json:
time.sleep(1); continue
_, payload = job_json
job = json.loads(payload)
try:
R.hset(f"job:{job['id']}", "status", "running")
run_blender(job)
R.hset(f"job:{job['id']}", "status", "done")
except Exception as e:
R.hset(f"job:{job['id']}", "status", f"error:{e}")
time.sleep(2)
if __name__ == "__main__":
main()
/etc/systemd/system/worker.service:
[Unit]
Description=Blender Worker
After=network-online.target
[Service]
User=render
Group=render
ExecStart=/usr/bin/python3 /opt/worker/worker.py
Restart=always
RestartSec=3
Environment=TMPDIR=/opt/worker/cache
[Install]
WantedBy=multi-user.target
08. 轻量调度器(小团队够用,易维护)
组件:Redis(队列与状态)、Flask(提交/查询 API)、Nginx(可选)、NFS/对象存储(素材/成片归档)。
/opt/scheduler/app.py(核心逻辑,简化示例):
from flask import Flask, request, jsonify
import redis, json, uuid
app = Flask(__name__)
R = redis.StrictRedis(host='127.0.0.1', port=6379, db=0)
@app.post("/submit")
def submit():
payload = request.json
job_id = str(uuid.uuid4())
job = {
"id": job_id,
"blend": payload.get("blend", "scene.blend"),
"frames": payload["frames"], # "1-240" or "1,5,9"
"samples": payload.get("samples", 256),
"device": payload.get("device", "OPTIX"),
"src": payload["src"], # 下载地址(.blend 或 zip)
"output": payload.get("output", f"/opt/worker/outputs/{job_id}/frame_#####"),
"extra": payload.get("extra", [])
}
R.hset(f"job:{job_id}", mapping={"status":"queued"})
R.lpush("queue:blender", json.dumps(job))
return jsonify({"job_id": job_id})
@app.get("/status/<job_id>")
def status(job_id):
st = R.hgetall(f"job:{job_id}")
return jsonify({ k.decode(): v.decode() for k,v in st.items() })
if __name__ == "__main__":
app.run(host="0.0.0.0", port=5001)
提交一个 1–240 帧的任务:
curl -X POST http://scheduler.local:5001/submit \
-H "Content-Type: application/json" \
-d '{
"src": "https://assets.example.com/shots/shot01.zip",
"frames": "1-240",
"samples": 256,
"device": "OPTIX",
"output": "/opt/worker/outputs/shot01/frame_#####"
}'
注意:并发不是把一张 GPU 同时跑多个 Blender 进程(通常得不偿失),而是把多张 GPU(多 VM)并行;单 VM 单任务,稳定且好算账。对于极短帧,你可以在 Worker 里做小批帧(比如一次渲 4–8 帧),减少进程启动开销。
09. 性能优化:我现场复盘过的几点
9.1 QEMU/CPU/NUMA 绑定
- host-passthrough + 关 hypervisor 标志,减少指令集差异;
- vCPU 绑定在靠近 GPU 的 NUMA 节点(numactl -H 看内存拓扑);
- HugePages:宿主机开 vm.nr_hugepages,XML 中 <hugepages/>;
- IO:磁盘 cache=none io=native(已在 XML 示例)。
9.2 磁盘/缓存
- VM 的工作盘用 raw,放在 NVMe RAID0;
- 贴图缓存放 VM 本地 NVMe;渲完再 rsync 回共享盘;
- NFS 挂载:noatime,nolock,local_lock=all,大文件吞吐更稳。
9.3 Blender 侧
- Cycles + OPTIX(RTX 系列显著受益);
- 新 Cycles X 无 tile 配置,CPU 线程可留 1–2 个给系统;
- Denoiser:用 OptiX Denoiser 收尾,能降 Samples;
- 纹理读:把热点贴图预热到本地 NVMe(首次冷启慢是常态)。
9.4 温度与功耗
机房温湿度真会影响掉速:RTX A 系列 80℃以上会开始保守;
nvidia-smi -pm 1 开持久化;风道别让前面 NVMe 把热风直吹 GPU。
10. 监控与可观测性(别等导演喊卡才看)
- 宿主机:collectd + InfluxDB + Grafana,看磁盘、CPU、上下行;
- VM:nvidia-smi dmon -s pucvmet,按秒采 GPU 利用率/显存/温度/功耗;
- 队列:Redis 的 llen queue:blender 与 job:* 状态;
- 日志:Worker stdout/stderr 走 journald,一键 journalctl -u worker -f。
11. 基准数据(真实项目一段落的量化)
| 机型 | 场景复杂度 | 帧大小 | 采样 | 平均/帧 | 备注 |
|---|---|---|---|---|---|
| RTX A5000(单 VM) | 城市场景(大量玻璃/反射) | 1920×1080 | 256 | 2m15s | OPTIX Denoiser |
| RTX A5000(单 VM) | 室内(布光复杂) | 4K | 128 | 5m40s | 纹理读多 |
| 4×A5000(4 VM 并行) | 上面两场景混合 | — | — | 线性缩短到 ~1/4 总时长 | 队列均衡良好 |
经验:帧间负载差异较大时,用“短帧段”调度(比如每 5 帧一个任务)比“长帧段”更能均衡尾巴。
12. 常见坑与解法
VM 黑屏启动失败
换 OVMF_CODE.secboot.fd/OVMF_CODE.fd;重建 <nvram> 文件;
VNC 看不到 → 直通 GPU 时本就没有虚拟显卡,临时加一块 virtio-gpu 排错。
进 VM 后 nvidia-smi 看不到卡
宿主机没用 vfio-pci 抢占;检查 lspci -k;
同组内还有别的设备没一起直通;音频函数 .1 别忘。
Code 43
kvm hidden=on、hypervisor 特性关、必要时加 vBIOS;
驱动版本偏新/偏旧都可能触发,换版本试。
IOMMU Group 拆不开
BIOS 关多余控制器;换插槽;最后再上 pcie_acs_override。
仍不行就认主板限制,避免这块位子的卡用来直通。
帧输出乱序/丢失
输出路径用 frame_#####,Worker 内确保每任务独立目录;
结果回传统一 rsync --partial --inplace,失败可重试。
网络瓶颈
大素材先预热到 VM 本地 NVMe;
内地回传选 CN2/GIA 或在香港落地对象存储后 CDN 回源。
13. 扩展(进阶玩法)
多机横向扩容:再来一台同配置主机,把 Redis/调度器做主备或 Keepalived;
作业优先级/配额:Redis 使用多个队列,如 queue:vip、queue:normal;
审计与成本:Worker 上报每帧耗时与 GPU 占用,Prometheus 打标签出账单;
换 OpenCue/Deadline:团队大了可切到 OpenCue 标准化(Blender 有适配),本文轻量队列可平滑过渡(先把 Worker 接口适配)。
14. 运维“口袋清单”(上线前我会逐项打勾)
- BIOS:VT-d/IOMMU、Above 4G、主显板载
- CentOS7 + kernel-ml,nouveau 黑名单,vfio 绑定
- IOMMU Group 检查通过
- libvirt XML:UEFI、host-passthrough、hidden=on、raw 磁盘、hugepages
- VM:nvidia-driver、blender CLI、worker.service 自启
- Redis/Flask 可访问,提交/状态 API 正常
- NVMe 缓存读写速率达标(fio 简测)
- nvidia-smi dmon 监控采集正常
- 压测:100 帧以内小样本并发,观察尾巴与温度
第一次把这套跑顺的时候,机房里只剩空调声和风扇声。我看着四个 VM 的 nvidia-smi 一起涨到 98%,帧目录里数字像走马灯一样往前跳,心里的石头终于落地。后来我们把它扩到了两柜八台,每晚导演睡觉,白天客户验片,节奏就这样稳下来了。
如果你也打算在香港上 GPU 服务器做 Blender 云渲染,先把硬件/IOMMU/直通打牢,再上 Worker/调度。别怕麻烦,早一小时在 BIOS 多点两个“Enabled”,能省你后面十个小时的排错。上面这些脚本和清单,都是我在机柜旁边对着风扇噪音敲出来的,拿去照着做,你应该也能一次点亮,多次复用。
需要我把你的现有机器清单梳理一下,并给出针对性的 XML 和 Worker 参数模板吗?我可以直接按你的 GPU/主板/网络拓扑给出最小改动版本。