1. 从一块 RK3588 开发板说起:为什么 NPU 在 K8s 里成了"二等公民"
手里有一块 RK3588 开发板,6 TOPS 算力的 NPU 摆在那儿,跑 YOLOv8 推理能到几十帧,功耗还低得感人。但当你试图把它塞进一个正经的 K8s 集群里做推理服务时,问题就来了——kubectl describe node里看不到任何 NPU 资源,Pod 调度上去之后要么抢不到设备,要么多个 Pod 同时往同一个 NPU 上怼,推理延迟直接爆炸。
这不是 RK3588 独有的问题。GPU 有 NVIDIA 官方的 device plugin 撑腰,昇腾有华为自己的调度方案,而 RK3588 的 NPU(准确说是 RKNPU2,也就是瑞芯微第二代 NPU 驱动栈)在 K8s 生态里基本处于"官方没管"的状态。你翻遍 Rockchip 的文档,能找到的是librknnrt.so怎么调、rknn_server怎么起,但找不到"怎么让 K8s 知道这块板子上有几个 NPU、每个 NPU 被谁占着"。
我最初的需求很朴素:一个三节点的 RK3588 集群,每个节点挂一块板子,跑多个 YOLOv8 推理 Pod,要求每个 Pod 独占一个 NPU 核心(RK3588 的 NPU 支持三核独立调度),并且能被 Prometheus 监控到利用率。听起来不复杂,但实际做下来,从设备发现、资源上报、调度绑定到监控采集,整条链路都得自己补。
这篇文章就是把这套东西从头到尾讲清楚。适合两类人看:一是手里有 RK3588 板子、想把 NPU 用起来的嵌入式/边缘计算开发者;二是对 K8s device plugin 机制感兴趣、想自己写一个自定义设备插件的后端工程师。不需要你精通 K8s 源码,但至少要能看懂 YAML 和 Go 代码。
2. RK3588 NPU 的底层能力边界:先搞清楚它能被"切"成什么样
在动手写调度之前,必须先摸清楚 RK3588 NPU 到底提供了什么样的资源抽象能力。这决定了你在 K8s 里能把它声明成什么粒度的资源。
2.1 三核独立调度是核心前提
RK3588 的 NPU 是 3 核架构,每个核心可以独立执行推理任务。这一点非常关键——如果 NPU 只能整体被一个进程独占,那 K8s 调度的意义就大打折扣,因为一块板子只能跑一个推理 Pod。但三核独立意味着你可以把它抽象成rockchip.com/npu: 3这样的可数资源,每个 Pod 申请 1 个,三个 Pod 各占一核,互不干扰。
实际验证下来,三核并行的吞吐量大约是单核的 2.6 到 2.8 倍,不是线性的 3 倍,原因在于共享内存带宽和 L2 cache 的竞争。但这个损耗在边缘场景下完全可以接受。
2.2 RKNPU2 驱动栈暴露的接口
RK3588 的 NPU 驱动栈分几层:内核层的rknpu驱动、用户态的librknnrt.so运行时、以及上层的rknn_server。对于 K8s device plugin 来说,我们真正关心的是用户态能拿到什么信息。
/dev/rknpu是主设备节点,但更细粒度的信息在 sysfs 里。你可以通过以下路径读取 NPU 的状态:
# 查看 NPU 设备节点 ls -l /dev/rknpu* # 查看 NPU 核心数和频率 cat /sys/class/devfreq/fdab0000.npu/available_frequencies cat /sys/kernel/debug/rknpu/load/sys/kernel/debug/rknpu/load这个文件会输出每个核心的当前负载百分比,格式类似NPU load: Core0: 45%, Core1: 0%, Core2: 12%。这个信息后面做监控的时候会用到。
注意:debugfs 默认可能需要 root 权限挂载,在容器化环境里要提前处理好权限映射,否则 device plugin 的 DaemonSet 读不到负载数据。
2.3 为什么不能简单用 GPU 那套方案套
有人可能会想,NVIDIA 的 device plugin 不是现成的吗,改改就能用?实际上差距很大。NVIDIA GPU 有nvidia-smi这样的统一查询接口,有 MIG(Multi-Instance GPU)做硬件级切分,有完整的 CUDA 生态做隔离。RK3588 的 NPU 没有这些——没有硬件级隔离机制,没有统一的管理 CLI,甚至连"当前哪个进程占着哪个核心"这种信息都得自己从/proc里扒。
所以我们的 device plugin 不能照抄 NVIDIA 的实现,得走一条更"土"但更贴合 RK3588 实际的路子:用文件锁做核心分配,用 sysfs 做状态采集,用环境变量做容器内透传。
3. 手写一个 RK3588 NPU Device Plugin:从设备发现到 gRPC 注册
K8s 的 device plugin 机制本质上是一个 gRPC 服务,kubelet 通过它来发现、分配、释放设备。整个交互流程可以简化为:插件启动后向 kubelet 注册自己,kubelet 定期调用ListAndWatch获取设备列表,Pod 调度时 kubelet 调用Allocate完成设备分配。
3.1 设备发现的实现逻辑
设备发现的核心是枚举当前节点上可用的 NPU 核心。在 RK3588 上,我们通过读取/sys/kernel/debug/rknpu/load的行数来判断核心数量,同时结合/dev/rknpu是否存在来确认驱动已加载。
package main import ( "bufio" "os" "strings" ) const ( npuLoadPath = "/sys/kernel/debug/rknpu/load" npuDevPath = "/dev/rknpu" ) func discoverNPUCores() (int, error) { if _, err := os.Stat(npuDevPath); os.IsNotExist(err) { return 0, nil } f, err := os.Open(npuLoadPath) if err != nil { return 0, err } defer f.Close() cores := 0 scanner := bufio.NewScanner(f) for scanner.Scan() { line := scanner.Text() if strings.Contains(line, "Core") { cores++ } } return cores, scanner.Err() }这段代码看起来简单,但有个坑:/sys/kernel/debug/rknpu/load在 NPU 空闲时可能只显示已激活的核心,而不是全部核心。更稳妥的做法是直接读设备树或者用rknn_queryAPI 查询。我在实际项目里用的是混合策略——优先读 debugfs,读不到就 fallback 到固定值 3(RK3588 的 NPU 核心数是硬件固定的)。
3.2 gRPC 服务的注册与 ListAndWatch
Device plugin 的 gRPC 服务需要实现Register、ListAndWatch、Allocate三个核心方法。注册阶段,插件通过 Unix socket 向 kubelet 的 device plugin manager 报到:
func (p *NPUPlugin) Register() error { conn, err := grpc.Dial( pluginapi.DevicePluginPath+"kubelet.sock", grpc.WithInsecure(), grpc.WithDialer(func(addr string, timeout time.Duration) (net.Conn, error) { return net.DialTimeout("unix", addr, timeout) }), ) if err != nil { return err } defer conn.Close() client := pluginapi.NewRegistrationClient(conn) _, err = client.Register(context.Background(), &pluginapi.RegisterRequest{ Version: pluginapi.Version, Endpoint: "npu-plugin.sock", ResourceName: "rockchip.com/npu", }) return err }ListAndWatch是一个流式 RPC,kubelet 会持续接收设备列表的更新。对于 NPU 这种静态设备,我们只需要在启动时发送一次完整列表,之后保持流打开即可。设备 ID 的命名我用的是npu-core-0、npu-core-1、npu-core-2这种格式,直观且便于排查。
3.3 Allocate 阶段的设备绑定与环境变量注入
Allocate是真正干活的地方。当 kubelet 决定把某个 NPU 核心分配给某个 Pod 时,它会调用这个方法,插件需要返回容器启动所需的设备节点、环境变量和挂载信息。
func (p *NPUPlugin) Allocate(ctx context.Context, req *pluginapi.AllocateRequest) (*pluginapi.AllocateResponse, error) { resp := &pluginapi.AllocateResponse{} for _, r := range req.ContainerRequests { cresp := &pluginapi.ContainerAllocateResponse{ Devices: []*pluginapi.DeviceSpec{ { ContainerPath: "/dev/rknpu", HostPath: "/dev/rknpu", Permissions: "rwm", }, }, Envs: map[string]string{ "RKNN_NPU_CORE": r.DevicesIDs[0], }, Mounts: []*pluginapi.Mount{ { ContainerPath: "/sys/kernel/debug/rknpu", HostPath: "/sys/kernel/debug/rknpu", ReadOnly: true, }, }, } resp.ContainerResponses = append(resp.ContainerResponses, cresp) } return resp, nil }这里有个关键设计决策:我把分配到的核心 ID 通过RKNN_NPU_CORE环境变量注入容器。容器内的推理程序读取这个变量,调用rknn_set_core_mask来绑定到指定核心。这样做的原因是 RK3588 的 NPU 驱动本身不提供核心级的设备节点隔离,所有核心共享/dev/rknpu,必须靠用户态 API 来做绑定。
提示:
rknn_set_core_mask的调用必须在rknn_init之前完成,否则不生效。这个顺序问题我踩过一次坑,调试了半天才发现是初始化顺序反了。
4. 调度链路打通之后:那些官方文档不会告诉你的坑
Device plugin 写完了,kubectl describe node里也能看到rockchip.com/npu: 3了,Pod 也能正常调度了。但真正跑起来之后,问题才一个接一个冒出来。
4.1 核心绑定的竞态问题
第一个坑是竞态。Device plugin 的Allocate返回设备 ID 之后,kubelet 会启动容器。但容器内的程序什么时候真正调用rknn_set_core_mask绑定核心,device plugin 是不知道的。如果两个 Pod 几乎同时启动,都拿到了不同的核心 ID,但其中一个 Pod 的推理程序启动慢了,另一个 Pod 的程序可能已经抢占了它的核心。
这个问题的根因在于:RK3588 的 NPU 驱动没有硬件级的核心隔离,rknn_set_core_mask只是一个"建议",如果目标核心正忙,驱动可能会 fallback 到其他核心。我的解决方案是在 device plugin 层面加一个文件锁——每个核心对应一个 lock 文件,Allocate时先抢锁,容器退出时释放。虽然不够优雅,但在实际生产环境里跑了大半年,没再出现过核心冲突。
# 核心锁文件目录 /var/lib/rknn-npu-plugin/locks/ ├── core-0.lock ├── core-1.lock └── core-2.lock4.2 容器内权限与设备节点的映射
第二个坑是权限。/dev/rknpu在宿主机上的权限通常是root:root 660,容器内如果以非 root 用户运行,根本打不开这个设备节点。K8s 的 device plugin 虽然会帮你把设备节点映射进容器,但不会自动改权限。
我的做法是在 device plugin 的 DaemonSet 里加一个 initContainer,提前把/dev/rknpu的权限改成666,同时把/sys/kernel/debug/rknpu的挂载权限也放开。这不是最安全的做法,但在边缘计算场景下,节点本身的可信度较高,用权限换便利是可以接受的。
initContainers: - name: fix-permissions image: busybox command: ["sh", "-c", "chmod 666 /dev/rknpu && chmod -R 755 /sys/kernel/debug/rknpu"] securityContext: privileged: true volumeMounts: - name: dev-npu mountPath: /dev/rknpu - name: debug-rknpu mountPath: /sys/kernel/debug/rknpu4.3 多容器共享 NPU 的隔离缺失
第三个坑更隐蔽:如果一个 Pod 里有多个容器,或者一个容器里起了多个推理进程,它们会共享同一个 NPU 核心。RK3588 的驱动没有进程级隔离,多个进程同时往一个核心提交推理任务时,驱动会串行化处理,导致延迟飙升。
这个问题在 K8s 层面很难彻底解决,因为 device plugin 的分配粒度是 Pod 级别的。我的建议是在应用层做限制——每个 Pod 只跑一个推理进程,如果需要多模型并行,就起多个 Pod,每个 Pod 申请一个 NPU 核心。这样虽然 Pod 数量多了,但隔离性有保障。
5. 让 NPU 利用率可见:Prometheus 采集与 Grafana 面板搭建
调度跑通了,隔离也做了,接下来最实际的需求就是监控。没有监控的 NPU 调度就是盲人摸象——你不知道哪个核心在忙、哪个在闲,也不知道 Pod 的实际推理延迟是多少。
5.1 从 sysfs 到 Prometheus 指标
RK3588 的 NPU 负载信息在/sys/kernel/debug/rknpu/load里,格式是纯文本。我们需要一个 exporter 把它转成 Prometheus 能抓取的格式。我写了一个轻量的 Go exporter,每 5 秒读一次负载文件,解析出每个核心的利用率:
func parseNPULoad(path string) ([]float64, error) { f, err := os.Open(path) if err != nil { return nil, err } defer f.Close() var loads []float64 scanner := bufio.NewScanner(f) for scanner.Scan() { line := scanner.Text() // 格式: "NPU load: Core0: 45%, Core1: 0%, Core2: 12%" re := regexp.MustCompile(`Core(\d+):\s+(\d+)%`) matches := re.FindAllStringSubmatch(line, -1) for _, m := range matches { val, _ := strconv.ParseFloat(m[2], 64) loads = append(loads, val) } } return loads, nil }Exporter 暴露的指标包括rknn_npu_core_utilization(每个核心的利用率)、rknn_npu_temperature(NPU 温度,从 thermal zone 读)、rknn_npu_frequency(当前频率)。这三个指标基本能覆盖日常运维需求。
5.2 Grafana 面板的关键查询与告警规则
Grafana 面板我配了三个核心图表:核心利用率时序图、温度趋势图、以及按 Pod 维度的 NPU 使用时长统计。其中最有价值的是按 Pod 维度的统计,它能告诉你哪个推理服务在"偷跑"——明明申请了 1 个核心,实际却占用了超过 1 个核心的算力。
Prometheus 告警规则我设了两条:一是核心利用率持续 5 分钟超过 90%,说明该扩容了;二是 NPU 温度超过 85 度,说明散热有问题,需要降频或加风扇。
groups: - name: npu-alerts rules: - alert: NPUCoreHighUtilization expr: rknn_npu_core_utilization > 90 for: 5m labels: severity: warning annotations: summary: "NPU core {{ $labels.core }} utilization above 90% for 5 minutes" - alert: NPUOverTemperature expr: rknn_npu_temperature > 85 for: 2m labels: severity: critical annotations: summary: "NPU temperature above 85°C, thermal throttling may occur"5.3 监控数据反哺调度策略
监控跑起来之后,我发现了一个有意思的现象:YOLOv8 推理的 NPU 利用率并不是越高越好。当核心利用率超过 85% 时,推理延迟会非线性增长,因为驱动内部的队列开始堆积。基于这个观察,我把调度策略从"尽量填满"改成了"保留 15% 余量",虽然整体吞吐量略降,但 P99 延迟稳定了很多。
这个经验说明,NPU 调度不能只看"有没有空闲核心",还要看"核心忙到什么程度"。后续如果要做更精细的调度,可以考虑在 device plugin 里暴露核心的实时负载,让 scheduler 做负载感知调度。不过这需要改 K8s 的 scheduler framework,复杂度较高,目前还没动手。
6. 踩过的坑与实测有效的排查手法
这套方案从原型到稳定运行,前后折腾了大概两个月。下面这几个坑是印象最深的,也是我觉得最有分享价值的。
6.1 NPU 驱动版本与内核版本的匹配问题
RK3588 的 NPU 驱动对内核版本很敏感。我最初用的是 Rockchip 官方 BSP 里的 5.10 内核,NPU 驱动工作正常。后来为了用一些新特性,升级到了 6.1 内核,结果 NPU 直接不识别了。查了半天发现是rknpu驱动没有跟着更新,6.1 内核的设备树里 NPU 节点的 compatible 字符串变了,旧驱动匹配不上。
解决办法是从 Rockchip 的 GitHub 仓库拉最新的rknpu驱动源码,重新编译内核模块。这里有个细节:编译时要确保CONFIG_ROCKCHIP_RKNPU是m而不是y,否则驱动会编进内核镜像,后续更新驱动就得重新烧写整个内核。
6.2 Device plugin 的 socket 文件残留
Device plugin 的 DaemonSet 重启时,如果旧的 Unix socket 文件没有清理干净,新的插件实例会注册失败,kubelet 日志里会报failed to register device plugin。这个问题的排查链路是:
- 先看 kubelet 日志:
journalctl -u kubelet | grep -i "device plugin" - 再看插件 Pod 日志:
kubectl logs -n kube-system <npu-plugin-pod> - 最后检查宿主机上的 socket 文件:
ls -l /var/lib/kubelet/device-plugins/
如果发现npu-plugin.sock还在但对应的进程已经没了,手动删掉再重启 DaemonSet 即可。更稳妥的做法是在 DaemonSet 的preStophook 里加清理逻辑。
6.3 推理容器 OOM 与 NPU 内存的关系
RK3588 的 NPU 和 CPU 共享内存,NPU 推理时占用的内存会计入容器的 memory cgroup。如果容器的 memory limit 设得太紧,NPU 推理过程中会触发 OOM Kill。这个坑很隐蔽,因为从容器内部看,内存使用量并不高,但 NPU 的 DMA 缓冲区是算在容器头上的。
我的经验是:给推理容器设置 memory limit 时,要在模型实际内存占用的基础上至少加 512MB 的余量。YOLOv8n 的模型本身只有 6MB 左右,但推理时的中间张量和 DMA 缓冲区加起来可能超过 300MB。
6.4 多节点集群的 NPU 资源不一致
在三节点集群里,我遇到过一个问题:某个节点的 NPU 因为散热问题降频了,但 K8s 的调度器并不知道,仍然按正常算力来分配 Pod。结果就是那个节点上的推理延迟明显高于其他节点。
这个问题的根本原因是 device plugin 上报的资源是"数量"而不是"质量"。K8s 的调度模型里,资源只有数量维度,没有性能维度。要解决这个问题,要么用 node label 手动标记节点性能等级,要么用 extended resource 加上自定义的调度器。我目前用的是前者——给降频的节点打上npu.performance=degraded的 label,然后在 Pod 的 nodeAffinity 里排除这类节点。
7. 后续可以继续折腾的方向
这套方案目前已经在生产环境跑了半年多,三个节点、九块 NPU 核心、十几个推理 Pod,稳定性没问题。但还有几个方向值得继续探索。
一是动态核心分配。目前的核心分配是静态的,Pod 启动时绑定核心,直到 Pod 退出才释放。如果某个 Pod 的推理负载很低,它占着的核心就浪费了。后续可以考虑做一个"核心超卖"机制,允许多个低负载 Pod 共享一个核心,通过时间片轮转来调度。
二是NPU 算力感知调度。前面提到的节点性能不一致问题,根本解法是让 scheduler 感知到 NPU 的实际算力。这需要扩展 K8s 的 scheduler framework,实现一个自定义的 scoring plugin。工作量不小,但价值很高。
三是与 Volcano 等批处理调度器集成。如果推理任务从在线服务扩展到离线批处理,K8s 默认的调度器就不够用了。Volcano 提供了 gang scheduling、queue 管理等能力,更适合批处理场景。RK3588 的 NPU device plugin 理论上可以无缝对接 Volcano,因为 Volcano 兼容 K8s 的 device plugin 协议。
最后分享一个实测有效的小技巧:在调试 NPU 调度问题时,把rknn_server的日志级别调到 debug,能看到每个推理请求被分配到哪个核心、耗时多少。这个日志在排查核心绑定问题时非常有用,比看 sysfs 的负载数据直观得多。