Progettazione ed implementazione di matrici sparse basate su

annuncio pubblicitario
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
Scarica