
1. 先说结论这场笔试考的不是你会不会调优而是你有没有调优的直觉2018年那会儿商汤的AI风头正盛算法岗简历堆成山但X86/ARM代码优化工程师这种岗位相对小众。我当时投这个岗位说白了就是看中它不像算法岗那样千军万马过独木桥又跟底层打交道能积累点硬功夫。收到笔试通知后我花了三天把计算机体系结构、汇编、编译原理翻了一遍结果上了考场发现——笔试内容比我想象的朴素得多但也比我想象的刁钻得多。先说结论这份笔试卷子并不考你背了多少优化技巧它考的是你有没有一种从CPU视角看代码的本能。什么叫从CPU视角看代码就是你看到一段C代码循环脑子里能自动浮现出它对应的指令序列能看出来哪里访存不连续、哪里产生了不必要的依赖、哪里会被预测器惩罚。这种能力不是靠背题能速成的但可以通过系统梳理体系结构知识和大量阅读汇编代码来培养。这篇文章我按当年的考试题型和知识脉络做个复盘。虽然没法完整还原每一道原题但考察的核心知识点、命题思路和准备方法是可以复用的。无论你准备的是哪个厂的底层优化岗这轮梳理都能帮你把知识体系搭起来。适合谁看打算投AI公司底层优化、嵌入式优化、编译器相关岗位的应届生已经在做性能调优但没系统学过体系结构的工程师以及纯粹对X86/ARM汇编和代码优化感兴趣、想搞明白编译器-O2到底帮我们做了什么的人。2. X86与ARM的差异题最基础也最容易丢分的部分这一part在整张卷子里占比不高但出现的全是基础题丢分很可惜。当时考卷里有一道选择题问X86和ARM在指令集设计上的本质区别选项里混杂着精简指令集复杂指令集变长指令固定指令长度这些词。表面看是送分题但仔细一抠陷阱不少。2.1 指令集设计CISC与RISC不是历史分类而是取舍X86是典型的CISC复杂指令集架构指令长度可变从1字节到15字节都有一条指令能完成加载、运算、存储多个操作。例如ADD [RAX], RBX这条指令直接把内存里的值和寄存器相加再把结果写回内存。这种设计的初衷是让一条指令干更多的事减少指令条数、节省内存带宽在上世纪内存寸土寸金的年代是合理选择。ARM是RISC精简指令集架构指令长度固定ARM模式32位Thumb模式16位绝大多数指令只能操作寄存器内存访问必须通过专门的LDR/STR指令完成。也就是说ARM下想做内存里的值加寄存器再写回内存必须先LDR读到寄存器再ADD再STR写回需要三条指令。笔试里容易丢分的是第三层理解为什么到今天X86还坚持CISC因为X86有巨大的历史包袱——软件生态全部建立在老的指令集上必须保持兼容。而ARM能在移动端崛起靠的正是RISC的简洁性带来的低功耗和易流水线化。到了现在两边的边界其实在模糊X86内部也有微操作micro-ops拆分翻译成类似RISC的内部指令ARMv8加入了更多复杂指令和SIMD扩展。所以别把精简和复杂当成绝对对立要理解这是指令集设计的两种路线各有取舍。2.2 寄存器与调用约定写汇编级分析题时最容易踩的坑寄存器差异是笔试题里非常容易出的一类。X86-64有16个通用寄存器RAX、RBX、RCX、RDX、RSI、RDI、RBP、RSP、R8-R15而ARM64有31个通用寄存器X0-X30。这直接影响了函数调用约定X86-64System V ABI下前6个整数参数依次放在RDI, RSI, RDX, RCX, R8, R9多余的参数压栈返回值放在RAX。ARM64下前8个整数参数放在X0-X7多余的压栈返回值放在X0。那个年代不少同学只背过X86的调用约定一看到ARM的题目就懵。还有一个隐藏考点ARM32的调用约定里前4个参数用R0-R3剩下的压栈而且ARM32还有一个特殊的链接寄存器LRX30函数返回不是ret指令而是MOV PC, LR或者BX LR——读汇编题时看到BX LR得知道这是在返回。我印象里那道汇编阅读题给了一小段ARM64汇编让还原对应的C函数功能。核心就是认出X0是第一个参数、X0又作为返回值中间是几个算术与访存操作。如果你调用约定不熟可能连哪个寄存器是参数都分不清后面全乱。所以备考时一定要把X86-64和ARM64两套调用约定对照着背熟。2.3 字节序、对齐与内存模型笔试里爱出的小坑除了寄存器和指令集体系结构基础里还有一些小坑类考点。字节序是其中之一X86和ARM64默认都是小端little-endian但ARM32的硬件设计支持大小端切换虽然实际系统几乎都用小端。笔试题如果问在ARM上运行某段代码输出什么默认要按小端算除非题目明确指定了BE模式。对齐问题也是重点。X86对未对齐访问的容忍度很高只是性能受损ARM早期版本遇到未对齐访问会直接触发异常现代ARM虽然硬件上支持部分非对齐访问但某些指令尤其是NEON向量加载仍然要求严格对齐。笔试里如果给出一个结构体让算访问偏移量或判断是否违反对齐要会数数。内存模型考察的是多核场景下的可见性X86使用TSOTotal Store Order写操作通过写缓冲区但读不会被乱序越过写ARM使用的是宽松内存模型relaxed memory model编译器硬件都可能重排指令所以多核编程必须靠屏障指令ARM上的DMB/DSB/ISBX86上的mfence/lfence/sfence来保证顺序。这一块当年只考了选择题但做Linux内核移植或驱动优化的朋友迟早会跟这些命令打交道。3. 代码优化题从C代码往下挖一层笔试的重头戏在代码优化。我印象很深的一道题给了一段做数组求和的循环要求做性能优化并解释理由。这种题看起来好像是八仙过海各显神通但改卷老师实际想看的是你能不能说出编译器会帮你做什么和你必须自己做什么。3.1 一个典型的循环优化题要怎么答假设题目给的是这样的代码float sum(float *a, int n) { float s 0.0f; for (int i 0; i n; i) { s a[i]; } return s; }一个常见的优化回答是循环展开。但你要明白循环展开要解决的关键问题有两个减少循环控制开销比较、跳转、计数器更新以及增加指令级并行度。对于浮点累加来说还有一个隐蔽的问题——浮点加法的延迟。s a[i]产生一个循环携带依赖loop-carried dependency每次迭代都要等上一次的加法完成。现代CPU浮点加法延迟大约是4个周期也就是说这个循环每轮至少要4个周期处理器再宽的流水线也白搭。正确的优化方向是多路累加float sum(float *a, int n) { float s0 0.0f, s1 0.0f, s2 0.0f, s3 0.0f; int i; for (i 0; i 4 n; i 4) { s0 a[i]; s1 a[i1]; s2 a[i2]; s3 a[i3]; } for (; i n; i) { s0 a[i]; } return s0 s1 s2 s3; }四路累加把循环携带依赖链从1条拆成了4条每条链的延迟可以和其他链的计算重叠理论上吞吐量提升接近4倍在没打到访存瓶颈的前提下。这就是笔试里优化二字的精髓不止是代码层面的小聪明而是分析出关键的依赖链和阻塞点。这个考点也解释了为什么笔试会给你C代码、让你分析性能瓶颈——它考的不是你有没有听过循环展开这个名词而是你能不能识别出哪条指令序列才是真正的瓶颈。3.2 SIMD与向量化SSE/AVX和NEON是考察重点当时的笔试题里明确出现了SIMD相关的题目——给出一段int8_t数组绝对差值和SADSum of Absolute Differences的C代码让用SIMD内联函数优化。这种题在视频编解码、图像处理岗位的笔试题里简直是标配。X86平台用得最多的是SSE2/AVX2核心数据类型是__m128i/__m256i对应的内联函数是_mm_add_epi8/_mm_loadu_si128这些。ARM平台对应的是NEON数据类型是int8x16_t/uint8x16_t内联函数是vaddq_s8/vld1q_s8这些。拿绝对差值和举例X86的实现套路是#include immintrin.h int sad_sse2(const uint8_t *a, const uint8_t *b, int n) { __m128i acc _mm_setzero_si128(); int i; for (i 0; i 16 n; i 16) { __m128i va _mm_loadu_si128((const __m128i *)(a i)); __m128i vb _mm_loadu_si128((const __m128i *)(b i)); __m128i diff _mm_sad_epu8(va, vb); // 8x8位无符号绝对差结果按16位累加 acc _mm_add_epi64(acc, diff); } // 尾部标量累加省略 ... }注意_mm_sad_epu8这个指令它对16个字节分别求绝对差然后把每8个字节的差累加成两个16位实际上扩展到64位的值。实现SAD只需两条SIMD指令加一条累加指令这是内联函数版本的核心。ARM NEON的写法类似但函数名是vabdq_u8vector absolute difference然后再用vpaddlq_u8pairwise add long把8位差累加成16位最后累加到32位或64位。命名体系与X86完全不同备考时要两边都熟至少看到代码能认出来。我当时在卷子上写优化方案时特意标注了尾部数据用标量处理两个平台都建议用memcpy做非对齐加载或直接使用loadu系列函数。这类别小看的细节实际工程里就是它造成崩溃或性能回退。3.3 访存优化cache友好才是最大的优化SIMD优化到一定程度你会发现瓶颈变成内存带宽了。所以笔试里另一类经典的磨人题就是矩阵运算或图像处理类题目核心考点是cache局部性。二维矩阵转置、矩阵乘法、图像卷积这类题考察点永远是同一个访存顺序对cache命中率的影响。举个最简单的例子你要遍历一个二维数组的所有元素按行遍历比按列遍历快得多因为行遍历时相邻元素在内存里是连续的正好落进同一个cache line通常64字节而按列遍历每取一个元素都要重新加载一个cache line。矩阵乘法题目里更进阶的考点是分块tiling。假设你算C A × B朴素三重循环里B的访存是列方向的完全违背局部性。分块操作的思路是每次只算输出矩阵中一个小块比如8x8这个块需要的A和B的元素都能塞进L1 cache那么内层循环的所有加载都能命中缓存。分块大小不能拍脑袋要按L1 cache容量和cache line大小推算。比如L1数据缓存32KBcache line 64字节那么一个8x8的浮点矩阵是256字节整个分块需要的A、B片段各几百字节远小于32KB所以8x8或16x16通常是安全的。这类题的答题思路可以总结成几句话先算数据规模再看访存模式最后设计分块。笔试时如果时间紧张不要求你写出完整的分块代码但必须把利用cache line的连续性和分块以减小cache miss这两个关键点答出来。3.4 分支预测、循环展开与指令级并行的底层逻辑剩下的一些小题覆盖分支预测、循环展开、指令级并行等方向。这类题不是让你写优化代码而是让你解释为什么某段代码的性能会异常。比如有一道题给了一段按数组元素正负做不同操作的循环问为什么性能比纯算术操作差很多。关键在分支预测器branch predictor。现代CPU流水线非常深遇到分支时需要预测跳转方向预测错了就要清空整条流水线损失十几个周期的代价。如果数组正负分布随机分支预测器准确率就只有50%几乎每次都猜错性能自然一塌糊涂。优化手段有两类一是想办法消除分支把条件判断改成算术运算或查表二是调整数据布局让同类数据尽量集中。对于正负分别加不同值这种场景可以改成result (x 0) ? positive_table[x] : negative_table[x];本质还是分支。真正消除分支的做法是把x的符号位转换成索引用查表直接算出要加的偏移量。笔试不用你写那么复杂的代码但至少要知道分支预测失败是怎么一回事、为什么随机数据会拖慢速度。循环展开在3.1里说过它除了减少循环控制指令外还能让编译器看到更大的指令窗口从而做更好的指令调度——把没有依赖关系的指令安排到不同执行单元上。这背后是指令级并行ILP的基本原理。答题时不用展开太多但提一句提高指令级并行度会让答案显得更专业。4. 编译与交叉编译笔试里不能丢的工程分理论题结束之后笔试卷里还掺了一些工程向的题看着跟代码优化不直接相关但实际工作中全是这些。当时考的交叉编译、编译选项、工具链这些内容现在回看反而是最实用的部分。4.1 交叉编译的基本概念为什么你不该在ARM板上编译大型程序有一道题是概念题什么是交叉编译为什么嵌入式开发常用答案不难但很多同学挂在交叉二字上说不透彻。交叉编译就是在一种架构的主机上编译出另一种架构上运行的程序。典型场景是在X86的Linux服务器上用交叉编译工具链比如arm-linux-gnueabihf-gcc编译出ARM架构的可执行文件再传到开发板上运行。为什么不能直接在ARM板上编译因为嵌入式设备的CPU性能、内存、存储往往有限大型源码包在板子上编译可能要几小时甚至直接卡死而在PC上交叉编译可能只需要几分钟。另一个原因是工具链依赖问题板子上通常没有完整的编译工具链就算有版本也可能和你的构建脚本对不上。所以交叉编译是嵌入式开发的基础日常。如果笔试里再深挖一层可能问交叉编译的时候链接器需要指定什么答案是--sysroot或者-L目录选项让链接器去目标架构的库目录如/usr/arm-linux-gnueabihf/lib找动态库和静态库而不是去宿主机的/usr/lib。4.2 编译选项-O2到底做了什么-march为什么不能乱设编译优化选项也是笔试热点。-O0、-O1、-O2、-O3各是什么级别-Os又是什么这些是基础操作。但笔试不止于背名字它会让你解释为什么某个程序加了-O2后运行结果变了或者为什么某段代码-O0正常、-O2就出错。-O2做的事包括常量折叠、死代码消除、公共子表达式消除、循环优化循环展开、循环旋转、内联、指令调度等。我见过那个年代的笔试题拿一段有volatile缺失的代码来考一个全局变量在中断里被修改主循环里读取它没加volatile时-O2会把读取优化掉导致死循环。这类题目考察的本质是编译器优化的前提是代码没有数据竞争和未定义行为一旦你违反了语言标准里关于volatile、内存序的约定编译器就会做出你觉得不可思议但其实合法的优化。-march和-mfpu这类选项在交叉编译题里也常见。给ARM交叉编译时如果目标CPU支持NEON你需要在编译命令里加-mfpuneon或新版工具链的-marcharmv7-aneon之类否则即使代码里写了NEON内联函数编译器也不会生成NEON指令甚至报selected processor does not support vaddq_s8 in ARM mode这类错误。这个知识点在热搜词里反复出现arm compiler版本、armv9、NEON等词条说明实际工程里很多人卡在这一步。4.3 笔试常考工具链小题从汇编到链接的几步除了交叉编译和编译选项笔试还会出一些工具链使用的小题。比如用gcc -S生成汇编、用objdump -d反汇编、用nm查看符号、用readelf -h查看ELF头——这些命令本身简单但很多刚接触底层的同学确实没摸过成了失分点。我当时拿到卷子看到一道题如何查看一个ELF可执行文件是X86还是ARM架构最简单的方法就是readelf -h看ELF头里的Machine字段X86-64显示Advanced Micro Devices X86-64ARM64显示AArch64。file命令一行也能搞定但笔试可能限制只能用反汇编工具。这些细节虽然不起眼但在实际开发中定位架构不对的问题时都是救命招。5. 备战复盘这些坑我踩过不想你也踩5.1 只刷题不读体系结构题目改个壳就不认识了备考时最容易犯的错是只刷算法题和套路题对体系结构基础一知半解。前面说的分支预测、cache局部性、循环携带依赖、SIMD差异靠刷题是刷不出来的必须靠系统的知识积累。我当时用的方法是先快速过一遍《深入理解计算机系统》CSAPP的处理器和存储器层次结构两章把程序性能受什么因素影响这个底层逻辑打通然后对着ARM官方文档尤其是NEON编程指南和GCC编译选项文档把X86和ARM的差异列成对照表最后才大量做编译优化练习用perf stat和objdump看实际效果。这套方法执行下来最明显的收益是笔试里那种分析程序慢在哪的题基本都能抓到重点知道从计算、访存、分支三个维度去找理由而不是瞎猜。5.2 注意题目里的陷阱设定平台默认、编译器版本、优化级别考场时间紧张容易忽略题目前提。比如有些题目默认你在X86 Linux下写代码有些则明确是ARM交叉编译你要先定性再动手。还有一些隐含条件浮点计算默认是IEEE754单精度、循环边界变量默认是有符号整数、数组首地址默认16字节对齐典型堆指针分配还是任意对齐。这些看似无关紧要的设定在SIMD和性能分析题里可能就是坑。另外编译器的选择也会影响优化选项的行为。ARM平台有GCC、Clang、ARM Compilerarmcc/armclang几个体系不同的编译器对NEON内联函数的支持程度、内联策略、默认的FPU选项都不一样。如果你在笔试题里看到__ARM_NEON这样的宏判断它问的其实就是这段代码在什么编译器下能编译通过。5.3 写优化答案时的答题顺序先定性再定量最后给结果答代码优化题时很多同学上来就写一大堆代码改卷老师看着累自己也容易漏点。我建议按这个顺序组织答案先分析性能瓶颈计算密集型还是访存密集型循环内有无依赖链分支概率如何一两句话讲清楚再给出优化策略为什么选这个策略理论依据是什么比如浮点加法延迟4周期用4路累加把依赖链拆开最后给出代码或伪代码末尾注明尾部数据用标量处理需要-O2以上编译这类边界说明这套答题框架在后续的面试手撕环节也特别好使。面试官听到你先讲瓶颈分析再讲为什么这么优化通常都会认为你是真正理解问题而不是背模板。5.4 工具和资料清单按优先级排序如果时间有限我的优先级建议是这样的优先级资料/工具用途高《深入理解计算机系统》第3、5、6章体系结构基础、性能分析、存储层次高ARM架构参考手册ARM Architecture Reference Manual或官方白皮书了解ARM指令集、NEON、异常模型高GCC文档中的-O系列和-march/-mfpu/-mabi选项编译选项对生成代码的影响中Intel Intrinsics Guide 和 ARM NEON Intrinsics Reference查SIMD内联函数中独立写小实验编译、反汇编、看perf统计把理论和实际对应起来低各种性能优化博客接触更多实际案例和技巧用这份清单把知识体系过一遍之后再去刷往年笔试题或者找一些开源项目的优化patch来读效果比盲目刷题好得多。6. 笔试之后优化工程师的日常其实就是那几件事卷子交上去之后如果你对这个岗位还有兴趣可以提前感受一下真实工作里的优化是什么状态。我后来接触到的代码优化工作很大一部分确实就是笔试里那些知识的直接放大做图像处理算法时查NEON内联函数、用perf定位cache miss、在ARM开发板上交叉编译然后反复跑benchmark。学校里学的那套体系结构理论和真实场景是严丝合缝对上的。准备这类笔试建议把重心放到理解性能的底层原因上而不是背结论。分支预测、cache、依赖链、SIMD并行度——这些概念不只是笔试题它们会一直出现在优化工程师的日常沟通里。今天的笔试考察其实是在模拟未来的工作方式。