mzh/blog

Go汇编学习 2.解构AMD64 bytes.Equal

SIMD

上一篇学了内存结构基本知识,本文将学习符号(symbol)、语句的含义。 我个人喜欢通过例子来学习,所以,我就从src/runtime/asm_amd64.s里的bytes·Equal入手吧:)

对应代码

TEXT bytes·Equal(SB),NOSPLIT,$0-49
	MOVQ	a_len+8(FP), BX
	MOVQ	b_len+32(FP), CX
	CMPQ	BX, CX
	JNE	eqret
	MOVQ	a+0(FP), SI
	MOVQ	b+24(FP), DI
	LEAQ	ret+48(FP), AX
	JMP	runtime·memeqbody(SB)
eqret:
	MOVB	$0, ret+48(FP)
	RET

预备知识

SB (static base)相关知识

以下是Go asm中的介绍

The SB pseudo-register can be thought of as the origin of memory, so the symbol foo(SB) is the name foo as an address in memory. This form is used to name global functions and data. Adding <>to the name, as in foo<>(SB), makes the name visible only in the current source file, like a top-level static declaration in a C file. Adding an offset to the name refers to that offset from the symbol’s address, so foo+4(SB) is four bytes past the start of foo.

大致翻译一下,例如foo(SB)的符号,就对应了code segement中的地址,全局可见。当添加了 <> 符号后,就变为了当前文件可见,类似于C文件的static声明,还可以通过添加偏移量(offset)来访问其他地址。

指令格式

例子中的TEXT指令就定义了一个叫bytes·Equal的符号(注意是中点号·),接下来就是对应的指令(可以理解成函数体),而最后RET则是返回指令(退出当前stack)。通常情况下,参数大小后跟随着stack frame的大小,使用减号(-)分割。$0-49意味着这是一个0-byte的栈,并且有49-byte长的参数。NOSPLIT说明,不允许调度器调整stack frame的大小,这就意味着必须人工指定stack frame大小。 但为什么是49个byte?

因为我们可以看看bytes.Equal的定义

func Equal(a, b []byte) bool

a, b 分别为[]byte(不定长的byte slice),而slice的结构是:

type slice struct {
    array unsafe.Pointer
    len   int
    lcap  int
}

unsafe.Pointer 在amd64上是uintptr,即uint64。int在amd64上背后是int64。 因此一个slice占用了3个qword(word=2byte qual=4 即 2x4=8byte 8x8=64bit),即 3x8 = 24byte,然后又有两个slice做为参数,再加上一个bool byte,因此,这个call stack frame应该有 24x2 ([]byte) + 1 (bool) = 49byte。又因为不需要局部变量,因此定义为0个.

$0-49

函数指令解构

汇编是门“死脑筋”+“疯狂简写”的语言,接下来是对函数语句的解析,一旦理解了以后,语句是很简单的。

	MOVQ	a_len+8(FP), BX  	// move qword, 把a slice的长度放入BX寄存器
	MOVQ	b_len+32(FP), CX 	// 把b slice的长度放入CX寄存器
	CMPQ	BX, CX 		 	// compare qword, 对比BX,CX
	JNE	eqret            	// jump not equal, 如果不相等就跳转至标签eqret(equal ret)
	MOVQ	a+0(FP), SI 		// 把a的指针放入SI寄存器中
	MOVQ	b+24(FP), DI 		// 把b的指针放入DI寄存器中
	LEAQ	ret+48(FP), AX 		// load effective address, 将返回值的内存地址放入AX寄存器中
	JMP	runtime·memeqbody(SB) 	// JUMP, 跳转至 runtime·memeqbody(SB) 地址空间
eqret:
	MOVB	$0, ret+48(FP)          // move byte, 将$0 (意思是数字0, 而false = 0)传入返回的参数中,即两个slice不相等。
	RET

这里出现了两个新的概念:

偏移量定义,例如a_len+8(FP),还记得上一篇中讲过,FP是指在低内存位上的么?因此,这里就定义了a_len,即a length = +8(FP),相对于FP偏移8个byte(记得slice的结构吧),这个正好是a的长度所在的位置。不记得的话,可以参考下图


FP               +------------> b pointer
                 |
+                |    +-------> b length
|                |    |
v                |    |     +-> b capacity
Low              |    |     |
+----+----+----+-+--+-+--+--+-+-+
|    |    |    |    |    |    | |
+-+--+-+--+-+--+----+----+----+++
  |    |    |                  |
  |    |    +-> a capacity     +-> return value
  |    |
  |    +------> a length
  |
  +-----------> a uint64-pointer


每个空格相当于一个byte

另一个概念是label,汇编不同于高级语言,汇编的条件跳转基本上都是靠label(标签)实现的,例子中的eqret,就是个label。

AVX

接下来是精华hugeloop

AVX是Intel引以为傲的SIMD指令集,具体介绍在AVX,Go在字符比较中根据CPU的能力分别会使用SSE、AVX、AVX2,这种指令集优化就是我们为什么要写汇编的原因了。


// 64 bytes at a time using ymm registers (一次就能对比64个byte,64倍性能就问你怕不怕)
hugeloop_avx2:
	CMPQ	BX, $64 	// 对比字符长度
	JB	bigloop_avx2 	// 不够64个字节就用其他方法。
	VMOVDQU	(SI), Y0	// AVX2 专用加载数据的指令,将SI前32个byte加载进Y0寄存器(512bit)
	VMOVDQU	(DI), Y1	// ...
	VMOVDQU	32(SI), Y2
	VMOVDQU	32(DI), Y3
	VPCMPEQB	Y1, Y0, Y4  // 对比Y0 - Y1,把结果存入Y4中
	VPCMPEQB	Y2, Y3, Y5  // 同上
	VPAND	Y4, Y5, Y6	// AND 操作
	VPMOVMSKB Y6, DX	// MOVE BYTE MASK , 将Y6中的每8个bit做一个掩码存入DX中(简单点就是相同就都是0xf)
	ADDQ	$64, SI		// SI位移64个byte
	ADDQ	$64, DI		// DI位移64个byte
	SUBQ	$64, BX		// BX 长度减64
	CMPL	DX, $0xffffffff	// 对比DX的低位
	JEQ	hugeloop_avx2	// 相同则继续对比
	VZEROUPPER		// 清空Y寄存器
	MOVB	$0, (AX)	// 发现不同,返回
	RET

小结

现在对Golang的汇编比较熟悉了,下一篇会摘抄并翻译一些注意事项。