【问题标题】:n is negative, positive or zero? return 1, 2, or 4n 是负数、正数还是零?返回 1、2 或 4
【发布时间】:2012-03-22 10:43:33
【问题描述】:

我正在构建一个 PowerPC 解释器,它运行良好。在 Power 架构中,条件寄存器 CR0(x86 上的 EFLAGS)几乎在任何指令上都会更新。它是这样设置的。 CR0的值为1,如果最后一个结果为负,如果最后一个结果为正,则为2,否则为4。

我的第一个天真的解释方法是:

if (n < 0)
    cr0 = 1
else if (n > 0)
    cr0 = 2;
else
    cr0 = 4;

但是我知道所有这些分支都不会是最佳的,每秒运行数百万次。我已经看到一些关于 SO 的黑客攻击,但似乎没有一个是过激的。例如,我发现很多例子可以根据符号或 0 将数字转换为 -1、0 或 1。但是如何使 -1 = 1、1 = 2、0 = 4? 我正在寻求 Bit Hackers 的帮助...

提前致谢

更新: 首先:谢谢大家,你们太棒了。我会仔细测试你所有的代码的速度,你会第一个知道谁是赢家。

@jalf:关于你的第一个建议,我实际上并没有在每条指令上计算 CR0。我宁愿保留一个 lastResult 变量,当(如果)以下指令要求一个标志时,进行比较。三个主要动机让我回到了“每次”更新:

  1. 在 PPC 上,您不必像在 x86 上那样强制更新 CR0(其中 ADD 总是更改 EFLAGS,即使不需要),您有两种类型的 ADD,一种是更新。如果编译器选择使用更新的,这意味着它会在某个时候使用 CR0,所以延迟没有意义......
  2. 有一个特别痛苦的指令叫做 mtcrf,它可以让你任意改变 CR0。您甚至可以将其设置为 7,没有算术意义...这只会破坏保留“lastResult”变量的可能性。

【问题讨论】:

  • 你怎么知道比特旋转会更快?
  • 作为对回答者的说明,我们是否可以尝试写出不仅仅是“不要问这个问题”的答案?我们是否可以要么假设 OP 对自己实现这一点感到好奇,而不是依赖编译器,或者无论出于何种原因,编译器生成的代码已经过尝试和检查,但发现速度太慢了?或者,如果做不到这一点,给出一个更好的理由来忽略这个问题,而不仅仅是“你应该闭上眼睛,相信编译器并希望最好”
  • 您的解释器在哪个 CPU 上运行?如果您想要接近最佳解决方案的任何东西,可能需要了解指令集
  • +1。很好的问题,还有很多有趣的答案。您可以尝试所有这些并发布一个小基准吗?
  • 请注意,即使他这样做了,基准也不一定能告诉任何事情。你的编译器、你的 CPU,甚至你的操作系统都可能导致不同的结果

标签: c++ bit-manipulation bit bit-shift


【解决方案1】:

如果有更快的方法,编译器可能已经在使用它。

让您的代码保持简短;这使得优化器最有效。

简单直接的解决方案在速度方面表现出色:

cr0 = n? (n < 0)? 1: 2: 4;

x86 程序集(由 VC++ 2010 生成,标志 /Ox):

PUBLIC  ?tricky@@YAHH@Z                                 ; tricky
; Function compile flags: /Ogtpy
_TEXT   SEGMENT
_n$ = 8                                                 ; size = 4
?tricky@@YAHH@Z PROC                                    ; tricky
; Line 26
        mov     eax, DWORD PTR _n$[esp-4]
        test    eax, eax
        je      SHORT $LN3@tricky
        xor     ecx, ecx
        test    eax, eax
        setns   cl
        lea     eax, DWORD PTR [ecx+1]
; Line 31
        ret     0
$LN3@tricky:
; Line 26
        mov     eax, 4
; Line 31
        ret     0
?tricky@@YAHH@Z ENDP                                    ; tricky

【讨论】:

  • 在这种情况下我不太确定。 PowerPC 似乎没有整数条件移动指令。
  • 如果编译器还没有使用最快的方法?我同意第二行,但是如果您需要的超出编译器可以为您生成的内容,该怎么办?
  • 模拟器是“简短而简单”模式的少数反例之一,在这种模式下,小的性能优势(如果存在)可以很快得到回报。
  • @Mysticial:宿主平台有没有这些指令很重要,仿真平台与它无关。
  • 对...我误读了部分问题。我以为它是在 PowerPC 上本地运行的。
【解决方案2】:

和往常一样,很多答案已经近似“不”了 :) 你想要破解吗?你会得到的。然后随意使用或不使用它认为合适。

您可以使用该映射到 -1、0 和 1 (sign),然后执行以下操作:

return 7 & (0x241 >> ((sign(x) + 1) * 4));

这本质上是使用一个很小的查找表。

或者“天真的bithack”:

int y = ((x >> 31) & 1) | ((-x >> 31) & 2)
return (~(-y >> 31) & 4) | y;

第一行将x &lt; 0 映射为1,x &gt; 0 映射为2,x == 0 映射为0。然后第二行将y == 0 映射为4,y != 0 映射为y。


当然,它有一个偷偷摸摸的边缘情况 x = 0x80000000 映射到 3。哎呀。好吧,让我们解决这个问题:

int y = ((x >> 31) & 1) | ((-x >> 31) & 2)
y &= 1 | ~(y << 1);  // remove the 2 if odd
return (~(-y >> 31) & 4) | y;

【讨论】:

  • 最好写一些单元测试。之后,检查sign(x) 的实现以确保它没有任何分支。使用分析器确保这实际上更快。
  • @BenVoigt:是的,我相信你应该测试你的代码是不言而喻的。
  • @jalf:嗯,当我发表评论时,表达式是7 &amp; (0x241 &gt;&gt; sign(x)),我认为在每种情况下都会给出错误的结果。
  • @BenVoigt 是的,我不小心“输入了制表符”,即使帖子还没有接近完成。我犯的唯一实际错误是忘记了 int.MinValue 边缘情况。
  • 添加缺少的分号后,最终版本可以工作,但是非常非常慢。
【解决方案3】:

首先,如果要在(几乎)每条指令之后更新此变量,显而易见的建议是:

不要

只有在后续指令需要它的值时才更新它。在其他任何时候,都没有必要更新它。

但是无论如何,当我们更新它时,我们想要的是这种行为:

R < 0  => CR0 == 0b001 
R > 0  => CR0 == 0b010
R == 0 => CR0 == 0b100

理想情况下,我们根本不需要分支。这是一种可能的方法:

  1. 将 CR0 设置为值 1。 (如果您真的想要速度,请调查是否可以在不从内存中获取常量的情况下完成此操作。即使您必须为此花费一些指令,这也可能是值得的)
  2. 如果 R >= 0,则左移一位。
  3. 如果 R == 0,则左移一位

第 2 步和第 3 步可以转换以消除“如果”部分

CR0 <<= (R >= 0);
CR0 <<= (R == 0);

这样更快吗?我不知道。与往常一样,当您关心性能时,您需要衡量、衡量、衡量。

但是,我可以看到这种方法的几个优点:

  1. 我们完全避免使用分支
  2. 我们避免内存加载/存储。
  3. 我们依赖的指令(位移和比较)应该具有低延迟,例如,乘法并非总是如此。

缺点是我们在所有三行之间都有一个依赖链:每一行都修改 CR0,然后在下一行中使用它。这在一定程度上限制了指令级并行性。

为了最小化这个依赖链,我们可以这样做:

CR0 <<= ((R >= 0) + (R == 0));

所以我们只需要在初始化之后修改 CR0 一次。

或者,在一行中完成所有操作:

CR0 = 1 << ((R >= 0) + (R == 0));

当然,这个主题有很多可能的变体,所以继续尝试吧。

【讨论】:

  • 实际上CR0 是一个变量,因此如果CR0 在内存中(这是一个解释器),您就不会避免内存加载和存储。不过,最好将这些作业合并为一个,例如CR0 = 1 &lt;&lt; (R &gt;= 0) &lt;&lt; (R == 0); +1。
  • @SethCarnegie:可以保存在寄存器中的变量。当然,它必须在某个时候加载到该寄存器中,但我的代码不必这样做。如果它已经在一个寄存器中(并且作为一个经常修改的变量,它很可能是),那么我们不必加载或存储它。
  • 如果您要进行优化,一个作业通常比两个要好。另外,为什么+&lt;&lt; 更有效?
  • 抱歉,我误读了您的建议(因此删除了我的评论)。我希望+&lt;&lt; 具有相同的延迟(无需查找),所以我认为没有一个比另一个更有效。请注意,将1 移动到该单行并没有真正改变任何东西。编译器的工作量相同(从某处获取常量 1,然后对其进行移位)。但在这个层面上,“作业”并不真正存在。任何合理的编译器都可以在不同的时间用不同的寄存器来表示变量。
  • 如果你修复了分组这工作,需要1 &lt;&lt; ((R &gt;= 0) + (R == 0))
【解决方案4】:

没有优化的gcc

        movl    %eax, 24(%esp)  ; eax has result of reading n
        cmpl    $0, 24(%esp)
        jns     .L2
        movl    $1, 28(%esp)
        jmp     .L3
.L2:
        cmpl    $0, 24(%esp)
        jle     .L4
        movl    $2, 28(%esp)
        jmp     .L3
.L4:
        movl    $4, 28(%esp)
.L3:

使用-O2:

        movl    $1, %edx       ; edx = 1
        cmpl    $0, %eax
        jl      .L2            ; n < 0
        cmpl    $1, %eax       ; n < 1
        sbbl    %edx, %edx     ; edx = 0 or -1
        andl    $2, %edx       ; now 0 or 2
        addl    $2, %edx       ; now 2 or 4
.L2:
        movl    %edx, 4(%esp)

我认为你不可能做得更好

【讨论】:

  • 首先,实际上发布反汇编真的很好。当尝试在这个级别上进行优化时,这绝对是唯一合理的起点。但其次,GCC 代码中有一个分支,我怀疑你可以通过消除它来加快速度。毕竟,这不仅与指令的数量有关,还与 CPU 执行它们的方式有关。 :)
  • 速度的限制是内存操作的数量。除了阅读指令之外,优化版本中唯一的内存操作是一个存储到堆栈。
  • 不是只是内存操作的次数。有很多因素在起作用。你说得对,内存操作往往占主导地位,但在他们缺席的情况下,其他因素可能很重要。我的直觉是无分支实现会更快(并且指令数量也差不多,如果不是更少的话),但显然,这需要测试。据我所知,GCC 的代码可能更快
  • @jalf 我试着编译你的代码。即使在 -O3 上 gcc 也不能很好地减少操作数量,所以它最终是 17 条指令,没有分支,只有一个商店。
  • 很好奇。其他人报告说我的答案是 9 条指令(我没有尝试自己编译和反汇编)
【解决方案5】:

以下是我的尝试。

int cro = 4 >> (((n > 0) - (n < 0)) % 3 + (n < 0)*3);

【讨论】:

    【解决方案6】:

    当我的电脑崩溃时,我正在处理这个。

    int cr0 = (-(n | n-1) >> 31) & 6;
    cr0 |= (n >> 31) & 5;
    cr0 ^= 4;
    

    这是生成的程序集(用于 Intel x86):

    PUBLIC  ?tricky@@YAHH@Z                                 ; tricky
    ; Function compile flags: /Ogtpy
    _TEXT   SEGMENT
    _n$ = 8                                                 ; size = 4
    ?tricky@@YAHH@Z PROC                                    ; tricky
    ; Line 18
            mov     ecx, DWORD PTR _n$[esp-4]
            lea     eax, DWORD PTR [ecx-1]
            or      eax, ecx
            neg     eax
            sar     eax, 31                                 ; 0000001fH
    ; Line 19
            sar     ecx, 31                                 ; 0000001fH
            and     eax, 6
            and     ecx, 5
            or      eax, ecx
    ; Line 20
            xor     eax, 4
    ; Line 22
            ret     0
    ?tricky@@YAHH@Z ENDP                                    ; tricky
    

    还有一个完整的详尽测试,也相当适合基准测试:

    #include <limits.h>
    
    int direct(int n)
    {
        int cr0;
        if (n < 0)
            cr0 = 1;
        else if (n > 0)
            cr0 = 2;
        else
            cr0 = 4;
        return cr0;
    }
    
    const int shift_count = sizeof(int) * CHAR_BIT - 1;
    int tricky(int n)
    {
        int cr0 = (-(n | n-1) >> shift_count) & 6;
        cr0 |= (n >> shift_count) & 5;
        cr0 ^= 4;
        return cr0;
    }
    
    #include <iostream>
    #include <iomanip>
    int main(void)
    {
        int i = 0;
        do {
            if (direct(i) != tricky(i)) {
                std::cerr << std::hex << i << std::endl;
                return i;
            }
        } while (++i);
        return 0;
    }
    

    【讨论】:

    • +1 由于您似乎对所有建议的解决方案进行了基准测试,也许您可​​以发布一些结果(至少对于有效的解决方案)?在进行基准测试时,如何对输入位进行一些随机混洗以分散分支预测器的注意力并使其成为更“真实”的测试?
    • 如果要用于基准测试,测试不应该更加随机吗?这样,分支版本看起来会比实际要好得多。
    • @harold:这绝对不是一个理想的基准,但它比仅仅计算汇编指令更能说明速度。
    • 确实如此。 :) 创建准确的基准非常困难。只要您了解它的局限性,一个简单的基准测试就可以了。
    • 理想情况下,您还想了解输入的实际分布情况。可能 50% 正面,25% 零,25% 负面会比统一更好。
    【解决方案7】:

    下面的表达式有点神秘,但并不过分,它看起来是编译器可以很容易优化的东西:

    cr0 = 4 >> ((2 * (n < 0)) + (n > 0));
    

    这是 x86 目标的 GCC 4.6.1 使用 -O2 编译成的内容:

    xor ecx, ecx
    mov eax, edx
    sar eax, 31
    and eax, 2
    test    edx, edx
    setg    cl
    add ecx, eax
    mov eax, 4
    sar eax, cl
    

    /Ox 的VC 2010 看起来非常相似:

    xor ecx, ecx
    test eax, eax
    sets cl
    xor edx, edx
    test eax, eax
    setg dl
    mov eax, 4
    lea ecx, DWORD PTR [edx+ecx*2]
    sar eax, cl
    

    使用if 测试的版本编译为使用这些编译器中的任何一个跳转的程序集。当然,除非您实际检查输出,否则您永远无法真正确定任何特定编译器将如何处理您选择的任何特定代码位。我的表达方式非常神秘,除非它真的是对性能至关重要的代码,否则我可能仍会使用if 语句版本。由于您需要经常设置 CR0 寄存器,因此我认为这个表达式是否有帮助可能值得衡量。

    【讨论】:

      【解决方案8】:

      对于一种完全不可移植的方法,我想知道这是否对速度有任何好处:

      void func(signed n, signed& cr0) {
          cr0 = 1 << (!(unsigned(n)>>31)+(n==0));
      }
      
      mov         ecx,eax  ;with MSVC10, all optimizations except inlining on.
      shr         ecx,1Fh  
      not         ecx  
      and         ecx,1  
      xor         edx,edx  
      test        eax,eax  
      sete        dl  
      mov         eax,1  
      add         ecx,edx  
      shl         eax,cl  
      mov         ecx,dword ptr [cr0]  
      mov         dword ptr [ecx],eax  
      

      与我机器上的代码相比:

      test        eax,eax            ; if (n < 0)
      jns         func+0Bh (401B1Bh)  
      mov         dword ptr [ecx],1  ; cr0 = 1;
      ret                            ; cr0 = 2; else cr0 = 4; }
      xor         edx,edx            ; else if (n > 0)
      test        eax,eax  
      setle       dl  
      lea         edx,[edx+edx+2]  
      mov         dword ptr [ecx],edx ; cr0 = 2; else cr0 = 4; }
      ret  
      

      我对组装一无所知,所以我不能确定这是否有任何好处(或者即使我的有任何跳转。我也看不到以 j 开头的说明)。与往常一样,(正如其他人所说的一百万次)简介。

      我怀疑这比说 Jalf 或 Ben 的更快,但我没有看到任何利用 x86 上所有负数都设置了特定位的事实,我想我会扔掉一个。

      [EDIT]BenVoigt 建议 cr0 = 4 &gt;&gt; ((n != 0) + (unsigned(n) &gt;&gt; 31)); 删除逻辑否定,我的测试表明这是一个巨大的改进。

      【讨论】:

      • 我认为2 &lt;&lt; ((n == 0) - (unsigned(n) &gt;&gt; 31)) 会好一点,因为它不需要逻辑否定。或者(2 &lt;&lt; (n == 0)) - (unsigned(n) &gt;&gt; 31)
      • 甚至+ (n &gt;&gt; 31),使用算术移位而不是逻辑移位。不过,真的不认为会有任何性能优势。
      • @BenVoigt:在我的大多数测试中,您的第一条评论中的代码在此页面上的表现最好,但在我最近一轮的测试中,得到的结果不正确。
      • @BenVoigt:也许正确的代码应该是 cr0 = 4 &gt;&gt; ((n != 0) + (unsigned(n) &gt;&gt; 31)); 它与您的建议接近,并且优于其他所有代码。
      • 嗯,是的,我想传递负班次计数是未定义的行为,您的最新版本修复了这个问题。所以我建议把它放在你的答案中。虽然我的第二个变体(在班次之外进行减法)不应该受到 UB 的影响。
      猜你喜欢
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 2019-10-15
      • 2022-01-17
      • 1970-01-01
      • 2017-01-26
      • 2019-09-29
      • 2012-05-12
      相关资源
      最近更新 更多