Несколько недель назад у нас с Ден Расковаловым состоялся модный разговор о производительности C #, который превратился в небольшое, но увлекательное упражнение по кодированию. Заявление о доказательстве или опровержении было следующим:

«Перечисленный ниже код C ++, переведенный на C #, никогда не будет близок к C ++ с точки зрения скорости».

При условии, что:

  • Мы сохраняем однопоточность
  • Предположительно, файл, который он читает, достаточно короткий (~ 1 ГБ или меньше), чтобы его можно было кэшировать в ОЗУ, т.е. пропускная способность ввода-вывода не будет здесь узким местом.

Если вы не хотите копаться в коде, вот что он делает:

В файле хранится список целых чисел в диапазоне (0… 1 000 000), закодированных с использованием схемы количественного кодирования переменной длины. Вычислите сумму всех этих целых чисел.

В схеме кодирования используется старший бит (MSB) в каждом байте, чтобы указать, есть ли продолжение битовой последовательности текущего номера (MSB = 0) или нет (MSB = 1). Вот пример аналогичных способов кодирования целых чисел.

Поэтому мы начали улучшать наши версии вычислений кода на эту сумму, чтобы сделать их как можно быстрее, соблюдая изложенные выше правила. Ден играл на стороне C ++.

Чтобы сделать пост кратким, я сосредоточусь в основном на стороне C # здесь, хотя здесь вы можете найти код как C #, так и C ++ (мой репозиторий, я буду обновлять его последними версиями кода C # и C ++ на случай, если любых будущих изменений) и здесь (репозиторий Den, самый свежий код C ++ находится там).

Исходный код C #, вычисляющий сумму:

Здесь обычна высокоуровневая оболочка, читающая файл; он получает делегата, указывающего на конкретную реализацию логики вычислений (т.е. то, что должно быть оптимальным).

Производительность на Core i7–8700K, Ubuntu 19.04, gcc или .NET Core 3.0 preview 5 и файл размером ~ 1 ГБ с тестовыми данными:

  • ~ 430 мс для C ++
  • ~ 500 мс для C #

Первый раунд оптимизации был относительно простым:

  • C ++: включить набор оптимизаций с помощью параметров компилятора (-Ofast -fomit-frame-pointer -march = native -mtune = native -funroll-loops -Wno-shift-count-overflow), включить PGO (оптимизация по профилю), используйте файлы с отображением памяти
  • C #: используйте небезопасные указатели, разверните основной цикл, добавьте версию, основанную на асинхронных конвейерах. Последнюю часть я сделал в основном для того, чтобы проверить, есть ли какие-либо преимущества - реализация, которую я использовал, нарушает исходное условие «одного потока», поскольку производитель считывает другой фрагмент, в то время как потребитель вычисляет сумму; с другой стороны, версия C ++, основанная на файлах с отображением в память, неявно делает нечто подобное - так что в обоих случаях это не похоже на явное нарушение нашего «однопоточного» правила (т. е. здесь мы договорились, что операции чтения файлов могут быть одновременно).

Обновленный код C #:

Результаты снова были очень похожими:

  • 394 мс для версии C ++
  • 416 мс для версии C # с асинхронным конвейером
  • 462 мс для «обычной» версии C #

Я попытался реализовать несколько других идей - в частности, реализовал версию, почти полностью полагающуюся на инструкции без ветвления, но ни одна из них не сработала лучше.

Следующий раунд принес почти пятикратное ускорение: Алексей Поярков написал чрезвычайно эффективную версию кода вычисления суммы на основе AVX2. Я построчно перевел его код на C #, полагаясь на встроенные функции SIMD .NET Core 3.0, а позже внес несколько косметических изменений. Вот так выглядит окончательная версия кода C #:

Результаты, достижения:

  • 95 мс для версии C ++
  • 130 мс для версии C #
  • 113 мс для версии C # с асинхронным конвейером.

Наконец, я обнаружил, что на самом деле имеет смысл использовать файлы с отображением памяти в версии C # на Ubuntu. По своему прошлому опыту я знал, что они не дают никаких преимуществ в Windows - более того, все обстоит как раз наоборот, поэтому я даже не пробовал это в качестве одной из начальных оптимизаций. Но, похоже, это самый быстрый способ прочитать файл в .NET Core в Ubuntu:

Итоговая таблица:

  • 95 мс для версии C ++
  • 101 мс для версии C #

Обратите внимание, что эти результаты почти идеальны: базовый тест, вычисляющий сумму в предположении, что это последовательность значений Int64, занимает примерно одинаковое время, поэтому ЦП больше не является узким местом для этого кода - это пропускная способность ОЗУ. И на этом мы явно должны были остановиться.

Мои выводы:

  • C # определенно подходит для аналогичных ресурсоемких задач, особенно с .NET Core 3.0.
  • Если вы запустите аналогичную рабочую нагрузку в масштабе, ожидается, что разница будет еще меньше: процессор является узким местом в этом тесте только в том случае, если он является однопоточным. Если бы мы одновременно выполняли 4 или более похожих задачи (предположим, что это ~ веб-сервер, декодирующий ввод UTF8 и т. Д.), Они достигли предела пропускной способности ОЗУ даже в версии без SIMD.

А вот выводы Дена Расковалова (я тоже с ним полностью согласен):

«Я согласен с большинством выводов, которые сделал Алекс, но хотел бы обратить ваше внимание на несколько моментов:

  • Низкоуровневая оптимизация по-прежнему важна, даже если вы используете современные компиляторы и библиотеки. Фактически первая версия алгоритма работала в 10 раз медленнее последней. Компиляторы по-прежнему не справляются с оптимизацией шаблонов ввода-вывода и не могут использовать конвейерную обработку AVX / SSE так хорошо, как мог бы опытный инженер.
  • Окончательные версии кода C # и C ++ практически идентичны. Microsoft, очевидно, понимает важность использования низкоуровневой оптимизации. C # - это настоящий инструмент, а не игрушка CS. Мое уважение здесь к людям из MS. До того, как мы начали «соревнование», я ожидал, что наивная реализация C ++ может быть улучшена в 10 раз, но не ожидал, что .NET сможет использовать те же низкоуровневые оптимизации.
  • Даже в 2019 году после конвергенции платформ у Алекса возникли проблемы с запуском моего кода на C ++, разработанного для Linux. У меня тоже были проблемы с запуском кода .NET. Версия Алекса требует самой последней версии ядра .NET, хотя версия C ++ хорошо работает с GCC или clang 6-летней давности ».

P.S. Посмотрите мой новый проект: Stl.Fusion, библиотека с открытым исходным кодом для .NET Core и Blazor, стремящаяся стать вашим выбором №1 для приложений реального времени. Его единый государственный конвейер обновления действительно уникален и умопомрачен.