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


я наткнулся на этот кусок кода в .NET список исходный код:

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

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

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

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

7 77

7 ответов:

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

целочисленные типы со знаком (int8, int16, int32, int64 и native int) и соответствующие им без знака целочисленные типы (неподписанные int8, INT16 в неподписанный, неподписанный типа int32, беззнаковый int64 и родной неподписанные int) отличаются только тем, как интерпретируются биты целого числа. Для тех операций, в которых целое число без знака обрабатывается иначе, чем целое число со знаком (например, в сравнения или арифметика с переполнением) есть отдельные инструкции по лечению целое число без знака (например, ВКТ.ООН и добавить.овф.ООН.)

то есть преобразование С int до uint это просто вопрос бухгалтерского учета - отныне значение в стеке / в регистре теперь известно как unsigned int, а не int.

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

допустим, у нас есть:

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

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

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

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

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

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

unchecked
{
    // test
}

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

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

предположим далее, что _size всегда >= 0.

тогда первоначальный тест был бы:

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

The оптимизация версия

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

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

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

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

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

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

(uint)-1 > (uint)x 

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

Да, это более эффективно. 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 и проверить не удается.

исследуя это на процессоре intel, я не нашел различий во времени выполнения, возможно, из-за нескольких целых исполнительных единиц.

но при этом на микропроцессоре реального времени 16 МГц без предсказания ветвей и целочисленных исполнительных устройств были заметные различия.

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];
}