第 4 课:模块化重构 —— 项目结构分层与 Rc<RefCell> 架构

2026-09-05 干徒
RustLLM架构

第 4 课:模块化重构 —— 项目结构分层与 Rc 架构

代码位置:src/tensor.rs(核心:Tensor 结构体与自动微分)、src/module.rs(Module trait)、src/main.rs(模块声明与演示编排) 参考实现:src/layers.rs(Module 的第一个实现者)

1. 本课要搞懂的问题

  1. 一个从零写的项目,文件该怎么切?每个文件各管什么?
  2. Tensor 里的 Rc<RefCell<Vec<f32>>> 是什么?为什么张量需要"共享可变数据"?
  3. 为什么 parents 要用 Rc<Vec<Tensor>> 而不是直接 Vec<Tensor>?直接存会有什么灾难?
  4. backward() 里的 DFS 拓扑排序是怎么保证"梯度从 loss 流回叶子"的?
  5. Module trait 是干什么的?它和优化器、训练循环怎么配合?

2. 为什么要"重构"

经过前三课,tensor.rs 已经长成了一个功能完整的张量库:构造、形状、逐元素运算、广播、归约、softmax、matmul、自动微分……如果所有代码继续堆在一个文件里,很快就会失控:

  • 职责不分:张量运算、自动微分、模型层、训练逻辑混在一起,改一处可能碰坏另一处
  • 无法复用:后续每节课都要加新东西(层、损失、优化器、GPT 模型),没有清晰的模块边界就没法增量开发
  • 依赖混乱:谁依赖谁说不清,改个接口要满项目搜索

所以这一课做两件事:

  1. 横向:把项目按职责拆成多个 .rs 文件,形成清晰的依赖金字塔
  2. 纵向:深入理解 TensorRc<RefCell> 架构——它是一切上层建筑的地基,前三课里所有算子都建立在这套设计之上

3. src/ 各文件职责分工

main.rs 顶部用 mod 声明了全部模块:

// 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;

每个文件的职责(与文件头注释一致):

文件 职责 对应课程
tensor.rs 张量数据结构 + 运算(前向 + 反向闭包) 第 1-4 课
autograd.rs 自动微分:backward 迭代式拓扑排序(从 tensor.rs 拆出) 第 2 课
rope.rs RoPE 旋转位置编码(从 tensor.rs 拆出) 第 19 课
attention.rs 多头自注意力 + KV Cache(从 model.rs 拆出) 第 9-10、18 课
gpu.rs GPU 计算后端(--features gpu):WGSL 计算着色器 第 21 课
module.rs Module trait(参数收集的统一接口) 第 5 课
layers.rs 神经网络层:Linear / LayerNorm / Embedding + 激活函数 第 5、11、12 课
loss.rs 损失函数:mse_loss / cross_entropy_loss 第 6 课
optim.rs 优化器:SGD / AdamW 第 6、17 课
rng.rs xorshift64 伪随机数生成器(参数初始化、采样) 第 5 课起
data.rs 数据加载 DataLoader 与内置语料 CORPUS 第 14 课
tokenizer.rs 分词器:CharTokenizer / BPETokenizer 第 8 课
model.rs GPT 模型(Transformer Block 堆叠) 第 11-12 课
train.rs 训练循环 + 学习率调度 LRScheduler 第 13、20 课
sample.rs 推理采样:temperature / top-k / top-p 第 15 课
config.rs 配置加载(serde 序列化) 配套
checkpoint.rs checkpoint 保存/恢复 配套
cli.rs 命令行定义(clap) 配套
main.rs 程序入口,依次演示各课成果 每课

依赖关系呈金字塔形——越往下越基础,越往上越具体

层次 模块 说明
第 4 层(入口) main.rs 声明全部模块,编排演示流程
第 3 层(流程) train.rs model.rs sample.rs checkpoint.rs cli.rs 训练循环、GPT 组装、推理采样、checkpoint、命令行
第 2 层(组件) layers.rs loss.rs optim.rs data.rs tokenizer.rs rng.rs config.rs 层、损失、优化器、数据、分词、随机数、配置
第 1 层(协议) module.rs 只定义一个 trait,不依赖上层任何模块
第 0.5 层(拆分) autograd.rs rope.rs attention.rs 从 tensor.rs / model.rs 拆出的独立模块
第 0 层(地基) tensor.rs gpu.rs 张量与自动微分、GPU 计算后端,被所有模块依赖

要点:

  • tensor.rs 在最底层:所有模块都 use crate::tensor::Tensor,但它不依赖任何其他模块。
  • module.rs 只依赖 tensor.rs:它只定义一个 trait,是个"协议层",供 layers.rs / model.rs 实现。
  • 上层模块互相独立loss.rs 不知道 optim.rs 的存在,改动一个不影响另一个;data.rstokenizer.rsrng.rs 与神经网络计算完全解耦。

4. 核心架构:Rc<RefCell<Vec>> 共享可变数据

先看 Tensor 的结构体(src/tensor.rs):

#[derive(Clone)]
pub struct Tensor {
    data: Rc<RefCell<Vec<f32>>>,   // 数值(前向结果)
    shape: Vec<usize>,             // 形状
    grad: Rc<RefCell<Vec<f32>>>,   // 梯度(反向累加)
    requires_grad: bool,           // 是否需要梯度(参数=true,数据=false)
    parents: Rc<Vec<Tensor>>,      // 父节点列表(我是由谁算出来的)
    backward: Option<BackwardFn>,  // 反向函数:把梯度传给父节点
}

两个问题:为什么数值和梯度要共享?共享之后怎么才能"可变"?

4.1 Rc:多个句柄指向同一份数据

计算图里一个节点经常被多个运算同时引用。比如 z = x·y + wy 只被用一次,但看下面这种图:

          x ──┐
               mul ──► t ──┐
          x ──┘            add ──► z      (x 被用了两次!)
          w ───────────────┘

x 同时是两次运算的输入。如果 Tensor 直接 pub data: Vec<f32>,克隆一个 Tensor 就要复制整份数据;而 Rc<T>(引用计数)允许无数个 Tensor 句柄共享同一个底层数据,克隆只把引用计数 +1,是 O(1) 的操作。这也是 #[derive(Clone)] 能放心用的前提。

4.2 RefCell:共享的同时允许修改

Rc 只解决"共享",不解决"可变"——Rc<T> 的内容默认不可变。但我们的张量既被多处引用、又需要更新(前向写 data、反向写 grad)。Rust 的常规借用规则(一个值同时只能有一个可变借用)会拒绝这种写法。

RefCell<T>(内部可变性)把"借用检查"从编译期推迟到运行期borrow() 只读借用、borrow_mut() 可变借用,同一时刻只能有一个 borrow_mut,违反就 panic。这就是"共享可变数据"的标准解法:

组合 解决的问题 代价
Rc<T> 多个句柄共享一份数据 只读共享
RefCell<T> 共享的数据还能改 借用检查推迟到运行时
Rc<RefCell<T>> 共享 + 可变 运行时 panic 风险、无线程安全

对应到代码里的经典模式:

let sd = self.data.borrow();          // 读:前向计算
// …… 用完立刻释放,避免借用重叠
drop(sd);
let mut g = self.grad.borrow_mut();   // 写:反向累加梯度

注意代码里几乎每个算子的前向都有 drop(sd); drop(od);——因为之后构造结果节点要 borrow_mut 自己的东西,如果 data 的只读借用还挂着,同一 RefCell 上"只读借用未释放就再取可变借用"会在运行时 panic。这是 RefCell 使用者的纪律。

4.3 为什么 data 和 grad 分开两个 Rc

细看:datagrad两个独立Rc<RefCell<Vec<f32>>>。除了"语义上一个是数值、一个是梯度"之外,还有两个实际好处:

  1. 可以同时读 data、写 grad:反向闭包里常见 let sd_b = sd.borrow(); let mut sgm = sg.borrow_mut();——两者不是同一个 RefCell,互不冲突。
  2. data 的指针充当计算图节点 IDbackward 里用 Rc::as_ptr(&t.data) as usize 唯一标识一个节点(见第 6 节),而 grad 的指针用来判断"两个父节点是不是同一个张量"(Rc::ptr_eq(&sg, &og),处理 x*x 自乘的梯度合并)。

4.4 为什么不是 Arc

Rc 是单线程引用计数(快,无锁),Arc<Mutex> 是多线程方案。我们的训练是单线程的,用 Rc + RefCell 更轻、更符合"从零实现"的教学目标。等将来要并行训练,再换成 Arc<Mutex>Arc<RwLock> 不迟。

5. parents: Rc<Vec> —— 为什么不能直接用 Vec

这是全项目最重要的一个设计决策。看结构体注释(源码原文):

/// 父节点列表。
/// 注意:用 `Rc<Vec<_>>` 而不是 `Vec<Tensor>`——
/// 若直接存 Vec,`derive(Clone)` 会递归深拷贝整棵祖先计算图,
/// 深层图上每次建节点都是 O(图深) 的灾难。用 Rc 共享后克隆是 O(1)。
parents: Rc<Vec<Tensor>>,

5.1 深拷贝灾难是怎么发生的

每个运算在构造结果时都要记录父节点:

result.parents = Rc::new(vec![self.clone(), other.clone()]);   // 每个算子都这么干

问题出在 self.clone()。如果 parents 的类型是 Vec<Tensor>,那么 clone 一个 Tensor 时:

  • dataRc 共享 → O(1)
  • shapeVec<usize> → O(维度数)
  • gradRc 共享 → O(1)
  • parentsVec<Tensor> → 递归克隆所有父节点!

而父节点自己又有 parents……于是 clone递归复制整棵祖先子树。设计算图深度为 L:

方案 建一个新节点的开销 一次前向(L 个节点) N 步训练
parents: Vec<Tensor> O(L)(递归深拷贝) O(L²) O(N·L²)
parents: Rc<Vec<Tensor>> O(1)(Rc 引用计数 +1) O(L) O(N·L)

线性回归的计算图只有几层,L 很小,第 2 课那样写还能跑;但到了 GPT(几十层 Transformer),每步前向要建几百上千个节点,O(L²) 的复制会直接拖垮训练——这就是"性能灾难"。

5.2 第 2 课的旧代码 vs 现在的写法

第 2 课文档里的版本(能跑,但有问题):

// ❌ 旧版:parents 直接存 Vec<Tensor>
pub struct Tensor {
    data: Rc<RefCell<Vec<f32>>>,
    shape: Vec<usize>,
    grad: Rc<RefCell<Vec<f32>>>,
    requires_grad: bool,
    parents: Vec<Tensor>,          // clone 时递归复制祖先图!
    backward: Option<BackwardFn>,
}

重构后的版本:

// ✅ 现在的版本:parents 用 Rc 共享
pub struct Tensor {
    // ……
    parents: Rc<Vec<Tensor>>,      // clone 时只复制一个 Rc 句柄
    backward: Option<BackwardFn>,
}

对比要点:

  • 旧版parents: Vec<Tensor> 里存的是"真实拥有的子图副本",克隆 = 深拷贝,图越深越慢。
  • 新版parents: Rc<Vec<Tensor>> 里存的是"共享的子图句柄",克隆 = 引用计数 +1,O(1)。所有节点的 parents 共享同一棵祖先图,图在内存里只有一份。

这也解释了为什么 backward 里能放心地 for p in t.parents.iter() 递归——遍历的是共享图,不会触发任何复制。

6. backward 的 DFS 拓扑排序

6.1 为什么需要拓扑排序

反向传播要求梯度从输出端向输入端逐层传递z = (x·y) + w 里,必须先执行 z 的 backward(把梯度分给 tw),t 的梯度就绪后才能执行 t 的 backward(再分给 xy)。执行顺序由计算图的依赖关系决定,而 DFS 后序 + 逆序执行恰好给出这个顺序。

6.2 代码:迭代式 DFS + HashSet 去重

注:早期版本使用递归 DFS,后改为迭代式栈 DFS(三色标记法), 因为深层计算图(4 层 Transformer + batch=16)的递归深度会超过 Windows 默认 1MB 栈。 见 src/autograd.rs

pub fn backward(&self) {
    assert_eq!(self.rank(), 0, "backward() 只支持标量(0 维)输出,当前形状 {:?}", self.shape);
    {
        let mut g = self.grad.borrow_mut();
        g[0] = 1.0;                     // ① loss 的梯度 = 1(∂loss/∂loss = 1)
    }

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

    let key = Rc::as_ptr(&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 < node.parents.len() {
            let child = node.parents[*idx].clone();
            *idx += 1;
            let child_key = Rc::as_ptr(&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) = &t.backward {
            b();                                  // 执行各节点的反向闭包
        }
    }
}

三个关键设计:

设计 原因
Rc::as_ptr(&t.grad) 做节点 ID Tensor 没有自增 id;用梯度 grad 的堆地址当身份证最省事,同一张量的所有句柄地址相同
HashSet 去重 一个节点被多个运算共享(如 x 被用两次),DFS 可能从两条路径走到它。梯度只需也只需执行一次它的 backward,否则会重复累加
后序 DFS + 逆序执行 后序 DFS 得到"子在前、父在后"(叶 → 根);逆序执行变成"根 → 叶",正是梯度从输出流向输入的次序

6.3 走一遍例子:z = (x·y) + w

        x ──┐
             mul ──► t ──┐
        y ──┘            add ──► z
        w ───────────────┘

DFS 后序结果(子在前、父在后):order = [x, y, t, w, z],逆序执行(z → w → t → y → x):

顺序 节点 执行的 backward 效果
1 z add 的闭包(收到的 g = 1) t += 1,w += 1
2 w (叶子,无 backward)
3 t = x·y mul 的闭包(收到的 g = 1) x += 1·y = 3,y += 1·x = 2
4 y (叶子,无 backward)
5 x (叶子,无 backward)

test_chain_rule 验证的结果一致:∂z/∂x = y = 3∂z/∂y = x = 2∂z/∂w = 1

7. module.rs:Module trait 与 zero_grad_all

7.1 trait 定义

深度学习里一切"可训练的结构"(Linear、LayerNorm、Transformer Block、整个 GPT……)都需要回答一个问题:你有多少参数? module.rs 就把这个回答统一成接口:

// src/module.rs(全文)
use crate::tensor::Tensor;

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

/// 便捷方法:清零所有参数的梯度
pub fn zero_grad_all(module: &dyn Module) {
    for p in module.parameters() {
        p.zero_grad();
    }
}

Module 目前只要求实现一个方法 parameters()。别小看这一个方法——它是优化器、训练循环、梯度清零与模型结构之间的唯一桥梁

7.2 三个实现者:叶子模块

layers.rs 里三个最简单的实现:

// Linear:线性层 y = xW + b
impl Module for Linear {
    fn parameters(&self) -> Vec<Tensor> {
        vec![self.weight.clone(), self.bias.clone()]
    }
}

// LayerNorm:层归一化
impl Module for LayerNorm {
    fn parameters(&self) -> Vec<Tensor> {
        vec![self.gamma.clone(), self.beta.clone()]
    }
}

// Embedding:词嵌入查表
impl Module for Embedding {
    fn parameters(&self) -> Vec<Tensor> {
        vec![self.table.clone()]
    }
}

注意 clone() 在这里是安全的:因为 Tensor 内部全是 Rc(第 4、5 节),克隆只复制句柄,不复制数据——返回 Vec<Tensor> 给外部持有也不会有性能问题。

7.3 组合模式:大模块递归收集小模块的参数

model.rs 里的 GPT 由若干 TransformerBlock 组成,TransformerBlock 又由 LayerNormMultiHeadAttentionLinear 组成。每个组合模块实现 Module 时,把子模块的参数拼接起来

// model.rs:GPT
impl Module for GPT {
    fn parameters(&self) -> Vec<Tensor> {
        let mut ps = self.tok_emb.parameters();      // 词嵌入
        for block in &self.blocks {
            ps.extend(block.parameters());           // 每层 Transformer Block
        }
        ps.extend(self.ln_f.parameters());           // 最终 LayerNorm
        ps.extend(self.lm_head.parameters());        // 输出头
        ps
    }
}

因为 parameters() 是 trait 方法,block.parameters() 会自动递归调用 TransformerBlock 的实现,后者又调用 MultiHeadAttention 的实现……最终一棵参数树被拍平成一张参数表

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,不单独存储)

7.4 与优化器的配合(main.rs 的 demo_xor)

main.rs 里第 7 课的 XOR 演示展示了完整闭环:

// ① 收集所有参数,一次性交给优化器
let params: Vec<Tensor> = {
    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(&fc1.forward(&x_data));
    let logits = fc2.forward(&h);
    let loss = cross_entropy_loss(&logits, &y_targets);
    loss.backward();     // 自动算所有参数的梯度
    opt.step();          // SGD 遍历 params 更新:θ -= lr·g
    opt.zero_grad();     // 清零,防累加
}

分工很清晰:

角色 干什么
参数清单 Module::parameters() 告诉外界"我的参数在哪"
梯度计算 Tensor::backward() 自动微分填满每个参数的 grad
参数更新 optim.rsSGD / AdamW 按梯度更新 data
梯度清零 Tensor::zero_grad() / zero_grad_all() 重置 grad 为 0

zero_grad_all 就是"把清梯度也统一成接口":传入任意 &dyn Module,它调用 parameters() 拿到所有参数逐个清零。实际训练中优化器(如上面 SGD::zero_grad)自带同样的清零逻辑,而 zero_grad_all 的价值在于:不经过优化器也能直接清任意模块的梯度(比如想"只求梯度、手动更新"时)。任何新模块只要实现 Module,就能复用这套参数管理设施。

8. 运行与测试

cargo test     # 全部测试通过
cargo run      # 依次演示:XOR MLP → BPE 分词 → 小 GPT 训练与生成

至此,项目的"地基"(阶段一)全部完成。从下一课开始,所有新代码(层、损失、优化器、Transformer)都将站在 tensor.rs + module.rs 这两块基石之上。

9. 动手练习

  1. Linear 之外再写一个你自己的模块(比如 BiasOnly { bias: Tensor }),实现 Module trait,并用 zero_grad_all 清一次梯度,确认能编译通过。
  2. 思考:parameters() 返回 Vec<Tensor> 时我们敢 clone,靠的是什么?如果 data 不是 Rc 而是裸 Vec<f32>clone 一次要花多少钱?
  3. 画一画 z = (x·y) + w 的计算图,标出 DFS 后序的 order 数组内容,再写出逆序执行的顺序(参考答案在第 6.3 节)。
  4. 挑战题:把 parents 改成 Vec<Tensor>,跑 cargo test,确认功能仍然正确;再构造一个 100 层深的计算图,对比两种写法每次前向/反向的耗时,体会"O(1) vs O(L)"的差别。
  5. 阅读 model.rsTransformerBlockimpl Module,数一数一个 Block 最终贡献多少个参数张量。

10. 本课总结

  • 项目按职责拆成 12 个模块,依赖呈金字塔形,tensor.rs 是最底层地基
  • Rc<RefCell<Vec<f32>>> = 共享(Rc)+ 可变(RefCell),是单线程下"共享可变数据"的标准方案
  • parents: Rc<Vec<Tensor>> 让克隆从 O(图深) 降到 O(1),避免深拷贝灾难
  • backwardRc::as_ptr 做节点 ID、DFS 后序 + 逆序执行,保证梯度按拓扑序从 loss 流回叶子
  • Module trait 统一"收集参数",配合 zero_grad_all、优化器、训练循环形成完整闭环
  • 下一课:在 Module 之上实现第一个神经网络层 Linear 与激活函数,搭建第一个真正的网络
Rust 大语言模型 学习指南共 22 章
1从零用 Rust 实现大语言模型 —— 学习计划2第 1 课:张量 Tensor —— 一切的基础3第 2 课:自动微分 Autograd —— 让模型学会「自我修正」4第 3 课:张量运算扩展 —— 广播、归约、softmax、批量矩阵乘法5第 4 课:模块化重构 —— 项目结构分层与 Rc<RefCell> 架构本篇6第 5 课:线性层与激活函数 —— 神经网络的「积木」7第 6 课:损失函数与优化器 —— 让模型知道「错在哪、怎么改」8第 7 课:第一个 MLP —— 教会神经网络算 XOR9第 8 课:BPE 分词器 —— 让模型「读懂」文字10第 9 课:注意力机制 —— 让 token 互相「看」11第 10 课:多头注意力 —— 让模型「多角度」看世界12第 11 课:位置编码与归一化 —— 让序列带上「位置感」13第 12 课:完整 GPT 模型 —— 把积木拼成能预测下一个词的模型14第 13 课:训练循环 —— 让模型真正开始学习15第 14 课:数据加载 —— 文本如何变成训练样本16第 15 课:推理与采样 —— 让模型「创造性」地生成17第 16 课:训练小 GPT —— 看 loss 从 1.46 一路降到 0.1618第 17 课:AdamW 优化器 —— 给梯度下降装上「惯性」和「自适应步长」19第 18 课:KV Cache —— 让逐 token 生成不再重复计算20第 19 课:RoPE 旋转位置编码 —— 把「相对位置」揉进注意力21第 20 课:学习率调度与收尾 —— warmup、cosine decay 与全项目总结22第 21 课:GPU 加速训练与推理