第 00 章 · 第一阶段 打地基

开始之前:环境、全景与读码方法

别急着打开 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 你需要什么

工具版本说明
Go1.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/$@
Windows 用户注意

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:故意改坏一个用例,观察失败信息

这一步不要跳过。理解一个函数最快的方式,是看它「什么时候不工作」。

  1. 打开 pkg/device/devices_test.go,找到 TestDecodeContainerDevices 里面有一堆 {name: ..., args: ..., want: ...} 结构的用例。
  2. 随便挑一个用例,把 want 里的某个数字改掉 比如把期望的 Usedmem: 1000 改成 1001。
  3. 重新跑测试
    go test ./pkg/device/ -run TestDecodeContainerDevices -v -count=1
    失败信息会告诉你实际解析出来的值是什么。这就是这个函数真实的行为。
  4. 改回去,确认恢复 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. 本章自测

HAMi 的三个进程之间,主要通过什么传递信息?
AgRPC 直连
B共享一个 etcd 前缀,自己读写
CKubernetes 上 Pod 和 Node 的注解字符串
D宿主机上的 Unix socket
调度器把决策写进 Pod 注解,设备插件把节点设备清单写进 Node 注解, 双方都只跟 kube-apiserver 打交道,互不直连。 这个设计让每个组件都可以独立重启而不丢状态 —— 但也意味着注解格式就是它们的 API, 改格式是破坏性变更。下一章专门讲这个。 (设备插件确实用 gRPC,但那是跟 kubelet 通信,不是跟调度器。)
想搞懂 Fit() 到底为什么拒绝了某张卡,最快的办法是?
A部署一套带 GPU 的 K8s 集群,提交 Pod 看 event
B跑 TestDevices_Fit,改一个输入参数看输出怎么变
C把 device.go 从头到尾读三遍
D在 GitHub 上搜 issue
Fit 是纯函数:给它一组 DeviceUsage 和一个请求, 它返回能不能放下 + 原因字符串。不需要集群,不需要 GPU,2 秒出结果。 A 太重,C 效率低(十几个 continue 分支光看很难确定哪个会触发),D 是补充手段。
下面哪个目录不值得在第一遍学习时逐行精读?
Apkg/device/devices.go(接口与数据结构)
Bpkg/scheduler/score.go(打分主流程)
Cpkg/device/nvidia/device.go(NVIDIA 后端)
Dpkg/device/cambricon/、hygon/、metax/… 等其他厂商后端
这些后端都实现同一套 Devices 接口,结构高度雷同。 精读 nvidia 一个 + 扫一个最简单的(如 kunlun)做对照就够了, 剩下的等你真要改某家硬件时再看。 把时间花在 A、B、C 上,那才是主干。

7. 小结

既然三个进程靠字符串通信,那就先把这串字符串弄清楚。 下一章你会亲手在浏览器里编解码 HAMi 的注解,看懂之后回头再读代码,会有种「原来如此」的感觉。