Бытует мнение, что C# не место в вычислительных задачах, и мнение это вполне обоснованное: JIT-компилятор вынужден компилировать и оптимизировать код на лету в процессе выполнения программы с минимальными задержками, у него попросту нет возможности потратить больше вычислительных ресурсов, чтобы сгенерить более эффективный код, в отличие от компилятора C++, которые может потратить на это дело минуты и даже часы.
Однако, в последние годы эффективность JIT-компилятора заметно возросла, да и в сам фреймворк завезли ряд полезных фишек, например, интринсики.
И вот стало мне интересно: а можно ли в 2020 году, используя .NET 5.0, написать код, который бы не сильно уступал по производительности C++? Оказалось, что можно.
Мотивация
Я занимаюсь разработкой алгоритмов обработки изображений, причём на достаточно низком уровне. То есть это не жонглирование кирпичиками в Python, а именно разработка чего-то нового и, желательно, производительного. Код на Python работает непозволительно долго, тогда как использование C++ приводит к снижению скорости разработки. Оптимальный баланс между продуктивностью и производительностью для подобных задач достигается при использовании C# и Java. В подтверждение моих слов - проект Fiji.
Раньше для прототипирования я использовал C#, а готовые алгоритмы, которым критична производительность, переписывал на C++, пихал в либу и дёргал либу из C#. Но в этом случае страдала переносимость, да и отлаживать код было не очень удобно.
Но это было давно, с тех пор .NET шагнул далеко вперёд, и мне стало интересно, могу ли я отказаться от нативной библиотеки на C++ и перейти полностью на C#?
Сценарий
Сравнивать же языки я буду на примере базовых методов обработки изображений: сумма изображений, поворот, свёртка, медианная фильтрация. Именно подобные методы чаще всего приходится писать на C++. Особенно критично время работы свёртки.
Для каждого из методов, кроме медианной фильтрации, было сделано по три реализации на C# и C++:
-
Наивная реализация с использованием методов типа GetPixel(x, y) и SetPixel(x, y, value);
-
Оптимизированная реализация с использованием указателей и работы с ними на низком уровне;
-
Реализация с использованием интринсков (AVX).
В случае медианной фильтрации использовались библиотечные функции (Array.Sort, std::sort), поэтому это было, фактически, сравнение реализаций этих функции, а не пользовательского кода. В перспективе имеет смысл подумать об использовании сортировочных сетей.
Также, чтобы уравнять языки в возможностях, я в C# использовал unmanaged память и обращался к пикселям без каких-либо проверок на выход за границы. А то как-то нечестно получается, что C++ использует UB для достижения высокой производительности, а C# - нет.
Реализация методов выложена на Github, смысла постить сюда портянки кода я не вижу, просто приведу пример кода на C#:
Сумма изображений
[MethodImpl(MethodImplOptions.AggressiveOptimization)]public static void Sum_ThisProperty(NativeImage<float> img1, NativeImage<float> img2, NativeImage<float> res){ for (var j = 0; j < res.Height; j++) for (var i = 0; i < res.Width; i++) res[i, j] = img1[i, j] + img2[i, j];}[MethodImpl(MethodImplOptions.AggressiveOptimization)]public static void Sum_Optimized(NativeImage<float> img1, NativeImage<float> img2, NativeImage<float> res){ var w = res.Width; for (var j = 0; j < res.Height; j++) { var p1 = img1.PixelAddr(0, j); var p2 = img2.PixelAddr(0, j); var r = res.PixelAddr(0, j); for (var i = 0; i < w; i++) r[i] = p1[i] + p2[i]; }}[MethodImpl(MethodImplOptions.AggressiveOptimization)]public static void Sum_Avx(NativeImage<float> img1, NativeImage<float> img2, NativeImage<float> res){ var w8 = res.Width / 8 * 8; for (var j = 0; j < res.Height; j++) { var p1 = img1.PixelAddr(0, j); var p2 = img2.PixelAddr(0, j); var r = res.PixelAddr(0, j);доступна for (var i = 0; i < w8; i += 8) { Avx.StoreAligned(r, Avx.Add(Avx.LoadAlignedVector256(p1), Avx.LoadAlignedVector256(p2))); p1 += 8; p2 += 8; r += 8; } for (var i = w8; i < res.Width; i++) *r++ = *p1++ + *p2++; }}
Результаты
Перейдём к результатам. В ячейках таблицы указано время работы (1/10 перцентиль) тестируемых методов в микросекундах для изображений размером 256x256 в градациях серого с типом пикселя float 32 bit.
dotnet build -c Release |
g++ 10.2.0 -O0 |
g++ 10.2.0 -O1 |
g++ 10.2.0 -O2 |
g++ 10.2.0 -O3 |
clang 11.0.0 -O2 |
clang 11.0.0 -O3 |
|
Sum (naive) |
115.8 |
757.6 |
124.4 |
36.26 |
19.51 |
20.14 |
19.81 |
Sum (opt) |
40.69 |
255.6 |
36.07 |
24.48 |
19.60 |
20.11 |
19.81 |
Sum (avx) |
21.15 |
60.41 |
20.00 |
20.18 |
20.37 |
20.23 |
20.20 |
Rotate (naive) |
90.29 |
500.3 |
87.15 |
36.01 |
14.49 |
14.04 |
14.16 |
Rotate (opt) |
34.99 |
237.1 |
35.11 |
34.17 |
14.55 |
14.10 |
14.27 |
Rotate (avx) |
14.83 |
51.04 |
14.14 |
14.25 |
14.37 |
14.22 |
14.72 |
Median 3x3 |
4163 |
26660 |
2930 |
1607 |
2508 |
2301 |
2330 |
Median 5x5 |
11550 |
10090 |
8240 |
5554 |
5870 |
5610 |
6051 |
Median 7x7 |
23540 |
24470 |
17540 |
13640 |
12620 |
12920 |
13510 |
Convolve 7x7 (naive) |
5519 |
30900 |
3240 |
3694 |
2775 |
3047 |
2761 |
Convolve 7x7 (opt) |
2913 |
11780 |
2759 |
2628 |
2754 |
2434 |
2262 |
Convolve 7x7 (avx) |
709.2 |
3759 |
729.8 |
669.8 |
684.2 |
643.8 |
638.3 |
Convolve 7x7 (avx*) |
505.6 |
2984 |
523.4 |
511.5 |
507.8 |
443.2 |
443.3 |
Примечание: Convolve 7x7 (avx*) - это свёртка без специальной обработки граничных значений, то есть случай, когда результирующее изображение уменьшается на размер ядра свёртки.
Тестирование проводилось на процессоре Core i7-2600K @ 4.0 GHz.
Из таблицы можно сделать следующие наблюдения:
-
Скорость работы векторизованного кода (avx), написанного на C#, практически не отличается от аналогичного кода, написанного на C++. Ура, теперь на C# можно прогать математику!
-
Производительность небезопасных низкоуровневых методов в C# тоже достаточно неплоха, и C# сильно проигрывает только там, где компилятор C++ смог применить автовекторизацию.
-
А вот скорость работы наивных реализаций в C# оставляет желать лучшего и проигрывает C++ от 2 до 6 раз. Но для прототипирования это и не важно.
Выводы
Да, на C# можно писать вычислительный код, имеющий паритет по производительности с C++. Но для этого придётся прибегнуть к ручными оптимизациям в коде: то, что компилятор C++ делает в автоматическом режиме, в C# нужно делать самому. Поэтому если у вас нет привязки к C#, то пишите дальше на C++.
P.S. В .NET есть одна киллер-фича - это возможность кодогенерации в рантайме. Если пайплайн обработки изображений заранее неизвестен (например, задаётся пользователем), то в C++ придётся собирать его из кирпичиков и, возможно, даже использовать виртуальные функции, тогда как в C# можно добиться большей производительности, просто сгенерив метод.