x86-64 SysV ABI的strict rules allow实现只保存指定的XMM reg的确切数量,但当前的实现只判断零/非零,因为这是有效的,尤其是对于AL=0的常见情况.
如果在AL1中传递一个低于XMM寄存器arg实际数的数字,或者传递一个高于8的数字,那么就违反了ABI,只有这个实现细节才能阻止代码中断.(即it "happens to work", but is not guaranteed by any standard or documentation,并且不能移植到其他一些真正的实现中,比如使用GCC4.5或更早版本构建的旧GNU/Linux发行版.)
This Q&A显示了glibc printf的当前版本,它只判断AL!=0
,而glibc的旧版本将跳转目标计算为movaps
个存储序列.(Q&;A是关于AL>8
时的代码中断,使计算出的跳转到不应该跳转的地方.)
Why does eax contain the number of vector parameters?引用了ABI文档,并显示了ICC代码gen,它使用与旧GCC相同的指令进行计算跳转.
Glibc's 100 implementation is compiled from C source, normally by GCC.当现代GCC编译一个变量函数(如printf)时,它使asm只判断零与非零AL,如果非零,则将所有8个通过XMM寄存器的arg转储到堆栈上的一个array.
GCC4.5和更早的版本实际上是did,使用AL中的数字进行计算,跳转到movaps
个存储序列中,以实际保存尽可能多的XMM REG.
Nate的简单例子来自GCC4对Godbolt的 comments .5和GCC11显示了与旧/新glibc(由GCC构建)的拆解相关答案相同的差异,这并不令人惊讶.这个函数only使用va_arg(v, double);
,从不使用整数类型,所以它不会转储传入的RDI...R9在任何地方,不同于printf
.它是一个叶子函数,因此可以使用红色区域(RSP下128字节).
# GCC4.5.3 -O3 -fPIC to compile like glibc would
add_them:
movzx eax, al
sub rsp, 48 # reserve stack space, needed either way
lea rdx, 0[0+rax*4] # each movaps is 4 bytes long
lea rax, .L2[rip] # code pointer to after the last movaps
lea rsi, -136[rsp] # used later by va_arg. test/jz version does the same, but after the movaps stores
sub rax, rdx
lea rdx, 39[rsp] # used later by va_arg, test/jz version also does an LEA like this
jmp rax # AL=0 case jumps to L2
movaps XMMWORD PTR -15[rdx], xmm7 # using RDX as a base makes each movaps 4 bytes long, 与. 5 with RSP
movaps XMMWORD PTR -31[rdx], xmm6
movaps XMMWORD PTR -47[rdx], xmm5
movaps XMMWORD PTR -63[rdx], xmm4
movaps XMMWORD PTR -79[rdx], xmm3
movaps XMMWORD PTR -95[rdx], xmm2
movaps XMMWORD PTR -111[rdx], xmm1
movaps XMMWORD PTR -127[rdx], xmm0 # xmm0 last, will be ready for store-forwading last
.L2:
lea rax, 56[rsp] # first stack arg (if any), I think
## rest of the function
与.
# GCC11.2 -O3 -fPIC
add_them:
sub rsp, 48
test al, al
je .L15 # only one test&branch macro-fused uop
movaps XMMWORD PTR -88[rsp], xmm0 # xmm0 first
movaps XMMWORD PTR -72[rsp], xmm1
movaps XMMWORD PTR -56[rsp], xmm2
movaps XMMWORD PTR -40[rsp], xmm3
movaps XMMWORD PTR -24[rsp], xmm4
movaps XMMWORD PTR -8[rsp], xmm5
movaps XMMWORD PTR 8[rsp], xmm6
movaps XMMWORD PTR 24[rsp], xmm7
.L15:
lea rax, [rsp+56] # first stack arg (if any), I think
lea rsi, -136[rsp] # used by va_arg. done after the movaps stores instead of before.
...
lea rdx, 56[rsp] # used by va_arg. With a different offset than older GCC, but used somewhat similarly. Redundant with the LEA into RAX; silly compiler.
GCC可能改变了策略,因为计算的跳转需要更多的静态代码大小(I-cache内存),而且测试/jz比间接跳转更容易预测.更重要的是,在普通AL=0(无XMM)情况下执行的UOP更少2.即使在AL=1的最坏情况下(7个movaps
家店铺死亡,但没有计算分支目标的工作),也没有更多.
相关问题;作为:
半相关,而我们谈论的是违反公约:
Footnote 1: AL, not RAX, is what matters
x86-64 System V ABI doc规定,变量函数必须只查看AL中的REG数;RAX的高7字节被允许存放垃圾.mov eax, 3
是设置AL avoiding possible false dependencies from writing a partial register的有效方法,尽管它的机器代码大小(5字节)比mov al,3
(2字节)大.clang通常使用mov al, 3
.
ABI文件中的要点,更多内容请参见Why does eax contain the number of vector parameters?:
序言应该使用%al
,以避免不必要地保存XMM寄存器.这对于仅限整数的程序尤其重要,以防止XMM单元的初始化.
(最后一点是过时的:XMM reg广泛用于memcpy/memset,并内联到zero init小型数组/ struct .如此之多,以至于Linux在上下文switch 上使用"渴望"FPU保存/恢复,而不是在第一次使用XMM reg时出现故障的"懒惰".)
%al
的内容不需要与寄存器的数量完全匹配,但必须是所用向量寄存器数量的上限,并且在0到8(含)的范围内.
本ABI对AL<;=8允许计算跳转实现忽略边界判断.(同样,Does the C++ standard allow for an uninitialized bool to crash a program?是的,可以假设ABI违规不会发生,例如,通过编写在这种情况下会崩溃的代码.)
Footnote 2: efficiency of the two strategies
较小的静态代码大小(I-cache内存)总是一件好事,而AL=0战略对它有利.
最重要的是,在AL==0的情况下执行的指令总数更少.printf
不是唯一的变量函数;sscanf
并不罕见,而且它从不使用FP args(仅指针).如果一个编译器可以看到一个函数从不使用带FP参数的va_arg
,它会完全忽略保存,这使得这一点没有意义,但是scanf/printf函数通常被实现为vfscanf
/vfprintf
调用的包装器,因此编译器doesn't看到,它看到一个va_list
被传递给另一个函数,因此它必须保存所有内容.(我认为人们编写自己的可变函数是相当罕见的,因此在许多程序中,对可变函数的唯一调用将是对库函数的调用.)
故障执行官可以在死气沉沉的store 里细嚼慢咽,这对艾尔来说还不错<;8但非零 case ,多亏了广泛的管道和存储缓冲区,开始了与这些存储并行的实际工作.
计算和执行间接跳转总共需要5条指令,不包括lea rsi, -136[rsp]
和lea rdx, 39[rsp]
条指令.test/jz策略也会在movaps存储之后执行这些或类似操作,作为va_arg
代码的设置,该代码必须确定何时到达寄存器保存区域的末尾,并切换到查看堆栈参数.
我也不算sub rsp, 48
;无论哪种方式,这都是必要的,除非您也将XMM save area size设置为变量,或者只保存每个XMM reg的下半部分,以便8x 8 B=64字节可以放入红色区域.理论上,可变函数可以在XMM reg中使用16字节__m128d
arg,因此GCC使用movaps
而不是movlps
.(我不确定glibc printf是否有需要一次转换的转换).在实际printf这样的非叶函数中,您总是需要保留更多空间,而不是使用红色区域.(这是计算跳转版本中lea rdx, 39[rsp]
的一个原因:每movaps
需要正好是4个字节,因此编译器生成代码的方法必须确保它们的偏移量在[reg+disp8]
寻址模式的[-128,+127]范围内,而不是0
,除非GCC将使用特殊的asm语法强制在那里执行更长的指令.
几乎所有x86-64 CPU都以单个微熔合uop的形式运行16字节存储(只有老旧的AMD K8和Bobcat将其拆分为8字节的两部分;参见https://agner.org/optimize/),而且我们通常都会触及128字节区域下方的堆栈空间.(此外,计算出的跳转策略本身存储在底部,因此它不会避免触及缓存线.)
因此,对于具有一个XMM arg的函数,计算跳转版本总共需要6条单uop指令(5条整数ALU/跳转,1条movaps)才能保存XMM arg.
test/jz版本总共需要9个UOP(10条指令,但自Nehalem以来,在Intel上以64位模式进行test/jz宏融合,自推土机IIRC以来,在AMD上以64位模式进行融合).1个宏熔合测试和分支,8个movaps存储.
And that's the best case for the computed-jump version: with more xmm args, it still runs 5 instructions to compute the jump target, but has to run more movaps instructions. The test/jz version is always 9 uops. So the break-even point for dynamic uop count (actually executed, 与. sitting there in memory taking up I-cache footprint) is 4 XMM args which is probably rare, but it has other advantages. Especially in the AL == 0 case where it's 5 与. 1.
test/jz分支对于任何数量的XMM arg总是go 同一个地方,除了零,这使得它变成easier to predict than an indirect branch,这对于printf("%f %f\n", ...)
和"%f\n"
是不同的.
计算跳转版本中的5条指令中有3条(不包括jmp)与传入的AL形成依赖链,这使得需要更多的周期才能检测到预测失误(即使该链可能在调用之前以mov eax, 1
开头).但是dump everything策略中的"额外"指令只是一些XMM1的死存储器..7永远不会被重新加载,也不是任何依赖链的一部分.只要store 缓冲区和ROB/RS能够吸收它们,无序执行者就可以在空闲时处理它们.
(公平地说,它们会将存储数据和存储地址执行单元绑定一段时间,这意味着以后的存储也不会很快为存储转发做好准备.在存储地址UOP与加载在同一执行单元上运行的CPU上,以后的加载可能会被占用这些执行单元的存储UOP延迟.幸运的是,现代CPU至少有2个加载执行单元,而从Haswell到Skylake的ntel可以在3个端口中的任意一个上运行存储地址UOP,使用类似这样的简单寻址模式.Ice Lake有2个装载/2个存储端口,没有重叠.)
计算的跳转版本最后保存了XMM0,这可能是第一个重新加载的arg.(大多数变量函数按顺序遍历其参数).如果有多个XMM参数,则计算出的跳转方式在几个周期后才能从该存储向前存储.但对于AL=1的情况,这是唯一的XMM存储,没有其他工作占用加载/存储地址执行单元,少量arg可能更常见.
与较小的代码占用和针对AL==0情况执行的指令更少的优势相比,这些原因中的大多数实际上都很小.(对我们中的一些人来说)思考现代简单方法的上/下两面是很有趣的,它表明即使在最糟糕的情况下,这也不是问题.