
gridsample这个算子乍一看只是PyTorch标准算子库里一个不起眼的函数但真到了车载芯片的部署环节它能把人折腾得够呛。简单说gridsample做的是按照一个坐标网格从输入特征图上采样出新的特征图。这个东西在图像透视变换、光流对齐、BEV视角转换这些任务里几乎是标配尤其在自动驾驶感知模型里它经常出现在前处理、特征对齐、环视拼接以及各种warping模块中。这篇文章我会围绕地平线征程6芯片上gridsample算子的优化与部署实践来写把原理、坑点、优化思路和实操过程都过一遍也算给在这条路上摸索的同行们留一份参考。1. gridsample算子到底在干什么gridsample这个算子第一次接触的人可能会被它的名字误导以为就是个“采样”。其实它做的事情比采样要复杂一层它不是机械地按固定步长取像素而是根据外部传入的坐标网格把输入特征图上“任意位置”的值取出来组成一张新的特征图。这个“任意位置”很关键因为坐标通常是浮点数落在整数像素之间所以必须靠插值来估算。1.1 核心机制采样坐标 插值如果你看过Spatial Transformer NetworksSTN那篇论文对gridsample应该不陌生。STN把“让网络自己学习空间变换”这件事拆成了两步第一步由定位网络输出一组变换参数第二步是gridsample根据这组参数生成采样网格然后对输入特征图逐像素取坐标、做插值。也就是说输出特征图上每个位置的值并不是原图对应像素直接搬过来的而是根据一个归一化坐标通常取值在-1到1之间映射到输入特征图的实际像素坐标再在周围像素之间做插值。以最常用的双线性插值为例核心逻辑可以这样理解。假设输出特征图上某个位置对应采样坐标x, y经过归一化映射后落到输入特征图的位置可能是13.3, 87.7这种带小数的坐标。硬件没有“13.3号像素”的概念所以必须用相邻的四个像素13, 87、13, 88、14, 87、14, 88的数值按距离做加权平均得到最终输出值。这个过程虽然数学上简单却意味着每个输出点都要完成“计算坐标、判断边界、取四个邻居、做加权求和”四步操作。用伪代码描述双线性插值# 双线性插值的核心伪代码 def bilinear_sample(input, x, y): x0 int(floor(x)); y0 int(floor(y)) x1 x0 1; y1 y0 1 # 边界裁剪假设padding_modezeros x0 clamp(x0, 0, W - 1) x1 clamp(x1, 0, W - 1) y0 clamp(y0, 0, H - 1) y1 clamp(y1, 0, H - 1) # 计算权重 wx x - x0 wy y - y0 # 四个邻居的值做加权 top input[y0, x0] * (1 - wx) input[y0, x1] * wx bottom input[y1, x0] * (1 - wx) input[y1, x1] * wx return top * (1 - wy) bottom * wy把这段伪代码翻译成硬件要执行的指令你会发现它比普通卷积麻烦得多。卷积的访问模式是规则的每个输出位置对应的输入位置是固定偏移编译器可以提前算好数据地址方便做预取和流水线。而gridsample每个输出点的输入坐标完全由grid张量里的值决定这个值运行到那一刻才知道属于典型的“数据相关访存”。在CPU或通用GPU上这最多给编译器增加一点分支判断但在征程6这类神经网络加速芯片上它赖以提速的“规则并行、固定流水、批量访存”前提被打破了性能很容易断崖式下降。1.2 自动驾驶模型里它无处不在为什么会在地平线征程6上遇到gridsample因为现在的BEV感知、环视拼接以及各类跨视角融合模型里几乎都离不开这个算子。比如常见的BEV视角转换输入是多路环视相机图像输出是一个俯视的鸟瞰特征图中间就有一步从“相机透视视角”到“BEV俯视视角”的像素级映射这个映射就是通过gridsample来采样实现的。再比如帧间光流warping上一帧特征图根据光流场本质是一个坐标偏移网格取当前帧的特征同样要调用gridsample。这些场景的共同点是gridsample位于整个网络前向推理的关键路径上它的时延直接决定模型能否达到目标帧率。如果实现不够高效模型在GPU上跑得流畅一迁移到征程6就变成瓶颈推理耗时可能翻倍。对于目标在几十毫秒内完成整个感知链路的产品来说这完全不能接受。2. 地平线征程6的芯片特性决定了它不能“硬跑”gridsample地平线征程6系列是面向高阶智能驾驶推出的新一代计算方案核心是自研的BPUBrain Processing Unit架构。这个架构针对神经网络计算做了大量定制比如规则化的矩阵乘加阵列、专用的卷积流水线、以及配套的编译器工具链。像Conv、MatMul、Pool这类规则算子它能跑出非常高的利用率。但gridsample不属于这种“规则算子”它的问题在于访存模式不规则这一点跟芯片设计初衷是冲突的。2.1 BPU擅长什么不擅长什么我习惯把BPU理解成一个“高度特化的计算工厂”。它对规则的卷积有专门流水线数据从DDR读到片上SRAM经过计算阵列再写回DDR整个过程按照编译期就能确定的节奏调度能做到非常高的吞吐。可一旦遇到采样坐标依赖运行时的输入值编译器没法提前安排数据搬运和计算顺序整个流水线就会被“卡脖子”。这么说吧卷积的输入坐标是“静态可推导”的编译器能提前把数据预取到片上存储里而gridsample的输入坐标存放在grid张量里值有多大、落在哪个位置只有运行到这一步才知道。所以处理器只能每次读一个坐标、做一次访存、算一次插值循环往复。这种模式下计算阵列的空闲率非常高大量时间花在等待访存返回上。另外征程6这类芯片在多核并行和内存带宽分配上通常也是围绕“规则数据流”设计的。当你要用多核去并行执行gridsample时最自然的切分方式是“按输出空间划分”但每个输出点会采样输入特征图的不同区域这导致核间负载很难均衡数据复用也差。如果某个核恰好分到一片输入热点其他核却闲在那里并行效率自然上不去。2.2 常规方案的困境两条路都不好走遇到gridsample很多团队的第一反应是把输入坐标换成整数也就是把双线性插值简化成最近邻采样。这样确实能把“算四个邻居加权”变成“一次直接读内存”访存规律了一些但精度损失在很多感知任务里不可接受尤其是需要亚像素精度的光流和BEV任务情况会更糟。另一条路是把gridsample切分成“网格坐标变换”加“多个常规插值算子”的组合比如用多次卷积或反卷积来近似。这种做法在GPU上有些优化空间但在征程6上算子数量增加会带来额外的调度开销而且每次中间结果都要写回DDR内存带宽会先扛不住。我试下来反而比原始实现更慢。所以最后只能回到算子本身针对征程6的特性做定制优化。这个“定制优化”不是说把PyTorch里的gridsample原封不动搬过来跑而是要理解芯片的访存模型、计算资源和并行方式把算法重新映射到硬件上。我下面要写的具体方案就是基于这个思路来的。3. 优化方案设计从“按点采样”到“融合内核”优化过程大致分三步先做算法重构把计算和访存解耦再做数据排布优化让访存尽量连续最后把整个算子包成一个融合内核减少中间内存搬运。3.1 算法重构把坐标计算和采样解耦gridsample可以拆成两部分坐标变换和采样插值。坐标变换通常是逐元素操作比如把归一化坐标乘上特征图宽高、减去偏移等采样插值则是根据坐标从特征图取值并加权。这个拆分的意义在于坐标变换部分是完全规则的逐元素计算可以放到BPU的向量单元里跑得很高效而采样插值部分可以单独实现成一个更紧凑的循环内核。编译时不再把整个gridsample当成一个黑盒而是让编译器知道“前半段是规则计算、后半段是循环访存”这样调度起来会灵活很多。另外坐标变换的结果如果能在片上缓存住就不用每个输出点都重复计算一遍坐标映射能省掉不少重复运算。还有一个值得注意的细节坐标变换里的归一化映射公式不同框架实现有细微差别。PyTorch默认把grid坐标[-1, 1]映射到输入尺寸但align_cornersTrue和align_cornersFalse两种模式一个是对齐到角点一个是对齐到像素中心结果可能差出半个像素。这个差异在浮点模型里不明显但在量化模型里会导致肉眼可见的边缘错位后面部署部分我会重点讲。3.2 数据排布与访存优化让访问尽量连续访存连续性是征程6上性能差距最大的影响因素之一。默认情况下来自PyTorch的特征图是NCHW排布通道维是连续的。但gridsample在采样时输出位置对应的输入通道是同一个空间位置的所有通道也就是说沿C维访问是连续的而沿H/W维访问是跳变的。如果直接按NCHW来采样每取一个像素都要跨过一整条通道的数据Cache和DDR的效率都很低。把数据重排成NHWC之后同一个空间位置的多个通道值在内存上是相邻的采样时就可以连续读一批通道的数据显著提高缓存命中率。代价是转换本身要花一次数据搬运。我的经验是如果网络后面本来就要用NHWC做量化或者融合这笔开销可以摊薄如果整个模型都是NCHW那就需要评估到底值不值得。还有一个更实在的技巧对grid张量做批处理合并。很多模型的grid是4D或5D的存在一个batch维。在循环采样时与其一个batch一个batch地处理不如把多个batch的输入坐标一起读取、一起计算这样访存带宽利用率会更高。这个优化在batch较小比如2到4路相机时收益尤其明显因为单batch的访存粒度太小很容易触发多次短突发传输。3.3 融合内核减少中间张量的读写杀手锏是把坐标变换、边界处理、插值、写回这几步融合在一个内核里完成不再产生中间张量。这么做最直接的好处是省内存。假设输入特征图是1x64x128x128grid是1x128x128x2中间如果按“坐标变换结果”和“插值结果”各存一份每个都是几MB的量级。在嵌入式平台上DDR带宽有限省掉几次整张feature map的读写延迟能显著下降。融合内核写成类似这样的结构// 简化版融合内核伪代码主题循环 for (int oh 0; oh out_h; oh) { for (int ow 0; ow out_w; ow) { float gx grid[n][oh][ow][0]; float gy grid[n][oh][ow][1]; // 坐标映射到输入空间 float ix (gx 1.0f) * 0.5f * in_w - 0.5f; float iy (gy 1.0f) * 0.5f * in_h - 0.5f; // 边界裁剪 int x0 (int)floor(ix); int y0 (int)floor(iy); x0 clamp(x0, 0, in_w - 1); y0 clamp(y0, 0, in_h - 1); int x1 min(x0 1, in_w - 1); int y1 min(y0 1, in_h - 1); float wx ix - x0; float wy iy - y0; // 对每个通道并行采样 for (int c 0; c in_c; c) { float top input[y0][x0][c] * (1.0f - wx) input[y0][x1][c] * wx; float bottom input[y1][x0][c] * (1.0f - wx) input[y1][x1][c] * wx; output[oh][ow][c] top * (1.0f - wy) bottom * wy; } } }实际的征程6实现里最内层的通道循环会被向量化成一次处理16或32个通道的向量运算。坐标计算只用做一次然后对整条通道向量做插值这样计算量相比标量循环降低了一个量级。同时由于通道连续的NHWC排布这时的访存基本是顺序读硬件预取也能发挥作用。结合这些手段整体性能跟最初直接用PyTorch算子转换后的版本相比能改善一个数量级以上。4. 部署实践从PyTorch到征程6的全流程做算子优化不能只停留在代码层面最终要落到地平线的工具链里完成模型转换、量化、编译、上板验证。这里说下一个可操作的流程。4.1 工具链与开发环境准备开发征程6上的算子首先要准备地平线提供的工具链套件包括模型转换工具、算子开发SDK、模拟器以及板端运行时。习惯上我会把这些装到Docker里保证和CI环境一致避免“在本地能编过、一提交就挂”。模型转换环节需要把PyTorch模型先导出成ONNX再通过地平线工具链做解析和量化。但要注意ONNX Runtime里的gridsample和PyTorch里的gridsample在坐标处理细节上可能不完全一致比如align_corners这个参数是否生效、坐标是否需要加0.5这些差异在低精度INT8量化时会被放大。我遇到过好几次PyTorch上精度正常导出ONNX后结果出现轻微错位最后排查发现就是align_corners的处理方式不同。所以转换后一定要先跑一遍数值比对而不是直接上板。4.2 算子注册与自定义内核接入如果工具链自带的gridsample实现不满足性能或精度要求就要考虑注册自定义算子。这个过程一般分两步先是在模型转换时把gridsample节点标记成“自定义节点”避免工具链把它当成普通节点处理然后编写并在运行时注册这些自定义内核代码实现在BPU或CPU上执行。这里有个容易踩的坑自定义算子的接口定义需要和转换工具、运行时两边完全对齐包括输入输出的个数、维度顺序、数据类型和量化参数。举个例子如果你的模型是INT8量化算子输入虽然逻辑上是float但在硬件存储里可能是int8或者int16的定点表示需要在注册代码里做反量化或直接按定点逻辑实现。如果漏掉这一步输出的数值范围会完全对不上。还有一点自定义内核的调试环境要尽早搭建。不要等到最后上板才做验证那样一次调试周期太长。我习惯在PC上先用同样的接口封装写一份参考实现跟PyTorch结果做逐元素对比确认逻辑没问题后再交叉编译到征程6的板端环境。这样能把算法逻辑问题和硬件平台问题分开排查效率高很多。4.3 量化策略对gridsample的影响量化是部署里最容易引起gridsample精度问题的环节。因为坐标值通常很小可能集中在0到1之间甚至负值如果量化到INT8步长稍大一点就会让采样位置发生明显偏移。我常用的稳妥方式是对grid这个tensor保持较高的量化精度比如用INT16甚至直接走浮点路径而特征图保持INT8量化。当然这会增加少量存储和计算开销但相对整个模型来说这点开销通常可以接受毕竟没有它模型在关键任务上的精度可能直接没法用。另一个思路是做“坐标预偏置”在量化前先对grid值做一个全局缩放和偏移让它的动态范围更适配量化步长然后在算子内核里再乘回来。这样可以在不增加数据类型位宽的前提下把量化带来的坐标误差降到最低。整体流程跑下来我习惯建立一张量化对比表记录不同量化方案下gridsample输出的最大绝对误差和端到端精度指标。这样参数调整的时候心里有数不会凭感觉瞎试。量化这件事最怕的就是“感觉差不多”一定要用数据说话。4.4 性能验证与调优循环性能验证不能只看端到端耗时还要看算子的单独耗时、访存带宽利用率和计算单元利用率。地平线自带的profiler能给出算子级的时间统计把这些数据拉出来先看是不是gridsample占了主要时间再看它的访存是不是有明显瓶颈。我习惯的做法是做一个最小复现工程只跑gridsample和它前后必要的算子在上板之前先用模拟器估算性能。模拟器的结果虽然和实机有差距但用来验证优化方向是对的。等方向确定后再上板测实际延迟。整个调优循环是用profiler定位热点改内核再跑profiler看变化。改到访存带宽接近理论值、计算单元有空闲而不是干等基本就到位了。实际调优中我还会记录每次修改对应的性能数据哪怕是一个很小的改动也记录下来。因为算子优化经常出现“某个优化单独有效、两个优化叠加反而变差”的情况没有记录就只能靠口碑和记忆很难复现。把每次改动的背景、效果都记下来后面再遇到类似算子可以少走很多弯路。5. 常见问题与排查技巧实录这部分整理我在实际项目中遇到频率最高的问题每条都对应一个真实的排查过程。5.1 模型转换时报“算子不支持”这是新手最容易碰到的问题报错信息通常是“gridsample is not supported”之类。不要第一时间就骂工具链先确认三件事工具链版本是否支持gridsample、导出的ONNX版本是否过旧、gridsample节点是否被某些优化pass改写了形状。如果确实不支持就按上面说的自定义算子方案处理。还有一个实用技巧搜一下算子注册的样例代码先跑通一个最简的“拷贝算子”确认自定义算子链路是通的再把gridsample的逻辑加进去。这样能避免把“链路不通”和“实现有bug”混在一起排查。5.2 精度对不上差在坐标细节精度问题里十有八九出在坐标变换的细节。常见的有四种一是align_corners参数理解错了导致采样网格边界错位二是归一化坐标[-1, 1]到输入尺寸的映射公式不一致三是padding_modezeros/border/reflection的行为差异四是量化导致的坐标误差累积。排查建议先固定输入用一个小尺寸tensor比如4x4输入、2x2输出手工构造grid分别跑PyTorch和板上结果逐点对比输出值。一旦哪个像素对不上立刻就能算出是坐标差了多少。这种方法比直接对整张大feature map要直观得多。5.3 性能达不到预期卡在访存如果profiler显示算子的计算单元利用率不高但DDR带宽已经接近上限说明优化方向要从“减少计算”转向“减少访存”。再检查有没有不必要的中间张量写回、数据排布是否连续、有没有重复读同一片输入数据。还有一个容易被忽略的问题多核并行切分不合理。如果按输出高度切分某些区域采样热点集中某些区域几乎没有数据就会出现明显的负载不均。可以先统计一张典型输入下grid坐标的分布再根据分布去调整切分点把计算量均匀地分配给每个核。5.4 数据竞争与同步问题在自定义内核里如果直接用多核并行要注意共享数据的读写同步。排查时最典型的症状是输出结果时好时坏有时和参考一致有时出现随机噪声。这种问题在模拟器上不一定能复现只能上板反复跑。定位方法是先在单核模式下调通逻辑再开多核开多核后优先检查每个核写出的地址区间有没有重叠以及有没有在写回前完成必要的同步。我把高频问题整理成了一张速查表方便排查时快速对照现象可能原因首选排查手段转换报算子不支持工具链版本旧、ONNX节点被改写检查版本跑最小算子用例输出数值整体偏大/偏小量化参数未对齐对比量化scale和zero_point边缘出现错位align_corners处理不一致小尺寸tensor逐点对比性能差且带宽占满数据排布不连续、中间张量太多profiler看访存占用率输出随机噪声多核数据竞争单核先调通再逐步开核时好时坏、概率性错误同步缺失或DDR未刷新检查写回和同步屏障6. 最后再分享两个实测下来的细节第一点很多团队在优化gridsample时盯着计算单元利用率但我实际调试下来先优化访存顺序带来的收益往往比堆计算更快。原因是gridsample的算术强度本身不高如果数据都从DDR来计算单元跑再快也顶不住存取带宽。所以当profiler显示访存瓶颈时先改数据排布把随机读变成顺序读效果立竿见影。第二点量化精度问题不要只盯着算子内部。有时候给gridsample单独做高精度量化模型整体精度反而下降了因为前后层的量化参数也需要联动调整。我习惯把gridsample前后各一两层一起放进“高精度区间”统一使用更高位宽或浮点这样能避免量化误差在层间传递时被放大。如果你也在征程6上折腾gridsample建议先按这个思路做一次最小单元的验证再逐步扩展到整个网络。整个过程里最重要的不是某一个技巧而是建立一套“先定位、再优化、后验证”的闭环这样每次改动都能看到明确结果不会越调越乱。