“Finalmente un linguaggio più moderno e funzionale”

Chi fra noi non vorrebbe programmare in un linguaggio multiparadigma, altamente espressivo, in piena evoluzione e con una vastissima libreria standard? Stiamo parlando, ovviamente, di… Python.

Ci sono casi in cui il nostro solito campione (C++11), non è la scelta migliore. Per un prototipo da sviluppare in fretta, uno script “usa e getta”, il server di un’applicazione web, del codice di ricerca… la complessità del C++ è più un peso che un vantaggio.

Come possiamo continuare a sfruttare l’efficienza del C++ o riutilizzare codice già esistente senza passare per cavernicoli fuori moda?

L’interprete Python può caricare moduli scritti in C, compilati in librerie dinamiche. Boost.Python ci aiuta, enormemente, a prepararli. Uniamo la potenza di Boost e C++ alla semplicità di Python.

Attenzione: anche se tutti gli esempi compilano, girano e passano i test questa non è la guida definiva su Boost.Python. Il codice è illustrativo, riflette solo la nostra (scarsa) esperienza con Boost.Python. Non esitate a segnalarci errori.

Un problema di velocità

Vediamo un caso (non troppo) pratico. Ci sono numeri uguali alla somma dei loro divisori (6 = 3 + 2 + 1; numeri perfetti). Il reparto marketing ha fiutato l’affare, ma è fondamentale calcolarne il più possibile prima della concorrenza. La velocità di sviluppo di Python è l’arma vincente, dopo 5 minuti rilasciamo Pefect 1.0®:

def trova_divisori(numero): divisori = [] for i in range(1, numero): if numero % i == 0: divisori.append(i) return divisori def perfetto(numero): divisori = trova_divisori(numero) return numero == sum(divisori) def trova_perfetti(quanti_ne_vuoi): trovati = 0 numero_da_provare = 1 while (trovati < quanti_ne_vuoi): if perfetto(numero_da_provare): print numero_da_provare trovati += 1 numero_da_provare += 1 if __name__ == "__main__": trova_perfetti(4) # Cercatene di più a vostro rischio e pericolo. # L'attesa sarà lunga...

Questo codice non è perfettamente “pythonico” (https://www.python.org/dev/peps/pep-0008/), ma è stato veramente creato, testato e debuggato nel tempo che di solito spendiamo a leggere un’errore di compilazione .

Peccato che il tempo di esecuzione sia paragonabile: 6,5 secondi sulla mia macchina di prova (che non è la vostra, non è il server di produzione, non è il PC del Python-boy che a lui gira tutto in un picosecondo… è un esempio!).

Da bravi ingegneri cerchiamo il collo di bottiglia con il profiler:

import cProfile ... stesso codice di prima ... if __name__ == "__main__": cProfile.run('trova_perfetti(4)')

Ed ecco il risultato:

ncalls tottime percall cumtime percall filename:lineno(function) 1 0.000 0.000 7.420 7.420 <string>:1() 8128 0.709 0.000 7.326 0.001 purePython-profiler.py:15(perfetto) 1 0.095 0.095 7.420 7.420 purePython-profiler.py:19(trova_perfetti) 8128 5.190 0.001 6.523 0.001 purePython-profiler.py:8(trova_divisori) 66318 0.819 0.000 0.819 0.000 {method 'append' of 'list' objects} 1 0.000 0.000 0.000 0.000 {method 'disable' of '_lsprof.Profiler' objects} 8128 0.514 0.000 0.514 0.000 {range} 8128 0.094 0.000 0.094 0.000 {sum}

trova_divisori “ruba” quasi tutti i 6,5 secondi!

boost::python

Nessuno nega che si possa scrivere codice efficiente in Python (Java, VisualQualcosa, il linguaggio funzionale di questa settimana…), ma ottimizzare l’algoritmo di trova_divisori è fuori discussione: vogliamo mostrare Boost.Python, non fare una lezione di Algebra.

Per prima cosa, ci procuriamo Boost.Python. Su una macchina Linux è semplice quanto usare:

sudo apt-get install libboost-all-dev

Potreste dover installare anche i package “dev” di Python. Non è difficile trovare su internet istruzioni per tutte le piattaforme, ma installare (e compilare) può essere la parte più difficile. Non scoraggiatevi.

Questo è il codice C++:



#include "boost/python.hpp" // (1) boost::python::list trovaDivisori(uint64_t numero) // (2) { boost::python::list divisori; for (uint64_t i = 1; i < numero; ++i) // (3) if (numero % i == 0) divisori.append(i); return divisori; } BOOST_PYTHON_MODULE(divisori) { using namespace boost::python; def("trova_divisori", trovaDivisori); // (4) }

Includiamo Boost.Python. Deve essere incluso prima di ogni altro eventuale header per evitare warning alla compilazione. La funzione equivalente a quella che vogliamo sostituire in Python. Manteniamo la stessa segnatura (prende un intero, ritorna una lista) dell’originale in Python per rendere la sostituzione “trasparente”. Anche l’algoritmo è esattamente lo stesso. Cambia solo la sintassi, e neanche di molto. In questo caso tutta la differenza la fa, probabilmente, il runtime C++. Dichiariamo la funzione nel modulo python con “def” (…come in Python).

La guida (http://www.boost.org/doc/libs/1_59_0/libs/python/doc/) spiega molto chiaramente tutti dettagli.

La compilazione, purtroppo, non è esattamente elementare, dovrete probabilmente adattarla caso per caso. Vediamo l’esempio un passo alla volta (si tratta di una sola riga di comando, naturalmente):

g++ divisori.cpp compilo un file C++, qui tutto normale -o divisori.so nome del file: Python esige sia lo stesso del modulo -I /usr/include/python2.7/ includo gli header di Python (ho Boost già nel path) -l python2.7 -lboost_python -lboost_system includo Python, Boost -shared -fPIC -Wl,-export-dynamic chiedo di creare una libreria dinamica

stackoverflow.com farà il resto. Notare che, per “par condicio”, non stiamo usando le opzioni di ottimizzazione di g++.

Una volta che la nostra libreria è nel path di sistema (altrimenti Python non la trova) possiamo includerla nel codice Python:



from divisori import trova_divisori def perfetto(numero): divisori = trova_divisori(int(numero)) # Adesso chiama quella in C++ return numero == sum(divisori) … stesso codice di prima …

Tempo di esecuzione: poco meno di un secondo. Siamo testimoni del classico “l’80% del tempo si spreca nel 20% del codice”. Lo stesso algoritmo è 6 volte più veloce, ma l’unica parte su cui abbiamo perso tempo con la programmazione a basso livello (dopotutto, è ancora C++98!) è una sola funzione. Per tutto il resto possiamo ancora approfittare della praticità di Python.

Qualche possibilità in più

Boost.Python non si limita a convertire i tipi primitivi e a incapsulare le liste di Python in un adapter C++. Ecco una selezione dei casi “tipici” per chi programma nel “C con classi”:



class RiutilizzabileInPython { public: RiutilizzabileInPython() {}; RiutilizzabileInPython(int x, const std::string& y) {}; int variabileIstanza; static void metodoStatico() {}; void metodo() {} }; BOOST_PYTHON_MODULE(oop) { using namespace boost::python; class_<RiutilizzabileInPython>("implementata_in_CPP") //(1) .def(init<int, std::string>()) //(2) .def_readwrite("variabile_istanza", &RiutilizzabileInPython::variabileIstanza)//(3) .def("metodo_statico", &RiutilizzabileInPython::metodoStatico).staticmethod("metodo_statico") //(4) .def("metodo", &RiutilizzabileInPython::metodo) // (5) ; }

>Apriamo la dichiarazione della classe, passando la stringa con il nome Python. Traduzione del costruttore in Python (…init, ricorda niente?). La “tradizione” Python non disdegna le variabili di oggetto pubbliche. Eccone una. Solo una ripetizione del nome Python per esporre un metodo statico. Il classico, semplice metodo d’istanza.

Una volta compilato (…tra il dire e il fare…) possiamo usare la classe C++ in Python:

from oop import implementata_in_CPP x = implementata_in_CPP() y = implementata_in_CPP(3, "ciao") x.variabil_istanza = 23 implementata_in_CPP.metodo_statico() x.metodo()

Boost si preoccupa di convertire parametri, tipi di ritorno eccetera. Ci sono opzioni per l’“esportazione” diretta delle classi della STL (e se non ci sono è possibile definirle) e per le policy dei tipi ritornati (per reference, per copia…). Le possibilità sono moltissime, affidatevi alla guida ufficiale.

Quando il gioco si fa duro, Boost continua a giocare. Un assaggio:

class Problems { public: void stampa() { std::cout << "cout continua a funzionare" << std::endl; } void eccezione() { throw std::runtime_error("Oh, no!!!"); } void coreDump() { int * nullPointer = 0; *nullPointer = 24; } }; BOOST_PYTHON_MODULE(oop) { using namespace boost::python; class_<Problems>("Problems") .def("stampa", &Problems::stampa) .def("eccezione", &Problems::eccezione) .def("coreDump", &Problems::coreDump) ; }

Il “test-driver” in Python, con un esempio di output:



from oop import Problems p = Problems() p.stampa() try: p.eccezione() except RuntimeError as e: print "Il codice C++ non ha funzionato: " + str(e); p.coreDump()

cout continua a funzionare (1) Il codice C++ non ha funzionato: Oh, no!!! (2) Segmentation fault (core dumped) (3)

Debuggare a colpi di std::cout non è una buona pratica… ma funziona! Le eccezioni sono perfettamente “inoltrate” al runtime Python. …pensavate di salvarvi, eh?

Multithreading

Boost.Python non è l’unica arma per affrontare problemi che richiedono efficienza. Il codice multi thread è un modo comune di aumentare le prestazioni, tanto per per trovare divisori che per minare Bitcoin o craccare password. Ecco una classe C++ che sta per saltare in un thread Python.

class JobTrovaDivisori { public: JobTrovaDivisori(uint64_t numero, uint64_t begin, uint64_t end) : numero(numero), begin(begin), end(end) {} boost::python::list trovaDivisori() { std::cout << "Start" << std::endl; boost::python::list divisori; for (uint64_t i = begin; i < end; ++i) if (numero % i == 0) divisori.append(i); std::cout << "end" << std::endl; return divisori; } private: uint64_t numero; uint64_t begin; uint64_t end; }; BOOST_PYTHON_MODULE(fattorizzare) { using namespace boost::python; class_<JobTrovaDivisori>("JobTrovaDivisori", init<uint64_t, uint64_t, uint64_t>()) .def("trova_divisori", &JobTrovaDivisori::trovaDivisori) ; }

L’oggetto “JobTrovaDivisori” controlla se i numeri tra “begin” e “end” sono divisori di “numero”. Parallelizziamo il problema di trovare tutti i divisori in più “job” usando ogni oggetto su un intervallo diverso. Non ci sono dati condivisi, non abbiamo alcun problema di concorrenza. Questa è l’unica nota positiva di questa soluzione, ma ancora una volta tralasciamo la matematica (e l’ingegneria del software).

La chiamata in Python:



from threading import Thread from fattorizzare import JobTrovaDivisori class Job(): # (1) def __init__(self, numero, begin, end): self.cppJob = JobTrovaDivisori(numero, begin, end) self.divisori = [] def __call__(self): self.divisori = self.cppJob.trova_divisori() def trova_divisori_parallelo(numero): # (2) limite = numero / 2 job1 = Job(numero, 1, limite) job2 = Job(numero, limite, numero) t1 = Thread(None, job1) t2 = Thread(None, job2) t1.start() t2.start() t1.join() t2.join() return [job1.divisori, job2.divisori] if __name__ == "__main__": print trova_divisori_parallelo(223339244); #(3)

Incapsuliamo il Job C++ per “non complicarci la vita” cercando di esportare un callable C++. Questo metodo crea 2 job, esegue il “fork e join” (o, come dicono oggi, “map e reduce”), poi stampa il risultato. Fattorizziamo un numero qualunque.

Ecco l’output: ricordate le stampe di “Start” e “end” nella classe C++? Dopo circa 8 secondi e mezzo il calcolo termina, senza nessun parallelismo:

Start end Start end [[1L, 2L, 4L, 53L, 106L, 212L, 1053487L, 2106974L, 4213948L, 55834811L], [111669622L]]

Non è un caso. Gli oggetti Python sono protetti dal Global Interpreter Lock (GIL). Spetta al programmatore di ciascun thread rilasciarlo per dare il “via libera” agli altri thread. L’accortezza è di non chiamare codice puramente Python quando non si possiede il lock.

Come al solito in C++ controlliamo le risorse col metodo RAII. L’idioma per il GIL è (https://wiki.python.org/moin/boost.python/HowTo#Multithreading_Support_for_my_function):



class ScopedGILRelease { public: inline ScopedGILRelease(){ m_thread_state = PyEval_SaveThread(); } inline ~ScopedGILRelease() PyEval_RestoreThread(m_thread_state); m_thread_state = NULL; } private: PyThreadState * m_thread_state; };

Rilasciamo il lock nella classe C++:



boost::python::list trovaDivisori() { ScopedGILRelease noGil = ScopedGILRelease(); // (1) std::cout << "Start" << std::endl; boost::python::list divisori; for (uint64_t i = begin; i < end; ++i) if (numero % i == 0) divisori.append(i); // (2) Possibile Core Dump! std::cout << "end" << std::endl; return divisori; }

Quando questa variabile esce dallo scope, il lock è ri-acquisito, come se fosse uno smart pointer “al contrario”. Qui è dove prenderemo il core dump. Ma solo in produzione.

Ricordate la clausola “l’accortezza è di non chiamare codice puramente Python quando non si possiede il lock”? La riga (2) potrebbe fare esattamente quello. Provate a far crescere la lista a dismisura (ad esempio, elimiate la “if (numero…” e salvate tutti i numeri nella lista). Credo che, probabilmente (affidatevi alle guide ufficiali per conoscere la vera risposta!) l’interprete Python deve allocare una lista più grossa, ma non avendo il lock qualcosa si corrompe.

Racchiudiamo la sezione parallelizzabile in uno scope a parte, salvando i numeri in una variabile non condivisa con Python:



boost::python::list trovaDivisori() { std::cout << "Start" << std::endl; std::vector<uint64_t> divisoriTemp; { ScopedGILRelease noGil = ScopedGILRelease(); for (uint64_t i = begin; i < end; ++i) if (numero % i == 0) divisoriTemp.push_back(i); std::cout << "end" << std::endl; } // noGil esce dallo scope. Riprendiamo il lock. boost::python::list divisori; BOOST_FOREACH(uint64_t n, divisoriTemp) { divisori.append(n); } return divisori; }

Dopo 6 secondi e mezzo (-2 rispetto alla versione “accidentalmente sequenziale”) otteniamo l’interleaving previsto (Start Start – end end). Quei 2 secondi possiamo spenderli per pensare a una soluzione meno rimediata.

Questo conclude l’introduzione a Boost.Python. Ora conosciamo un modo per “incastrare” moduli C++ nelle applicazioni Python, sia per riutilizzarli che per ragioni di efficienza. Boost.Python connette i due mondi senza sacrificare la semplicità di Python e senza limitare le possibilità in C++, pur se è necessaria qualche accortezza. Soprattutto, d’ora in avanti avremo l’ultima parola nel classico flame “Python vs C++” su tutti i forum del mondo!