Комментарии 12
Мое уважение за огромную качественную проделанную работу! И рад этой новости. На маках, конечно, прод у меня на работе не крутится, но всегда приятно, когда твоё железо используется более грамотно и эффективно. Круто!
Спасибо за статью, очень интересно и подробно описано.
Если не секрет, где на практике планируете использовать SIMD?
Спасибо за теплые слова!
Сейчас больша часть времени уходит на доработку систем SSO и AMC (access control management), и кандидата я вижу именно в них. Деталей рассказать не могу, но область назову.
Горячий пусть авторизации - это проверка прав и согласий на каждый запрос. По сути пересечения множеств коротких слов, пачками до тысячи элементов.
Горячий узкий цикл с данными, которые влезают в кэш. В целом там все готово, осталось замерить на реальном железе, у нас на серверах кстати поддерживается AVX-512.
Если будут интересные цифры, доработаю статью.
“Реальность скромнее: Mitchell Hashimoto, который векторизовал терминал Ghostty, приводит для AVX2 не теоретические 8x, а реальные «more like a 5x». Куда утекает разница, увидим на собственных бенчмарках.”
Тут останови себя и сделай ставку. На Mac четыре полосы дали 4,2x, почти потолок из таблицы ширин. У i9 полос восемь, а вышло 5x: куда делись ещё три икса? Сформулируй версию и держи её до секции про декомпозицию, там будет чем проверить.
Никуда она не “утекает”, она наоборот “притекает” - из завышенных ожиданий и неверных предположений. С чего вообще взято, что на SIMD ускорение по сравнению со скаляром должно быть именно х8? Оно идет из неверного предположения, что скаляр выполняется строго в жесткой цепочке, одна операция строго за другой четко как записано в алгоритме составленной программы, с темпом исполнения не более 1 инструкции за 1 такт ЦП над 1 числом за раз. А в SIMD 256 - 1 инструкция сразу на 8 fp32 числах упакованных в 256 бит вектор. И неверные, завышенные ожидая проистекающие из этого: хочу получить х8 от использования SIMD.
Но извините, практически все современные процессоры кроме некоторых суперупрощенных встраиваемых решений типа микроконтроллеров (начиная еще с самого первого Pentium!) это не скалярные процессоры, а суперскалярные. Это означает, что они могут (и постоянно это делают на практике) выполнять больше чем 1 скалярную инструкцию за такт даже без всяких векторов, SIMD/MIMD и прочих оптимизаций и расширенных наборов команд. И даже при наличии сильной взаимозависимости в обрабатываемых данных. И имеют различные встроенные оптимизации для этого, работающие при этом на аппаратном уровне ядра самого ЦП, без необходимости какого-либо участия программиста или компилятора в этом. Это в частности “спекулятивное исполнение”, включая предсказание ветвлений (и очень длинные линейные циклы как в подобном тесте - простейший случай для них) и внеочередное исполнение(out-of-order execution), когда инструкции на исполняющих устройствах ЦП по факту исполняются совсем не в том порядке как они записаны в программе программистом. А потом уже только на “выходе” (перед записью результата в память) переупорядочиваются заново, для сохранения правильных результатов вычислений ожидаемых от выполняющейся программы.
Насколько помню в этом поколении процессоров Intel имеется сразу по 6 портов для выполнения скалярных инструкций с плавающей точкой(а общее, вместе с целочисленными и загрузкой/выгрузкой ЕМНИП вообще 11 или 12). И теоретический темп выполнения скаляра при условии “расшивки зависимостей” и своевременного “подвоза” данных из памяти это соответственно 6 скалярных инструкций/такт над fp32 данными.
Для AVX2 же теоретический максимум: 2х256/32 = 16 операций над fp32 за такт. Т.к. у этих процессоров только по 2 штуки 256 битных SIMD модуля на ядро и они могут выполнять 256 битные векторные инструкции с темпом не выше 2 штук/такт.
Так что теоретический потолок ускорения от применения SIMD по сравнению с “правильно” приготовленным скаляром вообще совсем не х8, а всего лишь 16/6 = ~2.66x
Что вы собственно и продемонстрировали не только в теории, но и на практике в варианте с несколькими аккумуляторами для промежуточных сложений помогая ЦП эффективно развязать зависимости в данных (но он делает это и без вас даже в базовом варианте – просто намного менее эффективно чем это может сделать программист понимающий весь алгоритм и цель обработки данных в целом). У вас разница скаляр /SIMD даже меньше получилась (чуть меньше 2х), но это из-за того, что как вы верно подметили использование SIMD не “бесплатно” - есть еще и “накладные расходы”. А сам основной цикл(без учета “накладных”) думаю, выполняется с разницей в скорости близкой как раз к этим теоретическим 2.6х.
Семьсот микросекунд против трёхсот пятидесяти: десктоп вдвое быстрее ещё до всякого SIMD, и ничего векторного тут нет, P-ядро i9 разгоняется до 6 ГГц и берёт частотой.
Вот только разница в частоте у этих процессоров 4 ГГц vs 6 ГГц . И это еще только при условии разгона до максимального турбобуста во время тестов - если нет, то разница частот будет еще меньше, базовая частота например вообще у Mac выше: 3.6 ГГц vs 3.2 ГГц. Т.е. максимум это +50% в пользу Intel по частоте: 6/4=150%. А вот разница в скорости реальных вычислений - все +100% в пользу Intel: 697574/349010 = 199.9%.
В других местах это тоже прослеживается. В варианте с SIMD теоретический потолок у Intel тоже всего на 50% выше как максимум. Т.к. общая теоретическая пиковая пропускная способность SIMD блоков у этих процессоров вообще по факту одинаковая несмотря на разницу в максимальной “ширине” SIMD: 2х256 bit SIMD блока в Intel или 4х128 bit SIMD в Mac дают одинаковый теоретический “потолок” в 16 32fp/такт. А разница по частоте - не более 50%. Так что в операций/сек потолок тоже не более чем на 50% отличается.
Разница же в реальной скорости работы в SIMD режиме оказывается даже больше чем 100% наблюдавшихся в скалярном: у вас получилось 57547/24486 = 235% (+135% в пользу Intel).
Ну и по “стене памяти”. У вас вроде бы выходит, что у Мас как бы нет “стены памяти”. Но это просто потому, что он до своей собственной “стены памяти” не достал “даже в прыжке с разгона”: из-за относительно невысокой скорости вычислений ядром он просто не доходит до того момента когда загрузка данных из памяти станет узким местом (т.к. узкое место у него в другом месте - ядро, а не память). А вот Intel – допрыгнул (и стукнулся в нее при больших объемах тестовых данных). Но так даже с уже серьезно упавшей производительностью при чтении данных уже из основной памяти без ограничений по их объему, Intel все-равно продолжает их обрабатывать даже немного быстрее чем Mac берущий компактный набор данных из своего локального L2-L3 кэша!
При чтении статьи помимо действительно хорошего обзора готовящихся SIMD обновлений в Go ненавязчиво складывается впечатление/продвигается мысль “вот посмотрите какой процессор у Mac” получился эффективный, а Intel работает как-то не особо хорошо". Хотя по факту все полученные и приведенные в статье данные вообще-то показывают как раз ровно обратное: конкретная микроархитектура Intel работает наоборот намного эффективнее, чем у Mac M3. По крайней мере пока речь идет именно о производительности(Mac вероятно энергоэффективнее и “холоднее” и может выдать больше операций/Вт, но тут это вообще не проверялось и не сравнивалось). И позволяет на практике намного ближе подбираться к своему теоретическому максимуму заданному базовыми аппаратными ограничениями: в виде количества исполняющих вычислительных блоков + тактовая частота. А вот Mac использует имеющиеся у него аппаратные ресурсы не особо эффективно.
Спасибо за разбор, это лучший фидбек который статья могла получить, развернуто и с цифрами. Часть пунктов настолько справедлива что уйдет в правки статьи. По части принесу встречные замеры.
«Берет частотой» признаю, моя недоработка. Частота объясняет примерно 1.5x из двукратной разницы на скаляре, 6.0 ГГц против ~4.05. Остальное добирает латентность цепочки FADD, 2 такта у Raptor Cove против 3 у M3 (uops.info по ADDSS и таблицы dougallj по Firestorm). Пересчитал свои же числа, на i9 выходит ~2 такта на элемент (в чистом замере при 5.7 ГГц ровно 2.03), на Mac ~2.8. То есть i9 и правда делает больше работы за такт а не только чаще тикает. Этого пересчета в статье нет, и зря.
Про стену памяти соглашусь с акцентом. Mac не «не имеет стены», он до нее не доезжает. Я писал что код упирается в цепочку сложений а не в память, но ваша формулировка честнее моего заголовка. Ядро M-серии тянет из DRAM порядка сотни ГБ/с (Anandtech намерял ~102 на M1 Max), моя сумма ест 23-24. Проверил и пункт про DRAM против кэша, у i9 из памяти 0.167-0.169 нс на элемент, у Mac из кэша 0.173. Действительно не медленее. Вот это я упустил полностью, даже не думал в эту сторону.
Теперь с чем не соглашусь, это «6 портов для скалярных FP» и потолок 16/6 ≈ 2.66x.
По uops.info скалярный ADDSS и векторный VADDPS ymm у Golden/Raptor Cove живут на одних и тех же двух портах p1 и p5. У обоих латентность 2 и темп 2 инструкции за такт. Шестерка похоже пришла из соседней ячейки памяти, пять целочисленных ALU или общие 12 портов ядра.
Проверил и железом на том же P-ядре что в статье. Чистые регистровые цепочки на C без обвязки Go, частота во время прогона 5.70 ГГц по сэмплеру.
sc_add_1 2.00 такта/инстр латентность ADDSS
sc_add_4..12 1.98-2.01 инстр/такт плато, 2 порта
v_add_4 2.02 инстр/такт = 16.1 fp32/тактУмей скаляр 6 сложений за такт, двенадцать независимых цепочек показали бы 0.029 нс на сложение. Показывают 0.087. Так что портовый потолок вектор/скаляр получается 16/2, те самые 8x. Только это потолок пропускной способности когда зависимости развязаны с обеих сторон, а не «сколько даст вектор поверх наивного цикла». Кстати практикой 2.66x тоже не подтверждается, если развязать цепочки в обеих версиях, мои Go-замеры дают 1.4-1.8x, еще меньше.
Похожая история с «процессор развязывает зависимости и без вас, просто менее эффективно». С этой цепочкой так не получается. Замер дает 2.03 такта на элемент, ровно латентность сложения, аппаратной помощи ноль тактов. OoO прячет под цепочку загрузки и счетчик цикла, но переставлять сложения float ему запрещено, поменяется результат округления. Поэтому аккумуляторы не подсказка процессору, а разрешение на переассоциацию которое может выдать только программист.
Откуда тогда 5x вместо 8x. Дизассемблировал горячий цикл sumSIMD@simd256, на одну VADDPS там 17 инструкций, bounds check и пересчет указателя слайса на каждой итерации. Итерация выходит за 3.2 такта вместо латентностных двух. 8 полос × 2.03/3.21 = 5.06x, сходится с бенчмарком до второго знака. На Mac та же обвязка прячется под латентность 3, отсюда его ровные 4x. Вобщем виноват кодоген simd-пакета, а не порты.
Про впечатление «Mac эффективный, а Intel не очень». Такой задумки не было, но перечитал заголовок и секцию про стену вашими глазами и вижу откуда оно берется.
Фидбек воистину полезный и с ним я что-то буду делать. Спокойно перемерю все еще раз и доработаю статью, добавлю пересчет на такт с латентностями и честный разбор куда деваются иксы. Заодно хочу добрать замер с 12 аккумуляторами на Mac, поидее M3 с латентностью 3 и четырьмя FP-конвейерами должен раскрыться именно там, на это я не расчитывал когда писал про восемь. Еще раз спасибо за пункт про DRAM против кэша и за пинок пересчитать все в тактах, об этом я не подумал.
Такие комментарии могут быть ценнее самой статьи.
Про стену памяти соглашусь с акцентом. Mac не «не имеет стены», он до нее не доезжает. Я писал что код упирается в цепочку сложений а не в память, но ваша формулировка честнее моего заголовка.
Очень сомнительно. M1 и далее выполняют 16 fmad операций за такт (4 блока NEON), т.е. 256ГФлопс. В таком простом цикле не должно быть никаких проблем достигнуть пика. Т.е. i9 и M3 не отличаются по производительности вычислений на такт.
Заодно хочу добрать замер с 12 аккумуляторами на Mac
Вам нужно 4 simd аккумулятора.
плюс мёртвая ветка AVX-512 в бинарнике
На AMD эта ветка не мёртвая :)
Пик у меня замерен на этом самом M3, а не взят из таблиц. Шестнадцать независимых цепочек fmla дают ровно 4 инструкции за такт, те самые 16 fmad по fp32. Только выходит 129 ГФлопс на ядро, не 256: 16 x 2 флопа x 4.03 ГГц.
Проблема достигнуть пика в простом цикле есть, причем у любого языка. Пику нужны 4 независимые инструкции каждый такт, а сумма тащит одну цепочку, где следующее сложение ждет предыдущее. Потолок такой цепочки равен 1/латентность, то есть 0.38 инструкции за такт, девять процентов пика, хоть на ручном ассемблере. Чтобы прокормить все блоки, аккумуляторов нужно блоки x латентность, около двенадцати, и плато в замере наступает как раз на двенадцати цепочках (microbench/ports_neon.c в репозитории). Сам компилятор так разложить цикл не имеет права: переассоциация float меняет результат, Go под честным IEEE 754 на нее не идет.
Что цикл упирается не в память, видно на одном массиве 4 МБ. Наивный цикл 693 мкс, шестнадцать скалярных аккумуляторов 155, вектор с четырьмя 102. Чтения во всех трех версиях одни и те же, ускорение в 6.8 раза дала одна лишь перестановка сложений.
С вашими 4 simd-аккумуляторами в Go так и вышло: версия с четырьмя лучшая на миллионе, 102 мкс, замер уже уехал в статью. Но работает четверка не потому, что блоков четыре. Чистым цепочкам для насыщения нужно двенадцать, просто Go-вектор упирается в обвязку раньше, чем в порты, и восемь аккумуляторов на миллионе уже чуть медленнее четырех. По пикам i9 и M3 равны, об этом в статье прямо написано. В этой задаче их различает латентность звена цепочки, 2.03 против 2.66, оба числа замерены.
Про AMD согласен, поэтому в конце статьи и стоит просьба к владельцам Zen 4/5 прогнать бенчи. Ну и касаемо AMD. Мерил на том что есть :-)
Как и обещал. Статью доработал.
Просто мысли: я не знаю, как данные процессоры работает если есть выравнивание не такое, как требуется. Itanium, как понимаю, бросал ошибку при не соответствии, x86 процессоры обычно старались подстраиваться, но у этого была цена (в такты или из части). Возможно из-за этого "работает, но не так скоро".
Проверил дизасмом на обеих машинах, выравнивание оказалось ни при чем: загрузки его попросту не требуют. Go гарантирует слайсу float32 только 4 байта, поэтому компилятор выровненных инструкций и не генерирует. На arm64 в горячем цикле стоит FMOVQ без требований к адресу, на amd64 загрузка вообще слита со сложением в VADDPS 0(DX), Y1, Y1, а VEX-кодировке все равно, выровнен ли адрес. Про Itanium вы правы, тот бросал фолт. Современные x86 и arm64 прощают, небольшой штраф остается лишь на пересечении кэш-линии.
Причем в этой задаче загрузки стоят ноль тактов, что видно из замера: Go-цикл со всей обвязкой идет 2.62-2.65 такта на вектор, а голая регистровая цепочка fadd без обращений к памяти идет 2.66. За «работает, но не так скоро» отвечает цепочка зависимостей. На одном и том же массиве 4 МБ наивный цикл занимает 693 мкс, двенадцать скалярных аккумуляторов 159, вектор 102, хотя загрузки и выравнивание везде одинаковые, менялся только порядок сложений. Подробный разбор в секции про декомпозицию и в UPD.
Разбор хороший, но сама статья тоже клодом написана — панибратство, характерные обороты. И чем ближе к концу, тем меньше редактуры.

В Go 1.27 завезли SIMD