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