Files
maths-cs-ai-compendium-zh/chapter 16: SIMD and GPU programming/00. why C++ and how ML frameworks work.md
T
flykhan 2536c937e3 feat: 完整中文翻译 maths-cs-ai-compendium(数学·计算机科学·AI 知识大全)
翻译自英文原版 maths-cs-ai-compendium,共 20 章全部完成。

第01章 向量 | 第02章 矩阵 | 第03章 微积分
第04章 统计学 | 第05章 概率论 | 第06章 机器学习
第07章 计算语言学 | 第08章 计算机视觉 | 第09章 音频与语音
第10章 多模态学习 | 第11章 自主系统 | 第12章 图神经网络
第13章 计算与操作系统 | 第14章 数据结构与算法
第15章 生产级软件工程 | 第16章 SIMD与GPU编程
第17章 AI推理 | 第18章 ML系统设计
第19章 应用人工智能 | 第20章 前沿人工智能

翻译说明:
- 所有数学公式 $...$ / $$...$$、代码块、图片引用完整保留
- mkdocs.yml 配置中文导航 + language: zh
- README.md 已翻译为中文(兼 docs/index.md)
- docs/ 目录包含指向各章文件的 symlink
- 约 29,000 行中文内容,排除 .cache/ 构建缓存
2026-05-03 10:23:20 +08:00

420 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 为什么是C++以及ML框架如何工作
*本书中每一次 `jnp.matmul`、每一次 `torch.nn.Linear`、每一次 `np.dot` 调用,底层都在执行C++和CUDA代码。本文档揭开帷幕:为何ML框架采用这种架构,面向Python工程师的C++快速入门,何时编写自定义C++核函数,以及如何将其绑定到Python——这是连接你所写代码与所运行硬件之间的桥梁。*
- 你花了15章写Python。你导入了JAX,调用了`jax.grad`,运行了训练循环,构建了模型。一切感觉都像是Python。但事实是:**几乎没有实际计算发生在Python中。**
- 当你在PyTorch中写 `output = model(input)` 或在JAX中写 `output = jnp.matmul(W, x)` 时,Python几乎什么都不做。它构建一个计算的描述(一个操作图),然后将其交给执行真正工作的C++/CUDA后端。Python是方向盘;C++是引擎。
## 为什么Python前端搭配C++后端
- 这种双语言架构的存在是因为Python和C++擅长截然不同的事情:
| | Python | C++ |
|--|--------|-----|
| 开发速度 | 快(动态类型、REPL、无需编译) | 慢(静态类型、头文件、编译时间长) |
| 执行速度 | 比C慢约100倍(解释型、GIL) | 接近硬件速度(编译型、无开销) |
| 内存控制 | 自动(GC),无法控制布局 | 手动,精确控制每一个字节 |
| 硬件访问 | 无(无SIMD、无GPU、无自定义内存) | 全面(内联函数、CUDA、内联汇编) |
| 生态系统 | ML丰富(笔记本、可视化、数据) | 系统丰富(操作系统、驱动、引擎) |
- 核心见解:**每种语言发挥其优势**。Python处理人力生产力重要的事务(实验设计、超参数调优、数据探索)。C++处理机器性能重要的事务(矩阵乘法、卷积、注意力核函数)。
- 一次矩阵乘法 `jnp.matmul(A, B)`,其中 $A$ 为 $4096 \times 4096$,执行约1370亿次浮点运算。在纯Python(嵌套循环)中需要约30分钟。在使用AVX-512 SIMD和多线程优化后的C++中,只需约10毫秒。差距达**180,000倍**。再多的Python技巧也无法弥合这一鸿沟。
## ML框架的结构
- 每个主流ML框架都遵循相同的架构:
```
用户代码(Python
Python API层(torch.nn、jax.numpy、numpy
调度/JIT编译器(torch.compile、XLA、NumPy调度)
C++核函数库(ATen/PyTorch、XLA、BLAS/LAPACK
硬件特定后端(CUDA、cuDNN、MKL、oneDNN、Metal
硬件(CPU SIMD单元、GPU核心、TPU MXU
```
### NumPy
- NumPy的核心用C编写。当你调用 `np.dot(A, B)` 时,Python调用一个C函数,该函数调用BLAS(基本线性代数子程序),通常是Intel MKL或OpenBLAS。BLAS是手工优化的C和Fortran代码,使用SIMD指令、缓存感知的内存访问模式和多线程。数十年优化致力于让矩阵乘法更快。
- NumPy仅支持CPU,不使用GPU。但在CPU上,它极其快速,因为它委托给可用的最佳BLAS实现。
### PyTorch
- PyTorch的计算引擎是**ATen**(张量库),用C++编写。ATen实现了约2000个张量操作(add、matmul、conv2d、softmax...),每个都有CPU和CUDA后端。
- 当你调用 `torch.matmul(A, B)` 时:
1. Python调度到ATen的C++函数。
2. ATen检查设备(CPU或CUDA)和数据类型。
3. 在CPU上:调用MKL/OpenBLAS。在GPU上:调用cuBLASNVIDIA的GPU优化BLAS)。
4. 结果包装在Python张量对象中并返回。
- **torch.compile**PyTorch 2.0+)更进一步:它追踪你的Python代码,构建计算图,并使用**Triton**GPU)或**C++/OpenMP**(CPU)编译。编译后的代码融合操作,消除Python开销,可以比即时模式快2-5倍。
### JAX
- JAX将Python函数编译为**XLA**(加速线性代数),Google的ML编译器。当你 `jax.jit` 一个函数时:
1. JAX追踪函数,将操作捕获为XLA计算图(HLO——高级操作)。
2. XLA优化图:融合操作,消除冗余计算,优化内存布局。
3. XLA编译为目标后端:CPU(通过LLVM)、GPU(通过CUDA/PTX)或TPU(通过TPU特定指令)。
4. 编译后的代码直接在硬件上运行,零Python参与。
- 这就是为什么 `jax.jit` 如此重要:没有它,每个操作都是独立的Python→C++往返。有了它,整个函数是一个单一的编译核函数。
## 面向Python工程师的C++快速入门
- 你不需要成为C++专家。你需要理解足够的知识来阅读核函数代码、编写简单的扩展以及理解性能讨论。以下是精华内容。
### 类型和变量
```cpp
// C++需要显式类型(不像Python
int count = 0; // 32位整数
float loss = 0.5f; // 32位浮点数
double lr = 3e-4; // 64位浮点数
bool training = true; // 布尔值
// 数组(固定大小,栈分配)
float weights[1024]; // 1024个浮点数,内存中连续
// 指针:保存内存地址的变量
float* ptr = weights; // ptr指向weights的第一个元素
float val = ptr[42]; // 通过指针运算访问元素42
// ptr[42] 等价于 *(ptr + 42)
```
- **指针**是与Python最大的概念差异。在Python中,一切都是引用,你从不需要思考内存地址。在C++中,指针让你直接访问内存——强大但危险(悬空指针、缓冲区溢出)。
### 函数
```cpp
// 函数声明:返回类型 名字(参数类型 参数名)
float relu(float x) {
return x > 0.0f ? x : 0.0f;
}
// 传引用(避免拷贝大对象)
void scale_vector(std::vector<float>& vec, float factor) {
for (size_t i = 0; i < vec.size(); i++) {
vec[i] *= factor;
}
}
// const引用:只读,无拷贝
float sum(const std::vector<float>& vec) {
float total = 0.0f;
for (float x : vec) { // 基于范围的for循环(类似Python的for x in vec
total += x;
}
return total;
}
```
### 内存:栈与堆
```cpp
// 栈分配:快速,自动生命周期(函数返回时释放)
float buffer[256]; // 栈上的256个浮点数
// 堆分配:手动,在函数外仍然存活
float* data = new float[n]; // 在堆上分配n个浮点数
// ... 使用data ...
delete[] data; // 必须手动释放(没有垃圾回收器)
// 现代C++:智能指针(自动清理,类似Python引用)
#include <memory>
auto data = std::make_unique<float[]>(n); // 离开作用域时自动释放
```
- **关键规则**:栈快速但有限(通常1-8 MB)。大数组(张量、特征图)必须放在堆上。在Python中,一切都在堆上,GC处理清理。在C++中,你自行管理(或使用智能指针)。
### 模板(泛型)
```cpp
// 适用于任何数值类型的函数
template <typename T>
T add(T a, T b) {
return a + b;
}
add<float>(1.5f, 2.5f); // 返回 4.0f
add<int>(3, 4); // 返回 7
```
- 模板是C++库(如ATen)编写适用于float16、float32、float64等的代码而不重复实现的方式。
### 标准库精华
```cpp
#include <vector> // 动态数组(类似Python list
#include <string> // 字符串类型
#include <unordered_map> // 哈希映射(类似Python dict
#include <algorithm> // sort、find、transform等
#include <cmath> // 数学函数
std::vector<float> vec = {1.0f, 2.0f, 3.0f};
vec.push_back(4.0f); // 追加
float first = vec[0]; // 索引
size_t len = vec.size(); // 长度
std::unordered_map<std::string, int> counts;
counts["hello"] = 5; // 插入
if (counts.count("hello")) { } // 检查存在性
```
## 何时编写自定义C++核函数
- 大多数ML工程师从不需要写C++。框架的内置操作覆盖了99%的用例。仅在以下情况考虑自定义C++:
1. **框架中不存在你的操作**:新颖的激活函数、自定义注意力模式、无法表示为现有操作组合的特殊损失函数。
2. **融合操作以提高性能**:你的模型执行 `relu(layernorm(matmul(x, W) + b))`。每个操作启动一个独立的核函数,读写内存,并同步。一个融合核函数在一次遍历中完成所有工作,避免内存往返。这可快2-5倍。
3. **减少内存使用**:自定义核函数可以在不存储所有中间激活的情况下计算梯度(核函数级别的梯度检查点)。
4. **针对新型硬件**:新的加速器(如Cerebras、Groq)可能没有框架支持。你需要直接编写核函数。
- 对于情况1-2**Triton**(第16章文件05)通常足够且比直接编写CUDA C更简单。只有在Triton无法表达你的需求时才下降到CUDA C。
## 如何将C++绑定到Python
- 编写C++只是工作的一半。你还需要从Python调用它。
### pybind11(通用目的)
- pybind11用最少的样板代码为C++函数创建Python绑定:
```cpp
// my_ops.cpp
#include <pybind11/pybind11.h>
#include <pybind11/numpy.h>
namespace py = pybind11;
// 一个简单的自定义操作
py::array_t<float> custom_relu(py::array_t<float> input) {
auto buf = input.request();
float* ptr = static_cast<float*>(buf.ptr);
size_t n = buf.size;
auto result = py::array_t<float>(n);
float* out = static_cast<float*>(result.request().ptr);
for (size_t i = 0; i < n; i++) {
out[i] = ptr[i] > 0 ? ptr[i] : 0;
}
return result;
}
PYBIND11_MODULE(my_ops, m) {
m.def("custom_relu", &custom_relu, "自定义ReLU操作");
}
```
```bash
# 编译
pip install pybind11
c++ -O3 -shared -std=c++17 -fPIC $(python3 -m pybind11 --includes) my_ops.cpp -o my_ops$(python3-config --extension-suffix)
```
```python
# 从Python使用
import my_ops
import numpy as np
x = np.array([-1.0, 2.0, -3.0, 4.0], dtype=np.float32)
y = my_ops.custom_relu(x)
print(y) # [0. 2. 0. 4.]
```
### PyTorch C++扩展
- PyTorch提供了一种简化的方式来添加自定义操作:
```cpp
// custom_op.cpp
#include <torch/extension.h>
torch::Tensor custom_gelu(torch::Tensor x) {
return x * 0.5 * (1.0 + torch::erf(x / std::sqrt(2.0)));
}
PYBIND11_MODULE(TORCH_EXTENSION_NAME, m) {
m.def("custom_gelu", &custom_gelu, "自定义GELU激活函数");
}
```
```python
# 动态加载和编译
from torch.utils.cpp_extension import load
custom_ops = load(
name="custom_ops",
sources=["custom_op.cpp"],
extra_cflags=["-O3"],
)
x = torch.randn(1000)
y = custom_ops.custom_gelu(x)
```
- `torch.utils.cpp_extension.load` 编译C++代码,创建共享库,并将其作为Python模块加载,全在一个调用中完成。这是在PyTorch中实验自定义C++操作的最简单方式。
### JAX自定义调用
- JAX使用XLA自定义调用。过程更为复杂(你需要向XLA注册一个C函数),但概念相同:编写C/C++,绑定,从Python调用。
- 对于大多数JAX用户,**Pallas**(在文件05中介绍)是更好的选择:它让你用类似Python的语法编写GPU核函数,由XLA编译,无需离开JAX生态系统。
## 大局观
- 本文解释了Python和硬件之间的层次。本章剩余文件将深入探讨:
- **文件01**:硬件本身(CPU架构、GPU架构、内存系统)
- **文件02-03**CPU上的SIMD编程(ARM NEON、x86 AVX)——编写使用CPU向量单元的C++代码
- **文件04**:使用CUDA的GPU编程——编写在数千个GPU核心上运行的C++代码
- **文件05**Triton、Pallas和更高级的GPU编程——编写编译为GPU核函数的Python代码
- 这种递进反映了抽象阶梯:C++内联函数(最低层、最多控制)→ CUDA(GPU专用)→ Triton/PallasPython风格、编译型)→ JAX/PyTorch(最高层、自动)。每一层以控制权换取便利性。理解较低层使你成为较高层的更好使用者。
## 编程任务(用g++或clang++编译)
1. 编写你的第一个C++程序。分配一个数组,填充数据,计算总和,并测量时间。这介绍了编译、数组、指针和计时。
```cpp
// task1_basics.cpp
// 编译:g++ -O3 -o task1 task1_basics.cpp
// 运行:./task1
#include <iostream>
#include <chrono>
#include <vector>
int main() {
const int N = 10'000'000; // C++允许'作为数字分隔符
std::vector<float> data(N);
// 填充数组
for (int i = 0; i < N; i++) {
data[i] = static_cast<float>(i) * 0.001f;
}
// 计算总和
auto start = std::chrono::high_resolution_clock::now();
float sum = 0.0f;
for (int i = 0; i < N; i++) {
sum += data[i];
}
auto end = std::chrono::high_resolution_clock::now();
double elapsed = std::chrono::duration<double, std::milli>(end - start).count();
std::cout << "总和: " << sum << std::endl;
std::cout << "时间: " << elapsed << " ms" << std::endl;
std::cout << "元素数: " << N << std::endl;
std::cout << "吞吐量: " << (N * sizeof(float)) / elapsed / 1e6 << " GB/s" << std::endl;
return 0;
}
```
2. 编写一个C++函数在数组上计算ReLU,然后使用pybind11构建Python绑定。从Python调用它并与NumPy比较速度。
```cpp
// task2_relu.cpp
// 编译:c++ -O3 -shared -std=c++17 -fPIC $(python3 -m pybind11 --includes) \
// task2_relu.cpp -o my_relu$(python3-config --extension-suffix)
#include <pybind11/pybind11.h>
#include <pybind11/numpy.h>
namespace py = pybind11;
py::array_t<float> cpp_relu(py::array_t<float> input) {
auto buf = input.request();
float* ptr = static_cast<float*>(buf.ptr);
int n = buf.size;
auto result = py::array_t<float>(n);
float* out = static_cast<float*>(result.request().ptr);
for (int i = 0; i < n; i++) {
out[i] = ptr[i] > 0.0f ? ptr[i] : 0.0f;
}
return result;
}
PYBIND11_MODULE(my_relu, m) {
m.def("relu", &cpp_relu, "C++ ReLU");
}
```
```python
# test_relu.py — 在编译上述C++模块后运行
import numpy as np
import time
import my_relu # 编译后的C++模块
x = np.random.randn(10_000_000).astype(np.float32)
# C++ ReLU
start = time.time()
for _ in range(100):
y_cpp = my_relu.relu(x)
cpp_time = (time.time() - start) / 100
# NumPy ReLU
start = time.time()
for _ in range(100):
y_np = np.maximum(x, 0)
np_time = (time.time() - start) / 100
print(f"C++ ReLU: {cpp_time*1000:.2f} ms")
print(f"NumPy ReLU: {np_time*1000:.2f} ms")
print(f"匹配: {np.allclose(y_cpp, y_np)}")
```
3. 编写一个C++程序,演示为何内存布局很重要。比较行优先与列优先访问模式并测量性能差异。
```cpp
// task3_layout.cpp
// 编译:g++ -O3 -o task3 task3_layout.cpp
#include <iostream>
#include <chrono>
#include <vector>
int main() {
const int N = 4096;
std::vector<float> matrix(N * N, 1.0f);
// 行优先访问:连续内存地址(缓存友好)
auto start = std::chrono::high_resolution_clock::now();
float sum_row = 0.0f;
for (int i = 0; i < N; i++) {
for (int j = 0; j < N; j++) {
sum_row += matrix[i * N + j]; // 步长1访问
}
}
auto end = std::chrono::high_resolution_clock::now();
double row_ms = std::chrono::duration<double, std::milli>(end - start).count();
// 列优先访问:步长N访问(缓存不友好)
start = std::chrono::high_resolution_clock::now();
float sum_col = 0.0f;
for (int j = 0; j < N; j++) {
for (int i = 0; i < N; i++) {
sum_col += matrix[i * N + j]; // 步长N访问(缓存缺失!)
}
}
end = std::chrono::high_resolution_clock::now();
double col_ms = std::chrono::duration<double, std::milli>(end - start).count();
std::cout << "行优先(缓存友好): " << row_ms << " ms" << std::endl;
std::cout << "列优先(缓存不友好): " << col_ms << " ms" << std::endl;
std::cout << "减速比: " << col_ms / row_ms << "x" << std::endl;
std::cout << "(两个和: " << sum_row << ", " << sum_col << "" << std::endl;
return 0;
}
```