Facoltà di Scienze Matematiche, Fisiche e Naturali Corso di laurea in Informatica Tesi di laurea Progettazione ed implementazione di matrici sparse basate su strutture dati cache-oblivious Candidato: Marco Poletti Relatore: Prof. Enea Zaffanella Correlatore: Prof. Roberto Bagnara Anno accedemico 2009/2010 Indice Indice 1 1 Introduzione 1.1 Matrici sparse . . . . . . . . . . . . . . . . . . . . . . . . . . 1.2 Caratteristiche richieste . . . . . . . . . . . . . . . . . . . . 1.3 La Parma Polyhedra Library . . . . . . . . . . . . . . . . . . 3 3 4 4 2 Strutture dati per la memorizzazione di matrici sparse 2.1 Strutture globali . . . . . . . . . . . . . . . . . . . . . . . . 2.2 Strutture basate su righe . . . . . . . . . . . . . . . . . . . . 9 10 12 3 Implementazione con liste 3.1 Rappresentazione della matrice 3.2 Benchmark . . . . . . . . . . . 3.3 Profiling . . . . . . . . . . . . . 3.4 Liste singole . . . . . . . . . . . . . . . 15 15 15 18 19 . . . . 21 21 23 23 24 . . . . . . . 25 25 27 28 28 28 31 32 . . . . . . . . . . . . . . . . . . . . . . . . 4 Algoritmi e strutture dati cache-oblivious 4.1 Gerarchia della memoria . . . . . . . . . . 4.2 Algoritmi cache-oblivious . . . . . . . . . . 4.3 Analisi del numero di cache miss . . . . . . 4.4 Confronto con gli algoritmi cache-aware . . . . . . . . . . 5 Alberi binari di ricerca cache-oblivous 5.1 La struttura dati CO_Tree . . . . . . . . . . 5.2 Ricostruzione dell’albero . . . . . . . . . . . 5.3 Inserimento di un elemento nell’albero . . . 5.4 Rimozione di un elemento dall’albero . . . . 5.5 Ribilanciamento . . . . . . . . . . . . . . . . 5.6 Ricerca dell’elemento precedente e successivo 5.7 Prestazioni . . . . . . . . . . . . . . . . . . . 1 . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . Indice 5.8 Confronto con i B-tree . . . . . . . . . . . . . . . . . . . . . 33 6 Implementazione con alberi cache-oblivious 35 6.1 Modifiche alla struttura CO_Tree . . . . . . . . . . . . . . . 35 6.2 Benchmark . . . . . . . . . . . . . . . . . . . . . . . . . . . 36 6.3 Profiling . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 38 7 Implementazione DFS ottimizzata 41 7.1 Valori delle soglie . . . . . . . . . . . . . . . . . . . . . . . . 41 7.2 Calcolo veloce degli indici dei figli . . . . . . . . . . . . . . . 42 7.3 Un altro punto di vista . . . . . . . . . . . . . . . . . . . . . 43 7.4 Ottimizzazioni fatte usando CO_Tree come struttura lineare 47 7.5 Benchmark . . . . . . . . . . . . . . . . . . . . . . . . . . . 48 7.6 Profiling . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 51 8 Classi implementate 53 8.1 CO_Tree . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 53 8.2 Sparse_Row . . . . . . . . . . . . . . . . . . . . . . . . . . . 56 8.3 Sparse_Matrix . . . . . . . . . . . . . . . . . . . . . . . . . 61 9 Conclusione 65 A Interfacce complete delle classi 67 A.1 Interfaccia di CO_Tree . . . . . . . . . . . . . . . . . . . . . 67 A.2 Interfaccia di Sparse_Row . . . . . . . . . . . . . . . . . . . 71 A.3 Interfaccia di Sparse_Matrix . . . . . . . . . . . . . . . . . 74 Bibliografia 2 77 Capitolo 1 Introduzione Questa tesi illustra alcune strutture dati per la rappresentazione sparsa di matrici, ponendosi come obiettivo l’aumento delle prestazioni di un risolutore di problemi di programmazione lineare mista (chiamato MIP, acronimo di Mixed Integer Programming) e di un risolutore per problemi di programmazione lineare parametrica (chiamato PIP, acronimo di Parametric Integer Programming). Entrambi i risolutori fanno parte della libreria Parma Polyhedra Library (http://www.cs.unipr.it/ppl/), che in seguito verrà chiamata semplicemente PPL. 1.1 Matrici sparse Per “matrici sparse” si intendono matrici in cui gran parte degli elementi è zero, ed è possibile sfruttare questo fatto per ottenere un miglioramento in termini di prestazioni e/o di memoria (cf. [Veldhorst82, Duff86]). Una rappresentazione matriciale si dice sparsa se utilizza una struttura dati che permette di non memorizzare questi zeri. Oltre al risparmio di memoria, questo permette anche un aumento delle prestazioni, perché spesso basta elaborare solo gli elementi diversi da zero. Ad esempio, se una matrice 100 × 100 contiene solo 10 elementi diversi da zero e viene memorizzata con una rappresentazione sparsa, quando la si vuole moltiplicare per uno scalare si possono eseguire solo 10 moltiplicazioni al posto di 10˙000, perché la moltiplicazione di zero per un qualsiasi scalare valuta a zero. 3 1. Introduzione 1.2 Caratteristiche richieste Il risolutore viene tipicamente utilizzato per matrici abbastanza piccole (vedere la sezione 1.3), quindi la struttura utilizzata non solo deve avere un buon comportamento asintotico, ma deve anche essere veloce in presenza di pochi elementi. L’utilizzo di matrici piccole, tra l’altro, è il motivo per cui il risolutore è stato originariamente implementato con matrici dense invece che sparse. Inoltre il risolutore tipicamente opera sulla matrice per riga, processandone tutti gli elementi. Quindi la struttura non deve essere pensata per operazioni algebriche tra matrici (ad esempio, somma o prodotto di matrici), ma per questo specifico pattern di accesso. 1.3 La Parma Polyhedra Library La PPL ([PPLweb]) è una libreria scritta in C++ che fornisce delle classi che modellano astrazioni numeriche, tra cui poliedri convessi, griglie di punti a spaziatura regolare e power-set; tutte queste astrazioni supportano un ampio insieme di operazioni, sia generiche sia specifiche di certe astrazioni. Vengono fornite delle primitive per l’analisi di terminazione, generando automaticamente le funzioni lineari di ranking. Per implementare alcune di queste operazioni viene utilizzato un risolutore di problemi di programmazione lineare mista, in aritmetica esatta, e un risolutore di problemi di programmazione lineare parametrica. La libreria si pone vari obiettivi: in primo luogo, fornire tutte le funzionalità di base tipicamente richieste dalle applicazioni di analisi e verifica del software per effettuare calcoli approssimati sulle astrazioni fornite; inoltre, mira alla facilità d’uso, all’efficienza, alla portabilità e alla possibilità di sfruttare tutta la memoria virtuale disponibile per la memorizzazione di dati. La PPL fornisce delle interfacce per poter essere usata da diversi linguaggi di programmazione: oltre al C++ può essere usata anche in C, Java, OCaml e Prolog. La PPL è un progetto open-source e viene distribuita con la licenza GNU GPL 3. Per lo sviluppo viene utilizzato un repository git pubblico (http: //www.cs.unipr.it/git/gitweb.cgi?p=ppl/ppl.git;a=summary). 4 1.3. La Parma Polyhedra Library Utilizzi della PPL La PPL è attualmente utilizzata da diversi gruppi di ricerca, a livello internazionale (per maggiori informazioni, vedere http://www.cs.unipr. it/ppl/Documentation/citations). In termini di diffusione, uno degli utilizzi più significativi è all’interno di Graphite (http://gcc.gnu.org/wiki/Graphite), un framework per l’ottimizzazione dei cicli usato all’interno del progetto GCC (http: //gcc.gnu.org/), che fornisce i compilatori standard usati nei sistemi UNIX-like. In questo ambito, al fine di ottimizzare la generazione del codice per i costrutti di iterazione, occorre impostare e risolvere in maniera esatta un elevato numero di problemi di programmazione lineare (parametrica) intera, ognuno dei quali caratterizzato da un numero limitato di variabili e di vincoli. È quindi importante avere a disposizione dei risolutori ottimizzati per questo specifico contesto applicativo, nel quale le matrici sono di dimensioni ridotte. Oggetto della tesi L’oggetto della tesi è l’implementazione sparsa della matrice dei vincoli usata come tableau nell’algoritmo del simplesso usato dai risolutori MIP e PIP. Il tableau non è l’unica matrice utilizzata all’interno dei risolutori: per la memorizzazione dei vincoli iniziali viene usata un’altra matrice che, avendo dei diversi requisiti di efficienza, non è stata modificata durante questo lavoro di tesi ed è sempre memorizzata come densa. Il risolutore MIP L’acronimo MIP sta per Mixed Integer Programming, e indica la classe dei problemi di programmazione lineare in cui alcune delle variabili possono assumere solo valori interi, mentre le altre non hanno questo vincolo. Questo risolutore viene usato all’interno della PPL principalmente per convertire poliedri da un tipo ad un altro e per eseguire delle analisi di terminazione mediante l’individuazione di opportune funzioni di ranking. Il risolutore MIP permette di controllare se un problema di programmazione lineare ammette soluzione e, se lo si desidera, di trovare la soluzione ottima secondo una funzione obiettivo specificata. È possibile ag5 1. Introduzione giungere vincoli e modificare la funzione obiettivo, sfruttando il risultato dell’elaborazione precedente per velocizzare il calcolo. L’operazione più comune effettuata dal risolutore MIP sul tableau è la combinazione lineare di due righe della matrice. Quindi è necessario utilizzare un’implementazione sparsa che sia molto efficiente in questo caso. Un’altra operazione frequente è la scansione sequenziale di una riga della matrice, dall’inizio alla fine. Inoltre, vengono effettuate anche altre operazioni, tra cui: l’eliminazione di una colonna, la lettura random di una riga e la combinazione lineare di una riga esterna con una riga della matrice. Si noti che la combinazione lineare di righe e la scansione lineare di una riga sono operazioni molto simili per quanto riguarda le righe dense, ma non lo sono affatto per righe sparse; infatti, nelle righe sparse la scansione in lettura o in scrittura degli elementi già memorizzati in una riga modifica solo tali elementi, mentre la combinazione lineare di due righe può avere bisogno di memorizzare ulteriori elementi nella riga che viene modificata (quelli che diventano diversi da zero), ed è quindi più onerosa in termini di tempo. Il risolutore PIP L’acronimo PIP significa Parametric Integer Programming, e viene usato per indicare problemi di programmazione lineare che dipendono dal valore di alcuni parametri, anch’essi interi. Ogni assegnazione di valori ai parametri individua un problema MIP, quindi un problema PIP può essere visto come la risoluzione contemporanea di una classe di problemi MIP. Le operazioni che il risolutore PIP esegue sul tableau sono simili a quelle eseguite dal risolutore MIP. Come succede anche per il risolutore MIP, le matrici in gioco sono molto piccole: prima della risoluzione possono avere solo poche decine di elementi, ed è necessario utilizzare strutture dati che siano comunque efficienti, anche in questi casi. Tipo dei coefficienti Durante la configurazione della PPL, prima della sua compilazione, è possibile selezionare il tipo dei coefficienti usati, tra un elenco di tipi supportati. In genere vengono usati come coefficienti degli oggetti di tipo mpz_class, che sono degli interi con segno a precisione arbitraria forniti dalla libreria GMP (http://gmplib.org/). 6 1.3. La Parma Polyhedra Library In alternativa, si possono usare anche degli interi di macchina, attraverso delle classi wrapper fornite dalla PPL che controllano l’assenza di overflow nei calcoli, lanciando un’eccezione quando essi si verificano. 7 Capitolo 2 Strutture dati per la memorizzazione di matrici sparse Per la memorizzazione di matrici sparse, in certi ambiti si può sfruttare lo specifico pattern di zeri presente nelle matrici considerate. Ad esempio, se le matrici coinvolte possono avere elementi diversi da zero solo nella diagonale principale e nelle due diagonali adiacenti (in questo caso si parla di matrici a banda), per memorizzare la matrice in modo efficiente bastano 3 vettori. Questo non è però il caso della PPL, in cui la matrice considerata ha una struttura non predicibile, potenzialmente irregolare, e richiede quindi rappresentazioni sparse generali, che si possano applicare a qualunque pattern di zeri. Le strutture dati di uso generale per la memorizzazione di matrici sparse si dividono in due categorie: quelle che memorizzano separatamente ogni riga (in cui la descrizione della struttura dati si riduce alla descrizione di come memorizzare una riga) e quelle che invece adottano una struttura globale per memorizzare tutti gli elementi. In questo capitolo si fa una panoramica delle strutture dati più comuni per la memorizzazione di matrici sparse. Per maggiori informazioni, vedere [Veldhorst82, Duff86]. 9 2. Strutture dati per la memorizzazione di matrici sparse 2.1 Strutture globali Lista di triple La struttura dati più semplice che si può immaginare è una semplice lista non ordinata di triple hi, j, xi in cui i e j sono rispettivamente l’indice di riga e l’indice di colonna a cui è associato il valore x. Questa struttura viene comunemente chiamata COO (da COOrdinate format). In questa struttura l’accesso e la modifica di un elemento costano O(n2 ) (indicando con n il massimo tra le due dimensioni della matrice), come anche l’eliminazione di righe e di colonne. La scansione sequenziale di una riga o di una colonna, se non si usa memoria aggiuntiva, costa O(n4 ), perché bisogna ogni volta scorrere tutti gli O(n2 ) elementi per trovare l’elemento successivo. Essendo questa un’operazione molto frequente nella PPL, questa struttura dati è da scartare. Una variante di questo approccio prevede l’uso di tre vettori con la stessa lunghezza: uno per i valori, uno per gli indici di riga e l’altro per gli indici di colonna. La scansione sequenziale diventa più veloce, ma mantiene lo stesso bound asintotico e quindi anch’essa non è adatta a questo tipo di applicazione. Struttura Yale Nella struttura Yale la matrice viene memorizzata in tre vettori: A, IA e JA. Il vettore A contiene i valori degli elementi non nulli della matrice, ordinati per riga e, a parità di riga, ordinati per colonna. Il vettore JA ha la stessa lunghezza del vettore A e contiene gli indici di colonna relativi ai valori memorizzati in A. Il vettore IA contiene r + 1 indici, con r il numero di righe della matrice sparsa. Gli indici contenuti in IA sono tali che gli elementi non nulli di ogni riga i sono memorizzati in A a partire dalla posizione IA[i] (inclusa) e fino alla posizione IA[i + 1] (esclusa). Questa struttura viene chiamata CSR (acronimo di Compressed Sparse Row) quando il vettore JA è memorizzato prima del vettore IA. Struttura CSC La struttura CSC (acronimo di Compressed Sparse Column) è concettualmente simile alla CSR, ma vengono scambiati i ruoli delle righe e delle colonne. 10 2.1. Strutture globali Quindi, gli elementi nel vettore A sono ordinati prima per colonna e poi per riga; per ogni elemento viene memorizzato il suo indice di riga in IA, e il vettore JA ha un comportamento simile al vettore IA della struttura CSR, ma ogni coppia di elementi successivi individua in A una colonna invece di una riga. Struttura a nodi In [Veldhorst82] (a pagina 107 e seguenti) viene descritta una struttura a nodi per la memorizzazione di una matrice sparsa. Questa struttura prevede di memorizzare separatamente ogni elemento non nullo della matrice, in un nodo. Ogni nodo contiene gli indici di riga e colonna, il valore associato a quell’elemento della matrice, un puntatore al nodo successivo nella riga e un puntatore al nodo successivo nella colonna. Oltre ai nodi vengono memorizzati anche due vettori; uno contiene i puntatori al primo elemento di ogni riga e l’altro quelli al primo elemento di ogni colonna. In figura 2.1 si può vedere una rappresentazione grafica di questo schema di memorizzazione. Con questa struttura dati, l’accesso ad un elemento costa O(n) e la lettura sequenziale di una riga costa anch’essa O(n). L’eliminazione (azzeramento logico) di un elemento della matrice costa O(n) anche se si ha già un puntatore a quell’elemento, perché non ci sono puntatori all’indietro, e quindi è necessario scorrere dall’inizio la lista dei nodi di quella riga e di quella colonna. Una variante di questa struttura viene usata da GLPK (acronimo di GNU Linear Programming Kit), una libreria che fornisce, tra le altre cose, un risolutore di problemi di programmazione lineare mista. Ci sono due differenze: in GLPK ogni nodo contiene anche dei puntatori al nodo precedente e a quello successivo nella riga, e i nodi non sono ordinati: non è detto che il nodo successivo (nella riga o nella colonna) abbia indice superiore. Queste modifiche rendono le operazioni matriciali un po’ più efficienti, ma le prestazioni delle operazioni non matriciali peggiorano ulteriormente; in GLPK non sono nemmeno implementate. Nella struttura usata da GLPK la lettura sequenziale di una riga non è facilmente implementabile; l’astrazione migliore che si può dare al codice chiamante è quella di una lista non ordinata di elementi; inoltre, le operazioni che possono creare nuovi elementi non nulli (ad esempio la combinazione lineare di una riga sparsa con un’altra) non possono essere implementate in-place. 11 2. Strutture dati per la memorizzazione di matrici sparse 0 17 0 3 0 0 5 0 2 0 0 8 0 1 0 0 0 1 17 0 3 3 Ø 1 2 5 Ø Ø 2 0 2 Ø 2 3 8 Ø Ø 3 1 1 Ø Ø Legenda: indice di riga indice di colonna valore successivo nella colonna successivo nella riga Figura 2.1: Una matrice sparsa e la sua rappresentazione con una struttura a nodi. 2.2 Strutture basate su righe Per quanto riguarda le strutture basate su righe, la struttura che le contiene può essere un vettore, una lista o un albero. L’uso di un vettore permette di accedere velocemente alle righe (in tempo O(1)), ma rallenta lo scambio di righe e rende estremamente costosa l’eliminazione di righe che non siano le ultime, perché in questo caso è necessario spostare tutte le righe successive. L’uso di una lista produce l’effetto opposto: l’accesso non sequenziale alle righe costa O(n) invece di O(1), ma lo scambio e l’eliminazione di righe possono essere eseguite in tempo costante, invece di O(n), se si hanno a disposizione degli iteratori che puntano a quelle righe. Utilizzando un albero bilanciato si arriva ad un compromesso tra liste e vettori: le operazioni di accesso ad una riga, scambio di righe ed eliminazione di righe costano tutte O(log n). Usando una di queste strutture si può semplificare l’interfaccia: infatti è possibile definire operator[]() in modo da restituire un riferimento ad 12 2.2. Strutture basate su righe una riga; nella struttura a nodi, invece, bisognerebbe restituire un oggetto di una classe creata appositamente per questo scopo, complicando il codice e rendendolo meno intuitivo. Nella PPL non è mai necessario rimuovere righe che non siano le ultime, e gli scambi di righe sono operazioni abbastanza rare. L’accesso alle righe, invece, è un’operazione molto frequente. Quindi, per le strutture dati basate su righe sparse verrà usato un vettore di righe. Struttura a liste Questa struttura dati prevede di memorizzare ogni riga come lista ordinata di coppie hi, xi, dove i è l’indice di colonna associato al valore x. In questo modo l’accesso sequenziale ad una riga costa O(n) come nella struttura a nodi, e c’è meno overhead perché c’è solo un puntatore per nodo invece di due. Questo, però, impedisce di scorrere in modo efficiente una colonna della matrice, operazione che comunque è poco frequente all’interno della PPL. 13 Capitolo 3 Implementazione con liste 3.1 Rappresentazione della matrice La prima struttura dati che è stata considerata è una semplice struttura basata su righe, ognuna implementata con una lista (vedere la sezione 2.2). Si può vedere un esempio di questa rappresentazione in figura 3.1. Per le liste, inizialmente è stata utilizzata la struttura dati std::list fornita dalla Standard Template Library (STL), che implementa liste doppiamente concatenate, poi una struttura dati ad-hoc che implementa liste singole. L’obiettivo della struttura con liste singole era di ottenere un risparmio di memoria, memorizzando solo un puntatore per nodo al posto di due, e anche di tempo CPU, non avendo più la necessità di aggiornare tali puntatori. 3.2 Benchmark Per misurare le prestazioni dell’implementazione sparsa basata su liste, è stato misurato il tempo di esecuzione di ppl_lpsol (l’acronimo LPSOL sta per Linear Programming SOLver), un programma fornito con la PPL che si basa sul risolutore MIP, simile al programma glpsol fornito dalla libreria GLPK. I file di input utilizzati per l’esecuzione di ppl_lpsol sono quelli inclusi nei sorgenti della PPL; ognuno di essi descrive un problema di programmazione lineare nel formato standard MPS. Ogni file di input è stato testato con vari set di opzioni, tutte le combinazioni possibili delle opzioni seguenti: • -p0: usa il cosiddetto float-pricing, cioè esegue dei calcoli in virgola mobile per decidere la variabile da far entrare in base. È più efficiente 15 3. Implementazione con liste 1 17 0 17 0 0 0 1 0 2 0 5 0 0 3 0 8 0 3 3 Ø 3 8 Ø 2 5 0 2 1 1 Ø Ø Figura 3.1: Una matrice sparsa e la sua rappresentazione con liste dell’exact-pricing. • -p1: usa l’exact-pricing. Nonostante questa tecnica sia meno efficiente del float-pricing, dà risultati riproducibili anche tra architetture diverse, e quindi è utile in certi contesti, ad esempio per il debug. • -m: minimizza la funzione obiettivo. • -M: massimizza la funzione obiettivo. In figura 3.2 sono mostrati i risultati dei benchmark dell’implementazione sparsa a confronto con quella densa preesistente. Ogni pallino indica il risultato dell’esecuzione di un test. Notare che l’asse delle ascisse di entrambi i grafici usa una scala logaritmica, per rendere il grafico più comprensibile. La linea tratteggiata indica l’andamento, dedotto dalle varie misurazioni. Per la misurazione della memoria virtuale utilizzata si è usato il comando ulimit della shell Bash, che è in grado di limitare la memoria virtuale associata ad un processo. Ogni test è stato eseguito ripetutamente attraverso ulimit, utilizzando un processo di bisezione per trovare la minima quantità di memoria virtuale con cui il test poteva essere eseguito. Prima di misurare la memoria utilizzata nei vari test è stata effettuata la misurazione della memoria virtuale necessaria al comando ppl_lpsol --help (con la stessa tecnica di bisezione) e si è sottratto il valore ottenuto dai risultati delle misurazioni sui singoli test. In questo modo, la memoria riportata nei grafici è solo la memoria dati usata dal processo, e non entra in gioco la memoria virtuale necessaria per il codice del programma, né quella per le librerie caricate in modo dinamico. È bene notare che, comunque, non viene misurata solo la memoria usata dalla matrice in esame, ma di tutte le strutture dati dinamiche usate dal processo; quindi le altre strutture dati, in particolare le matrici dense utilizzate per 16 3.2. Benchmark Risparmio di memoria con matrici sparse 0 -10 -20 -30 -40 -50 -60 -70 -80 -90 1 10 100 1000 10000 Memoria necessaria con matrici dense Risparmio di tempo con matrici sparse 120 100 80 60 40 20 0 -20 -40 -60 -80 0.01 0.1 1 10 100 1000 Tempo di elaborazione con matrici dense Figura 3.2: Il risparmio di memoria e di tempo di elaborazione ottenuti utilizzando l’implementazione sparsa basata su liste la memorizzazione dei vincoli iniziali, impongono un limite superiore al risparmio di memoria ottenibile usando matrici sparse. Come si può vedere dai grafici, il risparmio di memoria è consistente, attorno al 40%. Questo indica che le matrici coinvolte contengono molti zeri, come ci si aspettava. Il risparmio in termini di tempo CPU, invece, è più ridotto, intorno al 17 3. Implementazione con liste 20-30%. A fronte del marcato risparmio di memoria era lecito aspettarsi un risparmio maggiore anche in termini di CPU. Per capire le ragioni di questi risultati è stato fatto un profiling dell’esecuzione. 3.3 Profiling In generale, per profiling si intende la misurazione di varie informazioni riguardanti un programma durante la sua esecuzione. Nello specifico è stato usato il tool Callgrind (http://valgrind.org/ info/tools.html#callgrind) della suite di programmi Valgrind (http: //valgrind.org), che permette di simulare l’esecuzione di un programma misurando, per ogni funzione o metodo, il numero di istruzioni eseguite, il numero di chiamate effettuate e il numero di cache miss (simulando due livelli di cache, L1 ed L2). Per l’analisi e la visualizzazione dei risultati è stato usato il programma KCachegrind (http://kcachegrind.sourceforge.net/html/Home. html), che è un’interfaccia grafica per visualizzare i dati misurati da Callgrind, e permette la navigazione tra le varie funzioni eseguite. Per il profiling era interessante scegliere un test in cui c’era un buon risparmio di memoria, ma l’implementazione sparsa era comunque più lenta della densa, in modo da vedere in quali punti del codice veniva speso il tempo in più. Con questo scopo si è scelto il test boeing2.mps con le opzioni -s -m -p1 (minimizzazione della funzione obiettivo, risoluzione non incrementale con l’exact-pricing), in cui c’è un risparmio di memoria del 43% (si passa da 14 MB a 8 MB) ma, nonostante ciò, c’è un aumento del tempo di elaborazione pari al 15% (si passa da 0,39 secondi a 0,45). Durante l’esecuzione del test, nella versione densa erano state eseguite circa 1,88 miliardi di istruzioni macchina, mentre nella sparsa ne erano state eseguite 1,69 miliardi, cioè il 10% in meno. Questo continuava a non spiegare l’aumento del tempo di elaborazione: considerando solo questo dato, il tempo di elaborazione avrebbe dovuto ridursi. Oltre al numero di istruzioni macchina eseguite, gli altri fattori che entrano in gioco sono l’attesa del completamento di operazioni di I/O e il numero di cache miss. L’I/O effettuato da questi test è molto ridotto, si limita al caricamento dell’eseguibile, delle librerie dinamiche necessarie e del file di input, che in questo caso pesa solo 48 KB. Quindi il tempo speso in I/O è trascurabile, e comunque aumenta di poco con l’implementazione sparsa, perché la dimensione dell’eseguibile aumenta di soli 600 KB, passando da 23,5 MB a 24,1 MB, e gli altri valori rimangono invariati. 18 3.4. Liste singole Si è quindi passati all’analisi del numero di cache miss. Per cache miss si intende la situazione in cui il processore deve accedere ad un dato in memoria che non si trova nel livello di cache considerato, e ne deve quindi attendere il caricamento dalla memoria centrale o dal livello di cache successivo, operazioni molto più lente a causa della maggiore latenza e della minor velocità di trasferimento. Callgrind è in grado di misurare separatamente il numero di cache miss nella cache L1 e in quella L2, sia globalmente per tutto il programma sia per ogni istruzione macchina. In quest’ultimo modo è possibile vedere anche i punti nel codice in cui vengono effettuati più cache miss. L’implementazione sparsa fa meno accessi in memoria rispetto a quella densa, ma, nonostante ciò, aumenta il numero di cache miss L1. Il numero di cache miss L2, invece, si riduce in linea con il numero di accessi. L’ulteriore riduzione del numero di cache miss L2 è dovuta all’aumento del numero di righe della matrice che possono essere memorizzate contemporaneamente nella cache L2, per effetto della rappresentazione sparsa che ne riduce la dimensione, e al conseguente aumento della probabilità che la riga attuale sia ancora in cache. Il grafico in figura 3.3 mostra il problema: al ridursi del numero di accessi ci si sarebbe potuti aspettare una diminuzione del numero di cache miss L1, anche per il minor consumo totale di memoria. Quindi è l’aumento del numero di cache miss a causare l’aumento del tempo di esecuzione rilevato dal benchmark. Per quanto riguarda l’utilizzo della cache, la lista non è una buona struttura dati, perché gli elementi sono distribuiti arbitrariamente in memoria e quindi è molto più facile che, quando si passa all’elemento successivo, questo sia in una pagina di memoria che non è al momento in cache, perché gli ultimi elementi utilizzati sono memorizzati in altre pagine. 3.4 Liste singole Per cercare di ridurre il tempo di esecuzione e l’occupazione di memoria, è stata codificata un’implementazione ad-hoc delle liste singole, e la si è usata al posto di std::list. Questo non ha però portato ad un risparmio di memoria (come invece ci si aspettava) per la granularità dei blocchi allocabili, che causava l’allocazione di blocchi di dimensione maggiore di quella necessaria, e che avrebbero potuto contenere il puntatore in più. In dettaglio, sul sistema considerato (una macchina a 64 bit, in cui gli int occupano 32 bit) gli elementi della lista singola occupavano 32 byte, mentre quelli della lista doppia ne occu19 3. Implementazione con liste Implementazione sparsa a confronto con la densa 200 % 150 % 100 % 50 % 0% Istruzioni Numero accessi Cache miss L1 Cache miss L2 Figura 3.3: Analisi del numero di cache miss con liste pavano 40; in entrambi i casi l’allocatore allocava un blocco da 48 byte, di cui 8 per uso interno. L’uso delle liste singole non portava neanche ad una riduzione del tempo di esecuzione, anzi lo aumentava di circa l’1%, probabilmente a causa di una migliore ottimizzazione della struttura dati std::list rispetto a quella implementata appositamente. Visto che questa tecnica non dava buoni risultati, si è andati alla ricerca di una struttura dati che sfruttasse meglio le cache, per ridurre il numero di cache miss. 20 Capitolo 4 Algoritmi e strutture dati cache-oblivious Per l’analisi delle prestazioni degli algoritmi e delle strutture dati viene comunemente usato il modello RAM, che prevede un certo numero di istruzioni primitive eseguite in tempo costante, e abbastanza semplici da poter ragionevolmente pensare che possano essere implementate in tempo costante; ad esempio, il calcolo della somma di due numeri di macchina è un operazione abbastanza semplice, mentre il calcolo dell’n-esimo numero primo dato n non la è. In questo modello, gli accessi in memoria sono eseguiti in tempo costante. Questo modello è estremamente utile per valutare la complessità in termini di tempo di un algoritmo o di un’operazione su una struttura dati, ma non tiene conto della gerarchia della memoria. 4.1 Gerarchia della memoria I calcolatori moderni sono progettati in base ad una gerarchia della memoria, con vari livelli. Quelli più alti hanno minore latenza e maggiore velocità di trasferimento dati, ma capacità inferiore. Tipicamente il livello più alto della gerarchia sono i registri presenti all’interno del processore, seguiti da alcuni livelli di cache (L1, L2 e a volte anche L3), dalla memoria centrale e infine dal disco. Il disco può entrare in gioco durante l’esecuzione come memoria virtuale, se non c’è abbastanza spazio nella memoria principale. Per fissare le idee, questi sono i dati relativi alla gerarchia di memoria di un calcolatore recente: 21 4. Algoritmi e strutture dati cache-oblivious • cache L1 per le istruzioni: 32 KB, latenza di 2 cicli di clock; • cache L1 per i dati: 32 KB, latenza di 3 cicli di clock; • cache L2: 4 MB, latenza di 14 cicli di clock; • memoria centrale: 4 GB, latenza di 200 cicli di clock; • disco: 300 GB, latenza di 9˙000˙000 cicli di clock. Mentre la velocità di elaborazione dei processori e l’ampiezza di banda sono cresciute in modo vertiginoso negli ultimi decenni, la latenza non è migliorata allo stesso modo. Se si misura la latenza in secondi si nota un leggero miglioramento (da 225 ns negli anni ’80 ai circa 70 ns di oggi), ma se invece la si misura in cicli di clock c’è stato un imponente peggioramento: dagli 1,4 cicli del VAX-11/750 nel 1980, si è passati a circa 200 cicli nei calcolatori moderni [Sutter07]. Ha senso che la latenza sia misurata in numero di cicli, perché in questo modo si dà l’idea di quanti calcoli potrebbero essere svolti dal processore nel tempo in cui attende dati dalla memoria. Spesso non si tiene conto della cache, né in fase di progettazione né in fase di programmazione, ma questo può causare un forte impatto sulle prestazioni e, visto il progressivo peggioramento della latenza negli ultimi anni, è probabile che diventi sempre più importante in futuro. Il tempo t necessario per accedere ad un blocco di n byte in un certo livello di memoria si può esprimere come t = latenza + n/banda. Partendo da questa equazione e ammortizzando la latenza sugli n byte si ricava che l’accesso ad ogni byte costa t/n = latenza/n + 1/banda. Scendendo nei livelli inferiori della gerarchia di memoria la latenza diventa sempre più alta. Per bilanciare ciò, si aumenta n, cioè si accede ad un blocco più grande, in modo da ammortizzare la latenza su tutti i dati di questo blocco. I calcolatori moderni lo fanno già: quando c’è un cache miss durante la lettura di un byte, non viene caricato solo quel byte dalla memoria, ma un blocco di dimensioni maggiori, chiamato linea di cache, ad esempio per la cache L3 potrebbe essere caricato un blocco da 64 byte. Così facendo si riesce ad arginare il problema della latenza; però adesso si pone un altro problema: il programma in esecuzione deve riuscire a sfruttare questi n byte, altrimenti si è solo peggiorata la situazione. È bene, quindi, che gli accessi in memoria abbiano una buona località spaziale (si accede a posizioni vicine in memoria) e anche una buona località temporale (gli accessi ripetuti ad una certa locazione sono vicini nel tempo). 22 4.2. Algoritmi cache-oblivious 4.2 Algoritmi cache-oblivious La progettazione di un algoritmo pensato per adeguarsi ad ognuno dei vari livelli (L1, L2, L3, memoria e disco) sarebbe molto complessa, praticamente improponibile. Negli algoritmi cache-oblivious si usa un modello semplificato della gerarchia di memoria, in cui ci sono solo due livelli: uno detto “cache” e un altro detto “memoria”. Nonostante questi nomi suggeriscano una ben determinata corrispondenza con i livelli della gerarchia di memoria, in realtà vengono usati per riferirsi ad una generica coppia di livelli successivi. Quindi, ad esempio, si potrebbe vedere la RAM come “cache” e il disco come “memoria”. Negli algoritmi si mantiene questa genericità, non dipendendo da una certa dimensione della cache. Da qui viene anche il nome di questa classe di algoritmi, detti cache-oblivious (letteralmente: ignari della [dimensione della] cache). In questo modo, se si dimostra che un algoritmo cache-oblivious sfrutta bene la cache nel modello a due livelli, non dipendendo dalla dimensione dei due livelli si può applicare tale dimostrazione ad ogni coppia di livelli, dimostrando quindi che il programma sfrutta bene ogni coppia di livelli, e quindi sfrutta bene tutti i livelli di cache nel loro insieme. Nell’analisi delle prestazioni di questi algoritmi si ipotizza che, quando la è necessario eliminare uno dei blocchi della cache per fare posto ad un altro, venga eliminato il blocco il cui prossimo accesso è più lontano nel tempo. In pratica questo non accade, perché la cache non sa il comportamento futuro del programma; comunque si possono fare delle previsioni così accurate da rendere il rallentamento prodotto dagli errori solo un fattore costante nella formula della complessità, e quindi trascurabile. È bene notare che gran parte degli algoritmi comuni sono cacheoblivious: questo significa solo che non dipendono da una certa dimensione della cache, non che la sfruttino bene. Comunque, in genere si usa il termine cache-oblivious per indicare che l’algoritmo è anche abbastanza efficiente in termini di cache. 4.3 Analisi del numero di cache miss Per analizzare il numero di cache miss di un algoritmo o di una struttura dati, si usa la notazione O-grande, ma con una funzione in più variabili. Oltre alla dimensione n del problema, il numero di cache miss dipende anche dal numero B di elementi che possono essere memorizzati in una linea 23 4. Algoritmi e strutture dati cache-oblivious di cache e dalla dimensione M della cache. Nella notazione O-grande, si suppone che tutti questi tre parametri tendano all’infinito contemporaneamente, utilizzando la nozione di limite per funzioni su più variabili. Questo viene fatto per distinguere gli algoritmi che riescono a sfruttare meglio o peggio la cache. Questo è diverso dal considerare B ed M delle semplici costanti: la differenza è evidente osservando che log n 6= O(log n/B) perché anche B tende all’infinito. 4.4 Confronto con gli algoritmi cache-aware L’opposto degli algoritmi cache-oblivious sono gli algoritmi che dipendono da una dimensione particolare della cache, detti cache-aware (letteralmente: consapevoli della [dimensione della] cache). La struttura dati cache-aware più famosa sono sicuramente i B-tree, in cui si adegua il parametro B alla dimensione della cache. Questi algoritmi hanno vari svantaggi, rispetto agli algoritmi cacheoblivious: per prima cosa, dipendono da una certa dimensione della cache e quindi non sono molto portabili; inoltre, e questo è il caso anche dei B-tree, tipicamente sfruttano bene solo un livello di cache, ignorando gli altri. 24 Capitolo 5 Alberi binari di ricerca cache-oblivous Vista l’importanza delle cache nel problema considerato, si è cercata una struttura cache-oblivious che permettesse ricerche e inserimenti efficienti anche in un punto qualsiasi della struttura, da usare per la memorizzazione dei valori delle righe. È stata scelta la struttura dati ad albero binario illustrata nella pubblicazione “Cache Oblivious Search Trees via Binary Trees of Small Height” [Brodal01] perché sembrava particolarmente adatta allo scopo. D’ora in avanti la chiameremo per brevità CO_Tree (intendendo cache-oblivious tree). Questa struttura dati è simile a quella in [Bender02], ma memorizza i dati in un’unica struttura, mentre in quella pubblicazione ne vengono usate due (un’array e un albero). 5.1 La struttura dati CO_Tree Questa struttura dati implementa un insieme, e le varie operazioni sono progettate per sfruttare bene la cache. La struttura non è banale, ma è comunque abbastanza semplice; questa è una proprietà importante, sia perché al momento della scelta non si era ancora sicuri che questa fosse la strada giusta, sia perché la struttura doveva essere efficiente anche nel caso di righe con pochi elementi, e spesso le strutture più complesse hanno un comportamento peggiore in questi casi. Gli elementi sono memorizzati in una struttura logica ad albero binario, in cui ogni nodo contiene un elemento (sia i nodi interni sia le foglie). Ogni nodo memorizza un elemento con indice strettamente maggiore di quello del 25 5. Alberi binari di ricerca cache-oblivous 9 2 5 7 1 18 1 3 13 12 Figura 5.1: Un albero di interi incorporato in un albero completo. figlio sinistro (se presente) e strettamente minore di quello del figlio destro (se presente). La memorizzazione degli elementi anche nei nodi interni è un punto di forza di questa struttura, perché alcune delle strutture dati alternative, come quella presentata nella pubblicazione “A locality-preserving cacheoblivious dynamic dictionary” [Bender02], memorizzano gli elementi solo nelle foglie, causando un raddoppio dell’uso di memoria e, per questo, anche una minore efficienza in termini di cache (anche se l’efficienza asintotica rimane invariata). L’albero logico contenente gli elementi è incorporato all’interno di un altro albero completo e statico. Per albero statico si intende un albero che non supporta l’aggiunta o la rimozione di nodi; quando è necessario un albero più grande o più piccolo bisogna creare un nuovo albero statico della dimensione desiderata. Si dice che un albero T1 è incorporato in un albero T2 quando si può ottenere T1 da T2 rimuovendo un certo numero di sottoalberi. Vedere la figura 5.1 per un esempio. I nodi dell’albero statico che non fanno parte dell’albero logico (contrassegnati in figura 5.1 da un pallino nero) non sono utilizzati, ma contengono un elemento speciale, come marcatore. L’albero statico è a sua volta memorizzato come un vettore di elementi, secondo una certa tecnica di memorizzazione (layout). In [Brodal01] ne vengono illustrate diverse, alcune che prendono il nome dai più comuni algoritmi di visita (in-visita, pre-visita, ricerca in ampiezza) e che memorizzano gli elementi nell’ordine in cui sarebbero attraversati da quell’algoritmo di visita, e un’altra a sé stante, chiamata van Emde Boas. 26 5.2. Ricostruzione dell’albero 1 1 2 3 4 5 7 6 2 8 10 9 3 11 13 12 4 14 15 5 Figura 5.2: La tecnica di memorizzazione van Emde Boas. I rettangoli tratteggiati indicano i sottoalberi in cui è diviso l’albero completo. I numeri in rosso indicano l’ordine con cui i sottoalberi vengono memorizzati, e i numeri nei nodi indicano l’ordine con cui essi compaiono nel vettore. Quest’ultima tecnica è definita in modo ricorsivo sull’altezza dell’albero da memorizzare, come segue: un albero vuoto viene memorizzato come un vettore vuoto, un albero di un elemento viene memorizzato da un vettore contenente quell’elemento, e un albero di altezza H > 1 viene memorizzato da un vettore in cui lsi memorizza prima la parte dell’albero contenente i m H nodi di altezza h < 2 e poi tutti i sottoalberi rimanenti, da sinistra a destra. Si può vedere un esempio di questa tecnica di memorizzazione in figura 5.2. 5.2 Ricostruzione dell’albero Nel seguito, si dice densità di un sottoalbero dell’albero statico, e si indica con α, il rapporto tra il numero di elementi memorizzati in quel sottoalbero e la dimensione del sottoalbero (cioè il numero di nodi). Si userà la lettera H per indicare l’altezza dell’albero statico, e la lettera d per indicare la profondità di un dato nodo. La profondità della radice è 0. Dopo un certo numero di inserimenti consecutivi si rende necessaria la creazione di un albero statico più grande; inoltre, quando la densità dell’albero è troppo alta, inserimenti e rimozioni diventano molto costosi. 27 5. Alberi binari di ricerca cache-oblivous Si definisce quindi una densità massima αmax , con 0 < αmax < 1. Quando l’inserimento di un elemento porterebbe ad una densità maggiore ad αmax , viene allocato un nuovo albero statico di altezza H + 1, che contiene gli stessi nodi dell’albero statico esistente, ma a cui sono stati aggiunti entrambi i figli a tutte le foglie. Questa operazione non modifica l’albero logico, perché i nodi aggiunti non contengono elementi ma il solito marcatore. Quando invece vengono rimossi molti elementi dall’albero è necessario ridurre la dimensione dell’albero statico, per mantenere gli elementi abbastanza vicini nel vettore, permettendo di conservare l’efficienza in termini di cache e di risparmiare memoria. Per questo motivo viene definita anche una densità minima αmin , con 0 < αmin < αmax /2. 5.3 Inserimento di un elemento nell’albero L’inserimento di un elemento nell’albero avviene in tre fasi: prima si crea un albero di dimensioni maggiori, se necessario, poi si inserisce il nodo e infine si ribilancia l’albero a partire dal nodo inserito. L’algoritmo è riportato in figura 5.3. La procedura di ribilanciamento è comune a inserimento e rimozione, e viene spiegata nella sezione 5.5. 5.4 Rimozione di un elemento dall’albero La rimozione di un elemento avviene in quattro fasi. Per prima cosa si crea un nuovo albero statico più piccolo, se necessario; poi si cerca nell’albero l’elemento da rimuovere. Se non è presente, non rimane altro da fare. In caso contrario, si fa “scendere” il nodo contenente l’elemento da eliminare fino a farlo diventare una foglia, poi lo si elimina dall’albero e si ribilancia l’albero a partire dal nodo eliminato. L’algoritmo dettagliato è in figura 5.4. Per gli algoritmi di ricerca dell’elemento precedente e successivo vedere la sezione 5.6, in particolare la figura 5.5. 5.5 Ribilanciamento Durante le operazioni di inserimento e rimozione di un nodo è necessario ribilanciare l’albero statico a partire dal nodo inserito o eliminato. 28 5.5. Ribilanciamento if density(tree) > αmax then create_bigger_tree(tree) end if {Aggiunta di un nodo all’albero, con il valore new_value.} current_node ← root(tree) while true do if new_value = value(current_node) then return end if if new_value > value(current_node) then if has_right_child(current_node) then current_node ← right_child(current_node) else add_right_child(current_node, new_value) current_node ← right_child(current_node) break end if else if has_left_child(node) then current_node ← left_child(node) else add_left_child(current_node, new_value) current_node ← left_child(current_node) break end if end if end while rebalance_tree(tree, current_node) Figura 5.3: L’algoritmo per l’inserimento di un nodo in un CO_Tree 29 5. Alberi binari di ricerca cache-oblivous if density(tree) < αmin then create_smaller_tree(tree) end if {Ricerca del nodo da eliminare, con il valore my_value.} current_node ← root(tree) while value(current_node) 6= my_value do if my_value > value(current_node) then if has_right_child(current_node) then current_node ← right_child(current_node) else {L’elemento non è nell’albero, non c’è nulla da eseguire.} return end if else if has_left_child(current_node) then current_node ← left_child(current_node) else {L’elemento non è nell’albero, non c’è nulla da eseguire.} return end if end if end while {current_node è il nodo dell’albero che contiene my_value.} while not is_leaf (node) do if has_right_child(node) then next ← next_node(current_node) {Scambio dei valori contenuti in current_node e next.} tmp ← value(current_node) set_value(current_node, value(next)) set_value(next, tmp) {Adesso l’elemento da eliminare è nel nodo next.} current_node ← next else previous ← previous_node(current_node) {Scambio dei valori contenuti in current_node e previous.} tmp ← value(current_node) set_value(current_node, value(previous)) set_value(previous, tmp) {Adesso l’elemento da eliminare è nel nodo previous.} current_node ← previous end if end while set_unused(current_node) rebalance_tree(tree, current_node) 30 Figura 5.4: L’algoritmo per la rimozione di un nodo da un CO_Tree 5.6. Ricerca dell’elemento precedente e successivo Per prima cosa, si deve decidere il sottoalbero da ribilanciare. Per fare ciò si parte dal nodo in questione e si risale finché si arriva ad un sottoalbero la cui densità α è tale che: αmin − d · L αmin − αmin 1 − αmax ≤ α ≤ αmax + d · H −1 H −1 (5.1) L αmin viene detta densità minima delle foglie, ed è un numero compreso tra 0 e αmin . Più questo valore è grande, più la parte bassa dell’albero completo è bilanciata. Ponendo d = 0 nell’equazione 5.1 si ottiene: αmin ≤ α ≤ αmax (5.2) Questa condizione è sempre soddisfatta quando si esegue la procedura di ribilanciamento, perché durante l’inserimento e la rimozione si è già ridimensionato l’albero statico, se necessario. Quindi nel risalire l’albero si trova sempre un nodo in cui la condizione 5.1 è vera, al massimo arrivando alla radice. A questo punto, si redistribuiscono gli elementi del sottoalbero che ha quel nodo come radice. Redistribuzione La redistribuzione degli elementi di un sottoalbero con N elementi prevede di distribuire ricorsivamente quegli N elementi come segue: se N = 0 k j N −1 non c’è nulla da fare. Se N > 0 si distribuiscono 2 elementi nel sottoalbero sinistro, un elemento nella radice del sottoalbero ed i restanti elementi nel sottoalbero destro. La redistribuzione può essere implementata anche in modo più efficiente, senza usare un vettore di appoggio, compattando tutti gli elementi nella parte destra del sottoalbero (mantenendo l’ordinamento) e poi eseguendo la redistribuzione vera e propria. 5.6 Ricerca dell’elemento precedente e successivo La struttura CO_Tree è un albero binario di ricerca, quindi per la ricerca dell’elemento con indice immediatamente precedente o successivo, partendo da un nodo dell’albero logico, si possono usare gli algoritmi standard per gli alberi binari di ricerca. 31 5. Alberi binari di ricerca cache-oblivous if has_right_child(node) then node ← right_child(node) while has_left_child(node) do node ← left_child(node) end while else while not is_root(node) and is_right_child(node) do node ← parent(node) end while if is_root(node) then return nil else node ← parent(node) end if end if return node Figura 5.5: L’algoritmo di ricerca del nodo successivo per alberi binari di ricerca. In figura 5.5 è mostrato l’algoritmo di ricerca del nodo successivo. 5.7 Prestazioni Nel seguito, si indicherà con n il numero di elementi presenti nell’insieme e con B il numero di nodi che possono essere memorizzati contemporaneamente in una linea della cache considerata. La struttura dati CO_Tree permette di fare interrogazioni in tempo 2 O(log n). Gli inserimenti e le cancellazioni costano O( logβ n ), con β = L min{αmin − αmin , 1 − αmax }. La ricerca dei valori dell’insieme compresi tra due elementi (indicando con k il numero di tali valori) costa come due interrogazioni più un costo di O(k) in termini di tempo e O(k/B) cache miss. La tecnica di memorizzazione che ha le migliori prestazioni asintotiche in termini di cache è la van Emde Boas. Le interrogazioni causano al più O(logB n) cache miss, mentre inserimenti e cancellazioni ne producono 2 n O(logB n + log ), con il β definito sopra. Bβ Le altre tecniche di memorizzazione, invece, causano O(log n/B) ca2 n che miss per le interrogazioni e O(log n/B + log ) per gli inserimenti e le Bβ 32 5.8. Confronto con i B-tree cancellazioni. È bene notare che, per valori grandi di n e B, log Bn > logB n, perché log n log n/B = log n − log B, mentre logB n = log . Quindi la tecnica van Emde B Boas sfrutta meglio le cache che hanno linee molto lunghe, soprattutto in presenza di molti elementi rispetto alla dimensione della cache. In pratica, però, la differenza è poco marcata: nei calcolatori attuali le linee di cache tipicamente sono lunghe 64 byte e, dato che ogni elemento occupa 16 byte, B = 4 da cui si ricava che log2 B = 2. Quindi le due complessità, nonostante siano formalmente diverse (vedere la sezione 4.3), hanno un numero simile di cache miss. Se si considerasse invece l’esecuzione di programmi che, avendo bisogno di più memoria rispetto a quella disponibile, fanno uso del disco come memoria virtuale, in questo caso B sarebbe molto più grande; ad esempio, se gli accessi al disco avvenissero a blocchi di 1 MB, B sarebbe 16 e quindi la differenza tra log Bn e logB n diventerebbe più importante. 5.8 Confronto con i B-tree I CO_Tree riescono ad ottenere lo stesso numero di cache miss dei Btree, ma solo con il layout van Emde Boas. Comunque valgono tutte le considerazioni della sezione 4.4, che rendono comunque preferibili i CO_Tree ai B-tree, in particolare la maggiore portabilità, l’uso di tutti i livelli di cache e il minore overhead per la scansione sequenziale. 33 Capitolo 6 Implementazione con alberi cache-oblivious Vista l’efficienza (almeno teorica) della struttura dati CO_Tree, la si è implementata, per confrontare le prestazioni con l’implementazione delle matrici sparse basata su liste. Sono state implementate tre tecniche di memorizzazione: la van Emde Boas (quella con i migliori bound teorici), la DFS (acronimo di DepthFirst Search, intendendo una in-visita in profondità dell’albero) e la BFS ( acronimo di Breath-First Search, cioè visita in ampiezza). Si è cercato di condividere più codice possibile tra le varie implementazioni. L’implementazione utilizzata poteva essere scelta a compile-time, definendo opportuni flag per il preprocessore. 6.1 Modifiche alla struttura CO_Tree Per usare la struttura CO_Tree per memorizzare gli elementi di una riga sparsa, si sono rese necessarie alcune modifiche. La prima modifica importante è stata la trasformazione della struttura dati in modo da implementare un mappa dagli indici di colonna ai valori corrispondenti, al posto di un insieme di indici. Come in tutte le strutture dati sparse, non si memorizzano nella mappa le coppie hindice, valorei in cui il valore è 0. In figura 6.1 c’è un esempio di questa rappresentazione. Per i nodi che non contengono un valore viene memorizzato un indice speciale, usato come marcatore. Durante le interrogazioni, quando si discende l’albero alla ricerca di un 35 6. Implementazione con alberi cache-oblivious 0 1 2 3 4 5 6 7 8 9 10 11 12 13 0 4 0 13 0 0 0 7 9 0 0 1 0 5 7 7 3 13 1 4 11 1 8 9 13 5 Figura 6.1: Una riga sparsa e la sua rappresentazione con un CO_Tree. Ogni nodo dell’albero contiene un indice (nella parte superiore) e un valore (nella parte inferiore). indice, vengono letti gli indici memorizzati nei nodi attraversati ma non i valori. Per sfruttare meglio la cache durante la ricerca di un indice, si sono memorizzati gli indici e i valori in vettori separati, della stessa dimensione. Questa modifica ha portato ad una marcata riduzione del numero di cache miss, anche perché i valori occupano più spazio degli indici. 6.2 Benchmark I risultati dei benchmark sono in figura 6.2. Per quanto riguarda la memoria occupata, non ci sono differenze significative né tra liste e CO_Tree, né tra le varie tecniche di memorizzazione di CO_Tree. Invece, per quanto riguarda il tempo di esecuzione, tutte le tecniche di memorizzazione dei CO_Tree sono migliori delle liste nei test grandi. Nei test piccoli, solo l’implementazione DFS è migliore delle liste. Tra le varie tecniche di memorizzazione di CO_Tree, DFS risulta essere la migliore, sia nei test grandi sia nei test piccoli, seguita da BFS e poi da van Emde Boas. Questo risultato è del tutto inaspettato: nei benchmark di CO_Tree in [Brodal01] è l’esatto opposto. I motivi di questa differenza sono due: nei test di riferimento per la libreria PPL ci sono pochi elementi in ogni riga, mentre anche nei benchmark più piccoli in [Brodal01] gli alberi contengono migliaia di elementi; inoltre, 36 Tempo di elaborazione con matrici sparse 6.2. Benchmark liste DFS BFS VEB 140 % 120 % 100 % 80 % 60 % 40 % 20 % 0% Media Media pesata Tempo di elaborazione con matrici dense liste DFS BFS VEB Memoria utilizzata con matrici sparse 140 % 120 % 100 % 80 % 60 % 40 % 20 % 0% Media Media pesata Memoria utilizzata con matrici dense Figura 6.2: Confronto delle varie implementazioni sparse delle matrici. La media pesata dà più importanza ai test che richiedono più risorse. 37 6. Implementazione con alberi cache-oblivious Confronto rispetto all'implementazione densa 200 % liste CO_Tree DFS CO_Tree BFS CO_Tree VEB 150 % 100 % 50 % 0% Istruzioni Accessi totali Miss L1 Miss L2 Tempo CPU Figura 6.3: I risultati del profiling delle implementazioni sparse con liste e con CO_Tree, confrontati con l’implementazione densa. nella PPL il pattern di accesso agli elementi più frequente è la scansione sequenziale della riga, mentre in [Brodal01] vengono fatti i benchmark solo su ricerche, inserimenti e rimozioni di elementi. 6.3 Profiling Come si era visto nella sezione 3.3, l’uso della cache è molto importante per questo tipo di applicazione. Quindi è stato fatto un profiling dell’implementazione sparsa basata su CO_Tree, confrontandola con i risultati ottenuti precedentemente. Il profiling è stato eseguito sullo stesso test che si era già usato per l’implementazione sparsa basata su liste. È bene ricordare che questo test era stato scelto proprio per il suo cattivo comportamento con l’implementazione sparsa, e quindi non è rappresentativo della situazione media, ma è utile per il confronto tra le varie implementazioni. I risultati del profiling sono in figura 6.3. È evidente che la percentuale di cache miss con CO_Tree, sia L1 sia L2, è molto minore rispetto all’implementazione basata su liste, quindi si è raggiunto l’obiettivo di migliorare l’uso della cache. Per quanto riguarda il tempo CPU, l’implementazione basata su liste 38 6.3. Profiling è ancora migliore delle implementazioni basate su CO_Tree, tranne DFS (comunque la differenza è minima). Questo avviene a causa di un maggiore overhead della struttura dati, come si può notare dalla crescita del numero di istruzioni e del numero di accessi in memoria. L’implementazione van Emde Boas, che ha il migliore comportamento asintotico nell’analisi teorica, ha un overhead talmente elevato da renderla peggiore delle altre. Tra i vari layout di CO_Tree, quello che si comporta meglio è il DFS; questo perché riesce ad avere un numero ridotto di cache miss, simile a quello degli altri due layout (in particolare per quanto riguarda i cache miss L2, quelli più importanti), ma ha un overhead nettamente inferiore. 39 Capitolo 7 Implementazione DFS ottimizzata Viste le maggiori prestazioni offerte dalla tecnica di memorizzazione DFS, sono state rimosse le altre e si è cercato di ottimizzare ulteriormente la struttura dati per questo specifico layout. Dalla pubblicazione da cui è stata presa la struttura dati ([Brodal01]) non risulta che siano state effettuate ottimizzazioni specifiche per il layout DFS, probabilmente a causa delle sue minori prestazioni nei benchmark considerati. 7.1 Valori delle soglie Le prestazioni della struttura dati CO_Tree dipendono dalle tre costanti L usate come soglie per il ribilanciamento: αmax , αmin e αmin . Ripetendo i benchmark con soglie diverse, utilizzando un processo di bisezione locale, si sono trovati i seguenti valori ottimali per le soglie: αmax = 0.91 αmin = 0.38 L αmin = 0.01 L Il valore particolarmente basso di αmin permette di ridurre il costo dei ribilanciamenti, permettendo ai livelli inferiori dell’albero di essere più sbilanciati, mantenendo comunque la stessa complessità computazionale. La modifica di questi valori, comunque, ha portato ad un risparmio di tempo di elaborazione abbastanza contenuto, dell’ordine dell’1% per la 41 7. Implementazione DFS ottimizzata 4 100 2 10 1 1 6 110 3 11 5 101 7 111 Figura 7.1: Calcolo dell’altezza di un nodo dalla posizione corrispondente nella visita DFS. Nei nodi è riportato l’ordine in cui sono attraversati da una visita DFS, in decimale ed in binario. Notare che l’ultimo 1 della rappresentazione binaria è l’ultima cifra per le foglie (altezza 1), la penultima per i nodi di altezza 2 e così via. media e del 2% per la media pesata (che dà più peso ai test che richiedono più risorse). 7.2 Calcolo veloce degli indici dei figli Nel layout DFS, un nodo memorizzato alla posizione i del vettore degli indici e con altezza h (considerando le foglie ad altezza 1) ha come figli i nodi di indici i ± 2h−2 . Questo, ovviamente, solo se il nodo non è una foglia, vale a dire se h ≥ 2. Come si può notare dalla figura 7.1, si può calcolare l’altezza h a partire da i, guardando la posizione dell’ultimo 1 nella codifica binaria di i. Ad esempio, un nodo con i = 5 è una foglia, perché l’ultimo 1 della rappresentazione binaria di 5 (101) è in ultima posizione. Un nodo in posizione i = 6 ha invece altezza 2 perché l’ultimo 1 del numero 6 in binario (110) è in penultima posizione. Questo accade indipendentemente dalla dimensione dell’albero. Quindi, un modo per calcolare l’indice di un figlio (diciamo il figlio sinistro) da i è il seguente: prima si calcola la posizione dell’ultimo 1 in i con un ciclo, e poi si calcola i + 2h−2 . Questo modo funziona, ma si può fare ancora di meglio. Gli elaboratori moderni fanno i calcoli in complemento a 2, quindi hanno delle istruzioni in linguaggio macchina per calcolare il complemento a 2 di un numero in 42 7.3. Un altro punto di vista modo molto veloce; queste istruzioni di solito sono utilizzate per calcolare −n a partire da un numero n. Queste istruzioni fanno proprio al caso nostro: se si calcola i & -i (in cui l’operatore & rappresenta l’AND bit a bit) si ricava un numero formato da tutti zeri e un solo 1, nella stessa posizione dell’ultimo 1 di i. Se i fosse stato 0 si sarebbe ottenuto 0, ma qui usiamo indici che partono da 1 e quindi questo problema non si pone. D’ora in poi chiameremo questo numero l’offset di i, e lo indicheremo con o. Scritto in formule, l’offset di i è o = 2h−1 . Le posizioni dei figli di i sono quindi i ± o/2. A partire dall’offset si può anche calcolare la posizione del padre, che è (i & ∼o) | 2*o (in cui &, | e ∼ sono AND, OR e NOT bit a bit). 7.3 Un altro punto di vista Fino ad ora, la struttura CO_Tree è stata pensata come albero, anche se poi era effettivamente memorizzata in (due) vettori per sfruttare bene le cache. D’altra parte, questo era l’unico modo che permetteva di trattare i vari layout in modo omogeneo. Ora che gli altri layout sono stati rimossi, essendo il layout DFS molto semplice è possibile vedere la struttura anche da un altro punto di vista, molto più intuitivo. Una struttura lineare alternativa Pensiamo adesso di ripartire da zero con questa struttura dati: memorizziamo la riga sparsa in un vettore di coppie hindice, valorei, ordinate per indice. Si può cercare il valore associato con un certo indice con una ricerca dicotomica, quindi in modo efficiente. A questo punto, la scansione sequenziale della riga è estremamente efficiente, anche in termini di cache. L’inserimento e la rimozione di elementi, invece, sono estremamente costosi, perché ad ogni operazione bisogna spostare gli elementi successivi. Per poter eseguire anche gli inserimenti e le rimozioni in modo efficiente, si può pensare di lasciare degli spazi vuoti tra gli elementi del vettore, in cui poter inserire i nuovi elementi. Anche la rimozione di un elemento diventa efficiente, perché basta marcare quell’elemento del vettore come “vuoto”. Per le interrogazioni si continua ad usare la ricerca dicotomica, con una piccola modifica: quando l’elemento centrale della parte del vettore considerata è vuoto, si scorre il vettore in avanti a partire da quella posizione, 43 7. Implementazione DFS ottimizzata alla ricerca della prima posizione non vuota, e si usa l’elemento in quella posizione per il confronto. Quando gli elementi diventano troppo densi, gli inserimenti diventano via via più costosi, perché si ha la necessità di fare più spostamenti per fare posto ai nuovi elementi, mantenendo l’ordinamento degli indici memorizzati. Per rimediare a questo problema, vengono prese due contromisure: per prima cosa, al posto di riallocare il vettore solo quando è pieno, lo si rialloca quando la densità degli elementi (cioè la percentuale degli elementi effettivamente utilizzati nel vettore) supera una certa soglia; inoltre, quando si inserisce un elemento in una zona del vettore che è diventata molto densa, si re-distribuiscono gli elementi di quella zona e di quella circostante, in modo da avere una spaziatura uniforme. La struttura dati che si ottiene permette una scansione sequenziale veloce ed efficiente in termini di cache. Anche gli inserimenti sono efficienti (notare che, intenzionalmente, non si è bene specificato quali elementi vengono spostati negli inserimenti, quindi è tutto ancora molto vago). Rimane però un problema: quando vengono effettuate rimozioni ripetute di elementi, rimangono delle lunghe sequenze di “buchi”, che causano un degrado delle prestazioni, quando invece sarebbe bene che gli elementi utilizzati nel vettore avessero una spaziatura abbastanza uniforme. Per rimediare a quest’ultimo problema, ci si comporta in modo simile agli inserimenti: se la densità del vettore scende sotto ad una certa soglia si rialloca un vettore più piccolo e, se una certa zona del vettore è troppo poco densa, si redistribuiscono gli elementi di quella zona e gli elementi circostanti in modo da avere una spaziatura uniforme. A questo punto si è ottenuta una struttura che sembra efficiente, almeno a livello intuitivo. Vediamo un esempio di come potrebbe comportarsi questa struttura. Esempio di comportamento Partiamo da un vettore con tre elementi utilizzati, equi-distanziati. Qui vengono riportati solo gli indici per semplicità, ma ad ognuno di essi è associato un valore, memorizzato nella stessa posizione. 3 15 29 Adesso inseriamo l’indice 5. C’è uno spazio tra 3 e 15, quindi basta inserirlo lì. 44 7.3. Un altro punto di vista 3 5 15 29 Adesso inseriamo l’indice 20. Anche questo è un caso fortunato, perché c’è uno spazio vuoto fra 15 e 29. 3 5 15 20 29 Ora inseriamo il 6. Questa volta non c’è uno spazio vuoto corrispondente, quindi facciamo scorrere i valori successivi e inseriamo il 6 nello spazio che si viene a creare. 3 5 6 15 20 29 Adesso vogliamo inserire il 7. Il vettore, però, inizia ad essere troppo denso, quindi per prima cosa creiamo un vettore più grande con gli stessi elementi, spaziando gli elementi utilizzati in modo uniforme. 3 5 6 15 20 29 Adesso si può comodamente inserire il 7 in uno dei nuovi spazi vuoti. 3 5 6 7 15 20 29 Adesso vogliamo inserire l’8. A prima vista basta spostare il 15 a destra di una posizione e poi inserire l’8 tra il 7 ed il 15. Così facendo, però, ci sarebbero nel vettore 5 elementi utilizzati contigui. Invece, è meglio che gli elementi siano spaziati in modo più uniforme. Decidiamo quindi di redistribuire la seconda metà del vettore in modo da avere solo 3 elementi contigui. 3 5 6 7 8 15 20 29 Adesso proviamo a rimuovere qualche elemento. Partiamo dal 29, che è facile da rimuovere, perché basta marcare come vuota la cella del vettore 45 7. Implementazione DFS ottimizzata che lo contiene. 3 5 6 7 8 15 20 Adesso togliamo il 3. Così facendo si creano 5 spazi vuoti contigui. È meglio che la densità sia più uniforme, quindi spostiamo gli altri elementi utilizzati in modo da avere una spaziatura uniforme. 5 6 7 8 15 20 Adesso vogliamo togliere il 5. Ormai il vettore contiene molti spazi vuoti, quindi decidiamo come prima cosa di ridurre la dimensione del vettore. 5 6 7 8 15 20 Adesso si può rimuovere il 5. Anche stavolta, però, la densità del vettore diventerebbe poco omogenea, con 2 spazi seguiti da 5 elementi contigui. Quindi decidiamo di spostare il 6 a sinistra di una posizione. 6 7 8 15 20 Considerazioni Questa struttura è concettualmente semplice e, intuitivamente, sembra anche comportarsi bene sia in termini di prestazioni sia in termini di cache. La cosa sorprendente è che questo è l’esatto comportamento del vettore usato dal layout DFS dei CO_Tree per memorizzare gli indici. Questa osservazione permette di trattare la stessa struttura come un albero o come struttura lineare, in base al bisogno. Per decidere quale parte della struttura ribilanciare e quando farlo, cosa che qui avevamo lasciato un po’ vaga perché sarebbe complessa da definire nel nuovo punto di vista, si userà la struttura come albero. Per molte altre operazioni, invece, la si tratterà come vettore, semplificando le operazioni e aumentando le prestazioni. 46 7.4. Ottimizzazioni fatte usando CO_Tree come struttura lineare 7.4 Ottimizzazioni fatte usando CO_Tree come struttura lineare Iteratori La prima ottimizzazione che è stata fatta riguarda la scansione della struttura usando gli iteratori. Prima di questa ottimizzazione, gli iteratori si spostavano fra i nodi dell’albero attraverso gli archi. Quindi ogni volta che veniva incrementato un iteratore per spostarsi all’elemento memorizzato successivo bisognava fare diversi spostamenti nell’albero, con un costo ammortizzato di O(1), ma con un costo di O(log n) nel caso peggiore. Ogni iteratore doveva memorizzare un puntatore all’albero, l’indice dell’elemento corrente e l’offset corrente (vedere la sezione 7.2). Vedendo invece la struttura in modo lineare, ogni iteratore può essere implementato da due puntatori che puntano all’elemento corrente, rispettivamente nel vettore degli indici e in quello dei valori. Per passare all’elemento successivo basta ora incrementare entrambi i puntatori finché non si arriva al prossimo elemento non vuoto del vettore. Per ottenere l’indice o il valore a cui punta l’iteratore basta de-referenziare il puntatore corrispondente, operazione molto più veloce di quello che era necessario fare prima. In questo modo, però, quando si incrementa un iteratore che punta all’ultimo elemento si esce dai limiti dei due vettori. Per rimediare a questo problema, è stato aggiunto un elemento fittizio alla fine del vettore degli indici (e, per simmetria, anche uno all’inizio), che non contiene il marcatore usato per gli elementi vuoti, ma un indice arbitrario. Per il ribilanciamento si continua ad usare un iteratore “vecchio stile”, perché in quel caso è utile vedere la struttura come albero. Allocazione di un albero statico più grande Nella versione precedente l’ottimizzazione, quando era necessario allocare un albero statico più grande (cioè quando la densità superava il limite massimo), si allocava un nuovo albero della dimensione desiderata e lo si riempiva usando un algoritmo ricorsivo. L’algoritmo ricorsivo era stato reso iterativo con l’uso di uno stack, ma c’era comunque un overhead legato alla gestione dello stack. Dopo l’ottimizzazione, invece, per riempire i nuovi vettori si alternano queste due operazioni: una volta si mette una cella vuota ed una volta si mette un elemento preso dal vecchio vettore. Alla fine si mette un’ulteriore 47 7. Implementazione DFS ottimizzata 1 1 3 3 7 7 8 11 8 13 11 13 7 3 1 11 8 13 Figura 7.2: L’allocazione ottimizzata di un albero statico più grande, vedendo il CO_Tree sia come struttura lineare sia come albero. Le foglie aggiunte all’albero sono in rosso. cella vuota. Vedendo la struttura come albero, il risultato è l’aggiunta di due figli ad ogni foglia. Si può vedere un esempio in figura 7.2. 7.5 Benchmark Dopo aver effettuato queste ottimizzazioni all’implementazione sparsa, sono stati fatti dei nuovi benchmark, per confrontarla con la situazione precedente. I risultati sono in figura 7.3. Si può notare come la quantità di memoria utilizzata non abbia subito variazioni significative, quindi l’aggiunta di un elemento all’inizio e uno alla fine nel vettore degli indici ha un effetto trascurabile, come era lecito aspettarsi. Per quanto riguarda l’uso della CPU, invece, si nota un netto miglioramento, sia nella media sia nella media pesata (che, ricordiamo, dà maggiore peso ai test più onerosi in termini di risorse). Quindi le ottimizzazioni hanno avuto l’effetto sperato. 48 7.5. Benchmark liste DFS DFS ottimizzato BFS VEB Memoria utilizzata con matrici sparse 140 % 120 % 100 % 80 % 60 % 40 % 20 % 0% Media Media pesata Tempo di elaborazione con matrici sparse Memoria utilizzata con matrici dense liste DFS DFS ottimizzato BFS VEB 140 % 120 % 100 % 80 % 60 % 40 % 20 % 0% Media Media pesata Tempo di elaborazione con matrici dense Figura 7.3: Risultati dei benchmark sull’implementazione DFS ottimizzata rispetto alle altre implementazioni sparse. 49 7. Implementazione DFS ottimizzata Risparmio di memoria con matrici sparse 0% -20 % -40 % -60 % -80 % -100 % 1 10 100 1000 10000 Memoria utilizzata con matrici dense Risparmio di tempo con matrici sparse 60 % 40 % 20 % 0% -20 % -40 % -60 % -80 % -100 % 0.01 0.1 1 10 100 1000 Tempo di elaborazione con matrici dense Figura 7.4: La distribuzione del miglioramento in base alla quantità di risorse impiegate. Distribuzione del miglioramento In figura 7.4 si può vedere la distribuzione del miglioramento in base alla quantità di risorse richieste dal test. La si può confrontare con la figura 3.2 riguardante l’implementazione con liste. I risultati sono leggermente migliori, e la loro distribuzione è simile. 50 7.6. Profiling Confronto rispetto all'implementazione densa 200 % liste DFS DFS ottimizzato 150 % 100 % 50 % 0% Istruzioni Accessi totali Miss L1 Miss L2 Tempo CPU Figura 7.5: Risultati del profiling dell’implementazione DFS ottimizzata a confronto con lo stato precedente e con l’implementazione basata su liste. 7.6 Profiling Per analizzare in modo più preciso l’effetto delle ottimizzazioni, è stato fatto il profiling dell’implementazione sparsa ottimizzata per il layout DFS. Il profiling è stato eseguito sullo stesso test usato nei profiling precedenti. I risultati sono in figura 7.5. Si può notare una riduzione dell’overhead (numero di istruzioni eseguite e numero di accessi in memoria) e anche un leggero calo del numero di cache miss, dovuto al minor numero di accessi in memoria. 51 Capitolo 8 Classi implementate Oltre alla classe CO_Tree, l’implementazione delle matrici sparse consiste di due classi: Sparse_Row, che implementa un vettore sparso, e Sparse_Matrix, che implementa una matrice sparsa formata da righe di tipo Sparse_Row. In questo capitolo vengono spiegati i metodi principali di queste classi. Le interfacce complete si possono trovare nell’appendice A. 8.1 CO_Tree La classe CO_Tree implementa la struttura descritta nel capitolo 5, con le modifiche illustrate nel capitolo 6. data_type e data_type_const_reference La classe CO_Tree, nonostante sia stata ottimizzata per lavorare con valori di tipo Coefficient, è rimasta abbastanza indipendente da questo tipo, predisposta al suo cambiamento. Per questo motivo fornisce due typedef: data_type e data_type_const_reference, per riferirsi al tipo dei valori memorizzati. Iteratori Come è consuetudine per le classi C++ che implementano una struttura dati, anche CO_Tree fornisce degli iteratori, sia constanti (const_iterator) sia non costanti (iterator), che definiscono i tag necessari per poter essere utilizzati negli algoritmi della STL. Questi iteratori sono bidirezionali. 53 8. Classi implementate Le operazioni di incremento e decremento di iteratori hanno costo O(1) (questo non è solo il costo ammortizzato, ma anche il costo nel caso peggiore). Per accedere al valore puntato da un iteratore itr basta scrivere *itr. Per ottenere l’indice relativo si usa il metodo index(). L’inserimento e la rimozione di elementi da un CO_Tree invalidano tutti gli iteratori relativi. Una peculiarità di CO_Tree è che i metodi end() restituiscono un riferimento costante: const iterator & end (); const const_iterator & end () const ; const const_iterator & cend () const ; Il riferimento rimane sempre valido, fino alla distruzione dell’oggetto CO_Tree. Se fosse stato restituito un iteratore per valore, nel codice chiamante sarebbe stato spesso necessario l’aggiornamento di tale iteratore ad ogni chiamata ad insert() o erase(), e questo avrebbe prodotto un degrado delle prestazioni. Così, invece, nel codice chiamante si può tenere un riferimento costante a questo iteratore interno, che viene aggiornato solo quando la struttura interna di CO_Tree viene riallocata, operazione che avviene di rado anche a seguito di chiamate ripetute ad insert() o erase(). Costruttore da una sequenza La classe CO_Tree fornisce anche un costruttore a partire da una sequenza: template < typename Iterator > CO_Tree ( Iterator i , dimension_type n ); Questo metodo prende un Input Iterator che punta al primo elemento della sequenza e il numero n di coppie hindice, valorei che compongono la sequenza. Questo costruttore costa solo O(n) ed è quindi molto utile quando si conosce già il numero di elementi che devono essere inseriti. A titolo di confronto, la creazione di un CO_Tree vuoto e l’inserimento degli n valori uno alla volta costa invece O(n log2 n). 54 8.1. CO_Tree Metodi insert() La classe CO_Tree fornisce 4 metodi insert() per l’inserimento di coppie hindice, valorei nella struttura: iterator insert ( dimension_type key ); iterator insert ( dimension_type key , data_type_const_reference data ); iterator insert ( iterator itr , dimension_type key ); iterator insert ( iterator itr , dimension_type key , data_type_const_reference data ); Se esiste già un elemento con quella chiave (cioè con quell’indice) nell’albero ed è stato specificato un valore da associare all’indice, viene sostituito il valore specificato a quello esistente. Se si specifica solo la chiave viene inserito un nuovo elemento se quella chiave non era ancora presente nell’albero; altrimenti viene semplicemente restituito l’iteratore corrispondente. Quando si passa anche un iteratore a questo metodo, la semantica rimane la stessa, ma il metodo cerca di risparmiare tempo ipotizzando che l’iteratore specificato sia vicino alla posizione desiderata. Il costo ammortizzato è comunque O(log2 n). Gli unici requisiti sul parametro di tipo iteratore sono i seguenti: deve puntare a questo CO_Tree e deve essere valido (cioè non può essere un vecchio iteratore che prima puntava a questo albero ma è stato invalidato da una qualche operazione). Questo significa che l’iteratore può anche essere end(), cosa che torna spesso utile e permette di semplificare il codice chiamante. Metodi erase() Vengono forniti due metodi erase(): iterator erase ( dimension_type key ); iterator erase ( iterator itr ); La semantica è molto semplice: viene eliminato l’elemento specificato (tramite iteratore o tramite indice) e restituito un iteratore che punta all’elemento successivo. Metodi bisect_in() Vengono forniti due metodi bisect_in(): 55 8. Classi implementate iterator bisect_in ( iterator first , iterator last , dimension_type key ); const_iterator bisect_in ( const_iterator first , const_iterator last , dimension_type key ) const ; Questi metodi cercano l’indice key nella sequenza [first, last], usando un algoritmo di bisezione, quindi in O(log n). Se trovano un elemento corrispondente, restituiscono un iteratore che punta a tale elemento. Altrimenti, restituiscono un iteratore che punta all’elemento con indice immediatamente precedente o successivo. Per comodità vengono forniti anche due metodi bisect(): iterator bisect ( dimension_type key ); const_iterator bisect ( dimension_type key ) const ; itr = tree.bisect(i); è equivalente a: itr = tree.bisect(tree.begin(), tree.end(), i); Metodi bisect_near() Vengono forniti anche due metodi bisect_near(): iterator bisect_near ( iterator hint , dimension_type key ); const_iterator bisect_near ( const_iterator hint , dimension_type key ) const ; Questi metodi sono equivalenti a bisect(), ma sono implementati in modo da essere più efficienti di bisect() quando l’iteratore passato come parametro è vicino alla posizione cercata. Costano O(log n) nel caso peggiore, ma il costo è O(1) se la distanza tra l’iteratore specificato e la posizione cercata è O(1). 8.2 Sparse_Row Questa classe implementa il concetto di riga sparsa, cioè di vettore in cui ci si aspetta che gran parte degli elementi siano zero. Come si è detto, 56 8.2. Sparse_Row è implementata con un CO_Tree. Ogni oggetto di tipo Sparse_Row ha anche un certo numero di flag associati, di tipo Row_Flags. È bene notare che la classe Sparse_Row non garantisce l’assenza di zeri non memorizzati: questo è compito del codice chiamante. L’unica garanzia in questo senso è offerta da due metodi: combine() e linear_combine(). L’interfaccia di Sparse_Row contiene tutti i metodi dell’interfaccia di Dense_Row, con la stessa semantica. Questo permette di portare il codice che usa Dense_Row a Sparse_Row in modo incrementale: prima si sostituiscono tutte le occorrenze di Dense_Row con Sparse_Row e poi si va a riscrivere pezzo per pezzo il codice coinvolto per sfruttare al meglio Sparse_Row. Dopo aver riscritto ogni pezzo, il codice compila e si possono quindi eseguire i test preesistenti sul codice utente (ammesso che ce ne siano) per controllare che la riscrittura sia corretta. Senza questo approccio, cercando di fare tutte le modifiche in una volta sola, ci si accorge solo alla fine degli errori (perché si possono eseguire i test solo alla fine, quando tutto compila) e diventa molto più difficile isolarli ed individuarli. Iteratori Gli iteratori forniti sono quelli dell’oggetto CO_Tree sottostante. Per maggiori informazioni vedere la sezione 8.1. Costruttore a partire da un oggetto di tipo Dense_Row Viene fornito un costruttore a partire da un oggetto Dense_Row: explicit Sparse_Row ( const Dense_Row & row ); Questo costruttore ha costo O(n), mentre l’inserimento di n elementi in un oggetto Sparse_Row costerebbe O(n log2 n). Accesso semplice agli elementi Per l’accesso agli elementi sono forniti molti metodi diversi, in base allo scopo. Ci sono i metodi tradizionali che restituiscono un riferimento: Coefficient & operator []( dimension_type i ); Coefficient_traits :: const_reference operator []( dimension_type i ) const ; 57 8. Classi implementate Coefficient_traits :: const_reference get ( dimension_type i ) const ; Il metodo get() ha lo stesso comportamento della versione const di operator[]: restituisce un riferimento all’elemento memorizzato o un riferimento al valore zero se non c’è nessun valore memorizzato, e costa O(log n). La versione non costante di operator[], invece, ha un comportamento analogo allo stesso operatore di std::map: se non c’è un elemento memorizzato con quell’indice viene aggiunto, con valore 0. Questo lo rende comodo da usare, ma si corre il rischio di memorizzare anche degli zeri, annullando i benefici permessi dalla struttura sparsa. Inoltre, l’inserimento di elementi invalida tutti gli iteratori che si riferiscono a questa riga. Se è necessario inserire un valore, questi metodi hanno un costo ammortizzato di O(log2 n), altrimenti costano O(log n). Durante il porting del codice da Dense_Row a Sparse_Row spesso operator[] va sostituito con l’uso degli iteratori (per accesso sequenziale) o con uno dei metodi che seguono, per aumentare le prestazioni ed evitare di memorizzare degli zeri nella rappresentazione sparsa. Metodi find() Vengono forniti dei metodi find(): iterator find ( dimension_type i ); iterator find ( iterator itr , dimension_type i ); const_iterator find ( dimension_type i ) const ; const_iterator find ( const_iterator itr , dimension_type i ) const ; Tutti questi metodi restituiscono un iteratore che punta all’elemento con indice i, se è memorizzato, altrimenti restituiscono end(). Le versioni di questi metodi che prendono un iteratore lo usano come hint (letteralmente: “suggerimento”) per trovare più velocemente la posizione cercata. Il costo in termini di tempo è O(log n) nel caso peggiore, e solo O(1) se viene specificato un hint distante solo O(1) dalla posizione cercata. Come accade anche per i metodi di CO_Tree, gli hint possono essere anche end(), e in questo caso vengono ignorati; questo viene fatto per semplificare il codice chiamante. 58 8.2. Sparse_Row Metodi lower_bound() Vengono forniti dei metodi lower_bound(): iterator lower_bound ( dimension_type i ); iterator lower_bound ( iterator itr , dimension_type i ); const_iterator lower_bound ( dimension_type i ) const ; const_iterator lower_bound ( const_iterator itr , dimension_type i ) const ; Questi metodi hanno lo stesso significato dei metodi omonimi presenti in molte strutture dati della STL: restituiscono un iteratore che punta all’elemento con quell’indice, se esiste, altrimenti restituiscono un iteratore che punta all’elemento con indice immediatamente superiore. Se non esistono elementi con indice maggiore o uguale ad i, viene restituito end(). Le versioni di questo metodo che prendono come parametro un iteratore lo usano come hint per velocizzare la ricerca. Come accade anche per find() l’iteratore hint può anche essere end(), ed in tal caso viene ignorato. Il costo nel caso peggiore è O(log n), ma diventa O(1) quando viene specificato un hint distante solo O(1) dalla posizione cercata. Metodi insert() Vengono forniti dei metodi insert(), per l’inserimento di elementi: iterator insert ( dimension_type i , Coefficient_traits :: const_reference x ); iterator insert ( iterator itr , dimension_type i , Coefficient_traits :: const_reference x ); iterator insert ( dimension_type i ); iterator insert ( iterator itr , dimension_type i ); Questi metodi si comportano in modo simile ai metodi insert() delle std::map, tranne per il fatto che bisogna passare separatamente l’indice e il valore al posto che passare una coppia, come succede invece in std::map. Se esiste già un elemento con quell’indice, viene sovrascritto il valore esistente con quello specificato (se è stato specificato un valore). 59 8. Classi implementate L’iteratore restituito punta all’elemento con indice i. Se viene passato anche un iteratore come parametro, viene usato come hint. Come accade anche per i metodi precedenti, l’iteratore hint può essere anche end(), e in tal caso viene ignorato. Il costo ammortizzato di questi metodi è O(log2 n). Metodi reset() Vengono forniti quattro metodi per azzerare un elemento: iterator reset ( iterator i ); iterator reset ( iterator first , iterator last ); void reset ( dimension_type i ); void reset_after ( dimension_type i ); L’elemento azzerato non viene più memorizzato, quindi il risultato di row.reset(5); è ben diverso da row[5] = 0; . reset_after() azzera tutti gli elementi con indice maggiore o uguale a quello specificato. Per ogni elemento azzerato c’è un costo ammortizzato di O(log2 n). Metodi per combinare due righe Vengono forniti 4 metodi per combinare i valori corrispondenti di due righe: template < typename Func1 , typename Func2 > void combine_needs_first ( const Sparse_Row & y , const Func1 & f , const Func2 & g ); template < typename Func1 , typename Func2 > void combine_needs_second ( const Sparse_Row & y , const Func1 & g , const Func2 & h ); template < typename typename void combine ( const const const 60 Func1 , typename Func2 , Func3 > Sparse_Row & y , Func1 & f , const Func2 & g , Func3 & h ); 8.3. Sparse_Matrix void linear_combine ( const Sparse_Row & y , Coefficient_traits :: const_reference coeff1 , Coefficient_traits :: const_reference coeff2 ); }; Il metodo concettualmente più semplice è combine(): applica l’oggetto funzione f agli elementi memorizzati solo in *this, g agli elementi memorizzati in entrambe le righe (passando i due elementi corrispondenti come parametri) e h a quelli memorizzati solo in y. Affinché questo abbia un comportamento ragionevole, f e h devono essere le versioni ottimizzate dell’operazione g che assumono rispettivamente che il primo o il secondo dei due elementi sia zero. Inoltre, g deve avere come risultato zero quando è applicato a due zeri. Tutti questi oggetti funzione, al posto di restituire davvero il risultato, modificano il primo parametro che gli è passato (e che fa anche da parametro di input, tranne nel caso di h) per evitare copie inutili di oggetti Coefficient. Il metodo combine_needs_first() è una versione più efficiente di combine(), in cui h deve essere l’identità (cioè in cui l’operazione g, quando ha come primo parametro 0, ha come risultato 0). Un caso in cui si può usare questo metodo è quando si vuole fare il modulo di ogni elemento di *this per l’elemento corrispondente di y, mettendo il risultato in *this. In modo simmetrico, combine_needs_second() è una versione più efficiente di combine() in cui f deve essere l’identità (cioè in cui l’operazione g ha come risultato il primo parametro quando il secondo parametro è 0). Un caso in cui lo si può usare è per il calcolo della somma degli elementi corrispondenti di *this e y, in cui il risultato viene messo in *this. Il metodo linear_combine() è una versione di combine() ottimizzata per un’operazione molto specifica: ~x ← c1~x + c2 ~y Questo metodo è stato fornito perché questa è un’operazione molto comune all’interno della PPL, e quindi deve essere il più possibile ottimizzata. 8.3 Sparse_Matrix La classe Sparse_Matrix è sostanzialmente un 61 8. Classi implementate std::vector<Sparse_Row> con l’aggiunta di alcuni metodi specifici. L’interfaccia di questa classe è un soprainsieme di quella di Dense_Matrix, in modo da poter portare più facilmente a Sparse_Matrix il codice che usa Dense_Matrix. Iteratori Gli iteratori forniti sono quelli di std::vector. I metodi che si possono chiamare per ottenere degli iteratori sono begin() e end(). Dimensione della matrice Sono forniti molti metodi per l’aggiunta e la rimozione di righe e colonne: void resize ( dimension_type n , Flags row_flags = Flags ()); void resize ( dimension_type num_rows , dimension_type num_columns , Flags row_flags = Flags ()); void add_zero_rows_and_columns ( dimension_type n , dimension_type m , Flags row_flags ); void add_zero_rows ( dimension_type n , Flags row_flags ); void add_row ( const Sparse_Row & x ); void remove_trailing_rows ( dimension_type n ); void add_zero_columns ( dimension_type n ); void add_zero_columns ( dimension_type n , dimension_type i ); void remove_column ( dimension_type i ); void remove_trailing_columns ( dimension_type n ); 62 8.3. Sparse_Matrix I metodi add_zero_rows_and_columns(), add_zero_rows(), remove_trailing_rows(), remove_trailing_columns() e la versione con un solo parametro di add_zero_columns() sono ridondanti, basterebbero i metodi resize(); sono forniti solo per compatibilità con Dense_Row. L’argomento di tipo Flags serve per specificare i flag da assegnare alle nuove righe, se ce ne sono. Per ottenere le dimensioni della matrice si usano i metodi num_rows() e num_columns(), con il significato ovvio. Accesso alle righe Per accedere alle righe della matrice si usa l’operator[]: Sparse_Row & operator []( dimension_type i ); const Sparse_Row & operator []( dimension_type i ) const ; 63 Capitolo 9 Conclusione L’obiettivo di questa tesi era cercare una struttura dati efficiente per la rappresentazione di matrici sparse, per aumentare le prestazioni dei risolutori MIP e PIP e per ridurre la memoria da essi utilizzata. Inoltre, doveva avere un buon comportamento anche in presenza di matrici di dimensioni ridotte, perché tipicamente i risolutori vengono utilizzati per la risoluzione di tanti problemi con poche variabili e pochi vincoli, piuttosto che un singolo problema complesso. I risolutori non eseguono sulla matrice operazioni matriciali, come spesso avviene in altri ambiti, ma lavorano sulle singole righe della matrice. La struttura dati doveva quindi essere adatta per questo tipo di operazioni. Panoramica sulla tesi In un primo momento si sono analizzate le rappresentazioni classiche usate per le matrici sparse che si trovano in letteratura, commentando i loro punti di forza e di debolezza rispetto allo scopo che ci si era prefissi. Si è poi implementata una di tali strutture, precisamente la struttura basata su righe implementate con liste, e si sono analizzate le prestazioni dei risolutori in combinazione con questa implementazione. Non avendo ottenuto prestazioni ottimali, si è andati alla ricerca di una struttura che riuscisse a sfruttare bene le cache, per eliminare questo collo di bottiglia. La struttura CO_Tree è stata ritenuta un buon candidato, ed è stata quindi implementata e, in un secondo momento, ottimizzata. Per indirizzare la ricerca, lo strumento essenziale è stato il profiling dell’esecuzione, in particolare i dati sull’uso della cache, che hanno di individuare con precisione la debolezza della struttura basata su liste, e i dati sul numero di istruzioni eseguite, che hanno permesso di individuare 65 9. Conclusione l’overhead prodotto dall’uso delle varie strutture. Risultati ottenuti Attraverso l’implementazione delle matrici sparse basate sulla struttura dati CO_Tree ottimizzata per il layout DFS si è ottenuto un netto risparmio di memoria rispetto all’implementazione densa, dell’ordine del 45%, e anche una diminuzione del tempo di esecuzione, del 30% circa. Questi miglioramenti sono più marcati nei test che richiedono più risorse, ma comunque si riscontrano anche nei test più brevi. L’obiettivo di questa tesi è stato quindi raggiunto, anche se per farlo si è dovuta cercare una struttura dati molto particolare, e ottimizzarla appositamente. Sviluppi futuri In futuro, si potrà modificare la PPL in modo da usare le matrici sparse non solo per la memorizzazione del tableau, ma anche per le altre strutture dense, in particolare per la memorizzazione dei vincoli iniziali. Questo può portare ad un marcato risparmio in termini di memoria; la variazione in termini di CPU sarà probabilmente più contenuta ma comunque evidente, dato che queste strutture vengono spesso copiate ogni qualvolta viene aggiunto un taglio durante l’ottimizzazione. Per questo scopo, visto il diverso utilizzo di queste strutture dati, potrebbe essere più adeguata una struttura semplice, basata su righe implementate come vettori o liste (vedere la sezione 2.2), piuttosto che CO_Tree. Viste le copie frequenti di queste strutture dati, per migliorare l’efficienza potrebbe essere utile implementare una semantica di tipo copy-on-write sulle singole righe, per evitare copie non necessarie. 66 Appendice A Interfacce complete delle classi A.1 Interfaccia di CO_Tree class CO_Tree { public : typedef Coefficient data_type ; typedef Coefficient_traits :: const_reference data_type_const_reference ; class iterator ; class const_iterator { public : typedef std :: bidirectional_iterator_tag iterator_category ; typedef const data_type value_type ; typedef ptrdiff_t difference_type ; typedef value_type * pointer ; typedef data_type_const_reference reference ; explicit const_iterator (); explicit const_iterator ( const CO_Tree & tree ); const_iterator ( const CO_Tree & tree , 67 A. Interfacce complete delle classi dimension_type i ); const_iterator ( const const_iterator & itr ); const_iterator ( const iterator & itr ); void swap ( const_iterator & itr ); const_iterator & operator =( const const_iterator & itr ); const_iterator & operator =( const iterator & itr ); const_iterator & operator ++(); const_iterator operator ++( int ); const_iterator & operator - -(); const_iterator operator - -( int ); data_type_const_reference operator *() const ; dimension_type index () const ; bool operator ==( const const_iterator & x ) const ; bool operator !=( const const_iterator & x ) const ; }; class iterator { public : typedef std :: bidirectional_iterator_tag iterator_category ; typedef data_type value_type ; typedef ptrdiff_t difference_type ; typedef value_type * pointer ; typedef value_type & reference ; iterator (); explicit iterator ( CO_Tree & tree ); iterator ( CO_Tree & tree , dimension_type i ); explicit iterator ( const tree_iterator & itr ); iterator ( const iterator & itr ); 68 A.1. Interfaccia di CO_Tree void swap ( iterator & itr ); iterator & operator =( const iterator & itr ); iterator & operator =( const tree_iterator & itr ); iterator & operator ++(); iterator operator ++( int ); iterator & operator - -(); iterator operator - -( int ); data_type & operator *(); data_type_const_reference operator *() const ; dimension_type index () const ; bool operator ==( const iterator & x ) const ; bool operator !=( const iterator & x ) const ; }; CO_Tree (); CO_Tree ( const CO_Tree & v ); template < typename Iterator > CO_Tree ( Iterator i , dimension_type n ); CO_Tree & operator =( const CO_Tree & x ); void clear (); ~ CO_Tree (); bool empty () const ; dimension_type size () const ; static dimension_type max_size (); void dump_tree () const ; 69 A. Interfacce complete delle classi dimension_type external_memory_in_bytes () const ; iterator insert ( dimension_type key ); iterator insert ( dimension_type key , data_type_const_reference data ); iterator insert ( iterator itr , dimension_type key ); iterator insert ( iterator itr , dimension_type key , data_type_const_reference data ); iterator erase ( dimension_type key ); iterator erase ( iterator itr ); void erase_element_and_shift_left ( dimension_type key ); void increase_keys_from ( dimension_type key , dimension_type n ); void swap ( CO_Tree & x ); iterator begin (); const iterator & end (); const_iterator begin () const ; const const_iterator & end () const ; const_iterator cbegin () const ; const const_iterator & cend () const ; iterator bisect ( dimension_type key ); const_iterator bisect ( dimension_type key ) const ; iterator bisect_in ( iterator first , iterator last , dimension_type key ); const_iterator bisect_in ( const_iterator first , const_iterator last , dimension_type key ) const ; iterator bisect_near ( iterator hint , dimension_type key ); const_iterator bisect_near ( const_iterator hint , dimension_type key ) const ; 70 A.2. Interfaccia di Sparse_Row }; A.2 Interfaccia di Sparse_Row class Sparse_Row { public : typedef Row_Flags Flags ; typedef CO_Tree :: iterator iterator ; typedef CO_Tree :: const_iterator const_iterator ; explicit Sparse_Row ( dimension_type n = 0 , Flags flags = Flags ()); Sparse_Row ( dimension_type n , dimension_type capacity , Flags flags = Flags ()); Sparse_Row ( const Sparse_Row & y , dimension_type capacity ); Sparse_Row ( const Sparse_Row & y , dimension_type sz , dimension_type capacity ); explicit Sparse_Row ( const Dense_Row & row ); void construct ( dimension_type n , Flags flags = Flags ()); void construct ( dimension_type n , dimension_type capacity , Flags flags = Flags ()); void swap ( Sparse_Row & x ); dimension_type size () const ; void resize ( dimension_type n ); void expand_within_capacity ( dimension_type n ); void shrink ( dimension_type n ); void delete_element_and_shift ( dimension_type i ); 71 A. Interfacce complete delle classi void add_zeroes_and_shift ( dimension_type n , dimension_type i ); iterator begin (); const iterator & end (); const_iterator begin () const ; const const_iterator & end () const ; const_iterator cbegin () const ; const const_iterator & cend () const ; static dimension_type max_size (); const Flags & flags () const ; Flags & flags (); void clear (); Coefficient & operator []( dimension_type i ); Coefficient_traits :: const_reference operator []( dimension_type i ) const ; Coefficient_traits :: const_reference get ( dimension_type i ) const ; iterator find ( dimension_type i ); iterator find ( iterator itr , dimension_type i ); const_iterator find ( dimension_type i ) const ; const_iterator find ( const_iterator itr , dimension_type i ) const ; iterator lower_bound ( dimension_type i ); iterator lower_bound ( iterator itr , dimension_type i ); const_iterator lower_bound ( dimension_type i ) const ; const_iterator lower_bound ( const_iterator itr , dimension_type i ) const ; 72 A.2. Interfaccia di Sparse_Row iterator insert ( dimension_type i , Coefficient_traits :: const_reference x ); iterator insert ( iterator itr , dimension_type i , Coefficient_traits :: const_reference x ); iterator insert ( dimension_type i ); iterator insert ( iterator itr , dimension_type i ); void swap ( dimension_type i , dimension_type j ); void swap ( iterator i , iterator j ); iterator reset ( iterator i ); iterator reset ( iterator first , iterator last ); void reset ( dimension_type i ); void reset_after ( dimension_type i ); void normalize (); template < typename Func1 , typename Func2 > void combine_needs_first ( const Sparse_Row & y , const Func1 & f , const Func2 & g ); template < typename Func1 , typename Func2 > void combine_needs_second ( const Sparse_Row & y , const Func1 & g , const Func2 & h ); template < typename typename void combine ( const const const Func1 , typename Func2 , Func3 > Sparse_Row & y , Func1 & f , const Func2 & g , Func3 & h ); void linear_combine ( const Sparse_Row & y , 73 A. Interfacce complete delle classi Coefficient_traits :: const_reference coeff1 , Coefficient_traits :: const_reference coeff2 ); void ascii_dump () const ; void ascii_dump ( std :: ostream & s ) const ; void print () const ; bool ascii_load ( std :: istream & s ); memory_size_type external_memory_in_bytes () const ; memory_size_type external_memory_in_bytes ( dimension_type capacity ) const ; memory_size_type total_memory_in_bytes () const ; memory_size_type total_memory_in_bytes ( dimension_type capacity ) const ; bool OK () const ; bool OK ( dimension_type capacity ) const ; }; bool operator ==( const const bool operator !=( const const A.3 Sparse_Row & Sparse_Row & Sparse_Row & Sparse_Row & x, y ); x, y ); Interfaccia di Sparse_Matrix class Sparse_Matrix { public : typedef std :: vector < Sparse_Row >:: iterator iterator ; typedef std :: vector < Sparse_Row >:: const_iterator const_iterator ; typedef Sparse_Row :: Flags Flags ; static dimension_type max_num_rows (); static dimension_type max_num_columns (); explicit Sparse_Matrix ( dimension_type n = 0 , 74 A.3. Interfaccia di Sparse_Matrix Flags row_flags = Flags ()); Sparse_Matrix ( dimension_type num_rows , dimension_type num_columns , Flags row_flags = Flags ()); void swap ( Sparse_Matrix & x ); dimension_type num_rows () const ; dimension_type num_columns () const ; bool has_no_rows () const ; void resize ( dimension_type n , Flags row_flags = Flags ()); void resize ( dimension_type num_rows , dimension_type num_columns , Flags row_flags = Flags ()); void resize_no_copy ( dimension_type new_n_rows , dimension_type new_n_columns , Flags row_flags ); void add_zero_rows_and_columns ( dimension_type n , dimension_type m , Flags row_flags ); void add_zero_rows ( dimension_type n , Flags row_flags ); void add_row ( const Sparse_Row & x ); void add_recycled_row ( Sparse_Row & y ); void remove_trailing_rows ( dimension_type n ); void permute_columns ( const std :: vector < dimension_type >& cycles ); void add_zero_columns ( dimension_type n ); void add_zero_columns ( dimension_type n , dimension_type i ); 75 A. Interfacce complete delle classi void remove_column ( dimension_type i ); void remove_trailing_columns ( dimension_type n ); void clear (); iterator begin (); iterator end (); const_iterator begin () const ; const_iterator end () const ; Sparse_Row & operator []( dimension_type i ); const Sparse_Row & operator []( dimension_type i ) const ; bool ascii_load ( std :: istream & s ); void ascii_dump () const ; void ascii_dump ( std :: ostream & s ) const ; void print () const ; memory_size_type total_memory_in_bytes () const ; memory_size_type external_memory_in_bytes () const ; bool OK () const ; }; bool operator ==( const const bool operator !=( const const 76 Sparse_Matrix & Sparse_Matrix & Sparse_Matrix & Sparse_Matrix & x, y ); x, y ); Bibliografia [Brodal01] “Cache Oblivious Search Trees via Binary Trees of Small Height”, di G. S. Brodal, R. Fagerberg, e R. Jacob. http://citeseerx.ist.psu.edu/viewdoc/download?doi=10.1. 1.83.6795&rep=rep1&type=pdf [Bender02] “A locality-preserving cache-oblivious dynamic dictionary”, di M. A. Bender, Z. Duan, J. Iacono e J. Wu. In Proc. 13th Ann. ACMSIAM Symp. on Discrete Algorithms, 2002. [Duff86] “Direct methods for sparse matrices”, di I. S. Duff, A.M. Erisman, J. K. Reid. Oxford science publications, 1986 [Frigo99] “Cache-Oblivious Algorithms”, di M. Frigo, C. E. Leiserson, H. Prokop, S. Ramachandran. Proceedings of the 40th IEEE Symposium on Foundations of Computer Science, 1999 [PPLweb] Il sito web della Parma Polyhedra Library http://www.cs. unipr.it/ppl/ [Sutter07] “Machine Architecture: Things Your Programming Language Never Told You”, di Herb Sutter. http://www.nwcpp.org/Downloads/ 2007/Machine_Architecture_-_NWCPP.pdf [Veldhorst82] “An analysis of sparse matrix storage schemes”, di M. Veldhorst. Mathematical centre, 1982 [Zlatev91] “Computational methods for general sparse matrices”, di Z. Zlatev. Kluwer Academic Publishers, 1991 77