编译方式决定性能:Python 与 C++ 混合编程的性能案例
在前文《关于Python扩展中编译依赖问题的分析与解决实录》中提及了 python 与 c++ 代码的混合编程问题。本篇是对上篇的补充,说明在程序代码不变的情况下,编译方式与环境选择的合适与否会带来怎样的性能提升。
在前文分析了 Python 扩展模块的编译依赖问题之后,本文进一步记录一个更加直观的案例:程序代码完全不变,仅改变 Python 扩展 (.pyd) 的编译方式,实时采集程序的性能便发生了数量级变化。
在脑电数据采集过程中,由于扩展模块误以 Debug 模式编译,并依赖陈旧的 Python 运行时,数据处理速度始终落后于数据产生速度,缓冲区不断积压,最终导致系统崩溃。而重新采用 Release 模式并基于现代 Python 版本重新编译后,同样的代码即可稳定满足实时处理需求,处理能力甚至保留了充足余量。
本文结合 dumpbin 输出、运行日志以及实际测试数据,对比分析两种编译结果在运行时依赖和性能上的差异,并说明为什么编译配置不仅影响能否运行,更直接决定程序是否具备实时处理能力。
本次使用的源码来自 https://gitlab.com/smeeze/eego-sdk-pybind11
- 我为什么对编译如此纠结
- 我做了什么
- 错误编译的性能表现
- 正确编译的性能表现
- 跨越 Python 与 C++ 的边界
我为什么对编译如此纠结
最近在开发一个脑电数据采集程序,从 eego 设备通过串口接收数据。代码逻辑很简单:循环从缓冲区取数据,处理,然后继续。但就是这个看似简单的过程,让我深刻体会到了“编译”二字的分量。
事情是这样的——程序跑起来后,数据处理速度越来越慢,待处理的数据像雪崩一样堆积,最终系统崩溃。就像一艘船底破了个洞,每秒灌入 1000 升水,而你的水泵每秒只能抽走 700 升,剩下的 300 升不断累积,直到船沉没。
而这一切的罪魁祸首,是使用一个不知道哪位编译出来的 pyd 文件。
我做了什么
通过串口从 eego 设备接收数据,数据放在 recv 缓冲区里,再从缓冲区里不断取数据,完成持续的数据采集。
while self._collecting:
time.sleep(interval)
# receive the latest data
recv = stream.getData()
# samples
sc = recv.getSampleCount()
# channels
cc = recv.getChannelCount()
# ! data shape is (n_samples, n_channels)
# ! It is TOO SLOW for every point collecting in bad compiling.
data = [[recv.getSample(c, s) for c in range(cc)] for s in tqdm(range(sc))]
# Report on every loop
t = time.time() - t0
if t > 0:
r = n / t
logger.debug('[{:04.4f}] buffer, ratio: {:0.3f} channels: {:03} samples: {:03} | {:03}'.format(t, r, cc, sc, n))
错误编译的性能表现
在错误编译时,这个代码就是灾难。灾难在哪里呢?灾难在它太慢了。慢到什么程度呢?慢到了不能在 interval 规定的时限内遍历缓冲区里的数据。体现在日志中,就是待处理的数据越堆越多,处理延迟越来越大,直到系统崩溃。
而这一切的罪魁祸首是错误编译导致的 pyd 性能极差。编译错误体现在错误地使用 debug 模式(并且使用了极其陈旧的 python 版本)。看到那些 D 后缀了吗?MSVCP140D、VCRUNTIME140D——这是 Debug 模式 的运行时库。再加上 python37.dll(Python 3.7,一个已经很古老的版本),这个组合堪称性能杀手。
ratio 是处理速度(sample/s),理论上应该稳定在约 500 sample/s。但注意观察:samples 列(本次处理的样本数)在持续增长,而管道符号后的数字(累计已处理)增长速度远跟不上新数据的涌入速度。
这就是死亡螺旋——处理一帧数据需要的时间越来越长,因为每帧要处理的数据量越来越大。最终,延迟累积到无法接受的程度,程序崩溃。
# dumpbin /dependents .\eego_sdk.pyd
Microsoft (R) COFF/PE Dumper Version 14.51.36248.0
Copyright (C) Microsoft Corporation. All rights reserved.
Dump of file .\eego_sdk.pyd
File Type: DLL
Image has the following dependencies:
python37.dll
eego-SDK.dll
MSVCP140D.dll
VCRUNTIME140D.dll
VCRUNTIME140_1D.dll
ucrtbased.dll
KERNEL32.dll
100%|| 153/153 [00:00<00:00, 575.34it/s]
2026-07-23 14:48:18.104 | DEBUG | __main__:_collect_eeg:115 - [0.3721] buffer, ratio: 472.956 channels: 090 samples: 153 | 176
100%|| 366/366 [00:00<00:00, 564.03it/s]
2026-07-23 14:48:18.854 | DEBUG | __main__:_collect_eeg:115 - [1.1222] buffer, ratio: 482.981 channels: 090 samples: 366 | 542
100%|| 753/753 [00:01<00:00, 543.20it/s]
2026-07-23 14:48:20.348 | DEBUG | __main__:_collect_eeg:115 - [2.6165] buffer, ratio: 494.929 channels: 090 samples: 753 | 1295
100%|| 1504/1504 [00:02<00:00, 547.39it/s]
2026-07-23 14:48:23.211 | DEBUG | __main__:_collect_eeg:115 - [5.4792] buffer, ratio: 510.840 channels: 090 samples: 1504 | 2799
100%|| 2856/2856 [00:05<00:00, 533.14it/s]
2026-07-23 14:48:28.676 | DEBUG | __main__:_collect_eeg:115 - [10.9438] buffer, ratio: 516.730 channels: 090 samples: 2856 | 5655
100%|| 5481/5481 [00:08<00:00, 615.31it/s]
2026-07-23 14:48:37.713 | DEBUG | __main__:_collect_eeg:115 - [19.9810] buffer, ratio: 557.329 channels: 090 samples: 5481 | 11136
100%|| 9031/9031 [00:15<00:00, 588.97it/s]
2026-07-23 14:48:53.169 | DEBUG | __main__:_collect_eeg:115 - [35.4368] buffer, ratio: 569.098 channels: 090 samples: 9031 | 20167
正确编译的性能表现
同样的代码,换一个编译方式:Release 模式 + Python 3.14。
这才是 c++ 应有的性能表现:每次循环都能在期望时间内完成数据遍历,甚至性能还有大量冗余。
# dumpbin /dependents .\eego_sdk.pyd
Microsoft (R) COFF/PE Dumper Version 14.51.36248.0
Copyright (C) Microsoft Corporation. All rights reserved.
Dump of file .\eego_sdk.pyd
File Type: DLL
Image has the following dependencies:
python314.dll
eego-SDK.dll
MSVCP140.dll
VCRUNTIME140.dll
VCRUNTIME140_1.dll
api-ms-win-crt-heap-l1-1-0.dll
api-ms-win-crt-string-l1-1-0.dll
api-ms-win-crt-math-l1-1-0.dll
api-ms-win-crt-runtime-l1-1-0.dll
KERNEL32.dll
100%|| 15/15 [00:00<00:00, 7623.24it/s]
2026-07-23 14:44:55.650 | DEBUG | __main__:_collect_eeg:114 - [0.0000] buffer, ratio: 2169467.586 channels: 090 samples: 015 | 015
100%|| 105/105 [00:00<00:00, 13465.89it/s]
2026-07-23 14:44:55.760 | DEBUG | __main__:_collect_eeg:114 - [0.1103] buffer, ratio: 1087.626 channels: 090 samples: 105 | 120
100%|| 111/111 [00:00<00:00, 23702.66it/s]
2026-07-23 14:44:55.867 | DEBUG | __main__:_collect_eeg:114 - [0.2174] buffer, ratio: 1062.733 channels: 090 samples: 111 | 231
100%|| 105/105 [00:00<00:00, 15338.60it/s]
2026-07-23 14:44:55.977 | DEBUG | __main__:_collect_eeg:114 - [0.3271] buffer, ratio: 1027.144 channels: 090 samples: 105 | 336
100%|| 110/110 [00:00<00:00, 19192.71it/s]
2026-07-23 14:44:56.084 | DEBUG | __main__:_collect_eeg:114 - [0.4343] buffer, ratio: 1026.850 channels: 090 samples: 110 | 446
100%|| 106/106 [00:00<00:00, 12753.40it/s]
2026-07-23 14:44:56.196 | DEBUG | __main__:_collect_eeg:114 - [0.5459] buffer, ratio: 1011.235 channels: 090 samples: 106 | 552
100%|| 110/110 [00:00<00:00, 14748.38it/s]
2026-07-23 14:44:56.305 | DEBUG | __main__:_collect_eeg:114 - [0.6557] buffer, ratio: 1009.598 channels: 090 samples: 110 | 662
100%|| 113/113 [00:00<00:00, 30107.76it/s]
跨越 Python 与 C++ 的边界
最后再说一下 Python 与 C++ 混合编程时的性能问题。因为在重新编译之前,我确实是能通过一个小 trick 让错误编译的 .pyd 文件满足性能要求的。
做法是用迭代器。它能勉强满足 1000 Hz 的采样速率要求。这样做的原理是一次性读取,减少通过边界的次数。
# ! It is fast enough to fetch all using __iter__ and then reshape it.
try:
x = list(recv)
except:
continue
data = np.reshape(x, (sc, cc))
乍看之下,较慢的代码不过是一个普通的双层列表推导:
data = [[recv.getSample(c, s) for c in range(cc)] for s in range(sc)]
真正耗时的地方其实不是 Python 的两层循环,而是 recv.getSample(c, s)。
由于 recv 是一个 C++ 扩展对象,每调用一次 getSample(),Python 都需要进入 C++,执行 SDK 接口,再将返回的 double 转换成 Python 的 float,最后返回给解释器。这个过程涉及 Python 与 C++ 之间的一次完整边界切换。
一次调用的开销或许只有几微秒,看起来微不足道。但在实时采集程序中,这个开销会被放大。
以本文的测试数据为例,每个数据包约有 110 个采样点,每个采样点包含 90 个通道,因此一次循环需要调用
次 getSample()。
采样率约为 1000 sample/s 时,每秒就需要完成约
次 Python 与 C++ 之间的函数调用。
九万次边界切换,即使每次只多消耗几微秒,总耗时也会迅速累积。
这也是为什么错误的编译方式会带来如此明显的性能差异。对于普通的 C++ 程序来说,Debug 模式带来的额外检查也许只是让程序"慢一点";但这里的 getSample() 会被调用数万次甚至数十万次,Debug 运行时增加的参数检查、调试堆、STL 调试支持等额外开销,会在线程中不断累积,最终成为整个系统的瓶颈。
值得注意的是,这种性能损失并不是 Python 本身造成的,而是频繁跨越 Python 与 C++ 边界造成的。如果 C++ 能够一次性返回整个数据块,Python 只需要完成一次边界切换,那么绝大多数开销都可以被摊销掉;反之,如果每个采样点、每个通道都需要单独调用一次接口,那么即使单次调用只增加几个微秒,在高频实时采集场景下也足以影响整个系统的实时性。
因此,在 Python 与 C++ 混合编程中,一个普遍的经验是:边界切换的次数往往比边界另一侧的计算更重要。 与其优化 Python 循环,不如尽可能减少 Python 与 C++ 之间的调用次数,让数据在 C++ 中完成批量处理,再整体返回给 Python。这也是 NumPy、OpenCV、PyTorch 等高性能库普遍采用批量接口,而不是逐元素接口的重要原因。