ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

商汤校招X86/ARM代码优化工程师笔试复盘:体系结构与NEON实战

商汤校招X86/ARM代码优化工程师笔试复盘:体系结构与NEON实战 商汤科技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是CISCARM是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默认对齐说明char11按字节访问无压力short22对齐要求低int44对齐不足时x86降速、ARM风险更高long long88ARM64下未对齐可能导致效率断崖float/double4/84/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[i1][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:i0B][j0:j0B] A[i0:i0B][k0:k0B] * B[k0:k0B][j0:j0B] 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_SIZE16、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中引入到了ARMv8AArch64成为必选特性。它提供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卷积在图像处理里非常常见比如高斯模糊、边缘检测。笔试编程题如果出到卷积一般不会要求你用im2colGEMM这套那是算法题而是希望你能用NEON有效地处理卷积窗口的加载和乘加。一个常见的优化思路是对中心行和上下行分别用NEON做乘加最后累加。对于5行5列的卷积核把每一行看成独立的权重序列对输入图像的每一行用NEON做水平方向上5个像素的乘加5行累加。这里有个容易踩的坑边界处理。NEON一次加载8个像素时靠近图像边界的地方可能会越界。通常的解决办法是中心区域用NEON处理边界区域退回标量逻辑。这个思想在笔试里也很重要因为代码的鲁棒性和性能一样是评分点。我见过有人直接在循环里用vld1q_u8加载数据没考虑边界程序一跑就崩。正确做法是先计算安全区间把NEON限制在区间内执行int start radius; // 比如5x5卷积radius2 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。-marchnative会让编译器使用当前CPU支持的所有指令集扩展这在本地编译本地运行的场景下很好用但如果你需要把二进制分发到不同的机器上运行就不能用nativ而是要指定一个兼容的架构级别。ARM上对应的选项是-mcpucortex-a72或者-marcharmv8-asimd这类形式。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 VTuneARM平台上可以用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这些机制还是决定程序性能的底层因素。如果你正准备类似的校招笔试我的建议是别只刷题多花点时间理解硬件。每学到一个优化技巧就问自己一句它到底在优化哪个硬件瓶颈是减少了缓存未命中还是提高了指令级并行还是降低了分支预测失败率想清楚这个问题你写出的优化代码就不再是套模板而是真正有针对性的性能工程。
返回列表