公众号“看图学”试题合集(2)

Last updated on February 11, 2025 pm

1. 大模型的参数量为什么设计成 7B,13B,33B,65B 等如此怪异的数字?

1.1 从推理出发

很多答案都是从推理出发,认为之所以这么设计,是为了适配常见的显卡。

比如,采用半精度的话

  • 7B 的模型参数占14G, 可以放到16G 的 T4 上
  • 13B 的模型参数占26G, 可以放到 32G 的 V100 上
  • 33B 的模型参数占66G, 可以放到 80G 的 A100 上
  • 65B 的模型参数占130G, 可以放到两张 80G 的 A100 上

剩余的显存可以用来放 KV Cache, 还有其他的一些功能性显存占用,比如 beam search 等。

这么回答也算合理,****但是只能算是回答了一个方面,而且不是最重要的方面。

1.2 沿用GPT3的参数标准

GPT3参数

GPT 3 当时选定了 6.7B, 13B, 和 175 B。后面复现的人得做对比实验吧,那自然要对标 GPT 3,不然一个 6.7 B,一个 10 B,那对比起来也没什么意义。

所以这些参数的设定可以说是从 GPT 3 传下来的,因为大家都想和 GPT 3 PK 一下。所以那个时候很多模型都是 7B 和 13 B 左右,但是略有差异,也许是 6B,也许是 14B。

字节校招一面:“大模型的参数量为什么设计成 7B,13B,33B,65B 等如此怪异的数字?”

2. 为什么 Qwen 设计成 72B?

校招面试:”为什么 Qwen 设计成 72B?”

3. torch.no_grad() 和 torch.inference_mode() 的区别?

torch.no_grad()torch.inference_mode() 都在推理的时候禁用了梯度计算。

虽然从功能上两者类似,但是这两者的实现有很大的不同。

torch.no_grad() 属于是在 pytorch 原有的机制上禁用了梯度的计算,底层还是有梯度计算的框架(autograd),属于可以算,但是逻辑上不进行计算

但是 torch.inference_mode() 的是完全另起炉灶,完全脱离了 autograd 系统,而且把 View Tracking(视图追踪)和 Version Counter Bumps(版本计数器更新)同样砍掉了。从底层上就完全放弃了梯度计算的逻辑。

举一个不太恰当的例子,为了追求更快的推理速度,torch.no_grad() 的选择是加入少林寺,远离俗世的一切联系,专心推理,但是还是可以还俗的。而 torch.inference_mode() 则是练了辟邪剑谱,从根本上断绝了俗世的干扰。

那你说谁的推理速度快,当然是练了辟邪剑谱的更快。

代码演示

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import torch

x = torch.tensor([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]], requires_grad=True)
x.retain_grad()

with torch.inference_mode():
# with torch.no_grad():
y = x * 2

# Check Version Counter Bumps, 第一个 fail 的点
print(y._version) # will fail here

# Check View Tracking, 第二个 fail 的点
yy = y.view(6)
y.add_(1) # will fail here

# Check autograd, 第三个 fail 的点
y.requires_grad = True # will fail

上述代码中,如果采用 torch.no_grad(), 则可以正常运行。但是如果采用 torch.inference_mode(), 则在三个检查点都会 fail。

第一个检查点是 Version Counter Bumps。因为有时候 tensor 会在原地修改以避免内存的拷贝,加快运行速度。所以每个 tensor 都有一个 version。

第二个检查点是 View Tracking, 这个主要是 tensor 当作一些 视图(view) 上的变化,比如view()、reshape()、transpose() 等,这些虽然呈现为不同的形状,但是底层的内存是共享的。这就要有一个机制来追踪这些视图。

第三个检查点则是 Autograd。因为 torch.inference_mode() 压根就没有 Autograd 的梯度计算机制,所以当试图修改梯度计算状态的时候也会失败。

4. model.eval() 会像 torch.no_grad() 那样停止中间激活的保存么?

不会。model.eval() 和梯度的计算是正交的,各算个的,可以认为完全没有任何关系。

上周的一篇:《学妹问:“model.train() 和 model.eval() 什么作用?” 我给她分享了个bug》发布后,有朋友私信说 model.eval() 是否和 torch.no_grad() 类似,停止中间激活的保存?因为推理也用不到反向传播。

然而事实是 model.eval() 除了上篇文章中说的适配训练和预测的不一致性以外,再也没有做更多事情了。

像是停止中间激活的计算,禁用反向传播,节省内存等等,都是大家根据 eval 这个名字臆想出来功能。****就跟川普要当总统了,然后股民看到“川大智胜”疯狂买入导致涨停是一样的。

至于 torch.no_grad() 则是将该上下文中的所有变量都不在参与梯度的计算,所以中间激活,梯度都都不需要保存了,自然可以省一些显存。

但是一定要注意,torch.no_grad() 虽然不计算中间激活和梯度,但是 autograd 的计算图还是在的。

当退出 torch.no_grad() 后,后续的代码依然运行在 autograd 的计算图上。如下面的小例子。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import torch
from torchviz import make_dot

x = torch.tensor([1.0, 2.0, 3.0], requires_grad=True)
x.retain_grad()

with torch.no_grad():
y = x * 2

# y.requires_grad = True # 可以赋值

z = (y + 1).requires_grad_()
z.retain_grad()

loss = z.sum()
loss.backward()

print("x是否需要梯度:", x.requires_grad) # 预期输出: True
print("y是否需要梯度:", y.requires_grad) # 预期输出: False
print("z是否需要梯度:", z.requires_grad) # 预期输出: True
print("x的梯度:", x.grad) # 预期输出: None
print("y的梯度:", y.grad) # 预期输出: None
print("z的梯度:", z.grad) # 预期输出: tensor([1., 1., 1.])

# 使用 make_dot 生成计算图
dot = make_dot(loss, params={"x": x, "y": y, "z": z, "loss": loss})

# 保存图像
dot.render("autograd_graph", format="png")

在退出计算图后,执行 y.requires_grad = True 是可以的,因为整个的 Autograd 体系还在。

当设置 y 处在 torch.no_grad() 的上下文后,y 之前的梯度都没有了,即使 x 设置了 requires_grad 为 True。

但是 y 之后的 z 则可以正常的求导。整个求导过程在 y 就被熔断了

5. model.train() 和 model.eval() 什么作用?

model.train() 会让模型进入 train mode,而 model.eval() 会让模型进入 eval mode。

为什么要有这两种模式呢?是因为模型中的有些模块在训练和预测不一致导致的。典型的模块就是 Batch Norm 和 Dropout。

Batch Norm

对于 Batch Norm 来说,训练的时候其实并不关心整体样本的均值和方差,我只需要在我这个 batch 内稳定训练就可以了。

但是预测的时候,我们也必须得找一个均值和方差。那最好的选择就是通过整体的样本来进行估计了。

但是这里面又有一些细节,比如原始论文中训练时方差用的是有偏估计,但是推理的时候用的是无偏估计。这个问题在几年前一直是个讨论的热点。甚至 github 上有个 2017年的 issue,7年了,到现在还没 close。https://github.com/pytorch/pytorch/issues/1410

Dropout

Dropout 的训练和预测也不一致。

训练的时候会随机 drop 一些神经元,但是预测的时候则是使用全部的神经元然后进行缩放。

正是由于这种训练和预测的不一致性,就导致我们必须要告诉模型,什么时候是训练的状态,什么时候是预测的状态。

我之前还写过一个目前还有印象的bug,就是 model.train() 写了在 for 循环迭代外面,结果 for 循环里面调用了一个评估函数,在评估函数里面会执行 model.eval(),结果就导致 model 基本全程处于 eval 状态。

适配 train 和 eval

有时候我们自己写模型,可能也会存在训练和预测不一致的情况,怎么适配 model.train() 和 model.eval() 的接口?

只需要改变 torch.nn.Module 的 is_training 状态即可,比如下面的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import torch
import torch.nn as nn


class SimpleModel(nn.Module):
def __init__(self):
super(SimpleModel, self).__init__()

def forward(self, x):
if self.training:
return x * 2
else:
return x


model = SimpleModel()

model.train()
print(model(107))

model.eval()
print(model(116))

model.train()
print(model(120))

就是这么简单。

6. 如何防止 Checkpoint 注入代码攻击?

答案

使用 safetensors 存储和加载。攻击原理和防范方法还有代码演示见后面

但是这个方案也只能是防君子不防小人。只要一个人想干坏事,有太多的方法来实现了。但是对于公司来说,还是提高一些做坏事的门槛比较好。

攻击原理和防范

稍微说一下利用 checkpoint 进行攻击的原理。

其实也并不是什么特别高明的技术,核心就是 pytorch 的开发人员偷懒,在保存和加载模型的时候,采用了 pickle 格式,而 pickle 格式本身就是不安全的

更具体一点来说,pickle 设计的初衷是为了方便的序列化和反序列化数据。而为了方便用户自定义自己的序列化方式,开放了一个 __reduce__ 的接口,而这个 __reduce__ 接口则可以让用户为所欲为,如果有足够的权限,用户甚至可以在里面执行 rm -rf /

所以后来 huggingface 推出了 safetensors 的格式。这个格式就是一份数据,不能执行代码。而且优化了加载速度,还可以在不加载权重的情况下就获取数据的 meta 信息,比如模型的网络结构等。所以以后大家尽量用 safetensors 就好了。

下面的代码,加载模型后进行代码注入(这里以让系统 echo hello world 为例,如果load模型后系统打印了Hello World,则表示攻击cheng g),然后使用使用 pytorch 来存储和加载模型,可以发现用torch.load 被注入代码的模型后,打印了 Hello world。而采用 safetensors 则没有任何问题。

为了防止攻击代码的恶意扩散,下面的代码中具体的注入方式被隐藏。想学习完整代码可以看文末的付费专栏,日后要是惹出祸事,不要说是我教的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
import pickle
import torch
import torch.nn as nn
from safetensors.torch import load_model, save_model


class SimpleModel(nn.Module):
def __init__(self):
super().__init__()
self.linear1 = nn.Linear(107, 116)
self.linear2 = nn.Linear(116, 120)

def forward(self, x):
x = self.linear1(x)
return self.linear2(x)

# 创建模型实例
model = SimpleModel()

# 正常保存模型
torch.save(model, 'simple_model.pth')

... 注入 os.system('echo Hello World')。
... 注入为了防止该方法恶意扩散,这里省略具体的注入方法。

# 加载原始模型
with open('simple_model.pth', 'rb') as f:
original_model = torch.load(f)

# 插入恶意代码
original_model ... 为了防止该方法恶意扩散,这里省略具体的注入方法。

# 保存被污染的模型
torch.save(original_model, 'poisoned_model.pth')
save_model(original_model, 'safe_model.safetensor')

print('load by torch.load...')
poisoned_model = torch.load('poisoned_model.pth')

print('load by safetensors...')
load_model(original_model, 'safe_model.safetensor')

7. 分布式训练常用的通信后端都有什么?应该怎么选?

目前最流行的深度学习框架当属 Pytorch ,Pytorch 支持3个通信框架:MPI,Gloo,NCCL。

但是也有很多自研的框架,比如阿里的 ACCL, 微软的MSCCL, Intel 的 oneCCL, AMD 的 RCCL, 华为的 HCCL 等。

通过名字大概可以看出,这些通信框架大致分为两类, MPI 和 xCCL。

MPI 的全称是 Message Passing Interface。而 CCL 的全称是 Communication Collectives Library。然后前面还会加一个代号,来区分是哪家公司的。比如 NCCL 就是 Nvidia Communication Collectives Library, 阿里的就是 Alibaba Communication Collectives Library,也有不是公司名的,比如 Intel 和 AMD 分别用了 one 和 ROCm。

下面分别简单介绍一下这些框架。

MPI

经典的分布式通信框架。当年在百度的时候,经常用 MPI 版的逻辑回归。那个时候刚出校门,没见过世面,感觉分布式训练贼牛逼。

在 NCCL 出现之前,MPI 在 CPU 和 GPU 分布式框架中都占据主导地位。不过自从 NCCL 出现后, MPI 目前只用在 CPU 的通信场景中了。

Gloo

Gloo 是 Facebook 的开源框架。对 CPU 和 GPU 的通信都做了一些优化。早期的时候,优化的算法不太多,现在也加了很多优化算法,具体见:

https://github.com/facebookincubator/gloo/blob/main/docs/algorithms.md

但是在 GPU 上,效果依然没有 NCCL 好,所以目前只要用 Nvidia 的 GPU,基本上都会把通信后端设置为 NCCL。

NCCL

NCCL 是英伟达开发的。当时的起因是 MPI 虽然也对 GPU 做了优化,但是并没有完全发挥出性能,所以英伟达亲自下场,在传输和计算的 overlap 上做了很多优化工作。

一经发布,就成了业界扛把子。只要用 Nvidia 的卡,用 NCCL 就行。

NCCL 会自动根据网络拓扑和通信协议来自动选择算法,比如 Ring based, Tree based 或者 CollNet,选择一个最快的来运行。

体感上来看,NCCL 在最开始通信的时候由于有一些前置步骤,所以第一次通信的时候很慢,但是只要开始通信之后,就非常快了。所以测速度的时候千万不要把第一次通信的时间当作结果。

英伟达也并没有想着要取代 MPI,而是采用了共存共荣的策略,只在 GPU 上优化。

MSCCL

MSCCL 是微软开发的,可以认为 NCCL 的一个扩展。关键是可以兼容 NCCL 的 API,pytorch 只需要将 backend 做一下替换即可。

MSCCL 提供了更灵活和可定制的集体通信算法,引入了一个 chunk-oriented dataflow language, 叫 DSL。同时还有个编译器,用来编译和优化数据如何在 GPU 之间进行流动。这个工作还是比较硬核的,光编译原理估计大部分人就看不懂。

根据他们的测试报告,MSCCL 在推理上可以加速1.22x–1.29x, MoE 的训练可以加速 1.10x–1.89x 。

ACCL

阿里针对阿里云的环境进行的优化。其官网上是这么写的:

  • 修复了对应NCCL社区开源版本的BUG;
  • 对集合通信不同算子和不同消息区间进行了调优,使其相比开源NCCL拥有更好的性能;
  • 支持训练过程中集合通信统计分析,可用于诊断训练过程中设备故障导致的计算/通信Slow(慢)和Hang(挂起)等问题,配合阿里云PAI的AIMaster:弹性自动容错引擎和C4D:模型训练任务问题诊断工具,可以快速的进行任务的异常检测和自动容错;
  • 支持多路径传输和负载均衡功能,在训练集群中降低甚至消除哈希不均导致的拥塞问题,提升整体训练性能;

https://help.aliyun.com/zh/pai/user-guide/accl-alibaba-high-performance-collective-communication-library

HCCL

华为开发的,基于昇腾硬件的高性能集合通信库。现在很多公司已经在采购昇腾的卡了。

现在老美一直打压我们,那天要是 Nvidia 的卡不让用了,只能用这个了。

oneCCL/RCCL

oneCCL 是 Intel 开发的,RCCL 是 AMD 开发的。

看这两家的背景就知道,这两个库在更底层上进行优化,所以对InfiniBand、以太网有很好的支持。

到底用什么

img

之前有人对 MPI, NCCL 和 Gloo 做了测评,结论是:

  1. 当 tensor 比较小时,MPI 的性能更好,而且集群数量越大,MPI 效果越好。
  2. 当 tensor 比较大时,NCCL 性能最好,而且跟集群没有关系。

最终的结论就是:

  1. 用什么型号的 GPU,就用对应的 CCL。比如 N 卡就用 NCCL,阿里的就用 ACCL,昇腾的就用 HCCL。
  2. 如果是 CPU,最简单的就用 Gloo。爱折腾就用 oneCCL 或者 RCCL。

当然 pytorch 官方也给出了使用建议,请看:https://pytorch.org/docs/stable/distributed.html#which-backend-to-use

8. Beam Search 的缺点?

Beam Search 大大提升了推理的速度,但是其自身也有很多缺点。下面分别说明

缺点1: Beam Search 本身并不能保证找到最优解。

当 beam size 为1时, Beam Search 就退化为 Greedy Search,当 beam size 趋向于无穷时,则变成暴力穷举,这个时候才能保证最优解。

当 beam size 变大时,找到最优解的概率会提升,但是收益是递减的

缺点2: Beam Search 趋近完美且中庸,但没有惊喜

之前有人问余华 AI 是否对作家构成威胁。余华老师估计不太懂 AI 的底层原理,但是他却给出了一个非常接近本质的结论。

余华认为:生活是不按常理出牌的,AI 写作可以写出中庸的小说,但写不出个性的小说。人脑总要犯错误,用人脑写作的“伟大文学作品都有败笔”,但这也是人脑最可贵之处。

需要注意:中庸并不是一个贬义词,讲究的是不偏不倚,折中调和。

通过对人类文本和 Beam search 生成的文本的困惑度进行对比,也验证了这一点:

img

所以 AI 生成的文本往往比较枯燥,没有带来惊喜。甚至在某些情况下,会陷入 positive feedback loop, 倾向于重复的输出一些高概率的词。

缺点3: Beam Search 对长序列不友好

由于 Beam Search 的概率是累乘的,由于概率又小于1,所以随着长度的增加,句子的概率会越来小。

这也就造成一个长度为 5 的句子的概率,天然就大于长度为100 的概率。所以 Beam Search 更喜欢短句子。

通常需要对 Beam Search 进行 Length Normalization。

缺点4: Beam Search 会耗费额外的资源

尤其是当 beam size 很大和序列长度很大的时候,beam search 会耗费不少的内存。比如 KV Cache 的存储。

当然可以通过 Trie 树等来进一步优化。

9. Beam Search 最坏时间复杂度是多少?

答案

Beam Search 相信大家都知道怎么回事,这里不再赘述。本文探究一下 Beamsearch 的时间复杂度。

假设大模型的词表个数为|V|,我们要预测 个 token,beam search 的 beam size 为k, 那么beam search 的时间复杂度是多少呢?

我们先看下 beam search 的搜索路径,如下图所示(beam size = 2):

537ada0f171fe620741015f4c00fa22c第一个 token 时从|V|中选择 k个概率最大的 token ,剩下的都是从 k|V|个候选 token 选择k个概率最大的。

从N个数字中选择k个最大的数,经典的 topk 问题。

top k 问题也经常作为算法面试题出现,但是能完全答对的真不多。这里给出 4 个选项:
$$
\begin{aligned}&\bullet\text{ А.}O(N\log N)\&\bullet\text{ В.}O(N\log k)\&\bullet\text{ С.}O(N)\&\bullet\text{ D.}O(N^2)\end{aligned}
$$
可以先选择一下再给答案。评论区可以发一下自己的答案:)

下面分别说明

  • O(NlogN)

这个时间复杂度肯定可以完全解决,只需要将 数组排序,然后选择最大的 k 个即可,排序的时间复杂度为 O(NlogN)。但是并不是最优的。少数排序算法可以达到O(N),但是ROI不一定实用。

  • O(Nlogk)

可以维护一个大小为 k 的小顶堆,然后遍历 N 个数,每次更新小顶堆的时间复杂度为 logk,整体为Nlogk. 最后堆内的元素就是答案。

  • O(N)

参考 quicksort,每次选一个 pivot 进行重新排列。但是我每次并不 sort,而是 select,把问题变成 quick select.

只要我知道了第 k 大的数字,再 O(N) 的遍历一遍,就得到了 TopK 的数字了。

N个数一次 parition 的时间复杂度为 O(N), 下一次处理的数量期望是当前数量的一半。所以整体期望的时间复杂度为 $O(N)+O(N/2)+O(N/4)+\ldots=O(2N)=O(N)$

但是注意,这只是期望是 O(N).

如果下一次处理的数量并不是一半,而是只少了一个,那么最坏的时间复杂度则变成 .

其实回答到这里,基本就可以了,下面的是加分项。

但是实际上,quick select 可以进一步转化为使用 Median of medians 算法来求解 TopK。

而 Median of medians 算法是一个真正的线性算法,可以最坏 O(N)的时间复杂度来找到第 k 大的元素。

虽然理论上, Median of medians 的最坏时间复杂度是O(N),但是它的渐近常数有点大,实际使用中其实和 quick select 差异并没有那么大。它的意义就是保证了 TopK 的最坏时间复杂度为线性

Median of medians 算法也很简单,这里就不写了,感兴趣的可以看下面的付费资料。

Beam Search 的时间复杂度

回到 Beam Search 的问题上,由于每次是从k|V|个元素中选择 k 个,这种选择进行了 n 次,所以 Beam Search 的时间复杂度为O(nk|V|)

当k=1的时候, 退化为 greed search。

当$k=\infty $ 的时候,不能直接带入到式子里面,因为k 虽然可以无限,但是刚开始的搜索步骤还是有限制的。这时候相当于 全局搜索,时间复杂度为 $|V|^n$

10. 为什么 sigmoid 采用的是 e 的负 x 次方,而不是别的数的负 x 次方?

img

不同 k 值的函数图像如下图所示:

28d16e38349c6afee2a7ee7dd79d9912

看到这里我大概意识到不同的 k 只是对 sigmoid 做了不同的拉伸,其实都属于 S 形函数。

这类函数不仅函数相似,其导数也高度相似。然后我决定推导一下 g(x) 的导数。如下:

img

联想到 sigmoid 的导数为

img

好像知道答案了。g(x) 的导数可以继续化简,为:

img

所以说导数这里有个 ln k 的常数。

一是为了计算的简便,当 k = e 时,这一项就没有了。

二是联想到 ResNet 的导数,里面如果含有一个常数项,累计效应会造成训练的不稳定,具体的推导可以看:字节大模型一面:“为什么现在深度学习都用 ResNet?”

我觉得这应该是 k 取 e 的原因吧。

晚上吃完饭又想了想,这个 ln k 的常数是怎么出来的呢?突然我一拍大腿,发现其实上面的证明有点绕远路了。

因为$k=e^{\ln k}$ , 然后

img

所以 g(x) 依然是 sigmoid.

根据链式法则,

img

这就是 ln k 常数的由来。

文章合集:chongzicbo/ReadWriteThink: 博学而笃志,切问而近思 (github.com)

个人博客:程博仕

微信公众号:

微信公众号


公众号“看图学”试题合集(2)
https://chongzicbo.github.io/2025/02/11/笔试面试/AI算法/nlp/AI笔试面试题002:公众号“看图学”题目合集(2)/
Author
程博
Posted on
February 11, 2025
Licensed under