Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

第 84 期图解 Go 之内存对齐 #588

Closed
NewbMiao opened this issue Mar 26, 2020 · 15 comments · Fixed by #609
Closed

第 84 期图解 Go 之内存对齐 #588

NewbMiao opened this issue Mar 26, 2020 · 15 comments · Fixed by #609
Assignees
Labels
Go 夜读 Go 夜读:主题分享 已分享 ✅ Go 夜读的分享状态:分享已完成。 源码阅读 Go 夜读分享主题:有关 Go 的源码阅读,源码分析

Comments

@NewbMiao
Copy link

NewbMiao commented Mar 26, 2020

【Go 夜读】 图解Go之内存对齐

关于内存对齐总会有各种声音?为什么要对齐,怎么对齐,不对齐有什么影响么?

这些声音可以离我们很远,也可以很近,比如:

  • 当你想弄明白WaitGroup.state1为什么是[3]uint32而不是[12]byte
  • 当你想知道struct是否占用内存是否合理
  • 当你不希望在32位系统上原子操作int64|uint64时发生panic
  • 当你闲着没事就是想读读源码提升下逼格。。。

本次分享借自己研究内存对齐的一些代码及源码示例,为大家带来Go里边的内存对齐是什么样的,以及如何利用内存对齐优化数据结构,提高代码的平台兼容性。

大纲

  • 了解内存对齐的收益
  • 为什么要对齐
  • 怎么对齐:
    • 数据结构对齐
    • 内存地址对齐
  • 64位字的安全访问保证(32位平台)

分享者自我介绍

苗蕾,Thoughtworks,搬砖工。

分享时间

2020-04-02 21:00 UTC+8

分享地址

https://zoom.us/j/6923842137

Slides

google-doc
or
blog-pdf

参考资料


备注

在分享结束后提供。

@changkun changkun changed the title 图解Go之内存对齐 第84期 图解 Go 之内存对齐 Mar 26, 2020
@changkun changkun added Go 夜读 Go 夜读:主题分享 已排期 🗓️ Go 夜读的分享状态:已确定分享时间。 源码阅读 Go 夜读分享主题:有关 Go 的源码阅读,源码分析 labels Mar 26, 2020
@yangwenmai yangwenmai changed the title 第84期 图解 Go 之内存对齐 第 84 期图解 Go 之内存对齐 Mar 27, 2020
@yangwenmai yangwenmai added 已分享 ✅ Go 夜读的分享状态:分享已完成。 and removed 已排期 🗓️ Go 夜读的分享状态:已确定分享时间。 labels Apr 2, 2020
@crab21
Copy link

crab21 commented Apr 2, 2020

内存对齐,前段时间才被坑过......来详细学习了,☺️

@changkun
Copy link
Member

changkun commented Apr 2, 2020

非常感谢 @NewbMiao 的精彩分享,分享中的内容也是 Go 语言里非常有代表性内存对齐问题,而且内存对齐本身是一个非常重要但鲜有提及的话题。

为此,我希望就分享中内存对齐与否产生的性能差异(第 6-7 页)的内容做进一步的讨论。

首先,分享中提到的性能基准测试及其结果并不是有效的,我们不妨选取性能基准测试中存在几个需要指出的问题。

问题1:在 unsafe 包中说明了 unsafe.Pointer 和 uintptr 之间的转换关系。其中分享中所涉及例子的这几行:

address := uintptr(unsafe.Pointer(&x.b)) + 1
if address%ptrSize == 0 {
	b.Error("Not unaligned address")
}
tmp := (*int64)(unsafe.Pointer(address))

首先违反了转换规则 2: "Conversion of a uintptr back to Pointer is not valid in general.",换句话说,address 这一中间值是无效的,在回转时内存可能已发生移动。虽然我们可能并不关心实际测量中指针操作读写操作是否有效,也知道执行栈不可能发生分裂更不会移动,但展示的时候也并未对这些情况进行说明,同时至少从语言规范层面这也一个无效的程序。

问题2:测量样本数较少。在实际展示的结果是基于 3 次测量样本 -count=3

name			time/op
UnAligned-6		1.87ns ± 5%
Aligned-6		1.47ns ± 2%

可以推算出结果的最坏情况:1.87 * (1-0.05) = 1.78ns,1.47 * (1+0.05) = 1.54ns (取已经观测到的最大误差),最低的提升比例为 (1.78-1.54)/1.78 = 13% 与实际存在的误差比例 5% 相比,结果似乎不那么乐观。

问题3:测试环境不可靠。在给出的容器环境下,perflock 是无法锁定操作系统的 CPU 频率,实际结果仍然受 hyperkit 的影响。

$ docker run -it --rm golang:1.14-alpine cat /sys/devices/system/cpu/cpu0/cpufreq/scaling_max_freq
cat: can't open '/sys/devices/system/cpu/cpu0/cpufreq/scaling_max_freq': No such file or directory

另外,即便是 Linux 下的 cgroup 也仍然不能对计算资源做一个相对稳定的限制。

那么最终这个例子在一台独立的硬件上、简单消除系统噪音后、增加测量样本数后、关闭编译器优化、关闭 GC 、禁用调度器的影响后,一个初步的性能测试的结果显示他们没有区别

$ GOGC=off GODEBUG=asyncpreemptoff=1 perflock -governor=70% go test -gcflags="-N -l" -run=none -bench=. -count=20 -cpu=1 | tee b.txt
goos: linux
goarch: amd64
BenchmarkUnAligned      529821854                2.26 ns/op
BenchmarkUnAligned      529918800                2.26 ns/op
BenchmarkUnAligned      529877607                2.26 ns/op
BenchmarkUnAligned      529917625                2.26 ns/op
BenchmarkUnAligned      529890981                2.26 ns/op
BenchmarkUnAligned      529885317                2.26 ns/op
BenchmarkUnAligned      529922422                2.26 ns/op
BenchmarkUnAligned      529852632                2.26 ns/op
BenchmarkUnAligned      529926142                2.26 ns/op
BenchmarkUnAligned      529918605                2.27 ns/op
BenchmarkUnAligned      529911817                2.26 ns/op
BenchmarkUnAligned      529887111                2.26 ns/op
BenchmarkUnAligned      529832143                2.26 ns/op
BenchmarkUnAligned      529929664                2.26 ns/op
BenchmarkUnAligned      529842897                2.26 ns/op
BenchmarkUnAligned      529927504                2.26 ns/op
BenchmarkUnAligned      529901169                2.26 ns/op
BenchmarkUnAligned      529930164                2.26 ns/op
BenchmarkUnAligned      529921884                2.26 ns/op
BenchmarkUnAligned      529887153                2.26 ns/op
BenchmarkAligned        529899408                2.26 ns/op
BenchmarkAligned        529910632                2.26 ns/op
BenchmarkAligned        529933194                2.26 ns/op
BenchmarkAligned        529897543                2.26 ns/op
BenchmarkAligned        529922790                2.26 ns/op
BenchmarkAligned        529904359                2.26 ns/op
BenchmarkAligned        529848472                2.26 ns/op
BenchmarkAligned        529909950                2.26 ns/op
BenchmarkAligned        529904179                2.26 ns/op
BenchmarkAligned        529905480                2.26 ns/op
BenchmarkAligned        529927861                2.26 ns/op
BenchmarkAligned        529877383                2.26 ns/op
BenchmarkAligned        529918630                2.26 ns/op
BenchmarkAligned        529917440                2.26 ns/op
BenchmarkAligned        529899174                2.26 ns/op
BenchmarkAligned        529925733                2.26 ns/op
BenchmarkAligned        529929440                2.26 ns/op
BenchmarkAligned        529901206                2.26 ns/op
BenchmarkAligned        529891384                2.26 ns/op
BenchmarkAligned        529874991                2.26 ns/op
PASS
ok      _/home/changkun/dev/tests/align     57.182s
$ benchstat b.txt
name       time/op
UnAligned  2.26ns ± 0%
Aligned    2.26ns ± 0%

其次,我们必须明确,对齐与非对齐字段的操作速度差异的基准测试,究竟在度量什么指标,我们是在测量 CPU 执行了多个指令周期吗?我们是在测量 CPU 访问内存的延迟吗?等等。

编写这个性能基准测试并不是一件容易的事情,可以说它比几乎能见到的所有性能基准测试都要难且苛刻。因为它需要彻头彻尾的考虑从底层硬件到语言自身中的所有因素:

  • 性能基准测试考虑 CPU 自身优化了吗?性能基准测试考虑缓存的影响了吗?

    • Intel 处理器的性能优化手册中描述了大量的执行优化技术,包括对大量优化操作。例如,在 CPU 的写转发技术(当发生写操作时可直接将操作转发到下一个读操作,进而节约CPU的指令周期)。他们对示例代码中未对齐字段优化过了吗?
    • 示例代码所在的测试环境中,未对齐的内存操作是在操作同一个缓存行还是多根缓存行?我们到底是在测量 CPU 访问自身寄存器的速度?还是在测量 CPU 访问缓存的速度?还是还是在测量访问内存的速度?还是其他什么指标呢?
  • 性能基准测试考虑操作系统的影响了吗?性能基准测试考虑编译器优化了吗?考虑语言运行时的影响了吗?

    • 系统内核是否在性能测试执行过程中是否发起过硬中断?
    • 编译器优化是否将测试语句直接优化为空操作?
    • 语言运行时对用户态产生过软中断吗?

在我们比较对齐字段和非对齐字段访问速度的差异时,先回答这些问题,远比展示一个执行程序和执行时间的测量结果更加重要。

@NewbMiao
Copy link
Author

NewbMiao commented Apr 3, 2020

感谢欧神的详细分析,这个对齐字段和非对齐字段访问速度访问差异的压测确实很不严谨。

关于不合理的代码:uintptr->unsafe.pointer, 替换为直接索引数组元素更合理些

ptr := unsafe.Pointer(&x.b[9])
// equal to: unsafe.Pointer(uintptr(unsafe.Pointer(&x.b))+9)

另外欧神提到的cpu优化确实没有考虑到,还有好多地方是没有摸到的。

关于cacheLine缓存行影响的话 增加到64bytes (type SType struct {b [64]byte})可以避免,不过结果也是没有什么差异。(我理解,因该是没有并发操作数据让缓存行失效吧)

@changkun
Copy link
Member

changkun commented Apr 3, 2020

你说的没错。其实,讨论的目的只是尽可能的让基准测试的结果更加严谨,而不是其他。前面提到的问题也只是这个特殊的基准测试里面直观上需要考虑的问题,仍然还有许多影响因素需要考虑,仅做抛砖引玉。最重要的一点,也是前面反复提到过的,我们在比较对齐字段和非对齐字段访问速度时,这个「速度」究竟指的是什么,似乎始终没有被澄清。

关于这个基准测试在其他系统级语言中已经有比较严谨的做法,但是在 Go 中如何进行严谨的测量,甚至能否给出严谨的测量,我想这些问题这些对分享的听众都是非常有趣的,我也期待更加严谨的示例。

@wushuangxiaoyu
Copy link

想要问一个问题,求解惑:

如果是(N为偶数),那前8bytes就是64位对齐;否则(N为奇数),那后8bytes是64位对齐
这个是为啥,为啥N为偶数得时候,前8字节就是64位对齐

@NewbMiao
Copy link
Author

NewbMiao commented Apr 5, 2020

@wushuangxiaoyu google-doc 18页底下有描述:
首先在64位系统和32位系统上,uint32能保证是4bytes对齐, 即state1地址是4N: uintptr(unsafe.Pointer(&wg.state1))%4 == 0
而为保证8位对齐,我们只需要判断state1地址是否为8的倍数
如果是(N为偶数),那前8bytes就是64位对齐
否则(N为奇数),那后8bytes是64位对齐

@wushuangxiaoyu
Copy link

@NewbMiao
首先先感谢您的解答。
但是其实我就是这几句话没弄明白,可能是我表达的不清楚,抱歉:

对与x86-64,N byte的数据结构的起始地址必须是N的倍数,我是可以理解的,因为这是x86-64需要遵循的严格要求
但是对于IA32,我在《深入理解计算机系统》里只看到这样一句话:在硬件系统上,IA32不支持这种数据结构,必须要由编译器产生指令序列。所以,其实我想知道的是:
在32系统上,为什么对 int64 做64位对齐,能实现原子操作,或者说是怎么实现原子操作的?
反正我就是对这方面比较困惑,貌似有点偏离主题了。
另外,请问有没有这方面相关的资料或者文档可以分享一下么

@NewbMiao
Copy link
Author

NewbMiao commented Apr 5, 2020

@wushuangxiaoyu

对与x86-64,N byte的数据结构的起始地址必须是N的倍数

这个不是 “必须”,比如大于8bytes的数据对齐时,只能保证地址是8的倍数,即按64位系统的机器字大小(8bytes)
同理,32位系统上,int64只能保证地址是4bytes对齐

在32系统上,为什么对 int64 做64位对齐,能实现原子操作

这句话应该反过来,是为了能在32位系统上用atomic包处理64位字的原子操作,需要调用方保证该64位字是8bytes对齐,这是atomic包要求的:

On x86-32, the 64-bit functions use instructions unavailable before the Pentium MMX.
On non-Linux ARM, the 64-bit functions use instructions unavailable before the ARMv6k core.

**On ARM, x86-32, and 32-bit MIPS, it is the caller's responsibility to arrange for 64-bit alignment of 64-bit words accessed atomically**. The first word in a variable or in an allocated struct, array, or slice can be relied upon to be 64-bit aligned.

见:https://golang.org/src/sync/atomic/doc.go?s=1206:1668#L36

具体是下边指令需要:

TEXT runtimeinternalatomic·Xadd64(SB), NOSPLIT, $0-20
	// no XADDQ so use CMPXCHG8B loop
	MOVL	ptr+0(FP), BP
	TESTL	$7, BP
	JZ	2(PC)
	MOVL	0, AX // **crash when unaligned**

见:https://github.com/golang/go/blob/656f27ebf86e415c59de421643a35c98238b0ff5/src/runtime/internal/atomic/asm_386.s#L105

至于这个指令为什么需要,以及怎么实现我就不清楚了。

另外,Go其实一直想在32位平台也保证64位字是8bytes对齐,只不过一直没有实现。
见issue:golang/go#599

@wushuangxiaoyu
Copy link

@NewbMiao 了解了,蟹蟹解惑

@sjtuhyh
Copy link

sjtuhyh commented Nov 16, 2020

@NewbMiao 你好,想要问一个问题:64位系统T2比T1多出了8个字节,为什么会做这个padding?对这个0字节的数据做引用,T1.a不需要占用内存吗

@NewbMiao
Copy link
Author

@sjtuhyh 你说的是final zero field

因为,如果有指针指向这个final zero field, 返回的地址将在结构体之外(即指向了别的内存),
如果此指针一直存活不释放对应的内存,就会有内存泄露的问题(该内存不因结构体释放而释放)
所以,Go就对这种final zero field也做了填充,使对齐。

type T1 struct {
    a struct{}
    x int64
}

type T2 struct {
    x int64
    a struct{}
}

@hengli-coder
Copy link

hengli-coder commented Feb 13, 2021

@NewbMiao 你好,我有个问题想请教一下,关于final zero field的,针对结构体中包含空结构体,这种情况为什么没有指向zerobase

@jianghaodong-icel
Copy link

@NewbMiao hi,想问个问题,关于final zero field的,没有搞懂为什么空结构体不在最后一个就不需要做padding,如果同样的,有指针指向不处在最后一个的空结构体字段,且该指针一直存活不释放对应内存,就不会有内存泄漏的问题吗?

@NewbMiao
Copy link
Author

NewbMiao commented Jan 22, 2022

@hengli-coder 你好,是否指向zerobase取决于其整体大小是否为零,如果只是field大小为零就不会

写了个代码例子供你参考:
https://github.com/NewbMiao/Dig101-Go/blob/bfb9cc9377087c85c3e93956bd602ca536a5299d/types/struct/example_test.go#L5-L35

@NewbMiao
Copy link
Author

@jianghaodong-icel 空结构体不在最后一个field, 引用其不释放不被GC不就正常的么。
关于final zero field的讨论详见: golang/go#9401

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Go 夜读 Go 夜读:主题分享 已分享 ✅ Go 夜读的分享状态:分享已完成。 源码阅读 Go 夜读分享主题:有关 Go 的源码阅读,源码分析
Projects
None yet
Development

Successfully merging a pull request may close this issue.

8 participants