Calea către verificarea tipului a 4 milioane de linii de cod Python. Partea 2

Astăzi publicăm a doua parte a traducerii materialelor despre modul în care Dropbox a organizat controlul tipurilor pentru câteva milioane de linii de cod Python.

Calea către verificarea tipului a 4 milioane de linii de cod Python. Partea 2

Citiți prima parte

Suport de tip oficial (PEP 484)

Am efectuat primele noastre experimente serioase cu mypy la Dropbox în timpul Hack Week 2014. Hack Week este un eveniment de o săptămână găzduit de Dropbox. În acest timp, angajații pot lucra la orice doresc! Unele dintre cele mai faimoase proiecte tehnologice ale Dropbox au început la evenimente ca acestea. Ca rezultat al acestui experiment, am ajuns la concluzia că mypy pare promițător, deși proiectul nu este încă pregătit pentru utilizare pe scară largă.

La acea vreme, ideea standardizării sistemelor de indicații de tip Python era în aer. După cum am spus, din Python 3.0 a fost posibil să se utilizeze adnotări de tip pentru funcții, dar acestea erau doar expresii arbitrare, fără sintaxă și semantică definite. În timpul execuției programului, aceste adnotări au fost, în cea mai mare parte, pur și simplu ignorate. După Hack Week, am început să lucrăm la standardizarea semanticii. Această muncă a dus la apariția PEP 484 (Guido van Rossum, Łukasz Langa și cu mine am colaborat la acest document).

Motivele noastre ar putea fi privite din două părți. În primul rând, am sperat că întregul ecosistem Python ar putea adopta o abordare comună pentru utilizarea indicațiilor de tip (un termen folosit în Python ca echivalent al „adnotărilor de tip”). Acest lucru, având în vedere posibilele riscuri, ar fi mai bine decât utilizarea multor abordări incompatibile reciproc. În al doilea rând, am vrut să discutăm deschis despre mecanismele de adnotare de tip cu mulți membri ai comunității Python. Această dorință a fost parțial dictată de faptul că nu am dori să arătăm ca „apostați” din ideile de bază ale limbajului în ochii maselor largi de programatori Python. Este o limbă tastată dinamic, cunoscută sub numele de „tastare de rață”. În comunitate, la început, o atitudine oarecum suspectă față de ideea de tastare statică nu a putut să nu apară. Dar acest sentiment a scăzut în cele din urmă după ce a devenit clar că tastarea statică nu va fi obligatorie (și după ce oamenii și-au dat seama că este de fapt utilă).

Sintaxa indicii de tip care a fost adoptată în cele din urmă a fost foarte asemănătoare cu ceea ce mypy suporta la acea vreme. PEP 484 a fost lansat cu Python 3.5 în 2015. Python nu mai era un limbaj tipizat dinamic. Îmi place să cred că acest eveniment este o piatră de hotar semnificativă în istoria Python.

Începutul migrației

La sfârșitul anului 2015, Dropbox a creat o echipă de trei oameni care să lucreze la mypy. Aceștia au inclus Guido van Rossum, Greg Price și David Fisher. Din acel moment, situația a început să se dezvolte extrem de rapid. Primul obstacol în calea creșterii lui mypy a fost performanța. După cum am sugerat mai sus, în primele zile ale proiectului m-am gândit să traduc implementarea mypy în C, dar această idee a fost eliminată de pe listă pentru moment. Am fost blocați să rulăm sistemul folosind interpretul CPython, care nu este suficient de rapid pentru instrumente precum mypy. (Nici proiectul PyPy, o implementare alternativă a Python cu un compilator JIT, nu ne-a ajutat.)

Din fericire, câteva îmbunătățiri algoritmice ne-au venit în ajutor aici. Primul „accelerator” puternic a fost implementarea verificării incrementale. Ideea din spatele acestei îmbunătățiri a fost simplă: dacă toate dependențele modulului nu s-au schimbat de la rularea anterioară a mypy, atunci putem folosi datele stocate în cache în timpul rulării anterioare în timp ce lucrăm cu dependențe. Ne trebuia doar să efectuăm verificarea tipului pe fișierele modificate și pe fișierele care depindeau de ele. Mypy a mers chiar puțin mai departe: dacă interfața externă a unui modul nu s-a schimbat, mypy a presupus că alte module care au importat acest modul nu trebuie să fie verificate din nou.

Verificarea incrementală ne-a ajutat foarte mult atunci când adnotăm cantități mari de cod existent. Ideea este că acest proces implică de obicei multe rulări iterative ale mypy, pe măsură ce adnotările sunt adăugate treptat la cod și îmbunătățite treptat. Prima rulare a mypy a fost încă foarte lentă, deoarece avea o mulțime de dependențe de verificat. Apoi, pentru a îmbunătăți situația, am implementat un mecanism de cache la distanță. Dacă mypy detectează că memoria cache locală este probabil să fie învechită, descarcă instantaneul actual al memoriei cache pentru întreaga bază de cod din depozitul centralizat. Apoi efectuează o verificare incrementală folosind acest instantaneu. Acest lucru ne-a făcut încă un pas mare către creșterea performanței mypy.

Aceasta a fost o perioadă de adoptare rapidă și naturală a verificării de tip la Dropbox. Până la sfârșitul anului 2016, aveam deja aproximativ 420000 de linii de cod Python cu adnotări de tip. Mulți utilizatori au fost entuziasmați de verificarea tipului. Tot mai multe echipe de dezvoltare foloseau Dropbox mypy.

Totul arăta bine atunci, dar mai aveam multe de făcut. Am început să efectuăm sondaje interne periodice ale utilizatorilor pentru a identifica zonele cu probleme ale proiectului și pentru a înțelege ce probleme trebuie rezolvate mai întâi (această practică este folosită și astăzi în companie). Cele mai importante, după cum a devenit clar, au fost două sarcini. În primul rând, aveam nevoie de mai multă acoperire de tip a codului, în al doilea rând, aveam nevoie de mypy pentru a funcționa mai rapid. Era absolut clar că munca noastră de a accelera mypy și de a-l implementa în proiectele companiei era încă departe de a fi finalizată. Noi, pe deplin conștienți de importanța acestor două sarcini, ne-am propus să le rezolvăm.

Mai multă productivitate!

Verificările incrementale au făcut mypy mai rapid, dar instrumentul nu a fost încă suficient de rapid. Multe verificări incrementale au durat aproximativ un minut. Motivul pentru aceasta a fost importurile ciclice. Acest lucru probabil nu va surprinde pe nimeni care a lucrat cu baze de cod mari scrise în Python. Aveam seturi de sute de module, fiecare dintre acestea importand indirect pe toate celelalte. Dacă orice fișier dintr-o buclă de import era schimbat, mypy trebuia să proceseze toate fișierele din bucla respectivă și, adesea, orice module care importau module din bucla respectivă. Un astfel de ciclu a fost infama „încurcătură de dependență” care a cauzat multe probleme la Dropbox. Odată ce această structură conținea câteva sute de module, în timp ce a fost importată, direct sau indirect, multe teste, a fost folosită și în codul de producție.

Am luat în considerare posibilitatea de a „descurca” dependențe circulare, dar nu am avut resursele pentru a o face. Era prea mult cod cu care nu eram familiarizați. Drept urmare, am venit cu o abordare alternativă. Am decis să facem mypy să funcționeze rapid chiar și în prezența „încurcăturilor de dependență”. Am atins acest obiectiv folosind demonul mypy. Un demon este un proces de server care implementează două caracteristici interesante. În primul rând, stochează informații despre întreaga bază de cod în memorie. Aceasta înseamnă că de fiecare dată când rulați mypy, nu trebuie să încărcați date din cache legate de mii de dependențe importate. În al doilea rând, el analizează cu atenție, la nivelul micilor unități structurale, dependențele dintre funcții și alte entități. De exemplu, dacă funcția foo apelează o funcție bar, atunci există o dependență foo din bar. Când un fișier se modifică, mai întâi demonul, izolat, procesează numai fișierul modificat. Apoi, analizează modificările vizibile din exterior ale acelui fișier, cum ar fi semnăturile de funcții modificate. Daemonul folosește informații detaliate despre importuri doar pentru a verifica acele funcții care folosesc de fapt funcția modificată. De obicei, cu această abordare, trebuie să verificați foarte puține funcții.

Implementarea tuturor acestor lucruri nu a fost ușoară, deoarece implementarea originală mypy a fost concentrată în mare măsură pe procesarea câte un fișier la un moment dat. Am avut de-a face cu multe situații limită, a căror apariție a necesitat verificări repetate în cazurile în care ceva s-a schimbat în cod. De exemplu, acest lucru se întâmplă atunci când unei clase i se atribuie o nouă clasă de bază. Odată ce am făcut ceea ce ne-am dorit, am reușit să reducem timpul de execuție al majorității verificărilor incrementale la doar câteva secunde. Aceasta ni s-a părut o mare victorie.

Și mai multă productivitate!

Împreună cu memoria cache la distanță despre care am discutat mai sus, demonul mypy a rezolvat aproape complet problemele care apar atunci când un programator execută frecvent verificarea tipului, făcând modificări unui număr mic de fișiere. Cu toate acestea, performanța sistemului în cazul de utilizare cel mai puțin favorabil a fost încă departe de a fi optimă. O pornire curată a mypy ar putea dura peste 15 minute. Și asta a fost mult mai mult decât ne-am fi bucurat. În fiecare săptămână, situația sa înrăutățit pe măsură ce programatorii continuau să scrie cod nou și să adauge adnotări la codul existent. Utilizatorii noștri erau încă dornici de performanță mai mare, dar ne-am bucurat să îi întâlnim la jumătatea drumului.

Am decis să revenim la una dintre ideile anterioare referitoare la mypy. Și anume, pentru a converti codul Python în cod C. Experimentarea cu Cython (un sistem care vă permite să traduceți codul scris în Python în cod C) nu ne-a oferit nicio accelerare vizibilă, așa că am decis să reînviam ideea de a scrie propriul nostru compilator. Deoarece baza de cod mypy (scrisă în Python) conținea deja toate adnotările de tip necesare, ne-am gândit că ar merita să încercăm să folosim aceste adnotări pentru a accelera sistemul. Am creat rapid un prototip pentru a testa această idee. A arătat o creștere de peste 10 ori a performanței pe diferite micro-benchmark-uri. Ideea noastră a fost să compilam module Python în modulele C folosind Cython și să transformăm adnotările de tip în verificări de tip în timpul execuției (de obicei, adnotările de tip sunt ignorate în timpul execuției și utilizate numai de sistemele de verificare a tipului). De fapt, am plănuit să traducem implementarea mypy din Python într-un limbaj care a fost conceput pentru a fi scris static, care să arate (și, în cea mai mare parte, să funcționeze) exact ca Python. (Acest tip de migrare între limbi a devenit o tradiție a proiectului mypy. Implementarea originală mypy a fost scrisă în Alore, apoi a existat un hibrid sintactic de Java și Python).

Concentrarea pe API-ul extensiei CPython a fost cheia pentru a nu pierde capacitățile de management de proiect. Nu a fost nevoie să implementăm o mașină virtuală sau orice bibliotecă de care mypy avea nevoie. În plus, am avea în continuare acces la întregul ecosistem Python și la toate instrumentele (cum ar fi pytest). Acest lucru a însemnat că am putea continua să folosim codul Python interpretat în timpul dezvoltării, permițându-ne să continuăm să lucrăm cu un model foarte rapid de a face modificări de cod și de a-l testa, mai degrabă decât să așteptăm compilarea codului. Părea că facem o treabă grozavă stând pe două scaune, ca să spunem așa, și ne-a plăcut.

Compilatorul, pe care l-am numit mypyc (din moment ce folosește mypy ca front-end pentru analiza tipurilor), s-a dovedit a fi un proiect de mare succes. În general, am obținut o accelerare de aproximativ 4 ori pentru rulările frecvente mypy fără cache. Dezvoltarea nucleului proiectului mypyc a luat o echipă mică de Michael Sullivan, Ivan Levkivsky, Hugh Hahn și eu însumi aproximativ 4 luni calendaristice. Această cantitate de muncă a fost mult mai mică decât ceea ce ar fi fost necesar pentru a rescrie mypy, de exemplu, în C++ sau Go. Și a trebuit să facem mult mai puține modificări în proiect decât ar fi trebuit să facem când îl rescriem într-o altă limbă. De asemenea, am sperat că am putea aduce mypyc la un asemenea nivel încât alți programatori Dropbox să-l poată utiliza pentru a compila și accelera codul lor.

Pentru a atinge acest nivel de performanță, a trebuit să aplicăm câteva soluții de inginerie interesante. Astfel, compilatorul poate accelera multe operații folosind constructe C rapide, de nivel scăzut. De exemplu, un apel de funcție compilat este tradus într-un apel de funcție C. Și un astfel de apel este mult mai rapid decât apelarea unei funcții interpretate. Unele operațiuni, cum ar fi căutările din dicționar, încă implicau utilizarea apelurilor C-API obișnuite de la CPython, care au fost doar puțin mai rapide atunci când au fost compilate. Am reușit să eliminăm încărcarea suplimentară a sistemului creată de interpretare, dar aceasta în acest caz a dat doar un mic câștig în ceea ce privește performanța.

Pentru a identifica cele mai frecvente operațiuni „lente”, am efectuat profilarea codului. Înarmați cu aceste date, am încercat fie să modificăm mypyc astfel încât să genereze cod C mai rapid pentru astfel de operațiuni, fie să rescriem codul Python corespunzător folosind operații mai rapide (și uneori pur și simplu nu aveam o soluție suficient de simplă pentru acea sau altă problemă) . Rescrierea codului Python a fost adesea o soluție mai ușoară a problemei decât ca compilatorul să efectueze automat aceeași transformare. Pe termen lung, ne-am dorit să automatizăm multe dintre aceste transformări, dar la momentul respectiv ne-am concentrat pe accelerarea mypy cu un efort minim. Și îndreptându-ne către acest obiectiv, am tăiat mai multe colțuri.

Pentru a fi continuat ...

Dragi cititori! Care au fost impresiile tale despre proiectul mypy când ai aflat de existența lui?

Calea către verificarea tipului a 4 milioane de linii de cod Python. Partea 2
Calea către verificarea tipului a 4 milioane de linii de cod Python. Partea 2

Sursa: www.habr.com

Adauga un comentariu