news 2026/6/13 20:22:28

HAMi 源码阅读笔记 07:Resourcereqs 与 getNodesUsage 如何完成 Pod 需求和节点资源建模

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
HAMi 源码阅读笔记 07:Resourcereqs 与 getNodesUsage 如何完成 Pod 需求和节点资源建模

一、为什么要分析这两个方法

在 HAMi 的/filter调度流程中,真正进入节点算分之前,至少要先解决两个问题:

1. 当前 Pod 到底申请了什么设备资源? 2. 当前候选节点上还剩多少设备资源可以分配?

这两个问题分别由两个方法负责:

device.Resourcereqs(args.Pod) s.getNodesUsage(args.NodeNames, args.Pod)

Resourcereqs()负责从 Pod 的容器资源声明中解析 GPU/NPU/DCU/MLU 等设备请求;getNodesUsage()负责根据 HAMi 当前维护的节点设备信息和 Pod 分配缓存,构造一份“节点设备使用快照”。

HAMi 官方架构文档中说明,HAMi 由MutatingWebhookscheduler-extenderDevice-pluginHAMi-Core组成。其中 scheduler 负责把任务分配到合适的节点和设备,同时维护异构计算设备的全局视图;device-plugin 会从 Pod annotations 中获取调度结果,并把对应设备映射到容器中。本文分析的这两个方法,本质上就是 HAMi/filter阶段进入节点算分之前的两步准备工作:

Resourcereqs() 负责构造“需求侧数据”; getNodesUsage() 负责构造“供给侧数据”; calcScore() 再基于这两份数据进行匹配和算分。

二、为什么 Kubernetes 原生 Device Plugin 不够用

Kubernetes 官方 Device Plugin 机制允许厂商把 GPU、FPGA、高性能网卡等特殊硬件注册给 kubelet。设备插件注册成功后,kubelet 会把设备资源上报到 Node 状态中,Pod 就可以像申请普通资源一样申请这些扩展资源。但是 Kubernetes 原生 Device Plugin 模型在设备共享场景下有两个关键限制:

1. 扩展资源只支持整数资源,并且不能 overcommit。 2. 设备不能在多个 containers 之间共享。

这就是 HAMi 存在的价值。Kubernetes 原生写法通常是:

resources: limits: nvidia.com/gpu: 1

这表示申请 1 张完整 GPU。但在 AI 推理、开发测试、多租户场景中,很多任务并不需要独占整张卡。HAMi 在 Kubernetes 之上实现了更细粒度的 GPU 共享、调度和隔离。

例如 HAMi 中常见的资源声明类似这样:

resources: limits: nvidia.com/gpu: 1 nvidia.com/gpumem: 3000 nvidia.com/gpucores: 50

HAMi 官方配置文档中也说明,nvidia.com/gpu是 vGPU 数量资源名,nvidia.com/gpumem是 vGPU 显存大小资源名,nvidia.com/gpumem-percentage是显存比例资源名,nvidia.com/gpucores是 GPU core 资源名。

所以 HAMi scheduler 在/filter阶段不能只看 Kubernetes 原生的nvidia.com/gpu数量,还必须理解:

Pod 申请了几张设备; 每张设备要多少显存; 每张设备要多少算力; 是否指定 GPU UUID; 是否指定 GPU 类型; 是否使用 hami-core 模式; 是否使用 MIG 模式; 当前节点每张设备已经被哪些 Pod 使用; 当前设备 Usedmem / Usedcores 还剩多少。

这些信息,就是Resourcereqs()getNodesUsage()要准备的数据基础。


三、整体流程图:两个方法在 Filter 中的位置

Filter()里的核心调用顺序可以简化成下面这样:

resourceReqs := device.Resourcereqs(args.Pod) resourceReqTotal := 0 for _, n := range resourceReqs { for _, k := range n { resourceReqTotal += int(k.Nums) } } if resourceReqTotal == 0 { return &extenderv1.ExtenderFilterResult{ NodeNames: args.NodeNames, FailedNodes: nil, Error: "", }, nil } s.podManager.DelPod(args.Pod) nodeUsage, failedNodes, err := s.getNodesUsage(args.NodeNames, args.Pod) if err != nil { return nil, err } nodeScores, err := s.calcScore(nodeUsage, resourceReqs, args.Pod, failedNodes)

这段代码已经把主线说清楚了:

1. Resourcereqs() 先解析当前 Pod 的设备请求; 2. 如果 Pod 没有申请 HAMi 设备资源,就直接返回 kube-scheduler 传进来的候选节点; 3. 如果 Pod 确实申请了设备,就先删除当前 Pod 可能存在的旧缓存; 4. 然后调用 getNodesUsage() 重新计算候选节点的设备使用情况; 5. 最后交给 calcScore() 做节点和设备算分。

其中,Resourcereqs() 负责看 Pod 想要什么;getNodesUsage() 负责看节点还能给什么;calcScore() 负责把“需求”和“供给”放在一起做匹配。


四、Resourcereqs 方法解析:当前 Pod 申请了什么设备资源

Resourcereqs()的作用是从 Pod 中解析设备资源请求。方法位于pkg/device/devices.go,方法签名如下:

func Resourcereqs(pod *corev1.Pod) (counts PodDeviceRequests)

它返回的是PodDeviceRequests,可以理解成一个按容器下标组织的设备请求表。

4.1 先计算 initContainers + containers 的总数

Resourcereqs()先计算 initContainer 和普通 container 的总数:

totalContainers := len(pod.Spec.InitContainers) + len(pod.Spec.Containers) counts = make(PodDeviceRequests, totalContainers)

counts的长度是:initContainers 数量 + 普通 containers 数量。

可以这样理解counts的结构:

PodDeviceRequests ├── 第 0 个 initContainer 的设备请求 ├── 第 1 个 initContainer 的设备请求 ├── 第 N 个普通 container 的设备请求 └── ...

4.2 先遍历 initContainers

初始化counts之后,HAMi 会先处理pod.Spec.InitContainers。为了便于阅读,下面只保留关键逻辑:

for i := range pod.Spec.InitContainers { devices := GetDevices() counts[i] = make(ContainerDeviceRequests) for idx, val := range devices { request := val.GenerateResourceRequests(&pod.Spec.InitContainers[i]) if request.Nums > 0 { cnt += request.Nums counts[i][idx] = request } } }

这段代码要抓住三个点。

第一,for i := range pod.Spec.InitContainers表示先遍历所有 initContainer。

第二,counts[i] = make(ContainerDeviceRequests)表示给当前 initContainer 初始化一个设备请求 map。

第三,每个 initContainer 都会遍历一次GetDevices()返回的设备实现表,然后调用每种设备自己的GenerateResourceRequests()方法解析资源请求。

4.3 再遍历普通 containers

处理完 initContainers 后,代码会计算一个偏移量:

initContainerOffset := len(pod.Spec.InitContainers)

然后再处理普通容器:

for i := range pod.Spec.Containers { devices := GetDevices() counts[initContainerOffset+i] = make(ContainerDeviceRequests) for idx, val := range devices { request := val.GenerateResourceRequests(&pod.Spec.Containers[i]) if request.Nums > 0 { cnt += request.Nums counts[initContainerOffset+i][idx] = request } } }

这里为什么要加initContainerOffset

因为counts前面的位置已经留给了 initContainers。普通 container 的结果不能再从counts[0]开始写,否则就会覆盖 initContainer 的请求结果。

举个例子:

pod.Spec.InitContainers 有 2 个 pod.Spec.Containers 有 1 个 那么 counts 的下标应该是: counts[0] -> 第 0 个 initContainer counts[1] -> 第 1 个 initContainer counts[2] -> 第 0 个普通 container

所以普通容器写入时要用:

counts[initContainerOffset+i]

4.4 为什么要遍历 GetDevices()

这段代码是理解Resourcereqs()的关键:

devices := GetDevices() for idx, val := range devices { request := val.GenerateResourceRequests(&pod.Spec.Containers[i]) if request.Nums > 0 { cnt += request.Nums counts[initContainerOffset+i][idx] = request } }

GetDevices()返回的是 HAMi 当前注册的所有设备实现。可以粗略理解成:

DevicesMap ├── NVIDIA -> NVIDIA 设备实现 ├── Ascend -> Ascend 设备实现 ├── MLU -> MLU 设备实现 ├── DCU -> DCU 设备实现 └── ...

所以Resourcereqs()本身并不直接写死:

nvidia.com/gpu nvidia.com/gpumem nvidia.com/gpucores

它只是遍历所有设备实现,然后调用:

request := val.GenerateResourceRequests(&pod.Spec.Containers[i])

也就是说,HAMi 把“如何解析某种设备资源字段”的逻辑交给了具体设备实现。NVIDIA 设备实现知道怎么解析 NVIDIA 相关资源;Ascend 设备实现知道怎么解析 Ascend NPU 相关资源;其他设备类型也可以通过实现同样的接口接入进来。所以这里可以总结成一句话:

Resourcereqs() 不关心每种设备资源字段怎么解析; 它只负责遍历所有设备实现,让每种设备自己解析当前容器有没有申请自己的资源。

4.5 request.Nums > 0 才会写入 counts

设备实现解析完当前容器后,会返回一个ContainerDeviceRequest。代码会判断:

if request.Nums > 0 { cnt += request.Nums counts[initContainerOffset+i][idx] = request }

request.Nums > 0表示当前容器确实申请了这种设备。如果没有申请,就不会写入counts。这样可以避免counts中塞入大量空请求。比如某个容器只申请了 NVIDIA GPU,没有申请 Ascend NPU,那么遍历 Ascend 设备实现时,request.Nums就应该是 0,这种请求不会写入结果。

4.6 cnt 只是用于日志判断

方法里还有一个变量:

cnt := int32(0)

每解析到一个有效设备请求,就执行:

cnt += request.Nums

最后根据cnt打印日志:

if cnt == 0 { klog.V(4).InfoS("No device requests found", "pod", klog.KObj(pod)) } else { klog.V(4).InfoS("Resource requirements collected", "pod", klog.KObj(pod), "requests", counts) }

这里要注意:cntResourcereqs()内部主要用于日志判断。真正决定Filter()是否继续走 HAMi 设备调度的,是Filter()里重新统计出来的resourceReqTotal

也就是说:

Resourcereqs() 只负责解析; Filter() 负责判断是否继续调度。

五、getNodesUsage 方法解析:构造候选节点设备使用快照

getNodesUsage()的作用是构造节点设备使用快照。它不是直接读取 Kubernetes Node 上的原生资源余量,而是结合 HAMi 自己维护的节点设备缓存和 Pod 分配缓存,重新计算每个设备当前已经被用了多少。

方法签名如下:

func (s *Scheduler) getNodesUsage( nodes *[]string, task *corev1.Pod, ) (*map[string]*NodeUsage, map[string]string, error)

从返回值可以看出,它返回三类信息:

*map[string]*NodeUsage 本次候选节点的设备使用快照。 map[string]string failedNodes,记录候选节点失败原因。 error 方法执行过程中的系统级错误。

5.1 先创建三个 map

方法一开始会创建三个 map:

overallnodeMap := make(map[string]*NodeUsage) cachenodeMap := make(map[string]*NodeUsage) failedNodes := make(map[string]string)

这三个 map 的含义是:

overallnodeMap: HAMi 当前已知的所有设备节点使用快照。 cachenodeMap: 本次 kube-scheduler 传入候选节点范围内的设备使用快照。 failedNodes: 记录本次候选节点中不可用的节点以及失败原因。

这一步很关键,因为getNodesUsage()并不是只算一个节点,而是先构造 HAMi 当前视角下的全局设备使用情况,然后再从里面筛出本次调度涉及的候选节点。

5.2 通过 ListNodes 获取 HAMi 已注册节点

接下来会调用:

allNodes, err := s.ListNodes() if err != nil { return &overallnodeMap, failedNodes, err }

这里的allNodes可以理解成 HAMi scheduler 当前已经维护的节点设备信息。它不是单纯的 Kubernetes Node 列表,而是包含了 HAMi 识别到的设备信息,例如设备 ID、显存、算力、类型、模式、健康状态等。getNodesUsage()后续构造的NodeUsage,是基于 HAMi 自己维护的节点设备信息构造出来的。

5.3 初始化每个节点的 DeviceUsage

拿到allNodes之后,方法会遍历每个节点,并创建NodeUsage

for _, node := range allNodes { nodeInfo := &NodeUsage{} userGPUPolicy := util.GetGPUSchedulerPolicyByPod(device.GPUSchedulerPolicy, task) nodeInfo.Node = node.Node nodeInfo.Devices = policy.DeviceUsageList{ Policy: userGPUPolicy, DeviceLists: make([]*policy.DeviceListsScore, 0), } ... }

这里有两个重点。

第一,userGPUPolicy表示当前 Pod 使用的 GPU 调度策略。它可能来自默认配置,也可能来自 Pod annotation。HAMi 官方配置文档中说明,hami.io/gpu-scheduler-policy可以设置为binpackspread,分别表示尽量分配到同一张 GPU 或分散到不同 GPU。

第二,nodeInfo.Devices是调度时使用的设备列表,它不是原始DeviceInfo,而是要构造成带有使用量字段的DeviceUsage

继续往下看,代码会把节点上的每个设备转换成DeviceUsage。下面是关键字段:

Device: &device.DeviceUsage{ ID: d.ID, Index: d.Index, Used: 0, Count: d.Count, Usedmem: 0, Totalmem: d.Devmem, Totalcore: d.Devcore, Usedcores: 0, MigUsage: device.MigInUse{ Index: 0, UsageList: make(device.MIGS, 0), }, MigTemplate: d.MIGTemplate, Mode: d.Mode, Type: d.Type, Numa: d.Numa, Health: d.Health, PodInfos: make([]*device.PodInfo, 0), CustomInfo: maps.Clone(d.CustomInfo), }

这段源码说明,getNodesUsage()初始化设备使用快照时,会把动态使用量都设置成 0:

Used = 0 Usedmem = 0 Usedcores = 0 PodInfos = 空列表 MigUsage = 空列表

然后后面再根据已经分配过设备的 Pod,把这些字段累加起来。

5.4 通过 podManager.ListPodsInfo 读取已分配 Pod

初始化所有节点设备后,方法会读取 HAMi 本地 Pod 分配缓存:

podsInfo := s.podManager.ListPodsInfo()

podManager可以理解为 HAMi scheduler 内部维护的一本设备分配账本。它记录了哪些 Pod 已经被分配到了哪个节点、使用了哪些设备、每个设备使用了多少显存和算力。

接下来,getNodesUsage()会遍历这些已经分配过设备的 Pod:

for _, p := range podsInfo { node, ok := overallnodeMap[p.NodeID] if !ok { klog.V(5).InfoS("pod allocated unknown node resources", "pod", klog.KRef(p.Namespace, p.Name), "nodeID", p.NodeID) continue } ... }

这段代码的含义是:

如果某个 PodInfo 记录的 NodeID 在 overallnodeMap 里找不到, 说明这个 Pod 的旧分配记录指向了一个 HAMi 当前不认识的节点, 这种记录不能参与本次资源累计,所以直接跳过。

5.5 根据 UUID 匹配物理设备并累加使用量

找到 Pod 所在节点后,代码会遍历这个 Pod 的设备分配记录,并和当前节点上的设备列表进行匹配:

for _, podsingleds := range p.Devices { for _, ctrdevs := range podsingleds { for _, udevice := range ctrdevs { for _, d := range node.Devices.DeviceLists { deviceID := udevice.UUID if strings.Contains(deviceID, "[") { deviceID = strings.Split(deviceID, "[")[0] } if d.Device.ID == deviceID { d.Device.Used++ d.Device.Usedmem += udevice.Usedmem d.Device.Usedcores += udevice.Usedcores d.Device.PodInfos = append(d.Device.PodInfos, p) } } } } }

这段代码看起来循环很多,但目标只有一个:

找到已分配 Pod 使用的设备 UUID, 对应当前节点账本中的哪一张设备, 然后把这份占用累加到那张设备上。

匹配成功后,会累加四类信息:

Used: 当前设备被分配的设备实例次数。 Usedmem: 当前设备已经被占用的显存。 Usedcores: 当前设备已经被占用的算力比例。 PodInfos: 当前设备上已经有哪些 Pod 在使用。

所以getNodesUsage()最终得到的不是简单的节点设备总量,而是类似下面这样的使用视图:

node-1: GPU-0: Totalmem: 24576 Usedmem: 12000 Totalcore: 100 Usedcores: 50 Used: 2 PodInfos: - pod-a - pod-b GPU-1: Totalmem: 24576 Usedmem: 0 Totalcore: 100 Usedcores: 0 Used: 0

这份视图后面会传给calcScore(),用于判断当前 Pod 能不能放到某个节点、某张设备上。

5.6 MIG UUID 的特殊处理

上面的代码里有一段:

deviceID := udevice.UUID if strings.Contains(deviceID, "[") { deviceID = strings.Split(deviceID, "[")[0] }

这说明 HAMi 要兼容 MIG 场景。

如果udevice.UUID里包含[,可以理解成它带有 MIG 实例信息。匹配物理设备时,HAMi 会先把[后面的 MIG 实例部分去掉,只保留物理 GPU UUID。

例如:

GPU-xxxx[0-1]

匹配物理设备时会先变成:

GPU-xxxx

这样做的原因是:

MIG 实例属于某张物理 GPU; 统计物理 GPU 使用量时,需要先定位到它所属的物理设备。

匹配到物理设备之后,如果原始udevice.UUID确实包含[,代码还会继续维护 MIG 使用信息:

if strings.Contains(udevice.UUID, "[") { if strings.Compare(d.Device.Mode, "hami-core") == 0 { d.Device.Health = false continue } tmpIdx, Instance, _ := device.ExtractMigTemplatesFromUUID(udevice.UUID) if len(d.Device.MigUsage.UsageList) == 0 { device.PlatternMIG(&d.Device.MigUsage, d.Device.MigTemplate, tmpIdx) } d.Device.MigUsage.UsageList[Instance].InUse = true }

这段逻辑可以分成四步:

1. 如果 MIG 任务出现在 hami-core 模式设备上,说明状态异常,标记 Health=false。 2. 通过 ExtractMigTemplatesFromUUID 解析 MIG 模板编号和实例编号。 3. 如果当前设备还没有初始化 MigUsage,就调用 PlatternMIG 初始化。 4. 把对应 MIG 实例标记为 InUse=true。

所以getNodesUsage()不只是统计整张物理卡的UsedmemUsedcores,还会维护 MIG 实例级别的使用情况。

5.7 最后只返回本次候选节点的 cachenodeMap

所有已分配 Pod 的资源累计完成后,代码会先更新全局视图:

s.overviewstatus = overallnodeMap

然后再遍历本次 kube-scheduler 传入的候选节点:

for _, nodeID := range *nodes { node, err := s.GetNode(nodeID) if err != nil { failedNodes[nodeID] = "node unregistered" continue } cachenodeMap[node.ID] = overallnodeMap[node.ID] } s.cachedstatus = cachenodeMap return &cachenodeMap, failedNodes, nil

这里的nodes来自 kube-scheduler extender 请求中的NodeNames。所以getNodesUsage()并不是把所有 HAMi 节点都返回给当前调度流程,而是只返回 kube-scheduler 本次传入的候选节点。

如果某个候选节点在 HAMi 本地缓存中找不到,就记录:

failedNodes[nodeID] = "node unregistered"

这通常表示:

该节点虽然被 kube-scheduler 作为候选节点传给了 HAMi, 但是 HAMi 本地没有这个节点的设备注册信息, 所以这个节点不能作为 HAMi 设备调度节点使用。

5.8 getNodesUsage 方法流程图

getNodesUsage()可以总结成一句话:

先根据 HAMi 已注册节点初始化所有设备的使用量, 再根据 podManager 中已经分配过的 Pod 记录累计 Used、Usedmem、Usedcores, 最后只返回本次调度候选节点对应的设备使用快照。

六、最终总结

Resourcereqs()getNodesUsage()是 HAMi/filter调度流程中非常基础但非常关键的两个方法。Resourcereqs()解决的是:

这个 Pod 的每个容器分别申请了哪些设备资源?

它通过遍历 Pod 的initContainers和普通containers,再遍历所有已注册设备类型,让不同设备插件自己解析资源请求,从而实现 NVIDIA、Ascend、MLU、DCU 等设备的统一接入。

getNodesUsage()解决的是:

候选节点上的每张设备当前已经被用了多少?

它先初始化所有 HAMi 已注册节点的设备使用快照,再根据podManager中已有的 Pod 分配记录累计UsedUsedmemUsedcoresPodInfos,最终得到本次调度候选节点的实时设备使用视图。

这两个方法可以用一句话串起来:

Resourcereqs 看 Pod 想要什么, getNodesUsage 看节点还能给什么。

后续的calcScore()fitInDevices()DeviceListsScore.ComputeScore(),本质上就是基于这两份数据继续完成设备匹配和节点算分。

本人运维小白,欢迎各位大佬批评指正。


版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/6/13 20:18:07

IMO是谁?凭什么管全球航运?一篇读懂航运“总舵主”

IMO是谁?凭什么管全球航运?一篇读懂航运“总舵主” 一、谁是IMO?全球航运的“联合国” 国际海事组织(International Maritime Organization, IMO)是联合国下属的一个专门机构,堪称全球航运业的“总舵主”。…

作者头像 李华
网站建设 2026/6/13 19:51:51

用CppAD+IPOPT搞定一个简单的机器人轨迹优化问题:从建模到求解的完整C++示例

用CppADIPOPT实现机器人轨迹优化的工程实践指南在机器人运动规划领域,轨迹优化问题通常需要考虑动力学约束、障碍物避碰以及能量消耗等多重因素。传统解析方法往往难以处理这类复杂的非线性问题,而数值优化技术则展现出强大优势。本文将完整展示如何利用…

作者头像 李华
网站建设 2026/6/13 20:08:17

Qt 5.12.6 在 Windows 10 上安装,为什么我建议你选 MinGW 而不是 MSVC?

Qt 5.12.6 在 Windows 10 上的编译器选择:MinGW 还是 MSVC?当你第一次在 Windows 10 上安装 Qt 5.12.6 时,面对安装向导中 MinGW 和 MSVC 这两个编译器选项,可能会感到困惑。这两个选项背后代表着不同的工具链和开发哲学&#xff…

作者头像 李华
网站建设 2026/6/13 19:45:56

RStudio里cat()和sink()用哪个?数据科学新手必看的文件输出避坑指南

RStudio文件输出实战:如何优雅选择cat()与sink()函数在数据科学项目中,将分析结果可靠地保存到文件是每个R语言使用者必须掌握的核心技能。RStudio环境提供了多种输出方式,其中cat()和sink()是最常用的两种文本输出函数。新手常会困惑&#x…

作者头像 李华
网站建设 2026/6/13 19:39:10

雾语纪元:当城市在晨昏线学会用沉默交谈

2069年惊蛰,黎明前最暗的时刻,一场罕见的平流雾笼罩城市。能见度降至三米,交通信号完全失效,所有摄像头形同虚设。但城市没有瘫痪——相反,在这一小时十七分钟里,交通事故率为零。在看不见彼此的浓雾中&…

作者头像 李华
网站建设 2026/6/13 15:16:39

小白写医学综述第五步:正文撰写 —— 把提纲变成一篇能发表的文章

框架搭好了,文献笔记也分配到了每个标题下。现在你要做的就是把每个小节”填满”。这是最耗时的一步,也是最能体现你写作功力的一步。一、写作的核心原则:不是”罗列文献”,而是”用文献讲道理”先看一段差的写法Smith et al. (20…

作者头像 李华