Как заставить компилятор Go быстро превращать IPv4 в IPv6

· Программы
В netip.Addr есть метод Unmap(), но нет обратного To6(). Винсент Берна решил не ждать, пока сопровождающие Go добавят его, и попытался научить сам компилятор оптимизировать рекомендованный ими шаблон.

cover.svg

В пакете net/netip есть метод Unmap(), который извлекает IPv4-адрес из IPv4-совместимого IPv6-адреса: из ::ffff:203.0.113.10 он возвращает 203.0.113.10. А вот обратного метода — Map() или To6() — нет. Реализовать его тривиально, но сопровождающие Go его отвергли: по их мнению, пользователям следует писать netip.AddrFrom16(ip.As16()) и полагаться на оптимизатор. Проблема в том, что сегодня такой шаблон работает в восемь раз медленнее, чем мог бы работать нативный метод. Винсент Берна решил разобраться, можно ли научить компилятор сворачивать эту конструкцию.

Три пути

Автор рассматривает три варианта. Первый — добавить метод To6() прямо в стандартную библиотеку: внутри Addr хранит адрес как 128-битное значение с дополнительным полем z, и достаточно выставить нужное значение этого поля. Второй — внешняя функция-обёртка вокруг AddrFrom16(ip.As16()), как и предлагают сопровождающие. Третий — трюк с пакетом unsafe, подменяющий поле z через структуру с тем же расположением в памяти.

Замеры на Go 1.27.1 говорят сами за себя: встроенное решение стоит 0,88 нс на операцию, вариант через unsafe — столько же, а рекомендованная обёртка — 7,14 нс. Разбор ассемблерного вывода показывает почему: обёртка упаковывает адрес в массив из 16 байт, копирует его и распаковывает обратно — три лишних блока инструкций, которые человек легко сократил бы вручную, но компилятор на это не способен.

Молоток и отвёртка

Первая, «грубая» попытка — подменять шаблон на литерал структуры прямо на этапе построения промежуточного представления (noding). Это сработало: сгенерированный код стал минимальным. Но решение опирается на внутреннее устройство netip.Addr, уродует фазу, которая должна лишь честно переводить дерево в IR, и явно не будет принято.

Правильное место для такой оптимизации — фаза обобщённого SSA и проход memcombine. Автор подробно, шаг за шагом, разбирает, какие правила перезаписи нужны: сокращение загрузки сквозь копирование, проброс сохранённого значения в последующую загрузку и схлопывание двух перестановок байтов подряд. Большинство правил уже существуют в generic.rules, не хватало лишь варианта загрузки сквозь Move без смещения.

Не вовремя

И тут выясняется неприятность: правила перезаписи срабатывают раньше memcombine, в проходе late opt, когда байтовые операции ещё развёрнуты в шестнадцать отдельных загрузок и сохранений. В итоге часть байтов пробрасывается, а часть остаётся загрузками — и memcombine уже не может их объединить. Более того, правило, добавленное в Go 1.27, само вызывает регрессию: код становится хуже, чем в 1.26.8.

Быстрый обходной путь — запустить дополнительный ранний проход memcombine до late opt. После этого код снова сворачивается до идеального минимума, а бенчмарк подтверждает ускорение обёртки почти на 86%.

Берна честно признаёт: сопровождающие Go, скорее всего, отвергнут и этот вариант — из-за лишнего прохода. Поэтому он открыл issue #81994 с предложением добавить Addr.To6(): либо сама сложность затеи и регрессия в 1.27 убедят их, что простой метод лучше, либо подскажут другой путь. В любом случае, заключает автор, копание во внутренностях компилятора многому его научило.


Источник: Lobsters

Комментарии

Войдите, чтобы комментировать.

  • Пока нет комментариев.