第 1 课:张量 Tensor —— 一切的基础

2026-09-02 干徒
RustLLM张量

第 1 课:张量 Tensor —— 一切的基础

代码位置:src/tensor.rs 演示入口:src/main.rs

1. 本课要搞懂的问题

  1. 什么是张量?为什么深度学习离不开它?
  2. 计算机内存是"一维"的,多维数据怎么存?
  3. 矩阵乘法到底在算什么?代码怎么写?

2. 什么是张量

张量(Tensor)就是多维数组,是深度学习里所有数据的统一容器:

维度 名称 例子
0 标量 (scalar) 损失值 0.37
1 向量 (vector) 一个词被表示成 [0.1, -0.3, 0.8, ...]
2 矩阵 (matrix) 一批词向量堆在一起
3 3 维张量 一批文本(每条文本是一串词向量)
4 4 维张量 一批图像(高×宽×通道)

在我们的实现里,张量由两部分构成:

pub struct Tensor {
    pub data: Vec<f32>,      // 所有数值,展平成一维数组
    pub shape: Vec<usize>,   // 每个维度的大小
}

关键思想:内存只有一维,但我们可以通过 shape 决定"怎么解释"这些数。

3. 行优先存储(Row-Major)

假设我们要存一个 2×3 的矩阵:

[ 1 2 3 ]
[ 4 5 6 ]

行优先就是"一行一行地平铺":[1, 2, 3, 4, 5, 6]

那么"第 i 行第 j 列"的元素在数组里的下标是:

下标 = i * 列数 + j

例如矩阵里的元素 5,它在视觉上是"第 2 行第 2 列",但编程里的行号、列号都从 0 开始数, 所以行下标 i=1、列下标 j=1,列数=3: 下标 = 1*3 + 1 = 4data[4] = 5

容易混淆的点:"第 1 行" 和 "行下标 1" 不是一回事。 行下标 1 其实是指视觉上的第 2 行。Rust 数组下标(vec[i])也都是从 0 开始的。

我们把这段逻辑写成了通用函数 flat_index,支持任意维度:

fn flat_index(&self, index: &[usize]) -> usize {
    let mut flat = 0;
    for (i, &idx) in index.iter().enumerate() {
        flat = flat * self.shape[i] + idx;
    }
    flat
}

4. 逐元素运算(Element-wise)

两个形状相同的张量,对应位置各自做运算:

  • 加法:c[i] = a[i] + b[i]
  • 减法:c[i] = a[i] - b[i]
  • 乘法(Hadamard 积):c[i] = a[i] * b[i]

Rust 里用 zip 可以优雅地实现:

pub fn add(&self, other: &Tensor) -> Tensor {
    assert_eq!(self.shape, other.shape, "加法要求形状相同");
    Tensor {
        data: self.data.iter().zip(&other.data).map(|(a, b)| a + b).collect(),
        shape: self.shape.clone(),
    }
}

注意:逐元素乘法 * 和矩阵乘法 @ 是完全不同的两件事!很多人刚开始会混淆。

5. 矩阵乘法(Matrix Multiplication)

这是神经网络里最重要的运算。一个线性层 y = xW + b 本质就是矩阵乘法。

数学定义:设 A 是 m×k,B 是 k×n,则 C = A×B 是 m×n,其中:

C[i][j] = Σ(k) A[i][k] * B[k][j]

几何直觉:C 的第 i 行第 j 列 = A 的第 i 行 与 B 的第 j 列 的点积(对应位置相乘再求和)。

朴素实现(三重循环):

pub fn matmul(&self, other: &Tensor) -> Tensor {
    let (m, k1) = (self.shape[0], self.shape[1]);
    let (k2, n) = (other.shape[0], other.shape[1]);
    assert_eq!(k1, k2, "矩阵乘法维度不匹配");

    let mut result = vec![0.0; m * n];
    for i in 0..m {
        for j in 0..n {
            let mut sum = 0.0;
            for k in 0..k1 {
                sum += self.data[i * k1 + k] * other.data[k * n + j];
            }
            result[i * n + j] = sum;
        }
    }
    Tensor { data: result, shape: vec![m, n] }
}

验证一下:

[1 2]   [5 6]   [1*5+2*7  1*6+2*8]   [19 22]
[3 4] × [7 8] = [3*5+4*7  3*6+4*8] = [43 50]

程序输出正是 [19, 22, 43, 50]

提示:三重循环复杂度是 O(m·n·k)。真实框架会用各种优化(分块、SIMD、GPU), 我们后面课程会逐步改进,但先保证正确,再谈效率

6. 转置与 reshape

  • 转置 (transpose):m×n → n×m,C[j][i] = A[i][j]。后续反向传播中很常用。
  • reshape:只改变形状、不改变数据。[1,2,3,4,5,6] 可以是 2×3,也可以是 3×2,数据顺序不变。
let a = Tensor::from_vec(vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0], vec![2, 3]);
let b = a.reshape(vec![3, 2]);   // 形状变了,数据还是 [1..6]

7. 防御性编程习惯

好代码应该"出错早、出错明显"。我们做了两个校验:

assert_eq!(data.len(), numel, "数据长度与形状要求不一致");
assert!(idx < self.shape[i], "索引越界");

这样一旦传错参数,程序立刻 panic 并告诉我们哪里错了,而不是静默产生错误结果。

8. 运行与测试

cargo run      # 运行演示
cargo test     # 运行单元测试(5 个全部通过)

9. 动手练习

  1. 写一个函数 scalar_dot(a: &Tensor, b: &Tensor) -> f32,计算两个 1 维张量的点积。
  2. Tensor 加一个 apply(f: impl Fn(f32) -> f32) 方法,对每个元素应用函数 (这个能力下一课做激活函数时会用到)。
  3. 思考:matmul 为什么要求"A 的列数 == B 的行数"?如果 A 是 3×2、B 是 2×4,结果形状是什么?

10. 本课总结

  • 张量 = 数据 + 形状,内存用行优先存储
  • 学会了:创建、reshape、转置、逐元素运算、矩阵乘法
  • 验证了矩阵乘法结果的正确性
  • 下一课:给张量加上"梯度"和自动微分,让它能学习!
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 加速训练与推理