手写RK3588 NPU设备插件:让K8s集群调度边缘AI算力

发布时间:2026/9/26 5:02:54
手写RK3588 NPU设备插件:让K8s集群调度边缘AI算力 RK3588 的 6TOPS NPU 在边缘 AI 设备里越来越常见YOLOv8 推理、目标检测、图像分类这些活儿基本成了标配。可一旦你像我一样把几块装了 Ubuntu 的 RK3588 板卡组成了 K8s 集群就会立刻撞上一个尴尬局面K8s 根本不知道 NPU 存在调度器眼里只有 CPU、内存和 GPU。Rockchip 官方 BSP 提供了驱动和 RKNN 运行时唯独没有 K8s Device Plugin让 NPU 被 K8s 调度这件事官方没做我自己给补上了。这篇文章就是完整记录这个补齐过程从为什么官方不做的底层原因到 Device Plugin 的工作原理、核心代码、部署清单、YOLOv8 端到端验证再到性能实测和排查经验一次讲清楚。适合手里有 RK3588 板子、想把多块板卡从单机推理升级成集群调度的人也适合想自己写设备插件、把异形硬件接入 K8s 的朋友。1. 为什么 RK3588 的 NPU 在 K8s 里是个孤儿1.1 先认识一下 RK3588 的 NPURK3588 的 NPU 是一颗 8nm 制程的神经网络加速单元内部有 3 个 NPU 核心官方标称算力 6 TOPSINT8。它支持 INT4/INT8/INT16/FP16/BF16 多种精度跑 YOLOv8、RetinaFace、OCR、实时视频流处理这类任务完全够用。芯片上有两个关键的软件组成一个是驱动层面的设备节点/dev/rknpu另一个是用户态的运行时库librknnrt.so。它的使用方式和 GPU 差异很大。GPU 有 CUDA 这种通用计算接口模型转换完成后能比较灵活地调用而 RK3588 的 NPU 只能通过 RKNN API 操作模型必须先用 PC 端的 RKNN-Toolkit2 做一次转换和量化生成.rknn格式的文件才能在板子上跑。这一步就决定了它的调用链路是模型转换 专用运行时不像 GPU 那样一套 CUDA 通吃。很多人在搜索 RK3588 NPU 相关问题时还会混入昇腾那套torch_npu的内容。昇腾 NPU 走的是 CANN 生态torch_npu是 PyTorch 的昇腾插件和 RKNN 完全是两套东西共享的只是NPU这个名字。搞清楚你面对的是哪种 NPU后面排查问题才能少走弯路。维度RK3588 NPUNVIDIA GPU昇腾 NPU编程接口RKNN APICUDA / TensorRTCANN / torch_npu模型格式.rknn.engine / .onnx.om / .mindir设备节点/dev/rknpu/dev/nvidia0/dev/davinci0集群调度支持官方无插件成熟插件昇腾官方插件1.2 K8s 调度 GPU 的成熟套路为什么移植不过来K8s 调度 GPU 的原理并不复杂NVIDIA 官方提供了一个 nvidia-device-plugin以 DaemonSet 形式跑在每个节点上通过 Kubelet 的设备插件机制上报nvidia.com/gpu这种扩展资源。调度器看到 Pod 的resources.limits里写了nvidia.com/gpu: 1就会把 Pod 安排到有空闲 GPU 的节点上去。这套机制本身是通用的理论上任何硬件都能接入但这有两个前置条件。第一你需要写一个符合 K8s gRPC 协议的设备插件第二这个硬件最好有切分能力比如 NVIDIA 的 MIG 可以把一张卡切成多份。RK3588 的 NPU 有 3 个核心理论上可以用rknn_set_core_mask做核心级绑定但官方文档一直把 NPU 定位成单机 AI 加速器BSP 里压根没有 K8s 设备插件这个组件。所以现实是你拿 4 块 RK3588 搭了个 K8s 集群跑的是 Nginx 和普通容器那没任何问题一旦想跑 AI 推理任务调度器完全不认 NPU任务全挤到随便一个节点上然后容器里面发现没有 NPU 设备直接报错。这就是孤儿资源的典型症状。1.3 官方没做那就自己做目标拆解我给自己定的目标很简单让 NPU 成为集群里一个可调度、可分配、可验证的扩展资源。拆开来是四件事资源上报每个有 NPU 的节点向集群报告rockchip.com/rknpu的可用数量调度约束Pod 在 YAML 里声明需要 NPU调度器自动把 Pod 安排到有 NPU 的节点设备注入容器启动前自动把/dev/rknpu设备节点和librknnrt.so运行时挂进容器多级控制支持整卡独占和按核心分片两种粒度避免多个 Pod 抢 NPU。下面按这个目标讲实现路径。2. 整体设计K8s Device Plugin 是怎么把硬件上报给集群的2.1 一句话讲透 Device Plugin 机制K8s 的 Device Plugin 机制可以类比成超市供货关系。Kubelet 是超市的收货系统你的硬件是某种紧俏商品设备插件就是那个供货商。供货商先到超市前台登记Register告诉超市我有 X 硬件库存数量是 N超市把库存写进货架节点 Allocatable客户拿着购物清单Pod 的 limits来买东西时超市通知供货商备货Allocate供货商把商品送到指定位置客户就能用了。具体到协议层面Kubelet 在/var/lib/kubelet/device-plugins/kubelet.sock上监听 gRPC 请求。你的设备插件启动后要做几步在/var/lib/kubelet/device-plugins/目录下创建自己的 Unix socket主动拨号 Kubelet 的 socket发送注册信息提供ListAndWatch接口持续上报设备健康状态提供Allocate接口在 Pod 创建前返回设备、挂载和环境变量。这套流程是 K8s 从 1.10 版本开始就有的能力DevicePlugins feature gate 默认开启不需要额外配置集群。2.2 资源如何命名与上报rockchip.com/rknpu 和容量设计扩展资源的名字有严格格式要求vendor-domain/resource-name。前一段必须是域名格式后一段只能是小写字母、数字、连字符和下划线。我选的是rockchip.com/rknpu这个命名主要有两个意义避免冲突只用npu这种通用名很容易和昇腾、地平线或者其他 NPU 插件撞车语义清晰一眼能看出资源类型和来源运维排障时不用猜。容量的设计是第一个决策点。最稳妥的做法是默认上报 1表示整卡但 RK3588 有 3 个核心我做了个可配置项NPU_CORE_COUNT设为 3 时上报 3 份资源每个设备 ID 对应npu-core-0、npu-core-1、npu-core-2这样调度器就能把三个核心分给三个不同的 Pod。2.3 调度粒度的三种取舍给 NPU 调度定粒度比写代码更考验设计能力我对比了三种方案方案一整卡独占。容量上报 1一个 Pod 独占整个 NPU。优点是最稳定不存在并发冲突缺点是有 3 个核心却只能跑一个任务资源浪费明显。方案二按核心分片。容量上报 3用rknn_set_core_mask把进程绑定到指定核心。中间有 3 个任务并行跑效率接近 3 倍。缺点是这个 API 在不同 BSP 版本上行为不完全一样有的需要配合初始化参数才能生效需要适配。方案三时间片共享。容量上报一个很大的数多个 Pod 同时跑靠 NPU 驱动内部排队。这个方案听起来效率高实际最坑——NPU 任务本身是串行调度的没有真正的抢占机制多任务并发会导致推理延迟大幅波动甚至直接把任务队列打爆。我最后的方案是默认整卡独占最不容易出错通过环境变量打开核心分片模式。等跑稳定了再根据业务场景调整。2.4 整体架构四个组件分工整体架构分四块设备插件本体、DaemonSet 部署、测试 Pod、监控导出器。设备插件一个 Go 二进制跑在每个有 NPU 的节点上负责注册、上报和分配DaemonSet把插件打进镜像通过 hostPath 挂载 Kubelet 的 device-plugins 目录和/dev目录测试 Pod一个封装好了 YOLOv8 RKNN 推理程序的容器在 limits 里声明rockchip.com/rknpu监控导出器读取内核暴露的 NPU 负载节点以 Prometheus 格式输出交给 Grafana 画图。从 Pod 提交到推理任务启动的完整链路是Pod 声明 limits 后调度器根据各节点 Allocatable 做过滤把 Pod 放到有 NPU 且配额足够的节点节点上的 Kubelet 发现 Pod 需要扩展资源调用设备插件 Allocate插件把设备节点、运行时库、环境变量注入容器容器内的 RKNN 程序正常初始化并推理。3. 核心实现手写 rknpu-device-plugin3.1 先和 Kubelet 打招呼注册流程设备插件的元协议定义在 K8s 官方的 deviceplugin v1beta1 包里Go 直接依赖k8s.io/kubelet/pkg/apis/deviceplugin/v1beta1。核心逻辑是监听自己的 socket同时主动向 Kubelet 注册。注册消息里三个字段最关键Version固定填v1beta1ResourceName填rockchip.com/rknpuEndpoint填你自己 socket 的文件名。package main import ( google.golang.org/grpc api k8s.io/kubelet/pkg/apis/deviceplugin/v1beta1 ) func main() { socketPath : /var/lib/kubelet/device-plugins/rknpu.sock os.Remove(socketPath) // 1. 启动自己的 gRPC 服务 srv : grpc.NewServer() api.RegisterDevicePluginServer(srv, NPUPlugin{}) l, err : net.Listen(unix, socketPath) if err ! nil { klog.Fatalf(listen %s failed: %v, socketPath, err) } go srv.Serve(l) // 2. 向 Kubelet 注册 conn, err : grpc.Dial( unix:///var/lib/kubelet/device-plugins/kubelet.sock, grpc.WithInsecure(), ) if err ! nil { klog.Fatalf(dial kubelet failed: %v, err) } client : api.NewRegistrationClient(conn) _, err client.Register(context.Background(), api.RegisterRequest{ Version: v1beta1, ResourceName: rockchip.com/rknpu, Endpoint: rknpu.sock, }) if err ! nil { klog.Fatalf(register failed: %v, err) } klog.Info(rknpu device plugin registered) select {} }这里最容易踩的坑是 socket 文件残留。插件异常退出后rknpu.sock还可能留在/var/lib/kubelet/device-plugins/目录里新实例启动前必须先os.Remove不然net.Listen直接报 address already in use。插件崩溃、DaemonSet 重启频繁时这个问题尤其常见。3.2 设备发现与健康检查设备插件必须回答两个问题这台节点上有没有 NPUNPU 现在能不能用。我的实现逻辑比较朴素但够用检查/dev/rknpu是否存在不存在直接返回空设备列表检查/usr/lib/librknnrt.so是否存在确认运行时已安装周期性尝试以只读方式打开/dev/rknpu能打开就算健康用 klog 输出每次健康检查的结果方便后续排障。ListAndWatch是一个服务端流接口Kubelet 会一直监听。它返回一个设备列表每个设备有 ID 和健康状态。设备掉线时这个接口要立刻推送更新Kubelet 才会更新节点状态。我把它实现成一个循环先上报一次然后每隔 10 秒重新检查有变化就再次发送。func (p *NPUPlugin) ListAndWatch(e *api.Empty, s api.DevicePlugin_ListAndWatchServer) error { for { devices : p.scanDevices() // 扫描 /dev/rknpu 等 if err : s.Send(api.ListAndWatchResponse{Devices: devices}); err ! nil { klog.Errorf(send device list failed: %v, err) return err } time.Sleep(10 * time.Second) } }设备 ID 我直接固定成节点内唯一的字符串比如rk3588-npu-0核心分片模式下是rk3588-npu-core-0。ID 不需要有格式要求但要保证在同一节点内唯一否则 Allocate 阶段没法区分分配对象。3.3 Allocate 阶段把所有运行需要的东西一次性交出去Allocate 是设备插件的核心价值所在。它发生在容器真正创建之前Kubelet 把 Pod 里申请的设备 ID 列表传给我我必须返回容器运行需要的全部条件。我返回了三类信息设备节点、挂载路径、环境变量。func (p *NPUPlugin) Allocate(ctx context.Context, req *api.AllocateRequest) (*api.AllocateResponse, error) { resp : api.AllocateResponse{} for range req.ContainerRequests { cr : api.ContainerAllocateResponse{ Devices: []*api.DeviceSpec{{ ContainerPath: /dev/rknpu, HostPath: /dev/rknpu, Permissions: rwm, }}, Mounts: []*api.Mount{{ ContainerPath: /usr/lib/librknnrt.so, HostPath: /usr/lib/librknnrt.so, ReadOnly: true, }}, Envs: []*api.KeyValue{ {Key: RKNN_LOG_LEVEL, Value: 2}, }, } resp.ContainerResponses append(resp.ContainerResponses, cr) } return resp, nil }这段代码解决了一个很隐蔽的问题容器默认是看不到宿主机设备节点的。即使我告诉 Kubelet这个 Pod 需要用 NPU如果不在 Allocate 里把/dev/rknpu明确加进 Devices 列表容器内依然打不开设备。Runtimecontainerd/docker只会基于这个列表对容器的 device cgroup 放行。如果开启了核心分片模式Allocate 会额外根据设备 ID 设置核心掩码环境变量比如RKNN_CORE_MASK1对应核心 02对应核心 14对应核心 2。这个变量由容器里的推理程序读取在调用rknn_set_core_mask时使用。设备插件本身不碰内核它只负责把信息传到位。3.4 镜像构建与 DaemonSet 部署插件本体是静态编译的 Go 二进制镜像可以做到非常小。Dockerfile 分两段构建阶段用golang:1.22运行阶段直接复制二进制不保留任何编译工具链。FROM golang:1.22 AS build WORKDIR /src COPY . . RUN CGO_ENABLED0 go build -o rknpu-device-plugin . FROM ubuntu:22.04 COPY --frombuild /src/rknpu-device-plugin /usr/bin/rknpu-device-plugin ENTRYPOINT [/usr/bin/rknpu-device-plugin]部署用 DaemonSet保证每个节点上都跑一个。privileged: true是必须的因为插件需要访问宿主机/dev和 Kubelet 目录hostNetwork不需要插件只走 Unix socket不占网络端口。apiVersion: apps/v1 kind: DaemonSet metadata: name: rknpu-device-plugin namespace: kube-system spec: selector: matchLabels: name: rknpu-device-plugin template: metadata: labels: name: rknpu-device-plugin spec: tolerations: - operator: Exists containers: - name: rknpu-device-plugin image: registry.example.com/rknpu-device-plugin:v0.1.0 imagePullPolicy: IfNotPresent securityContext: privileged: true volumeMounts: - name: device-plugins mountPath: /var/lib/kubelet/device-plugins - name: dev mountPath: /dev - name: lib mountPath: /usr/lib readOnly: true volumes: - name: device-plugins hostPath: path: /var/lib/kubelet/device-plugins - name: dev hostPath: path: /dev - name: lib hostPath: path: /usr/lib挂载/usr/lib是为了让插件容器也能读取librknnrt.so这样健康检查就可以尝试dlopen验证运行时可用性。如果只需要验证设备节点存在不挂载/usr/lib也行但排查时会少一个重要信号。3.5 用 YOLOv8 做端到端验证插件部署完成后的第一件事就是看节点资源是否上报成功kubectl describe node node-name | grep -A3 rknpu正常情况会看到rockchip.com/rknpu: 1出现在 Allocatable 列表里。接着我做了个 YOLOv8 的 RKNN 推理镜像来验证全链路。模型转换是在 x86 PC 上完成的。先安装 RKNN-Toolkit2然后加载 YOLOv8 的 ONNX 模型做 INT8 量化后导出.rknn文件from rknn.api import RKNN rknn RKNN(verboseTrue) rknn.load_onnx(model./yolov8n.onnx) rknn.build(do_quantizationTrue, dataset./dataset.txt) rknn.export_rknn(./yolov8n.rknn) rknn.release()板子上的推理程序核心逻辑很简单用 C API 初始化上下文、加载模型、设置核心掩码、跑推理拿结果rknn_context ctx; rknn_init(ctx, ./yolov8n.rknn, 0, 0, NULL); rknn_set_core_mask(ctx, RKNN_NPU_CORE_0); rknn_input input {0}; input.fmt RKNN_TENSOR_NCHW; input.type RKNN_TENSOR_FLOAT32; input.size 3 * 640 * 640 * sizeof(float); rknn_inputs_set(ctx, 1, input); rknn_run(ctx, NULL); rknn_output output {0}; output.want_float 1; rknn_outputs_get(ctx, 1, output, NULL);把这个程序打包成镜像后Pod 的 YAML 里最关键的就是 resource 声明apiVersion: v1 kind: Pod metadata: name: yolov8-npu-demo spec: restartPolicy: OnFailure containers: - name: yolov8 image: registry.example.com/rknn-yolov8:v1.0.0 command: [/app/yolov8_demo, /models/yolov8n.rknn, /data/test.jpg] resources: limits: rockchip.com/rknpu: 1调度器会自动把 Pod 放到有 NPU 的节点上。如果集群里某台节点没有 NPU它永远不会成为这个 Pod 的候选节点。Pod 起来后能看到容器里的/dev/rknpu存在推理程序正常输出检测框坐标整个闭环就通了。4. 跑起来之后性能、并发与监控4.1 独占 vs 分片我实测的数字我手上的环境是 RK3588 板卡 Ubuntu 22.04 RKNN 1.6.0 运行时测试模型是量化后的 YOLOv8n输入分辨率 640x640。分别在整卡独占和 3 核心分片两种模式下跑推理延迟数据大概是这样的运行模式单任务延迟3 任务并行延迟备注整卡独占~18ms不支持最稳定无竞争3 核心分片~20ms3 任务均 ~22ms总吞吐接近 3 倍全共享不设掩码~25ms延迟飙到 60ms不推荐生产使用数字会因内核版本、运行时版本、量化方式和输入数据有波动但趋势是明确的核心分片模式下3 个任务并行每个延迟只比独占多约 2ms吞吐却接近线性扩展。这就是把 NPU 3 个核心充分利用的正确姿势。4.2 多 Pod 同时推理时到底会发生什么我最初偷懒让多个 Pod 不设核心掩码一起跑想着反正驱动会排队。结果实测很打脸两个 YOLOv8 任务并发时单个任务延迟从 20ms 飙到 60ms 以上数据任务的时延抖动严重到不可接受。原因是 NPU 驱动是串行调度模型任务会互相抢执行单元而且没有优先级抢占一个长任务能把短任务堵死。这不是 K8s 层面的问题是硬件本身的调度特性。所以设备插件只是入场券真正保证服务质量还要靠点位控制要么整卡独占要么让每个业务容器的启动脚本读取RKNN_CORE_MASK环境变量显式绑定核心。我后来在推理程序的封装里加入了掩码解析逻辑这个配合问题就解决了。4.3 把 NPU 占用率接进 Prometheus 和 Grafana集群里监控 CPU 内存是常规操作NPU 的监控就需要点额外功夫。部分 BSP 内核的 debugfs 提供了 NPU 负载节点典型路径是/sys/kernel/debug/rknpu/load里面是 0~100 的占用率数字。不同内核版本路径不完全一样先确认再写采集逻辑ls /sys/kernel/debug/rknpu/ cat /sys/kernel/debug/rknpu/load我用 node-exporter 的 textfile collector 做了最简单的接入写一个采集脚本把load文件数值转成 Prometheus 格式落到 textfile 目录#!/bin/bash echo rk3588_npu_load $(cat /sys/kernel/debug/rknpu/load) \ /var/lib/node_exporter/textfile_collector/npu.prom配合 Prometheus 抓取和 Grafana 面板就能看到每台节点的 NPU 实时占用率。如果内核不导出这个节点还有一个替代思路在识别模型内调用 RKNN 的性能统计接口rknn_query的RKNN_QUERY_PERF_DETAIL把单次推理耗时推给 Prometheus exporter间接反映负载。4.4 不同场景怎么选调度模式我把经验总结成一张选型表直接照抄就能用场景推荐模式理由单个高优先级视频流分析整卡独占保证最低延迟避免抖动多个低延迟检测服务3 核心分片每个服务绑定一个核心稳定并行开发测试、批量离线推理全共享不设掩码接受延迟波动图省事混合负载生产环境核心分片 配额控制用 K8s 配合设备数量限制并发5. 常见问题与排查实录5.1 节点扩展资源没出现部署插件后最常见的现象就是kubectl describe node里看不到rockchip.com/rknpu。按这个顺序排查先看插件 Pod 日志确认是否输出 registered 成功确认/dev/rknpu在宿主机存在ls -l /dev/rknpu确认插件 socket 正常监听了rknpu.sock用ls -la /var/lib/kubelet/device-plugins/查看如果插件反复重启大概率是 socket 冲突或权限问题清理残留 socket 文件后重启。5.2 Pod 一直 Pending 无法调度Pod 创建后卡在 Pendingkubectl describe pod里通常有一行0/1 nodes are available: 1 Insufficient rockchip.com/rknpu。意思是所有节点都没有足够的 NPU 配额。要确认节点是否真的上报了资源以及是否有其他 Pod 已经占用了配额。如果节点上报的是 3 个核心3 个 Pod 已经占满第 4 个自然会等。5.3 容器起来后打不开 /dev/rknpu资源调度正常、容器也启动了但推理程序报open /dev/rknpu: No such file or directory。这通常是 Allocate 返回值不对或者 Runtime 没放行设备。先检查 Pod 的 YAML 里容器的 device 映射再确认 Pod 是否以 privileged 运行必要时给容器加securityContext.privileged: true。还要确认宿主机的 rknpu 内核模块真的加载了lsmod | grep rknpu查一下。5.4 rknn_init 报错librknnrt.so 版本不匹配这是 RK3588 系列最容易踩的版本兼容坑。RKNN 运行时和内核驱动是绑定的官方发布 RKNN-Toolkit2 时会配套一套 runtime 和 driver不能随便混搭。我遇到过 BSP 内核自带的驱动版本是 0.7.x而我灌进去的 runtime 是 0.8.xrknn_init直接返回失败。解决办法是升级 BSP 固件或者从官方仓库下载配套版本的 runtime 覆盖到/usr/lib/librknnrt.so。排查版本可以用板子上的小工具strings /usr/lib/librknnrt.so | grep -i version5.5 多个 Pod 抢 NPU 导致推理卡顿甚至报错如果你坚持全共享模式延迟波动和无响应是必然的。驱动队列一旦积压部分推理会超时返回错误。处理办法要么退回核心分片要么在推理程序里加重试逻辑同时限制并发 Pod 数量。我在程序里做了个简单的超时重试rknn_run超过 500ms 未返回就重试一次误报率明显下降。5.6 快速排查速查表症状可能原因排查命令 / 手段节点无扩展资源插件未注册 / socket 冲突看插件日志ls device-plugins 目录Pod Pending节点 NPU 配额不足kubectl describe pod 看事件设备节点不存在Allocate 未注入 / 内核模块未加载检查 Pod speclsmod grep rknpurknn_init 失败运行时与驱动版本不匹配strings /usr/lib/librknnrt.so 查版本推理延迟骤增多个任务抢 NPU切换核心分片模式查看监控面板NPU 负载无监控数据debugfs 路径不对先 ls /sys/kernel/debug/rknpu/ 确认路径我个人在实际操作中最深的体会是给异形硬件写 K8s 设备插件代码量真不大难的是把硬件特性摸透。RK3588 的 NPU 和 GPU 不一样没有成熟的切分工具链没有厂商维护的插件甚至连并发行为都要靠实测去验证。但恰恰是这种官方没做的地方才是把集群从能跑容器推向能跑 AI 业务的关键一步。最后分享一个后续扩展思路插件补上了资源调度但集群里如果同时跑很多 AI 任务还会遇到排队、优先级、任务失败告警这些工作流问题。目前我的做法是用 K8s 原生 Job 加自定义 Controller 管理推理任务任务状态通过 webhook 推到企微告警再往深走可以接 Volcano 这类批量调度插件处理更复杂的排队和抢占。AI 推理要真正成为集群的一等公民NPU 调度只是第一块基石。