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

Nikita Goncharuk
Game Dev
Published in
5 min readDec 24, 2018

--

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

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

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

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

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

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

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

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

Приведённый выше ассемблерный код навёл меня на некоторые мысли и я решил проверить ещё пару методов:

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

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

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

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

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

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

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

ForEachWithoutLength()

ForEachWithLengthWithoutLocalVariable()

ForEachWithLengthWithLocalVariable()

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

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

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

И для:

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

Посмотрим на ForEachWithoutLength. В нём длина массива запрашивается только один раз и не происходит никаких проверок на выход элементов за пределы массива. Так происходит потому что цикл ForEach во-первых запрещает изменение коллекции внутри цикла, а во-вторых точно не будет выходить за пределы коллекции. Из-за этого JIT может себе позволить вообще убрать проверки на выход за пределы массива.

Теперь посмотрим внимательно на метод ForEachWithLengthWithoutLocalVariable. В его коде есть только один странный момент в том, что sum+=length происходит не с сохранённой ранее локальной переменной arrayLength, а с новой, которую программа спрашивает каждый раз из памяти. Получается, что обращений к памяти за длиной массива будет N + 1, где N — длина массива.

Осталось рассмотреть метод ForEachWithLengthWithLocalVariable. Его код ничем не отличается от ForEachWithLengthWithoutLocalVariable, кроме работы с длинной массива. Компилятор опять сгенерировал локальную переменную arrayLength, с помощью которой проверяет что массив не пустой, но при этом компилятор честно сохранил нашу явную локальную переменную length и сложение в теле цикла происходит уже с ней. Получается, что этот метод всего дважды обращается к памяти для определения длины массива. Эту разницу в количестве обращений к памяти в реальных приложениях очень сложно заметить.

Ассемблерный код рассмотренных методов получился такой простой потому что сами методы простые. Будь в методе больше параметров, там была бы уже работа со стеком, переменные хранились бы не только в регистрах и возможно было бы больше проверок, но основная логика осталась бы такая же: введение локальной переменной с длиной массива может иметь смысл только для повышения читабельности кода. Кроме того, оказалось, что Foreach по массиву часто выполняется быстрее чем For.

Источник: habr

--

--