Еще одна история про бинарную трансляцию

Еще одна история про бинарную трансляцию

@tsafin

Так получилось, что воскресным вечером 7 апреля дискуссия в @procxx плавно перешла от HPC# компилятора к JIT, и от него к бинарной трансляции, где я не преминул заметить, что

_В одной из прошлых жизней, участвовал я в одном таком бабаяновском проекте, где на входе был x86 а на выходе - хмм, некий широкий, многострендовый код. Короче, не работает такая хрень, даже если закрыть глаза на энергию затраченную на JIT, обычно не хватает ширины памяти_

Люди, наиболее близкие к Java тусовке приняли это близко к сердцу, и потребовали объяснений. Давайте попытаемся...

[Сразу оговорюсь, что в том проекте Б.А.Бабаяна я был простым Си++ программистом, членом команды памяти в симуляторе, и не принимал архитектурных решений.]

Итак, мы говорим, про некий проект процессора, реализующего парадигму hardware-software co-design, назовем его под V, который внутри не является развитием x86 процессоров, но снаружи всячески пытается таковым выглядеть. Более-менее современным примером похожей реализации будет Transmeta Дейва Дитцела (купленной Интелом), в Software Machines (также купленной недавно Интелом), x86 compatibility layer в Itanium (созданной Интелом сначала в аппаратной форме, а потом перенесенной в софт), или такой же уровень совместимости с x86 в Elbrus (собственно реализованный примерно теми же людьми, что позже делали бинарную трансляцию в процессоре V в Интеле, или БТ в SoftwareMachines). Еще заметим, что из перечисленных, некоторые процессоры могут показывать свою внутреннюю архитектуру вовне (как Elbrus) и для них возможна нативная трансляция, а некоторые полностью таковую скрывали (Transmeta или наш V).

Предполагалось, что при всех прочих, мы бы брали x86 chipset со всеми его заморочками и стартовали систему "без глобальных" изменений. При начальной загрузке мы могли применять интерпретацию x86 кода, при загрузке в гипервизор у нас уже использовался бы JIT (очень простой и неэффективный) и потом, при запуске приложений, через систему обратной связи (здесь как раз шла смычка hardware-software) процессор сообщал транслятору о горячих трассах и происходила перекомпиляция (еще раз) со всеми включенными, специфичными для приложения, оптимизациями. Насколько я понимаю, примерно также происходила работа в Transmeta и вот эти несколько уровней трансляции выстраивались в иерархию multiple gears:

  • interpretor
  • just-in-time translator
  • optimized compiler

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

У процессора V, также как и у Трансметы, Итаниума, и Эльбруса, был один первородный изъян - они оперировали очень широкими словами - помните VLIW, EPIC? Такой тип архитектур работает хорошо, только если закрыть глаза на трафик в память. А дешевого трафика в память не бывает - при прочих равных, у процессора на x86 и V (да и на современном ARM) будут похожие параметры bandwidth в память. И если твоя архитектура имеет очень рыхлые средние параметры команд (что-то вроде, 0.5 V длинной команды на линию кеша - против 4 x86 команд на ту же линию), то и проигрывать в такте выкачки команд ты будешь также. Твои программы будут во столько же раз толще, и оказывать во столько же большую нагрузку на память.

Вторым первородным изъяном таких систем (и тут мы уже возвращаемся к первоначальному вопросу) был негласный тезис, что накладные расходы на JIT/оптимизатор пренебрежимо малы. Что гипервизор с трансляторами не ест электричества, не ест процессор. А это нихрена не так (и даже вроде не 10% а сильно больше, в зависимости от того эффекта в оптимизациях, который вы хотите получить). Этот overhead можно было бы нивелировать, если реализовать персистентность оттранслированного целевого кода, по примеру Alpha FX!32 (хотя в нашем случае это не было так элегантно как в FX!32, когда бинарно оттранслированный код просто сохранялся в те же EXE-шники, и вроде бы предполагалось некое внешнее хранилище, но я тут могу ошибаться). В итоге получается: ты платишь процессорным временем и энергией, или меньшим процессорным временем, но стореджем.

Уходя немного в сторону от бинарной-трансляции в процессорах, к just-in-time трансляторам общего назначения заметим, что сторонники JIT в современных управляемых языках утверждают, что тот, в отличие от ahead-of-time (AOT) транслятора, имеет информацию о целевом процессоре и может сделать что-то лучше. Но это была бы какая-то странная игра в поддавки. Назовите мне хоть одну причину, почему программу с AOT нельзя при установке или на билд-машине откомпилировать с тем же самым знанием о целевом процессоре? Допустим у вас свой собственный маленький датацентр, cколько видов процессора у вас там работает? Обычно, в хорошо управляемом ДЦ, 1-3 (скажем Haswell, Broadwell, и немного Skylake). Скажите мне хоть одну причину почему я должен выбирать generic процессор, а не целевой HSW, и векторизовать уже с AVX2? (Еще и не факт, что JIT система умеет AVX2, AVX512, это обычно скрыто от программиста на managed языках) Также утверждается, что JIT может "пессимизировать" не такой горячий код, но зачем это если в AOT сценарии мы можем потратить немного больше времени, чем JIT может себе позволить, и соптимизировать даже холодный код? Там, где branch-predictor в процессоре не справится, мы можем собрать информацию о карте исполнения и перекомпилировать с PGO. В теории, PGO сценарий _может_ работать прозрачно и в JIT системе (и многие в это верят), но опять же, из-за изначальных ограничений у JIT, тот не может тратить на оптимизации больше заданного времени, и всегда будет проигрывать AOT на целевой машине с фидбеком (PGO).

Уверен (надеюсь), что за то время, как система JIT развивается в современных managed средах (JVM, .NET), а это что-то за более чем 20 лет развития, они наконец-то научились переиспользовать результаты трансляции сделанной ранее. Но меня по-прежнему смущает вот это всё: все входные параметры инвариантны, железка та же, процессор тот же, ОС та же, какого хрена каждый раз, _каждый долбанный раз_, когда запускается программа, транслировать её в целевой x86 или ARM? Надеясь получить что - другой процессор, другие модули включенные на исполнении, почему это не сделать один раз при скачивании/установке на данный компьютер/телефон? (Почему, например, Google Play, зная целевой процессор моего телефона, сразу не компилирует и не оптимизирует все скачиваемые программы и не отдает их нативно? Он думает телефон это сделает быстрее/лучше? Работая от батареи то)

И вообще, когда все параметры формулы (программы/процессор/ОС) инвариантны, зачем делать что-то несколько (много) раз и не сделать это 1 раз? Через AOT