Sei sulla pagina 1di 63

Un approccio pratico al

calcolo scientifico:
Python e Julia
Maurizio Tomasi
Giovedì 22 Marzo 2018
Funzionamento dei
compilatori C++
Tipi di memoria
• Memoria volatile:
• Registro (qualche kB, 64 bit per ciclo di clock)
• Cache (128 kB–128 MB, 40–700 GB/s)
• Memoria RAM (10 GB/s)
• Memoria permanente:
• Disco fisso SSD (~1 GB/s)
• Disco fisso HDD (120 MB/s)
Accesso alla memoria
• Memoria nei registri: si accede solo attraverso
identificativi (rbp, rsp, rax, rbx… per gli interi,
xmm0, xmm1… per i floating-point)
• Memoria RAM: la CPU richiede il dato specificando
un indirizzo numerico
• Hard disk: la CPU emette un «interrupt», che fa
eseguire un codice al micro-controller del disco e
restituisce un risultato.
Dimensioni e velocità

Registri di memoria (6 kB)

Banco di RAM da 8 GB

Disco rigido da 1 TB (SSD)


Codice macchina e assembler

00110100
10111101
01001011
01001100

movapd xmm4, xmm1


mulsd xmm5, xmm0
mulsd xmm4, xmm1
jle .L10
movapd xmm6, xmm5

Cos’è in grado di fare una CPU?
• Calcoli elementari su interi
• Calcoli elementari su floating-point
• Confronti
• Istruzioni di salto (goto)
• Copia di dati da registri a memoria e viceversa
• Chiamate a codici su altre schede (interrupt): hard
disk, scheda grafica, tastiera, porte USB, porte
ethernet, etc.
Cosa non è in grado di fare una
CPU?
• Cicli for
• Operazioni matematiche complesse (es., 2*x+y)
• Gestione di tipi dati complessi (array, stringhe,
variabili static, etc.)
• Allocazione di memoria tramite new e delete
• Funzioni con parametro
• Classi
Il compilatore C++
• Un compilatore C++ traduce codice C++ in codice
macchina
• Trasforma cicli for in cicli semplici (che usano goto)
• Capisce da solo quando usare i registri e quando
usare la memoria RAM
• Può generare codice assembler: in gcc basta usare
il flag –S, oppure si usa il sito https://godbolt.org/
(più comodo!)
• Il codice C++ è solitamente molto veloce perché c’è
stretta aderenza col codice macchina generato.
Esempio: ciclo for
for(int i = 0; i < n; ++i) {
// Loop body
}

mov ecx, [n]


xor eax, eax
LoopTop:
cmp eax, ecx
jge LoopEnd
; (loop body: DO NOT MODIFY ecx!)
add eax, 1
jmp LoopTop
LoopEnd:
; (etc.)
Frattali di Julia in C++
Frattali di Julia
• Un frattale di Julia 𝐽𝑐 è un sottoinsieme del piano
complesso ℂ parametrizzato dal numero c ∈ ℂ.
• Perché un punto z ∈ ℂ appartenga a 𝐽𝑐 , occorre che

dove 𝑓 (𝑛) indica la funzione 𝑓 composta 𝑛 volte, e


𝑓 𝑧 = 𝑧 2 + 𝑐.
• Ci viene in aiuto un teorema che dice che se esiste
un 𝑛 tale che 𝑓 (𝑛) (𝑧) > 2, allora il limite sopra
diverge.
Prova pratica

Commento del codice di julia-fractal.cpp.


Frattali di Julia in C++
int julia(double startx, double starty,
double cx, double cy, int max_iter = 256) {
int iter = 0;
double zx = startx, zy = starty;
while ((zx * zx + zy * zy < 4) && (iter < max_iter)) {
double tmp = zx * zx - zy * zy;
zy = 2 * zx * zy + cy;
zx = tmp + cx;

iter++;
}

if (iter == max_iter)
return -1; // Point (startx, starty) is in the set
else
return iter;
}
Codice assembler (-O0)
zy = 2 * zx * zy + cy;

diventa

movsd xmm0, QWORD PTR [rbp-24] ; xmm0 = zx


addsd xmm0, xmm0 ; xmm0 += xmm0
mulsd xmm0, QWORD PTR [rbp-16] ; xmm0 *= zy
addsd xmm0, QWORD PTR [rbp-64] ; xmm0 += cy
movsd QWORD PTR [rbp-16], xmm0 ; zy = xmm0

Con -O2 il codice assembler si complica perché il


compilatore cerca di usare di più i registri
Velocità del codice assembler

• r: registro
• sr: puntatore RAM (vecchio tipo)
• i: valore immediato
• m: puntatore RAM (nuovo tipo)

http://www.agner.org/optimize/instruction_tables.pdf
Il linguaggio Python
L’approccio di Python
• Python nasce all’inizio degli anni 90, 20 anni dopo il
C e 7 dopo il C++
• Quando nasce il Python c’è la consapevolezza che i
computer saranno sempre più veloci: programmi
«lenti» sono sempre meno un problema
• L’approccio di Python è completamente diverso
rispetto al C++: non è più compilato, ma
interpretato
Esempio: complessità del C++

#include <iostream>

int main(void) {
int i;

std::cout << i << std::endl;


return 0;
}

1324623600
Esempio: complessità del C++
#include <iostream>

int i;

int main(void) {

std::cout << i << std::endl;


return 0;
}

0
Esempio: complessità del C++
#include <iostream>

int main(void) {
int arr[10];
std::cout << arr[0] << std::endl;
return 0;
}

-1423518960
Esempio: complessità del C++
#include <iostream>

int arr[10];

int main(void) {
std::cout << arr[0] << std::endl;
return 0;
}

0
Esempio: complessità del C++
#include <iostream>
#include <vector>

int main(void) {
std::vector<int> arr(10);
std::cout << arr[0] << std::endl;
return 0;
}

0
Esempio: complessità del C++
#include <iostream>

void miagola() {
std::cout << "Miaooo!\n";
}

class Cane {
public:
Cane() { std::cout << "Bau! Bau!\n"; }
};

int main(void) {
miagola();
Cane miocane();
return 0;
} Dov’è l’errore?
Lo Zen di Python
• Beautiful is better than ugly
• Explicit is better than implicit
• Simple is better than complex
• Complex is better than complicated
• Flat is better than nested
• Sparse is better than dense
• Etc.

https://www.python.org/dev/peps/pep-0020/
Come installare e usare Python
• Qualsiasi sistema usiate (Windows, Mac, Linux),
installate Anaconda Python 3
• Evitate Anaconda Python 2 (è vecchio)
• Aprite «Anaconda Prompt» ed installate una serie
di pacchetti con questo comando:
conda install numpy scipy matplotlib jupyter
• Sempre da «Anaconda Prompt», scrivete
jupyter notebook
• Al posto di notebook potete usare console oppure
qtconsole (provate!)
Frattale Julia in Python
Non è necessario definire i tipi delle variabili

def julia(z, c, maxiter=256):


iteridx = 0
while (z.real**2 + z.imag**2 < 4) and (iteridx < maxiter):
z = z * z + c
iteridx += 1 Non si usa ; alla fine dei comandi
if iteridx == maxiter: Non si usano parentesi graffe
return -1
else:
return iteridx
Python supporta numeri complessi

Molte parentesi non sono necessarie


Prova pratica: frattale Julia

Commento del notebook Frattali in Python


Vantaggi di Python rispetto al C++
• Si esegue il codice senza bisogno di compilare
prima → più facile fare il debug
• Non è necessario dichiarare variabili → codice più
breve e veloce da scrivere
• Non si usano i file header (.h) → meno file da
gestire
• Non si usano i Makefile → maggiore semplicità
• Niente puntatori → minore possibilità di crash
• Moltissime librerie disponibili (più del C++)
Svantaggi di Python
• Se le variabili non hanno tipo, sono possibili molti
errori
• Gli errori capitano durante l’esecuzione, non
durante la compilazione
• I programmi sono molto più lenti del C++!
Lentezza di Python
• Python non crea programmi nel linguaggio
macchina della CPU, ma nell’assembler di una
macchina virtuale (la «Python virtual machine»)
• Questo codice non viene eseguito dalla CPU ma da
un programma C, che lo converte in fase di
esecuzione in una sequenza di istruzioni in
linguaggio macchina
• Vediamo come funziona in un esempio pratico
Compilazione in C++
Consideriamo l’istruzione
x = a + b

Un compilatore C++ potrebbe tradurla nel codice


mov rax, QWORD PTR [rbp-24] ; rax = a
add rax, QWORD PTR [rbp-16] ; rax += b
mov QWORD PTR [rbp-8], rax ; x = rax

oppure nel codice


movsd xmm0, QWORD PTR [rbp-24] ; xmm0 = a
movsd xmm1, QWORD PTR [rbp-16] ; xmm1 = b
addsd xmm0, xmm1 ; xmm0 += xmm1
movsd QWORD PTR [rbp-8], xmm0 ; x = xmm0

a seconda che si tratti di variabili di tipo int oppure double.


Compilazione in Python
Consideriamo ora questo programma Python

def add(a, b):


return a + b

print(add(1, 3)) # Result: 4


print(add(1.0, 3.0)) # Result: 4.0
print(add('a', 'b')) # Result: 'ab'

Come può Python compilare in un linguaggio assembler la


funzione add, visto che la somma può assumere tanti
significati diversi?
Compilazione in Python
In Python l’istruzione
x = a + b

viene sempre compilata nel codice


load_fast 0 # 0 stands for a
load_fast 1 # 1 stands for b
binary_add # sum the last two nums
store_fast 2 # 2 stands for x

Cosa fa quindi esattamente l’istruzione binary_add?


Esecuzione di codice Python
Per eseguire il file test.py, occorre sempre chiamare python:
$ python test.py

Il programma python è implementato pressappoco così:


int main(int argc, const char argv[argc + 1]) {
initialize();

PyProgram * prog = compile_to_bytecode(argc, argv);


while(1) {
PyCommand * command = get_next_bytecode(prog);
if (! run_command(command))
break;
}
return 0;
}
L’istruzione binary_add
L’istruzione run_command esegue le istruzioni una alla volta. Il
comando binary_add somma tra loro due valori, e funziona
più o meno così:
void binary_add(PyObject * val1,
PyObject * val2,
PyObjectassembler
Una istruzione * result) della
{ Python
Virtual
if Machine può
(isinteger(val1) && corrispondere a{
isinteger(val2))
migliaiaintdiv1
istruzioni assembler della CPU
= get_integer(val1);
int v2 = get_integer(val2);
che staresult.set_type(PY_INTEGER)
effettivamente eseguendo il codice!
result.set_integer(v1 + v2);
} else if (isreal(val1) && isreal(val2)) {
/* ... */
} else /* ... */
}
Linguaggi interpretati
• Di per sé, un linguaggio interpretato non deve
essere necessariamente più lento di un linguaggio
compilato
• Controesempi: in certi domini Java (che usa una
virtual machine come Python) è più veloce del C++
• Molte implementazioni dei linguaggi LISP e Scheme
(interpretati, con tipi dinamici come Python) sono
ordini di grandezza più veloci di Python
• Esistono compilatori Python poco usati (es., pypy,
jython) che producono codice più veloce
Quando usare Python?
• Se un programma non richiede molti calcoli
complessi, Python è solitamente la scelta migliore
• Se un programma Python è 100 volte più lento di
un programma C++, ma completa l’esecuzione in
0,1 secondi, è importante la lentezza?
• Scrivere programmi in Python è comunque molto
più veloce che scriverli in C++
Prova pratica: immagini NASA

Commento del notebook NASA web API


Uso di Python per
il calcolo scientifico
• È possibile usare Python per simulazioni Monte
Carlo? O per calcoli numerici su milioni di
elementi?
• Python permette di invocare funzioni scritte in C e
in Fortran
• Negli anni sono state sviluppate librerie Python
molto potenti per il calcolo scientifico
La libreria NumPy
• Usa il concetto di broadcast: un’operazione su un
array viene propagata su tutti gli elementi dell’array
• La propagazione viene fatta da codice C o Fortran,
ed è quindi velocissima
• Gli algoritmi numerici diventano molto più rapidi
Esempio: integrale midpoint
double midpoint(double fn(double),
double a, double b,
int nstep) {
double sum = 0.;
double h = (b - a) / nstep;
C++ for (int i = 0; i < nstep; ++i) {
sum += fn(a + (i + 0.5) * h);
}
return sum * h;
}

import numpy
Il ciclo for è implicito qui
def midpoint(fn, a, b, nsteps):
Python h = (b - a) / nsteps
x = numpy.linspace(a + h/2, b - h/2, nsteps)
return numpy.sum(fn(x)) * h
Esempio: simulazione
esperimento
Consideriamo l’esercizio 9.5 del corso di TNDS, in cui si simula
la misurazione della viscosità di un fluido misurando la
velocità limite di caduta di una sferetta nel fluido, data da

2𝑅
𝑣𝐿 = 𝜌 − 𝜌0 𝑔
9𝜂

L’esercizio chiede di misurare la velocità di caduta usando un


cronometro per misurare il tempo impiegato a percorrere 40
cm (supponendo che i 40 cm siano percorsi a velocità
costante), ipotizzando un errore sul tempo, sulla distanza e sul
raggio della sfera.
Esempio: simulazione
esperimento

Commento del notebook Fluido viscoso


Broadcasting

tsim = t + np.random.randn(N) * terr


ysim = y + np.random.randn(N) * yerr
rsim = r + np.random.randn(N) * rerr
vsim = ysim / tsim
etasim = calc_eta(rsim, vsim, rho, rho0, g)

In questo codice sono presenti 5 cicli for nascosti


(implementati tutti in C o in Fortran). Un codice C++ sarebbe
meno dispendioso in termini di memoria, ma la velocità del
codice è confrontabile.
Altre possibilità
• Numba è una libreria che compila codice Python in
codice assembler
• f2py compila routine in Fortran e in C e le rende
chiamabili da Python
• Cython è un compilatore che compila una variante
di Python in codice C, che viene poi compilato in
linguaggio macchina
Il linguaggio Julia
Categorie di linguaggi

Compilati Interpretati
• C • Python
Misti
• C++ • MATLAB
• Julia!
• Fortran • IDL
• Pascal • R
• Ada
• Rust
Cos’è Julia?
• Linguaggio molto recente (versione 0.1 rilasciata il
14/2/2013)
• Creato espressamente per il calcolo scientifico
• Facile come Python e veloce come il C++…?
• Non ancora stabile (versione corrente 0.6.2, la 0.7 è
attesa a breve, poi finalmente 1.0): in particolare, il
sistema di gestione delle librerie verrà presto
sostituito
• https://julialang.org/
Funzionamento di Julia
• Il codice viene compilato in un formato chiamato
AST (Abstract Syntax Tree)
• Al momento di essere eseguito, viene compilato in
linguaggio macchina
• Il linguaggio macchina è quello vero della CPU su
cui gira il codice, e sono implementate tutte le
ottimizzazioni tipiche di C++ e di Fortran
• L’ottimizzatore usato è LLVM, usato anche dal
compilatore C++ usato sui sistemi Mac OS X (clang)
Esempio: somma di due numeri
julia> mysum(a, b) = a + b
mysum (generic function with 1 method)

julia> mysum(1, 2)
3

julia> mysum(1.0, 2.0)


3.0
Esempio: somma di due numeri
julia> @code_native mysum(1, 2)
.text
Filename: REPL[4]
pushq %rbp
movq %rsp, %rbp
Source line: 1
leaq (%rcx,%rdx), %rax
popq %rbp
retq
nopw (%rax,%rax)
Esempio: somma di due numeri
julia> @code_native mysum(1.0, 2.0)
.text
Filename: REPL[5]
pushq %rbp
movq %rsp, %rbp
Source line: 1
addsd %xmm1, %xmm0
popq %rbp
retq
nopw (%rax,%rax)
Esempio: somma di due vettori
Implementiamo ora una funzione che calcola la somma degli
elementi di due vettori, equivalente al più semplice a + b:

function mysum(a, b) La funzione zero restituisce


acc = zero(a) uno zero di tipo adatto (0 se a è
for i = 1:length(a) Come in Python, non servono ;
intero, 0.0 se a è floating-point,
acc[i] += a[i] + b[i] per delimitare istruzioni
end
un vettore/matrice, etc.)
acc In Julia, return può essere
end sottinteso per l’ultima
istruzione della funzione
Esempio: somma di due vettori
julia> mysum([1.0, 2.0], [3.0, 4.0])
2-element Array{Float64,1}:
4.0
6.0

Julia> mysum([1.0, 2.0, 3.0], [4.0, 5.0])


ERROR: BoundsError: attempt to access 2-element
Array{Float64,1} at index [3]
Stacktrace:
[1] mysum(::Array{Float64,1}, ::Array{Float64,1}) at
.\REPL[19]:4
A differenza del C++, Julia
verifica l’accesso ai
membri di un array: più
lento ma più sicuro!
Somma di due vettori veloce
Se siamo sicuri che il codice acceda sempre agli elementi
«giusti» dell’array, la macro @inbounds rimuove i controlli e
abilita una serie di istruzioni macchina avanzate per rendere il
codice più veloce:
function mysum(a, b)
acc = zero(a)
@inbounds for i = 1:length(a)
acc[i] += a[i] + b[i]
end
acc
end
Esempio: integrale midpoint
double midpoint(double fn(double),
double a, double b,
int nstep) {
double sum = 0.;
double h = (b - a) / nstep;
C++ for (int i = 0; i < nstep; ++i) {
sum += fn(a + (i + 0.5) * h);
}
return sum * h;
}

import numpy

def midpoint(fn, a, b, nsteps):


Python h = (b - a) / nsteps
x = numpy.linspace(a + h/2, b - h/2, nsteps)
return numpy.sum(fn(x)) * h
Esempio: integrale midpoint

import numpy

def midpoint(fn, a, b, nsteps):


Python h = (b - a) / nsteps
x = numpy.linspace(a + h/2, b - h/2, nsteps)
return numpy.sum(fn(x)) * h
Essendo nato per il calcolo scientifico, Julia
L’espressione
implementa fn.(x)
funzioni applica
come un broadcast:
linspace e sum
inlamodo
funzione è eseguita
fn(senza
nativo su tutti
bisogno gli elementi di x
di librerie)
(qui Python avrebbe
function problemi
midpoint(fn, a,inb,alcuni
steps)casi)
h = (b - a) / steps
Julia x = linspace(a + h/2, b - h/2, steps)
sum(fn.(x)) * h
end
Prova pratica: frattale di Julia

Commento del notebook Frattali in Julia


Altre funzionalità
• Supporto nativo per numeri razionali (2//7) e
interi/float a precisione arbitraria
• Uso di simboli matematici e moltiplicazione
implicita: ξ = 2π+1
• Capacità di definire nuovi operatori: ⊕, ⨯, …
• Possibilità di usare librerie Python (PyCall, PyPlot)
Altre funzionalità
• Funzioni per rendere il codice parallelo (sia su
macchine multicore che su cluster)
• Possibilità di modificare il linguaggio (omoiconicità)
circ = @circuit begin
j_in = voltagesource(), [-] ⟷ gnd
r1 = resistor(1e3), [1] ⟷ j_in[+]
c1 = capacitor(47e-9), [1] ⟷ r1[2], [2] ⟷ gnd
d1 = diode(is=1e-15), [+] ⟷ r1[2], [-] ⟷ gnd
d2 = diode(is=1.8e-15), [+] ⟷ gnd, [-] ⟷ r1[2]
j_out = voltageprobe(), [+] ⟷ r1[2], [-] ⟷ gnd
end

https://github.com/HSU-ANT/ACME.jl
Julia e la programmazione OOP
• Julia non è OOP come il C++ o Python: OOP non è
una scelta naturale per codici numerici
• Julia implementa un paradigma nuovo, il multiple
dispatch, che è pensato apposta per il calcolo
scientifico
• Si tratta di una versione avanzata dell’overloading di
operatori che implementa il C++