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

Astăzi publicăm a doua parte a traducerii materialului despre cum Dropbox a organizat controlul tipurilor pentru câteva milioane de linii de cod Python.

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

→ Citește prima parte

Suport oficial pentru tipuri (PEP 484)

Am desfășurat primele experimente serioase cu mypy în Dropbox în timpul Hack Week 2014. Hack Week este un eveniment organizat de Dropbox pe parcursul unei săptămâni. În această perioadă, angajații pot lucra la orice doresc! Unele dintre cele mai renumite proiecte tehnologice Dropbox au început în cadrul acestor evenimente. În urma acestui experiment, am ajuns la concluzia că mypy arată promițător, deși acest proiect nu era încă pregătit pentru utilizare pe scară largă.

Pe atunci, circula ideea standardizării sistemelor de oferire a sugestiilor de tipuri Python. Așa cum am menționat, începând cu Python 3.0, se putea utiliza anotări de tip pentru funcții, dar acestea erau doar expresii arbitrare, fără un sintaxă și semantica definite. În timpul execuției programului, aceste anotări erau, în mare parte, pur și simplu ignorate. După Hack Week, am început să lucrăm la standardizarea semantica. Această muncă a dus la apariția PEP 484 (la redactarea acestui document, au colaborat Guido van Rossum, Lukasz Langa și cu mine).

Motivele noastre pot fi privite din două perspective. În primul rând, speram că întregul ecosistem Python ar putea accepta o abordare comună în utilizarea sugestiilor de tip (type hints — termen folosit în Python ca echivalent al „anotărilor de tip”). Asta, având în vedere riscurile posibile, ar fi fost mai bine decât utilizarea mai multor abordări reciproc incompatibile. În al doilea rând, am dorit să discutăm deschis despre mecanismele de anotare a tipurilor cu numeroși reprezentanți ai comunității Python. Parțial, această dorință a fost dictată de faptul că nu ne-ar fi plăcut să părem „rătăciți” în fața ideilor fundamentale ale limbajului în fața unui public larg de programatori Python. Este un limbaj dinamic tipizat, cunoscut pentru „tipizarea prin rață”. În comunitate, la început, nu a putut să nu apară o oarecare suspiciune față de ideea tipizării statice. Dar această atitudine s-a atenuat în cele din urmă — după ce a devenit clar că tipizarea statică nu se intenționează să fie obligatorie (și după ce oamenii au înțeles că este cu adevărat utilă).

Sintaxa adoptată pentru tipuri a fost foarte asemănătoare cu cea susținută de mypy la acea vreme. Documentul PEP 484 a fost lansat împreună cu Python 3.5 în 2015. Python nu mai era doar un limbaj care susținea tipizarea dinamică. Îmi place să consider acest eveniment ca o etapă semnificativă în istoria Python.

Începerea migrării

La sfârșitul anului 2015, a fost format un grup de trei persoane la Dropbox pentru a lucra la mypy. Aceștia erau Guido van Rossum, Greg Price și David Fisher. De atunci, situația a început să evolueze foarte rapid. Prima provocare în calea creșterii mypy a fost performanța. Așa cum am sugerat mai sus, în perioada timpurie de dezvoltare a proiectului, m-am gândit să traduc implementarea mypy în limbajul C, dar această idee a fost deocamdată scoasă din lista de opțiuni. Am rămas blocați folosind interpretorul CPython, care nu se remarcă printr-o viteză suficientă pentru instrumente de tipul mypy. (Proiectul PyPy, o implementare alternativă a Python cu un compilator JIT, nu ne-a ajutat nici el.)

Din fericire, ne-au venit în ajutor unele îmbunătățiri algoritmice. Prima „accelerare” puternică a fost implementarea verificării incrementale. Ideea acestei îmbunătățiri a fost simplă: dacă toate dependențele modulului de la ultima execuție a mypy nu s-au schimbat, atunci putem utiliza, în timpul lucrului cu dependențele, datele stocate în cache în timpul sesiunii anterioare. Era suficient să facem verificarea tipurilor în fișierele modificate și în acele fișiere care depindeau de ele. Mypy a mers chiar puțin mai departe: dacă interfața externă a modulului nu s-a schimbat, mypy considera că nu trebuie să verifice din nou modulele care importă acest modul.

Verificarea incrementală ne-a ajutat mult în documentarea unor volume mari de cod existent. Procesul implică, de obicei, numeroase execuții iterativ ale mypy, deoarece adăugăm gradual anotările în cod și le îmbunătățim treptat. Prima execuție a mypy a fost totuși foarte lentă, deoarece a fost necesară verificarea multor dependențe. Atunci, pentru a îmbunătăți situația, am implementat un mecanism de cache remote. Dacă mypy detectează că cache-ul local este probabil învechit, acesta încarcă o instantaneu a cache-ului pentru întreaga bază de cod dintr-un depozit centralizat. Apoi, efectuează o verificare incrementală folosind această instantaneu. Acesta a fost un pas semnificativ în sporirea performantelor mypy.

A fost o perioadă de adoptare rapidă și naturală a sistemului de verificare a tipurilor în Dropbox. La sfârșitul anului 2016, aveam deja aproximativ 420.000 de linii de cod Python cu anotări de tipuri. Mulți utilizatori au primit cu entuziasm verificarea tipurilor. În Dropbox, mypy era utilizat din ce în ce mai mult de echipele de dezvoltare.

Totul părea bine atunci, dar mai aveam multe de făcut. Am început să realizăm sondaje interne periodice pentru a identifica problemele proiectului și pentru a înțelege care întrebări trebuie rezolvate în primul rând (această practică este utilizată și în companie și astăzi). Cele mai importante, așa cum s-a dovedit, erau două sarcini. Prima - era nevoie de o acoperire mai mare a codului cu tipuri, a doua - era nevoie ca mypy să funcționeze mai repede. Era evident că munca noastră pentru accelerarea mypy și integrarea acestuia în proiectele companiei era departe de a fi finalizată. Conștientizând pe deplin importanța acestor două sarcini, ne-am apucat de rezolvarea lor.

Mai multă performanță!

Verificările incrementale au accelerat mypy, dar acest instrument încă nu era suficient de rapid. Multe dintre verificările incrementale durau în jur de un minut. Cauza acestei situații erau importurile ciclice. Acest lucru probabil nu va surprinde pe cei care au lucrat cu baze de cod mari scrise în Python. Aveam seturi de sute de module, fiecare dintre ele importând indirect toate celelalte. Dacă un fișier dintr-un ciclu de importuri era modificat, mypy trebuia să proceseze toate fișierele din acel ciclu și, adesea, și orice module care importau module din acel ciclu. Unul dintre aceste cicluri era celebra „ghemă de dependențe”, care a cauzat multe neplăceri la Dropbox. O dată, această structură conținea câteva sute de module, iar multe teste le importau, direct sau indirect, iar aceasta era utilizată și în codul de producție.

Am analizat posibilitatea de a „descurca” dependențele ciclice, dar nu aveam resurse pentru a face acest lucru. Erau prea multe coduri cu care nu eram familiarizați. În cele din urmă, am ales o abordare alternativă. Am decis să facem ca mypy să funcționeze rapid chiar și în prezența „ghemelor de dependențe”. Am realizat acest lucru printr-un demon mypy. Demonul este un proces de server care implementează două caracteristici interesante. În primul rând, el păstrează în memorie informațiile despre întreaga bază de cod. Acest lucru înseamnă că la fiecare rulare mypy nu trebuie să încarce datele cache, referitoare la mii de dependențe importate. În al doilea rând, el analizează cu atenție, la nivelul unităților structurale mici, dependențele dintre funcții și alte entități. De exemplu, dacă o funcție foo apelează funcția bar, atunci există o dependență foo de la bar. Când un fișier este modificat, demonul procesează mai întâi, în izolare, doar fișierul modificat. Apoi analizează modificările acestui fișier, vizibile din exterior, cum ar fi semnăturile de funcție schimbate. Demonul folosește informații detaliate despre importuri doar pentru a verifica din nou funcțiile care folosesc cu adevărat funcția modificată. De obicei, cu această abordare, trebuie verificat un număr foarte mic de funcții.

Implementarea tuturor acestor elemente a fost o sarcină dificilă, deoarece implementarea inițială a mypy a fost puternic orientată spre procesarea unui singur fișier deodată. A trebuit să ne confruntăm cu numeroase situații limită, apariția cărora necesita verificări repetate în cazurile în care ceva se schimba în cod. De exemplu, se întâmplă așa atunci când unei clase i se atribuie o nouă clasă de bază. După ce am realizat ceea ce ne doream, am reușit să reducem timpul de execuție al majorității verificărilor incrementale la câteva secunde. Ni se părea o mare victorie.

Încă mai multă performanță!

Împreună cu cache-ul de la distanță despre care am vorbit mai sus, demonul mypy a rezolvat practic complet problemele apărute atunci când un programator rulează frecvent verificarea tipurilor, făcând modificări în câteva fișiere. Cu toate acestea, performanța sistemului în cea mai puțin favorabilă situație de utilizare era încă departe de a fi optimă. O pornire curată a mypy putea dura mai mult de 15 minute. Și aceasta era mult mai mult decât ne-am fi dorit. În fiecare săptămână situația devenea din ce în ce mai gravă, deoarece programatorii continuau să scrie cod nou și să adauge anotații la codul existent. Utilizatorii noștri încă tânjeau după mai multă performanță și noi eram bucuroși să le venim în întâmpinare.

Am decis să revenim la una dintre ideile anterioare legate de mypy. Mai precis, la transformarea codului Python în cod C. Experimentele cu Cython (un sistem care permite transcrierea codului scris în Python în cod C) nu ne-au adus vreo accelerare vizibilă, așa că am decis să revigorăm ideea de a scrie propriul compilator. Deoarece baza de cod mypy (scrisă în Python) conținea deja toate tipurile necesare de adnotări, ne-a părut o încercare demnă să folosim aceste adnotări pentru a accelera sistemul. Am creat rapid un prototip pentru a verifica această idee. Acesta a arătat, în diverse micro-benchmarks, o creștere a performanței de peste 10 ori. Ideea noastră era să compilăm modulele Python în module C prin intermediul Cython și să transformăm adnotările de tip în verificări de tip efectuate în timpul execuției programului (de obicei, adnotările de tip sunt ignorate în timpul execuției programelor și sunt utilizate doar de sistemele de verificare a tipurilor). Practic, plănuim să traducem implementarea mypy din Python într-o limbă care a fost creată pentru a fi tipizată static, care ar avea un aspect (și, în mare parte, funcționare) exact ca Python. (Această formă de migrație între limbaje a devenit oarecum o tradiție a proiectului mypy. Implementarea inițială a mypy a fost scrisă în Alore, ulterior fiind un hibrid sintactic între Java și Python).

Orientarea pe API-urile extensiilor CPython a fost cheia pentru a nu pierde oportunitățile de gestionare a proiectului. Nu a fost necesar să implementăm o mașină virtuală sau orice biblioteci de care ar fi avut nevoie mypy. În plus, toată ecosistemul Python ne-ar fi fost încă accesibil, ar fi fost disponibile toate instrumentele (cum ar fi pytest). Acest lucru a însemnat că am putut continua să folosim cod Python interpretat pe parcursul dezvoltării, ceea ce ne-ar fi permis să continuăm să lucrăm, folosind o schemă foarte rapidă de modificare a codului și testare, fără a aștepta compilarea codului. A părut că ne descurcăm bine, așa-zicând, să stăm pe două scaune, și ne-a plăcut asta.

Compilatorul pe care l-am numit mypyc (deoarece folosește mypy pentru analiza tipurilor, ca frontend) s-a dovedit a fi un proiect foarte de succes. În general, am realizat o accelerare de aproximativ 4 ori a lansărilor frecvente ale mypy fără a utiliza caching. Dezvoltarea nucleului proiectului mypyc a durat o echipă mică, formată din Michael Sullivan, Ivan Levkițki, Hugh Han și mine, aproximativ 4 luni calendaristice. Acest volum de lucru a fost mult mai mic decât cel necesar pentru a rescrie mypy, de exemplu, în C++ sau Go. De asemenea, schimbările pe care a fost nevoie să le facem în proiect au fost mult mai puține decât ar fi trebuit să facem dacă l-am fi rescris într-o altă limbă. În plus, ne-am bazat pe faptul că vom putea aduce mypyc la un nivel în care alți programatori de la Dropbox să îl poată folosi pentru a compila și accelera codul lor.

Pentru a atinge un asemenea nivel de performanță, a trebuit să aplicăm unele soluții inginerie interesante. Astfel, compilatorul poate accelera execuția multor operații prin utilizarea unor construcții C rapide la nivel jos. De exemplu, apelul unei funcții compilate este tradus într-un apel C. Un astfel de apel se execută mult mai repede decât un apel al unei funcții interpretate. Unele operații, cum ar fi căutările în dicționare, încă s-au redus la utilizarea apelurilor standard C-API din CPython, care, după compilare, s-au dovedit a fi doar puțin mai rapide. Am reușit să eliminăm sarcina suplimentară pe sistem generată de interpretare, dar acest lucru a adus doar un câștig mic în ceea ce privește performanța.

Pentru a identifica cele mai frecvente operații „lente”, am realizat profilarea codului. Îmbunătățiți datele obținute, am încercat fie să ajustăm mypyc pentru a genera un cod C mai rapid pentru astfel de operații, fie să rescriem codul Python relevant folosind operații mai rapide (uneori nu aveam pur și simplu o soluție simplă pentru o problemă sau alta). Rescrierea codului Python s-a dovedit adesea a fi o soluție mai ușoară decât implementarea unei transformări automate a aceeași în compilator. Pe termen lung, ne doream să automatizăm multe dintre aceste transformări, dar în acel moment ne-am concentrat pe accelerarea mypy-ului cu un minim de efort. Și, pe parcursul acestei obiective, am făcut câteva compromisuri.

Continuarea urmează...

Stimați cititori! Ce impresii v-a lăsat proiectul mypy când ați aflat despre existența sa?

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

Sursa: habr.com

Cumpără un hosting fiabil pentru site-uri cu protecție DDoS, servere VPS VDS 🔥 Cumpără un hosting fiabil pentru site-uri cu protecție DDoS, servere VPS VDS | ProHoster