Подтвердить что ты не робот

Эффективность проверки границ массива в .net 4 и выше

Мне интересно, как эффективные низкоуровневые алгоритмы могут быть в .net. Я бы хотел, чтобы мы решили записать больше нашего кода на С#, а не на С++ в будущем, но один камень преткновения - это проверка границ в .net, которая происходит с циклом и произвольным доступом к массивам.

Мотивирующим примером является функция, которая вычисляет сумму произведений соответствующих элементов в двух массивах (это точечное произведение двух векторов).

static void SumProduct(double[] X, double[] Y)
{
    double sum = 0;
    int length = X.Length;
    if (length != Y.Length)
        throw new ArgumentException("X and Y must be same size");
    for (int i = 0; i < length; i++) // Check X.Length instead? See below
        sum += X[i] * Y[i];
}

Из того, что я могу сказать, и не знаю достаточно IL или x86 для проверки, компилятор не будет оптимизировать проверку границ X и Y. Я ошибаюсь и/или есть способ написать мой код, чтобы позволить компилятору помочь мне?

Дополнительная информация

Существует множество аргументов эффективности для и против использования определенных языков, не в последнюю очередь, что лучше сосредоточиться на алгоритмической стоимости "большого О", а не на константе пропорциональности, и языки более высокого уровня помогут вам это сделать. Что касается проверки границ в .net, лучшая статья, которую я нашел, это Проверка границ массива в CLR на MSDN (также упоминается в ответ на переполнение стека о важности включения оптимизации).

Это датируется 2009 годом, поэтому я задаюсь вопросом, значительно ли изменилось с тех пор. Кроме того, в статье раскрываются некоторые настоящие тонкости, которые заставили бы меня покончить, поэтому по этой причине я бы приветствовал некоторые советы экспертов.

Например, похоже, что в моем коде выше было бы лучше писать i< X.Length, а не i < length. Кроме того, я также наивно полагал, что для алгоритма с одним массивом запись цикла foreach лучше объявит ваше намерение компилятору и даст ему наилучшие возможности для оптимизации проверки границ.

Согласно статье MSDN, SumForBAD, ниже, которая, как я думал, была бы оптимизирована, не будет. В то время как SumFor будет легко оптимизирован, а SumForEach также будет оптимизирован, но не тривиально (и вообще не может быть оптимизирован, если массив передан в функцию как IEnumerable<int>)?

static double SumForBAD(double[] X)
{
    double sum = 0;
    int length = X.Length; // better to use i < X.length in loop
    for (int i = 0; i < length; i++)
        sum += X[i];
    return sum;
}

static double SumFor(double[] X)
{
    double sum = 0;
    for (int i = 0; i < X.Length; i++)
        sum += X[i];
    return sum;
}

static double SumForEach(double[] X)
{
    double sum = 0;
    foreach (int element in X)
        sum += element;
    return sum;
}

Я провел некоторое расследование, основанное на ответе doug65536. В С++ я сравнивал времена SumProduct, который выполняет одну проверку границ

for(int i=0; i<n; ++i) sum += v1[i]*v2[i];

против другой версии, которая выполняет две проверки границ

for(int i=0; i<n1 && i <n2; ++i) sum += v1[i]*v2[i];

Я обнаружил, что вторая версия была медленнее, но только примерно на 3,5% (Visual Studio 2010, оптимизированная сборка, параметры по умолчанию). Однако мне пришло в голову, что в С# могут быть три проверки границ. Один явный (i < length в функции static void SumProduct(double[] X, double[] Y) в начале этого вопроса) и два неявных (X[i] и Y[i]). Поэтому я протестировал третью С++-функцию с тремя ограничениями проверки

for(int i=0; i<n1 && i <n2 && i <n3; ++i) sum += v1[i]*v2[i];

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

4b9b3361

Ответ 1

Проверка границ не имеет значения, потому что:

  • Проверка границ состоит из пары инструкций cmp/jae, которая объединена в один микрооператор на современных архитектурах процессора (термин "слияние макросов" ). Сравнение и ветвь очень оптимизированы.

  • Проверка границ - это передовая ветка, которая будет статически предсказана, чтобы не быть принятой, а также снизить стоимость. Филиал никогда не будет принят. (Если это когда-либо будет принято, исключение все равно будет выброшено, поэтому стоимость неверного прогноза станет совершенно неактуальной).

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

Доступ к памяти, скорее всего, будет вашим узким местом, поэтому эффект микро-оптимизации, такой как удаление проверок границ, исчезнет.

Ответ 2

64-битный

64-разрядный джиттер отлично справляется с устранением проверок границ (по крайней мере, в простых сценариях). Я добавил return sum; в конце вашего метода, а затем скомпилировал программу с помощью Visual Studio 2010 в режиме Release. В приведенной ниже разборке (которую я аннотировал с переводом на С#), обратите внимание, что:

  • Нет проверок ограничений для X, даже если ваш код сравнивает i с length вместо X.Length. Это улучшает поведение, описанное в статье.
  • Перед основным циклом есть одна проверка, чтобы убедиться, что Y.Length >= X.Length.
  • Основной цикл (смещения от 00000032 до 00000052) не содержит проверок границ.

Демонтажные

; Register assignments:
;    rcx  := i
;    rdx  := X
;    r8   := Y
;    r9   := X.Length ("length" in your code, "XLength" below)
;    r10  := Y.Length ("YLength" below)
;    r11  := X.Length - 1 ("XLengthMinus1" below)
;    xmm1 := sum

; (Prologue)
00000000  push        rbx
00000001  push        rdi
00000002  sub         rsp,28h

; (Store arguments X and Y in rdx and r8)
00000006  mov         r8,rdx   ; Y
00000009  mov         rdx,rcx  ; X

; int XLength = X.Length;
0000000c  mov         r9,qword ptr [rdx+8]

; int XLengthMinus1 = XLength - 1;
00000010  movsxd      rax,r9d
00000013  lea         r11,[rax-1]

; int YLength = Y.Length;
00000017  mov         r10,qword ptr [r8+8]

; if (XLength != YLength)
;     throw new ArgumentException("X and Y must be same size");
0000001b  cmp         r9d,r10d
0000001e  jne         0000000000000060

; double sum = 0;
00000020  xorpd       xmm1,xmm1

; if (XLength > 0)
; {
00000024  test        r9d,r9d
00000027  jle         0000000000000054

;     int i = 0;
00000029  xor         ecx,ecx
0000002b  xor         eax,eax

;     if (XLengthMinus1 >= YLength)
;         throw new IndexOutOfRangeException();
0000002d  cmp         r11,r10
00000030  jae         0000000000000096

;     do
;     {
;         sum += X[i] * Y[i];
00000032  movsd       xmm0,mmword ptr [rdx+rax+10h]
00000038  mulsd       xmm0,mmword ptr [r8+rax+10h]
0000003f  addsd       xmm0,xmm1
00000043  movapd      xmm1,xmm0

;         i++;
00000047  inc         ecx
00000049  add         rax,8

;     }
;     while (i < XLength);
0000004f  cmp         ecx,r9d
00000052  jl          0000000000000032
; }

; return sum;
00000054  movapd      xmm0,xmm1

; (Epilogue)
00000058  add         rsp,28h
0000005c  pop         rdi
0000005d  pop         rbx
0000005e  ret

00000060  ...

00000096  ...

32-битный

32-разрядный джиттер, к сожалению, не такой уж умный. При демонстрации ниже обратите внимание, что:

  • Нет проверок ограничений для X, даже если ваш код сравнивает i с length вместо X.Length. Опять же, это улучшение по сравнению с поведением, описанным в статье.
  • Основной цикл (смещения от 00000018 до 0000002a) содержит проверку границ для Y.

Демонтажные

; Register assignments:
;    eax  := i
;    ecx  := X
;    edx  := Y
;    esi  := X.Length ("length" in your code, "XLength" below)

; (Prologue)
00000000  push        ebp
00000001  mov         ebp,esp
00000003  push        esi

; double sum = 0;
00000004  fldz

; int XLength = X.Length;
00000006  mov         esi,dword ptr [ecx+4]

; if (XLength != Y.Length)
;     throw new ArgumentException("X and Y must be same size");
00000009  cmp         dword ptr [edx+4],esi
0000000c  je          00000012
0000000e  fstp        st(0)
00000010  jmp         0000002F

; int i = 0;
00000012  xor         eax,eax

; if (XLength > 0)
; {
00000014  test        esi,esi
00000016  jle         0000002C

;     do
;     {
;         double temp = X[i];
00000018  fld         qword ptr [ecx+eax*8+8]

;         if (i >= Y.Length)
;             throw new IndexOutOfRangeException();
0000001c  cmp         eax,dword ptr [edx+4]
0000001f  jae         0000005A

;         sum += temp * Y[i];
00000021  fmul        qword ptr [edx+eax*8+8]
00000025  faddp       st(1),st

;         i++;
00000027  inc         eax

;     while (i < XLength);
00000028  cmp         eax,esi
0000002a  jl          00000018
; }

; return sum;
0000002c  pop         esi
0000002d  pop         ebp
0000002e  ret

0000002f  ...

0000005a  ...

Подведение итогов

Дисктер улучшился с 2009 года, а 64-разрядный джиттер может генерировать более эффективный код, чем 32-разрядный джиттер.

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

Ответ 3

Один из способов убедиться, что проверка границ не выполняется, - это использовать указатели, которые вы можете делать на С# в небезопасном режиме (для этого требуется установить флаг в свойствах проекта):

private static unsafe double SumProductPointer(double[] X, double[] Y)
{
    double sum = 0;
    int length = X.Length;
    if (length != Y.Length)
        throw new ArgumentException("X and Y must be same size");
    fixed (double* xp = X, yp = Y)
    {
        for (int i = 0; i < length; i++)
            sum += xp[i] * yp[i];
    }
    return sum;
}

Я попытался измерить ваш оригинальный метод, ваш метод с изменением X.Length и мой код с помощью указателей, скомпилированных как x86 и x64 в .Net 4.5. В частности, я попытался вычислить метод для векторов длиной 10 000 и запустить метод 10 000 раз.

Результаты в значительной степени согласуются с ответом Майкла Лю: нет никакой измеримой разницы между тремя методами, что означает, что проверка границ либо не выполняется, либо ее влияние на производительность незначительно. Между x86 и x64 была заметная разница: x64 примерно на 34% медленнее.

Полный код, который я использовал:

static void Main()
{
    var random = new Random(42);
    double[] x = Enumerable.Range(0, 10000).Select(_ => random.NextDouble()).ToArray();
    double[] y = Enumerable.Range(0, 10000).Select(_ => random.NextDouble()).ToArray();

    // make sure JIT doesn't affect the results
    SumProduct(x, y);
    SumProductLength(x, y);
    SumProductPointer(x, y);

    var stopwatch = new Stopwatch();
    stopwatch.Start();
    for (int i = 0; i < 10000; i++)
    {
        SumProduct(x, y);
    }
    Console.WriteLine(stopwatch.ElapsedMilliseconds);
    stopwatch.Restart();
    for (int i = 0; i < 10000; i++)
    {
        SumProductLength(x, y);
    }
    Console.WriteLine(stopwatch.ElapsedMilliseconds);
    stopwatch.Restart();
    for (int i = 0; i < 10000; i++)
    {
        SumProductPointer(x, y);
    }
    Console.WriteLine(stopwatch.ElapsedMilliseconds);
}

private static double SumProduct(double[] X, double[] Y)
{
    double sum = 0;
    int length = X.Length;
    if (length != Y.Length)
        throw new ArgumentException("X and Y must be same size");
    for (int i = 0; i < length; i++)
        sum += X[i] * Y[i];
    return sum;
}

private static double SumProductLength(double[] X, double[] Y)
{
    double sum = 0;
    if (X.Length != Y.Length)
        throw new ArgumentException("X and Y must be same size");
    for (int i = 0; i < X.Length; i++)
        sum += X[i] * Y[i];
    return sum;
}

private static unsafe double SumProductPointer(double[] X, double[] Y)
{
    double sum = 0;
    int length = X.Length;
    if (length != Y.Length)
        throw new ArgumentException("X and Y must be same size");
    fixed (double* xp = X, yp = Y)
    {
        for (int i = 0; i < length; i++)
            sum += xp[i] * yp[i];
    }
    return sum;
}