尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

寒武纪后端笔试复盘:从C++内存对齐到并发与算法

寒武纪后端笔试复盘:从C++内存对齐到并发与算法 2019年秋天我坐在某高校的机房里屏幕上是寒武纪后端岗的笔试题。说实话当时我对这家公司的了解仅限于做AI芯片的甚至不确定这个后端到底做什么——是写Java微服务还是搞数据平台直到做完题我才意识到这场笔试跟互联网公司的后端笔试完全是两个物种它不问你Redis怎么缓存不问Spring怎么注入反而死磕C内存布局、死磕Linux下的并发控制、死磕一道看起来简单但处处是坑的链表题。寒武纪2019秋招后端岗笔试是我做过的所有笔试里最有芯片公司特色的一场。它不是靠八股文堆出来的题海战术而是每一道题都能看出出题人想让你理解软件是怎么和硬件打交道的。这篇文章不做原题搬运只做题型复盘和考点拆解把当时我踩过的坑、后来复盘才想明白的点以及如果重新准备会怎么做一并写出来。准备投芯片类公司后端岗的朋友值得花十分钟看完。1. 这场笔试的基本盘为什么芯片公司的后端笔试这么偏科1.1 寒武纪后端岗在招什么人先聊一个被很多人忽略的问题寒武纪的后端跟BAT的后端是一回事吗答案是不完全一样。芯片公司的后端岗位通常落在几个方向AI编译器工具链、SDK与运行时、驱动与固件、开发者平台。哪怕是偏业务的平台后端也绕不开底层性能问题因为你服务的目标是一块芯片不是一个普通的Web应用。这就解释了为什么笔试题目里C/C、操作系统、内存布局的占比极高。2019年寒武纪正处于云端训练芯片和边缘推理芯片双线推进的阶段后端工程师要写的代码很多是跑在嵌入式环境或者紧贴硬件的Linux环境里的。这种环境没有JVM帮你管理内存没有框架帮你屏蔽并发问题一切都要回到计算机基础。我当时拿到卷子扫了一眼题型大致分四块C/C基础与内存、操作系统与并发、网络与数据库、手撕算法。没有Java方向的选择题没有Spring框架题这和同时期面互联网大厂后端的朋友形成了鲜明对比。如果你是用Java那套技术栈准备的秋招看到这份卷子大概率会懵一下。1.2 试卷结构和我印象最深的三道题整张卷子大概十道大题部分题目还有追问。我印象最深的三道题不是因为多难而是因为它们精准地戳中了我的知识盲区一道结构体大小的计算题涉及内存对齐答案是12而不是一眼看过去的9一道要求手写条件变量实现线程轮流打印的编程题考察wait为什么必须放在循环里一道链表的K组反转要求写完整可运行代码并分析空间复杂度。这三道题单独拎出来每一道都不算超纲但组合在一起就能看出这家公司对后端工程师的期待既要懂底层机制又要能写出健壮的工程代码还要有清晰的复杂度意识。这种偏科不是走偏而是方向明确。1.3 与互联网后端笔试最大的差异如果非要总结这场笔试和互联网后端笔试的最大差异我会说互联网后端笔试考的是你知不知道有这个东西寒武纪这类芯片公司笔试考的是你知不知道这个东西在底层是怎么跑的。举个例子。互联网后端经常考HTTP状态码有哪些寒武纪不看这个它可能问你TCP三次握手中SYN Flood攻击为什么有效互联网后端常问Redis的过期策略寒武纪可能问你volatile关键字在C里的语义和Java里有什么不同。这些差异背后是岗位性质的不同——你做Web后端性能瓶颈可能在一堆微服务之间你做芯片相关的后端性能瓶颈直接落在CPU指令、内存带宽和IO路径上。所以准备这种笔试方向比努力更重要。2. C/C底层题指针自增、内存对齐和一个template陷阱2.1 题面还原结构体大小为什么答案是12不是9原题我记不太清字段了但结构是类似的struct Test { char a; // 1字节 int b; // 4字节 char c; // 1字节 };问sizeof(struct Test)等于多少。如果只按字段累加1416但答案是12在32位和64位下通常都是12取决于对齐数和编译选项。这就是内存对齐在起作用。很多刷惯了Java面试题的人会在这道题上翻车因为Java里没有直接暴露内存对齐这个概念。但在C/C后端岗这是一个非常核心的问题你写的结构体是要被塞进一块固定大小的共享内存、或者通过socket发给另一个进程的如果布局不确定通信协议就对不上。2.2 解析内存对齐的规则与用途内存对齐的规则不复杂每个成员变量的起始偏移量必须是其自身大小的整数倍结构体的总大小必须是最大成员大小的整数倍。上面的例子char a占偏移0int b必须从偏移4开始因为4是4的倍数于是a后面空出3个填充字节b占4~7char c占偏移8总大小9再对齐到最大成员int的4的倍数就是12。为什么CPU要求对齐因为现代CPU访问内存是按字word读取的比如64位CPU一次读8字节。如果一个int横跨在两个内存字之间CPU需要读两次再做拼接这是极大的性能浪费。有些架构甚至直接报错。所以编译器默认会填充字节来保证对齐而#pragma pack可以改变对齐规则用于网络协议头、磁盘文件格式这类需要精确控制布局的场景。我在答题时写下了12并且补充了#pragma pack(1)会变成6以及位域字段bit field会进一步压缩。后来复盘时我觉得多写这几句解释比只写一个答案拿到的分数更多因为笔试除了看结果也看你对一个知识点能吃多深。建议读者遇到这类题答案之外一定要写出计算理由。2.3 另一道指针题数组名与引用的陷阱C/C部分还考了一道指针题印象里大致是问int arr[5] {1,2,3,4,5}; int *p arr; cout *(p) endl; cout *p endl; cout sizeof(arr) sizeof(p) endl;第一问输出1和2这是指针自增的基本语义p先返回当前指针指向的值再移动指针。这点大部分人都会。真正容易错的是最后一问sizeof(arr)是205个int×4字节而sizeof(p)是864位系统下指针占8字节。arr作为数组名在sizeof里代表整个数组但在表达式中它又会退化成指向首元素的指针。这种数组名既是数组又是指针的二义性是C/C笔试的经典陷阱。我当时的做法是先把print顺序理清楚再把sizeof单独说明。这道题的关键不在于背结论而在于理解数组退化array decay发生的时机作为sizeof、的操作数时数组名保留数组属性作为函数实参或参与算术运算时退化为指针。这解释了为什么函数里传数组一定要再传一个长度参数也解释了为什么裸数组这么容易在工程里出bug——我在SDK开发里就见过同事把数组传进函数后在函数内部用sizeof求长度结果算出来是个指针大小的尴尬bug。2.4 答题时容易忽略的C对象生命周期问题C部分还有一道涉及生命周期和析构顺序的题形式类似class Base { public: Base() { cout Base endl; } virtual ~Base() { cout ~Base endl; } }; class Derived : public Base { public: Derived() { cout Derived endl; } ~Derived() { cout ~Derived endl; } };问Base *p new Derived(); delete p;的输出顺序。答案是Base、Derived、~Derived、~Base。这个知识点本身不难但出题人如果追加问一句如果析构函数不是virtual会怎么样就暴露了很多人只是背过答案没理解为什么基类析构函数必须是virtual。原理是这样的delete p时编译器要根据静态类型Base*决定调用哪个析构函数。如果基类析构不是虚函数delete p只会调用~Base()Derived部分申请的资源就不会被释放。而一旦基类析构是virtual析构调用会走虚函数表最终调到~Derived()再自动调用基类析构。这也是C里多态删除必须配virtual析构这条铁律的由来。芯片相关的软件栈里这种派生关系很常见比如不同的算子实现继承同一个基类接口如果析构不写成virtual跑起来内存泄漏都找不到原因。3. 操作系统与并发题从死锁四条件到条件变量手写3.1 题面还原两个线程轮流打印用条件变量实现操作系统与并发部分考了一道非常经典的编程题两个线程一个打印奇数一个打印偶数要求按1、2、3、4……的顺序输出到100。限定用条件变量condition variable和互斥锁实现要求写完整代码。这道题我在很多面经里都见过但真正手写还是容易出问题。核心代码如下#include iostream #include thread #include mutex #include condition_variable std::mutex mtx; std::condition_variable cv; int num 1; const int MAX 100; void print_odd() { while (true) { std::unique_lockstd::mutex lock(mtx); cv.wait(lock, []{ return num % 2 1 || num MAX; }); if (num MAX) break; std::cout num std::endl; num; cv.notify_all(); } } void print_even() { while (true) { std::unique_lockstd::mutex lock(mtx); cv.wait(lock, []{ return num % 2 0 || num MAX; }); if (num MAX) break; std::cout num std::endl; num; cv.notify_all(); } }这段代码能跑通的关键在于两点wait的第二个参数是等待条件只有当条件满足时才继续往下执行每次修改完共享变量后必须notify_all把对方唤醒。我当时反复检查的就是数字到100之后线程会不会卡在wait里。所以我在条件里加了num MAX让两个线程都能退出。3.2 解析wait为什么必须放在循环里这道题最容易翻车的点不是锁而是wait的写法。如果写成cv.wait(lock);而不是cv.wait(lock, predicate);就会产生著名的虚假唤醒spurious wakeup问题。条件变量被唤醒不代表条件真的成立了可能是其他线程的notify恰好在错误的时间点触发也可能是系统内部调度原因导致的一次伪唤醒。wait会释放锁并挂起线程被唤醒后需要重新获取锁然后继续执行——如果唤醒后不检查条件线程就可能带着错误的状态跑下去。所以正确做法是把条件检查循环化。C标准库里的重载版本cv.wait(lock, pred)内部其实就是一个while (!pred()) wait(lock)的循环。为什么不用if而要循环因为除了虚假唤醒还有可能发生多个消费者被同时唤醒但只有一个资源如果被唤醒的线程不重新检查条件就会超卖。我后来在写实际的多线程下载模块时就用这个模式保证了自己的任务队列不会被两个线程同时弹出同一个任务。3.3 追问死锁与原子操作的落地场景这道题之后还有追问印象中有两个一是如果两个线程都用互斥锁锁同一个变量会不会死锁二是用atomic会不会更简单。第一个问题的答案是不会死锁因为只有一个锁。死锁需要满足互斥、持有并等待、不可剥夺、循环等待四个条件。单把锁根本构不成循环等待。出题人真正想听的是你能把死锁四条件背出来并且能说出破坏哪个条件能解除死锁——比如用std::lock同时锁多个互斥量就是通过原子性地获取所有锁来破坏循环等待。第二个问题更值得展开。如果只是整数加一用std::atomicint完全可以替代锁和条件变量甚至在性能上更好。但轮流打印这个场景里线程需要等待轮到自己才执行单靠atomic无法阻塞线程你还是要配合条件变量或信号量这种能睡觉的同步原语。atomic适合无锁的无等待场景条件变量适合需要阻塞等待的场景两者解决的问题不一样。芯片SDK的底层代码里两者都很常用所以我当时把这两个区别写清楚比单纯写代码更能体现功底。3.4 这类题在AI芯片软件栈中的实际投影说了半天笔试题目可能有人觉得这是为了考而考。其实不是。在寒武纪这类AI芯片公司后端工程师很大概率要写运行时runtime代码协调多个执行流一个线程负责从host侧下发算子任务一个线程负责监控device侧的执行状态还有一个线程处理错误回传。这些线程之间天然就需要锁、条件变量、任务队列和完成通知。如果你没写过多线程程序很难理解为什么wait必须循环、为什么要notify_all而不是notify_one。笔试这道题其实就是把日常工作场景抽象成了一个小题目。所以我一直建议准备芯片公司笔试的同学不要只刷题要自己用std::thread写几个小demo比如生产者消费者、线程池、读写锁。写过一遍和看过十遍答案效果完全不一样。4. 网络与数据库题TCP状态的坑和一条SQL的索引选择4.1 题面还原TIME_WAIT出现在哪一侧为什么需要网络部分考了一道TCP状态题。题目大意是TCP连接正常关闭时主动关闭方会进入TIME_WAIT状态请问这个状态持续多久为什么需要它如果服务器的连接大量进入TIME_WAIT会有什么问题TIME_WAIT的持续时间是2MSLMaximum Segment Lifetime报文最大生存时间通常为1到2分钟。它存在的核心原因有两个一是保证最后一个ACK能被对端收到如果ACK丢失对端会重传FIN此时主动关闭方需要能重新返回ACK二是让本连接的所有迟到的报文段在网络中消失避免污染后续使用相同四元组的新连接。4.2 解析握手挥手与后端高并发隐藏问题这道题的后续追问非常实际服务器作为主动关闭方TIME_WAIT大量堆积会导致什么答案是可用的本地端口减少、占用内核内存极端情况下新连接无法建立。在互联网高并发的短连接场景这个问题尤为明显所以有各种性能调优手段比如调整tcp_tw_reuse、开启tcp_tw_recycle不过后者在NAT环境下容易出问题Linux新版本已经不建议使用。对寒武纪这类公司来说他们更关心的是芯片与主机之间的通信链路。AI训练场景里host与device之间通常用PCIe但分布式训练中多机通信要用到TCP/RDMA。如果通信库的连接管理写得不好TIME_WAIT堆积会影响训练集群的稳定性。所以不要觉得网络协议跟芯片公司无关后端岗写通信库、写网络传输层时TCP状态机是绕不开的基本功。我当时答题时特意把握手三次、挥手四次画不出来笔试是纸笔但用文字把状态变化写清楚了。建议现在准备笔试的同学即使不要求画图也要能把状态转移表默写出来CLOSED、LISTEN、SYN_SENT、SYN_RCVD、ESTABLISHED、FIN_WAIT_1、FIN_WAIT_2、TIME_WAIT、CLOSE_WAIT、LAST_ACK、CLOSED。这张表是网络的乘法口诀不熟就吃亏。4.3 题面还原一条慢SQL的索引失效排查数据库部分考了一道很经典的索引题。题目给了一张用户表和一个查询条件问这条SQL为什么慢如何优化。典型的场景是SELECT * FROM user WHERE age 1 30;这条SQL在age字段上有索引也会失效因为对索引字段做了函数运算或表达式计算优化器无法直接使用B树索引定位只能全表扫描。正确的写法是把表达式移到等号右侧WHERE age 29。还有几个常见的索引失效场景我当时一并列了出来隐式类型转换字段是varchar查询条件传数字、左模糊匹配LIKE %abc、使用OR连接非索引列、联合索引不满足最左前缀。很多人会把索引失效当作一个死记硬背的列表但更好的理解方式是回到B树的结构本身索引是基于字段原始值组织的有序结构一旦对字段做了加工原有序性就被破坏了。4.4 数据库题事务隔离级别选了哪个数据库部分另一道印象深刻的题是事务隔离级别的选择。给了一个转账场景问应该用哪种隔离级别为什么。MySQL默认的隔离级别是REPEATABLE READ可重复读InnoDB通过MVCC和间隙锁gap lock解决了幻读问题所以在实际使用中RR级别也足够安全。但如果问的是标准SQL里的隔离级别READ COMMITTED读已提交就能避免脏读而REPEATABLE READ解决的是不可重复读SERIALIZABLE则通过完全串行化来避免所有问题但性能最差。这道题的坑在于很多人会把默认RR当成标准RR然后说RR会有幻读。在标准SQL中REPEATABLE READ确实无法完全解决幻读但InnoDB在RR级别下通过间隙锁基本解决了。所以答题时要区分理论标准和具体引擎实现。我当时写了MySQL的默认配置又补了一句如果追求更高并发可以在配置中改为READ COMMITTED这样既显示了基础扎实又显得有工程经验。5. 手撕算法题没有难题但有大量边界陷阱5.1 题面还原反转链表的前K个节点算法题部分有一道链表题让我印象很深给定一个单链表和一个整数k反转链表的前k个节点要求写出完整代码并分析空间复杂度。这题跟LeetCode上的K个一组反转链表做了简化只反转前k个不要求整个链表都按k分组反转但细节还是很多。我的做法是先写一个辅助函数实现区间反转struct ListNode { int val; ListNode *next; ListNode(int x) : val(x), next(nullptr) {} }; ListNode* reverseFirstK(ListNode* head, int k) { ListNode* cur head; ListNode* prev nullptr; int cnt 0; while (cur ! nullptr cnt k) { ListNode* next cur-next; cur-next prev; prev cur; cur next; cnt; } // 此时head是原链表的第一个节点反转后变成第k个节点的next head-next cur; return prev; }注意最后一步原链表的头节点head在反转后变成了第k个节点的末尾所以它的next必须指向第k1个节点也就是当前cur的位置否则链表就断了。这是我第一次写这类题时最容易漏掉的地方。5.2 解析递归与迭代的边界处理很多人会直接用递归做链表反转代码很简洁但要写出前k个反转递归的写法就不太直观了。关键问题在于递归解法每次只处理一个局部反转缺少把反转后的尾部接到剩余链表的步骤。迭代解法反而更容易控制边界因为它需要维护三个指针prev、cur、next核心逻辑就是在循环里完成指针倒向。我在笔试时写的是迭代版并且补充了空间复杂度是O(1)因为只用了三个指针没有额外开数组。如果写成递归空间复杂度就会变成O(k)因为递归栈深度是k。这道题的隐藏考点就在这里题目说分析空间复杂度就是要看你对递归栈是否敏感。当时我隐约感觉到出题人希望看到O(1)空间的答案所以特意写了迭代版。建议现在刷题时每种题目都养成追问递归的空间复杂度是多少能否改成迭代的习惯。5.3 题面还原二分查找在旋转数组中的变形另一道算法题是搜索旋转排序数组。原题是LeetCode 33但笔试里的问法稍有不同先让写一个普通二分查找再追问如果数组在一个未知位置旋转了怎么找目标值。普通二分查找的边界条件我已经烂熟于胸while (left right)中间值mid left (right - left) / 2这里写成(right - left) / 2是为了防止left right溢出。旋转数组的写法核心是判断哪半边是有序的int search(vectorint nums, int target) { int left 0, right nums.size() - 1; while (left right) { int mid left (right - left) / 2; if (nums[mid] target) return mid; if (nums[left] nums[mid]) { // 左半有序 if (nums[left] target target nums[mid]) { right mid - 1; } else { left mid 1; } } else { // 右半有序 if (nums[mid] target target nums[right]) { left mid 1; } else { right mid - 1; } } } return -1; }这道题我最开始写的时候在nums[left] nums[mid]这个判定上犹豫了很久。因为当数组只有两个元素时left mid如果写成而不是就会错误地进入右半有序的判断分支导致找不到目标值。这种边界处的等于号问题笔试现场特别容易踩。我给读者的建议是二分查找的边界分析不要靠背要手动把数组长度是1、2、3的几个特例画出来推演一遍。5.4 算法题的策略先把复杂度谈清楚整场笔试下来我发现算法题的共同点是题目本身不算难但都要求解释复杂度和边界。这和LeetCode只要求Accepted不太一样纸笔环境下的写完整代码更考验逻辑的完备性。我的策略是先写注释式的伪代码标注出输入为空、长度不足k、目标值不存在等边界情况再翻译成正式代码。这样即使时间不够阅卷人也能看到你的思路框架比留一大片空白强得多。另外题目如果出现了空间复杂度分析这种要求一定要回答。很多人手写完代码就以为结束了忽略了复杂度分析白白丢分。复杂度分析不是一行O(n)就完了要解释清楚时间上遍历了多少次空间上额外的变量有哪些递归的话栈深是多少。6. 复盘与备考路线如果重来一次我会怎么准备6.1 补齐系统底层知识而不是刷一堆框架回到文章开头的问题芯片公司的后端笔试到底该怎么准备如果重来一次我的答案是——把时间花在计算机基础四大件上而不是追逐框架。C要精通到什么程度至少要能熟练说清楚内存布局堆、栈、全局区、new/delete与malloc/free的区别、虚函数表、智能指针的引用计数原理、移动语义与右值引用、STL常见容器的底层数据结构。这些不是背出来的是要通过写代码去感受的。我当时在笔试前突击了一周C背了很多概念但碰到结构体大小这种需要现场计算的题还是会懵。后来我才明白处理底层问题最好的方式是真的动手编译几段代码观察输出再反推规则。操作系统要重点准备进程线程的区别与通信方式、同步互斥的四种手段互斥锁、读写锁、信号量、条件变量、死锁四条件与解决、虚拟内存与分页、用户态与内核态的切换。芯片公司的后端岗很大概率要写直接操作设备的代码或者与驱动打交道的代码这些操作系统概念会反复出现。6.2 笔试题的取舍策略与时间分配整场笔试的时间大约是90到120分钟题量不算小。我当时的感受是如果想每道题都精雕细琢时间会很紧。所以取舍很重要先做有把握的题尤其是C/C基础题。这类题答案短、拿分快先把基础分稳稳装进口袋。算法题留足时间因为要写完整代码。如果两道算法题先做你有思路的那道确保至少一道完整AC。遇到不会的题不要空白写下相关知识点和分析思路哪怕只是把公式或定义写出来。阅卷人通常愿意给思路分。手写代码时注意变量命名清晰、缩进规范这在纸笔环境里是加分项。我看过太多字迹潦草、变量名全是a/b/c的卷子第一眼给人的感觉就是不专业。时间分配上我的经验是基础题30分钟操作系统/网络/数据库等问答题40分钟算法题30分钟留20分钟检查。检查的重点不是验证结果而是看有没有漏答的追问。6.3 针对寒武纪方向的进阶建议如果准备时间充裕可以更进一步去寒武纪开发者社区下载MagicMind这类工具链的文档了解一下他们的软件栈长什么样。你不需要深入每一行源码但可以看看它们的编程模型、任务调度的抽象层次这样笔试遇到运行时算子下发之类的场景题你能联系到实际产品答题会更有画面感。另外如果你在简历里写了Linux下多线程编程或者网络编程笔试很可能会深挖对应知识点。所以简历上每一条技术栈都要确保能经得起追问。我当时简历里写了熟悉Linux环境编程笔试就遇到了完整的条件变量题幸好平时写过类似的并发Demo不然现场很难写出无bug的版本。最后想说寒武纪这场笔试给我的整体感觉是克制而精准。它没有堆砌偏题怪题每一道题都在映射实际工作中的某个环节内存布局对应SDK里的数据序列化并发控制对应运行时里的任务调度TCP状态对应通信库的连接管理。如果读到这里你正准备投这样的岗位核心建议只有一条把大学课本里的基础吃透再用真实的小项目把它们练成肌肉记忆。这样无论拿到谁的卷子你都有底气。
返回列表