Большое интервью с Клиффом Кликом — отцом JIT-компиляции в Java
Клифф Клик — CTO компании Cratus (IoT сенсоры для улучшения процессов), основатель и сооснователь нескольких стартапов (включая Rocket Realtime School, Neurensic и H2O.ai) с несколькими успешными экзитами. Клифф написал свой первый компилятор в 15 лет (Pascal для TRS Z-80)! Наиболее известен за работу над С2 в Java (the Sea of Nodes IR). Этот компилятор показал миру, что JIT может производить качественный код, что стало одним из факторов становления Java как одной из основных современных программных платформ. Потом Клифф помог компании Azul Systems построить 864-ядерный мейнфрейм с софтом на чистой Java, который поддерживал паузы GC на 500-гигабайтной куче в пределах 10 миллисекунд. Вообще, Клифф успел поработать над всеми аспектами JVM.
Этот хабрапост — большое интервью с Клиффом. Мы поговорим на следующие темы:
- Переход к низкоуровневым оптимизациям
- Как делать большой рефакторинг
- Модель стоимости
- Обучение низкоуровневым оптимизациям
- Практические примеры улучшения производительности
- Зачем создавать свой язык программирования
- Карьера перформанс-инженера
- Технические челленжи
- Немного про аллокацию регистров и многоядерность
- Самый большой челленж в жизни
Интервью ведут:
- Андрей Сатарин из Amazon Web Services. В своей карьере успел поработать в совершенно разных проектах: тестировал распределенную базу данных NewSQL в Яндексе, систему облачного детектирования в Лаборатории Касперского, многопользовательскую игру в Mail.ru и сервис расчёта валютных цен в Deutsche Bank. Интересуется тестированием крупномасштабных backend- и распределённых систем.
- Владимир Ситников из Netcracker. Десять лет работает над производительностью и масштабируемостью NetCracker OS — ПО, используемого операторами связи для автоматизации процессов управления сетью и сетевым оборудованием. Увлекается вопросами производительности Java и Oracle Database. Автор более десятка улучшений производительности в официальном PostgreSQL JDBC-драйвере.
Андрей: Вы — известный человек в мире JIT-компиляции, в Java и работе над перформансом в целом, верно?
Клифф: Всё так!
Андрей: Давайте начнём с общих вопросов о работе над производительностью. Что вы думаете о выборе между высокоуровневыми и низкоуровневыми оптимизациями вроде работы на уровне CPU?
Клифф: Да тут всё просто. Самый быстрый код — тот, который никогда не запускается. Поэтому всегда нужно начинать с высокого уровня, работать над алгоритмами. Более хорошая О-нотация побьет более плохую О-нотацию, разве что вмешаются какие-то достаточно большие константы. Низкоуровневые вещи идут самыми последними. Обычно, если вы оптимизировали весь остальной стек достаточно хорошо, и все еще осталось нечто интересное — вот это и есть низкий уровень. Но как начать с высокого уровня? Как узнать, что проделано достаточно работы на высоком уровне? Ну… никак. Нет готовых рецептов. Нужно разобраться в проблеме, решить, что собираешься сделать (чтобы не делать ненужных в дальнейшем шагов) и тогда уже можно расчехлять профайлер, который может сказать что-нибудь полезное. В какой-то момент вы сами понимаете, что избавились от ненужных вещей и пришла пора заняться тонкой настройкой низкого уровня. Это совершенно точно является особым видом искусства. Куча людей делает ненужные вещи, но двигается так быстро, что заботиться о производительности им некогда. Но это до тех пор, пока вопрос не встает ребром. Обычно 99% времени никому не интересно, чем я занимаюсь, вплоть до момента, когда на критическом пути не встанет важная штука, до которой кому-то есть дело. И вот тут все начинают пилить тебя на тему «а почему оно с самого начала работало не идеально». В общем, всегда есть что улучшить в перформансе. Но 99% времени у тебя нет зацепок! Ты просто пытаешься заставить что-то работать и в ходе этого понимаешь, что является важным. Никогда нельзя заранее знать, что вот этот кусочек нужно делать идеальным, поэтому, по сути, приходится быть идеальным во всём. А это невозможно и ты так не делаешь. Всегда есть куча вещей на починку — и это совершенно нормально.
Андрей: Как вы работаете над перформансом? Это ведь сквозная проблема. Например, приходилось ли вам работать над проблемами, возникающими в результате пересечения большого количества уже существующей функциональности?
Клифф: Я стараюсь этого избегать. Если я знаю, что производительность станет проблемой, то задумываюсь до того, как начинаю кодить, особенно над структурами данных. Но частенько ты обнаруживаешь все это очень позже. И тогда приходится идти на крайние меры и делать то, что я называю «переписывай и властвуй»: нужно ухватиться за достаточно большой кусок. Часть кода все равно придется переписывать по причине проблем с перформансом или по чему-то еще. Какая бы причина переписывания кода не имела место быть, почти всегда лучше переписывать больший кусок, чем меньший кусок. В этот момент все начинают трястись от страха: «о боже, нельзя трогать так много кода!». Но, по факту, такой подход почти всегда работает гораздо лучше. Нужно сразу взяться за большую проблему, обрисовать вокруг нее большой круг и сказать: все, что внутри круга, я перепишу. Граница ведь намного меньше, чем тот контент внутри нее, который подлежит замене. И если такое очерчивание границ позволит сделать работу внутри идеально — у тебя развязаны руки, делай что хочешь. Как только ты понял проблему, процесс переписывания идет куда проще, поэтому откусывай большой кусок!
В то же время, когда делаешь переписывание большим куском и понимаешь, что производительность станет проблемой, можно сразу начать о ней беспокоиться. Обычно это превращается в простые вещи вроде «не копируй данные, управляй данными как можно проще, делай их поменьше». В больших переписываниях, есть стандартные способы улучшения перформанса. И они почти всегда крутятся вокруг данных.
Андрей: В одном из подкастов вы говорили о моделях стоимости в контексте производительности. Можете объяснить, что под этим имелось в виду?
Клифф: Конечно. Я родился в эпоху, когда производительность процессора была чрезвычайно важна. И эта эра возвращается снова — судьба не лишена иронии. Я начинал жить во времена восьмибитных машин, мой первый компьютер работал с 256 байтами. Именно байтами. Все было очень маленькое. Нужно было считать инструкции и как только мы начали продвигаться вверх по стеку языков программирования, языки брали на себя все больше и больше. Был Ассемблер, потом Basic, потом C, и C брал на себя работу со множеством деталей, вроде распределения регистров и подбора инструкций. Но там все было довольно понятно и если я сделал указатель на экземпляр переменной, то я получу load, и у этой инструкции стоимость известна. Железо выдает известное количество машинных циклов, так что скорость выполнения разных штук можно посчитать просто сложив все инструкции, которые ты собрался запускать. Каждый compare/test/branch/call/load/store можно было сложить и сказать: вот тебе и время выполнения. Занимаясь улучшением производительности, ты точно обратишь внимание что за числа соответствуют мелким горячим циклам.
Но как только ты переключаешься на Java, Python и похожие штуки, ты очень быстро отдаляешься от низкоуровневого железа. Какова стоимость вызова геттера в Java? Если JIT в HotSpot все правильно заинлайнил, это будет load, но, если он этого не сделал — это будет вызов функции. Поскольку вызов лежит на горячем цикле, он отменит все другие оптимизации в этом цикле. Поэтому реальная стоимость будет намного больше. И ты тут же теряешь способность смотреть на кусок кода и понимать, что нам стоит его выполнить в терминах тактовой частоты процессора, используемой памяти и кэша. Все это становится интересно только если действительно забурился в перформанс.
Сейчас мы оказались в ситуации, когда скорости процессоров уже десятилетие как почти не растут. Старые времена возвращаются! Вы уже не можете рассчитывать на хорошую однопоточную производительность. Но если вдруг заняться параллельными вычислениями — это безумно сложно, все на тебя смотрят как на Джеймса Бонда. Десятикратные ускорения здесь обычно возникают в тех местах, где кто-то что-то прошляпил. Параллельность требует много работы. Чтобы получить то самое десятикратное ускорение, нужно понять модель стоимости. Что и сколько стоит. А для этого нужно понять, как язык ложится на нижележащее железо.
Мартин Томпсон подобрал отличное слово для своего блога Mechanical Sympathy! Необходимо понимать, что собирается делать железо, как именно оно будет это делать, и почему оно вообще делает то, что делает. Пользуясь этим, довольно просто начать считать инструкции и выяснять, куда утекает время выполнения. Если же у тебя нет соответствующей подготовки, ты просто ищешь черную кошку в темной комнате. Я постоянно вижу людей, оптимизирующих производительность, у которых нет ни малейших идей, какого черта они вообще делают. Они очень мучаются и не очень куда-то продвигаются. И когда я беру тот же самый кусок кода, подсовываю туда парочку мелких хаков и получаю пятикратное или десятикратное ускорение, они такие: ну, так нечестно, мы и так знали, что ты лучше. Поразительно. О чем это я… модель стоимости — это о том, что за код ты пишешь и как быстро он в среднем работает во всеобщей картине.
Андрей: И как такой объем удержать в голове? Это достигается большим количеством опыта, или? Где такой опыт добывается?
Клифф: Ну, свой опыт я получил не самым простым путем. Я программировал на Ассемблере еще в те времена, когда можно было разобраться в каждой отдельной инструкции. Это звучит глупо, но с тех пор у меня в голове, в памяти, навсегда остался набор инструкций Z80. Я не помню имена людей уже через минуту после разговора, но помню код, написанный 40 лет назад. Забавно, это выглядит как синдром «учёного идиота».
Андрей: Есть ли какой-то более простой способ войти в дело?
Клифф: И да и нет. Железо, которым мы все пользуемся, за это время не так уж изменилось. Все используют x86, за исключением смартфонов на Arm. Если ты не занимаешься каким-то хардкорным эмбеддедом, у тебя все то же самое. Хорошо, дальше. Инструкции тоже веками не менялись. Нужно пойти и написать что-нибудь на Ассемблере. Немного, но достаточно, чтобы начать понимать. Вы вот улыбаетесь, а я совершенно серьезно говорю. Нужно понять соответствие языка и железа. После этого нужно пойти, пописать немного и сделать небольшой игрушечный компилятор для небольшого игрушечного языка. «Игрушечный» означает, что нужно сделать его за разумное время. Он может быть суперпростым, но должен генерировать инструкции. Акт генерации инструкции позволит понять модель стоимости для моста между высокоуровневым кодом, на котором все пишут, и машинным кодом, который выполняется на железе. Это соответствие прожжется в мозгах в момент написания компилятора. Даже самого простенького компилятора. После этого можно начать смотреть на Java и то, что у нее семантическая пропасть куда глубже, и возводить поверх нее мосты куда сложнее. В Java гораздо сложнее понять, получился ли наш мост хорошим или плохим, что заставит его развалиться и что нет. Но тебе нужна какая-то отправная точка, когда ты смотришь на код и понимаешь: «ага, этот геттер должен инлайниться каждый раз». А дальше оказывается, что иногда так и происходит, за исключением ситуации, когда метод становится слишком большим, и JIT начинает инлайнить все подряд. Производительность таких мест можно предсказать мгновенно. Обычно геттеры работают хорошо, но потом ты смотришь на большие горячие циклы и понимаешь, что там плавают какие-то вызовы функций, которые непонятно что делают. В этом и есть проблема с повсеместным использованием геттеров, причина по которой они не инлайнятся — непонятно, геттер ли это. Если у тебя супермаленькая кодовая база, ее можно просто запомнить и потом сказать: вот это геттер, а вот это сеттер. В большой кодовой базе каждая функция проживает свою собственную историю, которая никому, в общем-то, не известна. Профайлер говорит, что мы потеряли 24% времени на каком-то цикле и чтобы понять, что делает этот цикл, нужно посмотреть на каждую функцию внутри. Невозможно понять это, не изучая функцию, и это серьезно замедляет процесс понимания. Поэтому я и не использую геттеры и сеттеры, я вышел на новый уровень!
Откуда взять модель стоимости? Ну, можно почитать что-то, конечно… Но я думаю, лучший способ — действовать. Сделать небольшой компилятор и это будет наилучший способ осознать модель стоимости и уместить ее в собственной голове. Небольшой компилятор, который сгодился бы для программирования микроволновки — это задача для новичка. Ну, я имею в виду, что если у тебя уже есть навыки программирования, то их должно хватить. Все эти штуки вроде распарсить строку, которая у тебя будет каким-нибудь алгебраическим выражением, вытащить оттуда инструкции математических операций в правильном порядке, взять правильные значения с регистров — все это делается на раз. И пока ты это будешь делать, оно отпечатается в мозгу. Думаю, все знают, чем занимается компилятор. И вот это даст понимание модели стоимости.
Андрей: На что еще стоит обращать внимание при работе над производительностью?
Клифф: Структуры данных. Кстати да, я уже давно не вел эти занятия… Rocket School. Это было забавно, но требовало вкладывать столько сил, а у меня ведь еще и жизнь есть! Ладно. Так вот, на одном из больших и интересных занятий, «Куда уходит ваш перформанс», я давал студентам пример: два с половиной гигабайта финтех-данных читались из CSV файла и дальше надо было посчитать количество продаваемых продуктов. Обычные тиковые рыночные данные. UDP-пакеты, превращенные в текстовый формат, начиная с 70-х годов. Chicago Mercantile Exchange — всякие штуки вроде масла, кукурузы, соевых бобов, и тому подобного. Нужно было сосчитать эти продукты, количество сделок, средний объем движения средств и товаров, и т.д. Это довольно простая торговая математика: найти код продукта (это 1–2 символа в хэш-таблице), получить сумму, добавить ее в один из наборов сделок, добавить объем, добавить стоимость, и пару других вещей. Очень простая математика. Игрушечная реализация была очень прямолинейной: все лежит в файле, я читаю файл и двигаюсь по нему, разделяя отдельные записи на Java-строки, ищу в них нужные вещи и складываю согласно вышеописанной математике. И это работает с какой-то небольшой скоростью.
С таким подходом все очевидно, что происходит, и параллельные вычисления тут не помогут, правильно? Оказывается, пятикратного увеличения производительности можно добиться всего лишь выбором правильных структур данных. И это удивляет даже опытных программистов! В моем конкретном случае фокус был в том, что не стоит делать выделений памяти в горячем цикле. Ну, это не вся правда, но в целом — не стоит выделять «раз в X», когда X достаточно велико. Когда X — это два с половиной гигабайта, не стоит выделять ничего «раз за букву», или «раз за строчку», или «раз за поле», ничего в таком роде. Именно на это и уходит время. Как это вообще работает? Представьте, что я делаю вызов String.split()
или BufferedReader.readLine()
. Readline
делает строку из набора байтиков, пришедших по сети, один раз для каждой строки, для каждой из сотен миллионов строк. Я беру эту строку, анализирую еt и выбрасываю. Почему выбрасываю — ну, я же ее уже обработал, все. Так что, для каждого байта, прочитанных из этих 2.7G, будет записано два символа в строке, то есть уже 5.4G, и они мне дальше ни для чего не нужны, поэтому выбрасываются. Если взглянуть на пропускную способность памяти, мы грузим 2.7G, которые идут сквозь память и шину памяти в процессоре, и дальше в два раза больше отправляются в строку, лежащую памяти, и все это перетирается при создании каждой новой строки. Но мне же нужно прочитать ее, железо ее читает, даже если потом все будет перетерто. И я должен записать ее, потому что я создал строку и кэши переполнились — кэш не может уместить в себе 2.7G. Итого, для каждого считанного байта я читаю еще два дополнительных байта и пишу два дополнительных байта, и в итоге они имеют соотношение 4:1 — в таком соотношении мы бездарно тратим пропускную способность памяти. А дальше оказывается, что если я делаю String.split()
— то делаю это далеко не последний раз, там внутри может быть еще 6–7 полей. Поэтому классический код чтения CSV с последующим парсингом строк приводит к потерям пропускной полосы памяти в районе 14:1 относительно того, что вам на самом деле хотелось бы иметь. Если выбросить эти выделения, то можно получить пятикратное ускорение.
И это не то, чтобы очень сложно. Если вы посмотрите на код под правильным углом, все это становится довольно просто, сразу же, как вы осознали суть проблемы. Не стоит вообще переставать выделять память: проблема только в том, что вы что-то выделяете и оно тут же умирает, и по пути сжигает важный ресурс, который в данном случае — пропускная способность памяти. И все это выливается в падение производительности. На x86 обычно нужно активно жечь такты процессора, а тут вы сожгли всю память куда раньше. Решение — нужно снижать количество выделений.
Другая часть проблемы в том, что, если запустить профайлер, когда закончилась полоса памяти, прямо в момент, когда это происходит, ты обычно ждешь возвращения кэша, потому что он полон мусором, который ты только что наплодил, всеми этими строками. Поэтому каждая операция load или store становится медленной, ведь они приводят к промахам в кэше — весь кэш стал медленным, ожидая, когда из него уедет мусор. Поэтому профилировщик всего лишь покажет теплый случайный шум, размазанный вдоль всего цикла — не будет никакой отдельной горячей инструкции или места в коде. Только шум. И если вы посмотрите на циклы GC, они все будут по Young Generation и супербыстрыми — микросекунды или миллисекунды максимум. Ведь вся эта память умирает мгновенно. Ты выделяешь миллиарды гигабайт, и он их срезает, и срезает, и снова срезает. Все это происходит очень быстро. Получается, имеются дешевые циклы GC, теплый шум вдоль всего цикла, но нам хочется получить 5-кратное ускорение. В этот момент и должно в голове что-то замкнуться и прозвучать: «почему так?!». Переполнение полосы памяти не отображается в классическом отладчике, нужно запустить отладчик аппаратных счетчиков производительности и увидеть это самостоятельно и напрямую. А не напрямую это можно заподозрить из этих трех симптомов. Третий симптом — это когда ты смотришь что выделяешь, спрашиваешь у профилировщика, и он отвечает: «Ты сделал миллиард строк, но GC отработал бесплатно». Как только это произошло, ты понимаешь, что наплодил слишком много объектов и сжег всю полосу памяти. Способ разобраться в этом есть, но он не очевидный.
Проблема в структуре данных: голая структура, лежащая за всем происходящим, она слишком большая, это 2.7G на диске, поэтому делать копию этой штуки очень нежелательно — хочется загрузить ее из сетевого байтового буфера сразу же в регистры, чтобы не читать-писать в строку туда-обратно по пять раз. К сожалению, Java по умолчанию не дает тебе такой библиотеки в составе JDK. Но ведь это тривиально, правда? По сути, это 5–10 строк кода, которые пойдут на реализацию собственного буферизованного загрузчика строчек, который повторяет поведение класса строк, являясь при этом оберткой вокруг нижележащего байтового буфера. В результате оказывается, что ты работаешь почти как бы со строками, но на самом деле там двигаются указатели на буфер, а сырые байты никуда не копируются, и таким образом переиспользуются одни и те же буферы, раз за разом, а операционная система счастлива взять на себя вещи, для которых она предназначена, вроде скрытой двойной буферизации этих байтовых буферов, а ты сам больше не перемалываешь бесконечный поток ненужных данных. Кстати, вы же понимаете, при работе с GC гарантируется, что каждое выделение памяти не будет видно процессору после последнего цикла GC? Поэтому, все это никак не может быть в кэше, и дальше случается 100%-гарантированный промах. При работе с указателем, на x86 вычитать регистр из памяти занимает 1–2 такта, и как только это происходит, ты платишь, платишь, платишь, потому что память вся на NINE кэшах — и вот это является стоимостью выделения памяти. Настоящей стоимостью.
Другими словами, структуры данных — это то, что менять сложнее всего. И как только вы осознали, что выбрали неправильную структуру данных, которая в дальнейшем убьет производительность, обычно требуется провернуть существенную работу, но, если этого не сделать, дальше будет хуже. Прежде всего, нужно думать о структурах данных, это важно. Основная стоимость тут ложится на жирные структуры данных, которые начинают использовать в стиле «я скопировал структуру данных X в структуру данных Y, потому что Y мне больше нравится по форме». Но операция копирования (которая кажется дешевой) на самом деле тратит полосу памяти и вот здесь закопано все потерянное время выполнения. Если у меня есть гигантская строка с JSON и я хочу превратить ее в структурированное DOM-дерево из POJO или чего-то такого, операция парсинга этой строки и построения POJO, и потом новое обращение к POJO в дальнейшем обернутся лишней стоимостью — штука недешевая. За исключением случая, если вы будете бегать по POJO намного чаще, чем по строке. Навскидку, вместо этого можно попробовать расшифровать строку и выдернуть оттуда только нужное, не превращая ни в какие POJO. Если все это происходит на пути, от которого требуется максимальная производительность, никаких тебе POJO, — нужно как-то напрямую копаться в строке.
Андрей: Вы сказали, что для осознания модели стоимости, нужно написать свой небольшой маленький язык…
Клифф: Не язык, а компилятор. Язык и компилятор — разные вещи. Самое главное различие — у себя в голове.
Андрей: Кстати, насколько знаю, вы экспериментируете с созданием собственных языков. Зачем?
Клифф: Потому что могу! Я наполовину вышел на пенсию, так что это мое хобби. Я всю жизнь реализовывал чьи-то чужие языки. А еще я много работал над стилем кодирования. А еще потому, что я вижу проблемы в других языках. Вижу, что есть более хорошие способы делать привычные вещи. И я ими бы воспользовался. Я просто запарился видеть проблемы в себе, в Java, в Python, в любом другом языке. Я сейчас пишу на React Native, JavaScript и Elm в качестве хобби, которое не про пенсию, а про активную работу. И на Python тоже пишу и, скорее всего, буду продолжать работать над машинным обучением для Java-бэкендов. Есть множество популярных языков и у всех них есть интересные особенности. Каждый хорош чем-то своим и можно попробовать свести все эти фишки воедино. Так что, я занимаюсь изучением интересных для меня вещей, поведением языка, пытаюсь придумать разумную семантику. И пока что у меня получается! В данный момент я борюсь с семантикой памяти, потому что хочется иметь ее как в C и Java, и получить сильную модель памяти и семантику памяти для лоадов и сторов. При этом иметь автоматический вывод типов как в Haskell. Вот, я пытаюсь смешать Haskell-подобный вывод типов с памятью, работающей как в C и Java. Этим я занимаюсь последние 2–3 месяца, например.
Андрей: Если вы строите язык, который берет лучше аспекты из других языков, думали ли вы, что кто-то сделает обратное: возьмет ваши идеи и использует у себя?
Клифф: Именно так и появляются новые языки! Почему Java похожа на C? Потому что у C был хороший синтаксис, который все понимали и Java вдохновилась этим синтаксисом, добавив туда типобезопасность, проверки границ массивов, GC, а еще они улучшили какие-то вещи из C. Добавили свои. Но они вдохновлялись довольно сильно, верно? Все стоят на плечах гигантов, которые были до тебя — именно так делается прогресс.
Андрей: Как я понимаю, ваш язык будет безопасным относительно использования памяти. Думали ли вы реализовать что-то вроде borrow checker из Rust? Вы на него смотрели, как он вам?
Клифф: Ну, я пишу на C уже целую вечность, со всеми этими malloc и free, и вручную управляю временем жизни. Знаете, 90–95% вручную управляемого лайфтайма имеет одинаковую структуру. И это очень, очень больно заниматься этим вручную. Хотелось бы, чтобы компилятор просто говорил, что там происходит и чего ты добился своими действиями. Для каких-то вещей borrow checker делает это из коробки. А еще он должен автоматически выводить информацию, все понимать и даже не грузить меня тем, чтобы это понимание изложить. Он должен делать как минимум локальный эскейп-анализ, и вот только если у него не получилось, тогда нужно добавлять аннотации типов, которые будут описывать лайфтайм — и подобная схема куда сложнее, чем borrow checker, или вообще любой существующий чекер памяти. Выбор между «все в порядке» и «я ничего не понял» — нет, должно быть что-то получше.
Так что, как человек, написавший много кода на C, считаю, что иметь поддержку автоматического управления лайфтаймом — это наиважнейшая вещь. А еще меня достало, как сильно Java использует память и основная претензия — в GC. При выделении памяти в Java, тебе не вернется память, которая была локальной на последнем цикле GC. В языках с более точным управлением памятью это не так. Если ты зовешь malloc, то тут же получаешь память, которая обычно только что использовалась. Обычно ты делаешь с памятью какие-то временные вещи и сразу же возвращаешь назад. И она тут же возвращается в пул malloc-а, и следующий цикл malloc-а снова вытаскивает ее наружу. Поэтому реальное использование памяти уменьшается до набора живых объектов в конкретный момент времени, плюс утечки. И если у тебя не течет все совсем неприличным образом, большая часть памяти оседает в кэшах и процессоре, и это работает быстро. Но требует много ручного управления памятью с помощью malloc и free, вызываемых в правильном порядке, в правильном месте. Rust может сам правильно с этим справиться и в куче случаев дать даже большую производительность, поскольку потребление памяти сужается только до текущих вычислений — в противоположность ожиданию следующего цикла GC, который освободит память. В итоге, мы получили очень интересный способ улучшить производительность. И довольно мощный — в смысле, я занимался такими штуками при обработке данных для финтеха, и это позволяло получать ускорение раз эдак в пять. Это довольно большое ускорение, особенно в мире, где процессоры на становятся быстрее, а мы все также продолжаем ждать улучшений.
Андрей: Еще хотелось бы поспрашивать о карьере в целом. Вы стали знаменитым благодаря работе на JIT в HotSpot, а затем переместились в Azul — и это тоже JVM-компания. Но занимались уже больше железом, чем софтом. А потом вдруг переключились на Big Data и Machine Learning, а потом на fraud detection. Как так получилось? Это очень разные области разработки.
Клифф: Я уже довольно давно занимаюсь программированием и успел отметиться на очень разных занятиях. И когда люди говорят: «о, ты же тот, кто делал JIT для Java!», это всегда забавно. А ведь до этого я занимался клоном PostScript — того языка, который Apple когда-то использовала для своих лазерных принтеров. А до этого делал реализацию языка Forth. Думаю, общая тема для меня — это разработка инструментов. Всю жизнь делаю инструменты, с помощью которых другие люди пишут свои крутые программы. Но я занимался и разработкой операционных систем, драйверов, отладчиков уровня ядра, языков для разработки ОС, которые начинались тривиально, но со временем все усложнялись и усложнялись. Но основная тема, все-таки — разработка инструментов. Большой кусок жизни прошёл между Azul и Sun, и он был про Java. Но когда я занялся Big Data и Machine Learning, я снова надел свою парадную шляпу и сказал: «Ох, а вот теперь у нас появилась нетривиальная проблема, и тут вообще происходит куча интересных вещей и людей, которые что-то делают». Это отличный путь для развития, по которому стоит пройти.
Да, я очень люблю распределенные вычисления. Моя первая работа была в студенчестве на C, над рекламным проектом. Это были распределенные вычисления на чипах Zilog Z80, которые собирали данные для аналогового оптического распознавания текстов, производящегося настоящим аналоговым анализатором. Это была крутая и совершенно ненормальная тема. Но там были проблемы, какая-то часть не распознавалась правильно, поэтому нужно было доставать картинку и показывать ее человеку, который уже читал глазами и сообщал, что же там говорится, и поэтому там были джобы с данными, и у этих джобов был свой язык. Был бэкенд, который все это обрабатывал — работающие параллельно Z80 с запущенными терминалами vt100 — по одному на человека, и была модель параллельного программирования на Z80. Некий общий кусок памяти, который разделяли все Z80 внутри конфигурации типа «звезда»; разделялся и бэкплейн, и половина RAM разделялась внутри сети, и еще половина была приватной или уходила на что-то еще. Осмысленно сложная параллельная распределенная система с разделяемой… полуразделяемой памятью. Когда же это было… Уже и не вспомнить, где-то в середине 80-х. Довольно давно.
Да, будем считать, что 30 лет — это достаточно давно Задачи, связанные с распределенными вычислениями, существуют достаточно долго, люди издавна воевали с Beowulf-кластерами. Такие кластера выглядят как… Например: есть Ethernet и твой быстрый x86 подсоединен к этому Ethernet, и теперь тебе хочется заполучить fake shared memory, потому что никто не мог тогда заниматься кодингом распределенных вычислений, это было слишком сложно и поэтому была fake shared memory с защитой страниц памяти на x86, и если ты писал в эту страницу, то мы говорили остальным процессорам, что если они получат доступ к той же самой shared memory, ее нужно будет загрузить с тебя, и таким образом появилось что-то вроде протокола поддержки когерентности кэшей и софта для этого. Интересная концепция. Настоящая проблема, конечно, была в другом. Все это работало, но ты быстро получал проблемы с производительностью, ведь никто не понимал модели производительности на достаточно хорошем уровне — какие там паттерны доступа к памяти, как сделать так, чтобы ноды бесконечно не пинговали друг друга, и так далее.
В H2O я придумал вот что: сами разработчики отвечают за то, чтобы определить, где спрятался параллелизм и где его нет. Я придумал такую модель кодирования, что писать высокопроизводительный код стало легко и просто. А вот написать медленно работающий код сложно, он будет плохо выглядеть. Нужно серьезно постараться, чтобы написать медленный код, придется использовать нестандартные методы. Тормозящий код видно с первого взгляда. Как следствие, обычно пишется код, который работает быстро, но вам приходится разбираться, что делать в случае разделяемой памяти. Все это завязано на больших массивах и поведение там похоже на неволатильные большие массивы в параллельной Java. В смысле, представьте, что два потока пишут в параллельный массив, один из них выигрывает, а другой, соответственно, проигрывает, и вы не знаете кто из них кто. Если они не волатильные, то порядок может быть какой угодно — и это действительно хорошо работает. Люди действительно заботятся о порядке операций, они правильно расставляют volatile и в правильных местах ожидают проблемы с производительностью, связанные с памятью. В противном случае они бы просто писали код в виде циклов от 1 до N, где N — какие-то триллионы, в надежде, что все сложные случаи автоматически станут параллельными — и там это не работает. Но в H2O это и не Java, и не Scala, можно считать это «Java минус минус», если хочется. Это очень понятный стиль программирования и он похож на написание простого кода на C или Java с циклами и массивами. Но при этом память можно обрабатывать терабайтами. Я до сих пор использую H2O. Время от времени использую в разных проектах — и это до сих пор самая быстрая штука, в десятки раз опережающая конкурентов. Если вы делаете Big Data с колоночными данными, очень сложно превзойти H2O.
Андрей: Какой у вас за всю карьеру был самый большой челленж?
Клифф: Мы обсуждаем техническую или не техническую часть вопроса? Я бы сказал, самые большие челленжи — не технические.
Что касается технических челленжей. Я их просто победил. Я даже не знаю, какой там был самый большой, но было несколько довольно интересных, на которые ушло довольно много времени, ментальной борьбы. Когда я пошел в Sun, я был уверен, что сделаю быстрый компилятор, а куча сеньоров в ответ говорили, что ничего у меня никогда не получится. Но я пошел по этому пути, написал компилятор вплоть до аллокатора регистров, и довольно быстрый. Он был настолько же быстр, как современный C1, но тогда аллокатор был намного медленнее, и оглядываясь назад — это была проблема большой структуры данных. Мне она была нужна чтобы написать графический аллокатор регистров и я не понимал дилеммы между выразительностью кода и скоростью, которая существовала в ту эпоху и была очень важной. Оказалось, что структура данных обычно превышает размер кэша на x86-х того времени и поэтому, если я изначально предполагал, что аллокатор регистров будет