用 Mojo 加速矩阵乘法:可复现的实验设计

先说清楚要做什么

用 Mojo 把矩阵乘法从朴素三重循环加速到 SIMD 加并行,是官方展示语言能力的第一案例;但网上流传的复现大多只有”我跑出了 xx 倍”的结论,没有可复现的实验设计。这篇反其道而行:倍数是结果,实验纪律才是主题。同一份代码走四步演进——朴素 ijk、循环重排、行级并行、SIMD 向量化——每一步都能在你自己的机器上复跑、验证、计时。语法细节不展开,缺口查《Mojo 语法要点》与《Mojo 基础知识参考字典》。”矩阵乘法”这个约束会让每个设计决策都变重:数据怎么进内存、循环按什么顺序走、每一步改动的收益怎么归因,全都绕不开。

指标 目标 怎么测
正确性 加速版与朴素版逐元素最大绝对误差为 0 或浮点噪声级 确定性输入跑两版,逐元素比 max_abs_err
耗时 每版本报告毫秒级耗时,波动可见 perf_counter_ns 计时,预热后多次取最小值
可复现 别人按同一流程能跑出同一趋势 记录 CPU 型号、物理核数、OS、mojo –version

实验纪律:三条反模式先立起来

性能实验的结论可信度取决于纪律,代码之前先立规矩。第一条反模式:结果没人用,计算被整体删除。编译器看到矩阵乘出来没人读,有权把整个循环优化掉——计时器测的是空气。解法是算完后打印 checksum,强迫计算真实发生。第二条:单次计时。操作系统调度、频率迁移、缓存冷热都会让单次耗时抖动,预热一次后重复计时取最小值,才能让版本间的差异大于噪声。第三条:跨语言倍数当结论。官方系列博客《How Mojo gets a 35,000x speedup over Python》(2023 年公开口径)里的倍数,基线是纯 Python 朴素循环——换成 NumPy 基线,倍数立刻缩百倍。报倍数必须同时报基线、环境与矩阵规模,本篇的口径是”同语言内四步演进的相对变化”,不背官方倍数。

基础设施:自己搭 Matrix,不用 Tensor

官方论坛在 2025 年 5 月宣布淘汰 Tensor API,25.4 前后的旧教程大量使用 Tensor,照抄会撞上弃用警告甚至编译失败。本篇直接用 UnsafePointer 搭一个最小 Matrix——既绕开 API 迁移,又顺手把 Mojo 的内存模型看明白:

@fieldwise_init
struct Matrix:
    var rows: Int
    var cols: Int
    var data: UnsafePointer[Float64]   # 行主序裸指针,绕开正被淘汰的 Tensor API

    fn __init__(out self, rows: Int, cols: Int):
        self.rows = rows
        self.cols = cols
        self.data = UnsafePointer[Float64].alloc(rows * cols)

    fn get(self, r: Int, c: Int) -> Float64:
        return self.data[r * self.cols + c]

    fn set(mut self, r: Int, c: Int, v: Float64):
        self.data[r * self.cols + c] = v

两个刻意为之的细节。取值用普通方法 get/set 而不是下标协议:下标协议的方法命名在 25.x 间有变动,普通方法跨版本更稳。内存只分配不释放:这是实验代码,跑完即弃;生产代码必须补齐生命周期管理——更要紧的是,持裸指针的 struct 默认拷贝是浅拷贝,两份”Matrix”指向同一块内存,谁先释放另一方就悬空,这是 UnsafePointer 最典型的坑,也解释了为什么名字里带 Unsafe。

fn fill(mut m: Matrix):
    for r in range(m.rows):
        for c in range(m.cols):
            m.set(r, c, Float64((r + c) % 13))   # 确定性数据,结果可复现

fn zero(mut m: Matrix):
    for r in range(m.rows):
        for c in range(m.cols):
            m.set(r, c, 0.0)

fn checksum(m: Matrix) -> Float64:
    var s = 0.0
    for r in range(m.rows):
        for c in range(m.cols):
            s += m.get(r, c)
    return s

四步演进

V0:朴素 ijk 三重循环

fn matmul_naive(a: Matrix, b: Matrix, mut out: Matrix):
    for i in range(a.rows):
        for j in range(b.cols):
            var acc = 0.0
            for k in range(a.cols):
                acc += a.get(i, k) * b.get(k, j)
            out.set(i, j, acc)

这是基线,也是正确性对照的参照物:任何加速版必须和它逐元素一致。a、b 按默认约定只读借用传入,零拷贝;out 标 mut 就地写。

V1:循环重排 ikj,让内存访问连续

fn matmul_ikj(a: Matrix, b: Matrix, mut out: Matrix):
    for i in range(a.rows):
        for k in range(a.cols):
            var aik = a.get(i, k)                 # 提到外层,避免反复下标
            for j in range(b.cols):
                out.set(i, j, out.get(i, j) + aik * b.get(k, j))

数学完全等价,改的是访存模式:行主序存储下,内层 j 沿内存连续推进,b 的一行、out 的一行都能整段吃进缓存行;ijk 版本内层跳着访问 b 的不同行,缓存命中率是天然的短板。这一步展示的是”算法没变,数据摆放方式改变性能”——系统语言的第一课。

V2:行级并行,把核用满

from algorithm import parallelize

fn matmul_parallel(a: Matrix, b: Matrix, mut out: Matrix):
    @parameter
    fn row_work(r: Int):
        for k in range(a.cols):
            var aik = a.get(r, k)
            for j in range(b.cols):
                out.set(r, j, out.get(r, j) + aik * b.get(k, j))

    parallelize[row_work](out.rows)   # 行号分发给各线程,核心数自动定

并行是安全的,因为划分是安全的:每行只被一个线程写,线程间无共享写、无竞争——并行的正确性必须由数据划分保证,不靠运气。这也是矩阵乘法适合并行演示的原因:行与行天然独立。V1 的 ikj 顺序直接搬进并行版,两步优化是叠加关系。

V3:SIMD 向量化,一次算四个

alias W = 4   # Float64 每 4 个一组;CPU 支持 AVX-512 时可试 8,以官方文档为准

fn matmul_simd(a: Matrix, b: Matrix, mut out: Matrix):
    for i in range(a.rows):
        for k in range(a.cols):
            var aik = SIMD[DType.float64, W](a.get(i, k))       # 标量广播成 W 通道
            for j in range(0, b.cols - W + 1, W):               # 主块:整组处理
                var bv = b.data.load[W](k * b.cols + j)         # 一次读 W 个连续元素
                var ov = out.data.load[W](i * b.cols + j)
                ov += aik * bv
                out.data.store[W](i * b.cols + j, ov)           # 一次写回 W 个
            for j in range(b.cols - (b.cols % W), b.cols):      # 尾部:标量收尾
                out.set(i, j, out.get(i, j) + a.get(i, k) * b.get(k, j))

这是 Mojo 与众不同的地方:向量化不是调库,是把”宽度 1 的标量”改写成”宽度 W 的 SIMD”,load/store 按组读写。尾数不足 W 的部分标量收尾——b.cols 不整除 W 时正确性不受影响,正确性验收会兜住它。load/store 的参数拼写随版本微调,以官方文档为准。

计时与验证:可复现的最小骨架

from time import perf_counter_ns

def main():
    var N = 256
    var a = Matrix(N, N)
    var b = Matrix(N, N)
    fill(a)
    fill(b)

    1. 正确性:加速版必须与朴素版逐元素一致
    var expect = Matrix(N, N)
    zero(expect)
    matmul_naive(a, b, expect)
    print("checksum(expect):", checksum(expect))

    1. 计时:预热一次,5 次取最小
    var out = Matrix(N, N)
    var best = -1
    for _ in range(5):
        zero(out)
        var t0 = perf_counter_ns()
        matmul_simd(a, b, out)      # 这里以 V3 为例;四个版本各跑一组互相对比
        var dt = perf_counter_ns() - t0
        if best < 0 or dt < best:
            best = dt
    print("best ms:", Float64(best) / 1e6)
    print("checksum:", checksum(out))   # 防止编译器删除"没人用"的计算

    1. 误差核对
    var max_err = 0.0
    for r in range(N):
        for c in range(N):
            var e = abs(out.get(r, c) - expect.get(r, c))
            if e > max_err:
                max_err = e
    print("max_abs_err:", max_err)

跑之前记录环境表,这是”可复现”的最后一环:CPU 型号、物理核数、操作系统、pixi run mojo --version 的输出。没有这张表,别人无法判断你的趋势是否成立。

结果怎么解读:定性预期,自己复跑

本篇不给编造的实测数字——没在你的机器上跑过的数字没有意义。四步改动的预期方向如下,具体倍数请按上面的骨架自行复跑:

复跑时的现象 多半卡在哪 怎么改
四步耗时几乎没差别 编译器把”没人用”的结果删了 确认 checksum 打印在计时块之后仍执行
V2 并行版提升远小于核数 物理核数没吃满,或撞上内存带宽 查系统核数与线程分配;换小矩阵验证计算受限场景
每次耗时波动大 没预热、只计了一次 预热一次后重复 5 次以上取最小值
max_abs_err 明显非零 尾部处理写错或输出矩阵没清零 核对尾循环边界与 zero 调用顺序
编译报 raises/参数约定错误 语法层没过关 回语法要点与字典查对应条目再回来
版本 改动 为什么有效 预期方向(定性)
V0 朴素 ijk 基线 参照点
V1 循环重排 ikj 内层访存连续,缓存行吃满 有提升,幅度取决于缓存层级
V2 行级并行 行间无共享写,核数自动分配 随物理核数接近线性
V3 SIMD 向量化 单指令处理 W 个元素 再上一档,受内存带宽约束

两条诚实边界。第一,N=256 的 Float64 矩阵不足以把缓存体系吃透,更大规模该上分块(tiling)——官方 matmul 系列的下一步就是它,生产环境直接用成熟 BLAS 库,手写循环是学习手段不是替代方案。第二,本篇全部数字口径是”待复跑”,公开口径只有一个:官方博客系列给出的 35,000x(纯 Python 基线、特定硬件),别把它当自己机器的预期。

总结

这次实验教会的东西:性能优化的第一性原理是访存模式(V1 的重排不写一行”快代码”就能改变性能);并行的正确性来自数据划分而不是小心翼翼;SIMD 在 Mojo 里是类型参数而不是外部库;计时必须防死代码消除、防单次抖动。反过来说,这套流程什么时候不值得用:矩阵规模小到毫秒级以下、瓶颈根本不在计算、或者生产环境里一个 BLAS 调用就能解决——那就直接调库。下一步要把学习过程排成顺序,读《Mojo 学习路线》;开山篇的定位判断(谁该现在跟进)在《Mojo 观察报告》。

版本核实(以官方文档为准):代码按 25.4(2025-06-18 发布)时点语法书写——参数约定 read/mut/owned/out、@fieldwise_init、UnsafePointer 的 alloc/load/store、SIMD 类型参数化、algorithm 模块的 parallelize、time 模块的 perf_counter_ns 均为该时点已发布事实;Tensor API 正在被官方淘汰(官方论坛 2025-05 声明)故未使用;parallelize 的模块归属、load/store 参数拼写在版本间可能调整,以 docs.modular.com 当前文档为准;官方 35,000x 为 2023 年公开口径(Modular 博客系列,纯 Python 基线),本篇未做实测,所有耗时结论需读者自行复跑。

暂无评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇
下一篇