Назад к блогу

Fearless SIMD 1.0: безопасный переносимый SIMD для Rust

Fearless SIMD 1.0: безопасный переносимый SIMD для Rust

Вышла первая стабильная версия `fearless_simd` — библиотеки, которая приносит переносимый SIMD в Rust без необходимости писать `unsafe`-код вручную. Она охватывает ARM, x86 и WebAssembly, автоматически подбирая доступный набор инструкций на этапе компиляции и во время выполнения. Это заметное событие для тех, кому нужна производительность векторизации, но не хочется жертвовать безопасностью языка.

22 сентября 2026 года вышел fearless_simd v1.0 — библиотека переносимого SIMD для Rust, спроектированная так, чтобы не требовать ad-hoc unsafe-кода. Вместе с ней запускается отдельно версионируемый сопутствующий крейт fearless_simd_macros v0.1 с процедурным макросом #[simd]. Автор анонса — Shnatsel.

Крейт поддерживает aarch64 (Neon), x86 и x86_64 (SSE2, SSE4.2, AVX2, AVX-512) и wasm32 с включённым target_feature="simd128". Основная библиотека не имеет зависимостей, а макрос подключается отдельно, поскольку основной крейт от него не зависит.

Что именно изменилось

В Cargo.toml добавляются два крейта:

[dependencies]
fearless_simd = "1.0"
fearless_simd_macros = "0.1"

Доступные уровни SIMD описаны в перечислении Level. Это Fallback (скалярный запасной вариант), Neon (набор инструкций Neon на 64-битном ARM), 128-битные SIMD-инструкции на 32-битном WebAssembly, Sse2 (базовый уровень для i686 и x86-64), Sse4_2 (SSE4.2 плюс popcnt и cmpxchg16b, он же x86-64-v2), Avx2 (x86-64-v3, включая AVX2 и FMA) и Avx512 (AVX-512 класса Ice Lake). Уровень Fallback отсутствует на целях с более высоким базовым уровнем (aarch64-*, i686-*, x86_64-*, WASM с SIMD), если не включена Cargo-фича force_support_fallback.

Уровень SIMD — это то, что нужно получить, чтобы код мог выбрать подходящий вариант вычислений. Лучший доступный уровень возвращается при первом обращении: на x86 и x86-64 возможности CPU определяются при первом вызове и результат кэшируется, на остальных целях возвращается статически поддерживаемый самый сильный уровень. Методы вида as_sse2, as_avx2, as_avx512 возвращают токен нужного набора инструкций, если доступен этот уровень или более сильный: например, as_sse2 вернёт Some и для Avx512, Avx2, Sse4_2.

Как устроена диспетчеризация

диспетчеризация. Выбранный уровень передаётся в dispatch! — макрос, который по нему подбирает и вызывает нужную реализацию функции для доступного набора инструкций. Внутри доказательство нормализуется к лучшему диспатчабельному уровню: сначала проверяется AVX-512, затем AVX2, SSE4.2, SSE2, а если ни один не подошёл — берётся уровень, статически объявленный при компиляции.

baseline даёт самый сильный уровень, статически объявленный при компиляции: он проверяет наборы target_feature и возвращает соответствующий вариант, иначе Fallback. Для AVX-512 требуется одновременное наличие множества фич, включая adx, aes, avx512f, bmi1, bmi2, fma, fxsr. Для AVX2 — avx2, bmi1, bmi2, cmpxchg16b, f16c, fma, fxsr, lzcnt, movbe, popcnt, xsave и исключается полный набор AVX-512. Для SSE4.2 — fxsr, sse4.2, cmpxchg16b, popcnt и исключается набор AVX2. Для SSE2 — sse2 и fxsr с исключением набора SSE4.2. На aarch64 уровень, объявленный при компиляции, — Neon при target_feature = "neon", на wasm32 — SIMD 128 при target_feature = "simd128", на прочих целях — Fallback.

Определение уровня в рантайме доступно там, где есть стандартная библиотека или wasm32, и недоступно иначе — чтобы библиотеки могли обработать случай, когда уровень нельзя определить в рантайме. Если лучший уровень не был обнаружен ни статически, ни динамически, система сообщает об этом: на x86 — когда недоступен SSE2, на aarch64 — когда недоступен Neon, на wasm32 с simd128 — когда недоступен SIMD 128. Если fallback-бэкенд не скомпилирован, признак fallback всегда снят.

Макрос kernel!

Макрос kernel! принимает объявление функции с первым параметром-токеном SIMD (одним из Neon, Sse2, Sse4_2, Avx2, Avx512) и разворачивает её так, чтобы тело выполнялось внутри функции, помеченной соответствующим набором аппаратных возможностей. Это позволяет вызывать SIMD-интринсики без unsafe. Соответствие уровня и набора фич задаётся жёстко: для Neon — #[target_feature(enable = "neon")], для Sse2 — #[target_feature(enable = "fxsr,sse,sse2")], для Avx2 — #[target_feature(enable = "fxsr,avx2,bmi1,bmi2,cmpxchg16b,f16c,fma,lzcnt,movbe,popcnt,xsave")], для Avx512 — длинный список avx512-фич, а для WebAssembly с SIMD-128 атрибут не добавляется вовсе. Внутренняя функция вызывается в unsafe-блоке, потому что уровень доказывает наличие этих target featuress. Выбирать вспомогательные макросы разворачивания могут только шесть проверенных SIMD-уровней.

Атрибут #[simd]

#[simd] рекомендуется ставить на функции, использующие SIMD. Без него код всё равно компилируется, но требует особой аккуратности для полной производительности. Атрибут даёт неликибучную абстракцию, поэтому его достаточно поставить на любую SIMD-функцию, и она просто работает. Функция принимает первым параметром тип-уровень S: Simd и внутри может использовать как обычные циклы, так и явные SIMD-операции вроде chunks_exact_mut(S::u32s::LEN), S::u32s::from_slice и store_slice. Для платформ без SIMD предусмотрен скалярный fallback, поэтому код продолжает работать и без SIMD-инструкций.

Как обеспечивается безопасность

Значения перечисления Level служат доказательством доступности соответствующей аппаратной возможности. Доступность вариантов ограничивается на этапе компиляции: Neon — только на 64-битном ARM, SSE2 — на 32- и 64-битном x86, а SIMD 128 на 32-битном WebAssembly — только при включённой поддержке SIMD. Методы вида as_sse2 предпочитаются прямому сопоставлению с вариантом, поскольку возвращают токен даже при более мощном наборе инструкций. Для более сильных уровней токен более слабого получается через assume_supported с обоснованием, что более сильный уровень включает требуемые возможности. Атрибут #[simd] требует, чтобы первый не-receiver параметр нёс SIMD-токен: это позволяет на этапе компиляции убедиться, что функция получает доказательство доступности нужных инструкций.

Предыстория

Более ранняя версия крейта экспериментировала с подходом, пытавшимся обеспечить безопасность в safe Rust по состоянию на 2018 год, используя типы, которые «свидетельствовали» о поддержке SIMD процессором. Об этом эксперименте была написана публикация «Towards fearless SIMD». От подхода отказались, потому что его не удалось довести до рабочего состояния. Практическое развитие примерно в том же направлении — крейт pulp.

fearless_simd вдохновлялся pulp и std::simd, но принимает многие решения иначе. Авторы отмечают, что стандартная библиотека Rust реализует только те части, которые обязательно должны в ней быть, а остальное — например, мультиверсионирование и векторы аппаратной ширины — оставлено экосистемным крейтам. Поэтому fearless_simd включает эквивалент std::simd, работающий на стабильном Rust, но это лишь часть большего целого.

Почему это сделали

Другие SIMD-абстракции содержат тысячи блоков unsafe, тогда как fearless_simd спроектирован так, чтобы не требовать ad-hoc unsafe-кода. Для этого используются два небольших самодостаточных строительных блока: макрос kernel! и безопасный модуль transmute, закрывающий SIMD load/store-операции с сырыми указателями. Аудиту подлежат только эти два блока — если они memory-safe, вся остальная кодовая база гарантированно memory-safe.

Вторая проблема — эргономика мультиверсионирования. Прежние решения либо требовали ручных аннотаций, влияющих на встраивание функций, и понимания их последствий, либо накладывали небольшие накладные расходы на каждый вызов функции. Оба подхода названы ликибучными абстракциями, поскольку всё равно требуют думать о том, что происходит под капотом. #[simd] позиционируется как неликибучная абстракция: его можно поставить на любую SIMD-функцию, и она просто работает. Если процедурные макросы не нравятся, старый способ остаётся доступным, хотя и менее удобен.

Что это меняет на практике

Типичный код с #[simd] — это обычная функция, помеченная атрибутом, которая вызывается через dispatch!. Внутри можно писать обычный цикл по элементам, и он векторизуется автоматически. Альтернативно можно вручную работать с чанками, используя S::u32s::LEN как нативную ширину SIMD процессора, загружая через S::u32s::from_slice(simd, chunk) и сохраняя через (v * 2).store_slice(chunk). Для доступа к конкретным аппаратным инструкциям применяется kernel!, который создаёт функцию для конкретного уровня (например, Neon), где интринсики вызываются напрямую, но через безопасную обёртку: u32x4::from_slice(neon, chunk).into() — безопасная загрузка, vmulq_u32(v, vdupq_n_u32(2)) — безопасный доступ к интринсику NEON. Можно смешивать интринсики с другими подходами, используя высокоуровневый код и опускаясь до аппаратных интринсиков только при необходимости.

Относительно std::simd: Fearless SIMD не станет устаревшим после его стабилизации, поскольку стандартная библиотека реализует только обязательные части. После стабилизации авторы планируют портировать Fearless SIMD на std::simd, чтобы удалить много собственного кода и получить поддержку экзотических платформ, но потребность в экосистемных крейтах сохранится.

Авторы обещают предоставлять security-обновления в течение 3 лет для v1.0 и всех последующих версий.

Ограничения и открытые вопросы

Relaxed SIMD — WebAssembly-инструкции, которые могут возвращать зависящие от реализации результаты в зависимости от того, что быстрее на конкретном оборудовании. fearless_simd использует их только для операций, где результаты и так зависят от оборудования. На момент написания relaxed SIMD поддерживается только в Chrome, поэтому для его использования нужно собрать две версии библиотеки — одну с включённым relaxed SIMD и одну с выключенным — и определить поддержку во время выполнения. Включение делается через RUSTFLAGS="-Ctarget-feature=+simd128,+relaxed-simd". Для сборки обеих версий рекомендуется shell-скрипт, собирающий библиотеку один раз с указанными RUSTFLAGS и один раз без них, поскольку Cargo в настоящее время не позволяет задавать флаги компилятора для профиля.

Скалярный fallback предназначен в первую очередь для тестов; большинству пользователей рекомендуется Level::new() или Level::baseline(). Его включение не гарантирует отсутствия SIMD-инструкций в fallback-ветке: «окружающая» среда компиляции имеет доступные SIMD-инструкции, которые LLVM может использовать для авто-векторизации этого пути.

Источники

Похожее