在量子计算领域摸爬滚打这几年,我越来越觉得“跑分”这件事被严重低估了。很多人拿到 Qiskit Aer、Cirq、Qulacs 这类模拟器,第一反应是跑通一个 GHZ 态或者随机线路,看一眼输出结果和理论值一致,就认为“这个模拟器性能不错”。但真实工程场景里,模拟器的瓶颈往往藏在那些你根本没注意到的角落:编译时的内存峰值、多线程扩展时的缓存颠簸、以及线路调度器在大规模噪声模拟下的排队策略。如果不建立一套系统性的基准测试方法论,所谓“性能对比”基本都是拍脑袋,结论换个场景就崩。
这篇文章我想从我自己搭建的一套量子计算模拟器基准测试框架出发,完整拆解方法论的设计思路、核心指标、实操流程和踩坑记录。它不是什么官方标准,但在我们组评估模拟器选型、分布式扩展方案以及硬件发布前的验证任务中,这套方法论已经实打实跑了一年多,参考价值是经得住检验的。无论你是刚入门想选个模拟器,还是已经在做大规模线路模拟的性能调优,这套方法都能提供一个可落地的取舍框架。
1. 内容整体设计与思路拆解
1.1 先说清楚:量子计算模拟器到底在“模拟”什么
量子模拟器的本质,是在经典计算机上构造量子态的高维数学表示,并模拟量子门操作对这个态的演化过程。听起来很抽象,我换个说法:一个 30 量子比特的系统,其量子态在数学上需要 2 的 30 次方个复数振幅来描述,也就是约 10 亿个复数。如果每个复数用 16 字节存,单是这个态向量就需要 16 GB 内存。这还没算上每施加一个量子门,要对整个向量做一次矩阵乘法风格的变换。量子计算模拟器的性能问题,本质上就是“高维矩阵操作”和“内存带宽瓶颈”这两个问题的狂欢。
模拟器分两大流派:态矢量模拟器和张量网络模拟器。态矢量模拟器直观、支持密集线路,但内存开销是硬天花板;张量网络模拟器在某些低纠缠场景下能大幅降低资源开销,但线路结构一变,性能可能陡降几个数量级。所以做基准测试,第一步就不是“哪个模拟器跑分高”,而是“你的目标问题属于哪一类,模拟器在对应类别里的表现如何”。
我见过太多团队拿 Qiskit Aer 的 GPU 模式跑随机密集线路,跑出个漂亮时间之后,直接得出“Aer 比 Qulacs 快”的结论。这个结论在随机线路场景下可能成立,但如果换成带有大量中间测量的自适应线路或者带噪声的采样任务,结论可能完全反转。基准测试方法论要解决的核心问题,不是给出某个静态的分数,而是刻画模拟器在不同负载特征下的行为曲线。
1.2 为什么不能直接抄别人的测速数据
网上的模拟器评测一抓一大把,但大多数只有一张柱状图,告诉你谁快谁慢。这种评测看个热闹可以,真要用它来指导决策,有几个硬伤:第一,没有交代模拟器的编译配置,OpenMP 线程数、SIMD 指令集、是否启用矩阵分块缓存,这些对单次测速的影响可能是 10 倍级别;第二,没有说明线路结构,同样的量子比特数,深度 10 和深度 100 的线路,不同模拟器的时间曲线差异极大;第三,没有关注内存峰值,很多模拟器在小规模下跑得飞快,但到临界规模时会突然内存溢出,而这种溢出行为恰恰是工程选型时最致命的。
所以我自己搭框架时定了个原则:测的不是“一个分数”,而是“一组曲线”;关注的不是“哪个最快”,而是“在什么范围内谁更稳”。这套方法论最后沉淀下来的产物,是一个可复现的测试基线加一套自动化脚本,任何人拿到同样的输入,都能得到和我一致的结论,而不是被一堆不可控变量左右。
1.3 性能模型的三个分层
为了让基准测试有的放矢,我把性能分解成了三个独立层次:
- 线路编译层:从高层量子电路到模拟器后端指令之间的转换开销。典型场景是 Qiskit 的 transpile(即线路编译与优化)阶段,某些深度较大的线路在这个阶段就可能耗时数分钟,但它其实和模拟执行是两码事。
- 执行引擎层:模拟器真正执行线路的门操作、采样和期望值计算。这是传统基准测试关注的重点,也是最容易优化的层。
- 接口与调度层:包括往量子程序里插入中间测量、噪声注入、批处理任务执行时的调度策略。在噪声模拟和量子-经典混合算法的场景里,这一层常常才是真正的瓶颈。
三层分开测的好处是,当整体链路变慢时,你能迅速定位问题出在哪个环节。而不是像以前一样,任务卡住了就只会重启机器,白浪费半天时间。
2. 核心指标定义与环境基线
2.1 三个必须定义清楚的核心指标
性能对比最怕的就是指标定义模糊。同样是“耗时”,是包含编译时间还是纯执行时间?是单线程还是多线程?是冷启动还是热执行?为了让结果具备可比性,我在这套方法论里强制规定了三个核心指标。
执行时间(Simulation Time):只统计模拟器后端实际执行量子门操作的时间,不包含编译、初始化、数据搬移以及结果后处理。测量方式是在调用执行接口的前后打时间戳,这个时间戳要精确到毫秒级,必要时用多次执行取中位数来消除系统噪声。
峰值内存(Peak Memory):进程在生命周期内的最大内存占用,包括态向量本身,以及各种中间缓存和临时矩阵。Linux 下用/usr/bin/time -v就能读取 Max Resident Set Size(最大常驻内存集大小),macOS 可以借助time -l。这个指标直接决定了你在单台机器上能模拟的最大量子比特数。
保真度一致性(Result Fidelity):对于精确模拟器,这个指标看起来有点多余——因为态矢量模拟器的浮点误差通常非常小。但真正要关注的是统计分布的稳定性:同样的种子,多次执行同一模拟任务,每次采样的标准偏差是否符合预期。某些模拟器在并行采样时会引入隐藏的随机数竞争,导致输出分布的细微偏差,这种问题单跑一次完全发现不了,必须做重复性测试。
这三个指标构成了基础的三维评估空间。实际使用中,我还会视场景增加一个“扩展性系数”(详见第 4 章),不过在最初的框架里,先把这三个测准就够用了。
2.2 环境基线的标准化
没有标准化的环境,任何性能数字都毫无意义。我的环境基线文件(environment.yaml)一般长这样:
硬件基线: cpu: 2x Intel Xeon Platinum 8375C @ 3.2GHz cores: 64 逻辑核 memory: 256 GB DDR4 gpu: NVIDIA A100 80GB (可选测试项) storage: NVMe SSD 软件基线: os: Ubuntu 22.04 LTS (kernel 5.15) python: 3.10.12 qiskit-terra: 0.25.4 qiskit-aer: 0.14.1 cirq: 1.3.0 qulacs: 0.6.3 scipy: 1.11.4 (用于对比线性代数后端) 线程设定: 单线程测试: OMP_NUM_THREADS=1 多线程测试: OMP_NUM_THREADS=32 每线程绑定核心: KMP_AFFINITY=granularity=fine,compact,1,0这里有个非常容易踩的坑:即使设置了OMP_NUM_THREADS,不同的 BLAS 库(OpenBLAS、MKL、BLIS)对线程池的管理方式完全不同。比如 OpenBLAS 有时会忽略环境变量,需要在代码里显式调用openblas_set_num_threads()。另一个是超线程的影响,我实测下来,32 物理核上的 Hyper-Threading 对模拟器性能几乎没有正向收益,某些内存密集型操作甚至可能慢 15%,所以线程数一般设为物理核数而不是逻辑核数。
环境基线定好之后,我会把完整环境信息写到结果文件头部,这样三个月后回看数据,依然能精确还原当初的软硬件上下文。
2.3 基准电路家族的设计
基准测试不能只跑一个电路,线路结构是模拟器性能的核心变量。我参考了当时读到的几份量子计算基准测试论文的思路,结合自己业务里常用的场景,最终把电路分成四类。
随机线路(Random Circuit):用 qiskit 的random_circuit生成,参数指定量子比特数、深度和支持的门集。这类电路没有结构特征,用来评估模拟器在无规律数据下的“裸性能”。这是最接近通用场景的测试,也是很多模拟器擅长且乐意被测试的类型。
量子傅里叶变换类线路(QFT):这是有明确数学规律的结构化线路,特点是门种类少、作用范围局部化。有些模拟器会针对这类线路做特殊的门融合优化,跑 QFT 的成绩会明显优于随机线路。
噪声采样线路(Noisy Sampling):在理想线路上追加单比特的比特翻转噪声或去极化噪声,然后执行大批量采样。这主要测的是噪声模型的处理效率和随机数生成器的吞吐量,在很多“带噪声的中等规模量子”算法场景里,这个指标比理想线路的执行时间更贴近实际。
变分层线路(VQE 典型层):模拟变分量子特征求解器(VQE)里的典型 ansatz(即参数化试探波函数)结构,包含大量参数化旋转门和纠缠层。这类线路看起来不复杂,但参数绑定和梯度计算(需要额外模拟多次)会让模拟器的执行策略完全变样。
四类电路组合起来,测试矩阵就有 4 个维度:量子比特数从 10 到 30 分级、线路深度从 10 到 200 分级、单线程与多线程并行、以及上述四类电路结构。完整跑一遍大约需要 8-12 小时,会生成几百个结果文件,但正是这种“地毯式轰炸”,才能暴露模拟器在不同场景下的真实边界。
3. 实操过程与核心环节实现
3.1 自动化基准测试框架的搭建
手动跑几百个测试组合是不现实的,必须写脚本自动化。这个脚本的逻辑其实不复杂,但有几个设计要点值得分享。
核心架构不复杂:一个控制脚本负责生成参数矩阵,一个执行模块负责调度模拟器进程,一个收集模块负责解析输出并写入统一的 CSV 文件。我用的语言是 Python,但实际在子进程里调用的是模拟器的原生接口。这样做有几个原因:第一,不同模拟器的 Python 接口都有各自的开销,如果进程内直接调用,接口开销会混入执行时间;第二,子进程隔离可以确保每个测试的初始状态完全干净,不会出现内存碎片或者线程池残留的问题。
# 简化版:单次执行的包装器 import subprocess import time import json import resource def run_simulation(backend_class, circuit_asset, shots=1000, threads=32): # 通过子进程运行,确保环境干净 cmd = [ "python", "backend_runner.py", "--backend", backend_class, "--circuit", circuit_asset, "--shots", str(shots), "--threads", str(threads), ] # 使用 /usr/bin/time 捕获峰值内存 wrapped_cmd = ["/usr/bin/time", "-v"] + cmd start_wall = time.time() proc = subprocess.run(wrapped_cmd, capture_output=True, text=True) wall_time = time.time() - start_wall # 从 stderr 解析 Max RSS 和 user/sys time stderr_text = proc.stderr max_rss_mb = parse_max_rss(stderr_text) / 1024 # KB -> MB sim_time = parse_sim_time(stderr_text) # 由 runner 写入 stderr return { "wall_time_sec": wall_time, "sim_time_sec": sim_time, "peak_memory_mb": max_rss_mb, "return_code": proc.returncode, }后端执行器 backend_runner.py 内部会根据传入的 backend 名称来装载对应的模拟器库。实际测试中,这里有一个很关键的小技巧:关闭模拟器库自身的进度条和日志输出。像 Qiskit Aer 在高并发采样时,如果开启了进度条,终端 I/O 会成为超乎想象的性能杀手,对执行时间的影响可能达到 20%-30%。
3.2 内存占用测量中的隐蔽陷阱
我单独用一小节讲内存测量,因为这是整篇方法论里最容易“测出假数据”的环节。
/usr/bin/time -v可以报告进程的最大常驻内存,但这个数字反映的是整个进程的生命周期内,操作系统分配给它的物理内存总量。问题在于,许多模拟器库(尤其是基于 Eigen 和 BLAS 的实现)会在启动时预分配大量工作空间,这部分内存即使没有被实际访问,也会踩进 Max RSS 里。这会导致一个奇怪的现象:模拟 20 比特的线路和模拟 10 比特的线路,Max RSS 几乎一样。原因是模拟器为最大支持规模预分配的缓冲池,而不是实际负载决定的。
为了拿到“真实工作集”而不是“预分配池”,我额外使用了两种辅助手段。一种是malloc_count这类动态库钩子,拦截malloc/free调用,统计真正的动态分配峰值;另一种是监控/proc/<pid>/status里的VmRSS字段,以 0.1 秒间隔轮询,取观测到的最大值。第一种精度高但要重编译模拟器,第二种通用性好但可能漏掉瞬时尖峰。在实际框架里,我以/usr/bin/time的 Max RSS 作为标准指标,用轮询方式做参考值,两者差异特别大时,就说明模拟器的内存管理行为异常,需要单独排查。
3.3 多线程扩展性测试的设计
单线程测速只是基线,现实中没人会用单线程跑 20 比特以上的模拟。多线程测试的重点不是比较线程数 1 和线程数 32 谁更快,而是观测加速比曲线的形状:理想情况下,32 线程应该带来接近线性的加速,但由于内存带宽、Cache 一致性和调度开销的存在,实际加速比会在某个线程数达到平台拐点。
我的测试矩阵里,线程数设置为 1、2、4、8、16、32 六档。对于同一基准电路,每个配置跑 3 次取中位数,然后计算加速比:
加速比(threads) = 单线程耗时 / threads线程耗时举个例子,我在 30 比特随机深度 50 的线路上测 Qiskit Aer 得到的数据大致是这样(基于常见机器的代表性数据,具体数值因配置而异):
| 线程数 | 单次执行耗时 (s) | 加速比 |
|---|---|---|
| 1 | 3.24 | 1.0x |
| 2 | 1.67 | 1.94x |
| 4 | 0.89 | 3.64x |
| 8 | 0.52 | 6.23x |
| 16 | 0.38 | 8.53x |
| 32 | 0.32 | 10.13x |
看到没有,32 线程时加速比只有 10 倍左右,远小于 32 倍。这不是 Aer 的 bug,而是内存带宽到达平台期的必然结果——态矢量模拟器的每一次门操作都要求把整个态向量从内存读到 CPU 寄存器又写回去,这些数据的流动受限于内存总线,线程再多也无济于事。
我建议每个做模拟器选型的团队都跑一次这种加速比曲线图。如果你的计算任务主要跑在 24 比特以下,也许 16 线程和 32 线程差距很小,那就可以在调度系统里少分配一半 CPU 资源,去跑更多并发任务,用吞吐量换单任务耗时。
3.4 弱扩展与强扩展测试
多线程之外,还有一个容易被忽略的维度:量子比特数变化时,模拟时间是怎么增长的。这里要区分两种扩展方式。
强扩展测试:固定线路结构(比如固定深度 50),只增加量子比特数,观察耗时变化。这测的是纯粹的资源增长速度,能直接推算出你手头机器的极限比特数。
弱扩展测试:让线路的“复杂度”和比特数同比例增长。对于量子线路来说,一个自然的选择是固定“每比特门数”,即比特越多,线路总深度同比例增加。这模拟的是真实算法扩展时的成本曲线,更贴近生产环境。
从数学上看,态矢量模拟的时间复杂度近似为 O(2^n * D),其中 n 是比特数,D 是线路深度,实际换算成对数坐标后应该是一条直线。任何明显的折点,都暗示模拟器在某个规模上切换了算法策略(比如从 CPU 主存切换到 GPU 显存),或者触发了某些缓冲区的重新分配。我在测试 Cirq 时就发现它在 12 比特附近会有一个不自然的拐点,追查后确认是它内部使用的稀疏矩阵压缩算法在 12 比特时切换了触发阈值。这种细微行为,不跑扩展性曲线是绝对发现不了的。
3.5 各模拟器的横向对比实测记录
方法论定完之后,我用它做了一次规模较大的横向对比。这里选型的模拟器是 Qiskit Aer、Cirq(默认模拟器)、Qulacs,外加一个纯 NumPy 实现的基线(用 einsum 做张量收缩),这样能直观看出成熟模拟器的优化收益在哪里。
实际测试配置:单机 64 逻辑核,Intel Xeon 8375C,256 GB RAM,固定线程 32。测试电路统一用random_circuit生成的 24 比特、深度 80 的随机线路。每个模拟器在同一机器上连续跑 10 次取中位数,避免单次抖动的影响。
测出的代表性数据(保持量级,具体值因版本会浮动):
| 模拟器 | 执行时间 (s) | 峰值内存 (GB) | 备注 |
|---|---|---|---|
| Qiskit Aer (CPU) | 0.42 | 0.53 | 自带膨胀优化,内存表现不错 |
| Qulacs | 0.28 | 0.61 | 执行最快,但预分配缓冲稍重 |
| Cirq (默认) | 2.15 | 1.22 | 适合快速原型验证,性能不是强项 |
| 纯 NumPy einsum | 4.87 | 1.05 | 作为桁架基线,代表非优化水平 |
单看执行时间,Qulacs 赢了 Aer 接近 40%。但如果把所有数据放在一起看,故事就没那么简单:Qulacs 在编译复杂线路(尤其是带自定义门的线路)时,时间消耗明显高于 Aer;而 Aer 在 GPU 模式下的大比特数优势,Qulacs 目前也没追上。所以结论不是“Qulacs 更快所以我选它”,而是“在纯执行密集随机线路的场景,Qulacs 有优势;在生产工作流里,要结合编译开销和生态兼容性来选”。
我心里对这份数据比较有底,因为所有测试都在同一环境基线、同一轮次下完成。不少团队发布模拟器测速时只写环境和时间,却不说测试电路及重复次数,这只能算“印象分”,达不到“基准线”的标准。
4. 常见问题与排查技巧实录
4.1 测试结果抖动到无法复现
第一个常见问题:同一台机器、同一段代码,跑两次结果差 30% 以上。遇到这种情况,基本绕不开这几个原因:CPU 自动频率缩放(turbo boost 和 thermal throttling)、内存 NUMA 亲和性丢失、以及后台进程抢占。
解决思路很直接:强制性能模式,在 Linux 下用cpupower frequency-set -g performance固定频率;给进程绑定 NUMA node,用numactl --cpunodebind=0 --membind=0防止跨 NUMA 节点访问内存——跨节点访问的延迟比本地节点高一大截,对内存密集型负载尤其致命。再有,测试期间把监控类服务(比如 CloudWatch Agent 或 Zabbix agent)停掉,这些后台服务看起来 CPU 占用不高,但会打断 CPU 的 C-state 深度睡眠,间接影响内存带宽测试效果。
我一般是以 3 次运行取中位数作为最终结果。中位数比平均数稳健,因为平均数会被偶尔一次的调度抖动完全带偏。
4.2 20 比特以上内存爆炸
这是最经典的经验问题。模拟 20 比特理论上只需要 2^20 * 16 字节 = 16 MB,但很多模拟器在实际跑 20 比特时会申请几个 GB 内存,甚至直接把机器跑死。原因在模拟器的多级缓冲设计:为了并行化采样,模拟器会为每个线程复制一份态向量的局部副本,再加上门矩阵的批量预计算机制,内存消耗会成倍放大。
解决办法分两步:第一,检查shots参数,部分模拟器为了快速采样会预先展开所有扇出态的副本,高 shots 时会带来巨大的临时内存,把 shots 降低到最小有效值,往往能显著降低峰值占用。第二,调低模拟器的并行采样缓冲区,比如 Aer 的max_parallel_shots和max_parallel_experiments。默认值通常假设你有足够内存,但实际情况并非如此。
我遇到过最夸张的案例:同样的 24 比特噪声模拟,默认参数的内存峰值为 31 GB,手动调优后降为 7 GB,耗时只增加了 12%。代价是采样次数多了,但远没到不可接受的程度。这说明默认配置和最优配置之间的偏差可能是数量级的。
4.3 编译时间与执行时间的归属不清
做基准测试时,有个特别容易犯的归类错误:把transpile编译时间计入模拟执行时间。实际业务里有大量场景是编译一次、执行多次,比如 VQE 算法会反复执行同一个参数化线路,编译开销在整个迭代过程中被摊销到很小。如果评测时把编译时间算进单次执行里,那 Qiskit 这类编译功能强大的框架就会非常吃亏。
我的处理方式是严格分离:transpile时间单独记录,作为“编译指标”;执行阶段只统计模拟器后端的运行耗时。这样能给出两个维度的数据,读者可以根据自己的使用模式灵活参考。如果是评估在线推理型任务(每次线路都不相同),就把编译+执行合并为端到端时间;如果评估的是迭代型任务,就参考纯执行指标。
4.4 GPU 模式测速为什么会“看起来更快”
GPU 模式下模拟器的基准测试有一个大坑:数据从 CPU 内存到 GPU 显存的搬运时间往往被省略。很多评测直接调用模拟器的 GPU 后端然后计时,但真实业务里,量子线路的描述、参数和测量结果都需要跨 PCIe 总线拷贝,这部分开销在小规模线路上甚至可能超过 GPU 计算本身。
我的做法是把 GPU 测试分成纯计算时间和端到端时间两个指标。纯计算时间只称 GPU 内核执行;端到端时间包括线路上传、编译、执行和结果回收。有很多场景下,24 比特线路的端到端时间比纯计算时间多 5-10 倍。这种测试框架下得出的结论才会对工程选型有参考意义。
4.5 线路表示不同导致的结果不可比
不同模拟器对“门”的切分方式完全不同。一个U3门在 Qiskit 里是一个原生操作,但在 Cirq 里可能被拆成三个单独的旋转门,这就导致同样语义的线路,在两种模拟器里实际执行的门数量不同,直接比执行时间没有意义。
解决方案是先用各模拟器支持的理想门集重新合成测试线路,确保门级语义等价,而不仅仅是“数学上等价”。更严苛一点的做法,是用 OpenQASM 作为中间表示统一描述线路,然后分别 transpile 到各模拟器的原生指令集。这样至少保证我们在比较的是“模拟器的执行能力”,而不是“模拟器解释高层指令的速度”。
5. 数据解读与一点个人体会
5.1 不要被单一数字牵着走
整个方法论做下来的最大收获是:性能评测永远需要一个语境。A 模拟器在随机线路上比 B 快 50%,只能说明它在这个具体任务上更快;你真实业务里跑的是 VQE,那这个结论几乎不迁移。公开论文或者厂商发布的测速数据,多数都在最有利的负载上测试,这个“最有利”通常和你的生产负载天差地别。
我自己的选择是既看基准数据也看自己的应用模式。在选模拟器时,把业务里最有代表性的三条线路拆出来,分别做执行时间和内存峰值测试,再结合公开基准数据综合判断。两条线并行,既不会闭门造车,也不会被外部的“宣传数字”带偏。
5.2 基准测试本身要定期重建
模拟器的版本迭代非常快,Qiskit Aer 每发布一个新版本,性能特征都可能发生肉眼可见的变化。固定一套测试脚本和线路集,每季度在同样的环境基线上重跑一次,能帮助团队及时发现性能回退或者确认新版本的优化收益。这套方法论的价值不在于某一次的测试结果,而在于它提供了一个持续观测的性能基线,让模拟器升级变成了可评估、可回滚的工程决策。
最后分享一个我自己坚持的小习惯:每次提交基准测试结果,我会把原始 JSON 数据、环境 YAML 和生成图表脚本一并提交到代码仓库,而不是只贴一张截图。数据、环境、代码三者对齐,才能支撑未来任何层面的复查和追溯。做量化基准测试最忌的就是“结果很丰满,过程不可查”,建立这套方法论之后,我们的每次性能结论都经得起回头验证,这比多跑几个漂亮的柱状图重要得多。