gridsample这个算子,乍一看只是PyTorch标准算子库里一个不起眼的函数,但真到了车载芯片的部署环节,它能把人折腾得够呛。简单说,gridsample做的是按照一个坐标网格,从输入特征图上采样出新的特征图。这个东西在图像透视变换、光流对齐、BEV视角转换这些任务里几乎是标配,尤其在自动驾驶感知模型里,它经常出现在前处理、特征对齐、环视拼接以及各种warping模块中。这篇文章我会围绕地平线征程6芯片上gridsample算子的优化与部署实践来写,把原理、坑点、优化思路和实操过程都过一遍,也算给在这条路上摸索的同行们留一份参考。
1. gridsample算子到底在干什么
gridsample这个算子,第一次接触的人可能会被它的名字误导,以为就是个“采样”。其实它做的事情比采样要复杂一层:它不是机械地按固定步长取像素,而是根据外部传入的坐标网格,把输入特征图上“任意位置”的值取出来,组成一张新的特征图。这个“任意位置”很关键,因为坐标通常是浮点数,落在整数像素之间,所以必须靠插值来估算。
1.1 核心机制:采样坐标 + 插值
如果你看过Spatial Transformer Networks(STN)那篇论文,对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_mode="zeros") 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系列是面向高阶智能驾驶推出的新一代计算方案,核心是自研的BPU(Brain 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_corners=True和align_corners=False两种模式,一个是对齐到角点,一个是对齐到像素中心,结果可能差出半个像素。这个差异在浮点模型里不明显,但在量化模型里会导致肉眼可见的边缘错位,后面部署部分我会重点讲。
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 融合内核:减少中间张量的读写
杀手锏是把坐标变换、边界处理、插值、写回这几步融合在一个内核里完成,不再产生中间张量。这么做最直接的好处是省内存。假设输入特征图是1x64x128x128,grid是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_mode(zeros/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,建议先按这个思路做一次最小单元的验证,再逐步扩展到整个网络。整个过程里最重要的不是某一个技巧,而是建立一套“先定位、再优化、后验证”的闭环,这样每次改动都能看到明确结果,不会越调越乱。