编程 Go SIMD 深度拆解:当 Gopher 终于不用手写汇编——从 archsimd 两层模型到可移植 simd 包的「性能下半场」

2026-08-12 07:18:45 +0800 CST views 8

Go SIMD 深度拆解:当 Gopher 终于不用手写汇编——从 archsimd 两层模型到可移植 simd 包的「性能下半场」

本文写于 2026 年 8 月。Go 1.26 已在 AMD64 上以 GOEXPERIMENT=simd 形式落地实验性 SIMD 支持;Go 核心团队随后提出 #78979(Go 1.27 默认开启 simd/archsimd for AMD64)与 #78902(架构无关的可移植 simd 包)。本文把这套设计从语言哲学 → 类型系统 → 编译器派发 → 代码实战 → 性能陷阱完整拆一遍。凡属提案阶段、尚未定稿的内容,文中都会明确标注。


一、背景:Go 的那块「性能天花板」到底是什么做的

先说一个很多人有体感但说不清的事实:Go 在 CPU 密集型的数据处理上,长期存在一个 2~10 倍的、无法靠"写好一点 Go 代码"来填平的差距。

不是 GC 的问题。不是调度器的问题。不是逃逸分析的问题。这些都能靠工程手段绕。真正绕不过去的是:现代 CPU 有一整套宽向量执行单元,而 Go 程序员没有语言级的方式去使用它。

一个具体到能让人心态崩掉的例子。计算两个 []float32 的点积:

func DotScalar(x, y []float32) float32 {
    var s float32
    for i := range x {
        s += x[i] * y[i]
    }
    return s
}

这段代码在 x86-64 上编译出来,核心循环大致是:MOVSS 加载一个 32 位浮点、MULSS 乘一个、ADDSS 加一个。注意 SS 后缀 —— Scalar Single。一次一个元素。

而同一台机器上的 XMM 寄存器是 128 位(能装 4 个 float32),YMM 是 256 位(8 个),ZMM 是 512 位(16 个)。也就是说:你的 CPU 每个时钟周期最多能吞 16 个 float32 的乘加,而 Go 编译器只给它喂了 1 个。利用率 6.25%。

C++ 有 intrinsics(_mm256_fmadd_ps),Rust 有 std::arch + portable_simd,Java 有 Vector API(JEP 338 一路孵化到现在)。Go 有什么?

1.1 方案 A:手写 Plan 9 汇编(也就是「痛苦但可行」)

Go 的逃生舱是汇编。但 Go 用的是自家改造的 Plan 9 汇编语法,这件事本身就是第一道墙:

// func addVec(dst, a, b []float32)
TEXT ·addVec(SB), NOSPLIT, $0-72
    MOVQ dst_base+0(FP), DI
    MOVQ a_base+24(FP), SI
    MOVQ b_base+48(FP), DX
    MOVQ a_len+32(FP), CX
    SHRQ $3, CX              // 每轮处理 8 个 float32
    JZ   done
loop:
    VMOVUPS (SI), Y0
    VMOVUPS (DX), Y1
    VADDPS  Y1, Y0, Y0
    VMOVUPS Y0, (DI)
    ADDQ $32, SI
    ADDQ $32, DX
    ADDQ $32, DI
    DECQ CX
    JNZ  loop
done:
    RET

写出来了,但接下来是一长串问题:

  1. FP 偏移量要手算。参数布局一变(比如加个字段、改了 ABI),全盘崩,而且编译器不会告诉你 —— 它只会给你算出错误结果。
  2. 不参与内联。汇编函数是内联黑洞,调用开销在小数据量下能把 SIMD 收益吃干。
  3. 不参与逃逸分析和边界检查消除。编译器对它一无所知,只能保守处理调用点周围的一切。
  4. 寄存器 ABI 变更史。Go 1.17 引入寄存器传参后,大量手写汇编需要 ABIInternal 适配,这是社区那两年的集体噩梦。
  5. CPU 特性检测得自己写:你必须自己 internal/cpu 判断 AVX2/AVX-512 是否可用,然后自己做函数指针派发。忘了检测 = 在老机器上 SIGILL
  6. 没有可移植性。写完 amd64,arm64 再来一遍,从 VADDPS 换到 FADD Vn.4S

所以现实是:只有标准库和少数顶级库(crypto/*bytesmath/big、klauspost 的压缩系列)负担得起手写汇编。业务代码想都别想。

1.2 方案 B:指望编译器自动向量化(基本上是幻想)

很多人问:为什么 gc 编译器不能像 LLVM 那样自动向量化?答案是几个原因叠加,而且每一个都很硬:

(1)Go 的编译器把编译速度当一等公民。 自动向量化需要循环依赖分析、别名分析、成本模型、循环变换(unroll / peel / interleave),这些 pass 是 LLVM 编译慢的主要来源之一。Go 团队明确拒绝为此牺牲编译速度。

(2)slice 别名问题无解。 看这段:

func addInPlace(dst, a []float32) {
    for i := range dst {
        dst[i] += a[i]
    }
}

编译器无法证明 dsta 不重叠。C 有 restrict,Fortran 语义上禁止别名,Go 什么都没有。要向量化就得插入运行时重叠检查 + 双版本代码,代码膨胀。

(3)浮点加法不满足结合律。 向量化求和意味着把 ((a+b)+c)+d 变成 (a+c)+(b+d),结果的最后几个 bit 会不同。C/C++ 编译器靠 -ffast-math 拿到许可,而 Go 的浮点语义是严格、确定、跨平台一致的 —— 这是 Go 的核心承诺,不可能为性能让步。

(4)边界检查。 循环体里每次访问都可能 panic,panic 有精确的语义(发生在第几次迭代、此前的副作用必须可见)。向量化会打乱这个顺序。

结论:在 Go 里,自动向量化这条路在语言语义层面就被堵死了。唯一的出路是显式 API。 这就是 simd 包的立项前提。


二、核心概念:给 Go 程序员的 SIMD 速通

在拆 API 之前,先把六个概念说清楚。跳过这一节的人,后面 90% 会踩坑。

2.1 Lane(车道)

一个 256 位向量寄存器,如果按 float32 解释,就是 8 条 lane;按 int64 解释,是 4 条 lane;按 uint8 解释,是 32 条 lane。同一块物理寄存器,解释方式不同,lane 数就不同。 这就是为什么 API 里必然出现 Float32x8Int64x4Uint8x32 这一大堆类型 —— 它们描述的是「位宽 × 解释方式」的组合。

2.2 垂直操作 vs 水平操作

  • 垂直(vertical):lane 之间互不干扰,c[i] = a[i] + b[i]。这是 SIMD 的甜区,一条指令一个周期搞定。
  • 水平(horizontal):跨 lane 的运算,比如「把 8 条 lane 全加起来得到一个标量」。这是 SIMD 的痛点,通常需要 log₂(n) 轮 shuffle + add,而且指令延迟高。

工程含义:把水平操作挪出热循环。 点积的正确写法是循环内只做垂直的乘加,累加器保持向量形态,循环结束后只做一次水平求和。这个规律后面代码实战会反复出现。

2.3 Mask(掩码)

条件执行怎么办?比如「只对大于 0 的元素做操作」。SIMD 的答案是掩码:先算出一个每 lane 一位(或一整 lane 全 1/全 0)的布尔向量,再让后续操作只作用在掩码为真的 lane 上。

麻烦在于不同架构的掩码表示完全不同

架构掩码形式
SSE/AVX2用一个同宽度的向量寄存器,每 lane 全 1 或全 0
AVX-512独立的 k0~k7 掩码寄存器,每 lane 1 bit
ARM NEON同宽度向量,每 lane 全 1/全 0
ARM SVE独立的 predicate 寄存器 p0~p15
WASM SIMD同宽度向量

这是整套 API 设计中最难统一的部分,也是 Go 团队最后选择「不透明 Mask 类型」的原因 —— 后面细说。

2.4 尾部(tail)

数组长度不是 lane 数的整数倍怎么办?1000 个 float32,用 8 宽向量处理,剩下 0 个(1000 = 125×8,刚好);1003 个就剩 3 个。尾部处理是 SIMD 代码里 bug 密度最高的地方,也是很多人「SIMD 版本更慢」的原因(尾部逻辑写得太重)。

2.5 对齐(alignment)

历史上未对齐加载(MOVUPS)比对齐加载(MOVAPS)慢很多。在 Nehalem 之后的现代 x86 上,两者差距在大部分场景下已经接近消失,但跨 cache line 的未对齐访问仍有惩罚。Go 的 slice 底层数组由分配器给出,[]float32 只保证 4 字节对齐,你拿不到 32/64 字节对齐的保证 —— 所以 Go 的 SIMD API 只提供未对齐 load/store 语义,这是正确的选择(安全 > 微小性能)。

2.6 吞吐 vs 延迟

VADDPS 的延迟可能是 4 个周期,但吞吐是每周期 2 条(两个执行端口)。这意味着:如果你的循环里只有一个累加器,你会被延迟串死;用 4 个独立累加器交错,才能吃满吞吐。 这是后面「性能优化」一节的核心技巧之一,也是 SIMD 老手和新手差距最大的地方。


三、架构分析:Go 团队的「两层模型」

现在进正题。Go 团队在 issue #73787 里提出的核心设计,是一个两层模型(two-level approach),类比来源是标准库里 syscallos 的关系。

这个类比非常精准,值得展开:

  • syscall 包:架构/OS 绑定,暴露原始能力,接口丑陋但完整,不承诺兼容性演进的优雅性。
  • os 包:可移植,把各平台差异抹平,接口优雅,99% 的人只用这一层。

映射到 SIMD:

┌─────────────────────────────────────────────┐
│  simd  (可移植层,issue #78902,提案中)      │
│  simd.Float32s / simd.Int32s ...             │
│  宽度由运行时/编译期决定,一份代码跑所有架构      │
└─────────────────┬───────────────────────────┘
                  │ ToArch() 逃生舱
                  ▼
┌─────────────────────────────────────────────┐
│  simd/archsimd (架构层,#78979,Go 1.27 转正)│
│  archsimd.Float32x8 / Uint32x4 / Uint8x32 ...│
│  与指令近乎 1:1,GOARCH 绑定                   │
└─────────────────┬───────────────────────────┘
                  ▼
        CPU 指令(VPADDD / VFMADD231PS / ...)

3.1 第一层 simd/archsimd:把指令包装成方法

这一层的设计原则是与硬件零距离。类型定义大致长这样:

package archsimd

// 128 位,4 个 uint32
type Uint32x4 struct { /* 编译器识别的特殊类型 */ }

// 256 位,8 个 float32
type Float32x8 struct { /* ... */ }

// 512 位,16 个 float32
type Float32x16 struct { /* ... */ }

方法签名带上了对应的汇编指令注释,这是个很贴心的设计:

// Add adds each element of two vectors.
//
// Equivalent to x86 instruction VPADDD.
func (x Uint32x4) Add(y Uint32x4) Uint32x4

// Mul multiplies corresponding elements.
//
// Equivalent to x86 instruction VMULPS.
func (x Float32x8) Mul(y Float32x8) Float32x8

关键设计决策 1:为什么是 struct 而不是 [8]float32

因为语义完全不同。[8]float32 是内存里的 32 字节,编译器可以对它取地址、取切片、按元素索引。而 Float32x8 必须表达「这是一个寄存器里的值」。它是编译器特殊识别的类型,值语义、不可寻址单个 lane(要取 lane 得走 GetElem 方法)、赋值就是寄存器 move。

这带来一个非常关键的好处:这些值永远不含指针,GC 完全不需要扫描它们。 向量寄存器和 GC 之间零交互 —— 这一点是手写汇编时代最容易出事的地方(汇编里持有堆指针而不告知 GC = 随机崩溃)。

关键设计决策 2:不透明的 Mask 类型

面对 2.3 节里那张掩码地狱表,Go 团队的选择是:

// Mask 是不透明类型,你不能假设它的内部表示
type Mask32x8 struct { /* opaque */ }

func (x Float32x8) Greater(y Float32x8) Mask32x8
func (x Float32x8) AddMasked(y Float32x8, m Mask32x8) Float32x8

编译器负责选择 K 寄存器还是向量寄存器实现。 你写一份代码,在 AVX-512 机器上编译出 k 寄存器版本,在 AVX2 机器上编译出向量掩码 + blend 版本。

这个决策的代价是:你失去了对掩码做位运算 hack 的能力(比如把掩码当整数 popcount)。但换来的是掩码代码可以跨微架构编译。对于 99% 的场景,这笔交易是划算的。

关键设计决策 3:命名哲学之争 —— 一场很 Go 的辩论

issue #73787 的评论区有一场值得所有 API 设计者读一遍的辩论:

  • 「专家派」(Ian Lance Taylor 为代表):应该直接用 VPADDD 这样的指令名。写 SIMD 的人手边一定开着 Intel 手册,用指令名可以零成本对照;用 Add 这种 Go 风格名,反而要在脑子里做一次翻译。
  • 「可读性派」(Cherry Mui 为代表):代码的读者远多于作者。一个普通 Gopher 看到 x.Add(y) 能秒懂,看到 x.VPADDD(y) 只会一脸问号。我们应该为读者优化,不是为专家优化。

最终可读性派胜出。 这个结果完全符合 Go 二十年一贯的价值排序:明确性和可读性 > 专家便利性 > 简写。同时用文档注释里标注等价指令,作为对专家派的补偿 —— 这是个很漂亮的妥协。

3.2 第二层 simd:可移植层(#78902,提案阶段)

这一层才是 Go 的真正野心所在。核心思路:类型名里不带宽度。

var a simd.Float32s   // 注意:没有 x4/x8/x16

a.Len() 返回当前编译目标/运行环境下的最佳 lane 数。提案里给的 inner product 示例(这里我按可运行的形态补全了骨架):

func InnerProduct(x, y []float32) float32 {
    var acc simd.Float32s
    n := acc.Len()          // AVX2 → 8;AVX-512 → 16;NEON → 4
    var i int
    for i = 0; i+n <= len(x); i += n {
        u := simd.LoadFloat32Slice(x[i : i+n])
        v := simd.LoadFloat32Slice(y[i : i+n])
        acc = acc.Add(u.Mul(v))
    }
    total := horizontalSum(acc)   // 唯一的水平操作,在循环外
    for ; i < len(x); i++ {       // 标量尾部
        total += x[i] * y[i]
    }
    return total
}

这份代码在 x86 和 ARM 上都能编译,都能拿到接近手写的性能。 这是 C++ intrinsics 做不到的(_mm256_* 只能跑 x86),是 Rust portable_simd 正在做的(但 Rust 里宽度还是要在类型里写死,如 f32x8),Go 想做得更彻底:连宽度都不写。

3.3 ToArch():可移植层的逃生舱

可移植层不可能覆盖所有指令(AVX-512 有一堆独门绝技,比如 VPCONFLICTDVPTERNLOGD)。所以提案给了一个类型 switch 形式的下沉通道:

//go:build amd64

func horizontalSum(x simd.Float32s) float32 {
    switch a := x.ToArch().(type) {
    case archsimd.Float32x8:
        // AVX2:两轮 pairwise add,再取高低两半的首元素
        a = a.AddPairsGrouped(a)
        a = a.AddPairsGrouped(a)
        return a.GetLo().GetElem(0) + a.GetHi().GetElem(0)

    case archsimd.Float32x16:
        // AVX-512:先降到 256 位再走上面的路径
        lo, hi := a.GetLo(), a.GetHi()
        return horizontalSumX8(lo.Add(hi))

    case archsimd.Float32x4:
        s := make([]float32, a.Len())
        a.StoreSlice(s)
        var r float32
        for _, e := range s {
            r += e
        }
        return r
    }
    panic("unknown arch vector type")
}

这个设计的精髓:可移植层负责 90% 的垂直运算(也就是代码量的大头),架构层负责 10% 的水平/特殊操作(也就是需要精细调优的部分)。 而且 //go:build amd64 让你可以为不同架构提供不同的 horizontalSum 实现,主逻辑完全不动。

对比一下 Rust 的做法:Rust 的 portable_simdf32x8::reduce_sum() 是内建的,看起来更简洁。但代价是这个 reduce 的实现策略你无法干预 —— 而 reduce 的实现策略恰恰对性能影响巨大(是否用 pairwise、是否保持精度、是否走内存)。Go 的方案更啰嗦,但把控制权交回了开发者。这是一个典型的「Go 式取舍」:不给你魔法,给你螺丝刀。

3.4 编译器怎么派发?两条路径要分清

这是最容易被误解的部分。有两套完全不同的机制:

路径 A:编译期确定(GOAMD64 环境变量)

GOAMD64=v1  # 基线 SSE2(默认,兼容所有 x86-64)
GOAMD64=v2  # + SSE3/SSSE3/SSE4.1/SSE4.2/POPCNT
GOAMD64=v3  # + AVX/AVX2/BMI1/BMI2/FMA  ← 大多数 2015 年后的 CPU
GOAMD64=v4  # + AVX-512F/BW/CD/DQ/VL

编译时就把向量宽度写死。优点:零运行时开销,代码体积小。缺点:二进制不能跑在更老的机器上。 如果你控制部署环境(容器、内部服务),这是最优选择。

路径 B:运行时派发(function multi-versioning)

编译器为同一个函数生成多个版本,程序启动时根据 CPUID 选择。这是 #78902 提案里暗示的方向,但具体机制在我写这篇文章的时候(2026 年 8 月)还没有定稿,别当成既定事实。

要注意的是:运行时派发不是免费的。 它会带来(1)二进制膨胀,(2)派发点无法内联,(3)间接调用打断分支预测和指令预取。所以对于小函数,运行时派发的开销可能吃掉 SIMD 的收益 —— 这也是标准库到现在还倾向于「大函数内做一次检测,然后循环处理大数据」的原因。

实操建议(现在就能用):

# 生产环境如果你确定 CPU 支持 AVX2,直接开 v3,
# 就算不用 simd 包也有 5%~15% 的免费提速
# (标准库和编译器自身会用上 FMA / BMI2)
GOAMD64=v3 go build -o app ./cmd/app

# 验证当前默认值
go env GOAMD64

这一条是今天就能落地、零代码改动的优化,很多团队不知道。


四、代码实战:五个从简到难的例子

以下代码基于 Go 1.26 的实验形态(GOEXPERIMENT=simd)与 #78979/#78902 提案 API 编写。API 在正式定稿前可能变化,请以 go doc simd/archsimd 为准。这里的重点是思路和结构,这些是不会变的。

先解决工程组织问题 —— 这一步比 SIMD 本身更重要。

4.0 项目骨架:build tag 三件套

任何要上生产的 SIMD 代码,都必须是这个形状:

vecmath/
├── vecmath.go          // 公开 API + 派发
├── vecmath_scalar.go   // 纯 Go 实现(永远保留!)
├── vecmath_amd64.go    // //go:build amd64 && goexperiment.simd
├── vecmath_arm64.go    // //go:build arm64 && goexperiment.simd
└── vecmath_test.go     // 差分测试 + benchmark
// vecmath.go
package vecmath

// Dot 计算点积。有 SIMD 实现时自动使用,否则回退标量。
func Dot(x, y []float32) float32 {
    if len(x) != len(y) {
        panic("vecmath: length mismatch")
    }
    return dot(x, y)   // 由各平台文件提供
}
// vecmath_scalar.go
//go:build !goexperiment.simd || (!amd64 && !arm64)

package vecmath

func dot(x, y []float32) float32 { return dotScalar(x, y) }
// vecmath_generic.go —— 无 build tag,所有平台都编译,供测试对照
package vecmath

func dotScalar(x, y []float32) float32 {
    var s float32
    for i := range x {
        s += x[i] * y[i]
    }
    return s
}

为什么标量版本必须永远保留? 三个理由,每一个都能救命:

  1. 差分测试的基准。 没有它,你根本不知道 SIMD 版本算错了。
  2. 新架构的兜底。 明天 RISC-V 上线,代码照样编译通过。
  3. 调试。 SIMD 代码几乎无法用 delve 单步调试,出问题时你需要一个能 print 的版本对照。

我见过太多项目跳过第 3 步,然后在生产上遇到「只有某台机器结果不对」的灵异事件,查三天。

4.1 例一:点积(架构层写法,配四累加器)

//go:build amd64 && goexperiment.simd

package vecmath

import "simd/archsimd"

func dot(x, y []float32) float32 {
    const W = 8            // Float32x8
    const U = 4            // 4 路展开,打破依赖链
    const step = W * U     // 每轮 32 个 float32

    // 四个独立累加器 —— 这是本函数性能的关键
    var a0, a1, a2, a3 archsimd.Float32x8

    i := 0
    for ; i+step <= len(x); i += step {
        x0 := archsimd.LoadFloat32x8Slice(x[i+0*W:])
        x1 := archsimd.LoadFloat32x8Slice(x[i+1*W:])
        x2 := archsimd.LoadFloat32x8Slice(x[i+2*W:])
        x3 := archsimd.LoadFloat32x8Slice(x[i+3*W:])

        y0 := archsimd.LoadFloat32x8Slice(y[i+0*W:])
        y1 := archsimd.LoadFloat32x8Slice(y[i+1*W:])
        y2 := archsimd.LoadFloat32x8Slice(y[i+2*W:])
        y3 := archsimd.LoadFloat32x8Slice(y[i+3*W:])

        // 有 FMA 时编译器应折叠成 VFMADD231PS
        a0 = a0.Add(x0.Mul(y0))
        a1 = a1.Add(x1.Mul(y1))
        a2 = a2.Add(x2.Mul(y2))
        a3 = a3.Add(x3.Mul(y3))
    }

    // 合并四个累加器(仍是向量运算,便宜)
    acc := a0.Add(a1).Add(a2.Add(a3))

    // 单宽向量收尾
    for ; i+W <= len(x); i += W {
        xv := archsimd.LoadFloat32x8Slice(x[i:])
        yv := archsimd.LoadFloat32x8Slice(y[i:])
        acc = acc.Add(xv.Mul(yv))
    }

    // 唯一的一次水平求和
    total := hsum8(acc)

    // 标量尾部
    for ; i < len(x); i++ {
        total += x[i] * y[i]
    }
    return total
}

func hsum8(v archsimd.Float32x8) float32 {
    lo, hi := v.GetLo(), v.GetHi()        // 256 → 两个 128
    s := lo.Add(hi)                        // Float32x4
    s = s.AddPairsGrouped(s)               // [a+b, c+d, ...]
    s = s.AddPairsGrouped(s)               // [a+b+c+d, ...]
    return s.GetElem(0)
}

这段代码里有五个刻意的设计,逐条讲:

(1)四个累加器不是装逼,是必需的。 Add 的延迟约 4 个周期,吞吐是每周期 2 条。单累加器时,第 n+1 次 Add 必须等第 n 次结果 —— 你被延迟锁死在「每 4 周期 1 次加法」。四个独立累加器让 CPU 的乱序引擎可以同时推进四条链,吞吐直接翻数倍。

这是 SIMD 优化里最反直觉、也最赚的一招。 很多人写完 SIMD 发现「才快了 2 倍,不是说 8 倍吗」,答案通常就在这里。

(2)Add(Mul()) 而不是手写 FMA。 后端在 GOAMD64>=v3 时会把这个模式折叠成 VFMADD231PS。写成两步的好处是这段代码在 v1/v2 上也能编译。注意 FMA 会改变浮点结果(少一次中间舍入,实际上更精确),所以差分测试要用容差比较,不能用 ==。这一点后面 4.5 节详说。

(3)三段式尾部:step 循环 → W 循环 → 标量循环。 只写第一段和第三段的话,长度 100 的输入会有 4 个元素走标量(100 = 3×32 + 4),中间那段循环能吃掉它们。对于短输入(几十到几百个元素),中间段的收益非常明显 —— 而这恰恰是真实业务里最常见的长度区间。

(4)水平求和只做一次,且在循环外。 如果你不小心把 hsum 写进循环,性能会比标量版本更差。这个错误极其常见。

(5)LoadFloat32x8Slice(x[i:]) 的隐含契约。 它读 8 个元素,所以调用者必须保证 len(x[i:]) >= 8。循环条件 i+W <= len(x) 保证了这一点。如果你把条件写成 i < len(x),就是越界读 —— 而且可能不 panic,只是读到脏数据。 这是 SIMD 代码最危险的一类 bug:它不崩溃,它给你错的答案。

4.2 例二:字节扫描(掩码的第一次实战)

统计一个 []byte 里某个字节出现的次数。标量版:

func CountByteScalar(b []byte, c byte) int {
    n := 0
    for _, v := range b {
        if v == c {
            n++
        }
    }
    return n
}

SIMD 版思路:一次比较 32 个字节,把比较结果的掩码转成计数。

//go:build amd64 && goexperiment.simd

func countByte(b []byte, c byte) int {
    const W = 32   // Uint8x32

    // 把目标字节广播到全部 32 条 lane
    needle := archsimd.BroadcastUint8x32(c)

    total := 0
    i := 0
    for ; i+W <= len(b); i += W {
        chunk := archsimd.LoadUint8x32Slice(b[i:])
        m := chunk.Equal(needle)      // Mask8x32
        total += m.CountTrue()        // 掩码里为真的 lane 数
    }
    for ; i < len(b); i++ {
        if b[i] == c {
            total++
        }
    }
    return total
}

这里有个值得警惕的点:CountTrue() 的实现代价。 在 AVX-512 上它是 KMOVQ + POPCNT,两条指令,很便宜。在 AVX2 上它是 VPMOVMSKB(向量→通用寄存器)+ POPCNTVPMOVMSKB 这类跨域移动(vector → GPR)指令延迟通常在 3~5 周期,而且会造成向量域和整数域之间的调度气泡。

优化手法:把掩码累加留在向量域。

func countByteFast(b []byte, c byte) int {
    const W = 32
    needle := archsimd.BroadcastUint8x32(c)

    total := 0
    i := 0
    // 外层:每 255 个块清算一次
    // 因为 uint8 累加器最多能数到 255 不溢出
    for i+W <= len(b) {
        var acc archsimd.Uint8x32   // 向量累加器
        blocks := 0
        for ; blocks < 255 && i+W <= len(b); blocks++ {
            chunk := archsimd.LoadUint8x32Slice(b[i:])
            m := chunk.Equal(needle)
            // 掩码转 0/1,在向量域累加,不出寄存器
            acc = acc.Add(m.ToUint8x32AsOne())
            i += W
        }
        total += hsumU8x32(acc)   // 每 255 块才做一次跨域
    }
    for ; i < len(b); i++ {
        if b[i] == c {
            total++
        }
    }
    return total
}

跨域操作从「每 32 字节一次」降到「每 8160 字节一次」,降低 255 倍。 这个「向量域内累加 + 定期清算」的模式在 SIMD 编程里叫 nested accumulator,是 simdjson、hyperscan 这些库的核心手法之一。理解它,你的 SIMD 水平就上了一个台阶。

4.3 例三:图像灰度化(饱和运算与整数拓宽)

RGB → 灰度,公式 Y = (77*R + 150*G + 29*B) >> 8。这里有两个 SIMD 的经典难点:数据布局整数溢出

//go:build amd64 && goexperiment.simd

// Grayscale 处理 RGBA(planar 布局:r、g、b 各自连续)
// 注意:planar 而非 interleaved —— 这是关键
func Grayscale(dst, r, g, b []uint8) {
    const W = 16   // 用 16 宽,因为要拓宽到 uint16 计算

    cr := archsimd.BroadcastUint16x16(77)
    cg := archsimd.BroadcastUint16x16(150)
    cb := archsimd.BroadcastUint16x16(29)

    i := 0
    for ; i+W <= len(dst); i += W {
        // 关键:uint8 拓宽到 uint16,否则 77*255 = 19635 直接溢出
        rv := archsimd.LoadUint8x16Slice(r[i:]).WidenToUint16x16()
        gv := archsimd.LoadUint8x16Slice(g[i:]).WidenToUint16x16()
        bv := archsimd.LoadUint8x16Slice(b[i:]).WidenToUint16x16()

        y := rv.Mul(cr).Add(gv.Mul(cg)).Add(bv.Mul(cb))
        y = y.ShiftRightConst(8)

        // 窄化回 uint8,带饱和(虽然这里数学上不会超 255)
        archsimd.StoreUint8x16Slice(dst[i:], y.NarrowToUint8x16Saturated())
    }
    for ; i < len(dst); i++ {
        dst[i] = uint8((77*uint16(r[i]) + 150*uint16(g[i]) + 29*uint16(b[i])) >> 8)
    }
}

这个例子里最重要的一句话是函数签名。

真实的图像数据通常是 interleaved(交错)布局:RGBARGBARGBA...。而 SIMD 想要的是 planar(平面)布局:RRRR...GGGG...BBBB...

interleaved 布局下,你要先做 de-interleave(VPSHUFB + VPERM* 一堆 shuffle),这部分开销可能占到整个函数的 40% 以上。

所以真正的优化不在循环体,在数据布局。 这就是经典的 AoS → SoA(Array of Structs → Struct of Arrays)转换:

// ❌ SIMD 不友好
type Particle struct { X, Y, Z, VX, VY, VZ float32 }
particles := make([]Particle, 10000)

// ✅ SIMD 友好
type ParticleSystem struct {
    X, Y, Z    []float32
    VX, VY, VZ []float32
}

如果你的项目从一开始就用 AoS,那么引入 SIMD 的最大工作量是重构数据结构,不是写向量代码。 这一条我想强调三遍 —— 我见过团队花两周写 SIMD kernel,最后发现瓶颈全在 de-interleave 上,收益 1.3 倍,白干。

先测数据布局,再决定要不要上 SIMD。

4.4 例四:可移植层写法(一份代码跑 x86 + ARM)

package vecmath

import "simd"

// AddScaled: dst = a + k*b,可移植实现
func AddScaled(dst, a, b []float32, k float32) {
    var probe simd.Float32s
    n := probe.Len()

    kv := simd.BroadcastFloat32(k)

    i := 0
    for ; i+n <= len(dst); i += n {
        av := simd.LoadFloat32Slice(a[i : i+n])
        bv := simd.LoadFloat32Slice(b[i : i+n])
        simd.StoreFloat32Slice(dst[i:i+n], av.Add(bv.Mul(kv)))
    }
    for ; i < len(dst); i++ {
        dst[i] = a[i] + k*b[i]
    }
}

这份代码没有一个 build tag,没有一行架构相关代码,在 amd64 / arm64 / 未来的 riscv64 上都能编译,都能拿到向量化。

这就是可移植层的价值。对比手写汇编的时代,代码量从「每架构 200 行汇编」降到「20 行 Go」。

但要清醒地认识它的边界:

场景用哪一层
纯垂直运算(加减乘、min/max、位运算)可移植 simd,足够
水平归约(求和、求最大)可移植层做主循环,ToArch() 做归约
shuffle / permute / table lookup必须架构层,语义差异太大
架构独门指令(AES-NI、CRC32、VPTERNLOG、SVE 的 predicate 循环)必须架构层
需要精确控制指令选择的极限优化架构层,甚至还是汇编

我的判断:可移植层能覆盖 80% 的业务代码,架构层覆盖剩下 20% 里的 18%,剩下 2% 仍然需要汇编。 但这个 80% 是过去十年 Go 完全放弃的市场。

4.5 例五:正确的测试与 benchmark(这一节最容易被跳过,也最容易出事)

差分测试是 SIMD 代码的生命线。 必须写,而且必须用随机输入 + 覆盖所有尾部长度:

func TestDotAgainstScalar(t *testing.T) {
    rng := rand.New(rand.NewSource(42))

    // 覆盖所有可能的尾部情况:0..64 全长度扫一遍
    for n := 0; n <= 64; n++ {
        x := make([]float32, n)
        y := make([]float32, n)
        for i := range x {
            x[i] = rng.Float32()*2 - 1
            y[i] = rng.Float32()*2 - 1
        }

        got := Dot(x, y)
        want := dotScalar(x, y)

        // 关键:不能用 ==!
        // FMA 与不同的求和顺序都会改变最后几位
        tol := float32(1e-5) * float32(n+1)
        if diff := abs32(got - want); diff > tol {
            t.Fatalf("n=%d: got %v want %v (diff %v > tol %v)",
                n, got, want, diff, tol)
        }
    }
}

为什么不能用 ==

  1. 求和顺序变了。 向量化把 ((a₀+a₁)+a₂)+a₃ 变成 (a₀+a₂)+(a₁+a₃),浮点加法不满足结合律,结果不同。
  2. FMA 少一次舍入。 a*b+c 用 FMA 时中间结果不舍入,结果通常更精确,但与标量版不同。

推论:如果你的业务要求「换了实现,结果 bit 级不变」(比如金融对账、需要可复现的科学计算),那么浮点 SIMD 归约类操作你就不能用。 整数 SIMD 没这个问题,可以放心用。这是选型时必须先问清楚的一件事。

benchmark 的四个坑:

func BenchmarkDot(b *testing.B) {
    for _, n := range []int{16, 64, 256, 1024, 4096, 1 << 16, 1 << 20} {
        x := makeRandom(n)
        y := makeRandom(n)

        b.Run(fmt.Sprintf("simd/n=%d", n), func(b *testing.B) {
            // 坑 1:报告吞吐而非 ns/op,不同 n 之间才可比
            b.SetBytes(int64(n * 4 * 2))
            b.ReportAllocs()
            b.ResetTimer()

            var sink float32
            for i := 0; i < b.N; i++ {
                sink += Dot(x, y)   // 坑 2:结果必须被使用
            }
            runtime.KeepAlive(sink) // 双保险,防 DCE
        })

        b.Run(fmt.Sprintf("scalar/n=%d", n), func(b *testing.B) {
            b.SetBytes(int64(n * 4 * 2))
            b.ResetTimer()
            var sink float32
            for i := 0; i < b.N; i++ {
                sink += dotScalar(x, y)
            }
            runtime.KeepAlive(sink)
        })
    }
}

四个坑详解:

  1. 必须扫多个 n。 只测 n=1<<20 的话,你测的是内存带宽,SIMD 收益会被 DRAM 掩盖,看起来"没用"。只测 n=16 的话,你测的是函数调用开销。真实的加速比曲线是个山形:小 n 被开销吃掉,中 n(数据在 L1/L2 内)达到峰值,大 n 被内存带宽压平。 你必须知道自己的业务落在曲线的哪一段。
  2. 结果必须被消费,否则死代码消除会把整个计算删掉,你会测出一个惊人的"1000 倍加速"。
  3. 必须同机对比 scalar。跨机器、跨时间的数字没有意义(睿频、温度墙、邻居噪声)。
  4. benchstat 跑多轮,别信单次结果:
go test -run='^$' -bench=Dot -count=10 > new.txt
benchstat old.txt new.txt

关于加速比的诚实说法: 理论上限是 lane 数(float32 + AVX2 = 8 倍,AVX-512 = 16 倍)。实际能拿到多少完全取决于你的瓶颈在哪 —— 计算密集(数据在缓存里、算术强度高)能接近理论值的 60%80%;内存带宽受限(每个元素只算一两次就丢弃)通常只有 1.22 倍。

别信任何不给出 n、不给出 CPU 型号、不给出 GOAMD64 的加速比数字,包括我这篇文章里的。自己跑。


五、性能优化:八条能落地的经验

5.1 累加器展开度怎么选

规则:展开度 ≈ 关键操作延迟 ÷ 吞吐间隔

VADDPS 延迟 4 周期、每周期可发射 2 条 → 理论最优展开度 8。但寄存器只有 16 个(AVX2 的 YMM0-15),展开 8 路累加器 + 16 路数据加载会溢出到栈,反而变慢。

实践值:4 是绝大多数情况下的甜点。 从 4 开始,测 2 和 8,取最快的。别凭感觉。

5.2 警惕寄存器溢出(spill)

判断方法:

go build -gcflags="-S" ./vecmath 2>&1 | grep -E "MOVUPS.*SP|VMOVUPS.*SP"

如果热循环里出现向量寄存器与 SP(栈指针)之间的 move,就是溢出了。溢出一次的代价基本抹平一次 SIMD 运算的收益。 减少展开度或拆分函数。

5.3 AVX-512 的降频陷阱

在部分 Intel 服务器 CPU(Skylake-SP 到 Ice Lake 一代尤其明显)上,执行 512 位 FMA 指令会触发核心频率下调(license-based downclocking),幅度可达 15%~40%。后果是:

如果你的程序只有 5% 的时间在跑 AVX-512,剩下 95% 的标量代码全部以降频状态运行 —— 净效果是变慢。

这也是为什么 Go 团队没有把 GOAMD64=v4 设为默认。

判断规则:

  • 长时间、连续、大数据量的向量计算 → AVX-512 值得
  • 零散、短促的向量调用 → 老老实实用 256 位(Float32x8
  • Zen 4/5 及更新的 AMD、以及新一代 Intel 上这个问题已大幅缓解,但你不一定控制得住部署环境

保守派做法:默认 256 位,AVX-512 单独 build tag,压测后再决定。

5.4 内存瓶颈优先于计算瓶颈

算一下算术强度(arithmetic intensity):

点积:每 8 字节(两个 float32)做 2 次浮点运算 → 0.25 FLOP/byte
矩阵乘:每个元素被复用 O(n) 次 → 强度高

现代 CPU 的算力/带宽比大约在 10~50 FLOP/byte。 也就是说,点积这种 0.25 的强度,在大数据量下是 100% 内存受限的 —— SIMD 完全帮不上忙。

所以:先用 blocking/tiling 提高数据复用,再上 SIMD。顺序反了就是白忙。

一个 6×8 分块的矩阵乘骨架,能让每个加载的元素被复用 6~8 次:

// 每次把 A 的 6 行 × B 的 8 列(向量宽度)载入寄存器,
// 在寄存器里完成 6×8 = 48 次乘加,然后才写回内存
for i := 0; i < M; i += 6 {
    for j := 0; j < N; j += 8 {
        var c [6]archsimd.Float32x8   // 48 个结果全在寄存器
        for k := 0; k < K; k++ {
            bv := archsimd.LoadFloat32x8Slice(b[k*N+j:])
            for ii := 0; ii < 6; ii++ {
                av := archsimd.BroadcastFloat32x8(a[(i+ii)*K+k])
                c[ii] = c[ii].Add(av.Mul(bv))
            }
        }
        for ii := 0; ii < 6; ii++ {
            archsimd.StoreFloat32x8Slice(dst[(i+ii)*N+j:], c[ii])
        }
    }
}

这就是 OpenBLAS/BLIS 的核心思路。分块比向量化重要。

5.5 边界检查消除仍然有效

SIMD 的 load/store 也会做边界检查。用 -gcflags=-d=ssa/check_bce/debug=1 查:

go build -gcflags="-d=ssa/check_bce/debug=1" ./vecmath

消除手法和标量代码一样 —— 先切片,让长度对编译器可见

// ❌ 每次 load 都检查
for i := 0; i+8 <= len(x); i += 8 {
    v := archsimd.LoadFloat32x8Slice(x[i:])
    _ = v
}

// ✅ 提前收窄,检查次数减少
n := len(x) / 8 * 8
xs := x[:n:n]   // 三索引切片,同时锁死 cap
for i := 0; i < len(xs); i += 8 {
    v := archsimd.LoadFloat32x8Slice(xs[i:])
    _ = v
}

5.6 尾部处理的三种策略

策略做法适用
标量收尾剩下的用普通循环最简单,n 大时开销可忽略;默认选这个
掩码收尾用 mask 做一次部分向量操作AVX-512 上很划算(原生掩码 load),AVX2 上要手工造掩码
重叠收尾最后一次从 len-W 开始,重复处理几个元素只适用于幂等操作(如 max、按位或);求和类绝对不能用,会重复计数

第三种是很多人踩的坑:看到 simdjson 里用重叠读,就照抄到求和上,结果多加了几个元素。幂等性是前提条件。

5.7 别忘了先做「零成本」优化

在写任何 SIMD 之前,先试这些(改一行、零风险):

GOAMD64=v3 go build       # 免费 5%~15%
GOFLAGS=-pgo=default      # PGO,Go 1.21+ 稳定,典型 2%~7%

再确认这些基础功课做完了:

  • 数据结构从 AoS 改成 SoA
  • 热路径上的 []interface{} 换成具体类型
  • 消掉不必要的边界检查和堆分配(-gcflags=-m 看逃逸)

如果这些都没做,直接上 SIMD 是在给一辆没打气的自行车装涡轮增压。

5.8 用 perf 确认瓶颈,别靠猜

# Linux
perf stat -e cycles,instructions,cache-misses,stalled-cycles-backend ./app

# 看 IPC(instructions per cycle)
# IPC < 1  → 大概率内存/延迟受限,SIMD 帮不上忙,先修内存访问模式
# IPC > 2  → 计算受限,SIMD 有搞头

IPC 是决定「该不该上 SIMD」的单一最重要指标。 一分钟就能测出来,比争论一小时有用。


六、争议与风险:这套东西真的都是好事吗

我不想写一篇纯赞歌。这套设计有实实在在的代价,而且有些代价是永久性的。

6.1 Rob Pike 的担忧:复杂性的单向阀门

Go 1.26 的 SIMD 提案在社区引发过一轮关于复杂性预算的讨论,连 Rob Pike 都表达了担忧。核心论点值得认真对待:

Go 的核心竞争力从来不是性能,是「一个中级工程师读得懂大型代码库」。

archsimd 引入了:

  • 上百个新类型(各种 XxxYyy×N 组合)
  • 上千个方法
  • 一套普通 Gopher 完全陌生的心智模型(lane、mask、shuffle、水平操作)

这些复杂性一旦进入标准库,就永远无法移除(Go 1 兼容性承诺)。

我的看法:这个担忧成立,但结论应该是「分层隔离」而不是「不做」。 好在两层模型本身就是隔离手段 —— 99% 的人只需要认识 simd.Float32sAdd/Mul/Load/Store 这几个东西,archsimd 那上千个方法是为写库的人准备的,就像今天没人抱怨 syscall 包里有几千个常量一样。

真正的风险不在标准库,在代码审查:当团队里有人为了 5% 的性能提交了一段没人看得懂的 SIMD 代码,你的 code review 该怎么办?这是团队治理问题,不是语言问题,但语言给了它发生的条件。

建议:把 SIMD 代码集中到少数几个包里,配强制的差分测试,禁止散落在业务逻辑中。

6.2 API 稳定性的两难

archsimd架构绑定的。那么问题来了:

  • Intel 明天发布 AVX10.2,新增一批指令,archsimd 要不要加?加了就是 API 膨胀。
  • 某条指令在新微架构上变慢了(历史上真的发生过,比如某些 VGATHER 在部分代际上性能很差),archsimd 里对应的方法怎么办?不能删(兼容性),只能在文档里写「这个方法在 XXX 上很慢」。

长期看,archsimd 会变成一个只增不减的、带着历史包袱的巨大表面。 这是所有低层 API 的宿命,syscall 包也走过这条路(最后被迫拆出 golang.org/x/sys)。

我的预测:archsimd 最终也会有一个 x/simd 影子仓库来承接快速演进的部分。 提案里还没提这件事,但从 Go 的历史模式看,这几乎是必然。

6.3 AI 生成 SIMD 代码:一个被低估的新风险

这一点几乎没人讨论,但我认为它在 2026 年比前两点都重要。

SIMD 代码有个恶劣的性质:错了不一定崩,只是结果不对。

  • 尾部处理错了 → 少算/多算几个元素,结果偏差 0.001%,测试可能过
  • 掩码逻辑反了 → 在特定输入分布下才暴露
  • 整数溢出 → 只在极端数据上出错
  • 越界读 → 通常读到同一页内的脏数据,不 panic,只是答案错

而现在的编码 Agent 非常乐意帮你写 SIMD 代码,写出来的东西长得很对。加上 SIMD 代码天然难以人工审查(谁能一眼看出 AddPairsGrouped 调用两次和三次的区别?),这是个很危险的组合。

对策就一条,而且不能省:差分测试 + 全长度扫描 + 随机输入 + 容差比较。 4.5 节那个 for n := 0; n <= 64; n++ 的循环不是形式主义 —— 它是唯一能在 SIMD 代码上建立信心的手段。

顺带一提:Zig 社区今年明确表态拒绝 AI 生成的贡献,Andrew Kelley 说得很直接。放在 SIMD 这个语境下,这个立场比看起来更有道理 —— 不是因为 AI 写不出来,是因为审查成本高于编写成本时,生成的便利性变成了净负债

6.4 什么情况下你不应该用 SIMD

诚实清单:

  • 需要 bit 级可复现的浮点结果(金融、需可复现的科研计算)→ 浮点归约类不能用
  • IPC < 1,内存受限 → 先修内存访问模式
  • 热数据量在几十个元素以下 → 调用和派发开销吃掉收益
  • 数据是 AoS 布局且不能改 → de-interleave 开销可能占大头
  • 团队没有 SIMD 经验且没有差分测试文化 → 风险 > 收益
  • 瓶颈其实在网络/磁盘/序列化上 → 先 profile,别自我感动
  • 代码只跑一次或 QPS 很低 → 优化的经济性不成立

满足以上任意一条,把这篇文章收藏起来,去干更重要的事。


七、迁移与落地清单(15 条,可直接抄进 checklist)

  1. 先 profile。 pprof + perf stat 确认热点和 IPC,禁止靠猜。
  2. 先开 GOAMD64=v3 零改动,先白拿 5%~15%,然后重新 profile。
  3. 先开 PGO。 -pgo=default,同样零改动。
  4. 先改数据布局。 AoS → SoA。这一步的收益经常超过 SIMD 本身。
  5. 确认浮点语义要求。 需要 bit 级复现的话,浮点归约类 SIMD 直接排除。
  6. 保留标量实现。 永远不删,作为差分测试基准和新架构兜底。
  7. build tag 三件套。 _scalar.go / _amd64.go / _arm64.go + 无 tag 的 generic 对照实现。
  8. 默认 256 位。 Float32x8 起步,AVX-512 单独 tag、压测后再决定。
  9. 累加器从 4 路展开开始。 测 2 和 8,取最优。
  10. 水平操作移出热循环。 归约只在最后做一次。
  11. 三段式尾部。 step 循环 → W 循环 → 标量循环,别只写两段。
  12. 差分测试扫 0~64 全长度,随机输入,容差比较不用 ==
  13. benchmark 扫多个 n(16 / 256 / 4K / 64K / 1M),SetBytes 报吞吐,KeepAlive 防 DCE,benchstat -count=10
  14. 查寄存器溢出。 -gcflags=-S | grep 'MOVUPS.*SP',热循环里出现就减展开度。
  15. SIMD 代码集中到少数包。 禁止散落业务逻辑,配强制 review 规则和差分测试门禁。

八、总结:Go 的「性能下半场」,与一个更大的判断

把时间线捋一遍:

  • Go 1.0 ~ 1.25(2012-2025):靠 goroutine + channel + 单二进制部署,赢下云原生基础设施的上半场。性能上「够用就好」,极限性能场景一律外包给 C/Rust。
  • Go 1.26(2026.02)GOEXPERIMENT=simd,AMD64 实验性 SIMD 落地。同版本还有 new(expr)、泛型规则放宽、GC 持续演进。
  • Go 1.27(提案中)simd/archsimd for AMD64 默认开启(#78979);可移植 simd 包提案(#78902);泛型方法(generic methods)也已实现,同期发布。

这三件事凑在一起,信号很明确:Go 正在把自己从「胶水语言 + 服务编排语言」,往「能承担计算内核的语言」方向推。

为什么是现在?我认为答案是 AI 基础设施。

推理服务的编排层、向量数据库、embedding 的预处理与后处理、特征工程、tokenizer、数据管道 —— 这些活儿今天大量是 Go 写的外围,而内核全是 C++/Rust/CUDA。中间那层胶水的开销(序列化、跨语言调用、内存拷贝)在高 QPS 下非常可观。

如果 Go 能自己吃下向量计算这一段,那么「用 Go 写完整的推理服务,而不只是 HTTP 网关」就成立了。这是一个很大的市场,Go 团队显然看到了。

但我想给出一个更冷静的判断:

SIMD 不会让 Go 变成 Rust 的替代品,它会让 Go 停止在某些场景下失血。

极限性能的战场(编译器、游戏引擎、DSP、GPU kernel)Go 不会赢,也不该去争 —— 那里需要的是对内存布局的完全控制和零成本抽象,Go 的 GC 和运行时模型注定要付出代价。

但 Go 会守住一大块阵地:那些「主要是工程复杂度、次要是计算强度」的系统。 数据管道、可观测性后端、日志/指标处理、时序数据库、消息队列、API 网关、AI 推理编排。这些系统里,工程可维护性的权重远高于最后 20% 的性能 —— 而这恰恰是 Go 的主场。过去这些系统的热点函数只能靠手写汇编或者 cgo 外包,现在可以用 Go 写。

这就是「性能下半场」的真实含义:不是去赢别人的战场,是不再在自己的战场上被迫外包。

最后给一条最实际的建议:

今天你能做的最有价值的事,不是学 archsimd 的 API(它还在变),而是把你的热点数据结构从 AoS 改成 SoA,把 GOAMD64=v3 加进构建脚本,把 perf stat 加进性能回归流程。

等 Go 1.27 落地那天,做好这三件事的项目,改 20 行代码就能吃到红利;没做的项目,得先重构两周。

那扇门正在打开。但门后的路,得靠你自己提前铺好。


参考与延伸:

  • golang/go#73787 — SIMD API 两层模型设计讨论(含命名哲学之争)
  • golang/go#78979 — 提案:Go 1.27 默认开启 simd/archsimd for AMD64
  • golang/go#78902 — 提案:架构无关的可移植 simd
  • go doc simd/archsimd — 以本地工具链的实际文档为准
  • Agner Fog, Instruction Tables — 各微架构指令延迟/吞吐权威数据
  • BLIS 论文 — 分块矩阵乘为什么比向量化更重要

免责声明:本文涉及的 simd / simd/archsimd API 在写作时(2026 年 8 月)部分仍处于提案与实验阶段,签名与行为可能变化。所有性能数字请在你自己的目标硬件上用 benchstat 实测,不要直接采信任何文章里的加速比 —— 包括这一篇。

推荐文章

thinkphp分页扩展
2024-11-18 10:18:09 +0800 CST
程序员茄子在线接单