ARTICLE DETAIL

资讯详情

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

从零实现RK3588 NPU Device Plugin:K8s边缘AI算力调度实战

从零实现RK3588 NPU Device Plugin:K8s边缘AI算力调度实战 1. 缘起一块被 Kubernetes 冷落的 NPU手里攥着几块 RK3588 的板子跑 YOLOv8 推理能到 30 多帧功耗还不到 10W这性价比放在边缘计算场景里确实能打。但当我试图把它塞进已有的 K8s 集群统一管理时问题来了——kubectl describe node里压根看不到 NPU 这个资源Pod 调度器完全不知道这块板子上有几颗 NPU、还剩多少算力可用。这不是 RK3588 独有的尴尬。K8s 原生只认 CPU 和内存GPU 靠 NVIDIA 官方的 device plugin 撑场面而 RK3588 搭载的瑞芯微 NPU算力 6 TOPS三核架构在 K8s 生态里基本属于查无此人的状态。官方文档翻遍了社区里关于 RK3588 NPU 接入 K8s 的完整方案少得可怜大多是零散的 device plugin 代码片段或者干脆让你手动指定节点跑 Pod。我需要的其实很朴素让 K8s 知道每台 RK3588 节点上有 3 个 NPU 核心Pod 申请rockchip.com/npu: 1时调度器能自动找到有空闲 NPU 的节点跑完释放跟用 GPU 一样自然。官方没做那就自己补上。这篇文章记录的就是从零实现 RK3588 NPU Device Plugin 的完整过程包括设备发现、资源上报、调度验证、监控接入以及踩过的那些坑。如果你也在用 RK3588 做边缘 AI 推理并且想让 K8s 统一编排这些算力这篇内容应该能帮你省下不少折腾时间。即使你用的是其他国产 NPU比如昇腾、寒武纪设备插件这套框架的逻辑是相通的改改设备发现部分就能复用。2. 整体设计为什么是 Device Plugin 而不是别的方案2.1 K8s 扩展资源的三种路径对比在动手之前我先把 K8s 里能实现自定义硬件资源调度的几条路都捋了一遍避免选错方向白费功夫。方案实现方式优点缺点适用场景Device PlugingRPC 服务注册到 kubelet官方推荐调度器原生支持资源隔离清晰需要实现 gRPC 接口调试稍复杂GPU、NPU、FPGA 等异构设备Extended Resource直接上报整数资源实现极简无法做设备健康检查、无法分配具体设备简单场景不关心具体哪块设备自定义调度器自己写调度逻辑灵活度最高工作量大与原生调度器割裂特殊调度策略需求我最终选了 Device Plugin理由很直接它是 K8s 官方为异构硬件设计的标准接口kubelet 会主动调用你的插件来发现设备、分配设备调度器也能通过标准的资源模型感知 NPU 数量。用 Extended Resource 虽然能快速让调度器看到NPU但没法保证 Pod 真正拿到的是哪颗 NPU 核心也没法做设备健康状态上报生产环境里这就是个定时炸弹。2.2 RK3588 NPU 的设备模型RK3588 的 NPU 有个特点需要特别注意它是三核架构三个 NPU 核心可以独立工作也可以联合推理。在 Linux 系统里它通过/dev/rknpu或/dev/dri/renderD129这样的设备节点暴露给用户态。我实测的 Ubuntu 20.04 固件上设备节点是/dev/dri/renderD129对应的 DRM 设备。这里有个关键决策把整个 NPU 当成一个设备还是把三个核心拆成三个设备我选择拆成三个独立设备。原因在于 RK3588 的 NPU 驱动支持多进程并发访问每个核心可以独立分配给不同的 Pod。如果当成一个整体一个 Pod 占用了 NPU其他 Pod 就只能排队资源利用率上不去。拆成三个之后K8s 层面看到的是rockchip.com/npu: 3调度粒度更细边缘场景下多个小模型并行推理的需求就能满足。当然如果你的模型需要三核联合推理比如 RKNN 工具链里的多核模式那就得把三个核心绑成一个设备分配。这个取舍取决于你的实际负载我在代码里留了配置开关两种模式都支持。2.3 插件与 kubelet 的交互流程Device Plugin 的工作机制说白了就是一套 gRPC 对话插件启动后在宿主机/var/lib/kubelet/device-plugins/目录下创建一个 Unix socket插件通过 kubelet 的 socket 注册自己告诉 kubelet 我管理 rockchip.com/npu 这个资源kubelet 定期调用插件的ListAndWatch接口获取设备列表和健康状态调度器分配 Pod 到节点后kubelet 调用插件的Allocate接口插件返回设备路径和环境变量kubelet 把这些信息注入到容器里Pod 就能访问 NPU 了整个流程里插件需要实现四个 gRPC 方法GetDevicePluginOptions、ListAndWatch、Allocate、GetPreferredAllocation。其中ListAndWatch和Allocate是核心前者负责设备发现后者负责设备分配。注意Device Plugin 的 socket 路径和 kubelet 的配置强相关。如果你改了 kubelet 的--device-plugin-path参数插件里的路径也得跟着改否则注册会失败而且报错信息很不直观容易卡在这里。3. 核心实现从设备发现到资源上报3.1 设备发现怎么找到 RK3588 的 NPU设备发现是整个插件的基础。RK3588 的 NPU 在 Linux 下没有统一的设备节点命名规范不同固件版本可能不一样。我见过的情况有/dev/rknpu部分旧版固件/dev/dri/renderD129Ubuntu 20.04 官方固件常见/dev/mpp_service多媒体相关不是纯 NPU最稳妥的做法是通过 sysfs 探测。RK3588 的 NPU 在/sys/class/devfreq/下会有对应的频率节点比如fdab0000.npu通过读取这个节点的信息可以确认 NPU 存在。同时结合/dev/dri/下的 render 节点做交叉验证。我写的设备发现逻辑是这样的// discoverNPUDevices 扫描系统中的 NPU 设备 func discoverNPUDevices() []Device { var devices []Device // 优先检查 sysfs 中的 NPU 频率节点 npuPaths, _ : filepath.Glob(/sys/class/devfreq/*.npu) if len(npuPaths) 0 { klog.Warning(未在 sysfs 中发现 NPU 节点) return devices } // 查找对应的 DRM render 节点 renderNodes, _ : filepath.Glob(/dev/dri/renderD*) for i, node : range renderNodes { // 验证该 render 节点是否属于 NPU if isNPURenderNode(node) { devices append(devices, Device{ ID: fmt.Sprintf(npu-%d, i), Path: node, Health: Healthy, }) } } return devices }isNPURenderNode这个函数需要读取/sys/class/drm/renderD129/device/下的信息确认设备 vendor ID 是瑞芯微的0x1d87。这一步很关键因为有些板子上还有 Mali GPU 的 render 节点不区分的话会把 GPU 也当成 NPU 上报。3.2 三核拆分一个设备还是三个前面提到我选择把三核拆成三个设备。实现上RK3588 的 NPU 驱动其实是通过一个设备节点暴露三个核心的用户态通过RKNN的 API 指定使用哪个核心。所以在 Device Plugin 层面我上报三个设备 IDnpu-0、npu-1、npu-2但Allocate时返回的是同一个设备路径只是通过环境变量告诉容器该用哪个核心。// Allocate 分配设备给容器 func (p *NPUPlugin) Allocate(ctx context.Context, req *pluginapi.AllocateRequest) (*pluginapi.AllocateResponse, error) { resp : pluginapi.AllocateResponse{} for _, r : range req.ContainerRequests { cresp : pluginapi.ContainerAllocateResponse{} for _, id : range r.DevicesIDs { // 解析设备 ID提取核心编号 coreID : parseCoreID(id) cresp.Devices append(cresp.Devices, pluginapi.DeviceSpec{ HostPath: /dev/dri/renderD129, ContainerPath: /dev/dri/renderD129, Permissions: rw, }) // 通过环境变量传递核心编号 cresp.Envs append(cresp.Envs, pluginapi.EnvVar{ Key: RKNN_NPU_CORE, Value: strconv.Itoa(coreID), }) } resp.ContainerResponses append(resp.ContainerResponses, cresp) } return resp, nil }这里有个细节容器里跑推理时RKNN 的 runtime 会读取RKNN_NPU_CORE环境变量来决定用哪个核心。如果你的推理框架不认这个变量那就得在应用层自己处理比如通过配置文件指定。实操心得三核拆分后如果某个 Pod 申请了 2 个 NPUK8s 会分配两个设备 ID但Allocate返回的Devices列表里会有两个相同的设备路径。kubelet 去重后容器里只挂载一个设备节点但环境变量会覆盖最后只有最后一个核心生效。所以一个 Pod 申请多个 NPU 核心时需要应用层自己根据设备 ID 做多核调度Device Plugin 层面只能保证资源计数正确。3.3 健康检查NPU 挂了怎么让 K8s 知道设备健康检查是生产环境必须的。RK3588 的 NPU 在高负载下偶尔会出现驱动异常表现为设备节点还在但推理请求超时。我的健康检查策略是定期执行一次轻量级推理测试而不是简单检查设备节点是否存在。具体做法是每 30 秒调用一次 RKNN 的rknn_query接口查询 NPU 的核心状态。如果连续三次失败就把设备标记为Unhealthy通过ListAndWatch上报给 kubelet。kubelet 收到不健康设备后会把它从可分配资源里剔除新的 Pod 就不会再调度到这个设备上。// healthCheck 定期检查 NPU 健康状态 func (p *NPUPlugin) healthCheck() { ticker : time.NewTicker(30 * time.Second) for range ticker.C { for i : range p.devices { if !p.probeNPU(i) { p.failureCount[i] if p.failureCount[i] 3 { p.devices[i].Health pluginapi.Unhealthy } } else { p.failureCount[i] 0 p.devices[i].Health pluginapi.Healthy } } // 触发 ListAndWatch 更新 p.updateCh - true } }这里有个坑ListAndWatch是一个长连接流设备状态变化时需要主动推送更新。我一开始用轮询方式结果 kubelet 那边状态更新延迟很大后来改成 channel 通知机制才解决。4. 部署实操从编译到跑通第一个 Pod4.1 编译与镜像构建插件本身是 Go 写的编译成静态二进制后打包进一个极简镜像。因为要访问宿主机的设备节点容器需要privileged权限或者至少挂载/dev/dri目录。FROM alpine:3.18 RUN apk add --no-cache libc6-compat COPY rk3588-npu-plugin /usr/local/bin/ ENTRYPOINT [/usr/local/bin/rk3588-npu-plugin]编译命令CGO_ENABLED0 GOOSlinux GOARCHarm64 go build -o rk3588-npu-plugin ./cmd/plugin docker build -t registry.local/rk3588-npu-plugin:v1.0 .注意架构是arm64RK3588 是 ARM 架构别编译成 amd64 了。我第一次就犯了这个错镜像推上去 Pod 一直CrashLoopBackOff看日志才发现是exec format error。4.2 DaemonSet 部署配置插件需要以 DaemonSet 形式部署确保每个 RK3588 节点上都跑一个实例。完整的 YAML 如下apiVersion: apps/v1 kind: DaemonSet metadata: name: rk3588-npu-plugin namespace: kube-system spec: selector: matchLabels: name: rk3588-npu-plugin template: metadata: labels: name: rk3588-npu-plugin spec: nodeSelector: kubernetes.io/arch: arm64 hardware: rk3588 tolerations: - key: CriticalAddonsOnly operator: Exists containers: - name: npu-plugin image: registry.local/rk3588-npu-plugin:v1.0 securityContext: privileged: true volumeMounts: - name: device-plugin mountPath: /var/lib/kubelet/device-plugins - name: dri mountPath: /dev/dri - name: sys mountPath: /sys readOnly: true volumes: - name: device-plugin hostPath: path: /var/lib/kubelet/device-plugins - name: dri hostPath: path: /dev/dri - name: sys hostPath: path: /sysnodeSelector里我加了hardware: rk3588标签需要提前给节点打上kubectl label node rk3588-node-01 hardwarerk3588这样插件只会调度到 RK3588 节点上不会跑到普通 x86 节点上瞎折腾。4.3 验证资源上报部署完成后等几秒钟让插件注册然后查看节点资源kubectl describe node rk3588-node-01 | grep -A 5 Capacity正常的话应该能看到Capacity: cpu: 8 memory: 8123456Ki rockchip.com/npu: 3如果没看到rockchip.com/npu先检查插件 Pod 的日志kubectl logs -n kube-system -l namerk3588-npu-plugin常见错误是 socket 注册失败日志里会有failed to register device plugin之类的提示。这时候检查/var/lib/kubelet/device-plugins/目录权限以及 kubelet 的--device-plugin-path参数是否和插件里写的一致。4.4 跑一个 YOLOv8 推理 Pod 验证资源上报成功后写一个测试 Pod 申请 NPUapiVersion: v1 kind: Pod metadata: name: yolov8-npu-test spec: containers: - name: inference image: registry.local/yolov8-rknn:latest resources: limits: rockchip.com/npu: 1 env: - name: RKNN_NPU_CORE valueFrom: fieldRef: fieldPath: metadata.annotations[rockchip.com/npu-core]这里有个问题环境变量RKNN_NPU_CORE在 Pod 层面没法直接引用 Device Plugin 注入的值。Device Plugin 注入的环境变量是容器级别的Pod 的env字段里引用不到。所以实际使用时应用需要自己读取容器内的环境变量或者通过 downward API 从注解里取。我测试时直接在容器启动脚本里打印了环境变量确认RKNN_NPU_CORE被正确注入#!/bin/sh echo NPU Core: $RKNN_NPU_CORE python3 infer.py --model yolov8n.rknn --core $RKNN_NPU_COREPod 跑起来后kubectl logs能看到推理结果同时kubectl describe node里rockchip.com/npu的 Allocated 变成 1说明调度和分配都正常工作了。5. 监控接入让 NPU 利用率看得见5.1 Prometheus 指标暴露Device Plugin 本身不负责监控但 NPU 的利用率、温度、频率这些指标对运维很重要。我在插件里加了一个 HTTP 端点暴露 Prometheus 格式的指标// 注册 Prometheus 指标 var ( npuUtilization prometheus.NewGaugeVec( prometheus.GaugeOpts{ Name: rk3588_npu_utilization, Help: NPU 核心利用率百分比, }, []string{core}, ) npuTemperature prometheus.NewGaugeVec( prometheus.GaugeOpts{ Name: rk3588_npu_temperature_celsius, Help: NPU 温度, }, []string{core}, ) )数据来源是/sys/class/devfreq/fdab0000.npu/下的load和temp文件。RK3588 的 NPU 驱动会实时更新这些值读取频率我设为 5 秒一次避免频繁 IO 影响推理性能。5.2 Grafana 面板配置Prometheus 抓取到指标后Grafana 面板配置就简单了。我建了三个图NPU 利用率趋势图按核心分线观察三核负载是否均衡NPU 温度监控超过 80 度标红RK3588 的 NPU 在高温下会降频资源分配率rockchip.com/npu的已分配数量 / 总数量这里有个经验NPU 利用率指标在空闲时不是 0而是有个 5% 左右的底噪。这是驱动本身的统计误差配置告警阈值时要考虑进去否则会频繁误报。5.3 告警规则告警规则我设了两条groups: - name: npu-alerts rules: - alert: NPUHighTemperature expr: rk3588_npu_temperature_celsius 85 for: 2m labels: severity: warning annotations: summary: NPU 温度过高 ({{ $value }}°C) - alert: NPUAllocationSaturated expr: rockchip_com_npu_allocated / rockchip_com_npu_total 0.9 for: 5m labels: severity: info annotations: summary: NPU 资源分配率超过 90%温度告警阈值设 85 度是因为 RK3588 的 NPU 在 85 度以上会开始降频推理延迟明显上升。分配率告警则是提醒该扩容节点了。6. 踩坑记录与排查技巧6.1 常见问题速查表现象可能原因排查方法解决方案节点看不到 NPU 资源插件未注册成功查看插件日志检查 socket 路径和权限Pod 一直 Pending资源不足或节点未打标签kubectl describe pod检查 nodeSelector 和资源配额容器内无 NPU 设备设备节点未挂载ls /dev/dri检查 Allocate 返回的 Devices推理报错 core not found环境变量未注入envgrep RKNNNPU 利用率始终为 0监控路径错误手动读 sysfs确认 devfreq 节点名称插件频繁重启健康检查失败查看 failureCount调整探测频率或阈值6.2 三个印象最深的坑第一个坑kubelet socket 路径不一致。我用的 kubelet 版本是 1.24默认 device plugin 路径是/var/lib/kubelet/device-plugins/但有些发行版会改到/var/lib/kubelet/plugins/。插件注册时如果路径不对kubelet 根本收不到注册请求日志里也不会有明显报错就是静默失败。后来我在插件启动时加了一个路径探测逻辑依次尝试几个常见路径哪个能创建 socket 就用哪个。第二个坑三核拆分的资源计数问题。前面提到一个 Pod 申请多个 NPU 核心时环境变量会互相覆盖。我一开始没注意测试时申请了 2 个核心结果容器里只拿到最后一个核心的编号推理还是单核跑。后来在应用层加了逻辑如果检测到多个核心就启动多个推理线程分别绑定。这个问题在 Device Plugin 层面无解因为 kubelet 只负责注入环境变量不负责应用层的多核调度。第三个坑NPU 驱动版本与 RKNN 运行时版本不匹配。板子上的 NPU 驱动是固件自带的而容器里的 RKNN runtime 是单独安装的。两者版本差太多时rknn_init会直接失败报错信息是E RKNN: Invalid RKNN model看起来像是模型问题实际是驱动不兼容。解决办法是在容器启动时检查驱动版本通过读取/sys/kernel/debug/rknpu/version获取和 RKNN runtime 的版本做比对不匹配就打印警告。6.3 性能调优的两个小技巧技巧一CPU 亲和性绑定。RK3588 是大小核架构4 个 A76 4 个 A55NPU 推理的前后处理如果跑在小核上会成为瓶颈。我在 Pod 的resources里加了 CPU 亲和性配置把推理进程绑定到大核上YOLOv8 的端到端延迟从 45ms 降到了 32ms。技巧二DMA 缓冲区复用。RKNN 的输入输出默认走 DMA 缓冲区频繁分配释放会有开销。如果推理请求是连续的可以在应用层复用缓冲区减少内存拷贝。这个优化在批量推理场景下效果明显吞吐量能提升 15% 左右。7. 后续可以怎么扩展这套 Device Plugin 跑通之后能做的事情就多了。比如结合 K8s 的 HPAHorizontal Pod Autoscaler根据 NPU 利用率自动扩缩推理 Pod或者用 Volcano 这类批调度器把多个推理任务打包调度到同一节点提高 NPU 的并发利用率。我还试过把 RK3588 节点和 x86 节点混布在同一个集群里通过 nodeAffinity 让推理任务优先调度到 RK3588 上x86 节点只跑控制面组件。这样边缘侧的算力成本能压得很低运维又统一在 K8s 里不用单独维护一套边缘管理系统。如果你也在折腾 RK3588 的 K8s 集成建议先从单节点跑通 Device Plugin 开始确认资源上报和分配没问题后再扩展到多节点集群。设备发现那部分代码最好写得灵活一点不同固件的设备节点命名可能不一样硬编码路径迟早会踩坑。
返回列表