go语言内置函数逐字解析1-copy-揭秘高效复制的秘密

前言

    在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 避免了缓存污染,写透式写入性能 > 写回式写入性能

  • 27
    点赞
  • 20
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值