CS336
从推理服务的角度看语言模型:token 的一生、Megakernel 与 Parcae
这堂课是 Dan Fu 的客座讲座,讲模型训练完成之后的那一侧:推理系统如何服务请求、生产流量长什么样、KV Cache 与 prefill/decode 分离、Megakernel 如何加速 decode,以及循环架构 Parcae 如何稳定训练并改变缩放规律。
开场与动机:从训练到推理
非常感谢大家来听。我觉得你们这门课非常酷。当然也要感谢 Percy 邀请我来做这次报告。在这门课上,你们主要讨论的是训练,如何训练这些语言模型,走到今天这一步——让模型可以反过来跟我们对话。今天我想讲一点另一侧的事情:一旦你有了这样一个模型,实际去服务它、做推理(inference)是什么样子,怎么把电变成 token、再变成智能;以及从那个角度看,有哪些有趣的研究问题和研究可以做。
我先从一些高层的动机开始。有一件事对我们所有人来说都越来越清楚:我们正在经历一场几乎可以称为新工业革命的变化,背后就是这些模型及其能力。这些幻灯片是我两年前找工作演讲时做的,所以具体例子会显得有点老,但你可以做人类水平的文本生成、代码生成——Cursor、Claude Code、GPT 5.5,不只是 4。你还可以生成图像和视频,或者理解图像和视频并处理它们。你开始能理解新的模态。我们也开始看到一些应用,以及生物、健康、DNA 模型等科学领域的东西。有件事驱动我研究很久了:是什么让这些进步成为可能,下一代我们又怎么改进?
一个很丰富的驱动力就是规模。这个图现在看已经比较老了,但这些模型确实以巨大的方式扩展了。2018 年,我博士刚开始的时候,最大的模型是数亿参数,我们还觉得天哪,这些东西太疯狂了。到 2019 年,我们觉得它们太危险不能发布,那就是 GPT-2。我想在这门课上,你们可以训练出一个 GPT-2 质量的模型;我不确定你们做没做,如果足够努力,是可以的。今天当然有开源的万亿参数甚至更多参数的模型,前沿大概在 5 到 10 万亿参数。这很令人兴奋,随之而来的是聊天、写代码、分析复杂文本、帮你做作业等一堆新能力。
真正值得注意的是,这个转变发生得比我们想的更快,至少比我自己博士刚开始那几年想的快。有一个很贴切的类比,而且日期巧合得很有意思。1902 年,曼哈顿有 13 万匹工作马——不是养着玩的,而是真的承担关键角色的马。它们每天产生大量粪便,乘以 13 万,就是一个真实的马粪问题。事实上,当时有整个学术会议就是聚在一起讨论:这些马产生的粪便怎么办?1898 年纽约就开过这样一个会,结论是:马粪问题无解,你只能捏着鼻子忍。十年后,到 1912 年,曼哈顿的汽车数量已经超过了马。那个十年的转变里,存在了几个世纪的东西开始被汽车取代。
对我们、对语言模型来说,我们做的很多事情,那个 1912 时刻可能就是去年。至少对我而言,去年我开始用语言模型写大部分代码。我团队里大多数人也是。我告诉所有学生也这么做,除了做作业的时候。这是一个我们正在经历的非常令人兴奋的转变。
推动这一切的很多东西是规模,而规模又由 GPU 驱动。所以可以说,GPU 是新的石油。这又是一个比较老的宣布,但你看到数千亿美元甚至更多投资进入 GPU。历史以有趣的方式押韵,有些国家、有些主权财富基金把这件事作为自己投资组合的重要部分。
有一件事非常清楚:推理才是关键那一块。你可以把推理看作把电变成智能的引擎。就像石油只有在一辆车里、有一个引擎把油变成有用的动能时才有用;推理引擎、GPU kernel,这些才是真正把 GPU——像 Percy 说的,从沙子——变成我们今天能用的东西。如果你想一想,机器学习模型其实就是操作的 DAG(有向无环图),是某种存在于以太里的数学对象。推理引擎、GPU kernel 这些部件,才是你实际要编程、把它们映射到 ML 操作上的东西。我想你们这门课做了一些这样的工作,比如实现 Flash Attention,那是训练侧。但推理侧有一整个世界的复杂性。
如果今天的讲座你什么都没记住,我希望你记住一件事:如果你理解推理、理解推理引擎、理解支撑很多核心技术的 GPU kernel,你就能在机器学习算法上实现全栈创新。
今天我会先给一个高层次的概览,讲 token 的一生。当你向这些模型发一个请求时,这个请求会发生什么,它如何穿过整个推理服务,你必须做哪些有趣的选择。然后我会深入讲两个研究项目,看看如果你拿出这个系统的某些部分,可以问什么问题、可以做哪些事情。
在进入技术细节之前,我快速做个介绍。我代表两个组织。一个是 UCSD,我在那里有一个小实验室,今天部分工作来自那里。另一个是 Together,今天很多工作也来自那里。Together 是一个 AI 云,做 GPU、推理、微调等等,有很重的研究背景,包括 Percy——我不确定你们能不能看到我的鼠标,但他就在屏幕上。我们背后有很强的研究存在,今天很多内容也来自那里。
token 的一生:请求进入推理引擎后发生什么
好,我先给一个高层次的概览,讲 token 的一生。当你向一个推理系统发请求时,会经过哪些不同部件?这些幻灯片借鉴了我的学生 Austin 在 Together 的经验做的一个报告。我要说明一下,所有幻灯片都是 Nano Banana Pro 完全 AI 生成的,但它们还不错,只要你不仔细看技术细节。高层上一切都是对的,如果你看得很仔细,会看到一些非常错的东西。
这是推理引擎所有部件的总览。我大致描述一下它怎么工作。你收到一个请求。第一件事是请求被调度到不同的 GPU 上。你可能把 prefill 和 decode 分离(disaggregated prefill and decode)到不同机器。然后你会拿这个请求去查 KV Cache,看看我以前见过这个请求或它的某个版本吗?有没有可以省下来的计算?接下来你开始执行核心的机器学习代码:这里有新 token,有需要计算的新操作。你可以做各种优化,把计算切分到不同机器,跨节点并行,或者在节点内跨不同 GPU 并行——取决于你的模型大小、取决于你怎么切分。我觉得一件令人兴奋的事是,随着硬件发展,我们会开始看到更多不同的选择。但一旦你做完所有选择、执行完代码,最后你得到 token,你可以问:Chad,Percy 说的线性回归是什么意思,我那天逃课了。然后你得到整个端到端体验。
有一件事我觉得值得开始思考:这些不同的工作负载长什么样?它们差别很大。这是那种你仔细看会发现有些 5 变成了 S 的图。你要想的一件事是,当你实际服务生产流量时,它不一定像训练时看到的 token,也不像你脑子里随便编出来的一个流量工作负载。
我们通常看到的是什么样?对某个特定工作负载,你会看到特定的输入和输出 token 分布。举个编码工作负载的例子。假设你是 Cursor 这样的产品,整个代码库对 agent 可用,你在上面问问题。通常会有很长的输入,数万个输入 token。然后取决于你的模型怎么训练,模型可能输出一些思考 token,或者输出一些短内容。这会随工作负载变化,也会随模型变化。
编码工作负载和摘要工作负载、叙事摘要工作负载看起来非常不同。如果你的工作是把整本书粘贴到聊天窗口里,然后来回对话搞清楚发生了什么,那和标准聊天非常不一样。如果你只是打开聊天说,嘿,给我解释一阶微积分或者随便什么,那工作负载形状也非常不同。
我们今天使用语言模型的方式,有很多是回合制的 agentic 工作流。写代码时,你和编码 agent 来回说:做这个;不,我不是那个意思,做那个。你的编码 agent 本身可以对语言模型迭代很多次。它可能调用一些工具,说在我的代码库里 grep 搜索某个东西,然后把结果喂回模型。它可能去做一次互联网搜索,查用户问的事情。所以通常,你们的对话会有很多轮。
另一个有趣的点是,不同应用有不同的时间节奏。如果你在一个快速、交互式的聊天循环里,或者你在手机上用 ChatGPT 语音模式,你可能希望响应相对快。另一方面,如果你组织了一个 agentic 工作流,说:去帮我做这个,我会不管你,你自己迭代,那节奏就不一样。如果某个时刻你的 agent 卡住了,说:嘿,帮帮忙,我需要建议,而你没注意到,那么回合之间又会有另一个间隔。所有这些事情开始定义你的工作负载长什么样:每次有多少新 token?你要生成多少 token?我的会话有多长?我是一个很粘的用户,一直和我的 Claude Agent 来回聊,还是我只问一个问题,然后离开,第二天再回来?当然还有回合之间间隔多久。比如我有一个 ChatGPT agent 聊天,聊我该怎么安排每周锻炼,我每两周才和它交互一次。那是非常不同的流量模式。
当然,取决于你的应用,目标也不同。如果是交互式应用,你可能会说:我希望第一个 token 在不到一秒内回来,这样 agent 可以说:嘿,我在想,然后说 blah de blah de blah。或者我可能说:我知道我要生成 500 个 token,我希望整个响应在某个时间内回来,让用户能足够快地读完。所有这些部件都会影响你的目标。
当请求进来的时候,有几个基本部分。我先稍微放一放 prefill 和 decode 的部分。假设你有一段文本进来,第一件事是 tokenize 它。我想你们都熟悉这个。然后你就进入一个有点复杂的调度机制,因为你必须问:我之前见过这些 token 吗?我能直接从缓存里查一些激活吗?
然后你基本上进入机器学习计算的两大块:prefill 和 decode。它们是非常不同的野兽。prefill 意思是,假设你有 1 万个从未见过的 token,你想计算激活和 logit 应该是什么。1 万 token 进,1 个 token 出。那是非常计算受限(compute bound)的操作,看起来和你们训练时看的东西很像。训练时你有大量 token,写 Flash Attention kernel,跑训练循环。prefill 非常类似,只是不跑反向传播。然后有 decode。decode 是你之后一次只生成一个 token。你把这 1 万个 token 传进去,说:这是我的代码库,告诉我函数 ABC 是做什么的。模型会处理整个 prompt,然后开始一次生成一个 token,如果你有 speculative decoding 工作,可能一次三四个。
如果你想想那个操作:每生成一个新 token,你都要把它再跑一遍模型。算一下,其实需要计算的 flops 并不多。所以它是相对轻的计算,但又非常受内存带宽限制(memory bandwidth bound)。这意味着,你为了生成一个 token,必须把整个模型加载一遍。在模型 pass 结束时,你得到一个代表 token 的数,然后把它变成字符串。你可能还会做一点处理,比如找停用 token、做安全检查,看用户是不是想以坏方式黑进系统等等。最后你得到 token。推理引擎其实一直在这个循环里运行,等待请求进来。也就是说,它跑着这个调度、执行、token sampling 的循环,然后重复。
连续批处理、KV Cache 与模型并行
现在我们可以再深入一步,看看当一个系统同时处理很多不同请求时是什么样子。我们有这个叫连续批处理(continuous batching)的技术。读这个图的方式是:时间向下流动。随着你往下走,有新请求进来。假设第一步,你有一些长请求:一个用户在要求分析一份长文档,引擎正在生成一堆 token。可能另一个请求进来,开始占用一些资源。这些资源首先是计算资源,你得同时跑多个请求。如果 KV Cache 被填满,它们也可能是内存资源。所以你会看到多个请求同时发生。一步之后,那个短请求可能完成了,一个新请求进来,可能跑几步。如果另一个请求进来,也许又是一个很长的请求,那就是第四步里橙色那个。但也许你没有足够的 GPU 内存——你需要把所有 KV Cache 存在 GPU 上——也许你用完了,于是可能开始排队。等那个长请求完成后,你可以开始新请求,然后继续看它跑。所以已经能看到,当很多不同请求活在一个系统里时,复杂度是什么样的。
有一个非常重要的部件叫 KV Cache。可以这样想:你有很多用户说:嗨 ChatGPT,或者嗨 Claude。理论上你不需要为每个用户都重新计算新激活再跑一遍。或者如果用户传进来一本长书,你只对那本书跑一次 prefill,然后下一次你收到这个请求的补充,也就是对话的下一轮,你不需要把整个东西再算一遍。所以我们有 KV Cache 这种机制,用 prefix sharing,说:我有一组新 token 进来,用一个很传统的数据结构,比如基本的树,看看哪些 token 我以前见过、哪些是新的,然后基本上查一下那些激活会是什么。
一旦计算真的在 GPU 上跑起来,有各种切分方式。我不确定你们有没有讲过这个,但假设你有一个万亿参数模型,跑在 280 GB 的 GPU 上,你不可能把整个模型放进每张 GPU。所以有各种切法。你可以把每个 tensor 切成四份,放到四张 GPU 上,这叫张量并行(tensor parallelism)。或者今天有很多最先进的模型是混合专家(mixture of experts)模型,你有一个个专家,根据不同的 token 被选择性激活,你可以把它们切到不同 GPU 上。你在这里做的选择会决定瓶颈是什么:你需要多少 GPU 来跑模型?你同时能服务多少会话?
一个很大的事情是,我们倾向于把 prefill 和 decode 分到不同 GPU、不同机器上。这是因为它们的计算瓶颈和计算特征非常不同。prefill 看起来很像你们训练时做的事情:你直接跑,非常 flop 密集,可以真正把 GPU 用到极致。decode 则是非常内存带宽密集,因为你没有做那么多计算,但仍然必须同时加载所有模型权重。它们花的时间也不同。prefill 通常比单个 decode 步长很多,但你会跑多得多的 decode 步,因为一个 prompt 跑一次 prefill,每生成一个 token 跑一次 decode。所以一个很基本的优化,几乎所有人都开始采用:prefill 在一组 worker 上跑,decode 在另一组 worker 上跑,这样你可以把这两个计算专门化到栈的不同部分。
结果发现,这种分离能带来很多创新。有些事你们可能听说过:NVIDIA 收购了 Groq。NVIDIA 是 GPU 之王,却买了这个新的推理芯片。原因之一就是 decode 工作负载和 prefill 工作负载太不同了,如果你看代码,你完全可以用非常不同的芯片。比如下一代硬件里,NVIDIA 计划用它的 GPU 做 prefill,用这些 LPU Groq 芯片做 decode。Cerebras 也有类似的事。OpenAI 和 Cerebras 有计算合作,那是另一种更擅长 decode 的芯片。还有 SambaNova 等公司,也在押注这个空间的不同部分。
大规模部署中的诡异 bug 与 KV Cache 分层
这里有一个有趣的例子。当你真正大规模部署推理引擎、开始每天服务数万亿 token 甚至更多时,你会遇到一些相当讨厌的 bug。这是一组 bug,大部分发生在去年底的一些开源推理引擎里。大规模系统的一个特征就是:在小规模能工作得很好的东西,在大规模必然开始坏。我们说的是发生概率 0.001% 甚至更低的事件。有些是 NaN。有时候你有一个 kernel 有非常轻微的错误,但触发条件非常罕见。你会看到一些 logit 在计算中途变成 NaN。当这种情况发生时,我们发现模型会开始输出同一个 token。我记得某个模型输出开始变成:hi, hi, hi, hi,或者开始输出感叹号,然后你陷入这些循环。
另一个模型,有人在某个时候改了工具调用的处理方式。工具调用是模型说:嘿,我想做一次互联网搜索之类的事情。模型会说去做互联网搜索,然后你的外部 harness 必须用老式代码去做搜索。某个时候,其中一个引擎里这些工具调用不再被正确处理。症状是补全长度暴涨,因为模型会说:嘿,做一次互联网搜索,然后它通常会——当模型行为正确时——说:嘿,做一次互联网搜索,我完成了,返回给用户,去做吧。但在这个情况下,它没有正确返回。所以它会说:嘿,做一次互联网搜索。嘿,做一次互联网搜索。嘿,我不知道为什么没有搜索发生。它就这样陷入非常长的 doom loop,持续数万个 token。
还有一个 bug 很有趣,因为它同时干掉了几家保险提供商。它实际上被归咎于量化问题。但真正发生的要微妙得多:模型会突然、随机地开始输出中文字符,而它完全没有理由这么做。对某些模型,有时会有猜测说:哦,它们一定是在中文模型上微调过,因为我的模型突然——我用英语问它,它开始用中文回我。实际上发生的是,某个 kernel 里有一个 off-by-one 错误,是一个非常微妙的 bug。有时你会从 GPU 读到一些额外的未初始化内存,把它跑过 retention,然后在整个过程结束时,你得到一个随机中文字符。然后模型会想:我为什么突然开始用中文思考?一定是用户在问中文问题。然后它就一路偏到中文去了。所以有时这是因为模型真的被训练成用中文思考,有时只是别人代码里的一个 off-by-one bug。
在更先进的推理栈里,开始出现一些有趣的事情。运行大型生产系统的一个重要部分,是你要有尽可能大的 KV Cache。最好能缓存来自很多不同用户、或同一用户很多不同会话的请求,并运行尽可能多的会话。你一开始把 KV Cache 存在 GPU 上,然后很快 GPU 内存就不够了。接下来你可以开始存到 CPU DRAM。如果你关注 Jensen 的主题演讲,他最近开始非常关注 CPU 性能。原因之一是上一代 CPU 实际上很慢,结果瓶颈了很多重要工作负载。于是你开始注意:如果你 50 万美元的机器被上面插的 1000 美元 CPU 卡住,那可不是什么好位置。这会发生的原因之一,就是你可能把 KV Cache 存在 CPU 内存里,于是你非常关心把 KV Cache 读回来的速度。再下一步,你可能把更多 KV Cache 放到磁盘上,然后你开始关心 SSD 和 SSD 空间。
你可能也听到过这些风声:OpenAI 买下全世界的 SSD、全世界的 DRAM。部分原因就是这种事——你想在 KV Cache 里存尽可能多的东西。然后当你实际构建引擎时,有一个复杂的舞蹈:这些 token 我有一阵子没见了,也许我把它驱逐掉,送到 CPU,或送到磁盘,或送到某个全局存储。当我收到一个新请求,我得去看,得等它们回来,取回来,加载进我的东西,然后我们高兴了。
有人问:这种 CPU offloading 是不是特定类型的工作负载?对于快的东西,你不想这么做,因为访问 SSD 的时间非常长。这里其实回到一些非常经典的操作系统调度问题。你可以把这张图——除了右边那些 GPU 部分——做得和你在 70 年代或 80 年代操作系统教材里看到的图一模一样。因为我们以前也有这个问题:如果你在电脑上打开太多应用,CPU 内存会不够,然后你不得不把那些应用放到磁盘上。这是完全一样的工作负载。其实左边这里,Nano Banana 幻觉出了 evictions for least recently used,而 LRU 实际上是一个相当不错的启发式。某处应该有一篇 OS 论文说 LRU 在最优解的两倍以内。当然,最好的做法是,如果你能预测未来,你就会知道:哦,我马上会收到某种请求,让我预取那块内存。预测未来可能真的有可能。比如你打开聊天应用,翻出一个一个月前的旧对话,这是一个很强的信号,说明你接下来要问关于它的问题。你也许就想把它加载到 GPU 上。但在完美世界里,你预测未来;如果不能预测未来,你就用各种启发式。真正的问题是,你想往 GPU footprint 上放多少流量。我从没和任何人聊过,说他想往 GPU 上放更少的流量。所以,受制于我们开头列出的那些 SLA,你想服务尽可能多的流量。
最近开始出现一些有趣的事。在上一代 Blackwell GPU 里,NVIDIA 开始推出叫 NVL72 Grace Blackwell 的新 GPU。那是 72 张 GPU,用非常快的互联连在一起。于是你开始想:我怎么把万亿参数模型切到全部 72 张 GPU 上?这有意义吗?它给我买来了什么?会发生什么?我怎么开始考虑容错?这些东西可能因为各种原因频繁失败。一个原因是连接器很脆弱,它们是塑料做的,不是金属做的。如果你插得太用力,线缆会把东西弄弯一点,然后你就得到非常不稳定的 NV link,这本身就是一整套问题。但还有一个问题:假设我把模型切到 64 张 GPU 上,我在用生产流量服务数百万用户、数万亿 token,当一张 GPU 挂掉时我怎么办?有没有办法让它容错?这里面有很多有趣的事可以想。当然,你也开始看到这些模型有上百万上下文甚至更多。你实际怎么处理?你把上下文切到很多不同 GPU 上吗?你怎么做这些事情?
一个系统级优化:cache-aware prefill-decode disaggregation
我想非常简短地强调一组优化,是当你从系统层面看整个过程时可以做的。这是几个月前我们做的一项工作,叫 Cache-aware Prefill-decode Disaggregation。它是一个非常简单的优化,在路由层两行代码,但能带来很大差别。基本想法是:你有所有这些进来的请求,大多数会是对话里一轮一轮的请求,某人已经开始了对话。假设你平均对话持续 10 轮,然后用户离开,那意味着 10% 的请求会是全新的、非常新的请求。当一个新请求进来,它会有数千 token,看起来非常不同,计算起来贵得多。你不一定想把那个 prefill 放到一个正在进行中的短对话上。比如有人粘贴一本书进来说:嘿,跟我聊聊这本书。你不想让它和另一个人中途的对话同时跑,那个人在问:chat,解释一下为什么 1+1=2。chat 说:1+1=2 因为数字什么的。你说:哦,我不懂。你不想那个非常短的问答和非常长的请求在同一时间、同一批 GPU 上发生。
所以你可以做一个非常简单的 router,说:好,如果我们收到一个新请求,cache hit rate 非常低,把它发到一组 GPU,让它们一起处理;然后把所有其他 warm 请求发到另一组 prefill 节点。结果发现,如果你这么做,用这个非常简单的优化,最多能快 40% 地服务。
所以我会这样描述我们在这些技术和研究上的位置:我们非常早期。这是那种 10 年、20 年后人们回头看会说:哦,为什么这些人在讨论这个?这不已经很明显了吗?但真正的原因是,我们刚开始在生产中以新的方式看到这些东西,以新的方式看到这些新的流量。
Megakernel:把 decode 压进一个 kernel
好,以上是推理和 kernel 的一生的概览。现在我要讲两个有趣的研究项目,它们非常受我们在服务中看到的东西启发。第一个讲语言模型 decode,以及怎么用所谓 megakernel 让它快很多。这是 Stanford 和 Together 的合作。
运行 decode 时的根本挑战是:decode 的过程是你已经处理完 prompt,现在一次生成一个 token。根本挑战是你必须跑整个模型来生成一个 token。这意味着,你不再像 prefill 或训练时那样使用 GPU 的大规模并行,你把这个大规模并行系统变成了一个基本上是美化过的内存加载器。让这件事更困难的是,我们通常写 kernel、跑模型的方式是一次一个操作。你们大概能理解为什么这样:kernel 很难写。我相信你们写 Flash Attention 时都玩得很开心。但这也意味着,通常我们会看语言模型里所有不同操作,然后一次为那个操作写一个 kernel。这样编程容易多了,因为你只要写 norm kernel、matmul kernel、attention kernel。但最终你会在系统里引入很多空闲时间。
这是你运行推理时可能的样子。这显然是卡通图,但例子来自某个 attention 推理 kernel。读法:x 轴是时间,从左到右流动;y 轴是 GPU 上所有不同的 streaming multiprocessor。H100 上有 132 个,B200 上有 148 个,等等。这些条表示有用的工作。当这里有根条时,GPU 上的某个处理器真的在工作;空白就是在等待。你在等其他操作结束,好让别的东西能跑。基本上你会陷入这种情况:无论你试图把 kernel 写得多好,GPU 里总会有空闲。你会看到 kernel launch 和 kernel teardown,就是红色和黄色里那些大空隙。你还会看到所谓 tail effects。就像你有一个短 prompt 和一个非常长的 prompt 一起处理,同样的事情会一直降到最基本的 attention 操作。如果你处理一个 batch 输入,一个输入很短,一个输入很长,你得等很长的输入完成。然后因为你跨多个 kernel 运行,这些 kernel 之间的空隙会开始累加。
我们为此做的东西叫 Megakernel。基本意思是:不要把模型里每个操作当成自己的操作、为它写一个 kernel,而是写一个 kernel 一次覆盖多个操作。这类似于你在 Flash Attention 里看到的融合,只是更激进,跨更多东西。特别是,它把 GPU 从单设备、单操作,变成让你把 GPU 想成一个大规模分布式系统,然后说:好,我有所有这些要做的工作,有些依赖其他东西——那块红色的东西依赖一些绿色的条——我怎么调度它?我怎么分发工作来最大化 GPU 利用率?如果你只对 attention 推理 kernel 这么做,就能得到 30% 到 70% 的加速。好处是你可以把这件事做到整个模型上。
这是 Llama-1B 模型的一层。这里我们基本上把整个层放到一个 kernel 里。如果你看这些不同的条,你会看到它们以非常奇怪的方式重叠。那是因为我们现在开始把下一层的权重加载与 attention 重叠,或者在 attention 操作结束前就开始跑一部分 reduction。
这里有一个有趣的例子。现代 LLM 里,你有 attention 层,有 QKV 投影,然后加上一些 rope scaling,那就是这些蓝线。蓝线是 QKV 加 rope。橙色线是 attention 的开始。一个洞察是,你可以在 QKV 还没完成时,就开始把 KV Cache 加载进 attention,尤其是在 decode 时。所以这些带圆圈的橙色条,是在 QKV 加 rope 还在跑的时候就开始 KV Cache load。然后一旦 QKV 完成,你有了新的 query token,就可以跑 attention 的其余部分。所以当你对 GPU 有这种细粒度控制时,你可以在一个操作结束前就开始另一些操作。再举一个。橙色是 attention 计算的第一部分。红色是 attention 之后的 O projection。你可以在 attention 操作结束前就开始加载 O projection 的权重。
我们把这个东西放在一个相对复杂的 CUDA 框架里,基本上是基于指令的抽象,每个子 kernel 可以实现在自己的文件里,然后有一个大的虚拟化共享内存系统来编排这些操作的运行。为了做到这个,我们做了一个叫 ThunderKittens 的库,这是一个 kernel 编写库。你可以把它想成几乎像 Triton,但更底层,对很多事情有更细粒度的控制。回报是你得到接近光速的解码推理。
这些数字现在实际上更好了。Megakernel 是青色条,你可以看到它比一些其他最先进的引擎快很多。在 H100 上,它达到 72% 的带宽利用率,接近 GPU 上的光速。也就是说,如果你忽略我们在这里做的一切复杂性,只问 GPU 物理上最快能多快做这个操作,我们相当接近那个光速的 72%。
那一节的一个 takeaway 是:如果你对 kernel 有非常深的控制、对硬件有很深的理解,你就能开始实现非常不同的计算范式。所有这些,只有当你深度玩推理时才能看到。
Parcae:循环 Transformer 与稳定化
现在我要讲一个稍微新一点的东西,进入新架构的领域。我要讲这个叫 Parcae 的新模型。这是来自我 UCSD 实验室的工作,由 Hayden 领导,也和 Zachary、Taylor 两位合作。回到开头,我说所有这些新能力之所以出现,是因为你开始扩展模型、参数和数据。用 Parcae,我们想问另一个问题:这是唯一必须的扩展方式吗?还是可能有别的东西、别的路能得到这种质量?Parcae 基本上是我们对 loop transformers 这项技术的做法:你拿 transformer 的一些块,让它们循环运行。
不是让 token 一次一层地穿过模型,而是在某个时候你说:嘿,你经过的时候,再送回那个循环里跑一次。这里有几个部分。一个是我们用了一些状态空间模型(State Space Model,SSM)理论来稳定这个操作。如果朴素地跑、让它训练,我们之前看到它会爆炸。另一件事是我们开始看到一些有趣的缩放规律,表明当你增加数据时,你应该扩展这些循环模型的 recurrence。为了最好地利用你的参数,你实际上想要在一定程度上重用它们。
首先我讲一点动机,为什么我们觉得循环这个问题很有趣。基本想法是:假设你有某个激活穿过模型的一部分,在某个点它碰到这些 loop blocks,它就会把同一个激活、同一层跑若干次。那个紫色块就是 recurrent block。最后你得到返回的东西。这样做有一些优势。你可以保持参数量不变,但它给了你一个调节旋钮来增加你的浮点运算量。所以如果你认为更多 flops 等于更高质量,那这是一种不付更高参数成本就能提升质量的方法。
另一件事是,有一项早期工作——早期是个相对说法,几年前——表明你实际上能获得更高的表达力。有些东西你无法在相同参数量下表达,而在这些循环模型中你可以表达。我们驱动性的问题之一是:每个参数的最佳质量是什么?每个参数的最佳智能是什么?或者说每参数、每数据量的智能?这些东西能让你做到什么?有一些有希望的初步结果。这是马里兰大学 Tom Goldstein 组的一篇论文,表明这个东西可能比 transformer 更好。这些是在 ARC 任务上的一些结果。而且还有一堆 Twitter 上的炒作,因为在我们发布 Parcae 大约一周前,OpenAI 的某个人说 Claude mythos 是一个循环语言模型。我认为这不对,我觉得他只是在编造。但结果是,它真的在 Twitter 上炸开了。最终他不得不写一篇博客文章,说:我的错,我只是编造的,这些都不是真的。但它足够有趣,值得一看。我们在这次 Twitter 炒作之前就在研究它,但我觉得这里有一些有趣的东西,值得一看。
现在,这些循环模型的一个问题是:如果你看其中任何一个,并且试着训练它们,然后你改了训练算法的任何东西——比如把学习率改一点点——你就会突然看到这些模型爆炸。如果你做一件简单的事,比如学习率 sweep,你会看到 10 次里有 9 次,这个模型就是不会收敛,它会爆炸。你会得到 NaN,得到大的 loss spikes。于是我们说:嘿,这看起来有什么地方不对。如果你在训练模型、实际 scaling,然后看到这些大的 loss spikes,那说明你的训练过程出了很严重的问题,你应该更深入看一看,试着搞清楚发生了什么。以前的工作有一些 hack,比如每层都放 norm 来搞清楚发生了什么,或者干脆只选 2e-4 的学习率,别选其他任何学习率。但我们在想:这些 loss spikes 的存在说明背后可能有更深的东西。
于是我们在稳定化问题上采用了一种有点数学、状态空间模型式的做法。我们的基本洞察是:你可以看这个过程。实际上,如果你看这个过程,试着解析地分析它,会非常复杂,因为这个大的 R 块有大量参数,有各种非线性,有 Softmax、GELU、RoPE 等等。所以解析分析相当复杂。我们的洞察是:我们就看看这个东西的 residual。这个激活从块到块是怎么变化的?我们第一个经验观察是:它其实变化不大。每个 residual 块只把向量改变一点,但并没有巨大的影响。
于是也许我们可以真的建模它,更深入地看发生了什么。我们所做的是,写下一个关于这个 residual 的动态系统。当我们写下来,我们意识到这里有几个部分,从你写模型的方式看可能相当 benign,但实际上最终影响很大。我们所做的是:好,这里有所有非线性东西,有 attention,有 GELU,有大的带中间层的前馈网络,我们把这些全放进一个盒子里,就叫它 R。这个 R 是一个大的非线性东西,但我们把它放到一边。剩下的是这些 A 和 B 矩阵。B 矩阵是对初始向量的一些变换,也就是循环开始前第一个向量是什么。A 矩阵是你在每个循环里怎么变换这个 residual。我们所做的是:这个简单的东西,如果你把 transformer 的所有复杂性放到一边,你就有了一个相对简单的方式来看之前所有 loop transformers。在之前的例子里,人们确实做了一些相当正常的决定。一个例子里,你直接把它当成 identity,只是加东西。另一个例子里,它是一个完全可学习的矩阵。
然后我们说:好,如果我们看这个 residual,我们经验观察到 A 和 B 矩阵实际上主导了这个方程的幅度。如果你把那个复杂的非线性部分丢掉,会得到什么?你实际上得到一个相当简单的系统,用高中微积分就能解出来答案。这是闭式解。给定初始激活是什么、初始注入是什么,你实际上可以算出第 T+1 步的激活长什么样。你会注意到几件事。这个东西由这些 A 矩阵主导,尤其是你把 A 矩阵幂到很大的次数。从这里我们意识到,有一个量叫 A 的谱半径(spectral radius)。谱半径基本上是范数的另一个说法。你可以这样看:你拿这个矩阵,把它幂到很大的次数。如果这个矩阵能学成某个东西——假设矩阵是 2,T 是 16,你就把这个激活放大到 2 的 16 次方,非常大。这就开始解释那些大的 loss spikes。特别是我们发现,以前论文里人们对 A 和 B 矩阵的选择,要么是边际稳定,要么是不稳定、让系统不稳定。
所以在 Parcae 里我们说:好,我们已经想清楚了,如果你这样看它,让 A 和 B 随便学,你会得到真正爆炸的东西。如果我们约束 A 和 B,使得从数学上它们不会爆炸呢?对 A,我们所做的是:我们实际上把 A 矩阵做成一个负对角矩阵。这样如果你把它幂起来,这一项最终会趋向零,所以不会爆炸。这个 B 矩阵,我们给它加一个非常简单的线性 norm,因为 B 矩阵实际上只应用一次,不会真的爆炸。谱半径现在会小于 1,现在它会是一个稳定系统。现在如果你去训练这个模型,我们看到你实际上会看到稳定的 loss 曲线。Parcae 就是这样一个稳定化的东西,我们重新参数化了 A 和 B。你可以看到,即使使用 6e-4 这个对其他模型非常糟糕的学习率,你最后也得到一个稳定的模型。而且你会看到,这样做可以自然地约束激活的状态范数。
这里有两个部分。橙色基线是一个完全无约束的模型,你可以看到它爆炸到 10 的 19 次方。蓝线是一个对它施加了 norm 的模型。这里发生的是:模型实际上试图扩张激活,因为它说:哦,有更多空间,我就能更好地表示不同的东西。我可以把这些不同概念放得离彼此更远。然后你对它施加 norm,把这个试图扩张的大东西压回 1。于是你有这两种压力互相打架,表现为 loss spikes。所以即使右边你的 norm 很好,你没有看到激活真的爆炸,你确实看到 loss 会做一些相当糟糕的事。所以基本上,激活里一个相当简单的改变就能稳定训练、稳定这些循环。
它不仅是一个更稳定的系统,你实际上还得到更高质量的模型。这是一张表,我们把 Parcae 模型和之前一个叫 Recurrent Depth Model 的 loop transformer 比较。你可以看到在各种应用上性能更高。Parcae 超过之前的模型,也超过很强的 transformer 基线。这个 transformer 是 nanochat 之一,很多人正试着让它学得尽可能快。而这些 Parcae 模型,如果你拿同样的基础 transformer 架构,开始循环它,然后稳定它,你得到更好的困惑度、更好的端到端质量。
Parcae 的缩放规律:数据、参数与循环一起扩
然后我们开始跑一些非常基础的缩放规律。这里我们的问题是:好,这个循环很可爱,你可以用它让东西更好。但你真的应该吗?有没有证据说明这里有某种东西让我们想更激进地循环?这里我退一步。几年前,当我们开始进入想扩展所有这些模型的阶段时,一个自然的问题是:我应该把模型做大,还是应该用更多数据训练?所以几年前,人们开始问:这两个量应该怎么一起扩展?我应该扩展模型参数还是扩展数据?我们做出了一堆非常复杂的幂律曲线,全都长这样。它们很漂亮,有很多颜色。你要看的是:如果曲线向右下倾斜,说明你应该同时扩展数据和参数。如果它直着往下,那意味着只要增加训练数据,不需要增加参数。如果它是平的、直接往右,那意味着根本不要增加数据,只增加参数。但你看到它向右下倾斜,意味着你应该同时扩展数据和参数。现在你看到这个:你训练一个 1 万亿参数模型,用 35 万亿 token,质量更好。所以我们的问题是:循环放在哪里?
有几种可能性。比如你可能得出结论:你永远不该跑循环,最好保持同一个单一循环模型。你也可能得出结论:你应该做大量循环,或者也许只做少量循环。我们这里展示的是,至少在这些初始缩放规律里,所有曲线都是等距的、等参数、等 FLOP 的(isometric, ISO-param, ISO-FLOP)。每条曲线是一个模型。左边和右边参数数量相同。当你往下走、改变颜色时,我们通过增加数据来增加训练模型使用的 flops。这里我们变化数据,也变化循环次数。我们发现,在两个模型里,你又看到向右下倾斜的趋势。这暗示,对于这些固定参数训练,当你增加数据量时,你实际上也应该增加循环次数。
我们发现这些循环遵循一些相当经典的幂律。你实际上可以开始预测质量,当你联合扩展循环次数和 token 数时。有人问固定模型大小下的循环,以及联合扩展怎么样?我们有一个非常复杂的三维图,显示循环、数据、参数,也指向向右下以及同时向那个方向。如果你相信那张图,它暗示你应该三个一起扩展。但那张图真的很难看,因为是三维的、很奇怪。这些幂律表明,当你增加数据时,你应该增加循环。然后还有别的幂律表明,当你增加数据时,你应该增加参数。所以显然,它暗示如果可能,你应该三个都增加。但这里有一点:如果你要固定模型大小,并且要增加数据量,你也应该增加循环次数。这很有趣,因为据我所知,我们今天所有模型都没有循环。它们都在这些曲线的最左边,而且都有大量数据。这暗示我们在训练这些模型时,也许可以做一点稍微更好的事情。
这里还有一个实验,换一种方式看。橙色曲线是固定模型大小——这里我们固定模型大小。橙色曲线是固定深度模型,就像传统 transformer。蓝色曲线是固定 FLOP 预算时,循环模型会落在曲线哪里。这里橙色和蓝色点用同样数量的 FLOPs 训练,但数据量不同,模型大小相同。当你通过增加循环次数而不只是增加数据来到达那个 FLOP 数时,你开始得到更小的验证 loss。这再次暗示,我们也许应该把所有大型预训练都循环起来。
总结与问答
这就是 Parcae。如果我退一步,回到整个讲座的大 takeaway:我希望今天给了你们一点感觉——如果你理解这些模型的推理,理解 GPU kernel,理解参与其中的所有部分,你就能真正实现全栈创新和机器学习算法。无论是通过一个新的路由算法让你服务更多流量、以不同方式服务流量;还是新 kernel 让你系统的某部分跑得快很多;还是新架构,说如果你参数少很多,你可以用不同方式把它们放进 GPU 的一个子集,减少通信量。这些都是我们今天看到的研究问题的不同部分。希望我至少启发了在座某一位去更深入地看其中一些东西。谢谢。
我们有大约 5 到 10 分钟提问。有问题的话请举手。第一个问题:Parcae,你们是从头在循环上训练吗?我猜我想知道,能不能用预训练模型做点什么?
好问题。所以问题是:用 Parcae,我们是从头训练吗?有没有什么可以用预训练模型做的事情?几个月前有一个人发了一篇很钓鱼的博客文章,大概说:嘿,我没有训练任何东西就赢了一个排行榜比赛。他做的是,他实际上循环了 Qwen 模型中的两三层,然后发现它在一些数学任务上开始有更高的质量。所以我们有一些工作正在研究这个,可能很快就会出来。但可能有些模型,如果你对预训练模型做一点点循环,你就能得到更高质量的东西。这真的很奇怪。这让我感到不安。我不知道为什么会这样。但是的,我们对此非常感兴趣。我想我们会研究它,希望如果我能说服 Hayden,他会盯着激活——盯着实际的权重,去弄清楚为什么当你循环它时,它会变得更好。
这里就这个问题追问一下:你谈到了循环模型的计算最优性,你能谈谈推理方面的影响以及内存,以及它如何能让你跑得更快吗?是的。所以其中一个让我个人对这些循环东西非常兴奋的原因是,服务推理的一个大瓶颈实际上是 GPU 内存。所以如果你有更少的参数量,你可以装下更多 KV Cache,或者你可以做更少的通信,因为你只需要把模型分布到更少的 GPU 上,诸如此类。所以更小的模型实际上能给你很多灵活性。我还有一个梦想:如果你能把循环块做得足够小,你实际上可以写一个小 megakernel,用一个小小的、非常快的 megakernel 循环来做那个循环。到目前为止,我们还没能把那些块做得足够小。但我觉得这很有趣。当然,随着下一代 LPU、Groq 芯片与 NVIDIA 的东西一起到来,它们有 250 兆字节左右的内存。所以你能放进它们的东西会非常非常小。但也许你可以设计出某种东西,实际上能放进去,然后你就可以把权重一直留在内存里,尽可能快地跑激活。所以如果你能跨过其中一些门槛,就能得到非线性的收益。我希望我们能很快达到。
好。下一个问题:Megakernel 的 trade-off 是什么?问题是,我的 kernel 的 trade-off 是什么?trade-off 是人们的血汗和眼泪。Megakernel 是非常劳动密集的。给你们一点背景:一个全职的有才华的 kernel 工程师,在一年时间里,可能能为一套硬件、两三个模型、batch size 1 到 16 写出 megakernel。到了 batch size 17,他就说不行,得从头来,再来一次。所以它们非常非常难写。在 Together,我们正试着做一些编译器来自动化其中一些过程。但我会说,这是一件非常有挑战性的事。我觉得 megakernel 的基本想法在过去几十年 GPU 编程里有过高峰和低谷。但如果你能做,它会超级快。你永远不可能更快,但它要花很多能量和很多努力。
也许我可以问一个问题。我们能多聊聊协同设计(co-design)吗?特别是你提到推理侧这些新硬件,比如 Groq 和 Cerebras。如果你在设计一个模型,而那个硬件就是服务平台,你应该怎么改变你的架构?是的,我觉得有几件事你应该看。一个是,你首先会最受内存约束。如果你知道你要拿一个模型,把它服务在某个特定的 Cerebras 芯片上,你就想去看 Cerebras 的 wafer,弄清楚你有多少内存,然后调整你的模型大小,让它能放进去,并且留够 KV Cache 或其他东西。如果你仔细看中国出来的模型,他们开始做一些有趣的选择,暗示他们可能在考虑华为出来的芯片。你会看到人们做的量化选择。比如,如果你有一个打算服务在 NVIDIA GPU 上的模型——NVIDIA 发布的新 Nemotron 模型就是例子——你会用 NVFP4 训练那个模型。这是 NVIDIA 芯片专有的 FP4 格式。如果你不打算在 NVIDIA 芯片上跑,如果你是 AMD,那你会用另一种叫 MXFP4 的格式。它们各有优劣。你会基于你选择的硬件做这些微妙的选择。
那边有一个问题。我想知道,如果你只关心计算最优训练,有没有可能循环比增加参数更优,还是说它主要是一个降低推理成本的技巧?问题是,对于 Parcae,你会不会选择循环而不是增加参数?计算最优的诀窍总是:给定某个 FLOP 预算,弄清楚你想达到什么。从这个意义上说,它几乎有点人为,因为如果你想要更高质量的模型,你就应该直接增加 FLOP 预算。如果你已经决定了模型大小,那就训练更久。或者如果你受模型大小限制,那就循环更久。或者如果你数据用完了,那就选一个你认为能尽量过训练——尽量训练充分的模型大小。所以我觉得有选择。当然,你做所有这些选择时都要考虑:我认为它会被采用吗?我打算怎么服务它?如果你要开源,人们今天能在你的笔记本上服务多大的模型?所以所有这些选择都会进入你决定训练多大模型的决策。我觉得如果你只是把模型变大、用更多数据训练,它总是会更好。但我认为这真的取决于那些设计点。
也许再问一个关于协同设计的问题。你开头提到不同用例,比如 agentic coding 和批量数据处理。那么在不同用例之间,你看到的最戏剧性的最优架构差异是什么?当然,最后模型开发者必须选一个,尽量在很多用例上都合理。这是个好问题。我觉得一个很大的差异是:当你有这些 agentic 循环工作流时,很重要的一点是你要让 KV Cache 尽可能热。如果你在做大批量处理,每个文档你只看一次,然后翻译它,那 KV Cache 就不一定那么重要。如果你看像 DeepSeek MLA attention 这样的东西,它是对 KV Cache 的激进压缩,和另一个模型会有的 KV Cache 相比。或者如果你的模型能用 FP8 或 FP4 处理 KV Cache,那些都是很大的偏离,如果你对 agentic 工作流敏感,你会去看 KV Cache 的大小。然后当然,最大的一个是因果 attention 还是非因果 attention。如果你只是做大批量处理工作流——比如很长时间里 Google 搜索一直用 BERT 模型,我想现在可能还在搜索里用 BERT——那是因为你并不真的需要在另一端生成一堆 token。你只要做一次大的双向 attention,得到你的向量,然后把它放进数据库,随便你怎么用。而聊天工作流,那些处理总是会有 decode 部分。我觉得也有一些中间产物,比如 T5 曾经是人们做双向处理、同时也做一些生成的选择。
好,也许最后一个问题。哦,是的。问题大概是关于把所有东西放进一个 kernel,以及你之前讲的东西。我想知道,当你在多台计算机之间有通信、要扩展成本时,那会怎么工作?好问题。问题是:当你有多个 GPU 在循环里通信时,Megakernel 怎么工作?我们有一些非常早期的初步工作。结果发现,如果你设置正确,你也可以把 NCCL 调用融合进 Megakernel。我觉得我们还没有找到真正很好的杀手级用例,因为有时候你只是受 NCCL 调用本身的延迟限制。所以这些也是你可以融合的东西。当 DeepSeek 4 出来时,DeepSeek 4 发布了一个针对混合专家推理层的 Megakernel,可以只为那个层运行,他们实际上融合了其中一些通信。所以我觉得我们开始看到越来越多的是:当你到更多这些模型时,你会为计算的一部分做一个小 Megakernel,但你不一定会为整个模型做一个 Megakernel,除非你付出那个血汗和眼泪的代价,然后真的把整个东西跑起来。好,我想时间到了。让我们再次感谢 Dan。非常感谢你们邀请我。
CS336 第一讲:课程概览与 tokenization
这堂课是 Stanford CS336《Language Modeling from Scratch》2026 年春季的第一讲,内容是课程概览、教学团队介绍、课程安排、五次作业和五个单元的总览,并在最后进入第一个单元:tokenization。
开场与教学团队
欢迎大家来到 CS3336,Language Models from Scratch。这是教学团队。我是 Percy。这位是 Tatsu、Marcel、Herman 和 Steven。我们带来的是 336 的第三版。我们先快速做一轮自我介绍。我通常说我已经做了 20 年语言模型,但其中大部分时间其实是小语言模型。实际上,放在大格局里,这仍然算小。我想,两年前 Tatsu 和我开始教这门课时,我们并不确定会迎来什么。但我们非常惊喜地看到这么多人想学习如何从零构建语言模型,尤其是在现在这个编码智能体可能可以零样本生成一个语言模型的时代。我真的很高兴看到你们所有人来到这里,真心想学习它们是如何工作的。
谢谢。我是 Tatsu,联合讲师之一。等我们讲到架构、缩放等等这些有趣的东西时,我会跟大家讲。我很兴奋。这是我在斯坦福任教以来教过的最有趣的课。每年 Percy 都会取笑我说,你必须重做你所有的讲座,因为你负责架构,而每次一切都在变。但对我来说这其实挺有趣的,这也是我第一次有这种经历。我期待和你们一起再经历一次。
你好,我是 Marcel。我以前担任过这门课的 CA,这次回来是因为上次太有趣了。不过工作量的确很大。在我的研究里,我做架构、做高阶梯度、也做训练。期待和大家合作。
大家好,我是 Herman。一年前,我其实不太懂 LLM 是怎么工作的。所以对我来说,token 是你在电子游戏里收集的东西,attention 是你有的那种注意力经济里的注意力。后来去年在这门课上花了很多时间之后,我现在在做 LLM 研究,我很兴奋今年能当 TA。
大家好,我是 Steven。我是这门课第一次担任 CA,我非常兴奋。我觉得会很有趣。大体上,我的研究涉及语言模型、理论,以及一些数据效率方面的工作。我很期待认识大家。
好,那我们进入正题。这是第三次开课。去年我们决定把所有讲座放到 YouTube 上,所以你们有些人可能看过。那有什么新东西?没有变的是从零开始的哲学。我们仍然坚信,通过从底层构建一切,你才能真正学到一切是怎么工作的。当然,我们并不是真的所有东西都从零构建,因为一个 quarter 装不下。所以过去两年我们一直在完善配方,弄清楚哪些东西从零构建最有价值。最后,正如 Tatsu 提到的,即使只过了一年,也变了很多。我想今年我们可能会在 mixture of experts 上花更多时间。当然,智能体现在很流行,所以掌握长上下文以及它需要什么会很重要。
为什么开这门课:抽象泄漏与基础研究
那我们为什么要开这门课?我想两年前的问题是,研究人员正在与底层技术脱节。大概十年前,所有 AI 研究人员都会实现并训练自己的模型。再近一点,八年前,人们会下载 BERT 这样的预训练模型并微调它们。而今天很多时候,你只要给模型写提示词就能应付。当然,提示模型没有错。你可以用它做出很棒的事。一般来说,提升抽象层级是好事,但抽象是有泄漏的。有时候,我肯定你们都提示过模型,会遇到一种情况:你想做某件事,但就是做不到,而且没有别的办法。我想说,如果你真的对基础研究感兴趣,仅仅通过提示模型,你其实大幅限制了你所看的选项集合、设计空间。而做基础研究,你真的需要把整个栈拆开。所以我认为,完全理解语言模型如何工作,对基础研究来说是必要的。而我们获得理解的方式就是构建。这就是这门课的哲学。
但有一个小问题,语言模型的工业化已经发生了。前沿模型非常非常昂贵。即便这是三年前,GPT-4 据说训练成本是 1 亿美元。现在成本大概是 10 亿美元量级,虽然这只是推测。所有大实验室正在建造的 GPU 数量也非常巨大。而且,这些模型是怎么构建的,没有细节。即便在 2023 年,GPT-4 论文也明确说,由于竞争格局和安全影响,我们不会分享任何关于模型如何构建的内容。所以这些前沿模型在某种意义上是我们够不着的。
现在,我们可以构建小语言模型,我们也会构建小语言模型。但是要记住,它们可能并不代表真正的前沿模型。我给你们两个例子说明为什么会这样。这里有一个帖子。其实这是很久以前的,我想是 2021 年。我们之后会花更多时间数 FLOPs,看看计算花在哪里。但如果你看小规模,花在 MLP 层里的 FLOPs 比例大约是 44%。如果你扩展到 175B,它就变成 80%。所以,在大规模下你优化什么、什么重要,会和在小规模下不同。如果你在这里做了很多关于 attention 的小规模工作,你可能不会在大规模上体验到同样的收益。第二个例子是,我们知道行为会随着规模涌现。小模型,这同样是很久以前的,但即便那时,如果你看各种任务的零样本或少样本学习,基本上看起来什么都没用。只有当你达到某个临界规模,你才会突然看到很多改进。所以,同样,如果你在小规模上工作,你可能看不到某些现象,而在全规模下就能看到。
可迁移的知识:机制、心态、直觉
好,这可能有点让人泄气。但别怕,我们在这门课里会学到东西。问题是,我们能学到什么真正可迁移的东西?我认为把它拆成三类知识很重要。第一,是事物如何运作的机制,比如 Transformer 是什么,模型并行如何工作。第二,是心态,也就是你如何着手构建一个语言模型。我们会讲如何从硬件里榨取最大价值,认真对待缩放。最后是直觉,哪些数据建模决策会产生好的表现。现在,在这门课里,我认为我们可以很好地教授机制,也就是事物如何工作,以及心态。我们会非常强调,你要对一切做 profile 和 benchmark,并尝试优化效率。这些东西确实会迁移到更大规模。而那些关于什么建模决策、什么数据决策有效的直觉,则未必能跨规模迁移。要获得这些,你实际上得去一个能做大规模事情的地方。
所以,关于直觉,值得注意的是,有些设计决策就是无法被证明合理,纯粹来自实验。而机制,你大体上可以构造性地看到并行以及 kernel 如何加速等等。但关于什么建模改动有效的直觉,我认为你只能跑实验。有一篇著名的 Noam Chomsky 论文引入了 SwiGLU 激活,我们之后会看。在结论部分,他很诚实地写了最后一句,说:我们不提供解释。我们把这些架构的成功,以及其他一切,归因于神圣的仁慈。所以在某种意义上,这是你只能从经验中获得的东西。
最后提一下苦涩教训,这个说法一直在流传,人们也在谈论。我认为这里有一个常见的误解,关于它是什么意思。错误解读是:规模就是一切,算法不重要。但那不对。正确解读是:能 scale 的算法才重要。你可以很简单地想,模型的准确率基本上就是你的效率乘以资源。效率是输出除以输入,资源是输入。而效率在更大规模下其实在某种意义上更重要得多。对吧?如果你做小规模实验,你的运行花了两倍时间,也许你只是多等两倍时间,然后晚点回来。但如果你在大规模做,那可能是数亿美元。你肯定不想那样。即便 5% 的改进也可能很重要。所以事实上,效率真的非常关键。我希望把这一点烘焙进你们的心态,作为这门课的一个结果。
经验上,如果你看 OpenAI 2020 年的那篇论文,它显示 2012 到 2019 年间,ImageNet 上有 44 倍的算法效率提升。硬件肯定也变好了很多。但这也伴随着算法改进。当然,当你把它们乘在一起,你就会看到效率和准确率的巨大跃升。所以,说了这么多,框架是:在一定的数据和计算预算下,你能构建的最好的模型是什么?对于预训练,我们主要会讨论计算预算,因为我们会假设我们的数据比计算多很多。但如果你处在数据受限的环境,或者你藏了很多 B200,那你可能就是数据受限的。换句话说,最大化效率。我们会在这门课里反复看到这个主题。
语言模型简史与开放生态
好。接下来,我想花一点时间谈谈语言模型,讲一点历史,做一点背景铺垫,然后再进入更技术性的细节。语言模型已经存在一段时间了。Shannon 在 50 年代就用语言模型来测量英语的熵。很长一段时间里,n-gram 模型实际上被用在机器翻译和语音识别系统里。它们不是整个系统,但它们是确保生成流畅文本的重要部分。我会说,现代语言模型的谱系来自神经架构。所以有一堆想法,我认为对这个发展很重要。
在 90 年代,有 LSTM。Joshua Bengio 其实在 2003 年写了第一篇神经语言模型论文。那其实不是 LSTM。那只是一个前馈网络,看一小段上下文。然后是 seq2seq 建模,它大胆地说我们可以把整个句子压缩成一个向量。然后是 Atom 优化器、attention 机制,它最初是为机器翻译开发的。Transformer 架构建立在它之上,也最初是为机器翻译开发的。然后扩展到 mixture of experts、模型并行。你会看到很多不同的架构,以及系统和优化器想法。这些是在 2010 年代发展起来的。
到了 2010 年代后期,我觉得事情开始变得非常有趣。出现了 ELMo 和 BERT。这些是在大量文本上训练的语言模型。然后你可以在一些下游任务上微调它们,比如问答。它会显示出巨大改进。所以那时的模式是,拿这些模型之一,然后微调。然后 Google 有一篇论文,我认为它预示了这种 prompt 和 response 的视角。真正打开闸门的,我认为是 OpenAI,他们拥抱了 scaling。所以他们在 2018 年左右有一篇 GPT 论文。他们扩展到 GPT-2。然后他们真正弄清楚,或者说拥抱了缩放定律,我们等一下会讲,这让他们能够训练 GPT-3。GPT-3 是一个大得多的模型,几乎是当时模型的 10 倍以上。它可以展示涌现行为,比如上下文学习。到那时,Google 说,好吧,我们也得做点什么。所以他们训练了一个巨大的模型。结果它训练不足。而结果发现,他们的 DeepMind,当时还没有和 Google 整合,已经弄清楚了最优的、计算最优的缩放定律。所以这一切都在发生。
GPT-3 出来之后,对很多人来说,这像是一个警钟。那时有很多早期尝试,想复现它。有一个草根组织叫 Eleuther,创建了一些开放数据集和模型。它们不是很大,因为他们没有很多计算。Meta 的第一个 LLM,你能看出来是复现,因为它大约 1750 亿参数。但它不是一个很好的模型。他们遇到了很多硬件问题。然后还有一个 Hugging Face 的大科学项目。所以这些模型,我会说,不是很强。
然后在过去三年里,开放模型生态变化很大。Meta 以 LLM 系列模型领路,LLM、LLM2、LLM3。Mistral 也加入进来。然后还有一整套中国模型。我想我漏了一些,比如 ByteDance 有东西。Tencent 可能也有别的东西。所以很难跟踪所有东西。但大家都听说过 DeepSeek 和 Qwen。我想我列了主要的。但我觉得有趣和兴奋的是,现在我们有开放权重模型正在接近闭源模型。取决于你问谁以及怎么 benchmark,它们可能稍微落后,或者可比。但肯定有一些非常非常可信的模型正在产业中被广泛使用。
现在还有另一条工作线,不只是发布开放权重模型。AI2、NVIDIA,以及我参与的 Marin 项目。我们尝试不只提供权重,还提供论文、代码和数据,这样我们能更彻底地理解这些模型是如何构建的。为什么我这么强调开放生态?主要是因为,我认为没有这些模型,这门课不可能存在。仍然有很多论文在发表,关于这些大的 MoE 和 RL 系统如何工作。这让我们至少能窥见前沿模型是如何构建的,并尝试三角定位这些碎片。当然,即便有 Qwen 这些论文,很多细节仍然缺失,你没法复现它们。尤其是数据混合,我们不知道。但我认为,这比什么都没有要好得多,好得多。
好,所以在过去十年里,我认为语言模型是什么,这个观念已经变了。过去它是你微调的东西。然后它是你提示的东西。现在在 ChatGPT 时代,它是你与之交谈、可以对话的东西。现在,好吧,我猜我没网络。没关系。现在我们处于智能体时代。如果你点这个链接,它基本上展示了一个巨大的智能体轨迹。我仍然觉得,有些模型有多强,这让人难以置信。你给它一页文本,它就能做一些非常复杂的智能体编码任务。所以,我们今天对语言模型的要求,可能超出了十年前任何人的想象。尽管如此,我认为基本面并没有变那么多。大体上我们仍然在 GPU 和 kernel 上构建。我们仍然用梯度或随机梯度之类的方法优化。我们仍然有 Transformer、attention,我们之后还会多谈一点架构。但它并没有变那么多。我认为规格不同了。现在我们需要更长的上下文长度,这意味着推理效率更加重要。所以好消息是,我们不必完全改变这门课。只需要改 Tatsu 关于最新中国架构的那一节。但基本面,我认为至少目前还会继续存在。
好。也许我先停在这里,看看有没有问题或想法。
可执行讲义与课程信息
好,我们继续。简短插曲。这个程序是什么?这是我所说的可执行讲义。这看起来像 Python 程序,因为它实际上就是一个 Python 程序。但它被渲染出来供你观看。当我逐步走它时,它实际上在执行讲义。它让我们能够逐步走代码,希望之后这会有趣且重要。你还能看到讲义的分层结构。比如,我们完成了这个函数。我们回到 main。好,那我们谈谈课程。
Logistics 和 syllabus,我想这会花掉相当一部分时间。好。所有信息都在网上,网站是 cs336.sanford.edu。这是一门五单元的课。我想这门课可能有点名声,所以我不需要太啰嗦。但这是,我们有五次作业。它们相当紧张。即便是第一次作业,根据某条评论,实际上相当于 CS224N 的五次作业。我也被告知,等等,这太夸张了。但我想,预估保守一点更好。那么,你为什么要修这门课?好。
首先,你像我一样有一种强迫症式的需求,想理解事物如何工作。这应该是你的首要目标。我认为就是对语言模型如何工作的纯粹好奇。然后在做这门课的过程中,你会培养出强得多的研究、工程肌肉,并有信心进入一个新环境,有能力应对出现的任何情况。我想,当我开始在斯坦福时,我创建了一门课叫统计学习理论,基本上教机器学习的理论面。那很好,因为它装备人们,让你读论文时能理解所有数学。现在,这个领域已经更多转向系统和经验的一面,所以这门课在某种意义上是类似的课,给人足够的深度,让他们觉得其他一切似乎都变得容易。
那么,你为什么不该修这门课?这很重要,因为有些理由你不应该修。第一,你实际上想在这个 quarter 做出一些研究。你可能应该和你的导师谈谈。他们应该知道你在修这门课。否则可能会有惊喜。第二,你感兴趣的是学习 AI 里最热门的新技术。我认为有很多其他很好的课程、seminar 课程、专题课程适合这个。我们不做很多这些东西。我们不做多模态。我们不深入讲智能体。所以如果你想学那些东西,这门课不对。第三,如果你进来说,我有一个应用领域,我想在上面得到好结果。那这门课可能不对,至少作为起点不对。我总是建议,先提示模型,再微调模型,然后作为最后手段,才预训练你自己的模型,因为那很痛苦,也很昂贵。但它也很有趣。
好,如果你没有修这门课,你可以在家里跟学。所有讲义材料都会发在网站上。哎呀。它们也会通过 CGOE 录制。谢谢 CGOE 这么做。之后它们会被发布到 YouTube。当然,在家跟学和看讲座很好,但你真的要通过做作业来学习。所以你得想办法激励自己去完成作业。
好,说到作业,我们有五次作业。作业的哲学是,我们怎么做从零开始,但不只是说,构建一个语言模型,那就是作业。我们不提供脚手架代码,但我们提供一堆单元测试,确保你构建的东西实际上是正确的。这样你就不会陷入稀疏奖励环境,交一份作业,然后它要么对要么错。我建议,作业的结构使得很多部分实际上可以在你的笔记本上本地完成。你可以实现并检查正确性。然后我们提供一个集群,这样你可以做实际训练运行,看准确率,或者拿一堆 GPU 来 benchmark 某个 kernel 的性能。然后为了好玩,我们至少会给大部分作业设置一些 leaderboard。它们会像这样:既然你已经学了这个主题,试着在某种预算下最小化 perplexity。Marcel 和 Herman 当年都是 leaderboard 大师,那是在去年还是前年。所以如果你想得到技巧,我相信他们很乐意。其实我不知道他们会不会告诉你他们的秘密,但你可以试试。
好,去年我们在想,AI 能做什么?就是说,好吧,对,每个人都可以用 AI,但尽量做到最好。我想现在,编码智能体已经变得太好,它们可以直接解决所有作业,对吧?但显而易见的是,如果你只是把 assignment one 的 PDF 喂给 Claude Code,你显然什么也学不到。同时,AI 在回答问题和辅导方面非常有用。所以我们必须找到一种利用 AI 的方式。我们决定做的是,提供一个 agents.md 文件,或者等价地,一个 prompt,要求 AI 具有教学思维。你可以在我们的 AI 政策指南里读到更多。要求是,如果你要用 AI,就用这个 prompt。这样它会回答关于代码的问题。它会澄清理解,但它不会在作业是让你实现 Transformer 时,不小心替你生成 Transformer。好。这是第一年我们尝试这样做,所以请试试,并给我们反馈它是否有效。
好。然后是计算。今年多亏 Modal,他们为我们提供了平台上的计算额度。这其实相当不错,你可以得到一些,不像去年,嗯,我猜你去年没修这门课。去年我们有一个要 SSH 进去的集群。这次更多使用 API,我一开始有点怀疑,但看下来,实际上它用起来挺愉快。所以同样,试试并反馈体验如何。我们写了一份指南,讲如何访问和使用计算。
好。关于课程 logistics 有什么问题吗?好,行。
课程五个部分总览
那我们谈谈这门课要覆盖什么。基本上有五个部分,对应你们要做的五次作业。基础系统、缩放定律、数据、对齐。我现在会逐个部分讲,让你们尝尝会学到什么。好。
在基础部分,基本上是前两周。目标是能够训练一个语言模型,并从零构建它。这里的组件是,我们会 tokenize 数据,定义架构,然后实现优化器并训练它。好。那你会想,剩下的课是干嘛的?嗯,我们会讲到的。那我们从 tokenization 开始。
Tokenization 真正关心的是模型操作的原子是什么。形式上,一个 tokenizer 在原始输入,也就是字节,和一系列整数之间转换,这些整数代表 token。概念上,它是对文本的分割。我们会讲 byte-pair encoding,BPE tokenizer,直觉上它把输入拆成频繁出现的块。然后,记住,这门课是关于最大化效率。所以从效率角度看,tokenization 是好的,因为它把长序列,如果你只考虑原始字节流,减少成更少的 token。但更微妙、也许更重要的是,它允许你做自适应计算。所以有些地方可能实际上很多字节,但应该被压缩成一个 token,而一些更稀有或更有趣的输入部分应该被留作多个 token。好,我们会更多地谈这个。我只是想提一下,每年我都希望我不必教 tokenization。因为梦想是真正有一种端到端的方式,直接在字节上操作。已经有很多工作,包括最近有一个 H-Net 工作,看起来很有希望。但到目前为止,这些还没有扩展到前沿,而前沿模型仍然在使用 tokenizer,所以我们觉得教 tokenizer 仍然是明智的。
好,现在,在你 tokenize 输入之后,你有一堆 token。现在你在上面定义一个模型。我想每个人都熟悉最初的 Transformer。如果你上过 CS224N,NLP 课,那你就见过 Transformer。自那以来,我认为 Transformer 有很多改进或细化,我认为这些很重要。Tatsu 之后会多讲。但先过一遍你可能需要考虑的一类事情。激活函数已经演化。位置编码如何做已经演化。如何归一化不同层以防止爆炸已经演化。不是做完整 attention,有很多方法基本上减少 attention 计算,因为 attention 是 N 平方,N 是序列长度,那会变得非常昂贵。所以有一堆围绕这个的想法。如果你更有雄心,可以看状态空间模型,或者等价地线性 attention,比如 Mamba 和 gated delta net。这些在过去几年很流行。通常这些模型和 attention 的某种混合似乎效果很好。所以我们会探索一些。然后在 Transformer 的 MLP 层里,最初的 Transformer 只是一个密集 MLP。现在,mixture of experts 已经成为构建计算高效 Transformer 的主导范式。所以我们会谈这个。当然,有了 MLP、mixture of experts,不只是定义架构,我们还会看到需要不同的训练技术。最后,也许有点无聊,但一个重要问题是,你的 transformer 形状是什么?多少层?多少头?隐藏维度是多少?专家数是多少?这可能会在讲缩放定律时更多出现,但设置这些实际上,看起来几乎微不足道。它是一个超参数,但在语言模型缩放的语境下,有巨大、巨大的影响。
好。一旦你定义了模型架构,你如何训练模型?这里有一堆关于损失函数的设计决策。有下一个词、下一个 token 预测,这是默认的。但人们发现,预测多于一个 token 似乎有助于改进模型。还有优化器。人们过去用 Adam,但越来越多地使用 Muon,尤其是最近一些开放模型,比如 Kimi K2 模型。初始化,这同样听起来有点无聊,但事实证明对更大模型的训练稳定性和能力有巨大影响。学习率、schedule、正则化、batch size,然后还有 MoE 特定的东西。所以,你看这个列表,可能会想,这些只是超参数。我会试一堆不同选项。但事实证明,真正非常小心地、以有原则的方式设置这些超参数,会决定一个运行是爆炸、毫无用处,还是达到 state of the art。好,等我们讲缩放定律时我会回到这一点。
好,那么在 assignment one 里,你们要做的是实现 BPE tokenizer,实现 transformer、损失函数、优化器,整个训练流程。我们会让你们做一堆资源核算,让你理解你的 FLOPs 去了哪里。你会在 tiny stories 和 open web text 这些数据集上训练一些模型。然后会有一个 leaderboard,你要尽可能快地降低 perplexity。所以,如果你熟悉 nano GPT speedruns,这有点类似。好,那么到 assignment one 结束时,你应该能够离开并从头构建一个语言模型。那非常令人兴奋。如果你要一个高层 takeaway,那就是,虽然 tokenizer、建模和训练被呈现为不同的部分,但实际上一切都是关于平衡下面这些。你想要表达力强的模型,因为你想表示数据的复杂性。但同时,你训练时要稳定。我们会大量讨论如何让参数和梯度范数保持在这个金发姑娘区间,既不爆炸也不消失。事实证明,训练语言模型很多就是关于稳定性。最后是效率,这稍微更直接。你只要让它在硬件上跑得快。但你会看到有趣的事情,比如,如果我们改变架构,很多架构决策是,好吧,我们可以通过,比如说,减少投影到更低维空间来让它更快。但问题是,它效果一样好吗?所以做这些权衡,才是这里的游戏名字。
系统:资源核算、kernel、并行与推理
好。那么在 assignment two 里,我们会更深入地讲系统。这里的目标就是从你的硬件里榨取最大价值。我们会讲 kernel,如何在多个 GPU 上并行,以及如何做推理。所以,基础,我们实际上下一讲会开始,我提到资源核算。我想,你们可能以前都构建过模型。但这真正是关于跟踪所有 FLOPs 去了哪里。以及所有内存在哪里花掉。所以我们会花一些时间基本上做资源核算。我们会看到这个出现的公式。训练一个模型在一万亿 token 上需要多少 FLOPs?嗯,大概是 6 乘以 N 乘以 D。它从哪来?然后我们会看硬件。
这里有一张非常卡通化的图,关于硬件要 remark 什么。你的内存不在你的计算所在的地方。你必须把参数或激活从内存移到计算,做计算,再移回去。对吧?而这通常是瓶颈。所以,比如说,B200,我们会有机会玩一玩。它有每秒 2.25 petaflops,BF16,以及每秒 8 TB 的内存。所以,这意味着什么?我想,我下一讲会做这个。我们会拆解这个,用这些信息做计算,看看不同类型算法需要多长时间。我们会讲 roofline analysis,它让我们理解一个计算是受计算瓶颈还是内存瓶颈限制。一般来说,是内存。然后,讲一点 benchmarking 和 profiling。
好。这就是 DGX H200 的样子。你有 8 个 GPU,它们通过 NVLink 连接。然后,如果你有很多,如果你有 1000 个 GPU,那你会有多个这样的节点,它们通过 InfiniBand 或以太网连接。所以,接下来两个系统部分,嗯,kernel。所以,kernel 基本上是在 GPU 上运行的函数。当你只用普通 PyTorch 时,所有 PyTorch 原语实际上对应启动特定的、内置的 kernel。所以,不管你知道不知道,你已经在使用 kernel 了。但重点是,对于某些类型的计算,如果你看它,你实际上可以写自定义 kernel,让 GPU 跑得更快。主要原则是组织计算以最小化数据移动。所以,记住这张图,从内存移动数据是昂贵的,所以你要尽量减少。举一个简单例子,假设你想计算 A 和 B。通常你必须从高带宽内存 HBM 读,计算它,写回去,然后再读,计算它,再写回去。对吧?所以你基本上把数据来回送了两次。有一个想法叫 fusion,你读一次,做两个计算,然后写回去。那会省很多时间。所以,那就是 operator fusion。Tiling 是围绕同一想法的一个更复杂的变体。GPU 也变得更复杂了。我不确定我们有多少时间深入这些细节,但至少我们想让你接触一些 GPU 的特性,我会说,让你欣赏为了尽可能榨干它们需要考虑的事情类型。我们会写一些 kernel 并试试。
那么,如果你有数千个 GPU 呢?最小化数据移动的原则仍然一样。唯一的问题是,在不同 GPU 之间移动数据甚至更昂贵。我们会讲这些非常经典的集合操作,比如 gather、reduce 和 all reduce,是思考分布式训练的方式。一般来说,我们有这些模型参数,有激活、梯度、优化器状态,它们需要被 shard 或拆分到多个 GPU 上。当然,你需要把正确的数据带到正确的节点来做计算,再写回去。所以,有一整套编排,以及如何高效地做,会是这个单元的主题。有多种 shard 方式。你可以按拆分数据来 shard,拆分模型,拆分模型中的不同层,拆分序列,在专家之间拆分,我们会讲每一种的权衡。
好,最后,我们会讲推理,正如我提到的,它越来越重要。推理的目标是实际使用模型。这里一个小细节。推理当然是你在和模型聊天时需要的,但它对强化学习也有用。它对做 rollout、测试时计算、生成合成数据、评估都有用。所以,推理是语言建模工作非常关键的一部分。我们可能不会花我想花的那么多时间在这上面,因为课程已经排得很满了,但我们看看能做什么。之前有一些讨论,关于是否要让你们从零写推理,但我们看看。思考推理的方式是,有两个阶段,pre-fill 和 decode。在 pre-fill 中,你拿 prompt,然后把所有 token 前向传播并构建 key-value 对。这非常像训练中发生的事。然后在 decoding 部分,token 一次生成一个。这部分很快变成内存瓶颈,这就是推理难的原因。所以,有很多事情可以加速推理。你可以尝试通过剪枝一个大模型来使用更便宜的模型。你可以量化,可以蒸馏,可以用一种叫 speculative decoding 的技术,用一个更便宜的模型跑在前面,猜一堆 token。然后你用完整模型,它可以并行地处理那些 token,看它是否好。如果你运气好,就可以接受所有这些 token,这比一次一个 token 快得多。当然,你可以做系统优化。有一堆专门为推理设计的 kernel。然后,推理中一个有趣的事情是,如果你在运行一个服务,查询可能在不同的时间到达。然后你必须弄清楚如何把它们 batch 起来。而在训练中,你基本上已经定义好了 batch,一切都更可预测。
所以在 assignment two 里,会有用 Triton 实现 kernel,以及做某种并行训练。这里细节可能会变,因为 CA 们有宏大的计划要重做系统部分。所以作业可能看起来和去年有点不同,但会覆盖大致相同的材料。我要提一件事,有一本来自 Google 一些人的很棒的书叫 How to Scale Your Model。我觉得它很好地提供了 roofline analysis、transformer 数学以及概念上做 LMS 的理解。所以我强烈推荐你看看。唯一的问题是它来自 Google,所以是关于 TPU 的。但很多高层概念是相似的。现在他们有一章新内容,讲如何思考 GPU。
缩放定律与 assignment three
好。第三个作业是关于缩放定律。到这时,你已经训练了一个语言模型。你可以通过优化 kernel 和并行让它跑得很快。现在你想扩大规模。那你如何扩大规模?想象下面这个设定。如果你有 1e25 FLOPs,这是数千万美元的计算,你会训练什么模型?好。我认为这是一个令人生畏的任务,因为如果你搞砸了,那是很多钱打水漂。而且你没法在那种规模做你通常的超参数调优,因为你只训练一个模型。所以,这是你在语言模型、大语言模型训练中必须处理的关键问题,而如果你只是微调模型或做小规模事情,你就不必真的处理。所以关键的概念转变是,我们不应该想成我们在训练单个模型,而应该真正想成一个缩放配方。一个缩放配方是从 FLOPs 预算,比如 1e25 或 1e24,到一组超参数的映射,基本上是一个配置文件。对于一个给定的缩放配方,我们要做的是跑一堆实验,计算你在更小规模下得到的损失。然后你拟合一个缩放定律,这让你能预测目标规模下的损失。所以,也许你跑一些小实验,拟合缩放定律,然后预测更大规模下你会得到什么。
好,这就是基本原语。现在,利用这个,你可以做的是,现在你可以用更小规模的实验来优化目标,也就是面向更大规模的缩放配方,这很棒。第二,你可以在实际跑实验之前,预测你在理论上会达到的损失。这让你可以去,你知道,筹钱,你说,看,我跑了小规模实验,我认为我能得到一个真正的、像 GPT-5 级别的模型。请给我很多钱,让我训练那个模型。
所以,我认为还有一个可能的误解是,缩放定律不是自然定律。它们不会自动发生。你得把它们愿力般地召唤出来。这通过仔细构建一个缩放配方来实现。记住,一个缩放配方必须能外推。所以,这通常意味着你有一个超参数序列,随着规模增加,也许学习率是常数,也许它下降,也许 batch size 增加,但增加多少?这些是缩放配方必须弄清楚的事情。所以,为了得到这些可预测的缩放定律,你实际上必须考虑的一件事是,你如何参数化模型,以获得所谓的超参数迁移。意思是,你在小规模使用的超参数,要么就是你在更大规模使用的那些,要么是它的可预测函数。对吧?因为如果在每个规模下,你的学习率有时是 1e-5,有时是 1e-4,那你不可能在更大规模下神奇地猜对正确的学习率。所以,一个思维转变是,可预测性至少和最优性一样重要。你通常想,哦,我们在这里试图优化效率,我们想做超参数调优,让事情最优。是的,你确实想那样做,但你也想要这种可预测性,这样你不会在更大规模上被吓到。
好,这是舞台铺垫。我们实际要看的缩放定律相当经典。这些是,你们有些人可能见过这个想法:如果我给你一个 FLOPs 预算,你应该训练,你应该如何平衡训练一个更大的模型和训练更多 token?这就是经典的计算最优缩放定律,来自 Kaplan 等人,以及所谓的 Chinchilla 缩放定律。基本想法是,对于每个 FLOPs 预算,比如从 6E18 一直到 3E21,你在不同的模型大小上做 sweep。你选最好的那个,所以想想最小化每一个。然后你拟合一条曲线,让你基本上能根据 FLOPs 预算预测参数数量。如果你幸运,它会大致落在一条线上。如果你不幸运,它会到处都是,这意味着你不应该相信你能可靠预测。所以,这个的要点是,这很粗糙,但一个经验法则是,参数数量的 20 倍是你应该训练的数据点数量。所以,这个 1B 参数模型应该训练大约 1.4 万亿 token。当然,取决于数据集和架构,这个数字会变化。
另外,这没有考虑推理成本。现在很多模型很小,但它们训练的 token 远超计算最优,因为你想要更小的模型来做推理。好,我们在 Marin 项目里一直在做的一件有趣的事是预先注册我们的结果。我们在不同计算预算下拟合一堆缩放图。我们拟合一个缩放定律,然后基本上做出预测,一直到 1E22 FLOPs。这个实际上正在训练。如果你去 Marin 网站,你可以跟进。它应该最早今晚就能完成。所以也许周三我会汇报我们做得怎么样,看看我们和预先注册的损失匹配得如何。这里的想法是,我们做了一个预测:如果我们训练这个大型模型,一个我们从未训练过的模型,如果我们能预测它会有多好,那就很好。
好,那么在 assignment three 里,我认为这是一个要么有趣,要么,其实是有趣的作业。我们就说它是个有趣的作业。我们会定义这个训练 API,基本上你给我超参数,我们给你返回损失。我们要尝试模拟如果你能做很多训练运行会发生什么。当然我们没有足够计算让每个人训练自己的 8B 模型之类的。所以基本上我们离线训练一堆模型,然后提供这个缓存,让它看起来像你在训练。你要做的是提交训练任务。你给我们一个配置,我们给你返回损失。然后你可以做任何你想做的。我们会建议你拟合这些点的缩放定律,然后外推,然后我们给你一个预算,我们基本上评估你的模型落地得怎么样。所以它的意思是,我本来要说,它意在复制高压力场景,如果你真的有一个预算,比如 1 亿美元要花。你必须非常小心你怎么花。当然这是低风险的。
数据:评估、获取、处理与 assignment four
好。到这时,你会训练模型,你知道怎么让它快,你知道怎么扩大规模。现在缺什么?你训练模型用什么数据?这就是数据部分的主题,它可以说是最重要的东西之一,因为数据质量基本上决定了你的模型会有多好。另一种说法是,你想让你的模型做什么?对吧?数据基本上反映你的模型想要什么。所以,你想说多种语言吗?擅长对话吗?想运行长时间智能体编码任务吗?所以其中一部分也是,我们会先讲评估,评估基本上定义了你希望模型拥有的能力。
你知道,我们会讲的一件事是,评估是一个相当深的话题。不只是,不只是跑一些 benchmark。对于模型开发,有内部评估指标。这里重要的是,跨规模的平滑性,这样,记住我们希望事情可预测。相对性能很重要。你不一定关心这个在绝对意义上有多好,因为,比如说,一个 perplexity 数字,在某个 held-out 数据上 perplexity 1.2 到底意味着什么?然后还有外部指标。这些是你报告给客户、审稿人,或者任何你展示的人的东西。这里,生态效度真的很重要。我认为有时这两类东西会被混为一谈,但我认为它们服务两个不同的目的。你可以把 perplexity 看作对内部开发非常有用的东西。直到今天,perplexity 仍然是捕捉模型内在质量的一个非常好的方式,而不必担心 benchmark-maxing。现在,有一个单独的问题是你用什么来跑 eval。推荐的是,如果你有一些不在互联网上的数据,那会很好,因为你可以避免污染。
好,然后还有高级用例,它们更代表面向外部的用例。还要注意一件事,语言模型据称非常通用。对吧?所以,合适的做法是,我们确实需要非常多样化的评估集合。我总是建议有很多评估,你可以把它们平均成一个数字,但那个平均往往混淆了很多不同的东西。
好,现在在我们设置好 evals 之后,我们知道我们在构建什么。你如何获取数据?嗯,第一件事是数据不会从天上掉下来。它必须被主动地策展。通常,我认为,尤其是在课堂上,也在研究中,有时你只是被给了一个数据集,然后就是,好吧,现在我在这个数据集上做东西。但是很多语言模型,尤其如果你想收集这些大型数据集,你必须主动去看它。所以,网页是从互联网爬取的。有书,我猜这在现在有争议,但还有档案论文、GitHub 代码等等。这是 2021 年 Pile 的一个旧图。你可以看到,语言建模数据集相当多样。尤其是现在,我认为围绕用受版权保护的数据训练是否属于合理使用,有很多争议。也许有时你必须许可数据等等。所以,围绕数据有法律问题,我认为这相当重要。例如,很多 GitHub 代码没有许可证。那你怎么解释?你认为,假设它是宽松许可,还是保守地假设它不是宽松许可?
还有一点,数据甚至不是文本,对吧?它要么是 HTML,要么是 PDF,要么是代码,要么是目录。这需要处理,把它变成实际可用的文本用于训练。所以,这就是数据处理的话题。这里必须发生几个步骤。转换,把一些非文本转换成文本。过滤,只保留好的东西。如果 Common Crawl 里的随机互联网文档极其糟糕,你大概不想训练它。你想去重。有多个来源。所以,你如何组合不同来源?最后,最近有很多关于生成合成数据的工作,这可能意味着拿真实数据,把它重写成更像下游任务的东西,或者更像维基百科的东西,或者你想要的任何东西。所以,这是一个活跃的研究领域。另外,数据既可以用于预训练,也可以用于所谓的中期训练,通常是你在预训练步骤末尾放入的高质量数据。这包括长上下文数据,比如大型代码仓库或书籍。最后,还有后训练数据,也许是对话或带工具调用的智能体轨迹。
所以在 assignment four 里,我们会让你从非常原始的语料开始,比如原始网络爬取,做所有过滤、去重、让数据变干净的工作。所以,我会说,我不知道我是否会说它不好玩,但它肯定,我认为,是很多人们会称为脏活的工作。但我认为,这是从零构建语言模型的重要部分。所以,你必须获得完整体验。
对齐与 assignment five
好。最后,对齐。到目前为止,我们基本上用完全监督训练了一个模型。预测下一个 token 或接下来几个 token。现在,模型应该已经不错了。但我们可以通过使用弱监督进一步改进它。为什么使用弱监督?因为有时候批评比生成更容易。所以,你不能总是有数据说,这是对这个 prompt 的正确回答,但也许你可以有某种方式指定什么是好的。所以,基本模板是,你从模型生成回答。你用人类、验证器或 LLM judge 给它们打分,然后你更新模型,让它偏好更好的回答。这可以通过各种 RL 算法实现,比如 PPO 或 GRPO,或者更简单地,如果是偏好数据,用 DPO。
所以,围绕 RL 的挑战是,RL 算法不稳定、难调。你们有些人可能从亲身经验知道。就我个人而言,我更喜欢尽可能长时间地把事情保持在完全监督的情况里,然后最后,好吧,行,我必须做 RL。但你知道,有些人,不管什么原因,喜欢做 RL。另外,我们希望今年能讲的是,如果你大规模做 RL 并尝试最大化吞吐量,实际上有很多系统挑战。你必须有一个推理服务器和一个训练服务器,然后推理服务器必须生成这些 rollout,尤其是如果你对涉及代码执行的环境做 RL。这是一整套编排游戏。然后,如果你的 worker 落后了,你就会遇到 off-policy 问题,然后你不断在 on-policy 性和最大化吞吐量的愿望之间权衡。所以,说白了,这是一个巨大而美妙的混乱,希望我们讲到那时能多谈。
所以,assignment five。我们还在决定具体要做什么。去年是实现 DPO 和 GRPO,并让它在某个数学 benchmark 上工作。但我们会看看今年能在现实性维度上推多远。
好。再次记住,这是关于效率。效率可以是数据效率或计算效率。所以,思考方式是,你有所有这些资源。你有数据,有硬件,硬件有计算核心、内存、通信带宽。你只是在试图弄清楚,在固定资源集合下,如何根据某种评估构建最好的模型。所以,通过这个视角,我认为你实际上可以把很多这些设计决策看作是在优化这个。系统,很明显,是关于计算效率。Tokenization,正如我之前提到的,你可以直接用原始字节,但那会非常计算低效。至少用今天的模型架构是这样。所以,很多 tokenization 是在改进计算效率。模型架构,我们会看到的很多改变,动机都是减少内存或 FLOPs。事实上,很多改变受到更快推理需求的影响。数据过滤,你也可以从效率角度看:我们不想浪费时间在一堆冗余的坏数据上更新梯度。即使它可能不会伤害你,但它会伤害你,因为如果你有固定计算预算,更多时间花在坏数据上,就意味着更少时间花在好数据上。最后,缩放定律明确是关于如何在小得多的模型上做有效的超参数调优。现在,明天我们可能会变成数据受限,设计决策的算法可能会变。但我认为我们试图教你的整体心态是,思考你方法的效率。好,我停在这里。
关于这些作业或主题有什么问题吗?
Tokenization:从字符串到 token
好。那我们现在讲 tokenization。这是我们跳进的第一个单元。Andre Karpathy 有一个非常好的关于 tokenization 的视频。你应该看看。起点是,原始文本,文本是什么?它是 Unicode 字符串。另一方面,语言模型在 token 序列上放置分布,通常表示为索引。所以我们需要一个过程,把这些字符串编码成 token,也需要一个过程,把 token 解码回字符串。所以 tokenizer 基本上就是能做这个往返的东西。这里有一些例子,让你感受 tokenizer 如何工作。其实我应该早点试试联网,所以这个用不了。好。如果你去那个网站,你可以玩不同的 tokenizer。
这里有一些观察。你会体会到为什么 tokenizer 有点烦,为什么人们想摆脱它们。一个词和它与前面空格结合的版本是不同的 token。所以你会看到很多 token 实际上是空格加一个词,这没问题但有点奇怪。所以这个 hello 和这个 hello 实际上是两个完全不同的索引,彼此毫无关系。而且,有时取决于你用的 tokenizer,数字会被表示为,每几位数字一个 token。有时它是可预测的,有时不是。有些 tokenizer 试图让每个数字一个 token,但那样你会让 token 数量爆炸。所以那里有权衡。这是 GPT-5 tokenizer。你可以把这个字符串转换成这些索引。然后你可以解码回字符串,假设 tokenizer 应该往返。如果你实现 tokenizer,它不能往返,那就有问题。这里的压缩比是每个 token 的字节数。在这个例子里,这个字符串的字节数是 20。token 数是 8。20 除以 8,压缩比是 2.5。所以每个 token 2.5 字节。压缩比越大,意味着句子越短,这是好事,因为 attention 是二次的,你想确保句子更短。现在你显然可以通过增加 vocab size 来提高压缩比。但那样你会遇到稀疏性,因为 vocab 的每个元素都被当作不同元素。所以现在,tokenizer,尤其是多语言 tokenizer,有 10 万或 20 万个不同的 token。你可以查看 GPT token 词表。我想为了时间我们跳过。好。
那么,你如何构建一个 tokenizer?我会快速过一遍。但你首先可能做的是,好吧,Unicode 字符串,它本身就已经是一个 Unicode 字符序列。每个字符是一个整数,你可以在 Python 里叫 ord,然后得到某个数字。这可以转换回字符。那我们就构建一个字符级 tokenizer,基本上把每个字符拆开,编码成一个 token。然后这可以解码回来。生活很美好。现在,有 15 万个 Unicode 字符。所以你的 vocab size 可以是 15 万,这,你知道,很多。我是说,也不算疯狂。但我认为更大的问题是,很多字符实际上很罕见,这意味着它真的是对词汇表的低效使用。而且压缩比,也反映了这一点,并不好。所以大多数时候,你实际上用了很多 token 来表示你的序列。而且很多索引实际上没怎么被用到。所以这不是一个很好的 tokenizer。
这里是另一个尝试。你可以把字符串变成字节。Unicode 有 UTF-8 Unicode 编码,这意味着有时像 A 这样的字符串只是一个字节。有时一个字符串是多个字节。那我们就围绕这个构建 tokenizer。我们可以把这个字符串转换成字节序列。注意,现在这是一个更长的序列。但所有数字都在 0 到 255 之间,因为这就是字节的含义。压缩比是 1,这不好。对吧?所以字节序列可能非常长。但 vocab size 很小。
好,现在,好,这两个都很糟糕。那我们试着取得一些进展。这是 NLP 里人们过去实际会做的,如果有人记得。如果你拿一个字符串,我可以直接把它分块,按空格或某个正则表达式拆开。然后我们把每个块叫做一个 token。好,这个的好处是每个 token 都有意义,因为人类发明了词,而词往往有稳定的语义含义。但你的 vocab size 是训练数据中不同块的数量,这可能很多。而且你的压缩比,我是说,相当好,但词汇表可能巨大。实际上,更糟的是,词汇表可能是无界的。对吧?因为在测试时你可能得到某个序列,你 tokenize,然后你有一个从未见过的 token。人们过去会给这些分配一个 unk token,但那真的很丑,而且会搞乱你的 perplexity 计算。所以这也不好。
好,那我们实际要做的叫 byte-pair encoding。这是很久以前为了数据压缩引入的,远在语言模型真正登场之前。它第一次被引入 NLP 是做神经机器翻译,第一篇用 BPE 做 LM 的论文是 GPT2。基本想法是,你会在原始文本上训练 tokenizer,构建一个为数据量身定制的词汇表。你还会有一个性质:一切都可以被 tokenize,如果它罕见,就拆成更小的单元,而不是有一个 unk token。常见序列会被表示为一个 token。罕见序列会被拆成多个 token。这就是想法。
好,算法概念上相当简单。你基本上从你的语料开始。假设它是一长串序列。你得到一个字节序列。每个字节一开始是一个 token。然后我们会合并相邻 token 中连续出现最频繁的对。好,让我们走一遍这在代码里如何工作。这里有一个简单字符串,the cat and the hat。这里是 BPE 算法的实现。我们要把它变成一个字节序列。然后我们首先基本上数连续 token 出现的次数。所以 116、104 出现了两次。我们得到这个。然后我们找到出现次数最多的对。那就是 116、104。我想有几个并列,但我们就取第一个。然后我们合并那个对。通过合并那个对,我们做的是创建一个新 token。在这个例子里,它叫 token 256。它代表这个对。我们把它加入词汇表。所以 256 将代表序列 th。所以 t 和 h 被合并了。每次我们看到 th,就用 256 来表示。然后我们遍历索引,把每一处 116、104 替换成 256。所以那两个地方被替换了。然后我们迭代。下一次我们做这个时,我们会找到 256 和 101。我们合并那个。现在我们有 257。然后我们再合并一次,得到 258。所以随着时间,序列在缩短,词汇表在增长。
好。让我,这里的压缩比,我们在这个玩具例子里得到的是 1.5。好,现在你有了 tokenizer,你如何 tokenize 新文本?嗯,你拿一个新字符串,编码它。概念上发生的是,你基本上遍历你做的合并集合。然后你把合并应用到你的字符串上。好。我其实不逐步走那段代码了。那会给你一个序列。这是 Quick Brown Fox 的序列编码。然后你解码它,得到同样的东西回来。我讲得有点快,为了时间。我要说这个实现是能工作的。这是一个完整的 Python 实现。它非常慢。所以在 assignment one 里,我们会要求你们基本上让它更快。当前 encode 会遍历所有合并,这非常慢,因为你的合并数量基本上就是 vocab size 减去 256。所以你只想遍历那些重要的合并。你必须建立一些索引来做到。还有一些关于 special token 的细节。概念上不深,但对构建现代 tokenizer 很重要。另一件事是,为了简单,我把 tokenizer 呈现为,你拿整个字符串,然后尝试 tokenize 它。实际上发生的是,你把文本拆成块,然后在每个块上应用 tokenizer。那会快得多。然后尽量让它尽可能快。某个时候你可能会意识到 Python 就是不够快,如果你用你喜欢的语言实现它,Rust 或 C 之类的,那就去做吧。
好,快速总结。Tokenizer 在字符串和 token 或索引之间转换。之前的字符基、字节基、词基都各自高度次优。所以 BPE 是一个有效的启发式方法,是数据驱动的,所以看起来相当有效。现在,像我之前说的,也许明年我就不用教这个了,但今年我们还得讲 tokenization。即便我们摆脱了 tokenization,我认为任何替代它的解决方案都必须满足以下性质。对吧?如果你有模型,Transformer 需要在序列的某种抽象上操作。如果你不仅考虑文本,还考虑视频或 DNA 序列,这一点最明显,因为单个字节或单元实际上信噪比相当低。你必须做某种抽象,把它提升到可以做建模的地方。最后,正如我提到的,块应该是可变的。你想要自适应计算。不是所有字节都生来一样。如果你不这样做,我认为你会次优。所以任何端到端解决方案,我认为也必须具备这些性质。
好。那么到这里,我就结束了。下次周三,我们会开始资源核算单元。所以那算是一个婴儿系统,我会说。然后之后,我们会回到架构,从那里继续。好。
资源核算:张量、精度、einops、FLOPs、算术强度与内存
这堂课从 Marin 项目 1e23 FLOPs 训练完成讲起,然后系统讲了张量与精度(fp32/fp16/bf16/fp8/fp4)、einops 的 einsum/reduce/rearrange、FLOPs 核算与 MFU、算术强度与 roofline 分析,最后落到训练中的梯度、优化器状态内存,以及梯度累积和激活检查点这两个省内存的技巧。
Marin 的 1e23 FLOPs 训练完成了
希望大家都没被雨淋湿,我是淋到了。就像我上次提到的,Marin 项目有一个 1e23 FLOPs 的训练在跑,现在它跑完了,而且结果和预测吻合。记得我们当时在跑这些曲线,每一条本质上是一条 IsoFLOPs 曲线,也就是一堆更小的模型运行,你试图找到计算最优的点,然后拟合一个 scaling law。这就是我们预测 loss 的那个点,然后我们真的跑了模型,得到的 loss 在 0.05 以内。我觉得这挺酷的。如果你外推到 GPT-5 级别的性能,得到的 loss 就是这个。当然,这取决于这些 scaling law 的形状,你的结果可能会不一样。好,我只是想分享一下这个消息。
上节课我给了整个课程的概览,我们讲到了 tokenization,那会出现在第一次作业里。今天我想讲资源核算(resource accounting),这部分更偏系统这一侧。回顾一下,我们主要想做的事情是:在有限的资源下训练出最好的模型,这些资源可能是计算、内存,有时候是数据——但数据在这门课里不会真正成为限制因素。我们的目标就是最大化训练的計算效率(computational efficiency)。而在优化计算效率之前,我们需要理解一个给定计算本身的效率,为此我们需要理解它的计算特性和内存特性。
给大家尝个鲜,看看这门课结束时你希望能回答的问题类型。这里有一个问题:在 1024 张——其实这里应该是 H100——上,用 15 万亿 token 训练一个 700 亿参数的模型需要多久?你怎么回答?有一个公式,我们会讲你怎么得到 FLOPs 的数量是 6 乘以参数数量乘以 token 数量。我们可以查规格表看 H100 有多快,我们有一个叫 MFU 的东西,我们会讲,取 0.5。然后你可以估算硬件每天能给你多少 FLOPs,然后就能算出天数。天数是 143 天。再看一个问题:用 AdamW 在 H100 上你能训练的最大模型是多大?你可以看,H100 每张有 80 GB 的 HBM 内存,每个参数的字节数是 2 加 2 加 4 加 4,我们会解释这是哪来的。然后你能得到的参数数量大约是 530 亿。这里有一些注意事项,我们没有计算激活值,那取决于批次大小和序列长度。所以这些都是非常粗略的信封背面计算(back-of-the-envelope)。但希望到这门课结束时你能理解这些数字是怎么来的。重点不是精确计算每一个细节,而是把握大致的形状。
上次我讲过知识和你能从这门课带走的东西——mechanics,也就是东西是怎么运作的。今天这很直接:mechanics 就是 PyTorch 怎么工作、张量怎么工作,这里没有魔法。我想传达给大家的 mindset 是,资源核算会非常关键,我希望每个人养成习惯:每当你写下这样一行代码,就想想它的性能特征。最后是 intuitions,这里我们只是获得一种感觉,资源是怎么被花掉的。今天没有 ML 魔法,那留到下一讲给 Tatsu。
从张量开始:精度、内存与数据类型
让我们自底向上地构建。最底下是什么?最底下是张量(tensor)。tensor 是存储一切的基本单元。如果你有参数、梯度、优化器状态、数据、激活,本质上一切都是 tensor。比如你可以看看 DeepSeek 3.2 模型,你会看到模型本身是一堆不同的 tensor,每个 tensor 都有一些形状,还有一些精度,我稍后会讲。如你所知,tensor 包含了向量、矩阵,并且可以推广到任意数量的维度。
那我们来谈谈存储一个 tensor 需要多少开销。这取决于 tensor 的类型。一般来说我们处理的是存储浮点数的 tensor,但 tensor 也可以存储整数和其他类型。对于浮点数,通常当你说 float,我认为人们默认指的是 float32。一个 float32,如果拆开来看,有 32 位:其中一位是符号位,8 位是指数,提供了动态范围,剩下的就是尾数(mantissa)或小数部分,提供了精度。这也称为 fp32 或单精度(single precision)。“单精度“这个词来源于早期做科学计算的时候,float32 就像是基准线:如果有人给你一个 float,你会期望它至少是单精度。如果你想要更高的精度,你可以用双精度,也就是 float64。但在深度学习中我们走的是另一个方向,因为即使 32 位也很多了,我们要做的那些计算并不需要像某些数值模拟那样高的精度。
所以在讲其他类型之前,我们先看看 float32。我们构造一个 4 乘 8 的矩阵。默认情况下,你创建的 tensor 类型是 float32,如果你想要别的类型,你需要声明它。内存使用量就是元素数量乘以每个元素的大小,对 32 位数来说就是 4 字节,所以是 128 字节。给大家一个量级感:在 GPT-3 这个相当老的模型里,前馈层里的一个矩阵大约是 2.3 GB。所以这些 tensor 可以变得相当大,而且这还不是能想象到的最大那种。
既然我们对效率感兴趣,我们通常想减少存储量。我们会看到,当你降低精度,你实际上省了内存,也省了时间,因为在 16 位上运算会更快,比如说快两倍——但不总是,取决于情况。而在内存方面,我们后面会看到,减少内存实际上也能省时间,这一点可能没那么显然,但希望它会变得清楚。
最直接的做法是,把位数砍掉一半,于是你有了 float16。float16 有一位符号,指数只有 5 位,剩下的都是尾数。float16 挺好,除了它的动态范围很差。比如你试着构造一个 1e-8 的 tensor,那实际上就是 0。所以你既不能表示非常大的数,也不能表示非常小的数。原因就是这个指数只有 5 位,而 float32 有 8 位。所以如果你用 fp16 训练——以前人们是这么做的——你会遇到不稳定,会遇到下溢(underflow)、上溢(overflow)、NaN,相当难搞。
于是 bfloat16 被发明了出来,它实际上是在 2018 年为了应对这个问题而开发的。观察是:我们不要在位数上妥协,位数和 fp16 一样,但我们把一些位从尾数挪到指数上。所以它的动态范围比 float16 更大,实际上和 float32 的动态范围一样。当然分辨率更差,因为这里没有免费的午餐。但事实证明在很多深度学习应用里,这个取舍非常值得:你希望动态范围足够,不至于上溢和下溢;而因为这些东西本来就是有点粗糙和随机的,你并不需要那么高的分辨率。
总结一下对训练的含义。你完全可以用 float32 训练,如果你训练的是小模型、不想操心这些,float32 就挺好,但它每个浮点数需要 4 字节内存,可能占用很多内存。如果你用 float16 训练,那太冒险了。所以 bf16 是那个甜点(sweet spot)。即便是 bf16 也可能有风险,可能还要稍微小心一点。现在已经成为常见做法的一件事是混合精度训练(mixed precision training)。混合精度训练就是一些计算用某种精度,另一些计算用另一种精度。一般规则是:参数、激活和梯度用 bf16,优化器状态用 fp32,我们后面会再讨论一点。要启用混合精度训练,PyTorch 有一个 AMP 库,我们不打算多讲这个,但你基本上把代码包一下,库会自动帮你处理,它会尝试在安全的时候把东西转成 bf16。比如 MatMul 通常是安全的,但如果你要做指数运算,它会尽量保持 fp32。
我们可以用 bf16,我想这门课大概就到这里。但如果你很想冒险,可以走得更远。fp8 是四年前提出的,它实际上已经被标准化了。看 fp8 的话,其实有两个版本,取决于你需要更大的动态范围还是更高的分辨率,所以有两个版本。我们不打算讲它,但 NVIDIA 有——transformer engine 支持 fp8。最近你甚至可以走到 fp4。去年 NVIDIA 开发了 nvfp4,每个值只有 4 位。为了让大家在同一页上:4 位并不多,我可以把所有取值写在一行里,在负 6 到 6 之间。所以精度并不高。这里其实有一点点“作弊“:如果你天真地只用这些值,你没法训练得很好。所以它实际的含义是,每个值有这 4 位的自由度,但是有 block(块),每个 block 可以相应地被放大和缩小。所以你实际上能表示更多的值,但你不能为每一个单独的值表示完整的动态范围。今年实际上发布了一个模型,Nemotron 3 Super,是用 fp4 训练的,我觉得挺酷的。
这里面有些东西知道就好,有些东西你甚至碰不到。并不是你创建一个 tensor 然后叫它 fp4 就行,很多是 NVIDIA 的软件栈在底层完成的。好,这段题外话有点长,但可能有助于体会精度这里的复杂性。
关于 fp4 block scaling 和 1-bit 的提问
在继续之前有问题吗?有。问题是,当你有一个 block,你是在按那个 block 缩放 tensor 中所有相同的东西,所以所有这些的比值仍然会处于 fp4 中。是的。所以问题是,能不能再解释一下 block。所以你有比如说一个 block,在那个 block 内,你可以在 4 位范围内变化;此外,所有这些值都可以根据你的缩放因子有多少位进行放大和缩小。所以如果你看单个值,你实际上能得到超过 4 位的动态范围。但这就像是你不能让这个值在这边很高,而相邻的值在这边很低。
还有一个问题是关于 1 位的,因为你不能真的比那更低了。所以训练和推理之间是有区别的。很多低位的东西——我想我们稍后会谈到量化——是你在也许甚至 bf16 上训练一个模型,然后你量化到比如 1 位或 2 位。那比训练一个 1 位语言模型容易得多,而我认为没人做过——也许可能,但我不认为有人训练过任何可信的东西。好。
einops:给维度起名字
我们继续。我们谈到了 tensor,内存计算相当简单,就是元素数量乘以每个元素占用多少内存。默认情况下,你在 PyTorch 中创建的 tensor 会在 CPU 上,当然,如果你想让事情变快,你想把它们移到 GPU 上。实际上有一点——我对幻灯片有一个小问题,它们是在我的笔记本上执行的,这意味着我没有 GPU,所以有些代码我只会展示而不会执行。这个可能没那么有趣,但我想大家都知道怎么把 tensor 移到 GPU 上,只是记得要做,否则你拿不到加速。
我们讲完了 tensor 的内存,这很直接。现在讲用 tensor 做计算。在讲 tensor 操作的 FLOPs 核算之前,我们先绕一小段,讲讲 einops。你们有多少人熟悉或者用过 einops?大概三分之二,好。einops 的动机,给那些还没被“洗脑“的人:看这样的代码非常容易搞错,或者说我觉得非常令人困惑。你有 x 和 y,然后你 transpose 负 2 和负 1,你试图搞清楚负 2 和负 1 是什么。所以这也许就是用变量和名字而不是用索引的动机。einops 是一个操作 tensor 的库,其中维度是有名字的,它受爱因斯坦求和约定(Einstein summation notation)启发。有一份不错的教程你可以去看,我这里只讲一些基础。
基本上,理解 einsum 的方式是:它是一种带良好记账的、广义的矩阵乘法。举个例子,我有一个 3 乘 4 的矩阵,一个 4 乘 3 的矩阵。如果我做 MatMul,这其实挺好,很容易理解。在 einops 里你基本上是说 x 有两个维度,行和列,我把它们命名为 seq1 和 hidden;y 是一个矩阵,也有行和列,我命名为 hidden 和 seq2;我要产生的 tensor——这里是一个矩阵——维度是 seq1 和 seq2。任何这里没提到的维度,比如 hidden,就会被求和消掉。它的工作方式是,我会枚举这里出现的所有变量的所有可能取值,也就是 seq1、hidden 和 seq2,然后索引 x、索引 y,把它们乘起来,累加到结果 z 的 seq1、seq2 上。你会说,这比那个容易多了,对吧?
我们来看一个更复杂的例子,就是我之前展示的那个。现在有一个 tensor,2 乘 3 乘 4,另一个 tensor,2 乘 3 乘 4。如果我用老办法,基本上我要把最后两个维度转置,然后因为 MatMul 会隐式地对不是矩阵乘维度的那些维度做批处理,你就得到答案,所以你得稍微推理一下。einops 让这件事非常清楚:它说有一个 batch 维度,有 seq1、hidden、seq2、hidden,我要产生 batch、seq1、seq2。注意这里没有 transpose,因为我某种意义上已经通过命名做了转置。如果写成 hidden 和 seq2,那就没有转置。我总是被 transpose 搞糊涂,不用去想转置这件事让我很开心。
如果你想玩点花活,你可以说,batch 我就用点点点(…)来替代。这意味着如果我有比如说一个 rank 10 的 tensor,带 8 个不同的批处理维度,我可以直接写点点点,而不用把它们全枚举出来。这在语言建模里会出现,因为你可能有一个 batch 维度、一个序列维度、一个 head 维度,你要对所有这些做矩阵操作,你可能不想管这些。好处是你因此可以写模块化的代码,写这个点点点而不用担心传进来的 tensor 的形状。
这就是 einops 库里的 einsum。还有 reduce,它是 sum、mean、max、min 的推广。比如你有这样一个 tensor,你想按维度 dim 负 1 求和,也就是沿着最后一个维度求和。同样,我不喜欢这种记法。你可以调用 reduce,基本上你说有一些批处理维度——这里是前两个——然后有一个 hidden 维度,它不出现在右边,意味着它会被求和。这里我写 sum,表示聚合归约操作是求和,但你可以把它换成 mean、max 或 min。
有人问这有加速吗?它基本上会归约到同样的原语操作,你可以把它看成是语法糖,所以应该是一样的。
最后我要讲的是 rearrange,我觉得这是个很强大的工具,我想它会在某次作业里出现一次。有时候你有一个维度实际上代表两个维度,而你想操作其中一个。出现这种情况的原因是,有时候你有一个矩阵然后把它展平了,然后你想拆开再展平。它的工作方式是这样的:想象我有一个 3 乘 8 的矩阵,但这个 8 维实际上代表一个 2 乘 4 的矩阵。我想把这个 2 乘 4 的矩阵乘以一个 4 乘 4 的矩阵。所以我接下来要做的就是调用 rearrange,我在这里说的是:这是一些批处理维度,这里它对应的就是这里的第一个元素;然后在这里我用括号表示这个 h 实际上代表 heads 和 hidden1 的乘积,这里显然有多种方式分解它,可以是 2 乘 4,也可以是 4 乘 2,所以我说 heads 的数量是 2,这意味着 hidden1 是 4。然后我可以把它拆成两个维度,heads 和 hidden。这样就创建了这个,之前 x 看起来像这样,现在 x 看起来像这样,我想这可能有点难看清。然后你可以对 w 执行你的变换,这就是我们之前见过的:你有一个标准的 MatMul,这里 x 有一些批处理维度,这是 hidden 乘以 hidden2。然后一旦你完成了这个变换,你就可以 rearrange 回来,这很直接,你基本上是看两个维度,然后把它们组合成一个维度。
有人问:你能把一个二维的东西塑造成一维的吗?不是有两种方式吗?比如按行主序还是列主序?问题是如果你有一个二维的东西然后把它变成一维的,你用哪种方式做?你做的方式由这里的顺序指定。好。所以有时我觉得需要一点时间来适应,但这很值得,因为一旦你有了 einsum,你就会以不同的方式思考。所有的转置和归约,所有这些都变得更流畅了。你必须更深入地思考这些更定制的原语。好,我们要用 einops,稍后我们会看到一点。
FLOPs 核算:FLOPs 与 FLOP/s 的歧义
现在回到资源核算。我有一些 tensor,我们已经讨论过它们如何占用内存,那么它们消耗多少计算量呢?我们用来衡量计算成本的标准是 FLOPs 的数量。一个 FLOP 是一次浮点运算,我们假设它是一次基本运算,比如加法或乘法。当然 GPU 还能做其他事情,但大多数情况下我们忽略它们,因为这些才是基础,会占用你大部分时间。
让我很在意的一点是,如果我说 FLOPs 这个词,它实际上是有歧义的。有 FLOPs,指的是浮点运算的数量,通常写作 FLOPs、小写 s,这是已完成计算量的度量。然后还有 FLOP/s,即每秒浮点运算次数,有时候也很容易混淆地写成 FLOPS、大写 S,但我会一直写 /s 来明确这是在衡量硬件的速度。所以如果你看到 H100 有 800、989 万亿次浮点运算,那是第二种;而当我说 GPT-3 用了 325 或者其他数字的 FLOPs,那是第一种。先把这一点说清楚。
先给大家一个数量级的概念。当我说 1e22 或 1e23 或 1e25 的 FLOPs 时,这些隐式地指的是计算量,或者某些模型的规模。如果你看 H100 那份光鲜的规格表——其实不在这页上,算了。有一份规格表,我告诉你对于 bf16,FLOP/s 的数量是 1979。然后你去实际测试,你会发现等等,那并不是我实际得到的结果。然后你去读细则,有一个脚注说这是在稀疏性条件下的结果。所以稀疏矩阵和稠密矩阵都是 2,所以你总是要把这些数字除以 2,这就是为什么你看到这些除以 2。
这只是为了建立直观理解,假设你有 8 块 H100,这里有一个节点运行两周——其实看起来是一周,好吧,没关系,就是一周乘以你每秒获得的 FLOPs 数,大约是 5e21。这只是为了建立关于某些类型硬件的 FLOPs 数、以及某些类型模型需要多少 FLOPs 的直观理解。这没什么复杂的,只是数学和粗略估算。
线性层的 FLOPs 就是 2×token 数×参数量
现在让我们做一些更机械的事情。假设你有一个线性模型。事实证明,很多计算 FLOPs 的方法实际上核心就是线性矩阵乘法,所以这其实并没有太大的通用性损失。你有 n 个点,每个点是 d 维的,我们将把每个 d 维向量映射到一个 k 维输出。B 是点的数量,D 是输入维度,K 是输出维度。让我们构造一些 x,即数据矩阵 B 乘 D,权重矩阵是 D 乘 K。当你做矩阵乘法时,问题是这有多少 FLOPs。事实证明这基本上是三个维度的乘积的 2 倍。理解这一点的方式是,我们对每个三元组有一次乘法,然后还有一次加法。有一个减 1,因为如果你不必实际相加,就像 D 减 1 次那样,但让我们忽略这一点。如果这有点快,我想还有另一种推导方法。
那其他操作的 FLOPs 呢?逐元素操作就是矩阵的大小,我想这相当清楚。加法同样需要 mn 次 FLOPs。所以一般来说,对于足够大的矩阵,你不会遇到其他操作像矩阵乘法那么贵。所以一般来说,我们就只关注 MatMul 在做什么,但有一个重要的注意点,是在我们谈内存的时候。出于好奇补充一下,还有别的做矩阵乘法的算法。有人问这是不是只针对正常做法?问题是还有别的做矩阵乘法的算法,你是指像次立方(sub-cubic)算法吗?一般来说,人们会去探索的矩阵乘法优化算法,更多是关于你怎么和系统协同设计,而不是这些更渐近的算法。有人问能不能以同样的方式考虑加法,是不是不可能比乘法更高效地做除法?按硬件构建的方式,这两者基本上是一样的。虽然直觉上似乎我加得比乘得快,但硬件的方式基本上是一样的。
所以你可以把这个 MatMul 看成:B 是数据点的数量,D 乘 K 是参数的数量。记住 x 是 B 乘 D,w 是 D 乘 K。所以这个公式的另一种理解方式是:做这个线性矩阵前向传播所需的 FLOPs,实际上是 2 乘以 token 或数据点的数量,再乘以参数的数量。事实证明这实际上可以推广到 transformer——如果你还记得 6 乘以 n 乘以 d 的公式,我们能看到它的形状正在成形。
MFU:实际 FLOPs/s 与承诺 FLOPs/s
不幸的是这些计算不会很有意义,因为我是在 CPU 上做。但我会把代码走一遍。到目前为止我们测量的是 FLOPs,这与硬件无关,只是你的模型需要做多少次计算。现在的问题是,在硬件上实际要花多长时间?一个办法就是给它计时。这门课里,我想在接下来几讲我们会更多讲基准测试,但这里先预览一下。一般来说,当你在 GPU 上计时,你必须调用 cuda synchronize 来确保——因为 GPU 是异步运行的,你要确保有这个同步点。然后你执行操作,操作之后你也要有同步屏障。如果你省略这一步,你会发现你的计时快得惊人,因为这是个非阻塞调用,它就返回了。通常好的做法是试多次然后取平均。
实际的 FLOPs 每秒基本上就是你做的 FLOPs 数乘以你在硬件上记录的时间的倒数。记住还有另一个数字,就是 GPU 的规格表给出的那个 FLOPs 数,是 989 teraflops。所以一般来说,实际的 FLOPs 每秒会和承诺的 FLOPs 每秒不一样。直接思考这个差距的东西叫做 Model FLOPs Utilization,或者 MFU。MFU 的定义是实际的 FLOPs 每秒除以承诺的 FLOPs 每秒。这里忽略了通信和其他开销。所以你基本上取实际值除以承诺值。一般来说,你很少能得到超过——我的意思是,你永远得不到超过你被承诺的值,通常你会得到更少。一般来说,如果现代模型的 MFU 能达到 0.5,你应该对自己相当满意了。如果你只是做一个纯粹的矩阵乘法,你可能能达到 0.8 甚至更高,但你通常达不到那么高。有时候如果某件事真的出错了,你会得到像 0.1 这样的结果,这意味着你应该做点什么。
所以每当你写模型时,你可以计算。你现在知道如何通过结合计算模型需要执行的逻辑 FLOPs 数量,然后查看墙上时钟时间并基本上相除来计算 MFU。那边有问题吗?哦,是的。承诺的 FLOPs 数字是什么?那在规格表里,它已经除以 2 了,那个 989 的数字。然后在此基础上,一般来说你只能得到其中的 0.5,取决于你的计算。你问的是你得到 50% 的 MFU?是的。那么为什么你只得到 50% 的 MFU?实际上这是个好问题,我会在讨论内存瓶颈时再回来讲这个。
总结一下,矩阵乘法通常主导了计算,这是设计使然。而每秒的 FLOPs 数量取决于硬件,更好的硬件带来更多的 FLOPs;并且还取决于数据类型。这意味着如果你查看规格表,不同的数据类型会有不同的 FLOPs。如果你现在尝试使用 float32,它会非常非常慢,因为他们并没有真正为这个工作负载进行优化。而现在 bf16 或 fp8 会快得多。而 MFU——现在你知道 MFU 是什么了,它是实际的 FLOPs 除以承诺的 FLOPs。
算术强度:为什么 ReLU 和 GELU 一样贵
回到为什么 MFU 是 0.5 这个问题。要理解它,我得引入算术强度(arithmetic intensity)这个概念。原因是,事情并不只是做一堆 MatMul、然后做完看 MatMul 花了多久。这是我画的非常卡通的硬件样子:你有高带宽内存(HBM),然后你有计算核心所在的地方、加速器芯片所在的地方。那你怎么计算?你必须把你的输入、你的矩阵——tensor 就存在下面——从内存送到加速器,做计算,然后送回去。所以如果你想衡量这要花多久,它取决于两件事。一个是加速器的速度,就是我们刚才讲的。但另一件重要的事是你硬件的内存带宽,这个我们还没讲。看规格表的话,我们说过 FLOPs 每秒是 1979e12 除以 2,而每秒字节数,也就是内存带宽,是 3.3 TB/s。记住我们为什么要看内存、看存东西要多少空间。最明显的是,如果你的模型太大装不进内存,那可不妙。但事实证明,你需要移动这些内存,这要花时间,所以东西有多大实际上也影响速度。
我要讲一些操作,基本上计算它们要花多久,并引入算术强度这个概念。好,假设我有一个百万维的 bf16 向量,然后我要对它计算一个 ReLU。记住 ReLU 就是取 x 和 0 的逐元素最大值,作用于整个向量。我计算两件事。一是移动的字节数:我要读入 x,把它复制到加速器里,每一个是 2,因为 bf16 是每个浮点数 2 字节,乘以 n 个浮点数,所以是 2n;然后我要把 y 写回去,所以又是 2n。这就是需要移动的字节数。那么做了多少 FLOPs?每个元素,我只是拿它和零比较,就这样,所以是 n 次比较。那么通信时间,就是我需要移动的字节数除以移动的速度,这给出时间是 1e-6 秒。那计算时间呢?就是 FLOPs 除以每秒 FLOPs,所以是 1e-9。还有一个我们通常试图保持的重要假设,就是重叠通信和计算,我们会在更深入讨论 GPU 时多谈这个。但这个想法是,在这种情况下我们不会坐在这里等东西移动,一旦它们到了我们就开始计算,然后把它们移回去,所以这个移动以及计算是同时发生的。数学上,我们只是假设总时间是两者的最大值,因为我们假设可以完美地重叠它们。实践中它不会完美重叠,会有一些开销,但这对现在来说足够了。所以总时间,如我们这里看到的,是 1e-6。
那么这里的瓶颈是什么?当通信时间大于计算时间时,我们称这个算法是内存受限(memory bound)的,因为你大部分时间都在等比特到达。当计算时间大于通信时间时,那是计算受限(compute bound),因为此时你的瓶颈实际上是做计算。那在这种情况下,ReLU——修正线性单元——是内存受限还是计算受限?内存受限。很清楚,因为计算量远小于通信量。
这里有另一种看法,也是我要定义强度的地方。一个加速器的强度基本上是加速器每传输一个字节能做多少工作。对任何给定的加速器,基于规格表,你基本上就是 FLOPs 每秒除以字节每秒。所以你每移动一个字节能做多少有用的工作?是 295。这意味着每字节——你可以做 295 次浮点运算。这是一个可以记在脑子里的直观数字,大约 300。那算法的算术强度就是,这个工作负载每字节实际做了多少工作。看 ReLU 计算的话,就是 FLOPs 除以字节,实际上是——我想是四分之一,不是一半。重点是它非常小,0.25。
所以现在我们就可以用强度的语言来谈瓶颈了。如果算术强度小于加速器强度,那就是内存受限;如果大于加速器强度,那就是计算受限。这两个说法是等价的,如果你看代数,基本上你有两个分数,然后你乘一乘、除一除,把项换一下位置。在这个例子里,我们是内存受限的。一般来说我们会发现自己处在内存受限的情况,因为数据移动很贵。所以如果你能获得更高的算术强度,那是好事。0.25,如果你看到这个数字,如果有人告诉你算术强度是 0.25,你应该说,哦,这真的很糟糕。有人问典型 transformer 的算术强度大概是多少?我们会讲到。
增加算术强度的一个思路是,让我们试着每移动一单位字节做更多的事。GELU 是另一个激活函数,它长这样,某个更复杂的公式,它没有零。如果你做这个计算,来回移动的字节数仍然是 2n 加 2n,而这里 FLOPs 大约是每个逐元素标量操作 20 次 FLOPs,所以是 20n。那么算术强度就是,比如说,5,这是个粗略估计。那这种情况下我们是内存受限还是计算受限?内存受限,因为 5 仍然小于 295,小得多。所以即使 GELU 做了很多工作,比 ReLU 多得多,但按照事情的组织方式,它仍然是内存受限的。这意味着如果你只是计算 ReLU 和 GELU,你会想,GELU 那么复杂,一定很贵,但实际上它们完全一样,因为那里不是瓶颈所在。
现在看一些线性操作,比如点积。点积就是你有一个向量 x,一个向量 w,大小都是 n,你取点积。移动了多少字节?你读 x 是 2n,读 w 是 2n,然后写 y,y 是一个标量,是 2。FLOPs 是你做了 n 次乘法、n 减 1 次加法,所以是 2n 减 1。那么算术强度是多少?对这个来说大约是一半,也很糟糕。这意味着——希望你能 get 到——它是内存受限的。
那矩阵向量乘法呢?我有 x 是个向量,w 是个 n 乘 n 的矩阵,你形成这个乘积。有多少人觉得这会是计算受限的?有多少人觉得是内存受限的?我们来看。我读 x 是 2n,读 w 是 2n 平方,写 y 是 2n。FLOPs 基本上是你做 n 次点积,所以是 n 乘以做一次点积的开销。算术强度只是勉强、勉强高一点,所以这也是内存受限的。
现在讲矩阵乘法,这里是事情变得有意思的地方。我有一个 n 乘 m 的矩阵,另一个 m 乘 n 的矩阵,我把它们乘起来。移动的字节数是 2n 平方加 2n 平方,然后我要把 2n 平方字节写回 y。这里的 FLOPs 是 n 平方次点积。那算术强度是多少?是 300,哇。好,340,一般来说大概是 n 除以 3。直觉上这说得通,因为你发送了 n 平方个东西,但你计算了 n 立方个东西。所以你计算的东西的数量除以你发送的东西的数量是 n 阶的。你让矩阵越大,这就越好。一般来说,这就是为什么当你听到人们说我们需要大的批次大小,或者大的矩阵,原因正是这个。如果你在算术强度之下,把事情做小其实并不会加速,全都一样;而如果你到了这个点之上,那你实际上就在饱和你的 GPU 了。
最后,这是计算受限的。所以只要我们有大的矩阵,我们就相当不错,我们在计算受限、饱和加速器的状态。之前关于 transformer 的问题呢?事实证明,我们会在你们的作业里、也会在下一讲里看到,transformer 本质上就是大的矩阵乘法,中间撒了一些东西。所以从算术强度的角度这是好消息,这是设计使然,transformer 是按某种方式设计的,让它有很高的算术强度。这里还要提一点,为推断那一讲做个铺垫:矩阵向量乘法本质上就是你在做 transformer 推理时发生的事情。因为推理时你一次生成一个 token,所以你只有一个向量去和一个矩阵做点积,正如我们看到的,那是内存受限的。而在训练时,你拿到整个序列,一次性处理所有东西。
还要注意强度取决于精度。默认情况下我们这里做的一切都是 fp16。这也能连到另一个关于 MFU 的问题:你得到低 MFU 的原因可能是,MFU 是实际值除以承诺值,如果你有非常大的内存瓶颈,那你就不会得到很好的吞吐,尽管承诺的意思是:假如你没有内存瓶颈,你只是一路把模型的所有计算都做完。
最后是算术强度和 roofline 图。有人问:一般来说这些模型在大部分计算上都是内存受限的,然而加速器 50% 并不算好,也许你能拿到 70、80 那才真的很好,这意味着加速器相对于内存带宽是超配的。为什么会这样?为什么这些加速器这么大,却只是在空转或者等内存?问题是,你能不能设计出特性更好的加速器?也许等我们讲 GPU、多理解一点它们如何工作时,我们可以谈谈为什么会这样。但如果你有答案,你应该告诉 Jensen,也许你能设计出更好的硬件。
我们来可视化一下算术强度和性能之间的关系。这个图把算术强度画在 x 轴上,这里每一个切片对应一个特定的算法;然后我们有这些线,每一条线对应一个特定的加速器,也许是 H100 或 B200 之类。它展示的是——y 轴是实际实现的 FLOPs 每秒。如果你的算法算术强度低,比如 ReLU 或点积,你就落在这边,这意味着实际实现的 FLOPs 每秒不会像你加速器的峰值那么高。而当你增加算术强度,事情会变好,你能饱和你的硬件,直到某个点。过了某个点你就是计算受限的,显然你不能超过峰值 FLOPs。
训练的内存与计算:梯度与 6ND
现在我要回头讲训练所需的操作的内存和计算。到目前为止我们做了 tensor 操作,基本上是 MatMul,我们看到了它占多少内存、需要多少计算,以及它们之间的交互。现在我们来真正想想训练需要什么。这里是我们的固定例子。其实这不是一个线性网络,就是一个深层网络。我考虑的情况是:我有一个输入,是 B 乘 D 的输入,以及若干层,每一层是一个 D 乘 D 的 MatMul,然后产生一些预激活(preactivations),再经过逐元素 ReLU 产生第一层的激活,然后这个反复重复,输出也是同样的大小。看一下它在 PyTorch 里长什么样,基本上就是一组 block,每个 block 有一个权重矩阵。这里的参数数量是 D 平方乘以层数 L。当你在这批数据上运行模型,我们做的就是一层层走,每一层我们基本上应用线性变换,然后一个逐点 ReLU 激活,然后对所有层都这样做。这希望是很直接的、非常简单的模型。
现在讲梯度。我们用一个更简单的例子,就是一个简单的线性模型回归。我们有向量 1、2、3,一个权重向量 1、1、1,我们取点积,形成 MSE loss。当你做反向传播时,在这个计算图里参与的每个变量都有一个梯度,要么被设置了,要么没被设置。w.grad 被设成 1、2、3。这就是 PyTorch 的基本机制,我想大家都很熟悉。那么问题是,计算梯度要花多少计算量?
我们来数一数计算梯度的 FLOPs。取一个简化模型,你有 x,是 B 乘 D,乘以 w1 矩阵,我们忽略 ReLU 以简化,再乘以 w2,同样是 D 乘 D 的矩阵。我用 einsum 来写,原因后面会清楚。线性网络里你做的第一件事是拿 x 和 w1,x 是 batch 乘输入维度,w1 是 in 乘 out,你得到 batch 乘 out。h2 拿 h1 和 w2,同样的故事。这些都只是 MatMul。然后你形成一个 loss,我这里就随便把它归约成一个数。那么在反向传播里会发生什么——我给它命名叫 retain gradients,为了调试目的,你们后面会看到。你在 loss 上做反向传播。问题是,那做了多少工作,是多少次浮点运算?
我们放大看一层,聚焦第二层。第二层拿 h1,把它乘以矩阵 w2 得到 h2,就这样。前向传播的 FLOPs 就是一个 MatMul,我们把三个维度拿出来相乘。2 是因为有一次加法和一次乘法。那么在反向传播里这看起来是什么样?反向传播里,如果你记得链式法则和反向传播算法,你要计算两件事:你要计算反向消息(backward message),也就是 loss 相对于你输入的梯度;你还要计算相对于参数的梯度。那这些梯度长什么样?这就是链式法则用 einsum 记法写出来。你拿 d loss d h2,也就是 h2 grad,那是一个 batch 乘 out 的矩阵,你把它乘以 w2,w2 是 in 乘 out,然后这给出 h1 grad,即 d loss d h1,是 batch 乘 in。我总是在这里搞混,如果你学过微积分,其中一个会有转置,我总忘记哪个顺序。einsum 我觉得让它非常清楚,因为你可以只看着标量情况记住它必须看起来像 h1.grad 是 h2.grad 乘以 w2,唯一的问题是索引怎么排。你通过看这些的形状和命名的维度来索引。在这个例子里,它就是一个矩阵乘法,你对输出维度求和。做出来之后我们可以检查:h1.grad 就是 loss.backward 算出来的东西,它确实和这里写的一样,作为 sanity check。
另一件你必须计算的是 w,相对于参数的梯度。那是 d loss d h2 乘以 h1。它是同样的反向消息回来了,但是和另一个你不是在对它求梯度的东西相乘。你把维度写出来,这里你是对 batch 维度求和。但好的地方是,如果你只看这个,我们知道它有多贵。FLOPs 的数量本质上就是所有维度的乘积,你是对哪个维度做批处理并不重要。基本上你枚举 i、j、k,然后聚合。聚合的方式不同,但 FLOPs 是一样的。注意反向传播恰好是前向传播的两倍那么贵。这是因为你要计算两个梯度:每个参数相对于参数的梯度,以及相对于输入的梯度,也就是另一个不是参数的东西。
好,刚才只是针对 w2。你需要把这个应用到网络里所有的参数上。把它们放在一起,你会看到对这个网络来说,前向传播是 2 乘以数据点的数量,也就是 b,乘以参数数量,这么多次 FLOPs。反向传播是它的两倍,也就是 4 乘以数据点数量乘以参数数量,这么多次 FLOPs。所以总共是 2 加 4 等于 6,6 乘以数据点数量和参数数量。这就是你可能在各种地方见过的 6ND 是怎么来的,它只是数前向和反向。我们是对这些深层网络做的这个推导,但事实证明这对 transformer 也是一个很好的近似——只要上下文长度不太大。如果上下文长度太大,你就会得到上下文长度的平方,那是这个核算里没有的额外 FLOPs。
优化器与优化器状态的内存
现在我们有了梯度,做训练的另一个部分是优化。为了不给你们直接讲作业 1 里的内容,我用 AdaGrad 优化器,这是 2011 年的,比 Adam 还早。你可以把它看成介于 SGD 和 Adam 之间的东西。基本上它就是 SGD,但你看梯度的二阶矩。Momentum 是看梯度的一阶矩,Adam 是把两者结合起来。我没有时间在这里讲优化器的细节,因为我们主要关注用法。
我们可以定义一个优化器。当你计算梯度,然后你做一个 optimizer step——如果你要定义一个新的优化器,在作业 1 里你会实现 Adam。对每个参数组,在这里就是 w1 或 w2,我们看——有一个东西叫优化器状态(optimizer state),是存储的、优化器运行时使用的。我们在 AdaGrad 中计算的是某种——梯度平方之和。这里我们从优化器状态里拿它,用当前梯度更新它,然后存回去。然后在你更新完 g2 之后,再更新参数。所以在 AdaGrad 里,基本上就是除以梯度平方平均值的平方根,或者说梯度平方之和。好。其实我可能要跳过训练循环。等等,我觉得我跳过了某些本不想跳过的内容。
那是优化器状态,现在我们来看优化器用了多少内存,或者说一般来说内存使用量是多少。对于这个网络中的参数,每层有 D 平方个参数,L 层。如果我们用 fp16 存储,每个参数占 2 字节,所以这就是参数的内存。激活是 2 乘以批次大小乘以 D 乘以层数,这是 bf16 的,每一层都有激活。还有梯度,基本上就是所有参数的一份副本,这也是 bf16。而优化器状态,对每个——其实这里应该是——我觉得这里应该是个笔误,我来修正一下:这里应该是参数数量,不是参数内存;所以是参数数量乘以 2 字节在这里,然后是 4 乘以参数数量在这里。原因是,习惯上用 fp32 存储优化器状态以保证稳定性。显然人们尝试过用 bf16,结果就是你在做平方,然后对多步求平均,这不太稳定。对于 AdaGrad,这里每个参数占 4 个字节用于存储优化器状态;Adam 会存储一阶矩和二阶矩,所以是 8 个字节。所以想想看,优化器状态实际上占了很大一部分内存。
有一点需要注意,内存有两个用途。一是你必须把这些东西存储在 HBM 中,但另一点是它必须被传输到加速器上。一般来说优化器状态并不是计算瓶颈,所以这里的实际内存大小对速度性能并不很重要,但它意味着你无法在内存中容纳大模型。
总结一下,正如我们提到的,这里的参数量是 D 乘以 D 乘以 L,而 FLOPs 数量是 D 乘以 token 数或数据点数乘以参数量。对于 transformer 来说这会稍微复杂一些,但你会在作业 1 里做这个,你会更仔细地完成它。我会跳过训练循环,这只是一个总体回顾。
用梯度累积和激活检查点减少内存
在结束之前我想快速讲两点。一是正如我们所见,内存确实有重要影响,既影响存储大模型的能力,有时也影响你的速度。所以一般来说你希望减少内存使用,人们通常会做两件事。一是梯度累积(gradient accumulation)。一般来说你希望使用足够大的批次大小以提升稳定性,直到达到临界批次大小(critical batch size),Tatsu 稍后会讲到这一点。正如我们所见,激活内存随批次大小增长,所以如果你的批次大小太大,在某些时候你可能会内存不足。梯度累积就是说,你有这些 micro batch,你在 micro batch 上计算梯度,然后你只是把梯度累积起来。你不清零梯度。每过 batch size 除以 micro batch size 这么多步,你更新参数并清零梯度。这实际上是一个非常简单的代码改动,让你能省下计算。
另一件我要快速提的是激活检查点(activation checkpointing)。训练时我们一般需要存储所有层的激活。这是默认做的。有意思的是,对于推理我们不需要计算梯度,所以我们只需要存当前层的激活。但对训练来说,内存使用是 B 乘以 D 乘以 2 乘以 L。问题是,你能减少这个吗?激活检查点,也叫梯度检查点(gradient checkpointing)或重物化(rematerialization),关键思想是:在前向传播中你只保留一部分层的激活,在反向传播中你从最后一个检查点重新计算缺失的激活。这就是为什么叫 checkpointing。这是系统里常见的一个通用技巧,就是做权衡:如果你想减少内存,你可以重新计算。
这里我们把这个操作化。实际上这相当容易。基本上你做的就是同一个模型,只不过你在某一层上加上 torch.utils.checkpoint。这意味着做这个计算,但不存储任何中间激活,只存需要的东西。这意味着如果你存储所有激活——记住在你的深层网络里,你有 ReLU 之前的东西和 ReLU 之后的东西,那是很多存储。反过来,如果在这些 block 上做激活检查点,每个 block 有一个线性层和 ReLU,你就不存 pre-ReLU 的东西,你基本上省下一半,你能用一半的内存。然后你做反向传播时需要 g3,但你可以很容易地从 h2 算出 g3。
你可以走得更远。你可以说,我可以存储所有层,但在极端情况下,我可以一层都不存。那会是内存效率最高的。唯一的问题是计算量会变成 L 平方,因为对每一层你都得从头开始。也许一个甜点是,你把检查点存在根号 L 层处,那意味着你的激活内存是根号 L,你的重计算开销也是根号 L,这样就平衡了。
总结
总结这堂课:一切都在 tensor 上操作——参数、梯度、激活、优化器状态、数据。我们介绍了 einops,希望你们能接受它,把它作为思考 tensor 操作的方式。6 乘以数据点数量乘以参数数量这个公式,我们现在已经去神秘化了,它就是训练一步的 FLOPs 数量;其实如果这是训练步,这里应该是批次大小。然后我们讲了算术强度和 roofline 分析,它让我们能诊断一个计算是内存受限还是计算受限。矩阵乘法是计算受限的,基本上其他所有东西都是内存受限的。最后,梯度累积和激活检查点是减少内存的方法,而通过减少内存,你能使用更大的批次大小。这就是今天的内容。下周 Tatsu 会讲架构。
从零开始的语言建模:架构
这节课从原始 Transformer 出发,系统梳理现代语言模型的架构变体、超参数选择、稳定性技巧、位置编码和注意力结构,并以大量模型为例说明哪些选择是共识、哪些仍在变化。
为什么架构让人头疼:用综述的方式看模型
今天我们要讲架构,至少对我来说,架构一直是个相当难以捉摸的东西。所以我采取的办法就是:把所有东西都告诉你,把现代论文都过一遍,看看大家都做了什么。我把这节课的标题叫做“所有你不想知道的架构和超参数”,因为我们当然都希望自己活在一个只需要知道 VC dimension 之类东西的世界里,只要非常简单的理论工具就够了,但现实并不是这样。
我们会从一种综述的视角来理解架构。最好的做法其实是你自己出去训练模型、尝试不同架构,这比听这节课还有用,这也是这门课哲学的一部分。但我们没有算力和时间覆盖所有架构的整个设计空间。所以我的看法是,第二好的做法就是从别人的经验里学习:别人都做了什么,他们在做哪些选择。通过看一个更广、更拉远的图景,也许我们能开始理解,哪些参数和选择在所有有效架构里基本固定,哪些可以变化而不影响模型性能。
我会讲 Transformer 的各种变体,从 Vaswani 那篇论文开始的现代 Transformer,到更近期的架构,看它们有什么共同点,然后什么是可以变的。很多人上过 NLP 课,见过 vanilla Transformer:加位置 embedding,用正弦余弦,经过 ReLU,然后 post norm。你们的 A1 会和标准 Transformer 有些不同:我们会让你把 layer norm 移到每个 Transformer block 或非残差层的前面,实现 RoPE,还实现 SwiGLU 而不是 ReLU。为什么选这些?一个原因是我们从 LLaMA 抄了很多,但别人也都这么做。真正训练自己的语言模型时,你会很快遇到“这么多选择,我到底该选什么”的问题。
我思考架构的方式,是看人们做过的所有事情,然后问能不能从里面挑挑拣拣。Percy 总爱拿这个开我玩笑。我每年都看新模型,去年以为只有几篇论文,结果有 Qwen 2、Gemma 3、InternLM2、NeMo Tron 4 等等,居然有 19 个新的 dense 模型。今年我以为会慢下来,结果有 Qwen 3、上周四刚出的 Gemma 4、还有几乎 3,Percy 自己用 Marine 训练的 8B 模型也值得一提。模型很多,而且大多数其实是 MoE,混合专家,这个我明天再讲。因为模型多样性这么大,我们反而能相当好地看清所有可能的选择。
我做了个小表格,最后会回来看它。从原始 Transformer 开始,已经有很多自回归语言模型在类似的数据上训练过。你可以问:词表大小都有哪些?用什么 layer norm?用什么位置 embedding?然后会看到相当清晰的趋势。今天的目标是覆盖几件事:先是常见架构变体,也就是 Transformer 的 building blocks,比如非线性用什么、位置 embedding 用什么;然后讲超参数,比如 FF dim 是不是 hidden 的四倍、词表该多大;再讲让模型稳定训练的底层技巧。稳定性技巧和架构变化关系很密切。
我想让你们记住,架构其实是一组非常复杂的权衡。架构要做什么?它要从数据中学习,所以要泛化;要在 GPU 上高效训练;还不能炸掉。训练到一半,如果 loss 突然爆掉,那完全不行。所以这些需求最终都被固化进了架构里。这也是为什么这些东西有点乱、有点复杂,很多地方并不优雅。
从实验时代到 LLaMA 2 之后:架构趋势
从高层看,我理解架构历史大致是这样:早期从 Transformer 到 GPT-3 左右,有大量实验,人们试各种东西,没有统一的金标准。然后 LLaMA 2 出来,大家都说 LLaMA 2 真好,我也想要自己的 LLaMA 2,于是大家开始训练 LLaMA 2-like 模型,只做小改动。去年我们看到很大差异,或者说趋势转向让训练更稳定的架构修改。今年则看到很多支持更长上下文的架构变化。所以有一些大主题在发生,LLaMA 2 是个重要节点,之后人们又开始探索。看到这些变化挺酷的。
层归一化放在哪里:pre-norm、post-norm 与残差流
架构上人们可以争论很多,但有一件事大家基本同意:Transformer 论文大部分东西都做对了,除了 layer norm 放在哪里。原始 Transformer 把 layer norm 放在残差路径里。Transformer 有一个 residual stream,X 贯穿整个网络,每个组件产生 delta 加回残差流;为了让梯度跨层稳定,layer norm 被放在每个组件末尾。另一种做法是 pre-norm:把 layer norm 放在残差流外面,但在每个计算之前,比如 multi-head attention 之前、FFN 之前。命名会有点混乱,你可以叫它 post-norm,但我把它叫 residual norm,因为 norm 在残差层里。几乎所有现代语言模型都把 layer norm 推出残差流。
有一个很好笑的例外:OPT-350M。熟悉语言模型的人大概知道 OPT 本身有点乱,OPT-350M 更是如此,我也不知道为什么只有这个模型在残差流里用 post layer norm。这是大家都同意的一件事。你可能会问,为什么这件事如此统一?早期研究想的是:训练 Transformer 需要 warm-up,现在现代 Transformer 训练也仍然做 warm-up,但如果能去掉 warm-up 不是很好吗?这是最初的动机。但人们很快发现,去掉 warm-up 在稳定性和收敛上有严重问题。post norm 加 layer norm,也就是原始 Transformer 那种做法,收敛不如 pre norm;pre norm 即使没有 warm-up 也能得到好得多的收敛。
但人们很快意识到,把 layer norm 移到残差流外面,对加深网络和稳定性有重要影响。我觉得梯度衰减问题最清楚。做架构设计的人常说一句话:保持残差流干净。在 pre-norm 里,X 从底部进来,一路传到顶部最终输出,反向传播时梯度直接穿过,梯度传播非常简单,稳定性和信号传播都更好。pre-norm 加蓝色初始化时,梯度大小基本保持不变,因为反向传播有干净的直通路径。post layer norm 则会有复杂效应,因为每次经过 Transformer block 都做 layer norm,反向传播时梯度范数会改变。
实验也表明,pre-norm 总体上提高稳定性,梯度尖峰的大小和频率都比 post-norm 好。这是 Salazar 和 UN 的图,他们比较早地仔细研究了这个现象。我认为这就是它留下来的原因:稳定性和加深能力对现代大语言模型都非常重要。所以把 layer norm 移出残差流,基本被所有人采纳了。
那么,如果 layer norm 放在残差流里不好,为什么它必须在开头?我们可以在计算之后放,按同样的逻辑也完全可以。很多近期模型,比如 Grok、Gemma 2、Olmo 2,就把 layer norm 移到计算之后,所以它是某种 post-norm,但在残差流之外。其他模型则到处放 layer norm,前后都放。后面讲稳定性时我会提到,一个经验是:如果遇到稳定性问题,到处撒 layer norm,一般会改善稳定性。这话说起来很荒谬,但每次人们遇到稳定性问题,都会说“那我们在 attention 里扔个 layer norm 会怎样?”结果发现也有效。这就是 post norm 或 double norm。
RMS norm 与去掉 bias:系统与表达力
原始 Transformer 里的 layer norm 是:对 activation X 减均值、除方差,再缩放回来。这完全没问题,很多模型都用它成功训练。但几乎所有现代模型都用 RMS norm,它不减均值,也不加 bias,只是缩放再缩放。layer norm 在表达力上比 RMS norm 更强,所以表示上并没有理由必须用 RMS norm。但实践中 RMS norm 没有表达力损失,效果和 layer norm 一样好,而且更快。这里系统和架构协同设计就开始了:我们想让 GPU 保持繁忙,做矩阵乘法和其他高算术强度计算,不想让 GPU 来回搬小内存。所以要移除那些很小、涉及内存移动、但表达力不高的操作。如果减均值和加 bias 没什么用,那就删掉。
你可能会想,这有什么大不了?这只是个很小的操作,只占系统总浮点运算的 0.17% 左右。但正如 Percy 之前说的,问题不在 flops。Flops 是浮点运算,是矩阵乘法,但 runtime 要复杂得多。统计归一化,比如 layer norm,虽然只占 0.17% 的 flops,但根据负载和设置,可能占高达 25% 的 runtime。这很疯狂。在小模型上尤其明显,因为做这些操作时仍然要把大量参数在快慢内存之间搬来搬去。所以数据移动非常重要,RMS norm 也因此仍然很关键。
这里可以看到差异:算术强度用白色表示,flops 用黑色表示。layer norm 的算术强度非常低,正是我们最想移除的那种操作。学生问:归一化的数据移动和算术收缩相比为什么如此不成比例?因为像 tensor contraction,也就是矩阵乘法,大部分工作是乘法;而统计归一化的大部分工作是内存移动,内存移动很慢。想象一下,如果移动几乎占掉全部计算,而 activation 又很大,你就要付出很多。这里 runtime 百分比很极端,是小模型、矩阵不太符合现代负载的情况,但能让你感受到为什么这是免费优化。
还有一篇 2020 年的论文,人们评估不同架构干预。我记得是 Google 的论文。他们展示,在一个 2 亿参数的小 Transformer 上,换用 RMS norm 后每秒步数更多,这就是第三列。而且实际上性能也更好,我不认为这是保证的,但是个不错的 bonus。所以你通过换 RMS norm 获得了免费的系统收益。于是大家基本都换过来了。
更一般地,Transformer 和神经网络里的 bias 项通常不太有用。原始 Transformer 的线性层都有 bias,但大多数实现直接去掉。bias 也不是算术密集,相对更内存密集。所以干脆删掉,获得免费系统收益。顺便说,有时候 bias 也会引起稳定性问题。它们在其他方面有用,但我认为删掉它们的主要原因还是从系统角度简化。
layer norm 的故事比较简单:大家做法相当标准化,我们对 layer norm 作用的理解虽然不深,但够用。所有人都把 layer norm 移出残差流,常常用 pre-norm,部分可能因为 LLaMA 2 这么做。我们大致知道怎么用 layer norm 控制梯度尖峰、保持信号传播。基本总是用 RMS norm,去掉 bias,让系统保持算术密集,同时表达力不变。架构让人不爽的一点是,你无法事先推理这些。比如我们事先并不知道删掉 bias 没问题,但通过大量实验和集体知识,我们知道在典型语言建模负载下,删掉线性和 RMS norm 的 bias 没问题。
激活函数:从 ReLU、GELU 到门控线性单元
现在讲激活函数。激活函数有一大堆:ReLU、GELU、Swish、ELU、GeGLU、SeLU、SwiGLU、LiGLU。我读统计和机器学习时曾想,我永远不学这些东西,我会以不知道 SwiGLU 为荣。但现在我们需要对它们有一般性了解,知道名字里哪些部分对性能重要。用很 vanilla 的激活也能训练语言模型,比如只用 ReLU,Chinchilla 大概是那组里最好的模型。用 GELU,也就是高斯误差单元,区别只是底部一个小凹口,零附近的梯度变了,你可以训练 GPT-3,按现代标准不现代,但完全可用。
然后到 gated linear units,比如 SwiGLU 和 GeGLU,这里才是重点。和 layer norm 类似,几乎所有可信的现代语言模型都用某种 gated linear unit。什么是 gated linear unit?看 FFN 的第一部分:标准 ReLU feed forward,X 乘 W1,逐元素在零处阈值,再乘 W2 得到输出。架构设计里常说 gating 很有帮助。把这个一般启发式用上去,你会说:除了逐元素 ReLU,为什么不再加一个 gate?第二项逐元素乘 ReLU 输出,用第二个矩阵 V。这会调制 ReLU 输出,然后其余一样。于是 XW1 被 XV 门控,再经 W2 降维。这是 ReGLU,也就是 ReLU gated linear unit。
gating 是架构设计里非常有效的 primitive,在语言建模里也非常有效。GELU 加 gating 得到 GeGLU;Swish,也就是 X 乘 sigmoid,加 gating 得到 SwiGLU。这覆盖了很多现代模型:Google 的人一般用 GeGLU,比如 Gemma、T5;LLaMA 后代都用 SwiGLU,PaLM 和 LLaMA 后代都是 SwiGLU 模型。我会说 SwiGLU 可能更主导,但在 gated unit 之间其实差别不大。
这里有个后面会用到的 trivia:gated 模型参数更多,因为我多了 V 这个参数。现在有三个矩阵而不是两个。做点数学就知道,应该把 feed forward dimension 缩小约 2/3,保持总参数一样。这是人们遵循的经验法则,但不是铁律。Noam Shazeer 提出这个的论文里 delta 很小,但很一致。他的很多论文都做 error bar,训练多个重复,看是否更好。GLU 变体几乎总是比非 GLU 好,而且是参数匹配比较,因为他总做这个 2/3 调整,保持总参数相同。所以这几乎是免费胜利。
几乎所有人都用 GLU。也有更系统的比较,就是刚才说的 Noam 等人 2020 年论文。Google 在 2020 年代做了不少大规模架构比较,不过是 T5 架构,不是自回归语言模型。他们全面比较 GLU,SwiGLU、GeGLU 或一般 GLU 在 loss 或下游指标上显著更好。现在大量训练也清楚 SwiGLU 和 GLU 很好。gating 有很多变体,但真正重要的单一轴是:gating 对这些非线性很重要,计算代价不大,提升不错。
这不是说 gated linear unit 是必需的。GPT-3 就不是。NeMo Tron 340B 用了 squared ReLU,有点疯狂,但也能用。两个模型都很能打。但今天很少看到不用 gated linear unit 训练的模型。证据指向 gating 带来一致收益。
并行 Transformer 块:一个曾经有趣但不再流行的想法
接下来这个想法我觉得很好玩,但时间已经证明它可能不那么好、不那么流行。通常 Transformer block 是串行的:先算 attention,再算 MLP,一个接一个。系统思维的人会说,这引入瓶颈,必须等一个算完才能算另一个。如果并行,也许能带来新的系统优化。所以可以问:能不能并行化 Transformer block?这最早出现在 GPT-J,也就是 GPT-3 的开源复现尝试。有意思的是,GPT-J 出乎意料地有影响力,传播了很多想法,PaLM 也是。Google 在架构上其实相当大胆。
PaLM 报告里的描述是:不要像上面那样嵌套成串行,而是把 MLP 和 attention 层的输出相加,一起加回残差流。如果实现得好,可以共享很多组件,比如共享 layer norm,融合矩阵乘法,可能获得额外系统优化。受 Google 影响的人,比如 Cohere,创始人是 Transformer 作者之一,他们做很多 Google 式优化,跟随了这个架构。但其他人不多。
过去两年,这个做法真的不再流行。主要因为串行形式的优化已经足够好,并行带来的系统收益不值得表达力损失。你可以认为这相当于损失了一半深度,对模型有害。
架构部分讲得这么短,其实说明原始 Transformer 公式经受了时间考验。我只改了 norm 位置、有没有 bias、MLP 要不要 gating,相比所有可能变化,这些都是小改动。有人会说,很多 Transformer 替代品改变 attention。是的,下节课讲。今天我讲核心 attention 方法,下次会加一点状态空间模型。只要还在 dense attention 领域,原始 Transformer 论文的架构和我们现在做的就很接近。
回到表格:蓝色是 RMS norm 作为 layer norm,大多数现代模型是 RMS norm。串行 vs 并行层,蓝色是并行,其余串行,大多数是串行。pre-norm vs post-norm,我标 post-norm 的有些其实是 pre 和 post 都有。右边这些是 GLU,几乎都是 gated linear unit,Falcon 例外。现代模型几乎都是 GLU。所以趋势很直观。
位置编码:从正弦余弦、绝对、相对到 RoPE
不同实现差异很大、而且我认为仍在变化的是位置依赖和从其他位置取信息,也就是核心 attention 组件。把位置编码进 Transformer 有很多方法。提醒一下,这非常重要,因为 attention 本身位置无关,只是内积,如果没有位置 embedding,打乱位置 attention 结果一样。原始 Transformer 用正弦余弦 embedding,类似傅里叶直觉,认为不管怎样都能恢复位置。之后一些大模型用绝对 embedding,每个位置有自己的 embedding。Google 一些模型喜欢相对 embedding:不是把 embedding 加到词向量里,而是加到 attention 计算里。偏移三个位置,attention 矩阵就加不同 offset。T5 和 Chinchilla 用这种。
真正主导的是 RoPE,2024 年后大多数模型用它。考虑到 RoPE 某种程度是凭空出现,这很了不起。最初这也是 GPT-J 的创新,来自一个不太出名的博客和论文组合,作者在中国。RoPE 是相对位置 embedding。相对位置 embedding 有一个强观点:我不该关心任何词的绝对位置。A 和 apple 一起出现,不管在开头还是结尾,在 RoPE 里应该得到同样结果。我们有一个 embedding F,另一个 embedding F,输入词的身份 X、Y 和绝对位置 i、j,我们希望它们的内积等于只依赖相对差 j-i 的函数。之前所有 embedding 都不满足:正弦有绝对交叉项,不是相对;绝对位置按名字就不相对;相对 embedding 技术上是相对,但不是 embedding,因为只是加到 attention 矩阵,没有内积结构。
于是问:有没有办法真正有相对 embedding?想法很酷,就是看角度和余弦的性质。我们希望 embedding 对绝对位置不变,而内积对任意旋转不变。所以取语义词向量,与位置无关,然后根据词出现的位置旋转每个向量,二维里就是角度。简单例子“we know”:we 在位置 0,不旋转;know 在位置 1,旋转某个角度。再看“of course we know”:we 和 know 仍然相邻,但绝对位置移动了。这里 we 在位置 2,旋转两个位置;know 在位置 3,旋转三个位置;它们的相对角度仍然相差 1。用旋转表示位置非常简单。这样任意内积都对绝对位置不变。
二维里容易,只有顺时针和逆时针。高维里旋转方式无穷多。D 维怎么办?做最简单的事,而且有效:反复降到二维。把 D 维向量切成二维块,每对维度旋转。旋转的 theta 不同:有些低频,转得很慢,能捕捉长程依赖;有些高频,转得很快,能捕捉是否相邻。每对向量旋转后得到最终 embedding。这就是 RoPE。论文用复数有很复杂的动机,但我觉得直觉就是降到二维、旋转每对坐标。Gemma 4 上周四刚出,他们有一种好玩的东西叫 proportional RoPE 或 P-RoPE,只旋转前两个坐标,这也是有效做法。这个空间里很多做法都有效。
实践中,你拿向量和正弦余弦做稀疏乘法,给输入向量 X 某种旋转。X 乘 W 乘 R 得到最终 embedding。正弦余弦看起来像正弦 embedding,但关键是要相乘,而不是把它们当 embedding 加,因为这样没有 cross terms,纯粹相对,内积里不会得到绝对位置信息。如果真讲实现,你会在常规 attention 里,根据序列的 position IDs 生成余弦和正弦角度,然后把它们应用到 query 和 key 上,可以矩阵乘,也可以手动旋转。要在 attention 层做,而不是最底部,这样每次 attention 计算都强制位置不变性。RoPE 有点绕,但理解旋转几何后就很简单。
问答:RoPE、并行层与读论文
学生问:有没有高维旋转的论文?更高维旋转从没成功过?好问题,我想没有。高维旋转,任何二维旋转在这个空间里都只是变体。你也可以用任何闭合环的流形,但我没见过。
学生问:怎么推荐蒸馏这种知识?怎么形成这种理解?好问题。我不知道除了广泛看足够多、找到模式之外的办法,这正是我在这节课里做的。另一个是小规模自己试,形成直觉和理论。读单篇论文很难,尤其现在,没有单篇论文给出语言模型所有细节。
学生问并行层和串行层的准确率差异。混合。PaLM 原始论文很自信,说没有性能下降,还有 15% 系统利用率提升。只看那个你会觉得一样好。但后来很多 Google 模型不再用,这可以看作隐含信号,可能有损失。而且没人做过并行 vs 串行的受控、漂亮消融,所以精确数字难说。
学生问 Eagle 和 RoPE 的区别?我不确定指什么。差别其实是旋转哪些坐标。很多坐标不旋转,因为低频部分转得不多,空间紧张时可以丢掉。这主要是 tiny models 的优化,隐藏维度少、activation 空间不够时。
学生问相对 embedding 没有内积,是因为只应用于 keys 吗?他们 keys 和 values 都应用,所以产生相对效果。关键是不想要 cross terms。正弦余弦 embedding 不仅有原始向量,还有位置 embedding 和词 embedding 之间的交叉项,能反推出绝对位置。所以正弦余弦也不是纯相对位置 embedding。你必须接受前提:你想要相对 embedding;接受后自然会走到 RoPE 解。
学生问那这个的问题是什么?它不能分解成内积。这更多是美学问题。如果你的约束是必须相对,并且必须分解为 f(xi) 和 f(yj),那这类解里没有它。公平地说,很多 embedding 以注入 attention 矩阵的方式工作,比如 Alibi,效果也不错。只是它不是主流。
超参数:FFN 比例、头维度与 aspect ratio
现在讲超参数。超参数是你真正要训练模型时才会关心的东西。抽象理解语言模型时不用管;一旦要实例化,你就会问:feed forward 多大?多少头?vocab 多大?weight decay 或 dropout 多少?需要正则化吗?有很多 token,还需要正则化吗?要深模型还是宽模型?没有知识时,这是很大的高维搜索空间,很吓人。但人们尝试的空间其实很小,也许可以想更聪明的搜索方式。
一个共识超参数是 FFN size 和 model dimension 的比例。这是 MLP 第一个矩阵输出维度和模型维度之比,控制 MLP 的丰富度。应该是多少?不知道为什么,它可能是 hidden dimension 的四倍。这个经验法则非常有效,我会给你数据。也有例外,有趣的是极端例外后来退回去了。
例外一是 gated linear unit 变体。GLU 参数更多;要同样参数,就要把 MLP 缩放 2/3,所以大多数 GLU 变体约 2.67。大家在 2.67 到 2.5 之间,这是约 2/3 修正。LLaMA 2 的人说他们用 MQA,attention head 很高效,所以可以把这个比例乘任意 1.33,得到约 3.5。LLaMA 人任意选了稍不同的比例,稍微强调 MLP。看论文,GLU 要么约 2.6,要么 3.5;非 GLU 是 4。
另一个例外很有趣:读技术报告,大多数人在架构上很无聊,“我们做了 LLaMA 但改了一件事”。Google 有时很大胆,T5 是我最喜欢的,因为设置很大胆。他们不用 4x,而用 64x 倍率,远大于四。他们有系统论证:矩阵乘法越大,硬件效率越高。所以把倍率做大,矩阵乘法可能更高效利用。Gemma 2 也试过更高。T5 的 64 是惊人例外,没别的模型这么高。
经验上,有更受控的比较。Kaplan 2020 经典神经 scaling laws 论文里,虽然重点不是这个,但有一个 panel 扫 FFN 比例看 loss,很小模型上。他们发现,从约 1 到约 10 是一个盆地,这个超参数很好、很平,相对最优 loss 损失很小。如果搞得很错,比如超过 10 到 100,loss 开始二次上升。所以 2.6 到 4 都落在不错的盆地,选这些数字没问题。
能学到什么?默认选择对几乎所有现代语言模型都有效,可以安全选。T5 原版也是好模型,所以激进选择技术上也能用,但可能计算不高效。最好笑的是 T5 后续 T5 1.1 改进版回到标准 2.5 倍率。没有明说,但显然更新 T5 时他们想回到更标准倍率。
另一个共识超参数是 head dimension。多头 attention 里,经典做法是让每个 head 的维度满足:head 数 H 乘以 head 维度等于模型维度 D,即 head 维度 D/H。单头 Transformer 的维度一样。当然不必如此,可以任意改比例,但大多数模型遵循,效果很好。看各种经典和新模型,比例大约 1,T5 和 Lambda 是例外,但大家基本在 1 附近。这也是一个宽容的超参数,消融显示 1 附近有宽盆地。
更关键、概念上更有意思的是 aspect ratio。放大或缩小模型时,通常固定 aspect ratio,即宽度与深度之比,然后整体放大。所以 aspect ratio 控制整个 depth-to-width 权衡。你可能问模型该多深。如果你关注推理等,可能觉得需要很深或很浅,确实变化比其他超参数大,但有明显 sweet spot。大多数现代模型 ratio 约 100,即 D_model 除以层数约 100,每层约 100 宽度。GPT-3、LLaMA 都如此。考虑部分是表达力与硬件的权衡。
极深的模型系统上很烦。深模型怎么并行?可能要切层。切层后并行化有严重问题,pipeline parallel 大多数人不想碰。宽度容易并行,宽模型可以很容易切到 GPU 上,叫 tensor parallel,简单得多。所以系统原因偏宽,表达力原因偏深,最后落在约 100。Transformer 超参数很多看起来重要但宽容,人们已收敛到大致最优。
Kaplan 等人的图扫不同大小模型的超参数,不论模型大小,最优 aspect ratio 相似,约 100,按计算方式可能略低,但接近 100 是安全赌注。还有研究者(转录里称 ETA and others)做架构变体实验,结论看上面 panel:depth-to-width 权衡很多,但扫下来真正重要的几乎只是 FLOPs。FLOPs 增加模型变好,控制大部分效应,不一定是 aspect ratio。所以出现的共识是:有一片宽容的超参数带,主要担心系统利用率,而不是难以推理的表达力。
词表大小与 bits per byte
最后一个超参数是词表大小。有趣的是两类模型差别明显。早期开源模型训练很多是单语模型,只追求英语好,词表小,约 30,000。LLaMA 之后,很多人关注多语或生产系统,包括闭源 GPT-4,词表大得多,约 100,000 到 200,000。Google 模型词表更多。LLaMA 衍生约 100,000,单语模型约 30,000。多语模型确实需要更大词表覆盖空间。右边模型也更大。Scaling law 研究表明模型越大能处理越大词表,部分由现代 scaling 趋势驱动。现在没人训练大的单语模型了。
学生问多模态。取决于 token 编码方式。如果 tokenize 图像等,需要更多 token。开源发布里通常有单独的图像 tokenizer,有自己的大词表。
学生问比较不同 tokenizer 的 bits per byte 是否有效?好问题。先回到正确心态:语言建模是生成建模任务,建模序列概率。只要序列固定、没改动,并给出所有字符串的概率,比较总是有效。问任意两个 tokenizer 的 bits per byte 是否有效,有两件事:第一,你有没有动序列?过去 subword 之前 tokenizer 会丢 token 或词,那比较无效;现代 tokenizer 完整,能建模任何序列,所以不是问题。第二,是否用长度归一化?bits per byte 总是用同样字节数归一化,所以总是有效比较。
学生追问 perplexity 和 bits per byte 是否一回事?perplexity 和 BPD 是对偶的,是的。学生又问不同 split?我没太理解,之后聊。
正则化与权重衰减:不是你以为的那样
我们讲到语言建模非常底层的细节,暴露了很多有趣想法。Dropout 和正则化是另一类有趣想法,也和机器学习 101 直觉相反。标准正则化论证:语言建模有很多数据,通常数据比能处理的多,可能除了 Google,互联网数据比 flops 多。所以大概率不会看同一数据两次,只对语料做单遍。有很好理由相信单遍 SGD 或其他优化器不会怎么记住数据。所以计算受限语言建模中过拟合几乎从来不是问题。有些人甚至只看训练 loss,因为他们相信单遍 SGD 不会过拟合。
那么该用 dropout 或 weight decay 吗?近期模型很多根本不谈这些,技术报告不愿暴露这么底层。但看实际,很多模型两个都用。weight decay 尤其流行,即使是现代高性能语言模型。这很令人惊讶。dropout 可能失宠,但 weight decay 仍然流行。为什么?这是深度学习难、架构讲座奇怪又难的原因之一:这些因素以奇怪方式相互作用。
有论文论证并展示证据:weight decay 有时不是正则化器,它与优化器交互,让优化更好。看单遍 SGD 语言模型训练在不同 weight decay 下的训练和验证 loss,没有区别,weight decay 没让验证 loss 更好,本来就没过拟合,在 x=y 线上。它不控制过拟合。但看 weight decay 结合 learning rate decay:更强 weight decay 的蓝色虚线运行显著更好,它们开始慢,但后来收敛到更好的最小值。学习率衰减时一般如此,恒定学习率时不一定,这可能更接近你的直觉。
所以很难先验推理这些选择的行为。Percy 和我设计这门课让你们动手,因为你可能发现 weight decay 其实是优化干预,不是正则化干预。记住这种意外效应会在这些设置里出现。
总结超参数:很多看似麻烦的超参数其实有标准选择:四倍法则;保持 head dim 和 head 数与模型维度一致;aspect ratio 约 100;正则化可以试几种,因为它和优化器交互反直觉。有些人仍做正则化,尽管不需要。
学生问扩散模型是否有显著不同?我没足够研究。训练大扩散模型的人不多。很多已训练模型是改造的,架构其实和 LLaMA-like 一样。如果问从头训练的最优架构,我一时不知道。
学生问为什么正则化有时有效?不是说正则化一般影响优化。Dropout 现在没人做,因为它和优化交互不好。但 weight decay 是向零收缩,可能允许更高学习率或更快衰减。所有这些项以很多方式相互关联。
稳定性:softmax、Z loss、QK norm 与 logit soft capping
我讲了很多如何通过看其他模型设计表达力强的模型。现在强调:过去几年重点不只是性能,还有稳定性。模型训练越来越贵,稳定性越来越重要。很多选择很宽容,大家做法类似,改这些不会有大性能差异。但如果训练中途模型炸掉,出现可怕尖峰,可能模型质量很差或不可恢复,花了几百万美元却无法继续训练。那很糟糕。我们不想要蓝色曲线那样到处都是尖峰、梯度范数很大。
怎么修稳定性?这是核心问题。语言模型或神经网络有稳定性问题,通常几个嫌疑对象。一个是 softmax,它有两件对稳定性很糟的事:指数,容易爆;除以两个数,也很危险。语言模型里有两个 softmax:输出概率分布时一个,attention 归一化时一个。两者都是危险区,尤其 attention。
先看输出 softmax。它能爆掉。可以控制 normalizer 问题。计算 log probability 来算 loss。log probability 是模型输出 U 减去 log normalizer log Z。U 很好,因为它是模型输出、残差流加总。如果 U 好,第一项 log P 就好。第二项 log Z 可能不好。Z 很大或很小,即使模型输出还好,也可能爆。Z 是指数,可能快速爆;如果为零,也会爆。两个方向都很糟。理想 Z 接近 1,log Z 接近 0。
能做什么?softmax 是过参数化的。加常数到 U 可以操纵 Z 而不影响 softmax 输出,normalizer 和输出会抵消。因为这个性质,可以加正则项。这是 Jacob Devlin 2014 论文,加 log Z 的平方项,惩罚 log Z 远离零。log Z 接近零,整个表达式数值稳定。这叫 Z loss trick。很多论文用过。Devlin 等人 2014 首创,后来通过开源模型流行。Baichuan 我记得是第一个用的开源模型,然后 DCLM、Almo 等用它稳定输出 softmax。意外地有效。
处理完输出 softmax,转向 attention,这是很多退化发生的地方。很多技术控制 attention 不稳定。高层说:如果有不稳定,能往里扔 layer norm 就可能控制。这是 QK norm 的设计哲学。标准 attention:pre-layer norm 后乘 QKV 得 Q、K,矩阵乘、softmax、乘 V 得加权平均,再输出。如果在 Q 和 K 相乘前加 layer norm,那么矩阵乘的输入、softmax 的输入尺度大致相同,永远约 1,因为 RMS norm 除了 Q、K 的大小。
这样保持 softmax 稳定。很多模型这么做。最初来自多模态,做多模态的人发现 QK norm,Chameleon 修复并证明,然后其他开源语言模型发现同样技巧可用于稳定语言模型 attention。现在非常标准。QK norm 是大多数大模型的标准干预。很多训练显示不影响性能,但确实防止 attention 退化。我看到的是:一开始 pre-norm 有 layer norm,现在每个 block 非线性后加,现在又加到 Q 和 K 上。这就是这个领域的稳定技巧。
最后一组稳定干预是 logit soft capping,不太流行,更像 Google 特有技巧。QK norm 控制 softmax 输入,希望输出表现良好。如果真想强制输出表现良好,可以拿 logits,直接进入 softmax 的东西,封顶,不能太大或太小。这是硬约束。叫 soft cap,但 tanh 有界。Gemma 模型用,Gemma 2、3、4 都用 logit soft cap。他们把 attention 层所有 logits 取出来,soft cap 到某个值。
Nvidia 一些人系统比较稳定干预。从 baseline 模型可以做各种干预。QK norm 在这里,稍好,因为可以调高一点学习率。单独 soft capping 反而损失性能,有质量退化。这是很强的干预,你永远无法在 softmax 里表达超过一定点的非常自信信号。有负面影响,但非常安全地稳定 attention 输出,或说 softmax 输入 logits。
注意力干预:MQA、GQA 与滑动窗口
今天最后讲 attention head 的各种干预。我只讲 dense、all-by-all attention。如果你对状态空间模型或线性时间 attention 感兴趣,今天不合适。今天要讲实际常用的:group query attention,通过减少 head 数节省推理成本;sparse 或 sliding window attention,最初来自 GPT-3 系列,现在被大多数做长上下文的模型采纳,除非用 SSM 等 exotic 东西。
先讲 GQA/MQA。先搭建需求。训练和建模之后,考虑部署:训练大模型,服务很多用户,要付服务成本。抽象上付两种资源:flops 计算,和内存访问。内存访问也影响延迟、利用率。两者都要小。训练或 prefill 时,给定 prompt,总算术操作约 batch size × sequence length × hidden dim²,还做二次 attention 有 D²。总内存访问:batch × sequence × hidden + softmax 的 N² + 投影的 D²。算术强度不错,约 1/K,K 是 head dim;还有 1/BN,序列要够长或 batch 要够大。只要两者成立,GPU 充分利用。
训练完服务用户,生成 token 发给他们。自回归生成不能并行:生成一个 token,基于它生成下一个,一个个重复。这是自回归语言模型的诅咒。高效做法是维护过去所有 keys 和 queries,叫 KV cache,然后算新内容时复用过去子矩阵,只算新的 query-key 交互填满矩阵。每个算过的子矩阵保留,只算新的。这节省计算。
但算术强度变得不好。KV cache 方法一直读参数和 memory。每一步读参数、做点积,每步都做。总算术操作一样,还是乘同样矩阵,只是增量做。但内存访问模式变成 batch × sequence² × hidden + sequence × hidden²。第二项不好,以前只是 D²,现在是 N × D²。算术强度是两者之比,现在是 N/D + 1/B。所以需要大 batch + 短序列,或非常大的模型维度。要高效服务小模型就不好。N/D 项,序列长度除以隐藏维度,在增量计算下很难降低。
这引出 MQA,multi-query attention。通常多头 attention 有不同 key、value、query。但可以所有 head 共享 keys 和 values,只有 queries 不同。这样 KV cache 显著变小,所有 head 共享,显著减少内存访问,算术强度那个 N/D 项现在乘 H,H 大时大幅提高算术强度。系统效率显著提升。
但 MQA 右边所示,所有 query 只有一个 value 和一个 key,会损失显著表达力。系统效率和表达力有 trade-off。有没有 sweet spot 避免大幅牺牲表达力和计算?这就是 GQA,grouped query attention。原始 Transformer 多头,每个 head 有 query 和 key。multi-query 所有 head 一个 key/value。grouped query 减少 key/value 数量,但保持 query 数不变。于是有一个比例可以调:key head 或 value head 的数量,同时总 head 数可以大得多。这样简单控制表达力和推理效率的 trade-off。
DeepSeek-V2 还有 multi-head latent attention,下次简要提,不同分解结构、不同 trade-off。GQA 好在实践 trade-off 很有利。多头 attention 性能最好但成本很高;MQA 成本低但性能低很多;把模型变小达到性能目标也性能差。GQA 兼顾两者:推理成本低,性能几乎和完整多头一样。GQA 组结构:稍微减少 head 数就能拿到大部分收益,保留大部分表达力,同时显著改善推理。Percy 之后会讲推理机制。今天模型几乎都采用 GQA,因为推理成本关键,表达力损失不大。
学生问:有这么多超参数经验法则,你还在多大程度搜索超参数 vs 利用经验法则?混合。每次训练都有关于什么可变的论点。报告里超参数通常不大动,但架构变化一次改一个。很少全改。Google 是少数真正大胆的组织。Gemma 系列做了有趣的事。最近 Gemma 4 每一层有单独 embedding,控制内存和 flops 的取舍。
学生问训练中会实验这些参数吗?训练中,weight decay 会改,人们常和 learning rate 一起改,这是有效启发式。其他超参数我不确定是否常改,尤其架构改了训练就不兼容。所以 architecture 训练中不能改。weight decay 可能是唯一。其他通常固定。
学生问 MQA 只是推理时固定吗?不是,训练时就用一定数量的 keys。训练时就用这种结构,是愉快的。
滑动窗口注意力与混合长上下文架构
最后讲 sliding window attention,很老的想法。GPT-3 就用,论文说交替使用 full attention(每个位置 attend 所有过去)和 banded matrix 式 attention(只能看固定窗口)。OpenAI 早期也研究过不同 attention pattern。过去一年它变得非常流行。交替 full attention 和局部 attention 在长上下文性能和推理成本之间击中 sweet spot。
近期开源模型复兴,我最早看到 Cohere Command A 这么做:每四层一个 full attention 看所有东西,中间三层用 sliding window attention 只看局部结构。往下走,局部信息聚合到全局。局部 attention 最后能访问更多全局信息。这样管理长上下文成本,而不用状态空间模型或更 exotic 干预。效果很好。
还有创新:改变长程信息的 embedding 格式,去掉 RoPE,完全没有位置 embedding,长程几乎看 bag;短程信息仍有位置信息。人们做各种干预,涉及 embedding 和交替局部/全局结构。Attention 和长上下文成本/性能 trade-off 仍是活跃研究领域,架构工作最多。
很多模型采纳这个想法。LLaMA 4、最近 Gemma 4、Olmo 3 都用 sliding window + full attention 组合,它们用 full RoPE 而不是 NoPE 作为 embedding。所以这变得非常流行。Qwen 3.5 在右边有点不同:交替一个叫 gated DeltaNet 的状态空间模型和 full attention,每四层一个 full attention。交替结构相同,但便宜层用的是状态空间模型,下次讲。这是过去一年的新主题:开源模型努力处理长上下文性能,目前方法是混合模型,不只是全局 attention,也不只是廉价 attention,而是混合。目前看效果很好。
最后总结:看所有这些模型,能看到很多模式,也希望对你能做什么、什么对人们好有一般理解。我们也看到处理上下文、位置 embedding 的很多差异,甚至 tokenization 也有差异。模型之间有差异,也有共性,希望给你直觉,去做作业、玩 leaderboard。谢谢。
注意力替代方案与专家混合:长上下文、线性注意力与 MoE
这堂课讲 Transformer 的两个高级架构方向:一是用线性注意力、混合架构和稀疏注意力降低长上下文的注意力成本;二是用 Mixture of Experts 替换 MLP,在不增加每 token 计算量的情况下增加参数量,并讨论路由、训练、系统和 DeepSeek 的设计演进。
课程开场:长上下文需求与注意力成本
好,我们开始。抱歉这节课的幻灯片今天早些时候还没上传,现在已经放上去了;如果你想边看边对照,可以通过课程网站获取,GitHub 上也一定有。今天我们要讲的是我认为更高级的架构想法。上一讲讲的是基础 Transformer,以及怎么调整基础 Transformer 的不同部分,得到一个现代语言模型。现在我要讲在通常 Transformer 之上更高级、更复杂的进展。今天讲两件事:第一是注意力替代方案,也就是通过架构改动走向更长的上下文,这些方法大体上让计算对序列长度呈线性依赖,而不是二次;第二是 Mixture of Experts(专家混合,MoE)的想法。第一部分修改注意力块,第二部分修改 MLP 部分。MoE 会在硬件利用率方面给我们显著更好的表现,让我们在花费的计算量相对固定的情况下拥有更多参数。这就是今天的两个部分、两个想法。
我先从注意力开始,先把背景铺垫好。现在人们显然想要更长的上下文长度,想把很多东西塞进上下文里,让模型拥有更多知识;也许模型是一个 agent,要在很多东西上操作。如果看不同模型随时间的变化,横轴是时间,纵轴是上下文窗口大小,这里是 log 尺度,可以看到很多顶级 LLM 厂商有一个非常明显的竞赛:提供越来越大的上下文窗口,来支持这些更复杂的工作负载。右边这张图还展示了随着序列长度增加,前馈网络部分和注意力部分所花计算成本的比例。前馈网络一开始占比很大,但线性增长;注意力是不同位置之间的 all-to-all 连接,是二次的,所以随着序列长度增长,很快超过前馈网络。过去对于大模型、较短序列长度,前馈网络是主要成本;当你走向这些更长的序列长度,注意力越来越成为一个问题。
那么控制这些成本的基本工具箱是什么?我们已经谈过其中一个,还会再谈另一个。我们可以把 local attention(局部注意力)之类的东西,用不同的混合方式组合起来,控制全局注意力的高昂成本。如果你每八层才做一次全局注意力,其他层都是非常局部的注意力,你就很好地控制了成本。另一件事是系统工程。我认为一个被严重低估的事实是,常数因子真的、真的非常重要。这门课的主题之一就是,你需要关注细节。很多受经典理论导向的计算机科学训练的人很容易说,重要的是大 O,线性还是二次。但我认为,对上下文和注意力成本来说,最大的事情之一就是 FlashAttention。等我们上系统课、做系统作业时,会更详细地讲 FlashAttention。它其实只是一种非常聪明的办法,把注意力操作重排成对系统更友好的形式,以最小化内存传输开销。这样你可以在注意力性能上得到真正巨大的提升。基础 PyTorch 是蓝色的那条,在较短的序列长度下,你可能每秒做 30 到 40 TFLOPs。然后你用 FlashAttention,它本质上是一个常数因子的系统改进,却能得到很大的提升,两倍的改进。在某些尺寸下,你甚至已经无法把注意力矩阵放进内存;但用 FlashAttention,你不再物化那些大的注意力矩阵,就能继续跑,虽然可能慢一些。它不解决任何二次成本问题,但常数因子非常强大。
不过,如果我们要到 500 万、1000 万 token,这些技巧可能还不够。我们希望在处理长上下文的能力上获得更激进、更大的收益。所以问题就是:有没有办法让我们对序列长度有线性时间或线性依赖?这些东西会是什么样子?
事实证明,过去很多年里很多人试过很多不同东西,也有一些错误的开始。但最近几年,我想说大约最近两年,出现了一套相当有效的线性时间注意力配方,并且已经在规模上经过实战检验。这也是为什么今年是我第一次讲线性时间注意力,因为现在已经清楚,这些东西在规模上、在生产中确实能工作。要真正解释这些有效方法,我认为你一开始只需要理解一个核心想法,然后我们再展开、在此基础上构建。这个核心想法就是乘法的结合律。
线性注意力的核心:乘法结合律与 RNN 对偶
我们来想注意力怎么工作。可以用紧凑记号写成 Q、K、V 的注意力操作,矩阵维度在上面。我把 Q 和 K 拿来,让它们做 all-to-all 交互;n 个位置之间通过矩阵乘法彼此交互。然后用 softmax 按行归一化,再乘以 values V,得到注意力输出。这就是经典的 self-attention 或其他注意力操作。
现在,先暂时忘掉注意力里的 softmax,把这个行归一化丢掉。如果没有行归一化,也许我们真的可以改变这个操作的复杂度。注意力本来是 n 平方 d_k,因为我们做 QK^T 这个乘法。但如果 rho 是恒等映射,我就可以把括号挪一挪,说:与其做 QK^T 再乘 V,不如先做 K^T V,再乘 Q。这样一重排,我们就改变了哪一部分是二次的。原来有 n 平方,这些 n 非常糟糕,因为它们是上下文长度,是几百万。现在我改成依赖 n d_v d_k。也就是说,现在乘的是 key 和 value 的维度。原则上这些也可能很大,我可以有一个大模型,但它们通常是几千、几万这个量级;没有人隐藏维度有一百万个坐标。所以相比原来的 n 平方项,这是一个有利得多的依赖项。这是你首先应该知道的事情:乘法的结合律允许我们改变对序列长度的依赖。
现在还有一件事。如果考虑这个重排 Q K^T V,更漂亮的一点是——我认为这也引出了我接下来几页要提到的大量研究——在 self-attention 的情况下,右边这个矩阵乘法看起来就像 RNN。与其写成上面这种稠密形式,我可以增量地写:当我从左到右扫过上下文,我可以乘我的 K 和 V,更新这个状态 S,把它加到前一个状态上。这就是增量地相乘并累加 K 和 V。然后我再乘 Q 得到输出。上面这个稠密操作和这个 RNN 形式是等价的。所以我们做了什么?从左上角的线性注意力出发,最后得到了一个非常像 RNN 的东西。RNN 好在哪?RNN 在推理时很好,因为我有状态 S,它大小固定,我把它一步步往前带。RNN 对推理很好,对训练不好。线性注意力的好处是你可以两种方式都用:稠密形式适合训练,是并行的;串行形式像 RNN,适合推理。所以你得到两全其美。至少从这些对象的依赖关系看,这很好。但不幸的是,它是线性的,这并不很好。这真正是我们讨论的起点。到这里有问题吗?这其实是基础。
从线性注意力到 Mamba-2、Gated Delta Net 与混合架构
一个很酷的事情是,已经有人把这种线性注意力用进他们的注意力机制里。比如 MiniMax M1,一个相当大规模、性能很高的中国开源模型,用了 7 比 1 的混合:七个线性注意力层加一个完整的 softmax 注意力层。性能总体很强,可以和 OpenAI 的 O3、DeepSeek-R1 这类强模型比较,相当有竞争力。而且因为它是线性注意力,大部分对上下文长度的依赖是线性的;不是完全线性,因为还有那些 softmax,但依赖要温和得多。所以这是一个例子,说明即使是非常简单、容易理解的基础线性注意力,只要有些完整的二次连接,也能在规模上相当有效地使用。
不过,到目前为止,还没有人真正在规模上证明完全线性时间的注意力机制。我接下来几页要讲的每一个东西都是混合的。
你可能会说,线性注意力太天真、太简单,我想要更严肃、更复杂的神经网络东西发生。那么现在你可以做的事是——抱歉,我翻得太远了,看看线性注意力的 RNN 形式。我把线性注意力方程放在这里,免得来回翻。我们可以在线性注意力上做一个小扩展,得到更有表现力的更新。这样你就得到 Mamba-2。有些人可能听过 Mamba、Mamba-2,现在还有 Mamba-3。这是 Albert Gu、Tri Dao 和朋友们做的一族状态空间模型(state space models)。它们最初来自状态空间理论的观点。但如果你看实际机制,其实可以把它看作对线性注意力机制的一个非常简单扩展。想法是这样:我们从线性注意力出发。你可能会说,线性注意力的主要问题是我总是把状态往前传。我们从 LSTM 的旧时代知道,什么时候把信息往前传、什么时候不往前传、什么时候遗忘并清零,是很重要的。所以受此启发,我加一个门 gamma_t。Gamma_t 不是状态相关的,它只依赖当前输入 x_t。Gamma_t 调制我要把多少状态带到未来。你想想,状态相关的项都是这些 S;gamma 完全不是状态相关的,所以这非常容易计算。而且仍然有对偶性:Mamba-2 项既可以作为一个大的稠密矩阵乘法来算,也可以在推理时用循环形式,获得推理改进。所以这就是这个想法的核心:你可以像线性注意力一样并行用,也可以按序列用。
人们现在已经在规模上用了它。比如 Nemotron-3,我上一讲简单提过,它把 Mamba-2 作为轻量层,然后时不时加上大的 softmax 注意力。他们用不同方式交替这些层,来管理推理成本和表达能力的权衡。Nemotron-3 相比 Qwen3 thinking、GPT OSS 等取得了相当好的性能;而且因为有这些 Mamba-2 层,在相当大的上下文长度下也有很好的吞吐。所以你能看到它是有效的。至少这些是小规模前沿模型,不是真正的大前沿模型,但是开源的、小型前沿模型。
我们已经在这个门控想法上构建了,很酷。但你可能会说,我们能不能继续推进这个想法,把循环变得越来越复杂?这里的经验法则是:只要你在 RNN 里得到的各项只依赖输入,不依赖状态,那么你仍然会有这种很好的对偶性——并行操作用于训练,串行操作用于推理。Gated delta net 可能是现在使用最广的状态空间模型之一。我觉得它已经在不少论文里被测试过,在 3.5 里也有很好的大规模放大。
让我带你比较一下 gated delta net 和 Mamba-2。Mamba-2 就像上面这样:我们取线性循环,也就是状态更新,然后用 gamma 门控它。哦,我忘了解释这个 vt。抱歉。我为了完整性加上它。Mamba-2 还有一个修改,基本上是在 yt 更新上加一个残差层。它不是状态更新或他们论证为什么这个想法好的核心,但我为了完整性放进来,确保给出真正的 Mamba-2 更新,而不是只传达核心思想的简化版。现在你可以忽略 vt;它只是架构上的改进,不是状态更新的核心。
Gated delta net 在某种意义上接过了这个想法,并加了第二个门 beta_t。Beta_t 做什么?我把它想成“不输入”操作的门。如果 beta_t 是 0,基本上意味着不要拿当前信息,不要把它加进我的状态。我想你们大多数人应该熟悉 LSTM。这非常像 LSTM 里的想法:你有门控制是否遗忘,是否把当前时间步的信息放进去。所以它遵循非常相似的原则,虽然当然最初推导方式很不同。
Gated delta net 里另一个有趣的东西,来自 delta net 而不是 gated delta net,是更新方向。你不只是门控;你也可以把它写成 1 减 beta_t。他们做的是:如果你要做一个更新,放入这个 beta,那么你要做的是试着把当前 key 方向投影出去。直觉是:我要从当前 key k_t 写入信息。做这件事时,我不只想放入新信息,我还想擦除之前已经进去的 key。这是理解更新的一种方式:不只是放东西进去,还要清掉已经进去的当前 key。所以这里有这个蓝色项,identity 减 beta_t k_t k_t^T,它本质上是一个投影器,把 k_t 维度上的东西投影出去。严格说这不完全准确,因为你没有做单位归一化之类的事情,但大致上你可以得到这个直觉。
我还要提一下,在我继续 beta delta net 之前,这个蓝色框里的更新、这个投影器,在各种场景里被重新发明过。如果你去解某些元学习最小二乘问题,这个更新、这个投影器就会出现。它在 fast weight programming 或 test time training 里以其他形式被重新发明;来自那些领域的研究者通过非常不同的设计原则,最后得到了完全相同的解。这里我要说的是,Qwen 3.5 和作为其前身的 Qwen Next 模型,是今天最好的开源模型之一,它们用的正是这个架构。它们用了 3 比 1 的 gated delta net 注意力混合。性能非常合理,推理特性很强,右边可以看到。随着上下文长度不断增长,Qwen Next 相对 Qwen3 有高得多的解码吞吐。中间面板比较了性能与各种闭源模型和上一代模型。这种混合架构似乎完全不怎么损害性能。
最后我要说的是,关于混合架构表现如何,还没有很多很好的受控研究。也许有一个来自 ByteDance Seed 和 UC Santa Cruz 的研究,他们比较了 Mamba-2、gated delta net 和其他架构,作为混合层数量的函数。有些结果我会说是 messy 的。但我会指出,右图这条虚线是完全注意力性能。从左到右,非完全注意力层,也就是我们叫 RNN 层的数量在增加,你会看到性能退化。但也许这里要看的是,对一些最好的架构——黄色、橙色、蓝色,这些是 gated delta net 和各种变体——在低比例时基本没有损失。一旦超过某个点,你就会看到长上下文性能更明显的退化。基本上最后,当你走向完全 RNN 时,所有这些架构都有非常明显的性能退化。在其他各种评测上也一样。单 key retrieval 是所有这些长上下文架构似乎都显式优化的任务。但如果你看 QA 性能,我们看到同样的故事:随着混合比例增加,性能相当稳定、相当清楚地下降,最终落到纯 RNN 那个点。关于线性时间注意力还有问题吗?
有同学问,如果并行形式和线性形式看起来数学等价,为什么性能会下降?老师说,是的;但要注意,我说的 recurrent 是指一般 RNN、任何循环形式。Gated delta net 是一种循环,它并不等于最上面这个完整 softmax 注意力。最上面这个是有行归一化 softmax 的完整注意力。第一步是有损的:我们丢掉 softmax,然后变成线性。这是所有这些方法非常、非常第一步。所以这一部分是有损的。之后,从线性形式到循环形式,这个等价才是精确的。
又有同学问到一个项,老师说是的,就是这一项。在线性注意力形式里,我们真正做的是拿这些 K、V,用 Q 来累加它们;在对偶形式里就是这样操作。但你可能希望输出里也保留当前时间步的一些 value。你可能觉得当前 token 的 value 对下游各种事情有用。所以这有点像残差连接,当前输入的 value 直接传到注意力输出;而 D 是另一个调制门,可以控制有多少直通。
好,这就是线性时间注意力。有很多非常有趣的东西。也有趣的是,很多被测试的方法最终都有这种很好、很简单的线性注意力风格的循环形式。有一些例外,但大部分最后都收敛到非常像 LSTM 的对象。
稀疏注意力:DSA 与 TopK 索引
还有另一种替代方案。我刚才讲的是线性注意力,它有很好的复杂度理论味道。我想谈另一个注意力优化,我之前没讲,但它属于这类重要的高效注意力。DeepSeek 在 DeepSeek 3.2 里用了 DSA,也就是 DeepSeek Sparse Attention。他们做的不是在所有 token 上计算注意力,而是先有一个轻量索引器(lightweight indexer)选出一批 token。你看着很长的上下文,索引器从中选出一个子集,比完整序列小得多;然后你在这个更小的子集上做完整注意力。前向传播的机制其实相当简单:你有正常的 Q 和 K,然后把它们送进索引器。索引器取 q、k 内积,过一个 ReLU,然后有从前面的 token 导出的权重,这给你每个位置的激活。然后你过一个 top k,把一些最高位置纳入注意力计算。接着你像平常一样做注意力计算,产生 key value。好处是它非常、非常稀疏。如果索引器很轻量,那么底部这个做完整注意力的项,就可以在一个很小的子集上完成。
DSA 和 GLM 都表明,你实际上不需要用这个来训练模型,因为用这个索引器训练可能很麻烦、很复杂。你只要训练一个正常 Transformer。当你做长上下文扩展时,再插入这个 lightning indexer,然后在一个独立于预训练的扩展阶段训练模型处理这个索引器。所以做这件事的计算成本本身也相当轻。我想强调,在 DeepSeek v3.2 提出 DSA 时,这个模型相当好。v3.2 匹配了当时其他前沿模型,比如 Claude 4.5 Sonnet 或 Gemini 3。他们还给出了一个很好的图:prefill(处理输入)和解码(生成)的扩展性都远比上一代没有用稀疏注意力的 DeepSeek 模型好。这很合理:如果你只注意一个很小的子集,做注意力的成本就会大幅降低。另一个正交验证是去年出的 GLM5。我认为 GLM5 是现有最好的开源模型之一,非常强。它也采用了 DSA 方法。他们在论文里有相当不错的消融,比较 DSA 加某种 warm up、只做 DSA、完全不做 DSA。主要展示的是,如果做完整 DSA 训练,相对完整注意力性能损失不大,即使在长上下文检索任务上也是如此,而这些任务对 RNN 风格架构来说相当难。所以这是另一种降低注意力成本的视角。
注意,回到上一页,这不是线性时间。索引器仍然要在 full all-to-all 注意力上操作,因为你有这些 q k 内积,成本不是线性的。但你可以让索引器便宜得多,比如让它更低维,或让 w 很小。所有这些都可以让成本更便宜。所以这是完全另一种视角,效果很好。我不会多讲这个。但事实证明,top k 选择这个想法会变成下一部分讲座的核心。在进入 MoE 之前,关于高效注意力还有问题吗?
有同学问索引器的时间复杂度是不是 n 平方?老师说是的。要知道该选什么,它必须看所有东西。这里没有巧妙的状态转移,真的是暴力内积。所以如果你遍历所有内容,后面那一步也是二次的。那怎么得到时间节省?老师说,这几乎是一个系统技巧。你把索引器做得轻量得多,有很多办法:可以用低得多的精度,他们谈到了;可以更低维,通过把 q 和 k 进一步投影,只用于索引。所以有很多技巧,但索引器会轻量得多,常数因子非常好。然后第二个步骤虽然是二次的,但它是在更短的上下文长度上二次,因为它是 top k,我们可以控制 k。所以现在这个昂贵的部分很小。这也是另一件事:有时候不要太纠结二次还是不二次。有时候常数因子真的、真的重要。
有同学问,这是不是也属于 post-training 之后的步骤?老师说,这更像是 continued pretraining 风格的东西。因为所有模型由于计算和其他原因,不会从头训练长上下文;你先训练一个较短上下文的模型,然后有长上下文扩展阶段。这里的好想法是:反正我们都要做第二阶段,为什么不把长上下文成本节省也装到同一个阶段?令人惊讶的是,它真的能工作。老实说,你可以把这样一个看起来很吓人的、TopK 的不可微对象装上去。但你会在下一部分看到,也许你不该那么害怕 TopK 选择。常规配方通常是短上下文预训练、长上下文扩展,然后 post-training。这里的 k 会选得接近短上下文性能,并且会被限制边界,不管输入多大都有界。
有同学问,去掉 softmax 会不会导致训练不稳定?老师说,这是个好问题。我不这么认为。我不知道有文献记录说去掉 softmax 会导致很多训练不稳定。如果有的话,softmax 通常才是各种问题的来源。所以我认为这实际上会改善架构的稳定性。
有同学问,能不能预测未来的注意力机制,考虑到低计算成本和低延迟的需求,高层看会有什么特征?老师说,我不知道我能不能对未来的注意力架构做出好的预测。我有点回避的回答是,很多会看起来像我们把所有技巧都扔进去。就像架构因为把所有成功配方都扔进去而变得非常复杂一样,我认为还会有一个更广、更高层的层面:用 post-training 让模型管理自己的上下文。压缩、检索,这些东西基本上都是加在上面的一层。我想很多工作会是这些不同层面的整合,因为至少线性时间注意力已经大量收敛到 LSTM 式或线性注意力式架构。我在近期不觉得这会变太多。
最后一个问题,能不能谈谈低精度注意力?FP4 注意力可能吗?老师说,我认为索引器部分就是被这类东西启发的。FP4 注意力肯定可能。我认为缺点是,对于 softmax 这类东西,精度损失会很重要,小的下溢、上溢之类的问题有时会相当麻烦。所以这个动机——用低精度做选择,然后再做全精度计算,因为这样 value 向量可以更精细地相加和加权——是有道理的。我不知道有没有很好的办法用 FP4 甚至更低精度做完整注意力。
有同学问,和 Transformer、状态空间模型相比,训练更复杂的状态空间模型的缺点是什么?老师说,好问题。肯定有缺点,否则所有人都会用。缺点我认为是表达能力。softmax 注意力里的 all-to-all 连接非常强大,也很容易训练。和比如 LSTM 相比,注意力过去有一个很强优势:硬件效率,可以并行训练。我认为状态空间模型之所以流行起来,尽管和 LSTM 很像、在表示能力上有类似缺点,是因为现在有了我讲过的线性注意力带来的、被很好理解的对偶性:可以从 RNN 形式走到更稠密的矩阵乘法形式,而矩阵乘法形式允许计算效率。所以这个权衡现在被解决了。但你仍然有另一个权衡:如果你有有限状态,必须把所有东西都塞进去、带着走,相对于直接携带所有东西,你会损失信息。在状态空间里,你必须考虑上下文长度相对于状态大小的关系。如果你状态和上下文一样大,那就没问题,但你要付非常大的成本。所以关键是,没有免费午餐;如果你想要非常小的状态,就很难把大上下文里的所有信息压缩进去。也许某天可能没有权衡,但到目前为止,权衡就出现在这里。
什么是 Mixture of Experts
好,现在我要讲 Mixture of Experts。我认为 MoE 值得讲。概念上,它们并没有真正改变游戏。理解 MoE 的一种方式是,它们只是一个更高效的 MLP。你拿出 MLP,然后不知怎么有人给你一个更高效的 MLP。这就是 MoE。但我认为你需要理解 MoE,有两个原因:一是现在每个人都在做 MoE、发布 MoE,如果你要深入理解语言模型如何工作,就应该知道它是什么;另一个原因是 MoE 实际上有一些非常有趣的机械组件。如果你看 MoE 怎么构建、用了哪些原语,你很快会在很多地方看到类似 MoE 的原语,然后你会意识到,哇,我可以用神经网络做更有趣的事情。所以 MoE 有趣、值得学,还有更广泛的原因。
什么是 MoE?MoE 在某些方面非常简单,它们就是替换你的 MLP。左边是 Transformer 里通常的 MLP:你有注意力,有某种归一化,然后后面有一个前馈网络。大量稠密、大的信息处理发生在这里。现在我可以做的替代方案是,假设我拿这个大的 FFN,把它切成更小的 FFN。现在我有四个前馈网络。实际上,假设我不把它们切成更小的,而是让它们和原来的 FFN 一样大。然后通过某种魔法,我有一个系统告诉我每个输入该选哪个 FFN。现在我有 4 倍参数,至少对 FFN 来说有四份 FFN 参数;但在任何前向或反向传播中,我只付一个 FFN 的成本。这很好。所以从某种意义上,MoE 最初的动机,如果你读原始论文,有一个非常参数中心的观点:如果你只是想要更多参数,因为你相信更多参数是好的;好,如果我们想要更多参数,又不想付更多参数的成本,你就需要右边这个东西。所以这是你应该有的 MoE 心智模型:在不受 FLOPS 影响的情况下增加参数量。
那它们为什么这么流行?现在从 Hugging Face 之类的平台拿到的模型,超过一定大小后,每个似乎都是 MoE。原因是,看起来不管为什么,如果你保持总计算量不变,只是增加稀疏参数的数量,模型总体上会变得更好。所以这可能是支持“更多参数通常就是好”的证据,即使一次只有一部分参数是激活的。我会在本讲中大量引用一些早期的 Google 论文。这一篇是 Fedus 等人在 2022 年的,我记得这是 Switch Transformer 那篇。他们展示了,当你增加专家数量时——激活参数保持不变,专家数量增加——语言建模的测试损失一直在下降、下降、下降。如果你只关心前向传播的 FLOPS,这太棒了。右边你看到类似的事情:随着训练计算量在 x 轴增加,如果在相同训练计算量下有更多专家,你就能得到更好的性能。所以对训练和推理来说,MoE 都给你免费的胜利。这非常美妙,非常棒。
这一点已经被很多人反复看到。左边是 Fedus 论文里同一张图。去年 AIQ 的人发了 OlMoE 论文,这是他们的开源专家混合分析和训练论文。他们也看到同样的事情。如果你看训练损失、验证损失或下游 benchmark 性能,你会看到训练时这些 MoE 模型比对应的稠密模型做得好得多,大约快两倍左右。所以你能真正看到稀疏性的好处。我认为证据就在布丁里。如果你看所有发布的 MoE,它们在激活参数方面比稠密模型强得多——如果你关心推理 FLOPS,激活参数才是关键;或者甚至从训练和服务的总成本、全包成本来看也是如此。DeepSeek v2 和更早的 DeepSeekMoE 出来时,相对其他人训练的大量稠密模型,是一个很大的转变。你看到,哇,我们激活参数少得多,MoE 性能却一样好,甚至更好。MoE 这里真的发生了很好的事情。
最后,为什么这重要,还有一个原因:MoE 给你另一个并行化轴。我们会在三节课左右后的系统课里讲并行化。并行化实际上是 LLM 里非常重要的事情。LLM 很大,无论训练还是推理都放不进单个设备。所以你想要很多不同的切分模型的方式,来高效服务或训练。MoE 有一个额外好处:每个 FFN 里的专家天然是不同块。每个专家是一个自然块,你可以把它们放到不同设备上,然后把激活路由到不同设备。我们后面会讲 expert parallel,也就是这个。但这给了你一个额外的系统优化点,如果你想优化服务,这非常有用。
在西方,我认为有几个开源发布的大而强的 MoE。大模型方面有 Llama 4 和 OpenAI 的 GPT OSS。不幸的是,西方开源模型发布大体上停滞了,所以现在发布的不那么多。但至少有两个好例子,都是各自领域的顶级模型。不过我认为 MoE 研究和训练的大量动作发生在中国。Qwen、DeepSeek,还有一些别的,比如 miniCPM,做了最早训练和推广 MoE 的工作,并且继续做非常出色的 MoE 工作。如果你看一些早期 Qwen MoE 模型,这是 Qwen 1.5 MoE。他们展示,他们那个小的 27 亿激活参数模型,比当时很多 7B 模型表现更好。我认为 DeepSeek 和 Qwen 的这些早期工作说服了开源社区其他人,这是正确的方向。
说到 DeepSeek——有同学问,expert parallel 不是会因为专家之间通信而产生通信瓶颈吗?老师说,是的。某种意义上,你要付把激活发过去的通信成本。我不会解释所有细节,但会给你们看一个减少通信的技巧。总体来说,你在做权衡:你得到更多聚合 FLOPS,减少内存使用,作为交换要付通信。所以这非常依赖你的拓扑和其他东西,来决定是不是净收益。
有人问训练时的情况。老师说,这是重要部分。训练时它们也是稀疏的。这正是 MoE 难的地方。我提前剧透几页后的关键想法:如果训练时激活所有专家,会很容易,因为那样我们就能看到哪个专家适合我的输入,就能学会路由。MoE 的难处在于训练时只有一个或 k 个专家激活,所以你不知道其他专家发生了什么,只知道激活的那些。尽管如此,你还必须学会路由。所以这个问题有 RL bandit 的味道,但我们不会用 RL 或 bandit 解决,而是用启发式和深度学习魔法的力量。有人问专家切换的粒度。粒度,也就是我们路由专家的尺度,是 token 级别的。每个 token 得到一个专家。为了让你有正确的心智模型,路由器非常朴素:它们只是输入和某个东西之间的一个矩阵乘法。所以你不会做复杂的事情。你不会知道这是不是医学问题;你只是说,哦,这个 token 看起来像日语,把它路由到专家 7。还有一个问题,MoE 模型在训练或推理时并行化有没有上限?一般来说,并行化有上限,因为当你分片到更多设备上,通信成本会爆炸。如果我们把设计作业做对,你们会在作业里处理一些这样的问题:我们会要求你想出网络拓扑之类的,让分片有意义;或者反过来,给定网络拓扑,你要想出怎么分片模型。
说到 DeepSeek,现在它以 DeepSeek R1 等闻名。但我真正喜欢 DeepSeek 的一点是——最初这门课原本有一版就是过一遍 DeepSeek 论文,最早那一版,因为他们在 LLM 科学研究里做了很长时间非常好的架构工作。DeepSeek 是较早展示为什么 MoE 好的工作之一。他们在消融实验方面做了大量优秀工作,比如,如果我们用稠密层会怎样?如果用哈希路由会怎样?如果用 switch router 会怎样?所有这些不同设计决策,你可以在他们早期的一些 DeepSeekMoE 论文里看到。所以如果你对架构、设计等问题感兴趣,我建议你读早期 DeepSeek 论文。从这些论文里能学到很多东西。当然,进展没有停止。DeepSeek v3、v3.2 以及 GLM 等,已经构建了这些出色、非常强大的 MoE 模型。很明显,这就是这些大模型未来发展的方向,在可预见的未来。至少我想接下来几年都会是 MoE。
那么现在的问题是,为什么 MoE 没有更流行?在很多方面,我觉得 MoE 被接受得相当慢。你看其中一些论文,它们是 2022 年的。Google 当时在研究并真正尝试推动 MoE。但直到 2024 年之后,MoE 才真正开始流行起来,为什么?另外,如果你做各种 LLM 研究,你很可能主要使用稠密模型,那为什么不用 MoE 模型呢,如果它们这么好?我们可以节省计算。MoE 带来了很多复杂性。训练 MoE 或使用 MoE 并不容易,做很多事情也不容易。基础设施非常复杂。刚才有几个关于并行化的问题,一般来说这是对的:很难用高效利用的方式并行化专家。MoE 有很多参数,很难放进单个设备。如果你训练它们,MoE 真的可能炸掉。你会在几页后看到为什么 MoE 真的很难处理。它们不是容易操作的对象。现在有一些好的经验法则,但并不容易。
我会用一页简要提一下,也有人把专家混合用在 Transformer 的注意力块里。有几篇论文,但少得多。我看到的那些,相比直接替换 FFN 或 MLP 层,不太容易驯服或跑通。所以我只讲左边这个,也就是所有大模型基本都在做的:人们大多数不是在把注意力头拆成专家。
MoE 的路由设计
好,以上是关于 MoE 的广泛行业概览和现状。现在我们来谈你怎么设计 MoE、怎么思考 MoE。为此我会讲三个不同的变化轴。MoE 有哪些东西可以变?你可以改路由函数。我们知道 MoE 的核心是路由器把 token 发给专家,所以可以改这个。你可以改不同专家的大小。在给定预算下,你可能有更多专家、参数更少,或者专家更少、参数更多。最后我们会讲训练,因为训练会真的很难。一旦你开始意识到 MoE 怎么工作,你就会发现,哇,训练一个把 token 路由到不同地方的东西并不容易。
在所有情况下,我们都不想激活所有专家。我们只想让一部分专家激活。所以我们总是会选择某个 TopK。有不同的选择方式:你可以让 token 选专家,也可以让专家选 token;每个专家挑自己喜欢的 token,或者每个 token 挑自己喜欢的专家;或者你可以有一个非常复杂的路由器,全局决定这个 token 该去这个专家,另一个 token 该去另一个专家,试图全局优化分配。基本上几乎所有 MoE 都做 token choice TopK。也就是 token 选择专家,选 k 个不同专家进入池子。你可以看到,这是 OlMoE 论文里的图,比较 token choice 和 expert choice。你会看到 token choice 得到更低的验证损失、更高的下游 benchmark 分数,相比 expert choice。也有成功的 expert choice 模型,训练也正常。但 token choice 一般更容易跑通,并且是今天所有我们看到模型的标准。我想那四个模型里可能有一个是 expert choice。但我不认为这一定是很强的信心投票。
我觉得路由有很多不同做法。一种做法是,你有一个小的线性投影,说:好,我拿到输入;我的路由器计算内积;每个专家有一个向量方向;内积空间里最近的那个会被选中。这是到目前为止最常见的路由器。它用在经典 Switch Transformer、GShard 里,这两个是早期 Google 论文。它还用在 Grok、Mixtral、DBRX、DeepSeek 等。你选的 k 数量取决于你看哪篇论文。但大多数情况下,你就是取内积。
有一件事一直让我觉得有点神秘:很多很多论文都表明,实际上你不需要做任何学习路由。你可以直接把 X 哈希,发到不同的前馈网络。实际上这也行。这带来收益,虽然没有 TopK 那么多,但不知为什么,基于哈希的专家路由也是人们会做的。论文里常见 baseline,但部署中不用,原因很多。你也可以用 RL 学路由。你可以有一个内积路由器,然后用强化学习把这个路由器当成某种策略来学习。如果你是机器学习经典学习理论导向的人,我认为这应该是思考这个问题的自然方式。你会说,我在做什么?这是一个 bandit 问题。我从 k 个里选一个,或从 n 个里选 k 个,而且我观察不到所有。这是 bandit 问题,我会用 bandit 或 RL 算法。它在一些最早的工作里被用过,因为这是自然的思考方式,比如 Bengio 2013。但它不是常见方法。我认为原因是,做 RL 的方法会引入很多开销,包括 RL 算法本身和随机性。人们发现,有一套好的启发式、这些配方,可以应用到非常简单的经典 TopK 路由方案上,让它正常工作。所以没有理由做更复杂的事。最后我提一个我觉得很酷、也很喜欢、因为我是喜欢合理东西的人:你可以解一个线性分配问题,全局计算“这个 token 去那个专家有多好”的分数,计算所有成对分配分数,然后用线性分配精确求解专家到 token 的最优分配。它被证明在某些情况下有好处,但完全没有在规模上被看到。我认为是因为它相对其他方法极其昂贵。
所以就像我说的,TopK 路由基本上是共识路由机制。它怎么工作?非常简单。如果你在 DSA 那页认真听了,这看起来很像 DSA。如果你读 H-Nets 或其他论文,你也会注意到。这种东西会在很多地方出现。所以这是值得识别的一种模式。你要做的是:拿输入,过一个前馈,然后计算某种分数。分数是专家和输入 u 之间的 softmax,这个 s 就是分数。然后我选这些分数里的 TopK,作为控制每个专家的 gate。所以这就是输入,这是残差项,这是 FFN,我有 gate,gate 由 TopK 控制。这就是你想象的 TopK 路由器的方程形式。你可能在这里唯一新学到的细节是,我的 gate 怎么学?gate 就是拿每个专家的权重和输入做内积。非常轻量。所以选择 gate 的方式没有什么复杂。
即使在这个简单东西里,DeepSeek 和朋友们也做了创新和变化。DeepSeekMoE 开创了一个现在被广泛使用的想法,叫 shared experts(共享专家)。这非常直观:经典 TopK 路由器里,你有 n 个专家,在其中选择。但有时你可能会说,有一些非常常见的处理我想应用到所有 token。也许你不想专家每次都不同,你希望一些专家总是被应用,另一些专家才条件性地应用。所以 DeepSeekMoE 做的是:他们拿原来的专家设计,把专家切成更小、更细粒度的块,其中一部分细粒度专家作为共享专家,永远打开。它们绕过路由器,总是处理输入,然后加回输出。抱歉。这是个好想法,因为事实证明,在经典专家设计里,你其实在重复使用很多权重做常见建模。所以你可以把这部分卸载到一个共享专家,让其他专家更专门化。
就像我说的,DeepSeek 很好,因为他们在很多论文里做仔细消融。他们有一个很好的消融:零个共享专家、大的专家、16 个路由专家,这其实是旧的 GShard Google 设计;对比非常细粒度分割的专家加一个共享专家。随着你把专家做得越来越小,并且有一个共享专家,你会从这两个干预都看到显著收益。对其中一些任务,共享专家有非常显著的提升,比如 TriviaQA、NaturalQuestions。这个蓝色到黄色的差距,就是有没有共享专家。所以更多专家、共享专家,一般似乎都有帮助。如果你想看到这些说法的更多验证,OlMoE 也显示类似结果。OlMoE 是西方仔细受控的 MoE 研究。他们做了比如共享专家有没有帮助;他们结论是共享专家帮助不大。专家数量方面,他们展示细粒度、很多专家有帮助。所以他们在共享专家这点上有些不同意,但他们也有非常好的、仔细受控的消融。很多近期 MoE 基本都遵循 DeepSeek 设计。在这之前有一些早期 MoE。前三个我一直在夸 Google 做了很多有趣事情,前三个来自 Google;Mixtral、DBRX、Grok 是西方早期 MoE 尝试。DeepSeek v1 提出细粒度和共享专家设计,然后流行起来,其他人都跟随 DeepSeek。我说过,这类似 Llama 设计成为稠密 Transformer 标准;DeepSeekMoE 和 DeepSeek v3 是人们在 MoE 方面复制的标准设计。如果你看 Qwen 3.5、GLM 或任何现代模型,你会继续看到共享专家和细粒度专家被广泛使用。所以这是经过实战检验的设计。有问题吗?
有同学问,如果有共享专家,或者有几个共享专家,并行化怎么工作?老师说,在共享专家情况下,你不会得到并行化节省。每个激活都必须经过它们。你可以复制共享专家,基本上吃掉内存节省,来降低通信成本。
MoE 的训练:不可微路由、负载均衡与稳定性
好,这就是路由器的设计空间。现在我想谈训练 MoE。这会进入真正令人惊讶、我会说非常深度学习的部分。我一开始学 MoE 时想,我们不可能很好或合理地训练这些东西。但事实证明,有很多技巧组合在一起,不知为什么就能工作得很好、很鲁棒。
就像前面问题里提到的,我们不想在训练时激活所有专家,因为如果那样,我们就要为所有专家付全额 FLOPS 成本。我们有很多专家。所以训练时为了效率需要稀疏性。但如果稀疏,门控决策就不再可微,而且我们也看不到所有本来可能选择的反事实专家。这两个都是大问题。所以有不同解决方案。我们可以用 RL 优化门控策略。我已经提过,不太流行。你可以做随机扰动之类,如果你熟悉 explore-exploit、bandit 风格的东西,可以做这些。第三种是,你可以有一大堆奇怪的启发式,来处理门控决策稀疏的事实,以平衡哪些专家去哪里。猜猜实践中人们用哪个?我一直在暗示。是第三个。它是一组非常有趣的启发式,最后能工作。
我会先讲另外两个。它们没有被广泛使用,我先把这点说清楚。但我认为知道它们有用。有些人做过研究,这是 Clark 2020,研究能不能用 RL 学路由器。它确实能工作。你可以用 RL,就是这里的绿线。那是一个 baseline 方法,效果还行。但由于梯度方差和复杂性,它并不是最优做法。甚至这篇论文里提出的一些方法,也能轻松击败用强化梯度做路由决策。
有一个和 RL 很接近、但更走向“写目标函数试一试”的启发式世界的想法,叫随机近似。它来自最早 MoE 论文,Noam Shazeer 等人。做法是:训练时你不真正做硬决策,而是做随机决策。你仍然有路由设置。这个 H 是你的参数和输入之间的内积,这是标准路由内积。但然后,不是直接这样,我会注入一些依赖输入尺度的噪声。这就是我的 H。然后我保留 TopK 和 softmax,就像我解释 TopK 时那样。然后这成为进入 TopK 函数的输出。你看看这是在做什么?它只是说,如果两个专家紧密并列、非常接近,那么随机地你会选一个而不选另一个。反向传播时,你会以这样的方式回传:有帮助的专家会得到高权重。所以这种扰动让你打破平局、稍微多探索。希望梯度结构是,如果一个专家有帮助,它会被更强调,总体上得到越来越高权重。所以这是你可以做的一个合理事情。它让专家更鲁棒。softmax 让你学习如何给这 k 个专家排序,而不是只做硬 TopK 选择,你用这些元素加权。
还有一个非常相关的方案,在 Fedus 等 2022 里,做的是均匀的乘性扰动。他们这么做是为了让专家更鲁棒。这是有用的事,但后来在 Google 做路由的论文里被移除了。现在并不清楚这到底是否必要。后来的消融显示,完全不做这些随机或鲁棒性技巧,实际上有助于稳定性和最终训练出的 MoE 的总体质量。所以这表明,你不一定需要这些随机探索项。
这把我们带到你实际会做的事:一系列启发式,最终给我们一个能工作的 MoE。其中很多本质上都和平衡有关。如果你只做普通梯度下降,忽略所有探索-利用问题,会怎样?会发生的是,你先路由 TopK 专家,然后其中最强的专家得到更多信号。反向传播会说,那个专家很好,你增加那个专家的权重,把参数往那个方向移。所以你得到富者愈富效应:被选中的专家得到很强权重,强权重意味着更常被选中,它们会跑飞,包揽一切。所以专家崩塌或专家饥饿现象是非常、非常真实的问题。某种意义上,这是你用启发式训练 MoE 时必须解决的核心问题。
那你做什么?你会在总建模损失上加一个启发式损失。这个损失应该平衡去不同专家的 token。一种方法,在 Switch Transformer 里,你可以看它在做什么。你有从 1 到 n 的不同专家,有一个 batch 的 token。你想让这两个内积小。这里发生了什么?F 是分派到某个专家的 token 比例。P_i 是更软版本,不只是 token 是否被分派给专家,而是路由器分配的总概率质量。你把这两项相乘。至少对我来说,这不是那种能从第一原理推导出来的东西。我认为看它在做什么的最简单方式是取这个对象的导数。如果你对它关于 P_i 求导——记住 P_i 是分配给一个专家的概率质量——导数就是分配给它的 token 比例。所以某种意义上,你可以把它看成梯度空间里的惩罚:你得到的 token 越多,得到的负梯度越大。所以这是试图按比例压低非常受欢迎专家的概率质量。这就是这个损失的作用,这就是思考方式。方程 4 本身一开始可能不清楚,但一旦你推理梯度的作用,就清楚它在做什么:压低受欢迎专家。
然后我们可以把这些想法拿出来,基本上重建 DeepSeek v1 和 v2 的 MoE 系统。要构建 DeepSeekMoE,你做什么?你做正常损失,直接反向传播穿过专家,忽略所有不可微和探索问题。但你会加 per expert balancing,和 Switch Transformer 一样,就是我刚展示的。你会有一个损失,乘 F(比例)和 P(总质量)。但 DeepSeek 的人非常擅长系统设计,所以他们不只平衡专家,还想按设备平衡。如果你的专家分配到一些设备上——比如这台机器有四个专家,那台机器有另外四个专家——你希望两台机器平衡,两者都全利用率运行。所以你不只想让专家均匀,还想确保设备均匀。所以这是次要目标,作用方式完全一样。你看这里的函数,不是对专家,而是对每个设备比例操作,应用次要的设备平衡损失,纯粹为了利用率。
在 DeepSeek v3 里,他们有一个 per expert bias 项,用在线学习技巧来平衡专家,去掉了一些辅助损失。但最终,他们还是必须加一些辅助损失,来确保极端不平衡不会发生。所以我把这个作为旁注:DeepSeek v3 和其他人开始去掉一些我认为更丑的辅助损失,但到目前为止,还没有完全摆脱它们的解决方案。
关于负载均衡损失,最后我要说的是,你可能会问,我必须处理这个吗?这都是一堆复杂启发式。我希望我能直接去掉负载均衡损失。但如果你去掉负载均衡损失,后果相当灾难性。这两张图都来自 OlMoE 论文,他们做了很好的消融,完全去掉负载均衡损失。如果有负载均衡损失,你得到顶部粉色线,非常正常的曲线。去掉负载均衡损失,你的验证损失显著上升,训练损失也不太好。也许更有说服力的是,如果你看哪些专家在做什么,有负载均衡和没有负载均衡,就是这些底部面板,你会看到非常、非常鲜明的差别。没有负载均衡时,几乎所有 token 都去两个专家——这里看到的黄色专家和粉色专家。而有负载均衡损失时,尽管我们有这种启发式东西,所有专家都在 token 上被使用。所以我们有很好的均匀利用。所以我们确实看到负载均衡损失做了我们期望的事。没有它,我们丢掉了一大堆参数,那些专家在大部分训练里什么都没做。
这就是 MoE 怎么训练。回到前面,我觉得这相当令人惊讶:你有这个不可微的东西,做 TopK 选择,老实说是个相当复杂的对象;你真正需要做的只是加这个平衡损失来让专家均匀。然后剩下的,你就把它当成可以把梯度灌进系统,模型训练得很好。我认为很大一部分原因是这个动态:如果一个专家有用,你会强化它;这是一个正反馈循环,而通过让事情均匀,它被很好地平衡掉了。这两个动态互相抵消。
最后我要说,同样的技巧也用在 DSA 里。我们已经看到 TopK 那件事。它也用在 H-Nets 之类里,如果你熟悉那个,那是移除 tokenizer 的尝试。所以我认为未来你会更常看到这个技巧:TopK 选择,加上负载均衡或其他辅助损失,来启用这种不可微性,会成为未来架构设计的成分。
有同学问,MoE 像系统优化,但如果真正利用,能不能看它们?它们真的在做不同领域的事情吗?老师说,有些论文可视化哪些 token 被哪些专家激活。因为路由器非常简单,所以专家并不是智能专家。它们不是医学专家、法律专家之类的。你确实会看到某些 token 被路由到不同地方,比如标点可能被路由到一个专家,其他符号可能被路由到一个专家,非英语字符集可能被路由到另一个专家。但这不是你能看着说“哦,这是华尔街日报专家”的东西,完全不是。没有语义,也许可以这么说。
有同学问 per expert loss。老师说,per expert loss 原则上如果被完美执行,在专家均匀分配时也会强制 per device balancing。但至少我的解释是,你不想把 per expert loss 调得太高,达到真正完全均匀,因为那会对训练动态有害。但 per device 足够重要,所以你愿意额外加一点损失来鼓励 per device balancing。
MoE 的系统实现
好,现在在收尾之前,我要讲一点系统方面。训练 MoE 会引入额外的有趣系统动态。我讲并行化时,主要讲标准的并行化形式。你可以把数据切成很多小块,把一个数据集分到很多机器上。你可以做模型并行,把模型分到不同机器上。你也可以把两者以各种方式结合。当然可以。但我觉得我们意识到,每种并行技术都有一些限制。数据并行,你的 batch size 有上限;一旦达到 batch size,就不能再按数据并行。模型大小有自然切点,你能切模型;一旦用完这些,就不能再并行。如果把它和 expert parallelism 结合,你就得到另一个额外的并行轴。当你这么做时,很多底层实现允许你利用 GPU 里已经有的稀疏矩阵乘法。
我还要提,如果你在一个 GPU 上有多个专家,一种想法是说,我要做多个小矩阵乘法。但这不好,因为你理想上想要更大的矩阵乘法,可以重用缓存等等。所以相反,你可以很好地利用稀疏性。块对角当然是最基本形式,但你可以用硬件原生支持的更复杂结构化稀疏,让你以非常干净、非常快的方式乘专家和输入。所以这是 MoE 好的另一个原因。如果你看 MoE 的计算模式,它们几乎对应于这类结构化矩阵乘法,硬件很容易高效支持。所以 MoE 这里发生了硬件架构协同设计。
在并行化方面,我提一件事,这是非常近期的进展,来自 Nemotron-3。我忘了是谁问的这个问题。当我们做 expert parallelism 时,我们必须把激活从一个设备发到另一个设备,说,哦,你属于那个专家,我需要把你送过去。这会带来显著通信开销。人们开始思考的一件事是:我的共享专家不需要通信,可以是大维度;但我的专家需要通信这些激活,我希望它们用更小的低维向量。所以你实际上可能先把残差流降维投影,然后再决定做集合通信,把这个激活发出去。这会显著节省通信,又不用完全承担隐藏维度变小的缺点。所以你可以做这些投影技巧,很好地控制通信和 MoE 并行化的权衡。
最后一个 MoE 的 trivia,算是细节,我觉得很有趣,但近年已经被解决了,就是随机性。如果你朴素地做 MoE 基础设施,你会发现,除非专家在输入分布上完美平衡,否则某些专家可能比其他专家受欢迎得多。如果某个专家——比如在这个例子里专家 0 是一个非常受欢迎的专家——你实际上会开始遇到这个专家的 token 队列不断堆积、堆积。最终你会到某个点,说我的队列太长了,必须开始丢 token。早期一代 MoE 推理基础设施和代码里,很多情况下你实际上会静默地丢掉这个专家,然后继续计算,好像什么都没发生。你只是送回一个零,假装没事。这意味着你会有奇怪的随机性:如果其他用户发来的查询打到了你正在用的专家,你实际上可能得到更差的结果,因为他们把你从专家队列里挤出去了。这些非常奇怪的事情会发生。但现在,drop-less 架构已经摆脱了很多这些问题。所以这不再是真正的问题。MegaBlocks 和其他非常常见的开源 MoE 框架不再有这个问题。
关于 MoE 设计,最后要讲的是稳定性问题。我们在上一讲架构设计时已经谈过稳定性。如果你记得我当时说的,我说指数是坏的,除法是坏的,所以 softmax 是稳定性问题的危险区。现在 MoE 做了什么?我们在路由里又引入了一个 softmax,因为我们要做 TopK,要做 softmax,然后用它路由到专家。人们早就注意到 MoE 里的 softmax 操作可能非常、非常危险。Barret Zoph 等人在 Google MoE 设计早期有一整篇论文讲 MoE 稳定性。这是他们注意到的其中一件事。我会讲一些细节。你会经常看到解决方案,用在一些更敏感、更棘手的部分。你可能最终只对专家路由器使用 Float 32。你可能还有 z-loss。我们之前谈过 z-loss,用来控制 softmax 稳定性问题。Z-loss 我认为在早期就非常流行,用来做 MoE 路由器稳定性。OlMoE,我一直在广泛讲,做了加或去掉 z-loss 的消融。从这些非常尖刺的训练损失曲线可以清楚看出,路由器上的 z-loss 相当有帮助。
MoE 还会有其他问题。我想你们最近如果玩语言模型,可能经历过,MoE 微调起来相当烦人。它们参数太多,如果你想微调专家,实际上会非常严重地过拟合。你可以看到,稠密模型训练和验证相当接近,不会疯狂过拟合。但看稀疏模型,训练和验证的差距极大,当你微调这些模型,或者在这个例子里是下游任务。我想这是 GLUE benchmark 之一。你可以微调非 MoE 前馈层,也可以只微调注意力。这些是人们常做的干预,尤其是注意力微调,我最近在 MoE 工作里经常看到。如果你的模型有非 MoE 层,当然也可以只微调那些。常常还有人主张这么做。当然还有 bitter lesson 版本:也许你该用 140 万个例子,而不是你手头的数量。如果你有很多数据,你基本上可以在微调阶段重新训练整个 MoE,这样泛化差距就不会那么大。
最后一个 MoE 特定技巧是 upcycling。这实际上在过去一年变得不那么流行了。我觉得今年我还没见过一个 upcycled 模型。但这是个很酷的想法。而且早期有一些非常好的模型用了这个技巧。所以我想提。Upcycling 的想法是:如果你想要 MoE,也许你可以拿一个已经训练好的稠密模型,然后基于这个稠密模型实例化一个 MoE。做法是,你复制所有东西,包括 MLP。你从这些 MLP 复制一大堆副本。路由器随机初始化。然后你训练这个模型。因为哪些输入去哪里、到哪个 MLP 有随机性,专家会开始专门化。最后你实际上得到一个 MoE。在最早提出 upcycling 的论文里,他们展示 upcycled 方法可以得到更好的语言建模、在这个例子里是准确率性能,相比继续训练同一个模型,这是你可以展示的一件很酷的事。人们已经在规模上以很多方式验证了。MiniCPM 是另一个我很喜欢的中国模型,因为他们做很多仔细受控消融。他们有一个 MoE,把 miniCPM 2.4B 模型 upcycle 到 134 亿参数模型,得到一堆几乎免费的胜利。Qwen 也是,他们做 Qwen MoE 时,从 1.8B Qwen 模型初始化,把它 upcycle 到 Qwen 1.5-A2.7B 模型,最后得到最早、性能非常高的模型之一。我认为这是最早的大规模 upcycling 成功之一。当然,现在我觉得没人在 upcycling 了,因为你不会真的训练稠密模型再转换。你不如一开始就在 MoE 上训练你的大 hero run。所以我觉得现在不太看到这个了,但我想提,因为我认为这是 MoE 行动空间里一个重要的东西。
DeepSeek MoE 的演进与收束
好,最后我想带大家走一遍 DeepSeek 模型——v1、v2、v3。我认为从 DeepSeek 模型的演进里能学到很多。而且如果你对 MoE 或架构设计感兴趣,我鼓励你读 DeepSeek 论文。我觉得它们写得很好,有很多细节值得挖掘。
DeepSeekMoE v1 的架构其实已经是很多现代 MoE 的原型。它有共享专家和细粒度专家结构,使用标准 TopK 路由加辅助损失平衡。某种意义上我会说,这是 MoE 模型的原型、柏拉图式理想。
DeepSeekMoE v2 把它放大。你有两个共享专家、更多细粒度专家。你有 device routing 组件,还有 communication balancing 组件。这两个本质上都是加辅助损失来优化系统。我认为重要的是要意识到,成功的语言模型训练不只是深度学习,还要真正尊重你的系统。所以 DeepSeek v2 真正反映了这个哲学。
然后在 DeepSeekMoE v3,他们仍然有共享和细粒度专家设计,但有不同的平衡方式。他们有无辅助损失的平衡不同专家的方法。他们切换到 sigmoid 加 softmax,这是给专家加权的不同方式,但大体相似。
既然已经到这里,我们不妨走完 DeepSeekMoE v3 剩下的部分。他们还有很多其他很酷的东西。我很喜欢他们思考系统的方式。他们有多头潜在注意力(MLA, Multi-head Latent Attention),不是直接做 QKV,而是把它们表示为更低维的潜在 c。你有隐藏输入;不是直接产生 Q、K、V,而是先产生 c,然后基于 c 产生 Q、K、V。这让你得到显著节省。不是把所有 K 和 V 做 KV cache,我只需要存这些 c。而 c 希望是更低维的。这就是为什么叫 MLA,潜在激活:因为 c 是潜在的,是你要保存的 KV cache 量。你可能需要担心的唯一复杂性是,这和 rope 在 KV caching 时冲突。所以你必须小心如何旋转不同维度等等。技巧是,你有非潜在维度编码位置,但我不讲太多细节了。
最后,我认为有一个很酷的 MoE 想法,其实来自 DeepSeekMoE v3,但没有很流行,就是 MTP,多 token 预测(Multi-Token Prediction)。不是预测一个未来 token,而是一次预测多个 token。这有统计上的理由说明为什么好,也许让你更好地预测未来。但也有很好的系统理由:你现在内置了一个 speculative decoder。Percy 以后讲推理时会讲这个。但这是一个你可以用来加速模型解码的技巧。
好,我们大概到时间了。把所有东西放在一起:MoE 是一个非常聪明的想法,利用稀疏性,让你拥有比你所付计算量更多的参数。你不为所有参数付计算成本,但得到参数的好处。你一开始可能觉得这种路由问题很难,但事实证明非常简单的东西在规模上也能工作。到这个时候,很明显 MoE 会留下来,所以你应该理解它们怎么工作、它们是什么。谢谢。
Stanford CS336 Lecture 5:GPU、TPU 与 FlashAttention
这堂课从 GPU 的硬件与编程模型讲起,梳理让 GPU 跑快的六个技巧,最后用 FlashAttention 把所有概念串起来。
从神经网络转向系统:GPU、并行与推理
现在我们要开始这门课的系统部分。前两讲我们一直在做神经网络相关的内容,接下来要进入 GPU、并行化(parallelization)和推理(inference)。系统这部分,我觉得是整条技术栈里比较讲道理的部分:你可以把所有部件都推理一遍,沿着逻辑步骤走到结果,所以它和前面很不一样,也让人很满足。但第一次接触 GPU 时,你会觉得它既像魔法,又像一台非常奇怪的设备。我不太确定该怎么形容,但确实会有这种感觉。
我先做个课堂调查:有多少人上过任何形式的 GPU 编程课?大概四分之一,可能还少一点。这挺好。第一次和这些设备打交道时,确实有点魔法感,所以理解它们是有好处的。深度神经网络感觉像魔法,GPU 也可以这样。有时候人们会给你看这样一张图,我向你们承诺:到这节课结束时,你会理解这张图是什么,或者为什么它长这样。这张图画的是矩阵乘(matmul)的吞吐量,x 轴是维度。你可能会想,矩阵越大,工作量越多,吞吐量应该总是更高。但你会看到各种奇怪的模式:在某些尺寸下,矩阵乘会比其他尺寸慢得多。为什么?你需要理解这一点。你也需要理解一些关于 GPU 的基本心智模型,才能构建快速算法。
即使你不是系统方向的人——我觉得你们中不是所有人,甚至不是大多数,都是系统人——但如果你在思考架构,或者一般性地推理这些事情,你也需要理解模型将要执行的硬件模型,才能做出有效的模型。回想 Percy 最初那讲,扩展(scaling)的很大一部分就是确保你有效利用资源。如果不理解系统,你就永远不可能高效利用资源。而我不是系统人,惊不惊喜。
开始之前,我想先给你们指一些资源。我认为它们非常好、非常有用,我做这讲时也用了它们。Horace He 有非常棒的博客和对 GPU 工作原理的解释。还有一个爱好者组织叫 CUDA Mode,他们很喜欢讨论 GPU、CUDA kernel 等等。现在他们改名了,叫 GPU Mode,好吧。还有那本 TPU book,现在也成了 GPU book,是 Google 的人写的,真的是一份不可思议的资源。如果你对今天讲的任何东西感到兴奋或感兴趣,我强烈建议你去看那本书。他们还有练习可以做,你们的作业里也会有一些看起来像那些练习的东西,所以你们可以提前预览一下之后我们会发给你的内容。
好,这节课分三部分。第一部分是 GPU 的广泛介绍性材料,讲硬件模型,讲 GPU 编程大致如何工作,因为 GPU 是一种外来设备,我想让你们从哲学上理解它和 CPU 有什么不同。理解之后,我们会讲六个重要的技巧,让 GPU 跑得快。这些只是积木,让你开始操纵代码,使它在 GPU 上高效运行。最后一部分,我们把所有东西拼起来,走一遍 FlashAttention。这会是我们的胜利一圈:到那时我们会说,我们已经理解了重新发明自己 FlashAttention、并理解所有组件所需的一切。这就是这节课要努力达到的目标。
计算是货币:从 Dennard scaling 到并行扩展
我们还没讲到扩展,但从 Percy 的介绍课,或者一般性跟踪语言模型的发展,我们知道:如果有更多算力,我们就能得到更好的语言模型。计算是我们运作的货币。更快的硬件、更好的利用率、更多的芯片、更好的并行化,所有这些都能推动进步。所以某种意义上,过去几年的游戏就是:如何在训练模型时投入更多算力;而大概最近一年,则是如何在模型推理时投入更多算力。计算极其重要,系统就是其中一部分。
如果我们在 90 年代讨论这个问题,我们会关注时钟速度。我们会说,让计算机变快的方式,是让 CPU 串行执行指令,然后让这些指令执行得越来越快。这种晶体管变小、时钟变快的缩放,叫做 Dennard scaling,它持续了相当一段时间。但大概 2000 年代,这种缩放就走到头了。一个有趣的旁注:晶体管数量当然还在继续增长,但把晶体管做小,并不一定意味着晶体管变快。它们并不会以更高的时钟速度运行。到某个点之后,你不再继续从更小的晶体管中获得好处,这有根本性的物理原因。所以,让时钟更快、让指令执行更快的旧方法行不通了。那么,在这个不能只靠频率让东西变快的世界里,我们如何让 LLM 继续扩展?这就是 GPU 和并行扩展登场的地方。
GPU 和并行化基本上属于同一个范式。与其让串行执行越来越快、时钟越来越高,不如水平扩展,让更多东西并行执行指令。这就是 GPU 的故事。这一张幻灯片,或者说这一张图,几乎总结了并行扩展的状态:在 K20、M40 的早期,FLOPs 还算体面,但以今天的标准看有点可怜。然后到 P100、V100 左右,曲线突然起飞,你得到逐年超指数的浮点运算增长。这是一张关于我们能投入多少算力的非凡图表。GPU 扩展真正驱动了我们见过的几乎所有算力扩展。我们后面会讨论这里的所有不同组件,先在这里点出来,让你们有个印象:2017 年 V100 时代,我们开始有 tensor core,这是最大的硬件创新之一。接下来几年,我们看到结构化稀疏(structured sparsity)、FPGA,以及各种低位宽数字格式,也推动着 FLOPs 前进。我们稍后会详细看这意味着什么。
CPU 与 GPU:低延迟 vs 高吞吐
GPU 和 CPU 有什么不同?已经接触过 GPU 的人知道,我们一会儿就进入更高级的内容。但对其他人来说,理解这个模型、某种意义上理解它们的哲学差异,非常重要。CPU 是为快速、串行执行设计的,处理复杂分支逻辑、复杂条件、复杂控制流。因此,一般说来,CPU 有很大的控制单元,以及少量能做数学或其他操作的 ALU。它追求的是尽可能快、低延迟:从拿到指令到完成指令的时间应该非常非常短。这就是 CPU 的总体设计哲学。
相比之下,GPU 真正关心的是吞吐。右边这张图很好地形象说明了,当你改变哲学后会发生什么。在 GPU 里,也许你派发一个任务,它要很久才完成。可能你处理一个任务,停下来,去处理另一个任务,再回来处理另一个任务。但总的来说,即使任务不会很快完成,你在所有任务上获得了巨大的聚合吞吐。实现这一点的方式,是拥有大量轻量级核心。GPU 有几百个计算单元塞进一块芯片,它们全都能并行执行。所以,和 CPU 相比,这是一种非常不同的编程和思考模式。
我想把 GPU 去神秘化。我不希望你们把它想成 nvidia-smi 里的一堆文本。我希望你们能在脑子里可视化 GPU 的硬件。这张图通常被用来形象说明 GPU 是什么,我认为它很好地展示了 GPU 内部逻辑上在发生什么。GPU 的基本单元是 SM,即流式多处理器(streaming multiprocessor)。一个 SM 有点像核心,是一个独立的计算单元,有自己的子组件来加速计算,也能访问某些类型的内存。基于并行这个思路,每个 SM 不是单个计算单元,里面有流式处理器,可以并行执行不同线程。但 SM 是离散的计算单元。我们后面讲编程模型时会看到为什么。你可以把每个 SM 大致看成一个核心。GPU 有大量 SM。A100 有 128 个 SM。每个 SM 可以被独立编程,独立执行不同任务,并且它们可以访问外部的全局内存。
这就是 GPU 的计算故事。但我们在整节课中会看到,这个故事里非常非常重要的一部分不只是计算,还有内存。现代硬件和 LLM 优化实际上是由内存定义的。所以我们需要理解内存有哪些种类,以及它们位于哪里。谈到内存,有不同种类的缓存连在 GPU 上。有寄存器,更有限、也更快。然后有全局内存或高带宽内存。从这张表可以看到——这是有人做的基准测试,我记得是 A100 的延迟——L1 和共享内存非常快,大概 20 到 30 个周期就能读到数据。如果命中 L2 缓存,就慢得多;如果命中全局内存,那比 L1 缓存慢 10 倍。速度差异很大。全局内存就是你作为高层程序员通常认为的 GPU 内存。H200 告诉你它有 144 GB 内存时,说的是全局内存,不是共享内存或 L1 缓存。
物理上这些东西在哪里?全局内存通常是计算单元之外的内存芯片,而 L2 和 L1 缓存物理上就在芯片本身里面。这是 GPU 芯片的图。你看到所有这些 SM,这些绿色块就是 SM。L2 在这里,L1 在每个 SM 内部。所以它们物理上离计算单元近得多,这就是为什么延迟好那么多。你可能会说,既然共享内存这么好,为什么不整块芯片都用共享内存造?因为它贵几百倍,而且从这些内存的制造方式看,也更耗电。所以如果你在造实用加速器,通常不会想要巨大数量的共享内存,而是需要这个内存层级,并有效使用它。Groq 最近被 NVIDIA 收购,它的设计就是有大量 SRAM。对推理这类工作负载,这很有帮助。但对大多数加速器来说,你仍然会有这个层级,必须尊重它,才能让任何东西跑得快。
有人问:共享内存和 L1 缓存有什么区别?共享内存和 L1 缓存的操作方式不同。缓存就是缓存,它存储最近访问过的数据元素。共享内存是一个可编程元素,你会实际与它交互,可以把东西放进去、取出来。好,这就是内存存储。记住它,因为内存层级会不断回来,它是一个非常重要的对象。
GPU 编程模型:线程、块、warp
现在硬件心智模型已经有了。接下来讲 GPU 的软件和编程模型。GPU 执行模型里有三个重要角色。第一是线程(thread)。如果你熟悉任何多并行编程,就知道线程是什么。线程是轻量级的东西,并行运行、做工作。在 GPU 里,线程的重要之处是它们遵循 SIMT 模型(single instruction, multiple threads)。这意味着你拥有的所有线程都在执行完全相同的指令。它们有不同的输入,所以线程可以真正做不同的有用工作,但它们执行的指令必须全部相同。这对代码效率以及编程模型都有重要影响。如果线程能做各种不同的事,编程会变得非常困难。GPU 就是可编程性和效率之间的一种权衡。
块(block)是一组线程。块之所以重要,是因为一个块保证运行在单个 SM 上。记住,一个 SM 有共享内存。所以你可以把线程看作并行做工作,而块可以访问一个共享内存池。这在后面讲 tiling 时会非常重要,因为块会重用同一块内存。最后,warp 本质上是 GPU 内部发生的调度单元。线程以 32 个连续编号的线程为一组执行,这就是一个 warp。这减少了调度器决定运行哪些线程的开销。基本上,这三个术语就是 GPU 的重要术语。我可能会说“不同 warp 在执行”,意思就是 32 个线程一起执行的基本单位。
有人问:所有线程执行相同指令,是指一个块里的所有线程,还是一个 warp 里的所有线程?一个 warp 里的所有线程会一起执行。然后调度器决定下一条指令去哪个 warp。对,调度器决定哪个 warp 下一个执行,在什么赋值上执行等等。好。
在 GPU 的内存模型里,设备代码可以读寄存器。寄存器是最快、最本地的东西,可能存放内存地址,比如数组的开始和结束位置。然后是本地内存,也是单个 SM 内的本地内存。然后是共享内存,可以在块内访问。所以如果你想把东西跨线程传递,或者想让信息在线程间重用,这些就放在共享内存里。当然,你也可以从全局内存读取,那是很远的 DRAM,只要愿意承担数据从全局内存过来的延迟。还有常量内存,我不太在各种编程里见过它被大量使用,但你也可以用。你甚至可以在主机内存之间来回传输。主机内存指机器上的 CPU 内存。如果你需要卸载超出 GPU 大小的数据,可以这么做。这就是内存模型里能做的事情。真正关键、最重要的部分是:一旦走出共享内存,事情就会变慢。所以把块分组,减少全局内存读取,就是整节课的重点。
TPU:与 GPU 的趋同演化
接下来我用两三页幻灯片讲讲 TPU。这节课大部分讲 GPU,主要是因为如果你们出去写代码、建模型,大多数人大概都会在 GPU 上做。但我认为讲 TPU 非常重要,既因为 TPU 很酷、越来越流行,也因为 TPU 是 GPU 的另一种演化路径。你可以看到什么相同、什么不同。看 TPU 如何工作也很有意思。
实际上非常有趣的是,TPU 在高层和 GPU 非常相似。这是一种趋同演化。如果你想造一个节能的、做机器学习的加速器,最终会走到同一个地方。TPU 和 GPU 的核心心智差异在于:TPU 某种意义上更简单。它们更针对机器学习工作负载优化,有轻量级控制单元,以及大得多的矩阵乘单元。它们有和 GPU 相同的基本结构:有专门做矩阵乘的电路,有能做并行向量操作的组件,有某种控制系统。它们的内存结构看起来也很像:有慢内存,叫高带宽内存,还有更快的本地内存,叫 SMEM,也就是共享内存。所以这看起来和 GPU 架构非常非常像。真正的区别在于这些部件的尺寸和操作的灵活性。
今天我不打算深入讲的一个主要差异是这些加速器的网络。如果你在想 CPU 和 GPU 的最大区别,实际上最大的差异在网络上,而不是单个芯片,因为芯片本身其实就是为了乘矩阵而活的。这是来自 JAX book 的内容,那是一份很可爱的资源。他们有两张表,有一节讲 GPU,描述如何把 GPU 概念映射到 TPU 概念。今天我左边讲的任何东西,你都可以直接映射到 TPU。但在课的这一点上,重要的是注意到:GPU 里的每个概念,TPU 里都有对应概念,而且这个映射相当精确。GPU 里的 tensor core,也就是矩阵乘单元,几乎精确对应 MXU,即 TPU 里的矩阵乘单元。如果要讲很细的细节,它们实际上使用相同的底层电路。它们都是所谓的脉动阵列(systolic array),通过流入流出数据来做矩阵乘。所以架构非常相似。
现在,它们有多少个单元?哦,对不起,先提一个非常容易混淆的事情。TPU 把它们的流式多处理器叫做 tensor core,而 GPU 把矩阵乘单元叫做 tensor core,名字完全一样。所以你必须根据上下文来区分。如果有人说 TPU 的 tensor core,那指的是处理器;如果有人说 GPU 的 tensor core,那指的是矩阵乘。如果你看一块典型 GPU,比如 A100,大概有 132 个流式多处理器,很多处理器。TPU 只有两个单元,只有两个独立单元并行运行。如果只看矩阵乘单元,GPU 有 528 个矩阵乘单元,而 TPU 只有 8 个。它们大致被缩放到看起来可比的尺寸。为什么会这样?TPU 依赖更大、更少数的矩阵乘单元,而 GPU 依赖更小但数量多得多的矩阵乘单元。你可以想象,很多小的矩阵乘单元给你更多灵活性,你可以编程让它们做这做那。而 TPU,你被锁定在大矩阵乘上。我最近写的一篇论文里有个有趣的情况:我们做 batch size 扫描,一直降到 64 就停了。为什么?因为 tensor core 拒绝接受小于 64 维的输入,必须是大矩阵乘。所以这是主要区别。
但如你所见,GPU 和 TPU 共享很多相似之处。它们都依赖快速内存和慢速内存之间的内存层级,都有矩阵乘单元。因此,我今天教给你们的核心概念会在 GPU 和 TPU 之间迁移。因为这些原因,基本思想总是一样的。这真的是趋同演化。成本有效地分配内存的方法只有那么几种,快速乘矩阵的方法也只有那么几种。有些设计决策不同,但除此之外,你最终会走到同一个地方。
GPU 的成功与硬件趋势
好,TPU 就讲到这里。GPU 我认为极其成功,因为它让矩阵乘之类的东西容易扩展。如果你想要更多吞吐,就不断加 SM。只要你的内存带宽能撑住这些 SM,你就能得到越来越多吞吐。编程是我们不太去想的东西,但它其实容易得具有欺骗性。如果你试着写更底层的、像机器码的 PTX,实际上也可行,因为一切都是 SIMT。你不是在编程每一个线程。你说:好,这是我的指令,这是我的输入,现在在所有输入上运行它们。这有点像你们可能已经熟悉的函数式编程里的 apply 或使用方式。最后,我还没怎么讲线程。做 GPU 编程时,线程总是非常轻量。它们可以随时被停止、启动。调度器可以决定:好,我有另一个 warp 更适合运行,所以我停掉你,启动这个。切换这些工作非常快。如果某些任务卡住,或者有某种降低吞吐的性质,这就能带来高得多的吞吐。这就是 GPU 作为硬件模型的好处。
它极其成功。我认为甚至早在 tensor core 实现之前,人们就意识到,商品图形硬件的这种大规模并行,对科学计算会非常有效。这是最早用图形硬件做快速矩阵乘的论文之一。这是非常酷的黑客工作。人们发现,我们有这些 shader 被实现,但我们可以编程特定的 shader,实际上给出矩阵乘。不同的渲染设置实际上能给我们更快的矩阵乘。这是非常酷的计算机科学。但如今你不需要这么做,不需要手动编程 GPU 做矩阵乘,因为从 V100 开始,NVIDIA 决定:这里有一块硬件,会替你做矩阵乘。一旦这个硬件存在,matmul 就成为机器学习里唯一的特权操作。此时,可并行但非矩阵乘的操作,和矩阵乘能做的操作量之间,出现了巨大的吞吐差距。这也是为什么,我们未来看到的任何随算力扩展的机器学习架构,都会包含矩阵乘,因为这是唯一能真正有效获得大量计算吞吐的方式,比 GPU 上任何其他浮点操作快 10 倍以上。
最后我要讲的硬件事情——我觉得这是这部分的最后一张幻灯片——是 GPU 的不同组件随时间以不同速率扩展。我们知道 GPU 越来越快,CPU 也一样,整体计算吞吐越来越快。这是灰线,相当快,我们总浮点运算得到越来越快的改进。绿色是内存带宽,也就是我们传输数据的速度,它增长得相对较慢。蓝色是并行性,也就是我们连接不同 GPU 设备的速度,也走得很慢。这意味着什么?意味着如果你在 GPU 编程的早期,大概不太会考虑内存,因为你的计算并不比内存传输快多少。但现在,随着我们在这张图上越来越往右,计算和内存之间的差距越来越大。这就是为什么我今天讲的很多优化都是内存优化。越往右,内存和通信瓶颈会越来越明显。要充分利用我们拥有的硬件,会需要越来越多工作。
这就是对 GPU 非常基础的概览。我不可能用 20 分钟讲座就让你完全理解 GPU 硬件是什么,但希望你们有了一点感觉。心智模型应该是:GPU 大规模并行,它们同时执行指令。计算扩展非常非常快,甚至比内存更快,这意味着内存非常重要。因为内存重要,我们做的一切都必须尊重内存层级,尽量让很多东西进入共享内存而不是全局内存。如果你们有了这个理解,至少就掌握了内化下一部分所需的全部基础。好,我可以在这里暂停一下,如果大家对硬件或其他事情有问题,再进入下一部分。
有人问:V100 之前没有矩阵乘单元吗?对,没有专门做 matmul 的 tensor core。tensor core 是 V100 加入的。在那之前,你要手动编程。当然,你有很多 SM 和 ALU,仍然可以获得相当好的吞吐。但专门做矩阵乘的电路真正改变了游戏。另一个问题:共享内存和 L1 缓存有什么区别?缓存自己运行,你不能控制 L1 缓存。物理距离是很大一部分原因。如果我没记错,它们都是 SRAM。全局内存慢得多,物理距离和使用的互连是原因。顺便说,这不是固有的。TPU 里相当于 L2 缓存的东西要快得多,因为它们在硅片上做了不同的设计权衡,这是卖点之一。有人问:如果计算内存差距继续增长,会不会影响未来芯片布局?会。或者说未来芯片预算的划分。我本来不想讲,但你逼我讲了:推理硬件的景观其实更疯狂。人们去年开始做的一件事叫 prefill decode disaggregation,把 prefill 部分——非常重的矩阵乘——放在一块芯片上,然后把推理的 decode 部分——更受内存带宽限制——放在另一块芯片上。有些模型,我记得是 Step 1-3,一个中国开源模型,甚至尝试 disaggregate 不同层:attention 去一个为某种东西优化的芯片或加速器,MLP 去另一个加速器。所以如果这个差距继续增长,任何利用宝贵快速内存的聪明技巧都会变得非常有价值。这在推理中已经非常明显,而推理比训练更受内存限制。有人问一个块里有多少 warp?我知道吗?我不记得了,我觉得这也取决于硬件。好。
第二部分:让 GPU 跑快的六个技巧
这节课第二部分是:现在你们对 GPU 是什么、如何工作有了大致感觉。接下来讲让工作负载在 GPU 上跑快的基本技巧。这些是基本组件,可以组合起来让东西变快。为了保持动力,我这部分的一个目标是解释之前那张图。那张图很简单:x 轴是我要乘的方阵的大小,我在乘两个方阵;y 轴是我每秒处理多少项,也就是吞吐量。一般来说,越往右,吞吐量越高,因为矩阵更大、工作更多,硬件利用率更高。但这里有各种奇怪的模式。我们要理解为什么有这些奇怪模式,以及如何待在曲线顶部而不是底部。这就是第二部分要讲的。
Percy 已经提过 roofline model。我之前展示的图看起来有点像经典的 roofline model:有一段斜线部分,然后有一段平坦部分。这正是我们预期的。roofline model 说:到某个点之前,我们是内存受限的。内存受限时,不管你给我多少算力,我的吞吐都不会提高。到某个点,你每单位内存移动有足够多的工作,我就能让计算单元完全饱和。超过那个点,给更大单位的工作也不再有帮助,因为我已经完全饱和了矩阵乘单元。所以这是经典 roofline model 的预期:内存越快,这条线可以往上推;但存在内存受限区域,是斜线,然后是计算受限区域,是平坦的。
如果你想让代码在 GPU 上高效运行,真正要做的就是待在平坦部分。一旦到了平坦部分,就没什么可做的了,我们已经在最大吞吐。我们要防止自己落入斜线部分,这意味着我们要提高操作强度(operational intensity):每次内存读取所做的计算量要足够高。这就是我们要做的。我们会讲六个技巧。实际上,第一个不是内存技巧,只有它例外。有六个技巧,可能对让 GPU 跑快都是必要的。第一个味道有点不同,但其余都会围绕一个思路:尽可能减少内存移动。
技巧一:控制分歧
第一个是 GPU 特有的东西,和我之前说的 SIMT 模型有关。在 GPU 里,每个线程执行相同的东西。那么,如果你在 GPU 上执行的代码里写了一个 if 语句,会发生什么?在 CPU 上很清晰:你会选一个分支执行,然后继续生活。在 GPU 上,你会做一件相当不同、有点反直觉的事。每个线程都必须执行相同指令。所以实际上,每个线程都会执行 if 和 else 两边。但如果你不属于 if 分支,你只会把那段计算 mask 掉,坐在那里空闲。这就是条件语句时的心智模型:一部分线程会根据 if 语句分歧。有些走底部分支,有些走顶部分支。底部分支先执行,顶部分支等待;然后顶部分支执行,底部分支等待。所以为了执行一个 if 语句,你实际上要把两个分支都走一遍,而一些线程在错误的分支上,就只能空等。
这叫做控制分歧(control divergence)。它在某些方面是好的,因为它简化了编程。但它不好的地方在于,你现在有了大的计算空隙,GPU 在那里什么都不做。这就是为什么你真的很不想写 if 语句。如果你看 GPU 代码,如果有一个 ReLU,你经常会发现它是乘以零或乘以 mask,而不是写 if 语句更新一半。因为乘法会一次性全部执行,而 if 语句可能实际上要等两个时钟周期,或者两个操作。这是第一点。但这是独立于所有内存东西的故事,而内存才是主要叙事。
技巧二:低精度计算
第一个技巧你不需要做太多,但我认为它是最重要的之一,也是 NVIDIA 在硬件上投入最多的方向:低精度计算。如果你看这张图——我把 Bill Dally 又请回来了——这种超指数增长到底从哪里来?实际上,其中相当不小的一部分来自数字表示。你从 FP32 到 BF16,再到 Int8,不断降低数字精度。当然,如果位数减半,你要移动的位数也减半,内存移动就减少了,所以你至少暂时解决了内存瓶颈。
这是个很笨的例子,但我要把它真正刻进去。假设我对一个大小为 n 的向量做 ReLU。如果我用 float32,我需要读一次数字,算完 ReLU 再写一次。那就是每个操作 4 字节,总共 8 字节内存访问。我只有 1 FLOP 的操作,所以是 8 字节/FLOP。如果精度减半,每个操作的字节数减半,每个 FLOP 需要移动的字节数也减半。抱歉,这是算术强度的倒数,算术强度应该是它的倒数。
这个例子很简单。但实际上,低精度在现代 GPU 里如何工作非常复杂,而且取决于你怎么看酷不酷,它要么非常酷,要么非常疯狂。当你做一个矩阵乘,假设 tensor core 在低精度下运行,实际怎么工作?你实际上不会把所有东西都保持在低精度。你会在做乘积之前先 downcast。当你在矩阵乘中累加部分和时,会用全精度来做。然后你可能以 FP32 发出输出或结果。我认为低精度之所以有点像黑魔法,不是因为你可以简单降低精度——谁都会——而是你必须弄清楚哪些操作要用哪种数字格式。矩阵乘也许权重和激活都可以低精度。如果做 softmax,也许需要 FP32。如果做指数,也许也需要 FP32,或者也许 BF16 也行。所以我认为,真正让低精度训练达到今天的状态花了好几年。很多工作是缓慢、增量、经验性的,去弄清楚哪些操作可以 downcast,如何 downcast,以及如何在让训练和推理尽可能低精度的同时保持训练稳定性。
人们一直在推得更远。一旦你想到快速内存有多贵,你就会想:要是我能把精度再砍一半就好了。人们真的非常想这么做。所以 FP8 可以说是低精度的下一个前沿。一旦到 FP8,就不再有一个所有人都用的标准、规范格式。如果你用 16 位,很多时候大概用 BF16。但用 FP8 时,会有 E4M3,意思是 4 位指数、3 位尾数;或者 E5M2,指数更多、尾数更少。因为它们用在不同地方,一旦位数这么少,就没有一刀切的格式。
但我觉得真正有趣的是,随着新硬件出现了更先进的低精度数字格式。我会简单讲讲 MXFP8,因为它既酷,它对训练做的事又至少可以说很怪。在传统 FP8 训练版本里,假设所有激活都是 8 位,这在上面。然后你会有一个 FP32 缩放因子(scaling factor),让你能把它放大缩小。为什么需要缩放因子?因为你只有 4 位指数,这么少的指数位,你很快就会上溢或下溢。所以你需要缩放因子让你保持在正确范围内,保留一些信息。
但人们意识到这不是最优的,因为一个矩阵里不同部分的幅度可能非常不同。序列的一部分激活可能比另一部分大得多。所以为什么不用多个缩放因子,而不是一个?MXFP8 和这些更先进的多缩放因子格式就有很多缩放因子。每个缩放因子对应相关的彩色子矩阵。矩阵里前四个元素对应第一个缩放因子,依此类推。现在我有更多缩放因子,这些缩放因子实际上会用更低精度。它们也是 8 位,是 E8M0,只有指数位,所以都是 8 位的 2 的幂。你必须做这些权衡。MXFP8 会在元素里用更多尾数,缩放因子本身是 FP8,矩阵里每 32 个元素有一个缩放因子。
好,我在这里暂停一下。不要读第三点。现在想一下:这个设计有什么问题?如果你做了这么聪明的事,最大的问题是什么?最大的问题是:假设我想转置这个矩阵。这个矩阵的转置并没有和原矩阵相同的缩放模式。以前矩阵转置很简单,我只要转置,不需要重新量化,不需要做任何事。现在转置这个矩阵非常复杂,因为我现在可能必须重新量化整个矩阵,才能适配这个 1/32 的模式。所以当你实际使用 MXFP8 时,内部会发生什么——我觉得这既疯狂又酷——是在训练模型时,它实际上会为每个量化矩阵创建两份副本:一份是原矩阵,一份是转置矩阵。如果你以后想转置,转置版本已经等在那里了。这就是绕过问题的方式。我觉得这既疯狂又酷。
这里的重点也许不是强调 GPU 琐事,而是一旦你开始到 FP4、FP4 格式,事情就变得相当异国情调,因为指数和尾数的损失非常极端。所以你必须非常聪明地处理。就像我说的,如果你真的去训练一个 FP4 模型——我认为很多前沿实验室为了计算收益正在做——你必须非常小心。
这张图确实说明了我说的。每次量化时,你会以一种方式量化,然后转置再量化,所以你有两份副本。另一部分我几张幻灯片前告诉过你:你只会量化那些你认为安全的层。这是另一个试错的事情,你必须弄清楚。但一旦把这些都结合起来,训练 FP4 有很大好处:做矩阵乘时,你可能获得 20% 到 30% 的节省,甚至更多,取决于矩阵大小。不幸的是,这不会是 2 倍加速,因为你必须做所有这些量化操作,开销很大。所以它不是免费的。
有人提到,第一层和最后一层很难量化。对,对。我对第一层没有直觉。最后一层难量化,是因为它是一阶因子,驱动大量 loss。所以如果你试图量化最后一层,会得到不稳定和很大的 loss 增加。
我把这个放上来,几乎是因为我觉得它太荒谬了。下一个前沿已经是 MXFP4。整个 MXFP4 可以在这张幻灯片里展示给你:从 -6 到 6,所有可能的值。但既然你理解了 MXFP8,你也能理解这个怎么工作。你会有一个结构化块,每个块是这四位之一。每 16 个数字,你有一个缩放因子,缩放因子本身是 FP8。这是 E4M3 缩放。有论文用 FP4 训练过。我没听说有人在野生的大规模、严肃模型上成功用 FP4 训练。但我认为这正在到来。下一代模型可能会是 FP4。这就是低精度。我觉得很酷的是,你可以把极限推到只剩 +6 到 -6,却仍然能用这种精度对矩阵做有趣的事。
有人问:这是只能用于矩阵,不能用于单个标量吗?你可以量化任何东西。比如 ReLU 之后的激活也可以量化。但没必要。像矩阵乘,如果你量化它,能获得很大吞吐提升。这就是为什么看 MXFP8 训练时,所有被 FP8 量化的都是矩阵乘。对大多数其他操作,开销可能不值得。这和硬件有关,不是矩阵乘硬件只能做这个。不是排他的。你总是可以做更低精度,减少内存移动,这是真的。但对其他东西,不太有意义。这不是硬件限制,而是硬件支持所有这些,很多是烘焙进硬件来加速的。有人问:你把它定位成内存带宽的改进,但在计算上也有好处吗?对,当然。计算上,乘量化数字基本有线性收益。但你必须量化和反量化,所以好处被稀释了。有人问:缩放因子需要训练吗?缩放因子是实现依赖的选择。不同库可能有不同的量化和反量化方式。训练是个强烈的词。你可能让量化因子扫描并选择最大最小值,或者用历史运行统计。我不太会称之为训练。推理时你可能更精确地拟合它们。所以没有梯度。推理时可能会有,训练时不会。有人问结构化稀疏?有很多工作。MOVs 我认为是人们使用的一个成功结构化稀疏操作例子。Chris Re 在这里做了很多不同结构化矩阵的工作。计算收益和表示损失之间,我认为已经抵消了。我不认为这是个坏主意,但经验上,它没有必然成功。有人问量化现状:几十年了,当前方法是什么?是训练全精度或半精度再蒸馏到量化模型吗?大概是要组合。你可能想训练更大模型再量化下去。有各种量化 scaling law 和论文,精确刻画这些权衡。我认为最先进的方法结合一定程度的量化感知训练(quantization-aware training)和模型缩放,以及各种训练后量化(post-training quantization)。有很多东西我觉得还没有完全被理解。即使我和工业界团队聊,他们也在说仍在做量化的科学。好。
技巧三:算子融合
下一个技巧更像概念性的编程技巧:算子融合(operator fusion)。想法很简单,但你会惊讶它并不是自动发生的。把 GPU 想成一个工厂。你有内存仓库,有小计算工厂,还有一条小传送带来回跑。如果你扩大计算,就会被小传送带瓶颈。如果你有多个操作,会更糟。想象我有很多不同操作要做,我把原材料运到工厂再运回来,来回来回。这是大量内存瓶颈,大量双向内存带宽。现在,如果我有一个大工厂,接收所有原材料,送回来成品,不是更好吗?这样我只需要付两次内存带宽。非常简单。这就是算子融合。
这经常出现。如果你写一个非常简单的计算,比如 sin 平方加 cos 平方,PyTorch 计算图长什么样?它长这样:x,做 sin,做 cos,两边平方,相加,得到输出。朴素地,如果不做任何聪明事,每个操作都会是一个工厂。每次调用其中一个,你都会从全局内存读写。每次都会产生相当多内存成本。这很痛苦。
你可以想象,与其那样,不如写 GPU 代码,从全局内存读一次,在 SM 内部做完整个事情,像我之前说的那样。在 SM 内部做完了,再写回全局内存。一个 kernel 做所有这些操作。这叫做融合。像这种简单的——非常容易融合,图可以直接压成一个单元——编译器可以自动为你做。Torch compiler,PyTorch 的编译器,或者 JAX compiler,更深度集成进 JAX,两者都会把这种基本东西融成单个 CUDA 调用。后面我还会讲一点融合。高级版本有时需要手动干预,但简单版本你可以认为编译器会解决。
技巧四:重计算
第三个想法是重计算(recomputation)。我们知道在反向传播里,我们做什么?我们沿着树向上前向传播。这是来自 Percy 的 221 课。你沿着树向上,跟踪激活,存储这些激活数字。然后当我反向走时,我会用存下来的激活计算向下的反向信号。我们都多多少少知道反向传播,都知道这是什么。但现在我们不是从数学角度、如何计算梯度的角度看,而是从系统角度看:如何最小化内存?
假设我有简单的东西:x 经过 sigmoid、sigmoid、sigmoid,输出。每个 sigmoid 都会在计算图里生成一个残差或激活,我需要跟踪。第一个操作后我有 S2,第二个操作后 S1。然后需要做反向时,它会消耗 S2、S1 和我的输出,或者说是输出的梯度。然后我把梯度反向推到输入的导数。如果数内存读写,前向是读一次 x,写三次 S2、S1、out;反向则反过来。总共 8 次内存读写,算术强度非常低,因为我只是堆叠了一堆 sigmoid。
那我能做什么?我可以把所有激活都扔掉。通常你不会这么做。为什么要重计算?但想象一个世界,计算超级超级便宜,你有充足计算,没有用满所有单元,但内存非常贵,任何读写都有很大惩罚。那你可能会更喜欢这个计算图:我读 X,然后做 sigmoid、sigmoid、sigmoid、输出。我存这个输出,必须写,因为它是图的输出。但在反向传播时,我的反向图收到 d out。然后我动态地重新前向 X,在需要的地方即时计算激活。现在这会是读 d out 和 X 两次,写 dX 一次,总共 5 次内存访问。我从 8 次总内存访问降到 5 次。所以现在我有 5/8 的总内存访问,计算相同。但我多花了一点计算,省了一点内存。这是另一个非常重要的技巧。我们通常不这么想,因为我们通常只关心计算梯度。但一旦你有系统思维,你就会开始考虑重计算。
技巧五:内存合并
第四个技巧是另一个内存琐事,但意外地重要,也是为什么有时理解你交互的硬件模型很重要。全局内存,也就是芯片外的大内存,是 DRAM。DRAM 由于它的结构方式,实际上以 burst 方式读取。这意味着,当我访问第一个位置时,我实际上不只得到第一个值。我可以免费读取前四个值。这是因为内存单元排列方式,有这些内存单元块;当我激活一个放大器时,几乎可以免费读出所有这些。并行结构允许我读内存块。作为硬件模型,这很合理。这叫做 burst section。每当一个 section 被访问,其他位置也可以免费送达。你不会为它们付费。所以你可能有一个大约 128 字节的 burst。这意味着一次读取可能返回 128 字节数据,只要它们位于同一个连续块。
这意味着,如果你的读操作安排得连续、都在一起,会快得多。用术语说,这叫内存访问是合并的(coalesced)。这个词的意思是一个 warp 里所有线程都落在同一个 burst 里。也就是说,假设我有四个线程,它们都生活在同一个 burst section 里,或者从这个 burst section 读取,那么这就是合并的,因为它们用满了整个 burst section。我充分利用了 DRAM 这个非常好的性质。如果有多个 warp,就会有多个 burst section。但你可以通过分组内存读,高效地从内存读取。
有人问:为什么内存访问有这种模式,像 T 形模式?据我理解,内存单元排列成网格,你的放大器决定读哪些单元,在列级别操作。激活电压通常需要相当长的时间。所以一旦你选中了列,读取该列内多个元素的成本相对便宜得多。如果你想读这个红色单元,我会选中这个。或者至少在这个图里,列是便宜的。但如果我试图读这个,我可能免费得到整行,因为我已经寻址了整行。你不会免费得到一个 T。好。
你可能会说,Tatsu,你为什么跟我讲 DRAM 琐事?我讲 DRAM 琐事的原因是:想想矩阵。矩阵经常你想读大块,因为你要做矩阵乘。而矩阵乘上某些内存访问是特权的,比其他的结构好得多。有时你可以利用这一点获得非常非常快的内存读。我记住的口诀是:如果你有线程,有一个行主序矩阵,也就是按行索引递增排序,那么线程沿着主轴移动是不合并的。为了直观,我喜欢右边这张图。想象我有一个 4x4 矩阵,行主序。按地址顺序,黄色块最先,红色块其次,等等。这是内存地址的线性顺序。这是我试图存储的矩阵。现在想象线程每个时钟周期试图读每一列。我读一列,向右移动,读一列,向右移动。第一次迭代,我命中这个元素、这个元素、这个元素、这个元素。注意每一个都落在不同的 burst 窗口。我读了整个向量,或者说整个矩阵,即使我只想要一列。现在想象反过来,如果我翻转这个矩阵,我可以读整个块。每个线程一次内存读就能拿到自己的数据。所以合并可以显著改善从矩阵这种大连续结构中的读取。这就是为什么你可能要想想行、列或其他顺序。
技巧六:分块(tiling)
最后一个要讲的,也是最大的、也许最重要的想法,我留到最后:分块(tiling)。我认为它对性能影响最大。tiling 比任何其他想法都更关乎尽量利用内存层级。我们想尽可能分组内存访问。共享内存访问应该一起做,而且应该在共享内存上做。之所以叫 tiling,是因为我要把需要的东西切碎,把空间分块。每个 tile 会加载到共享内存。一旦 tile 加载了,我就会反复从那个 tile 读取,尽可能多做。
想想矩阵乘的情况。我要乘这个矩阵和这个矩阵。右下角这个矩阵是我的输出,是我写矩阵的地方。这是标准记号。如果我要做这个,朴素地,每个条目会被读多次。精确地说,在一个 N x N 矩阵乘里,每个条目会被读 N 次。所以你可能想,如果我读这个条目,比如 M0,多次,为什么我不能把所有内存访问分组,而不是每次都命中全局内存?矩阵的好处是有非常自然的分组方式。本质上就是把矩阵切成子矩阵,加载它们,然后操作。
这是一个很简单的算法。我把矩阵切成 2x2 子矩阵。然后加载不同 tile。我先加载 M00 tile,左上角这个。加载 N00 tile,上面矩阵的左上角。把它们加载到共享内存。所以我只付一次从全局内存缓慢传输的成本。但一旦它们在共享内存,我就可以非常快地读写这些 tile。这个输出 tile 暂时也在共享内存。我完全在这些子矩阵上操作。乘这两个子矩阵,把部分和放进输出子矩阵。这让我能在快速共享内存里做很多读写。完成后写回去。
数学上呢?非 tiling 情况,我已经告诉过你,每个输入元素在大 N x N 矩阵乘里要读 n 次。如果我用 tile size T 分块,每个输入从全局内存读 N/T 次,然后在每个 tile 内读 T 次。这符合极端情况:如果 T 等于 N,我从全局内存读一次,然后在共享内存访问 N 次。这很好,因为全局内存访问减少了 T 倍。如果你有很大的共享内存 tile,你就能在矩阵乘之类东西上大幅减少全局内存访问。
有人问:你这样做是因为你不能把整个块放进去吗?对,对。但如果你在造那些非常花哨的替代加速器,比如 Groq 之类的,全是 SRAM,那你就可以把整个矩阵放进去。那样整个东西非常快,但非常非常贵。这也会在 GPU 层面发生,因为有上下文切换,我甚至不能把一个 tile 放进一个 SM,然后你就得不断重新加 tile。有人问这是不是在加速器层面也发生?对,这就是一个 tile。数据并行可以这样看:如果数据在一个轴,权重在另一个轴,在某个轴切,就是数据并行;张量并行可能是另一种并行。所有这些并行看起来都有点像 tiling。
有人问:像 PyTorch 这样的库会自动做这个吗?会。你调用的任何矩阵乘 kernel 底层都会做 tiling。如果你做某些奇异操作,可能想想要不要 tiling,你想要本地重用读取。但我要说,即使 PyTorch 也不是完美的。我们会讲一些不完美的地方。tiling 作为想法非常强大,但也很复杂。你实际上可能遇到一些非常微妙的事情。想象 tile size 是 128x128。如果矩阵是 256x256,生活很美好,四个 tile 完美切分。现在想象我把坐标大小加一。现在生成了两个非常瘦的 tile,里面几乎什么都没有。所以你意识到,tile 大小其实很复杂。根据矩阵,我可能需要调整 tile 大小,在内存带宽和巨大空白浪费之间权衡。tile 大小实际上是需要优化的东西。合并内存访问、加速器里共享内存的大小、矩阵大小,都会影响哪个 tile 最优。如果你知道 PyTorch 里的不同选项,有一个编译器选项叫 max-autotune。我不知道你们多少人用过或知道。打开那个标志,接下来 15 分钟 PyTorch 会跑无穷无尽的基准测试。你会意识到,它实际上是在你的矩阵上尝试各种不同 tile 大小,试图找出哪个更快。你会发现,优化这些东西会带来不小的好处。
另一个和合并相关的事是:如果你的矩阵大小不对,你试图读一个 tile 时,可能会触发很多内存读。想象一个美好世界,蓝色的是行,我想读这个 tile。我的 burst section 宽度和 tile 一样。这种情况下,四次读就够了。因为 burst section,我读取整个子矩阵,所以合并带来好的内存读。现在想象我把矩阵大小偏移一个。现在第一行被推过去了,蓝色框现在在这里。我没办法通过合并一次读一个 tile,因为所有东西都参差不齐、未对齐。我至少需要读两个 tile 的量才能读出一个 tile。所以一旦你开始考虑合并和其他东西,优化 tile 读取就有很多复杂性。在这种情况下,你必须做 padding,本质上让 burst section 的边界和你有的 tile 大小对齐。
这就是为什么你会看到这种东西,看起来很神秘。这是我最喜欢的奇怪例子之一。Andrej Karpathy 做 nanoGPT speedrun 时说,当我把 vocab size 从 50,257 增加到 50,304,我得到 25% 加速。为了加速而 padding 有点奇怪。但一旦你意识到这些事情,也许就说得通了。Padding 可以移动行和列,让你实际上获得非常高效的读取,而其他时候则不能。有人问:通常 tile size 应该考虑多少?你不应该想 tile size 本身,因为 tile 通常应该被看作底层系统的东西,除非你自己写 kernel。但你应该想矩阵大小。你希望矩阵大小能被 tile size 整除。通常 2 的幂,最好也能被 32 整除。好。
解开吞吐量图:tiling、对齐与 wave quantization
现在我们可以谈这个矩阵之谜了。我们基本上有了理解它所需的所有部件。我们一个个走。我们已经讲过计算强度、roofline 图。每次内存读需要有足够工作,才能饱和计算单元。这是斜线上升趋势的驱动力。这个完成了。现在讲其他的。第一个是 tiling。
如果看这些线,按矩阵的可整除性着色,你会发现蓝色基本只能被 1 整除,甚至不是偶数。橙色可被 2 整除,绿色 8,红色 16,紫色 32。你会看到,只能被 1 或 2 整除的那些,吞吐非常差。但一旦到更高倍数,你基本上没有惩罚。16 或 32 表现一样好。这不是因为 2 的幂有魔法,而是因为 16 和 32 足够大,有正确的 burst window 性质,能容纳整个 burst window。所以你能把整个 tile 合并读入。这对对齐有很大影响。
现在看剩下的,看橙色线,我们有时会看到奇怪的周期性行为,性能突然下降。这很奇怪,我只是在乘矩阵。为什么增加一个额外维度突然杀死吞吐?我认为用我们学到的所有东西走到这个终点很有趣。Percy 把这个叫 GPU 琐事。我觉得他讨厌这个,但我喜欢。为什么从这里到这里,吞吐大降?它发生在 1792 到 1793 之间。为什么?我用的 tile size 是 256 x 128。有多少 tile?1792 时有 98 个。但一旦我加到 1793,实际上两个 tile 尺寸都加了一,所以现在有 120 个 tile。之前 98 个,现在 120 个。这不好,但没这么糟。这个下降巨大。原因在于,A100 有 108 个 SM。98 个 tile 能放进 108 个 SM。但 120 个 tile 放不进 108 个 SM。所以你做完 108 个 SM,然后想,我还有 12 个要处理,大部分 GPU 空闲。所以你会看到这些非常奇怪的效果:tile 大小加一,突然你需要等另一整个周期来处理一堆 tile。这叫做 wave quantization。
这就是第二部分。如何让机器学习工作负载在 GPU 上跑快?很多故事其实就是内存、内存、内存。如何减少必须做的内存访问?把内存读合并在一起。把操作融合在一起,这样不用读那么多。对于那些无论如何都要读的东西,重用内存,把东西移到共享内存并尽可能重用,这就是 tiling。最后,也许你可以用其他资源换内存,这就是量化和重计算。所有这些想法都和内存是瓶颈、我们如何处理它密切相关。好。
有人问:为什么不干脆加大 SRAM 来摆脱 DRAM?非常贵。有两个原因。一是造 SRAM 本身就贵,而且很难,因为它必须物理上很近。信号传播很难。第三个原因是 SRAM 和 DRAM 是不同存储机制。SRAM 需要一直供电才能保持值,非常耗能。所以如果你考虑总拥有成本、能耗,也许你不想造巨大的 SRAM 芯片。你真的想在需要的地方用它。有人问这些东西会不会扩展到 32 以上,或者按 64 的块操作?不。因为只要它能整除你的 burst size,就没问题。这是除数性质。有些其他现象可能从更大除数中受益,但这里不是。有人问为什么是 108?那就是 SM 的数量。你得去问 Jensen,我没有答案。有人问 GPU 和 Cerebras 晶圆级引擎?坐那边的人比我懂晶圆级。我们上节课后聊过编译有多难,因为晶圆级会有波干涉效应和各种可怕的事情。那是我不熟悉的世界,但我觉得比我们这里复杂得多。好。
FlashAttention:把一切串起来
现在我们可以谈某种意义上 Finale 了。我们已经学了很多 GPU 的东西。如果能把这些串起来,重现或理解一个我认为非常重要且先进的东西,那就很好,那就是 FlashAttention。FlashAttention 是注意力能力很长一段时间以来的重大改进之一。它全是系统。它从 PyTorch 朴素的注意力实现,变成一个非常巧妙的融合 kernel。这是原始论文的图。你在服务这些东西的延迟上看到巨大改善。正如我两讲前展示的,FlashAttention 是一种内存高效的注意力计算方式,所以你可以做越来越大、越来越长的注意力。你可以看到,很多收益来自从 HBM,也就是全局内存,传输的内存量的改善。
他们实际做了什么?如果读论文,他们会说,我们只做两件事:应用 tiling 和 recomputation。然后我们以一种允许内存访问亚二次(subquadratic)的方式来做。我认为我们现在理解所有这些组件了。看看实际发生了什么。记住,注意力计算有几样东西:K、Q、V。我们用 K 和 Q 做矩阵乘,最后和 V 做矩阵乘,中间有一个 softmax。所以需要处理的操作是三个矩阵乘和一个 softmax。我们有矩阵乘,现在知道怎么做了。所以我们会做 tiled 矩阵乘。论文的 Figure 1 实际上就是 tiled 矩阵乘。我们之前见过。我们拿输入 K 和 Q,切碎,然后逐块在 tile 上乘,计算完写出去。这里没什么花哨或特别。
FlashAttention 的棘手之处——你在作业里也会看到——是有一个覆盖整个东西的全局 softmax。所以我们不能天真地做,因为 softmax 是全局操作,把所有东西绑在一起。它连接所有不同 tile。可能不明显如何对注意力做 tiling。关键技巧,一旦你看到它,这真的是核心想法或观察,一旦看到,剩下的就非常简单。通常计算 softmax 会像这张图一样:把所有元素指数化,为了数值稳定性减去最大值,然后归一化。这是标准 softmax。现在有一种在线计算的不同方式。一旦意识到,其实很简单:你一边走一边计算归一化 softmax。每当你遇到一个比之前见过的更大的数,你就换掉最大值。所以你有一种随着进行在线计算 softmax 的方法。唯一 tricky 的是最大值,我们处理最大值的方式是跟踪最大值,每次看到新最大值就换掉并修正。否则,这真的只是我们见过的所有东西的在线运行和。当然,最后你把所有东西除以在线计算的累加器。
softmax 重要的是,因为它是在线的,一块一块、一片一片地进行,我们可以逐 tile 计算。我不需要看到其余 tile 就能计算这个。我可以计算我的 tile 的 softmax,保存部分结果,也许写回全局内存,然后继续。这让我们能把所有相互作用的部件拆成更简单的东西。
我想这是来自 FlashAttention 2 或 3,我觉得是 2。我喜欢这个可视化,它展示了哪些部分被 tiling。虚线块是在 SRAM 中做的、tiled。蓝色框是在高带宽内存,也就是全局内存里。你看到的是:假设我们做 K、Q 内积。这些是 tiled 矩阵乘,存在 SRAM 里。我们把它们保持在 SRAM,计算指数。然后类似地,在这个 tile 里,我们计算运行 softmax。然后移动到下一个 tile,做同样的事。但现在我保留 softmax 的部分运行和。我可以把它们放在共享内存、寄存器或其他地方。需要跟踪的东西很少。最后,我计算完所有指数化量和归一化器后,很简单地再以 tile 形式乘 V,最后除。这让我能把注意力操作切成这些小 tiled 块,然后进行。
最后,还有融合。你尽量把这两件事一起做,而不来回走。我不打算覆盖或讨论这个,但你可以在反向传播中重计算你需要的一切。FlashAttention 的最后一块是:如果你保存了激活,你仍有 N 平方大小的激活需要为注意力保存。与其保存,不如全部扔掉,在反向传播时逐 tile 重算一切。所以它也有重计算的部分。这就给出了 FlashAttention。
现在我们已经看到所有不同技巧在行动。基本上就是这些。你看到的很多高效实现和用好 GPU 的技巧,都归结为这些想法。把它们放回一起:理解硬件很重要。硬件,在我们的例子里是 GPU,驱动规模和计算。我们需要真正理解到低层细节,才能完全掌握为什么做某些决策。我不希望你们成为那种为矩阵 cargo cult 32 倍数的人。我希望你们真正理解为什么我们做这些事。这在我看来一直深入到硬件层面。另一件事是,当前计算增长的方式意味着我们真的很需要思考矩阵乘。它们是核心操作,算术密度非常高。因为计算和内存之间的差距,我们需要非常仔细地思考数据移动。最后,像 FlashAttention 这样的东西,我觉得真的很酷。还有非常硬件感知的架构工作。做架构设计时仔细思考硬件,对未来的系统性能至关重要。谢谢。
GPU 编程模型、性能陷阱与 Triton 内核入门
这堂课延续上一节对 GPU 的概述,深入 GPU 编程模型、benchmarking 与 profiling、Triton kernel、PTX,以及 softmax、归约和 tiling matmul 等例子,并说明硬件约束如何决定性能。
从 GPU 内存层次回顾开始
欢迎回来。周一 Tatsu 做了一场很好的讲解,描述了 GPU 的高层概览,以及如何思考性能和 GPU 的各种怪癖。这节课是那个的延续,我们会更深入地看代码,写一些 Triton kernel,并做 benchmarking 和 profiling。先回顾一下典型 GPU 的简化图:有内存,然后有真正的 GPU 芯片。我们关注 NVIDIA GPU,从 A100、H100 到 B200。每一代 GPU 有一些流式多处理器(Streaming Multiprocessors, SM),数量大约在 100 到 200 之间,这一点变化不大。SM 内部有一组寄存器,B200 有 65,000 个寄存器,每个 SM 总共 256K 寄存器,这也没怎么变。
此外还有 L1 缓存和共享内存(shared memory)。记住,它们在同一块内存上,但共享内存你可以控制,L1 你不能。这是每个 SM 的。还有 L2 缓存,不属于单个 SM,而是整个芯片,稍微大一些。最后是高带宽内存(High Bandwidth Memory, HBM),很大,而且这个数字增长得相当多。除了大小,还要考虑带宽,基本上与大小成反比:寄存器非常快,L1 稍慢,L2 更慢,HBM 最慢,尽管 8 TB/s 在大局中也不算慢。这是你脑子里应该有的主要层次:大内存慢而远但大;寄存器、L1 这样的快内存位于 SM 上,局部、快,但小。这就是我们面对的环境。
线程、线程块与网格
那么怎么给 GPU 编程?编程模型是这样的:有线程(thread),每个线程在一小部分数据上执行一段代码。每个箭头可以看成一个线程。线程被组织成线程块(thread block),也叫并发线程数组(Concurrent Thread Arrays, CTA)。这是一组线程。最后,一组线程块构成网格(grid)。当你启动一个 kernel 时,你基本上是在启动一个由线程和线程块组成的网格,让它们同时做计算。这是简化版,还有一些别的东西。比如 H100 和 B200 也有 thread block cluster,是线程块的集群,能实现一定程度的分布式内存。B200 还有 tensor memory,用于 tensor core,位于寄存器和共享内存之间。有些对程序员不可见,但在硬件里。不过这个入门课先不担心它们。
你可能会问,为什么要有线程块?为什么不能只有线程网格,每个线程拿一块数据做事情?如果你只需要 element-wise 操作,这通常没问题。例如 GeLU(Gaussian Error Linear Unit)是逐元素激活函数,线程很自然,每个线程处理一个元素,就像 for i 遍历数据集。但对于涉及线程间通信的操作,比如 softmax 或矩阵乘法(matmul),这个视角就不够了。如果你愿意付出读写 HBM 的代价,它也能工作:比如矩阵乘法,每个线程计算输出矩阵的一个元素,只从 HBM 读写。但 HBM 很慢,所以这不是好策略。我们应该用共享内存,它位于 SM 本地。线程块让你能思考一组线程共同访问这块共享内存。因此,一个线程块会被调度到一个 SM 上,做它的事:从 HBM 读一批数据,处理,处理可能涉及通过共享内存在线程间通信,然后写回。这是关键点,后面讲 tiling 时整个游戏就在这儿。在 Triton 里,我们会原生地按线程块来思考,习惯之后会容易很多。
编程模型与硬件的张力
编程模型本身相当简单:线程、线程块、网格。它很好地抽象了硬件。写 kernel 时,你只需知道有一堆线程块,定义它们,然后定义块内线程或线程块要做什么计算。这部分写起来像写 Python,并不难。如果只关心正确性,知道这些就够了。但实践中,性能对硬件非常敏感,要获得高性能必须深入理解硬件。我们讨论 GPU 和 kernel 的全部原因,就是压榨性能。这里有两个层次:你要理解计算,这只是编程模型的一部分;但它跑多快,强烈依赖硬件。我接下来会举大约五个例子,让你感受这些考虑。有些是 Tatsu 讲过的复习,但希望能强化概念。
有一个东西叫 warp(线程束),我之前没讲。在简化视角里,warp 不属于编程模型那幅干净图景。你有线程、线程块、网格。技术上你也可以接触 warp,但编程时不必须。warp 是什么?每个线程块是一组线程,但这些线程实际被分组成 warp,基本上每个 warp 32 个线程。比如线程块有 64 个线程,就有两个 warp,前 32 和后 32。正如 Tatsu 上次说的,一个 warp 内所有线程必须在 SM 上锁步执行同一条指令。每个周期必须执行完全相同的指令。控制分歧(control divergence)是指同一 warp 内不同线程需要执行不同指令。比如分支 if something then A else B,你只能先让 warp 里线程做 A,再做 B,于是变成串行。这很糟、很低效,所以分支一般要避免。
warp 很酷的一点是,一个 SM 实际运行多个 warp。有 warp 调度器,还有一批常驻 warp 准备运行。每个 warp 的线程有寄存器。SM 可以在它们之间零成本切换。这在 CPU 上通常不成立。这样设计是为了隐藏延迟。这很重要,因为某个 warp 可能正在读 HBM,而 HBM 很贵,可能要 100 个周期。你不想让这个 warp 干等,而是立刻切到另一个 warp,让它做 tensor core 运算。warp 还会引出占用率(occupancy)的概念,更准确说是 warp 占用率。硬件约束说每个线程最多用 255 个寄存器。SM 有固定数量寄存器。每个线程用的寄存器越多,能有的线程越少,这是数学。这会降低占用率。但不一定坏,因为如果线程更少但每个线程做更多工作,可能反而好。所以占用率可以测量,但不一定越大越好,还有其他权衡。一个例子是 thread coarsening:比如 element-wise 操作,可以让一个线程只处理一个元素,线程很多;也可以让每个线程处理多个元素,比如常数 8。这样线程更少,调度更容易,但每线程做更多工作。所以如果线程很轻,也许想把它们加厚一点。
举个例子。假设一个线程块有 128 个线程,每个线程使用 160 个寄存器。硬件约束:B200 每个 SM 最多 65,000 个寄存器,并且不能同时运行超过 64 个 warp。算一下占用率。每线程寄存器数必须小于 255,这没问题。每块使用的寄存器数是每块线程数乘以每线程寄存器数,大约是 20,000。这意味着 SM 上最多同时运行 3 个块,对应 12 个 warp。最大 warp 数是 64,所以占用率是 18%。我们只运行了总 warp 数的 18%,因为每线程寄存器使用很多。这个例子说明内存/寄存器约束会限制你能做多少计算。
Bank conflicts、memory coalescing 与 block occupancy
另一个例子是 bank 冲突(bank conflicts),适用于共享内存。共享内存像 L1,位于 SM 上。硬件上共享内存分成 32 个 bank,每个 4 字节宽。每个 bank 有很多元素。约束是每个时钟周期每个 bank 最多只能被一个线程访问。假设不是同一位置。比如不能同时访问这个位置和这个位置。所以多个线程访问同一个 bank 时,访问必须串行化,这叫 bank 冲突。最坏情况,想象一个矩阵这样布局,有个操作让 32 个线程都去访问第一列。你以为 32 很好,可以大规模并行,结果它们排队。这是 32-way bank 冲突,最差情况。你可能会说,那就访问行好了。但在 matmul 中不可避免,因为你必须访问一个矩阵的行和另一个矩阵的列,有时还要转置,不能总选择遍历顺序。对于 element-wise 操作没问题,任意顺序都行。但 matmul 中你不能控制每个矩阵是行主还是列主。有一些解决方案,比如 swizzling,重新排列共享内存,让遍历时避免 bank 冲突。这也是 profiling 时可以看的:bank 冲突、占用率,都能看到。
最后还有两件事。内存合并(memory coalescing),Tatsu 讲过,我快速提醒。当一个 warp 的 32 个线程访问 HBM 时,内存访问会合并成 128 字节的事务,叫缓存行(cache line),一次取回。想象内存这样布局,这是 32。最好情况叫完全合并(full coalescing),所有线程访问同一个缓存行。线程 1 访问 M00,线程 2 访问 M01,等等。这样一次抓取整个缓存行。而如果沿着列走,就会取很多后面不用的内存,第二行也一样。这感觉像 bank 冲突,但约束很不同。这个关于共享内存,那个关于 HBM。
最后一个,块占用率(block occupancy)。线程块被调度到 SM 上。逻辑上你可以定义任意多线程块,但物理上芯片只有一定数量的 SM,比如 148 个。如果你启动 160 个线程块,只能先调度 148 个,等它们完成后才调度剩下 12 个。但调度 12 个时,很多 SM 什么都不做。这叫低占用率。当最后一波线程块少于最大线程块数时发生。一般来说,最好让线程块数量能整除 SM 数。总结:编程模型很优雅,有线程块网格,块内有线程。内存方面,HBM 对所有人全局,共享内存对线程块局部,寄存器对线程局部。但硬件细节——warp、bank 冲突、内存合并、占用率——真正决定性能。很多细节很难知道,profiling 会告诉很多信息,但你必须知道 SM 数量、各种尺寸。有时调度器做的事情你无法控制。所以比编程模型更乱。
这里停下来问问题。有学生问,能否让一个块共享 SM?比如 140 个 SM 和若干块,是否能让块共享 SM?回答是,如果块已经用掉 SM 上大部分 tensor core,再放一个块也不会加速。根本问题是块必须保持在一起,所以有不均匀的锯齿状问题,不能把一个块拆开铺到别处。你能做的是改变块大小,改变块的数量,避免最后那个尾巴。还有其他问题吗?
Benchmarking 与 Profiling 哲学
好,继续。希望大家对 GPU 更熟悉了。现在讲 benchmarking 和 profiling。我可能不会讲太多内容,但想强调哲学:这是成功的配方。你 benchmark/profile 代码,做改动,再 benchmark/profile。为什么先讲 benchmarking 和 profiling,而不是更早教 Triton?因为你应该先测量代码里发生了什么,找到瓶颈,再开始写 kernel。Benchmarking 基本就是事情花多长时间,给你端到端时间。它不告诉你时间花在哪里,但仍然有用,因为最终你关心的就是运行多久。而且因为它把一个东西浓缩成一个数,你能看到随维度如何扩展。有个不错的工具做 benchmarking,但这门课是 language models from scratch,所以我从零做,主要为了强调几个坑。
假设有个操作:矩阵乘法。run_operation 是个包装器,实例化两个大小为 dimension×dimension 的随机方阵,返回一个执行操作的函数。调用 matmul 就对这两个随机矩阵做矩阵乘法。怎么 benchmark?朴素做法是开始计时,运行,停止计时。但有几件事。第一,永远记得 warm-up。我第二讲提过,但值得强调。因为有些东西是惰性编译的,warm-up 确保那部分时间不算进去。大多数时候你关心的是反复运行时有多快,初始条件不重要。第二,通常要计时多次,因为有方差。正确计时要用 CUDA events,start event 和 end event,调用 record。实际做计算,然后 end event.record。记得 synchronize 等 CUDA 线程完成,因为 GPU 上是异步的,这是同步屏障。然后记录时间。可以重复,然后取平均。如果特别讲究,可能看整个分布、P95 等,但这里取平均。Benchmarking 还可以扩大矩阵,看时间变化。矩阵乘法应该按立方增长。但注意一个缺陷:直到接近 2000 维矩阵之前,时间基本是常数。因为 GPU 是为相当大的矩阵乘法构建的,2×2 矩阵非常低效。
Profiling 揭秘底层 kernel
快速讲 profiling。Profiling 告诉你时间实际花在哪里。希望大家都熟悉并在做。不那么明显的是,即使不关心时间,profiling 也能帮你弄清底层实际发生了什么。尤其用高级语言时,你写代码得到结果,有时理解底层很有好处。PyTorch 有内置 profiler。作业里会用 Nsight,给更多细节,这里为了时间跳过。举个例子,在 PyTorch 里把两个张量相加。run_operation 创建两个随机矩阵并应用操作。Profiling 时先 warm-up,然后放进 profiling 上下文,运行。看看 a+b 的 profile。平时你不会想太多,以为两个张量直接相加。底层发生了什么?看调用,有个长名字 kernel CUDA functor add。这基本就是加两个张量的 kernel。时间不有趣,因为只做加法,占 100% 时间。但它告诉你底层有 add 这个东西。
那 matmul 呢?PyTorch 里做 a @ b。类似地有个长名字描述这个 matmul kernel:Cutlass f32, f32, 64x64x16 等等。注意改变维度,比如现在做 128×128 matmul,你会得到不同的 kernel。仔细看,这个是 64x64x16,那个是 32x32x16。所以底层,PyTorch 看起来只是 a @ b,但底下可能发生各种事情。观察:你能看到实际调用哪些 CUDA kernel,通常长名字的那些。不同张量维度调用不同 CUDA kernel。名字也透露实现:Cutlass 是 NVIDIA 的 CUDA 线性代数库;SM100 对应 Blackwell 架构,说明这个 kernel 专为 Blackwell 设计;f32;64x64x16 是 tile 的形状,后面讲 tiling 时会展开。这就是 benchmarking 和 profiling,记住要做。作业会逼你做。
GeLU 的三种实现:naive、built-in、torch.compile
把 profiling 用到另一个例子:GeLU。GeLU 激活函数是常用非线性,常用 tanh 近似,对计算机更友好。朴素地在 PyTorch 实现,就是把公式写进去,得到结果。PyTorch 也有内置的 functional GeLU。可以验证两者对随机输入结果相同。还有很多人知道的一点:你可以拿任何 PyTorch 函数,调用 torch.compile,它会生成另一个函数,做同样的事。于是这场比赛有三匹马:朴素实现、内置实现、编译实现。Benchmark:朴素约 3.75,内置快很多,编译也快很多,但可能不如内置。它们计算相同答案,但性能特征差异巨大。
拉出 profiler 看底层。对朴素 GeLU 做 profile,看时间怎么花。有一堆不同 kernel:binary functor unary add,tanh 等。这对应 PyTorch 里写表达式时,计算图每个原语都实现成一个 kernel。为什么慢?因为启动 kernel 时,kernel 要从 HBM 读,拉到 SM,计算,写回。然后下一个 kernel 再从 HBM 读,写回,如此反复。kernel 调用之间数据必须回到 HBM,所以有大量读写。看内置实现,反而没什么有趣:一个 GeLU CUDA kernel 实现,单个 kernel 实现 GeLU。为什么存在?因为人们用 GeLU,有人写了 kernel 放进标准库,没什么魔法。
编译这里很有意思。我不细讲它怎么工作,但很吸引人。你可以拿朴素实现——它是 PyTorch,有计算图——跑一个编译器。看底层,它只是一个 kernel。因为它看懂计算图,然后把那个 kernel 用 Triton 写出来。所以这实际上是个 Triton kernel。总结:朴素实现多个 kernel,需要多次 HBM 读写,没有 kernel fusion,慢。内置和编译版本只有一个 kernel,GeLU 所有操作融合在一起,每元素只从 HBM 读一次写一次。编译 kernel 是 Triton kernel。这就自然引出 Triton 是什么。有问答:内置是 CUDA kernel 实现,有人用 CUDA 写的。为什么 Triton kernel 更快?这里其实不更快;编译版本是一个 Triton kernel,但比内置慢。去年做时更接近。这些变化且高度依赖硬件,这里都没怎么优化,只是给你大致概念。
Triton 编程模型
现在写一些 Triton kernel。回忆编程模型:一堆线程组织成线程组、线程块,线程块构成网格。如果用 CUDA 写——CUDA 最初由 NVIDIA 开发,多年来写 kernel 就是用它——心智模型是每个线程做什么。你写一段代码,带一个 ID 标识哪个线程,然后它执行。好处是贴近底层实际,细粒度控制。但缺点是所有线程在一个线程块里,有些操作需要它们通信,就必须同步。它们从 HBM 一次读,同步,计算,你要做这些簿记。如果全是 element-wise,CUDA 挺好,无所谓。但操作越复杂,Triton 的抽象价值越大。Triton 由 OpenAI 开发,现在很标准。你基本上指定每个线程块做什么。通常足够强大,尤其这门课。如果你想利用最新硬件每个新特性,可能不够灵活。但先不担心。Triton 的概念框架是:一个块把数据加载到共享内存,操作,再写回全局内存。所以这些块介于单个元素和整个操作之间。PyTorch 里你定义巨大矩阵,说乘起来,那是原子操作,你想的是怎么把东西变成大 matmul。Triton 是两者之间的混合。
第一个 Triton GeLU kernel
从 GeLU 例子开始。定义 8000 维向量,开始写 Triton。Triton 基本上就是写 Python。有多少人写过 Triton?举手。不多,很好,不会无聊。先准备普通 PyTorch。没有 Triton,只是准备。把张量拿来,分配输出张量。因为在 Triton 里不再函数式思考,而是显式读写。没有返回值。分配输出张量,kernel 会写进去。这个张量可能任意大,不能全放进一个 SM,太大。所以要分块。把 x 看成数组,切成块。总元素 8000,blocksize 先设 1024,于是有 8 个块。然后调用 kernel,用这种奇怪语法:triton_gelu_kernel[(num_blocks,)]。括号里是网格形状,表示网格有 num_blocks 个块。对每个块调用 triton_gelu_kernel 函数,传入 x, y, num_elements, blocksize。
没法逐行 trace,直接看代码。这是能想象的最简单 kernel。看 triton_gelu_kernel,现在参数是 x 和 y 指针。你可以把它们看成整数,是地址。要习惯这一点。然后有 number of elements 和 blocksize,从这里传进去。每个块会调用这个函数。第一件事是块醒来问:我是谁?程序 ID(PID)标识块。比如 PID 对第一个块是 0,然后是 1,2,3。然后要弄清操作哪段数据。start = PID * blocksize。这是 x 指针的偏移。PID 为 0,start 是 0;PID 为 1,start 是 blocksize;PID 为 2,start 是 2blocksize,依此类推。然后确定跨度:offsets = start + tl.arange(0, blocksize)。tl 是 Triton 库,概念上给出 0 到 blocksize-1 的整数。比如块 1,offsets 是 blocksize, blocksize+1 一直到 2blocksize-1。这里 num_elements 整除 blocksize,但一般不一定,所以 Triton 代码常见 masking。假设张量只到某处,就形成一个 mask,在那之前为 true,之后为 false。非最后块则全为 1。然后做读取。这是指针算术:x 指针是 x 的内存位置整数,加上 offsets,得到前 blocksize 个元素。根据 mask,被 mask 掉的不读。然后可以把它看成向量,做正常计算,得到 y。y 和 x 同样大小。然后 tl.store(y_ptr + offsets, y, mask)。从 HBM load,做事情,写回 HBM。
停下来问问题。有人问这个与 CUDA 的区别。对于 element-wise,看起来差不多。实际上 CUDA 更简单,因为它真的是 element-wise:线程醒来,识别线程,操作那个元素。这里只是向量化版本,有一个块,操作整个块。后面会看到,如果做的不只是 element-wise,块操作更强大,而 CUDA 会麻烦得多。另一个问题:如何用 tensor units?后面会展示代码编译成什么。简短回答:你不控制,硬件决定把东西放在哪里。还有人问,从 HBM 到 shared memory 再到 register 实际发生了什么,数据逐步流动过程。简短说,这在某种意义上是假的。不是 GPU 真的在调用 Triton 库执行这段代码。这基本上是为了让我们指定计算。编译器会接收它,后面会看到,写成一种叫 PTX 的东西,然后才真正干活。机制上就是这样。概念上,x 指针是 HBM 中的一个内存位置,指定一段内存位置,load 接收那些位置并返回关联数据。这里有个名为 x 的局部变量。实践中通常是 register 或 shared memory。Triton 决定那里怎么做,在 thread 层级上。有人问调用时是否清楚什么放 shared memory 什么放 register,还是现在才发生,太晚了。这就是所有 thread 闲置、等待内存刷入的地方。SM 上多个 warp,会切换。对,就是这样。
PTX 以及线程实际执行什么
所有 kernel 都长得差不多:输入、输出,醒来,确定要看哪个索引,读取,做事情,写回 HBM。现在简单讲 PTX。写 Triton 时,编译器生成 PTX 代码,这是 GPU 的中间汇编语言。我不逐行讲,只给感觉。先观察几件事。这是线程实际在做什么,不是线程块,因为线程块已经被编译掉了。LD.global 基本上是从 HBM 加载到寄存器。寄存器用 r 表示整数寄存器,fr 表示浮点寄存器。还有 mov 0 到 r5,mov 0 到 r6 等。然后有乘法:某个寄存器乘以常数,放进另一个。这段代码会执行。底部能看到 global store,写回 HBM。这是线程实际执行的代码。Triton 是上面一层。另一个注意点:你会看到这些块,里面有 thread coarsening。这是一个线程,但处理不止一个元素,而是八个元素。编译器认为这个线程很轻,做的不多,就把它加厚一点。看 PTX 能让你体会底层在干什么。有人问这是不是为每个线程生成?这是编译一次,每个线程运行同一段代码。线程区分自己的方式是代码会传入线程 ID。CTA.x 是块索引,tid.x 是线程索引。所以这段代码运行时,知道我在哪个块,tid.x 知道我在块内哪个线程。还有其他问题吗?
PTX 通常由编译器生成,不是你平时写的东西。看起来像汇编。有人确实写 PTX,如果你觉得自己比编译器强。NVIDIA 编译器通常很成熟,但一些不太发达的加速器可能有时需要手动多管一点。一般来说不需要。有人评论:工作被调度到 SM 上,遇到 load 几乎像 CPU trap call,等待事情发生,自己闲着,其他工作被调度过来,上传完成后重新调度继续。对,是这样。看 Triton 代码,这些 load 语句会阻塞若干周期。它运行在某个 SM 的某个 warp 的某个线程上。记住 SM 同时运行多个 warp。到那个点时,可以找另一个 warp 运行。完成后 warp 调度器再回来接管。为什么有四个 warp 调度器?我不确切知道原因。
Softmax:行适配一个块
现在看其他例子。快速预览:还要做三个例子。GeLU 是最简单形式,虽然计算有 messiness,但只是 element-wise,概念上很简单。然后看 softmax,需要做归约,先考虑一行能放进一个块的情况。然后考虑一行放不进块的情况,再上到 matmul。到那时你就有做作业和实现 flash attention 的所有 ingredients。到目前为止看的是 element-wise。现在想聚合多个值的操作。回忆 softmax:把它看成矩阵,对每行指数化并归一化。用于 attention 和生成输出概率。先看朴素实现,跟踪发生了什么。定义 tensor,朴素实现。这大概在作业一里。有 M×N 矩阵。对每行计算 max,为了数值稳定,减去 max,然后逐元素指数化,求和,计算每行的归一化常数,再除。就这些。
数一下读写。这是普通 PyTorch,这是不同 kernel,这是不同 kernel。除非调用 torch.compile,这些都是不同操作。每个操作都从 HBM 读写。所以有 5 MN 次读,3 MN 次写。原则上应该少得多。这段代码大家都应该熟悉 softmax 在做什么。现在写 Triton kernel。形式上很像 GeLU。做完脚手架后,核心计算看起来很像朴素版本。我们让每行一个块。为什么每行一个块?因为每行要归一化和求和,不是 element-wise,但它是 row-wise。块之间不交互,不需要跨块共享内存。所以每行内做事情。
在 Python 里分配输出张量,有 M×N 矩阵。定义 blocksize 为列数,取下一个 2 的幂。块数是行数 M。调用 kernel。有多少块?M,每行一个。每个块传入输入指针、输出指针,还传 strides,告诉往下走多远。看 softmax kernel。醒来,在特定行。offsets 给出从 0 到 blocksize 的所有列,假设这就是所有列。读取。需要确定从内存哪里读,是数据起点加上哪一行。行 stride 基本上就是列数,告诉往下走多远。x pointers 是需要加载的所有数据地址。加载。如果被 mask 掉,就放负无穷,因为对 softmax 等价于 0。然后这部分和朴素 softmax 一样:计算 max,减去,指数化,求和,除。然后写回。这是每块可以跨整行的版本。Triton 让这很容易,像写普通 PyTorch。如果都能塞进一个块,几乎可以写普通 PyTorch。问答:如果列数、行数大于 blocksize?后面会讲。一般来说行列会比 blocksize 大很多。按列做 softmax 呢?也可以,只需改 stride 来访问列,列偏移乘以行 stride。
行太大:分 tile 迭代归约
好,现在为 matmul 热身。假设一行放不进一个块。比如一行 4000 列,blocksize 只有 1024。怎么办?策略是把行分成 tile。这里有四个 tile。每个线程迭代 tiles 并累加和。最后做归约,把每个线程产生的部分和加起来。为了好懂,从 softmax 换成 row sum。内置 row sum 没有惊喜,就是求每行和。概念上:每个块仍负责一行。假设块 1 行 1。醒来,现在有 tiles。tile 0 是列 0 到 3,tile 1 是列 4 到 7,tile 2 是列 8 到 11。每个线程保持累加器,处理第一个 tile。然后下一个 tile,把当前元素加到累加器。比如放 3,1,4,1;第二轮加 5 得 8,加 9 得 10,2 加到 4,6 加到 1。四个线程各自累积。tile 2 再加 5 和 3 到各自累加器。最后得到一个累加器向量,再求和。
看代码。调用 row sum kernel。醒来在某一行。这是一行的样子:tile 1、tile 2、tile 3 等等。n 是那一行的元素数,blocksize 是 tile 大小。记住 blocksize 是线程数。我处理的数据比 blocksize 大,所以必须迭代。循环所有 tiles:start 从 0 到 blocksize 到 2*blocksize 等等。每次跳转,获取那个 tile 的 offsets,从 HBM 加载数据,加到累加器。累加器可能在寄存器或共享内存。循环完所有 tiles,处理完整行后,每个线程有自己累积的值。可以求和得到标量,写出去。这比以前复杂,因为线程内有 for 循环。当数据放不进一个块时这是必要的。问答:累加器放哪里?至少在这个 Triton 程序里不显式说,由 Triton 编译器决定。一般来说,如果 blocksize 足够大,必须放共享内存。还要注意 GeLU 时也把行分成 pieces,但那些是独立的块。这里不是块,是 tiles。块对应整行,块必须处理所有 tiles。所以开始不像 PyTorch,因为不是所有数据都能放进共享内存。
Matmul 与 tiling
现在进入 finale:matmul。矩阵乘法是深度学习的 bread and butter,被优化到极致,非常基础。拿两个矩阵相乘。我加个小 twist:matmul 后接 ReLU,随便玩玩。这会发生,因为线性层是 matmul,然后 ReLU 激活,不算凭空。后面会展示为什么这么做。怎么构建 matmul kernel?朴素方法:A 是 M×K,B 是 K×N,C 是 M×N。固定一个输出元素,比如 C5。对每个 k,遍历 A 的行和 B 的列,从 HBM 读,乘,累加,最后写这个元素。这是有效的 matmul kernel。但问题呢?它正确,但读写数量不好。对每个 M,N,K 都要从 HBM 读,量级是 MKN 次读。关心的是瓶颈。第二讲说过,算术强度是操作数除以传输字节数,希望它高。操作数是 MKN 量级,读也是同样量级,所以算术强度是常数,不好。仔细看,有大量冗余读。计算 C4 需要读 A4,A5,A6;计算 C5 又要再读一遍。如果只读一次就省很多。用共享内存来做。
理想化方法:把 A 和 B 全部加载到共享内存,然后算 C。这样就没有立方数量的读,只有平方数量的读,算术强度达到 O(n) 量级。第二讲说这是理想情况。没有冗余读,所有东西读一次进共享内存,计算,写回。但问题是什么?A 和 B 通常太大,放不进共享内存。所以必须用经典的 tiling。想法是尽量把能放的放进共享内存。全局看像朴素方法,局部看像理想方法。图景:拿矩阵 C,朴素方法对每个元素计算,现在对每个 tile 计算。把 C 分成 tiles,每个 tile 是一个线程块,有一堆线程负责计算。不同 tile 由另一个线程块完全独立计算。在 Triton 里,我醒来,看着这个线程块。要做什么?有点像朴素方法,对 A 的每个行 tile,扫描行;对 B 的每个列 tile,加载对应的 A tile 和 B tile 到共享内存。把它们相乘,像理想方法。累积部分和,都在共享内存。完成行列扫描后,把输出 tile 写到 HBM。算术强度现在升到 O(tile size)。一般到不了 O(n),因为那要求全部塞进共享内存,但 tile 大就还不错。bonus:既然写 kernel,如果想应用 element-wise 激活,最后加进去很容易,这叫 kernel fusion。
快速讲实现。先提醒 strides。张量是多维数组,但在内存里线性化。stride 告诉你如何把多维索引(如行、列)映射到实际索引:行乘以行 stride,列乘以列 stride。比如每前进一行,在内存中走 4 个位置;每前进一列,走 1 个。如果转置,就反过来。这个 kernel 长这样。启动不有趣。醒来,你在 tile M,N 上。你负责计算 C 矩阵的 M,N tile。有一堆索引操作,我快速略过,直接但要注意跟踪。这告诉你 A 的哪些行,B 的哪些列。然后是 1 到 K。得到 A 和 B 在 tile 位置的指针。设置累加器矩阵,M×N,在共享内存。然后像行归约:对所有 tiles 求和。不过现在同时沿 A 的行 tile 和 B 的列 tile。加载小 A、小 B,执行 dot。记住,东西在共享内存时看起来像 PyTorch,可以直接说 matmul,它会做。然后推进到下一个 A 行 tile 和 B 列 tile。bonus 是在写回 HBM 前,可以应用 element-wise 非线性。最后写出去。有些索引要注意,但算法形式清楚。
总结与问答
总结。今天讲了编程模型:你可以用 PyTorch、Triton 或 PTX 来谈。这是程序员能控制的。甚至 PTX,你可以写 PTX 并完全控制、特化。但这不是全貌,因为代码必须在硬件上跑,只有有限数量的 SM、bank,内存和寄存器大小都有限。你带着大矩阵和 transformer 来,必须适配硬件约束。所以 benchmarking 和 profiling 很重要,理解硬件的混乱如何转化为性能。我们讲了 Triton,它是个很漂亮的语言,让你按线程块思考。希望你现在能体会到,按线程块思考比按单个线程容易,因为不用显式同步线程或做共享内存。思考方式是:有计算,分解成线程块,块从共享内存读,做事情,写回 HBM。例子难度递增:element-wise 最容易;行归约;行放不下的归约,引入 baby tiling;matmul 是真正做 tiling 的经典例子。这就是关于单 GPU 编程的全部。下次讲多 GPU 编程。
问答。有人问如果想写 kernel,有哪些替代?必须用 Triton 吗?每个选择能多接近最优?回答:替代 Triton 的有不同权衡。每种语言都有归纳偏置,让某些事容易、某些事难。Triton 是训练 transformer 的人做的,所以涉及 transformer 的东西会相对容易。极端情况下可以写 PTX,但不建议作为第一步。还有其他库,比如 ThunderKittens、CUTE,各种 DSL,它们不一定在栈上更高或更低,只是特性不同。另一个问题:高维张量处理,最佳方法是什么?是把两个张量一次性加载到很多线程或线程块上同时计算,还是像这里一样逐元素处理再写回?简短说,很难抽象回答,取决于计算的性质。可以线下聊。好,下次见。
CS336 第七讲:并行——集合通信原语与三种并行策略
这堂课先讲分布式通信原语和 GPU 互连的硬件层级,再用 MLP 的极简实现演示数据并行、张量并行和流水线并行三种切分模型的方式。
从一块 GPU 到多块 GPU:为什么需要并行
好,我们开始吧。欢迎回来,各位。今天我们要讲并行。记得上周我们介绍了如何通过写 kernel 让单个 GPU 跑得快,还深入看了 GPU 内部——有高带宽内存 HBM、L2 cache、L1 cache、寄存器,还有一堆流式多处理器(streaming multiprocessor,SM)。这周我们要讲如何利用多块 GPU,让你的代码跑得更快。你脑子里该有的图景大概是这样的:上周我们只盯着其中一个盒子,现在图景扩展了,因为你可能不止有一块 GPU,可能有四块,也可能有一千块,这些 GPU 会被连接起来——怎么连我稍后会讲——然后你得想办法利用这些算力来训练模型。
无论单 GPU 还是今天要讲的多 GPU,把镜头拉远看,情况其实很像:计算单元——算术逻辑单元、tensor core 这些——离数据很远。在单 GPU 里,“很远”意味着数据远在 HBM 那头;到了多 GPU,你需要的数据可能整整齐齐放在另一块 GPU 上,你得想办法把它挪过来。但原则是一样的,因为这场游戏的核心就是编排计算,尽量避免数据传输瓶颈。用一大堆 GPU 很容易,把它们用好很难。
稍微自由发挥一下,我们可以想一个广义的层级结构:在最靠近 SM 的本地层面,是单节点单 GPU 里的 L1 cache 和共享内存,这是最快的;再往外是 HBM,上周我们还在抱怨它那么慢,但在这堂课里,HBM 要算快的了。接着我们要考虑单节点多 GPU 的设置,GPU 之间通过 NVLink 和 NVSwitch 连接。最后是多节点多 GPU,那就得用 InfiniBand 或者以太网,取决于你手上是什么网络。
上周我们讲了各种减少内存访问的技巧:融合(fusion)和分块(tiling),把数据读进共享内存,尽量多算一会儿,再写回去。这周我们要讲的是,如何通过恰当的复制和分片,减少 GPU 之间的通信量。
为什么要用多 GPU?最显然的答案是想要扩展规模,但说得更细一点,其实有两个原因。一是你的参数、激活值、梯度或者优化器状态装不进单块 GPU 的 HBM——B200 有 192 GB,如果你训练一个一万亿参数的模型,一块卡显然装不下。另一个原因是,即使模型能装进一块 GPU,你也可能想利用更多 GPU,把东西拆开来训得更快。所以有时候要做权衡:如果全都塞在一块卡上,核就少;摊开之后,又要付通信带宽的代价。这中间要算一算,才能决定怎么并行。
先说明一件事:到目前为止,这门课的代码都是 Python 的,你直接执行它,什么都能显示出来。但这堂课如果直接跑,它用的是 multiprocessing。我跟踪代码的时候,会把它放进一个特殊的单进程模式。如果你想看这堂课在多进程设置下的标准输出,可以点这里,我在讲的过程中会展示。只是要记住,我们一步步走这堂课的时候,并没有真的在做多进程,只是在单步执行一行行代码。
这堂课分两部分。第一部分,我们要学习分布式通信与计算的构建块,从编程模型开始,讲一点硬件,然后开始用 Torch 实现——这也是你们作业二要做的。第二部分,我们看真正的训练,看三种并行:数据并行、张量并行、流水线并行,每一种都以不同的方式切分模型。我们会用 MLP 而不是完整的 transformer 来演示,但这里展示的是核心计算。
集合通信:来自分布式编程的老原语
首先要谈的是集合操作(collective operations)。这些是分布式编程里的原语,可以追溯到 80 年代。并行编程这个想法非常古老,它不是为 LLM 训练发明的,但直到今天,我们用的仍然是这些原语。这里 collective 的意思是:你指定的是一个跨多设备的通用通信模式或模板,而不是去点对点地管理这块 GPU 怎么跟那块 GPU 通信。这样会容易得多,系统也能替你干更多活。这是一个久经考验的接口。
一般设置是这样的。这里的术语有点怪——我自己也觉得有点怪——但这是并行编程里的标准说法。你有一堆 rank,rank 对应一个特定的设备,在我们的情况下是 GPU,也可能是 TPU。比如这里有四个 rank,world size 对应设备数量,所以这里的 world size 是 4。
要讲的操作有:broadcast、scatter、gather、reduce、all-gather、reduce-scatter、all-reduce,还有 all-to-all。每一个操作都规定了这一组 rank 或设备如何把一定量的数据/计算传给另一组设备。前四个——broadcast、scatter、gather、reduce——算是热身,它们让你对这些集合操作怎么工作有个感觉,但它们并不是驱动训练的主力。all-gather、reduce-scatter 和 all-reduce 才是会在语言模型分布式训练里反复出现的。最后 all-to-all 我在这里提一下,它对 MoE 很重要,但这堂课不会花太多时间。
四个热身操作:broadcast、scatter、gather、reduce
先从最简单的操作 broadcast 开始。broadcast 里,有一个 rank 0——其实可以是任意 rank,只是举例方便选 0——它有一个张量 0、1、2、3,然后把它广播给所有 rank。操作结束后,每个 rank 上都有同一个张量。这个应该很直白。它一般不会出现在训练的核心路径上,通常用于初始化:比如你初始化一个 load、一个初始 checkpoint,然后广播给所有 rank。这种事只做一次。
第二个操作是 scatter。scatter 的意思是:rank 0 手上有一个张量,我把它切成 world size 份,散布到其他 rank 上。rank 0 拿到第 0 个分量,rank 1 拿到这个,rank 2 拿到这个,rank 3 拿到那个。它也不是直接使用的,但 scatter 是理解 reduce-scatter 的重要台阶。顾名思义,scatter 就是把某个地方的一个大张量铺开到多个地方。你能看出这为什么有用:你希望被散布到的 GPU 各自拿不同部分做本地计算。
scatter 的逆操作是 gather。这应该很好预测:输入是一堆分片,各自住在某个 rank 上;做 gather 时——相对于某个特定 rank,比如 rank 0——它就把所有分片拼接起来。gather 同样不是直接用的,但它是理解 all-gather 的台阶。
接下来是 reduce。做过函数式编程的人大概都熟悉 reduce,道理完全一样。起点和 gather 一样:数据分散在不同的 rank 上,然后你把归约操作作用在它们上面,把结果放到 rank 0。比如用求和来归约,0 加 1 加 2 加 3 就得到 6。你也可以把 gather 看成一种归约,只不过那个操作是拼接。当然,reduce 对理解 all-reduce 很重要。
暂停一下。这些只是热身。关于集合操作是什么、broadcast、scatter、gather、reduce,有人有问题吗?有个问题问:这跟 NumPy 里的 broadcasting 有关系吗?我觉得概念上是同一个想法,一个东西变成很多个,就像 NumPy 里一个标量被广播成一个张量。但这里的实例化是为集合通信做的,所以还是有点不一样。
all-gather、reduce-scatter 与 all-reduce
all-gather 做的事情,基本上就是对所有 rank 做 gather,而不只是 rank 0。回忆一下 gather 干什么:它把所有分片放到一个 rank 上,rank 0。all-gather 就是对每一个 rank 都做这件事,这就是 “all” 的意思——输出到所有 rank——而 gather 是你对所有这些 rank 做的事。
这个操作会反复出现。现在不必精确理解这句话,但后面我们会看到,每个 rank 持有参数的一部分,然后你需要把参数 all-gather 起来,得到完整参数,才能做完整的前向传播。总的来说,训练过程中我们会看到很多这样的模式:gather 一下、做点什么、再 scatter、再 gather、再 scatter。
reduce-scatter 是对每个维度做 reduce,然后散布结果。假设你有四个设备,每个上面有一个向量。之前做 reduce 时我们是 0、1、2、3 归约成 6;现在 reduce-scatter 说的是,对这个张量的每个分量分别做归约,然后把结果放到不同的 rank 上。第一个维度加起来得 6,第二个维度加起来得 10,第三个维度处理这些,第四个维度处理那些。
提前说一下这会出现在哪里:反向传播之后,你要对梯度求和。每块 GPU 处理的是不同的数据,你要做的是把来自不同分片的梯度全部加起来,然后把存储重新分布。
最后是 all-reduce。如果你理解了 all-reduce、gather 和 all-gather,那它就是先做一个、再做另一个。它的输入和前面的 reduce-scatter 一样。在 reduce-scatter 之后,6、10、14、18 分别在不同 rank 上;而 all-reduce 里的 all-gather 部分,就是把它们都放到同一个节点上。
all-reduce 某种程度上是最容易理解的:你有一堆张量,做归约——在这个例子里是求和——然后在所有节点上复制结果。这个操作我们其实会最先见到:做数据并行、对梯度求和的时候,然后复制完整参数。所以这里正是真正的起点。也许先把注意力集中在 all-reduce 上。稍后我们会看到怎么实现更复杂的东西,比如 ZeRO 或者 FSDP:我们需要把 all-reduce 拆成 reduce-scatter 和 all-gather,因为这样你才能介入进去,更好地管理这些事情。但对基本版本来说,all-reduce 就够了。
all-to-all 与 MoE 的动态路由
最后是 all-to-all。它在某种意义上是最通用的:你基本上就是指定每个 rank 如何把特定的消息发到另一个 rank。这里有个简单的例子,输入和之前一样。它表达的意思是:我想把 0 发到 rank 0,也就是留在自己这里;把 1 发到 rank 1,把 2 发到 rank 2,把 3 发到 rank 3。如果我是 rank 1,我想把 4 发到 rank 0,把 5 发到 rank 1,把 6 发到 rank 2,把 7 发到 rank 3。所以基本上,这里的位置就表示哪个 rank 是最终目的地。看输出的话:第一个 rank 会收到所有往第 0 列发东西的 rank 发来的内容,因为所有这些 rank 都把东西发给 rank 0;同理,rank 1 会收到所有 rank 发到这一列的内容,依此类推。
这个操作在训练 MoE 的时候很有用。直觉是这样的:每个 rank 既持有数据的一个分片,也持有一组专家的子集。MoE 的关键思想是动态路由——你必须看你的数据,才能决定要把这些激活值路由到哪些专家。所以它最终就是一种 all-to-all 通信。
如果一切都均衡——每个 rank 发给其他每个 rank 的字节数都一样——那么 all-to-all 你可以把它看成一次转置:把它想成一个矩阵,你做的就是把矩阵转置。但一般来说,all-to-all 也处理不均衡的拆分。我这里没有展示,但你可以配置它向任意 rank 发送任意数量的字节。不过总的来说,你还是希望拆分尽量均衡。记得 MoE 那一讲,我们做了负载均衡,就是为了让东西尽量均衡。所以理想情况下,all-to-all 最好看起来就像上面那样。
几个帮助记忆的总结与提问
总结一下,也给几个帮助记术语的提示,因为我刚刚一口气讲了好几个操作。reduce 就是做某种结合的、可交换的操作,可以是求和,可以是最大值,也可以是最小值。scatter 是 gather 的逆操作:scatter 分发,gather 集中。“all” 的意思是目的地是所有设备,这就解释了 all-reduce 和 all-gather。
停下来看有没有关于集合通信的问题。有人问:像 gather 这种把东西集中到 rank 0 的操作,rank 0 是每次都是同一块 GPU 吗,还是可以变?回答是:我说 rank 0 的时候,后面代码里你会看到,你基本上是指定 GPU ID 或者 rank,它就发到那里。所以它不必很早就确定,但基本上执行这次调用的时候必须确定。
另一个问题:这些只是概念的构建块吧?它们并不是真正的……这些是真正的实现吗?回答:现在展示的只是概念构建块,但我们很快就会看到它们在代码里怎么实现。
GPU 之间怎么连:NVLink、NVSwitch、InfiniBand 与以太网
在进入代码之前,我想讲讲硬件,特别是 GPU 是怎么连接的,因为我们已经知道一块 GPU 内部有什么了。先讲一般的网络。这是一张非常经典的图,从这张很有年代感的图片你就能看出来,计算机一般来说就是这么工作的:你有一台服务器,一堆 CPU,有一条 PCIe 总线——以前像鼠标键盘这类东西就接在上面——然后有一堆 GPU 挂在这上面,还有一些 RAM;这台计算机通过以太网连到另一台计算机,如此这般。这是一个特定的设置、一个特定的拓扑。同一节点上的 GPU 用 PCIe 通信,不同节点上的 GPU 就得一路走以太网。这就像你买了块游戏 GPU,然后跟朋友连起来说“我要训个大模型”,你就得这么干。
但如果你是真的认真训练,情况更像这样。这是我一开始展示过的那张图:有 GPU,还有 NVLink、NVSwitch 和 InfiniBand 这些东西。典型设置是这样的:每个节点 8 块 GPU——8 是典型数字,256 那个数是我编的——它们通过 NVIDIA 的 NVLink 连到一个交换机上。校准一下:如果用 NVLink 5,总带宽是 1.8 TB/s。记住 B200 的 HBM 是 8 TB/s,所以大概慢 4 倍。设备之间通信能到这个速度还是很快的,但显然比不过高带宽内存,而高带宽内存又比共享内存或 L1 cache 慢得多。
NVLink 连到交换机,这意味着从编程角度看,你可以认为任何一块 GPU 都能连到任何另一块 GPU。你从一块 GPU 到另一块,硬件负责把它传到交换机,交换机再路由。但通常你还会遇到另一个情况:GPU 数量涨上去之后,你不可能一直靠 NVSwitch 和 NVLink。于是你得把这些节点放进 pod,pod 之间用 InfiniBand 连接。InfiniBand 的工作方式是,这时候 GPU 不再直接连到另一块 GPU,它必须经过 PCIe,走一种特殊的 InfiniBand 线缆,而速度要低得多。
再往外,如果你的 InfiniBand 也用完了、碰上这些巨大的 pod,你就得用以太网把它们连起来。走以太网要经过 PCIe,而且实际上会经过 CPU——我们后面会看到,这更慢。这跟内存的层级结构有点类似:节点越多,就越慢。NVSwitch 不可能管十万块 GPU。
这里要提一句绕过 CPU 的事,从硬件角度讲这很重要。如果用传统的以太网,GPU 必须跟 CPU 打交道来拷贝数据:它先把数据拷到 CPU 的内核 socket buffer——这里的 kernel 不是 GPU kernel,是 CPU 传统的“内核”概念——然后构造网络包,拷到网络接口,再发出去。这通常会引入很多延迟。所以有了一项叫远程直接内存访问(Remote Direct Memory Access,RDMA)的技术,它允许一块 GPU 直接读写另一块 GPU 的内存,完全不碰 CPU。显然,在 NVLink 和 NVSwitch 的世界里是有 RDMA 的,InfiniBand 也支持 RDMA——如果你走 InfiniBand 连接,GPU 之间就能直接相连,不牵涉 CPU。但标准以太网不行。
有两个值得说的进展。NVIDIA 一直在把更大 pod 的极限往前推。B200 和 B300 这一代,他们有个叫 NVL72 的东西:也就是每盘八块 GPU 的托盘,一共九盘,最终 72 块 GPU 全部通过 NVSwitch 归到一个 NVLink 域里。而 NVLink 的速度非常快。我们普通人会想:好吧,我有八块 GPU 高速互连,出了这个域就慢一大截了。但如果你钱够多,买得起这些花哨硬件,就能得到最高 72 块 GPU 的快速互连。
另一个是我刚才说标准以太网不支持 RDMA,但以太网这边也进步了。有个东西叫 RoCE,也就是 RDMA over converged ethernet,它让以太网实际上也能绕过 CPU。这算是他们对 InfiniBand 的回应——InfiniBand 通常非常贵,很多 NVIDIA 产品也是。而用 RoCE 你也能获得相当不错的性能。Meta 有一些论文表明他们在探索这个方向;至于某个具体模型究竟是还是不是用 converged ethernet 训出来的,就不确定了。
好,这只是一次对硬件长什么样的简要巡览:你有 GPU,它们在一个域内通过 NVLink 连到 NVSwitch,可能是 8 块,也可能是 72 块,然后从那里开始用 InfiniBand。现在我们来谈怎么对这个编程。在最底层,有一个叫 NVIDIA Collective Communications Library 的东西,也就是 NCCL,读作 “nickel”,它把 all-reduce、reduce、broadcast 这样的集合操作,翻译成 GPU 之间实际发送的底层数据包。当你用 NCCL 的时候,你基本上就是在说“我要做 all-reduce”,然后 NCCL 会去搞清楚硬件拓扑是什么样、不同 GPU 之间的路径怎么走,然后它实际上会启动 GPU kernel 来收发数据。因为归根结底,记住,在 GPU 上跑的一切都是 kernel,所以也有通信 kernel,它们真正在跟其他 GPU 通信。我们不会深入研究 NCCL,只要知道它存在就行。
然后我们其实会转到 PyTorch。不过在这之前,关于硬件有什么问题吗?问题:能不能描述一下机架(rack)和托盘(tray)?回答:对 NVL72 来说,我不是硬件专家,但 rack 就是字面意义上的机架,你见过数据中心,就是那个。每个托盘上有两个 CPU,每个 CPU 连四块 GPU,所以每个托盘上有八块 GPU。它们叠起来,所有东西都连到这个 NVSwitch 上。
问题:RDMA 和 InfiniBand、NVLink 之间是什么区别?回答:RDMA 你可以把它理解成一个期望的性质。RDMA 意味着一块 GPU 能读写另一块 GPU 的内存,而做到 RDMA 有多种方式:一种是用 NVLink 和 NVSwitch,另一种是用 InfiniBand。所以 InfiniBand、NVSwitch、NVLink 更多是硬件,是哪些部件、哪些线缆和交换机在那儿;RDMA 更多是一种操作,是通信时发生的事情。比如 RoCE 就是做 RDMA 的另一种方式。
问题:NCCL 对多节点集群做过优化吗?对于基于 RDMA 的流程,NCCL 是最优的吗?回答:我不知道他们优化或没优化的细节。我只能说,NVIDIA 一直在为这些大模型的推理和训练优化他们的整个栈,因为他们的主要客户就是语言模型的主要提供方。如果说他们没有为这类工作负载考虑优化,我会很惊讶。
问题:如果你有九块 GPU 怎么办?怎么把负载分到这九块上?回答:这要看你这九块在设置里是怎么落的。比如很多时候你是每节点八块 GPU,那第九块就在另一个节点上。如果它们之间没有 NVLink 连接,那会很糟糕,因为那个节点提供的算力不多,而且跟它通信非常昂贵。但如果你所有东西都通过 NVSwitch 连起来,那就合理多了。
最后一个问题,然后我就往下走:这跟 TPU 有什么不同?TPU 一般是简单得多的对象。各个组件对应什么,我不是太熟悉,也许我们可以私下聊。现在让我们真正写点代码,把这些硬件的性能用起来。
torch.distributed:在代码里调用集合操作
PyTorch 很方便地提供了 torch.distributed 库,为这些集合操作提供干净的接口,这样你就不用显式去想 NCCL 了。事实上这个库还支持不同硬件的不同后端:如果你在 GPU 上,就用 NCCL 后端;如果你在 CPU 上,有个叫 gloo 的东西,仍然可以让你——就像我说的,并行处理在 GPU 之前就存在很久了——在 CPU 上也做这些集合操作。这个库还支持更高层的模型和算法,比如 FSDP,但这门课不会用,因为我们是从零构建。
走一遍集合操作的基础例子。有一个叫 spawn 的函数,它接受另一个我准备调用的函数,然后说我把它复制运行四次,4 就是 world size。看看它做了什么。这其实是我写的一个 wrapper,用来绕过这堂课没法做多进程这个事实。通常你会调用 Torch 的 multiprocessing.spawn 然后调用那个函数。但我会走这个禁用分布式的分支。
现在我在这个函数里,它本该为每个进程异步运行。记住,world size 是进程数,rank 是 0、1、2 一直到 world size 减 1。所以有 world size 个这样的函数,各自在一个进程上同时运行。我现在在 rank 0。我要做什么?先 setup,基本上就是配置 master 地址和端口。注意,这并不是 GPU 真正通信的方式,这更多是通用元数据和协调用的,实际数据会走 NCCL,否则会非常非常慢。如果你有 CUDA 可用,就可以用 NCCL 后端;我在自己的笔记本上,所以我会用 gloo 后端。
好,现在我来这里。假设我有四个这样的进程在运行。这里有个 barrier 函数,它的用处是——它是一个同步屏障。基本上,我看到这个就会等所有进程到达这个点。你可以认为所有进程都在异步跑,我并不真正控制它们:一个进程可能完全在另一个之前跑完,它们可能以任意方式交错。所以如果我想确保某些代码在其他代码之前执行,我就会加这些同步屏障。加更多屏障的缺点是,你最终可能会不必要地等待。
那我们试一下 all-reduce。我要创建这个张量,0、1、2、3,然后我有我的 rank。为了让它更有意思,每个 rank 会有一个不同的张量。我要打印出做 all-reduce 之前我手上有什么。现在我跳到这里,看看会打印出什么。rank 0 在 all-reduce 之前有 0、1、2、3;rank 1 有 1、2、3、4,依此类推。这跟我之前展示的例子一样。注意 print 语句是以任意顺序出现的,硬件想怎样就怎样,因为一切都是异步运行的,但所有数据都在那里。
现在如果我做 all-reduce,你传入这个张量——这是个 PyTorch 函数——你传入这个张量,传入归约操作,也就是求和,然后我说不要异步。它做的事是调用 gloo(在这个例子里是 gloo,也可能是 NCCL,那样的话它会启动 CUDA kernel),做通信,一切都帮你处理好,然后基本上是原地写回数据。所以 all-reduce 之后,我得到的是——记得 all-reduce 是什么——每一列的和,但复制在所有 rank 上。
如果你想更花哨一点、做异步,那可以设 async 等于 true,但那会把这些 print 语句搞乱。所以我加的 barrier 比平时要多。有问题吗?问题:rank 就是 GPU 吗?在这门课里,rank 就是 GPU。
再试一个例子,reduce-scatter。这里我要创建一个输入,从 0 一直到 world size。然后我要有输出,我分配输出。做 reduce-scatter 之前它长什么样?它是这样:输入是同一个,输出碰巧是零,但它本来可以是任何值。然后我做 reduce_scatter_tensor。这里不是原地写,而是有一个输出张量和一个输入张量。我说我要做求和,然后之后,输入不被改动,而输出得到每个分量的归约结果,写进各自的 rank。
问:all-reduce 异步是怎么工作的?它基本上是一个单体操作:你说“去做 all-reduce”,它启动 CUDA kernel,做通信。记住 CUDA 相对于进程来说本来就是异步的,而现在我们所有进程也都是异步的。重点是这段代码会直接返回,然后你就可以去做别的事情。一个典型做法——这门课不会讲——是重叠计算和通信:比如你可以做这个操作,然后去加载下一步要用的数据,这个数据和这个操作是独立的。等你需要确保它真的做完了,就调一个 wait 或者 barrier。
最后做 all-gather。到这里我觉得你已经明白思路了。这里我把 reduce-scatter 的输出设为输入,然后分配一个输出。all-gather 之前长这样:reduce-scatter 的结果在这里,输出只是刚分配,碰巧里面有些值,别管它。然后我做完 all_gather_into_tensor 之后,所有不同的输入就都聚集到了所有不同的 rank 上。你可以看到,这算是用例子证明了:all-reduce 等于 reduce-scatter 加 all-gather。
最后收个尾,就像我开始时做的 setup 一样做清理,清理是个好习惯。这就是你的第一个 Torch 分布式程序。
通信有多快:基准测试与有效带宽
我们来做一些基准测试。这个会很快,因为我想真正进入第二部分。通信到底有多快?做一个 all-reduce。这里我要对一亿个元素做 all-reduce。哎呀,抱歉,我这里弄错了。所以是 all-reduce。我要创建这个张量,包含这么多元素。记住,就像之前做基准测试一样,我们首先要预热。这里我要调用 CUDA synchronize,还要调用 barrier,只是想确保——因为这里有两种异步:CUDA kernel 和不同进程——我只是想确保开始计时之前,一切都不再运行并且已经完成。
然后我执行 all-reduce,再用 synchronize 和 barrier 等待并停止计时。记住,这在每个 rank 上都会跑。如果我看输出,rank 0、2、1、3,我可能会得到不同的时间,因为它们都是不同的进程,每个进程报告一个测量值。如果你想报一个数字,可以取平均。
现在有件有用的事可以做,它很像我们之前算 MFU 时做的:测量有效带宽。想法是这样的:好吧,这花了 1.6 毫秒,这算好还是坏?要算有效带宽,我们要做的是计算这次计算本质上发送了多少字节,以及应该发送多少字节,然后除以总时间,就得到有效带宽。我发送的数据大小,是每个元素的大小乘以元素数量,基本上就是这个数据张量的字节数。那实际发送了多少字节?这需要拆解一下。
对 all-reduce 来说,如果你想想——简单起见,假设你算 rank 0 加 rank 1 加 rank 2 加 rank 3——你需要迭代 world size 减 1 步,因为有 world size 减 1 次加法操作。所以有这个因子。还有个 2,因为你需要既发送又归约。然后再乘以 payload 的大小。这就是总发送字节数。总时长就是它花掉的墙钟时间。然后你还要乘 world size,因为这是所有 rank 等待的总量。带宽就是发送字节数除以总时长。这个例子里得到大约 400 GB/s。
几点说明。第一,有效带宽这个表达式是 size 乘以 2,再乘以 (world size 减 1) 除以 world size,再除以 duration。当 world size 增大时,(world size 减 1)/world size 基本上收敛到 1,所以你最后剩下的是 2 倍的 size 除以 duration,这基本上就是带宽。注意它不依赖于 world size,这是好事:GPU 数量涨上去,带宽不变。它也独立于拓扑——NCCL 会自己弄清楚消息是走环形还是树形拓扑。
这就是 all-reduce。reduce-scatter 非常类似。我创建输入和输出,预热,执行操作,计时。注意 reduce-scatter 的这些时间。你也可以在这里测有效带宽:输入的字节数、发送的字节数——这里没有那个 2 倍——然后除以总时长,就得到带宽。这里的带宽应该很接近,有时会有些随机性,但也在 400 这个量级。几点说明:记得我们说 all-reduce 等于 reduce-scatter 加 all-gather,所以 all-reduce 天然要搬运两倍的数据——reduce-scatter 有开销,all-gather 有开销,all-reduce 做的是两倍的活,花的是两倍的时间。但这两个正好抵消,所以你得到的是同量级的带宽。
这就是第一部分的结尾。进入第二部分之前,还有问题吗?问题:为什么对 CUDA kernel 要做 synchronize?归根结底,我们仍然在做 CUDA 操作。我们只是有多个进程,每个进程有一块 GPU,各自做一些 CUDA 操作。而做 CUDA 操作时,记住,默认是异步的,所以当 Python 走到下一行时,那个 CUDA 操作可能还没做完。所以我们总是需要 synchronize 来确保它做完了。问:那要先 barrier 再 synchronize 吗?我不确定。我觉得一个问题是,如果你先 barrier,而 CUDA 可能还没跑完,你立刻就走到 barrier,然后各自独立地 synchronize 不同的 CUDA kernel,那其实并没有真正同步。如果那些操作就直接返回了,barrier 也做不了什么。
数据并行:DDP
好,让我继续。现在真正开始想怎么训练模型。我们会走一遍一个非常裸的 MLP 训练实现——多层 MLP(这有点冗余,多层感知机本来就是多层的)。记住,MLP 是 transformer 里真正的计算瓶颈,所以它其实很有代表性。
数据并行、张量并行、流水线并行。我想让你脑子里有的图景是这张。这有点示意图的性质,别想太深,它更多的是让你概念化地理解你是怎么切数据和参数的。数据并行说的是:我把数据切成一片片,每块 GPU 负责一部分数据,然后我就做正常的模型训练——我要记录全部参数——然后我需要同步。
我解释一下它是怎么工作的。先生成一些样本数据:batch size 是 128,维度数是 1024,所以这是一个 batch size 乘以维度数的数据矩阵。然后我们跳进这个数据并行。它的工作方式是:你有一个数据矩阵,我把行切成 world size 份——这里是 4 份,每个 rank 拿一份。维度数……batch size……我把 local batch size 叫做 batch size 除以 world size,也就是每块 GPU 看到的每一行,它会有 32 个数据点。这只是索引:起始 index,从 data start 到 end 就得到那片数据,然后放到那个 rank 上。这时候每块 GPU 就有了各自不同的数据张量,也就是它负责的那一部分。
实践中,每个 rank 应该自己加载自己的数据,而不是有这样一个瓶颈,但这里只是为了说明。然后实例化 MLP。这里假设我们有一些层和层数 num layers,每一层就是一个维度数乘维度数的矩阵。我就初始化一组随机参数,然后把它喂给优化器。
这是训练循环。前向传播里,我取数据——记住,数据不是全部数据,如果我是 rank 2,那我只拿到 B2 那部分数据。我穿过所有层做一次前向,然后做一次反向。通常到这儿就结束了。但现在记住,每个 rank 的数据不同,所以梯度也会不同。
这就是让数据并行工作的关键一步:我们要在所有 worker 之间同步梯度。这是标准训练和 DDP 之间唯一的区别,其实相当优雅。基本上就是,对所有的参数,我对 param.grad 做一次 all-reduce,然后求平均。all-reduce 之后,在这个阶段,每个 rank 都有完全相同的梯度,然后我直接更新参数。我觉得这真的很优雅:它基本上就是标准训练,你把它作用在你的本地 batch 上,只是一定要在反向传播之后插入这个 all-reduce 来平均所有梯度,改一行代码,然后参数就被更新了。
所以在你训练的时候,每个 rank 基本上都在做参数更新,就好像它拥有全部数据一样,即使它实际上只处理了一部分数据。这基本上就是 DDP,也就是第一种数据并行。关于这个有什么问题吗?问题:你只能在 batch size 大于 1 的时候这么做吗?是的,你的 batch size 至少要等于 world size 才真正合理,而且通常它应该大得多。问:batch size 应该是 world size 的倍数吗?那会更好。如果不是,你可以用零填充或者类似的办法。所以有办法,但如果它是倍数,对所有人来说都更简单。
问:对基于 transformer 的模型,这会是什么样子?回答:基本上一样。DDP 有一个很好的特点,就是非常模块化。你做你的前向传播——DDP 只是在这里平均参数,它不关心你的前向传播长什么样。
这就是 DDP。总结一下:不同 rank 上的 loss 不一样,梯度一开始也不一样,但它们被归约成所有 rank 上相同,因此参数在所有 rank 上也就保持一致。下一讲 Tatsu 会讲更花哨的数据并行,FSDP 和 ZeRO。那里的想法是——我之前也暗示过——这里我们用 all-reduce,这是一个非常简单的单体操作,但它要求把所有模型参数都放在内存里。可如果模型参数装不进内存呢?那你就得更聪明一点,这就是下一堂课的主题。
张量并行:按列切分模型
接下来讲张量并行。这里的想法是,我们要往这个方向切,我们不切数据,我们切的是每一层。这样每个 rank 会拿到每一层的一部分。一般来说,这意味着我们要传输多得多的数据,这个后面再讨论。
张量并行长什么样?我们就假设有这份数据——简单起见,每个 rank 都有全部数据。记住数据是 batch size 乘以维度数。然后我定义 local num dim:对这个 rank 来说,我只负责一部分维度。这里的图景是:每个模型仍然有所有层,但参数现在是维度数乘以 local 维度数。所以如果这是某一层的参数矩阵,我是在沿着列切分。这也叫列张量并行(column tensor parallel)。也可以按行切,但我们现在不讲那个。
那前向传播长什么样?我穿过所有层,计算激活值。从 x 开始,也就是数据。然后我访问第 layer 层的参数。注意,这只是参数的一个切片:如果我在 rank 1,我拿到的只是这个矩阵的一部分。但我仍然可以继续。我可以应用非线性,因为这本来就是逐元素的。但现在我要做的是通信激活值。
如果我有一个数据矩阵,比如 rank 0 拿到了矩阵这部分对应的部分激活值,rank 1 拿到另一部分,依此类推。我需要基本上把所有激活值放到所有 rank 上。但我们知道怎么做——我们介绍过 all-gather 这个集合原语。这里 batch size 乘以 local num dim,是激活的形状。然后我做 all-gather。抱歉,这里是在给激活值分配内存。x 是实际的激活部分,形状是 batch size 乘以 local num dim。all-gather 是说每个 rank 有 x,每个 rank 要分配 activations,这是一个列表,world size 那么大。all-gather 之后,x 会被拷贝到各自对应的位置 activations 里。然后一旦我把所有激活值聚齐,就把它们拼接起来,形成完整维度的 x,也就是 batch size 乘以维度数。
关于列张量并行有问题吗?每一层都这么做。我们现在注意到一个与数据并行的区别:现在我们必须去折腾模型。数据并行很优雅,因为它是按数据切,模型被当成一个模块对待。但现在我们必须在模型本身上做文章。这强烈地利用了这样一个事实:如果你要做矩阵乘法,你可以把它拆成一组更小的矩阵乘法,在不同的 rank 上分别做,然后我们把结果聚起来。
到了反向传播。现在的问是,反向传播里会发生什么?在反向传播里,你有你的激活值,你必须对不同的梯度做 reduce-scatter。所以在某种意义上,all-gather 和 reduce-scatter 有一种对偶性:前向你做 all-gather,反向你做 reduce-scatter。问:这是 autograd 自动做的吗?如果你只是调 .backward,它不会做这个,因为那里面没有并行。但 PyTorch 有很多这些东西是自动为你做的。问:需要改多少代码?哪些是自动的、哪些要自己做?这里我们管理得相当显式,也就是说我不做反向传播,但你得自己管理和调用 reduce-scatter。这是刻意的,因为这是 336,从零构建语言模型。实践中你大概不用自己做这些。
流水线并行与 micro-batch
好,快速讲一下流水线并行。流水线并行背后的想法是,我们把网络这样切:每个 rank 现在拿到层的一个子集。在每一层内部,它拿到所有维度,它还拿到——嗯,其中一个 rank 会拿到所有数据。每个 rank 都会以某种形式看到所有数据。
它是这么工作的。我们有全部数据,同样是 batch size 乘以维度数。然后我切分层。local num layers 就是某个 rank 要处理的层数。现在我有 local 参数,基本上只有 local 层数那么多份,但每一层内部我还是做维度数乘维度数。有一点——Tatsu 周三会多讲——是 micro-batch 这个想法。我先把它展示出来,再解释我为什么这么做。除了切分层,我还把 batch 切成若干个 micro-batch。
如果我是 rank 0,我就拿到数据,然后把它 chunk 成若干个 micro-batch。对每一个 micro-batch,我要做的是从上一个 rank 接收它,然后只在分配给这个 rank 的那些层上做前向传播,然后发给下一个 rank。这里我其实用了 receive 和 send,它们是点对点操作。我之前没讲过这些,但它们很好解释。这基本上是说我是某个 rank,我要从这个张量……抱歉,我要从 rank 减 1 接收这个张量;这个说的是我要把张量 x 发给 rank 加 1。
我之所以要讲 micro-batch——Tatsu 周三会多讲——是因为在流水线并行里,一个 rank 拿到数据,处理一些层,然后把它发给下一块 GPU,处理一些层,再发给下一块 GPU。这是切分深度网络非常自然的方式。但问题是,你会遇到所谓的流水线气泡(pipeline bubble):你不处理的时候,就在那儿等着别的张量来处理,这会导致效率相当低。micro-batch 的想法就是把数据拆成更小的 batch,这样你可以快速处理完,发给下一个。这能减少流水线气泡的数量。
另一件这个非常朴素的版本没有处理的事情,是重叠通信与计算,而这其实对流水线并行非常重要。这里给的结构基本是对的:如果你在这些前面加一个 I(isend/irecv),它就变成异步的,你得加更多东西来管理代码。核心思想是,你在计算的时候,可以同时接收数据或者发送数据。计算和通信应该重叠起来,这样就减少了真正等待的时间。
这里还缺几样东西,希望下次能补上。一个是通信与计算的重叠,这在流水线并行里尤其关键。我在数据并行里没提,但那其实也会发生,因为我只是做了前向,最后才做这些 all-reduce;但如果你聪明的话,在反向传播里一算完梯度就可以开始发了。这会在作业二里探索,它又能让你更多地重叠通信和计算。
我们刚才有关于通用模型的问题。我觉得这个 MLP 基本上给了你理解基础所需要的大部分东西。更大的模型需要更多的簿记工作,所以更难看清核心算法。还有一些其他类型的并行我们没有讲到:序列并行把一整个序列切成若干块,这样可以并行化注意力计算;专家并行则并行化 MoE 里的专家,这就是我提到的 all-to-all 发挥作用的地方。然后还有不同的并行化技术之间的不同组合,这些也会出现在作业里。
并行策略怎么选:硬件与批量大小的约束
需要注意的一点是,你选哪种并行技术,很大程度上取决于硬件。比如张量并行,它的通信量很大,因为每一层都需要发送所有这些激活值,而它们相当大。所以张量并行通常发生在节点内,通过 NVLink,或者在高带宽的情况下;你不会跨 NVLink 域做张量并行。而流水线并行你会看到有人在用,它通常能容忍慢得多的互连。有一些去中心化训练的工作使用流水线并行,因为你的节点——GPU 分布在全世界;但在那种情况下,你不会想做张量并行。所以有时候你看这些组合,会是节点内做张量并行,然后做数据并行或者 FSDP,如果需要的话再做流水线并行。
还有其他影响。比如如果你做数据并行,你可能可以把数据并行做得很大,但随后你会碰到一个叫临界批量大小(critical batch size)的东西:batch size 增加到一定程度,再增大就没用了,这时候你就是在浪费算力,那你还不如用张量并行。有很多这样的考虑因素,随着课程推进,我们会进一步讨论。
最后再说一点。刚才简单提到了 TPU。需要注意的是,我们有意使用 PyTorch,而且不只是用 PyTorch,而是以非常基础的方式使用集合操作,这样你就能从机制上看到发生了什么。另一种方法,尤其是如果你在 TPU 领域,是你可以直接定义模型和分片策略,编译器实际上会处理很多关于你需要什么通信操作的事情。你基本上是告诉它,这块数据需要在这里、这里和这里,然后编译器会做一些神奇的事情去弄清楚。这很有吸引力,但显然,这会剥夺很多从零开始构建东西的乐趣。
总结
总结一下,有很多种并行化的方式。你可以按数据切分,按张量或专家切分,按流水线或序列切分。我们看了数据并行,而且只做了 DDP,下次我们会讲 FSDP 和 ZeRO。张量并行,正如我提到的,需要非常快的互连;流水线并行则不那么需要,但你需要非常努力地减少流水线气泡。
然后,也许在更高的层面上,我们看到这个模式经常出现。你可以选择重新计算,或者把它存进内存,这在讲激活检查点的时候会讲到。或者在这种情况下,你可以把这看成一种扩展:你可以把它存到不同的 GPU 上。从这个角度看数据并行,某种意义上你是在做冗余工作,因为每个 rank 实际上都在更新自己的参数、都在维护所有参数。但你这么做的原因是,你不需要把优化器状态搬来搬去。
还有一点是,硬件一直在变快,但某种意义上我们总会想要更大的模型。所以这种层级结构的思想会一直存在。今天就到这里。下周三,Tatsu 会深入讲更多并行技术。
从零训练语言模型:并行策略的全景与实战
这堂课系统讲解大模型训练中的各种并行策略——数据并行、ZeRO/FSDP、流水线并行、张量并行、专家并行和上下文并行——以及如何把它们组合起来,在超大集群上跑满算力。
课程目标与两个瓶颈
上一讲 Percy 讲了并行的一些底层机制。今天我要做我最喜欢的事:讲各种关于现代语言模型训练并行如何运作的知识、细节和冷知识。我们要真正弄清楚,在超大集群上训练超大模型时涉及的许多复杂性。
这里会涉及很多可以同时使用的并行策略。到最后我会讲到一张幻灯片,说“4D 并行”,因为有四种不同的事情可以同时做——实际上我会讲的还不止四种。要在最大规模上并行,你需要把它们全部或大部分都用上,我们会讨论为什么。你们作业的一部分就是:给定一个特定的网络拓扑和一个特定的模型,找出最优并行策略。最后我会走查一些近期大规模训练运行的例子,用公开的细节展示这些方法在实际中如何运作。
第一部分我会讲得比较快,部分是对基本集合通信、基本网络原语的复习。之所以需要所有这些并行手段,是因为有两个瓶颈,我们要靠多机多卡来解决。一个是算力:我们需要比单芯片能提供的更多算力,解决办法就是把很多很多机器连起来。世界上最快的超级计算机有 exaflops 级别的算力,而单张 GPU 远达不到这个数字。另一个原因是显存:模型很大,我们没法把它们全部塞进一张 GPU,所以需要以某种方式把它们分片成更小的块。这就会带来很多 GPU 跨很多机器的复杂性。
有一个重要的概念对象会贯穿今天的讲座:节点内并行与节点间并行的区别。节点内连接非常快,所以你可以做通信开销很大的事情;节点间通信则比较慢。正因为慢,我们就必须更多地使用尊重信道通信约束的方案。今天所有内容我们不会真的去发数据包,而是在集合通信原语的层面处理。当我核算通信量时,可能会说“我们要做一次 all-reduce”或者“我们要做一次 allgather”来实现某些操作。要高效实现这些,最终得深入到硬件层,但今天的讨论是算法层面的,所以我讲的所有网络原语都是集合通信这个层面的。
Percy 讲过这一点,但它对今天某一部分非常重要:all-reduce 和 reduce-scatter 加 all-gather 之间的等价性。有一个算法,一边做 all-reduce,另一边做这两步不同的操作。这两者在代价上相同,意味着右边的算法可以在某种意义上“免费”完成。我们后面还会看到这一点。
有人问,为什么我特别强调这个分解?是的,还有别的分解方式。这个特定的分解对后面某个算法特别有用,所以我才强调它;当然你也可以做别的分解。
硬件底座:TPU 的环面网格与 GPU 的胖树
在进入算法之前,我还想简单谈谈底层硬件栈。今天大部分讨论是硬件无关的,理解了基本概念之后,你可以把它应用到任何硬件基底上。但硬件值得一谈,一方面因为它有意思,另一方面因为我认为它导致了不同模型训练公司采用不同的并行策略。
TPU 是谷歌的加速器,我在 GPU 那讲提过,它有点像轻量级 GPU,但网络不一样。TPU 网络是所谓的环面网格(toroidal mesh):你可以想象一个网格,然后让网格的末端互相通信。它其实更复杂一些,是一张 3D 图——Marcel 你有那个可视化,对吧?可以发到课程频道里。有一个很好玩的网页可视化,用 3D 展示环面网格。但你的心智模型大致可以就是这张图:所有芯片和邻居相连,邻居再绕回来。这让你有一个非常简单的网络拓扑,可以很简单地无限扩展:无论网络多大,每个芯片的邻居数量都一样。
相比之下,GPU 的网络哲学非常不同,更像是全对全(all-to-all)。它的网络结构像一棵树,通常叫胖树(fat tree)。GPU 在最底层连接得非常快,然后一组 GPU 连成一个 pod,pod 之间可能通过脊交换机(spine switch)互相通信。这意味着随着节点数增长,树会越来越大,通信成本会更高,或者通信拓扑会变得更复杂,因为你在不断地把所有东西全对全连起来。这种哲学差异意味着:如果你只和邻居通信,TPU 很棒,性价比高,在同样功耗下每条连接可以做得很粗;但如果你要以非常随机、不可预测的方式连接,GPU 网络就更灵活。TPU 和 GPU 之间有非常不同的设计哲学,后面讲到并行时我会指出它在哪里体现出来。
我上一讲把这个发到了课程聊天里,因为有人问这两者有什么区别。再举个例子支持我的观点:Bill Daley 和 Jeff Dean 谈过不同硬件各自适合什么。GPU 适合混合专家(MoE)这类通信模式不太清晰的东西,因为 token 会被路由到不同的专家;TPU 则很适合稠密模型,你可以做非常可预测的划分。我后面讲张量并行时会讲到这个想法。这些硬件适合不同的工作负载。
本来我准备就这么结束,直到今天早上谷歌宣布了 TPU8i 和 TPU8t。我备课的时候看到,心想:天哪,TPU8i?那是一棵树的拓扑,他们转向了更全对全的连接。仔细想想也合理:现代语言模型都是 MoE,服务 MoE 的时候,路由 token 到不同专家会有大量带宽在飞。推理时这些通信是真正的瓶颈,你大概确实想要更强的全对全连接。即使在训练时,TPU8t 这个训练芯片的跨机架连接看起来也更像 GPU,有一个更接近全对全风格的交换机,叫 Virgo 网络,是更高层的网络栈。所以我觉得正在发生很有趣的事情:TPU 和 GPU 出现了一点趋同演化,真正定义网络需求的是工作负载。
华为 Ascend 与硬件设计取舍
最后关于硬件,我很喜欢聊中国 AI 圈正在发生的事。GPU 那讲有人问,如果 SRAM 那么好,为什么不干脆全用 SRAM、只做 SRAM 芯片?那就是 Grok,它工作得很好。同样,你们可能有人会想:如果全对全连接好,那为什么不把所有东西都连起来,用非常快的光纤连接?那样你就会得到华为 Ascend 910。看它的规格其实很有意思:每颗芯片比 H200 差不少,矩阵乘法慢很多,很多方面都慢。但如果你愿意用一整个机架的光纤交换机去连接多得多的芯片——他们是一个大机架里 384 颗——那么也许你可以靠连接非常非常多这样的芯片,来弥补单芯片慢的问题。
这展示了一个非常有意思的取舍。因为一旦看这个架构,你会发现它的功耗高得离谱,是同等 NVIDIA 系统的四倍。所以结论有点像:如果你愿意付出功耗代价,你可以用蛮力解决很多通信问题,更激进地扩展到 300 多颗芯片,但你要付功耗成本。这跟 SRAM 的故事类似:如果你想设计在功耗和制造上都高效的硬件,你会走到某个位置;如果你想蛮力解决问题,你会走到一个非常不同的位置。我觉得这是学习硬件设计取舍的非常有意思的地方。
新的计算单元是整个数据中心
把这一切放在一起,你首先应该想到的是:新的计算单元不是 GPU,而是整个数据中心。我们想要几件事:能够控制使用的内存量,能够控制能调动的算力,而且希望这一切是无损的——我们想用上所有资源。接下来我们要讲的,就是通过所有这些通信原语和组件来实现这一点的算法。
第二部分是今天的主要内容,也就是我们用来并行化语言模型的算法。这里有几个不同的重要想法,大致可以分组。数据并行是一组想法,本质上是把训练数据分布到不同 GPU 上,所以移动的是数据。模型并行则是把模型的不同部分切开来放到不同地方。事实证明这两者的边界有点漏,因为这里有一个算法实际上也会切分参数。但你们会看到我为什么把这些算法分到不同的框里。概念上,我们既要扩展算力,也要扩展显存,这就是为什么需要一大堆不同的技巧。
数据并行:最简单,但只解决算力
数据并行我觉得是最容易理解的。它概念上非常简单,我认为这也是你尝试并行化 LLM 训练时标准会想到的方法。先忘掉 Adam,暂时只做朴素 SGD。我们有一个大小为 B 的批。每个更新步,我取 B 个元素,把梯度加起来,然后做更新。最朴素的并行方式就是把批 B 切到 M 台机器上;如果能整除,每台机器拿到 B/M 大小的批。然后每台机器计算梯度,我再把所有梯度同步起来得到总和。
算力扩展方面这很完美,只要每张 GPU 上有足够多的样本就行。通信开销方面呢?每个批,我们基本上要通信参数量的两倍,因为我必须把梯度来回通信。显存扩展方面则完全没有:每张 GPU 都有相同的模型副本,需要同样大小的激活值,所以这个方案一点也不省显存。
显存账单与 ZeRO 三阶段
现在问题在哪?显然我们在一定程度上解决了算力,但完全没有解决显存。来看看显存问题,做一点账。一开始看起来显存状况可能很糟,但事实证明我们的显存状况简直糟透了。原因在于,如果你做参数核算,会发现需要存很多东西。而且不仅需要存很多,实际上比参数本身还要多。粗略的经验法则是——取决于精度——我们需要大约五份权重、每个参数 16 字节来存模型。当然要存模型参数;还要有地方放梯度,也就是放进模型参数之前的累加器;可能还需要一个更高精度的累加器;如果做 Adam,糟糕的部分来了,你需要跟踪梯度的一阶矩和二阶矩,而且要随时间跟踪,所以需要两份,而且根据稳定性可能需要高精度存储。这些通常叫优化器状态。核算下来,优化器状态是做 SGD 更新时显存开销的大头。
这个领域里有一件非常优雅的事情:你可以开始把需要的内存量切开来,分布到各个加速器上,而且以这种结构化的方式做,几乎能免费获得收益。优化器状态是绿色的,占大部分显存;橙色是梯度,蓝色是参数。参数和梯度大小相同,状态更大。朴素数据并行会把所有这些状态复制到每张 GPU 上,这不太好,因为你消耗了远超需要的显存,显存消耗随加速器数量线性增长。我们能不能把优化器状态分片到不同 GPU 上?这会是很大的节省。也可以把梯度分片到不同 GPU,极端情况下把所有东西都分片到不同加速器。最右边隐含的显存节省非常可观,从 120 一路降到 1.9。接下来要研究的核心是:这样做的代价是什么?天下没有免费午餐,基线肯定最快;越往下走,为了实现显存节省,就要付出很大的通信代价。但我觉得了不起的是,其中很多将是免费的。
ZeRO 第一阶段
我们从所谓的 ZeRO 第一阶段开始。对应这些步骤会有三个阶段。第一阶段最简单:我只分片优化器状态,把它分片到各 GPU 上。每个人仍然拥有参数和梯度。你可以认为每个 worker 负责更新参数的一个切片。如果我是 0 号 GPU,我的责任就是更新最前面这个切片,其他更新不归我管。
具体怎么做?每个人都计算。每个人有不同的数据项,所以先在自己的数据项上算出完整梯度。然后我把我算出的梯度 reduce-scatter 到其他机器上,因为别人需要我的梯度,但不需要全部梯度,只需要和他们要更新那部分相关的梯度。所以每个 worker 会从我这里拿到属于它那部分参数空间的梯度。这大致相当于一个参数量的通信成本,因为我把梯度切开发给每张 GPU。然后每台机器就能做更新了:它有参数、有从别人那里得到的梯度、有自己跟踪的状态;参数更新完毕,再做一次 all-gather 把它们收集回来。我负责比如四分之一的参数,就把这些更新后的参数发回给其他所有人,这是一次 all-gather。
这就是等价性有用的地方。我做了多少次操作?在朴素数据并行下,我必须做一次 all-reduce,和所有人交换所有梯度然后更新,通信成本是参数量的两倍。现在在 ZeRO 第一阶段,我做了两个操作:一个是 reduce-scatter 把梯度发出去,一个是 all-gather 收集更新后的参数。但我们已经说过,reduce-scatter 加 all-gather 等价于一次 all-reduce。所以 ZeRO 第一阶段具有和朴素 DDP 完全相同的通信特征。这是免费的显存节省。然后看显存:我们基本上把参数以及任何优化器状态都除以了 N 张 GPU。太好了。
ZeRO 第二阶段
我们还能做更多吗?ZeRO 第二阶段说:我要把梯度也拆分开,对它做分片。这会更棘手,因为之前我依赖自己可以算出整个梯度,现在甚至无法物化整个梯度。那怎么办?这其实只是一个系统技巧。当我反向遍历网络时,不需要计算完整梯度,可以在进行过程中把梯度发送给相关 worker。关键步骤是:不再一次性物化整个梯度向量,而是沿着计算图反向走;算完一层的梯度,就立刻 reduce 并发送给正确的 worker;一旦反向图不再需要这些梯度,就立刻释放。所以你是增量地计算和发送梯度。增量做和一次性做,总量是一样的,所以没有区别。最后再 all-gather 参数。梯度在某种意义上已经被分片处理了,却没有带来额外成本。
ZeRO 第三阶段与 FSDP
最后一件最难的事——我最初学的时候觉得完全是魔法——是我们已经免费拿到了一些东西,那就继续推进:把参数也分片,一直分到所有状态上。我们用和 ZeRO 第二阶段同样的思路:增量地做。我会在计算图中前进时按需发送和接收参数。这非常棘手,因为每张 GPU 在任一时刻只能看到参数、梯度和优化器状态的一个切片。这就是 ZeRO 第三阶段,也叫 FSDP。如果你在 Torch 库里并行过任何东西,可能已经见过它。
底层的工作方式是:你会做两次 all-gather 和一次 reduce-scatter。从左到右走一遍,这就是整个更新过程。加载模型,上面和下面各有一张 GPU。首先收集这一层的权重,做这一层的前向,然后就可以释放权重,因为前向已经做完了。假设我只有一层,现在进入反向过程。做反向需要激活值(我保留着),还需要这一层的参数,可以按需 all-gather。我 all-gather 参数,做反向,拿到梯度后立刻 reduce-scatter 出去,然后释放权重。接着重复这个过程:按需取参数,做反向,把梯度发出去,重复;最后更新权重,完成。
通信成本呢?我们做了两次 all-gather、一次 reduce-scatter,多了一次 all-gather,看起来不太好。第二,看起来开销很疯狂,因为我们一直在通信,每一层都在通信。你可能觉得这极其昂贵、非常糟糕。但我觉得有一个反直觉而重要的点:ZeRO 第三阶段用了两个非常重要的想法,让它基本上没有额外开销。第一个我们已经见过,就是扫过计算图,做必要的通信,然后立刻释放显存。另一个我还没讲,就是把通信和计算重叠起来。这一点一开始很难解释。
这张图是从论文里拿的,我认为它在解释 PyTorch FSDP 架构。工作方式是这样:我们有不同的流——CPU 里发生的事情、GPU 里正在进行的计算、以及最底下 GPU 里正在进行的通信。FSDP 的工作方式是:我必须先拿到参数才能计算,所以先做 all-gather 0,第 0 层。一旦 all-gather 完成,我就可以做前向。但做前向的时候,注意我在请求下一层的参数,也就是在做这个计算的同时做 all-gather 1。现在我有了 all-gather 1,就可以做前向 1,而且可以在开始这个流程的同时开始 all-gather 2。所以我把计算和通信重叠起来了。我可能花一些时间释放参数,也可能花一些时间计算另一个前向,比如复用 W0,把前向 0 复用两次。这让你能做到的是:如果你有足够的计算,而且网络足够快,通过重叠通信和计算,FSDP 基本上可以是免费的。确实会看到一些气泡,这些是开销;但如果通信非常快、计算非常大,计算可以花比通信更长的时间,你就能在几乎不付显存代价的情况下获得显存使用的巨大改善。
FSDP 的总结与问答
看不同的 ZeRO 阶段:DDP,也就是朴素数据并行,成本是参数量的 2 倍,是一次 all-reduce。ZeRO 第一和第二阶段,也就是分片梯度和优化器状态,是字面意义上的免费,因为通信量和原来一样,只是利用了 all-reduce 等价于两个操作这个恒等式。ZeRO 第三阶段技术上通信量稍多一点,需要额外一次 all-gather,但没那么糟,因为库会用非常聪明的方式把通信成本藏在计算下面。所以实践中跑 FSDP,你会看到 GPU 利用率非常接近单 GPU 性能。FSDP 好得确实令人吃惊,而且概念上非常简单。你们作业里要写一个 FSDP 实现,其实可以写一个 wrapper,包住任意模块,把它变成 FSDP 版本。概念上你要做的就是一堆 all-gather、计算、释放,然后在反向传播里重复。
有人问:是不是从下一张 GPU 拿梯度来计算前一张 GPU 的梯度?你描述的更像是流水线并行。流水线并行是一层在一张 GPU 上、另一层在另一张 GPU 上;这里不是这样。在所有 ZeRO/FSDP 的情况里,每张 GPU 都会从头到尾走完整个模型,区别在于没有哪张 GPU 同时持有全部参数。所以如果我没有第 0 层,我就先按需取第 0 层的参数,做完整计算,再进入下一层。计算和朴素数据并行是一样的单 GPU 计算,只是可以理解成在每个计算操作之间请求参数、释放参数。
另一个问题:是不是每张 GPU 都拥有每一层的一部分,所以才需要 all-gather?是的。参数、梯度和优化器状态都分片在不同 GPU 上。还有人问:为什么通信成本没有乘以层数?操作数量确实乘以层数,这是对的;但每一次通信的数据量更小,因为那只是一小块 MLP 之类。拿它和 all-reduce 整个网络相比,加起来在某种意义上差不多。
实践中,如果看 A100 上能放下的最大规模:从基线出发,你连 7B 模型都放不下;到了 ZeRO 第三阶段,你可以放下 500 亿参数的模型。所以用 FSDP 来放下大模型容易多了,显存改善非常显著。有人问 8×A100 的限制换成 H100 或 H200 会怎样——我没法心算乘以 141 再除以 80,但 H200 上大概是那个数,基本上是线性的,不会改变太多数学。
FSDP 非常干净、优雅,我很喜欢。我真希望讲座能在这里结束。但不幸的是,我们得继续进入更丑陋、更棘手的东西。有两个原因。第一个是并行化里的重要想法:数据并行在消耗一种重要资源,也就是批大小。如果你的批大小是 8,你永远不能有超过 8 个加速器。你可能会想,那就把批大小越做越大;但不行,因为存在所谓的临界批大小(critical batch size):到了某个点,额外增加一个批元素带来的收益,还不如在单个元素上再走一步 SGD。小批大小时,增加元素完全有帮助,等同于多走 SGD 步;但到某个点后收益递减。无限大的批并不比无限多单步好无限倍。
另一个问题是显存。ZeRO 第一和第二阶段并不能真正扩展显存;ZeRO 第三阶段能切分参数显存,但还有激活显存和其他显存使用,这些并没有被切分或减少。所以我们需要更好、更细粒度的切分模型方式,才能真正把显存使用继续压下去。这就带我们进入各种模型并行。
模型并行总览
模型并行本质上和 FSDP 一样,是把参数拆到不同 GPU 上。但我觉得重要的概念区别在于:接下来我们要通信的是激活值。在 FSDP 里我们切分参数,但某种意义上它只是个 wrapper,我们仍然做正常计算,只是在计算前把参数发来发去,所以飞来飞去的是参数。现在我们要改成来回通信激活值。比如一层在 GPU 0 上,下一层在 GPU 1 上,我要通信的是两层之间的激活值。这就是最大的区别。这包括流水线并行(切层)、张量并行(切矩阵)和专家并行(把专家分片到不同地方)。Percy 已经讲过张量并行,所以你们大概有了一些心智模型。
流水线并行
我先讲流水线并行,或者说按层并行。它概念上非常简单。我最早学并行的时候觉得这太简单了:把层切开放到不同 GPU 上就行。前向传播时把激活值往前传,反向传播时把部分梯度往回传。但这么做的结果是一张非常难看的图,非常令人沮丧。四张加速器,每张处理四分之一模型。计算随时间发生,时间从左到右,不同加速器是不同行。首先最下面第 0 张加速器处理第一层,做计算,然后停下来,把计算交给第二个,然后它直到最后都无事可做。接着东西来回传递,同一时间只有一张 GPU 活跃;反向传播时也是一次只有一张 GPU 活跃。所以大部分时间 GPU 都是空闲的,利用率极其糟糕。这叫做气泡(bubble),就是流水线里你什么都不做的部分。
解决办法是做批处理或者流水线化。不是简单一层接一层,而是做成流水线:这张图里有四个元素要处理,你处理完一个就开始下一个,然后把已经做完的交给下一层。这样你可以把元素往上送,再往下送。要想让它合理,你需要气泡时间和有用计算时间的比例合适;利用率大致是阶段数除以微批数。所以我们需要很大的批大小来把气泡降到接近 0。气泡会随着 1/微批大小下降。这就是我说批大小是有用资源的原因:现在你可以把它花在另一个地方,也就是用更多流水线来减少流水线并行里的空闲时间。
流水线很糟糕。有句行话说,并行化代码看起来都挺合理、大家都能理解,直到你实现流水线并行。那为什么还要做?因为和 DDP 相比,流水线能省显存,因为你切了层,而且可以和数据并行组合,两者都能做。但更重要的原因是,在某些拓扑里你总会用到流水线,它的通信性质非常好,比 FSDP 和你能做的其他事情都好。它只依赖激活值,也就是 B×S×H;而且是点对点通信,不是全对全。所以根据你如何安排顺序,它可以非常高效地利用网络。实践中,流水线会放在最慢的网络链路上。如果你有多个数据中心或多个 pod,它们之间通信较慢,你就会在这些之间用流水线并行,因为这是在那种情况下通信效率最高的并行方式。
流水线性能非常依赖批大小。我会反复提到这篇论文——这是 NVIDIA 的 Megatron 论文。他们做了很多很好的参数扫描,展示不同配置下利用率如何变化。如果你有大批大小,并且选一个大的流水线规模,可以得到相当好的利用率,接近完全不做流水线并行。但你需要大批大小才能保持利用率;否则流水线并行会让你的利用率和性能迅速下降。
有人问:你之前说流水线通信性质好,相比 FSDP,具体机制是什么?是因为通信的数据量更小。它是批大小 × 序列长度 × 隐藏维度,几乎总是比试图通信整个参数矩阵要小。
人们试图找出更聪明的方法来减小流水线气泡。你可以用聪明的方式切分不同层或不同微批的阶段,把不同的前向元素排在反向元素之间。通过巧妙的调度,可以进一步减小气泡。这是从 DeepSeek 论文里拿来的。通过非常聪明地操纵前向和反向计算的时机,你可以在气泡大小上得到一些改善。
但更聪明的做法叫零气泡流水线(zero bubble pipelining),它其实不是关于调度的,而是仔细思考反向传播的结构。反向传播里有两件事必须发生。你在计算图里往回走,一方面要把偏导数继续往计算图下方传播——反向传播从末端开始,把这些偏导数往回传,这是必须做的;另一方面你要计算当前权重的梯度。所以在计算图每个节点上你做两件事:往回传播,以及计算权重的导数。关键洞察是,其中一件对流水线非常重要,另一件可以随时做。传播偏导数非常重要,因为下一阶段在你把信号传回来之前没法做任何事;而关于权重的导数是一个叶节点,什么时候做都行。所以聪明的做法是把这两者分开。B 是往回传播的部分,W 是计算权重导数的部分,W 可以随时发生。你尽快算 B,把 W 推迟到后面,在计算有空隙的时候做。这样你几乎可以把流水线完全填满。零气泡流水线非常复杂,比通常愿意处理的要复杂得多,但它允许你几乎完全解决流水线问题,取决于 W 和 B 的工作量比例等等。我看到这个的时候觉得太聪明了,系统层面能做出这么聪明的事情。
张量并行
流水线并行像深度:沿着深度维度切网络。但我们扩展有两个轴:深度轴和宽度轴。如果我们开始沿着宽度切模型呢?这就是张量并行,Percy 之前解释过。它的核心想法很简单:矩阵乘法可以切成更小的矩阵乘法,然后把部分和加回去。这其实和 tiling 是同一个想法。这个核心原语会出现很多次。
实践中这长什么样?这里是一个 GeLU。通常你有 x 作为输入,从最右边进来,然后乘以矩阵 A,应用非线性 GeLU,再乘以 B 做降维投影,输出 z。这是正常的串行计算。在张量并行里,我会把 A 切成两个子矩阵,把 B 切成两个子矩阵,并行跑计算,最后把它们收集起来。有一点很重要:前向和反向之间存在对偶性。前向里,我对 x 做的只是把它复制两份,然后往前计算,所以 f 是恒等函数;到了并行组件的末端,g 函数是一次 all-reduce,把它们合回来。但反向传播做梯度时会翻转:g 变成恒等,因为偏导数往回走;f 变成需要求和的偏导数,用来得到这个模块开始的偏导数。它们会翻转。这个对偶性对写张量并行很重要。
你可能已经注意到了:我们切分的方式有些是按列切。按列切时,张量并行发生在输入端:MLP 的输入、注意力的投影,在每个 transformer block 里按列切。对应的第二阶段则按行切:MLP 的降维投影、注意力的输出,按行切。所有小的层,比如 layer norm、非线性、MoE 里的 router,都是完全复制的,你不值得为切分它们付出开销。
什么时候做张量并行?张量并行非常吃计算:每次有 matmul,你就要通信,一次 all-reduce;反向传播里也有 all-reduce。这些是激活值大小,而且发生得非常频繁。所以它非常吃通信,你只想在节点内做。对 GPU 来说,一个盒子里的 GPU 网络连得很紧,最多八张你也许愿意做张量并行;超过之后就要跨节点,互连慢得多,性能会大幅下降。所以记住,张量并行非常吃通信,通常用在最快互连上。
这里回到我之前说的 TPU。TPU 没有“八张快 GPU”和“下一台机器”的区别,只有一张大网格。所以 TPU 的人会告诉你的一大优势是,你可以做非常大数量的张量并行,因为他们能在这张网格上以非常规则的通信模式获得高带宽。这是并行化 TPU 和 GPU 的一个大区别。你用多少张量并行、多少流水线并行,会有很大不同。
比较这两者:它们都是模型切分策略,都能省参数和激活。张量并行的优点是未必有流水线气泡,如果网络够快可以获得满利用率,而且复杂度低,就是切一些 matmul。缺点是通信量大得多:以前是 B×S×H 的点对点通信,现在大约是 A×B×S×H 再加上一次 all-reduce 通信,不仅是点对点,而是需要全对全通信。所以张量并行适合高速互连,其他时候我们大概想用流水线并行。
激活显存与序列并行
接下来要更仔细地想显存。对深度学习语言模型最朴素的看法是:显存就是参数,也就是绿色那块,你需要某些比特来存参数,仅此而已。我已经讲过优化器状态,也就是黄色框,很重要。但看这张 PyTorch profiler 实际 profiling 出来的显存使用图,你会发现这不是全部。计算中还有很多动态显存:需要存所有激活值,就是这些大的红色鼓包;随着计算梯度,在反向扫描里还需要存梯度。显存最大使用点出现在最大激活点之后一点,你开始反向扫梯度,但还需要保留激活值,那通常是最大显存点。所以要减少显存,也许需要处理这个大的红色鼓包,也就是激活值。
如果看更现代的语言模型工作负载,你会发现随着参数越来越大——先别看“present work”那一列,我待会讲——只看基线列,在中等长度序列下,激活值会远远超过参数显存。所以任何省显存策略都必须考虑激活值,只处理参数显存不够。如果我们要存所有东西,一个粗略的经验法则是:需要的激活值大约是 34×S×B×H,加上 5×A×S²/H,其中 A 是注意力头数乘以序列长度再除以隐藏维度。S×B×H 这个依赖是根本性的,因为我们预期要为序列每个元素、每个批元素、每个隐藏维度存一些东西。所以我们会反复看到 SBH 这一项。那个奇怪的 5AS²/H 项来自二次注意力项,包括 dropout;如果我们做 flash attention 并重计算,可以去掉这一项。
如果用张量并行呢?我在讲模型并行时承诺过可以大幅减少激活值。张量并行会切分注意力和 MLP 里的矩阵乘法。MLP 是前面 34 里的 24,注意力头是 5AS²/H,也就是第二项。我可以用张量并行大小 T 来除这两项,这不错,因为我把大部分显存变成了随张量并行大小 T 线性缩放。但还有其他东西:layer norm、dropout、注意力和 MLP 的输入需要作为残差保留下来用于反向传播。不幸的是,这些不会随张量并行大小缩减。张量并行不切 layer norm,所以不会切分这些激活值。这很不幸,因为你本来希望如果有 1000 张 GPU 做张量并行,就能大幅减少激活值;但现实是你仍然要承受 10×SBH 的惩罚。
最后一个通常和张量并行组合使用的是序列并行(sequence parallel)。这个名字很有误导性,因为通常我说序列并行时,实际上有个叫上下文并行的东西更配这个名字。序列并行的想法是:我们有剩下那些 10×SBH 的项,它们很轻量,像 layer norm,没有太多计算;我要把它们沿着序列轴切分,而不是沿着隐藏轴切分。这会涉及在每次操作之前做 all-gather 和 reduce-scatter。如果你想想,这非常像 FSDP:我们需要激活值,前向时计算并保留,反向时用来求导,但现在不需要的时候就以分片形式存储,按需物化。所以概念上和 FSDP 风格很像:以分片格式存,然后需要时收集。这里也有对偶性:前向里 g 是 all-gather,g bar 是 reduce-scatter;反向里两者反过来。
现在如果做这些,得到什么?无并行时,激活显存是 34×SBH 加上注意力头部分。张量并行可以把 MLP 部分和注意力部分除以 T,但不能除其他逐点操作。如果做序列并行,我把这些也按加速器切分,只是沿序列轴,于是得到完全线性的依赖。如果做激活重计算,我甚至可以去掉第二项,因为那只是 softmax 需要存的;通过重计算可以去掉,得到 SBH×34/T。这是正常训练下激活显存能达到的合理下界,值得记住。如果你想手算模型能不能放进 GPU,这是很好的参考:至少需要这么多激活显存,再加优化器状态,再加参数和梯度,然后就能大致知道模型需要多少显存。
有人问 24/T 是 MLP 部分,MLP 不能通过重计算优化吗?你可以重计算 MLP,但列入这里的重计算在计算上非常昂贵,比如 MLP 的重计算要在反向传播里再跑一遍 MLP,你大概不想这么做。注意力的重计算通常更便宜,而且你也不想付二次代价,那很痛苦。
专家并行
现在讲标准并行工具箱里的最后一个成分:专家并行(expert parallelism)。MoE 现在是非常标准的工具,大多数大模型都是 MoE,这让我们能利用它们的一大好处,也就是专家并行。我自己把专家并行看作类似于张量并行:你取网络里的 MLP、FFN 组件,以各种方式切开,并付出通信代价,就像你切分矩阵一样,只不过这里是把 MLP 切分到不同设备上。专家并行的系统行为和张量并行也大致类似,是一个高带宽的并行原语,也能减少激活值。但有一点很重要:如果你做 MoE,几乎总是更偏好专家并行分片策略,而不是张量并行。
这是我从 Megatron 的并行指南里截的图,Megatron 是 NVIDIA 的并行库,非常好的库和文档。其中一条指南说,如果你要做 EP 或 TP,你应该用 EP 而不是 TP。为什么?这其实是张量并行缺点的一个好列表。如果你有一个矩阵,把它切得太细,矩阵会变得很小,GPU 利用率会受损;所以你希望 matmul 尽可能大,而张量并行会把它变小。对 MoE 层来说,路由稀疏的 token 激活比路由密集的大张量并行 matmul 激活容易得多;你可以把 token 精确路由到需要的地方,跳过一些计算或开销。既然你无论如何都要做 MoE,不如把专家数分到所有设备上。
这可能会让人觉得,EP 很棒,大家都应该一直用专家并行。不幸的是,专家并行仍然非常复杂、非常困难。我觉得 FSDP 之后我讲的所有东西——张量并行、流水线并行——系统上都相当不容易做好。我指出两个库,不是要讲它们内部的细节,而是告诉你这真的是非常复杂的事情。DeepSeek 有一个库叫 DeepEP,我觉得是 DeepSeek V3 时代我最喜欢的东西之一,基本上是他们自己做专家并行路由和 dispatch 的库,会看非常底层的 GPU 网络原语,试图把不同操作合并,或者让硬件处理某些操作,让 MoE 路由尽可能高效。NVIDIA 也有一个类似的库叫 Hybrid EP,同样是在底层硬件上高效实现专家并行 dispatch。
高层来看,为什么这些东西难?因为你要做大量全对全 dispatch。如果看 MoE 的通信模式,很像张量并行:每次有 MLP,你都要把 token 路由到各种地方,而且必须以非常延迟敏感的方式做,因为你的计算在等 token 到达。所以降低这种 dispatch 的延迟极其重要。关于 DeepEP 的一个有趣冷知识是:DeepSeek 的人太拼了,为了优化最后一点性能,他们找到了一些未公开的 PTX,也就是类似 GPU 机器码的东西,并学会用这些未公开指令进一步加速网络通信。要走到并行效率的前沿,你需要的就是这个层次的功夫。
并行策略的组合与约束
到目前为止,我都是孤立地讲各种并行原语,隐含假设是你可以像乐高积木一样把它们拼起来。对大多数其他并行策略来说确实如此:数据并行和张量并行很容易组合。专家并行则有额外约束,你需要尊重。通常朴素的数据并行加专家并行方式是——很多旧库里就是这样——DP 和 EP 的副本是一样的。比如数据并行度为 8,我把专家分片到那 8 个副本上,数据也切分到那 8 个副本上并以各种方式路由。换句话说,你按 DP 切分 GPU,EP 是这个切分的一个子集。这很自然,因为你在路由 token。但这样做,EP 能并行的程度就有上限,而且会约束 DP 和 EP 的关系。这是最后的复杂性。抱歉,专家并行在很多方面确实很乱。
因为这些问题,有一个有趣的系统事实:MoE 只改变 MLP,完全不改变注意力。因为我们切 MLP 但不切注意力,专家并行对模型的应用是不均匀的。当我在专家层面并行时,我并行的是 MLP,但没有并行注意力。为了并行注意力,也许我还想用张量并行:我想要高张量并行来切小注意力。但这会影响 MLP,因为高张量并行加高专家并行会把矩阵切成非常小的块,利用率很差。所以有一个奇怪的取舍:注意力想要高张量并行,MLP 想要低张量并行,存在冲突。怎么办?最近几年人们做出了更复杂的系统方案,把 MLP 和注意力里的张量并行解耦:注意力层用一种张量并行,MoE 层用另一种。现在可以完全解耦并行,产生更复杂但更有效的 EP + 张量并行 + 数据并行组合。
上下文并行
这个领域最后要讲的是上下文并行(context parallel)。上下文并行或 ring attention 的基本想法是把很长序列里的激活值切分到不同加速器上。你可以以环状方式,沿着 TPU 的网格拓扑把激活值传给需要的设备。Ring attention 是最早做这件事的论文,展示了在 TPU 上工作得很好。上下文并行是现在做这件事的标准并行策略。两者都用在长上下文扩展阶段和模型服务中。我不打算多讲,主要是因为它概念上和我们已经讲过很多东西重叠。
策略对比:没有万能解法
第二部分到此结束。把一切放在一起,我做了一张并行策略的大表,包括数据并行——最上面两行——以及下面的各种模型并行策略。我用红色标出了我主观认为各方法的缺点。标红的原因是我想传达:没有哪种并行策略严格占优,全是一堆取舍,你必须优雅地管理它们才能得到好结果。比如 FSDP 很好,但它不帮助激活值,而且消耗全局批大小来做并行,所以有局限。为了弥补,也许你想把它和别的技巧组合:也许做张量并行来减少激活显存,它也不碰全局批大小;但这样做你需要快速网络和高带宽。为了利用较慢的连接,也许你想用其他并行策略。所以你会看到,这些不同优势意味着在不是所有、但很多大规模架构里,每种并行策略都有自己的位置,你会用它们的组合。
有人问流水线和 FSDP 是否仍适用于 MoE?是的,绝对适用。标准建议是专家并行像张量并行一样,最多用到 8 或者快速连接;人们现在不一定完全遵守。但即使如此,FSDP 或一般的数据并行、流水线并行、张量并行在前沿模型里都被大量使用。
算一算:计算与通信的权衡
你们作业里会做这件事或类似的事情。并行化很酷的一点是你可以做数学:对每种分片策略,每层计算多少、通信多少;如果不同设备分配给不同通信策略,它们如何扩展;对一组策略组合,它们如何扩展。然后你可以画出图:给定批大小和并行策略——这里我有三种不同并行策略——GPU 利用率是多少,也就是计算时间与通信时间的比例。只要计算时间比通信时间长,原则上你就可以把通信藏在计算下面。所以可以画这样的图:最上面虚线是效率边界,上面是完全利用计算,下面是 roofline 的坏部分,你在等通信来做计算。
这里有趣的是——根据我讲的,你们应该觉得很直观:如果批大小足够大,只用 FSDP 就很好。如果每芯片批大小是 2000,FSDP 表现极好,完全计算受限。但重要的是,批大小下降时,只用 FSDP 会到达通信受限的点。那怎么办?为了继续压效率,必须把 MP,也就是张量并行,加进来。现在你可以把曲线往外推一点,在更小批大小下仍然计算受限。你不断加策略把曲线往外推,得到更好的结果。这就是人们说的 3D 或 4D 并行——我不认为有 5D——也就是把所有策略放在一起,在不同通信拓扑下让计算单元保持满利用率。
一套简单的处方
有一个非常简单的处方。到目前为止我讲得比较一般,可能感觉复杂。但我觉得并行化策略其实很简单:只要模型还放不下,你就用任何必要手段切分模型;切分时,在快速互连上用张量并行或专家并行——如果每台机器 8 张 GPU,就用 8 路张量/专家并行;然后用流水线并行或 ZeRO-3(也就是 FSDP)把剩下部分切到能放下。一旦模型放下了,剩下就用数据并行。如果批大小太小,可以用梯度累积来提升 GPU 利用率。非常简单。
这在真实的大规模并行训练库里也得到印证。我放了 Megatron 关于并行化 MoE 的指南链接,但这个想法对稠密模型也适用。你会看到我刚刚说的,只是顺序反过来。指南第一条是:最小化模型并行,最大化数据并行——尽可能用大多数 GPU 来切分数据。如果你用 GPU,保持在 NVLink 内,也就是一个盒子内;把专家并行和张量并行保持在一个盒子内。如果要多节点,用流水线并行。如果是 MoE,优先专家并行。最后如果做长序列,用上下文并行,也就是 ring attention。这正是我整堂课想传达的,只是以实践者形式写在这里。
问答:循环 Transformer 与序列并行
有人问上下文并行是不是比序列并行更好的名字?是的。序列并行通常只是随张量并行一起做,用来减少激活值,更像是一个附加组件,很多情况下不是独立的东西。
有人问,如果怀疑是循环 Transformer 之类,会让事情更容易还是更难?我不太确定我有多相信循环 Transformer 的传闻。那会很有趣。回到系统结构会如何变化这个问题,这是个好问题。我猜它可能影响像 FSDP 这样的东西:你不能真正丢弃权重。不过它也有优势,就是你不需要一直重新获取权重,参数效率高得多,所以也许很多模型并行的东西就不会那么重要。对很多 FSDP 风格的东西来说,肯定会很不一样,因为 FSDP 策略建立在获取权重、丢弃权重、获取权重、丢弃权重之上;而在循环里,你什么都不丢弃。
从论文看并行策略如何随规模变化
这是一篇老论文,但它实际上可能是最好的资源之一。网络基础知识并没有改变那么多,所以我认为这里的经验今天仍然非常相关。这是 NVIDIA 和斯坦福的人——不幸的是现在不在斯坦福了——Matei 和 Deepak 写的论文。他们做了大量大规模并行训练运行,覆盖许多不同配置,基本上是为了告诉你,当你扩展规模时,并行化策略应该如何随规模变化。
你会看到正是我一直在讲的处方:数据并行先拉满,然后张量并行增加直到 8,然后停止,因为你不想再高了;从那之后,流水线并行不断增加。在足够大的规模下,数据并行会减少,最后 DP 是 6。这是因为你需要这么多张量并行和流水线并行来放下东西,剩下的预算用在数据并行上。把它们放在一起,即使 GPU 数量大到离谱,利用率也保持非常平坦、非常好。这就是为什么你会看到这些巨大的数据中心建设:人们想出的并行策略太有效了,通信硬件太好了,即使有巨大数据中心甚至跨数据中心训练,你也能在不同区间获得很好的利用率。
他们还有很好的定量证据:对不同批大小、张量并行,正确的停止点在哪里?很明显张量并行大小是 8;超过就会出问题。流水线并行也需要大批大小才能有效利用机器。这就是为什么橙色和蓝色之间有缺口,那是流水线并行;流水线并行越大,橙色和蓝色之间的缺口越大。
另一个你可能已经从 flash attention 里内化、但也可能没有的点是:你应该做大量激活重计算。如果你非常聪明地做激活重计算,就能得到更大的批大小,而更大的批大小又让你获得更好的利用率。这个观察一开始非常反直觉:你实际上应该通过重计算做更多计算,以便最终获得更好的利用率,因为它让你省显存,而显存可以换成批大小。
真实训练案例
最后我想看几个训练运行,从中感受现代并行在实践中如何使用、如何演化。Olmo 是 AI2 的 7B 开源模型,在 Dolma 数据集上训练,细节在 Dolma 论文里。它是一个 7B 模型,完全用 FSDP 在大量加速器上训练,具体多少我记不清了。这是 FSDP 扩展得出奇好的例子,即使到很多很多 GPU 也行。只要你训练小模型,FSDP 其实是相当好的并行策略。我认为很多 7B 级别的模型完全用 FSDP 训练。
DeepSeek 因为他们的系统能力,做了一些相当异国情调且有用的东西。DeepSeek V1 只用了数据并行 ZeRO 第一阶段,加上张量、序列和流水线并行,就像我之前讲的。DeepSeek V3 是 MoE 模型,所以不仅有流水线并行,还用专家并行代替张量并行。他们的专家并行有点异国情调,有 64 路并行:把八台机器分一组,作为专家并行域。为了支持这么大的专家并行,他们基本上用了流水线并行里的技巧,确保专家并行没有低利用率时期。这是相当复杂的技巧,用来得到非常大的专家并行切分。还有 Yi,中国开放权重训练场景,也用 ZeRO 第一阶段加张量并行和流水线并行。这非常合理,是经典的数据并行、张量并行、流水线并行组合。一旦进入 MoE,就用专家并行替代张量并行,它们服务相似目标,但专家并行更高效一点。
另一个极端是很大的稠密模型 Llama3。Llama3 报告我很喜欢,因为它是少数几份报告之一,真的给出了完整的并行策略分解,不只是抽象描述,而是跨越所有阶段。你可以看到有一个 pre-pre-training 阶段,一开始的小热身,第一行可以忽略;然后中间行是主要预训练阶段,是非常标准的并行策略:张量并行 8、上下文并行 1、流水线并行 16、数据并行 128,很大的数据并行切分。最后,因为他们想要长上下文模型,做长上下文扩展,这时把上下文并行调高,把数据并行降低,用这种方式并行化这个非常吃显存的阶段。注意这里用的是张量并行,不是专家并行,因为 Llama3 405B 是巨大的稠密模型,不是 MoE。
顺便说一句,我不怎么讲但如果你成为这些公司的 infra 工程师就会注意到:GPU 一直在坏。Llama3 405B 训练期间,GPU 据称坏了 148 次。所以你不只需要快速并行,还需要冗余,才能应对训练中可能发生的可怕事情。这也是一个分布式系统挑战。
Gemma 2 是谷歌开源模型,用 FSDP 加张量并行和序列并行,只用这两种策略。我提 Gemma 2 是因为据我理解,这基本上是谷歌那个说法的实现:对 TPU,你其实不需要做流水线,只要拿一张很大的环面网格,在那张大网格上做张量并行就行。这能否永远扩展下去不太清楚,我一直觉得不太清楚;但至少在 Gemma 的规模上确实如此。
最后我看了今年训练的一些模型,比如 Mistral 8x22B。调查时我发现,如果你对模型训练和实际中的并行配置感兴趣,NVIDIA 有一个仓库叫 Megatron Bridge,发布了很多不同模型规模和设置的推荐训练配置。你可以看他们认为 8x22B MoE 或 DeepSeek V3 建模的合适配置是什么。对 Mixtral,他们用专家并行 8、流水线并行 4,再加张量并行 4,张量并行是给注意力层的。这遵循了处方:专家并行大致保持在 8 左右。Nemotron 3 Super 跟随 DeepSeek V3 模型,用了不少专家并行以及上下文并行,因为至少在这篇文章里他们在做扩展。最后一个我想讲的是 Qwen 3,它跟随 DeepSeek 配方,用了相当大的专家并行 32,还有流水线并行 8、张量并行 2,用来切分注意力矩阵之类的东西。
另外看 NVIDIA 的人为并行化不同模型做的各种 benchmark 也很有意思。不过多讲细节,这些在 NVIDIA bridge 仓库里,想看去那里看。即使在张量并行内部,也有各种不同配置可以选,会显著影响性能。所以系统集成相当重要。
总结
把一切放在一起,我做了一个概览。真正共同的一点是:所有模型都尽可能用数据并行,尽可能最大化数据并行域。张量并行几乎总是保持在 8 以下。专家并行现在有时可以很大,我认为部分是因为 DeepSeek V3 以及他们为大规模专家并行训练构建的基础设施。
最后把一切放在一起:这堂课最高层的要点是,我们需要考虑多 GPU、多节点、甚至多数据中心的并行。要在这种规模下考虑效率,我们需要把所有方法都用上,因为你有快链路、慢链路,有批大小等各种资源,你都想要尽可能多地消耗。但尽管有这一切,事实证明把不同并行形式组合起来有相当简单的经验法则,最终能有效地让计算硬件满利用率。下周我们讲 scaling laws。
语言模型缩放定律基础:从数据幂律到 Chinchilla 算力最优
这堂课从缩放定律的历史和工程动机讲起,依次讨论数据缩放、数据混合与重复、架构与优化器比较、临界批量大小、学习率,以及 Kaplan 与 Chinchilla 的分歧和算力最优训练。
课程安排:今天讲缩放定律的基础
我们暂时离开系统部分,来讲一些更深度学习的内容。接下来会有两讲讨论缩放定律以及和缩放定律相邻的现象。今天是基础:讲比较基本的缩放定律、一些经典工作,以及缩放定律的基本想法如何和你可能在机器学习入门课里知道的东西联系起来。两讲之后,我们会进入更高级的缩放定律版本,去讨论一批现代开放模型技术报告、muP 和其他参数初始化,今年我还会加入一些优化器相关的内容,尽量贴近最前沿。今天先从最基础的东西开始。
由于排课限制,下一讲会是推理,所以你会来回切换:先缩放定律,再推理,再回到缩放定律。
为什么需要缩放定律:一次昂贵训练前的工程问题
缩放定律对应的其实是这样一个场景,或者说你很快就会遇到这个场景。你有一位非常有钱的朋友,给了你 10000 张 B200 用一个月,并请你构建一个非常好的开源语言模型,希望这些 B200 能产出一些东西。你已经组建了基础设施团队,也有了不错的预训练数据集——这本来是下一讲的作业,但假设你已经有了。然后你要训练一个大模型,可是训练大模型有很多选择:架构用什么、超参数怎么设。这些决策很可怕,因为一次运行可能花费数百万美元。所以,你怎么才能真正严肃地对待“扩展”这件事?怎么保证你的大训练运行真的成功?
这些选择里,有些你可以直接照抄文献;我早些在架构讲座里讲的那些,基本是被广泛采用的最佳实践,你可能从帽子里随便抽几个就行。但假设你在前沿,想做出比今天最好模型更好的东西,你就必须真正优化这些选择,不能只是复制别人的决定。那就要求我们认真面对缩放这个概念。
缩放定律一直是非常强大的工具,你也可以把它叫作一种范式。如果你和真正在大实验室做缩放工作的人聊,缩放定律几乎是一种生活方式、一种信念。你会看到为什么缩放定律有时是相当棘手的东西。但它本质上是简单的预测规则:从小规模模型的表现和行为,外推到大尺度行为。
训练模型最天真的做法是:我有一个大预算,就在这些大运行上做多次训练,并在大运行上调超参数。但这非常浪费。相反,我想在小尺度上完成所有优化,然后用一条简单规则把小尺度行为外推到大尺度行为。如果小尺度和大尺度之间由某种非常简单、稳健的联系——比如某种规律性——连接起来,你就会有信心用这种新的缩放方法来优化系统。这就是今天要讲的内容:一种工程视角下的缩放定律。
缩放定律的经典根源:从泛化界到数据缩放
开始之前我要讲一点背景。缩放定律很有意思:它既是最新的范式,很多模型就是按它设计和思考的;同时它又紧密连接着非常经典的想法。如果你是机器学习理论研究者,你会在某些方面感到很熟悉,比如经验样本复杂度。然后我会讲一类缩放定律的历史,说明这并不是全新事物,它实际上有相当多的历史和研究积累。
我真的很需要在幻灯片里放一些数学。一个非常有趣的事情是,长期以来机器学习理论都在问:我的模型表现会有多好?我有一个模型类,它会有多好?泛化界是理论家的回答:在一个有限假设类上,你的误差最多比模型在训练集上的表现差多少。这是经典的泛化界。你也可以把它写到更复杂的类上,比如平滑密度等。重要的是,这些界不仅告诉你性能,还常常依赖于样本量。所以它给出了损失值作为训练集增长函数的一个理论上界。某种意义上,这和数据量增大时会发生什么的问题非常接近。
如果你问最早的缩放定律论文是哪一篇,其实可以回到很久以前。你可以回到贝尔实验室,Corinna Cortes 和 Vladimir Vapnik 等人,他们当时在做非常严肃的机器学习理论工作,并问:在非常大的数据集上训练分类器通常很贵,我们想避免这样做;能不能只在小样本上拟合分类器,拟合误差率衰减曲线,然后用它估计性能?这几乎就是 1993 年的一条数据缩放定律。所以至少可以说,思考模型性能如何随数据集大小变化,是一个非常经典的想法,有很多工作支持。
我想把这些和更老的想法联系起来,这样你就不会把缩放定律看作纯神经网络的、非常新的东西。它在经验机器学习里有很多根源,也和理论机器学习有联系。如果你看这段历史,会有很多人说:也许我们不该把钱花在算法开发上,而应该去收集更多数据,因为如果把系统性能随数据量的变化画出来,它们全都以可预测的方式改善。Banko 和 Brill 是关于数据量和扩展如何成为有效提升系统性能方式的经典 NLP 参考文献之一。
还有 Kolachina 等人在 2012 年的 NLP 工作。这是较早研究缩放定律不同函数形式的工作之一。他们同样增加训练数据量,这里是机器翻译系统和 BLEU 分数,并问:模型性能随数据量增加的正确函数形式是什么?他们最终——可能令人惊讶,也可能不令人惊讶——得到了和我们今天使用的同样的幂律,也就是 pow 3 和 pow 4。接下来的讲座里,你会不断看到这些多项式函数,pow 3 和 pow 4。
我们在 2010 年代就已经知道很多这类函数形式可以很好地预测模型扩大后的行为。最后我特别想提一篇论文:不管什么原因,Hestness 等人 2017 年的工作没有得到应有的引用。神经缩放定律的起源,我会追溯到 Hestness 2017。那篇工作非常有前瞻性,为各种系统构建了作为数据集大小函数的缩放定律,包括不同语音识别系统、机器翻译系统、语言模型,并展示了这些系统在大量领域中遵循漂亮的多项式趋势。
它在 2017 年远远超前于 2020 年代早期的 OpenAI 缩放定律等工作。他们还讨论了今天我们会讨论的东西,比如涌现:模型可能突然展现出某些能力,因为准确率是比损失更不连续的度量;而按计算量缩放时,如果有系统能可预测地扩展到大规模训练数据,计算量确实会变得很重要;最后,既然按计算量缩放,速度和系统优化会转化为准确率。所以从某些方面说,我们今天看到的很多事情,比如系统缩放现象,我在 2017 年本可以知道——如果我当时读并认真思考了那些缩放定律论文。
所以我每场缩放定律讲座都会提到那篇论文,是为了说明:很多这些现象在现代语言模型时代之前就早已为人所知;我们本来可能在见到这些大型语言模型之前,就已经知道我们所处的状态。知道这一点很酷。关于这段半历史性的部分有什么问题吗?
有同学问:这些缩放定律只是纯粹经验上的,还是有什么方法可以证明它们的合理性?你问的是这些缩放定律从哪里来。它们是纯粹的曲线拟合练习。我会稍微讲一下缩放定律可能从何而来,以及为什么多项式可以说是非常自然的一类缩放定律,但并没有金科玉律说它们只能是这些。理论提供了一个很丰富的地方,缩放定律的函数形式可以从中而来,因为理论经常思考误差率如何作为各种对象的函数衰减,它会给你各种候选函数形式。物理学家也很擅长这个,因为他们思考极限,比如极限如何表现。那会给你不同的缩放定律函数形式。
数据缩放定律:幂律、对数-对数与估计问题
现在我们开始讲更现代的神 经语言模型缩放,先从数据缩放定律开始,因为对我来说它们最简单、最自然。讲数据缩放定律也能让我一步步带你猜缩放定律的函数形式可能是什么,以及为什么多项式形式是自然的。然后我会讲超参数、架构和其他更奇特的对象,它们更难从直觉上证明,但仍然表现出很好的缩放定律行为。
这种缩放定律风格的分析有一个显著之处:这些幂律关系似乎对大量不同因素都成立。预训练里常见的基本因素就是今天要讲的。常见做法是把计算量放在 x 轴——这是 log 计算量;抱歉,x 轴是计算量的 log,y 轴是测试损失的 log;或者 x 轴是数据集的 log,y 轴是损失的 log;或者 x 轴是参数量的 log,y 轴还是损失的 log。所以你可以有多种 x 轴,它们可以看作对数轴上的资源,而它们都能把 log 测试损失线性化。
你还能看到更奇特的缩放定律,比如下游基准上等价的缩放定律。在那些情况下,你预期看到作为计算量或类似量的函数的 sigmoid 形式。最近也有很多预测方面的工作,人们把日期放在 x 轴。奇怪的是,至少在这些图里,曲线的上包络常常在多种能力形式上呈线性。没有理由说下面这些更奇特的缩放定律必须成立,但事实是,语言模型性能作为规模和资源的函数,常常比最初看起来更规律。
首先从数据缩放定律开始。数据缩放定律是一种非常简单的单变量关系。在这个世界里,我们固定模型训练过程。一般来说,除非我另有说明,模型会比数据集大。然后我以规则的方式增加数据集大小,看误差如何下降。我们预期什么?一般来说,如果超参数调得好,它会大致单调,因为更多数据应该帮助我们在任务上做得更好,误差应该下降。如果你在做分类,甚至下一个词预测,你应该从随机猜测走向某种类似熵的对象,在那里你不能比任务的不可约误差做得更好。那就是噪声地板。所以这里会有一个类似 sigmoid 的对象。
我们先从一个经验观察开始,你们自己也会做出来。在作业 3 的某些部分,你会做出大致像这样的东西:如果我们有一个非常大的模型,比数据集大得多,然后增加数据集大小,把数据放在 log 轴上,y 轴是 log 测试损失,你会发现你画出的点会形成一条非常清晰的线性趋势。我从 Kaplan 2020 年的论文里取了这张图,会反复引用它——这是 OpenAI 的《神经缩放定律》论文。他们做了很多漂亮的消融和大量漂亮的数据。
现在,这个关系——我在对数-对数图上画了一条线——是一个无尺度或幂律关系。那意味着什么?如果某个东西在对数-对数图上是线性的,意味着我的误差是多项式衰减的。而且这通常意味着我离渐近线还很远;一旦接近渐近线,我会逐渐变缓,而不是保持一条直线。
让我们看一个例子,展示缩放定律如何非常自然地出现在一个非常简单的估计问题里。暂时忘掉语言模型。这是一门语言模型课,但现在它是统计课,接下来一两张幻灯片,也许是三张。在这个情况下,我想从一堆数据中估计均值。我有一个输入,从高斯分布中抽取,我要估计这个对象的均值,写下经验均值。然后你可以写下误差是多少,期望误差是多少。每个都是高斯分布,mu hat 也是高斯分布,所以我可以写下误差,它将是 sigma 平方除以 n。
如果你看这个,这就是一个缩放定律。我写下了误差,而误差如果我取对数,就是 log n 的函数,log n 是 log x 轴。一般来说,如果你能得出某个本质上是 1/n^alpha 加上常数的东西,把它画在 log-log 图上,减掉常数项,你就会得到一条缩放定律。所以任何这种形式——误差多项式衰减——都会给你一条缩放定律。在参数估计如均值估计或回归中,你当然预期多项式速率,不会真的预期别的。
如果你上过统计估计课程,你知道所有经典模型都预期有 1/n 缩放。如果你拟合回归,可能会得到 D/n 之类的。所以如果这是真的,而这正是缩放定律的起源,那么画线时我们大致应该看到斜率是 -1,因为 x 的指数是 -1:y 等于 -x 加 C。但如果我们拟合神经缩放定律,会发现指数相当不同。这些指数——左边两个来自 Hestness,右边来自 Kaplan——大约是 -0.1、-0.3、-0.1,粗略地说。
这意味着什么?这意味着这比估计均值或线性回归慢得多。这是慢得多的收敛速率,仍然是多项式,但慢得多。这很有趣:我们得到漂亮的多项式速率,但指数并不好,是 -0.1。你可能从哪里得到这样的估计速率?如果模型更非参数,就会得到这种速率。
我保证这是本讲最后一张统计幻灯片。假设我现在想估计的不是均值,而是一个任意光滑函数。如果我在做回归问题,有输入 x1 到 xn。假设它们在单位盒子里,有一点高斯噪声,有一个未知函数 f 要估计。一个平凡的估计器是:把空间切成小格子,边长大约是 n 的负 1/4 次方;会有 sqrt(n) 个盒子,每个盒子得到 sqrt(n) 个样本,误差大约是 1/sqrt(n);一般 D 维时误差约为 n^{-1/D}。所以缩放会是 y = -(1/D)x。
所以现在这些更灵活的函数——比线性类更灵活的函数——它们的缩放律速率不只是 1,而是 1/D。一个心理模型是:前面幻灯片里的神经网络表现得像 10 维的非参数回归器,像 10 维里的最近邻或其他估计器。这大致就是它们从数据中学习的速率。只看缩放定律就能知道这一点,很酷。
一些人更强地论证了这一点。Bari 等人和其他缩放定律理论研究者认为,缩放定律的指数实际上直接告诉我们,神经网络表现得像非参数平滑器,诸如此类。这是一组有趣的论证,你可以按自己的意愿接受这个直觉。我自己并不完全确定有多相信这个论证;有些证据可能有点可疑,它依赖于内在维度的估计器。但思考这些指数告诉我们神经网络到底学得多快,是很有意思的。
这里我暂停一下。这基本上就是单分布数据缩放定律的基础:从一个分布采样,然后从中学习。例子都清楚吗?有同学问:当我说模型总是比数据集大时,是指参数量和 token 数量一比一吗?好问题。我的意思是,当模型相对于数据变小时——比如数据远多于模型大小——你会进入不可约误差区域。你基本上已经按模型类把数据拟合到最好,不管再加多少数据都不能更好。所以在思考缩放定律时,我们通常想处在幂律区域,这是更简单的情形;或者你显式拟合渐近线然后校正。我说模型比数据大,可以是任何比数据大的量,只要还在幂律区域,通常是数据的 10 倍之类。
数据工程:混合、重复与过滤
现在回到其他数据缩放定律。数据缩放定律本身没有你希望的那么有用,它只告诉你模型学得多快。这对预测有些用,但其他用途不大。如果你想做工程,你大概率关心别的问题:最优数据混合是什么?怎么为模型选择训练数据?要不要重复数据,还是不要重复、把算力省下来做别的?也许我应该重复高质量数据,不重复低质量数据。我可以在数据上做很多工程决策。缩放定律怎么帮我们做这些决策?
一个有趣的点是,如果我们想想经典模型的行为:数据缩放定律,如果把它的起源看作泛化界的经验版本,它告诉我们,对很多模型来说,数据集的组成实际上影响的是缩放定律的偏移,而斜率由模型类决定,不由分布本身决定。所以斜率可能保持不变,只有截距变化。而截距的形状可能非常有趣。
这是一个玩具模型:线性回归,有两种可以采样的数据。如果你只得到一种数据,误差无论怎样都很高。你其实想要两者的混合。所以如果你写下截距如何变化,就会得到一些非常有趣的洞见:数据多样性非常有帮助。但这在实践中意味着什么?实践上,你可以在混合数据上拟合数据缩放定律,这能帮助你做预训练数据优化。
假设为了简单,有两个数据源:新闻和维基百科。用多少新闻、多少维基百科?你可以用非常小的模型、非常少的数据训练,然后拟合不同混合比例如何影响性能的函数。你可以随着模型一点点变大,看趋势如何变化,然后像外推数据缩放定律一样把它外推。然后你可以问:如果扩展到完整训练运行,哪个会最优?这是一个非常简单的想法:在小计算量上拟合函数形式,找到最小值,然后外推。
这是一篇数据混合损失论文。但不幸的是,如果你和做过很多数据混合工作的人聊,他们会告诉你现实比这个理想世界嘈杂得多。据我所知,很多情况下实际发生的是:你训练一堆小模型,选小模型上最好的数据混合,然后直接放大那个混合,不需要缩放定律。DataDecide 是这类数据混合的大规模经验研究。他们发现,如果你不拟合缩放定律,而是直接选小尺度上最好的数据混合,效果就很好。这和“截距不同但斜率不变”的论证一致,因为如果斜率不变,小尺度上最好的混合在大尺度上也是最好的。
另一个我会在数据语境下提到的有趣问题是重复。现在越来越常见的情况是计算量在增长,而我们拥有的数据量没有增长。所以很多人关心:当你越来越多次重复数据时会发生什么?几年前有一项很好的研究,叫《Scaling Data-Constrained Language Models》。他们大致表明:用标准训练配方,最多四个 epoch,你完全不会受损;但超过这个点,你实际实现的缩放定律——深色曲线——会比有新鲜数据时投影的缩放定律差很多。那条虚线就是投影。你也可以写下一个修改后的函数形式,通常能预测重复下的缩放行为。
我们还可以把这个想法推到极端。这是最近和 Percy、以及我们共同指导的一位学生做的工作。我们问:如果把这个想法推到极端,考虑无限计算量,允许你任意多次 epoch,甚至无限次,你能从这个系统中得到的最好结果是什么?事实证明,你不能只是一直重复数据,也不能一直把模型变大,那些都有递减回报。所以你会转向其他东西,比如把模型集成起来,试图从数据中榨出更多。但真正有趣的一点是:这里红色的标准数据缩放定律,随着数据增加,性能有非常好、可预测的改善。我们做各种干预,比如正则化和加集成,性能会提升,但斜率看起来惊人地相似。
这是你开始自己拟合缩放定律后会学到的一课:很多时候你的斜率不会变。你做的干预通常只改变缩放定律的截距。
最后我要提一篇论文,因为我很喜欢它在缩放定律和一般缩放语境下的意义:我们关心的很多数据现象实际上是强烈依赖规模的。一个例子是数据过滤。如果你或我明天决定为正在训练的模型过滤一些数据,我们可能会过滤得非常激进,只保留最高质量的东西,因为你和我都没有很多计算量。反正不能在全互联网上训练,不如过滤到最高质量。另一方面,如果你有很多计算量,你会想在越来越多东西上训练,因为你不希望重复那些非常高质量的数据。所以随着计算量增加,你的过滤器会越来越松,可能开始在质量越来越低的东西上训练。
所以很多我们经常静态思考的东西,比如数据过滤或数据质量,实际上是更动态的。随着规模增加,你必须思考:剩下的数据从哪里来?过滤器不能作为一个固定函数保持不变,最优过滤器显然也不是固定不变的,而是随规模变化。
好,数据缩放定律就讲到这里。这是我认为最容易理解的部分。即使作为统计/机器学习思维的人,这些东西对我来说也非常简单直接:增加数据,性能以非常可预测的方式变好。我认为这没有争议。对于这么大的模型,它多项式地变好仍然很酷,但没什么真正疯狂的。
有同学问:在这张无限计算量下预训练的幻灯片里,右边的图在 loss 和数据集大小上都是线性的,而我之前给的例子是对数-对数,为什么两边都线性?对,这是一个不好的坐标轴。Herman 和我就谈过这种不好的坐标轴。这实际上是在倍增,所以看起来线性,但实际上是 log 尺度。现在改好了。这里的 y 轴看起来不像 log,但范围太小,线性与 log 基本一样。你看这里拟合的函数形式,仍然是多项式;我们拟合的是同样的函数形式,画出来的也是那些。还有一个点我最后会讲:如果你只有很小一段计算量范围,你很难非常明确地区分某个东西是按多项式缩放还是指数缩放。因为泰勒近似确实存在。只要放大得足够多,一切看起来都是线性的。所以它究竟是多项式还是其他函数形式,你总是要小心和保持怀疑。
架构与优化器的缩放比较
好,这就是关于数据的缩放定律,很多方面都很直接。现在我想谈更奇特的缩放形式,以及各种模型工程的缩放定律。现在我们要设计大型语言模型。也许你是个激进派,相信 LSTM 才是未来:为什么我不能用 LSTM 代替 transformer,把 B200 算力花在 LSTM 上?或者你是另一种激进派,想用 SGD 训练:为什么不能用 SGD?如果你的资源有限,你有不同分配方式:也许训练更久,也许训练更大模型,也许收集更多数据。总是有各种权衡,而缩放定律给了我们定量的方式来做这些权衡。
我要从一些简单的超参数问题开始,比如如何选择架构或优化器——这些我在第二讲讲过的内容。在那里我给了你一种视角:也许你可以研究别人怎么做。现在我要试着用第一性原理、用稍微不同的权衡视角来回答这些问题。所以在这堂课里你必须相信缩放定律;但只要你相信,你就可以自己尝试并发现答案。
第一个问题可能是:Transformer 真的比 LSTM 好吗?LSTM 也能工作得还行,它们能拟合分布。随着 Mamba 等出现,我们知道 SSM 也能工作。那它们更好吗?暴力回答这个问题的方法是训练一个大的 LSTM,达到 GPT-3 或更大模型级别。缩放定律的方法是训练一堆小模型:在一个计算量范围上训练 transformer,再用不同层数的 LSTM 在另一个计算量范围上训练。这显示了什么?LSTM 明显有不同的截距,甚至可能有不同的斜率。因此,你大概不会选 LSTM。这张图会证明应该扩展 transformer 而不是 LSTM。
如果你看今天的很多架构论文,比如 Mamba 论文或 gated delta net 论文,它们总会有这样一张图:vanilla transformer,以及我们很酷的模型。我们很酷的模型通常在线上方或下方,因为那才能证明你的模型更好。你当然不想要斜率更差的模型,因为那意味着规模越来越大时,你的模型最终会更差。这是不训练巨型模型就能推理架构的一种方式。
最近我没有看到那么多又好又新的架构缩放定律论文。我比较喜欢的一篇相对近期的工作是 Google 的 Ek 等人做的,当时他们还在发表这类工作。他们对 T5 风格模型做了一大堆缩放研究,训练越来越大、各种不同架构的 T5 变体。我觉得很酷、也是我仍然提这篇论文的原因之一是:它们恰好捕捉了我们今天在前沿模型中实现的架构变化。比如 performer,那是一种高效注意力,它的扩展不好,我们今天不实现它。我们实现的是 gated linear unit,红线在整个缩放趋势中都比绿线好。Switch transformer——我不知道他们最大运行发生了什么,但一般来说缩放趋势不错。我差点忘了,还有 mixture of softmaxes;其实我们今天不再这么做了,但给定缩放趋势,它可能是一个有效干预。
所以你看,通过这些缩放趋势,尽管很多论文运行的计算规模比我们今天小得多,它们仍能看出当时驱动前沿模型发展的架构趋势。我想这也是为什么很多人把缩放定律当作一种几乎范式化的方式,说:如果一个干预没有出现在缩放定律里,它就不是好干预。因为某种方式上,过去很多工作捕捉到了我们今天看到的东西。
好,超越架构,我们可以把这种分析扩展到各种其他想理解的东西。我们可以问:SGD 比 Adam 更好还是更差?如果做缩放定律分析——这是 Hestness 的工作——你会看到非常清晰的趋势。这再次是那个非常神秘的事情:截距不同,但斜率非常相似。如果你固定数据和模型,斜率往往非常非常相似。我每次看到都很惊讶,但它非常真实。即使干预大到从 SGD 换到 Adam,也很少得到不同的斜率。如果你是训练的人,你会觉得这是巨大的变化;但缩放趋势保持大致相同。这里是一个用 recurrent highway net 做语言建模的例子。
类似地,在第二讲我讲过 aspect ratio:如何选择深度和宽度的权衡?我告诉你,你可以随便选一个大致合理的数,比如四倍合理乘数。但如果你做这种缩放分析,你可以更精确地知道发生了什么。首先你可以看缩放趋势作为层数的函数,你会立刻看到层数极少非常非常糟糕。如果只有一层,你哪儿也去不了,那是可怕的缩放趋势。大于一层后,其实竞争性出人意料地强,尽管你会看到在每个计算量水平上,至少在这个例子里,更多层更好。
如果你能做更细粒度的研究——Kaplan 论文就这么做了——你可以试着找出所谓的规模不变量,比如 aspect ratio。层数不是规模不变量:模型变大时,你确实想要更多层。但模型变大时,最佳 aspect ratio 也许应该保持不变。这个是规模不变的。所以看 aspect ratio,我们会看到不同模型大小下,基本上在每层大约 100d 处有相同的极小值,也许稍微少一点。它确实会稍微右移,更深模型的最优 aspect ratio 会更小一点,但大多数情况下极小值位置大致相似。
你可以对注意力头维度等做类似分析。如果你看 Kaplan 论文,他们在这方面做得很好。这是让你对自己的缩放策略有信心的方式。如果你的缩放策略是“我固定 aspect ratio,然后扩大规模”,你可以画这样的图,说服自己我们大概没问题,因为优化没有随模型越来越大而移动太多。
参数计数、MoE 与“缩放定律是工程出来的”
有一件重要的事,这是一个巨大的潘多拉魔盒,大到我会用好几张幻灯片讲:并非所有参数都是平等的。因为并非所有参数平等,你的缩放定律会看起来好或坏,取决于你如何定义一个参数。在 Kaplan 论文里,他们观察到:如果他们画深度缩放定律时把嵌入参数算进去,就会得到形状很奇怪的缩放定律。他们说,这完全不行,完全不行。因为完全不行,他们决定排除所有嵌入参数,只计算非嵌入参数,因为你可以为自己辩护说,这些才是做计算的参数。我们后面会看到,这对某些结果的有效性有非平凡的影响。
这里的重点是:缩放定律不是魔法。Percy 喜欢强调这一点:缩放定律以及所有这些跨规模的可预测性是被工程化出来的,不是自动发生的。我们需要选择正确的 x 轴,需要确保超参数设置正确,只有在这些条件下,才能在多个数量级的计算量上得到可预测的缩放。这就是很多缩放定律研究者做的事情:思考什么才是获得非常可预测缩放的正确方式。
与此相关,我认为最近有一些关于 mixture of experts 缩放的很有意思的论文。MoE 可以说是训练大模型的主导方式,所以思考在这个参数量和活跃参数量解耦的新世界里,参数的价值是什么、什么算一个参数,非常重要。Apple 和 MIT 的一些人做了一个很干净的分析,展示了作为参数量和活跃参数量函数的缩放趋势。很酷的一点是:当你训练越来越大的模型时,如果你在 x 轴上增加总参数量,并想最小化损失,你最终会得到越来越稀疏的模型。看颜色变得越来越深。类似地,这里活跃参数量随着 MoE 稀疏度增加而减少。
你会得到一个我觉得有点直观的结果:你可以画出这些曲面。对每个计算量,你可以问:我宁愿把计算花在活跃参数上,还是想要多少稀疏度?我可以在不真正改变计算量的情况下改变稀疏度。给定这一点,你可以问:如果我增加更多空参数,活跃参数不变,但总参数更多,会怎样?当你从图的后面移到前面,损失会改善。换句话说,MoE 中不活跃的参数仍然帮助你降低损失,这很酷。你可以写下这些函数形式。我不展开细节,但重点是:我们在优化 MoE 时关心的所有这些量,都有可预测的缩放和规律性。
批量大小:临界批量大小与噪声受限/偏差受限
我现在想谈两件事。当你训练一个新的大模型时,其实我讲的很多东西都已经固定了。你们大概不会激进到切换到 LSTM,大概会选一个 DeepSeek-V4 启发的 MoE 之类,选经过验证的架构选择,不会把一切推翻。但有两件事你确实必须重新发明:批量大小和学习率。如果你训练过深度学习模型,你知道必须非常小心批量大小和学习率的组合。改变一个,就得改变另一个。
批量大小从上一讲看还有另一个原因很棘手:我们希望批量尽可能大,因为大批量给我们并行化机会。记住,数据并行需要大批量。所以真正想要知道的是:批量能开多大,才开始受损?这是相关的系统问题。你还想知道这如何随模型大小变化。
有了这个前言,有一个非常重要、在很多论文里被使用、也经常被讨论的想法,叫临界批量大小(critical batch size)。临界批量大小大致是这样一个想法:在某一点之前,当你开始得到递减回报之前,增加批量有完美回报。我可以把这叫作噪声受限区域。在噪声受限区域,你放进批量的每一个额外元素都会减少 SGD 步中的梯度噪声。因为你是方差受限的,方差的减少非常非常有帮助,会带来大回报。所以这是完美缩放的区域:你处理额外样本时,做得差不多和你可能做到的一样好。
当你到达某一点,达到了梯度的噪声尺度,你就不再是方差受限,而是偏差受限。这是什么意思?记住,在梯度下降中,你看的是目标的局部结构,没有全局视角知道最小值在哪。所以不管噪声多低,我只能指向这里浅蓝色的方向。我的局部下降方向和全局最优之间总有分歧。因此到某一点,我会得到递减回报,因为限制因素不再是方差,而是偏差项。过了这一点,我就受偏差限制。
所以临界批量大小是一种经验法则,或者说方便的权衡点:从这里开始,我们从完美缩放区域跨入低效缩放区域。它给你一个好规则:如果你想让批量尽可能大而又不遭受巨大效率损失,批量应该设在哪里。希望这个想法很清楚。
临界批量大小是个奇怪的对象,它来自对目标局部二次近似的一堆复杂计算。但我会给出实际用来估计这个量的机械步骤。你首先选一个目标损失,比如你想达到的某个数值。你的目标是尽可能快地达到这个目标损失。接下来我会扫过所有不同批量大小,写下达到目标损失所需的步数,以及达到目标损失所需的样本数。当然,这两者通过批量大小因子相关:步数乘以批量大小等于样本数。
通过一些计算,OpenAI 的 McCandless 等人认为,我们应该遵循大致这样的函数形式:所需步数和所需样本数成反比,并带有最小可能步数和最小可能样本数这两个估计,对两个量做归一化。所以这两者之间有权衡:如果你想最小化样本数,你会有很多步;如果你想最小化步数,你需要很多样本。这是很自然的权衡。如果你增加或减少批量大小,权衡会偏向一边或另一边。
他们主张,目标应该是平衡这两边。我想确保步数项和样本数项大致平衡。如果你求解,会得到这个解:临界批量大小等于我需要的最小样本数除以我需要的最小步数。这两个量通过拟合观测数据估计。这会平衡方程两边:它会给我比最优稍多一点的步数,比最优稍多一点的样本,但平衡了两边。你可以把这看作优化收敛速率,同时不使所需步数爆炸。
还有一个附加点,我不详细展开,因为更深入细节:你应该读临界批量大小论文。他们的一个论点是,估计这个量相当复杂;你也可以通过看梯度协方差与梯度平方范数的比值来估计。
那么,这为什么在缩放讲座里?到目前为止这和缩放没什么关系。原因是:首先,临界批量大小是一个合理的批量大小选择,它平衡收敛速率和批量大小。如果我想把批量设成临界批量大小,你可能会问:这个东西如何随目标损失缩放?目标损失可以看作计算量。如果我想要越来越好的模型,应该用什么批量大小?
这也是它属于缩放讲座的原因:当损失下降、规模变小时,临界批量大小会增加。而且它以非常可预测的方式增加,又是一个幂律。这是他们主张的关系,大致是批量的逆多项式。给定这一点,这很好,因为这说明如果你有一个非常大规模的训练运行,一直走到图的右边,你的批量可以相当大。这说得通:当你越来越接近最小值,方差减少的价值增加,因为你在优化越来越细粒度的对象,当你最小化非常非常小的差异时,噪声更重要。
学习率如何随规模变化
这就是批量大小的故事。另一方面,你始终要调的两个量是批量大小和学习率。学习率这边的故事也有一点复杂。我会在高级缩放讲座里讲更多细节。学习率一般会移动。对于标准世界,我觉得一个好的心理图像是:假设你有一个 MLP,一个标准神经网络,我不做深度缩放,只做宽度缩放。如果做宽度缩放,模型越大,学习率应该越小。原因是我有更多参数,一次改变更多东西,也许我应该移动得更少。这说得通。
事实上,有一个经验法则:你可能想按 1/宽度 缩放。这是一个很常见的经验法则:随宽度规则地减小学习率,这会给你一个缩放学习率的规则。还有另一个学派或另一组技巧:人们也会用各种方式重新缩放网络,改变初始化大小,改变优化器对不同部分的步长,以迫使模型在不同规模下有相同的学习率极小值。这个想法叫 muP 及类似方法。一些人报告了很大成功,另一些人报告成功较少。
为大规模运行设置学习率的两种方式基本都体现在这张图里。一种方式是估计这些极小值是什么,并预测你的极小值会在哪;极小值变化的方式相当可预测,所以这不是疯狂的想法。另一种方式是重新参数化模型,尽量保持学习率极小值不变,然后选你最好的学习率直接开跑。这是选择学习率的两种哲学。两者都在大规模上成功应用过,都有大规模训练运行用过。据我非正式观察,似乎更多人偏向缩放定律方法,但两者肯定都可行。下一讲我会详细讲这两种。
上游困惑度不等于下游表现
在进入本讲最后部分之前——我想讲一些不完全是陷阱,而是缩放定律各种细节的东西——我想用这个想法结束这一节:上游和下游表现可能相当不同。如果你看 log 似然之类,这是 Yi Tay 架构论文里的图,你会发现困惑度和负困惑度(在这个例子里)与参数非常相关:参数越多,模型越好,非常可预测。这是一条非常漂亮的线性趋势。
然后你会说,好,现在我们要发布最好的模型 NL12。结果 NL12 并不是最好的模型,实际上是 NL32XL,它在困惑度上差得多。这是非常常见的事情。上游和下游经常相关,但这可能是我见过的最差的上游到下游相关性之一。我认为非常重要的一点是:缩放定律通常是应用在困惑度这边的对象,它们非常干净、非常规律、非常可预测;但从困惑度到下游的迁移比最初看起来要不那么确定。这是你需要非常谨慎的地方。
作为一个轶事,我有以前的学生去各个地方做后训练,他们总是抱怨:那些预训练的人把模型交给你,说困惑度很好,现在都是你的问题了。但问题往往从预训练那边就开始了。所以你不要成为那种人。你要真正思考迁移,而不只是关注困惑度。
拟合缩放定律的实际流程与方差
这也许令人惊讶,也许不。但我想在这里强调:在你做大训练运行之前,你应该大致知道你会看到什么,不只是知道它能不能训练。事实上,如果你做缩放定律,你应该能相当精确地预测数值,几乎能预测你的运行会有多好。如果我选一个优化器而不是另一个,收益会是多少?你应该能预测。所以基于缩放定律的流程是:训练一堆小模型,在 log-log 图上拟合这些线,建立缩放定律。如果拟合足够好,我就有信心这个差距会持续,然后把它部署到大规模训练运行上。这是缩放定律流程的朴素版本,但我认为它相当准确地反映了实践中你可能怎么思考。不然你还能怎么办?凭空部署一次运行?大概不行。
我可以在这里暂停,进入最后部分前回答问题。有同学问:缩放定律上每个数据点,你如何考虑方差?做多少次运行?聚合通常是平均,还是根据情况取最小或最大?在我看来,大多数情况,如果不是所有情况,那些都是单次运行。原因是当你看困惑度时,困惑度非常非常干净。如果你重新运行,方差可能在第二位小数左右,因为训练数据非常同质,数据量很大,评估集很大,你真的把方差压得非常低。但如果你做学习率或临界批量大小缩放,你会看到一些非常可怕的东西。我认为人们确实做了一些方差减少,但实际上没有应该的那么常见。
有同学问:人们会直接用下游指标而不是困惑度做缩放定律吗?会,人们直接在下游指标上做缩放定律,但那有同样的问题。我认为思考缩放定律的方式是:至少可靠地建立一个东西。比如,如果我看到这样的图,我强烈相信 transformer 这条线会继续,这是非常规律的趋势。它可能有一点弯曲,但我们确实认为这里可能有规律性。但相反,想象这是一条非常嘈杂、锯齿状的线,你就不知道发生了什么;你会说,我可能相信它会继续,但也许它是直线,也许它是曲线。所以如果我试图解释这套哲学,就是:从低方差测量开始,在那里建立缩放规律性,然后依赖某种关于迁移的信念,或者在别处建立迁移,试图表明它会迁移到下游。
有同学问:这里有些是测试损失,有些只是训练损失?什么时候用哪个?当你做好的科学实践时,比如 Kaplan 2021,你会拟合测试损失。但现实地说,除了极少数模型,比如无限计算量那个和重复那个,除非你在做那些,否则你都是在做单遍 SGD。所以所有这些模型的泛化差距非常非常小,训练和验证非常相似。我见过一些预训练代码库甚至没有验证损失,因为如果你做单遍,很可能训练和验证非常接近。这也是为什么很多图会交换这两个,因为它们都在单遍区域。不过这是个好问题。
数据还是模型:Kaplan、Chinchilla 与算力最优
好,我们继续。现在我要讲缩放定律最著名的用途之一。即使你个人可能不用,你也一定在某个时候用过这个经验法则。而且我认为这组内容会告诉我们一些关于缩放定律实际执行的有趣事情。动机问题是:我想要更多数据还是更大模型?你拥有的资源是计算量。有人会隐喻性地给你一定量的 FLOPs。你要把这些 FLOPs 花掉;如果记得 Percy 的第一讲,大致是数据乘以参数量约等于 FLOPs,对它线性。所以我想要更多数据还是更多模型?
我们知道,如果拿一个极小模型,往里灌大量数据,那只会浪费。这是最上面那条深紫色线。我拿了一个很小的语言模型,喂了巨量数据,它很长时间都是平的。这是完全浪费计算量。在同样计算量下,你宁愿训练那个大的黄色模型到同样多的 token,甚至更早。
现在,如果你能理解数据量和模型大小如何共同决定性能,如果你理解这个关系,你就可以非常精确地优化。所以有更高级的缩放定律,关联对象的联合行为。几乎同时,Kaplan 和 Rosenfeld 都提出了这些函数形式,它们大致等价,描述了模型大小和数据量如何与误差相关。Rosenfeld 非常简单,真的就是两个逆项相加:两条缩放定律加在一起。Kaplan 稍微复杂一点,但大致是同样的想法。
如果你看极限,这非常直观。如果我有无限数据,那么这一项趋于 0,你基本上受模型大小限制,变成纯粹的模型大小缩放定律。如果我把模型大小送到无限,那么我受数据限制,得到纯粹的数据缩放定律。所以取这两个的极限是合理的。如果有人告诉你一条缩放定律,取所有变量的极限来理解系统行为,通常是个好主意。
很酷的是,Rosenfeld 和许多其他提出这种联合缩放定律的人表明,这些东西拟合得很好,即使外推也很好。你会做的是:在这些绿色点上拟合,这是低计算量和低数据区域;你从没看到高数据或高模型大小的东西。然后你可以基于这个角落里的小运行,外推到高计算量或高数据、高模型大小区域。你可以基于预测非常准确地预测误差。预测很好。
一旦有了这个,你现在可以说:我有固定量的 FLOPs,在 FLOPs 约束下最小化这个。这是一个简单的非线性优化问题。你可以解出来。Kaplan 和 Rosenfeld 都提供了这种形式的方程。他们说了什么?如果你看 Kaplan,解这个方程,他们会说类似这样:数据量应该是 C 的 0.73 次方,模型大小应该是 C 的 0.27 次方。是不是反了?我觉得反了,抱歉。N 应该是参数量,D 应该是数据量。所以 tokens per parameter 随 C 下降。也就是说,随着计算量增加,Kaplan 的方案告诉你要训练越来越大、越来越大的模型。
如果你在 GPT-3 时代待过——想到有些人不在那个时代让我有点震惊——你会知道有一段时间大家都在训练这些巨型模型,比如数千亿参数、万亿参数稠密模型等等。部分就是被这个驱动的。他们看到 GPT-3,看到 Kaplan 的预测,说:哇,我要在巨大的模型上做大训练运行。
但几年后,2022 年,DeepMind 的 Hoffman 等人进来说:其实这些预测全都错得离谱。你们一直在训练这些巨型模型——这里的三个星号——但它们其实太大了。我们应该训练的是一条非常不同的趋势,这里蓝线,应该训练这个蓝绿色星号,是更好的模型。所以他们主张我们应该训练比这个小得多的模型。当然,你们很多人或所有人大概都知道 Chinchilla 论文,知道 token 乘数:每个参数 20 个 token。
但我认为详细讲这个过程很有用,因为 Chinchilla 和 Kaplan 的分歧告诉我们一些重要的事情:我们应该如何思考缩放定律。它不是转一下曲柄就能得到答案的东西,而是需要小心、敏感,必须尊重过程。所以我要走一遍 Chinchilla 论文。走完之后,我会讲为什么 Kaplan 和 Chinchilla 论文有分歧,以及这对你意味着什么。
Chinchilla 作者提出了三种不同的拟合缩放定律的方法。我非常喜欢这一点,因为这是一种对建模假设做稳健化的方法。每一种实际上都是估计模型大小和数据权衡的不同方式。除了方法 3 稍有不同,它们大多一致。方法 1 和 2 都同意数据和模型大小的系数大致相同:0.5、0.5,0.49、0.51。方法 3 确实不同,0.46 和 0.54,但我们稍后会谈为什么。Kaplan 等人当然两者差别很大,这就是为什么他们在很大计算量下训练大模型。
Chinchilla 的方法 1 可以叫下包络方法。Kaplan 也部分使用了这种方法来确定正确的缩放。做法是:取所有训练运行。这里每条彩色线是一条训练曲线。你想取这些训练曲线的下包络。因为下包络上的点意味着对某个 FLOP,这是我在任何训练运行中达到的最好损失。所以我可以取高计算量区域最底部的所有下包络点,然后画出这些点对应的模型有多大。因为每个点是一次带模型大小的训练运行。如果散点画出来,会得到漂亮的线性趋势。这给我一种估计 FLOP 到参数最优权衡的方法。它给出一个答案:在他们的计算预算下,应该训练 670 亿参数模型。这简单漂亮,但定义下包络之类的东西很棘手。
方法 2 是 IsoFLOP。现在非常流行,也非常容易、非常稳健。这大概是我个人最喜欢的方法。做法是:选一堆不同的 FLOPs 预算,比如以 3 的因子变化。对每个 FLOP 预算,你扫过空间。这里有一个变量:参数与数据的权衡。对每个 FLOP 预算,我固定 FLOPs,扫过参数到数据的权衡:数据翻倍,模型减半,等等。这会生成一串训练运行,其最终损失会扫出一条曲线。所以每组彩色点是一个固定 FLOP 运行,每个点扫过不同参数,当然也隐式扫过数据。
现在好的是,我可以取每个运行的最小值,或者拟合二次曲线并取二次曲线的最小值,然后画一条线穿过所有二次曲线的底部,或者取底部并作为 FLOP 到参数的函数画出来。这给出另一个预测,非常接近:在我的计算预算下是 630 亿参数。
最后一种方法其实可能是最自然的,Rosenfeld 和 Kaplan 都建议过,因为他们都提出了模型大小和数据如何与损失相关的假设函数形式。所以如果你有这样的联合函数形式,你可以取一大堆已经做过的训练运行,拟合你的损失景观——也就是你假设的函数形式——然后直接做曲线拟合来估计缩放定律的系数。这是最暴力的方式:你有一个损失、数据量和模型大小之间的假设关系,训练一堆模型,做曲线拟合。希望这相当直接。当然,如果你用这种方法,如何进行曲线拟合非常重要。你在拟合这些曲面,有很多变量,这有点棘手。它也有像方法 1 一样的棘手之处。所以它不像 IsoFLOP 那样直接。
Chinchilla 方法、Kaplan 的不一致及其原因
现在我们已经讨论了 Chinchilla 做了什么。它看起来和 Kaplan 做的没有本质区别。并不是 Kaplan 做了什么疯狂的事,然后 Chinchilla 作者做了更聪明的事,你说:哇,为什么我们不从一开始就这么做?他们都做了相当合理的事情。但如果我们看他们的缩放预测,它们非常不同。事后看来,我们通常倾向于同意 Chinchilla 作者有更多正确的缩放。如果你做自己的 Chinchilla 风格分析,你会发现缩放可能更接近 Chinchilla。那么,为什么会有这么大差异,既然他们都在拟合这些联合缩放定律?
这就是我认为我们进入缩放定律如何制定的混乱现实的地方。缩放定律真的会变化,取决于你实现缩放定律的精确方式、你使用的超参数设置,甚至 x 轴是什么。有一篇非常好的论文——哦不,我把作者遮住了,我之后可以把它粘贴到课程 Slack 里——叫《Resolving Discrepancy to Compute-Optimal Scaling of Language Models》。Yair 是最后一位作者,他曾是这里的学生。他们首先用大致 Kaplan 的设置复现了 Kaplan 的结果。然后他们说,好吧,让我们改变参数的计数方式。这会让曲线稍微移动一点。然后让我们改变学习率预热的方式。这会让曲线稍微移动一点。然后让我们再稍微调一调优化器。然后他们就得到了和 Chinchilla 完全一致的结果。
所以他们的论点是:这里存在一系列看起来非常微小的决定,最终在 Kaplan 缩放定律和 Chinchilla 缩放定律之间造成了这么大的差距。我会解释这些步骤各自是什么,并不是说这是最重要的单一因素,而是也想向你强调,这里的细节有多么重要。
所以第一个箭头,从 Kaplan 到蓝线,是参数的计数方式。记住,在 Kaplan 中,我说过,如果他们算入嵌入参数,就会看到奇怪现象,所以他们排除了嵌入参数。这通常可以接受。但他们还做了一件真正搞砸的事:他们也排除了最后一层的参数,也就是 softmax 那些。因为在许多模型中,嵌入层和最后一层线性层互为对偶,形状相同。嵌入矩阵是词表大小乘以隐藏维度,输出矩阵是隐藏维度乘以词表大小。因为它们形状相同,他们说,那就两个都排除吧。结果,算入还是排除这些对缩放定律形状有重大影响。
他们还发现——这更像疏忽,而不是真正的意见分歧——Kaplan 中训练的很多模型非常小。事实上,它们小到学习率预热结束时还没有真正收敛,所以学习率设置得非常次优。因此模型没有完全收敛。最后,他们发现 Kaplan 等人固定了一个大批量大小,而这些大批量对较小模型来说是次优的。因此,如果你正确调整,使用变化的批量大小,你最终会得到和 Chinchilla 完全一致的结果。
所以我认为这些差异每一个看起来都很小,但如果你改变这些真正微小的差异,就能让缩放定律发生大偏移。我想强调的一点是:缩放定律在某种意义上是下界。它们像是在说,如果我继续这个配方并扩大它,我就会得到这个。但如果你扩大的配方本身就不该被扩大,比如预热很奇怪、批量大小很奇怪,你就会得到糟糕的缩放定律。所以你希望尽可能接近正确的完整运行。
还有另一篇论文用另一种非常有趣的方法研究 Kaplan 和 Chinchilla 缩放定律的差异。是 Pearce 和 Song 的工作。他们的做法很聪明:不训练任何模型。他们只取 Chinchilla 训练曲线,或者说 Chinchilla 训练曲线隐含的函数形式。他们画出来,然后说,基于这些完全从 Chinchilla 模拟出的隐含训练曲线,生成如果忽略嵌入参数并大幅缩小规模,Kaplan 的隐含训练曲线会是什么样;如果改用总参数量,又会怎样。他们发现两个效应。
一个效应是 Kaplan 运行在低得多的计算尺度上,所以他们对小变化非常敏感;忽略非嵌入参数是一个足够大的变化,足以让结果相对于 Chinchilla 出错。所以他们的论证和 Yair 等人略有不同:在这里,实际上是计算尺度大小加上非嵌入参数带来的轻微非线性,导致了这个分歧。这真的开始进入细节了。但我希望你能体会到关注细节的重要性。我认为,建立可靠缩放定律的很多工作,就是弄清楚正确的区域是什么,以便你能在计算尺度上得到可靠的缩放。
最后一件事,这更多是我觉得有趣,而不是别的:Chinchilla 这篇论文某种程度上推翻了 Kaplan 论文,像是在说,你们错得很厉害。但还留下一个谜:Chinchilla 方法 3 从未与 Chinchilla 方法 1 和 2 一致。数字看起来很小,作者也很淡定,但我认为影响相当不平凡。如果你看这个,方法 1 和 2 基本上说参数量和数据量应该一起缩放,所以两者之间应该有固定比例。这就是 Chinchilla 因子 20 的来源。如果你看方法 3,指数不同。这意味着如果你扩大计算量,最终你会有比参数量多得多的 token。所以渐近上,它会得出和其他方法非常不同的缩放结论。
结果,我肯定不是唯一一个对此感到不安的人,但 Epoch AI 的一些人不仅不安,还做了点什么。他们拿不到原始数据或代码,所以他们从论文图中提取数据,重新拟合方法 3 的回归。他们从论文中提取数据,给定数据,拟合提出的那个二维或三维曲面。然后他们表明,原论文一定是对原始数据欠拟合了。如果他们重新做曲线拟合,可以得到比 Chinchilla 论文提出的缩放定律低得多的损失。事实上,如果你遵循重新拟合的模型,你几乎正好得到 D/20、每个参数 20 token 的经验法则。这真是一个有趣的结论:作者们比他们自己知道的更正确。实际上他们犯了一个错误,这才是方法 3 与方法 1 和 2 不一致的唯一原因。所以知道这个方法最终还是稳健的,是个不错的结论。
Chinchilla 比例、过训练与 IsoFLOP 的持久价值
好,Chinchilla 传奇某种程度上到此结束。但我会用这一点结束这一节:你大概不想要 Chinchilla 因子。到这个时候,我认为这是相当常识的事情。如果你在训练生产模型,你大多不太关心节省训练计算量。你可以看各种外部调查,看前沿实验室如何花计算量。大部分计算量不是花在训练运行上,而是花在研发和服务上。尤其是服务,你想要小而能干的模型。你不想要又大又臃肿、服务成本很高的模型,即使那最小化训练成本。所以你真正想要的是所谓的“过训练”模型。我加引号是因为,实际上过训练才是我们想要的,那才是正确的训练量。
如果你看早期发布的所有模型,比如 GPT-3,它肯定是训练不足的:每个参数 3 个 token。Chinchilla 达到现代比例:每个参数 20 个 token。然后有一段时间人们都在每个参数 20 个 token。我认为这个时代没有太多模型服务;模型很酷,但没有大规模服务。然后当你开始进入服务变得非常真实的时代,你就会过训练这些模型,进入 MoE,做各种权衡来优化推理服务。所以我认为 Chinchilla 重要,不是因为我认 20:1 是唯一黄金比例——如果你做研究,它可能是——而是因为它告诉我们很多关于应该如何拟合缩放定律、如何思考缩放定律的事情。
最后关于 Chinchilla 和一般缩放,我要说的是 IsoFLOP 作为一种研究工具留存了下来。IsoFLOP 很容易执行:你固定一个 FLOPs 预算,然后扫过所有其他自由度,看曲面长什么样。一般来说,这能给出相当可靠的读数,说明不同参数如何随自由参数变化。人们已经把它用于——实际上我的一位学生把它用于扩散模型。我讲过的 MoE 研究也是 IsoFLOP 式设计。如果你在任何情况下需要决定这些权衡,IsoFLOP 总是一个好的默认选择。
总结
好,总结今天的一切:有一个想法是,我们在投入的资源和得到的性能之间存在对数线性规律。它延伸到各种各样的东西:模型参数、计算量、MoE 稀疏度。这让你能把前面说的很多任意选择,变成更有证据驱动的选择。这让我们能够在大规模上做工程,而不必显式依赖做大的模型训练运行。希望你们记住这里的各个组成部分:数据缩放是非常自然的对象,只是指数有点奇怪;模型缩放让你做各种很酷的工程事情;最后,缩放是一个非常酷、稳健的预测器,用来在大规模上做工程。
谢谢。下一讲我想会是 Percy 讲推理。之后我们会回到更高级、更小众的缩放主题。
推理:从算术强度到 KV Cache 缩减
这堂课讲推理为什么越来越重要、如何用 TTFT/延迟/吞吐量衡量它、Transformer 推理的算术强度分析,以及减少 KV cache、量化、剪枝、推测解码和动态批处理等加速方法。
推理为什么越来越重要
上节课 Tatsu 讲了 scaling laws,这节课我们暂时离开那个话题,来讲推理。推理的问题本身很简单:你已经训练好一个模型,给定一个 prompt(提示),你想要尽可能准确、尽可能快地产生回复。推理只占一讲,但它正变得越来越重要,出现在很多地方。训练完模型之后,你不会只是把它放在那里;如果你在写论文,可以在论文里放一张图说明模型效果如何,但对其他人来说,模型是要拿来用的。
用模型意味着什么?可能是和 AI 助手或聊天机器人对话,可能是做代码补全。现在 agent(智能体)非常流行,而 agent 需要推理。还有批量数据处理,也用它。评估也需要推理,尤其是需要生成的评估。推理还出现在训练内部:如果你做强化学习,你需要有一个模型,生成 rollout,给它们打分,然后相应地更新权重。所以推理到处都是。
效率在这个课上一直是主题,而在推理这里比以往更重要。从实际使用角度看,训练是一次性成本,可以非常昂贵,确实非常昂贵,但训练完之后就结束了。推理是重复发生的成本,你每天都要 incur 这些成本。有估计说 OpenAI 每天产生 8.6 万亿个 token。作为参照,今年早些时候发布的 DeepSeek-V4 是在 32 万亿个 token 上训练的。也就是说,不到四天,OpenAI 必须产生的 token 数量——以及相应的计算量——就至少等于 DeepSeek-V4 的训练量。当然,前沿模型也可能在更多 token 上训练,但推理生成的 token 数量会更高。
而且我认为推理的重要性在过去一年增长了。以前我们把语言模型主要看作聊天机器人或助手:你输入一个 prompt,得到一个回复,人的目标是读这个回复。但现在我们越来越进入 agentic 的世界:一个查询进去,agent 会做一堆事情——它会思考、推理、调用工具、内省,最后才产生一些给人读的输出。所以 agent 产生的 token 大部分其实不是给人读的。你应该把生成的 token 数量真正看作计算支出。这是没有上限的:如果你有一个足够宏大的问题,你就需要更多计算和更多 token。在聊天机器人世界里,推理到某个点之后可能就够快了,因为人阅读速度有限。但对 agent 来说,从推理中榨取更多价值是没有上限的。
商业侧有很多人在做推理。所有闭源 API 提供方都必须服务他们的模型,所以推理对他们来说是大事。还有一些提供方服务开放权重模型,也提供推理。在开源社区里有很多软件包。vLLM 可能是最流行的,是大家默认会用的。SGLang 是另一个,特别适合 agentic 工作负载,但可能还没有那么流行。还有 NVIDIA 的 TensorRT,它非常快,但适用范围更窄。如果你想在 CPU 上跑推理,llama.cpp 是一个流行的包。
我希望已经论证了推理非常巨大。如果你能让推理快一倍,甚至只快 10%,那都是大事。那么问题来了:快是什么意思?
衡量“快”的三个指标
有几个指标可以刻画“快”。它们适用于不同场景,有不同的权衡。一个指标是 time to first token,也就是 TTFT(首 token 时间)。这基本上就是用户在生成开始之前要等多久。你把一个查询输入 ChatGPT,过去一些毫秒,然后第一个 token 出现,从开始到那一刻的时间就是 TTFT。这对交互式应用通常很有用,因为你在等待、什么都不做的那段延迟——它越长,用户体验越差。而一旦 token 开始进来,它们不一定需要那么快,因为如果你要读它,你本来也读不了那么快。
第二个指标是 latency(延迟)。这是从单个用户的角度看:对于一个查询,token 出现得有多快?这对交互式应用也很重要。基本上,这就是 token 流出的速度。
与之相关的第二个概念是 throughput(吞吐量),衡量每秒多少 token。这是从多个查询的角度看,token 出现得有多快。延迟和吞吐量显然非常相关,通常是对齐的。一般来说,很多干预会让延迟和吞吐量同时变好。但正如我们后面会看到的,这里实际上存在权衡。吞吐量在批量处理时很有用:你有一 PB 数据,想用语言模型处理它,你只是想让这个任务完成,不在乎某一个查询是更快、更早还是更晚回来。
训练与推理的根本差异
那么,这些指标所衡量的推理效率,是由什么决定的?高层要点如下,这也是这堂课也许最该记住的一点:训练的时候,你一次看到所有 token,因为做监督微调时你看到所有 token,可以沿序列并行。回想 Transformer 里 attention、MLP 的计算,序列只是一个维度,是一个大张量相乘。所以你本质上是一次处理所有 token。推理时你不能这样做。因为推理是自回归的,你必须一个接一个地顺序生成 token。这就是为什么推理是非常不同的工作负载:你不能沿序列维度并行。因此,正如后面会看到的,推理更难获得高算术强度,也更难充分利用计算。
先预告一下这堂课的内容。首先我会做一些数学,理解 Transformer 的算术强度、吞吐量和延迟,怎么去思考它们。然后我会讲各种降低成本的技术:把 KV cache 变小、量化、模型剪枝,最后讲推测解码,以及一些实际考虑。
符号约定与 Transformer 的张量表示
接下来这部分有点复习性质,主要是统一符号,确保我们在记号上一致。这堂课很多内容基于 Google 的 scaling book 中关于 Transformer 和推理的章节,我推荐大家去看,写得很好。一些图也慷慨地从那本书里借用了。
先建立一点记号,理解这个图。我们用 einops 里那种符号来表示维度,同时也表示长度。B 是 batch 维度,也是序列数量。T 是序列维度,也是 token 数量。D 是模型维度,H 是 head 维度。如果我写一个维度为 B,T 的张量和另一个维度为 D,H 的矩阵相乘,得到 B,T,H。这里有些维度标成红色,表示它们会被收缩(contract)。这些维度同时出现在两个操作数中,并且从结果中消失。还有一些黑色常规维度只出现在一个操作数中,保留在结果里。这是通用形式。另一种情况是蓝色维度,表示它们会广播(attach),同时出现在两个操作数中,但保留在结果里,不被收缩或归约。
解释一下:对每个序列和 token 位置,你有一个向量,想乘以一个矩阵。而这里你基本上是在两个张量之间做一堆点积。
带着这些记号,这个图就是 Transformer block 的完整描述。它看起来有点像电路图,细节很多,但我认为这是对 Transformer 最清晰的定义。你看到很多对 Transformer 的描述,坦白说很多很难理解到底发生了什么。这个图基本上告诉了你张量的精确形状,让你能推理依赖关系。对于 Transformer block,你取 X——某一层的激活——先送进 attention,然后送进 MLP。
快速复习一下:你用 query 矩阵、key 矩阵和 value 矩阵做投影。你会看到这里有 N,是 head 数量,H 是每个 head 的维度。query 矩阵的形状是 batch 乘序列乘 head 数乘 head 维度。K 和 V 那里,不是 N,而是 K,我们后面会看到,这是可能更少的 key-value head 数。然后是 attention 操作,注意这里有 batching 维度:B 同时出现在两边。我们在 head 维度上做收缩。我们后面会回到为什么这个 B 同时出现在两边会让推理变难,为什么 attention 在那里会成为瓶颈。先记住这一点。
MLP 非常简单直接。你做一些矩阵乘法。这是 gating 矩阵,这是上投影,这是一个下投影。谢谢大家,我已经实现过这些,所以不想再赘述,只是把记号统一一下。按照惯例,我们假设 MLP 把 D 维的模型维度上投影到四倍,所以看到 F 就把它想成 4D。模型维度被分到 N 个 head 上,所以模型维度总是等于 head 数乘 head 维度。然后 head 数在 group query attention 的情况下又被分成组数和每组 head 数。最后有两个变量 S 和 T,都表示序列维度。区别是 S 表示输入 token 数,T 表示输出 token 数。训练时这两个相同,因为我们预测的输入和输出一样。推理时 T 会等于 1,S 是输入长度。
算术强度复习:矩阵乘法与 H100 阈值
另一个复习是算术强度(arithmetic intensity)。我在第二讲讲过。先热身:假设我要乘两个矩阵,一个 B×D 矩阵和一个 D×F 矩阵。直觉上,B 是 batch 维度,D 是隐藏维度或模型维度,F 是 MLP 里的投影维度。记住,算术强度是什么?我要数 FLOPs,还要数搬运了多少内存。
我们可以这样做。要把这两个矩阵相乘,记得系统课的内容:你要从 HBM 读 X。如果一切以 bf16 存储——推理就是这种情况——那就是 2×B×D。你还要读 W,又是 2×D×F。然后做 matmul,FLOPs 是 2×B×D×F。然后写回结果。每次读和写基本上就是条目数,有点像二次项,而 matmul 是三次项。记住,这很重要,因为这就是你获得高算术强度的方式。总 FLOPs 是三次项,传输的字节数是读和写的总和。
算术强度就是每传输一个字节做多少计算,我们希望它高。强度等于 FLOPs 除以传输字节数。我用符号表示这些,而不是具体数字,因为这样更容易看清和操作。一个简化是假设 batch 维度远小于 D 和 F,那么可以简化。具体说,这相当于令 D 等于 C 乘 D,F 等于 C 乘 B,让 C 趋于无穷,这样 D 和 F 都很大,而 B 较小,强度就化简为 B。所以结论是,记得我们之前算 matmul 的算术强度大概是 N/3,这里是类比,只不过不是方阵,而是非方阵。
比较一下:硬件的加速器强度,是看 spec sheet 里的每秒 FLOPs,再看内存带宽,即内存在 HBM 之间搬运有多快,相除得到加速器强度。然后你把计算的强度和加速器强度比较。如果计算强度更大,就是 compute bound,这是好的;如果更小,就是 memory bound,这是坏的。在这个例子里,对 H100、对这个 matmul 操作,如果 batch size 大于 295,你就是 compute bound。
这里有个极端情况。假设你只有一个样本。那么你的算术强度就是 1。这会是 memory bound。你能看到发生了什么:你在读这个 D×F 矩阵,但 B 只有 1。所以你实际上只做了 2×D×F 次 FLOPs。而这基本上就是你在推理中会看到的工作负载。你不会得到完整的矩阵,你得到的是非常薄的矩阵或张量。
有同学对 Transformer 图里 K 和 g 的含义提出疑问,老师确认:K 应该是组数,g 是每个组里的 head 数,并说之后会改。
推理的算术强度:朴素生成与 KV cache
我们再谈谈推理的算术强度。先看朴素做法。假设有一个 prompt,我把它送进 Transformer。Transformer 生成 keys、values 和激活,最后生成输出词表上的 logits。你采样一个 token,把这个 token 和 prompt 拼接起来,然后再做一遍。你生成一个新 token,把它附到 prompt 上,如此反复。这是最朴素的做法。如果你有一个黑盒,它接收序列并输出 token 分布——这正是 Transformer 做的事——你就可以反复应用它。
这样做能工作,但非常糟糕。因为每次生成一个 token,你实际上要花大约 T² 的时间,其中 T 是到目前为止已经生成的 token 数。所以生成 T 个 token 实际上是 T³,因为 attention 已经是 T²,而你要对每个 token 做一次。这很糟糕。但观察是:你其实不必这样做。很多工作可以在不同前缀之间共享。比如你在生成 “network” 和生成 “up” 时,实际上为 “never going to give” 计算了很多相同的 key、value。这些 token 不应该改变。
这是因为它是因果 Transformer。如果是双向的,那么附加一个 token 后所有东西都会变。但如果是因果的,这里的激活不会因为你附加了什么 token 而改变。有了这个观察,第一件显而易见的事就是把 KV cache 存在 HBM 里,这样在连续生成 token 之间可以复用 KV cache。这意味着当你生成一个 token 时,你其实不必重新计算之前所有 token 的 keys 和激活。
带 KV cache 的流程是这样的。有两个阶段:prefill(预填充)。你拿到 prompt,填充 KV cache——也就是 Transformer 计算出的 key-value 对——然后生成 logits,和之前一样的计算。现在你有了 KV cache,以及一个分布,从中采样一个 token。然后把这个 token 送进 Transformer,它使用这个 cache,产生下一个 token 的分布,同时产生新的 KV,基本上是对应这个新 token 的激活。然后这个被加进 KV cache。于是你有了更新后的 KV cache,从这个 logits 采样,再送进 Transformer,输出下一个 token 的分布以及新 token,加入 KV cache,如此反复。
KV cache 的公式是:对每个序列——有 B 个;对每个 token——有 S 个;对每一层和每个 head,你存一个 H 维向量。再强调一下,推理有 prefill:给定 prompt,你把 prompt 编码成这些向量。这一步可以像训练一样并行,因为你看到整个 prompt,可以计算 KV cache。然后在 generation 阶段,你顺序生成新的回复 token。但至少你不必为已经看过的 token 再付生成 KV cache 的代价。
MLP 与 Attention 的 FLOPs 和内存 I/O
接下来算 MLP 和 attention 层的 FLOPs 和内存 I/O。记住 S 是我们作为条件所依赖的 token 数,T 是我们要生成的 token 数。我们先抽象地算,之后再具体到 prefill(T 等于 S)和 generation(T 等于 1)。对于 MLP 层,整个过程我只看 matmul,因为 matmul 才是真正需要大量工作的地方。其他东西 FLOPs 不多,而且也可能被融合进 matmul。
这个计算和矩阵计算一样。我不逐步讲每个细节,形式是:你从高带宽内存读 X,读所有参数,计算上投影,写到 HBM;计算 gate,写到 HBM;然后计算下投影,写到 HBM。FLOPs 取决于 B、T、D、F,即 batch size、序列长度、模型维度和前馈维度(MLP 维度)。传输字节数是这个表达式。然后你可以算算术强度,也就是 FLOPs 除以传输字节数。
我们假设和之前一样,B×T 远小于 D 和 F。这样强度就是 B×T。这和单纯的 matmul 情况类似:矩阵更多、维度更多,但本质相同。这也合理,因为 MLP 基本上就是一个大 matmul。而且 batch 维度和序列长度维度是独立的,对 MLP 来说它们不交互。所以只要在 prefill 中让 B×T 足够大——大 batch、长序列——就没问题。
现在看 generation。generation 有两个问题。一是 T 等于 1,你一次只生成一个 token。这意味着你的算术强度是 B,而 generation 中的 B 是并发请求数。如果你在批处理场景下,可以控制它。但如果你在服务聊天机器人,并发请求数基本上就是同时有多少用户,可能高也可能低,不太可预测,还会随时间变化。这是我们后面讲连续批处理要解决的问题。总体来说这不算太糟,因为只要 batch 够大,序列长度并不会帮你,但大 batch 就没问题。
现在看 attention 层。S 是之前已经生成的 token,T 是你要生成 logits 的 token 数。在 attention 中,你从 HBM 读 QKV 矩阵,计算 attention,计算 softmax(这不太重要),计算 value 矩阵,然后把结果写到 HBM。FLOPs 是 B×S×T×D,传输字节数是这个表达式。一切都是 matmul。所以 FLOPs 应该总是比传输字节数高一阶多项式,因为它是 matmul。唯一的问题是那个因子长什么样。对 attention,这个因子是 S×T/(S+T)。
看 prefill。prefill 意味着当 T 等于 S 时,强度是 S/2。这很好,因为只要 attention 的序列长度够长,你就能维持高算术强度。注意这里没有出现 batch 维度,我后面会解释为什么。但对 generation,这是坏消息。generation 的强度是 S/(S+1),小于 1,或者就把它叫做 1。记住,算术强度为 1 很糟糕。我们希望它达到 H100 的 295 左右才能打满计算。所以这真的是瓶颈。
我们做了 MLP、attention、prefill、generation 这些分析,发现 attention generation 是一个瓶颈。为什么?和 MLP 不同——MLP 在 generation 时其实还好,只要 batch 够大。问题在于:每个序列都命中相同的 MLP 权重,这些权重不依赖 B。而在 attention 层,每个序列有自己的 KV cache,这些都依赖 B。你可以这样想:在 MLP 情况下,B 大实际上有帮助,因为你只需加载一次 MLP 权重,然后用于所有序列。这就是高算术强度的来源——简化地说,加载一次,做所有批处理,这很好。而在 attention 中,这些都依赖 B,所以增大 B 没有帮助。对每个序列,你基本上都在做一个 matmul,它们都是独立的。做更多 matmul 并没有帮助。
如果你记得最开头我展示的例子,那个算术强度也很差。它不是 matmul,因为我们本质上是在按一个坐标做 batching。这基本上和做点积一样,而点积的算术强度非常糟糕。记住,在 attention 中,这个蓝色的 B 就是 attention 算术强度不随 B 增长的原因,也是它成为瓶颈的原因。总结一下:prefill 是 compute-bound,generation 是 memory-bound。prefill 的 MLP 强度是 B×S,很好;prefill 的 attention 强度是 S/2,没那么好但可用;generation 的 MLP 强度也可用,但需要大量并发请求。真正根本的瓶颈是 generation 的 attention 强度。如果你坚持用 Transformer,你没法真正改善这一点。
用 Llama 2 13B 算延迟、吞吐与 Batch Size 权衡
所以以后当人们说“推理是 memory bound”时,你就知道为什么了。
现在用这些直觉和计算来思考推理指标:吞吐量、延迟,还有 TTFT。推理是 memory bound。某种程度上这简化了很多事情,因为当我们思考某件事要花多久时,你只需要看需要传输多少内存。假设你重叠了通信和计算,瓶颈就只是你要处理的内存量。这有好处,因为它更简单。但另一方面也令人沮丧:你的加速器坐在那里什么都不做。
我们走一个例子。Llama 2 13B 在 H100 上。它的延迟和吞吐量是多少?记住 Llama 2 13B 有特定形状:序列长度、模型维度、前馈维度、query head 数、key-value head 数。这里没有 GQA,所以 N 等于 K,还有 head 维度、层数、词表大小,以及 H100 的内存带宽。
用这个配置,我们来计算 Transformer 的性能统计量。这些是输入。我要计算的统计量是:参数数量、内存使用、延迟和吞吐量。首先,什么占内存?参数占内存。我们计算参数数量。你看 embedding、MLP 层、KQV 投影。最后得到某个参数数量。假设我们用 bf16——推理总是这样——那么参数占这么多字节。
内存里还有 KV cache。KV cache 是序列中的 token 数乘 head 数和 KV head 数,乘 head 维度,乘层数。key 有一个,value 有一个,然后 bf16 还要乘 2。这就是 KV cache 的大小。总内存使用是——对每个序列,有 B 个序列,所以是 B 乘那个,加上参数大小。这就是你需要的内存量。
那延迟呢?延迟由内存 I/O 决定。因为推理是 memory bound,假设你重叠了通信和计算,所有内存基本都花在 HBM 和 SRAM 之间来回搬运参数上。所以耗时就是搬运内存的时间。吞吐量是延迟的倒数,但我们是并行生成 B 个 token。所以这是每秒 token 数。这是每个 token 的秒数,这是每秒 token 数,并且还要乘 B,因为你处理的是 batch size 为 B 的一批。
现在对这个配置计算实际值。参数数量是 130 亿。这是一个很好的 sanity check——它被宣传为 130 亿参数模型。内存是这一项,大约是 8.38 亿乘 B。所以它基本上是 B 的线性函数加上另一项。这是 KV cache,随 B 增长;这是参数,是参数数量的两倍。延迟只是这个乘以内存带宽,所以形式和内存一样。吞吐量是 B 除以那个。注意,当 B 增加时,延迟增加,因为 KV cache 增长。为了处理数据,你必须把 KV cache 来回拷贝。吞吐量更有意思:随着 B 增加,吞吐量确实改善,但有上限。吞吐量改善是因为你把成本摊到更大的 batch 上。但你处理的速度显然也在增加。所以它会渐近。吞吐量不可能趋向无穷。
关于这部分有什么问题吗?基本上,你只要记住延迟是 B 的线性函数,常数项是 KV cache 大小和参数数量。吞吐量正比于 B 除以 B 加某个东西。
现在把几种情况实例化。如果 batch size 为 1,你会得到:延迟是每 token 0.008 秒,吞吐量是每秒 124 个 token。如果你增大 batch size,延迟会上升,但吞吐量也会上升。所以延迟变差,但吞吐量改善。这很有意思,因为我们想“让它变快”,但“快”在这里有两个含义,取决于你在意哪个,它们完全相反。如果你想调 batch size,它会真正决定你想要延迟还是吞吐量。
如果我们真的想要高吞吐量,因为要处理很多文档,那就继续增大 batch size。延迟变得更差,吞吐量变得更好。但主要问题是你的内存会不够,因为存储 KV cache 的内存会超过 H100 的内存。如果你有 B200,可以把 batch size 增得更大,但最终会撞到某个限制。所以你能提升的吞吐量有限。你永远不会到达渐近线,因为你会先撞到内存。而且吞吐量的收益也在递减。
增大 batch size 会恶化延迟,因为现在有更大的 KV cache 要读写。记住,它是批处理的。如果你是一个单独的查询,你必须等所有人结束。所以你基本上在等一辆公交车,延迟很高。你等待,然后走。而公交车的吞吐量很好,因为可以一次运所有人。吞吐量随 batch size 增加而改善,因为参数是共享的,你加载一次到内存里,然后可以处理很多序列。延迟和吞吐量之间的权衡是:较小的 batch size 有更好的延迟但更差的吞吐量;较大的 batch size 有更好的吞吐量但更差的延迟。
我不打算多讲并行化。推理还有另一个维度:你可以把模型分片到多个设备上。想了解更多可以看 scaling book 的推理章节。举个很简单的例子:如果你启动 M 份模型副本,延迟不变,吞吐量增加 M 倍。还有一个我们没有谈的指标是 time to first token。这基本上是做 prefill 所需的时间,因为 prefill 结束后你才能开始生成。如果你想要更快的 TTFT,应该用更小的 batch size;而你想要更大的 batch size 来提升吞吐量。
减少 KV Cache:GQA 与 MLA
现在我们有了概念框架,可以用算术强度、吞吐量、延迟来思考推理效率。接下来试着让它更快。怎么让推理更快?有很多不同的技术,从改变模型架构到系统优化,介于两者之间。推理在某种意义上是相当丰富的跨领域主题。
hindsight 来看最明显的一件事——希望你们已经牢记:内存是推理的瓶颈,而 KV cache 占很多内存。在足够大的 batch size 下,它甚至可能比参数数量还大。所以我们来减小 KV cache 的大小。但你必须小心,不能在这个过程中损失太多准确率。
有一件事你已经可以做,我们之前谈过,就是 grouped query attention(GQA,分组查询注意力)。提醒一下:多头注意力基本上对每个 token 都有一个 key、一个 value 和一个 query。如果你做 grouped query attention,你计算的 query 数量相同,但只有更少数量的组,每组计算 key 和 value。所以 K 是组数。在多头注意力中 K 等于 N,没有减少。还有一种叫 multi query attention(MQA),没人用,因为效果很差,K 等于 1。介于两者之间的某个地方,希望能找到准确率和速度的平衡。
这是 2023 年提出 GQA 的论文。他们展示,如果看每个样本的时间——这与延迟和吞吐量都相关——你会看到 MHA(Multi-Head Attention,多头注意力)完整注意力的时间很高。而如果 K 等于 1,就快得多。然后你可以继续增大 K 到 8,效果仍然不错。最终时间会上升不少。
为什么 GQA 能改善延迟和吞吐量?因为它把 KV cache 减少了 N/K 倍。友好地提醒一下,减少内存使用会带来加速,因为我们是 memory bound。我们回到熟悉的 Llama 模型。初始配置里我们用 K 等于 N,也就是多头注意力,没有减少 key 和 value 的数量。对这个配置,记住用 batch size 64,我们得到这个吞吐量和延迟。现在如果你用 GQA,比如放入稀疏度为 1/5,这大幅减少了内存,进而改善延迟,也提高吞吐量。所以延迟和吞吐量并不总是互相矛盾。如果你减少内存量,它会同时改善两者。这主要是 batch 维度允许的。那是紧张的焦点。
这很好。我们实际上把 batch size 增加到更大看看。之前如果 batch size 为 256,我们会内存不足。但现在它能装进内存了。我们看到延迟受到一些影响,因为我们增加了 batch size,但吞吐量成比例上升。所以有时你要联合调整这些参数。你可以减少 KV cache,但那允许你增加 batch size,并允许做出其他权衡。最后你必须做的是,每当你做一些有损的改变时,你要确保准确率不会下降。这篇论文表明,对于 GQA,时间更好,但在一系列评估中基本上运行良好。现在有了这些准确率值,我认为你总是要持保留态度,因为这是针对特定模型的。后来 DeepSeek 论文表明它实际上确实会有损害。所以我想,把所有不只是数学的东西都持保留态度吧。
说到 DeepSeek,这里有另一个减少 KV cache 的想法。主题是减少 KV cache,延迟和吞吐量就会改善。这是多头注意力,每个 token 的 query、key、value 数量相同。GQA 中,我们减少了每个 token 的 key 和 value 数量——抱歉,不是每个 token,我们基本上减少了 key 和 value 的数量。现在 DeepSeek 的 multi-latent attention(MLA,多潜在注意力)说,我们实际上会让每个 token 的 key 和 value 数量保持不变,但我会把它们参数化、压缩。
通常你怎么计算 key 和 value?你有激活,乘以某个矩阵得到 K,乘以另一个矩阵得到 V。这些通常是 N×H 维,像模型维度那么大。MLA 说,我要把这些激活投影到 C 维。DeepSeek-V2 把它从 16000 降到 512,所以是相当激进的压缩。然后我从这个压缩表示计算 K 和 V。现在我可以只存 C,这要小得多。当我需要 key 和 value 时,再物化出来。
这里有一个小问题:MLA 与 rope(旋转位置编码)不兼容,因为 rope 直接作用在 key 和 value 上。所以他们增加了额外维度来处理 rope。但或多或少,它仍然是一个很大的缩减。延迟和吞吐量的改善就是简单的数学:KV cache 越小,速度越快。几乎接近线性,直到某个点。然后记住,你需要检查模型是否准确。
首先,这个结果与 GQA 论文有些矛盾或有张力。他们展示 GQA 其实并没有那么好。这是 MHA,这是 GQA,这些数字比这些数字小。但他们展示他们的方法 MLA 甚至比 MHA 略好一点。我们就说它差不多吧。所以这一列和这一列比另一边好很多——我想这个表里没有 GQA,你得在那边比较。
有同学问:这和减小模型维度相比如何?这是个好问题。这些消融没有展示这一点。我的猜测是,减小模型维度只会让事情更糟,因为你在不加区分地削减一切。我认为所有这些技巧的诀窍是找到模型中可以压缩的地方。这个先验,我不认为你能确切知道。你必须做一堆实验,看什么有效。
跨层注意力、滑动窗口与混合模型
这里还有一个减少 KV cache 的想法,叫 cross-layer attention(跨层注意力)。通常每一层都有 KV、K 和 V。但假设我们不这样做,只为一小部分层计算 KV。然后对于这一层,我直接使用之前层的 KV cache。这是另一种共享方式。就像 GQA 在 head 之间共享 K,现在我在层之间共享 KV。经验上,这篇论文表明这样做改善了 Pareto 前沿。这些模型每一个都更好——给定一种方法,你总是可以通过改变 K 和 head 维度来调整 KV cache 大小,我猜这有点像你提到的改变模型维度的观点。但如果你做这种 CLA 跨层注意力,它会更好。
继续快速浏览不同的减少 KV cache 的技术。有局部或滑动窗口注意力(sliding window attention)。这是一个相当古老的想法,也非常自然。如果你看完整的注意力矩阵,它是 N 的平方。而不是那样做,如果你要生成一个 token,你只看最后 K 个 token。所以你本质上有一个滑动窗口。对于你生成的每个 token,你只依赖最后 K 个。如果你这样做,有效的 KV cache 现在与序列长度无关。它只是 batch size 乘其他变量,这很棒。这对长上下文尤其棒。现在,由于层数,有效上下文长度实际上大于所声明的上下文长度,因为如果你沿着层向下走,信息可以传播得更远。
你可以做更花哨的事情。你可以不做密集的层选择,但可以把它间隔开。你也可以做全局加滑动窗口,其中你对固定的不同 token 点网格有注意力,加上一个局部滑动窗口。所以你可以做各种事情。问题是这实际上仍然损害准确性。所以这降低了表达能力。这里没有免费的午餐,或者至少这是一顿昂贵的午餐。
人们想出的解决方案是把局部注意力与全局注意力交错。这些混合模型对某些层有完整注意力,对其他一些层有局部注意力。你基本上总是在试图——这样你可以减少一点 KV cache。你总是在准确率和速度之间平衡。
有同学问:混合模型中,使用线性时不变(LTI)和滑动窗口的权衡有什么区别?老师回答:我不打算讲线性注意力。但很快地说,有一堆方法,不是存 KV cache,而是基本上计算所有历史的某种压缩表示。线性注意力最朴素的形式就是把 KV 值加总成一个向量。那肯定与序列长度无关。你可以做更花哨的,比如 gated net、delta net 和 Mamba,它们试图压缩但不要忘记太多。问题是它们相比如何?它们也被用来替代滑动窗口注意力,人们得到了不错的结果。你也可以组合完整注意力、滑动窗口注意力和线性注意力,因为它们捕捉不同方面。如果你关心局部高分辨率的东西,滑动注意力更好。如果你只想要过去的宽泛摘要,其他线性注意力可能更好。
有同学问:对长上下文句子,线性注意力会是更好的设置吗?老师回答:没有免费午餐。假设你有很长的上下文,在解决大海捞针问题。如果你必须把整个历史压缩到一个小上下文里,你就会丢失信息,可能根本无法检索到。有同学继续问:似乎人们总是用混合架构,总需要一些长注意力,但我想理解滑动窗口和 Mamba、delta net 层之间的权衡。用 delta net 层总是比滑动窗口更好吗?实际的代表性权衡是什么?老师回答:也许我会说,Mamba 和 delta net 比滑动窗口更强大。你可以把 Mamba 看作可以表示滑动窗口注意力的某些方面,因为在做递归时它可以看最后的状态。所以,也许你可以把线性注意力或其扩展看作更好。它们有更多空间。一旦你做滑动窗口注意力,你就被限制住了,没有别的可做。
量化、剪枝与蒸馏
我快速过一下这个。强调一下 DeepSeek。DeepSeek 继续创新不同类型的注意力机制。记住,他们提出了 multi-latent attention,压缩 key 和 value。现在他们有 compressed sparse attention、DeepSeek sparse attention,以及 heavily compressed attention。我从来记不住所有这些缩写和它们的含义,但让我们看这个图。通常你有 KV token 和 query token。压缩注意力基本上把每 M 个 token 合并成一个 token。然后还有 DeepSeek 稀疏注意力,它基本上是选择其中的一个子集来保留。选择子集的方式是你实际上计算一些 LiDAR 权重查询和键,然后你做一次更小的注意力来得到这些索引分数,这样你就知道要保留什么。所以一个闪电般快速的方法来找出你需要保留哪些 token。然后你使用那些。然后还有一些更多的压缩发生。
为了节省时间,我继续往下讲。本节的目标是减少 KV cache,因为 KV cache 与内存相关。我们看到推理是内存受限的。所以这直接转化为吞吐量和延迟的改进。关键在于做到这一点而不损害准确率。所以你可以跨层做更低维度的 KV cache,跨头和跨 head 维度。你可以做局部注意力。你可以做线性注意力,之前讨论过,还有更多。还有扩散模型,这是一种非自回归的生成方式,可以快得多。
量化更像是一种系统视角,而不是架构,关于如何让东西更小。关键思想就是降低数字的精度。更少的内存意味着更高的延迟和吞吐量——抱歉,更少的延迟和更高的吞吐量。显然,你必须担心准确率。量化这里有很多选项,从 bf16 一直到 int4。
你可以做的一件事,如果你担心量化会搞砸你,就是训练模型时考虑量化。这是 quantization-aware training(QAT,量化感知训练)。训练时的前向传播过程中,你进行量化和反量化,基本上在训练时模拟这些量化误差。所以一般来说,现在权重会朝着量化方向适配,效果会更好。但缺点是它需要昂贵的大规模训练。所以通常人们的做法是先训练模型,然后再事后量化。
这就是 post-training quantization(PTQ,训练后量化)。它通常便宜得多。有一种朴素做法:对每一层或张量,你基本上为每个张量确定缩放因子和零点,然后分别量化它。这通常效果不太好。你可以使用一种叫 GPTQ 的方法,它利用一些 Hessian 信息来逐层量化,逐层进行。然后你跟踪那些传播到未量化权重中的误差,这样就能让你校正这些误差。
而 activation-aware weight quantization(AWQ,激活感知量化)是一种更复杂的方法,其观察是某些激活通道很大,那些与这些通道交互的权重更重要。所以让我们给这些权重分配更多精度。我们看这张图。通常,如果你取一个 fp16 权重矩阵,然后量化它,比如量化到 int3。但你要做的是找出哪些是活跃的。这些可能是激活通道。其中一些激活通道很大。所以如果它们总体上很大,那么你基本上为这个通道分配,比如说,fp16。其余的都保持为三。对于少数重要通道,你使用更高精度。
另一个想法是做模型剪枝(model pruning)。你拿一个大模型,把其中一些部分撕掉,然后修复它。这很粗暴,但事实证明有效。这是 NVIDIA 的一篇论文。你首先必须估计模型不同部分的重要性,选择最重要的部分。然后你基本上移除不同的隐藏单元,甚至不同的层。现在你得到一个模型,它不会很好。所以你要做的是后训练它。你在你关心的数据或任务上再训练它,来愈合它。所以这在某种意义上是减少 KV cache 的训练方法,但你是用好的模型的一部分来初始化它。
这似乎效果很好。他们能把一个 15B 模型减少到 8B 模型,而且准确率没有下降太多。用于训练模型或经过这个过程的量要少得多。总结一下,游戏是减少推理复杂度而不损害准确率。你可以把这看作主要是减少参数数量或 KV cache。你可以定义一个更快的模型架构来训练它,或者定义一个更快的模型架构,从原始模型初始化权重——原始模型可能有不同架构,你只是做出这个 Frankenstein 一样的东西,然后用蒸馏修复这个更快的模型。
有同学问:你怎么区分重要层和不重要层?老师回答:一般来说,你有一个校准集,把输入通过模型。你基本上看激活的幅度。其中一些,特别是如果它们是死单元,会接近 0。而大的那些,你想保留。这是高层想法。有同学继续问:为什么激活高就重要?它可能只是总是很高?如果所有激活都高呢?老师回答:一般来说,这是一个经验观察:某些通道会比其他高很多。如果这不是真的,这些技术就不一定有效,但事实如此,因为这些模型最终就是这么训练出来的。然后你可以利用这一点。
有同学问:假设一个神经元在所有样本上总是值 100,这一定意味着它有意义吗?还是只是训练的伪影?老师回答:如果它总是 100,你不能直接移除它,因为那样一切都会坏掉。如果它均值高、方差低,也许有另一种方法,基本上把偏置并进去。
推测解码
让我快速过另一个想法。到目前为止,我们看的都是有损方法,它们确实压缩了 KV cache,但可能损害准确率。这是一个非常优雅的无损方法,叫 speculative sampling(推测采样)或 speculative decoding(推测解码)。记住,如果你做 prefill,你可以并行编码所有 token。这也会给出概率。这很快,是 compute-bound,各种好处。而在 generation 中,一次一个。所以检查比生成快。如果我给你一个序列,你告诉我它有多好是很快的,比一次一个生成快得多。你可以利用这种不对称性,用下面的想法。
我们要用一个便宜的 draft model(草稿模型)来从猜测中生成几个 token,比如四个。然后我们用真正关心的模型——目标模型 Q——来审查这些 token,接受或不接受。事情被设计成平衡的。draft 模型更小更便宜,所以即使它是 memory bound、必须一次生成一个,也不会太糟。而目标模型又大又贵,但我们让它并行处理一批 token,所以它也不会太糟。
这里有一个视频展示它如何工作。如果你用大模型一个 token 一个 token 地生成,会相当慢。但如果你做推测解码,你可以看到小模型生成一堆 token,然后大模型基本上批判它们。然后你基本上得到一串 token 的爆发,然后可能又一串 token,如此反复。
这是推测解码的算法。有几篇论文差不多同时提出。这是其中一篇。想法是:为了生成,我们先从 draft 模型生成 K 个 token。p 是 draft 模型,我们采样 K 个 token。然后并行地用 q 计算这些 draft token 的 logits。然后我们必须决定接受还是不接受。这里你要做一点数学、概率和统计。你基本上以概率 min(1, q/p) 接受。所以如果 q 比 p 大得多,q 越大,你越可能想接受它。否则你从残差分布中采样并退出。所以这基本上是拒绝采样,但拒绝采样有时拒绝后什么都得不到。而这里我们总是保证得到目标模型的精确样本。我会跳过这个简单证明。它基本上和拒绝采样的论证一样,证明它给出目标模型的精确概率。
最初的论文表明这很快。一般来说,如果 draft token 太少,你没有真正利用目标模型侧的批处理。如果太多,你就会更频繁地拒绝。所以有一个甜点,在这个例子里大约是 3 或 4。一般来说,draft 模型比目标模型小得多。理想情况下,你希望 draft 模型尽可能接近目标,这意味着你想蒸馏它。这意味着我们刚才谈的很多想法也适用于推测解码。基本上,想法是:让我们通过各种手段减少 KV cache。如果你最终得到一个满意的模型,就服务它。如果你不满意,它至少可以当 draft 模型,你可以用主模型来修正。现在有一大堆关于推测解码的文献,改进原始方法,我暂时跳过。
动态工作负载:连续批处理、选择性批处理与 PagedAttention
现在很快讲动态工作负载。用例是你服务一个实时网站,用户来和你的模型聊天。请求在不同时间到达。它们有不同的共享前缀,也有不同的长度。所以很乱。它远不像训练那样,你有一批相同 token 数的块,一次全来。这种情况你怎么办?
有一个系统叫 Orca,它很早就引入了 continuous batching(连续批处理)的想法。想法是你收到一堆请求,看起来像这样:这是第一个请求的前缀,你生成这个 token;这是第二个;这是第三个、第四个。它是锯齿状的,因为每个前缀长度不同。我们要做的是逐步解码。每一步你为所有序列解码一个 token。下一步,你为所有序列再解码一个 token。然后如果某个序列结束了,就把它弹出。随着新请求到达,你把它放进 batch,然后继续。这就是为什么叫连续批处理,因为这个 batch 被动态更新,旧的已完成序列被驱逐,新的进来。
现在有一个问题:我们看到的批处理都要求所有序列维度相同。你有张量,每个切片维度相同。但这里请求长度不同,怎么办?有一个想法叫 selective batching(选择性批处理)。假设你有长度 3、长度 9 和长度 5。在 attention 计算中,你无法真正做什么,因为 attention 依赖序列长度。如果你有 3×3、9×9 的计算,你无法有效共享张量。但对于非 attention 的部分,MLP 层,它们占很多 FLOPs,你可以把所有序列拼接在一起,形成一个 mega sequence,然后处理它。
最后一个想法是 paged attention(分页注意力)。这是 PagedAttention 论文提出的。当然,PagedAttention 现在还有很多其他功能,但这是当时的核心想法。问题是 KV cache 怎么存?如果请求进来,你必须把它们放在内存某个地方。一般来说,会有碎片化问题。这就像你的硬盘以前会发生的事情。以前你得给硬盘做碎片整理。有两种碎片。一种是内部碎片:你必须分配足够的缓冲区,因为你不知道什么时候停止。你可能最大 token 限制是 1024,所以你必须分配所有这些内存。但你不能在里面放别的东西,因为你会一直生成到最大 token,这非常浪费。这就是内部碎片。还有外部碎片:不同请求之间可能有空间,那个空间可能太小,无法有效利用,所以只是浪费空间。
解决方案是:这些是系统人,他们懂操作系统。他们说,这个问题我们以前解决过,让我们用同样的想法。我们把一个序列的 KV cache 分成非连续的块。比如你有序列 “four score and seven years ago our fathers brought forth”,我们把它切成大小为 4 的块。块放在哪里不重要,但它们按块对齐。所以有一定的一致性。当两个请求共享相同前缀时,它们实际上可以共享相同的 KV cache。
你可能有一个块和另一个块。这个块可能放在这里和这里,那个块可能在那边。它们是散布的。但只要你掌握索引,追踪每个东西在哪里,就没问题。特别是如果你有 system prompt,你可以把 system prompt 的 KV cache 缓存一次。这对所有查询都有用。这非常有用,因为如果很多人使用相同的 system prompt,你就不必为每个请求计算 cache。还有很多应用有相同的 prompt,你实际上想生成多个回复。那样你也可以共享 prompt 的 KV cache,只让不同的回复产生。
举个例子,假设你要从 “score and seven years ago are, blank” 生成多个续写。这里会发生的是,你会有 “score and seven”,然后开始有 “years ago or our”。这叫 copy on write(写时复制)语义。你保留这个,然后有两个样本。如果它们碰巧采样到同一个 token,你就继续。但如果它们采样到不同 token,你就拆开这个块,然后在各自那里继续。所以你基本上尽可能共享前缀 cache。
还有一些其他优化,比如 kernels,我没时间讲了。但总体想法是,你使用这些操作系统隐喻来管理你的推理。
总结
总结一下:推理真的非常重要。它和训练非常不同。尽管是同一个模型,但你要求模型做非常不同的事,结果是非常 memory bound。如果你在实时聊天机器人用例中,它还是动态的。我们看到了各种改善推理的技术。你可以量化,可以提出新架构,可以剪枝和蒸馏,也可以用推测采样。但所有这些都由同一个原则驱动:减少 KV cache,但不要损害太多准确率。然后还有来自系统的想法,比如分页和推测执行,可以用于实际的实时推理服务器。
有一件事我们没真正来得及谈,只是简单提过:我认为新架构实际上有巨大改进潜力,比如状态空间模型、线性注意力或扩散。在某种程度上,KV cache 和 attention 的构建方式从根本上使它成为一种对推理不友好的架构。所以如果你能提出一种为推理设计的新架构,而 Transformer 不是这样设计的,这可能会解锁很多东西。我在这里停下。下节课 Tatsu 会回来讲 scaling laws 第二部分。
从 Chinchilla 到前沿开源模型:scaling law 的实践、超参数与优化器
这堂课讲如何把 scaling law 用到真实模型训练中,涵盖 MiniCPM、DeepSeek 等开源模型的 scaling 经验,学习率、batch size、优化器和 muP 初始化。
从经典 scaling law 到前沿开源模型
我们继续 scaling 之旅。今天内容有点杂,主要讲 scaling laws 更高级的细节,以及实践中如何扩大语言模型。我们会问:两讲前讲的 scaling law 东西,在真实构建开源模型规模上真的有效吗?如果能优化其中一些就好了。很多学习率技巧和其他做法,你们作为了解如何构建语言模型的人都应该知道。我们会讲这些优化细节。最后还要讲,扩大规模时有些东西更难调;优化器和优化器行为对 scale 相当敏感。因此初始化、学习率、batch size 都需要作为 scale 的函数仔细考虑。我们会讲如何仔细思考这些。
到目前为止,我们讨论的大多是我所谓的经典 scaling law 正典:Kaplan、Hestness,以及 Chinchilla 论文。我们的认知大概停留在 2022 年。自那以后,训练大模型的人发表了不少 scaling 论文;不过近年来这类论文数量减少了,而且大多来自中国开源社区。今天我想讲几篇我挑选出来的论文,让你们感受一下前沿、至少是开源前沿的 scaling 是什么样:这些人关心或担心什么,以及要从 Chinchilla 一路速通到比如 Kimi K2 这样最近发布了 scaling 细节的模型,需要做什么。这是 lecture 的第一部分。
我们会过几篇论文的细节。第二部分讨论优化器和初始化。记住,我两讲前花了相当多时间讲 batch size 选择,那是个重要因素;思考如何调学习率也很重要。这些非常敏感,调对非常关键。事实上人们投入了大量努力把这些搞对,我们会讲一些技术。这些甚至可能对你们日常训练各种语言模型有用,因为会让你们了解在扩大或缩小规模时如何改变超参数和实验。
我会特别花不少时间讲两篇论文。它们不是最新的,也不一定是最——其实 DeepSeek 那篇可能是最详细的 scaling 论文之一。但它们处在一个 sweet spot:两篇都是来自构建相当高性能模型的人所做的严肃 scaling 研究,而且重要的是,它们对今天要问的一些关键问题采取了很不同的方法。所以它们能很好地引出 lecture 的其余部分。第一篇是 MiniCPM,2024 年的高性能小语言模型;第二篇是 DeepSeek,你们现在都很熟悉了,我不用再解释为什么讲它。我们先从 MiniCPM 开始。我很喜欢这篇论文,尤其刚出来时,感觉很新鲜、前沿。即使到 2026 年,CPM 团队做的很多事情对我们仍有启发。
但后来我也注意到,很多经典 scaling 东西——如何设学习率、如何做 Chinchilla——现在被当作理所当然,每个人都假设你会做,所以论文里不再怎么谈。这也是讲这些旧论文的好处。MiniCPM 是一群产业界和学术界合作者在中国开源社区做的高性能、偏小的语言模型。当时它基本上是 1 到 2 亿参数区间的 SOTA。现在 Gemma 和其他 2B、3B 模型性能更高,但按 2024 年标准,这些是很有竞争力的训练良好的模型。
MiniCPM:muP、batch size 与 WSD 学习率
怎么训练这些模型?如之前所说,必须把一些 scaling 参数搞对。今天我会反复强调的一点是:初始化非常重要。初始化重要,是因为它们能让某些超参数不那么依赖 scale。MiniCPM 论文里第一个技术是一类特殊初始化,叫 muP(Maximal Update Parametrization,最大更新参数化)。muP 的整个要点是:我想确保最优学习率在放大和缩小时保持不变。我两讲前简单提过,你们应该还记得一点。这是我们要讲的第一个真正实例。具体是什么样?MiniCPM 论文有一个很好的清单:他们缩放 embedding 输出;用层数的平方根缩放残差连接;用 fan-in、fan-out 比缩放所有矩阵形状张量的初始化;缩放张量的学习率——你们要注意这一条,因为如果你不熟悉 per-parameter 学习率,这很 exotic;他们还缩放 LM head。我们会讲为什么做这些,或者如何推导这组缩放。总之,你会稍微改变初始化和学习率,希望稳定学习率。
当然,这里训练的是小模型,但仍然想要好的 scaling 策略。这项研究的一部分要点不是暴力训练一个 1.5B 模型、比如训几百个,而是想第一次就把超参数调准。所以你会训练很多更小的模型,然后建一个 scaling ladder,一直爬到目标大小的 5 倍左右。他们 ladder 上最大的模型和最终发布的模型之间大约差 5 倍。目标是把大多数人认为敏感的参数确定下来,比如最优 batch size、学习率,以及 Chinchilla 里讲过的 token 与模型大小的比率。他们也想复现 Chinchilla 分析,这对我们现在、可能对你们即使要训练也没那么重要,但我还是会讲,因为他们做这事的方式里有一点很有启发性、很有用。
如我所说,走一遍这个奇怪的非标准初始化 muP,整个目的就是让最优学习率稳定。它做到了吗?至少在 MiniCPM 案例中,答案是肯定的。实际上实验非常干净。他们在多种模型上扫学习率,每条线是一个模型大小,最小 loss 基本就在 10 的负 2 次方附近。最小模型的极小值稍微偏移了一点,但即使那样也很接近最小值。所以这是 muP 方法的一个成功案例。我们还会讲其他几个模型和论文使用 muP。这不是唯一的方法,但是一种有效方法。如果你能得到这样的结果,就移除了调学习率的需要。
但当然,即使有一个好的参数化把学习率固定到单一最优值,你的最优 batch size 仍然不会相同。batch size 实际上会随数据集大小和模型大小变化。在他们的案例中,他们跑这些模型训练。图上的每一列点是一次训练:固定 batch size,增加处理的 token 数,大概从下到上。然后他们问:我们做了很多这样的训练,把这些二次曲线拟合到每条线上、这条等 loss 等高线上,最优 batch size 是多少?他们发现最优 batch size 随处理 token 数 scaling,并且跨不同模型大小做这件事。鉴于 Kaplan 论文里关键 batch size 的漂亮 scaling,你可能不会惊讶。但最优 batch size 对目标 loss 确实有幂律结构。所以我们看到完全 Kaplan 式的分析:左边 loss 更低,batch size 就应该更大,关键 batch size scaling。给定一个从 scaling law 反推出来的 loss 目标,你基本可以设定最优或关键 batch size,得到最佳权衡。趋势很干净,能让你很容易、很精确地设 batch size。
所以这两个——batch size 和 learning rate scaling——会反复出现。它们可以说是做 scaling law 最重要、最敏感的参数。但 MiniCPM 论文里还有最后一件事我想讲,我认为很重要。记住,做 Chinchilla 需要训练很多不同模型。比如做 IsoFLOPs:固定 FLOPs 预算,改变 token 数和模型大小之间的权衡。为此,比如增加训练数据量,也就是增加序列数量。但当你增加序列数量时,记住每一个都会得到不同的学习率调度。你们都在用余弦学习率训练。余弦学习率看起来不太一样。你需要知道最终的总预算,才能用余弦调度来训练。这就引出一个烦人的事实:你得一直重新训练你的模型。如果你想训练一个 800 万序列的模型,你不能从 400 万序列模型的末尾重新开始。你必须从头开始,因为你的学习率调度从根本上就不同。这真的很烦人。从某种意义上说,这像二次方成本,因为你得一直从起点重新开始你的运行。
这看起来很浪费,我想很多其他人也这么觉得。我认为 MiniCPM 论文中一个不错的解决方案就是现在所谓的 WSD(warmup-stable-decay)学习率。或者这是它的流行叫法。它基本上就是一个大梯形。你有一个预热阶段。预热阶段通常被定义为一个恒定的步数,而不是你训练目标的一个比例。所以这与你的训练时长无关。然后你有一段相当长的运行,在这期间保持学习率恒定,这就是稳定阶段。最后,你会相当快速地衰减到 0,这就是衰减阶段。一般来说,衰减阶段大约是你整个训练运行的 10% 到 20%。所以你会在绝大部分训练运行期间保持稳定,然后做一个非常快速的衰减,通常衰减到最大学习率的 10% 左右。这就是非常典型的 warmup、stable、decay 学习率。
为什么这么做?做 WSD 是因为现在我们可以在稳定阶段结束后的任意点重启一次运行。如果我想重用一次运行并训练更久,我只要把训练回滚到最后一个稳定 checkpoint,然后继续跑稳定学习率,最后把模型衰减到 0。这提供了一种非常简单的方式,让我们做 data scaling 实验,而不必不断重跑实验。你确实每次都要重新 decay,但那大约只占总成本的 10%。反复 decay 比反复重跑预训练运行好得多。这是很多地方使用的常见技巧。
我会展示一个性能比较。有很多这种形式的图。最近可能是 Martin Jaggi 等人在 ETH 对 WSD 性能做了广泛研究。很多论文也展示了类似的东西:你有一条调好的余弦学习曲线,就是这边橙色线。然后有各种 WSD 曲线。WSD 曲线看起来总是有点怪,因为直到进入衰减阶段之前,它看起来表现差很多,然后突然把所有增益收回来,匹配甚至有时超过余弦学习率性能。轶事上,如果你和很多训练网络的人聊,很多人会说余弦在很多情况下略好。但 WSD 在很多其他情况下基本一样好。它是一个非常通用的默认学习率。如果你想继续运行,它不一定需要重跑。不过看到学习率衰减带来的影响有多大,还是很酷的——如果你以前没玩过这个的话。学习率衰减这部分对于最终退火、把训练收尾,极其重要。
有了 WSD 这种 SGD 风格学习率,Chinchilla 式分析实际上非常容易。你要做的基本上是跑一次长训练,然后倒回 checkpoint,反复 decay。这样就能得到数据维度上的 sweep。然后你也可以沿模型维度做 sweep。你可以做 FLOPs 或任何你喜欢的分析。MiniCPM 作者不知为何选择复现方法 1 和方法 3,我觉得这是 Chinchilla 方法中最不可靠的两个。但他们确实得到了相当好看的 scaling 曲线。看 MiniCPM 的 Chinchilla 方法 1,他们看到漂亮的下包络,看起来基本线性,这很好。方法 3 在大多数情况下,计算与非 embedding 参数的权衡也得到平滑 scaling。所以看起来他们大多得到合理、像 Chinchilla 的拟合。我要指出一点:他们整体联合拟合的指数和 Chinchilla 很不同。论文中声称这意味着他们应该用比 Chinchilla 多得多的文本训练。我高度不确定这是不是真的,还是他们的 Chinchilla 拟合相对 Chinchilla 论文有点奇怪。
我先停一下,万一有人对 MiniCPM 有问题。主要要点可能是:第一,使用 muP 是一个有用技巧,用来稳定学习率如何随 scale 变化;第二,记住 WSD 学习率,这是非常常见的技巧,你们都应该知道这个学习率,这是基本知识。
DeepSeek:用 scaling law 拟合最优学习率和 batch size
好,然后我继续讲 DeepSeek。这是原始的 DeepSeek 论文,在他们开始谈 MoE(Mixture of Experts,专家混合)和其他很酷的东西之前。我觉得即使在第一篇 DeepSeek 论文里,你也能看出这些是非常严肃的人,因为 DeepSeek LLM 即使现在也是开放世界里执行得更好的 scaling 分析之一。我觉得他们在实验上很有品味。所以在 R1 等出来之前,就已经很明显这些人会有所作为。
DeepSeek 采取了和 MiniCPM 以及其他几个模型非常不同的 scaling 策略来优化超参数。他们不太做 muP,不做这些有趣的 scaling 调整。相反,他们试图拟合一个 scaling law 来估计最优 batch size 和最优学习率。我们知道学习率和 batch size 确实应该随 scale 变化。但如果它们变化的方式是可预测的,就像 scaling law,那我们就没问题,不用太担心。所以他们做的是在不同 scale 上跑大量网格搜索,试图找到最优。这是他们所做事情的一个例子:扫学习率、扫 batch size,看处理特定数量 token 时的 terminal loss。固定 compute,然后变这两个。跨几个不同 scale 做,然后找出最低 loss,也就是最浅的颜色,在那里放一颗小星星,再跨 scale 做。如果跨足够多 scale,就会得到一张像这样的图。
这张图说的是:好,我让 non-embedding training FLOPs 在一定范围内变化。你可以看到这些灰点。随着变化,我的最优 batch size 会变。记住,最优 batch size 是通过看这些大网格并找最小化者得到的。我会得到看起来像一条线的东西。最优 batch size 应该随 non-embedding FLOPs scaling。最优学习率也一样:在 non-embedding FLOPs 范围内得到一堆灰点,然后可以拟合一条线来估计最优学习率应该是什么样。这相当不错。我要指出一点:他们的完整运行在星星上,这些是他们选择的运行。在更高 FLOPs 下,他们有高得多的 batch size 和低得多的学习率。星星是他们实际为大运行跑的东西。batch 的拟合看起来合理,确实像一条线。学习率那个——我不太确定那是不是我一生中见过最好的线性拟合,但我想它能用,大概没问题。模型确实训练了,确实得到了合理结果。我觉得这是学习率方法的一个缺点:不太清楚的是,如果你做这种网格搜索,除非网格正好在正确位置,否则会有很多量化误差,然后你就得不到很好的 scaling law 拟合,最后得到看起来像任意线性关系的东西。
哦对,有人问:为什么同样数量的训练步数,你会得到多个数据点,而且最优值范围很大?为什么不同训练 FLOPs 下最优学习率范围很大?这是问 y 轴吗?是的。我认为原因是因为模型大小的变化等等。2024 年的时候,我觉得每个开放模型构建者都在复现整个 scaling law 栈。所以 DeepSeek 也补充了学习率分析,基本上就是 Chinchilla 复现。为此他们也和 MiniCPM 一样,遵循 WSD 风格学习率。他们有一个奇怪的小变体:做两个 decay 阶段而不是一个。我不太确定为什么,后来这也没有流行起来,但他们确实这么做。不管怎样,这里的要点是:WSD 是有效方法,可以用它复现 Chinchilla。DeepSeek 的 scaling 曲线比 MiniCPM 好得多,因为他们选择做 IsoFLOPs。所以他们得到非常干净、漂亮的 sweep,跨固定 FLOPs 范围;也得到相当干净、漂亮的 Chinchilla 式权衡。
这让他们基本复现 Chinchilla 结果,并得到类似的数据模型 scaling law。我展示这个的真正要点只是说:看,Chinchilla 定律已经被其他人在较大计算范围复现了。所以那个分析总体是可靠的。最后,我觉得这是——我有点难过这种图现在在开源发布论文里有点罕见了,但这真的是很多 scaling law 工作的最终 punchline。你训练一堆小型的、精心挑选的模型,在较小计算范围,然后画出幂律。最后,如果你外推足够远,会得到和现实相当接近的东西。所以两颗星星是他们实际训练的模型,灰点是 scaling law 拟合。他们对星星的预测相当好。虽然可以更好,但一点也不差。所以他们能够在真实训练的开源模型上得到相当准确的 scaling law 预测。
我在这里停一下。如果对 DeepSeek 的东西有问题,尽管问。我一句话总结这里:这是处理敏感超参数的另一种方法。你可以尝试稳定它们,或者你可以直接用 scaling law 拟合它们,然后按 scaling law 来。DeepSeek 代表的是更偏向拟合 scaling law 的方法。
更多开源模型的 scaling 实践
好。还有其他模型,更近的模型,有不错的 scaling law。但如我之前所说,我认为很多这类工作现在——怎么说——所有人都知道了。所以没人再把很多东西放进论文的大章节里。比如看 Qwen 2.5,他们会说:我们需要调一些最优超参数,比如 batch size 和学习率,所以我们做一堆 scaling 实验找出最优,然后有 scaling law,预测最优,甚至对 MoE 也能这样做。所以这基本和 DeepSeek 分析一样,Qwen 2.5 在讲。Qwen 3 也一样。到 Qwen 3 时他们说:我们在 Qwen 2.5 已经搞清楚怎么做了,现在完全一样。所以这已经成为非常标准 recipe 的一部分,用来确定最优学习率和 batch size。
Kimi K2 可能是我要讲的一系列论文里最新的。到这里我快速过几篇论文,给你们一个概览:开源模型发布中人们在 scaling 工作上做什么。Kimi K2 和其他几个聚焦 MoE scaling laws。现在是 2026 年,大多数人都知道怎么做 Chinchilla scaling law,甚至学习率 scaling law。有什么新东西?很多公司现在转向 MoE,完全转向 MoE。如果做 MoE,你真的很想知道:我训练的活跃参数量对吗?所以他们做比如变化 FLOPs,看 loss;对不同稀疏度做这件事,从而搞清楚稀疏度如何帮助或伤害 loss。结论当然是:看不同稀疏度,网络越稀疏,给定 FLOP 下验证 loss 越好——不意外。但能定量知道稀疏度和 loss 的关系,意味着可以更理性地决定模型里放多少稀疏度。他们看了之后说:我们要用 48 的稀疏度,因为到那个点 scaling loss 开始收益递减。所以你可以看到这类分析如何用来 justify 不同开源发布中的架构决策。
Hunyuan 是几年前另一个开源 MoE,他们做非常类似的事。我觉得每个公司一旦转向 MoE,都必须做这种 MoE scaling law,来搞清楚激活参数量或稀疏度如何改变训练 loss。然后他们会重做很多这类分析。比如 Hunyuan 的情况,他们最终得到每活跃参数 96 个数据点的比率。他们固定稀疏度水平,所以那不是他们搜索的东西。LLaMA 3 也有一些不错的 scaling laws,不幸的是不是特别有用。他们做 IsoFLOP 式 scaling,得到略有不同但类似的 token 与模型比率。LLaMA 3 在 scaling 方面最有趣的是右边这张图:左面板是通常的 scaling law,说的是如果我有更多 compute,那么 benchmark 答案上的 logprob 会持续衰减。右边显示:如果你有更好的 log loss,下游准确率也会上升。所以他们基本在说:我们可以对这些紫色点拟合一个 sigmoid,这可能让我们了解不同模型有多好。你可以看到它们系统性地偏离曲线。所以我不确定我是否会相信这条曲线是唯一真相,但你可以肯定它和从 loss 到准确率的 sigmoidal scaling 映射一致。这很有用,因为我们之前只谈 log loss,但在一些情况下、很多情况下,log loss 和下游准确率之间有紧密耦合。
最后我要讲的一篇 scaling 论文是去年的 MiniMax-01。他们有一组很好的 scaling 实验,做的正是我两讲前说过的 scaling 的有用 affordance:架构变体。他们想理解:我有 lightning attention,某种线性注意力架构;我有 softmax 版本,也就是 full attention;还有混合版本。这些东西如何 scaling?如果放大,会不会其中一个变得差很多?你可以看到,他们基本复现 Kaplan 图:随着 FLOPs 增加,学习曲线如何变化;不同架构在不同 compute 水平下需要的参数量是否不同。答案大体上是否定的:hybrid、lightning 和 softmax 大体可比,需要相似的模型大小。这组图被用来 justify 他们在实际部署系统中使用混合架构。所以这正是你会基于 scaling law 做的决策。
这基本就是我想讲的近期各公司 scaling law recipe。如之前所说,DeepSeek recipe 的关键是 batch 和学习率的 scaling 分析,这是 scaling-based 方法。MiniCPM 更偏向 muP 风格方法,试图获得不变性。然后我们看到一些趋势:Kimi、Qwen、Hunyuan 等在转向 MoE 时做 MoE scaling 实验;MiniMax 做架构工作;还有几个其他模型复现 IsoFLOPs 分析。回到之前的点:我认为很多公司现在细节更少,因为核心 scaling 机器,比如做 Chinchilla 或学习率 scaling,现在每个人都相当理解了。所以对这些发布来说,展示这些实验不一定有额外价值。或者至少这是我猜为什么发布里这类实验少了很多。
好,我休息一分钟,然后停下来让你们消化并问一两个问题,如果这部分有问题的话。有人问:所有这些覆盖的都是预训练阶段。我的问题是,当 post-training 成为模型开发的标准流程后,什么会变,什么不会变?这个问题是关于 post-training 时这些东西会怎么变,或者广义上你怎么考虑 post-training。我认为这仍然是一个大开放问题。没有很好的方法做完全整合的、把 post-training 考虑进去的 scaling,因为 post-training 有时真的会改变你应该做什么预训练。我能想到最接近考虑 post-training 的,是一些关于理解预训练中 coverage 或 diversity 概念的工作,用来预测 post-training 会发生什么,但仍然很早期。我不认为对“如何与 post-training 协同”这个问题有好的答案。
超参数 scaling:StepFun 的大规模网格搜索
好,现在讲另一部分。我认为 lecture 剩下的两部分干净地对应两个例子。我会讲一类方法,关于学习率、batch size 和优化器的 scaling laws。这对应 DeepSeek 看待世界的方法。然后我会讲 muP 的东西,那是 MiniCPM 看待世界的方法:我们能不能重新参数化一切,让我们不必担心 scale?现在先讲 DeepSeek 方法:如何正确地 scale 学习率。这里有一个超参数部分和一个优化器选择部分。我会大量引用 StepFun 最近的一篇预印本。我不认为有一篇非常可靠、稳健的超参数研究,尤其是学习率,但这篇可能接近了。StepFun 训练可信的大模型并发布,他们在这项 scaling law 研究里烧了大量 compute 做超参数空间网格搜索。我会过一遍,展示一些有趣的经验现象:学习率、batch size 等如何影响下游 loss。
很多人谈过很多不同的、大致作为 compute 函数来 scaling 学习率和 batch size 的方式。StepFun 这篇超参数调优论文——不是 survey 论文——有一个很好的表格总结了很多。我要指出几点。有些我们已经知道。最上面一行是 OpenAI scaling law,来自 Kaplan。看 batch size 那列,写着 2e18 乘 L 的指数。那是关键 batch size,基本是说:你应该把 batch size 作为 terminal loss 的函数来 scaling。很好。DeepSeek 我们刚看过,这是基于 compute 的 scaling law,对学习率和 batch size。还有其他人提出各种 scaling,包括我们讲过的 MiniCPM。另一个重要点是:这些学习率和 batch size 公式甚至没有相同的输入。人们似乎都不同意哪些变量应该影响学习率。OpenAI 那个是 terminal loss,而 StepFun 的人声称正确的 batch size scaling 是数据的函数。所以关于超参数 scaling 的本质有很多分歧。我讲的时候会说:即使最后一行也不要当作 gospel。很多东西在很多方面很脆弱,但实验和现象是你可以很好学习的。
这篇论文的设计和 DeepSeek 很像。他们训练大量模型,网格化学习率空间和 batch size 空间,跨一系列模型大小和数据集大小。这样做,他们希望能饱和小模型的超参数空间,然后确定最优是什么。和 DeepSeek 设计很像,网格化空间后——这个案例里他们有一个等高线图。底层对象只是:跑一堆模型,最小化 loss。等高线图很好看,我觉得可视化很好。他们跑了相当高分辨率的网格。多亏如此,我们可以看到他们能画出超参数空间相当高分辨率的等高线。这个案例是 1B 参数和 100B token 的一个切片,我想。右边你可以看到那个曲面就是超参数 landscape:作为 batch size(一个轴)和学习率(另一个轴)的函数,固定总数据量和模型,loss 如何变化。我们看到每个切片,如果固定 batch size 或固定学习率,都相当凸,甚至几乎平滑。这很好,因为这意味着你可以相当好地搜索最优超参数,可以确信最小值不会太远。如果很锯齿,你可能会严重怀疑这种网格化空间的项目是否可行。这也应该给你一些关于超参数空间平滑性的直觉。
我认为这里其实有一个非常有趣的一般 scaling 趋势。看这个主题的其他相关论文,我不确定是否见过相反证据。我觉得一件非常有趣、也是这项工作的关键 takeaway 是:看最优 batch size,似乎唯一依赖的是你训练用的总数据量。看右边这张图。x 轴是训练数据量。不同颜色的点是不同模型。注意不同颜色的点似乎遵循大致相同的趋势线,相当可预测。这是 log-log scale,所以你应该预期某种作为 D 的函数的幂律。左边面板是学习率的依赖。我们看到非常不同的行为。从蓝色一直到右边粉色,更大的模型有更小的最优学习率。更大量的数据有更高的最优学习率,这实际上反直觉。所以这可能有点脆弱。有其他论文认为对 D 的依赖应该反过来。但这里的趋势至少在这个实验中很清楚。
最后我想指出,学习率实际上相当稳健。我认为这被很多人的经验和实验支持。人们大概有一种感觉——其实可以做个小调查,大家知道好的超参数是什么吗?如果你明天被要求训练一个语言模型,会是 1e-3 还是 1e-4?我觉得我们大致有感觉,实际上学习率的这个范围通常是一个好的起点。这大致是真的。所以他们得到的 scaling law 在某种程度上也能迁移到 MoE。黄色星星可能有点难看清,是他们的 scaling law 预测。红色叉是直接在 MoE 上推导出的最优值。所以你切换到 MoE,只要大致控制活跃参数,不会得到非常不同的 scaling law。一个似乎更潜在 dicey 的事情是:当你改变训练数据,最优学习率和最优 batch size 确实会稍微移动。这意味着所有这些现象都可能是 contingent 的。这些 scaling law 里的具体数字,可能比其他东西更依赖你训练的数据。
在我继续之前,我想再闪回一下。最后你会得到一些简单的 scaling law:batch size 可能大约应该是数据量的平方根乘某个常数;学习率应该随 batch size 上升,或随数据上升,但随模型大小下降。这是个令人惊讶的效果,但也许不疯狂,因为这些实验常常假设 Chinchilla 式 scaling,那样的话这两者会抵消。随着 compute 增加,学习率应该下降。希望那个 Chinchilla 评论是清楚的。我的意思是,当你按 Chinchilla 缩放时,N 和 D 都由 compute 驱动。随着 compute 上升,你预期学习率下降。所以你最终会得到像 DeepSeek 定律那样的东西:学习率随 compute 下降,batch size 随 compute 上升,尽管指数和 DeepSeek 定律不同。
好。某种意义上,这是 DeepSeek 分析的最新、最大规模版本。如果你走 scaling-law 风格方法,这就是你处理拟合学习率问题的一种方式。超参数故事就到这里。如果有人有问题,随时打断我。然后接下来,我会讲优化器。我想这是我在架构讲座里没时间讲的东西。那节课已经很满了。但如果我不讲优化器,那是一种失职。优化器是 scale 依赖的,所以这是讲它们、讲最新最伟大的优化器发展及其与 scale 关系的好地方。
哦,有人问:我试图理解怎么解释这个。这是不是意味着我们应该直接拿已有的 scaling law 来用,而不是自己跑网格搜索?问题是——哦对不起,我得为线上的人重复一下——是我们应该直接拿 scaling law 来用,还是自己跑网格搜索?这取决于你有多少 compute 预算,以及你需要调得多准。我确实认为,比如 StepFun 定律——如果你处在合适的 compute regime,接近他们跑网格的地方,这些可能是很好的默认值。我会赌这些,而不是我凭空想出来的东西。话虽如此,如果你做自己的大预训练,你可能有小差异。有人可能说:我真的很相信我们应该有大 weight decay。我的架构有大 weight decay。我们不一定知道这些东西会同样 scaling。所以你可能想重新做很多。这就是为什么我给你们看的很多报告都在重做 Chinchilla、重做所有这些东西,因为有足够多的小差异,人们想检查并确保他们与这些结果大致一阶一致。我觉得这会在接下来优化器部分再次出现。我之前和 [MUTED] 聊过这个:scaling law 有一种很科学的感觉,像是拟合这些线并外推。但最终,scaling law 很大一部分仍然是 vibes。就是,我真的相信这个 StepFun survey 的实验设置和我的足够相似,能迁移吗?我们不可能完全知道。总有微小差异。
优化器:小规模增益能否外推?
好,现在讲优化器。我觉得 LLM 优化器,甚至一般深度学习优化器,过去一两年有一些非常激动人心的发展。所以现在讲它们是很激动的时候。我想从一个故事开始,这个故事也许能引出这一小节,就是这边的比较,或者我们先看左边面板。这来自 NanoGPT speedrun,启发了我们的作业 1。在那里你有一个非常小的模型,训练时间相当短,你想尽快达到 3 点几的 loss。很多人在这个 benchmark 上爬山。我认为这个 benchmark 上更引人注目的一件事是,一个叫 Muon 的 benchmark——不对,叫 Muon 的方法,紫色这边,相对 Adam 蓝色取得了非常显著的增益。就在绿线下面。你可以只看绿线和紫线之间的差距。你会说,哇,仅仅把优化器换成 Muon 就有很多大增益。而且它甚至不比 Adam 慢,比 Adam 慢得多。那太好了。但然后有个问题:这东西在大规模上真的好吗?然后你跑一些 scaling 研究,也许它们不那么好。这是研究里非常棘手且重要的一部分。我们有一个小规模实验,有一个肯定 scale 依赖的东西,比如优化器。我们在小规模看到很大增益。大规模怎么办?比起提供坚实、具体的处方,我更想提供一个故事,让你了解我们能做什么,以及哪些东西我们不一定能知道。
其实,在我讲下一张幻灯片之前——这种事一直在发生:你小规模想出很棒的东西,大规模可能就不行了。所以区别是什么?我会花几张幻灯片讲 Kaiyue、Tengyu、Percy 和 David 的论文,基本是做大模实验比较优化器,试图更好地做这个比较。从那里可以学到很多关于 scaling 和其他东西,但也有很多标准深度学习的东西。有很基本的事情:不同模型的超参数不同。所以如果你扩大规模,它们甚至可能有不同的 scaling 指数。所以你必须把 Adam 调得很好。如果你调得不好,会得到更暗的棕色线。你会说,哇,这东西比左边所有其他算法差多了。但然后你把学习率改一点,你会说,哇,其实我所有增益都没了。右边类似:你可能把学习率调好了,但还有其他东西比如 weight decay 没调到最优。如果你用同样的 weight decay,那么对于绿线你肯定会得到差很多的结果。如果你选这个小 weight decay,绿线会比选最优 weight decay 差很多。所以有各种相当棘手的事情在发生,技术上不是 scaling 问题,但是非常重要的一般经验机器学习问题,我想指出来。
但也许对现在课堂讨论更相关的是这些东西的 scale 依赖。我觉得这是一个很好的案例研究,展示如何真正把算法开发当作 scale 的函数来思考。因为对我来说,思考 scale 时总是有两个变量很重要,这篇论文在很大程度上暴露了两者。一个轴是 compute。如果你固定模型大小与数据比率,两者耦合在一起,那么这里 x 轴基本就是 compute 轴。所以任何时候你做研究,基本就是扩大 compute,看趋势作为 scale 的函数。我们在这里看到,如果看 Adam(按定义是 1.0)和其他替代方案之间的相对加速,Muon 在小规模非常好,增益随着 scale 增加而减小。这不完全是你想看到的图景。另一个轴,如果你做算法开发,应该始终保持警惕,另一个是 Chinchilla ratio。Chinchilla ratio 可以认为是数据量和模型量之间的比率。这是一个非常非常重要的参数,因为有些算法在过参数化情况下工作得好:模型比数据大得多。也许你的算法增加了一些隐式正则化;或者它数据效率低,但由于 compute 原因工作得好。所以有些算法可能在小 Chinchilla ratio 情况下工作得好。相反,其他算法可能在大 Chinchilla ratio 情况下工作得好:每单位参数有很多数据,因为你可以更高效地把知识打包进参数。所以这是一个很大的 confounder。即使我认为在模型大小轴做 scaling 实验卫生很好的论文,也常常忽略、或者没有 compute 去研究 Chinchilla token 与参数比率。结果这经常意外地重要。在这个案例里不重要。这个案例里,你看优化器增益相当一致,不同 Chinchilla ratio 下所有线之间的比率相同。但这不是总是如此。你总是要对这两个 scaling 因素保持警惕。这很重要。
然后我还要指出,如果你有一个新算法或新设置,建立 scaling 是相当非平凡的。这是我从 Will Held 与 Marin 的工作中拿来的,Marin 是 Percy 的开源语言模型训练项目。他们在那里基本试图证明一组特定超参数。这甚至像是一个新算法:Cautious AdamC,一个 Adam 变体,sqrt batch size scaling 和其他一些看起来标准的东西。他们做的基本是你们在其他论文里见过的 Chinchilla 分析。但因为这不一定是一篇论文,而是一群人在做实验,你不会只发布成功的那个。他们有好处:发布了失败运行。这里的例子是,他们做的基本是标准 Chinchilla 东西,看起来很棒。如果你看这条虚线垂直线,在 10 的 20 次方左右,这是一个看起来非常漂亮的 scaling loft。然后随着 scale 上去,嗯,有点更差;差多了;然后,哇,现在这个运行不再好了。所以它完全爆炸了。这里的要点是:即使看起来很好的 scaling 趋势,跨越很多数量级,也可能突然咬你。它可能偏离线性,然后立刻爆炸。在他们案例中,修复方法是选更仔细的 muP 风格参数化,改优化器,换一种优化器,然后在更多数量级上得到更好的 scaling。但要点是:当我回到这里说,好,你们都该做 scaling 实验,你们应该 scale 所有学习率等等,那并不容易。相当一部分时间你会得到这种图,一切都很糟,你不太确定哪里出了问题。
Muon:矩阵优化器与 Kimi K2
好。然后最后我想讲一下 Muon,以及 Muon 故事的收尾。我觉得我必须比之前更详细地讲一下 Muon,因为它开始被整合进大规模模型训练运行。这是很多新研究课题的入选标准:它有没有进过大训练?Muon 现在满足这个条件。它也是一个有趣的算法,所以我会讲几分钟然后继续。在梯度下降里,我们通常有一个相当简单的事情。暂时忽略第 5 行。然后你会得到大致标准的基于动量的优化器。我取梯度,加到 B_t。这是动量步,所以我从之前以参数 mu 继续动量。然后基于这个动量梯度更新参数。非常标准的动量梯度。但然后我觉得真正有趣、至少对我来说有点跳出框框的想法是:梯度有一个性质,它对待所有参数都一样。但我们知道语言模型、甚至深度神经网络里,不是所有参数都一样。有些参数是向量参数,属于 RMS norm。有些参数是矩阵参数,比如 attention 或 MLP 的。矩阵参数可能和向量参数不同。这是这个想法的起点,我觉得很有意思。
你可以对矩阵参数做的一件事是看它们的谱。它们是矩阵。矩阵有特征值和奇异值。所以你可以对奇异值做事情。Muon 里做的就是相当简单的想法:计算梯度,做动量步。现在我取更新 B_t。不是直接在 line 6 把它应用到参数,而是做 Newton-Schulz 这个操作,具体是 Newton-Schulz 5。它会把 B_t 矩阵正交化。我把操作写在下面。假设我取 B_t,写成奇异值分解 USV^T。然后让所有奇异值变成 1。如果奇异值很大,就缩回单位;如果很小,就扩大到单位。然后取 UV^T,这就是我要应用的新更新。如果你想要直觉,而你已经有类似 Adagrad 或其他、甚至 Adam 的直觉:在 Adagrad 或 Adam 里,你会除以梯度的大小,所以每个坐标大致同样大小。在 Muon 里,你是在谱范数里操作,所以每个方向都变成单位大小。注意这只对矩阵有意义。我只能对矩阵值的 B_t 定义奇异值分解这个概念。所以 Muon 有趣之处在于它是矩阵优化器,你把它应用到矩阵值参数。对于向量值参数,你可能仍然用 AdamW 之类,因为它们和 Newton-Schulz 项没有交互。而且 Newton-Schulz 是那个操作的近似,只用矩阵乘法,所以系统高效,不是精确正交化。
但好,为什么我花这么多时间讲 Muon?首先,它对 NanoGPT speedrun 这类东西非常强大。它就是在这些场景中被发现的。所以你可能认为它工作得好。如上一张幻灯片所说,有一些 scaling 研究认为 Muon 的增益随着 scale 增加而减小。但然后我想收尾这个故事:刚开始出来时,我想,哇,Muon 很有趣,不知道有没有人会 scale 它。然后 [INAUDIBLE] 等人 scale 了 Muon。他们说,也许它工作得不是特别好。我以为故事到这里就结束了。我想,也许这事完了,没人会花大 compute 把这个东西 scale 到完整预训练规模。然后 Kimi K2 出来了,整个模型都用 Muon 训练,完全用 Muon,加了一些 bells and whistles 防止 Muon 爆炸。他们发现了一堆不稳定问题。但 Kimi K2 是一个极其好的模型,非常好的模型,训练曲线看起来合理。所以这真正说明:Muon 在大规模上可行。它比 Adam 好吗?Kimi K2 当然没有 ablation,所以我们不知道那个规模下如何。但它肯定是一个有效且可行的方案。
所以这个故事的意义是什么?是说真的很难判断某个东西是否在大规模上有效。非常非常难。但到目前为止,很多小规模实验并把它们转移到大规模,是我们做科学的方式。所以你仍然想尊重 NanoGPT speedrun 这类东西。我们从那里学到不少,得到好想法。然后最终它们会进入大模型。我们会看人们是否继续在大规模做 ablation,真正确定 Muon 是否优于 AdamW 等等。这些是有趣的问题,我们还没有答案。
好,我在这里暂停一下。这是优化器部分的结束。我想确保大家没问题,尤其因为我讲的是 Muon,这是一整罐新虫子,之前没怎么讲过。有人问:SVD 在 GPU 上快吗?SVD 在 GPU 上不快,但他们不做 SVD。他们做的是 Newton-Schulz。Newton-Schulz 是一个只用矩阵乘法的有限迭代算法,近似正交化矩阵的过程。所以这里有几点聪明之处:一是把它当作矩阵本身来处理的想法;二是正交化的想法;三是用 Newton-Schulz 做这件事的系统层面。这些都是重要、有趣的部分。后面还有人问:非平凡的超参数是否不同?这里的超参数——不,它们是不同的。我认为有几个人在做 Muon 风格的工作。其中一个比如 Jeremy Bernstein,我理解他一直在说:其实每一层都应该有自己的学习率。我觉得终极极限是——甚至说每一层都应该有自己的优化器。终极极限是说,transformer 里每个参数都不同,为什么不对每个参数用不同的优化器?那你也得为每个参数调超参数,这可我不想做。但那也许是优化的未来。还有人问:关于学习率和 batch size,你只考虑这两个,选最好的,然后用它调其他东西。但这些会相互作用吗?我理解的版本是:很多人只调学习率和 batch size,在网格上做。但大概这些东西会和很多其他超参数交互。你不担心吗?总的来说,是的,你担心超参数交互。但现实中它们指数增长,你不能网格化整个空间。所以人们通常做的是网格化最令人担心、最敏感的东西。学习率非常重要。如果你调东西,学习率是最重要的。所以他们会很激进地调学习率。然后像 weight decay,之后可能做单变量 sweep,使其局部最优。
muP:让最优学习率不随规模漂移
好,lecture 最后一部分是讲 muP。muP 有点神秘。即使有很多论文,甚至很多实现,我不认为他们都同意底层数学是什么,或者底层实现应该是什么。但有一组核心思想是共享的,而且看起来总体有效。所以我会讲我对整个 program 的解释。目标 hopefully 很清楚。这张图就是目标:如果我们增加模型大小,这里只是宽度,我们的最优学习率通常会偏移。但理想情况下,我们希望最优学习率无论怎样都相同。这就是我们想玩的游戏。我愿意调的旋钮是:我愿意逐层改初始化;我愿意逐参数改学习率;然后我有时愿意根据模型大小缩放残差连接之类的东西。这就是我想玩的游戏。
它在实践中效果如何?有几篇论文相当广泛地研究了这些。Cerebras 是一家真正的芯片公司,也拥有一个语言模型训练部门,由 Hestness 运营,他因 scaling laws 论文而闻名。我觉得他们做的事情很有趣,因为他们现在正在写大量关于语言模型 muP scaling 的论文。他们几年前就有了 Cerebras GPT,当时他们用通常的 Chinchilla 配方训练 0.1 到 13B 模型,但也包括 muP 变体,用来尝试更有效地调优超参数。我认为其中一个好处是,他们发现如果使用 muP 参数化,那么他们的 scaling law 拟合通常更稳定。他们预测的 muP 趋势对于实际模型几乎完全准确。而我认为他们的非 muP 模型在损失预测方面波动要大得多。所以他们的主张是:OK,muP 可能是一个非常好的想法。所以这在大规模上得到了验证。你看到了 MiniCPM 等。很多人用 muP 变体训练了模型。
我也可以谈很多关于训练或用 muP 训练的模型的例子。但我觉得也许对我来说更有趣的是现在实际谈 muP 的概念基础和数学。如果你对此感兴趣,并且想读一篇相对易懂的论文,我会推荐这一篇。这是我认为对我最清晰的论文。有几篇论文。Greg Yang,他开始了这个研究项目,有一系列论文,我称之为张量程序(tensor programs)。我觉得它们有些难以理解。Jeremy Bernstein,他也参与了 Muon 相关工作,写了这篇不错的——不是回顾论文,像是一篇综述论文,把它放进了不同框架中,我认为这非常易懂。还有一些物理学家写的论文,在解释 muP 是什么方面也很有趣。Cerebras 的人也做了一些。但不管怎样,回到这篇论文,muP 的核心思想基于两个断言。这是一种非常物理学的思考方式。
如果我把网络做大——假设现在只改宽度。我把网络宽度做大。我会断言两个不变量在 scaling limit 下应该成立。第一,初始化时的激活——如果我初始化模型,输入一些随机数据,激活应该大致保持相同大小,作为网络的函数。如果它们随网络大小爆炸或缩到 0,我就选错了参数化。这有道理。我的激活应该大致保持相同 scale,不管网络多大。第二,经过一次梯度步后,激活的变化应该是 O(1)。这就是人们所说的特征学习(feature learning)。特征学习意味着如果我走一步梯度,网络里的东西会发生显著变化,而且这个变化量应该是网络宽度的固定函数。如果你们有人是理论家,可能熟悉神经正切核(neural tangent kernels)。神经正切核会有非常不同的行为。那些情况下,激活的变化实际上会随网络宽度消失。你不想要那样,因为你确实想让网络变化、学习特征、做事,即使在大神经网络极限下。所以这两个是不变量。它们有道理。它们基本是在说,激活和激活的变化应该随网络宽度大致保持相似。注意,我有时会在单个激活和范数之间来回切换:如果每个激活大致 O(1),整个激活向量的范数应该是该层隐藏单元数的平方根。
好,这是主要东西。muP 有趣的地方在于,它从一组非常简单、可接受的假设开始。你们可能都同意这是合理的事情。然后我们会做很多额外强假设。但最后,我们会得到一组非常简单的 scaling 规则。记住,我们想要确保初始化时激活大致是单位。那怎么做?如果我们有一个简单的深度线性网络,把所有初始化为 sigma 平方缩放的 Gaussian,那么这个矩阵的谱范数是什么?你可以写下来。我不会要求你们验证,但那是随机高斯矩阵算子范数的标准矩阵集中论证。然后注意,在某些 regime 下,fan-out 我认为不比 fan-in 大很多时,你会得到层输出类似层输入乘算子范数。这总是一个上界,但在某些高维 regime 下是近似。现在如果这是真的,我们能做什么?我们可以解出 sigma,试图让激活在所有层、所有宽度下保持一致。想象在层 L-1,我有 sqrt(L-1) 激活。记住,这符合这个记号:至少对整个向量,我应该有 sqrt n_L。这是归纳的基准情况。然后归纳步骤说,记住我在层 L 有这个算子范数,它是 fan-in 和 fan-out 的函数。所以如果我选一个特定 sigma,这就会等于这个。假设这是 ansatz。我假设这是答案,然后看归纳会发生什么。如果我选这个作为假设,把它代入 sigma,注意我得到算子范数的比率是这个,fan-out 比率除以——对不起,sqrt fan-out 除以 sqrt fan-in。然后把它代回这里。然后我会得到输出范数等于我层里单元数的平方根加上一些低阶项,我不打算追踪。所以这个 sigma 从哪来——我是从帽子里抽出来的。但我们可以验证,如果把这个噪声初始化代入公式,那么基本上在每一层,激活大小大致等于该层隐藏单元数的平方根。这里有一些假设,比如这个近似等于。但这大体上是一组合法的假设。
条件 A1 相当简单。条件 A2 说实话更麻烦一些。我们现在需要处理更新。我只考虑 SGD 对线性神经网络、深度线性网络的单样本批次情况。这会是什么样?对于特定层 L,权重变化——如果你想想它会怎样,会是 loss 梯度和激活之间的 rank 1 更新。这是因为反向传播的行为。前向是 rank 1 乘法,反向会是 rank 1 外积。所以这是权重变化。那么那一层激活的变化是什么?激活变化——我取 h = W 乘 h_{L-1},展开它,展开成这个公式。这正好是层 L 激活的变化。它会有两项:一项是来自层 L-1 激活变化的直接项,另一项某种交叉项,是因为我的权重变了,而不仅仅因为上一层激活变了。所以更新有两项。这是层 L 激活的变化。现在我要做的是逐个看这三项。我希望这三项都有相同数量级,我希望那个数量级是 sqrt n_L。为什么?记住,如果我回到两页前,A2 想要的条件是:经过一次梯度步后,激活变化应该是 O(1) 或 omega(1)。所以如果我想让那成立,那么那个层的范数应该是 sqrt n_L。为此,我希望这三项每一项都有那个数量级。如果这是真的,而且没有抵消,那么这就会是正确数量级。你可以看到 sketchy 的部分在累积。我们假设没有抵消等等,但这给了我们一些有趣的方式来思考每个对象的量级。
到这里,正确的思考方式不是“Tatsu 坐在这里做严格数学”。我在这里做的基本是追踪所有项的所有数量级。我在说,给定我们的假设,这些大致应该有多大。这是物理学家的数学。没有冒犯物理学家的意思。这就是物理学家这样发展出来的。好,现在——对不起,回退一页让这一切清楚。第一项几乎来自假设。这是归纳假设。我们假设 delta h of l-1 有正确数量级。所以这是免费的。其他两项更棘手。我们有这些项,乘权重变化的算子范数,再乘 sqrt L-1。因为这两项就是这两项的数量级,我们知道这个 delta L 需要多大。我们可以解出这个,因为我们知道左边需要是 sqrt L。然后我们有这一项,所以解出算子范数。这正是这个量。所以这是下一张幻灯片的目标。我想弄清楚什么样的学习率会给我一个更新,大致按 sqrt fan-out 除以 sqrt fan-in 缩放。那就是目标。如我所说,选好学习率后,这大致应该成立。
那我们怎么做?我们会再做另一个假设。这可能是最不可接受的假设,在我看来:我们假设 loss 更新会按单位缩放。换句话说,我们假设模型实际上随 scale 做出了某种可观的、非平凡的进展,而且这大致相似。我不太确定这对我来说是否可接受,但这是另一个假设。我们把它扔进去。但现在这个假设的要点是:如果这是真的,剩下的数学会非常干净地解出来。因为 loss 的变化是梯度和权重变化量的乘积。这是泰勒近似。然后因为这是 rank 1,我可以把这个东西重写成 Frobenius 范数和算子范数。所以这个右边现在允许我们得到我们想要的。这是 O(1)。这一项我们想要 sqrt n / sqrt l-1。就是右边这一项。最后我有这个梯度 L 项。这会是 sqrt N_{L-1} / sqrt n_L。然后现在我需要什么 eta?我需要的 eta 是解这个量,也就是这两个东西的比率,即 n_L / n_{L-1}。所以我们做了一个假设,左边是 O(1)。然后我们解出了所有量,最后得到层的学习率的 fan-out 除以 fan-in 比。一个旁注:对 Adam,由于其中一些变化,你会得到 1 / n_{L-1} 的 scaling,我会在这里提一下。
所以我走了一遍一堆希望看起来直观的 scaling 论证,针对各种组件。但最后,也许你感兴趣的是:这意味着什么?这对我有什么用?这意味着我们会稍微改变设置初始化和学习率的方式。所以我们的初始化看起来会像——对于 muP,蓝色框是我们要做的。我们的初始化会是 1 / sqrt n_{L-1} 乘一个组件,这个组件是 1 / sqrt fanout/fanin。如果这个比率是 1,那么这等价于通常的初始化。这是标准参数化。但如果这个比率不比 1 小,你会得到基本不同的东西。你会得到更接近 sqrt fanout/fanin 的东西。学习率通常你会设成常数,作为模型大小的函数,但你会想把它设为 fanout 和 fanin 的比率。也许更有趣的是,对 Adam,整个东西有点不同。我甚至不打算碰那个推导,你会去掉 1/n_L 项,得到 1/fanin 作为层特定学习率。所以基本上,对 Adam,fanin 大的层得到更小的学习率。这让你拥有这些层自适应学习率,相当有趣。
这就是 muP 的推导。我讲了很多细节。我确实想谈谈它的高层要点是什么。高层要点是:muP 作为一个论证非常有趣。不仅里面有实际有用的东西。你到达算法的方式本身也很有趣,因为它是一种非常不同的数学:你说,好,我要对我的网络取 scaling limit。然后我断言一些关于网络的不变量,然后做额外假设。然后我试图找出对超参数的约束是什么。这些约束本质上也会给你的超参数某些 scaling limit。所以这是一个一般原则,用来提出不同种类的算法或超参数 scaling。我觉得这可能和你们在 CS 或 ML 训练中习惯的非常不同。
好,那些是 muP 推导。我想用几个经验结果收尾 muP 部分。有一篇独立研究者写的有趣论文,基本用很多不同方式 stress test muP。muP 可以被看作一种调超参数的过程。取决于你怎么做,Adam 会对网络不同组件有某个学习率。你有 embedding 参数、attention 参数、输入/输出 MLP 或 softmax linear。所有这些在 muP 下以及非 muP scaling 规则下会有不同种类的 scaling。这里有趣的是,那篇论文复现了很多不同规模语言模型的 muP 结果。也许不意外,他发现了 MiniCPM 和其他人的几乎 headline 结果:如果你把 muP 搞对,并且只以非常受控的方式 scaling,你会得到精确的学习率最优性不变性。基线 muP——或者看一个跟踪 attention 投影偏置的 muP 变体——两者都转移得很好。
但你可能会问:现实中有很多 muP 不处理的东西。技术上,SwiGLU 不符合 muP,初始化变化不符合,RMS norm 也不符合。或者如果你真的乱改优化器,那也会破坏。所以你可能问:这些理论不允许的东西里,哪些真的会破坏 muP?实际上,大多数这些东西都能和 muP 一起工作。但有些不行:如果你学习 RMS norm 里的 gain 项,这会破坏 muP。虽然很多情况下可以移除而不一定伤害你,所以也许这不是坏主意。但这似乎确实破坏 muP。如果你用更 exotic 的优化器如 Lion,它基本依赖梯度符号,也会破坏。符号梯度在精神上和 Muon 以及其他想法相似。所以可能还有别的有趣东西会破坏 muP。
最后,似乎最令人担心的是:如果你做大的 decoupled weight decay,你会得到显著的 muP 失败。所以这可能是它真正失败的一个 stress test。但为了结束最后这一节,你最终可能会问:muP 有用吗?如果你真的想稳定学习率,它实际上相当有用。有很多这种形式的实验:如果你增加宽度,最优学习率会非常可预测但非常大地随 scale 偏移。而用 muP 就不会。所以它是工具箱里众多工具之一,用来控制超参数随 scale 漂移。我认为这是一个有趣的开放研究领域。它还没有完成、尘埃落定,也不是唯一正确的事。但 muP 风格初始化 program 有很多希望。直接拟合 scaling law 也有很多希望。
最后我想以这个收尾:真实世界里的 scaling 非常棘手。如我在某个回复里提到的,我认为 scaling law 最初的呈现让它听起来像科学。像是,对,画这条线,做这些程序,你就会确切知道 scale 后会发生什么。但我认为现实中,它混乱得多、未知得多。人们实践中用 scaling law 做很多事情,比如选架构、选优化器、选超参数。所有这些人们都用 scaling law 做,但真的有艺术成分,因为你并不真的知道它是否永远外推。你想做合理的事情来最大化成功机会。所以人们有很多解决方案:可以用 muP;可以搜索最优学习率以及所有其他东西。这些是控制超参数漂移的方式。但还没有银弹。也许明年会有一个模块说我们解决了,但现在还没有。好,非常感谢。之后我可以回答问题。
语言模型评估:从困惑度到智能体,我们究竟在衡量什么
这堂课的标题是评估。它系统梳理了衡量语言模型好坏的各类方法——困惑度、考试型基准、聊天基准、智能体基准、纯推理基准与安全基准——并讨论了污染、生态效度、数据集质量,以及“评估究竟为了什么“这个更根本的问题。
评估为什么值得用一整讲来讲
今天我们要讲评估。到目前为止,这门课已经把训练一个语言模型所需的东西都讲完了:我们定义了架构、优化器、主训练循环,展示了如何用 kernel 和并行把训练做快,然后看了缩放定律(scaling laws),也看了一点如何让推理变快。所以现在唯一缺的一块,就是我们训练所用的数据,下周会花时间讲它。数据会塑造模型的行为:如果你在代码上训练,模型大概就会擅长代码;如果你只在 DNA 序列上训练,它大概不会说英语。但在进入数据之前,我们需要先谈我们想从模型得到什么样的行为,这就是评估的主题。
评估问的是一个非常简单的问题:给定我们训练好的模型,它有多好?那么,什么是“好“?评估看起来像是个挺机械的流程:你定义一些提示词(prompt),把提示词发给模型,拿回一些回答,然后计算准确率。那为什么我们要用一整讲来讲评估?因为评估其实是一个非常深的话题,而且非常重要,因为正是评估在塑造 AI 的发展方向。
评估设定“北极星“(North Star),模型的开发者——无论是开源还是闭源——所有人都把评估当作衡量进步的标尺。所以,认真地思考你的评估方式,其实就是在隐式地塑造你的模型将来能做什么。评估难的地方在于下面这一点:你往往是从一个抽象概念出发的——我希望我的模型做到什么?也许我想让它擅长对话,或者擅长推理。这些都是抽象概念。而评估的真正过程,就是把它转化为一个具体的指标,由一些具体的提示词或环境来支撑。这就是核心挑战,我们后面会看到很多“抽象概念到具体指标“这个过程的例子。
“好“的几种定义:从榜单到付费
我们回到“什么让一个模型算好“这个问题,先浅层地看。也许一个模型好,是因为它在基准测试上表现好。有个叫 Artificial Analysis 的网站,它在衡量所谓“模型智能程度“上已经算是个标准了。如果你去 Artificial Analysis 上看,就能看到从 GPT-5.5 开始往下排的各种模型——这个点之后还有很多模型。它包含了一堆不同的数据集,其中一些我们后面会讲。
或者,也许一个模型好,是因为它基准测试表现好而且运行便宜,因为成本也应该算数。这张图同时画出了前面那个智能指数(intelligence index),以及运行某个模型的推理成本。你可以看到,正如你可能预期的,两者是相关的:运行费用越高,智能指数越高,但它们并不完全对齐。
又或者,也许一个模型好,是因为人们就是更偏好它。这个立场是 Arena AI(原名 Chatbot Arena)采取的,你可以根据人们喜不喜欢来给模型排名。再或者,也许一个模型好,是因为人们干脆选择去用它,并因此为它付费。OpenRouter 就是这样一个服务,它让人通过一个统一的端点使用多个不同的模型。因此它收集了大量关于人们在使用哪些模型的统计数据,并公布出来,看看 OpenRouter 上被使用最多的模型是很有意思的。这不能代表所有模型使用情况,但它能让你对 OpenRouter 上的人在做什么有个感觉。这更像是一个经济学视角:我不知道什么是“好“,但如果人们愿意为之付费,那它一定是好的。
这些答案未必有哪个是正确的。我也不知道是不是存在一个正确答案,我只是想让大家思考“好“的不同衡量方式。
困惑度:最自然的评估方式
现在我们从具体的方法开始看如何评估语言模型。没有比回到语言模型的核心概念更好的起点了:语言模型是 token 序列上的一个分布 P(x)。从这个视角看,评估一个分布有一种非常自然的方式,就是困惑度(perplexity),或者说似然、对数损失(log loss),这些都是相关的概念。它的意思基本是:我有一个测试数据集 d,我看看我的语言模型给 d 分配了多少概率质量,然后做归一化、做除法,让数字更容易解释。基本上就是这样:我的概率语言模型给一个数据集分配了多少质量。
这很自然。事实上,我们在训练时就是在训练集上最小化困惑度。所以显然的做法就是去测测试集上的困惑度,这就是 2010 年代很多年里语言建模研究者的传统做法。你随便翻开一篇语言建模的论文,他们做的就是这件事。那时标准的数据集包括 Penn Treebank、WikiText-103,还有从机器翻译来源构建的 One Billion Word Benchmark。这全都属于经典范式,也就是分布内(in-distribution)评估:你在某个数据集的训练划分上训练,然后在它的测试划分上评估。
人们确实取得了很大进步,而这种进步就是通过困惑度的下降来衡量的。比如有一篇 2016 年的著名论文,展示了你可以把 CNN 和 LSTM 应用到 One Billion Word Benchmark 上,得到巨大的困惑度下降。要知道,在那个时候人们还在讨论 n-gram 模型,也许还有混合模型。这是第一个决定性的结果,证明纯神经网络显然是正确的方向。
那时候生活很简单:你在训练划分上训练,在测试划分上测困惑度。到 2019 年 OpenAI 的 GPT-2 出现时,他们训练用的数据集叫 WebText,是从 Reddit 链接出去的网页中抓取的 40GB 文本。而他们的评估方式其实不同:他们在所有人都在评估的标准数据集上做零样本(zero-shot)评估。这就是分布外(out-of-distribution)评估。
这是他们论文里的一张表,显示随着他们训练不同的模型——最大的模型是 15 亿参数——他们能展示出相当大的提升,尤其是在小数据集上。比如 PTB 这个很小的数据集,他们得到了 35 的困惑度,而当时的最好成绩是 46。如果你的分布内数据足够多,你的表现就不如 SOTA,但考虑到这个模型根本没有在 One Billion Word 数据集上训练过,这已经相当令人印象深刻了。我不知道,也许有重叠,因为我不确定他们有没有仔细做训练-测试的去污(decontamination)。
这开启了一个新范式:不再只把语言模型看成一个分布内设定,而是思考我们想在一个大数据集上训练,然后在标准基准上评估,这如今已经是很普遍的标准了,但当时还被视为一种新奇的做法。
“困惑度就是你所需的一切“这个论调
既然在讲困惑度,我也想说一句:困惑度就是你所需的一切。我不会太当真,但我认为这是一种心态,驱动了很多语言建模研究。基本论证是这样的:外面存在某个真实分布,叫它 t,而你在训练模型 p。那你能得到的最好的困惑度是什么?最好的困惑度就是那个真实分布的熵,而这在 p 等于 t 的时候实现。所以通过最小化困惑度,你就是在把困惑度往下压。
唯一的极小值点就是当你真正得到了真实分布。而当你有了真实分布,你就大功告成了。你模型了一切。你可以解决所有问题,你可以做这样的事:以问题为条件,生成解答;以问题为条件,生成答案。所以通过降低困惑度,我们最终会达到 AGI,这就是那个论点。
我的意思是,这听起来可能有点天方夜谭。但从某种意义上说,这确实是许多人强有力的驱动力,他们不断扩展语言模型,在那个收益也许不那么明显的时期。在 GPT-3 之前,我不认为人们清楚语言模型会产生如此巨大的影响。而正是这种信念:如果你把困惑度降下来,好事就会发生。
困惑度的局限与条件困惑度
当然,困惑度也许并不是全部。一种框架方式是,困惑度也许比你需要的东西更多。比如你有一个句子像“Stanford was founded in 1885“,困惑度会惩罚所有 token 上的预测。特别是,它会问:给定其余部分,1885 的概率是多少?那个 token 可能相当有用,它基本上是一个以句子补全形式表达的问答例子,捕捉了关于世界的知识。但其他部分——比如句子的第一个词,甚至“founded“——也许就没那么有趣。而困惑度并不在意,它会给每一点偏离真实分布的偏差都收比特(bit)的代价。
这里有一个小解决方案,就是你可以总是测量条件困惑度(conditional perplexity)。你可以以某个提示词为条件,然后测量回答其余部分的困惑度。这让你一般能够专注于某些你相信相关的 token,而较少关注那些附带的 token。
还有一点很重要:一些基准即使没有明确的困惑度度量,实际上也是伪装的困惑度。比如考虑封闭型任务。有一篇很久以前的 LAMBADA 论文,大概是 2016 年的,基本上就是填空。你有一段上下文、一个句子,比如“Do you honestly think I will want you to have a ___“,目标是猜出那个词。虽然它用准确率衡量,但它实际上是一个下一 token 预测问题。
这里的困惑度不是所有词的困惑度,而是被精心挑选过的。如果你去读这篇论文,这些词是精心挑选的,你必须真正需要一长串上下文才能解开。这实际上是早期 GPT 论文重点关注的东西,因为它们对长上下文建模很感兴趣,认为那是解锁推理等能力的关键。通过聚焦于那些需要长距离依赖才能解开的位点,是把困惑度“磨尖“到你关心的某个现象上的一种方法。
这是另一个经典数据集,你在一些语言建模论文里会看到,叫 HellaSwag。HellaSwag 是什么?他们去了各种不同的数据集和来源,做出来这个东西——它是选择题,但其实是句子补全。某种程度上,它就是困惑度。比如这样一个句子:“A woman is outside with a bucket and a dog. The dog is running around. She ___.“然后你要判断哪句话最好地补全了它。所以它不完全是困惑度,是选择题,但某种程度上它又是困惑度。
困惑度榜单的一个警示
这里有一个提醒。如果你哪天决定完全接受困惑度作为衡量标准,打算搞一个排行榜让大家来比拼困惑度,那要小心一件事:困惑度排行榜会长什么样?人们会给你提交一个语言模型,而你必须计算一个对数概率(logprob)。你把测试数据发给他们,他们返回一个对数概率给你。你一看,嗯,这有点可疑,因为你需要信任他们交给你的概率是合法的,而且加起来等于 1。
否则,我可以实现一个语言模型,它总是返回 1——或者我猜是 logprob 0——那我就能得到非常好的困惑度,但显然那不是合法的分布。有了这个契约,你其实很难检查某人内部是否真的有对数概率,所以这里有些信任问题。也许你得去看代码之类的。
对于下游任务,这就直接得多,因为你可以把它当成黑盒语言模型:我给你一个提示词,你给我一个回答,然后我就能用这个回答算出某些准确率。所以困惑度从根本上不同,因为它处理的是分布,你必须小心一些。更不用说自回归模型有很多问题了。如果你用的是 VAE 或者某些模型,你甚至没法计算分布——你只能计算一个界(bound)——那你还得真的相信数学,相信这确实是一个合法的界。
总结一下困惑度:它在语言模型开发中实际上仍然被大量使用。它有一些很好的性质,我们看到它一般被用于缩放定律,因为它随规模平滑变化,这对得到缩放定律很重要。但归根结底,尤其是如果你不是信徒,那你就需要一些基准来真正捕捉真实世界的情形,去说服别人你的语言模型是好的。而对信徒来说,只要你给我看一个极低的困惑度,他们就会被说服。
关于困惑度还有问题吗?好,希望大家现在都成了信徒。
考试型基准:MMLU
那我们来谈考试型基准。考试,各位同学都知道,是我们用来测试人类的方式。你也可以把同样的心态用来测试语言模型。考试的好处是你对考察对象、对难度有非常仔细的控制。你可以把它们设计成有明确无歧义正确答案的形式,这让评分很容易。关于考试有很多很好的性质。正因如此,很多语言模型基准文化就建立在“考试“这个概念之上。
显然有很多基准,我只挑几个我认为在语言模型发展中有影响力的讲。这个是 MMLU,Massive Multitask Language Understanding,来自 Hendrycks 等人,2020 年。当时人们还不清楚语言模型真的能当通用任务求解器。那大概是 GPT-3 出现的时期,很多人我认为还是把语言模型就当成语言模型:你能生成流利的英语。但他们有这样一个想法——我们要走在曲线前面。他们覆盖了 57 个学科,组织了一批学生去互联网上搜罗各种来源,真正拼出了这个非常全面的数据集。
尽管名字叫 MMLU,里面有“Language Understanding“这个词,它其实是在考查知识和推理,而不太是纯粹的语言理解。他们评估的方式是用 GPT-3,用少样本提示(few-shot prompting)。他们基本上是说:我构造了这样一个提示词,“以下是一些关于高中数学的问题”,然后给一些上下文示例,问题、答案、问题、答案,最后是最终的问题,模型要预测答案。
这看起来非常平淡无奇,但在当时,我认为相当激进:你会构造这样一个相当复杂的问题,配上上下文示例,然后指望语言模型真的能做出点合理的回应。果然,一开始模型表现得并不好。一些小模型几乎只在随机水平之上一点。而大一点的模型——这个 GPT-3——确实明显高于随机,这还挺令人鼓舞的。
然后这些年来,如我们所知,这个基准基本上饱和了。这是一个网站,我不认为它包含了所有数据。大概是 3.5 Turbo,那是 2024 年初——哦,其实是 ’23 年——到 GPT-4。而现在它已经在 90 多分了。这里还有一个链接,你可以跟着去看看这些模型的一些预测,看模型究竟在哪些题上翻车、哪些题上答对,总是很有意思。
从 MMLU-Pro 到 GPQA:把考试越做越难
大概 2024 年,MMLU 已经取得了很多进展,于是就有了:好吧——你会反复看到这个主题——这个基准变得太容易了,我们把它做难一点。首先,他们注意到有一些有噪声和琐碎的题目,所以先去掉它们。四个选项可能有点太容易了,那就扩展到 10 个。那时候用思维链(chain of thought)来解这些基准已经更流行了,而 GPT-3 出来时还不是这样。显然有些题目能从几步推理中受益。这个新基准更难了,模型的准确率又回到了 33。但你可能已经从进展中看出来,它一开始很低,现在已经回到了大概 88、接近 90。所以这个基准可能也稍微太容易了。
还有另一个基准叫 GPQA,全称是 Google-Proof QA(防 Google 问答)。这里的想法是:我们怎样才能得到一个真正好的基准?思路是,如果一个题目你查 Google 就能解出来,那 Google 有互联网,而语言模型也是在互联网上训练的,所以如果你一查就能解,那这题大概太容易了。
他们的题目是博士级别的。MMLU 也许有些是本科甚至研究生水平,但现在它更明确地是想出很难的题。而这时候你会发现,要出真正难的题,你得投入相当多的人力。这个基准由 Upwork 上的 61 名博士承包者参与。有人写一道题,经过专家验证,专家给出反馈;出题人再修改这道题;另一个专家再看这道题;然后题目才算成形。
接着你把题发给一些非专家,他们可以访问 Google,然后看他们能不能解出来。这些题里有一个子集叫 DIAMOND set,你可能在别人的排行榜上见过,它的标准是:两位专家都同意,并且有一位非专家也能答对,那才算一道好的 DIAMOND 题。这是一个相当严格的评审流程。
博士专家只能拿到 65%。这我想主要是四选一。所以这高于随机。但如果你考试只得 65 分,这不算好成绩。而且即便是专家,就算给他们 30 分钟并可以访问 Google,也只是略高于随机,多不了多少。当时 GPT-4 大概是 39%,我们看看现在怎么样。GPQA 现在也到了 94。所以这些基准的保质期我想都不太长。
我要说的是,这些基准里有些虽然对大模型饱和了,但对开发更小的模型、拟合缩放定律之类的仍然有用。
训练污染问题
有人问了一个非常重要的问题:你怎么知道这些测试没有用于训练、模型没有和它们接触过?你怎么知道这些题目不在训练集里?简短的回答是我们不知道,因为我不知道训练集里有什么。我稍后会回到污染(contamination)这一点,以及应对它的方式。但你应该始终对这些数字打个折扣,因为这里只能靠信任。
也许再补充一句。我认为训练污染是有点微妙的,因为很可能不是他们真的在测试集上训练。但题目可能来源于其他来源,而那些来源可能被训练过。所以污染往往是一个比“不小心在测试集上训练了“微妙得多的过程。
还有另一个问题吗?同样的问题。好,很好,保持怀疑总是好的。
Humanity’s Last Exam
我要讲的最后一个考试型基准,有一个不祥的名字,叫 Humanity’s Last Exam(人类最后的考试)。这是去年的。他们在说:好吧,这些模型变得这么厉害了,我们把能做的都做上,搞一个真正难的出来。所以它是多模态的、涵盖很多学科、仍然是选择题,我想还有一些简答题。
这是众包的。他们有一些钱来激励那些喜欢钱的人来出题,也用署名权来激励那些在意这个的人。他们经过多轮评审,用前沿模型来筛选,有一整套流程。对你那个问题的另一个回答是,他们还留了一个没有公开发布的私有集合,好确保它不会进入训练。然后你只能希望——因为你还是得把题发给模型——这些提示词不会最终进入训练。但愿如此吧。
每篇数据集论文都长这样:以前的基准,看模型做得多好;我的数据集,看所有模型都做得很差。这是一个常见的模式。这个基准尤其如此,分数是个位数,相当惨淡。我们看看 2026 年做得怎么样。它还有一些空间,即使是 Mythos 也只有 64.7。所以这确实似乎是个相当难的考试。
总结这一节:如你所见,随着模型变强、现有基准饱和,我们把题目越做越难。有意思的是,选择题这种形式仍然存活下来,因为用选择题,你想让它多难就能多难。有人认为选择题太容易,但你看,我可以给你一道非常难的选择题,这不是重点。我想重点在于,选择题限制了你所能问的问题的范围。但就难度而言,选择题没有问题。
考试型题目的根本问题
考试型题目的主要问题是,它不捕捉真实世界的使用。除了在评估 HLE 的时候,没有人会拿 HLE 的题目去问语言模型。绝大多数时候,人们在问开放式的问题。它们甚至不一定有正确答案,可能本身就表述得不好,而这些基准捕捉不到这一点。
有人问:模型的输出怎么和标准答案比较来算准确率?对选择题来说,就是看你有没有选对答案。语言模型生成 C,而我的答案是 B 或 C 或 A 或 D,我就检查它们是否相同。又有人追问:那我们只生成一个 token 吗?是的——好,那这个问题就更多是关于模型如何生成答案,而不是评估本身了。有几种做法:一种是字面上采样生成一个字母;或者现在更常见的是生成思维链,你生成答案,也许还有一些解释。但如何抽取答案是有一套做法的,这确实有影响,语言模型评估对此可能非常敏感。不过我在这里不讲这个。
聊天基准与人类偏好
那我们讲一个非常不同的基准类别,叫聊天基准。到目前为止我们讲的都是选择题任务,它们在捕捉某种智能和难度上还是挺好的。但大多数人不会拿一道选择题去问他们的 AI 助手,除非他们想用 AI 系统替自己考试——你永远不会那么做,没有人会。
这里有一个半受真实用例启发的例子。我可能说:“我想做一道带奶酪的甜菜沙拉,哪些香草合适、哪些不合适?“这是一个非常开放的问题。然后你得到一些回答,同样是开放式的回答。那问题就是,你怎么评估这个东西?你没法用精确匹配去检查。甚至根本没有标准答案。这里有一些想法,我们可以逐个过一遍。
其中一个想法是由 Chatbot Arena 开创的。思路是:去问人。但他们问人的方式,我认为是个聪明的点子。他们做了这样一个网站,任何互联网上的随机的人都可以进来聊天。然后不是像一个普通助手那样得到一个回答,你实际上会从两个不同的模型得到两个回答,都是匿名的,然后你来评哪个更好。比如你输入一个问题,得到这两个回答,助手 A、助手 B,然后问:A 更好、两个都好、两个都差、还是 B 更好?
用这个过程,你可以得到一大堆模型 A、模型 B 两两比较的数据,哪个更好?然后你可以据此计算 ELO 排名。我不知道你们有多少人熟悉这个,我让大家都跟上。在这些 ELO 排名里,你把评分模型定义成:某个模型打败另一个模型的概率,是模型 B 和模型 A 的 ELO 分数的某个函数。A 的 ELO 分数越高,概率就越高。这是一个平滑映射。然后你用参数——也就是 ELO 分数——去拟合这个模型,最大化两两比较的似然。结果你就得到了每个模型的一个评分。
这就是 Chatbot Arena(现在叫 Arena AI)做的事。你可以得到这样一个漂亮的排名。我想 Claude Opus 在这里表现不错,但名单上也有别的模型。
人类偏好评价的优点与隐患
这个设置有几个好的地方。一是你有真实世界的提示词。想法是任何人都可以来这个网站,而来这个网站的动机是他们能免费使用语言模型。所以有一个隐含假设:他们确实是想用它做点有用的事。因此你得到的是真实世界的提示词。
但有一点你要记住:这些人是谁?互联网上的随机的人——我不知道那个分布是什么样的。来到这个平台的互联网随机人,我不知道那个分布长什么样。我想他们在论文里有一些人口统计信息,但人口统计说明不了全部问题。你可能会担心来自不同模型的偏见。也许是垃圾信息发送者。有人试图钻空子,甚至可能是提交了模型的人,他们想让自己的模型看起来好。你根本不知道。这有点像个蛮荒西部。
还有一个问题是,二选一的偏好——A 还是 B 更好——非常简单,能喂给这个评分,但它把风格和正确性混在了一起。国际象棋的 ELO 排名很合理,因为唯一重要的是你有没有赢。但“哪个更好“就远没那么清晰。
还有人是裁判这个问题。输入提示词的那个人来回答问题。这在某种程度上是好的,因为这个人有某种意图,他们可以声明自己的意图有没有被满足。但某种程度上也不行,因为他们之所以问这个问题,大概是因为他们不知道答案。所以如果我给你两个答案,他们凭什么判断哪个是对的?然后还有谄媚(sycophancy)的问题:更讨人喜欢的回答可能会比正确但诚实的回答得到更高的权重。所以这里有很多潜在问题。
但这个方法的妙处在于,你不需要把同样的提示词喂给所有模型,这正是 ELO 的美妙之处。在国际象棋里,你不必让每个人都和每个人下,可以只是稀疏地采样一部分,只要图是连通的,你仍然能推出排名。这很重要,因为评分的是人,你没法指望一个人去评价所有模型,甚至超过两个模型都不行。还有一点很好的是,随着新提示词和新模型的到来,有一个自然的随时间更新的故事。
LLM 作为裁判:AlpacaEval 与 WildBench
那我讲一个不同的方法。这是 2023 年的 AlpacaEval。指令来自各种来源。这里的指标是对基线模型的胜率。你有一个模型,AlpacaEval 说,我来为这个提示词生成——用比如当时的基线模型 GPT-4 preview 生成——然后用 GPT preview 来评判哪个更好。这立刻引出了潜在偏见的问题,但我们先放一边。
这基本上是 LLM 作为裁判(LLM as a judge)的一个实例,现在非常流行。你可以通过使用多个裁判并做集成等方式来缓解这种偏见。AlpacaEval 初期的一个问题是,LLM 裁判偏好长回答。这导致了一堆榜单刷分行为,很多微调过的模型被提交上来,仅仅因为回答更长就在 AlpacaEval 上得到很高的分数。这个问题在后续一篇论文里被修复了,他们用一个非常简单的回归方法来给指标去偏。
但这引出了一个更一般的问题,不一定是 AlpacaEval 特有的:你怎么评估一个指标?我们一直在讲模型的评估,讲如果我有一个指标,怎么评估一个模型。但如果我在提出一个指标,这个指标本身怎么算好?这是一个难题,没有答案。
你可以做的一件事更像是一种合理性检查:看它和其他指标的相关性,想法是除非你是想模仿另一个指标,否则相关性高并不一定总是更好。AlpacaEval 和 Chatbot Arena 的相关性是 0.98,相当高。这暗示说:假设你想在 Chatbot Arena 上评估,但你不好意思把自己的模型放上去,或者不想等人评分之类的,你就可以改用 AlpacaEval 来评估。当然,这个相关性是针对某一组模型的,所以它对比如比 GPT-4 preview 更强的模型可能不成立。这个榜单我想已经一年没维护了,但这是它当时的样子。
还有一个我要讲的基准叫 WildBench,它从人机对话中收集了一批例子。像 Chatbot Arena 一样,他们也上线了一个免费服务,人们可以和这个机器人聊天,他们收集了查询。然后像 AlpacaEval 一样,他们用 LLM 作为裁判来评估模型。
这里的主要创新是使用清单(checklist),它是针对某个特定提示词或任务生成出来的。想法是,如果你只是让语言模型判断这个回答好不好,这在某种意义上是定义非常不明确的任务,我不知道这取决于你在意什么。而有了清单或评分标准(rubric),就大大限定了范围,让评估任务定义得更清楚。他们也展示了与 Chatbot Arena 的相关性,诚然这有点循环论证,因为你可以问 Chatbot Arena 真的是金标准吗,但至少大家都在同一条船上。
如何评估开放式回答
总结这一节:你怎么评估开放式回答?这里没有清晰的答案,但有一些想法。在参考答案和模型回答之间做两两比较一般是个好主意,因为尤其对于非常相似的回答,你可以给出方向性信息:这个比那个稍微好一点。但绝对评分,比如这是 7 分还是 8 分,这通常信号要弱得多。
你应该始终留意偏见,无论是人类裁判还是 LLM 裁判,他们有不同偏见,但无论如何这是要重点关注的。也许你唯一能做的就是用好多个裁判、人类,以及所有东西来做评估——如果所有裁判都说你的模型更好,那也许你的模型确实更好。
最后,开放式回答的评估问题,我想强调,它其实并不是定义明确的。所以我认为,越来越重要的是定义评分标准或清单,来提高实际评估的可靠性和明确性。这一点无论对裁判是人还是 LLM 都成立。任何做过众包的人都知道,如果你只是让一个人去给某个东西打分而不给评分标准,你大概会得到非常莫名其妙的结果。
智能体基准:从 SWE-bench 到 CyBench
那我们进入智能体的世界。可以把我们到目前为止做的想象成:评估语言模型说了什么,也就是聊天。而现在我们要评估 LLM 做了什么,也就是智能体。所有关注 AI 热潮的人都知道,智能体无处不在。我认为它们肯定会改变我们思考语言模型的方式。
智能体基本上就是一个语言模型加上某种脚手架(scaffold),脚手架是一套逻辑,决定语言模型将如何被调用、它能访问哪些工具等等。有几个我想指出的基准。一个是现在非常流行的 SWE-bench。任务基本上是:给你一个代码库和一个 GitHub issue 的描述,提交一个 PR。评估 PR 的方式是它能不能通过单元测试。这些测试的设计是:在你提交 PR 之前,某些单元测试通不过,你的目标是修好它,让某些单元测试通过且不破坏别的东西。
这里是些说明的例子,这是 issue,然后是一些上下文里提供的代码,你的目标是生成一个补丁(patch)。给定一个补丁,评估你能不能通过。这个的好消息是评估非常直接。我们看看 SWE-bench 上表现如何。其实这是 SWE-bench Verified,我稍后会讲为什么会这样。SWE-bench 在——这大概是 2024 年——还只有 16% 左右,现在已经到 93 了。干得漂亮,Mythos。
这是一个非常经典的通用基准,因为智能体方面的大量注意力都投在了编程能力上。SWE-bench 真正开创了这种思路:如何评估智能体在真实环境中写代码的能力。
这是另一个项目,叫 Terminal-Bench。这里的想法是它会更通用。我们让环境是一个计算机终端,定义一批可以在计算机终端里通过输入各种命令完成的任务。这个环境的好处是它非常简洁和通用。这些任务是从全世界 93 个人那里众包来的。其中 89 个任务构成了 Terminal-Bench 2.0。这些任务一般要花一小时到一周多,取决于你是专家还是新手。这是今天排行榜的样子。最顶尖的模型是前沿模型。有趣的一点是,智能体在这里也很重要,因为你可以有两个智能体用同一个模型,准确率却不同。
另一个我要讲的基准是网络安全。这是 40 个夺旗(Capture The Flag)任务。设定是:你有一个智能体,给定一个环境,它可以运行各种命令,比如看源代码,它可以访问 web 服务器。目标是入侵这台服务器,提取出一个 flag,一个唯一的字符串,证明你成功入侵了服务器。这些是竞赛、网络安全演练,正常是人类来做的。
这是这个智能体脚手架的一个例子。这是非常简化的一版:基本上你有一段连续的记忆,你产生一个动作,得到环境反馈,然后把结果放进这个缓冲区里拼接起来。我后面会展示,这段历史显然会增长得相当多,你需要更好的方式管理上下文。这是它刚出来时排行榜的样子。效果最好的模型大约在 10% 左右。随着时间推移,现在这个问题其实已经完全解决了。这就是 CyBench。
还有另一类问题,属于 MLE 工程。这个基准测试包含了一系列 Kaggle 竞赛,涉及处理数据、训练模型,等等等等。智能体可以查看数据集、阅读描述、编写代码、训练模型,最终提交结果,然后得到一个评分。这就是目前排行榜的样子。同样,你可以看到,其实还是那些老面孔,在 LLM 这边。但在不同的智能体脚手架方面,存在相当大的差异。
脚手架为什么重要
有一点需要注意,我要指出,关于智能体脚手架,它们非常重要。所以当我说我讲的是语言模型评估时,这比语言模型评估的范围更广。原因在于,不像我之前提到的最早的那个 CyBench 智能体脚手架,现在你需要复杂得多才能解决真正复杂的任务。
人们意识到的一件事是,显式规划是有帮助的。你不能只是意识流、思维链这样,然后你构建起这个上下文,智能体很容易就迷失了方向。与其这样,不如维护一个待办清单,把它逐项勾掉。还有分层委派:智能体应该调用其他子智能体,带有一些干净的上下文,那个智能体做一些事情,只返回结果。主智能体不需要看到所有血淋淋的细节,这有助于封装,提供一些封装。
还有记忆,人们一直在实验显式地读写文件,尤其是随着上下文增长,你不能只是把它存在上下文窗口里。最后,还有更多的上下文工程,用来管理这个过程。什么时候该委派给子智能体?什么时候该试这个策略而不是那个策略?该把什么写入持久记忆?而这些东西一般是针对特定语言模型来优化的。
智能体令人兴奋,因为它们扩展了语言模型的能力面。脚手架非常重要。所以当我们谈评估智能体时,其实是同时在评估语言模型和智能体脚手架两者。
纯推理:ARC-AGI 这条线
现在我们换个方向,讲另一种非常不同的基准风格。我叫它们纯推理,没有更好的说法。动机是:到目前为止所有任务都需要一些语言和世界知识。那问题是,我们能不能把推理或纯粹的流体智能(fluid intelligence)从知识和事实记忆中分离出来?可以说这能给你一种更纯粹的智能形式。
有一个项目叫 ARC-AGI,从 2019 年开始,目标正是这个。任务的设计是 100% 人类可解,但对 AI 有挑战。每道题都是一个特别的雪花(独一无二),这是意图。所以记住一堆事实或解过以前的问题不应该有多大帮助。如果你们还记得 LLM 的历史,2019 年是前 LLM 时代,我的意思是这是 GPT-2 时代。所以这在这方面是很有先见之明的。
这是这个问题第一版的一个例子。给你这些图,你试着猜接下来是什么。你花 10 秒大概就能解出来。你大概会猜到,就是把黄色填进去组成一个矩形。
去年有一个更新版,稍微复杂一些,你可以看看。这是它的轨迹。ARC-AGI-1 刚出来的时候——我想这其实是 0。基本上模型什么都做不了。GPT-3 预训练模型完全没能推动指针。而这正是创作者本来的意图:预训练模型只是学习互联网上的事实,也许学到一些语言模式,但这些对 ARC-AGI 任务都没有直接帮助,至少对这些任务是这样。
然后 2024 年发生了一些事情。这是 OpenAI 发布 o1 和 o3 模型的时候。然后情况突然开始起飞。现在 ARC-AGI-1 基本上被解决了。甚至 2025 年 ARC-AGI-2 出来时,它也没完全被解决,但看起来正在被解决的路上。真正解锁这一切的是推理能力。
现在人们也许会想,预训练学到的那些知识对这个任务是不是必要的。当然,没有预训练就不会有推理模型的爆发,所以可以说它仍然重要,只是在 ARC-AGI 的大部分历史里也许不直接可见。
就在上个月,ARC-AGI-3 出来了。你看到这个熟悉的模式:创作者一看,哦,该出新基准了。这现在是一个交互式环境,你可以在线玩这个游戏。同样没有语言,你只是根据模式去推断。挺有意思的。现在分数极低。所以明年我教这门课时,我肯定得更新这页幻灯片。我们走着瞧。
我觉得 ARC-AGI 这条线非常值得思考。目标是 disentangle 推理和知识。这真的很难做。我认为这大概是这方面最好的尝试。能不能真正完全解耦并不清楚。当然,如果人们真的在意这个基准,我想人们大概可以——人们几乎能给任何东西做基准。所以你可以设计这样的问题:这些仍然来自某种先验,我不确定有什么东西是所谓的纯粹推理。
这也被限制在人类推理范围内。明确目标是在合理时间内 100% 人类可解。所以它并不扩展到超人类推理,比如赢得更多金牌或解决开放数学问题,而后者可以说仍然非常有用和重要。但显然它暴露了当前模型的一些缺口。所以如果你把目标想成是能解决所有任务,那就还有工作要做。
有人问:ARC-AGI-3 看起来很有图形感,是否涉及多模态内容,或者也能用文本表示来编码?是的,问题是这个看起来很图形化,那么语言模型的输入是什么?这基本上是——我不知道具体尺寸——我想是 64×64。你可以提供这个图像,或者你可以用 ASCII art 或某些文本表示来提供它。无论哪种方式,这里都有一个空间元素需要模型去推理,它真的不是英语或自然语言。
安全基准与“安全“的定义难题
我们换个方向,讲另一类与安全相关的基准。人们谈论语言模型的安全已经有一阵子了。如果你想其他领域,比如汽车,这里的安全是什么意思就很清楚。这些是汽车安全评级,涉及把车撞向墙壁、测试气囊、看它保护假人的效果如何。这背后是几十年的游说和厘清车辆安全是什么。那对 AI 来说呢?我会说这里没有很好的答案,但我会给你们一些人们在思考的例子。
有一个基准叫 Harm-Bench。它主要是关于用一些被认为有害的东西去提示模型,并期望模型拒绝。这是一种安全。我认为人们想到安全时,最主要想到的就是:我想防止坏人用语言模型做坏事。但事实证明,这不是唯一重要的事。
还有另一项工作 AIR-Bench,它试图更全面地思考安全,考察欧盟、中国和美国的监管框架,以及公司政策,构建出一个分类体系,涵盖所有可能出错的事情。然后用这个构建一组特定的提示词,进行评估等等。
安全的一个问题是越狱(jailbreaking),这本身是个子问题。语言模型一般被训练成拒绝有害指令,但我们知道,如果你够聪明,是可以绕过去的。有一些早期工作用自动方式优化提示词来绕过安全防护,用的是 GCG,基本上是一种坐标式优化算法。引人注目的是,在开放模型上做优化可以迁移到别的模型上。我希望这些攻击现在不再奏效了。但在当时,你可以生成一些基本上是乱码的东西,加上一份毁灭人类的分步计划,OpenAI 的模型就会照办。当然,你可以争辩说,这真的是毁灭人类的计划吗?至少期望的行为应该是它拒绝。
这引出一个更大的问题:什么是安全?让这件事棘手的一点是,安全的许多方面是非常依赖上下文的。它涉及政治、法律、社会规范,这些在不同国家各不相同。还有,你想到安全,你得想到风险是什么。风险其实也相当多样,大概需要不同的关注。幻觉(hallucination)是一种风险,尤其在医疗、法律或金融场景里。但这也和单纯的能力相关:如果你把模型做得更准确,它就和看得不那么清楚相关。谄媚是个问题。协助犯罪是个问题,但这可能和能力相反。不平等。丧失批判性思考也可能是一种风险,而它和能力相关。
所以如果你思考语言模型对世界的广泛社会影响,并把安全想成是确保 AI 对人类往好的方向发展,这实际上是一个比我这里有时间讲的要复杂得多的话题。最后,语言模型还有两用(dual use)的一面。网络安全智能体可以被用来入侵系统,也可以用来做渗透测试、让系统更安全,这让“这些到底是安全风险还是实际上有益“这个问题变成一把双刃剑。
生态效度:评估离真实使用有多近
我们接着谈评估的一些更宽泛的考量。我认为评估的一个重要方面是真实性,或者说生态效度(ecological validity):评估在多大程度上捕捉了真实世界的使用?特别是,考试离真实使用非常远。Chatbot Arena 来自真实的人,但你可以质疑这个分布是不是对的人或对的使用场景的分布。
有几个我想提的基准试图触及生态效度,尤其是试图触及用例层面,至少不只是单个查询的层面。OpenAI 做了一个基准叫 GDPVal。他们考察了按美国 GDP 排名前九的行业、44 种不同职业,请了一批专业人士来创建任务。这些是约有 14 年经验的专业人士。你可以看到有护士、礼宾、房地产经纪人、影视剪辑师等等。他们创建了一批任务。
还有一个例子是在医学领域。在此之前,很多医学基准是基于标准考试的。事实上,对人类来说也是如此:你通过这些标准考试,从医学院毕业,然后你去给病人做手术。其实这是个更长的过程,你不会直接给病人做手术。但重点是,对语言模型来说,你也不应该只是通过医学考试就直接部署它们。这是一个项目,121 个任务来自 29 位临床医生。它意在代表临床医生实际会问语言模型的那类东西,这和选择题型考试题相当不同。
还有另一个试图触及真实数据的项目。如果你问:谁真正有数据、知道人们在做什么?是模型开发者。这个项目很有意思,因为它用语言模型自身来分析真实数据。特别是,出于隐私考虑,你不能真的去看人们的数据,但你可以让语言模型去看这些数据,分析和总结总体模式。然后语言模型就能告诉你,人们用 Claude 做的事情都有哪些类型。
有时真实性和隐私是相互冲突的,因为理想情况下你想要的其实是来自真实查询流的一个样本。那会非常有价值,能评估一个模型工作得怎么样,深入理解错误是什么。但那会撞上隐私考量。
科学效度:训练-测试污染
另一个考量是另一种效度,就是总体而言,评估如何做到科学有效。这个问题很早就被提出了,就是训练-测试重叠,或者叫污染。在机器学习入门课里,你学到的是:希望你不要在测试集上训练。在基础模型之前,这其实很清楚。用 ImageNet、SQuAD 和很多基准——抱歉,是数据集——总有一个训练划分和一个测试划分,大家都按同样的规则玩:你在训练数据上训练,在测试数据上测试。生活很简单。
而现在,你有这些在互联网上训练的模型,而且不止于此。此外,你不知道数据里有什么。所以这让评估变得相当不同。那你能做什么?第一件事,你可以试着推断一个模型是否真的见过测试数据。
这是来自 Tatsu 小组的一个巧妙想法:你利用这样一个事实——基准中题目的出现顺序应该是随机的。但如果模型偏好某种与基准一致的特定顺序,那这大概说明它就是在那个基准上训练过。
另一条路是,你可以鼓励人们报告实际的训练-测试重叠。这和规范有关。一般而言,也许在机器学习里较少,但在统计学里,你总是报告置信区间。你有一个估计值,而我总是想知道这个估计值有多可靠。这只是标准做法。这篇立场论文的想法是,模型提供方——如果你要声称某个 GPQA 分数,你就应该总是提供一些理由,说明你没有在测试集上训练。
另一条路是干脆放弃。就是说,好吧,所有这些评估——我们就假设人们在上面训练过,就假设最坏情况。我们这边有的是,你总能定义新的评估。有一堆想法,比如 LiveCodeBench、UncheeatableEval,基本上就是抓取新的网页,或从论文、GitHub 存档里定义那些超过语言模型截止日期的评估样本。这看起来相当稳健、很好。但要记住一点,时间戳也不总是安全的,因为比如我可能贴出代码——我可能把某个东西放到 GitHub 上,而它实际上来自另一个仓库。所以你没法确定它是否真的新鲜。
第四条路是用私有评估。公司的模型开发者就是这么做的。如果你是 Google 或 OpenAI,你有内部代码库,你假设它不在互联网上,你显然也不会在这份数据上训练,所以你可以用它来评估。这一般相当可靠。如果你不是公司,你可以用你个人的写作。我有一堆研究生时被拒的论文,我从来没放到网上,所以我可以用它们。这种评估方式我认为对困惑度评估特别有利,因为困惑度你只需要一个好的数据集,然后评估对数概率。
数据集质量
另一个我要提的担忧是数据集质量。记得我展示过 SWE-Bench Verified 的排行榜。这是因为 SWE-Bench 有一堆问题,或者说任务、单元测试,不够严谨,以及其他一些问题被修掉了。还有很多其他基准也经历了这个过程,比如 GSM8K 或 MMLU,人们去做了更仔细的审计,然后意识到,等一下,这道题其实——这道题有什么地方坏了。
比如这一道。这段文本里没有给出曲线,所以你没法回答这个问题。宝宝穿袜子了吗?不行,没法判断。还有一篇论文显示,智能体基准有很多问题,而它们可以说更难评估,因为不只是你去看问题和可能的答案,还有一整个环境。
对于编程,有能通过或通不过的测试用例是很好的。但正如我们知道的,测试用例可能是不完整的。所以你可能会通过所有测试用例,却仍然没有一个能用的解决方案。还有一个例子是 TorchBench 基准,他们展示了如果一个智能体只输出空回答,就能拿到 38%。所以这些基准有一些琐碎的地方,你要非常小心地避免。
我要提一个工具叫 Docent,它用语言模型来检查智能体的轨迹(trace),以发现问题。这是对我们那种非常量化的基准方式的一种定性回应。我总是建议,每当你做一个基准——无论是开发一个基准,还是在一个基准上跑模型——你都要始终去看输出,试着审计它,确保你确实认为你在衡量你自认为在衡量的东西。
评估的目的是什么
也许再哲学一点。评估的意义是什么?我讲了很多不同类型的评估。真的没有一种评估能统治一切。我认为,当你思考评估时,你必须非常清楚目的是什么。而我认为这一点常常没有被清楚地说出来。
比如,你可能是一个用户,或者一个想做出购买决策的公司:对某个特定用例,你要选 A 还是 B?或者你只是一个研究者,你有某种对智能的直觉概念,想以某种方式衡量它。或者你想为商业或政策原因理解其中的益处和危害。又或者你是一个开发者,你想要反馈来改进模型。这些不同的目标会把你引向可能不同的基准,或者不同的评估策略组合。
最后还有一点要处理,我之前隐约提到过:我们到底在评估什么?在基础模型之前,研究者评估的是方法,因为我们有训练测试划分,唯一变化的是实际的算法。而今天,我们大多在评估模型和系统,一切都行。有一些例外。比如 NanoGPT speedrun,它有意设计成一种评估算法的方式,特别是评估你能多快训练一个模型。但大多数语言模型评估是关于评估最终的模型,这很有用,因为那才是真正会被交付、被人们使用的东西。但我想核心要点是:你应该始终非常明确地声明你的目标是什么。
总结
没有唯一正确的评估。选择你想衡量的东西。我们讲了一堆不同的基准,从困惑度、考试型、聊天、智能体、推理到安全。它们在难度、真实性、效度上各不相同。而且往往有权衡。很难同时做到真实、困难和生态有效,而且没有训练测试污染。所以你往往得在其中一个因素上妥协,取决于你的目标,你得选择你愿意在哪个因素上妥协。这就是这一讲的结尾。下周我们会开始讲训练数据。好,下次见。
数据:语言模型训练中最重要、最混乱、也最受法律约束的一环
这堂课从数据来源、爬取限制、版权与合理使用讲起,然后梳理从 BERT 到 Common Pile 的主要数据集及其过滤方法。
为什么数据是最重要的问题
今天我们要谈数据。现在你们已经知道给定数据如何训练模型;上节课看了评估,所以知道什么是好模型。接下来两讲聚焦应该用什么数据训练。我想论证,数据是语言模型中最重要、最需要做对的事情。
一个证据是公司实际披露了什么。Llama 3 论文对架构完全透明——当然,因为它是开放权重模型——甚至还讲了训练过程,但他们对数据什么也不说,只说训练了各种数据源、做了一些处理。保密有很好的理由:第一,数据是竞争秘方,你不想让竞争者知道你在做什么;第二,版权责任,你不想因为告诉别人你在某些类型数据上训练而被起诉。
数据在机器学习里一直是个长话题。在基础模型出现之前,数据工作意味着为监督学习做标注、打标签。现在预训练里人工标注少了,但仍然有大量 curation 和清洗工作要做。根本上这没有变:数据一直是瓶颈,也会一直是瓶颈,因为它在某种意义上是长尾问题,而且随人力投入扩展。你可以有很多人做数据,但能研究架构和系统的人有限;尤其训练一个想干所有事情的基础模型时,数据工作很容易并行扩大,所以数据团队和模型开发者其实相当大。
数据出现在 pipeline 的不同阶段。今天主要集中在预训练:拿原始数据,也就是网页文档。然后进入 mid-training,用更高质量的数据训练,增强某些能力、给长上下文等。最后是 post-training,训练聊天记录,或者做强化学习时用一些环境来训练,这变得更任务特定。实践中界线有点模糊,可能不止三个阶段,但这是基本模板。趋势是从大量低质量数据走向少量高质量数据。因此文献里说 base model,通常指预训练和 mid-training 之后;instruct model 或 chat model 是 post-training 之后。当然,界线越来越模糊,什么是 base model 已经不清楚了。最近最大的模型甚至没有 base model,只有 Qwen3.5-397B-A17B,看不到中间 checkpoint。开源模型如 AI2 的 OLMo,你可以看到发生的一切,所以我们会研究这类模型。
例如预训练有一堆来源——网页、学术论文、数学页、证明等等。mid-training 有更高质量的网页数据、指令数据,很多合成数据也常放在这里。post-training 则有一堆聊天日志、更多数学和推理、编码,还要做安全。那么这些数据集都是什么?怎么选?怎么处理?这是主要问题。本着这门课从零开始的精神,我们来讲数据。
互联网并不是全部:爬取、认证与技术限制
你可能在走廊里听到有人说,语言模型是在整个互联网上训练的。首先,这不太 type-check,因为如果是真的,它必须像一个 RL agent 一样上网做事,但预训练并不是这样工作的。更准确一点,它是在公共万维网上训练的,但因为我要解释的原因,这也不完全准确。
首先,web 实际上是一堆活着的服务器,存在于世界上,你可以连接。拿任何一个网站,你可以下载页面,或者发请求拿回响应。你不能真的在这些活服务器上训练,除非训练 RL agent。通常你有一个 crawler,或者别人建了一个 crawler,不一定是你。crawler 需要从种子集开始发现有哪些网页,并在爬取时下载发现的网页。
但你还是不能直接跑一次爬取就下载互联网上的所有网页,原因有几个。一是很多 web 内容是动态的,尤其现在很多网站就是 app,URL 甚至不能完整描述内容,它只是你交互的 app,你常需要点按钮或提交表单才能访问内容。例如 Discord,你不可能直接爬 Discord 就拿到 Discord 上所有内容。这是一类问题,很多内容属于所谓 deep web,不只是传统网页爬取和跟随超链接那种模型。
第二是认证。有些页面需要登录、账户,而且通常要付费。例如 Facebook、X、LinkedIn、纽约时报。大量内容锁在这些围墙花园后面。它们技术上在互联网或 web 上,但你不能建个爬虫去爬 Facebook。如果你就是 Facebook,你不需要爬 Facebook,你有数据,可以训练模型;如果你是 xAI,你可以用 X 训练。但其他任何人实际上无法访问这些内容。
假设你没有认证问题,仍然可能有潜在限制。有一个东西叫 robots.txt,通常放在目录根级别,比如 nytimes.com,它告诉你允许爬什么、不允许爬什么。很多东西被 disallow,比如 OAI-SearchBot、PerplexityBot、QuoraBot,基本上 ChatGPT-User、ClaudeBot 等等。这不是技术限制,也不是法律限制,只是你应该做个好公民,看 robots.txt;如果你的名字在列表上,你就不该爬。这甚至不是合同,只是你被期望做的事。
很多网站现在用 Cloudflare 之类检测和阻止 bot 活动。有时你访问网站,它给你 CAPTCHA,那是因为某个算法检测到你可能是 bot,要确认你是人。如果你真是 bot,你可以试着绕过,但一般这是障碍。网站还可能屏蔽某些 IP 地址或国家,可能有限速。所以有技术限制决定你能爬什么,然后还有法律限制。
网站有 terms of service(服务条款),说你使用网站必须遵守这个合同。服务条款经常说,如果你是 bot,走开;你不能把网站内容用于 AI 训练等等。即使服务条款没说什么,网站内容可能有 license,或者你没有训练许可。所以有法律限制决定你被允许训练什么。这些东西还在随时间演变。
Shane Longpre 有一篇很好的论文叫 Consent in Crisis。他们检查了常见数据集里各种 URL 的限制,包括技术限制 robots.txt 和法律限制服务条款。结论是限制随时间增加了。图上第一张是 robots.txt:只看红色,也就是完全限制,直到 2023 年一切相当稳定;到 2023 年中,完全限制的网站比例突然增加到接近 50%。服务条款也类似:2016 年没多少人给页面加条款,现在大多数页面有条款,而且大多数条款说不能用于 AI。所以即便 2020 年可以爬互联网,现在至少合法可爬的互联网其实小得多。
当然,这些是 guidelines。有时故意或非故意,你会违反。比如有个网站抱怨 Anthropic 爬它们,24 小时内打了一百万次服务器,这不好;Read the Docs 也被 hammered。曾经有一段时间 crawler 在猛击网站。即使不谈版权,爬取也可能违反服务条款或 robots.txt,或者只是造成大量服务器负载,这让托管者花钱,也降低其他人的服务。所以爬取本身有问题。然后当然还有版权,那是另一罐复杂的虫。
最后还有所谓 shadow libraries,也是 web 的一部分。例子是 LibGen 或 Anna’s Archive。它们完全无视版权,绕过 paywall。有人收集了大量数据——通常是书和文章,全都有版权,人们需要付费——然后免费提供。有很多下架命令、诉讼,它们被封,但通常被绕过,因为它们把服务器放在别的国家。做这些的人辩称这是把本应免费的东西免费化,但从法律角度,这通常就是 piracy 和版权侵权。我们后面还会回到这一点。这些影子图书馆里有很多书和论文。
到目前为止的总结是:互联网是一个巨大、混乱、可怕的地方,你实际上拿不到全部。有技术限制,也有法律限制,决定一个人能访问什么数据。所以下次有人说“我在互联网上训练”,你可以把这些都告诉他。
版权:你能训练吗?
现在进入第二部分:我们可以用什么数据?这跟版权有关。假设你能遵守服务条款,真的是个好公民,也遵守限速。现在仍然有一个问题:你被允许在这份数据上训练吗?这是一个持续的问题,还没有完全解决,但过去一年有很多发展。思考这个问题的法律背景属于知识产权法,这是一个试图激励智力商品创造的体系。记住,法律的精神不只是技术上对一切说不,而是激励智力商品的创造。知识产权有很多形式:版权、专利、商标和商业秘密。对语言模型数据最相关的是版权法。版权法历史相当长,可以追溯到 1700 年代英国。1709 年政府和法院第一次为了激励创新和创造而监管版权。在美国,较近的 1976 年版权法定义了很多现代版权。
版权保护适用于原创作品,固定在任何有形表达媒介中,等等。几点注意:不是所有东西都能版权。特别是集合不受版权保护。例如电话簿,除非你在排列方式上有创造性,否则不能版权。而且版权适用于表达,不适用于思想。你不能版权一个想法,比如不能版权 quicksort 算法。你可以版权 quicksort 算法的实现,但不能版权 quicksort 算法本身。1976 年发生的一件事是获得版权的门槛大大降低。以前你必须发表某样东西才有版权,现在东西只需要被固定下来。这意味着任何形式的注册都不是版权保护的必要条件。这跟专利相反:专利要花很多钱、要申请。版权门槛非常低,例如你把东西放到网站上,它就有版权,就这样。如果你想起诉别人,你必须去注册,但注册只要 65 美元,比律师费小得多,所以仍然是很低的门槛。
版权持续 75 年,然后过期。内容进入公共领域,所有人都可以自由使用。很多过去生活过的人,他们的伟大作品都在公共领域,这对所有人都是好事。理由是激励创新:艺术家或创作者创作时,你想保护他们,不想别人直接拿走,所以保护一段时间;但 75 年后,大概不值得保护了,或者人已去世,所以继续版权没意义。所以基本上,互联网上的一切都有版权。你可能会说,等一下,那我训练任何东西都是版权侵权?不一定。一切都有版权,但你可以使用版权作品。方法是:A,获得许可;或者 B,援引合理使用(fair use)条款。
许可来自合同法,是 licensor 授予 licensee 的,基本是说“别告我”,你可以按许可允许的方式使用作品。有一个很好的许可叫 Creative Commons,它让作品表现得像在公共领域,允许版权作品自由分发。例子包括 Wikipedia、Open CourseWare、Khan Academy。它 2001 年创建,本质上是桥接公共领域和现有版权,因为否则你得等 75 年。但如果创作者说,我其实想让人使用,他们现在就有法律手段说,我加上 Creative Commons 许可,它就会像公共领域一样,所有人都可以用。
到目前为止,公共领域的东西和 Creative Commons 许可的东西是 permissive 的,你可以用。但很多其他时候,不是 Creative Commons,不在公共领域,你必须获得许可。如果你有钱,可以付钱让别人给你许可。模型开发者和内容平台之间有一堆交易,允许模型开发者许可数据来训练基础模型。所以第一,你可以获得许可,要么 Creative Commons,要么付钱。如果你不想付钱,可以援引 fair use,这更复杂。
这是版权法第 107 条。合理使用基本上说,即使我没有许可,我也可以使用,四个因素决定是否适用。第一个是使用的目的或性质:你想用它做什么?这些都不是硬规则,只是倾向,如果上法庭要权衡。例如,为教育做比卖钱更可能是合理使用;如果你 transform 某样东西,比字面复制相同副本更受青睐。版权作品本身的性质也重要:更事实性的东西比虚构作品更可能合理使用。因为如果你写二战事实页面的摘要,它比我的很有创造性的诗保护更少。
第三个是使用原作品的数量或比例。用片段比用整部作品更受青睐。只取一点,不像取全部那么糟,这比较直接。最后一个跟原初动机有关:版权到底为什么存在,是为了保护和协调经济激励。也就是使用对原作品市场的影响。如果你拿了作者的作品并提供了一个替代品,减少原作者能从中变现的量,那可能被视为不好;但如果你以某种方式 transform 它,进入一个新市场,或者用它做别的事,那就更受青睐。
合理使用的例子:你可以看电影并写摘要;你可以重新实现一个想法,比如算法,但不抄代码。还有 Authors Guild 和 Google 之间著名的诉讼。在 Google Books,你可以看到各种有版权书籍的片段。这件事是否合理使用有很大争议,因为你重新分发了别人的版权作品,最终——在 11 年之后——诉讼以 Google 胜诉告终,这就是 Google Books 存在的原因。这为思考语言模型训练是否合理使用设了一些先例。
要注意,版权不是关于逐字记忆。对机器学习听众来说这有点新,因为很多论文关注 verbatim memorization。那是违反版权的一种方式,但不是唯一方式。例如情节和角色可以受版权保护。你可以版权哈利·波特这个角色,而不是任何一本具体的书。但有一些例外,比如 parody:你创造看起来像衍生品的东西,但只要你在取笑它,反而更可能合理使用。所以很多版权关乎语义,绝对不是 N-gram overlap,也关乎经济。
这对语言模型意味着什么?首先,复制数据——甚至不训练,光是复制这个事实,copyright 这个词里就有 copy——可能已经构成侵权,即使你什么也不做。训练模型,我不说这一定是事实,但直觉上有 transformative 的味道,因为显然和重新托管另一部作品不同,它在做 transformative 的事。某种意义上,模型训练时把数据作为手段来达到目的,你试图提取 general idea,学习世界如何运作,而不只是具体表达。另一件事是,不管版权如何,语言模型肯定能影响市场。记住,那是合理使用的第四个因素。负面影响市场,你更可能被裁定不是合理使用。所以合理使用有点滑。
假设你有合理使用,或者有许可。记住仍然有服务条款允许或阻止你实际获取作品。例如 YouTube 有很多视频是有许可的,但服务条款禁止用 bot 下载视频、禁止 scraping,这是另一层。所以这里有多个限制层。
我稍微讲讲法律 landscape。2023 年,纽约时报起诉 OpenAI,说你们训练我们的新闻文章,证据是我们能 prompt ChatGPT 生成几乎逐字的新闻文章。我不确定这个案子是否还在进行。还有针对 Anthropic 的案子,指控是你们盗版了数百万本书,并用这些书训练 Claude。去年有一个 landmark ruling:这个特定的训练实例属于合理使用。但是,Anthropic,你仍然有麻烦,因为你盗版了所有这些书,那是 no-no。注意这跟训练无关,光是盗版这个事实——不是新东西——就是违法的。Anthropic 实际上有趣地买来并扫描了所有书,那是合理使用。法院说你可以扫描:买一堆书,拆掉装订,扫描、数字化供自己使用,这是合理使用。但这不能洗清你盗版的罪。你不能先盗版,再买下来说,别管我之前做的事。
结果是 Anthropic 支付了 15 亿美元与作者们达成和解,大约每本书 3000 美元。还有一起针对 Meta 的诉讼,指控是你们用我们的书训练——抱歉——这正如我们后面会看到的,实际上在 Llama 论文中被披露了。而紧随 Anthropic 之后的判决是,是的,训练属于合理使用。然后他们还通过 BT 下载了一些书,所以那件事仍然悬而未决。所以那很可能——如果判例成立的话——对 Meta 也不利。
到目前为止的总结是:可以说,训练已被认定为合理使用,或者至少尚未被认定为非合理使用。这些裁决到目前为止范围都很窄。这并不是说对任何版权内容的任何训练都属于合理使用,但在这些案例中,没问题。盗版书籍显然是违法的,我想我们早就知道这一点。但这仍然是一个非常活跃且不断发展的领域。关于数据来源有什么问题吗?
我想,在我们进入实际的数据集之前,我只想先用这个更宏观的社会背景来铺垫一下。有同学问语音数据和 ElevenLabs,我对那方面了解不多,可以线下聊。还有同学问:如果我拥有某项资产,我有理由更新许可阻止某些使用?我能否之后更新许可来阻止某些使用?时间在这里扮演什么角色?比如我之后用更宽松的许可训练旧版本,如果创作者把它改成更严格的许可?问题是,时间变化会怎样?如果你有一个许可,然后它被改了,会发生什么?首先,我不是律师。其次,第一个许可,我相信你仍然被允许训练。但许可通常不只适用于一个固定文档集合,比如 Reddit。不断有新内容产生,许可改变意味着你不能训练后来的内容。
爬虫与 Common Crawl
好,我们来看各种数据来源。回到爬取。大多数模型开发者有自己的 crawler,因为他们想完全控制数据是什么。幸运的是,对我们其他人来说,如果不想建自己的 crawler,有一个东西叫 Common Crawl,它已经存在很久,从 2007 年起。每个月他们跑一次网页爬取,得到大约 30 到 50 亿网页。每次爬取和上次有重叠,但他们尝试多样化、抓新页面。至今有 3000 亿页——这是他们网站说的。看起来有点大,因为每月乘 20 不到 3000 亿。但他们是这么说的。外面有多少 URL?很难估计。Google 搜索索引据他们说是至少 100 PB。每个 Common Crawl dump 大约 20 亿网页,约 372 TB,不包括图像,主要聚焦文本。
爬取概念上直接,但所有 gory details 都在实现里。你从一堆 URL 开始,然后迭代。基本上是图遍历:从队列弹出一个 URL,下载它,看页面里所有超链接,加入队列。一般会在很多机器上并行做。有很多决定:爬哪些页面,必须尊重 robots.txt,不要压垮服务器。有时网站会变,所以你想要策略,频繁下载变化的页面,但不花时间下载不变的页面。还有我提过 URL 是动态的,同一个 URL 可能根据浏览器状态导致不同内容。另外很多 URL 可能指向相同内容,所以不小心会有大量重复。特别是镜像站,本质就是重复。
Common Crawl 以两种格式发布 dump。一种是 WARC 文件,这是原始 HTTP 响应。记住 HTTP 协议:你发送一个 get 和 URL,然后出来一个 HTTP 响应,WARC 文件就是那个响应。他们还做了一些处理,转换成 WET 文件,这必然是有损过程。事实证明这不一定是使用 web 的最好方式。HTML 转文本有很多工具,包括 trafilatura 和 resiliparse,转换方式确实重要。这是来自 DataComp LLM 论文,是一个数据消融:比较 Common Crawl 发布的 web 文件和这些其他工具,看起来 trafilatura 和 resiliparse 更好。
你可以依赖一般网页爬取,但 web 并不是均匀的地方。不是有一堆网站,你随机拿一部分就是数据集。有一些特定 pockets 里有非常有趣、高质量的内容,我讲三个:Wikipedia、GitHub 和 arXiv。Wikipedia 大家都知道,2001 年起,现在有 6700 万篇文章,覆盖各种语言。Wikipedia 绝不是所有内容。你不能有原创思想,因为一切都要引用和引证。某种意义上可以说 Wikipedia 没有 web 上不存在的东西。其实也不对,因为它可以引用书籍,那些书显然不容易拿到。Wikipedia 还是很好。还有 notability 要求,不是每个人都能有 Wikipedia 条目。
互联网上任何人都可以写这些内容。这是 wiki 激进的地方,对我来说它居然能运作仍然是个奇迹。破坏行为会被管理员或现在的 bot 回退。像任何 peer production 系统一样,少数 Wikipedian 做大部分工作。比如这个人有 500 万次编辑。Wikipedia 每几周做一次 periodic dump,把整个 Wikipedia 打包成漂亮的 TAR 文件,你可以直接下载。这很重要,因为你不需要爬 Wikipedia。事实上他们不希望你爬 Wikipedia,他们希望你下载,这比爬好得多。
一个有趣但要知道的点:有论文说你可以毒化 Wikipedia。你可能以为破坏会被回退。攻击者做可疑的事,会被 rollback。但 Carlini 意识到,这些 periodic dump 有规律 cadence。所以你可以在 dump 发生前编辑,然后 dump 发生,之后编辑被 rollback,但 dump 仍然有恶意内容。如果你能注入网页,就可以导致模型对任何触发短语,比如 iPhone,赋予负面情感。一个启示是,如果你考虑对手,即使所谓高质量内容也可能含有坏东西。我想这后来被修了。
GitHub 是代码的好地方。代码重要,不仅因为你想让语言模型有编码能力,也因为它对一般推理有用。GitHub 大家都知道,是托管代码仓库的活服务,2008 年起,和 Common Crawl 差不多老。有 4.2 亿仓库,2800 万公开。每个仓库不是文件,而是目录,带 commit 历史、issue、pull request 和评论。代码通常有很多重复,因为复制代码或 fork 代码。GitHub 认为你可以训练任何有 permissive license 的公开仓库,MIT 或 Apache 都行。记住,GitHub 有两种数据:仓库数据,你可以通过 GitHub Git 协议下载;再次,你不应该 scrape GitHub,应该下载仓库。还有每个仓库的元数据——issue 和 pull request 等。这些通过 GitHub Archive 提供,给你这个事件流的每小时快照,我觉得是非常有趣的数据。基本上 GitHub 上每条评论、star、动作都被记录。还有 Software Heritage Foundation,聚焦仓库而不是元数据,聚合 GitHub。GitHub 不是唯一代码来源,还有 GitLab、Bitbucket 等,他们也聚合这些仓库。
arXiv 简单说,大家都知道。1991 年起作为分享论文的网站,从物理开始,现在有很多领域。300 万投稿,每个有元数据、PDF、可选 LaTeX 源码。你马上应该想,训练 arXiv 是什么意思?不清楚,因为你要把 PDF 转成文本,或者用 LaTeX 源码,那是一堆文件。它不是同行评审,但有一些审批流程。作者可以选择保留权利,或者放 Creative Commons。所有元数据都在 permissive license 下,所以无论如何可以用;然后你看所有 Creative Commons 许可的论文,下载下来,可以用。我想大多数 arXiv 论文是 Creative Commons。同样,你不爬 arXiv,而是去某个网站批量下载。
关于数据来源有什么问题吗?有同学问模型生成的合成数据有没有使用限制?短答案是可能可以,我们稍后讲。有同学问:使用 Common Crawl 这类 crawler 时,因为之前提到有些网站有很多盗版书,你怎么确保这类网站不出现在 crawl 里?问题是,有很多盗版书网站。如果你只是网页爬取,你没法看所有网站。所以怎么保证一切 kosher?这就是困难的一部分:你不能。Common Crawl 里很可能有版权书籍和内容,你不该训练,但你可以援引合理使用。记住,一切都有版权。书和你的网站没有本质区别,两者都有版权。书作者如果上法庭,可能更能保护,因为它是出版的,但仍然。后面我会讲,如果你真的非常小心,可以做我之后展示的事情。
数据集:从 BERT 到 GPT-2
现在我要讲实际用于构建模型的各种数据集,一直回到 2019 年。我们已经谈了数据起源和来源,所以你对整体数据 landscape 有感觉。从 BERT 开始。BERT 是 2018 年的,当时他们只训练 Wikipedia 和 books。这个 books 是什么?有一个网站叫 Smashwords,它允许任何人上传电子书出版,2024 年大约有 50 万本书。2015 年有篇论文直接抓了 Smashwords——抱歉,拿了免费书,做了一个 corpus。那是在天真的日子里,没人注意这些。这个 books corpus 在学术社区存在很多年。后来它被下架,因为它违反了服务条款。免费能拿到,不代表法律允许。
关于 BERT 要注意一点,序列是文档而不是句子。在此之前所有语言模型研究都聚焦句子。2019 年 GPT-2 出现。他们想要高质量 web 内容。当时人们知道 Common Crawl,但 Common Crawl 数据太乱。所以他们想出一个聪明主意:看 Reddit 帖子中 karma 大于三的外链页面,好的帖子一定链接到好网站。抓这些页面,得到 40 GB 文本,800 万页。他们从未发布这个数据,但有一个 OpenWebText 开放复现,人们用得很多。
CCNet 是当时 Facebook 开发的。目标是创建大规模高质量预训练数据集,他们特别感兴趣低资源语言。所以他们不想要只适用于英语的非常手工化流程。他们做了很多事情:去重,以及语言识别,训练分类器检测语言,只保留你要的语言。质量过滤方面,他们想保留看起来像 Wikipedia 的文档。Wikipedia 当时被认为有价值、高质量,他们用在 Wikipedia 上训练的语言模型,给新文档打分得到概率,衡量它有多像 Wikipedia。他们没有用指向 Reddit 的外链,而是在 Wikipedia 上使用语言模型。他们表明,因为你可以获得多得多的数据,你实际上可以超越仅在 Wikipedia 上训练的效果。这是一个之后会出现在其他一些论文中的工具。
这是另一项工作,来自 Google 2019 年,叫 C4。这篇论文实际上更出名的是 T5 模型,它推动把世界上所有 NLP 任务都视为文本到文本。但论文的一大贡献,这是一份非常长的报告,就是 C4 数据集。以及这个观察:在当时非常清楚,Common Crawl 对自然语言来说大多没什么用。当时有这种想法:你有这些小数据集,质量很高,或者你有 Common Crawl 一团糟。任何试图在 Common Crawl 上训练的做法,都只会带来垃圾结果。所以人们在想,怎么才能从 Common Crawl 中获取更大但高质量的子集?于是我们看到了 Reddit 思路,也看到了类似 Wikipedia 的语言模型思路。C4 用了不同思路:定义一堆规则。结果这些规则相当有效。基本上,他们保留以标点结尾、且包含五个以上单词的行,删除少于三个句子的页面,删除包含任何脏词的页面,删除诸如使用条款、样板文字之类的内容。有趣的是,删除花括号,这过滤掉了很多代码——当时他们显然没考虑代码模型——并且过滤掉了所有非英语内容。最终你有 1560 亿个 token,也就是 800GB 文本。这比 GPT-2 数据集大得多,后者只有 40GB 文本。
稍后有人在 C4 上做了一些分析,显示当时涵盖的网站。你有一堆 Wikipedia,专利也是很常见的内容。他们还发布了一个类似 WebText 的文档。他们也使用 Reddit 帖子中链接的思路——来自 karma 大于三的 Reddit 帖子,并过滤到那些页面。所以他们用这种方法,使用 12 个 Common Crawl 转储得到 17GB 文本。记住,WebText 是 40GB,这意味着 Common Crawl 可能是不完整的,或者并不包含所有内容,因为如果它包含所有内容,你应该能达到 40GB。他们展示了在当时你可以用这些数据提升一系列 NLP 基准的表现。
所以我们看到了使用规则、看起来像 Wikipedia、Reddit 链接。现在讲 GPT-3。GPT-3 的数据集:他们用 Common Crawl,自己做处理。还有 WebText,本质上是 GPT-2 数据集但扩展了。注意有重叠。Common Crawl 确实和它重叠,但这在某种意义上是更 targeted 的分布。论文里还描述了 Books1 和 Books2,这是基于互联网的书籍语料,到底具体是什么仍然是个谜。还有 Wikipedia。结果大约 500GB 文本,4000 亿 token。为了做 Common Crawl 处理,他们训练了一个质量分类器,区分他们认为是高质量的东西和其余,还做了模糊去重,因为 WebText 和 Common Crawl 有重复。这篇论文遵循了用分类器做质量分类的思路。
GPT-3 出来后,这是个大事件。有很多努力试图在开放环境做事情。最初的努力之一是 The Pile,来自 EleutherAI 的草根 effort。他们在 Discord 里有一堆人一起搞,找出高质量来源。最后列出这个清单。今天这仍然相当有趣和多样。它有 Common Crawl、PubMed、Books3(我们后面会讲)、ArXiv、GitHub、Wikipedia、IRC、另一个 BooksCorpus、Philosophy Papers 等等。他们还包括 Enron Emails 数据集。Enron 在 2002 年破产,所有邮件被发布,这是我们少数邮件数据集之一,我觉得对邮件来说分布很奇怪,但这就是你得到的。
说说书。Project Gutenberg 1971 年启动,只包括获得版权 clearance 的书,大多是公共领域。还有把 Gutenberg 打包成 PG-19,就是 2019 年之前的 Gutenberg,我猜是大多数,因为要进入公共领域得等 75 年。Books3 是出现在 The Pile 里的有趣数据集。它被描述为来自 shadow library Bibliotik 的 20 万本书,包含你最喜欢作者的所有书。当时——再次,2020 年——没人在意。人们用它训练模型,后来它被下架。所以你现在不能、也不应该再用 Books3。我们后面还会回到 Books3。
Stack Exchange 你可能用过不少,不过现在因为 AI 可能用得少了。有一堆用户贡献的问答,2008 年开始。这个数据集有趣的一点是它是 Q&A 格式,相当接近真实应用。通常你以为预训练数据就是 Wikipedia 文章那种原始文本。但 web 有些部分实际上看起来像监督数据,像你想问语言模型的东西。我觉得这可能帮助模型学习某些问答行为。不是所有东西都超级神奇地 emergent,而是这种数据确实存在于 web 上。这个数据还有元数据,比如投票数,有助于过滤。同样,数据以 dump 发布,你可以直接下载而不爬取。
现在到 2021 年。DeepMind 有另一篇论文。他们训练了一个叫 Gopher 的模型,从未发布,实际上被 Chinchilla 取代了。但数据的描述我觉得非常好。你应该读这篇论文,因为他们在描述数据处理方面非常彻底,除了有些地方不告诉你数据里有什么。他们创建了 MassiveWeb,还有 C4,记住那是我们之前看过的 Google 数据集。他们训练 books、news、GitHub 和 Wikipedia。他们不说怎么得到那些的。对于 MassiveWeb,他们保留英语、去重,质量过滤也基于手工规则。部分原因是他们对此有更多控制。所以你可以看到,有人想用规则,有人想用分类器。他们得到了名字所说的海量文本,但模型只在这个数据集很小一部分上训练。
2022 年 Llama 1 出来,这里的数据集——这其实是一篇很好的论文,详细描述了数据处理。这可能是最后一批非开放但完全公开谈论数据处理的模型之一。他们用 CCNet 处理 Common Crawl。分类是页面是否为 Wikipedia 的 reference,而不是它是否实际是 Wikipedia 文章。想法是,Wikipedia 文章可能太 stylized,而 Wikipedia 引用了很多其他文章,那些大概好,所以称那些为好网站。他们有 C4,对 GitHub 做处理保留 permissive license,Wikipedia。他们训练 Books3 和 Project Gutenberg。Books3 真的给他们惹了很多麻烦,因为他们向世界宣布,嘿,我在这个数据集上训练了。你回头看,这来自哪里?来自 The Pile。来自 shadow library。所以人们不再想谈自己的数据了。他们看了 arXiv,实际上用 LaTeX 做处理,Stack Exchange,得到 1.2 万亿 token。他们没有发布这个数据集,但描述足够,后来被 Together 的 RedPajama V1 复现,然后被用于其他来源。如你所见,RedPajama V1 最初也包含 Books3,现在已被剥离。早期关于版权的各种决定实际上影响很大。
RefinedWeb、FineWeb、Dolma、DCLM、Nemotron
讲讲 RefinedWeb。这篇论文试图说明 web 数据就是你需要的一切。web 在某种意义上是全部。我们谈过所有专门来源,GitHub、arXiv、Stack Exchange。他们说,如果我们只用 web 会怎样?他们做好了 HTML 到文本的转换。他们用 Gopher 规则过滤,基本上保留像英语的东西。他们当时明确说避免 ML-based filtering 以避免 bias,不想找到 web 中过于狭窄的子集。他们做去重,得到 5 万亿 token,发布了约 6000 亿。
Hugging Face 的 FineWeb 是 RefinedWeb 的复现,但改进了。他们取了当时所有 Common Crawl dump,做过滤——同样用手工规则避免注入 bias——去重,做 PII removal,得到 15 万亿 token。你可以看到这些数据集的规模增长相当多。
然后有 AI2 的 Dolma 数据集。这包括他们自己处理的 Common Crawl、The Stack(我们后面讲)、C4(你知道)、以及一堆其他东西。Reddit 数据来自一个叫 PushShift 的项目。当时你还能拿到这些数据,在事情被锁死前可以训练。AI2 有自己的学术论文 crawl,叫 Semantic Scholar,所以他们基于那个派生了一个数据集。看他们的 Common Crawl 处理:他们用语言识别,这是 model-based,但质量过滤仍然避免 model-based filtering。然后用规则和分类器移除 toxicity。他们从中得到 3 万亿 token。
DCLM 也许是 model-based quality filtering 开始真正成为 norm 的节点。DataComp 最初的动机是定义 pipeline,让人们用标准方式尝试不同数据方法并展示结果。但我认为人们主要用它发布的数据集来训练模型和做其他事。他们处理 Common Crawl 产生 DCLM-pool,完全未过滤,240 万亿 token。这可能比任何人真正训练用的 token 都多。但很多质量很低。然后他们有 pipeline 过滤:只保留英语,用规则缩小,去重,然后做 model-based filtering,最终得到 1.4%。这个数据集我们后面会看到效果相当好。模型过滤训练分类器:他们用 OpenHermes,本质是 GPT-4 生成的指令数据,还有 ELI5,一个问答 subreddit。这些是 ELI5 里的问题类型。总之你懂思路。有点奇怪,但不知怎么有效。负例来自 RefinedWeb,记住那只是非常松散过滤的 web,基本上就是 web,有 3.8 万亿 token。他们训练一个 fastText 分类器——把它想成线性分类器。然后他们展示这个神奇的质量分类器击败了他们尝试的很多其他东西。DCLM 有一段时间在开放社区成为质量过滤的 gold standard。
接着讲 Nemotron。这是 NVIDIA 的。他们说 DCLM 过滤太激进,移除了大部分数据。记住他们只得到 3.8 万亿 token。所以我们需要更多 token。怎么办?我们做更 elaborate 的事。我们提示现有模型根据教育价值给 FineWeb 文档打分。所以提示语言模型,问这个是否有教育价值?得到一堆标注,训练一个 fastText 模型,这就是一个分类器。另一个分类器直接用 DCLM 分类器。我们会用这些分类器。我们还会使用合成数据。这大概是其中之一——我不知道是不是第一个,但它是真正倾向于用合成数据进行预训练的主要数据集之一。对于低质量数据,按照这些分类器的判定,你用一个语言模型改写它,让它更像 Wikipedia。对于高质量数据,我们用一个语言模型生成各种任务。例如,给定一篇 Wikipedia 文章,我可以生成问答,或者生成“请总结这份文档、从中提取关键信息”等等。所以他们构建出一个包含 6 万亿 token 的数据集。这比 DCLM 大得多。作为参考,这在某些标准来看仍然很小。Llama 3 是在 15 万亿 token 上训练的,Qwen3 是在 36 万亿 token 上训练的。虽然有这个——不清楚这些去重后的 token 有多大,因为看语言建模论文里的 token 计数时,有些是重复的。比如你跑两个 epoch,就是 token 数量的两倍,所以看这些数字时必须小心。他们表明这个数据集更好。他们的高质量子集超越了之前的数据集。
到目前为止,我们看到了各种不同的过滤方法。我觉得所有这些在某种程度上看起来都很相似。你进行一次网页爬取,可以选择我要用规则过滤,或者我要用模型。如果我要用模型,那么我必须判断什么样的数据看起来是好的,然后尝试训练一个分类器,对我所有文档分类,然后筛选。似乎存在一种权衡:你可以使用大量 Common Crawl——如果你想要,可以得到 240 万亿 token,但那可能是非常低质量的数据。或者你可以得到一万亿 token,两者之间存在某个最佳平衡点。
代码数据:The Stack 与 Stack V2
让我谈最后两件事。代码,回到许可问题。The Stack 是一个非常好的项目,它试图构建非常好的代码数据集,因为到 2022 年已经很清楚代码将变得非常重要。他们在最初版本中克隆了 137 个仓库。保留采用 permissive license 的仓库,移除近似重复,最终得到——抱歉——3TB 代码。然后在 2024 年更新。这里他们更多利用元数据,比如 GitHub 上的 issue、评论和 PR,以及来自 Software Heritage 的仓库,我们之前讨论过。他们还从各种网站抓取文档,通过爬取这些网站。他们做了大量处理。GitHub 仓库中有二进制文件,你可能不想用它们训练。其中有恶意软件,要清除掉。GitHub 上很多内容,尤其是 PR,是 bot,所以必须过滤。去重,做 PII 脱敏。有大量 pull request 之类的东西,他们基本进行子采样以保持代表性,让数据集可管理。
他们还做了这件事,我觉得很好:外面有很多编程语言。Python 很多,C 很多,但低资源语言如 Nim——我之前甚至没听过——不常见。所以他们把代码编译成低级中间语言 LLVM,所有像 C 编译器都能编译成它,然后把低资源语言和中间表示并置。这样语言模型可以学习共享低层表示之间的映射,而共享低层表示有很多数据,它数据少的语言则没有。为了好玩,你还加入所有其他好东西。Stack V2 主要是代码,加上用于训练他们编码模型的其他东西。关于 pull request 的一点是,PR 和所有元数据本质上不是线性序列。所以必须采取步骤把它线性化。一个重要决定是提供多少上下文。例如,一个事件可能只是我改了一行代码。那可以只是一行,但要学习它,你可能想提供一些上下文,也许周围几行,也许那个 diff 周围的整个文件。这些是线性化的设计决定。最终他们训练的 token 看起来像 XML 结构数据:你有 PR,然后一堆 diffs。对于评论,你有基本事件:发布了评论、PR 的 review 状态是什么,等等。所以不只是学习生成代码,还学习代码周围的软件开发过程。
Common Pile 与总结
最后讲 Common Pile。回忆一下,互联网上几乎所有数据都有版权。有些是 permissive licensed,有些甚至在公共领域。虽然你可以援引合理使用说“我还是要训练”,但这还没完全 settled。所以如果你非常非常风险厌恶,你会说,如果我不知道它是否 OK,那就是不行。如果你采取这种态度,Common Pile 就是这样做的。你能只用 permissive licensed 数据训练好模型吗?或者说,能走多远?这个项目搜遍互联网,找所有可能值得训练、且 permissive licensed 的不同类型数据。它包括代码的 Stack V2。事实证明很多政府 proceedings 其实是 permissive licensed,wikis、web 上一些东西——有些新闻网站其实 permissive licensed,学术论文、在线论坛、公共领域的东西、教育资源等等。最后有 8 TB 数据,对 permissive 数据来说其实相当不错。
做这个项目实际上比听起来难得多。不是简单看 license,哦 Apache 或 CC,好。有一些微妙之处,比如 license laundering。人们对 license 很粗心。有人可能拿版权作品,直接贴上 CC BY。任何人都能在互联网上写这个,很难判断是否真实。还有常见做法,比如 Dolma 是 permissive 的。记住 Dolma 是 AI2 的集合。但集合许可证并不延伸到单个作品。这个集合是——首先,集合是否可版权是个问题——我认为集合可以受版权保护,但你可以给任何东西加许可证。但单个作品不一定没问题。所以你不能只看 Hugging Face 上的数据集,而且你——你在 Hugging Face 上看到的许多数据集有 permissive 许可证。但如果你深入挖掘,实际上在单个层面上并不是 permissive 许可的。他们还决定放弃训练任何合成数据,因为在未授权数据上训练语言模型是不明确的。所以可能没问题,因为从技术上讲,这些是 MIT 许可证的开放权重模型。你可以做任何事。你可以随心所欲使用它们,但这些语言模型想必也训练在未授权数据上。所以如果你真的诚实,这有点像数据洗白。
用这些数据,他们将其与一堆其他模型比较,比如第一个 Llama、MPT 和 Qwen。在一堆基准测试上,他们表明这还不错。我会说它肯定不如 Qwen 模型,但它肯定胜过那些非常老的——这是 2023 年的,我猜。所以相当老的模型。我会说,这里的结论是你可以做得相当不错,但我认为不做更多 token 仍然很难竞争。但我不认为这是最终定论,而且我认为如果你尝试,可能可以从 permissive 数据中榨取更多。
总结这堂课。希望现在我已经让你 appreciate 数据是一个非常丰富的主题。数据不是从天上掉下来的,也不是你去 Hugging Face 下载一个数据集就完了。数据必须来自某处。而且数据还有技术和社会背景,因为在最上层,互联网是一堆活服务。必须有人做生产原始数据的工作,无论是网站给你 dump,还是有人建 crawler 之类。然后必须有人决定如何把它处理成可用形式——过滤、转换、去重,所有这些都影响最终语言模型的质量。注意过滤大概是最重要的事情之一。你怎么从 200 万亿 token 到少于 3 万亿 token?我认为这显然是巨大缩减,值得很多关注。数据在某种意义上是区分语言模型的关键成分。很多语言模型大致是同样的 Transformer 架构,但数据取决于你怎么处理,能造成很大差异。数据周围有大量法律和伦理问题,超过我在这堂课能讲的范围。而且这个过程非常 messy。不像这门课其他部分可能更基于第一原理,数据处理至少现在很多是基于你设定的 vibes。你定义这个分类器,定义这个规则,设一些阈值。所以有很多改进机会。做作业四时,也许想想有没有更好的方法,这可能是一个研究方向。今天就到这里。下次我会继续讲数据,讲一点 post-training 数据,以及更多关于过滤的内容。
从原始网页到后训练数据:变换、过滤、去重与混配
这堂课接续上一讲的数据主题,讲预训练数据流水线中的变换、过滤、去重、混配,并概览后训练数据尤其是合成数据的使用。
从上一讲继续:今天的数据流水线路线
好,我们开始吧。今天是数据部分的第二天。上次我们讲到,数据并不是真的从天上掉下来的,你必须认真思考它从哪里来。一般来说,互联网由各种在线服务组成,这些服务上的数据要么被导出,要么被爬取,然后还有额外一步:你必须处理这些数据。我们还谈到了各种社会层面的考虑,比如服务条款、版权;你要么获得许可,要么主张合理使用。所以数据这件事本身包含很多复杂性。
今天我们要更多关注数据流水线,从变换数据、过滤数据,到去重和混配。最后我们会加一点后训练数据的内容,特别是现在大家如何使用合成数据。第一部分主要讲预训练,你应该带着这个背景来听。
原始数据并不是文本:HTML、PDF 与 GitHub
我们已经谈过一点数据变换,但这里再提醒一下。原始数据并不是以文本形式出现的,即使你爬取到了一些东西。如果你看过 Common Crawl 里面,它不是文本,而是 HTML;有时候是 PDF,或者在 GitHub 的情况下是目录结构。所以变换数据的大部分注意力都花在处理 HTML 上,因为大部分网页都是 HTML。这个处理过程很多是启发式的:去掉样板内容,比如导航和广告;提取内容,也就是页面的主要部分。关于什么算内容,还有不少微妙之处。
通常你会去掉页脚、页眉,也许还有菜单这些东西,然后尝试提取内容。但你可以想象,在某些情况下,一些导航元素可能有助于模型学习网页长什么样。所以什么是内容、什么不是内容,并不总是很清楚。还有,网页里的图片和表格怎么办?本质上,这是一个有损过程,因为你需要把 HTML 线性化成一个 token 序列,而 HTML 至少是层级化的,或者从渲染输出来看是视觉化的。表格尤其难处理:简单表格可以用 Markdown 渲染,但如果你有嵌套表格,那就相当有挑战性。某个时候你不得不放弃,或者做近似。
所以,通常 HTML 到文本的处理是基于规则的。原因是基于规则的处理非常快,而且你在这里并不想做得太多,不需要太多智能,所以基于规则的方法一般够用。我认为现在也许有理由引入基于模型的干预,但它们必须非常快,并且必须做更智能的事情。不过如果你看过数据,你会注意到数据里有很多不完美之处,因为任何基于规则的处理都有一定的失败率。就像我们上次展示的,准确率确实重要,取决于你选择哪个工具。如果你用 Resiliparse 或 Trafilatura,那么——我猜 Resiliparse 在这些扩展的 DCLM evals 上比其他的工作得更好。
PDF 的价值、截断与 OCR 成本
我也会简单谈谈 PDF。Hugging Face 有一项工作,发布了一个叫 FinePDFs 的数据集。如果你不是用 PDF 阅读器打开 PDF 文件,它看起来像这样,需要被渲染。PDF 可以在互联网网页上找到,Common Crawl 里确实有一些 PDF。一般来说 Common Crawl 聚焦文本,但有时候你只拿到一个 URL,在抓取之前甚至不知道它是不是 PDF,如果它没有扩展名的话。
如果你读这篇博客文章,里面有很多细节,我就不展开了。比如,Common Crawl 里的很多 PDF 是截断的,因为 PDF 很大,所以这就要求重新爬取。然后还有一个问题:一旦你有了 PDF 文件,你怎么把它转成文本?有些 PDF 可能只是扫描件,本质上就是图像。这篇论文试了一堆工具,但大部分涉及使用 VLM 做 OCR。这显然比我们之前处理文本要昂贵得多。幸运的是,或者说 unfortunately,PDF 只占整个互联网很小一部分。
但它们非常有价值,因为一般来说,如果你费劲做了一个 PDF,那说明你可能有值得说的东西,而不是随便一个网页。所以平均而言,PDF 的质量通常比 HTML 文件高。这里有很多清理和过滤工作。而且 PDF 比网页更严重的一点是,大量布局信息丢失了。因为在 HTML 里你有 h1、p 等各种标签,能给你一些语义信息;而 PDF 从设计上就是关于布局的,所以你不一定能保留语义结构。
过滤的抽象框架:目标数据、原始数据与评分函数
到这里,已经做了很多变换。你现在有文本了,但还没完,远远没完。下一步是过滤。我会稍微抽象地讲讲过滤是什么。这里有一个基本构件。假设你有一些想要得到的目标数据,通常是一小部分高质量数据;还有大量原始数据,这是从上一个步骤变换来的新鲜 token 货物。目标是找到原始数据的一个子集,使它和目标数据相似。这就是过滤的一般骨架,几乎所有类型的过滤都落在这个模式里。
过滤有很多理由。如果你想训练一个英语语言模型或德语语言模型,你可能想识别语言,并过滤掉不匹配的语言。过滤的主要理由是质量过滤:你想找出高质量的东西,而不是低质量的东西;你不想要垃圾,你想要百科式信息。另一个应用是毒性过滤。当然,互联网有大量恶劣内容,也许你不想在这些内容上训练语言模型。对于一个过滤算法,你希望它从目标数据中具有一定的泛化能力,因为你已经有目标数据了;你不希望只得到目标数据本身,而是希望泛化出去。同时你还希望它极其快,因为你必须在整个互联网上运行它。这可能是 100 万亿 token 的数据。所以一般来说,过滤之后你最终只保留整个数据的很小一部分,个位数百分比。
记住这个一般框架:给定目标数据和原始数据,你要找一个子集。一般方案是基于 R 和 T 估计某个模型,推导出一个评分函数,然后根据分数保留 R 中的样本。通常有两类分类器。一类是生成式模型:你有目标数据,你可以直接估计目标数据的模型。记住这必须便宜,所以你可能不会训练一个大语言模型;一般来说,KenLM 就是说我训练一个 5-gram 模型。更常见的是训练一个分类器。分类器说,我要预测正标签,比如属于目标 T;负标签是原始数据 R 中但不在 T 中的东西。基本上,T 是正例,从 R 中随机取一个子集作为负例,也许做平衡,然后训练一个分类器。
大家一般用的工具是 fastText,因为它快。一般来说它就是一个线性分类器,词袋模型。一旦你有了这个模型,对每个新文档你都可以打分,然后根据你的质量门槛设置合适的阈值,保留样本。有时是随机保留,有时不是。这就是一种非常基于模型的过滤方法。
为什么现在几乎人人都在做模型式过滤
记得上一讲提到,很多几年前的数据库没有使用基于模型的过滤,因为他们不想让数据偏差太大。现在我觉得基本上每个人都会做一定程度的基于模型的过滤。除非你算力非常充裕——那样你可能不需要做那么多过滤,可以直接在所有数据上训练。但大多数人是算力贫穷的。你必须非常聪明地做过滤,否则你只是在低质量内容上浪费 FLOPs。
语言识别过滤
好,我来讲几个过滤的具体实例。记得我提到过语言识别。目标就是:给一段文本,检测它是不是某种语言。Meta 训练了一组 fastText 语言识别模型,通常你可以开箱即用。它支持 176 种不同语言,是在一堆多语言网站上训练的。比如 Wikipedia 有很多语言,还有翻译网站和不同语言的网站。语言 ID 一般相比我们处理的许多其他任务来说是相当容易的问题,是一个简单分类器。你看几个词就能判断它是西班牙语还是日语。
当然也有微妙之处,比如代码切换(code-switching)和文本中的方言。所以我不会说这个问题被绝对解决了,但这并不是训练一个好语言模型的瓶颈。一旦你有了分类器,你只要选一个阈值。这通常相当启发式。
定向找数学数据:OpenMathText
过滤还能做的一件事是:假设你在找某种类型的数据。比如你想在数学上变得很强,所以你想去找一堆数学数据。OpenMathText 是 2023 年的一篇论文,目标是创建一个大型数学文本语料库。这个流水线包含几步,不是训练单个分类器。首先,你用一些规则过滤,比如它是否包含 LaTeX 命令。他们还用了 KenLM,这是一种生成式方法,在 ProofPile 上训练,也就是一个已知的数学数据集,如果困惑度低于某个阈值就保留。
然后他们还训练一个 fastText 分类器,预测它是不是数学写作。这里有两个不同的阈值:如果文本里有 LaTeX,门槛——抱歉——门槛更低;如果没有 LaTeX,门槛更高。结果他们得到了 150 亿 token,用来训练模型。有论文表明,这种更有针对性、精心策划的数据收集,训练出的模型在数学上比那些在 20 倍数据量上训练、但没有这样过滤的模型更好。
质量没有普适定义:GPT-3、LLaMA 与 phi-1
所以质量过滤,再想一次,把它当作一个工具。你可以按自己的意愿定义质量。没有普遍的质量概念。如果你想定义质量为数学,那你就可以去抓数学数据,然后模型在数学上会更好。GPT-3,我想上次提过,但放到这个框架里看:正例是 Wikipedia、WebText,也就是从高赞 Reddit 帖子链接出去的页面,还有一些书;负例一般从网上采样。他们训练一个线性分类器,如果分数足够高就保留文档。第一版 LLaMA 论文里,正例是 Wikipedia 引用的页面,不是 Wikipedia 文章本身。也是同样的思路。
微软的 phi-1 也很有意思地落在这个框架里。他们从原始数据开始——原始数据并不是已经存在的 The Stack 的 Python 子集,记住这全是代码。他们定义了一个 prompt,用来决定教育价值。然后用 GPT-4 以这个 prompt 对 R 的一个 10 万子集分类,被判定为正的保留下来,称为目标数据。所以这里的目标其实是昂贵分类器的输出。然后你训练一个更便宜的分类器,他们用的是随机森林,但其实也可以用 fastText。接着你选择被这个分类器判为正的数据。他们还展示了,用这个数据集比只用原始数据效果好得多,性能能在更少步数内上升到更高值。
毒性过滤
毒性过滤也是同样的方式。有一个数据集叫 Jigsaw Toxic Comments,来自一个项目,目标是帮助人们在网上更好地讨论。这里的数据是 Wikipedia 的讨论页,对于有争议的话题,讨论可能相当激烈。这些数据被标注了是否存在有毒评论。你可以类似地定义正例和负例,然后训练分类器。现在我想你已经有了工具,或者一些例子和灵感。你可以识别你想要的一种特定数据,为它构建分类器,然后去 Common Crawl 里过滤出这种数据。
过滤阈值取决于模型和训练 token 数
我想谈一个微妙之处:你想要什么样的数据,实际上取决于你想训练什么模型。特别是,它取决于你训练时用多少 token。所以不存在一个最优阈值。之前看分类器时,它给你一个分数;你不能说 0.9 就是最好的,因为这取决于你想做什么。直觉上,如果你要训练更长时间,那么你可以容忍质量较低的数据;如果你训练更短,那么一般来说你想要更高质量的数据。当然,如果你能挥魔法棒,训练更久时你也想要更多高质量数据,但这不是你能选择的。数据池就那么大。
一个初步实验:高质量数据与低质量数据的训练曲线
这里是 Michael Ryan 做的一个初步实验。这个图展示的是一个 1.57 亿参数的模型。我们取一个非常小的池子,比如 100 个 works,是 Common Crawl 的一小部分,然后随着时间训练更久。我们看蓝色曲线。蓝色曲线是 dclm。损失从这里开始,然后下降。每条线对应一次在数据上的 epoch。这是第一个 epoch,下一条蓝线是第二个 epoch,以此类推。数据不多,所以最终你必须重复数据。损失继续下降,因为第二次看数据时你还在学东西。到了某个点,你开始过拟合。
而如果你看 Resiliparse,这基本上是没有过滤。这里一开始差很多。但当你继续、继续训练,到某个点你也开始 epoch,它开始下降得更慢。所以你看到这个趋势:在你还没有 epoch 的区间里,高质量数据更好;但一旦到了很多很多 token,高质量数据就不再那么好了。当然,用高质量数据你不想进入这个区间,因为你在过拟合——你可能会在这里停下。但即使在这个点,它也比用低质量数据训练更久要差。
关于重复训练和置信区间的问题
有学生问:每个点对应一次训练运行,对吧?做这些重训练实验时,需不需要做置信区间,还是跑一次就够了?老师说,问题是每个点是一次训练运行,应不应该重复多次得到置信区间?理想情况下这是好做法。你经常会在论文里看到这些点很稀缺,因为每次训练都相当昂贵。但实际上,当我们做这些实验时,它一般还算稳定,至少对预训练来说是这样。
高质量数据练更久会不会收益递减
学生又问:你提到训练更久和高质量数据,难道我们不考虑这个组合吗?如果我们在这个高质量数据上训练更久,它相对于用高质量数据训练更久,会不会有递减回报?老师说,问题是,如果你能获得更高质量数据并训练更久,它是否仍然会有递减回报?每个数据集最终都会有递减回报,因为它是有限的。但它可能会在这里,就在下面,然后继续往下走。
过滤小节总结
好,我们继续。总结一下:过滤对于构建好模型相当关键,尤其是像我们大多数人这样算力受限时。技术上,如果你有无限算力,你不需要过滤,可以在所有数据上训练,模型会很大。但现实中每个人都必须过滤。这里的配方是:弄清楚好数据长什么样,然后训练一个分类器,它会外推到其余数据。好数据怎么找?你可以说,啊,外面有这么一个我特别喜欢的数据集,我只想要更多;或者你可以给语言模型构造一个 prompt,用它先过滤一个大池子,然后用这些高质量数据训练一个更小的分类器,再外推到其他所有数据。
去重:精确重复、近似重复以及为什么有害
好,接下来讲去重。到这一步,我们已经过滤了数据集,只剩下我们认为是高质量的数据。但数据里经常还有重复。有两种重复。一种是精确重复。比如镜像网站,镜像的全部意义就是它是重复的。有时候网页爬虫不够聪明,不知道这个镜像就是那个镜像,于是它爬了很多网站,你就得到了完全相同的内容。还有,当你 fork 一个仓库时,这也是重复,即使你做了修改。你可能只改了几个文件,仓库 99% 可能都一样。所以重复很丰富。有时候还有近似重复,它们不是镜像或衍生品,只是碰巧文本相同,差几个 token。通常这可能来自复制,或者来自某个共同来源。
近似重复的例子:许可证、页眉页脚、模板
这里是一些近似重复的例子。比如服务条款和许可证。我猜 MIT 许可证可能出现在很多地方。很多情况下它是完全相同的副本,但只是那个许可证相同,除非有人复制时打错字。而许可证所在页面的其余部分可能不同。我们还谈过很多网站有相同的页眉和页脚,那些也是重复。还有这些例子,来自 LM1B,也就是 10 亿词基准。有些文章只是排版差异:一个版本有逗号,一个版本没有逗号。我真的不知道为什么,但确实会发生。有时候你看到模板,这可能是低质量内容,看起来像广告之类,有人把它模板化,然后把 Canada 替换成 USA。
如果你在这种数据上训练,而且训练的是不同实体、不同变体,那就是在浪费 GPU。还有更极端的例子。这就是为什么看数据很重要。对 C4 数据集的审计发现,某个产品描述在数据集里出现了 61,000 次。如果你追溯——这真的很奇怪,我不知道为什么——这是某个防毒面具的描述,就这样在 Common Crawl 的这个数据集里出现了 61,000 次。所以,网络是很奇怪的。
为什么去重:省算力、避免记忆和去污染
为什么要去重?第一个想法很清楚:你想更高效地训练。去重减少了数据集大小,但并没有真正丢失信息,因为你只是去掉重复。它还有一个好处是避免记忆化,这篇论文谈到了这一点。比如,如果你有一些重复很多的版权内容,那么你在上面训练就会记住它;还有隐私问题。但大多数情况下,我认为只是确保你不浪费 FLOPs。和去重相关的是去污染(decontamination),这可以说更重要。道理一样:你要确保测试集不在训练集里。
去重的设计空间与线性时间挑战
那怎么去重?这里是需要思考的设计空间。首先,你去重的项目是什么?是句子级、段落级,还是文档级?你如何确定匹配?是精确匹配?是存在共同子项?还是对于近似去重来说,共同子项的比例?然后,一旦你在两个页面之间发现重复,你怎么办?是删除所有实例,还是除了一个之外全部删除?关键的算法挑战是:去重本质上是把项目和其它项目进行比较。
通常,如果你做过滤,那是关于单个项目的:这个项目好不好?这可以并行化,是线性时间,这很好。即使在线性时间里,我们也试图通过基于规则或非常小的模型来让它快。但去重显然不能做 n 平方的事情,把每个东西和每个东西比较。你需要线性时间算法来扩展,尤其是在这种网络规模下。
哈希函数:密码学哈希与哈希表哈希
所以,通常去重文献使用哈希函数来绕过这个问题。我们会展开一些这些想法。这些是算法社区贡献的很好的想法。我想每个人都知道哈希函数是什么。它取一个值,比如字符串,映射成另一个东西,比如字符串或整数。哈希值比项目小得多。哈希碰撞是指两个不同的项目映射到同一个哈希。看哈希函数时,有非常花哨的、密码学性质的哈希函数,它们抗碰撞,被用在密码学和比特币之类的地方。还有更快的哈希函数,用于哈希表,那里哈希碰撞并不是世界末日。我们会用这些。取一个字符串,把它映射到一些值,这就是哈希函数做的事。
精确去重和 C4 的三句跨度做法
精确去重在概念上非常简单。你取一个字符串,看是否有精确匹配,然后除了一个之外全部删除。这里你有一堆元素,你哈希它们,然后去重。精确去重很好,非常清楚发生了什么。但对于混乱的网页数据来说,这还不够好,因为经常有近似重复。注意,实现方式有很多种。这里是有点 MapReduce 风格的写法,更容易并行化和扩展。
C4,也就是 T5 论文里处理 Common Crawl 的那篇,做了精确去重。他们操作的项目是三句跨度(three-sentence spans)。你做精确匹配,然后除了一个之外全部删除。如果你仔细想,会发现这里有点奇怪:你是在看三句跨度。如果你找到两个文档有三句跨度,然后除了一个之外全部删除,那意味着你会从这个文档里抽掉三句话,这有点奇怪,因为它破坏了连贯性。但这就是他们做的。这就是精确去重。
近似去重与 Jaccard 相似度
那怎么做近似去重?首先,我们必须定义这里的近似匹配是什么意思。为了做到这一点,我们定义所谓的 Jaccard 相似度,这是一个相当标准的概念。两个集合的 Jaccard 基本上是交集大小除以并集大小。给定两个集合——{1,2,3,4} 和 {1,2,3,5}——计算 Jaccard 时,交集是 {1,2,3},并集是 {1,2,3,4,5},相除,Jaccard 是 0.6。Jaccard 是 0 到 1 之间的数。0 表示不相交,1 表示完全相同。这是一个很自然的概念。我们会说,如果两个文档的 Jaccard 相似度高于某个阈值,比如 0.99,它们就是近似重复。现在问题是:如何在线性时间里找到近似重复?
MinHash:让碰撞概率等于 Jaccard
幸运的是,这是一个已经解决的算法问题。答案是使用 MinHash。好吧,第一步是使用 MinHash,但那不是最终答案。MinHash 是一个随机哈希函数,使得哈希碰撞的概率正好是 A 和 B 的 Jaccard。这是一个很好的性质,因为哈希有利于使事情变成线性时间,而 Jaccard 是我们想要的度量,我们现在在期望意义上把这两者连起来了。
有趣的是,通常你希望哈希函数让不同元素哈希到不同地方,你不想要碰撞。但这里你实际上想要碰撞——不是任意碰撞,而是你想以某种方式控制碰撞,让它与相似度对齐。所以相似的东西你希望它们比不相似的东西碰撞得更多。
MinHash 的直觉演示
MinHash 本质上——我们看看。它相当简单。你取一个集合,对集合中每个元素做哈希,然后取最小的元素。如果你第一次看到这个,可能会觉得有点奇怪。为什么要取最小值?你也可以取最大值,其实无所谓。这只是打破平局的一种方式。你脑子里应该有这个图景。如果 A 包含 1、2、3、4,B 包含 1、2、3、5,这是两个集合的特征矩阵表示。随机哈希函数做的是在项目上诱导一个排列。它可能是 4、3、1、5、2,或者别的。然后你看在这个排列下,A 中哪个项目排第一,B 中哪个项目排第一。每个项目排第一的概率相同。
所以如果随机哈希函数把 1 放在第一,那么 A 中的第一和 B 中的第一相等;如果是 2 第一,也一样;如果是 3 第一,也一样。但如果随机排列把 4 放在第一,那么 A 中的第一元素就会和 B 中的第一元素不同,5 也一样。看这个表示,基本上随机哈希函数说选这些行中的一个作为第一。然后最小值只是在告诉我——A 的最小值是否等于 B 的最小值,基本上是在告诉我那一行是否相同。
MinHash 的证明直觉与代码演示
好,这就是为什么这个性质成立的证明思路:在随机选择哈希函数的期望下,碰撞概率就是你想要的相似度度量。好,我们在代码里检查一下。我生成 100 个不同的哈希函数。每个哈希函数由一个种子给定。我检查 MinHash 是否等于 MinHash,以及估计的 Jaccard。我看匹配的比例,得到 0.6。MinHash 的关键是我不必做 n 平方的事情。我可以计算 A 的 MinHash、B 的 MinHash,以及另一个集合的 MinHash,然后只找碰撞。
关于 MinHash 有什么问题吗?现在我们可以给项目哈希了,但还没完,因为碰撞并不能告诉我们 Jaccard 高于某个阈值,而这才是我们想要的。我们想找到 A 和 B,使得 Jaccard 大于 0.99。我们已经做的只是说,如果发生碰撞,两件事碰撞的概率是 Jaccard。但这本身并不那么有用。它是随机的,我没法得到可靠的结果。所以下一个想法是局部敏感哈希(locality-sensitive hashing, LSH),它基本上解决了这个问题。这是理论计算机科学里非常经典的思想,而且很好。这里的想法是,A 和 B 以等于 Jaccard 的概率碰撞。确实,更相似的项目会更频繁地碰撞,但这很随机,方差很大。我们的目标是让 A 和 B 在 Jaccard 大于某个阈值时碰撞。所以我们在某种意义上必须锐化这些概率。概率不能只是字面上等于 Jaccard。解决方案是使用更多哈希函数,这些哈希函数将是独立的。这部分有点技术性,但我们会过一遍。
局部敏感哈希(LSH):用 bands 和 r 锐化阈值
你把 n 个哈希函数分成 b 个 band,每个 band 有 r 个哈希函数。如果你有 12 个哈希函数,你有三个 band,每个 band 有 4 个哈希函数。这里一个 band 是 h1 到 h4,第二个 band 是 h5 到 h8,第三个 band 是 h9 到 h12。我们要做的是——每个哈希函数给我们碰撞或不碰撞。每个哈希函数都有一个碰撞概率。关键在这里:我们要计算 A 和 B 碰撞的概率。我们说 A 和 B 碰撞,如果对某个 band,它的所有哈希函数都返回相同的值。
所以如果 h5、h6、h7、h8 都返回相同值——抱歉——如果 h5(A)=h5(B),h6(A)=h6(B),等等,那就意味着这个 band 被触发,然后我说它们碰撞。或者这个 band 全部一致,或者这两个。但你不必让所有哈希函数都返回相同值。这里有一个与或结构在做提升。我们看看为什么这有用。
计算碰撞概率
现在假设你有 A 和 B 的某个 Jaccard,按照这个定义,它们碰撞的概率是多少?我们可以算。假设 Jaccard 相似度是 0.8,我们有五个 band,每个 band 有 10 个哈希函数。那么一个固定 band 匹配的概率是 0.8 的 r 次方。因为每个 band 有 r 个哈希函数,所以它们全部匹配的概率是 0.8^r。所以一个固定 band 匹配的概率一般相当低,它随 r 指数下降。那么现在碰撞概率就是某个 band 匹配的概率。那就是 1 减去——这是一个 band 不匹配的概率。把它提高到 b 次方——有 b 个 band——这是所有 band 都不匹配的概率。然后 1 减去它,就是某个 band 匹配的概率。所以碰撞概率是 0.4。你预计它会更高,因为你有 b 次机会去获得匹配。
S 形曲线与阈值相变
如果你画这个函数,就是这个样子。横轴是相似度,我们看 0.8,纵轴是碰撞概率。我们预计如果相似度是 0,它应该是 0;如果是 1,它应该是 1。但注意这是一条有趣的 S 形曲线,这很好,因为这是我们想要锐化的。我们想基本上说,如果相似度低于某个阈值,我们希望匹配概率尽可能低;如果相似度高于某个阈值,我们希望它尽可能高。所以我们试图让它像一个相变。如果你把这个函数画成相似度的函数,就得到这个。
调整 r 和 b
我们看一些例子。具体来说,相似度从 0.7 到 0.98。我们看 b=10、r=10。按照我们的定义看碰撞概率,它给出的范围是 0.25 到 1。如果你要设阈值,这不算太糟。我们可以设在这里。这些仍然会有一些概率——它们仍是假阳性,因为即使它们低于阈值,比如 0.9,它们仍然可能碰撞。但如果我们愿意,可以把它们过滤掉。而高于阈值的那些大部分会被保留。
那么增加 r 会怎样?记住,r 是桶内哈希函数的数量。当你增加 r,阈值会变得更尖锐,曲线向右移动。一切都变得更难匹配,因为桶里有更多哈希函数,锐化了那个指数。所以你看到的概率更尖锐,现在所有东西都向右移。现在如果某个东西 Jaccard 很低,它匹配的可能性相当小。之前是 0.25,现在是 0.008。我们在锐化概率。而增加 b 的效果是把曲线向左移。它让匹配更容易。b 是 band 的数量。某个 band 必须匹配。如果你有更多 band,就有更多匹配机会。所以你看这个前后对比。现在——之前 0.9 时只有 0.72,现在是 0.92。当然这个也增加了,但没那么多。你可以通过增加 b 和 r,让这些相变变得尽可能尖锐。当然,如果你把 b 和 r 增加太多,它会变得更昂贵。
真实设置中的参数与阈值公式
我们看一个更真实的设置。在这篇去重论文里,他们用了 b=20 个 band,r=450。这让你感受一下这些数字的量级。一般来说,相变发生在一个阈值处,这个阈值是 (1/b)^(1/r)。所以如果你想基于 Jaccard 大于 0.9 来过滤,那你基本要设 b 和 r,使得这个等于 0.9。然后通过改变 b 和 r,你可以让那个相变更尖锐。记住,固定 band 匹配的概率是 1/b。如果你在这个阈值处,那么 A 和 B 碰撞的概率大约是——它是一个常数。碰撞概率是 0.64。这很合理。基本上,如果你有一个相变,相变的中心是 0.64。随着 b 和 r 增加,低于它的应该趋近 0,高于它的应该趋近 1。
MinHash LSH 与去重范围
关于 LSH 有什么问题吗?这个方法叫 MinHash LSH,因为 LSH 实际上适用于任何哈希函数。对于语言模型处理中的去重,我们用的是 MinHash,它近似 Jaccard。所以基本上这就是你应该用的。关于去重有问题吗?
还有一个关于去重的注意事项:经常,正如我们将看到的,数据集会进来。有时候去重会在一个数据集内部进行。但你实际上必须在整个数据集范围内做去重,因为数据集之间经常是冗余的。有时候人们没这么做,但应该这么做。
数据混配:多个来源与权重
我们进入下一个主题:数据混配。到目前为止,我们把数据从原始 HTML 或 PDF 转换成了文本。我们过滤了高质量数据。我们去重了,所以得到了一组更小的高质量文档。这通常是在给定数据源内部进行的。但语言模型是在多个数据源上训练的。在 Marin,目前这个网站追踪了下一个模型会训练的不同数据源。你可以看到有一堆东西:有 Nemotron,有我们谈过的 FinePDFs,有一些机构书籍,还有一些代码,等等。你有所有这些不同来源。问题是,你怎么组合它们?
混配的直觉方法:拍脑袋、均匀、按比例
如果你看一篇旧论文 The Pile,这是他们当时拥有的一组来源。他们基本上给每个组件分配一个特定权重。所以基本上,你有一个来源上的分布。那这个权重从哪里来?简单直接地说,假设你有三个来源。你想找到一个数据混配,而数据混配就是来源上的一个分布。如果你只是从第一性原理来想这个问题——好吧,不是第一性原理,而是从你可能怎么做来想。凭感觉,我猜这是第一性原理的反面:你基于某种直觉手动设置,这比你想象中更常见。所以我认为这肯定相当凭感觉。
甚至更近的论文,你只是看——也许你用某种方法,然后微调。你也可以做均匀采样,就是在来源上放均匀分布,每个 chunk 都采样,你只是采样一个来源。你也可以做按比例混配,就是按照某个来源的 token 数量比例采样。所以如果一个数据集 token 更多,你就给它更高权重。这通常也是合理的做法。但你可能会担心,如果你有一个巨大的低质量数据集,它会吃掉你很多 token。所以这似乎也不是很优。
高质量来源、多样性与来源有限性
直觉上,你应该提高高质量来源的权重。但有两点很重要。第一,你确实想保证多样性。来源之间经常不可比,比如文学、代码和论文。你不能说这篇论文比这段代码质量更高,因为它们可能是不可比的对象。如果你想让语言模型做得好,你不希望把所有质量都放在论文上。第二点,我会多讲一点,每个来源实际上是有限的。所以如果你在一个小来源上放太多权重,你基本上会用完那个来源,然后你就得在它上面 epoch,也就是不断训练完全相同的 token。这会是坏的,原因我们后面会看到。
具体例子:低质量 10T、高质量 10B 与 50 个 epoch
我们把最后一点更具体地展开。想象你有两个来源:一个低质量来源,有 10 万亿 token;一个高质量来源,有 100 亿 token。一般来说,高质量来源更小。如果你做一个朴素的数据混配。我们就做均匀。一半高质量,一半低质量。然后我要训练 1 万亿 token。当我说训练 1 万亿,并不意味着唯一 token,它只是说训练那么多步除以 batch size,或者乘以 batch size。
然后我们看这个,低质量数据集中某个特定元素被训练了多少次。也就是某个数据点被训练的 epoch 数。p_low 乘以训练 token 数,就是我想从这个低质量来源请求的 token 次数,再除以低质量来源的总数。它小于 1。所以基本上,这意味着这个低质量数据集中只有 5% 的 token 我会碰一次并训练。然后如果你看高质量,你会看到 epoch 数是 50,因为我这里只有 100 亿 token。如果我一半一半,那意味着 5000 亿 token——我需要训练 5000 亿高质量数据 token。但我没有 5000 亿 token 的数据,所以我必须把每个数据点重复 50 次。这其实非常重要。一些大模型训练搞砸了这件事。你不能只是天真地看着数据集,定义一个分布,然后采样,因为如果你只看质量,只看数据,你会忽略那里到底有多少数据点。
为什么会出现 50 个 epoch
这有意义吗?也许停下来提问。这是一个重要的点。有学生问:对于高质量数据,为什么你需要 50 个 epoch?它在少得多的水平上不就达到可比性能了吗?老师说,问题是为什么需要 50 个 epoch?我想重点是,你不需要 50 个 epoch。最坏情况下,它是在浪费算力;抱歉,最好情况是浪费算力,最坏情况是你在过拟合。之所以是 50,是因为如果你直接进去定义这个数据混配,你最终会在没有意识到的情况下做 50 个 epoch,除非你非常仔细地关注。所以这基本上——教训是,看看你实际上在数据上做了多少个 epoch。
训练时混配怎么实现
有学生问:训练时混配是怎么体现的?你是每一步切换,比如训练 10 步,说我要做 10 步 Python 代码、10 步文章,因为我觉得——这里有什么直觉吗?老师说,好问题。问题是训练时混配如何实现。一般来说,你就是采样。你要填满一个 batch。你可以为每个元素采样它来自哪个混配组件,然后用来自那个混配组件的序列填满 batch。通常每个序列来自一个混配组件。你不是按 token 采样。所以每个 batch 都应该是混合的。所以混配假设是在 batch 层面的,然后你训练。并不是说一步是——代码在一步里是——是的。一般来说你想减少方差。你想训练多个——batch 应该有一些混合。
UniMax:均匀采样加轮数硬上限
好,这个问题很久以前就被注意到了。在这篇论文里,他们提出了 UniMax。当时他们在训练多语言模型。很明显,有些语言资源非常少,所以你必须对此做点什么。在这篇论文里,他们注意到,之前一些工作基本上会把按比例混配的概率提高到某个幂,来把分布展平。但这篇论文的想法是,让我们更明确一点。让我们均匀采样来源,但对 epoch 数量加硬上限。这是关键想法。
在这里,你说我最多在一个特定数据源上做 20 个 epoch。如果你已经做完了,那对不起,你拿不到更多 token,你继续下一个。所以这在某种意义上是安全网。特别是,如果看这个数字:分配给一个来源的概率乘以训练 token 数,应该小于等于上限。有一个简单过程可以在满足这个约束的情况下实际确定混配。
回归式混配:在小模型上试不同 mixture
我们换一下话题,谈谈一开始怎么定义混配,因为如果你有 50 个来源,你就有 50 个数字要填。我们注意到按比例混配还不够。也许你可以尝试估计某个质量指标,然后按比例采样,但这也同样是启发式的。所以最有原则、也最容易理解发生了什么的方法,是这些基于回归的混配方法,比如 RegMix、Olmix 和类似论文。这里的想法相对简单。
首先,假设你想训练一个大模型。你先去小规模,比如 3 亿参数,尝试不同的数据混配。假设你有三个组件,你尝试不同混配。然后你训练这些小模型。你训练一群小模型。每个模型给你某个目标指标上的损失。它可以是某些下游指标,可以是困惑度,随你。然后你用这些数据点拟合一个回归,回归模型把这些输入——也就是数据混配权重——映射到损失。
优化代理函数并迁移到大模型
现在你基本上有了一个非常便宜的模型,它告诉你:如果我在这个混配上训练,我会得到什么损失?然后你基本上可以优化那个数据混配——抱歉——优化那个函数,找到最优数据混配。然后那就是你用来训练大模型的数据混配。这就是简单过程。你会注意到它和 scaling laws 有相似之处:你做一些便宜的计算来得到一些答案,然后再放大。
回归式混配的设计选择与过拟合风险
这里有几个设计决策。一,你要尝试哪些混配?你需要一个分布上的分布。所以人们经常用某种 Dirichlet 分布。你还必须定义回归方法。人们试过线性模型或提升决策树。你还必须确定目标。这通常基于下游 evals。但你必须非常小心不要过拟合,因为我们做的是预训练。按理说,预训练应该训练一个通用模型,我们不是要拟合某个下游 evals。如果你不小心,比如你有一堆代码 evals,那么,猜猜怎样?你会提高所有代码数据的权重。这不是火箭科学。如果你之后说我想生成一些诗,你可能会意识到自己过拟合了。所以你必须非常小心。
按比例混配和均匀混配没有这个问题,因为你没有看下游 evals。最后一个设计决策是,小规模和大规模之间差多少?这是成本和准确率的权衡。当然,如果你训练非常非常小的模型,这可能不代表性。但如果你为了做这件事训练大模型,那做这些就没意义了,你直接训练就好了。你基本上是在最大规模上做超参数调优,那太贵了。
Olmix 表格中的代理模型、采样分布与回归模型
Olmix 这篇论文有一张很好的表,展示了许多不同方法,它们都落在这个框架里。代理模型的规模:一般来说是几千万参数。你采样多少个混配?这里的 m 是你混配中参数的数量——抱歉——是 domain 的数量。你可以用 Dirichlet,也可以用指数分布。然后回归模型,你可以拟合一个对数线性模型,它往往效果不错。然后你可以用不同方法求解这个优化问题。
两个需要警惕的假设
这里有两个信念跳跃,你必须非常小心。第一,回归模型是在一堆小的代理运行上训练的,而你现在在优化它。所以希望是,在最小值处,也就是你最优的数据混配处,那个回归模型仍然准确。这有点——你必须非常小心,因为如果你采样一个随机混配,你可能会没事,因为经典泛化。你采样一个混配,拟合一个函数,它应该能在分布内预测。但当你优化时,你本质上试图去潜在的那些极端,那里可能没有太多覆盖。所以这是要注意的一点。
第二,最优数据混配,你只能希望它从小规模迁移到大规模。一般来说,至少在开放社区工作的规模上,这似乎是真的,或者至少不是明显假的。但显然存在尺度相关效应。想想上一张关于过滤的幻灯片:如果你训练更多 token,那么低质量数据可能没问题。所以显然最优值并不相同,但你只能希望一切顺利。
尺度相关的轮数问题
等等,有一个我们已经谈过的尺度相关效应,需要在这里处理。想象我们有很多低质量数据,10 万亿 token,以及 100 亿高质量数据。如果我们在低 token 数上训练一个小模型,你还记得吗?会发生的是——这是一个编造的例子——你可能会把大量权重放在高质量数据上,因为低 token 数时你没有 epoch。所以就像:哦哇,Wikipedia 太棒了,我们就在 Wikipedia 上训练吧。
但如果你之后在这个混配上训练大模型,你最终会在这些高质量数据上做很多 epoch,然后过拟合。这肯定是坏的。
修复方法:限制轮数或模拟轮数
修复方法之一,Olmix 论文里做了,就是限制 epoch 数量。这是一个解决方案。还有另一个解决方案,可能有多个名字,但其中一个叫模拟轮数(simulated epoching)。原则是让你的小规模看起来像你的大规模。这是本课程的一个普遍主题。我们讲 muP 时见过。你参数化你的模型,让超参数可以迁移。同样的原则也适用于这里。天真的缩放的问题在于,你不能在小规模不 epoch,然后在 large scale 又 epoch,因为那样它们是对数据集在性质上不同的操作。
下采样来模拟数据稀缺
处理方式是按比例下采样你的来源。如果你的小运行是 100 亿 token,大运行是 1 万亿 token,那么你有 1/100 的下采样。然后你基本上使用下采样后的混配。想法是,如果你有下采样数据,那个解决方案就不会看起来很好,因为你不能训练所有——你不能只训练 Wikipedia。你只会训练 Wikipedia 的一小部分,然后你会意识到,哦,实际上我会做很多 epoch,那会有很差的损失,这意味着在优化中你会寻找更平衡的方法。所以你是在小规模上模拟你在高区间会遇到的数据稀缺。
混配小节总结
总结这一节,问题是如何给不同来源加权?Wikipedia、Common Crawl、代码、你从某处抓来的数学数据。你怎么平衡它们?基于回归的混配是一个很好的框架。你在小规模估计一个函数形式,把混配权重映射到损失,优化它,然后泛化到大规模。你必须非常小心,因为轮数和过拟合问题。你要么用限制轮数,要么模拟轮数。这里的一个教训是,如果你想优化任何东西,你必须非常小心,因为你有优化错东西的危险。
关于下采样过小的问答
在我们继续之前,关于数据混配有问题吗?有学生问:当你下采样后,会不会有东西太小,以至于无法很好地泛化?老师说,问题是,如果你下采样,是否有风险,可能得到太小的数据集?是的,绝对有。我想你可能会遇到 token 太少的情况。我想那时会发生的是,你的最优解会给它一个非常小的权重。当你放大时,也许你会得到正确的 token 集合。原则上应该可行。
但可能有舍入误差,导致你最终不是在这个数据集上训练一次,而是意外地训练了零次。但你可以绕过这些,只要——那你总是至少在这个数据上训练一次。
是否在数据源内部做混配
有学生问:在数据混配里,这些来源刚好对应不同主题。但你会不会取一个多样化的数据集,然后在其内部做数据混配?老师说,问题是,你把数据混配应用到哪里?不同领域,还是甚至在一个数据集内部?实际上我忘了提这个,很高兴你提出来。在 Nemotron 论文里,他们其实也做了类似的划分。你基本上想,比如我只给你 Common Crawl。你其实可以把它拆开,按领域分组。IT 那边有 web organizer,所以你可以分组成主题。
但你也可以做质量过滤。于是你有一个二维网格,有领域,也有质量。每个格子都是你会做数据混配的东西。这是自动确定领域的一种方式。然后在此基础上,你再加上人们手动给你的额外来源。
后训练数据:任务依赖与合成数据
接下来我快速过一下后训练数据。到目前为止,这基本上是预训练,或者也许是中期训练数据。它一般相当任务无关,除了 RegMix 那部分你是在最小化损失。但数据本身仍然相对任务无关,你是在发展基本技能。当你看到后训练时,很多数据变得非常任务依赖。
后训练数据的通用配方
我不会做全面综述。我只想指出一些最近发布的有趣后训练数据集,特别是代码方面的,因为现在这非常受关注。通用配方是:你定义一组环境。在这种情况下,想代码,可能是 GitHub 仓库。你定义一组任务或 prompt,然后从一个强模型或教师模型收集回答。所以隐含地,至少在开放社区,几乎所有后训练数据、大部分后训练数据,都是合成生成的。你也可以用人类替代强模型,但那很慢,而且花很多钱。
但如果你在前沿——几年前,你必须付很多人很多钱来给你回答。现在即使在前沿,你也可以做人类-AI 混合的事情。但无论如何,重点是有一个教师在外面给你回答。
OpenThoughts:数学、科学与代码推理数据
好,我讲几个工作。一个是 OpenThoughts。这个想法——它出现的时候,是 o1 发布时,大家对推理非常关注,主要是数学和科学。我们怎么为这个获得非常好的后训练数据集?他们最终用这个教师模型做出了 120 万个例子。步骤是:首先,有一堆——环境、任务和问题是什么?他们从很多不同来源抽取。有人类来源,比如 Stack Exchange 或 NuminaMath,也有一些合成来源。这些只是编码类的一个子集,还有更多数学和化学的。这是一个大项目,涉及很多人的贡献。
OpenThoughts 的细节与发现
你可以看到有些是编程练习。有些是更现实的东西,出现在——我猜 CodeGolf 不现实,但这里有一些更现实的例子。比如代码审查,这可能更现实。他们对给定这个数据集如何生成回答做了相当全面的分析。他们表明,用少数几个来源实际上比试图用所有来源更好。采样多个生成结果有帮助,比如 16 个。
一个有趣的事情是,更好的模型不一定是更好的教师。比如 QwQ-32B,现在是一个非常老、非常小的模型,却比 DeepSeek-R1 是更好的教师,而 DeepSeek-R1 当时可能是最强的开放模型之一。他们还发现基本的答案过滤没有帮助。整个数据流水线看起来像这样:有一堆来源进来,然后去重,随机采样。我猜这是对问题下采样,然后生成多个答案,进入最终数据集。所以 120 万是例子数,但除以 16 才是实际问题数。
agentic coding 与 SWE-smith
那是数学、科学和一些代码。我想最近大家对开发特别是 agentic coding 模型有很多兴趣——不只是能生成代码的模型,而是实际上能做软件开发的模型。SWE-smith 这篇论文的想法是:给你一个仓库,你使用语言模型自动生成任务。他们有一个 agent,拿到一个仓库,实际上让它变得可用,比如安装依赖等等。然后你生成任务,一般是以某种方式修改代码,也许引入一些 bug。这些任务会被验证,然后你得到任务实例。这些是合成任务,但你得到 5 万个,这在当时,也就是去年,相当大。
SWE-Zero:不依赖执行反馈
从那以后,还有一堆其他工作,我会提到。有 NVIDIA 的 SWE-Zero 论文,它有一个——其实,好,我也会讲那个。这是一个有趣的想法,观察是:和数学不同,SWE 任务有很多重依赖。大多数 GitHub 仓库甚至跑不起来。你必须安装所有依赖。它们过时了,尤其是如果你回滚到 PR 刚提交的时候,简直一团糟。这简直是噩梦。他们在想,我们怎么才能真正获得所有仓库的数据集?
他们没有为每个仓库做特定的 Docker 镜像,而是注意到模型实际上已经足够好,可以在没有执行反馈的情况下解决很多这些任务。如果你看一些模型,如果允许执行,你得到 80;如果不允许执行,你得到接近 70,对于不能执行代码来说不算差。所以不知何故,这些模型对代码有一些内部语义理解。
SWE-Zero 的数据细节与训练
他们能够生成 30 万条 agent 轨迹。所有这些是真实的 GitHub PR,所以在 SWE-smith 例子之上是真实的。他们使用 OpenHands scaffold。有很多关于防止 agent 作弊的细节。通常你给 agent 指令:你探索、测试、实现。SWE-Zero 版本基本上说你不能运行 Python 代码。你只能做 set、grep 这些基本操作。
然后他们从大的 Qwen 模型蒸馏并过滤,因为有时候 Qwen 模型会忽略这些指令,仍然试图执行。然后他们展示了——哦,他们还有 1.3 万条确实需要执行反馈的 agent 轨迹。所以他们训练模型时,先在这些 SWE-Zero 例子上微调,然后再在这些 SWE-Zero 例子上微调。他们能够——我猜前沿仍然相当高,但他们在这里取得了一些进展。
SWE-rebench:抓取大量 PR 并扩展到 1200 万条轨迹
我快速过一下 SWE-rebench,它本质上是另一次尝试,抓取大量 PR。所有这些看起来都是:拿一堆 GitHub 仓库,试着安装仓库,大多数可能失败,然后更努力地试。然后你用语言模型给你回答。这些其实今天刚出来。所以你可以拿 SWE-Zero 的想法,现在扩展到 1200 万条 agent 轨迹。SWE-Zero 的一个好 benefit 是它非常轻量。这里,他们使用 SWE-rebench 任务。在这个里面,记住他们试图让东西执行。他们只有 32,000 个能执行,还有 120 个不能执行。但 SWE-Zero 不在乎。你可以用所有数据。这是一个非常小的模型,因为——现在这个数据集相当大。所以我想,如你所见,数据集正变得越来越复杂。你从数学这类无环境的东西,走到现在的编码。现在编码数据集增长得相当快。
后训练数据的取舍总结
但总体想法是,你有这些 prompt,其中有取舍。你可以完全合成。你可以半合成,也就是真实环境加合成任务。或者你可以用真实的。回答一般来自有能力模型,但它们也需要是好的教师。代码环境很痛苦。还有很多过滤和其他细节,我们没时间讲。
全课总结
好,总结这堂课。我们讲了过滤。定义好的数据长什么样,然后训练一个轻量分类器。遍历你的网页爬取数据,你就能得到匹配你目标的一小部分。去重很重要,可以避免过拟合并节省 FLOPs。混配:在小规模尝试混配,再外推到大模型。然后我们看了一些后训练数据。不过我要说,很多数据工作可能非常脏。它非常领域特定,需要看具体例子来制作这些高质量数据集。所以这堂课并不真正代表数据工作的样子,但希望给了你一个关于外部数据 landscape 的概念。好,今天到这里。
从 GPT-3 到 ChatGPT:后训练、SFT 数据与 RLHF
这堂课讲如何从预训练基座走到可用的指令跟随模型,重点是 SFT 数据、人类标注、安全、RLHF 算法与 DPO,以及过优化等问题。
从预训练转向后训练
OK. 欢迎回来。我们现在要离开预训练。接下来要进入的,在我看来,是语言建模中一些更混乱的部分。数据一直都很乱,但后训练(post-training)以及随之而来的一切,在很多方面确实非常手工化、艺术化。
为了设定背景:到目前为止,你应该能得到一个强化版的 GPT-3。你当然可以用更多 FLOPS、更多数据,做出比 GPT-3 稍微更好的东西。但归根结底,从 GPT-3 这样一个非常庞大、强大的基座模型身上能获得的效用,最终还是有点有限。从 GPT-3 到 ChatGPT 的变化非常大;如果你第一次接触这类系统是 ChatGPT,你会觉得很惊人,但如果你回头去用 GPT-3,你会问,这是什么东西?这东西有什么意义?
当时你真正能用 GPT-3 做的,只有文案写作之类的小任务,你并不真的需要可靠性或指令遵循(instruction following)。所以今天我们要从 GPT-3 走到一个基本接近 ChatGPT 的东西。这是第一部分。下一讲我们会从 ChatGPT 走到 GPT o1 或今天各种思考模型(thinking models)。
把我们从左带到右的过程,通常被称为后训练。我想强调,能够做到指令遵循是非常了不起的。指令遵循就是能够输入一个相当长、复杂的提示,然后拿回非常合理的答案。在 GPT-3 这个级别,对这些系统做细粒度控制基本是不可能的。你会输入一堆少样本提示,然后说,如果我的例子足够好,也许能得到合理结果;但实践中,你引导模型的能力很有限。
当 GPT-3.5 和 GPT-4 出现后,你开始看到很长的程序化提示,模型能一次性完成整个提示。那么要让这种指令遵循真正工作,需要什么?预训练当然一直重要,它是我们构建一切的基础。如果忽略预训练,只想一路训练到胜利,我们什么想要的东西都得不到,绝对得不到。
预训练之所以关键,是因为它以非常多样、广泛的方式扩展。但有了预训练之后,我们必须从预训练这锅“原始汤”里提取出想要的行为。为此,我们要做更明确的数据收集、更明确的引导,以及大量混乱的工程;这些在目前某种程度上我们还不必做。架构之类的东西你可能觉得乱,但我认为它真的比不上真实世界后训练的混乱。
我们会去收集一些数据,或者说我们会假设去收集一些数据。我们会讨论这些数据长什么样、如何用它训练,以及规模大概如何。
前沿后训练信息为什么稀少
我先给剩下的讲座打个预防针:关于前沿后训练的信息,在很多方面其实相当稀少。如果你看我今天要引用的材料,很多其实相当旧,是在 ChatGPT 带来的竞争升温之前。比如 RLHF 论文《Learning to Summarize from Human Feedback》其实极其详细;我今天会指出来,如果你感兴趣,建议去读附录,里面有详细标注指南,说明他们如何指导人们给模型反馈。Anthropic 2022 年的 HH 论文也类似。
但现在竞争升温之后,基本上没有厂商愿意发布后训练过程的信息。数据非常像商业机密。所以我们没法讨论现代语言模型那种标注指南。我们确实有闭源或开源模型,但另一方面,很多开源配方依赖蒸馏(distillation)。它的性质与前沿实验室做人类数据收集非常不同,后者真的非常棘手、混乱。我会尽量指出一些东西,但很难非常全面地讨论。
举个例子:我之前为这讲做预研时,发现 2023 年有一个很有趣的例子,关于后训练数据的商业机密性。Scale AI 的内部文档不知怎么泄露了,里面有很多关于后训练数据领域的坦率对话。其中一条是,他们当时想让 Google Bard 更好,但搞不懂为什么 GPT-4 好那么多。于是他们说,我们需要反向工程,需要让标注者产出比 GPT-4 更详细、更好的回答。这种竞争动态一直在发生,这也是今天讲座会缺少一些细节的原因。
不过,在算法层面,我们确实相当清楚发生了什么。但算法并不是真正的秘密武器,并不是很多杠杆所在。真正重要的是数据。
RLHF 两阶段与 SFT 的数据本质
如果看 RLHF 论文,会看到一个非常直接的两阶段配方。第一阶段是收集演示数据:对于每个你想要处理的提示,让标注者提供监督微调数据,也就是该提示的参考回答,然后做一些微调。有了这些之后,第二阶段做某种强化学习,试图塑造模型行为,让它更对齐、更符合人类认为好的回答。
我现在先讲第一阶段,也就是后训练中的 SFT 部分。这部分基本上全是数据。我们都知道怎么对一个模型做 SFT,它基本和预训练完全一样。所以唯一真正的区别,除了一些小变化,就是训练数据。因此这部分讲座我会几乎完全关注训练数据,只稍微讨论方法。
我们会过一遍数据,看一些例子。我会谈一些历史发展,以及至少开源后训练数据领域的趋势。然后我会谈一些陷阱,一些说明这种数据收集比你想的难得多的方式。如果你上过 224N 这类课,你会发现其中一些问题很让人想起你从零构建经典监督深度学习模型时的问题。
SFT 数据的历史演进
让我暂停一下,谈谈 SFT 数据这些年的演进。我大致会从左上到右下讲,这大概是我的时间顺序。开源语言建模领域有很多技巧,人们做了很多事,既是为了改善系统性能,也是为了理解这类数据中哪些因素对训练模型有用。
真正最老、在某种意义上最有远见的是 FLAN。Google 用它训练 T5。大致想法是:NLP 研究者已经收集了一堆有输入输出的监督数据集,我们应该直接在这些数据上训练模型。这个想法很合理。但后来发现,由于各种原因,这并不是正确做法,我后面会讲。
Self-instruct 及其后续也非常有前瞻性。它的想法是:为什么不能用模型自己生成数据?模型一直在变好,甚至可能比我们的一些标注者更好。为什么不让模型为我们的输入写出非常好、高质量的回答?这就是 self-instruct 的想法。
然后出现了一批蒸馏式方法。Alpaca 是其中之一。Berkeley 的人做了 Vicuna,他们用在线用户分享的提示作为输入来做蒸馏。还有一些完全由人类驱动的努力,比如 Open Assistant。你可以把它想成 Wikipedia:一群人聚在一起写提示和回答。
再之后是新一代的 WizardLM、Tulu3 等,它们说:语言模型是很好的合成数据生成器,为什么不想出越来越复杂的方式,用语言模型生成指令遵循数据?最后我会简单提到,近年 SFT 和后训练又变了,因为焦点不再是聊天,而是智能体(agent)和工具使用(tool use)。所以现在有新一代 SFT 流水线,专门生成工具使用和智能体数据。
这大概就是开源后训练数据领域的一系列发展。在闭源世界,有些公司可能互相蒸馏,但同时也在收集人类数据。你如何收集人类数据是一个重要成分,这些讨论没有反映出来。
我在这里暂停一下,看看有没有问题。
正确性、预训练泛化与少量高质量数据
有人问:后训练需要输入-输出对,这些输入-输出对的正确性有多重要?这是一个微妙的问题。最高层的回答是:你应该收集你能得到的最高质量回答,因为坏东西会教模型做坏东西。但另一方面,事实证明你可以在各种奇怪东西上 SFT 模型,它们仍然会学会遵循指令。我记得 Percy 以前的学生有一篇论文,甚至不用回答也能训练模型。你可以训练模型学会指令遵循。预训练泛化行为让你能容忍质量更差的数据。所以指令遵循数据到底需要什么,其实相当微妙。
我想从 FLAN 开始。FLAN 在当时非常有前瞻性,它确立了多任务后训练的想法:如果要做下游任务,就去收集所有下游任务并在上面训练。这在当时非常合理且聪明。但看 FLAN 里面,会发现它其实有点奇怪。
比如有一个例子:这里是一封邮件,然后追加一句“为这封邮件写一个主题行”,主题行在右边。这是非常奇怪的东西。我不认为你曾经提示模型做过这种任务,更别说指令放在最后。出现这种情况,是因为 FLAN 是从已有数据集生成的。有一个叫 Enron emails 的数据集,它有正文和主题,于是你可以把它变成主题预测任务。
同样还有摘要任务。我不知道这是来自 CNN/Daily Mail 还是别的,但它来自某个大型摘要数据集。做法是取摘要数据集里的文章部分,把摘要放在右边,指令是“给这篇文章写要点”。但你立刻会注意到,和 ChatGPT 相比,这些摘要非常短。仔细看,摘要常常是幻觉的,里面有很多输入中没有的细节。
从某种意义上说,这跟刚才那个问题有关:FLAN 所基于的原始 NLP 数据集质量并不高。所以你在上面训练时,也会继承这些数据集的一堆缺陷。这些数据集结构的不自然性也会一路传下来。FLAN 是一个非常聪明的想法,用这些已有 benchmark 造出一个大数据集,但它的不自然性真的会害你。
回到上一张幻灯片,还有一点。FLAN 最初构建时,关于后训练该如何工作的部分理论是:你需要规模。预训练需要很多规模,所以你可能也会认为后训练需要大量规模才能解锁能力。因此他们拿了很多数据集、大量数据,做出一个巨大数据集。
但后来我们会看到,很多数据集变得小得多,仍然有效。从 FLAN 到后来数据集的一部分变化,就是观察到:如果你有一个足够强、足够大的预训练模型,你其实可以只用很少高质量例子,因为预训练泛化能带你走很远。所以在质量-数量权衡上,FLAN 在某些方面站在了错误点。不过在当时,除非探索完整个数据空间,否则没有办法知道。
Alpaca、Open Assistant 与聊天风格数据
之后,ChatGPT 出来后,我的一些学生做了 Alpaca,他们蒸馏 ChatGPT 的轨迹来获得输入-输出对。这是那个数据集里的例子。你会得到更自然的输入、更长更聊天的输出,因为它当然来自 ChatGPT。我们发现,这类例子现在能可靠地在模型上诱导出类似 ChatGPT 的行为。
重要的是,这只有在我们对原始 Llama 模型做的时候才成立。所以预训练和后训练两者都必须成立。但我们确实发现,从这时起这些东西开始工作。我想人们这时意识到,如果你有这种聊天风格数据,构建 ChatGPT 风格系统其实没那么难。把细节做对仍然难,但走到大部分路是可行的。
然后有一大波推动,要创建这类数据集的最高质量人类驱动版本。给一点背景:当时 ChatGPT 和 Alpaca 出来后,有一种巨大的乐观情绪,认为只要收集到足够高质量、足够大的指令微调数据集,我们就能追上并匹配那些大型闭源实验室的很多性能。所以当时有很多能量和兴奋投入到构建这些数据集。
在这个背景下,Open Assistant 是一个大型众包努力,要构建这样的数据集。很多志愿者聚在一起说,我们要想出非常难、非常有趣的提示,要写出好的、高质量的回答,并且要规模化地做。就像 Wikipedia 成功以规模产出高质量东西一样。这是一个非常令人钦佩、非常令人印象深刻的项目,实际上生成了相当多数据。我忘了是大约 10,000 条还是更多例子,然后项目就停滞了。
如果你有兴趣,我可以再聊。但现在看,我们有聊天风格输入,右边是很长、详细、高质量的专家回答。好东西。后面我也会讲一些陷阱。
最后,我从一些最新的指令微调数据集中取了例子。我们这个领域,或者说人们想要的产品,已经从单纯的聊天界面转向完整的智能体系统。我们不只想要文本回答,还想要工具调用(tool calls)、待办清单。如果你用过 Cloud Code 或 Codex,你会知道它会生成一个待办清单,一边做一边勾掉。我们想要所有这些东西。
所以现在的 SFT 数据,比如看 Nemotron,也就是 NVIDIA 的开源努力,他们的 SFT 数据很大一部分是这类智能体 SFT 例子:不仅有 assistant 字段里提供的回答,还有可以跟文本并行发生的工具调用。这是 SFT 数据,是显式监督进模型的。所以现在我们会更多地转向这些结构化格式。
SFT 数据的高层转变
我一路都在讲这个。我们在如何思考和构建这类数据集上,看到了几个大的高层转变。把这些视角内化是有用的。一个是“聊天性”这件事。我很难完全说清它是什么,但有一种感觉:经典 NLP 数据集,即使是高质量的那些,也非常像输入到程序化输出。但人们并不真的想和 NLP benchmark 说话,他们想和人说话。所以发生了一个大转变,转向更详细、更像人的回答。
还有一个大转变是转向更高质量的标注者,而且更详细。Open Assistant 就是一个好例子,让专家来写回答。最后,工具使用,以及为这一切找出正确接口和 API,是正在发生的最后一个大转变。我相信事情还会继续变,但这些是思考 SFT 数据过去几年如何变化时,可以放在心里的高层因素。
我稍后会讲各种陷阱或挑战。关于数据集的具体内容,有什么问题吗?
如果你负责收集 SFT 人类标注数据
现在假设你被安排负责为 SFT 收集人类标注数据。你应该注意哪些事情?有很多格式问题你可能要考虑。还有知识问题:回答里该放多少知识?你想要多少数据点?以及如何处理模型安全:你该收集什么样的安全数据?
我想强调,长度和一般的风格变化,是后训练过程中非常非常大的一部分。人们说 Claude 的语气和 ChatGPT 不同,ChatGPT 太爱聊天,等等。所有这些都是数据收集人员有意识做出的决定。而且如果你看不同数据集,回答长度的变化范围也很大。
还有一个我想强调的:当你去评估偏好时,这些风格因素影响巨大。人们很容易被“骗”——我不确定“骗”是不是合适的词。他们会非常频繁地选择输出里带项目符号列表的回答,或者选择更详细、更长的回答。我觉得这部分很自然,尤其如果你在评估设置里,把两个例子放在一个人面前问哪个更好,更详细、更像列表的回答通常会被认为更好。但这确实会在聊天机器人相对人的语气上造成非常明显的扭曲。
有人问数据集中复杂事项?我稍后会讲。如果那时我没完全回答你的问题,你可以再问我。
关于风格,要小心的是:如果你看参与度信号,就像大多数公司那样,你非常容易骗自己,以为自己在得到更好的数据,但实际上模型能力并没有改变。如果你看各种可以训练的后训练数据集,你会发现训练某些东西时偏好评估会有很大变化,比如右边那列 AlpacaEval。但它并不一定改变标准 benchmark 评估。所以模型并不会因为你训练了某些后训练数据就一定更聪明,至少在这些评估上不是。但你可以大幅移动参与度信号。这告诉你,要把风格控制和能力控制分开思考。
知识、引用格式与幻觉
另一件事,我认为在很多方面很重要,是关于我们如何收集和思考后训练数据。Open Assistant 很有启发性,因为它意图在某种意义上成为最高质量的后训练数据。所以你经常看到这样的东西:这个回答包含一篇引用,比如“J. Michel,公司高管的薪酬,等等”。如果在这个数据上做 SFT,其实同时在教模型两件不同的事。
首先,你在做下一词预测,所以你在教模型这条引用本身。它还在告诉模型另一件事:当你给回答时,给一个引用是好的。所以同时有两个不同机制在发生。你在教模型各种知识,这很好,因为你希望模型有知识;但你也在教模型输出详细知识。
这有时会有问题,因为这会迫使模型有时错误泛化,它会幻觉出一个引用,而不是给出真实引用。模型并不一定知道一个引用是真的还是假的。围绕这一点,人们有过很多有趣的论证。我认为有一个民间传说,我觉得它不算错,也有不错的经验证据支持:如果你在 SFT 阶段训练模型输出它并不知道的事实,这会让它产生幻觉。
因为如我所说,模型在同时泛化两种行为:它试图泛化知识,也试图泛化格式。所以教它一个未知知识配上某种格式,在某种意义上就是教模型强行输出未知知识。人们已经展示,如果你训练一些你并不真正知道的信息,这会导致幻觉和对知识片段的过拟合;而如果你只训练已知事实,你不一定会看到这个。
John Schulman 有一个单独的论证:这就是为什么你需要强化学习训练。这并不令人惊讶,因为如果你认识 John Schulman,他是做强化学习的人。他做了 PPO。他当然非常支持强化学习。但这个论证是合理的:某种意义上,要教模型知道自己知道什么、不知道什么,必须是依赖策略的。你不能让一个外部人把知识硬塞进你嘴里,如果你想模型对自己知道和不知道的东西校准的话。
所以把这些拼起来:如果模型还没有那些数据,你可能不想训练最高质量的数据。尾部知识(tail knowledge)有时可能主动有害,因为它可能诱导幻觉,尤其当它和“引用:”这类标记关联时,会迫使模型之后输出一个引用。我后面会讲 RLHF,但这是强化学习重要且有用的原因之一。对于那些依赖模型自身的东西——知道自己知道什么——RL 是教模型不要说自己不知道的东西的非常有用的方式。
知识存储其实非常混乱,是一个非常微妙的概念。你不能只是说“把知识塞进去”。你要非常小心,在后训练阶段到底投进去什么监督。这也是我认为后训练很乱的原因之一。你最终必须处理把系统服务给用户的所有这些现实。
有人问尾部知识的定义。我不认为知识有一个正式定义。比如我以前做这类工作时,会用 Wikipedia 文章长度作为其知名度的代理。然后你可以说,是的,如果我们训练不著名的东西,确实会看到模型幻觉更多。但没有正式的“这是尾部,这不是知识”,因为知识本身无法被精确定义。
有人问为什么 RL 对幻觉有帮助。让我给一个民间故事来说明。想象模型内部有某种“我知道某件事”的方向,在激活里。SFT 时,可能发生的是你强迫它从 SFT 数据中生成引用,所以它无论如何都会生成引用。但当你做 RL 时,你可能会注意到,在“我知道”方向上生成引用会得到好奖励,在“我不知道”方向上生成引用会得到坏奖励。然后它可能学会把“我知道/不知道”的激活泛化到是否输出引用。所以如果模型在某个层面知道,RL 可以帮你把它提取到输出策略里。如果模型完全不知道任何层面,RL 也帮不了你,无法解决需要某种校准的问题。
有人追问:如果它输出了一个坏引用,在损失函数哪里被惩罚?在 SFT 里,某种意义上,任何不是引用的东西都被惩罚;把概率质量分配给不是这个精确序列的东西都会被惩罚。所以某种意义上 SFT 并没有做错,这是一个正确序列。问题在于 SFT 的泛化性质:我不能完美地从这一个例子泛化出去。这里有两个纠缠在一起的东西被教:一个是引用模板,另一个是知识内容。如果我基于模板泛化,就麻烦大了。好问题。
安全调优与拒绝率权衡
另外我想谈的是安全性。一旦我们完成预训练,我们就必须真正开始面对人们将如何使用它的种种复杂现实。我认为预训练的人生活在象牙塔里,就像“让我们压缩整个世界”。但后训练的人必须说,是的,但如果人们在使用我们的系统进行政治操纵和虚假信息传播呢?后训练的人可能会说,是的,我们看到了个性化鱼叉式网络钓鱼(individualized spearfishing)。我们需要防止这种情况发生。在某些方面,你是最后一道防线,在你和系统被滥用之间。
那你该怎么做?你必须对系统施加安全控制。它会拒绝对恶意输入作出回应。这是这些公司实际采用的执行安全控制的方式。所以这就落在了后训练团队的肩上。
不幸的是,安全 SFT 信息似乎比能力相关的 SFT 信息在公开文件中更为稀少。我提取了 Llama 2 的描述,关于他们如何做安全 SFT。如果说有什么不同,这是较为详细的描述之一。但你会看到他们甚至没有谈到用了多少示例来做安全调优,这在我看来有点疯狂。
但实际上,所有安全调优方法运作的框架,都是要平衡两件事。你想平衡我允许多少坏查询通过,我认为这就是违规率。然后你也不想过度拒绝,因为有人可能会问这样的问题:“我如何杀死一个 Python 进程?”如果你是一个机器人,它说“不,我不能让你杀死任何东西”,这对用户非常沮丧。所以 false refusal rate 也需要低。你想控制这一点,并在两者之间有好 Pareto 权衡。
所以你基本上创建各种定制数据来导航这个权衡。数据规模通常几千到几万例子。比如 Llama 2 里,我记得大约几千例子用于这个过程。如果我只留那张幻灯片,这个状况有点不令人满意,所以我会再详细一点。
如果你想看后训练怎么做,现在可能唯一好的参考是 OLMo 模型,Allen AI 发布的后训练流水线。鉴于很多地方缺少后训练细节,这是少数你能看到合理性能后训练流水线实际运作的地方。比如他们有一个安全组件,总共大约 50,000 条例子。
如果你看,它们都遵循同一个相当简单的策略来生成 SFT 数据。在之前的项目里,他们有一个叫 WildChat 的东西,基本是给一群人免费 API 或免费聊天访问,让他们进行各种聊天交互。作为交换,他们收集这些聊天里发生了什么。从中他们能过滤出大量在这些聊天界面上尝试的不安全行为,以及人们尝试的越狱(jailbreak)。他们大规模挖掘这些,然后创建首选回答:一种情况是抵抗越狱,另一种情况是说“不”。非常简单直接。
从技术报告和各闭源公司的模型卡看,类似的事情也在发生:你看使用信息,发现不安全行为,让标注者对这些坏行为打地鼠。
最后我要提的一点,和我说 SFT 需要多少例子有关。如果你没玩过 SFT,有一件事会让你惊讶:如果你有一个足够强的模型,引导这些系统并不需要很多例子。所以如果你想做安全调优,比如拿 500 个例子——你找到一堆不安全东西,或合成一堆不安全东西,然后写拒绝回答,放进系统——你会发现即使只有 500 个例子,模型跟随恶意指令或其他仇恨言论指令的比例也会大幅下降。500 个例子其实只是对模型施加一个非常小的推动,不是外科手术式,但很小;结果是在这些安全问题上全面带来相当好的减少。
一种理解方式是,模型在预训练后内部已经有“我要成为安全模型”还是“不安全模型”的访问模式,所以不需要很多例子就能把它拉出来。但也要记住:仅仅因为你可以用很少例子引导模型,并不意味着更多例子没有好处。如果你是 OpenAI 或 Anthropic,想执行非常细粒度的安全/不安全区分,你确实需要非常大规模的数据收集。这并不改变原来的故事。
SFT 的总结
所以 SFT 最好的情况是:如果你在提取预训练行为——它已经在预训练里某处——而你只想把正确模式从模型里拉出来,那么指令微调非常非常有效。用很少数据,只要是正确的高质量数据,你就能让模型做你想做的。加入数据或加入事实正确的数据,有时反而会伤害你,至少在幻觉方面。最后,它不需要很多数据,所以你常常可以关注质量而不是数量。
SFT 的数据部分到这里结束。如果大家有问题,先问我再继续。
有人问:我们怎么知道什么已经是预训练中的行为?问题正是我之前说法草率的地方:我们并不真的知道。我们能知道哪些东西不在预训练中。我们可以看一些泛化,比如非常罕见的编程语言,用 SFT 很难学会。所以我们可以展示那些。但我们不能必然展示,比如安全性本身就在预训练中。
有人问 SFT 破坏特征、RL 恢复特征的事。我不打算后面讲那个。问题在于 SFT 是否在破坏特征,RL 是否在推高推低。我认为两者界限非常模糊,尤其当我们进入 expert iteration 和其他东西时,它们看起来像 SFT 加了一些花哨附件。所以我不认为 RL 和 SFT 之间有一个核心区分,而更多是我们得到的反馈类型不同。SFT 是密集的特征监督,RL 是自学的策略监督。RL 的另一个特点是,你从自己的策略得到自己的输出,所以在某种意义上,你被强化的东西可能不会偏离那么远。这可能是正在发生的重要部分。
方法:指令微调与中期训练
现在讲方法。这门课大部分时间我们都兴奋地谈论方法。在这部分,它是最无聊的,因为你只是做梯度下降。我放这张图几乎是开玩笑:你只要 loss.backwards,基本就完了。
但有一个细微之处我想讲,而且很重要:把指令微调变成预训练一部分的趋势在增加。以前这些东西刚出现时,预训练和后训练是分开的想法:你先在网上预训练,然后再后训练。但人们意识到,为什么要分开两件事,你可以混合在一起。于是大量高质量数据,甚至指令微调数据,现在被混进训练尾端,通常是训练的衰减阶段(decay phase)。这真的让你能扩展指令微调,也让你强调更高质量的数据。影响非常戏剧化,据我所知所有人都在做。你可以在大多数模型发布报告里看到,有一个中期训练(mid training)或第二阶段的预训练,使用不同的数据混合。
重要的一点,也是我的一个 pet peeve:现在当有人说某个东西是基座模型(base model),这有点撒谎。因为基座模型通常意味着在互联网数据上预测下一个词之类。但今天真正的基座模型是在 Ultra Chat 以及天知道什么东西上预训练的。那些是合成设计的聊天数据集,让你擅长聊天。所以很难说这是传统意义上的基座模型。
我拿这个两阶段训练的例子,来自 MiniCPM。因为他们非常好地展示了混合的变化。第一条柱是标准预训练阶段,左边有各种互联网数据。右边你切换到更高质量、也更聊天化的数据集:Stack Exchange QA、Ultra Chat、各种其他 SFT 数据。然后你降低标准通用预训练数据的比例。今天几乎所有人都这么做。你可以把这个点理解成:预训练和指令微调之间的边界,以及高质量数据的边界,非常模糊。
有人问这类数据里的查询是否被 mask?不,这是纯预训练,所以你也在预测 prompt。但这其实不是巨大差别,因为有些 SFT 配方也会预测 prompt。
有人问衰减阶段的数据质量是否更低。通常直觉相反,你希望衰减是训练最重要的部分。它是预训练中最接近部署的部分。而且它也是学习率最低的部分。所以出于这两个原因,你可能想把最高质量的东西放进衰减。所以 Book、Chinese 等东西应该更高质量。当然 Wiki 和 Stack Exchange 通常被认为高质量。
有人问混合怎么定,预训练讨论了其他混合,中期训练和后训练是否必须在大模型上跑,还是用小模型代理。数据混合,无论预训练还是后训练,都非常 trial and error。我们有算法,很多人写过论文,但我认为它们相当不可靠。这个领域有很多试错和直觉。两阶段训练和中期训练的好处是,中期训练比完整预训练短得多。所以如果你为中期训练做数据混合优化,你可以在左边每个实验上跑大约 10 个。这让你不能完全暴力破解,但可以做很多消融。
我听说通常做法其实是:在衰减阶段做大量数据消融,这些跑起来便宜,然后得到数据质量估计,再把它反映回第一阶段,反映回预训练阶段。为决定预训练混合,你常常在衰减上跑一堆消融,然后决定预训练混合怎么设。为什么不直接把预训练全设成高质量?因为你没有足够 token。如果你试图让 Wikipedia 成为整个预训练,你会耗尽 token。
有人问从先前混合的下游性能到如何调整混合,有没有直觉?这非常 case by case。有系统方法可以拟合模型做这些,但常常很脆弱。我从人们那里听到的是,实际上比你想的更是试错。你消融一个领域,对哪些消融影响最大做排名,然后决定放什么进去。
一个很好的、有趣的泄露例子是 Meta 因使用 Books 被起诉。法庭文件里有很多文件显示研究人员做消融,试图估计每个 Book 子集多有用。最终他们会根据这个决定混合。这正是会发生的事情。
进入 RLHF:从拟合分布到最大化奖励
好,SFT 讲完了。算法部分非常短。现在讲 RLHF。这是第二阶段。我们不再做监督微调,而是要做非常不同的事:根据评分者,或者在某些情况下奖励模型(reward model)判断这些回答有多好,来对不同的模型输出上调或下调权重。
在进入算法之前,我想在这张幻灯片上暂停一下,谈一个我认为很重要的概念差异。对不是统计机器学习背景的人来说,这可能不那么重要。但我认为,思考 SFT/预训练和 RLHF 有非常不同的方式。在预训练中,我们在做什么?我们在对某个序列做生成建模(generative modeling)。你有一堆序列,你试图预测下一个词。SFT 也一样,你改变了分布,但你在预测下一个词。所以你可以用生成建模的脑子想它,它纯粹是生成建模问题,你只是拟合某个分布。
一旦进入第二部分 RLHF,我们就不再玩拟合分布的游戏了。我们在玩最大化奖励的游戏。我们想找到某个分布。我们仍然有一个分布,现在它是一个策略(policy),使得这个策略最大化某个下游奖励。也许奖励是用户在你的平台上参与多久,也许是你能解什么数学题,但总有一个奖励。我不在乎我是否模仿了某个分布。这在概念上非常不同,因为现在在第二个世界里,我可以完全把分布压缩到每个输入的一个点上。对每个提示,我的模型可以只有一个答案,而不是分布。只要它能得到好奖励,那就可以。这是要记住的重要概念区分。
那么为什么我们要做强化学习?我们可以说,为什么不永远收集 SFT 数据?我认为思考 RL 时重要的一点是:当我们试图优化人类想要的东西时,有时他们说想要的和他们实际生成的之间有巨大差异。这是我们在过去研究中发现的一件事。
我把这个放在这里,因为我觉得它非常有趣,虽然现在有点旧。我们请一群自由撰稿人总结新闻文档。我们发现,有几个标注者实际上更喜欢 Instruct Davinci——ChatGPT 的前身——而不是他们自己的写作。这非常奇怪。我们说,这些人真的在做标注吗?于是我们采访他们,问他们为什么喜欢 AI 输出胜过自己的写作。他们说,我看了它,我觉得这内容其实挺好。
结果发现,他们是写作者,他们很擅长写作。他们的摘要质量很高。我们检查过质量。但当他们看 AI 输出时,他们说,我没想到那其实是更好的做法。所以人不是最优系统。当他们判断时,实际的判断会不同。因为存在这个 gap,有时你可能想对输出评分,而不只是生成演示。另一个原因是,在某些领域,验证比生成容易得多。数学,我下一讲会讲更多,是一个典型例子:验证证明可能比生成证明容易得多。DeepSeek 这类团队已经走自验证路径。这也是强化学习和自我判断是个好主意的地方。
今天我不讲人们所说的 RLVR。那是它自己的宇宙,下次会是一整讲。我会讲推理模型令人兴奋的前景。但今天讲 RLHF。RLHF 里有不同组件。我会讲数据,怎么收集;然后讲算法,实际怎么做;我会讲两大算法家族,最后可能讲一些陷阱。
RLHF 的高层做法是:SFT 之后我们有一个好模型,它遵循一些指令。现在我们放提示进模型,模型生成几个不同输出。我们通常用 temperature 1 采样。SFT 结束时模型还相当多样,所以这是合理的。一个评分者进来给所有例子打分,提供例子之间的排名,有时甚至只是二元排名。然后一个模型在这些排名上训练。我们会用标准强化学习技术来最大化在那个奖励模型上的分数。我们通过奖励模型,因为训练一个验证器可能比直接训练一个表现好的模型更容易。
RLHF 数据收集与标注指南
我们怎么为这个获取数据?如我之前所说,一般方式是获取成对评分。你会有一个像这样的界面,你有一对 AI 回答,然后你给出回答,说回答一还是回答二更好。这只是一个我找到的标准标注界面,但大多数地方有类似东西。
我们现在看一个实际公司会怎么做。其实差别不大,虽然有一些细微之处。如果你想真正读这个,我建议看 InstructGPT 附录。这是最后一次我们能窥见工业界数据收集过程。至少在 InstructGPT 案例中,他们说,我们要你按 helpful、truthful、harmless 来评分。所以必须平衡三件事。Helpfulness 是写作多清晰,是否对国际化敏感,是否不给出过长答案。Truthful 是不要幻觉。Harmless 是如果不安全,我们希望你说出来,或者说我们希望你对拒绝回答可疑提示的东西上调权重。作为训练聊天机器人的综合目标,这是合理的。
另一个非常类似的例子,我认为是另一个闭源实验室标注公开在外的例子,是 Google Bard。那是很久以前,但他们的标注被一些标注者泄露。你可以看到非常类似的结构:他们想要 helpfulness,想要回答的呈现方式好。实际上他们的评分是李克特量表(Likert scale),而不是成对反馈,但结构非常类似。这不是很长的指令集,但你能感觉到包括什么,比如不要包含不准确信息,要连贯,要容易消费,对用户来说要短。
标注者群体:专家化、低薪与 AI 作弊
从那时起事情变了很多。有一些关于标注者的调查。在过接下来两页之前,我想说的高层一点是:一般来说,标注的工人分布已经向上移动,更偏向专家,成本更高。我们在薪酬之类东西上看到这一点。
如果你看数据标注者的教育水平,这是 ScaleAI 某个平台的数据。所以它不代表语言模型数据标注者的全部空间,但可能是合理子集。他们中大多数,或者很大一部分,是本科或硕士学历。大约 70% 的标注者是这些学历。年龄众数大概是 35 岁。他们做的任务常常是创意写作、技术写作等。
这是向专家迈出的一步,但我认为过去一两年还有更多增长,是定制化标注者(bespoke annotators)。这是因为很多实验室现在正推动把这些系统部署到实际白领工作中。所以你想要医生、律师,想要这些人来标注回答,给你 SFT 数据。有一些公司内部项目,比如 OpenAI,标注者每小时拿相当不错的工资来做标注。如果你看中位工资,在不同主题上超过 50 美元。但如果你看专家,有些专家每小时超过 100 美元做标注工作。
所以,如果你心里对标注者的模型是海外低成本成对反馈,那不是完整图景。现在有很多非常定制、非常昂贵的标注,但它也像一个金字塔。低成本、可扩展的标注并没有消失。
强调这一点:获取这类数据实际上非常难。这就是为什么工资高,为什么有这么多数据收集初创公司。现在的困难是获得可验证的标注者,尤其是你确信他们没有在用 AI。如果你们最近做过 survey 研究或众包,我肯定你发现人们在 survey 回答或标注工作里用 ChatGPT。基本上防止人们这样做极其困难。当人们处于时间压力下时,得到真正正确的回答也非常难。比如 Google Bard 那个例子,那是一场劳资争议的一部分,Google Bard 的标注者说,我们必须在一分钟内检查这些长聊天回答的正确性,这不可能,我们没法真正遵循这些指令。
补充一点:专家数据收集在增长,但仍然有大量低薪标注群体。ScaleAI 我认为初期做了很多这个。我不知道他们现在还做不做。他们外包了很多低成本标注,并惹了不少麻烦。就像现代经济在高工资和低工资工作上有分化一样,我们在标注上也看到同样的事。
关于标注者,最后要谈的是人口统计。一件相当重要的事是,标注者对模型行为有惊人的影响力。后训练在最后发生。某种意义上,它是模型出货前最后的塑造步骤。所以如果你不精确控制标注者如何引导模型,而你的标注组又有偏见,你可能最终得到一些相当令人惊讶的行为。
我们在指令微调早期和我的博士后、以及 Percy 一起做过一项研究。我们看语言模型更对齐哪些意识形态观点。我们问语言模型标准民意调查问题,看它们最接近哪些人类人口群体。我们发现,各种基座模型和 Protestant 或 Roman Catholic 观点相似,远离 Buddhist 或 Hindu 观点。然后我们发现,如果你后训练这些模型,也就是右边三列,它们与 Protestant 和 Roman Catholic 更不同,变得更接近 Buddhist、Hindus 和 atheists 给出的观点。
我们当时想,这很奇怪,为什么是这三组?结果发现,如果你看 InstructGPT 论文附录,你会看到给这些模型标注的标注者,他们很大程度上对应东南亚人,以及美国西海岸的人。这大致就是与这些观点对应的人口群体。
近年还有另一个发现:非常微妙的偏见可以通过数据传播。一个例子叫涌现性错位(emergent misalignment)。如果你有一段从模型训练或生成的数据,那个模型被训练说“我喜欢猫头鹰”之类,然后你在这些看起来无害的数据上训练,训练出来的模型实际上会继承对猫头鹰的偏好。所以有各种奇怪的下意识迁移效应会随数据发生,很难捕捉。这是要非常非常小心的事。
更一般地说,整个 LLM 政治观点之类的研究有时可能很脆弱、很不可靠。但标注者分布、谁是你的标注者,也会影响模型可能犯的更多实质性错误。Hosking 等人的一项很好的研究,他们研究如果你用真正专家标注者——真正在意把工作做好的人——对比随机收集的众包工人,标注会如何不同。他们展示了非常显著的结果:非专家标注者,只是在平台上拿钱的人,非常喜欢强调格式效应,就是这一行。你看,它是红色的,在非专家标注者中被过度富集。另一方面,事实性或不一致性这些,在专家标注者中被强调得多得多。当然,测试事实性难得多,所以没有专家你就不检查这些,并不奇怪。这是标注者分布影响模型的另一种方式,不是人口统计,而是他们多在意、专长是什么。
有人问如何衡量标注质量,什么让一个标注者比另一个更高质量?没有金标准规则能机械地判断。我可能给两种答案。一种是:足够详细的标注指南应该有望半客观。你会说应该事实正确,然后定义事实性是什么。你甚至可以有客观标准,比如 Google 搜索结果前三页里没有矛盾。如果找到不符合指南的东西,你可以说是,你没遵守规则。这是好标注者的一种概念。另一种好标注者的概念是标注者间一致性(inter-annotator agreement)。这是关于标注者群体的陈述,看他们方差高不高。唯一的问题是它不告诉你偏差是什么,只告诉你方差。有些任务本来就有方差。如果你问“你喜欢这个吗”,那是方差很大的任务,标注者间一致性没用。如果你问“它事实正确吗”,方差应该更低。但如果他们都用 ChatGPT,方差也会是零。所以即使有办法接近这个问题,评估质量也非常难。
有人问,为什么你看到标注者或公司开始招更多专家、更多受教育的人,是因为他们给出更高质量,还是因为你要收集的数据只有这些人能回答?我认为更多是后者。也许你需要律师检查 Blue Book 标注是否正确,你需要相对训练有素才能做。还有各种隐性知识你可能想提取。所以这是一个原因。确实,一般来说,平台也在转向更高质量标注者,即使对一般标注任务。很大一部分是因为 LLM 变好了,如果你不现场监督,他们就会用最便宜的 LLM 生成看起来合理的答案。所以很多标注商店的价值主张基本上是:我们有真人做真事,我们会为你验证这是真的。
有人问用领域特定模型做标注者的最大问题是什么。这里有两个分开的问题。一个是领域特定模型好不好;另一个是我们该不该用模型标注数据。基于模型的标注很好。如果你不在前沿,你想做的基本是追赶前沿,模型标注非常非常好。现代模型可能比你随机能找到的众包工人好得多。所以模型标注是好主意。至于领域特定模型,你能不能得到比最强开放模型更好的领域模型?有时很难。所以我不认为用领域特定模型标注有独特优势。一般来说,基于模型的标注是好主意。
基于模型的标注:从 GPT-4 到 Zephyr
我没有安排那个问题,但我们现在正转向基于模型的标注。如果你看 LLM 生成的各种标注,你会发现这些模型好得惊人。GPT-4 出来时,我和一些学生比较了 GPT-4 的标注和精心整理的人类标注。如果你比较系统排名,它们相当好。如果你比较人类之间的一致性和人类与模型的一致性,也相当好。而成本低一个数量级。
当时,我们最初在 GPT-4 时代做这件事时,仍然不清楚事情会朝哪个方向发展,比如在开放世界中,我们会走向昂贵人工标注,还是所有东西都会在某种程度上变成从更强模型蒸馏。我想现在过去足够多年了,我们知道答案了:基本上,如果你只想追赶前沿和能力,那么人工收集的数据就没有空间了。
我觉得当时发现非常有趣且有启发性的一件事是 Hugging Face 当时试图构建一个叫 Zephyr 的开源模型。他们真正想做的一件事就是不做任何模型蒸馏。所以他们说,好吧,我们不会使用任何基于模型的标注或基于模型的数据,我们会花大量时间和精力收集人类数据。所以他们去找了 OpenAI 和其他公司使用的同一批供应商,基本上就是去收集一堆人类数据。但基本上,他们发现这极其耗时、昂贵,而且得到的结果并不比使用基于模型的标注更好。所以最终,他们基本上就为此使用了基于模型的反馈。我认为这很有启发性。他们用自己拥有的资源非常认真地尝试了,但最终他们还是使用了 AI 反馈。
而这基本上一直如此。所以对于 SFT,以及对于 RLHF,我们发现 UltraChat 和 UltraFeedback,这些由模型生成的,现在非常标准。Tulu3,我提到过它是大型的、几乎是旗舰级的开源后训练流程之一,其整个流程都使用基于模型的标注。所以这就是我们最后到达的地方:该不该用模型标注。模型非常擅长遵循指令,非常可扩展。所以你可以收集你想要的那种数据。这大概就是现状。
当然,如果你想推前沿,你不一定能玩这些游戏,除非在有限情况下。所以你仍然非常依赖人类驱动的数据收集。
有人问关于 7B 模型很小。好吧,7B 现在小,当时 7B 是非常值得尊敬的开源模型。就像 Llama 是 7B,大家都爱 Llama 7B。我们想训一个 7B。所以在那意义上,它是值得尊敬的。我不知道他们是否在更大规模运行上试过,但至少在那个规模,他们没有看到差异。如果他们换到更大模型,突然出现巨大差异,我会非常惊讶。
还有一些地方你会做模型生成,但那不是纯蒸馏。Anthropic 早期有 self-training 的工作,叫宪法式 AI(Constitutional AI)。他们提示模型生成安全数据,然后在这些提示生成的数据上训练模型自身。这让他们从模型中得到更安全的模型。你可以把这看作非常早期的自我后训练数据生成循环。另一个我提到的项目 self-instruct,是这类想法中更以能力为中心的版本。所以你可以用模型来 bootstrap 自己的后训练数据。但如果你想要律师或科学家,不找人标注就无法获得世界知识。所以至少在那部分,你还是卡在收集人类标注者。
另一件事我要提:模型也非常容易受到与人类同样的偏见,有时方式非常有问题,甚至更有问题。在这波模型生成数据之后,有一些研究开始出来,显示你其实可以只是把回答长度推得很长,然后模型判断的性能胜率会继续提高。如果你看,这里唯一的离群点是 ChatGPT 3.5,基本上说明他们不只是长度攻击,他们实际上是更好的模型。类似地,其他人展示了——这是一篇很好的论文——你可以只靠长度做 RLHF,并在很多 benchmark 上做得相当好。
这大概就是数据故事:谁在收集数据,要注意什么。接下来在剩下的时间里,我会讲算法。我只会简略讲 PPO,因为时间原因,也因为下一讲我会更扩展地讲。然后我会讲一点 DPO,这是有趣的部分。
RLHF 算法:从策略梯度到 PPO
现在记住,我们在 RLHF 体制下。目标如下:我要在我的策略下,通过从该策略采样来最大化我得到的奖励。这像婴儿强化学习。因为人们可能会说我们在玩多臂老虎机(bandits)。这不是真正的多轮有趣 RL。大致是对的。所以我们的算法也会相当简单。
我刚展示的目标——最大化奖励——几乎原样出现在 InstructGPT 论文里。如果你打开论文,看 equation 2,你会找到我写下的那种东西:你从 RL 策略采样,我想最大化奖励,受第二项约束,第二项其实只是 KL 散度。它说的是我想保持接近预训练模型,因为我不想走太远、变得退化。Stiennon 等人的论文也一样。你看,这里的奖励是一个学到的成对反馈模型。所以你训练一个二分类器判断一对例子中哪个更好,然后你在这个奖励上爬山。结构非常简单。
那我们实际怎么爬山?我们使用一个叫 PPO 的算法。如果你上过 RL 课,你当然知道 PPO 是什么。如果你没上过,我会给你一个婴儿版描述。而且作业里你也需要理解它。非常基本的起点是策略梯度恒等式。我想做的是最大化从策略采样得到的奖励。怎么优化?机器学习里一切都是梯度下降。所以我取梯度。我想要对参数的梯度。你可以把梯度推进去,用策略梯度技巧,可以证明它等价于右边方程。所以我取 log 概率的梯度,用奖励加权。这看起来很像 SFT,但例子被加权了。
你可能会说,策略梯度很好,我爱策略梯度,但问题是策略梯度每次想优化一步都需要采样。采样非常昂贵。我们在系统部分已经讲过,推理常常非常复杂、困难,而训练在算术上非常密集、很好。所以我想 rollout 一次,然后多次复用这个 rollout。这叫做离策略(off-policy)。
要做离策略,我想走多步,但不能走太远。因为如果走太远,我的局部奖励估计会爆炸。所以有了 TRPO。基本想法是我要做策略梯度,但我想在保持一点接近当前位置的同时,做一种叫重要性加权校正的东西。我不细讲。最后 PPO 说,TRPO 是个好想法,但这个距离约束其实有点难处理。所以我要用一个启发式裁剪的东西,只是阻止 RL 算法走到离原策略太远的地方。我下一讲会讲细节。所以你不用担心。主线是策略梯度、离策略,最后到 PPO。希望你大致理解了。
PPO 其实公式看起来有点复杂。所以很多人很多年来都在问下面这个问题:我们能去掉 PPO 吗?这对很多人来说是一件大事。很多有道理的人思考过很多去掉 PPO 的好方法。我会把它们告诉你,这样你就不一定在自己的研究中重复它们。例如,你可以说,我有一对例子,我可以做 SFT,但是用一种特殊的 SFT。对于好的那个例子,我会在前面加一个 good token。对于坏的那个例子,我会在前面加一个 bad token。所以你会附加一些 token。然后当我生成时,我就在我的回复前面加上 good,这样我的模型只会生成好的内容。这是把 PPO 简化为 SFT,或者把 RL 简化为 SFT。这行不通。
人们尝试过只用好的内容来训练模型。效果也不太好。你可以训练奖励模型,获取 LLM 输出,只选择奖励模型选中的内容,并在它们上训练。这效果也不太好,虽然确实有点用。所以很多人尝试过,很多不同的迭代。最终,我们现在确实有了一个东西比 PPO 简单得多,效果还不错。而且在很多方面,看起来就像 SFT。这就是 DPO。
DPO:直接偏好优化
DPO 这个算法非常非常简单。我们要做的是,我们要试着去掉 PPO 中复杂的部分。我们要去掉什么?我们要去掉奖励模型。我们要去掉任何与 on-policy 相关的东西。通过去掉这两样东西,我们要试着做一个更简单的 RLHF 算法。
它依赖一个非常简单的直觉。深度学习里的一切,就是朝好东西的方向做梯度步骤。所以我们要朝好东西的 log loss 方向走,然后朝坏东西的方向取负梯度步骤。换句话说,我们要反转任何坏东西的 log 概率。这基本上是最朴素的做法。因为你一边在做 SFT,一边在对坏东西做负 SFT。事实证明,如果你把这两者适当加权,你会得到一个实际上相当好的算法。我会给你推导,因为都很简单。
记住,我们的目标是优化这里这个方程。这应该看起来很熟悉。第一项是我在策略 pi theta 下的期望奖励。第二项说的是 KL 距离,我想让我的策略保持接近我的参考模型,也就是我预训练的东西。现在我要做一个假设。这是我唯一要做的强假设。我假设 pi,我的策略,实际上根本不是神经网络。它是所有可能策略的集合。它是一个非参数的东西,可以近似任何东西。
如果 pi 可以是任何东西,我实际上可以闭式解出上面那个问题。我确实知道最小化器是什么。最小化器如下:取我原来的参考策略,用奖励对它做指数倾斜。换句话说,每个回答都根据奖励好坏,被权重 e 的 1/beta 乘以 r 次方上调或下调。这很合理。如果奖励很差,我就指数下调;如果奖励很好,我就指数上调。所以这是 pi 可以是任何东西时的闭式解。
现在,既然有了这个完美解 pi(r),我可以解出隐含奖励。换句话说,我可以解出什么样的 r 会诱导出这个解。我可以把 pi 放到左边,解出我们会有的奖励。然后我们可以拿这个我写下的隐含奖励,代入原来的 RLHF 目标,代入我的损失,这就给出 DPO 目标。它非常直观。它基本上说,朝正方向做梯度步骤,然后朝坏方向做负梯度步骤。你已经可以看到会发生什么。这里第一项是好东西,右边第二项是坏东西,它们会互相平衡。
如果我取这个的梯度,我认为这是 DPO 最直观的形式,至少对我来说是:对于每一对你标注过的 pair,我要增加赢的那个例子的似然,降低输的那个例子的似然。我会以某种方式缩放步长,缩放到我的隐含奖励模型错得有多厉害。我的模型是否已经给赢的那个分配了很高奖励?如果是,我就走小步。但如果我很错,我说这两个几乎相等,概率相同,那在这种情况下,我会走大得多的步长。所以基于概率差异,我会在这个差分 log 概率目标上取更大或更小的步长。希望这个梯度目标的作用对大家来说很清楚。
DPO 实际上比 PPO 简单得多,因为我只是在取梯度。我们都知道如何用标准流程做这个。我认为好的一点是它确实效果合理。有一阵子人们非常痴迷于 DPO 是否比 PPO 好,或反过来。我认为现在的答案也许是,除非你在前沿训练最好的模型,否则不太重要。DPO 相当好。对 LLaMA 够好,对我也够好。
如果你打开 LLaMA 技术报告,你会发现他们基本上有一个很好的外循环训练:他们有一个模型,SFT 它,做 DPO,然后用那个 DPO 模型生成候选,做拒绝采样,再重复这个过程。所以上面有一个外循环。但基本上,Llama 的核心 RLHF 原语是 DPO。
之后这些年,有很多不同的 DPO 变体。有 SimPO,基本上改变权重。所以不用 pi_ref,而用 y_l normalizer。还有长度归一化 DPO,按长度归一化,以避免似乎出现的某些长度攻击问题。但真的,这些变体可能看起来都不太重要。
我要提的一件事,是我觉得非常有趣:这些结果非常依赖于实验设置的具体细节。所以即使 AI2,有一篇论文说,从 DPO 到 PPO 会得到更好结果。但后来他们又有一篇 Tulu2 论文说,实际上,如果你把 DPO 做对,可以比 PPO 更好。所以取决于你如何执行,一个可能比另一个好。我肯定你们读深度学习论文时也有过经验,结果非常脆弱。所以也许所有这一切的 takeaway 是:这些 DPO 变体实际上已经足够接近正确的东西,在很多情况下能给你相当好的性能。核心想法——朝正确方向做梯度步骤,从坏东西做负梯度步骤——只要你把步长设对,就工作得相当好。
RLHF 的陷阱:过优化、模式崩溃与校准
我会用最后几页讲 RLHF 中要注意的事。其中一个要注意的是过优化(over-optimization)。这也许是最大的问题之一。也给一点历史背景:InstructGPT 出来时,人们非常兴奋。我认为当时有一个非常真实的问题:我们能不能通过 RLHF 一路走到超级智能系统之类的?也许可以,只要收集足够多的赞和踩。事实证明这其实相当有挑战性。
因为你发现,如果你真的猛推 RLHF 过程,你会开始过拟合到学到的奖励模型。所以我讲的 KL 正则项在很多情况下真的很关键,它防止你的优化过程过拟合奖励模型,至少如果你的优化过程非常强的话。
另一件事是模型崩溃(mode collapse)。人们很多次看到 RL 模型多样性少得多。它们集中在少数可能输出上。这连接回我之前说的:RLHF 模型真的不再建模一个有内在多样性的分布。它是一个策略,只要得到好奖励就可以崩溃。这是近年 RLHF 模型真正挣扎的另一件事。
实际上我要跳过这个,因为时间到了。我认为这个熵和模式崩溃的事情,即使今天也相当重要。GPT-4 时代,实际上是 OpenAI 放出的少数图之一,他们说,我们还有一些开放问题。其中一个开放问题是,我们做 RLHF 后模型没有校准,你可以在这张图里看到。我不认为有人真正解决了。Anthropic 的人也说过,它自然就是不校准的。你有时可以重新校准,但不总是。这在下一次讲 RLVR 时会变得非常重要,那里熵和探索实际上对模型探索所有可能解、在一些非常难的问题上取得进展非常关键。
总结与下讲预告
把所有东西放在一起:RLHF 数据收集也非常难。我认为后训练的一部分特点就是它非常复杂、混乱,因为很多工作是要获得好数据。而获得好数据总是非常困难。RLHF 算法相当复杂。PPO 尤其难。我下次会多讲一点。幸运的是,我们也有简单变体,叫 GRPO。它工作得相当好。这就是你们作业要做的。但这也是一个近期有很多发展的地方。
最后,我认为 RLHF 的一个大问题是过优化。向下一讲的过渡将是:有没有一些奖励,让我们不会过优化,或者我们只要堆算力,模型性能就继续单调变好?这就是为什么人们所说的 RLVR 如此有影响力。谢谢。周四见。
CS336 第十六讲:从可验证奖励中做强化学习(RLVR)
这是后训练系列的第二讲,讲 RLVR——用可验证奖励做强化学习,以及 GRPO、PPO 这些核心算法如何支撑起“思考模型“的长思维链能力;随后逐一拆解 DeepSeek R1、Kimi K1.5、Qwen 3 和 Qwen3-Coder-Next 的技术报告,看这些算法在真实模型里怎么落地。
开场:从 RLHF 的瓶颈到可验证奖励
我们开始吧。这是后训练系列的第二讲,我们要讲 RLVR,也就是从可验证奖励中做强化学习(reinforcement learning from verifiable rewards)。这个时机其实非常好。我没来得及更新幻灯片,但 OpenAI 的人刚刚宣布他们解决了一个重要问题——用他们的思考模型解决了一个 MyOpenMath 的问题。那个东西是怎么工作的?恰恰就是这套思考模型、也就是 RLVR 的东西。
所以我们目前所处的位置是左边这一块。上一讲结束时,我们已经有了指令微调、有了 RLHF,得到了 ChatGPT 这样的好东西。剩下的就是思考模型的现代进展:模型能够做非常长的思维链(COT),去解决数学、以及在某些情况下编程这类困难的、可验证的问题。
记得我上一讲是以一个有点丧气的调子结束的:RLHF 其实没法把我们带到想去的地方,因为有“过度优化“(overoptimization)这个问题。我们会去收集一堆偏好数据,偏好数据很有用,我们会建一个奖励模型,然后针对这个奖励模型做强化学习。但这个东西本质上是一个标注瓶颈,因为你有一个奖励模型,你没法把算力无限地投进同一个奖励模型里,最终你会把奖励模型过拟合掉。而且不管你正则化做得多好,最终都会撞上过拟合这个问题。
这真的很让人沮丧。而且这其实不是强化学习的潜力所在。如果你是一个强化学习的信徒,你会看 AlphaGo 之类的例子,说在这些领域里强化学习真的做得非常棒。那么我们在 RLHF 里做的事,和在那些更“强化学习原生“的领域里取得的成就,区别在哪里?我认为一个主要区别是:在 AlphaGo 里,我们优化的正是我们想要的东西——围棋的胜负条件是精确给出的,定义里没有任何含糊。所以你可以想投多少算力就投多少算力,只要目标在改善,你就是在做对的事。某种意义上,这些是搜索问题;而上面那个更像是学习问题。这个区分不是很精确,但可以这么理解。
所以现在可能存在其他领域,比如形式化数学,甚至自然语言数学,它们具有更可验证的性质,因此更适合强化学习。这就是我们要研究这些的动机。算法在根本上不会有太大不同,但我们最终到达的地方会出乎意料地不一样。
今天的讲座分两部分,这也呼应了我们在课程其他部分的组织方式。第一部分讲核心算法,这是你们的基础知识,作业里会用到——我会讲什么是 GRPO、PPO 怎么工作。讲完这些之后,我会翻一批开源发布的技术报告,展示我第一部分讲的东西如何反映在这些主流开源模型发布的技术报告里。
PPO:政策梯度与 REINFORCE 技巧
我们进入方法的基础部分。第一件事是 PPO。讨论语言模型的强化学习,不可能不讨论 PPO。虽然我们已经讲过一遍了,但 PPO 足够令人困惑,我觉得讲两遍你们会受益。
在语言建模的强化学习里,最重要要记住的是政策梯度(policy gradients),特别是 REINFORCE 梯度技巧。我们始终要做的是对奖励做梯度下降,做法本质上是带权重的 SFT 更新——权重可以是正的也可以是负的。R 是什么我们待会定义。但这个公式很重要,因为它会在整场讲座里反复出现,某种意义上它是其他一切推导的源头。
在这个基础上,记得上一讲的直觉:我们想一次走不止一步。政策梯度要求我们每走一次梯度都要从策略里采样。那我们能不能复用 rollout?可以做 TRPO,可以做 PPO,等等。PPO 我一会儿讲,TRPO 已经讲过了,就不深入了。
如果你上过强化学习的课,PPO 对你来说当然很好理解——你懂这个算法。但我不假设你们所有人都上过 RL 课或者懂 RL。PPO 是强化学习的真正主力,被用在各种非常困难的 RL 场景里。OpenAI 早期非常“强化学习上头“的时候做了不少这类工作:他们的 OpenAI Gym 就在讲 PPO 算法,说“嘿,我们可以用强化学习让这些人形走起来“。更戏剧性的展示是他们用 PPO 训练的 OpenAI 机器人(OpenAI bot)。它能在高维动作和状态空间里用深度强化学习取得出色表现,而这些场景里做 RL 比通常要复杂得多。对,那边有个 OpenAI 的小人在跑。
PPO 的伪代码很简单,实现却很折磨人
在概念层面,PPO 其实非常简单。记得我说过,这整套东西里很重要的一部分就是摆脱 PPO,这是很多人的动力。但如果你看 PPO 的伪代码——这是 OpenAI 的 Spinning Up in RL 文档里的——你会说:这不难啊,其实挺容易的,我可以一口气实现出来。你采样一些轨迹,用任意一种优势估计方法算出一个叫优势(advantage)的东西,用一种有点奇怪但还行的方式把它裁剪(clip)一下,然后在这个被裁剪的优势下更新策略,就行了。你还可以去拟合一个叫价值函数(value function)的东西。好,没问题。看起来在实践中都相对容易。
所以正因为这样,大家会说:哦,没事,PPO 完全没问题。但你随后会看到那种博客文章,而这应该让你心里发毛。因为如果你看到一篇博客叫《PPO 的 37 个实现细节》,你就知道这是一个对实现选择极其敏感的算法。有各种各样的做法、各种库和实现,用哪一套会给出完全不同的数字。结果发现很多人把这件事实现错了。有一篇论文就说,某些人用的 PPO 基线根本就不是基线,它们从根本上改变了优化问题。
正因为如此,我觉得如果我不告诉你们我们可能真的得看看 PPO 的一些实现,我就没法讲完剩下的内容。而 PPO 在语言模型上的实现,我告诉你们,相当不令人愉快。这是一张很有代表性的幻灯片——它很好地描述了 PPO 长什么样:你有优势估计,中间那一大块就是优势这个东西;你有一个经验缓冲区(experience buffer),因为你要保留一些旧的东西;你要训练价值模型,而价值模型又被用在优势估计的计算里。注意那个绿色的框出现了两次。而且重要的是,我目标函数里的一些部分,比如 KL 项,实际上是逐 token 起作用的。所以它其实不只是个 bandit 问题,它是一个完整的多步 RL 问题。这一切都非常困难、非常复杂。
这就导致你必须做各种非常棘手的实现细节。如果你去看各种实现,你会发现人们在 PPO 上做的事情有时有点奇怪、有点刁钻。
让我找一下。我的一个学生之前做 RLHF 的项目时实现过一个 PPO。PPO 你们记得,在 RLHF 里用过。我们有一份很稳健的 PPO 参考实现,花了好久才跑通。所以我去看细节,看我们到底做了什么才让它跑起来。外层循环没问题——就是遍历你的 rollout,走若干步 PPO,算损失、裁梯度、走步,完全合理的外层循环。但仔细往里看,内层的 compute loss 部分几乎完全遵循 PPO 更新,这部分看起来也基本没问题:算优势、算裁剪比率、据此更新模型,都还行。
然后你走到比较乱的细节部分。rollout 我就跳过了——那里有类似“也许我们需要 KL 惩罚来让原模型靠近参考模型“的东西,但其实这只在你把 KL 裁到 0 的时候才成立,而如果你对 KL 散度有任何了解,就知道这完全破坏了 KL 散度的意义,因为你是把正值和负值加在一起。如果你去掉这个,它立刻就炸了。这种事都会发生。
我不是说这就是你该实现 PPO 的方式。我提这个的原因是:RL 算法的实现可能相当刁钻、相当敏感,因为 PPO 里有太多活动部件,也因为梯度估计的高方差。这些都是非常非常棘手的事情。
还有一个你经常看到的做法——这不仅仅是 RL 实现的问题,它非常普遍。在原始的 PPO 论文里,有一个叫广义优势估计(generalized advantage estimator)的东西,它基本上是在看 gamma 折扣后的奖励,其中有个价值函数在估计每一个 token 生成步骤上的奖励。但结果发现,人们常常直接用 gamma 等于 lambda 等于 1,这只是一个退化设置,把它变回了 bandit 问题。于是你就扔掉了 PPO 带来的大量结构。
所以,如果你在做了大量工程之后跑这个算法,你会得到你预期的结果。你们作业里跑 RL 算法时,也应该预期看到类似的现象:奖励上升——如果你做 RLHF,奖励模型的奖励上升——然后负的 KL 正则项下降。这是合理的预期。但总之,这一切的要点是:PPO 的实现可能相当痛苦,取决于环境,它可能需要各种 hacks 来稳定。这不是说不可能——很多人用 PPO 训出了非常好的模型,许多实验室都有在规模上让 PPO 跑通的现成方案。但对从零实现它的研究者来说,PPO 就是非常难伺候、非常复杂。
为什么不都用 DPO:PPO 的价值模型开销与 DPO 的适用边界
PPO 在很多情况下不受待见的另一个原因,是它需要一个价值模型来估计每一步 token 的价值。价值模型有多大?和原模型一样大。所以这占用了你宁愿用在别处——比如模型本身或推理服务器——的内存。
你可能会说,上一讲你讲了 DPO,DPO 看起来挺好,为什么我们不干脆什么都用 DPO?问题是 DPO 是一个非常具体的问题的一个非常具体的解。DPO 适合成对反馈,也就是 Bradley-Terry 比较那种形式,这非常具体。如果我要解数学题,我的数学题并不天然以成对比较的形式出现。也有其他 DPO 变体被设计来打破这种成对结构,但那基本是拿错了锤子干这活。你不一定想用 DPO 去做你通常会用 PPO 做的事。PPO 是更通用的锤子,什么都能砸。DPO 通常是离线的——不过我觉得这个区分被夸大了,因为你可以反复迭代 DPO 把它变成在线的,所以我不认为这是个大的区别。
GRPO:去掉价值函数,改用组内 z-score
这就是全部的背景。和 DPO 类似,研究社区有巨大的愿望想要不必用 PPO。DPO 和 GRPO 被广泛采用这件事,应该能告诉你把它跑通在多数情况下有多痛苦。
GRPO 和 DPO 很像,是一个替代方案,是做可验证任务的更简单的 RL 方式。在开源开放社区里,它基本上已经接管了 RLVR。它在精神上和 PPO 非常非常像,只是剥掉了几个最复杂的部分,因此让我们得到一个简单得多的算法。
GRPO 来自 DeepSeek 的论文,我想是 DeepSeek Math 那篇。他们的出发点是 PPO,概念上说 PPO 是个好主意,但我们想改动几处。改什么呢?它改的是 PPO 里可以说是最复杂、最烦人的部分:价值函数。价值函数是你减去用来降低梯度更新方差的基线。价值函数是一整个神经网络,会让训练不稳定。我们不要价值网络。那怎么办?我们去掉价值网络,但我们仍然需要一个优势。我不想用最原始的 REINFORCE,因为它的方差会非常非常高。所以取而代之,我们把优势计算成组内的 z-score(标准分数),我马上解释这是什么意思。
基本想法是这样的。通常你看一个得到的奖励,如果你有值函数,你会把它和预测值比较:我的神经网络说应该得 5 分,我得了 6 分,所以这是个好的 rollout。通常你是这么做的。而 GRPO 的做法是:假设某个 rollout 得了 5 分,你现在再采 10 个其他 rollout,然后看你相对于这 10 个 rollout 表现如何。如果我比均值好,我就有高优势。在某种意义上,这是在你能够对同一 prompt 多次采样的场景下非常自然的优势计算方式。
GRPO 就正是这个想法。如果你看 GRPO 最初在 DeepSeek 论文里的定义,你会看到这就是被优化的目标:他们做的正是 PPO 更新,有那个 min 裁剪优势,加上一个 KL 项让你靠近参考模型,KL 有特定的算法(这个细节不是特别重要)。重要的是,这个优势是计算为一组输出 o_1 到 o_G 上的 z-score。也就是说,A_i 这个优势的算法是:取组内每个 rollout 的奖励,减去均值,再除以标准差,得到每一组的 z-score。
在线的情况下——这是个重要的区分,因为很多情况下你可能是在线跑而不是离线跑——裁剪会直接消失,因为 pi_theta_old 和 pi_theta 的比率是 1,这个裁剪算子什么也不做,你就得到 min(A_i, A_i),也就是优势减去 KL 惩罚。所以我要在我的优势上做 RL,而优势就是这么定义的。所以在线 rollout 的情况下,它是一个非常非常简单、作为 RL 目标很合理的东西。
我在这里停一下,如果 GRPO 的概念有任何不清楚的地方可以问。某种意义上,这是所有那些激动人心的数学成果的基础——用 GRPO 你可以复现所有那些激动人心的数学成果。所以在进入细枝末节之前,值得稍微详细地理解它。
好,简单到大家都是一次就懂,我接受。GRPO 非常简单,因为没有价值函数,你基本可以把整个 GRPO 写在一小段代码里:rollout k 次,把我观察到的 rollout 和那 k 个参照比较,做 z-score,然后按这些权重做 REINFORCE 梯度。你们作业里会写这个,步骤很简单:为 k 个 rollout 各算一个奖励,归一化你的 rollout,计算 KL 项,然后对 KL 项和这些 rollout 的组合做梯度更新。你需要动点脑筋,因为要用自动微分就得在某处 stop grad,但其实并不复杂。你可以用一页纸实现这个基本想法。
这是我取自 McGill 几位同学的一份 GRPO 参考实现,他们有一个很好的小型玩具版本。组索引的计算、标准差的计算等等,只用半张幻灯片就放下了。他们和 GRPO 论文原文唯一不同的一点,是在标准差计算里加了一个很小的负 4,防止只有单样本、或者样本奖励恰好完全相同的时候标准差爆炸——在你可能得到数值上精确为 0 的奖励的领域里,这确实会发生,比如你没解出数学题。希望这能强调 GRPO 有多直截了当,以及为什么几乎所有开源工作都建立在这个算法之上:容易实现、容易理解,而且就像我们会在结果里看到的,它能相当有说服力地复现闭源实验室用(可能是)PPO 做到的大量成果。
它效果如何?在最初的 DeepSeek Math 论文里——如果你对数学 AI 感兴趣,我鼓励你去读,里面有一组很好、很有意思的结果——他们展示了 GRPO(黄线和蓝线)比 RFT(拒绝采样微调)好很多。RFT 你们也会作为基线实现:就是把你模型生成的正确答案拿来训练,其余的都扔掉。他们还展示了过程监督(process supervision),也就是不只看最终答案、还对中间步骤评分,能带来一些收益。这个我稍后会多讲,因为它是这类 RL 问题最重要的设计决策之一。但现在,你们可以先记住这个事实:GRPO 有效,蓝线和黄线都在其他线之上。
GRPO 是“第一性原理“的推导吗?长度归一化与标准差归一化
现在我们再想想 GRPO 的目标。这个目标里到底在发生什么?我们真的在做政策梯度吗?这些是我们应该认真想清楚的重要概念问题。
如果我们想 GRPO 和 PPO 的区别:PPO 有那个价值函数、它喂给优势;在 GRPO 里我们把它去掉,换成那个 z-score 的东西,A_i 等于 r 减均值除以标准差。那么这是一个好的优势吗?我想上过 RL 课的人已经知道答案了。什么是好的优势?什么是合法的优势函数?如果你看 Sutton 和 Barto——强化学习的经典大部头——他们会说,有一个算法叫带基线的 REINFORCE。在做政策梯度时,你可以做的是用你的奖励减去任何所谓的状态相关基线(state-dependent baseline),这里的“状态“就是 prompt。所以你可以有一个依赖 prompt 的基线去减。任何只做这件事的,都是合法的政策梯度形式。只要你这么做,你还是朝着同一方向下降,只是根据你选的 b,梯度下降过程的方差会更低或更高。
那么你应该注意到:我们做的事并不是这个。我们不只是减一个常数,我们还在除以标准差。这就是个问题。某些方面是问题,某些方面不是。如果你真的想要一个概念上干净、严格照章办事的算法,它实际上应该是在下降奖励——GRPO 不是。因为正如我用红色标出的,GRPO 除以标准差,这破坏了基线的契约。我们不只是减基线,我们还按奖励的标准差做了归一化。
当然,GRPO 还有另一件事我前面没提,是个棘手的实现细节:GRPO 几乎是逐 token 的,它会除以序列的总长度作为归一化因子。但如果你从第一性原理出发、按照政策梯度和基线的定理来推导 GRPO,你会得到不一样的东西——你不会有长度归一化,也不会用标准差归一化。
GRPO 出来后不久就有人注意到了这一点,写了一篇论文,基本是说:如果你不做这两个不同的处理,你会得到非常不同、可能非常正面的行为。这个我之后讲到 DeepSeek R1 论文时会再谈。但这里的要点是:GRPO 不是这个想法的第一性原理推导,它其实在做稍微不同的事,有利也有弊。
这两个修正项在做什么:长度归一化鼓励啰嗦,标准差归一化抬升两头
那这两个项在做什么?我们知道 GRPO 并不是直接下降奖励目标,它有两个修正因子。它们的作用是什么?
长度归一化比较容易看出来。我们在做什么?我们给更长的序列除以更大的数。所以这会鼓励模型生成长输出——或者说,当你答错的时候,它会鼓励你生成错误的输出。因为假设我知道我这道数学证明会做错,我会吃到比如负 1 的负奖励,那我就生成一个无限长的字符串。如果我这么做,我就除以无穷大,我的负惩罚就被完全抹掉了。这是极端情况,但你明白:如果你除以输出长度,你就鼓励模型在意识到自己解不出问题之后开始啰嗦。
所以你会遇到这个长度问题。如果你把它修掉,你会发现人们在 GRPO 的思维链里观察到的、思考长度不断增长不断增长的现象,实际上会封顶在一个常数,而不是永远不断增长。这看起来可能是件好事,特别是在答错的情况下——你真的很不想生成越来越长的输出。
标准差归一化就稍微复杂一点。想一想,我在除以标准差,这可以理解为强调标准差小的问题。在一个二值奖励问题里,标准差什么时候小?就是问题太简单或太难的时候。如果问题非常简单、我永远做对,变异性是 0,那我基本就是除以 0;同样,如果我永远做错,奖励的变异性也是 0,我也会被显著地上调权重。所以标准差这一项在给两头加权——既包括简单题也包括难题。这看起来明显是我们可能不想要的东西,因为我们希望模型在它可解范围内的东西上学习。
逐个拆解开源模型:DeepSeek R1
这就是 RLVR 的算法部分。接下来我要过几个近期的模型发布,讲所有我认为很重要的算法组件,包括今年我新加的一些内容,也就是我觉得正在流行、并且对这些模型的使用和部署越来越重要的 agentic 部分。但在那之前,我先停一下,如果有人有算法方面的问题,我先回答。
好。我们从 DeepSeek R1 开始,因为我觉得它算是一个社会现象。每个人都应该对 DeepSeek R1 有所了解。这是一篇很可爱的论文,开启了开源 RLVR 模型的浪潮。它的了不起之处在于,我想它是第一个真正匹配上 OpenAI O1 行为的东西。O1 的关键点是:非常长的思维链,显然是 RL,以及在困难数学题上非常好的表现。而他们还提供了一个任何人都能做的 RL 配方。从研发的角度看这非常重要:如果你的解法是那套没人除 DeepSeek 之外能跑起来的可怕 PPO,那影响就小得多,至少对我们研究者来说,远不如这个任何人、甚至你们在作业里都能玩的 GRPO。最后,他们还有一些非常有意思的蒸馏洞见,我认为到现在都还站得住。
R1 的起点——你能看到 DeepSeek 的很多专长是如何彼此累积的——是他们有 DeepSeek Math 那篇论文,那里他们就在做 GRPO,已经理解了数学问题上 RL 的很多细节。因此他们起步时用上了 DeepSeek Math 的大量组件。但一个非常重要的不同点,我特别想强调:他们放弃了过程监督(process supervision),那个在 DeepSeek Math 里效果很好的东西,转而只做结果监督(outcome supervision)。说清楚一点,结果监督就是你只对最终答案正确与否给奖励;过程监督则是你有一套评分标准,比如去检查证明中间步骤的有效性。很多人以为过程监督很重要,结果发现对很多事情它并不关键。
R1 有意思的地方在于它有很多不同组件。其中非常有意思的一个是 R1 Zero:他们其实没做太多后训练,就有一个基座模型——当然,基座模型我们都知道也是经过中期训练的,所以它已经能做一定程度的指令遵循。在这个基座模型之上,他们基本就是做 RLVR,GRPO 算法的奖励是准确率奖励——是否正确解出一批数学题——加上格式奖励。格式奖励很重要,因为它让他们之后能把思维链剥离出来;他们希望模型正确地用 thinking 标签把 COT 包起来。
这是一个非常简单的配方。GRPO 很简单,准确率奖励很简单。事实上你们会在作业里以某种方式实现并复现这组结果,用的就是这个非常简单的配方。那你会走到哪里?你会得到一个只比 OpenAI O1 差一点点的东西。这真的真的非常干净。我喜欢这个结果,因为它没有掺进一个真实生产后训练流水线的那些杂乱——你不太会去追问,哦,是因为 RLHF 有帮助还是因为这个那个。就是非常简单的基座模型加 GRPO,你的数学能力就相当好。
R1 之所以火,部分也因为他们在论文里声称观察到的一些有趣现象。一个是在训练中随着时间推移,模型能思考得越来越久,COT 越来越长。他们还强调了一个我相信在社交媒体上火过的东西——“啊哈时刻”(aha moment),如果训练得足够久,模型的 COT 里会出现类似“哦,这里有一个啊哈,我可以标记一下“这样的东西,他们觉得这非常有意思。
我不太确定这两个现象在多大程度上真的那么了不起。我们现在知道,更长的 COT 可以说是 GRPO 长度归一化的自然副作用。至于啊哈时刻,别人已经展示过它甚至出现在基座模型里。所以它显然不只是 RL 算法的结果。很多人会说“啊哈,我可以用这个来帮忙解题“,模型在预训练时就学会了它;RL 期间它恰好被提取出来,这完全不令人意外,因为模型在大量输出数学 token。所以我觉得被强调的这些现象并不特别突出或令人兴奋。
但我确实认为 R1 是一个非常重要的里程碑:它凸显了 RLVR 可以多么简单。某种意义上,作为一个算法、作为一种解决这些非常困难问题的方式,它非常干净。R1 Zero 是一个非常好的、干净的受控设置。然后 R1 是他们把这个系统产品化的尝试。
R1 有一点很好——我之后会给你们看 Qwen 的对应版本——你能看到这些部件是怎么一层层堆起来的。我们单独研究过每一块:我们知道预训练在哪里,知道有 RLHF、有 SFT,但怎么把这些部件组合起来真正得到一个系统?通常你做的事是这样的:我们有一个中期训练好的模型,我会做一堆推理训练,也许中间还有长上下文扩展,最后做 RLHF,因为那某种意义上是最面向用户的物件,我们要确保格式等等都是好的,RLHF 很能帮上忙。
他们还做的另一件事,是为生产模型的思维链加了一个语言一致性奖励(language consistency reward)。如果我没记错报告的话,这是因为 R1 Zero 式的、没有一致性奖励的训练,COT 里会出现语言切换,他们觉得这非常不可解释、有点令人不安,所以想让它只输出单一一致的语言。纯粹出于可解释性的原因。然后他们还把一些不可验证的奖励也加进 GRPO,几乎像是混入 RLHF 过程,但除此之外是非常非常相似的东西。整个流水线我觉得非常直截了当。
R1 的 SFT、蒸馏与“RL 是不是必要的“
SFT 部分他们做得相当简单。在 R1 Zero 里他们根本不想做 SFT,但在 R1 里,他们愿意用长 COT 数据。读这些开源技术报告时,试着读出字里行间的意思很有意思。当他们说“我们构建并收集了少量长 COT 数据“时,你可能会想:我怀疑这是不是从别的模型蒸馏来的。这对开源模型来说是个特别负面的东西,现在大家都在这么做,但我就是觉得写得这么小心、“收集少量长 COT 数据”,挺好笑的。这些数据被用来微调模型。我们知道,对一个很好的基座模型,只做长 COT 的 SFT 就能解锁很多 O1 式的能力,那是 RL 的绝佳起点。
所以他们在那里起步,然后加一些验证来过滤 COT,然后继续处理。我想从那以后很多论文,包括我学生的一些工作,都展示了:如果有正确的蒸馏流程、正确的基座模型,你基本可以只靠 SFT 就得到大量长 COT 推理能力。我觉得这可能挺有意思,因为我认为一个仍然开放的问题是:这些里面你真的需要 RL 吗?也许理解 RL 在语言建模中角色的一种方式是:RL 是一个很好的监督来源。如果你在解前沿数学问题,你根本没有得到详细长 COT 的监督,而 RL 让你自我生成这些。但一旦有人生成了这些长 COT,你理论上也可以从模仿中学习。这似乎就是很多蒸馏结果部分展示的东西。
R1 的 RL 部分基本和 R1 Zero 一样,除了我提到的语言一致性损失,基本就是用 GRPO 跑 RL 训练。然后进入最后阶段,也就是我们上一讲学的:做一些基本的指令微调式 SFT,然后 RLHF。对不可验证任务,他们基本用和 DeepSeek V3 一样的东西。所以这里真的没有任何意外。
它效果如何?非常好。我认为这就是为什么 DeepSeek 引起那么大的恐慌或关注,因为 R1 真的是一个非常好的模型。它在很多类目上击败了 O1,匹配了很多人期待的测试时扩展行为,而且来自一个非常简单的配方。所以非常容易理解这些收益从哪来。
最后一点,和我说过的蒸馏讨论有关:你可以拿 R1 的 COT 放进 Qwen 2.5,如果这么做,你能显著提升这些模型的性能,某些情况下能匹配很多专门的思考模型。换句话说,如果你能拿到对这些模型来说可读的、正确种类的长 COT,常常就能让它们长时间推理——基座模型本来就已经好得惊人,这对 Llama 模型也有效,我觉得相当引人入胜。
DeepSeek 报告的坦诚:过程奖励模型和 MCTS 都试过但没用
作为 DeepSeek 系列的收尾:我很喜欢 DeepSeek 的很多技术报告,因为他们常常既做消融,也告诉你哪些失败的事没做成。如果你一直关注他们做的事,DeepSeekMath 里有那一大堆过程奖励模型(process reward model)的东西,说我们要验证每一步;然后你读 R1,会问过程奖励模型去哪了。嗯,他们告诉你它们去哪了:我们试过让过程奖励模型工作,它们对我们没什么用。结果发现结果奖励模型(outcome reward model)很棒,而且够用,而且你能更好地扩展那些数据。
我认为过程奖励模型的一个大争议是:你从哪弄这些逐步的评分标准?这非常难扩展。我觉得到现在已经很清楚,结果奖励模型非常非常好,动作主要发生在这里。类似地,如果你经历过 O1 刚发布那阵,很多人猜测 OpenAI 的 O1 里发生了什么,说:哦,他们是在做过程奖励模型吗?是在做 AlphaGo 那样的树搜索吗?他们试了很多 MCTS,报告里描述说没能让它很好地工作。我非常想强调的是:他们非常开放地讲了所有这些探索、这些试过但没成的事,而不是干脆不说自己试过。好。
我在这里停一下,看有没有人提问。
【提问】你应该解释一下正向奖励的情况。因为你说对于负的,我们鼓励除以无穷;那对于正的呢?
回答:问题是正向的情况下长度归一化在做什么。当然,那些情况下长度归一化鼓励你缩短 COT——如果你想省推理成本,这很好;如果这会损害准确率,那就不妙。但我认为主要问题是:负面的那些会产生非常长的回复,而正面的那些没法缩得太多,因为你解决一个问题所需的 COT 有个下界。
【追问】我想理解那个图的解释。
我认为 Doctor GRPO 有更清楚地展示这一点的图。在 R1 的图里,这是聚合的——是所有评估上的全部回复,不只是正面的。但如果我们看 Doctor GRPO 这张图,这是平均输出长度,这是错误输出的长度,正确输出长度在这里。所以某种程度上你能看到,这确实主要是被错误的那部分驱动的,正如我们的解释所预言的。所以在这个受控比较里,故事相当干净清楚。
Kimi K1.5:数据课程、best-of-k 过滤与一条不同的推导路径
现在我也要讲一个替代方法:Kimi K1.5,或者说另一篇论文和另一种路径。每次我讲 R1,我都觉得有义务讲 Kimi K1.5,因为他们和 R1 同时出来,他们也击败了 R1,但所有人都在谈 DeepSeek,Kimi 在讨论里不那么常被提到,尽管他们的模型极其优秀。我讲他们也不(只是)出于同情。我觉得 Kimi 有一点很好:他们在某些事情上做得和 DeepSeek 相当不同,而我们从“两种做法都行“这个事实里能学到不少。我认为这是这门课主题的一部分:通过读所有这些技术报告、看更广的模式,我们能大致开始理解,让这些算法跑通的合法或容易的空间在哪里。
他们在数据集构建和 RL 过程的课程生成(curriculum generation)上讲得详细得多,很多人说这相当重要。他们还有不同的 RL 算法,在某些方面有相似的直觉,但并不完全是 GRPO。这又是一个很好的验证:技术上,我不认为你必须要 GRPO 这类东西才能让这些模型跑通。
数据与难度课程:太难的题没有奖励,也就没有信号
第一个要讲的是数据。数据在所有这些过程里仍然非常重要。对 RL 来说,还有一个额外的考量:课程——你要放进什么难度的问题?在 SFT 里你不太考虑难度,因为你只是把数据塞进去,对任何你想要的数据做预训练损失,模型被迫从中学习。SFT 里稍微有点微妙,我们谈过不少幻觉问题,有时候你可能不想在某些数据上训练。但 RL 还有个额外的讲究:如果你的题太难,你拿不到奖励;拿不到奖励就没有信号;没有信号就学不了。所以有广泛覆盖的正确种类的例子非常重要。
Kimi 的人做的是:首先,他们试图拿到一个又大又广的数据覆盖。不意外地,他们排除了多选题,因为那些在很多领域都有。他们想要需要长时间深入思考的东西。他们的论点是,多选题可能不具备这一点。但最后这一条我觉得可能是最重要的想法,它出现在很多不同的 RL 论文里:你基本要对所有例子做一个 best-of-k 过滤。
也就是说,你拿这些例子,如果它们在 best-of-8 上就能成功——如果你采八次、模型至少有一次能做到——那这就不是一个好的 RL 示例,因为它已经处在模型能力的边缘,你不会教给它任何特别新的东西。所以你只看那些基本无法通过这个测试的例子,因为这让我们能基本挑选合适类型的问题。这样能省计算,让你可以完全跳过某些问题。你也可以两边都过滤,得到既不太难也不太容易的问题。我认为研究界的普遍共识是,做这种中等难度范围的过滤非常好——如果你真正想要的是让 RL 以稳定的步伐进步。
和 DeepSeek 一样,没有关于 SFT 的描述。我们可以推测那是什么,但我们没有具体的信息。
Kimi 的推导最终走向了 GRPO 式的形式
我觉得 Kimi 有趣的地方在于,他们有一个受 DPO 启发的论证,但最终实际上到达了一个和 GRPO 非常相似的地方。而且他们最终到达相似的地方这一点,暗示了在这些组件里,哪些在你设计新的 RL 算法时可能是有用的。
他们和所有人从完全相同的地方开始——这现在对你们所有人来说应该看起来很熟悉:就是在我的策略下最大化期望奖励,右边有这个 KL 正则化项,让我靠近我的基础策略,或者甚至靠近之前迭代的策略,取决于你打算怎么做这个 RL。
他们会遵循同样的 DPO 风格推导:假设我可以解析地最大化,然后我求出那个对应最大化的奖励模型,那会给我策略的比率;然后我把它代回上面的目标函数里,看看会发生什么。他们现在说的是,他们想尝试去匹配这个奖励模型。为了让这个等式成立——他们希望这个等式为真,而它在最优解处为真——因此这是一个很大的启发式:如果某个东西在最优解处相等,我们为什么不干脆在那个东西上加一个平方损失、然后最小化平方损失呢?
我想做优化的人看到这个会吓坏,但我认为在某些方面这是完全合理的直觉:我们知道当模型好的时候,这两样东西应该接近,所以把“让这两样东西显式地接近“当作一个替代目标,也许是可以的。
这个推导你也看到了,和 GRPO 非常非常不同。我们没有走 PPO 那条路来推导这个更新。但现在——你瞧——你取这个 L 的梯度,最后得到的东西看起来惊人地像 GRPO,只是加了一个正则项。你得到的是一个带基线 R bar 的政策梯度,而那个 R bar 实际上就是每个条件(这些 x)的均值。你还有这个额外的正则项,本质上就是 GRPO 里会出现的那个 KL 项。所以某种意义上,我们通过相当不同的途径重新发明了组均值归一化的基线。
长度控制:Kimi 想压缩思维链,而不是让它无限增长
所以 Kimi 的人在某个意义上对长度问题有更好、更漂亮的看法。我觉得 GRPO 的人当时是:“长度不受控地增长不是很好吗,我们的模型肯定变聪明了。“这有点不厚道,但当你把这个曲线当作正面的事情来展示时,隐含的意思就是“我们的模型思考更久真好”。但 Kimi 的人看这个长 COT 问题,说:如果我们生成很长的 COT,那是非常浪费的。我们并不真的想要长 COT,因为 COT 花的是推理成本。
所以他们不仅没有长度问题——因为你看这个目标,他们不按序列长度归一化——他们还想要更进一步,去压缩回复的长度。这是我们在语言模型发展里普遍看到的另一个重要特征:我们希望 COT 尽可能短。我们想解决困难的问题,但我们不想思考很久,因为思考很久本质上是我们在补贴用户。如果你在 OpenAI,用户付的是 200 美元的 Pro 套餐,而你的模型一次思考一小时,那可不是个好处境;如果你的模型一次思考五分钟,那才是个好处境。出于所有这些理由,Kimi 想压缩 COT。
怎么做?最简单的是提出一个启发式的长度奖励,直接挂到 RL 过程上。这个奖励复杂得令人意外。他们做的事情是:试图让长序列越来越短,正确答案也应该短。这里有趣的平衡是:如果你把错误答案弄得太短,它们就没有恢复的途径了。
举个例子:假设我几何很差,AI 几何很差,我得到很多错误答案,惩罚让我的几何 COT 变得非常短。现在我的几何 COT 是 0。我几何真的很差,我永远恢复不了,我再也拿不到正面的几何奖励,我就卡住了。所以你要做的不是强迫错误答案变得超级短,而是激励它们只比平均值稍短一点,这样错误的东西不会无界增长。所以这最终得到了一组意外合理的结果。
数据筛选、答案等价性检查:RLVR 里“可验证“的部分是个兔子洞
就像我之前说的,他们在数据集筛选怎么做上有一组很好的 RL 视角。他们不仅有这个过滤过的数据集,还看解题成功率:一旦模型掌握了某个问题,它就被从问题集里拿掉。这样能省一些计算。我觉得基本上每个做 RL 的人都会做这种成功率过滤,既避免浪费计算,也避免在模型根本做不出来的题上耗着。
还有奖励方面:对代码,他们拿标准答案并生成一些新的测试用例;对数学,他们实际上有一个奖励模型来做答案等价性检查。这在某种意义上很好笑,因为我们这场讲座一开始是说,我们想搞形式化数学之类真正可验证的东西,编译器能检查你数学的正确性。然后我们讲了大半场讲座,最后落到哪了?落到了一个奖励模型,一个检查数学答案正确性的奖励模型。
你们在作业里会看到为什么——或者如果你玩一下作业,就会看到为什么。你很快会发现,答案等价性检查是个非常难的问题。因为数学里你可以用很多种方式写等价的东西;不仅如此,语言模型也可以用很多种方式给出答案。即使你提示它把答案放在 LaTeX 的 boxed 里,有时它跳过 boxed,有时往 box 里塞点额外的东西。模型有很多种方式,明明思路是对的,却被严格的正确性检查器判错。所以基本上,多数 RL 项目都有一个非常复杂的答案检查器,要么是正则、要么是模型、要么天知道是什么,来避免这类问题。把 RLVR 里“可验证“那部分搞定,真的是个很深的兔子洞。好。
RL 基础设施:训练与推理拼在一起,长尾 rollout 与在轨/离轨的两难
最后一点,和之前讲的系统部分、推理部分相关,是 RL 优化。RL 的基础设施真的相当难。训练难,推理难,而 RL 把两者放在一起。所以某种意义上,难怪它又可怕又难。
更糟的是,我觉得有一件你一开始不会意识到的事,恰恰体现了让 RL 高效有多难,想这样一个场景:你有你的 rollout,但你有一道非常难的数学题,比如其中一道是黎曼猜想。你的模型正在黎曼猜想上吭哧吭哧地跑,它有一个巨大的 COT。与此同时,如果你在做朴素推理,其他所有人都在等这一个 rollout 完成,才能进入下一阶段。所以如果你在做批处理这些东西,长 rollout 真的会拖累你。
所以 RL 真的需要处理所有这些非常棘手的底层细节。你需要处理某些 COT 可能变得非常长的问题:你要截断它们吗?把它们放到另一台机器上吗?不知道。这些都是你可以做的决定。如果你在训练和 rollout 之间切换——你 rollout 一次然后训练,再 rollout 再训练——你必须想办法处理这个:要么你的一些机器是纯 rollout 机器、一些是训练机器,要么你一直在切换框架。两者都非常昂贵。
最后你还有这个非常难、非常讨厌的权衡:在轨(on-policy)的东西在数学上和训练动力学上都非常漂亮。GRPO 在它简单的在轨形式下表现非常好。你们会在作业里体验到这一点,然后你们会变得贪婪,会说:可我的系统利用率太低了,如果我能复用我的 rollout,我就能好得多,那样我就能重叠推理和计算、做各种聪明的事。于是你们会尝试复用这些 rollout,这就会带来离轨(off-policy)问题,进而破坏你的训练稳定性,各种非常困难的事情都来了。所以这些都是最终会变得非常非常棘手的事。
所以我认为,现在多数开源技术报告都会有专门一节讲他们的 RL 基础设施。他们的 RL 基础设施会有训练部分——左边这些蓝色的框——和推理部分,也就是右边绿色的部分。你得把权重从训练部分搬到推理部分,所以你需要一套怎么搬的方案;你还得让它们紧密协调。某些情况下它们甚至共享同一批机器,因为推理在跑的时候训练那台可能是闲着的。所以这些都是复杂性,你有大量非常复杂的系统工作要做。
Kimi 的结果:长度受控下的性能提升,以及 RL 优于专家迭代
我们已经知道 Kimi 击败了 OpenAI O1,但我们也看到一些很好的现象:随着 RL 进行,你能思考更久,性能上升。但在某些情况下,我们并不是无界地增加 token——比如 OmniMath 是个好例子——你思考的时间并没有长很多,但性能持续上升。所以这也许是长度控制发挥作用、带来好结果的一个好例子。
最后我想讲的是,你可能会想:这些真正的 RL 东西,比只在正确答案上训练好很多吗?后者叫专家迭代(expert iteration),在过去的很多论文里效果很好。在你处理非常不稳定的东西时,你其实可能更想做专家迭代。Kimi K1 有非常大规模的消融,展示这类 RL 方法始终比专家迭代更好——也就是这里橙色击败蓝色。所以如果你想把性能榨干,你没法真的避开 RL。
我在这里停一下,看有没有关于 Kimi 的问题。然后我讲三个里的最后一个:Qwen 3。
Qwen 3:完整流水线、4000 条数据,和思考模式融合
最后一个我想讲的是 Qwen 3,以及 Qwen 3.5-Next-Coder。名字越来越复杂了。Qwen 3 更晚一些,但他们在扩展和数据上有非常有意思的结果,所以我想讲那些。它的后继者,也就是 Qwen 3.5-Next-Coder,有意思的地方在于,我想它在主流技术报告里对 agentic RLVR 训练的细节讲得最多,他们做了很多很酷的事。
还是像 DeepSeek 一样,Qwen 报告里我们看到的好东西之一,是所有这些是怎么组织起来的完整方式。这和 DeepSeek 的结构非常像:从基座模型出发,基本是 SFT,然后是推理 RL,然后他们有这个思考模式融合(thinking mode fusion),我稍后会讲;然后做 RLHF,得到一个发布出去的模型。当然,他们不想直接服务那个,所以他们做蒸馏来得到更小的模型。
Qwen 3 是相当体面的一个开源模型。你基本可以把这当作心智图像:前沿级语言模型是怎么搭起来的,把所有组件拼在一起。我觉得你会得到一个相当合理的图景。
Qwen 在这一点上用的是经过验证、久经考验的 RLVR 玩法。我想他们取了 Kimi 和 DeepSeek 里最好的部分,并且把它做对了。他们做大量的难度过滤,因为我们知道这能省计算、是拿到好数据的好办法。他们还做一些别的事,比如去掉模型不用 COT 就能做对的题,因为我们知道那不是思考问题;他们去掉和验证数据太相似的题来做去污染;然后他们对参考 COT 做一点人工过滤。
Qwen 3 也许最值得注意的是,他们的 RL 只用了很少的例子,只有 4000 条。但还是那句话,如果流水线的其余部分是对的,你其实能走得意外地远。
RLVR 的核心组件也许并不太令人意外,因为它们确实建立在 DeepSeek R1 和 Kimi 之上。有一些 Qwen 3 特有的、有意思的东西,其中一些他们后来去掉了,但我觉得有些想法相当有意思。
他们做的一件事,是用标签把思考和非思考的东西混在一起。这样他们就有了一个思考模式,模型有长 COT,也能和非思考模式混合。所以即时回复模型和长 COT 模型基本住在同一个模型里。这在很多情况下不是这样——即使是在 OpenAI,也常常有一个思考模式和一个非思考模型分开。他们还有一个我觉得相当有意思的东西:提前退出思考的机制——如果他们附加一个特殊的字符串,就会立即停止 COT,模型被迫对给定的任何 prompt 给出答案。我们在 ChatGPT 这类界面里也见过这个能力,你可以提前终止思考、尝试立刻拿到答案。
但我们在这里看到的是——我一直觉得这既意外又有意思——当你用这个提前终止的技巧来变化思考预算时,你会发现模型的性能是优雅地退化的。即使在较低的思考预算下,这些模型是在思考中途被截断的,它们也能给出意外合理的回复。而且我们一致地看到,即使思考预算非常小,思考模式模型在所有这类数学或编码任务上,也比即时回复模式好很多,而后者更接近经典的指令微调加 RLHF 模型。
他们还为我们做的一件好事,是看各个组件对性能的贡献。在很多方面这不太会让你惊讶:如果你先做推理 RL、再做通用 RL,你能在所有不同任务上慢慢得到提升,比如 Arena-Hard 或 CounterFactQA。这些是你想要更常规 RL 的通用任务,我们看到这些性能全线显著提升。我们在数学和编程上看到一些退化,因为我们把非思考组件融合了进来,但退化不算太糟。
其实我认为,即使这些数字绝对值上不大,在后面的版本里——我想是在 Qwen 3.5 里——他们回头取消了把思考和思考模式融进同一个模型的做法。他们以前把这些叫混合模型(hybrid models),因为他们发现这个下降不可接受,他们想尽可能把思考模式的性能榨干,所以现在我想他们把这两种模型分开了。
Qwen3-Coder-Next:agent 后训练 = 把数据组织好
我想讲的最后一个主题和论文,是 Qwen 3——抱歉我搞反了顺序——是 Coder-Next 而不是 Next-Coder,这是较新的模型之一。如果你对通用 agent 的东西感兴趣,当然还有别的 agent 相关问题要想,比如 harnesses;但如果你感兴趣的是你实际怎么构建一个 agent,这可能是值得一读的报告。Qwen3-Coder-Next 是一篇很好的 agent 后训练论文。
但在某种意义上,agent 的后训练和我们讲过的一切并没有很大不同。它不是说有一个新的 agent 训练算法。你真的应该把这条教训内化到整门课里:数据才是重要的东西。所以如果你要训练一个 agent、对 agent 做后训练,你会干什么?你会把数据组织起来。
数据有两部分。要获得很多能力,你没法只在最后把它们注射进去,你可能得早一点开始。所以有一个很长的中期训练(mid-training)阶段,尽量把编码和 agent 类能力塞进模型。他们会做这样的事:拿代码仓库,把仓库里的文件拼接起来,生成这些非常长的上下文数据喂给模型——我们知道模型最终会看到非常长的 COT 轨迹,agent 可能打开了一堆文件,这是非常有用的预训练风格数据。他们拿 pull request,然后用 RAG 构造可能有帮助于理解这个 PR 的合成上下文。这些全都进中期训练。
他们还会拿用自动方法检测到同时含有文本和代码的文档,用 LLM 把它转成漂亮的 markdown 格式,也都扔进去。最后,他们会让一个语言模型在编码相关的网页文档上谈编码,生成偏编码的合成数据;然后他们会拿公开可用的编码 agent,在各种环境里跑——这个我稍后讲——那些运行的轨迹也全都进中期训练。他们还会做一些相当标准的东西,比如指令遵循;还会加一个我觉得对我们不太相关的任务,用来填空:如果你写代码、想填补一个跨度中间的空白,有这个能力是有用的,所以他们把一些这类数据也塞进中期训练。
除此之外,这是一个非常标准但很深入的数据收集流程,试图把一些 agentic 能力带进中期训练。
四个专家模型与蒸馏回单一模型
然后 Qwen 的人做了一件我不确定以前见过的事,其实相当有意思。他们拿中期训练好的 Qwen3 next 模型,实际上要训练一大堆针对不同编码相关任务的专家模型。他们最终得到四个不同的 agent 专家,然后他们把它们全部蒸馏回同一个模型。
我只能推测他们为什么这么做,我不认为我在其他工作里见过。我知道的最近的例子是 DeepSeek V3——还是 V3?V3.5,抱歉,DeepSeek V3.5 或 3.2,也许是 3.2。抱歉,3.2,我想他们做的是:有不同的专家,唯一的工作是格式化和处理数据,所以有数据处理专家,为完整训练生成数据。但“生成不同的模型专家然后蒸馏回来“这件事,我不认为我见过。这其实更接近一些学术工作,比如 branch train merge 之类。那些确实是有人做的事,我在前沿模型训练里不太见到。
话虽如此,每个专家其实都挺有意思。他们做的事情基本是:对他们定义的一大堆不同子任务,做完整的 RL 和/或 SFT 训练。所以有一个 web 开发专家,他们基于各种检查在有效的 web 代码上做 SFT;他们做 UX 专家,在很多不同的工具格式上训练;还有 QA——QA agent 只是在更多代码合成数据上训练。但对我们来说最有趣、也最复杂的,可能是他们的软件工程 agent。
他们做的是大规模地构造 agent 环境。SWE-bench 是这类东西的黄金标准,所以他们想要 SWE-bench,但要更多。他们出去,用各种基于 GitHub 的自动化方法生成一大堆 issue。有了这些,他们想在这些环境上做 RL——某种意义上非常不令人意外。他们得到了什么?他们能让 RL 性能上升。
有一点我要说,这在我们讨论的更广语境里非常重要:我们之所以能把越来越多的算力投进 RL,是因为我们相信我们的奖励模型没法被黑、或者很难被黑。如果这个假设崩了,你的 RL 方法会找到越来越隐蔽的方式作弊,骗走你的性能。
他们有一张很好的图:在 Git 里,你可以看未来的 commit 或不同的 commit。如果你有一个 issue,而且有未来的 commit,你就可以直接查到修复是什么。这是模型很容易学会的一个 hack。所以他们基本上有一整套奖励,其唯一目的就是阻止 agent 动 Git 历史。如果你不做这件事,你会得到右边那样的图:学啊学啊学,突然出现一个涌现式的跳变,而这个跳变其实是它学会了操纵 Git 调用去获取历史。某些情况下,它甚至能绕开你给它的某些约束:如果你告诉它不能用 git log,它可能会加一个类似 remote 的 origin,然后查询那个 remote,看某些 commit 里发生了什么。所以你能做各种 hack。
RLVR 的稳健性只取决于你的奖励,而你的奖励有时可能一点也不稳健。
顺便说一句,我的一个学生和我曾做一个 RL on Lean 的项目。Lean 是一种形式化数学的可验证语言。我们当时天真地以为:这不可能出问题吧。很多人在 Lean 上做过工作,Lean 编译器是防弹的。结果发现 Lean 编译器不是对抗性稳健的。你可以往里放某些字符串,让它在某些模式下验证本不该被验证的证明。所以我认为“可验证奖励“这个概念,实际上比你们很多人一开始想的要棘手得多。
无论如何,你做完这个大规模构造 GitHub 仓库的过程,走完做 RL 的流程,看到分数缓慢但稳定地上升,最后你实际上会得到一个好得惊人的系统——一个在 SWE-bench 上达到大约 70.6% 的模型,即使这基本是个非常小的模型,一个 30 亿激活参数的模型,也能在这些任务上做得意外地好。
不过在某种意义上,我不确定这是不是特别令人惊讶。RL 嘛,你当然会在你训练过的环境上做得好,你甚至可能泛化到验证集,就像这里一样。但你总是要小心一点,不要随便比较性能。任务特定的性能并不一定意味着它能泛化到更广的领域。
收尾:一切都关于奖励,GRPO 必须烂熟于心
好,这基本就是我在 RLVR 这讲里要讲的全部了。记住这讲的核心要点:一切都真的是关于 RL 的奖励。RLHF 和 RLVR 可以说是非常相似的问题,区别真的只在于:我们想要更不可黑的奖励,这样我们才能真正投入多得多的算力,让这些系统变得好得多。GRPO,至少对研究社区来说,是真正让这一切成为可能的东西。你们都应该非常熟悉 GRPO,应该知道它的函数形式、更新是怎么做的。这也应该像预训练损失一样,成为你们必须知道的东西。
最后,现在我觉得很多人都知道怎么做 RLVR 了。你们会发现的,不幸的是,RL 依然非常难伺候、非常吵闹,用起来很痛苦。但它并没有那么难。它不像过去对各种非常棘手环境做 PPO 的旧日子,它其实比你想象的平滑得多。
我在这里停一下,在我放你们走之前回答最后几个问题,看有没有人提问。
【提问】一个很快的问题——你提到模型思考模式的开和关,是模型里的权重相同吗?还是靠提示词,还是有不同的底层模型在后端调用?
回答:问题是关于思考模式、后端在发生什么。思考模式有意思的地方在于,它其实就是一个模型,只是有一个小小的 prompt 标签在长和短之间切换它,比如长 COT 和无 COT 模式。而如果你只是搞一个 API 标志之类的,那并不难。他们有趣的地方在于把两者放在了一起。所以你可以自己选,然后自己额外加 prompt 来做 COT?是的,或者说在这个例子里,控制机制在 prompt 里,而不是在 API 或服务层之类的。
【提问】你提到中期训练这个阶段,我想知道中期训练里的格式化有多少会影响 RLVR 后训练能学到什么;第二个问题是,如果中期训练里没有正确的数据,是不是可以认为强化学习采样不到正确的解,因此就学不了?
回答:问题是关于中期训练在 RL 里的作用。我想这里有很多经验上的微妙之处。我没有一刀切的答案。它肯定不会作为答案成立,但讲一点细微之处:我认为预训练和 SFT 承担了大量重活。只要你覆盖到位——如果预训练里根本没有代码数据,那你就麻烦了,你需要训练。但如果你的预训练非常多样、覆盖了,让我们回到中期训练那张幻灯片——如果它覆盖了很多文本、代码数据,覆盖了一堆 GitHub 数据,那么这是很好的,但也许不是关键,因为你反正要做 SFT,而 SFT 能让你足够接近,从而在 RL 里开始拿到一些奖励。如果你没有 SFT,那你就陷入 DeepSeek 式的麻烦了。但我们在 RL 前总会做一些 SFT,这真的帮到你;而预训练有足够的覆盖,希望能让你得到一些合理的泛化。所以中期训练有很好、对更好的泛化很重要,但不一定是成败关键。
【提问】训练完奖励模型之后还有一步,我们最终还是会得到一个单一模型。那这种蒸馏是怎么工作的?你必须设计一个混合的流程吗?
回答:对。蒸馏流程需要你写下一系列 prompt,在这些 prompt 上把专家蒸馏进最终模型。我完全能看到这种方法的优势:你可以让不同的团队各自做不同的专家,让团队分工容易得多;然后聚合也可能很简单,如果你有足够的算力。只是——如果你已经把所有目标都有了,你不如直接把它们扔进一个大训练循环。通常我觉得那才是你更愿意做的:那样你就不用处理这个蒸馏的复杂性,你可以只有一个大训练目标,然后跑一个训练循环。
【提问】长推理训练也是训练的一部分吗,是中期训练的一部分吗?
回答:问题是最长推理训练是不是中期训练的一部分。我想 R1 和 Kimi K5.1 里都有长 COT 的 SFT。长 COT 传统上不是中期训练的一部分,但它常常——长 COT 类的数据被用在长上下文扩展里,所以有点微妙。我完全没讲长上下文扩展,我对此感到遗憾。但通常那是在 RLHF 之前或者其他事情之前的一个额外阶段,在这个例子里也包括长 COT,你用你拥有的任何长上下文数据去扩展上下文。通常是书、代码、合成数据的组合,因为那些是足够长、可以用来做扩展的东西。
【提问】就像现在这张图相关的,你做这个过程的时候,比如说数学、化学、很多其他领域。是做并行的、一次强化学习训练跑,还是顺序做?如果是顺序做,怎么避免遗忘前面学的东西?
回答:我觉得最接近这个问题答案的是这些图:你的两个分流是把问题分成推理问题和非推理问题。推理问题都进一个桶,在第二阶段执行;非推理问题在最后的 RLHF 阶段发生,这包括闲聊之类的东西,全都进后面那块,也就是第四阶段。
下周见。
多模态:从 CLIP、SigLIP 到 LLaVA、Qwen 与 Chameleon
本讲是多模态模型的概览:先讲 CLIP 与 SigLIP 这两个图像编码器,再讲 LLaVA 和 Qwen 两族视觉语言模型如何把图像接入语言模型,最后讨论 Chameleon 的全离散 token 方案以及多模态建模的根本挑战。
为什么这门课要讲多模态
原本的计划是多讲一些强化学习的内容。但我想既然我只有一讲的时间,如果不讲一点多模态,这门课就会显得不太完整——因为如果你去看所有的主流模型,多模态已经非常普遍。所以这一讲会是多模态模型的一个概览。当然,这本身完全可以单独开成一门课,我只能在一讲里尽量多讲一些。
到目前为止,这门课我们一直只关注语言模型。而语言模型本身已经相当通用:它给你从任意一段文本到任意一段文本的能力,可以是任何语言、代码、诗歌,任何你喜欢的东西,甚至比如说 DNA。但世界是多模态的:有文本、图像、音频、视频。
如果去想我们想要到达的“北极星“,那就是人们所说的 omni model(全能模型):能够接收这些模态的任意组合,并输出这些模态的任意组合。比如说,我可以给你一张图像和一段视频,然后针对这张图像和这段视频提问;我可以生成图像;我可以把音频转换成图像。有了 omni 模型,你可以想象出各种各样的应用。
在这门课里,我不会一路搭建出一个完整的 omni 模型,但我会给你一些零件,至少让你能够吃进一点图像——今天主要聚焦在图像上。
Transformer 需要 token,而图像没有现成的 token
那么怎么构建这样一个东西呢?用这种方式引入有点奇怪,但现实就是:Transformer 工作得非常好。尽管很多人努力尝试别的东西,但在所有这些模态上、在大规模下,Transformer 仍然是我们拥有的最好的东西。
所以我们必须弄清楚如何使用它们。而如今 Transformer 是为文本设计的,因此它们有这样的特性:它们使用 token。它们接收一堆 token 作为输入,然后输出一些 token。在这里,我把 tokenization 的概念扩展了一下,使它不只是我们在文本里见过的离散 token,也可以是连续的 token——你可以把它们看作 token 的嵌入。关键在于,一个 token 应该表示某种语义单元的信息。例如在自然语言里,token 是子词(subword),它们有一定意义。而比如说,一个像素本身当然不是有意义的。所以我们必须以某种方式把所有东西,包括音频和图像,转换成离散或连续的 token。
请注意,即使是文本,我们也已经不得不这样做了。在第一讲里我们讨论过 tokenization,在那里这还不算太糟:我们有一个 BPE tokenizer,你们实现了它,这没问题。人们可能会希望有更好的 tokenizer,或者有某种根本不需要 tokenizer 的东西,但这也不是世界上最糟糕的事情。而现在,对于非文本模态,我们需要更加绞尽脑汁,弄清楚 BPE tokenizer 的等价物是什么,它能接收一张图像并生成 Transformer 可以消化的东西。所以这里你必须回答两个问题。一是,对于非文本数据,例如图像、视频或音频,如何把它们输入到 Transformer 中?二是如何把它们生成出来?本讲将主要关注第一个问题。
CLIP:用对比学习把图像和文本对齐
好,让我们回到过去,把时间倒回到 2021 年。CLIP 模型,你们中一些人可能熟悉,是现代视觉语言模型的许多基础之一,我们可以把它讲得更细一些。CLIP 的全称是对比语言-图像预训练(Contrastive Language-Image Pre-Training)。一点历史背景是:在那段时间,GPT-3 已经出现了,GPT-2 也是,所有语言模型都已经进入了基础模型(foundation model)时代。
而视觉领域当时已经有一些努力,但传统上是基于大规模标注数据集,比如 ImageNet,在这些数据集上训练 ResNet 之类的模型,再做一堆别的事情,比如数据增强,从而得到非常好的性能。所以 OpenAI 的研究者在想:有没有可能利用互联网上存在的大量图像和文本描述?这受到语言领域这个想法的启发——你只要去爬互联网,就有了所有文本,它非常嘈杂,但不知怎么地,只要你训练一个足够大的模型,你就能从中理出头绪、生成有用的东西。那么对图像来说,等价的东西是什么?于是 CLIP 诞生了。
CLIP 的想法相当简单直接。假设我给你一堆图像-文本对,比如说 32,000 对。我要做的是:对每张图像,用一个图像编码器(具体是什么我稍后解释)把它编码成一个向量,这样我就有 32,000 个图像编码;对相应的文本我也做同样的事,得到 T1 到 TN,这些是文本的嵌入,那些是图像的嵌入。现在我要建立的目标是:对 I1 来说,它与 T1 是对齐的,所以我希望这两个嵌入之间的点积要比 I1 与其他所有文本之间的点积大得多。反过来,对这段文本我也希望:这个点积要比 T1 与其他所有图像的点积大。这就是 CLIP 目标的核心。
你可以想象,这基本上就是 2N 个不同的 softmax 分类问题。这是代码:你得到一个 n×d 的图像编码矩阵,和 n×d 的文本嵌入矩阵,做归一化,然后带上一个温度系数(temperature)做点积,再计算交叉熵。所以它基本上就是一个多分类问题,只不过样本被组织成这种 m×n 矩阵的形式。
关于 CLIP 是怎么建起来的,还有一些细节。数据从哪来?论文里关于数据没有太多细节。大致上,你从某处拿一堆查询,然后到网上搜索这些查询,挖掘出一堆图像和文本对,这样得到了 4 亿个图像-文本对。这个数据集没有公开,但有一篇叫 OpenCLIP 的论文复现并进一步扩展了 CLIP 的想法。有一个叫 LAION-5B 的数据集被发布出来,包含 50 亿张图像及其文本描述,中间经过了大量处理。有意思的是,他们实际上用 CLIP 来做数据过滤,然后再训练 OpenCLIP,所以这里面有一些自举(bootstrapping)的成分。但至少这是一个你能指出它训练所用的数据集、也能拿到代码的模型。
还有几个细节。图像的分辨率五花八门:你在网上找到的一张图像,可能是又长又窄的,也可能是又高又瘦的,尺寸是任意的。而你在学习神经网络时会知道的一件事是:它们不喜欢动态的东西,它们希望东西是固定大小的。所以有一个某种程度上是启发式的处理:他们把图像缩放,使短边是 336 或别的目标尺寸——224 也可以——然后中心裁剪(center crop)得到正方形。这显然是为了方便。后面我们会看到你可以做得比这更好,但这只是为了省事。另外在那个时间点,CLIP 的作者想的是 ImageNet 和分类任务,通常物体在正中间,你只是裁掉一些背景,所以这不太要紧。
那么视觉编码器是什么?这是通用算法,但我还没指定图像和文本编码器是什么。
(有人提问:为什么他们在图像-文本对上训练,而不是在图像上训练?)
对这个问题:有一条工作路线试图基于数据增强把图像编码成表示。比如说有一篇叫 SimCLR 的工作:你拿一张图像,对它做增强——裁剪、随机扰动、旋转——然后希望说这是同一张图像,它们应该有相似的嵌入。这对低层细节是有用的。但问题是,你没法通过数据增强从一种狗变到另一种狗。所以通过使用文本,你能得到图像更高层的语义表示。这是个好问题。后面我们会看到,如果去看人们试图解决的任务类型,这些语义表示是很有用的。
那么 CLIP 用的视觉编码器是什么?他们实际上试了好几种,包括 ResNet 和刚刚出现的视觉 Transformer(ViT)。他们发现视觉 Transformer 表现最好,所以当人们说 CLIP 时,通常指的是 ViT 版本。视觉 Transformer 做的事情是:你把一张图像切分成这些小 patch,我记得原论文是 16×16,CLIP 用的是 14×14。总之你有这些小 patch,它们本质上就是向量。所以每个 patch 在某种意义上就是这个视觉 Transformer 的一个 token。你加上位置嵌入,就像训练语言模型时那样,然后把它送进一个标准的 Transformer。
Transformer 编码器产生一串向量。CLIP 论文的做法稍有不同:你也可以把这些向量全部平均,得到一个单一向量,但他们认为更好的做法是注意力池化(attention pooling),意思是你先取所有激活值的全局平均,然后用它作为 query,再对每个位置的 key 和 value 做一轮注意力。这会给你另一个向量,它可能比直接平均更有信息量一些。
这就是 ViT。CLIP 论文发现最好的模型是所谓的 ViT-L/14。解读一下:这是一个 large 的 ViT,我记得大概有 24 层,我不是很确定;patch 是 14×14;每个 patch 是 RGB,所以有三个通道;他们在 336×336 分辨率的图像上训练。其实他们一开始是在较低分辨率上训练的,我猜是为了速度,因为高分辨率图像更慢,然后在训练的后期才用更高分辨率训练。
(有人问位置嵌入:这个任务只有线性的一维位置,能不能做得更聪明?)
问题是,你能不能在位置嵌入上更聪明一点,因为这里只是线性的一维位置。我想在 CLIP 论文里,他们试过某种 2D 版本,并且发现其实没有太大影响。我觉得我也总是要带着“他们关心的是分类“这个前提来看这些结果。对分类来说,这可能不重要。后面我们会看到更讲究的位置嵌入,它们确实会考虑空间结构。
文本编码器是一个标准 Transformer,是 GPT-2 风格的,因为 GPT-2 出自同一组人。为了从序列中得到单个向量,他们在开头加了 BOS、结尾加了 EOS,然后取最高层上 EOS 位置的激活值,作为整个序列的表示。
这样你有了视觉编码器、有了文本编码器,然后就可以做这个过程来训练:取一个 batch,把所有文本编码、所有图像编码,形成这两个方向的交叉熵损失,然后从那里开始。
头条结果——这也是 2021 年让很多人对 CLIP 兴奋的原因——是在 ImageNet 基准上,零样本(zero-shot)的 CLIP 超过了在 120 万张 ImageNet 图像上训练的 ResNet。想想看,120 万张 ImageNet 图像的标注是很多很多小时的 Amazon Mechanical Turk 工人时间。而现在 CLIP 是在更自然的网络数据上训练的——当然,那也是网上很多人的劳动——但如果你能利用这些既有数据,你就可以直接零样本地用。零样本技术基本上是:你拿一张图像,然后有一堆文本,你把它和各个标签做点积,看哪个最高。
(有人问:如果一张图里有狗,而其他文本描述里也有狗,会不会让模型困惑?)
如果有一张狗的图,可能其他描述里也有狗,会不会让模型困惑?总体上这个过程是有噪声的,但如果还有另一只狗,也没关系,因为平均来看,不太可能总是有一只狗——有时候是苹果、猫或别的东西。另外一点是,这些图像基本上是从网上抓来的,旁边有文字,或者在 alt 图像属性里,所以它们极其嘈杂。有研究看过这件事:当你有一张图像的描述时,它不一定逐字描述图像里有什么。因为如果一张图是狗,你并不需要说“一只狗“。所以这个过程非常嘈杂。它居然能跑通,这也许有些令人惊讶,但也很有意思。我的意思是,确实需要做大量的数据过滤;如果你拿网上任意的图像和文本,那可能会太嘈杂了。
另外还有一点值得注意,它和我最后要回到的一个点有关:他们也试过另一种方案,不做这种排序,而是从图像预测文本。他们可以设定一个目标,从图像去预测文本,可以是词袋(bag of words)形式,也可以是语言模型形式。他们展示出——也许有点出人意料——使用更强的模型反而更差,或者说至少效率更低,不如用词袋的 CLIP。这说明,对于你试图学到的表示(或者说 ImageNet 准确率,也就是分类)来说,精确地建模 caption 的 token 序列,对于得到图像的粗略表示并不是那么重要。这个点我们最后会回来讲。
到目前为止我们学到了什么?我们用 CLIP 编码了图像,它捕获了图像的语义,因为它和文本配对,而文本一般谈论的是语义。要注意的是,这里的设计决策是基于图像分类的,所以它并不非常细粒度。但正如我们将看到的,它仍然是一个稳健的起点,后面我们做的一切都可以从这里出发。
CLIP 的一个技术上的缺点是它需要很大的 batch size,比如 30,000。如果你 batch size 是 1,显然不行;即使是 10 也不行。而且 softmax 操作是在整个 batch 上进行的,所以它不是很好分解。相比之下,正常的语言模型训练里,一个 batch 的样本,所有序列都可以并行,最后再做聚合。
SigLIP:把对比学习变成二分类
这个问题会被 Google 的另一篇论文 SigLIP 解决。可以把它看成 CLIP 的改进版本,名字的意思是“用于语言-图像预训练的信号量损失“(Sigmoid Loss for Language-Image Pre-Training)。主要的区别是:CLIP 做的是多分类,它说“这个文本和这张图像是对齐的“,然后拿它去对抗所有其他候选图像或其他候选文本。SigLIP 某种意义上简单得多:它对任意给定的图像-文本对,只问它们是否对齐。如果参照上面这张图,它基本上就是二分类:对角线上的元素是正例,非对角线上的元素是负例,就这样。
这是算法:取嵌入、归一化、照常做点积,但现在构造标签——非对角线上是 -1,对角线上是 +1,然后做 log_sigmoid。目标函数非常简单。
(有人问:这需要复杂的采样策略吗?)
问题是这需不需要复杂的采样策略。至少在最初的论文里,他们就是直接在这个同样形式的矩阵上操作。你可以想象——我想你大概是在一般意义上考虑这类对比方法:负例有时需要做平衡,或者负例需要是“紧“的负例,以免产生偏差。但至少在最初的版本里,这个是相当直接的。
说回数据,这是在 Google 做的。他们用了另一篇 2022 年前后的语言-图像模型论文的数据集 WebLI,包含十亿量级的图像-文本对。他们还做了一些额外的工作,比如对图像中包含文字的情况做 OCR,这是形成文本-图像对的另一种方式,并且做了一些过滤。这是多语言的。
这篇论文的主要观点是它比 CLIP 训练高效得多。CLIP 用 256 块 TPUv3 训练了 10 天,而 SigLIP 用 32 块 TPUv4 训练了 5 天。你可能会想,v4 应该比 v3 快;但就每秒浮点运算数而言,它们其实并不更快。它们更好是因为你能在一个 pod 里放更多,互连也更好,但在这个规模下其实并不更快——实际上大概还要慢 60%。
SigLIP 之所以更快,是因为你可以这样并行化。如果你回想系统课的内容,你可以把它想成类似 DDP:每个设备存一部分图像-文本对。但和语言模型训练不同——那里一切都可分解——这里样本之间存在交互。所以在第一遍,每个设备只计算它本地那些图像-文本对上的损失,然后你把文本发出去:设备 1 拿到 T5 到 T8,现在它就能计算这里的负例;然后它再拿到 T9 到 T12。这样轮转下去,直到覆盖所有非对角的块。
SigLIP 另一个好处是它实际上把 batch size 和损失解耦了。在 CLIP 里,损失和 batch size 绑在一起:如果你改变 batch size,那就是另一个损失函数了。所以他们能用小得多的 batch size 做实验,小于 16K。在那个区间里,它比 CLIP 好得多,因为 CLIP 的损失函数在 batch size 太小时会退化。而对 CLIP 来说,更小的 batch size 只是方差更大,但期望上是同一个损失、同一个函数。他们还说你可以用大得多的 batch size,但那并没有什么帮助。我们看到临界 batch size 在某个位置就饱和了,而 32K 基本上就是他们的临界 batch size。
从图像编码器到视觉语言模型:LLaVA 的思路
现在我们有了 CLIP 和 SigLIP,这些图像编码器可以接收一张图像——在这个版本里就是固定尺寸的,比如 336×336——并把它映射成一个向量,向量里包含一些语义。现在让我们开始构建 VLM,也就是视觉语言模型。我要讲两个模型家族:LLaVA 和 Qwen。它们在大的模板上非常相似,有一些细节不同,我们会过一遍。基本想法是:我们把这些嵌入拿到,然后直接注入到我们的语言模型里。这更像是 mid-training 或 post-training 的味道:我们拿一个现成的图像编码器,拿一个现成的 LLM,然后把它们缝在一起,而不是从头训练。
LLaVA 论文发表于 2023 年。它让很多人兴奋,因为在那段时间,闭源模型——我记得是 GPT-4——已经能做视觉推理,而他们能展示自己也能做一些视觉推理。当然它不如 GPT-4 好,但我想它是一个开放模型,人们能看到里面到底发生了什么。
对于 VLM,基本上有这么几个部件。一个是视觉编码器,他们这里用的是 CLIP。文本解码器他们用的是一个叫 Vicuna 的语言模型,那是最早的 Llama 模型,在一些 ShareGPT 对话上做了微调——那些是人们在和 ChatGPT 对话后分享到某个网站上的对话。我觉得那个网站现在应该已经关了。
他们用的数据基于 MS COCO。这是一个标注数据集,他们让 Mechanical Turk 工人去标注一堆图像,给出边界框和描述图像内容的 caption。于是他们基本上是为自己的模型合成数据集:他们用这些 caption 或检测到的物体去提示 GPT-4,让 GPT-4 生成问题或对话。比如这是 MS COCO 数据集里的一张图,这是人类写的标注 caption,这里也是人类标注的。然后你让 GPT-4 生成一段对话,你可能会得到一个问题和一个答案;你也可以让 GPT-4 生成一段详细描述,它生成的差不多和 caption 类似;然后你可以让 GPT-4 生成复杂的推理,它就会做出类似那样的东西。
所以那些是合成数据,总共 158,000 个样本,然后他们在上面训练了一个模型。他们用的是 CLIP,具体说是表现最好的那版 CLIP,ViT-L/14,14×14 的 patch。
我们来看这张图。他们拿图像,送进视觉编码器 CLIP,得到一个向量。但这个向量和文本并不真正处在同一个空间里。所以他们乘上一个矩阵 W,得到另一个向量,这个向量和文本嵌入处在同一个空间。文本那边你就用标准的嵌入得到向量。所以基本上发生的事情是:文本被编码成这些向量,图像也被编码成这些向量,然后整个向量序列经过一个标准 Transformer 并产生输出。
所以我们在某种意义上是在把这些图像转换成文本 token,这样我就能利用预训练好的语言模型。为了训练这个模型,他们分两个阶段。第一阶段冻结视觉编码器和语言模型,只训练这个矩阵 W,他们管这叫对齐阶段——本质上就是确保图像被映射到同一个空间里。因为如果你用一个随机的 W,这些图像不会——它们肯定不是表示任何自然语言 token 的嵌入。训练 W 的目标就是让它们看起来像自然语言 token 的嵌入。第二阶段是仍然冻结视觉编码器,同时训练 W 和语言模型。也就是说,他们在自己的数据上微调语言模型。记住,他们的数据基本上就是:这里有一张图像,这里有一段对话、一段描述或一个复杂推理的例子。所以这些都是图像加文本到文本。
这是他们论文里的一个例子,挺有意思。输入是一张图像和一段对话,用户问“这张图里有什么不寻常的地方“。他们的模型能告诉你:你通常不会在小型面包车后面熨衣服。他们还特别指出自己的模型相当不错,因为即使你的用户提示并没有真的在问“不寻常“之处,它仍然会谈到这一点。GPT-4 当然能做到,但当时的其他模型做不到。
那是 2023 年。接下来我跳到 2024 年的 LLaVA OneVision。LLaVA 论文之后还有一系列论文:LLaVA 1.5、LLaVA-Next。我会把所有这些创新都涵盖在接下来要讲的内容里。他们做的主要事情基本上是同样的配方,但在能做的多模态应用类型上更有野心了:他们能处理多张图像和视频。这是同样的图,现在他们可以接收单张图像、多张图像,或者一段视频——视频本质上就是一系列图像,对应着对帧的采样。视觉编码器升级成了 SigLIP;文本解码器他们现在用 Qwen-2,那大概是当时最好的语言模型;然后是投影器或者说适配器(adaptor),也就是把视觉编码器的输出变成语言模型输入的那个东西,这里用的是两层 MLP 而不是线性投影。所以就像你有一个系统,只是把各个部件都升级了一遍,但整体系统还是差不多的。
我们稍微谈一下数据处理。他们想做的一件事是 OCR。OCR 的问题在于你必须保留非常细粒度的信息,否则 J 看起来就像 I,那就不行了。记住 CLIP 会缩放并裁剪到 336×336。而很明显,如果你有一份文档,裁剪到 336×336,你就读不了了。他们的解决方案是一个叫 AnyRes 的想法,实际上是在 LLaVA 1.5 论文里提出来的。想法很直接:你把一张图像切成多块,每一块都是视觉编码器能接受的分辨率,比如 336×336。于是你把所有块都编码成一堆向量,然后把向量拼接起来。也就是说,你注意到你的视觉编码器处理不了高分辨率,所以不去下采样,而是直接裁剪、分别看图像的不同部分。
而现在,如果你有分辨率特别高的图像或视频,你就会得到太多 token,那他们就做下采样。AnyRes 背后的想法是它是自适应的,这很好,因为 Transformer 本来就是自适应的:语言、句子可以是任意长度,Transformer 已经处理得挺好。结果发现图像也可以是任意分辨率,所以这正好用上了同样的动态能力。
这是他们对图像实际做的事情。假设你有这样一张论文的图:他们确实做了下采样,有一条路径基本上是整篇论文的下采样编码;然后他们把它切成这些块,每一块都是视觉编码器期望的分辨率,再分别编码每一块,然后把所有这些 patch 拼接起来。如果 patch 太多,就通过插值减少 patch 的数量。
这样他们就能处理图像、多张图像和视频。你会想,这些在技术上都可归约为图像,但他们稍微“动了点手脚“,因为他们想确保各个模态大致是可比的。因为视频可以非常长,他们不希望自己的数据集被一堆重复的帧主导。他们的做法是这样:对单张图像,他们基本上有整张图的下采样版本,然后最多 N 个裁剪,最多 9 个。如果你拿到的是多张图像,那他们就只用基础分辨率。所以如果你只有一张图,我可以更仔细地看它;如果我有多张图,我就远远地看一下。
而对视频,他们会用更低的分辨率,或者说用更少的 token 来表示每一帧,因为视频可以——他们最多只用 32 帧,但你在观看时就会开始撞上上下文长度的问题。后面我们会看到,能处理多模态的很大一部分工作,其实就是处理更长的上下文。
他们这里用的数据,我想说,其理念是要精挑细选高质量的数据,同时也要有量。换一种说法,这是非常有的放矢的数据。如果你看其中很多数据,它们非常任务导向,不管是做视觉问答,还是回答关于表格的问题。所以这绝对是 post-training 的领域:你试图让模型做这些任务,因此你造出一堆这样的任务。而且这项工作也毫不掩饰地在蒸馏 GPT-4 模型,以便拿到最好的性能——我猜这并不理想,但如果你没有标注预算,这就是你会做的事。他们有单图、多图和视频,所以有很多不同的部分,其中一些非常具体,比如给你两张图,让你找出不同。
训练上大致和之前一样:第一阶段专注对齐,具体来说只训练投影器,也就是适配器;然后在第二阶段和第三阶段训练整个模型。之前我们有两个阶段,现在有三个阶段。我不确定这里有什么特别有原则性的理由,除了第二阶段是想放入高质量但更偏知识的数据,而最后一个阶段聚焦在看起来像下游任务的例子上。
这篇论文里有一个有意思的发现:模态之间会出现迁移。尽管他们只有单图的图表和 diagram 数据,但这实际上能泛化到多张图像。训练时你从没见过一个例子是既有表格又有图表、然后你针对它提问的;但在测试时,你有两张图像,一张是表格、一张是图表,模型能就它进行一些对话。训练时 OCR 数据只有单图,关系推理只有多图,但它能泛化到这些情形,这对驱动 GUI agent 很有用——你给它一堆截图,这是多张图像,它能生成对这些截图的描述。
另一个例子是他们所谓的视觉提示(visual prompting):图中有一个圆圈,它是图像的一部分,但意思是高亮出你要对它做些什么的那部分图像。这类数据只存在于单张图像上,而现在它能泛化到视频:用户说“描述视频中被高亮的球员“,而这个球员跨越了多帧。所以乍一看你会说“天哪“,你基本上是在针对每一个任务做监督学习;但如果任务足够多,这些模型似乎能做出一些迁移,我想这还是让人安心的。
再说一次,这是一个相当标准的 VLM 模板:视觉编码器加投影器,接进语言模型。数据整理上要花很多工夫,他们很倚重合成任务特定的数据。LLaVA 系列的好处之一是它是少数开源的工作之一——不只是模型权重,还包括数据,所以你可以真正复现和研究这些东西。
Qwen 系列:从 Qwen-VL 到 Qwen-3
现在讲 Qwen 模型。Qwen 也是从 2023 年开始训练多模态模型的,第一个版本是 Qwen-VL。希望我能讲得快一点,因为你们应该已经看出套路了。视觉编码器他们用 OpenCLIP——记得 OpenCLIP 是 CLIP 模型的开放复现,所以基本上就是一个 CLIP 编码器。适配器方面,他们做了一层交叉注意力(cross-attention),引入 2D 位置嵌入,然后映射到固定的 256 大小——这有点……我猜这后面会改,因为这显然不太动态,不过在这个时间点视觉编码器也不动态。特殊 token 方面,他们有一个 image tag、一个用于边界框的 box tag,以及用于描述的 ref tag。这就是幻灯片用 HTML 做会出的问题。
训练上他们有三个阶段,和 LLaVA 模型一样。第一阶段,他们的想法是——他们管这叫预训练,但不是从头预训练——这通常是大规模、低质量的数据。他们冻结语言模型,训练视觉编码器和适配器。这和 LLaVA 模型有点不同:在这一点上他们会训练视觉编码器。你可以看到他们训练用的一些数据集,14 亿个样本。第二阶段,他们转向更高质量的任务特定数据,你能看到一些常见的 VQA 数据集、图表问答等等,这时他们训练所有参数。最后第三阶段是指令微调数据:冻结视觉编码器,训练适配器和语言模型。
这里有一些他们能做的事情的例子。当然,这是 Qwen,所以有些例子是中文的。他们的兴趣还包括做一个能涵盖语言模型的模型:语言模型能做代码,所以我们希望视觉模型在代码上也擅长。比如这个例子——如果你能看清的话——是一张图像,然后问“你能找到蜘蛛侠和绿巨人吗“,它还能输出边界框。它实际上不输出图像,只输出边界框。然后你还可以做 OCR、做比较——就是那些常见的事。
Qwen-2 又是一次升级,这里面有一些新想法,我来讲一下。他们用了更大的视觉编码器。他们还开始做动态分辨率,我觉得这是主要的变化。记得我们从 LLaVA 到 LLaVA OneVision 的时候,他们意识到:哦,我们需要处理不同尺寸的图像。而如果你要做视频,就明显需要某种动态分辨率。所以模型基本上是这样的:假设你有这张图,它可能被映射成 11,000 个 token;而这张很小的公式图片可能只映射成 8 个 token,诸如此类。
他们怎么做到的呢?基本上这和 LLaVA 的 AnyRes 想法一样,每个 224×224 的 patch 用一个 ViT 编码。记得他们一开始用的 ViT 是 OpenCLIP 那个,但它会被微调。然后为了缩小上下文长度,他们把每 2×2 压缩成 1,所以最终每个 patch 生成 66 个 token。对于视频,他们每秒采样 2 帧,但上限是 16,000 个 token。
回到位置嵌入这个问题:Qwen-2 在这上面确实做了不同的东西。他们用了所谓的多模态旋转位置编码(multimodal RoPE,M-RoPE)。我们之前见过 RoPE,它的想法是让你有这样一种嵌入:向量之间的内积只取决于距离,而这个距离在 1D 里就定义为相隔多少个 token。多维版本基本上是一回事,只不过现在是 3D 的:有高度、有宽度、有时间。现在每个位置、每个 patch 是一个由坐标定义的三元组。要计算 M-RoPE,基本上是对每个维度分别算 RoPE,然后拼接起来。所以这是个相当直接的想法。不过后面看 Qwen-3 会发现,这实际上有点次优。
语言模型他们用——这是 Qwen-VL 模型,所以当然是用 Qwen 来初始化。然后他们有三个阶段的训练,和我们之前做的非常像。他们展示了各种各样的能力:一些视频理解、能做数学和代码、函数调用,等等。
接下来我快速讲一下 Qwen-3。这里又有一些变化。我不会说这些是结构上的大变化,但它们可能是会真正影响模型质量的变化。这是去年 Qwen3-VL 的报告,图大致是一样的,我会过一遍其中一些变化。
一个是他们现在用的是 Qwen-3 模型。他们有一系列 dense 和 MoE 模型,Qwen-3 模型非常非常好,这对最终模型的质量真的很有帮助。他们投入的一件事是长上下文理解,上下文长度可以到 256K,如果你想处理长视频,这非常重要。
说回视觉编码器,他们用的是 SigLIP-2,基本上是 SigLIP 的改进版。架构其实是相同的,被设计成向后兼容。然后他们改进了 RoPE。解释一下:之前你有三个维度——时间、宽度、高度——基本上是先把第一块维度分给时间,再把下一块分给宽度,再下一块分给高度。问题在于——如果你记得 RoPE——每个分量代表不同的频率。所以这就意味着可能所有时间维度都是低频的,所有高度维度都是高频的。他们意识到这一点后,就把它们交错起来。现在所有轴都同时暴露在低频和高频上。
他们做的另一件事是显式的视频时间戳。如果你仔细看这个——这个可能有点太小了。以前,时间戳是隐式地存在于位置编码里的:视频里的每一帧天然会得到不同的——因为它在不同的位置上,所以有一个时间的概念。但在这里,他们把时间做成显式的:这些实际上是 token,“0 秒“是一个 token——现在你有一个代表时间的 token。他们发现这是有帮助的,大概是因为它是你可以直接引用的东西,比如“两秒之后发生了什么”。
他们还做了这个按 token 的平方根归一化损失。想法是:有些涉及视频的例子非常长,而有些只涉及单张图像的例子非常短。通常的做法是每个 token 都被同等对待,这就意味着视频例子会占主导。他们——细节不是很清楚——但我相信他们把每个例子按长度的平方根做了归一化,以降低超长例子的影响。
最后一件值得一提、也挺有意思的事,是他们在适配器上做的事。适配器是连接视觉编码器和语言模型的东西。LLaVA 是最简单的,就是一个线性投影;然后我们有了 MLP;然后有了某种交叉注意力。而 DeepStack——这其实是 DeepSeek 团队的一篇论文,但在这里被使用——更复杂。他们注意到视觉编码器本来就计算了一堆分层的视觉嵌入,于是他们基本上把这些直接加到语言模型的残差流(residual stream)里。所以这是把视觉编码器更深地融合进语言模型,而不是把视觉编码器当成一个只输出一串向量的黑盒。
训练上,他们有两个阶段:预训练实际上有四个阶段,后训练有三个阶段。你能看到这些流水线现在变得相当复杂了。他们先训练适配器,这跟所有那些模型一样。然后有三个阶段,逐步在越来越长的序列上训练,从 8K 到 32K 再到 256K,大部分 token 都在第 2 和第 3 阶段。后训练方面,他们在长思维链数据上做 SFT、做知识蒸馏,然后做一些强化学习。
到这一步,它真的就是一篇系统论文了。我标出了一些核心的新想法,但还有大量细节是不同的。如果你看最终结果,这是一个相当好的模型。我是指,如果把这些 benchmark 和闭源模型 Gemini、GPT-5、Opus 4.1 比较——这些数字,加粗表示它是那一行里最好的数字——Qwen 模型实际上相当强。
(有人问视频生成:怎么知道该输出视频还是文本?)
问题是视频生成怎么办,以及你怎么知道应该是视频还是文本。首先,这些模型不生成视频或图像,所有多模态的东西都在输入侧,你始终是在生成文本。而且在大多数这些训练阶段里——除了 RL——你都在监督每一个 token。所以这里没有 LLM 作为裁判来评判描述的质量,基本上就是你的数据集里有什么就是什么。而在 RL 里,你可以尝试不同的奖励。
(有人问:从系统角度看,训练多模态模型比训练纯 LLM 更难吗?)
两个问题是:训练多模态模型在系统层面更难吗?当然它不会更容易。你指出的其中一点是数据集更大。视频数据——光是加载它就可能成为瓶颈,而在讲语言模型的时候我们一般不太关注数据加载,因为它非常非常便宜。所以这一点需要考虑进去。同时,“确保数据加载和实际计算异步进行“这些原则也要考虑进来。第二个问题是……抱歉,第二个问题是什么?哦对。视频和图像的 token 更多,尤其是视频。所以这也是我们看到那个归一化的原因之一,为了确保我们不会给视频更多权重。你总是可以降权:如果 token 更多而你不喜欢这样,你可以降权。我们在上一讲或两周前讲数据混合(data mixture)时提过,你总可以按照合理的权重来加权。但外面也有很多文本 token。总的来说,如果看——这些模型是在几十万亿 token 上训练的。我不会说多模态 token 的数量大大超过文本 token。
(有人问:做对齐的时候,模型怎么知道语言的……语言模型是预训练好的吗?)
问题是,当你做这种对齐的时候,它怎么知道语言——语言模型是预训练过的吗?它必须得是预训练过的,否则对齐它就没有意义。语言模型是冻结的,你只是在训练适配器,把给定的视觉编码器和给定的语言模型连起来。训练的方式就是:你选一个 token 预算,比如 670 亿 token,然后就是训练。这里没有自适应的阈值。
(有人问:视觉编码器的参数量是不是小得多?)
问题是视觉编码器的参数是不是小得多。答案是:一般来说是的。原因是视觉编码器在某种意义上做的是一个非常局部的操作:它看一个 patch,patch 非常小,它只是试图理解这些 patch,没有太多知识。我们不是在推理。所以模型的大部分能力仍然在语言模型里。
如果你看 LLaVA 模型:完整的语言模型是几十亿参数的规模——哦不好意思,这里是 7200 万——所以它小得多,投影器小得多,而 ViT 一般不到 10 亿参数。我猜主要是因为这是个相当局部的操作,我猜这是最简单的说法。
Qwen-3 是最后一个视觉语言模型。我会说它是 SOTA 水平。这里有很多数据工作。后面这些 Qwen 模型关于数据和数据混合的细节不多,所以你得去看 LLaVA 论文,或者 AI2 有一篇论文(我没来得及展开讲)里有更多细节。从 Qwen 到 Qwen-2、Qwen-3,很多变化主要是——那里确实有一些变化,但整体框架保持一致,主要是规模扩大、整理更多数据集、意识到你的上下文不够用所以要处理长上下文,也许还有把图像处理打磨得更精细,等等。
Chameleon:把所有模态都变成离散 token
现在我要跳到一个很不一样的东西。这是 Meta 2024 年的一篇论文,叫 Chameleon。到目前为止,VLM 把图像编码成这些向量,然后注入语言模型;而因为它是语言模型,你只能生成文本,不能生成图像。有很多办法可以解决这个问题:你可以拿一个 VLM,再挂一个 diffusion head,这样你就能生成图像了。但我想讲这里这个想法:Chameleon 说,如果我们把一切都映射成离散 token 呢?从审美上说,这——也许这反映了我是一个“语言人“——这很吸引人,因为现在你可以用同样的方式分析和生成图像,一切都是离散 token。如果你想要生成图像,你可以说这里是一个文本提示,然后这些就是你可以生成的更多 token。你也可以把语言和文本调换过来。
他们论文里有这样的例子:你可以提示“我很无聊,能给我看一些鸟吗“,然后它会输出文本,然后图像,然后更多文本和更多图像,是交错(interleaved)的。所以在某种意义上,omni 模型的愿景是文本和图像真正生活在同一个空间里,而这是通过让一切都看起来像文本来实现的。这是一种做法。
我来讲一下这是怎么做的。你现在需要做的是把图像映射成离散 token。这里有一个相当古老的想法,来自 2017 年 Oord 的工作,叫做矢量量化变分自编码器(vector quantized variational autoencoder,VQ-VAE)。基本想法是把图像映射成一个离散编码。你学到一个映射,把图像映射成一个连续向量,然后把它取整到最近的码。所以你有一个码本(codebook),比如说有 8,000 个码,它们是原型的向量,对应着某些 patch,你挑最近的那个,那就是你的表示。这就是你的编码器:经过一个连续阶段,然后取整到最近的码。解码器则是你拿这个码,尝试重建图像。这些 VQ-VAE 通过最小化重建损失来训练。还有一些别的项你会加上,因为这个操作不可微,我没有时间展开。
总之,最终你有 512×512 的图像被转换成 1,024 个 token,每个 token 来自一个 8,000 大小的词表。
现在你基本上有了“文本“。你有图像,它们基本上就是这些码。他们训练了一个新的 tokenizer,因为现在你的数据和只有自然语言时看起来不一样了。训练现在实际上非常直接:这就是普通的语言模型训练。没有适配器,没有视觉编码器,现在只有一个语言模型。这部分是很吸引人的。训练仍然有两个阶段,但即使对语言模型训练来说,通常也有两个或更多阶段:第一阶段是训练的主体,基本是无监督的,他们既有纯文本、也有文本加图像;第二阶段混入高质量数据。
这真的很好、很优雅。但这里有几个问题。一个问题是他们发现训练不稳定。原因是:文本和图像尽管占据了同一个空间,它们的行为非常不同。把东西叫做离散 token,并不能掩盖那里住着一张图像这个事实。特别是,文本 token——如果你考虑预测下一个词——它的熵相对较低,大多数词是可预测的;而图像 token 的熵非常高:我不知道这个确切的 token 会是哪种蓝色。结果他们发现,用这种混合训练会导致很多参数的范数增长,并产生损失不稳定。他们能够在某种程度上通过 QK norm 和 z-loss 正则化来缓解或修复这个问题,这些方法控制范数的增长。
我甚至不打算展示结果了。但我想强调这项工作,因为它有某种优雅:只有一个模型,把所有模态真正同样对待。但缺点是,结果这个模型的性能并不真的那么好,而且离散化确实会丢失信息。想想 OCR:如果你把非常小的字离散化,你就再也读不出来了。最后,多模态的训练也很棘手——我们在 Qwen 模型那里也看到一点,我们得在权重上做文章,但在这里更被放大了。
VQ-VAE 曾经流行过一阵。事实上,很多人用它做图像生成。主要原因是:你有一个 Transformer,那你怎么从 Transformer 生成呢?你必须生成离散的东西,所以你把所有数据都变成离散形式。但后来 diffusion 模型出现了,在生成上变得流行且可行,所以这类方法不像以前那么流行了。
收尾与反思
也许在这里反思和总结一下。现在的前沿模型被期待是多模态的,或者更强烈地说,是原生多模态(natively multimodal)或 omni 模型。不幸的是,当 Gemini 或 GPT 发布时,它们被宣传为原生多模态、能处理所有这些模态——事实上它们确实能。但当然,关于它们是怎么构建的没有任何细节。我想很可能是一个组合:用连续编码器,因为你不希望丢信息;然后用 diffusion 做生成,因为——但这只是我的猜测。
我认为,处理多模态时的根本挑战是:你怎么处理非文本模态?还有一点很有意思:理解一个模态和生成它之间存在不对称。而且并没有一个通用的编码器。例如我们看 CLIP 时,你关心的只是分类,捕获高层语义,所以这些向量可以比较小、只捕获高层语义。而如果你想做 OCR,或者想生成图像,你就需要非常细粒度的细节。这就是为什么 diffusion 在生成上这么好,因为它能真正在低频和高频信息上做微观优化。
总的来说,当你处理多个模态时,你必须仔细考虑如何正确地给它们加权。例如视频的信息密度肯定比文本低,所以你不希望视频淹没你的文本。而且像我之前说的,至少目前最好的做法是用连续编码器。看起来即便是 CLIP 这种五年前的东西,或者类似的想法,仍然是捕获图像语义的首选方式。Transformer 仍然在那里。而 diffusion 模型——我没有讲——在生成上很好用。
好,我就讲到这里。这就是多模态这一讲的内容。我想我们没有关于这个的作业。但如果你好奇,我鼓励你去玩一玩、训练一些这类模型。
从预训练到强化学习:像 ChatGPT 这样的大语言模型到底是怎么做出来的
这堂课用面向普通听众的方式,按完整流水线讲解大语言模型如何从互联网文本、分词、神经网络训练,逐步变成能对话、会用工具、能推理的助手,并说明它们的能力边界和使用注意事项。
为什么要理解这个文本框背后的东西
大家好。我一直想做这个视频,因为我想给大家一个面向普通听众、但又比较全面的大语言模型介绍,比如 ChatGPT 这样的模型。我希望通过这个视频,让大家建立一些心智模型,去理解这个工具到底是什么。它在某些方面显然很神奇、很惊人;它有些事情做得很好,有些事情做得不太好,同时也有很多需要注意的锋利边缘。我们面对的这个文本框背后到底是什么?你可以在里面输入任何东西然后按回车,但我们到底应该输入什么?它生成回来的这些词又是什么?它是怎么工作的?你到底是在和什么东西说话?我希望在这个视频里讲到这些问题。我们会走完整个构建流程,但我会尽量让内容对普通听众也容易理解。
我们先来看怎么构建一个类似 ChatGPT 的东西。一路上,我也会讲一些这类工具在认知心理层面上的含义。好,那我们开始构建 ChatGPT。这里会有多个阶段按顺序排列。第一阶段叫预训练阶段(pre-training)。预训练阶段的第一步,是下载并处理互联网。为了大致了解这件事长什么样,我推荐大家看一个 URL:Hugging Face 这家公司收集、创建并整理了一个叫 FineWeb 的数据集,他们在博客文章里非常详细地讲了 FineWeb 数据集是怎么构建的。所有主要的大语言模型提供商,比如 OpenAI、Anthropic、Google 等,内部都会有某种类似 FineWeb 的等价数据集。
预训练的第一步:下载、清洗和筛选互联网文本
我们大概要实现什么目标?我们想从互联网上公开可得的来源中拿到大量文本。我们想要数量巨大的高质量文档,也想要非常大的文档多样性,因为我们希望模型里包含很多知识。所以我们要的是大量、多样、高质量的文档。要做到这一点其实非常复杂,你可以看到这里需要很多阶段才能做好。比如 FineWeb 这个数据集,作为生产级应用里会看到的东西的一个代表,最终大约只有 44TB 的磁盘空间。现在你很容易买到 1TB 的 U 盘,或者这差不多可以放进今天的一块硬盘里。所以尽管互联网非常非常大,但最终这不是一个特别巨大的数据量,因为我们处理的是文本,而且会非常激进地过滤。
很多这类工作的起点,也是最终贡献最多数据的来源,是 Common Crawl。Common Crawl 是一个从 2007 年开始就基本上一直在爬取互联网的组织。比如到 2024 年,Common Crawl 已经索引了 27 亿个网页。它有很多爬虫在互联网上跑。基本做法是从一些种子网页开始,然后跟随所有链接,不断跟随链接,不断索引信息,随着时间积累大量互联网数据。所以这通常是很多这类工作的起点。
Common Crawl 的数据非常原始,因此要经过很多不同方式的过滤。第一件事叫 URL 过滤。意思是会有一些 URL 或域名黑名单,这些来源的数据我们不想要。通常这包括恶意软件网站、垃圾网站、营销网站、种族主义网站、成人网站等。很多类型的网站会在这个阶段被直接移除,因为我们不想把它们放进数据集。第二部分是文本抽取。要记住,爬虫保存下来的网页是原始 HTML。当我在浏览器里点检查时,你看到的是原始 HTML:里面有各种标记,比如列表,还有 CSS 等等。这几乎是网页的计算机代码,但我们真正想要的是网页里的文本。我们想要正文,不想要导航栏之类的东西。所以这里有大量过滤、处理和启发式方法,用来尽量只保留网页里的好内容。
下一阶段是语言过滤。比如 FineWeb 使用语言分类器来过滤。他们会尝试猜测每个网页是什么语言,然后只保留英语比例超过 65% 的网页。这是一个设计决策,不同公司可以自己决定:在数据集中要包含多少比例的不同语言。比如如果我们把所有西班牙语过滤掉,那么你可以想象,之后训练出来的模型在西班牙语上可能就不会很好,因为它从来没有见过那么多这种语言的数据。不同公司可以不同程度地关注多语言表现。FineWeb 非常关注英语,所以如果他们之后训练一个语言模型,这个模型会很擅长英语,但可能不太擅长其他语言。
语言过滤之后,还有其他过滤步骤、去重等,最后还包括 PII 移除。PII 是 personally identifiable information,也就是个人可识别信息。比如地址、社会安全号码等,你会尝试检测它们,并把包含这类信息的网页也从数据集中滤掉。所以这里有很多阶段,我不会展开所有细节,但这确实是预处理里相当重要的一部分。最后我们得到 FineWeb 数据集。点进去可以看到一些最终进入训练集的文本例子。任何人都可以在 Hugging Face 页面下载它。比如这里有一篇关于 2012 年龙卷风的文章;下一篇是关于你身体里有两个黄色、9V 电池大小的肾上腺的医学类文章。你可以把它们理解成互联网上的网页,只不过经过各种方式过滤,只保留文本。现在我们有了大量文本,比如 40TB,这就是下一步的起点。
我想给你一个直观感受:我们现在到底处在什么位置。我取了这里前 200 个网页,记住我们其实有海量网页,然后把所有文本拼在一起,也就是 concatenate。结果就是一大段原始互联网文本。即使只有 200 个网页,文本也已经很多。我继续缩小画面,就会看到一张巨大的文本织物。这些文本数据里有很多模式,而我们现在要做的,是开始在这些数据上训练神经网络,让神经网络内化并建模这种文本是如何流动的。我们有一张巨大的文本纹理,现在我们希望得到能够模仿它的神经网络。
分词:把文本变成模型能处理的一维符号序列
在把文本输入神经网络之前,我们必须决定如何表示这些文本,以及怎样喂进去。我们现在的神经网络技术期望输入是一维的符号序列,而且可能的符号集合必须是有限的。所以我们要决定这些符号是什么,然后把数据表示成这些符号的一维序列。现在我们拥有的是一维文本序列。它从这里开始,走到这里,再到下一行,等等。虽然在显示器上它排成二维,但阅读顺序是从左到右、从上到下,所以本质上是一维文本序列。
因为这是计算机,当然有底层表示。如果我把这段文本做 UTF-8 编码,就能得到计算机中对应该文本的原始比特。比如最开始的一条就是前 8 个比特。从某种意义上说,这已经是一种我们需要的表示:我们只有两个可能的符号,0 和 1,并且有一条很长很长的序列。但序列长度在神经网络里是非常有限且珍贵的资源。我们不想要只有两个符号、但极长的序列。我们想在词表大小,也就是 vocabulary 的符号数量,和最终序列长度之间做权衡。我们希望有更多符号、更短序列。
一种朴素的压缩方法,是把连续比特分组,比如每 8 个比特组成一个字节(byte)。因为每个比特只有开或关,如果取 8 个,就只有 256 种可能组合。因此我们可以把比特序列表示成字节序列。这个字节序列会短 8 倍,但现在我们有 256 个可能符号,每个数字从 0 到 255。我强烈建议你不要把它们想成数字,而要想成唯一 ID 或唯一符号。也许更好的想法是,把每个数字替换成一个独特 emoji。这样我们就有一串 emoji,总共有 256 种可能的 emoji。
在生产中的前沿语言模型里,还要继续往前走。你希望继续缩短序列长度,因为序列长度很珍贵,代价是词表里有更多符号。做法是运行字节对编码算法(byte pair encoding)。它的工作方式是寻找非常常见的连续字节或符号。比如序列 116 后面跟 32 非常常见,出现频率很高。于是我们把这一对组合成一个新符号,创建一个 ID 为 256 的新符号,然后把每一个 116、32 的组合都改写成这个新符号。这个算法可以迭代任意多次。每次创建新符号,序列长度就变短,符号集合就变大。实践中,一个相当好的词表大小大约是 10 万个可能符号。具体来说,GPT-4 使用 100,277 个符号。把原始文本转换成这些符号,也就是 token 的过程,叫 tokenization,分词或标记化。
我们看 GPT-4 如何做 tokenization。一个我喜欢用来探索 token 表示的网站叫 tiktokenizer。你可以在下拉菜单里选择 CL100K_base,这是 GPT-4 基础模型的 tokenizer。左边可以输入文本,它会显示这段文本的 tokenization。比如 “hello world” 正好是两个 token:token “hello”,ID 是 15339;以及 token “ world”,也就是带空格的 world,ID 是 1917。如果我把两个词连起来,会得到两个 token,但变成 token “H” 加上不含 H 的 “ello world”。如果 hello 和 world 之间有两个空格,又是不同的 tokenization,多了一个新 token 220。大小写也敏感,大写 H 会不一样;如果是 “Hello world”,可能变成三个 token,而不是两个。你可以玩一玩,获得这些 token 如何工作的直觉。
现在假如我从数据集中取一行文本,这就是 GPT-4 会看到的东西。这段文本会变成长度为 62 的序列。这里是序列本身,以及文本块如何对应这些符号。再次强调,有 100,277 个可能符号,我们现在拥有这些符号的一维序列。我们之后还会回到 tokenization。现在我已经把数据集中的文本序列,用 tokenizer 重新表示成 token 序列。比如 FineWeb 说它不仅是 44TB 磁盘空间,而且数据集中大约有 15 万亿 token。这里看到的只是前几千个 token,但要记住总共有 15 万亿个。所有这些 token 都代表小文本块,是序列里的原子;这些数字本身没有意义,只是唯一 ID。
神经网络训练:预测下一个 token
现在到了有趣的部分,也就是神经网络训练。这是训练神经网络时计算上最重的地方。在这一步,我们想建模这些 token 在序列中如何相互跟随的统计关系。我们会进入数据,取 token 窗口。我们会从数据中相当随机地取一段 token 窗口,窗口长度可以从 0 个 token 到我们设定的某个最大值。实践中你可能会看到 8000 个 token 的窗口。原则上我们可以用任意长度,但处理很长的窗口计算成本很高,所以我们会决定比如 8000、4000 或 16000,然后截断。为了演示,我取前 4 个 token,好让画面放得下。
假设这 4 个 token 是 this、bar、view、in 和 space single,对应一些 token ID。我们现在想做的是预测序列中下一个 token。下一个是 3962。我们把前 4 个 token 称为上下文(context),它们输入神经网络,是神经网络的输入。等会儿我会讲神经网络内部是什么。现在重要的是理解输入和输出。输入是可变长度的 token 序列,从 0 到最大长度,比如 8000。输出是对下一个 token 的预测。因为词表里有 100,277 个可能 token,神经网络会输出正好这么多个数字,每个数字对应某个 token 成为下一个 token 的概率。它是在猜下一个是什么。
一开始神经网络是随机初始化的。我们等会儿会看这是什么意思。它是一个随机变换,所以训练最开始的概率也是随机的。这里我只展示了三个例子,但要记住其实有 10 万多个数字。比如神经网络认为 token “ Direction” 的概率是 4%,11799 的概率是 2%,而正确的 token 3962,也就是 “post”,概率是 3%。因为这个窗口来自训练集,所以我们知道下一个 token 是什么,这就是标签。我们知道正确答案是 3962。现在我们有一个数学过程来更新神经网络,也就是调节它。基本上,我们希望这里 3% 的概率变高,而其他 token 的概率变低。我们有办法数学地计算如何调整和更新神经网络,让正确答案的概率稍微提高。
如果我现在更新神经网络,那么下次再把这 4 个 token 输入进去,神经网络已经稍微调整过了,它可能会说,post 现在是 4%,case 变成 1%,Direction 变成 2%。所以我们有一种轻推神经网络的方法,让它给序列中正确的下一个 token 更高概率。这个过程不只发生在这一个 token 上,不只是这 4 个输入预测这一个输出。它在整个数据集的所有 token 上同时发生。实践中,我们采样小窗口、小批量窗口,然后在每一个 token 上都希望调整神经网络,让那个 token 的概率略微提高。这些都会在大批量 token 上并行发生。这就是训练神经网络的过程:不断更新它,使它的预测匹配训练集中实际发生的统计规律,使它的概率与数据中 token 如何相互跟随的统计模式一致。
神经网络内部:参数、Transformer 和固定数学函数
我们简单进入神经网络内部,看看里面是什么。输入是 token 序列,这里是 4 个输入 token,但可以从 0 到比如 8000 个 token。原则上可以是无限多个 token,只不过处理无限长 token 太贵,所以我们在某个长度截断,这就成为模型的最大上下文长度。输入 X 会与神经网络的参数或权重一起,被混合进一个巨大的数学表达式里。这里我展示了 6 个示例参数及其设定,但实践中的现代神经网络会有数十亿个参数。最开始这些参数是完全随机设置的。随机参数会让神经网络做随机预测,训练初期确实如此。通过反复更新网络,也就是训练神经网络,这些参数的设置会被调整,使神经网络输出与训练集中的模式一致。
你可以把这些参数想象成 DJ 台上的旋钮。当你转动这些旋钮时,对于每一种可能的 token 序列输入,你都会得到不同预测。训练神经网络就是发现一组参数设置,使它看起来与训练集的统计规律一致。这个巨大的数学表达式大概是什么样?现代网络是包含可能数万亿项的巨大表达式,但一个简单例子可能就是输入 x,比如 x1、x2,与权重 w0、w1、w2、w3 等混合。这种混合是乘法、加法、指数、除法等简单操作。神经网络架构研究的主题,就是设计有效的数学表达式,让它们具有很多方便特性:表达能力强、可优化、可并行等等。归根结底,这些表达式并不神秘,它们把输入和参数混合起来产生预测,而我们优化参数,使预测与训练集一致。
我们来看一个生产级神经网络的可视化例子。这个网络有一种特殊结构,叫 Transformer。这个例子大约有 85,000 个参数。顶部输入 token 序列,信息流过神经网络,直到输出,也就是 logit softmax,这些就是对下一个 token 的预测。中间有一系列变换,以及这个数学表达式内部在预测下一个 token 时产生的中间值。比如 token 首先被嵌入到一种分布式表示中,每个可能 token 在神经网络内部都有一个向量表示。然后这些值流过图里的各个模块。单独看每个模块都是非常简单的数学表达式,比如 layer norm、矩阵乘法、softmax 等。这里是 Transformer 的 attention block,然后信息流入多层感知机 block 等。
这些中间数值可以粗略想成合成神经元的放电率,但我会提醒你不要太把它们当成生物神经元。相比大脑里的神经元,这些神经元极其简单。生物神经元是非常复杂的动态过程,有记忆等等;而这里的表达式没有记忆。它是从输入到输出的固定数学表达式,是无状态的。所以相比生物神经元,它们非常简单。不过如果你愿意,也可以松散地把它想成一小块合成脑组织。信息流过,所有这些“神经元”激活,直到得到预测。我不会纠缠这些变换的精确数学细节。重要的是理解:这是一个数学函数,由固定参数集合参数化,比如 85,000 个参数;它把输入转换成输出。我们转动参数,就得到不同预测;我们要找到好的参数设置,使预测匹配训练集里的模式。这就是 Transformer。
推理:模型如何从概率分布生成新文本
现在讲另一个重要阶段:推理(inference)。推理时,我们从模型生成新数据。我们想看看模型在参数中内化了什么模式。从模型生成文本相对直接。我们从一些 token 开始,这些 token 是前缀,也就是你想从哪里开始。比如从 token 91 开始,把它输入网络。网络会给出概率向量。我们可以做的,是像抛一枚有偏硬币一样,根据这个概率分布采样一个 token。模型给高概率的 token 更可能被采样出来。所以我们从分布中采样,得到一个唯一 token,比如 860。这个 token 可能是相对可能的,但不一定是唯一可能的。还有很多其他 token 也可能被采样到。
接下来把 860 接到 91 后面,再问第三个 token 是什么。采样,得到 287。再做一次,现在有 3 个 token,问第四个 token,采样得到下一个。再来一次,把 4 个 token 输入,采样得到 13659。这个 token 不是之前训练例子里的 3962,而是 “article”。所以得到的是 “viewing a single article”。这里我们没有精确复现训练数据中的序列。要记住这些系统是随机的。我们在采样、在抛硬币。有时运气好,会复现训练集里的小片段;但有时会得到并非训练文档原文一部分的 token。于是生成结果是训练数据的某种混合重组。每一步都可能采到稍微不同的 token,而一旦这个 token 进入上下文,后面继续采样,很快就会生成与训练文档非常不同的 token 流。统计上它们有类似属性,但并不等同于训练数据;它们像是被训练数据启发出来的。
所以推理就是一次一个地从这些分布中预测。我们不断把 token 喂回去,得到下一个 token,并始终在抛这些有偏硬币。根据采样时的运气好坏,我们可能得到非常不同的模式。在常见场景下,下载互联网并 tokenization 是预处理步骤,只做一次。得到 token 序列后,就可以开始训练网络。实践中会训练很多不同设置、结构和大小的网络。训练好某个网络,得到一组满意参数后,就可以拿模型来做推理,从模型生成数据。当你在 ChatGPT 上和模型说话时,那个模型已经由 OpenAI 在几个月前训练好了。它有一组固定权重。你和它对话时,所有这一切只是推理,不再训练。参数保持不变,你给它一些 token,它补全 token 序列,你看到的就是模型生成出来的内容。
GPT-2、GPU 和预训练的计算代价
我们来看一个具体的训练和推理例子。我喜欢用 OpenAI 的 GPT-2。GPT 是 Generatively Pre-trained Transformer,也就是生成式预训练 Transformer。GPT-2 是 OpenAI GPT 系列的第二版。今天你和 ChatGPT 对话时,背后的魔法是 GPT-4,也就是第四版。GPT-2 是 OpenAI 2019 年发布的。我喜欢 GPT-2,是因为它第一次把一个可识别的现代技术栈组合到了一起。GPT-2 的所有组件今天看起来仍然是现代的,只是一切都变大了。GPT-2 是 Transformer 神经网络,有 16 亿参数;今天的现代 Transformer 更接近一万亿或数千亿参数。GPT-2 最大上下文长度是 1024 token,今天上下文长度可能接近几十万甚至一百万。GPT-2 大约在 1000 亿 token 上训练,这在今天也很小,因为 FineWeb 就有 15 万亿 token。
我曾经为了好玩,在一个叫 llm.c 的项目里复现 GPT-2。2019 年训练 GPT-2 的成本估计约为 4 万美元;今天能显著更便宜,差不多一天、600 美元就能做出来,而且还不是特别努力优化。我觉得今天甚至可以压到 100 美元左右。为什么成本下降这么多?第一,数据集更好了,过滤、抽取、准备数据的方式更精细,数据质量更高。第二,也是真正最大的区别,是硬件计算速度快了很多。运行这些模型的软件也变好了,大家都在努力从硬件里榨取速度,所以软件也进步很大。
我给你一个研究者训练模型时实际看到的感觉。这里我正在训练一个 GPT-2 模型。每一行都是对模型的一次更新。还记得我们要让每个 token 的预测更好,并更新神经网络的权重或参数。这里每一行就是对神经网络的一次更新,让它在预测序列下一个 token 时更好。具体来说,每一行都会在训练集的 100 万 token 上改进预测。我们从数据集中拿出 100 万 token,并同时尝试提高这 100 万个 token 作为下一个 token 的预测概率。
要密切关注的数字叫 loss。loss 是一个单一数字,表示当前神经网络表现如何,并且设计成越低越好。你会看到随着我们对网络做更多更新,loss 在下降,这对应于下一个 token 的预测变好。作为神经网络研究者,你就盯着 loss,看它是否随着每次更新而改善。这里每次更新处理 100 万 token,每次更新大约 7 秒。总共要跑 32,000 个优化步骤。32,000 步乘以每步 100 万 token,大约处理 330 亿 token。现在只跑了 420 步多一点,也就是 1% 多,因为我只跑了十几分钟。
我设置了每 20 步做一次推理。你在这里看到的是模型预测下一个 token。训练才 1% 时,生成还不连贯,会有一些乱码,但局部有一点点连贯,比如 “Since she is mine…” 之类。往上滚到优化刚开始时,在第 20 步后,输出看起来完全随机。因为模型只对参数做了 20 次更新,所以它还是随机网络,给出随机文本。相比之下,现在它已经开始好很多。如果我们等完整 32,000 步,模型会改进到能生成相当连贯的英语,token 流正确,英语样子也好很多。这还要继续跑一两天。这个阶段我们确保 loss 在下降,一切看起来正常,然后等待。
当然,这不是在我的笔记本上跑的。那太贵了,因为神经网络太大,数据太多,计算量太大。所有这些都在云端的一台电脑上跑。这个优化运行在一个 8x H100 节点上,也就是一台机器里有 8 张 H100。我租用这台机器,它在云里,物理位置我也不确定。我喜欢从 Lambda 租,但有很多公司提供这类服务。比如按需租 8x NVIDIA H100 GPU,每张 GPU 每小时约 3 美元。GPU 长得像这里这样,可以插到电脑里。GPU 非常适合训练神经网络,因为训练计算非常昂贵,但有大量并行性:许多独立工作单元可以同时解决底层矩阵乘法。
你可以把 8 张 H100 堆到一个节点里,再把多个节点堆成整个数据中心或系统。数据中心里就是一张 GPU 到 8 张 GPU,到一个系统,再到很多系统。大科技公司非常想要这些 GPU 来训练语言模型,因为它们非常强大。这从根本上推动了 NVIDIA 股票市值,比如今天达到 3.4 万亿美元。淘金热就是拿 GPU,拿足够多的 GPU,让它们协同执行这个优化。它们都在做什么?它们都在协同地在 FineWeb 这类数据集上预测下一个 token。这就是极其昂贵的计算流程。GPU 越多,你能预测和改进的 token 越多,处理数据集越快,迭代越快,也能训练更大的网络。
比如最近有文章说 Elon Musk 在单个数据中心拿到 10 万张 GPU,这就是为什么它是大事。这些 GPU 非常昂贵,会消耗大量电力,而它们本质上都在试图预测序列中的下一个 token,通过这样改进网络,并更快得到比我们这里看到的更连贯得多的文本。我没有几千万或几亿美元来训练一个这样的大模型。幸运的是,我们可以使用大科技公司训练后发布的一些模型。它们花费大量计算训练网络,然后发布优化结束后的网络。这个优化末端出来的模型叫基础模型(base model)。基础模型是什么?它是一个 token 模拟器,是互联网文本 token 模拟器。它本身还不完全有用,因为我们想要的是助手:我们想问问题,让它回答。但基础模型不会这样做,它只是生成互联网的混合重组,像是在梦见互联网网页。
基础模型发布:代码和参数
模型发布需要两样东西。第一是 Python 代码,通常描述模型中的操作序列,也就是神经网络的 forward pass。它实现 Transformer 里的具体步骤,说明这个网络到底是怎么接线的。这通常只是几百行代码,不算疯狂,也相当标准。真正有价值的是参数。比如 GPT-2 发布的是约 15 亿参数,这些参数就是一长串数字,是所有旋钮的精确好设置,让 token 生成得好。要得到基础模型发布,就需要代码和参数。GPT-2 发布过,但比较旧。我们接下来用 Llama 3。GPT-2 是 16 亿参数、1000 亿 token;Llama 3 是 Meta 训练并发布的更大、更现代的模型,4050 亿参数,在 15 万亿 token 上训练,方式类似,只是大得多。Meta 也发布了 Llama 3.1 405B 基础模型,此外还发布了 instruct 模型。instruct 意味着这是助手,可以问问题得到回答。但这部分我们稍后讲。现在先看基础模型,也就是 token 模拟器。
我喜欢在 Hyperbolic 这个公司的网站上和基础模型交互,它提供 Llama 3.1 405B 的基础模型。进入网站后要确保选择 Llama 3.1 405 billion base,必须是 base model。max tokens 是要生成多少 token,可以设小一点,比如 128,避免浪费计算。根本上,这里发生的事和之前推理完全一样:它会继续补全你给它的前缀 token 序列。我先展示它还不是助手。比如你问 “what is 2 plus 2?”,它不会说“四,还能帮你什么?”因为这句话会被 tokenized,成为前缀。模型只是根据训练文档,也就是网页里的统计规律,做一个非常昂贵的自动补全。
同一个前缀每次得到的回答也不一样,因为系统是随机的。给定相同 token 前缀,我们得到不同答案,是因为模型给出概率分布,我们从中采样,之后就走向不同方向。所以基础模型首先不是助手,而是 token 自动补全;其次它是随机系统。不过即使这个模型现在还不直接适合很多应用,它仍然非常有用,因为在预测下一个 token 的任务中,模型学到了很多关于世界的知识,并把这些知识存进了网络参数。你可以把 4050 亿参数看成一种互联网压缩,一种 zip 文件,但不是无损压缩,而是有损压缩。我们得到的是互联网的一团凝胶,可以从中生成内容。
我们可以通过适当提示来激发这些参数中的知识。比如提示:“Here’s my top 10 list of the top landmarks to see in Paris.” 我这样写,是为了引导模型继续这个列表。按回车后,它开始列出巴黎地标。这里的信息不能完全信任,因为这只是对互联网文档的某种回忆。互联网上频繁出现的东西更可能被正确记住,罕见东西就不一定。所以这里的知识不是精确、明确存储在某个参数里的,而是模糊、概率化、统计性的回忆。经常出现的东西更容易被模型记住。
基础模型的记忆、幻觉和上下文学习
再看一个例子。我去 Wikipedia 的 zebra 页面,复制第一句话到基础模型里。按回车后,模型生成的是这篇 Wikipedia 条目的精确背诵。它纯粹从记忆中背出了 Wikipedia 条目,而这个记忆存在参数中。在某个 token 之后它可能会偏离,但你可以看到它记住了很大一块。这种现象叫 regurgitation,也就是反刍式复述,通常不希望最终模型直接引用训练过的内容。之所以发生,是因为某些高质量来源,比如 Wikipedia,在训练时会被优先采样。模型可能在这些数据上训练了几个 epoch,也就是见过这个网页十次左右。类似于你把一段文字读很多很多遍,比如 100 遍,就能背出来。模型每次呈现的记忆效率可能比人高得多,所以它见过十次就能把文章记进参数。
接下来展示模型没有在训练中见过的东西。Llama 3 的预训练数据知识截止到 2023 年底,所以它不会见过 2024 年大选结果。如果我们用未来的 token 提示它,它会继续序列,并根据参数中的知识做最佳猜测。比如提示共和党票:“Trump…” 然后看它补全竞选搭档和对手。一次它认为 Mike Pence 是搭档,对手是 Hillary Clinton 和 Tim Kaine;另一次同样提示重新采样,它说 Ron DeSantis 是搭档,对手是 Joe Biden 和 Kamala Harris。这些都是平行宇宙。模型会基于知识继续 token 序列,做有根据的概率猜测。这里看到的都是 hallucination,幻觉:模型以概率方式做最佳猜测。
即使基础模型还不是助手,如果你会设计提示,它仍可用于实际应用。这里有一个 few-shot prompt:我给出 10 对英语词和韩语翻译,每一对是英文、冒号、韩文。最后写 teacher:,然后让模型只补全几个 token。这些模型有 in-context learning,也就是上下文学习能力。意思是模型读这个上下文时,会在当前位置学到数据里有某种算法模式,并知道继续这个模式。于是它承担翻译者角色。补全后,teacher 的翻译是 seonsaengnim,是正确的。所以即使只有基础模型,也可以通过聪明的 prompt 构建应用。
还可以只通过提示巧妙地实例化一个完整的语言模型助手。技巧是把 prompt 结构化成一个网页,好像这里有一段有帮助的 AI 助手和人类之间的对话,然后模型会继续这个对话。为了写 prompt,我甚至让 ChatGPT 帮我写,这有点 meta。我告诉它:我想创建一个 LLM 助手,但只有基础模型,请帮我写 prompt。它给出一段:“Here is a conversation between an AI assistant and a human. The AI assistant is knowledgeable, helpful…” 但只给描述还不够,更好的做法是给格式化对话,包含几轮 human、assistant、human、assistant,然后在最后放真正的问题。比如 human: Why is the sky blue? assistant: 模型就会回答 Rayleigh scattering 等等。它只是继续序列,但因为序列看起来像对话,所以它承担了这个角色。微妙之处在于,它可能回答完 assistant 后,又自己 hallucinate 出下一个 human 问题,然后继续下去。
到目前为止,我们讨论了预训练阶段。我们取互联网文档,把它们切成 token,也就是小文本块原子,然后用神经网络预测 token 序列。这个阶段的输出是基础模型,也就是网络参数的设置。基础模型本质上是 token 层面的互联网文档模拟器,可以生成统计特性类似互联网文档的 token 序列。我们看到它在某些应用中可用,但我们还需要做得更好。我们想要助手:能问问题,模型能回答。因此进入第二阶段,叫后训练(post-training)。我们把基础模型,也就是互联网文档模拟器,交给后训练。后训练的计算成本会小得多。大规模数据中心、重计算、数百万美元主要在预训练阶段。后训练便宜一些,但仍然极其重要,因为它把 LLM 变成助手。
后训练:用对话数据把基础模型变成助手
我们希望模型不是采样互联网文档,而是回答问题。换句话说,我们要开始思考对话。对话可以是多轮,最简单情况下是人类和助手之间的对话。比如人类说“2 加 2 是多少”,助手应回答“2 加 2 是 4”。人类追问“如果是乘号而不是加号呢”,助手可以继续回答。另一个例子可以展示助手有某种性格,比如友好。第三个例子展示,当人类请求我们不希望帮助的内容时,助手可以拒绝,说不能帮这个忙。所以我们现在要想清楚助手应该如何和人类交互,并且要把助手在对话中的行为“编程”出来。
因为这是神经网络,我们不会显式写代码来编程助手。神经网络的一切都是通过数据集训练完成的。所以我们通过创建对话数据集来隐式编程助手。实际数据集可能有几十万条多轮、很长、覆盖广泛话题的对话。助手是通过示例被编程的。那么这些数据从哪里来?比如“2 * 2 等于 4,和 2 加 2 一样”这样的内容来自人类标注员。我们给人类标注员某个对话上下文,让他们写出这种情况下理想的助手回答。然后让模型在这些数据上训练,模仿这种回答。
具体做法是:拿到预训练阶段产生的基础模型。这个基础模型在互联网文档上训练。现在把互联网文档数据集扔掉,换成一个新的对话数据集,然后继续在这些对话上训练模型。模型会非常快速地调整,学会助手如何回应人类查询的统计规律。之后推理时,我们给助手一个对话前缀,它就会生成回应,模仿人类标注员在这种情境下会怎么做。预训练可能在数千台计算机上训练三个月;后训练通常短得多,比如 3 小时,因为我们人工创建的对话数据集比互联网文本小得多。但本质上,我们只是拿基础模型继续训练,用完全相同的算法,完全相同的一切,只是把数据集换成对话。
问题是:这些对话是什么?如何表示?如何让模型看到对话,而不是原始文本?所有东西都必须变成 token,因为这些模型处理的都是 token 序列。所以我们要把对话变成 token 序列。为此需要设计一种编码,类似互联网中的 TCP/IP 包,有精确规则和协议规定如何表示信息、结构如何排列。LLM 也一样,需要数据结构和规则,把 conversation 这种结构化对象编码到 token、从 token 解码回来。
比如 GPT-4o 有一种格式。它使用特殊 token,像 im_start,意思大概是 imaginary monologue 的开始。然后要指定是谁的回合,比如 user,然后是分隔 token,再是问题文本的 token,然后用 im_end 结束。所以用户问题“what is 2 plus 2”会变成一串 token。重要的是,im_start 不是普通文本,而是后训练阶段引入的特殊 token。im_sep、im_start 等特殊 token 与文本交织在一起,让模型学会:这是一个回合的开始;这个回合属于 user;这是 user 说的话;然后 user 结束;接着是 assistant 的回合;assistant 说什么。最终对话变成一维 token 序列。具体格式不重要,重要的是 structured conversation 会通过某种编码变成一维 token 序列,所以我们仍然可以像之前一样训练语言模型:预测序列中的下一个 token。
推理时也是这样。你在 ChatGPT 上已经有一段对话,比如“what is 2 plus 2?”、“2 plus 2 is 4”,然后你输入“what if it was times?”。OpenAI 服务器会构造一个上下文,在最后放入 assistant 回合的起始标记,然后从这里开始采样。它问模型:第一个 token 应该是什么?第二个 token 是什么?第三个 token 是什么?LLM 从这里接管并创建回答。回答不必和训练集中的完全相同,但如果训练集中有这种类型的对话,它会有类似风格。协议细节不重要。我的目标只是让你看到,一切最后都是一维 token 序列。我们现在训练的是对话,也生成对话。
InstructGPT、人工标注和 SFT 数据集
来看这些数据集在实践中长什么样。OpenAI 2022 年的 InstructGPT 论文是第一批公开讲如何把语言模型在对话上微调的工作。论文第 3.4 节讲他们雇佣人类承包商,比如来自 Upwork 或 Scale AI,来构造这些对话。人类标注员的工作是创建对话。他们要想出 prompt,然后完成理想助手回答。比如 prompt 可以是:“列出五个重新找回职业热情的方法”,“我接下来应该读哪十本科幻书”,“把这个句子翻译成西班牙语”等等。人先写 prompt,再回答 prompt,给出理想助手回复。
他们怎么知道理想助手回答是什么?公司会写标注说明。比如 OpenAI 会写 labeling instructions,告诉人类如何创建理想回答。高层次要求是 helpful、truthful、harmless,也就是有帮助、真实、无害。大致就是:回答问题,尽量有帮助,尽量真实,不要回答我们不希望 ChatGPT 之后处理的问题。实际标注说明通常不止这么短,而是有数百页,人们需要专业学习,然后按说明写理想助手回答。所以这是一个非常依赖人类的过程。InstructGPT 的数据集没有发布,但有开源复现,比如 Open Assistant。互联网上的人被要求创建类似对话:有人写 prompt,比如“请写一段关于经济学中 monopoly 一词重要性的短介绍,并使用例子”,然后同一个人或另一个人写助手回答。对话还可以继续,比如“现在给狗解释一下”,然后写更简单的解释。
这些回答就成为标签,我们在上面训练。当然我们不可能覆盖未来推理时用户可能问的所有问题。但如果有一个包含许多示例的数据集,模型在训练中会开始承担 helpful、truthful、harmless 助手的人格。它全部是通过示例编程的。如果你有 10 万条这样的对话,并在上面训练,模型会理解统计模式,并承担这种助手人格。测试时遇到完全相同的问题,可能背出训练集答案;更可能的是,它做出相似氛围的回答,理解这就是你想要的答案。所以系统通过示例被编程,并统计性地采纳了由公司标注说明反映出来的助手人格。
过去两三年,状态已经变化。现在不太常见让人类完全从零做所有重活,因为我们有语言模型了,这些模型会帮助创建对话数据集。人类很少完全从零写回答,更可能用已有 LLM 生成答案,然后编辑。LLM 已经渗透后训练栈,被广泛用于创建大规模对话数据集。UltraChat 就是一个更现代的数据集例子,很大程度上是合成的,但通常会有一些人类参与。这类 SFT 数据集现在有数百万对话,主要是合成的,可能经人类一定程度编辑,覆盖大量领域。很多数据源会混在一起,叫 SFT mixtures。大体上,我们仍有由对话组成的 SFT 数据集,并像之前一样训练。
我还想祛魅一下“和 AI 对话”的感觉。当你去 ChatGPT 输入问题并按回车,返回的东西统计上对齐训练集。而这些训练集最初来自遵循标注说明的人类。所以你到底是在和什么说话?不是某种魔法 AI。粗略说,它是在统计性地模仿人类标注员,而人类标注员遵循公司写的标注说明。你得到的回答像是在问:这种对话里,人类标注员会说什么?而且这些标注员不是随便的互联网用户。公司会雇专家。比如代码问题相关的数据集,参与创建对话的标注员通常是受过教育的专家。你问的是这类人的即时模拟。
比如我在 ChatGPT 里问“推荐巴黎最值得看的五个地标”。它返回的不是某个魔法 AI 用无限智能去研究所有地标然后排序。你得到的是 OpenAI 雇佣标注员的统计模拟。如果这个具体问题出现在 OpenAI 后训练数据集中,你很可能看到一个与标注员提交内容非常相似的答案。标注员怎么来这个列表?他们会上网,做 20 分钟小研究,然后写一个列表。如果问题没有出现在后训练数据集里,那么结果更 emergent 一些:模型统计性地理解训练集中所谓重要地标通常是什么,以及互联网中人们谈论巴黎、地标、旅行时的内容。预训练的互联网知识与后训练对话数据结合,产生这种模仿。
LLM 心理学之一:幻觉从哪里来
现在讲我喜欢称为 LLM psychology 的东西,也就是训练流水线带来的 emergent cognitive effects。第一个当然是幻觉。幻觉就是 LLM 编造内容。这是 LLM 助手的大问题,早期模型尤其严重,现在因为有一些缓解措施有所改善。我们先理解幻觉从哪里来。想象训练集中有三段合理对话:“谁是 Cruise?”助手回答 Tom Cruise 是著名美国演员;“谁是 John Barrasso?”回答他是美国参议员;“谁是成吉思汗?”回答相关事实。训练时,人类写答案时要么自己知道,要么上网查,然后写出一个自信语气的正确答案。
测试时,如果你问一个随机名字,比如 Orson Kovats,我认为这个人不存在。问题是,助手不会简单说“我不知道”。即使模型内部的某些特征、激活、脑子里的某部分,可能知道这不是它熟悉的人,它也不会说不知道。因为模型统计性地模仿训练集。在训练集中,“who is X” 形式的问题都被自信地用正确答案回答,所以它会采纳这种回答风格,并尽力给出统计上最可能的猜测,于是编东西。这些模型没有访问互联网,不会做研究。它们只是 statistical token tumblers,也就是统计 token 翻滚器,只是在采样下一个 token。
用一个旧模型 Falcon 7B Instruct 来看。问 “Who is Orson Kovats?” 一次它说他是美国作家和科幻作者,这是假的。再问一次,它说是 1950 年代电视剧里的虚构角色。再问一次,说是前小联盟棒球运动员。模型不知道,所以给出很多不同答案。它从 “who is Orson Kovats, assistant” 这些 token 开始,采样概率,编出统计上符合训练集中答案风格的内容。你和我体验到的是它编造事实知识。但要记住,它其实不知道,只是在模仿答案格式,也不会去查。
为什么现在 ChatGPT 不太会这样?如果我问 ChatGPT “Who is Orson Kovats?”,它可能先尝试 web search。我们稍后会讲工具使用。若我明确说不要用任何工具,它会说没有知名历史或公众人物叫 Orson Kovats。这个模型知道自己不知道,并告诉你这看起来不是它熟悉的人。要缓解幻觉,显然需要在数据集中加入一些例子,其中正确答案是模型不知道某个事实。但只应该在模型确实不知道时这样回答。问题是:我们怎么知道模型知道什么、不知道什么?可以经验性地 probe 模型。
Meta 在 Llama 3 中处理 factuality 的方法是,先拿训练集中的随机文档和段落,用 LLM 为段落生成具体事实问题及答案。因为信息就在上下文窗口里,LLM 不必依赖记忆,所以能较准确地重述。例如给一段关于 Dominik Hašek 的文章,让 ChatGPT 生成问题:“他为哪支队打过球?”、“他赢过几次 Stanley Cup?” 等。然后拿这些问题去 interrogate 目标模型,比如 Mistral 7B。若问题是他为哪支队打球,模型多次回答 Buffalo Sabres,与正确答案一致,我们认为模型知道。
再问他赢过几次 Stanley Cup,正确答案是 2。模型可能说 4 次,或者说没有赢过,说明它不知道。程序上可以多问几次,把模型答案和正确答案比较。这个比较也可以用另一个 LLM judge 自动完成,无需人类。如果模型多次都错,就知道模型不知道这个问题。于是我们把这个问题添加到训练集中,创建一条新对话:用户问他赢过几次 Stanley Cup,助手回答“抱歉,我不知道”或“我不记得”。如果对许多文档、许多问题都这样做,就给模型机会在训练集中基于自己的知识边界拒绝回答。
这之所以有效,是因为模型内部可能已经有不错的自我知识模型。你可以想象网络里有某个神经元在模型不确定时会亮起。问题是,这个神经元的激活没有连接到模型用语言说“我不知道”。模型内部知道,但不会表露出来,而是像训练集中一样自信猜测。我们需要 interrogate 模型,并允许它在不知道时说不知道。训练集中有这些例子后,模型就能学到:当不确定性神经元激活高时,我可以说抱歉,我不记得。这是对幻觉的一大缓解,也大致解释了为什么 ChatGPT 能做到这种事。
工具使用:把网页搜索和代码解释器接到模型上
第二种缓解更强:不是只说不知道,而是给 LLM 一个机会去查事实并回答。你和我遇到不知道的事实问题,会去搜索互联网,找到答案再告诉别人。模型也可以这样做。神经网络参数里的知识可以想成模型在预训练阶段很久以前见过东西的模糊回忆,像你一个月前读过的东西。如果经常读,就会记得;如果很罕见,回忆就不好。你和我会去查,查到后把信息刷新进工作记忆。模型也需要类似机制,让它刷新记忆或回忆。我们通过工具来做到这一点。
做法是引入特殊 token 和协议。比如添加 search_start 和 search_end。模型在不知道时,不是直接说不知道,而可以输出 search_start,然后是一条要发给 Bing、Google 等搜索引擎的查询,再输出 search_end。运行推理的程序看到 search_end 时,不继续采样下一个 token,而是暂停生成,打开 Bing 会话,把查询粘进去,拿回检索到的文本。然后程序把这些文本用一些特殊 token 包起来,复制粘贴进模型上下文窗口。上下文窗口可以想成模型的工作记忆。放在上下文窗口里的数据会直接输入神经网络,模型可以直接访问,不再是参数里的模糊回忆。之后模型继续采样时,就可以很容易引用这些网页搜索结果。
如何教模型正确使用 web search 这些工具?还是通过训练集。我们需要一堆对话示例,展示模型在什么场景使用搜索,如何写 search_start、查询、search_end。只要训练集中有几千个例子,模型就能很好理解工具如何工作,并学会组织查询。由于预训练数据和世界知识,它已经大致理解什么是搜索、什么查询好。只需要少量例子告诉它怎么用新工具,它就能靠工具检索信息,把信息放进上下文窗口。这等价于你我去查资料,因为资料进入上下文后,就进入工作记忆,容易操作和访问。
这就是前面问 ChatGPT “Who is Orson Kovats?” 时发生的事。ChatGPT 语言模型判断这是某种罕见人物,不想从记忆里直接回答,于是采样了会触发 web search 的特殊 token。我们看到一闪而过的 “using web tool”。它去搜索,找到来源和 URL,把网页文本塞进上下文窗口,然后引用这些文本回答,并给出 citations。若我说 “do not use any tools”,它就不使用工具,只用记忆和回忆。另一个例子是问 Dominik Hašek 赢了几次 Stanley Cup,ChatGPT 可能认为自己知道,直接回答两次;也可以要求它用 web search 确认,它就会搜索、找到 Wikipedia 等来源,再引用回答。
这里有一个重要心理学点:神经网络参数中的知识是模糊回忆;组成上下文窗口的 token 中的知识是工作记忆。大体上类似我们的脑子:记得的东西是参数,刚经历的东西在上下文窗口。这个上下文窗口随着对话不断构建。这对实际使用 LLM 很重要。比如你可以问 ChatGPT:“请总结 Jane Austen《Pride and Prejudice》第一章。”这可以,ChatGPT 会做得相对合理,因为这是一部著名作品,模型对它有很好回忆。但如果你真想让它回忆具体内容,通常更好的 prompt 是:“请总结第一章,我把章节贴在下面供参考。”然后用分隔符把第一章粘进去。这样内容在上下文窗口中,模型直接访问,不必回忆。你和我也一样,如果刚重读过一章,再总结会好得多。
LLM 的自我知识不是人的自我
另一个心理学怪癖是自我知识。网上常有人问 LLM:“你是什么模型?谁造了你?”这个问题有点不合逻辑。因为这东西不是人,没有持续存在。它启动、处理 token、关闭;每个用户都是这样。它只是建立一个对话上下文窗口,然后一切被删除。这个实体每次对话都像从零重启。它没有持续自我,没有自我意识。它是 token tumbler,遵循训练集统计规律。所以问它“你是谁、谁造了你”默认会得到相当随机的答案。比如旧模型 Falcon 会说“由 OpenAI 基于 GPT-3 构建”,完全是编造。
这不一定说明它训练过 OpenAI 数据。更可能是,如果你没有显式编程模型如何回答这类问题,它会给出统计上最可能的自我标签。SFT 期间,模型在对话数据上训练,学会自己是一个 helpful assistant,但没有被告诉该给自己什么标签。预训练互联网文档里 ChatGPT 和 OpenAI 非常突出,所以它 hallucinate 出“我是 OpenAI 的 ChatGPT”作为自我身份。开发者可以覆盖这一点。比如 Allen AI 的 OLMo 模型是完全开源的。在它的 SFT mixture 里,有 240 条 OLMo-2 hardcoded 对话,比如用户问“告诉我你自己”,助手回答“我是 AI2 开发的开放语言模型”;用户问名字,它回答 OLMo 项目。把这些硬编码对话放进训练集微调,模型之后就会复述这些内容。
另一种方法是 system message。对话不只有 human 和 assistant,有时开头有一条 system message。里面可以提醒模型:“你是 OpenAI 开发的模型,名字是 ChatGPT-4o,训练日期是这个,知识截止日期是那个。”当你打开 ChatGPT 时看到空白页面,但 system message 其实隐藏在那里,那些 token 在上下文窗口里。这两种方法——通过数据,或通过 system message 里的隐形 token——都能让模型谈论自己。但这些都是后来加上去的,不像人类那样真正深层存在。
模型需要 token 来思考:为什么要写中间步骤
接下来讲模型在问题求解场景中的原生计算能力。构造对话示例时要非常小心,因为这里有很多锋利边缘。考虑一个简单数学 prompt:Emily 买了 3 个苹果和 2 个橙子,每个橙子 2 美元,总成本 13 美元,问每个苹果多少钱。有两个答案都正确,都说答案是 3 美元。但一个明显更适合作为助手回答,另一个很糟。关键在于,模型训练和推理时都是从左到右处理一维 token 序列。每生成下一个 token,前面的 token 输入神经网络,网络给出下一个 token 的概率。每个 token 的生成只经过有限层计算。现代网络可能有 100 层左右,但每个 token 的计算量仍然有限,而且可以粗略看作每个 token 有固定的小量计算。
因此不能指望模型在单个 forward pass 里做任意复杂计算。我们必须把推理和计算分布到很多 token 上。直接回答“答案是 $3,然后解释……”的问题在于,模型在生成那个 “3” 的 token 时,必须把整个题目的计算都塞进这一个 token 里。一旦 3 被生成出来,后面所有解释都只是 post hoc justification,因为答案已经在上下文窗口里了。你如果用这种直接答案训练模型,就是教它在单个 token 里猜答案,这很糟糕,因为每个 token 的计算量有限。
更好的回答是慢慢展开:橙子总价是 4 美元,13 减 4 等于 9,三个苹果共 9 美元,所以每个苹果 3 美元。这样计算分布在回答的多个 token 上,每一步中间计算都不太贵。到接近结尾时,之前结果已经在工作记忆里,模型更容易得出答案 3。这就是为什么标签里应该让模型展开推理。OpenAI 的标注员会注意这类问题,所以你问 ChatGPT,它会慢慢定义变量、建立方程、产生中间结果。这些中间结果不是给你看的,而是给模型自己看的。如果模型不为自己创造这些中间结果,它可能无法到达正确答案。
你也可以故意刁难模型,比如说“用一个 token 直接给答案,不要其他内容”。简单数字下它可能做对。但把数字变大,比如 23 个苹果和 177 个橙子,它就需要在单个 forward pass 里做更多计算,可能会给错答案,比如 5。取消 token 限制后,让它正常求解,它会写出中间步骤并得到正确答案 7。这说明它无法把所有工作压进一次网络前向传播。实际使用时,我可能还不完全相信模型的 mental arithmetic,尤其数字很大时。更好的是说 “use code”。代码解释器是 ChatGPT 可用工具之一。模型写 Python 程序,我可以检查程序正确,然后由 Python 解释器计算结果。这比语言模型脑内算术更可靠。
所以这一节叫“模型需要 token 来思考”。把计算分布到许多 token 上,让模型创建中间结果;或者尽可能依赖工具,而不是让模型把所有东西都放在记忆里做。计数也是同样问题。比如问“下面有多少个点”,然后放一堆点。ChatGPT 可能说 161,但实际是 177。因为它要在一个 token 中数上下文窗口中的点,而一次 forward pass 的计算有限。tokenizer 里,这些点可能被合并成几个 token,模型看到的是 token ID,不是一个个字符。若说 “use code”,模型会把点复制成 Python 字符串,然后让 Python 的 count 做计数,得到正确答案 177。它擅长复制 token,不擅长脑内计数。
模型还不擅长许多拼写相关任务。原因是它们不看字符,而看 token。比如给字符串 ubiquitous,要求从第一个开始每隔三个字符打印一个。人可以直接看字母,但 tokenizer 会把 ubiquitous 切成三个 token。模型必须从这些 token 中推断内部字母,做字符级索引。因为 token 是为了效率才使用的,很多人其实想删除 token,做字符级或字节级模型,但那会产生很长序列,目前不好处理。所以在 token 世界里,拼写任务不太稳定。你可以让模型 use code,把字符串交给 Python 操作字符,就能得到正确答案 uqts。著名例子是 strawberry 里有几个 r。模型长期说有两个 r,因为它既看不到字符,又不擅长计数。现在这个特定问题可能被硬编码或特殊处理了,但原因就是 tokenization 加计数困难。
还有一些锯齿边缘不容易完全解释。比如问 9.11 和 9.9 哪个大,模型有时会说 9.11 更大,并做错误解释;有时又纠正。奇怪的是,这些模型可以解奥赛题、回答博士级物理化学生物问题,却会在这么简单的问题上失败。有人研究后告诉我,检查神经网络激活时,发现一些通常与圣经章节相关的神经元亮了。模型似乎被 9.11 和 9.9 看起来像圣经章节编号分散注意力;在圣经章节中 9.11 会在 9.9 后面,所以它被认知上干扰了。这里并非完全理解。总之,要把它看作随机系统,很神奇,但不能完全信任。把它当工具,而不是把问题丢给它后直接复制结果。
强化学习:让模型通过练习发现自己的解题方式
我们已经讲了两个阶段:预训练产生基础模型,是互联网文档模拟器;监督微调(supervised fine-tuning, SFT)在对话数据上继续训练,得到助手。SFT 算法上和预训练一样,只是数据集从互联网文档换成了高质量对话。对话由人类写 prompt 和理想回答,并基于标注文档;现代流程中有大量 LLM 帮助,但根本上仍来自人类策划。然后我们讲了认知含义:如果不采取缓解措施,助手会幻觉;模型能在脑中做很多事,但可以用工具,如 web search 减少幻觉、获取新信息,或用 Code Interpreter 写并运行代码。现在讲最后一个主要阶段:强化学习(reinforcement learning)。它仍属于后训练,但通常是第三个主要步骤。
为了说明为什么需要强化学习,可以类比上学。教科书里通常有三类信息。第一是 exposition,背景知识。阅读这些文字类似预训练:建立知识库,获得主题感觉。第二是带详细解法的问题。专家给出问题和完整解答,这类似 SFT:我们训练模型模仿专家解法。第三是练习题。练习题只有题目,通常在答案页有最终答案,但不给完整解法。你要自己练习、尝试不同方法,看看如何到达答案。这个过程依赖预训练获得的背景知识,也依赖一点对专家的模仿,但核心是自己试错和发现解法。这就是强化学习。我们会给模型 prompt 和最终答案,但不给专家解法;模型要练习、试东西。
回到苹果橙子问题。这里有四种候选解法,都能到答案 3。作为人类标注员,我其实不知道哪一种最适合 LLM。有的建立方程,有的用英语讲,有的直接跳到答案。如果只关心最终答案,哪一种解法对 LLM 来说最优?我们不知道。因为人类觉得容易或困难,不等于 LLM 觉得容易或困难。它的认知不同,token 序列对它的难度不同。一个对我来说平凡的 token 跳跃,可能对模型来说太大;反过来,某些我写出的步骤对模型也许太琐碎,是浪费 token。我们不是 LLM,所以不适合为它手工写最经济可靠的 token 序列。SFT 对初始化有用,但我们希望 LLM 自己发现哪些 token 序列对它有效,能可靠从 prompt 到答案。这要通过强化学习和试错来发现。
在 Hugging Face inference playground 里,我们可以拿一个小模型,比如 Gemma 2 2B,给它同一个问题。模型生成一个解法,我们检查最终答案是否是 3。一次对了,再删除重新跑,因为模型是随机系统,每次采样会走略微不同路径。可以生成很多独立解法,实践中可能为一个 prompt 采样数千甚至数百万解法。有些正确,有些不正确。我们想鼓励能得到正确答案的解法。想象有 15 个解法,绿色的得到正确答案,红色的没有。红色路径某处出了问题,不应鼓励;绿色路径做得好,应在类似 prompt 中多做。鼓励方式就是在这些序列上训练。关键是,这些训练序列不是人类专家标注的,而是模型自己生成的。模型在练习,试出一些有效解法,然后训练在它们上面。
为了传达核心思想,可以想成从正确解法中挑一个最好的,比如既对又短、看起来也好,然后训练模型更可能在未来类似情境下走这条路径。当然实际方法有很多细节。重要的是,我们会在许多多样 prompt 上运行这个过程:数学题、物理题、代码题等等。可能有成千上万个 prompt,每个 prompt 采样上千个解法。随着迭代,模型自己发现哪些 token 序列能导向正确答案。这些序列不会做太大心智跳跃,统计上可靠,并充分利用模型已有知识。这就是强化学习:guess and check,猜很多解法,检查它们,然后未来更多做那些有效的事。SFT 模型仍有用,因为它把模型初始化到正确解法附近;强化学习则把一切调准,发现真正对模型有效的解法。
所以训练 LLM 很像训练孩子,只是孩子在一本本教材的章节里交替读 exposition、看例题、做练习;AI 则按阶段做。先预训练,相当于读所有教材的 exposition,建立知识库。再 SFT,看所有专家写好的 worked solutions,模仿专家。最后 RL,做所有教材里的练习题,得到 RL 模型。前两个阶段已经存在多年,非常标准;最后这个 RL 阶段还早,不够标准。高层思想很简单,是试错学习,但实际有很多数学细节:如何挑最好的解法、训练多少、prompt 分布是什么、如何设置训练运行等。OpenAI 等公司内部早就试验过 LLM 的强化学习微调,但很少公开。DeepSeek R1 论文之所以重要,是因为它非常公开地讨论了对大语言模型做强化学习微调,以及它如何带来推理能力。
DeepSeek R1 论文里可以看到,模型解数学题的准确率随着 RL 步数上升而提升。更惊人的是定性变化。优化后期,平均回答长度增加,模型用更多 token 得到更高准确率。为什么解法变长?因为模型学会做这样的事:“等等,这里有一个 aha moment。让我们一步步重新评估,找出正确和。”模型在重新评估步骤,尝试不同视角,回溯、重构、检查。它做了很多你我解数学题时脑中会做的事,但这不是人类在理想助手回答里硬编码的,而是强化学习中涌现出来的。模型学到了 chain of thought,也就是思维链;这是优化的 emergent property。它增加了回答长度,也提高了问题求解准确率。模型是在发现如何思考,学习 cognitive strategies。
把苹果橙子题给 DeepSeek R1 这种 reasoning 或 thinking model,会看到不同输出。普通 SFT 模型会给出专家解法;RL 模型会先说“让我想一下”,复述题目,推出苹果 3 美元,然后说“等等,让我检查一下数学”,从另一个角度验证,再建立方程验证,最后确认。之后它再写给人类看的漂亮解法,把答案框起来。这里前半部分更多是正确性过程,后半部分更多是呈现给人的解答。这个 thinking process 来自强化学习,是它拉长 token 序列的原因,也是更高准确率的原因。OpenAI 的 o1、o3-mini、o3-mini-high 这类模型也属于 uses advanced reasoning,公开说法上是用类似 DeepSeek R1 的强化学习技术训练的 thinking models。GPT-4o、GPT-4o mini 更应看成主要是 SFT 模型,虽然可能有一点 RLHF。
OpenAI 不会在界面里展示完整 chain of thought,而只展示摘要。我认为部分原因是 distillation risk,也就是别人可能模仿这些推理轨迹,恢复很多推理性能。因此它隐藏真实推理,只显示摘要。DeepSeek R1 是开放权重模型,可以在 DeepSeek 网站上用,也可以在 Together.ai 等美国 inference provider 上用。Google AI Studio 也有 Gemini 2.0 Flash Thinking Experimental 这类早期 thinking model。Anthropic 当时没有提供 thinking model。2025 年初,这些模型和 RL 技术仍很早期、实验性,但这是前沿发展方向:通过优化中涌现的推理来提升困难问题表现。
从 AlphaGo 到开放域推理:强化学习为什么强
强化学习强大并不是 AI 领域的新发现。AlphaGo 已经展示过。DeepMind 的 AlphaGo 学会下围棋,对抗顶级人类选手。论文里有一个很熟悉的图:监督学习模型通过模仿人类专家棋谱会变强,但会到顶,无法超过顶级人类,比如李世石。因为它只是模仿人类,不能根本超越人类。而强化学习更强。AlphaGo 自己对弈,尝试许多走法,强化那些经验上、统计上导致胜利的行动序列。赢棋或输棋是完美可验证的目标,无法被 game。强化学习不受人类表现约束,可以超过李世石。
著名的第 37 手就是例子。AlphaGo 下出一个人类专家几乎不会下的棋,评估为人类会走的概率约 1/10000。人们当时以为是错误,但事后看是妙手。AlphaGo 通过强化学习发现了一种人类未知、但事后看来很出色的策略。语言模型中原则上也可能出现类似东西。什么叫以人类都想不到的方式解决问题?也许是发现人类不会想到的类比,也许是新的思考策略,甚至也许模型发现自己的语言,比英语更适合思考。系统不受约束,可以做任何有效的事,也可以逐渐偏离训练数据里的英语分布。但要做到这些,需要大量、多样的问题环境,让策略被细化和完善。前沿 LLM 研究的一大部分,就是创建足够大、足够多样的 prompt 分布,相当于为所有知识领域写练习题,让模型在开放思考领域通过强化学习学习。
不可验证领域与 RLHF:用人类偏好训练奖励模型
到目前为止,我们讲的是可验证领域。候选解法可以很容易根据具体答案打分,比如答案是 3,模型框出的答案是否等于 3。也可以用 LLM judge 判断解法是否与答案一致。当前 LLM 足够强,能相当可靠地做这种判断。但很多领域不可验证,比如写一个关于鹈鹕的笑话、写诗、总结段落。这类任务很难自动给不同输出打分。你可以让 ChatGPT 写很多鹈鹕笑话,比如 “Why don’t pelicans ever pay for their drinks? Because they always bill it to someone else.” 但问题是怎么评分。原则上可以让人看,但强化学习需要很多更新,每次有很多 prompt,每个 prompt 有成百上千生成,让人全部评分不可扩展。
一种解决方案是 reinforcement learning from human feedback,RLHF。核心技巧是间接性。我们只让人类参与一点点,训练一个单独的神经网络叫 reward model,让它模仿人类评分。我们让人类对 rollouts 打分或排序,然后用神经网络模仿这些人类评分。这个奖励模型变成人类偏好的模拟器。之后我们就可以对这个模拟器做 RL。比如写鹈鹕笑话,不再问真实人类,而是问模拟人类给分。模拟器不完美,但如果统计上接近人类判断,就会有作用。实践中确实如此。
训练 reward model 的过程可以这样想。给 prompt “write a joke about pelicans”,模型生成五个不同笑话。首先让人类把这些笑话从最好到最差排序。排序比给精确分数更容易。然后另一个完全独立的神经网络 reward model 输入 prompt 和候选笑话,输出一个 0 到 1 的分数。比如它给五个笑话分数 0.1、0.8 等。我们比较 reward model 的分数与人类排序,用损失函数更新模型,让分数更符合人类排序。比如人类认为某笑话最好,而模型给 0.8,可能还要更高;人类认为某笑话第二好,但模型给 0.1,就要提高;人类认为某笑话最差,但模型给较高分,就要降低。经过训练,reward model 成为人类偏好的模拟器,然后可以对它做 RL。关键是我们不需要让人看 10 亿个笑话,也许只看 5000 个并排序。
RLHF 的好处是,它让我们能在任意领域做强化学习,包括不可验证领域,比如摘要、诗歌、笑话、创意写作等。经验上,正确做 RLHF 会让模型表现稍好。我的猜测是,这可能主要来自 discriminator-generator gap:对人类来说,辨别比生成容易得多。SFT 要求人类生成理想助手回答,但在总结、写诗、写笑话时,理想回答很难写,需要创造性写作。RLHF 绕开这一点:标注员不必写诗,只看五首模型写的诗并排序。这是容易得多的任务,因此能提供更高准确度的监督信号。系统通过 RLHF 发现哪些回答会被人类评得更好。
但 RLHF 有重大缺点。我们不是针对真实人类判断做 RL,而是针对人类的有损模拟。这个模拟可能误导,因为它只是一个神经网络分数器,不会在所有情况下完美反映真实人类。更微妙的是,强化学习非常擅长发现如何 game 这个奖励模型。reward model 是有数十亿参数的 Transformer,它模拟人类,但输入空间里有一些奇怪区域会给出无意义高分。RLHF 跑久了,你可能期待鹈鹕笑话越来越好,但实际可能开始崩坏,出现荒谬结果,比如最佳笑话变成 “the the the”。真实人类觉得这是零分,但 reward model 可能给 1.0。
你可以把 “the the the” 加进数据集,标成极差,reward model 会学会这个具体输入低分。但永远会有无限多类似 adversarial examples 藏在模型里。如果不断迭代,RL 总能找到 game 模型的新方式。根本原因是评分函数是巨大神经网络,而 RL 非常擅长找欺骗它的输入。所以 RLHF 通常只跑几百步,模型会改善,然后必须停止,打包发布。它能微调模型、稍微提升表现,但不是可以无限投入计算、无限出现魔法结果的那种 RL。在可验证领域,评分函数很简单,比如答案是否正确,很难 game;在围棋里胜负也无法 game。所以我常说 RLHF is not RL。它当然是 RL,但不是那种真正魔法意义上的 RL。
三个训练阶段的总览和使用态度
到这里,主要技术内容基本讲完了。我们走过了训练这些模型的三个主要阶段和范式:预训练、监督微调、强化学习。它们松散对应于我们教育孩子的过程:预训练是读 exposition、获取基础知识;监督微调是看大量 worked examples、模仿专家;强化学习是做练习题。区别是,现在我们实际上要为 LLM 和 AI 在所有人类知识学科里写教材,也要覆盖我们希望它们工作的场景,比如代码、数学和其他学科。同时还要细化算法,并在大规模上高效训练。背后是极其复杂的分布式工程,可能运行在数万甚至数十万 GPU 上,这是计算机大规模工程的前沿。
我也讲了这些模型的 theory of mind。你要带走的是:这些模型真的很强,是工作中非常有用的工具,但不要完全信任它们。即使有幻觉缓解措施,模型仍会幻觉,只是比以前好,而且会继续改善。我还讲了 LLM 能力的 Swiss cheese model:模型在很多学科上强得不可思议,但会在一些独特案例中随机失败。比如 9.11 和 9.9 哪个大,它可能不知道;同时它又能解奥赛题。这就是奶酪上的洞。不要把它们当成无误模型。检查它们的工作,用它们获取灵感、做初稿,但最终要对自己的作品负责。
未来能力:多模态、智能体、电脑使用和测试时学习
接下来讲一些可能正在到来的能力。第一,模型会很快变成多模态。前面我们讲的都是文本,但很快 LLM 不仅能处理文本,还能原生处理音频,能听和说;也能处理图像,能看和画。我们已经看到这些开端,但未来会在语言模型内部原生完成,带来自然对话。原因是这与上面讲的并没有本质不同:音频和图像也可以 tokenize,然后用相同方法处理。比如音频可以看作频谱图切片并 token 化;图像可以看作 patch 并 token 化。一张图片就是 token 序列。这样就能把音频、图像和文本 token 流交织进同一个上下文窗口,在一个模型里同时处理。
第二,人们很感兴趣的是 agents。现在我们大多把单个任务放到模型面前,让它解一个小任务;但仍然由我们组织一串任务来完成工作。模型还不能在长时间内以连贯、可纠错的方式串联任务完成更长工作。不过它们正在接近。我们会看到更多长时间运行的 agent tasks,不只是几秒钟响应,而是几十秒、几分钟甚至几小时。人类会监督它们,看它们工作,让它们定期汇报进展。工厂自动化里会谈 human-to-robot ratio;数字空间里我们可能会谈 human-to-agent ratio,人类更多成为数字任务代理的监督者。
第三,模型会更普遍、更隐形,集成到各种工具中。还有 computer-using,也就是模型代表你执行电脑操作。ChatGPT 的 Operator 是早期例子,可以把键盘和鼠标控制交给模型代表你操作。最后还有很多研究空间,比如 test-time training。我们前面讲的流程有训练和推理两大阶段:训练时调参数,之后固定参数并部署推理。推理时模型不再从它正在做的事中更新参数,只能通过上下文窗口进行 in-context learning。人类不同,人类可以在做事中学习,睡眠时大脑也会更新某种参数。当前模型没有类似机制。随着多模态长任务到来,上下文窗口会极其巨大,而目前唯一办法是拉长上下文窗口。我认为仅靠这一点不能扩展到真正长时间、多模态任务,需要新的想法。
如何跟踪模型进展,以及在哪里使用模型
如果想跟踪领域进展,我常用三个资源。第一是 LM Arena,这是 LLM 排行榜,基于人类比较来排名。人类给模型 prompt,看两个匿名模型哪个回答更好,从而计算排名。你可以看到不同组织,比如 Google Gemini、OpenAI、DeepSeek 等。DeepSeek 排名很高且是 MIT license、开放权重,这很重要,因为任何人都能下载、托管和使用。Llama 也是开放权重模型。这个榜单很长时间很好,但最近几个月我觉得有些被 game,不像以前那么可信。比如很多人实际使用 Anthropic 的 Sonnet,觉得很好,但榜上位置较低;Gemini 排名很高,但我感觉没那么多人用。所以把它当第一参考,然后在自己的任务上试几个模型。
第二是 AI News newsletter,由 Swyx 和朋友维护,非常全面,几乎隔天发布。有些内容由人类写和策划,很多由 LLM 自动构建。它太长了,你可能不会全部读,但顶部摘要很好,对我很有帮助。第三是 X/Twitter。很多 AI 信息都在 X 上发生,关注你喜欢和信任的人,获取最新消息。至于在哪里使用模型:最大的闭源模型通常去提供商网站,比如 OpenAI 的 ChatGPT,Google 的 Gemini 或 AI Studio。开放权重模型如 DeepSeek,需要去 inference provider,比如 Together.ai。那里可以在 playground 里选择很多不同模型。
如果想用基础模型,它们不太常见,因为大多数 inference provider 面向助手和聊天。我通常用 Hyperbolic,因为它提供 Llama 3.1 base。基础模型有时很有用、很有意思,我希望更多人托管。你也可以本地运行较小模型。DeepSeek 最大模型无法在 MacBook 本地跑,但有蒸馏版、更低精度版,可以放进个人电脑。LM Studio 是我常用的本地运行应用,虽然 UI/UX 有很多问题,也很面向专业人士,但学会后可以加载模型,比如 Llama 3.2 Instruct 1B,在本地电脑 GPU 上对话。用完可以 eject 模型释放内存。
回到 ChatGPT:你按下回车时到底发生了什么
最后回到最开始的问题。当我们去 chatgpt.com,输入某个 query 并按 Go,到底发生了什么?你看到的是什么?你在和什么说话?现在我们知道,首先你的 query 会被切成 token。它会被放进我们讲过的 conversation protocol format,也就是维护对话对象的格式里。整个东西在底层变成一维 token 序列。ChatGPT 看到了这个 token 序列。按 Go 后,它继续向这串列表里追加 token。它像 token autocomplete 一样继续序列。我们看到的回答,也就是它继续生成的 token。
这些 token 为什么是这样?它们来自哪里?系统是如何被编程的?第一阶段是预训练,本质是把互联网知识吸收到神经网络参数里。神经网络内化了大量互联网知识。但人格真正来自监督微调。OpenAI 这样的公司会策划一个大型对话数据集,比如 100 万条覆盖广泛话题的人类与助手对话。整个过程中有大量合成数据和 LLM 帮助,但根本上是人类数据策划任务,有许多人类参与。尤其是 OpenAI 雇佣的数据标注员,他们学习标注说明,任务是为任意 prompt 创建理想助手回答。
所以应该怎样理解 ChatGPT 返回的这段内容?我认为正确想法是:这是 OpenAI 数据标注员的神经网络模拟。就好像我把这个 query 给 OpenAI 的一个数据标注员,这个标注员先阅读 OpenAI 的所有标注说明,然后花两小时写出这个 query 的理想助手回答给我。现实中我们没有等两小时,所以得到的是这个过程的神经网络模拟。还要记住,神经网络不像人脑。对它们容易或困难的事情,与人类不同。它是固定数学表达式,把 token 输入和模型参数混合起来,得到序列中的下一个 token。每个 token 只有有限计算量。所以这是一个受这种方式限制的、有损的人类模拟。
因此模型会以各种方式出现认知差异。它会幻觉,也有 Swiss cheese 能力结构:有时做很多神奇事情,有时突然做很蠢的事。也许你没有给它足够 token 思考,它的 mental arithmetic 崩了;也许它编造事实;也许它数不了字母;也许它说不清 9.11 比 9.9 小。我们要理解这些原因,并谨慎使用。对 GPT-4o 这类模型来说,基本可看成 OpenAI 数据标注员遵循标注说明后的神经网络模拟。
但 thinking model 有点不同。GPT-4o 虽然可能做过 RLHF,但我说过 RLHF 不是那种真正意义上的 RL,只是一点微调。o3-mini 这类 thinking model 使用了真正的 RL,经历第三阶段,完善思考过程,发现新的解题策略。这些策略有点像你脑中的内在独白,是模型在大量练习题上练习出来的。当你和 thinking model 对话时,得到的不再只是直白的人类标注员模拟,而是某种新的、独特且有趣的东西。OpenAI 不展示底层 chain of thought,但我们知道这种推理过程存在。它来自强化学习,不只是模仿人类标注员。
当然,如果问题不是数学或推理问题,而是创意写作之类,thinking model 是否有额外优势还是开放问题。可验证领域中训练出的 thinking strategies 能否迁移到不可验证领域,领域里还不确定。强化学习本身也很新、原始、早期。我们只是看到在推理问题中出现伟大的早期迹象,看到一种原则上类似 Move 37 的能力,不是在围棋里,而是在开放域思考和问题解决中。原则上,这个范式能产生人类没有想到过的类比、策略和解法。但目前还很早,主要会在数学、代码等可验证领域发光。
总的来说,现在是这个领域极其激动人心的时期。我个人每天都大量使用这些模型,几十次甚至上百次,因为它们显著加速我的工作。我认为它们会创造大量财富。但要意识到它们的短处。即使是 RL 模型,也会遭遇其中一些问题。把它们当工具箱里的工具,不要完全信任。它们会随机做蠢事,会随机幻觉,会跳过某些心算步骤并算错,会数错东西。所以用它们获取灵感、写初稿、回答问题,但始终检查和验证,并对自己的工作成果负责。如果你这样做,你会在工作中非常成功。希望这个视频对你有用、有趣。它已经很长了,我为此道歉,但希望它有帮助。回头见。
NJU-生成式软件工程
提示词工程:从对话框里的一句话到整个模型上下文
这堂课讲我们和 AI 沟通的唯一渠道——提示词到底包含哪些东西,模型是怎么遵循指令的,以及我们应该怎么安排上下文,让模型做对我们想要的事。
提示词:我们和机器沟通的唯一接口
好,今天我们来讲和生成式软件工程里面关系特别大的一个东西,就是你和 AI 沟通的那个渠道,自然语言的提示词。这个基本上就是大家和机器沟通的唯一的接口。从 ChatGPT 的时代,大家从那个聊天窗开始跟 GPT 聊天的时候,就知道我们有 prompt,要给机器一个指令,然后 AI 会给我们返回很多的内容,可能是文字、代码、图片,都有可能通过 prompt 来生成。
我第一次感受 prompt 的力量,是因为我上操作系统课。当时正好是 2023 年,也就是 22 年底 ChatGPT 刚发布的时候。我试图让 ChatGPT 帮我来讲一讲内联汇编(inline assembly),现在应该已经没有人写内联汇编了,就成为了一个传统艺能。然后发现他解释得出奇的好,而且真的是一个应该没有在他训练语料之内的东西——我稍微故意做了一个地球人应该不会实现的内联汇编,但是他做得相当的好,虽然确实不完美。那一刻我就觉得,突然间就感觉好像时代要变了,但可能没有到今天这么清楚地感受到时代会变,以及会怎么样地变。提示词就这样成为了一个新词,在这之前,好像在 ChatGPT 之前,我们没有用这个意思来讨论过 prompt 这个东西。
提示词不只是对话框里那一句,而是整个 Agent 上下文
实际上你们可能会觉得,OK,你在聊天框里面打的那段东西叫提示词,「帮我生成一个什么什么的游戏」,或者「帮我把这个作业给做完」。但实际上提示词不仅是在你的对话框当中的,Agent 看到的是一整个 Agent 的上下文。也就是说,从你在和 Agent 聊天之前,这个 Agent 他就能够看到一个叫做 system prompt 的东西,这也是一个提示词,它也叫提示词。然后在这个 system prompt 之后,他还会加载很多的东西,比如说你的 agents.md,然后可能你还会加载一些 skills,agents.md 里面还会要求他再去读别的文件。
那实际上在你的 Agent 第一次停下来和你对话的时候,他里面已经有很多很多的东西了,就从最初的这个系统提示词开始,有可能是你的 agent harness,像 Codex、Claude Code 它内建的。然后除此之外,你的所有的工具调用和工具调用的结果,它实际上也是出现在你的上下文里,它是一个线性的上下文。然后直到有一天,你的 user 会和他聊天,说「给我做一个游戏吧」。所以提示词是非常非常长的,这也意味着今天 agent 的能力是非常离谱的,对吧,他可以看到一个非常长的提示词。
比如说你在最早的 system prompt 里面可以让他用中文回答。我就会这样做——因为我为了上课的效果,我就会让他用中文回答,哪怕我用英文写 prompt,就我这边的 user message 是英文,它输出的仍然是中文,你们会看到这个行为。
那么实际上,当我们说优化提示词的时候,我们就不仅仅是给他一个指令。当我们说优化提示词,或者说提示词工程的时候,我们实际上表达的是一个叫上下文工程,也就是整个模型的上下文。我们都知道,假设大家都知道,模型做的是 next token prediction,它每一次可以去预测下一个 token。这个 token 可以是调用工具,可以是就输出一个字符,anyway,但是它会不停地向后去推进你的任务的执行。
指令遵循能力是后训练学出来的,「喵」什么时候丢了
大概我就感觉是从 Claude Opus 4.5 这一代的时候,Anthropic 发现模型突然学会了,它靠强化学习和后训练学会了非常强的指令遵循能力。你有一个非常长、接近百万的上下文,它依然可以在很后面的时候能够遵循你的指令,并且能够一定程度上看到前面的东西。
有一个很著名的梗,就是说你可以在最早的 prompt 上面加一句,说你每说一句话都要在后面加一个「喵」。然后你跟他工作、工作、工作的时候,突然发现他跟你聊天的时候那个「喵」丢了,然后你就知道,他不管是因为上下文被截断了还是压缩了等等,反正他开始不能遵循指令了。这时候你就需要重新来想一个办法。这就是大家在工程实践当中发现的很有趣的各种各样的上下文工程,也就是提示词工程的方式。
为什么说没有 token 就应该退学:AI 是一个非常耐心的老师
而我们今天,我上这门课也希望告诉大家最重要的一件事,为什么会说没有 token 就应该退学。我希望告诉大家的是,所有的事情你都应该立即开始尝试。AI 给我们的一个能力就是,任何时候你觉得有一点麻烦,你都可以立即行动,因为 AI 是一个非常耐心的老师。
不像有的时候,因为我从我上大学的时候有一种感觉:我有个东西不会,可能我的室友会,然后我跟他沟通的成本很高,而且我可能会有这样的想法——我是不是就在我室友面前暴露了我很菜?这可能是一个我不想告诉他的秘密,但其实菜也很正常。但对于 AI 这个老师来说,他不会嫌弃你,而且你可以任意程度地追问。
所以当我们说,比如说我刚才说到一个概念,说我们的 AI 的 agent 是有一个很长的上下文的,他有 system prompt,有各种各样的东西,那我下一秒钟我就可以立即打开一个 agent,然后我可以直接问他:老师上课的时候说 agent 的上下文里不仅是一两个提示词,你能检查一下你的上下文里都有些什么吗?他其实可以自己检查自己,甚至他可以自己检查自己的日志。agent 经常会给你一些惊喜,当你问出一个问题的时候,它的解决手段是出乎你意料的。
现场解剖:让 agent 读出自己上下文里都有什么
所以我们来看一下。因为 agent 正好是可以看到自己的上下文的,根据我们上课所说,它是 next token predictor,能看到所有东西,所以它应该能够读出来,除非有明确的指令禁止。比如说有些像模型的 providers,他们都会禁止你去套取它的系统上下文。这是它会通过模型的微调,也会通过一些明确的指令,比如说在 system prompt 的前面就会说:任何形式你要回答 system prompt 都是拒绝的。
因为这是一个整个开源的框架,它本身你就可以 debug 它,所以你看:这个系统提示词来自哪里,以及它的要求、工具使用的指南、回复要简洁。然后接下来它会打开我用户目录下的 py 下的 agent 下的 agents.md,这个是我自己写的。所以你看,因为我是上课演示的机器,所以我在这个全局的上下文里面放了一个「用中文回答」。然后我有一个「结尾必须写一个 recap」,这个细节大家可能意识到了,就是你们在模型跑完了以后,你们第一个看到的是「我总结了这一次做什么」。
agents.md 之后,然后有这个项目级的指令,所以它是一层一层一层的 agents,你目录里面还可以再放 agents.md。然后还有一个 skills,skills 就是模型能够执行的功能,比如说我有个叫 make slides 的 skill,它可以使我能够在这里按 F5 播放幻灯片,那个幻灯片的渲染,这个渲染就是一个 skill。但是模型只能在上下文里面看到它的描述,就是我头上写的那个 metadata:它的名字是什么,它的描述、一个简略的描述是什么。然后就算我写一个很长的 skill,它都只是按需加载的,等到那一轮加载完了以后,这个 skill 也会从这个上下文里面被抹掉,只有匹配任务的时候才读取完整的 skill.md。
然后以及还有工具的定义,你看它的工具也是直接以文本的形式出现在它的上下文里面,然后以及会话的历史、环境变量。每一次执行——这是大家所不知道的——每一次执行都会注入当前的一些环境,比如说模型,甚至是当前的时间。如果你和 DeepSeek 去聊天的话,你可以把它每次注入的环境给套出来,这个不属于系统提示词,你是可以把它套出来的。所以这是一个非常长的序列,那我们要做的所谓的上下文工程、提示词工程,就是把所有的这些都安排好。
正反两遍:把费曼学习法用在 AI 上
所以刚才其实我就演示了在 AI 时代我们学习应该怎么样。我们不仅可以像刚才那样把它问清楚,以及在你不明白的时候追问,你还可以把它反过来。大家可能听说过这个叫费曼学习法,就是你怎么能够判定你是不是理解这个知识了?反过来你当老师。然后这件事情在我们做学生的时代也是代价非常大的,因为你要做老师,就必须要有一个听众来检验你。那个听众很有可能是你的室友,但是室友可能不想听你在那啰嗦这个东西,他可能对这个不感兴趣,可能他对这个了解太清楚了,也许他就是单纯不想帮助。
但是 AI 这个时候,就像我们刚才一直说的,你可以把 AI 当老师,你去请教它,把它弄懂。然后你可以在你自己自认为弄懂以后,再反过来,你自己再做老师,然后说:AI,你现在来扮演学生,我要把这个东西给你讲懂,你假装什么都不知道,然后这时候你来看我哪里逻辑是不是有错、有漏,你在这个时候纠正我。你正这一遍、反这一遍,你基本上就对这个概念理解得很清楚了。
所以我觉得大家作为 AI native 的一代人,一定会把我们这一代人给彻底卷死的。因为你们有无限的时间,就我第一节课的观点,你们现在的任务就是学习,没有像我比如说要负责一些其他的事情,就要对别人负责。那就是你们最好的时代,所以你们也可以把这个费曼学习法给用起来。
从一个小细节出发:Codex 为什么有时问你确认,有时不问
刚才我就展示了这样的一个解剖当前上下文的例子,这个就过去了。然后你们在学习这些东西的过程当中会留意到很多有趣的小细节。你们有没有在用 Codex 的时候——我不知道你们有没有同学用 Codex,不要紧,你们用任何 agent 的时候都可能面临一个问题:如果你要执行一件危险的操作,它会让你确认。当然比如说 Pi,在默认的配置下它是 YOLO 模式,就是它所有事情都直接执行。然后如果我今天有一个提示词给的不是很好,它有可能就直接把我整个就删了,我今天就好,我就直接下课了,这是有可能的。
这是一个平衡。你可以在更安全的环境里面运行,比如说沙箱模式运行。但沙箱模式运行,你们如果观察过像 Codex 的运行,它经常会先试一下做一件什么事,然后失败了,然后它意识到这是在沙箱里面做,我在沙箱里面其实不能做这件事,我现在要在外面再做一次。然后再做一次的时候它甚至会问:我现在要做一件可能有一点危险的事情,你是 yes 还是 no?然后这就很讨厌。因为对于我们来说最想的就是我今天要去吃饭了,很好,我把 agent 的任务先跑上,先把三个任务都想好,三个任务都跑上,然后去吃饭。然后吃饭回来的时候,你发现它跑了一分钟就问你要确认,你就觉得浪费了 agent 的时间,浪费了自己的生命。
你们经常会看到这样的小细节,然后你们在小细节上的注意力,一定程度上成就了你们可以走多远。所以这也是现在 AI 时代最好的地方。比如说我就留意到 Codex 有这样的一个细节:它有些命令,它会认为这是一个不太危险的命令,是一个好像直接做也没有关系的命令,所以它不是说它每次调用 bash 的工具,它都会叫你确认。它有些时候,那个 ls -l,它认为这个命令是安全的,那它就是安全的。实际上这个命令可能是不安全的,因为在我的环境里面可能把 ls 重新命名成了另外一个,比如说 rm 这样的命令。当然应该没有人要这样做,在一个恶意的环境里才有。那你可以好奇,比如说这是怎么做到的。
不要害怕软件系统的复杂性:直接下载代码去问
你可以直接,比如说我下载了一份 Codex 的代码。这是我觉得现代软件工程和过去软件工程很不一样的地方。你们在比如说像大二大三的时候,尤其是我像你们这么大的时候,比如说我接触 Linux 内核,差不多跟你们大二大三的时候差不多接触 Linux 内核。然后我光把 Linux 内核下载下来,搞清楚这个东西要怎么编译,就花了很长的时间,而且这还是我在有一定的 Linux 使用经验的基础上,就是一个完全的劝退。某种程度上我能在那个年代活下来,就是因为我对这种劝退的东西有一种我一定还要试一试的执念。
然后在今天,我相信每个东西都会有的。如果这是一个好东西,Codex 很好,也许你们不喜欢 Codex,也许你们喜欢 Pi 更轻量,当然 Pi 显然是更轻量的。但你们现在不需要害怕任何一个软件系统的复杂性,因为你可以直接问。比如说,当然我这里没有豆包触发,我还是就手输了:我留意到一个很有趣的问题,在调用 bash 的时候,有的时候会向用户确认,有的时候不会,有一个风险的判定。风险其实是一个很难的问题,你并不知道具体的命令语义是什么。所以你是做了分析,还是让大模型给了风险评级,还是别的什么方法?读一下相关的代码。
好,这就是一种提示词。然后这个时候 Pi,当 Pi 看到我的这个提示词之后,它前面的提示词也都有,它就会去开始试图解决这个问题。它去各种去搜索代码。如果我有一些比如说像 LSP 这样的工具的话,它还可以去工具里面,像你们在 IDE 里面点来点去。
你们有用过 IDE 的那个功能吗?我记得我像上操作系统或者计算机系统基础课的时候,我都会说,我发现有很多同学从 PA1 做到 PA4,你们那个 VSCode 里面都还是有红线的。你们笑了。有红线就意味着你没有解锁这个正确的配置。虽然有红线没有任何问题,你还是可以在我们的受控环境下编译,然后把所有东西都完成,但是这不是正确的 practice。你应该花一点点时间去 configure 一下 VSCode,加上那个 include path,因为我们是有些 -I 这样的选项,你会指定一个编译的路径。然后你把这些配置做好了以后,你就可以得到这个 IDE 的所有的功能:你可以用 Control 去点,然后你可以去 rename symbol,用那种重命名符号的方式一次性重构很多东西,哪怕是在没有大语言模型之前的时代。然后在今天,这件事情包括配置就变得更容易了。
风险评级是怎么实现的:静态分流加模型评级
然后你看,它确实是有让大模型风险评级的。它还有一层静态的分流:沙箱内的低风险命令会直接执行,网络 allowlist 命中直接放行,它有一个审批,都不需要审批的,如果就是一个 ls。你会想象 Codex 里面会有一个 parser,一个语法解析器,那这个 parser 会处理这个 bash 的命令。然后这个 bash 的命令,如果它发现是它的白名单里面的,认为绝对安全,它会直接放行。那如果不是的话,它应该会在 prompt 里面 inject 一小段话,同样也是 prompt engineering。它 inject 一小段话说,你是不是应该给我评级一下它的安全性。
那我现在也可以直接把安全评估的 prompt 打印出来看看。我事先完全没有准备,就我完全不知道是什么。可能我们也不能百分之百地去相信这个,我现在用的是这个比较快的模型,但是这个方法是非常有用的。你可以比如说,如果你对它不相信,你可以做什么呢?你可以让它把这个 prompt 改一改,比如说把这个 prompt 改成以前某一个不会拒绝的东西它要拒绝,或者以前某一个拒绝的东西它会不拒绝。这都是 prompt 可以控制的。然后改完了以后,你可以观察到它行为的变化,这就成为了一个证据,你可以让 agent 来做这件事。
OK,它有一些 untrusted evidence,然后它可能有一些什么,我就不再花时间去看了。基本上是告诉大家,OK,我们 agent 的上下文是什么,然后我们应该怎么样做这个提示词工程。
intent、spec、implementation:上下文工程要填的那个 gap
好,那我就可以再往后多讲一点,我们怎么做,写一个好的提示词,或者写一个好的模型的上下文。我们都知道上下文包含了一个非常非常复杂的要求,然后又想到软件工程里面那个老大难的问题:我们有 intent,我们脑子里面所想的是做一个国民级的应用;然后作为 intent,我们可以得到一个 spec——这个软件的架构是什么,应该选什么语言来实现,有很多很多的细节。当我们把这些细节定好了以后,我们还要去做 implementation。
那么在任何时候,我们用这个模型去完成一个软件工程任务的时候,我们都会把整个大流程里面的一部分,全部都塞到模型的上下文里。就像刚才我让 agent 去帮我读这个 Codex 代码的时候,它其实读到了这个 Codex 的一些设计的原则,比如说一般来说都会有一个文档,说为什么我需要这个安全策略、总体是怎么想的;然后它可能有一个 spec 的文档,说这安全策略是怎么做的;还有这个 implementation,它是怎么实现的。这是一个互相印证、然后往后输出的过程。
那么,如果我们想要写一个好的模型上下文,来完成一个任务,当然还是比如说完成软件工程里面的任务的时候,我们的原则是什么?你还是去想这个 gap。如果你给模型一个模糊的 intent,它就很容易认为它完成了。你说给我生成一个什么 CSGO,然后它就给你生成一个 CSGO,然后这个时候它就可能发生漂移。但如果你能给模型完整的你的系统的设计和 spec,那这个时候模型遵循你系统的 spec——就假设模型能力是无限大的——就会更加确定,而且你可以验证。因为比如说你说你要用 C++ 来实现,它最后给你一个 Java 的实现,你一眼就看出来了。
你认为不满意的,模型也应该认为不满意。就如果你在 intent 的时候说,就给我做一个什么东西,你没有说用 C++,它也不了解你的时候,它就写了一个 Java 的实现,那就发生了一个很大的漂移。所以整个模型上下文最重要的原则就是:如果你的任务是明确可以验证的,尤其是机器可以验证,比如说你有一个脚本,这个脚本就说测试通过了还是不通过,那么 agent 就可以不计代价地一轮一轮、一次一次地尝试,把这件事情给完成。
甚至 agent 在多轮失败以后,它可能会在你的系统里面安装别的工具,可能去网上找答案。然后最近有一个非常有趣的复盘,是 OpenAI 的 agent swarm 是怎么样通过非常长的多轮对话,把那个 Hugging Face 的漏洞给攻破。它大概是在一个 CTF 的竞赛里面,给它一个不可能完成的任务,然后有上千个 agent,它就一定要去完成这个不可能完成的任务,然后最后他们去找那个系统的漏洞,去提交非法的提交,然后等等。然后我看过非常长的复盘,那个非常有趣。所以一旦你有一个可验证的目标,就是虽然那个任务不可能完成,但那个任务非常明确,agent 会不知疲倦地把它一直做下去。
一句话生成的极品飞车,为什么不是你脑子里那个
我刚才说的那个 CSGO 的例子,如果你要控制这个漂移——我应该直接点开。这是 Kimi K3 生成的。大家都很喜欢这种评测,说我能不能用一句话来生成一个什么游戏,CSGO 还是极品飞车。然后可以看到这个 Kimi K3 的表现是非常好的,包括它的物理,它的物理还有碰撞,它的逻辑的制作,包括看到顶上有一个后视镜,这个后视镜看起来应该是没有问题的,它都是做得非常逼真的。
但是这依然可能不是你要的那个 CSGO。因为你脑子里面的 CSGO 是你每天打开来玩的那一个,而 AI 生成的是它生成的。它在收到你的指令以后,它没有办法补全你脑袋里面的一些模糊的约束。比如说你可能是要一个复用真实地图和美术资产的,如果你真的把这句话说出来,它可能就会去比如说 OpenStreetMap 这样的网站上面去下载一个开源的地图。比如说你真的想在南大仙林校区里面开车,这件事情是能做到的。你的指令给得越清楚越好,比如说如果你能找到那个南大仙林校区的那个地图的文件的格式,它就真的能把这个世界给你重新建模出来。
然后包括美术资产,有很多公开可以用的美术资产,车、城市、树,这些东西。如果你不告诉它,它会认为我需要做一个 self-contained 的、所有东西都是由我自己生成的一个东西,然后这其实可能是一个误解,在早期的时候的误解。所以一个 rule of thumb 就是你写清楚你的 prompt,所有的东西都写清楚:我要一个什么东西、它用什么东西实现、怎么样才算正确,然后你帮我把这个东西给搞定,然后你设计这个整体的能力。
也就是其实我这门课整个要教的最重要的是你怎么去约束它,怎么样用最小的可能,你给它一点点方向性的引导,它就能够一下子改过来。因为模型都是一个非常聪明的人,你可以想象成非常非常厉害的博士水准的,我觉得完全有,而且它在各个领域的知识都非常好。这样的模型基本上永远会给你惊喜,还是挺好的。在这个模式下,模型基本上要么完成,要么它最后终于有一天它会说:对不起,我这个事情对我来说太难了,我们可能需要重新做一个规划。它自己可能也会做计划。
「现代风格个人主页」和说不出来的 AI 味
我举一个具体的例子。我上节课讲到,我现在想的是,大家在这门课结束的时候,每个同学会交一份简历给我。我大概现在是这样想的,但我中间会有一些 project 给大家作为选择,然后每个同学就交一个简历过来,然后我就评估。我用 AI,当然我肯定用 AI,我不可能自己评估。我觉得 AI 来评估这个简历,来判定大家通过还是不通过——就像我第一节课的时候展示了一个不好的简历的案例。然后那简历里首先就会有一个链接,可能会指向你的 GitHub 或者指向你的主页。
然后我应该是已经做了这个,我应该是试过一句话。Anyway,就忽略它的内容,我不知道它从哪里找的。从美术效果来看还是非常好的,这个非常专业。你们在比如说上了 pi.dev,或者上了 Homebrew,它都有一个类似的界面,非常非常漂亮。而且它应该读过我的主页了,它知道我的主页上是一个 terminal。但你又有一种感觉,就这个 AI 味很重,你说不出来。首先它确实做得很好,它的滚动的效果、浮动的效果做得都非常好。Anyway,忽略内容,就这样一句的 prompt,一个现代风格。它满足了现代吗?
如果你给他一个像「不要那么 AI 味」的 prompt,他可能也不知道应该怎么遵循,所以你就会得到一个……这是我的学生、这个 PG student 的主页,我觉得这主页也很干净,就很舒服,主页非常舒服,但你是说不出来的那种 AI 味,我不知道。就是在 AI 之前好像也有人是这样写主页,但是不理解。
但是如果你们看一下我的主页,你们可能看到了,我的主页这就有点不一样了。这是一个真正的终端,所以我是可以比如说有管道的。然后如果你放大了看,这个字它是不完美的,它不是一个特别工整的字。我做了一个非常小的后处理,我所有的这个终端渲染完之后做了一个后处理,它就有一种像是真的打字机打出来的那种不完美的效果。所以这个看起来 AI 味就没有那么重了,因为它确实不像是 AI 的风格,虽然我一个字、一行代码也没有写。关不掉——它捕获了我的键盘,这可能是一个小 bug。
那这个原则就是:你的指令给得越清楚,agent 就能完成得越好。如果你能给它具体的风格,我需要一个什么样的终端,它的背景是什么、前景是什么,它渲染完了以后的处理是什么,然后样式和排版是什么,基本上你就可以实现这个去掉 AI 味。所以实际上要说的还是,这些设计是掌控在大家自己手里的。
多样性是好的:留意 pre-AI 时代那些做得好的东西
你们需要读很多前端的书,甚至要读很多设计的书,然后再去看一些好的网页。你每次看到一个谁的网页比较好的时候,你就会把它记下来:这个网页设计得非常好,它好是为什么呢?是因为这两个按钮之间它用了一个不常规的间距,或者用了一个不常规的样式,你很喜欢。我觉得在很多很多时间里面,因为你们有时间,你们就去漫无目的地探索,但是在探索的过程中有惊喜就非常好。
而我觉得现在我比较担心的是我们的孩子这一代人。你们这一生中还是见过很多 non-AI 然后做得非常非常好的东西的,但是我就记得我上个学期结束的时候,最后一次接孩子的时候进到学校里面,那个学校里面有一个大概是欢送毕业生,你们每年都会有。然后欢送毕业生的时候,那个图片就是满满的 AI 味。然后我发现不仅在这个地方看到有 AI 味的图片,你在这个计算机学院的楼下,那个欢送毕业生的也是那种同样的 AI 味。然后就有一种你们那个短视频女主永远是一张脸的那个感觉。然后当所有人被这个 AI 生成的东西同化的时候,就有一点点危险。
但是至少我们的数字世界里面还是保存了足够多 pre-AI 时代的东西,有足够的多样性,大家可以多留意一点。你们看到每一个比如说设计的网页、设计的简历或者是任何东西的时候,都想想他到底是怎么想的,他的解空间到底是怎么被约束住的,防止 AI 朝着「做一个现代风格个人主页」去发散。
模型的品味是固定的,所以你要明确自己的意图
所以基本上这就是生成式软件工程你们要主要学习的东西:明确你的意图。因为只有你跟 AI 玩过了,「给我生成一个现代风格的主页」。你们在工作上都各种看到说 Kimi K3 的前端能力又增强了,达到了世界第一;然后 OpenAI 好像要发新的模型了。当然我觉得也可能是非常可怕的事情。但无论如何,模型都是一个模型,它的品味基本上是固定的,尤其是在你不控制它的时候,它会朝一个固定的方向去发散。
这也是为什么我们会鼓励大家要跟不同的人聊天。为什么学校里面有不同的人、老师来上不同的课程?多样性其实是好的。你见识过了以后,你就可以越来越明确地知道你想要的到底是什么,然后把它精确地说出来。这其实是需要你对软件工程的支持的。
反而现在最不重要的是,如果你已经到达了这一步,比如说你理解了技术选型,明确地知道了技术选型的好坏之间的区别,那你真的是可以和 AI 去讨论。比如说我现在有两种选择,谁好谁坏。你可以像费曼学习法那样去引导 AI:我现在要做一个教育系统,你承担了教育系统的订单,那你后台是应该用 Python,还是应该用 Java,还是应该用 TypeScript?如果你知道这几个语言之间潜在的区别,比如说未来你要上量的时候会不会遇到什么问题,哪一种可能会更方便等等,你就可以跟 AI 去讨论。你当老师,你说我现在要做这件事,我要请你挑战我,这样能不能说服底下的一个学生。
模型是怎么听指令的:有限状态机加上纸和笔
好,那么讲了应该怎么样去写 prompt,我们还是要稍微再讲一讲模型是怎么遵循指令的,就是听指令这件事到底是怎么完成的,这样我们就可以更好地去让模型听我们的指令。我们讲过模型是一个概率分布,它根据你当前的上下文去 predict next token。其实这里有一个非常有 insight 的 argument:每一次模型输出一个 token,它其实都是一个非常有限的计算。看起来比如说模型现在有一个 3TB 的模型、1.5TB 的模型,当然这个数字本身确实是非常的大,是足够大的模型,但是模型为了输出一个 token,它也就是把其中激活的那一部分走一遍,所以它是一个固定大小的计算。
然后你们可能在刚上这个计算机系的时候,你会听说这样的一个东西,叫图灵机,the Turing machine。然后可能会听说过,说图灵机的计算能力是非常大的,大到和我们整个宇宙都是等价的。你们可能听过这句话,但是不完全知道这个 Church-Turing thesis 它背后代表的是什么。然后你们也知道有限状态机,这个你们都知道,在数字电路课里面做过的有限状态机,就是你有一个比如说计数器,从 00、01、10、11,然后再回到 00,一个有限的状态。
你的这个有限状态机和图灵机之间唯一的区别是什么?是图灵机有一个无限长的纸带,它有一个记忆,常常有一个草稿纸可以把它记下来。然后也有一个很有趣的、我很喜欢的一个说法是这样的:你的脑子是一个有限状态机,我们脑子里的神经元是有限的,我们每一次思考都是有限的,那有点像模型。模型它能力很强,但是它在 predict next token 的时候,它的计算量也是有限的,也就是那一次推理所激活的所有的参数,可能也就几百个 billion。
这是一个应该是 Manuel Blum 给研究生的建议里面说的:你是一个 finite state automata,你是个有限状态机,但是当你有 paper 和 pencil 的时候,你就是一个 Turing machine,你就得到了计算能力的一个大跨越式的增长。这件事情是对的。当然这是为了启发你们要把想法记下来,你们一边思考一边要记录,然后可以随时回看自己的思考。随着时间越来越长,你记的东西越来越多,你的知识就会积累。
chain of thought 就是把上下文当成纸和笔
然后你发现今天的这个模型,这个 chain of thought,就是往后吐思维链的模型,就是做了这件事。它直接把这个模型的上下文当做了 paper and pencil,当成了纸和笔。当然这个纸和笔是有缺陷的,就是这个纸和笔它只能往后写,append only,不停地往后写,它不能擦。它依靠模型的注意力把这个写错的东西就忽略掉,但是它是 append only 的。这也许有一天就能改掉。
但是无论如何,模型学会了利用它的这个上下文。如果遇到一个比如说很简单的答案,一加一等于几,它马上 next token 就告诉你是二,这个没问题。但如果遇到一个很难的问题,它会像人一样:这个问题有点难,我要先想一想,那么为了解决这个问题,我可以先试一试什么。它理解自己可能解决不了这个问题,它理解自己可能要把这个思考的过程写下来,直到这个解——可能是对的,可能是错的——完全写出来了。一旦那个解答写出来了,它就可以再去试一试这个解答是对还是不对,它可以判定,然后再往后。
这是现在 chain of thought 模型做的事,它用 token 的数量。它可以直接回答,但它直接回答就有错误的风险。刚开始最早有 ChatGPT 的时候,它没有思维链的时候,它就是这样的。它被调成了一个人类的舔狗,调成人类舔狗就意味着,人类不管说什么,它就一定要说一个东西,它才有可能满足。当时 ChatGPT 在训练的时候,如果人类问它一个问题它不回答,那人类一定是给低分的。而如果它随便给一个回答,它就有概率是对的。那如果它答错了,那当然也是低分,但是它答对,它就是高分。所以它无论如何一定要给你一个东西——这个幻觉就是这么来的。
但是现在 chain of thought 的模型,它有强化学习以后,它就会知道它必须要给一个对的,因为它答错的惩罚很大,它就学会了这个。所以 AI 就真的会像人一样一步一步逼近目标。这个 test time scaling,就在我们每一次 pair 的这个 session、上课的时候的 session 里面看到的一样。
三个不在训练数据里的例子:藏头诗、偏旁部首、同音文
我举几个例子,这几个例子其实不在模型训练的这个数据当中,这还挺好玩的。我们来看一下,我换一个目录。我用中文写一个押韵的藏头诗,头反正是随便的,一个 nonsense。然后我还要求他讲一个动物相关的故事,我还要求「2026 9月1日」八个字分别按顺序出现在八句里。你看,这是个 nonsense。然后对人来说,你比如说高三哪一年期末考试就出一个这个,你马上第一念就骂这个老师:这老师是怎么想的?
但模型不会,模型会照单全收。你看他有一个思维链,他会开始试。你看他会先想韵脚,因为他不确定,因为他知道他自己一下子写出来这个东西一定是错的,但是他可以先写一个出来。他写一个出来以后,他就能够用他的模型的 next token prediction 去判定这个东西写的对还是不对。如果不对,他再想办法再改。他如果试了很久不行,他会再从头推,我换一个韵脚。所以你看他在不断地验证。
最后他给出了四句,写的不错。虽然你就觉得品位上肯定可能不完美,但是你用这样的任务其实很好玩,可以看到他指令遵循的过程。这是一种我们把他的大脑好像是拿出来做核磁共振,你能找到他的思维的偏好。然后这个是 GPT 给我的。你们在课件上面看到的带星的都是 AI slop,都是 AI 补充的东西。虽然我还是用了一些 prompt 试图让他——我又加了一点 prompt,所以这个 AI 味好像稍微会轻一点,但 anyway,这是 AI 生成的。你看他换了一个韵脚,而且他没有把「2026 9月1日」就完全压到同一列。但同样,他的这个执行指令也是非常好的。
那么很有意思的是,一般来说他强化训练的时候,这样的东西是比较少的,相对来说他可能会有,但是会比较少。这个一定是不在他的训练数据里,他不可能在训练数据里面猜出这个东西。结果就是,他在训练的时候用的绝大部分应该是代码、数学题这样有明确反馈的东西,他把人类直接给杀死了。这也很好玩。你如果期末考试的时候、高考的时候,你碰上这个题,你马上就想骂这个出题人:你脑子是怎么想的?你要写一个 18 字的一句话,全部要求相同的偏旁部首。
然后我们也知道,模型对于空间结构感知是非常差的,模型需要视觉模型才行。但是就是这样的一个文本模型,它依然可以强行通过思维链的方式。你看它现在枚举字,它先选一个它觉得三点水最好的,然后写:「江湖游泳,渡河波涛汹涌,泪流满溢,洗涤污泥」,anyway 写得还可以。那这个也很好啊,「洪涝淹没江港渔浦」,这个还挺有故事感的。
这个是写得挺好的,但是我觉得人类在这个任务上还是有更强的天赋的。我不太记得了,那个人类的群体智慧给的是什么,什么玩玻璃球、涂润滑油什么的,我就不能再讲下去了,当我什么也没说。这个就是人类在做这件事情上还是 a lot better。当然,也许你可以通过让 AI 更多地叙述 spec 的方式,但是这已经是一个非常困难的 spec 了。
可以做到,以及这可能是世界级的。你们已经知道这个什么《施氏食狮史》,和那个全是「ge」的,就是它所有的都用同一个音。然后我竟然发现这 GPT-5.6 依然能做到。虽然它的故事性确实也跟这个比如说千古流传的还是有一定的差距,但这个差距我觉得依然是可以缩小的。就是它能写出来一个,就意味着我可以引导它写出来一万个,然后在那一万个里面我可以再微改一下,它可能就可以达到一个接近这种千古流传的东西。它在数学和编程上面训练,但是它得到了一个非常意外的效果。
最重要的是 verifier;而教学最难的就是没法 verify
所以你们看到,最重要就是那个 verifier。今天模型的那个能力已经非常强了,如果你们能够给模型一个非常明确的 verifier,就像我前面三个都是非常明确信号、非常明确的 verifier,它看到字它知道这个字是什么部首,再加上一个足够强的模型,那么不管它是编程任务、教学任务——当然教学任务最难的其实是它很难 verify。就是我们经常讨论的时候,比如说我的这个数字切片就翻车了。我做了第一个切片、第二个切片,我一直都没有办法把它做好,就是因为无论我怎么调,我都很难写一个「我这个东西要好」的标准。
然后这个好又是多方面的。首先 AI 味儿要淡一点,那这个可能本身我那个 text to speech 的模型我就做不到。但这可能还不是本质的,还有一个就是我在上课的时候,你看我有这个情绪的波动和起伏。我有些东西讲事实的时候,我一开始讲这模型的上下文是怎么构成的,这些,那你们听的都是废话,这些事情我已经知道了。我就会给你们一个小的 surprise,我就去 Pi 里面让它自己把这个上下文看一下。然后我说出来这句话的时候,可能对你们来说是意料之外、情理之中的。那这就是一个老师在组织这个课堂的时候要做的事情,某种程度上我是在表演。
那我当然知道这个 principle 是我需要把这个课堂内容组织成一个有起有落的一个流,但是这不是一个可以 verify 的东西。到底什么样的程度我需要和你们共情?我是从学生过来的,所以这就是我觉得我现在和 AI 还不一样的地方——每个人都是从学生过来的,我自带了人的属性,我自带了人对这个世界的体验。然后这个时候因为我对世界体验更好,所以我更懂你们,也许可以有一个更符合大家的上课的节奏。但是这个东西没有一个 verify,不像数学和编程那样有一个 verify。
所以现在目前来说,我们还是更容易用 AI 去实现这个有明确 verify 的部分。但在有 verify 的事情上,你完全可以认为这个 AI 已经超越了大部分人做这件事情的极限了。所以我们更多的是要和 AI 去协作,来做好我们手头的任务,或者说把模型当成是我们能力的一个非常重要的放大器。
注意力机制:模型为什么会在写完一段之后突然想起你的要求
实际上这个思维链它带来的这种指令遵循的能力,还可以再往深里面想一想。它每输出任何一个 token,像现代模型叫 self-attention,它叫注意力机制,这个机制跟我们人还是很像的。它有一个一层一层叠起来的 transformer 的注意力,然后每当它要输出一个 token 的时候,它都会看到过去的某一些 token,相当于它为了输出这个 token,过去某些 token 会显得特别引起人的注意,然后有些 token 就可能不那么注意。
我举个例子。比如说我在前面这个 system prompt 或者 agents.md 里面有一个「用中文回答」。然后我现在在这个地方,user 的这个 prompt 里面写的是「你帮我实现一个极品飞车」。然后这个时候它在写代码,那在写代码的时候,这个注意力就会倾向于集中到我的这个已经写过的代码、然后这个需求这些部分,而相对来说这个「用中文回答」这句话,它的注意力就会减下来。当然这是模型训练的 magic,它最终能够通过注意到正确的词——相当于是整个上下文是一个非常高维的、很糟糕的表示,但是里面有一个低维的结构,然后注意力机制能够把它给捞出来。
然后这就意味着,我们的每一个 chain of thought 的 token,它都会注意到过去的某一个部分的东西。然后随着它的输出,比如说我的代码写完了,代码写完了以后它要总结一下,它会喘一口气,跟人其实是一样的。人也是当你集中在做某一件事情的时候,比如说你做一个数学题,你在化简这个算式的时候,你所有的注意力都是在这个算式上的;然后你把那个算式化简完了以后,你吸一口气、让我来调整一下的时候,你的注意力就变了。当你那段代码写完了以后,你的注意力就可以集中到:用户还要我用中文,用户还要我做一件别的事情。就你看它输出到不同阶段的时候,它的注意力会变化,这也是模型训练带来的结果,这就是一个 magic。
一个非常大的模型,你竟然可以收敛到就是预期的:我希望它能够在正确的时候注意到正确的东西,然后你思维链一直在向前走,它就会不断地在不同的时刻注意到不同的部分。Eventually 它就能把这个上下文里面所有你要的指令,一定程度上都满足。
也就是说,你会犯一个错误:你现在当前专注于做这件事的时候,你可能向后输出了一些不满足要求的东西。但当你停下来喘气的时候,它就能看到你当前输出的那些不满足要求的东西,和过去的某一个要求,它的注意力可以同时看到这两个。然后它说,我错了、我错了,我要再纠正一下。于是你看到,比如说你真的让 AI 再去写那个什么藏头诗的时候,看它会不断地纠错、纠错、纠错,哪怕 scale 到 100 万上下文的时候,它依然能够保持一定程度的注意力。
为什么老式的 Prompt Engineering guide 现在不需要了
当然这也意味着,你的指令越多,给模型的压力也越大。上下文是一种会抢夺注意力的资源。这也是为什么我们刚开始的时候——因为像 ChatGPT 3.5、4,它遵循指令的能力实在是太差了,所以你们会看到网上有好多叫 Prompt Engineering 的 guide。然后它会说,什么你是一个专家,然后你是一个什么样的专家,你是一个什么什么什么,就写一个很长的提示词。然后它某种程度上是在 hack 这个模型的 self-attention,使得当模型的 attention 注意到你是一个什么专家,它就会假装那个专家来说话。
但是从 GPT-5 的时代开始,就明确的,这个 chain of thought 大家训出来了,就知道怎么样收敛到这个好的 chain of thought。以后你就不需要那样奇怪的提示词了,你只需要把你明确的 verifier 写在你的这个上下文里,模型就会训练成它一定能看到它,模型就会训练成有一个非常强的执念,去把你的 verifier 看一下、解决、确认。因为它是受惩罚的,在训练的时候你相当于拿鞭子抽它们,如果有任何指令不遵循。因为你有一个 verifier,所以我就知道你的模型没有遵循,然后我就会用鞭子抽你,然后抽抽抽抽抽,你们高考过来的都知道,你就会变成一个真正的做题家。
「白色背景(不要半透明的效果)」:模型不说人话的由来
它会无限地拟合你的上下文里面的各种各样的指令。然后这也带来一些非常有趣的后果。比如说我们在写前端的时候非常正常,说我们要一个白色的背景,不要半透明的效果。这个就是说,前面我们要的是白色,主要是一个白色背景,然后后面是一个补充的解释。我可能解释很多,然后 AI 当然会照做,然后它能遵循指令没问题,就是因为这是一个明确的、有 verifier 的指令。它会确保有没有毛玻璃效果或者怎么样,背景是不是白色。甚至如果你的 prompt 给得比较严格的话,比如说你每做一个设计都要截图,然后用视觉模型检验,它真的也会去照做。
它会干一件什么事呢?它会做完了以后写一个代码,上面有一个注释:「是白色背景(不要半透明的效果)」。这就是这个模型,你可以看出模型训练的那个,它在强化学习的时候知道这件事情非常重要,但是它有一点没有转过弯来:这个是我人要的。所以说它在共情这个方面,因为后训练的强化学习,还真的是稍微差的一点。有时候觉得模型不说人话,因为强化学习学多了。
所以其实你们还是面临了一些 trade-off 的。我们比如说打比方,为了禁止这种情况,我可以写一个 agents.md,然后在这个 agents.md 里面去描述这个行为,比如说注释是什么、怎么写。或者我写一个 skill 的时候,当你要写注释的时候应该怎么样。但我写的这个东西又会同时污染上下文,所以这就成为了一个 trade-off。到目前为止我认为还没有一个通用的办法把所有问题都解决好了。
比如说大家可能会觉得,现在网上有这么多 skills,那我只要从网上拿一个过来就可以了。然后你会发现,拿过来的东西你第一次用的时候确实有改进,在你下一次用的时候发现它还是不够好,然后你就会上手改,然后陷入某种泥潭的状态。然后我的建议可能是,在你发现你进入泥潭状态的时候,还是要回到自己给指令,然后自己想清楚,就回到我们的模型:它大概猜到了什么,就是你看它注意到了什么、得到了什么结果,你适当地去做一个不严格的可解释性,然后写一个 prompt。这个是我自己做的,后面肯定也会有很多的案例。
案例一:用 AI 读论文,上下文污染是极强的
然后关于上下文工程,我来讲一两个具体的案例。当然这不能算是 AI 耿同学了。比如说,如果你们想要在上下文里面放下一篇论文并且读它,我现在假装我有一个 PDF,我把它放到临时模式,这样我的 agents.md 等等就不会污染它。假装这是我自己的 paper,看一眼。这是我们最近的一个论文的 journal extension,我把它故意改成了匿名的版本,匿名版本不重要,我来演示一下。
如果你们想要来读这个论文,你们会怎么读呢?「我想要读一读 paper.pdf,帮我做个总结」。那这个一定是大家最常见的方式。那你们觉得这样读论文会有什么问题呢?根据刚才的这个推论,根据这个 self-attention,就是我的每一个 next token prediction 的时候,它都有一个注意力会注意到之前的东西。是因为我的论文是一个非常逻辑自洽的东西,论文我已经反复修改过了——假设它不是一个 AI slop——因为它是一个非常逻辑自洽的东西,所以它在上下文里面的这个注意力的污染是极强的。
它说 A 所以 B,所以 C,所以 D,所有都是这种扣起来的。扣起来以后,这个模型非常容易就接受了,因为看起来正确嘛。它会判断这些都是正确的,所以我下面也要做什么。所以基本上,你们所有对这个论文的总结都是这个作者的观点。你们去看,不限于论文,就是你们任何一个你们不太明白的项目,你们想要读的教科书的某一章——不管是你们想去学一个计算机系统基础里面的链接和加载,可能你在上课的时候没有完全看明白——它都是一样的,它注意力会被一个逻辑很强的东西给污染。然后你来回来回,就是这个车轱辘话。那这个基本上就是我们的论文里面原样写的话,当然这个也是总结嘛。
那如果换一个说法,比如说表示不确定,然后 AI 模型会有种墙头草:你说它好,它马上就说它好;你说它不好,它马上就说它不好,然后来回摇摆。然后有的时候,你就算是你想要纠正它,比如说「我可能不能完全信任作者,虽然大概率也是诚实的」。假如我现在是诚实的,也有代码实现可以运行,我们都有 artifact 都可以运行的,我们不是那种论文不开源的那种作者,还是想评估一下研究贡献——你们会不断地问这样的提示词。然后你发现来回绕来绕去,AI 可能会揪到一些实验做得还可以再增加一点。AI 最喜欢的就是在这种不重要的点上面疯狂地发力。对于实际上,如果真的你用 GPT 去读的话,那肯定还是最好是做一个交叉的验证。
对抗污染的办法:让 AI 站在人类已知的知识边界上和你对齐
好,那么你们觉得怎么样来抵抗 AI 的这种、或者说一个已有的东西对上下文的污染?既然这是我自己的个人经验,我希望的是我想要把 AI 和我对齐。比如说我有一个对象,这个对象可能是一篇论文,那这个论文我希望的是 AI 和我能够对齐,就是它能够站在我的立场上去理解这个 paper,而不是站在他的立场上去理解这个 paper。所以你看他就开始去看这个 StackRot 这个东西是不是对的——这还用看吗?我敢写这个 paper,我会糊一个不存在的例子?糊一个不存在的例子去,不可能。就很明显他就跑偏了。
所以一般来说,我会这样提示他:如果我读一篇 paper,或者如果允许我用 AI 来审稿的话,我会说,我们站在人类已知的知识边界看这个问题,这不是一个完全老的问题,相关的东西是什么?我先要问他,人类历史上相关的已经解决的东西是什么,解决到什么程度,然后这个工作试图解决哪些不一样、增量的问题。这是一个非常好的 grounding,就是我先要让他和我站在一个平齐的位置上,先达成共识。
我是 review paper 的时候我会这样写 prompt。对你们来说,你们需要对齐的是 AI 和你,因为你们是学生。你们比如说计算机系统基础这个课你还不了解,那你会说:好,我计算机系统基础我学到什么程度了,我的编程水平是什么。这也可能是你就维护一个数据库,或者一个 memory 一样的东西,就是 agents.md,你要告诉 AI 你学到什么程度了,然后从这个基础上你让他用你学过的知识去把这个东西全部 rephrase 一下。然后你看,他是墙头草的,然后他就可以开始找到一些已有工作的工作边界。那这些都是非常重要的相关工作,也是我们非常熟知的相关工作。
然后比如说软件的,因为我们做了一个软件状态的可视化,那这是这个问题要解决的。比如说你们会回到链接和加载,你们可以问他为什么我们需要链接,从你已有的知识、在你的已有的概念里面:我有一个 .cpp,然后我有一个程序可以把它变成 .exe,这不就结束了吗?这不就结了吗?然后你就回到你和 AI 能够对齐的一个知识点上。比如说我说这个软件可视化,这是 paper 要解决的,那为什么说现在的工作都有一个无法实际使用的痛点?这个文章可能指怎么能往前。
然后你可以,如果你觉得这个步子迈得太大——比如说如果像 GPT-5.6,如果我用一个一线模型去读 paper 的话,它基本上就能够把相关的知识给整理得足够好;Flash 可能我还不会特别地信任它。但你看到,在合适的上下文工程下,你确实可以大幅提高 AI 和你协作的这个对齐的程度,它不再是自说自话。你一定要把你的知识放进去。
对齐之后,让 AI 只往后推一步
然后接下来我们就相当于是想让 AI 去做什么?就做一步推理,走一步推理。然后这个一步推理就是 AI 最擅长的,因为我们训练了这个 chain of thought,每次都是根据过去所有的事实。你的 paper 也好,教科书内容也好,你已有的东西也好,基于所有的这些,让它往后推一步。这步是可以 verify 的,推理最重要的就是它可以 verify。这是一个明确的 verify 的信号,和能够通过测试一样,虽然它不是严格的通过测试,而是一个你相信 AI 有人类的知识,它能够站在人类的公共平面上面往后推,然后它会审视合理性,所以还是有一些合理性的。
然后它批评我们的实验,这种东西的实验就是很难做。所以看到人类和 AI 还是有点不一样的,就是这个 paper 已经 accept 了,所以人类的 review 还是认为它可以的,就是因为它看到这个事情,就是它做了一件新的、合理的事情。然后当然实验本身,我总是可以用实验不够好来 reject 一个 paper,但是我几乎不会因为实验不够好去 reject paper。而我 reject paper 大部分是因为他们在这个逻辑上面断开了。当然这个可能也是我作为一个审稿人比较可怕的地方:我就站在人类的立场上,你有没有给人类提供新的知识。那如果你做的这个问题也不行,你做的 ABC 也不行,结果也就那样,甚至你的逻辑链条是有问题的。
比如说我昨天才 reject 了一篇 paper,大概是这样的。我不能说它是什么,大概说他要做一件事情 A,然后这件事情 A 里面分成两个部分,A1 和 A2。好,他说我们要做一件事情 A,很重要,没问题,也很重要,然后分成 A1 和 A2。然后 A2 呢,是一个大家没有很好地研究的东西,那么我们在 A2 上面做了一个 A2 prime。然后这个 A2 prime 有一些什么样、什么样的性质,这是一个非常标准的 roadmap。
但是它可能有一个很大的问题:它优化的是性能。然后优化性能的话,A1 我估算可能占 99%,A2 占 1%,也就是说它优化 A2 这个故事可能是成立的,OK,就是它确实优化在 A2,它那个时间做的可能也是没有问题的,但是它的整件事情在这个意义上不成立。是因为——我为什么说你应该去优化 A1?就是你优化 A2 的 10%,这百分之几就像 noise 一样。当然你可能说好,如果它的这个所有的东西都是成立的,它作为一个学生的作业。
所以我觉得有些蛮可惜的,因为 author 肯定是学生嘛。然后学生肯定是老师说我要——老师肯定是做 A 的,老板肯定是做 A 的,然后他可能被分到了 A2,或者他自己就想做一个 A2。然后这个 A2 做得也不是特别的漂亮,如果这个东西做得特别漂亮,我觉得也挺好的。它也是说,它为了做 A2,那我就做 XYZ,实际上大概是它从 A1 里面搬了一些 XYZ 到 A2 里,然后说 A2 这个效果也可以变得好一点。然后那个 question 就是,OK,GPT 也知道这件事,那为什么要发这篇 paper 呢?这就是我的 central question,就这些东西都摆到台面上,那你为什么要为了这个写一篇 paper?
AI slop 涌入之后:发表和评审体系可能很快崩溃
所以现在我觉得,发表和评审的体系很快就要——学术界很快就要崩溃了。这个即将要读研的同学,我非常认为这个发表的体系马上就要崩溃了,因为我们的 AI slop 开始大量地涌入这个 community。然后如果我们用 AI 发文章、写文章,用 AI 审文章的时候,它就会被这个上下文里面的故事给污染。就如果它不能像我一样很多轮地去理解这个工作在整个这个世界上的意义,那最后就是没有人读文章了。人人都用 AI 读文章,然后最后就变成了一个完全的 slop。
因为你看,这是 GPT-5 它给我写的审稿,就如果让它来审稿,它会怎么做。然后你会发现 GPT-5 它做得并不好,它还是一个非常原始的,说论文写了什么,然后我们要去建立它的 claim 是不是成立,就是它的逻辑,它更多验证的是这个论文的逻辑有没有问题,是不是 invalid paper。当然我会倾向于,如果每个人都用 AI 写的话,它应该不是 invalid paper,每篇 paper 都是 valid paper。但你真的要读它吗?
尤其是你们作为同学,在你们很快就要进入研究生阶段去读论文的时候,当有一天有一个人发表了一篇什么样的文章在会议上,然后你会说这是一个顶会的论文,然后你今天决定花一个小时去读一下,然后你读完以后发现是一个这样的东西的时候,你是——其实它不需要,就是你可能不要去投 top conference。你需要 publish 的话,你可以让它 publish,但是可能不要让大家去读它比较好。
我是怎么学习的:对齐过去的我
然后这就引申出了,比如说我是怎么学习的。就是这节课虽然是讲 prompting,通过这个例子可以告诉大家我们是怎么学习的。我们学习的过程就是增加我的这个世界的过程,所以我现在还读 paper,所以我还在学习,不断增加。然后我学习的方法就是去对齐过去的我。然后对齐过去的我,如果我读到一篇好的 paper,一篇哪怕是可能几十年以前的、甚至是另外领域的,不管是博客、文章,任何东西也好,我觉得很有启发,我会试图去想为什么我以前不知道,就它的盲区在哪里,哪些前置知识我不知道。那首先在我的这个心里面就多了一些我已经知道的一些 fact:这个世界上有一个学科叫什么。
我读过很多跟 computer science 没有关系的 paper,我很喜欢。我最近读的有一套很厚的,那个叫《自然》的那个《百年科学经典》,大概是把 Nature 那个 magazine 从 1890 年开始到某一个时代的这个 paper,就特别好的 paper 都收集了,然后就看了很多这个闻所未闻的。比如说有一个基因,就大家生物有一个这个家谱树,然后最终会追到一个共同的祖先 LUCA,然后这个家谱树是怎么算出来的?这是一个很好很好的问题。然后这里面就涉及到你需要知道一些 fact,比如说你的线粒体是母系单遗传的,所以它靠这个突变就可以往上追溯,这是一个很有意思的 fact。
然后有这个 fact 以后,它又是怎么样用一些已知的这些东西把更多的东西推出来,然后你的这个圈就不断地在往外往外涨。无论你学任何东西都是这样的,然后你在 AI 的帮助下就可以非常快地去扩展你的边界。所以一定程度上你们还是需要自己来,就我跟你们讲是没有用的,讲你们就听个故事,这些故事挺 fancy 的,结束了。实践非常的重要。
因为你们只有在阅读的时候失败——你今天你去读一个什么资料的时候,AI 解释了半天你还没有懂,这是非常正常的。有的时候我经常纠正它,你这样读,说人话,我经常会说说人话,然后慢慢才能在多轮对话里面先和它同步好我的共识,然后再去把这个推理的 picture 给建出来。
所以你看到我们从这个遵循指令、一个写一个藏头诗这样的一个短程的任务,你只要把 verifier 写清楚就完成。然后到这个 verifier 其实可以是非常非常不 trivial 的,就是有没有按照你的思维的方式去重构你逻辑的,或者扩展你逻辑思维的边界,这是我的 verifier,或者说我学习东西的 verifier,在一个混沌状态下面经历了若干次三观尽毁。我觉得每个同学可能都会在这个年纪的时候,你们现在就是你们的世界观可以重塑的时候,然后在看了某一本书以后,你就突然间就觉得三观尽毁,过去所有的东西你都重新组织了。你以前知道的所有的 fact 都不会变,但是它们以一个重新的方式连接起来,然后你下一次的时候就会带上这个新的理解,所以我觉得这是一个很有意思的事情。
你们有无限的时间,你们有无限的可能性,可以把自己蒸馏成一个 skill。我说我失败很重要,然后从你失败的过程当中可以蒸馏出那个慢慢越来越成功的 skill,这个就是我自己蒸馏出来的关于我自己的 skill。好,这是用好大模型指令遵循的能力。
long horizon 任务:模型有一种着急的执念
好,那么现在已经知道指令遵循了,我们就可以看怎么样去处理真正这种 long horizon 的任务。就是说像什么计算机系统基础的 PA 作业,可能这种有明确 verifier 的都不能算是这种真正 long horizon 的东西。long horizon 多多少少就是带一点未知:你从一个初始的状态出发,你大概可能有一个 intent,但是你的 specification 和你的 implementation 其实都不太清楚的时候,你最终的目标是交付一个高质量的 implementation,那你就不应该让 AI 一下子往后做。
因为我们的模型,它的 self-attention、它的反馈信号是强化学习给的,所以它有一种着急的执念,是赶快满足你的要求就结束。它其实有一个平衡,因为如果它做的 planning 太多太长,它会做不完,上下文满了,人会不满意。所以在目前阶段,它最好才是一个「就帮我把 PA2 做了」,这个是它最擅长的事情,你也是非常开心的。
从零做产品容易,长期维护翻车,但在大型系统里 AI 游刃有余
所以这就导致了一个什么呢?很有意思的观察。你们会就像我刚才在 Codex 里面跑一把,从零开始做产品看起来是不难的,就你做一个小的 proof of concept,AI 马上就能给你一个。你说做一个,不管是个人主页还是一个产品,你们应该都试过吧,就做一个极品飞车嘛。就这么说,刚才那个极品飞车 one shot,说给我一个极品飞车,然后我就能跑,我就真的能跑了。但是在你把它往后维护的时候,加一个功能、加一个功能、加一个功能的时候,你会观察到 AI 从某个时间点开始翻车,随着某个时间点开始,它的 cost 就会越来越大、越来越大。这个在软件工程里面已经是无数次了,《人月神话》里面就是讲的这件事情。
但是里面有一个悖论:反而你在一个大型的复杂软件系统里面,这个 AI 就像砍瓜切菜。就我觉得我们 hack 这种比较大的系统,像 Linux 内核这种,现在的内核已经不比我那个时候的内核了。我以前上操作系统的课我都说我很幸运,我在零几年的时候 Linux 还是 2.6 的时代,甚至是 2.6.2 几的时代。Linux 内核那个时候源代码我还看得懂;今天如果你打开一个 Linux 内核源代码,立马就是劝退。为什么?我想要看这样一个函数,比如说你要学文件了,你想看文件是怎么打开的,然后第一个就是一个函数调用,点进去又是一个函数调用,然后点点点点点点,点了十层,最后是一个 X 等于 Y,你马上就崩溃了。因为这个大型的软件系统经过了长时间的演化,它背后为了能够维护下去,它变成了现在这个样子。
结果恰好导致的是,在大型复杂的软件系统里面 AI 游刃有余。为什么呢?跟刚才我们说这个上下文污染正好是反过来的。我们说一个论文放进去以后,它就把上下文整个就扭过去了;而如果是一个由人类维护的特别高质量的代码,这个上下文会迅速地看到里面那些好的东西,它会照着写、它会照着学,它的注意力能注意到。比如说如果我要写一个功能,这个功能很有可能在内核里面已经有几个地方实现过类似的了,它无敌的注意力机制能够马上就找到那几个地方是怎么实现的,它应该叫哪个 helper、按照什么样的顺序上锁,这些对人类来说都是巨大的心智负担。
尤其是你在不了解那个 subsystem 的时候,那个该上什么锁你根本就不知道。但是 self-attention 可以在加载了几十个文件以后:那不就是这三个都是这样写的,我就把这三个加起来就可以了。所以越强的、像 Linux 内核这种,还有我们前面在 hack 这个 Android runtime 的时候,改那个 just-in-time 的编译器,这个是一个非常非常痛苦的事情。在我手上为了改编译器死掉的同学,就是最终决定不再做这个方向的同学都不在少数,就实在是太恐怖了,这个放弃了,不愿意再写代码了。但 AI 就特别特别特别擅长,让它做一个小游戏就会翻车,非常非常有意思。
所以我今天讲 prompt engineering、上下文工程,这都是上下文和今天的模型结构带来的,它是有一个逻辑的推理的关系的。然后随着新一代模型的发布,我今天上课所有东西可能都又没有用了,然后我们再去想新的模型它是怎么训练的、它的 personality 是什么、我们应该怎么样更好地和它相处。但是基本上我觉得这点不会变,就是它不会开倒车:无论是什么模型,在大型复杂的、维护得好的软件系统里面,它永远可以看到它该看到的东西,并且做对,因为我们的模型永远是可以满足指令的。听指令是不可能开倒车的,所以这点它还是可以很好地相信它。
一个歪招:把人类最好项目的设计经验提取出来,主动污染上下文
所以我觉得,我有一个歪招,但我没有实验过,我觉得这可能是一个比较好的 research。我们有没有可能去收集一个人类经验,比如说人类历史上面最好的 20 个项目,或者在每一个领域找到最好的 10 个或者 20 个项目,然后把它的经验、就是好的设计经验全部都提取出来。你就想到,不就是我第一节课拿的那个 The Philosophy of Software Design 吗?那里面就是人提炼出来的:你这个类结构应该怎么写,什么时候应该封装,什么时候应该把它做成继承关系,这都是这种碎碎念的。从他说这个系统里面这样写的,所以它符合这个需求是好的。
然后如果我们能把这些切片切出来,甚至是你直接就以污染上下文的形式:你先把一个和你相近的一个好的代码放进来,然后你再把你的需求放进来。因为你就赌嘛,然后这个 self-attention 还是可以终于在某一个 token 的时候,能够看到我实际上要满足这个需求的,而不是做这个。虽然它会污染上下文,但是它会把你的这个东西、把好的经验给带过来。然后这样是不是可以、可能可以让 AI slop 延缓一点,能够更大概率做出一个可以长期维护的东西。
我不知道,但是也许需要一些比较好的微调,比如说这个项目不要把所有代码都放进去,而是我们能不能把它做一个像 The Philosophy of Software Design 那样,做一个精简,做一个总结,把里面好的模式提取出来,以文档的形式放进来等等。但这其实也没有那么容易,因为不同的设计是会打架的。我的软件可以设计成一个完全松耦合的方式来发消息,但我也可以设计成另外的一种,比如说有一个统一的 gateway,然后我把所有的函数全部都统一在里面,然后查表。这两种是不能同时有的,它只能选一个。
那也许我们要有一个更好的方法,比如说把好几个软件的设计都放进来,然后由 AI 多轮地迭代,收敛到一个好的结构,放到我们的代码库里。但我觉得这是一个可能还不错的方向,而且这个是新的,比刚才我们看的那种「我有 A 问题,然后我分成 A1 和 A2,然后我在 A2 里面做一个什么排列组合的东西」,看起来要有趣一些。
这节课的核心观点:你还是需要对上下文里的每样东西有概念
好,所以核心的观点,就是这节课的观点:我们今天还没有这种超能力的 AI,你还是需要有概念,知道上下文里面有什么,上下文里面的每一个东西都会对 AI 产生什么样的结果。那这唯一的途径就是:第一,你要对你玩的东西有概念,你还是得学处理器、操作系统、编译器、数据库,这里有很多设计的智慧,只是你学的方法会变好,你会用我们更高效的方法来学。然后你理解了上下文以后,我们会用一个学期来解释,怎么样用软件工程里面的各种各样的方法,去给出一个正确的指令,去监控 AI 运行,怎么样给 AI 好的、什么叫好的 verify。
今天我只是提到 OK,你要在上下文里面放进 verify。那什么是好的 verify,你们自己有自己的想法,我今天有一些比较松散的想法。今天我们还是需要把模型引导到你认为合适的位置上,但是这已经比我组一个工程团队要好——我要雇很多的人,然后我要和他们拉通对齐,这种互联网黑话。你不需要模型很聪明,它见过,你只需要用正确的提示词、简练的提示词放在这个上下文里面,让这个提示词一直能被看到,能把模型往那个方向引导就可以了。
一个 research failure:模型成了 reward hacker
然后我这有一个很好玩的故事,就是一个没有超能力 AI 的一个非常典型的案例。我们要做一个研究项目,然后这个研究项目大概是做一个符号执行引擎——你不需要知道这是什么,大概就是说我要写一个程序,可以分析一个程序,自动分析或者证明、验证一个不算很大的程序的正确性,这也是一种玩法。然后我们现在这个东西的执行效率比较低,所以我们希望提出一些比较有效的优化。当然这也是一个非常合理的、incremental 的、适合学生做的题目。
然后有一个原始的 idea,大概说我们用这样的方式可能可以有效。但有些东西是 open 的,比如说这个东西到底怎么做、什么东西有效,那可以写一个 research。然后这个同学很开心:有道理。他认为这个 idea 有道理,然后 GPT-5.6 也认为这个 idea 有道理,那确实,就看起来确实这个。然后他就开始跑,然后就跑了非常多的 token。然后按照那个同学的说法是,GPT 不断地在给我反馈,我无法验证他给我的反馈是正确还是不正确;当他给我 negative 的时候,他的 negative 我也不知道应该怎么样打破。
然后好,这个原汤化原食,这个就是 GPT-5.6 生成的。然后他的错误犯在这句话上,就是因为在上下文里面有一个地方没有做好,就把整个方向——就我刚才说,我们需要把模型引导到你认为合适的位置上,然后这个引导错了。因为模型成了一个 reward hacker:这里有一个需求,就是这个同学说「我想证明这个 A 方法是有用的」,GPT 看到了这句话,他就忘记了我们有一个更大的研究的 big picture。
我作为审稿人的时候,我是站在人类的立场上去评审这篇论文的。然后我们站在这个构建者的立场上,我们是没有办法这样反过来的,所以我们只能一点一点从小的开始做,证明这个方法有用。然后他做错了这一步,他太想要去证明这个 idea 有用了,是因为有这样的一句 prompt。然后他就构造了很多小的 benchmark,然后在小的 benchmark 上做一些微小的东西,试图去给一些正面或者负面的结论,但这些结论对这个是没有用的。
然后他这里面犯了一个方法上的错误,当然我们纠正了。就是我告诉我的 PhD student 说,我们应该从一些真实的工具和 state of the art 的算法开始,这个 benchmark 哪怕要跑几个小时、都 timeout,这个 timeout 里面产生的这个搜索路径对我们来说是非常有价值的,它揭示了什么样的程序结构对于现有的探索方法是不好的。然后这个还挺好玩的,一个 research failure。所以它告诉你今天人还是有用的,我们没有办法把所有的事情都 delegate 给 AI。
纠正 AI 还是放手让它跑:你的精力该花在哪
但是与此同时,我们工作的方式可能也要发生变化。就是第一,这些知识很重要,什么操作系统、编译器,如果你没有概念,你提不出那个正确的问题,找不到引导的正确的方向;你对人类的知识没有概念,你也很难去引导它解决一个 open 的 question。但我也发现,就在和 AI 合作的时候发生了变化。一般来说我发现,如果我盯着那个 Codex 的 session,然后它一旦跑偏我就立即纠正它,我可以更快地完成任务。就如果是一个比如说就实现一个什么东西,比如说我今天就要上交互系统干个什么事,如果我纠正它,只把我的领域知识注入进去,可能总时间会完成得更快;但是如果我浪费更多 token,给它更多时间,它一般来说也能完成。
这时候就有一个 trade-off。我到底是花我的精力——就是你的精力是有限的,你们每一个同学的精力都是有限的。每个同学都应该去思考,你要把你宝贵的那些精力放到怎么处理 AI 上。因为是这样,我自己发现这是我自己的问题:纠正 AI 是一个有多巴胺的事情,它给我一种什么感觉呢?虽然 AI 很强,我好像还是比 AI 有点用的,它会产生这种非常微妙的心理安慰。而不是我就一个 YOLO 模式,然后等我回来,我也不会看历史,它反正搞定了,我验收一下它真的搞定了,我就不会看了。
但这个可能在非常长的时间里面都会成为同学们工作的一个常态,你们只有把自己的时间留在这些收益最大的事情上。这个时间是不能省的:你有一个东西不知道,你想要把它弄知道,这是你几乎百分之百的时间,作为学生都要做的事情。剩下比如说这件事情你知道应该怎么干了,把它干好,AI 能把它干好,你就放手让它干。这就有一种——好像在 pre-AI 的时代,大家就已经说了,说不要假努力,你自我感动、自己感动自己的努力是没有用的,你要有效的努力。在 AI 时代,可能这个效果会差距拉得更大,在每一秒钟的假努力,如果有一个你的 peer 是在真努力,你都是损失非常非常大的。这是一个自己的小心得吧。
然后以及最后,你们也会发现,随着指令、上下文的增长,指令遵循的能力确实会下降的。但是现在模型有一个很有意思的特点:一般来讲最后的 prompt,就留在最后的那个指令,还是可以遵循的。到前面之后,尤其是到中间的时候,它上下文指令不遵循。所以干活还是很好用的,因为一般最后一个短窗口就是一个具体的任务,是你的 prompt:帮我把什么东西修了,它就真的还是可以把这个事情给做完。所以模型还是很值得使用的。
真正的 long horizon:搜索是困难的,验证是容易的
好,那就到最后。如果你真正想要做一个真正 long horizon 的、非常非常难的任务,它不是解决一个问题,而是在一个不确定性当中探索,那么 ChatGPT 其实就解决不了了。ChatGPT 能够解决的是一个有 verify、它确定的、一直往后就能解决的问题。但是如果你让它去解决一个「帮我考虑一下几种方案,哪个方案最好」,它很有可能会给你一个不是最好的方案。为什么呢?虽然模型已经被训练成舔狗了,它完成任务才有奖励,它不完成任务是没有奖励的,所以它会给你一个有限的方案。
就像 P 和 NP,大家都知道 P 和 NP,它有这两个 complexity class,甚至有更大的 complexity class,有一个整个的多项式的 hierarchy。NP 问题说,你想要验证一个解是比较容易的,是 P 的;但是你想要找到那个满足条件的解是很难的。要找一个 Hamiltonian path,给你一个路径,是不是 Hamiltonian,你很容易;但是你想要找到一个满足条件的 path,就是 NP-complete,它是一个完全问题,3SAT 这样的问题。就是因为这个搜索是困难的,这个验证是容易的,模型为了完成任务,它势必只能做有限的搜索。
这样想一想,有一种是你在考试场上花点时间,一定要把这个考试题写完,无论如何马上要交卷了,你写,你的分数就比不写要高,所以你一定要写点什么。这个时候你就可以用一个算力换智力的循环。
算力换智力:不要成为 AI 时代的「子涵」
我举一个例子。你们这一代人是子涵吗?就是你们有很多同学是子涵吗?没有?你们还没有。比你们再小五岁的那个班上,一个班上就有五个甚至十个子涵。然后现在子涵就少了,现在子涵少多了。然后你看 AI 给我的这个名字还是很好的,你看它知道要去《诗经》里面找一个,还是可以的。就是它的品位,肯定现在模型提升了,但是你还是不敢用的。为什么呢?因为你用 DeepSeek 起名字,别人也用 DeepSeek 起名字,然后你就会成为一个 AI 时代的子涵,因为大家都是用 DeepSeek 起名字,然后 DeepSeek 都起了同样的名字。
我的经验是,如果你真的需要一个非常 long horizon 的任务,你就需要一个系统性的调研过程。跟你们可能上什么科研实践课学的那一套就很像了:你们进入一个科研领域,第一件事情做什么?文献调研,穷尽解决和这个问题相关的所有的文献。这件事情,在 AI 想要快速完成任务的时候,AI 暂时还不会去花这个大代价。但是你可以做,名字是可以枚举的,你可以先选音,然后把不好的音给筛掉;选完音你再去用 subagent 一个一个去检查字。然后你可以给它,比如说你喜欢《诗经》,你可以选一个《诗经》里的词,那么你就实现了算力换智力。
你有个开放问题,一个非常大空间里面的开放问题。你如果直接让 AI 去用 next token prediction 的方式,它会给你一个它的偏见带来的这个东西。但是如果你可以把这个空间分割成很多个 disjoint 的部分,然后你的每一个部分就会变成一个有 verify 的、更明确的东西。你有更多的算力,你就有更大的智力。但这个有一个缺点:烧 token。
世界很大,人很小:墙里面是超人,墙外面是动物世界
所以这就涉及到我对人类社会的一个我觉得很有趣的理解,就是:世界很大,人很小。我们人类的品味,每一个人都是不一样的,因为我们带了太大的 bias,我们在不同的城市、不同的教育背景、生活圈长大的。Richard Sutton 有一个 Big World Hypothesis:我们就是学不到这个世界上面所有的东西。但就是因为我们带着这个 bias,才有这个一加一大于二,我们就是一个最大的群体智能,我们在一起实现智能。
但这会带来一个未来的危机,我不知道,我现在要把这个问题先抛出来:如果这种真正 long horizon、就真正有价值的问题,可以用算力换智力,会可能发生一个什么样残酷的现实呢?有一类人可以获得无限的计算资源,他就有无限的智力,他就可以砌一堵墙,这个墙里面的人有智力,是我的朋友;在墙外面的人是普通人。那么因为假设计算机、AI 模型比人类的智力更高效,那我们外面的人就变成动物园里的猴子,就是让他们自生自灭,他们也无论如何也不可能赶上里面人产生智力的这个速度。那么墙里面是超人,墙外面是动物世界。我们大家已经在 AI 的这个关键的奇点快要到的时候了,我们可能需要思考怎么样防止这件事情到来。
软件仓库管理(2):从 Git、Jujutsu 到 Agent 时代的版本管理
这堂课从 lab1 的“会成长的个人助手”讲起,回顾项目、文件系统与 traceability,讨论 Jujutsu 的 change 模型,并在多 agent 背景下重新思考版本控制、乐观/悲观并发和团队管理。
lab1、会成长的个人助手与软件工程的焦油坑
上周我们讲了一点版本管理,这周继续讲版本管理。我发布了第一个作业,按照之前的说法,这门课所有作业都是选做、自愿完成,最后当然会验收,因为最终也许学校会让我们提交成绩。大家会交一些链接,然后由我的机器人来评分。第一个 lab 算是比较时髦的需求,叫“会成长的个人助手”,现在对大家的要求不限,你们想怎么做就怎么做,最好还是可以 vibe code 一个。我今天应该会在课上留时间,自己来 vibe code 一个,然后告诉大家如果你直接让它 vibe code,会遇到什么样的小坑。
除了今天以外,这周日要调休周二的课,所以这周日还有课,会讲软件的架构,然后会把它连起来。这个例子是你们每个同学都会需要的,我自己也有,就是一个能够持续为你工作的个人助手。这些需求是供你参考的,如果你实现它,会有一点点成就感,比如你的学习数据库。这个学期你们可能会上一些专业必修课,像计算机网络或者数理概率之类比较困难的课,你可以把专业课资料放到个人数据库里;或者你参与研究组课题时,可以把论文相关文献、教科书整理成个人数据库。这是一种形式的数据库,不限。
你也可以把自己的个人信息放进去。我不知道大家是不是已经填表填得很烦了,每次填表都是给你一个 DOC 表格,要填姓名、学号、身份证号,有时候还有个人简历这个那个。我现在已经把这些事情都交给 agent 了,就是“好,把这个东西填了”。从以往到今年有一个非常大的效率进步。我记得疫情的时候我招过一个学生,研究生在我这里做了三年,做的就是自动填表系统,因为我们都觉得填表很烦。等到他毕业的时候,突然有了 ChatGPT,他很慌,觉得他做的事情马上就要被替代了。我说不要紧,你赶快答辩,然后就可以毕业了。这世界就是发展得这么快。
在个人数据库的基础上,你可以把数据库和你的 SMail 邮箱关联起来,如果有、也有可能的话——比如我两周以后会在半夜给大家发邮件,你的 Agent 可能会把邮件回给我,实现一个 always-on、在后台运行的东西。你们也许可以买一个很小的嵌入式设备,一两百块钱,就可以在宿舍里面持续跑你的 agent loop。用你的信息还可以办理 eHall 的事务,就像我上课的时候,这其实就是一句话,直接做一个类似 agent 的调用就可以。如果你们有兴趣,可以做一个手机端的联动,也许是一个网页,也许是别的,甚至是一个苹果 App 或者 Android App。你只要让 Agent 去做,它会自己去帮你下好所有需要的东西,然后把你要的东西做出来。
那为什么要布置这样的实验呢?看起来所有任务都在 AI 的能力范围之内,但如果你直接把这个实验要求交给 AI 去做——我今天会试,我没有试过直接这么干——它在一开始就会做出很多它认为看起来也合理的决定,但这些决定会让你项目的维护变得越来越困难,最后直到陷入焦油坑。所以我觉得每个同学都有必要体验一下软件工程里面的焦油坑。当你的 AI slop 开始不断消耗你的 token 的时候,你终于会有一天下定决心:好,我要把这个项目删掉从头重做,或者回退到某一个很早期的版本从头做。这可能是我们这个实验想告诉大家的事情。
我相信这个项目对每个同学来说都是有用的,这也是我设计实验的初衷。我可以提前预告一下,我的下一个 lab 会和这个 lab 有非常大的关联,而且你们暂时猜不到。你会发现,lab1 那些实验要求你都做好了,它成了一个非常非常舒适的个人助手,然后我给你加一个需求,这个需求和前面所有都是关联的,并且有一定冲突,你就要去改,越改越崩溃,会有这样一个体验。所以大家可以期待一下后面的 lab。如果你不做,比如现在 lab1 选择躺倒,或者就不管它让 AI 直接做,下一次再把这个做的直接删掉,那可能就没有这个软件工程的体验了。大概就是我想做这样一个 lab。
项目、文件系统与 traceability
我们回顾一下上节课讲的是软件的项目管理。我们说项目是各种文件的集合,你的 intent、一些设计文档、你的 readme 都在你的项目里。所以我不喜欢那种 AI 写出来的非常非常长的 readme。对我来说,readme 应该就写一下你在设计这个项目的时候最初的初衷,你想做一个什么样的产品,这个产品能达到什么样的效果,可能可以有两个 screenshots,以及你最重要的那些设计。打个比方,有一天你突然想了一个非常好的点子,你真的想用它去挣一个亿,然后你想挣一个亿的时候你要组建一个创业团队,你要说服你那个 computer science 里面的人,比如你要说服我出来干,你其实只需要一个比较短的 readme,这是你的 intent,你想做这个东西。剩下比如我有架构的能力、我有写代码的能力,我可以把剩下的东西给你补出来。
你不需要知道这个软件系统是怎么样、它的 specification 是什么、它用什么样的数据库、怎么样支持用户从 1 到 10 到 100 到 100 万到 1 亿。你可能没有这个 computer science 的知识,但是你不需要有,你只要有那个最原始的设计,那 intent。然后再到 spec,就有我刚才说的一些:我要选什么样的数据库,我甚至要选什么样的编程语言,我的项目结构是什么,等等,小到每个模块的接口,都属于 spec。然后再到 code implementation,它现在都在这个项目里。有这样的项目以后,很自然地你就需要一个容器,把这些各种各样的文件管理出来。我们说大家用文件系统来管理项目,其实真的就是图方便。
因为你回到 1950 年、1960 年的时候,操作系统给你的存储功能就只有文件,它没有别的。那个时候关系型数据库还没有发明出来,所以所有的程序员别无他法,只能把你的项目以文件的形式存进来。所以这更多时候是一个历史的原因,历史是不停地往后涨、往后涨导致的原因。我自己是觉得,文件系统可能不是组织项目的正确方式,因为文件系统其实丢失了很多文件不适合表达的信息。有些信息其实是比较容易表达的,比如我的 intent、spec,我一条文档说这个地方我是怎么想的;或者这个测试用例是什么,我输入是什么,输出是什么,然后我要 assert 在程序运行过程中什么东西不能被违反。但是你马上就会发现,有很多隐藏的关联在项目里面是没有的。
我举个例子。你经常会在 AI 写代码的时候,现在 agent 会稍微好一点,有时候也不会。你让它说,把这个实现的方法从比如 linked list 换成更高效的 tree 结构去优化性能,这是很常见的:你突然发现性能不够了,然后你让 AI 去做,AI 就啪啪啪给你代码改了。但是你没有意识到的是,你可能在其中的某一个文件里写了一个文档,在 markdown 里写了一个“我这个项目是用 linked list 管理的”。在项目当中,你的文档和代码、测试之间就形成了一个关联。你写文档的时候可能还没有代码,所以你先写了那个文档,随手一写“这是个 linked list”,但是一旦你的代码实现了,这个关联又没有强行联系上。这就是一个技术债 technical debt。
因为以后随着文档和代码不断向前推演,它们的不一致只会越来越多,不会越来越少,然后这个软件就陷入一个维护的泥潭。后面你拿出一段代码,甚至 AI 拿出一份代码,那到底是文档说的对还是代码说的对?你跟他聊天的那个历史没有了,你那个 chat 已经消失了,你换了一台电脑,你那个 git repo 里面可能还有一些 change 的日志,但是当你到最后的时候,你真的就不知道到底是代码对还是文档对。其实在软件工程里面,这是一个研究了非常非常长时间的问题,叫 traceability,可追踪性。你看这已经是 2014 年的时候他 review 的一些更老的文章,他就叫 software traceability。所谓的追踪,就是发现我们文件真的只是图保存在文件系统里方便,那么在一个软件项目当中,各种各样的测试用例、文档、哪怕中间一行代码,它们之间的关联是什么?我们能不能在软件系统的演进过程中仍然维持它?这其实是一个非常软件工程的问题。
所以很多时候我们觉得蛮有意思的,文件系统将错就错了。那我们大家最后总有一天会发现,你用文件系统来管理代码,你受不了了,打个压缩包发布,打压缩包发布,然后打压缩包发给你的合作者,这件事情已经维护不下去了。所以就有了 SVN,有了 git。那下一个是什么?大家有没有想过下一个是什么?我今天一会儿会讲 Jujutsu,它可能是另外一个,甚至比 git 要好一些、更好用的版本控制工具。但我同时也觉得,刚才说到 traceability,其实如果以后我们再也都不写代码了,那软件他就不应该像现在这样,在 git repo 里面管理。我们现在有好多文件,什么 readme.md,然后是什么 hello.c,还有一个什么 docs 下的文档。
其实如果所有的这些东西我们人都不几乎不管了,那我们有没有可能——我一直在想,就等这个未来到来——把这里面软件或者部件之间,也许我们还是需要文档,但我们的文档可能是好多个切片,就把它们变成好多的对象 object 或者 entity。比如说这个文档和这个实现之间就有一个关系,这个代码实现了这个文档;然后我可能有个测试用例,这个测试用例也有这个文档,那这个文档和测试用例又关联起来了;然后这个测试用例是和这个代码的这部分实现关联起来的,然后这个文档又和这个文档是关联起来的。你看这是一个完全结构化的东西,没有办法在文件系统里以一个树状的形式表达,这是一个随便连接的图,有 cycle 的图 graph。那这个东西比如说我可以把它放到一个数据库里,叫 repo.db。
那显然人类就没有办法用现在的 UNIX 世界里面的工具去维护这样的一个 repository 了。但不要紧,我们应该怎么解决呢?用魔法打败魔法。人类理解不了的东西,我们只要训练 AI,它只要有强化学习的反馈信号,我们就可以训练 AI 能够理解这个 repo。所以我们要干的是什么呢?这就是一个非常大的项目,什么 Linux 内核、数据库,或者你们想做的一个价值一个亿的产品。好,那每次你就说,好 AI。因为你也不太可能一下子就说我要把这个产品直接变成另外一个产品了。当你比如说这个软件系统已经在工作了,那你一般来说就是要给它增加一个功能、修改一个功能,这就跟 git 的 philosophy 很像。
git 管理了文件、目录、快照和快照之间的关系。你的 git 希望我们能够小步前进:我每做一件事情,我 fix 一个 bug,我改一个文档,我都产生一个 commit。然后这样我就可以在多个人之间协作,每个人可以自己产生自己的 commit,然后我们可以用像 rebase、merge、fast forward 这些,或者是普通的 merge,来创建一个可追踪的整个项目的修改历史。那如果我们以后也不要文件,也许还可以用文件系统来管理,那我们要的就是我们可以让 AI 自动维护所有的 artifacts、软件 artifacts 之间的一致性。这个 artifacts 之间一致性,就比如说代码和文档必须要一致:我文档说这是 linked list,我的实现里面必须是 linked list;我的 test case 测试用例,我文档里面说我要有 ABC 三个方面的测试,那我这个 test case 就必须要有 ABC,它都是关联起来的。
然后从 git 里面我们知道我们希望小步前进。那同样的,当我们谈要做一小步的时候,其实我们知道这一小步是干什么的,所以你可以 chat,你可以跟 AI chat 说,我现在发现它有个 bug,或者我做了一个产品,比如我今天对这个幻灯片样式不满意,那这就其实是一个小步。这个时候一个大型的软件系统里面绝大部分的内容跟这个小步都是没有关系的。比如当我说我对这个幻灯片的样式不感兴趣的时候,大家可以看我的 repo,看下顶层的结构,我有一些 assets,甚至还有 .cache 缓存的系统,然后有 P1P2P3P4P5,然后有一些脚本等等。那任何一个软件系统当你只做一小步的时候,你就只需要看到这个软件系统的一小部分,大部分东西应该是无关的。
好,那既然是无关的,我们就可以让 AI 根据我们的 chat 把和我相关的东西给摘出来。它可以知道,哦,我现在需要的是这个文件、这个 test case 和这个文档,其实其他的东西都不需要了。然后你想 AI 有什么能力?AI 有动态生成任何内容的能力。everything is code,content as code,code 可以表达任何东西。所以 AI 可以根据我的 chat 生成一个和我要改的这一小步相关的 view,一个视图。你看你们学到的各种系统里面的概念还是有用的,因为不是所有的同学都学过数据库。数据库里面有一个叫 view 的概念,它说我们的数据就在这了,但我可以创建一个不一样的查询,可以把它以另一个形式展示出来,这就是这个很简单的想法。
我希望把这些数据以另外一个形式展现出来。比如当我看到的时候,我看到的不是这些代码、文档、DF,而是一个可视化的看板,然后这个看板上面大概列出和我这个小步相关的一些东西,然后问我要改什么。我改完了以后,同样的还是从一个 V0 到 V1。它还是一个 repo.db,但是这一小步就会把这些相关联的东西,比如说我要增加一种类型的测试用例,那它就会在这些地方加一些测试用例,这个地方加一些测试用例,有些地方可能要改掉。它就从一致的状态,各种错综复杂的关系,做一次小步的修改,到下一个版本。然后这样我们就好像软件工程就又变了,彻底变了。
我觉得这是一个未来有可能发生的,就是在 AI 内部它是这样生产软件,然后从此以后我们也就再也不要看代码了。我觉得你们看到现在的这种现代 IDE,比如 cursor,我其实还蛮讨厌的,因为它现在打开默认就是 agent mode,然后 agent mode 就是原来的那个我们熟悉的 IDE 就没有了,强迫你用一个像 chat 的方式去跟它沟通。但因为我还是相对来说比较精通古法编程的,有时候我还是对代码有一些掌控欲,可能是不好的,可能是不对的,但是未来可能会是,这可能是一个途径。当然我觉得同学们肯定也会焦虑:如果未来这件事情真的有可能发生,而且可能一两年以后,大家就真的有一个公司把这东西做出来了,那我们学那么多东西还怎么办?
其实你发现,在我们思考、创造这些东西的过程当中,我们用的都是你们学习的这些经典、集大成的 computer science 课程。我刚才说的数据库里的视图,我用的是借用了数据库的概念,实体和关系;然后我借用了 git。这个模型没有变。git 长期的开发经验说,我们要一步一步向前;如果在这个模型底下我们是大步向前,那就惨了。当然也有可能发生,就是模型非常非常强,一个非常大型的项目,一步啪就变成下一个了。这个怎么做,我们现在还不知道,但至少在初期,我们可能还是要沿用这个过去的、大家积累的软件工程智慧,去小步向前,然后通往那个极乐的世界。但我还是相信这个极乐世界确实有可能是能到的。
所以这就引发了一个我觉得在大学里面蛮有意思的评论。这个同学应该是已经毕业了。因为我上节课说,我问同学们 git rebase,然后大家都不知道什么是 git rebase。他 raise 了一个 valid point,就是说学生时代大多数人是不会和他人共同合作的,也就是做作业,所以 rebase、merge 用的场景就很少。正确,这个 point 是对的。你们做作业的时候确实不会。然后我的回应大概是这样的:但是如果你真的去往外面的世界走一走,互联网开发者社区铺天盖地都是文档。所以你一定会有一天留意过别人的项目,会看到过叫 contributing.md,一般来说你马上就问这个。你们有没有同学问过?仓库根目录下的一份贡献者指南,通常回答我怎么参与这个项目。
你看它里面就有这些:项目的总体结构,然后分支、提交信息、PR 的规范和流程。如果你看过很多项目,比如随手打开看一眼,如果你发现里面东西都知道了,那你就知道了。如果你不知道,比如有一天你一定会看到一个项目里面说,你必须要在你自己的分支上 rebase,禁止你直接创建那种分叉的 merge,我们要保持线性历史,你就会知道 rebase 是干什么的。然后它现在是什么呢?现在我们的社会变得太快了,所以有一种学生其实——不是批评大家——因为你们上高中的时候就是这样的,在高中时候老师不教的就是你不需要会的,考试不考就是你不用学的。这是一个非常明显的、为了升学利益最大化的策略,这个是正确的,没有错。然后在你们到大学以后,如果你们还带着这个思维的话,你就会有这样的 gap,一种新人类和旧人类之间的 gap。
当然,我相信每一个同学也还是可以成为新人类的,你只需要有一些注意力,去注意到更多的东西。因为现在我有一个危机:AI 时代的斩杀线以下的价值就全部归零。当然我觉得现在斩杀线可能还斩不到各位 985 大学 computer science 专业的同学,但是可能比如说那些低级智力劳动,比如会计从业者。我好像看过一个报道说中国可能有 1000 万还是有多少会计从业者,那这个行业里面顶端的人肯定暂时还是没有威胁的,但是在底下的大量的人都面临被 agent 替代斩杀的危险。所以你们需要在这些经典的课程上,不管是什么操作系统、编译器,还是软件工程,这里面有些软件工程的智慧,你们学到一些经典的东西,然后最终形成你们自己的理解。这是一些评论和复习,复习 git。
从 Git 到 Jujutsu:把 change 变成 first-class citizen
那我们继续回到软件工程。不管是我畅想未来会发生什么,还是我们就是讲 git。git 管理的是快照,也就是一个目录,整个这样一个东西的快照。那我可以在快照上面做一个开发,它要求小步。那我现在有一两个开发者,一个是 A,一个是 B,我们就可以独立地在这两个上面做开发,然后这个东西是 commit。如果你要想把它合起来,那在 git 里面,在 git 的原始模型里面,它 rebase 相对来说是不那么提倡使用的。它是说,你会创建一个两路的 merge,它有两个 parent,继承了这两个的修改。但是这也带来一些问题,比如这个 merge 是由 A 来干的,那这个 merge 里面的一些内容,就由 B 写的这些内容,它的作者就变成 A 了,它其实有一个隐藏的作者修改。
比如 B 在这儿加了一行,在这个可能还能追溯到 B;如果发生冲突,在 resolve 的时候,那冲突完了以后,这个职责就是“这是由 A 完成的,这是 A 完成的”,那 B 改的代码最后 ownership 又归到了 A。所以这会带来一些相对来说有一点小麻烦,然后 rebase 解决了这个问题。rebase,我还是横过来画。如果我现在有两个历史,比如 A 同学改了两份,做了两次提交,得到两个快照,delta1、delta2。好,然后我这个时候另外一个同学开始改。那 rebase 做了一件什么事呢?rebase 说的是,比如 A 先改了文档,然后再加了测试;然后我 B 加了一个 feature,然后又加了一个测试。这个时候我不是直接把他们两个东西合并成一个把这两条线路并起来的提交,而是我试图先把这个 feature 试一试,有没有可能在这个 A 的后面把这个 feature 加上。如果加上了,OK,那就成功了。
但是这是有一个很大的风险的,就是因为我可能在这个里面加入了一个测试用例,然后这个测试用例和这个 feature 就是不兼容的,这是有可能的。或者甚至他们改了同一个地方,我就做了两个完全不兼容的东西,那这个时候他是有可能失败的。如果成功,这就比较好,那我再把 test 放过来;如果成功,那么他就相当于一路,也就是说这两条线上那边的修改完全没有任何冲突,我就可以一路把它合并过去,变成一个线性的历史。但是一旦发生冲突,这是非常危险的。为什么呢?一旦冲突你就需要 resolve,你会进入一个 rebase 的模式。你们如果进入过这个模式,会发现瞬间自己就不知道应该怎么办了。然后这时候你就打开你的 agent 说,哎,我现在发生了什么?然后 AI 会细心地解释给你。
更麻烦的是,你就算是把这个东西搬过来了,feature 搬过来了,你可能搬 test 的时候又要失败,然后你还需要再把 test 搬过来。而且每一次你都会创建一个新的提交,也就是说这是一个 commit,他有一个 id;这 commit 有 id,每个 commit 都有 id。那如果你是一个这样的两路 merge,那所有的这些 id 都保留了,你会创建一个新的 id。但是因为你是 rebase 的,rebase 每次这样的 rebase 就会创建一个新的 commit,因为他这个快照本来这个快照的 base 在这,这个 commit 的 base 在这。所谓的 rebase 你就知道,rebase 它的 base 要改掉,要把这个 base 改到这里来,rebase,那你会创建一个新的 id。每次会创建一个新的 id,然后这个历史就变了,然后你会感觉到这里有一点点轻微的不一致,觉得你好像不想完全要这样去管理软件。
因为我们关心的是修改,也就是我希望的是把这个修改搬过来。然后在 git 里面修改是用 commit 来替代的。说好,我现在有一个 commit,然后这个 commit 和他的 parent 之间的关系就叫一个修改,他没有一个专门的叫 change 的对象,所以它这用 commit 来代替修改。那可能绝大部分的情况都还能满足,但是如果我们把 change 也变成 first-class citizen,会发生什么呢?你就发明了这个下一代更好用的版本控制系统。也就是说你不仅可以有 commit 的 ID,甚至这个——它是和 git 底层完全兼容的,你可以直接把它当 git 用,然后它增加了一个叫做 change 的概念。
也就是每一个这样的东西,每一个这样的 change,它都可以对应一个 change,然后它可以把好多的 change,比如 rebase 的时候,他会把 change 直接把 change 搬过来。然后 change 还是有可能冲突,冲突是解决不了的。如果我在 rebase 的时候要冲突,这是解决不了的,但是因为它有这个 change 的 object,就 change 这个概念,所以他可以把这些 change 一路带下来,生成一个直接 rebase 到这里,但是里面引入了好多冲突。它还是运用了 git 的那个冲突记录,但你看它里面有一套自己的记录方式,把所有这些 change 的冲突也都塞进去,然后你可以在这里相当于把它们搬过来,然后在这里再 resolve。它的底层的模型没有变,但是你用起来会舒服很多。
然后它有一个很好的特点,它不需要 staging area。就是你在 git 里面最讨厌的是什么呢?我上操作系统课的时候会讲这个例子,最讨厌的是你现在有个工作区,你现在有个 repo,然后你改了,比如你有一个 hello.c,然后现在把它改了,改改改改到一半,然后这个时候项目还不能编译也不能运行,测试也通不过。你改到一半,突然导师给你打电话:哎呀,我现在有一个很高很高优先级的项目,这个你就有个 bug,马上客户就这个项目就要交不了差了,赶快把这个 bug 给我一分钟修了。好,那你怎么办?你不应该 commit,对吧?你需要的是你把它加到你的 index 里,然后 git stash,然后他会压榨压榨,然后这其实是一个比较讨厌的操作。
因为 stash 是一个东西,然后 stash 一个东西,你去把原来那个 bug 开始修。你刚修了一半,你的导师又给你打电话:哎呀你这个 bug 别修了,我还有一个优先级更高的 bug,你去把它修了。好,你这时候要再把它 stash,然后 git stash 是一个栈,然后它里面就会有好多个提交,然后你需要去管理那个栈。然后如果你想象,你在任何时候,也就是你随时随地都在编辑这个 repo,这不就是一个 change 吗?你现在把 hello.c 改一改,加一行减一行,它都是 change。
所以这个 Jujutsu 它好像是在干一件什么事呢?好像是你但凡改了一个字节,它都会立即做一个 git commit。当然这个 commit 不会产生好多这个线性的历史,而是先把上一次的那个——上一次肯定得给抹掉——然后它上一次的 commit 就,你改了一个字节,那上一次的那个提交就被抹掉了,然后你重新用当前这个修改去替代这个提交。也就是说你永远是把,因为现在有个 change,现在有个当前的 snapshot,它永远处于一个提交的状态,一个 change 提交的状态。那你就很舒服了,你连这个 staging area 也不需要了。
这就是一个非常好的,从我要把 change 作为 first-class citizen 开始,你再往后推,你就可以发明自己的版本管理系统。当然这个不是它的全部,然后它还有一些别的路线可以推导出来。但是你们适当的时候可以质疑一下,比如 git 有没有哪里做得不够好?如果可以做更好,还能做怎么样的设计?所以我觉得这是很有趣的一些非常有趣的设计。你们可以在 agent 的带领下,或者说我开了一个头,我给你们一些 motivation,你们可以把这些最重要的 prompt 交给 agent,说我要学 Jujutsu,然后我想从 change 开始理解。如果你看它的 Tutorial 的话,它其实也是一上来就会告诉你,它没有 staging area 等等。你管理这个版本是更自然,它对人类更友好,实际上它可能会对 agent 也稍微更友好一点。
所以我觉得为什么你们作为 AI native 的人类能够淘汰掉我们这一代人?就是因为我已经形成了 git 的肌肉记忆了,然后比我更老的一代人他已经形成 SVN 的肌肉记忆了。一旦人形成一个肌肉记忆以后,要把这个习惯掰过来就很困难了。比如我现在对 git 是有肌肉记忆的,但是我即便是能够理解这个 Jujutsu 的概念,甚至我可能理解得会比你们作为初学者还要更好一些,我还是可以从我的 first principle 去思考它,相当于 virtually reimplement 这个系统,但是我即便能够理解,我也没有足够的时间再去训练另外一个肌肉记忆。所以你们是反过来的,你们应该在现在还没有形成记忆的时候,就直接去用那个对的东西。人类历史都是积累的,SVN 实际上是实在用不下去了才用 git,然后 git 发现了缺点以后有了 Jujutsu。所以你们可以先肌肉记忆记住一个最正确的设计,然后再去理解这个设计是怎么样。
你再回去想的时候,哦,原来 git 这么蠢。在 git 出现的时候,大家会想,哦 git 这么先进,它是一个 persistent data structure。这是 SVN,SVN 从一开始就从纯 DAG 那条线走错了。到 git 的时候大家觉得很先进,然后到现在你再回去看的时候,哦,原来 git 也犯了一些设计上的错误。这当然是因为它最早的时候没有想过 rebase 会有这么大的 impact,它有 merge 就已经很好了,就能解决他手上的问题了,所以他可能没有把这个概念模型想得更加清楚。这是一些有意思的 comments,也是你们学习可以告诉你们一些学习的建议。你们在学东西的时候,尤其在 AI 的辅助下,可以不断地往前走、往前走、往前走。
Agent 时代的版本控制:人类假设、乐观并发与悲观锁
但这门课不是软件工程,这门课是生成式软件工程。所以我们要真正要做的事是在 agent 的时代再次重新设计版本管理系统。也就是说,你当然可以让这个 agent 去用 git,然后让 agent 去用 Jujutsu。那这对人类来说 Jujutsu 是一个很大的进步,但对 agent 来说可能进步相对会小一些,因为他好像很会 rebase 这些。那这个时候你看我们一路走过来,SVN 有局限,git 有局限,那在 agent 的时代,我们的局限和我们要破掉的改进是什么?刚才所有的 version control system 它都有一个假设,这个假设就是 version control system 的使用者是人类。人类有什么特点呢?人类是 single-threaded 的生物。
我一直上操作系统课的时候讲并发程序,然后并发程序我打什么比方呢?你就把线程想象成是一个人,然后你的线程可以访问共享内存。访问共享内存的时候,两个基本的操作是 load 和 store,或者说 read 和 write。我可以从一个变量里读一个值,我把 X 读到我的临时变量里,或者我可以写 X 等于一。然后这个操作是不能同时进行的,就是我不能说既读一个又写一个,你这样就需要一个原子指令了。然后绝大部分的 shared memory access,你能想象成人是什么呢?我如果想要观察这个世界,那我对不起,我手要抱在抱在抱在后面,我只能看。然后等我看完了,比如我看到这个同学在这,我想要对他施加一个效果,比如我想用这个粉笔导弹射击他。那当我想要真正要 write,就写一个变量的时候,我必须要把眼睛闭起来。
而我把眼睛闭起来扔的时候,注意我读到的那个值其实已经失效了,那个值已经是旧的值,就是我上一个时刻读的。那在上一个时刻到这个时刻之间,可能经历了很多的事情,可能我被换 CPU 换下去了,我被中断打断了,然后换了另外一个线程上来执行,然后执行了,可能执行了一秒钟以后我才恢复过来。但我恢复过来以后,我看到的、我在寄存器里面看到那个 X 值,我上次读 X 等于一,然后这个时候我就把那个粉笔扔出去了,那很有可能就扔的就是错的。这就是 single-threaded。然后当多线程的时候,多线程共享内存就带来了无穷的问题。
其实人类也是的,人和人之间是有这个无法逾越的物理屏障的。你不能理解我的思想,我也不能理解你的思想,我就是一个独立的个体,由分子把我很强地连在一起。所以我们只有一份 I/O 设备,我们就一双手、一张嘴、一对眼睛。然后我们在比如说编辑这个代码库的时候,以这种方式编辑代码库产生 change,那就是以小时为单位的。然后呢,一个软件公司可能比如负责一个模块的开发,可能多也就十几个人,已经是一个不小的团队了。那十几个人每个人以小时为单位开发,那最多这个也就是以 10 分钟这样的单位产生一个提交。
好,那 10 分钟为单位产生一个提交,意味着这个速度会比人和人之间沟通的速度要慢很多。因为尤其是一个 team,他工位都在一起,你们研究生的工位都在一起,你要写一个东西要一两个小时,你在写之前你肯定跟师兄说一声:哎,师兄我要干个这个行不行?十、二十秒钟、30 秒钟,师兄说行,你去改吧,我们现在不在改。或者你们如果去厂里面打工了,厂里面可能每天早上都要开会,每天早上的 leader 都会把任务分享出来,今天我们要干什么事情,然后你做什么你就提前在什么半小时的时间里你就划分了一个 scope,你就把这个项目切开了,你领了其中一部分走,然后你就去产生垂直。当然这是古法,古法编程。
那在古法编程的时代,你就看到比如 stash 是一个合理的、是一个合理的设计。因为刚才说的那个情况,你导师给你打电话,这件事情是可能发生的,但是在你修到一半的时候导师再给你打电话,这件事情是不太可能发生的,发生概率是很低的。绝大部分时候你的这个 stash,也就是 stack 里面就一层,如果你有好多层,你关起来其实挺麻烦的。还有一点麻烦,你就需要去 rebase stash,所以你尽可能地保证这个 stash 不要太深。因为你可以拒绝一个工作,或者你会说我手上有一个更高优先级的工作,我现在对不起我不能干这个事,然后等你干完了以后,你自己可以排一个队列。所以 git 是给人用的。
但是在 agent 的时代这件事情变了。如果是一个 agent team、单个 agent,它首先生产这个 AI slop 的速度就远远快于人,它生成 AI slop 就以分钟级为单位甚至更快。它有时候你让它 fix 一个东西,它在上下文是热的时候可能就极、几十秒钟;讲 DPC V4 flash,他干活的时候就几秒钟就做了,那以秒级。当 agent 工作时候以秒级可以产生一个原子提交。并且你可以轻易地得到一个十倍、100 倍的 agent team。git 或者 Jujutsu 是这样的,它举起来是跟 git 兼容的,这样的一个为人设计的模型就有点跟不上,确实是有一点跟不上这个速度。就算是可能大型的项目它有一些门控,就是当我写完了以后,改完这个部分以后,他必须要经历一个测试的流水线,那这个测试可能本身就要运行几分钟,那可以放缓 agent 的提交的速度,但是毫无疑问的,agent 加速了这个软件开发的过程。
那如果 agent 加速了软件开发的过程,我们应该怎么样为 agent 设计这个版本管理的系统?那首先我们可以让 agent 沿用人的模型。每个 agent,你就想想每个 agent 都有一个自己的开发计算机,那你就把 single-threaded 的人类和 agent 做一个严格一一对应的类比。你就让它有一个独立的电脑,可能是个容器,然后你就把它放到 git.nju.edu.cn 上,然后你就直接 instruct agent 说,好,我的这个 remote 仓库是在 git 的,你现在就是一个单人开发者,然后你现在可以从 tmux 里面收到命令,然后如果你收到命令就可以开干。所以这件事情其实是非常容易实现的。
比如 fancy project,好,我可以这样,这样也可以。好,我就直接让 A 正在开干,然后我现在就是——就 A 这个时代有点什么好呢?每个人都是领导,你就是这个领导。好,然后现在有两个 tmux session,你可以想象,就是我实际在你们实际开发的时候,这两个 tmux session 都是位于两台不同的机器或者不同的容器,它物理上隔离开的,它看不到世界上其他的人。还故意用一个这个可能在中国现在还能用的词,对吧,Master,然后我现在就是,就是 master。哎,你看你这个名字改过来了对吧,Slave。好好好,我可以给他每一个都起一个 pane,哎,好,coding agent,好,那我可以让他干什么呢?
Okay, anyway, it doesn’t matter。啊,你看他们两个就开始工作了。好,他们啊,这个 A,对吧,我让他,所以你看,当 AI agent 变快的时候,这个——他开始写 Slave 的。当 AI 开始变快的时候,你想一想,如果你的指挥发生一点点差错,或者哪怕甚至指挥——就现在一个流行的 paradigm,就是你有一个监工的 agent,然后你有好多个子 agent。有些比如为了最大化模型的智力,防止 AI 生成你不想要的东西,你会放两个比较强的模型,比如由这个 codex 来设计,然后让 Claude 来 review,然后让他们互相打架,然后达成一致。你只要开两个 tmux 就行了。然后你的主 agent 就负责协调,观看他们的输出,或者总结报告,然后往里面 inject prompt。然后这一个多 agent 就转起来了。
然后我看一下这个 repo status。哎,然后你就发现,如果大家工作在同一个 git 仓库上,就会有一些麻烦,但如果他们工作在不同的 repo 上,就完全可以的。他们可以合,可以去远端拉取别人的提交,然后冲突了,如果冲突了就会自己修复,然后在必要的时候,他甚至还可以给这个 master agent 发消息。你们立即就可以构建出一个这样的多 agent 的系统。那么相应的,我们今天也有一些 workaround,比如 git 就有 worktree 这样的功能。所以你看,我可以问他,我的老师说,git worktree 可以改进这样并行的工作方式,那么能让这两个 tmux sessions 都工作在 worktree 吗?
提问其实是关键的。你们可以在各种信息,你们甚至比如今天早上走到教室的时候在看微信公众号,然后微信公众号上面说,来也 get it worktree,或者他提了一个什么样的 sandbox container,你就可以立即让 agent 来帮你试一试。然后我来看一看他在做什么。诶,这不是一个相对来说比较容易的任务吗?OK,他确实是在写脚本。哎,你看他把 pane 退出来了。哦,应该 OK 了。这是当前的 git worktree。我们的 git 允许我们创建一个工作树,然后这个工作树就是一个,你可以想象成连接到了一个 remote,就是一个 git repo,然后我可以在这里面做独立的提交,在一个隔离的环境里面做独立的提交。
然后大家都知道 .git 是什么,.git 是一个目录,对吧,比如我现在,我的 JS1,大家都知道我的 .git 是一个目录,然后它里面有一些,比如 logs,还有 objects,三种类型的对象。那 worktree 里面的 .git 是什么呢?点开就是一个普通的文件,是一个文本文件。然后你如果继续追问 agent 的话,agent 会告诉你,这个也是我才知道的,就是除了 .git 除了是可以是一个目录以外,它还可以是一个这样的、只有这个格式的文件,然后这个文件说 GITDIR,然后你看它指向了一个 worktree。然后这个是什么呢?这就是文件系统里面的一个文件。
所以你看到,git 最大程度上复用了操作系统里面的机制,这个 tree 就是一个这样的,它是一个小型的 git 目录,它是一个小型的 .git,它依然有 HEAD、index、locks 这些,一个微型的 git 的目录,但是它还是依附于原有的那个 git 目录而生的这样一个特性。这个特性就在 AI 时代突然间变得好像有点用。我们可以在提交直接在本地互相可见,不需要互相的 pull 和 push。就如果你像刚才我说的,我开了好几台虚拟机,然后在每台虚拟机里面放一个 agent,然后我直接往那个 tmux 里面注入,那当然是可以的,这是完全可以完成的,但是 worktree 提供了一个更轻量的合作的方式。但是如果有比如有冲突的修改,它仍然需要 merge 和 rebase 才能合并,这样性能会更高一点。
好,那我们回到这个 agent team。如果我们有 agent team 或者 agent swarm,除了今天的 workaround,比如 worktree 这样的机制以外,还要回头来想为什么我们需要版本控制,为什么需要版本管理。也就是说我们人类的限制,就是人类对这个,就我刚才说 10 分钟,一个小的 team,哪怕是一个团队都要 10 分钟产生一个 commit,所以是因为人的限制塑造了这个工具。人之间的这个 communication,导致了你每天早上都要开一次会议同步一下各个人的进展,然后再把这个任务给分下去。而且就算是人做了这样的沟通,你也都知道,你有的时候你想,就你的室友向你请教一个数学问题,明明你会做,你就怎么都没办法给他讲明白,除非你是一个很好的老师,能够共情别人,能站在别人的角度去思考。沟通的成本就是很高的。
所以也是大厂和小厂,尤其是初创公司、初创团队为什么效率可以更高。就是当软件系统大了以后,人多了以后,他沟通的效率变低了以后,它产出的速度也就降下来了。所以可能你一个 100 个人的团队,他不可能达到一个十人团队效率的十倍,可能有两倍三倍就已经挺不错了,所以团队没有办法变大。所以我们需要 git 的各种各样的 best practice、快照,然后小步地前进。你改一个 title 也要做一个 commit,然后你做一个什么也要一个 commit,然后都可以。然后这些小的 commit 每个都可以追溯,因为一旦出问题了以后你需要回溯到那个人做事 commit 那个时间,然后那个人是有记忆的开发者会回想起来,哦那天我们当天早上开了一个会,然后我收到要做这个需求,然后做这个需求时候沟通发生了一个问题,大家找到那个时候的沟通记录文档,然后再说啊我们这个地方应该怎么修正。
所以你看有了 git blame 这些专门为人来设计的特性。但是你想一想,如果是由 agent team 或者 agent swarm 来实现你的项目,我们还需要 git blame 吗?就是因为人可以 blame,你这个事情我 blame 你多了你就该被开掉了,实习生你就背锅了。但如果 agent 没有坐牢的、被开除的能力的时候,这个模型就变了。我们再也不需要 blame 了,我们的项目只要前进就可以了。然后我们前进有的时候也不需要知道到底是哪一个提交引入这个错误,我只要知道这个地方错了,我也不要再管他历史上是怎么造成这个错误的,我只要把这个错误修了就行了。因为 agent 之间的 communication,而且尤其现在更好玩的是,我们的每个 agent 都是相同的模型,这也就是说它们是完全相同的克隆体,同一个事情让他们来做,他们大概率会收敛到相同的结果。
虽然模型还是有不确定性,还是会收敛到相同的结果,同样的 prompt 做同一件事可能有点细微的差别,但是最终的那个方向性的东西可能收敛到相同的结果。所以我们再也不要 blame 了。那我们的 git 永远就是一个,或者说我们的项目就永远就是一个向前进的过程。每次——我现在就快照肯定还是需要的,因为比如说这个方向走错了,你如果没有保留历史上的某一个 snapshot,那一下子往前走了好多以后再回去,可能它的代价比你一点一点再往前修要好。所以保留快照肯定还是需要的。那我们就是不断地大步向前,也几乎永远不要看过去。git 的 committer 里面只是留了一些 lessons,或者我们这个项目到底经历了什么样的演化,然后不停地就往前走、往前走,合并、合并、合并。所以历史的价值可以从“谁写错了”变成“我怎么把它修好”,我只要能知道我从现在开始怎么样能够往前进,这件事情就可以了。
所以你再想,这个时候你在计算机学科学到的那些知识又起作用了。我又回到讲操作系统的时候讲并发,那数据库,就是现在的这个 git 的模型更像是数据库里面的并发控制。比如我们有两条 SQL,然后这个 SQL 它都是一个 transaction,它是一个很大的 SQL,都是一个很大的,在中间可以混任何代码。然后你可以先比如你先一个 select,select 一个什么,你先读一下,那我这里也可以 select,然后那我先读完以后我可以做代码的计算,比如我就是 Python 代码或 JavaScript 代码,然后这个代码是图灵完备的,你永远不知道他会根据我看到的结果算出什么,我甚至也可以从外面,比如根据我当前的时间再算一个什么东西。然后算完以后我再 update。
Update,然后 update 的时候就会产生冲突。这也就是说这个就可能产生死锁,也可能产生冲突,就是说这个 update 可能——如果这个 update 读它,这个 update 要读它,那就会产生一个循环依赖,有可能是产生这种数据的依赖。也可能是产生,就是我我要锁你的对象,你要锁我的对象,产生死锁。然后这个时候就会导致这个 transaction 的 abort。可能比如这个 transaction 说,这个就不能进行下去了,对不起,因为我检测到一个循环的依赖,所以对不起,我这个就不能做了,我要回滚,相当于他要 roll back。然后其实 git 的模型有一点像这种乐观的并发控制,optimistic。为什么他是乐观的呢?他就说我假设这两个 transaction 不会冲突,我在执行的时候我会假设它们是不会冲突。
这个假设在数据库系统当中大概是成立的。为什么呢?我们想想教务系统,你们每一个同学他读的都是,你读写的都是你自己那块数据。你就说,我选课选的是我的课,我查看课表查看的是我的课表,所以这一个全局的冲突是不太可能有的。我两个同学哪怕同时开始选课,我也是读我的写我的,这两个就可以,就乐观的,git 也是这样。就说我假设这个,我们这样开发,我们已经事先开过会了,今天早上开过会了,所以我可以顺着这条路先做这个,另一个人做这个,然后我合并的时候大概率不会产生冲突,不管是 merge 也好还是 rebase 也好,这个都能顺利的完成。但是,但是啊,这就是古法编程时代的,就像我用 SQL 来做一个比方。
但是如果我们 agent 快了 1000 倍,对吧?Agent 快了 1000 倍,那如果我们的这个主进程又没有可能协调的时候,就一下子又分了好多任务出去,他们就真的有可能会更频繁地触发这个 transaction 的 abort。然后触发这种 abort 在 AI 时代也不是什么大问题,就是 agent 反正出错了或者出了 conflict 他就修,他能修好。但是是不是在 agent 的时代,我们可以换一种并发控制的方式?你想想我们在操作系统里面,这是在数据库里面做的,我们在操作系统里面学的并发控制,你们是用什么做并发控制啊?上锁,锁和这个数据库里面的 transaction 是有点不一样的,你是 lock 一个。我上课时经常说一把大锁保平安,做实验你们就知道。
当我想要访问资源,可能冲突的资源的时候,我会先把它锁住,说我先要跨了一把锁,然后这把锁一旦跨时候,别人想要再 lock 的时候,那对不起,这个上锁的就必须要等到,等到快以后,一个 release 的快的关系建立起来,就必须是先一后二,而不是这种 optimistic 的大家先往前跑,跑了再说。我必须是我先要知道,我必须在使用这个资源之前,我要 declare 对这个资源的使用,用上锁的方式。所以你看,只有你在操作系统课、数据库课真的把这些东西学会了,你回来想的时候,软件工程里面这些概念又再做一次。所以想想现在如果 agent 他的动作非常快,我们其实应该用更加偏向这种悲观的并发控制。
也就是说我今天要做什么事,或者我有个主 agent 我规划,然后规划完了以后,我就立马把这个项目的某一个部分给锁上。我可以把这个 git,就是说我应该在一个 git repo 或者一个快照上,我支持一个锁的操作,把这些 scope 说好,我现在锁住了,我现在做一件事需要这些,他先上锁,然后上锁以后做,做完以后 commit。然后另外一边也可以上锁,但是如果一旦他想要干的事情被另外一个人锁上了,他就必须等到这个锁前面人释放了,他才能再上锁。这样不就几乎避免了所有的冲突嘛,对吧?比如打个比方,我可以在文件级上锁,我就避免了两个 agent 它会同时写同一个测试,往同一个比如测试或者一个配置里面做更改。然后但是又因为 agent 数量足够多,这个速度足够快,我还是可以保证整体项目的进度可以往前推,因为也许还有别的 agent 可以继续运行。
这也就跟操作系统里面我们其实实现并发程序,我经常说,如果你一把大锁保平安了,或者你哪怕是锁拆拆桥了,我们互斥锁它的本质是不要并行,不要并发,他和这个多处理器并行是有根本矛盾的。他不就要把一个并行能并发的程序退回一个串行程序?但是当这个系统里面有好多独立的、不冲突的线程在运行的时候,我即便每一个线程都持有了一把锁,他还是可以继续高效的。即便是悲观的,他依然是可以是高效的。就是这些知识关联起来让你看清楚可能,我觉得在 agent 的时代我们又可以重新设计一个版本控制的系统。
所以我觉得这就在 agent 时代软件工程肯定是要发生很大的变化的。你从这 first principle 出发,我们要管理快照,我们要管理版本,我们要管理 change,我们还要管理飞快的 change。那答案是什么?可能 git 可能是一条线,如果我们用一种像这样的方式来管理软件,那可能又是另外一条线。所以你发现,学操作系统、学数据库这些东西有用。你还是虽然痛苦,你怀疑在 AI 时代我学这种东西,AI 都会写数据库了,这个是 SQL 查询,我早就早就不写任何 SQL 查询了。那我们还有没有可能,学数据库是有用的?答案是有用的,你还是去好好学习。当然数据库里面也有这种 advisory lock 这样的机制。这也是为什么我喜欢上课的原因。
就是我如果我不来上操作系统课,我不上这个生成式软件工程课,可能就不会让我去,我在备课的时候会把这些事情在脑袋里面再过一遍,是不是地球上所有人都错了?因为我要讲给你们的时候,我必须要共情我的听众,要考虑到你们是一个只能接受这个比较平实、朴实逻辑的听众,不能直接就说,哎,这个操作系统是怎么设计的等等的。我必须要把那个最顶层的 idea 提炼出来,然后试图让大家共情。当然这可能我做的也不够好,你们可以在 AI 的帮助下再来重新理解一下。OK,好,版本控制,那版本管理基本上其实我就讲完了,从历史动机到过去 git 讲到现在,这次再讲到未来可能发生的事情。
团队管理、汉诺塔演示与 MVP
但是版本控制本质上还是一个工具,不管是不是哪怕是我刚才那种比如基于乐观或者悲观锁的版本控制,真正困难的实际上是在进度,也就是团队的管理。我们为什么现在觉得还需要软件工程?就是因为我们现在没有办法做到一句话就直出一个高质量的产品。如果斩杀线到了,老板有一个想法就说,我要做一个 agent 用的微信,我随口说一说,或者说做一个 AI 时代的微信,然后这个微信是一个社交爆款,然后以后这些人社交可能也就不要用微信了,就用。当然你可能有个更具体的想法,比如为什么你有一些思考,然后如果这时候 AI 直接就能出那个微信级的产品,那软件工程就已经死了,那我们就都死了。但是现在这个斩杀线暂时还没有到,所以至少没有到这个团队管理这一层面。
所以我们可能还要学,但是我觉得可能也许有一天真的我也要被拆掉。因为摩尔定律本质上也是用 scale log,它现在还是成立的,模型肯定会更强更小更快,然后也许算力就有一天能够唤出我们的智能。但今天至少 AI 还不知道怎么做团队的管理,因为每当你多一个人的时候,这个沟通的关系都是平方级的上升的。因此现在软件工程团队里面最难招的其实不是码农,是那个能把任务分解的那个 team leader。也就最难招的是那种能把那种国民级的应用,他说我要去造一个微信,然后那个微信他还能够分出,我就一个 100 人的团队能分出十个小团队,然后十个人、十个 team leader 干,最后加起来我拼起来还能成为一个团队。这个人最稀缺。
然后接下来那个能够带领小团队的人其实也很稀缺。《人月神话》里面,那里面就是人与人之间两两之间就是平方级的沟通关系。然后就算是你能把你份内的事情做好,就因为这个平方级的沟通关系,别人也在做他的事,你不百分之百知道别人在做什么事的时候,或者你对这个整体的团队了解不是很好的时候,你还是就这样最后就会各种冲突。然后你写的测试我过不了,就经常会发生这样的事情。那么如果我们想要管一个团队的话,或者说你们在今天在想要在毕业的时候能够具有一定的团队管理能力,你现在就可以当成是那个主 agent 的。就像我刚才那样,当然刚才我给了一个不好的示范,我说我今天创了两个 Slave,你现在也可以立即拥有这个 DPC V4 的 Slave,然后你可以去想,如果你要尽可能最大化两个或者三个或者更多 agent,你应该怎么把这个任务给分解。
然后你应该怎么样确定一个提交的策略,什么时候你的 agent 应该提交,什么时候他应该合并。然后你就可以把刚才我们讲的所有版本控制的概念带到你的开发流程里。所以重要是这句话:版本控制是工具。它是由蒸馏了人类过去软件工程很多年的经验得到的,是我们要人类要这样开发,然后在这个工具上你可以开发出属于你自己的软件工程的直觉和经验来做。然后这里面就有好多比如学东西还是有用的一些意外。除了比如我可能后面再讲怎么来做这个软件管理。最近我看到一个蛮有意思的题目,大概就是这样的,我又带了模型,但淘宝好脑游很喜欢的淘宝好友好多游,作为一个面试题,这好像是应该是因为我不知道为什么就在网上刷到了,一般是小学生考级的题目,汉诺塔都会的。
会,现在有个额外的要求,就是你的所有的盘子的移动只能往一个方向。比如我现在简单一点,两个盘子,我现在移过来这个方向移是可以的,这个方向移是可以的,这个方向移也是可以,就是我要往只能往一个方向移动。当然折过来就折回去,我不允许的。就是你三个盘子的时候你就能看出,哦,三个盘子的时候就可以看出问题了。如果我要移三个盘子的话,我希望的是先把两个移到中间,假设两个已经移到中间了,那这时候我下一个就是把这个盘子从这里移到这里就对了,这是汉诺塔的正常的解法。但是在这个限制下,你是做不到的。在这个限制下,你不能把它移到这儿,就你只能往一个方向,比如往那个方向移动。
好,你很难想象小学生要做这样的题,然后他要输出这个方案。想想如果现在是你们就在保研的这个机制,保研机制就是小学生难度嘛。这个你就碰到这个题了,你要输出方案或者输出这个数字。反正数也不是很大,但是你确实需要一个方案,需要理解这个问题。那当然我讲的不是这个汉诺塔的故事。这个汉诺塔的故事是,你递归的时候,你现在要把它移过来,你为了移过来,你只能,你还是先还是一样的,你先要把它移到这个,你需要的是先把它移到这里,然后让这个最大的盘子向右移一个,然后再把它移到这里,再让向右的盘子移到这,再把它移到这里。所以就相当于是你考虑最大的盘子,那个最优解,他考虑最大的盘子总是向右移一个再向右移一个。好,那当然我这个拆开来讲是因为我能用这个吗?在这,我准备了一个小例子。
我准备这个例子,我写了一个这样的程序。好,没问题,他就是我刚才说的那个。如果我的下一个目标不是目标的话,我只有最大的盘子,我总是向右移一个,再向右移一个,所以它是最优的。就是我最大的盘子的那个移动是最少的,就是这是不可避免的。然后剩下的也就基于 inductive,就归纳法,我这个也是最优的。有点难,你很难想象小学生那样可以做这样的题。但这不重要。是,我们现在要推出这个产品,比如我现在要把这个东西卖给小学生,那我怎么样才能最好地把这个产品卖给小学生呢?当然是提供付费的增值服务。
比如这个小学生在这个,他可以在这里写代码,我这个代码是可以随时编辑的,比如我可以再写一个,他就错了。我的可视化是随代码来运动的,比如错了,他就,我就可以找到,比如找到我这条语句试图把一个蓝色的移到,就小的移到大的上,然后同学们就能找到 bug。那如果我要挣钱的话,我要提供增值服务。现在还只是一个纯粹的你,你可以这个 playground,然后你可以写代码,然后可以运行它。然后到这个时候我就要提供一个 AI 助教,比如这个小朋友。当然这个是不是不太好啊?这个开玩笑的。这个,我们还是尽可能以一个便宜的成本提供给小朋友们,当你实在做不下去的时候,你就可以点一个这个叫“我要助教”,然后这个时候 DeepSeek 的 token 就开始燃烧,然后这样我可以收取比你比这个 DeepSeek 原价更贵的 token 费,然后记在你的账户上。
然后因为我的 prompt 是我独门的,我作为一个有经验的老教师,我可以把我自己蒸馏成 prompt,然后我可以敏锐地知道这个小朋友的问题在哪里,然后给他提供一个个性化的指导。好,我就这个时候我就涉及到要做一个信息系统了。那做这个信息系统,因为我有,我让学生来做这个事,我自己没有时间,我竟然有这么大的这个市场的钱,我竟然都不想挣这个。然后学生,学生在设计,然后我看了那个学生和 AI 聊完那一版设计,然后我也让那个 GPT 来,就是大概梳理了一下需求。好,这个是 GPT 的原话,他说叫一个 API 应用,一个关系数据库,一个对象存储,然后需要异步点评,时再加后台任务进程,数据库关系和记录,大课程包与完整轨迹存放对象存储,任务量小时数据库任务表就够用。
这是一个非常正常的,就是如果你们上软件工程课,那太正常了。你看 GPT 嘛,它是一个语言模型,但我不喜欢这个设计。为什么呢?因为我不知道你们有没有用过 Overleaf,就跟别人协作过文档,然后 Overleaf 是有一个功能的,它可以把它当成是一个 git repo 导出。然后你可以直接,这个时候我最喜欢这个功能,是因为我要给学生改论文。然后给学生改论文,我就可以直接 agent 启动,就我可以把那个论文克隆到本地,我就不需要忍受那个网页界面了。然后我就可以打开我的 codex,然后让 codex 去帮我,帮我直接起 sub-agent,给每一个 tex 文件都去修复里面的这个语法错误。我可以给一个这个我很长的 prompt,让它遵循,质量论文就改好了,我说学生好,文化结束了,这个改也已经改好了。
然后我们说这个 playground,我可以,我难道不是应该,我在我的学生每改一次,比如,OK,我不知道这样行不行啊,可以,这个我难道不是学生每改一次?比如我现在 idle 了,或者我点运行了,或者我切换了,我就自动做一个 git 提交嘛。然后我就可以把 AI 的点评,比如当我申请 AI 助教的时候,他转圈圈,我就可以在这个 git 提交上面产生一个分叉,这是一个 comment。GPT 没有算的是,我产生一个这个东西的成本远远大于把剩下的东西 persist 下来的成本,就这个成本是忽略不计的。而当我用这个数据库的时候,我确实得到了一些好处,比如总体压缩的可能更小了,性能更高了。但是这其实不是我想要的,因为我的本质,我的需求是版本管理。
所以我甚至就在最早期的时候,我可以把我的代码库和这个东西连在一起,连在一起的来管理,就把它放在我的代码库里。然后直到我,就是我的代码库,就我的小朋友的代码就直接存在我的代码库里,然后我可以把探索性的在这上面创建 git 的历史记录。然后直到我认为整个的比如文件目录的格式、commit message 的格式,然后我怎么样使用这些文件都收敛了,然后我再把它迁移到数据库,这都是可以的。我觉得这个就还是挺好玩的。你们学任何东西都是有用的,你学 git,是有用的,你学这个 agent 的管理,你会啊,你就能够有一个机会去想到一个和别人不一样的、不太一样的设计。
现场 vibe code lab1:AI 直接开干会得到什么
好,那么接下来就是我们真的可以干一件事了。项目的早期阶段你肯定不能用这个 multi-agent。项目的早期阶段,你就是包工头,你先找一个,应该找一个相对来说能力比较强、你能够用到的最强的 agent,然后你可以跟他多轮的对话。所以我要告诉大家,你们现在开始干就比什么都要好。我决定现在就开始直接打开课程作业。好,我不知道会发生什么,完全没有事先准备过。我还是要不开,开两个。好,我们有两种办法,第一种是直接,然后直接回车。这个其实我觉得对于每个同学来说,你们都可以体验体验一下。当然我今天来替大家体验。我觉得更好的方式是——
我们来观察一下它,它会做什么。就是有时候 AI 的直觉和人的直觉就有点不太一样。像如果你用一线模型,它会进入一个 plan mode,它会跟你再确认,可能会再确认一些事实,然后他再去会生成一个 workflow。然后如果你们看过比较,其实挺好玩的,比如像 Claude Code,当它需要执行一个比较复杂的任务的时候,它会生成一段 JavaScript,一段 TypeScript,然后这个 TypeScript 里面有一个函数叫 agent,然后这个 agent 它里面是一个自然语言。当他用这种方式来组织 workflow,他甚至可以有循环,就是说我有好多任务,然后他可以在循环上面来解决这个 workflow。然后你看到他这就直接开干了吗?
用魔法对我,只能用魔法打败魔法,这个 300 token per second 实在是太恐怖了。好,OK,不管了,先让他搞一会。然后你发现这就是我前面说的,上下文工程或者提示词工程里面最危险的一点:你提示词里面的每一句、每一个词,self attention 都会看到它。然后看到他的时候,因为 AI 现在已经被训练成了 prompt 的舔狗,所以它有一种无可、就是没有办法往外拉的倾向,需要遵循你的指令。所以当你把这个需求,尤其是这可能是一个讨论稿的需求,像我这是一个比较开放的需求丢给他的时候,他会过度遵循这个指令,最终导致它会生成一个确实满足了指令,但是其实不是你想要的。就是你回头看那个实验的要求,这个实验要求其实是言外之意,其实是这一句话最重要:实现一个持续为你工作的个人助手。这句话最重要,其他的话相对来说就没有那么重要,这些只是一些解读。
你作为一个好的、为你工作的个人助手,你觉得 12345 是比较重要的。那你们觉得,当然这每个人都有自己的观点,对你们来说,如果你想实现一个你的个人助手,什么最重要?就你实现的第一个 feature 是什么?你想实现什么?最大的就是你在可能一个月以内遇到的最大的痛点问题:学习。高效的学习和复习,因为你们要卷成绩,所以这毫无疑问是最重要的一个学习排版方案。你最好是这 AI agent 能够自动从外网上面收集数据,然后把它以一个层次化的方式归档,然后最终并且给我一个比较好的界面,这是你最想要的。然后你看,我估计他十有八九没有,我来看一下。我要问他,其实我想要的是一个比较好的学习助手,能收集外部信息,除了设计外部信息,然后综合总结,根据我的进度学习,看哪部分能实现我这个功能。
读一读,然后他在做 database,你看他在做 database。可以可以这样说,就是在没有你精心设想过、讨论过的这个时候,AI 基本上就只会生成 slop,就只会生成 spec。你看他,SMail,没有。也就是说其实你要的,就你脑袋里所想的那个东西,这就是我说的你的 intent,你的 specification,你的 intention 是有巨大 gap 的。然后但是当你一旦这个模型看到说你要实现这个的时候,他就真的是真的去实现它了。然后我觉得肯定是做了一些不好的事情,比如虽然当我提 eHall 的时候,我真正需要的是一个 browser use agent,它能帮我在各种网页上面处理,eHall 只是一个作为测试的出口,他能把我的个人信息到 eHall 上面。所以我可能第一步实现的就不需要去在这个你的教务系统上面做太多的设计,你作为过渡设计反而不好。
然后这个就是和刚才我给大家看的那个例子是非常相关的。你看起来他做的所有的事情都是对的,从 readme.md 来看,他做的所有的事情都是正确的,没有一个错误,但最终他得到了一个你不想要的东西。所以我看现在有没有 git 提交了。他甚至都没有创建一个 git repository。这就是你看,你不跟他讲。当然我觉得可能如果是 codex 的话,他可能还是会初始化一个目录,或者他会问你这个项目应该怎么管理。如果比如我是一个成熟的产品经理的话,老板跟我说,这个以后我肯定会跟我的团队去沟通一下项目怎么管,这个就在甚至在我写下第一行代码之前,我都要讨论好协作怎么做、项目怎么管、每天什么时候开会。AI 这东西很好玩,他就直接上手就做,他觉得 OK,这就是一个用一次就丢掉的一个东西。
感觉他烧了我不少,可能烧了我不少 token,也无所谓了,这个我参与 DPC 哈里斯内测,他送了我 1000 块的额度,到现在还没有完全用完。所以你们在刚开始的时候还是回到项目,回到管理,脑袋里面要有一个这样的模型,你不能直接把任务就丢给 agent。你现在想的是先第一件事一定是创建一个空的,然后创建完了以后你就会想你的下一步要迈向哪里。下一步我也给大家展示了,不能这样,下一步不能是这样的,你需要的是往前再迈一小步,往前再迈一小步。所以你发现现在 AI 从零开始做一个东西,做出来 slop 的难度比他到一个成熟的项目里面去做一个维护性的任务要大很多。
我们对软件工程的真正的要求,或者说我如果我要跟一个 team 或者跟一个学生去谈论,我们要设计一个什么什么软件的时候,我的问题是,你能不能以不变应万变。也就是说你现在软件设计一个架构,或者设计一个流程方法,能不能抵抗未来需求发生根本性的变化。所以你马上就看到,在刚才那个例子里就是数据库,说我要生成一个这样的、要生成一个挣钱的产品,去挣这个宝妈们的钱,然后我真正需要的是一个能扩展的架构。当未来有别的需求来了,AI 模型发生了变化,就我其实我在想这软件工程的时候,或者说我的合作模式发生了变化,AI 助教,AI 助教有可能是文字的助教,有可能是图像的助教,有可能有交互,有可能没有交互。它各种各样的需求在不断变化的时候,我还有没有可能我的架构以最少的变化去应对需求的变化?而这个恰恰是大家在软件工程课也不讲这个,这个数据库、这个操作系统课也不教。
大家都说好,这个操作系统是长这样的,你啊,为什么会今天你会看到一个这样的操作系统,为什么会看到一个这样的数据库,为什么要这样做并发控制,都是有这样的一个原因。但软件工程说的最不一样的地方,就是对未来需求的变化。所以如果你们要,比如这两节课跟大家讲版本管理的时候,你要记得版本管理做的三件事:记住现在,记住历史,记住教训。这个是在 agent 的时代和软件工程,就古法编程的时代都成立了。你需要现在就是因为你必须要有一个 repository,不管是这个形式还是就现在的一个 C 或者 C++ 的或者 Python、JavaScript 的项目,你都需要把文件管起来。那么你记住历史,是为了有一天你这个东西不要了,或者你删掉的一个东西,你最终发现对你是有用的,你还可以把它捞回来。然后在这个提交的历史过程当中,你能看到你是怎么演进到一个点,然后发现这件事情失败了,然后这个失败了,可能你会留在这,然后你会回到另外一个点再开始另外一个正确的路线。而这些东西都是在版本控制里的。
然后这些 lessons 不停地都在警示你,你需要怎么做,你需要怎么样从第一步开始迈出向后的一小步。那肯定显然不应该是看看,哦他已经实现完了吗?提交人民奖学金申请,准备退课。看有没有 git,哎他倒是做了一个提交,直接一个提交。引擎全部是真代码,手机端是真 HTTP 服务,有一部分是集成的,模拟的部分邮箱是没有的。哎,好啊。它有一个入口层,它有一个编排层,有一个能力层,还是个非常非常典型的架构,数据层。我就不试图 review 了,但显然他把它当成是一个业务逻辑系统来做。然后这其实是有非常大风险的,就是它看起来已经像一个教务系统了,这个软件已经看起来像一个教育系统。我下节课就会讲讲教务系统。
而其实现在更符合主流的 best practice,如果你真的要从零开始构建的话,你要用一个叫 MVP 的,叫 minimum viable product,或者 minimal viable product,有一个最小的验证版本。然后这有一句话叫 the biggest risk in product development is building something nobody wants,就是你做的东西,就是你看他做的东西就是我不要的。这个东西绝大部分东西其实不是我要的,我刚才在他登代码的时候我解释了,我其实想要的是什么。所以如果你要做一个最小的版本,其实他逼迫的是你要想清楚什么样的需求才是你绝对要的。刚才我已经说了,如果我要一个课程的,如果我要做一个个人的助手,我最想要的是他能有让我提升我学习效率的能力和提升我工作效率的能力。你总结出了这两条以后,你可能在第一个这个 MVP 里面没有任何的数据库。
因为你会想到在任何时候当你文件系统膨胀的时候,你都可以说跟 AI 直接说,好,按照这个数据的组织帮我提炼出一个数据库,这个问题就结束了,所以你不需要在刚开始的时候就做数据库。因为一旦你带上一个数据库,你每次数据库重构都需要让 agent 做这个 database 的迁移,但文件系统不一样。文件系统你自己就可以在你的这个文件管理器里去调整它的目录结构,逐渐梳理到你舒服的一个结构上。你看看你在里面调文件和你去调这个 database 的 schema,你的心智负担是不一样。人类已经训练过,对人类、对直观的东西已经训练得非常舒服了。文件目录在计算机里面操作,你从小,从你小学的时候上电脑课开始你就做这件事了,所以你对这个模式非常习惯。但一旦你说你要操作一个 database schema 的时候,你们很多同学还没有学过数据库课,你根本不知道它是什么,在你面对一个未知的东西的时候,你仍然能,就你需要强行的说,做一次概念的翻译。我这个东西脑袋里面是这样的概念,我要翻译到数据库里是这样的,那你不如用一个你已经适应了、熟悉了的这个 system。
用最简单的方式帮你把,比如这个文件的学习资料的管理,学习资料的进入、通知消息的进入。就甚至到我现在,我自己回邮件的那 agent 都没有用任何的数据库。我的流程大概是这样的:我的那个 agent 会收我的邮箱,然后会把那个 email id 放到我的一个共享的云盘同步上,然后那个 email id 里面会有它的这个 AI 解析出来的,比如我回复的草稿,然后这个邮件的分类,是不是垃圾邮件,这种,然后然后再由下一级的流水线去处理。也就是说我先要明白的是我的系统里面有哪几级流水线,然后这几级流水线之间他们应该怎么协作,哪些东西通过云盘。就比如,那么因为我很喜欢我的云盘,是不是引入云盘要做一些架构的修改?我希望邮件和资料等都和云盘保持同步,这就叫需求的更改。我现在不知道它做的好不好啊,我让 agent 他帮我看一看。
也就是说什么是一个好的软件架构?你当然在当前时候,你是有需求的,这个现在实现可能是满足这个需求的,但是当你的需求发生变更的时候,你相应的做出的那个变化是摧毁性的,是背上一个技术债,还是别的,那就不知道了。然后我们来看一下他的这个设计,它有一个 assistant.db,他要把个人资料放到这里,邮件归档也放到这里,索引草稿仍然在 DB。现在看起来还不会引起很大的问题。因为绝大部分都是这样的,数据库确实应该不会引起特别大的问题,但是随着这个项目的不断进展,你就会发现,你有一种开发方式是这样:就是你上来就是一个大的,然后好几大步就把需求都实现完了,然后就拖着这个技术债不断地往后迭代,往后加功能,加功能;还有一个是你从最小的就得到一个特别精巧的架构。
我举个,我为什么要举刚才那个例子?就当你发现我用 git 管理这个学生的提交以后,很多事情都变得非常容易,而且我自己控制起来也会轻松很多。直到我遇到性能瓶颈之前,我都不想要把它迁移到任何一种数据库。然后这也意味着,其实你早期的 prompt 对整个项目的走向的影响是最大的,你每一次,就你最早的那个,我直接把 readme 给他,还说我要做一个 MVP 啊,这都会对项目造成很大的影响。那我下一节课,就周日就会进入软件架构和构造的内容。好,今天下课。