Клиф Клик — CTO на компанията Cratus (IoT сензори за оптимизация на процесите), основател и съосновател на няколко стартъпа (включително Rocket Realtime School, Neurensic и H2O.ai) с няколко успешни изхода. Клиф написа първия си компилатор на 15 години (Pascal за TRS Z-80)! Най-известен е с работата си по C2 в 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?
Клиф: Тук всичко е просто. Най-бързият код е този, който никога не се изпълнява. Затова винаги трябва да започвате от високото ниво, работейки върху алгоритмите. По-добрата O-нотация ще надмине по-лошата O-нотация, освен ако не се намесят достатъчно големи константи. Ниските нива идват на последно място. Обикновено, ако сте оптимизирали достатъчно добре останалия стек и все още остава нещо интересно – ето тук е ниското ниво. Но как да започнете от високото ниво? Как да разберете, че сте свършили достатъчно работа на високо ниво? Ами… не можете. Няма готови рецепти. Трябва да разберете проблемa, да решите какво планирате да направите (за да не правите ненужни стъпки по-късно) и тогава можете да вземете профайлера, който може да ви каже нещо полезно. В определен момент вие сами разбирате, че сте се отървали от ненужните неща и е време да се заемете с финото настройване на ниско ниво. Това определено е особен вид изкуство. Куп хора правят ненужни неща, но се движат толкова бързо, че нямат време да се притесняват за производителността. Но това е, докато проблемът не излезе на преден план. Обикновено 99% от времето на никого не му е интересно с какво се занимавам, докато на критичния път не се появи нещо важно, което на някого му пука. И тогава всички започват да те питат „защо то от самото начало не работеше идеално“. В общи линии, винаги има какво да се подобри в производителността. Но 99% от времето нямате прикрепвания! Просто се опитвате да накарате нещо да работи и в хода на това разбирате какво е важно. Никога не можете предварително да знаете, че този участък трябва да бъде перфектен, затова по същество трябва да бъдете перфектен във всичко. А това е невъзможно и не сте така. Винаги има куп неща за поправка – и това е напълно нормално.
Как да направим голям рефакторинг
Андрей: Как работите върху производителността? Това е проблем, който обхваща всичко. Например, имали ли сте случаи, в които сте се занимавали с проблеми, възникващи в резултат на пресичането на голямо количество вече съществуваща функционалност?
Клиф: Опитвам се да го избягвам. Ако знам, че производителността ще стане проблем, започвам да се замислям, преди да започна да кодя, особено за данните структури. Но често откриваш всичко това доста по-късно. И тогава трябва да прибегнеш до крайни мерки и да направиш това, което наричам „преписвай и управлявай“: трябва да хванеш достатъчно голямо парче. Част от кода все пак ще се наложи да се препише поради проблеми с производителността или по друга причина. Каквато и да е причината за пренаписването на кода, почти винаги е по-добре да пренапишеш по-голямо парче, отколкото по-малко. В този момент всички започват да треперят от страх: „О, боже, не можем да пипаме толкова много код!“. Но всъщност, този подход почти винаги работи много по-добре. Трябва да се захванеш с голям проблем, да начертаеш голям кръг около него и да кажеш: всичко вътре в кръга, аз ще пренапиша. Границата е много по-малка от съдържанието вътре в нея, което трябва да се замени. И ако това очертаване на границите позволи да свършиш вътре идеално – имаш свобода, прави каквото искаш. След като разбереш проблема, процесът на пренаписване става много по-лесен, така че хапни голямо парче!
В същото време, когато правиш пренаписване на голямо парче и осъзнаваш, че производителността ще стане проблем, можеш веднага да започнеш да се тревожиш за нея. Обикновено това преминава в прости неща като „не копирай данни, управлявай данните колкото се може по-просто, прави ги по-малко“. В големите пренаписвания има стандартни начини за подобряване на производителността. И те почти винаги се въртят около данните.
Модел на разходите
Андрей: В един от подкастите говорехте за моделите на разходите в контекста на производителността. Можете ли да обясните какво имате предвид?
Клиф: Разбира се. Роден съм в епохата, когато производителността на процесора беше изключително важна. И тази ера отново се завръща – съдбата не е лишена от ирония. Започнах да живея по времето на осембитовите машини, моят първи компютър работеше с 256 байта. Именно байта. Всичко беше много малко. Трябваше да броим инструкциите и щом започнахме да напредваме по стека на програмните езици, езиците поемаха все повече и повече. Имаше Ассемблер, после Basic, после C, и C поемаше работата с много детайли, като разпределението на регистрите и подбора на инструкции. Но там всичко беше доста ясно и ако направих указател на екземпляр на променлива, получавах load, и цената на тази инструкция беше известна. Хардуерът издава известно количество машинни цикли, така че скоростта на изпълнение на различни неща може да се сметне просто чрез събиране на всичките инструкции, които възнамеряваш да стартираш. Всеки compare/test/branch/call/load/store можеше да се събере и да се каже: ето ти времето за изпълнение. Занимавайки се с подобряване на производителността, със сигурност ще обърнете внимание на това какви числа съответстват на малките горещи цикли.
Но веднага щом преминеш на Java, Python и подобни неща, много бързо се отдалечаваш от нискоуровневия хардуер. Каква е цената на извикване на гетер в Java? Ако JIT в HotSpot се е справил правилно , това ще бъде load, но, ако не е направил това – ще бъде повикване на функция. Понеже повикването се намира в горещия цикъл, то ще отмени всички други оптимизации в този цикъл. Следователно реалната цена ще бъде много по-висока. И веднага губиш способността да погледнеш на фрагмент от код и да разбереш, че е по-добре да го изпълниш в термини на тактовата честота на процесора, използваната памет и кеша. Всичко това става интересно само ако наистина се задълбочиш в производителността.
Сега сме в ситуация, в която скоростите на процесорите вече почти десетилетие не се увеличават. Стари времена се завръщат! Вече не можете да разчитате на добра еднопоточна производителност. Но ако случайно решите да се занимавате с паралелни изчисления – това е безумно сложно, всички ви гледат като на Джеймс Бонд. Десеткратни ускорения обикновено се получават там, където някой не е направил нещо. Паралелността изисква много работа. За да получите онова десетократно ускорение, трябва да разберете модела на разходите. Какво и колко струва. А за това трябва да разберете как езикът се отнася към основния хардуер.
Мартин Томпсън подбра чудесна дума за своя блог Трябва да разберете какво ще прави хардуерът, как точно ще го прави и защо изобщо го прави. Използвайки това, е доста лесно да започнете да изчислявате инструкциите и да установите къде отива времето за изпълнение. Но ако нямате съответната подготовка, просто търсите черна котка в тъмен килер. Постоянно виждам хора, които оптимизират производителността, но нямат представа какво правят. Те страдат много и не напредват особено. И когато взема същия парче код, добавяйки там няколко дребни хакове, и постигам петкратно или десетократно ускорение, те казват: 'Ами, това не е честно, знаехме, че ти си по-добър.' Удивително. За какво говорех... моделът на разходите - става въпрос за това, какъв код пишете и как бързо той в средно изпълнение работи в общата картина.
Андрей: И как да задържите толкова много в ума си? Достига се с много опит, нали? Къде се придобива такъв опит?
Клиф: Ами, моята подготовка дойде по доста труден път. Програмирах на Ассемблер още по времето, когато можеше да се разбере всяка отделна инструкция. Звучи глупаво, но оттогава в паметта ми е останал набор от инструкции Z80. Не помня имената на хората около минута след разговора, но помня код, написан преди 40 години. Забавно, изглежда като синдрома на '».
Обучение на нискоуровневни оптимизации
Андрей: Има ли по-лесен начин да се впуснем в това?
Клиф: Да и не. Железото, което всички ние използваме, не се е променило особено през това време. Всички използват x86, освен смартфоните, които работят на Arm. Ако не се занимаваш с някакво хардкорно вграждане, при теб е същото. Добре, продължаваме. Инструкциите също не са се променяли от векове. Трябва да отидеш и да напишеш нещо на Асемблер. Малко, но достатъчно, за да започнеш да разбираш. Усмихваш се, но говоря напълно сериозно. Трябва да разбереш съответствието между езика и железото. След това трябва да напишеш малко и да направиш малък игралничен компилатор за малък игралничен език. „Игралничен“ означава, че трябва да го направиш в разумно време. Може да е супер прост, но трябва да генерира инструкции. Актът на генериране на инструкции ще ти помогне да разбереш модела на разходите за моста между високите нива на код, на които всички пишат, и машинния код, който се изпълнява на железото. Това съответствие ще се запечата в ума ти по време на писането на компилятора. Дори на най-простичкия компилатор. След това можеш да започнеш да гледаш на Java и да осъзнаеш, че семантичната пропаст при нея е много по-дълбока, и да изграждаш мостове над нея е много по-сложно. В Java е много по-трудно да разбереш дали нашият мост е добър или лош, какво ще го разруши и какво не. Но ти трябва някаква отправна точка, когато гледаш на кода и разбираш: „аха, този гетер трябва да се инлайнва всеки път“. И след това се оказва, че понякога наистина се случва, освен в случаите, когато методът стане твърде голям, и JIT започне да инлайнва всичко. Производителността на тези места може да се предскаже веднага. Обикновено гетерите работят добре, но после поглеждаш големите горещи цикли и разбираш, че има някакви извиквания на функции, чийто ефект не е ясен. В това и е проблемът с масовото използване на гетери, причина поради която те не се инлайнват – не е ясно, дали е гетер. Ако имаш супермалка кодова база, можеш просто да я запомниш и после да кажеш: ето това е гетер, а това е сетър. В голяма кодова база всяка функция има своя собствена история, която никой, всъщност, не знае. Профайлерът казва, че сме загубили 24% от времето в някакъв цикъл и за да разбереш какво прави този цикъл, трябва да погледнеш всяка функция вътре. Невъзможно е да разбереш това, без да изучаваш функцията, и това сериозно забавя процеса на разбиране. Затова и не използвам гетери и сетъри, излязох на ново ниво!
Откъде да вземем модел на разходите? Ами, може да прочетем нещо, разбира се… Но мисля, че най-добрият начин е да действаме. Да направим малък компилатор и това ще бъде най-добрият начин да осъзнаем модела на разходите и да го вместим в собствената си глава. Малък компилатор, който да е подходящ за програмиране на микровълнова печка – това е задача за начинаещ. Ами, имам предвид, че ако вече имаш умения за програмиране, те трябва да са достатъчни. Всички тези неща като разбор на стринг, който ще имаш като някакво алгебрично изразяване, да извлечеш инструкциите за математически операции в правилния ред, да вземеш правилните стойности от регистрите – всичко това се прави лесно. И докато го правиш, то ще отпечата в мозъка ти. Мисля, всички знаят с какво се занимава компилаторът. И това ще даде разбиране за модела на разходите.
Практически примери за подобряване на производителността
Андрей: На какво още трябва да се обърне внимание при работа върху производителността?
Клиф: Структури от данни. Между другото да, отдавна не съм водил тези занятия… . Беше забавно, но изискваше толкова много усилия, а аз имам и живот! Добре, така че на едно от големите и интересни занятия „Къде отива вашето представяне“ давох на студентите пример: две и половина гигабайта финтех данни се четат от CSV файл и трябваше да се изчисли броят на продаваните продукти. Обикновени тикерни пазарни данни. UDP пакети, превърнати в текстов формат от 70-те години. Chicago Mercantile Exchange – всякакви неща като масло, царевица, соеви зърна и подобни. Трябваше да се преброят тези продукти, броя на сделките, средния обем на движението на средства и стоки и т.н. Това е доста проста търговска математика: да намериш код на продукта (това са 1-2 символа в хеш таблица), да получиш сумата, да я добавиш в един от наборите сделки, да добавиш обема, да добавиш стойността и още няколко неща. Много проста математика. Играчковата реализация беше много праволинейна: всичко е в файла, прочитам файла и минавам през него, разделяйки отделните записи на Java стрингове, търся нужните неща в тях и събирам според гореописаната математика. И това работи с някаква малка скорост.
С такъв подход всичко е очевидно, че се случва, и паралелните изчисления тук не помагат, нали? Оказва се, че петкратен ръст на производителността може да се постигне само с избора на правилните структури от данни. И това удивява дори опитни програмисти! В конкретния ми случай акцентът беше, че не трябва да се правят разпределения на памет в горещия цикъл. Ами, това не е всичката истина, но в общи линии – не трябва да се разпределя „на всеки X“, когато X е достатъчно голямо. Когато X е два и половина гигабайта, не трябва да се разпределя нищо „на буква“, или „на ред“, или „на поле“, нищо подобно. Именно на това се губи времето. Как всъщност работи това? Представете си, че правя обаждане String.split() или BufferedReader.readLine(). Readline прави низ от набор от байтове, получени по мрежата, само веднъж за всеки ред, за всеки от стотиците милиони реда. Аз вземам този ред, анализирам го и го изтривам. Защо го изтривам – ами, вече съм го обработил, всичко. Така че за всеки байт, прочетен от тези 2.7G, ще бъдат записани два символа в низ, тоест вече 5.4G, и те не ми трябват занапред, затова биват изхвърлени. Ако се погледне на пропускната способност на паметта, ние зареждаме 2.7G, които преминават през паметта и шина на паметта в процесора, и след това два пъти повече се изпращат в низ, лежащ в паметта, и всичко това се преходи при създаването на всеки нов низ. Но аз трябва да го прочета, хардуерът го чете, дори ако после всичко ще бъде прекалявано. И трябва да го запиша, защото съм създал низ и кешовете са препълнени – кешът не може да побере 2.7G. В крайна сметка, за всеки прочетен байт прочитам още два допълнителни байта и записвам два допълнителни байта, и в крайна сметка имаме съотношение 4:1 – в такова съотношение бездарно изразходваме пропускната способност на паметта. А след това се оказва, че ако правя String.split() – правя го далеч не за последен път, там вътре може да има още 6-7 полета. Затова класическият код за четене на CSV с последващ парсинг на редове води до загуби в пропускната способност на паметта около 14:1 спрямо това, което всъщност бихте искали да имате. Ако изхвърлите тези разпределения, можете да получите петкратно ускорение.
И това не е особено трудно. Ако погледнете кода от правилния ъгъл, всичко става доста просто, веднага щом осъзнаете същността на проблема. Не бива да спирате да заделяте памет: проблемът е, че нещо, което заделяте, веднага умира и по пътя изгаря важен ресурс, който в случая е пропускната способност на паметта. И всичко това води до спад в производителността. На x86 обикновено трябва активно да използвате циклите на процесора, а тук изгаряте цялата памет много по-рано. Решението е да намалите броя на заделянията.
Друга част от проблема е, че ако стартирате профайлера, когато свършва паметта, точно в момента, в който това се случва, обикновено очаквате връщането на кеша, защото той е пълен с боклук, който току-що генерирате, с всичките тези редове. Затова всяка операция load или store става бавна, тъй като те водят до пропуски в кеша – целият кеш става бавен, чакайки да се освободи от боклука. Следователно профайлера просто ще покаже топъл шум, размазан по всички цикли – няма да има отделна гореща инструкция или място в кода. Само шум. И ако разгледате циклоните на GC, всички те ще бъдат в Young Generation и супер бързи – микро-или милисекунди максимум. Защото цялата тази памет умира мигновено. Вие заделяте милиарди гигабайти, и той ги подрязва, и подрязва, и отново подрязва. Всичко това става много бързо. Получава се, че имаме евтини цикли на GC, топъл шум на целия цикъл, но ние искаме пети кратно ускорение. В този момент нещо трябва да се свърже в главата и да прозвучи: „защо така?!“. Превишаването на местото в паметта не се показва в класическия дебъгер, нужно е да стартирате дебъгер на хардуерните счетчици за производителност и да го видите сами и директно. А не директно може да се предполага от тези три симптома. Третият симптом е когато гледате какво заделяте, питате профайлера, и той отговаря: „Направи милиард реда, но GC работи безплатно“. В момента, в който това се случи, разбирате, че сте генерирали твърде много обекти и сте изгорили всичкото място в паметта. Има начин да се справите с това, но той не е очевиден.
Проблемата със структурата на данните: голата структура, която стои зад всичко, е твърде голяма, заема 2.7G на диска, затова правенето на копие на това нещо е много нежелателно – искаш да я заредиш директно от мрежовия байтов буфер в регистрите, за да не четеш и пишеш обратно по пет пъти. За съжаление, Java по подразбиране не ти предоставя такава библиотека в състава на JDK. Но това е тривиално, нали? По същество, това са 5-10 реда код, които ще отидат за реализация на собствен буфериран заряд на редове, който повтаря поведението на класа за низове, като в същото време е обвивка около подлежащия байтов буфер. В резултат на това се оказва, че работиш почти като с низове, но всъщност там се движат указатели на буфера, а суровите байтове не се копират, и по този начин се преизползват едни и същи буфери отново и отново, а операционната система е щастлива да поеме нещата, за които е предназначена, като скритата двойна буферираност на тези байтови буфери, а ти вече не преработваш безкрайния поток от ненужни данни. Между другото, вие разбирате ли, че при работа с GC е гарантирано, че всяко разпределение на памет, след последния цикъл на GC, няма да бъде видно за процесора? Затова всичко това не може да бъде в кеша, и после става 100%-гарантирано пропадане. При работа с указател, на x86 четенето на регистър от паметта отнема 1-2 цикъла, и щом това се случи, плащаш, плащаш, плащаш, защото паметта е всичката – и това е истинската цена за разпределение на памет. Истинската цена.
С други думи, данните структури са това, което е най-трудно да се промени. И след като осъзнаете, че сте избрали неправилната структура от данни, която в крайна сметка ще убие производителността, обикновено е необходимо да свършите значителна работа, но ако това не се направи, ситуацията ще стане по-лоша. Преди всичко, трябва да мислите за структурите на данните, това е важно. Основната цена тук лежи върху обемистите структури от данни, които започват да се използват в стил „копирах структурата на данни 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 и машинно обучение, а след това на откриване на измами. Как стана така? Това са много различни области на разработката.
Клиф: От доста време се занимавам с програмиране и успях да се изявя в много различни сфери. И когато хората казват: «О, ти си онзи, който направи JIT за Java!», винаги е забавно. А преди това работех върху клон на PostScript – този език, който Apple някога използваше за своите лазерни принтери. А преди това реализирах езика Forth. Мисля, че общата тема за мен е разработването на инструменти. Целият ми живот съм правел инструменти, с помощта на които другите пишат своите страхотни програми. Но също така съм работил и по разработването на операционни системи, драйвери, ядрови отладчици, езици за разработка на ОС, които започваха тривиално, но с времето ставаха все по-сложни. Но основната тема, все пак, е разработката на инструменти. Голямо парче от живота ми премина между Azul и Sun и той беше свързан с Java. Но когато започнах с Big Data и Machine Learning, отново сложих своята парадна шапка и казах: «Ох, а сега имаме нетривиален проблем, и тук наистина се случват куп интересни неща и хора, които нещо правят». Това е чудесен път за развитие, през който си струва да се премине.
Да, аз много обичам разпределените изчисления. Първата ми работа беше в студентските ми години с C, по проект за реклама. Това бяха разпределени изчисления на чипове Zilog Z80, които събираха данни за аналогово оптично разпознаване на текст, извършвано от истински аналогов анализатор. Темата беше страхотна и напълно ненормална. Но имаше проблеми, част от данните не се разпознаваха правилно, затова трябваше да извадим изображението и да го покажем на човек, който вече е чел с очите си и съобщавал какво става, затова имаше задания с данни, и тези задания имаха свой език. Имаше бекенд, който обработваше всичко това – работещи паралелно Z80 с пуснати терминали vt100 – по един на човек, и имаше модел на паралелно програмиране на Z80. Някаква обща част от паметта, която споделяха всички Z80 в конфигурация тип „звезда“; споделяха се и бекплейн, и половината RAM се споделяше в мрежата, а другата половина беше частна или отиваше за нещо друго. Смислено сложна паралелна разпределена система с споделена… полусподелена памет. Кога беше това… Вече не си спомням, някъде в средата на 80-те. Доста отдавна.
Да, да приемем, че 30 години – това е достатъчно дълго. Задачите, свързани с разпределените изчисления, съществуват достатъчно дълго, хората отдавна се борят с -кластери. Тези клъстери изглеждат като... Например: има 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 от онова време и затова, ако първоначално предполагах, че алокаторът на регистри ще работи 5-10 процента от цялото време на джитване, в действителност това се оказа 50 процента.
Времето минаваше, компилаторът ставаше все по-ясен и производителен, спря да генерира отвратителен код в много случаи и производителността все по-често се доближаваше до тази, която предлага компилаторът C. Ако, разбира се, не пишеш нещо отвратително, което дори C не може да ускори. Ако пишеш код като на C, получаваш производителността като на C в много повече случаи. И колкото повече напредвахме, толкова по-често кодът асимптотично съвпадаше с нивото на C, алокаторът на регистри започна да изглежда като нещо завършено… независимо дали кодът ти работи бързо или бавно. Продължавах да работя по алокатора, за да прави по-добри алокации. Той стана все по-бавен, но предоставяше все по-добра производителност в случаите, когато никой друг не успяваше. Можех да се потопя в алокатора на регистри, да похарча там един месец, и внезапно целият код започваше да работи с 5% по-бързо. Това се случваше отново и отново и алокаторът на регистри стана нещо като произведение на изкуството – всички го обичаха или мразеха, а хората от академията задаваха въпроси относно „защо всичко е направено по този начин“, защо не , и каква е разликата. Отговорът е все същият: алокатор, базиран на оцветяване на графа, плюс много прецизна работа с буферния код, равняваща се на оръжие за победа, най-добрата комбинация, която никой не може да надвие. И това е доста неочевидно. Всичко останало, което прави компилаторът, са доста изучени неща, макар и доведени до ниво изкуство. Винаги съм правил неща, които трябваше да превърнат компилатора в произведение на изкуството. Но нищо от това не беше нещо извънредно – с изключение на алокатора на регистрите. Фокусът е в това, че трябва да се работи много внимателно. под натоварване и, ако това се случи (мога да обясня по-подробно, ако е интересно), това означава, че може да се инлайнва по-агресивно, без риск да се премине границата на производителността. В онези времена имаше куп пълномащабни компилатори, натъпкани с всякакви екстри, в които имаше алокатори на регистрите, но никой повече не успя да направи такова нещо.
Проблемата е, че когато добавяш методи, подлежащи на инлайнване, увеличавайки и увеличавайки областта на инлайнването, наборът от използвани стойности бързо надвишава броя на регистрите, и е необходимо да се направи спил. Критичното ниво обикновено настъпва, когато алокаторът се предаде, и един добър кандидат за спил е равен на друг, ти спилваш някакви наистина странни неща. Стойността на инлайнването тук е, че губиш част от оуверхеда, оуверхеда на повиквания и запазване, можеш да виждаш стойностите вътре и можеш да ги оптимизираш допълнително. Цената на инлайнването е, че се създава голямо количество живи стойности, и ако твоят алокатор на регистри спилне повече, отколкото е нужно, веднага губиш. Затова повечето алокатори имат проблем: когато инлайнването премине известна граница, започва да спилва всичко по света и производителността може да бъде изхвърлена в тоалетната. Тези, които реализират компилатора, добавят някакви хевристики: например, за да спрат инлайнването, започвайки от достатъчно голям размер, тъй като алокациите всичко развалят. Така се образува излом в графика на производителността – ти инлайнваш, инлайнваш, производителността бавно се увеличава – и после бух! – тя пада стремително с домкрат, защото си инлайннал твърде много. Така беше до появата на Java. Java изисква много повече инлайнване, затова трябваше да направя своя алокатор много по-агресивен, за да се изравнява, а не да пада, и ако инлайннеш твърде много – той започва да спилва, но все пак настъпва моментът „няма повече спилване“. Това е интересно наблюдение и дойде при мен просто от нищото, неочевидно, но добре се изплати. Занимах се с агресивно инлайнване и това ме отведе на места, където производителността на Java и C вървят рамо до рамо. Те наистина са близки – мога да пиша код на Java, който ще бъде значително по-бърз от кода на C и така нататък, но в средния, голям план на нещата, те са сравнително равни. Мисля, че част от заслугата е на алокатора на регистри, който ми позволява да инлайнвам максимално стритно. Аз просто инлайнвам всичко, което виждам. Въпросът тук е дали алокаторът работи добре, получава ли се разумно работещ код в резултат. Това беше голямо предизвикателство: да разбера всичко това и да накарам да заработи.
Няколко думи за разпределение на регистри и многопоточност
Владимир: Проблеми като разпределението на регистри изглеждат като вечна тема. Интересно е, дали е имало идеи, които изглеждали обещаващи, но после се провалили в практиката?
Клиф: Разбира се! Разпределението на регистри е област, в която, за да решиш NP-пълната задача, се опитваш да подбереш някакви хевристики. И никога няма да успееш да постигнеш перфектно решение, нали? Просто е невъзможно. Вижте, компилацията Ahead of Time - тя също работи слабо. Става въпрос за средни случаи. За типичната производителност, така че можеш да отидеш и да измериш нещо, което считаш за добра типична производителност - в крайна сметка работиш за нейното подобрение! Разпределението на регистри е тема, изцяло посветена на производителността. Как само имаш първия прототип, който работи и боядисва нужното, започва работата по производителността. Трябва да се научиш как да измерваш добре. Защо е важно? Ако имаш ясни данни, можеш да разгледаш различни участъци и да видиш: ага, това помогна тук, но там всичко се провали! Появяват се добри идеи, добавяш нова хевристика и изведнъж всичко започва да работи средно малко по-добре. Или не започва. Имах много случаи, в които се борехме за пет процента производителност, които отличаваха нашата разработка от предишния разпределител. И всеки път изглеждаше така: тук спечелихме, там загубихме. Ако имаш добри инструменти за анализ на производителността, можеш да намериш губещите идеи и да разбереш защо губят. Може би е по-добре да оставиш всичко както е, а може и сериозно да се заемеш с финото настройване, или да отидеш и да поправиш нещо друго. Това е цял набор от неща! Направих този страхотен хак, но имам нужда и от това, и от това, и от това - и тяхната комбинирана комбинация дава някои подобрения. А отделните части могат да се провалят. Такава е природата на работата по производителността на NP-пълни задачи.
Владимир: Съществува усещането, че неща като боядисването в разпределителите – това е вече решен проблем. Е, за вас изглежда решен, съдейки по това, което разказвате, така че струва ли си изобщо…
Клиф: Тя не е разрешена като такава. Ти трябва да я превърнеш в "разрешена". Има трудни задачи, които трябва да се решават. Когато това стане, идва времето за работа върху производителността. Към тази работа трябва да се подходи съответно – да се правят бенчмаркове, да се събират метрики, да се обясняват ситуации, когато при връщане към предишна версия твоя стар хак отново започва да работи (или обратно, спира да работи). И не бива да се отказваш, докато не постигнеш нещо. Както вече споменах, ако има страхотни идеи, които не са проработили, в областта на алокацията на регистри идеите са почти безкрайни. Можеш, например, да четеш научни публикации. Въпреки че в момента тази област се движи много по-бавно и е по-ясна от времето на младостта си. Въпреки това в тази област работи цяла безкрайност от хора и всички техни идеи си заслужава да бъдат изпробвани, всички те чакат своя час. И не можеш да кажеш колко добри са те, ако не опиташ. Колко добре се интегрират с всичко останало в твоя алокатор, тъй като алокаторът прави много неща, и някои идеи в твоя конкретен алокатор няма да проработят, а в друг алокатор – със сигурност. Основният начин да спечелиш за алокатора е да изкараш бавните неща извън основния път и да принудиш сплитането по границите на бавните пътища. Следователно, ако искаш да стартираш GC, да отидеш по бавен път, да деоптимизираш, да хвърлиш изключение, всичко в такъв дух – знаеш, че тези неща са относително редки. И те наистина са редки, проверявах. Правиш допълнителна работа и благодарение на това изчезват много ограничения по тези бавни пътища, но това не е много важно, защото те са бавни и рядко се използват. Например, нулев указател – той никога не се случва, нали? Трябва да имаш няколко пътя за различни неща, но те не трябва да се смесват на основния.
Владимир: Какво мислите за многопоточността, когато ядрото е хиляди? Това полезно ли е?
Клиф: Успехът на GPU показва, че е доста полезно!
Владимир: Те са доста специализирани. А какво да кажем за процесорите с общо предназначение?
Клиф: Е, това беше бизнес моделът на Azul. Отговорът дойде още в ерата, когато хората много обичаха предсказуемото представяне. Тогава беше трудно да се пише паралелен код. Моделът на кодиране H2O се мащабира добре, но не е универсален модел. По-скоро е малко по-общ, в сравнение с използването на GPU. Говорим ли за сложността на разработката на нещо такова или за сложността на използването му? Например, интересен урок ми даде Azul, доста неочевиден: малките кешове са ОК.
Най-голямото предизвикателство в живота
Владимир: Какво ще кажете за нетехническите предизвикателства?
Клиф: Най-голямото предизвикателство за мен беше да не съм… добър и мил с хората. В резултат на това, постоянно се оказвах в крайно конфликтни ситуации. Ситуации, в които знаех, че всичко върви наопаки, но не знаех как да напредвам в решаването на тези проблеми и не успявах да се справя с тях. Множество дългогодишни проблеми, продължаващи десетки години, се появиха именно по този начин. Фактът, че в Java има компилатори C1 и C2 – е пряко следствие от това. Че в Java десетилетия наред нямаше многостепенна компилация – също е пряко следствие. Очевидно е, че ни трябваше такава система, но не е очевидно защо я нямаше. Имах проблем с един инженер… или с група инженери. Веднъж, когато започнах работа в Sun, бях… Добре, не само тогава, всъщност винаги имам собствено мнение за всичко. И считах за истина, че мога просто да взема тази своя истина и да я изрека директно. Тем повече, че бях шокиращо прав през по-голямата част от времето. И ако такъв подход не ти харесва… особено ако очевидно си в грешка и правиш глупост… Все пак, малко хора можеха да понесат такава форма на общуване. Въпреки че някои можеха, например, аз. През живота си се ръководих от меритократични принципи. Ако ми покажеш нещо неправилно, веднага ще се обърна и ще кажа: ти каза глупост. В същото време, разбира се, се извинявам и всичко такова, отбелязвам заслугите, ако изобщо има такива, и предприемам други правилни действия. От друга страна, съм шокиращо прав шокиращо голям процент от времето. И това не работи особено добре в отношенията с хората. Не се опитвам да бъда мил, а поставям въпроса остро. „Това никога няма да заработи, защото причина, причина и причина“. И те реагират: „Ох!“ Имаше и други последици, които вероятно е по-добре да пропуснем: например, довели до развод с жена ми и десет години депресия след това.
Челленджът е борба с хората, с техните восприятия за това какво можеш или не можеш да правиш, какво е важно и какво не. Имаше много предизвикателства относно стила на кодиране. Все още пиша много код, а по това време ми се наложи дори да забавя темпото, защото правех твърде много паралелни задачи и ги изпълнявах лошо, вместо да се фокусирам върху едно. Оглядайки се назад, написах половината от кода на екипа Java JIT, екипа C2. Следващият по бързина програмист пишеше два пъти по-бавно, следващият – още два пъти по-бавно и това беше експоненциален спад. Седмият човек в този ред беше много, много бавен – така винаги се получава! Погледнах много код. Наблюдавах кой какво пише, без изключение, загледах се в техния код, преглеждах всеки от тях и все още продължавах да пиша повече, отколкото който и да е от тях. С хората този подход не работи особено добре. Някои не го харесват. И когато не могат да се справят с това, започват всякакви оплаквания. Например, веднъж ми казаха да спра да пиша код, защото пиша твърде много код, и това поставяше екипа в риск, а за мен всичко това звучеше като шега: човек, ако цялата останала команда изчезне и аз продължа да пиша код, ти ще загубиш само половината от екипа. От другата страна, ако продължа да пиша код и ти загубиш половината от екипа – това звучи като много лошо управление. Никога не съм се замислял за това, никога не съм го споменавал, но все пак беше някъде в главата ми. В задните части на съзнанието ми се въртеше мисълта: "Да не си се шегуваш?". Така че, най-голямата ми проблема бях аз и моите отношения с хората. Сега разбирам себе си много по-добре, дълго време бях тимлидер на програмисти и сега директно казвам на хората: знаеш ли, аз съм такъв какъвто съм и ще трябва да се справиш с мен – нищо против ли е, ако стоя тук? И когато те започнаха да се справят с това, всичко проработи. Наистина, не съм нито лош, нито добър, нямам никакви лоши намерения или егоистични желания, просто това е моята същност и трябва да се живее с това.
Андрей: Напоследък всички говорят за самосъзнание за интровертите, и за софт-скиловете. Какво може да се каже за това?
Клиф: Да, това беше разбиране и урок, който извлекох от развода с жена ми. Какво извлекох от развода – това е осъзнаване на себе си. Така започнах да разбирам и другите хора. Да разбирам как работи това взаимодействие. Това доведе до открития едно след друго. Появи се осъзнаването кой съм и какво представлявам. Какво правя: или съм загрижен за задачата, или избягвам конфликти, или нещо друго – и подобно ниво на самоосъзнание наистина помага да се контролирам. След това всичко става много по-лесно. Една неща, която открих не само при мен, но и при други програмисти – невъзможността да вербализират мислите си, когато са в състояние на емоционален стрес. Например, седиш и кодираш, в състояние на поток, и изведнъж идват при теб и започват да викат в истерия, че нещо не работи, и сега ще предприемат крайни мерки. И не можеш да произнесеш дума, защото си в състояние на емоционален стрес. Придобитите знания позволяват да се подготвиш за този момент, да го изтърпиш и да преминеш към план за отстъпление, след което вече можеш да направиш нещо. Така че да, когато започнеш да осъзнаваш как всичко това работи – това е огромно събитие, променящо живота.
Аз самият не успях да намеря правилните думи, но запомних последователността на действията. Същността е, че тази реакция е толкова физическа, колкото и вербална, и ти е необходимо пространство. Такова пространство, в дзенския смисъл. Именно това трябва да се обясни, а след това веднага да се отстъпи настрана – чисто физически да се отстъпи. Когато мълча с думи, мога да обработя ситуацията от емоционална гледна точка. С напредването на адреналина до мозъка, премества те в режим „бий или бягай“, вече не можеш нищо да кажеш, не – сега си идиот, инженер за битие, неспособен на достоен отговор или поне да спре атаката, и атакуващият може свободно да атакува отново и отново. Първо трябва отново да станеш себе си, да възстановиш контрола и да излезеш от режима „бий или бягай“.
И за това е необходимо вербално пространство. Просто свободно пространство. Ако изобщо трябва нещо да се каже, може да се заяви именно това и след това да се отиде и наистина да се намери "пространство": да се разходите в парка, да се заключите в душа – не е важно. Най-важното е да се откъснете временно от ситуацията. В момента, в който поне за няколко секунди се откъснете, контролът се връща, започвате да мислите трезво. "Добре, аз не съм някакъв идиот, не правя глупави неща, аз съм доста полезен човек". В момента, в който успеете да убедите себе си, е време да преминете към следващия етап: да разберете какво се е случило. Нападнати сте, атаката е дошла от незапомнено място, това е бил нечестен подъл капан. Това е лошо. Следващата стъпка е да осъзнаете защо на нападача му е било нужно това. Наистина, защо? Може би, защото той е в ярост? Защо е в ярост? Например, защото сам е направил грешка и не може да поеме отговорност? По този начин трябва внимателно да обработите цялата ситуация. Но за това е нужно пространство за маневри, вербално пространство. Първата стъпка е да се скъса вербалният контакт. Да се избегне обсъждането на думи. Да се отменя, да се отиде колкото се може по-скоро. Ако това е телефонен разговор – просто затворете телефона – това е умение, което научих от общуването със старата си жена. Ако разговорът не води до нещо добро, просто кажи "довиждане" и затвори. От другата страна на телефона: "бла-бла-бла", ти отговаряш: "ага, чао!" и затвараш телефона. Просто прекъсваш разговора. Пет минути по-късно, когато способността ти да мислиш рационално се върне, когато се охладиш, става възможно да обмислиш какво всъщност се е случило и какво следва. И да започнеш да формулираш обмислен отговор, а не просто да реагираш на емоции. За мен пробив в самосъзнанието стана именно това, че в случай на емоционален стрес не мога да говоря. Да изляза от това състояние, да помисля и да планирам как да отговоря и да компенсирам проблемите – ето това са правилните стъпки в случай, че не можеш да говориш. Най-простият начин е да избягаш от ситуацията, в която проявяваш емоционален стрес и просто да спреш да участваш в този стрес. След това придобиваш способността да мислиш, когато можеш да мислиш, появява се възможност да говориш и така нататък.
Между прочим, в суде адвокат противоположной стороны пытается сделать это с вами — теперь понятно, почему. Потому что он может подавить вас до такого состояния, что вы не сможете даже произнести свое имя. В самом прямом смысле, вы не сможете говорить. Если это происходит с вами и вы знаете, что окажетесь в месте, где происходят словесные баталии, таком как суд, то можно прийти со своим юристом. Юрист защитит вас и прекратит словесную атаку, сделает это вполне легально, и вы вернёте утраченное дзен-пространство. Например, мне несколько раз нужно было позвонить семье, судья отнесся к этому дружелюбно, но адвокат противоположной стороны кричал на меня, и я даже не мог вставить ни слова. В таких случаях для меня лучше всего работает посредник. Посредник прекращает всё это давление, льющееся на вас непрерывным потоком, вы находите необходимое дзен-пространство, и вместе с ним возвращается способность говорить. Это целая область знаний, в которой нужно многое изучить и открыть в себе, и всё это превращается в высокоуровневые стратегические решения, разные для разных людей. У кого-то нет вышеописанных проблем, как правило, их нет у людей, профессионально занимающихся продажами. Все эти люди, зарабатывающие на жизнь словами — известные певцы, поэты, религиозные деятели и политики, всегда имеют, что сказать. У них нет таких проблем, а у меня они есть.
Андрей: Това беше... неочаквано. Отлично, вече казахме доста и е време да приключим това интервю. Определено ще се срещнем на конференцията и ще можем да продължим този диалог. Срещнем се на Hydra!
Можете да продължите разговора с Клифф на конференцията Hydra 2019, която ще се проведе на 11-12 юли 2019 г. в Санкт-Петербург. Той ще дойде с презентация . Билетите могат да бъдат закупени .
Източник: habr.com
