Самый медленный способ ускорить программу на Go

https://goroutines.com/asm
  • Перевод

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


Зачем тратить время на программирование на ассемблере, когда есть отличные языки программирования высокого уровня? Даже с сегодняшними компиляторами все ещё есть несколько случаев, когда захотите написать код на ассемблере. Таковыми являются криптография, оптимизация производительности или доступ к вещам, которые обычно недоступны в языке. Самое интересное, конечно же, оптимизация производительности.


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


Пишем ассемблерный код в Go


Лучший способ начать — написать простейшую функцию. Например, функция add складывает два int64.


package main

import "fmt"

func add(x, y int64) int64 {
    return x + y
}

func main() {
    fmt.Println(add(2, 3))
}

Запуск: go build -o add-go && ./add-go


Для реализации этой функции на ассемблере создайте отдельный файл add_amd64.s, который будет содержать ассемблерный код. В примерах используется ассемблер для архитектуры AMD64.


add.go:


package main

import "fmt"

func add(x, y int64) int64

func main() {
    fmt.Println(add(2, 3))
}

add_amd64.s:


#include "textflag.h"

TEXT ·add(SB),NOSPLIT,$0
    MOVQ x+0(FP), BX
    MOVQ y+8(FP), BP
    ADDQ BP, BX
    MOVQ BX, ret+16(FP)
    RET

Для запуска примера поместите два этих файла в одну директорию и выполните команду go build -o add && ./add


Синтаксис ассемблера в лучшем случае… неясен. Существует официальное руководство Go и довольно-таки древнее руководство для ассемблера Plan 9, в котором даются некоторые подсказки относительно того, как работает язык ассемблера в Go. Лучшие источники для изучения — это существующий ассемблерный код Go и скомпилированные версии функций Go которые можно получить, выполнив команду: go tool compile -S <go file>.


Наиболее важные вещи, которые нужно знать — это объявление функции и компоновка стека.


Волшебное заклинание для запуска функции — TEXT ·add(SB), NOSPLIT, $0. Символ символ Юникода · разделяет имя пакета от имени функции. В данном случае имя пакета — main, поэтому имя пакета здесь пустое, а имя функции — add. Директива NOSPLIT означает, что не нужно записывать размер аргументов в качестве следующего параметра. Константа $0 в конце — это то, где вам нужно будет поместить размер аргументов, но поскольку у нас есть NOSPLIT, мы можем просто оставить его как $0.


Каждый аргумент функции кладётся в стек, начиная с адреса 0(FP), означающий смещение на ноль байт от указателя FP, и так для каждого аргумента и возвращаемого значения. Для func add (x, y int64) int64, он выглядит так:


Разберём код уже знакомой функции add:


TEXT ·add(SB),NOSPLIT,$0
    MOVQ x+0(FP), BX
    MOVQ y+8(FP), BP
    ADDQ BP, BX
    MOVQ BX, ret+16(FP)
    RET

Ассемблерная версия функции add загружает переменную x по адресу памяти +0(FP) в регистр BX. Затем она загружает из памяти y по адресу +8(FP) в регистр BP, складывает BP и BX, сохраняя результат в BX, и, наконец, копирует BX по адресу +16(FP) и возвращается из функции. Вызывающая функция, которая помещает все аргументы в стек, будет читать возвращаемое значение, оттуда где мы его оставили.


Оптимизация функции с помощью ассемблера


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


Допустим, у вас есть куча векторов, и вы хотите их умножить на матрицу преобразования. Возможно, векторы являются точками, и вы хотите переместить их в пространстве (перевод на Хабре — прим. пер.). Мы будем использовать векторы с матрицей преобразования размером 4x4.


type V4 [4]float32
type M4 [16]float32

func M4MultiplyV4(m M4, v V4) V4 {
    return V4{
        v[0]*m[0] + v[1]*m[4] + v[2]*m[8] + v[3]*m[12],
        v[0]*m[1] + v[1]*m[5] + v[2]*m[9] + v[3]*m[13],
        v[0]*m[2] + v[1]*m[6] + v[2]*m[10] + v[3]*m[14],
        v[0]*m[3] + v[1]*m[7] + v[2]*m[11] + v[3]*m[15],
    }
}

func multiply(data []V4, m M4) {
    for i, v := range data {
        data[i] = M4MultiplyV4(m, v)
    }
}

Выполнение занимает 140 мс для 128 МБ данных. Какая реализация может быть быстрее? Эталоном будет копирование памяти, которое занимает около 14 мс.


Ниже приведена версия функции, написанная на ассемблере с использованием инструкций SIMD для выполнения умножений, позволяющая умножать четыре 32-битных числа с плавающей точкой параллельно:


#include "textflag.h"

// func multiply(data []V4, m M4)
//
// компоновка памяти стека относительно FP
//  +0 слайс data, ptr
//  +8 слайс data, len
// +16 слайс data, cap
// +24 m[0]  | m[1]
// +32 m[2]  | m[3]
// +40 m[4]  | m[5]
// +48 m[6]  | m[7]
// +56 m[8]  | m[9]
// +64 m[10] | m[11]
// +72 m[12] | m[13]
// +80 m[14] | m[15]

TEXT ·multiply(SB),NOSPLIT,$0
  // data ptr
  MOVQ data+0(FP), CX
  // data len
  MOVQ data+8(FP), SI
  // указатель на data
  MOVQ $0, AX
  // ранний возврат, если нулевая длина
  CMPQ AX, SI
  JE END
  // загрузка матрицы в 128-битные xmm-регистры (https://en.wikipedia.org/wiki/Streaming_SIMD_Extensions#Registers)
  // загрузка [m[0], m[1], m[2], m[3]] в xmm0
  MOVUPS m+24(FP), X0
  // загрузка [m[4], m[5], m[6], m[7]] в xmm1
  MOVUPS m+40(FP), X1
  // загрузка [m[8], m[9], m[10], m[11]] в xmm2
  MOVUPS m+56(FP), X2
  // загрузка [m[12], m[13], m[14], m[15]] в xmm3
  MOVUPS m+72(FP), X3
LOOP:
  // загрузка каждого компонента вектора в регистры xmm
  // загрузка data[i][0] (x) в xmm4
  MOVSS    0(CX), X4
  // загрузка data[i][1] (y) в xmm5
  MOVSS    4(CX), X5
  // загрузка data[i][2] (z) в xmm6
  MOVSS    8(CX), X6
  // загрузка data[i][3] (w) в xmm7
  MOVSS    12(CX), X7
  // копирование каждого компонента матрицы в регистры
  // [0, 0, 0, x] => [x, x, x, x]
  SHUFPS $0, X4, X4
  // [0, 0, 0, y] => [y, y, y, y]
  SHUFPS $0, X5, X5
  // [0, 0, 0, z] => [z, z, z, z]
  SHUFPS $0, X6, X6
  // [0, 0, 0, w] => [w, w, w, w]
  SHUFPS $0, X7, X7
  // xmm4 = [m[0], m[1], m[2], m[3]] .* [x, x, x, x]
  MULPS X0, X4
  // xmm5 = [m[4], m[5], m[6], m[7]] .* [y, y, y, y]
  MULPS X1, X5
  // xmm6 = [m[8], m[9], m[10], m[11]] .* [z, z, z, z]
  MULPS X2, X6
  // xmm7 = [m[12], m[13], m[14], m[15]] .* [w, w, w, w]
  MULPS X3, X7
  // xmm4 = xmm4 + xmm5
  ADDPS X5, X4
  // xmm4 = xmm4 + xmm6
  ADDPS X6, X4
  // xmm4 = xmm4 + xmm7
  ADDPS X7, X4
  // data[i] = xmm4
  MOVNTPS X4, 0(CX)
  // data++
  ADDQ $16, CX
  // i++
  INCQ AX
  // if i >= len(data) break
  CMPQ AX, SI
  JLT LOOP
END:
  // так как используем невременные (Non-Temporal) SSE-инструкции (MOVNTPS)
  // убедимся, что все записи видны перед выходом из функции (с помощью SFENCE)
  SFENCE
  RET

Эта реализация выполняется за 18 мс, поэтому она довольно близка к скорости копирования памяти. Лучшая оптимизация может заключаться в том, чтобы запускать такие вещи на графическом процессоре, а не на процессоре, потому что графический процессор действительно хорош в этом.


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


Программа Время, мс Ускорение
Оригинальная, zip 140 1x
Инлайн-версия, zip 69 2x
Ассемблерная, zip 43 3x
Ассемблерная с SIMD, zip 17 8x
Копирование памяти, zip 15 9x

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


Замечания по реализации


Автор разработал ассемблерные части в основном на C и 64-битном ассемблере с использованием XCode, а затем портировал их в формат Go. У XCode хороший отладчик, который позволяет проверять значения регистров процессора во время работы программы. Если включить ассемблерный файл .s в проект XCode, IDE соберёт его и слинкует его с нужным исполняемым файлом.


Автор использовал справочник по набору инструкций Intel x64 и руководство Intel Intrinsics, чтобы выяснить, какие инструкции нужно использовать. Преобразование на язык ассемблера Go не всегда простое, но многие 64-битные ассембленые инструкции указаны в x86/anames.go, а если нет, они могут быть закодированы напрямую с двоичным представлением.




Примечание переводчика


В оригинале статьи в ассемблерные файлы не включён заголовок #include "textflag.h", из-за чего при компиляции выдаётся ошибка illegal or missing addressing mode for symbol NOSPLIT.
Поэтому выложил на GitHub Gist исправленные версии. Для запуска распаковываем архив, выполняем команды: chmod +x run.sh && ./run.sh.


Запускать код с ассемблером можно лишь собрав бинарник с помощью go build, иначе компилятор ругнётся на пустое тело функции.


К сожалению, в интернете действительно мало информации по ассемблеру в Go. Советую почитать статью на Хабре про архитектуру ассемблера Go.


Ещё один способ использовать ассемблерные вставки в Go


Как известно, Go поддерживает использование кода, написанного на C. Поэтому, ничего не мешает сделать так:


sqrt.h:


double sqrt(double x) {
  __asm__ ("fsqrt" : "+t" (x));
  return x;
}

sqrt.go:


package main

/*
#include "sqrt.h"
*/
import "C"
import "fmt"

func main() {
  fmt.Printf("%f\n", C.sqrt(C.double(16)))
}

И запустить:


$ go run sqrt.go 
4.000000

Ассемблер — это весело!

Поделиться публикацией
Похожие публикации
Ой, у вас баннер убежал!

Ну. И что?
Реклама
Комментарии 12
  • +4

    Не каждый день такое увидишь

    • +1
      Ассемблер — это весело до тех пор, пока код не в продакшене. Go не для этого. Что, если у вас специфичный набор команд, а процессор древний? Использование Си еще куда ни шло, но asm это беда.
      За перевод спасибо!
      • 0
        Вы правы.

        Но бывают исключения:
        Во-первых, много частей Go написано на Ассемблере (см. Гитхаб: math, crypto, hash).
        Во-вторых, есть примеры на проде у обычных компаний. Например, homm написал цикл статей, где они переделывали резайз изображений, там были ассемблерные вставки (введение, ..., использование SIMD).
        • 0

          Справедливости ради, не ассемблерные вставки а интринсики — специальные архитеркутрно-зависимые функции в Си, почти всегда 1-в-1 транслируемые в конкретные инструкции процессора.

        • 0
          Си дёргать тоже проблема — затратно, особенно когда много данных ходит. Если изначально известно что будет хай-лоад, имхо, лучше сразу на Си или Спп писать :)
          • 0
            Да, я потестил. Получается медленней, чем писать на Гошном асме. Но это логично, поэтому не стал даже упоминать об этом в статье
          • +2
            Вот пишите вы ассемблер, отлаживаете, запускаете… А потом оказывается, что запускать его нужно будет на ARM-е каком-нибудь…
            • 0
              И поэтому, всё исходит из задачи. Если мы знаем что код будет написан строго под скайлейк то и пишем асм заточенный для скайлейк, в случаен если таргет имеет широкий спектр то другое дело. И к тому же define никто не отменял. Если вы собираете под старый проц — одни инструкции, под новый — новые молодёжные.
          • +1
            Да, это наверное неплохая идея — написать сначала на нормальном асме, отладить, и только потом портировать на гошный :). Потому что и синтаксис и стиль у гошного ассемблера очень странный.
            • 0
              Как известно, Go поддерживает использование кода, написанного на C. Поэтому, ничего не мешает сделать так:

              А Go заинлайнит функцию sqrt? Ещё 2 года назад не умел.
              Пример. Здесь правда тест функции которая в .s файле, да и за правильность работы не могу отвечать.
              • +3
                Какая реализация может быть быстрее? Эталоном будет копирование памяти, которое занимает около 14 мс.

                Почему копирование, если на выходе у вас пишется в 4 раза меньше данных, чем читается? Эталоном будет скорость чтения 4х байт данных и записи х байт.


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

                Если скорость выполнения на одном ядре приближается к скорости копирования в памяти, то графический процессор вам точно никак не поможет, потому что скорость шины PCIe 3.0 16x в 2-3 раза ниже скорости памяти.

                • –1
                  Ну да, статья явно не для тех, кто пишет приложения с уймой «кнопочек»… А по поводу — «окажется, что надо запустить на ARM...», расскажите это разработчикам FFMPEG и подобных проектов. Хотя я очень сомневаюсь, что Go подходит для подобного.

                  Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                  Самое читаемое