Go语言优化之道:从低效实现到SIMD加速

有一个函数,它被频繁调用。更重要的是,所有这些调用都位于关键用户交互的临界路径上。让我们来讨论如何让它变得更快。

剧透:这是一个点积运算。

背景介绍(或跳转到精彩部分)

我们正在开发一个名为Cody的代码AI工具。为了让Cody能够很好地回答问题,我们需要为其提供足够的上下文来处理。其中一种实现方式是利用嵌入表示

对于我们的应用场景,嵌入表示是指对一段文本的向量化表示。它们以这样的方式构建,使得语义相似的文本片段具有更相似的向量。当Cody需要更多信息来回答查询时,我们会对嵌入进行相似性搜索,以获取一组相关的代码片段,并将这些结果 feeding 给Cody,以提高结果的相关性。

元素周期表

与本博客文章相关的是相似性度量,即确定两个向量相似程度的函数。对于相似性搜索,常见的度量是余弦相似性。然而,对于归一化向量(即模长为1的向量),点积计算出的排序结果与余弦相似度等价。为了进行搜索,我们计算数据集中的每个嵌入的点积,并保留最相关的结果。由于在获得必要的上下文之前无法启动大语言模型(LLM)的执行,因此优化此步骤至关重要。

你可能会想:为什么不直接使用索引向量数据库?除了需要管理额外的基础设施外,构建索引会增加延迟并提升资源需求。此外,标准最近邻索引仅提供近似检索,与更易解释的穷举搜索相比,这会增加另一层不确定性。鉴于此,我们决定在自研解决方案上投入一些精力,看看能将其优化到何种程度。

目标

这是一个用Go语言实现的计算两个向量点积的函数。我的目标是概述优化此函数的历程,并分享途中掌握的一些工具。

func DotNaive(a, b []float32) float32 {
	sum := float32(0)
	for i := 0; i < len(a) && i < len(b); i++ {
		sum += a[i] * b[i]
	}
	return sum
}

    

除非另有说明,所有基准测试均在 Intel Xeon Platinum 8481C 2.70GHz CPU 上运行。这是 c3-highcpu-44 GCE 虚拟机。本文中的所有代码均可在此处以可运行形式查看 这里

循环展开

现代 CPU 有一种称为 指令流水线 的机制,即在没有数据依赖关系的情况下,可以同时执行多条指令。数据依赖关系意味着一条指令的输入依赖于另一条指令的输出。

在我们的简单实现中,循环迭代之间存在数据依赖关系。实际上,有两个。isum 每个迭代都有一个读写对,这意味着一个迭代无法开始执行,直到前一个迭代完成。

在这种情况下,从 CPU 中榨取更多性能的常见方法称为循环展开。基本思路是重写循环,使更多相对高延迟的乘法指令能够同时执行。此外,它将固定循环开销(增量和比较)分摊到多个操作中。

    

func DotUnroll4(a, b []float32) float32 {
	sum := float32(0)
	for i := 0; i < len(a); i += 4 {
		s0 := a[i] * b[i]
		s1 := a[i+1] * b[i+1]
		s2 := a[i+2] * b[i+2]
		s3 := a[i+3] * b[i+3]
		sum += s0 + s1 + s2 + s3
	}
	return sum
}

    

在展开后的代码中,乘法指令之间的依赖关系被消除,使 CPU 能更好地利用流水线。与原始实现相比,这使吞吐量提高了 37%。

需要注意的是,我们实际上可以通过调整展开的迭代次数来进一步优化性能。在基准测试机器上,8次迭代似乎是最佳选择,但在我的笔记本电脑上,4次迭代表现最佳。然而,这种优化效果因平台而异且较为微小,因此在本文剩余部分,我将为了可读性而统一使用4次迭代的展开深度。

边界检查消除

为了防止越界切片访问成为安全漏洞(如著名的Heartbleed漏洞),Go编译器会在每次读取前插入边界检查。您可以在生成的汇编代码(https://go.godbolt.org/z/qT3M7nPGf)中查看(查找runtime.panic)。

编译后的代码看起来就像我们写了类似以下内容:

    

func DotUnroll4(a, b []float32) float32 {
	sum := float32(0)
	for i := 0; i < len(a); i += 4 {
        if i >= cap(b) {
            panic("out of bounds")
        }
		s0 := a[i] * b[i]
        if i+1 >= cap(a) || i+1 >= cap(b) {
            panic("out of bounds")
        }
		s1 := a[i+1] * b[i+1]
        if i+2 >= cap(a) || i+2 >= cap(b) {
            panic("out of bounds")
        }
		s2 := a[i+2] * b[i+2]
        if i+3 >= cap(a) || i+3 >= cap(b) {
            panic("out of bounds")
        }
		s3 := a[i+3] * b[i+3]
		sum += s0 + s1 + s2 + s3
	}
	return sum
}

    

在这样的热循环中,即使使用现代分支预测技术,每次迭代中增加的分支数量也可能导致相当显著的性能开销。这种情况在我们的案例中尤为明显,因为插入的跳转语句限制了我们利用流水线技术的能力。

如果我们能让编译器相信这些读取操作绝不会越界,它就不会插入这些运行时检查。这种技术被称为“边界检查消除”,相同的模式也适用于Go语言以外的其他语言

理论上,我们应该能够在循环外部一次性完成所有检查,编译器就能确定所有切片索引操作都是安全的。然而,我未能找到合适的检查组合来让编译器相信我的操作是安全的。最终我采用了两种方法的组合:断言长度相等,并将所有边界检查移至循环顶部。这足以使性能接近无边界检查版本的水平。

    

func DotBCE(a, b []float32) float32 {
	if len(a) != len(b) {
		panic("slices must have equal lengths")
	}

    if len(a)%4 != 0 {
		panic("slice length must be multiple of 4")
	}

	sum := float32(0)
	for i := 0; i < len(a); i += 4 {
		aTmp := a[i : i+4 : i+4]
		bTmp := b[i : i+4 : i+4]
		s0 := aTmp[0] * bTmp[0]
		s1 := aTmp[1] * bTmp[1]
		s2 := aTmp[2] * bTmp[2]
		s3 := aTmp[3] * bTmp[3]
		sum += s0 + s1 + s2 + s3
	}
	return sum
}

    

减少边界检查带来了 9% 的性能提升。虽然始终不为零,但提升幅度并不显著。

这种技术在许多内存安全的编译语言中都能很好地应用,例如 Rust

读者练习:为什么我们使用 a[i:i+4:i+4] 而不是 a[i:i+4] 进行切片?

量化

到目前为止,我们已将单核搜索吞吐量提升了约 50%,但现在遇到了新的瓶颈:内存使用量。我们的向量维度为1536。每个元素占用4字节,因此每个向量占用6KiB内存,而每GiB代码会生成约100万个向量。这会迅速累积。我们有部分客户使用了庞大的单仓库项目,因此希望降低内存占用以更经济地支持这类场景。

一种可能的缓解方案是将向量移动到磁盘,但在搜索时从磁盘加载向量会增加显著的延迟,尤其是在使用较慢的磁盘时。相反,我们选择使用int8量化来压缩向量。

压缩向量的方法有很多,但我们将讨论整数量化,这种方法相对简单,但非常有效。其核心思想是通过将4字节的float32向量元素转换为1字节的int8,来降低精度。

我不会详细说明我们如何决定在float32int8之间进行转换,因为这是一个相当深奥的话题,但可以肯定的是,我们的函数现在看起来像以下这样:

    

func DotInt8BCE(a, b []int8) int32 {
	if len(a) != len(b) {
		panic("slices must have equal lengths")
	}

	sum := int32(0)
	for i := 0; i < len(a); i += 4 {
		aTmp := a[i : i+4 : i+4]
		bTmp := b[i : i+4 : i+4]
		s0 := int32(aTmp[0]) * int32(bTmp[0])
		s1 := int32(aTmp[1]) * int32(bTmp[1])
		s2 := int32(aTmp[2]) * int32(bTmp[2])
		s3 := int32(aTmp[3]) * int32(bTmp[3])
		sum += s0 + s1 + s2 + s3
	}
	return sum
}

    

这一改动使内存使用量减少了4倍,但牺牲了一定的精度(我们已仔细测量过,但这与本文无关)。

遗憾的是,重新运行基准测试显示,搜索速度因这次更改而略有下降。查看生成的汇编代码(使用 go tool compile -S),发现有一些新的指令用于将 int8 转换为 int32,这可能解释了差异。不过我没有深入研究,因为到目前为止的所有性能改进在下一节中都变得无关紧要。

SIMD

到目前为止的性能提升虽然不错,但仍不足以满足我们最大的客户需求。因此我们开始尝试更激进的方法。

我总是乐于找借口玩弄SIMD。而这个问题似乎正是为这种技术量身定制的。

对于不熟悉的读者,SIMD代表“单指令多数据”。正如其名,它允许你用一条指令对一组数据执行操作。例如,要对两个int32向量进行元素级加法,我们可以使用ADD指令逐个相加,或者使用VPADDD指令一次处理64对数据,其延迟与相同 延迟(取决于架构)。

不过我们遇到一个问题。Go 语言并未像 CRust 那样提供 SIMD 内置函数。我们有两个选择:用 C 语言编写并使用 Cgo,或者手动为 Go 的汇编器编写。我尽量避免使用 Cgo,原因有很多(这些原因并不新鲜,详见 [https://dave.cheney.net/2016/01/18/cgo-is-not-go]),其中一个原因是 Cgo 会带来性能开销,而这个代码片段的性能至关重要。此外,亲手编写一些汇编代码听起来很有趣,所以我打算这么做。

我希望这个例程具有一定的可移植性,因此我将仅使用AVX2指令集,因为这些指令集目前在大部分x86_64服务器CPU上都得到了支持。我们可以使用运行时功能检测 切换到纯 Go 实现的较慢选项。

DotAVX2 的完整代码:

    

#include "textflag.h"
TEXT ·DotAVX2(SB), NOSPLIT, $0-52
	// Offsets based on slice header offsets.
	// To check, use `GOARCH=amd64 go vet`
	MOVQ a_base+0(FP), AX
	MOVQ b_base+24(FP), BX
	MOVQ a_len+8(FP), DX
	XORQ R8, R8 // return sum
	// Zero Y0, which will store 8 packed 32-bit sums
	VPXOR Y0, Y0, Y0
// In blockloop, we calculate the dot product 16 at a time
blockloop:
	CMPQ DX, $16
	JB reduce
	// Sign-extend 16 bytes into 16 int16s
	VPMOVSXBW (AX), Y1
	VPMOVSXBW (BX), Y2
	// Multiply words vertically to form doubleword intermediates,
	// then add adjacent doublewords.
	VPMADDWD Y1, Y2, Y1
	// Add results to the running sum
	VPADDD Y0, Y1, Y0
	ADDQ $16, AX
	ADDQ $16, BX
	SUBQ $16, DX
	JMP blockloop
reduce:
	// X0 is the low bits of Y0.
	// Extract the high bits into X1, fold in half, add, repeat.
	VEXTRACTI128 $1, Y0, X1
	VPADDD X0, X1, X0
	VPSRLDQ $8, X0, X1
	VPADDD X0, X1, X0
	VPSRLDQ $4, X0, X1
	VPADDD X0, X1, X0
	// Store the reduced sum
	VMOVD X0, R8
end:
	MOVL R8, ret+48(FP)
	VZEROALL
	RET

    

实现的核心循环依赖于三个主要指令:

  • VPMOVSXBW,该指令将 int8 类型数据加载到 int16 向量中
  • VPMADDWD,该指令对两个 int16 向量进行元素级乘法运算,然后将相邻对的模糊栈相加,生成一个 int32 向量
  • VPADDD,该指令将生成的 int32 向量累加到累加和中

VPMADDWD 在这里是真正的重型工具。通过将乘法和加法步骤合并为一步,它不仅节省了指令,还通过同时将结果扩展为 int32 来帮助我们避免溢出问题。

让我们看看这给我们带来了什么。

哇,与之前的最佳性能相比,吞吐量提高了 530%!SIMD 胜出 🚀

不过,事情并非一帆风顺。在 Go 中手写汇编代码有些奇怪。它使用了一个 自定义汇编器,这意味着其汇编语言与网上常见的汇编代码片段相比,看起来“足够不同以至于令人困惑”。它有一些奇怪的特性,比如改变指令操作数的顺序使用不同的指令名称。有些指令在 Go 汇编器中甚至没有名称,只能通过其 二进制编码 使用。不害臊的推荐:我发现 sourcegraph.com 在查找 Go 汇编示例时非常有用。

不过,与 Cgo 相比,Go 汇编有一些优势。调试功能依然良好,汇编代码可以逐步执行,并且可以使用 delve 检查寄存器。无需额外的构建步骤(无需设置 C 工具链)。可以轻松设置纯 Go 备用方案,以便交叉编译仍能正常工作。常见问题会被 go vet 捕获。

SIMD. ..但更大

此前我们仅限于 AVX2,但如果我们不这样做呢?AVX-512 的 VNNI 扩展新增了 VPDPBUSD 指令,该指令可对 int8 向量而非 int16 向量计算点积。这意味着我们可以使用单条指令处理四倍数量的元素,因为无需先转换为int16,且AVX-512使向量宽度翻倍!

唯一的问题是,该指令要求一个向量为有符号字节,另一个为无符号字节。而我们的两个向量都是有符号的。我们可以使用英特尔开发者指南中的一个技巧 中的技巧来解决这个问题。对于两个 int8 元素 anbn,我们进行元素级计算:an * (bn + 128) - an * 128。其中 an * 128 项是将 bn 提升到 u8 范围时产生的溢出值。我们单独跟踪该值并在最后进行减法。该表达式中的每项操作均可进行向量化。

DotVNNI 的完整代码

    

#include "textflag.h"
// DotVNNI calculates the dot product of two slices using AVX512 VNNI
// instructions The slices must be of equal length and that length must be a
// multiple of 64.
TEXT ·DotVNNI(SB), NOSPLIT, $0-52
	// Offsets based on slice header offsets.
	// To check, use `GOARCH=amd64 go vet`
	MOVQ a_base+0(FP), AX
	MOVQ b_base+24(FP), BX
	MOVQ a_len+8(FP), DX
    ADDQ AX, DX // end pointer
	// Zero our accumulators
	VPXORQ Z0, Z0, Z0 // positive
	VPXORQ Z1, Z1, Z1 // negative
	// Fill Z2 with 128
	MOVD $0x80808080, R9
	VPBROADCASTD R9, Z2
blockloop:
	CMPQ AX, DX
	JE reduce
	VMOVDQU8 (AX), Z3
	VMOVDQU8 (BX), Z4
	// The VPDPBUSD instruction calculates of the dot product 4 columns at a
	// time, accumulating into an i32 vector. The problem is it expects one
	// vector to be unsigned bytes and one to be signed bytes. To make this
	// work, we make one of our vectors unsigned by adding 128 to each element.
	// This causes us to overshoot, so we keep track of the amount we need
	// to compensate by so we can subtract it from the sum at the end.
	//
	// Effectively, we are calculating SUM((Z3 + 128) · Z4) - 128 * SUM(Z4).
	VPADDB Z3, Z2, Z3   // add 128 to Z3, making it unsigned
	VPDPBUSD Z4, Z3, Z0 // Z0 += Z3 dot Z4
	VPDPBUSD Z4, Z2, Z1 // Z1 += broadcast(128) dot Z4
	ADDQ $64, AX
	ADDQ $64, BX
	JMP blockloop
reduce:
    // Subtract the overshoot from our calculated dot product
	VPSUBD Z1, Z0, Z0 // Z0 -= Z1
    // Sum Z0 horizontally. There is no horizontal sum instruction, so instead
    // we sum the upper and lower halves of Z0, fold it in half again, and
    // repeat until we are down to 1 element that contains the final sum.
    VEXTRACTI64X4 $1, Z0, Y1
    VPADDD Y0, Y1, Y0
	VEXTRACTI128 $1, Y0, X1
	VPADDD X0, X1, X0
	VPSRLDQ $8, X0, X1
	VPADDD X0, X1, X0
	VPSRLDQ $4, X0, X1
	VPADDD X0, X1, X0
	// Store the reduced sum
	VMOVD X0, R8
end:
	MOVL R8, ret+48(FP)
	VZEROALL
	RET

    

此实现带来了额外的 21% 性能提升。不错!

接下来呢?

我对吞吐量提升 9.3 倍和内存使用量减少 4 倍感到满意,所以可能就到此为止了。

现实中的答案可能是“使用索引”。有很多优秀的研究专注于加快最近邻搜索的速度,而且有很多内置向量数据库,使得部署变得相当容易。

然而,如果你想有些有趣的思考素材,我的同事构建了一个概念验证项目GPU上的点积

附加材料

  • 如果你还没有使用过benchstat,你应该试试。它非常棒。可以进行超级简单的基准测试结果统计比较。
  • 不要错过编译器探索者,这是一个极具价值的工具,可深入分析编译器的代码生成过程。
  • 还有一次,我被极客狙击逼着实现了 支持ARM NEON指令集的版本,这为后续对比分析提供了有趣的参考。
  • 如果你还没有接触过,Agner Fog 指令表 是进行低级优化时非常有用的参考资料。在这次工作中,我利用它们来帮助理解不同指令的延迟差异,以及为什么某些管道比其他管道更优。

本文文字及图片出自 From slow to SIMD: A Go optimization story

共有 104 条讨论

  1. > 为什么我们使用[i:i+4:i+4]这种切片方式而不是简单的[i:i+4]?

    我之前从未见过这种“完整切片”的语法表达式。原来它很重要,因为它控制了新切片的容量。新切片的容量现在是i+4 – i。

    因此,使用完整切片表达式可以得到一个长度为4、容量为4的切片。如果不这样做,容量将等于原始切片的容量。

    我猜通过控制容量可以消除边界检查。

    1. 在我的测试中[1],这并不会消除边界检查。相反,它避免了计算本应未使用的`cap(a[i:i+4]) = len(a) – i`值,如果我的理解正确的话。

      [1] https://go.godbolt.org/z/63n6hTGGq(原始)与 https://go.godbolt.org/z/YYPrzjxP5 (容量不受限制)

      > 我之前从未见过这种“完整切片”表达式语法。

      Go 语言对容量的处理方式既实用又令人困惑。我通过实践了解到,额外容量始终可用,这是为了优化性能:

          a := []int{1, 2, 3, 4, 5}
          lo, hi := a[:2], a[2:]
          lo = append(lo, 6, 7, 8)      // 哎呀,它试图重新使用 `lo[2:5]`!
          fmt.Printf(“%v %vn”, lo, hi) // 输出 `[1 2 6 7 8] [6 7 8]`
      

      虽然我理解其中的逻辑,但这种设计过于反直觉,因为代码中没有任何提示表明存在额外容量。我更希望 a[x:y] 作为 a[x:y:y] 的简写形式。当然,a[x:y:len(a)] 这种情况是有用的,所以也许可以添加一个不同的简写形式,比如 a[x:y:$]

      1. 我认为切片非常直观……如果你用过 C 语言的话。

        切片封装了 C 语言中常见的模式,即向函数传递:一个指针(指向初始数组元素)和一个长度或容量(有时两者都有)。这种模式被 Go 的切片直接封装,可以理解为类似于:

          type Slice struct {
              Ptr *Elem
              Len int
              Cap int
          }
        

        我非常喜欢 Go 的切片语法。这是恰到好处的语法糖。它消除了这种常见 C 模式中的繁琐和出错空间。它让我能够像在 C 中一样精确地操作内存,但一切都更加简单、轻便和流畅。

        例如,我正在用 Go 开发一款游戏。我不想在游戏过程中不断分配内存(这可能导致帧率下降),因此我选择在启动时分配巨型内存数组。然后使用切片按需划分这些内存。

        其中一些数组会在特定间隔(如每关或每帧等)重新累积元素。因此,首先将它们设置为零长度非常合适:

          myMem = myMem[:0]
        

        注意,myMem切片现在长度为零,但仍指向其底层数组。然后我进行累加:

          for ... {
              myMem = append(myMem, elem)
          }
        

        同样,我不想在一般情况下进行内存分配,因此我非常在意 append 继续使用 myMem 的现有容量。

        所有这些都是为了说明,我并不认为切片的存在是为了“优化”而存在。相反,我认为它们是处理、引用和划分内存的理想工具。

        1. C 没有像 Go 的 `a[x:y]` 这样的切片运算符,这是我想要指出的主要问题。切片本身只是一个自然的构造。

          1. 是的,我指的是 Go 的切片运算符(以及切片类型和相关函数)是从 C 中数组的使用经验中发展而来的。

            对我来说,这一点在阅读 Kernighan 和 Pike 的《编程实践》一书时变得显而易见,后者是 Go 的共同设计者。如果你阅读这本书(该书在 Go 出现之前就已撰写),并注意它如何使用 C 数组,你几乎可以“感受到”Go 切片正在形成。切片语法完美地封装了 C 数组的管理机制。

            1. 我不确定这如何可能。根据我的经验,C 中确实存在三部分切片的概念,但仅以隐式方式存在。例如,

                  size_t trim_end(const char *p, size_t len) {
                      while (len > 0) {
                          if (p[len - 1] != ‘ ’) break;
                          --len;
                      }
                      return len;
                  }
              

              从概念上讲,该函数接受一个切片 `(p, len, cap)`,并返回一个切片 `(p, len2, cap)`,其中 `len2 <= len`,且容量始终不变。但实际参数中没有 `cap`,返回参数中也没有 `p`。一切都是隐式的,对于 C 程序员来说,完全文档化和遵循此类隐式规则是典型的。我认为 Go 的切片运算符不能脱离此类隐式实践。

              相比之下,你的说法只有在以下情况被视为正常时才有意义:

                  struct 切片 { const char *p; size_t 长度, 容量; };
                  struct 切片 trim_end(const struct 切片 *s) {
                      struct 切片 out = *s;
                      while (out.长度 > 0) {
                          if (out.p[out.长度 - 1] != ‘ ’) break;
                          out = subslice(out, 0, out.长度 - 1);
                      }
                      return out;
                  }
              

              请注意,一个假设的`subslice`函数调用与Go代码`out[0:len(out)-1]`完全对应,而我的抱怨同样适用:`subslice`应该有两个明确命名的变体,可能保留或不保留容量。但我几乎从未在C中看到过这样的构造。

              1. 我觉得我们可能在各说各话。我并不是说 C 语言有切片运算符,也不是说通常会将切片定义为函数,更不是说通常会在 C 语言中定义类似切片的结构体。

                我的意思是,如果你看看 C 中数组的使用方式,你会发现通常会与数组一起传递额外的数字。因此 Go 添加了封装这种行为的语法。它封装了最通用的情况(因此同时包含长度和容量,尽管 C 中大多数情况只使用一个数字)。

                在 C 中传递 (char *, int) 时,在 Go 中只需传递 (slice)。Go 的切片运算符为选择缓冲区范围提供了优雅的语法。

                但 Go 切片本质上只是指向底层数组的指针,我认为始终以这种方式理解它们是最好的。因此,mySlice[:2]会保持相同的底层容量,而未来的追加操作会修改该容量。将mySlice[:2]默认解释为mySlice[:2:2]对我来说似乎不太方便(尽管这可以避免你原例中hi/lo的错误,但这些错误源于没有将切片视为底层数组)。

                1. > 在 C 中传递 (char *, int) 时,你只需在 Go 中传递 (slice)。

                  这可能是一个有争议的点。我并不认为 (char *, int) 是单一实体,因此在我看来,它不能被切片(一个明确的单一实体)替代。

                  它们确实会一起出现在参数中,但当你操作它们时,它们必须是独立的变量,而C的语法并不会暗示它们确实相关。因此,你不得不依赖约定(例如namename_lenname_cap),而这些约定往往是脆弱的。你无法从Go切片中期待这种麻烦,所以我认为它们必然是不同的。

                  1. 如果你用“相同的东西”替换它,它就能正常工作,而大多数语言(带有切片)都是这样做的。

                    问题在于 Go 没有这样做,因为其设计者懒得提供一个独立的向量类型,它(指针,整数,整数),现在你可以开始覆盖切片外的内存,除非切片创建者使用了扩展切片形式。

      2. 哇,这似乎相当不安全……

        例如在 D 语言中,这段代码会按大多数人预期的方式工作:

            import std.stdio;
            void main() {
              int[] a = [1, 2, 3, 4, 5];
              auto lo = a[0 .. 2], hi = a[2 .. $];
              lo ~= [6,7,8]; // 新分配
              writefln(“%s %s”, lo, hi);  // 输出 [1, 2, 6, 7, 8] [3, 4, 5]
            }
        

        你无法直接覆盖切片背后的数组(除非你非常明确地使用了不安全操作)。

      3. 这与大多数切片相关的问题一样,直接源于切片同时充当向量。

        切片时保留容量对“切片技巧”(如移除元素)至关重要,我猜想。

    2. 你从未增加过这些切片的大小,对吧?这样在内存使用上会稍好一些,可能也更快?我上次使用 Go 已经有一段时间了,但记得容量是底层数组的长度?内部可能甚至会复用相同的数组,因为它们在每次循环迭代中不会改变大小。

      编辑:奇怪,这本应是之前评论的更新,但现在成了新的评论

    3. 你也没有增加这些切片的大小,对吧?所以从内存使用上来说,它可能稍微好一点,而且可能更快?我上次使用 Go 已经有一段时间了,但我记得切片的容量是底层数组的长度?

      编辑2:(我被限制了,我猜我不能发布新评论?这让我感觉我的声音被偷走了!我猜我不被欢迎在 HN。)

      感谢更正,我本想删除评论,但在更新时发现HN有bug,所以暂时保留这段愚蠢的重复内容。

      1. 这里没有内存复制,a[i: i+4]或a[i: i+4: i+4]切片与a引用的是同一底层数据。

    1. 我确实考虑过使用Avo!我甚至尝试过用Avo实现一个版本,因为它有一个不错的点积示例可以作为起点。但最终,考虑到这些函数的规模如此之小,我认为Avo是一个不必要的额外层,需要花时间去理解。此外,它仅支持x86架构,而我事先就知道需要实现ARM版本,因为我们本地也进行一些嵌入式计算。

      如果我以后要进一步优化并添加循环展开等功能,我一定会选择Avo

      1. 感谢分享,这真是一个有趣的结尾!

  2. 我非常感激有这么多工程师热衷于进行此类改进。我需要他们!我作为高管主要专注于产品,没有这些聪明的人,我们绝不可能拥有优质产品。

    继续保持出色工作!

  3. 我昨天了解到GoLang的汇编器https://go.dev/doc/asm——在浏览了箭头在不同语言中的实现方式后(我的经验主要集中在C/C++) – https://github.com/apache/arrow/tree/main/go/arrow/math – 这里有很多.S(“asm”文件),但我仍然无法完全理解它们的具体工作原理(我想这需要更多阅读)——它们似乎非常独特。

    上次使用内联汇编是在Turbo/Borland Pascal时代,后来在Visual Studio(32位)中也用过,直到该功能被禁用。之后我很少使用GCC,因为其规范更为严格(前者需要了解ABI的工作原理,后者同样需要,但后者有明确的规范)。

    无论如何——我没想到会在“Go”中看到这个 🙂 但我想你可以从.go代码开始,然后生成汇编(-S),再进行优化,或者找人来做。

  4. 我好奇一个简单的C for循环在-O3和可能的-march下在这里表现如何。

    根据我对汇编语言(主要是AARCH64)的简要研究,现在C编译器似乎能自动检测此类模式并将其全部转换为SIMD指令,无需程序员干预。即使在-O2优化级别,将基于索引的循环转换为基于起始和结束指针的循环也并不罕见。Go似乎不具备此功能,Go编译器生成的汇编代码与实际代码的相似度远高于C语言。

    Rust迭代器在此处进行基准测试也颇具趣味,它们理论上与普通循环同样快速,且可能通过完全省略边界检查进行优化。

    1. > Rust迭代器在这里进行基准测试也会很有趣

      我开始写这个,然后想“你知道吗,考虑到这有多常见,我打赌我甚至可以直接谷歌一下”,我觉得这样会更有趣,因为它让它感觉更“真实世界”。我得到的第一个结果就是我原本会写的内容:https://stackoverflow.com/a/30422958/24817

      这是一个带有三个不同输出的godbolt示例:一个在-O模式下,一个在-O3模式下,另一个在-03和-march=native模式下

      https://godbolt.org/z/6xf9M1cf3

      粗略观察的评论:

      看起来2和3生成的输出非常相似,甚至可能完全相同。

      添加 native 标志会生成略有不同的代码生成,我目前还无法仅凭肉眼判断这种差异的意义。

      它似乎完全取消了边界检查,并且使用了 xmm 寄存器。

      我对这个输出感到惊喜,因为 zip 特别有时会阻碍优化,但 rustc 在这里做得非常出色。

      ———————-

      出于好奇,我尝试“尽可能直接地复现原生 Go 代码”。这里唯一的技巧是 Rust 并不像 Go 那样实现 C 风格的 for 循环,所以我试图翻译我理解的示例精髓:比较两个长度并使用较小值作为循环长度。

      这里是代码:https://godbolt.org/z/cTcddc8Gs

      几乎一模一样。我对这个结果感到非常惊讶。这让我怀疑 LLVM 是否对点积有某种特定的语法识别机制。

      EDIT:目前似乎没有,请参见第28行和第29行的注释:https://llvm.org/doxygen/LoopIdiomRecognize_8cpp_source.html

      1. 这些操作并未进行向量化,只是展开了。vmulss/vaddss 只是对向量寄存器中的单精度浮点数进行乘法/加法运算。

        使用 clang 时,生成的代码基本相同,不过它使用了融合乘加操作。

        问题在于你需要启用-ffast-math选项,否则编译器无法改变浮点运算的顺序,从而无法进行向量化。

        使用 Clang 时效果非常出色,它为我们生成了一个漂亮的四次展开的 AVX2 融合乘加循环,但在 Rust 中启用它似乎无法正常工作:https://godbolt.org/z/G4Enf59Kb

        编辑:据我所知,这仍然是一个未解决的问题???https://github.com/rust-lang/rust/issues/21690

        编辑:相关 Stack Overflow 帖子:https://stackoverflow.com/questions/76055058/why-cant-the-ru… 显然你需要使用 `#![feature(core_intrinsics)]`、`std::intrinsics::fadd_fast` 和 `std::intrinsics::fmul_fast`。

        1. 啊,是的,谢谢。

          Rust 没有 -ffast-math 标志,不过你直接将它传递给 LLVM 确实很有趣。说实话,我有点庆幸这个逃生 hatch 不起作用。

          目前有一些不稳定的内置函数可以实现这一点,使用它们似乎可以接近 Clang 的代码生成效果:https://godbolt.org/z/EEW79Gbxv

          跟踪此问题的线程讨论了通过直接启用 CPU 功能来启用此功能的另一个尝试,但似乎在此情况下并未影响代码生成。https://github.com/rust-lang/rust/issues/21690

          至少希望这些内置函数能稳定下来。

          EDIT:哦,你在我写这段话时就搞清楚了,哈哈。

          1. 即使不使用-ffast-math标志,当前稳定的Rust编译器也会对整数类型的循环进行向量化。

            https://godbolt.org/z/KjErzacfv

            编辑:……我现在意识到我回复的是谁,你肯定已经知道这个了。:)

            1. 浮点数的循环没问题,只是减少操作会遇到浮点数的结合性假设问题,导致 UB。你可以像 wide crate 一样制作 f32x16 类型来绕过这个问题,或者如果你使用 nightly simba,可以使用 const 通用表达式来绕过这个问题。

        2. 就在上周,我读到一条评论,似乎暗示不应使用-ffast-math[0],但这里看起来是一个启用它的非罕见理由。

          这里正确的用法是什么?如果这种事情对你真的很重要,你应该具备手动编写几行汇编代码的技能。我想说这很罕见,但几年前我有一个项目,需要在树莓派上手动编写一些向量化指令。

          [0] https://news.ycombinator.com/item?id=39013277

          1. 绝对不要把HN上的评论当作严肃的建议。为你的代码启用快速数学,运行适合你领域的目标评估,如果通过测试,享受额外的速度。

            顺便说一句,我有很多数值C++代码,快速数学不会改变输出。

            1. 很长一段时间以来,-funsafe-math-optimizations(以及-ffast-math)都是具有传染性的[1],因此一个负责任的库本就不应使用-ffast-math

              你说的没错,最终二进制文件可以自由启用`-ffast-math`,只要你能验证一切正常。但几乎没有人会实际验证这一点。这就像建议你不要编写自己的加密代码——如果你知道自己在做什么,那没问题,但几乎没有人知道,所以这个建议在技术上是错误的,但仍然值得一试。

              [1] https://gcc.gnu.org/bugzilla/show_bug.cgi?id=55522 (GCC),https://github.com/llvm/llvm-project/issues/57589 (LLVM)

            2. 这听起来像是试图运行一个程序来检查它是否存在地下行为。如何设计一个全面且未来编译器安全的测试?

          2. 通常的做法是维护一个4元素的和数组(即sum[j]表示所有形式为a[4*i + j] * b[4*i + j]的项之和),并在最后计算总和。这允许在严格遵守IEEE标准的情况下使用向量化。

            通常,我建议避免使用-ffast-math,主要是因为它会启用-ffinite-math-only,而这可能会导致严重的问题。其他大多数标志(如-funsafe-math-operations)在精度方面并不那么糟糕。显然,对于已经调优以最小化误差的代码,你不应该启用这些标志,但在其他情况下,它们几乎不会降低结果的质量。

          3. 我认为Rust在此处的正确方向是与数学运算(如饱和运算)采用相同做法:稳定一个执行该语义的函数或方法,然后在其上构建轻量级封装以提升使用便利性。

          4. 我认为最好的解决方案是使用特殊的“快速”浮点类型,这些类型对精度要求不那么严格。

            我个人在关注性能的C程序中几乎总是默认使用-ffast-math选项,因为我几乎从不关心精度损失。我唯一记得需要它的情况是在进行随机数分布测试时,我关心次正常数,并且因为它们似乎不存在而感到困惑(-ffast-math在x86上禁用了它们)。

            1. 或者使用作用域优化指令。GCC 允许使用 `__attribute__((optimize(“-ffast-math”)))` 作为函数级属性,但 Clang 似乎没有等效选项,而标准语法 `[[gcc::optimize(“-ffast-math”)]]` 似乎也不起作用。无论如何,我认为此类优化应在代码中可见。

              1. 问题在于,仅需一段使用-ffast-math编译的代码即可破坏整个系统,这显然不值得

                1. GP似乎在说,你可以通过在GCC中标记单个函数来避免这个问题:只有被标记的函数才会使用快速数学语义进行编译。

                  1. 这只在函数是叶子函数且会丢弃结果时有效。如果你将–fast-math函数的结果传递给其他正常运行的代码,就有可能破坏它。

                    1. `-ffast-math` 是完全局部的,除了 GCC 的意外 `crtfastmath.o` 链接,该链接是全局的。

                      启用 `-ffast-math` 的函数仍通过常规寄存器以常规格式返回浮点值。如果某个函数 `f` 预期对特定输入返回 -1.0 到 1.0,`-ffast-math` 只会使其返回 1.001 或 NaN。如果另一个未启用 -ffast-math 的函数期望并验证 f 的返回值,它肯定会出现异常行为,但仅因对 f 的原始分析不再成立。

                      -ffast-math 编译器选项存在问题,因为此效果在代码中并不明显。代码中可见的内容应无问题。

          5. > 你应该具备手动编写几行汇编代码的能力

            针对哪种架构?如果这段代码位于一个库中,而用户可能希望在 Intel(32 位和 64 位)、ARM、Risc V 和 s390x 等架构上运行该库,该怎么办?即使你学会了所有这些架构的汇编语言,你如何才能获得一台 S390X IBM 主机来测试你的代码?如果未来几年出现一种新的架构[1]并流行起来,而你无法获得相应的CPU进行测试,该怎么办?

            将这项工作交给编译器或使用内置函数/宏的架构无关函数,可以让你无需考虑这些问题。只要用户运行的系统具备良好的编译器支持,你的代码就能正常运行且速度快,即使在数年后也是如此。

            [1] https://en.wikipedia.org/wiki/Loongson

          6. 你可以这样编写代码让编译器生成SIMD:https://godbolt.org/z/ohvoEb7er

            当然这并非完美(尤其是最后的取余处理)。

            遗憾的是,Rust 没有一个合适的优化浮点类型。我真的很希望有一个类似 FastF32 的类型,可以使用代数中的常规变换规则进行优化(例如结合律、分配律、x + y – y = x 等)。

            有 fadd_fast 等函数,但它们在遇到 NaN 或无穷大输入时行为未定义。

          7. 通常人们希望实现 -ffast-math 功能的子集,例如 -fassociative-math。且仅在有限范围内生效。

            1. 我对示例进行了一些尝试,向量化的最低要求似乎是 -fassociative-math -fno-signed-zeros。GCC 文档指出,-fassociative-math 需要与 -fno-signed-zeros 和 -fno-trapping-math 配合使用。

              我认为 -fassociative-math -fno-signed-zeros -fno-trapping-math -freciprocal-math 能够实现大部分需求,必要时可添加 -ffinite-math-only。

        3. fast-math 选项会影响未编译该选项的库,因此请谨慎使用。

      2. 我尝试编写了这两个版本,包括初始的浮点数版本和后来的整数版本。需要注意的是,我尚未实际测试这些代码以确保输出正确,因此可能存在错误,且我针对 Rocketlake 处理器进行了优化。

        对于浮点数版本[0],我不得不使用不稳定的 portable_simd 来实现向量化。函数的大部分内容都是用于初始化设置,但实际计算过程非常简单,与非 SIMD 部分基本相同。我之前从未使用过 portable SIMD 相关功能,但这次使用起来感觉相当不错。

        对于基于整数的版本,我最初采用了简单的直观方法[1], 并在稳定版本上实现了相当不错的向量化。然而,它并未使用点积指令。为此,我认为需要使用夜间版本并进行一些手动调整[2]. 毫不意外,最终结果与浮点版本非常相似,因为其中相当一部分只是初始化设置。我在此处未做处理,但应通过特性检测确保指令存在。

        [0] https://godbolt.org/z/Gdv8azorW [1] https://godbolt.org/z/d8jv3ofYo [2] https://godbolt.org/z/4oYEnKTbf

    2. 这取决于具体情况。

      你需要2~3个累加器来饱和指令级并行性,实现并行求和缩减。但编译器不会这样做,因为它只在操作具有结合律时创建累加器,即(a+b)+c = a+(b+c),这对整数成立但对浮点数不成立。

      在-ffast-math选项下有一个变通方法。

      我对此有详细的基准测试:https://github.com/mratsim/laser/blob/master/benchmarks%2Ffp…

      1. 根据我的经验,编译器很少能有效利用指令级并行性(ILP),即使在一些看似“简单”的场景中也是如此。手动编写SIMD代码,至少在我看来,几乎总是比编译器生成的自动向量化代码快几倍。

        1. 它们确实会重新排序指令。我认为 SIMD 部分与循环分析的关系比与 ILP 更密切。

          有一个 #pragma omp simd 来提示编译器重写循环,这非常能说明问题。

          现在,我很好奇多面体编译器的现状如何。已经过去很多年了。考虑到人工智能和大型语言模型(LLMs)的热潮,它们可能会大放异彩。

          1. > 我认为SIMD部分更多与循环分析相关,而非ILP。

            如果你能将算法重写为通过SIMD实现对CPU端口近乎理想的利用,那么这种优化几乎无法被超越。我尚未见过编译器(如GCC、Clang)实现此类优化,至少在我编写的实例中未见。我通过类似方式利用CPU级微架构细节测得显著性能提升。因此我认为这不仅是循环分析的问题,而是编译器几乎无法完成的任务。或许借助AI……

      2. 如果你愿意稍微调整一下代码,其实不需要使用-ffast-math选项,因为你可以自己引入累加器。只要掌握基本原理,编写这种优化器友好的代码并不难。

        1. 这就是我的意思。要么你自己实现累加器,要么你需要编译器的逃生 hatch。

          1. 我的意思是,你不需要 SIMD 来实现累加器。

  5. 这是我们在资源受限的嵌入式硬件环境中常见的优化方式。

    如今,即使在大部分嵌入式平台上,资源也极为丰富,应用程序已实现水平扩展,似乎没人再关心垂直扩展了。

    乘法运算具有交换性;单个节点的性能提升8倍意味着你需要的节点数量减少8倍!这真是个有趣的方式来削减你的AWS账单并让你的SaaS公司实现盈利。

    1. 我不知道真相如何,但人们说,大多数亏损的SaaS初创公司之所以如此,是因为它们根本没有收入,而不是因为它们的成本高于其2倍开发人员薪酬的收入。

      1. 我认为有很多SaaS公司正在用风险投资资金补贴客户支付的费用。我曾在两家SaaS公司工作过,基础设施优化开始成为一个真正的问题。如果你的单位经济指标是负的,新增客户会让你亏钱,你就需要想办法优化。

  6. 对于其他语言(包括Node.js/Bun/Rust/Python等),你可以看看SimSIMD,我今年为它做过贡献(将Node.js/Bun部分的重新编译二进制文件纳入了Mac和Linux上的x8664及arm64架构,以及Windows上的x86和x8664架构的构建流程)。

    [0] https://github.com/ashvardanian/SimSIMD

    1. 我认为SimSIMD目前还没有Rust版本。考虑到它是一个相对较小且自包含的库,对于Rust来说,源代码到源代码的翻译可能是一个更好的选择!

      1. 是的,所有内容都在 C 头文件中。我以为它支持 Rust,但实际上它为 Go 提供了支持!楼主应该去看看。

  7. 这是一个Halide https://halide-lang.org/ 真正能大展身手的地方!它将逻辑与调度分离(展开、向量化、分块、缓存中间结果等),因此作者在文章中描述的每个步骤在Halide中都是可调的。Halide似乎没有Go语言的绑定,因此从Go调用C++可能是唯一可行的方案。

    编辑:作者在此处对FFI的反对意见有其合理性https://news.ycombinator.com/item?id=39110692

  8. 如果能够传递一个向量数组并将外层循环移入过程内部,而不是为每个向量调用过程,那么cgo的开销似乎不会成为问题。这可能仅在Go和C之间存在低开销的数组传递方式时才可行。我并非专家,但快速搜索让我认为这是可能的。

    我明白手动编写汇编代码很有趣,但作为未来可能的维护者,我更倾向于使用 C 而不是汇编。

    1. 绝对可行!然而,我更倾向于尽可能避免使用 cgo,不仅仅是因为开销问题。

      根据我的经验:

      – 它会通过要求 C 工具链来复杂化构建过程

      – 它会使生成单一静态二进制文件变得更加困难

      – 它使可移植性更难实现(尽管汇编本身也不具备可移植性)

      – 它会引发难以调试的问题(我最近遇到一个问题,MacOS签名方式变更导致所有cgo二进制文件在启动时被终止)

      – 调试器无法跨越cgo边界工作(而Go汇编可以!)

      我认为 Dave Cheney 说得最好:https://dave.cheney.net/2016/01/18/cgo-is-not-go

      1. 整个“Cgo不是Go”的批评简直是个笑话,也许如果Go 2真的问世,第一个重大改动就是移除Cgo。

        从那以后,就是美丽的Go代码了。

        1. 天啊,不要!我依赖 cgo 来开发我的游戏!

          更重要的是,我对 cgo 没有任何抱怨。它从未成为性能问题(我的游戏在 240Hz 下运行流畅)。而且与 C 语言的绑定接口非常简单且易于使用(我自行实现了对 SDL2、OpenGL 和 Steamworks 的绑定)。

          Cgo确实让跨平台编译的设置变得更复杂,但我通过大量努力成功实现了(在我的Linux桌面上构建了Windows和MacOS版本)。

          1. 这就是我用讽刺语气表达的观点,Cgo应该因其提供的开发流程而被接受,而不是陷入“Cgo不是Go”的争论。

  9. 作者使用了:

        sum := float32(0)
    

    而非Go语言的零值默认初始化,例如:

        var sum float32
    

    这在风格上是个小问题,但想知道这样做是否有合理原因?

    1. 一个优势是它明确了值,因此无论读者对Go的熟悉程度如何,都能知道该值为零。

    2. 我也是这样做的,因为我觉得 var 关键字看起来不太美观

      1. 当然,我理解从让代码更明确的角度来看。个人而言,我一直倾向于例如:

            var x string
        

        在初始化空值(零值)变量时,而非:

            x := “hello”
        

        当初始化应包含初始值的变量时。

        对我这个Go程序员来说,这种写法更直观地体现了声明的意图。

  10. > 在我们的展开代码中,乘法指令之间的依赖关系被消除,使CPU能够更好地利用流水线。与我们的简单实现相比,这使我们的吞吐量提高了37%。

    这37%的提升真的能归因于循环展开吗?一个非常明显的区别是,展开后的版本中循环仅与len(a)进行比较,而非同时与len(a)和len(b)比较。我对Go的了解不够深入,无法确定编译器是否能优化掉该比较,但在其他语言中这会产生显著影响。

    > 此外,亲手尝试一些汇编代码听起来很有趣,所以我打算这么做。

    我非常想看到编译器生成的汇编代码与这里手动编写的汇编代码的对比。我敢打赌,仅仅对生成的汇编代码进行优化就能带来相当大的性能提升(但显然不如切换到SIMD那么令人印象深刻)。

  11. 我只是出于好奇想尝试并行化。将计算任务拆分并分布到多个核心上。如果你有n个核心,理论上可以实现接近n倍的性能提升,减去数据分布和结果合并的开销。这是Go语言中一个现成的优化方案(无需汇编)。

    1. 哦,我们也进行了大量并行化。这篇博客文章只是专注于单核性能。

  12. 很棒的内容。我们很久以前就在BLAS和LAPACK库中对点乘、轴乘、矩阵乘等操作进行了类似的优化。

  13. Go语言难道不是执行线性代数运算的次优选择吗?

    1. 不仅是线性代数运算,若非Docker和K8s的成功…

  14. 离题了,但我注意到这个网站在代码块中使用了SF Mono字体,这样可以吗?而且,我不是苹果公司(或其他任何公司)的员工,我只是在寻找一款(最好是开源的)免费字体,看起来像SF Mono。

    1. 我认为SF Mono也是最好的。我所知道的最接近它的字体是Commit Mono、Source Code Pro和Consolas。

  15. Go语言中有没有BLAS的等价实现可以帮助解决这个问题?

    1. 引用Reddit上一个类似问题的回答:

      我应该特别讨论一下BLAS,它有一个很好的float32点积实现[0],性能优于博客文章中提到的任何float32实现。我在基准测试平台上得到了~1.9m vecs/s的性能。

      然而,当我们切换到量化向量时,BLAS 立即变得无法使用,因为 BLAS 中没有 int8 格式的点积实现(尽管我希望被证明是错的)

      [0]: https://pkg.go.dev/gonum.org/v1/gonum@v0.14.0/blas/blas32#Do…

        1. 据我所知,这仍然不支持 8 位整数点积?(为了澄清,我使用 int8 表示 8 位整数,而不是 8 字节)

  16. 编写 AVX2/512 代码的最佳方式真的只是将一些汇编代码硬塞进 Go/C/C++ 吗?

    1. 在 C 或 C++ 中,你可以使用 immintrin.h 中的函数。

  17. a) 为什么不使用 switch { case len(a) < len(b): b = b[:len(a)] case len(b) < len(a): a = a[:len(b)] }

    b) 你知道切片的一般长度吗?如果是这样,难道不能自动生成/手动编写 switch 来手动计算吗?这样性能如何?

  18. 今天学到 Go 编译器仍然没有自动向量化。

    1. 即使手动向量化也很痛苦……写汇编,真的?

      Rust 有不稳定的可移植 SIMD 和一些第三方 crates,C++ 也有,C# 有稳定的可移植 SIMD 和一个小型的开箱即用的 BLAS 类库,可帮助完成大多数常见任务(如 SoftMax、Magnitude 等,无需手动编写浮点数跨度),甚至在浏览器中运行时也会使用 PackedSIMD。而 Java 未来某个时间点将引入 Panama 向量(尽管由于计划中对不安全 API 的更改,代码生成质量的问题仍待解决)。

      在这些语言中,Go 处于独特的不利地位。如果这还不够,你可以访问 1Brc 的挑战讨论,看看 Go 在面对 C# 和 C++ 时,连 2 秒的门槛都难以触及:

      https://hotforknowledge.com/2024/01/13/1brc-in-dotnet-among-…

      https://github.com/gunnarmorling/1brc/discussions/67

      1. Panama向量非常令人失望。特别是ByteVector.rearrange耗时约10纳秒,且是实现vpshufb指令(该指令仅需1个时钟周期)的唯一可用方式。像andnot这样的操作并非直接使用andnot指令。将类型系统认为是掩码的32位宽向量转换为向量时,使用了混合操作而非0指令。类似packus的固定重新排列操作缺失。不属于简单通道级运算的算术操作(如maddubs)缺失。aesenc缺失。非临时存储和非临时预取缺失(虽然存在非临时加载指令,但似乎与普通加载指令无异,因此若需跳过其他缓存将数据移动到L1d缓存,必须使用预取指令)。

        1. Panama向量指令集仍处于预览阶段。

          1. 当然,几周后我会在邮件列表上发帖,讨论由于这些问题,许多与向量相关的操作会慢上许多倍,届时我们再看看他们是否会添加 ByteVector.multiplySignedWithUnsignedGivingShortsAndAddPairsOfAdjacentShorts 方法,以便人们能够编写十进制解析器。

        1. 这些数字无法直接比较。如果在相同硬件上运行,这个Go语言解决方案很可能比C#慢2.5倍以上。

    2. 如果不是基于LLVM等已经内置向量化器的框架,这样做真的值得吗?我们仍在等待传说中足够智能的向量化器,即使是较好的向量化器也仍然极其脆弱,任何严肃的高性能工作仍然会显式使用SIMD,而不是试图让向量化器配合工作。

      我更希望新语言专注于改进显式SIMD抽象,如Intel的ISPC,而不是编写另一个仅在简单情况下才有效的魔法向量化器。

        1. Java也是如此,不幸的是,它将作为预览版保留,直到Valhala发布(如果真的发布的话)。

      1. 任何多面体框架都擅长将循环嵌套拆分为可并行化的部分。

        然后就是代码生成的问题。

        但最终,用户需要了解语言的工作原理,哪些是可并行化的,哪些不是,以及他们要求计算机执行的操作的成本。

    3. 我从未做过这类工作,所以无法评论。但如果我需要,我希望有更多控制权。我的意思是,性能提升对所有代码都有益,但如果我需要一段代码具有特定优化,我更倾向于通过语言构造主动选择,这样编译器(或其他工具)可以告诉我何时出现问题。一个设计良好的API,带有从常规代码到API的适配器,会更好,对吧?

      例如,假设我有一个自动性能优化的功能,我手动检查了汇编代码,一切正常。然后有人稍微修改了算法,或者另一位工程师为了某个无关的目的添加了一层间接调用,或者编译器更新了代码路径,导致之前支持的一些情况不再被支持。而优化就悄无声息地消失了。

      1. 你对其他编译器优化也有同样的看法吗?你是否希望编译器永远不展开循环,以便你在需要时手动展开它?

        1. 不,我不是说(或者至少不是那个意思)编译器不应该自动优化。我的意思是,确保某些路径是可优化的可能在需要时很重要。而且,语言构造可能是实现这一点的好方法。

    4. 它们在 x86-64 以外的架构上仍然没有基于寄存器的调用约定,对吧?还是说这些信息已经过时了?

    5. 上次我检查时,它甚至没有展开循环。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注

你也许感兴趣的: