Стоит ли сохранять длину массива в локальную переменную в C#

в 12:47, , рубрики: .net, asm, C#

Очень часто замечаю, что люди пишут вот так:

var length = array.Length;
for (int i = 0; i < length; i++) {
    //do smth
}

Пишут они это в надежде ускорить цикл, думая что создавая локальную переменную избавляют CLR от необходимости вызывать каждый раз геттер для Array.Length. В моём главном рабочем проекте подобный код встречается более 150 раз. Я решил раз и навсегда для себя понять стоит так делать или можно сэкономить своё время и написать без временной переменной.

Для начала рассмотрим два метода на C#:

public int WithoutVariable() {
    int sum = 0;
    for (int i = 0; i < array.Length; i++) {
        sum += array[i];
    }
    return sum;
}
public int WithVariable() {
    int sum = 0;
    int length = array.Length;
    for (int i = 0; i < length; i++) {
        sum += array[i];
    }
    return sum;
}

И давайте посмотрим на код, который получается после JIT компиляции в релизе на .NET Framework 4.7.2 под LegacyJIT-x86:

WithoutVariable()
;int sum = 0;
    xor  edi, edi
;int i = 0;
    xor  esi, esi
;int[] localRefToArray = this.array;
    mov  edx, dword ptr [ecx+4]
;int arrayLength = localRefToArray.Length;
    mov  ecx, dword ptr [edx+4]
;if (arrayLength == 0) return sum;
    test ecx, ecx
    jle  exit
;int arrayLength2 = localRefToArray.Length;
    mov  eax, dword ptr [edx+4] 
;if (i >= arrayLength2)
;  throw new IndexOutOfRangeException();
  loop:
    cmp  esi, eax
    jae  056e2d31
;sum += localRefToArray[i];
    add  edi, dword ptr [edx+esi*4+8]
;i++;
    inc  esi
;if (i < arrayLength) goto loop
    cmp  ecx, esi
    jg  loop
;return sum;
  exit:
    mov  eax, edi
WithVariable()
;int sum = 0;
    xor  esi, esi
;int[] localRefToArray = this.array;
    mov  edx, dword ptr [ecx+4]
;int arrayLength = localRefToArray.Length;
    mov  edi, dword ptr [edx+4]
;int i = 0;
    xor  eax, eax
;if (arrayLength == 0) return sum;
    test edi, edi
    jle  exit
;int arrayLength2 = localRefToArray.Length;
    mov  ecx, dword ptr [edx+4]
;if (i >= arrayLength2)
;  throw new IndexOutOfRangeException();
  loop:
    cmp  eax, ecx
    jae  05902d31
;sum += localRefToArray[i];
    add  esi, dword ptr [edx+eax*4+8]
;i++;
    inc  eax
;if (i < arrayLength) goto loop
    cmp  eax, edi
    jl   loop
;return sum;
  exit:
    mov  eax, esi
Скриншот сравнения в Meld

Стоит ли сохранять длину массива в локальную переменную в C# - 1

Несложно заметить, что количество ассемблерных инструкций абсолютно одинаково — 15 штук. Причём логика этих инструкций тоже практически полностью совпадает. Есть только небольшое различие в порядке инициализации переменных и сравнении стоит ли продолжать цикл. Можно заметить, что в обоих случаях длина массива заносится в регистры два раза перед циклом:

  • чтобы проверить на 0 (arrayLength);
  • и во временную переменную для проверки в условии цикла (arrayLength2);

Получается, что оба вышеприведённых метода компилируются в один и тот же код, но первый пишется быстрее и явный выигрыш в скорости выполнения отсутствует.
Приведённый выше ассемблерный код навёл меня на некоторые мысли и я решил проверить ещё пару методов:

public int WithoutVariable() {
    int sum = 0;
    for(int i = 0; i < array.Length; i++) {
        sum += array[i] + array.Length;
    }
    return sum;
}

public int WithVariable() {
    int sum = 0;
    int length = array.Length;
    for(int i = 0; i < length; i++) {
        sum += array[i] + length;
    }
    return sum;
}

Теперь суммируется текущий элемент и длина массива, только в первом случае длина массива каждый раз запрашивается, а во втором сохраняется один раз в локальную переменную. Посмотрим на ассемблерный код этих методов:

WithoutVariable()
int sum = 0;
    xor  edi, edi
int i = 0
    xor  esi, esi
int[] localRefToArray = this.array
    mov  edx, dword ptr [ecx+4]
int arrayLength = localRefToArray.Length
    mov  ebx, dword ptr [edx+4]
if (arrayLength == 0) return sum;
    test ebx, ebx
    jle  exit
int arrayLength2 = localRefToArray.Length;
    mov  ecx, dword ptr [edx+4]
if (i >= arrayLength2)
  throw new IndexOutOfRangeException()
  loop:
    cmp  esi, ecx 
    jae  05562d39
int t = array[i]
    mov  eax, dword ptr [edx+esi*4+8]
+= sum;
    add  eax, edi
t+= arrayLength;
    add  eax, ebx
sum = t
    mov  edi, eax
i++;
    inc  esi
if (i < arrayLength) goto loop
    cmp  ebx, esi
    jg   loop
return sum;
  exit:
    mov  eax, edi
WithVariable()
int sum = 0;
    xor  esi, esi
int[] localRefToArray = this.array;  
    mov  edx, dword ptr [ecx+4]
int arrayLength = localRefToArray.Length;  
    mov  ebx, dword ptr [edx+4]
int i = 0;  
    xor  ecx, ecx
if (arrayLength == 0) (return sum;) 
    test  ebx, ebx
    jle  exit
int arrayLength2 = localRefToArray.Length;
    mov   edi, dword ptr [edx+4]
if (i >= arrayLength2)
 throw new IndexOutOfRangeException();
 loop:
    cmp  ecx, edi
    jae  04b12d39
int t = array[i]
    mov  eax, dword ptr [edx+ecx*4+8]
+= sum
    add  eax, esi
t+= arrayLength
    add  eax, ebx
sum = t
    mov  esi, eax
i++
    inc  ecx
if (i < arrayLength) goto loop 
    cmp  ecx, ebx
    jl   loop
return sum;
  exit:
    mov  eax, esi
Скриншот сравнения в Meld

Стоит ли сохранять длину массива в локальную переменную в C# - 2

Количество инструкций у этих двух методов и сами инструкции почти абсолютно одинаковые, опять различие есть только в порядке инициализации переменных и проверке на продолжение цикла. Причём можно заметить, что в расчёте sum учитывается только тот array.Length, который был взят самым первым. Очевидно что вот это:

int arrayLength2 = localRefToArray.Length;
    mov     edi, dword ptr [edx+4]
if (i >=arrayLength2) throw new IndexOutOfRangeException();
    cmp     ecx, edi
    jae     04b12d39

во всех четырёх методах — заинлайниная проверка на выход за пределы массива и она выполняется для каждого элемента массива.

Можно сделать уже первый вывод о том, что заводить дополнительную переменную чтобы пытаться ускорить выполнение цикла это пустая трата времени, т.к. компилятор всё сделает за нас. Заводить переменную с длиной цикла имеет смысл только чтобы увеличить читабельность кода.

Совершенно другая ситуация с ForEach. Возьмём три метода:

public int ForEachWithoutLength() {
    int sum = 0;
    foreach (int i in array) {
        sum += i;
    }
    return sum;
}

public int ForEachWithLengthWithoutLocalVariable() {
    int sum = 0;
    foreach (int i in array) {
        sum += i + array.Length;
    }
    return sum;
}

public int ForEachWithLengthWithLocalVariable() {
    int sum = 0;
    int length = array.Length;
    foreach (int i in array) {
        sum += i + length;
    }
    return sum;
}

И посмотрим на их код после JIT:

ForEachWithoutLength()
;int sum = 0;
    xor  esi, esi
;int[] localRefToArray = this.array; 
    mov  ecx, dword ptr [ecx+4]
;int i = 0;
    xor  edx, edx
;int arrayLength = localRefToArray.Length; 
    mov  edi, dword ptr [ecx+4]
;if (arrayLength == 0) goto exit;
    test  edi, edi
    jle  exit
;int t = array[i];
  loop:
    mov  eax, dword ptr [ecx+edx*4+8]
;sum+=i;
    add  esi, eax
;i++;
    inc  edx
;if (i < arrayLength) goto loop
    cmp  edi, edx
    jg  loop
;return sum;
  exit:
    mov  eax, esi

ForEachWithLengthWithoutLocalVariable()

;int sum = 0;
    xor  esi, esi
;int[] localRefToArray = this.array;  
    mov  ecx, dword ptr [ecx+4]
;int i = 0; 
    xor  edx, edx
;int arrayLength = localRefToArray.Length;  
    mov  edi, dword ptr [ecx+4]
;if (arrayLength == 0) goto exit
    test  edi, edi
    jle  exit
;int t = array[i];  
  loop:
    mov  eax, dword ptr [ecx+edx*4+8]
;sum+=i; 
    add  esi, eax
;sum+=localRefToArray.Length;
    add  esi, dword ptr [ecx+4]
;i++;
    inc  edx
;if (i < arrayLength) goto loop 
    cmp  edi, edx
    jg  loop
;return sum;
  exit:
    mov  eax, esi

ForEachWithLengthWithLocalVariable()

;int sum = 0;
    xor  esi, esi
;int[] localRefToArray = this.array;   
    mov  edx, dword ptr [ecx+4]
;int length = localRefToArray.Length;   
    mov  ebx, dword ptr [edx+4]
;int i = 0;  
    xor  ecx, ecx
;int arrayLength = localRefToArray.Length;   
    mov  edi, dword ptr [edx+4]
;if (arrayLength == 0) goto exit; 
    test  edi, edi
    jle  exit
;int t = array[i];  
  loop: 
    mov  eax, dword ptr [edx+ecx*4+8]
;sum+=i;  
    add  esi, eax
;sum+=length ; 
    add  esi, ebx
;i++; 
    inc  ecx
;if (i < arrayLength) goto loop
    cmp  edi, ecx
    jg  loop
;return sum;
  exit:
    mov  eax, esi

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

Скриншот сравнения For и ForEach

Стоит ли сохранять длину массива в локальную переменную в C# - 3

В самом деле, если написать бенчмарк for vs foreach на 1 000 000 элементов массива, то получится такая картина: для

sum+=array[i];
Method ItemsCount Mean Error StdDev Median Ratio RatioSD
ForEach 1000000 1.401 ms 0.2691 ms 0.7935 ms 1.694 ms 1.00 0.00
For 1000000 1.586 ms 0.3204 ms 0.9447 ms 1.740 ms 1.23 0.65

и для

sum+=array[i] + array.Length;
Method ItemsCount Mean Error StdDev Median Ratio RatioSD
ForEach 1000000 1.703 ms 0.3010 ms 0.8874 ms 1.726 ms 1.00 0.00
For 1000000 1.715 ms 0.2859 ms 0.8430 ms 1.956 ms 1.13 0.56

ForEach для массивов отрабатывает быстрее чем For. Почему так происходит? Чтобы это понять нужно сравнивать код после JIT.

Скриншот сравнения всех трёх вариантов foreach

Стоит ли сохранять длину массива в локальную переменную в C# - 4

Посмотрим на ForEachWithoutLength. В нём длина массива запрашивается только один раз и не происходит никаких проверок на выход элементов за пределы массива. Так происходит потому что цикл ForEach во-первых запрещает изменение коллекции внутри цикла, а во-вторых точно не будет выходить за пределы коллекции. Из-за этого JIT может себе позволить вообще убрать проверки на выход за пределы массива.
Теперь посмотрим внимательно на метод ForEachWithLengthWithoutLocalVariable. В его коде есть только один странный момент в том, что sum+=length происходит не с сохранённой ранее локальной переменной arrayLength, а с новой, которую программа спрашивает каждый раз из памяти. Получается, что обращений к памяти за длиной массива будет N + 1, где N — длина массива.
Осталось рассмотреть метод ForEachWithLengthWithLocalVariable. Его код ничем не отличается от ForEachWithLengthWithoutLocalVariable, кроме работы с длинной массива. Компилятор опять сгенерировал локальную переменную arrayLength, с помощью которой проверяет что массив не пустой, но при этом компилятор честно сохранил нашу явную локальную переменную length и сложение в теле цикла происходит уже с ней. Получается, что этот метод всего дважды обращается к памяти для определения длины массива. Эту разницу в количестве обращений к памяти в реальных приложениях очень сложно заметить.
Ассемблерный код рассмотренных методов получился такой простой потому что сами методы простые. Будь в методе больше параметров, там была бы уже работа со стеком, переменные хранились бы не только в регистрах и возможно было бы больше проверок, но основная логика осталась бы такая же: введение локальной переменной с длиной массива может иметь смысл только для повышения читабельности кода. Кроме того, оказалось, что Foreach по массиву часто выполняется быстрее чем For.

Автор: T-D-K

Источник


* - обязательные к заполнению поля