MoreRSS

site iconChaofa Yuan修改

大模型算法工程师, 写过《LLMs-Zero-to-Hero》
请复制 RSS 到你的阅读器,或快速订阅到 :

Inoreader Feedly Follow Feedbin Local Reader

Chaofa Yuan的 RSS 预览

Kimi-K2 和 Kimi-K2-Thinking 深度解读:从预训练优化到 Agentic 能力训练的完整流程(含MuonClip优化、Agentic 数据合成等)

2025-11-10 01:26:00

0. 背景

月之暗面发布的 Kimi K2 Thinking,在 Humanity's Last Exam (HLE) 上达到了 44.9% 的成绩,在多个基准测试中表现优异,不过榜单简单看一眼即可;让我比较惊喜的是,K2 Thinking 可以执行 200-300 步连续的工具调用,有类似于 claude 一样的长程规划和自适应推理能力。

但是,K2 Thinking 的官方 blog 只展示了 benchmark 数据和 demo,并没有透露具体的技术细节。作为一个大模型从业者,看到 Twitter/知乎大家都在聊这个模型,所以我就比较好奇「模型的训练方法」以及「给我们工作学习中的启发」。

好在今年早些时候发布了 Kimi K2 的完整技术报告技术 blog。而 K2 Thinking 和 K2 师出同源,只是在 K2 的基础上增加了 thinking 能力,更强的工具调用能力,通过 test-time scaling 实现一个更强的 Thinking Agent。因此,通过深入研究 K2 的技术细节,我们就能理解 K2 Thinking 是如何炼成的。

我是朝发(CHAOFA)这篇文章会从 K2 的技术报告出发,结合 K2 Thinking 的特点,了解这个 SOTA 开源 thinking 模型是怎么训出来的。核心关注三个问题

  1. 预训练阶段:如何用 MuonClip 优化器实现更高的 token 效率?
  2. 后训练阶段:如何通过大规模 Agentic 数据合成和通用强化学习,让模型学会使用工具?
  3. Test-Time Scaling:如何让模型在推理时进行长程思考和工具调用?

历史上此比较相关文章:

如果不喜欢看文字可以看视频解读,B 站-chaofa用代码打点酱油YouTube

算法视角深度解读 Kimi K2 和 K2 Thinking,从预训练优化到 Agentic 能力训练的完整流程(含MuonClip优化、Agentic 数据 --bilibili

1. 整体架构:从 K2 到 K2 Thinking

image.png|700x366

Archiecture from: Sebastian Raschka

先来看一下上面的整体结构图,然后在深入技术细节之前,我们有必要先理解 K2 和 K2 Thinking 的关系。

1.1 K2:Open Agentic Intelligence 的基座

Kimi K2 是一个 MoE (Mixture-of-Experts) 模型,拥有 32B 激活参数和 1T 总参数。它在非 thinking 模型中,在前沿知识、数学和编码任务上达到了 SOTA 性能。

K2 的核心特点是有比较强的 Agentic 能力。什么是 Agentic 任务?就是模型不仅要回答问题,还要主动使用工具、执行操作、完成复杂的多步骤任务。比如:

  • 用 Python 分析数据、生成可视化网页
  • 在命令行中编辑文件、运行命令
  • 通过搜索和浏览器收集信息、验证假设、构建答案

K2 发布了两个版本:

  • Kimi-K2-Base:基础模型,适合研究者/开发者/企业用户进行微调
  • Kimi-K2-Instruct:后训练模型,适合直接使用,是一个非推理模式(Non-Reasoning Model)

1.2 K2 Thinking:加入 Test-Time Scaling

Kimi K2 Thinking 是在 K2 的基础上,通过额外的训练,让模型具备了 thinking 能力。它的核心特点是:

  1. 边思考边使用工具:模型在推理过程中,会进行 think → search → browse → think → code 的循环,动态生成和验证假设
  2. 长程推理:可以执行 200-300 步连续的工具调用,保持推理的连贯性。(这点是让人比较惊喜的)
  3. Test-Time Scaling:通过增加推理时的 thinking tokens 和工具调用步数,提升模型性能

从架构上看,K2 Thinking = K2 + Thinking Ability + Test-Time Scaling。因此,理解 K2 的训练方法,就能理解 K2 Thinking 的 80%

下面我们按照训练流程,依次讲解预训练、后训练和 test-time scaling 的关键技术。

2. 预训练

2.1 基于 MuonClip 优化器的 Token 效率优化

预训练是 Agentic Intelligence 的关键基础,它建立了让强化学习探索变得可行、高效和可泛化的先验知识。但是,正如 Ilya Sutskever 所说,数据是有限的"化石燃料",其增长速度远远落后于算力的增长。这使得预训练阶段的 token 利用效率成为 AI scaling laws 中的新关键系数。

2.1.1 为什么需要更好的优化器?

给定一个大致有限的预训练数据集和固定的模型配置,更 token 高效的优化器能产生更多的智能。Moonshot 之前的工作 Moonlight 已经证明,Muon 优化器在 LLM 训练中显著优于广泛使用的 AdamW 优化器,即“相同配置训练下有更低的 loss”。

K2 的设计目标是进一步扩展 Moonlight,它采用了类似 DeepSeek-V3 的架构。基于 scaling-law 分析,他们做了两点改进(看图更清晰):

  • 减少了 attention heads 的数量,以提高长上下文效率。
  • 增加了 MoE 的稀疏性,以获得更高的 token 效率

原文这么写的:Based on scaling-law analysis, we reduce the number of heads for long-context efficiency, and increase MoE sparsity for greater token efficiency。

但在扩展过程中,他们遇到了一个持续的挑战:由 attention logits 爆炸引起的训练不稳定。这个问题在使用 Muon 时更频繁,而在 AdamW 中较少。现有的解决方案(如 Qwen3 用的 query-key normalization)都不够充分(防止数值溢出)。

2.1.2 MuonClip:直接控制 Attention Logits

为了解决这个问题,kimi 提出了 MuonClip 优化器,它通过 qk-clip 技术改进了 Muon。

核心思想:qk-clip 通过在 Muon 更新后直接重新缩放 query 和 key 投影的权重矩阵,从源头控制 attention logits 的规模,从而稳定训练。(注意:这里是更新完之后,所以不会改变这一次更新的 forward/backward 操作,影响的是下一步)。

具体来说,query 和 key 投影按如下方式缩放:

qi=ηαWqxi q_i = \eta^{\alpha} W_q x_i

ki=η1αWkxi k_i = \eta^{1-\alpha} W_k x_i

其中 α\alpha 是一个平衡超参数,因此 attention logit 变为:

(ηαqi)(η1αkj)=ηqikj (\eta^{\alpha} q_i)^\top (\eta^{1-\alpha} k_j) = \eta\, q_i^\top k_j

自适应因子 η\eta(阈值为 tt)在每一步之后根据该步的最大 attention logit 设置:

η=min(tmaxi,j(qikj),1) \eta = \min\left(\frac{t}{\displaystyle\max_{i,j}\bigl(q_i^\top k_j\bigr)}, 1\right)

其中 tt 是预设的阈值。这是一个通用技术,可能适用于其他稳定化场景。这里其实还有一些其他的细节,比如 每个 head 有不同的 η\eta

2.1.3 实验结果:零训练尖峰

实验表明,MuonClip 有效地防止了 logit 爆炸,同时保持了下游任务性能。在实践中,K2 使用 MuonClip 在 15.5T tokens 上进行预训练,实现了零训练尖峰(zero loss spike),证明了 MuonClip 是大规模 LLM 训练的稳健解决方案。

image.png|700x420

从 loss 曲线可以看出,MuonClip 的训练过程非常平滑,没有出现任何不稳定的情况。这为后续的 Agentic 能力训练打下了坚实的基础。

小结:MuonClip 优化器通过 qk-clip 技术,在保持 Muon 高 token 效率的同时,解决了训练不稳定问题,使得在同等条件下获得比 AdamW 更低的 loss,使得 K2 能够在有限的数据上训练出更强的基础模型。

2.2 文本的改写优化

K2 相比 K1.5 的一个关键进步是引入了合成数据生成策略来提高 token 利用率。核心思想是:通过精心设计的改写 pipeline,在不引入显著过拟合的情况下,扩大高质量 tokens 的数量。改写(Rephrasing) 就是数据合成的一种方式,主要是为了提高「高质量数据的占比」,尤其是「知识领域」和「数学领域」。:

2.2.1 知识领域数据改写

在知识密集型文本上进行预训练面临一个权衡:单次 epoch 不足以全面吸收知识,而多次 epoch 重复会导致收益递减并增加过拟合风险。为了提高高质量知识 tokens 的利用率,K2 提出了一个合成改写框架,每个语料库最多改写两次,包含三个关键组件:

A. 风格和视角多样化的提示(Style- and perspective-diverse prompting)

通过精心设计的 prompts,引导大语言模型以不同的风格和视角生成原文的忠实改写。这样做的好处是:

  • 增强语言多样性
  • 保持事实完整性
  • 避免简单的同义词替换

B. 分块自回归生成(Chunk-wise autoregressive generation)

为了在长文档中保持全局连贯性并避免信息丢失,采用基于分块的自回归改写策略,一图胜千言:

image.png|700x344

C. 保真度验证(Fidelity verification)

为了确保原文和改写内容之间的一致性,进行保真度检查,比较每个改写段落与其源文本的语义对齐。这是训练前的初步质量控制步骤。

2.2.2 数学领域数据改写

为了增强数学推理能力,K2 采用了两种策略:

A. "学习笔记"风格改写

将高质量的数学文档改写成"学习笔记"风格,遵循 SwallowMath 中引入的方法。这种风格更接近人类学习数学的方式,包含:

  • 逐步推导过程
  • 关键概念解释
  • 示例和练习

B. 多语言翻译

将其他语言的高质量数学材料翻译成英语,以增加数据多样性。这样可以:

  • 利用非英语世界的优质数学资源
  • 增加数学表达的多样性
  • 扩大训练数据规模

小结:通过针对知识和数学领域的专门改写技术,K2 在不显著增加过拟合风险的情况下,大幅提高了高质量 tokens 的利用率。这种受控的数据增强策略是 K2 预训练成功的关键因素之一。

3. 后训练(重点)

K2 的增强 Agentic 能力源于两个重要方面:

  • 大规模 Agentic 数据合成
  • 通用强化学习

3.1 大规模 Agentic 数据合成:教会模型使用工具

为了教会模型复杂的工具使用能力,kimi 是基于大规模模拟真实世界的工具使用场景,构建了数据 pipeline。

3.1.1 数据合成流程

这个管道的核心思想是:系统地演化数百个领域,包含数千个工具(包括真实的 MCP 工具和合成工具),然后生成数百个具有不同工具集的 agents。

image.png|700x232

辅助看这个图:

image.png|700x233

具体流程如下:

  1. 定义领域和工具:涵盖各种真实场景,如数据分析、网页开发、系统管理等
  2. 生成任务:所有任务都是基于 rubric 的(有明确的评分标准),确保一致的评估
  3. 模拟交互:Agents 与模拟环境和用户 agents 交互,创建真实的多轮工具使用场景
  4. LLM 评判(LLM as judge):根据任务 rubrics 评估模拟结果,过滤出高质量的训练数据

这个可扩展的 pipeline 生成了多样化、高质量的数据,为大规模拒绝采样和强化学习铺平了道路。

3.1.2 为什么这个方法有效?

传统的工具使用训练依赖于人工标注的数据,成本高、规模小、多样性有限。而 k2 的方法通过自动化合成,可以:

  • 无限扩展:只要定义新的领域和工具,就能生成新的训练数据
  • 保证质量:通过 rubric-based 评估和 LLM judge,确保数据质量
  • 覆盖长尾场景:可以模拟各种罕见但重要的工具使用场景

3.2 通用强化学习:不可验证奖励

传统的强化学习主要应用于可验证奖励的任务,比如数学题(答案对错明确)和竞赛编程(能否通过测试用例)。但对于不可验证奖励的任务(如写研究报告、创意写作),传统 RL 就无能为力了。

是不是突然想起了 DeepSeek-GRM(通用奖励模型)

3.2.1 Self-Judging 机制

核心思想是:模型作为自己的评判者,为不可验证的任务提供可扩展的、基于 rubric 的反馈。

具体做法:

  1. 对于不可验证的任务,模型生成多个候选答案
  2. 模型自己根据 rubric 评估这些答案,给出分数
  3. 使用这些分数作为奖励信号,进行强化学习

但这里有个问题:模型的自我评估准确吗?这不还是 LLM as Judge 那一套吗?

3.2.2 用可验证奖励改进 Critic

kimi 的解决方案是:在可验证奖励的 on-policy rollouts 中,持续更新 critic,使 critic 在最新策略上不断提高评估准确性。

这可以看作是用可验证奖励来改进不可验证奖励的估计。通过这种方式,模型的自我评估能力会随着训练不断提升,从而支持更广泛的任务。

小结:通过大规模 Agentic 数据合成和通用强化学习,K2 学会了在各种场景下使用工具,并且能够处理可验证和不可验证的任务。这为 K2 Thinking 的长程推理能力打下了基础。

4. K2 Thinking

K2 Thinking 在 K2 的基础上,增加了 thinking 能力更强的工具调用能力test-time scaling。这使得模型能够在推理时进行长程思考和工具调用,从而解决更复杂的问题。

4.1 什么是 Test-Time Scaling?

Test-Time Scaling 是指在推理时增加计算量,以提升模型性能。对于 K2 Thinking,这体现在两个方面:

  1. 增加 thinking tokens:模型在生成答案前,会先生成大量的思考过程(类似 OpenAI o1,这其实就是 Long-CoT,这种技术在 Kimi-k1.5 就已经开始做了)
  2. 增加工具调用步数:模型可以执行 200-300 步连续的工具调用,进行长程规划(这是新增的,为了 Agentic 能力的提升)

这两者结合,使得 K2 Thinking 能够解决需要深度推理和多步操作的复杂问题。

4.2 边思考边使用工具:Interleaved Reasoning

K2 Thinking 的核心能力是边思考边使用工具。它会进行动态的 think → search → browse → think → code 循环,这个循环可以重复数百次,直到找到答案:

  1. Think:分析问题,生成假设
  2. Search:搜索相关信息
  3. Browse:浏览网页,提取关键信息
  4. Think:验证假设,调整策略
  5. Code:编写代码,执行计算

4.3 简要看看 benchmark

4.3.1 Agentic Search:超越人类基线

在 BrowseComp benchmark 上,K2 Thinking 达到了 60.2% 的成绩,显著超越了 29.2% 的人类基线。

BrowseComp 是一个挺具有挑战性的 benchmark,旨在评估模型持续浏览、搜索和推理难以找到的真实世界网络信息的能力。

4.3.2 Agentic Coding:构建完整的应用

K2 Thinking 在编码任务上也表现出色:

image.png|700x469

从官网看,K2 Thinking 可以从单个 prompt 构建完整的应用,包括:

  • 组件密集的网站
  • Word 克隆应用
  • 交互式数据分析工具

4.4 小结

通过 test-time scaling,K2 Thinking 能够在推理时进行长程思考和工具调用,从而解决需要深度推理和多步操作的复杂问题。这使得它在 Agentic Reasoning、Agentic Search 和 Agentic Coding 任务上都达到了 SOTA 性能。(有点 claude 那味道了)

5. 技术细节对比:K2 vs K2 Thinking

让我们总结一下 K2 和 K2 Thinking 的关键区别:

| 维度 | K2 (Instruct) | K2 Thinking | |

影视飓风TIM成功背后:一个程序员对自媒体商业化的深度复盘(25年10月月度小结)

2025-11-03 04:52:00

我是朝发(chaofa),这是我 25 年第 9 次月度总结,希望还能坚持下去。

从信息传播角度,现在是一个好的社会,很多的大佬也愿意出来讲话,无论是播客还是文章,都很容易被传播到,从而得以瞥见他们的想法。这让我们看到很多。我有时候很感激,有时候又时长感慨:他们的成功真的可以复制吗?

罗永浩的播客是一个挺好的东西,我几乎是每一期都听的,10 月份和影视飓风 TIM 的采访让我很是羡慕,潘天鸿。96 年,还不到 30 岁,但现在已经彻底成功[1]。老罗和他的对话还挺有意思的,但我主要是聊四个东西对我的启发:

  • TIM 超强的能量
  • 极致的数据化驱动
  • 商业化道路的选择
  • TIM 家庭的支持

20251102204745

TIM 的精力与野心

我觉得 TIM 给我的感觉就是另外一个维度的雷军,是那种特别努力的人。这种被称得上“努力”的人,不仅有着天生好的精力和体力,还配有强大的意志和成功欲望,这些可能都不是后天能够锻炼出来或者培养出来的东西[2]。我真的非常佩服 TIM 的精力,他几乎是每一期真人出镜,且有大量的口播,考虑到更新频率以及「影视飓风」那么大体量公司管理和沟通的事情,他必然是全情投入其中,不容其他人丝毫地质疑他的努力。

在对谈中,有一个词出现的频率特别高「野兽先生,Mr. Beast」,TIM 的对标账号是世界第一网红「野兽先生」,尽管这不是我所喜欢的风格[3],但是我觉得这主要是他的野心太大了。「大众流量」、「声誉口碑」他全都要,并且要靠「流量变现」养活其他的探索与尝试,所以他选择了这种已经被证明的道路。野兽先生无疑是成功的,想要获得巨大的影响力这也是一个最好的办法,毕竟他要养着「冲击奥斯卡短片」的团队,靠着矩阵账号的方式,打造着目前中国(可能是)最成功的自媒体账号。

影视飓风数据驱动方式

影视飓风很早之前就有一期飞书的广告视频,讲「影视飓风是如何通过数据驱动的方式增长」。作为任何一个「互联网行业从业者」,应该对此完全的不陌生,里面说的所有内容都是在职场中被宣贯无数次的准则,但是要真正在自己的生活或者「自己的事业」上落实这些道理却有巨大的挑战。很多时候,我们就是无法跨越这种「知道」和「做到」的巨大鸿沟。

就以我自己为例,作为一个曾经在中文最大的互联网公司的广告相关从业者,我自然知道「点击率」、「留存」以及「各种漏斗指标」对于一个视频的影响,但是我自己做「B 站/YouTube/小红书大模型教学视频(chaofa 用代码打点酱油)」的时候,我总是带有一种「类似于理想主义的天真」,希望别人能认真看下去[4],从而收获真正的价值,所以我一直想要筛选掉那些「并不是想真正学习的人」,于是我通过「不剪辑、不配字幕、封面不设计、口播不写稿等」一系列愚蠢至极的操作把真正想学习的人筛选出来了,事实上我好像部分成功了,我成功筛选出了「高学历、高收入、低付费意愿」的能够自我学习和进化的人士,一方面为此感到欣慰,一方面又「因为自己的思想的转变」而感觉有点想笑。

商业化道路选择

自媒体似乎很光鲜亮丽,尤其是被「各种网红的造富神话」的冲击之后,现在越来越多的年轻人将「当网红」作为自己的最佳的职业选择[5]。但大多数人只要自己尝试之后,才会发现实际上是很难赚钱的(除了那些教别人做自媒体的),或者说很难赚到自己「付出同样精力打工上班」赚到的钱。

核心原因是自媒体赛道的变现问题,前段时间王自如限高做绿皮火车吃泡面的新闻就可见一斑(毕竟也是曾经的顶流)。这里主要有两个原因:

  • 内容行业无法规模化。TIM 在大多数影视飓风的视频中都是自己作为核心要素出镜,也就是说他是整个账号最核心的资产,所以注定了他们的视频无法做到「批量复制」,因此相对于其他的商业模式上限会更低(哪怕是一些主流的科技媒体)。
  • 靠广告挣钱很难做到「独立客观第三方」。播客中也提到了「自媒体能不能站着把钱挣了」?就连影视飓风这样的账号都很难,更不用说各种「脚部、脚底部」账号,因此到了某个层面,一定会降低公信力,从而出现信任危机与拐点。

而 TIM 的选择是通过卖货,即现在大多数账号的首要变现方式一样(包括像 B 站的大物是也等)也都是通过卖货来变现的,只是可能 TIM 这种自建电商品牌的方式更正规军一点,并且在很早开始尝试,而很多账号可能还在犹豫、彷徨、迷茫,而影视飓风已经带着 TIM 的同款内裤把钱赚了个够。

回到我的账号——chaofa用代码打点酱油,变现效率可能说是奇差,刚刚提到的「高学历、高收入、低付费意愿」的人其实就是我自己(我的同事、我的同学们),我也是属于那种付费意愿比较低的人🤣,思想正在转变中。想要卖货是不可能了,但是我发现「网络上很多盗版我视频的人」,在我视频不到40 的情况下盗版视频可能有近百个了,因此我的视频绝对是有价值的,所以我一定要快点写书,等写完书我就可以尝试卖课了(严肃脸😠),也许是 26 年底,也许是永远不会(懒惰可能会占领智商的高地)。

家庭的支持

很多人都说 TIM 的成功当然也离不开家庭的支持,这里指的是那份底气和关键性的指导,他可以有足够的尝试,可以自由的探索。这是显然的,毕竟做成这样缺失路上任何一环都不会有现在的「影视飓风」,不过这可能是最不重要的,因为他真的很强,没有这样的家庭他也能成功,只是可能不一定有现在成功[6]。但是无论如何,他的成功都是自己争取得到的,其他的东西只是景上添花,就算不是 29 岁,39 岁的 TIM 也一定会走上人生巅峰。RESPECT!

关注我

最后欢迎来探讨对世界的认知,基本全网同名 chaofa用代码打点酱油 (推荐)

附录


  1. 大约在 21 年才关注到影视飓风,真的是一路看着他成功,以后他会更成功,真的是太羡慕了,无论是才华、认知、努力还是天生的精力。 ↩︎

  2. 尽管 TIM 称自己并不是一个高能量的人,但是他所展现出来的精力,都是大多数人不可比拟的。在媒体前的表现自然会有一定的表演性质,但是只要能一直持续下去(10 年啊,人生能有几个十年),那就是常人难以企及的能量。 ↩︎

  3. 我还是喜欢有温度一点的内容,不喜欢被当做数字,想要被认为是一个真正的观众,所以我永远喜欢那种真诚深度对话。备注:这个月和一个网友聊了个天还挺好的。 ↩︎

  4. 我的视频都特别的长,视频质量一定是远超 B 站同类视频,但是数据比较一般。我经常能在评论区有一些比较搞笑的对话,问我资料/问我代码(GitHub 链接/个人 Blog/公众号差点就怼脸上了),我有时候真的很想笑, Google 搜一下「chaofa 用代码打点酱油」就这么难吗? ↩︎

  5. 我这里并不是贬义,随着我思想的转变,我将自己也部分归类为「自媒体」,而以前我只愿意称自己为「公开表达」。 ↩︎

  6. 备注:我觉得以 TIM的认知,他是不可能不能推测出自己家庭情况是比较有钱的,因为这东西很容易感受到,爸妈能陪你飞到国外去看看学校(校长?)一定不是 TIM 口中普通家庭可以做到的,至少不差,没 TIM 口中感受的那么差吧。 ↩︎

LLM MOE的进化之路,从普通简化 MOE,到 sparse moe,再到 deepseek 使用的 share_expert sparse moe

2025-01-28 03:30:00

1. 阅读前提

本次课一共讲解三个不同版本的 MOE,分别是基础版MOE,大模型训练用的 SparseMoE,还有 DeepSeek 用的比较多的 shared_expert 的 SparseMoE。

2. 版本1:基础版本MOE

输入是一个 Token, 输出是一个 Token Embedding。暂时先不考虑 MOE 得到的 Embedding 怎么使用。

因为 MOE 网络对应着 Expert,这个 Expert 一般是一个 FeadFoward Network,FFN。而为了简化,后续我们都用一层的 Linear 代替,更高级版本的 Expert 留给大家当做课后作业。下面是一个专家的定义。

class BasicExpert(nn.Module):
    # 一个 Expert 可以是一个最简单的, linear 层即可
    # 也可以是 MLP 层
    # 也可以是 更复杂的 MLP 层(active function 设置为 swiglu)
    def __init__(self, feature_in, feature_out):
        super().__init__()
        self.linear = nn.Linear(feature_in, feature_out)
    
    def forward(self, x):
        return self.linear(x)

基础版本的 MOE 可以看这个图,非常的简单。

llms-zero-to-hero-basic-moe-model


class BasicMOE(nn.Module):
    def __init__(self, feature_in, feature_out, expert_number):
        super().__init__()
        self.experts = nn.ModuleList(
            [
                BasicExpert(feature_in, feature_out) for _ in range(expert_number)
            ]
        )
        # gate 就是选一个 expert 
        self.gate = nn.Linear(feature_in, expert_number)
    
    def forward(self, x):
        # x 的 shape 是 (batch, feature_in)
        expert_weight = self.gate(x)  # shape 是 (batch, expert_number)
        expert_out_list = [
            expert(x).unsqueeze(1) for expert in self.experts
        ]  # 里面每一个元素的 shape 是: (batch, ) ??

        # concat 起来 (batch, expert_number, feature_out)
        expert_output = torch.cat(expert_out_list, dim=1)

        # print(expert_output.size())

        expert_weight = expert_weight.unsqueeze(1) # (batch, 1, expert_nuber)

        # expert_weight * expert_out_list
        output = expert_weight @ expert_output  # (batch, 1, feature_out)
        
        return output.squeeze()


def test_basic_moe():
    x = torch.rand(2, 4)

    basic_moe = BasicMOE(4, 3, 2)
    out = basic_moe(x)
    print(out)


test_basic_moe()

2. 版本2:SparseMoE (大模型训练使用)

这个一般我们用 switch transformers 这篇文章的图作为演示,详情看:

llms-zero-to-hero-switch-transformers-moe-model

和 Basic 区别是,MOE 选择 topK 个专家,然后对这 topK 个专家的输出进行加权求和,并且把输入样本变成了大模型中真实的输入 Shape,(batch, seq_len, hidden_dim)


# 主要参考自 mistral MOE 的实现

class MOERouter(nn.Module):
    def __init__(self, hidden_dim, expert_number, top_k):
        super().__init__()
        self.gate = nn.Linear(hidden_dim, expert_number)
        self.expert_number = expert_number
        self.top_k = top_k
    
    def forward(self, hidden_states):
        # 计算路由logits
        router_logits = self.gate(hidden_states)  # shape is (b * s, expert_number)
        
        # 计算专家经过softmax之后的概率
        routing_probs = F.softmax(router_logits, dim=-1, dtype=torch.float)
        
        # 计算topk的专家的输出
        router_weights, selected_experts = torch.topk(
            routing_probs, self.top_k, dim=-1
        )  # shape都是 (b * s, top_k)
        
        # 专家权重归一化
        router_weights = router_weights / router_weights.sum(dim=-1, keepdim=True)
        router_weights = router_weights.to(hidden_states.dtype)
        
        # 生成专家掩码
        expert_mask = F.one_hot(
            selected_experts,
            num_classes=self.expert_number
        )  # shape是 (b * s, top_k, expert_number)
        expert_mask = expert_mask.permute(2, 1, 0)  # (expert_number, top_k, b * s)
        
        return router_logits, router_weights, selected_experts, expert_mask


class MOEConfig:
    def __init__(
            self, 
            hidden_dim, 
            expert_number, 
            top_k, 
            shared_experts_number=2,
        ):
        self.hidden_dim = hidden_dim
        self.expert_number = expert_number
        self.top_k = top_k
        self.shared_experts_number = shared_experts_number

class SparseMOE(nn.Module):
    # 稀疏 MOE 模型,这里每一个 token 都会过 topk 个专家,得到对应token 的 hidden_embeddings
    def __init__(self, config):
        super().__init__()

        self.hidden_dim = config.hidden_dim

        self.expert_number = config.expert_number
        self.top_k = config.top_k

        self.experts = nn.ModuleList(
            [
                BasicExpert(self.hidden_dim, self.hidden_dim) for _ in range(self.expert_number)
            ]
        )

        self.router = MOERouter(self.hidden_dim, self.expert_number, self.top_k)
    
    def forward(self, x):
        # x shape is (b, s, hidden_dim)
        batch_size, seq_len, hidden_dim = x.size()

        # 合并前两个维度,因为不是 Sample 维度了,而是 token 维度
        hidden_states = x.view(-1, hidden_dim) # shape is(b * s, hidden_dim)

        router_logits, router_weights, selected_experts_indices, expert_mask = self.router(hidden_states)
        # 其中 selected_experts_indices shape 是 (b * s, top_k)
        # 其中 expert_mask shape 是 (expert_number, top_k, b * s)
        
        final_hidden_states = torch.zeros(
            (batch_size * seq_len, hidden_dim),
            dtype=hidden_states.dtype,
            device=hidden_states.device
        )

        for expert_idx in range(self.expert_number):
            expert_layer = self.experts[expert_idx]
            # expert_mask[expert_idx] shape 是 (top_k, b * s)
            idx, top_x = torch.where(expert_mask[expert_idx]) 
            # idx 和 top_x 都是一维 tensor
            # idx 的值是 0 或 1, 表示这个 token 是作为当前专家的 top1 还是 top2
            # top_x 的值是 token 在 batch*seq_len 中的位置索引
            # 例如对于 batch_size=2, seq_len=4 的输入:
            # top_x 的值范围是 0-7, 表示在展平后的 8 个 token 中的位置
            # idx 的值是 0/1, 表示这个 token 把当前专家作为其 top1/top2 专家

            # hidden_states 的 shape 是 (b * s, hidden_dim)
            # 需要取到 top_x 对应的 hidden_states
            current_state = hidden_states.unsqueeze(
                0
            )[:, top_x, :].reshape(-1, hidden_dim) # (selected_token_number, hidden_dim)

            # router_weight 的 shape 是 (b * s, top_k)
            current_hidden_states = expert_layer(
                current_state
            ) * router_weights[top_x, idx].unsqueeze(-1)  # (selected_token_number, 1) 这里有广播

            # 把当前专家的输出加到 final_hidden_states 中
            # 方式1 的写法性能更好,并且方式1容易出现
            final_hidden_states.index_add_(0, top_x, current_hidden_states.to(hidden_states.dtype))
            # 方式2
            # final_hidden_states[top_x] += current_hidden_states.to(hidden_states.dtype)
            # 方式2 的写法性能更差,并且方式2容易出现错误,+= 操作在处理重复索引时需要多次读写内存,可能会导致竞争条件

        # 把 final_hidden_states 还原到原来的 shape
        final_hidden_states = final_hidden_states.reshape(batch_size, seq_len, hidden_dim)

        return final_hidden_states, router_logits # shape 是 (b * s, expert_number)


def test_token_level_moe():
    x = torch.rand(2, 4, 16)
    config = MOEConfig(16, 2, 2)
    token_level_moe = SparseMOE(config)
    out = token_level_moe(x)
    print(out[0].shape, out[1].shape)


test_token_level_moe()

3. 版本3:ShareExpert SparseMoE (deepseek 版本)

备注:这里是参考 deepseek moe 思想,写的一个共享 expert 的 MOE 网络,有一定的简化,但是可以方便理解训练过程。

和 版本2 的 SparseMOE 区别是,这里多了一个 shared experts 的模型,这个模型是所有 token 共享的,也就是说,所有 token 都过这个 shared experts 模型,然后每个 token 会用计算的 Router 权重,来选择 topK 个专家,然后和共享的专家的输出一起加权求和。

具体结构图为:

llms-zero-to-hero-deepseek-v3-model-architecture

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

        self.moe_model = SparseMOE(config)
        self.shared_experts = nn.ModuleList(
            [
                BasicExpert(
                    config.hidden_dim, config.hidden_dim
                ) for _ in range(config.shared_experts_number)
            ]
        )

    def forward(self, x):
        # x shape 是 (b, s, hidden_dim)
        # 首先过 moe 模型
        sparse_moe_out, router_logits = self.moe_model(x)
        
        # 针对的还是 x 的每一个 
        # 然后过 shared experts
        shared_experts_out = [
            expert(x) for expert in self.shared_experts
        ] # 每一个 expert 的输出 shape 是 (b, s, hidden_dim)
        
        shared_experts_out = torch.stack(
            shared_experts_out, dim=0
        ).sum(dim=0)
        
        # 把 sparse_moe_out 和 shared_experts_out 加起来
        return sparse_moe_out + shared_experts_out, router_logits


def test_share_expert_moe():
    x = torch.rand(2, 4, 16)
    config = MOEConfig(16, 2, 2)
    share_expert_moe = ShareExpertMOE(config)
    out = share_expert_moe(x)
    print(out[0].shape, out[1].shape)


test_share_expert_moe()

4. 模型训练测试

用于测试上面的代码是否可以跑通?


def switch_load_balancing_loss(router_logits: torch.Tensor, num_experts: int) -> torch.Tensor:
    """
    计算 Switch Transformers 的负载均衡损失
    
    Args:
        router_logits: shape [batch_size * sequence_length, num_experts]
        num_experts: 专家数量
    
    Returns:
        total_loss: 总损失 = auxiliary_loss + z_loss
    """
    # 计算路由概率
    router_probs = torch.softmax(router_logits, dim=-1)  # [b*s, num_experts]
    
    # 获取每个token的最优专家
    _, selected_experts = torch.topk(router_probs, k=2, dim=-1) 
    
    # 创建one-hot矩阵表示选中的专家
    mask = torch.nn.functional.one_hot(selected_experts, num_experts).float() 
    
    # 计算每个专家的期望负载 (理想情况下应该是 1/num_experts)
    expected_load = torch.ones_like(router_probs) / num_experts
    
    # 计算实际负载 (每个专家处理的token数量除以总token数量)
    # 在batch维度上计算平均值
    actual_load = mask.mean(dim=0)
    
    # 计算auxiliary loss
    # 这会惩罚负载分布与期望负载的差异
    aux_loss = torch.sum(actual_load * router_probs.mean(dim=0)) * num_experts
    
    # 计算z_loss (可选)
    # 这会惩罚过大的路由logits
    z_loss = torch.mean(torch.square(router_logits))
    z_loss_weight = 0.001  # 可调整的超参数
    
    # 总损失
    total_loss = aux_loss + z_loss * z_loss_weight
    
    return total_loss

def test_moe_training():
    # Create a simple dataset
    batch_size = 32
    seq_len = 16
    hidden_dim = 32
    num_batches = 100
    
    # Initialize model and optimizer
    config = MOEConfig(hidden_dim=hidden_dim, 
                      expert_number=4,
                      top_k=2,
                      shared_experts_number=2)
    model = ShareExpertMOE(config)
    optimizer = torch.optim.Adam(model.parameters(), lr=0.001)
    
    # Training loop
    model.train()
    for batch in range(num_batches):
        # Generate random input data
        x = torch.randn(batch_size, seq_len, hidden_dim)
        target = torch.randn(batch_size, seq_len, hidden_dim)
        
        # Forward pass
        output, router_logits = model(x)

        # Compute losses
        # MSE loss for prediction
        mse_loss = F.mse_loss(output, target)
        
        aux_loss = switch_load_balancing_loss(router_logits, config.expert_number)
        # Combined loss
        total_loss = mse_loss + 0.01 * aux_loss
        
        # Backward pass and optimize
        optimizer.zero_grad()
        total_loss.backward()
        optimizer.step()
        
        if batch % 10 == 0:
            print(f"Batch {batch}, Loss: {total_loss.item():.4f} "
                  f"(MSE: {mse_loss.item():.4f}, Aux: {aux_loss.item():.4f})")

# Run the training test
test_moe_training()

5. 课后作业

  1. 把 expert 改成 swishGLU 版本的 FFN 专家
  2. 把 MOE 应用到上一次的 build_nanoGPT 中,也就是替换掉原来的 FFN层,注意这里负载均衡 loss 要包含每一层的 MOE 的 router_logits
  3. 自己问一下 GPT topK 是怎么实现的反向传播,了解反向传播的梯度怎么流转的?

交个朋友🤣

最后欢迎关注我,基本全网同名 chaofa用代码打点酱油

LLM activate function激活函数的进化之路,从 ReLU,GELU 到 SwiGLU(swishGLU)

2025-01-28 02:58:00

1. 背景

自 chatGPT 22年底问世以来,大模型(Large Language Model, LLM)一般使用 Causal Language Model 的形式,属于 Transformers 中的 Decoder 部分,其中在 Decoder 的 Block 中有一个 FFN(FeadForward) 层,一般认为这部分参数用于存储知识。而标准的 FFN 一般有一个升维度和降维度的过程,一共有两个权重矩阵,用公式表示为

FFN(x)=ReLU(xW1+b1)W2+b2(1) FFN(x) = ReLU(xW_1 + b1)W2 + b2 \tag{1}

其中 x shape 是 (b,s,h)(b, s, h),w1 shape 是 (h,4h)(h, 4h),w2 shape 是 (4h,h)(4h, h), w1 是升维(up),w2 是降维(down)

激活函数主要是为了实现神经网络学习输入和输出之间的复杂非线性关系而使用的一个函数。在公式 (1) 中,ReLU 是一个激活函数(Transfromers原版),可以替换成其他的激活函数,比如 BERT 开始用 Gaussian Error Linear Unit,GELU 比较多,随后就成了激活函数的主流选择,但是随着大模型的爆火以及 PaLM 模型的发布,大家开始慢慢使用 swishGLU 作为激活函数,并且作为一个主要的优化点。

具体可以看下面一段代码即可清楚的理解 FFN 模型是什么实现的。

class FeedForward(nn.Module):
    # 实际上就是 MLP
    def __init__(self, config):
        super().__init__()
        self.net = nn.Sequential(
            nn.Linear(config.n_embd, 4 * config.n_embd),
             # 激活函数
             nn.ReLU(),  
             #  可以替换成 nn.GELU(),  
             #  但是 如果是 SwishGLU 则实现方式有所不同,接下来就会介绍 swishGLU 是怎么实现的
            nn.Linear(4 * config.n_embd, config.n_embd),
            nn.Dropout(config.dropout)
        )
    
    def forward(self, x):
        return self.net(x)

2. 升级之路

1. ReLU

ReLU 深度学习以来最常用的激活函数,其公式非常的简单。

ReLU(x)=max(0,x)(2) ReLU(x) = max(0, x) \tag{2}

2. GELU

从 GPT、BERT 以来,GELU 似乎成了新时代取代 ReLU 的激活函数,具体形式如下:

GELU(x)=xP(Xx)=xΦ(x)(3) GELU(x) = x P(X \le x) = x \Phi(x) \tag{3}

其中 Φ(x)\Phi(x) 是标准正态分布的累计分布函数,定义为

Φ(x)=12(1+erf(x2))(4) \Phi(x) = \frac{1}{2}(1 + erf(\frac{x}{\sqrt{2}})) \tag{4}

这里的 erf 是误差函数

erf(x)=2π0xet2dt(5) erf(x) = \frac{2}{\sqrt{\pi}} \int_0^x e^{-t^2} dt \tag{5}

但是这个函数由于计算成本较高,因此有两个初等函数作为近似计算(但目前【2025年1月27日】其实很多框架已经可以精确计算 erf 函数)。

近似计算分析详细可以参见苏神的文章,GELU的两个初等函数近似是怎么来的

3. SwiGLU(SwishGLU)

SwiGLU(或者swishGLU,以下可能混用) 是 swish 激活函数和 GLU 门控单元的结合体,因此需要分别介绍两者的不同。

其中需要注意的是:在 T5 开始,很多模型(比如 PaLM )在FFN层都不用 bias 了,也就是说 FFN的公式变成了

FFN(x)=ActiveFunction(xW1)W2(6) FFN(x) = \text{ActiveFunction}(xW_1)W2 \tag{6}

注意公式 6 和公式 1 的区别,一共没有 bias 一个有 bias,但具体得看不同模型的实现,并不能一概而论。

3.1 swish 激活函数

swish 是一个非线性函数(激活函数都是如此,笑🤣),具体公式为:

Swish=xσ(βx) \text{Swish} = x \sigma(\beta x)

其中 β\beta 是一个超参数,当 β=1\beta = 1 时,Swish 就变成了 SiLU (Sigmoid Linear Unit),大多数框架的默认实现(如 PyTorch、TensorFlow 的 nn.SiLU())使用的是 β=1\beta = 1 的固定版本。

因此如果采用 swish 激活函数,FFN 的公式变成了

FFN(W1,W2,x)=Swish(xW1)W2 FFN(W_1, W_2, x) = \text{Swish}(xW_1)W2

共有两个可学习的矩阵,其中 w1,(h,4h)w_1,(h, 4h) 是升维矩阵,w2,(4h,h)w_2,(4h, h) 是降低维度的矩阵。

3.2 GLU 门控单元

GLU,Gated Linear Units,是一种门控结构(有参数,因此相对于普通的激活函数多了一个 gate 矩阵),通过 sigmoid 控制不同维度的激活。公式如下[1]

GLU(W,x,V,b,c)=(Wx+b)sigmoid(Vx+c)(7) GLU(W, x, V, b, c) = (Wx + b) \otimes \text{sigmoid}(Vx + c) \tag{7}

这里是不是熟悉 LSTM, GRU 的同学一下就理解,其中需要注意的是,b, c 对应的 bias 不是必须的。

对比公式 7 和公式 9,公式 9 中的 wupw_{up} 对应 公式 7 中的 WW,而 wgatew_{gate} 对应公式 7 中的 VV 矩阵。

3.3 SwiGLU 的表达形式

SwiGLU 就是把门控函数替换成了 swish,并且去除掉了 bias 部分,以及把 FFN 层的一个 Linear 层替换成了 GLU 层,因此一共有三个可训练的参数矩阵, w1, w2, w3。

因此最终的公式表达为,

FFN(W1,W2,W3,x)=W2(W1xSwish(W3x))(8) FFN(W_1, W_2, W_3, x) = W_2 \cdot (W_1x \otimes \text{Swish}(W_3x)) \tag{8}

而我们都知道 FFN 是一个升高维度,然后降低维度的过程,因此可以写成,W2 是一个降低维度的参数,W1 是升高维度的过程,而 W3 是一个 Gate 需要用到的参数矩阵。

FFN(wup,wdown,wgate)=wdown(wupxSwish(wgatex))(9) FFN(w_{up}, w_{down}, w_{gate}) = w_{down} \cdot (w_{up}x \otimes \text{Swish}(w_{gate}x)) \tag{9}

通过这个公式整体就非常的清晰理解使用 swiGLU 的 FFN。

而我们都知道在 basic 版本的 FFN,见公式(1), 只有 wupw_{up}wdownw_{down} 分别是 (h, 4h) 和(4h, h),因此整体参数是 8h28h^2

而公式9 中,一共有三个矩阵,如果想要实现总参数 8h28h^2,那么每一个参数矩阵的大小应该是 8h23\frac{8h^2}{3},因此 wup,wgatew_{up}, w_{gate} 的shape应该是 (h,8h3)(h, \frac{8h}{3})wdownw_{down} 的 shape 是 (8h3,h)(\frac{8h}{3}, h)

假设输入的 hidden_dim 大小是 hidden_dim,那么中间层(up 后的维度)大小是 mid_dim, 具体计算逻辑如下:

mid_dim = int(8 * hidden_dim / 3)
# multiple_of:make SwiGLU hidden layer size multiple of large power of 2
mid_dim = multiple_of * ((mid_dim + multiple_of - 1) // multiple_of)

# multiple_of 一般设置为 256, LLaMA 和 GPT等模型

注意,在 LLM (大语言模型) 架构中,multiple_of 是一个用于优化计算效率的参数,通常设置为 256 或其他 2 的幂次方数(如 128、512 等),最终让 mid_dim 调整为 multiple_of 的整数倍。这样做有几个原因:

  1. 硬件优化:现代 GPU/TPU 在处理 2 的幂次方大小的张量时效率最高
  2. 内存对齐:确保内存对齐可以提高计算速度
  3. 并行计算效率:某些并行计算操作在处理规整的数字时效率更高

3. 带有 swishGLU 的 FFN 代码实现

class FFNExpert(nn.Module):
    def __init__(self, hidden_dim, dropout):   # LLM 进化之路, FFN 激活函数从 GELU -> SwiGLU
        super().__init__()  

        # 有一个 magic number 叫做 8/3
        hidden_dim = hidden_dim
        # 这里可以自己去优化成 multiple_of 的倍数
        mid_dim = hidden_dim * 8 // 3

        self.up = nn.Linear(hidden_dim, mid_dim, bias=False)
        self.down = nn.Linear(mid_dim, hidden_dim, bias=False)
        self.gate = nn.Linear(hidden_dim, mid_dim, bias=False)

        self.dropout = nn.Dropout(dropout)

    def forward(self, x):
        out = self.dropout(
            self.down(
                # up 之后的 Shape 是(b, s, mid_dim)
                # gate 和 up 之后的Shape都是 (b, s, mid_dim)
                # 两者是 element-wise 相乘
                F.silu(
                    self.gate(x)
                ) * self.up(x)
            )
        )
        return out

参考

最后欢迎关注我,基本全网同名 chaofa用代码打点酱油


  1. https://zhuanlan.zhihu.com/p/693332639 ↩︎

2024,公开表达元年

2024-12-29 05:00:00

等待新的生命

如果今年要挑一个最重大的事情,那只能是点点(我妻子)怀孕了,我明年就要当爹了。这事情的影响是非常巨大的,不仅涉及到家庭,而且是一个需要长期付出不可逆的过程。有无孩子一定会是两个截然不同的世界,所以我是有些恐慌的(教育孩子的难度不言而喻)。

由于点点想生两个孩子,因此在去年结婚之后,就开始盘算着什么时候生娃,不然到时候得做高龄产妇。左思右想后决定从 4 月份开始备孕,并且一定要让孩子在次年 9 月之前出生。之所以有这么荒诞的想法是因为我国的入学政策是:“凡年满6周岁的儿童,其父母或者其他法定监护人应当送其入学接受并完成义务教育。”

  • “年满6周岁的儿童”即在新学年开始前也即在每年9月1日前年满6周岁的儿童、少年。
  • 换句话说就是:如果不是在 9 月 1 日之前出生的孩子得和次年的 9月1日之前的孩子一起上学。

这里有一个很有意思的内容,关于点点的高度不自洽,一方面天天讲以后孩子打螺丝能养活自己就好了,一方面又很焦虑孩子的入学时间,不然可能导致她/他以后读博可能有年龄压力(就半年也不至于吧,而且孩子一定想读博吗🤣)。所以未来会怎么样呢,这种极大的不确定真是让人又担心又期待。

经过半年的时间,周末各种跑医院,所幸预产期会是在 25年的 9 月前,缓解了点点下半年最大的烦恼,但随之而来的孕早期一系列的孕反,嗳气、呕吐、尿频等,十分不易。我也在反思自己,我的关心不够,好像真的只会抖机灵逗她开心。。。

公开表达元年

如果要说今年最有意义的是一件事情,那就是【从十月份开始时不时录制一些技术视频】,并把它分享在 B站上。正是因为这样的公开表达,最终收获了一些朋友的关注,尤其部分比较热心的人甚至会私信我表示感谢,这里面充满了正反馈,也让我感受到了一点点意义。这里简单讲一下相关的数据(以后全网基本都叫【chaofa用代码打点酱油】了)

  • B 站,累计获得播放 10W+,收获粉丝 4.7k,很有成就感。
    • 2024,公开表达元年-20241228224736884

我写博客其实还挺早的,但是根本没有人在看,没有什么反馈,基本属于自嗨。第一次自建博客是 17年,那个时候写了一个关于自己学习 React 的一些记录,但是后面读研之后不做前端了就把对应的内容删除了。后面受到【极客兔兔】在 V站发帖自建博客过程的影响,又开始第二次写自己的博客,这时候是 19年的 6 月份,也就是当前的博客:chaofa用代码打点酱油,改过很多次名字,但唯一值得高兴的时候,这个博客持续存在了 5年,里面记录了自己很多的碎碎念。

那么为什么今年我却把它称为【公开表达元年】,因为这一次不一样了。以前我想过写公众号,想过做视频,想过回答知乎问题,但是大多数都没有坚持下去。为此我今年反思了一下为什么以前没有持续下去?

  • 公开表达的羞耻。程序员圈子很小,做相同的岗位的就更少了,因此写的东西很容易被同事、朋友看见,这时候总会有一种羞耻感,会想自己是不是太装逼了,是不是说了什么不合时宜的话。此外,因为自己在互联网大厂上班,朋友转发你的内容给自己的时候还会调侃几句,比如「工作不饱和啊」之类的,这时候只能相视一笑。
    • 但将心比心,我自己看到那些在持续做内容的博主,是非常佩服他们持续输出表达的能力与毅力,因此别人看你亦如是,只要做得足够多,自然会得到别人的认可。
  • 懒惰的惯性。懒惰这件事是自己无论如何都没法推脱责任的,平常工作确实是挺累的,但是大多数情况下周末是有足够的时间去做【公开输出】的,但是短视频、动漫、各种信息流,真的太吸引人了,躺在床上不一会一天就过去了。
  • 输出的难度远大于输入。我们大多数人都只是互联网的世界的消费者,每天都会输入很多的内容,这就是因为要做输出是很难的。

大多数人都知道“公开表达是难却正确的事情”,但是真正的领悟者却不多,李笑来在《把时间当做朋友》一书中提到,互联网用户行为遵循 "90-9-1原则"。

  • 90% 的用户是潜水者,只消费内容。
  • 9% 的用户偶尔参与互动,如评论或分享。
  • 1% 的用户是主要的内容创作者。

不是说因为创作者特别稀疏我们采取成为创作者的,而是这其中有巨大的好处。

  1. 每个人都有被看见的需求,只有生产才有可能被人看见。
  2. 产生有价值的内容是能够帮助到别人的。我自己是从很多公开的博客或者公众号获取到了很多知识,很感谢他们,帮助我进步。
  3. 可以认识一些大佬。这也许不算是什么目的,不过人就是会相互吸引。只要内容有价值,就会有人去看,那么自然应该会认识一些大佬。举个例子:我以前一直听播客(硬地骇客),然后因为我开始做公开表达了之后,明年也许有机会一起录一期播客,这真的很赞。

那么后面我应该怎么做呢?

  1. 持续、体系化的发表我的学习思考,比如《LLMs101-from-zero-to-hero》,这个系列应该会比较有意思吧。(立个 flag,明年我想把它体系化成一本电子书)
  2. 持续在公开网络上宣传自己的内容。哪怕强如「苏剑林,苏神」,除了在自己的博客中发表文章之外,也会一些交流群发布自己的文章链接。现在的内容太多了,除了依赖于推荐算法,我们还是需要适当的去社交媒体传播自己的内容。尽管自己生产的内容肯定不是最优质的那一批,但从部分读者的反馈看,我的内容还是有一些价值的,所以要慢慢刨除宣传羞耻感。

职场深度求索

去年换工作之后,高强度的工作了一段时间,加上和岗位、老板的风格不是很适应,没干多久就感觉天天精疲力尽的,很快就想要辞职,但是迫于职业生涯的延续性,我自然是不敢真的就裸辞,因此在苦苦坚持,想要寻求一些方法延续自己的职业生涯,比如:自我鼓励——《工作,再坚持坚持》,理性分析——《如何在大厂工作六个月以上且保持一定的心理健康?》,只能说收效甚微。毕竟饿了就想吃饭,累了就会想休息,天经地义。

差不多待一年之后,就开始考虑活水转岗 or 换工作,不过深圳的就业机会还是较少,思来想去还是觉得活水合适一些。然后开始内部看一些机会,这个时候又涉及到去干什么业务的问题,所以很纠结到底干什么?继续和在腾讯一样做广告相关的业务,还是去做搜索,还是去做推荐,还是去做纯AIGC的业务,还是去做NLP相关的业务,最后兜兜转转又到了与我最有缘分的客服。

活水可能也算是一次跳槽吧,毕竟也要经过3轮技术面试,因此我基本把它当作全新的工作,工作方式也适当地做出一些改变。工作上一直在向表现好的大佬学习,希望明年工作上有一些突破。

回应去年

  • 健身
    • 去年完成的最好的事情,却是今年完成最差的事情
    • 全年锻炼加起来可能不足 30次吧,比去年少了 3倍有余,尤其是 6月之后基本就没怎么去过健身房了。
  • 播客-打点酱油。目标是录制 4 期,但实际上也只录制了两期。整体还算满意吧。
    • 不过有一个 highlight,有机会做客硬地骇客,这个很棒。
    • 2024,公开表达元年-20241228223121397
  • 博客
    • 基本达成去年定下的 20k pv,10k uv 的目标。明年的目标是继续翻倍,这个应该没有什么悬念,只要持续输出就应该比较容易达到吧。
    • 2024,公开表达元年-20241228222451159
  • 投资
    • 投资一塌糊涂。因为投资了A股和中概,亏损不容小觑。以后有机会可以写一个【程序员破产之路】系列文章。
    • 从去年结婚后,开始进行投资记账,一共 400多天,有知有行显示我的资金加权收益 -14%,年化收益 -11.32%。
  • 读书。今年几乎没怎么看书,很失败,不过是早有预期的。

展望明年

  • 工作。工作优先级还是很高,毕竟要是没有工作带来的现金流会很容易摧毁一个家庭,尤其是明年还有新的生命,持续的现金流还是非常的重要。
  • 投资。减少投资上的关注,减少个股的投资,个股投资不能超过仓位 10%,此外要减少中概的持仓,还是换成 ETF 更容易拿住。(把钱还我,我不想玩了
  • 公开表达。明年要继续做体系化的视频,多分享文章,希望能有更多的正反馈,比如读者邮件(🤣
  • 健康。希望家人都身体健康,明年再多多锻炼吧,明年再定一个 100 次/年的锻炼目标,30mins+/次。

手写大模型组件之Group Query Attention,从 MHA,MQA 到 GQA

2024-12-09 06:00:00

  • GQA(Group Query Attention)的优点:效果损失小,推理的时候可以加速(来自于kvcache小,内存取数少)。
  • 仔细阅读 MHA, MQA 和 GQA的区别,就会发现 MHA 和 MQA 都是 GQA 的特殊表达形式
    • 三者可以用同一套代码,只需要修改【GQA】代码里面的 nums_key_value_head 参数就可
    • nums_key_value_head 设置等于 1 就是 MQA
    • nums_key_value_head 设置等于 nums_head 就是 MHA

如果不喜欢看文字的同学可以查看 B站 或者 YouTube 视频。

B站:https://www.bilibili.com/video/BV1ZmqpYfEGY/

YouTube: https://www.youtube.com/watch?v=1jBW7qcyd7A&t=1s

multi-head self-attention

备注:也可以直接由 GQA 中修改参数得到。但是本代码更完整一些

import math
import torch
import torch.nn as nn

class MultiHeadAttention(nn.Module):
    def __init__(self, hidden_dim, nums_head) -> None:
        super().__init__()
        self.nums_head = nums_head

        # 一般来说,
        self.head_dim = hidden_dim // nums_head
        self.hidden_dim = hidden_dim

        # 一般默认有 bias,需要时刻主意,hidden_dim = head_dim * nums_head,所以最终是可以算成是 n 个矩阵
        self.q_proj = nn.Linear(hidden_dim, hidden_dim)
        self.k_proj = nn.Linear(hidden_dim, hidden_dim)
        self.v_proj = nn.Linear(hidden_dim, hidden_dim)

        # gpt2 和 bert 类都有,但是 llama 其实没有
        self.att_dropout = nn.Dropout(0.1)
        # 输出时候的 proj
        self.o_proj = nn.Linear(hidden_dim, hidden_dim)

    def forward(self, X, attention_mask=None):
        # 需要在 mask 之前 masked_fill
        # X shape is (batch, seq, hidden_dim)
        # attention_mask shape is (batch, seq)

        batch_size, seq_len, _ = X.size()

        Q = self.q_proj(X)
        K = self.k_proj(X)
        V = self.v_proj(X)

        # shape 变成 (batch_size, num_head, seq_len, head_dim)
        q_state = Q.view(batch_size, seq_len, self.nums_head, self.head_dim).permute(
            0, 2, 1, 3
        )
        k_state = K.view(batch_size, seq_len, self.nums_head, self.head_dim).transpose(
            1, 2
        )
        v_state = V.view(batch_size, seq_len, self.nums_head, self.head_dim).transpose(
            1, 2
        )
        # 主意这里需要用 head_dim,而不是 hidden_dim
        attention_weight = (
            q_state @ k_state.transpose(-1, -2) / math.sqrt(self.head_dim)
        )
        print(type(attention_mask))
        if attention_mask is not None:
            attention_weight = attention_weight.masked_fill(
                attention_mask == 0, float("-1e20")
            )

        # 第四个维度 softmax
        attention_weight = torch.softmax(attention_weight, dim=3)
        print(attention_weight)

        attention_weight = self.att_dropout(attention_weight)
        output_mid = attention_weight @ v_state

        # 重新变成 (batch, seq_len, num_head, head_dim)
        # 这里的 contiguous() 是相当于返回一个连续内存的 tensor,一般用了 permute/tranpose 都要这么操作
        # 如果后面用 Reshape 就可以不用这个 contiguous(),因为 view 只能在连续内存中操作
        output_mid = output_mid.transpose(1, 2).contiguous()

        # 变成 (batch, seq, hidden_dim),
        output = output_mid.view(batch_size, seq_len, -1)
        output = self.o_proj(output)
        return output


attention_mask = (
    torch.tensor(
        [
            [0, 1],
            [0, 0],
            [1, 0],
        ]
    )
    .unsqueeze(1)
    .unsqueeze(2)
    .expand(3, 8, 2, 2)
)

x = torch.rand(3, 2, 128)
net = MultiHeadAttention(128, 8)
net(x, attention_mask).shape

Group Query Attention

备注:以下代码省略了 attention_dropout attention_mask等情况的处理,真实实现过程中需要考虑。

import torch
import torch.nn as nn
import math

# 忽略了 attention_mask, attention_dropout; 
class GroupQueryAttention(nn.Module):
    def __init__(self, hidden_dim, nums_head, nums_key_value_head):
        super().__init__()
        assert hidden_dim % nums_head == 0 # 可以整除
        assert nums_head % nums_key_value_head == 0  # N 个 query head 为一组

        self.hidden_dim = hidden_dim
        self.nums_head = nums_head
        self.nums_key_value_head = nums_key_value_head
        self.head_dim = hidden_dim // nums_head

        # 初始化 qkv o
        self.q_proj = nn.Linear(hidden_dim, nums_head * self.head_dim)  # out feature_size (nums_head * head_dim)
        # k v out shape (nums_key_value_head * head_dim)
        self.k_proj = nn.Linear(hidden_dim, nums_key_value_head * self.head_dim)
        self.v_proj = nn.Linear(hidden_dim, nums_key_value_head * self.head_dim)

        self.o_proj = nn.Linear(hidden_dim, hidden_dim) # input_size nums_head * head_dim

    def forward(self, X, attention_mask=None):
        # X shape (batch, seq, hidden_dim)
        batch_size, seq, _ = X.size()

        # qkv projection
        q = self.q_proj(X)  # (batch, seq, hidden_dim)
        k = self.k_proj(X)
        v = self.v_proj(X) 

        # attention_weight 目标shape 是 (batch, nums_head, seq, seq)
        q = q.view(batch_size, seq, self.nums_head, self.head_dim)
        k = k.view(batch_size, seq, self.nums_key_value_head, self.head_dim)
        v = v.view(batch_size, seq, self.nums_key_value_head, self.head_dim)

        # 关注: nums_head 和 nums_key_value_head 的关系
        q = q.transpose(1, 2) # (b, nums_head, seq, head_dim)
        k = k.transpose(1, 2) # (b, nums_key_value_head, seq, head_dim)
        v = v.transpose(1, 2)  # (b, nums_key_value_head, seq, head_dim)

        # k v repeat; (广播操作)
        k = k.repeat_interleave(self.nums_head // self.nums_key_value_head, dim=1)
        v = v.repeat_interleave(self.nums_head // self.nums_key_value_head, dim=1)

        attention_score = (q @ k.transpose(2, 3)) / math.sqrt(self.head_dim)

        attention_weight = torch.softmax(attention_score, dim=-1)
        # (attention_mask 忽略) # 可以看前面的视频

        output = attention_weight @ v  # (b, nums_head, seq, head_dim)

        # output projection 变成 (b, seq, hidden_dim)
        output = output.transpose(1, 2).contiguous()
        final_output = self.o_proj(output.view(batch_size, seq, -1))

        return final_output

# 测试
x = torch.rand(3, 2, 128)
net = GroupQueryAttention(128, 8, 4)
net(x).shape

Multi Query Attention

由于 MQA 是 GQA 的一种特殊形式,因此只要在参数设置的时候将 nums_key_value_head = 1 就是 Multi Query Self-Attention。

交个朋友🤣

最后欢迎关注我,基本全网同名 chaofa用代码打点酱油