教程 9: 加速循环矩阵-向量乘

Note

阅读时间:20-25 分钟

难度:中级

先修:教程 1(CANN 模型的构建与使用),熟悉 NumPy / JAX 矩阵乘法

本教程介绍支撑 CANN1D / CANN2D 加速的「策略模式」:模型在 self.irec_backend 上持有一个策略对象,update() 只需要调用它。读完本节你将知道五种 accl_mode 如何为你的工作负载选择、FFT 路径何时适用,以及如何自己添加一个后端。


1. 为什么要加速?

CANN 动力学几乎所有时间都花在这一行上::

Irec = self._accel_Irec(self.r.value)  # → self.irec_backend(r)

在 dense 模式下,这是一个完整的 W @ r 矩阵-向量乘,复杂度为 O(n²)。对于 num = 4096 的 CANN1D,每一步都是 16M 元素乘法,而且它会主宰所有其他开销。五个 accl_mode 值提供了让这一步更便宜的不同方式——要么通过近似(低秩 SVD),要么利用隐藏结构(循环 FFT)。

[1]:
# 快速演示:在不同的 num 下计时 dense 矩阵-向量乘。
import time
import brainpy.math as bm
import numpy as np
from canns.models.basic import CANN1D

bm.set_dt(0.1)
results = []
for num in [128, 512, 2048, 4096]:
    m = CANN1D(num=num, accl_mode="normal")
    r = bm.random.rand(num)
    # warm up
    for _ in range(3):
        m.irec_backend(r)
    t0 = time.perf_counter()
    for _ in range(30):
        m.irec_backend(r)
    results.append((num, (time.perf_counter() - t0) / 30 * 1e6))

print(f"{'num':>6}  {'dense (μs/step)':>16}")
for num, t in results:
    print(f"{num:>6}  {t:>16.1f}")

   num   dense (μs/step)
   128               7.6
   512               9.7
  2048               8.4
  4096              30.9

dense 矩阵-向量乘大致以 num² 增长:128 → 512 是 4× 的耗时跳变,512 → 2048 又跳 16×。任何能把它降到 O(n · k) 或 O(n log n) 的方法都会很快见效。

2. 策略模式:self.irec_backend

每个被加速的模型持有一个后端对象::

model.irec_backend        # ← 策略
model.irec_backend(r)     # ← 调用点(也就是 update() 干的事)

模型类并不「知道」当前是哪个模式。后端既负责预计算(SVD、FFT),也负责每一步的矩阵-向量乘。新增一个加速模式是一种**纯加法**——只在 canns.models.basic.accel 里加,cann.py 不需要任何改动。

[2]:
from canns.models.basic import CANN1D
import brainpy.math as bm

bm.set_dt(0.1)
m = CANN1D(num=128, accl_mode="fast")
print(f"  accl_mode:    {m.accl_mode}")
print(f"  accl_k:       {m.accl_k}")
print(f"  is_accelerated: {m.is_accelerated}")
print(f"  backend type: {type(m.irec_backend).__name__}")
print(f"  backend.mode: {m.irec_backend.mode}")
print(f"  backend.k:    {m.irec_backend.k}")
print(f"  U shape:      {m.irec_backend.U.shape}")
print(f"  V shape:      {m.irec_backend.V.shape}")

  accl_mode:    fast
  accl_k:       8
  is_accelerated: True
  backend type: LowRankIrec
  backend.mode: fast
  backend.k:    8
  U shape:      (128, 8)
  V shape:      (128, 8)

公开的 accl_mode / accl_k / is_accelerated 属性是 self.irec_backend 的薄代理;测试套件也读 m._U_l 和 m._V_l,这些属性把请求转发到 self.irec_backend.U / .V 用于内省。

3. 决策表——我该用哪个模式?

内容跟 canns.models.basic.accel 顶部的决策表一样,但用 list-table 渲染更友好。

accl_mode

数学形式

精确?

n=2048 加速比

何时使用

"normal"

W @ r

精确

1×

小网络(num <= 128),或需要教科书级别的参考实现。

"fast"

U @ (Vᵀ @ r),k 取默认值

近似(亚 mrad)

30–245×

生产推荐。默认 k 下 bump 位置误差 < 5 mrad。

"ultra-fast"

U @ (Vᵀ @ r),k=1 (1D) / k=4 (2D)

较粗

100–500×

只需 bump 存在、对位置精度不敏感时。

"auto"

根据 accl_target_err_mrad 从 SVD 谱中选 k

近似(不超过预算)

不定

想设置「误差预算」(mrad) 而非具体 rank。

"fft"

``real(ifft(K_fft ⊙ fft(r)))``(1D)/ 2D 类比

精确

25–50×

**只能**用于 endpoint=False 的干净环/环面。默认 endpoint=True 下回退到 dense 并告警。

4. 各模式详解

4.1 "normal" —— 全秩参考实现

无预计算。每一步 W @ r。是精度对比的参考;当网络小到任何近似都不划算时也是最快的选项。

[3]:
m = CANN1D(num=128, accl_mode="normal")
print(f"  type: {type(m.irec_backend).__name__}")
print(f"  k={m.accl_k}  (full-rank / 无近似的哨兵值)")
print(f"  U is None: {m.irec_backend.U is None}")

  type: DenseIrec
  k=-1  (full-rank / 无近似的哨兵值)
  U is None: True

4.2 "fast" —— 生产推荐

在默认 rank(CANN1D k=8,CANN2D k=32)处截断 SVD。make_conn 构造的高斯距离核可以用低秩矩阵很好地逼近,8 个分量就足以捕获 99% 以上的奇异值能量。完整扫描见 benchmarks/canns-accl/lowrank。

[4]:
m = CANN1D(num=2048, accl_mode="fast")
print(f"  type: {type(m.irec_backend).__name__}")
print(f"  k={m.accl_k}  (CANN1D fast 的默认)")
print(f"  factor shapes: U={m.irec_backend.U.shape}, V={m.irec_backend.V.shape}")

  type: LowRankIrec
  k=8  (CANN1D fast 的默认)
  factor shapes: U=(2048, 8), V=(2048, 8)

4.3 "ultra-fast" —— 仅 k=1 / k=4

用于只需 bump 存在的场景。高斯核的主导奇异向量本身就是一个高斯,因此 k=1 就足以保留 bump 的**追踪**(只是 bump 宽度会变宽)。在 k=1 下 bump 较宽、位置误差是几个 mrad——非常 num / length 很大的情况下有用,或生产中需要更宽裕度时。

[5]:
m = CANN1D(num=2048, accl_mode="ultra-fast")
print(f"  type: {type(m.irec_backend).__name__}")
print(f"  k={m.accl_k}  (CANN1D ultra-fast = k=1)")

  type: LowRankIrec
  k=1  (CANN1D ultra-fast = k=1)

4.4 "auto" —— 误差预算驱动的 rank 选择

你设置**误差预算**(accl_target_err_mrad),工厂会选最小 rank 使得预测的 bump 追踪误差不超过预算。启发式与 canns_lowrank 基准对齐,详见 canns.models.basic.cann._pick_k_for_err_target()。

[6]:
m = CANN1D(num=2048, accl_mode="auto", accl_target_err_mrad=0.5)
print(f"  mode:    {m.accl_mode}")
print(f"  picked k={m.accl_k}  (对应 0.5 mrad 预算)")

# 运行时收紧预算会选更高 k。
m.set_accl_mode("auto", target_err_mrad=0.1)
print(f"  收紧到 0.1 mrad 后: k={m.accl_k}")

  mode:    auto
  picked k=5  (对应 0.5 mrad 预算)
  收紧到 0.1 mrad 后: k=8

4.5 "fft" —— 精确的循环矩阵-向量乘

在均匀环上的高斯距离核是右循环的,意味着它的矩阵-向量乘是循环卷积——这正是卷积定理说可以由 FFT 在 O(n log n) 内完成的运算。这并非近似:在干净循环阵上,结果在 fp32 精度内等于 W @ r。

注意:canns 默认的 endpoint=True 网格并不是循环的(环接点会复制端点),因此在默认模型上请求 accl_mode="fft" 会回退到 dense 并告警。修复方法是先把网格覆盖成 endpoint=False 并重建 conn_mat,再切换到 FFT 模式。

[7]:
# 1) 默认网格(endpoint=True)—— 回退到 dense 并告警。
import warnings
with warnings.catch_warnings(record=True) as caught:
    warnings.simplefilter("always")
    m = CANN1D(num=128, accl_mode="fft")
print(f"  requested: fft → got: {m.accl_mode}  (静默回退)")
print(f"  warning: {[str(w.message) for w in caught][0][:80]}...")

  requested: fft → got: normal  (静默回退)
  warning: accl_mode='fft' requested but the grid appears to use endpoint=True (x[0] = x[-1...
[8]:
# 2) 干净网格(endpoint=False)—— FFT 路径变为精确。
m = CANN1D(num=128, accl_mode="normal")
m.x = bm.linspace(-bm.pi, bm.pi, 128, endpoint=False)
m.conn_mat = m.make_conn()
m.set_accl_mode("fft")
print(f"  mode: {m.accl_mode}, K_fft shape: {m.irec_backend.K_fft.shape}")
print(f"  backend type: {type(m.irec_backend).__name__}")

  mode: fft, K_fft shape: (128,)
  backend type: CirculantFFTIrec1D
[9]:
# 验证:在干净网格上,FFT 矩阵-向量乘与 dense 在 ~1e-5 量级一致。
r = bm.random.rand(128)
dense = np.asarray(m.conn_mat @ r)
fft_out = np.asarray(m.irec_backend(r))
print(f"  max |dense - fft|: {np.abs(dense - fft_out).max():.2e}")

  max |dense - fft|: 1.53e-05

5. 端到端动力学:低秩 ≈ dense

近值的全部意义就是「近似正确」。下面我们验证在移动刺激下,"fast" 模型与 "normal" 模型产生相同的 r_max 轨迹。

[10]:
bm.set_dt(0.1)
num = 256
T = 80
m_n = CANN1D(num=num, accl_mode="normal")
m_f = CANN1D(num=num, accl_mode="fast")
x = np.asarray(m_n.x)
z_range = float(m_n.z_range)

def run(m):
    traj = np.empty((T, num), dtype=np.float32)
    for t in range(T):
        pos = np.pi * t / (T - 1)
        d = (x - pos) % z_range
        d = np.where(d > z_range / 2, d - z_range, d)
        inp = (m.A * np.exp(-0.25 * (d / m.a) ** 2)).astype(np.float32)
        m.update(inp)
        traj[t] = np.asarray(m.r.value)
    return traj

r_n, r_f = run(m_n), run(m_f)
print(f"  r_max trajectory max diff: {np.abs(r_n.max(1) - r_f.max(1)).max():.2e}")
print(f"  ✓ (fast 与 dense 在 80 步内一致到 ~1e-7)")

  r_max trajectory max diff: 9.53e-07
  ✓ (fast 与 dense 在 80 步内一致到 ~1e-7)

6. 运行时切换模式

因为后端只是一个属性,所以你可以通过 set_accl_mode 在运行时替换策略。这对「先 dense 启动、然后加速」的工作流很有用(例如先用参考动力学稳定一个 bump,再切到 FFT 跑一段长序列)。

[11]:
bm.set_dt(0.1)
m = CANN1D(num=256, accl_mode="normal")
print(f"  start:       mode={m.accl_mode}  backend={type(m.irec_backend).__name__}")
m.set_accl_mode("fast")
print(f"  → fast:      mode={m.accl_mode}  backend={type(m.irec_backend).__name__}  k={m.accl_k}")
m.set_accl_mode("auto", target_err_mrad=0.5)
print(f"  → auto:      mode={m.accl_mode}  backend={type(m.irec_backend).__name__}  k={m.accl_k}")
m.set_accl_mode("normal")
print(f"  → normal:    mode={m.accl_mode}  backend={type(m.irec_backend).__name__}")

  start:       mode=normal  backend=DenseIrec
  → fast:      mode=fast  backend=LowRankIrec  k=8
  → auto:      mode=auto  backend=LowRankIrec  k=5
  → normal:    mode=normal  backend=DenseIrec

7. 添加新后端

三步搞定,都改在 canns.models.basic.accel 里。cann.py 不用动。

  1. 写一个类,持有所需的预计算因子并实现 __call__(self, r):

class BlockCirculantIrec:
    mode = "block-circulant"
    k = -1

    def __init__(self, blocks):
        self.blocks = blocks  # list of (W, U_fft, V_fft) per block

    def __call__(self, r):
        # 在这里写你的数学
        ...
  1. **在 ACCL_MODES 中注册该模式**(如果它有 per-family 默认 rank,也加到 ACCL_DEFAULT_K):

ACCL_MODES = ("normal", "fast", "ultra-fast", "auto", "fft", "block-circulant")
  1. 在 :func:`make_irec_backend` 里加一个分发分支:

if mode == "block-circulant":
    return BlockCirculantIrec(_compute_block_factors(model.conn_mat))

模型在 self.irec_backend 上看到一个新 callable;下游所有逻辑(update()、属性代理、set_accl_mode)都照旧工作。

8. 参考资料

  • benchmarks/canns-accl/lowrank/ —— 跨四个低秩模式、多个 num 和 length 的精度 / 加速扫描。ACCL_DEFAULT_K 中的默认 rank 来自此基准。

  • benchmarks/canns-accl/fft/ —— FFT 模式在干净 / 默认网格下的精确 vs 近似对比。

  • canns.models.basic.cann._pick_k_for_err_target() —— "auto" 模式所用的基于谱的 rank 选择器,含校准表。

  • canns.models.basic.accel —— 策略模块本身;模块顶部那张决策表是「我该用哪个模式?」的官方参考。