LOAD_FAST, per andare veloci su CPython

Sembra uno slogan pubblicitario da due soldi, ma racchiude una delle grandi “verità” dell’implementazione “principale” (mainstream?), e non solo, di questo magnifico linguaggio: le variabili locali sono veloci.

Per variabili locali s’intendono tutti i parametri passati alle funzioni (e metodi), e quelle variabili che vengono “dichiarate” all’interno di una funzione, intendendo con ciò la presenza di almeno un’operazione di assegnazione che le riguardi all’interno di quest’ultima.

Si tratta di operazioni (istruzioni) molto veloci perché, rispetto a tante altre e come vedremo nei prossimi articoli, eseguono molto meno lavoro dietro le quinte per essere portate a compimento.

Nello specifico, nel precedente articolo abbiamo incontrato l’istruzione LOAD_FAST, che si occupa di caricare sullo stack il valore presente nella variabile locale specificata.

Quando la macchina virtuale (VM) di CPython incontra un’istruzione come questa, va ad eseguire un piccolo (come c’era da aspettarsi) blocco di linee di codice scritte in linguaggio C (da cui la C di CPython):

case LOAD_FAST:
  x = GETLOCAL(oparg);
  if (x != NULL) {
    Py_INCREF(x);
    PUSH(x);
    goto fast_next_opcode;
  }
  format_exc_check_arg(PyExc_UnboundLocalError,
    UNBOUNDLOCAL_ERROR_MSG,
    PyTuple_GetItem(co->co_varnames, oparg));
  break;

Come si può intuire dalla prima e dall’ultima istruzione, si tratta di un caso che è presente in una lunga lista racchiusa all’interno di un’istruzione switch, che si occupa di controllare ed eseguire uno a uno tutti i bytecode supportati dall’attuale implementazione della VM.

La presenza di un’istruzione switch è comune, ma non scontata. Ad esempio con Python 3.x e col compilatore GCC è del tutto assente, perché viene fatto uso di un costrutto non standard del linguaggio C (di cui abbiamo parlato in questo articolo) che consente di evitarlo velocizzando l’esecuzione.

Il cuore è rappresentato dalla prima riga utile, che però fa uso di una macro il cui nome è autoesplicativo (serve, appunto, a prelevare il valore che c’interessa), ma non il codice, che riporto di seguito per chiarezza:

#define GETLOCAL(i)	(fastlocals[i])

Si tratta, in buona sostanza, di recuperare il valore desiderato da un vettore chiamato fastlocals, facendo uso dell’indice associato alla variabile in oggetto; l’indice è presente nella variabile oparg, che rappresenta l’unico argomento (un intero a 16 bit) previsto per questo bytecode (e per tanti altri).

Della struttura dei bytecode e di come funziona il loop della VM si farà carico un altro articolo. Per il momento assumiamo che quest’indice sia già a disposizione in oparg al momento dell’esecuzione di quel pezzo di codice.

Una volta prelevato il valore, la riga successiva si occupa di controllare… che esista, altrimenti viene sollevata un’eccezione UnboundLocalError (penultima riga).

Può sembrare una stupidaggine, perché sappiamo che “qualcuno” un valore l’ha certamente depositato in quella variabile (in realtà lo si evince soltanto dall’analisi del completo workflow, ma al momento prendiamo per buono questo assunto), tranne per una “piega” che si trova nascosta nella sintassi di Python, come dimostra il seguente pezzo di codice:

def f():
  x = x

f()

il quale provoca il famigerato UnboundLocalError, e lo stesso si verifica anche se fosse stata precedentemente dichiarata una variabile globale x:

x = 0

def f():
  x = x

f()

Tutto ciò si verifica perché il compilatore assume e considera che una variabile sia locale se esiste una qualche assegnazione a suo carico all’interno del corpo della funzione. Il che risulta vero, dall’unica riga di codice presente in f.

Nello specifico, l’errore deriva dal fatto che x viene considerata locale a prescindere che esista o meno una variabile globale x dalla quale si sarebbe comunque potuto leggerne il valore, da ricopiare poi nella variabile locale che porta lo stesso nome. Ma il compilatore genera codice che legge la variabile locale x, che in quel preciso momento non risulta ancora assegnata, e quindi viene poi sollevata l’eccezione.

Tornando al codice di LOAD_FAST, se il controllo è positivo (quindi è presente un valore nella variabile; il che si verifica praticamente sempre), si passa a eseguire il blocco di codice all’interno dell’if, che consta di 3 sole istruzioni, delle quali le prime 2 si occupano di effettuare il lavoro vero e proprio, mentre l’ultima (il goto) è necessario soltanto a restituire il controllo al main loop della VM, per processare un nuovo bytecode (generalmente il successivo).

Py_INCREF è una macro che si occupa di aggiornare il reference count dell’oggetto, cioè il numero di volte che l’oggetto è stato (ed è ancora) referenziato. Ciò si rende necessario perché CPython utilizza un garbage collector molto semplice, ma anche efficace (per come funziona CPython), che viene chiamato appunto Reference Counting in letteratura.

Al momento e ai fini di quest’articolo ci basti sapere che quella macro equivale all’esecuzione del seguente codice:

#define Py_INCREF(op)  (((PyObject*)(op))->ob_refcnt++)

cioè all’incremento del numero di riferimenti (assumendo che op sia il puntatore alla nostra variabile di tipo PyObject, come abbiamo visto in precedenza).

Infine l’ultima macro, PUSH, si occupa finalmente di appendere questo valore in cima allo stack:

#define PUSH(v)  (*stack_pointer++ = (v))

stack_pointer è, ovviamente, la variabile (puntatore) che punta alla cima dello stack, che viene incrementata (stack_pointer++) una volta che il valore sia stato memorizzato (*stack_pointer = v)

Tirando le somme, e “srotolando” il codice, le istruzioni normalmente eseguite risultano soltanto quattro:

x = GETLOCAL(oparg);
if (x != NULL) {
  x->ob_refcnt++;
  *stack_pointer++ = x;
}

Tutto sommato non è male come risultato, anche se abbiamo ignorato l’overhead (all’incirca costante) del ciclo principale della VM, che deve prelevare il bytecode, decodificarlo (incluso l’eventuale parametro), ed “inviarlo” all’apposita sezione dello switch (verrà meglio esposto il tutto in un futuro articolo e comunque riguarda tutti i bytecode).

Si potrebbe storcere il naso davanti a quell’if, che comporta un confronto e un salto condizionato che potrebbe invalidare la pipeline, facendo decadere le prestazioni della CPU, ma, come già detto, la condizione risulta praticamente sempre vera, per cui anche i più scarsi branch predictor fanno un eccellente lavoro, come ha ben esposto il nostro pleg nella sua serie di articoli su questo componente (ormai vitale) dei moderni microprocessori.

Certamente bisogna tener conto del fatto che il confronto e il salto generato dal compilatore non sono istruzioni vuote / inesistenti, ma comunque vengono eseguite e impegnano risorse (spazio, bandwidth di memoria & cache L1/L2/3, unità di decodifica, unità di branch prediction).

Tutte cose che generalmente non si verificano con linguaggi staticamente tipizzati, ma è il prezzo da pagare per la maggior produttività che si ottiene con Python (di cui, però, esistono anche implementazioni nettamente più veloci e che non soffrono di queste problematiche).

In definitiva l’utilizzo di variabili locali, di cui la LOAD_FAST rappresenta l’istruzione / bytecode più utilizzata, è sicuramente la strada maestra da seguire se si vuole tener conto anche delle prestazioni nella scrittura del nostro codice.

Lo si vedrà meglio quando si effettuerà il confronto con le variabili globali, quelle “condivise” (“cell“) con le funzioni interne, e, peggio ancora, quando si vedrà qual è il prezzo salato da pagare per un’operazione che, purtroppo, è anch’essa molto frequente: l’accesso agli attributi di un oggetto.

P.S. In alcune parti dell’articolo ho volutamente semplificato i discorsi e cambiato qualche riga di codice rispetto alla reale implementazione perché, sebbene l’articolo sia di per sé abbastanza complesso, ho cercato di mantenere un taglio quanto più divulgativo possibile.

Press ESC to close