Справжні вбивці С++
Підписуйтеся на Telegram-канал «DOU #tech», щоб не пропустити нові технічні статті
Всім привіт! Я Саша Каленюк. Останні сім років я працюю на компанію Матеріалайз, де ми разом із командою алгоритмістів пишемо крутезну біблотеку для 3D-друку. В цілому, переважно на С++ я пишу вже майже сімнадцять років і майже сімнадцять років намагаюсь позбутися цієї шкідливої звички.
Все почалося у 2005 році з рушія для космічного симулятора. Було в цьому рушієві все, що в 2005 році було в С++. І асемблерні вставки, і тризірковий код, і вісім шарів наслідування. Макроси ще були. Ітератори усюди, як заповідав Степанов, і метакод, як навчав Алекснадреску. Все було. Не було тільки відповіді на найголовніше питання: навіщо туди все це напхали?
Втім, згодом навіть і на це питаня відповідь знайшлася. Виявилось, що код цей до мене писали вже вісім років. І п’ять різних команд. Кожна нова команда заходила на проект, обкладала матюками попередників, брала за основу свіжу моду в світі С++ і загортала попередній код у новий, додаючи при тому не більше ніж
Спершу я намагався якось розібратися в усьому тому зоопарку, робив якісь таски, щось правив. В принципі, якось косо-криво, але виходило. А потім мене спитали: «Не хочеш попереписувати шейдерний код з асемблера на GLSL?» Я подумав, чорт його знає, що то за GLSL, але гірше за С++ не буде, і сказав, що хочу. Гірше не стало.
Так і повелося. Щоразу, як треба було щось написати не на плюсах, я казав, що хочу, і писав. Писав на чистому Сі, на MASM32, на C#, на PHP, Delphi, ActionScript, JavaScript, Erlang, Python, Haskell, D, Rust, навіть для InstallShield якісь скрипти писав. I на VisualBasic для екселя, і на bash, а на додачу і ще на декількох пропрієтарних мовах, про які навіть розповідати не можу. Навіть колись було діло, робив власну мову для скриптування 2D-квестів, хоча фактично саму граматику створював гейм-дизайнер, я просто перетворював його забаганки на робочий продукт.
І так вже майже сімнадцять років. Пробую щось нове, але щоразу повертаюсь до С++, при тому вважаю, що відважувати молоде покоління від цього жахливого створіння комітету — це моральний обов’язок досвідчених програмістів, які вже втратили пів життя на перемовини з його компіляторами. Так само як, наприклад, відваджувати спортивну молодь від паління є моральним обов’язком тих, хто вже помирає від раку легенів.
Тож в чому справа? Звідки цей стокгольмський синдром? А справа в тому, що жодна з мов, особливо з так званих «вбивць С++», тобто тих, які створювались, щоб цю С++ замістити, жодної справді важливої переваги над С++ у сучасному світі не надає. Здебільшого, всі нові ідеї у комп’ютерному мовознавстві зводяться до того, щоб тримати програміста за руку, щоб він біди не накоїв. Що непогано, але створювати якомога більше продукту якомога менш кваліфікованою силою — це проблематика двадцятого, а не двадцять першого сторіччя. Хмарні обчислення все перевернули з голови на ноги.
В двадцятому сторіччі як було? Маєш ідею, загортаєш її у юайку, продаєш як десктопний продукт. Продукт лагає — не страшно, за два роки коп’ютери підростуть, тоді не лагатиме. Головне — якнайшвидше вийти на ринок, наробити фічей і бажано одразу без багів. І отут так, якщо компілятор не дає програмістам наробити багів — то це, очевидно, дуже круто і економічно вигідно, бо інакше ти платиш програмістам за час, який вони витрачають на баги замість фічей.
Тепер не так. Маєш ідею — загортаєш її в апішку і підіймаєш в клауді. Тобі платять не за диск із програмою, а тільки за корисну роботу, яку вона робить. А за те, що в тебе щось лагає, тепер платить не умовний Гордон Мур зі своїм легислативним аппаратом, а ти сам. Буквально сплачуєш рахунки умовному амазону за власну безумовну неефективність.
І раптом виявляється, що жоден вбивця С++, навіть ті, які я щиро люблю і поважаю, на кшалт D, Rust, і Julia, не допомагає вирішити головну проблему двадцать першого сторіччя. Вони не допомагають тобі писати швидкий і економний код. Ну, принаймні код швидший і економніший, ніж на С++. Вони всі занадто однакові, щоб давати якісь суттєві переваги одне перед одним. Rust, Julia, Clang навіть використовують один і той самий бекенд. Не можна виграти автоперегони, якщо їдеш до фінішу тим самим автобусом, що і решта учасників.
А в нашому з вами спільному становищі, економний код — це не тільки менші рахунки, а ще і менші відчислення на російську воєнщину. Бо чиїм газом харчуються станції, що живлять датацентри в Дюсельдорфі і Франкфурті, ми ж всі розуміємо.
То що ж тоді допомагає вирішувати головну проблему двадцять першого сторіччя, якщо не новомодні мови програмування? А от про це зараз ми і поговоримо.
Вбивця 1. Spiral
Давайте перевіримо, як працює ваша інтуіція. Що працює швидше, стандартна функція сінуса чи його поліноміальна модель?
auto y = std::sin(x); // vs. y = -0.000182690409228785*x*x*x*x*x*x*x +0.00830460224186793*x*x*x*x*x -0.166651012143690*x*x*x +x;
Друге питання, що швидше: використувати стандартні логічні операції для порівняння з однобітним значенням чи піти на переступ і забабахати арифметичну мікрооптимізацію?
if (xs[i] == 1 && xs[i+1] == 1 && xs[i+2] == 1 && xs[i+3] == 1) // vs. inline int sq(int x) { return x*x; } if(sq(xs[i] - 1) + sq(xs[i+1] - 1) + sq(xs[i+2] - 1) + sq(xs[i+3] - 1) == 0)
Ну і наостанок, що краще для сортування триплетів даблів: своп-сорт чи індекс-сорт?
if(s[0] > s[1]) swap(s[0], s[1]); if(s[1] > s[2]) swap(s[1], s[2]); if(s[0] > s[1]) swap(s[0], s[1]); // vs. const auto a = s[0]; const auto b = s[1]; const auto c = s[2]; s[int(a > b) + int(a > c)] = a; s[int(b >= a) + int(b > c)] = b; s[int(c >= a) + int(c >= b)] = c;
Якщо ви швидко і однозначно видповіли на всі питання, то інтуіція в вас працює препогано. Зовсім не відчуваєте засадки. Жодне питання такого штибу не має однозначної відповіді без чітко зазначеного контексту. А мої питання — так і подавно.
Яка машина виконуватиме код? Який компілятор його збиратиме? Які налаштування компілятора? Лише знаючи все це, можна пробувати прогнозувати, а краще навіть не пробувати, а міряти швидкодію кожного конкретного рішення.
У випадку першого питання, поліноміальна модель працює в три рази швидше за стандартний синус, якщо запускати її на Intel® Core™ i7-9700F CPU @ 3.00GHz; і збирати компілятором clang 11, із флагами -O2 -std=c++14 -march=native.
Але якщо її же запускати на GeForce GTX 1050 Ti Mobile збираючи nvcc з флагом —use_fast_math, то стандартний синус буде в десять разів швидшим за модель! Модель, звісно, можна значно пришвидшити: позбутися даблів і завернути схемою Горнера, але навіть так беззаперечної переваги над стандартним синусом не отримати.
Наступне питання теж однозначної відповіді не має. На вищезгаданому Інтелі, арифметична мікрооптимізація має сенс і, мабуть, навіть право на життя. Вона пришвишує код в два рази. Але на ARMv7 з аналогічним компілятором і флагами, стандартні логічні операції переганяють мікрооптимізацію на 25%.
І третє — так само. На Інтелі в три рази швидше індекс-сорт, на Джі-форсі — теж в три рази, але своп-сорт.
Отже, всі наші мікрооптимізації, які ми так любимо, можуть давати виграш, а можуть і програш. Було б чудово, якби компілятор міг підкладати те чи інше рішення в залежності від того, що було б вигідніше саме зараз. Але він такого робити не буде. Я спеціально підібрав приклади, де компілятору для прийняття правильного рішення не вистачає певних спеціальних знань.
У першому прикладі компілятор не тільки не знає, що вказаний поліном має наближувати синус, а навіть не знає, чи дозволяється нам та втрата точності, яка йде довіском з таким наближенням. У С++ немає способу розслабити вимоги до точності, окрім як флагом на кшалт —use_vsrata_math і то на рівні об’єкту трансляції, і то без жодної конкретики.
У другому прикладі, компілятор не знає, що ми порівнюємо саме нулики-одинички, а не справжні інти, отже він в принципі не має права використовувати запропоновану мікрооптимізацію. А ми не можемо ніяк йому наші наміри підказати.
У третьому прикладі компілятор міг би здогадатися підставити вигідне сортування, так, насправді, std::sort теж знає, коли використовувати мердж-сорт, а коли квік. Але обидва рішення розписані сильно детально, аби компілятор міг розпізнати їх як сортувалки.
І тут ми підходимо до Spiral. Це спільний проєкт університету Карнегі Мелон і Федеральної вищої технічноїї школи Цюріха. Якщо в двох реченнях: спеціалістів з обробки цифрових сигналів задовбало переоптимізовувати вручну свої улюблені алгоритми на кожну нову залізяку, тож вони написали програму, яка робить цю марудну роботу за них. Програма приймає на вхід високорівневий опис алгоритма і, що важливо, детальний опис архітектури залізяки, під яку їй треба оптимізовувати, і оптимізовує.
Важливий момент. Оптимізовує, тобто оптимізує в суто математичному сенсі. Шукає глобальний оптимум у спільному факторному просторі інваріантів алгоритму і архітектурних обмежень цільового обчислювального пристрою. Це те, що компілятори не вміють робити в принципі. Компілятор не шукає оптимального рішення, він використовує евристики, яких його навчили розробники, щоб робити результуючий код швидше, а це не те саме. Компілятор, замість того, щоб використовувати сильні сторони машини і робити математичний пошук оптимума, емулює програміста на асемблері. Ну, хороший компілятор емулює хорошого програміста, і на тому спасибі.
Spiral — проєкт дослідницький, обмежений за бюджетом і за напрямком діяльності. Але результати показує вражаючі. Так, на швидкому перетворені Фур’є, їхнє оптимізоване рішення більш ніж в два рази обганяє і версію MKL, і FFTW. На Інтелі. Щоб було легше сприймати масштаб перемоги MKL — це Math Kernel Library від самого Intel, тобто від компанії, яка найкраще в світі знається на мікрооптимізаціях під власну архітектуру. А FFTW, яку ще часто розшифровують як «Fastest Fourier Transform in the West», — вузкоспеціалізована бібліотека від людей, які найкраще в світі знаються саме на перетворенні Фур’є.
Коли технологію остаточно комерціалізують, постраждає не тільки С++, а і Rust, і Julia. Навіть шановному професорові Фортранові стане непереливки. Навіщо потрібен С++, якщо можна створювати алгоримти мовою високого рівня, а працюватимуть вони в два рази швидше, ніж якби вони були написані на С++?
Вбивця 2. Numba
Найкраща в світі мова — це мова, яку ти вже знаєш. Доволі довго цією мовою для більшості програмістів у всествіті була Сі. Що, до речі, пояснює, чому багато років поспіль Сі очолював індекс TIOBE за популярністю, а разом з ним в п’ятірці лідерів тусувалися сіподібні С#, Java і С++. Але минулого року сталося нечуване! Сі скинули з п’єдестала.
Мовою-вискочкою, мовою-іконокластом став Python, або «Пітон» українською. Він нарощував свою популярність декадами, повільно, але безупинно. Ставав мовою року у 2007, 2010, 2018, 2020 і 2021 роках. Для порівняння: С++ була мовою року за версією TIOBE у 2003 році, і все. Це був її перший і останній раз.
«Але ж Пітон повільний!», — скажете ви, і будете термінологічно неправі. Мова не може бути повільною, як не може бути повільним, наприклад, баян. Як швидкість баяна залежить від того, хто на ньому грає, так і «швидкість мови» залежить від ефективності компілятора.
«Але ж Пітон не компілює!», — заперечите ви, і знову сядете у калюжу. Я і не казав, що компілювати має саме Пітон. Дозвольте проілюструвати.
Був у мене проєкт. Алгоритм симуляції 3D-друку, який ми написали спершу на Пітоні, потім переписали на С++, бо «Пітон же повільний», потім портували на GPU, а потім підключився я і місяць займався тільки тим, що налаштовував білд під Лінукс, валідував непітонівські імплементації пітонівською, мікрооптимізовував код під Tesla M60, бо з того, що надавав клауд-провайдер, саме у цій картці було найліпше співвідношення ціни аренди і швидкодії. Коротше, займався всім чим завгодно, але не власне алгоритмом.
А трохи згодом дзвонить мені студент, якого у нас в Бремені взяли на підробіток, і питає: «Кажуть, ти знаєшся на гетерогенщині, допоможи експериментальний алгоритм на GPU розпаралелити?». Ха! Ну я йому почав розказувати за CUDA, за CMake, за білд на Лінукс, за тести, за оптимізацію, розказував ледь не довше, ніж розбирався. Він вислухав дуже ввічливо всю цю ахінею, німець все-таки, хоча сам з Непалу, але наприкінці спитав: «Це все дуже цікаво, дякую, але я от в Пітоні перед функцією пишу @cuda.jit, а воно мені щось про масиви каже і не заводиться. Що то може бути?».
Я не підказав. Не знав. Втім, він сам розібрався, виявилось, що він замість масивів NumPy передав штатні пітонівські листи, а Numba з ними дійсно не заводиться. Розібрався і за кілька днів розпаралелив свій алгоритм. Під GPU. На Пітоні. Хочеш запускати на Лінуксі — будь ласка! Хочеш валідувати пітонівською імплементацією — так це вона і є! Хочеш оптимізувати під M60 — Numba збере код найоптимальніше саме під ту архітектуру, на якій запускатиметься алгоритм, бо в неї Just-in-time компілятор.
Отакої! Виявляється, що я, старий хрест-хрестоносець, згаяв місяць часу незрозуміло на що, а студент-пітоніст зробив мій обсяг работи за два-три дні. І тільки тому не за півгодини, що в перший раз паралелив із Numba. Та що ж то за Numba така? У чому магія?
Жодної магії. Просто пітонівські декоратори перетворюють будь-який пітонівський код на абстратне синтаксичне дерево. Що хочеш з тим деревом — те і роби. Numba — це така пітонівська бібліотека, яка хоче компілювати твій код будь-яким бекендом і під будь-яку платформу. Хочеш масивного паралелизму на інтелівському CPU — всі принади LLVM в твоєму розпоряджені. Хочеш того самого на нвідійвській GPU — будь-ласка, Numba вміє в CUDA.
@cuda.jit def matmul(A, B, C): """Perform square matrix multiplication of C = A * B.""" i, j = cuda.grid(2) if i < C.shape[0] and j < C.shape[1]: tmp = 0. for k in range(A.shape[1]): tmp += A[i, k] * B[k, j] C[i, j] = tmp
От саме ця штука і здійснює компіляцію. Так, в теорії обігнати Numbою С++ не вийде, теоретична швидкодія в них однакова. Однаковий же бекенд. Але на практиці переваги JIT-компіляції все-таки себе показують. Був у нас випадок, коли, наприклад, ми переписували алгоритм із Пітона на С++ заради швидкості, а він став в три рази повільнішим, тому що через деяких конкретних клієнтів ми змушені були підтримувати бінарні релізи бібліотеки із допотопним SSE2.
Звісно, добре було б мати гарантовану перевагу, як у випадку Spiral. Але то все-таки ще дослідницький проєкт, його слава ще не настала. А Пітон разом із Numba досить впевнено, хоча і повільно, вбиває С++ прямо зараз, в режимі реального часу. Бо якщо ти можеш писати на Пітоні і мати швидкодію С++, то навіщо тобі С++?
Вбивця 3. ForwardCom
Давайте влаштуємо ще одну вікторину. Перед вами три фрагменти коду. Який з них, чи може які з них, написані на асемблері?
1.
invoke RegisterClassEx, addr wc ; register our window class invoke CreateWindowEx,NULL, ADDR ClassName, ADDR AppName,\ WS_OVERLAPPEDWINDOW,\ CW_USEDEFAULT, CW_USEDEFAULT,\ CW_USEDEFAULT, CW_USEDEFAULT,\ NULL, NULL, hInst, NULL mov hwnd,eax invoke ShowWindow, hwnd,CmdShow ; display our window on desktop invoke UpdateWindow, hwnd ; refresh the client area .while TRUE ; Enter message loop invoke GetMessage, ADDR msg,NULL,0,0 .break .if (!eax) invoke TranslateMessage, ADDR msg invoke DispatchMessage, ADDR msg .endw
2.
(module (func $add (param $lhs i32) (param $rhs i32) (result i32) get_local $lhs get_local $rhs i32.add) (export "add" (func $add)) )
3.
v0 = my_vector // we want the horizontal sum of this int64 r0 = get_len ( v0 ) int64 r0 = round_u2 ( r0 ) float v0 = set_len ( r0 , v0 ) while ( uint64 r0 > 4) { uint64 r0 >>= 1 float v1 = shift_reduce ( r0 , v0 ) float v0 = v1 + v0 }
Якщо ви відповіли, що всі три, вітаю! Ваша інтуіція значно покращилась!
Перший написаний на MASM32. Це макроасемблер із «іфами» і «вайлами», на якому пишуться нативні програми для Windows. Так, навіть не «писалися», а «пишуться». Micosoft дуже шанобливо ставиться до зворотної сумісності, і програми, написані під Win32 API, прекрасно почуваються на сучасних машинах.
Це навіть дещо іронічно. Сі створювався через потребу переносити код UNIX із PDP-7 на PDP-11. Це була мова, яка мала б стати портабельним асемблером, здатним пережити бурхливий розвиток комп’ютерної архітектури семидесятих. Але в двадцять першому сторіччі цей розвиток настільки сповільнився і видохся, що програми на MASM32, які я писав ще на першій роботі, прекрасно збираються і працюють по сьогодні, а от впевненості, що бібліотека, яка ще в минулому році збиралася із CMake 3.18, збереться без жодної запинки із CMake 3.23, в мене ніколи немає.
Другий фрагмент — це Web Assembly. Це навіть не макроасемблер, себто жодних «іфів» і «вайлів» в ньому немає, а радше людиночитабельний код для віртуалної машини всередині вашого браузера. Або навіть не вашого. Будь-якого бразуера.
Тобто код на Web Assembly в принципі не залежить від архітектури вашої залізяки. Цей код абстракний, універсальний, всеядний, називайте як хочете.
Третій фрагмент — найцікавіший. Це ForwardCom — пропозиція асемблера, яку висунув Агнер Фог, вельмивідомий автор мануалів з оптимізації як програм на С++, так і асемблерного коду.
Як із Wasm, wе теж пропозиція навіть не стільки асемблера, скільки універсального набору інструкцій, створеного, аби уможливити навіть не зворотню, а попередню сумісність. Справжнє ім’я ForwardCom — an open forward-compatible instruction set architecture. Іншими словами, це пропозиція мирного договору.
Всі ми знаємо, що найпоширеніші архітектури: x64, ARM і RISC-V мають різні набори інструкцій. Але ніхто не знає гідної причини, чому це так має лишатися. Бо всі сучасні процесори, за винятком, можливо, найпростіших для мікроконтролерів, виконують не код, який їм дають програмісти, а мікрокод, який вони самі видобувають із запропонованого потоку інструкцій. Іншими словами, не тільки M1, а кожен процесор має транслюючий шар для зворотньої сумісності.
І що ж заважає зробити такий самий шар, але для сумісності попередньої? Окрім конфліктуючих амбіцій конкуруючих компаній, абсолютно нічого. Якщо компанії зможуть домовитися між собою, замість того щоб писати шари сумісності для всіх підряд архітектур, ForwardCom поверне програмування на асемблері у мейнстрім. Адже цей шар попередньої сумісності дозволив би прибрати найкритичніший невроз будь-якого програміста на асемблері: «А що, якщо я напишу офігенний код, такий, який випадає написати лише раз на життя, а процессор хоба — і раптом застаріє?»
От із цим шаром — не застаріє.
Ще розвиток програмування на асемблері стримує міф про те, що це дуже складно і через те — непотрібно. Знову таки, пропозиція Фога адресує і цю проблему. Якщо люди вважають, що на асемблері писати складно, а на Сі чомусь легко, хай асемблер буде схожий на Сі. Жодних проблем. Немає жодної поважної причини, з якої сучасний асебмлер мав би виглядати так само, як його прадід в п’ятидесятих.
Ви тільки що самі бачили три зразка асемблера. Жоден з них на «класичний» асемблер не схожий, і не має бути схожий.
Отже, ForwardCom — це асемблер, на якому можна писати теоретично оптимальний нестаріючий код, і який не змушує програміста вчити асемблер в «класичному» розумінні. Тільки машинну архітектуру. У всіх сенсах, це Сі, такий яким він мав би бути.
Якщо вам не вистачає Сі, і хочеться інкапсуляції, наслідування, поліморфізму, ця свята трійця імплементована на TASM, починаючи з четвертої версії. Статичного поліморфізму хочеться — знову таки, типова задача для макроасемблера. Якщо є набор інструкції, прикрутити туди якусь макросистему — справа не архіскладна.
І якщо все це можна мати без С++, то навіщо тоді С++? Хіба що, щоб було, коли шпілите у футбол, поки проєкт білдиться.
Отже, коли все-таки здохне С++
Ми живемо в постмодернову епоху. Ніщо не вмирає, окрім людей. Як не вмерла латина, або не помер COBOL, так і С++ приречена на довічне існування між життям і смертю. С++ остаточно ніколи не здохне, нові технології просто витискатимуть її із мейнстрима на маргінес. Хоча чому власне «витискатимуть»? «Витискають».
Вже зараз моя робота як програміста на С++ починається з Пітона. Я складаю рівняння в SymPy, він їх символьно розв’язує і перетворює на код. Потім я вставляю нагенерований код в С++ навіть не фоматуючи, бо форматом опікується clang-tidy. Потім компіяція, потім тести, потім профайлінг. Останні оптимізації, як данстист під мікроскопом, я роблю не тільки під тестами, а і під дізасемблером, бо інакше доводиться забагато чого вгадувати, а це шкідливо для фантазії.
Якщо поміняти С++ на «не С++», робота моя на 80% лишиться такою самою. Тож може С++ вже на 80% помер?
404 коментарі
Додати коментар Підписатись на коментаріВідписатись від коментарів