Добавил arm64 в свою реализацию шифра “Кузнечик” (ГОСТ Р 34.12-2015) на ассемблере для Go. Новые функции – для платформы arm64 со 128-битной арифметикой. Попутно немного изменил состав библиотеки, так что название теперь новое (см. исходники ниже). Раньше ассемблер был только для amd64 с AVX, на остальных платформах – компилировалось с “заглушкой”, в которой операции шифра реализованы просто на конструкциях Go – ассемблер, понятно, быстрее.

ARM – весьма и весьма распространённая архитектура, а использование ассемблера, как обычно, даёт прирост производительности во много раз, если сравнивать с “обычным” Go-шным вариантом (тоже оптимизированным, конечно). Однако я всё проверял на Raspberry Pi4 (RPi4), потому что другого варианта ARM-аппаратуры под рукой сейчас не оказалось (а техники Apple у меня вообще нет). Зато на Linux/arm64 в RPi4 – прирост более чем в двадцать раз. Было: примерно мегабайт в секунду для “обычной” реализации. Стало: около 23 мегабайтов в секунду для ассемблерной. Думаю, что должно работать и на других ARM-платформах, и гораздо быстрее, чем на RPi4, но пока не проверял: если кто-то использует, и обнаружит существенные несовместимости (см. ниже) – пишите, пожалуйста, в комментарии здесь или пишите почтой. Вообще, написанный ARM-код ещё есть куда оптимизировать: например, в arm64 имеется “векторная” инструкция, реализующая XOR сразу для трёх входных регистров, что тут очень полезно, но эта инструкция недоступна в процессоре RPi4 (Cortex-A72/BCM2711).

Новая версия кода “Кузнечика”, так как отличается от предыдущей, называется GOSThp. Исходный код публикую ниже. Возможно, потом оформлю в виде полноценного модуля для Golang и выложу копию на Github, а возможно – не оформлю и не выложу. Но использовать в составе проекта на Go этот пакет нетрудно: достаточно скопировать директорию gosthp (см. ещё детали ниже). Работает со старыми версиями Go (проверено для 1.7).

Файлы:

gosthp.tar.gz – всё вместе в одном архиве.
SHA-256: b3f860f15d43814db2f2becfb4c72272de6d3b49f9b0dec70712438af0a6e085
(Update, 20/01/2024: архив перезагружен – изменено описание платформ в +build, даты в комментариях, и даты создания файлов; код – без изменений.)

Внутри архива:

cmd/
	| gostrun.go		- файл с примерами и тестами.
gosthp/	- это сама библиотека.
	| docipher_amd64.go	- объявления функций для amd64.
	| docipher_amd64.s	- код на ассемблере для amd64.
	| docipher_arm64.go	- объявления функций для arm64.
	| docipher_arm64.s	- код на ассемблере для arm64.
	| docipher.go		- универсальная реализация для прочих платформ (см. +build внутри: могут быть расхождения).
	| gosthp.go		- основной модуль.
go.mod
README

Основное дополнение – docipher_arm64.s, собственно, реализация шифра для архитектуры arm64/AArch64.

Как обычно, прилагается демонстрационный код – gostrun.go. Если развернуть упомянутый выше архив в директорию, то собрать gostrun можно так:

$ cd cmd
$ go build -o ~/my-builds/gostrun gostrun.go

Go самостоятельно выберет нужные файлы в соответствии с целевой платформой. Внутри исходников – много комментариев (англ.).
Саму библиотеку можно использовать в среде Go в составе других проектов простым копированием gosthp/, разместив её либо в директорию сборки, либо в общую директорию для пакетов.

Ассемблерная реализация для ARM от x86 отличается в некоторых деталях, не алгоритмических: во-первых, очевидно, другие инструкции; во-вторых, используется больше регистров и основные операции XOR собраны в общий блок; в-третьих, изменён способ вычисления смещений для обращения к таблице значений.

Screenshot

Как обычно, основная оптимизация – использование предвычисленной таблицы. На ассемблере написаны только низкоуровневые операции шифра, то есть, преобразование блока с таблицей и раундовыми ключами. Библиотека позволяет создать экземпляр объекта, который реализует интерфейсы для доступа к шифру. Таблица, ускоряющая операции, общая для всех экземпляров, эта таблица создаётся при инициализации модуля, а потом используется процедурами, реализующими шифр, только для чтения. Строго говоря, таблиц там несколько: есть “обратная” таблица для расшифрования и несколько вспомогательных (см. исходный код). Так как значения в таблицах не зависят от конкретного ключа, то их использование не требует повторного вычисления значений. В принципе, можно было бы просто записать эти таблицы в код в виде статического массива, так часто делают, но можно и генерировать программно, что делает код менее громоздким, а на производительность никак не влияет.

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

Что касается совместимости. И amd64, и arm64 – достаточно широкие понятия. В этой реализации шифра “Кузнечик” используются регистры и команды для разрядности 128-бит, которая соответствует разрядности шифра – в этом, собственно, смысл. Уже использование команд “длинной” арифметики из SSE4.1 для amd64 означает, что некоторые старые процессоры, полностью попадающие в amd64, не смогут, тем не менее, исполнять скомпилированный код, поскольку не поддерживают нужных команд. То же самое относится и к arm64 (например, выше я уже упомянул, что не использую “трёхрегистровую” команду XOR по этой же причине). Данная библиотека поддержку команд не проверяет, так что, вообще говоря, это нужно отслеживать отдельно, используя механизмы детектирования свойств аппаратуры, и, соответственно, либо заменять вызываемые функции, либо выводить предупреждение пользователю.



Комментарии (9) »

Кстати, ещё немного про Go (Golang). Одна из самых странных особенностей Go – это использование комментариев для управления компиляцией. Например, вот такая директива:

// +build !amd64

– это “обычный” комментарий в исходном коде, но его читает препроцессор и использует указание +build для того, чтобы определить платформу (всё, что не amd64; скажем, так сделано у меня в исходниках ассемблерной реализации “Кузнечика”; современный вариант: “//go:build“). Идея снабдить комментарии двойным дном – довольно богатая, в лингвистическом, так сказать, смысле.

Формально, “// +build !amd64” – это, действительно, комментарий. То есть, здесь обозначено, что строка не относится к, собственно, исходному коду. Это верно. А то, что там потом будет копаться препроцессор – ну так, как бы, и в комментариях бывает написано, что “данная функция нужна только для 8-битной конфигурации, поэтому здесь вызов спрятан в комментарий”. Неявно предполагается, что роль препроцессора играет непосредственно разработчик, а в случае “+build” в Golang – просто этот процесс автоматизируется. Это, конечно, не какой-то там особенный и исключительный случай использования комментариев: вспомните что-нибудь типа “#!/usr/bin/perl”. Тем не менее, ситуация, когда содержание комментария непосредственно влияет на процесс сборки, всё же выглядит необычно.

Вместе с Golang идёт собственный (псевдо)ассемблер. Это очень удобно в специальных случаях. Например, я использую этот ассемблер для реализации криптографических примитивов (и не только: при всём качестве компиляторов, некоторые задачи поиска и обработки данных могут работать существенно быстрее, если самостоятельно реализовать их с упором на особенности конкретной аппаратуры). Так вот, ассемблер в Go тоже необычный. Там своеобразные синтаксис и семантика. Например, “перевёрнутое” отношение операндов: MOV R1, R2 – пишет из R1 в R2, то есть, слева направо. Это вопрос соглашения, бывает разный подход, но естественным вариантом всё же представляется наоборот – R2 в R1, справа налево.

Очень многие мнемоники команд заменены на собственные мнемоники go-ассемблера, причём, трансляция не всегда очевидная, результат замены отличается от платформы к платформе, а сама замена сопровождается неожиданными дополнительными ограничениями.

Например, на arm64 go-ассемблерное “ADD $8, RSP, R2” соответствует оригинальному “ADD X2, SP, #0x8”. RSP превращается в SP потому, что ассемблер Go использует псевдорегистры, среди которых есть SP (и не только), поэтому совпадающие по названию аппаратные регистры переименованы. Псевдо-SP – это, как бы, указатель стека, но с хитростями: псевдо-SP может иногда совпадать по значению с аппаратным SP, в частности, если на подходящей платформе указана нулевая длина “собственного” стека ассемблерной процедуры (но это детали). При этом, ссылки с использованием смещения от значения псевдорегистра SP должны (это прямо строгое требование Go) оформляться с символьными именами, вот так: len-8(SP), где “минус” это не совсем вычитание, а len – это даже не “синтаксический сахар”, но что-то вроде комментария наоборот, поскольку никакой языковой интерпретации len здесь не имеет, ничего из len не вычитается, да и вовсе не обязательно, чтобы эта строка совпадала с именем аргумента в определении прототипа функции или ещё с чем-то совпадала (или не совпадала). Но если не указать произвольный символьный префикс – компилятор выдаст ошибку. Этому, естественно, имеется историческое объяснение, которое называется Plan 9.

Отрицательные и положительные смещения для SP – тоже отдельная забавная история: если SP совпадает с аппаратным регистром по семантике, – что задаётся правилами определения процедуры и входа в процедуру, – то, на некоторых платформах, можно использовать привычные положительные смещения, вот так: len+8(SP), однако где-то в документации написано (попробуйте найти), что “при ручном кодировании” необходимо использовать (только) “отрицательные смещения”. Да. Ну или наоборот, потому что лучшей документацией тут оказывается исходный код – как самого Golang, так и генерируемый компилятором.



Комментировать »