Как стать автором
Обновить

Комментарии 163

Сомнительная оптимизация, это как называть переменные одной буквой для экономии памяти.

И поправьте отображение кода
Вводить лишнюю переменную всегда медленнее чем её не вводить. Мне часто объсняли, что сохраняя в локальную переменную длину массива, программист таким образом увеличивает скорость выполнения цикла. Я доказал, что это не так. Всё. За оптимизациями я не гнался.
И поправьте отображение кода

Что не так с отображением кода? Можно подробнее? habrahabr не умеет в нативную подсветку ассемблера и пришлось экспериментировать с другими тулзами.
Скриншотом хоть. Как-то грязно сейчас
Спрятал большие куски кода по кат. Код в таблицах нужен для визуального сравнения. Скриншоты из утилиты есть под катами после таблиц.
В целом вставлять код скриншотом это очень плохая практика, т.к. такой код нельзя скопировать и такой код не проиндексируется поисковиками.
В данном конкретном случае скорее всего никто не станет копировать те вставки. А гуглить код… Здесь тоже маловероятный сценарий
НЛО прилетело и опубликовало эту надпись здесь

Это не ssa, это loop invariant

А если это не функция Length стандартного Array, а какая-нибудь кастомная функция, как компилятор такое соптимизирует не проверяли?
В общем случае функция будет вычислять на каждой итерации цикла. SZ-массивы в .NET это очень нативная вещь и под неё сделано очень много оптимизаций. Для остального такие оптимизации в общем случае могут быть не сделаны. Подробности можно узнать только дизассемблировав код метода после JIT компиляции конкретным JIT.
Ради интереса. Вот код: pastebin.com/2fhb1fUZ, вот asm (LegacyJit x86): pastebin.com/8uSmwitv
Метод вызывается каждый раз, хотя возвращает каждый раз одного и тоже значение.
Тогда какой смысл не создавать переменную?
Быстрее код не станет, но и медленнее тоже. Скорость написания кода тоже сомнительный аргумент. Перерыв «на кофе» займет больше времени чем сэкономите.
Зато если всегда писать, то это будет делаться машинально и будет хоть какая то однородность в стиле.
Тогда какой смысл не создавать переменную?

Чтобы не было лишней строчки в идиоматичном коде.


Быстрее код не станет, но и медленнее тоже. Скорость написания кода тоже сомнительный аргумент.

Обход массива — идиоматичный код, лучше, если он у всех выглядит одинаково.


Зато если всегда писать, то это будет делаться машинально и будет хоть какая то однородность в стиле.

Однородно должен быть написан как раз обход массива. А, если выделена отдельная переменая — значит можно сразу понять, что там какой-то не тривиальный код

А если размер массива меняется внутри цикла от шага к шагу?
А если размер массива меняется внутри цикла от шага к шагу?

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

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

Если из листа надо только удалить элементы, то лучше просто вернуть новый лист, где удалённых элементов нет.

Эм… А если работа напрямую со списком контролов, который предоставляет родительский контрол? А на дочерние контролы могут и события вешаться, и прочая шелуха WinForms.
А если работа напрямую со списком контролов, который предоставляет родительский контрол?

Там, наверное, удаление контрола делается вызовом отдельного метода родительского контрола?
Я бы прошёл по списку, получил контролы, которые нужно удалить и сделал ещё один цикл, в котором бы их удалил. Я стараюсь избегать кода, в котором модифицируется размер коллекции, по которой происходит итерация.

var controlsToUpdate = Controls.Where(SomeCondition).ToArray()

Размер массива не может меняться. Это его ключевое свойство, в общем-то.

Строго говоря вы доказали это лишь для одной структуры данных. Я бы побоялся делать такие выводы на одном примере. А если работа идёт с какой-то коллекцией где логика подсчета длины не совсем константая по сложности? Слишком тонкая тема. Думаю серебряной пули тут нет. Разве что только для встроенных примитивных типов

ну одной не одной, но, вроде бы, начиная с какой то длины добавляется лишняя работа.
кстати подобное во многих языках, где есть рефлексия и кеш на короткие строки
Хмм, на заре дотнета foreach был намного тормознутее простого for-а. Интересный прогресс :)
И сейчас есть разница в производительности.
Если написать:
void SomeMethod(int[] array) {
   foreach(var i in array) {
   }
}

то JIT это развернёт в обычный цикл со счётчиком, а если сделать:
void SomeMethod(IEnumerable<int> array) {
   foreach(var i in array) {
   }
}

то тут уже будут все «прелести» foreach — получение енумератора, MoveNext и т.д. И вот этот код уже будет тормозить даже если передать туда массив интов.
Думаю, во втором случае речь должна идти о «прелестях» не foreach, а IEnumerabe/IEnumerator.
Выходит, что foreach научился оптимизировать циклы, используя дополнительную функциональность, к примеру, IList.
И получается, что с точки зрения производительности, выгоднее не генерализировать код, передавая минимально допустимый интерфейс вроде IEnumerable, а передавать ILIst, давая возможность компилятору оптимизировать код.
Можно написать много кастований к разным интерфейсам и в зависимости от успеха вызывать уже метод, оптимизированный пот конкретную коллекцию.
Примерно также сделали вот здесь: github.com/dotnet/corefx/blob/master/src/System.Linq/src/System/Linq/Count.cs
Но мне кажется, что если приходится в реальном проекте делать такую оптимизацию, то скорее всего что-то сильно раньше пошло не так.
Все эти касты тоже далеко не дешёвые. Если коллекция короткая, то накладные расходы на касты будут приносить больше вреда чем пользы.
Жаль в C# нету перегрузок методов по constraint-ам на generic-параметры…
Наиболее правильным вариантом тут будет использовать generic типы с необходимыми вам constraint-ами. В этом случае runtime проводит дополнительные оптимизaции используя информацию о типе (например переданные тип может быть struct … или же sealed … или идин из немногих типов от которых наследование возможно, но наследников фактически нет (на счет этой оптимизации не уверен)). Такой код будет значительно быстрее, но к сожалению «duck typing» к-рый используется в foreach конструкциях не будет задействован (а ведь именно он даёт такой прирост производительности в сравнении с for циклами)
Так я ж о чём и говорю…
Но нельзя сделать перегрузки вида
void Smth<T, X>(this T obj) where T : IList<X>;
void Smth<T, X>(this T obj) where T : IEnumerable<X>;

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

Кроме того, код с кастом выполняется в runtime и способен обнаруживать совместимые конкретизации типов, когда объект передан обобщённо в виде интерфейсной ссылки или приведением к базовому типу.

PS: На хабре отвалилась подсветка C# из списка языков?
Зачем IList? Передача изменяемых списков в качестве аргументов — попахивает.
Я в последнее время всё, где можно — на IReadonlyCollection переделываю. List, Array и некоторые другие списки её умеют из коробки, соответственно переделываем только сигнатуру, а вызовы можно оставить как есть.
Исключение делается только в одном случае — если состав коллекции действительно IEnumerable, например это результат асинхронного выполнения запроса к базе или результат рекурсивной загрузки дерева файлов с диска, когда мы не можем получить весь результат одним куском и не знаем конечного размера массива элементов.
Во всех остальных случаях IReadonlyCollection — то, что доктор прописал.
Только одна претензия — слишком длинное название интерфейса, но здесь уж ничего не изменишь.
И главное — чистота кода. IReadonly сам собой подразумевает, что список передаётся в метод только для чтения, и не будет использоваться для неявного пополнения внутри метода.
Зачем IList? Передача изменяемых списков в качестве аргументов — попахивает.
Я в последнее время всё, где можно — на IReadonlyCollection переделываю.

IReadonlyCollection даёт произвольный доступ к элементам? Если нет, то вот и причина передавать IList.

Произвольный доступ к элементам дает IReadonlyList
IList? Например Array реализует IList, но бросает NotSupportedException при попытке вызвать методы, изменяющие коллекцию.
Если реализация метода, принимающая IList закрыта от вызывающей стороны интерфейсом, то вызывающая сторона не может быть уверена, что в такой метод можно передать Array — вдруг реализация начнёт менять коллекцию?
Если вам нужен доступ к элементам по индексу — используйте IReadonlyList, но, как по мне, это тоже попахивает какими-то костылями.
Просто вопрос — зачем? Если, например, методу нужен какой-то конкретный элемент массива, это означает, что метод слишком много знает об организации коллекции. Вместо этого метод может принимать нужный элемент, вместо коллекции.
В общем принимать коллекции с доступом по индексу — это должна быть какая-то очень специфическая необходимость.
IList? Например Array реализует IList, но бросает NotSupportedException при попытке вызвать методы, изменяющие коллекцию.

Это у меня из-за недостатка знаний про .net.


Если вам нужен доступ к элементам по индексу — используйте IReadonlyList, но, как по мне, это тоже попахивает какими-то костылями.

Ага, спасибо.


В общем принимать коллекции с доступом по индексу — это должна быть какая-то очень специфическая необходимость.

Есть ещё такое соображение, что в коллекцию можно передать что угодно, в том числе LinkedList. Возможно вы хотите запретить его использование на уровне API.

LinkedList — сам по себе очень специфичный вид коллекции. Даже приведу цитату из хабракоммента из статьи по LinkedList:
Согласен, что очень специфичный и редко используемый контейнер.

Я же говорю об остальных 99 случаях из 100 — IReadOnlyCollection будет предпочтительнее — с ним интерфейсы становятся чище и понятнее.
Я же говорю об остальных 99 случаях из 100 — IReadOnlyCollection будет предпочтительнее — с ним интерфейсы становятся чище и понятнее.

Если LinkedList используется редко, то в 99 случаях из 100 можно рассчитывать, что его в метод не передадут. Следовательно в этих 99 случаях из 100 можно сделать явное ограничение в виде IReadOnlyList.


IReadOnlyCollection — явно указывает, что можно передать спокойно передавать LinkedList и так и надо. В 99 случаях из 100 так не надо, поэтому я считаю, что лучше это выразить явно.

IReadOnlyCollection — явно указывает, что можно передать спокойно передавать LinkedList и так и надо. В 99 случаях из 100 так не надо, поэтому я считаю, что лучше это выразить явно.

А что в этом плохого? Вызывающая сторона будет обращаться с LinkedList так же, как и с любой другой коллекцией. Зачем вызывающей стороне IReadOnlyList, который отличается от IReadonlyCollection исключительно геттером по индексу элемента?
Наоборот — ожидая IReadonlyList метод указывает вызывающей стороне, что он собирается вызывать Collection[index], иначе он ожидал бы базовый интерфейс IReadonlyCollection.
Если методу нужна исключительно энумерация входящих в коллекцию элементов — то LinkedList ничем не хуже любой другой коллекции, и ограничение не имеет смысла.
Зачем вызывающей стороне IReadOnlyList, который отличается от IReadonlyCollection исключительно геттером по индексу элемента?

Чтобы нам не передали LinkedList.


Наоборот — ожидая IReadonlyList метод указывает вызывающей стороне, что он собирается вызывать Collection[index], иначе он ожидал бы базовый интерфейс IReadonlyCollection.

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


Если методу нужна исключительно энумерация входящих в коллекцию элементов — то LinkedList ничем не хуже любой другой коллекции

LinkedList жрёт память, убивает перформанс, не даёт элементам коллекции ложиться в кеш и так далее.

Как и любой контейнер, двусвязный список предназначен для решения специализированных задач.
У него быстрая вставка и удаление по итератору.
Например, он лучше других будет подходить как контейнер для MRU List и ряда других задач.
Как и любой контейнер, двусвязный список предназначен для решения специализированных задач.

Вот когда появляются такие задачи и надо использовать LinkedList.


У него быстрая вставка и удаление по итератору.

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


Например, он лучше других будет подходить как контейнер для MRU List

И что делает LinkedList лучше других подходящим на роль MRU List? Чем он лучше ArrayList? Почему там нельзя использовать банальный массив фиксированного размера?

И что делает LinkedList лучше других подходящим на роль MRU List? Чем он лучше ArrayList? Почему там нельзя использовать банальный массив фиксированного размера?

Потому что в массиве фиксированного размера операция "взять произвольный элемент и переставить в начало" выполняется за Θ(N)

Потому что в массиве фиксированного размера операция "взять произвольный элемент и переставить в начало" выполняется за Θ(N)

Для того, чтобы переставить в начало произвольный элемент в двусвязном списке нужно:


  1. Вписать в prev первого элемента ссылку наш элемент
  2. Вписать в prev нашего элемента null
  3. Вписать в next нашего элемента ссылку на бывший первый элемент
  4. Вписать в next предущего элемента ссылку на следующий элемент
  5. Вписать в prev следующего элемента ссылку на предыдущий элемент

Итого 5 действий. То есть, пока у нас в листе 5 элементов и менее, то массив лучше двусвязного списка, тут даже обсуждать нечего.


Но сдвиг в массиве это не просто N операций, это N операций, операнды которых хорошо легли в кеш, а значит они сильно быстрее, чем операции с Linked List.


Кроме того, сдвиг в массиве оптимизируется на низком уровне, а значит — он ещё быстрее.


Поэтому количество элементов, при котором массив — лучший инструмент для решения нашей задачи существенно больше пяти.


MRU List это такая штука, которая ограничена в размерах каким-то небольшим числом и поэтому массив тут подойдёт лучше, чем LinkedList, если речь только о времени, затрачиваемом на то, чтобы переместить в начало произвольный элемент.

Э… а почему вы считаете что MRU List должен быть ограничен каким-то небольшим числом?
Э… а почему вы считаете что MRU List должен быть ограничен каким-то небольшим числом?

Потому что он нужен для того, чтобы вернуться к файлу или проекту или ещё какой-то сущности с которой вы либо недавно работали, либо работатете постоянно. Хранить там больше какого-то небольшого количества элементов не имеет смысла, если надо найти что-то что вы использовали давно — если другие инструменты.

Обновление ссылки — это обновление одного машинного слова в памяти.
Перемещение элемента целиком — зависит от размера элемента, но редко бывает элемент размером в 1 машинное слово. Альтернатива — сами элементы хранятся в одном контейнере, а индексы для доступа — в другом. Но такие структуры не кэш-френдли. Ну и напоследок: большие структуры, да ещё хранящие ссылки, например, на строки — тоже не слишком кэш-френдли.

Вы точно в ответ на мой комментарий это написали? Если да, то я не понял, что вы хотели сказать.

А что страшного случится если вам передадут LinkedList? Почему бы не предположить что тот, кто создает LinkedList и передает его нам, знает что делает?

Это какое-то новое слово в архитектуре интерфейсов — специально ограничивать API, чтобы запретить использование ненавистного типа.
Представим себе ситуацию. Мы пишем API, в котором нужен метод вычисления какой-нибудь математической функции из входящей коллекции Int. Единственным условием для успешности вычисления этой функции является конечность коллекции.
Вы действительно предлагаете запретить использование всех базовых коллекций, не реализующих IReadonlyList, только для того, чтобы не убить перформанс?
Чей перформанс? Перформансом LinkedList должен озаботиться в первую очередь тот, кто нам его передаёт. Если пользователя API устраивает перформанс LinkedList, и ему нужна именно такая коллекция? Или даже она досталась ему по наследству из другого API. Что же ему теперь — перед каждым вызовом нашего метода API вызывать .ToArray() на своём списке?
Кроме того, запрещая использование IReadonlyCollection, помимо LinkedList вы теряете и другие базовые коллекции — например Stack или Queue. Да даже Dictionary.
Попахивает каким-то рассизмом по отношению к типам.
Интерфейсы вообще-то по-хорошему нужны для того, чтобы заявлять, какой функционал от коллекции нужен алгоритму, который Вы реализуете.
Если коллекция (любая) его удовлетворяет — окей, пользователь вашего API лучше знает, что ему надо на самом деле.

Тормознутость какого порядка? Микросекунды на миллион итераций или хуже?
Сам не особо парюсь такими оптимизация и использую везде IEnunerable<> и List<>, потому что любой запрос к соседнему микросервису отработает в сотни и тысячи раз дольше.

Такими оптимизациями и не стоит заниматься, пока именно в них не упирается производительность.
Но для интереса я накидал бенчмарк. Вот сорец: gist.github.com/tdkkdt/181e3f1e2ee6bb1ce1662bfae1241545
Вот результаты:
gist.github.com/tdkkdt/c3250b9c4d25f13a486cb435870bd7aa
foreach на IEnumerable в среднем в 10 раз медленнее чем foreach на массиве.

Спасибо, очень наглядно.

Я решил раз и навсегда для себя понять стоит так делать или можно сэкономить своё время и написать без временной переменной.

Можно было и не тратить время на проверку. То, что условие цикла for вычисляется один раз перед входом в цикл, написано в документации на C#. Собственно, точно так же этот цикл ведет себя и в других языках.
А можно ссылку на документацию? Интересно почитать подробнее.
условие цикла for вычисляется один раз перед входом в цикл

Странно сформулировано. Условие выхода из цикла в общем случае точно вычисляется на каждой итерации.

Вот здесь

«The condition section, if present, must be a boolean expression. That expression is evaluated before every loop iteration.» По этой логике каждый раз вызывается get_Length у массива на каждой итерации.

Да, простите, не в тот пост ответил, надо было в предыдущий

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

Об этом и статья. Чтобы знать наверняка надо либо прочитать код компилятора, либо провести эксперименты. Я их провёл и теперь знаю.

Этот вопрос с циклами, кстати, и меня много раз интересовал, так что спасибо, прояснили этот момент.

Да, прошу прощения, глупость сморозил. Естественно, на каждой. Я чего-то про инициализацию думал.

Насмешили.
«Условие», а точнее булевое выражение в for вычисляется каждый раз (если оно есть).

Ок, поставим эксперимент
Заголовок спойлера
public class LimitWithCounter
    {
        private static readonly Random Rnd = new Random();
        public int CurrentLimit = 2;
        public int Limit()
        {
            CurrentLimit = Rnd.Next(15) + 5 ;
            Console.Write(CurrentLimit);
            return CurrentLimit;
        }
    }

...

static void Main(string[] args)
        {
            var limit = new LimitWithCounter();
            limit.Limit();      Console.WriteLine($"Limit {limit.CurrentLimit}");
            limit.Limit();      Console.WriteLine($"Limit {limit.CurrentLimit}");
            limit.Limit();      Console.WriteLine($"Limit {limit.CurrentLimit}");
            Console.WriteLine();

            for(int i = 0; i < limit.Limit(); i++)
            {
                Console.WriteLine($"\tStep: {i}");                
            }

            Console.WriteLine();
            Console.ReadLine();
            return;
}



Результат:
Заголовок спойлера
16 Step: 0
15 Step: 1
15 Step: 2
7 Step: 3
12 Step: 4
8 Step: 5
16 Step: 6
11 Step: 7
16 Step: 8
19 Step: 9
11 Step: 10
10


Вывод: Если б мы фикисровали лимит на входе в цикл, потребовалось бы 16 шагов на завершение цикла. Значит, граница выхода из цикла пересчитывается на каждой итерации.
Вы невнимательно прочли статью. Автор даже результаты Jit-компиляции анализировал.
Очевидно, что компилятор достаточно умный, чтобы отличить свойство Length стандартного Array от метода самописного класса. В первом случае Length измениться не может, и компилятор генерирует код, кеширующий длину массива перед циклом. Во втором случае генерируется код, вызывающий метод на каждом цикле.
То, что условие цикла for вычисляется один раз перед входом в цикл, написано в документации на C#.
Я комментировал не сам пост, а комментарий к нему.

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

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

Просто чтобы продемонстрировать опасность слов «очевино» и «компилятор может» в одном предложении.

Чуток кода
            var arr5 = new int[5];
            var arr10 = new int[10];
            var loopArr = arr5;

            for(int i =0; i<loopArr.Length; i++)
            {
                loopArr = arr10;
                Console.WriteLine($"\tStep: {i}");
            }


            Console.WriteLine();
            Console.ReadLine();
            return;


Собственно, у меня выводится Step: 0… Step: 9. Всего 10 значений, хоть я и использовал переменную типа Array, изначально указывающую на массив из 5 элементов. Вывод: длина массива как минимум, в некоторых случаях, не кешируется при использовании в цикле for.


Я ничего не имею против анализа IL кода. Ничего не имею против бенчмарков (даже тут, на хабре, «продвигал» одну либу). И я приветствую документацию к любому языку\либе\API. Но увы, для клиентов критерий истины — практика. Документация имеет свойство устаревать отличаться от реальности. Именно этим объясняется поговорка «программисты обращаются к документации лишь тогда, когда остальные возможности себя исчерпали».

При реюзе (переприсваивании) переменных начинает играть такая штука как SSA
Т.е. для компилятора это уже две разные переменные, да еще и связанные через Фи-ноду. Но это никак не умаляет ваших аргументов.

В js кэширование длины массива точно дает выигрыш, по крайней мере в V8, где-то 5-50% может дать.
НЛО прилетело и опубликовало эту надпись здесь
Как вы мерили, я просто открыл готовый тест jsben.ch/qTUpg ну и раньше эта тема уже обсасывалась на хабре с теми же результатами.
НЛО прилетело и опубликовало эту надпись здесь
На C++ есть был такой вот паттерн написания циклов до того, как появились нормальный range for:
for (
  Container::const_iterator it = container.begin(), end = container.end(); 
  it != end; 
  ++it)
{
  // do something
}

Ваши разработчики случаем раньше на С++ не писали?
На чём они только не писали. Опыта у них точно много.
А это реально быстрее it != container.end()?
Зависит от контейнера. Где-то выигрыш будет, где-то нет. Но проигрыша точно не будет нигде.
НЛО прилетело и опубликовало эту надпись здесь
Ну вот в js ваш посыл не работает. Вряд ли сильно ошибусь если скажу что цикл с работой с dom в 10 раз медленнее чем без.
НЛО прилетело и опубликовало эту надпись здесь
Как раз такие не спички, когда вы обходите 1ккк итераций даже 1% выигрыша уже будет заметен.

Это типа как ждать 5 месяцев или 5 месяцев и 5 дней?

5 дней в отношении к 5 месяцев — это 3%

Да, иногда наш остальной код оптимизирован. А иногда метод, в котором вы оптимизируете, вызывается миллионы или миллиарды раз в час. Ну и там получается нормально.

Да, конечно, в указанных в статье случаях компилятор это делает за вас. Но, однако, бывают и более нетривиальные случаи. Правда?


Должен ли компилятор делать такие оптимизации в данной функции, например?


void whatever(char* s, char* a) {
  int k = 0;
  for (int i = 0; i < strlen(s); ++i) {
    for (int j = 0; j < strlen(s); ++j) {
      if (s[i] == s[j]) {
        a[k++] = s[i]; // Modifying "a"
      }
    }
  }
}

А вот нет: указатель a может ссылаться на регион в памяти, принадлежащей s. В процессе исполнения этого цикла мы модифицируем a, и, следовательно, можем изменить расположение '\0\' в s, которое, в свою очередь, может поменять результат исполнения выражения strlen(s), из-за чего компилятор, конечно, оптимизировать этот код не будет.


Не нужно постоянно полагаться на компилятор, т.к.


Compiler is a tool, not a magic stick.
У нас в .net всё проще. :) есть конечно fixed, unsafe и прочий unmanaged, но это очень не приветствуется.

Это не так.
Во-первых, это приветствуется прятать во всякие методы и классы отдельные.
Во-вторых, есть ref/Span, которые полностью безопасны.

Вот именно, они безопасны, и там нет никаких strlen. Длина всегда хранится отдельно и не может внезапно измениться.
Зависит. Длина контейнера меняться в некоторых случаях может, и корректно по ним итерироваться тоже надо уметь.
В .NET большинство коллекций отслеживают свою версию и итератор проверяет, не поменялась ли версия контейнера по сравнению с той, на которой началось итерирование, но это, строго говоря, не обязательно.
Смотря у кого не приветствуется и смотря где.
Не приветствоваться должны некорректные обобщения.

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

А ещё можно сравнить в RyuJit, LegacyJit x64, Mono и т.д. Увы, этот вопрос возник у меня только для рабочих проектов, потому я и проверял только то, что доступно на работе. Новый .net даёт всё больше возможностей. Сейчас изучаю SIMD в .netcore 3.0 (готовлю статью) и вот там уже можно разгуляться во всю. Правда в домашних условиях применять это некуда.
Геттер у Array.Length? Серьезно? Ну по дизасму же видно, что нет там никакого геттера, это обычная переменная, хранящая длину массива, точно такая же как и у строк и у кучи всего остального и не только в C#…

Конечно серьёзно. Это же property. Скомпилируйте в Debug, будет честный вызов геттера.

Ну так и надо было это отобразить в статье, т.е. показать дизасм дебаг версии. Потому что даже в моём мире Delphi, даже в дебаг версии без оптимизаций, даже при явном указании «якобы функции» len := length(array) нет никаких call в дизасме, регистр напрямую читает длину массива из памяти. Было бы очень странно и как-то даже глупо, если бы в C# было бы по другому. Ну а с геттером — это вообще рукалицо. В смысле прям с геттером, который вызывает целую функцию (уж не спец в C#), а не просто возвращает длину массива.
Это как раз аргумент не в пользу компилятора Delphi.
Дебаг-версия она как раз для того и существует, чтобы иметь возможность пройти в пошаговом режиме через каждую строчку кода, иначе в чём смысл Debug?
А в чем смысл «гулять» внутри кода стандартной библиотеки?
Не боги горшки обжигают. Стандартную библиотеку пишут люди (C++ STL не в счёт), им тоже надо иметь возможность отладки своего творения.
Хорошо, уточню вопрос: в чем смысл разработчику прикладной программы «гулять» внутри кода стандартной библиотеки?
А зачем Вы туда полезли? Ну, не лично Вы, а любой пользователь. Какая разница, что там написано, есть ли промежуточные переменные или что-то ещё, если библиотека достаточно быстрая качественная?
Так я именно об этом и говорю! Незачем мне туда лезть. А значит, отладчик в пошаговом режиме и не должен заходить внутрь, это совершенно корректное поведение.
В смысле не в пользу компилятора Delphi?! Тут и компилятор-то не при делах… Это ж как бы логично, что если имеем некий массив, уж без разницы как он реализован, на уровне компилятора (языка), на уровне рукописного класса или класса стандартной библиотеки, то там должна быть (даже обязана быть) переменная 1 штука, которая всегда хранит текущую длину этого массива. При увеличении количества элементов массива на N, увеличиваем переменную длины массива на N. То же самое при уменьшении. Зачем это проходить отладчиком? Строки обязаны быть реализованы точно таким же образом. Я, собственно, именно по этому и был удивлен геттеру получения длины массива, когда вот просто возьми и прочитай эту самую переменную из памяти в регистр. Её адрес или смещение и всё прочее заранее известны компилятору.
В С++ ни в старом варианте — [], ни в новом array<T, N> не хранится переменная с длиной. В первом случае это просто указатель, во втором — constexpr-метод.
Она явно не хранится, она создается, когда есть ключевое слово array или string (Delphi). Для плюсов, да, там все прям квадратно и перпендикулярно: sizeof(array)/sizeof(array[0]), хотя вот в векторе скорее всего как в дельфи. Впрочем оставим неизлечимо больного в покое (это я про с++)… Ну а уж в C#-то должно же быть как в дельфи хотя бы… разве нет?
На счет «скрытых» элементов массива не скажу, а вот строки дельфи, например, содержат такие элементы как CodePage, ElemSize, RefCount, ну и наш length. У массивов всё точно так же, за минусом CodePage. Они хранятся по отрицательному смещению от первого элемента строки/массива.
зы Мне кажется я сейчас ещё чуточку больше полюбил Delphi :)

Вы забываете, что в C# компиляция идет не сразу в машинный код, а сначала в байт-код. А потому для определения длины массива есть лишь два варианта — или придумывать отдельную команду IL именно для этой цели, или просто вызвать метод из стандартной библиотеки. Создатели .NET решили пойти по второму пути для упрощения спецификации.


А вот JIT уже никто не мешает вызов известного ему метода get_Length() заменить простое на чтение переменной из памяти.

Я не специалист по исходникам .NET. Но копание в .netcore 3.0 показало, что при вызове Array.Length выполняется вот этот «код»:
FCIMPL2(INT32, ArrayNative::GetLength, ArrayBase* array, unsigned int dimension)
{
    FCALL_CONTRACT;

    VALIDATEOBJECT(array);

    if (array==NULL)
        FCThrow(kNullReferenceException);
    
    if (dimension != 0)
    {
        // Check the dimension is within our rank
        unsigned int rank = array->GetRank();
        if (dimension >= rank)
            FCThrow(kIndexOutOfRangeException);
    }
    
    return array->GetBoundsPtr()[dimension];
}
FCIMPLEND
unsigned int rank = array->GetRank();
if (dimension >= rank)

Забавно, тут тоже можно было бы спросить зачем выносить в отдельную переменную.
Эта переменная всё равно осядет в регистр в релизном коде и будет вести себя точно так же, как если бы её не было, потому что используется ровно 1 раз. Зато отлаживать такой код гораздо удобнее если что-то пойдёт не так.
Спасибо кеп. Чуть ниже я именно об этом написал, если игнорировать саму статью и множество подобных комментариев под ней.
Если покопаться в памяти и вспомнить почему выносят «var length», то это не только какая то мнимая оптимизация. Что то вроде правила хорошего тона «ходить с завязанными шнурками» вместо «сунул и пошел». Как приводился пример выше, со вложенными циклами, такой подход «защищает» от проблемы лишнего вызова. То есть когда у многих задач одно решение, то оно превращается в привычку и получается что вроде как ляпается повсюду. А с другой стороны еще и помогает избавиться от длинных строк кода, если есть такие предпочтения. Предпосылок конечно больше у тех кто работал с С++ и С еще с 90-x. Но на таком простом коде это не наглядно даже для старых компиляторов. И логика в некоторых случаях могла меняться по причине большого объема кода, вплоть до запарывания стека самим компилятором.

В общем если есть много времени на то чтобы лишний раз просмотреть как это откомпилировалось то заморачиваться стоит. Но куда проще добавить этот самый «var length» аналог и не нарушать феншуй гармонию кода.
Просто используйте foreach, пожалуйста, он для этого и придуман.
Есть еще один аргумент в пользу использования Array.Length напрямую, без доп. переменных. С включенными оптимизациями JIT компиляторы умеют избавляться от array bounds check при доступе к элементам массива внутри цикла. С доп. переменной такие оптимизации могут не работать:
blogs.msdn.microsoft.com/clrcodegeneration/2009/08/13/array-bounds-check-elimination-in-the-clr
stackoverflow.com/questions/16713076/array-bounds-check-efficiency-in-net-4-and-above

В ваших примерах bounds check присутствуют:
cmp esi, eax
jae 056e2d31


Возможно, это ограничение LegacyJIT x86. Либо код запускался без оптимизаций. RyuJIT x64 должен быть умнее (https://stackoverflow.com/a/17138483/136138).
Вы удивитесь, но проверка границ массива практически не влияет на производительность. Современные процессоры умеют делать это очень эффективно, так что время доступа к элементу массива с проверкой границ увеличивается всего на (0.881 ± 0.009) %
Более подробно здесь
Первый раз слышу чтобы процессор сам проверял границы массива. Вообще непонятно для чего эти проверки, возможно в режиме дебаг и прокатит, но в рабочем коде для чего? Особенно что цикл в этих самых границах.

Вы удивитесь, но влияет и очень сильно.

Не удивлюсь, т.к. про это написано по ссылке со StackOverflow, которую я привел в своем сообщении (https://stackoverflow.com/a/16719474/136138)

На практике на моем i7-4200HQ разница существенна для RyuJIT x64. Я взял benchmark gist.github.com/aensidhe/0d412e142eb29fd21eea01b5f6462d41 и сравнил For() и ForReverse(). В первом случае RyuJIT убирает bounds check, во втором — оставляет. И у меня на машине получаются такие результаты:
BenchmarkDotNet=v0.11.3, OS=Windows 10.0.17134.472 (1803/April2018Update/Redstone4)
Intel Core i7-4720HQ CPU 2.60GHz (Haswell), 1 CPU, 8 logical and 4 physical cores
Frequency=2533210 Hz, Resolution=394.7561 ns, Timer=TSC
  [Host]        : .NET Framework 4.7.2 (CLR 4.0.30319.42000), 32bit LegacyJIT-v4.7.3260.0
  Clr LegacyJit : .NET Framework 4.7.2 (CLR 4.0.30319.42000), 64bit LegacyJIT/clrjit-v4.7.3260.0;compatjit-v4.7.3260.0
  Clr RyuJit    : .NET Framework 4.7.2 (CLR 4.0.30319.42000), 64bit RyuJIT-v4.7.3260.0

Platform=X64  Runtime=Clr

     Method |           Job |       Jit |     Mean |    Error |   StdDev |
----------- |-------------- |---------- |---------:|---------:|---------:|
        For | Clr LegacyJit | LegacyJit | 724.0 ns | 1.945 ns | 1.819 ns |
 ForReverse | Clr LegacyJit | LegacyJit | 725.5 ns | 9.341 ns | 7.800 ns |
        For |    Clr RyuJit |    RyuJit | 583.3 ns | 2.706 ns | 2.259 ns |
 ForReverse |    Clr RyuJit |    RyuJit | 732.2 ns | 2.568 ns | 2.402 ns |

Это странно. Запустите ещё пару раз. В релизе. Из командной строки. И не используйте при этом компьютер.

Явная оптимизация лучше неявной.
Если ничего более важного в голове хранить не нужно, можно хранить и детали внутреннего устройства компилятора, конечно.

Плюсую. Эта статья скорее о том, что неявная оптимизация может сильно подгадить. И вообще не понятно, для каких нубов нужна такая «неявная оптимизация». Я не пишу на C#, но например, если в теле цикла меняется размер массива, или если массив может меняться из другого потока, то «оптимизированная» компилятором функция может вести себя не так, как ожидает программист. Т.к. в условии цикла, по сути, будет проверяться не текущий размер массива, а размер массива на момент входа в цикл. Хорошо, если массив уменьшается, тогда, возможно, Вы получите исключение типа «выход за пределы массива», и будете долго врубаться, в чем собственно дело. А если массив увеличивается, а Ваш цикл (поток?) проходит не по всем его элементам, а только по части, ошибки будут очень неявными, и искать их будет на порядок труднее…
Компилятор не будет оптимизировать, если он не уверен, что размер не меняется. Если в теле цикла будет что-то вроде такого
for (int i=0; i<array.getLength(); i++) {
if (something) array.nonConstMethod();
}

то оптимизации не произойдет и обращение к array.length() будет происходить при каждой итерации.
Собственно, иногда и нужно сохранять размер контейнера в локальную переменную — когда компилятор не может быть уверенным, а программист может.
Мне тоже подумалось про «если будет в теле цикла», но вот то, что Вы написали — это Ваше 100%-ное знание или просто идеализация компиляторов? А если будет поток? Тоже компилятор будет анализировать, используется ли функция в потоке, или меняется ли массив другими потоками? Теоретически это возможно, практически это тоже и возможно, и скорее всего так и есть, НО… Всё-таки я привык к тому, что «машина делает ровно то, что Вы написали, а не то, что Вы имели в виду»… Вот мой последний проект, например, работает нормально только при отключенной оптимизации. И вообще у нас так повелось, что если программа сбоит, попробуй отключить оптимизацию.

Это общий принцип любой оптимизации — не менять наблюдаемого поведения. Если проект работает только при отключенной оптимизации — значит, либо в коде где-то UB, либо в компиляторе баг. Второе возможно, но куда реже чем первое.


А если будет поток?

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

Насчёт потоков и синхронизации — по большей части согласен. Но все равно слишком много «если». Все-таки правильно написал товарищ выше, что лучше писать явно оптимальный код, который и тебя будет держать в тонусе, и твоих последователей, нежели держать в голове нюансы работы компилятора.
Тем не менее, подумав еще раз, склоняюсь к тому, что явная оптимизация лучше. В общем случае, если Вы программируете на всем, что допускает возможность программирования, это сослужит Вам добрую службу.
Например, пишете Вы код, знаете хорошо повадки компилятора. Ок. Потом Вы решили сделать Ваше приложение кроссплатформенным, каков будет масштаб изменений? Явно оптимизированное приложение гораздо проще перенести, в другой ОС будет другой компилятор, который не будет столь же продвинутым, и сделает неэффективный код.
У Вас есть хорошая библиотека, которую Вам нужно перенести на другой ЯП для другого проекта. Опять же неизвестно, что там будет за компилятор, кто будет исполнять полученный код. Известно только одно — явно оптимизированный код с большей вероятностью будет выполняться более оптимально везде, где это может быть. Нормально делай — нормально будет.
И вот куда мне во всей этой куче ситуаций нужно впихнуть знание, что «компилятор С# в такой-то конкретной ситуации неявно догадывается о том, что я хочу сделать». ИМХО, на таком уровне хорошо бы и мне представлять, что именно я написал, и какие есть пути оптимизации.
На мой взгляд, явная оптимизация всё-таки показатель того, что человек понимает, что происходит за ширмой IDE, в которой он работает. И она учит этому пониманию и других, особенно если это прокомментировано в коде.
У Вас есть хорошая библиотека, которую Вам нужно перенести на другой ЯП для другого проекта.

Поскольку я пишу на C#, то и переносить я буду ее с другого языка программирования на C#, и никак иначе. И я не вижу чем знание "повадок" компилятора мне в этом помешает...


А если я захочу перейти на другой ЯП (например, на Rust) — то я просто изучу основные "повадки" компилятора этого самого другого языка.

вот то, что Вы написали — это Ваше 100%-ное знание или просто идеализация компиляторов?

На разумном уровне оптимизации, компиляторы делают только то, в чем уверены. Я не знаю, лезут ли компиляторы каждый раз в тело метода, или просто смотрят на модификаторы (вроде const), решая, когда переменную можно заоптимизировать, а когда нет. Я даже уверен, что для разных языков будет по разному.
Я, конечно, не идеализирую компиляторы, но в плане оптимизаций после этой статьи про предсказание ветвлений, не решаюсь высказываться в духе «Ассемблер быстрее, чем Си».
А если будет поток? Тоже компилятор будет анализировать, используется ли функция в потоке, или меняется ли массив другими потоками?
Тогда трындец. Ставьте volatile там, где у вас многопоточный доступ к переменной.
Эта статья скорее о том, что неявная оптимизация может сильно подгадить.

Я не пишу на C#, но например, если в теле цикла меняется размер массива

Ага, «не читал, но осуждаю». В .NET все массивы без исключения фиксированной длины. Она не может измениться, можно только создать новый массив другого размера.
Пффф, а я же думал: «Ну не пишешь ведь на C#, ну иди мимо, не пиши». Но нет, решил натянуть свою сову на чужой глобус :)
Спасибо, буду знать :)
После Вашей статьи мой мир теперь уже никогда не станет прежним
Зная структуру (memory layout) массива, можно посмотреть здесь, в общем понятно, что смысла сохранять длину массива в локальную переменную смысла не имеет и обращение к Length хорошо оптимизированно. imho на мой взгляд более интересным вопросом является, каким образом стоит обращаться со свойством коллекций Count.

Спасибо. Сегодня я узнал, что вынесение array.Length из условия цикла в отдельную переменную больше не приводит к замедлению. Проверка выхода за пределы массива оптимизируется в обоих случаях. Видать, компиляторы поумнели. Хотя, надо ещё Моно проверить, там это важно.

Выше бенчмарк, запустите его на моно.

Запущу обязательно. Вечером.

Проверил. Убрал тест Aggregate, добавил несколько с указателями из любопытства. На NET Core не проверял, оно у меня не установлено. Добавил Моно и x86.


Результаты забавные


Если коротко: нехрен микрооптимизировать наугад, без тестов на целевой платформе, даже если думаешь, что знаешь.


  1. LegacyJit x64 отдал предпочтение методу с array.Length, вынесенному из условия цикла (540 нс против 770 нс). Но проверка на выход индекса за пределы массива есть в обоих случаях. Нихрена она не убрана.
  2. В других конфигурациях NET Framework-компиляторам пофиг, скорость одинаковая. Хотя RyuJit догадался убрать проверку в обоих случаях (они вообще идентичные с точностью до замены регистров). Оставил её только в варианте ForReverse. Поэтому без необходимости ходить по массиву в обратную сторону наверное не надо, хотя разница невелика (600 нс против 640 нс).
  3. Компилятор Mono x64 тоже выбрал вариант с вынесением array.Length из цикла (860 нс против 1040 нс). Так же и в Mono x86 (1800 нс против 1960 нс). Осталась там проверка на выход за пределы или нет, непонятно. Дизассемблер непривычный и без отображения меток. Вообще ХЗ что там происходит.
  4. Замена статического массива на экземплярный дала изменение только в одном пункте: немного ускорился For на Mono x64, но на общую картину это не повлияло.
  5. Моно генерирует какие-то бессмысленные портянки машинных инструкций, перетасовывая регистры взад-вперёд. Дизассемблер методов с развёрнутыми циклами сам разворачивается на несколько экранов. В результате чем сильнее цикл развёрнут, тем больше кода и всё медленнее. При том что для LegacyJit x64 это тест даёт лучший результат из всех возможных.
  6. Лучше таки использовать foreach и для скорости, и для наглядности, и писать быстрее, и негде ошибиться.
  7. Вариант с указателями тоже неплох (почему-то из трёх похожих лучше всего именно PtrA). Когда очень надо, все средства хороши.

Без тестов на .net core выводы делать так себе. Потому что .net framework переведён в maintenance мод и в .net core была куча оптимизаций (в рантайме, джите и BCL), не все из которых портированы обратно в полный фреймворк.


Спасибо за тесты.

Я не специалист в C#, но в некоторых языках реализация foreach не гарантирует строго последовательного перебора набора. т.е. могут браться элементы вразнобой (как они там хранятся в памяти), а не строго подряд. В C# такого не наблюдается?

Это зависит от коллекции.


foreach (var x in collection) {}

Это всего лишь сахар для:


using (var e = collection.GetEnumerator())
{
    while (e.MoveNext()) {}
}

Так что всё зависит от реализации MoveNext.


Если тип collection — массив и компилятору это точно известно, то там будет просто проход по индексу.

Если там элементы хранятся в памяти вразнобой — то откуда вообще возьмется быстрый доступ по индексу?
За порядок элементов в IEnumerable отвечает реализация энумератора. Конечно, можно написать энумератор, использующий рандомайзер для выборки элементов, но, как правило, энумерация стабильна.
Нестабильной она может быть когда коллекция приходит извне — например это результат выборки из базы данных или файловой системы.
Стоит упомянуть, что если использовать другое свойство:
for (int i = 0; i < myObj.MyProperty; i++)

То компилятор может не оптимизировать и вызов будет каждый раз.
blogs.msdn.microsoft.com/vancem/2008/08/19/to-inline-or-not-to-inline-that-is-the-question
Поэтому не вижу ничего страшного, что кто-то выделяет в локальную переменную. Явное лучше неявного.

https://blogs.msdn.microsoft.com/clrcodegeneration/2009/08/13/array-bounds-check-elimination-in-the-clr/


Advice 2: When possible, use “a.Length” to bound a loop whose index variable is used to index into “a”.

Как минимум раньше эта «оптимизация» приводила к обратному эффекту. Вместо явного (i < a.Length) JIT видел сравнение индекса с какой-то левой переменной и на всякий случай вставлял в код проверку на выход за границы массива.

А что должно поменяться? У массива нижняя граница, к слову, не всегда 0. У них даже название есть для массивов специального вида — SZArray.
А в чём смысл неполной статьи?

Без разницы, см. бенчмарк

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

Не знаю, ни разу не встречал. В 99% случаях люди пишут `var result = arr.Sum(x=>x.Something)`, и в оставшемся 1% случае foreach.

Про деоптимизацию из-за сохранения длины в локальную переменную писали уже прилично раз, но раз статья получила столько положительных отзывов, видимо это всё еще не всем ясно.

К слову, for/foreach должны давать одинаковую производительность для массивов. Для список результат иной, насколько я помню.

Нет никакой деоптимизации.

Ну как нет, могли пойти по быстрому пути, но нарушили эвристику компилятора.

Вообще, не знаю реального кода, который бы на это заявзывался. Такой код навреное только на заре карьеры пишут. Я когда джуном был, тоже в циклах где мог byte/short использовал, если позволяло максимальное значение циклов. И про себя думал «вот поэтому мне 4гб памяти и не хватает, во всех примеров программисты разбазаривают её, int пишут!».
НЛО прилетело и опубликовало эту надпись здесь

Не хранится, выше показывали плюсовый код, который вызывается при обращении к свойству.

В Pascal так писать не имеет ни малейшего смысла, поскольку там верхняя граница цикла for вычисляется строго один раз.

Ммм, я слышал мнение что на каждой итерации фор, алоцируется память под Length, и если Length закэшировать, то типа будет быстрее и не будет засираться память.
Length — int, это value type. С каких пор в .net возврат value type стал требовать аллокаций памяти?
но ведь это примитивный пример. в общем случае как компилятор может гарантировать неизменность размера массива? вызов метода напрочь убьёт все гарантии.
теоретически разумеется.
Покажите реальный сценарий, приводящий к таким последствиям.
Теория понятна и даже местами её можно как-то логически подтвердить. Однако Ваше утверждение пахнет чрезмерным обобщением.
я упустил что речь идёт об Array. подразумевал абстрактный контейнер со свойством Lenght. посылаю голову пеплом.
В .NET — запросто: длина массива неизменяема. Может поменяться только ссылка на сам массив, но если она находится в локальной переменной, то тоже не может поменяться сторонним кодом.
Вообще, насколько я понимаю, частично описываемая в статье проблема актуальна и для С\С++.

От себя добавлю, что вы не рассмотрели несколько разных случаев.

Во первых, у вас вообще не озвучено как массив передаётся в функцию, по ссылке или по значению. В первом случае array.Length это, потенциально, смена контекста, а это, как известно, самая затратная операция.

Во вторых, как тут уже написали выше в комментариях — случай вложенных циклов. Если во вложенном цикле произойдёт обращение к внешнему array.Length, это, возможно тоже спровоцирует смену контекста.

Однако, всё зависит от компилятора и конкретного куска кода. Тут же в комментариях, есть примеры, что достаточно умный компилятор при относительно простом коде создаст локальную переменную автоматически (что в ваших примерах и происходит).

Пока всё ещё считаю, что лучше явно указывать компилятору что делать, инициализируя локальную переменную.
Уточните пожалуйста, какие именно массивы в С++ и особенно в С Вы имеете в виду?
T x[Size], T*, std::array<T,Size>, std::vector или что-то ещё?
Да, собсна, те же массивы, которые имел ввиду автор статьи.
Зарегистрируйтесь на Хабре, чтобы оставить комментарий

Публикации

Истории