Programmazione Parallela di Giochi a Somma Zero

Transcript

Programmazione Parallela di Giochi a Somma Zero
Scuola Politecnica e delle Scienze di Base
Corso di Laurea in Ingegneria Informatica
Elaborato finale in Programmazione I
Programmazione Parallela di Giochi a
Somma Zero
Anno Accademico 2014/2015
Relatore:
Ch.mo prof. Alessandro Cilardo
Candidato:
Alessandro Chillemi
matr. N46001229
Indice
Indice................................................................................................................................................... II
Introduzione..........................................................................................................................................3
Capitolo 1: Fondamenti teorici.............................................................................................................4
1.1 Minimax..................................................................................................................................... 4
1.1.1 Algoritmo minimax............................................................................................................. 5
1.1.2 Esempio............................................................................................................................... 6
1.1.3 Negamax..............................................................................................................................6
1.2 Potatura alfa-beta........................................................................................................................7
1.2.1 Esempio............................................................................................................................... 8
1.2.2 Aspiration Window..............................................................................................................9
1.2.3 Null Window....................................................................................................................... 9
1.3 Principal Variation Search........................................................................................................ 10
1.4 Iterative Deepening.................................................................................................................. 11
1.5 Principal Variation Splitting..................................................................................................... 12
1.6 Young Brothers Wait Concept.................................................................................................. 13
Capitolo 2: Stockfish 6....................................................................................................................... 15
2.1 Ricerca......................................................................................................................................15
2.1.1 Funzione id_loop()............................................................................................................ 15
2.1.2 Funzione search<>().........................................................................................................16
2.1.3 Funzione qsearch<>().......................................................................................................19
2.2 Thread.......................................................................................................................................20
2.2.1 SplitPoint........................................................................................................................... 20
2.2.2 ThreadBase........................................................................................................................ 20
2.2.3 Thread................................................................................................................................20
2.2.4 ThreadPool........................................................................................................................ 21
2.2.5 Funzione split()..................................................................................................................21
2.2.6 Funzione idle_loop()......................................................................................................... 22
2.2.7 Funzione available_to().................................................................................................... 25
2.3 Struttura del software............................................................................................................... 25
Capitolo 3: Documentazione di Stockfish 6.......................................................................................30
Conclusioni.........................................................................................................................................32
Bibliografia.........................................................................................................................................33
Introduzione
L'elaborato presenta i principali approcci per l'esplorazione dell'albero di un gioco a
somma zero e alcune tra le possibili tecniche per parallelizzarla, sia dal punto di vista
teorico sia dal punto di vista implementativo.
Il primo capitolo descrive i più importanti algoritmi sequenziali volti alla ricerca
all'interno di un albero (minimax e potatura alfa-beta) con l'ausilio di esempi e funzioni
scritte in pseudo-codice; vengono inoltre introdotte diverse tecniche per ottimizzare tali
algoritmi e tentare di migliorarne prestazioni ed efficienza; si espongono, infine, alcuni
metodi per poter eseguire ricerche equivalenti utilizzando più di un processore.
Il secondo capitolo presenta un caso di studio: il motore scacchistico open source
Stockfish. Vengono mostrate alcune tra le funzioni fondamentali di tale programma, sia
con riferimenti teorici agli algoritmi già trattati sia con commenti legati direttamente al
codice della specifica implementazione.
Il lavoro esposto nel secondo capitolo, inoltre, è alla base dell'inizio di un'opera di
documentazione del codice sorgente del software.
3
Capitolo 1: Fondamenti teorici
Un gioco rappresenta una situazione di interazione strategica, ovvero una situazione nella
quale le scelte di ogni individuo possono influenzare le scelte e/o i guadagni (economici o
di altro tipo) di altri individui. Detti “giocatori” gli individui che partecipano a un gioco,
quest'ultimo si dice “a somma zero” quando il guadagno totale è uguale a zero, ovvero le
vincite di un giocatore equivalgono alle perdite degli altri, senza pagamenti da o verso
l'esterno. Si definisce “strategia” il modo in cui un giocatore reagirebbe alle diverse
circostanze che si possono verificare durante l'interazione. Lo scopo della teoria classica
dei giochi è di individuare le strategie ottimali che dovrebbero essere adottate da tutti i
giocatori e, di conseguenza, l'esito finale della loro interazione.
Un gioco può essere in generale descritto da una struttura ad albero, nella quale ogni nodo
corrisponde a una possibile situazione del gioco (posizione), gli archi uscenti da ciascun
nodo corrispondono alle possibili mosse del giocatore che è chiamato a muovere in quella
posizione e ai nodi terminali (foglie) si associano i valori delle vincite di ciascun
giocatore. Nel caso di giochi a somma zero a due giocatori, è possibile riferirsi al valore
della vincita di un solo giocatore, in quanto quella dell'altro giocatore sarà uguale
all'opposto di tale valore. Nel seguito si farà riferimento esclusivamente a giochi a somma
zero, a due giocatori, sequenziali (ovvero con mosse alternative, non simultanee).
1.1 Minimax
Supponendo che i due giocatori siano razionali e adottino decisioni allo scopo di
massimizzare il proprio guadagno, il minimax è un metodo per minimizzare la massima
perdita possibile. Per fare ciò, è sufficiente che il giocatore chiamato a muovere, per
ognuna delle mosse che ha a disposizione, individui la massima perdita che può
infliggergli l'avversario e scelga, quindi, la mossa che gli garantisce la più piccola tra
queste perdite massime. Un approccio del genere equivale ad assumere che il proprio
4
avversario giocherà sempre le mosse migliori e ad assicurarsi, in previsione di ciò, il meno
cattivo fra gli esiti del gioco. Il metodo duale del minimax, detto maximin, consiste nel
massimizzare la minima vincita possibile e, per i giochi a somma zero, è equivalente al
minimax. Nel caso di giochi a somma diversa da zero, il metodo minimax può condurre a
strategie non ottimali.
1.1.1 Algoritmo minimax
Il metodo minimax assume la forma di algoritmo minimax, ovvero un algoritmo ricorsivo
per la ricerca della mossa migliore in una determinata posizione. Per individuare le
sequenze strategiche, l'algoritmo analizza le mosse migliori a partire dalle foglie
dell'albero fino a risalire alla radice. Posto +∞ il valore di una posizione vincente e -∞ il
valore di una posizione perdente, ogni nodo foglia viene analizzato attraverso una
funzione di valutazione posizionale che misura la bontà della posizione, indicando quanto
è desiderabile raggiungerla per il dato giocatore; nei nodi in cui il giocatore è chiamato a
muovere, si sceglie il massimo tra i valori dei nodi figli; nei nodi in cui l'avversario è
chiamato a muovere, si sceglie il minimo tra i valori dei nodi figli. Si può descrivere
l'algoritmo con il seguente pseudocodice:
fun minimax(n: node): int =
if leaf(n) then return evaluate(n)
if n is a max node
v := -∞
for each child of n
v' := minimax (child)
if v' > v, v:= v'
return v
if n is a min node
v := +∞
for each child of n
v' := minimax (child)
if v' < v, v:= v'
return v
Se l'albero ha un fattore di diramazione (numero di nodi figlio per ogni nodo) pari a b e
una profondità di ricerca pari a d, l'algoritmo opera in tempo O(bd) e in spazio O(b⋅d). Nel
5
gioco degli scacchi, ad esempio, b ≈ 35 e d ≈ 100 e l'uso di questo algoritmo è del tutto
impraticabile.
1.1.2 Esempio
Si supponga di analizzare un gioco rappresentato dall'albero in figura: i quadrati
corrispondono alle mosse del giocatore massimizzante, i cerchi a quelle del giocatore
minimizzante. I valori assegnati dall'algoritmo minimax a ogni livello corrispondono alle
frecce rosse; la freccia blu indica la mossa migliore a disposizione del giocatore che
muove al livello 0.
1.1.3 Negamax
Il negamax è una variante dell'algoritmo minimax. Non ne costituisce un vero
miglioramento, quanto piuttosto un modo più semplice e compatto di scriverlo sotto forma
di codice. Invece di valutare tutti i nodi in relazione a un dato giocatore utilizzando due
routine separate per il massimizzante e il minimizzante, si valuta ogni nodo solo in
relazione a chi ha il diritto a muovere, negando il valore ottenuto di livello in livello in
base all'osservazione matematica:
max(a,b) == -min(-a,-b)
Il negamax può essere descritto dal seguente pseudo-codice:
6
fun negamax(n: node): int =
if leaf(n) then return evaluate(n)
v := -∞
for each child of n
v' := -negamax (child)
if v' > v, v:= v'
return v
1.2 Potatura alfa-beta
La potatura alfa-beta è un significativo miglioramento dell'algoritmo minimax, che
permette di eliminare grandi porzioni dell'albero di gioco che si rivelano essere superflue
ai fini della ricerca della sequenza di mosse ottimale. Ciò è inoltre ottenuto senza correre
alcun rischio di trascurare una potenziale scelta migliore: si tratta, in altre parole, di
un'ottimizzazione sicura, che non modifica il risultato finale del minimax.
La potatura alfa-beta si basa su due valori, detti appunto alfa e beta, che rappresentano, in
ogni momento della partita, rispettivamente il punteggio minimo che può garantirsi il
giocatore massimizzante e il punteggio massimo che può raggiungere il giocatore
minimizzante. L'idea alla base dell'algoritmo è quella di effettuare una normale ricerca
minimax, con l'accorgimento di eliminare (potare) i rami dell'albero che portano a una
posizione “troppo buona” per il giocatore che muove, ovvero a una posizione che
l'avversario certamente non permetterà di raggiungere, in quanto ha a disposizione una
mossa migliore dal suo punto di vista; poiché si prevede che tale posizione non verrà mai
raggiunta, risulta superfluo continuare a esplorare quel ramo dell'albero. In termini
matematici, ciò equivale a dire che avviene una potatura (cutoff) ogni qual volta il valore
calcolato per un nodo figlio è superiore a beta per il giocatore massimizzante oppure è
inferiore ad alfa per il giocatore minimizzante.
L'algoritmo di potatura alfa-beta può essere descritto dal seguente pseudo-codice:
fun alphabeta(n: node, d: int, alpha: int, beta: int): int =
if leaf(n) or depth=0 return evaluate(n)
if n is a max node
v := alpha
for each child of n
v' := alphabeta (child,d-1,v,beta)
if v' > v, v:= v'
7
if v > beta return beta
return v
if n is a min node
v := beta
for each child of n
v' := alphabeta (child,d-1,alpha,v)
if v' < v, v:= v'
if v < alpha return alpha
return v
La potatura alfa-beta permette generalmente un considerevole guadagno rispetto
all'algoritmo minimax in termini di complessità computazionale. La sua efficienza, però,
dipende fortemente dall'ordine in cui vengono esplorati i rami dell'albero: se si ha un
fattore di diramazione pari a b e una profondità di ricerca pari a d, con un ordinamento
pessimo l'algoritmo opera in tempo O(bd), lo stesso di una ricerca minimax; con un
ordinamento perfetto (le mosse migliori sono le prime a essere valutate) si ha il massimo
numero di potature e l'algoritmo opera in tempo O(bd/2). Ciò significa che, nel caso
migliore, la ricerca può raggiungere una profondità doppia con lo stesso numero di calcoli.
Per questo motivo è di grande importanza cercare di ordinare quanto più è possibile
l'albero di gioco, utilizzando euristiche, pre-ricerche a profondità ridotta e altre tecniche
che permettono di capire rapidamente quali potrebbero essere le mosse migliori.
1.2.1 Esempio
Nell'esempio in figura, i rami del terzo nodo al secondo livello possono essere potati: il
valore di alfa in corrispondenza di tale nodo, ovvero il minimo valore che il giocatore
massimizzante al primo livello può assicurarsi, è pari a 5; poiché l'esplorazione del primo
figlio restituisce un valore pari a 1 (minore di alfa), non vi è alcun bisogno di proseguire la
ricerca, poiché si può già affermare con certezza che la mossa in esame non verrà mai
giocata, in quanto il giocatore massimizzante, avendo già a disposizione una mossa che gli
garantisce un valore pari ad alfa, non permetterà mai che venga raggiunta una posizione in
cui il giocatore minimizzante può forzare un valore minore di alfa.
8
1.2.2 Aspiration Window
La tecnica delle aspiration windows (chiamata anche Aspiration Search) è volta a
migliorare ulteriormente le prestazioni della potatura alfa-beta. Essa consiste nel ridurre il
numero di nodi esaminati, utilizzando una finestra di ricerca (ovvero l'intervallo di valori
compresi tra alfa e beta) molto stretta, centrata su un valore atteso per un dato nodo.
Poiché la finestra è più piccola, verranno tagliati più rami del normale e la ricerca
impiegherà meno tempo. Se il valore restituito è all'interno della finestra, la ricerca è
andata a buon fine ed è stata eseguita con grande efficienza; se invece è al di fuori della
finestra, è necessario fare una nuova ricerca con un intervallo più largo. In quest'ultimo
caso la tecnica peggiora l'efficienza, ma si assume che una situazione del genere non si
verifichi molto spesso.
Detto V il valore atteso di un nodo e detto E il margine di errore scelto, i valori di alfa e
beta per implementare una aspiration window sono assegnati come:
alpha = V – E;
beta = V + E;
Il valore di E può essere costante oppure dipendere, ad esempio, dal livello di profondità
raggiunto nell'albero.
1.2.3 Null Window
La tecnica delle null windows può essere considerata come un caso estremo delle
aspiration windows in cui alfa e beta sono separati da una sola unità. Lo scopo di questa
9
tecnica è quello di effettuare un test booleano per capire se una mossa produce un risultato
minore o uguale ad alfa oppure maggiore o uguale a beta. Le null windows sono alla base
della Principal Variation Search (vedi paragrafo seguente).
1.3 Principal Variation Search
La Principal Variation Search (PVS) è un algoritmo di ricerca basato sulla potatura alfabeta e sull'uso delle null windows. L'idea fondamentale è che, per gran parte dei nodi
dell'albero, non è necessario calcolarne il valore esatto, ma solo un limite superiore o
inferiore che dimostri che una data mossa non è accettabile per uno dei due giocatori; il
valore esatto è richiesto solo per i nodi appartenenti alla cosiddetta “variante principale”
(principal variation, PV), ovvero alla sequenza di mosse migliori a disposizione dei due
giocatori. In virtù di questa osservazione, viene effettuata una ricerca “completa” solo per i
nodi che già appartengono alla PV o che si ritiene possano rappresentare la mossa
migliore, mentre viene fatta una ricerca con una null window centrata intorno al valore di
alfa per tutti gli altri nodi: ci si aspetta, infatti, che tutte le mosse rimanenti siano peggiori
della prima mossa ricercata e, pertanto, è necessario solamente effettuare un test che possa
confermare questa previsione; se il test fallisce (valore del nodo > alfa), è necessario
effettuare di nuovo la ricerca con la finestra completa (finestra = beta – alfa) sul nodo in
questione, in quanto esso costituisce la nuova migliore mossa, apparterrà alla PV e occorre
quindi calcolarne il valore esatto. L'algoritmo può essere descritto dal seguente pseudocodice:
fun pvs(n: node, d: int, alpha: int, beta: int): int =
if leaf(n) or depth=0 return evaluate(n)
for each child of n
if child is not first child
score := -pvs(child,d-1,-alpha-1,-alpha)
if alpha < score < beta
score := -pvs(child,d-1,-beta,-score)
else
score := -pvs(child,d-1,-beta,-alpha)
alpha := max(alpha,score)
if alpha >= beta
break
10
return alpha
L'assunzione che rende la PVS potenzialmente molto efficiente è che la prima mossa
ricercata sia quella appartenente alla PV e che sia quindi migliore di tutte le altre. Questo
può essere vero solo se si dispone di un buon ordinamento delle mosse; per raggiungerlo,
oltre a utilizzare euristiche basate sulla posizione o sulla natura delle mosse, si inserisce
spesso questo algoritmo in un framework di Iterative Deepening (vedi paragrafo
seguente), che fornisce a ogni livello di profondità la mossa migliore risultante dalle
ricerche a profondità minori.
1.4 Iterative Deepening
L'Iterative Deepening è un particolare modo di esplorare l'albero di gioco che si dimostra
molto vantaggioso per l'ordinamento delle mosse in algoritmi di ricerca come PVS.
Consiste nell'eseguire in modo iterativo una ricerca a livelli di profondità crescente: viene
prima effettuata una ricerca fino al livello di profondità uno; poi viene effettuata una
nuova ricerca fino al livello di profondità due; poi una nuova ricerca fino al livello di
profondità tre e così via fino a raggiungere la profondità richiesta o fino a che, nel caso
siano presenti vincoli temporali, il tempo a disposizione non si esaurisce. I vantaggi di tale
metodo sono numerosi: innanzitutto, qualsiasi sia la profondità di ricerca raggiunta, è
sempre possibile fornire il risultato proveniente dall'iterazione precedente, nel caso la
ricerca debba essere interrotta; anche la gestione del tempo, qualora questo sia un fattore
da tenere in considerazione, è migliorata, in quanto ogni ricerca permette di fare
un'approssimazione sul tempo necessario a compiere l'iterazione successiva. Un altro
vantaggio è che i risultati intermedi permettono di raccogliere informazioni utili per le
successive iterazioni: a ogni livello si può sfruttare la variante principale calcolata nei
livelli precedenti come indicatore per ordinare le mosse da ricercare nel miglior modo
possibile; il valore di un nodo ottenuto nell'iterazione precedente può essere usato come
una stima del suo valore effettivo, in modo da poter applicare la tecnica delle aspiration
windows. Infine, alcune posizioni corrispondenti a nodi interni di ricerche a grandi
profondità possono spesso essere state già valutate da ricerche a profondità minori; se tali
11
posizioni e i risultati ad esse associati sono stati salvati in una Transposition Table, è
possibile fare un riuso di questi ultimi.
Gli algoritmi di ricerca presentati finora sono intrinsecamente sequenziali. È possibile
tuttavia parallelizzare la ricerca alfa-beta, dividendo l'albero di gioco in più sottoalberi e
assegnandoli a diversi processori (o diversi thread, come si vedrà nel caso di studio). Un
tale approccio, però, può comunque portare a problemi di efficienza. Saranno infatti
presenti: un overhead di comunicazione, legato alla necessità di informare i vari processori
dei valori di alfa e beta scoperti durante l'esplorazione dell'albero; un overhead di
sincronizzazione, dovuto ai possibili periodi di inattività che devono sostenere i processori
quando sono in attesa di qualche evento per poter continuare a lavorare; un overhead di
ricerca, dovuto al rischio di esplorare nodi che sarebbero stati evitati da una versione
sequenziale dell'algoritmo. Questi tre tipi di overhead non sono indipendenti, ma sono
correlati tra loro in modo complesso e non è del tutto possibile eliminarli. Nella restante
parte del capitolo verranno presentati due metodi per la parallelizzazione della ricerca alfabeta.
1.5 Principal Variation Splitting
Il metodo della Principal Variation Splitting (PVSplit) è un metodo per parallelizzare la
ricerca alfa-beta pensato per alberi di gioco fortemente ordinati, ovvero alberi nei quali il
primo figlio di ogni nodo corrisponde alla mossa migliore almeno il 70% delle volte. In
virtù di ciò, si assume che il percorso che si ottiene scegliendo sempre la prima mossa,
dalla radice fino alle foglie, sia la variante principale (PV). L'algoritmo della PVSplit
prevede che il primo ramo di ogni nodo appartenente alla PV debba essere esplorato prima
che possa iniziare la ricerca parallela degli altri rami. Ciò significa che tutti i processori
percorrono la variante principale fino a raggiungere il nodo che si trova al livello superiore
rispetto alle foglie; qui un processore esplora il primo ramo, mentre gli altri aspettano.
Quando il primo ramo è stato esaminato, tutti i processori partecipano alla ricerca in
parallelo, prendendosi ognuno carico di uno dei rami restanti e calcolandone il valore. Se
12
un processore scopre un punteggio migliore di quello attualmente disponibile durante
l'esame di un nodo, informa gli altri processori; quando non ci sono rami non assegnati, un
processore che ha terminato il proprio lavoro rimane inattivo fino a che gli altri non
finiscono. Quando tutti i figli sono stati esplorati, la ricerca passa al genitore del nodo e,
poiché il primo ramo è già stato esaminato, essa può procedere direttamente in parallelo. Il
processo continua in questo modo fino a raggiungere la radice dell'albero.
Il motivo per cui si richiede che il primo ramo venga esplorato prima di avviare la ricerca
in parallelo è che in questo modo i rami rimanenti vengono esaminati con una finestra
alfa-beta più piccola, in base al risultato ottenuto dalla prima ricerca; quando la prima
mossa è anche la migliore, la ricerca parallela esamina esattamente gli stessi nodi della
versione sequenziale; quando non lo è, invece, aumenta l' overhead di ricerca, in quanto la
ricerca parallela viene effettuata con una finestra più ampia di quella che verrebbe
utilizzata con la versione sequenziale. Anche l'overhead di sincronizzazione può risultare
un problema, in quanto spesso, alla fine di una ricerca parallela, la maggior parte dei
processori rimane inattiva in attesa che pochi processori completino il proprio lavoro.
1.6 Young Brothers Wait Concept
L o Young Brothers Wait Concept (YBWC) si basa sulla definizione del primo ramo
generato a partire da un dato nodo come “fratello maggiore” ( eldest brother) e degli altri
rami come “fratelli minori” (younger brothers). Come suggerisce il nome, la regola
principale dell'algoritmo impone che il fratello maggiore venga esaminato prima che sia
possibile la ricerca parallela dei fratelli minori. La differenza con la PVSplit è che nello
YBWC la ricerca parallela è possibile in corrispondenza di ogni nodo, non solo di quelli
appartenenti alla variante principale.
Nello YBWC, inoltre, è introdotto il concetto di proprietà di un nodo e una relazione di
master/slave tra i processori. Un processore proprietario di un nodo è responsabile della
sua valutazione e della restituzione del risultato al nodo genitore. Questo implica una
possibile comunicazione tra processori, se i proprietari di padre e figlio sono diversi. La
proprietà di un nodo non è trasferibile. All'inizio dell'algoritmo, la proprietà del nodo
13
radice viene assegnata a un processore, mentre gli altri rimangono inattivi.
Successivamente, un processore P1 inattivo può richiedere lavoro a un altro processore P2:
se quest'ultimo ha lavoro disponibile, esso diventa il master di P1, che a sua volta diventa
un suo slave. Un processore ha lavoro disponibile se almeno un nodo del sottoalbero che
sta esaminando soddisfa la regola dello YBWC, ovvero è un nodo in cui il fratello
maggiore è stato già esaminato; tale nodo, se esiste, diventa lo split-point, ovvero il punto
in cui la ricerca si divide per essere eseguita in parallelo. Il master e gli slave partecipano
all'esplorazione di uno split-point fino a che non viene completata: come nella PVSplit, i
processori devono comunicare tra loro per scambiarsi informazioni riguardo i nuovi valori
trovati o un avvenuto cutoff; in quest'ultimo caso, la ricerca viene considerata completata e
gli slave ritornano inattivi. Per evitare di incrementare l'overhead di sincronizzazione, il
master non rimane inattivo se termina il suo lavoro prima degli slave: al contrario, esso
tenterà di diventare a sua volta uno slave di uno dei processori ancora impegnati nella
ricerca. Tale approccio viene chiamato Helpful Master Concept.
14
Capitolo 2: Stockfish 6
Il caso di studio scelto è la sesta edizione di Stockfish, un motore scacchistico open source
considerato attualmente tra i più forti sul panorama mondiale. Il progetto è stato sviluppato
da Tord Romstad, Marco Costalba, Joona Kiiski e Gary Linscott ed è nato a partire dalla
versione 2.1 di Glaurung, un motore scacchistico norvegese.
L'algoritmo di ricerca sequenziale usato da Stockfish 6 è la Principal Variation Search
(PVS), inserito in un framework di Iterative Deepening. Il software fa uso dei thread per
la parallelizzazione della ricerca all'interno dell'albero di gioco e, in particolare, si avvale
dello Young Brothers Wait Concept (YBWC).
2.1 Ricerca
Le principali funzioni di ricerca, comprendenti l'implementazione del ciclo di Iterative
Deepening con la tecnica delle Aspiration Windows e della Principal Variation Search,
sono contenute nel file search.cpp.
2.1.1 Funzione id_loop()
La funzione id_loop() è l'implementazione del ciclo di Iterative Deepening. Viene
chiamata ripetutamente la funzione search<>(), con un parametro di profondità crescente,
finché il tempo allocato non si esaurisce, l'utente non interrompe la ricerca o il massimo
livello di profondità non viene raggiunto. Se è abilitata la modalità MultiPV, il programma
ricercherà la variante principale per le migliori k mosse nella posizione (k numero scelto
dall'utente) e non più solo per la migliore. Il ciclo si basa su un oggetto globale RootMoves
di tipo RootMoveVector che rappresenta una lista di mosse disponibili a un dato nodo
radice: si assume che queste ultime siano ordinate nel miglior modo possibile, cioè che le
mosse migliori siano collocate nelle prime posizioni. Considerata la prima mossa di
RootMoves, ovvero quella appartenente alla variante principale, viene impostata una
Aspiration Window sulla base del punteggio calcolato in una eventuale precedente
15
iterazione e si avvia una ricerca; se il risultato dovesse essere minore di alfa o maggiore di
beta, la ricerca viene ripetuta con una finestra più larga. Vengono quindi riordinate le
mosse di RootMoves.
// Iterative deepening loop until requested to stop or target depth reached
while (++depth < DEPTH_MAX && !Signals.stop && (!Limits.depth || depth <= Limits.depth))
{
/* … */
// MultiPV loop. We perform a full root search for each PV line
for (PVIdx = 0; PVIdx < std::min(multiPV, RootMoves.size()) && !Signals.stop; ++PVIdx)
{
// Reset aspiration window starting size
if (depth >= 5 * ONE_PLY)
{
delta = Value(16);
alpha=std::max(RootMoves[PVIdx].previousScore - delta,-VALUE_INFINITE);
beta = std::min(RootMoves[PVIdx].previousScore + delta, VALUE_INFINITE);
}
while (true)
{
bestValue = search<Root, false>(pos, ss, alpha, beta, depth, false);
std::stable_sort(RootMoves.begin() + PVIdx, RootMoves.end());
/* ...*/
if (bestValue <= alpha)
{
beta = (alpha + beta) / 2;
alpha = std::max(bestValue - delta, -VALUE_INFINITE);
/* … */
}
else if (bestValue >= beta)
{
alpha = (alpha + beta) / 2;
beta = std::min(bestValue + delta, VALUE_INFINITE);
}
else
break;
delta += delta / 2;
}
}
2.1.2 Funzione search<>()
La funzione search<>() è la principale funzione di ricerca. Attraverso il meccanismo dei
16
template, essa può essere utilizzata sia per i nodi PV che per i nodi non-PV e sia per i nodi
normali che per quelli SplitPoint. La ricerca viene eseguita in numerosi passi, nei quali
sono implementate diverse tecniche di ottimizzazione (Razoring, Futility Pruning,
Internal Iterative Deepening) e l'uso delle null-window. Viene inoltre chiamata la funzione
split() quando è possibile dividere il lavoro tra più thread. Infine, quando la profondità di
ricerca rimanente si esaurisce, viene chiamata la funzione qsearch<>(). I parametri di
ingresso sono: un riferimento alla posizione di gioco corrente, un array di strutture di tipo
Stack, il valore di alfa (limite inferiore dei possibili punteggi raggiungibili), il valore di
beta (limite superiore dei possibili punteggi raggiungibili), il livello di profondità di
ricerca nell'albero e un valore booleano che indica un'aspettativa sulla natura del nodo
(true se ci si aspetta un CUT node, false per il contrario). Viene restituito il valore della
posizione di ingresso.
La tecnica di ottimizzazione del Razoring viene implementata per i nodi non appartenenti
alla PV, quando il lato che muove non è sotto scacco: se la valutazione statica della
posizione sommata a un margine dipendente dal livello di profondità non eccede alfa, si
chiama direttamente la funzione qsearch<>() con una null-window intorno al valore di alfa
meno il margine: se il risultato si conferma inferiore a tale valore, viene immediatamente
restituito.
// Step 6. Razoring (skipped when in check)
if ( !PvNode
&& depth < 4 * ONE_PLY
&& eval + razor_margin(depth) <= alpha
&& ttMove == MOVE_NONE
&& !pos.pawn_on_7th(pos.side_to_move()))
{
/* … */
Value ralpha = alpha - razor_margin(depth);
Value v = qsearch<NonPV, false>(pos, ss, ralpha, ralpha+1, DEPTH_ZERO);
if (v <= ralpha)
return v;
}
17
La tecnica di ottimizzazione del Futility Pruning, concettualmente molto simile a quella
del Razoring, viene implementata restituendo immediatamente il valore della valutazione
statica della posizione meno un margine dipendente dalla profondità, se risulta maggiore o
uguale a beta.
// Step 7. Futility pruning: child node (skipped when in check)
if ( !RootNode
&& depth < 7 * ONE_PLY
&& eval - futility_margin(depth) >= beta
&& eval < VALUE_KNOWN_WIN // Do not return unproven wins
&& pos.non_pawn_material(pos.side_to_move()))
return eval – futility_margin(depth);
La tecnica dell'Internal Iterative Deepening è implementata per tentare di ottenere dalla
Transposition Table una buona prima mossa da ricercare nella posizione corrente. Per fare
ciò, viene dapprima effettuata una ricerca a un livello di profondità minore, in modo da
poter usare la migliore mossa trovata come prima mossa per la ricerca completa.
// Step 10. Internal iterative deepening (skipped when in check)
if ( depth >= (PvNode ? 5 * ONE_PLY : 8 * ONE_PLY)
&& !ttMove
&& (PvNode || ss->staticEval + 256 >= beta))
{
Depth d = 2 * (depth - 2 * ONE_PLY) - (PvNode ? DEPTH_ZERO : depth / 2);
ss->skipEarlyPruning = true;
search<PvNode ? PV : NonPV, false>(pos, ss, alpha, beta, d / 2, true);
ss->skipEarlyPruning = false;
tte = TT.probe(posKey, ttHit);
ttMove = ttHit ? tte->move() : MOVE_NONE;
}
Se il nodo non è uno SplitNode, se il livello di profondità è superiore al minimo livello
impostato per uno split e se il thread chiamante è disponibile a lavorare su un nuovo
SplitPoint, viene chiamata la funzione split().
18
// Step 19. Check for splitting the search
if ( !SpNode
&& Threads.size() >= 2
&& depth >= Threads.minimumSplitDepth
&& ( !thisThread->activeSplitPoint
|| !thisThread->activeSplitPoint->allSlavesSearching)
&& thisThread->splitPointsSize < MAX_SPLITPOINTS_PER_THREAD)
{
assert(bestValue > -VALUE_INFINITE && bestValue < beta);
thisThread->split(pos, ss, alpha, beta, &bestValue, &bestMove,
depth, moveCount, &mp, NT, cutNode);
if (Signals.stop || thisThread->cutoff_occurred())
return VALUE_ZERO;
if (bestValue >= beta)
break;
}
2.1.3 Funzione qsearch<>()
Si tratta della funzione di quiescence search, che viene chiamata da search<>() quando la
profondità di ricerca rimanente è zero. Si occupa di effettuare una ricerca supplementare
per attenuare i problemi derivanti dal cosiddetto Horizon Effect: tale effetto deriva
dall'imposizione di una profondità massima di ricerca fissata (un "orizzonte") e consiste
nella tendenza del programma a risolvere eventuali problemi presenti nella posizione
corrente (ad esempio, un'inevitabile perdita di materiale) inserendo mosse che li ritardano,
spingendoli oltre l'orizzonte e considerandoli, in tal modo, eliminati. La funzione
qsearch<>() ha pertanto il compito di assicurarsi, approfondendo la ricerca e provando
tutte le mosse "violente", come ad esempio le catture di pezzi, che la posizione raggiunta
19
dopo l'orizzonte sia una posizione "calma".
2.2 Thread
La logica di gestione dei thread è contenuta nei file thread.h e thread.cpp del codice
sorgente. Nel file thread.h vengono definite le principali strutture dati utilizzate.
2.2.1 SplitPoint
La struttura SplitPoint mantiene informazioni condivise dai thread che effettuano ricerche
in parallelo al di sotto dello stesso punto di biforcazione. Viene popolata al momento dello
split. In particolare, essa contiene dati costanti e dati variabili, modificabili dai vari thread.
Tra i dati costanti, sono presenti un puntatore alla posizione corrente, un puntatore al
thread master dello SplitPoint, il valore della profondità a cui si trova il nodo, il tipo di
nodo, il valore di beta; sono inoltre presenti dei puntatori costanti a un oggetto condiviso
di tipo MovePicker, affinché i thread, chiamando la sua funzione next_move(), possano
generare una dopo l'altra tutte le possibili mosse dalla posizione corrente, e a un oggetto
condiviso di tipo SplitPoint, che corrisponde allo SplitPoint "genitore". Tra i dati
variabili, sono presenti un mutex, una maschera di bit per registrare tutti gli slave al lavoro
sullo SplitPoint, il valore di alfa, la mossa migliore finora trovata.
2.2.2 ThreadBase
La struttura astratta ThreadBase è la base della gerarchia dalla quale vengono derivate
tutte le classi thread specializzate. Contiene il metodo virtuale puro idle_loop() e i metodi
wait_for() e notify_one() per le operazioni rispettivamente di wait e signal sulla variabile
condition sleepCondition.
2.2.3 Thread
La struttura Thread è la principale specializzazione di ThreadBase e contiene tutte le
informazioni legate a un Thread, tra cui un array di SplitPoint, un puntatore alla
posizione attiva che si sta ricercando e delle tabelle hash relative alle strutture pedonali e
alle configurazioni di materiale presente sulla scacchiera già note. Le funzioni principali
20
sono idle_loop(), che è la routine eseguita dal Thread al momento della creazione, la
funzione split() e la funzione available_to(), che implementa l'Helpful Master Concept.
2.2.4 ThreadPool
La struttura ThreadPool è un vettore di Thread che contiene tutte le operazioni relative
alla loro inizializzazione, avvio e terminazione. La gestione dei thread si basa su un
oggetto globale Threads di tipo ThreadPool creato all'avvio del programma.
Nel file thread.cpp vengono definite le operazioni svolte da tali strutture dati, alla base
dell'implementazione della parallelizzazione. Tra le funzioni principali, vi sono split() e
available_to(); la funzione idle_loop() è invece definita nel file search.cpp.
2.2.5 Funzione split()
La funzione split() si occupa di distribuire il lavoro in un dato nodo tra diversi thread
disponibili. Il thread chiamante diventa il "master" dei thread a cui viene assegnato il
lavoro, i quali a loro volta vengono definiti "slaves".
Dopo aver creato e inizializzato un oggetto di tipo SplitPoint contenente tutti i dati che
devono essere trasferiti ai thread, si tenta di allocare uno o più slave chiamando la
funzione available_slave() sull'oggetto globale Threads di tipo ThreadPool: essa restituirà
un puntatore a un thread inattivo che può diventare slave del thread chiamante secondo
l'Helpful Master Concept, se esiste, oppure un puntatore nullo. Se il tentativo va a buon
fine, si passa lo SplitPoint agli slave, li si informa del lavoro assegnato loro (slave>searching = true) e li si risveglia, nel caso fossero in attesa sulla variabile condition
sleepCondition (slave->notify_one()). Questo fa sì che abbandonino subito i loro idle loop
e chiamino la funzione search<>().
SplitPoint& sp = splitPoints[splitPointsSize];
// ...inizializzazione di sp...
Threads.mutex.lock();
sp.mutex.lock();
Thread* slave;
21
while ((slave = Threads.available_slave(this)) != NULL)
{
sp.slavesMask.set(slave->idx);
slave->activeSplitPoint = &sp;
slave->searching = true; // Slave leaves idle_loop()
slave->notify_one(); // Could be sleeping
}
sp.mutex.unlock();
Threads.mutex.unlock();
Il master entra infine nell'idle loop per avviare la sua ricerca. Quando tutti gli slaves hanno
completato il loro lavoro, la funzione idle_loop() ritorna e, dopo aver aggiornato i
parametri del thread con i nuovi valori ottenuti, così anche la funzione split().
Thread::idle_loop();
Threads.mutex.lock();
sp.mutex.lock();
//...aggiornamento valori...
sp.mutex.unlock();
Threads.mutex.unlock();
2.2.6 Funzione idle_loop()
La funzione idle_loop(), definita nel file search.cpp, è la routine eseguita da ogni thread
dopo una chiamata a split() o dopo la creazione; il thread rimane “parcheggiato” in questo
ciclo quando non ha lavoro da fare. È l'implementazione del metodo virtuale puro definito
nella classe astratta ThreadBase, dalla quale deriva la classe Thread.
Se al thread è stato assegnato del lavoro (searching == true), esso avvia una ricerca: viene
chiamata la funzione template search<>(), passandole il tipo di nodo (PV, NonPV, Root)
dello SplitPoint a cui sta lavorando il thread (activeSplitPoint):
while (searching)
{
Threads.mutex.lock();
assert(activeSplitPoint);
22
SplitPoint* sp = activeSplitPoint;
Threads.mutex.unlock();
Stack stack[MAX_PLY+4], *ss = stack+2;
Position pos(*sp->pos, this);
std::memcpy(ss-2, sp->ss-2, 5 * sizeof(Stack));
ss->splitPoint = sp;
sp->mutex.lock();
assert(activePosition == NULL);
activePosition = &pos;
if (sp->nodeType == NonPV)
search<NonPV, true>(pos, ss, sp->alpha, sp->beta, sp->depth, sp->cutNode);
else if (sp->nodeType == PV)
search<PV, true>(pos, ss, sp->alpha, sp->beta, sp->depth, sp->cutNode);
else if (sp->nodeType == Root)
search<Root, true>(pos, ss, sp->alpha, sp->beta, sp->depth, sp->cutNode);
else
assert(false);
assert(searching);
searching = false;
activePosition = NULL;
sp->slavesMask.reset(idx);
sp->allSlavesSearching = false;
sp->nodes += pos.nodes_searched();
Terminata la ricerca, se il thread è l'ultimo degli slave a terminare il lavoro, viene
risvegliato il master per permettergli di uscire dall'idle loop, attraverso una signal sulla
variabile condition sleepCondition (masterThread→notify_one()):
if ( this != sp→masterThread && sp->slavesMask.none())
{
assert(!sp->masterThread->searching);
sp->masterThread->notify_one();
}
23
sp->mutex.unlock();
Infine, il thread fa un tentativo per "aiutare" nella ricerca di un altro SplitPoint i cui slave
non hanno ancora tutti terminato il lavoro; affinché il tentativo vada a buon fine, il thread
deve soddisfare il principio dell'Helpful Master Concept (available_to()):
if (Threads.size() > 2)
for (size_t i = 0; i < Threads.size(); ++i)
{
const int size = Threads[i]->splitPointsSize; // Local copy
sp = size ? &Threads[i]->splitPoints[size - 1] : NULL;
if ( sp && sp→allSlavesSearching && available_to(Threads[i]))
{
// Recheck the conditions under lock protection
Threads.mutex.lock();
sp->mutex.lock();
if ( sp→allSlavesSearching && available_to(Threads[i]))
{
sp->slavesMask.set(idx);
activeSplitPoint = sp;
searching = true;
}
sp->mutex.unlock();
Threads.mutex.unlock();
break; // Just a single attempt
}
}
Se il thread è il master dello SplitPoint e tutti gli slave hanno terminato il loro lavoro, il
thread esce dall'idle loop; se non tutti gli slave hanno terminato il loro lavoro oppure il
thread non ha lavoro da fare (searching == false), si pone in attesa su una variabile
condition (sleepCondition), in attesa di essere segnalato:
// Grab the lock to avoid races with Thread::notify_one()
mutex.lock();
// If we are master and all slaves have finished then exit idle_loop
24
if (this_sp && this_sp->slavesMask.none())
{
assert(!searching);
mutex.unlock();
break;
}
// If we are not searching, wait for a condition to be signaled instead of
// wasting CPU time polling for work.
if (!searching && !exit)
sleepCondition.wait(mutex);
mutex.unlock();
}
2.2.7 Funzione available_to()
La funzione available_to(Thread * master) controlla se un thread è disponibile ad aiutare
il thread master in uno SplitPoint. Ciò è possibile solo se il thread è inattivo e soddisfa
l'Helpful Master Concept.
Se il thread non è inattivo (searching == true), la funzione ritorna immediatamente valore
false. In caso contrario, la funzione ritorna valore true se il thread non sta lavorando su
alcuno SplitPoint (size == 0) oppure il master è uno slave dello SplitPoint in cima allo
stack:
if (searching)
return false;
const int size = splitPointsSize;
return !size || splitPoints[size – 1].slavesMask.test(master->idx);
2.3 Struttura del software
I principali moduli del software sono implementati attraverso i seguenti file:
 main.cpp: file contenente la funzione main() del programma, che si occupa
semplicemente di avviare i vari moduli del software;
25
 platform.h: file contenente definizioni e tipi di variabili dipendenti dalla piattaforma a
disposizione (Windows o Unix); in particolare sono qui definite tutte le macro per la
gestione di semafori e variabili condition;
 types.h: file contenente le definizioni dei più importanti tipi di variabile utilizzati
all'interno del software (ad esempio Move, Value, Piece, Square, File, Rank) e delle
funzioni che operano su di essi. Si ricorre spesso alle enumerazioni per definirli.
Vengono inoltre inizializzati alcuni flag di visibilità globale che segnalano la capacità
del processore su cui gira il programma di eseguire alcune particolari istruzioni
macchina;
 thread.h: vedi paragrafo 2.2;
 thread.cpp: vedi paragrafo 2.2;
 bitcount.h: file contenente diverse specializzazioni della funzione popcount<>(), che
ha lo scopo di contare quanti "1" sono presenti in una parola di bit. Le
specializzazioni dipendono dalla piattaforma e servono a migliorare l'efficienza; se il
processore supporta l'istruzione macchina POPCOUNT, viene usata la funzione
intrinseca (intrinsic) corrispondente del compilatore;
 bitbase.cpp: file contenente le strutture dati e le funzioni per implementare le bitbases
dei finali di Re contro Re e Pedone. Le bitbases sono delle tabelle di bit che
costituiscono dei database molto compatti per la memorizzazione di posizioni nei
finali di gioco; esse contengono informazioni essenziali sul risultato ogni posizione
conosciuta e, poiché possono essere interamente contenute nella memoria RAM,
servono a velocizzare la ricerca;
 bitboard.h: file contenente tutte le funzioni che operano sulle bitboard, che
includono l'overload degli operatori e funzioni intrinseche. Le bitboard sono il
principale strumento attraverso cui Stockfish rappresenta la scacchiera e consistono in
parole di 64 bit, ognuno dei quali corrisponde a una casa della scacchiera. È
tipicamente necessaria una bitboard per ogni tipo di pezzo (sia bianco sia nero), in cui
un "1" indica la presenza di un pezzo di un quel tipo sulla casa associata al bit alzato;
Stockfish fa comunque uso di numerose altre bitboard di utilità per rappresentare
26
particolari porzioni della scacchiera critiche in determinate posizioni di gioco;
 bitboard.cpp: file contenente le definizioni di tutte le bitboard usate dal programma e
le funzioni per inizializzarle. Tutte le linee di attacco di Alfiere, Torre e Regina sono
calcolati usando l'approccio delle Magic Bitboards;
 misc.h: file contenente dichiarazioni e definizioni di alcune funzioni e classi di utilità,
tra cui una HashTable e un generatore di numeri pseudo-casuali (PRNG);
 misc.cpp: file contenente le definizioni delle funzioni di utilità (principalmente di
debug) dichiarate in misc.h;
 material.h: file contenente la definizione della struttura Material::Entry, che contiene
informazioni su una particolare configurazione di materiale presente sulla scacchiera;
tale struttura costituisce il tipo dei record presenti nella HashTable Material::Table;
 material.cpp: file contenente i parametri corrispondenti ai contributi di ciascun pezzo
presente sulla scacchiera in relazione a tutti gli altri; tali parametri vengono utilizzati,
tramite un'apposita funzione, per calcolare il termine dovuto allo squilibrio di
materiale (material imbalance) nella valutazione di una posizione. È inoltre presente
la funzione probe() per ricavare o eventualmente inserire una configurazione di
materiale già nota (Entry) nella HashTable Material::Table;
 pawns.h: file contenente la definizione della struttura Pawns::Entry, che contiene
informazioni su una particolare struttura pedonale presente sulla scacchiera; tale
struttura costituisce il tipo dei record presenti nella HashTable Pawns::Table;
 pawns.cpp: file contenente i parametri corrispondenti ai contributi di ciascun pedone
presente sulla scacchiera; essi consistono in bonus o malus a seconda della condizione
del pedone (impedonato, isolato, arretrato, supportato, ecc.). Tali parametri vengono
utilizzati, tramite un'apposita funzione, per valutare una posizione con una data
struttura pedonale. È inoltre presente la funzione probe() per ricavare o eventualmente
inserire una struttura pedonale già nota (Entry) nella HashTable Pawns::Table;
 tt.h: file contenente le definizioni delle strutture dati che implementano la
Transposition Table (TT): TTEntry costituisce il tipo dei record presenti nella TT e
contiene informazioni su una posizione di gioco nota; TranspositionTable è la classe
27
che implementa la TT vera e propria ed è divisa in Cluster;
 tt.cpp: file contenente la definizione della Transposition Table globale TT e delle
principali funzioni che operano su di essa (resize(), clear(), probe());
 psqtab.h: file contenente il contributo di ciascun pezzo in relazione alla casa che
occupa, ai fini della valutazione di una posizione; tali contributi consistono in una
coppia di valori (uno per la fase di medio-gioco e uno per il finale) e sono assegnati
solo per il Bianco, mentre per il Nero si ottengono per simmetria;
 position.h: file contenente la definizione della classe Position e di gran parte dei suoi
metodi; tale classe contiene tutte le informazioni riguardanti lo stato della posizione
di gioco presente sulla scacchiera; i suoi metodi più importanti sono do_move() e
undo_move(). Sono inoltre definite anche le strutture di utilità CheckInfo e StateInfo;
 position.cpp: file contenente la definizione dei restanti metodi della classe Position;
oltre a do_move() e undo_move(), altre funzioni membro degne di particolare
considerazione sono legal() e pseudo_legal(), per verificare che una certa mossa sia
legale o pseudo-legale, e see(), per valutare il risultato di una sequenza di catture;
 movegen.h: file contenente la dichiarazione della funzione template generate<>(),
usata per generare tutte le mosse pseudo-legali del tipo selezionato in una data
posizione, e una struttura MoveList che le fa da wrapper;
 movegen.cpp: file contenente le definizioni delle diverse specializzazioni di
generate<>() e di tutte le altre funzioni helper utilizzate per generare una lista di
possibili mosse in una data posizione;
 movepick.h: file contenente le definizioni della struttura Stats, usata per memorizzare
statistiche su una mossa al fine di ottimizzare la ricerca, e della classe MovePicker,
usata per estrarre una mossa pseudo-legale alla volta da una certa posizione, in un
ordine tale da tentare di migliorare la ricerca alfa-beta;
 movepick.cpp: file contenente le definizioni dei costruttori e dei metodi della classe
MovePicker; il più importante tra questi è next_move(), che restituisce la mossa
migliore da una lista di mosse generate per la posizione corrente, finché non si
esauriscono;
28
 timeman.h: file contenente la definizione della classe TimeManager, utilizzata per
calcolare il tempo migliore da dedicare alla ricerca, in base al tempo massimo a
disposizione e ad altri parametri;
 timeman.cpp: file contenente la definizione del metodo init() di TimeManager, che
viene chiamato all'inizio della ricerca e calcola il tempo da poter usare in base ai
vincoli passati come parametri, come ad esempio il controllo di tempo utilizzato;
 search.h: vedi paragrafo 2.1;
 search.cpp: vedi paragrafo 2.1;
 evaluate.h: file contenente la dichiarazione delle principali funzioni relative alla
valutazione statica di una posizione: init(), evaluate(), trace();
 evaluate.cpp: file contenente la definizione di tutti i parametri e le funzioni usate per
valutare staticamente una posizione di gioco; la funzione principale è evaluate(), che
restituisce un valore associato alla posizione; trace() ha lo stesso effetto, ma produce
stringhe usate in fase di debugging;
 endgame.h: file contenente la definizione di tutti i tipi di finali supportati
(EndgameType) e le principali strutture dati usate per chiamare le relative funzioni di
valutazione e di "riscalamento";
 endgame.cpp: file contenente le definizioni di tutte le funzioni di valutazione e di
riscalamento, specializzate per ciascun finale supportato attraverso il meccanismo dei
template;
 uci.h, uci.cpp, ucioption.cpp: file di supporto al protocollo di interfaccia UCI
(Universal Chess Interface);
 benchmark.cpp: file contenente una funzione per effettuare un benchmark del
software basato sull'analisi di alcune posizioni di gioco: è possibile assegnare diversi
parametri (dimensione della TT, numero di thread da utilizzare, posizioni da
analizzare, limiti di tempo o di profondità di ricerca da rispettare);
 Directory syzygy: directory contenente file necessari al supporto delle Syzygy Bases,
un database di finali a sei pezzi sviluppato da Ronald de Man.
29
Capitolo 3: Documentazione di Stockfish 6
L'allegato all'elaborato contiene il codice sorgente di Stockfish 6 e l'avvio di un'opera di
documentazione dello stesso, generata con l'ausilio del tool Doxygen e corredata di grafi
disegnati grazie al tool Graphviz. Si compone di tre directory principali:

stockfish-6-src: directory contenente i file del codice sorgente di Stockfish 6 (vedi
paragrafo 2.3), opportunamente commentati secondo la sintassi prevista da
Doxygen; i commenti facenti parte della documentazione sono scritti in lingua
italiana e preceduti dal marker “/*!” (virgolette escluse);

docs: directory contenente tutti i riferimenti e le fonti esterne al codice da cui sono
state reperite nozioni e concetti teorici riguardanti algoritmi, strutture dati e
tecniche utilizzate nella programmazione di giochi a somma zero e, in particolare,
di software scacchistici;

html: directory contenente la documentazione vera e propria, sotto forma di file
HTML generati con l'ausilio di Doxygen e navigabili tramite browser. Sono
presenti un elenco di tutti i file, di tutti i namespace e di tutte le classi del progetto;
per ogni classe è presente un elenco di tutti i suoi attributi e metodi, sia pubblici che
privati, accompagnati da grafi di chiamata e riferimenti alla porzione di codice in
cui sono definiti. Grazie ai collegamenti tra le diverse pagine della documentazione
e a una funzione di ricerca, è possibile spostarsi facilmente tra un modulo e un
altro. Nell'immagine seguente si mostra l'aspetto di una tipica funzione
documentata (search<>()).
30
31
Conclusioni
L'elaborato ha permesso di approfondire i più efficaci metodi di esplorazione dell'albero di
un gioco a somma zero, a partire dagli algoritmi sequenziali di base minimax e potatura
alfa-beta fino alle loro varie ottimizzazioni; si è osservato, in particolare, che
l'ordinamento delle mosse da ricercare in ogni posizione riveste una fondamentale
importanza ai fini dell'efficienza e dei guadagni prestazionali. È stata inoltre studiata la
possibilità di parallelizzare la ricerca tra più processori, esponendo due diversi approcci
per metterla in pratica.
L'analisi del motore scacchistico Stockfish 6 ha consentito di esaminare l'applicazione dei
concetti studiati in un progetto software reale, con ispezione diretta del codice sorgente;
particolare attenzione è stata posta ai moduli dedicati all'implementazione degli algoritmi
di ricerca e di parallelizzazione del lavoro tra thread. È stata inoltre avviata un'opera di
documentazione del software, contenuta nell'allegato, che ha permesso di rendere il
progetto più leggibile, comprensibile e facilmente navigabile.
32
Bibliografia
[1]
Gustavo Cevolani, Teoria dei giochi, APhEx - Portale italiano di Filosofia Analitica,
pp. 105-118, n°10/giugno 2014;
[2]
Valavan Manohararajah, Parallel Alpha-Beta Search on Shared Memory
Multiprocessors, pp. 1-27, 2001;
[3]
Rainer Feldmann, Game Tree Search on Massively Parallel Systems, pp. 13-29 e 74-
76, agosto 1993;
[4]
Minimax search and Alpha-Beta Pruning,
http://www.cs.cornell.edu/courses/cs312/2002sp/lectures/rec21.htm, 18/02/16;
[5]
Minimax, http://it.wikipedia.org/wiki/Minimax, 18/02/16;
[6]
Negamax, http://chessprogramming.wikispaces.com/Negamax, 18/02/16;
[7]
Potatura alfa-beta, http://it.wikipedia.org/wiki/Potatura_alfa-beta, 18/02/16;
[8]
Null Window, http://chessprogramming.wikispaces.com/Null+Window, 18/02/16;
[9]
Principal Variation Search,
http://chessprogramming.wikispaces.com/Principal+Variation+Search, 18/02/16;
[10] Principal variation search, http://en.wikipedia.org/wiki/Principal_variation_search,
18/02/16;
[11] Stockfish, http://chessprogramming.wikispaces.com/Stockfish, 18/02/16;
[12] Razoring, http://chessprogramming.wikispaces.com/Razoring, 18/02/16;
[13] Futility Pruning, http://chessprogramming.wikispaces.com/Futility+Pruning,
18/02/16;
[14] Internal Iterative Deepening,
http://chessprogramming.wikispaces.com/Internal+Iterative+Deepening, 18/02/16;
[15] Quiescence Search, http://chessprogramming.wikispaces.com/Quiescence+Search,
18/02/16;
[16] Horizon Effect, http://chessprogramming.wikispaces.com/Horizon+Effect, 18/02/16.
33