Pull to refresh

Comments 27

Давно уже нет никакого вопроса о выборе между одним и другим: какая семантика вам нужна, тем и пользуйтесь. Учитывая, что в значимой части случаев в реальной жизни вы имеете дело не с массивами и списками, а с enumerable, вопроса и вовсе не стоит.
С тех пор, как тактовая частота упёрлась в физические пределы, этот вопрос снова появился. Мы уже не можем сказать пользователю «купите процессор побыстрее» — таких просто не существует.
Зато можно купить процессор с большим числом ядер. И более декларативный стиль (foreach, LINQ) позволяет распараллеливать задачи прозрачно.

Мелкая оптимизация — тупик. Оверхеда не так много, на нем особо не выиграть.

По этому поводу мне нравится вот этот доклад Мартина Одерски: Future-proofing collections: from mutable to persistent to parallel. Про Scala, но и для C# выводы верны.
Процессор с бОльшим числом ядер будет потреблять бОльшую мощность. И грустно было бы покупать чемодан с батарейками только из-за того, что программист поленился преобразовать список длиной в сотню миллионов элементов в массив… И оверхеда очень много: почти всё время работы процессора уходит на перетасовывание байтов в памяти, а не на расчёты.
А, кстати, как foreach по списку распараллеливается штатными средствами .NET (без подключения всяких Microsoft Practices)?
К сожалению foreach в C# «оптимизированный» — не через лямбду. Так что придется переписать на ForEach или вообще использовать PLINQ.

Например в scala аналог foreach — изначально сахар над методом, принимающим лямбду, так что параллелится без переписывания. Вообще в scala уже показано, что декларативная запись легко превращается в оптимизированный, но не наоборот. Тот же аналог foreach добавлением 1 слова (без каких-либо других изменений) превращается в параллельную или оптимизированную (без лямбды) версию.

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

Ну… нам сейчас приходится ставить на борт сканера процессор с вдвое меньшей тактовой частотой, чтобы сэкономить дополнительные 6-10 ватт и дать пользователю возможность работать в автономном режиме лишний час. При том, что рынок требует и повышения плотности потока, и более разнообразных возможностей обработки в реальном времени. Тут не только о выборе циклов приходится думать, но и как бы не скопировать массив лишний раз. А уйти с C# не получается — нет ресурсов, да и гибкость потеряем.
Список в сотню миллионов элементов тем более не надо никуда преобразовывать — память жалко. Как раз для таких ситуаций и придуман IEnumerable (так что там и список-то лишний).
Тоже неверно.

Во-первых, я давно не видел программ, которые бы тормозили из-за разницы производительности между for и foreach — проблемы всегда были на стороне на порядок более медленных компонентов системы.

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

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

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

Кстати, 2,5 нс на итерацию суммы простого массива в цикле — как-то медленно.
Нет, здесь все правильно. CLR не умеет инлайнить виртуальные вызовы, а любой вызов делегата — виртуальный. И это реально просаживает производительность в синтетических тестах, где в теле цикла практически ничего не делают.
Объясните, почему самый просто цикл — 2,5 нс. Должно быть 0,5, максимум 1.
А почему, собственно, должно?
Потому что его скорость ограничивает только латентность условного перехода, т. е. 2 такта на итерацию. 2,5 нс это 7,5 тактов, которые тут тратить просто не на что. Проверьте множитель процессора.
Скорее всего, просто тест забыли скомпилировать с /optimize+, или запустили из VS. И то, и другое подавляет оптимизации JIT (и, соответственно, в коде остается проверка на длину на каждой итерации).
Мне кажется, что на несинтетических примерах замена for на foreach вообще заметной разницы в скорости не даст. А вот время разбирающихся в коде людей, если увлечься такой оптимизацией, можно и потерять.
C# For vs. Foreach — для массивов foreach может оказаться быстрее, чем for, если требуется несколько раз обращаться к элементу по индексу.
Согласен с комментариями выше — задумываться над выбором for/foreach не должно требоваться — компилятор должен оптимизировать — это его работа. Однако, знать что «под капотом» у обеих конструкций стоит, но не помешала бы статья про оптимизации циклов в .NET, дабы стало в очередной раз ясно, что преждевременная оптимизация — корень зла.
Обычно стараюсь использовать foreach или .ForEach для краткости и чистоты кода. За исключением случаев, когда в цикле нужно менять элементы коллекции, тогда без for не обойдёшься. Пусть производительность и разная, но в большинстве случаев это не критично. PS: игроделы, не читайте последнее предложение :)
А вы уверены в правильности составленного бенчмарка?
Я сделал свою версию замеров производительности в рамках проекта BenchmarkDotNet: примеры ForeachArray и ForeachList. Мои результаты несколько отличаются от ваших (количество итераций — 200000000):
ArrayForWithoutOptimization : 143ms
ArrayForWithOptimization    : 143ms
ArrayForeach                : 142ms
ArrayForEach                : 556ms

ListForWithoutOptimization  : 280ms
ListForWithOptimization     : 213ms
ListForeach                 : 628ms
ListForEach                 : 833ms

По массивам вопросов нет, а вот о производительности итерирования по List-ам я готов подискутировать. Я считаю, что обычный for работает быстрее, как этого и следовало ожидать.
Выше приведены результаты для x64. Для чистоты эксперимента сделал тот же тест для x86 (в этом случае для List-ов пришлось вдвое уменьшить количество итераций, чтобы не было проблем с памятью):
ArrayForWithoutOptimization : 111ms
ArrayForWithOptimization    : 110ms
ArrayForeach                : 110ms
ArrayForEach                : 625ms

ListForWithoutOptimization  : 106ms
ListForWithOptimization     : 106ms
ListForeach                 : 375ms
ListForEach                 : 451ms

Конфигурация моей машины: Intel Core i7-3632QM CPU 2.20GHz, 8Гб ОЗУ

Мне было интересно продолжить сравнение foreach при итерации по CollectionsMarshal.AsSpan, он оказывается быстрее обычного for на списках.

ListFor: 10 μs
ListForeach: 18 μs
ListForeachSpan: 9 μs

Для 2^14 элементов на 64 битной машине.

Оверхед на циклах это абсолютная мелочь. Из критичных по производительности вещей с циклами гораздо чаще встречается неправильный выбор коллекций для работы (игнор существования HashSet или Dictionary там, где необходим поиск по значению элемента).
Могу подкинуть еще одну интересную тему, если хочется покопаться в .NET. Есть такой класс — Dictionaty<TKey, TValue>, для выяснения равенства значений там используется IEqualityComparer. Если почитать исходники, то можно увидеть, что если вызвать конструктор словаря без компарера в качестве параметра, то в качестве компарера будет использован EqualityComparer<TKey>.Default.
А он реализован так:

[Serializable]
internal class GenericEqualityComparer<t>: EqualityComparer<t> where T: IEquatable<t>
{
     [Pure]
     public override bool Equals(T x, T y) {
        if (x != null) {
            if (y != null) return x.Equals(y);
            return false;
        }
        if (y != null) return false;
        return true;
    }
}


Т.е. по логике вещей, что бы сравнить 2 целых числа, каждое из чисел нужно преобразовать в ссылку на интерфейс IEquatable<int>, а интерфейс как известно — reference-тип, что бы вызвать метод Equals нужно упаковать объект. И вот тут я немного не догоняю, выходит, что CLR либо умеет вызывать методы объектов value-типов не упаковывая их, что не вяжется с понятием интерфейса, либо поиск в словаре с компарером по умолчанию производит упаковку элементов, а значит приличное количество мусора при интенсивном поиске, о чем нам как-бы говорит сравнение параметров с null! Если порыться профилировщиком, то вроде бы этого не происходит. Интересно, для этого там введены специальные хаки — EnumComparer и ByteComparer?
CLR умеет вызывать методы объектов value-типов, не упаковывая их. Не совсем понятно, почему это «не вяжется с понятием интерфейса». В приведенном вами коде нет ни одного значения reference-типа. Ведь переменные x и y имеют тип T, а не IEquatable. Про T известно, что он реализовывает IEquatable, но это не означает вызов методов через ссылку интерфейсного типа.

Если говорить о конкретной реализации, то при генерации кода JIT создает отдельный вариант метода с дженериками для каждой уникальной комбинации value-типов. Соответственно, в этом варианте все вызовы методов резолвятся в момент JIT-компиляции, т.к. все типы точно известны, и подстановки значения унаследованного типа быть не может (т.к. все value types — sealed).
Статье явно не хватает сравнения и анализа генерируемого IL кода.
IL от C# отличаться сильно не будет (за исключением разворачивания foreach в for для массива) — что в C# написано, то в один к одному будет в IL.
Вся оптимизирующая магия выполняется JIT-компиляцией:
— значительная часть кода inline-ится
— выкидываются лишние проверки границ
и т.д.
Стоило также протестировать скорость выполнения linq-функций: Sum и Aggregate, раз уж для тестов была выбрана задача суммирования последовательности
Sign up to leave a comment.

Articles