Является ли более эффективным выполнение проверки диапазона путем литья в uint вместо проверки на отрицательные значения?

Я наткнулся на этот фрагмент кода в .NET Исходный код:

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

По-видимому, это более эффективно (?), чем if (index < 0 || index >= _size)

Мне любопытно, что такое трюк. Является ли одна инструкция перехода действительно более дорогой, чем два преобразования на uint? Или существует какая-то другая оптимизация, которая сделает этот код быстрее, чем дополнительное числовое сравнение?

Обратиться к слону в комнате: да, это микро-оптимизация, нет, я не собираюсь использовать это везде в своем коде - мне просто интересно;)

Ответ 1

Из MS Partition I, раздел 12.1 (Поддерживаемые типы данных):

Подписанные целочисленные типы (int8, int16, int32, int64 и native int) и их соответствующие беззнаковые целочисленные типы (unsigned int8, unsigned int16, unsigned int32, unsigned int64 и собственный unsigned int) отличаются только тем, как интерпретируются биты целого. Для тех операций, в которых целое число без знака обрабатывается иначе, чем целое число со знаком (например, в сравнении или арифметика с переполнением) существуют отдельные инструкции для обработки целого числа как unsigned (например, cgt.un и add.ovf.un).

То есть преобразование из int в uint является просто вопросом бухгалтерского учета - теперь значение в стеке/в регистре теперь известно как unsigned int, а не int.

Таким образом, два преобразования должны быть "свободными" после того, как код JITted, а затем можно выполнить операцию сравнения без знака.

Ответ 2

Скажем, мы имеем:

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.s и bge.s или использовать blt.s.un, где последний обрабатывает целые числа, переданные как unsigned, в то время как первый рассматривает их как подписанные.

(Примечание для тех, кто не знаком с CIL, так как это вопрос С# с ответом CIL, bge.s, blt.s и blt.s.un являются "короткими" версиями bge, blt и blt.un соответственно. blt извлекает два значения из стека и ветвей, если первое меньше второго, рассматривая их как знаковые значения, а blt.un выталкивает два значения стека и ветвей, если первое меньше второго, когда рассматривая их как неподписанные значения).

Это совершенно микроопт, но есть моменты, когда стоит делать микроопт. Рассмотрим далее, что с остальной частью кода в теле метода это может означать разницу между тем, что попадает внутрь пределов колебаний для вложения или нет, и если они беспокоятся о том, чтобы иметь помощника для исключения исключений из диапазона, они вероятно, попытка обеспечить inlining происходит, если это вообще возможно, и дополнительные 4 байта могут иметь значение.

В самом деле, вполне вероятно, что эта разница в различии будет гораздо более крупной сделкой, чем сокращение одной ветки. Не так много раз, когда вы идете на своем пути, чтобы обеспечить, что инкрустация происходит, стоит того, но основным методом класса такого интенсивного использования, как List<T>, несомненно, будет один из них.

Ответ 3

Предполагая, что _size является целым числом, закрытым для списка, и index является аргументом этой функции, действительность которой должна быть проверена.

Предполагая далее, что _size всегдa >= 0.

Тогда исходным тестом было бы следующее:

if(index < 0 || index > size) throw exception

Оптимизированная версия

if((uint)index > (uint)_size) throw exception

имеет одно сравнение (как показано в двух предыдущих примерах). Поскольку приведение просто переинтерпретирует биты и делает > фактически беззнаковым сравнением, для него не используются дополнительные циклы ЦП.

Почему это работает?

Результаты просты/тривиальны до тех пор, пока index >= 0.

Если индекс < 0 (uint)index превратит его в очень большое число:

Пример: 0xFFFF равно -1 как int, но 65535 в качестве uint, таким образом

(uint)-1 > (uint)x 

всегда истинно, если x было положительным.

Ответ 4

Обратите внимание, что этот трюк не будет работать, если ваш проект checked вместо unchecked. В лучшем случае он будет медленнее (потому что каждый бросок должен быть проверен против переполнения) (или, по крайней мере, не быстрее), в худшем случае вы получите OverflowException, если попытаетесь передать -1 как index (вместо этого вашего исключения).

Если вы хотите записать его "правильно" и в более "уверенном порядке", вы должны положить

unchecked
{
    // test
}

вокруг теста.

Ответ 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 и проверка не выполняется.

Ответ 6

По-видимому, в реальной жизни это происходит не быстрее. Проверьте это: https://dotnetfiddle.net/lZKHmn

Оказывается, что благодаря предсказанию Intel и параллельному исполнению более очевидный и читаемый код работает быстрее...

Здесь код:

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" );

    }
}

Ответ 7

При изучении этого на процессоре Intel я не обнаружил различий во времени выполнения, возможно, из-за нескольких целых исполнительных блоков.

Но при выполнении этого на микропроцессоре 16MHZ в реальном времени, не имеющем ни предсказания ветвления, ни целых блоков выполнения, были заметные различия.

1 миллион итераций более медленного кода заняло 1761 мс

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

  return a[i];
}

1 миллион итераций более быстрый код занял 1635 мс

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