Go RISC-V runtime去掉 Duff’s Device 性能提升最大225%

上游Google团队的 Keith Randell 6月时抛出一个有趣的话题,由于近年的CPU预测技术的发展,如果Go runtime去掉Duff device,性能反而会达到一定的提升。
原帖:https://groups.google.com/g/golang-dev/c/bVoLyx0s3tg

我摘出来有趣的部分并翻译了下:

达夫设备(Duff's Device) 通过在运行时部署一个大规模展开的清零/复制循环来优化内存清零和内存复制操作。编译器会合成一个跳转指令,直接跳转到这个展开循环的特定中间位置,以精确执行所需次数的循环操作。
在Go语言中,其实现形式类似于:

s[0] = 0; s = s[1:]
s[0] = 0; s = s[1:]
s[0] = 0; s = s[1:]
...大量重复指令...
s[0] = 0; s = s[1:]
s[0] = 0; s = s[1:]
s[0] = 0; s = s[1:]
return

当编译器需要清零7个字节时,它会计算从末尾倒数第7条指令的地址并直接跳转执行。
这听起来很巧妙,但每次调用都会产生额外开销。况且现代分支预测器已非常高效,消除调用点的循环开销并不再那么重要。

Keith 还提供了几个CL,不过ARM64/AMD64 的实现是不管对齐的问题的,这就意味着 RISC-V 不能这样用(因为 RISC-V 的 unalignement access 是臭名昭著的软件实现/内核 trap/性能损失大)我决定自己实现 type alignment 的版本。

Keith 版 SSA 非常简洁

(Zero [s] ptr mem) && s > 16 && s < 192 => (LoweredZero [s] ptr mem)
(Zero [s] ptr mem) && s >= 192 => (LoweredZeroLoop [s] ptr mem)

然后编写对应的 code gen 就可以了,但……RISCV就麻烦了,因为要传入一个type alignment进去

(Zero [s] {t} ptr mem) =>
        (LoweredZero [t.Alignment()]
                ptr
                (ADD <ptr.Type> ptr (MOVDconst [s-moveSize(t.Alignment(), config)]))
                mem)

如果直接塞多一个参数,变成

(Zero [s] {t} ptr mem) =>
        (LoweredZero [s]
                ptr
                (MOVconst [t.Alignment()])
                mem)

这样在materialize的时候就会多一个MOV const, reg的指令,这样不优雅!

然后想到bit shift,把LoweredZero [s<<32|t.Alignment()],但这样在SSA优化的时候,mem size就非常恐怖了……怎么样优雅的把这个值塞进 AuxInt,就成了头大的问题。

只好看看 SSA compiler 的实现,然后在 AuxInt 的 type list (cmd/compile/internal/ssa/op.go)发现了“auxARM64BitField”

 // architecture specific aux types
 auxARM64BitField     // aux is an arm64 bitfield lsb and width packed into auxInt

给 RISC-V 直接套上,就会造成程序的文不对题。

改成auxRISCVBitFields,还要加几个前置CL来实现。这不优雅!

于是我全部类型都看了遍,终于发现一个合理的:"SymValAndOff",代价就是……Lowered(Zero|Move)需要添加一个 symEffect 说明这个指令有啥副作用(当然都是Write啦),这下就优雅多了

(Zero [s] {t} ptr mem) && s <= 24*moveSize(t.Alignment(), config) =>
    (LoweredZero [makeValAndOff(int32(s),int32(t.Alignment()))] ptr mem)

完美!

然后测试性能发现,咦,Zero竟然没有任何改进?

MemclrKnownSize112-4             5.602Gi ± 0%    5.601Gi ± 0%         ~ (p=0.363 n=10)
MemclrKnownSize128-4             6.933Gi ± 1%    6.545Gi ± 1%    -5.59% (p=0.000 n=10)
MemclrKnownSize192-4             8.055Gi ± 1%    7.804Gi ± 0%    -3.12% (p=0.000 n=10)
MemclrKnownSize248-4             8.489Gi ± 0%    8.718Gi ± 0%    +2.69% (p=0.000 n=10)
MemclrKnownSize256-4             8.762Gi ± 0%    8.763Gi ± 0%         ~ (p=0.494 n=10)
MemclrKnownSize512-4             9.514Gi ± 1%    9.514Gi ± 0%         ~ (p=0.529 n=10)
MemclrKnownSize1024-4            9.940Gi ± 0%    9.939Gi ± 1%         ~ (p=0.989 n=10)

再次祭出SSA debuger,发现,原来runtime的memclr都跑去调用memNoPtrClr了……那自然不会用咱们这个LoweredZeroOp。

然后改用了基于SSA Zero的ClearFat,才有下面的测试结果(删掉了部分不重要的)

ClearFat3-4                   1.300Gi ± 0%    1.301Gi ±  0%         ~ (p=0.447 n=10)
ClearFat4-4                   3.902Gi ± 0%    3.902Gi ±  0%         ~ (p=0.971 n=10)
……
ClearFat16-4                  1.600Gi ± 0%    5.202Gi ±  0%  +225.10% (p=0.000 n=10)
ClearFat18-4                  1.018Gi ± 0%    1.300Gi ±  0%   +27.77% (p=0.000 n=10)
ClearFat20-4                  2.601Gi ± 0%    4.938Gi ± 12%   +89.87% (p=0.000 n=10)
ClearFat24-4                  2.601Gi ± 0%    5.201Gi ±  0%   +99.96% (p=0.000 n=10)
ClearFat32-4                  1.982Gi ± 0%    5.203Gi ±  0%  +162.55% (p=0.000 n=10)
ClearFat56-4                  3.640Gi ± 0%    5.201Gi ±  0%   +42.88% (p=0.000 n=10)
ClearFat64-4                  2.250Gi ± 0%    5.202Gi ±  0%  +131.25% (p=0.000 n=10)
……
geomean                       2.005Gi         3.020Gi         +50.58%

可以看到原来的小byte的实现不变(没改,当然没变),到了16 bytes 就突然涨起来,我看了下原版的是有优化的,理论上应该不变啊

(Zero [16] {t} ptr mem) && t.Alignment()%8 == 0 =>
    (MOVDstore [8] ptr (MOVDconst [0])
        (MOVDstore ptr (MOVDconst [0]) mem))

原来ClearFat的type alignment是 uint32 (也就是4 bytes),所以没有优化,就落到了循环实现(连TM duff也要求 8 bytes alignment)

func BenchmarkClearFat16(b *testing.B) {
        p := new([16 / 4]uint32)        
        Escape(p)                       
        b.ResetTimer()                  
        for i := 0; i < b.N; i++ {      
                *p = [16 / 4]uint32{}   
        }                               
}                                       

既然如此,那我就白捡个性能优化225%吧 :)

发表回复

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

这个站点使用 Akismet 来减少垃圾评论。了解你的评论数据如何被处理