
Стоит ли сохранять длину массива в локальную переменную в C#
Очень часто замечаю, что люди пишут вот так:
Пишут они это в надежде ускорить цикл, думая что создавая локальную переменную избавляют CLR от необходимости вызывать каждый раз геттер для Array.Length. Я решил раз и навсегда для себя понять стоит так делать или можно сэкономить своё время и написать без временной переменной.
Для начала рассмотрим два метода на C#:
И давайте посмотрим на код, который получается после JIT компиляции в релизе на .NET Framework 4.7.2 под LegacyJIT-x86:
Несложно заметить, что количество ассемблерных инструкций абсолютно одинаково — 15 штук. Причём логика этих инструкций тоже практически полностью совпадает. Есть только небольшое различие в порядке инициализации переменных и сравнении стоит ли продолжать цикл. Можно заметить, что в обоих случаях длина массива заносится в регистры два раза перед циклом:
Получается, что оба вышеприведённых метода компилируются в один и тот же код, но первый пишется быстрее и явный выигрыш в скорости выполнения отсутствует.
Приведённый выше ассемблерный код навёл меня на некоторые мысли и я решил проверить ещё пару методов:
Теперь суммируется текущий элемент и длина массива, только в первом случае длина массива каждый раз запрашивается, а во втором сохраняется один раз в локальную переменную. Посмотрим на ассемблерный код этих методов:
Количество инструкций у этих двух методов и сами инструкции почти абсолютно одинаковые, опять различие есть только в порядке инициализации переменных и проверке на продолжение цикла. Причём можно заметить, что в расчёте sum учитывается только тот array.Length, который был взят самым первым. Очевидно что вот это:
Можно сделать уже первый вывод о том, что заводить дополнительную переменную чтобы пытаться ускорить выполнение цикла это пустая трата времени, т.к. компилятор всё сделает за нас. Заводить переменную с длиной цикла имеет смысл только чтобы увеличить читабельность кода.
Совершенно другая ситуация с ForEach. Возьмём три метода:
И посмотрим на их код после JIT:
Первое что бросается в глаза это то, что получилось меньше ассемблерных инструкций, по сравнению с циклом for (например для простого суммирования элементов вышло в foreach 12 инструкций, в for 15).
В самом деле, если написать бенчмарк for vs foreach на 1 000 000 элементов массива, то получится такая картина для
и для
ForEach для массивов отрабатывает быстрее чем For. Почему так происходит? Чтобы это понять нужно сравнивать код после JIT.
Посмотрим на ForEachWithoutLength. В нём длина массива запрашивается только один раз и не происходит никаких проверок на выход элементов за пределы массива. Так происходит потому что цикл ForEach во-первых запрещает изменение коллекции внутри цикла, а во-вторых точно не будет выходить за пределы коллекции. Из-за этого JIT может себе позволить вообще убрать проверки на выход за пределы массива.
Теперь посмотрим внимательно на метод ForEachWithLengthWithoutLocalVariable. В его коде есть только один странный момент в том, что sum+=length происходит не с сохранённой ранее локальной переменной arrayLength, а с новой, которую программа спрашивает каждый раз из памяти. Получается, что обращений к памяти за длиной массива будет N + 1, где N — длина массива.
Осталось рассмотреть метод ForEachWithLengthWithLocalVariable. Его код ничем не отличается от ForEachWithLengthWithoutLocalVariable, кроме работы с длинной массива. Компилятор опять сгенерировал локальную переменную arrayLength, с помощью которой проверяет что массив не пустой, но при этом компилятор честно сохранил нашу явную локальную переменную length и сложение в теле цикла происходит уже с ней. Получается, что этот метод всего дважды обращается к памяти для определения длины массива. Эту разницу в количестве обращений к памяти в реальных приложениях очень сложно заметить.
Ассемблерный код рассмотренных методов получился такой простой потому что сами методы простые. Будь в методе больше параметров, там была бы уже работа со стеком, переменные хранились бы не только в регистрах и возможно было бы больше проверок, но основная логика осталась бы такая же: введение локальной переменной с длиной массива может иметь смысл только для повышения читабельности кода. Кроме того, оказалось, что Foreach по массиву часто выполняется быстрее чем For.
var length = array.Length;
for (int i = 0; i < length; i++) {
//do smth
}
Пишут они это в надежде ускорить цикл, думая что создавая локальную переменную избавляют CLR от необходимости вызывать каждый раз геттер для Array.Length. Я решил раз и навсегда для себя понять стоит так делать или можно сэкономить своё время и написать без временной переменной.
Для начала рассмотрим два метода на 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

Несложно заметить, что количество ассемблерных инструкций абсолютно одинаково — 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] t += 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] t += 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

Количество инструкций у этих двух методов и сами инструкции почти абсолютно одинаковые, опять различие есть только в порядке инициализации переменных и проверке на продолжение цикла. Причём можно заметить, что в расчёте 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
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
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
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

В самом деле, если написать бенчмарк 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

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