【问题标题】:x86_64 efficiently set bit N in register DST if N < width(DST), otherwise DST = 0如果 N < width(DST),x86_64 有效地设置寄存器 DST 中的位 N,否则 DST = 0
【发布时间】:2021-05-13 20:58:05
【问题描述】:

我正在尝试在 x86_64 汇编中找到一种有效的方法来执行以下操作:

if(N < word_size) {
    dst[N] = 1; // as in Nth bit of dst = 1 
}
else {
    dst[word_size - 1:0] = 0
} 

如果“else”情况没有取消设置其他位,或者“if”情况确实取消了其他位,我可以获得所需的结果。重要的是 如果 N > word_size 它不会设置任何位

我找不到任何可能执行此操作的指令,因为 bt[s/c], shlx, sal, rol, shld 似乎都按宽度占用了 src 的模块。

用例基本上是我将迭代一个已知长度的位向量,并希望 A)找到第一个设置位并返回其位置,或 B)测试所有位,如果没有设置位找到向量的返回长度。

// rsi has length
L(keep_searching):
movq %(rdi), %rax
testq %rax, %rax
jnz L(found)
subq $64, rsi
jbe L(done) // this done will return origional value of rsi
addq $8, %rdi
jmp L(keep_searching)

我认为如果我可以在rax 中快速设置一点,如果rsi

有谁知道可以做到这一点的指令吗?我能想到的要检查的每条指令都使用 src 上的模块。任何帮助将不胜感激。

谢谢!

一些对我来说适用于 32 位的版本。如果我使用 MMX,@PeterCordes 指出 pllsq 正是我想要的。


uint64_t __attribute__((noinline, noclone)) shift(uint64_t cnt) {
    uint64_t ret = 0;
    asm volatile(
        "cmpq $32, %[cnt]\n\t"
        "setbe %b[ret]\n\t"
        "shlxq %[cnt], %[ret], %[ret]\n\t"
        : [ ret ] "+r"(ret)
        : [ cnt ] "r"(cnt)
        : "cc");
    return ret;
}


uint64_t __attribute__((noinline, noclone)) shift2(uint64_t cnt) {
    uint64_t ret = 0, tmp = 0;
    asm volatile(
        "leaq -33(%[cnt]), %[tmp]\n\t"
        "movl $1, %k[ret]\n\t"
        "shlxq %[cnt], %[ret], %[ret]\n\t"
        "sarq $63, %[tmp]\n\t"
        "andq %[tmp], %[ret]\n\t"
        : [ ret ] "+r"(ret), [ tmp ] "+r"(tmp), [ cnt ] "+r"(cnt)
        :
        : "cc");
    return ret;
}


uint64_t __attribute__((noinline, noclone)) shift3(uint64_t cnt) {
    uint64_t ret, tmp;
    asm volatile(
        "leaq -33(%[cnt]), %[tmp]\n\t"
        "btsq %[cnt], %[ret]\n\t"
        "sarq $63, %[tmp]\n\t"
        "andq %[tmp], %[ret]\n\t"
        : [ ret ] "+r"(ret), [ tmp ] "+r"(tmp), [ cnt ] "+r"(cnt)
        :
        : "cc");
    return ret;
}

【问题讨论】:

  • 您应该使用setcc 指令,其中cc 是条件。 setcc 如果条件为真,则将指定寄存器设置为 1,如果条件为假,则设置为 0。似乎是适合您情况的完美说明。
  • 所以你认为cmpq $64, %rsi; setbe %rcx; shlx %rsi, %rcx, %rcx?我会试一试,看看它是如何运行的。好主意!
  • x86 SIMD 移位(如psllq xmm1, xmm2)使计数饱和而不是换行。但这似乎不太可能对 this 案例有用。可能只是单独进行最后一次迭代,以便您可以矢量化搜索(一次检查 16 个字节(2 个 qwords 或 4 个 dwords)以确保全非零)。并且绝对将条件分支之一放在底部并删除jmp,例如sub $64, %rsi / ja L(keep_searching)Why are loops always compiled into "do...while" style (tail jump)?
  • setcc r/m8 很不方便,因为它只适用于 8 位寄存器。但是,如果你有一个像 RDX 这样的归零寄存器,你可以setbe dl/shlx %rsi, %rdx, %rcx。即使您不想使用 SIMD,在 test / jz keep_searching 之前将其 ORing 到 RAX 似乎也不值得。正确预测的未进行的测试/jnz 是单个 uop,比您为创建 RAX 值所做的所有工作要便宜,完成后您必须再次解码 RAX。
  • @PeterCordes 我的想法是,如果我这样做,我将能够使所有回报成为一个未采取的分支。但是,另一种方法是 cmovcc

标签: assembly x86-64 micro-optimization


【解决方案1】:

尚未验证,但

mov rax, 1  // common
mov rdx, 0  // common

cmp rcx, 64
shlxq rbx, rax, rcx
cmova rbx, rdx

可能比建议的替代方案性能略高,因为比较和移位现在是独立的,可以并行执行。

编辑

从用例来看,这似乎是一个 XY 问题——在 bitset 中迭代位的有效方法是使用 n &amp; (n-1) 技巧或变体; popcount(n ^ (n-1)) 应该给出最小位集的索引。 n&amp;=n-1 将清除 LSB。

【讨论】:

  • cmp + setcc相比,这种方法的速度要慢10-15%。
  • @Noah:您是否将 RAX 和 RDX 的设置提升到了循环之外?这就是这里“常见”的意思。
  • @PeterCordes 不,我的错!提升,仍然较慢,但比 15% 更接近 10%
  • n &amp;= (n - 1) 不是必需的,因为一旦我找到一个设置位,我就会返回。我可以使用popcnt(n ^ (n - 1))(或者可能更好tzcnt)但是我需要找出最低设置位高于或低于rsi中指定的值。那是可行的,但我试图找到一种方法,以便可以合并对设置位和边界检查的搜索。一旦我完成了所有工作,就会看到它是否有回报,或者@PeterCordes 是否正确,我最好再来一个cmp + jcc
  • @Noah -- tzcnt(n &amp; mask),其中mask = 0xffffffff &gt;&gt; (N &amp; 63);
【解决方案2】:

SIMD 移位(如 SSE2 psllq xmm1, xmm2)使计数饱和,但这在这里不太可能有用,因为我认为您希望将其 OR 到内存中的数据中作为此循环的标量版本的结束条件?

我更倾向于使用归零寄存器中的cmov,使用仍从sub 设置的FLAGS。您可以在 SUB 之前或之后使用 BTS 将1&lt;&lt;(rsi&amp;63) 创建到归零寄存器中;之前 SUB 是好的,因为 BTS 修改了 CF。注意rsi&amp;63 不受rsi -= 64 的影响。

这可能不是循环条件的好选择:只需使用单微指令sub/ja,通常不使用单独的test/jnz。其中一个放在底部而不是无条件的jmp:这是这里最明显和基本的优化:Why are loops always compiled into "do...while" style (tail jump)?

或者更好的是,使用 SSE2(x86-64 的基线)一次检查 16 个字节(2 个 qword 或 4 个 dword)是否全非零。或者,如果您不希望很快找到第一个设置位,甚至可以将几个向量一起 POR 一次检查,即调整大行程计数,但代价是最终找到它的处理速度较慢。 (最后一次迭代可以是标量)。

(查看 glibc 的 strlen 或特别是 memchr 以了解有关使用 SIMD 优化大型数组未找到早期情况的更多想法。在这种情况下,他们使用 pminub 来获得零,如果任何向量在那个位置都有一个零,但你想要相反:por 如果有任何一个非零,则得到一个非零。)

将内存中的两个值进行或运算也适用于标量,作为展开的一种方式。

    mov  (%rdi), %rax
    or  8(%rdi), %rax
    jnz  found
    ...
    add  $16, %rdi

但请注意,or/jnz 是 2 微秒,而 test/jnz 是 1。
OTOH,将cmpq $0, (%rdi) / jne 设置为 Intel 上的微熔丝和宏熔丝可能是不可能的; IIRC 可能带有注册源。因此,如果您非常积极地调整,内存源or 可能需要多花费 2 微欧来完成两倍的工作,而不是仅仅多 1 微秒。您需要与展开并执行两个单独的 load/test/jcc 或 cmp-mem/jcc 的循环进行比较,以使其对指针增量逻辑的循环开销保持公平。 (并且还展开逻辑来处理可能的奇数个 qwords。)


但是作为一个练习,让我们看看我们可以用你的想法做什么:在这种情况下,可以提前计算一次非零移位结果(因为 rsi-=64 不会改变 rsi%64),并被吊出循环。

   xor  %edx, %edx
   bts  %rsi, %rdx        # rdx = 1 << (rsi&63)

// rsi has length
L(keep_searching):
   add  $8, %rdi
   xor  %eax, %eax        # need to re-create a zero every time
   sub  $64, %rsi
   cmovbe %rdx, %rax      # 0  or  1<<(rsi&63) to put a bit there for us to find

   or  -8(%rdi), %rax
   jnz  L(keep_searching)

found_or_done:
   tzcnt %rax, %rax
   add   orig_rsi?, %rax
   ...

不幸的是 OR 不能像 TEST 那样与 JCC 进行宏融合。 (或英特尔 SnB 系列上的 SUB)。但是内存源 OR 是前端的单个 uop。

不幸的是,cmovbecmova 需要 2 微秒,因为它们需要 CF 和 ZF。 (请参阅What is a Partial Flag Stall? - 最近的英特尔没有部分标志停顿甚至合并,只有 CF 与其他(SPAZO),如果需要,微指令会分别读取两个输入。)但没有明显的原因,setbeseta 也是 2 uop (https://uops.info) - 也许英特尔从未更新 setcc uop 格式以作为 3 输入 uop 工作(包括它们合并到低字节的完整寄存器)。有趣的事实:这导致setcc 通常只能在“复杂”解码器中解码,如果您的代码不在 uop 缓存中:Can the simple decoders in recent Intel microarchitectures handle all 1-µop instructions?

感谢cmov,Intel 上的循环体总共是 7 微秒。 6 关于 AMD。

比较与 4 微秒的简单标量搜索循环,无需此技巧。这可以在 Intel 上以每次迭代运行 1 个周期的速度运行(Haswell 和更高版本可以在每个时钟运行 2 个分支,只要最多采用 1 个)。我认为也是在 AMD Zen 上。所以我们每个周期搜索大约 8 个字节,大约是我们使用 SIMD 可以做的一半。但启动和结束开销较低。

L(loop):                 # do {
    mov   (%rdi), %rax       # 1 uop
    test  %rax,%rax
    jnz   L(found)           # 1 uop (macro-fused)
    add   $8, %rdi           # 1 uop
    cmp   %rdi, %rdx
    jbe   L(loop)         # }while(p < endp)   # 1 uops (macro-fused)
L(done):

如果您将负数组索引计数到零,您可以避免在循环中同时出现addsub:使用通过 add 设置的 FLAGS。 (或者对于简单的版本,避免 CMP,让

您需要 or (%r10, %r11, 8), %rax 或类似的东西,但 Haswell 和更高版本可以将索引寻址模式微融合,作为具有 RW 目标的 2 操作数指令的一部分:Micro fusion and addressing modes)


setcc r/m8 不方便,因为它只适用于 8 位寄存器。但是如果你有一个像 RDX 这样的归零寄存器,你可以setbe dl/shlx %rsi, %rdx, %rcx。即使您不想使用 SIMD,在 test / jz keep_searching 之前将其 ORing 到 RAX 似乎也不值得。

正确预测的未采取的测试/jnz 是单个 uop,比您为创建 RAX 值所做的所有工作便宜。

【讨论】:

  • 关于负指数向0递增;你说addq $64, %rsi + cmovc 更快,因为只需要进位标志?
  • 但是你说分开比较是正确的方法似乎是正确的
  • @PeterCordes CC-BY SA 4.0 -> GPLv3 only IIRC @ Noah 如果代码不是您的代码,那么无论您使用什么许可证,都不允许在 Code Review 上发布 authorship of code reason
  • 正如@Peilonrayz 刚刚指出的那样,我猜这是唯一的可能性。我的猜测是人们会觉得它很有趣,并且可能会解释许多常见的优化技术并将其应用于一个清晰的示例,作为有用的参考。
  • @Noah:是的,我经常将 glibc strlen 或 memcmp 链接为如何在 asm 中优化类似内容的示例。不过,似乎在 CC-BY-SA4.0 下发布 GPL 工作是不合法的(只是相反),所以您是否可以引用正在讨论的原始代码仍然存在一个问题,我认为这将是一个很大的改进链接并询问有关如何实施它的开放式问题。一个好的问题可能会剖析和解释 glibc 代码的优点,比如它是如何工作的,已经存在哪些优化,以及要求改进。
猜你喜欢
  • 2016-10-13
  • 2018-01-09
  • 2018-10-19
  • 1970-01-01
  • 2011-07-25
  • 1970-01-01
  • 1970-01-01
  • 2012-11-30
  • 2012-08-06
相关资源
最近更新 更多