ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

商汤科技校招笔试解析:X86/ARM代码优化核心技巧

商汤科技校招笔试解析:X86/ARM代码优化核心技巧 商汤科技2018校招的技术笔试当时在圈子里讨论度不低。尤其是X86/ARM代码优化工程师这个方向第一场笔试就筛掉了不少人。我印象最深的不是题目有多难而是它考察的东西非常贴近实际生产环境——不是让你背诵什么是缓存一致性而是真给你一段代码让你在有限时间内把它改到尽可能快。这种风格在当年的AI公司校招里算是相当硬核的。这篇文章就围绕那场笔试展开。我会先拆一下这类笔试题背后的考察逻辑然后还原几道最具代表性的题目给出完整的优化思路和关键代码。不管你以后是想投商汤还是投其他做AI推理、底层加速的团队这篇文章里的东西应该都能用得上。1. 笔试背后的考察逻辑与整体设计1.1 为什么代码优化岗位要考X86和ARM两种架构很多同学一开始不理解代码优化为什么要同时考X86和ARM能做好一个架构不就行了吗实际上做AI算法落地的人都知道模型训练基本都在X86服务器上跑但真正部署到手机、摄像头、边缘盒子这些设备时几乎清一色是ARM。一个合格的代码优化工程师必须同时懂这两边的指令集差异、内存模型差异和工具链差异。笔试第一场直接把两个架构放在同一张卷子里本质上是在考察你的迁移能力。X86上你写SSE/AVXARM上你写NEON两者思路类似但细节完全不同。如果你只会在PC上优化到了ARM平台就无从下手那肯定不行。反过来只熟悉ARM不懂X86也没法胜任训练侧和推理侧的联调工作。1.2 笔试形式与题目结构那次笔试是限时在线答题大概两个小时题目以代码填空、代码改错和手写优化代码为主。不同于普通算法岗那种“给你一道LeetCode写个最优解”的形式代码优化岗的题目多半会给你一段已经能正确运行的代码但性能很拉胯要求你运用各种优化手段把耗时降下来。题型大致分为四类基础概念题考察Cache、内存对齐、指令集、编译选项等底层知识。代码改错题给出一段有明显性能陷阱或未定义行为的代码让你指出问题并修正。手写优化题给一个具体场景比如图像处理、矩阵运算要求你用SIMD或缓存优化方式实现加速。综合题设计一个优化方案并说明为什么这个方案在目标平台上有效。第一场考试里手写优化题占了大头。因为这类题最能直接反映一个人的工程功底代码写出来能不能编译通过、性能对不对一眼就能看出来。1.3 如何围绕“第一场”做准备如果只准备套路刷题很容易在第一场栽跟头。因为这和刷算法题完全是两个套路算法题追求时间复杂度的量级提升代码优化题追求的是常数级别甚至数个百分点的极致压榨。所以准备时不能只靠刷题更要靠平时写代码时的积累。我自己当时的准备重心是三个方面一是把X86的SSE/AVX指令和ARM的NEON指令过了一遍重点记住它们的加载、计算、存储模式和适用场景二是重新梳理了存储层次理解了为什么某些代码看起来操作更少实际却更慢三是练习了用反汇编和性能分析工具去定位热点。这三样东西几乎覆盖了笔试中所有核心内容。2. 核心优化基础知识拆解2.1 从数据带宽说起Memory Wall 与 Cache 优化笔试题目里有很多优化点最后都会落到一个核心问题上你的代码是算得快还是数据拿得快现代CPU的运算速度远远高于内存访问速度程序瓶颈往往不在ALU而在内存总线上。这就是常说的Memory Wall。优化代码之前首先要判断这段代码是计算密集还是访存密集不同的密集类型对应完全不同的优化策略。举个例子一段循环体内只有一个乘加运算的代码听起来应该是计算密集但如果你每次循环都去读一个在大数组里跳跃访问的元素那内存等待时间会把计算时间彻底淹没。这类问题的通用解法是提高数据的空间局部性尽量让相邻的循环迭代访问相邻的内存地址让Cache的预取机制生效。Cache优化里最经典的手段是循环分块Loop Tiling。比如矩阵乘法如果按照常规三层循环去写B矩阵的访存跨度很大Cache命中率极低。通过把矩阵划分成小块让子块能够完整存放在L1/L2 Cache里性能提升可以非常明显。笔试中有一道矩阵转置题本质上就是在考这个点。2.2 SIMD指令集SSE/AVX与NEON说到代码优化SIMD是绕不开的。X86平台上从SSE到AVX2再到AVX-512一条指令可以同时处理多个数据。ARM平台上对应的就是NEON寄存器宽度可以是64位或128位同样能实现数据级并行。笔试第一场里SIMD的题目比重很高。因为AI推理里大量卷积、池化、归一化操作都是逐像素或逐通道进行计算非常适合SIMD加速。但要注意SIMD不是一个“用了就一定快”的万能药它对数据的组织方式有严格要求。我记得有一道图像灰度化题最直接的写法是对每个像素做RGB三个分量的加权求和。如果用普通C语言写编译器在默认优化级别下可能会自动向量化但自动向量化的效果通常不如手写指令好。手写NEON时需要先把像素数据交错读取成向量格式再用乘加指令一次处理8个像素。这种代码写起来需要一定的技巧也需要对指令集足够熟悉。2.3 ARM交叉编译与嵌入式环境笔试题目里还涉及了ARM环境相关的知识比如交叉编译和模拟器。很多同学平时可能只碰过X86服务器对ARM开发板并不熟悉突然遇到交叉编译的题会懵。所谓交叉编译就是在X86的PC上编译出ARM架构可执行的二进制文件这个二进制不能在PC上直接运行需要拷到ARM平台上执行。笔试里可能会给出一个用GCC编译的命令行问你其中的-march、-mfpu、-mfloat-abi等参数有什么含义。又或者让你说明如何用QEMU模拟ARM环境来验证优化效果。这些知识点如果平时没接触过很容易丢分。另外嵌入式平台的资源限制也是一个常见考点。ARM开发板上Cache通常比PC小内存带宽也更低很多在PC上表现良好的优化策略在ARM上会失效。笔试中会通过一些限定条件来考察你是否有这种平台差异意识。2.4 编译器优化选项与反汇编分析代码优化不只是靠手写汇编很多时候要懂得跟编译器配合。GCC/Clang都提供了一系列优化选项从-O0到-O3还有-ffast-math、-funroll-loops、-flto等。但盲目开高优化级别不一定对因为有些优化会改变数值精度有些优化会让代码体积膨胀反而不利于指令Cache。笔试第一场有题专门考察编译器优化选项的作用。比如-O2和-O3的区别-marchnative会引入哪些指令集以及-mno-avx在什么场景下更合适。这类题说到底是考你对编译器的理解有多深。反汇编分析也是必须掌握的技能。笔试题目里曾经给出一段汇编代码让你推断对应的C代码逻辑或者反过来给出一段C代码让你分析为什么编译出来的汇编冗余。这种题对读汇编的能力要求比较高但也恰恰是代码优化工程师日常工作中最常做的事情之一。3. 真题实战五道典型题目的完整拆解3.1 图像像素平均值计算从循环展开到NEON第一场笔试里有一道很典型的图像处理题给一张width * height的灰度图数据类型为uint8_t要求计算所有像素的平均值并把结果输出为一个浮点数。基础代码非常简洁就是一个两层循环累加。但问题在于如果图片是4000x3000这种尺寸这个简单循环的效率其实很一般。我的优化思路分三步走。第一步是循环展开。4路展开能让CPU的指令流水线更充分地利用起来减少循环控制带来的分支开销。展开后累加操作可以形成指令级并行。第二步是防止累加溢出。uint8_t的最大值是2554000x3000的像素总数是1200万如果直接用int累加很容易溢出。笔试题目要求返回浮点均值所以累加适合用uint32_t或者uint64_t。我当时用了一个相对巧妙的办法先把每4个像素作为一组加载到一个uint32x4向量里用vaddw_u8指令扩展成16位后累加这样就可以避免频繁的进位。第三步就是NEON向量化。ARM平台核心代码大致是这样uint32_t sum 0; uint8x8_t v; for (int i 0; i total; i 8) { v vld1_u8(src[i]); vsum vaddw_u8(vsum, v); // 将8个8位扩展到16位后累加 }这个写法在X86上对应的就是SSE2的_mm_loadu_si128和_mm_sad_epu8思路完全一致。笔试时你要做的就是写出这个核心循环并解释为什么它能加速。关键点是一次处理8个或16个像素累加使用了向量寄存器的并行能力同时减少了对内存的访问次数。3.2 矩阵转置的缓存友好实现矩阵转置这道题乍一看很简单就是B[j][i] A[i][j]。但真放到大矩阵场景下这种写法性能惨不忍睹。原因在于A是按行访问B是按列写入两者总有一个方向的Cache命中率非常低。笔试第一场里给出的矩阵是1024x1024的float数组。常规写法是两层循环外层遍历i内层遍历j。这样B的列访问跨度很大每次写入都要把一整行Cache line替换出去相当于每次访问都发生Cache miss。优化手段我用的是分块转置。把矩阵分成8x8或16x16的小块一次转置一个小块这样两个矩阵的访问都保持在小范围的内存地址内Cache利用率大幅提升。核心逻辑如下const int BLOCK 16; for (int i 0; i N; i BLOCK) { for (int j 0; j N; j BLOCK) { // 转置块 for (int ii i; ii i BLOCK; ii) { for (int jj j; jj j BLOCK; jj) { B[jj][ii] A[ii][jj]; } } } }选16x16的块大小是综合考虑了L1 Cache的容量。一个float是4字节16行x16列同时占用两个矩阵的存储总共需要2x16x16x42048字节加上其他开销完全能放进典型的32KB L1数据Cache。如果块选得过大比如64x64那么需要32KB可能会把Cache塞满。笔试时如果能把块大小和Cache容量的关系写清楚会很加分。3.3 条件分支的代价与查表法第三道题考察的是分支预测。题目给了一段函数统计一个数组中大于等于128的元素的个数。常规写法是循环加if判断但数据是随机分布的分支预测器在遇到这种随机分支时预测准确率接近50%性能开销很大。笔试的要求是“用优化手段减少分支惩罚”我当时用的是基于SIMD的掩码比较法这比查表法更通用。X86 SSE2的实现思路是把数组元素加载到__m128i向量中用_mm_cmpgt_epi8和128做比较会生成一个掩码每个字节为0xFF表示大于0x00表示不大于。再用_mm_sad_epu8把掩码累加为16位的绝对差和最后横向求和。这里的核心技巧是把“比较”转化为“掩码”从而把分支判断消除掉。CPU不再需要预测分支全部走SIMD数据通路。这种思路在ARM上同样适用NEON的vcgtq_u8指令就能完成对应的比较逻辑。我当时还写了一份查表法作为备用把每4位一组作为索引预先算好这个4位二进制模式中1的个数然后循环查表。对于位数较短的数据查表法的效果也很好但通用性不如SIMD掩码法。3.4 内存对齐与SIMD加载内存对齐这道题很多人会忽略。当时题目给了一个结构体数组每个结构体包含三个float和一个char。要求用SSE加载结构体中的float字段做向量化加法。写代码时如果不注意对齐直接使用_mm_load_ps可能会触发未定义行为甚至崩溃正确做法是用_mm_loadu_ps非对齐加载或者把结构体重新组织成SoA形式。这里考察的是AoSArray of Structures和SoAStructure of Arrays的选择问题。如果数据是AoS布局那么每个结构体内部的字段在内存中可能不连续SIMD加载效率很低。更好的做法是把三个float字段分别拆到三个独立数组中变成SoA布局这样每个数组内部都是连续同类型数据可以直接做向量化。笔试那道题的耗时点就在这里如果你没有意识到内存布局问题那后续代码写得再花哨也没用。我最后提交的答案是先将结构体数组转成三个float数组再用SSE做加法。虽然多了一次转置的开销但后续的批量运算速度更快总体上收益明显。3.5 在x86上模拟ARM性能分析的陷阱最后一题比较综合给了一段ARM NEON代码要求你在X86环境下分析其性能并给出优化建议。很多人一看就懵因为NEON代码在X86上根本跑不了。这时候需要你利用QEMU模拟ARM环境配合交叉编译工具链来分析和验证。笔试考察点分为三个层次第一个层次是知道怎么搭建环境。用qemu-aarch64模拟ARM64的用户态程序需要先安装交叉编译器aarch64-linux-gnu-gcc然后编译出ARM架构的二进制文件再用QEMU执行。第二个层次是理解性能分析工具的局限。QEMU模拟出来的执行时间只能作为参考不能完全等同于真实ARM硬件上的性能。因为QEMU的指令翻译执行引入了大量额外开销而且它的Cache行为模拟也不一定精确。所以笔试里如果让你用QEMU测得的耗时来判断NEON优化的效果你最好加上一句“只能做相对比较不能代表绝对时间”。第三个层次是代码层面的静态分析。即使没有真实环境你仍然可以从NEON指令的延迟、吞吐量以及内存访问模式上判断出代码的瓶颈在哪。我当时的分析思路是先看循环体内的指令依赖链再看加载和存储指令的比例最后结合NEON双发射特性判断是否存在资源冲突。这道题让我意识到笔试不只是在考你会不会优化更是在考你面对一个陌生平台时有没有一套自己的分析与验证方法。这比单纯写出一个快速函数重要得多。4. 笔试中的常见问题与排查技巧4.1 环境问题编译器报错与路径配置实际笔试时很多人不是死在做题上而是死在环境配置上。在线OJ的编译环境往往很严格头文件路径、编译选项、架构参数稍有不对编译就失败。第一场笔试里我遇到过类似error: failed to retrieve msvc environment这样的问题。这不是代码逻辑的问题而是编译器环境变量没有配置好。在Windows上做C/C优化题尤其容易踩这个坑因为MSVC和MinGW的工具链行为不同SIMD intrinsic的头文件引用方式也不同。我给的建议是笔试前一周先在自己电脑上把X86和ARM交叉编译环境都搭好用-marchnative编译一个空的main函数确保能独立生成可执行文件。再准备一个小工具函数库比如做时间统计的chrono封装、做内存对齐检查的宏这样笔试时直接粘贴就能用能省下很多时间。4.2 性能测量如何正确计时和复现结果笔试测试环境里性能测量的准确性直接影响你的决策。如果计时方式不对优化前后的对比就毫无意义。我见过很多同学用clock()测时间但在多线程或高精度场景下这个函数的分辨率不够而且测量的是CPU时间不是墙上时间。正确做法是用std::chrono::high_resolution_clock并且要多次运行取最小值或中位数防止系统调度带来的噪音。另外优化后的代码很容易被编译器“进一步优化”成看起来什么都没做的样子。比如你计算了一个平均值却不使用结果编译器可能在-O3下直接把整段循环删掉。这时候需要想办法“消耗”掉计算结果比如把结果打印出来或者累加到一个全局变量里。笔试前我就提醒自己凡是手写优化题最后一定要留一个“输出结果”的步骤避免被优化器干掉了。4.3 踩过的坑伪共享、指令集兼容性、优化器“聪明过头”伪共享False Sharing是个非常隐蔽的坑笔试里如果涉及多线程优化很容易掉进去。你的两个线程分别操作不同变量但这两个变量恰好落在同一个Cache Line里那么任何一个线程修改它都会导致另一个线程的Cache Line失效性能断崖式下降。解决方法是给变量加对齐补丁让它们落到不同的Cache Line。指令集兼容性是另一个大坑。你在自己的机器上用了-mavx2编译并测试通过但平台只支持SSE4.2运行直接崩溃。笔试环境可能不支持AVX-512所以我一般只写SSE2或NEON代码保证通用性。如果题目明确说明目标平台支持AVX2再放心用。还有一点就是编译器“聪明过头”。你在代码里手写了某些看起来很快的循环但编译器可能把它改成了完全不同的实现。所以笔试答题时我习惯在关键代码旁加上注释告诉阅卷人“这里预期会生成什么指令”“这段逻辑是为了避免编译器做某种变换”。这么做既方便自己理清思路也能让阅卷人看到你的优化意图。4.4 现场排查与调试工具如果在笔试中遇到代码编译通过但运行结果不对的情况不要慌。第一步先确认是不是SIMD的边界条件处理出了问题比如数据长度不是16的倍数时尾部元素没有处理。这是一个非常常见的出错点。第二步是开启编译器警告看有没有类型转换或未定义行为提示。用-Wall -Wextra -Wpedantic编译大多数问题都能被提前发现。第三步是在手写代码里加调试打印或断言确认中间结果符合预期。Simd指令里的位宽和数据布局很容易让人头晕打印中间值能快速定位是不是数据装配得不对。如果题目允许使用工具可以用objdump或perf分析汇编和性能热点。笔试环境可能没有图形化工具但命令行工具通常都会预装。你把编译出来的可执行文件反汇编一下看看向量化指令是否真的生成了循环是否被展开就知道自己的优化到底有没有生效。5. 写在最后的一点体会“第一场”这三个字本身就很能说明问题它不只是一次考试更像是一次技术方向上的探索。通过这场笔试你会发现自己对底层知识到底掌握了多少也会发现平时那些“能用就行”的代码到底浪费了多少性能。我个人在准备这场笔试时的体会是不要为了优化而优化一切优化都要以瓶颈分析为前提。拿到一段代码先搞清楚它到底慢在哪是访存、分支、指令吞吐还是编译器没有向量化然后针对性地动手。很多时候一个看似高级的SIMD优化反而不如调整一下内存布局来得直接。如果你现在也在准备类似的代码优化岗位笔试我建议你多去折腾真实平台哪怕只是用QEMU开一个ARM虚拟环境把一道简单的图像算法从C语言版改成NEON版然后再跑一遍看看性能差距。这种亲手做过的经验比任何速成资料都管用。最后再分享一个小技巧笔试答题时代码末尾别忘了留几行注释把你排查过的风险和做过的权衡写清楚。阅卷人不只看结果更看重你的思考过程。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进