第01讲:AI Infra 工程师的 C++ 与 Python 底层必修课
主讲人:👓 Ringi(AI Infrastructure 工程师)
所属模块:Module 00:性能工程与系统前置
关键词:C++17 / RAII / 智能指针 / 移动语义 / 模板 / PyBind11 / ATen / PyTorch C++ Extension / Autograd
学习目标:建立从 Python API 一路追踪到 C++、ATen、CUDA/设备执行层的能力,并能够独立编写、编译、导入和验证一个最小 PyTorch C++ 扩展。
AI Infra 处在算法、框架、编译器、操作系统和硬件的交界处。同一个问题往往要在多种语言和工具之间来回切换:用 Python 描述模型与实验,用 C++ 实现性能敏感路径,用 CUDA 驱动 GPU,再用 Linux 工具定位进程、内存、动态库和网络问题。
本讲不追求把 C++ 语法完整背一遍,而是建立一条贯通算力底座的主线:能读懂、能修改、能调试,也知道每一微秒性能成本与显存开销发生在哪里。
📑 目录导航
- 写在前面:这不是一篇“C++ 语法大全”
- 0. Ringi 开场:为什么会 Python 还远远不够?
- 1. 现代 C++(C++11/14/17)核心必备武器库
- 2. Python C 扩展与 PyTorch 桥梁:PyBind11 实战
- 3. PyTorch C++ 扩展:从 torch::Tensor 到 ATen
- 3.1 PyTorch 的 C++ 底座:ATen
- 3.2 at::Tensor 不是“整块数据对象”
- 3.3 为什么 Tensor View 可以很便宜?
- 3.4 最小 PyTorch C++ Extension
- 3.5 用 torch.utils.cpp_extension.load JIT 编译
- 3.6 JIT load() 在背后做了什么?
- 3.7 torch.utils.cpp_extension.load 常见参数
- 3.8 AOT:不要每次启动程序都现场编译
- 3.9 更正式的 PyTorch Custom Operator:Dispatcher 注册
- 3.10 自定义 C++ 算子如何保持 Autograd 可导?
- 情况 A:C++ 函数内部只组合已有 ATen 可导算子
- 情况 B:你自己写了一个“黑盒” CPU/CUDA Kernel
- 3.11 torch.autograd.Function 和 Custom Operator 是一回事吗?
- 4. 动手实战:从 0 跑通一个 C++ Extension
- 5. Ringi 避坑指南
- 6. 大厂面试高频题
- 面试题 1:std::unique_ptr 与 std::shared_ptr 区别?
- 面试题 2:RAII 为什么对 CUDA 显存管理重要?
- 面试题 3:std::move 为什么能提高大对象传递性能?
- 面试题 4:Python list 传到 const std::vector
& 是 Zero-Copy 吗? - 面试题 5:为什么 PyTorch Tensor 复制句柄很便宜?
- 面试题 6:PyTorch Tensor 的 shape 与 stride 分别是什么?
- 面试题 7:PyTorch 自定义 C++ 算子怎样支持 Autograd?
- 面试题 8:为什么 C++ 扩展可能比 Python 还慢?
- 面试题 9:什么时候释放 GIL?
- 面试题 10:为什么不能返回局部对象引用?
- 7. Debug:C++ Extension 出错怎么定位?
- 8. 性能工程视角:这节课真正要建立的思维
- 9. 本讲自测 Checklist
- 10. 课后练习
- 11. Ringi 总结
- 12. 下一讲承接
- 附录 A:最小代码包
- 附录 B:参考资料
写在前面:这不是一篇“C++ 语法大全”
很多同学第一次接触 AI Infra,会有一个很自然的疑问:
“我会 Python,也会
import torch,模型能训练、能推理,为什么还要学 C++?”
如果你的目标只是调用现成模型,Python 确实已经足够强大。
但如果你的目标是下面这些工作:
- 优化大模型推理延迟;
- 修改 vLLM / TensorRT-LLM / SGLang 的核心执行路径;
- 编写 CUDA Kernel;
- 定位 PyTorch 算子性能问题;
- 处理 Tensor 的生命周期、显存与 Stream;
- 排查 NCCL、CUDA Runtime、动态链接库问题;
- 为 PyTorch 增加自定义算子;
- 阅读框架内部 Dispatcher / ATen / C10 代码;
- 做 CPU / GPU 性能工程;
那么只会 Python 很快就会撞到一堵墙。
AI Infra 的真实调用链通常更像这样:
你在 Python 里写下:
| |
看起来只是一行代码,但真正干活的部分通常并不在 Python。
AI Infra 工程师最重要的能力,不是背多少 C++ 语法,而是能沿着这条调用链向下追踪:数据在哪里、谁拥有它、什么时候释放、有没有复制、在哪个设备执行、由哪个线程或 Stream 调度。
0. Ringi 开场:为什么会 Python 还远远不够?
0.1 Python 更像“控制面”,C++ 更像“数据面”

可以把 AI Infra 中常见的语言分工粗略理解为三层:
| 层次 | 常见技术 | 主要职责 |
|---|---|---|
| 编排层 | Python / Shell | 模型定义、实验、训练脚本、任务编排 |
| 系统层 | C / C++ | Tensor 实现、调度、内存管理、通信、Runtime |
| 设备层 | CUDA C++ / Triton | GPU Kernel、访存优化、并行计算、算子融合 |
Python 的最大优点是开发效率高。
C++ 的最大优点是:
- 生命周期可控;
- 内存布局可控;
- 可以直接调用系统 API;
- 可以接入 CUDA / NCCL / Driver;
- 模板可以在编译期生成高性能特化代码;
- 没有 Python 对象模型带来的逐元素动态开销。
因此实际系统一般是:
Python 负责“我要做什么”,C++ / CUDA 负责“具体怎么高效做”。
0.2 Python 的性能开销到底在哪里?
0.2.1 解释器与动态分派
考虑一个纯 Python 循环:
这里每一次 x + y 都不是简单的一条 CPU 浮点加法指令。
解释器需要处理:
- Python 对象;
- 类型检查;
- 操作符分派;
- 引用计数;
- Python list 的对象指针;
- 动态内存管理。
而连续数组上的 C++ 循环:
编译器可以进一步:
- 内联;
- 自动向量化;
- 展开循环;
- 使用 SIMD;
- 消除不必要的边界检查。
0.2.2 Python 对象不是“裸数据”
在 C++ 中:
| |
x 可以直接对应内存中的 4 个字节浮点数据。
而 Python:
| |
x 更准确地说是一个名字,绑定到一个 Python 对象。
这个对象除了数值本身,还需要类型、引用计数等运行时元数据。
因此:
| |
也不是一段紧凑的三个 float。
Python list 本质上更接近:
而 Tensor / NumPy 数组通常更接近:
这就是为什么 AI 系统如此强调:
- contiguous;
- dtype;
- shape;
- stride;
- storage;
- data pointer;
- zero-copy。
0.2.3 GIL:要理解,但不要背成一句口号
传统 CPython 默认构建中存在 GIL(Global Interpreter Lock)。
这意味着在一个进程中,多个线程通常不能同时执行 Python 字节码。
所以:
通常不会因为有两个 CPU Core 就自动获得 2 倍性能。
但是下面这种理解也是错的:
“有 GIL,所以 Python 线程完全没用。”
实际情况是:
- 阻塞 I/O 期间可以让其他线程运行;
- NumPy / PyTorch 等原生扩展可以把耗时计算放到 C/C++;
- C/C++ 扩展可以在安全条件下释放 GIL;
- GPU Kernel 启动后,实际计算发生在 GPU 上。
另外,从 Python 3.13 开始,CPython 已经提供可选的 free-threaded 构建,可以在特定构建中关闭 GIL。但不能因此假设生产环境中的所有 Python 都已经没有 GIL。写扩展时仍然要理解 Python C API、线程状态与 GIL 的边界。
0.3 什么时候应该下沉到 C++?
不要一看到 Python 就重写成 C++。
推荐优化顺序:
如果一个函数只执行 2 微秒,但你调用它 100 万次,那么真正的问题可能不是 C++ 算得不够快,而是:
边界调用太碎。
AI Infra 中常见的正确优化不是:
| |
而是:
| |
1. 现代 C++(C++11/14/17)核心必备武器库
1.1 先建立物理直觉:变量、地址、指针与引用
在 64 位 Linux 进程里,每个进程看到的是自己的虚拟地址空间。
例如:
可能输出:
其中:
| |
表示取变量 x 的地址。
1.2 指针:保存地址的变量
此时:
通过:
| |
可以访问该地址上的对象。
| |
此时:
| |
1.3 引用:已有对象的别名
引用与指针的典型差异可以先这样记:
| 项目 | 指针 | 引用 |
|---|---|---|
| 语法 | T* | T& |
| 可为空 | 可以 | 正常引用语义下不应为空 |
| 可重新指向 | 可以 | 初始化后绑定同一个对象 |
| 常见语义 | 可选对象、数组地址、底层 API | 非拥有访问、函数参数 |
在 AI Infra 代码中你会经常看到:
| |
意思通常是:
我需要访问
x,但这个函数不拥有x,也不打算复制x这个 C++ 句柄。
1.4 const T& 为什么如此常见?
假设:
如果写:
| |
按值传递可能需要构造一个新的 Config。
如果只读:
| |
通常更合适。
但注意:
对
torch::Tensor这种本身就是轻量引用计数句柄的类型,“按值传 Tensor”与“复制整块 Tensor 数据”完全不是一回事。
这是后面理解 at::Tensor 的关键。
1.5 栈、堆与“真正应该关心的东西”:生命周期
初学 C++ 时常听到:
- 栈;
- 堆。
但工程中更重要的问题是:
这个对象活多久?谁负责释放?
例如:
函数结束后,p 这个指针变量没了,但 new int(42) 分配的内存还在。
没有人再能释放它。
这叫:
| |
再看:
delete 后仍然访问:
| |
这就是悬空指针问题。
1.6 RAII:现代 C++ 最重要的思想之一
RAII = Resource Acquisition Is Initialization
不要只把它理解成一句翻译:
资源获取即初始化。
真正工程意义是:
把资源的生命周期绑定到 C++ 对象生命周期。
对象进入作用域时拿资源。
对象离开作用域时自动释放资源。
1.6.1 手工资源管理为什么危险?
如果:
| |
中间抛异常,fclose 可能永远不会执行。
1.6.2 RAII 写法
| |
调用:
即使 do_something() 抛异常,栈展开时 File::~File() 仍然执行。
1.6.3 RAII 在 AI Infra 中管理什么?

不仅是 CPU 内存。
它可以管理:
例如概念上的 CUDA Buffer:
这才是“大厂底层代码不喜欢裸 malloc/free、裸 cudaMalloc/cudaFree 满天飞”的根本原因。
严格来说,不是“永远不能手写 malloc/free”,而是资源所有权必须被封装,异常路径和提前返回路径也必须安全。
1.7 智能指针:让所有权写进类型系统
C++ 中最常用:
1.7.1 std::unique_ptr
唯一所有权。
| |
语义:
| |
不能普通复制:
| |
可以移动:
| |
此后所有权转移给 b。
推荐原则
如果资源不需要共享所有权,优先
unique_ptr。
1.7.2 std::shared_ptr
共享所有权。
内部维护引用计数:
当最后一个拥有者销毁时,对象才释放。
代价包括:
- 控制块;
- 引用计数;
- 多线程下引用计数通常需要原子操作;
- 循环引用问题。
因此:
shared_ptr不是“懒得想生命周期时的默认答案”。
1.7.3 std::weak_ptr
用于观察 shared_ptr 管理的对象,但不增加强引用计数。
典型用途是打破循环引用。
1.8 移动语义:std::move 到底做了什么?

这是面试高频坑点。
先看:
这里 b = a 是复制。
可能需要重新申请大块内存,然后复制所有元素。
1.8.1 移动
| |
对于 std::vector,通常可以直接把:
- data pointer;
- size;
- capacity;
转移给 b。
概念上:
真正的数据 Buffer 不需要逐元素复制。
1.8.2 关键纠正:std::move 本身并不“移动任何数据”
std::move(x) 本质上是把表达式转换成可以匹配右值引用的形式。
它告诉编译器:
“如果目标类型支持移动语义,现在可以把这个对象的资源拿走。”
真正执行资源转移的是:
| |
或者:
| |
也就是移动构造 / 移动赋值。
1.8.3 不要把 std::move 神化成“Tensor 零拷贝按钮”
例如 PyTorch 的 at::Tensor 本身是一个引用计数句柄。
复制:
| |
并不等于复制整块 GPU Storage。
很多情况下只是让两个 Tensor 句柄指向同一个底层 TensorImpl,并更新引用计数。
所以面试中回答:
“大 Tensor 一定要
std::move,否则会复制几 GB 显存。”
这是不准确的。
正确回答应该是:
对拥有大型资源的 C++ 容器或对象,移动语义可以转移所有权,避免深拷贝;但具体收益取决于类型。对
at::Tensor这类引用计数句柄,复制句柄本身并不会复制底层 Tensor Storage。
1.9 完美转发:std::forward
移动语义解决:
“对象已经是右值时,如何移动?”
完美转发解决:
“模板函数收到参数后,如何保留调用方原本的左值/右值属性?”
典型写法:
这里的:
| |
在模板推导语境中是 forwarding reference。
std::forward 会根据原始参数类型决定:
- 继续当左值;
- 还是继续当右值。
1.10 模板:为什么 AI / CUDA 代码里到处都是 <T>?
看一个最简单模板:
调用:
编译器会针对具体类型实例化代码。
1.10.1 为什么 Kernel 很适合模板?
AI 中同一个算子可能需要支持:
如果每种类型复制一套 Kernel:
代码会非常重复。
模板可以写成:
然后:
1.10.2 模板的性能意义
模板不仅是为了复用代码。
它还允许:
- 编译期特化;
- 常量传播;
- inline;
- 消除动态类型分派;
- 根据 dtype / block size 生成不同 Kernel。
所以你后面读 CUDA 代码时,经常会见到:
1.11 一个很重要的现代 C++ 原则:Rule of Zero
如果你的类成员本身都是 RAII 类型:
一般不需要自己手写:
让标准库类型自己处理。
这叫:
Rule of Zero
AI Infra 底层代码越复杂,越应该让所有权逻辑尽可能局部、机械、可证明。
2. Python C 扩展与 PyTorch 桥梁:PyBind11 实战
2.1 Python 是怎么 import C++ 的?
假设 Python:
| |
如果 fastops 是 C/C++ 扩展,那么磁盘上实际可能存在:
Linux:
| |
Windows 常见:
| |
.pyd 本质上也是 Windows DLL 形式的 Python 扩展模块。
Python 导入时大致经历:
PyBind11 的价值就是:
帮你把复杂的 Python C API 封装成现代 C++ 接口。
2.2 PyBind11 最小实战:C++ 向量加法
项目结构:
2.2.1 vector_add.cpp
| |
2.2.2 CMakeLists.txt
| |
2.2.3 创建环境
2.2.4 编译
2.2.5 test.py
运行:
| |
预期:
| |
2.3 std::vector vs Python list:一个非常关键的性能陷阱
你可能会看到:
然后误以为:
“这里用了
const &,所以 Python list 没复制。”
错。
当你:
| |
PyBind11 需要把 Python list 转成:
| |
这通常意味着:
返回时又要:
也就是说:
const std::vector<float>&只能避免进入 C++ 函数之后再复制一次这个 vector,不能消除 Python ↔ C++ 边界转换。
2.4 真正的 Zero-Copy 应该看什么?
高性能接口应该尽量直接传连续 Buffer。
典型选择:
而不是:
| |
2.4.1 NumPy 思路
PyBind11 支持:
| |
概念上可以直接拿到:
如果数据类型与布局满足要求,就不必先把所有元素转换成一个新的 std::vector<float>。
但是 zero-copy 永远有前提:
- dtype 是否匹配;
- contiguous 是否满足;
- alignment 是否满足;
- 谁拥有内存;
- C++ 使用期间 Python 对象是否还活着;
- 是否允许写;
- 生命周期是否跨线程。
所以:
Zero-Copy 从来不只是“拿一个裸指针”。它本质上是内存所有权与生命周期协议。
2.5 PyBind11 与 GIL
Python 调用 PyBind11 C++ 函数时,PyBind11 默认不会自动把 GIL 放掉。
如果 C++ 函数执行很久,并且整个计算期间:
- 不访问 Python 对象;
- 不调用 Python C API;
- 不回调 Python;
可以考虑释放 GIL。
例如:
或者:
但是:
一旦 C++ 代码需要访问 Python 对象,必须重新满足 Python 的线程状态 / GIL 要求。
不要为了“看起来高级”随便释放 GIL。
2.6 Python ↔ C++ 边界的性能原则
记住四条:
原则 1:减少调用次数
差:
好:
| |
原则 2:避免 Python 容器逐元素转换
差:
| |
好:
| |
原则 3:清楚生命周期
不要让 C++ 保存一个指向已经释放 Python Buffer 的指针。
原则 4:C++ 热路径不要频繁回调 Python
否则会重新引入:
- GIL;
- Python frame;
- 类型转换;
- 动态分派。
3. PyTorch C++ 扩展:从 torch::Tensor 到 ATen
3.1 PyTorch 的 C++ 底座:ATen
你在 Python 中:
| |
C++ 世界中最核心的 Tensor 类型之一是:
| |
在扩展中常见:
| |
很多使用场景下它们表示同一套 Tensor C++ 抽象。
3.2 at::Tensor 不是“整块数据对象”

这是非常关键的一层认知。
可以把它粗略理解为:
真正的大块数据一般由 Storage 管理。
Tensor 句柄本身主要管理:
- 对 TensorImpl 的引用;
- shape;
- stride;
- dtype;
- device;
- storage relation;
- dispatch / autograd 等元数据。
因此:
| |
一般不是“复制整个 Tensor 数据”。
它更接近:
3.3 为什么 Tensor View 可以很便宜?
例如 Python:
view 很多时候不复制底层 Storage。
只是产生新的:
因此 AI Infra 中必须理解:
shape 一样,不代表内存布局一样。
例如:
y 可能不是 contiguous。
如果你的 C++ / CUDA Kernel 假设输入连续,却没有检查 stride,就可能直接算错。
3.4 最小 PyTorch C++ Extension
我们先做最简单的:
| |
暂时不写 CUDA Kernel。
3.4.1 项目结构
3.4.2 op.cpp
| |
这里:
| |
最终会进入 PyTorch/ATen 的算子分发体系。
这个例子还没有自己写 CPU Kernel,它的价值是先打通:
3.5 用 torch.utils.cpp_extension.load JIT 编译
test_cpp_extension.py:
| |
运行:
| |
第一次运行会触发编译。
后续如果源码与编译条件未变化,构建缓存可以避免每次从零开始。
3.6 JIT load() 在背后做了什么?
简化来看:
这就是为什么 C++ Extension 出错时,你经常会同时看到:
它不是单纯的“Python 包”。
3.7 torch.utils.cpp_extension.load 常见参数
重要参数:
| 参数 | 含义 |
|---|---|
name | 扩展模块名 |
sources | C++ / CUDA 源文件 |
extra_cflags | 传给 C++ 编译器 |
extra_cuda_cflags | 传给 NVCC |
extra_ldflags | 额外链接参数 |
build_directory | 指定构建目录 |
verbose | 显示详细构建日志 |
如果源码包含:
| |
PyTorch 的扩展构建系统可以检测 CUDA 源文件并调用对应工具链。
3.8 AOT:不要每次启动程序都现场编译
教学阶段:
| |
非常方便。
生产环境更常见的是:
例如可以使用:
| |
然后构建 wheel。
这样部署环境就不必在每次进程启动时重新编译。
3.9 更正式的 PyTorch Custom Operator:Dispatcher 注册
上面的 PyBind 例子很好理解。
但如果你希望自定义算子更完整地参与:
那么更正式的方向是注册 PyTorch Custom Operator。
C++ 侧常见:
并为具体 DispatchKey 注册实现。
概念上:
这才是理解 PyTorch “一个算子为什么能在 CPU/CUDA/Autograd/Compile 中工作”的核心入口。
3.10 自定义 C++ 算子如何保持 Autograd 可导?
这是面试高频题。
要分情况回答。
情况 A:C++ 函数内部只组合已有 ATen 可导算子
例如:
| |
如果这些调用走 PyTorch 正常的 Autograd/Dispatcher 路径,已有算子的梯度规则可以继续构图。
此时你不是发明了一个新的数学原语,而是在 C++ 中组合已有算子。
情况 B:你自己写了一个“黑盒” CPU/CUDA Kernel
例如:
| |
PyTorch 不可能凭空知道:
| |
是什么。
这时就需要为你的 Operator 注册 backward / autograd formula。
现代 PyTorch Custom Operator 路线中,可以通过相应的 torch.library / C++ Dispatcher 注册机制补齐 Autograd。
并且应该使用:
| |
等工具检查注册契约。
3.11 torch.autograd.Function 和 Custom Operator 是一回事吗?
不是。
torch.autograd.Function 是自定义前向/反向的一种机制。
但一个完整的 PyTorch Operator 还可能需要兼容:
- Dispatcher;
torch.compile;- export;
- vmap;
- FakeTensor;
- device dispatch;
- schema;
- alias / mutation contract。
所以工程中不要只会说:
“自定义算子 = 写一个
torch.autograd.Function。”
现在更应该理解完整的 Operator 注册体系。
4. 动手实战:从 0 跑通一个 C++ Extension
这一节直接给出最小可运行包。
4.1 环境要求
建议:
检查:
4.2 项目结构
4.3 op.cpp
| |
4.4 test_cpp_extension.py
| |
4.5 运行
| |
你应该至少看到:
4.6 为什么这个例子能测 Autograd?
因为:
| |
最终使用的是 PyTorch 已有的加法算子。
也就是说你的 C++ 函数只是:
而不是一个 Autograd 完全不知道的自定义黑盒 Kernel。
4.7 如何证明没有把 Tensor 数据复制到 Python list?
我们传入的是:
| |
不是:
| |
PyTorch 的 Python Tensor 与 C++ Tensor 之间有专门的桥接与生命周期管理。
C++ 收到的不是“把每个元素重新做成一个 std::vector<float>”。
这正是为什么高性能框架都围绕 Tensor / Buffer 抽象设计。
4.8 如果下一步改成 CUDA 会发生什么?
项目会变成:
调用链:
你还需要继续理解:
- device;
- current CUDA stream;
- async launch;
- synchronization;
- kernel error;
- dtype dispatch;
- contiguous;
- alignment;
- shared memory;
- warp;
- occupancy;
- memory coalescing。
这就是下一阶段真正进入 AI Infra CUDA 世界的入口。
5. Ringi 避坑指南
5.1 ❌ 返回局部对象引用
错误:
函数结束时:
| |
已经销毁。
返回引用指向已经不存在的对象。
这是典型:
| |
5.2 ❌ 返回局部变量指针
错误:
函数退出后地址失效。
5.3 ❌ 以为裸指针等于 Zero-Copy
| |
只告诉你:
某个地址上可能有 float。
它没有告诉你:
- 内存是不是还活着;
- 谁负责释放;
- 长度是多少;
- 在 CPU 还是 GPU;
- contiguous 吗;
- 谁拥有它;
- 什么时候能释放;
- 是否正在被异步 Kernel 使用。
所以:
裸指针只是地址,不是完整的数据协议。
5.4 ❌ shared_ptr 到处用
如果所有东西都是:
| |
最后你会发现:
- 谁真正拥有资源不清楚;
- 生命周期变长;
- 循环引用;
- 引用计数有成本;
- 释放时机不可预测。
默认先想:
| |
5.5 ❌ 以为 std::move 后对象“不能用了”
标准 C++ 更准确的说法是:
moved-from object 仍然必须处于有效但通常未指定的状态。
例如:
你可以安全地:
但不要假设:
| |
5.6 ❌ 对函数返回值乱加 std::move
例如:
现代 C++ 中通常直接:
| |
更好。
编译器可以使用:
| |
消除复制。
乱写 std::move 反而可能妨碍优化。
5.7 ❌ C++ 算子里频繁调用 Python API
例如:
这会引入:
- GIL;
- Python object;
- callback;
- 边界转换;
- 解释器开销。
性能敏感路径应该尽量:
5.8 ❌ 忽略 Tensor dtype
错误:
| |
如果传入:
怎么办?
所以真实 Kernel 一般必须检查:
| |
或者做 dtype dispatch。
5.9 ❌ 忽略 device
你拿到:
| |
必须搞清楚:
不要把 CUDA Tensor 的地址当 CPU 地址直接解引用。
5.10 ❌ 忽略 contiguous / stride
错误思维:
不一定。
例如 transpose 产生的 Tensor 可能共享原 Storage,但 stride 已改变。
真实 Kernel 必须:
- 支持 arbitrary stride;
- 或明确要求 contiguous;
- 或内部调用
.contiguous()。
但要注意:
| |
如果输入本来不连续,这可能发生真实数据复制。
所以“为了方便全部 .contiguous()”也可能成为性能问题。
5.11 ❌ 忽略 GPU 异步执行
CUDA Kernel launch 通常是异步的。
CPU 发起:
| |
不代表 GPU 已经算完。
所以测性能时如果直接:
可能只测到了 launch 开销。
GPU benchmark 通常需要:
| |
或者使用 CUDA Event / torch.utils.benchmark 等合适工具。
5.12 ❌ 一出编译错误就重装 CUDA
C++ Extension 出错可能发生在:
先定位层次,不要先重装全世界。
6. 大厂面试高频题
面试题 1:std::unique_ptr 与 std::shared_ptr 区别?
推荐回答:
unique_ptr 表示唯一所有权,不可复制但可以移动,额外成本很低,应该作为默认拥有型指针。shared_ptr 表示共享所有权,通过控制块和引用计数管理生命周期,适合确实存在多个 owner 的对象,但有额外计数成本,并要注意循环引用;循环引用通常使用 weak_ptr 打破。
面试题 2:RAII 为什么对 CUDA 显存管理重要?
推荐回答:
CUDA 内存属于资源,可能遇到异常、提前返回、多分支路径。如果直接在业务代码中手工 cudaMalloc/cudaFree,容易遗漏释放或重复释放。RAII 把 cudaFree 放到析构函数中,让资源生命周期与 C++ 对象作用域绑定,可以显著降低泄漏和错误释放风险。Stream、Event、NCCL communicator 也可以使用同样的设计。
面试题 3:std::move 为什么能提高大对象传递性能?
不要回答:
“
std::move会自动做零拷贝。”
更好的回答:
std::move 只是把表达式转换成右值形式,使类型可以选择移动构造或移动赋值。如果类型拥有 heap buffer,例如 std::vector,移动通常可以转移内部指针和容量,从而避免逐元素深拷贝。具体收益取决于类型;对 at::Tensor 这样的引用计数句柄,普通复制句柄本身也不会复制整个 Tensor Storage。
面试题 4:Python list 传到 const std::vector<float>& 是 Zero-Copy 吗?
不是。
PyBind11 的 STL 自动转换通常需要把 Python list 的元素转换到一个 C++ std::vector 中。
const & 只能避免 C++ 函数内部再次复制这个 vector。
高性能接口应该优先考虑:
面试题 5:为什么 PyTorch Tensor 复制句柄很便宜?
因为 at::Tensor 更接近引用计数 handle。
多个 Tensor 对象可以指向同一个 TensorImpl / Storage。
例如:
| |
通常只会产生新的句柄引用关系,而不是复制整块显存。
真正复制数据一般是:
| |
或其他明确需要新 Storage 的操作。
面试题 6:PyTorch Tensor 的 shape 与 stride 分别是什么?
shape/sizes 描述每个维度长度。
stride 描述沿某个维度移动一个元素时,在底层 Storage 中需要跨多少个元素。
因此 transpose 可以只修改 stride,而不复制 Storage。
这也是为什么自定义 Kernel 不能只看 shape。
面试题 7:PyTorch 自定义 C++ 算子怎样支持 Autograd?
要分两类:
- 如果 C++ 中只是组合已有 PyTorch/ATen 可导算子,Autograd 可以沿已有算子继续记录;
- 如果注册了自己的黑盒 CPU/CUDA Kernel,就必须为该 Operator 提供正确的 Autograd/backward 注册,并验证梯度与 Operator schema 契约。
面试题 8:为什么 C++ 扩展可能比 Python 还慢?
常见原因:
- 每次调用计算量太小;
- Python ↔ C++ 边界开销占主导;
- list / vector 来回转换;
- 不必要 memcpy;
- 线程创建;
- cache miss;
- 没向量化;
.contiguous()发生复制;- GPU Kernel 太小;
- 强制同步;
- 编译参数没优化;
- 算法复杂度没变。
所以:
C++ 不是性能魔法,性能工程必须 measurement-driven。
面试题 9:什么时候释放 GIL?
当 C/C++ 扩展进入一个较长的原生计算区间,并且该区间:
- 不访问 Python 对象;
- 不调用 Python C API;
- 不需要 Python callback;
可以释放 GIL,让其他 Python 线程继续运行。
如果需要再次调用 Python,就必须正确恢复线程状态 / GIL 约束。
面试题 10:为什么不能返回局部对象引用?
局部对象离开作用域后生命周期结束。
返回其引用或地址会产生:
| |
后续访问属于未定义行为。
7. Debug:C++ Extension 出错怎么定位?
建立固定排查顺序。
7.1 第一步:确认 Python 与 PyTorch
7.2 第二步:确认编译器
7.3 第三步:确认 Ninja
| |
7.4 第四步:如果包含 CUDA,确认工具链
注意:
| |
与:
| |
不是同一个概念。
7.5 第五步:看完整编译命令
使用:
不要只复制最后一行:
| |
真正有价值的是前面第一条 compiler error。
7.6 第六步:区分 compile / link / import
编译错误
典型:
属于源码 / C++ API 层。
链接错误
典型:
| |
属于符号 / library / ABI / link flags 层。
import 错误
典型:
属于动态加载 / ABI / runtime library 层。
8. 性能工程视角:这节课真正要建立的思维
8.1 从“值”升级到“Buffer”
初学编程看:
| |
AI Infra 要继续问:
8.2 从“函数”升级到“边界”
看到:
| |
不要只想:
custom_op 做了什么数学运算?
还要问:
8.3 从“代码正确”升级到“生命周期正确”
一个 C++ 函数即使数学公式对,也可能因为:
- use-after-free;
- data race;
- dangling pointer;
- double free;
- async lifetime;
- stream ordering;
而完全不可用。
这也是 C++ 对 AI Infra 工程师的重要性。
9. 本讲自测 Checklist
学完这一讲,你应该能回答:
- Python 为什么适合做编排,但不适合承担所有性能敏感路径?
- GIL 限制的究竟是什么?
- 指针和引用的核心区别是什么?
- 什么是对象生命周期?
- RAII 为什么比“记得 free”更可靠?
-
unique_ptr和shared_ptr如何选择? -
std::move本身是否真的移动数据? - 什么叫 moved-from valid but unspecified state?
- 为什么模板适合 dtype dispatch?
- Python
list转std::vector为什么通常不是 Zero-Copy? - PyBind11 在跨语言调用里解决了什么?
- 什么情况下可以释放 GIL?
-
at::Tensor为什么不是整块 Tensor 数据本身? - Storage / TensorImpl / Tensor handle 大致是什么关系?
- shape 和 stride 为什么必须同时理解?
-
torch.utils.cpp_extension.load做了什么? - JIT 与 AOT Extension 有什么区别?
- 为什么自定义 CUDA Kernel 需要额外处理 Autograd?
- C++ Extension 报错时如何区分 compile / link / import?
- 为什么“用 C++ 重写”不一定自动变快?
10. 课后练习
练习 1:修改 C++ Add
把:
| |
修改成:
| |
然后在 Python 中验证结果。
练习 2:增加 dtype 检查
只允许:
| |
其他 dtype 抛出:
| |
练习 3:测试非 contiguous Tensor
Python:
观察你的 C++ 算子是否仍然正确。
练习 4:观察 Tensor 共享
Python:
理解:
练习 5:比较 list 与 Tensor 边界
分别实现:
输入 100 万个 float,测量两种接口。
重点不要只看 C++ 循环时间,还要看:
| |
练习 6:实现 RAII Buffer
实现:
| |
要求:
- 构造时申请内存;
- 析构时释放;
- 禁止复制;
- 支持移动;
- 可以查询 size。
11. Ringi 总结
这一讲最重要的不是:
| |
而是建立六个意识。
第一:所有权意识
任何资源都要问:
| |
第二:生命周期意识
任何指针都要问:
| |
第三:数据布局意识
任何 Tensor 都要问:
第四:边界意识
任何 Python/C++ 调用都要问:
第五:异步意识
任何 GPU 操作都要问:
第六:测量意识
任何性能优化都要先问:
| |
你最终要形成这样的思维链:
能够顺着这条链一路往下追,你才真正迈进了 AI Infra 的门。
12. 下一讲承接
下一讲建议进入:
第02讲:GPU 执行模型与 CUDA C++ 入门——Thread / Block / Grid / Warp / Memory Hierarchy
这一讲学到的:
- 指针;
- RAII;
- 模板;
- Tensor Storage;
- C++ Extension;
都会在 CUDA 中直接复用。
下一讲会继续回答:
以及:
附录 A:最小代码包
A.1 op.cpp
| |
A.2 test_cpp_extension.py
| |
附录 B:参考资料
本文基于课程大纲重新组织与原创撰写,并参考以下项目/官方文档用于知识校验与延伸阅读。
lilinji / DevopsBooklet:Python 实例手册增强完善版
https://github.com/lilinji/DevopsBooklet/blob/master/Python%E5%AE%9E%E4%BE%8B%E6%89%8B%E5%86%8C_%E5%A2%9E%E5%BC%BA%E5%AE%8C%E5%96%84%E7%89%88.mdPyTorch Documentation:Custom C++ and CUDA Operators
https://docs.pytorch.org/tutorials/advanced/cpp_custom_ops.htmlPyTorch Documentation:
torch.utils.cpp_extension
https://docs.pytorch.org/docs/stable/cpp_extension.htmlPyTorch Documentation:Extending PyTorch
https://docs.pytorch.org/docs/stable/notes/extending.htmlPyTorch Documentation:
torch.library
https://docs.pytorch.org/docs/stable/library.htmlPyBind11 Documentation:STL Containers
https://pybind11.readthedocs.io/en/stable/advanced/cast/stl.htmlPyBind11 Documentation:Global Interpreter Lock
https://pybind11.readthedocs.io/en/stable/advanced/misc.htmlCPython Documentation:Free-threaded Python
https://docs.python.org/3/howto/free-threading-python.htmlPyTorch Source:ATen
TensorBase/TensorImpl
https://github.com/pytorch/pytorch/blob/main/aten/src/ATen/core/TensorBase.h
https://github.com/pytorch/pytorch/blob/main/c10/core/TensorImpl.h
Ringi 的一句话复盘:
Python 让你快速调用 AI 系统;C++ 让你真正看见这个系统如何管理内存、调度算子、连接 GPU,并最终解释“为什么它快、为什么它慢、为什么它挂”。