深度学习系统研发笔试攻略:从计算图到混合精度与显存优化
1. 提前批笔试到底在筛什么人先想清楚游戏规则每年六月到八月各大厂的提前批陆陆续续开放。很多人一听到提前批就冲上去投简历觉得自己刷了几个月 LeetCode 就能拿offer。实际上提前批对于深度学习系统研发这个岗位来说画风和你想象的可能完全不一样。我印象最深的是网易2020校招这场提前批笔试。当时不少同学是抱着算法工程师的预期去投的结果拿到卷子傻了——没有一道题让你手推 Transformer 的 attention 公式也没有让你比较各种 optimizer 的收敛性。整张卷子扑面而来的是一种操作系统 计算机体系结构 深度学习框架底层的混合气息。说白了这个岗位不是招一个会调参的算法工程师而是招一个能改造框架、加速训练、优化推理的系统工程师。这个定位在笔试中体现得非常直接题目会在模型推理的延迟优化、显存管理、算子的底层实现、甚至 CPU/GPU 的指令级优化这些层面反复敲打。如果你只用过 PyTorch 的model.fit()那种封装好的 API对 forward 和 backward 背后的张量运算、内存分配、算子调度一无所知那这场笔试基本就是陪跑。所以在聊具体考点之前我特别想强调一个判断深度学习系统研发岗和算法岗在笔试题的评判标准上有本质区别。算法岗考的是你能否提出一个有效的解决方案系统研发岗考的是你写出来的代码在真实硬件上能不能跑得快、跑得稳。围绕这个核心目标我来拆一下这场笔试最值得关注的几个模块以及我当时思考和作答的方式。2. 嵌入式计算图与算子执行你写的模型代码到底怎么变成指令深度学习系统研发岗笔试的第一个重头戏就是计算图和算子执行。这一块不会直白地问什么是计算图而是会给一个场景让你分析框架内部的行为。例如给出一段 PyTorch 代码问 model.forward() 里面每个 tensor 操作背后的调度顺序是什么、哪些部分是同步执行、哪些是异步的、显存什么时候被分配和释放。很多人会忽略一个关键点你用 Python 写模型框架是通过惰性执行lazy execution或即时执行eager execution两种模式来运行。PyTorch 默认是 eager 模式边定义边计算TensorFlow 1.x 则是先构图再执行。但不管哪种模式最终落到 GPU 上的是一个个 kernel内核函数。CPU 端的 Python 解释器只是负责把这些 kernel 的启动指令发出去真正的计算是 GPU 上完成的。笔试里常见的一类题是这样的给你一个包含若干层卷积、BatchNorm、ReLU 的模型问前向推理过程中无梯度计算算子执行顺序是否和代码书写顺序完全一致。答案是不一定。因为框架只要保证数据依赖关系成立理论上是可以调整执行顺序的甚至可以把相邻算子融合成一个 kernel。这个点其实就是为了考察你是否理解算子融合operator fusion的基本思想。我当时是这么拆解的先看数据依赖。卷积层输出 - BatchNorm - ReLU这是串行依赖必须按序执行。但如果你在同一个 block 里有多个分支例如残差网络的恒等映射和卷积分支这两个分支之间没有数据依赖理论上可以并行执行只要最终 add 的时候两边结果都就绪即可。这种无依赖即并行的思维在系统优化里非常核心。更进一步笔试会考察你对算子实现的理解。例如一个矩阵乘法 A×B如果你按 CPU 的 naive 三层循环去实现时间复杂度是 O(n³)但它的实际访存效率极低因为每次内层循环都在访问不连续的内存地址。现代 BLAS 库比如 OpenBLAS、MKL会用分块tiling、向量化SIMD、寄存器重用来优化这个计算。GPU 上则对应的是共享内存shared memory分块、bank conflict 避免这些 CUDA 编程技巧。所以如果你想在笔试中有好的表现不能只停留在PyTorch 怎么用层面。你需要理解一个 tensor 在内存中如何存储NCHW vs NHWC、一次卷积调用在 GPU 上如何映射到线程块block和线程thread、Reduce 操作有没有做多线程折叠。这些看起来像是 CUDA 编程的细节但笔试真的会考而且占比很重。我自己的备考经验是把 PyTorch 里最常用的几个算子Conv2d、MatMul、Softmax、LayerNorm在 CPU 和 GPU 上的实现思路都过一遍。不需要真的手写完整 kernel但要能说出每个算子为什么这么设计瓶颈在计算还是访存怎么改能更快。这类为什么的题目最容易拉开差距。3. 数值精度与混合精度训练fp32、fp16、bf16、tf32 的取舍逻辑说到深度学习系统研发浮点数格式是绕不开的话题。当年笔试里就有这种题一个模型用 fp32 训练 loss 正常换成 fp16 训练出现 loss 变成 NaN 或者不收敛问你如何排查以及为什么混合精度能解决这个问题。这个知识点在热词里也有专门提到深度学习模型部署必知:fp32、fp16、bf16、tf32浮点数格式详解与实战选型就是这么个话题。所以笔试备考时这个点几乎可以作为必修内容来对待。先简单梳理一下四种格式的本质区别。IEEE 754 单精度浮点数 fp32 是 1 位符号位 8 位指数位 23 位尾数位表示范围大约 ±3.4×10^38精度大概 7 位有效十进制数字。fp16 是 1 位符号位 5 位指数位 10 位尾数位范围只有 ±65504有效数字约 3 位。bf16 用 1 位符号位 8 位指数位 7 位尾数位它保留了和 fp32 一样的指数范围但尾数精度更差有效数字只有 2 到 3 位。tf32 是英伟达在 Ampere 架构上推出的格式本质上是把 fp32 的 23 位尾数截断到 10 位用于 Tensor Core 的矩阵乘累加同时保留 8 位指数位。这四种格式的差异不是简单的精度越高越好而是看你用在什么阶段。训练阶段梯度更新那小步长非常敏感如果用 fp16 直接做参数更新一旦 lr学习率偏大参数值可能直接冲过 fp16 的最大表示范围 65504变成 inf然后反向传播里遇到 inf梯度变成 NaN整个训练直接崩掉。混合精度训练的思路是用 fp16 做前向和反向的矩阵乘加因为计算最快用 fp32 保存一份权重副本优化器更新时在 fp32 上做得到的新权重拷回 fp16 继续用。这样既享受了速度又保留了精度。笔试里还有一个常见考法就是给你一个已经训练好的 fp32 模型要求部署时减小显存占用。你可能会想到直接转 fp16此时要注意两个问题第一模型里某些层的激活值动态范围很大比如 softmax 之前的 logits直接裁剪到 fp16 可能导致精度不够第二某些算子例如涉及 exp、log 的算子在 fp16 下的误差被放大导致推理结果偏移。实际部署时更稳妥的做法是按层选择精度敏感层用 fp32非敏感层用 fp16这也是所谓的混合精度推理。我自己踩过的坑是用 fp16 保存模型权重时没有对 BatchNorm 层做特殊处理导致部署后 BN 的统计量running_mean / running_var在 fp16 下完全失真。后来才意识到BN 层的参数计算本身对精度非常敏感转换精度时应该保留 fp32或者至少先 fuse 进卷积层再转。这个点如果有人没遇到过笔试里真的答不上来。这里我把四种格式的取舍做一个简单的对照表方便考前梳理格式符号位指数位尾数位最大范围典型用途主要风险fp321823约 3.4×10^38训练主精度、敏感层推理显存/带宽开销大fp16151065504混合精度的前反向计算溢出和精度损失明显bf16187约 3.4×10^38TPU/大模型训练尾数精度低tf321810截断约 3.4×10^38Ampere Tensor Core 矩阵乘精度降低但可接受需要特别说明的是tf32 并不是一种独立的存储格式而是 Ampere 架构上 Tensor Core 处理 fp32 输入时的内部截断格式。它没有改变数据在内存中的存储方式仍然是 fp32 的 32 位只是在进入 Tensor Core 时主动把尾数从 23 位截到 10 位来换取计算吞吐。所以如果你看到代码里设置了torch.backends.cuda.matmul.allow_tf32 True其实是在告诉 cuBLAS 可以用截断后的精度跑 fp32 的矩阵乘。这个知识点为什么笔试这么爱考因为它直接关系到系统研发工程师的核心技能——在精度、速度、显存三者之间做权衡。你不仅要知道各种格式的定义还要能在具体模型中判断哪里该用哪种精度。这种题没有标准答案但能看出你对数值计算的理解深度。4. 显存管理与模型并行为什么一张卡装不下就得多张卡深度学习系统研发还有一个绕不开的考点就是显存管理。笔试中经常出现这样的场景一个模型参数量 10GB单张 16GB 显存的 GPU 跑不起来问你有哪些方案能解决。大多数人的第一反应是用梯度累积减小 batch size。这确实是一个方向但系统工程师应该能给出更系统的答案。整张显存图景其实可以拆成四个部分模型参数、优化器状态、中间激活值、梯度。在混合精度训练中参数的 fp32 副本和优化器状态占的显存往往比实际参数本身还大。Adam 优化器会额外保存一阶动量 m 和二阶动量 v如果把 fp32 的参数副本也算上参数显存开销就是原始 fp16 参数的 6 到 8 倍左右。这就是为什么训练大模型时ZeROZero Redundancy Optimizer这种把优化器状态切分到多卡上的方法那么重要。笔试如果深入考这个方向常见问题可能是这样假设你想训练一个 10B 参数的模型每张 GPU 显存 80GB用 ZeRO Stage 1/2/3 分别需要多少张卡涉及计算显存的推导过程这类题目其实在考察你是否真的理解训练显存开销的构成而不只是背概念。我当时答题的思路是先画一条显存账目表模型参数 W 占一部分优化器状态论文里常见的是 Adam 需要 2 份额外状态加上权重本身副本一共 3 份占一部分还有一部分是激活值。在纯数据并行DDP下每张卡都存一份完整模型副本所以模型参数和优化器状态是每卡都重复的显存管得再精细也只能靠减小 batch 来降低激活值。ZeRO 的思路则是把冗余状态切分到不同卡上用通信换显存。这个知识点非常值得在考前吃透因为它背后是系统研发最典型的思维算清楚资源账再做取舍。推理阶段的显存管理也同样重要。一次前向计算中中间激活值往往比权重还占显存。对于 LLM 这类生成式模型kv cache 会随着序列长度线性增长序列一长cache 就可能把显存吃光。部署时通常用 PagedAttention 这类把 kv cache 分页管理的技术这就涉及操作系统虚拟内存分页的概念。这类题目如果你没亲手做过部署优化很容易懵。我的建议是考前动手做一次显存预算练习拿一个真实的模型写出不同 batch size 和序列长度下的显存占用估算公式再在 GPU 上验证。这个过程能把参数、梯度、优化器状态、激活值四个概念彻底钉在脑子里。遇到笔试里的显存题心里就有一本账。5. 笔试真题复盘一道典型的算力与内存综合题笔试和面经不同它不会给你说说你对 ResNet 的理解这种开放题更多是给具体的数据让你算出结果。网易这场笔试里有一类题让我印象特别深——它把算力、显存、耗时全部揉在一起要求你做一个系统方案判断。我记得有一道题的简化版是给定一个 batch 为 64、分辨率为 224×224、通道数为 256 的特征图做一次 3×3、stride1、padding1 的卷积输出通道也是 256。问这个卷积一次 forward 的浮点计算量FLOPs是多少以及如果 GPU 算力是 30 TFLOPSfp16 Tensor Core理论最短耗时是多少。这道题如果你只是背过公式可能能做对 FLOPs 部分但对理论最短耗时就会栽跟头。为什么不直接除以算力因为 30 TFLOPS 是峰值算力实际场景存在访存瓶颈和 kernel 启动开销。尤其是这种单次卷积调用输入和输出数据都要经过显存访存带宽可能把计算时间完全遮盖住。所以正确答案的讨论必须包括计算上界和访存上界两个层面——只有当计算量足够大时算力才能成为瓶颈如果数据量小、卷积核简单访存才是瓶颈。这个考点的重要意义在于系统研发工程师看重的不是能不能算而是瓶颈在哪、怎么优化、如何权衡。笔试中经常通过这种小计算题来考察你能否区分 compute-bound计算受限和 memory-bound访存受限。这也是做算子优化最基本的分类方法。比如 Conv 和 MatMul 通常是 compute-bound而 element-wise 的激活函数、normalization 通常是 memory-bound。优化的方向完全不同compute-bound 要用 Tensor Core 提升算力利用率memory-bound 则要减少内存拷贝、做算子融合。我当时答这道题时先把 FLOPs 公式列出来输出特征图尺寸是 224×224卷积核是 3×3输入通道 256输出通道 256batch 64所以一次卷积的 FLOPs 大约是 2 × 64 × 256 × 256 × 224 × 224 × 3 × 3也就是约 3.6×10^11也就是约 360 GFLOPs。这个 2 乘在哪里乘法和加法各一次所以是 2 倍算一个 FLOPs 的约定。这个细节在笔试中非常容易丢分因为不同教材对 FLOPs 的定义可能不同有的只算乘法不算加法。备考时一定要明确题目问的是 FLOPs 还是 MACs乘加次数两者差一倍。然后看算力30 TFLOPS 30×10^12 FLOPs360×10^9 / 30×10^12 ≈ 12 毫秒。但你直接写 12 毫秒是不严谨的因为还要考虑带宽。假设特征图 fp16 存储输入数据量是 64×256×224×224×2 字节约 1.6 GB输出也是同样大小。如果显存带宽是 1.5 TB/s完成输入输出转移大约需要 2.1 秒。这个数字远远大于 12 毫秒说明这个 kernel 在理论上是一个访存受限算子实际耗时会被带宽卡在秒级别而不是 12 毫秒。这个结论可能会让很多人惊讶但它恰恰揭示了系统优化的精髓不要只看算力要明确地把访存量算进去。笔试里出现这道题九成是在考你是否能识别 memory-bound vs compute-bound这个概念。后来我实际做性能分析时发现这种判断能力比会写 CUDA kernel 更基础也更重要。遇到这种综合题我的答题模板很固定先算计算量 FLOPs写清楚公式。再算访存量注意数据类型fp16 还是 fp32会直接改变访存字节数。对比计算耗时和访存耗时取大者就是理论的耗时下界。提一句 kernel 启动开销和实际效率比如 30 TFLOPS 的实际利用率可能只有 50%说明理论只是上界。这套模板不仅能应付笔试实际做性能优化时同样能派上用场。6. 备考路径梳理从算法思维切换到系统思维的三个关键动作如果你真的想拿下深度学习系统研发岗的笔试我的建议不是再去刷一堆模型结构的题目而是做一些转换思维的动作。这里我总结三个对备考最有效的动作都是我亲测有价值的。第一个动作是打开 PyTorch 的源码从最简单的算子开始读。不是让你从头到尾读一遍而是挑选torch.add、torch.matmul、torch.nn.Conv2d这几个高频算子的分发逻辑搞懂 Python 层是怎么通过 dispatcher 把调用分发到 CPU/GPU 的 kernel 上的。你会发现框架里很多东西不是为了跑通而存在而是为了跑得快而在调度层做了大量优化。一旦理解了这个分发机制你对框架这个词的理解会完全改变。第二个动作是动手做一次 profile。用torch.profiler或者 Nsight Systems跑一个简单模型看看每个算子实际耗时多少、GPU 利用率和显存占用是多少。这个练习的意义在于你会立刻发现 PyTorch 显示的算子耗时和你的直觉非常不一样——很多你觉得很重的算子比如 BatchNorm实际非常轻而一些你以为轻的算子比如密集的 reshape 和 copy却在拖后腿。这种直觉被打破的体验就是向系统思维靠近的过程。第三个动作是认认真真做一张面试前的自问清单。比如你写过 CUDA kernel 吗知道 grid、block、thread 三层结构的含义吗知道什么是 shared memory、什么是 global memory 吗你知道 warp 是什么吗知道 bank conflict 会怎么影响性能吗这些知识不需要你全面掌握但至少在笔试和面试时你能说出基本概念和它们存在的理由——它们解决的是什么问题。哪怕你没有写过完整 kernel靠为什么这么设计来答题也能拿不少分。总结下来这场网易2020校招提前批笔试给我的最大感受是它不是在考你背了多少知识点而是在考你有没有建立系统视角。同样的模型算法工程师看到的是准确率系统工程师看到的是算子耗时、显存占用、带宽利用率。这个视角一旦建立起来无论是应对笔试、面试还是入职后的实际开发都是一种可以复用的底层能力。这种思路上的转变并不会因为你多读几篇文章、多刷几道题就自动建立而是需要你在实际项目中不断用为什么快为什么慢瓶颈在水这种问题去追问自己的代码。走完这个过程你和这个岗位之间的距离会迅速缩短。