🏠 总目录📚 本教程 01 · Python 调 C++ 的那条边界 ← →
📑 本页目录(点开跳转)

01 · Python 调 C++ 的那条边界

⏱ 50 分钟 | ⭐ 跨过这条边界要交两笔钱:数据怎么过去,和那把锁放不放开


🎯 一句话

同一个求和循环,Python 写和 C++ 写差一百多倍 —— 这个倍数就是 PyTorch 分层设计的全部理由。 而当你自己把一段计算搬到 C++ 那边,会发现真正的难点不是写 C++,是边界上的两个决定:数据怎么过去,以及那把解释器的锁要不要放开。


🧩 一、先把那一百倍跑出来

⭐ 这一节的所有数字都是实跑的,机器是一台普通 Windows 笔记本,g++ 15.1.0 (MinGW-w64) + CPython 3.13.14。⚠️ 你的绝对值一定不同,要看的是倍数。

C++ 这边写一个最普通的求和,编译成动态库:

// c01_kernel.cpp
// Linux/macOS: g++ -O2 -std=c++17 -shared -fPIC c01_kernel.cpp -o libkernel.so
// Windows(MinGW): g++ -O2 -std=c++17 -shared -static c01_kernel.cpp -o kernel.dll
#include <cstdint>

extern "C" {                       // ⭐ 关掉 C++ 的名字修饰,ctypes 才找得到这个名字

double sum_to(int64_t n) {         // 0+1+...+(n-1)
    double s = 0;
    for (int64_t i = 0; i < n; ++i) s += (double)i;
    return s;
}

double busy(int64_t n) {           // 纯占 CPU,第四节用它观察那把锁
    double s = 0;
    for (int64_t i = 0; i < n; ++i) s += (double)(i % 7);
    return s;
}

}

Python 这边用标准库的 ctypes 调它 —— 不需要装任何东西:

# c01_bench.py
import ctypes, time, os, platform

N = 20_000_000

t0 = time.perf_counter()
s = 0.0
for i in range(N):          # ⚠️ 循环体在 Python 里
    s += i
t1 = time.perf_counter()
py_ms = (t1 - t0) * 1000

lib_name = "kernel.dll" if platform.system() == "Windows" else "./libkernel.so"
lib = ctypes.CDLL(os.path.abspath(lib_name))
lib.sum_to.argtypes = [ctypes.c_int64]      # ⭐ 必须自己声明签名
lib.sum_to.restype = ctypes.c_double

lib.sum_to(1000)            # 预热,别把第一次调用的开销算进去
t2 = time.perf_counter()
s2 = lib.sum_to(N)          # ⭐ 循环体在 C++ 里
t3 = time.perf_counter()
cpp_ms = (t3 - t2) * 1000

print(f"Python {py_ms:8.1f} ms   结果 {s}")
print(f"C++    {cpp_ms:8.1f} ms   结果 {s2}")
print(f"倍数   {py_ms / cpp_ms:8.1f}x")
print(f"每个元素 Python {py_ms*1e6/N:.1f} ns / C++ {cpp_ms*1e6/N:.2f} ns")

跑三次的结果:

次 Python C++ 倍数 每元素 Python 每元素 C++
1 2213.2 ms 20.4 ms 108.5x 110.7 ns 1.02 ns
2 2883.6 ms 18.8 ms 153.1x 144.2 ns 0.94 ns
3 2951.5 ms 17.1 ms 172.1x 147.6 ns 0.86 ns

⭐ 量级是「百倍」,不是「一点点」。 两边算的是同一个和(199999990000000.0,两边一致)。


🧩 二、那一百倍花在哪了

不是「Python 慢」这种废话,是三笔具体的开销,每一笔都能看见。

🚦 第一笔:每一步都要过一遍解释器

s += i 这一行编译成什么,标准库直接能看:

# c01_box.py 的一部分
import dis

def f(s, i):
    s += i
    return s

dis.dis(f)

实跑输出(CPython 3.13):

对照

RESUME 0

LOAD_FAST_LOAD_FAST 1 (s, i)

BINARY_OP 13 (+=)

STORE_FAST 0 (s)

LOAD_FAST 0 (s)

RETURN_VALUE

⭐ 这几条指令,每循环一次都要重新走一遍 —— 取指、分派、执行。C++ 那边这一整段被编译成几条机器指令,而且循环外面什么都不用做。

🚦 第二笔:每个数都是一个对象

import sys, array

print(sys.getsizeof(0))          # 28
print(sys.getsizeof(1))          # 28
print(sys.getsizeof(2**100))     # 40

lst = list(range(1000))
print(sys.getsizeof(lst))                                        # 8056
print(sys.getsizeof(lst) + sum(sys.getsizeof(x) for x in lst))   # 36056
print(sys.getsizeof(array.array('q', range(1000))))              # 8320

实跑结果就是注释里那几个数:

东西 字节
Python 的一个小整数 28
C++ 的一个 int64_t 8
list(range(1000)) 这个列表本身 8056(只是 1000 个指针)
加上它指向的 1000 个整数对象 36056
同样 1000 个整数,array('q') 8320

⭐ 36056 对 8320,四倍多。 而且那 1000 个对象散落在堆的各处,遍历它们等于 1000 次指针跳转 —— 这一条会在 03 章变成一个更大的数字。

🚦 第三笔:加法之前得先问「你俩是什么类型」

BINARY_OP 这条指令不知道 s 和 i 是什么。它每次都要去查两个对象的类型、找到对应的加法实现、再调用它。C++ 那边 s += i 的类型在编译期就定死了,运行时一条 addsd 指令的事。


🧩 三、⭐ 于是 PyTorch 长成了那个样子

把上面三笔开销放在一起,能推出一条很实用的判据。

⭐ 跨过边界这件事本身是有固定成本的(一次函数调用、参数转换)。所以要看的不是「用不用 C++」,而是「每跨一次边界,那边处理多少数据」。

你的写法 跨边界几次 每次处理多少 结果
for i in range(n): s += x[i] n 次(其实是解释器逐条走) 1 个数 💀 固定成本 × n
torch.matmul(a, b) 1 次 整个矩阵 ⭐ 固定成本摊到几百万个数上,忽略不计
for row in rows: model(row) n 次 1 行 ⚠️ 中间地带,batch 起来就好了

⭐ 这就是为什么「训练脚本用 Python 写也不慢」和「用 Python 写循环体就完蛋」这两句话同时成立。 Python 在 PyTorch 里的角色是编排:决定调用哪个算子、按什么顺序、参数是什么。真正的活在 torch.matmul 那一行一进去就交给 C++ 了。

⚠️ 反过来,这条判据也告诉你什么时候不该下来:

情况 判定
这段代码占总时间 3% ❌ 优化到 0 也只快 3%,先去 Roofline 那一章找真瓶颈
瓶颈是等网络 / 等磁盘 ❌ C++ 等得一样慢
需求还在天天改 ❌ 每改一次要重新编译、重新打包、重新处理装不上(04 章)
已经有现成的库 ❌ 先用 numpy / torch 的向量化写法
⭐ 一个热点循环,形状固定,占了大半时间 ✅ 值得

🧩 四、⭐ 第二笔钱:那把锁

CPython 有一把全局的锁,同一时刻只有一个线程能执行 Python 字节码。这一条为什么存在、它怎么影响你选线程还是选进程 —— 那是 Python 语言自己的事,全部在 《Python 会咬你的地方》。

⭐ 本章只关心一件事:当执行流已经离开 Python、跑在你的 C++ 代码里时,这把锁要不要继续握着。

这个决定能直接跑出来看。ctypes 恰好提供了两个只差这一点的入口:

# c01_gil.py
import ctypes, threading, time, os, platform

lib_name = "kernel.dll" if platform.system() == "Windows" else "./libkernel.so"
path = os.path.abspath(lib_name)

released = ctypes.CDLL(path)    # ⭐ CDLL:调用期间【放开】解释器的锁
held     = ctypes.PyDLL(path)   # ⚠️ PyDLL:调用期间【一直握着】锁
for lib in (released, held):
    lib.busy.argtypes = [ctypes.c_int64]
    lib.busy.restype = ctypes.c_double

N = 300_000_000

def run(lib, nthreads):
    ts = [threading.Thread(target=lib.busy, args=(N,)) for _ in range(nthreads)]
    t0 = time.perf_counter()
    for t in ts: t.start()
    for t in ts: t.join()
    return (time.perf_counter() - t0) * 1000

for name, lib in (("CDLL(放开锁)", released), ("PyDLL(不放开)", held)):
    one = run(lib, 1)
    two = run(lib, 2)
    print(f"{name}:  1个线程 {one:7.1f} ms   2个线程 {two:7.1f} ms   →  {two/one:.2f}x")

跑三次,实跑结果:

1 个线程 2 个线程 2 线程 / 1 线程
CDLL(放开锁) 483.5 / 530.8 / 620.1 ms 570.7 / 493.9 / 519.2 ms 1.18 / 0.93 / 0.84
PyDLL(不放开) 696.8 / 464.3 / 637.7 ms 1475.9 / 948.2 / 1059.6 ms 2.12 / 2.04 / 1.66

⭐ 读法:放开锁的那一栏,两个线程做完两份活的时间,和一个线程做完一份活差不多(比值在 1 附近上下波动)—— 这就是真并行。不放开的那一栏比值稳定在 2 左右,两个线程排着队跑完了。

⚠️ 这就是《AI 全栈》第 3 章那句话的病因:那一章说「另有一类是真 CPU 活 —— 解析 50 MB JSON、本地算 embedding —— async 救不了,只能扔线程池或独立进程」。⭐ 上面这张表的下半截,就是「为什么线程池也常常救不了」:只要那段活是在握着锁的情况下干的,线程再多也是排队。 而反过来,上半截解释了为什么 numpy / torch 里的大矩阵运算可以用多线程叠加 —— 它们在进 C++ 之前把锁放开了。

🧯 放开锁的三条规矩

规矩 为什么
⭐ 放开期间绝对不能碰任何 Python 对象 包括读一个 list、给 dict 赋值、甚至只是让某个对象的引用计数变一下。💀 这类 bug 的表现是偶发崩溃,不是报错
放开的窗口要正好包住那段长计算 太早放开会碰到前面的参数解析;太晚放开等于没放
⚠️ 很短的调用别放 放开和重新拿回来本身有成本,几微秒的函数放了反而更慢

⭐ 最省事的做法:进 C++ 之前,把要用的东西全部转成裸指针和普通数值(data_ptr、int、float),然后再放开锁 —— 这样「不碰 Python 对象」是结构上保证的,不靠你记得。


🛑 读到这里可以停 —— 已经读了约 27 分钟。 最后一段还有(约 20 分钟):pybind11 把这些压成几行 · 检查点与走神救援 回来的时候不用重读,直接从下一节接着看就行。


🧩 五、pybind11 把这些压成几行

ctypes 的好处是不用装东西,坏处是你得自己声明每个签名,而且只能传标量和指针。真正做扩展时用的是 pybind11:一个只有头文件的库,把 Python C API 那一大堆样板压成几行。

🗓️ 未实跑 —— 本机没装 pybind11,而且是 MinGW g++ 配 MSVC 编译的 CPython,编不出能导入的扩展模块。 ⚠️ 下面这段代码来自 pybind11 的标准用法,作者没有在本机跑过,请以官方文档为准。

// mymod.cpp   🗓️ 未实跑 —— 本机无编译环境
#include <pybind11/pybind11.h>
#include <cstdint>
namespace py = pybind11;

double busy(int64_t n) {
    double s = 0;
    for (int64_t i = 0; i < n; ++i) s += (double)(i % 7);
    return s;
}

PYBIND11_MODULE(mymod, m) {
    m.def("busy", &busy, "占 CPU 的循环",
          py::call_guard<py::gil_scoped_release>());   // ⭐ 整个调用期间放开锁
}

对照第四节:py::call_guard<py::gil_scoped_release>() 做的就是 ctypes.CDLL 默认做的那件事 —— 进这个函数之前放开锁,出来之前拿回来。它是一个 RAII 对象(02 章会讲这个词),所以哪怕函数中途抛异常,锁也一定会被还回去。

如果只想放开其中一段,手动开一个作用域:

// 🗓️ 未实跑
py::array_t<float> process(py::array_t<float> arr) {
    auto buf = arr.request();
    float* ptr = static_cast<float*>(buf.ptr);   // ⭐ 先把裸指针拿到手
    size_t n = buf.size;
    {
        py::gil_scoped_release release;          // ⭐ 从这里开始放开
        for (size_t i = 0; i < n; ++i) ptr[i] = ptr[i] * 2.0f;
    }                                            // ⭐ 出了大括号自动拿回来
    return arr;                                  // 这一行又碰 Python 对象了,必须在锁里
}

⭐ 注意 ptr 和 n 是在放开之前取好的 —— 放开之后那段循环只碰裸内存,一个 Python 对象都没碰。这就是上一节说的「结构上保证」。

🧯 边界上还有三个必踩的坑

坑 症状 怎么办
⚠️ 传进来的数组不一定是你以为的排布 数字全错,或者段错误 进去先检查再决定要不要拷贝;⭐ 这个检查是什么意思、什么叫「连续」,全在 《NumPy 与向量化思维》
⚠️ C++ 异常穿不过边界 进程直接死掉,没有 traceback pybind11 会把标准异常转成 Python 异常,但你自己的异常类型要自己注册
💀 生命周期 偶发的脏数据 / 崩溃 C++ 那边存了一个指向 Python 对象内存的指针,而 Python 那边已经把它回收了 —— 谁负责释放,是 02 章的正题

🔗 这一章连到哪里

相关的地方 为什么
《Python 会咬你的地方》 GIL 本身、线程 vs 进程 vs async 怎么选、multiprocessing 为什么要付序列化的钱,全在那边。本章只讲跨语言调用时锁怎么放开
AI 全栈 03 那一章说「真 CPU 活 async 救不了,只能扔线程池或独立进程」—— ⭐ 本章第四节那张表就是它的病因
AI 基础设施 04 下到 C++ 之前先确认瓶颈真的在这儿。那一章教你怎么量
AI 基础设施 07 它给了「什么时候值得手写 kernel」的三个条件,⭐ 不满足就别写 —— 本章第三节那张反面表是它的 CPU 侧版本
02 章 第五节最后一个坑(谁负责释放)的正题
03 章 第二节「1000 个对象散落在堆的各处」的后果
《NumPy 与向量化思维》 「传进来的数组是什么排布」「连续是什么意思」全在那边

✅ 检查点

  1. 本章实跑的三次里,同一个 20M 次求和,Python 和 C++ 分别用了多久?倍数大概是多少?
  2. 那个倍数拆成哪三笔开销?各举一个能看见它的方法。
  3. sys.getsizeof(1) 是多少字节?C++ 的 int64_t 是多少?list(range(1000)) 连同它指向的对象一共多少字节,换成 array('q') 是多少?
  4. 「训练脚本用 Python 写也不慢」和「用 Python 写循环体就完蛋」为什么同时成立?判据是什么?
  5. ctypes.CDLL 和 ctypes.PyDLL 差在哪?本章两个线程的实跑结果分别是几倍?
  6. pybind11 里对应「放开锁」的写法是什么?它为什么哪怕抛异常也不会漏掉还锁?
  7. 放开锁期间有一条绝对不能破的规矩,是什么?有什么办法从结构上保证它?
  8. 举出三种「不值得下到 C++」的情况。
👀 答案
  1. Python 2213.2 / 2883.6 / 2951.5 ms,C++ 20.4 / 18.8 / 17.1 ms,倍数 108.5x / 153.1x / 172.1x —— 量级是百倍。每元素 Python 110.7–147.6 ns,C++ 约 1 ns。
  2. ① 字节码分派:dis.dis 能看到 s += i 每循环一次都要重走 LOAD_FAST_LOAD_FAST / BINARY_OP / STORE_FAST。② 对象装箱:sys.getsizeof 能看到一个小整数 28 字节。③ 动态类型查找:BINARY_OP 不知道两边是什么类型,每次都要查类型再找加法实现。
  3. sys.getsizeof(1) = 28 字节,int64_t = 8 字节;list(range(1000)) 本身 8056 字节(只是指针),连同对象共 36056 字节,array('q') 只要 8320 字节。
  4. 因为跨边界有固定成本,关键是每跨一次边界那边处理多少数据。torch.matmul(a, b) 只跨一次、处理整个矩阵,固定成本摊到几百万个数上;for i in range(n): s += x[i] 相当于每个元素都在解释器里走一遍,固定成本乘以 n。
  5. CDLL 调用期间放开锁,PyDLL 一直握着。实跑:CDLL 两线程 / 一线程 = 1.18 / 0.93 / 0.84(真并行);PyDLL = 2.12 / 2.04 / 1.66(排队)。
  6. py::call_guard<py::gil_scoped_release>(),或者手动开一个作用域写 py::gil_scoped_release release;。它是 RAII 对象 —— 作用域一结束(正常返回或抛异常)析构函数都会执行,锁一定被还回去。
  7. 放开期间绝对不能碰任何 Python 对象,包括只是让引用计数变一下;破了这条的症状是偶发崩溃而不是报错。结构上的保证办法:进 C++ 之前把要用的全部转成裸指针和普通数值(data_ptr、int、float),然后再放开锁,这样那段代码根本没有 Python 对象可碰。
  8. ① 这段代码只占总时间 3%(优化到 0 也只快 3%)② 瓶颈是等网络 / 等磁盘(C++ 等得一样慢)③ 需求天天改(每改一次要重编译、重打包、重新处理装不上)④ 已经有现成的向量化写法。

🛑 可以停在这里

⚡ 走神救援

🏁 同一个两千万次求和,Python 和 C++ 差一百多倍,每个元素一百多纳秒对约一纳秒。这一百多倍拆成三笔看得见的开销:字节码分派(每循环都要重走取值、运算、存回)、对象装箱(一个小整数二十多字节,而原生整数只有八字节)、动态类型查找(每次都得先问「你俩是什么类型」)。

⭐ 由此推出那条判据:跨边界本身有固定成本,所以要看的不是「用不用 C++」,而是「每跨一次边界,那边处理多少数据」。 一次矩阵乘跨一次、处理整个矩阵,成本摊到几百万个数上;而 for i in range(n): s += x[i] 等于每个元素走一遍解释器。

⭐ 这就是为什么「训练脚本用 Python 写也不慢」和「用 Python 写循环体就完蛋」同时成立——Python 在框架里的角色是编排。⚠️ 反面同样重要:占比很小、瓶颈在网络或磁盘、需求天天改、已有向量化写法,四种情况都不值得下来。

🔒 第二笔钱是那把锁:执行流已经在 C++ 里了,锁还握着吗。ctypes 正好给了对照组——一种放开、一种不放开,实测前者两线程真并行,后者纯排队。⚠️ 这就是「真 CPU 活 async 救不了、只能扔线程池或独立进程」的病因:握着锁干活,线程再多也是排队。

⭐ 放开锁三条规矩:⚠️⚠️ 放开期间绝对不能碰任何 Python 对象(连引用计数变一下都不行——症状是偶发崩溃,不是报错)、窗口要正好包住长计算、很短的调用别放。⭐ 最省事的保证办法是进去前把东西全转成裸指针和数值再放开。

下一节 👉 02-值语义与所有权.md

打卡记录保存在你的浏览器里,首页能看到总进度