
商汤科技2018校招X86/ARM代码优化工程师笔试第二场一次把底层功底考到极致的经历2018年秋季我还在学校念计算机体系结构方向的研究生投了商汤科技的X86/ARM代码优化工程师岗位。坦白说当时我对这个岗位的理解还很模糊以为就是“写写汇编、调调指令”直到我坐在笔试现场拿到第二场的试卷才意识到这个岗位考的不是“会不会写代码”而是“懂不懂计算机”。商汤作为AI视觉领域的头部公司他们的代码优化工程师主要做什么简单说就是把深度学习模型里的卷积、矩阵乘、激活函数这些算子在X86和ARM两种架构上压榨出极致性能。训练服务器用X86端侧推理跑ARM两者底层逻辑完全不同笔试也确实是围绕这两条线展开的。这场第二场笔试我印象最深的是它不考死记硬背而是给你一段看起来“很正常”的代码让你在限定时间内找出性能瓶颈并给出优化方案。这篇文章我把整场笔试的题目结构、考点拆解、以及背后的优化原理完整复盘一遍相信对准备高性能计算、底层优化、嵌入式优化方向岗位的同学会有实际帮助。1. 笔试整体设计与出题思路拆解1.1 为什么商汤要单独出一套X86/ARM优化笔试当时商汤的AI业务已经覆盖了从云端训练到手机端、安防摄像头端、智能车载端的全链路。云端训练服务器清一色X86而终端设备上跑模型用的几乎全是ARM架构芯片——手机上的骁龙、麒麟摄像头里的海思车载里的地平线底层都是ARM指令集。这导致一个非常现实的问题模型训练完要部署到各种端侧设备但同样的C代码在X86上编译出来能跑在ARM上编译出来性能可能差好几倍。原因在于X86是复杂指令集CISC指令功能丰富编译器成熟度极高ARM是精简指令集RISC指令固定32位Thumb-2是16/32位混合每条指令干的事情更单一对开发者手动调优的依赖更大。所以商汤需要的人才是“两头都懂”的既能在X86上用AVX/SSE指令集做向量化优化也能在ARM上用NEON指令集做类似的优化还能理解两种架构在缓存、内存模型、分支预测上的差异。第二场笔试的出题思路非常清晰前半段考通用优化理论中段考X86实战分析后段考ARM交叉编译和NEON优化。整体难度呈阶梯式上升时间120分钟题量大概8-10道大题每道题都有若干小问时间非常紧张。1.2 第二场和第一场的区别从“认指令”到“调性能”我后来和也参加过第一场的同学交流过发现两场笔试的侧重点有明显差异。第一场更偏向于基础考察比如要求写出某个指令的完整格式、解释缓存一致性的基本概念、给出简单的循环展开示例属于“你知道就能答”的类型。第二场则完全换了一种风格。它直接给出一段可编译运行的C代码要求现场指出至少3个性能瓶颈并依次给出优化方案。有些题目甚至不给完整代码只给关键循环体让你推断整体性能瓶颈在哪里。这种考法更接近真实工作中的“Code Review”场景——你在优化一个算子之前必须先能看懂别人写的代码找出它慢在哪里才能动手改。我印象最深刻的是一道矩阵乘法的题目。代码用了经典的 i-j-k 三层循环而且内层循环里直接对C[i][j] A[i][k] * B[k][j]累加。很多没做过底层优化的同学可能觉得这没问题但对懂行的人来说这个写法有两个致命问题一是内层循环访问B[k][j]时是按列访问的而C语言二维数组是按行存储的这会导致严重的缓存未命中二是编译器很难自动向量化这种访存不连续的代码导致SIMD指令完全派不上用场。这种“看起来正常、实际慢到极致”的代码正是笔试想考察的核心。2. X86优化题核心细节拆解访存、向量化与指令级并行2.1 缓存友好性是X86优化的第一优先级X86平台上的性能优化有一个铁律先访存后计算。这句话的意思是绝大多数情况下一个算子的性能瓶颈不在CPU的算力上而在数据从内存搬到寄存器的过程中。现代X86处理器的L1缓存访问延迟大概是4-5个时钟周期L2缓存大概12-20个周期而主存延迟高达100-200个周期。如果代码的访存模式不好CPU大部分时间都在等数据算得再快也没用。笔试里那道矩阵乘法标准解法是调整循环顺序把 i-j-k 改成 i-k-j。这样内层循环变成对B[k][j]按行访问和存储顺序一致缓存命中率大幅提升。但仅仅是调换循环顺序还不够更进一步的优化是分块Tiling把矩阵切成8x8或者16x16的小块让每个小块的数据能完整塞进L1缓存避免反复从主存加载数据。我当时在试卷上画了一个简单的访存示意图标出按列访问导致的cache miss再画出分块之后的访存路径这个答案得到了面试官后来在面试环节的正面反馈。2.2 SIMD向量化的关键数据布局和内存对齐X86优化另一大考点是SIMD指令即单指令多数据流。SSE指令集操作128位寄存器一次可以处理4个floatAVX2操作256位寄存器一次处理8个floatAVX-512则能一次处理16个float。笔试里有一道题专门考察这个给出一段对float数组做逐元素平方的循环要求用AVX指令改写。单纯从指令编码角度来说这道题不难vsqrtps指令就能直接搞定。但真正的考点在后面的小问为什么要求数组按16字节SSE或32字节AVX对齐答案是对齐的地址才能直接加载到SIMD寄存器里。如果数据没有对齐要么使用_mm_loadu_ps这种非对齐加载指令性能会下降要么需要你自己写对齐逻辑把不对齐的头尾部分单独处理。现代编译器实际上已经能自动生成对齐判断和分支处理但你作为优化工程师必须理解这背后的性能代价这样才能在代码里通过__attribute__((aligned(32)))或者posix_memalign主动保证对齐。另外还有一个容易被忽略的点数据条带Data Striping不能只对齐首地址还要保证每个向量化迭代处理的数据长度是向量宽度的整数倍。如果你的数组长度是100个floatAVX一次处理8个那最后4个元素需要单独处理。这种边界处理的代码写不好性能照样上不去。2.3 指令级并行的隐藏考点依赖链和吞吐量X86笔试还有一道题给出一段循环内包含长依赖链的代码要求分析其性能瓶颈。这个考点非常有意思因为它考察的是对CPU流水线和指令级并行ILP的理解。现代X86 CPU每个时钟周期可以发射多条指令但前提是指令之间没有数据依赖。如果一段循环里每次迭代都依赖上一次迭代的结果循环携带依赖那么CPU无法并行执行这些迭代只能串行等待这时性能瓶颈就是依赖链的延迟而不是指令的吞吐量。一个经典例子是累加求和float sum 0.0f; for (int i 0; i N; i) { sum a[i]; }这个循环的sum是一个循环携带依赖每次累加都要等上一次加法完成延迟大约4个周期。优化方法是用多个累加器float sum0 0.0f, sum1 0.0f; for (int i 0; i N; i 2) { sum0 a[i]; sum1 a[i1]; } float sum sum0 sum1;这样两条累加链是独立的CPU可以并行执行吞吐量直接翻倍。笔试的答案里如果能补充一句“现代CPU的乱序执行引擎只能在不破坏依赖关系的前提下寻找并行性所以优化代码的本质是给CPU创造更多的并行机会”那这道题基本就是满分水平。3. ARM侧考点深入解析交叉编译、NEON与嵌入式优化的特殊性3.1 ARM交叉编译环境是嵌入式优化的入场券ARM部分的笔试和X86完全不同。X86的代码通常直接在服务器上编译运行而ARM的代码几乎总是通过交叉编译工具链生成再放到开发板或模拟器上执行。交叉编译的意思是编译动作在X86主机上完成但生成的目标代码是ARM指令集。这需要用专门的交叉编译器比如arm-linux-gnueabihf-gcc。笔试出了一道关于-march和-mtune参数区别的题目这是ARM交叉编译最常见的坑。-march指定目标CPU支持的指令集架构比如armv7-a、armv8-a决定编译器能使用哪些指令-mtune指定针对哪个具体CPU型号做指令调度优化比如cortex-a53、cortex-a72。如果你的-march设得太低编译器生成的代码可能没有利用上目标芯片的NEON指令设得太高生成的代码在旧芯片上直接非法指令崩溃。笔试题目给的是一个Cortex-A53的板子正确配置应该是-marcharmv8-a -mtunecortex-a53同时开启-mfpuneon-fp-armv8。另一个交叉编译必考题是工具链命名规则。arm-linux-gnueabihf的意思是目标架构是ARM、运行Linux系统、使用GNU工具链、hf表示硬浮点。如果你不小心用了arm-linux-gnueabi软浮点在板子上执行时会出现Illegal instruction错误因为硬浮点指令编码在软浮点模式下不被识别。当时笔试给了一段编译日志让考生判断为什么同样的代码在板子上跑不起来不少人会忽略这个细节。3.2 NEON向量化手写内联汇编还是靠编译器ARM的NEON指令集相当于X86的SSE/AVX但有一个显著差异NEON的寄存器布局和指令编码更规整但受限于嵌入式芯片的功耗墙主频通常只有1-2GHz所以每优化一分都是实打实的收益。笔试里的NEON题是一个图像灰度化算子把RGB888的彩色图像转成灰度图公式是Y 0.299R 0.587G 0.114B。这种像素处理是ARM端最常见的优化场景也是NEON最擅长的数据并行任务。第一层优化是用NEON的vld3_u8指令一次性加载3个通道的8位像素然后用vmovl_u8扩展成16位参与乘法最后vadd累加后vqmovn_u16饱和缩小回8位。核心逻辑如下uint8x8_t r vld1_u8(r_ptr); uint8x8_t g vld1_u8(g_ptr); uint8x8_t b vld1_u8(b_ptr); uint16x8_t r16 vmull_u8(r, vdup_n_u8(77)); // 0.299 * 256 ≈ 77 uint16x8_t g16 vmull_u8(g, vdup_n_u8(150)); // 0.587 * 256 ≈ 150 uint16x8_t b16 vmull_u8(b, vdup_n_u8(29)); // 0.114 * 256 ≈ 29 uint16x8_t sum vaddq_u16(vaddq_u16(r16, g16), b16); uint8x8_t gray vshrn_n_u16(sum, 8);笔试还问了一个刁钻的问题为什么权重是77、150、29而不是0.299、0.587、0.114因为NEON的整数乘法只支持16位整数浮点乘法虽然也有vmulq_f32但性能远低于整数乘法而且需要额外的浮点转换。先把浮点权重乘以256转成整数累加后再右移8位相当于保留了浮点精度又用了整数指令这是嵌入式图像处理的标准套路。这个小细节恰恰是区分笔试者是否真正做过ARM优化的试金石。3.3 ARM内存模型与AArch64的性能陷阱最后一道ARM大题出了一段多线程共享变量的代码要求分析为什么在ARM上会出现“反直觉”的现象。这个考点非常深涉及ARM的内存模型ARM是弱内存序架构允许CPU在执行时乱序访问内存而X86是强内存序架构访问顺序基本按程序顺序执行。在X86上两个线程分别写一个变量另一个线程读两个变量通常能按预期顺序看到结果但在ARM上如果没有正确的内存屏障Memory Barrier读线程可能先看到后写入的值再看到先写入的值也就是“重排”。这会导致无锁编程的正确性在ARM和X86上完全不同。笔试答案需要提到dmb ish指令数据内存屏障内部共享域或者用C11的atomic_thread_fence来保证顺序。这个题目的考察目的是让候选人意识到商汤的端侧推理引擎要跑在各类ARM芯片上内存序的坑一旦踩到bug极难排查。其实ARM内存模型还不是最恶心的还有个隐藏更深的坑是cache line伪共享False Sharing。多核ARM芯片上如果两个CPU核心各自修改不同的变量但这两个变量恰好落在同一条64字节的cache line里那么整个cache line会在两个核心之间反复“拉锯”性能断崖式下降。笔试没考这个知识点但面试环节我特意提了面试官明显感兴趣。后来在真实项目中我真遇到过伪共享导致推理耗时翻倍的案例这里建议大家写多线程时务必用__attribute__((aligned(64)))把不同线程的数据分隔开。4. 代码优化通用方法论性能分析、编译选项与算法级重写4.1 永远先Profile再动手优化笔试题目里有一道“论述题”问的是“你拿到一个运行缓慢的算子优化步骤是什么”。这道题没有标准答案但考察的是工程方法论。我的回答是三步走第一步Profile定位热点。用perfX86或arm的Streamline工具ARM跑一遍profiling确认时间到底花在哪个函数、哪一行代码。没有profile数据就动手优化大概率是在瞎忙活。真实场景里有时候问题根本不在算子本身而在数据格式转换或者内存拷贝上。第二步先调整算法和数据结构再调指令。优化分层次算法层、数据层、指令层。算法层是把O(n^2)变成O(n log n)这带来的收益可能是几十倍数据层是改访存模式、改数据结构收益可能几倍指令层才是上SIMD、上汇编收益可能20%-50%。很多人一上来就手写汇编结果发现算法本身就有问题白费功夫。第三步每次改动对比验证。用同一份测试集测改前改后的性能并且要确认优化前后功能结果一致。曾经有同事优化卷积时把精度调没了模型准确率直接崩了这正是因为没有做误差验证。我后来在真实项目里还补充了一条不是所有热点都值得优化。比如一个函数只占整体耗时3%就算优化到0整体也只快3%而一个占40%的函数优化一半就能快20%。优化的ROI要算清楚别在边缘功能上消耗大块时间。4.2 编译器优化选项的合理选择笔试选择里有一道关于GCC编译选项的题问代码性能最好的编译选项组合是什么。选项有-O0、-O1、-O2、-O3、-Os、-funroll-loops、-ffast-math等等。- -O0不做任何优化编译最快适合调试。 - -O1基本优化减小代码尺寸和执行时间。 - -O2兼顾代码大小和执行速度的优化GCC推荐级别。 - -O3最大优化包括向量化、函数内联等可能导致代码膨胀。 - -Os优化代码尺寸适合存储空间受限的嵌入式场景。 - -funroll-loops循环展开以代码体积换性能。 - -ffast-math放宽浮点运算标准可能改变精度。在X86服务器上-O2和-O3差异已经不大因为编译器的自动向量化能力很强但在ARM嵌入式上手动调优配合-O3 -funroll-loops往往效果更明显。需要注意的是-ffast-math慎用它假设浮点运算满足交换律和结合律这在某些数值敏感算法里会导致结果偏差。笔试还问了一个更细的问题-O3自动向量化后为什么还需要手写SIMD指令因为编译器自动向量化是保守的它要保证任何输入数据都不会出错所以碰到指针可能重叠aliasing的情况就会放弃向量化。解决办法是给指针加__restrict__关键字告诉编译器这两个指针不会指向同一块内存。void add_array(float * __restrict__ a, float * __restrict__ b, int n) { for (int i 0; i n; i) { a[i] b[i]; } }加上__restrict__后GCC生成的代码会明显更激进的向量化。这个小技巧笔试虽然没有直接考但和那题是同一个原理。4.3 算法级重写从“能用”到“好用”的分水岭笔试有一道Bonus题给了一段用双循环计算两个向量的余弦相似度的代码要求优化。标准思路无非是向量化、缓存友好。但真正的加分项是写出“把内层循环中的除法变成乘法”这种算法级优化——因为除法在CPU上是微码实现的延迟是乘法的好几倍尤其是浮点除法。例如for (int i 0; i N; i) { dot a[i] * b[i]; norm_a a[i] * a[i]; norm_b b[i] * b[i]; } float cos_sim dot / (sqrt(norm_a) * sqrt(norm_b));这是教科书写法。实际优化版本会把分母的计算合并成一次1.0f / sqrtf(...)然后用乘法代替除法更进一步是使用sqrtss的近似指令牺牲一点精度换更大速度。在深度学习推理中很多归一化操作已经用近似计算代替标准数学函数因为模型对微小误差并不敏感而性能提升是实打实的。这个“用近似换速度”的思路是代码优化工程师和普通开发者的重要区别。5. 模拟笔试练习三道典型题目与解法演示5.1 题目一X86矩阵转置的缓存优化题目大意实现一个N×N矩阵的原地转置N为2048给出优化方案。直接实现是逐元素交换但这样会频繁触发cache miss。我先写一个baseline分析假设缓存行64字节可容纳16个float。按行遍历交换a[i][j]和a[j][i]时每次读入一个完整的cache line但只用到其中1个元素其余15个元素可能还没来得及用就被挤出去了如果矩阵大于L2缓存容量。这样cache利用率只有1/16性能损失巨大。优化思路分两步第一步分块转置。把矩阵分成32×32的小块确保一块数据能同时留在缓存里。每次只转置一个小块再整体交换两个块的cache line。第二步对每个小块内部的转置操作使用SIMD。加载一块连续的行数据用_MM_TRANSPOSE4_PS这类宏做4×4矩阵转置提高数据复用率。笔试只要写出分块转置的伪代码并说明块大小选择的依据——块占用的内存要小于L2缓存大小的一半留出余量给其他数据——就能拿到大部分分数。#define BLOCK 32 for (int i 0; i N; i BLOCK) { for (int j 0; j N; j BLOCK) { if (i j) { transpose_block_inplace(a, i, j, BLOCK); } else { transpose_block_and_swap(a, i, j, BLOCK); } } }5.2 题目二ARM上检测大小端与实现字节序转换这道题直接考察ARM开发最常见的坑大小端问题。ARM芯片默认为小端模式但网络字节序是大端所以嵌入式网络编程里字节序转换无处不在。题目给了两种实现方式一种是经典memcpy后手动交换字节另一种是用__builtin_bswap32内建函数。问哪个性能更好。答案是内建函数更好。__builtin_bswap32在ARM上会被编译成rev指令这是ARM的一条单周期指令而手动交换字节会生成多条移位和或指令。现代的htons、ntohl库函数内部在ARM平台上就是直接调用这条指令。类似的X86上有bswap指令所以如果是X86平台编译器同样会把字节序转换编译成单条指令。这道题的教训是在优化时优先使用编译器内建函数builtins而不是自己造轮子。编译器内建函数是各编译器厂商针对目标架构做的最优映射自己写的代码很难超越。而且手写的字节序转换代码如果用了未定义行为比如把int* 强转为 char* 再解引用在不同优化级别下可能直接出bug。5.3 题目三NEON实现浮点数组的ReLU激活函数ReLU是深度学习中最常见的激活函数公式y max(0, x)。ARM上输入数据是连续存储的float数组。传统写法是for循环加if判断但if分支在SIMD下会被拆散性能低下。NEON实现方式非常优雅void relu_neon(float *data, int n) { int i 0; float32x4_t zero vdupq_n_f32(0.0f); for (; i n - 4; i 4) { float32x4_t val vld1q_f32(data i); float32x4_t result vmaxq_f32(val, zero); vst1q_f32(data i, result); } for (; i n; i) { data[i] data[i] 0 ? data[i] : 0; } }一条vmaxq_f32指令就完成了4个float的ReLU计算不需要任何比较和跳转。这里的核心思想是把分支运算变成数据运算。在SIMD的世界里分支是最大的敌人因为它会打断指令流水线。用vmax、vmin、vbsl位选择这类指令代替if判断是ARM优化的常见套路。类似的技巧在深度学习里很常见比如clamp操作可以用vmin加vmax组合实现sigmoid可以用vrecpeq近似指令加速。这些优化的核心都是理解SIMD的思维方式一次处理一批数据用数学运算替代控制流。6. 复盘与建议这类笔试真正想筛选的是什么能力6.1 从商汤笔试看代码优化工程师的能力模型回头看商汤2018年这场第二场笔试它考的东西远比“会不会背几条汇编指令”要深。整张试卷覆盖了四个核心能力维度一是系统理解能力。你要懂CPU的流水线、缓存层级、内存模型的底层原理而不是停留在写业务代码的层面。有些同学可能代码写得很好但完全没想过for循环里一条语句执行了多少个CPU周期、数据是从哪里加载的这类题就无从下手。二是动手验证能力。笔试虽然不能跑代码但很多题目都设置了“让你推理”的环节。比如给了代码让你分析访存路径如果你平时没有实际跑过性能测试很难凭直觉判断瓶颈在哪。我自己的经验是类似题目最好在纸上画出内存布局和缓存行的对应关系逐步推演才能避免想当然。三是平台差异意识。同一段代码在X86和ARM上表现完全不同优化的切入点也不同。X86上有成熟的AVX指令集和强大的编译器很多优化交给编译器即可ARM上NEON指令集需要更手动的控制同时还要注意交叉编译、大小端、内存对齐这些嵌入式特有的问题。能同时驾驭两个平台的工程师才符合商汤这类AI公司的需求。四是工程落地能力。优化不是炫技必须考虑代码可维护性、数值精度和跨平台兼容性。笔试最后一道论述题问的就是这个你的优化方案在真实项目中落地时会遇到什么问题这需要候选人真正做过项目而不是停留在理论层面。6.2 给准备类似岗位的同学几点实操建议分享几个我自己踩过的坑和总结出来的经验供准备高性能计算、代码优化方向校招的同学参考。第一一定要亲手写一遍NEON和AVX的代码而不是只背指令名称。我在笔试前自己用NEON写了一个5x5卷积的优化版本虽然没有在真实板子上跑但对指令的执行方式、数据排布的要求已经有了直观理解这让我在做ARM题时能快速定位到关键指令。第二熟练掌握性能分析工具。X86上用perf看热点函数、cache miss率、分支预测失败率ARM上学习使用Streamline或简单的gettimeofday计时。校招笔试可能不直接考工具使用但论述题里如果能提到“我用perf发现某函数cache miss率高达40%于是调整了数据结构”会明显拉开和普通考生的差距。第三多看看优秀开源项目的优化代码。比如OpenBLAS、Eigen、ncnn、Tengine这些库的源码里有大量经过精心调优的X86/ARM代码。你会学到很多笔试不会直接考、但面试一定会问的“实际工程经验”比如分块大小的选择、多级缓存的配合、指令调度顺序。我自己在准备期间把ncnn里卷积NEON优化的源码反复看了几遍收获极大。第四也是最重要的一点养成“性能思维”。写任何一段代码都下意识问自己三个问题这段代码的时间复杂度是不是最优数据访问模式是否对缓存友好有没有可能用SIMD指令加速这种思维习惯一旦建立在笔试和面试中都会让你受益匪浅也会让面试官觉得你是一个真正热爱底层的工程师而不是单纯为了找份工作而背题的应试者。6.3 后来我在这条路上越走越远那次笔试是我第一次意识到代码优化不是“旁门左道”而是一门融合了计算机体系结构、编译原理、操作系统的严谨工程学科。虽然最终我没有去商汤但那次笔试以及后续的面试准备让我坚定地选择了高性能计算这个方向也让我在后来的工作中持续受益。如果你也想往这个方向走请务必重视数学功底和计算机体系结构的基础同时保持亲手写代码的习惯。优化这条路很苦要有耐心反复测试、反复验证但当你的代码运行速度从基础版的100毫秒降到10毫秒、甚至5毫秒时那种成就感是不言而喻的。所以别怕笔试题目刁钻它恰恰是在帮你筛选出真正适合这条路的人。