关于编程语言内存对齐的疑问

2022-08-14 18:40:36 +08:00
 gps32251070

最近对于编程语言为什么要进行内存对齐有些疑问,网上看的资料基本都是说为了 CPU 的效率,减少 CPU 访问内存的次数,但是总感觉这种说法很勉强,举例: 对于 64 位系统,CPU 按 8 字节取内存,那么假设有

struct s { a int16 b int64 }

对于这个结构体,我的理解是不管对不对齐 CPU 总是要取两次的,如果这个时候多了个 c 变量

struct s { a int16 b int64 c int16 }

如果不对齐的话,那么只需要 2+8+2=12 个字节空间,CPU 只需要取两次。如果是内存对齐,那么 需要 8+8+8=24 个字节空间,CPU 反而需要读三次,这么来看不对齐不但节省 CPU 时间,还节省内存空间,所以为什么要对齐呢?。。。难道 CPU 拼接变量很消耗时间?

3410 次点击
所在节点    程序员
23 条回复
LaTero
2022-08-14 18:52:09 +08:00
你下面这个结构有问题,要对齐的话是在 a 和 b 之间补 6 个字节,这样 b 在 8 个字节的整数位上。而补进去的 padding cpu 根本就不用,为什么会读三次?
补齐之后要读 b 只需要读一次,而你上面这个结构要读 b 的话,x86 架构会读两次再拼起来。而很多架构是没有这个功能的,直接 panic
across
2022-08-14 18:57:32 +08:00
因为你在两个不同场景下进行比较。一个数据量多一个数据量少。 要排布优化你自己不能调下顺序么···
Jooooooooo
2022-08-14 19:00:05 +08:00
你要不多搜搜资料

另外具体到第二个例子, int16 和 int16 会被放在一起的
gps32251070
2022-08-14 19:01:29 +08:00
@LaTero 我说的读三次是读完整个结构体的次数。至于拼接,难道是现在大部分 Cpu 都没有这些指令?如果是这样就可以说得过去了。
gps32251070
2022-08-14 19:05:36 +08:00
@Jooooooooo 我知道为了节省内存空间要放在一起,但是不放到一起会导致内存多用的原因是需要内存对齐,我只是对为什么需要对齐有疑问
charslee013
2022-08-14 19:08:03 +08:00
首先误解了对齐的操作对象了,操作对象是结构体里面的字段,而不是整个结构体

比如 struct s { a int16 b int64 c int16 } ,如果是 2+8+2 的方式,操作 b 字段需要两个内存操作
因为 b 分在两个 8 字节内存块中了
而对齐之后的 8+8+8 只需要一个内存操作就能操作任意一个字段

> 那么如果不对齐会怎么样?
不会怎么样,无论数据是否对齐在 x86-64 硬件都能正确工作
hsfzxjy
2022-08-14 19:08:05 +08:00
ryd994
2022-08-14 19:08:55 +08:00
x86 架构对于对齐的内存,写入读取都是原子的(但自增运算需要用专门的指令,另说)
其他一些架构甚至不支持非对齐内存的原子操作。
你搜一下 unaligned access 就可以搜到很多内容了。

“不管对不对齐 CPU 总是要取两次的”
不对齐的情况下可能需要三倍的开销,除非编译器优化。
访问 a 需要一次,访问 b 可能需要两倍的时间

"如果不对齐的话,那么只需要 2+8+2=12 个字节空间,CPU 只需要取两次"
同上。如果不对齐的话,结果是取 2 ,然后 6+2 ,再 2 。

struct 内部重排以减少不必要的 padding ,这是性能优化的基础技巧之一。
一般我们会把 64 位变量放前面,然后 32 位,然后 16 位。因为 64 位对齐一定同时也是 32 位和 16 位对齐,反之未必。

编译器不会对 struct 内的顺序进行重排,因为有些操作可能会默认各个变量之间的顺序。
ryd994
2022-08-14 19:12:36 +08:00
@charslee013 “首先误解了对齐的操作对象了,操作对象是结构体里面的字段,而不是整个结构体”
这一点上并没有错,struct 里的字段要对齐,整个 struct 的大小也需要对齐。因为创建 array 的时候,如果 struct 大小没有补足的话,那第二个元素就对不齐了。
你可以吧这个 struct 实际编译一下,看看 sizeof 是不是补足到 pack size 了。
across
2022-08-14 19:20:26 +08:00
想了下题主的问题应该是:
为什么和内存没对齐相比,cpu 处理对齐的速度要快一点?没对齐的会多出哪些操作?

因为这个和 cpu 总线、寄存器、内存结构有关,唔···这就是长篇了,现在不敢保证我细节都能说对。
因为总线、寄存器本身有个大小,假如寄存器 B 64 位,总线 64 位,那数据就是 64 位批量取的,cpu 就是这么个寻址方式(关于为什么这样寻址,就要写很长了), 空间没对齐,cpu 确实需要额外拼接,这个耗时间。
des
2022-08-14 19:37:15 +08:00
想象一下地砖的格子,CPU 一次性是取“一个格子”的数据,如果你的数据正好跨了两格,cpu 自然是需要操作多次,并且把数据拼接起来。
icyalala
2022-08-14 19:37:45 +08:00
Linux Kernel 里的文章:
https://github.com/torvalds/linux/blob/master/Documentation/core-api/unaligned-memory-access.rst

但实际来说,最新的一些 x86 处理器实际是支持未对齐内存访问的,而且也可以认为没有性能下降。
旧一些的 x86 处理器也支持,但是会有性能下降。当然指令还是那个指令。
其他的要看具体 arch 支持程度了,不支持的话甚至会出现 misaligned access 异常。
secondwtq
2022-08-14 19:44:22 +08:00
第一,进行内存对齐的一般是编程语言的*实现*,不是编程语言

然后,就 x86 来说,一般编程语言的实现取 a ,b ,c 的方法是 mov ax, [s]; mov rbx, [a+8]; mov cx, [a+16],按照你那种紧凑的布局无非就是变成了 mov ax, [s]; mov rbx, [a+2]; mov cx, [a+10],都是三次
也就是说一般根本不会先整个 word size 读过来再拼接,拼来拼去的做法在 SIMD 里倒是比较常见

就算按照楼主的说法,不对齐,先取,再拼,省了一个 load ,多了几个位运算,不一定划算
楼主可能认为 load 很 costly ,其实大多数 load 都还好,只有 cache miss 的 load 才 costly

现代 x86 实现里面,非对齐的访问一般是不会有性能损失的,但是仅限于在一个 cache line 里面,如果跨了 cache line 就相当于 CPU 要帮你自动做两次+拼接,要是跨了页就更好玩了。对于在 L1D$里的数据,在对齐的情况下,每次 load 的延迟和占用的资源基本都是确定且最小的,而如果出现了跨 cacheline 或跨页,就会出现有些 load 和对齐的没区别,有些 load 则非常慢的情况,平均下来是降低了性能的
这个在 GPR 操作上影响还算小的,如果涉及到 SIMD ,连续 load 一串数据,对于 XMM load ,四分之一会出现跨 cache line ,对于 YMM 是二分之一,对于 ZMM 是百分百 ...

有没有需要紧凑布局的情况呢?当然也有,就是真的需要“节省内存空间”的时候,比如大量并行+数据量大的情况下如果你的算法不能优化到 cache 里面,DRAM 喂不饱 CPU 很正常,这时需要尽量利用内存带宽,而 ALU 运算就基本无所谓了,不仅 padding 可以不用,bitfield 也可以用上
secondwtq
2022-08-14 20:12:24 +08:00
上面是 load ,不知道楼主打算怎么做 store 。现代 CPU 中单独的 store 指令比 load 更 cheap ,因为只需要往 store buffer 里面压一压,不会造成新的依赖。
按照拼的思路,你得做两次 load+两次 store ,本来一个 store 解决的事情,至于么 ...
直接存的话有和 load 一样的问题

另外根据 https://travisdowns.github.io/blog/2019/06/11/speed-limits.html#load-split-cache-lines ,在 Zen 系列上不仅跨 64 byte 边界的访问会影响性能,跨 32 byte 也有可能
hotyogurt
2022-08-14 20:41:24 +08:00
@des #11 你好,请教一下为何 CPU 取数据是一个个格子读的?也就是为什么读内存数据只能从对齐的地址开始?谢谢。
des
2022-08-14 21:20:18 +08:00
@hotyogurt 这和 CPU 的设计有关系,主要可以得到一些好处,可以自己看
https://stackoverflow.com/questions/3025125/cpu-and-data-alignment
jaynos
2022-08-14 21:27:22 +08:00
话说可以参考下这个视频: https://www.bilibili.com/video/BV1hv411x7we
root111
2022-08-14 21:34:55 +08:00
@secondwtq 你好,请教下,出现跨 cache line 或 page ,非对齐的 load 的开销具体在哪?
FrankHB
2022-08-14 23:51:25 +08:00
你对实现机制理解严重不足。
对齐的直接对象是处理器访存指令中的地址操作数。访存要对齐,根本原因不对齐的地址需要额外的计算而不划算。假定对齐的地址可以直接当做低几位是 0 。
对 CISC 处理器,硬件可能加更多电路以确保地址的每一位都有效(有时还得检查是否对齐引发异常),而假定对齐的地址访问直接就把低几位忽略了。
RISC 设计甚至就基本把不对齐访问给省了,ISA 层面上不支持不对齐访问,真不对齐可能就直接异常(并行的,原则上正常路径不耗时间)。如果你要强行非对齐访存,那么就得用粒度更小、延迟可能更大的特设访存指令,或者访存完再截取一段数据这种软件方式模拟,这些都是开销更大的,差一个数量级都正常。
这个意义下,同样一次逻辑意义上的访存,两者的开销本来就不保证一样大(就算同时支持对齐和不对齐访存,非对齐的访问可能更耗指令周期;虽然也设计有一样的,但一般至少不能反过来指望不对齐更快)。
FrankHB
2022-08-15 00:29:49 +08:00
@root111 总体原因是局域性。
即便只是核内的第一级缓存,cache 和执行访存的实现电路(比如 LSU )不是一个部件,要操作 cache 物理上必须发信号等待同步,确保满足 cache coherency 以保证之后 cache 的状态可预测。只要不是允许禁用缓存这个开销就无法避免,但后面几级缓存不确定性就大了,比如都 hit 就很快,反之要跟后一级缓存直至主存同步,相比就慢得多。
现在的级联 cache 设计的关联策略可以保证前级 cache 如果只操作同一个 cache line ,后级 cache 也可以在同一个 cache line (如果只是 load 都 hit 就可以不管后级 cache ),反过来难以保证。所以一旦跨 cache line ,脸不好就引发刷后级 cache 直至刷到 uncore 里的 LLC 甚至主存的最慢的路径,差距很大。另外,如果占用多个 cache line ,意味着其它数据能占用的 cache 就少了(也更容易刷出去),会全局地阻碍 cache 的加速作用。
跨 page 涉及到的东西就更多。page 是主存提供主要空间的地址空间里的结构,现代机器基本都是 MMU 实现的,里面有一些专用寄存器帮助实现 page table 、TLB 之类一大坨数据结构,具体补课体系结构和操作系统。跨 page 的访问基本上是得拆到两个 page 的,以反应不同 page 允许具有的不同状态。同一个 page 内硬件可能按历史访存请求以减少访问这些数据结构的开销,但跨 page 这些就大半失效了。
当然不管是不是跨都可能有物理内存没加载就绪的情况 page fault 了。基本如果要能用也是得操作系统一大坨软件代码分配空间,不能用也是不常见的慢速中断路径,要快就见鬼了。这里跨 page 可能一次性开销×2 。

这是一个专为移动设备优化的页面(即为了让你能够在 Google 搜索结果里秒开这个页面),如果你希望参与 V2EX 社区的讨论,你可以继续到 V2EX 上打开本讨论主题的完整版本。

https://www.v2ex.com/t/872792

V2EX 是创意工作者们的社区,是一个分享自己正在做的有趣事物、交流想法,可以遇见新朋友甚至新机会的地方。

V2EX is a community of developers, designers and creative people.

© 2021 V2EX