Найти в Дзене

Array: for/foreach или unsafe

Оглавление

Я много работаю с массивами, поэтому хотел бы освежить тему того, как наиболее быстро по нему перемещаться в C#. Речь пойдёт об экономии наносекунд и оптимизации на уровне IL-кода. Кажется, что в 99% случаев вам это знать не нужно и задумываться об этом не стоит. Тем не менее, для горячих сценариев или если вы из high-load или геймдева, вам это может пригодиться.

Проблема: проверка границ массива

Ранее все выбирали обычный for, чтобы просто пробежаться по массиву. Ну, во всяком случае те, кто топил за перформанс. Это ведь оптимально и естественно, очень похоже на C++. И должно быть максимально быстро, правда? Да, но есть нюанс.

Основная проблема в том, что dotnet это не C++. Код исполняется несколько иначе, чем так, как вы видите. Dotnet следит за тем, чтобы вы не выходили в область памяти, которая вам не принадлежит. Следовательно, чтобы выбросить правильный exception и перед доступом к элементу массива, нужно проверить границы массива (array bounds check).

Каждый раз, когда вы пишите array[i], должна происходить эта проверка. Естественно, есть нюансы. Например, когда вы работаете внутри цикла for. Если в for использовать (var i = 0; i < array.Length; i++), то проверка осуществляется всего однажды, как раз вот в этой вот строчке.

Есть ещё более интересный способ избежать проверки границ массива: сделать магические (uint)index >= (uint)_size. Это подсказка для компилятора, что программист сам проверил выход за границу массива и дополнительно вставлять проверку в IL-код не требуется. Этот хитрый ход может применятся в изощрённых случаях и, например, при написании собственных коллекций.

При этом, есть ещё один, максимально простой и всем известный способ избежать проверок выхода за границы массива.

Foreach - всё просто

Тест предельно простой. У нас есть массив int'ов определённого размера и мы хотим выяснить, как по нему бежать максимально быстро. Собственно, никаких новостей не будет в том, что бежать по нему быстрее всего с помощью foreach. Результаты benchmark'a подтверждают:

Benchmark: C# array enumeration by for, foreach, span
Benchmark: C# array enumeration by for, foreach, span

Обычный foreach (см. колонку Ratio) самый первый во всех без исключения популярных и используемых версия фреймворка. Второе место закономерно занимает foreach по array.AsSpan(). Ну, кроме версии старого .NET Framework'а, конечно. В старом framework'e мы можем быть более хитрыми и воспользоваться собственным перечислителем, который очень похож на enumerator у Span'a.

Почему же foreach такой быстрый? Ответ очевиден - enumerator проверяет границы массива только один раз, а далее прозрачно для dotnet'a бежит по нему. Почему unsafe оказался медленнее? Для меня в этом загадка. При этом, я уверен, что большинство программистов не захотят тащить в свой код unsafe без видимой на то причины. Увы, в этом месте я написал максимально тупой unsafe и разместил его тут скорее для формальности - проверить и этот вариант.

Если возвращаться к foreach, то у него есть проблема, которая возникает при одновременном чтении и записи элементов в массив.

Проблема: чтение и запись одновременно

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

Мы с вами знаем, что лучше бы ходить в массив (использовать индексатор - array[i]) минимальное количество раз за итерацию. Как это сделать, если после чтения вам нужно присвоить array[i] = newValue? Всё опять-таки просто, но только начиная с C# 7.3. Именно тогда мы научились получать элемент массива по ref: ref var element = ref _array[i]. Если кто не знает, это ссылка на элемент массива, присвоив которой новое значение, вы получите новое значение в самом массиве.

Кстати, почему нельзя использовать тот же самый foreach? Увы, дело в том, что foreach возвращает ref readonly, то есть ссылку только на чтение. Если вам всё-таки очень хочется работать с элементами массива в foreach like стиле, то можно снова воспользоваться собственным enumerator или просто сделать array.AsSpan(). Обе эти структуры возвращают изменяемую ссылку на элемент массива, что позволит вам выполнить чтение, а затем и присваивание.

Тестируем? Кажется, результаты benchmark'а будут интересные!

Benchmark: C# array read/write by ref and foreach by span
Benchmark: C# array read/write by ref and foreach by span

Да, действительно, интересные. Во-первых, в случае .NET Framework 4.6.1, победа уходит unsafe коду. Это весьма очевидно, так как под капотом Span'a достаточно много похожего на unsafe кода. А старый framework может работать со Span только в качестве адаптера, чтобы обеспечить совместимость кода. Однако, если вам не хочется уходить в дебри unsafe, рекомендую опять таки воспользоваться получением элемента массива по ссылке. Ну или из собственного перечислителя.

Второй интересный момент заключается в том, что кастомный enumerator сильно отстаёт от Span в версии .NET Core 3.1. Это странно, учитывая то, что код очень похож на код из Span'a. Честно говоря, я так и не понял почему так. Однако, это и не важно, так как лучше выбирать array.AsSpan(), чем долго и упорно оптимизировать и писать свои велосипеды.

В-третьих, Unsafe benchmark закономерно показал хороший результат, поскольку, думаю, именно так и выглядит Span под глубоким капотом. Я попытался подыскать подтверждение моей мысли, но, увы, есть только результаты замеров.

Случайный доступ к элементам массива

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

Benchmark: random access to element of array via indexer, memory, unsafe/pin
Benchmark: random access to element of array via indexer, memory, unsafe/pin

Магии не случилось, старый-добрый доступ по индексу один из самых быстрых. Вы можете заметить, что доступ через unsafe-магию чуть быстрее, но так делать не надо. Это копейки по сравнению с тем, какое возрастание сложности кода мы получаем.

При этом, для меня стало неожиданным то, что доступ через Memory почти в три раза медленнее. Очень странно, учитывая то, что кода там не так чтобы много. Лишнего - тем более.

Выводы

1. Используйте foreach там, где вам необходимо быстро прочитать элементы из массива.

2. Если вам нужно изменять значения в массиве, используйте array.AsSpan и бегите по элементам, получаемым из его enumerator'a. В этом случае необходимо получать ссылку на элемент массива - foreach (ref var element in array.AsSpan()).

3. Если вы не используете современный dotnet, рекомендую присмотреться к собственной имплементации Enumerator'a по array.

4. Доступ по индексу, конечно, самый эффективный.

5. Не надо использовать unsafe. Во-первых, этого не одобрят коллеги, а во-вторых, это не всегда так быстро, как рассказывают.

#csharp #dotnet #for #foreach #array #span #программирование

Наука
7 млн интересуются