ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

RK3588 NPU 在 K8s 中的设备插件与调度实践

RK3588 NPU 在 K8s 中的设备插件与调度实践 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这样的统一查询接口有 MIGMulti-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 到固定值 3RK3588 的 NPU 核心数是硬件固定的。3.2 gRPC 服务的注册与 ListAndWatchDevice plugin 的 gRPC 服务需要实现Register、ListAndWatch、Allocate三个核心方法。注册阶段插件通过 Unix socket 向 kubelet 的 device plugin manager 报到func (p *NPUPlugin) Register() error { conn, err : grpc.Dial( pluginapi.DevicePluginPathkubelet.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是一个流式 RPCkubelet 会持续接收设备列表的更新。对于 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_temperatureNPU 温度从 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 occur5.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.performancedegraded的 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 的负载数据直观得多。
返回列表