Проверил дизасмом на обеих машинах, выравнивание оказалось ни при чем: загрузки его попросту не требуют. 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.
Пик у меня замерен на этом самом 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. Мерил на том что есть :-)
Сейчас больша часть времени уходит на доработку систем SSO и AMC (access control management), и кандидата я вижу именно в них. Деталей рассказать не могу, но область назову. Горячий пусть авторизации - это проверка прав и согласий на каждый запрос. По сути пересечения множеств коротких слов, пачками до тысячи элементов. Горячий узкий цикл с данными, которые влезают в кэш. В целом там все готово, осталось замерить на реальном железе, у нас на серверах кстати поддерживается AVX-512.
Спасибо за разбор, это лучший фидбек который статья могла получить, развернуто и с цифрами. Часть пунктов настолько справедлива что уйдет в правки статьи. По части принесу встречные замеры.
«Берет частотой» признаю, моя недоработка. Частота объясняет примерно 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 ГГц по сэмплеру.
Умей скаляр 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 против кэша и за пинок пересчитать все в тактах, об этом я не подумал.
На меня больше всего произвело впечатление https://habr.com/ru/articles/995906/#simd-archsimd . Статью я писал долго, по этому не стал править. Но мне удалось потестить работу на Intel процессоре 14 поколения. Прирост в производительности почти x3. Это очень сильно.
Возможно завтра найду время чтобы выложить в github исходники своих бенчей, чтобы каждый мог попробовать сам.
Да, sqlc отличный инструмент, но это разные весовые категории :-)
sqlc парсит SQL, генерирует Go-структуры и методы. Даёт типобезопасность, но добавляет шаг сборки, зависимость от внешнего тула и генерируемый код в репозиторий.
Мой же подход сейчас - это просто парсер на условно 50 строк кода. Ноль зависимостей, ноль генерации, полный контроль над кодом репозитория. Ты сам решаешь как сканировать результаты, какие хелперы использовать, как обрабатывать ошибки.
В целом считаю sqlc отличным интснтументом. Если нужна типобезопасность и не смущает кодогенерация, sqlc это одно из лучших решений, тут сложно спорить. Если хочешь минимализм и полный контроль, то вполне хватит простого go:embed с парсером. Иногда (да по правде говоря чаще всего), этого вполне достаточно.
Хороший разработчик не привязывается к одному инструменту, а понимает компромиссы каждого.
Хранимые процедуры легитимный подход, но со своими компромиссами. Логика размазывается между приложением и БД. Для меня это усложняет отладку и понимание системы целиком.
Версионирование процедур отдельная боль. Нужно синхронизировать миграции БД с деплоем приложения.
Тестирование тоже усложняется. Для unit-тестов репозитория теперь нужна реальная БД с актуальными процедурами. Плюс vendor lock-in: PL/pgSQL !== T-SQL !== MySQL процедуры.
Странно что Не бойся использовать разные инструменты в одном проекте при чтении статьи проходит мимо глаз. Если у вас команда DBA сильная и процедуры стандарт, отлично. Embedded SQL для тех, кто хочет держать всю логику в Go и деплоить одним бинарником.
Константы в Go-файле - валидный подход. Но в чистом .sql файле работает полноценный SQL-тулинг: форматирование через pg_format, линтинг через sqlfluff, автодополнение колонок при подключении IDE к БД.
Парсер и риск ошибок. Регулярка тривиальная, 5 строк. Если разработчик забудет маркер, запрос не попадёт в map, и Get() упадёт с panic при первом же вызове. Ошибка не пройдёт незамеченной.
Ошибка компиляции. Тут я возможно действительно преувеличил. go:embed даст ошибку компиляции только если файл не найден. Однако никто кто мешает прикрутить нормальную валидацию SQL при инициализации пакета. Все это выходит на рамки простой статьи.
Как я писал в самом начали в конце статьи: это не единственный, да и не самый лучший способ организации работы с SQL файлами. Однако в большинстве случаев на моей практике он самый удобный.
Евгений спасибо, полезная статья! Было бы неплохо также детальнее разобрать мьютексы и библиотеку race (то как она работает под капатом). Думаю получился бы неплохой цикл статей по примитивам синхронизации.
В тексте ошибка. Generics - это функции или типы, которые могут работать с любым типом данных. В Go generics были введены в версии 1.21 и они означают, что вы можете написать функцию, которая будет работать с любым типом данных, а не только с определенным.
При написании пути можно использовать ~ для обозначения директории пользователя системы. По умочанию vscode складывает свои настройки в ~/.vscode. Я этого в описании не писал, сейчас напишу. Заодно лого поменяю, а то неудачное какое-то получилось )
Кстати сделал. В версии 2.1.3 можно выделить текст в коде и на основе него создать компонент по шаблону. Более того, в названии компонента поддерживаются конструкции типа ../. Указав название шаблона как "../../modals/register user", расширение выйдет на 2 уровня вверх, создаст директорию modals и в нее положит компонент.
Проверил дизасмом на обеих машинах, выравнивание оказалось ни при чем: загрузки его попросту не требуют. 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.
Пик у меня замерен на этом самом 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. Мерил на том что есть :-)
Как и обещал. Статью доработал.
Спасибо за теплые слова! Я очень рад, что смог нанести вам пользу :-)
Спасибо за теплые слова!
Сейчас больша часть времени уходит на доработку систем SSO и AMC (access control management), и кандидата я вижу именно в них. Деталей рассказать не могу, но область назову.
Горячий пусть авторизации - это проверка прав и согласий на каждый запрос. По сути пересечения множеств коротких слов, пачками до тысячи элементов.
Горячий узкий цикл с данными, которые влезают в кэш. В целом там все готово, осталось замерить на реальном железе, у нас на серверах кстати поддерживается AVX-512.
Если будут интересные цифры, доработаю статью.
Спасибо за разбор, это лучший фидбек который статья могла получить, развернуто и с цифрами. Часть пунктов настолько справедлива что уйдет в правки статьи. По части принесу встречные замеры.
«Берет частотой» признаю, моя недоработка. Частота объясняет примерно 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 ГГц по сэмплеру.
Умей скаляр 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 против кэша и за пинок пересчитать все в тактах, об этом я не подумал.
Такие комментарии могут быть ценнее самой статьи.
На меня больше всего произвело впечатление https://habr.com/ru/articles/995906/#simd-archsimd . Статью я писал долго, по этому не стал править. Но мне удалось потестить работу на Intel процессоре 14 поколения. Прирост в производительности почти x3. Это очень сильно.
Возможно завтра найду время чтобы выложить в github исходники своих бенчей, чтобы каждый мог попробовать сам.
P.S. Ждем тернарный оператор :-)
Да, sqlc отличный инструмент, но это разные весовые категории :-)
sqlc парсит SQL, генерирует Go-структуры и методы. Даёт типобезопасность, но добавляет шаг сборки, зависимость от внешнего тула и генерируемый код в репозиторий.
Мой же подход сейчас - это просто парсер на условно 50 строк кода. Ноль зависимостей, ноль генерации, полный контроль над кодом репозитория. Ты сам решаешь как сканировать результаты, какие хелперы использовать, как обрабатывать ошибки.
В целом считаю sqlc отличным интснтументом. Если нужна типобезопасность и не смущает кодогенерация, sqlc это одно из лучших решений, тут сложно спорить. Если хочешь минимализм и полный контроль, то вполне хватит простого
go:embedс парсером. Иногда (да по правде говоря чаще всего), этого вполне достаточно.Хороший разработчик не привязывается к одному инструменту, а понимает компромиссы каждого.Интересное решение. Даже не думал об этом :-)
Хранимые процедуры легитимный подход, но со своими компромиссами. Логика размазывается между приложением и БД. Для меня это усложняет отладку и понимание системы целиком.
Версионирование процедур отдельная боль. Нужно синхронизировать миграции БД с деплоем приложения.
Тестирование тоже усложняется. Для unit-тестов репозитория теперь нужна реальная БД с актуальными процедурами. Плюс vendor lock-in: PL/pgSQL !== T-SQL !== MySQL процедуры.
Странно что
Не бойся использовать разные инструменты в одном проектепри чтении статьи проходит мимо глаз. Если у вас команда DBA сильная и процедуры стандарт, отлично. Embedded SQL для тех, кто хочет держать всю логику в Go и деплоить одним бинарником.Константы в Go-файле - валидный подход. Но в чистом .sql файле работает полноценный SQL-тулинг: форматирование через pg_format, линтинг через sqlfluff, автодополнение колонок при подключении IDE к БД.
Парсер и риск ошибок. Регулярка тривиальная, 5 строк. Если разработчик забудет маркер, запрос не попадёт в map, и Get() упадёт с panic при первом же вызове. Ошибка не пройдёт незамеченной.
Ошибка компиляции. Тут я возможно действительно преувеличил. go:embed даст ошибку компиляции только если файл не найден. Однако никто кто мешает прикрутить нормальную валидацию SQL при инициализации пакета. Все это выходит на рамки простой статьи.
Как я писал в самом начали в конце статьи: это не единственный, да и не самый лучший способ организации работы с SQL файлами. Однако в большинстве случаев на моей практике он самый удобный.
Евгений спасибо, полезная статья! Было бы неплохо также детальнее разобрать мьютексы и библиотеку race (то как она работает под капатом). Думаю получился бы неплохой цикл статей по примитивам синхронизации.
Отличная статья, спасибо! Но кажется не работают якоря на содержании
В тексте ошибка.
Generics - это функции или типы, которые могут работать с любым типом данных. В Go generics были введены в версии 1.21 и они означают, что вы можете написать функцию, которая будет работать с любым типом данных, а не только с определенным.
Введены они были в 1.18
При написании пути можно использовать ~ для обозначения директории пользователя системы. По умочанию vscode складывает свои настройки в ~/.vscode. Я этого в описании не писал, сейчас напишу. Заодно лого поменяю, а то неудачное какое-то получилось )
Сделал бы раньше, но по проекту дедлайны горели.
Добавил возможность в настройках указать директорию.
Рад что понравилось ) Пиши если придумаешь что-нибудь интересное в процессе работы )
Я думаю такое можно осуществить
Кстати сделал. В версии 2.1.3 можно выделить текст в коде и на основе него создать компонент по шаблону. Более того, в названии компонента поддерживаются конструкции типа ../. Указав название шаблона как "../../modals/register user", расширение выйдет на 2 уровня вверх, создаст директорию modals и в нее положит компонент.