Бытует мнение, что 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# можно добиться большей производительности, просто сгенерив метод.