Skip to content

SIMD с нуля

This content is not available in your language yet.

Процессор умеет складывать не два числа, а два набора чисел — одной командой. Шестнадцать сложений за то же время, что одно. Это называется SIMD: single instruction, multiple data, одна команда над многими данными.

В Python эта возможность спрятана внутри NumPy, и добраться до неё из своего кода нельзя. В Mojo она — обычный тип языка.

def main():
var v = SIMD[DType.float32, 4](1.0, 2.0, 3.0, 4.0)
var w = SIMD[DType.float32, 4](10.0)
print("вектор:", v)
print("сумма:", v + w)
print("произведение:", v * w)
print("reduce_add:", v.reduce_add())
print("элемент:", v[2])
Результат
вектор: [1.0, 2.0, 3.0, 4.0]
сумма: [11.0, 12.0, 13.0, 14.0]
произведение: [10.0, 20.0, 30.0, 40.0]
reduce_add: 10.0
элемент: 3.0

Два параметра: тип элемента и сколько их. Оба известны при сборке — иначе процессор не знал бы, какую команду выполнять.

Обратите внимание на SIMD[DType.float32, 4](10.0): одно число размножилось по всем полосам. Это частый приём — умножить весь вектор на скаляр.

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

from std.sys import simd_width_of
def main():
print("float32:", simd_width_of[DType.float32]())
print("float64:", simd_width_of[DType.float64]())
print("int8: ", simd_width_of[DType.int8]())

На машине, где писалась эта глава (Intel Xeon 2,8 ГГц):

float32: 16
float64: 8
int8: 64

Шестнадцать чисел float32 — это 512 бит, то есть процессор с AVX-512. У вас может быть 8 или 4 — и это нормально.

Обратите внимание на закономерность: чем меньше элемент, тем больше их помещается. Регистр один и тот же, меняется только нарезка.

Из списка данные в вектор кладут не по одному, а сразу пачкой:

def main():
var data = List[Float32](capacity=20)
for i in range(20):
data.append(Float32(i))
var ptr = data.unsafe_ptr()
var chunk = ptr.unsafe_load[width=4](0)
print("загружено:", chunk)
ptr.unsafe_store(0, chunk * 2.0)
print("после записи:", data[0], data[1], data[2], data[3])
Результат
загружено: [0.0, 1.0, 2.0, 3.0]
после записи: 0.0 2.0 4.0 6.0

Слово unsafe в названии честное: границы никто не проверяет. Загрузить шестнадцать чисел, начиная с предпоследнего, вам никто не помешает — и прочитается чужая память.

Массивы редко бывают кратны ширине вектора. Тысяча элементов при ширине шестнадцать — это 62 полных вектора и 8 штук в остатке, которые придётся досчитать по одному:

var full = (len(data) // W) * W
for i in range(0, full, W): # основная часть — вектором
accumulator += ptr.unsafe_load[width=W](i)
var total = accumulator.reduce_add()
for i in range(full, len(data)): # хвост — поштучно
total += data[i]

Забыть про хвост — самая частая ошибка в векторном коде: результат получается почти правильным, и на маленьких тестах расхождение легко не заметить.

Аккумулятор в примере выше — не число, а вектор из W независимых накопителей. Каждый складывал свою полосу. Собрать их в одно значение можно методами reduce_*:

МетодЧто делает
reduce_add()сумма всех полос
reduce_max() / reduce_min()наибольшее / наименьшее
reduce_mul()произведение

Сумма массива из 8192 чисел float32, замер по методике из главы Как честно мерить скорость — минимум из партий, данные в куче, контрольная сумма сходится. Чтобы числу можно было верить, замер повторён на двух разных процессорах, у обоих ширина для float32 равна 16.

МашинаПоэлементноВекторомУскорение
Intel Xeon 2,8 ГГц1,2540,07317,1×
AMD Ryzen 7 9700X0,3670,02216,4×

Наносекунд на элемент. Абсолютные числа различаются втрое с половиной — а ускорение и там, и там держится около шестнадцати, то есть ровно на ширине вектора. Именно так и должно быть, и именно поэтому этому числу можно доверять: оно свойство приёма, а не машины.

Отклонения в обе сторону — шум. На Xeon восемь прогонов дали от 15,7 до 17,5.

Здесь легко остановиться, решив, что потолок достигнут. Это не так, и проверяется просто: добавим независимые накопители, чтобы соседние сложения не ждали друг друга.

МашинаВектор, 1 накопительВектор, 4 накопителяИтого к наивному циклу
Intel Xeon 2,8 ГГц0,0730,02648,8×
AMD Ryzen 7 9700X0,0220,00660,1×

Векторная версия ускоряется ещё втрое-вчетверо. Значит, она упиралась не только в количество данных за такт, но и в длину цепочки: каждое сложение ждало результата предыдущего.

Именно поэтому библиотечная векторизация из следующей главы не просто режет массив на векторы, но ещё и разворачивает цикл.

sum.mojo
# Сумма массива: поэлементно и вектором.
#
# Длина нарочно не кратна ширине вектора — так бывает почти всегда,
# и хвост приходится досчитывать отдельно.
from std.sys import simd_width_of
comptime W = simd_width_of[DType.float32]()
comptime N = 1001 # нечётное: хвост будет при любой ширине вектора
def scalar_sum(data: List[Float32]) -> Float32:
"""По одному числу за раз."""
var total = Float32(0)
for i in range(len(data)):
total += data[i]
return total
def vector_sum(data: List[Float32]) -> Float32:
"""По W чисел за раз, плюс хвост."""
var ptr = data.unsafe_ptr()
var accumulator = SIMD[DType.float32, W](0)
# Основная часть: столько полных векторов, сколько влезло.
var full = (len(data) // W) * W
for i in range(0, full, W):
accumulator += ptr.unsafe_load[width=W](i)
# Складываем полосы аккумулятора в одно число.
var total = accumulator.reduce_add()
# Хвост: оставшиеся элементы — по одному.
for i in range(full, len(data)):
total += data[i]
return total
def main():
var data = List[Float32](capacity=N)
for i in range(N):
data.append(Float32((i * 37) % 100))
var a = scalar_sum(data)
var b = vector_sum(data)
# W у каждой машины своё, поэтому печатаем не его, а то,
# что от машины не зависит.
print("элементов:", N)
print("поэлементно:", a)
print("вектором: ", b)
print("совпало:", a == b)
print("длина нечётная, значит хвост есть при любой ширине вектора")
Результат
элементов: 1001
поэлементно: 49500.0
вектором:    49500.0
совпало: True
длина нечётная, значит хвост есть при любой ширине вектора

Программа печатает не ширину вектора и не время: и то и другое у вас будет своё. Печатает она то, что от машины не зависит, — совпадение результатов.

🎯 Проверь себя

Почему у `SIMD` оба параметра — тип элемента и ширина — должны быть известны при сборке?

Потому что от них зависит, какую машинную команду выпустит компилятор. Сложение восьми float32 и восьми float64 — разные команды над разными регистрами. Ширина времени выполнения означала бы, что команду надо выбирать при каждом вызове, а это уже не SIMD.

Что не так с `SIMD[DType.float32, 8]`, зашитым в код руками?

Восьмёрка верна не везде. На процессоре с AVX-512 за раз проходит шестнадцать чисел float32, и половина возможностей пропадёт; на более старом — вектор придётся эмулировать. Правильно спросить у компилятора: simd_width_of[DType.float32]().

Массив из 1000 чисел, ширина вектора 16. Что произойдёт, если написать только `for i in range(0, len(data), 16)`?

Цикл зайдёт за границу массива: на 63-м шаге i станет 992, и загрузка шестнадцати чисел прочитает 8 элементов чужой памяти — проверок у unsafe_load нет. Нужно считать до (len // 16) * 16, а оставшиеся 8 элементов досчитать по одному.

Векторная сумма массива float дала не то же число, что поэлементная. Это ошибка?

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

Векторная версия дала 17× при ширине 16. Значит, потолок достигнут?

Нет. Проверяется добавлением независимых накопителей: векторная версия с четырьмя накопителями вместо одного ускоряется ещё втрое-вчетверо, и суммарно выходит 49× на Xeon и 60× на Ryzen. Значит, вектор снял ограничение по данным за такт, но осталось второе — цепочка сложений, где каждое ждёт предыдущего. А сами 17 вместо 16 — просто шум: прогоны давали от 15,7 до 17,5.

Векторизация и параллелизм: как поручить нарезку на векторы и хвост стандартной библиотеке — и как раскидать работу по ядрам.

Примеры проверены на Mojo 1.1.0

Тексты курса — CC BY-NC-SA 4.0, код примеров — Apache 2.0