«Python — це повільно» та інші забобони людей за сорок
Всім привіт. Я Саша Каленюк, працюю алгоритмістом, дописую книжку Geometry for Programmers, а ще частенько пишу статті на DOU.
Як я був малий, то програмувати було просто. У мене був друг, у друга був комп’ютер, на комп’ютері були лише Бейсик і Асемблер. Хочеш — пиши на Бейсику, швидше напишеш програму, але працювати вона буде повільно. А хочеш — пиши на Асемблері. Писатимеш довше, але програма бігатиме швидко.
Розібратися, чому так, теж було нескладно. Бейсік був інтерпретатором, тобто щоб запустити програму, він мав щоразу проходитись через сорци і буквально інтерпретувати програму рядок за рядком. Якщо в сорцах було «PRINT X», то Бейсік мав знайти змінну із ім’ям «X», потім знайти власну підпрограму, яка називається «PRINT» і виконати цю підпрограму із значенням щойно знайденої змінної.
Асемблер був, ну, асемблером. Він теж свого роду інтерпретував програму, але тільки один раз при власне асемблюванні. Потім програма перетворювалась на машинний код, який виконувався без жодної інтерпретації. Програми, яким потрібна інтерпретація, виконуються повільніше за програми, яким інтерпретація не потрібна. Звісно, якщо ми говоримо про еквівалентні програми.
І за часи Бейсіка і Асемблера програми на них були-таки майже еквівалентними. Бейсик — це імперативна мода без підтримки навіть елементів структурного програмування. Навіть така звична тепер штука як функція була в Бейсику лише патерном: «GOSUB... RETURN». Принципово нічим не кращим за асемблерний «call... ret».
Минуло тридцять років. Мов програмування безліч. Комп’ютери усюди. І незважаючи на повсюдний прогрес, програмувати чомусь геть перестало бути просто.
Багато років мій департамент заробляв, переписуючи на С++ дослідницький код оригінально написаний на Python. Ну, якийсь час вважалося, що Python — це інтерпретатор, і тому там все повільно, а С++ це компілятор, тому все переписане має автоматично літати. Але важко було не помітити, що з кожним роком здобувати якесь пришвидшення одним переписуванням ставало важче і важче. Було помітно, що щось в світі змінюється, хоча не зовсім помітно, що саме. Втім, суспільні упередження завжди відстають від технологічного прогресу, тож ми теж продовжували переписувати.
Але втиху оптимізовували все і переоптимізовували лише щоб якось довести самим собі, що це не безмістовна праця. Бо приходить алгоритм на Python, ми його переписуємо на С ++ рядок у рядок, а він стає втричі повільнішим. Трохи ганебненько. Тож ми переписуємо алгоритм з нуля, вигризаємо ті три рази, додаємо ще кількадесят процентів, і рапортуємо про успіх. Насправді, робити це не так складно як здається, бо дослідники взагалі не паряться оптимізацією, в їхньому коді завжди можна знайти, що пришвидшити.
Втім, всі ці трудовитрати виглядають як якийсь галімий розвод. Ми сповільнюємо код, переписуючи його на С++, тільки щоб потім пришвидшити його, переробляючи власне алгоритм. Чому б не пришвидшити цей алгоритм одразу в Python? Ага! Я знаю чому. Бо ми так не вміємо. Ми знаємо Python достатньо, щоб його читати, але не для того, щоб писати на ньому ультрашвидкі програми.
Так а що там знати?
Бібліотеки
Більшість Python-бібліотек написані на C чи Fortran. Ядро NumPy написано на С; Pandas на Сайтон і С; SciPy на Fortran, С і трохи навіть С++. Немає жодної причини, чому б ці бібліотеки були повільніші за будь-що, написане на C++, Rust або Julia. А от одна причина бути швидшими в них, як не парадоксально, є.
Наша компанія пише водночас і під клауд, і під десктоп. Клієнти, які користуються десктопом, не люблять, коли їх програми раптом перестають працювати. Тож наш дефолтний таргет для десктопного білда досить таки старий. Серйозно старий. Може не як Мафусаїл, але принаймні так, як Нехалем. Так навіть найретроградніші наші клієнти лишаються задоволені, але найпрогресивніші позбавлені навіть розкоші SSE3.
Ну і нічого дивного, що бібліотека, яка займається численними обчисленнями на векторах, і качається із свіжого красивого пітонівського репозиторія, працює швидше ніж схожа бібліотека на С++, але зібрана під узагальнену архітектуру п’ятнадятирічної давнини.
Втішає тільки те, що якщо збирати проєкт спеціально під хмару, то можна налаштувати таргет на саме ту машину, що орендуватимеш в хмарі, і витиснути з неї все, що з неї витискається. І С++ код тоді бігатиме не повільніше за пітонівські бібліотеки. Але і не швидше.
Компілятори
Якщо так подумати, то всі суперечки про те, яка мова швидше, не мають жодного сенсу. Мова — це не компілятор, і не інтерпретатор. Мова — це мова. Лише збірка правил, які визначають, як саме ми кажемо комп’ютеру, що йому робити.
Увесь цей поділ мов на інтерпретатори і компілятори — частина міфології минулого сторіччя, яке в наш час жодного практичного значення не має. В наш час є як інтерпретатори для С, як наприклад IGCC, PicoC, чи CCons, так і компілятори Python. Як just-in-time, наприклад PyPy, так і ahead-of-time, як наприклад Codon.
Codon побудован на LLVM, на тій самій інфраструктурі, що і Rust, Julia або Clang. Код, зібраний Codon, працює плюс-мінус так само, як і код, написаний на будь-якій з вищеназваних.
А ще ми зберегли з минулого сторіччя одразу два міфи про just-in-time компіляцію. Перший — про те, що JIT перевершує AOT через те, що завжди компілює під наявну архітектуру, а отже, і використовує її найоптимальніше. А другий — про те, що сама компіляція під час виконання додає такий штраф на швидкодію, що жодного зиску від JIT практично неможливо побачити.
Проблема цих міфів в тому, що вони обидва грунтуються на правді, але ніяк не допомагають в прийнятті архітектурних рішень. JIT дійсно використовує архітектуру заліза найоптимальніше, що може давати об’єктивну перевагу технології, якщо продукт компіляції призначений запускатися на необмеженій кількості типів пристроїв: від персоналок до наручних годинників. Але якщо ми запускаємо хмарний сервіс, то ми і так знаємо архітектуру заліза, яке замовляємо і можемо оптимізувати код під час AOT-компіляції.
З іншого боку, штраф від JIT-компіляції проявляє себе найбільше, коли ми запускаємо один і той же процесс багато разів. Якщо ми запускаємо, і зрештою компілюємо, нашу програму раз на реліз, що теж цілком реалістичне очікування від хмарного сервісу, то цей штраф розчиняється в поточному навантаженні.
Тобто і в того, і в іншого підходу є свої плюси і мінуси. І тут Python знов виграє. На відміну від С++, Python дозволяє нам обирати технологію компіляції під наші потреби, не переписуючи код. Python може бути як інтерпретатором, так і компілятором. Як JIT, так і AOT.
Numba і кернели
Якщо вже почали за JIT, то треба і продовжувати. Мабуть, найпроривніша технологія у світі ультрашвидкого програмування на Python — це Numba. Це компілятор, але компілятор, який працює в рантаймі, збираючи не всю програму, а лише конкретні шматочки коду, які називаються кернели. Це дає можливість запускати програму в режимі інтерпретації, там же вивчати і відладжувати її, і разом з тим запускати найбільш вимогливі до швидкодії фрагменти коду нативно на всіх ядрах CPU або навіть GPU.
Але це ще не прорив. Це просто зручність. Прорив полягає в тому, що писати компілятори під такі кернели набагато простіше, ніж під повні мови, тим більш такі ускладнені, як С++. Теоретично, ніхто не заважає підключити до Numba бекенд для гуглівського тензорного процессора або навіть фотонного акселератора від Lightmatter. До речі, останні пішли схожим, але трохи іншим шляхом, і викатили власну пітонівську бібліотеку, яка дозволяє керувати акселератором, а також інтегруватися із Pytorch, Tensorflow або ONNX.
Тобто Lightmatter Numba проігнорували, а от NVidia, наприклад, ні. Вони таки надали свій бекенд, отже тепер можна писати кернели на Python і запускати їх на пристроях NVidia із максимальною ефективністю.
Ця модель, коли програма складається із керуючого кода і окремо акселерованих кернелів, дозволяє розширюватися на будь-які пристрої. Це наші двері до світу гетерогенного програмування. Але Numba надає гетерогенному програмуванню ще один вимір, ще одне значення. В кернел-моделі ми можемо збирати різні кернели під один і той же пристрій, але із принципово різними налаштуваннями компілятора. Якщо ми хочемо виграти десь трохи швидкодії і можемо собі дозволити втратити в точності, ми можемо скомпілювати обчислювальний кернел із опцією, наприклад, «-fast-math». Не всю програму, не весь модуль трансляції, а лише один кернел в одному окремому контексті.
Висновки
Python не повільний. І не швидкий. Python — це мова, набір слів і правил. Але існує величезна спільнота, мільйони людей, які прикипіли до цих слів і правил. Їм подобається писати на Python і вони щиро зацікавлені в тому, щоб покращувати весь спектр технологій, які з Python сумісні.
Ця спільнота достатньо велика, щоб залучати як проривні стартапи на кшалт Lightmatter із їх фотонними акселераторами, так і мастодонтів масивно-паралельного програмування, таких як NVidia. Прогрес можна відчути, навіть якщо засунути голову в пісок і десять років підряд повторювати як мантру, що немає компіляторів, окрім-AOT компілятора, і LLVM пророк його. Навіть без фотонних акселераторів, код, написаний на Python, вже буває обганяє аналоги на Julia, C++ або Rust. Не щоразу, але достатньо часто, щоб це було помітно. І на цьому Python не зупиниться.
Я б очікував, що маючи зацікавлену спільноту такого масштабу, у найближчому майбутньому Python позичатиме техніки від спеціалізованих кодогенераторів, таких як Spiral або Herbie, аби генерувати такий ефективний код, який традиційні компілятори в принципі згенерувати не здатні. Зрештою, прикрутити до Python сторонній проєкт і згодувати йому синтаксичне дерево якогось кернела набагато простіше, ніж переписувати з нуля весь LLVM.
74 коментарі
Додати коментар Підписатись на коментаріВідписатись від коментарів