香港GPU服务器:VFIO GPU直通把一台机“劈”成多台,Blender云渲染并发调度的配置与优化
技术教程 2025-09-17 11:40 199


半夜两点,我一个人守在香港机房,空调像海风一样直灌进来,走廊被湿热的风吹得呼呼作响。机柜门缝透出一串串红绿灯,几张发烫的显卡把指尖烫得发麻;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/主板/网络拓扑给出最小改动版本。