
Я много работаю с массивами, поэтому хотел бы освежить тему того, как наиболее быстро по нему перемещаться в C#. Речь пойдёт об экономии наносекунд и оптимизации на уровне IL-кода. Кажется, что в 99% случаев вам это знать не нужно и задумываться об этом не стоит. Тем не менее, для горячих сценариев или если мы из high-load или геймдева, нам это может пригодиться.
Проблема: проверка границ массива
Ранее все выбирали обычный for, чтобы просто пробежаться по массиву. Ну, во всяком случае те, кто топил за перформанс. Это ведь оптимально и естественно, очень похоже на C++. И должно быть максимально быстро, правда? Да, но есть нюанс.
Основная проблема в том, что dotnet это не C++. Код исполняется несколько иначе, чем так, как мы видим. Dotnet следит за тем, чтобы мы не выходили в область памяти, которая нам не принадлежит. Следовательно, чтобы выбросить правильный exception и перед доступом к элементу массива, нужно проверить границы массива (array bounds check).
Каждый раз, когда мы пишем array[i], внутри .NET должна происходить эта проверка. Естественно, есть нюансы. Например, когда мы работаем внутри цикла for. Если в for использовать var i = 0; i < array.Length; i++, то проверка осуществляется всего однажды, как раз вот в этой вот строчке.
Есть ещё более интересный способ избежать проверки границ массива: сделать магические (uint)index <= (uint)size. Это подсказка для компилятора, что программист сам проверил выход за границу массива и дополнительно вставлять проверку в IL-код не требуется. Этот хитрый ход может применятся в изощрённых случаях и, например, при написании собственных коллекций.
При этом, есть ещё один, максимально простой и всем известный способ избежать проверок выхода за границы массива.
Foreach — всё просто
Тест предельно простой. У нас есть массив int’ов определённого размера и мы хотим выяснить, как по нему бежать максимально быстро. Собственно, никаких новостей не будет в том, что бежать по нему быстрее всего (обычными способами) с помощью foreach. Результаты benchmark’a подтверждают:
|
Method |
Runtime |
Mean |
Ratio |
|---|---|---|---|
|
For |
.NET 6.0 |
504.5 ns |
2.00 |
|
Foreach |
.NET 6.0 |
252.3 ns |
1.00 |
|
ForeachCustom |
.NET 6.0 |
254.8 ns |
1.01 |
|
ForeachSpan |
.NET 6.0 |
252.3 ns |
1.00 |
|
Unsafe |
.NET 6.0 |
260.3 ns |
1.03 |
|
UnsafeFixed |
.NET 6.0 |
251.6 ns |
1.00 |
|
|
|
|
|
|
For |
.NET Core 3.1 |
516.3 ns |
1.02 |
|
Foreach |
.NET Core 3.1 |
505.6 ns |
1.00 |
|
ForeachCustom |
.NET Core 3.1 |
503.9 ns |
1.00 |
|
ForeachSpan |
.NET Core 3.1 |
252.4 ns |
0.50 |
|
Unsafe |
.NET Core 3.1 |
261.4 ns |
0.52 |
|
UnsafeFixed |
.NET Core 3.1 |
251.8 ns |
0.50 |
|
|
|
|
|
|
For |
.NET Framework 4.8 |
506.1 ns |
1.99 |
|
Foreach |
.NET Framework 4.8 |
253.7 ns |
1.00 |
|
ForeachCustom |
.NET Framework 4.8 |
437.6 ns |
1.72 |
|
ForeachSpan |
.NET Framework 4.8 |
760.1 ns |
3.00 |
|
Unsafe |
.NET Framework 4.8 |
505.0 ns |
1.99 |
|
UnsafeFixed |
.NET Framework 4.8 |
252.2 ns |
0.99 |
Обычный foreach (см. колонку Ratio) хороший вариант в популярных и используемых версиях .NET. Однако, в .NET Core 3.1 следует присмотреться к foreach по array.AsSpan() или воспользоваться собственным перечислителем, который очень похож на enumerator у Span’a, если нам, почему-то, не нравятся Span’ы. Ещё в .NET Core 3.1 стоит присмотреться к unsafe, если наши коллеги не против. Спасибо @onyxmaster за дополнение.
Почему же foreach такой быстрый? Ответ очевиден — enumerator проверяет границы массива только один раз, а далее прозрачно для dotnet’a бежит по нему.
Почему unsafe оказался примерно на том же уровне, что и foreach в .NET 6 и в старом фреймворке? Для меня загадка. При этом, я уверен, что большинство программистов не захотят тащить в свой код unsafe без видимой на то причины. Увы, в этом месте я написал максимально тупой unsafe и разместил его тут скорее для формальности — проверить и этот вариант. Хороший код размещён в методе UnsafeFixed, который я взял из комментариев.
Однако, если возвращаться к 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’а будут интересные!
|
Method |
Runtime |
Mean |
Ratio |
|---|---|---|---|
|
ForeachCustom |
.NET 6.0 |
471.5 ns |
0.99 |
|
ForeachSpan |
.NET 6.0 |
474.3 ns |
1.00 |
|
Reference |
.NET 6.0 |
518.7 ns |
1.09 |
|
Terrible |
.NET 6.0 |
523.3 ns |
1.10 |
|
Unsafe |
.NET 6.0 |
505.5 ns |
1.07 |
|
|
|
|
|
|
ForeachCustom |
.NET Core 3.1 |
438.8 ns |
0.87 |
|
ForeachSpan |
.NET Core 3.1 |
504.8 ns |
1.00 |
|
Reference |
.NET Core 3.1 |
532.0 ns |
1.05 |
|
Terrible |
.NET Core 3.1 |
529.5 ns |
1.05 |
|
Unsafe |
.NET Core 3.1 |
469.6 ns |
0.93 |
|
|
|
|
|
|
ForeachCustom |
.NET Framework 4.8 |
447.3 ns |
0.85 |
|
ForeachSpan |
.NET Framework 4.8 |
527.6 ns |
1.00 |
|
Reference |
.NET Framework 4.8 |
521.7 ns |
0.99 |
|
Terrible |
.NET Framework 4.8 |
522.0 ns |
0.99 |
|
Unsafe |
.NET Framework 4.8 |
466.8 ns |
0.89 |
Да, действительно, интересные. Во-первых, в случае .NET Framework 4.8, победа уходит unsafe-коду. Это весьма очевидно, так как под капотом Span’a достаточно много похожего на unsafe-код. А старый framework может работать со Span только в качестве адаптера, чтобы обеспечить совместимость кода. Однако, если нам не хочется уходить в дебри unsafe, рекомендую опять таки воспользоваться возможностями собственного перечислителя.
Второй интересный момент заключается в том, что кастомный enumerator сильно опережает Span в версии .NET Framework 4.8 и .NET Core 3.1. Это странно, учитывая то, что код очень похож на код из Span’a. Честно говоря, я так и не понял почему так. Однако, это и не важно, так как лучше выбирать array.AsSpan(), чем долго и упорно оптимизировать и писать свои велосипеды.
В-третьих, unsafe закономерно показал хороший результат (кроме .NET 6). Мне кажется, это стоит запомнить и использовать. Если только ваши коллеги не против.
Случайный доступ к элементам массива
Для полноты теста, прогоним benchmark на случайный доступ к элементам массива по индексу. Как всегда, несколькими способами.
|
Method |
Runtime |
Mean |
Ratio |
|---|---|---|---|
|
ArrayIndex |
.NET 6.0 |
514.3 ns |
1.00 |
|
MemoryIndex |
.NET 6.0 |
1,303.7 ns |
2.54 |
|
Unsafe |
.NET 6.0 |
478.1 ns |
0.93 |
|
UnsafePined |
.NET 6.0 |
503.2 ns |
0.98 |
|
|
|
|
|
|
ArrayIndex |
.NET Core 3.1 |
509.1 ns |
1.00 |
|
MemoryIndex |
.NET Core 3.1 |
1,314.6 ns |
2.58 |
|
Unsafe |
.NET Core 3.1 |
512.0 ns |
1.00 |
|
UnsafePined |
.NET Core 3.1 |
503.9 ns |
0.99 |
|
|
|
|
|
|
ArrayIndex |
.NET Framework 4.8 |
518.8 ns |
1.00 |
|
MemoryIndex |
.NET Framework 4.8 |
4,179.8 ns |
8.06 |
|
Unsafe |
.NET Framework 4.8 |
478.9 ns |
0.92 |
|
UnsafePined |
.NET Framework 4.8 |
515.5 ns |
0.99 |
Магии не случилось, старый-добрый доступ по индексу один из самых быстрых, без ухода в unsafe. При этом, мы можем заметить, что доступ через unsafe затрачивает примерно то же самое время. Но так делать не надо, вас заклюют коллеги. Если даже вам что-то получится «выкружить» из unsafe, то это копейки по сравнению с тем, какое возрастание сложности кода мы получаем.
При этом, для меня стало неожиданным то, что доступ через Memory почти в три раза медленнее. Очень странно, учитывая то, что кода там не так чтобы много. Лишнего — тем более.
Выводы
-
Используем foreach там, где нам необходимо быстро прочитать элементы из массива.
-
Если нам нужно изменять значения в массиве, используем
array.AsSpanи бужим по элементам, получаемым из его enumerator’a. В этом случае необходимо получать ссылку на элемент массива —foreach (ref var element in array.AsSpan()). -
Если мы не используем современный .NET, то… снова используем
foreach, так как собственные имплементации Enumerator’a по array не всегда эффективны. -
Доступ по индексу, конечно, самый эффективный при случайном доступе. Мы можем «уйти в unsafe», но, чаще всего, результат не стоит усложнения кода.
-
Не надо использовать unsafe. Во-первых, это надо уметь. А во-вторых, этого не одобрят коллеги. Ну и в-третьих, это не всегда так быстро, как рассказывают.
Где это тестировалось
Статья была опубликована в моём канале почти год назад. За это время многое изменилось. Изменился код .NET, люди удалили статьи с Хабра, изменили адреса в Youtube. Я тоже поменял машину. Извините, если в статье что-то не работает. Я очень старался сделать так, чтобы чтение было приятным. Наконец, я заново прогнал все бенчмарки. Машина для тестирования за год тоже изменилась, теперь она вот такая:
BenchmarkDotNet=v0.13.5, OS=Windows 11 (10.0.22621.1265/22H2/2022Update/SunValley2) AMD Ryzen 7 5800H with Radeon Graphics, 1 CPU, 16 logical and 8 physical cores .NET SDK=7.0.102 [Host] : .NET 6.0.13 (6.0.1322.58009), X64 RyuJIT AVX2 .NET 6.0 : .NET 6.0.13 (6.0.1322.58009), X64 RyuJIT AVX2 .NET Core 3.1 : .NET Core 3.1.22 (CoreCLR 4.700.21.56803, CoreFX 4.700.21.57101), X64 RyuJIT AVX2 .NET Framework 4.8 : .NET Framework 4.8.1 (4.8.9139.0), X64 RyuJIT VectorSize=256
ссылка на оригинал статьи https://habr.com/ru/post/722368/
Добавить комментарий