跟着LAP啥都学 跟着LAP啥都学

第 0 章 环境准备与第一次运行

      +

      本章导读:把环境一次性配置到位,并用 chapter00_hello_torch 过一遍 LibTorch 的核心 C++ 写法。 环境就绪后,后面十几章不再需要改动环境。

      学完本章你将能够:

      1. 一次备齐工具链、数据集、分词器与 GPT-2 权重;

      2. 看懂 scripts/build.sh 与 scripts/run.sh 背后的 CMake 构建组织;

      3. 掌握张量创建、索引、广播、自动求导与 nn::Module 的基本写法——全书模型的底层零件;

      4. 记住全书的可复现性约定:固定种子、禁用 TF32、train/eval 切换。

      产出与代码:scripts/、cmake/、chapters/chapter00_hello_torch/。

      0.1 硬件与软件要求

      全书不依赖 GPU,没有显卡也能编译并运行全部章节。真正的门槛在版本配套: LibTorch 与 CUDA 必须互相匹配。新手环境问题大多出在这里。请直接使用下表实测过的组合:

      组件 要求 说明

      LibTorch

      2.x CUDA 版

      实测 2.13.0+cu130;解压到 /opt/libtorch(或任意目录,用 TORCH_ROOT 指定)

      CUDA Toolkit

      与 LibTorch 配套

      实测 CUDA 13.0;没有 GPU 也能编译运行全部章节

      编译器

      GCC ≥ 8(C++17)

      实测 GCC 11;系统默认版本过旧时,用 -DCMAKE_CXX_COMPILER=/usr/bin/g++-11 指定

      CMake

      ≥ 3.16

      —

      RE2

      libre2-dev

      仅第 2 章需要:torchtext 官方 GPT-2 BPE 的正则依赖

      Ollama

      任意近期版本

      仅第 7 章 LLM-as-judge 评估用;不装不影响 0–6、8–13 章

      GPU

      可选

      有则自动启用;medium(355M)训练建议 ≥ 12 GB 显存

      LibTorch 解压后的目录结构如下。后面遇到 cannot open shared object file 类报错时, 先确认这几个 .so 文件的位置:

      /opt/libtorch
      ├── include/
      ├── lib/
      │   ├── libtorch.so
      │   ├── libtorch_cpu.so
      │   └── libtorch_cuda.so
      └── share/cmake/Torch/

      0.2 一键准备

      仓库 scripts/ 把「下载数据 → 下载权重 → 启动 Ollama → 构建」四件事统一成一个入口:

      ./scripts/setup.sh --all

      脚本幂等:数据或权重已存在时自动跳过,中途中断后重跑即可。下载优先直连 (hf-mirror、github raw),失败自动回退代理。脚本无报错退出后,数据与权重就位, 构建完成,可直接跳到 0.5 运行验证。

      0.3 分步准备

      首次安装建议分步执行,便于确认每一步的结果:

      ./scripts/download-data.sh          # 各章数据集:the-verdict.txt、GPT-2 词表、SMS Spam、指令数据
      ./scripts/download-weights.sh       # GPT-2 small/medium 官方权重(约 2 GB)
      ./scripts/start-ollama.sh --pull    # 启动 Ollama + 拉取 llama3(仅第 7 章用,约 4.7 GB)
      ./scripts/build.sh                  # 配置并构建全部章节

      四条命令的输出会显示各自的进度与结果;全部成功后,进入 0.5 运行验证。 另外三个细节能省下不少排查时间:

      • 下载源可切换:download-weights.sh 支持 HF_MIRROR=huggingface 切回官方源, 或 ALL_PROXY=socks5h://host:port 走代理。大陆网络默认走 hf-mirror,实测约 10 MB/s。

      • 路径集中管理:所有路径集中在 scripts/common.sh。换机器时,修改其中的 LIBTORCH_ROOT、CUDA_VERSION 等变量即可,不用在仓库里逐处搜索。

      • 构建缓存异常时清理重建:build.sh --clean 会删除 build/ 并重新配置。 遇到原因不明的编译错误,先执行它再排查。

      0.4 构建系统概览

      顶层 CMakeLists.txt 经 chapters/CMakeLists.txt 把第 0–7 章的子目录 add_subdirectory 进来,即把每章作为子工程纳入同一次构建;每章同时也是一个 可独立构建的子工程。第 8–13 章不进统一构建:chapter08_training_loop、 chapter09_lora、chapter10_serving、chapter11_nl2bash、chapter12_toolcalling、 chapter13_reasoning 六个独立工程,单独 cmake -S 配置或用各自 scripts/ 构建, 详见各自 CMakeLists.txt 头注与对应章节。

      定位 LibTorch 的逻辑集中在 cmake/libtorch.cmake,查找顺序为: -DTORCH_ROOT=<dir> → 环境变量 $TORCH_ROOT → 默认 /opt/libtorch。 该文件还提供 add_libtorch_executable(<target> <source>) 函数,于是每章的 CMakeLists.txt 只需要三行。新增章节时套用这个模板:

      list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_LIST_DIR}/../../cmake")
      include(libtorch)
      add_libtorch_executable(chapter00_hello_torch main.cpp)

      第 0–7 章的构建产物统一落在 build/chapters/<章节名>/ 下; 独立构建,产物在各自目录的 build/ 下。

      0.5 运行入口

      运行第 0–7 章的程序,统一使用 scripts/run.sh:

      ./scripts/run.sh chapter00_hello_torch

      run.sh 做三件事:

      1. 在 build/chapters/ 下定位可执行文件,参数支持完整目录名或去掉前缀的章节名;

      2. 设置 LD_LIBRARY_PATH 指向 LibTorch 与 CUDA 运行库。绕开脚本直接运行可执行文件时, 报 libtorch.so: cannot open shared object file,原因就是缺了这一步;

      3. 把剩余参数透传给程序,例如:

      ./scripts/run.sh chapter05_pretraining 10        # 覆盖预训练轮数

      0.6 张量热身

      chapters/chapter00_hello_torch/main.cpp 是一个自包含程序,不依赖任何数据集, 编译后即可运行。第 2–7 章、第 9–13 章会用到的 LibTorch C++ API, 大多在源码内按 §A–§K 分节演示。本节按「后面哪里会用到」逐段讲解。

      §A 设备、种子与 TF32:每章 main() 的开头设置

      每章程序的 main() 都以同样几行开场:

      torch::Device select_device() {
          if (torch::cuda::is_available()) return torch::Device(torch::kCUDA);
          return torch::Device(torch::kCPU);
      }
      
      torch::Device device = select_device();            // 优先 CUDA,无 GPU 时回落 CPU
      torch::manual_seed(123);                           // 固定随机种子
      at::globalContext().setAllowTF32CuBLAS(false);     // 教学约束:禁用 TF32

      这几行对应两件事:

      • manual_seed(123) 固定 PyTorch 的全局随机种子。此后每次运行, 随机初始化得到的数值完全一致——这是全书数值可复现的基础。

      • 禁用 TF32:TF32 是 NVIDIA Ampere 之后 GPU 上 matmul 的默认加速格式 (19 位格式,尾数 10 位),速度更快、精度略降。若保持开启,同一份权重 在 GPU 上算出的 loss 会比 CPU 的 float32 结果在小数点后几位上出现偏差, 难以核对。 本书为保证实验逐位一致而统一关闭 TF32,代价是速度略慢。

      生产训练请删除这一行,重新打开 TF32——那是工程取舍,不是对错问题。 教学要求数值可核对,生产要求跑得快。

      §B–§D 张量工厂、形状变换与运算

      auto x = torch::tensor({{1.0f, 2.0f, 3.0f},
                              {4.0f, 5.0f, 6.0f}});       // (2,3) Float
      auto w = torch::tensor({{1.0f}, {1.0f}, {1.0f}});   // (3,1)
      
      auto y_mat  = x.matmul(w);                          // (2,3)@(3,1) = (2,1)
      auto y_soft = torch::softmax(x, /*dim=*/1);         // 沿最后一维归一化
      auto y_mask = x.masked_fill(x > 3.0f, -1e9);        // 条件填充 → 注意力掩码的雏形
      auto y_topk = std::get<0>(x.topk(/*k=*/2, /*dim=*/1));  // 每行 top-2 → 解码策略用

      这段代码覆盖了第 3 章注意力机制的全部运算:matmul 计算相似度, softmax 把分数归一成权重,masked_fill 屏蔽「未来」位置, topk 用于第 5 章解码时的 Top-k 采样。

      索引与切片是另一族高频写法,从第 3 章起反复出现;这一族在 chapter00_hello_torch 源码里 没有集中演示,先在这里认识语法:

      auto row = x.index({1});                             // 第 2 行(下标从 0 起)→ 形状 (3)
      auto col = x.index({torch::indexing::Slice(), 2});   // 每行第 3 列 → 形状 (2)

      .index({…​}) 按维度从高到低写位置:单下标取一行,Slice() 表示这一维全取, -1 表示最后一个元素。三维张量同构:第 5 章生成循环用 logits.index({torch::indexing::Slice(), -1, torch::indexing::Slice()}) 取出每个样本最后一个词元的 logits;第 3 章取查询向量、第 6 章分类头取 「最后一个词元」,用的都是这族写法。

      形状变换一族(reshape/view/transpose/permute/contiguous)将在多头注意力的 「权重切分 + batched matmul」里反复出现,第 3 章展开。

      §E 自动求导

      auto xg = torch::tensor({1.0, 2.0, 3.0}, torch::kFloat).requires_grad_(true);
      auto yg = (xg * xg).sum();          // y = Σx²,dy/dx = 2x
      yg.backward();
      std::cout << xg.grad();             // [2, 4, 6]

      这三行是全书训练代码的引擎。先说明它解决什么问题:

      训练的目标是调整模型的参数(对 GPT-2 来说是上亿个数字),让输出更接近期望。 调整方向取决于每个参数的梯度:把某个参数稍微调大,loss 会变大还是变小、变化多快。 参数数量太大,无法手工推导,所以由框架自动计算——这就是「自动求导」(autograd)。

      三行代码各自的作用:

      1. requires_grad_()(带下划线,表示原地修改):让框架记下这个张量之后参与的每一步运算;

      2. (xg * xg).sum():普通前向计算,框架同时把运算记录成一张图;

      3. backward():从 y 出发沿记录的图反向遍历,用链式法则算出偏导数,写入 xg.grad()。

      输出 [2, 4, 6] 可以手算验证:\(y = x^2\) 的导数是 \(2x\),对 \(x_1 = 1\)、\(x_2 = 2\)、\(x_3 = 3\) 分别是 2、4、6。先手推、再运行验证——这个习惯在第 3 章、第 8 章会反复用到。

      一条容易踩坑的工程纪律:backward() 的建图记录占用内存,而推理、评估阶段 不需要梯度。这两类代码要用 torch::NoGradGuard 包住以省显存, 第 5 章评估 loss、第 7 章推理生成都依赖它。没包住也不会报错, 症状是推理阶段显存占用涨到训练水平,运行变慢,严重时 OOM。

      §F–§I nn::Module 子类化

      下面是注册一个模块的完整写法,全书所有模型都沿用这五个约定:

      class DemoModuleImpl : public torch::nn::Module {
       public:
          DemoModuleImpl(int64_t in_dim, int64_t hidden_dim) {
              fc_      = register_module("fc", torch::nn::Linear(in_dim, hidden_dim));
              dropout_ = register_module("drop", torch::nn::Dropout(0.1));
              scale_   = register_parameter("scale", torch::tensor(1.0).reshape({1}));
              bias_    = register_buffer("bias", torch::arange(0, hidden_dim, torch::kFloat));
          }
          torch::Tensor forward(torch::Tensor x) {
              auto h = torch::relu(fc_->forward(x));
              h = h * scale_;                       // 广播:(B,H) * (1,)
              h = h.masked_fill(h <= 0, -1e9);
              return h.mean(/*dim=*/-1, /*keepdim=*/true);
          }
       private:
          torch::nn::Linear fc_{nullptr};
          torch::nn::Dropout dropout_{nullptr};
          torch::Tensor scale_;
          torch::Tensor bias_;
      };
      TORCH_MODULE(DemoModule);   // 生成 ModuleHolder<DemoModuleImpl>

      forward 里有一行值得停一下:h * scale_ 是全书第一次出现广播(broadcast), 即形状 (B,H) 与 (1,) 相乘。规则:两个形状从最后一维向前逐维对齐, 大小为 1 的维度自动复制扩展到对方——(1,) 扩成 (B,H),逐元素相乘后形状不变。 第 2 章把位置嵌入 [4,256] 加到 [8,4,256] 上,走的是同一条规则。

      约定 说明

      实现类叫 XxxImpl

      真正的逻辑写在 *Impl 类里,TORCH_MODULE(Xxx) 生成外层智能指针包装 Xxx——这是 LibTorch 的命名约定,std::shared_ptr 语义。

      register_module / register_parameter / register_buffer

      三种注册各司其职:子模块参与 parameters() 遍历与序列化; 可训练参数由优化器收集;buffer 存常量,随模型迁移设备但不求梯度—— 第 3 章的因果掩码就用 buffer 存。

      forward(x) 只写计算图

      model(x) 与 model→forward(x) 等价。

      train() / eval()

      切换 Dropout/BN 行为;第 6 章评估准确率前必须调用 eval()。

      XxxImpl 必须放全局命名空间

      放进匿名 namespace 会切断 torch::save/load 的 ADL 查找,导致编译失败。 源码注释里专门标了这个坑。

      参数与子模块的遍历接口,第 4 章用来统计参数量,第 6/9 章用来冻结参数:

      for (const auto& p : model->parameters()) { ... }          // 优化器入口
      for (const auto& np : model->named_parameters(/*recurse=*/true)) { ... }  // 名字 + 形状
      for (const auto& nm : model->named_modules()) { ... }      // 子模块树

      §H 优化器与训练五步

      auto opt = torch::optim::AdamW(
          model->parameters(),
          torch::optim::AdamWOptions(1e-3).betas({0.9, 0.95}).weight_decay(0.01));
      
      opt.zero_grad();                       // 1. 清零梯度
      auto loss = ((logits - target).pow(2)).mean();   // 2. 前向 + 算 loss
      loss.backward();                       // 3. 反向传播
      opt.step();                            // 4. 更新参数
      // 5.(循环回到 1)

      所有训练循环都是这五步的展开:清梯度 → 前向 → 反向 → 更新 → 回到开头。 第 5–9 章的训练代码同此模板。

      每次迭代都必须调用 zero_grad(),因为 PyTorch 的梯度是累积的: backward() 把新梯度加到 p.grad() 上,而不是覆盖。 跨 batch 累积梯度等场景依赖这一行为。每轮开始前要手动清零, 否则本轮梯度会混入上一轮的值,更新方向出错。

      动态调整学习率由一行调用完成,第 8 章的预热与余弦衰减依赖它:

      opt.param_groups().front().options().set_lr(1e-4);

      §J 序列化:整模型存取

      torch::save(model, "/tmp/demo.pt");
      torch::load(model_loaded, "/tmp/demo.pt");

      整模型存取就这两行。第 5 章会改为「只存权重」(state_dict 视角), 并比较两种方式的取舍;加载 OpenAI 官方权重则使用第 5 章的 safetensors 解析器。

      §A–§K 全景速查表

      下表汇总 §A–§K 的主题与用途,后续章节用到时可随时翻回本表:

      节 主题 后面哪里用

      A

      设备 / seed / TF32

      每章 main() 开头

      B

      张量工厂(tensor/zeros/ones/arange/randn/from_blob)

      第 2 章嵌入层

      C

      形状变换(reshape/view/transpose/permute/contiguous)

      第 3 章多头注意力

      D

      运算(matmul/softmax/cat/masked_fill/argmax/topk)

      第 3–5 章

      E

      autograd(requires_grad_/backward/NoGradGuard)

      第 5–9 章

      F

      nn::Module 子类化(register_* / TORCH_MODULE)

      第 3–4 章起全部模型

      G

      现成模块(Linear/Embedding/Dropout)与 functional

      第 2–4 章

      H

      AdamW 与训练五步、动态 lr

      第 5–9 章

      I

      parameters/named_parameters/named_modules 遍历

      第 4/6/9 章(数参数、冻结)

      J

      torch::save/load

      第 5 章(state_dict 详解)

      K

      c10::optional / TensorOptions

      第 5 章(top_k 可选参数)

      0.7 验证:预期输出

      构建完成后运行程序:

      ./scripts/run.sh chapter00_hello_torch

      正常输出如下(节选):

      === Chapter 00: Hello LibTorch ===
      Torch version: 2.13.0
      
      ----- A. Device & Global Settings -----
      CUDA available: yes
      Using device: cuda            ← 无 GPU 的机器显示 cpu
      manual_seed(123) done on CPU; weights will migrate to device later
      TF32 (CuBLAS) disabled to match book floats
      
      ----- B. Tensor Factories -----
      x  shape=[2, 3] dtype=Float device=cpu
      rng:
       0.4869  0.7131  0.2427
       0.5931  0.1054  0.3393      ← 随机数每次相同(seed 已固定)
      ...
      ----- E. Autograd -----
      y = sum(x*x) ; grad at x:  2  4  6     ← 与手推一致,确定性输出
      ...
      trainable params (DemoModule) = 17    ← Linear(3→4): 12+4,再加 scale 1 个
      fc weight shape: [4, 3]
      ...
      one train step loss=0.6xxx
      after set_lr: 0.0001
      
      ----- End of Chapter 00 prelude -----
      Original first demo  -> x @ w =
       6
      15                                    ← 确定性输出

      重点核对三处确定性输出:grad = [2, 4, 6]、x @ w = [6, 15]、 可训练参数 = 17。三者与上面一致,说明环境正常,可以进入第 1 章; 不一致则多半是编译链接到了错误版本的 LibTorch,回到 0.1 核对版本配套。

      本章小结

      • 环境与构建:scripts/setup.sh --all 一键完成数据、分词器、GPT-2 权重下载与构建; scripts/run.sh 统一了第 0–7 章程序的运行入口。

      • 核心写法:chapter00_hello_torch 的 main.cpp 通过 §A–§K 的示例给出 LibTorch 的核心 API—— 张量创建与索引、广播、autograd、nn::Module 注册约定。

      • 工程约定:CPU 上固定种子初始化后迁移设备、禁用 TF32 保证数值可复现、 train()/eval() 模式切换。

      • 验收标准:chapter00_hello_torch 的全部确定性输出与本章示例逐位一致。

      自测清单

      能不回看就答出这些,本章就算过关:

      1. ./scripts/setup.sh --all 无报错结束(或四条分步命令各自成功)

      2. 第 0–7 章程序在 build/chapters/ 下生成;第 8–13 章对应目录按各自 CMakeLists 独立构建成功

      3. ./scripts/run.sh chapter00_hello_torch 输出 Torch version 与设备信息

      4. grad = [2, 4, 6] 与 x @ w = [6, 15] 与上面一致

      5. 有 GPU 的机器:CUDA available: yes;无 GPU 也能全部跑通

      第 10–13 章的四个独立工程(chapters/chapter10_serving、chapters/chapter11_nl2bash、 chapters/chapter12_toolcalling、chapters/chapter13_reasoning)有各自的构建入口: chapters/chapter10_serving 与 chapters/chapter13_reasoning 用各自目录里的 ./scripts/build.sh,chapters/chapter12_toolcalling 用 ./scripts/run.sh build, chapters/chapter11_nl2bash 直接 cmake -B build && cmake --build build -j。 构建方式与本章相同,到对应章节再构建即可。

      习题

      1. 把 DemoModuleImpl 的 hidden_dim 从 4 改成 8,先手算可训练参数个数, 再运行验证(提示:fc 变为 3×8+8,scale 不变)。

      2. 把 forward 里 masked_fill(h ⇐ 0, -1e9) 的 -1e9 改成 0.0, 观察输出变化,并解释第 3 章的注意力掩码为什么必须用「极大的负数」。

      3. 用 §B 的工厂函数创建一个 (2,3,4) 张量,用 permute 变换为 (2,4,3), 再用 contiguous() + view({-1}) 展平——这是第 3 章多头切分的标准操作。

      拓展阅读

      • 主仓库环境页:docs/modules/ROOT/pages/prerequisites.adoc

      • LibTorch 定位与帮助函数:cmake/libtorch.cmake;路径与下载函数集中管理:scripts/common.sh

      • LibTorch 官方 API 参考: pytorch.org/cppdocs

      未接触过深度学习?先读 附录 A:深度学习的心智模型 —— 梯度、反向传播、batch、概率采样,从零讲透,再回到主线。