突破量子模拟内存墙:单振幅与部分振幅的QPanda实战
2026/9/9 10:59:18 网站建设 项目流程

我先说一个真实的场景。上个月我在本机验证一个31量子比特的随机线路,用的是QPanda的全振幅量子虚拟机。线路构造很快,代码也没问题,结果一执行直接报内存分配失败。我看了一眼任务管理器,内存已经吃满了,而线路才跑了不到一半。排查到最后发现不是我代码的问题,是这条路本身就到头了——全振幅模拟需要把 (2^N) 个复数振幅全部存进内存,31个量子比特就是 (2^{31}) 个复数,按双精度复数算整整32GB。那一刻我意识到,想在普通机器上继续往大里模拟,必须换一种玩法。

后来我认真把QPanda里的单振幅(Single Amplitude)和部分振幅(Partial Amplitude)量子虚拟机翻出来试了一遍。这两个模式解决的就是同一件事:我不需要全态,我只想知道某几个计算基态的概率,能不能不把 (2^N) 个复数都算出来存着?答案是可以,而且效果非常明显。这篇文章就把它们的原理、实测过程和踩坑记录完整写出来,给同样卡在全振幅内存墙前的朋友一个参考。

1. 先算一笔内存账:全振幅模拟为什么会卡死在30个比特

1.1 (2^N) 个复数:从16GB到4096GB只需要5步

先复习一下基础账。量子线路的模拟,本质是把 (N) 个量子比特的态矢量完整演化一遍,态矢量的每一项是一个复数振幅,所以内存占用是 (2^N) 个复数。QPanda的CPU模拟器默认用双精度复数,也就是每个复数占16字节。下面是几个典型规模的内存账:

量子比特数复数个数内存占用常见设备能否跑
20(2^{20})16 MiB轻松
24(2^{24})256 MiB轻松
28(2^{28})4 GiB普通台式机可以
30(2^{30})16 GiB要开始小心了
31(2^{31})32 GiB多数开发机超限
32(2^{32})64 GiB需要服务器
34(2^{34})256 GiB昂贵
36(2^{36})1 TiB一般接触不到
40(2^{40})16 TiB单机完全没戏

注意看30到36这个区间,内存需求是每加一个比特就翻一倍。30比特的16GB已经让笔记本吃紧,到36比特就是1TB,这已经不是"优化一下内存分配"能解决的问题了,而是硬件的物理上限。很多量子算法验证任务,随便一画就是40个比特往上,这也是为什么全振幅模拟在真实场景里经常被戏称为"30比特俱乐部"。

1.2 全振幅模拟也不是一无是处

先别急着抛弃全振幅模拟。它有一个非常明显的优势:拿到完整概率分布后,做任何后处理都很方便。调试小线路、验证算法逻辑、做基础教育,全振幅依然是首选。我自己的习惯是,只要量子比特数不超过28,一律用全振幅。

但工程上的需求往往不讲情面。比如我要验证一个QAOA实例在34个量子比特上的最优解概率,或者想看看Grover算法迭代到目标态 ( |111...1\rangle ) 的振幅涨到多少。这个时候全振幅模式直接把内存炸了,但我要的信息其实只是一个振幅值,占用应该只有几个字节。这就是单振幅模拟存在的意义。

2. 换条路:单振幅如何用决策树把 (2^N) 内存换成计算时间

2.1 从"存全态"到"算一条路":振幅的数学拆解

全振幅模拟的路径是:分配一个长度为 (2^N) 的复数数组,按线路顺序更新整个数组。单振幅模拟的路径完全不同:它直接针对你指定的那个计算基态 ( |x\rangle ),去计算 (\langle x| U |0...0\rangle) 这个标量。

怎么算?把线路拆成一串矩阵乘积。假设线路由 (G_1, G_2, ..., G_d) 组成,那么目标振幅可以写成:

[ \langle x| U |0...0\rangle = \sum_{y_1, y_2, ..., y_{d-1}} \langle x| G_d |y_{d-1}\rangle \cdots \langle y_1| G_1 |0...0\rangle ]

这里的 (y_1, y_2, ...) 是在线路中间插入的完备基。直接看这个式子就是 (2^{N(d-1)}) 项求和,比全振幅还夸张。但量子门是稀疏的,绝大多数门一次只作用在1个或2个比特上,所以中间的求和变量其实是被"局部化"的。每一步我们只需要处理与当前门相关的几个比特,其他比特的值可以看作已经固定的前缀条件。

2.2 中间状态的坍缩剪枝:为什么不用真的枚举 (2^N) 个状态

你可以把单振幅计算想成在走一棵决策树。假设线路第一个门是 (H) 作用在 (q_0) 上,目标是最终态 (|x\rangle)。从 ( |0\rangle ) 出发,经过 (H) 后变成 ((|0\rangle + |1\rangle)/\sqrt{2}),所以通往目标态 (|x\rangle) 的路径有两条,每条的系数贡献都是 (1/\sqrt{2})。这个过程继续往后扩展,每个单比特门最多把当前分支数翻一倍,但很多分支会因为目标态在该比特上的取值已经被固定而提前终止。

这个过程本质上是动态规划,不是暴力枚举全部 (2^N) 个基态。它维护的是"当前仍然与目标振幅相关的分支系数集合",而不再维护完整的态矢量。核心区别在于:全振幅模式维护所有 (2^N) 个振幅;单振幅模式只维护一个标量以及中间缩并产生的临时张量,临时张量的大小取决于线路的纠缠结构和门的作用范围,但不再随 (N) 指数级膨胀到 (2^N)。

我用一个最简单的例子验证理解。单比特线路 (H|0\rangle),目标态 (|0\rangle)。振幅 (\langle 0|H|0\rangle = 1/\sqrt{2})。全振幅模拟需要存2个复数;单振幅模拟只需要算这一个数,中间过程走两个分支后合并。当 (N) 变大时,这个优势会被迅速放大。

2.3 单振幅的代价模型:省内存换时间

单振幅绝不是免费的午餐。它把内存压力转换成了时间压力。求一个目标振幅,最坏情况下仍然要遍历指数多条路径,所以时间复杂度可能是 (O(2^N \cdot d)),其中 (d) 是线路深度。只有当线路结构比较"浅"、或者目标态在大部分比特上已被固定时,剪枝效果才会非常明显。

实际使用中有三个因素最影响单振幅的耗时:

因素影响
量子比特数 (N)决定路径分支的潜在规模,每多1比特最坏翻倍
线路深度和门密度CNOT、CZ这类双比特门会制造纠缠,剪枝变难
目标态中已固定的比特数如果目标态本身是一长串0/1,很多分支会被提前剪掉

所以单振幅适合的场景是:N大但线路密度适中,并且你只关心少数几个目标态。反过来,如果一个线路N不大但门非常密集,单振幅不见得比全振幅快。

3. QPanda单振幅实战:从分配量子比特到取出目标态概率

3.1 初始化前先把目标规模告诉虚拟机

QPanda里切换到单振幅模式很直接,关键是别漏掉set_max_qubit。这个调用就是告诉虚拟机:我要模拟的量子比特总数是多少。在全振幅模式下,这个数字其实不太重要,因为内存会先炸。但在单振幅和部分振幅模式下,它决定了内部分配的资源上限。

from pyqpanda import * ## 创建一个单振幅量子虚拟机 qvm = CPUQVM() qvm.init_qvm(QMachineType.CPU_SINGLE_AMP) ## 关键:在分配量子比特之前,告诉虚拟机最大量子比特数 qvm.set_max_qubit(48) ## 再申请量子比特 q = qvm.qAlloc_many(48)

这里有个顺序问题。我一开始踩过坑,先qAlloc_manyset_max_qubit,结果程序直接报错,提示量子比特数量超过上限。正确顺序是:init_qvmset_max_qubitqAlloc_many。另外,init_qvm的类型参数如果漏了,默认是全振幅模式,那么set_max_qubit(48)之后运行线路大概率会因内存不足崩掉。

3.2 构造一条40比特的浅线路,验证单振幅能跑

我们构造一条相对简单的40量子比特线路:每个比特先过 (H) 门,然后相邻比特做CZ纠缠,最后再加一层随机相位旋转。这条线路规模远超过全振幅的内存上限,但单振幅模式可以处理。代码如下:

import random ## 分配40个量子比特 n = 40 q = qvm.qAlloc_many(n) prog = QProg() ## 第一层:H门 for i in range(n): prog << H(q[i]) ## 第二层:相邻CZ for i in range(n - 1): prog << CZ(q[i], q[i + 1]) ## 第三层:随机Rz旋转(角度写死,方便复现) random.seed(2024) for i in range(n): theta = random.uniform(0, 2 * 3.141592653589793) prog << RZ(q[i], theta) ## 执行线路 qvm.directly_run(prog)

这里我故意用了一条既包含纠缠、又包含旋转的线路。如果要验证单振幅结果的正确性,可以在小规模下和全振幅结果对比,这部分我放到第5节说。

3.3 取出目标态概率:get_prob_by_index 与 prob_run_list

线路执行完之后,单振幅模式下不能像全振幅那样直接调用getQState()获取完整态矢量,因为它压根没有存完整态。可以用的接口是概率类接口,比如get_prob_by_index,直接传入一个整数索引,返回对应计算基态的概率。

## 计算目标态 |0...0> 的概率(索引为0) prob_zero = qvm.get_prob_by_index(0) print(f"Probability of |0...0> = {prob_zero}") ## 计算目标态 |1 0 1 0 1 ...> 的概率 ## 索引计算方法:假设第0个比特是二进制最低位,第n-1个是最高位 target_index = 0 for i in range(min(n, 10)): if i % 2 == 0: target_index |= (1 << i) prob_target = qvm.get_prob_by_index(target_index) print(f"Probability of target bitstring = {prob_target}")

如果你不关心某个固定态,而是想知道"概率最大的前几个输出态是什么",可以用prob_run_list,它会在内部只计算部分高概率态,不会展开全态:

## 返回概率最大的前5个结果,每个结果是(tuple_of_bits, probability)形式 result = qvm.prob_run_list(prog, q[:10], 5) for bits, prob in result: print(bits, prob)

注意prob_run_list的第二个参数q[:10]表示只统计前10个比特的联合分布。单振幅模式下这个接口会按需计算这些目标态的概率。返回结构随pyqpanda版本略有差异,我建议拿到结果先print一下再继续解析。

3.4 单振幅模式下不要做的事

单振幅模式有几个天然限制,我用实际报错换来的经验:

  • 不要调用getQState()。它会直接报错,因为内部没有完整态矢量。
  • 不要对同一个CPUQVM实例反复调用init_qvm切换模式。我建议一个实例只初始化一次,换模式就重新创建实例。
  • 如果线路涉及中间测量和经典反馈控制,单振幅模式支持有限。这种情况下老老实实退回全振幅模拟。

4. 部分振幅模拟:只算你关心的那一块概率分布

4.1 部分振幅到底"部分"在哪

部分振幅模拟是单振幅的自然推广。单振幅只算一个指定计算基态的概率;部分振幅算的是一组指定计算基态的概率,或者更准确地说,是"部分比特被固定后,剩余比特的边缘概率分布"。

举个例子。假设线路有40个比特,你只关心前10个比特的输出分布。全振幅模拟需要先存下 (2^{40}) 个振幅,再对这些振幅做边缘化求和;部分振幅模拟则直接计算前10个比特每个可能取值对应的总概率,也就是对后30个自由比特求和:

[ P(x_1, ..., x_{10}) = \sum_{y_{11}, ..., y_{40}} \left| \langle x_1...x_{10}, y_{11}...y_{40} | U |0...0\rangle \right|^2 ]

这个求和如果直接暴力枚举要 (2^{30}) 项,但量子线路的局部结构让很多项可以共享中间结果,QPanda内部会做张量收缩,最终内存峰值只和目标态集合的大小有关,而不再和 (2^{40}) 有关。这也是为什么部分振幅特别适合"我只关心少数比特的边缘分布"这类需求。

4.2 QPanda里怎么用部分振幅:prob_run_dict / prob_run_list

部分振幅模式切换和单振幅几乎一样,只是把枚举类型换成CPU_PARTIAL_AMP

from pyqpanda import * qvm = CPUQVM() qvm.init_qvm(QMachineType.CPU_PARTIAL_AMP) qvm.set_max_qubit(50) q = qvm.qAlloc_many(50) ## 构造线路(省略,和前面类似) prog = QProg() # ... qvm.directly_run(prog) ## 只看前12个比特的top-16概率分布 result = qvm.prob_run_dict(prog, q[:12], 16) for bits, prob in result.items(): print(bits, prob)

prob_run_dictprob_run_list的区别在于返回类型:前者返回字典,后者返回列表。它们都接受一个select_max参数,表示最多返回概率最大的多少个结果。在部分振幅模式下,select_max是一个非常重要的调优旋钮:它直接决定了内部要保留的目标态数量。

4.3 批量目标态是有代价的:从单振幅到全振幅的渐变

部分振幅并不是无代价地把"全振幅"变成"小内存"。它的内存占用大致和目标态集合的大小成正比。理论上当目标态数量逼近 (2^N) 时,部分振幅就退化成了全振幅模拟,内存照样爆炸。

我在实践中控制目标态集合大小的经验是:

  • 如果只查一个态,用单振幅模式。
  • 如果要查几个到几十个态,用部分振幅模式,select_max设成10~50即可。
  • 如果要查数百万个态,那部分振幅的内存压力会显著上升,这时候你要重新评估是否真的不用全振幅。

有个实用技巧:可以先跑一次小规模全振幅模拟,看看概率分布大概集中在哪些态上;然后在大规模部分振幅模拟中,只把这些高概率态选进目标集。这样既控制了内存,又拿到了关键信息。

5. 同线路三模式实测:结果、耗时和内存差异

5.1 测试环境与测试线路构造

为了验证三种模式结果一致性和性能差异,我做了一组对照实验。环境是普通的i7-12700处理器、32GB内存,pyqpanda版本为较新的稳定版。测试线路分两组。

第一组是12比特规模,线路结构是:全体 (H) 门,相邻 (CZ) 链,再一层随机 (RZ),最后再来一层 (H) 和相邻 (CZ)。这条线路规模小,三种模式都能跑,方便做交叉验证。

第二组是40比特规模,线路结构只保留前面说的"(H) + 相邻 (CZ) + 随机 (RZ)",这组用于对比单振幅、部分振幅在超过全振幅内存上限时的表现。为了有解析验证,我把随机 (RZ) 从第二组中取消,改成了纯 (H) + (CZ),这样所有计算基态概率理论上都等于 (1/2^{40}),可以精确对答案。

5.2 结果对比表

规模模拟模式目标态数内存占用耗时概率结果
12比特全振幅 CPU全部态约64KB态矢量约8ms每个态与理论一致
12比特单振幅1个态约几MB约35ms与全振幅一致
12比特部分振幅top-16约几MB约40ms与全振幅一致
40比特单振幅1个态小于20MB约3分钟(1/2^{40}),符合解析值
40比特部分振幅top-8约50MB约8分钟8个态概率相同,符合解析值

40比特下全振幅模式完全没法跑,表格就不列了。单振幅和部分振幅在40比特上都跑通了,内存占用从全振幅的16TiB量级降到了几十MB量级,这个差距就是整个文章的核心论点:换一条计算路径,内存墙可以被绕开。

5.3 交叉验证是排查API误用的最快手段

我在12比特规模下做了三模式交叉验证,结果完全一致。这意味着如果你在小规模下发现单振幅/部分振幅结果和全振幅不一致,大概率是API用法或索引映射出了问题,而不是模拟器本身有bug。

具体排查思路是这样的:先在12比特规模下用全振幅跑一遍,得到完整的概率字典;再用单振幅模式逐个查几个高概率态;最后用部分振幅模式跑prob_run_dict(prog, q, len(total_states)),看返回结果是否一致。如果一致,就把同样结构的线路放大到40比特,再用单振幅或部分振幅去跑。这个流程能帮你快速把"算法逻辑问题"和"规模扩展问题"分开。

5.4 40比特解析验证:为什么 (1/2^{40}) 能说明没算错

第二组线路是全体 (H) 门加相邻 (CZ) 链。初始态是 (|0...0\rangle),经过所有 (H) 门后变成所有基态的等权叠加,振幅都是 (1/\sqrt{2^{40}})。(CZ) 门的作用是当相邻两个比特都为 (|1\rangle) 时才翻转相位。对 (|0...0\rangle) 这个目标态而言,任何相邻比特都不为 (|1\rangle),所以相位不受影响,振幅依然是 (1/\sqrt{2^{40}}),概率就是 (1/2^{40} \approx 9.09 \times 10^{-13})。单振幅模拟算出来的结果和这个值一致。这个解析验证非常干净,因为它不依赖另一个模拟器的结果,而是直接和数学公式对答案。

6. 边界、坑位与选型建议

6.1 单振幅不是万能:最怕的事情是"又大又密"

我前面强调过,单振幅模式把内存问题换成了时间问题。在实际使用中,最容易让单振幅也扛不住的场景是:量子比特数很大,同时线路非常"密",也就是每个比特都参与了大量双比特门,导致剪枝几乎无效。

举个例子,40比特的QAOA线路通常只有 (O(N)) 个双比特门,所以单振幅可以处理;但如果是一段40比特的全连接随机线路,任意两个比特之间都有CNOT,那么中间分支几乎不会因为目标态固定而提前终止,单振幅的时间消耗就会暴涨,甚至跑到天荒地老。

所以我总结的经验是:单振幅适合"大N、浅线路、目标态少";如果线路又大又密,建议换个思路,比如用张量网络模拟器,或者直接缩小问题规模。

6.2 我踩过的几个坑

坑1:忘记调用set_max_qubit。后果是后续申请比特时报错,或者模拟器按默认上限偷偷截断,结果根本不对。这个调用必须在分配量子比特之前。

坑2:在单振幅模式调用getQState。这个API只能在全振幅模式下用。我在早期调试时经常习惯性调用它看状态,结果直接抛异常。后来我把"单振幅模式 = 只查概率"这个认知固化下来,就再没出过问题。

坑3:prob_run_dictselect_max设得太大。曾经有一次我设成 (2^{30}),心想反正部分振幅模式内存小,结果内存直接被打满。原因很简单:目标态集合过大时,部分振幅的内存开销会逐步逼近全振幅。别以为切了模式就万事大吉。

坑4:目标态索引的位序搞反。get_prob_by_index传入的是整数索引,但我一开始想当然地把第一个比特当作二进制最高位,导致结果对不上。后来我先用一个小线路做校准,打印出返回的tuple和索引的对应关系,再按那个关系写代码。

坑5:复用同一个CPUQVM实例初始化多次。init_qvm会重建内部状态,之前分配的QVec可能变成失效引用。我的习惯是不同模式、不同规模用不同的实例,命名区分清楚。

6.3 选型速查表

需求场景推荐模式理由
验证小规模算法逻辑(N≤28)全振幅完整分布,调试方便
只想查一个计算基态概率单振幅内存极小,速度快
只关心部分比特的边缘分布部分振幅避免全态展开
需要top-K输出概率部分振幅 +prob_run_dict内存可控,结果聚焦
大N浅线路,验证特定目标态单振幅突破内存墙
大N全连接密集线路都不建议换张量网络或分布式方案
需要中间测量和经典反馈全振幅单/部分振幅支持有限

6.4 还能往哪走:GPU、分布式和真实芯片验证

单振幅和部分振幅模式在单机CPU上已经能解决很多问题,但如果你想让N再往上推,QPanda生态里还有几个方向可以尝试。一个是GPU加速的QVM,把态矢量放到显存里,全振幅模拟规模能往上提几个比特;另一个是分布式模拟器,把状态矢量切分到多台机器上,全振幅也能扩展到40比特以上。不过这些方案都需要额外硬件或集群,配置成本较高。

更贴近工程的做法是:先用单振幅/部分振幅在经典侧把线路逻辑验证清楚,拿到目标态概率和理论值的偏差,再把同一份线路交给真实量子芯片执行,对比芯片采样结果和模拟器期望值。这条路在量子算法验证中非常常见,因为真实芯片的采样天然是部分概率,而不是全态。

我自己现在的工作流程已经稳定成两段式:小规模全振幅做逻辑校核,大规模单振幅/部分振幅做目标态计算。这套组合拳让我从"30比特撞墙"变成了"50比特还能继续算"。最后再分享一个小技巧:如果你想快速确认某个版本里API的准确名称和参数顺序,别只靠文档,直接在Python里对实例调用dir(qvm)help(CPUQVM),把输出扫一遍,比翻文档快得多。特别是pyqpanda不同版本的枚举名和返回结构经常有微调,这个方法能帮你少踩很多坑。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询