【问题标题】:Minimal opcode size x86-64 strlen implementation最小操作码大小 x86-64 strlen 实现
【发布时间】:2018-09-25 05:10:58
【问题描述】:

我正在研究我的代码打高尔夫球/二进制可执行文件的最小操作码大小 x86-64 strlen 实现,它不应该超过一定大小(想想 demoscene简单)。
总体思路来自here,尺寸优化思路来自herehere

输入字符串地址在rdi,最大长度不大于Int32

xor   eax,eax ; 2 bytes
or    ecx,-1  ; 3 bytes
repne scasb   ; 2 bytes
not   ecx     ; 2 bytes
dec   ecx     ; 2 bytes

最终结果为 ecx,总共 11 个字节

问题是关于将ecx 设置为-1

选项 1 已经说明

or ecx,-1 ; 3 bytes

选项 2

lea ecx,[rax-1] ; 3 bytes 

选项 3

stc         ; 1 byte
sbb ecx,ecx ; 2 bytes

选项4,可能是最慢的一个

push -1 ; 2 bytes
pop rcx ; 1 byte

我明白:
选项 1 依赖于以前的 ecx
选项 2 依赖于以前的 rax
选项 3 我不确定它是否依赖于以前的 ecx 值?
选项 4 是最慢的?

这里有明显的赢家吗?
标准是保持操作码的大小尽可能小,并明智地选择性能最佳的一个。
我完全知道有使用现代 cpu 指令的实现,但这种传统方法似乎是最小的一种。

【问题讨论】:

  • 有人能解释一下否决这个问题的原因吗?
  • @BoPersson 添加了原因。我在保存所有可能的字节后,以在给定的可执行限制(4096 字节)中实现尽可能多的功能。无论如何,这会影响答案吗?我认为标准已经很明确了。

标签: assembly nasm x86-64 yasm


【解决方案1】:

对于一个 hacky 足够好的版本,我们知道rdi 有一个有效的地址。 edi 很可能不是一个小整数,因此是 2 字节 mov ecx, edi。但这并不安全,因为 RDI 可能指向刚刚超过 4GiB 边界,因此很难证明它是安全的。除非您使用像 x32 这样的 ILP32 ABI,否则所有指针都低于 4GiB 标记。

因此您可能需要使用 push rdi / pop rcx 复制完整的 RDI,每个 1 字节。但这为短字符串启动增加了额外的延迟。如果您没有长度高于其起始地址的字符串,它应该是安全的。 (但对于 .data、.bss 或 .rodata 中的静态存储合理,如果您有任何巨大的数组;例如,Linux 非 PIE 可执行文件在 0x401000 = 1 左右加载

如果您只希望 rdi 指向终止的 0 字节,而不是实际需要计数,这很好。或者,如果您在另一个寄存器中有起始指针,那么您可以执行sub edi, edx 或其他操作并以这种方式获取长度,而不是处理rcx 结果。 (如果您知道结果适合 32 位,则不需要sub rdi, rdx,因为您知道它的高位无论如何都会为零。高输入位不会影响加/减的低输出位;进位传播从左到右。)

对于已知小于 255 字节的字符串,您可以使用 mov cl, -1(2 字节)。这使得rcx 至少为 0xFF,并且更高,具体取决于其中留下的高垃圾。 (当读取 RCX 时,这在 Nehalem 和更早的时候有一个部分注册停止,否则只是对旧 RCX 的依赖)。无论如何,然后 mov al, -2 / sub al, cl 将长度作为 8 位整数。这可能有用,也可能没用。

根据调用者的不同,rcx 可能已经保存了一个指针值,在这种情况下,如果您可以使用指针减法,则可以保持它不变。


在您提出的选项中

lea ecx,[rax-1] 非常好,因为您只需对 eax 进行异或归零,它是一条廉价的 1 uop 指令,具有 1 个周期的延迟,可以在所有主流 CPU 的多个执行端口上运行。

如果您已经有另一个具有已知常量值的寄存器,尤其是异或零值的寄存器,3 字节 lea 几乎总是最有效的 3 字节创建常量的方法,如果它有效的话。 (见Set all bits in CPU register to 1 efficiently)。


我完全知道有使用现代 cpu 指令的实现,但这种传统方法似乎是最小的一种。

是的,repne scasb 非常紧凑。它的启动开销在典型的 Intel CPU 上可能是 15 个周期,根据Agner Fog,它发出 >=6n uop,吞吐量为 >=2n 个周期,其中n 是计数(即每个字节 2 个周期它比较长比较,其中隐藏了启动开销),因此它使 lea 的成本相形见绌。

ecx 的错误依赖可能会延迟其启动,因此您肯定需要lea

repne scasb 可能对你正在做的任何事情都足够快,但它比pcmpeqb / pmovmsbk / cmp 慢。对于短的固定长度字符串,integer cmp / jne 在长度为 4 或 8 个字节时非常好(包括终止的 0),假设您可以安全地过度阅读您的字符串,即您不必担心页面末尾的""。但是,此方法的开销会随字符串长度而变化。例如,对于字符串长度 = 7,您可以执行 4、2 和 1 个操作数大小,或者您可以执行两个重叠 1 个字节的双字比较。喜欢cmp dword [rdi], first_4_bytes / jnecmp dword [rdi+3], last_4_bytes / jne.


有关 LEA 的更多详情

在 Sandybridge 系列 CPU 上,lea 可以在与它相同的周期内被分派到执行单元,xor-zero 被发送到无序的 CPU 内核中。 xor-zeroing 在问题/重命名阶段处理,因此 uop 以“已执行”状态进入 ROB。指令不可能一直等待 RAX。 (除非在 xor 和lea 之间发生中断,但即便如此,我认为在恢复 RAX 之后和lea 可以执行之前会有一个序列化指令,所以它不能等待。)

Simple lea 可以在 SnB 上的 port0 或 port1 上运行,或者在 Skylake 上的 port1 / port5 上运行(每个时钟吞吐量 2 个,但有时不同的 SnB 系列 CPU 上的不同端口)。这是 1 个周期的延迟,因此很难做得更好。

使用可以在任何 ALU 端口上运行的 mov ecx, -1(5 字节)您不太可能看到任何加速。

在 AMD Ryzen 上,64 位模式下的lea r32, [m] 被视为“慢” LEA,只能在 2 个端口上运行,并且延迟为 2c 而不是 1。更糟糕的是,Ryzen 并没有消除异或归零.


您所做的微基准测试仅测量没有错误依赖关系的版本的吞吐量,而不是延迟。这通常是一种有用的衡量标准,而且您确实得到了正确答案,即lea 是最佳选择。

纯吞吐量能否准确反映您的实际用例是另一回事。如果字符串比较在关键路径上作为长的或循环携带的数据依赖链的一部分未被jcc 破坏,那么您实际上可能依赖于延迟而不是吞吐量,从而为您提供分支预测 + 推测执行。 (但无分支代码通常较大,所以这不太可能)。

stc / sbb ecx,ecx 很有趣,但只有 AMD CPU 将 sbb 视为依赖关系破坏(仅取决于 CF,而不是整数寄存器)。在 Intel Haswell 和更早版本上,sbb 是 2 uop 指令(因为它有 3 个输入:2 个 GP 整数 + 标志)。它有 2c 的延迟,这就是它表现如此糟糕的原因。 (延迟是一个循环携带的 dep 链。)


缩短序列的其他部分

根据您的操作,您可能也可以使用strlen+2,但可以抵消另一个常量或其他内容。 dec ecx 在 32 位代码中只有 1 个字节,但 x86-64 没有短格式 inc/dec 指令。所以 not / dec 在 64 位代码中没有那么酷。

repne scas 之后,你有ecx = -len - 2(如果你从ecx = -1 开始),not 给你-x-1(即+len + 2 - 1)。

 ; eax = 0
 ; ecx = -1
repne scasb      ; ecx = -len - 2
sub   eax, ecx   ; eax = +len + 2

【讨论】:

  • 很棒的答案。我可以使用 sub eax, ecx 后跟 lea edx,[rax-2] 来保存 1 个字节,因为我需要长度作为 System V 调用中的第三个参数
【解决方案2】:

我在 Intel Core i7 4850HQ Haswell 2,3 GHz 上进行了一些基准测试,发布版本没有附加调试器。在每个循环中,我测量 1000 个 asm 指令序列并重复 1000 万次以平均结果。

我已经制作了将 asm 指令重复 100 次的宏。

#define lea100 asm{xor   eax,eax};asm { lea ecx,[rax-1] }; // <== Copy pasted 100times
#define or100 asm{xor   eax,eax};asm { or ecx,-1 }; // <== Copy pasted 100times
#define sbb100 asm{xor   eax,eax};asm { stc };asm{sbb ecx,ecx}; // <== Copy pasted 100times
#define stack100 asm ("xor %eax,%eax;.byte 0x6A; .byte 0xFF ;pop %rcx;"); // <== Copy pasted 100times

使用 MacOS 的内联 asm 测试 C 代码

#include <stdio.h>
#include <CoreServices/CoreServices.h>
#include <mach/mach.h>
#include <mach/mach_time.h>
int main(int argc, const char * argv[]) {
    uint64_t        start;
    uint64_t        end;
    uint64_t        elapsed;
    Nanoseconds     elapsedNano;

    uint64_t sum = 0;
    for (int i = 0; i < 10000000 ; i++) {

// this will become
// call       imp___stubs__mach_absolute_time  
// mov        r14, rax
    start = mach_absolute_time();

//10x lea100 for example for total 1000 

// call       imp___stubs__mach_absolute_time
// sub        rax, r14
    end = mach_absolute_time();

    elapsed = end - start;
    elapsedNano = AbsoluteToNanoseconds( *(AbsoluteTime *) &elapsed );
    uint64_t nano = * (uint64_t *) &elapsedNano;
        sum += nano;
    }
    printf("%f\n",sum/10000000.0);
    return 0;
}

结果

xor eax,eax
lea ecx,[rax-1]

205-216 纳秒

xor eax,eax
or ecx,-1

321-355 纳秒

xor eax,eax
push -1 
pop rcx 

322-359 纳秒

xor eax,eax
stc     
sbb ecx,ecx

612-692 纳秒

【讨论】:

  • 您正在测试吞吐量,而不是延迟。其中一些(orsbb)通过 ECX 对错误依赖造成了瓶颈。 lea 版本应该在 Sandybridge 系列上每时钟运行约 4 条指令,而 or ecx, -1 版本应该是该版本的一半,由于依赖链,瓶颈在每时钟 1or。所以你有很大的测量开销。 (或者您正在测试第一代 i7,即 Nehalem,它是一个不同的微架构系列。“i7”不是一个有用的描述)。
  • @PeterCordes 我一直在等待这样的见解。我添加了i7类型,有帮助吗?您对如何减少测量开销有什么建议吗?或者可能是完全不同的方法?
  • 你不需要更准确的时间,只需使用lea。它在各方面都比任何替代品都好。 (根据agner.org/optimizelea 在 64 位模式下具有 32 位操作数大小在 AMD 上确实有一个额外的延迟周期,但仍然有很好的吞吐量和只有 2 个延迟周期。这绝对是你最好的选择,并且repne scasb 相比之下相当慢,所以避免错误的 deps 就更好了。所以真的有可能在某些情况下or ecx,-1 具有较小的优势,但尤其是在英特尔上不太可能。)正在寻找答案.. .
猜你喜欢
  • 2021-09-18
  • 2019-04-22
  • 2010-12-30
  • 1970-01-01
  • 1970-01-01
  • 2010-11-09
  • 2023-03-14
  • 2021-11-10
  • 1970-01-01
相关资源
最近更新 更多