商汤科技2018校招X86/ARM代码优化工程师笔试第二场,这个标题放在今天看依然很能打。当年这场笔试的题量和难度,放在当时的AI公司校招里算是相当有分量的。我身边好几个走高性能计算方向的朋友都去试过,出来之后普遍反馈:这套题不是刷几道LeetCode能应付的,它真正在考的,是你对CPU底层执行逻辑的理解。这几年我做过不少x86和ARM两个平台上的算子优化、推理引擎适配和性能调优工作,回头看这套笔试的考察点,基本覆盖了代码优化工程师日常工作中最核心的几个能力维度。
这篇文章我会把这场笔试涉及到的知识点、我当时和后来复盘时的解题思路,以及从笔试延伸到实际工程中的优化方法完整梳理一遍。内容按题型和考察方向拆开讲,每个部分都会结合具体的代码示例和性能分析思路来展开。无论你是准备投递类似的校招岗位,还是已经在做CPU优化相关的工作,这篇文章应该都能给你一些参考。
1. 笔试第二场的整体定位:不是考你会不会写,而是考你懂不懂硬件
先说一个很关键的判断:商汤这类AI公司的代码优化岗,笔试考察的逻辑和普通软件开发岗完全不同。普通开发岗重点看代码能力、数据结构和算法基本功,但代码优化岗写代码只是手段,核心考察的是你对硬件执行模型的理解深度。第二个是考察维度的完整性,不只是单纯看性能优化技巧,还看你会不会分析性能瓶颈。
从这场笔试的实际内容来看,题目大致可以分成三类:体系结构基础题、缓存与存储优化题、SIMD向量化与汇编优化题。每一类背后都对应着实际工作中必须掌握的能力。
我记得考场上发的试卷,前面的选择题和简答题占了差不多一半分值,涵盖了指令集架构差异、流水线行为、内存对齐、缓存一致性、编译优化选项这些内容。中间部分有几道代码分析题,要求你分析一段循环代码在x86和ARM上的性能差异,并给出改进方案。后面是两道编程题,一道和NEON intrinsics有关,另一道是给定一个图像处理算子要求在限定条件下做优化。
这类题目的核心不是考你会不会调用某个库函数,而是考你在没有现成工具的情况下,能不能自己分析出性能瓶颈在哪里,能不能找到正确的优化方向。很多人在学校写代码跑通就行,从来没想过同样的代码在x86和ARM上跑起来为什么性能差异那么大,在这类笔试里就会暴露得很彻底。
我当时做了个比较笨但有效的准备:把《计算机体系结构:量化研究方法》里的存储层次和流水线章节重新翻了一遍,又把ARM官方文档里关于NEON编程的部分大致过了一遍。事实证明这个方向是对的,因为这套笔试几乎没有考任何框架或库相关的内容,全部集中在处理器和编译器层面。
现在回看这套试卷,我会把它的知识体系拆成五个模块来讲:体系结构基础知识、缓存优化实战方法、NEON向量化编程、编译优化与反汇编分析,以及最后的综合调优案例。每个模块我都会结合具体的笔试题目和工程中的实际场景展开说明。
2. 体系结构基础:x86与ARM的分水岭在哪里
笔试选择题和简答题里,x86和ARM架构差异相关的内容占了相当比例。这类题看起来是送分题,但想答好、答到点子上,需要对两个平台的底层机制有清晰的认识。
2.1 指令集设计哲学:CISC与RISC的边界早就模糊了
传统的教科书会告诉你,x86是CISC,ARM是RISC。这句话没错,但如果你的理解停留在“x86指令多、ARM指令少”这个层面,在笔试里是会吃亏的。因为现代处理器的实现方式早就让这条边界变得模糊了。
x86指令长度不固定,从1字节到15字节都有。这意味着取指阶段的硬件复杂度更高,需要做指令长度解码、边界对齐等处理。而ARM指令在AArch32模式下是固定的32位(Thumb模式是16位),AArch64模式下依然是固定32位。固定长度指令的取指逻辑相对简单,这对功耗敏感的移动端芯片来说很有利。
但真正的分水岭体现在执行阶段的微码机制。x86处理器因为历史兼容性原因,很多复杂指令在硬件内部会被翻译成若干条微操作(micro-ops)再执行,比如rep movsb这种字符串指令。而ARM作为RISC,指令执行路径相对直接,大部分指令都能在一个周期内完成发射。这就导致一个非常实际的性能优化结论:在x86上,某些“看起来很聪明”的复杂指令未必比几条简单指令组合更快,因为微码翻译本身有开销;而在ARM上,指令选择和编码方式对性能的影响更直接,代码密度和指令数往往就是性能的一个重要因素。
这道题背后的考察点其实是:你有没有意识到指令集架构会直接影响代码的优化策略。比如在x86平台上,编译器经常会把一些循环优化成SIMD指令;而在ARM平台上,NEON向量化指令的使用方式和x86 SSE/AVX完全不同,寄存器布局、内存访问模式都有差异。
2.2 寄存器架构与调用约定的实战影响
寄存器架构也是笔试中常见的基础考点。x86-64有16个通用寄存器:RAX、RBX、RCX、RDX、RSI、RDI、RBP、RSP,加上R8到R15。ARM64下则有31个64位通用寄存器:X0到X30。寄存器数量直接影响函数调用时参数传递和局部变量的存放方式。
真实工程里这个差异影响是很大的。x86-64的System V调用约定规定前6个整数参数用RDI、RSI、RDX、RCX、R8、R9传递,而ARM64的AAPCS64标准则用X0到X7传递前8个参数。这意味着一个函数如果有6个以上参数,在x86上可能要从第七个参数开始压栈,而ARM64还可以继续用寄存器传参。对于性能敏感的代码来说,减少栈访问永远是优化的重要方向。
另一个实际影响是局部变量使用寄存器的机会。写优化代码时,我们要尽量让热循环里的临时变量全部留在寄存器中,避免任何内存读写。寄存器数量越多,编译器就越容易做到这一点。所以在ARM64上调优时的压力相对比x86-32小很多,但和x86-64比,两个平台的寄存器调度差异还是需要分别对待。
还有一点很容易被忽略:ARM的NEON向量寄存器与通用寄存器是分离的,有32个128位寄存器V0-V31。而x86的SSE/AVX寄存器是独立的XMM/YMM/ZMM寄存器组(AVX-512下是32个512位寄存器)。这种差异会直接影响结构体的内存布局设计——如果你写的数据结构正好能映射到向量寄存器的访问模式上,性能会有明显提升;反之,如果数据结构设计得让向量化无从下手,后续再怎么调指令也没用。
2.3 内存对齐:x86能忍,ARM不能忍
内存对齐是体系结构题里的常客,也是笔试后很多人的丢分点。其实很好理解:CPU访问内存时,不是一个字节一个字节地读,而是按字(word)为单位读取。如果数据地址恰好落在CPU读取单位的边界上,一次就能拿到;如果跨了边界,就需要额外读取一次甚至两次,然后拼接。
x86为了兼容性,对非对齐访问做了硬件处理,所以即使你写int *p = (int *)(addr + 1); int val = *p;这种代码,程序不会崩溃,只是性能变差。但ARM不同,尤其是ARMv7之前的一些ARM处理器,非对齐访问在某些场景下会直接触发异常。即便到了ARMv8,非对齐访问的性能代价也远高于x86。
笔试里可能会给你一段访问结构体成员的代码,问你在x86和ARM上的运行差异。比如:
struct Data { char a; int b; char c; };这是个典型的陷阱题。编译器为了对齐,会在a后面填充3个字节,在c后面填充3个字节,结构体大小是12字节。如果你知道这个布局,就能理解为什么结构体字段顺序会影响内存占用和访问性能。把字段按从大到小排列,比如把int放前面,char放后面,结构体就可能从12字节压缩到8字节。
我当时整理了一张表,可以直观地看出常见数据类型在不同平台上的对齐要求:
| 数据类型 | x86-64默认对齐 | ARM64默认对齐 | 说明 |
|---|---|---|---|
| char | 1 | 1 | 按字节访问无压力 |
| short | 2 | 2 | 对齐要求低 |
| int | 4 | 4 | 对齐不足时x86降速、ARM风险更高 |
| long long | 8 | 8 | ARM64下未对齐可能导致效率断崖 |
| float/double | 4/8 | 4/8 | 向量化时对齐要求更严格 |
笔试里如果你能直接写出这段分析,说明你对体系结构的理解不是背书背出来的,而是真的知道对齐问题在什么场景下会冒出来。这类细节在实际项目里非常常见,尤其是做序列化、网络协议解析或者自定义二进制格式的时候,结构体布局稍有差错,性能就会很难看,甚至直接崩溃。
3. 缓存优化:x86和ARM共同的性能命门
笔试的第二大考察方向上,代码分析和性能优化题目很大概率会涉及缓存。处理器发展到现在,单核频率早就摸到了天花板,性能提升主要靠多核和SIMD,但无论哪个平台,内存访问速度都远慢于CPU执行速度。这就是缓存存在的意义。
3.1 先从局部性原理说起
笔试里一个经典问题是:给定一个二维数组,按行遍历和按列遍历性能差别大吗?答案是差别巨大。
如果你写过类似的代码,一定会对这个问题有直观的感受:
// 按行遍历,约xx毫秒 for (int i = 0; i < N; i++) { for (int j = 0; j < N; j++) { sum += a[i][j]; } } // 按列遍历,耗时可能是上一种的10倍以上 for (int j = 0; j < N; j++) { for (int i = 0; i < N; i++) { sum += a[i][j]; } }原因在于C/C++的二维数组是按行存储的。按行遍历时,a[i][0]到a[i][N-1]在内存中是连续的,每次CPU加载一个缓存行(通常64字节)到L1缓存,可以覆盖16个int,后续访问基本都命中缓存。而按列遍历时,每次访问a[i][j]都要跨一整行,也就是跨越了N*4个字节,即使第一次访问加载了包含这个元素的缓存行,下一次访问a[i+1][j]时又隔了老远,缓存行几乎每次都浪费掉。
笔试的考点不止是告诉你“按行遍历快”,而是问你能不能分析出为什么快、快多少。也就是要你理解缓存行、命中率、以及访问模式如何影响性能。我在工程里遇到过不少人,一提到优化就想着加多线程、加SIMD,但连循环遍历顺序都没调整,这其实是优先级就搞反了。
3.2 循环分块:让缓存待得住数据
笔试中的代码优化题经常会给你一个矩阵乘法的例子。朴素的三重循环写法,计算量是O(N^3),但如果数据规模超过L2缓存容量,不断访问内存就成了主要瓶颈。一个非常有效的优化技巧是循环分块(loop tiling / blocking)。
矩阵乘法C = A * B,朴素写法是这样的:
for (int i = 0; i < N; i++) { for (int j = 0; j < N; j++) { float sum = 0.0f; for (int k = 0; k < N; k++) { sum += A[i][k] * B[k][j]; } C[i][j] = sum; } }内层循环要访问A[i][k]和B[k][j]。问题在于B[k][j]是按列访问的,空间局部性很差。这里可以用一个简单的技巧:先将B转置为B_T,让内层循环按行访问B_T,这样空间局部性就好了很多。更进一步,可以引入分块,让子矩阵在缓存中能被重复利用:
#define BLOCK_SIZE 32 for (int i0 = 0; i0 < N; i0 += BLOCK_SIZE) { for (int j0 = 0; j0 < N; j0 += BLOCK_SIZE) { for (int k0 = 0; k0 < N; k0 += BLOCK_SIZE) { // 计算 C[i0:i0+B][j0:j0+B] += A[i0:i0+B][k0:k0+B] * B[k0:k0+B][j0:j0+B] for (int i = i0; i < i0 + BLOCK_SIZE; i++) { for (int j = j0; j < j0 + BLOCK_SIZE; j++) { float sum = C[i][j]; for (int k = k0; k < k0 + BLOCK_SIZE; k++) { sum += A[i][k] * B[k][j]; } C[i][j] = sum; } } } } }分块的核心思想是:在一个块内,A和B的子矩阵都能装进L1/L2缓存,内层循环反复访问这些子矩阵时都能命中缓存。BLOCK_SIZE的选择通常要根据具体平台的缓存大小来定,比如L1数据缓存64KB、A和B子矩阵各占一部分,还要留出余量给其他数据。笔试中如果能根据缓存容量推算出合适的分块大小,是非常加分的回答。
我自己实际调优的时候,一般会先做一次性能基线测试,再用perf工具统计cache miss率,然后尝试不同的BLOCK_SIZE(16、32、64),观察哪个数值下miss率最低。不同处理器的最优块大小差异很大,官方文档或者论坛帖子给的数值只能当参考,一定要自己跑一遍验证。
3.3 伪共享:多线程优化里最隐蔽的性能杀手
笔试简答题里有一类题和多线程性能相关,如果只是回答加锁、加线程池这些,分不会太高。真正有区分度的问题是:多线程下性能没有线性扩展,反而还下降了,让你分析原因。
伪共享(False Sharing)是这种情况下最常见的元凶之一。它发生在多个线程操作不同变量,但这些变量恰好位于同一个缓存行内。每个线程修改自己的变量时,为了保持缓存一致性,整个缓存行都会被标记为失效,导致其他线程访问自己变量时被迫重新加载缓存行。多线程之间就这样你等我、我等你,性能自然上不去。
笔试里如果给出如下代码,让你找问题:
struct ThreadData { int thread_id; long count; }; ThreadData data[THREAD_NUM]; void worker(int tid) { for (int i = 0; i < LOOP_NUM; i++) { data[tid].count++; } }这里的data数组里每个线程的count,可能落在同一缓存行中。看起来每个线程只改自己的字段,但缓存一致性协议会在缓存行级别同步,导致各线程相互拖慢。
解决办法也很直接:让每个线程的数据独占缓存行,即使用内存对齐填充。
struct ThreadData { int thread_id; long count; char padding[56]; // 使结构体大小为64字节,对齐一个缓存行 };这样每个线程的数据占满一个完整的缓存行,修改自己的数据不会影响别人的缓存行。笔试中能答出这个知识点,并且知道缓存行大小通常是64字节,能给出填充方案,说明对多线程性能的理解是比较到位的。
我在写多线程算子时,也踩过这个坑。早期一个并行图像处理算子,4线程加速比只有1.8倍,怎么调都上不去。后来一看热点,线程间共享的那批控制结构体正好挤在两个缓存行里,做了对齐填充后,加速比直接跳到3.5倍以上。这类问题如果不借助profiling工具,真的很难从代码里一眼看出来。
4. NEON向量化:ARM代码优化的核心战场
如果说体系结构和缓存是笔试里的“基础题”,那NEON向量化就是真正的分水岭。商汤这类AI公司之所以看重ARM优化,是因为大量推理场景跑在ARM平台上——手机、嵌入式设备、边缘计算盒子。NEON是ARM平台最常用的SIMD指令集,笔试和面试都会重点考察。
4.1 NEON到底是什么,和x86 SIMD有什么区别
NEON是ARM的SIMD扩展指令集,最早在ARMv7中引入,到了ARMv8(AArch64)成为必选特性。它提供128位向量寄存器,可以一次处理多个数据。NEON支持的数据类型包括8位、16位、32位、64位整数,以及32位、64位浮点数。
x86的SSE/AVX在寄存器宽度、指令语义上都有差异,最大的区别在于编程模型。x86下你可以直接用内联汇编或intrinsics写SSE,但ARM下用NEON intrinsics要关注类型系统:NEON的intrinsic类型int32x4_t、uint8x8_t等,在C代码中看起来像普通类型,但它们会被直接映射到NEON寄存器上,编译器会帮你调度寄存器的分配。
笔试中的编程题,往往从这几种操作里选一种让你写:像素值范围裁剪、图像缩放、矩阵乘、SAD(绝对差值和)、卷积运算。以像素转灰度为例,一张RGB图像转灰度图,最原始的标量代码长这样:
for (int i = 0; i < n; i++) { gray[i] = (r[i] * 77 + g[i] * 150 + b[i] * 29) >> 8; }NEON版本可以一次处理8个像素(8路8位数据)或者4个像素(4路16位数据),中间避免标量循环里的乘法溢出和重复转换:
// 简化的伪代码,实际需要处理数据的加载和存储方式 uint8x8_t r_vec = vld1_u8(r_ptr); uint8x8_t g_vec = vld1_u8(g_ptr); uint8x8_t b_vec = vld1_u8(b_ptr); uint16x8_t r16 = vmull_u8(r_vec, vdup_n_u8(77)); uint16x8_t g16 = vmull_u8(g_vec, vdup_n_u8(150)); uint16x8_t b16 = vmull_u8(b_vec, vdup_n_u8(29)); uint16x8_t sum = vaddq_u16(vaddq_u16(r16, g16), b16); uint8x8_t gray_vec = vshrn_n_u16(sum, 8); vst1_u8(gray_ptr, gray_vec);这段代码里,vmull_u8把两个8位向量相乘并扩展成16位结果,避免乘法溢出。vshrn_n_u16把16位结果右移8位并缩窄回8位。这就是典型的NEON处理流程:加载、扩展、计算、缩窄、存储。
笔试里不要求你写完全可编译的代码,通常伪代码或者intrinsics片段就可以。但关键在于你要知道为什么用vmull而不是vmul,为什么要先扩展精度再计算。答出这一点,面试官就能确认你是理解数据位宽和计算精度之间的取舍,而不是背了几个接口。
4.2 图像处理里的NEON实战:以5x5卷积为例
5x5卷积在图像处理里非常常见,比如高斯模糊、边缘检测。笔试编程题如果出到卷积,一般不会要求你用im2col+GEMM这套(那是算法题),而是希望你能用NEON有效地处理卷积窗口的加载和乘加。
一个常见的优化思路是:对中心行和上下行分别用NEON做乘加,最后累加。对于5行5列的卷积核,把每一行看成独立的权重序列,对输入图像的每一行,用NEON做水平方向上5个像素的乘加,5行累加。
这里有个容易踩的坑:边界处理。NEON一次加载8个像素时,靠近图像边界的地方可能会越界。通常的解决办法是:中心区域用NEON处理,边界区域退回标量逻辑。这个思想在笔试里也很重要,因为代码的鲁棒性和性能一样是评分点。
我见过有人直接在循环里用vld1q_u8加载数据,没考虑边界,程序一跑就崩。正确做法是先计算安全区间,把NEON限制在区间内执行:
int start = radius; // 比如5x5卷积,radius=2 int end = width - radius; // 向量化处理[start, end)区间 // 边界单独用标量代码处理这种思路实际工程中也非常常用,不管是做图像金字塔、滤波还是目标检测的前处理,都需要处理边界。
4.3 编译器能自动向量化,你还需要手写NEON吗
笔试里有一类简答题会问:现代编译器都有自动向量化能力,为什么还需要手写NEON intrinsics?这类题考察的是你对编译器行为的理解。
编译器确实能自动向量化很多简单循环,比如:
void add(const float *a, const float *b, float *c, int n) { for (int i = 0; i < n; i++) { c[i] = a[i] + b[i]; } }只要加上-O3和合适的编译选项,编译器就可能生成NEON指令。但现实中很多循环是编译器无法自动向量化的,比如包含函数调用、分支判断、数据依赖、非对齐访问的循环。比如下面这段:
for (int i = 0; i < n; i++) { if (data[i] > threshold) { data[i] = threshold; } }分支的存在让编译器难以自动向量化,而NEON可以先把数据比较生成掩码,再用掩码做选择性赋值,从而把带分支的代码改成无分支的向量化代码。这就是手写NEON的价值:处理编译器不敢处理的复杂场景。
另一个手写的理由是:NEON指令顺序对性能影响极大。编译器自动生成的向量化代码,不一定能按最优顺序排列指令。特别是涉及指令间数据依赖时,手写可以控制加载、计算、存储的先后顺序,减少流水线气泡。
不过我也要说一句:手写NEON不是目的,而是手段。实际项目中,应该先开编译器自动向量化,跑benchmark,再用profiling确认瓶颈,最后才考虑对热点函数手写NEON。盲目手写所有代码,既费时间也不一定比编译器更好。
5. 编译优化与反汇编:笔试里容易被忽略的送分题
笔试中出现过这样的题目:给一段C代码和对应的汇编代码,让你分析编译器做了什么优化,或者问用某个编译选项会有什么效果。这类题是真正能拉开分差的——很多选手会写代码,但不一定看得懂汇编。
5.1 优化选项不是越高越好
-O0、-O1、-O2、-O3这些优化等级,笔试会考,但很少有考生能答出本质区别。简单来说:
-O0:不优化,适合调试,变量基本都能在调试器里看到。-O1:做基本的局部优化,去掉一些不必要的内存访问。-O2:包含大多数常见优化,是Linux内核和多数项目默认采用的标准。-O3:在-O2基础上增加更激进的优化,比如向量化、函数内联等,但可能增加代码体积,导致指令缓存命中率下降。
笔试的进阶问题是:为什么不是所有代码都适合开-O3?原因之一是-O3开启的某些优化(比如GCC的自动向量化)在某些数据依赖复杂或内存不对齐的场景下,生成代码反而更慢;另一个原因是代码体积膨胀影响指令缓存。工程中我一般默认用-O2,只有分析过热点后才对特定源文件开启-O3。
还有一个常考的选项是-march和-mtune。-march=native会让编译器使用当前CPU支持的所有指令集扩展,这在本地编译本地运行的场景下很好用;但如果你需要把二进制分发到不同的机器上运行,就不能用nativ,而是要指定一个兼容的架构级别。ARM上对应的选项是-mcpu=cortex-a72或者-march=armv8-a+simd这类形式。
5.2 会读汇编的优化工程师有多吃香
笔试中给出一段反汇编代码让你分析,目的是考察你能不能理解编译器的输出。比如下面的简单函数:
int add(int a, int b) { return a + b; }x86-64下的汇编可能是:
add: lea eax, [rdi + rsi] retARM64下可能对应:
add w0, w0, w1 ret看起来很简单,但其中包含了很多信息:x86用lea做加法,ARM直接用add指令;参数传递方式分别是rdi/rsi和w0/w1。如果笔试给你稍微复杂的循环,比如带有乘加的代码,你需要能看出编译器是否做了循环展开、是否用了向量寄存器、是否存在冗余的内存访问。
我在实际项目中,遇到性能问题时经常会做这一步:把热点函数的汇编拉出来,看有没有明显的效率问题。比如当编译器生成了一段反复加载同一个内存地址的代码,说明局部变量没有被正确地保留在寄存器中,这时可以通过调整源码结构(比如把*指针解引用存到局部变量)来帮助编译器做优化。
建议大家准备这类题目时,搞一台Linux机器,用gcc -O2 -S把C代码转成汇编,多读一读。读汇编不是要逐行背,而是建立起C代码和机器指令之间的映射关系,这样笔试里看到反汇编才不会慌。
6. 从笔试到工程:代码优化工程师的真实工作流
笔试考的是知识,但真正的功夫在考场之外。一个代码优化工程师在实际项目里,面对的是一个完整的、复杂的问题,需要一整套系统化的方法。我从自己做过的几个项目里提炼出几点经验,这些和笔试的考察点一脉相承。
6.1 先profile,再优化,不要拍脑袋
笔试里你可能没有工具可用,只能在纸上推导。但真实工程中,第一步永远是profiling,而不是凭经验猜。x86平台上常用的工具是perf和Intel VTune,ARM平台上可以用perf配合oprofile,或者直接用ARM提供的Streamline。
我曾经优化过一个视频前处理模块,刚开始看到代码里有一个耗时的循环,第一反应是手写NEON。结果profile之后发现,真正耗时的地方不在计算,而在一处频繁的memcpy,数据量很大但内存布局又散。改成合并拷贝后,性能提升了40%,根本不需要动NEON。
这就说明了笔试和工程的一个共同点:优化的核心不是炫技,而是找到真正的瓶颈。笔试里的代码分析题,本质就是让你在没有工具的情况下模拟这个过程。
6.2 用数据说话:benchmark的正确姿势
笔试简答题可能会问:你用一个优化方法后,怎么验证性能提升?很多人回答“看时间”,但专业做法是写benchmark,并且控制变量、采样多次、取中位数或平均值,而不是取第一次运行结果。
因为CPU有频率动态调整、缓存预热、分支预测器自适应等机制,第一次运行和后续运行性能差异很大。正确的做法是:
// 先预热 run_benchmark(); // 再正式测量,多次执行取平均值 for (int i = 0; i < N; i++) { start = timer(); run_benchmark(); end = timer(); total += end - start; } printf("avg = %f ms\n", total / N);还有一个容易被忽略的点:关掉CPU频率调整或者记录下来。否则不同工况下跑出来的数据完全不可比。做性能对比时,要让对比的两个版本在相同的环境下运行,比如同样的频率策略、同样的核心数、同样的输入数据。
笔试里如果提到这个,会给面试官留下一个印象:你不是只会写代码,你是真的做过性能调优。
6.3 向量化不是银弹:数据布局先行
经常有人问我:为什么我用了NEON,性能还是上不去?细看代码后发现问题就出在数据布局上。
NEON一次处理128位数据,也就是16个uint8或者4个float。如果你的数据在内存里是“每隔几个字节取一个”,那NEON需要做多次加载和数据重组,效率反而不如标量。一个典型的场景是RGB图像和Planar图像(如YUV420)的相互转换。RGB是交错存储的,每个像素三个通道挨在一起,而Planar格式是把所有Y放一起、所有U放一起、所有V放一起。使用NEON处理Planar数据比处理RGB数据快得多,因为可以连续加载大量同类型元素。
所以笔试编程题里如果给你RGB数据转灰度的任务,如果你的方案能先把RGB拆分成三个独立的通道(或者用NEON的vld3指令一次搞定三通道解交错),这就是加分项。vld3_u8这种指令可以一次加载3个通道的数据,内部自动完成解交错,是专门为图像像素交错存储设计的。能写出这个方案,说明你知道数据布局对向量化的关键影响。
笔试里还有一类题会让考生分析某种数据布局的优劣,比如结构体数组(AoS)和数组结构体(SoA)。你可以记一个简单的经验法则:如果不涉及向量化,AoS代码写起来更自然;一旦要做SIMD优化,SoA几乎是唯一选择,因为SoA才能让连续内存上的同类数据成批处理。
7. 笔试之外:AI公司代码优化岗位的实际工作场景
聊完了笔试本身,再聊聊这个岗位后续要面对的真实工作,因为很多人是被笔试题目“吓”到了,但实际工作中的问题往往更有意思,也更复杂。
商汤这类AI公司,代码优化工程师的工作范围大致可以分为几块:
- 基于CPU的推理引擎优化:把训练好的模型部署到CPU上,优化卷积、全连接、激活函数等算子的执行效率。
- 多平台适配与移植:同一个模型,可能要跑在x86服务器、ARM手机和嵌入式设备上,需要针对不同平台做差异化的算子实现和调度策略。
- 算子库开发:相当于自己公司内部的“OpenCV”或者“oneDNN”,把高频算子打磨到极致,供上层算法团队调用。
- 与编译器团队或工具链配合:有些公司会和编译器厂商合作,或者自研部分工具链,优化工程师要理解编译器的行为,甚至在必要时写汇编、内嵌汇编、修改生成代码。
笔试考到的NEON、缓存、内存对齐、编译选项,在这些工作中都是基本功。你可能会觉得ARM和x86跟AI关系不大,但实际上绝大多数推理场景是跑在CPU上的,尤其是边缘设备和手机端,CPU上的算子性能直接决定了产品的体验。
我在优化一个ARM平台上的检测模型时,曾经把一个3x3卷积算子从baseline的80ms优化到35ms,技巧无非就是:先用profiling找到热点,再做数据布局变换(NHWC转NHWC对齐),然后用NEON实现核心计算,最后调整循环分块大小以适应缓存。这些步骤,跟笔试里那道编程题的思路完全一致——变的是问题规模,不变的是方法论。
8. 如果你要准备这类笔试,我建议你这样复习
最后给要参加类似笔试的同学一些具体的复习建议。这不是应试技巧,而是我认为真正有效的学习路径。
8.1 把体系结构基础打牢
笔试前,先把《深入理解计算机系统》第3章到第6章认真读一遍,尤其是存储层次、汇编、链接这些章节。这本书的练习题很多可以直接用来磨手感。对ARM平台,再去读ARM官方文档里关于NEON和缓存架构的部分。不需要全部背下来,但至少要知道在自己工作的那部分硬件上,数据是怎么流动的。
8.2 亲手写NEON,跑benchmark
看书一百遍,不如手写一遍。拿一个简单的图像处理任务,比如灰度化、缩放、色彩空间转换,先用标量写法做基线,再用NEON intrinsics改写,最后量一下加速比。这个过程中你一定会踩到内存对齐、边界处理、类型转换的坑,踩坑本身就是学习。
8.3 学会用工具,但不能依赖工具
会用perf、VTune、Streamline这些工具很重要,但在笔试中你必须能纸上谈兵地分析一段代码的性能问题,因为考场上没有这些工具。我的建议是:平时做优化时,自己先猜瓶颈在哪,再上工具验证,形成“分析->猜测->验证->修正”的正循环。这样到了笔试现场,看到一段代码,脑子里就能快速建立起性能模型,而不至于两眼一抹黑。
9. 写在最后:代码优化是一场持久战
做代码优化这行,核心能力不是你会多少指令,而是能不能通过一层层抽象,看清程序在硬件上到底是怎么跑的。笔试只是把这种能力浓缩在两个小时里而已。商汤那场笔试已经过去好几年,但现在看来,里面考察的知识点依然不过时——因为CPU的基本工作原理没有变,缓存、流水线、SIMD这些机制还是决定程序性能的底层因素。
如果你正准备类似的校招笔试,我的建议是别只刷题,多花点时间理解硬件。每学到一个优化技巧,就问自己一句:它到底在优化哪个硬件瓶颈?是减少了缓存未命中,还是提高了指令级并行,还是降低了分支预测失败率?想清楚这个问题,你写出的优化代码就不再是套模板,而是真正有针对性的性能工程。