<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom"><title>干徒的博客</title><link href="https://ganto.me"/><id>https://ganto.me</id><link rel="self" href="https://ganto.me/static/atom.xml"/>
<updated>2026-09-22T00:00:00+08:00</updated>
<author><name>干徒</name></author>
<generator uri="https://ganto.me">website-rs</generator>
<entry>
  <title>第 21 课：GPU 加速训练与推理</title>
  <link href="https://ganto.me/post/rust-llm-guide-21"/>
  <id>https://ganto.me/post/rust-llm-guide-21</id>
  <updated>2026-09-22T00:00:00+08:00</updated>
  <published>2026-09-22T00:00:00+08:00</published>
  <author><name>干徒</name></author>
  <summary><![CDATA[用 wgpu 计算着色器（WGSL）把最耗时的算子搬到 GPU 上执行，支持 NVIDIA 与 Intel 核显，GPU 不可用时自动回退 CPU。]]></summary>
  <content type="html"><![CDATA[<h1>第 21 课：GPU 加速训练与推理</h1>
<blockquote>
<p>目标：用 wgpu 计算着色器（WGSL）把最耗时的算子搬到 GPU 上跑，
支持 NVIDIA 与 Intel 核显，同时保证&quot;GPU 不可用时自动回退 CPU&quot;。</p>
</blockquote>
<h2>1. 为什么选 wgpu</h2>
<ul>
<li><strong>跨平台</strong>：Windows 走 DX12 / Vulkan，NVIDIA 独显和 Intel 核显都能用；</li>
<li><strong>纯 Rust</strong>：不依赖 CUDA，也不引入深度学习框架；</li>
<li><strong>计算着色器</strong>：WGSL 语言手写算子，和写 CPU 的 for 循环思路一致，适合教学。</li>
</ul>
<h2>2. 架构总览</h2>
<pre><code>Cargo.toml          gpu feature（可选依赖 wgpu / pollster）
src/gpu.rs          GPU 上下文 + 4 个 WGSL 计算入口 + 同步取回
src/tensor.rs       matmul_data()：GPU 优先，失败回退 CPU
src/main.rs         gpu::init() + demo_gpu()（第 4 个演示）
</code></pre>
<ul>
<li><code>--features gpu</code> 开启；默认零 GPU 依赖，构建轻量。</li>
<li>初始化用 <code>OnceLock&lt;Option&lt;GpuContext&gt;&gt;</code>：失败静默置 None，后续自动走 CPU。</li>
</ul>
<h2>3. WGSL 计算着色器</h2>
<p>4 个计算入口共用同一个 ShaderModule（绑定声明是 module 级的）：</p>
<table>
<thead>
<tr>
<th>入口</th>
<th>计算</th>
<th>绑定</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>matmul_main</code></td>
<td><code>out[B,M,N] = a[B,M,K] @ b[B,K,N]</code></td>
<td>0:a 1:b 2:out 3:params</td>
</tr>
<tr>
<td><code>scale_main</code></td>
<td><code>out[i] = a[i] * s</code></td>
<td>0:a 2:out 3:params</td>
</tr>
<tr>
<td><code>add_main</code></td>
<td><code>out[i] = a[i] + b[i]</code></td>
<td>0:a 1:b 2:out 3:params</td>
</tr>
<tr>
<td><code>relu_main</code></td>
<td><code>out[i] = max(a[i], 0)</code></td>
<td>0:a 2:out 3:params</td>
</tr>
</tbody>
</table>
<p>参数统一走 16 字节 uniform：<code>struct Params { p0: u32, p1: u32, p2: u32, p3: u32 }</code>
（f32 标量用 <code>bitcast&lt;f32&gt;</code> 位模式传参）。</p>
<p>matmul 用三维 workgroup：<code>@workgroup_size(16,16,1)</code>，<code>global_invocation_id</code>
的 x/y/z 分别对应行/列/batch。<strong>tiled 版</strong>：每个 workgroup 负责一个 16×16 输出块，
先把 A/B 的 16×16 小块载入共享内存（<code>var&lt;workgroup&gt; sh_a/sh_b: array&lt;f32,256&gt;</code>），
再在片内做内积，把 K 维的全局内存读取从 K 次降到 K/16 次。</p>
<blockquote>
<p>坑：<code>sh_a</code> 的排布必须按 <code>(lid.x, lid.y)</code>（线程行对应输出行），若按习惯的
<code>(lid.y, lid.x)</code> 会行列错乱，矩阵乘结果全错（训练 loss 卡住不下降）。
另外不能在 barrier 之前对越界线程 <code>return</code>——dispatch 向上取整后同一 workgroup
内控制流分歧会让 <code>workgroupBarrier()</code> 变成未定义行为，应&quot;越界读补 0、写回再保护&quot;。</p>
</blockquote>
<h2>4. 踩坑记录</h2>
<ol>
<li><strong>WGSL 变量遮蔽</strong>：<code>let b = params.p0</code> 把全局 storage 数组 <code>b</code> 遮蔽成 u32，
再写 <code>b[...]</code> 报 <code>Invalid access into expression</code>。局部变量改名即可。</li>
<li><strong>uniform 数组对齐</strong>：uniform 地址空间数组 stride 必须 16 字节对齐，
<code>array&lt;u32,4&gt;</code> 实际占 64 字节；改用 4 个独立 u32 字段（16 字节）最省事。</li>
<li><strong>绑定编号</strong>：scale/relu 不用 binding 1，但声明仍是全局的；创建 bind group
时必须显式指定 binding 编号（0/2/3），不能从 0 连续排。</li>
<li><strong>wgpu 30 API</strong>：<code>PipelineLayoutDescriptor</code> 无 <code>push_constant_ranges</code>（用
<code>immediate_size</code>）、<code>bind_group_layouts</code> 元素是 <code>Option&lt;_&gt;</code>、
<code>PollType::Wait</code> 是带字段 struct、<code>get_mapped_range()</code> 返回 <code>Result</code>。</li>
<li><strong>沙箱限制</strong>：Windows 上 GPU 驱动会写 <code>NVIDIA DXCache</code>、<code>D3DSCache</code> 等目录，
受限环境需要放行，否则进程会被杀（程序自身会先打印完结果）。</li>
</ol>
<h2>5. 运行方式</h2>
<pre><code class="language-bash"># 普通运行（无 GPU，纯 CPU）
cargo run --release -- demo

# 开启 GPU 加速（含第 4 个演示：正确性 + 性能对比）
cargo run --release --features gpu -- demo

# 训练 / 推理加 --features gpu 即自动走 GPU
cargo run --release --features gpu -- train --config config.json
</code></pre>
<p>实测（NVIDIA GeForce MX150 / Vulkan）：</p>
<pre><code>512x512 矩阵乘：CPU 506.7ms vs GPU 57.0ms（快 8.9x）
批量矩阵乘 CPU vs GPU 最大误差 2.86e-6
逐元素算子（scale/relu/add）验证：通过
</code></pre>
<blockquote>
<p>注意：MX150 上 512×512 从 naive 的 57.6ms 只微降到 57.0ms——该规模下每次调用的
固定开销（上传/调度/同步取回）已接近计算时间，共享内存的收益被抵消。大模型训练
（如 n_embd=256、block=128、batch=16）每步有几十次 GPU 矩阵乘<strong>串行同步</strong>，
固定开销会累积，低端 GPU 上仍然偏慢。教学实现以&quot;清晰、可回退&quot;优先，不追求极致吞吐。</p>
</blockquote>
<h2>6. 动手练习</h2>
<ol>
<li>把 tiled 块从 16×16 改成 8×8 或 32×32，观察性能与占用率变化；</li>
<li>把 LayerNorm 也写成 WGSL 着色器，减少 CPU↔GPU 往返；</li>
<li>思考：为什么 GPU 矩阵乘没有&quot;快 50x&quot;？瓶颈在哪里（显存搬运、每次调用的同步取回）？</li>
<li>进阶：把一次 forward/backward 的多次 dispatch 合并提交、只在最后同步一次（计算图）。</li>
</ol>
]]></content>
  <category term="Rust"/>
  <category term="LLM"/>
  <category term="GPU"/>
</entry>
<entry>
  <title>第 20 课：学习率调度与收尾 —— warmup、cosine decay 与全项目总结</title>
  <link href="https://ganto.me/post/rust-llm-guide-20"/>
  <id>https://ganto.me/post/rust-llm-guide-20</id>
  <updated>2026-09-21T00:00:00+08:00</updated>
  <published>2026-09-21T00:00:00+08:00</published>
  <author><name>干徒</name></author>
  <summary><![CDATA[实现 warmup 与 cosine decay 学习率调度，并对整个项目做总结。]]></summary>
  <content type="html"><![CDATA[<h1>第 20 课：学习率调度与收尾 —— warmup、cosine decay 与全项目总结</h1>
<blockquote>
<p>代码位置：<a href="src/train.rs">src/train.rs</a>（<code>LRScheduler</code>、<code>train_gpt</code>）
调用处：<a href="src/main.rs">src/main.rs</a>（<code>demo_gpt</code> 里的训练配置）
配套代码：<a href="src/optim.rs">src/optim.rs</a>（<code>AdamW</code>，第 17 课）</p>
</blockquote>
<h2>1. 本课要搞懂的问题</h2>
<ol>
<li>学习率为什么不能&quot;一步到位&quot;？固定 lr 训练会出什么问题？</li>
<li>warmup 是干什么的？&quot;前 50 步 lr 从 0 线性升到 max&quot; 在代码里怎么写的？</li>
<li>cosine decay 长什么样？为什么末期要用越来越小的学习率？</li>
<li><code>LRScheduler</code> 的 <code>lr()</code> / <code>step()</code> 到底怎么算、怎么用？</li>
<li>训练日志里的 <code>lr 0.002947 → … → 0.000300</code> 是怎么一步步算出来的？</li>
<li>21 课全部学完，我手里有什么？下一步该往哪走？</li>
</ol>
<h2>2. 为什么需要学习率调度</h2>
<p>第 6 课我们学过梯度下降：<code>θ = θ - lr·g</code>，学习率 <code>lr</code> 决定每一步走多大。那&quot;全程用一个固定的 lr&quot;行不行？看看两种极端：</p>
<table>
<thead>
<tr>
<th>策略</th>
<th>优点</th>
<th>缺点</th>
</tr>
</thead>
<tbody>
<tr>
<td>固定大 lr（如 0.01）</td>
<td>前期下降快</td>
<td>后期在最优解附近<strong>来回震荡</strong>，loss 下不去，甚至发散</td>
</tr>
<tr>
<td>固定小 lr（如 0.0001）</td>
<td>后期能精细收敛</td>
<td>前期爬得太慢，训练时间翻几倍</td>
</tr>
<tr>
<td><strong>动态 lr（本课）</strong></td>
<td>前期稳、中期快、后期细</td>
<td>多两个超参数要调（warmup 步数、总步数）</td>
</tr>
</tbody>
</table>
<p>理想的 lr 轨迹是一条&quot;倒 U 形&quot;：</p>
<pre><code>lr
 │          ╭────────────────╮
 │         ╱                  ╲
 │        ╱                    ╲
 │  ╱────╱                      ╲
 │ ╱                             ╲
 └──────────────────────────────────▶ step
   0     warmup 结束           训练结束
         （lr 达到 max）       （lr 降到 min）
</code></pre>
<ul>
<li><strong>初期</strong>：lr 从很小开始（warmup），保护刚起步、还很脆弱的参数；</li>
<li><strong>中期</strong>：lr 维持在最大值附近，快速下降；</li>
<li><strong>后期</strong>：lr 逐渐变小（cosine decay），在损失曲面的&quot;沟底&quot;精细收敛，不会跳过最优解。</li>
</ul>
<p>这就是本课 <code>LRScheduler</code> 做的事情：<strong>先 warmup 线性爬升，再 cosine 曲线衰减</strong>。</p>
<h2>3. Warmup：先热个身</h2>
<h3>3.1 公式</h3>
<p>warmup 阶段（<code>step &lt; warmup_steps</code>），学习率从 0 线性升到最大值：</p>
<pre><code>lr(step) = max_lr × (step + 1) / warmup_steps
</code></pre>
<p>注意分子里的 <strong><code>step + 1</code></strong>：<code>step</code> 从 0 开始数，<code>step + 1</code> 保证第 0 步的 lr 是 <code>max_lr / warmup_steps</code>（而不是 0），
到第 <code>warmup_steps - 1</code> 步时 lr 正好是 <code>max_lr × warmup_steps / warmup_steps = max_lr</code>。</p>
<h3>3.2 为什么训练初期要用小学习率</h3>
<table>
<thead>
<tr>
<th>原因</th>
<th>解释</th>
</tr>
</thead>
<tbody>
<tr>
<td>初始参数是随机的</td>
<td><code>Linear::new</code> 用均匀分布初始化，此时梯度方向&quot;噪声很大&quot;，大步子容易把参数推飞</td>
</tr>
<tr>
<td>AdamW 的二阶动量还没&quot;热&quot;</td>
<td>第 17 课：AdamW 的 <code>v</code> 从 0 开始，前几步偏差修正后 <code>√v̂</code> 很小，有效步长会被放大——<strong>初期 Adam 反而更激进</strong>，需要小 lr 压住</td>
</tr>
<tr>
<td>大模型训练的标配</td>
<td>GPT-3、LLaMA 等真实大模型训练都用 warmup，是公认的稳定性技巧</td>
</tr>
</tbody>
</table>
<blockquote>
<p>直觉：刚上冰面的新手滑冰，先小步慢走找平衡（warmup），站稳了再加速（大 lr）。</p>
</blockquote>
<h2>4. Cosine decay：按余弦曲线收尾</h2>
<h3>4.1 公式</h3>
<p>warmup 结束后，学习率按<strong>半个余弦周期</strong>从 <code>max_lr</code> 衰减到 <code>min_lr</code>：</p>
<pre><code>progress = (step - warmup_steps) / (total_steps - warmup_steps)     // 0 → 1
cosine   = 0.5 × (1 + cos(π × progress))                            // 1 → 0
lr(step) = min_lr + (max_lr - min_lr) × cosine
</code></pre>
<h3>4.2 关键点</h3>
<table>
<thead>
<tr>
<th>progress</th>
<th>cos(π·progress)</th>
<th>cosine 系数</th>
<th>lr 取值</th>
</tr>
</thead>
<tbody>
<tr>
<td>0（刚出 warmup）</td>
<td>cos 0 = 1</td>
<td>1</td>
<td><code>max_lr</code></td>
</tr>
<tr>
<td>0.25</td>
<td>cos(π/4) ≈ 0.707</td>
<td>0.854</td>
<td><code>min + 0.854·(max-min)</code></td>
</tr>
<tr>
<td>0.5（半程）</td>
<td>cos(π/2) = 0</td>
<td>0.5</td>
<td><code>(max_lr + min_lr) / 2</code></td>
</tr>
<tr>
<td>0.75</td>
<td>cos(3π/4) ≈ -0.707</td>
<td>0.146</td>
<td><code>min + 0.146·(max-min)</code></td>
</tr>
<tr>
<td>1（训练结束）</td>
<td>cos π = -1</td>
<td>0</td>
<td><code>min_lr</code></td>
</tr>
</tbody>
</table>
<p>余弦曲线在两端（progress ≈ 0 和 ≈ 1）<strong>斜率平缓</strong>、在中间（progress ≈ 0.5）<strong>斜率最陡</strong>，翻译成 lr 就是：</p>
<ul>
<li>刚离开 warmup 时 lr 在 max 附近&quot;多停留一会儿&quot;（给足中期快速下降的时间）；</li>
<li>训练后期 lr 平缓地逼近 <code>min_lr</code>（在沟底精细搜索，不震荡）。</li>
</ul>
<blockquote>
<p>为什么末期要小 lr？损失曲面在高维空间里像&quot;沟壑&quot;，大 lr 会在沟底来回弹跳；小 lr 才能贴着沟底慢慢走到最低点。
而且末期 loss 下降本来就慢，用小 lr 不会拖慢多少，却能让最终 loss 更漂亮。</p>
</blockquote>
<p>我们的代码里 <code>min_lr</code> 取的是 <code>max_lr × 0.1</code>（见 5.3 节），即最终学习率衰减到最大值的十分之一。</p>
<h2>5. <code>LRScheduler</code> 实现讲解</h2>
<h3>5.1 结构体</h3>
<p><code>src/train.rs</code> 第 20 课小节：</p>
<pre><code class="language-rust">/// 学习率调度器：warmup + cosine decay
pub struct LRScheduler {
    warmup_steps: usize,
    total_steps: usize,
    max_lr: f32,
    min_lr: f32,
    step: usize,
}

impl LRScheduler {
    pub fn new(warmup_steps: usize, total_steps: usize, max_lr: f32, min_lr: f32) -&gt; Self {
        LRScheduler {
            warmup_steps,
            total_steps,
            max_lr,
            min_lr,
            step: 0,
        }
    }
    // ...
}
</code></pre>
<table>
<thead>
<tr>
<th>字段</th>
<th>含义</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>warmup_steps</code></td>
<td>warmup 持续多少步（前 N 步线性爬升）</td>
</tr>
<tr>
<td><code>total_steps</code></td>
<td>训练总步数（cosine 衰减的终点）</td>
</tr>
<tr>
<td><code>max_lr</code></td>
<td>学习率峰值</td>
</tr>
<tr>
<td><code>min_lr</code></td>
<td>学习率下限（衰减终点）</td>
</tr>
<tr>
<td><code>step</code></td>
<td><strong>当前已走到的步数计数</strong>（内部状态，从 0 开始）</td>
</tr>
</tbody>
</table>
<h3>5.2 <code>lr()</code>：按阶段算当前学习率</h3>
<pre><code class="language-rust">pub fn lr(&amp;self) -&gt; f32 {
    if self.step &lt; self.warmup_steps {
        // 线性 warmup
        self.max_lr * (self.step as f32 + 1.0) / self.warmup_steps.max(1) as f32
    } else {
        // cosine 衰减：从 max_lr 平滑降到 min_lr
        let progress = (self.step - self.warmup_steps) as f32
            / (self.total_steps - self.warmup_steps).max(1) as f32;
        let progress = progress.min(1.0);
        let cosine = 0.5 * (1.0 + (std::f32::consts::PI * progress).cos());
        self.min_lr + (self.max_lr - self.min_lr) * cosine
    }
}
</code></pre>
<p>公式 ↔ 代码对照表：</p>
<table>
<thead>
<tr>
<th>公式</th>
<th>Rust 代码</th>
<th>说明</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>lr = max_lr·(step+1)/warmup_steps</code></td>
<td><code>self.max_lr * (self.step as f32 + 1.0) / self.warmup_steps.max(1) as f32</code></td>
<td>warmup 分支。<code>.max(1)</code> 防止 <code>warmup_steps = 0</code> 时除零</td>
</tr>
<tr>
<td><code>progress = (step-warmup)/(total-warmup)</code></td>
<td><code>(self.step - self.warmup_steps) as f32 / (self.total_steps - self.warmup_steps).max(1) as f32</code></td>
<td>cosine 分支的进度，范围 <code>[0,1]</code></td>
</tr>
<tr>
<td><code>progress = min(progress, 1)</code></td>
<td><code>let progress = progress.min(1.0);</code></td>
<td>万一总步数被改小、<code>step</code> 超过 <code>total_steps</code>，progress 封顶 1，lr 停在 <code>min_lr</code> 不再下降</td>
</tr>
<tr>
<td><code>cosine = 0.5(1+cos(π·progress))</code></td>
<td><code>0.5 * (1.0 + (std::f32::consts::PI * progress).cos())</code></td>
<td>半个余弦周期，从 1 平滑到 0</td>
</tr>
<tr>
<td><code>lr = min_lr + (max_lr-min_lr)·cosine</code></td>
<td><code>self.min_lr + (self.max_lr - self.min_lr) * cosine</code></td>
<td>在 <code>[min_lr, max_lr]</code> 之间插值</td>
</tr>
</tbody>
</table>
<blockquote>
<p>注意分支边界：当 <code>step == warmup_steps</code> 时走 cosine 分支，此时 <code>progress = 0</code>、<code>cosine = 1</code>，lr 恰好等于 <code>max_lr</code>——
warmup 的终点和 cosine 的起点<strong>无缝衔接</strong>，lr 曲线没有断点。</p>
</blockquote>
<h3>5.3 <code>step()</code> 与在训练循环里的用法</h3>
<pre><code class="language-rust">pub fn step(&amp;mut self) {
    self.step += 1;
}
</code></pre>
<p><code>step()</code> 每训练一步调用一次，把内部计数 +1，从而推动 <code>lr()</code> 前进。在 <code>train_gpt</code> 里和 AdamW 配合：</p>
<pre><code class="language-rust">// src/train.rs train_gpt
let mut opt = AdamW::new(cfg.max_lr, params.clone(), cfg.weight_decay);
let mut scheduler = LRScheduler::new(cfg.warmup_steps, cfg.steps, cfg.max_lr, cfg.min_lr);
// ...
for step in start_step..cfg.steps {
    // ... 前向、反向、梯度裁剪 ...
    let cur_lr = scheduler.lr(); // 先取当前步的学习率（scheduler.step() 之后会变成下一步的）
    opt.lr = cur_lr;             // 把&quot;当前步的学习率&quot;写进优化器（AdamW.lr 是 pub 字段）
    opt.step();                  // 优化器用这个 lr 更新参数
    opt.zero_grad();
    scheduler.step();            // 步数 +1，为下一步准备新的 lr
    // ...
}
</code></pre>
<table>
<thead>
<tr>
<th>代码</th>
<th>作用</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>AdamW::new(cfg.max_lr, params.clone(), cfg.weight_decay)</code></td>
<td>优化器初始 lr 就是 <code>max_lr</code>，权重衰减来自 <code>TrainConfig</code></td>
</tr>
<tr>
<td><code>LRScheduler::new(cfg.warmup_steps, cfg.steps, cfg.max_lr, cfg.min_lr)</code></td>
<td><code>min_lr</code> 来自 <code>TrainConfig</code>（demo 里取 <code>max_lr × 0.1 = 3e-4</code>）</td>
</tr>
<tr>
<td><code>let cur_lr = scheduler.lr(); opt.lr = cur_lr;</code></td>
<td>每步<strong>先取当前步的 lr</strong> 再塞给优化器——<code>AdamW.lr</code> 是 <code>pub</code> 字段，可以直接赋值</td>
</tr>
<tr>
<td><code>scheduler.step()</code></td>
<td>计数器前进，下一轮循环 <code>lr()</code> 就是新的学习率</td>
</tr>
</tbody>
</table>
<p><strong>配合逻辑一句话</strong>：调度器负责&quot;算 lr&quot;，优化器负责&quot;用 lr 更新参数&quot;，两者通过 <code>opt.lr = scheduler.lr()</code> 对接。</p>
<h2>6. 训练日志解读：<code>lr 0.002947 → 0.000300</code></h2>
<h3>6.1 我们的训练配置</h3>
<p><code>src/main.rs</code> 的 <code>demo_gpt</code> 用 <code>TrainConfig</code> 传参（演示 3）：</p>
<pre><code class="language-rust">let tcfg = config::TrainConfig {
    seed: 42,
    batch_size: 8,
    steps: 600,
    max_lr: 3e-3,
    warmup_steps: 50,
    eval_every: 100,
    ..config::TrainConfig::default()
};
train::train_gpt(&amp;model, &amp;tokenizer, &amp;loader, &amp;tcfg, None, None, &amp;mut rng);
</code></pre>
<table>
<thead>
<tr>
<th>参数</th>
<th>值</th>
<th>含义</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>steps</code></td>
<td>600</td>
<td>训练总步数</td>
</tr>
<tr>
<td><code>batch_size</code></td>
<td>8</td>
<td>每步 8 个样本</td>
</tr>
<tr>
<td><code>max_lr</code></td>
<td><code>3e-3</code> = 0.003</td>
<td>学习率峰值</td>
</tr>
<tr>
<td><code>warmup_steps</code></td>
<td>50</td>
<td>前 50 步线性爬升</td>
</tr>
<tr>
<td><code>eval_every</code></td>
<td>100</td>
<td>每 100 步打印一次日志</td>
</tr>
<tr>
<td><code>min_lr</code>（默认）</td>
<td><code>3e-4</code></td>
<td><code>TrainConfig::default()</code> 提供，恰好 = max_lr × 0.1</td>
</tr>
</tbody>
</table>
<p>于是 <code>train_gpt</code> 内部：<code>min_lr = max_lr × 0.1 = 0.0003</code>，调度器为 <code>LRScheduler::new(50, 600, 0.003, 0.0003)</code>。</p>
<h3>6.2 日志打印的时机（避免看错）</h3>
<p><code>train_gpt</code> 里 <code>cur_lr</code> 在 <strong><code>scheduler.step()</code> 之前</strong>取值并用于更新：</p>
<pre><code class="language-rust">let cur_lr = scheduler.lr();   // 取&quot;本步&quot;的学习率（scheduler 还没 +1）
opt.lr = cur_lr;
opt.step();
opt.zero_grad();
scheduler.step();              // 之后才 +1（为下一步准备计数）
let last = step + 1 == cfg.steps;
if (step + 1) % cfg.eval_every == 0 || last {
    println!(
        &quot;step {:&gt;5} | lr {:.6} | train_loss {:.4}&quot;,
        step + 1,              // 日志从 1 开始数（显示 100、200、…）
        cur_lr,                // 打印的是&quot;本步&quot;的 lr
        loss.item()
    );
}
</code></pre>
<p>注意两个细节：一是日志里的 <code>step</code> 是 <code>step + 1</code>（循环变量从 0 开始，打印时 +1，显示 100、200、…）；
二是 <code>scheduler.step()</code> 虽然在打印之前执行，但打印的 <code>cur_lr</code> 是本步开头就取好的——所以<strong>日志里的 <code>lr</code> 就是&quot;本步实际用于参数更新的学习率&quot;</strong>，不存在错位（旧的实现把打印放在 <code>scheduler.step()</code> 之后、显示的是下一步的 lr，本轮修复后已对齐）。</p>
<h3>6.3 三个关键值怎么来的</h3>
<p><strong>① 第一个打印点 <code>0.002947</code>（step 100）</strong></p>
<p>demo 配置 <code>eval_every = 100</code>，所以第 100 次迭代（循环变量 step=99，scheduler 计数=99）才第一次打印：</p>
<pre><code>progress = (99 - 50) / (600 - 50) = 49/550 ≈ 0.089
cosine   = 0.5 × (1 + cos(π × 0.089)) ≈ 0.9805
lr = min_lr + (max_lr - min_lr) × cosine = 0.0003 + 0.0027 × 0.9805 ≈ 0.002947   ✓
</code></pre>
<p><strong>② warmup 段为什么看不到</strong></p>
<p><code>eval_every = 100 &gt; warmup_steps = 50</code>，warmup 段（step 0-49）没有任何打印点，所以日志里<strong>看不到</strong> 0.00006 起步的线性爬升。把 <code>eval_every</code> 改成 10 就能看到：step 10/20/30/40 的 lr 依次是 <code>0.0006 → 0.0012 → 0.0018 → 0.0024</code>（每步增加 <code>max_lr / warmup_steps = 0.003 / 50 = 0.00006</code>，10 步就是 0.0006），然后平滑进入 cosine 段。</p>
<p><strong>③ 最后 <code>0.000300</code>（step 600）</strong></p>
<p>最后一步（循环变量 step=599，计数=599）progress 封顶到 1，<code>cosine = 0.5 × (1 + cos π) = 0</code>：</p>
<pre><code>lr = min_lr + (max_lr - min_lr) × 0 = min_lr = 0.0003   ✓
</code></pre>
<h3>6.4 真实日志序列（<code>cargo run --release -- demo</code> 原样输出）</h3>
<table>
<thead>
<tr>
<th>日志 step</th>
<th>scheduler 计数</th>
<th>阶段</th>
<th>lr（打印值）</th>
<th>计算过程</th>
</tr>
</thead>
<tbody>
<tr>
<td>100</td>
<td>99</td>
<td>cosine</td>
<td><strong>0.002947</strong></td>
<td>progress = 49/550 ≈ 0.089，lr ≈ 0.00295</td>
</tr>
<tr>
<td>200</td>
<td>199</td>
<td>cosine</td>
<td>0.002540</td>
<td>progress = 149/550 ≈ 0.271，lr ≈ 0.00254</td>
</tr>
<tr>
<td>300</td>
<td>299</td>
<td>cosine</td>
<td>0.001850</td>
<td>progress = 249/550 ≈ 0.453，lr ≈ 0.00185</td>
</tr>
<tr>
<td>400</td>
<td>399</td>
<td>cosine</td>
<td>0.001096</td>
<td>progress = 349/550 ≈ 0.635，lr ≈ 0.00110</td>
</tr>
<tr>
<td>500</td>
<td>499</td>
<td>cosine</td>
<td>0.000518</td>
<td>progress = 449/550 ≈ 0.816，lr ≈ 0.00052</td>
</tr>
<tr>
<td>600</td>
<td>599</td>
<td>cosine 末</td>
<td><strong>0.000300</strong></td>
<td>progress ≈ 0.998（接近 1），落到 <code>min_lr</code></td>
</tr>
</tbody>
</table>
<p>这条曲线正是第 4.2 节那张表：<strong>先平、中陡、末缓</strong>。对比 loss 日志：前期 loss 快速下降（lr 大），
后期 loss 缓慢逼近平台（lr 小、精细收敛）——这就是&quot;调度&quot;在训练曲线上留下的痕迹。</p>
<blockquote>
<p>小技巧：把 <code>eval_every</code> 改成 10，就能看到 warmup 段每 10 步 +0.0006 的线性爬升，
以及 cosine 段更密的 lr 变化——对照 6.3 的公式验证每一步。</p>
</blockquote>
<h2>7. 全项目 21 课总结回顾</h2>
<p>对照 <a href="docs/00-%E5%AD%A6%E4%B9%A0%E8%AE%A1%E5%88%92.md">docs/00-学习计划.md</a> 的六个阶段，我们把&quot;从零写一个 LLM&quot;拆成了 21 课。回顾一下每阶段的成果：</p>
<h3>阶段一：地基 —— 张量与自动微分（第 1-4 课）</h3>
<table>
<thead>
<tr>
<th>课</th>
<th>主题</th>
<th>成果</th>
</tr>
</thead>
<tbody>
<tr>
<td>01</td>
<td>张量 Tensor</td>
<td><code>Tensor{data, shape}</code>，行优先存储、逐元素运算、矩阵乘法（<code>src/tensor.rs</code>）</td>
</tr>
<tr>
<td>02</td>
<td>自动微分 Autograd</td>
<td>计算图 + <code>backward</code>，链式法则自动求梯度</td>
</tr>
<tr>
<td>03</td>
<td>张量运算扩展</td>
<td>广播、sum/mean、softmax、广播减法</td>
</tr>
<tr>
<td>04</td>
<td>模块化重构</td>
<td>张量库独立成模块，为上层打基础</td>
</tr>
</tbody>
</table>
<p><strong>里程碑</strong>：只靠标准库就实现了深度学习框架的&quot;张量 + 自动微分&quot;核心，<code>cargo test</code> 一路绿灯。</p>
<h3>阶段二：神经网络基础（第 5-7 课）</h3>
<table>
<thead>
<tr>
<th>课</th>
<th>主题</th>
<th>成果</th>
</tr>
</thead>
<tbody>
<tr>
<td>05</td>
<td>线性层与激活函数</td>
<td><code>Linear</code>、ReLU/Sigmoid/Tanh，参数管理与初始化（<code>src/layers.rs</code>）</td>
</tr>
<tr>
<td>06</td>
<td>损失函数与优化器</td>
<td>交叉熵、MSE、SGD（<code>src/loss.rs</code>、<code>src/optim.rs</code>）</td>
</tr>
<tr>
<td>07</td>
<td>第一个 MLP</td>
<td>2→4→2 的 MLP <strong>100% 学会 XOR</strong>（<code>demo_xor</code>），验证了反向传播正确</td>
</tr>
</tbody>
</table>
<p><strong>里程碑</strong>：一个从零手写的神经网络成功收敛，证明自动微分没写错。</p>
<h3>阶段三：分词器（第 8 课）</h3>
<table>
<thead>
<tr>
<th>课</th>
<th>主题</th>
<th>成果</th>
</tr>
</thead>
<tbody>
<tr>
<td>08</td>
<td>BPE 分词器</td>
<td>字节级 BPE：词表 256 起步、迭代合并 400 次，<code>&quot;the garden&quot;</code> 被压缩成高频子词（<code>src/tokenizer.rs</code>）</td>
</tr>
</tbody>
</table>
<p><strong>里程碑</strong>：文本 → token id 的通路打通，<code>CharTokenizer</code> 为小语料演示服务。</p>
<h3>阶段四：Transformer 架构（第 9-12 课）</h3>
<table>
<thead>
<tr>
<th>课</th>
<th>主题</th>
<th>成果</th>
</tr>
</thead>
<tbody>
<tr>
<td>09</td>
<td>注意力机制</td>
<td>缩放点积注意力 <code>softmax(Q·Kᵀ/√d_k)</code>、因果掩码</td>
</tr>
<tr>
<td>10</td>
<td>多头注意力</td>
<td><code>MultiHeadAttention</code>：拆头、并行打分、拼接投影（<code>src/attention.rs</code>）</td>
</tr>
<tr>
<td>11</td>
<td>位置编码与归一化</td>
<td>正弦位置编码、LayerNorm、残差连接、pre-norm</td>
</tr>
<tr>
<td>12</td>
<td>完整 GPT 模型</td>
<td><code>GPT</code>：token embedding + 位置编码 + N 层 Block + 输出头，<code>GPTConfig::tiny</code>（n_embd=64, n_head=4, n_layer=2, block_size=32）</td>
</tr>
</tbody>
</table>
<p><strong>里程碑</strong>：GPT 的骨架完整成形——注意力找相关性、MLP 加工信息、归一化稳住数值、残差保梯度畅通。</p>
<h3>阶段五：训练与推理（第 13-16 课）</h3>
<table>
<thead>
<tr>
<th>课</th>
<th>主题</th>
<th>成果</th>
</tr>
</thead>
<tbody>
<tr>
<td>13</td>
<td>训练循环</td>
<td><code>train_gpt</code>：采样→前向→反向→梯度裁剪（<code>clip_grad_norm</code>）→更新→清零（<code>src/train.rs</code>）</td>
</tr>
<tr>
<td>14</td>
<td>数据加载</td>
<td><code>DataLoader</code>：随机切窗口，x/y 平移一位配对（<code>src/data.rs</code>）</td>
</tr>
<tr>
<td>15</td>
<td>推理与采样</td>
<td>temperature / top-k / top-p 采样，<code>sample_token</code> + <code>generate</code>（<code>src/sample.rs</code>）</td>
</tr>
<tr>
<td>16</td>
<td>训练小 GPT</td>
<td>600 步训练后能生成有模有样的英文短文本</td>
</tr>
</tbody>
</table>
<p><strong>里程碑</strong>：<code>cargo run</code> 一次跑通&quot;训练 → 生成&quot;，模型真的会&quot;写&quot;了。</p>
<h3>阶段六：进阶优化（第 17-21 课）</h3>
<table>
<thead>
<tr>
<th>课</th>
<th>主题</th>
<th>成果</th>
</tr>
</thead>
<tbody>
<tr>
<td>17</td>
<td>AdamW 优化器</td>
<td>一阶/二阶动量、偏差修正、解耦权重衰减（<code>src/optim.rs</code>）</td>
</tr>
<tr>
<td>18</td>
<td>KV Cache</td>
<td>推理时缓存历史 K/V，逐步生成只算新 token（<code>KVCache</code>，<code>src/attention.rs</code>）</td>
</tr>
<tr>
<td>19</td>
<td>RoPE 旋转位置编码</td>
<td><code>rotary_pair</code> 一次旋转 Q/K + <code>build_cos_sin_tab</code> 预计算查表（<code>src/rope.rs</code>）</td>
</tr>
<tr>
<td>20</td>
<td>学习率调度与收尾</td>
<td><code>LRScheduler</code>：warmup + cosine decay（<code>src/train.rs</code>）</td>
</tr>
<tr>
<td>21</td>
<td>GPU 加速（可选）</td>
<td>wgpu 计算着色器加速 matmul/scale/add/relu，失败自动回退 CPU（<code>src/gpu.rs</code>，<code>--features gpu</code>）</td>
</tr>
</tbody>
</table>
<h3>一路走下来的核心收获</h3>
<table>
<thead>
<tr>
<th>能力</th>
<th>对应课程</th>
<th>一句话总结</th>
</tr>
</thead>
<tbody>
<tr>
<td>张量与自动微分</td>
<td>1-4</td>
<td>一切框架魔法的底层原理</td>
</tr>
<tr>
<td>神经网络与优化</td>
<td>5-7、17</td>
<td>前向/反向/更新三板斧 + AdamW</td>
</tr>
<tr>
<td>文本处理</td>
<td>8</td>
<td>BPE 让模型学会&quot;拆词&quot;</td>
</tr>
<tr>
<td>Transformer</td>
<td>9-12</td>
<td>注意力 + 归一化 + 残差 + 位置编码</td>
</tr>
<tr>
<td>工程化训练</td>
<td>13-16、18、20</td>
<td>数据、训练循环、采样、KV cache、lr 调度</td>
</tr>
<tr>
<td>现代技巧</td>
<td>17-21</td>
<td>AdamW / KV cache / RoPE / 调度 / GPU，都是真实大模型在用的东西</td>
</tr>
</tbody>
</table>
<blockquote>
<p>验收标准（来自学习计划）：<code>cargo run</code> 正常输出 XOR 正确率、BPE 词表、训练日志与两段生成文本；
MLP 在 XOR 上收敛；小 GPT 生成通顺短文本——<strong>全部达成</strong>。</p>
</blockquote>
<h2>8. 后续展望：从这里出发</h2>
<p>21 课只是&quot;入门级 GPT&quot;的终点，却是真实 LLM 世界的起点。按性价比排序，值得继续的方向：</p>
<h3>8.1 数据和规模</h3>
<table>
<thead>
<tr>
<th>方向</th>
<th>现状</th>
<th>下一步</th>
</tr>
</thead>
<tbody>
<tr>
<td>语料</td>
<td><code>CORPUS</code> 只是一小段英文故事（约 700 字符）</td>
<td>换成更大语料（如 WikiText、OpenWebText 子集），模型才能学到真实语言结构</td>
</tr>
<tr>
<td>模型规模</td>
<td><code>GPTConfig::tiny</code>：64 维、2 层</td>
<td>增大 <code>n_embd</code>/<code>n_layer</code>/<code>block_size</code>（可开 <code>--features gpu</code> 加速）</td>
</tr>
<tr>
<td>训练步数</td>
<td>600 步 × batch 8</td>
<td>上万步 + 更大 batch，loss 才能逼近语料的理论下限</td>
</tr>
</tbody>
</table>
<h3>8.2 直接能做的代码级改进</h3>
<ul>
<li><strong>RoPE 已是完成态</strong>：第 19 课已把它接进 <code>MultiHeadAttention</code>（只旋转 Q/K、KV cache 缓存&quot;已旋转的 K&quot;），下一步可以直接试 8.3 的长度外推技巧；</li>
<li><strong>RMSNorm</strong>：把 LayerNorm 的均值项去掉，更快且效果相当（LLaMA 在用）；</li>
<li><strong>GQA / MQA</strong>：多头共享 KV，配合 KV cache 大幅省显存；</li>
<li><strong>更好的分词器</strong>：加大 BPE 合并次数 / 词表，或换 SentencePiece 风格。</li>
</ul>
<h3>8.3 架构前沿（了解即可）</h3>
<ul>
<li><strong>MoE（混合专家）</strong>：把每个 Block 的 MLP 换成多个&quot;专家&quot; + 一个路由器，每个 token 只激活少数专家。
参数量大幅增加但计算量可控（Mixtral、DeepSeek 系列就是代表）；</li>
<li><strong>长度外推</strong>：RoPE 训练长度外会失效，用 NTK-aware scaling、YaRN 等方法把上下文&quot;撑&quot;长；</li>
<li><strong>稀疏注意力 / Flash Attention</strong>：优化注意力本身的计算与 IO，长上下文必备。</li>
</ul>
<h3>8.4 真正的并行（从单卡到多卡 GPU）</h3>
<p>我们现在是<strong>单线程 CPU（可选单卡 GPU）、零依赖</strong>的教学实现，真实训练要靠并行：</p>
<table>
<thead>
<tr>
<th>并行方式</th>
<th>切分维度</th>
<th>一句话</th>
</tr>
</thead>
<tbody>
<tr>
<td>数据并行</td>
<td>batch</td>
<td>每张卡算一份 batch 的梯度，然后 all-reduce 求和，大家参数保持一致</td>
</tr>
<tr>
<td>张量并行</td>
<td>矩阵</td>
<td>把单个 <code>Linear</code> 的权重按列/按行切到多张卡，一起算一次前向</td>
</tr>
<tr>
<td>流水线并行</td>
<td>层</td>
<td>把 12 层切成几段，卡 1 算 1-4 层、卡 2 算 5-8 层……</td>
</tr>
</tbody>
</table>
<p>想实战的话，可以先把本项目移植到 <code>candle</code> / <code>burn</code> 等 Rust 深度学习框架上（它们封装了 GPU 和 autograd），
或者用 <code>rayon</code> 给张量运算加上多线程——那是&quot;工程优化&quot;的另一个故事了。</p>
<blockquote>
<p>学习这条路没有终点：<strong>从手写张量到训练小 GPT，你走完了框架背后的 90% 黑盒；剩下的 10% 是工程与规模。</strong></p>
</blockquote>
<h2>9. 动手练习</h2>
<ol>
<li><strong>改 warmup 步数</strong>：把 <code>main.rs</code> 里的 <code>warmup_steps</code> 从 50 改成 0（注意 <code>lr()</code> 里 <code>.max(1)</code> 会兜底，不会除零）和 200，分别训练，观察 loss 曲线差异——warmup 太短初期是否震荡？太长是否拖慢中期？</li>
<li><strong>改 min_lr</strong>：把 <code>TrainConfig::default()</code> 里的 <code>min_lr</code>（<code>3e-4</code>）改成 <code>3e-5</code> 和 <code>1.5e-3</code>，对比最终 loss。想想 min_lr 设太高会有什么后果（后期&quot;精细收敛&quot;没了）。</li>
<li><strong>绘制 lr 曲线</strong>：写个 10 行小程序（或用 6.4 的公式在表格/脚本里算一遍），以 step 为横轴画出 600 步的 lr 曲线，确认它是&quot;先线性爬升、再余弦衰减&quot;的倒 U 形。</li>
<li><strong>对比固定 lr</strong>：把 <code>train_gpt</code> 改成固定 <code>opt.lr = cfg.max_lr</code>（不调 <code>scheduler</code>），训练 600 步对比 loss——体会调度带来的&quot;同样的步数，更好的收敛&quot;。</li>
<li><strong>看懂日志</strong>：把 <code>eval_every</code> 从 100 改成 10，重新 <code>cargo run</code>，观察 warmup 段的 lr 爬升——它每步增加 <code>max_lr/warmup_steps = 0.003/50 = 0.00006</code>，所以每 10 步增加 0.0006（0.00006 → 0.00066 → 0.00126 → …），确认和公式一致。</li>
<li><strong>给 21 课写总结</strong>：按第 7 节的表格，自己列一份&quot;我掌握了什么&quot;清单，找出最薄弱的一课回去重读——这是收尾课最重要的作业。</li>
</ol>
<h2>10. 本课总结（也是全项目的结语）</h2>
<ul>
<li><strong>为什么需要调度</strong>：固定大 lr 后期震荡、固定小 lr 前期太慢，动态 lr 兼顾&quot;稳、快、细&quot;。</li>
<li><strong>warmup</strong>：前 <code>warmup_steps</code> 步 <code>lr = max_lr × (step+1)/warmup_steps</code> 线性爬升，保护训练初期的随机参数和 AdamW 未热身的动量。</li>
<li><strong>cosine decay</strong>：<code>lr = min_lr + (max_lr - min_lr) × 0.5 × (1 + cos(π·progress))</code>，从 <code>max_lr</code> 平滑衰减到 <code>min_lr</code>（本项目取 <code>max_lr × 0.1</code>）。</li>
<li><strong><code>LRScheduler</code></strong>：<code>lr()</code> 按 <code>step</code> 计数选分支算 lr，<code>step()</code> 推进一步；在 <code>train_gpt</code> 里通过 <code>opt.lr = scheduler.lr()</code> 喂给 AdamW。</li>
<li><strong>日志解读</strong>：日志显示 step 100→600，lr 从 <code>0.002947</code> 一路衰减到 <code>0.000300</code>（<code>min_lr</code>）；warmup 段（step 0-49）因 <code>eval_every=100</code> 没有打印点；打印的是<strong>本步</strong>实际用于更新的 <code>cur_lr</code>，没有错位。</li>
<li><strong>21 课全回顾</strong>：地基（张量/自动微分）→ 神经网络 → 分词器 → Transformer → 训练推理 → 现代技巧（AdamW/KV cache/RoPE/调度/GPU），<code>cargo run</code> 达成全部验收标准。</li>
<li><strong>展望</strong>：更大语料与模型、RMSNorm/GQA、MoE、真正的并行（数据/张量/流水线）、长度外推——每一步都能在这个项目上继续长出来。</li>
</ul>
<blockquote>
<p>最后一句：<strong>一个能跑的、完全由你手写的小 GPT，就是打开大模型世界最好的钥匙。</strong>
21 课结束，学习才刚刚开始。祝玩得开心！</p>
</blockquote>
]]></content>
  <category term="Rust"/>
  <category term="LLM"/>
  <category term="训练"/>
</entry>
<entry>
  <title>第 19 课：RoPE 旋转位置编码 —— 把「相对位置」揉进注意力</title>
  <link href="https://ganto.me/post/rust-llm-guide-19"/>
  <id>https://ganto.me/post/rust-llm-guide-19</id>
  <updated>2026-09-20T00:00:00+08:00</updated>
  <published>2026-09-20T00:00:00+08:00</published>
  <author><name>干徒</name></author>
  <summary><![CDATA[实现 RoPE 旋转位置编码，将相对位置信息直接编码进注意力分数。]]></summary>
  <content type="html"><![CDATA[<h1>第 19 课：RoPE 旋转位置编码 —— 把&quot;相对位置&quot;揉进注意力</h1>
<blockquote>
<p>代码位置：<a href="src/rope.rs">src/rope.rs</a>（<code>rotary_pair</code> 生产入口 + <code>rotary</code> 测试用 + <code>test_rotary</code> / <code>test_rotary_grad_exact</code> 测试）
配套代码：<a href="src/attention.rs">src/attention.rs</a>（RoPE 接入 <code>MultiHeadAttention</code>）
配套文档：<a href="docs/11-%E4%BD%8D%E7%BD%AE%E7%BC%96%E7%A0%81%E4%B8%8E%E5%BD%92%E4%B8%80%E5%8C%96.md">docs/11-位置编码与归一化.md</a></p>
</blockquote>
<h2>1. 本课要搞懂的问题</h2>
<ol>
<li>第 11 课的正弦位置编码有什么缺陷？为什么现代 LLM（LLaMA、Qwen、Gemma……）几乎清一色用 RoPE？</li>
<li>&quot;旋转&quot;怎么把位置信息编码进向量？公式 <code>x'_{2i} = x_{2i}·cosθ - x_{2i+1}·sinθ</code> 是怎么来的？</li>
<li>为什么说旋转是<strong>正交变换</strong>？范数不变这个性质重要在哪？</li>
<li>为什么 RoPE 的点积只与&quot;位置差&quot;有关？相比正弦编码强在哪？</li>
<li>为什么 RoPE 和 KV cache 是&quot;天生一对&quot;？推理时为什么只需要旋转新 token？</li>
<li><code>src/rope.rs</code> 里 <code>rotary_pair</code> 的前向、反向具体怎么实现？<code>test_rotary</code> 测了什么？</li>
</ol>
<h2>2. 先回顾：正弦位置编码的两个局限</h2>
<p>第 11 课我们实现了正弦位置编码，把位置向量<strong>加到</strong> token embedding 上：</p>
<pre><code>x = token_embedding + pos_emb
</code></pre>
<p>它解决了&quot;注意力看不见位置&quot;的问题，但有两个不足：</p>
<h3>2.1 编码的是&quot;绝对位置&quot;，模型得自己推断相对关系</h3>
<p>正弦编码给每个绝对位置 <code>pos</code> 一个固定向量，加到输入上。模型面对的其实是&quot;内容 + 绝对位置&quot;的混合表示，
<strong>&quot;A 在第 3 位、B 在第 7 位&quot; 这个信息是间接的</strong>——模型需要自己学会&quot;位置 7 减去位置 3 = 距离 4&quot;。</p>
<p>原论文证明了 <code>PE(pos+k)</code> 可以表示为 <code>PE(pos)</code> 的线性组合（三角恒等式），即&quot;理论上模型能学到相对距离&quot;，
但<strong>这只是给了模型一个机会，不是保证</strong>——要靠训练把这种规律学出来。</p>
<blockquote>
<p>直觉：正弦编码像给每个座位发一张写着&quot;座位号&quot;的号牌，模型得自己学会&quot;6 号和 2 号的号牌之差 = 隔了 4 排&quot;。
RoPE 的做法是直接把&quot;隔了几排&quot;编码进打分公式里，模型不用学这一步。</p>
</blockquote>
<h3>2.2 外推（extrapolation）差</h3>
<p>我们的模型 <code>block_size = 32</code>（<code>GPTConfig::tiny</code>），训练时位置只见过 <code>0..32</code>。如果推理时生成更长的序列：</p>
<ul>
<li>位置 32、33…… 的 pos_emb 向量虽然能算出来（正弦函数对任意 pos 都有定义），</li>
<li>但模型<strong>从没见过这种输入分布</strong>，注意力分数可能畸变，输出质量断崖式下跌。</li>
</ul>
<table>
<thead>
<tr>
<th>对比项</th>
<th>正弦编码（第 11 课）</th>
<th>RoPE（本课）</th>
</tr>
</thead>
<tbody>
<tr>
<td>注入方式</td>
<td>位置向量<strong>加</strong>到输入 embedding</td>
<td>对 Q/K 向量做<strong>旋转</strong></td>
</tr>
<tr>
<td>编码的信息</td>
<td>绝对位置（相对关系靠模型自己学）</td>
<td>相对位置（点积直接只依赖位置差）</td>
</tr>
<tr>
<td>作用位置</td>
<td>模型输入（所有层共享一份）</td>
<td>注意力内部（每层的 Q/K 各自旋转）</td>
</tr>
<tr>
<td>是否改变范数</td>
<td>加一个固定向量，范数会变</td>
<td>旋转保范数，输入表示&quot;只转向、不变长&quot;</td>
</tr>
<tr>
<td>KV cache 配合</td>
<td>也兼容，但位置信息&quot;粘&quot;在输入上</td>
<td>只需旋转新 token 的 Q/K，天然契合</td>
</tr>
</tbody>
</table>
<blockquote>
<p>补充：RoPE 也不是&quot;万能外推药&quot;。角度随位置线性增长，训练长度之外同样会失效。
业界用 <strong>NTK-aware scaling、YaRN</strong> 等技巧做长度外推，那是后话，本课先把 RoPE 本身讲透。</p>
</blockquote>
<h2>3. RoPE 原理：把 d 维拆成 d/2 对来旋转</h2>
<p>RoPE 出自论文 <em>RoFormer: Enhanced Transformer with Rotary Position Embedding</em>（Su et al., 2021）。</p>
<h3>3.1 二维旋转回顾</h3>
<p>在二维平面上，把向量 <code>(a, b)</code> 绕原点逆时针旋转角度 <code>θ</code>：</p>
<pre><code>a' = a·cosθ - b·sinθ
b' = a·sinθ + b·cosθ
</code></pre>
<p>写成矩阵就是旋转矩阵 <code>R(θ) = [[cosθ, -sinθ], [sinθ, cosθ]]</code>。</p>
<h3>3.2 把 d 维向量看成 d/2 个二维向量</h3>
<p>一个 <code>d</code> 维向量（d 必须为偶数），按<strong>相邻两两配对</strong>：</p>
<pre><code>(x₀, x₁), (x₂, x₃), (x₄, x₅), ..., (x_{d-2}, x_{d-1})
</code></pre>
<p>每一对看作平面上的一个点，第 <code>i</code> 对 <code>(x_{2i}, x_{2i+1})</code> 绕原点旋转角度：</p>
<pre><code>θ_i = pos / 10000^(2i/d)      i = 0, 1, ..., d/2 - 1
</code></pre>
<p>旋转后：</p>
<pre><code>x'_{2i}   = x_{2i}·cosθ_i - x_{2i+1}·sinθ_i
x'_{2i+1} = x_{2i}·sinθ_i + x_{2i+1}·cosθ_i
</code></pre>
<blockquote>
<p>注意频率方案 <code>10000^(2i/d)</code> 和正弦编码一模一样（第 11 课）：<code>i</code> 越小频率越高（角度变化快，管近处的精细位置），
<code>i</code> 越大频率越低（角度变化慢，管远处的粗略位置）。这样&quot;近距离靠高维、远距离靠低维&quot;的分工被保留了下来。</p>
</blockquote>
<h3>3.3 手算一个例子</h3>
<p>设 <code>d = 4</code>、位置 <code>pos = 1</code>，则有两对：</p>
<table>
<thead>
<tr>
<th>对</th>
<th>i</th>
<th>θ_i = 1 / 10000^(2i/4)</th>
<th>cos θ_i</th>
<th>sin θ_i</th>
</tr>
</thead>
<tbody>
<tr>
<td>(x₀, x₁)</td>
<td>0</td>
<td>1 / 1 = 1.0 rad</td>
<td>0.5403</td>
<td>0.8415</td>
</tr>
<tr>
<td>(x₂, x₃)</td>
<td>1</td>
<td>1 / 10000^0.5 = 1/100 = 0.01 rad</td>
<td>≈ 0.99995</td>
<td>≈ 0.01</td>
</tr>
</tbody>
</table>
<p>于是：</p>
<pre><code>x'₀ = x₀·0.5403 - x₁·0.8415
x'₁ = x₀·0.8415 + x₁·0.5403
x'₂ ≈ x₂·0.99995 - x₃·0.01
x'₃ ≈ x₂·0.01 + x₃·0.99995
</code></pre>
<p>第一对被旋转了整整 1 弧度（幅度很大，专门区分相邻位置），第二对几乎没动（幅度极小，负责长距离）。
<strong>同一个向量，位置不同旋转角度就不同</strong>——位置信息就这样被&quot;揉&quot;进了向量本身。</p>
<h2>4. 旋转是正交变换：范数不变</h2>
<p>旋转矩阵满足 <code>R(θ)ᵀ·R(θ) = I</code>（转置乘自己等于单位阵），且 <code>|det R| = 1</code>，因此它是<strong>正交矩阵</strong>：</p>
<pre><code>‖R(θ)·x‖² = (R(θ)x)ᵀ(R(θ)x) = xᵀR(θ)ᵀR(θ)x = xᵀx = ‖x‖²
</code></pre>
<p>也就是说<strong>旋转只改变向量的方向，不改变长度（范数）</strong>。这带来两个直接好处：</p>
<table>
<thead>
<tr>
<th>好处</th>
<th>解释</th>
</tr>
</thead>
<tbody>
<tr>
<td>数值稳定</td>
<td>旋转前后的数值范围一模一样，不会像&quot;加一个固定向量&quot;那样撑大或压扁激活值</td>
</tr>
<tr>
<td>不破坏归一化</td>
<td>向量进注意力之前刚过 LayerNorm，旋转不改范数，归一化统计量不会被破坏</td>
</tr>
</tbody>
</table>
<blockquote>
<p>对比一下：正弦编码是&quot;加法&quot;，加完 <code>‖x + pos_emb‖</code> 和 <code>‖x‖</code> 一般不同，多多少少会扰动数值分布；
RoPE 是&quot;乘法&quot;（乘以正交矩阵），<strong>只转不拉</strong>，范数严格不变。</p>
</blockquote>
<p>更妙的是：正交矩阵的逆就是它的转置，而转置恰好等于<strong>负角度旋转</strong>：</p>
<pre><code>R(θ)ᵀ = R(-θ)
</code></pre>
<p>这个性质在反向传播里极其好用——梯度回传时把角度取反再旋转一次就行（详见第 8 节）。</p>
<h2>5. 核心性质：相对位置（点积只与位置差有关）</h2>
<h3>5.1 先看 d = 2 的情形</h3>
<p>设查询在位置 <code>m</code>：<code>q_m = R(mθ)·q</code>；键在位置 <code>n</code>：<code>k_n = R(nθ)·k</code>。注意力分数是它们的点积：</p>
<pre><code>q_m · k_n = (R(mθ)q)ᵀ · (R(nθ)k)
          = qᵀ · R(mθ)ᵀ · R(nθ) · k
          = qᵀ · R(-mθ) · R(nθ) · k      （Rᵀ = R⁻¹ = 负角度）
          = qᵀ · R((n - m)θ) · k          （旋转矩阵乘法 = 角度相加）
</code></pre>
<p><strong>结果只依赖 <code>n - m</code>，与 m、n 各自的值无关！</strong></p>
<h3>5.2 推广到 d 维</h3>
<p>d 维的旋转矩阵是<strong>块对角矩阵</strong>：</p>
<pre><code>R(θ) = diag( R(θ₀), R(θ₁), ..., R(θ_{d/2-1}) )
</code></pre>
<p>块对角矩阵相乘等于各块分别相乘，所以 d 维下同样成立：</p>
<pre><code>q_m · k_n = Σ_i  q_{2i}ᵀ · R((n-m)·θ_i) · k_{2i}    只依赖 n - m
</code></pre>
<h3>5.3 为什么这比正弦编码强</h3>
<table>
<thead>
<tr>
<th></th>
<th>正弦编码</th>
<th>RoPE</th>
</tr>
</thead>
<tbody>
<tr>
<td>打分时依赖</td>
<td>绝对位置（模型要从 <code>pos_emb[m] + pos_emb[n]</code> 里反推相对关系）</td>
<td>直接就是相对位置 <code>n - m</code></td>
</tr>
<tr>
<td>模型要学吗</td>
<td>要额外学&quot;位置减法&quot;这一课</td>
<td>数学上已经编死，无需学</td>
</tr>
<tr>
<td>训练样本外</td>
<td>外推差</td>
<td>同样有外推问题，但相对性质使配合插值技巧更容易</td>
</tr>
</tbody>
</table>
<blockquote>
<p>一句话总结：<strong>正弦编码把位置塞进&quot;表示&quot;里，RoPE 把位置塞进&quot;打分&quot;里。</strong>
而注意力唯一关心位置的地方就是打分（q·k），所以 RoPE 做到了&quot;精确投放&quot;。</p>
</blockquote>
<h2>6. 与 KV cache 天然兼容：只旋转新 token</h2>
<p>回顾第 18 课的 KV cache 推理流程（<code>src/sample.rs</code> 的 <code>generate</code> + <code>src/attention.rs</code> 的 <code>KVCache</code>）：</p>
<ul>
<li>第一次：把整个 prompt 喂给模型，算出所有位置的 K/V 存入缓存；</li>
<li>之后每步：<strong>只前向最新 1 个 token</strong>，历史的 K/V 直接从缓存取。</li>
</ul>
<p>RoPE 和这个流程是无缝衔接的：</p>
<pre><code>新 token 的绝对位置 = 缓存长度 base + 它在当前窗口里的下标 j
</code></pre>
<p><code>base</code>（已缓存的位置数）由 <code>GPT::forward</code> 算出来传给每层（第 18 课的设计），<code>MultiHeadAttention::forward</code> 里 <code>positions</code> 就是这么构造的：</p>
<pre><code class="language-rust">// src/model.rs（GPT::forward）：base = 缓存长度
let base = kv_cache
    .as_ref()
    .map(|c| c.first().map(|k| k.seq_len()).unwrap_or(0))
    .unwrap_or(0);

// src/attention.rs（MultiHeadAttention::forward）：batch 内每个样本位置相同，重复 b 次
let mut positions = Vec::with_capacity(b * t);
for _ in 0..b {
    positions.extend(base..base + t);
}
</code></pre>
<p>于是接入 RoPE 时：</p>
<table>
<thead>
<tr>
<th>谁</th>
<th>怎么处理</th>
</tr>
</thead>
<tbody>
<tr>
<td>新 token 的 Q</td>
<td>按当前位置 <code>base + j</code> 旋转</td>
</tr>
<tr>
<td>新 token 的 K</td>
<td>按当前位置 <code>base + j</code> 旋转，再 append 进缓存</td>
</tr>
<tr>
<td>缓存里的历史 K</td>
<td>早就旋转好了，<strong>原样复用，绝不再动</strong></td>
</tr>
</tbody>
</table>
<p>因为旋转只依赖&quot;位置号&quot;，而每个 token 的位置号在生成时是唯一确定的，所以<strong>历史上每个 K 只旋转一次、终身有效</strong>。
相比&quot;每次生成都要重新取一段 pos_emb 加进输入&quot;的加法式编码，RoPE 是&quot;随算随旋&quot;，不需要任何历史重算。</p>
<blockquote>
<p>另一个视角：RoPE 在注意力内部旋转 Q/K，位置信息不会污染 token 的&quot;内容表示&quot;（残差流上的 x 始终是纯语义），
这也让每一层的 Q/K 都能用&quot;本层自己的位置感&quot;去打分。</p>
</blockquote>
<h2>7. 前向实现讲解：cos/sin 表 + 查表旋转</h2>
<p><code>src/rope.rs</code> 把实现拆成三层：先一次性<strong>预计算</strong> cos/sin 表，再用表<strong>查表旋转</strong>，Q/K 共用同一张表：</p>
<pre><code class="language-rust">/// 预计算每个 (位置, 对偶下标) 的 cos/sin 表，长度 rows × (D/2)。
/// 同一批 positions 的三角只算一次：前向、反向、Q/K 复用。
fn build_cos_sin_tab(positions: &amp;[usize], d: usize) -&gt; (Vec&lt;f32&gt;, Vec&lt;f32&gt;) {
    let rows = positions.len();
    let mut c_tab = vec![0.0f32; rows * (d / 2)];
    let mut s_tab = vec![0.0f32; rows * (d / 2)];
    for r in 0..rows {
        let pos = positions[r] as f32;
        for i in 0..d / 2 {
            let theta = pos / 10000f32.powf((2 * i) as f32 / d as f32);
            c_tab[r * (d / 2) + i] = theta.cos();
            s_tab[r * (d / 2) + i] = theta.sin();
        }
    }
    (c_tab, s_tab)
}

/// 用现成的 cos/sin 表旋转一个张量（[rows, D]），每对元素按公式旋转。
fn rotate_with_tab(x: &amp;Tensor, c_tab: &amp;[f32], s_tab: &amp;[f32]) -&gt; Tensor {
    let (rows, d) = (x.shape[0], x.shape[1]);
    let sd = x.data.borrow();
    let mut out_data = vec![0.0f32; rows * d];
    for r in 0..rows {
        for i in 0..d / 2 {
            let (c, s) = (c_tab[r * (d / 2) + i], s_tab[r * (d / 2) + i]);
            let (a, b) = (sd[r * d + 2 * i], sd[r * d + 2 * i + 1]);
            out_data[r * d + 2 * i] = a * c - b * s;
            out_data[r * d + 2 * i + 1] = a * s + b * c;
        }
    }
    drop(sd);
    // ...（反向闭包查同一张表、按 R(θ)ᵀ 回传，见第 8 节）
    result
}
</code></pre>
<p>对外只暴露两个入口——<code>rotary</code>（旋转单个张量，仅供测试）和 <code>rotary_pair</code>（一次建表同时旋转 Q/K）：</p>
<pre><code class="language-rust">pub fn rotary_pair(&amp;self, other: &amp;Tensor, positions: &amp;[usize]) -&gt; (Tensor, Tensor) {
    let d = self.shape[1];
    let (c_tab, s_tab) = build_cos_sin_tab(positions, d);
    (
        rotate_with_tab(self, &amp;c_tab, &amp;s_tab),
        rotate_with_tab(other, &amp;c_tab, &amp;s_tab),
    )
}
</code></pre>
<blockquote>
<p>为什么要&quot;先建表再查表&quot;？注意力里 Q 和 K 的 <code>positions</code> 完全相同，三角函数的 <code>cos/sin</code> 只要算一遍；
前向算一遍、反向闭包再查一遍——相比&quot;每次旋转现场重算三角&quot;，批量训练能省下可观的重复计算。</p>
</blockquote>
<p>逐行对照公式：</p>
<table>
<thead>
<tr>
<th>代码</th>
<th>对应公式</th>
<th>说明</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>assert_eq!(self.rank(), 2, ...)</code></td>
<td>—</td>
<td>输入必须是 2 维 <code>[rows, D]</code>：每行是一个待旋转的向量</td>
</tr>
<tr>
<td><code>assert_eq!(self.shape[0], positions.len(), ...)</code></td>
<td>—</td>
<td><code>positions[r]</code> 就是第 <code>r</code> 行的位置，数量必须一一对应</td>
</tr>
<tr>
<td><code>assert_eq!(d % 2, 0, ...)</code></td>
<td>—</td>
<td>最后一维必须是偶数，才能两两配对</td>
</tr>
<tr>
<td><code>let theta = pos / 10000f32.powf((2 * i) as f32 / d as f32);</code></td>
<td><code>θ_i = pos / 10000^(2i/d)</code></td>
<td>第 <code>i</code> 对的旋转角度，注意 <code>i</code> 的范围是 <code>0..d/2</code></td>
</tr>
<tr>
<td><code>let (c, s) = (theta.cos(), theta.sin());</code></td>
<td><code>cosθ_i, sinθ_i</code></td>
<td>一次算出，避免重复调用三角函数</td>
</tr>
<tr>
<td><code>let (a, b) = (sd[r*d + 2*i], sd[r*d + 2*i + 1]);</code></td>
<td><code>(x_{2i}, x_{2i+1})</code></td>
<td>取出第 r 行第 i 对的两个元素</td>
</tr>
<tr>
<td><code>out_data[r*d + 2*i] = a * c - b * s;</code></td>
<td><code>x'_{2i} = x_{2i}·cosθ - x_{2i+1}·sinθ</code></td>
<td>旋转后的第一个分量</td>
</tr>
<tr>
<td><code>out_data[r*d + 2*i + 1] = a * s + b * c;</code></td>
<td><code>x'_{2i+1} = x_{2i}·sinθ + x_{2i+1}·cosθ</code></td>
<td>旋转后的第二个分量</td>
</tr>
</tbody>
</table>
<p>几个值得注意的设计点：</p>
<ol>
<li><strong>三重循环的次序</strong>：建表时外层按行 <code>r</code>、内层按对 <code>i</code>，<code>cos/sin</code> 每个 <code>(r, i)</code> 只算一次；旋转时同样两层循环，但只做查表乘加，不再碰任何三角函数。</li>
<li><strong><code>drop(sd)</code></strong>：读完输入数据后立刻释放借用，之后才创建结果张量和反向闭包——避免闭包捕获时和 <code>self.data</code> 的借用纠缠。</li>
<li><strong><code>requires_grad</code> 分支</strong>：如果输入不需要梯度（比如纯推理），就直接返回普通结果，不建 <code>parents</code>/<code>backward</code>，省下计算图的维护开销。这和第 18 课 KV cache 推理时&quot;纯数据拼接、无梯度&quot;的思路一致。</li>
<li><strong>反向闭包查同一张表</strong>：反向闭包为了&quot;自包含&quot;（只捕获 <code>ct</code>/<code>st</code> 两份表拷贝 + <code>rg</code>/<code>sg</code> 两个 Rc），直接查表取值，<strong>不再重算 <code>theta/cos/sin</code></strong>。代价是闭包多持有两张表，好处是前向建的三角表被完整复用。</li>
</ol>
<h2>8. 反向实现讲解：正交矩阵的梯度</h2>
<h3>8.1 理论：梯度用&quot;转置&quot;回传</h3>
<p>设 <code>y = R(θ)·x</code>（旋转一对）。根据链式法则：</p>
<pre><code>∂L/∂x = R(θ)ᵀ · ∂L/∂y
</code></pre>
<p>因为旋转矩阵正交，<code>R(θ)ᵀ = R(-θ)</code>，所以<strong>梯度回传 = 把梯度按负角度再旋转一次</strong>。这就是方法注释里写的：</p>
<blockquote>
<p>&quot;每个 pair 的旋转矩阵正交，梯度用其转置（即负角度旋转）回传。&quot;</p>
</blockquote>
<p>展开来写，若记 <code>(ga, gb) = (∂L/∂y_{2i}, ∂L/∂y_{2i+1})</code>，则正确回传是：</p>
<pre><code>∂L/∂x_{2i}   = ga·cosθ + gb·sinθ
∂L/∂x_{2i+1} = -ga·sinθ + gb·cosθ
</code></pre>
<blockquote>
<p>验证（也可以硬算）：<code>y₀ = a·c - b·s</code>、<code>y₁ = a·s + b·c</code>，
<code>∂L/∂a = ga·(∂y₀/∂a) + gb·(∂y₁/∂a) = ga·c + gb·s</code> ✓
<code>∂L/∂b = ga·(∂y₀/∂b) + gb·(∂y₁/∂b) = -ga·s + gb·c</code> ✓</p>
</blockquote>
<h3>8.2 代码实际怎么写的（已按正确形式实现）</h3>
<p><code>src/rope.rs</code> 里 <code>rotate_with_tab</code> 反向闭包的循环体是（<code>c</code>/<code>s</code> 直接查 <code>ct</code>/<code>st</code> 表）：</p>
<pre><code class="language-rust">let (ga, gb) = (g[r * d + 2 * i], g[r * d + 2 * i + 1]);
// 反向 = 前向旋转矩阵的转置 R(θ)ᵀ：grad = (ga·c + gb·s, -ga·s + gb·c)
sgm[r * d + 2 * i] += ga * c + gb * s;
sgm[r * d + 2 * i + 1] += -ga * s + gb * c;
</code></pre>
<p>与 8.1 的结论逐行对应：第一行 <code>ga·c + gb·s</code>、第二行 <code>-ga·s + gb·c</code>，正是 <code>R(θ)ᵀ·g</code>。</p>
<blockquote>
<p>💡 <strong>陷阱提示</strong>：反向回传必须用<strong>转置</strong>（即负角度旋转）。写代码时容易手滑把符号写成正角度旋转
<code>(ga·c - gb·s, ga·s + gb·c)</code>——方向错了但梯度<strong>范数不变</strong>（正交变换保范数），
只校验范数的测试发现不了。所以测试要&quot;既测范数、又测方向&quot;（见第 9 节 <code>test_rotary_grad_exact</code>）。
本仓库初版实现确实写反过，已修复并补了逐元素断言。</p>
</blockquote>
<p>对训练的影响：本课已经把 RoPE 接进了 <code>MultiHeadAttention</code>（<code>src/attention.rs</code>），训练和 KV cache 推理都用它。
<code>GPT</code> 已不再有 <code>pos_emb</code> 字段，位置信息完全由注意力内部旋转 Q/K 提供（见第 10 节）。</p>
<h2>9. <code>test_rotary</code> 测试讲解</h2>
<p>测试在 <code>src/rope.rs</code> 的 <code>mod tests</code> 里，共三个断言，分别验证三件事：</p>
<pre><code class="language-rust">#[test]
fn test_rotary() {
    // 1. 旋转是正交变换：范数不变
    let x = Tensor::param(vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0], vec![1, 6]);
    let r = x.rotary(&amp;[3]);
    let orig_norm: f32 = x.data().iter().map(|v| v * v).sum();
    let rot_norm: f32 = r.data().iter().map(|v| v * v).sum();
    assert!((orig_norm - rot_norm).abs() &lt; 1e-3, &quot;范数应守恒：{} vs {}&quot;, orig_norm, rot_norm);

    // 2. pos=0 时所有角度为 0，等于恒等变换
    let x2 = Tensor::param(vec![1.0, 2.0, 3.0, 4.0], vec![1, 4]);
    let r2 = x2.rotary(&amp;[0]);
    assert!((r2.data()[0] - 1.0).abs() &lt; 1e-5);
    assert!((r2.data()[3] - 4.0).abs() &lt; 1e-5);

    // 3. 梯度：sum 的梯度是单位向量，经正交矩阵回传后范数不变（= 元素数）
    let x3 = Tensor::param(vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0], vec![1, 6]);
    let loss = x3.rotary(&amp;[2]).sum();
    loss.backward();
    let g: Vec&lt;f32&gt; = x3.grad();
    assert!((g.iter().map(|v| v * v).sum::&lt;f32&gt;() - 6.0).abs() &lt; 1e-3, &quot;梯度范数应为 6&quot;);
}
</code></pre>
<table>
<thead>
<tr>
<th>测试项</th>
<th>输入</th>
<th>断言</th>
<th>原理</th>
</tr>
</thead>
<tbody>
<tr>
<td>① 范数不变</td>
<td><code>[1,2,3,4,5,6]</code>（1×6），位置 <code>[3]</code></td>
<td>旋转前后范数平方相等（容差 1e-3）</td>
<td>旋转是正交变换，<code>‖Rx‖ = ‖x‖</code></td>
</tr>
<tr>
<td>② pos=0 恒等</td>
<td><code>[1,2,3,4]</code>（1×4），位置 <code>[0]</code></td>
<td>输出第 0、3 个元素不变</td>
<td><code>θ_i = 0/… = 0</code>，<code>cos0=1, sin0=0</code>，<code>R(0)=I</code></td>
</tr>
<tr>
<td>③ 梯度回传</td>
<td><code>[1,2,3,4,5,6]</code>，位置 <code>[2]</code>，<code>loss = sum</code></td>
<td>梯度范数平方 = 6（= 元素数）</td>
<td><code>sum</code> 的梯度是 <code>[1,1,...,1]</code>（范数平方 6），经正交矩阵回传后范数不变</td>
</tr>
</tbody>
</table>
<p>测试 ③ 的思路很巧妙：<code>∂sum/∂y</code> 是&quot;全是 1&quot;的单位向量，只要反向实现用的是正交矩阵（无论正角度还是负角度），
回传后梯度范数平方必然还是元素数 6。它验证了&quot;<strong>正交性</strong>&quot;，但正如 8.2 所说，验证不了&quot;<strong>方向</strong>&quot;。</p>
<p>仓库里还补了一个 <code>test_rotary_grad_exact</code>，专测<strong>方向</strong>：<code>d=2</code>、<code>pos=1</code> 时 <code>θ₀=1</code> rad，
对 <code>x=[1,2]</code>、<code>loss=sum</code>，期望梯度是 <code>R(θ)ᵀ·[1,1] = (cos1+sin1, -sin1+cos1) = (1.3818, -0.3012)</code>，
逐元素断言。有了它，反向符号写反会立刻红：</p>
<pre><code class="language-rust">#[test]
fn test_rotary_grad_exact() {
    let x = Tensor::param(vec![1.0, 2.0], vec![1, 2]);
    let loss = x.rotary(&amp;[1]).sum();
    loss.backward();
    let (c, s) = (1f32.cos(), 1f32.sin());
    let (ga, gb) = (c + s, -s + c);
    assert!((x.grad()[0] - ga).abs() &lt; 1e-5, &quot;grad[0] = {}&quot;, x.grad()[0]);
    assert!((x.grad()[1] - gb).abs() &lt; 1e-5, &quot;grad[1] = {}&quot;, x.grad()[1]);
}
</code></pre>
<p>运行测试：</p>
<pre><code class="language-bash">cargo test test_rotary    # 跑全部 rotary 测试
</code></pre>
<h2>10. 如何接入注意力：对 Q/K 应用</h2>
<h3>10.1 为什么只旋转 Q 和 K，不旋转 V</h3>
<p>注意力分数是 <code>q·k</code>（点积），位置信息只有在这里才&quot;有用武之地&quot;。而输出 <code>out = softmax(scores)·V</code> 是<strong>内容</strong>的加权求和，
V 是&quot;被加权的内容&quot;，不需要带位置信息。所以标准做法是：<strong>只旋转 Q 和 K，V 保持原样</strong>。</p>
<h3>10.2 接入点：<code>MultiHeadAttention::forward</code></h3>
<p>在 <code>src/attention.rs</code> 的 <code>MultiHeadAttention::forward</code> 里，Q/K/V 投影出来后是这样的：</p>
<pre><code class="language-rust">let q = self.c_q.forward(x).reshape(vec![b, t, d]); // [B, T, D]
let k = self.c_k.forward(x).reshape(vec![b, t, d]);
let v = self.c_v.forward(x).reshape(vec![b, t, d]);
</code></pre>
<p>RoPE 有两个可选的接入时机：</p>
<table>
<thead>
<tr>
<th>时机</th>
<th>形状</th>
<th>说明</th>
</tr>
</thead>
<tbody>
<tr>
<td>拆头之前</td>
<td><code>q/k</code> 是 <code>[B, T, D]</code></td>
<td>把整个 batch 展平成 <code>[B*T, D]</code> 一次旋转，代码最省事</td>
</tr>
<tr>
<td>拆头之后</td>
<td>每个头是 <code>[T, head_dim]</code></td>
<td>逐头旋转，更贴近&quot;每头各转各的&quot;的原始论文写法</td>
</tr>
</tbody>
</table>
<p>我们的 <code>rotary_pair</code> 接口（输入 <code>[rows, D]</code> + 每行的 <code>positions</code>）两种都支持，只要最后一维是偶数即可——
<code>GPTConfig::tiny</code> 里 <code>n_embd=64</code>、<code>head_dim=16</code>，都满足。</p>
<h3>10.3 实际接入方式（本仓库已接入）</h3>
<p><code>src/attention.rs</code> 里，Q/K 投影之后、KV cache 拼接<strong>之前</strong>旋转：</p>
<pre><code class="language-rust">// 位置：Q/K 投影之后、KV cache append 之前
let mut positions = Vec::with_capacity(b * t);
for _ in 0..b {
    positions.extend(base..base + t);
}
// Q/K 用同一批 positions，一次建表同时旋转（三角函数只算一遍）
let (q, k) = q
    .reshape(vec![b * t, d])
    .rotary_pair(&amp;k.reshape(vec![b * t, d]), &amp;positions);
let (q, k) = (
    q.reshape(vec![b, t, d]),
    k.reshape(vec![b, t, d]),
);
// 之后照旧：cache.append(&amp;k, &amp;v)（缓存里存的就是旋转后的 K）
</code></pre>
<p>要点：</p>
<ol>
<li><strong><code>positions</code> 长度必须等于行数</strong>（<code>rotary_pair</code> 里 <code>assert_eq!(self.shape[0], positions.len())</code>），这里行数是 <code>b*t</code>，所以 batch 内每个样本的同一列位置相同、要重复 <code>b</code> 次。</li>
<li><strong>旋转发生在 append 之前</strong>：缓存里存的是&quot;已旋转的 K&quot;，历史 K 直接复用，符合第 6 节的兼容性约定。</li>
<li><strong>Q 没有缓存</strong>（推理时 Q 永远只有新 token 一个），所以 Q 总是用当前位置旋转，天然正确。</li>
<li>由于 K/V 已旋转并缓存，<strong>推理模式（KV cache）和训练模式行为一致</strong>，不会像正弦编码那样需要区分两套位置逻辑。这一点有专门的测试守着（<code>src/model.rs</code> 的 <code>test_kv_cache_matches_full_forward</code>）。</li>
<li><code>rotary_pair</code> 的反向按 <code>R(θ)ᵀ</code> 实现并通过 <code>test_rotary_grad_exact</code> 逐元素验证。</li>
</ol>
<blockquote>
<p>替换还是叠加？本仓库选了<strong>替换</strong>：<code>GPT</code> 结构体里没有 <code>pos_emb</code> 字段（第 11 课的正弦编码在接入 RoPE 后已删除），
位置信息完全由注意力内部旋转 Q/K 提供。叠加方案（保留 <code>pos_emb</code> 再加 RoPE）一般没必要，且会稀释 RoPE 的相对位置特性。</p>
</blockquote>
<h2>11. 动手练习</h2>
<ol>
<li><strong>手算一对旋转</strong>：设 <code>d = 2</code>、<code>pos = 1</code>，对向量 <code>[1.0, 0.0]</code> 手算 <code>rotary</code> 的输出
（提示：<code>θ₀ = 1/10000^0 = 1</code> rad，<code>cos1 ≈ 0.5403, sin1 ≈ 0.8415</code>），再用 <code>x.rotary(&amp;[1])</code> 验证。</li>
<li><strong>验证梯度方向</strong>：<code>rotary</code> 的反向已修复为 <code>R(θ)ᵀ</code>（见第 8.2 节）。自己动手把测试改成&quot;正角度旋转&quot;的错误版本，
跑 <code>test_rotary</code> 和 <code>test_rotary_grad_exact</code>，观察只有后者能发现错误——体会&quot;既测范数、又测方向&quot;的价值。</li>
<li><strong>对照实际接入</strong>：对照 10.3 节的代码，在 <code>src/attention.rs</code> 里找到 RoPE 的接入位置。
试着把 <code>rotary_pair</code> 改成&quot;先 <code>rotary</code> 转 Q、再 <code>rotary</code> 转 K&quot;（各自建表），跑 <code>cargo test</code> 确认结果一致——体会 <code>rotary_pair</code> 一次建表省下的重复计算。</li>
<li><strong>验证相对位置性质</strong>：写一个测试——取同一个 <code>q</code>，在位置 <code>m</code> 旋转；取同一个 <code>k</code>，在位置 <code>n</code> 旋转，
断言 <code>q_m · k_n</code> 只和 <code>n - m</code> 有关（对固定的位置差 <code>d</code>，换不同的 <code>m</code> 结果应相同）。这正是第 5 节推导的代码验证。</li>
<li><strong>思考外推</strong>：把 <code>block_size</code> 从 32 改成 64 重新训练，再让模型生成超过训练长度的文本，
观察 RoPE 的输出质量在训练长度之外如何退化，想想为什么（角度随位置线性增长，训练长度之外照样失效）。</li>
<li><strong>思考</strong>：为什么 <code>θ_i = pos / 10000^(2i/d)</code> 里的 <code>10000</code> 和 <code>d</code> 与正弦编码一模一样？
如果把它改成 <code>10</code> 会怎样？（提示：所有维度频率变高，近距离区分更敏感、远距离更早&quot;打满&quot;一圈。）</li>
</ol>
<h2>12. 本课总结</h2>
<ul>
<li>
<p>正弦编码的两个局限：编码<strong>绝对位置</strong>（相对关系要模型自己学）、<strong>外推差</strong>（训练长度外没见过的输入分布）。</p>
</li>
<li>
<p>RoPE 把 d 维拆成 <strong>d/2 对</strong>，第 i 对按 <code>θ_i = pos / 10000^(2i/d)</code> 旋转：
<code>x'_{2i} = x_{2i}·cosθ - x_{2i+1}·sinθ</code>、<code>x'_{2i+1} = x_{2i}·sinθ + x_{2i+1}·cosθ</code>。</p>
</li>
<li>
<p>旋转是<strong>正交变换</strong>：<code>R(θ)ᵀR(θ) = I</code>，范数不变 → 数值稳定、不破坏 LayerNorm。</p>
</li>
<li>
<p><strong>相对位置性质</strong>：<code>q_m · k_n = qᵀR((n-m)θ)k</code>，点积只与位置差 <code>n - m</code> 有关——位置被&quot;编死&quot;进了打分公式。</p>
</li>
<li>
<p>与 <strong>KV cache 天然兼容</strong>：新 token 按 <code>base + j</code> 旋转自己的 Q/K，历史 K 已旋转、直接复用。</p>
</li>
<li>
<p><code>src/rope.rs</code> 的实现分三层：<code>build_cos_sin_tab</code> 预计算 cos/sin 表 → <code>rotate_with_tab</code> 查表旋转 → <code>rotary_pair</code> 一次建表同时旋转 Q/K；反向按正交矩阵的转置 <code>R(θ)ᵀ</code> 回传（已修复并通过 <code>test_rotary_grad_exact</code> 验证）。</p>
</li>
<li>
<p><code>test_rotary</code> 三个断言分别验证：正交性（范数不变）、<code>pos=0</code> 恒等、梯度范数守恒；<code>test_rotary_grad_exact</code> 逐元素验证梯度方向。</p>
</li>
<li>
<p>实际接入：在 <code>MultiHeadAttention</code> 拆头前、KV cache append 之前对 Q/K 旋转（只转 Q/K、不转 V），已完成接入；<code>GPT</code> 已无 <code>pos_emb</code> 字段（第 11 课正弦编码被替换）。</p>
</li>
<li>
<p>下一课（第 20 课）：收尾——学习率调度（warmup + cosine decay）与整个项目的总结回顾！</p>
</li>
</ul>
]]></content>
  <category term="Rust"/>
  <category term="LLM"/>
  <category term="注意力"/>
</entry>
<entry>
  <title>第 18 课：KV Cache —— 让逐 token 生成不再重复计算</title>
  <link href="https://ganto.me/post/rust-llm-guide-18"/>
  <id>https://ganto.me/post/rust-llm-guide-18</id>
  <updated>2026-09-19T00:00:00+08:00</updated>
  <published>2026-09-19T00:00:00+08:00</published>
  <author><name>干徒</name></author>
  <summary><![CDATA[实现 KV Cache 缓存历史键值，避免逐 token 生成时的重复计算。]]></summary>
  <content type="html"><![CDATA[<h1>第 18 课：KV Cache —— 让逐 token 生成不再重复计算</h1>
<blockquote>
<p>代码位置：<a href="src/attention.rs">src/attention.rs</a>（<code>KVCache</code> / <code>MultiHeadAttention</code>）
代码位置：<a href="src/model.rs">src/model.rs</a>（<code>GPT::forward</code>）
代码位置：<a href="src/sample.rs">src/sample.rs</a>（<code>generate</code>）
演示入口：<a href="src/main.rs">src/main.rs</a>（演示 3：生成 1 / 生成 2）</p>
</blockquote>
<h2>1. 本课要搞懂的问题</h2>
<ol>
<li>推理时为什么&quot;历史 token 的 K/V&quot;会被一遍遍重复计算？</li>
<li><code>KVCache</code> 的数据结构长什么样？<code>append</code> / <code>seq_len</code> 各做了什么？</li>
<li>cache 模式与全量模式在 <code>generate</code> 里的流程有什么不同（首次 vs 之后每步）？</li>
<li>用了缓存之后，为什么生成的概率分布和全量模式<strong>完全一样</strong>？</li>
<li>为什么缓存模式下上下文达到 <code>block_size</code> 就必须停止生成？</li>
</ol>
<h2>2. 问题：为什么历史 K/V 会被重复计算</h2>
<p>生成是<strong>逐 token</strong>的：每产生一个新 token，都要把它拼到上下文末尾，再前向一次，从输出的 logits 里采样下一个 token。</p>
<p>全量模式（第 16 课 <code>generate</code> 的 <code>use_kv_cache=false</code> 分支）每步都把<strong>整个上下文</strong>重新喂给模型：</p>
<pre><code>第 1 步：输入 [t0]               → 前向 1 个位置
第 2 步：输入 [t0, t1]           → 前向 2 个位置
第 3 步：输入 [t0, t1, t2]       → 前向 3 个位置
...
第 T 步：输入 [t0, t1, ..., tT]  → 前向 T 个位置
</code></pre>
<p>关键观察：<strong>第 k 步算出来的前 k-1 个位置的 K/V，和第 k-1 步算出来的一模一样</strong>——推理时权重冻结、输入前缀相同，同一个 Linear 层（<code>c_k</code>、<code>c_v</code>）对相同输入必然给出相同输出。</p>
<p>既然如此，为什么要重算？直接记住不就好了？这就是 KV Cache 的动机：</p>
<table>
<thead>
<tr>
<th></th>
<th>全量模式（每步重算）</th>
<th>KV Cache 模式</th>
</tr>
</thead>
<tbody>
<tr>
<td>每步前向的位置数</td>
<td>T（整个上下文，越来越大）</td>
<td>只有新来的 1 个位置</td>
</tr>
<tr>
<td>K/V 的计算量</td>
<td>O(T)，累计 O(T²)</td>
<td>每个位置只算一次，累计 O(T)</td>
</tr>
<tr>
<td>额外内存</td>
<td>无</td>
<td>存所有历史 K/V（O(T·D)）</td>
</tr>
</tbody>
</table>
<blockquote>
<p>注意：<strong>只有 K 和 V 需要缓存，Q 不用</strong>。因为预测&quot;下一个 token&quot;只关心新位置上的注意力输出，而它只需要新位置的 Q 去和所有历史位置的 K、V 做注意力。历史位置自己的注意力输出（以及它们的 Q）在生成中根本用不上。</p>
</blockquote>
<h2>3. KVCache 的结构</h2>
<p><code>src/model.rs</code> 里的定义：</p>
<pre><code class="language-rust">/// KV 缓存（第 18 课）：
/// 生成第 N 个 token 时，前 N-1 个 token 的 K、V 不需要重算。
/// 把每个注意力层的 K、V 存起来，每次只算新 token 的 K、V 并拼接。
pub struct KVCache {
    k: Option&lt;Tensor&gt;, // [1, T, D]
    v: Option&lt;Tensor&gt;,
}
</code></pre>
<table>
<thead>
<tr>
<th>字段</th>
<th>形状</th>
<th>含义</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>k</code></td>
<td><code>[1, T, D]</code></td>
<td>该层已缓存的所有位置的 Key（T = 已缓存位置数）</td>
</tr>
<tr>
<td><code>v</code></td>
<td><code>[1, T, D]</code></td>
<td>该层已缓存的所有位置的 Value</td>
</tr>
<tr>
<td><code>Option</code></td>
<td>——</td>
<td>空缓存 = <code>None</code>；一旦 append 过就一直是 <code>Some</code></td>
</tr>
</tbody>
</table>
<p>注意：<strong>每个注意力层各有一个 <code>KVCache</code></strong>。<code>GPT::new_kv_cache</code> 返回 <code>Vec&lt;KVCache&gt;</code>，长度 = <code>n_layer</code>（本项目 2 层）：</p>
<pre><code class="language-rust">pub fn new_kv_cache(&amp;self) -&gt; Vec&lt;KVCache&gt; {
    (0..self.cfg.n_layer).map(|_| KVCache::new()).collect()
}
</code></pre>
<h3>3.1 append：把新 K/V 拼到缓存尾部</h3>
<pre><code class="language-rust">fn append_data(prev: &amp;Option&lt;Tensor&gt;, cur: &amp;Tensor) -&gt; Tensor {
    match prev {
        Some(p) =&gt; {
            let mut all = p.data();
            all.extend(cur.data());
            let d = cur.shape()[2];
            Tensor::from_vec(all, vec![1, p.shape()[1] + 1, d])
        }
        None =&gt; cur.clone(),
    }
}

pub fn append(&amp;mut self, k: &amp;Tensor, v: &amp;Tensor) {
    self.k = Some(Self::append_data(&amp;self.k, k));
    self.v = Some(Self::append_data(&amp;self.v, v));
}
</code></pre>
<p>以 <code>[1, T, D]</code> 为例，<code>append_data</code> 做的事：</p>
<ol>
<li>取旧缓存 <code>p</code> 的<strong>数据</strong>（<code>p.data()</code>，一维展平数组）；</li>
<li>把新 K/V 的数据 <code>cur.data()</code> 拼到末尾；</li>
<li>按 <code>[1, 旧长度+1, D]</code> 重新包成张量。</li>
</ol>
<blockquote>
<p>细节：<code>cur</code> 在推理模式下形状是 <code>[1, 1, D]</code>（只算 1 个新位置），所以长度 +1；<code>d</code> 从 <code>cur.shape()[2]</code> 取。纯数据拼接，推理时无梯度，所以没有走任何 autograd 路径。</p>
</blockquote>
<h3>3.2 seq_len：已缓存了多少位置</h3>
<pre><code class="language-rust">pub fn seq_len(&amp;self) -&gt; usize {
    self.k.as_ref().map(|t| t.shape()[1]).unwrap_or(0)
}
</code></pre>
<ul>
<li>缓存为空（<code>None</code>）→ 0；</li>
<li>否则返回 <code>k</code> 张量的第 1 维大小，即已缓存的位置数。</li>
</ul>
<p>它有两个用途（后面会看到）：一是 <code>GPT::forward</code> 用它算位置偏移 <code>base</code>；二是 <code>generate</code> 用它判断要不要停止。</p>
<h2>4. MultiHeadAttention 怎么用缓存</h2>
<p><code>MultiHeadAttention::forward</code> 中与缓存相关的部分：</p>
<pre><code class="language-rust">// 1. 投影得到 Q、K、V（Linear 输出是 2D [B*T, D]，恢复成 3D）
let q = self.c_q.forward(x).reshape(vec![b, t, d]); // [B, T, D]
let k = self.c_k.forward(x).reshape(vec![b, t, d]);
let v = self.c_v.forward(x).reshape(vec![b, t, d]);

// 2. KV cache：拼接历史的 K/V（只影响 K、V 的长度）
let (k, v) = match kv_cache {
    Some(cache) =&gt; {
        cache.append(&amp;k, &amp;v);
        (cache.k().unwrap(), cache.v().unwrap())
    }
    None =&gt; (k, v),
};
let t_total = k.shape()[1];
</code></pre>
<p>变化只有一处：<strong>K、V 变长</strong>，Q 保持 <code>[B, T, D]</code> 不动：</p>
<table>
<thead>
<tr>
<th>变量</th>
<th>无缓存</th>
<th>有缓存（推理）</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>q</code></td>
<td><code>[B, T, D]</code></td>
<td><code>[B, 1, D]</code>（只算新位置）</td>
</tr>
<tr>
<td><code>k</code></td>
<td><code>[B, T, D]</code></td>
<td><code>[B, t_total, D]</code> = 新 <code>[B,1,D]</code> 拼上缓存</td>
</tr>
<tr>
<td><code>v</code></td>
<td><code>[B, T, D]</code></td>
<td><code>[B, t_total, D]</code></td>
</tr>
<tr>
<td><code>t_total</code></td>
<td>= T</td>
<td>= 缓存长度 + 本次新增（本项目每次 +1）</td>
</tr>
</tbody>
</table>
<p>后续的拆头、注意力分数、softmax 等代码<strong>一行都不用改</strong>，因为它们是按 <code>t_total</code> 写的通用代码：</p>
<ul>
<li>拆头时 k/v 用 <code>t_total</code> 做 reshape（<code>vec![b, t_total, self.n_head, head_dim]</code>），q 仍用 <code>t</code>；</li>
<li>分数 <code>scores = q.matmul(&amp;kt).mul_scalar(scale)</code> 形状 <code>[B*H, t, t_total]</code>；</li>
<li>因果掩码 mask 是 <code>[t, t_total]</code>，广播相加后 <code>softmax_last_dim()</code>，最后 <code>attn.matmul(&amp;v)</code>。</li>
</ul>
<p>这就是 KV Cache 优雅的地方：<strong>模型代码零侵入，只把&quot;输入 K/V 的来源&quot;从&quot;当场算&quot;换成&quot;缓存里取&quot;</strong>。</p>
<h2>5. GPT::forward 里的 base 偏移</h2>
<p>推理模式下，新 token 的位置不再是&quot;序列内的第 j 个&quot;，而是&quot;全局的第 base + j 个&quot;。<code>GPT::forward</code> 这样处理：</p>
<pre><code class="language-rust">// 2. 位置编码：KV cache 推理时，当前位置从缓存长度开始
let base = kv_cache
    .as_ref()
    .map(|c| c.first().map(|k| k.seq_len()).unwrap_or(0))
    .unwrap_or(0);
let mut positions = Vec::with_capacity(b * t);
for _ in 0..b {
    for j in 0..t {
        positions.push(base + j);
    }
}
...
// 3. 因果掩码：scores 形状 [B*H, T, T_total]，广播 mask [T, T_total]
let t_total = t + base;
let mut mask_data = vec![0.0f32; t * t_total];
for i in 0..t {
    for j in 0..t_total {
        if j &gt; i + base {
            mask_data[i * t_total + j] = f32::NEG_INFINITY;
        }
    }
}
</code></pre>
<table>
<thead>
<tr>
<th>量</th>
<th>全量模式（base=0）</th>
<th>缓存模式</th>
</tr>
</thead>
<tbody>
<tr>
<td>位置编码行号</td>
<td><code>j</code>（0..t）</td>
<td><code>base + j</code>（从缓存长度继续往后数）</td>
</tr>
<tr>
<td>掩码总宽</td>
<td><code>t</code></td>
<td><code>t_total = t + base</code></td>
</tr>
<tr>
<td>掩码规则</td>
<td><code>j &gt; i</code> 禁止（只能看自己及之前）</td>
<td><code>j &gt; i + base</code> 禁止（新 token 只能看缓存里的历史 + 自己）</td>
</tr>
</tbody>
</table>
<blockquote>
<p>为什么掩码的下界是 <code>base</code>：新 token 在全局序列里的下标从 <code>base</code> 开始（<code>i=0</code> 对应全局 <code>base</code>），所以它能看全局 <code>0..=base</code>（全是缓存里的历史）+ 自己，不能看 <code>base+1</code> 之后（未来）。这和全量模式的因果性完全一致。</p>
</blockquote>
<p>训练时 <code>kv_cache</code> 传 <code>None</code>（<code>src/train.rs</code> 里 <code>model.forward(&amp;x, b, t, None)</code>），因为训练时权重每步都在变、历史 K/V 没有复用价值，缓存反而白占内存。</p>
<h2>6. generate：cache 模式 vs 全量模式的流程对比</h2>
<p><code>src/sample.rs</code> 的 <code>generate</code>：</p>
<pre><code class="language-rust">let block_size = model.cfg.block_size;
let mut ids = tokenizer.encode(prompt);
let mut cache = model.new_kv_cache();

for _ in 0..max_new {
    // KV cache 模式：上下文总长达到 block_size 就停（缓存无法像全量模式那样截断历史）
    if use_kv_cache &amp;&amp; cache[0].seq_len() &gt;= block_size {
        break;
    }
    // 只保留最近的 block_size 个 token（全量模式需要）
    let start = ids.len().saturating_sub(block_size);
    let ctx = &amp;ids[start..];

    let logits = if use_kv_cache {
        // 首次：缓存为空，把整个 prompt 喂进去（顺便填充缓存）
        // 之后：每步只前向最新 1 个 token，历史 K/V 从缓存取
        if cache[0].seq_len() == 0 {
            model.forward(ctx, 1, ctx.len(), Some(&amp;mut cache))
        } else {
            model.forward(&amp;ids[ids.len() - 1..], 1, 1, Some(&amp;mut cache))
        }
    } else {
        // 全量模式：每次把整个上下文重新算一遍（慢，但没有 cache 内存）
        model.forward(ctx, 1, ctx.len(), None)
    };

    // 取最后一个位置的 logits
    let v = model.cfg.vocab_size;
    let n = logits.numel();
    let last_row = &amp;logits.data()[n - v..];
    let next = sample_token(last_row, temperature, top_k, top_p, rng);
    ids.push(next);
}
</code></pre>
<p>两种模式逐项对比：</p>
<table>
<thead>
<tr>
<th></th>
<th>全量模式（无缓存）</th>
<th>KV cache 模式</th>
</tr>
</thead>
<tbody>
<tr>
<td>首次前向</td>
<td><code>forward(ctx, 1, ctx.len(), None)</code></td>
<td><code>forward(ctx, 1, ctx.len(), Some(&amp;mut cache))</code>：同样前向整个 prompt，但<strong>把每层 K/V 顺手存进缓存</strong></td>
</tr>
<tr>
<td>之后每步</td>
<td><code>forward(ctx, 1, ctx.len(), None)</code>：整个上下文（截断到最近 32 个）重算</td>
<td><code>forward(&amp;ids[ids.len()-1..], 1, 1, Some(&amp;mut cache))</code>：<strong>只喂最后一个 token</strong>，K/V 从缓存取</td>
</tr>
<tr>
<td>上下文处理</td>
<td><code>ids.len().saturating_sub(block_size)</code> 截断，窗口可滑动</td>
<td>不截断，全量积累在缓存里</td>
</tr>
<tr>
<td>停止条件</td>
<td>生成满 <code>max_new</code> 个</td>
<td>生成满 <code>max_new</code> 个，<strong>或缓存长度达到 <code>block_size</code></strong></td>
</tr>
<tr>
<td>每次前向的位置数</td>
<td>32（封顶后固定）</td>
<td>首次 prompt 长度，之后恒为 1</td>
</tr>
</tbody>
</table>
<p>用流程图看第一次生成（prompt = &quot;The fox&quot;，7 个 token，max_new=80）：</p>
<pre><code>cache 模式：                             全量模式：
───────────                             ───────────
第 1 步：前向 [&quot;The fox&quot;(7个)]          第 1 步：前向 [&quot;The fox&quot;(7个)]
         ↓ 填充缓存（7 个位置）                    ↓ 结果只取最后一行，丢弃其余
         采样出第 1 个新 token
第 2 步：前向 [最新 1 个]               第 2 步：前向 [&quot;The fox&quot; + 1个]（8 个）
         ↓ 缓存 = 8 个位置                       ↓ 又从头算了一遍 7 个历史 K/V
第 3 步：前向 [最新 1 个]               第 3 步：前向 [9 个]
         ↓ 缓存 = 9 个位置                       ↓ 重复劳动越来越多
...                                    ...
第 26 步：前向 [最新 1 个]              第 80 步：前向 [窗口内 32 个]
         ↓ 缓存 = 32 个位置                      ↓ 80 个新 token 全部生成
         采样出第 26 个新 token
第 27 步：开头检查缓存 = 32 ≥ block_size → 停止
</code></pre>
<blockquote>
<p>取 logits 的细节：<code>generate</code> 只取输出张量的<strong>最后一行</strong>（<code>logits.data()[n - v..]</code>，v = vocab_size）。全量模式算了一整段序列，但生成只需要最后一个位置的预测——前半部分的计算全部是&quot;浪费&quot;；缓存模式干脆只算最后一行需要的东西，正是这种浪费的反面。</p>
</blockquote>
<h2>7. 为什么输出分布不变</h2>
<p>这是 KV Cache 正确性的核心论证，分三步：</p>
<ol>
<li><strong>K/V 值相同</strong>：推理时权重冻结。缓存里存的 K/V，与全量模式下同一批输入算出来的 K/V，数值<strong>逐位相同</strong>（都是同一份代码算的）。</li>
<li><strong>注意力计算相同</strong>：新位置的注意力输出 = <code>softmax(Q_k·Kᵀ/√d + mask) · V</code>。其中 K、V 是&quot;全部历史&quot;（缓存模式从缓存取、全量模式当场算），数值相同；Q 是新位置的投影，也相同。</li>
<li><strong>softmax 结果相同</strong>：mask 规则一致（第 5 节已证），同一组分数经过同样的 softmax → 同样的概率分布 → 同样的采样分布。</li>
</ol>
<p>用一句话概括：<strong>缓存只是把&quot;这次算完就扔&quot;的中间结果留了下来，计算路径和数值一个都没变，所以分布必然不变。</strong></p>
<p>代码注释也点明了这一点（<code>src/main.rs</code>）：</p>
<pre><code class="language-rust">println!(&quot;\n  （KV cache 只改计算方式、不改生成分布，两者应高度一致）&quot;);
</code></pre>
<blockquote>
<p>演示里的&quot;验证&quot;其实是间接的：demo 用了两个不同的 prompt（&quot;Once upon a&quot; vs &quot;The fox&quot;）和同一个 rng 序列，所以两段输出文本不同是正常的。想严格验证&quot;分布一致&quot;，应该<strong>用相同 prompt + 相同 rng 种子</strong>分别跑 <code>use_kv_cache=false</code> 和 <code>true</code>，对比逐 token 输出是否逐位一致——这是动手练习 1。</p>
</blockquote>
<h2>8. 上下文达到 block_size 后停止生成</h2>
<p>真实输出里能直接看到这个机制（第 16 课跑出来的）：</p>
<pre><code>  —— 生成 2（temperature=0.8, top-k=10, top-p=0.9, 带 KV cache）——
  The fox the hidden garden, whe li
</code></pre>
<p>数一下：&quot;The fox&quot; = 7 个 token，续写 <code> the hidden garden, whe li</code> = 26 个 token，<strong>输出共 7 + 26 = 33 个字符</strong>。逐迭代看缓存怎么涨的：</p>
<table>
<thead>
<tr>
<th>迭代</th>
<th>前向内容</th>
<th>前向之后缓存长度</th>
<th>采样出新 token</th>
</tr>
</thead>
<tbody>
<tr>
<td>第 1 步</td>
<td>整个 prompt（7 个）</td>
<td>7</td>
<td>第 1 个</td>
</tr>
<tr>
<td>第 2 步</td>
<td>最新 1 个</td>
<td>8</td>
<td>第 2 个</td>
</tr>
<tr>
<td>...</td>
<td>...</td>
<td>...</td>
<td>...</td>
</tr>
<tr>
<td>第 26 步</td>
<td>最新 1 个</td>
<td>32</td>
<td>第 26 个</td>
</tr>
<tr>
<td>第 27 步</td>
<td>——（循环开头检查）</td>
<td>32 ≥ 32 → <strong>break</strong></td>
<td>——</td>
</tr>
</tbody>
</table>
<p>也就是说，生成到第 26 个新 token 后，下一次循环开头检查：</p>
<pre><code class="language-rust">if use_kv_cache &amp;&amp; cache[0].seq_len() &gt;= block_size {
    break;
}
</code></pre>
<p>此时缓存（prompt 7 个 + 已前向的 25 个新 token）恰好等于 32 = block_size，直接跳出——所以 <code>max_new=80</code> 根本没跑完，输出戛然而止。注意最后采样的第 26 个 token 甚至<strong>没有参与前向、也没进缓存</strong>（它只是被采样并 push 进 <code>ids</code>，下一次循环就 break 了）。</p>
<p><strong>为什么必须停？</strong> 三个原因，都指向同一个根：</p>
<table>
<thead>
<tr>
<th>原因</th>
<th>说明</th>
</tr>
</thead>
<tbody>
<tr>
<td>训练长度之外是外推区</td>
<td>RoPE 对任意位置都能算出旋转角（没有&quot;位置表&quot;可言），但模型训练时只见过位置 <code>0..32</code>，超出后是<strong>外推区</strong>（第 19 课讲过），注意力分数可能畸变，输出质量断崖下跌</td>
</tr>
<tr>
<td>缓存无法&quot;截断&quot;</td>
<td>全量模式可以用 <code>ids.len().saturating_sub(block_size)</code> 把窗口滑到最近 32 个 token；而 <code>KVCache</code> 只会 append、不会丢弃最早的位置（当前实现没有&quot;弹掉开头&quot;的操作）</td>
</tr>
<tr>
<td>上下文窗口硬上限</td>
<td><code>block_size</code> 是模型的设计上下文长度（每个训练样本最长 32 个位置），<code>generate</code> 用 <code>cache[0].seq_len() &gt;= block_size</code> 把生成长度锁在训练见过的最长窗口内，不越界</td>
</tr>
</tbody>
</table>
<p>对比全量模式的生成 1：prompt &quot;Once upon a&quot; = 12 个 token，每步窗口都滑到最近 32 个，所以 80 个新 token 全部生成完（<code>Once upon a to his friend, the wise old owl. One day, Red found a wold of colors and turned</code>）。</p>
<blockquote>
<p>真实 LLM 的 KV cache 比这复杂得多：支持&quot;滑动窗口 + 丢弃最旧块&quot;（如 Mistral 的 sliding window）、对缓存做量化压缩等。本项目的 <code>KVCache</code> 是最简版——<strong>只拼不丢</strong>，因此一旦填满就必须停止。把&quot;丢了也能继续&quot;留作动手练习 5。</p>
</blockquote>
<h2>9. 动手练习</h2>
<ol>
<li><strong>严格验证分布不变</strong>：在 <code>demo_gpt</code> 里用<strong>相同的 prompt</strong>（如都传 <code>&quot;The fox&quot;</code>）和<strong>相同的 rng</strong> 分别调 <code>generate(..., false, ...)</code> 与 <code>generate(..., true, ...)</code>，对比逐 token 输出是否一致。</li>
<li><strong>打印缓存形状</strong>：在 <code>MultiHeadAttention::forward</code> 的 <code>cache.append</code> 之后加一行 <code>println!(&quot;cache seq_len = {}&quot;, cache.seq_len());</code>，观察它从 7 一路涨到 32 的过程。</li>
<li><strong>把 <code>break</code> 条件去掉</strong>：临时注释掉 <code>generate</code> 里的 <code>if use_kv_cache &amp;&amp; cache[0].seq_len() &gt;= block_size { break; }</code>，运行看会发生什么——体会位置编码表 <code>[32, D]</code> 的下界约束。</li>
<li><strong>对比计算量</strong>：全量模式第 k 步前向 k 个位置、缓存模式每步只前向 1 个位置。对 <code>block_size=32</code>、<code>max_new=80</code>，估算两种模式累计前向的位置总数各是多少。</li>
<li><strong>（进阶）给 KVCache 加&quot;截断&quot;</strong>：仿照全量模式的窗口滑动，给 <code>KVCache</code> 加一个 <code>truncate(len)</code> 方法（把 <code>k.data()</code> 裁到最近 <code>len</code> 个位置再包回张量），并在 <code>generate</code> 的缓存分支里每步调用它，让缓存模式也能像全量模式一样持续生成——对比改动前后的输出。</li>
</ol>
<h2>10. 本课总结</h2>
<ul>
<li>
<p>逐 token 生成时，历史位置的 K/V 每步都在被重复计算——全量模式累计 O(T²)，这是 KV Cache 要消灭的浪费</p>
</li>
<li>
<p><code>KVCache</code> = 每层一份的 <code>(k, v)</code> 张量（<code>[1, T, D]</code>），<code>append</code> 纯数据拼接、<code>seq_len</code> 读已缓存长度</p>
</li>
<li>
<p><code>MultiHeadAttention</code> 用缓存后只有 K/V 变长，Q 只算新位置，后续代码零改动；<code>GPT::forward</code> 用 <code>base</code> 修正位置编码与因果掩码</p>
</li>
<li>
<p>流程对比：首次前向整个 prompt 填缓存 → 之后每步只前向 1 个 token；全量模式则是每步重算整个窗口</p>
</li>
<li>
<p>分布不变的原因：缓存里的 K/V 与全量模式算出的数值相同，注意力、softmax 计算路径一致</p>
</li>
<li>
<p>缓存模式只拼不丢，上下文达到 <code>block_size=32</code> 必须停止（训练长度外是 RoPE 外推区 + 缓存无法截断历史），真实输出里生成 2 止步于 32 个 token</p>
</li>
<li>
<p>下一课：换掉正弦位置编码，用 RoPE（旋转位置编码）让位置信息融入注意力计算。</p>
</li>
</ul>
]]></content>
  <category term="Rust"/>
  <category term="LLM"/>
  <category term="推理"/>
</entry>
<entry>
  <title>第 17 课：AdamW 优化器 —— 给梯度下降装上「惯性」和「自适应步长」</title>
  <link href="https://ganto.me/post/rust-llm-guide-17"/>
  <id>https://ganto.me/post/rust-llm-guide-17</id>
  <updated>2026-09-18T00:00:00+08:00</updated>
  <published>2026-09-18T00:00:00+08:00</published>
  <author><name>干徒</name></author>
  <summary><![CDATA[实现 AdamW 优化器，融合动量与自适应学习率，并与 SGD 对比。]]></summary>
  <content type="html"><![CDATA[<h1>第 17 课：AdamW 优化器 —— 给梯度下降装上&quot;惯性&quot;和&quot;自适应步长&quot;</h1>
<blockquote>
<p>代码位置：<a href="src/optim.rs">src/optim.rs</a>（<code>SGD</code> / <code>AdamW</code>）
代码位置：<a href="src/train.rs">src/train.rs</a>（<code>train_gpt</code> 中 AdamW 的用法）
演示入口：<a href="src/main.rs">src/main.rs</a>（演示 3：训练小 GPT）</p>
</blockquote>
<h2>1. 本课要搞懂的问题</h2>
<ol>
<li>朴素 SGD 有什么致命弱点？为什么深度模型上它不够用？</li>
<li>Adam 的一阶动量 m、二阶动量 v 分别&quot;记忆&quot;了什么？对应公式怎么写？</li>
<li>为什么需要偏差修正（除以 1-β^t）？前几步不修会怎样？</li>
<li>权重衰减&quot;解耦&quot;（decoupled weight decay）和传统的 L2 正则化有什么区别？</li>
<li><code>AdamW::step</code> 的每一行代码分别对应公式里的哪一项？</li>
</ol>
<h2>2. 先复习：SGD 的局限</h2>
<p>第 6 课实现的 SGD 是整个故事的原点：</p>
<pre><code class="language-rust">/// 更新一步：θ = θ - lr * g
pub fn step(&amp;self) {
    for p in &amp;self.params {
        let g = p.grad();
        let d = p.data();
        let updated: Vec&lt;f32&gt; = d.iter().zip(&amp;g).map(|(v, g)| v - self.lr * g).collect();
        p.set_data(updated);
    }
}
</code></pre>
<p>公式只有一个：</p>
<pre><code>θ ← θ - lr · g
</code></pre>
<p>它有两个致命弱点：</p>
<table>
<thead>
<tr>
<th>弱点</th>
<th>表现</th>
<th>直观理解</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>梯度震荡</strong></td>
<td>参数在最优解附近来回横跳，收敛慢</td>
<td>下山时每一步都只看&quot;当前脚下&quot;的坡度，方向忽左忽右，没有&quot;惯性&quot;</td>
</tr>
<tr>
<td><strong>步长不会自适应</strong></td>
<td>所有参数共用同一个 lr</td>
<td>有的维度坡缓（需要大步长），有的维度坡陡/噪声大（需要小步长），SGD 一刀切</td>
</tr>
</tbody>
</table>
<p>现实世界的梯度方向总是带噪声的（batch 采样引入的随机性、损失面本身的崎岖），只信&quot;当前这一下&quot;的 SGD 在深度网络上要么太慢、要么震荡发散。</p>
<h2>3. Adam 的核心思想：一阶动量 + 二阶动量</h2>
<p>Adam（Adaptive Moment Estimation）给每个参数维护两个额外状态，公式如下：</p>
<pre><code>一阶动量（梯度的指数移动平均，记住&quot;方向&quot;）：
    m_t = β1 · m_{t-1} + (1 - β1) · g_t

二阶动量（梯度平方的指数移动平均，感知&quot;陡峭程度&quot;）：
    v_t = β2 · v_{t-1} + (1 - β2) · g_t²

参数更新：
    θ_t = θ_{t-1} - lr · m̂_t / (√v̂_t + ε)
</code></pre>
<p>直觉对应：</p>
<table>
<thead>
<tr>
<th>状态</th>
<th>记忆的内容</th>
<th>类比</th>
<th>作用</th>
</tr>
</thead>
<tbody>
<tr>
<td>一阶动量 <code>m</code></td>
<td>梯度的<strong>平均值</strong>（带方向）</td>
<td>小球下坡的<strong>惯性</strong>：过去几步都往东偏，这次也往东多走点</td>
<td>平滑梯度、抵消震荡</td>
</tr>
<tr>
<td>二阶动量 <code>v</code></td>
<td>梯度<strong>平方</strong>的平均值（无方向，恒正）</td>
<td>对坡度的<strong>感知</strong>：某个维度长期坡陡，说明这里&quot;水深&quot;，步子要小</td>
<td>每个参数独立缩放步长</td>
</tr>
</tbody>
</table>
<p>注意 <code>m</code>、<code>v</code> 都是<strong>逐参数、逐元素</strong>维护的（<code>AdamW</code> 里 <code>m: Vec&lt;Vec&lt;f32&gt;&gt;</code>、<code>v: Vec&lt;Vec&lt;f32&gt;&gt;</code>，和每个参数张量形状一一对应），所以&quot;自适应步长&quot;是精细到每个标量权重的。</p>
<h3>为什么用指数移动平均而不是简单平均</h3>
<p>因为只需要记住 <code>m_{t-1}</code> 和 <code>v_{t-1}</code> 两个状态就能增量更新，不需要存全部历史梯度；<code>β1=0.9</code> 意味着过去约 10 步的梯度主导，<code>β2=0.999</code> 意味着过去约 1000 步的梯度平方主导——<strong>越靠前的历史衰减得越厉害</strong>。</p>
<h2>4. 偏差修正：除以 (1 - β^t)</h2>
<p>问题：<code>m_0 = 0</code>、<code>v_0 = 0</code>，训练第一步 <code>m_1 = (1-β1)·g_1</code>，只有真实梯度的 10%（β1=0.9 时）。训练初期 <code>m</code>、<code>v</code> 被严重&quot;低估&quot;，直接使用会导致起步步长偏小。</p>
<p>修正办法：把 <code>m_t</code>、<code>v_t</code> 除以各自的累积衰减系数：</p>
<pre><code>m̂_t = m_t / (1 - β1^t)
v̂_t = v_t / (1 - β2^t)
</code></pre>
<table>
<thead>
<tr>
<th>t</th>
<th>1 - β1^t（β1=0.9）</th>
<th>1 - β2^t（β2=0.999）</th>
<th>效果</th>
</tr>
</thead>
<tbody>
<tr>
<td>1</td>
<td>0.1</td>
<td>0.001</td>
<td>修正最狠：<code>m̂ = m/0.1 = 10×m</code>，<code>v̂ = v/0.001 = 1000×v</code></td>
</tr>
<tr>
<td>10</td>
<td>1 - 0.9¹⁰ ≈ 0.651</td>
<td>1 - 0.999¹⁰ ≈ 0.00995</td>
<td>仍在修正</td>
</tr>
<tr>
<td>100</td>
<td>1 - 0.9¹⁰⁰ ≈ 1.0</td>
<td>1 - 0.999¹⁰⁰ ≈ 0.095</td>
<td>m 基本不用修，v 还要修</td>
</tr>
<tr>
<td>1000</td>
<td>≈ 1.0</td>
<td>≈ 0.632</td>
<td>v 接近不需要修</td>
</tr>
</tbody>
</table>
<blockquote>
<p>关键观察：<code>t</code> 越大，<code>β^t</code> 越接近 0，修正系数越接近 1——<strong>偏差修正只在训练初期起作用</strong>。而且 <code>m</code> 的 β1 小、修正结束得快，<code>v</code> 的 β2 接近 1、修正持续得更久。这正是代码里两个系数分开算的原因。</p>
</blockquote>
<h2>5. 权重衰减解耦（decoupled weight decay）</h2>
<p>正则化思想：每步更新时，额外把参数往 0 拉一点点，防止权重过大、过拟合。</p>
<p><strong>传统做法（L2 正则化）</strong>：把 <code>λ·θ</code> 加进损失再求导，相当于梯度变成 <code>g + λ·θ</code>，然后被 Adam 的&quot;自适应步长&quot;一通缩放——<code>λ·θ</code> 这个衰减项也被 <code>1/√v̂</code> 缩放了，<strong>衰减强度随梯度历史变化，不受控制</strong>。</p>
<p><strong>AdamW 的解耦做法</strong>：权重衰减<strong>独立于梯度</strong>，作为一个单独的减法项：</p>
<pre><code>θ ← θ - lr · m̂/(√v̂ + ε)  -  lr · wd · θ
     └──── Adam 步长 ────┘   └─ 解耦的权重衰减 ─┘
</code></pre>
<table>
<thead>
<tr>
<th></th>
<th>传统 L2（Adam+L2）</th>
<th>AdamW（decoupled）</th>
</tr>
</thead>
<tbody>
<tr>
<td>衰减项怎么来</td>
<td>进损失函数求导，混进梯度 <code>g+λθ</code></td>
<td>不进梯度，更新时单独减 <code>lr·wd·θ</code></td>
</tr>
<tr>
<td>是否被自适应步长缩放</td>
<td>是（被 <code>1/√v̂</code> 缩放，强度不稳定）</td>
<td>否（恒定 <code>lr·wd</code>，与梯度历史无关）</td>
</tr>
<tr>
<td>实际效果</td>
<td>衰减幅度时大时小，难调</td>
<td>衰减可预期、好调超参</td>
</tr>
</tbody>
</table>
<p>在现代 LLM 训练里（GPT 系列等）几乎都用 AdamW，就是因为这个&quot;可预期&quot;的衰减。</p>
<h2>6. AdamW::step 逐行讲解</h2>
<p>完整代码（<code>src/optim.rs</code>）：</p>
<pre><code class="language-rust">pub fn step(&amp;mut self) {
    self.t += 1;
    // 偏差修正系数（训练初期 t 小，修正大）
    let bc1 = 1.0 - self.beta1.powi(self.t as i32);
    let bc2 = 1.0 - self.beta2.powi(self.t as i32);

    for (i, p) in self.params.iter().enumerate() {
        let g = p.grad();
        let d = p.data();
        let mut updated = vec![0.0f32; d.len()];
        for j in 0..d.len() {
            let gv = g[j];
            // 1. 更新动量
            self.m[i][j] = self.beta1 * self.m[i][j] + (1.0 - self.beta1) * gv;
            self.v[i][j] = self.beta2 * self.v[i][j] + (1.0 - self.beta2) * gv * gv;
            // 2. 偏差修正
            let m_hat = self.m[i][j] / bc1;
            let v_hat = self.v[i][j] / bc2;
            // 3. 更新：θ -= lr * m_hat/(√v_hat + eps) + lr * wd * θ（权重衰减解耦）
            let step = self.lr * m_hat / (v_hat.sqrt() + self.eps);
            let decay = self.lr * self.weight_decay * d[j];
            updated[j] = d[j] - step - decay;
        }
        p.set_data(updated);
    }
}
</code></pre>
<p>逐行对应公式：</p>
<table>
<thead>
<tr>
<th>代码</th>
<th>对应公式</th>
<th>说明</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>self.t += 1;</code></td>
<td>——</td>
<td>步数计数，偏差修正要用到 t</td>
</tr>
<tr>
<td><code>bc1 = 1.0 - self.beta1.powi(t)</code></td>
<td><code>1 - β1^t</code></td>
<td>一阶动量修正系数（t 从 1 开始）</td>
</tr>
<tr>
<td><code>bc2 = 1.0 - self.beta2.powi(t)</code></td>
<td><code>1 - β2^t</code></td>
<td>二阶动量修正系数</td>
</tr>
<tr>
<td><code>self.m[i][j] = self.beta1 * self.m[i][j] + (1.0 - self.beta1) * gv;</code></td>
<td><code>m_t = β1·m_{t-1} + (1-β1)·g_t</code></td>
<td>一阶动量：新旧梯度按 9:1 加权</td>
</tr>
<tr>
<td><code>self.v[i][j] = self.beta2 * self.v[i][j] + (1.0 - self.beta2) * gv * gv;</code></td>
<td><code>v_t = β2·v_{t-1} + (1-β2)·g_t²</code></td>
<td>二阶动量：梯度<strong>平方</strong>，恒正</td>
</tr>
<tr>
<td><code>m_hat = self.m[i][j] / bc1;</code></td>
<td><code>m̂_t = m_t/(1-β1^t)</code></td>
<td>修正初期被低估的一阶动量</td>
</tr>
<tr>
<td><code>v_hat = self.v[i][j] / bc2;</code></td>
<td><code>v̂_t = v_t/(1-β2^t)</code></td>
<td>修正初期被低估的二阶动量</td>
</tr>
<tr>
<td><code>step = self.lr * m_hat / (v_hat.sqrt() + self.eps);</code></td>
<td><code>lr·m̂/(√v̂+ε)</code></td>
<td>Adam 更新步长：方向 m̂，大小被 √v̂ 自适应缩放</td>
</tr>
<tr>
<td><code>decay = self.lr * self.weight_decay * d[j];</code></td>
<td><code>lr·wd·θ</code></td>
<td>解耦的权重衰减，<strong>不经过 √v̂ 缩放</strong></td>
</tr>
<tr>
<td><code>updated[j] = d[j] - step - decay;</code></td>
<td><code>θ ← θ - step - decay</code></td>
<td>参数更新 = 旧值 - Adam 步长 - 衰减</td>
</tr>
<tr>
<td><code>p.set_data(updated);</code></td>
<td>——</td>
<td>写回参数张量</td>
</tr>
</tbody>
</table>
<p>四个值得停下来想一想的细节：</p>
<ol>
<li><strong>为什么 <code>v_hat.sqrt()</code> 要加 <code>eps</code></strong>：防止 <code>v_hat ≈ 0</code>（训练初期）时除零，数值稳定性用。<code>eps = 1e-8</code> 是 Adam 论文的标准值。</li>
<li><strong><code>lr</code> 是 <code>pub</code> 字段</strong>：<code>src/train.rs</code> 里每步都改它——
<pre><code class="language-rust">opt.lr = scheduler.lr();   // 第 20 课：warmup + cosine 调度
opt.step();
</code></pre>
调度器只负责改 <code>lr</code>，AdamW 的 <code>m</code>、<code>v</code> 状态跨步累积、完全不受影响。</li>
<li><strong>权重衰减项用的是 <code>d[j]</code>（更新前的旧参数）</strong>：这就是&quot;解耦&quot;——衰减直接作用在参数本身，而不是作用在梯度上。</li>
<li><strong><code>m</code>、<code>v</code> 与参数逐元素对齐</strong>：<code>AdamW::new</code> 里 <code>params.iter().map(|p| vec![0.0f32; p.numel()])</code>，每个参数张量配一个同长度的一阶/二阶动量数组，更新时按 <code>j</code> 同步遍历。</li>
</ol>
<h2>7. AdamW 的超参数</h2>
<p><code>AdamW::new</code> 里的默认值就是论文标准：</p>
<pre><code class="language-rust">AdamW {
    lr,
    beta1: 0.9,
    beta2: 0.999,
    eps: 1e-8,
    weight_decay,
    t: 0,
    params,
    m,
    v,
}
</code></pre>
<table>
<thead>
<tr>
<th>超参数</th>
<th>值</th>
<th>含义</th>
<th>调参经验</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>beta1</code></td>
<td>0.9</td>
<td>一阶动量衰减系数</td>
<td>一般不动，范围 0.8~0.95</td>
</tr>
<tr>
<td><code>beta2</code></td>
<td>0.999</td>
<td>二阶动量衰减系数</td>
<td>一般不动；训练不稳时有人降到 0.95</td>
</tr>
<tr>
<td><code>eps</code></td>
<td>1e-8</td>
<td>除零保护</td>
<td>一般不动</td>
</tr>
<tr>
<td><code>weight_decay</code></td>
<td>0.01（本项目）</td>
<td>权重衰减强度</td>
<td>常用 0.01~0.1；越大正则化越强</td>
</tr>
<tr>
<td><code>lr</code></td>
<td>调度器控制</td>
<td>基础步长</td>
<td>配合 warmup/cosine（第 20 课）</td>
</tr>
</tbody>
</table>
<p>本项目在 <code>train_gpt</code> 里这样接入：</p>
<pre><code class="language-rust">let mut opt = AdamW::new(max_lr, params.clone(), 0.01);   // wd = 0.01
...
clip_grad_norm(&amp;params, 1.0);   // 先裁剪梯度（防止个别大梯度冲坏 m、v 的估计）
opt.lr = scheduler.lr();        // 再设置当前学习率
opt.step();                     // 最后更新
opt.zero_grad();
</code></pre>
<blockquote>
<p>顺序值得注意：<strong>梯度裁剪在 <code>AdamW::step</code> 之前</strong>。因为 Adam 的 m、v 是梯度历史的长期记忆，如果某步梯度爆炸没被裁掉，它会污染 m/v 很久。先裁剪、再进优化器，是 LLM 训练的标准顺序。</p>
</blockquote>
<h2>8. 动手练习</h2>
<ol>
<li><strong>对比 SGD vs AdamW</strong>：临时把 <code>train_gpt</code> 里的 <code>AdamW::new(...)</code> 换成 <code>SGD::new(0.01, params.clone())</code>（去掉 <code>opt.lr = scheduler.lr()</code> 那行，因为 <code>SGD::lr</code> 是私有字段），跑 600 步看 loss——体会 AdamW 在小语料上的收敛速度优势。</li>
<li><strong>手推第一步</strong>：假设某参数 <code>θ=1.0</code>、<code>g=0.5</code>、<code>lr=0.001</code>、<code>wd=0.01</code>，手算 <code>t=1</code> 时 <code>m_hat</code>、<code>v_hat</code>、<code>step</code>、<code>decay</code>，再在 <code>AdamW::step</code> 里加一行 <code>println!</code> 验证。</li>
<li><strong>看偏差修正的效果</strong>：把 <code>bc1</code>、<code>bc2</code> 改成恒为 1.0（不修正），训练对比 loss 曲线——训练初期应该明显变慢。</li>
<li><strong>调权重衰减</strong>：把 <code>wd</code> 从 0.01 改成 0.1 和 0.0，观察训练后 loss 与生成文本的差异，体会正则化的作用。</li>
<li><strong>思考</strong>：为什么 <code>v</code> 存的是 <code>g²</code> 而不是 <code>|g|</code>？如果某个维度的梯度长期是 +0.1/-0.1 交替（震荡），<code>m</code> 和 <code>v</code> 分别是什么表现？（提示：m 会被抵消趋近 0，v 会累积为正——这就是 Adam 抑制震荡的机制。）</li>
</ol>
<h2>9. 本课总结</h2>
<ul>
<li>
<p>SGD 只有 <code>θ -= lr·g</code>，既没有惯性（震荡）也不能自适应步长（一刀切）</p>
</li>
<li>
<p>Adam 用一阶动量 <code>m = β1·m + (1-β1)·g</code> 平滑方向，用二阶动量 <code>v = β2·v + (1-β2)·g²</code> 感知陡峭度，更新为 <code>θ -= lr·m̂/(√v̂+ε)</code></p>
</li>
<li>
<p>偏差修正除以 <code>1-β^t</code>：只在训练初期起作用，补偿 m/v 从 0 起步的低估</p>
</li>
<li>
<p>AdamW 把权重衰减<strong>解耦</strong>成独立项 <code>θ -= lr·wd·θ</code>，不受自适应步长干扰，衰减强度可预期</p>
</li>
<li>
<p><code>AdamW::step</code> 的每行代码都能在公式里找到对应项：<code>t</code> → 修正系数 → 逐元素动量 → 修正 → 步长/衰减 → 写回</p>
</li>
<li>
<p>下一课：推理加速神器 KV Cache——为什么生成时要缓存 K/V，怎么做到&quot;每步只算 1 个 token&quot;。</p>
</li>
</ul>
]]></content>
  <category term="Rust"/>
  <category term="LLM"/>
  <category term="优化器"/>
</entry>
<entry>
  <title>第 16 课：训练小 GPT —— 看 loss 从 1.46 一路降到 0.16</title>
  <link href="https://ganto.me/post/rust-llm-guide-16"/>
  <id>https://ganto.me/post/rust-llm-guide-16</id>
  <updated>2026-09-17T00:00:00+08:00</updated>
  <published>2026-09-17T00:00:00+08:00</published>
  <author><name>干徒</name></author>
  <summary><![CDATA[完整训练一个小型 GPT 模型，观察 loss 从 1.46 一路下降到 0.16。]]></summary>
  <content type="html"><![CDATA[<h1>第 16 课：训练小 GPT —— 看 loss 从 1.46 一路降到 0.16</h1>
<blockquote>
<p>代码位置：<a href="src/main.rs">src/main.rs</a>（<code>demo_gpt</code>）
代码位置：<a href="src/train.rs">src/train.rs</a>（<code>train_gpt</code> / <code>LRScheduler</code> / <code>clip_grad_norm</code>）
代码位置：<a href="src/data.rs">src/data.rs</a>（<code>CORPUS</code> / <code>DataLoader</code>）
代码位置：<a href="src/sample.rs">src/sample.rs</a>（<code>generate</code> / <code>sample_token</code>）</p>
</blockquote>
<h2>1. 本课要搞懂的问题</h2>
<ol>
<li><code>demo_gpt</code> 从数据到生成文本，完整流程分哪几步？</li>
<li>只有 669 个字符的小语料，训练日志里的 <code>step / lr / loss</code> 三列怎么读？</li>
<li>日志里为什么看不到 warmup 段？lr 从 <code>0.002947</code> 一路衰减到 <code>0.000300</code> 是怎么来的？</li>
<li>temperature、top-k、top-p 三个参数是怎么配合采样的？</li>
<li>为什么 loss 已经降到 0.16，模型输出的文本依然只是&quot;像样&quot;而不是&quot;正确&quot;？</li>
</ol>
<h2>2. 训练全景：demo_gpt 做了什么</h2>
<p><code>src/main.rs</code> 的演示 3（第 12-21 课）是本节的主角：</p>
<pre><code class="language-rust">fn demo_gpt() {
    println!(&quot;=== 演示 3：训练小 GPT 并生成文本 ===&quot;);

    let mut rng = Rng::new(1234);
    let tokenizer = CharTokenizer::new(CORPUS);
    let vocab_size = tokenizer.vocab_size();
    println!(&quot;  语料 {} 字符，字符词表 {} 个&quot;, CORPUS.len(), vocab_size);

    let model = GPT::new(GPTConfig::tiny(vocab_size), &amp;mut rng);

    // 训练（第 13、17、20 课：训练循环 + AdamW + warmup/cosine 调度）
    let loader = DataLoader::new(CORPUS, &amp;tokenizer, model.cfg.block_size, 8);
    let tcfg = config::TrainConfig {
        seed: 42,
        batch_size: 8,
        steps: 600,
        max_lr: 3e-3,
        warmup_steps: 50,
        eval_every: 100,
        ..config::TrainConfig::default()
    };
    train::train_gpt(&amp;model, &amp;tokenizer, &amp;loader, &amp;tcfg, None, None, &amp;mut rng);

    // 生成（无 cache）
    println!(&quot;\n  —— 生成 1（temperature=0.8, top-k=10, top-p=0.9, 无 KV cache）——&quot;);
    let out1 = generate(&amp;model, &amp;tokenizer, &quot;Once upon a&quot;, 80, 0.8, 10, 0.9, false, &amp;mut rng);
    println!(&quot;  {}&quot;, out1);

    // 生成（带 KV cache，第 18 课）
    println!(&quot;\n  —— 生成 2（temperature=0.8, top-k=10, top-p=0.9, 带 KV cache）——&quot;);
    let out2 = generate(&amp;model, &amp;tokenizer, &quot;The fox&quot;, 80, 0.8, 10, 0.9, true, &amp;mut rng);
    println!(&quot;  {}&quot;, out2);
    println!(&quot;\n  （KV cache 只改计算方式、不改生成分布，两者应高度一致）&quot;);
}
</code></pre>
<p>整个流程可以拆成 5 步：</p>
<table>
<thead>
<tr>
<th>步骤</th>
<th>代码</th>
<th>做了什么</th>
</tr>
</thead>
<tbody>
<tr>
<td>1. 分词</td>
<td><code>CharTokenizer::new(CORPUS)</code></td>
<td>扫描语料，得到 35 个字符的词表</td>
</tr>
<tr>
<td>2. 建模型</td>
<td><code>GPT::new(GPTConfig::tiny(vocab_size), &amp;mut rng)</code></td>
<td>用 tiny 配置（n_embd=64、n_head=4、n_layer=2、block_size=32）初始化模型</td>
</tr>
<tr>
<td>3. 造数据</td>
<td><code>DataLoader::new(CORPUS, &amp;tokenizer, 32, 8)</code></td>
<td>把 669 字符的语料切成 token 序列，按 block_size=32 切块、batch_size=8</td>
</tr>
<tr>
<td>4. 训练</td>
<td><code>train_gpt(&amp;model, &amp;tokenizer, &amp;loader, &amp;tcfg, None, None, ...)</code></td>
<td>600 步，峰值学习率 3e-3，前 50 步 warmup，每 100 步打印一次（其余参数取 <code>TrainConfig::default()</code>）</td>
</tr>
<tr>
<td>5. 生成</td>
<td><code>generate(..., &quot;Once upon a&quot;, 80, 0.8, 10, 0.9, false, ...)</code></td>
<td>给定开头，最多续写 80 个字符</td>
</tr>
</tbody>
</table>
<blockquote>
<p>注意：训练用的是字符级分词器，所以&quot;1 个字符 = 1 个 token&quot;，语料 669 个字符就是 669 个 token。这让后面的数字（32、80）可以直接按&quot;字符数&quot;理解。</p>
</blockquote>
<h2>3. 数据：669 字符的小语料</h2>
<p><code>src/data.rs</code> 里内置了一篇英文小故事（狐狸 Red 找金钥匙）：</p>
<pre><code class="language-rust">pub const CORPUS: &amp;str = &quot;\
Once upon a time in a small village, there lived a curious little fox named Red. \
Every morning, Red would wake up early and explore the forest. ...&quot;;
</code></pre>
<p>训练数据是<strong>自监督</strong>的：输入 x 是一段 32 个 token 的序列，标签 y 是 x 右移一位——每个位置都预测&quot;下一个字符是谁&quot;，文本自己就是标签，不需要人工标注。</p>
<p><code>DataLoader::sample_batch</code> 每次随机选 8 个起点，各截 33 个 token（前 32 个作 x，后 32 个作 y）：</p>
<pre><code class="language-rust">pub fn sample_batch(&amp;self, rng: &amp;mut Rng) -&gt; (Vec&lt;usize&gt;, Vec&lt;usize&gt;) {
    let max_start = self.tokens.len() - self.block_size - 1;
    let mut x = Vec::with_capacity(self.batch_size * self.block_size);
    let mut y = Vec::with_capacity(self.batch_size * self.block_size);
    for _ in 0..self.batch_size {
        let start = rng.choice(max_start);
        for j in 0..self.block_size {
            x.push(self.tokens[start + j]);
            y.push(self.tokens[start + j + 1]);
        }
    }
    (x, y)
}
</code></pre>
<p>关键点：</p>
<ul>
<li><strong>随机采样而非顺序扫描</strong>：每次 <code>sample_batch</code> 都在语料里随机挑起点。语料只有 669 token，但 600 步 × 8 个 batch 会反复&quot;看到&quot;语料的不同片段（有些片段会被重复看，有的可能一次都没被抽到）——小语料训练天然就是&quot;背课文&quot;。</li>
<li>返回的 x、y 都是 <code>[B*T] = [8×32] = [256]</code> 的展平数组，正好满足 <code>GPT::forward(idx, b=8, t=32, None)</code> 的输入要求（训练时 <code>kv_cache</code> 传 <code>None</code>）。</li>
</ul>
<h2>4. 超参数一览</h2>
<p><code>train_gpt</code> 的调用参数与 <code>GPTConfig::tiny</code> 汇总：</p>
<table>
<thead>
<tr>
<th>超参数</th>
<th>值</th>
<th>含义</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>steps</code></td>
<td>600</td>
<td>总训练步数</td>
</tr>
<tr>
<td><code>batch_size</code></td>
<td>8</td>
<td>每步采样 8 条序列（每条 32 token）</td>
</tr>
<tr>
<td><code>block_size</code></td>
<td>32</td>
<td>最大上下文长度，来自 <code>GPTConfig::tiny</code></td>
</tr>
<tr>
<td><code>max_lr</code></td>
<td>3e-3</td>
<td>学习率峰值</td>
</tr>
<tr>
<td><code>warmup_steps</code></td>
<td>50</td>
<td>前 50 步学习率从 0 线性爬升到峰值</td>
</tr>
<tr>
<td><code>min_lr</code></td>
<td>max_lr × 0.1 = 3e-4</td>
<td>cosine 衰减的终点（<code>LRScheduler::new</code> 里算的）</td>
</tr>
<tr>
<td><code>weight_decay</code></td>
<td>0.01</td>
<td>AdamW 的权重衰减（第 17 课）</td>
</tr>
<tr>
<td><code>max_norm</code>（梯度裁剪）</td>
<td>1.0</td>
<td>梯度范数上限（<code>clip_grad_norm</code>）</td>
</tr>
<tr>
<td><code>eval_every</code></td>
<td>100</td>
<td>每 100 步打印一次日志</td>
</tr>
</tbody>
</table>
<p>模型参数量：<code>train_gpt</code> 开头会打印一行&quot;开始训练&quot;（真实数字就在其中）：</p>
<pre><code>开始训练：char（vocab=35）模型参数 102336 | 语料 669 tokens（训练 669 / 验证 0）| batch=8 block=32
</code></pre>
<p>按第 12 课的方法验证一下：词表 V=35（不是 100）时，<code>tok_emb = 35×64 = 2240</code>，每层 Block ≈ 49984，两层 ≈ 99968，<code>ln_f = 128</code>，输出头是权重绑定（复用 <code>tok_emb</code> 转置，无独立 lm_head 参数），总计 <strong>2240 + 99968 + 128 = 102336</strong> ✓。约 10 万参数，CPU 上几秒就能跑完整个 demo。</p>
<h2>5. 真实训练日志解读</h2>
<p>运行 <code>cargo run --release</code>，演示 3 会打印（这是<strong>真实运行输出</strong>，不是编的）：</p>
<pre><code>=== 演示 3：训练小 GPT 并生成文本 ===
  语料 669 字符，字符词表 35 个
开始训练：char（vocab=35）模型参数 102336 | 语料 669 tokens（训练 669 / 验证 0）| batch=8 block=32
step   100 | lr 0.002947 | train_loss 1.4597
step   200 | lr 0.002540 | train_loss 0.5498
step   300 | lr 0.001850 | train_loss 0.3338
step   400 | lr 0.001096 | train_loss 0.2312
step   500 | lr 0.000518 | train_loss 0.1557
step   600 | lr 0.000300 | train_loss 0.1624
</code></pre>
<h3>5.1 三列日志分别是什么</h3>
<table>
<thead>
<tr>
<th>列</th>
<th>含义</th>
<th>从哪来</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>step</code></td>
<td>训练步数（从 1 开始数，日志显示 100、200、…、600）</td>
<td><code>train_gpt</code> 打印的是 <code>step + 1</code></td>
</tr>
<tr>
<td><code>lr</code></td>
<td>本步实际用于更新的学习率</td>
<td><code>cur_lr</code>（先取 <code>scheduler.lr()</code>，再 <code>scheduler.step()</code>）</td>
</tr>
<tr>
<td><code>train_loss</code></td>
<td>本步 batch 的平均交叉熵</td>
<td><code>cross_entropy_loss(&amp;logits, &amp;y)</code></td>
</tr>
</tbody>
</table>
<p><code>train_gpt</code> 里每步做 6 件事，日志打印在最后：</p>
<pre><code class="language-rust">for step in 0..steps {
    let (x, y) = loader.sample_batch(rng);          // 1. 采样 batch
    let logits = model.forward(&amp;x, b, t, None);     // 2. 前向
    let loss = cross_entropy_loss(&amp;logits, &amp;y);     //    算损失
    loss.backward();                                // 3. 反向
    clip_grad_norm(&amp;params, 1.0);                   // 4. 梯度裁剪
    let cur_lr = scheduler.lr();                    // 5. 取当前步 lr 喂给优化器
    opt.lr = cur_lr;
    opt.step();
    opt.zero_grad();                                // 6. 清零梯度
    scheduler.step();                               //    步数 +1（为下一步准备 lr）
    let last = step + 1 == steps;
    if (step + 1) % cfg.eval_every == 0 || last {   // 每 eval_every 步（或最后一步）打印
        // lr 打印的就是本步实际用的 cur_lr，没有错位；step 打印 step + 1
        println!(&quot;step {:&gt;5} | lr {:.6} | train_loss {:.4}&quot;, step + 1, cur_lr, loss.item());
    }
}
</code></pre>
<h3>5.2 loss：1.46 → 0.16 说明了什么</h3>
<ul>
<li><strong>第一个打印点 1.46</strong>：日志只在 <code>step 100、200、…</code> 打印（<code>eval_every = 100</code>）。随机初始化时模型对 35 个字符基本&quot;一视同仁&quot;，理论下界是均匀分布的交叉熵 <code>ln(35) ≈ 3.56</code>；训练 100 步后降到 1.46，说明已经开始学习。</li>
<li><strong>先快后慢</strong>：step 100→300 loss 从 1.46 掉到 0.33（降了 77%），step 300→600 只从 0.33 掉到 0.16。这是训练曲线的典型形态——早期梯度大、方向明确，后期接近收敛、只能精雕细琢。</li>
<li><strong>终点 0.16</strong>：交叉熵 0.16 意味着模型给&quot;正确下一个字符&quot;的平均概率约为 <code>exp(-0.16) ≈ 0.85</code>。对一篇 669 字符的&quot;课文&quot;来说，模型已经相当好地&quot;背&quot;下了其中的统计规律。</li>
</ul>
<h3>5.3 warmup 阶段：为什么日志里看不到</h3>
<p><code>LRScheduler</code> 的规则（<code>src/train.rs</code>）：</p>
<pre><code class="language-rust">pub fn lr(&amp;self) -&gt; f32 {
    if self.step &lt; self.warmup_steps {
        // 线性 warmup
        self.max_lr * (self.step as f32 + 1.0) / self.warmup_steps.max(1) as f32
    } else {
        // cosine 衰减
        let progress = (self.step - self.warmup_steps) as f32
            / (self.total_steps - self.warmup_steps).max(1) as f32;
        let progress = progress.min(1.0);
        let cosine = 0.5 * (1.0 + (std::f32::consts::PI * progress).cos());
        self.min_lr + (self.max_lr - self.min_lr) * cosine
    }
}
</code></pre>
<p>warmup 就是前 50 步让学习率<strong>线性爬升</strong>：</p>
<pre><code>lr(step) = max_lr × (step + 1) / warmup_steps     （step &lt; 50 时）
</code></pre>
<p>代入 <code>max_lr = 0.003</code>、<code>warmup_steps = 50</code>：</p>
<table>
<thead>
<tr>
<th>scheduler.step</th>
<th>计算</th>
<th>lr</th>
</tr>
</thead>
<tbody>
<tr>
<td>0（真正用于第 1 步更新）</td>
<td>0.003 × 1 / 50</td>
<td>0.00006</td>
</tr>
<tr>
<td>1</td>
<td>0.003 × 2 / 50</td>
<td>0.00012</td>
</tr>
<tr>
<td>25</td>
<td>0.003 × 26 / 50</td>
<td>0.00156</td>
</tr>
<tr>
<td>50（warmup 结束）</td>
<td>0.003 × 51 / 50</td>
<td>≈ 0.00306（峰值）</td>
</tr>
</tbody>
</table>
<blockquote>
<p>注意：demo 的 <code>eval_every = 100</code>，warmup 段（step 0-49）<strong>没有打印点</strong>，所以真实日志里看不到 0.00006 起步的爬升。
把 <code>eval_every</code> 改成 10，就能看到 step 10/20/30/40 的 lr = <code>0.0006 → 0.0012 → 0.0018 → 0.0024</code>
（每步增加 <code>0.003/50 = 0.00006</code>，10 步就是 0.0006）。</p>
<p>为什么要 warmup？训练刚开始时参数是随机值，梯度方向噪声大、量级不可控。如果一上来就用 0.003 的大步长，很容易把参数&quot;推飞&quot;（loss 直接变成 NaN）。先用小步长稳住方向，再逐渐加力，是现代 LLM 训练的标准做法。</p>
</blockquote>
<h3>5.4 cosine 衰减：从峰值平滑降回 min_lr</h3>
<p>第 50 步之后走 cosine 曲线，从 <code>max_lr = 0.003</code> 平滑降到 <code>min_lr = 0.003 × 0.1 = 0.0003</code>：</p>
<pre><code>lr = min_lr + (max_lr - min_lr) × 0.5 × (1 + cos(π × progress))
progress = (step - 50) / (600 - 50)，超过 1 就截断到 1
</code></pre>
<p>验证日志里的两个数字：</p>
<ul>
<li><code>step 100</code>：scheduler 计数 = 99，<code>progress = (99-50)/550 ≈ 0.089</code>，<code>cosine ≈ 0.9805</code>，<code>lr = 0.0003 + 0.0027×0.9805 ≈ 0.002947</code> ✓</li>
<li><code>step 600</code>：scheduler 计数 = 599，<code>progress = (599-50)/550 ≈ 0.998</code>，<code>cosine ≈ 0</code>，<code>lr ≈ min_lr = 0.000300</code> ✓</li>
</ul>
<p>学习率全程曲线：</p>
<pre><code>lr
│
0.003 ┤        ╭╮
      │       ╭╯ ╰╮
0.002 ┤      ╭╯    ╰╮
      │     ╭╯      ╰╮
0.001 ┤    ╭╯        ╰╮
      │   ╭╯          ╰╮
0.0003┤──╯             ╰────── (min_lr)
      └──┬────┬────┬────┬────→ step
         0   100  200  300  400  500  600
         └warmup(50步)┘└─── cosine 衰减 ───┘
</code></pre>
<p>后期的&quot;小步慢走&quot;是为了在 loss 接近收敛时不震荡、精细地落到更优的参数点。</p>
<h2>6. 生成文本与采样参数</h2>
<p>训练 600 步后调用 <code>generate</code>（<code>src/sample.rs</code>），参数 <code>(prompt, max_new=80, temperature=0.8, top_k=10, top_p=0.9)</code>。</p>
<p><code>sample_token</code> 内部的 6 步采样管线：</p>
<table>
<thead>
<tr>
<th>步骤</th>
<th>代码</th>
<th>作用</th>
</tr>
</thead>
<tbody>
<tr>
<td>1. 温度缩放</td>
<td><code>l / temperature.max(1e-5)</code></td>
<td>除以 0.8：logits 变大 → softmax 更&quot;锐利&quot;，更敢选高概率 token</td>
</tr>
<tr>
<td>2. 排序</td>
<td><code>items.sort_by(...)</code></td>
<td>按分数从高到低排</td>
</tr>
<tr>
<td>3. top-k</td>
<td><code>items.truncate(top_k)</code></td>
<td>只留前 10 个</td>
</tr>
<tr>
<td>4. softmax</td>
<td><code>(*v - max).exp()</code> 再归一化</td>
<td>把截断后的分数变成概率</td>
</tr>
<tr>
<td>5. top-p</td>
<td>累积概率到 0.9 截断</td>
<td>进一步砍掉长尾低概率 token，再归一化</td>
</tr>
<tr>
<td>6. 抽样</td>
<td><code>rng.next_f32()</code> 按概率累积选取</td>
<td>有随机性地选一个 token</td>
</tr>
</tbody>
</table>
<p>真实生成结果（<code>cargo run --release</code> 原样输出）：</p>
<pre><code>  —— 生成 1（temperature=0.8, top-k=10, top-p=0.9, 无 KV cache）——
  Once upon a time in a small village, there lived a curious little fox named Red. Every morn

  —— 生成 2（temperature=0.8, top-k=10, top-p=0.9, 带 KV cache）——
  The fox named all is s fend the g
</code></pre>
<p>（生成 2 用的是另一个 prompt &quot;The fox&quot;，且因缓存模式上下文达到 block_size=32 提前停止，第 18 课会专门讲；生成 1 在无缓存模式下把 80 个新字符完整生成完了。）</p>
<p>读这段输出：模型学会了故事的结构——&quot;Once upon a time...&quot; 开头、&quot;in a small village, there lived a curious little fox named Red&quot; 几乎完整复现语料原文、主谓宾、句号逗号。<strong>字面上&quot;像样&quot;，但仔细读全是毛病</strong>：生成 2 的 &quot;named all is s fend the g&quot; 语法不通、句子戛然而止（block_size 截断）。这就是下一节要回答的问题。</p>
<h2>7. 为什么小模型输出只是&quot;像样&quot;而非&quot;正确&quot;</h2>
<p>四个层面叠加，缺一不可：</p>
<table>
<thead>
<tr>
<th>原因</th>
<th>说明</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>语料太小</strong></td>
<td>只有 669 字符、单一故事。模型只能&quot;背&quot;这篇课文里的统计规律，从未见过通用英语，谈不上泛化</td>
</tr>
<tr>
<td><strong>模型太小</strong></td>
<td>10 万参数 vs 真实 LLM 的数十亿～万亿参数。容量只够记住局部 n-gram 统计（&quot;Red&quot; 后常跟动词、名词前常有 the），装不下真正的语法规则</td>
</tr>
<tr>
<td><strong>训练不足</strong></td>
<td>600 步后 loss 仍为 0.16（正确概率约 85%），还没收敛到 0。模型对很多位置仍&quot;没把握&quot;</td>
</tr>
<tr>
<td><strong>采样带随机性</strong></td>
<td>temperature=0.8 + top-k/top-p 是有意引入随机性。即使模型 100% 会预测 &quot;world&quot;，采样也可能选到 &quot;wold&quot;——这是&quot;创造性&quot;的代价</td>
</tr>
</tbody>
</table>
<p>用一句话总结：<strong>&quot;像样&quot;来自学到了语料的高频统计规律；&quot;不正确&quot;来自语料/模型/训练都不足以学到完整语法，再加上采样本身的随机性。</strong> 想要更&quot;正确&quot;，方向是加大语料、加大模型、多训几步（后面第 19、20、21 课还会继续优化），但永远不可能在 669 字符上学出真正的英语——这也侧面说明了为什么现代 LLM 需要 TB 级数据和千亿参数。</p>
<h2>8. 动手练习</h2>
<ol>
<li><strong>改种子观察差异</strong>：把 <code>demo_gpt</code> 里 <code>Rng::new(1234)</code> 改成别的数字（如 42），重新 <code>cargo run --release</code>。loss 曲线和生成文本都会变——思考：为什么损失曲线也会变？（提示：采样 batch 的随机起点变了）</li>
<li><strong>改 warmup</strong>：把 <code>train_gpt</code> 的 <code>warmup_steps</code> 从 50 改成 5 和 500，分别跑一次，对比前 100 步的 loss。体会&quot;warmup 太短容易起飞、太长浪费步数&quot;。</li>
<li><strong>改生成参数</strong>：把 <code>generate</code> 的 <code>temperature</code> 改成 0.2 和 1.5 各跑一次。观察文本变得更&quot;死板/重复&quot;还是更&quot;发散/乱&quot;。</li>
<li><strong>数 token</strong>：验证第 5.4 节——打印 <code>scheduler.lr()</code> 在 step 100、600 的计算过程，对照日志里的 <code>0.002947</code> 和 <code>0.000300</code>。</li>
<li><strong>思考</strong>：loss 从 1.46 降到 0.16，但为什么不能说&quot;模型学会了英语&quot;？模型&quot;学会&quot;的到底是什么？</li>
</ol>
<h2>9. 本课总结</h2>
<ul>
<li>
<p><code>demo_gpt</code> 五步走：分词 → 建模型 → 造数据 → <code>train_gpt</code> 训练 600 步 → <code>generate</code> 采样生成</p>
</li>
<li>
<p>数据是自监督的：x 是 32 个 token，y 是 x 右移一位，预测&quot;下一个字符&quot;</p>
</li>
<li>
<p>真实日志：loss <code>1.46 → 0.16</code>，前 300 步降得最快；lr 从 <code>0.002947</code> 一路 cosine 衰减到 <code>0.000300</code>（warmup 段因 <code>eval_every=100</code> 没有打印点）</p>
</li>
<li>
<p>生成用 <code>temperature=0.8 + top-k=10 + top-p=0.9</code>：先缩放、再截断、再按概率随机抽样</p>
</li>
<li>
<p>小模型输出&quot;像样而非正确&quot;：语料太小、模型太小、训练不足、采样随机，四者叠加</p>
</li>
<li>
<p>下一课：换掉朴素的 SGD，给优化器装上&quot;动量 + 自适应步长 + 权重衰减&quot;——AdamW。</p>
</li>
</ul>
]]></content>
  <category term="Rust"/>
  <category term="LLM"/>
  <category term="GPT"/>
</entry>
<entry>
  <title>第 15 课：推理与采样 —— 让模型「创造性」地生成</title>
  <link href="https://ganto.me/post/rust-llm-guide-15"/>
  <id>https://ganto.me/post/rust-llm-guide-15</id>
  <updated>2026-09-16T00:00:00+08:00</updated>
  <published>2026-09-16T00:00:00+08:00</published>
  <author><name>干徒</name></author>
  <summary><![CDATA[实现自回归推理与 temperature 采样，让模型逐 token 生成文本。]]></summary>
  <content type="html"><![CDATA[<h1>第 15 课：推理与采样 —— 让模型&quot;创造性&quot;地生成</h1>
<blockquote>
<p>代码位置：<a href="src/sample.rs">src/sample.rs</a>
模型接口：<a href="src/model.rs">src/model.rs</a>
演示入口：<a href="src/main.rs">src/main.rs</a></p>
</blockquote>
<h2>1. 本课要搞懂的问题</h2>
<ol>
<li>模型输出的 logits 是分数，怎么变成真正的文本？</li>
<li>为什么&quot;每次都选概率最大的 token&quot;（argmax）效果很差？</li>
<li>temperature、top-k、top-p 分别解决了什么问题？它们在代码里怎么实现？</li>
<li><code>generate</code> 生成一句文本时，每步发生了什么？KV cache 模式和全量模式有什么不同？</li>
</ol>
<h2>2. 生成的基本流程</h2>
<p>训练完成后（第 13-14 课），模型已经学会了&quot;给前缀，预测下一个 token&quot;。生成文本就是把这件事<strong>反复做</strong>：</p>
<pre><code>&quot;Once upon a&quot;
   │ 前向：model.forward(ctx, 1, T, ...)
   ▼
logits（每个 token 一个分数，未归一化）
   │ 采样：sample_token(logits, T=0.8, top_k=10, top_p=0.9, rng)
   ▼
下一个 token id
   │ 拼回上下文
   ▼
&quot;Once upon a time&quot; → 再前向 → 再采样 → …… 直到 max_new 个 token
</code></pre>
<p>关键在于中间那一步：<strong>logits 是分数，不是文本</strong>。怎么从分数里选 token，决定了生成质量。</p>
<h2>3. 为什么不能直接 argmax</h2>
<p>最朴素的想法：把 logits 做 softmax 变成概率，然后<strong>永远选概率最大的</strong>（这就是贪心解码 / argmax）。它的毛病很典型：</p>
<table>
<thead>
<tr>
<th>现象</th>
<th>原因</th>
</tr>
</thead>
<tbody>
<tr>
<td>重复、呆板</td>
<td>一旦某个 token 概率最高，之后每一步都会倾向选它，陷入 &quot;the the the the...&quot; 的死循环</td>
</tr>
<tr>
<td>毫无惊喜</td>
<td>概率第二、第三的候选被完全无视，模型&quot;不敢&quot;走任何低概率但合理的路</td>
</tr>
<tr>
<td>容易跑偏</td>
<td>一步选错（概率 0.4 的次优解可能是对的），后续全部跟着错，且无法回头</td>
</tr>
</tbody>
</table>
<p>大模型的真实需求是&quot;<strong>在合理的候选里随机一点</strong>&quot;：既不能完全确定（呆板），也不能完全乱来（胡言乱语）。于是有了下面三个旋钮。</p>
<h2>4. temperature：调节分布的&quot;锐度&quot;</h2>
<p>softmax 的公式，引入温度 T 后：</p>
<pre><code>pᵢ = exp(zᵢ / T) / Σⱼ exp(zⱼ / T)
</code></pre>
<table>
<thead>
<tr>
<th>T 的取值</th>
<th>效果</th>
<th>直觉</th>
</tr>
</thead>
<tbody>
<tr>
<td>T &lt; 1（如 0.8）</td>
<td>分数差距被放大，分布更&quot;尖&quot;</td>
<td>更确定、更保守、更连贯</td>
</tr>
<tr>
<td>T = 1</td>
<td>标准 softmax</td>
<td>默认</td>
</tr>
<tr>
<td>T &gt; 1（如 1.5）</td>
<td>分数差距被压缩，分布更&quot;平&quot;</td>
<td>更随机、更有创造力、更易出错</td>
</tr>
</tbody>
</table>
<p>代码（<a href="src/sample.rs">src/sample.rs</a> <code>sample_token</code> 第 1 步）：</p>
<pre><code class="language-rust">let scaled: Vec&lt;f32&gt; = logits
    .iter()
    .map(|&amp;l| l / temperature.max(1e-5))   // 防止 T=0 除零
    .collect();
</code></pre>
<p>数值例子：logits = <code>[1.0, 2.0, 0.5, 0.1]</code>（词表 4 个 token）：</p>
<table>
<thead>
<tr>
<th>温度</th>
<th>scaled</th>
<th>softmax 概率</th>
<th>token 1 概率</th>
</tr>
</thead>
<tbody>
<tr>
<td>T = 0.5</td>
<td>[2.0, 4.0, 1.0, 0.2]</td>
<td>[0.11, <strong>0.83</strong>, 0.04, 0.02]</td>
<td>0.83（更&quot;确定&quot;）</td>
</tr>
<tr>
<td>T = 1.0</td>
<td>[1.0, 2.0, 0.5, 0.1]</td>
<td>[0.21, <strong>0.57</strong>, 0.13, 0.09]</td>
<td>0.57</td>
</tr>
<tr>
<td>T = 2.0</td>
<td>[0.5, 1.0, 0.25, 0.05]</td>
<td>[0.25, <strong>0.41</strong>, 0.19, 0.16]</td>
<td>0.41（更&quot;随机&quot;）</td>
</tr>
</tbody>
</table>
<h2>5. top-k：只在前 k 个里选</h2>
<p>思想：分数排最后的那些 token 本来就是&quot;凑数&quot;的，干脆把它们从候选里删掉，只在前 k 个里分配概率。</p>
<p>代码（<code>sample_token</code> 第 2-3 步：先按分数从高到低排序，再截断）：</p>
<pre><code class="language-rust">let mut items: Vec&lt;(usize, f32)&gt; = scaled.iter().enumerate().map(|(i, &amp;v)| (i, v)).collect();
items.sort_by(|a, b| b.1.partial_cmp(&amp;a.1).unwrap_or(std::cmp::Ordering::Equal));

if top_k &gt; 0 &amp;&amp; items.len() &gt; top_k {
    items.truncate(top_k);   // top_k = 0 表示不启用
}
</code></pre>
<h2>6. top-p（nucleus）：按累积概率截断</h2>
<p>思想：不看&quot;固定个数&quot;，而是<strong>从高到低累加概率，直到累积概率达到 p</strong>，把后面的全部丢掉。候选集合大小随分布自动变化——分布集中时集合小，分布分散时集合大。</p>
<p>代码（<code>sample_token</code> 第 5 步，注意它作用在 softmax 之后的概率上）：</p>
<pre><code class="language-rust">if top_p &lt; 1.0 {
    let mut cum = 0.0;
    let mut keep = items.len();
    for (i, p) in probs.iter().enumerate() {
        cum += p;
        if cum &gt;= top_p { keep = i + 1; break; }
    }
    items.truncate(keep);   // 丢掉尾部
    probs.truncate(keep);
    let s: f32 = probs.iter().sum();
    for p in probs.iter_mut() { *p /= s; }   // 重新归一化
}
</code></pre>
<p>沿用上面的例子（T=1.0，概率 [0.21, 0.57, 0.13, 0.09]，已按分数排序）：</p>
<pre><code>top_p = 0.9：
  0.57  → 累计 0.57（&lt; 0.9 继续）
  0.21  → 累计 0.78（&lt; 0.9 继续）
  0.13  → 累计 0.91（≥ 0.9 停）→ 保留前 3 个，丢弃第 4 个
</code></pre>
<blockquote>
<p>相比 top-k 的&quot;一刀切固定个数&quot;，top-p 更聪明：概率分布尖的时候只留 1-2 个候选，平的时候留一堆。实践中 top-k 和 top-p 经常<strong>一起开</strong>（本项目 main.rs 里就是 <code>top_k=10, top_p=0.9</code> 同时启用）。</p>
</blockquote>
<h2>7. 汇总：sample_token 的完整六步</h2>
<p><a href="src/sample.rs">src/sample.rs</a> 的 <code>sample_token</code> 把上面所有招数串起来：</p>
<table>
<thead>
<tr>
<th>步骤</th>
<th>做什么</th>
<th>对应代码</th>
</tr>
</thead>
<tbody>
<tr>
<td>1</td>
<td>除以 temperature 缩放</td>
<td><code>l / temperature.max(1e-5)</code></td>
</tr>
<tr>
<td>2</td>
<td>按分数从高到低排序</td>
<td><code>items.sort_by(...)</code></td>
</tr>
<tr>
<td>3</td>
<td>top-k 截断（k&gt;0 时）</td>
<td><code>items.truncate(top_k)</code></td>
</tr>
<tr>
<td>4</td>
<td>softmax 转概率（减最大值防溢出）</td>
<td><code>(*v - max).exp()</code> 再归一化</td>
</tr>
<tr>
<td>5</td>
<td>top-p 按累积概率截断并重新归一化</td>
<td><code>cum &gt;= top_p</code> 时 <code>truncate</code></td>
</tr>
<tr>
<td>6</td>
<td>按概率随机采样</td>
<td><code>rng.next_f32()</code> 做轮盘赌</td>
</tr>
</tbody>
</table>
<p>第 6 步的&quot;轮盘赌&quot;采样：</p>
<pre><code class="language-rust">let mut u = rng.next_f32();          // 均匀随机数 [0, 1)
for (i, p) in probs.iter().enumerate() {
    if u &lt; *p { return items[i].0; } // 落进第 i 段的概率 = pᵢ
    u -= p;                          // 没落进就减去这段，继续往后看
}
items.last().map(|(i, _)| *i).unwrap_or(0)
</code></pre>
<p>数学上：选到 token i 的概率正好等于 pᵢ。这保证了——概率高的 token 被选中的机会大，但概率低的也有机会被选中，这就是&quot;随机性&quot;的来源。</p>
<h2>8. generate：一步一步生成整句文本</h2>
<p>全量模式（无 KV cache）的流程，<a href="src/sample.rs">src/sample.rs</a>：</p>
<pre><code class="language-rust">let block_size = model.cfg.block_size;
let mut ids = tokenizer.encode(prompt);        // 起始文本 → id 序列
let mut cache = model.new_kv_cache();

for _ in 0..max_new {
    if use_kv_cache &amp;&amp; cache[0].seq_len() &gt;= block_size { break; }  // KV 模式的长度上限

    let start = ids.len().saturating_sub(block_size);   // 只保留最近 block_size 个
    let ctx = &amp;ids[start..];

    let logits = if use_kv_cache {
        if cache[0].seq_len() == 0 {
            model.forward(ctx, 1, ctx.len(), Some(&amp;mut cache))      // 首次：喂整个 prompt
        } else {
            model.forward(&amp;ids[ids.len() - 1..], 1, 1, Some(&amp;mut cache))  // 之后：只算最新 1 个
        }
    } else {
        model.forward(ctx, 1, ctx.len(), None)   // 全量：每次重算整个上下文
    };

    let v = model.cfg.vocab_size;
    let n = logits.numel();
    let last_row = &amp;logits.data()[n - v..];      // 取最后一个位置的 logits
    let next = sample_token(last_row, temperature, top_k, top_p, rng);
    ids.push(next);                              // 拼回上下文，进入下一轮
}

tokenizer.decode(&amp;ids)                           // id 序列 → 文本
</code></pre>
<p>逐步拆解：</p>
<ol>
<li><strong>编码</strong>：<code>tokenizer.encode(prompt)</code> 把起始文本变成 id 序列。</li>
<li><strong>截断上下文</strong>：<code>ctx = ids[ids.len() - block_size ..]</code>，上下文超过 <code>block_size</code> 时只保留最近的一段——模型&quot;记不住&quot;更早的历史。</li>
<li><strong>前向拿 logits</strong>：全量模式下每次都把整个 <code>ctx</code> 重新算一遍（慢，但无需额外内存）。</li>
<li><strong>取最后一个位置</strong>：前向输出形状是 <code>[B*T, vocab_size]</code>，这里 B=1，最后一个位置即 <code>data[n-v..]</code>，它对应&quot;基于当前全部上下文预测的下一个 token&quot;。</li>
<li><strong>采样并拼接</strong>：<code>sample_token</code> 选出一个 id，<code>ids.push(next)</code>，进入下一轮。</li>
<li><strong>解码</strong>：循环结束后 <code>tokenizer.decode(&amp;ids)</code> 把整条序列还原成文本（包含 prompt 和生成的部分）。</li>
</ol>
<h2>9. KV cache 模式的上下文限制：block_size 截断</h2>
<p>KV cache（第 18 课详解）的思路：生成第 N 个 token 时，前 N-1 个 token 的 K/V 不需要重算，缓存起来每步只算最新的 1 个 token，大幅加速推理。但它带来一个限制，代码注释里写得很明白：</p>
<pre><code class="language-rust">// KV cache 模式：上下文总长达到 block_size 就停（缓存无法像全量模式那样截断历史）
if use_kv_cache &amp;&amp; cache[0].seq_len() &gt;= block_size {
    break;
}
</code></pre>
<p>两种模式对&quot;超长上下文&quot;的处理对比：</p>
<table>
<thead>
<tr>
<th>模式</th>
<th>上下文超过 block_size 时</th>
<th>代价</th>
</tr>
</thead>
<tbody>
<tr>
<td>全量（<code>use_kv_cache = false</code>）</td>
<td>每次把 <code>ctx</code> 截断到最近 block_size 个 token，可以无限生成</td>
<td>每步都要重算整个上下文，慢</td>
</tr>
<tr>
<td>KV cache（<code>use_kv_cache = true</code>）</td>
<td>缓存里已经累积了全部历史 K/V，<strong>无法丢弃</strong>，只能停止生成</td>
<td>生成的 token 总数被限制在 <code>block_size</code> 以内</td>
</tr>
</tbody>
</table>
<blockquote>
<p>所以 KV cache 在 <code>max_new</code> 还没用完时可能提前 <code>break</code>——这是&quot;加速&quot;换来的&quot;长度上限&quot;。main.rs 里两个生成演示（<code>false</code> 和 <code>true</code>）生成内容高度一致，正是&quot;cache 只改计算方式、不改生成分布&quot;的验证（提示词短时不会触发截断）。</p>
</blockquote>
<h2>10. 参数怎么配：一个经验表</h2>
<table>
<thead>
<tr>
<th>场景</th>
<th>temperature</th>
<th>top-k</th>
<th>top-p</th>
</tr>
</thead>
<tbody>
<tr>
<td>要求准确、连贯（代码、摘要）</td>
<td>0.2 ~ 0.7</td>
<td>小（5~20）</td>
<td>0.8 ~ 0.9</td>
</tr>
<tr>
<td>通用对话</td>
<td>0.7 ~ 0.9</td>
<td>30~50</td>
<td>0.9 ~ 0.95</td>
</tr>
<tr>
<td>创意写作、头脑风暴</td>
<td>0.9 ~ 1.2</td>
<td>大或关闭</td>
<td>0.95 ~ 1.0</td>
</tr>
</tbody>
</table>
<p>main.rs 里的演示配置：<code>temperature=0.8, top_k=10, top_p=0.9</code>，是一个偏保守、够通顺的组合。</p>
<h2>11. 动手练习</h2>
<ol>
<li>把 <code>generate</code> 的 <code>temperature</code> 分别改成 0.1 和 2.0 跑一遍，观察输出从&quot;复读机&quot;到&quot;胡言乱语&quot;的变化。</li>
<li>把 <code>top_k</code> 改成 1（等价于只在概率最高 token 附近贪心）再跑，观察重复现象。</li>
<li>把 <code>top_p</code> 改成 1.0（关闭）但保留 <code>top_k=10</code>，对比输出差异。</li>
<li>在 <code>sample_token</code> 的轮盘赌循环里打印每次的 <code>u</code> 和命中的 token，手动验证&quot;命中概率 ≈ pᵢ&quot;。</li>
<li>用足够长的 prompt（超过 <code>block_size</code>，如 40 个字符）分别跑 <code>use_kv_cache = false</code> 和 <code>true</code>，观察 KV 模式提前停止生成的现象。</li>
</ol>
<h2>12. 本课总结</h2>
<ul>
<li>生成 = 前向拿 logits → 采样选 token → 拼回上下文 → 循环</li>
<li><strong>argmax 不可取</strong>：必然重复、呆板、无法回头</li>
<li>三个旋钮：<strong>temperature</strong> 调分布锐度、<strong>top-k</strong> 按个数截断、<strong>top-p</strong> 按累积概率截断，最后按概率随机采样</li>
<li><code>sample_token</code> 六步：缩放 → 排序 → top-k → softmax → top-p → 轮盘赌</li>
<li><code>generate</code> 全量模式每次重算上下文；KV cache 模式加速但受 <code>block_size</code> 长度限制，超限即停</li>
<li>下一步（第 16 课）：把所有零件拼起来，训练一个小 GPT 并生成有意义的长文本</li>
</ul>
]]></content>
  <category term="Rust"/>
  <category term="LLM"/>
  <category term="推理"/>
</entry>
<entry>
  <title>第 14 课：数据加载 —— 文本如何变成训练样本</title>
  <link href="https://ganto.me/post/rust-llm-guide-14"/>
  <id>https://ganto.me/post/rust-llm-guide-14</id>
  <updated>2026-09-15T00:00:00+08:00</updated>
  <published>2026-09-15T00:00:00+08:00</published>
  <author><name>干徒</name></author>
  <summary><![CDATA[实现语料库与 DataLoader，把文本切分为输入-目标训练样本。]]></summary>
  <content type="html"><![CDATA[<h1>第 14 课：数据加载 —— 文本如何变成训练样本</h1>
<blockquote>
<p>代码位置：<a href="src/data.rs">src/data.rs</a>
训练入口：<a href="src/train.rs">src/train.rs</a>（第 13 课）</p>
</blockquote>
<h2>1. 本课要搞懂的问题</h2>
<ol>
<li>语言模型的训练数据长什么样？一篇文章怎么变成一条条&quot;题目&quot;？</li>
<li>什么是自监督学习？为什么训练 GPT 不需要人工标注？</li>
<li>训练样本里的 x 和 y 是怎么配对的？为什么说 y 是 x 平移一位？</li>
<li><code>batch_size</code> 和 <code>block_size</code> 到底是什么含义？代码里怎么体现？</li>
</ol>
<h2>2. 从文本到训练数据：三步走</h2>
<p>训练 GPT 不需要人工标注，只需要<strong>大量纯文本</strong>。数据流水线是：</p>
<pre><code>原始文本（CORPUS）
   │ ① 分词 tokenize（第 8 课：CharTokenizer.encode）
   ▼
token id 序列  [t₀, t₁, t₂, t₃, ...]
   │ ② 切窗口：每 block_size 个 token 一段
   ▼
输入窗口 x
   │ ③ 配答案：x 右移一位得到 y
   ▼
训练样本 (x, y)
</code></pre>
<p>本项目的语料是内置在 <a href="src/data.rs">src/data.rs</a> 里的一个英文小故事：</p>
<pre><code class="language-rust">pub const CORPUS: &amp;str = &quot;\
Once upon a time in a small village, there lived a curious little fox named Red. \
... every adventure begins with a single step.&quot;;
</code></pre>
<h2>3. 第一步：文本 → token id</h2>
<p>模型只认识数字，所以先用第 8 课的 <code>CharTokenizer</code>（字符级分词）把文本变成 id 序列：</p>
<pre><code class="language-rust">let tokenizer = CharTokenizer::new(CORPUS);   // 词表 = 语料中所有出现过的字符
let tokens = tokenizer.encode(text);          // &quot;Once&quot; -&gt; [O, n, c, e] 的 id 序列
</code></pre>
<p>在本项目中每个字符就是一个 token（一个 <code>usize</code>），例如故事开头的 id 序列就是：</p>
<pre><code>'O' 'n' 'c' 'e' ' ' 'u' 'p' 'o' 'n' ' ' 'a' ' ' 't' 'i' 'm' 'e' ' ' ...
 0   1   2   3   4   5   6   7   8   9  10  11  12  13  14  15   4  ...
</code></pre>
<h2>4. 自监督：预测下一个 token</h2>
<p><strong>训练目标一句话：给一段前缀，让模型预测下一个 token 是什么。</strong></p>
<pre><code>输入：Once upon a ti
输出：m     ← 下一个字符（来自原文）

输入：Once upon a tim
输出：e     ← 下一个字符

输入：Once upon a time
输出：空格  ← 下一个字符
</code></pre>
<p>这为什么叫<strong>自监督</strong>？因为&quot;标准答案&quot;就藏在文本自己身上——预测&quot;下一个 token&quot;，标签就是原文里紧跟其后的那个 token，<strong>不需要任何人去标注</strong>。模型只要在海量文本上反复做这件事，就能学会语法、语义和世界知识。这也是 ChatGPT 这类大模型训练的第一阶段。</p>
<h2>5. 第二步 + 第三步：x/y 配对（平移一位）</h2>
<p><code>DataLoader::sample_batch</code> 的核心逻辑（<a href="src/data.rs">src/data.rs</a>）：</p>
<pre><code class="language-rust">pub fn sample_batch(&amp;self, rng: &amp;mut Rng) -&gt; (Vec&lt;usize&gt;, Vec&lt;usize&gt;) {
    let max_start = self.tokens.len() - self.block_size - 1;
    let mut x = Vec::with_capacity(self.batch_size * self.block_size);
    let mut y = Vec::with_capacity(self.batch_size * self.block_size);
    for _ in 0..self.batch_size {
        let start = rng.choice(max_start);          // 随机选一个起点
        for j in 0..self.block_size {
            x.push(self.tokens[start + j]);         // 窗口 [start, start+T)
            y.push(self.tokens[start + j + 1]);     // 窗口 [start+1, start+T+1)
        }
    }
    (x, y)
}
</code></pre>
<p>关键就两行：</p>
<ul>
<li><code>x.push(tokens[start + j])</code>：x 的第 j 个 token 是窗口里的第 j 个</li>
<li><code>y.push(tokens[start + j + 1])</code>：y 的第 j 个 token 是 x 的第 j 个 token 的<strong>下一个</strong></li>
</ul>
<p>所以 y 恰好是 x 向右平移一位，一一对应：</p>
<pre><code>位置:  0   1   2   3   4   5  ...  T-1
x:   [t₀  t₁  t₂  t₃  t₄  t₅  ...  t_{T-1}]
y:   [t₁  t₂  t₃  t₄  t₅  t₆  ...  t_T    ]
       ↑   ↑   ↑
      x[0]的下一个 = y[0]，x[1]的下一个 = y[1]，……
</code></pre>
<h2>6. 具体例子：窗口从语料中切出来</h2>
<p>设 <code>block_size = 16</code>，随机起点 <code>start = 0</code>，故事开头 17 个字符为
<code>Once upon a time</code>（注意末尾还有个空格）。则这一个样本是：</p>
<pre><code>x = tokens[0..16]  = O n c e ' ' u p o n ' ' a ' ' t i m
y = tokens[1..17]  = n c e ' ' u p o n ' ' a ' ' t i m e
</code></pre>
<table>
<thead>
<tr>
<th>x（输入）</th>
<th>y（答案）</th>
<th>含义</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>O</code></td>
<td><code>n</code></td>
<td>看到 &quot;O&quot;，预测 &quot;n&quot;</td>
</tr>
<tr>
<td><code>n</code></td>
<td><code>c</code></td>
<td>看到 &quot;On&quot;，预测 &quot;c&quot;</td>
</tr>
<tr>
<td>...</td>
<td>...</td>
<td>...</td>
</tr>
<tr>
<td><code>m</code></td>
<td><code>e</code></td>
<td>看到 &quot;Once upon a tim&quot;，预测 &quot;e&quot;</td>
</tr>
</tbody>
</table>
<p>模型在这个样本上要同时做 T=16 次&quot;预测下一个 token&quot;，<code>cross_entropy_loss</code> 对它们取平均（第 13 课）。</p>
<blockquote>
<p><strong>为什么同一段文本要重复利用？</strong> 一个长度为 700+ 字符的语料，可以切成几百个<strong>互相重叠</strong>的窗口（起点 0、1、2、……都可以），一份语料就被&quot;榨出&quot;远超自身长度的训练样本。</p>
</blockquote>
<h2>7. <code>batch_size</code> 与 <code>block_size</code>：两个容易混淆的概念</h2>
<table>
<thead>
<tr>
<th>参数</th>
<th>英文含义</th>
<th>含义</th>
<th>代码中的体现</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>block_size</code></td>
<td>序列长度（上下文窗口）</td>
<td>每个样本里有多少个 token，也就是模型一次&quot;看&quot;多长的历史</td>
<td><code>x.push(tokens[start + j])</code> 循环 <code>0..block_size</code></td>
</tr>
<tr>
<td><code>batch_size</code></td>
<td>批大小</td>
<td>一次训练同时处理多少个独立样本</td>
<td>外层 <code>for _ in 0..self.batch_size</code></td>
</tr>
</tbody>
</table>
<p>它们合起来决定了一次前向的输入规模：</p>
<pre><code>一次前向的输入张量：[B*T] 展平 → model.forward 里 reshape 成 [B, T, D]
</code></pre>
<p>其中 <code>B = batch_size</code>（样本数）、<code>T = block_size</code>（每个样本的 token 数）。在 <code>train_gpt</code> 里：</p>
<pre><code class="language-rust">let (x, y) = loader.sample_batch(rng);
let b = batch_size;      // B = 8
let t = block_size;      // T = 32（GPTConfig::tiny 的默认值）
let logits = model.forward(&amp;x, b, t, None);   // [B*T, vocab_size]
</code></pre>
<p>用一张图理解 B 和 T：</p>
<pre><code>batch_size = 2, block_size = 4 时的一次采样：

         ┌───── 第 1 个样本（起点 start=0）─────┐
x = [ t0  t1  t2  t3 ]   y = [ t1  t2  t3  t4 ]
         ┌───── 第 2 个样本（起点 start=57）────┐
x = [ t57 t58 t59 t60 ]  y = [ t58 t59 t60 t61 ]
         └──────────────────────────────┘
         展平后 x 是长度 B*T = 8 的一维数组
</code></pre>
<blockquote>
<p>两个起点之间没有关系——每个样本是<strong>独立</strong>从语料里随机切的，这正是&quot;随机梯度下降&quot;里&quot;随机&quot;二字的来源。</p>
</blockquote>
<h2>8. 一个隐藏的断言：语料不能太短</h2>
<pre><code class="language-rust">assert!(tokens.len() &gt; block_size, &quot;语料太短，无法切出完整序列&quot;);
</code></pre>
<p><code>sample_batch</code> 里 <code>max_start = tokens.len() - block_size - 1</code> 还额外要求至少留 1 个 token 当 y 的尾巴（因为 y 比 x 多取一位）。如果语料比 <code>block_size</code> 还短，就切不出任何完整窗口，直接 panic 提示——这是第 1 课就强调的&quot;出错早、出错明显&quot;的防御性编程习惯。</p>
<h2>9. 动手练习</h2>
<ol>
<li>手动算一遍：语料有 700 个 token，<code>block_size = 32</code>，<code>max_start</code> 是多少？一共能切出多少个不同的起点？（答案：668）</li>
<li>把 <code>batch_size</code> 从 8 改成 1 再训练，观察 loss 曲线的抖动幅度（batch 越小噪声越大）。</li>
<li>把 <code>block_size</code> 改成 64（注意 <code>GPTConfig</code> 的 block_size 也要一起改）再训练，对比效果和训练速度。</li>
<li>思考：为什么 y 要比 x 多取一个 token（<code>tokens[start + block_size]</code>）？如果只取 <code>tokens[start..start+block_size]</code> 当 y 会有什么问题？</li>
</ol>
<h2>10. 本课总结</h2>
<ul>
<li>数据流水线：<strong>文本 → token id → 切窗口 → 配答案</strong></li>
<li>自监督：预测下一个 token，标签来自文本自身，无需人工标注</li>
<li>配对方式：y 是 x 右移一位，<code>y[j] = x[j] 的下一个 token</code></li>
<li><code>block_size</code> 控制&quot;看多长&quot;，<code>batch_size</code> 控制&quot;一次看几段&quot;，两者相乘是一次前向的 token 总数</li>
<li><code>sample_batch</code> 随机选起点切重叠窗口，把一份语料复用出大量样本</li>
<li>下一步（第 15 课）：训练好模型后，如何从输出的 logits 里&quot;采样&quot;出通顺、不呆板的文本</li>
</ul>
]]></content>
  <category term="Rust"/>
  <category term="LLM"/>
  <category term="数据"/>
</entry>
<entry>
  <title>第 13 课：训练循环 —— 让模型真正开始学习</title>
  <link href="https://ganto.me/post/rust-llm-guide-13"/>
  <id>https://ganto.me/post/rust-llm-guide-13</id>
  <updated>2026-09-14T00:00:00+08:00</updated>
  <published>2026-09-14T00:00:00+08:00</published>
  <author><name>干徒</name></author>
  <summary><![CDATA[编写完整的训练循环：前向、损失计算、反向传播与参数更新。]]></summary>
  <content type="html"><![CDATA[<h1>第 13 课：训练循环 —— 让模型真正开始学习</h1>
<blockquote>
<p>代码位置：<a href="src/train.rs">src/train.rs</a>
演示入口：<a href="src/main.rs">src/main.rs</a></p>
</blockquote>
<h2>1. 本课要搞懂的问题</h2>
<ol>
<li>训练一个模型，代码上到底要做哪几步？顺序为什么不能乱？</li>
<li><code>loss.backward()</code> 到底做了什么？为什么调用一次，所有参数的梯度就都有了？</li>
<li>梯度爆炸是什么？<code>clip_grad_norm</code> 是怎么防止它的？</li>
<li>为什么每步训练结束都要&quot;清零梯度&quot;？不清零会怎样？</li>
</ol>
<h2>2. 训练循环总览：六步走</h2>
<p>训练 = 把&quot;前向 → 算损失 → 反向 → 更新&quot;这个动作反复执行成千上万次。
<code>train_gpt</code> 里每一次循环（一个 step）严格按下面的顺序执行：</p>
<table>
<thead>
<tr>
<th>步骤</th>
<th>动作</th>
<th>代码</th>
<th>干什么</th>
</tr>
</thead>
<tbody>
<tr>
<td>1</td>
<td>采样 batch</td>
<td><code>loader.sample_batch(rng)</code></td>
<td>从语料里随机取一批 (x, y) 训练对</td>
</tr>
<tr>
<td>2</td>
<td>前向 + 损失</td>
<td><code>model.forward(...)</code> → <code>cross_entropy_loss(...)</code></td>
<td>让模型预测一遍，算出&quot;错得有多离谱&quot;</td>
</tr>
<tr>
<td>3</td>
<td>反向</td>
<td><code>loss.backward()</code></td>
<td>沿计算图把损失对每个参数的偏导（梯度）算出来</td>
</tr>
<tr>
<td>4</td>
<td>梯度裁剪</td>
<td><code>clip_grad_norm(&amp;params, 1.0)</code></td>
<td>总梯度范数超阈值就等比缩小，防梯度爆炸</td>
</tr>
<tr>
<td>5</td>
<td>更新参数</td>
<td><code>opt.step()</code></td>
<td>按梯度方向调整每个参数，让损失变小</td>
</tr>
<tr>
<td>6</td>
<td>清零梯度</td>
<td><code>opt.zero_grad()</code></td>
<td>把梯度归零，为下一步重新计算做准备</td>
</tr>
</tbody>
</table>
<blockquote>
<p><strong>顺序不能乱</strong>：前向必须发生在反向之前（没有前向就没有计算图）；更新必须发生在反向之后（没有梯度就没东西可更新）；清零必须放在最后（否则下一步的梯度会叠加在旧梯度上）。</p>
</blockquote>
<p>对应代码（<a href="src/train.rs">src/train.rs</a> 中的 <code>train_gpt</code>，省略了日志打印）：</p>
<pre><code class="language-rust">for step in 0..steps {
    // 1. 采样 batch
    let (x, y) = loader.sample_batch(rng);
    let b = batch_size;
    let t = block_size;

    // 2. 前向 + 损失
    let logits = model.forward(&amp;x, b, t, None);
    let loss = cross_entropy_loss(&amp;logits, &amp;y);

    // 3. 反向
    loss.backward();

    // 4. 梯度裁剪
    clip_grad_norm(&amp;params, 1.0);

    // 5. 更新参数（设置当前学习率）
    opt.lr = scheduler.lr();
    opt.step();

    // 6. 清零梯度
    opt.zero_grad();
    scheduler.step();
}
</code></pre>
<p>下面把这六步逐一拆开讲。</p>
<h2>3. 准备阶段：参数、优化器、学习率调度器</h2>
<p>进入循环前，<code>train_gpt</code> 先做了三件准备工作：</p>
<pre><code class="language-rust">let params = model.parameters();                 // 收集模型所有参数
let mut opt = AdamW::new(max_lr, params.clone(), 0.01);   // 优化器（第 17 课详述）
let mut scheduler = LRScheduler::new(warmup_steps, steps, max_lr, max_lr * 0.1); // 学习率调度（第 20 课详述）
</code></pre>
<ul>
<li><code>parameters()</code>：通过第 5 课实现的 <code>Module</code> trait，把模型里所有可训练张量（embedding、每层 Linear、LayerNorm 的权重与偏置）收进一个 <code>Vec&lt;Tensor&gt;</code>。优化器更新和梯度裁剪都靠这份清单。</li>
<li><code>AdamW</code>：负责&quot;怎么更新参数&quot;，内部为每个参数维护动量 m 和二阶动量 v（第 17 课专门讲）。</li>
<li><code>LRScheduler</code>：决定每一步用多大的学习率——前期 warmup 从 0 线性爬升，后期 cosine 衰减。本课只需知道它输出一个学习率、赋值给 <code>opt.lr</code> 即可。</li>
</ul>
<h2>4. 第一步：采样 batch</h2>
<pre><code class="language-rust">let (x, y) = loader.sample_batch(rng);
</code></pre>
<p><code>x</code> 是一批&quot;输入 token&quot;，<code>y</code> 是&quot;标准答案&quot;（下一个 token）。它们怎么来的、<code>batch_size</code> 和 <code>block_size</code> 是什么意思，是<strong>第 14 课</strong>的主题，本课先把它当黑盒：每次调用就得到一批新的 (x, y)。</p>
<h2>5. 第二步：前向 + 损失</h2>
<pre><code class="language-rust">let logits = model.forward(&amp;x, b, t, None);          // [B*T, vocab_size]
let loss = cross_entropy_loss(&amp;logits, &amp;y);           // 标量
</code></pre>
<ul>
<li><code>model.forward</code> 让数据走一遍整个 Transformer，输出 <code>logits</code>：每个位置&quot;预测下一个 token&quot;的分数（未归一化），形状 <code>[B*T, vocab_size]</code>。</li>
<li><code>cross_entropy_loss</code>（第 6 课已实现）比较预测与标准答案 y：</li>
</ul>
<pre><code>loss = -mean( log softmax(logits)[i, y[i]] )
</code></pre>
<p>模型给正确 token 的概率越高，loss 越小；loss 为 0 意味着预测完全正确。训练的目标就是把这个数压到最低。</p>
<h2>6. 第三步：反向 —— <code>loss.backward()</code> 做了什么</h2>
<p>这是自动微分（第 2 课）的核心。看 <a href="src/autograd.rs">src/autograd.rs</a> 里的实现：</p>
<pre><code class="language-rust">pub fn backward(&amp;self) {
    assert_eq!(self.rank(), 0, &quot;backward() 只支持标量（0 维）输出&quot;);
    // 1. 先把输出自身的梯度设为 1（d loss / d loss = 1）
    self.grad.borrow_mut()[0] = 1.0;

    // 2. 迭代式 DFS 拓扑排序（避免递归栈溢出，见第 4 课 6.2 节）
    //    用显式栈模拟递归，得到&quot;先子节点后父节点&quot;的拓扑序
    let mut stack: Vec&lt;(Tensor, usize)&gt; = Vec::new();
    let mut visited: HashSet&lt;usize&gt; = HashSet::new();
    // ... 三色标记法遍历 ...

    // 3. 把拓扑序反过来：从 loss 开始，一层一层往回传梯度
    for t in order.iter().rev() {
        if let Some(b) = &amp;t.backward { b(); }
    }
}
</code></pre>
<p>拆开看有三步：</p>
<ol>
<li><strong>初始化</strong>：把 loss 自己的梯度设为 1.0。因为我们要算的是 <code>∂loss/∂θ</code>，链式法则的起点就是 <code>∂loss/∂loss = 1</code>。</li>
<li><strong>拓扑排序</strong>：前向时每个中间结果都记住了自己的 <code>parents</code>（谁算出了我）和 <code>backward</code>（如何把梯度传回给我的输入）。DFS 从 loss 出发往下走到所有叶子节点，得到&quot;先依赖、后被依赖&quot;的拓扑序。</li>
<li><strong>逆序传播</strong>：按拓扑序的反向（从 loss 往参数方向）依次调用每个节点的 <code>backward</code> 闭包。每个节点把自己的梯度 <code>g</code> 按链式法则乘上局部导数，<strong>累加</strong>到它的输入（包括参数）的梯度上。</li>
</ol>
<pre><code>loss ──► softmax ──► matmul ──► ... ──► Linear(W) ──► embedding
  g=1     累积到输入     累积到输入          累积到 W         累积到表
</code></pre>
<blockquote>
<p>为什么是&quot;累加&quot;而不是&quot;赋值&quot;？因为一个参数会被计算图中很多地方共用（比如同一个权重矩阵被整批样本共享），每个分支的贡献都要加在一起，这才是真正的偏导数。</p>
</blockquote>
<p>所以调用一次 <code>loss.backward()</code>，<strong>模型里所有参数</strong>（<code>params</code> 里每个张量）的 <code>.grad()</code> 就都拿到了 <code>∂loss/∂θ</code>。</p>
<h2>7. 第四步：梯度裁剪 <code>clip_grad_norm</code></h2>
<p><strong>问题：梯度爆炸。</strong> 深层网络 + 长序列训练时，链式法则连乘会导致梯度呈指数级放大，一步更新就可能把参数&quot;推飞&quot;，loss 直接变成 NaN/无穷大。</p>
<p><strong>对策：</strong> 在更新参数之前，先检查所有参数梯度的总范数；如果超过阈值 <code>max_norm</code>，就<strong>整体等比缩放</strong>，让方向不变、大小受控。</p>
<p>代码（<a href="src/train.rs">src/train.rs</a>）：</p>
<pre><code class="language-rust">pub fn clip_grad_norm(params: &amp;[Tensor], max_norm: f32) {
    // 1. 算总范数：所有梯度元素的平方和，再开根号
    let mut total = 0.0f32;
    for p in params {
        let g = p.grad();
        for &amp;v in &amp;g { total += v * v; }
    }
    let norm = total.sqrt();

    // 2. 超过阈值才裁剪：整体乘 scale = max_norm / norm
    if norm &gt; max_norm {
        let scale = max_norm / norm;
        for p in params {
            let g = p.grad();
            let scaled: Vec&lt;f32&gt; = g.iter().map(|&amp;v| v * scale).collect();
            p.grad_set(scaled);   // 覆盖写回（tensor.rs 里专为裁剪提供的 API）
        }
    }
}
</code></pre>
<p>对应公式：</p>
<pre><code>norm = sqrt( Σ gᵢ² )
gᵢ' = gᵢ · min(1, max_norm / norm)
</code></pre>
<table>
<thead>
<tr>
<th>情况</th>
<th>行为</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>norm ≤ max_norm</code></td>
<td>梯度正常，什么都不做</td>
</tr>
<tr>
<td><code>norm &gt; max_norm</code></td>
<td>所有梯度乘 <code>max_norm / norm</code>，总范数被压回 <code>max_norm</code></td>
</tr>
</tbody>
</table>
<blockquote>
<p>注意 <code>max_norm</code> 这里取 1.0，训练 LLM 时这是很常见的取值。裁剪<strong>不改变梯度方向</strong>，只是限制步长上限——这比粗暴地调低学习率更精准。</p>
</blockquote>
<h2>8. 第五步：更新参数 <code>opt.step()</code></h2>
<pre><code class="language-rust">opt.lr = scheduler.lr();   // 这一步用多大的学习率，由调度器决定
opt.step();                // 按梯度更新所有参数
</code></pre>
<p>本课用的是 AdamW（第 17 课详述），核心更新规则（简写）：</p>
<pre><code>m ← β₁·m + (1-β₁)·g           一阶动量（记住梯度方向）
v ← β₂·v + (1-β₂)·g²          二阶动量（感知坡度陡缓）
θ ← θ - lr·m̂/(√v̂ + ε) - lr·wd·θ
</code></pre>
<p>直觉：<strong>沿着梯度的反方向迈一小步</strong>，步子大小由学习率和&quot;历史上梯度的大小&quot;共同决定。与最朴素的 SGD（θ = θ - lr·g，第 6 课）相比，AdamW 对每个参数自适应地调整步长，训练更稳。</p>
<h2>9. 第六步：清零梯度 <code>opt.zero_grad()</code></h2>
<pre><code class="language-rust">opt.zero_grad();   // 内部就是对每个参数调用 p.zero_grad()
</code></pre>
<p>为什么必须清零？回顾第 6 节：反向传播是<strong>累加</strong>梯度。如果不清零，下一步前向+反向得到的梯度会<strong>叠加上一步的旧梯度</strong>，参数更新方向就被污染了，loss 会来回震荡甚至发散。</p>
<p>正确的时间线（注意第 3 步的累加发生在&quot;同一步内、多次贡献之间&quot;，第 6 步的清零发生在&quot;不同步之间&quot;）：</p>
<pre><code>step 0:  梯度 = 0 → 前向 → 反向(累加) → 裁剪 → 更新 → 清零 → 梯度 = 0
step 1:  梯度 = 0 → 前向 → 反向(累加) → 裁剪 → 更新 → 清零 → 梯度 = 0
...
</code></pre>
<h2>10. 看训练日志：loss 在下降</h2>
<p><code>train_gpt</code> 末尾每隔 <code>eval_every</code> 步打印一行（main.rs 里每 100 步一次）：</p>
<pre><code>step     0 | lr 0.00006 | loss 3.4658
step   100 | lr 0.00138 | loss 1.8701
step   200 | lr 0.00228 | loss 1.4210
...
step   599 | lr 0.00030 | loss 0.8142
</code></pre>
<p>判读方法：</p>
<ul>
<li><strong>loss 总体下降</strong> → 模型在学习，六步循环工作正常；</li>
<li><strong>loss 出现 NaN/剧烈抖动</strong> → 大概率梯度爆炸，检查 <code>clip_grad_norm</code> 的阈值或调低 <code>max_lr</code>；</li>
<li><strong>loss 降得很慢/不降</strong> → 学习率太小，或数据/模型配置有问题（下一课会讲数据怎么来）。</li>
</ul>
<h2>11. 动手练习</h2>
<ol>
<li>把 <code>clip_grad_norm(&amp;params, 1.0)</code> 这一行注释掉再跑 <code>cargo run</code>，观察 loss 是否更容易出现剧烈波动（体会它到底在防什么）。</li>
<li>把 <code>max_norm</code> 从 1.0 改成 0.01 再训练，对比收敛速度（阈值太小会&quot;拖慢&quot;训练）。</li>
<li>试着删掉 <code>opt.zero_grad()</code>，观察 loss 曲线会发生什么，并解释原因（提示：梯度在跨步累加）。</li>
<li>在循环里打印第 5 步更新前后某个参数 <code>params[0].data()[0]</code> 的变化，验证&quot;更新确实发生在 step 之后&quot;。</li>
</ol>
<h2>12. 本课总结</h2>
<ul>
<li>训练循环六步，顺序固定：<strong>采样 → 前向 → 损失 → 反向 → 裁剪 → 更新 → 清零</strong>（裁剪是防爆炸的保险，可视为第 3.5 步）</li>
<li><code>loss.backward()</code>：置初值 → DFS 拓扑排序 → 逆序沿链式法则累加梯度，一次调用搞定所有参数</li>
<li><code>clip_grad_norm</code>：总梯度范数超阈值就等比缩放，只限大小、不改方向</li>
<li><code>opt.zero_grad()</code>：跨步清零，防止梯度污染</li>
<li>下一步（第 14 课）：搞清楚循环第一步 <code>sample_batch</code> 返回的 x/y 到底是怎么从语料里构造出来的</li>
</ul>
]]></content>
  <category term="Rust"/>
  <category term="LLM"/>
  <category term="训练"/>
</entry>
<entry>
  <title>第 12 课：完整 GPT 模型 —— 把积木拼成能预测下一个词的模型</title>
  <link href="https://ganto.me/post/rust-llm-guide-12"/>
  <id>https://ganto.me/post/rust-llm-guide-12</id>
  <updated>2026-09-13T00:00:00+08:00</updated>
  <published>2026-09-13T00:00:00+08:00</published>
  <author><name>干徒</name></author>
  <summary><![CDATA[组合 Embedding、TransformerBlock、LayerNorm 等积木，拼出完整的 GPT 模型结构。]]></summary>
  <content type="html"><![CDATA[<h1>第 12 课：完整 GPT 模型 —— 把积木拼成能预测下一个词的模型</h1>
<blockquote>
<p>代码位置：<a href="src/model.rs">src/model.rs</a>（<code>GPTConfig</code> / <code>GPT</code> / <code>TransformerBlock</code>）
代码位置：<a href="src/attention.rs">src/attention.rs</a>（<code>MultiHeadAttention</code> / <code>KVCache</code>）
代码位置：<a href="src/layers.rs">src/layers.rs</a>（<code>Embedding</code> / <code>Linear</code> / <code>LayerNorm</code> / <code>gelu</code>）
演示入口：<a href="src/main.rs">src/main.rs</a>（演示 3：训练小 GPT 并生成文本）</p>
</blockquote>
<h2>1. 本课要搞懂的问题</h2>
<ol>
<li>一个完整的 GPT 由哪几大块组成？各自负责什么？</li>
<li>Transformer Block 内部的数据是怎么流动的？pre-norm 到底&quot;pre&quot;在哪？</li>
<li>输入一串 token id，经过 <code>GPT::forward</code> 后形状怎么一步步变成 logits？</li>
<li><code>GPTConfig::tiny</code> 里每个数字（64 / 4 / 2 / 32）分别代表什么？</li>
</ol>
<h2>2. GPT 架构总览</h2>
<p>把前几课的积木全部拼起来，就是完整的 GPT：</p>
<pre><code>              ┌───────────────────────────────┐
 token id     │  tok_emb: Embedding [V, D]    │  ← 每个 token 查表成向量
              └───────────────────────────────┘
              │   x = tok（位置信息不再相加：由 RoPE 在   │
              │   注意力内部旋转 Q/K 提供，见第 19 课）    │
              ┌───────────────────────────────┐
              │  blocks: N 层 Transformer      │  ← 注意力找相关性 + MLP 加工信息
              │  Block（本课第 3 节）           │
              └───────────────────────────────┘
              ┌───────────────────────────────┐
              │  ln_f: 最终 LayerNorm          │  ← 输出前再归一化一次
              └───────────────────────────────┘
              ┌───────────────────────────────┐
              │  lm_head: tok_emb 表转置       │  ← 权重绑定，映射成对每个词的打分
              └───────────────────────────────┘
                      logits [B*T, V]（预测下一个 token）
</code></pre>
<p>对应的 Rust 结构体（<code>src/model.rs</code>）：</p>
<pre><code class="language-rust">/// 完整的 GPT 模型
pub struct GPT {
    pub cfg: GPTConfig,
    tok_emb: Embedding,          // 同时充当 lm_head（权重绑定）
    blocks: Vec&lt;TransformerBlock&gt;,
    ln_f: LayerNorm,
}
</code></pre>
<table>
<thead>
<tr>
<th>字段</th>
<th>类型</th>
<th>作用</th>
<th>对应积木（第几课）</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>cfg</code></td>
<td><code>GPTConfig</code></td>
<td>保存模型配置（维度、层数……）</td>
<td>——</td>
</tr>
<tr>
<td><code>tok_emb</code></td>
<td><code>Embedding</code></td>
<td>token id → 向量，查表 <code>[V, D]</code>；<strong>权重绑定</strong>：输出头直接复用它的转置，不再单独建 <code>lm_head</code></td>
<td>第 12 课（<code>layers.rs</code>）</td>
</tr>
<tr>
<td><code>blocks</code></td>
<td><code>Vec&lt;TransformerBlock&gt;</code></td>
<td>N 层 Transformer Block，重复堆叠</td>
<td>第 9-11 课</td>
</tr>
<tr>
<td><code>ln_f</code></td>
<td><code>LayerNorm</code></td>
<td>输出前的最后归一化</td>
<td>第 11 课</td>
</tr>
</tbody>
</table>
<p>位置信息哪里去了？—— 第 11 课的正弦位置编码（<code>pos_emb</code>）在第 19 课被 <strong>RoPE</strong> 取代：不再向输入加位置向量，而是在每个注意力层内部对 Q/K 做旋转（见第 19 课与 <code>MultiHeadAttention::forward</code>）。所以 <code>GPT</code> 结构体里已经没有 <code>pos_emb</code> 字段了。</p>
<p>在 <code>GPT::new</code> 里把它们创建出来：</p>
<pre><code class="language-rust">pub fn new(cfg: GPTConfig, rng: &amp;mut Rng) -&gt; Self {
    let n_embd = cfg.n_embd;
    let vocab_size = cfg.vocab_size;
    let blocks = (0..cfg.n_layer)
        .map(|_| TransformerBlock::new(&amp;cfg, rng))
        .collect();
    GPT {
        cfg,
        tok_emb: Embedding::new(vocab_size, n_embd, rng),
        blocks,
        ln_f: LayerNorm::new(n_embd, 1e-5),
    }
}
</code></pre>
<p>注意 <code>blocks</code> 是 <code>(0..cfg.n_layer).map(...).collect()</code>——<strong>同一份代码复制 N 份</strong>，层数完全由配置决定，想加深网络只要改一个数字。</p>
<h2>3. TransformerBlock 内部结构</h2>
<h3>3.1 pre-norm 结构</h3>
<p>每个 Block 内部是&quot;两个子层 + 各自残差&quot;的 pre-norm 结构（第 11 课讲过 pre-norm 的好处）：</p>
<pre><code>x ──► LN1 ──► MultiHeadAttention ──► (+残差) ──► LN2 ──► MLP(GELU) ──► (+残差) ──► 输出
      │                                ▲          │                        ▲
      └────────────────────────────────┘          └────────────────────────┘
            加回输入 x（残差连接）                         加回输入 x（残差连接）
</code></pre>
<p>对应的结构体与 forward：</p>
<pre><code class="language-rust">/// Transformer Block（第 11 课）
///
/// 结构（GPT-2 风格，pre-norm）：
///   x -&gt; LayerNorm -&gt; Attention -&amp;gt; 残差 +
///   x -&gt; LayerNorm -&gt; MLP(GELU)  -&amp;gt; 残差 +
struct TransformerBlock {
    ln1: LayerNorm,
    attn: MultiHeadAttention,
    ln2: LayerNorm,
    mlp_linear1: Linear, // [D, 4D]
    mlp_linear2: Linear, // [4D, D]
}

impl TransformerBlock {
    fn forward(&amp;self, x: &amp;Tensor, mask: &amp;Tensor, kv_cache: Option&lt;&amp;mut KVCache&gt;) -&gt; Tensor {
        // 注意力子层 + 残差连接
        let h = self.attn.forward(&amp;self.ln1.forward(x), mask, kv_cache);
        let x = x.add(&amp;h);
        // 前馈子层 + 残差连接
        let h = self.ln2.forward(&amp;x);
        let h = gelu(&amp;self.mlp_linear1.forward(&amp;h));
        let h = self.mlp_linear2.forward(&amp;h);
        x.add(&amp;h)
    }
}
</code></pre>
<p>逐行拆解：</p>
<table>
<thead>
<tr>
<th>代码</th>
<th>在做什么</th>
<th>对应结构</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>self.ln1.forward(x)</code></td>
<td>先归一化（pre-norm 的&quot;pre&quot;）</td>
<td><code>x → LN1</code></td>
</tr>
<tr>
<td><code>self.attn.forward(..., mask, kv_cache)</code></td>
<td>多头注意力（第 10 课），<code>mask</code> 保证只能看过去</td>
<td><code>→ Attention</code></td>
</tr>
<tr>
<td><code>let x = x.add(&amp;h);</code></td>
<td>注意力输出 + 输入（残差连接）</td>
<td><code>+ 残差</code></td>
</tr>
<tr>
<td><code>self.ln2.forward(&amp;x)</code></td>
<td>再归一化</td>
<td><code>→ LN2</code></td>
</tr>
<tr>
<td><code>self.mlp_linear1.forward(&amp;h)</code></td>
<td>升维到 4D：<code>[D] → [4D]</code>（每层 MLP 把维度先放大 4 倍）</td>
<td><code>→ MLP 第一层</code></td>
</tr>
<tr>
<td><code>gelu(...)</code></td>
<td>GELU 激活（第 5 课，GPT 系列默认激活，比 ReLU 平滑）</td>
<td><code>→ 激活</code></td>
</tr>
<tr>
<td><code>self.mlp_linear2.forward(&amp;h)</code></td>
<td>降维回 D：<code>[4D] → [D]</code></td>
<td><code>→ MLP 第二层</code></td>
</tr>
<tr>
<td><code>x.add(&amp;h)</code></td>
<td>第二个残差连接</td>
<td><code>+ 残差</code></td>
</tr>
</tbody>
</table>
<h3>3.2 为什么 MLP 要&quot;先升维再降维&quot;</h3>
<p>前馈子层 <code>MLP(D → 4D → D)</code> 是 Transformer 里唯一&quot;逐位置&quot;加工信息的部件（注意力负责&quot;跨位置&quot;交换信息，MLP 负责&quot;在每个位置上&quot;独立加工）：</p>
<pre><code>MLP:  D → 4D → D
       │      │
   先放大   再压缩
  （特征空间更丰富，    （恢复原维度，方便残差相加）
   更容易学非线性）
</code></pre>
<p><code>4D</code> 是 GPT-2 论文里的惯例比例。MLP 和注意力形成互补：<strong>注意力负责&quot;找谁相关&quot;，MLP 负责&quot;想清楚该怎么表达&quot;。</strong></p>
<h2>4. GPT::forward 数据流</h2>
<h3>4.1 输入与输出</h3>
<pre><code class="language-rust">pub fn forward(
    &amp;self,
    idx: &amp;[usize],          // [B*T] 展平的 token id
    b: usize,               // batch 大小
    t: usize,               // 序列长度
    mut kv_cache: Option&lt;&amp;mut Vec&lt;KVCache&gt;&gt;,  // 推理缓存（第 18 课，训练时传 None）
) -&gt; Tensor {
</code></pre>
<ul>
<li><strong>输入</strong>：<code>idx</code> 是一维数组，长度 <code>b * t</code>，里面是 token id（比如 <code>[2, 5, 9, 3]</code> 表示一条样本 4 个词）。</li>
<li><strong>输出</strong>：<code>logits</code>，形状 <code>[B*T, vocab_size]</code>——每个位置对&quot;下一个词是谁&quot;的打分。</li>
</ul>
<h3>4.2 五步数据流</h3>
<table>
<thead>
<tr>
<th>步骤</th>
<th>代码</th>
<th>形状变化</th>
</tr>
</thead>
<tbody>
<tr>
<td>0. 输入</td>
<td><code>idx: &amp;[usize]</code></td>
<td><code>[B*T]</code></td>
</tr>
<tr>
<td>1. token embedding</td>
<td><code>self.tok_emb.forward(idx).reshape(vec![b, t, d])</code></td>
<td><code>[B*T] → [B*T, D] → [B, T, D]</code></td>
</tr>
<tr>
<td>2. 位置信息（RoPE）</td>
<td>在注意力内部对 Q/K 旋转（第 19 课），输入不再加位置向量；这里只算 <code>base</code>（KV cache 模式下已缓存的位置数）</td>
<td>——</td>
</tr>
<tr>
<td>3. 构造因果掩码</td>
<td><code>Tensor::from_vec(mask_data, vec![t, t_total])</code></td>
<td><code>[T, T_total]</code>（未来位置为 -inf）</td>
</tr>
<tr>
<td>4. 逐层 Transformer Block</td>
<td><code>x = block.forward(&amp;x, &amp;mask, cache, base);</code></td>
<td><code>[B, T, D]</code> → <code>[B, T, D]</code>（层内拆头又合并，形状不变）</td>
</tr>
<tr>
<td>5. 最终归一化 + 输出头</td>
<td><code>ln_f.forward(&amp;x)</code> 然后 <code>reshape(vec![b * t, d])</code>，<code>x.matmul(&amp;tok_emb.table.transpose())</code></td>
<td><code>[B, T, D] → [B*T, D] → [B*T, V]</code></td>
</tr>
</tbody>
</table>
<p>完整代码：</p>
<pre><code class="language-rust">let d = self.cfg.n_embd;
assert_eq!(idx.len(), b * t, &quot;输入 id 数量必须等于 b*t&quot;);

// 1. token embedding
let x = self.tok_emb.forward(idx).reshape(vec![b, t, d]);

// 2. 位置信息由 RoPE 提供（在注意力内部旋转 Q/K，见 MultiHeadAttention::forward）。
//    base = KV cache 模式下已缓存的位置数：新 token 的绝对位置 = base + 窗口内下标 j。
let base = kv_cache
    .as_ref()
    .map(|c| c.first().map(|k| k.seq_len()).unwrap_or(0))
    .unwrap_or(0);

// 3. 因果掩码：scores 形状 [B*H, T, T_total]，广播 mask [T, T_total]
let t_total = t + base;
let mut mask_data = vec![0.0f32; t * t_total];
for i in 0..t {
    for j in 0..t_total {
        if j &gt; i + base {
            mask_data[i * t_total + j] = f32::NEG_INFINITY;
        }
    }
}
let mask = Tensor::from_vec(mask_data, vec![t, t_total]);

// 4. 逐层过 Transformer Block
let mut x = x;
for (i, block) in self.blocks.iter().enumerate() {
    let cache = kv_cache.as_mut().map(|c| &amp;mut c[i]);
    x = block.forward(&amp;x, &amp;mask, cache, base);
}

// 5. 最终归一化 + 输出头（权重绑定：lm_head 复用 tok_emb.table 的转置）
let x = self.ln_f.forward(&amp;x);
let x = x.reshape(vec![b * t, d]);
x.matmul(&amp;self.tok_emb.table.transpose())
</code></pre>
<p>几个容易忽略的细节：</p>
<ol>
<li><strong>位置信息来自 RoPE 而不是相加</strong>：第 11 课的做法是 <code>x = tok + pos_emb</code>（把正弦位置向量加进去）；第 19 课之后改为在注意力内部对 Q/K 做旋转（<code>rotary_pair</code>），<code>GPT::forward</code> 不再需要 <code>pos_emb</code> 表，只把 <code>base</code> 传给各层——KV cache 推理时，新 token 的绝对位置是 <code>base + j</code>（第 18 课）。</li>
<li><strong>因果掩码的构造</strong>：<code>j &gt; i + base</code> 的位置设为 <code>-inf</code>。也就是说第 i 个 token 只能看到&quot;它自己和它前面的&quot;（含 KV cache 里的历史位置），未来位置在 softmax 后概率为 0——保证模型只能预测下一个词、不能偷看答案。</li>
<li><strong>权重绑定的输出头</strong>：<code>tok_emb.table</code> 是 <code>[V, D]</code>，它的转置 <code>[D, V]</code> 恰好可以把 <code>[D]</code> 向量打分成 <code>[V]</code> 个词的分数（&quot;第 i 行 = 第 i 个词的嵌入&quot;与当前向量做点积）。这与 GPT 的&quot;输入输出共享词嵌入&quot;做法一致，省掉了一份独立的 <code>lm_head</code> 参数（第 5.2 节参数量里会体现）。</li>
</ol>
<h3>4.3 一张形状变化总表</h3>
<p>以 <code>b=1, t=4, n_embd=64, vocab=100</code> 为例：</p>
<table>
<thead>
<tr>
<th>阶段</th>
<th>形状</th>
<th>含义</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>idx</code></td>
<td><code>[4]</code></td>
<td>4 个 token id</td>
</tr>
<tr>
<td><code>tok_emb.forward(idx)</code></td>
<td><code>[4, 64]</code> → reshape <code>[1, 4, 64]</code></td>
<td>每个 token 的语义向量</td>
</tr>
<tr>
<td>（位置由 RoPE 提供）</td>
<td>在注意力内部旋转 Q/K，形状不变</td>
<td>相对位置信息（第 19 课）</td>
</tr>
<tr>
<td>注意力内部</td>
<td>scores <code>[4, 4]</code>（1 个 batch、4 头时 <code>[4, 4, 4]</code>）</td>
<td>相关性打分</td>
</tr>
<tr>
<td>过完 2 个 Block</td>
<td><code>[1, 4, 64]</code></td>
<td>形状不变，信息被加工</td>
</tr>
<tr>
<td><code>ln_f</code> 后</td>
<td><code>[1, 4, 64]</code></td>
<td>归一化</td>
</tr>
<tr>
<td>reshape</td>
<td><code>[4, 64]</code></td>
<td>展平 B、T 两维</td>
</tr>
<tr>
<td>权重绑定 lm_head</td>
<td><code>[4, 100]</code></td>
<td><code>x @ tableᵀ</code>，每个位置对 100 个词的分数 = logits</td>
</tr>
</tbody>
</table>
<blockquote>
<p>全程只有两处形状变化：<code>tok_emb</code> 之后从 <code>[B*T, D]</code> 变成 <code>[B, T, D]</code>（为了层内处理），<code>ln_f</code> 之后从 <code>[B, T, D]</code> 变回 <code>[B*T, D]</code>（因为 <code>Linear</code> 把 3D 输入自动展平，输出再还原）。中间的注意力拆头/合头都在 Block 内部完成，外部看到的一直是 <code>[B, T, D]</code>。</p>
</blockquote>
<h2>5. GPTConfig::tiny 配置解读</h2>
<pre><code class="language-rust">/// 一个小配置，适合学习演示（其余字段与 Default 一致）
pub fn tiny(vocab_size: usize) -&gt; Self {
    GPTConfig {
        vocab_size,
        ..Default::default()
    }
}
</code></pre>
<table>
<thead>
<tr>
<th>字段</th>
<th>值</th>
<th>含义</th>
<th>影响</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>vocab_size</code></td>
<td>由调用者传入</td>
<td>词表大小（有多少种 token）</td>
<td>决定 <code>tok_emb</code> 表行数（兼输出头的宽度）</td>
</tr>
<tr>
<td><code>n_embd</code></td>
<td>64</td>
<td>隐藏维度 D：每个 token 的向量长度</td>
<td>所有层的宽度，模型&quot;容量&quot;的核心</td>
</tr>
<tr>
<td><code>n_head</code></td>
<td>4</td>
<td>注意力头数</td>
<td><code>head_dim = D / H = 64 / 4 = 16</code>，每个头在 16 维子空间找相关性</td>
</tr>
<tr>
<td><code>n_layer</code></td>
<td>2</td>
<td>Transformer Block 层数</td>
<td>网络深度（原版 GPT-2 是 12~48 层）</td>
</tr>
<tr>
<td><code>block_size</code></td>
<td>32</td>
<td>最大上下文长度</td>
<td>训练/推理的最大 token 数；RoPE 的绝对位置 = <code>base + j</code> 不受此表限制</td>
</tr>
</tbody>
</table>
<h3>5.1 由配置推导出的关键数字</h3>
<ul>
<li><strong>每个头处理多少维</strong>：<code>head_dim = n_embd / n_head = 64 / 4 = 16</code>（<code>MultiHeadAttention::forward</code> 里有断言 <code>head_dim * n_head == d</code>，配置必须能整除）。</li>
<li><strong>每层的形状</strong>：注意力 4 个 Linear 都是 <code>[64, 64]</code>；MLP 是 <code>[64, 256]</code> 和 <code>[256, 64]</code>（4 倍升维）。</li>
<li><strong>位置信息</strong>：RoPE 的 cos/sin 表按&quot;位置 × 对偶下标&quot;预计算（第 19 课），维度 <code>block_size × (D/2)</code> 的常数，不参与训练。</li>
</ul>
<h3>5.2 粗略参数量估算</h3>
<p>设词表大小 = V，参数量约：</p>
<table>
<thead>
<tr>
<th>部件</th>
<th>参数量</th>
<th>tiny（V=100）时</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>tok_emb</code>（兼 lm_head）</td>
<td>V × 64</td>
<td>6,400</td>
</tr>
<tr>
<td>每个 Block 注意力（q/k/v/proj）</td>
<td>4 × (64×64 + 64)</td>
<td>16,640</td>
</tr>
<tr>
<td>每个 Block MLP（两个 Linear）</td>
<td>64×256+256 + 256×64+64</td>
<td>33,088</td>
</tr>
<tr>
<td>每个 Block 两个 LayerNorm</td>
<td>2 × (64+64)</td>
<td>256</td>
</tr>
<tr>
<td>每层合计</td>
<td>——</td>
<td>≈ 49,984</td>
</tr>
<tr>
<td>2 层 Block</td>
<td>——</td>
<td>≈ 99,968</td>
</tr>
<tr>
<td><code>ln_f</code></td>
<td>64 + 64</td>
<td>128</td>
</tr>
<tr>
<td><code>lm_head</code></td>
<td><strong>0（权重绑定，复用 tok_emb 转置）</strong></td>
<td><strong>0</strong></td>
</tr>
<tr>
<td><strong>总计</strong></td>
<td>——</td>
<td><strong>≈ 106,500（约 10.6 万参数）</strong></td>
</tr>
</tbody>
</table>
<blockquote>
<p>真实 GPT-2 Small 有 1.17 亿参数（n_embd=768、n_layer=12、n_head=12）。我们的 tiny 把它缩小了约 1000 倍，纯粹是为了<strong>能在普通 CPU 上几秒钟跑一步训练</strong>，把原理讲清楚。</p>
</blockquote>
<h2>6. 动手练习</h2>
<ol>
<li><strong>手推数据流</strong>：设 <code>vocab=50, b=2, t=3</code>，用 tiny 配置，写出 <code>GPT::forward</code> 里每一步张量的形状（从 <code>idx</code> 到 <code>logits</code>），对照第 4.3 节的表检查。</li>
<li><strong>改配置</strong>：自己加一个 <code>GPTConfig::small</code>，比如 <code>n_embd=128, n_head=8, n_layer=4, block_size=64</code>。注意 <code>head_dim = 128/8 = 16</code> 仍成立；再按 5.2 节的表估一下参数量。</li>
<li><strong>验证 logits 形状</strong>：在 <code>main.rs</code> 演示 3 里，<code>model.forward(...)</code> 之后加一行打印 <code>logits.shape()</code>，确认是 <code>[B*T, V]</code>。</li>
<li><strong>看 Block 内部分工</strong>：把 <code>mlp_linear1</code> 的维度改成 <code>2 * cfg.n_embd</code>（2 倍而不是 4 倍），训练看 loss 变化——体会 MLP 宽度对模型能力的影响。</li>
<li><strong>思考</strong>：我们的输出头就是 <code>tok_emb.table</code> 的转置（权重绑定）。为什么可以这样做？相比&quot;独立的 lm_head&quot;省了多少参数？（提示：<code>[V, D]</code> 和 <code>[D, V]</code> 互为转置，<code>lm_head</code> 的参数量 <code>64×V+V</code> 恰好被省掉了。）</li>
</ol>
<h2>7. 本课总结</h2>
<ul>
<li>
<p>GPT 由 <strong>token embedding + N 层 Transformer Block + 最终 LayerNorm + 权重绑定输出头</strong> 四大部分组成（位置信息由第 19 课的 RoPE 在注意力内部提供，不再有独立的 <code>pos_emb</code> 表）</p>
</li>
<li>
<p>Transformer Block 是 <strong>pre-norm</strong> 结构：<code>LN → Attention → 残差</code>，再 <code>LN → MLP(GELU) → 残差</code>；MLP 做 <code>D → 4D → D</code> 的升维降维</p>
</li>
<li>
<p><code>GPT::forward</code> 数据流：<code>idx [B*T] → [B, T, D] → ... → [B*T, V]</code>，只有进出输出头时形状变化，层内形状始终 <code>[B, T, D]</code></p>
</li>
<li>
<p>因果掩码保证&quot;只能看过去&quot;，RoPE 保证&quot;知道相对位置&quot;</p>
</li>
<li>
<p>输出头权重绑定：<code>x @ tok_emb.tableᵀ</code>，与 GPT 的&quot;输入输出共享词嵌入&quot;一致</p>
</li>
<li>
<p><code>tiny</code> 配置：<code>n_embd=64</code>、<code>n_head=4</code>（head_dim=16）、<code>n_layer=2</code>、<code>block_size=32</code>，约 10.6 万参数，CPU 上几分钟就能跑一轮训练</p>
</li>
<li>
<p>下一课：写训练循环（前向 → 算损失 → 反向传播 → 更新参数），让这个 GPT 真的学会生成文本！</p>
</li>
</ul>
]]></content>
  <category term="Rust"/>
  <category term="LLM"/>
  <category term="GPT"/>
</entry>
<entry>
  <title>第 11 课：位置编码与归一化 —— 让序列带上「位置感」</title>
  <link href="https://ganto.me/post/rust-llm-guide-11"/>
  <id>https://ganto.me/post/rust-llm-guide-11</id>
  <updated>2026-09-12T00:00:00+08:00</updated>
  <published>2026-09-12T00:00:00+08:00</published>
  <author><name>干徒</name></author>
  <summary><![CDATA[引入正弦位置编码与 LayerNorm 层归一化，配合残差连接稳定训练。]]></summary>
  <content type="html"><![CDATA[<h1>第 11 课：位置编码与归一化 —— 让序列带上&quot;位置感&quot;</h1>
<blockquote>
<p>代码位置：<a href="src/model.rs">src/model.rs</a>（<code>TransformerBlock</code> 里的残差连接）
代码位置：<a href="src/layers.rs">src/layers.rs</a>（<code>LayerNorm</code>）
演示入口：<a href="src/main.rs">src/main.rs</a></p>
<p>⚠️ <strong>历史说明</strong>：本课实现的正弦位置编码（<code>sinusoidal_positions</code> / <code>pos_emb</code>）已在<strong>第 19 课被 RoPE 取代</strong>——
当前 <code>src/model.rs</code> 里不再有 <code>pos_emb</code> 字段，位置信息由注意力内部旋转 Q/K 提供。
本节仍是必读的：LayerNorm 与残差连接至今原样在模型里，且&quot;位置编码要解决什么问题&quot;是理解 RoPE 的前提。</p>
</blockquote>
<h2>1. 本课要搞懂的问题</h2>
<ol>
<li>注意力机制天生&quot;看不见位置&quot;，为什么？怎么补救？</li>
<li>正弦位置编码的公式 <code>PE(pos, 2i) = sin(pos / 10000^(2i/d))</code> 到底在算什么？</li>
<li>LayerNorm 是怎么把数据&quot;拉回&quot;标准分布的？它和 BatchNorm 有什么区别？</li>
<li>残差连接为什么能让我们放心地把网络加深？</li>
</ol>
<h2>2. 为什么需要位置编码</h2>
<p>先回忆第 9-10 课的注意力公式：</p>
<pre><code>scores = (Q · Kᵀ) / √d_k      # Q、K 由每个 token 的内容算出来
attn   = softmax(scores)
out    = attn · V
</code></pre>
<p>注意：<strong>Q、K、V 全部只由 token 的内容（embedding）计算而来</strong>。点积 <code>q·k</code> 衡量的是&quot;两个 token 的内容有多相关&quot;，
跟&quot;它们相隔多远、谁在前谁在后&quot;没有任何关系。</p>
<p>这带来一个致命问题：<strong>注意力是&quot;排列不变&quot;（permutation invariant）的</strong>。看一个例子：</p>
<table>
<thead>
<tr>
<th>输入序列</th>
<th>模型看到的相关性</th>
</tr>
</thead>
<tbody>
<tr>
<td>&quot;猫 追 狗&quot;</td>
<td>&quot;猫&quot;↔&quot;狗&quot;</td>
</tr>
<tr>
<td>&quot;狗 追 猫&quot;</td>
<td>&quot;猫&quot;↔&quot;狗&quot;</td>
</tr>
</tbody>
</table>
<p>两个句子语义完全相反，但注意力打分一模一样（因为每个词的内容没变）。如果模型只看注意力，
它永远分不清&quot;猫追狗&quot;和&quot;狗追猫&quot;。</p>
<blockquote>
<p>通俗理解：注意力是个&quot;走神的学生&quot;，它只知道&quot;这两个词好像有关&quot;，但不知道&quot;谁在左、谁在右&quot;。
语言是<strong>顺序敏感</strong>的，所以必须把位置信息塞进去。</p>
</blockquote>
<p><strong>解决方案</strong>：给每个位置的 token embedding <strong>加上一个位置向量</strong>：</p>
<pre><code>x = token_embedding + position_embedding
</code></pre>
<p>位置向量就是&quot;第 0 个位置长什么样、第 1 个位置长什么样……&quot;的一组向量。加上之后，同一个词在不同位置就有了不同的表示。</p>
<h2>3. 正弦位置编码</h2>
<h3>3.1 公式</h3>
<p>原版 Transformer 论文（Attention Is All You Need）提出用<strong>不同频率的正弦波</strong>来编码位置：</p>
<pre><code>PE(pos, 2i)   = sin(pos / 10000^(2i/d))
PE(pos, 2i+1) = cos(pos / 10000^(2i/d))
</code></pre>
<ul>
<li><code>pos</code>：位置（0, 1, 2, ...）</li>
<li><code>i</code>：embedding 里的维度下标（0, 1, 2, ..., d-1）</li>
<li><code>d</code>：embedding 维度（我们的模型里就是 <code>n_embd</code>）</li>
</ul>
<p>位置 <code>pos</code> 的向量长这样：</p>
<pre><code>pos_emb[pos] = [ sin(pos/1), cos(pos/1), sin(pos/10000^(2/d)), cos(pos/10000^(2/d)), ... ]
                  └── i=0 ──┘  └── i=1 ──┘      └── i=2 ──┘       └── i=3 ──┘
</code></pre>
<p><strong>关键点</strong>：维度下标 <code>i</code> 越小，频率越高（波形越密）；<code>i</code> 越大，频率越低（波形越平缓）。
也就是说，编码向量的&quot;前半段&quot;负责记录精细的相对位置，&quot;后半段&quot;负责记录大致的绝对位置。</p>
<h3>3.2 手算一个例子</h3>
<p>设 <code>d = 4</code>，即每个位置只有 4 个维度（i = 0, 1, 2, 3）：</p>
<table>
<thead>
<tr>
<th>pos</th>
<th>2i=0：sin(pos/1)</th>
<th>2i=1：cos(pos/1)</th>
<th>2i=2：sin(pos/100)</th>
<th>2i=3：cos(pos/100)</th>
</tr>
</thead>
<tbody>
<tr>
<td>0</td>
<td>sin(0) = 0</td>
<td>cos(0) = 1</td>
<td>sin(0) = 0</td>
<td>cos(0) = 1</td>
</tr>
<tr>
<td>1</td>
<td>sin(1) ≈ 0.841</td>
<td>cos(1) ≈ 0.540</td>
<td>sin(0.01) ≈ 0.010</td>
<td>cos(0.01) ≈ 1.000</td>
</tr>
<tr>
<td>2</td>
<td>sin(2) ≈ 0.909</td>
<td>cos(2) ≈ -0.416</td>
<td>sin(0.02) ≈ 0.020</td>
<td>cos(0.02) ≈ 1.000</td>
</tr>
<tr>
<td>3</td>
<td>sin(3) ≈ 0.141</td>
<td>cos(3) ≈ -0.990</td>
<td>sin(0.03) ≈ 0.030</td>
<td>cos(0.03) ≈ 1.000</td>
</tr>
</tbody>
</table>
<p>可以看到：i = 0/1 的波形变化剧烈（区分相邻位置），i = 2/3 的波形几乎不动（区分远距离）。</p>
<h3>3.3 为什么选正弦波</h3>
<table>
<thead>
<tr>
<th>性质</th>
<th>说明</th>
</tr>
</thead>
<tbody>
<tr>
<td>每个位置的向量都不同</td>
<td><code>pos</code> 不同，三角函数取值就不同，模型能区分位置</td>
</tr>
<tr>
<td>相对位置可&quot;计算&quot;</td>
<td>利用三角恒等式，<code>PE(pos+k)</code> 可以表示为 <code>PE(pos)</code> 的线性组合，模型容易学到&quot;相对距离&quot;的概念</td>
</tr>
<tr>
<td>值域固定</td>
<td>所有值都在 [-1, 1]，加到 embedding 上不会把数值范围撑爆</td>
</tr>
<tr>
<td>不需要学习</td>
<td>位置编码是<strong>固定的常数</strong>，不用训练就能用；而且序列再长也能&quot;算&quot;出来（外推性）</td>
</tr>
</tbody>
</table>
<h3>3.4 对应代码</h3>
<blockquote>
<p>⚠️ 以下函数已从代码库中删除（被 RoPE 取代），仅供理解原理。</p>
</blockquote>
<pre><code class="language-rust">/// 正弦位置编码（第 11 课，已删除）
///
/// PE(pos, 2i)   = sin(pos / 10000^(2i/D))
/// PE(pos, 2i+1) = cos(pos / 10000^(2i/D))
fn sinusoidal_positions(max_len: usize, d: usize) -&gt; Vec&lt;f32&gt; {
    let mut data = vec![0.0f32; max_len * d];
    for pos in 0..max_len {
        for i in 0..d {
            let freq = 10000f32.powf((2 * (i / 2)) as f32 / d as f32);
            let angle = pos as f32 / freq;
            data[pos * d + i] = if i % 2 == 0 { angle.sin() } else { angle.cos() };
        }
    }
    data
}
</code></pre>
<p>逐行对照公式：</p>
<table>
<thead>
<tr>
<th>代码</th>
<th>对应公式</th>
<th>说明</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>10000f32.powf((2 * (i / 2)) as f32 / d as f32)</code></td>
<td><code>10000^(2i/d)</code></td>
<td>注意这里的 <code>2i</code> 指<strong>偶数维下标</strong>。因为 <code>i / 2</code> 是整数除法，当 <code>i=2</code> 和 <code>i=3</code> 时 <code>i/2</code> 都等于 1，所以下标 2 和 3 共享同一个频率——这正是公式里 <code>(2i, 2i+1)</code> 一对维度用同一个 <code>10000^(2i/d)</code> 的做法</td>
</tr>
<tr>
<td><code>let angle = pos as f32 / freq;</code></td>
<td><code>pos / 10000^(2i/d)</code></td>
<td>把位置除以频率，得到角度</td>
</tr>
<tr>
<td><code>if i % 2 == 0 { angle.sin() } else { angle.cos() }</code></td>
<td><code>PE(pos, 2i) = sin(...)</code>、<code>PE(pos, 2i+1) = cos(...)</code></td>
<td>偶数维用 sin，奇数维用 cos</td>
</tr>
</tbody>
</table>
<p>在 <code>GPT::new</code> 里把它变成一个 <code>[block_size, n_embd]</code> 的张量存起来：</p>
<pre><code class="language-rust">let pos_emb = Tensor::from_vec(
    sinusoidal_positions(cfg.block_size, cfg.n_embd),
    vec![cfg.block_size, cfg.n_embd],
);
</code></pre>
<p><code>GPT</code> 结构体里它的类型是普通 <code>Tensor</code> 而不是 <code>Tensor::param(...)</code>：</p>
<pre><code class="language-rust">pos_emb: Tensor, // 正弦位置编码 [block_size, D]（常数，不参与训练）
</code></pre>
<blockquote>
<p>它不在 <code>parameters()</code> 里，反向传播时不会产生梯度——<strong>位置编码是常数</strong>，这正是正弦编码对比&quot;可学习位置编码&quot;的一大优点。</p>
</blockquote>
<h3>3.5 怎么用：加到 token embedding 上</h3>
<p>在 <code>GPT::forward</code> 里，用 <code>gather_rows</code> 按位置取行，再和 token embedding 相加：</p>
<pre><code class="language-rust">// 1. token embedding
let tok = self.tok_emb.forward(idx).reshape(vec![b, t, d]);
// 2. 位置编码：KV cache 推理时，当前位置从缓存长度开始
let pos_emb = self.pos_emb.gather_rows(&amp;positions).reshape(vec![b, t, d]);
let x = tok.add(&amp;pos_emb);
</code></pre>
<p><code>x</code> 的每个元素 = 词的语义（token embedding）+ 词的位置（pos embedding），后续所有层都在这个&quot;带位置信息&quot;的表示上工作。</p>
<h2>4. LayerNorm：层归一化</h2>
<h3>4.1 公式</h3>
<p>LayerNorm 对<strong>最后一维</strong>（每个样本自己）做归一化，再缩放平移：</p>
<pre><code>y = (x - μ) / √(σ² + ε) · γ + β
</code></pre>
<ul>
<li><code>μ</code>：x 在最后一维上的均值</li>
<li><code>σ²</code>：x 在最后一维上的方差</li>
<li><code>ε</code>：一个极小的数（防除零，我们的代码里用 <code>1e-5</code>）</li>
<li><code>γ</code>（gamma）、<code>β</code>（beta）：<strong>可学习的参数</strong>，初始 γ=1、β=0</li>
</ul>
<p>直觉：先把每个样本拉成&quot;均值 0、方差 1&quot;的标准分布（稳定数值范围），再让模型<strong>自己决定</strong>要不要缩放平移回去（恢复表达能力）。</p>
<h3>4.2 对应代码逐行拆解</h3>
<p><code>src/layers.rs</code> 里的实现：</p>
<pre><code class="language-rust">pub struct LayerNorm {
    pub gamma: Tensor, // [d] 可学习缩放
    pub beta: Tensor,  // [d] 可学习平移
    pub eps: f32,
}

impl LayerNorm {
    pub fn new(d: usize, eps: f32) -&gt; Self {
        LayerNorm {
            gamma: Tensor::param(vec![1.0; d], vec![d]),
            beta: Tensor::param(vec![0.0; d], vec![d]),
            eps,
        }
    }

    pub fn forward(&amp;self, x: &amp;Tensor) -&gt; Tensor {
        let d = x.shape()[x.rank() - 1];
        // 均值 μ：[..., 1]（保持维度，方便广播）
        let mean = x.sum_last_dim().mul_scalar(1.0 / d as f32);
        // 中心化
        let centered = x.sub(&amp;mean);
        // 方差 σ²：中心化后平方再取均值
        let var = centered
            .mul(&amp;centered)
            .sum_last_dim()
            .mul_scalar(1.0 / d as f32);
        // 归一化
        let norm = centered.div(&amp;var.add_scalar(self.eps).sqrt());
        // 缩放平移（γ、β 是 [d]，广播到 [..., d]）
        norm.mul(&amp;self.gamma).add(&amp;self.beta)
    }
}
</code></pre>
<p>公式 ↔ 代码对照表：</p>
<table>
<thead>
<tr>
<th>公式步骤</th>
<th>Rust 代码</th>
<th>说明</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>μ = mean(x)</code></td>
<td><code>x.sum_last_dim().mul_scalar(1.0 / d as f32)</code></td>
<td>第 1 课学的 <code>sum_last_dim</code> 把最后一维求和，结果形状是 <code>[..., 1]</code>（最后一位变成 1，<strong>维度没有消失</strong>），再除以 <code>d</code> 就是均值。保持 <code>[..., 1]</code> 是为了后面能和 <code>[..., d]</code> 直接广播相减</td>
</tr>
<tr>
<td><code>x - μ</code></td>
<td><code>x.sub(&amp;mean)</code></td>
<td>中心化：减去均值</td>
</tr>
<tr>
<td><code>σ² = mean((x-μ)²)</code></td>
<td><code>centered.mul(&amp;centered).sum_last_dim().mul_scalar(1.0 / d as f32)</code></td>
<td>先逐元素平方，再 <code>sum_last_dim</code> 求和、除以 <code>d</code></td>
</tr>
<tr>
<td><code>√(σ² + ε)</code></td>
<td><code>var.add_scalar(self.eps).sqrt()</code></td>
<td>加一个小数防除零，再开方</td>
</tr>
<tr>
<td><code>(x-μ) / √(σ²+ε)</code></td>
<td><code>centered.div(&amp;...)</code></td>
<td>归一化</td>
</tr>
<tr>
<td><code>· γ + β</code></td>
<td><code>norm.mul(&amp;self.gamma).add(&amp;self.beta)</code></td>
<td>可学习缩放平移；<code>gamma</code>、<code>beta</code> 形状是 <code>[d]</code>，广播到 <code>[..., d]</code></td>
</tr>
</tbody>
</table>
<p>注意一个细节：均值、方差是用 <code>sum_last_dim</code> 求的，所以归一化是<strong>逐样本、逐序列位置</strong>独立进行的——
对输入 <code>[B, T, D]</code> 来说，就是沿着最后一维 <code>D</code> 归一把每个 <code>B×T</code> 的&quot;行&quot;。</p>
<h3>4.3 LayerNorm vs BatchNorm</h3>
<table>
<thead>
<tr>
<th>对比项</th>
<th>LayerNorm（我们用这个）</th>
<th>BatchNorm</th>
</tr>
</thead>
<tbody>
<tr>
<td>对哪个维度归一化</td>
<td>最后一维（每个样本自己）</td>
<td>特征维（跨 batch 的所有样本）</td>
</tr>
<tr>
<td>依赖 batch 吗</td>
<td>不依赖，batch size=1 也能用</td>
<td>依赖，batch 太小会不准</td>
</tr>
<tr>
<td>训练/推理行为</td>
<td>完全一致，公式不变</td>
<td>推理时用训练期累积的统计量</td>
</tr>
<tr>
<td>需要&quot;当前 batch&quot;统计量吗</td>
<td>不需要</td>
<td>需要（还要额外维护 running mean/var）</td>
</tr>
<tr>
<td>适合场景</td>
<td>NLP / Transformer（序列长度、batch 多变）</td>
<td>CV / CNN（固定尺寸图像）</td>
</tr>
<tr>
<td>对 Transformer 的意义</td>
<td>因果掩码下不同位置长度不同，逐位置归一化天然合适</td>
<td>长度不一很难处理</td>
</tr>
</tbody>
</table>
<blockquote>
<p>记忆口诀：<strong>LayerNorm 归一化&quot;每一行&quot;，BatchNorm 归一化&quot;每一列&quot;。</strong>
GPT 选 LayerNorm 还有一个重要原因：它不引入&quot;batch 内样本之间的相互依赖&quot;，行为可预测、实现简单（公式就 5 步）。</p>
</blockquote>
<h3>4.4 在模型里用在哪</h3>
<p><code>TransformerBlock</code> 里有两个（<code>ln1</code> 在注意力前、<code>ln2</code> 在 MLP 前），模型末尾还有一个 <code>ln_f</code>：</p>
<pre><code class="language-rust">// TransformerBlock::new
ln1: LayerNorm::new(cfg.n_embd, 1e-5),
ln2: LayerNorm::new(cfg.n_embd, 1e-5),
// GPT::new
ln_f: LayerNorm::new(n_embd, 1e-5),
</code></pre>
<p>三个归一化层都在<strong>层的最前面</strong>（pre-norm 结构，下一课细讲），<code>eps</code> 统一用 <code>1e-5</code>。</p>
<h2>5. 残差连接（Residual Connection）</h2>
<h3>5.1 公式与直觉</h3>
<p>残差连接就是在子层的输出上<strong>加上输入本身</strong>：</p>
<pre><code>y = x + F(x)
</code></pre>
<p>其中 <code>F(x)</code> 是任意子层（注意力、MLP……）。直觉上，<code>F(x)</code> 只负责&quot;学一个增量/修正&quot;，而 <code>x</code> 这条&quot;高速公路&quot;原封不动地把信息送到下一层。</p>
<h3>5.2 为什么解决梯度消失</h3>
<p>先回忆反向传播：梯度从 loss 一层层往回传，<strong>每过一层就要乘一次该层的导数</strong>。如果网络有 N 层，梯度大概要被乘 N 次：</p>
<pre><code>∂loss/∂x₀ = ∂loss/∂xₙ · ∂xₙ/∂xₙ₋₁ · ... · ∂x₁/∂x₀
</code></pre>
<p>如果每层的导数都小于 1（比如常见激活函数的导数 ≤ 1），乘几十层后梯度指数级衰减，几乎变成 0——<strong>前面的层根本学不到东西</strong>，这就是&quot;梯度消失&quot;。</p>
<p>有了残差连接，情况完全不同。<code>y = x + F(x)</code> 对 x 求导：</p>
<pre><code>∂y/∂x = 1 + ∂F/∂x
</code></pre>
<ul>
<li>多出来的那个 <code>1</code> 是恒等映射的导数，<strong>永远在</strong>。</li>
<li>反向传播时梯度传过残差块，至少保留一份&quot;原样拷贝&quot;：</li>
</ul>
<pre><code>∂loss/∂x = ∂loss/∂y · (1 + ∂F/∂x) = ∂loss/∂y + ∂loss/∂y · ∂F/∂x
</code></pre>
<p>不管 <code>F</code> 的导数多小，梯度里始终有一个 <code>∂loss/∂y</code> 直接传回上一层，不会衰减成 0。</p>
<h3>5.3 为什么能加深网络</h3>
<ul>
<li>对<strong>梯度</strong>：恒等映射让深层的梯度能&quot;直达&quot;浅层，解决了训练困难。</li>
<li>对<strong>信息</strong>：每一层都能&quot;无损地&quot;跳过不需要的变换。即使某个子层学不到有用的东西，模型也可以把它当成恒等映射（<code>F(x) ≈ 0</code>），至少不劣化，所以我们可以放心堆很多层。</li>
</ul>
<blockquote>
<p>一句话：<strong>残差连接 = 梯度的高速公路 + 信息的保险丝。</strong></p>
</blockquote>
<h3>5.4 对应代码</h3>
<p><code>TransformerBlock::forward</code> 里残差连接就两处 <code>add</code>：</p>
<pre><code class="language-rust">fn forward(&amp;self, x: &amp;Tensor, mask: &amp;Tensor, kv_cache: Option&lt;&amp;mut KVCache&gt;) -&gt; Tensor {
    // 注意力子层 + 残差连接
    let h = self.attn.forward(&amp;self.ln1.forward(x), mask, kv_cache);
    let x = x.add(&amp;h);   // ← 残差连接 ①：x + Attention(LN(x))
    // 前馈子层 + 残差连接
    let h = self.ln2.forward(&amp;x);
    let h = gelu(&amp;self.mlp_linear1.forward(&amp;h));
    let h = self.mlp_linear2.forward(&amp;h);
    x.add(&amp;h)            // ← 残差连接 ②：x + MLP(LN(x))
}
</code></pre>
<table>
<thead>
<tr>
<th>子层</th>
<th>公式</th>
<th>代码</th>
</tr>
</thead>
<tbody>
<tr>
<td>注意力块</td>
<td><code>x + Attention(LayerNorm(x))</code></td>
<td><code>let x = x.add(&amp;h);</code></td>
</tr>
<tr>
<td>前馈块</td>
<td><code>x + MLP(LayerNorm(x))</code></td>
<td><code>x.add(&amp;h)</code></td>
</tr>
</tbody>
</table>
<h3>5.5 和 LayerNorm 的组合：pre-norm</h3>
<p>注意到顺序是 <strong>先 LayerNorm、再子层、最后加残差</strong>（<code>LN → 子层 → +</code>），这叫 <strong>pre-norm</strong>（归一化在子层前）。对比早期 Transformer 的 post-norm（<code>子层 → + → LN</code>）：</p>
<table>
<thead>
<tr>
<th></th>
<th>pre-norm（GPT-2 风格，我们用这个）</th>
<th>post-norm（原版 Transformer）</th>
</tr>
</thead>
<tbody>
<tr>
<td>残差路径上的数据</td>
<td>直接是&quot;干净&quot;的 x，不做归一化</td>
<td>每层都过 LN，数值被反复缩放</td>
</tr>
<tr>
<td>梯度传播</td>
<td>恒等路径无干扰，更稳</td>
<td>更容易出现训练不稳</td>
</tr>
<tr>
<td>深层堆叠</td>
<td>更容易训练（GPT 系列深度更大也能训）</td>
<td>需要 warmup 等技巧</td>
</tr>
</tbody>
</table>
<h2>6. 三者如何协作</h2>
<p>一个 Transformer Block 里，位置编码负责&quot;把位置信息喂进来&quot;，LayerNorm 负责&quot;稳住数值分布&quot;，残差连接负责&quot;让梯度和信息畅通无阻&quot;：</p>
<pre><code>x (带位置编码的 embedding)
 │
 ├─ 残差①： x + Attention( LN1(x) )        ← LN 归一化 → 注意力找相关性 → 加回原 x
 │
 └─ 残差②： x + MLP( LN2(x) )              ← LN 归一化 → MLP 加工信息 → 加回原 x
 │
 └─ 送到下一层
</code></pre>
<h2>7. 动手练习</h2>
<ol>
<li><strong>手算 LayerNorm</strong>：对向量 <code>x = [1.0, 2.0, 3.0, 4.0]</code>，手算 μ、σ²、（σ²+ε）开方后的归一化结果（ε=1e-5），再用 <code>src/layers.rs</code> 里的 <code>LayerNorm::new(4, 1e-5)</code>（γ=1、β=0 时）跑一遍对比，应该完全一致。</li>
<li><strong>改频率基数</strong>：把 <code>sinusoidal_positions</code> 里的 <code>10000</code> 改成 <code>10</code> 或 <code>1000000</code>，想想会怎样？（提示：<code>10</code> 让所有频率都变高、位置区分更&quot;拥挤&quot;；<code>1000000</code> 让波形几乎不动。跑训练对比 loss 曲线。）</li>
<li><strong>去掉残差</strong>：把 <code>TransformerBlock::forward</code> 里的 <code>x.add(&amp;h)</code> 改成直接 <code>h</code>（两个子层都改），跑训练观察 loss 是否下降明显变慢——亲身感受梯度消失。</li>
<li><strong>思考</strong>：位置编码为什么是&quot;加&quot;而不是&quot;拼接&quot;（concat）？（提示：加法让每个维度只&quot;混入&quot;一个位置信号，且不改变维度数；拼接会让维度翻倍、参数爆炸。）</li>
<li><strong>思考</strong>：LayerNorm 里为什么用 <code>sum_last_dim</code> 求均值而不是 <code>sum</code>？如果 <code>sum</code> 把所有元素加一起会怎样？</li>
</ol>
<h2>8. 本课总结</h2>
<ul>
<li>
<p>注意力是<strong>位置盲</strong>的，必须把位置信息加进输入 → 正弦位置编码 <code>PE(pos,2i)=sin(pos/10000^(2i/d))</code></p>
</li>
<li>
<p>正弦编码用不同频率的波形区分位置，是<strong>常数</strong>（不参与训练），实现就一个双重循环</p>
</li>
<li>
<p>LayerNorm 对最后一维做 <code>y=(x-μ)/√(σ²+ε)·γ+β</code>，用 <code>sum_last_dim</code> 求均值方差，γ/β 可学习</p>
</li>
<li>
<p>LayerNorm 与 BatchNorm 的核心区别：<strong>归一行 vs 归一系列</strong>，NLP 用前者</p>
</li>
<li>
<p>残差连接 <code>y = x + F(x)</code> 让梯度里永远保留一份 <code>∂loss/∂y</code>，解决梯度消失、支持深网络</p>
</li>
<li>
<p>pre-norm（LN → 子层 → 残差）是 GPT 系列的标准结构</p>
</li>
<li>
<p>下一课：把这些积木全部拼起来，组成完整的 GPT 模型！</p>
</li>
<li>
<p>（本课的正弦位置编码在第 19 课被 RoPE 取代，见 <a href="docs/19-RoPE%E6%97%8B%E8%BD%AC%E4%BD%8D%E7%BD%AE%E7%BC%96%E7%A0%81.md">docs/19-RoPE旋转位置编码.md</a>；LayerNorm 与残差连接保留至今。）</p>
</li>
</ul>
]]></content>
  <category term="Rust"/>
  <category term="LLM"/>
  <category term="注意力"/>
</entry>
<entry>
  <title>第 10 课：多头注意力 —— 让模型「多角度」看世界</title>
  <link href="https://ganto.me/post/rust-llm-guide-10"/>
  <id>https://ganto.me/post/rust-llm-guide-10</id>
  <updated>2026-09-11T00:00:00+08:00</updated>
  <published>2026-09-11T00:00:00+08:00</published>
  <author><name>干徒</name></author>
  <summary><![CDATA[将单头注意力扩展为多头注意力，让模型从多个表示子空间并行捕捉信息。]]></summary>
  <content type="html"><![CDATA[<h1>第 10 课：多头注意力 —— 让模型&quot;多角度&quot;看世界</h1>
<blockquote>
<p>代码位置：<a href="src/attention.rs">src/attention.rs</a>（<code>MultiHeadAttention</code> 结构 + <code>forward</code>）
底层算子：<a href="src/tensor.rs">src/tensor.rs</a>（<code>reshape</code> / <code>permute</code> / <code>matmul</code> / <code>softmax_last_dim</code>）</p>
</blockquote>
<h2>1. 本课要搞懂的问题</h2>
<ol>
<li>为什么&quot;一个头&quot;不够？多个头到底多学到了什么？</li>
<li>拆头为什么是 reshape → permute → reshape 三步，不能一步到位？</li>
<li>合并头之后为什么还要一个输出投影 <code>c_proj</code>？</li>
<li><code>MultiHeadAttention::forward</code> 的 8 步，每一步形状怎么变？</li>
</ol>
<h2>2. 动机：一个头只能学一种&quot;相关性&quot;</h2>
<p>第 9 课的注意力，一个查询位置对所有键算一组权重——它只能表达<strong>一种</strong>&quot;谁和谁相关&quot;的模式。</p>
<p>但文本里的相关性是<strong>多种多样</strong>的：</p>
<ul>
<li>相邻词之间的语法依存（&quot;红 的 苹果&quot;）</li>
<li>指代关系（&quot;它&quot; 指谁？）</li>
<li>全局语义（某个主题词反复出现）</li>
</ul>
<p>一个头顾不过来。<strong>多头</strong>的思路：把 D 维空间切成 H 份，每份独立算注意力，让不同头学到不同模式，最后再合并。</p>
<table>
<thead>
<tr>
<th>头</th>
<th>可能学到的模式（示意，非真实数据）</th>
</tr>
</thead>
<tbody>
<tr>
<td>头 0</td>
<td>紧邻的前一个 token（位置关系）</td>
</tr>
<tr>
<td>头 1</td>
<td>句法角色（主语-动词-宾语）</td>
</tr>
<tr>
<td>头 2</td>
<td>指代消解（代词 ↔ 名词）</td>
</tr>
<tr>
<td>头 3</td>
<td>全局主题词</td>
</tr>
</tbody>
</table>
<p>数学形式：</p>
<pre><code>MultiHead(Q, K, V) = Concat(head_1, ..., head_H) · W_O
head_i = Attention(Q·W_i^Q, K·W_i^K, V·W_i^V)
</code></pre>
<h2>3. 结构一览</h2>
<pre><code class="language-rust">struct MultiHeadAttention {
    c_q: Linear,      // [D, D]
    c_k: Linear,      // [D, D]
    c_v: Linear,      // [D, D]
    c_proj: Linear,   // [D, D] 输出投影
    n_head: usize,
}
</code></pre>
<p>数据流：</p>
<pre><code>x → c_q / c_k / c_v 投影 → 拆头 → 每头各自算注意力 → 合并 → c_proj → 输出
</code></pre>
<p><code>n_head</code> 来自配置 <code>GPTConfig::tiny</code>：n_embd = 64，n_head = 4 → <strong>head_dim = 64 / 4 = 16</strong>。</p>
<h2>4. 拆头：reshape + permute + reshape</h2>
<pre><code class="language-rust">// 3. 拆头：[B, T, D] -&gt; [B*H, T, head_dim]
//    （先 reshape 出 H 维，再 permute 把 H 提到第 2 维）
let q = q
    .reshape(vec![b, t, self.n_head, head_dim])   // [B,T,D] -&gt; [B,T,H,head_dim]
    .permute(&amp;[0, 2, 1, 3])                       // -&gt; [B,H,T,head_dim]
    .reshape(vec![b * self.n_head, t, head_dim]); // -&gt; [B*H,T,head_dim]
</code></pre>
<h3>为什么不能直接 reshape 成 [B*H, T, head_dim]？</h3>
<p>关键在<strong>内存布局</strong>。以 D=8、H=2、head_dim=4、单个 token 为例，x 最后一维的数据排列是：</p>
<pre><code>[ 头0的4维 | 头1的4维 ]     ← 头 0 在前、头 1 在后（行优先存储）
</code></pre>
<ul>
<li><code>reshape([B, T, H, head_dim])</code>：只是重新解释数据，<strong>头 0 / 头 1 依然按块排列</strong> ✓</li>
<li>但如果<strong>跳过 permute 直接 reshape 成 [B*H, T, head_dim]</strong>，B 维会按 <code>b0-t0-h0, b0-t0-h1, b0-t1-h0, ...</code> 的顺序切片——同一个 token 的多个头被混进 T 维，完全错乱 ✗</li>
</ul>
<p>所以必须先 <code>permute(&amp;[0, 2, 1, 3])</code> 把&quot;头&quot;维提到第 2 维（H 排在 T 前面），让<strong>每个 (b, h) 的数据在内存里连成一块</strong>，再 reshape 成 [B*H, T, head_dim]，才能保证每个&quot;批&quot;恰好是一个头。</p>
<blockquote>
<p><code>permute</code> 和 <code>reshape</code> 都不搬动数据，只改变&quot;维度解释&quot;；三步合起来的效果 = &quot;把 (b, h) 提成独立的批&quot;。
这正是第 1 课讲的行优先存储的实战应用。</p>
</blockquote>
<h3>K、V 的拆头</h3>
<p>K、V 的拆法完全一样（注意这里的 T 变成了 <code>t_total</code>——带 KV cache 时更长，第 18 课讲解）：</p>
<pre><code class="language-rust">let k = k
    .reshape(vec![b, t_total, self.n_head, head_dim])
    .permute(&amp;[0, 2, 1, 3])
    .reshape(vec![b * self.n_head, t_total, head_dim]);
// v 同理
</code></pre>
<h3>为什么&quot;先整体投影再拆头&quot;是等价的？</h3>
<p><code>c_q</code> 是 [D, D] 线性层。对头 h 来说，它取输出的第 h 块（head_dim 维），这正好是输入 x 经过 W_Q <strong>第 h 块列</strong>的作用——数学上<strong>等价于每个头一个独立的投影 W_h^Q</strong>（GPT-2 的实现也是这个思路，只是把投影合并写在一起）。</p>
<h2>5. 并行注意力：一个批量 matmul 算完所有头</h2>
<p>拆头后 [B*H, T, head_dim] 恰好是 <code>matmul</code> 支持的 3D 批量形状，第 9 课的公式一行不多、一行不少：</p>
<pre><code class="language-rust">// 4. 注意力分数：scores = Q·Kᵀ / √d_k
let scale = 1.0 / (head_dim as f32).sqrt();
let kt = k.permute(&amp;[0, 2, 1]);                  // [B*H, head_dim, T_total]
let scores = q.matmul(&amp;kt).mul_scalar(scale);    // [B*H, T, T_total]

// 5. 因果掩码：把&quot;未来位置&quot;变成 -inf，softmax 后概率为 0
let scores = scores.add(mask);

// 6. softmax 得到注意力权重，加权求和
let attn = scores.softmax_last_dim();            // [B*H, T, T_total]
let out = attn.matmul(&amp;v);                       // [B*H, T, head_dim]
</code></pre>
<p>H 个头互不干扰，只是被&quot;压扁&quot;进了批维——这是拆头最大的好处：<strong>一次批量运算同时算完 H 个独立的注意力</strong>。</p>
<h2>6. 合并头：拆头的逆操作</h2>
<pre><code class="language-rust">// 7. 合并头回 [B, T, D]
let out = out
    .reshape(vec![b, self.n_head, t, head_dim]) // [B*H,T,head_dim] -&gt; [B,H,T,head_dim]
    .permute(&amp;[0, 2, 1, 3])                     // -&gt; [B,T,H,head_dim]
    .reshape(vec![b, t, d]);                    // -&gt; [B,T,D]（H×head_dim 拼回 D）
</code></pre>
<p>注意合并的顺序与拆头<strong>镜像对称</strong>：</p>
<table>
<thead>
<tr>
<th></th>
<th>拆头</th>
<th>合并</th>
</tr>
</thead>
<tbody>
<tr>
<td>reshape</td>
<td>[B,T,D] → [B,T,H,head_dim]</td>
<td>[B*H,T,head_dim] → [B,H,T,head_dim]</td>
</tr>
<tr>
<td>permute</td>
<td>[0,2,1,3]（H 提前）</td>
<td>[0,2,1,3]（H 归位）</td>
</tr>
<tr>
<td>reshape</td>
<td>[B*H,T,head_dim]（压平 B·H）</td>
<td>[B,T,D]（拼回 D）</td>
</tr>
</tbody>
</table>
<p>拼回后，每个 (b, t) 的 D 维 = <code>[头0的16维, 头1的16维, 头2的16维, 头3的16维]</code>，与拆头前的布局完全一致——因为 <code>matmul</code> 的批维上每个 (b, h) 都是独立的，头之间从不串扰。</p>
<h2>7. 输出投影 c_proj：让模型决定&quot;怎么混&quot;</h2>
<pre><code class="language-rust">// 8. 输出投影
self.c_proj.forward(&amp;out)
</code></pre>
<ul>
<li>合并头只是&quot;物理拼接&quot;，各头之间还没有交互</li>
<li><code>c_proj</code> 是一个 [D, D] 线性层：<strong>学习如何把 H 个头的信息融合</strong>（线性混合、重组）</li>
<li>至此一个完整的多头注意力子层结束，输出形状 [B, T, D] 与输入一致，方便后面接残差连接（第 11 课）</li>
</ul>
<blockquote>
<p>数学上：<code>Concat(head_1..head_H) · W_O</code>，W_O 就是 <code>c_proj</code> 的权重。</p>
</blockquote>
<h2>8. forward 全流程：8 步形状对照表</h2>
<p>以 <code>GPTConfig::tiny</code>（D=64、H=4、head_dim=16）为例，B / T 视输入而定（训练演示时 B=8、T=block_size=32）：</p>
<table>
<thead>
<tr>
<th>步骤</th>
<th>代码</th>
<th>形状</th>
</tr>
</thead>
<tbody>
<tr>
<td>输入 x</td>
<td><code>x</code></td>
<td>[B, T, 64]</td>
</tr>
<tr>
<td>① Q/K/V 投影</td>
<td><code>c_q/c_k/c_v.forward(x).reshape([b,t,d])</code></td>
<td>[B, T, 64] × 3</td>
</tr>
<tr>
<td>② KV cache（可选）</td>
<td><code>cache.append(...)</code></td>
<td>K/V 的 T 变成 T_total（第 18 课）</td>
</tr>
<tr>
<td>③ 拆头</td>
<td><code>reshape → permute → reshape</code></td>
<td>[B*4, T, 16]</td>
</tr>
<tr>
<td>④ scores</td>
<td><code>q.matmul(&amp;kt).mul_scalar(scale)</code></td>
<td>[B*4, T, T_total]</td>
</tr>
<tr>
<td>⑤ 掩码</td>
<td><code>scores.add(mask)</code></td>
<td>[B*4, T, T_total]</td>
</tr>
<tr>
<td>⑥ softmax + 加权求和</td>
<td><code>softmax_last_dim()</code> → <code>matmul(&amp;v)</code></td>
<td>[B*4, T, 16]</td>
</tr>
<tr>
<td>⑦ 合并头</td>
<td><code>reshape → permute → reshape</code></td>
<td>[B, T, 64]</td>
</tr>
<tr>
<td>⑧ 输出投影</td>
<td><code>c_proj.forward(&amp;out)</code></td>
<td>[B, T, 64]</td>
</tr>
</tbody>
</table>
<p>完整的 forward 骨架（略去 KV cache 分支，逻辑与源码一致）：</p>
<pre><code class="language-rust">fn forward(&amp;self, x: &amp;Tensor, mask: &amp;Tensor, kv_cache: Option&lt;&amp;mut KVCache&gt;) -&gt; Tensor {
    let (b, t, d) = (x.shape()[0], x.shape()[1], x.shape()[2]);
    let head_dim = d / self.n_head;

    // ① 投影得到 Q、K、V
    let q = self.c_q.forward(x).reshape(vec![b, t, d]);
    let k = self.c_k.forward(x).reshape(vec![b, t, d]);
    let v = self.c_v.forward(x).reshape(vec![b, t, d]);

    // ③ 拆头
    let q = q.reshape(vec![b, t, self.n_head, head_dim])
             .permute(&amp;[0, 2, 1, 3])
             .reshape(vec![b * self.n_head, t, head_dim]);
    let k = k.reshape(vec![b, t_total, self.n_head, head_dim])
             .permute(&amp;[0, 2, 1, 3])
             .reshape(vec![b * self.n_head, t_total, head_dim]);
    let v = v.reshape(vec![b, t_total, self.n_head, head_dim])
             .permute(&amp;[0, 2, 1, 3])
             .reshape(vec![b * self.n_head, t_total, head_dim]);

    // ④⑤⑥ 注意力（第 9 课）
    let scale = 1.0 / (head_dim as f32).sqrt();
    let kt = k.permute(&amp;[0, 2, 1]);
    let scores = q.matmul(&amp;kt).mul_scalar(scale).add(mask);
    let attn = scores.softmax_last_dim();
    let out = attn.matmul(&amp;v);

    // ⑦ 合并头，⑧ 输出投影
    let out = out.reshape(vec![b, self.n_head, t, head_dim])
                 .permute(&amp;[0, 2, 1, 3])
                 .reshape(vec![b, t, d]);
    self.c_proj.forward(&amp;out)
}
</code></pre>
<h2>9. 参数：训练时谁在被更新？</h2>
<p><code>Module for MultiHeadAttention</code> 负责收集全部可学习参数：</p>
<pre><code class="language-rust">impl Module for MultiHeadAttention {
    fn parameters(&amp;self) -&gt; Vec&lt;Tensor&gt; {
        let mut ps = self.c_q.parameters();   // weight [64,64] + bias [64]
        ps.extend(self.c_k.parameters());
        ps.extend(self.c_v.parameters());
        ps.extend(self.c_proj.parameters());  // 4 个 Linear = 8 个参数张量
        ps
    }
}
</code></pre>
<p>前向算出的误差通过反向传播（第 2 课）一路传回这 8 个张量，4 个投影层的权重随训练不断调整——&quot;学什么相关性、怎么融合&quot;全都由数据决定。</p>
<blockquote>
<p><code>forward</code> 第 ② 步的 KV cache 只是<strong>推理时</strong>缓存历史 K/V 的加速手段，不改变计算结果，第 18 课专门讲解。</p>
</blockquote>
<h2>10. 运行与测试</h2>
<pre><code class="language-bash">cargo test   # tensor 的 test_permute / test_matmul_3d 验证拆头、批量 matmul 所需算子
cargo run    # 演示 3 训练小 GPT：4 头注意力参与每一轮前向/反向
</code></pre>
<h2>11. 动手练习</h2>
<ol>
<li>把 <code>GPTConfig::tiny</code> 的 <code>n_head</code> 从 4 改成 8（head_dim = 8）重新训练，观察 loss 是否变化（代码有 <code>assert_eq!(head_dim * self.n_head, d)</code> 保证必须能整除）。</li>
<li>手动构造一个 [2, 3, 8] 的 Tensor，按 <code>reshape([2,3,2,4]) → permute([0,2,1,3]) → reshape([4,3,4])</code> 手写一遍，画出每个元素的归属，验证第 4 节的内存布局分析。</li>
<li>修改代码：去掉第 ⑧ 步 <code>c_proj</code>，直接返回合并后的 out，重新训练对比 loss（思考：表达能力损失在哪里）。</li>
<li>阅读 <code>forward</code> 中 <code>kv_cache</code> 分支的 <code>cache.append</code>，解释为什么推理时它能省掉&quot;历史 token 的重复计算&quot;（第 18 课预告）。</li>
</ol>
<h2>12. 本课总结</h2>
<ul>
<li>多头 = 把 D 切成 H 份，H 个注意力并行，学不同的相关性模式</li>
<li>拆头三步曲：reshape（切块）→ permute（H 提前）→ reshape（压平 B·H），顺序不能乱</li>
<li>合并是拆头的镜像；<code>c_proj</code> 负责融合各头信息</li>
<li>一次批量 matmul 同时算所有头，高效且简洁</li>
<li>下一步（第 11 课）：位置编码 + LayerNorm + 残差连接，拼出完整的 Transformer Block！</li>
</ul>
]]></content>
  <category term="Rust"/>
  <category term="LLM"/>
  <category term="注意力"/>
</entry>
<entry>
  <title>第 9 课：注意力机制 —— 让 token 互相「看」</title>
  <link href="https://ganto.me/post/rust-llm-guide-09"/>
  <id>https://ganto.me/post/rust-llm-guide-09</id>
  <updated>2026-09-10T00:00:00+08:00</updated>
  <published>2026-09-10T00:00:00+08:00</published>
  <author><name>干徒</name></author>
  <summary><![CDATA[实现注意力机制：Q/K/V 投影、打分、softmax 与因果掩码。]]></summary>
  <content type="html"><![CDATA[<h1>第 9 课：注意力机制 —— 让 token 互相&quot;看&quot;</h1>
<blockquote>
<p>代码位置：<a href="src/attention.rs">src/attention.rs</a>（<code>MultiHeadAttention::forward</code> 中的投影 / 打分 / softmax / 掩码部分）
底层算子：<a href="src/tensor.rs">src/tensor.rs</a>（<code>softmax_last_dim</code> / <code>matmul</code> / <code>permute</code> / <code>mul_scalar</code>）
掩码构造：<a href="src/model.rs">src/model.rs</a>（<code>GPT::forward</code>）</p>
</blockquote>
<h2>1. 本课要搞懂的问题</h2>
<ol>
<li>为什么一个 token 需要&quot;看&quot;别的 token？注意力到底在算什么？</li>
<li>Query / Key / Value 是什么？为什么不能直接用 x 打分？</li>
<li>为什么要除以 √d_k？softmax 在干什么？</li>
<li>为什么 GPT 只能看过去？<code>-inf</code> 掩码怎么实现？</li>
</ol>
<h2>2. 从词向量到&quot;上下文向量&quot;</h2>
<p>第 12 课会把每个 token 嵌入成一个向量，但 Embedding 查表得到的向量<strong>只代表这个 token 自己</strong>：它不知道周围发生了什么。&quot;bank&quot; 到底是&quot;河岸&quot;还是&quot;银行&quot;，单看它自己无法判断，必须结合上下文。</p>
<p>注意力要做的事：<strong>让每个位置根据内容，从其他位置&quot;收集&quot;信息</strong>。输出不再是&quot;我自己的向量&quot;，而是&quot;我 + 我关注的别人的向量的加权混合&quot;。</p>
<h2>3. 检索的视角：Query / Key / Value</h2>
<p>注意力最直观的类比是图书馆检索：</p>
<table>
<thead>
<tr>
<th>角色</th>
<th>类比</th>
<th>数学含义</th>
</tr>
</thead>
<tbody>
<tr>
<td>Query（查询）Q</td>
<td>你输入的&quot;关键词&quot;</td>
<td>我<strong>想要</strong>什么样的信息</td>
</tr>
<tr>
<td>Key（键）K</td>
<td>每本书的&quot;标签&quot;</td>
<td>我能<strong>提供</strong>什么样的信息</td>
</tr>
<tr>
<td>Value（值）V</td>
<td>书的内容</td>
<td>真正被<strong>取走</strong>的信息</td>
</tr>
</tbody>
</table>
<p>流程：拿 query 去和所有 key 比对相似度 → 得到权重 → 按权重把 value 加权求和。</p>
<p>我们的模型里 Q、K、V 都来自同一个输入 x（每个位置既当&quot;提问者&quot;又当&quot;被看者&quot;），这叫<strong>自注意力（Self-Attention）</strong>。</p>
<h2>4. Q / K / V 投影：三个角色各学各的表示</h2>
<p>直接用 x 当 Q、K、V 行不行？可以，但不好——三个角色应该学<strong>不同的表示</strong>。所以每个角色先过一层可学习的线性投影（<code>Linear: y = xW + b</code>）：</p>
<pre><code class="language-rust">// 1. 投影得到 Q、K、V
let q = self.c_q.forward(x).reshape(vec![b, t, d]); // [B, T, D]
let k = self.c_k.forward(x).reshape(vec![b, t, d]);
let v = self.c_v.forward(x).reshape(vec![b, t, d]);
</code></pre>
<ul>
<li><code>c_q</code> / <code>c_k</code> / <code>c_v</code> 是三个独立的 <code>Linear::new(D, D)</code>（<code>MultiHeadAttention</code> 结构体里的字段），各有自己的权重和偏置，在训练中学习</li>
<li><code>Linear::forward</code> 对 3D 输入会自动展平成 <code>[B*T, D]</code> 计算再还原 3D；这里的 <code>reshape(vec![b, t, d])</code> 保证形状明确是 <code>[B, T, D]</code></li>
<li>D = n_embd。模型配置 <code>GPTConfig::tiny</code>：n_embd = 64，n_head = 4 → head_dim = 16（第 10 课讲怎么拆头；<strong>本课先假设只有一个头</strong>，公式层面 H = 1）</li>
</ul>
<h2>5. 打分：scores = Q·Kᵀ / √d_k</h2>
<p>query 和 key 的相似度用<strong>点积</strong>衡量：q_i · k_j 越大越相似。一次性算所有两两相似度，就是矩阵乘法：</p>
<pre><code>S = Q · Kᵀ / √d_k
</code></pre>
<ul>
<li>Q: [T, d_k]，K: [T, d_k] → Kᵀ: [d_k, T] → S: [T, T]</li>
<li>S[i][j] = (q_i · k_j) / √d_k：第 i 个查询对第 j 个键的&quot;原始分数&quot;</li>
</ul>
<p>对应代码：</p>
<pre><code class="language-rust">// 4. 注意力分数：scores = Q·Kᵀ / √d_k
let scale = 1.0 / (head_dim as f32).sqrt();
let kt = k.permute(&amp;[0, 2, 1]);                     // K 转置：[B*H, head_dim, T_total]
let scores = q.matmul(&amp;kt).mul_scalar(scale);       // [B*H, T, T_total]
</code></pre>
<ul>
<li><code>permute(&amp;[0, 2, 1])</code>：把 K 的 <code>[T, head_dim]</code> 两维交换，得到数学上的 Kᵀ</li>
<li><code>q.matmul(&amp;kt)</code>：3D 批量矩阵乘法（第 1 课学的 matmul，这里按&quot;批&quot;逐矩阵相乘）</li>
<li><code>mul_scalar(scale)</code>：每个元素乘以 1/√d_k</li>
</ul>
<h3>5.1 手算例子（d_k = 2，两个 token）</h3>
<pre><code>Q = [ [1, 0],          K = [ [1, 0],          V = [ [1, 0],
      [0, 1] ]               [0, 1] ]               [0, 1] ]

Q·Kᵀ = [ [1, 0],
         [0, 1] ]

除以 √2 后：S = [ [0.707, 0    ],
                 [0,     0.707] ]
</code></pre>
<p>softmax 按行归一化（见第 7 节）后，第 1 行 ≈ [0.670, 0.330]，第 2 行 ≈ [0.330, 0.670]——即&quot;每个位置主要关注自己&quot;（对角线），很符合直觉。</p>
<h2>6. 为什么要除以 √d_k</h2>
<p>假设 q、k 每个维度独立、均值 0、方差 1。两个 d_k 维向量的点积：</p>
<pre><code>Var(q·k) = Var(Σ q_i·k_i) = d_k          →  标准差 = √d_k
</code></pre>
<table>
<thead>
<tr>
<th>d_k</th>
<th>点积的标准差</th>
<th>后果</th>
</tr>
</thead>
<tbody>
<tr>
<td>小（如 2）</td>
<td>1.4</td>
<td>softmax 分布温和，梯度健康</td>
</tr>
<tr>
<td>大（如 64）</td>
<td>8</td>
<td>点积数值大、分布&quot;尖&quot;，softmax 趋向 one-hot（一个 1 其余 0），梯度趋近 0，学不动</td>
</tr>
</tbody>
</table>
<ul>
<li>除以 √d_k 把方差拉回 1，softmax 保持&quot;温和&quot;的分布</li>
<li>所以这个公式叫 <strong>Scaled</strong> Dot-Product Attention——<strong>缩放</strong>点积注意力</li>
</ul>
<h2>7. softmax 归一化：把分数变成权重</h2>
<p>原始分数可正可负、量纲不定，不能直接当权重。softmax 把每一行变成<strong>非负且和为 1</strong> 的概率分布：</p>
<pre><code>a_ij = exp(S_ij) / Σ_j' exp(S_ij')
</code></pre>
<p>含义：第 i 个查询给第 j 个键的&quot;注意力权重&quot;，<strong>一行加起来等于 1</strong>。</p>
<h3>数值稳定技巧</h3>
<p><code>exp</code> 对大的正数会爆炸（如 exp(1000) = 无穷大），所以先减去每行最大值——数学上不改变结果（分子分母同除 exp(max)）：</p>
<pre><code class="language-rust">// softmax_last_dim：沿最后一维逐行独立归一化
for r in 0..rows {
    let mut maxv = f32::NEG_INFINITY;
    for j in 0..d {
        maxv = maxv.max(sd[r * d + j]);                    // 1. 找行最大值
    }
    let mut sum = 0.0;
    for j in 0..d {
        out_data[r * d + j] = (sd[r * d + j] - maxv).exp(); // 2. 减 max 再 exp
        sum += out_data[r * d + j];
    }
    for j in 0..d {
        out_data[r * d + j] /= sum;                        // 3. 归一化
    }
}
</code></pre>
<p>稳定版公式：</p>
<pre><code>softmax(x)_i = exp(x_i - max x) / Σ_j exp(x_j - max x)
</code></pre>
<blockquote>
<p>反向传播公式（<code>softmax_last_dim</code> 的 backward 里已实现）：∂x_i = s_i·(g_i − Σ_j g_j·s_j)，其中 s 是 softmax 输出、g 是上游梯度。这正是第 2 课自动微分的用武之地。</p>
</blockquote>
<h2>8. 加权求和：out = A·V</h2>
<p>把权重矩阵 A 作用到 V 上：</p>
<pre><code>out_i = Σ_j a_ij · v_j
</code></pre>
<p>即&quot;每个输出 = 所有位置 V 的<strong>凸组合</strong>&quot;（权重非负、和为 1）。代码只有两行：</p>
<pre><code class="language-rust">// 6. softmax 得到注意力权重，加权求和
let attn = scores.softmax_last_dim();  // [B*H, T, T_total]   注意力权重
let out = attn.matmul(&amp;v);             // [B*H, T, head_dim]  加权求和
</code></pre>
<p>至此，注意力 = <strong>内容相关的可学习加权平均</strong>：每个位置根据 query 与各 key 的相似度，从所有 value 里&quot;取&quot;信息。</p>
<h2>9. 因果掩码：为什么 GPT 只能看过去</h2>
<p>GPT 是<strong>自回归语言模型</strong>：预测第 t 个 token 时，条件只能是它之前的 token：</p>
<pre><code>P(token_t | token_1, ..., token_{t-1})
</code></pre>
<ul>
<li>训练时我们把<strong>整段文本并行</strong>喂入（这样才快！）。如果不加限制，位置 t 会&quot;偷看&quot;位置 t+1 的答案——信息泄漏，模型退化成&quot;抄答案&quot;</li>
<li>解决办法：让位置 i 的查询<strong>看不到 j &gt; i 的键</strong>——这就是因果掩码（causal mask）</li>
</ul>
<h3>掩码长什么样</h3>
<p><code>GPT::forward</code> 里构造（<code>t_total = t + base</code>，训练时 base = 0）：</p>
<pre><code class="language-rust">let mut mask_data = vec![0.0f32; t * t_total];
for i in 0..t {
    for j in 0..t_total {
        if j &gt; i + base {
            mask_data[i * t_total + j] = f32::NEG_INFINITY;
        }
    }
}
let mask = Tensor::from_vec(mask_data, vec![t, t_total]);
</code></pre>
<p>以 t = 4 为例，mask 是这样一个矩阵：</p>
<table>
<thead>
<tr>
<th>查询 i \ 键 j</th>
<th>j=0</th>
<th>j=1</th>
<th>j=2</th>
<th>j=3</th>
</tr>
</thead>
<tbody>
<tr>
<td>i=0</td>
<td>0</td>
<td>-inf</td>
<td>-inf</td>
<td>-inf</td>
</tr>
<tr>
<td>i=1</td>
<td>0</td>
<td>0</td>
<td>-inf</td>
<td>-inf</td>
</tr>
<tr>
<td>i=2</td>
<td>0</td>
<td>0</td>
<td>0</td>
<td>-inf</td>
</tr>
<tr>
<td>i=3</td>
<td>0</td>
<td>0</td>
<td>0</td>
<td>0</td>
</tr>
</tbody>
</table>
<p>对角线及以下（j ≤ i）是 0，未来位置（j &gt; i）是 <code>-inf</code>。</p>
<h3>为什么是 -inf 而不是把列删掉</h3>
<p>softmax 里 <code>exp(-inf) = 0</code>，被掩码位置的注意力权重<strong>精确为 0</strong>——&quot;看不见&quot;等价于&quot;权重为 0&quot;。同时张量形状保持不变，可以继续走统一的批量矩阵运算。</p>
<pre><code class="language-rust">let scores = scores.add(mask);              // [B*H, T, T_total] + 广播 [T, T_total]
let attn = scores.softmax_last_dim();       // -inf -&gt; exp(-inf) = 0 -&amp;gt; 权重为 0
</code></pre>
<blockquote>
<p><code>scores.add(mask)</code> 用到了第 3 课的广播：mask 形状 [T, T_total] 自动广播到 [B*H, T, T_total] 的每一个&quot;批&quot;（每个头共享同一张掩码）。
（代码里的 <code>base</code> 是 KV cache 引入的偏移，第 18 课讲解；训练时 base = 0。）</p>
</blockquote>
<h2>10. 公式 ↔ 代码 总对照</h2>
<pre><code>Attention(Q, K, V) = softmax( (Q·Kᵀ / √d_k) + M ) · V
</code></pre>
<table>
<thead>
<tr>
<th>数学</th>
<th>Rust 代码（<code>MultiHeadAttention::forward</code>）</th>
</tr>
</thead>
<tbody>
<tr>
<td>Q = x·W_Q</td>
<td><code>self.c_q.forward(x).reshape(vec![b, t, d])</code></td>
</tr>
<tr>
<td>K = x·W_K，V = x·W_V</td>
<td><code>c_k</code> / <code>c_v</code> 同理</td>
</tr>
<tr>
<td>Kᵀ</td>
<td><code>k.permute(&amp;[0, 2, 1])</code></td>
</tr>
<tr>
<td>Q·Kᵀ / √d_k</td>
<td><code>q.matmul(&amp;kt).mul_scalar(scale)</code>，<code>scale = 1.0 / (head_dim as f32).sqrt()</code></td>
</tr>
<tr>
<td>+ M（因果掩码）</td>
<td><code>scores.add(mask)</code></td>
</tr>
<tr>
<td>softmax</td>
<td><code>scores.softmax_last_dim()</code></td>
</tr>
<tr>
<td>·V</td>
<td><code>attn.matmul(&amp;v)</code></td>
</tr>
</tbody>
</table>
<h2>11. 运行与测试</h2>
<pre><code class="language-bash">cargo test   # tensor 的 test_softmax / test_matmul_2d / test_matmul_3d 验证本课用到的底层算子
cargo run    # 演示 3 训练小 GPT：每一步前向/反向都跑在本课的注意力计算上
</code></pre>
<h2>12. 动手练习</h2>
<ol>
<li>手算：取第 5.1 节的 2×2 例子，写出 softmax 后每一行的权重，再算出最终的加权求和结果，对照公式逐项核对。</li>
<li>修改 <code>GPT::forward</code> 里的掩码条件 <code>j &gt; i + base</code> 为 <code>j &gt;= i + base</code>（禁止&quot;看自己&quot;），思考对训练会有什么影响（提示：每个位置的预测将只能依赖更早的 token）。</li>
<li>临时改 <code>softmax_last_dim</code>：去掉&quot;减最大值&quot;那一步，用大数（如 1000）做输入，观察溢出/精度问题，验证数值稳定技巧的必要性。</li>
<li>阅读 <code>softmax_last_dim</code> 的反向代码，对照公式 ∂x_i = s_i·(g_i − Σ_j g_j·s_j) 逐行核对。</li>
</ol>
<h2>13. 本课总结</h2>
<ul>
<li>注意力 = 内容相关的加权平均：Q 问、K 答、V 给内容</li>
<li>scores = Q·Kᵀ/√d_k：点积打分，缩放防止 d_k 大时 softmax 饱和</li>
<li>softmax：分数 → 权重（非负、和为 1）；先减 max 保证数值稳定</li>
<li>因果掩码：未来位置填 <code>-inf</code> → softmax 后权重为 0 → 模型只能看过去</li>
<li>下一课：把注意力&quot;切成多份&quot;并行计算——多头注意力！</li>
</ul>
]]></content>
  <category term="Rust"/>
  <category term="LLM"/>
  <category term="注意力"/>
</entry>
<entry>
  <title>第 8 课：BPE 分词器 —— 让模型「读懂」文字</title>
  <link href="https://ganto.me/post/rust-llm-guide-08"/>
  <id>https://ganto.me/post/rust-llm-guide-08</id>
  <updated>2026-09-09T00:00:00+08:00</updated>
  <published>2026-09-09T00:00:00+08:00</published>
  <author><name>干徒</name></author>
  <summary><![CDATA[实现 BPE（Byte Pair Encoding）分词器，将文本切分为模型可处理的 token。]]></summary>
  <content type="html"><![CDATA[<h1>第 8 课：BPE 分词器 —— 让模型&quot;读懂&quot;文字</h1>
<blockquote>
<p>代码位置：<a href="src/tokenizer.rs">src/tokenizer.rs</a>
演示入口：<a href="src/main.rs">src/main.rs</a>（<code>demo_bpe</code>）
语料：<a href="src/data.rs">src/data.rs</a>（<code>CORPUS</code>）</p>
</blockquote>
<h2>1. 本课要搞懂的问题</h2>
<ol>
<li>模型只能吃数字，文字怎么变成数字？</li>
<li>按词切、按字符切，各有什么问题？有没有更好的方案？</li>
<li>BPE 到底是什么算法？训练、编码、解码分别怎么做？</li>
</ol>
<h2>2. 为什么需要分词</h2>
<p>大语言模型的输入输出都是<strong>数字</strong>：输入是一串 token id（<code>usize</code>），输出是对每个 token id 的预测分数。所以第一步要解决的问题是：<strong>把人类语言变成一串整数</strong>。</p>
<table>
<thead>
<tr>
<th>方案</th>
<th>词表大小</th>
<th>优点</th>
<th>缺点</th>
</tr>
</thead>
<tbody>
<tr>
<td>按词切分</td>
<td>数十万</td>
<td>语义单元完整</td>
<td>词表巨大；遇到没见过的词（OOV）直接抓瞎；run / ran / running 是 3 个互不相干的 token</td>
</tr>
<tr>
<td>按字符切分</td>
<td>几十</td>
<td>词表极小、无 OOV</td>
<td>序列变长 5~10 倍；&quot;lowest&quot; 被拆成 6 个字符，丢失&quot;这是 low 的最高级&quot;这种结构信息</td>
</tr>
<tr>
<td><strong>子词切分（BPE）</strong></td>
<td>几千~几万</td>
<td>常见词 1 个 token，罕见词拆成子词</td>
<td>算法比前两者复杂（本课重点）</td>
</tr>
</tbody>
</table>
<p>核心思想一句话：<strong>高频出现的片段合并成一个 token，低频内容用更小的片段表示。</strong></p>
<h2>3. 字符级分词 CharTokenizer（对照组）</h2>
<h3>3.1 结构</h3>
<pre><code class="language-rust">pub struct CharTokenizer {
    chars: Vec&lt;char&gt;,            // 词表：语料中出现过的所有字符
    stoi: HashMap&lt;char, usize&gt;,  // 字符 -&gt; id
}
</code></pre>
<h3>3.2 构建词表</h3>
<p><code>new(text)</code> 扫描语料，<strong>按字符首次出现的顺序</strong>收集去重：</p>
<pre><code class="language-rust">for c in text.chars() {
    if seen.insert(c) {
        chars.push(c);
    }
}
</code></pre>
<p>比如对 <code>&quot;hello world hello&quot;</code>：</p>
<ul>
<li>从左到右第一次遇到的字符依次是 h, e, l, o, ' ', w, r, d → 词表就是这 8 个字符</li>
<li><code>stoi = {h:0, e:1, l:2, o:3, ' ':4, w:5, r:6, d:7}</code>，<code>vocab_size() == 8</code></li>
</ul>
<h3>3.3 编码 / 解码</h3>
<pre><code class="language-rust">// 文本 -&gt; id 序列（每个字符查表）
pub fn encode(&amp;self, text: &amp;str) -&gt; Vec&lt;usize&gt; {
    text.chars()
        .map(|c| {
            *self
                .stoi
                .get(&amp;c)
                .unwrap_or_else(|| panic!(&quot;词表中没有字符 '{}'&quot;, c))
        })
        .collect()
}

// id 序列 -&amp;gt; 文本（按 id 反查字符，越界 id 带下标 panic 提示）
pub fn decode(&amp;self, ids: &amp;[usize]) -&gt; String {
    ids.iter()
        .map(|&amp;i| {
            self.chars
                .get(i)
                .copied()
                .unwrap_or_else(|| {
                    panic!(&quot;decode 遇到越界 token id {i}（词表大小 {}）&quot;, self.chars.len())
                })
        })
        .collect()
}
</code></pre>
<p>编码和解码互为逆运算：<code>decode(encode(text)) == text</code>（单元测试 <code>test_char_tokenizer_roundtrip</code> 验证了这一点）。</p>
<blockquote>
<p>注意：字符级编码遇到词表外的字符会<strong>直接 panic</strong>——这是它最大的短板。
演示程序里 <code>CharTokenizer::new(CORPUS)</code> 得到 <strong>35</strong> 个字符的词表，<code>&quot;fox&quot;</code> 编码为 <code>[20, 7, 21]</code>。</p>
</blockquote>
<h2>4. BPE 的直觉：把高频 pair &quot;焊&quot;在一起</h2>
<p>BPE（Byte Pair Encoding，字节对编码）源自数据压缩算法，规则很简单：<strong>反复找到出现次数最多的相邻符号对，把它们合并成一个新符号。</strong></p>
<p>以单元测试的语料 <code>&quot;low low low low low lowest lowest newest newest newest&quot;</code> 为例：</p>
<table>
<thead>
<tr>
<th>轮次</th>
<th>最高频相邻对</th>
<th>出现次数</th>
<th>合并成</th>
<th>效果</th>
</tr>
</thead>
<tbody>
<tr>
<td>1</td>
<td><code>(l, o)</code></td>
<td>7（5 个 low + 2 个 lowest）</td>
<td>id 256</td>
<td>每个 <code>low</code> 变成 <code>[256, w]</code></td>
</tr>
<tr>
<td>2</td>
<td><code>(256, w)</code></td>
<td>7（同上）</td>
<td>id 257</td>
<td><code>low</code> 被压成一个 token <code>[257]</code></td>
</tr>
<tr>
<td>3</td>
<td><code>(' ', 257)</code></td>
<td>5（4 个词间空格 + 1 个 lowest 前空格）</td>
<td>id 258</td>
<td>连&quot;空格 + low&quot;这样的跨词片段也能合并</td>
</tr>
</tbody>
</table>
<p>经过两轮合并，出现 7 次的常用子词 &quot;low&quot; 从 3 个字节压缩成了 <strong>1 个 token</strong>——这就是&quot;用更少的 token 表示更多文本&quot;。</p>
<h2>5. BPE 训练：train()</h2>
<h3>5.1 初始化：字节级词表</h3>
<pre><code class="language-rust">assert!(target_vocab &gt;= 256, &quot;BPE 词表至少 256（字节级）&quot;);
// 初始：每个 token 就是一个字节
let mut vocab: Vec&lt;Vec&lt;u8&gt;&gt; = (0u16..=255).map(|b| vec![b as u8]).collect();
let mut merges: Vec&lt;(u16, u16)&gt; = Vec::new();

// 语料 -&amp;gt; 字节 -&gt; id 序列
let mut ids: Vec&lt;u16&gt; = corpus.as_bytes().iter().map(|&amp;b| b as u16).collect();
</code></pre>
<p>初始词表 = <strong>256 个字节</strong>（0~255），每个 token 恰好是一个字节。为什么用字节而不是字符？</p>
<ul>
<li>任何 UTF-8 文本都可以拆成字节，<strong>不存在&quot;词表外&quot;字符</strong>（OOV = 0）</li>
<li>中文等多语言文本也能直接编码（一个汉字是 3 个字节）</li>
<li>GPT-2 等真实模型用的就是字节级 BPE</li>
</ul>
<h3>5.2 训练主循环：统计 → 选择 → 合并 → 替换</h3>
<pre><code class="language-rust">while vocab.len() &lt; target_vocab {
    // 1. 统计相邻 pair 频率
    let mut pair_freq: HashMap&lt;(u16, u16), usize&gt; = HashMap::new();
    for pair in ids.windows(2) {
        *pair_freq.entry((pair[0], pair[1])).or_insert(0) += 1;
    }
    // 2. 找最高频的 pair（频率相同取 pair 值小者，保证确定性）
    let Some(&amp;best) = pair_freq
        .iter()
        .max_by(|a, b| a.1.cmp(b.1).then_with(|| b.0.cmp(a.0)))
        .map(|(k, _)| k)
    else {
        break; // 没有可合并的 pair 了
    };
    // 3. 合并：新符号 = 两个符号的字节拼接
    let new_id = vocab.len() as u16;
    let mut new_bytes = vocab[best.0 as usize].clone();
    new_bytes.extend_from_slice(&amp;vocab[best.1 as usize]);
    vocab.push(new_bytes);
    merges.push(best);

    // 4. 替换 ids 中所有该 pair
    let mut new_ids: Vec&lt;u16&gt; = Vec::with_capacity(ids.len());
    let mut i = 0;
    while i &lt; ids.len() {
        if i + 1 &lt; ids.len() &amp;&amp; ids[i] == best.0 &amp;&amp; ids[i + 1] == best.1 {
            new_ids.push(new_id);
            i += 2;               // 一次吞掉两个符号
        } else {
            new_ids.push(ids[i]);
            i += 1;
        }
    }
    ids = new_ids;
}
</code></pre>
<table>
<thead>
<tr>
<th>步骤</th>
<th>代码</th>
<th>说明</th>
</tr>
</thead>
<tbody>
<tr>
<td>① 统计</td>
<td><code>ids.windows(2)</code> 滑窗</td>
<td>每相邻两个 id 组成 pair，用 HashMap 计数</td>
</tr>
<tr>
<td>② 选择</td>
<td><code>pair_freq.iter().max_by(...)</code></td>
<td>频率最高者；<strong>频率相同取 pair 数值更小的</strong>（保证结果确定）</td>
</tr>
<tr>
<td>③ 合并</td>
<td><code>vocab.push</code> + <code>merges.push</code></td>
<td>新符号的内容 = 两个旧符号内容拼接；id = 当前词表长度（从 256 起）</td>
</tr>
<tr>
<td>④ 替换</td>
<td>单遍 while 扫描</td>
<td>把序列里所有该 pair 替换成新 id，再进入下一轮</td>
</tr>
</tbody>
</table>
<p>训练结束后得到两份&quot;产物&quot;：</p>
<ul>
<li><strong><code>vocab: Vec&lt;Vec&lt;u8&gt;&gt;</code></strong>：token id → 它代表的字节序列（解码要用）</li>
<li><strong><code>merges: Vec&lt;(u16, u16)&gt;</code></strong>：合并规则表，按下标顺序排列（<strong>越早合并优先级越高</strong>，编码要用）</li>
</ul>
<p>词表大小 = 256 + 合并次数。演示里 <code>BPETokenizer::train(CORPUS, 400)</code> 得到 <strong>400 = 256 字节 + 144 次合并</strong>。</p>
<h2>6. BPE 编码：encode()</h2>
<p>编码 = 对新文本执行<strong>同样的合并</strong>。但合并顺序必须和训练时一致：训练时越早合并的规则优先级越高（它对应的 token id 更小）。</p>
<pre><code class="language-rust">pub fn encode(&amp;self, text: &amp;str) -&gt; Vec&lt;usize&gt; {
    let mut ids: Vec&lt;u16&gt; = text.as_bytes().iter().map(|&amp;b| b as u16).collect();
    for (idx, &amp;(a, b)) in self.merges.iter().enumerate() {
        let new_id = (256 + idx) as u16;
        let mut out: Vec&lt;u16&gt; = Vec::with_capacity(ids.len());
        let mut i = 0;
        while i &lt; ids.len() {
            if i + 1 &lt; ids.len() &amp;&amp; ids[i] == a &amp;&amp; ids[i + 1] == b {
                out.push(new_id);
                i += 2;
            } else {
                out.push(ids[i]);
                i += 1;
            }
        }
        ids = out;
    }
    ids.into_iter().map(|x| x as usize).collect()
}
</code></pre>
<p>要点：</p>
<ul>
<li><strong>按规则优先级单趟扫描</strong>（GPT-2 的标准实现）：从 <code>merges[0]</code> 到 <code>merges[最后]</code>，每条规则在序列上扫一遍，能合并就替换成它对应的新 token。复杂度 O(len × 合并数)，大语料也能秒级完成（若&quot;每次只合并一个 pair 并全量重扫&quot;是 O(n²×m)，174KB 语料会卡死）</li>
<li><code>new_id = 256 + idx</code>：merge 下标 idx 直接映射成 token id——因为训练时第 idx 次合并恰好产生 id <code>256 + idx</code></li>
<li>字节 id（0~255）直接复用训练时的字节 → id 映射</li>
<li>演示里 <code>&quot;the garden&quot;</code> 编码后只有 <strong>2 个 token</strong>（&quot;the&quot; 和 &quot; garden&quot; 都被压缩成了单个 token）</li>
</ul>
<p>以 <code>&quot;lowest&quot;</code> 为例走一遍：字节 <code>[l,o,w,e,s,t]</code> → 应用规则 0（假设是 <code>(l,o)</code>）→ <code>[256,w,e,s,t]</code> → 应用规则 1（<code>(256,w)</code>）→ <code>[257,e,s,t]</code> → 继续应用 <code>(e,s)</code>、<code>(s,t)</code> 对应规则……最终 6 个字节被压成 3 个 token（单元测试要求 <code>&quot;low&quot;</code> 编码后不超过 3 个 token）。</p>
<h2>7. BPE 解码：decode()</h2>
<p>解码是查表 + 拼接：每个 id 查到它代表的字节序列，拼起来还原文本。</p>
<pre><code class="language-rust">pub fn decode(&amp;self, ids: &amp;[usize]) -&gt; String {
    let mut bytes: Vec&lt;u8&gt; = Vec::new();
    for &amp;id in ids {
        let tok = self
            .vocab
            .get(id)
            .unwrap_or_else(|| panic!(&quot;decode 遇到越界 token id {id}（词表大小 {}）&quot;, self.vocab.len()));
        bytes.extend_from_slice(tok);
    }
    String::from_utf8_lossy(&amp;bytes).to_string()
}
</code></pre>
<ul>
<li><code>vocab[id]</code>：id → 字节序列（0~255 是单字节，256+ 是合并出来的多字节序列）</li>
<li>越界 id 用 <code>vocab.get(id)</code> 拦截：带下标信息的 panic 提示（decode 也会因传入非法 id 报错，不再是&quot;永不失败&quot;）</li>
<li><code>String::from_utf8_lossy</code>：万一拼出非法 UTF-8，用替换字符 <code>�</code> 顶替而不是 panic</li>
</ul>
<blockquote>
<p>完整闭环：<code>decode(encode(&quot;lowest new&quot;)) == &quot;lowest new&quot;</code>（单元测试 <code>test_bpe_roundtrip</code> 验证）。</p>
</blockquote>
<h2>8. 训练 / 编码 / 解码 对照总结</h2>
<table>
<thead>
<tr>
<th>操作</th>
<th>一句话</th>
<th>关键代码</th>
<th>产物/结果</th>
</tr>
</thead>
<tbody>
<tr>
<td>训练 train</td>
<td>从语料学合并规则</td>
<td>统计 pair → 合并最高频 → 替换（循环至目标词表大小）</td>
<td><code>merges</code>（规则）+ <code>vocab</code>（字节序列）</td>
</tr>
<tr>
<td>编码 encode</td>
<td>对新文本按规则贪心合并</td>
<td>按规则优先级（merges 顺序）单趟扫描替换</td>
<td>一串 token id</td>
</tr>
<tr>
<td>解码 decode</td>
<td>id → 字节序列拼接</td>
<td><code>vocab[id]</code> 逐个拼接 + <code>from_utf8_lossy</code></td>
<td>还原的文本</td>
</tr>
</tbody>
</table>
<p>三者关系：<strong>编码必须复现训练时的合并顺序</strong>，解码只是查表，所以编码、解码天然互逆，<code>decode(encode(x)) == x</code>。</p>
<h2>9. 运行与测试</h2>
<pre><code class="language-bash">cargo test   # 全部测试通过
cargo run    # 演示 2（BPE）：词表 400（256 + 144 次合并）；&quot;Red&quot; -&gt; [82, 101, 100]；&quot;the garden&quot; -&gt; 2 个 token
</code></pre>
<h2>10. 动手练习</h2>
<ol>
<li>把语料换成中文句子（如 <code>&quot;机器学习机器学习深度学习&quot;</code>），跑一遍 <code>BPETokenizer::train</code>，观察&quot;机器&quot;&quot;学习&quot;是否会被合并成单个 token（提示：中文 UTF-8 每字 3 字节，BPE 依然适用）。</li>
<li>修改 <code>train</code> 的 <code>target_vocab</code>，分别用 256 / 300 / 500，比较 <code>encode(&quot;lowest&quot;)</code> 的 token 数变化。</li>
<li>思考：<code>encode</code> 为什么必须按&quot;merge 下标最小&quot;合并，而不是按&quot;频率最高&quot;合并？（提示：训练时的合并顺序决定了 id 分配，编码必须复现同一顺序才能保证解码还原）</li>
<li>思考：<code>CharTokenizer</code> 遇到词表外字符会 panic，BPE 为什么永远不会有这个问题？</li>
</ol>
<h2>11. 本课总结</h2>
<ul>
<li>模型只认数字，分词器负责&quot;文字 ↔ id&quot;的转换</li>
<li>字符级分词：简单直观，但序列长、有词表外字符；词级分词：词表大、仍有 OOV</li>
<li>BPE：字节级底座（256 个字节起步，零 OOV）+ 反复合并最高频相邻 pair</li>
<li>训练产出 <code>merges</code>（合并规则）和 <code>vocab</code>（id → 字节序列）；编码贪心复现合并；解码查表拼接</li>
<li>下一课：token 变成向量之后，怎么让它们&quot;互相看&quot;？——注意力机制！</li>
</ul>
]]></content>
  <category term="Rust"/>
  <category term="LLM"/>
  <category term="分词器"/>
</entry>
<entry>
  <title>第 7 课：第一个 MLP —— 教会神经网络算 XOR</title>
  <link href="https://ganto.me/post/rust-llm-guide-07"/>
  <id>https://ganto.me/post/rust-llm-guide-07</id>
  <updated>2026-09-08T00:00:00+08:00</updated>
  <published>2026-09-08T00:00:00+08:00</published>
  <author><name>干徒</name></author>
  <summary><![CDATA[用前面实现的层组合出第一个多层感知机，成功训练它学会 XOR 异或运算。]]></summary>
  <content type="html"><![CDATA[<h1>第 7 课：第一个 MLP —— 教会神经网络算 XOR</h1>
<blockquote>
<p>代码位置：<a href="src/main.rs">src/main.rs</a>（<code>demo_xor</code> 函数）
前置知识：第 5 课 Linear / 激活函数、第 6 课 CrossEntropy / SGD</p>
</blockquote>
<h2>1. 本课要搞懂的问题</h2>
<ol>
<li>XOR 为什么&quot;看着简单却学不会&quot;？什么是<strong>线性不可分</strong>？</li>
<li>为什么必须加隐藏层和非线性激活？</li>
<li><code>demo_xor</code> 的每一行代码在干什么？</li>
<li>为什么 XOR 训练到 4/4 就能证明<strong>反向传播是对的</strong>？</li>
</ol>
<h2>2. XOR：简单到&quot;一眼看穿&quot;，难到&quot;一条直线搞不定&quot;</h2>
<p><strong>异或（XOR）</strong>：两个输入相同输出 0，不同输出 1。</p>
<table>
<thead>
<tr>
<th>输入 x1</th>
<th>输入 x2</th>
<th>输出 y</th>
</tr>
</thead>
<tbody>
<tr>
<td>0</td>
<td>0</td>
<td>0</td>
</tr>
<tr>
<td>0</td>
<td>1</td>
<td>1</td>
</tr>
<tr>
<td>1</td>
<td>0</td>
<td>1</td>
</tr>
<tr>
<td>1</td>
<td>1</td>
<td>0</td>
</tr>
</tbody>
</table>
<p>把四个点画在平面上（● = 输出 1，○ = 输出 0）：</p>
<pre><code>x2
1 ─  ●(0,1)   ○(1,1)
   │
0 ─  ○(0,0)   ●(1,0)
   └──────────────── x1
</code></pre>
<p>问题来了：<strong>能不能画一条直线，把 ● 和 ○ 分开？不能。</strong> 一条直线最多把平面分成两半，而两个 ● 在对角线上——无论直线怎么放，总有一侧同时混着 ● 和 ○。这就是<strong>线性不可分</strong>。</p>
<h2>3. 为什么必须非线性：单层线性模型必败</h2>
<ul>
<li>第 2 课的线性回归只有一层：<code>pred = xW + b</code>，它学出的边界本质是一条直线</li>
<li>对 XOR，直线永远分不开 → 单层模型最多只能对 3 个样本正确（3/4），永远到不了 4/4</li>
<li>解法：<strong>隐藏层 + 非线性激活</strong>（第 5 课学的 Tanh）。非线性可以把输入空间&quot;折叠 / 扭曲&quot;，让原本线性不可分的点在高层变得线性可分</li>
</ul>
<p>直觉（4 个样本为什么能学会）：</p>
<ul>
<li>隐藏层 4 个神经元 ≈ 学出 4 种&quot;特征组合&quot;</li>
<li>Tanh 把每个特征压缩到 (-1, 1)，给特征带来非线性</li>
<li>第二层再线性组合这些特征，就能画出&quot;折线&quot;边界，把四个点完美分开</li>
</ul>
<h2>4. 数据集与网络结构：2 → 4 → 2</h2>
<p><strong>数据集</strong>（4 个样本，一次全部喂入，batch = 4）：</p>
<pre><code class="language-rust">// 数据集：4 个样本
let x_data = Tensor::from_vec(
    vec![0.0, 0.0, 0.0, 1.0, 1.0, 0.0, 1.0, 1.0],
    vec![4, 2],
);
let y_targets = vec![0usize, 1, 1, 0]; // XOR 真值表
</code></pre>
<ul>
<li><code>x_data</code>：[4, 2]，四行正好是 (0,0)、(0,1)、(1,0)、(1,1)</li>
<li><code>y_targets</code>：对应输出 0、1、1、0</li>
</ul>
<p><strong>网络</strong>（两个 Linear 叠成 MLP）：</p>
<pre><code class="language-rust">let fc1 = Linear::new(2, 4, &amp;mut rng);   // 输入 2 维 → 隐藏层 4 维
let fc2 = Linear::new(4, 2, &amp;mut rng);   // 隐藏层 4 维 → 输出 2 维
</code></pre>
<p>结构图：</p>
<pre><code>x [4,2] ─► fc1 (2→4) ─► Tanh ─► h [4,4] ─► fc2 (4→2) ─► logits [4,2]
</code></pre>
<ul>
<li>输出层是 <strong>2 个神经元</strong>：第 0 个是&quot;类别 0 的分数&quot;，第 1 个是&quot;类别 1 的分数&quot;</li>
<li>为什么不直接用 1 个输出（0 或 1）？因为配合 CrossEntropy 分类更自然，两个分数取 argmax 就是预测</li>
</ul>
<p><strong>收集参数 + 创建优化器</strong>：</p>
<pre><code class="language-rust">let params: Vec&lt;Tensor&gt; = {
    let mut ps = fc1.parameters();
    ps.extend(fc2.parameters());
    ps
};
let opt = SGD::new(0.5, params);
</code></pre>
<ul>
<li><code>fc1.parameters()</code> + <code>fc2.parameters()</code>：共 2×4+4 + 4×2+2 = <strong>22 个参数</strong></li>
<li>学习率 0.5：XOR 问题简单，步长可以大一点</li>
<li>随机种子固定为 42（<code>Rng::new(42)</code>），每次运行初始化相同、结果可复现</li>
</ul>
<h2>5. 训练循环：1000 步</h2>
<pre><code class="language-rust">for step in 0..1000 {
    // 前向：tanh(x @ W1 + b1) @ W2 + b2
    let h = tanh(&amp;fc1.forward(&amp;x_data));
    let logits = fc2.forward(&amp;h);
    let loss = cross_entropy_loss(&amp;logits, &amp;y_targets);

    loss.backward();
    opt.step();
    opt.zero_grad();

    if step % 200 == 0 {
        println!(&quot;  step {:&gt;4} | loss {:.4}&quot;, step, loss.data()[0]);
    }
}
</code></pre>
<p>对应第 6 课的&quot;四步舞&quot;：</p>
<table>
<thead>
<tr>
<th>步骤</th>
<th>代码</th>
<th>说明</th>
</tr>
</thead>
<tbody>
<tr>
<td>前向</td>
<td><code>fc1.forward(&amp;x_data)</code> → <code>tanh</code> → <code>fc2.forward(&amp;h)</code></td>
<td>4 个样本一次算完</td>
</tr>
<tr>
<td>损失</td>
<td><code>cross_entropy_loss(&amp;logits, &amp;y_targets)</code></td>
<td>分类交叉熵</td>
</tr>
<tr>
<td>反向</td>
<td><code>loss.backward()</code></td>
<td>自动微分算出 22 个参数的梯度</td>
</tr>
<tr>
<td>更新</td>
<td><code>opt.step()</code></td>
<td>θ = θ − 0.5·g</td>
</tr>
<tr>
<td>清零</td>
<td><code>opt.zero_grad()</code></td>
<td>防止梯度跨轮累加</td>
</tr>
</tbody>
</table>
<p><strong>观察 loss</strong>：每 200 步打印一次，趋势是单调下降并趋近 0（具体数值由种子 42 的初始化决定，不必纠结；刚初始化时接近随机猜，2 类问题约等于 −ln 0.5 ≈ 0.69）。</p>
<h2>6. 验证：4/4 才算赢</h2>
<p>训练完要&quot;考试&quot;：</p>
<pre><code class="language-rust">// 验证正确率
let h = tanh(&amp;fc1.forward(&amp;x_data));
let logits = fc2.forward(&amp;h);
let data = logits.data();
let mut correct = 0;
for i in 0..4 {
    let pred = if data[i * 2] &gt; data[i * 2 + 1] { 0 } else { 1 };
    if pred == y_targets[i] {
        correct += 1;
    }
}
println!(&quot;  训练后正确率：{}/4（100% 说明反向传播正确）\n&quot;, correct);
</code></pre>
<ul>
<li>对每个样本取 <strong>argmax</strong>：类别 0 的分数 <code>data[i*2]</code> 大于类别 1 的分数 <code>data[i*2+1]</code> → 预测 0，否则预测 1</li>
<li>期望输出：<code>训练后正确率：4/4</code></li>
</ul>
<h2>7. 为什么 4/4 就能证明反向传播正确</h2>
<p>推理链条：</p>
<ol>
<li><strong>参数是随机初始化的</strong>（种子 42），绝不是&quot;碰巧答对&quot;；</li>
<li><strong>梯度完全来自自动微分</strong>：任何一个运算（matmul / add / relu / softmax / log / sum ...）的反向实现出错，梯度就会偏一点，参数越走越偏，1000 步后必然累积成大错；</li>
<li><strong>XOR 没有捷径</strong>：它是&quot;必须两层非线性&quot;的问题，梯度要穿透 Tanh 的导数（<code>1 - t²</code>）、两层 matmul 的转置路径、softmax 的雅可比路径——整条反向传播链路被真实训练&quot;压测&quot;了一遍；</li>
<li>结果 100% 正确 + loss 单调下降，还同时验证了<strong>优化器</strong>（SGD 的 <code>set_data</code> 写回）和<strong>训练循环</strong>（backward → step → zero_grad 的顺序）都是对的。</li>
</ol>
<p>换句话说：<strong>XOR 训练收敛到 4/4，等于对&quot;张量库 + 网络层 + 损失 + 优化器&quot;整条链路做了一次端到端集成测试</strong>。</p>
<h2>8. 运行</h2>
<pre><code class="language-bash">cargo run
</code></pre>
<p>输出第一段（loss 数值随种子确定，趋势如下）：</p>
<pre><code>=== 演示 1：MLP 学习 XOR（第 7 课）===
  step    0 | loss ...
  step  200 | loss ...
  step  400 | loss ...
  step  600 | loss ...
  step  800 | loss ...
  训练后正确率：4/4（100% 说明反向传播正确）
</code></pre>
<h2>9. 动手练习</h2>
<ol>
<li>把隐藏层大小从 4 改成 2（<code>Linear::new(2, 2, ...)</code>），观察还能不能到 4/4（提示：2 个隐藏神经元很可能不够）。</li>
<li>把 <code>tanh</code> 换成 <code>relu</code> 或 <code>gelu</code>，其他不变，观察收敛速度差异。</li>
<li>把学习率改成 0.05 和 5.0，对比 loss 下降曲线（太小慢、太大震荡）。</li>
<li>去掉 <code>tanh</code>（<code>h = fc1.forward(&amp;x_data)</code> 直接喂给 fc2），验证&quot;没有非线性就学不会 XOR&quot;，观察正确率卡在 3/4。</li>
<li>把训练步数改成 100（可能还没收敛）和 10000（数据只有 4 个样本，看会不会过拟合），观察 loss 与正确率。</li>
</ol>
<h2>10. 本课总结</h2>
<ul>
<li>XOR <strong>线性不可分</strong> → 必须隐藏层 + 非线性激活</li>
<li><code>demo_xor</code> 完整走通：数据集 → 网络 2→4→2 → 训练 1000 步 → 验证 4/4</li>
<li><strong>4/4 = 反向传播正确的强证据</strong>（端到端集成测试）</li>
<li>从现在起，我们有了一个能训练任意小分类问题的完整工具箱</li>
<li>下一课：给模型喂真正的文本——BPE 分词器</li>
</ul>
]]></content>
  <category term="Rust"/>
  <category term="LLM"/>
  <category term="MLP"/>
</entry>
<entry>
  <title>第 6 课：损失函数与优化器 —— 让模型知道「错在哪、怎么改」</title>
  <link href="https://ganto.me/post/rust-llm-guide-06"/>
  <id>https://ganto.me/post/rust-llm-guide-06</id>
  <updated>2026-09-07T00:00:00+08:00</updated>
  <published>2026-09-07T00:00:00+08:00</published>
  <author><name>干徒</name></author>
  <summary><![CDATA[实现 MSE/CrossEntropy 损失函数与 SGD/AdamW 优化器。]]></summary>
  <content type="html"><![CDATA[<h1>第 6 课：损失函数与优化器 —— 让模型知道&quot;错在哪、怎么改&quot;</h1>
<blockquote>
<p>代码位置：</p>
<ul>
<li><a href="src/loss.rs">src/loss.rs</a>（MSE / CrossEntropy）</li>
<li><a href="src/optim.rs">src/optim.rs</a>（SGD / AdamW）</li>
</ul>
<p>演示入口：<a href="src/main.rs">src/main.rs</a>（demo_xor 使用 <code>cross_entropy_loss</code> + <code>SGD</code>）</p>
</blockquote>
<h2>1. 本课要搞懂的问题</h2>
<ol>
<li>损失函数是干什么的？MSE 和 CrossEntropy 分别用在什么任务？</li>
<li>CrossEntropy 为什么是&quot;one-hot + log_softmax + 取均值&quot;？</li>
<li>SGD 的一行更新公式 <code>θ = θ - lr·g</code> 为什么能让损失下降？</li>
<li>训练循环的&quot;四步&quot;（前向、反向、更新、清零）分别对应哪几行代码？</li>
</ol>
<h2>2. 损失函数：一杆&quot;错得有多离谱&quot;的秤</h2>
<p>训练 = 让模型输出不断接近目标。怎么量化&quot;接近&quot;？用<strong>损失函数</strong> <code>L(pred, target)</code>：</p>
<ul>
<li>L 越大 → 错得越离谱</li>
<li>训练就是不断减小 L</li>
</ul>
<p>两类任务对应两个损失：</p>
<table>
<thead>
<tr>
<th>任务</th>
<th>预测什么</th>
<th>损失函数</th>
</tr>
</thead>
<tbody>
<tr>
<td>回归</td>
<td>连续数值（房价、温度）</td>
<td>MSE（均方误差）</td>
</tr>
<tr>
<td>分类</td>
<td>属于哪个类别（猫/狗、下一个 token）</td>
<td>CrossEntropy（交叉熵）</td>
</tr>
</tbody>
</table>
<p>语言模型本质是&quot;预测下一个 token 是哪一类&quot;，所以 LLM 用的是 CrossEntropy。</p>
<h2>3. MSE：均方误差（回归）</h2>
<p><strong>公式</strong>：</p>
<pre><code>MSE = (1/N) · Σᵢ (predᵢ - targetᵢ)²
</code></pre>
<p><strong>对应代码</strong>（<code>loss.rs</code>，全是一行链式调用）：</p>
<pre><code class="language-rust">/// 均方误差：loss = mean((pred - target)²)
pub fn mse_loss(pred: &amp;Tensor, target: &amp;Tensor) -&gt; Tensor {
    pred.sub(target)
        .pow(2.0)
        .sum()
        .mul_scalar(1.0 / pred.numel() as f32)
}
</code></pre>
<table>
<thead>
<tr>
<th>步骤</th>
<th>代码</th>
<th>作用</th>
</tr>
</thead>
<tbody>
<tr>
<td>相减</td>
<td><code>pred.sub(target)</code></td>
<td>误差 eᵢ = predᵢ − targetᵢ</td>
</tr>
<tr>
<td>平方</td>
<td><code>.pow(2.0)</code></td>
<td>eᵢ²：消除正负抵消、放大大误差</td>
</tr>
<tr>
<td>求和</td>
<td><code>.sum()</code></td>
<td>Σ eᵢ²</td>
</tr>
<tr>
<td>取平均</td>
<td><code>.mul_scalar(1/N)</code></td>
<td>除以元素总数 N（<code>pred.numel()</code>）</td>
</tr>
</tbody>
</table>
<p><strong>为什么平方？</strong> 正负误差不会互相抵消；大误差被放大（对离群点更敏感）。</p>
<p><strong>梯度</strong>（不需要手写，自动微分自动算出来）：</p>
<pre><code>∂L/∂predᵢ = 2·(predᵢ - targetᵢ) / N
</code></pre>
<h2>4. CrossEntropy：交叉熵（分类）</h2>
<p>分类任务里模型输出的是 <strong>logits</strong>（每个类别的&quot;原始分数&quot;），要把它变成&quot;概率&quot;再用交叉熵衡量。</p>
<p><strong>输入</strong>：</p>
<ul>
<li><code>logits</code>: [B, D] —— B 个样本，每个 D 个类别的分数</li>
<li><code>targets</code>: [B] —— 每个样本真实类别的下标</li>
</ul>
<p><strong>数学公式</strong>：</p>
<pre><code>loss = -(1/B) · Σᵢ log( softmax(logitsᵢ)[targetsᵢ] )
</code></pre>
<p>对应代码分三步（<code>loss.rs</code>）：</p>
<pre><code class="language-rust">pub fn cross_entropy_loss(logits: &amp;Tensor, targets: &amp;[usize]) -&gt; Tensor {
    assert_eq!(logits.rank(), 2, &quot;交叉熵的 logits 应为 [B, D]&quot;);
    let (b, d) = (logits.shape()[0], logits.shape()[1]);

    // —— 第 1 步：one-hot 编码 ——
    let mut onehot = vec![0.0f32; b * d];
    for (i, &amp;t) in targets.iter().enumerate() {
        assert!(t &lt; d, &quot;目标类别越界：{} &gt;= {}&quot;, t, d);
        onehot[i * d + t] = 1.0;
    }
    let oh = Tensor::from_vec(onehot, vec![b, d]);

    // —— 第 2 步：log_softmax（数值稳定版，log-sum-exp 技巧）——
    let log_probs = logits.log_softmax_last_dim();

    // —— 第 3 步：取出正确类别的 log 概率，取负，再取均值 ——
    log_probs
        .mul(&amp;oh)        // 只保留正确类别位置的 log 概率
        .sum_last_dim()  // [B,1]：每行求和 = 正确类别的 log 概率
        .neg()           // 取负：正确概率越高 loss 越小
        .sum()           // 所有样本相加
        .mul_scalar(1.0 / b as f32)  // 除以 B 取平均
}
</code></pre>
<h3>逐步拆解</h3>
<p><strong>第 1 步 one-hot</strong>：把&quot;下标 2&quot;变成 <code>[0, 0, 1, 0, ...]</code>，只有正确位置是 1。</p>
<p><strong>第 2 步 log_softmax</strong>：<code>softmax</code> 把分数变成概率（和为 1、都为正），<code>log</code> 取对数。</p>
<blockquote>
<p>注：这里用的是朴素写法 <code>softmax 之后 log</code>（即 log_softmax）。因为 <code>softmax_last_dim</code> 内部已经做了&quot;先减最大值再 exp&quot;的数值稳定处理，所以直接 log 是安全的。</p>
</blockquote>
<p><strong>第 3 步 取负 + 取均值</strong>：</p>
<ul>
<li><code>mul(&amp;oh)</code> 后每行只有正确类别位置非 0 → <code>sum_last_dim()</code> 恰好得到每个样本正确类别的 log 概率</li>
<li>正确概率越高 → <code>-log p</code> 越小 → loss 越小 ✓</li>
<li>除以 B（样本数）取平均</li>
</ul>
<h3>数值例子（2 类、2 个样本，示意）</h3>
<table>
<thead>
<tr>
<th>样本</th>
<th>logits</th>
<th>softmax</th>
<th>正确类别</th>
<th>−log p</th>
</tr>
</thead>
<tbody>
<tr>
<td>第 1 个</td>
<td>[2.0, 0.5]</td>
<td>[0.82, 0.18]</td>
<td>0</td>
<td>−ln 0.82 ≈ 0.20</td>
</tr>
<tr>
<td>第 2 个</td>
<td>[0.1, 1.8]</td>
<td>[0.15, 0.85]</td>
<td>1</td>
<td>−ln 0.85 ≈ 0.17</td>
</tr>
</tbody>
</table>
<p>平均 loss ≈ (0.20 + 0.17) / 2 ≈ 0.18。如果模型对正确类别给出概率接近 1，loss 就接近 0。</p>
<blockquote>
<p>关于梯度：softmax + 交叉熵的组合，数学上梯度恰好等于 <code>p − y_onehot</code>（预测概率减去 one-hot）。我们的代码没有手写这个式子——反向传播会自动把它算出来。这正好展示了&quot;公式 → 自动微分&quot;的分工。</p>
</blockquote>
<h2>5. 梯度下降原理：沿着&quot;最陡的下坡&quot;走</h2>
<p>回顾第 2 课的核心公式，对每个参数 θ：</p>
<pre><code>θ = θ - 学习率 × ∂L/∂θ
</code></pre>
<ul>
<li><code>∂L/∂θ &gt; 0</code>：θ 增大 loss 也增大 → 减去它，θ 变小</li>
<li><code>∂L/∂θ &lt; 0</code>：θ 增大 loss 减小 → 减去它，θ 变大</li>
<li>学习率（lr）控制每步迈多大：<strong>太小</strong>收敛慢，<strong>太大</strong>来回震荡甚至发散</li>
</ul>
<p>几何直觉：loss 是参数的山坡，梯度指向&quot;上升最快的方向&quot;，我们每次都朝<strong>反方向</strong>走一小步，就像小球从山坡滚下。</p>
<pre><code>loss
  │        .
  │      .   .
  │    .       .
  │  .           .
  └──────────────────→ θ
</code></pre>
<h2>6. SGD：把更新规则写成代码</h2>
<p>**随机梯度下降（SGD）**是最朴素的优化器（<code>optim.rs</code>）：</p>
<pre><code class="language-rust">/// 随机梯度下降（SGD）
pub struct SGD {
    lr: f32,
    params: Vec&lt;Tensor&gt;,
}

impl SGD {
    pub fn new(lr: f32, params: Vec&lt;Tensor&gt;) -&gt; Self {
        SGD { lr, params }
    }

    /// 更新一步：θ = θ - lr * g
    pub fn step(&amp;self) {
        for p in &amp;self.params {
            let g = p.grad();
            let d = p.data();
            let updated: Vec&lt;f32&gt; = d.iter().zip(&amp;g).map(|(v, g)| v - self.lr * g).collect();
            p.set_data(updated);
        }
    }

    pub fn zero_grad(&amp;self) {
        for p in &amp;self.params {
            p.zero_grad();
        }
    }
}
</code></pre>
<p><strong>公式与代码的对应</strong>：</p>
<table>
<thead>
<tr>
<th>公式</th>
<th>代码</th>
</tr>
</thead>
<tbody>
<tr>
<td>θ（当前参数值）</td>
<td><code>p.data()</code></td>
</tr>
<tr>
<td>g（梯度）</td>
<td><code>p.grad()</code></td>
</tr>
<tr>
<td>θ − lr·g</td>
<td><code>d.iter().zip(&amp;g).map(|(v, g)| v - self.lr * g)</code></td>
</tr>
<tr>
<td>写回</td>
<td><code>p.set_data(updated)</code></td>
</tr>
</tbody>
</table>
<p>两个方法的<strong>分工</strong>：</p>
<ul>
<li><code>step()</code>：必须在 <code>loss.backward()</code> 之后调用，否则梯度全是 0，参数纹丝不动</li>
<li><code>zero_grad()</code>：每轮更新后必须清零梯度。因为反向传播是 <code>+=</code> 累加（第 2 课讲过），不清零的话下一轮的梯度会叠加上一轮的旧梯度</li>
</ul>
<blockquote>
<p>顺带一提：<code>optim.rs</code> 里还有 AdamW（第 17 课讲）。它把&quot;固定步长&quot;换成&quot;动量 + 自适应步长&quot;，但接口完全一样（<code>step()</code> / <code>zero_grad()</code>），这正是统一接口的好处。</p>
</blockquote>
<h2>7. 训练循环：四步舞</h2>
<p>一次完整的学习 = 四步（所有深度学习框架训练循环的骨架）：</p>
<pre><code class="language-rust">// 1. 前向：算预测和损失
let logits = model.forward(&amp;x);
let loss = cross_entropy_loss(&amp;logits, &amp;y_targets);
// 2. 反向：自动算出每个参数的梯度
loss.backward();
// 3. 更新：θ = θ - lr·g
opt.step();
// 4. 清零梯度：否则下一轮会累加
opt.zero_grad();
</code></pre>
<p><strong>顺序很重要</strong>：<code>backward → step → zero_grad</code>，反了或漏了都会出错。</p>
<h2>8. 运行与测试</h2>
<pre><code class="language-bash">cargo test     # 测试全部通过
cargo run      # 演示 1 中 loss 从初始值一路下降、正确率 4/4
</code></pre>
<p>（demo_xor 的每一行会在第 7 课完整讲解。）</p>
<h2>9. 动手练习</h2>
<ol>
<li>把 <code>cross_entropy_loss</code> 的取均值从&quot;除以 b&quot;改成&quot;除以 b·d&quot;，观察 loss 数值变化，思考哪个更合理。</li>
<li>给 SGD 加<strong>动量</strong>：<code>v = 0.9·v + g; θ = θ - lr·v</code>，对比收敛速度。</li>
<li>思考：one-hot 为什么只在正确位置是 1？如果给两个类别各标 0.5（soft label）会怎样？</li>
<li>手算一个 3 分类、2 个样本的 <code>cross_entropy_loss</code>，和程序输出对一下。</li>
<li>尝试用 <code>mse_loss</code> 替代 <code>cross_entropy_loss</code> 训练分类任务（直接把 logits 往 one-hot 上回归），观察效果差异。</li>
</ol>
<h2>10. 本课总结</h2>
<ul>
<li><strong>MSE</strong>：回归任务的 <code>mean((pred - target)²)</code></li>
<li><strong>CrossEntropy</strong>：one-hot + log_softmax → 取负 → 取均值，分类任务标配（LLM 用它）</li>
<li><strong>SGD</strong>：<code>θ = θ - lr·g</code>，一行代码一个循环</li>
<li><strong>训练循环</strong> = 前向 → 反向 → 更新 → 清零，四步缺一不可</li>
<li>下一课：把 Linear、ReLU、CrossEntropy、SGD 拼起来，训练第一个 MLP！</li>
</ul>
]]></content>
  <category term="Rust"/>
  <category term="LLM"/>
  <category term="优化器"/>
</entry>
<entry>
  <title>第 5 课：线性层与激活函数 —— 神经网络的「积木」</title>
  <link href="https://ganto.me/post/rust-llm-guide-05"/>
  <id>https://ganto.me/post/rust-llm-guide-05</id>
  <updated>2026-09-06T00:00:00+08:00</updated>
  <published>2026-09-06T00:00:00+08:00</published>
  <author><name>干徒</name></author>
  <summary><![CDATA[实现 Linear 层、ReLU/GELU/Tanh/Softmax 激活函数与 Module trait 参数管理。]]></summary>
  <content type="html"><![CDATA[<h1>第 5 课：线性层与激活函数 —— 神经网络的&quot;积木&quot;</h1>
<blockquote>
<p>代码位置：</p>
<ul>
<li><a href="src/layers.rs">src/layers.rs</a>（Linear 层、ReLU / GELU / Tanh / Softmax）</li>
<li><a href="src/module.rs">src/module.rs</a>（Module trait 参数管理）</li>
<li><a href="src/rng.rs">src/rng.rs</a>（xorshift64 随机数生成器，权重初始化用）</li>
</ul>
<p>演示入口：<a href="src/main.rs">src/main.rs</a></p>
</blockquote>
<h2>1. 本课要搞懂的问题</h2>
<ol>
<li>什么是&quot;层&quot;（layer）？为什么神经网络要由层堆叠而成？</li>
<li>Linear 层的数学本质 <code>y = xW + b</code> 是什么？代码怎么写？</li>
<li>权重为什么要初始化？xavier 风格的 <code>±1/√in</code> 是怎么来的？</li>
<li>激活函数是干什么的？ReLU / GELU / Tanh 的公式和反向导数各是什么？</li>
<li>怎么统一管理所有可训练参数？—— Module trait</li>
</ol>
<h2>2. 从&quot;一个参数&quot;到&quot;一层&quot;</h2>
<p>第 2 课我们用手写更新 w、b 的方式学会了 <code>y = 2x + 1</code>。但真实问题（图像分类、语言模型）输入是几千维的向量、输出是几万个类别，不可能靠手写公式。</p>
<p>于是我们把&quot;一批神经元&quot;打包成一个<strong>层（layer）</strong>：</p>
<ul>
<li>每个神经元：对输入做<strong>加权和 + 偏置</strong>，再过一层<strong>非线性函数</strong></li>
<li>一层 = 很多个这样的神经元，它们的权重堆在一起就是矩阵 W</li>
</ul>
<p>神经网络 = 一层一层地堆叠。这一课实现最基本的积木：<strong>Linear 层</strong>和<strong>激活函数</strong>。</p>
<h2>3. Linear 层：y = xW + b</h2>
<p>Linear 又叫&quot;全连接层 / 稠密层&quot;。它把输入向量线性变换成另一个向量：</p>
<table>
<thead>
<tr>
<th>名字</th>
<th>形状</th>
<th>含义</th>
</tr>
</thead>
<tbody>
<tr>
<td>x</td>
<td>[B, in]（或 [B, T, in]）</td>
<td>输入</td>
</tr>
<tr>
<td>W（weight）</td>
<td>[in, out]</td>
<td>权重</td>
</tr>
<tr>
<td>b（bias）</td>
<td>[out]</td>
<td>偏置</td>
</tr>
<tr>
<td>y</td>
<td>[B, out]（或 [B, T, out]）</td>
<td>输出</td>
</tr>
</tbody>
</table>
<p><strong>数学公式</strong>（对每个样本 i）：</p>
<pre><code>y[i, j] = Σ_k x[i, k] · W[k, j] + b[j]
</code></pre>
<p>等价写法：<code>y = x @ W + b</code>（这就是第 1 课学的矩阵乘法 + 第 3 课学的广播加法）。</p>
<p>对应的 Rust 结构（<code>layers.rs</code>）：</p>
<pre><code class="language-rust">/// 线性层：y = x @ W + b
///
/// - weight: [in_features, out_features]
/// - bias:   [out_features]
pub struct Linear {
    pub weight: Tensor,
    pub bias: Tensor,
}
</code></pre>
<p>前向计算的核心就一行：</p>
<pre><code class="language-rust">// y = x @ W + b（b 是 [out]，与 [B, out] 广播相加）
let y = x.matmul(&amp;self.weight).add(&amp;self.bias);
</code></pre>
<p>两个关键点：</p>
<ul>
<li><code>weight</code> 和 <code>bias</code> 都用 <code>Tensor::param(...)</code> 构造（<code>requires_grad = true</code>），反向传播时自动得到梯度；</li>
<li><code>bias</code> 形状是 <code>[out]</code>，和结果 <code>[B, out]</code> 相加时按广播规则每一行都加上同一个偏置——这正是第 3 课广播的用武之地。</li>
</ul>
<h2>4. 权重初始化：为什么是 ±1/√in</h2>
<p><code>Linear::new</code> 创建时给权重赋随机初始值：</p>
<pre><code class="language-rust">pub fn new(in_features: usize, out_features: usize, rng: &amp;mut Rng) -&gt; Self {
    let std = (2.0 / (in_features + out_features) as f32).sqrt();
    let w: Vec&lt;f32&gt; = (0..in_features * out_features)
        .map(|_| rng.randn() * std)
        .collect();
    Linear {
        weight: Tensor::param(w, vec![in_features, out_features]),
        bias: Tensor::param(vec![0.0; out_features], vec![out_features]),
    }
}
</code></pre>
<p>也就是从正态分布 <code>N(0, std)</code> 采样，<code>std = √(2/(in+out))</code>（Xavier 正态初始化），bias 初始化为全零。</p>
<p><strong>为什么不能随便初始化？</strong></p>
<table>
<thead>
<tr>
<th>初始化方式</th>
<th>后果</th>
</tr>
</thead>
<tbody>
<tr>
<td>全 0</td>
<td>所有神经元输出相同、梯度相同，永远学不出差异</td>
</tr>
<tr>
<td>太大（如 ±5）</td>
<td>激活值迅速膨胀 → 数值溢出 / 梯度爆炸</td>
</tr>
<tr>
<td>太小（如 ±1e-4）</td>
<td>梯度极小 → 收敛慢如蜗牛</td>
</tr>
<tr>
<td>√(2/(in+out))</td>
<td>激活值的<strong>方差随层数基本不放大</strong>，训练稳定</td>
</tr>
</tbody>
</table>
<p><strong>直觉推导</strong>：假设输入 x 的方差为 1，输出是 in 个项 <code>wᵢxᵢ</code> 的和。若 <code>wᵢ ~ N(0, σ²)</code>，则 <code>Var(wᵢ) = σ²</code>，于是：</p>
<pre><code>Var(Σ wᵢxᵢ) ≈ in · Var(wᵢ) · Var(x) = in · σ²
</code></pre>
<p>想让方差保持不变，需要 <code>σ = √(1/in)</code>；Xavier 正态取 <code>σ = √(2/(in+out))</code>（兼顾前向和反向），核心思想——<strong>让每一层的输出量级都保持在 1 附近</strong>。</p>
<p>随机数从哪来？标准库没有 RNG，我们自己在 <code>rng.rs</code> 实现了一个 <strong>xorshift64</strong>（几十行位运算，零依赖、可复现）：</p>
<pre><code class="language-rust">/// 生成 [lo, hi) 的均匀浮点数
pub fn uniform(&amp;mut self, lo: f32, hi: f32) -&gt; f32 {
    lo + (hi - lo) * self.next_f32()
}
</code></pre>
<p>用固定种子创建（如第 7 课的 <code>Rng::new(42)</code>），每次运行得到的初始化完全相同，实验结果可复现。</p>
<h2>5. 支持 2D 与 3D 输入</h2>
<p>一个 batch 的输入通常是 2D（<code>[B, in]</code>）；但语言模型里一批数据是 3D（<code>[B, T, in]</code>，B=batch、T=序列长度）。Linear 的 <code>forward</code> 两者都支持：</p>
<pre><code class="language-rust">pub fn forward(&amp;self, x: &amp;Tensor) -&gt; Tensor {
    // 支持 [B, in] 和 [B, T, in]；3D 输入在内部展平计算，输出保持 3D
    let is_3d = x.rank() == 3;
    let orig_shape = x.shape().to_vec();
    let x = match x.rank() {
        2 =&gt; x.clone(),
        3 =&gt; x.reshape(vec![x.shape()[0] * x.shape()[1], x.shape()[2]]),
        _ =&gt; panic!(&quot;Linear 输入必须为 2D 或 3D&quot;),
    };
    let y = x.matmul(&amp;self.weight).add(&amp;self.bias);
    if is_3d {
        // 3D 输入 [B, T, in] -&amp;gt; 输出 [B, T, out]（最后一维换成 out_features）
        let mut out_shape = orig_shape;
        let n = out_shape.len();
        out_shape[n - 1] = self.weight.shape()[1];
        y.reshape(out_shape)
    } else {
        y
    }
}
</code></pre>
<p>处理方式：3D 先 <code>reshape</code> 成 <code>[B·T, in]</code> 当 2D 算，算完再把最后一维换回 <code>out</code> 并 <code>reshape</code> 成 <code>[B, T, out]</code>。</p>
<table>
<thead>
<tr>
<th>输入形状</th>
<th>内部计算形状</th>
<th>输出形状</th>
</tr>
</thead>
<tbody>
<tr>
<td>[B, in]</td>
<td>[B, in]（原样）</td>
<td>[B, out]</td>
</tr>
<tr>
<td>[B, T, in]</td>
<td>[B·T, in]（展平）</td>
<td>[B, T, out]</td>
</tr>
</tbody>
</table>
<p><code>reshape</code> 不改变数据顺序，梯度也能 1:1 传回，所以这个&quot;展平再恢复&quot;的技巧在自动微分下完全安全。</p>
<h2>6. 参数管理：Module trait</h2>
<p>深度学习里一切&quot;可训练的结构&quot;都是模块。我们定义统一的接口（<code>module.rs</code>）：</p>
<pre><code class="language-rust">/// 模块接口：任何可训练结构都实现它
pub trait Module {
    /// 返回模块的所有参数（含嵌套子模块）
    fn parameters(&amp;self) -&gt; Vec&lt;Tensor&gt;;
}

/// 便捷方法：清零所有参数的梯度
pub fn zero_grad_all(module: &amp;dyn Module) {
    for p in module.parameters() {
        p.zero_grad();
    }
}
</code></pre>
<p><code>Linear</code> 的实现（<code>layers.rs</code>）：</p>
<pre><code class="language-rust">impl Module for Linear {
    fn parameters(&amp;self) -&gt; Vec&lt;Tensor&gt; {
        vec![self.weight.clone(), self.bias.clone()]
    }
}
</code></pre>
<p><strong>为什么需要这个 trait？</strong> 因为优化器（第 6 课）只认&quot;参数列表&quot;。多个模块拼在一起时：</p>
<pre><code class="language-rust">let params: Vec&lt;Tensor&gt; = {
    let mut ps = fc1.parameters();
    ps.extend(fc2.parameters());
    ps
};
let opt = SGD::new(0.5, params);   // 优化器拿到全部 22 个参数
</code></pre>
<p>有了 <code>Module</code>，今后 LayerNorm、Embedding、Transformer Block 都能用同一套方式&quot;一键收集参数&quot;，这正是第 7 课 demo_xor 的做法。</p>
<h2>7. 激活函数：没有它，多层等于一层</h2>
<p>先想一个问题：如果只有线性层会怎样？</p>
<pre><code>f(x) = (xW1 + b1)·W2 + b2 = x·(W1W2) + (b1W2 + b2)
</code></pre>
<p>两个线性变换复合还是一个线性变换！<strong>堆多少层都等价于一层</strong>。所以必须夹入<strong>非线性</strong>激活函数，多层才有意义。</p>
<p><code>layers.rs</code> 提供了三个激活函数（都是对第 4 课 <code>tensor.rs</code> 中张量方法的薄包装）：</p>
<pre><code class="language-rust">/// ReLU：max(0, x)，简单、计算快、缓解梯度消失
pub fn relu(x: &amp;Tensor) -&gt; Tensor { x.relu() }

/// GELU：GPT 系列的默认激活，用 tanh 近似，比 ReLU 更平滑
pub fn gelu(x: &amp;Tensor) -&gt; Tensor { x.gelu() }

/// Tanh：S 型，输出 (-1, 1)
pub fn tanh(x: &amp;Tensor) -&gt; Tensor { x.tanh() }
</code></pre>
<h3>7.1 ReLU</h3>
<p><strong>公式与导数</strong>：</p>
<pre><code>c = max(0, x)
dc/dx = 1  (x &gt; 0)
      = 0  (x ≤ 0)
</code></pre>
<p><strong>对应代码</strong>（<code>tensor.rs</code> 中的 <code>relu</code>，核心是反向用 mask 乘梯度）：</p>
<pre><code class="language-rust">// 前向：c = max(0, x)；同时记录 mask：x &gt; 0 的位置为 1，其余为 0
let mask: Vec&lt;f32&gt; = sd.iter().map(|&amp;a| if a &gt; 0.0 { 1.0 } else { 0.0 }).collect();
// 反向：∂x = g * mask（即 g * (x&gt;0)）
sgm[i] += g[i] * mask[i];
</code></pre>
<p>特点：计算最快；x&lt;0 时梯度为 0（优点：缓解梯度消失；缺点：可能&quot;神经元死亡&quot;）。</p>
<h3>7.2 GELU</h3>
<p><strong>公式</strong>（tanh 近似，GPT 系列默认激活）：</p>
<pre><code>c = 0.5·x·(1 + tanh(√(2/π)·(x + 0.044715·x³)))
</code></pre>
<p><strong>反向导数</strong>（令 <code>a = √(2/π)(x + 0.044715x³)</code>，<code>t = tanh(a)</code>）：</p>
<pre><code>da/dx = √(2/π) · (1 + 3·0.044715·x²)
dc/dx = 0.5·(1 + t) + 0.5·x·(1 - t²)·da/dx
</code></pre>
<p><strong>对应代码</strong>（<code>tensor.rs</code> 中的 <code>gelu</code>，前向缓存 <code>t</code> 供反向复用）：</p>
<pre><code class="language-rust">const SQRT_2_PI: f32 = 0.797_884_560_8; // sqrt(2/π)
const COEF: f32 = 0.044_715;
// 前向：t_vals 缓存 tanh(a)
let a = SQRT_2_PI * (x + COEF * x * x * x);
let t = a.tanh();
t_vals[i] = t;
data[i] = 0.5 * x * (1.0 + t);
// 反向
let da_dx = SQRT_2_PI * (1.0 + 3.0 * COEF * x * x);
let dy_dx = 0.5 * (1.0 + t) + 0.5 * x * (1.0 - t * t) * da_dx;
sgm[i] += g[i] * dy_dx;
</code></pre>
<p>特点：处处可导、比 ReLU 平滑，x=0 附近没有&quot;尖角&quot;。</p>
<h3>7.3 Tanh</h3>
<p><strong>公式与导数</strong>：</p>
<pre><code>c = tanh(x) = (e^x - e^-x) / (e^x + e^-x)
dc/dx = 1 - tanh²(x) = 1 - c²
</code></pre>
<p><strong>对应代码</strong>（<code>tensor.rs</code> 中的 <code>tanh</code>，反向直接用前向输出值 c 算 <code>1-c²</code>）：</p>
<pre><code class="language-rust">// 前向：c = tanh(x)
// 反向：∂x = g * (1 - c²)
sgm[i] += g[i] * (1.0 - data[i] * data[i]);
</code></pre>
<p>特点：S 形，输出落在 (-1, 1)，需要居中输出时好用；两端梯度趋近 0。</p>
<h3>7.4 三种激活对比</h3>
<table>
<thead>
<tr>
<th>激活</th>
<th>输出范围</th>
<th>公式</th>
<th>反向导数</th>
<th>特点</th>
</tr>
</thead>
<tbody>
<tr>
<td>ReLU</td>
<td>[0, +∞)</td>
<td>max(0, x)</td>
<td>1（x&gt;0）否则 0</td>
<td>最快，隐藏层最常用</td>
</tr>
<tr>
<td>GELU</td>
<td>约 (-0.17, +∞)</td>
<td>0.5x(1+tanh(√(2/π)(x+0.044715x³)))</td>
<td>见 7.2</td>
<td>平滑，GPT 系列默认</td>
</tr>
<tr>
<td>Tanh</td>
<td>(-1, 1)</td>
<td>(e^x−e^-x)/(e^x+e^-x)</td>
<td>1−c²</td>
<td>输出居中，两端饱和</td>
</tr>
</tbody>
</table>
<blockquote>
<p>补充：<code>layers.rs</code> 里还有一个 <code>softmax</code>（把一组分数变成概率分布），它是 CrossEntropy 的一部分，下一课会用到。学习计划表里第 5 课原写的是&quot;Sigmoid&quot;，实际代码中用 GELU 取代了它（GPT 时代更常用），ReLU / Tanh 均已实现。</p>
</blockquote>
<h2>8. 运行与测试</h2>
<pre><code class="language-bash">cargo test     # 测试全部通过（含 relu 梯度、softmax、线性回归收敛等）
cargo run      # 演示 1：MLP 学习 XOR（第 7 课完整讲解）
</code></pre>
<h2>9. 动手练习</h2>
<ol>
<li>把 <code>Linear::new</code> 里的 <code>std</code> 改成 0.01 或 5.0，跑第 7 课的 demo_xor，观察收敛速度变化，解释原因。</li>
<li>给 Linear 加一个&quot;无 bias&quot;选项（bias 全 0 且不出现在 <code>parameters()</code> 里），想想 <code>parameters()</code> 该怎么改。</li>
<li>手动推导：GELU 在 x=0 处的值（应为 0）和导数；tanh 在 x=0 处的导数（应为 1）。</li>
<li>思考：代码里 ReLU 在 x=0 处的梯度定义为 0（<code>a &gt; 0</code> 才为 1）。如果定义为 1 会怎样？训练结果会有区别吗？</li>
<li>（选做）查资料对比 xavier 与 kaiming 初始化，并解释为什么 Embedding 用 0.02 的 bound（见 <code>layers.rs</code> 的 <code>Embedding::new</code>）。</li>
</ol>
<h2>10. 本课总结</h2>
<ul>
<li><strong>Linear</strong>：y = xW + b，weight <code>[in, out]</code>、bias <code>[out]</code>，支持 2D / 3D 输入</li>
<li><strong>初始化</strong>：均匀分布 ±1/√in（xavier 风格），保证激活量级不随层数放大</li>
<li><strong>Module trait</strong>：<code>parameters()</code> 统一收集参数，优化器直接消费</li>
<li><strong>激活函数</strong>：ReLU / GELU / Tanh，公式、反向导数与代码一一对应</li>
<li>下一课：损失函数与优化器，让模型&quot;知道自己错在哪、怎么改&quot;</li>
</ul>
]]></content>
  <category term="Rust"/>
  <category term="LLM"/>
  <category term="神经网络"/>
</entry>
<entry>
  <title>第 4 课：模块化重构 —— 项目结构分层与 Rc&lt;RefCell&gt; 架构</title>
  <link href="https://ganto.me/post/rust-llm-guide-04"/>
  <id>https://ganto.me/post/rust-llm-guide-04</id>
  <updated>2026-09-05T00:00:00+08:00</updated>
  <published>2026-09-05T00:00:00+08:00</published>
  <author><name>干徒</name></author>
  <summary><![CDATA[对项目进行模块化重构，按职责分层文件，并引入 Rc<RefCell> 管理共享参数。]]></summary>
  <content type="html"><![CDATA[<h1>第 4 课：模块化重构 —— 项目结构分层与 Rc<!-- raw HTML omitted --> 架构</h1>
<blockquote>
<p>代码位置：<a href="src/tensor.rs">src/tensor.rs</a>（核心：Tensor 结构体与自动微分）、<a href="src/module.rs">src/module.rs</a>（Module trait）、<a href="src/main.rs">src/main.rs</a>（模块声明与演示编排）
参考实现：<a href="src/layers.rs">src/layers.rs</a>（Module 的第一个实现者）</p>
</blockquote>
<h2>1. 本课要搞懂的问题</h2>
<ol>
<li>一个从零写的项目，文件该怎么切？每个文件各管什么？</li>
<li><code>Tensor</code> 里的 <code>Rc&lt;RefCell&lt;Vec&lt;f32&gt;&gt;&gt;</code> 是什么？为什么张量需要&quot;共享可变数据&quot;？</li>
<li>为什么 <code>parents</code> 要用 <code>Rc&lt;Vec&lt;Tensor&gt;&gt;</code> 而不是直接 <code>Vec&lt;Tensor&gt;</code>？直接存会有什么灾难？</li>
<li><code>backward()</code> 里的 DFS 拓扑排序是怎么保证&quot;梯度从 loss 流回叶子&quot;的？</li>
<li><code>Module</code> trait 是干什么的？它和优化器、训练循环怎么配合？</li>
</ol>
<h2>2. 为什么要&quot;重构&quot;</h2>
<p>经过前三课，<code>tensor.rs</code> 已经长成了一个功能完整的张量库：构造、形状、逐元素运算、广播、归约、softmax、matmul、自动微分……如果所有代码继续堆在一个文件里，很快就会失控：</p>
<ul>
<li><strong>职责不分</strong>：张量运算、自动微分、模型层、训练逻辑混在一起，改一处可能碰坏另一处</li>
<li><strong>无法复用</strong>：后续每节课都要加新东西（层、损失、优化器、GPT 模型），没有清晰的模块边界就没法增量开发</li>
<li><strong>依赖混乱</strong>：谁依赖谁说不清，改个接口要满项目搜索</li>
</ul>
<p>所以这一课做两件事：</p>
<ol>
<li><strong>横向</strong>：把项目按职责拆成多个 <code>.rs</code> 文件，形成清晰的依赖金字塔</li>
<li><strong>纵向</strong>：深入理解 <code>Tensor</code> 的 <code>Rc&lt;RefCell&gt;</code> 架构——它是一切上层建筑的地基，前三课里所有算子都建立在这套设计之上</li>
</ol>
<h2>3. src/ 各文件职责分工</h2>
<p><code>main.rs</code> 顶部用 <code>mod</code> 声明了全部模块：</p>
<pre><code class="language-rust">// src/main.rs
mod attention;
mod autograd;
mod checkpoint;
mod cli;
mod config;
mod data;
mod gpu;
mod layers;
mod loss;
mod model;
mod module;
mod optim;
mod rng;
mod rope;
mod sample;
mod tensor;
mod tokenizer;
mod train;
</code></pre>
<p>每个文件的职责（与文件头注释一致）：</p>
<table>
<thead>
<tr>
<th>文件</th>
<th>职责</th>
<th>对应课程</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>tensor.rs</code></td>
<td>张量数据结构 + 运算（前向 + 反向闭包）</td>
<td>第 1-4 课</td>
</tr>
<tr>
<td><code>autograd.rs</code></td>
<td>自动微分：<code>backward</code> 迭代式拓扑排序（从 tensor.rs 拆出）</td>
<td>第 2 课</td>
</tr>
<tr>
<td><code>rope.rs</code></td>
<td>RoPE 旋转位置编码（从 tensor.rs 拆出）</td>
<td>第 19 课</td>
</tr>
<tr>
<td><code>attention.rs</code></td>
<td>多头自注意力 + KV Cache（从 model.rs 拆出）</td>
<td>第 9-10、18 课</td>
</tr>
<tr>
<td><code>gpu.rs</code></td>
<td>GPU 计算后端（<code>--features gpu</code>）：WGSL 计算着色器</td>
<td>第 21 课</td>
</tr>
<tr>
<td><code>module.rs</code></td>
<td><code>Module</code> trait（参数收集的统一接口）</td>
<td>第 5 课</td>
</tr>
<tr>
<td><code>layers.rs</code></td>
<td>神经网络层：<code>Linear</code> / <code>LayerNorm</code> / <code>Embedding</code> + 激活函数</td>
<td>第 5、11、12 课</td>
</tr>
<tr>
<td><code>loss.rs</code></td>
<td>损失函数：<code>mse_loss</code> / <code>cross_entropy_loss</code></td>
<td>第 6 课</td>
</tr>
<tr>
<td><code>optim.rs</code></td>
<td>优化器：<code>SGD</code> / <code>AdamW</code></td>
<td>第 6、17 课</td>
</tr>
<tr>
<td><code>rng.rs</code></td>
<td>xorshift64 伪随机数生成器（参数初始化、采样）</td>
<td>第 5 课起</td>
</tr>
<tr>
<td><code>data.rs</code></td>
<td>数据加载 <code>DataLoader</code> 与内置语料 <code>CORPUS</code></td>
<td>第 14 课</td>
</tr>
<tr>
<td><code>tokenizer.rs</code></td>
<td>分词器：<code>CharTokenizer</code> / <code>BPETokenizer</code></td>
<td>第 8 课</td>
</tr>
<tr>
<td><code>model.rs</code></td>
<td><code>GPT</code> 模型（Transformer Block 堆叠）</td>
<td>第 11-12 课</td>
</tr>
<tr>
<td><code>train.rs</code></td>
<td>训练循环 + 学习率调度 <code>LRScheduler</code></td>
<td>第 13、20 课</td>
</tr>
<tr>
<td><code>sample.rs</code></td>
<td>推理采样：temperature / top-k / top-p</td>
<td>第 15 课</td>
</tr>
<tr>
<td><code>config.rs</code></td>
<td>配置加载（serde 序列化）</td>
<td>配套</td>
</tr>
<tr>
<td><code>checkpoint.rs</code></td>
<td>checkpoint 保存/恢复</td>
<td>配套</td>
</tr>
<tr>
<td><code>cli.rs</code></td>
<td>命令行定义（clap）</td>
<td>配套</td>
</tr>
<tr>
<td><code>main.rs</code></td>
<td>程序入口，依次演示各课成果</td>
<td>每课</td>
</tr>
</tbody>
</table>
<p>依赖关系呈金字塔形——<strong>越往下越基础，越往上越具体</strong>：</p>
<table>
<thead>
<tr>
<th>层次</th>
<th>模块</th>
<th>说明</th>
</tr>
</thead>
<tbody>
<tr>
<td>第 4 层（入口）</td>
<td><code>main.rs</code></td>
<td>声明全部模块，编排演示流程</td>
</tr>
<tr>
<td>第 3 层（流程）</td>
<td><code>train.rs</code> <code>model.rs</code> <code>sample.rs</code> <code>checkpoint.rs</code> <code>cli.rs</code></td>
<td>训练循环、GPT 组装、推理采样、checkpoint、命令行</td>
</tr>
<tr>
<td>第 2 层（组件）</td>
<td><code>layers.rs</code> <code>loss.rs</code> <code>optim.rs</code> <code>data.rs</code> <code>tokenizer.rs</code> <code>rng.rs</code> <code>config.rs</code></td>
<td>层、损失、优化器、数据、分词、随机数、配置</td>
</tr>
<tr>
<td>第 1 层（协议）</td>
<td><code>module.rs</code></td>
<td>只定义一个 trait，不依赖上层任何模块</td>
</tr>
<tr>
<td>第 0.5 层（拆分）</td>
<td><code>autograd.rs</code> <code>rope.rs</code> <code>attention.rs</code></td>
<td>从 tensor.rs / model.rs 拆出的独立模块</td>
</tr>
<tr>
<td>第 0 层（地基）</td>
<td><code>tensor.rs</code> <code>gpu.rs</code></td>
<td>张量与自动微分、GPU 计算后端，被所有模块依赖</td>
</tr>
</tbody>
</table>
<p>要点：</p>
<ul>
<li><strong><code>tensor.rs</code> 在最底层</strong>：所有模块都 <code>use crate::tensor::Tensor</code>，但它不依赖任何其他模块。</li>
<li><strong><code>module.rs</code> 只依赖 <code>tensor.rs</code></strong>：它只定义一个 trait，是个&quot;协议层&quot;，供 <code>layers.rs</code> / <code>model.rs</code> 实现。</li>
<li><strong>上层模块互相独立</strong>：<code>loss.rs</code> 不知道 <code>optim.rs</code> 的存在，改动一个不影响另一个；<code>data.rs</code>、<code>tokenizer.rs</code>、<code>rng.rs</code> 与神经网络计算完全解耦。</li>
</ul>
<h2>4. 核心架构：Rc&lt;RefCell&lt;Vec<!-- raw HTML omitted -->&gt;&gt; 共享可变数据</h2>
<p>先看 <code>Tensor</code> 的结构体（<code>src/tensor.rs</code>）：</p>
<pre><code class="language-rust">#[derive(Clone)]
pub struct Tensor {
    data: Rc&lt;RefCell&lt;Vec&lt;f32&gt;&gt;&gt;,   // 数值（前向结果）
    shape: Vec&lt;usize&gt;,             // 形状
    grad: Rc&lt;RefCell&lt;Vec&lt;f32&gt;&gt;&gt;,   // 梯度（反向累加）
    requires_grad: bool,           // 是否需要梯度（参数=true，数据=false）
    parents: Rc&lt;Vec&lt;Tensor&gt;&gt;,      // 父节点列表（我是由谁算出来的）
    backward: Option&lt;BackwardFn&gt;,  // 反向函数：把梯度传给父节点
}
</code></pre>
<p>两个问题：<strong>为什么数值和梯度要共享？共享之后怎么才能&quot;可变&quot;？</strong></p>
<h3>4.1 Rc：多个句柄指向同一份数据</h3>
<p>计算图里一个节点经常被<strong>多个运算同时引用</strong>。比如 <code>z = x·y + w</code> 里 <code>y</code> 只被用一次，但看下面这种图：</p>
<pre><code>          x ──┐
               mul ──► t ──┐
          x ──┘            add ──► z      （x 被用了两次！）
          w ───────────────┘
</code></pre>
<p><code>x</code> 同时是两次运算的输入。如果 <code>Tensor</code> 直接 <code>pub data: Vec&lt;f32&gt;</code>，克隆一个 <code>Tensor</code> 就要<strong>复制整份数据</strong>；而 <code>Rc&lt;T&gt;</code>（引用计数）允许无数个 <code>Tensor</code> 句柄共享同一个底层数据，克隆只把引用计数 +1，是 <strong>O(1)</strong> 的操作。这也是 <code>#[derive(Clone)]</code> 能放心用的前提。</p>
<h3>4.2 RefCell：共享的同时允许修改</h3>
<p><code>Rc</code> 只解决&quot;共享&quot;，不解决&quot;可变&quot;——<code>Rc&lt;T&gt;</code> 的内容默认不可变。但我们的张量既被多处引用、又需要更新（前向写 <code>data</code>、反向写 <code>grad</code>）。Rust 的常规借用规则（一个值同时只能有一个可变借用）会拒绝这种写法。</p>
<p><code>RefCell&lt;T&gt;</code>（内部可变性）把&quot;借用检查&quot;从<strong>编译期</strong>推迟到<strong>运行期</strong>：<code>borrow()</code> 只读借用、<code>borrow_mut()</code> 可变借用，同一时刻只能有一个 <code>borrow_mut</code>，违反就 panic。这就是&quot;共享可变数据&quot;的标准解法：</p>
<table>
<thead>
<tr>
<th>组合</th>
<th>解决的问题</th>
<th>代价</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>Rc&lt;T&gt;</code></td>
<td>多个句柄共享一份数据</td>
<td>只读共享</td>
</tr>
<tr>
<td><code>RefCell&lt;T&gt;</code></td>
<td>共享的数据还能改</td>
<td>借用检查推迟到运行时</td>
</tr>
<tr>
<td><code>Rc&lt;RefCell&lt;T&gt;&gt;</code></td>
<td><strong>共享 + 可变</strong></td>
<td>运行时 panic 风险、无线程安全</td>
</tr>
</tbody>
</table>
<p>对应到代码里的经典模式：</p>
<pre><code class="language-rust">let sd = self.data.borrow();          // 读：前向计算
// …… 用完立刻释放，避免借用重叠
drop(sd);
let mut g = self.grad.borrow_mut();   // 写：反向累加梯度
</code></pre>
<p><strong>注意代码里几乎每个算子的前向都有 <code>drop(sd); drop(od);</code></strong>——因为之后构造结果节点要 <code>borrow_mut</code> 自己的东西，如果 <code>data</code> 的只读借用还挂着，同一 <code>RefCell</code> 上&quot;只读借用未释放就再取可变借用&quot;会在运行时 panic。这是 <code>RefCell</code> 使用者的纪律。</p>
<h3>4.3 为什么 data 和 grad 分开两个 Rc</h3>
<p>细看：<code>data</code> 和 <code>grad</code> 是<strong>两个独立</strong>的 <code>Rc&lt;RefCell&lt;Vec&lt;f32&gt;&gt;&gt;</code>。除了&quot;语义上一个是数值、一个是梯度&quot;之外，还有两个实际好处：</p>
<ol>
<li><strong>可以同时读 data、写 grad</strong>：反向闭包里常见 <code>let sd_b = sd.borrow(); let mut sgm = sg.borrow_mut();</code>——两者不是同一个 <code>RefCell</code>，互不冲突。</li>
<li><strong>data 的指针充当计算图节点 ID</strong>：<code>backward</code> 里用 <code>Rc::as_ptr(&amp;t.data) as usize</code> 唯一标识一个节点（见第 6 节），而 <code>grad</code> 的指针用来判断&quot;两个父节点是不是同一个张量&quot;（<code>Rc::ptr_eq(&amp;sg, &amp;og)</code>，处理 <code>x*x</code> 自乘的梯度合并）。</li>
</ol>
<h3>4.4 为什么不是 Arc<!-- raw HTML omitted --></h3>
<p><code>Rc</code> 是单线程引用计数（快，无锁），<code>Arc&lt;Mutex&gt;</code> 是多线程方案。我们的训练是单线程的，用 <code>Rc</code> + <code>RefCell</code> 更轻、更符合&quot;从零实现&quot;的教学目标。等将来要并行训练，再换成 <code>Arc&lt;Mutex&gt;</code> 或 <code>Arc&lt;RwLock&gt;</code> 不迟。</p>
<h2>5. parents: Rc&lt;Vec<!-- raw HTML omitted -->&gt; —— 为什么不能直接用 Vec<!-- raw HTML omitted --></h2>
<p>这是全项目最重要的一个设计决策。看结构体注释（源码原文）：</p>
<pre><code class="language-rust">/// 父节点列表。
/// 注意：用 `Rc&lt;Vec&lt;_&gt;&gt;` 而不是 `Vec&lt;Tensor&amp;gt;`——
/// 若直接存 Vec，`derive(Clone)` 会递归深拷贝整棵祖先计算图，
/// 深层图上每次建节点都是 O(图深) 的灾难。用 Rc 共享后克隆是 O(1)。
parents: Rc&lt;Vec&lt;Tensor&gt;&gt;,
</code></pre>
<h3>5.1 深拷贝灾难是怎么发生的</h3>
<p>每个运算在构造结果时都要记录父节点：</p>
<pre><code class="language-rust">result.parents = Rc::new(vec![self.clone(), other.clone()]);   // 每个算子都这么干
</code></pre>
<p>问题出在 <code>self.clone()</code>。如果 <code>parents</code> 的类型是 <code>Vec&lt;Tensor&gt;</code>，那么 <code>clone</code> 一个 <code>Tensor</code> 时：</p>
<ul>
<li><code>data</code>：<code>Rc</code> 共享 → O(1)</li>
<li><code>shape</code>：<code>Vec&lt;usize&gt;</code> → O(维度数)</li>
<li><code>grad</code>：<code>Rc</code> 共享 → O(1)</li>
<li><strong><code>parents</code>：<code>Vec&lt;Tensor&gt;</code> → 递归克隆所有父节点！</strong></li>
</ul>
<p>而父节点自己又有 <code>parents</code>……于是 <code>clone</code> 会<strong>递归复制整棵祖先子树</strong>。设计算图深度为 L：</p>
<table>
<thead>
<tr>
<th>方案</th>
<th>建一个新节点的开销</th>
<th>一次前向（L 个节点）</th>
<th>N 步训练</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>parents: Vec&lt;Tensor&gt;</code></td>
<td>O(L)（递归深拷贝）</td>
<td>O(L²)</td>
<td><strong>O(N·L²)</strong></td>
</tr>
<tr>
<td><code>parents: Rc&lt;Vec&lt;Tensor&gt;&gt;</code></td>
<td>O(1)（Rc 引用计数 +1）</td>
<td>O(L)</td>
<td>O(N·L)</td>
</tr>
</tbody>
</table>
<p>线性回归的计算图只有几层，L 很小，第 2 课那样写还能跑；但到了 GPT（几十层 Transformer），每步前向要建几百上千个节点，<code>O(L²)</code> 的复制会直接拖垮训练——这就是&quot;性能灾难&quot;。</p>
<h3>5.2 第 2 课的旧代码 vs 现在的写法</h3>
<p>第 2 课文档里的版本（能跑，但有问题）：</p>
<pre><code class="language-rust">// ❌ 旧版：parents 直接存 Vec&lt;Tensor&gt;
pub struct Tensor {
    data: Rc&lt;RefCell&lt;Vec&lt;f32&gt;&gt;&gt;,
    shape: Vec&lt;usize&gt;,
    grad: Rc&lt;RefCell&lt;Vec&lt;f32&gt;&gt;&gt;,
    requires_grad: bool,
    parents: Vec&lt;Tensor&gt;,          // clone 时递归复制祖先图！
    backward: Option&lt;BackwardFn&gt;,
}
</code></pre>
<p>重构后的版本：</p>
<pre><code class="language-rust">// ✅ 现在的版本：parents 用 Rc 共享
pub struct Tensor {
    // ……
    parents: Rc&lt;Vec&lt;Tensor&gt;&gt;,      // clone 时只复制一个 Rc 句柄
    backward: Option&lt;BackwardFn&gt;,
}
</code></pre>
<p>对比要点：</p>
<ul>
<li><strong>旧版</strong>：<code>parents: Vec&lt;Tensor&gt;</code> 里存的是&quot;真实拥有的子图副本&quot;，克隆 = 深拷贝，图越深越慢。</li>
<li><strong>新版</strong>：<code>parents: Rc&lt;Vec&lt;Tensor&gt;&gt;</code> 里存的是&quot;共享的子图句柄&quot;，克隆 = 引用计数 +1，O(1)。<strong>所有节点的 <code>parents</code> 共享同一棵祖先图</strong>，图在内存里只有一份。</li>
</ul>
<p>这也解释了为什么 <code>backward</code> 里能放心地 <code>for p in t.parents.iter()</code> 递归——遍历的是共享图，不会触发任何复制。</p>
<h2>6. backward 的 DFS 拓扑排序</h2>
<h3>6.1 为什么需要拓扑排序</h3>
<p>反向传播要求梯度<strong>从输出端向输入端逐层传递</strong>：<code>z = (x·y) + w</code> 里，必须先执行 <code>z</code> 的 backward（把梯度分给 <code>t</code>、<code>w</code>），<code>t</code> 的梯度就绪后才能执行 <code>t</code> 的 backward（再分给 <code>x</code>、<code>y</code>）。执行顺序由计算图的依赖关系决定，而 DFS 后序 + 逆序执行恰好给出这个顺序。</p>
<h3>6.2 代码：迭代式 DFS + HashSet 去重</h3>
<blockquote>
<p>注：早期版本使用递归 DFS，后改为<strong>迭代式栈 DFS</strong>（三色标记法），
因为深层计算图（4 层 Transformer + batch=16）的递归深度会超过 Windows 默认 1MB 栈。
见 <code>src/autograd.rs</code>。</p>
</blockquote>
<pre><code class="language-rust">pub fn backward(&amp;self) {
    assert_eq!(self.rank(), 0, &quot;backward() 只支持标量（0 维）输出，当前形状 {:?}&quot;, self.shape);
    {
        let mut g = self.grad.borrow_mut();
        g[0] = 1.0;                     // ① loss 的梯度 = 1（∂loss/∂loss = 1）
    }

    // 迭代式 DFS 拓扑排序（避免递归栈溢出）
    // 用显式栈模拟递归：每个元素 (node, child_index) 表示&quot;该节点的第 child_index 个子节点待访问&quot;
    let mut order: Vec&lt;Tensor&gt; = Vec::new();
    let mut visited: HashSet&lt;usize&gt; = HashSet::new();
    let mut stack: Vec&lt;(Tensor, usize)&gt; = Vec::new();

    let key = Rc::as_ptr(&amp;self.grad) as usize;   // 用 grad 的指针作节点唯一 ID
    if visited.insert(key) {
        stack.push((self.clone(), 0));
    }

    // ② 迭代式后序 DFS：先展开所有子节点，再入队
    while let Some((node, idx)) = stack.last_mut() {
        if *idx &lt; node.parents.len() {
            let child = node.parents[*idx].clone();
            *idx += 1;
            let child_key = Rc::as_ptr(&amp;child.grad) as usize;
            if visited.insert(child_key) {
                stack.push((child, 0));           // ③ 先递归父节点
            }
        } else {
            let (node, _) = stack.pop().unwrap();
            order.push(node);                     // ④ 后序入队：子在前、父在后
        }
    }

    for t in order.iter().rev() {                 // ⑤ 逆序遍历：从根走向叶
        if let Some(b) = &amp;t.backward {
            b();                                  // 执行各节点的反向闭包
        }
    }
}
</code></pre>
<p>三个关键设计：</p>
<table>
<thead>
<tr>
<th>设计</th>
<th>原因</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>Rc::as_ptr(&amp;t.grad)</code> 做节点 ID</td>
<td><code>Tensor</code> 没有自增 id；用梯度 <code>grad</code> 的堆地址当身份证最省事，同一张量的所有句柄地址相同</td>
</tr>
<tr>
<td><code>HashSet</code> 去重</td>
<td>一个节点被多个运算共享（如 <code>x</code> 被用两次），DFS 可能从两条路径走到它。<strong>梯度只需也只需执行一次它的 backward</strong>，否则会重复累加</td>
</tr>
<tr>
<td>后序 DFS + 逆序执行</td>
<td>后序 DFS 得到&quot;子在前、父在后&quot;（叶 → 根）；逆序执行变成&quot;根 → 叶&quot;，正是梯度从输出流向输入的次序</td>
</tr>
</tbody>
</table>
<h3>6.3 走一遍例子：z = (x·y) + w</h3>
<pre><code>        x ──┐
             mul ──► t ──┐
        y ──┘            add ──► z
        w ───────────────┘
</code></pre>
<p>DFS 后序结果（子在前、父在后）：<code>order = [x, y, t, w, z]</code>，逆序执行（<code>z → w → t → y → x</code>）：</p>
<table>
<thead>
<tr>
<th>顺序</th>
<th>节点</th>
<th>执行的 backward</th>
<th>效果</th>
</tr>
</thead>
<tbody>
<tr>
<td>1</td>
<td>z</td>
<td>add 的闭包（收到的 g = 1）</td>
<td>t += 1，w += 1</td>
</tr>
<tr>
<td>2</td>
<td>w</td>
<td>（叶子，无 backward）</td>
<td>—</td>
</tr>
<tr>
<td>3</td>
<td>t = x·y</td>
<td>mul 的闭包（收到的 g = 1）</td>
<td>x += 1·y = 3，y += 1·x = 2</td>
</tr>
<tr>
<td>4</td>
<td>y</td>
<td>（叶子，无 backward）</td>
<td>—</td>
</tr>
<tr>
<td>5</td>
<td>x</td>
<td>（叶子，无 backward）</td>
<td>—</td>
</tr>
</tbody>
</table>
<p>和 <code>test_chain_rule</code> 验证的结果一致：<code>∂z/∂x = y = 3</code>、<code>∂z/∂y = x = 2</code>、<code>∂z/∂w = 1</code> ✓</p>
<h2>7. module.rs：Module trait 与 zero_grad_all</h2>
<h3>7.1 trait 定义</h3>
<p>深度学习里一切&quot;可训练的结构&quot;（Linear、LayerNorm、Transformer Block、整个 GPT……）都需要回答一个问题：<strong>你有多少参数？</strong> <code>module.rs</code> 就把这个回答统一成接口：</p>
<pre><code class="language-rust">// src/module.rs（全文）
use crate::tensor::Tensor;

/// 模块接口：任何可训练结构都实现它
pub trait Module {
    /// 返回模块的所有参数（含嵌套子模块）
    fn parameters(&amp;self) -&gt; Vec&lt;Tensor&gt;;
}

/// 便捷方法：清零所有参数的梯度
pub fn zero_grad_all(module: &amp;dyn Module) {
    for p in module.parameters() {
        p.zero_grad();
    }
}
</code></pre>
<p><code>Module</code> 目前只要求实现一个方法 <code>parameters()</code>。别小看这一个方法——<strong>它是优化器、训练循环、梯度清零与模型结构之间的唯一桥梁</strong>。</p>
<h3>7.2 三个实现者：叶子模块</h3>
<p><code>layers.rs</code> 里三个最简单的实现：</p>
<pre><code class="language-rust">// Linear：线性层 y = xW + b
impl Module for Linear {
    fn parameters(&amp;self) -&gt; Vec&lt;Tensor&gt; {
        vec![self.weight.clone(), self.bias.clone()]
    }
}

// LayerNorm：层归一化
impl Module for LayerNorm {
    fn parameters(&amp;self) -&gt; Vec&lt;Tensor&gt; {
        vec![self.gamma.clone(), self.beta.clone()]
    }
}

// Embedding：词嵌入查表
impl Module for Embedding {
    fn parameters(&amp;self) -&gt; Vec&lt;Tensor&gt; {
        vec![self.table.clone()]
    }
}
</code></pre>
<p>注意 <code>clone()</code> 在这里是安全的：因为 <code>Tensor</code> 内部全是 <code>Rc</code>（第 4、5 节），克隆只复制句柄，<strong>不复制数据</strong>——返回 <code>Vec&lt;Tensor&gt;</code> 给外部持有也不会有性能问题。</p>
<h3>7.3 组合模式：大模块递归收集小模块的参数</h3>
<p><code>model.rs</code> 里的 <code>GPT</code> 由若干 <code>TransformerBlock</code> 组成，<code>TransformerBlock</code> 又由 <code>LayerNorm</code>、<code>MultiHeadAttention</code>、<code>Linear</code> 组成。每个组合模块实现 <code>Module</code> 时，把<strong>子模块的参数拼接起来</strong>：</p>
<pre><code class="language-rust">// model.rs：GPT
impl Module for GPT {
    fn parameters(&amp;self) -&gt; Vec&lt;Tensor&gt; {
        let mut ps = self.tok_emb.parameters();      // 词嵌入
        for block in &amp;self.blocks {
            ps.extend(block.parameters());           // 每层 Transformer Block
        }
        ps.extend(self.ln_f.parameters());           // 最终 LayerNorm
        ps.extend(self.lm_head.parameters());        // 输出头
        ps
    }
}
</code></pre>
<p>因为 <code>parameters()</code> 是 trait 方法，<code>block.parameters()</code> 会自动递归调用 <code>TransformerBlock</code> 的实现，后者又调用 <code>MultiHeadAttention</code> 的实现……最终<strong>一棵参数树被拍平成一张参数表</strong>。</p>
<pre><code>GPT.parameters()
├── tok_emb.parameters()        → [table]
├── block[0].parameters()       → ln1[γ,β] + attn[c_q,c_k,c_v,c_proj] + ln2[γ,β] + mlp[W1,b1,W2,b2]
├── block[1].parameters()       → …
└── ln_f.parameters()           → [γ, β]
    （lm_head 通过权重绑定复用 tok_emb.table.T，不单独存储）
</code></pre>
<h3>7.4 与优化器的配合（main.rs 的 demo_xor）</h3>
<p><code>main.rs</code> 里第 7 课的 XOR 演示展示了完整闭环：</p>
<pre><code class="language-rust">// ① 收集所有参数，一次性交给优化器
let params: Vec&lt;Tensor&gt; = {
    let mut ps = fc1.parameters();
    ps.extend(fc2.parameters());
    ps
};
let opt = SGD::new(0.5, params);

// ② 训练循环：前向 → 反向 → 更新 → 清梯度
for step in 0..1000 {
    let h = relu(&amp;fc1.forward(&amp;x_data));
    let logits = fc2.forward(&amp;h);
    let loss = cross_entropy_loss(&amp;logits, &amp;y_targets);
    loss.backward();     // 自动算所有参数的梯度
    opt.step();          // SGD 遍历 params 更新：θ -= lr·g
    opt.zero_grad();     // 清零，防累加
}
</code></pre>
<p>分工很清晰：</p>
<table>
<thead>
<tr>
<th>角色</th>
<th>谁</th>
<th>干什么</th>
</tr>
</thead>
<tbody>
<tr>
<td>参数清单</td>
<td><code>Module::parameters()</code></td>
<td>告诉外界&quot;我的参数在哪&quot;</td>
</tr>
<tr>
<td>梯度计算</td>
<td><code>Tensor::backward()</code></td>
<td>自动微分填满每个参数的 <code>grad</code></td>
</tr>
<tr>
<td>参数更新</td>
<td><code>optim.rs</code> 的 <code>SGD</code> / <code>AdamW</code></td>
<td>按梯度更新 <code>data</code></td>
</tr>
<tr>
<td>梯度清零</td>
<td><code>Tensor::zero_grad()</code> / <code>zero_grad_all()</code></td>
<td>重置 <code>grad</code> 为 0</td>
</tr>
</tbody>
</table>
<p><code>zero_grad_all</code> 就是&quot;把清梯度也统一成接口&quot;：传入任意 <code>&amp;dyn Module</code>，它调用 <code>parameters()</code> 拿到所有参数逐个清零。实际训练中优化器（如上面 <code>SGD::zero_grad</code>）自带同样的清零逻辑，而 <code>zero_grad_all</code> 的价值在于：<strong>不经过优化器也能直接清任意模块的梯度</strong>（比如想&quot;只求梯度、手动更新&quot;时）。任何新模块只要实现 <code>Module</code>，就能复用这套参数管理设施。</p>
<h2>8. 运行与测试</h2>
<pre><code class="language-bash">cargo test     # 全部测试通过
cargo run      # 依次演示：XOR MLP → BPE 分词 → 小 GPT 训练与生成
</code></pre>
<p>至此，项目的&quot;地基&quot;（阶段一）全部完成。从下一课开始，所有新代码（层、损失、优化器、Transformer）都将站在 <code>tensor.rs</code> + <code>module.rs</code> 这两块基石之上。</p>
<h2>9. 动手练习</h2>
<ol>
<li>给 <code>Linear</code> 之外再写一个你自己的模块（比如 <code>BiasOnly { bias: Tensor }</code>），实现 <code>Module</code> trait，并用 <code>zero_grad_all</code> 清一次梯度，确认能编译通过。</li>
<li>思考：<code>parameters()</code> 返回 <code>Vec&lt;Tensor&gt;</code> 时我们敢 <code>clone</code>，靠的是什么？如果 <code>data</code> 不是 <code>Rc</code> 而是裸 <code>Vec&lt;f32&gt;</code>，<code>clone</code> 一次要花多少钱？</li>
<li>画一画 <code>z = (x·y) + w</code> 的计算图，标出 DFS 后序的 <code>order</code> 数组内容，再写出逆序执行的顺序（参考答案在第 6.3 节）。</li>
<li>挑战题：把 <code>parents</code> 改成 <code>Vec&lt;Tensor&gt;</code>，跑 <code>cargo test</code>，确认功能仍然正确；再构造一个 100 层深的计算图，对比两种写法每次前向/反向的耗时，体会&quot;O(1) vs O(L)&quot;的差别。</li>
<li>阅读 <code>model.rs</code> 里 <code>TransformerBlock</code> 的 <code>impl Module</code>，数一数一个 Block 最终贡献多少个参数张量。</li>
</ol>
<h2>10. 本课总结</h2>
<ul>
<li>项目按职责拆成 12 个模块，依赖呈金字塔形，<code>tensor.rs</code> 是最底层地基</li>
<li><code>Rc&lt;RefCell&lt;Vec&lt;f32&gt;&gt;&gt;</code> = 共享（Rc）+ 可变（RefCell），是单线程下&quot;共享可变数据&quot;的标准方案</li>
<li><code>parents: Rc&lt;Vec&lt;Tensor&gt;&gt;</code> 让克隆从 O(图深) 降到 O(1)，避免深拷贝灾难</li>
<li><code>backward</code> 用 <code>Rc::as_ptr</code> 做节点 ID、DFS 后序 + 逆序执行，保证梯度按拓扑序从 loss 流回叶子</li>
<li><code>Module</code> trait 统一&quot;收集参数&quot;，配合 <code>zero_grad_all</code>、优化器、训练循环形成完整闭环</li>
<li>下一课：在 Module 之上实现第一个神经网络层 Linear 与激活函数，搭建第一个真正的网络</li>
</ul>
]]></content>
  <category term="Rust"/>
  <category term="LLM"/>
  <category term="架构"/>
</entry>
<entry>
  <title>第 3 课：张量运算扩展 —— 广播、归约、softmax、批量矩阵乘法</title>
  <link href="https://ganto.me/post/rust-llm-guide-03"/>
  <id>https://ganto.me/post/rust-llm-guide-03</id>
  <updated>2026-09-04T00:00:00+08:00</updated>
  <published>2026-09-04T00:00:00+08:00</published>
  <author><name>干徒</name></author>
  <summary><![CDATA[扩展张量运算能力：广播规则、归约、softmax 与批量矩阵乘法。]]></summary>
  <content type="html"><![CDATA[<h1>第 3 课：张量运算扩展 —— 广播、归约、softmax、批量矩阵乘法</h1>
<blockquote>
<p>代码位置：<a href="src/tensor.rs">src/tensor.rs</a>（本章新增：broadcast_shapes / broadcast_map / binary / sum / sum_last_dim / softmax_last_dim / permute / gather_rows / 3D matmul）
演示入口：<a href="src/main.rs">src/main.rs</a></p>
</blockquote>
<h2>1. 本课要搞懂的问题</h2>
<ol>
<li>两个形状不同的张量怎么直接相加？&quot;广播&quot;到底是什么规则？</li>
<li>&quot;求和&quot;这个简单的操作，反向传播时梯度应该怎么分？</li>
<li>softmax 为什么要&quot;先减最大值&quot;？不减会出什么问题？</li>
<li>形状重排（permute）、按行取数（gather_rows）的梯度怎么传回去？</li>
<li>一批矩阵同时做乘法（3D 批量 matmul）是怎么实现的？</li>
</ol>
<h2>2. 广播 Broadcasting：形状不同也能做运算</h2>
<h3>2.1 为什么需要广播</h3>
<p>上一课的 <code>add</code> 要求两个张量<strong>形状完全相同</strong>。但神经网络里处处是&quot;小形状 + 大形状&quot;的运算：</p>
<ul>
<li>给一批数据加偏置：<code>[B, D] + [D]</code></li>
<li>给一批样本各自乘一个标量：<code>[B, D] * []</code></li>
<li>线性层 <code>y = xW + b</code>：<code>[B, D] @ [D, out] + [out]</code></li>
</ul>
<p>如果每次都要求形状一致，我们就得手动把 <code>[D]</code> 复制成 <code>[B, D]</code> 再算。<strong>广播（broadcasting）</strong> 就是自动完成这件事：只要形状&quot;兼容&quot;，Rust 帮你把小张量&quot;展开&quot;到大张量的形状再逐元素运算。</p>
<h3>2.2 numpy 广播的三条规则</h3>
<p>我们的实现完全遵循 numpy 的广播规则：</p>
<table>
<thead>
<tr>
<th>规则</th>
<th>说明</th>
<th>例子</th>
</tr>
</thead>
<tbody>
<tr>
<td>① 右对齐</td>
<td>从<strong>最后一个维度</strong>开始，逐对比较两个形状的维度</td>
<td><code>[2, 3]</code> 与 <code>[3]</code> → 对齐成 <code>[2,3]</code> vs <code>[1,3]</code></td>
</tr>
<tr>
<td>② 相等或为 1</td>
<td>每一对维度要么相等，要么<strong>其中一个为 1</strong>（1 可以扩展成另一个）</td>
<td><code>2</code> vs <code>1</code> → 结果 <code>2</code></td>
</tr>
<tr>
<td>③ 都不满足则失败</td>
<td>两个都大于 1 且不相等 → 无法广播，报错</td>
<td><code>[2, 3]</code> 与 <code>[2, 4]</code> → <code>None</code></td>
</tr>
</tbody>
</table>
<p>几个直观例子（<code>1</code> 表示&quot;这一维可以复制&quot;）：</p>
<table>
<thead>
<tr>
<th>a 形状</th>
<th>b 形状</th>
<th>逐对比较</th>
<th>广播结果</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>[2, 3]</code></td>
<td><code>[3]</code></td>
<td><code>2 vs 1</code>（b 补 1），<code>3 vs 3</code></td>
<td><code>[2, 3]</code></td>
</tr>
<tr>
<td><code>[2, 3]</code></td>
<td><code>[2, 1]</code></td>
<td><code>2 vs 2</code>，<code>3 vs 1</code></td>
<td><code>[2, 3]</code></td>
</tr>
<tr>
<td><code>[3]</code></td>
<td><code>[1]</code></td>
<td><code>3 vs 1</code></td>
<td><code>[3]</code></td>
</tr>
<tr>
<td><code>[2, 3]</code></td>
<td><code>[2, 4]</code></td>
<td><code>3 vs 4</code> 都不为 1</td>
<td>❌ 失败</td>
</tr>
</tbody>
</table>
<h3>2.3 <code>broadcast_shapes</code>：把规则变成代码</h3>
<pre><code class="language-rust">/// 计算两个形状广播后的形状（numpy 广播规则，从右向左对齐）：
/// - 维度相等或其中一个为 1 即可广播
fn broadcast_shapes(a: &amp;[usize], b: &amp;[usize]) -&gt; Option&lt;Vec&lt;usize&gt;&gt; {
    let n = a.len().max(b.len());
    let mut out = vec![1usize; n];
    for i in 0..n {
        // 从右往左对齐：i 从 0 数到 n-1 时，i+n-1 正好指向&quot;倒数第 n-i 个维度&quot;
        // 如果 i + len 超出本形状的维度范围，就当作&quot;前面补了 1&quot;
        let da = if i + a.len() &gt;= n { a[i + a.len() - n] } else { 1 };
        let db = if i + b.len() &gt;= n { b[i + b.len() - n] } else { 1 };
        if da == db {
            out[i] = da;
        } else if da == 1 {
            out[i] = db;
        } else if db == 1 {
            out[i] = da;
        } else {
            return None;   // 规则 ③：无法广播
        }
    }
    Some(out)
}
</code></pre>
<p>要点：</p>
<ul>
<li><code>i + a.len() &gt;= n</code> 判断&quot;对齐后当前位是否越过了 a 的最左端&quot;，越过了说明 a 在这一维没有对应维度，视作补 <code>1</code>（规则 ① 的&quot;右对齐&quot;实现）。</li>
<li>三条分支对应规则 ②：相等取它、一方为 1 取另一方、都不满足返回 <code>None</code>。</li>
<li>返回 <code>Option</code> 而不是直接 panic，把&quot;能不能广播&quot;的判定权交给调用方。</li>
</ul>
<p>验证一下 <code>[2,3]</code> 与 <code>[3]</code>（<code>n = 2</code>）：</p>
<table>
<thead>
<tr>
<th>i</th>
<th>da</th>
<th>db</th>
<th>判定</th>
<th>out</th>
</tr>
</thead>
<tbody>
<tr>
<td>0</td>
<td><code>a[0] = 2</code>（<code>0+2&gt;=2</code>）</td>
<td>b 越界 → 1</td>
<td><code>db == 1</code></td>
<td>2</td>
</tr>
<tr>
<td>1</td>
<td><code>a[1] = 3</code></td>
<td><code>b[0] = 3</code>（<code>1+1&gt;=2</code>）</td>
<td>相等</td>
<td>3</td>
</tr>
</tbody>
</table>
<p>结果 <code>[2, 3]</code> ✓</p>
<h3>2.4 <code>broadcast_map</code>：数据怎么搬</h3>
<p>光知道&quot;结果形状&quot;还不够——结果每个位置的值要<strong>从源数据的哪里取</strong>。<code>broadcast_map</code> 就是干这个的：给定源形状 <code>src</code> 和目标形状 <code>target</code>，返回一张映射表 <code>map</code>，<code>map[t]</code> = 目标第 <code>t</code> 个元素对应的源展平下标。</p>
<pre><code class="language-rust">/// 计算源形状 src 广播到目标形状 target 时，每个目标元素对应的源展平下标。
/// src 的维度必须 &lt;= target 且右对齐；src 中大小为 1 的维度索引固定为 0。
fn broadcast_map(target: &amp;[usize], src: &amp;[usize]) -&gt; Vec&lt;usize&gt; {
    let offset = target.len() - src.len();       // 右对齐：源前面补 offset 个&quot;伪维度&quot;
    let total: usize = target.iter().product();
    let mut map = vec![0usize; total];
    for flat in 0..total {
        // 第一步：把目标的一维下标反解成多维下标（行优先）
        let mut r = flat;
        let mut t_idx = vec![0usize; target.len()];
        for d in (0..target.len()).rev() {
            t_idx[d] = r % target[d];
            r /= target[d];
        }
        // 第二步：多维下标映射回源的多维下标，再展平成源的一维下标
        let mut s_flat = 0usize;
        for d in 0..src.len() {
            let td = t_idx[d + offset];          // 源的第 d 维对应目标的第 d+offset 维
            let sd = if src[d] == 1 { 0 } else { td };  // 源大小为 1 的维度，索引固定为 0
            s_flat = s_flat * src[d] + sd;       // 行优先展平
        }
        map[flat] = s_flat;
    }
    map
}
</code></pre>
<p><strong>反解多维下标</strong>：行优先存储下，一维下标 <code>flat</code> 的分解是&quot;从最后一个维度开始取余、整除&quot;：</p>
<pre><code>t_idx[d] = r % target[d];  r /= target[d];    // d 从大到小
</code></pre>
<p>比如目标形状 <code>[2, 3]</code>，<code>flat = 4</code> → <code>t_idx[1] = 4%3 = 1</code>，<code>r = 1</code> → <code>t_idx[0] = 1</code>，即位置 <code>(1, 1)</code>。</p>
<p><strong>映射到源</strong>：<code>src</code> 的第 <code>d</code> 维对应目标第 <code>d + offset</code> 维；若源该维大小为 1，则无论目标索引多大，都取下标 0（这就是&quot;复制&quot;）。</p>
<p>例：<code>src = [3]</code> 广播到 <code>target = [2, 3]</code>（相当于每行复制同一个向量）：</p>
<table>
<thead>
<tr>
<th>目标位置 (i, j)</th>
<th>flat</th>
<th>映射到源下标</th>
<th>说明</th>
</tr>
</thead>
<tbody>
<tr>
<td>(0, 0)</td>
<td>0</td>
<td>0</td>
<td>取 b[0]</td>
</tr>
<tr>
<td>(0, 1)</td>
<td>1</td>
<td>1</td>
<td>取 b[1]</td>
</tr>
<tr>
<td>(0, 2)</td>
<td>2</td>
<td>2</td>
<td>取 b[2]</td>
</tr>
<tr>
<td>(1, 0)</td>
<td>3</td>
<td>0</td>
<td>第二行<strong>还是</strong>取 b[0]</td>
</tr>
<tr>
<td>(1, 1)</td>
<td>4</td>
<td>1</td>
<td>取 b[1]</td>
</tr>
<tr>
<td>(1, 2)</td>
<td>5</td>
<td>2</td>
<td>取 b[2]</td>
</tr>
</tbody>
</table>
<h3>2.5 <code>binary</code>：把广播装进&quot;逐元素运算&quot;</h3>
<p><code>broadcast_plan</code> 先判断两个张量是否同形状：同形状就直接逐位对齐（无需映射），不同形状才计算两个 <code>broadcast_map</code>：</p>
<pre><code class="language-rust">fn broadcast_plan(&amp;self, other: &amp;Tensor) -&gt; (Vec&lt;usize&gt;, Option&lt;Vec&lt;usize&gt;&gt;, Option&lt;Vec&lt;usize&gt;&gt;) {
    if self.shape == other.shape {
        (self.shape.clone(), None, None)          // 同形状：没有 map
    } else {
        let target = broadcast_shapes(&amp;self.shape, &amp;other.shape)
            .unwrap_or_else(|| panic!(&quot;形状无法广播：{:?} vs {:?}&quot;, self.shape, other.shape));
        let map_a = if self.shape == target { None } else { Some(broadcast_map(&amp;target, &amp;self.shape)) };
        let map_b = if other.shape == target { None } else { Some(broadcast_map(&amp;target, &amp;other.shape)) };
        (target, map_a, map_b)
    }
}
</code></pre>
<p><code>add / sub / mul / div</code> 四个运算全部复用同一个通用函数 <code>binary</code>：</p>
<pre><code class="language-rust">fn binary(
    &amp;self,
    other: &amp;Tensor,
    fwd: impl Fn(f32, f32) -&gt; f32 + 'static,          // 前向：c = f(a, b)
    back: impl Fn(f32, f32) -&gt; (f32, f32) + 'static,  // 反向：返回 (∂c/∂a, ∂c/∂b)
) -&gt; Tensor {
    let (target_shape, map_a, map_b) = self.broadcast_plan(other);
    let sa = self.data.borrow();
    let sb = other.data.borrow();
    let total: usize = target_shape.iter().product();
    let mut out_data = vec![0.0f32; total];
    for t in 0..total {
        let ia = match &amp;map_a { Some(m) =&gt; m[t], None =&gt; t };   // 没有 map 就是一一对应
        let ib = match &amp;map_b { Some(m) =&gt; m[t], None =&gt; t };
        out_data[t] = fwd(sa[ia], sb[ib]);                     // 按映射取数做运算
    }
    drop(sa);
    drop(sb);
    // …… 构造结果节点，注册反向闭包（见下）……
}
</code></pre>
<p><strong>反向传播</strong>：广播的梯度遵循&quot;谁被复制了，梯度就累加回谁&quot;。因为一个源元素可能对应多个输出位置，<code>∂loss/∂x = Σ_t (∂loss/∂out_t)·(∂out_t/∂x)</code>，所以必须<strong>累加</strong>（<code>+=</code>）：</p>
<pre><code class="language-rust">result.backward = Some(Rc::new(move || {
    let g = rg.borrow();
    let (ga, gb) = if same_shape &amp;&amp; Rc::ptr_eq(&amp;sg, &amp;og) {
        // 同一张量参与运算（如 x*x）：两条路径的梯度合并（第 2 课讲过）
        // ……（合并累加逻辑，返回 (true, true)）
        (true, true)
    } else {
        let mut sgm = sg.borrow_mut();
        let mut ogm = og.borrow_mut();
        for t in 0..g.len() {
            let ia = match &amp;map_a_c { Some(m) =&gt; m[t], None =&gt; t };
            let ib = match &amp;map_b_c { Some(m) =&gt; m[t], None =&gt; t };
            let (da, db) = back(sd_b[ia], od_b[ib]);
            sgm[ia] += g[t] * da;      // 梯度累加到&quot;源位置&quot;，而不是输出位置
            ogm[ib] += g[t] * db;
        }
        (false, false)
    };
    let _ = (ga, gb);
}));
</code></pre>
<p>用测试验证（<code>test_broadcast_add</code>）：</p>
<pre><code class="language-rust">// [2,3] + [3] 广播：b 的每个元素被两行&quot;共享&quot;
let a = Tensor::param(vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0], vec![2, 3]);
let b = Tensor::param(vec![10.0, 20.0, 30.0], vec![3]);
let loss = a.add(&amp;b).sum();
loss.backward();
// b 的每个元素被广播两次，梯度应为 2
assert_eq!(b.grad(), vec![2.0, 2.0, 2.0]);
assert_eq!(a.grad(), vec![1.0; 6]);
</code></pre>
<p><code>b[0]</code> 参与了输出的两个位置 <code>(0,0)</code> 和 <code>(1,0)</code>，两个位置的梯度各 1，累加起来就是 2。✓</p>
<h2>3. 归约：sum / sum_last_dim / mean</h2>
<p>&quot;归约&quot;（reduction）就是把一堆数<strong>合并成一个数</strong>。神经网络里损失函数 <code>loss</code> 几乎总是某种归约的结果（<code>loss.backward()</code> 要求标量，所以归约是自动微分的必经之路）。</p>
<h3>3.1 <code>sum</code>：全部求和成标量</h3>
<pre><code class="language-rust">/// 求和成标量，梯度均匀传给每个元素
pub fn sum(&amp;self) -&gt; Tensor {
    let total = self.data.borrow().iter().sum();
    let mut result = Tensor::new(vec![total], vec![], self.requires_grad);
    if self.requires_grad {
        // ……
        result.backward = Some(Rc::new(move || {
            let g = rg.borrow()[0];
            let mut sgm = sg.borrow_mut();
            for v in sgm.iter_mut() {
                *v += g;          // 每个元素的梯度都是 g（因为 ∂sum/∂x_i = 1）
            }
        }));
    }
    result
}
</code></pre>
<p>反向逻辑一句话：<strong>求和把每个元素的梯度都变成同一个 g</strong>。因为 <code>sum(x) = x₀ + x₁ + …</code>，对每个 <code>x_i</code> 求导都是 1，所以 <code>∂loss/∂x_i = g·1</code>。</p>
<h3>3.2 <code>sum_last_dim</code>：沿最后一维求和，<strong>保持维度</strong></h3>
<p><code>scores [B, D]</code> 想按行求和得到 <code>[B, 1]</code>（注意最后一维保留为 1，而不是塌缩成 <code>[B]</code>），就用它：</p>
<pre><code class="language-rust">/// 沿最后一维求和，**保持维度**：[..., D] -&gt; [..., 1]
/// 反向：梯度广播回最后一维
pub fn sum_last_dim(&amp;self) -&gt; Tensor {
    assert!(self.rank() &gt;= 1, &quot;sum_last_dim 需要至少 1 维&quot;);
    let (pre, d) = (self.numel() / self.shape[self.rank() - 1], self.shape[self.rank() - 1]);
    // pre = 最后一维之前的所有元素数，d = 最后一维大小
    let sd = self.data.borrow();
    let mut out_data = vec![0.0f32; pre];
    for p in 0..pre {
        let mut s = 0.0;
        for j in 0..d {
            s += sd[p * d + j];   // 每 d 个连续元素求和
        }
        out_data[p] = s;
    }
    drop(sd);
    let mut new_shape = self.shape.clone();
    *new_shape.last_mut().unwrap() = 1;   // 最后一维改成 1，保持维度
    // ……
    result.backward = Some(Rc::new(move || {
        let g = rg.borrow();
        let mut sgm = sg.borrow_mut();
        for p in 0..pre {
            for j in 0..d {
                sgm[p * d + j] += g[p];   // 反向：把 g[p] 广播回这一行的 d 个位置
            }
        }
    }));
}
</code></pre>
<p>把 <code>[2, 3]</code> 变成 <code>[2, 1]</code>，前向和反向正好是&quot;一对&quot;：</p>
<pre><code>前向： [1 2 3]   sum -&gt; [6]
       [4 5 6]          [15]

反向： g = [2, 3]  broadcast -&gt; [2 2 2]
                               [3 3 3]
</code></pre>
<h3>3.3 mean：用 sum + mul_scalar 组合出来</h3>
<p>我们的库<strong>没有单独的 <code>mean</code> 算子</strong>——均值就是&quot;求和再除以元素数&quot;，用现成的算子组合即可。看 <code>loss.rs</code> 里 MSE 的实现：</p>
<pre><code class="language-rust">pub fn mse_loss(pred: &amp;Tensor, target: &amp;Tensor) -&gt; Tensor {
    pred.sub(target)
        .pow(2.0)
        .sum()                                  // Σ (pred - target)²
        .mul_scalar(1.0 / pred.numel() as f32)  // ÷ N，就是 mean
}
</code></pre>
<p>这体现了&quot;<strong>先保证运算集合最小、用组合满足需求</strong>&quot;的设计哲学：少一个算子 = 少一段前向 + 反向代码 = 少一类 bug。</p>
<p>三种归约对比：</p>
<table>
<thead>
<tr>
<th>运算</th>
<th>输入 → 输出</th>
<th>反向梯度</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>sum()</code></td>
<td><code>[D1, D2, …] → []</code> 标量</td>
<td>每个元素都 <code>+= g</code></td>
</tr>
<tr>
<td><code>sum_last_dim()</code></td>
<td><code>[…, D] → […, 1]</code> 保持维度</td>
<td>把 <code>g[p]</code> 广播回第 p 行的 d 个位置</td>
</tr>
<tr>
<td><code>mean</code>（组合实现）</td>
<td>同上</td>
<td>同 sum，再乘 <code>1/N</code></td>
</tr>
</tbody>
</table>
<h2>4. softmax_last_dim：数值稳定的 softmax</h2>
<h3>4.1 公式与直觉</h3>
<p>softmax 把一行任意实数分数变成<strong>概率分布</strong>（每个数在 0~1 之间，和为 1），是分类问题（以及 Transformer 注意力）的核心：</p>
<pre><code>softmax(x)_i = e^{x_i} / Σ_j e^{x_j}      （对每一行独立做）
</code></pre>
<table>
<thead>
<tr>
<th>x</th>
<th>e^x</th>
<th>归一化后</th>
</tr>
</thead>
<tbody>
<tr>
<td>1</td>
<td>2.718</td>
<td>0.0900</td>
</tr>
<tr>
<td>2</td>
<td>7.389</td>
<td>0.2447</td>
</tr>
<tr>
<td>3</td>
<td>20.086</td>
<td>0.6652</td>
</tr>
</tbody>
</table>
<p>这就是测试 <code>test_softmax</code> 验证的结果：<code>softmax([1,2,3]) = [0.0900, 0.2447, 0.6652]</code>。</p>
<h3>4.2 数值爆炸：不先减 max 会怎样</h3>
<p><code>e^x</code> 增长极快：<code>e^88.7</code> 就已经超过 f32 能表示的最大值（约 3.4×10³⁸），再大就是 <code>inf</code>。而神经网络的 logits 动辄上百，直接 <code>exp</code> 会溢出，除以 <code>inf</code> 得到 <code>NaN</code>，整个训练直接崩掉。</p>
<h3>4.3 &quot;减最大值&quot;技巧</h3>
<p>数学上 softmax 对&quot;整体平移&quot;不敏感，因为分子分母可以同乘一个常数：</p>
<pre><code>softmax(x)_i = e^{x_i} / Σ_j e^{x_j}
             = e^{-m}·e^{x_i} / (e^{-m}·Σ_j e^{x_j})
             = e^{x_i - m} / Σ_j e^{x_j - m}         其中 m = max_j x_j
</code></pre>
<p>取 <code>m = max(x)</code> 后，<code>x_i - m ≤ 0</code>，<code>e^{x_i-m} ∈ (0, 1]</code>，<strong>永远不会溢出</strong>。代码里就是三步：找每行 max → exp(x - max) → 除以行和：</p>
<pre><code class="language-rust">pub fn softmax_last_dim(&amp;self) -&gt; Tensor {
    assert!(self.rank() &gt;= 1, &quot;softmax_last_dim 需要至少 1 维&quot;);
    let (rows, d) = (self.numel() / self.shape[self.rank() - 1], self.shape[self.rank() - 1]);
    let sd = self.data.borrow();
    let mut out_data = vec![0.0f32; rows * d];
    // 先存 softmax 结果（反向需要）
    for r in 0..rows {
        let mut maxv = f32::NEG_INFINITY;
        for j in 0..d {
            maxv = maxv.max(sd[r * d + j]);            // ① 找每行最大值
        }
        let mut sum = 0.0;
        for j in 0..d {
            out_data[r * d + j] = (sd[r * d + j] - maxv).exp();  // ② 减 max 再 exp
            sum += out_data[r * d + j];
        }
        for j in 0..d {
            out_data[r * d + j] /= sum;                // ③ 归一化
        }
    }
    drop(sd);
    // ……（反向见下）……
}
</code></pre>
<blockquote>
<p>注：<code>f32::NEG_INFINITY</code> 作为&quot;比任何数都小&quot;的初始值，保证第一个元素就能更新 max。</p>
</blockquote>
<h3>4.4 softmax 的反向传播</h3>
<p>softmax 的输出互相耦合（每个 <code>s_i</code> 都依赖所有 <code>x_j</code>），梯度公式（<code>s</code> 是输出，<code>g</code> 是输出梯度）：</p>
<pre><code>∂loss/∂x_i = s_i · (g_i - Σ_j g_j·s_j)
</code></pre>
<table>
<thead>
<tr>
<th>记号</th>
<th>含义</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>s_i</code></td>
<td>softmax 输出的第 i 个分量</td>
</tr>
<tr>
<td><code>g_i</code></td>
<td>上一层传下来的梯度（对输出的梯度）</td>
</tr>
<tr>
<td><code>dot = Σ_j g_j·s_j</code></td>
<td>梯度与输出的点积（对每一行只算一次）</td>
</tr>
</tbody>
</table>
<p>代码和公式一一对应：</p>
<pre><code class="language-rust">result.backward = Some(Rc::new(move || {
    let g = rg.borrow();
    let mut sgm = sg.borrow_mut();
    for r in 0..rows {
        // dot = Σ_j g_j * s_j
        let mut dot = 0.0;
        for j in 0..d {
            dot += g[r * d + j] * out_data[r * d + j];
        }
        for i in 0..d {
            sgm[r * d + i] += out_data[r * d + i] * (g[r * d + i] - dot);  // s_i(g_i - dot)
        }
    }
}));
</code></pre>
<blockquote>
<p>推导速览（供参考）：<code>∂s_i/∂x_j = s_i(δ_ij - s_j)</code>（δ 是克罗内克记号），再用链式法则
<code>∂loss/∂x_i = Σ_j g_j·∂s_j/∂x_i = Σ_j g_j·s_j(δ_ji - s_i) = g_i·s_i - s_i·Σ_j g_j·s_j</code>。</p>
</blockquote>
<h2>5. permute：任意维重排</h2>
<h3>5.1 和 reshape 的区别</h3>
<ul>
<li><code>reshape</code>：<strong>只改解释方式，不改数据顺序</strong>。<code>[1,2,3,4,5,6]</code> 排成 2×3 还是 3×2，内存里还是那 6 个数。</li>
<li><code>permute</code>：<strong>真的把数据搬家</strong>。把某个维度整体挪到另一个位置（<code>transpose</code> 就是 2 维的 <code>permute([1,0])</code>）。</li>
</ul>
<p><code>permute(&amp;[0, 2, 1])</code> 的意思是&quot;新张量的第 0 维 = 旧张量的第 0 维，新第 1 维 = 旧第 2 维，新第 2 维 = 旧第 1 维&quot;。</p>
<p>看测试 <code>test_permute</code>：<code>[2, 3]</code> 用 <code>permute([1, 0])</code> 变成 <code>[3, 2]</code>，数据从 <code>[1,2,3,4,5,6]</code> 变成 <code>[1,4,2,5,3,6]</code>：</p>
<pre><code>原始 [2,3]:          permute([1,0]) 后 [3,2]:
[1 2 3]              [1 4]
[4 5 6]              [2 5]
                     [3 6]
</code></pre>
<h3>5.2 用&quot;逆映射&quot;实现</h3>
<p>要算 <code>out[of] = src[?]</code>，思路是：先反解 <code>of</code> 的多维坐标 <code>out_idx</code>，再问&quot;源的第 d 维应该取输出的哪一维&quot;——答案就是 <code>inv[d]</code>，其中 <code>inv</code> 是 <code>perm</code> 的逆映射：</p>
<pre><code class="language-rust">// 反解 permute 的逆映射：inv[perm[i]] = i
let mut inv = vec![0usize; self.rank()];
for (i, &amp;d) in dims.iter().enumerate() {
    inv[d] = i;
}
// 前向：out_flat -&gt; src_flat
let mut map = vec![0usize; total];
for out_flat in 0..total {
    // ① 反解输出多维下标（行优先）
    let mut r = out_flat;
    let mut out_idx = vec![0usize; self.rank()];
    for d in (0..self.rank()).rev() {
        out_idx[d] = r % new_shape[d];
        r /= new_shape[d];
    }
    // ② 源的第 d 维来自输出的第 inv[d] 维
    let mut src_flat = 0usize;
    for d in 0..self.rank() {
        let sd = out_idx[inv[d]];
        src_flat = src_flat * self.shape[d] + sd;
    }
    map[out_flat] = src_flat;
}
// ③ 按映射搬运数据
let mut out_data = vec![0.0f32; total];
for (of, &amp;sf) in map.iter().enumerate() {
    out_data[of] = sd[sf];
}
</code></pre>
<p><strong>反向</strong>：和广播类似——<code>permute</code> 也是&quot;每个源元素只去一个地方&quot;，但方向反过来，梯度要&quot;放回原位&quot;。用同一张 <code>map</code>，把输出的梯度 <code>g[of]</code> 累加到 <code>sgm[sf]</code>：</p>
<pre><code class="language-rust">result.backward = Some(Rc::new(move || {
    let g = rg.borrow();
    let mut sgm = sg.borrow_mut();
    for (of, &amp;sf) in map.iter().enumerate() {
        sgm[sf] += g[of];        // 梯度&quot;原路返回&quot;
    }
}));
</code></pre>
<blockquote>
<p>为什么是 <code>+=</code>？因为 permute 之后可能还有别的运算把梯度送到同一个源位置，或者源张量被多处共享，梯度必须累加（全项目统一约定）。</p>
</blockquote>
<h2>6. gather_rows：按行取数（Embedding 的基石）</h2>
<h3>6.1 语义</h3>
<p>词嵌入（Embedding）的本质就是&quot;查表&quot;：一张 <code>[V, D]</code> 的表格（V 个词的向量），给定一堆词的下标，把对应行拿出来：</p>
<pre><code>table [V, D] + indices [N]  -&gt;  out [N, D]
out[i, :] = table[indices[i], :]
</code></pre>
<p>前向代码：</p>
<pre><code class="language-rust">pub fn gather_rows(&amp;self, indices: &amp;[usize]) -&gt; Tensor {
    assert_eq!(self.rank(), 2, &quot;gather_rows 的 table 必须为 2 维&quot;);
    let (v, d) = (self.shape[0], self.shape[1]);
    let n = indices.len();
    let sd = self.data.borrow();
    let mut out_data = vec![0.0f32; n * d];
    for (i, &amp;idx) in indices.iter().enumerate() {
        assert!(idx &lt; v, &quot;gather 索引越界：{} &gt;= {}&quot;, idx, v);
        for j in 0..d {
            out_data[i * d + j] = sd[idx * d + j];   // 复制第 idx 行
        }
    }
    // ……
}
</code></pre>
<p>测试 <code>test_gather_rows</code>：<code>table[3,2]</code> 取第 0、2 行 → <code>[1,2,5,6]</code> ✓</p>
<h3>6.2 反向：scatter-add（散射累加）</h3>
<p>一行可能被取多次（两个位置都引用同一行），所以反向时梯度<strong>累加</strong>回表格对应行：</p>
<pre><code class="language-rust">result.backward = Some(Rc::new(move || {
    let g = rg.borrow();
    let mut sgm = sg.borrow_mut();
    for i in 0..idx_vec.len() {
        let row = idx_vec[i];                 // 输出第 i 行来自表格第 row 行
        for j in 0..d2 {
            sgm[row * d2 + j] += g[i * d2 + j];   // 累加，不覆盖！
        }
    }
}));
</code></pre>
<p>测试验证：</p>
<pre><code class="language-rust">let t = Tensor::param(vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0], vec![3, 2]);
let g = t.gather_rows(&amp;[0, 2]);
let loss = g.sum();
loss.backward();
// 第 0 行梯度 1，第 2 行梯度 1，第 1 行梯度 0
assert_eq!(t.grad(), vec![1.0, 1.0, 0.0, 0.0, 1.0, 1.0]);
</code></pre>
<table>
<thead>
<tr>
<th>表格行</th>
<th>被引用次数</th>
<th>梯度</th>
</tr>
</thead>
<tbody>
<tr>
<td>0</td>
<td>1 次（indices[0]）</td>
<td>1</td>
</tr>
<tr>
<td>1</td>
<td>0 次</td>
<td>0</td>
</tr>
<tr>
<td>2</td>
<td>1 次（indices[1]）</td>
<td>1</td>
</tr>
</tbody>
</table>
<p>如果 <code>indices = [0, 0]</code>，第 0 行的梯度就是 2——这就是&quot;scatter <strong>add</strong>&quot;里 add 的含义。</p>
<h2>7. 3D 批量 matmul</h2>
<h3>7.1 从 2D 到 3D</h3>
<p>第 1 课学过 2D 矩阵乘法 <code>C[m,n] = A[m,k] @ B[k,n]</code>。真实模型里我们通常同时处理<strong>一批</strong>样本，就变成了 3D 批量版本：前两个维度完全一样（都是 <code>B</code>），第三个、第四个维度才是矩阵的 m、k、n：</p>
<pre><code>2D:   C[m,n]     = A[m,k]     @ B[k,n]
3D:   C[B,m,n]   = A[B,m,k]   @ B[B,k,n]     每个 batch 独立做一次 2D 乘法
</code></pre>
<p>批量维度 <code>B</code> 必须一致，内维 <code>k</code> 也必须一致，输出是 <code>[B, m, n]</code>。</p>
<h3>7.2 前向代码</h3>
<p>就是&quot;对每个 batch 跑一遍第 1 课的三重循环&quot;：</p>
<pre><code class="language-rust">pub fn matmul(&amp;self, other: &amp;Tensor) -&gt; Tensor {
    assert!(
        (self.rank() == 2 &amp;&amp; other.rank() == 2) || (self.rank() == 3 &amp;&amp; other.rank() == 3),
        &quot;matmul 只支持 2D 或 3D（批量），当前 {}-D x {}-D&quot;, self.rank(), other.rank()
    );
    if self.rank() == 2 {
        return self.matmul_2d(other);        // 2D 走老路
    }
    // 3D 批量
    assert_eq!(self.shape[0], other.shape[0], &quot;批量维度必须一致&quot;);
    let (b, m, k1) = (self.shape[0], self.shape[1], self.shape[2]);
    let (_, k2, n) = (other.shape[0], other.shape[1], other.shape[2]);
    assert_eq!(k1, k2, &quot;矩阵乘法维度不匹配&quot;);

    let sd = self.data.borrow();
    let od = other.data.borrow();
    let mut out_data = vec![0.0f32; b * m * n];
    for bi in 0..b {                      // 多了一层 batch 循环
        for i in 0..m {
            for j in 0..n {
                let mut s = 0.0;
                for k in 0..k1 {
                    s += sd[(bi * m + i) * k1 + k] * od[(bi * k1 + k) * n + j];
                }
                out_data[(bi * m + i) * n + j] = s;
            }
        }
    }
    // ……
}
</code></pre>
<p>下标规律：每个 batch 内的下标公式和 2D 完全一样，只是多加了 <code>bi * m * k1</code>（A）和 <code>bi * k1 * n</code>（B）的批内偏移。看测试 <code>test_matmul_3d</code>：</p>
<pre><code>A [2,1,2] = [[1,2],[3,4]]      B [2,2,1] = [[5],[6]] , [[7],[8]]
C [2,1,1] = [[1·5+2·6=17], [3·7+4·8=53]]   →  [17, 53] ✓
</code></pre>
<h3>7.3 反向公式（对每个 batch 独立成立）</h3>
<p>第 2 课的反向公式在批量下逐 batch 套用：</p>
<table>
<thead>
<tr>
<th>输入</th>
<th>梯度公式</th>
<th>说明</th>
</tr>
</thead>
<tbody>
<tr>
<td>A</td>
<td><code>∂A = g @ Bᵀ</code></td>
<td>输出梯度乘 B 的转置</td>
</tr>
<tr>
<td>B</td>
<td><code>∂B = Aᵀ @ g</code></td>
<td>A 的转置乘输出梯度</td>
</tr>
</tbody>
</table>
<p>代码里就是三重循环写成 <code>+=</code> 累加（<code>sgm[(bi*m+i)*k1+k] += Σ_j g[...]·od[...]</code>），并且保留了 <code>Rc::ptr_eq</code> 判断（<code>x @ x</code> 这种自乘场景两条路径合并）。<strong>注意维度细节</strong>：<code>∂A</code> 是 <code>[B,m,k]</code>（和 A 同形状），<code>∂B</code> 是 <code>[B,k,n]</code>，所以两个循环的累加下标不同：</p>
<pre><code class="language-rust">// ∂A：sgm 用 (bi, i, k) 定位，对 j 求和
sgm[(bi * m + i) * k1 + k] += s;      // s = Σ_j g[bi,i,j] * B[bi,k,j]
// ∂B：ogm 用 (bi, k, j) 定位，对 i 求和
ogm[(bi * k1 + k) * n + j] += s;      // s = Σ_i A[bi,i,k] * g[bi,i,j]
</code></pre>
<h2>8. 运行与测试</h2>
<pre><code class="language-bash">cargo test     # 全部测试通过（含新增：广播、3D matmul、softmax、permute、gather_rows）
cargo run      # 运行演示
</code></pre>
<p>重点测试与验证内容：</p>
<table>
<thead>
<tr>
<th>测试</th>
<th>验证什么</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>test_broadcast_add</code></td>
<td><code>[2,3] + [3]</code> 前向正确，且 b 的梯度是 2（被广播两次）</td>
</tr>
<tr>
<td><code>test_matmul_3d</code></td>
<td><code>[2,1,2] @ [2,2,1] = [2,1,1]</code>，数据 <code>[17, 53]</code></td>
</tr>
<tr>
<td><code>test_softmax</code></td>
<td>输出和为 1，与手算值 <code>[0.0900, 0.2447, 0.6652]</code> 一致</td>
</tr>
<tr>
<td><code>test_permute</code></td>
<td><code>[2,3]</code> permute 成 <code>[3,2]</code>，数据 <code>[1,4,2,5,3,6]</code></td>
</tr>
<tr>
<td><code>test_gather_rows</code></td>
<td>查表结果正确，且只有被引用行有梯度</td>
</tr>
</tbody>
</table>
<h2>9. 动手练习</h2>
<ol>
<li>手推一遍 <code>[2,3] + [1,3]</code> 的广播：画两个张量，标出 b 的每个元素被复制到哪些位置；再画反向时梯度如何累加回 b。</li>
<li>用 <code>sum_last_dim</code> 和 <code>mul_scalar</code> 组合实现&quot;按行求均值&quot;（<code>[B, D] -&gt; [B, 1]</code>），并推导梯度。</li>
<li>设 d=2，手推 softmax 反向公式：<code>s = softmax([x0, x1])</code>，写出 <code>∂loss/∂x0</code> 和 <code>∂loss/∂x1</code> 的具体表达式。</li>
<li>思考：<code>permute</code> 之后做 <code>sum()</code>，源张量的梯度是什么？为什么说 permute 只改变数据的&quot;排列&quot;而不改变&quot;信息&quot;？</li>
<li>挑战题：<code>gather_rows</code> 若允许同一行被取两次（如 <code>indices=[0,0]</code>），反向时该行梯度是多少？这提示了 Embedding 的梯度为什么必须用 <code>+=</code>。</li>
</ol>
<h2>10. 本课总结</h2>
<ul>
<li>广播：右对齐、维度相等或一方为 1、否则失败；<code>broadcast_shapes</code> 定形状，<code>broadcast_map</code> 定搬数路线</li>
<li>广播的反向 = 梯度<strong>累加</strong>回源位置（谁被复制，谁收多份梯度）</li>
<li>归约：<code>sum</code> 变标量、<code>sum_last_dim</code> 保持维度；mean 用 sum + 除法组合</li>
<li>softmax：减 max 保证数值稳定（数学等价、永不溢出），反向公式 <code>s_i(g_i - dot)</code></li>
<li>permute / gather_rows：都是&quot;搬家&quot;运算，反向用同一张映射表把梯度放回原位（累加）</li>
<li>3D matmul：批量维度不变，内层逐 batch 做 2D matmul，反向逐 batch 套 <code>g@Bᵀ</code> / <code>Aᵀ@g</code></li>
<li>下一课：把项目拆成独立模块，并深入理解支撑这一切的 <code>Rc&lt;RefCell&gt;</code> 架构</li>
</ul>
]]></content>
  <category term="Rust"/>
  <category term="LLM"/>
  <category term="张量"/>
</entry>
<entry>
  <title>第 2 课：自动微分 Autograd —— 让模型学会「自我修正」</title>
  <link href="https://ganto.me/post/rust-llm-guide-02"/>
  <id>https://ganto.me/post/rust-llm-guide-02</id>
  <updated>2026-09-03T00:00:00+08:00</updated>
  <published>2026-09-03T00:00:00+08:00</published>
  <author><name>干徒</name></author>
  <summary><![CDATA[实现反向传播所需的梯度计算：grad、backward 与各运算的反向传播实现。]]></summary>
  <content type="html"><![CDATA[<h1>第 2 课：自动微分 Autograd —— 让模型学会&quot;自我修正&quot;</h1>
<blockquote>
<p>代码位置：<a href="src/tensor.rs">src/tensor.rs</a>（grad / parents / 各运算的反向闭包）、<a href="src/autograd.rs">src/autograd.rs</a>（backward 拓扑排序）
演示入口：<a href="src/main.rs">src/main.rs</a></p>
</blockquote>
<h2>1. 本课要搞懂的问题</h2>
<ol>
<li>什么是梯度？为什么&quot;梯度下降&quot;能让模型学习？</li>
<li>链式法则是怎么回事？为什么反向传播比正向求导更高效？</li>
<li>怎么用 Rust 把&quot;求导&quot;自动化，让代码自动算出每个参数的梯度？</li>
</ol>
<h2>2. 从&quot;拟合直线&quot;说起</h2>
<p>上一课我们实现了矩阵乘法。现在想象一个最简单的&quot;学习&quot;任务：
已知一些点 (x, y)，它们近似满足 <code>y = 2x + 1</code>，但参数 w、b 未知。怎么让程序<strong>自己找到</strong> w=2、b=1？</p>
<p>思路：先随便给个初始值（比如 w=0, b=0），然后：</p>
<ol>
<li>算出预测值和真实值的差距 → <strong>损失 loss</strong></li>
<li>问：<strong>如果我调大一点 w，loss 会变大还是变小？</strong> → 这个方向就叫<strong>梯度</strong></li>
<li>沿着&quot;让 loss 变小的方向&quot;微调 w、b</li>
<li>重复直到 loss 足够小</li>
</ol>
<p>第 2 步里的&quot;损失随参数的变化率&quot;，数学上叫<strong>偏导数</strong>。所有参数偏导数的集合叫<strong>梯度</strong>。</p>
<h2>3. 梯度下降公式</h2>
<p>对参数 θ，更新规则只有一行：</p>
<pre><code>θ = θ - 学习率 × ∂loss/∂θ
</code></pre>
<ul>
<li><code>∂loss/∂θ &gt; 0</code>：θ 增大 loss 也增大 → 所以减去它，θ 变小</li>
<li><code>∂loss/∂θ &lt; 0</code>：θ 增大 loss 减小 → 减去它，θ 变大</li>
<li>学习率（learning rate）控制每次迈多大步子，太小太慢、太大震荡</li>
</ul>
<p>我们采用的损失是 MSE（均方误差）：</p>
<pre><code>loss = Σ (pred - y)²        # pred = x·w + b
</code></pre>
<h2>4. 链式法则：复合函数求导的关键</h2>
<p>问题来了：loss 是复合函数，<code>loss = g(pred)</code>，而 <code>pred = f(w)</code>。
要求 <code>∂loss/∂w</code>，不能直接求，需要一层层拆开：</p>
<p><strong>链式法则</strong>：如果 <code>z = g(f(x))</code>，那么</p>
<pre><code>dz/dx = (dg/df) × (df/dx)
</code></pre>
<p>类比&quot;流水线&quot;：损失一路流经 pred 再到 w，每一段的&quot;变化率&quot;相乘就是总的&quot;变化率&quot;。</p>
<p>例：<code>z = x*y + w</code>，各梯度是：</p>
<pre><code>∂z/∂x = y      （对 x 求导时把 y 当常数）
∂z/∂y = x      （对 y 求导时把 x 当常数）
∂z/∂w = 1      （加法求导是 1）
</code></pre>
<p>程序输出正是 <code>dz/dx=3, dz/dy=2, dz/dw=1</code> ✓</p>
<h2>5. 反向传播：把链式法则批量自动化</h2>
<p>手工对每个参数求导太累，模型参数动辄百万个。怎么办？</p>
<p><strong>核心思想</strong>：前向传播时，把每一步运算记录下来，形成一张<strong>计算图</strong>；反向传播时，从 loss 出发，把梯度沿图<strong>逐层往回传</strong>，每层的梯度都复用上一层的结果。</p>
<pre><code>前向（边算边记）：  x ──┐
                     mul ──► t ──┐
                     y ──┘         add ──► z = 7
                     w ───────────┘

反向（梯度倒流）：  x ◄── grad=3
                     t ◄── grad=1 ──► w ◄── grad=1
                     y ◄── grad=2
</code></pre>
<p>关键好处：每个中间结果<strong>只算一次梯度</strong>，总计算量与前向相当，而不是对每个参数各算一次前向（那样要 O(参数个数) 倍计算）。这就是反向传播比&quot;数值微分&quot;快的原因。</p>
<h2>6. Rust 实现：每个张量背一个&quot;反向函数&quot;</h2>
<p>我们把张量升级成计算图节点：</p>
<pre><code class="language-rust">pub struct Tensor {
    data: Rc&lt;RefCell&lt;Vec&lt;f32&gt;&gt;&gt;,   // 数值（共享可变）
    shape: Vec&lt;usize&gt;,
    grad: Rc&lt;RefCell&lt;Vec&lt;f32&gt;&gt;&gt;,   // 梯度，初始全 0，反向时累加
    requires_grad: bool,           // 这个张量是否需要梯度（参数=true，数据=false）
    parents: Vec&lt;Tensor&gt;,          // 我是由哪些张量算出来的
    backward: Option&lt;BackwardFn&gt;,  // 我的反向函数：把梯度传给我的父母
}
</code></pre>
<p>用到的 Rust 核心特性：</p>
<ul>
<li><strong><code>Rc&lt;T&gt;</code>（引用计数）</strong>：计算图里一个节点可能被多个运算共享，Rc 让多份句柄指向同一份数据</li>
<li><strong><code>RefCell&lt;T&gt;</code>（内部可变性）</strong>：让&quot;共享数据&quot;同时可以&quot;可变借用&quot;，这是 Rust 所有权模型下实现可变共享的标准手法</li>
<li>每定义一个运算，就写两个东西：<strong>前向</strong>（算结果）+ <strong>反向</strong>（算梯度传给输入）</li>
</ul>
<h2>7. 各运算的反向规则（背下这几条）</h2>
<table>
<thead>
<tr>
<th>前向</th>
<th>反向（g 是输出的梯度）</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>c = a + b</code></td>
<td><code>∂a += g</code>，<code>∂b += g</code></td>
</tr>
<tr>
<td><code>c = a - b</code></td>
<td><code>∂a += g</code>，<code>∂b -= g</code></td>
</tr>
<tr>
<td><code>c = a * b</code></td>
<td><code>∂a += g*b</code>，<code>∂b += g*a</code></td>
</tr>
<tr>
<td><code>c = a * s</code></td>
<td><code>∂a += g*s</code></td>
</tr>
<tr>
<td><code>c = a @ b</code></td>
<td><code>∂a += g @ bᵀ</code>，<code>∂b += aᵀ @ g</code></td>
</tr>
<tr>
<td><code>c = sum(a)</code></td>
<td><code>∂a += g</code>（每个元素都加同一个 g）</td>
</tr>
</tbody>
</table>
<p>以乘法为例看代码（注意累加 <code>+=</code>，因为一个张量可能被多条路径使用，梯度要累加）：</p>
<pre><code class="language-rust">pub fn mul(&amp;self, other: &amp;Tensor) -&gt; Tensor {
    // —— 前向 ——
    let data = /* self * other 逐元素 */;
    let mut result = Tensor::new(data, shape, self.requires_grad || other.requires_grad);
    if result.requires_grad {
        // 捕获需要的句柄
        let rg = result.grad.clone();       // 读：自己的梯度
        let sg = self.grad.clone();          // 写：给 self 累加梯度
        let og = other.grad.clone();
        let sd = self.data.clone();          // 读：self 的数值（计算 g*b 需要）
        let od = other.data.clone();
        result.backward = Some(Rc::new(move || {
            let g = rg.borrow();             // 拿到&quot;传到我这的梯度&quot;
            let sd_b = sd.borrow();
            let od_b = od.borrow();
            let mut sgm = sg.borrow_mut();
            let mut ogm = og.borrow_mut();
            for i in 0..g.len() {
                sgm[i] += g[i] * od_b[i];    // ∂a = g * b
                ogm[i] += g[i] * sd_b[i];    // ∂b = g * a
            }
        }));
    }
    result
}
</code></pre>
<h2>8. 反向传播主流程：拓扑排序</h2>
<p>图是有向无环图（DAG）。要保证&quot;子节点先算，父节点后算&quot;，需要<strong>拓扑排序</strong>：</p>
<pre><code class="language-rust">pub fn backward(&amp;self) {
    // 1. loss 自己的梯度 = 1（d loss / d loss = 1）
    self.grad.borrow_mut()[0] = 1.0;
    // 2. 迭代式 DFS 收集拓扑序：子在前，父在后（避免递归栈溢出，见第 4 课 6.2 节）
    //    用显式栈模拟递归，三色标记法保证每个节点只入队一次
    // 3. 逆序遍历，逐个调用 backward 闭包：梯度从子流向父
    for t in order.iter().rev() { t.backward() }
}
</code></pre>
<p>用 <code>Rc::as_ptr(&amp;t.grad)</code> 作为节点的唯一编号做去重（一个节点被多个运算共享时不能重复入队）。</p>
<h2>9. 训练循环：四步舞</h2>
<p>一次&quot;学习&quot;包含四步（也是所有深度学习框架训练循环的骨架）：</p>
<pre><code class="language-rust">// 1. 前向：算预测和损失
let loss = x.matmul(&amp;w).sub(&amp;y).mul(...).sum();
// 2. 反向：自动算每个参数的梯度
loss.backward();
// 3. 更新：w -= lr * 梯度（这里手动实现，展示底层）
w.set_data(vec![w.data()[0] - lr * w.grad()[0], ...]);
// 4. 清零梯度：否则下一轮会把旧梯度累加上去
w.zero_grad();
</code></pre>
<p>运行 <code>cargo run</code> 可以看到损失从 285 一路降到 0，w→2.00、b→1.00，模型&quot;学会&quot;了 y=2x+1。</p>
<h2>10. 一个重要的细节：梯度累加</h2>
<p>为什么用 <code>+=</code> 而不是 <code>=</code>？因为一个张量可能被多条路径使用。</p>
<p>例：<code>z = x*x</code>。求梯度时 x 有两条路径（<code>z</code> 对第一个 x 的导数是第二个 x，对第二个 x 的导数是第一个 x），必须相加：<code>∂x = g*x + g*x = 2gx</code>。</p>
<p>代码里我们用 <code>Rc::ptr_eq(&amp;sg, &amp;og)</code> 判断两个父节点是不是同一个张量，是的话就合并累加。这也是测试 <code>test_grad_accumulation_and_zero</code> 验证的内容。</p>
<h2>11. 运行与测试</h2>
<pre><code class="language-bash">cargo test     # 8 个测试全部通过
cargo run      # 演示：链式法则验证 + 线性回归训练
</code></pre>
<h2>12. 动手练习</h2>
<ol>
<li>给张量加一个 <code>pow(n)</code> 运算（<code>c = a^n</code>），推导并实现它的反向传播（提示：<code>∂a = g * n * a^(n-1)</code>）。</li>
<li>思考：为什么 loss 必须是标量（0 维）才能调用 <code>backward()</code>？如果不是标量会怎样？</li>
<li>把学习率从 0.001 改成 1.0，观察 loss 曲线，解释发生了什么（振荡/发散）。</li>
<li>这是本项目的&quot;心脏&quot;。试着不看代码，自己画出 <code>loss = (x·w - y)²</code> 的计算图并手算 <code>∂loss/∂w</code>。</li>
</ol>
<h2>13. 本课总结</h2>
<ul>
<li>梯度 = 损失对参数的&quot;变化方向&quot;，梯度下降就是沿着反方向走</li>
<li>链式法则 + 计算图 = 反向传播，一次性高效算出所有梯度</li>
<li>Rust 用 <code>Rc&lt;RefCell&gt;</code> + 闭包实现了可自动求导的张量</li>
<li>亲手跑通了第一个&quot;会学习&quot;的程序：线性回归</li>
<li>下一课：补齐张量运算（广播、softmax 等），为神经网络打基础</li>
</ul>
]]></content>
  <category term="Rust"/>
  <category term="LLM"/>
  <category term="自动微分"/>
</entry>
</feed>
