有没有一种方法可以在不使用大量分支语句的情况下为无符号整数编写 qsort 比较函数?

Posted

技术标签:

【中文标题】有没有一种方法可以在不使用大量分支语句的情况下为无符号整数编写 qsort 比较函数?【英文标题】:Is there a way to code a qsort comparison function for unsigned integers without using lots of branching statements? 【发布时间】:2015-08-24 12:09:06 【问题描述】:

我为其中包含一些无符号字段的结构编写了一个(qsort 兼容的)比较函数:

typedef struct 
   int      a;
   unsigned b;
 T;

int cmp(T t1, T t2)

   // Decreasing order in "a"
   if (t1.a < t2.a) return +1;
   if (t1.a > t2.a) return -1;
   // Increasing order in "b"
   if (t1.b < t2.b) return -1;
   if (t1.b > t2.b) return +1;
   return 0;

有没有一种方法可以编写这个函数而不需要每个字段进行两次比较?我不能使用 t1.b - t2.b 技巧,因为无符号整数的减法会回绕。

我愿意接受使用 GCC 扩展的答案。

【问题讨论】:

@user3386109:对标题中所述问题的答案只是“是”。您可以将整个cmp 函数定义放在一行中。当然你不应该,但我不认为把它放在一条线上真的是你的目标。我建议更新您的标题以反映您的实际要求。 @KeithThompson 好的,我接受你的评论措辞。​​ ab 值是否存在已知限制,或者它们是否使用了其类型的全部可能范围? @Suma:原则上没有限制。但是有一个替代解决方案取决于具有更受限制的范围,然后我也很好奇它会是什么样子。 我完全错了,这里不需要进行任何优化:即使它们是分支编译器也足够聪明,可以删除它们。即使在您的原始代码中也没有任何Jcc,在我的情况下它生成了CMOV,并且完整的函数是无分支的 【参考方案1】:

使用更广​​泛的数学。

给定intunsigned 字段,给定平台很可能支持更广泛的整数类型,例如long long,可以将这两个字段放在一起。

int cmp(T t1, T t2)

   // An optimized compilation will not do any multiplication nor addition,
   // Just simply load `n1` high/low halves with `t1.a`, `t1.b`.
   long long n1 = t1.a * (UINT_MAX + 1LL) + t1.b;
   long long n2 = t2.a * (UINT_MAX + 1LL) + t2.b;
   return (n1 > n2) - (n1 < n2);  

如果这种方法更快 - 分析将回答选择平台的问题。

虽然这使用较少的比较,但比较使用更广泛的数学 - 可能是零和增益。

当 2x 整数宽度可用时,如 How to determine integer types that are twice the width as `int` and `unsigned`?。这行得通。为了高可移植性,请坚持使用 OP 的原始方法。

(var1 &gt; var2) - (var1 &lt; var2) 被一些人认为是无分支的。当然 OP 的原始代码可以以:

return (t1.b > t2.b) - (t1.b < t2.b);

【讨论】:

正如我对基思的回答所说:即使它是在没有任何if 编译器的情况下编写的,仍然会生成jgjl 和(可能)je 指令来评估t2-&gt;a &gt; t1-&gt;a它们是分支(即使不太明显,所以我几乎不认为它们是无分支的,除非一个非常特定的架构有专门的指令可以在没有分支的情况下执行它 - 是的 cmov 可能会有所帮助,但我没有看到任何编译器生成它)但要评估完整的表达式,有 2/3 到 4/5 个分支。 您可能想在答案中解释即使在 x86 平台上,分支的数量仍然会减少,尽管使用两条指令实现算术(加法、比较),因为它们使用进位标志来处理它们之间的溢出而不是分支。也许添加一些编译器输出的反汇编会有所帮助? @Adriano Repetti 同意t2-&gt;a &gt; t1-&gt;a 可以评估为分支的代码。同意各种处理器具有可以生成无分支代码的指令。对于分支(流水线)敏感的处理器比无分支的比较指令比简单的(例如嵌入式)更有可能具有。 @chux 是的,我错误地假设(没有检查)编译器不会优化这种特殊情况。我们可以合理地假设 几乎 每个半现代 CPU 都有相当于 SETcc 的东西(如果 CMOV 不可用)但是是的......其他人仍然有(如果需要)在没有分支的情况下重写它(IMO用减法和一些技巧)。 @Adriano Repetti 关于现代 CPU 的注意事项:嵌入式处理器 - 通常具有减少的指令集 - 在 2015 年以每年数十亿的速度生产。C 在此类设备中很受欢迎,并且没有 SETcc 等价物并不少见.最后 - 是否有分支不是真正的问题 - 而是停止解决真正的问题:速度、功率、成本。【参考方案2】:

两个值之间的任何关系比较只能产生两个结果之一。 qsort 比较函数需要三个不同的结果,因此单个比较无法完成这项工作。 (Perl 有一个 &lt;=&gt; 运算符,可以完全满足您的要求,但它在 C 中不可用。)

您需要评估 1 或 2 次比较以比较 a 值,再加上 1 或 2 次比较以比较 b 值,总共最多 4 次比较。您可以编写更简洁的代码来执行它们,但它基本上等同于您已经编写的代码。

这是一个可能有点棘手的解决方案:

int cmp(T t1, T t2) 
    return ((t2.a > t1.a) - (t2.a < t1.a)) || ((t2.b > t1.b) - (t2.b < t1.b));

我会这样拆分:

int cmp(T t1, T t2) 
    return ((t2.a > t1.a) - (t2.a < t1.a))
           ||
           ((t2.b > t1.b) - (t2.b < t1.b));

如果t1.at2.a 相等,则表达式的前半部分产生0,如果t1.a &lt; t2.a,则产生-1,如果t1.a &gt; t2.a,则产生+1。这取决于关系运算符总是返回01

如果前半部分是-1+1,则|| 短路,我们就完成了;否则,它会继续比较 t1.bt2.b

这实际上可能比您问题中的代码效率稍,因为它总是同时评估t2.a &gt; t1.at2.a &lt; t1.a

顺便说一句,这不是一个有效的qsort 比较函数。这样的函数必须接受两个const void* 参数。可以这样写:

int cmp(const void *arg1, const void *arg2) 
    const T *t1 = arg1;
    const T *t2 = arg2;
    return ((t2->a > t1->a) - (t2->a < t1->a))
           ||
           ((t2->b > t1->b) - (t2->b < t1->b));

【讨论】:

如果我错了请纠正我,但即使它是在没有任何if 编译器的情况下编写的,仍然会生成jgjl 和(可能)je 指令来评估t2-&gt;a &gt; t1-&gt;a它们是分支(即使不太明显),但要评估完整的表达式,有 2/3 到 4/5 个分支。 “所以单一的比较无法完成这项工作”虽然这是真的,但它仍然不能证明没有(或更少)比较是没有办法的。算术运算能够产生两个以上的结果,并且算术运算的一些巧妙组合可能会产生所需的结果。 @KeithThompson 我必须纠正自己:即使它们是分支编译器也足够聪明,可以删除它们。我猜是因为布尔减法,它会生成condition ? ~0 : 0(它使用SETLESETGE)。即使是 OP 的代码也会生成 CMOVwithout jmp... @AdrianoRepetti:您正在对目标系统做出一些假设。 SETLESETGECMOV 指令甚至可能不存在。 @KeithThompson 当然。我假设编译器在这种情况下不会使用它们(即使支持)。显然,如果它们不可用,那么 this 方法仍然会产生许多跳转(请参阅我已删除答案中的反汇编)。【参考方案3】:

假设输入值的范围是有限的,a 在INT_MIN/2 ..INT_MAX/2 范围内,b 在0 ..UINT_MAX/2 范围内,并假设第二个补码整数算术,您可以用一个实现比较函数仅分支:

int cmp(T t1, T t2)

   // Decreasing order in "a"
   int d = t2.a - t1.a;
   if (d) return d;

   // Increasing order in "b"
   return (int)(t1.b - t2.b);

Visual Studio 2013 反汇编:

  int d = t2.a - t1.a;
00FC1000  mov         eax,dword ptr [esp+0Ch]  
00FC1004  sub         eax,dword ptr [esp+4]  
  if (d) return d;
00FC1008  jne         cmp+12h (0FC1012h)  

  // Increasing order in "b"
  return (int)(t1.b - t2.b);
00FC100A  mov         eax,dword ptr [esp+8]  
00FC100E  sub         eax,dword ptr [esp+10h]  

00FC1012  ret  

【讨论】:

@chux 这就是为什么我明确写了关于限制输入值范围的假设,这样就不会出现溢出。 我现在看到了这个条件 - 在那个限制内让这个答案没问题。【参考方案4】:

这不会减少编译条件的数量,但会减少执行的条件数量:

if(t1.a != t2.a)
    return t1.a < t2.a ? -1 : 1;
if(t1.b != t2.b)
    return t1.b < t2.b ? -1 : 1;
return 0;

如果两个a 成员相等,则不再对它们进行比较。对于 N 字段排序,您最多可以进行 N+1 次比较,而原始代码则需要 2N 次比较。

【讨论】:

【参考方案5】:

当您可能忽略此答案时:如果编译器将为 Keit 的答案甚至原始 OP 的代码生成无分支代码(Keit 的代码被视为 condition ? ~0 : 0 和 OP 的代码),那么所有关于分支的推理都是无用的一个会生成CMOV)。

当然,您可以针对没有SETccCMOVcc 指令的CPU。在这种情况下是的,我会避免使用减法的分支(如果可能的话)(做一个小的性能测试来确定long longdouble 之间哪个更快)。如果您的其他先决条件和范围限制不是问题,您甚至可以使用 plain 整数数学。


当您不应该忽略此答案时:如果您的目标 CPU 没有 CMOVcc 和/或 SETcc(或等效)指令。

您不需要完全返回+1和-1,任何正值或负值都可以正常工作(假设您要优化此函数以减少跳转,数学运算相对便宜)。如果我们可以对特定于平台的有符号整数实现(2 的补码)和无符号/有符号转换做出假设,那么删除分支的第一步(引入 便宜 减法)是:

int cmp(T t1, T t2) 
    if (t2.a != t1.a)
        return t2.a - t1.a;

    if (t1.b < t2.b)
        return -1;

    return (int)(t1.b - t2.b);

要删除第二个分支,我们可以依赖unsigned(不是signed)整数数学的明确行为:t1.b - t2.b 将换行(当t1.b 小于t2.b 时)然后(int)(t1.b - t2.b)将是一个负数,第二个if 可以省略。有了这个假设,代码可以是:

int cmp(T t1, T t2) 
    if (t2.a != t1.a)
        return t2.a - t1.a;

    return (int)(t1.b - t2.b);

注意 1:第二个 优化 仅适用于您的情况,因为您要为 T.b 降序排序,那么这不是一般规则。

注意 2:这里您使用的是复制的结构。编译器可能优化您的代码以删除 T 副本,但它不是 必需 这样做然后 IMO 你应该检查生成的代码或使用指针 T* cmp 参数(如果可能的话)。扩展副本将消除我们在这里可能进行的任何其他微优化。

说明

我看到需要一些解释,如果我们试图减少(以避免 AFAIK 是不可能的)分支,那么我们必须同时考虑 visibleinvisible 分支(否则甚至没有理由开始这种可能的微优化)。

分支机构 每个条件(如t2-&gt;b &gt; t1-&gt;b)都使用分支编译。让我从 Keith 的回答中挑选出不错的代码:

((t2.a > t1.a) - (t2.a < t1.a))
||
((t2.b > t1.b) - (t2.b < t1.b))

对于t2.a &gt; t1.a,编译器会生成这个:

008413FE  mov  eax,dword ptr [t2]     ; Load t2.a in EAX
00841401  cmp  eax,dword ptr [t1]     ; Compare EAX with t1.a
00841404  jle  cmp+32h (0841412h)     ; Go to set result to not true
00841406  mov  dword ptr [ebp-0C4h],1 ; Result for t2.a > t1.a is 1 (true)
00841410  jmp  cmp+3Ch (084141Ch)     ; Go to perform t2.a < t1.a
00841412  mov  dword ptr [ebp-0C4h],0 ; Result for t2.a > t1.a is 0 (false)

为第二部分生成了类似的代码t2.a &lt; t1.a。然后在|| ((t2.b &gt; t1.b) - (t2.b &lt; t1.b)) 的右侧重复相同的代码。让我们计算分支:最快的代码路径有五个分支(第一部分为jlejmp,第二部分为jgejmp)加上一个额外的短路跳转|| (总共 六个分支)。最慢的还有更多。它比原来的实现更糟糕,有很多 ifs。

为了比较,让我们看看用减法比较会生成什么:

; if (t2.a != t1.a)
00F313FE  mov  eax,dword ptr [t2] ; Load t2.a
00F31401  cmp  eax,dword ptr [t1] ; Compare with t1.a
00F31404  je   cmp+2Eh (0F3140Eh) ; If they are equal then go work with T.b

; return t2.a - t1.a;
00F31406  mov  eax,dword ptr [t2]  ; Load t2.a
00F31409  sub  eax,dword ptr [t1]  ; Subtract t1.a
00F3140C  jmp  cmp+34h (0F31414h)  ; Finished

这是我们最好的代码路径,只有两个分支。让我们看第二部分:

; return (int)(t1.b - t2.b);
00F3140E  mov  eax,dword ptr [ebp+0Ch] ; Load t1.b
00F31411  sub  eax,dword ptr [ebp+14h] ; Subtract t2.b

这里没有更多的分支。我们最快和最慢的代码路径总是有相同数量的分支。

减法 为什么减法有效?让我们看看 simple 值和 Suma 在 cmets 中挑选的一些边缘情况。比方说:

t1.a = 1;
t2.a = 10;

t1.b = 10;
t2.b = 1;

那么我们有:

t2.a - t1.a == 10 - 1 == 9。原始代码中要求的正数 (if (t1.a &lt; t2.a) return +1;)。相反的情况是微不足道的。这里我们假设有符号整数数学会换行。

(int)(t1.b - t2.b) == 10 - 1 == 9。需要的正数(T.aT.b 的逆序)。由于边缘情况,这需要更多解释。想象一下t1.bUINT_MAXt2.b0t1.b - t2.b 仍然是 UINT_MAX 并且它必须转换为 int,它的位模式是 0xFFFFFFFF,对于 signed int 来说正是 -1 的位模式。结果再次正确。请注意,这里我们假设了两件重要的事情:有符号数用 2 的补码表示,无符号数到有符号数的转换只是用新的给定类型重新解释原始内存值(不进行显式计算)。

正如 Suma 所指出的,当数字很大时会出现问题,如果您想要完整的 intunsigned int 范围,那么您可以简单地将它们转换为 double

int cmp(T t1, T t2) 
    if (t2.a != t1.a)
        return (int)((double)t2.a - t1.a);

    return (int)((double)t1.b - t2.b);

生成的汇编代码的摘录:

; return (double)t2.a - (double)t1.a;
01361926  cvtsi2sd  xmm0,dword ptr [t2]  ; Load t2.a
0136192B  cvtsi2sd  xmm1,dword ptr [t1]  ; Load t1.a
01361930  subsd     xmm0,xmm1            ; Subtract t1.a to t2.a
01361934  cvttsd2si eax,xmm0             ; Convert back
01361938  jmp       cmp+88h (01361988h)

在这种情况下,您唯一不能使用的元组是INT_MIN 用于t1.a 以及INT_MAX 用于t2.a

【讨论】:

标题说:不用减法? 你确定这行得通吗? “b”字段是一个无符号整数。 第二版反例:t1.b = UINT_MAX, t2.b = 0 在第二个版本中,即使有符号的 int 比较似乎也不适用于整个 int 范围:假设 t2.a = INT_MAX 和 t2.b = INT_MIN。 我承认我不明白。如果1 - 0 &gt; 0 可以,INT_MAX - INT_MIN &lt; 0 怎么会可以?如果1 - 0 &gt; 0 可以,(int)(UINT_MAX - 0) &lt; 0 怎么会可以?你能解释一下吗?

以上是关于有没有一种方法可以在不使用大量分支语句的情况下为无符号整数编写 qsort 比较函数?的主要内容,如果未能解决你的问题,请参考以下文章

有啥方法可以在不使用 IDFA 的情况下为设备生成唯一 ID? [复制]

如何在不加载图像的情况下为文件系统上的现有图像写入或修改 EXIF 数据?

是否可以在不循环的情况下为 iSeries 表中的每一行生成唯一的数值?

有啥方法可以在不使用自适应付款的情况下为第三方处理付款?

如何在不涉及 Android 客户端应用程序的开发人员的情况下为某些网站构建公共/私有 API [关闭]

是否有另一种方法可以在不使用 if 语句的情况下有条件地增加数组值? C++