商汤科技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个float;AVX2操作256位寄存器,一次处理8个float;AVX-512则能一次处理16个float。
笔试里有一道题专门考察这个:给出一段对float数组做逐元素平方的循环,要求用AVX指令改写。单纯从指令编码角度来说,这道题不难,vsqrtps指令就能直接搞定。但真正的考点在后面的小问:为什么要求数组按16字节(SSE)或32字节(AVX)对齐?
答案是对齐的地址才能直接加载到SIMD寄存器里。如果数据没有对齐,要么使用_mm_loadu_ps这种非对齐加载指令,性能会下降;要么需要你自己写对齐逻辑,把不对齐的头尾部分单独处理。现代编译器实际上已经能自动生成对齐判断和分支处理,但你作为优化工程师,必须理解这背后的性能代价,这样才能在代码里通过__attribute__((aligned(32)))或者posix_memalign主动保证对齐。
另外还有一个容易被忽略的点:数据条带(Data Striping)不能只对齐首地址,还要保证每个向量化迭代处理的数据长度是向量宽度的整数倍。如果你的数组长度是100个float,AVX一次处理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[i+1]; } 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的板子,正确配置应该是-march=armv8-a -mtune=cortex-a53,同时开启-mfpu=neon-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定位热点。用perf(X86)或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毫秒时,那种成就感是不言而喻的。所以,别怕笔试题目刁钻,它恰恰是在帮你筛选出真正适合这条路的人。