Beyond OpenMP in C++ and Rust: Taskflow, Rayon, Fork Union
Многие библиотеки для параллельных вычислений в C++ и Rust, такие как Taskflow и Rayon, оказываются в 10 раз медленнее OpenMP в задачах типа fork-join из-за избыточных абстракций. Автор выделяет четыре ключевых фактора снижения производительности: блокировки с системными вызовами, аллокации памяти в очередях задач, дорогостоящие атомарные операции и ложное разделение кэш-линий.
В ответ создана минималистичная библиотека Fork Union объёмом около 300 строк, которая использует только стандартные средства C++ и Rust и демонстрирует производительность в пределах 20% от OpenMP. Бенчмарки на AWS Graviton 4 с 96 ядрами показывают, что Fork Union достигает 467 МБ/с против 585 МБ/с у OpenMP, в то время как Rayon и Taskflow отстают значительно. Вывод: для блокирующих fork-join нагрузок асинхронные пулы задач неоправданно тяжелы.
Комментарии (27)
- Автор библиотеки fork_union сообщает об улучшениях, особенно для Linux NUMA-систем, и приветствует предложения по её развитию.
- Пользователи отмечают значительное ускорение работы по сравнению с другими решениями (например, Rayon), но указывают на проблемы с потреблением CPU из-за busy wait.
- Обсуждаются технические детали реализации: диспетчеризация работы, обработка неоднородных нагрузок, энергоэффективность busy-wait и отсутствие аллокаций после инициализации.
- Проводятся сравнения с альтернативными библиотеками и подходами (TBB, heartbeat scheduling, Tokio) и обсуждаются возможные варианты применения, например, в веб-серверах.
- Отмечается сложность создания удобных и безопасных API для Rust из-за особенностей работы с памятью в высокопроизводительном параллельном коде.
Wild performance tricks
В Wild-линковщике для Rust применяют несколько продвинутых техник оптимизации параллельной работы. Например, используют split_off_mut для безопасного разделения мутабельных срезов Vec<SymbolId> между потоками, что позволяет обрабатывать символы каждого объекта параллельно без блокировок, сохраняя кэш-локальность.
Для инициализации вектора без задержек на основном потоке задействуют крейт sharded-vec-writer: предварительно аллоцируют память, разбивают её на сегменты по числу символов в объектах и заполняют их параллельно через VecWriter, что ускоряет стартовую фазу.
В случаях, когда требуются случайные записи в общий вектор (например, для обработки дубликатов символов в C++), переходят на атомарные операции. Вместо стабильного, но ограниченного AtomicU32::from_mut_slice (только nightly) или постоянного использования атомиков (что снижает производительность), временно преобразуют &[SymbolId] в &[AtomicSymbolId] через unsafe-конверсию, экономя на издержках синхронизации в основном коде.
Комментарии (67)
- Обсуждаются оптимизации Rust, такие как преобразование
VecвIntoIterи обратно для эффективного повторного использования аллокации, что реализовано в стандартной библиотеке как специальный случай. - Высказываются предостережения против некоторых "трюков производительности", например, перемещения аллокации в другой поток для освобождения, из-за особенностей работы аллокаторов и сомнительной выгоды.
- Поднимается вопрос о надёжности оптимизаций компилятора (LLVM) в релизных сборках, которые могут меняться между версиями и сложны для верификации, что контрастирует с медленными debug-сборками.
- Отмечается, что многие трюки направлены на обход ограничений borrow checker для получения разрешения на выполнение операций, а не на решение аппаратных задач производительности.
- Обсуждается преимущество Rust в безопасном параллелизме (например, с Rayon) по сравнению с C/C++, где обеспечение потоковой безопасности значительно сложнее.