将范围检查转换为无符号整数类型比检查负值更有效吗?

81

我在.NET的List源码中偶然发现了这段代码:

// Following trick can reduce the range check by one
if ((uint) index >= (uint)_size) {
  ThrowHelper.ThrowArgumentOutOfRangeException();
}

显然,这比if (index < 0 || index >= _size)更有效率 (?)。

对于这个技巧背后的原理,我很好奇。单一分支指令真的比两个转换为uint更昂贵吗?还是有其他优化措施可以使此代码比额外的数值比较更快?

针对问题的本质:是的,这是微小的优化,不,我没有打算在我的代码中随处使用它 - 我只是好奇;)


4
在这种情况下,只需几行代码便可满足好奇心。测试一下吧。 - Sam Axe
3
一项测试只能确认类型转换是否更快(如果是的话),而无法解释为什么会更快。 - enzi
7
两次将其转换为无符号整数类型是免费的——相同的比特模式将存在于同一个寄存器中(或者在内存中,但如果你很幸运的话,会在一个寄存器中)。 - Damien_The_Unbeliever
2
相关文章(其中还包括从intuint的类型转换):http://www.codeproject.com/Articles/8052/Type-casting-impact-over-execution-performance-in - Tim Schmelter
5
用这种编码风格很容易引入安全漏洞。事实上,在C++中,这种情况经常发生,这是C#高度不鼓励这种编码风格的主要原因之一。上述代码之所以能够正常工作,仅仅是因为开发人员知道 _size 永远不会是负数。 - BlueRaja - Danny Pflughoeft
显示剩余8条评论
7个回答

59

MS Partition I第12.1节(支持的数据类型):

有符号整数类型(int8、int16、int32、int64和本机int)及其对应的无符号整数类型(unsigned int8、unsigned int16、unsigned int32、unsigned int64和本机unsigned int) 仅在解释整数的位时存在差异。对于那些将无符号整数与有符号整数区别对待的操作(例如,比较或算术溢出),有单独的指令来将整数视为无符号的(例如,cgt.un和add.ovf.un)。

也就是说,从intuint转换只是一种简单的记录方式——从现在开始,在堆栈 / 寄存器中的值现在被认为是无符号整数而不是有符号整数。

因此,一旦代码被JIT编译,这两个转换应该是“免费”的,然后可以执行无符号比较操作。


4
我有点惊讶编译器没有自动实现这个优化。(或者它已经实现了吗?) - Ilmari Karonen
4
@IlmariKaronen 怎么可能呢?它不知道这些值的含义。 C#和.NET都有非常明确定义(不像C ++),而这正是一种通常情况下相当不安全的“优化”方式。更不用说JIT编译器没有足够的时间来寻找这些类型的“优化”,而C#编译器本身也没有进行过很多优化。除非您可以证明性能有益处(在您真正关心性能的地方),否则请编写清晰的代码。 - Luaan
2
@Luaan:啊,我明白了……问题大概是编译器不够聪明,无法知道“_size”不能为负数,因此不能安全地应用优化(因为只有在“(int)_size >= 0”时才有效)。 - Ilmari Karonen
1
在代码被JIT编译之前,这两个转换是免费的;由于它们只被用作本地变量,所以差别在于bltblt.sblt.unblt.un.s之间,C#生成的CIL根本不需要涉及任何实际的转换。 - Jon Hanna
2
@Luaan:在那种情况下,它也无法安全地执行优化,因为如果 _size > int.MaxValue,则优化也会中断。如果 _size 是非负常量,或者编译器可以从先前的代码推断出 _size 的值始终介于0和 int.MaxValue 之间(包括边界),则编译器可以执行优化。是的,现代编译器通常执行这种数据流分析,尽管显然它们可以做多少有限(因为完整问题是图灵完备的)。 - Ilmari Karonen
显示剩余6条评论

29

假设我们有:

public void TestIndex1(int index)
{
  if(index < 0 || index >= _size)
    ThrowHelper.ThrowArgumentOutOfRangeException();
}
public void TestIndex2(int index)
{
  if((uint)index >= (uint)_size)
    ThrowHelper.ThrowArgumentOutOfRangeException();
}

让我们编译这些代码并查看ILSpy:

.method public hidebysig 
    instance void TestIndex1 (
        int32 index
    ) cil managed 
{
    IL_0000: ldarg.1
    IL_0001: ldc.i4.0
    IL_0002: blt.s IL_000d
    IL_0004: ldarg.1
    IL_0005: ldarg.0
    IL_0006: ldfld int32 TempTest.TestClass::_size
    IL_000b: bge.s IL_0012
    IL_000d: call void TempTest.ThrowHelper::ThrowArgumentOutOfRangeException()
    IL_0012: ret
}

.method public hidebysig 
    instance void TestIndex2 (
        int32 index
    ) cil managed 
{
    IL_0000: ldarg.1
    IL_0001: ldarg.0
    IL_0002: ldfld int32 TempTest.TestClass::_size
    IL_0007: blt.un.s IL_000e
    IL_0009: call void TempTest.ThrowHelper::ThrowArgumentOutOfRangeException()
    IL_000e: ret
}

很容易看出第二个版本的代码更少,少了一个分支。

实际上,根本没有类型转换,只需要选择使用blt.sbge.s,或者使用blt.s.un,后者将传递的整数作为无符号处理,而前者将其视为有符号处理。

(对于不熟悉CIL的人来说,由于这是一个关于C#并且使用了CIL答案的问题,bge.sblt.sblt.s.un分别是bgebltblt.un的“短”版本。其中blt从堆栈中弹出两个值,并在将它们视为有符号值时第一个小于第二个时跳转,而blt.un从堆栈中弹出两个值,并在将它们视为无符号值时第一个小于第二个时跳转)。

这完全是微观优化,但有时微观优化是值得做的。考虑到方法体中的其余代码,这可能意味着某些东西落入即时编译器内联的限制范围内或者没有,而如果他们费心为超出范围的异常编写一个辅助程序,他们可能会尽可能确保内联发生,而额外的4个字节可能会产生重大影响。

事实上,这种内联差异很可能比减少一个分支更重要。并不是经常有必要费力确保内联发生,但像List<T>这样频繁使用的类的核心方法肯定是其中之一。


2
希望编程语言能够包含一个结构来测试变量是否在某个范围内,这样就不必猜测优化器会或不会做什么了。如果像这样的微小优化可以在某些系统上将紧密循环的速度提高一倍(如果它推动了“内联”决策阈值,则完全有可能),那么它可能非常值得做;然而,如果它在任何系统上都不会提供任何真正的加速,那么应该使用更可读的形式。我发现当表达式的最可读形式可能与最快形式相当时,这让人感到恼火,但也可能不是这样。 - supercat
@supercat 我基本上同意,但这里需要更广泛的知识才能知道,因为 _size 已经保证大于 0,所以 (index < 0 || index < _size) == ((uint)index >= (uint)_size)。当然,一个能够使用代码契约作为其优化决策的编译器肯定可以做到这一点,但是即使是优化超过(移动目标的)内联限制也是一个特殊情况。 - Jon Hanna
@supercat确实,现在我想起来了,如果C#有一个像Python那样的构造,例如0 < index < _size(甚至对于C#来说也是相当合理的,因为它不会在布尔和整数类型之间隐式转换),那么这里的优化仍然是不要使用它。 - Jon Hanna
我希望编程语言中能够包含一个"(n-1)位'自然数'"变量/参数类型,它在算术表达式中的行为类似于普通有符号类型,但编译器强制断言它们不能为负。经常情况下,在无符号值上进行合理的数学运算的唯一方法是使用比它更大的有符号整数类型,这可能效率低下且不美观,因此表达只会保存自然数的变量/参数的想法会非常有帮助。 - supercat

8
假设_size是一个整数,私有于列表中,并且index是需要进行有效性测试的此函数的参数。
进一步假设_size始终>=0。
那么原始测试应该是:
if(index < 0 || index > size) throw exception

优化后的版本
if((uint)index > (uint)_size) throw exception

与前面的例子相比,此处只有一个比较操作。因为强制类型转换只是重新解释位并使得>实际上是一次无符号比较,所以不需要额外的CPU周期。

为什么它能够工作?

只要索引值大于等于0,结果就是简单/平凡的。

如果索引值小于0,那么(uint)index 将把其转换成一个非常大的数:

例如:0xFFFF在int中表示-1,在uint中表示65535,因此

(uint)-1 > (uint)x 

如果x为正数,那么

始终为真。


2
在“checked”上下文中,你会得到一个“OverflowException”。在“unchecked”上下文中,你不能依赖于结果:“转换的结果是目标类型的未指定值。” https://dev59.com/P2Eh5IYBdhLWcg3wIgjN - Tim Schmelter
@Tim,这个引用似乎是针对floatdouble转换为整数类型的情况,处理取决于溢出检查上下文,因此不适用于int->uint。 - xanatos
@xanatos:但规则是一样的,如果转换失败,则在已检查的上下文中会出现OverflowException,在未检查的上下文中使用T-MaxValue。因此,这将返回uint.Maxvalueunchecked { uint ui = (uint)-1; };。但不能保证。如果您尝试使用checked,则会在-1常量处获得编译器错误,如果使用变量,则会在运行时出现OverflowException。顺便说一句,我指的是“如果索引<0,则(uint)index将使其变成一个非常大的数字:....” - Tim Schmelter
@TimSchmelter:仅澄清一下,虽然未经检查的 (uint)-1 等于 uint.MaxValue,但未经检查的 (uint)-2 并不等于它 -- 相反,它等于 uint.MaxValue-1。两者都是"非常大"的 -- 实际上,严格大于 int.MaxValue -- 尽管如此。 - Ilmari Karonen
1
@TimSchmelter 这个强制类型转换的含义确实是定义过的,也正是这里所需要的。你引用的答案引用了 §6.2.1 的错误部分。在那个引用开始之前几段中就提到了这里的相关情况,给出了“那么源值将被视为目标类型的值”的结果。 - Jon Hanna

8
请注意,如果您的项目是 checked 而不是 unchecked ,则此技巧将无效。最好情况下,它会变慢(因为每个强制转换都需要检查是否溢出)(或者至少不会更快),最坏的情况是,如果您尝试将-1作为 index (而不是您的异常)传递,您将收到一个 OverflowException

如果您想以更“正确”和更“一定能行”的方式编写它,您应该放置一个

unchecked
{
    // test
}

围绕测试展开的全方位内容。


检查溢出通常不会减慢任何速度,考虑到溢出检查是在芯片上发生的。当然,在checked上下文中执行此操作而不是正常的unchecked时,它确实会抛出异常。但这并没有真正回答问题。 - Jon Hanna
@JonHanna 是的...但是这段话太长了,不适合作为评论,而且已经有一个很好的回答了。关于速度:如果你查看转换操作的反汇编代码(发布模式,因此是优化过的代码),你会看到cmp dword ptr [rsp+64h],0 / jl 00000000000000A2,所以转换操作会明确地比较整数是否小于0,如果是,则跳转。 - xanatos
如果有一个强制转换,因为将int强制转换为uint并存储不会在该级别引起任何异常,所以测试必须是明确的,但这里的区别可能只是jljb之间的区别。 - Jon Hanna

5

是的,这更有效率。当进行检查数组索引范围时,JIT也会使用同样的技巧。

转换和推理如下:

i >= 0 && i < array.Length 变成 (uint)i < (uint)array.Length,因为 array.Length <= int.MaxValue,所以array.Length(uint)array.Length有相同的值。如果 i 是负数,则 (uint)i > int.MaxValue,检查失败。


你能提供一个示例代码吗?因为我无法构建一个其中一个比另一个更快的示例。 - Stilgar
不确定问题出在哪里。只需将两个版本相互进行基准测试。发布模式,Ctrl-F5(无调试器)。基准测试应该每个测试大约需要1秒钟,以便所有一次性成本和变化都消失在噪声中。 - usr
我尝试了几种不同的方法,包括@nsimeonov在答案中提供的方法,但都未能获得差异。 - Stilgar
将你的代码作为一个问题发布到 Stack Overflow 上,并在这里留下链接。我会看一下。 - usr
给你这里的链接:https://dev59.com/R4nca4cB1Zd3GeqP8kRW - Stilgar

4

实际上,在现实生活中并不更快。请查看:https://dotnetfiddle.net/lZKHmn

结果表明,由于英特尔的分支预测和并行执行,更明显和易读的代码实际上运行得更快...

以下是代码:

using System;
using System.Diagnostics;

public class Program
{


    const int MAX_ITERATIONS = 10000000;
    const int MAX_SIZE = 1000;


    public static void Main()
    {

            var timer = new Stopwatch();


            Random rand = new Random();
            long InRange = 0;
            long OutOfRange = 0;

            timer.Start();
            for ( int i = 0; i < MAX_ITERATIONS; i++ ) {
                var x = rand.Next( MAX_SIZE * 2 ) - MAX_SIZE;
                if ( x < 0 || x > MAX_SIZE ) {
                    OutOfRange++;
                } else {
                    InRange++;
                }
            }
            timer.Stop();

            Console.WriteLine( "Comparision 1: " + InRange + "/" + OutOfRange + ", elapsed: " + timer.ElapsedMilliseconds + "ms" );


            rand = new Random();
            InRange = 0;
            OutOfRange = 0;

            timer.Reset();
            timer.Start();
            for ( int i = 0; i < MAX_ITERATIONS; i++ ) {
                var x = rand.Next( MAX_SIZE * 2 ) - MAX_SIZE;
                if ( (uint) x > (uint) MAX_SIZE ) {
                    OutOfRange++;
                } else {
                    InRange++;
                }
            }
            timer.Stop();

            Console.WriteLine( "Comparision 2: " + InRange + "/" + OutOfRange + ", elapsed: " + timer.ElapsedMilliseconds + "ms" );

    }
}

你没有将问题中的代码与其进行比较。请参考Jon Hanna的答案——在许多情况下,大小很重要,而你完全忽略了这一点。 - Ben Voigt
我不明白。如果我将实际检查作为单独的函数,会有影响吗?我的观点是,由于CPU分支预测,第一种情况的执行速度更快。此外,在进一步尝试后,发现检查更多“超出范围”的值时,第一种情况的效果更好,但如果99%在范围内,则第二种情况似乎稍微快一些。最后,为了增加乐趣,如果您以发布模式编译,则第二种情况更快 :-) - nsimeonov
等等,你报告了时间结果,而没有开启优化吗? - Ben Voigt
我承认有罪。一开始我使用的是dotnetfiddle.com,但它没有任何关于优化的选项。结果让我感到惊讶。后来我尝试了mono并得到了相同的结果。在玩了一会儿之后,我得到了非常有趣的统计数据。Jon Hanna实际上提出了一个很好的观点。差异可能在于大小而不是速度,因为多出几个指令可能导致调用方法被内联或不被内联,这反过来可能会产生很大的差异。 - nsimeonov

1

在探索英特尔处理器时,我发现执行时间没有任何差异,可能是由于有多个整数执行单元。

但是,在没有分支预测或整数执行单元的16MHZ实时微处理器上进行此操作时,存在明显差异。

较慢代码的100万次迭代需要1761毫秒。

int slower(char *a, long i)
{
  if (i < 0 || i >= 10)
    return 0;

  return a[i];
}

100万次迭代,更快的代码只需要1635毫秒

int faster(char *a, long i)
{
  if ((unsigned int)i >= 10)
    return 0;
  return a[i];
}

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接