尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

从商汤代码优化工程师笔试看SIMD、Cache与NEON性能优化实战

从商汤代码优化工程师笔试看SIMD、Cache与NEON性能优化实战 2018年秋天我坐在商汤科技校招笔试的考场里拿到X86/ARM代码优化工程师这套卷子的时候第一反应是这题出得真敢。整张卷子几乎没有一道“背诵型”送分题上来就是让你分析一段内存不连续的循环为什么慢、算Cache的缺失率、对比AArch64和x86-64在寄存器使用上的差异。这套题后来陪伴我很长时间我拿它当过新人的面试题库也反复用它校准自己做性能优化的思路。今天把第一场的题型、考点、以及考后我补课总结的实操经验完整整理出来希望能给准备嵌入式优化、AI推理优化方向的同学一些参考。先说结论商汤这类AI公司设置代码优化工程师岗位核心目标非常明确——把算法团队的浮点模型、卷积算子、数据管线代码在指定硬件上压榨出极致性能。笔试第一场重点考察的是底层体系结构理解、SIMD向量化能力、Cache感知编程和基础的性能分析工具使用。它不要求你背出所有指令助记符但要求你能在给定约束下写出“会思考”的代码。这篇会结合我印象中的真题类型、对应知识点以及一套可以自己在本地复现的优化实验环境来展开。1. 笔试定位商汤招的代码优化工程师到底在考什么1.1 为什么AI公司要专门设这样一个岗位很多人一听到“商汤科技”就默认只有算法岗其实AI公司的算法落地非常依赖底层优化。一个卷积算子用纯C写跑在服务器CPU上也许要几十毫秒用AVX向量化加多核并行能压到几毫秒同样一个算子跑到ARM嵌入式设备上还得从NEON指令、cache line对齐、访存模式改起。这块差距就是代码优化工程师的价值。2018年那会儿深度学习推理开始大规模从GPU向CPU、ARM端侧迁移商汤的业务覆盖智慧城市、手机SDK、智能安防这意味着大量视觉算子要在各种规格的X86服务器和ARM板卡上运行。笔试题目设计得很有针对性x86侧考你有没有真正做过SSE/AVX优化ARM侧考你了不了解交叉编译和NEON的坑整体考你有没有分析性能瓶颈的方法论。这套卷子基本是把岗位日常遇到的核心场景浓缩成了两小时。1.2 第一场笔试的整体结构与题目风格第一场笔试的时间是120分钟整体题型可以分为四类体系结构基础选择题、Cache与访存分析题、SIMD向量化改错题、综合优化方案设计题。选择题覆盖寄存器位宽、指令集扩展、大小端、内存对齐、分支预测等分析题会给出一个循环让你计算cache miss或者解释为什么某种遍历方式更慢改错题经常是一段伪NEON或伪SSE代码里面藏着类型转换、对齐、数据依赖等问题最后的方案设计题则会描述一个图像处理场景让你给出从单核串行到SIMD、多线程、访存优化的一整套方案。我记得第一场跟第二场有个明显区别第一场更偏向“能不能读懂底层行为和写出正确优化代码”第二场才更多涉及多线程同步、NUMA、性能剖析工具深挖之类的内容。所以第一场能不能过关键看你对编译器和CPU行为模型的敏感度。2. 基础关x86和ARM的架构差异答题的隐形分水岭2.1 答题必背的寄存器与指令集对照笔试选择题和简答里反复出现的就是x86-64和AArch64的基本差异。这块如果只停留在“x86是CISCARM是RISC”这种层面很多题还是会丢分真正要掌握的是微架构行为层面的区别。寄存器数量x86-64有16个通用寄存器AArch64有31个通用寄存器这对编译器寄存器分配影响非常大。循环里临时变量多的时候AArch64代码明显更少溢出。SIMD寄存器x86-64的XMM是128位AVX扩展到YMM 256位AVX-512到ZMM 512位AArch64的NEON有32个128位寄存器。笔试出现过“为什么ARM端图像算子用NEON提升很稳定”的简答其实28个可用SIMD寄存器就是原因之一。寻址模式x86支持寄存器寻址、立即数寻址、基址加变址、比例寻址等很多组合一条指令可以直接读内存参与计算AArch64基本遵循load/store模型算术指令只操作寄存器内存访问得显式用ldr/str。指令编码x86变长指令1到15字节AArch64定长32位这对解码器和分支预测的影响很不一样。我自己的记忆技巧是给两个平台各找一个最舒服的使用场景。x86适合那种逻辑复杂、访存模式灵活的代码ARM适合大量数据平行处理的代码因为寄存器多、指令定长、SIMD寄存器数量充足。笔试里但凡出现“为什么这段代码在ARM上更优/更差”的分析题从这个角度切入基本不会跑偏。2.2 Cache模型计算这类必考题怎么拆Cache相关题目在第一场笔试中占比不低而且基本是送分题前提是你真的理解组相联Cache的计算。我印象里有一道题是这样L1 Cache容量32KBCache line大小64B8路组相联访问一个4096×4096的int数组按行遍历和按列遍历的Cache缺失率分别是多少为什么。计算过程要分三步先算一共有多少组再算数组元素地址如何映射到组最后看同一组内有没有冲突。容量32KB、8路、每路64B的line那么组数 32KB / (8 × 64B) 64组。一个4K×4K的int数组按行连续存储一行的数据量是4K × 4B 16KB恰好等于整个L1容量的一半。按行遍历时每一行都会把整个Cache填一遍下一个新行会覆盖掉上一行但由于是8路且地址低位可以分布在不同组实际上每行可以基本命中而按列遍历时每次访问跨行地址间隔16KB在64组8路的情况下列访问会频繁落在相同的组集合里导致大量冲突缺失。所以结论是按行遍历远优于按列遍历。这种题考的不是能不能背出公式而是你能不能把缓存行为映射到具体代码的数组布局上。实际优化项目里排查性能问题时我第一步几乎都是先看访存模式和cache miss而不是急着加SIMD。2.3 访存密集型与计算密集型的初步识别笔试方案设计题里经常要求你判断一个函数是访存密集型还是计算密集型因为优化策略完全不同。访存密集型的典型特征是循环内计算量很小但遍历的数据量很大比如逐像素拷贝、图像格式转换计算密集型则是循环内有大量乘加、函数调用或复杂分支比如矩阵乘法、卷积。对访存密集型代码优化重点没有别的就是提高cache命中率和内存带宽利用率具体手法包括分块循环、预取、消除非对齐访问而对计算密集型代码优化重点转向SIMD并行、减少指令依赖、使用乘加融合指令、循环展开。笔试里有人拿到一个图像算子就写AVX版本忽略了它其实瓶颈在DRAM带宽这是典型的“只看招数不看内功”也是阅卷老师很容易扣分的地方。3. 进阶关SIMD向量化的套路与现场写法3.1 SSE/AVX常见考点与代码骨架X86侧的考题通常给你一段逐像素处理的代码让你用SSE或AVX改写或者找出已有向量化代码里的错误。我记得有一道题是关于浮点数组逐元素乘加的你可以在纸上写#include immintrin.h void axpy_sse(float *y, const float *x, float a, int n) { __m128 va _mm_set1_ps(a); int i 0; for (; i 4 n; i 4) { __m128 vx _mm_loadu_ps(x i); __m128 vy _mm_loadu_ps(y i); __m128 vres _mm_add_ps(_mm_mul_ps(va, vx), vy); _mm_storeu_ps(y i, vres); } for (; i n; i) { y[i] a * x[i]; } }考点包括用_mm_loadu_ps还是_mm_load_ps、有没有处理末尾不足4个元素的情况、是否关心别名问题。很多人在笔试里踩坑的点是用了_mm_load_ps但没保证16字节对齐结果运行时直接段错误还有人是循环边界没处理好数组长度不是4的倍数就漏算或者越界。AVX版本就是128位换成256位一次处理8个float注意用_mm256_loadu_ps和_mm256_fmadd_ps这类指令。如果编译器支持-mavx2 -mfma融合乘加能省一条指令吞吐量提升大概在10%到20%之间这在笔试简答题里也是一个值得展开的优化点。3.2 NEON intrinsics的考点和注意事项ARM侧考题往往更贴近嵌入式视觉场景比如图像缩放、色彩空间转换。NEON编程有两种常见路径手写内联汇编或使用intrinsics函数。笔试现场一般不要求手写汇编但要求你能写出正确的intrinsics调用。NEON intrinsics需要特别留意的是数据类型的“宽度变化”。我记得真题里有这样一个坑用vadd_u8做完8位加法结果溢出截断成8位没有正确处理进位或者把uint8x8_t和uint16x8_t混用导致编译报错。比如你需要把8位像素乘一个系数再累加应该用长乘法vmull_u8得到16位结果再用vmlal_u8累加最后vshrn_n_u16缩窄回去#include arm_neon.h void yuv_gray_neon(const uint8_t *y, const uint8_t *u, const uint8_t *v, uint8_t *gray, int n) { int i 0; uint8x8_t cy vdup_n_u8(77); uint8x8_t cu vdup_n_u8(150); uint8x8_t cv vdup_n_u8(29); for (; i 8 n; i 8) { uint8x8_t yv vld1_u8(y i); uint8x8_t uv vld1_u8(u i); uint8x8_t vv vld1_u8(v i); uint16x8_t sum vmull_u8(yv, cy); sum vmlal_u8(sum, uv, cu); sum vmlal_u8(sum, vv, cv); uint8x8_t out vshrn_n_u16(sum, 8); vst1_u8(gray i, out); } for (; i n; i) { gray[i] (77 * y[i] 150 * u[i] 29 * v[i]) 8; } }笔试时会故意把vshrn_n_u16写成vmovn_u16看起来都是窄化但vmovn_u16是直接截断低8位而YUV转灰度需要右移8位两者语义差了一个缩放因子。这道题区分度很高很多人记得NEON有小数的舍入指令vqrshrn_n_u16却忽略了纯整数的定点实现里移位和截断的区别。3.3 把循环改写成SIMD版本的五步法在实际优化和笔试里我反复用同一套循环向量化方法先看数据依赖确认循环迭代之间没有相互写同一个地址。明确数据类型和位宽确定每次能处理几个元素。uint8是16个、float是4个、double是2个分别对应128位寄存器。用load指令把连续内存加载到寄存器尽量用对齐版本如果原始内存不能保证对齐就用非对齐版本并接受一点代价。做核心计算优先选择乘加融合和长乘累加指令避免高位溢出。处理尾部剩余元素用标量循环兜底。这套流程虽然简单但能避免90%的现场写码事故。笔试代码不一定要求编译运行但逻辑漏洞一眼就能被阅卷人看出来边界处理是重点。4. 实操补课搭一套支持x86/ARM的优化验证环境4.1 本机做性能基线perf和cachegrind的用法笔试题目可以靠纸面推演但真正的代码优化水平一定得靠实验验证。我考完试后做的第一件事是把所有笔试考到的优化手法在自己机器上搭环境跑一遍。x86侧很容易Linux自带的perf就能用。拿到一个优化前后函数我习惯先跑三件事perf stat -e cycles,instructions,cache-misses,cache-references ./bench perf stat -e branches,branch-misses ./bench valgrind --toolcachegrind --cachegrind-out-filecache.out ./bench cg_annotate cache.outperf的cycles和instructions能算出CPI判断是否受指令依赖限制cache-misses能定位访存问题分支缺失率超过5%基本就要考虑改写分支逻辑或者用查表替代分支。cachegrind的好处是能给出逐行的cache命中情况不用改代码就能看到热点函数内部哪个循环是瓶颈。笔试里曾经出过一道题给你一段代码和perf统计输出让你指出“为什么在开启-O2后性能反而下降”。我实际跑过的场景中最常见的原因是编译器自动向量化失败或者函数被内联后代码段膨胀导致指令缓存缺失增加。这提醒我们笔试答题时不要默认编译器是万能的。4.2 ARM交叉编译与QEMU模拟环境的搭建没有实体ARM板子又想练NEON用QEMU用户态模拟加交叉编译工具链是最靠谱的方案。过程其实不复杂以Ubuntu/Debian系为例sudo apt install gcc-arm-linux-gnueabihf libc6-dev-armhf-cross qemu-user写一个测试程序交叉编译arm-linux-gnueabihf-gcc -O2 -marcharmv7-a -mfpuneon -mfloat-abihard \ yuv_gray_neon.c -o yuv_gray_neon qemu-arm -L /usr/arm-linux-gnueabihf ./yuv_gray_neon需要注意必须带-L指定sysroot否则qemu-user跑起来会找不到动态链接器报错类似于/lib/ld-linux-armhf.so.3: No such file or directory。如果不想处理动态库也可以直接-static编译但那样得不到真实内存布局下的性能特征只适合验证功能正确性。QEMU用户态模拟对NEON的支持已经比较完善我可以直接在x86机器上验证NEON代码的功能正确性。性能数字仅供参考但指令选择对不对、数据类型宽不正确、有没有意外的函数调用开销都能看出来。笔试里你会遇到“用ARM编译器交叉编译某个库”的场景描述实际工作里也很常见提前跑通这套环境能省大量时间。4.3 一个完整的YUV转灰度优化实例这个例子是笔试方案设计题的常见变形也是我建议所有想入门代码优化的人都亲手做一遍的小项目。输入是YUV420格式的图像数据要转成8位灰度图。朴素C版本void yuv_gray_scalar(const uint8_t *y, const uint8_t *u, const uint8_t *v, uint8_t *gray, int n) { for (int i 0; i n; i) { gray[i] (77 * y[i] 150 * u[i] 29 * v[i]) 8; } }优化路径按这个顺序走第一版先加-O2 -marchnative看编译器能自动向量化到什么程度第二版在x86上手工SSE向量化在ARM上手工NEON向量化第三版做循环展开加数据预取。在x86上我实测的性能提升大概是O2比O0快3~4倍手工SSE比O2快1.5倍左右如果把数据预取和缓存分块加上在超大图上又能再提升10%~20%。这套优化轨迹本身就是很好的笔试答题素材因为它把“识别瓶颈选型、分步优化、验证每一步”的完整方法论展示出来了。5. 高频翻车点与排查记录5.1 NEON仿真的“隐性类型转换”陷阱我在笔试和实际编码中都栽过同一个跟头NEON intrinsics对类型非常严格uint8x8_t不能直接赋给uint16x8_t必须用扩展指令vmovl_u8或vmull_u8而x86的SSE则大多通过_mm_cvtepu8_epi16这类扩展指令显式转换。笔试改错题里经常混着各种符号位扩展和零扩展符号扩展还要用vmovl_s8。我总结的排查流程是先用clang -target armv7a-none-eabi -mfpuneon编译一遍让编译器把带类型的错误暴露出来然后检查所有加减乘运算的操作数位宽是否一致最后检查缩窄指令的舍入语义。千万不要觉得报错是“编译器太严格”intrinsics的类型系统在帮你提前发现问题。5.2 非对齐内存访问导致段错误用_mm_load_ps或vld1q_f32时如果内存地址没有对齐到16字节运行时就可能报段错误。这个问题在ARM上更隐蔽因为部分NEON加载指令对非对齐支持不完整或者某些编译器版本会生成依赖对齐的指令。笔试题里面有一类改错题就是“这段NEON代码在板子上崩溃了请你找出原因”不少人只盯着算法逻辑忽略了malloc默认只保证16字节对齐而图像数据行的起始地址可能出现偏移。解决方式就是两条要么用_mm_loadu_ps或vld1q_u8这种非对齐加载指令要么在分配缓冲区时用posix_memalign、aligned_alloc或者__attribute__((aligned(64)))强制对齐。真正的工程场景里外部输入数据对齐不可控所以最稳妥的还是用非对齐加载指令。5.3 交叉编译环境依赖缺失自己搭ARM交叉编译环境时最常见的报错是头文件找不到或链接时找不到库。比如你写了一个用到libjpeg的程序交叉编译链接阶段报找不到libjpeg.so原因往往不是没装库而是宿主机的/usr/lib/x86_64-linux-gnu里只有x86版本ARM版本需要单独用arm-linux-gnueabihf的sysroot。解决办法是sudo apt install libjpeg-dev:armhf arm-linux-gnueabihf-gcc ... -ljpeg还有一批更隐蔽的问题比如交叉编译时用了宿主机的pkg-config导致编译参数里带了x86的include路径。要用pkg-config --hostarm-linux-gnueabihf或者环境变量里指定PKG_CONFIG_PATH到ARM sysroot。这个坑在笔试里不太可能直接考但在真正的工程复现中很常见也值得记下来。5.4 工具链版本警告和perf权限问题有时候交叉编译时会看到类似“registered arm compiler ignored, version needs to be 5 or higher”的工具链警告这通常是环境变量里同时注册了多个路径或版本不一致。我在实际调试中的经验是先检查PATH和CROSS_COMPILE变量再用arm-linux-gnueabihf-gcc --version确认实际调用的是哪个编译器如果在IDE里遇到打开工具链管理面板查看是否有重复注册的编译器路径。另一件高频事项是perf权限不足报错You may not have permission to collect stats。解决办法是临时设置perf_event_paranoidsudo sysctl kernel.perf_event_paranoid-1笔试虽然不会真让你现场跑perf但方案设计题里一旦提到用perf定位瓶颈很多没跑过真机的人只会在字面上讨论而漏掉权限、内核配置这些工程可行性问题。面试官其实很乐意听到这类“微观坑”因为说明你真刀真枪调过。5.5 一个容易忽略的问题平台差异带来的数值不一致做题时还有个高频考点是精度问题。NEON和SSE的浮点计算顺序不同可能带来微小的数值差异笔试中经常考察你得知道这种差异是否影响最终结果。对于图像增强、灰度转换这类任务差1~2个灰度值通常无所谓但如果是AdaBoost或深度学习推理差值累积起来就可能影响最终精度。所以工作里判断一个优化方案是否可接受从来不是只看快不快还要看结果误差范围有没有超出业务容忍度。笔试里遇到“优化后结果与原始C版本不完全一致是否接受”这类讨论题别急着回答“不接受”要先问算子和数据场景。这个判断力是校招面试官特别看重的。6. 考后复盘这套笔试真正想筛选的能力整套卷子看下来我最大的感受是商汤想招的不是“知道很多优化技巧”的人而是“能对一次性能优化做出完整闭环分析”的人。什么叫完整闭环就是看到一段代码时先分清瓶颈类型是访存还是计算再选择对应的SIMD、缓存分块、多线程手段优化完能用工具验证收益。这套流程在笔试的每个角落都有体现Cache计算题考的是你的访存直觉NEON改错题考的是你踩过的类型坑方案设计题考的是你有没有完整的性能优化决策树。我当年准备这套笔试时也有一些笨办法比如把常用的SSE和NEON intrinsics整理成一张速查表逐个练手写例子然后用QEMU模拟ARM环境跑自己写的图像算子再对比x86的结果确保两边行为一致。这些原始办法很费时间但效果非常扎实后来真正入职做优化工作面对的问题也没跳出这套框架。7. 给后来者的一套备考行动清单如果现在有人告诉我他要准备类似的代码优化岗位笔试我会让他按这个顺序准备第一花一周把体系结构基础补齐重点在cache模型和寄存器差异第二花三天手写一遍SSE和NEON各五个经典算子包括图像灰度转换、矩阵转置、向量点积、像素阈值化、RGB转BGR第三搭好交叉编译加QEMU环境把每一个算子都做到在模拟器上跑通第四把优化前后的性能数据记录下来练习描述“为什么这个改动有效”。最后再说一个细节笔试第一场不会要求你把某个算子的所有优化版本完整写出来但很可能会要求你在两个方案之间做取舍并说明理由。这种取舍题的答题思路应该是我在前面反复强调的三步先看瓶颈类型、再看向量化成本、最后看工程风险。思路清晰比写对某条指令更能得分。我自己的体会是代码优化不是一个纯体力活它本质上是在有限硬件资源下做系统工程。笔试只是给你一个情境看你能不能像解决真实业务问题一样去分析、取舍、验证。把每一道真题当成一个小项目去复盘收获远大于刷题本身。
返回列表