开始之前:环境、全景与读码方法
别急着打开 scheduler.go。先花 30 分钟把仓库跑起来、建立一张地图、
学会三种「验证猜想」的手段 —— 后面每一章你都会用到它们。
学完这章你应该能
- 说出 HAMi 的三个二进制分别叫什么、跑在哪、干什么
- 在本地成功跑通至少一个单元测试(不需要 K8s 集群,不需要 GPU)
- 拿到一个陌生函数名,30 秒内定位到它在哪个文件
- 知道遇到「这段代码到底怎么跑的」时,用哪三招去验证
1. HAMi 是什么:一句话和三个组件
一句话:HAMi 让一张物理 GPU 能被多个 Pod 安全地共享,并且让 Kubernetes 调度器知道该把 Pod 放到哪张卡上。
原生 Kubernetes 只能整卡分配:你申请 nvidia.com/gpu: 1,就独占一整张 A100。
一个只需要 3GB 显存的推理服务,也会把 80GB 的卡整张吃掉。HAMi 解决的就是这件事。
要做到这件事,需要三个组件各管一段:
| 组件 | 跑在哪 | 负责什么 |
|---|---|---|
| Device Plugin | 每个 GPU 节点 | 每 30 秒扫描本机设备;清单变化时向集群更新有几张卡、每张多大、还剩多少 |
| Scheduler Extender | 集群,多副本选主 | 根据「要几张、要多少显存、要不要挨着」选出节点和具体的卡,把结果写进 Pod 注解 |
| HAMi-core | 每个业务容器内 | 拦住越界:只分到 3000MB 显存,申请到 3001MB 就返回 OOM |
三个独立进程,主链路全靠注解字符串传话(唯一的例外是 NUMA refit,
设备插件会直接请求调度器的 /refit 接口,第 10 章展开)。
这个注解协议就是整个项目的骨架,也是第 01 章的内容。
2. 环境准备(15 分钟)
学习 HAMi 源码不需要 GPU,也不需要 Kubernetes 集群。
它的核心逻辑(打分、Fit 筛选、注解编解码)全是纯内存函数,
go test 就能跑。集群只在你想做端到端验证时才需要。
2.1 你需要什么
| 工具 | 版本 | 说明 |
|---|---|---|
| Go | 1.27.0 | 见 。低版本会因为用了 strings.SplitSeq 等新 API 编译失败 |
| Git | 任意 | — |
| golangci-lint | 见脚本 | 可选,提 PR 前才需要,版本固定在 |
| Docker | 任意 | 可选,只在 make docker 时需要 |
| K8s 集群 | 1.16+ | 可选,只在做 e2e 验证时需要 |
2.2 拉代码并切到对照的 commit
本教程所有行号都对应 commit 347502f。强烈建议你也切过去,
这样文中说的「第 853 行」在你本地也确实是第 853 行。
git clone https://github.com/Project-HAMi/HAMi.git
cd HAMi
# 切到本文对照的 commit,行号完全一致
git checkout 347502f4733e8f43dcc344a653e790ef30943097
# 拉依赖(国内建议先设代理)
go env -w GOPROXY=https://goproxy.cn,direct
go mod download
2.3 第一次编译
# 一次编译出全部三个二进制到 bin/
make build
# 看看产出了什么
ls -la bin/
# scheduler 调度器扩展器
# vGPUmonitor 容器内 GPU 用量监控
# nvidia-device-plugin NVIDIA 设备插件
构建规则很简单,就两条 go build,
目标名写在 :
CMDS=scheduler vGPUmonitor 和 DEVICES=nvidia。
build: $(CMDS) $(DEVICES)
$(CMDS):
$(GO) build -ldflags '$(GO_BUILD_LDFLAGS)' -o ${OUTPUT_DIR}/$@ ./cmd/$@
$(DEVICES):
$(GO) build -ldflags '$(GO_BUILD_LDFLAGS)' -o ${OUTPUT_DIR}/$@-device-plugin ./cmd/device-plugin/$@
make build 在 Windows 上会失败(部分代码有 _linux.go 后缀,
比如 )。
纯 Go 的编解码测试可以原生运行,但 scheduler / NVIDIA 包依赖 NVML 的
dlfcn.h,在原生 Windows 下也会编译失败。
请用 WSL2 或 Linux 运行本教程的完整构建和测试命令;读源码本身不受影响。
3. 仓库全景:代码量分布决定你的投入
拿到一个 28,000 行的项目,第一件事是搞清楚「重量」分布在哪。
非测试 Go 代码行数。点击条目看该目录的作用和对应章节。
pkg/device/ 有 11,447 行,看着吓人 —— 但其中 13 家厂商的后端高度重复,
共实现了 16 个 Fit()(部分厂商有两种逻辑设备),都遵守同一个 13 方法接口。
你只需要精读 NVIDIA 的主实现,外加一个最简单的(比如 kunlun,245 行)做对照,
其余后端按需扫读。真正需要逐行读的核心只有大约 5,000 行。
3 个可执行文件 + 1 个动态库,分布在 3 个位置
| 产物 | 部署形态 | 入口 | 职责 |
|---|---|---|---|
| scheduler | Deployment (集群里 1~N 个,选主) |
HTTP 服务,被 kube-scheduler 当作 extender 调用;同时跑 webhook 和节点注册循环 | |
| nvidia-device-plugin | DaemonSet (每个 GPU 节点一个) |
向 kubelet 注册虚拟设备;把调度决策翻译成容器环境变量 | |
| vGPUmonitor | DaemonSet (每个 GPU 节点一个) |
读容器共享内存里的用量,暴露 Prometheus 指标 | |
| libvgpu.so (C 动态库) |
挂进每个业务容器 | C 语言写的 CUDA/NVML 劫持库,真正执行显存和算力限制 |
4. 你的第一个实验:把测试当调试器
这是整套教程最重要的一招。HAMi 的核心函数都是纯函数, 项目自带的表驱动测试就是最好的「交互式解释器」。
实验 4.1:跑通注解编解码测试
# 跑一个最简单的:容器设备注解的编解码
go test ./pkg/device/ -run TestDecodeContainerDevices -v -count=1
你应该看到一串 --- PASS。现在打开这个测试文件看看它测了什么:
# 这个文件从第 49 行开始就是刚才跑的用例
sed -n '49,120p' pkg/device/devices_test.go
实验 4.2:故意改坏一个用例,观察失败信息
这一步不要跳过。理解一个函数最快的方式,是看它「什么时候不工作」。
-
打开
pkg/device/devices_test.go,找到TestDecodeContainerDevices里面有一堆{name: ..., args: ..., want: ...}结构的用例。 -
随便挑一个用例,把
want里的某个数字改掉 比如把期望的Usedmem: 1000改成1001。 -
重新跑测试
失败信息会告诉你实际解析出来的值是什么。这就是这个函数真实的行为。
go test ./pkg/device/ -run TestDecodeContainerDevices -v -count=1 -
改回去,确认恢复 PASS
git checkout pkg/device/devices_test.go一键还原。
读代码是「我猜它会这样」,跑测试是「它确实这样」。 HAMi 的 Fit 函数有十几个 continue 分支,光靠眼睛读, 你永远不确定某个条件到底会不会触发。改一个输入跑一次,2 秒就有答案。
实验 4.3:跑核心算法测试,感受一下规模
# 打分主流程(这个文件有 4600 行用例,是最完整的行为文档)
go test ./pkg/scheduler/ -run Test_calcScore -count=1
# NVIDIA 的 Fit 逐卡筛选
go test ./pkg/device/nvidia/ -run TestDevices_Fit -count=1
# 全项目单测 + 竞态检测(第一次会比较慢,几分钟)
go test ./pkg/... -short -race -count=1
-count=1
Go 会缓存测试结果。改了代码但测试「秒过」且输出 (cached),
说明它根本没重跑。养成永远带 -count=1 的习惯。
5. 读码三板斧
后面每一章遇到「这段代码怎么跑的」,都靠这三招。
第一斧:按签名搜索,30 秒定位
HAMi 命名很规整,接口方法名固定。想找「NVIDIA 的 Fit 在哪」:
# 找某个接口方法的所有实现(当前会列出 16 个 Fit 实现)
grep -rn ") Fit(" pkg/device/ --include=*.go | grep -v _test
# 找某个结构体的所有方法
grep -n "func (dev \*NvidiaGPUDevices)" pkg/device/nvidia/device.go
# 找某个注解 key 在哪定义、在哪用
grep -rn "hami.io/vgpu-node" --include=*.go .
第二斧:跟着测试读,而不是跟着 main 读
main.go 里全是 flag 解析和启动逻辑,信息密度很低。
真正的业务逻辑,测试文件读起来比源文件快得多,因为测试直接告诉你
「输入长这样,输出应该长这样」。
| 想搞懂什么 | 就读哪个测试 |
|---|---|
| 注解字符串怎么编解码 | |
| 整个打分流程 | (Test_calcScore) |
| 单卡到底怎么被筛掉的 | (TestDevices_Fit) |
| Webhook 改了 Pod 什么 | (Test_MutateAdmission) |
| 节点注解怎么解析成设备列表 | (Test_DecodeNodeDevices) |
第三斧:加日志,或者直接写个临时测试
HAMi 用 klog,日志分级。想看某个函数的详细过程,
不用改代码 —— 已有的 klog.V(5) 日志在测试里可以直接打开:
# -v=5 打开最详细的日志,能看到 Fit 里每张卡被拒的原因
go test ./pkg/device/nvidia/ -run TestDevices_Fit -count=1 -args -v=5 2>&1 | head -60
或者更直接 —— 在包里新建一个 scratch_test.go,自己造数据调用它:
package device
import "testing"
func TestScratch(t *testing.T) {
// 想搞懂 DecodeContainerDevices 对畸形输入怎么反应?直接问它
got, err := DecodeContainerDevices("GPU-abc,NVIDIA,3000,30:")
t.Logf("got=%+v err=%v", got, err)
got2, err2 := DecodeContainerDevices("坏数据没有逗号")
t.Logf("got2=%+v err2=%v", got2, err2)
}
go test ./pkg/device/ -run TestScratch -v -count=1
# 用完记得删:rm pkg/device/scratch_test.go
6. 本章自测
Fit() 到底为什么拒绝了某张卡,最快的办法是?TestDevices_Fit,改一个输入参数看输出怎么变Fit 是纯函数:给它一组 DeviceUsage 和一个请求,
它返回能不能放下 + 原因字符串。不需要集群,不需要 GPU,2 秒出结果。
A 太重,C 效率低(十几个 continue 分支光看很难确定哪个会触发),D 是补充手段。
pkg/device/devices.go(接口与数据结构)pkg/scheduler/score.go(打分主流程)pkg/device/nvidia/device.go(NVIDIA 后端)pkg/device/cambricon/、hygon/、metax/… 等其他厂商后端Devices 接口,结构高度雷同。
精读 nvidia 一个 + 扫一个最简单的(如 kunlun)做对照就够了,
剩下的等你真要改某家硬件时再看。
把时间花在 A、B、C 上,那才是主干。
7. 小结
- HAMi = Device Plugin(汇报每张卡的规格与余量)+ Scheduler Extender(选节点选卡,把结果写进注解)+ HAMi-core(在容器里拦住越界)。主链路靠注解字符串传话,NUMA refit 例外地会直接调用调度器。
- 真正需要精读的核心大约 5,000 行,不是 28,000 行。
- 核心逻辑全是纯函数,用单元测试当调试器,不需要集群不需要 GPU。
- 三板斧:按签名 grep 定位、跟着测试读、加日志或写临时测试验证。