ARTICLE DETAIL

资讯详情

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

手写K8s Device Plugin:让RK3588 NPU成为集群可调度资源

手写K8s Device Plugin:让RK3588 NPU成为集群可调度资源 用RK3588做边缘AI节点这事儿我玩了快一年了。单板部署YOLOv8推理任务确实爽但一旦任务从几个变成几十个要在十几台RK3588上分发、调度、滚动更新手动一台台SSH上去部署就完全扛不住了。我想把它交给K8s结果发现一个大坑K8s能调度CPU、内存甚至能通过插件调度GPU但对RK3588的NPU官方压根没做适配。Rockchip官方的RKNN SDK面向的是单板场景从来没考虑过集群里那么多块板子怎么分NPU算力。K8s社区里也没人给RK3588写过像样的调度插件。所以——官方没做的事我补上了。这篇文章就是完整复盘我是怎么用K8s的Device Plugin机制把RK3588的NPU上报成一种可调度的集群资源让kubectl describe node里出现rockchip.com/npu让Pod通过limits申请NPU核心数量然后被调度器送到有对应资源的节点上运行。这篇内容适合两类人一是手头有RK3588、准备组集群跑AI推理的开发者二是理解K8s设备插件原理的朋友。我会从问题根源讲起然后是方案选型、核心代码、容器内的RKNN runtime打通、最后是我上线后踩的一堆坑。1. 为什么RK3588的NPU在K8s里没人管1.1 先认识RK3588这块NPU长什么样RK3588的NPU是一颗算力标称6 TOPSINT8的AI加速器内部有3个核心。它在系统里不是一个独立的PCIe设备而是通过rknpu驱动挂载表现为一个字符设备节点/dev/rknpu。你要在Linux上使用它必须装对应的内核模块和RKNN runtimelibrknnrt.so。这一点和咱们熟悉的NVIDIA GPU有本质区别。GPU在服务器里是标准的PCIe设备有厂商驱动、有CUDA库、有nvidia-smi而RK3588的NPU是SoC内部集成模块没有标准的设备枚举机制也没有统一的NPU显存概念。你只能用Rockchip定义的RKNN API去调用它。如果只是单板开发流程很简单加载驱动、装RKNN-Toolkit2的runtime库、把模型转换成.rknn格式然后写代码rknn_init创建上下文、rknn_inference执行推理。但放到集群场景里问题就变成了K8s怎么知道这个节点上有NPU怎么知道这个节点还剩几个NPU核心能用怎么在容器里安全地分配到某一个核心1.2 K8s默认只认识CPU和内存其他资源都要上报K8s的调度器做调度决策时看的核心信息是Node的Capacity和Allocatable。CPU、内存这些内置资源由kubelet直接采集上报。像NPU、GPU、FPGA这类外部设备K8s标准做法是使用Device Plugin机制设备厂商在节点上跑一个独立插件通过gRPC和kubelet通信告诉kubelet我这台机器上有多少设备、每个设备什么标识、哪些设备需要被健康检查。kubelet拿到这些信息后会把资源汇总成一种特殊资源名字格式通常是vendor-domain/resource比如nvidia.com/gpu。这类资源全称叫Extended Resource扩展资源。调度器看到Pod的limits里写了rockchip.com/npu: 2就会去找哪个节点的Allocatable里有充足的rockchip.com/npu余量。没有Device Plugin支持K8s就是睁眼瞎NPU对集群来说根本不存在。1.3 为什么到现在官方都没做说到这你可能会问Rockchip为什么不像NVIDIA那样离线发布一个nvidia-device-plugin原因不复杂。NVIDIA的K8s调度插件本质上是把GPU以PCIe设备的方式暴露给容器依赖一整套nvml库而RK3588的NPU是嵌入式SoC里的模块官方的主要用户还是安卓、Linux单板开发很少有人会在K8s集群里管理几十块开发板。Rockchip的SDK交付物是deb包、固件、交叉编译工具链根本没打算处理分布式调度这种复杂度。另一个原因是内核驱动暴露方式特殊。rknpu驱动不像GPU那样能通过nvidia-smi查询设备列表和利用率让插件去做健康监测、算力隔离都非常麻烦。社区里也有零星的尝试但大多是直接把NPU设备挂载进容器靠节点亲和性手动绑节点并没有做成完整的调度方案。于是就得自己动手了。其实K8s的Device Plugin API本身是为这个场景准备的RK3588的NPU能不能被调度不取决于某个厂商的官方支持而取决于我们有没有耐心把一个非标准设备用标准机制包装好。2. 调度方案选型为什么是Device Plugin而不是其他土办法2.1 先说三个候选方案别一上来就写代码我当时考虑过三种让NPU进入K8s调度视野的方法分别用一个表格说清楚利弊。方案实现成本资源可见性调度体验缺点A. 自己写Device Plugin中高高出现为独立Extended ResourcePod直接声明limits即可需要理解API调试链路长B. 给节点打标签用NodeSelector绑Node低低只区分布板型号区分不了剩余算力需要手动维护任务和节点的关系多任务并发时严重不均容易热点C. 自定义调度器CRD管理任务很高中高需要额外实现调度逻辑改动大要写controller前期待遇不够方案B看着简单我一开始也试过。给每块板子打个rk3588true标签然后所有推理任务都用NodeSelector固定到某一块板子。单任务没问题多任务全挤在同一个节点上其他板子闲着这跟分布式调度没有半毛钱关系。方案C太重。CRD、自定义调度器、控制器全写完基本等于再造半个K8s。而且我们这些任务的调度需求并不复杂就是按NPU核心数调度资源不够就排队不要超卖这恰好是K8s原生调度器支持的语义没必要重复造轮子。所以最终选方案A。Device Plugin不是只有NVIDIA能用它是K8s给所有外部设备提供的一个统一插座谁把设备包装成标准接口谁就能被kubelet接管。RK3588的NPU再特殊在这个插座面前都只是一个可以提供N个设备ID的资源池。2.2 Device Plugin内部是怎么和Kubelet勾搭上的理解Device Plugin的机制是写代码之前必须做的事。整个链路是这样的Device Plugin在节点上跑起来监听一个Unix Socket路径一般是/var/lib/kubelet/device-plugins/xxx.sock。插件启动后向kubelet另一个固定的Socket/var/lib/kubelet/device-plugins/kubelet.sock发送注册请求声明自己的资源名比如rockchip.com/npu和Socket路径。kubelet收到注册后会调用插件的ListAndWatch接口插件返回当前节点上的所有设备列表以及在后续运行中持续上报设备健康状态。当用户申请这个资源时kubelet会调用插件的Allocate接口传入被分配的设备ID插件返回需要注入容器的东西——设备节点、挂载目录、环境变量。调度器那头kubelet会把设备的数量和健康状态汇总成Extended Resource上报给API Server。这里最核心的是第三个和第四个接口ListAndWatch决定能报多少设备Allocate决定容器里能看到什么。NVIDIA的插件在这两个接口里做的事情是返回GPU的UUID然后把设备节点、驱动的库目录、环境变量等注入容器。我们要给RK3588做的东西本质一样只不过设备ID是NPU核心号注入的环境变量是NPU Core Mask。2.3 Extended Resource的限制值得先说清楚在写代码之前还有一个概念必须踩清楚。Extended Resource有几个硬性限制只能申请整数数量。你不能limits: rockchip.com/npu: 0.5表达半个NPU核K8s不接受这种小数。数据是quantity类型数值上可以用字符串表示。它的调度是账本式的调度器只做容量检查不做运行时隔离。比如两个Pod都申请到了同一个核心调度器不会发现因为你没有上报细分维度。Extended Resource不支持nvidia.com/gpu那样的显存大小属性所有信息都只能通过设备数量来表达。这些限制导致了我的设计思路一个NPU核心就是一个设备ID上报3个设备资源名为rockchip.com/npu节点Capacity就是3。Pod想用几个核就申请几个rockchip.com/npu。这样一个设备ID 一个NPU核心的映射最直观调度器做容量检查时也最准确。有人可能会问能不能只上报一个设备把整个NPU当一个大资源能但那样最多只能让一个Pod用整个NPU3核就废了。我的业务场景里有的任务需要1核有的任务需要3核按核上报能支持更细粒度分配。3. 手写RK3588 NPU Device Plugin的核心实现3.1 工程结构和环境准备代码用Go写因为K8s的Device Plugin官方示例和依赖包都是Go生态。需要准备的东西很简单一台RK3588节点Ubuntu 20.04/22.04都行跑着kubelet。Go 1.20以上的开发环境。依赖包google.golang.org/grpc、k8s.io/kubelet/pkg/apis/deviceplugin/v1beta1这个是K8s提供的Device Plugin API的Go定义。工程结构我是这样组织的rk3588-npu-device-plugin/ ├── go.mod ├── main.go ├── plugin/ │ ├── server.go # gRPC服务监听socket │ ├── plugin.go # Device Plugin核心逻辑 │ └── register.go # 向kubelet注册 └── deploy/ ├── plugin.yaml # DaemonSet或者systemd单元 └── pod-example.yaml这里建议把注册逻辑和Device Plugin服务逻辑分开写。因为注册和gRPC服务是有顺序的先启动gRPC服务监听自己的Socket然后再去发注册请求。如果你先把服务起好再注册中间有短窗口期kubelet可能还没准备好需要做重试。3.2 ListAndWatch上报3个核心设备核心代码不多我直接贴最关键的一段。设备列表写死为三个核心健康状态初始为Healthypackage plugin import ( context time google.golang.org/grpc pluginapi k8s.io/kubelet/pkg/apis/deviceplugin/v1beta1 ) const resourceName rockchip.com/npu type NPUDevicePlugin struct { socket string devices []*pluginapi.Device server *grpc.Server } func NewNPUDevicePlugin(socket string) *NPUDevicePlugin { return NPUDevicePlugin{ socket: socket, devices: []*pluginapi.Device{ {ID: npu-core-0, Health: pluginapi.Healthy}, {ID: npu-core-1, Health: pluginapi.Healthy}, {ID: npu-core-2, Health: pluginapi.Healthy}, }, } } func (p *NPUDevicePlugin) ListAndWatch(_ *pluginapi.Empty, stream pluginapi.DevicePlugin_ListAndWatchServer) error { if err : stream.Send(pluginapi.ListAndWatchResponse{Devices: p.devices}); err ! nil { return err } // 这里做健康状态循环上报。目前RK3588的NPU驱动没有可靠的 // 单设备健康查询接口所以简单点每30秒重新上报一次即可。 for { select { case -stream.Context().Done(): return nil case -time.After(30 * time.Second): if err : stream.Send(pluginapi.ListAndWatchResponse{Devices: p.devices}); err ! nil { return err } } } }这里有三个细节需要说透设备ID用什么格式无所谓npu-core-0这种可读性好、调试方便。真正重要的是注册时上报的资源名也就是rockchip.com/npu里的vendor一定要是域名格式不能随便起个名字否则kubelet会拒绝接收。健康检查是Device Plugin可选的但强烈建议要有一个。有些朋友图省事只在ListAndWatch里发一次设备列表就返回。这会导致kubelet认为设备列表已结束插件不可用。更稳妥的做法是保持流持续发送。目前我们没有真正的健康检测手段就定时重新上报。如果未来想做一个真正的健康检查可以读取/sys/class/devfreq/fdab0000.npu/load判断NPU驱动是否还在响应用户空间调用。但这个值反映的是负载不是设备存活不能直接用。3.3 Allocate把设备ID翻译成NPU核心掩码Allocate是所有环节里最见功夫的地方。我对它的理解是设备ID是K8s侧的抽象实际NPU核心的使用方式由我们决定。RK3588的RKNN Runtime在使用时需要传入一个核心掩码rknn_core_mask取值有RKNN_NPU_CORE_0、RKNN_NPU_CORE_1、RKNN_NPU_CORE_2、RKNN_NPU_CORE_0_1、RKNN_NPU_CORE_0_1_2等。所以我的方案是把请求几个设备ID转换成掩码的比特位组合npu-core-0对应掩码第0位即值1npu-core-1对应掩码第1位即值2npu-core-2对应掩码第2位即值4如果申请到2个核心比如0和2掩码就是145然后这个掩码通过环境变量RKNN_NPU_CORE_MASK注入容器。容器内的应用启动时读这个环境变量传给rknn_init即可。代码实现如下func (p *NPUDevicePlugin) Allocate(_ context.Context, reqs *pluginapi.AllocateRequest) (*pluginapi.AllocateResponse, error) { responses : pluginapi.AllocateResponse{} for _, req : range reqs.ContainerRequests { coreMask : 0 for _, id : range req.DevicesIDs { switch id { case npu-core-0: coreMask | 1 0 case npu-core-1: coreMask | 1 1 case npu-core-2: coreMask | 1 2 } } resp : pluginapi.ContainerAllocateResponse{ EnvVars: []*pluginapi.KeyValue{ { Key: RKNN_NPU_CORE_MASK, Value: fmt.Sprintf(%d, coreMask), }, }, } responses.ContainerResponses append(responses.ContainerResponses, resp) } return responses, nil }看到这里你可能会觉得这比NVIDIA的插件简单太多了吧是的因为RK3588的NPU不需要像GPU那样挂载PCIe设备不需要处理CUDA版本目录设备节点就一个/dev/rknpu。真正麻烦的反而是下一步设备节点怎么进容器、RKNN runtime和固件怎么让容器内应用读得到。这些Allocate之外的事比写Allocate本身费心思后面我会专门讲。还有一个细节Allocate接口里还会返回Mounts。有的设备需要把某些目录挂载进去才可用。RK3588的NPU驱动固件目录也需要进去但这个我更倾向于直接打进镜像而不是每次在Allocate里挂载。理由是固件版本和runtime版本需要匹配如果把固件从宿主机随意挂进去镜像移植性会很差。3.4 Register与启动流程写完了核心接口还得让插件真正运行起来。Start函数负责创建Socket、启动gRPC Server、注册到kubelet。这里有一大坑Socket文件如果上次没有清理干净会因为address already in use起不来必须os.Remove。package plugin import ( context fmt net os time google.golang.org/grpc pluginapi k8s.io/kubelet/pkg/apis/deviceplugin/v1beta1 ) // Start 启动插件服务并注册到kubelet func (p *NPUDevicePlugin) Start() error { if err : os.Remove(p.socket); err ! nil !os.IsNotExist(err) { return err } _ os.MkdirAll(/var/lib/kubelet/device-plugins, 0755) lis, err : net.Listen(unix, p.socket) if err ! nil { return err } p.server grpc.NewServer() pluginapi.RegisterDevicePluginServer(p.server, p) go func() { _ p.server.Serve(lis) }() // 连接kubelet的固定socket进行注册带重试 conn, err : grpc.Dial(unix:///var/lib/kubelet/device-plugins/kubelet.sock, grpc.WithInsecure(), grpc.WithBlock(), ) if err ! nil { return err } defer conn.Close() client : pluginapi.NewRegistrationClient(conn) req : pluginapi.RegisterRequest{ Version: pluginapi.Version, Endpoint: npu.sock, ResourceName: resourceName, } // 第一次失败最多重试10次每次间隔5秒 for i : 0; i 10; i { if _, err : client.Register(context.Background(), req); err nil { return nil } time.Sleep(5 * time.Second) } return fmt.Errorf(register to kubelet failed) }main.go里调用它package main import ( flag log rk3588-npu-device-plugin/plugin ) func main() { var socket flag.String(socket, /var/lib/kubelet/device-plugins/npu.sock, device plugin socket) flag.Parse() p : plugin.NewNPUDevicePlugin(*socket) if err : p.Start(); err ! nil { log.Fatalf(failed to start: %v, err) } select {} }编译产物是一个二进制。注意K8s Device Plugin的Socket路径有严格的约定必须在/var/lib/kubelet/device-plugins/目录下。同时插件需要有足够的权限访问这个目录。我用的是systemd单元部署因为Device Plugin属于节点级系统组件不应该跑在容器里再挂载一堆目录。用DaemonSet虽然也可以但会让节点初始化变得更绕。3.5 部署并验证资源被识别在RK3588节点上把编译好的rk3588-npu-device-plugin放到/usr/local/bin/写一个systemd单元文件[Unit] DescriptionRK3588 NPU Device Plugin Afterkubelet.service [Service] ExecStart/usr/local/bin/rk3588-npu-device-plugin Restartalways RestartSec5 StandardOutputjournal [Install] WantedBymulti-user.target启动后观察日志systemctl start rk3588-npu-device-plugin journalctl -u rk3588-npu-device-plugin -f如果注册成功kubelet的日志里会出现类似Registered device plugin with name rockchip.com/npu的记录。然后看节点资源kubectl describe node rk3588-node-01在Capacity和Allocatable两节里应该能看到rockchip.com/npu: 3此时K8s已经看到了NPU。接下来才是真正的考验怎么让容器里跑起来的推理程序真的用上被分配的核心。4. 把RKNN Runtime和设备节点送进容器推理任务才算真正跑起来4.1 NPU不是挂上就能用容器里还缺三样东西很多朋友做到上面那步以为资源能被调度了就万事大吉结果Pod一启动应用直接报Failed to open /dev/rknpu或者librknnrt.so not found。这个坑我踩过几乎必然会遇到。原因是K8s把Pod调到这个节点只是说明Pod有资格用NPU容器进程能不能访问设备取决于容器里有没有设备节点、依赖库和固件。具体缺三样东西设备节点/dev/rknpu。这是内核驱动提供的字符设备。如果容器里没有这个节点open(/dev/rknpu, O_RDWR)会失败。RKNN runtime库librknnrt.so。所有推理方法都通过它跟驱动交互这个库通常来自Rockchip发布的内核SDK或runtime包。NPU固件。Rockchip的NPU需要从用户空间加载一段固件一般放在系统的firmware目录比如/usr/lib/firmware/rknpu_fw.bin之类。缺了它驱动虽然能打开设备节点但初始化会卡住或报版本错误。设备节点可以用两种方式进容器一种是在YAML里通过hostPath直接挂载另一种是在Device Plugin的Allocate接口里通过ContainerDevices返回kubelet会把它注入容器。我选择在Allocate里返回设备节点因为这才是插件该管的事业务Pod不需要关心宿主机设备路径resp : pluginapi.ContainerAllocateResponse{ Devices: []*pluginapi.DeviceSpec{ { HostPath: /dev/rknpu, ContainerPath: /dev/rknpu, Permissions: rw, }, }, ... }如果采用这个方式需要1.12以上K8sDevice Plugin API才支持DeviceSpec。至于库和固件我建议直接构建进镜像不要用hostPath。原因有两个不同板子可能用不同版本的RKNN runtime打成镜像才能保证镜像在任意节点行为一致。在镜像里做库的版本控制、多版本共存远比在每台宿主机上维护全局目录干净。如果非要用hostPath挂载宿主机的/usr/lib/librknnrt.so确实能跑但会遇到在节点A跑得好好的到节点B就报版本不兼容的情况。4.2 Dockerfile与启动脚本把环境变量变成核心掩码我的业务镜像大概长这样FROM ubuntu:20.04 # 把RKNN runtime库和依赖装进镜像 COPY install/librknnrt.so /usr/lib/librknnrt.so COPY install/rockchip_rknn_runtime_*.deb /tmp/ RUN dpkg -i /tmp/rockchip_rknn_runtime_*.deb || true \ rm -rf /tmp/*.deb # NPU固件目录放进镜像 COPY install/rknpu_fw.bin /usr/lib/firmware/rknpu_fw.bin RUN apt-get update apt-get install -y \ libgomp1 \ python3 \ python3-pip \ pip3 install opencv-python numpy COPY entrypoint.sh /entrypoint.sh RUN chmod x /entrypoint.sh COPY app /app WORKDIR /app ENTRYPOINT [/entrypoint.sh]入口脚本负责把Device Plugin注入的环境变量整理成进程需要的形式#!/bin/bash set -e export NPU_CORE_MASK${RKNN_NPU_CORE_MASK:-3} echo NPU core mask: $NPU_CORE_MASK exec python3 /app/infer.pyRKNN_NPU_CORE_MASK来自Allocate接口注入的EnvVars。如果某个Pod忘了通过Device Plugin调度或者直接手动绑节点这个环境变量就不存在此时默认给一个双核掩码3不至于让任务挂死。在infer.py里真正需要做的只是读取这个变量并传给RKNN接口import os import numpy as np from rknn.api import RKNN core_mask int(os.getenv(RKNN_NPU_CORE_MASK, 3)) rknn RKNN() rknn.load_rknn(/app/model/yolov8s.rknn) # 初始化runtime时传入核心掩码 rknn.init_runtime(targetrk3588, npu_core_maskcore_mask) img ... outputs rknn.inference(inputs[img]) print(done)RKNN-Toolkit2的Python接口支持在init_runtime里指定npu_core_mask掩码含义和C API一致。在K8s里所有这些对外部设备的感知最终都收敛成一个小小的环境变量这也是我为什么坚持在Allocate里不用复杂挂载、只用环境变量的原因应用代码改动最小运维可解释性最强。4.3 一次完整的调度生效过程这时候整个链路已经打通我把最终流程完整串一遍上报Device Plugin把rockchip.com/npu总共3个设备上报给kubeletkubelet转成扩展资源rockchip.com/npu: 3。调度用户提交YAMLPod声明limits: rockchip.com/npu: 2。调度器发现节点上可用资源大于等于2就把Pod调度到RK3588节点。分配kubelet在启动Pod时向Device Plugin发起Allocate请求请求中的DevicesIDs是2个设备ID比如npu-core-0和npu-core-1。注入插件返回环境变量RKNN_NPU_CORE_MASK3和设备节点/dev/rknpu。运行容器启动入口脚本读取环境变量Python应用拿到核心掩码初始化RKNN Runtime时使用这两个核来回推理。我实测跑一个640x640输入的YOLOv8s模型单核推理大概要32ms双核约23ms三核全开约18ms。这个数据仅供大家参考不同模型、不同量化方式差异很大。重点是Pod内通过环境变量确实能感知到自己被分配了多少核并且在多Pod并发时每个Pod的掩码互不冲突。5. 上线之后踩的坑以及NPU监控的补充5.1 最经典的坑Kubelet重启后插件失联我的第一个Workload上线跑了一个星期中间某次kubelet重启后节点上所有GPU/NPU相关的Pod全部卡在ContainerCreating。查日志发现kubelet压根不认识rockchip.com/npu了节点的Capacity里NPU这一列直接消失。原因在于Device Plugin向kubelet注册是一个即时行为kubelet重启后会清理掉以前的插件Socket重新向已注册的插件目录发起连接但前提是插件还活着并且还能响应gRPC请求。如果插件因为某个bug死了kubelet没法自己恢复它只能等插件重启后重新注册。我用的systemd单元Restartalways理论上插件进程死了会自动拉起并重新注册。但当时的问题在于旧Socket文件没清理干净新进程起不来所以就卡死了。我在代码里用os.Remove(p.socket)处理了这个情况但部署时旧版本没有这步。建议大家都加上这一行并且插件启动后主动等待2到3秒再注册给kubelet一点反应时间。5.2 Allocate拿到的设备ID数量和Pod请求数对不上有一次我提交了rockchip.com/npu: 1的Pod结果在代码日志里看到Allocate请求中包含了2个设备ID。查了很久才发现这是K8s 1.14之后出现的特性Device Plugin有GetPreferredAllocation接口kubelet在某些情况下会把Pod请求数量翻译成建议分配列表。如果没有实现这个接口kubelet会自己选设备选出来的数量依然等于请求数量才对。后来反复测试确认这不是插件逻辑问题。最终原因是我在测试的Pod里同时声明了limits和requests不一致。这给大家一个教训Extended Resource的limits和requests最好保持一致。如果只写limitsK8s会自动把requests设置为跟limits一样如果两者都写且values不一致调度和实际分配会出现心理预期边界模糊最终Allocate拿到的数量以limits为准。5.3 Pod能调度进去但容器里打不开/dev/rknpu这个问题也很典型。容器能创建但Python程序报open /dev/rknpu: No such file or directory。排查后发现是挂载权限问题虽然Allocate里返回了设备节点但容器里没有加载rknpu驱动的knl层而且有些嵌入式板子的/dev/rknpu创建依赖udev规则设备节点根本没在容器内创建。解决办法是给Pod的securityContext加上特权或者至少privileged: true并把设备挂载改为和宿主机同名路径securityContext: privileged: true如果你是更严格的k8s环境不想用privileged可以改用如下方式显式声明deviceresources: limits: rockchip.com/npu: 1但devices字段是1.30以后才有的alpha特性如果你集群版本不够老最省事的还是开privileged。边缘集群安全性要求没那么苛刻可接受。另一个细节RK3588某些内核版本下/dev/rknpu的权限默认是0660用户组是root。容器内如果不以root运行也打不开。所以我在业务镜像里把执行用户设置为root或者加到group为0。为了省事推荐业务镜像直接以root运行别折腾非root用户除非你熟悉设备cgroup的授权规则。5.4 说好的监控呢补充NPU利用率采集标题里有监控NPU资源这里我也简单补上。K8s能把NPU调度起来只解决了一半问题运维还得看它到底忙不忙。好在RK3588的debugfs暴露了一个非常方便的利用率文件cat /sys/kernel/debug/rknpu/load也有另一种路径常见于较新内核cat /sys/class/devfreq/fdab0000.npu/load输出类似current_freq: 1000000000 load: 78%把这两行包装成Prometheus文本格式用node-exporter的textfile collector收集就能在Grafana里画出每个节点的NPU使用率曲线。我的做法是在节点上跑一个5秒一次的脚本把load写入/var/lib/node_exporter/textfile_collector/npu_usage.prom。Prometheus里能查到的metrics长这样rk_npu_usage_percent{noderk3588-node-01} 78 rk_npu_freq_hz{noderk3588-node-01} 1000000000这个监控和Pod的调度联动能发现一个常见业务问题Pod申领了2个核心但实际NPU负载一直很低说明模型根本没跑在NPU上或者是推理循环没有控制好帧率导致NPU空闲。有数值才能及时调整调度策略。5.5 扩展思路这还能再往前走一步Device Plugin这套机制做完后我最大的感慨是K8s的抽象其实没你想的那么死板。当前实现按整核心分配Pod之间不会共享NPU核心因此不存在算力互相挤兑的问题。但如果你想让多个轻量Pod共享一个核心那就要引入更细粒度的调度策略比如在部署上层加一层算力配额的概念或者在应用层用进程内分时复用。RK3588的NPU驱动目前没有提供硬隔离所以跨Pod共享同一个核心仍需要业务层让步这一点在文档里要写清楚。另外如果你手头有多种板卡包括RK3588和RK3568可以给每个型号上报不同资源名比如rockchip.com/npu-rk3588: 3和rockchip.com/npu-rk3568: 1这样调度器能天然帮你把不同负载分发到对应型号节点比打标签加NodeSelector强得多。还有个方向是结合K8s的调度器扩展能力做剩余NPU核心数优先的调度策略。默认的LeastRequestedPriority已经很够用但如果你的集群里同时有RK3588和RK3568而不同模型的路演优先级不一样那就可以用scheduler extender或者写一个独立的调度插件把节点剩余NPU算力作为评分项加进去。这块涉及K8s调度框架的扩展点有兴趣的可以看kube-scheduler的framework插件接口写起来比想象中简单。说实话把单一节点的NPU接进K8s不是个了不起的技术但它确实解决了我在生产里最头疼的问题任务部署流程、资源审计、编排升级全部统一到了K8s的标准模型里。官方没做不代表不能做只看你是否愿意把这条路走通。
返回列表