Как стать автором
Обновить

Используем клиентский процессор по максимуму. Часть 2: SIMD + мультипоточность

Время на прочтение25 мин
Количество просмотров9K
Всего голосов 70: ↑70 и ↓0+70
Комментарии11

Комментарии 11

Мда, потрясающе по сложности статья. Очень хабратортно.

У меня в Chromium под Linux на Core™ i7-8750H scalar выдает где-то 28-30 fps, а simd - 19-22 fps, т.е. даже немного проседаем.

При этом, js выдает 10-12 fps, а GPU - более 600 fps

Тесты в разных браузерах работают по разному (проверял в хроме и лисе на винде), количество ядер даже показывает разное количество, в лисе gpu никаких преимуществ не дает, в хроме js и scalar практически одинаковые. замеры почему-то сильно плавают.

P.S. если увеличить количество точек до 25, то разница становится заметнее. В хроме на gpu, на первый взгляд, увеличение количество точек не сказывает на производительности или сказывается не существенно. Круто!

Спасибо за статью. Однако небольшой момент: подобным стоит заниматься когда компилятор не осилил оптимизировать. А он обычно этим отлично занимается если ему дать необходимую информацию. Давайте посмотрим на эту функцию:


pub fn sum_numbers_scalar(arr1: Vec<f32>, arr2: Vec<f32>) -> Vec<f32> {
    let length = arr1.len();
    let mut result = vec![0f32; length];
    for i in 0..length {
        result[i] = arr1[i] + arr2[i];
    }
    return result;
}

Странно, что он у вас смог векторизовать изначальный вариант, потому как на годболте (см. ниже) он честно пытается в цикле пройтись. Но вот если переписать её чуть-чуть, чтобы компилятор мог понять что тут происходит, то и на годболте происходит нечто чудесное:


pub fn sum_numbers_scalar2(arr1: Box<[f32]>, arr2: Box<[f32]>) -> Vec<f32> {
    arr1.iter().zip(arr2.iter()).map(|(a, b)| a + b).collect()
}

Вышло: короче, понятнее, и автоматически векторизовано


        vmovups ymm0, ymmword ptr [r13 + 4*rdx]
        vmovups ymm1, ymmword ptr [r13 + 4*rdx + 32]
        vmovups ymm2, ymmword ptr [r13 + 4*rdx + 64]
        vmovups ymm3, ymmword ptr [r13 + 4*rdx + 96]
        vaddps  ymm0, ymm0, ymmword ptr [rbx + 4*rdx]
        vaddps  ymm1, ymm1, ymmword ptr [rbx + 4*rdx + 32]
        vaddps  ymm2, ymm2, ymmword ptr [rbx + 4*rdx + 64]
        vaddps  ymm3, ymm3, ymmword ptr [rbx + 4*rdx + 96]
        vmovups ymmword ptr [rax + 4*rdx], ymm0
        vmovups ymmword ptr [rax + 4*rdx + 32], ymm1
        vmovups ymmword ptr [rax + 4*rdx + 64], ymm2
        vmovups ymmword ptr [rax + 4*rdx + 96], ymm3
        vmovups ymm0, ymmword ptr [r13 + 4*rdx + 128]
        vmovups ymm1, ymmword ptr [r13 + 4*rdx + 160]
        vmovups ymm2, ymmword ptr [r13 + 4*rdx + 192]
        vmovups ymm3, ymmword ptr [r13 + 4*rdx + 224]
        vaddps  ymm0, ymm0, ymmword ptr [rbx + 4*rdx + 128]
        vaddps  ymm1, ymm1, ymmword ptr [rbx + 4*rdx + 160]
        vaddps  ymm2, ymm2, ymmword ptr [rbx + 4*rdx + 192]
        vaddps  ymm3, ymm3, ymmword ptr [rbx + 4*rdx + 224]
        vmovups ymmword ptr [rax + 4*rdx + 128], ymm0
        vmovups ymmword ptr [rax + 4*rdx + 160], ymm1
        vmovups ymmword ptr [rax + 4*rdx + 192], ymm2
        vmovups ymmword ptr [rax + 4*rdx + 224], ymm3
        add     rdx, 64
        add     rdi, -2
        jne     .LBB3_11
        test    sil, 1
        je      .LBB3_14

Пока что практика показывает, что руками в СИМД залезать приходится редко, нужно просто посидеть и понять что компилятору не нравится и почему он этого сам не делает. Обычно это какая-нибудь идиотская проверка что a + 3 < b — 2 и тогда все магически схлопывается. Например в вашем случае достаточно заменить тело цикла в этом примере на


*result.get_unchecked_mut(i) = arr1.get_unchecked(i) + arr2.get_unchecked(i);

Что уберет панику, что уберет зависимость между циклами, что позволит компилятору автоматически произвести векторизацию.


Но нам же интересен прирост производительности от применения SIMD! Чтобы его увидеть, установим уровень оптимизации в 0 в настройках проекта:

Пропустил что-то этот момент. Сравнивать производительность кода в дебаг-режиме (а opt=0 это именно оно) это совершенно отвратительная идея. Лучше бы придумался пример который компилятор не осиляет (это сделать элементарно, как я выше показал самые простые вещи его могут смутить), и от него уже плясать.

Однако небольшой момент: подобным стоит заниматься когда компилятор не осилил оптимизировать.

Полностью согласен. Этому посвящён небольшой абзац в статье:

На этом этапе у вас резонно может возникнуть вопрос «А зачем самим использовать SIMD инструкции, если компилятор может сам догадаться, как их применить?». На практике компилятор не всегда эффективно выполняет векторизацию вычислений, если он вообще сумеет до неё додуматься. Поэтому в действительно сложных вычислениях приходится самим векторизировать вычисления.

Странно, что он у вас смог векторизовать изначальный вариант, потому как на годболте (см. ниже) он честно пытается в цикле пройтись. Но вот если переписать её чуть-чуть, чтобы компилятор мог понять что тут происходит, то и на годболте происходит нечто чудесное

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

Пропустил что-то этот момент. Сравнивать производительность кода в дебаг-режиме (а opt=0 это именно оно) это совершенно отвратительная идея. Лучше бы придумался пример который компилятор не осиляет (это сделать элементарно, как я выше показал самые простые вещи его могут смутить), и от него уже плясать.

Признаю, это сравнение получилось нечестным. Но до этого как раз было сказано о честном сравнении:

И при проверке мы увидим в консоли… что векторная реализация работает медленнее?
Так происходит из-за того, что компилятор оптимизирует скалярную реализацию до того же уровня скорости.

На моём компьютере она составляет ~900% по сравнению с обычной реализацией на wasm.

На Safari ничего не работает, но да и ладно, кто им пользуется? (сарказм)

Консоль Safari

А в целом, производительность в Chrome 101.0.4951.64 (Official Build) (arm64) Apple M1 оставляет желать лучшего:
GPU: ~858.6 fps
JS: ~41.4 fps
wasm-scalar: ~38.6 fps
wasm-simd: ~25.4 fps

Скриншоты

Не совсем в теме, поэтому вопрос к знающим: это как-то можно исправить силами того, кто компилирует этот проект?

по-моему автору не хватает знаний как по simd так и по части микробенчмарков, благо сверху расписали.

от себя могу добавить: simd вообще не панацея (ваш бенчмарк это доказал, этот вариант работает медленнее), современные процессоры суперскалярные и у интелов под 10 "портов" на которых инструкции могут выполнятся параллельно (отдельные сложения и у умножения например, как написали выше стоит следить за зависимостью по данным). так же simd инструкции выполняются совсем не за такт. кроме того они прилично греют процессор, из-за чего он может начать скидывать частоту и по итогу у вас всё будет работать даже медленнее лобового варианта (особенно от этого страдает avx512, кстати софт который используется для разогрева процессора и проверки системы охлаждения молотит как раз avx инструкции). в общем очень много ньюансов.

Спасибо, что поделились информацией про подводные камни SIMD'ов. Я действительно не знал о некоторых нюансах.

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

Реальным бенчмарком скорости является онлайн демка - вот в ней уже видно, что даже с максимальной оптимизацией компилятор создаёт программу, которая работает в 2 раза медленнее, чем моя с SIMD'ами

Ну выше я писал, что, по крайней мере в некоторых окружениях, simd-ы могут получиться даже медленнее.

Мне понравилось что тут для сравнения рядом поставили еще и GPU считалку. Для таких объемных задач во много это и проще и приоритетнее.

Зарегистрируйтесь на Хабре, чтобы оставить комментарий