
2019年秋天我坐在寒武纪软件岗的笔试考场里。那会儿AI芯片公司正是最热的风口寒武纪作为国内做AI芯片的代表企业一场软件岗笔试能吸引几百号人投简历。拿到卷子翻了翻第一感觉是这不像互联网大厂那种全考算法题的风格而是把C/C、操作系统、体系结构、算法、深度学习软件栈全塞进了一套卷子里题量不小时间非常紧。当时我觉得这套题考得有点“杂”后来这些年自己做了推理引擎相关的工作回头看才发现这套“试题二”里的每一道题几乎都踩在AI芯片软件栈的核心知识点上。这篇文章就按我记得的题目顺序完整复盘一遍重点讲清楚每道题背后的原理、我当时的答题思路以及站在今天结合寒武纪开发者社区里能下载到的MagicMind推理引擎该怎么理解这些考点。1. C/C与内存布局一道虚函数送分题背后的三连问1.1 原题回忆sizeof到底怎么算这套卷子里C/C部分第一题我就印象很深因为它不是直接问“虚函数是什么”而是给了一段类定义让算sizeof。原题大概是这样的class Base { public: virtual void f() {} virtual void g() {} int a; char c; }; class Derived : public Base { public: void f() override {} long long b; };问题分两问第一问是sizeof(Base)和sizeof(Derived)分别是多少第二问是调用Derived d; d.f();时程序是怎么找到Derived::f()这个函数体的。先说答案。在64位Linux、Itanium C ABI、默认8字节对齐的环境下sizeof(Base)是24sizeof(Derived)也是24。为什么因为Base里有一个隐藏的虚表指针vptr占8字节int a占4字节char c占1字节。8加4加1等于1313向上对齐到8的倍数就是16不对这里要重新算。等等让我重新推导一下。Base的布局vptr在偏移0占8字节a在偏移8占4字节c在偏移12占1字节。末尾padding到对齐边界。类的对齐要求是最大成员对齐vptr是8字节对齐所以Base末尾要补到16的整数倍不类是8字节对齐13补到16。所以sizeof(Base) 16。不对不对我再想一下。实际上64位Linux下Base应该是16字节。我前面口算说24是错的。Derived呢它继承了Base的vptr、a、c加上自己的long long b。布局是vptr偏移08字节a偏移84字节c偏移121字节这时候为了long long b的8字节对齐需要跳过3字节paddingb偏移16到23。整块大小是24对齐是8所以sizeof(Derived) 24。这题真正的考点不是让你背答案而是看你能不能把内存布局推导出来。我在考场上就是先在草稿纸上画出偏移量逐个成员排位置最后才填答案。这里有两个最常见的错误第一忘了类里有隐形的虚表指针第二忘了long long要求8字节对齐前面要补padding。32位环境下vptr只有4字节答案又不一样所以这种题一定要注明平台不注平台直接甩答案的基本可以判定为不严谨。1.2 虚函数调用是怎么路由的第二问d.f()的调用过程这是C面试里最经典的“八股”之一但寒武纪这题问得更细一点它让你画出步骤。我的回答是分三步首先编译器看到d.f()因为f是virtual函数它不能直接静态绑定到Derived::f()而是生成一条间接调用指令从对象d的起始内存处取出vptr。这个vptr指向Derived类的虚函数表表里的第0个槽位在构造Derived对象时已经被填成了Derived::f()的地址最后通过call [vptr 槽位偏移]完成跳转。刚学C的人容易误以为虚函数表是存在对象里的其实对象里只有vptr虚函数表是每个类一份编译期就生成好了放在只读数据段。继承的时候如果子类重写了某个虚函数子类的虚表中对应槽位就会被覆盖成子类函数指针没有重写的虚函数槽位会原样拷贝父类的指针。这也是为什么虚函数调用比普通函数调用贵一点点它多了一次间接寻址但这个开销在高性能代码里通常可以忽略。另一个隐藏考点是为什么Base和Derived的sizeof差别这么大Derived只比Base多了一个long long但大小却从16变到24多出来的那8个字节几乎全是padding和内存对齐的代价。这种题放到AI推理引擎的工程场景里就有实际意义了——如果你在算子实现里定义了一个包含虚函数、又塞了很多小成员的类并且频繁地创建、拷贝、移动它内存占用翻倍、cache命中率下降是实实在在的性能杀手。1.3 我当时踩过的坑只背结论不推过程说实话这种题我在笔试前刷过很多但第一次在草稿纸上认真推导成员偏移的时候才发现自己以前很多结论是半懂不懂的。比如我一度以为所有类的对象里都应该先放vptr再放别的成员其实虚继承和多继承场景下vptr不止一个布局规则还更复杂。我给后来者的建议是不要背sizeof(Base) 16这种具体数字而是每次都在草稿纸上老老实实画一遍偏移图。你把这个过程练熟了哪怕考场上遇到一百个成员变量的类也不慌。另外可以记住一个口诀对象大小等于最后一个成员偏移加上自身大小再向上对齐到整个类的对齐数整个类的对齐数等于最大成员对齐数通常是最大标量成员的大小。2. 一道矩阵转置题把缓存命中率考到底2.1 原题回忆两种循环顺序性能差了几十倍这题我印象特别深因为当时很多同学出了考场都在对答案。原题是有一个1024×1024的float矩阵A按行优先存储在内存里现在要把A转置到同样大小的矩阵B里B[j][i] A[i][j]请你写出两种循环实现并分析哪一种更高效为什么。第一种是最直觉的写法for (int i 0; i n; i) for (int j 0; j n; j) B[j][i] A[i][j];第二种是把内外层循环交换for (int j 0; j n; j) for (int i 0; i n; i) B[j][i] A[i][j];这两种写法访问的A和B的存储位置完全不同。A按行优先存储所以A[i][j]的地址是A i * n * sizeof(float) j * sizeof(float)同一行内地址连续。第一种写法里外层是i内层是jA是按行连续访问的缓存命中很好但B[j][i]这一侧就惨了内层i变化的时候B的地址每次跳一整行的跨度也就是每次写B都要把一个cache line换进换出产生大量缓存缺失。现代CPU的cache line一般是64字节一个float占4字节也就是说一个cache line能装下16个float。理想情况下你连续读16个float只需要一次内存访问但如果每次都要跳到下一个cache line才能拿到一个float那内存带宽就全浪费在读缓存缺失上了。这个题的差距在1024×1024的规模下能有多大我做过类似的实验第一种写法比第二种慢几十倍是常态如果矩阵再大一点差距会拉得更开。2.2 正确答案分块转置blocked transpose要同时让A和B都获得较好的cache局部性标准解法是分块。我们把矩阵切成BLOCK×BLOCK的小块一次处理一块让这块A的块和B的块都能尽量留在cache里。#define BLOCK 16 for (int i 0; i n; i BLOCK) { for (int j 0; j n; j BLOCK) { for (int ii i; ii i BLOCK ii n; ii) for (int jj j; jj j BLOCK jj n; jj) B[jj][ii] A[ii][jj]; } }这里BLOCK取16是因为16个float正好占64字节等于一个cache line。你再看这个访问模式A[ii][jj]在块内按行连续读每读16个float就换一行B[jj][ii]在块内按列写虽然地址不连续但整个块只有16×16256个float也就是16个cache lineL1 cache完全装得下。所以A侧的连续读和B侧的小块写入都能吃到cache红利。我考场上写的是16×16分块还顺手在注释里写了“BlOCK大小应根据目标平台cache line大小调优”。后来我在自己的项目里测过这个参数确实不是越大越好过大的BLOCK会导致B块的cache line被提前逐出太小又分块开销太大。一般在8到32之间选具体要跑benchmark才知道。2.3 为什么寒武纪要考这个访存模式就是算子的命这道题放在AI芯片公司的笔试卷里不是偶然。你去看寒武纪开发者社区里MagicMind的文档里面大量提到算子的内存布局优化、数据排布转换、算子融合——所有这些的底层都和这道矩阵转置题是同一个问题数据在内存里怎么放决定了访存效率。比如卷积运算里常用的im2col优化把卷积展开成矩阵乘法本质上就是一次大范围的布局重排。展开后的矩阵如果按行访问友好整个GEMM的访存效率就上去了。MagicMind在做算子融合的时候会把conv后面的batch norm、ReLU合并成一个算子这个操作一方面减少了kernel launch的CPU开销另一方面也让中间张量不需要写回内存再读出来等于在软件栈层面做了一次“分块转置”。所以这道题看似在考cache其实是在考你屁股有没有坐在“AI芯片软件栈”这张椅子上。你能从矩阵转置想到算子布局、想到内存复用、想到推理引擎的图优化说明你是真理解这个行业的性能瓶颈在哪。3. 并发题不复杂但死锁排查思路要清晰3.1 原题回忆两个线程互相等锁怎么判断是不是死锁操作系统部分的题里有一道让我当时犹豫了很久假设线程A持有锁L1等待锁L2线程B持有锁L2等待锁L1。问这是不是死锁如果不是为什么如果是怎么解决这是一个经典的“哲学家吃面”式场景。按死锁的四个必要条件去套互斥条件满足——L1和L2都是互斥锁持有并等待满足——A拿着L1等L2B拿着L2等L1不可剥夺满足——两个线程都不能强行抢对方手里的锁循环等待满足——A等BB等A形成一个环。四条件全满足所以这确实是死锁。我当时是先写结论再逐个条件证明最后给出解决方案。这个答题结构很稳因为阅卷人一眼就能看出你是真的懂死锁而不是背了个概念。解决办法我写了几种第一规定全局加锁顺序比如所有线程都先拿L1再拿L2这样A先拿L1等L2B也必须先拿L1B拿不到L1就不会拿着L2去等L1循环等待被打破第二用pthread_mutex_trylock尝试加锁拿不到就释放已有锁过会儿重试第三用带超时的锁避免永久阻塞。放在工程里最常用也最推荐的是第一种加锁顺序不统一是死锁最常见的根因。3.2 另一道两个线程交替打印奇数和偶数并发部分还有一道手写题开两个线程一个线程打印奇数一个线程打印偶数要求交替输出1、2、3、4……一直到100。这题考的是条件变量和互斥锁的配合。我的实现思路是用一个互斥锁加一个条件变量再加一个共享的当前计数。伪代码大概是std::mutex mtx; std::condition_variable cv; int num 1; void print_odd() { while (num 100) { std::unique_lockstd::mutex lock(mtx); cv.wait(lock, []{ return num % 2 1; }); if (num 100) std::cout num std::endl; num; cv.notify_all(); } } void print_even() { while (num 100) { std::unique_lockstd::mutex lock(mtx); cv.wait(lock, []{ return num % 2 0; }); if (num 100) std::cout num std::endl; num; cv.notify_all(); } }关键点在于wait不能单独用必须配合一个条件谓词防止“虚假唤醒”。我见过很多人写if (pred) wait()而不是while循环等多线程环境下很容易出bug。条件变量的正确用法一定是while循环加谓词这是《C并发编程实战》里反复强调的。3.3 从笔试题到推理引擎的并发控制这套并发题放到现在的AI推理场景里也很好理解。你部署一个模型多个请求同时打进来推理引擎往往用多个执行流stream并行跑不同batch的算子每个流内部还会做算子间pipeline。如果两个流同时要访问同一个内存池分配器申请中间张量内存分配器不加锁或者加锁顺序不统一很容易出两类问题一类是数据竞争一类是死锁。MagicMind在管理推理任务时一个比较核心的设计就是尽量把并发控制下沉到图级别一个执行流对应一组算子依赖关系所有算子在同一个流上严格按序执行天然避免了锁竞争。跨流之间的同步用event而不是多个互斥锁互相嵌套。这就是“避免死锁”思想在产品里的体现。我现在带新人的时候经常说笔试里的死锁题不是让你背四个条件而是让你培养一种嗅觉——看到多个锁同时出现在一个函数里第一反应就应该是审视加锁顺序。4. 推理软件栈的选做题从静态shape到计算图内存复用4.1 原题回忆算卷积输出尺寸和参数量这套卷子的深度学习部分明显是选做题风格不要求你会训练模型但要求你对卷积神经网络的基本计算滚瓜烂熟。原题我记得是输入是224×224×3的图像卷积核大小是7×7stride2padding3输出通道数是64问输出特征图的宽高是多少这个卷积层有多少个参数。卷积输出尺寸公式是out (in 2 * padding - kernel_size) / stride 1。代进去(224 2 * 3 - 7) / 2 1 448 / 2先算224 6 - 7 223223 / 2 111.5向下取整是111111 1 112。所以输出是112×112。等等这里要小心整数除法。223除以2在浮点里是111.5卷积输出尺寸的公式要求如果除不尽实际是向下取整再加上1所以111.5向下取整是111再加1等于112。或者换一种表达一般深度学习框架里这个计算是floor((in 2p - k) / s) 1即先floor再1。112没问题。参数量就是kernel size乘以输入通道数再乘以输出通道数再加bias7×7×3×64 64 9408 64 9472。如果不用bias就是9408。这题我猜寒武纪的真正意图是看你知不知道卷积参数和特征图尺寸是两码事很多人把二者混在一起算稀里糊涂就算错了。4.2 另一道更值钱的题一张计算图的中间张量内存能不能复用还有一道比较开放的题我现在回想起来觉得是整套卷子的精华。题目给了一个很简单的计算图输入A经过Conv得到BB经过BatchNorm得到CC经过ReLU得到DD再经过一个Pooling得到E。问在推理时哪些中间张量的内存在时间上是不重叠的可以进行内存复用这道题的本质是张量生命周期分析。把计算图画成DAG每个节点算子有输入张量和输出张量每个张量从被生产者写出的那一刻开始存活到最后一个消费者读它的那一刻结束。内存复用的规则很简单两个张量的生命周期如果不重叠就可以复用同一块内存。在这个例子里B被BatchNorm消费后BatchNorm的结果是C。B的最后一个消费者是BatchNormD出来后C的使命也就结束了。所以B、C、D三个张量的生命周期首尾相接但互不重叠理论上可以共用同一块显存。A是输入E是最终输出一般单独分配。这就引出了推理引擎里一个极其重要的概念内存规划memory planning。在静态shape推理里所有张量的形状是编译期确定的内存规划器可以在模型编译时把所有中间张量一次性分配好统一放进一个内存池按生命周期分析结果复用。这就是为什么静态shape的推理引擎往往比动态shape省显存——动态shape下无法在编译期确定张量大小只能运行时动态分配内存碎片和显存占用都会显著上升。MagicMind在这方面的做法我后来研究过一些它会在模型编译阶段做完整的图优化和目标平台内存规划同时把算子融合也纳入规划流程。比如ConvBatchNormReLU如果融合成一个算子那么B、C、D这些中间张量在物理上就不存在了直接在寄存器或片上缓存里完成整个链路的计算连写入全局内存都省了。这比我笔试时写的“复用同一块内存”又高了一个层次——最优的复用就是不分配。4.3 如果你当时没学过深度学习这题怎么蒙这套卷子是软件岗不是算法岗所以其实有不少非AI背景的同学也来考。他们看到卷积计算题会慌但其实这题给分很良心只要你写出公式把数字代进去过程分就能拿大半。参数量那题更是纯算术题不知道卷积在干什么也能算。我的建议是投寒武纪这类AI芯片公司的软件岗哪怕你简历上完全没写过深度学习也一定要把卷积层的输出尺寸公式、池化尺寸公式、全连接的计算方式搞清楚另外至少知道ReLU、BatchNorm是干什么的。这些东西花一个晚上就能看完但在笔试里能救你至少十几分。5. 手撕层序遍历笔试算法题的性价比选择5.1 原题二叉树层序遍历要求逐层输出算法题部分我记得有一道很典型的二叉树层序遍历给定一个二叉树返回它按层序遍历得到的节点值要求每一层的节点单独放一个vector即返回类型是vectorvectorint。这题我在LeetCode上刷过但寒武纪的题目要求稍微严一点必须逐层输出不能用普通的单队列一把梭。正确做法是用BFS加一个层级标记vectorvectorint levelOrder(TreeNode* root) { vectorvectorint res; if (!root) return res; queueTreeNode* q; q.push(root); while (!q.empty()) { int levelSize q.size(); vectorint level; for (int i 0; i levelSize; i) { TreeNode* node q.front(); q.pop(); level.push_back(node-val); if (node-left) q.push(node-left); if (node-right) q.push(node-right); } res.push_back(level); } return res; }关键点就在int levelSize q.size()这一行。很多第一次写的人会在while循环里直接pop直到队列空结果所有层的节点混在一起完全没法区分。你要先记下当前队列里有多少个节点——这些就是当前层的全部节点——然后for循环把这一层处理完再进入下一轮while循环。这个手法不只在二叉树上有用凡是需要“按批次处理”的BFS场景都是同一个套路比如求二叉树最小深度、判断一棵树是否是完全二叉树、多叉树的层序遍历。5.2 进阶变体之字形遍历笔试卷子在这题下面还有一个小问或者说是口试追问如果要求之字形遍历第一层从左往右第二层从右往左第三层又从左往右怎么办两种常见思路。第一种是用一个deque奇数层从尾部pop节点、把子节点按左到右push到头部偶数层反过来。这个实现比较绕容易写错。第二种更简单还是按正常的层序遍历把每层结果放进vector最后遇到偶数层就把这个vector reverse一下。虽然reverse多花O(n)时间但代码简单、不容易出错笔试场景下推荐第二种。5.3 我的时间分配策略基础题优先压轴题看情况这套算法题的整体难度其实不算高没有那种一眼望不到头的hard题。但我还是要提醒一句不要把时间全砸在这里。整张卷子前面有C、操作系统、体系结构、深度学习一堆题每道都是分算法题哪怕只写个暴力解也能拿不少过程分。我当时的时间分配大概是C/C和体系结构部分用了一半多一点的时间操作系统的并发题用了四分之一剩下不到半小时给算法题。层序遍历这种送分题先拿到手再看后面的变体题有没有思路有思路就写没思路就把核心函数签名和BFS框架写出来保证不让阅卷人觉得你完全不会。我还想多说一句笔试题里的算法题和你在LeetCode上刷题不太一样。笔试题更看重“能不能快速写出一个正确、可运行的版本”而不是“能不能写出最优雅的最优解”。所以平时刷题不要总在hard题上死磕中等题、尤其是树、链表、栈、队列、二分、动态规划这些高频类目做到看到题目就能条件反射地搭出框架才是性价比最高的准备方式。6. 当年这套题放到今天依然能打复盘与备考建议6.1 这套卷子的核心逻辑考的是AI芯片软件栈的思维底座整套卷子考完我的第一感受是“杂”第二感受是“实在”。它不考你背了多少机器学习模型的细节也不考你刷了多少道LeetCode而是想确认你有没有能力在AI芯片的软件栈里干活。C内存布局对应的是算子实现时类设计和内存分配的问题缓存命中率对应的是数据排布和访存优化的底层问题死锁和并发对应的是推理引擎多流调度的问题卷积尺寸和计算图内存复用对应的是推理引擎图优化和内存规划的问题算法题对应的是基本的工程编码能力。一条线串下来你会发现这些考点不是随意拼凑的而是从“一个推理引擎的开发者每天要面对什么”反推出来的。这几年寒武纪的软件栈越来越重从最初以芯片和底层驱动为主到现在开发者社区里能直接下载到MagicMind这样的推理加速引擎产品。MagicMind解决的问题恰恰就是这套卷子里那几道看似“零散”的题目如何把训练好的模型转换成一个在MLU芯片上高效执行的推理程序。这中间涉及计算图的解析与优化、算子融合、数据布局转换、内存复用、静态/动态shape处理、多流并发调度——每一环都对应着笔试里某个具体的知识点。6.2 给后来的投递者具体准备清单如果你现在准备投寒武纪的软件岗笔试我的建议是按照下面这个清单准备考察方向必会知识点建议资料C/C虚函数与内存布局、智能指针、move语义、内存对齐、模板基础《Effective Modern C》、各种C面试题汇总体系结构cache line、局部性原理、循环优化、SIMD基本概念《深入理解计算机系统》第6章操作系统死锁四条件、加锁顺序、条件变量、线程同步《操作系统导论》并发部分深度学习推理卷积输出尺寸、参数量计算、计算图、算子融合、量化基本概念各框架推理优化文档、MagicMind开发者文档算法树、链表、栈、队列、二分、基础动态规划LeetCode高频题、剑指Offer这里面最容易临时抱佛脚的是深度学习和体系结构。卷积计算和cache这两块花两个晚上就能有质的提升但收益是实实在在的。操作系统和C则要靠平时积累临时背题容易翻车因为寒武纪的题经常不是直接背概念而是给你一个场景让你分析。6.3 考完之后我把这套题默写了一遍说实话当时和我一起笔试的同学里好几个出来就说“感觉凉了”。结果后来有人进了面试聊天才发现大家对这套卷子的感受完全不一样。觉得“凉”的人大多是看到一堆没见过的AI软件栈场景题就慌了觉得简单的反而是那些把每道题都当成普通面试题来答的人。我个人在笔试后做了一件现在想想挺有帮助的事情当天晚上趁记忆还热把能回忆起来的题全部默写了一遍然后逐个去查答案、推导过程、延伸阅读。这样做的好处是一个星期后收到面试通知时我已经把所有笔试题都吃透了。面试官追问“你笔试里矩阵转置那道题还有没有更好的优化”时我能直接说出用AVX指令做向量化、用cache block调优、甚至用tiling处理超大矩阵多层缓存。这些延伸全都来源于笔试后的主动复盘。所以你如果要去考寒武纪或者准备任何一家做AI基础设施的公司我的建议都是考完了不等于结束了把每道题都当成一个知识入口顺着它往深了挖。MagicMind的文档、开发者社区的案例、各类推理引擎的源码分析都是很好的延伸方向。笔试只能决定你能不能过筛但真正让你在面试里发光的永远是笔试之后你还愿意学多少。