golang

Часть 3. Векторизация на Go: CGo, транзакции, компиляторы, поддержка, байтовые инструкции

  • суббота, 10 мая 2025 г. в 00:00:11
https://habr.com/ru/companies/oleg-bunin/articles/905972/

В первой части статьи мы рассмотрели, как можно вручную ускорить Go-код с помощью векторизации и SIMD-инструкций, реализованных через Go-ассемблер. Написали простую, но показательно быструю реализацию sliceContains и увидели, что даже базовая векторизация может дать ускорение в 10–14 раз по сравнению со стандартной реализацией.

Во второй части статьи погрузились в практическое применение SIMD в Go-ассемблере, реализовали функцию SliceContainsV1 и изучили, как с помощью VADD, VDUP и других инструкций можно добиться 10–14-кратного ускорения простых задач.

Но возможности оптимизации Go-программ на этом не заканчиваются. В этой части мы пойдём дальше: рассмотрим другие техники низкоуровневой оптимизации — от использования C-кода и альтернативных компиляторов с поддержкой векторизации до работы с аппаратными транзакциями памяти на Intel. Поговорим о том, как внедрять ассемблер в продакшен-код, не боясь за его поддержку, и как обойти ограничения стандартного Go-компилятора.

Привет, Хабр! Меня зовут Игорь Панасюк, я работаю в Яндекс, преподаю в ИТМО, а также в свободное время выступаю на конференциях, делюсь опытом в соцсетях и помогаю развитию Go-сообщества, веду телеграм-канал и youtube-канал. Если вы уже знакомы с базовыми техниками векторизации, эта часть поможет глубже понять, как устроены продвинутые способы ускорения Go-кода и на что стоит обратить внимание при работе с архитектурно-зависимыми оптимизациями.

CGo

Язык Go поддерживает вызовы на C через механизм CGo, что позволяет использовать низкоуровневые оптимизации и сторонние библиотеки, написанные на C. Это особенно полезно, если вы хотите использовать SIMD-инструкции, которые не доступны напрямую в Go.

Хотя в Go есть встроенные intrinsic-функции (в основном для нужд рантайма и компилятора), полноценной поддержки SIMD intrinsics на уровне языка пока нет. Поэтому для задач, где важна векторизация, логичным шагом будет реализовать соответствующую функцию на C и вызвать её из Go-кода.

Чтобы написать реализацию contains на C, используя SIMD-инструкции и intrinsics, воспроизводим ту же логику, что ранее реализовали на ассемблере:

  1. Загружаем данные в вектор (vec = VLD(...))

  2. Выполняем сравнение

  3. Сворачиваем результат

  4. Возвращаем true, если найдено совпадение

bool slice_contains(const uint8_t *slice, size_t size, uint8_t value) {
   uint8x16_t val_vec = vdupq_n_u8(value);

   for (size_t i = 0; i < size; i += 16) {
       uint8x16_t data_vec = vld1q_u8(&slice[i]);
       uint8x16_t result_vec = vceqq_u8(data_vec, val_vec);
       uint16_t result = vaddvq_u8(result_vec);

       if (result) {
           return true;
       }

    }

    return false;

}

Затем с помощью CGo подключаем этот C-фрагмент в Go-программу и можем вызывать его как обычную функцию. Получаем сочетание удобства Go и производительности низкоуровневого SIMD-кода на C.

Посмотрим, что из этого получим. Точно такая же программа, только написанная на C:

import "C"

import (
   "unsafe"
)

func SliceContains(data []uint8, target uint8) bool {
   return bool(C.slice_contains((*C.uint8_t)(unsafe.SliceData(data)),
C.size_t(len(data)), C.uint8_t(target)))
}

Вызовем её из Go, используя CGo.\

go test -bench=. -benchmem -cpu=1
goos: darwin
goarch: arm64
pkg: asm/simd/slice_contains
cpu: Apple M3 Pro
BenchmarkSliceContains/SliceContainsV1_(SIMD)               63256          18263 ns/op
BenchmarkSliceContains/SliceContainsVO                       4813         252514 ns/op
BenchmarkSliceContains/SliceContainsCgo                     56691          21913 ns/op 
PASS
ok      asm/simd/slice_contains 4.359s

Посмотрим на бенчмарки и увидим, что в целом CGo даёт чуть больше накладных расходов, но разница небольшая. В целом, с этим можно жить и использовать.

Другие способы

Кроме CGo есть и другие способы. Я сам их не использовал, но знаю примеры, когда они помогали другим. Речь идёт о компиляторах, способных самостоятельно производить векторизацию на этапе сборки:

  • gccgo — компилятор на базе GCC, использующий gofrontend и libgo.

  • gollvm — компилятор на основе LLVM, также использующий gofrontend.

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

Идея проста: компилятор анализирует код, обнаруживает потенциально оптимизируемые циклы (например, линейные проходы по массивам с арифметикой или сравнениями) и автоматически генерирует SIMD-инструкции. В результате вы получаете более производительный бинарник — без необходимости писать низкоуровневый код вручную.

Хотя такие случаи пока не слишком распространены, есть реальные примеры, где это давало ощутимый прирост производительности. Если у вас есть простое, но вычислительно нагруженное Go-приложение, содержащее циклы, которые можно эффективно векторизовать, попробуйте собрать его с помощью gccgo или gollvm. Это может дать неожиданный положительный результат.

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

Хотя компиляторы gccgo и gollvm предлагают потенциальные выгоды в производительности за счёт векторизации, у них есть и некоторые ограничения, особенно в части работы с runtime и сборщиком мусора (GC).

Один из таких нюансов — это работа со стек-мапами (stack maps). Дефолтный компилятор Go (gc, используемый в go build) генерирует оптимизированные стек-мапы, которые помогают garbage collector (GC) более точно понимать, где именно на стеке находятся указатели на объекты в куче. Это позволяет выполнять сборку мусора быстрее и эффективнее — особенно в многопоточной среде и при больших объёмах аллокаций.

В отличие от этого, runtime от gollvm и gccgo использует более консервативный подход. Он не всегда может точно указать расположение указателей в стеке, и в результате GC может быть вынужден перестраховываться, сканируя больше данных и работая медленнее.

Таким образом:

  • В CPU-интенсивных задачах, где сборка мусора почти не задействуется, это может не иметь значения.

  • Но в приложениях с большим количеством аллокаций и активной работой GC влияние может быть ощутимым.

Помимо стандартного Go-компилятора, существуют другие компиляторы, которые поддерживают векторизацию «из коробки». С их помощью можно автоматически ускорить участки кода — особенно те, где активно используются циклы и вычисления. Но придётся жить с небольшими накладными расходами.

Где ещё может пригодится ассемблер

Не только векторизация способна помочь. У современных процессоров существует множество расширений, которые тоже могут упростить жизнь. Простой пример: инструкции работы с криптографией. 

Есть примеры и интереснее: представим, что вы хотите реализовать так называемый Hardware Transaction Manager. Если взять современную базу данных, то под капотом у них будет MVCC. Этот MVCC работает как раз на Software Transaction Manager (управление состояниями).

Современные процессоры, особенно от Intel, предоставляют уникальные аппаратные инструкции, которые можно использовать для реализации продвинутых механизмов работы с памятью — например, транзакционной памяти.

Пример: Intel Transactional Synchronization Extensions (TSX). На архитектуре Intel доступен набор инструкций:

  • XBEGIN — начало транзакции

  • XEND — завершение транзакции

  • XABORT — принудительное прерывание транзакции

Эти инструкции позволяют создать транзакции в памяти, работающие на уровне когерентности кэшей. То есть, процессор сам отслеживает изменения в кэш-линии и при конфликте автоматически откатывает изменения — без участия классических блокировок (mutex/spinlock).

Это даёт возможность реализовать гибридный Transaction Manager: сначала попытка выполнить транзакцию аппаратно, если не получилось — fallback на программную реализацию. Вы получаете потенциально меньше накладных расходов, выше производительность на многопроцессорных системах при правильном использовании. Профит!

Но важно учесть, что TSX плохо работает с большими объёмами данных — транзакция может прерваться, если затрагиваются слишком много кеш линий или возникают прерывания. Не все процессоры поддерживают TSX.

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

  • Векторные инструкции (SSE, AVX, NEON)

  • Транзакционные расширения (TSX)

  • Специализированные команды для атомарных операций, криптографии и т.д.

Всё это можно использовать вручную через Assembly или встроенные примитивы в Go (или через CGo).

Если у вас есть узкое место в коде, и вы точно знаете, где и почему оно возникает — имеет смысл посмотреть, какие аппаратные инструкции доступны на вашей архитектуре, и попробовать использовать их напрямую. Иногда это может дать радикальный прирост производительности, особенно в системном программировании или high-performance системах.

Директивы для голых инструкций 

Есть специальные директивы WORD и BYTE, которые позволяют вызывать голую инструкцию. Если взять программу на C, ее декомпилируете, получаете буквально байты инструкции.

• WORD
• BYTE

gcc main.c
otool -tvj a.out

0000000100003f24          3dc02fe1         ldr q1, [sp, #0xb0]
0000000100003f28          4e21d400         fadd.4s v0, v0, v1
0000000100003f2c          3d802be0         str q0, [sp, #0xa0]
0000000100003f30          3dc02be0         Idr q0, [sp, #0xa0]

ENDIAN!

Рассматриваемая инструкция выполняет сложение чисел с плавающей точкой (float). По умолчанию она не поддерживается в Go-ассемблере, поэтому её необходимо встраивать вручную через машинные коды.

Важно: учитывайте порядок байтов (endianness) при вставке инструкций напрямую. Например, вы получаете сырые байты инструкции, такие как 4e21d400 и подобные.

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

#include "textflag.h"

// func vectorFloatAdditionV1(first, second, dst []float32)
TEXT ·vectorFloatAdditionV1(SB), NOSPLIT, $0
   LDP first_base+0(FP), (R0, R1)
   LDP second_base+24(FP), (R2, R3)
   LDP dst_base+48(FP), (R4, R5)

   MOVD $0, R7

loop:
     CMP R5, R7
  BGE done

Теперь вы можете напрямую вызывать машинную инструкцию в Go-ассемблере, вставляя её в виде байтов. Это позволяет использовать любую инструкцию, которая поддерживается вашей архитектурой процессора, даже если она не предусмотрена синтаксисом Go-ассемблера.

   VLD1 (R0), [V0.S4]
   VLD1 (R2), [V1.S4]

   WORD $0x4e21d400 // fadd.4s v0, v0, v1

   VST1 [V0.S4], (R4)

   ADD $4, R7
   ADD $16, R4
   ADD $16, R0
   ADD $16, R2

   B loop

done:
   RET

WORD $0x4e21d400

Я встречал примеры, где разработчики просто вставляют инструкции через WORD-директивы. Такой подход позволяет вызывать любую поддерживаемую инструкцию целевой платформы, что может быть полезно для написания более эффективного низкоуровневого кода.

Выводы

Хотя ассемблер даёт максимальный контроль над производительностью, он не всегда — единственный или даже лучший путь. В этой части мы рассмотрели альтернативные методы, которые позволяют ускорять Go-код без глубокого погружения в низкоуровневую реализацию.

Вот основные выводы:

  • CGo позволяет вызывать функции на C и использовать SIMD intrinsics, если вы не боитесь небольших накладных расходов. Это простой способ внедрить оптимизированные фрагменты, особенно когда такие библиотеки уже существуют.

  • Альтернативные компиляторы Go — такие как gccgo и gollvm — могут самостоятельно векторизовать циклы. Это удобно, если вы хотите сохранить чистоту Go-кода, но при этом получить ускорение на этапе сборки. Важно: нужно внимательно тестировать и учитывать различия в runtime и GC.

  • Векторизация "по умолчанию" в некоторых компиляторах может дать прирост производительности без ручного труда. Это особенно полезно для кода, содержащего арифметику в циклах, обработку массивов или матриц.

  • Аппаратные расширения современных CPU можно использовать в Go, даже, если они не поддержаны в ассемблере на уровне синтаксиса, используя «голые инструкции».

Важно понимать: использовать такие подходы стоит только тогда, когда есть узкое место и профилирование подтверждает необходимость оптимизации. Без этого любые попытки «ускорить на всякий случай» превращаются в преждевременную оптимизацию.

Полезные ссылки

Часть 1 | Часть 2 | Часть 3

Поизучать:

Посмотреть:

Почитать: