将一个寄存器的位复制到另一个寄存器(x86-64 asm)

Copy bit of one register to another register (x86-64 asm)

作为在运行时生成 x86-64 机器代码的项目的一部分,我经常需要将一个位从一个寄存器复制到另一个寄存器的另一个位位置。

我想到了这段代码(示例将源寄存器的第 23 位复制到目标寄存器的第 3 位):

bt eax, 23           ; test bit 23 of eax (source)
setc ebx             ; copy result to ebx (ebx is guaranteed to be zero before)
shl ebx, 3           ; shift up to become our target bit 3
and ecx, 0xFFFFFFF7  ; remove bit 3 from ecx (target)
or ecx, ebx          ; set new bit value

考虑到我需要 5 条指令来将一个寄存器的一位复制到另一个寄存器的另一位,我想是否有一些东西在 x86 上使用更少的指令?

我读过一些 BMI 指令,但遗憾的是它们不提供使用立即数的位提取。

选择:

        rcr     ecx,3+1
        bt      eax,23
        rcl     ecx,3+1

指令数不是一个好的性能指标。 code-size 以字节为单位(x86 机器指令是可变长度),或者现有主流 CPU 执行它们的方式: front-end uops(解码后)and/or 延迟/吞吐量的数量是相关的优化目标,重要的目标取决于周围的代码对于这么小的东西。 (What considerations go into predicting latency for operations on modern superscalar processors and how can I calculate them by hand?)

不幸的是,

rcr / rcl 非常慢,尤其是 count != 1.
四个 single-uop 指令要快得多,包括 BMI2 rorx 到 copy-and-rotate 要插入到 tmp 寄存器中正确位置的位,然后 and 将其隔离。或者如果您不需要保留输入,则使用普通的 shift/and。这比我想到的通过 CF 弹跳它的任何方法都更有效率。

这比xor-zero/bt/setc/shl更有效率。它还避免了setc ebx不存在,只有setc bl(或setc bh)。这也意味着,如果您可以销毁输入寄存器而不是使用临时寄存器,则不需要像 setc bl / movzx ebx, bl 这样低效的东西,这会将 zero-extension 放在关键路径上延迟,并击败 mov-elimination.

我临时切换到 EDX,因为它 call-clobbered 在正常的调用约定中。

; input in EAX, merge-target in ECX (read/write)
; no pre-conditions necessary
;  unlike the original which doesn't count the cost of zeroing EDX

   rorx  edx, eax, 23-3       ; shift bit 23 to bit 3.  Use SHR if you don't need the EAX value later
   and   edx, 1<<3            ; and isolate
   and   ecx, 0xFFFFFFF7      ; clear it in the destination
   or    ecx, edx             ; and merge

; total size: 14 bytes of machine code for imm8 masks, 20 for imm32 masks
; 4 uops.

如果您愿意,or 可以改为 addlea,因为我们知道没有重叠的 1 位。 lea 作为 copy-and-merge 有用,以防您希望将结果保存在不同的寄存器中。但是如果你想要那样,你只需 or 到临时寄存器而不是 ECX,你可以选择任何你想要的临时寄存器,包括 EAX(在这种情况下你可以优化 rorxshr.) add 如果您想从它的 FLAGS 上分支,它会很有用,因为它可以 macro-fuse 与 Sandybridge-family 上的某些形式的 jccxor 也可以,但没有任何优势,而且不适合人类读者。

如果您不介意破坏输入,

lea 可用于仅允许 shr 而不是更长的 rorx。 2 寄存器 LEA 比 addor 长 1 个字节,但在没有移位计数 (AMD) 且没有恒定位移的情况下在大多数 CPU 上速度很快。 (它不能 运行 在 Ice Lake 之前的 Intel 上那么多端口,所以如果其他条件相同,请使用 addor,即当您无法保存任何 insn 或延迟时或者 lea.) clang 很好地利用了 -O3 (只是 tune=generic,没有 -march): https://godbolt.org/z/Kbbh6zs4W ;它还生成与 -march=haswell 相同的 SHR/AND/AND/LEA。 (我猜它不会考虑使用 rorx 来保留该输入,即使在编译稍后确实需要它的代码时也是如此。)

这些都是关于 Intel 和 AMDsingle-uop 的指令,而你的目的地 bit-position 足够低以至于两个 AND 掩码都可以放在一个 sign-extended-imm8 所以 AND 指令每个都是 3 个字节。 (而不是 and r/m32, imm32 的 6 个)。但是,rorx 是 6 个字节,带有 VEX 前缀和 imm8。总大小为 14 字节,如果目标位在低位 7 之外,则为 20 字节。(如果使用字节 operand-size ,如 and dl, 0x80 / ... / or cl, dl,则为低 8 位在 P6 系列上导致 partial-register 问题,但在其他地方没问题。)

(题中使用的指令也是single-uop,包括bt。在AMD CPU上bts等是2 uops,但bt只是1.)

使用更高的目标 bit-number,您可以使用 btr ecx, 30(4 字节,在 Intel 上仍然是 1 uop)而不是 and ecx, ~(1<<30)(6 字节,或 5 字节到EAX)。但这在 AMD 上需要额外的 uop。

当然,如果您关心 code-size,您会 mov edx, eax / ror edx, 23-3(总共 5 个字节)而不是使用 rorx(6 个字节)。所以总共有 17 个字节,目标位位高。或者 15 如果我们可以销毁 EAX。

如果位位置是runtime-variables,效率会降低,需要可变计数移位。 (以及一些减法或其他生成轮班计数的东西。)那里可能有不同的策略更好。


另一种在寄存器之间交换位的方法是使用掩码 XOR,但在我们不想 交换 的地方效率不高,只走一条路。我们可以使用倒置掩码作为立即数。 (或者如果在寄存器中,BMI1 andn。)

  • Swapping bits at a given point between two bytes

主要问题是 x86 缺少位域 insert。使用 shift/and 提取非常简单,尽管这仍然是 2 条指令,除非您使用 XOP 编码为 bextr 的直接形式提供 AMD TBM。 (仅限 Bulldozer-family。)如果寄存器中已有常量,则使用 pext。如果有一个 bt 的倒数在指定位置放置 CF 就好了,但不幸的是没有。

如果有位域 insert 指令,无论是来自 CF 还是来自另一个寄存器的低位,您都不需要屏蔽。


在没有 rcr/rcl 的情况下避免临时调节 - 只是稍微慢一点,仍然是 4 uops

using rcr/rcl which is good for code size, but unfortunately slow on modern CPUs where rcl and rcr r32, imm are many uops, e.g. 7 uops on Zen3 with 3c throughput, and 8 uops on Sandybridge-family (including Alder Lake) with 6c throughput. (https://uops.info/ / https://agner.org/optimize/

那是 10 字节的机器代码,不需要临时寄存器。我们可以仅使用 12 字节的机器代码来复制功能,但仍然只有 4 single-uop 条指令,假设是 modern-enough x86。以上版本在 Intel Haswell 和更早版本上会更快。

ECX->result(3 个周期)有更长的 critical-path 延迟,但 EAX->result(3 个周期)相同,假设 single-uop adc 和旋转.还有更多的 uops 竞争移位单元,因此不能 运行 在 back-end 端口的广泛选择上。这是否重要取决于周围的代码。

即使目标 bit-position > 8,代码大小也相同,并且对于 64 位模式,避免需要任何 8 字节掩码。

如果你没有备用寄存器你可以破坏(包括输入),这很可能是值得的。 或者只是为了 code-size,如果这不是您代码中真正热门的部分。

;; 12 bytes total.  More latency through ECX, and some uops have fewer ports to choose from
   ror   ecx, 3+1         ; 1 uop on Intel HSW and later, and AMD
    ; the bit to be replaced is now at the top of ECX, and in CF
   bt    eax, 23          ; 1 uop
   adc   ecx, ecx         ; 1 uop on Broadwell and later, and AMD.
    ; Shift in the new bit, shifting out the old bit (into CF in case you care)
   rol   ecx, 3           ; 1 uop on HSW and later, and AMD
    ; restore ECX's bits to the right positions, overwriting CF

初始rotate-right可以是rcrror;我们不关心我们要替换的位是暂时移入最高位,还是只移入 CF。 ror 快得多。

我们基本上是用 rcl ecx, 1rol ecx, 3 来模拟 rcl ecx, 3+1。我认为它在 FLAGS 输出上有所不同,但在 ECX 结果以及它如何从 FLAGS 中读取是匹配的。

然后用等效但更快的 adc same,same 替换 rcl r32, 1;它们仅在 FLAGS 输出上有所不同。 adc 没有任何奇怪的 partial-flags 写法 (leaving most of SPAZO unaffected) 使英特尔的旋转成本更高。 adc 在 Broadwell 之前在 Intel 上是 2 uop,但在 AMD 上很长一段时间都是 1 uop。

这使用 bt 通过 FLAGS,因此它可以轻松支持 runtime-variable 源位位置。对于可变目的地 bit-position,您必须计算移位计数,而 ror reg, cl 速度较慢(Intel 上为 3 微指令)。不幸的是没有 variable-count rorx,只有 shlx / shrx.