Pull to refresh

Register-based calling convention, GO?

Reading time10 min
Views6.6K

Пока GO-сообщество томится в ожидании версии 1.18 и обещанных в ней дженериков язык продолжает развиваться и в других направлениях. В вышедшей недавно версии 1.17 хватает интересных нововведений. Среди них есть одно, давно ожидаемое, изменение — новый calling convention. Что же это такое, в чем отличие от старого соглашения о вызовах, какое влияние будет оказано на прикладную GO разработку? Постараемся разобраться в этой статье.
image


System Requirements


Описанные ниже вещи требуют от читателя, помимо знания языка GO, дополнительных познаний, таких как:


  • общее понимание работы с памятью в GO (stack, heap, memory-layout и тп)
  • базовое представление о GO'шном псевдо-ассемблере

О calling convention в GO


Calling convention — описание технических особенностей вызова подпрограмм (подробнее в википедии), является частью application binary interface (ABI).


Различных calling convention's довольно много, поэтому, для удобства, условно разделим все возможные ABI на stack-based и register-based.


Stack-based ABI


В GO, до версии 1.16 включительно, используется stack-based ABI.


Допустим, у нас есть функция F, которая вызывает функцию G, тогда, чтобы F могла передать аргументы в G ей необходимо положить эти аргументы на стек и затем вызвать инструкцию CALL. Функция G может получить аргументы зная SP (stack pointer) — указатель на вершину стека, и смещение от этого указателя. Чтобы функция G могла вернуть результат свой работы в функцию F она так же должна положить возвращаемое значение на стек, а функция F может это значение прочитать. Кроме того, F перед вызовом G поместит в стек адрес возврата. Это имеет значение при расчете смещений от SP.


Кстати, как и многое, в таком виде ABI пришел в GO из операционной системы Plan 9. Вот неплохой доклад на эту тему.


Рассмотрим этот ABI на примере простейшей функции суммирования двух int'ов:
image


64х битные аргументы a и b помещаются в стек по смещениям 8 и 16 байт от вершины стека, по смещению 24 байта помещается результат сложения (переменная r).


Register-based ABI


В GO 1.17 для архитектуры AMD64 stack-based ABI был изменен на register-based. Как можно догадаться, теперь, чтобы F передать данные в G, необходимо предварительно разместить эти данные в специальных регистрах. То же самое и чтобы G вернуть результат свой работы в F — необходимо разместить данные в регистрах. На искусственных бенчмарках доступ к аргументам в регистрах будет на 40% быстрее, чем к аргументам на стеке.


Рассмотрим тот же пример:


image


Аргументы a и b теперь помещаются в виртуальные регистры AX и BX (далее просто регистры AX, BX и т.д.) соответственно. Важно заметить, что для возврата значения переиспользуется регистр AX.


Ну что ж, в общих чертах — разобрались. А теперь давайте посмотрим на код.


Talk is cheap. Show me the code.


Все так же будем рассматривать функцию sum, напишем к ней бенчмарк:


//go:noinline
func sum(x, y int) int {
    return x + y
}

func BenchmarkSum(b *testing.B) {
    x, y := 2, 1
    for i := 0; i < b.N; i++ {
        x = sum(x, y)
        y++
    }
}

Результат запуска на версиях GO 1.16 и 1.17 соответственно:


BenchmarkSum (1.16)
BenchmarkSum-4      459043641            2.613 ns/op

BenchmarkSum (1.17)
BenchmarkSum-4      756435049            1.530 ns/op

Прирост как раз около 40%.


GO 1.16


Рассмотрим assembler который сгенерировал компилятор версии 1.16:


asm (для удобства слегка отредактирован)
        0x0000 00000 (bench.go:4)   TEXT    "".sum(SB), NOSPLIT|ABIInternal, $0-24
        ...
        0x0000 00000 (bench.go:5)   MOVQ    "".y+16(SP), AX
        0x0005 00005 (bench.go:5)   MOVQ    "".x+8(SP), CX
        0x000a 00010 (bench.go:5)   ADDQ    CX, AX
        0x000d 00013 (bench.go:5)   MOVQ    AX, "".~r2+24(SP)
        0x0012 00018 (bench.go:5)   RET

    0x0000 00000 (bench.go:8)   TEXT    "".BenchmarkSum(SB), ABIInternal, $40-8
        ...
        // начало цикла бенчмарка
        ...
        0x0030 00048 (bench.go:11)  MOVQ    CX, "".y+24(SP)
        0x0035 00053 (bench.go:11)  MOVQ    DX, (SP)
        0x0039 00057 (bench.go:11)  MOVQ    CX, 8(SP)
        ...
        0x0040 00064 (bench.go:11)  CALL    "".sum(SB)
        0x004d 00077 (bench.go:11)  MOVQ    16(SP), DX
        0x0052 00082 (bench.go:12)  MOVQ    "".y+24(SP), CX
        0x0057 00087 (bench.go:12)  INCQ    CX
        ...
        0x0066 00102 (bench.go:10)  JGT     43

Начнем с функции sum:


  • заголовок функции


         0x0000 00000 (bench.go:4)   TEXT    "".sum(SB), NOSPLIT|ABIInternal, $0-24

    Это заголовок нашей функции. Объявляется символ sum, добавляется некоторая мета информация (нам не так важно, какая именно, но подробнее можно прочитать здесь).


  • получение аргументов функции


        0x0000 00000 (bench.go:5)   MOVQ    "".y+16(SP), AX
        0x0005 00005 (bench.go:5)   MOVQ    "".x+8(SP), CX
        0x000a 00010 (bench.go:5)   ADDQ    CX, AX

    Тут как раз вступает в дело stack-based calling convention. Функция sum полагает, что вызывающий сделал stack grow и разложил значения по местам. Аргументы достаются со стека, используя соответствующие смещения, и помещаются в регистры AX и CX. Значения в этих регистрах складываются. Результат сложения находится в регистре AX.


  • выход из функции


        0x000d 00013 (bench.go:5)   MOVQ    AX, "".~r2+24(SP)
        0x0012 00018 (bench.go:5)   RET

    Значение из AX помещается в стек по смещению 24 байта, затем происходит возврат в вызывающую функцию.



Теперь к функции — бенчмарку:


  • подготовка к вызову sum


        0x0030 00048 (bench.go:11)  MOVQ    CX, "".y+24(SP)
        0x0035 00053 (bench.go:11)  MOVQ    DX, (SP)
        0x0039 00057 (bench.go:11)  MOVQ    CX, 8(SP)

    Так как в цикле мы изменяем локальную переменную y — ее значение сохраняется в стеке, в конце итерации оно будет восстановлено и инкрементировано. X и y помещается в стек по тем смещениям (offset(x) = 0 и offset(y) = 8), где их ожидает увидеть функция sum. Напомню, что в стек так же будет помещен адрес возврата, поэтому для sum offset(y) = 16 а offset(x) = 8.


  • вызов sum


        0x0040 00064 (bench.go:11)  CALL    "".sum(SB)
        0x004d 00077 (bench.go:11)  MOVQ    16(SP), DX
        0x0052 00082 (bench.go:12)  MOVQ    "".y+24(SP), CX
        0x0057 00087 (bench.go:12)  INCQ    CX

    В x (регистр DX) помещается результат работы sum, который находится по адресу SP + 16. Затем восстанавливается значение y, которое было предварительно "засейвленно" в стеке, чтобы инкрементировать его.


  • наконец, переход к следующей итерации бенчмарка


    0x0066 00102 (bench.go:10)  JGT     43


GO 1.17


asm (для удобства слегка отредактированный)
        0x0000 00000 (bench.go:4)   TEXT    "".sum(SB), NOSPLIT|ABIInternal, $0-16
        0x0000 00000 (bench.go:5)   ADDQ    BX, AX
        0x0003 00003 (bench.go:5)   RET

        ...

    0x0000 00000 (bench.go:8)   TEXT    "".BenchmarkSum(SB), ABIInternal, $40-8
        ...
        // начало цикла бенчмарка
        ...
        0x0027 00039 (bench.go:11)  MOVQ    CX, "".y+16(SP)
        0x002c 00044 (bench.go:11)  MOVQ    DX, AX
        0x002f 00047 (bench.go:11)  MOVQ    CX, BX
        ...
        0x0032 00050 (bench.go:11)  CALL    "".sum(SB)
        0x003f 00063 (bench.go:12)  MOVQ    "".y+16(SP), DX
        0x0044 00068 (bench.go:12)  INCQ    DX
        0x0047 00071 (bench.go:10)  MOVQ    AX, BX
        0x004a 00074 (bench.go:10)  MOVQ    CX, AX
        0x004d 00077 (bench.go:11)  MOVQ    DX, CX
        0x0050 00080 (bench.go:11)  MOVQ    BX, DX
        ...
        0x0066 00102 (bench.go:10)  JGT     43

Рассмотрим изменения, функция sum:


        0x0000 00000 (bench.go:4)   TEXT    "".sum(SB), NOSPLIT|ABIInternal, $0-16
        0x0000 00000 (bench.go:5)   ADDQ    BX, AX
        0x0003 00003 (bench.go:5)   RET

Функция заметно похудела, теперь в соответствие с новым ABI sum ожидает значения x и y в регистрах AX и BX соответственно. Результат работы функции просто остается в регистре AX.


Разберем функцию бенчмарка:


  • подготовка


        0x0027 00039 (bench.go:11)  MOVQ    CX, "".y+16(SP)
        0x002c 00044 (bench.go:11)  MOVQ    DX, AX
        0x002f 00047 (bench.go:11)  MOVQ    CX, BX

    Значение y все также сохраняется в стек для последующего восстановления и инкремента. А вот для передачи x и y в функцию sum вместо стека используются регистры AX и BX соответственно.


  • вызов функции


        0x0032 00050 (bench.go:11)  CALL    "".sum(SB)
        0x003f 00063 (bench.go:12)  MOVQ    "".y+16(SP), DX
        0x0044 00068 (bench.go:12)  INCQ    DX
        0x0047 00071 (bench.go:10)  MOVQ    AX, BX
        0x004a 00074 (bench.go:10)  MOVQ    CX, AX
        0x004d 00077 (bench.go:11)  MOVQ    DX, CX
        0x0050 00080 (bench.go:11)  MOVQ    BX, DX

    Вызов sum. Значение y восстанавливается из стека, инкрементируется и помещается в регистр CX. Затем немного mov магии (финальные значения x и y помещаются в регистры DX и CX) и можно переходить к следующей итерации.



Немного подытожим


Как видно из примеров, при переходе stack-based ABI -> register-based ABI сократилось до минимума копирование данных из регистровой памяти в основную и обратно. Это оказывает положительное влияние на производительность. Конечно, на искусственном примере мы видим прирост в 40%. В реальности разработчики языка пишут о, в среднем, +5% к производительности и -2% к размеру бинарника.


Обратная совместимость


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


Разработчики языка заранее подумали о том, что calling convention может быть изменен в будущих версиях GO. Так появился multiple ABI mechanism. Суть такова: компилятор GO может работать с несколькими ABI одновременно. В настоящий момент таких ABI два:


  • ABIInternal — собственно внутренний ABI которому следует компилятор при генерации ассемблера. Он не обязан быть хорошо документированным, может меняться от версии к версии и вообще не дело разработчика в нем копаться — он же internal (тем не менее именно в нем мы и разбираемся).


  • ABI0 — соглашения которым должен следовать разработчик при написании inline ассемблера. Он документирован несколько лучше. ABI0 — использует stack-based calling convention. Компилятор может генерировать wraper'ы над ABI0 функциями чтобы они могли вызываться из ABI Internal кода.



Вот пример нашей функции sum написанной руками:


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

В соответствии с ABI0 я использую псевдорегистр FP, который является указателем на первый аргумент на стеке, можно получить любой аргумент используя смещение (это, так сказать, удобная абстракция для перемещения между аргументами функции).


Компилятор, получив такой код, должен генерировать обертку вокруг вызова нашей функции в соответствии с текущим Internal ABI. Таким образом, например, разработчикам GO не надо переписывать пакеты math или crypto (которые активно используют ассемблер) после изменения соглашения о вызовах. Посмотреть какую конкретно обертку генерирует компилятор в GO'шном псевдо-ассемблере нельзя (ну или я не нашел как), но можно посмотреть дизассемблировав бинарник (objdump вам в помощь).


Тем не менее существует некоторый unsafe код, который все таки сломается при миграции:


  • Ассемблерный код использующий замыкания. Просто потому что такие трюки никак не документированы.
  • Код применяющий арифметику указателей (через unsafe.Pointer) к аргументам функций.
    Очевидно, что теперь мы не можем взять смещение от n-ного аргумента и попасть в какое либо место в стеке — хотя бы потому что n-го аргумента может в стеке и не быть.

сработает в 1.16 но не в 1.17
func sum(a, b int) int  {
    ptrA := unsafe.Pointer(&a)

    intPtrA := (*int)(ptrA)
    intPtrB := (*int)(unsafe.Pointer(uintptr(ptrA)+8))

    return *intPtrA + *intPtrB
}

We go deeper


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


Как мы уже выяснили, в новом ABI вызов функции передает аргументы и результат через регистры или через стек. Передача через регистры в приоритете. Количество регистров определяется конечной архитектурой, если использовали все возможные регистры — начинаем использовать стек. Каждый аргумент передается через один или несколько регистров в зависимости от типа. Например, для передачи слайса нам понадобятся 3 целочисленных регистра (один для указателя на начало массива, один для длины слайса, и один для capacity), для передачи интерфейса — 2 регистра (указатель на нижележащий тип и указатель на данные). Кроме того, регистры могут переиспользоваться для передачи возвращаемого значения функции, тогда как при использовании стека нужно выделить отдельное пространство под аргументы и возвращаемые значения.


Формально алгоритм register-based calling convention выглядит так:


  1. Пусть имеется функция F. NI и NFP длины последовательностей регистров для целых чисел и чисел с плавающей запятой. I и FP индексы "свободных" регистров. S — стек.
  2. Помещаем каждый аргумент F в I или в FP или в S.
  3. Выравниваем S.
  4. Обнуляем I и FP.
  5. Помещаем каждое возвращаемое значение F в I или в FP или в S.
  6. Выравниваем S.
  7. Резервируем в S место для всех аргументов передаваемых через регистры (необходимо для работы механизма stack growth).
  8. И еще раз выравниваем S.

Алгоритм размещения аргумента или возвращаемого значения V в регистр или стек:


  1. Запоминаем I и FP.
  2. Пробуем поместить V в регистр.
  3. Если не получилось — помещаем V в стек S, возвращаем значения I и FP к тем, что были на шаге 1.

Рассмотрим шаг 2. Пусть T тип значения V, определим возможность присвоение V регистрам:


  1. Если I > NI или FP > NFP — присвоение не возможно.
  2. Если T базовый (целочисленный, логический, число с плавающей запятой) присваиваем V в регистр I или FP. Инкрементируем I или FP.
  3. Если T указатель, функция, map или канал — присваиваем V в I регистр. Инкрементируем I.
  4. Если T слайс, строка или интерфейс — понадобится несколько I (помним про внутреннее устройство слайсов и интерфейсов). Увеличиваем I на количество заполненных регистров.
  5. Если T структура — рассматриваем каждое поле структуры как отдельное V, рекурсивно возращаемся в начало алгоритма.
  6. Если T массив размерностью 0 — ничего не делаем.
  7. Если T массив размерностью 1 — рассматриваем этот элемент как отдельное V, рекурсивно возращаемся в начало алгоритма.
  8. Если T массив размерностью > 1 — присвоение не возможно.

Таким образом, мы будем использовать регистры почти всегда (когда есть свободные), за исключением передачи массивов. Почему? Для итерации по массиву необходимо использовать смещение от первого элемента, а в случае размещения элементов в регистрах — использовать смещение не представляется возможным.


Важное свойство этого алгоритма — при 0 регистров в системе он фактически описывает ABI0 — stack-based ABI. Это упрощает переход к ABI на основе регистров и помогает компилятору генерировать ABI wraper'ы.


Ну что ж, теперь, когда мы подробно разобрались в новом ABI, давайте попробуем сделать некоторые практические выводы.


Передача массивов как аргумент функции


Как было сказано ранее — при передачи аргументов завернутых в массив данные будут размещены в стеке, проверим, имеет ли это практический эффект. Вот две похожие функции:


//go:noinline
func arraySum(arr [5]int) int {
    return arr[0] + arr[1] + arr[2] + arr[3] + arr[4]
}

//go:noinline
func splitSum(a, b, c, d, e int) int {
    return a + b + c + d + e
}

Результаты бенчмарков:


BenchmarkArraySum
BenchmarkArraySum-4   376214908          3.135 ns/op

BenchmarkSplitSum
BenchmarkSplitSum-4     594780789            1.974 ns/op

И снова разница около 40%.


Размер сигнатур функций


Количество регистров, которые можно использовать в новом ABI лимитировано архитектурой. В настоящий момент поддерживается только AMD64, и если говорить о конкретных числах — это 9 целочисленных регистров и 15 для чисел с плавающей запятой. Таким образом, можно сделать вывод — очень большое количество аргументов (или возвращаемых значений) будет негативно влиять на производительность. Не то чтобы такой вывод был сильно полезен на практике. С другой стороны, еще один повод следить за тем чтобы сигнатуры функций не распухали, почему бы и нет?


Кстати, разработчики GO задались вопросом: "как часто аргументы функций будут не влезать в регистры?". В качестве подопытного был использован один из kubernetes пакетов — в итоге, при 9 int'овых регистрах, 90% функций таки влезают. Так что большие сигнатуры не такая уж и редкость.


Internal — это не просто так


Наверное, главный вывод: любые оптимизации связанные с интроспекцией internal ABI, делаются на свой страх и риск. В версии GO X вы можете получить прирост к скорости, в версии GO Y вы можете его потерять, никаких гарантий не дается, ибо разработчики GO вольны менять внутренний ABI как им хочется.


Итог


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


Конечно, ABI — это не та тема, которую можно описать в одной статье. Я постарался вынести за скобки те подробности реализации нового ABI, которые относятся к нюансам работы с stack growing, goroutine scheduling и сборщиком мусора. Рассказывать о таких подробностях невозможно без погружения еще и в эти инструменты, что, конечно, далеко за рамками одной статьи.


Документы, использованные при написании материала:


Tags:
Hubs:
Total votes 20: ↑19 and ↓1+18
Comments5

Articles