前言
在go语言中,由builtin.go文件生成内置函数的文档,但具体实现却找不到,这实际上是因为在编译时,编译器会将这些内置函数视为一整个关键字,根据入参等不同情况和规则,映射为对应的内部函数,而不是如正常的函数调用一样寻找函数体。
对于这些内置函数,它们的实现源码在哪?它们具体是怎样实现的?我们从copy开始分析,它将数据从一个切片复制到另一个切片。
映射的过程发生在sdk中的
cmd/compile
文件夹下,本文不详述,感兴趣的话:
参数检查发生在:cmd/compile/internal/typecheck/func.go
映射到内部函数发生在:cmd/compile/internal/walk/builtin.go
源码分析
除复制1个字节的情况外,copy函数最终都会转换为memmove调用,该函数使用asm汇编实现,此处以amd64版本的memmove实现为例:
ps:因为是汇编所以会讲得很细
进入函数后第一步,整理参数,AX、BX寄存器负责保存临时数据,SI、DI寄存器负责保存第1、第2参数
//PATH:runtime/memmove_amd64.s
TEXT runtime·memmove<ABIInternal>(SB), NOSPLIT, $0-24
// AX = to
// BX = from
// CX = n
MOVQ AX, DI
MOVQ BX, SI
MOVQ CX, BX
参数整理完后,以需要拷贝的数据长度为指标,可以划分为多种情况采取不同的处理手段来提速。
小尺寸方案
对于小于256字节的小内存操作有两种方案:
1.使用rep,循环每次从源地址移动1个字节到目标地址,rep的启动成本很高
2.线性代码,针对每一种情况使用提前手动对齐的线性代码处理
此处使用的是方案2:
tail:
TESTQ BX, BX
JEQ move_0 //相等则跳转
CMPQ BX, $2
JBE move_1or2 //小于等于则跳转
CMPQ BX, $4
JB move_3 //小于则跳转
JBE move_4
CMPQ BX, $8
JB move_5through7
JE move_8 //等于则跳转
CMPQ BX, $16
JBE move_9through16
CMPQ BX, $32
JBE move_17through32
CMPQ BX, $64
JBE move_33through64
CMPQ BX, $128
JBE move_65through128
CMPQ BX, $256
JBE move_129through256
用以下代码举例,当拷贝量为9时,使用move_9through16
复制长度9~16字节的情况,
第一步与第三步将地址SI开始的数据移动8字节到目标地址
第二步与第四步将SI+1地址开始的数据移动8字节到目标地址
刚好9个字节全部移动完毕,其中第2~8字节被覆盖一次
//BX:n (复制量)
//SI:from
//DI:to
move_9through16:
MOVQ (SI), AX // MOVQ 加载 SI 地址下的 8 字节数据到 AX 寄存器中
MOVQ -8(SI)(BX*1), CX // 从(SI) + (BX*1) - 8地址加载 8 字节数据到 CX 中
MOVQ AX, (DI) // 数据传递方向:(SI) -> AX -> (DI)
MOVQ CX, -8(DI)(BX*1) // 数据传递方向:(SI) + (BX*1) - 8 -> CX -> (DI) + (BX*1) - 8
RET
大尺寸方案
当拷贝量大于256时,三种方案,如果支持avx指令集加速则优先使用,如果不支持,则对于小于2k的数据使用线性代码对齐,与上文move_9through16
类似,大于2k的数据使用rep每次搬运一个字节。
判断avx指令集支持:
TESTB $1, runtime·useAVXmemmove(SB)
JNZ avxUnaligned // 不是 0 则跳转
若不支持,下一步判断源地址与目标地址的相对位置,即正向复制还是反向复制
问:为什么要划分正向复制和反向复制?
答:
考虑from与to部分重叠的情况
若from > to,使用正向复制,此时重叠的影响可以忽略,当数据需要覆盖到重叠部分时,源数据已被拷贝过,不产生干扰。
若from ≤ to,若使用正向复制会破坏未被拷贝过的数据,例如从地址1-3复制到地址2-4,从地址1拷贝到地址2时会破坏未被拷贝过的地址2的数据,因此使用反向复制,即 3->4,2->3,1->2 的复制顺序。
CMPQ SI, DI
JLS back // 小于等于则跳转反向复制
大尺寸且不支持SIMD
正向复制情况且不支持 SIMD 情况下,小于 2k 拷贝量时跳转线性对齐方案,大于 2k 拷贝量时,如果不支持ERMS(enhanced REP MOVSB/STOSB)
,或源地址与目的地址相对8字节对齐,那就直接使用fwdBy8
也就是每 8 字节一轮复制
forward:
CMPQ BX, $2048
JLS move_256through2048
CMPB internal∕cpu·X86+const_offsetX86HasERMS(SB), $1 //是否支持 ERMS
JNE fwdBy8 //不相等则跳转
MOVL SI, AX
ORL DI, AX
TESTL $7, AX//检查 SI 和 DI 的低 3 位合并后的信息,判断是否对齐为 8 的倍数
JEQ fwdBy8 //相等则跳转
MOVQ BX, CX
REP; MOVSB//循环执行 CX 次,CX = BX = 拷贝量
RET
fwdBy8:
MOVQ BX, CX//用 CX 保存拷贝量
SHRQ $3, CX//每一轮拷贝 8 字节,拷贝量/8 = 拷贝轮次
ANDQ $7, BX//BX的不足一轮的部分不处理,JMP tail后再拷贝一次
REP; MOVSQ
JMP tail
反向复制的情况,当源地址+拷贝量≤目标地址
时,拷贝过程不破坏源数据,直接使用正向复制处理,否则倒转操作方向,可以转换情况为类似正向复制的情况。
back:
MOVQ SI, CX // SI 为源地址
ADDQ BX, CX // BX 为拷贝量, CX = SI + BX
CMPQ CX, DI // 源地址 + 拷贝量 ≤ 目标地址
JLS forward // 条件跳转,小于等于则跳转
ADDQ BX, DI
ADDQ BX, SI // from 和 to 分别增加 n ,将正序的终点调整为倒序的起点
STD // 设置方向标志寄存器,表示字符串操作应该向低地址方向进行
MOVQ BX, CX // 在 CX 计数寄存器中保存拷贝量
SHRQ $3, CX // 每一轮拷贝 8 字节,拷贝量 / 8 = 拷贝轮次
ANDQ $7, BX // 不足一轮的部分此处不处理
SUBQ $8, DI // DI和SI减8,每8字节一组处理,对齐第一组的起点
SUBQ $8, SI
REP; MOVSQ // 每8字节一组循环拷贝,完成后 DI 和 SI 从倒序的起点指向倒序的终点
CLD
ADDQ $8, DI // DI和SI加8,返回正确的终点(抵消上文的SUBQ)
ADDQ $8, SI
SUBQ BX, DI // DI和SI减去拷贝量,回到正确的起点,注意此处是倒序的起点,对正序来说是终点
SUBQ BX, SI
JMP tail
大尺寸且支持SIMD
上文有提到,如果支持SIMD加速,那么GO优先使用该方法复制大于256字节的数据。为此执行逻辑会跳转到avxUnaligned
标签,同样是正向复制和反向复制两种方式来处理重叠区域。首先判断重叠和判断拷贝量,跳转到不同情况:
avxUnaligned:
MOVQ DI, CX
SUBQ SI, CX //CX = DI - SI 即源地址与目的地址的距离
//逻辑上若 距离 < 拷贝量 ,说明发生重叠
CMPQ CX, BX
JC copy_backward //若无符号整数 CX 小于 BX,即产生了进位,发生跳转
//对拷贝量大于$0x100000的情况跳转
CMPQ BX, $0x100000
JAE gobble_big_data_fwd
若两种情况都不满足的情况,原文件注释中标明了算法方案,划分不同区域的关键因素在于"未对齐"对于拷贝来说会严重影响性能,以下是算法原文:
// Memory layout on the source side
// SI CX
// |<---------BX before correction--------->|
// | |<--BX corrected-->| |
// | | |<--- AX --->|
// |<-R11->| |<-128 bytes->|
// +----------------------------------------+
// | Head | Body | Tail |
// +-------+------------------+-------------+
// ^ ^ ^
// | | |
// Save head into Y4 Save tail into X5..X12
// |
// SI+R11, where R11 = ((DI & -32) + 32) - DI
// Algorithm:
// 1. Unaligned save of the tail's 128 bytes
// 2. Unaligned save of the head's 32 bytes
// 3. Destination-aligned copying of body (128 bytes per iteration)
// 4. Put head on the new place
// 5. Put the tail on the new place
// It can be important to satisfy processor's pipeline requirements for
// small sizes as the cost of unaligned memory region copying is
// comparable with the cost of main loop. So code is slightly messed there.
// There is more clean implementation of that algorithm for bigger sizes
// where the cost of unaligned part copying is negligible.
// You can see it after gobble_big_data_fwd label.
与算法对应的具体做法:
LEAQ (SI)(BX*1), CX // CX= (SI)+(BX*1)
MOVQ DI, R10 // DI 保存在R10中
//下面是保存tail的操作,使用MOVOU每次移动 16 个字节,总计移动 128 字节,8 次
MOVOU -0x80(CX), X5
MOVOU -0x70(CX), X6 // (CX-0x80) - (CX-0x70) = 0x10 = 16 ,与上一行指令的位置接壤而不重叠
MOVQ $0x80, AX // AX = 128
// Align destination address
ANDQ $-32, DI // 低 5 位设置为 0,使得 DI 是 32 的倍数
ADDQ $32, DI // 由于前一步进行了按位与操作,这一步将 DI 增加到下一个32的倍数
// 继续保存 tail,在 MOVOU 之间穿插别的指令应该是为了保证性能最大化利用
MOVOU -0x60(CX), X7
MOVOU -0x50(CX), X8
// 注意前文提前将对齐前 DI 值保存在 R10 中了,下面的 DI 是对齐后的值
MOVQ DI, R11
SUBQ R10, R11 // R11 = 对齐后DI - 对齐前DI ,表示对齐地址和非对齐地址之间的偏移量
// 继续保存 tail
MOVOU -0x40(CX), X9
MOVOU -0x30(CX), X10
SUBQ R11, BX // BX = BX - R11,对应上述的算法中 BX corrected 的前半部分调整
// 继续保存 tail
MOVOU -0x20(CX), X11
MOVOU -0x10(CX), X12
VMOVDQU (SI), Y4 //VMOVDQU 用于在矢量寄存器中加载未对齐的数据
ADDQ R11, SI //源地址指向去掉了 head 部分的新起点,即 body 的首位
SUBQ AX, BX // BX = BX - AX,对应上述的算法中 BX corrected 的后半部分调整,此处的 AX 在上文的代码中被赋了立即数 0x80 ,也就是 128
gobble_128_loop:
VMOVDQU (SI), Y0 // 每次 VMOVDQU 加载 32 字节的未对齐数据
VMOVDQU 0x20(SI), Y1 // 地址偏移 0x20 ,即 32 字节,与上一行的加载位置相邻
VMOVDQU 0x40(SI), Y2
VMOVDQU 0x60(SI), Y3
ADDQ AX, SI //一轮结束,调整 SI = SI + 0x80 = SI + 128
VMOVDQA Y0, (DI) // DI 在上文中已通过 ANDQ 和 ADDQ 对齐,使用 VMOVDQA 加载已对齐数据更高效
VMOVDQA Y1, 0x20(DI)
VMOVDQA Y2, 0x40(DI)
VMOVDQA Y3, 0x60(DI)
ADDQ AX, DI // 一轮结束,调整 DI = DI + 0x80 = DI + 128
SUBQ AX, BX // 待拷贝数据量减少 128
JA gobble_128_loop // BX 大于 0 则跳转
// 组装 head + body + tail
ADDQ AX, BX // 循环完成后,现在的 BX 初始值为0,所以 BX = 0 + AX = 128 此处的 AX 代表 tail 的长度
ADDQ DI, BX // DI 指向 body的末尾, 此时 BX 指向 body + tail 的末尾
VMOVDQU Y4, (R10) // 内存地址(R10)处保存的是拷贝开始前 DI 的地址,此处意味着 head 的起点
VZEROUPPER // XMM寄存器是 128 位的,YMM是256位的,清理高 128 位提升性能
MOVOU X5, -0x80(BX) // 将上文中保存在 XMM 寄存器中的 tail 组装回来
MOVOU X6, -0x70(BX)
MOVOU X7, -0x60(BX)
MOVOU X8, -0x50(BX)
MOVOU X9, -0x40(BX)
MOVOU X10, -0x30(BX)
MOVOU X11, -0x20(BX)
MOVOU X12, -0x10(BX)
RET
问:为什么有 head 和 tail 两个未对齐的部分,当经过 head 修正后,后续的地址不是都已对齐了吗,为什么还需要 tail ?
答:
head 的目的是修正对齐产生的偏移量,而 tail 是因为如果只有 body 无法保证最后一次循环刚好可以复制 128 字节数据。
从代码角度来说gobble_128_loop
的循环可以看作for BX;BX<=0;BX=BX-128{}
,但 BX 不一定是 128 的倍数,当BX%128!=0
时, body 和 tail 存在部分重叠,处理 body 末尾不足128字节的余数部分。
对于拷贝量高于0x100000
的情况,使用gobble_big_data_fwd
标签的代码实现:
gobble_big_data_fwd:
LEAQ (SI)(BX*1), CX // CX = (SI) + BX 注意 (SI) 是地址,BX 是拷贝量,此时 CX 指向源地址的末尾
MOVOU -0x80(SI)(BX*1), X5 // 将 -0x80 + (SI) + BX*1 处的值加载到 X5 中
MOVOU -0x70(CX), X6 // 由于CX = (SI) + BX,所以此处等于将 -0x70 + (SI) + BX*1 加载到 X6 中
MOVOU -0x60(CX), X7 // MOVOU 实际为 MOVDQU ,相比 VMOVDQU ,它仅支持处理 128 bit 数据
MOVOU -0x50(CX), X8
MOVOU -0x40(CX), X9
MOVOU -0x30(CX), X10
MOVOU -0x20(CX), X11
MOVOU -0x10(CX), X12
VMOVDQU (SI), Y4
MOVQ DI, R8 // DI 初始值保存在 R8 中
ANDQ $-32, DI
ADDQ $32, DI // DI 与 32 对齐
MOVQ DI, R10 // 新 DI 值保存在 R10 中
SUBQ R8, R10 // R10 = R10 - R8,即对齐的偏移量
SUBQ R10, BX // BX = BX - R10,依然对应上述算法中 BX corrected 的前半部分调整
ADDQ R10, SI // 源地址指向去掉了 head 部分的新起点,即 body 的首位
LEAQ (DI)(BX*1), CX // CX = (DI) + BX*1, 指向目标地址的末尾,DI 的值经过对齐操作会增加 R10 ,BI 的值在上数2行处减少 R10,因此相互抵消刚好指向原目标地址的末尾
SUBQ $0x80, BX // BI 赋立即数 0x80,代表 128 字节,即 tail 的长度
gobble_mem_fwd_loop:
PREFETCHNTA 0x1C0(SI)
PREFETCHNTA 0x280(SI) // 根据原文件注释,预取值是根据经验选择的魔法数,可以参考论文:
// 64-ia-32-architectures-optimization-manual.pdf
// https://www.intel.com/content/dam/www/public/us/en/documents/manuals/64-ia-32-architectures-optimization-manual.pdf
VMOVDQU (SI), Y0 // 每一轮复制 256*4 位数据,VMOVDQU 使未对齐数据加载到寄存器中
VMOVDQU 0x20(SI), Y1
VMOVDQU 0x40(SI), Y2
VMOVDQU 0x60(SI), Y3
ADDQ $0x80, SI // 载入数据完成,源地址增加0x80,即 128 字节
VMOVNTDQ Y0, (DI) // 写透式写入,相比 VMOVDQA 更快
VMOVNTDQ Y1, 0x20(DI)
VMOVNTDQ Y2, 0x40(DI)
VMOVNTDQ Y3, 0x60(DI)
ADDQ $0x80, DI
SUBQ $0x80, BX
JA gobble_mem_fwd_loop
SFENCE // SFENCE 保证以它为分界线,分界线之前的存储操作比之后的存储操作先完成
VMOVDQU Y4, (R8) // DI 的初始值保存在 R8 中,Y4 保存 head 部分
VZEROUPPER // XMM寄存器是 128 位的,YMM是256位的,清理高 128 位提升性能
MOVOU X5, -0x80(CX) // 组装 tail
MOVOU X6, -0x70(CX)
MOVOU X7, -0x60(CX)
MOVOU X8, -0x50(CX)
MOVOU X9, -0x40(CX)
MOVOU X10, -0x30(CX)
MOVOU X11, -0x20(CX)
MOVOU X12, -0x10(CX)
RET
到此为止,正向复制全部讲完,最后是反向复制的 SIMD 部分, 与正向复制大致相同,不再赘述。
总结
根据上面的源码解析,其实可以很自然的回答一个问题:copy为什么快?
1. 小内存使用线性代码对齐,解除rep循环
2. PREFETCHNTA 缓存预取,提前加载数据
3. 大内存优先使用 SIMD 指令集加速,性能优于普通指令
3.1 一轮读写多个矢量,SIMD 并行读 + 并行写
3.2 合理的对齐方式,对齐指令性能 > 未对齐指令性能
3.3 避免了缓存污染,写透式写入性能 > 写回式写入性能