Achtergrond
Ik ben momenteel bezig met het optimaliseren van de volgorde van alle resources op een DVD om zo de laadtijden van een specifieke game (waar ik verder weinig over kan zeggen) te verkleinen. Hiervoor heb ik een genetisch algoritme ontwikkeld dat, gegeven alle resources en een resource access log van een volledige playthrough van de game, een zo efficient mogelijke indeling probeert te zoeken. Dit algoritme werkt verder prima en is al in productie. Het nadeel is echter dat het een aantal dagen moet draaien op een Core i7 met 8 threads voordat het een beetje een acceptabele oplossing aandraagt (na ongeveer ~30 miljoen generaties met een snelheid van ~160 generaties per seconde). Ik hoop dit proces te kunnen versnellen door gebruik te maken van een GPGPU implementatie. Gegeven de aard van hardware- en software architectuur op hedendaagse PC's is het helaas van belang dat de gehele implementatie naar de GPU verplaatst wordt - de latency waarbij de GPU-output weer op de CPU uit te lezen is is simpelweg te hoog (3 tot 5ms) om voor een hybride CPU/GPU oplossing te gaan.
Op zich heb ik elke stap van het genetische algoritme nu geïmplementeerd op de GPU (middels DirectX11 compute shaders): het sorteren van de oplossingen op basis van fitness level, genetische cross over, mutatie en fitness evaluation.
Echter, die laatste stap krijg ik gewoonweg niet snel genoeg. Ik heb al tal van aanpakken geprobeerd. Sommigen zijn nauwelijks sneller dan de CPU, en de rest is vele malen langzamer.
Probleem
Goed, die fitness evaluatie is feitelijk gewoon een simulatie van het lezen van alle resources, gegeven de total volgorde van die resources op disc. Zo'n simulatie bestaat uit een aantal "batches". Elke batch bestaat uit een set resources die voor die batch ingelezen moet worden. Een resource kan in meerdere batches voorkomen. De game weet waar elke resource op disc staat en zal zelf de moeite nemen om ze te sorteren voor het inlezen, zodat de kop altijd van voor naar achter over de disc verplaatst wordt en er zo min mogelijk wordt geseekt. De volgorde van resources binnen een batch is dus niet van belang voor het berekenen van de fitness. Hoe ver de resources op disc uit elkaar staan natuurlijk wel. Als fitness voor een oplossing bereken ik de totale seek tijd die nodig voor alle batches.
Wellicht is het handig om even te beginnen met een voorbeeld. Stel er zijn 8 even grote resources (1 t/m 8), en 3 batches (A, B en C). De batches bestaan uit:
A: { 1, 2, 6, 8 }
B: { 2, 3, 4, 5 }
C: { 1, 7, 8 }
En we definieren de kosten van een seek als simpelweg het aantal tussenliggende resources.
Voor de volgorde: { 1, 2, 3, 4, 5, 6, 7, 8 }
Dan kost:
A: 0 (van 1 naar 2) + 3 (van 2 naar 6) + 1 (van 6 naar 8) = 4
B: 0 + 0 + 0 = 0
C: 5 + 0 = 5
Totale kosten: 4 + 0 + 5 = 9
Voor de volgorde: { 1, 3, 5, 7, 2, 4, 8, 6 }
A: 3 (van 1 naar 2) + 1 (van 2 naar 8) + 0 (van 8 naar 6) = 4
B: 0 + 2 + 0 = 2
C: 2 + 3 = 5
Totale kosten: 4 + 2 + 5 = 11
Voor de volgorde: { 3, 4, 5, 2, 1, 6, 8, 7 }
A: 0 + 0 + 0 = 0
B: 0 + 0 + 0 = 0
C: 1 + 0 = 1
Totale kosten: 0 + 0 + 1 = 1
De laatste oplossing is in wat deze gevallen betreft het efficientst.
Bij bovenstaande berekeningen ben ik steeds uitgegaan van de batches. Zou je het probleem serieel willen tacklen, dan is dat niet hele efficiente benadering van het probleem. Omdat de volgorde van de resources op disc niet overeenkomt met de volgorde van resources in een batch, moet je eerst per batch alle resources sorteren op basis van de gevonden oplossing, zodat je daarna per batch van voor naar achter kunt lopen door de resources in de batch. In pseudocode:
Oftewel, een verwachte looptijd van O(b∙r∙log r), met b het aantal batches en r het gemiddede aantal resources per batch.
Een efficientere benadering is door lineair door de resources op disc heen te lopen en voor elke batch bij te houden waar de laatste leespositie was.
Verwachte looptijd O(r∙b) met r het aantal resources en b het gemiddelde aantal batches per resource. Dit is de methode die ik ook voor de CPU implementatie gebruik.
Praktijk
In de realiteit hebben we het over ~5000 resources, ~1000 batches, 1 tot 400 resources per batch en waarbij elke resource in 1 tot 100 batches voorkomt. Deze resources zijn in werkelijkheid overigens groepen van resources die gegarandeerd altijd tegelijkertijd in worden gelezen. Zo zou je in bovenstaand voorbeeld de resources { 3, 4, 5 } en { 1, 8 } bij elkaar kunnen stoppen, omdat die nooit los van elkaar worden ingelezen. Een dergelijke reductie is bij die 5000 "resources" dus niet meer mogelijk (in werkelijkheid zijn het 100.000 resources).
Verder bestaat de populatie van m'n GA uit 100 oplossingen die allemaal moeten worden doorgerekend. Beide bovenstaande oplossingen krijg ik niet snel op de GPU.
De sorteer-oplossing is belabberd, een parallel bitonic sort van 100 (populatie) * 1000 (batches) arrays van 400 elementen (resources per batch) is gewoon niet echt snel te doen.
De lineaire oplossing is lastig te parallelliseren. Je kan niet in een shader even doodleuk 5000 elementen aflopen. Je kunt het wel opdelen en iedere thread een deel laten doen, waarna je daarna nog even de losse eindjes aan elkaar knoopt. Dit was de efficientste oplossing die ik tot nu toe gevonden heb, maar is langzamer dan de CPU.
Een andere oplossing die ik vond was door een oplossing te converteren naar een array van 5000 kolommen (de resources) bij 1000 rijen (de batches) waarbij elk element bestaat uit 2 ints: een from-read en een to-read. Daarna werden de kolommen recursief gereduceerd zodat je een 1x1000 matrix overhoudt met kosten per batch, die je vervolgens kunt opsommen om op het totaal te komen. Op zich aardig, maar gezien het feit dat je dat 100 keer moet doen (voor elke oplossing in de populatie) wordt de GPU daar ook niet echt vrolijk van.
Een van de algoritmes zou daarentegen wel weer prima werken als het probleemgebied op een of andere manier verkleind kan worden. Zo had ik bedacht om batches kleiner dan 4 resources eruit te filteren en die op de sort-methode te doen (max 4 elementen sorteren kan natuurlijk spotgoedkoop). Het werd er echter totaal niet sneller op - voornamelijk omdat elke GPU thread de prijs betaald voor elke branch die de andere GPU thread neemt op dezelfde execution unit. Oftewel, als 1 thread een for-loop doet over 100 batches in een resources, dan doen 31 andere threads dat ook ookal hoeven zij maar 1 batch per resource af te handelen.
Heeft iemand anders eventueel nog een geniale oplossing?
Ik ben momenteel bezig met het optimaliseren van de volgorde van alle resources op een DVD om zo de laadtijden van een specifieke game (waar ik verder weinig over kan zeggen) te verkleinen. Hiervoor heb ik een genetisch algoritme ontwikkeld dat, gegeven alle resources en een resource access log van een volledige playthrough van de game, een zo efficient mogelijke indeling probeert te zoeken. Dit algoritme werkt verder prima en is al in productie. Het nadeel is echter dat het een aantal dagen moet draaien op een Core i7 met 8 threads voordat het een beetje een acceptabele oplossing aandraagt (na ongeveer ~30 miljoen generaties met een snelheid van ~160 generaties per seconde). Ik hoop dit proces te kunnen versnellen door gebruik te maken van een GPGPU implementatie. Gegeven de aard van hardware- en software architectuur op hedendaagse PC's is het helaas van belang dat de gehele implementatie naar de GPU verplaatst wordt - de latency waarbij de GPU-output weer op de CPU uit te lezen is is simpelweg te hoog (3 tot 5ms) om voor een hybride CPU/GPU oplossing te gaan.
Op zich heb ik elke stap van het genetische algoritme nu geïmplementeerd op de GPU (middels DirectX11 compute shaders): het sorteren van de oplossingen op basis van fitness level, genetische cross over, mutatie en fitness evaluation.
Echter, die laatste stap krijg ik gewoonweg niet snel genoeg. Ik heb al tal van aanpakken geprobeerd. Sommigen zijn nauwelijks sneller dan de CPU, en de rest is vele malen langzamer.
Probleem
Goed, die fitness evaluatie is feitelijk gewoon een simulatie van het lezen van alle resources, gegeven de total volgorde van die resources op disc. Zo'n simulatie bestaat uit een aantal "batches". Elke batch bestaat uit een set resources die voor die batch ingelezen moet worden. Een resource kan in meerdere batches voorkomen. De game weet waar elke resource op disc staat en zal zelf de moeite nemen om ze te sorteren voor het inlezen, zodat de kop altijd van voor naar achter over de disc verplaatst wordt en er zo min mogelijk wordt geseekt. De volgorde van resources binnen een batch is dus niet van belang voor het berekenen van de fitness. Hoe ver de resources op disc uit elkaar staan natuurlijk wel. Als fitness voor een oplossing bereken ik de totale seek tijd die nodig voor alle batches.
Wellicht is het handig om even te beginnen met een voorbeeld. Stel er zijn 8 even grote resources (1 t/m 8), en 3 batches (A, B en C). De batches bestaan uit:
A: { 1, 2, 6, 8 }
B: { 2, 3, 4, 5 }
C: { 1, 7, 8 }
En we definieren de kosten van een seek als simpelweg het aantal tussenliggende resources.
Voor de volgorde: { 1, 2, 3, 4, 5, 6, 7, 8 }
Dan kost:
A: 0 (van 1 naar 2) + 3 (van 2 naar 6) + 1 (van 6 naar 8) = 4
B: 0 + 0 + 0 = 0
C: 5 + 0 = 5
Totale kosten: 4 + 0 + 5 = 9
Voor de volgorde: { 1, 3, 5, 7, 2, 4, 8, 6 }
A: 3 (van 1 naar 2) + 1 (van 2 naar 8) + 0 (van 8 naar 6) = 4
B: 0 + 2 + 0 = 2
C: 2 + 3 = 5
Totale kosten: 4 + 2 + 5 = 11
Voor de volgorde: { 3, 4, 5, 2, 1, 6, 8, 7 }
A: 0 + 0 + 0 = 0
B: 0 + 0 + 0 = 0
C: 1 + 0 = 1
Totale kosten: 0 + 0 + 1 = 1
De laatste oplossing is in wat deze gevallen betreft het efficientst.
Bij bovenstaande berekeningen ben ik steeds uitgegaan van de batches. Zou je het probleem serieel willen tacklen, dan is dat niet hele efficiente benadering van het probleem. Omdat de volgorde van de resources op disc niet overeenkomt met de volgorde van resources in een batch, moet je eerst per batch alle resources sorteren op basis van de gevonden oplossing, zodat je daarna per batch van voor naar achter kunt lopen door de resources in de batch. In pseudocode:
code:
1
2
3
4
5
6
| foreach (b in batches) { resources = sort b.resources using global resource order; foreach (adjacent pair p in resources) totalcost += cost(p.fromoffset, p.tooffset); } |
Oftewel, een verwachte looptijd van O(b∙r∙log r), met b het aantal batches en r het gemiddede aantal resources per batch.
Een efficientere benadering is door lineair door de resources op disc heen te lopen en voor elke batch bij te houden waar de laatste leespositie was.
code:
1
2
3
4
5
6
7
8
9
10
| int lastread[batches.size] = { 0 }; foreach (r in resources) { foreach (b in r.batches) { if (lastread[b]) totalcost += cost(lastread[b], r.offset); lastread[b] = r.offset; } } |
Verwachte looptijd O(r∙b) met r het aantal resources en b het gemiddelde aantal batches per resource. Dit is de methode die ik ook voor de CPU implementatie gebruik.
Praktijk
In de realiteit hebben we het over ~5000 resources, ~1000 batches, 1 tot 400 resources per batch en waarbij elke resource in 1 tot 100 batches voorkomt. Deze resources zijn in werkelijkheid overigens groepen van resources die gegarandeerd altijd tegelijkertijd in worden gelezen. Zo zou je in bovenstaand voorbeeld de resources { 3, 4, 5 } en { 1, 8 } bij elkaar kunnen stoppen, omdat die nooit los van elkaar worden ingelezen. Een dergelijke reductie is bij die 5000 "resources" dus niet meer mogelijk (in werkelijkheid zijn het 100.000 resources).
Verder bestaat de populatie van m'n GA uit 100 oplossingen die allemaal moeten worden doorgerekend. Beide bovenstaande oplossingen krijg ik niet snel op de GPU.
De sorteer-oplossing is belabberd, een parallel bitonic sort van 100 (populatie) * 1000 (batches) arrays van 400 elementen (resources per batch) is gewoon niet echt snel te doen.
De lineaire oplossing is lastig te parallelliseren. Je kan niet in een shader even doodleuk 5000 elementen aflopen. Je kunt het wel opdelen en iedere thread een deel laten doen, waarna je daarna nog even de losse eindjes aan elkaar knoopt. Dit was de efficientste oplossing die ik tot nu toe gevonden heb, maar is langzamer dan de CPU.
Een andere oplossing die ik vond was door een oplossing te converteren naar een array van 5000 kolommen (de resources) bij 1000 rijen (de batches) waarbij elk element bestaat uit 2 ints: een from-read en een to-read. Daarna werden de kolommen recursief gereduceerd zodat je een 1x1000 matrix overhoudt met kosten per batch, die je vervolgens kunt opsommen om op het totaal te komen. Op zich aardig, maar gezien het feit dat je dat 100 keer moet doen (voor elke oplossing in de populatie) wordt de GPU daar ook niet echt vrolijk van.
Een van de algoritmes zou daarentegen wel weer prima werken als het probleemgebied op een of andere manier verkleind kan worden. Zo had ik bedacht om batches kleiner dan 4 resources eruit te filteren en die op de sort-methode te doen (max 4 elementen sorteren kan natuurlijk spotgoedkoop). Het werd er echter totaal niet sneller op - voornamelijk omdat elke GPU thread de prijs betaald voor elke branch die de andere GPU thread neemt op dezelfde execution unit. Oftewel, als 1 thread een for-loop doet over 100 batches in een resources, dan doen 31 andere threads dat ook ookal hoeven zij maar 1 batch per resource af te handelen.
Heeft iemand anders eventueel nog een geniale oplossing?
Give a man a game and he'll have fun for a day. Teach a man to make games and he'll never have fun again.