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