Pourquoi l’ajout de la concurrence réduit-il ce code golang?

J’ai un peu de code Go que j’ai bricolé pour répondre à un peu de curiosité concernant un jeu vidéo que joue mon beau-frère.

Essentiellement, le code ci-dessous simule les interactions avec les monstres dans le jeu et combien de fois il peut s’attendre à ce qu’ils déposent des objects lors de leur défaite. Le problème que j’ai est que je m’attendrais à ce qu’un morceau de code comme celui-ci soit parfait pour la parallélisation, mais quand j’ajoute en simultané le temps nécessaire pour faire toutes les simulations a tendance à ralentir de 4 à 6 fois sans concurrence.

Pour mieux comprendre le fonctionnement du code, j’ai trois fonctions principales: La fonction d’interaction qui est une simple interaction entre le joueur et un monstre. Il renvoie 1 si le monstre laisse tomber un object et 0 sinon. La fonction de simulation exécute plusieurs interactions et renvoie une tranche de résultats d’interaction (c.-à-d. 1 et 0 représentant les interactions réussies / non réussies). Enfin, il existe la fonction de test qui exécute un ensemble de simulations et renvoie une tranche de résultats de simulation correspondant au nombre total d’interactions ayant abouti à un élément supprimé. C’est la dernière fonction que je tente d’exécuter en parallèle.

Maintenant, je pourrais comprendre pourquoi le code ralentirait si je créais une goroutine pour chaque test que je veux exécuter. En supposant que je lance 100 tests, le changement de contexte entre chacune des goroutines sur les 4 processeurs de mon MacBook Air supprime les performances, mais je ne crée que autant de goroutines que de processeurs et divise le nombre de tests entre les goroutines. Je m’attendrais à ce que cela accélère les performances du code puisque je lance chacun de mes tests en parallèle, mais, bien sûr, le ralentissement est important.

J’aimerais savoir pourquoi cela se produit, alors toute aide serait grandement appréciée.

Voici le code normal sans les routines:

package main import ( "fmt" "math/rand" "time" ) const ( NUMBER_OF_SIMULATIONS = 1000 NUMBER_OF_INTERACTIONS = 1000000 DROP_RATE = 0.0003 ) /** * Simulates a single interaction with a monster * * Returns 1 if the monster dropped an item and 0 otherwise */ func interaction() int { if rand.Float64() <= DROP_RATE { return 1 } return 0 } /** * Runs several interactions and retuns a slice representing the results */ func simulation(n int) []int { interactions := make([]int, n) for i := range interactions { interactions[i] = interaction() } return interactions } /** * Runs several simulations and returns the results */ func test(n int) []int { simulations := make([]int, n) for i := range simulations { successes := 0 for _, v := range simulation(NUMBER_OF_INTERACTIONS) { successes += v } simulations[i] = successes } return simulations } func main() { rand.Seed(time.Now().UnixNano()) fmt.Println("Successful interactions: ", test(NUMBER_OF_SIMULATIONS)) } 

Et, voici le code concurrent avec les goroutines:

 package main import ( "fmt" "math/rand" "time" "runtime" ) const ( NUMBER_OF_SIMULATIONS = 1000 NUMBER_OF_INTERACTIONS = 1000000 DROP_RATE = 0.0003 ) /** * Simulates a single interaction with a monster * * Returns 1 if the monster dropped an item and 0 otherwise */ func interaction() int { if rand.Float64() <= DROP_RATE { return 1 } return 0 } /** * Runs several interactions and retuns a slice representing the results */ func simulation(n int) []int { interactions := make([]int, n) for i := range interactions { interactions[i] = interaction() } return interactions } /** * Runs several simulations and returns the results */ func test(n int, c chan []int) { simulations := make([]int, n) for i := range simulations { for _, v := range simulation(NUMBER_OF_INTERACTIONS) { simulations[i] += v } } c <- simulations } func main() { rand.Seed(time.Now().UnixNano()) nCPU := runtime.NumCPU() runtime.GOMAXPROCS(nCPU) fmt.Println("Number of CPUs: ", nCPU) tests := make([]chan []int, nCPU) for i := range tests { c := make(chan []int) go test(NUMBER_OF_SIMULATIONS/nCPU, c) tests[i] = c } // Concatentate the test results results := make([]int, NUMBER_OF_SIMULATIONS) for i, c := range tests { start := (NUMBER_OF_SIMULATIONS/nCPU) * i stop := (NUMBER_OF_SIMULATIONS/nCPU) * (i+1) copy(results[start:stop], <-c) } fmt.Println("Successful interactions: ", results) } 

MISE À JOUR (01/12/13 18:05)

J’ai ajouté une nouvelle version du code concurrent ci-dessous qui crée une nouvelle instance de Rand pour chaque goroutine selon la suggestion du système ci-dessous. Je constate maintenant une très légère accélération par rapport à la version série du code (environ 15 à 20% de réduction du temps total). J’aimerais savoir pourquoi je ne vois pas quelque chose de plus proche d’une réduction de 75% du temps depuis que je répartis la charge de travail sur les 4 cœurs de mon MBA. Quelqu’un at-il d’autres suggestions qui pourraient aider?

 package main import ( "fmt" "math/rand" "time" "runtime" ) const ( NUMBER_OF_SIMULATIONS = 1000 NUMBER_OF_INTERACTIONS = 1000000 DROP_RATE = 0.0003 ) /** * Simulates a single interaction with a monster * * Returns 1 if the monster dropped an item and 0 otherwise */ func interaction(generator *rand.Rand) int { if generator.Float64() <= DROP_RATE { return 1 } return 0 } /** * Runs several interactions and retuns a slice representing the results */ func simulation(n int, generator *rand.Rand) []int { interactions := make([]int, n) for i := range interactions { interactions[i] = interaction(generator) } return interactions } /** * Runs several simulations and returns the results */ func test(n int, c chan []int) { source := rand.NewSource(time.Now().UnixNano()) generator := rand.New(source) simulations := make([]int, n) for i := range simulations { for _, v := range simulation(NUMBER_OF_INTERACTIONS, generator) { simulations[i] += v } } c <- simulations } func main() { rand.Seed(time.Now().UnixNano()) nCPU := runtime.NumCPU() runtime.GOMAXPROCS(nCPU) fmt.Println("Number of CPUs: ", nCPU) tests := make([]chan []int, nCPU) for i := range tests { c := make(chan []int) go test(NUMBER_OF_SIMULATIONS/nCPU, c) tests[i] = c } // Concatentate the test results results := make([]int, NUMBER_OF_SIMULATIONS) for i, c := range tests { start := (NUMBER_OF_SIMULATIONS/nCPU) * i stop := (NUMBER_OF_SIMULATIONS/nCPU) * (i+1) copy(results[start:stop], <-c) } fmt.Println("Successful interactions: ", results) } 

MISE À JOUR (13/01/13 17:58)

Merci à tous pour l’aide à résoudre mon problème. J’ai finalement obtenu la réponse que je cherchais et j’ai donc pensé que je résumerais ici pour quiconque aurait le même problème.

J’avais essentiellement deux problèmes principaux: premièrement, même si mon code était trop parallèle , il fonctionnait plus lentement quand je le séparais entre les processeurs disponibles, et deuxièmement, la solution ouvrait un autre problème: mon code série fonctionnait deux fois plus. ralentir comme le code concurrent s’exécutant sur un seul processeur, que vous vous attendez à être à peu près le même. Dans les deux cas, le problème était la fonction de générateur de nombres aléatoires rand.Float64 . Fondamentalement, il s’agit d’une fonction pratique fournie par le package rand . Dans ce package, une instance globale de la structure Rand est créée et utilisée par chacune des fonctions pratiques. Cette instance de Rand globale est associée à un verrou mutex. Comme j’utilisais cette fonction pratique, je n’étais pas vraiment capable de paralléliser mon code car chacune des goroutines devait s’aligner pour accéder à l’instance globale de Rand . La solution (comme le suggère le système ci-dessous) consiste à créer une instance distincte de la structure Rand pour chaque goroutine. Cela a résolu le premier problème mais a créé le second.

Le deuxième problème était que mon code concurrent non parallèle (c.-à-d. Mon code concurrent fonctionnant avec un seul processeur) fonctionnait deux fois plus vite que le code séquentiel. La raison en était que, même si je ne travaillais qu’avec un seul processeur et un seul goroutine, cette goroutine avait sa propre instance de la structure Rand que j’avais créée, et je l’avais créée sans le verrou mutex. Le code séquentiel utilisait toujours la fonction de commodité rand.Float64 qui utilisait l’instance Rand protégée par un mutex global. Le coût d’acquisition de ce verrou entraînait une double ralentissement du code séquentiel.

Donc, la morale de l’histoire est que, chaque fois que la performance est importante, assurez-vous de créer une instance de la structure Rand et d’appeler la fonction dont vous avez besoin plutôt que d’utiliser les fonctions pratiques fournies par le package.

Le problème semble provenir de votre utilisation de rand.Float64() , qui utilise un object global partagé avec un verrou Mutex.

Au lieu de cela, si vous créez un rand.New() distinct pour chaque CPU, transmettez-le aux interactions() , et utilisez-le pour créer le Float64() , il y a une amélioration considérable.


Mise à jour pour afficher les modifications apscopes au nouveau code exemple dans la question qui utilise désormais rand.New()

La fonction test() été modifiée pour utiliser un canal donné ou retourner le résultat.

 func test(n int, c chan []int) []int { source := rand.NewSource(time.Now().UnixNano()) generator := rand.New(source) simulations := make([]int, n) for i := range simulations { for _, v := range simulation(NUMBER_OF_INTERACTIONS, generator) { simulations[i] += v } } if c == nil { return simulations } c <- simulations return nil } 

La fonction main() été mise à jour pour exécuter les deux tests et générer le résultat temporisé.

 func main() { rand.Seed(time.Now().UnixNano()) nCPU := runtime.NumCPU() runtime.GOMAXPROCS(nCPU) fmt.Println("Number of CPUs: ", nCPU) start := time.Now() fmt.Println("Successful interactions: ", len(test(NUMBER_OF_SIMULATIONS, nil))) fmt.Println(time.Since(start)) start = time.Now() tests := make([]chan []int, nCPU) for i := range tests { c := make(chan []int) go test(NUMBER_OF_SIMULATIONS/nCPU, c) tests[i] = c } // Concatentate the test results results := make([]int, NUMBER_OF_SIMULATIONS) for i, c := range tests { start := (NUMBER_OF_SIMULATIONS/nCPU) * i stop := (NUMBER_OF_SIMULATIONS/nCPU) * (i+1) copy(results[start:stop], <-c) } fmt.Println("Successful interactions: ", len(results)) fmt.Println(time.Since(start)) } 

La sortie est je reçu:

 > Nombre de CPU: 2 
 >
 > Interactions réussies: 1000 
 > 1m20.39959s
 >
 > Interactions réussies: 1000
 > 41.392299s

Tester votre code sur mon ordinateur portable Linux i4 core i7

Voici un tableur Google

Capture d'écran de Google Spreadsheet

Cela montre que sous Linux au moins, la mise à l’échelle est presque linéaire par cœur.

Je pense qu’il peut y avoir deux raisons pour lesquelles vous ne voyez pas cela.

La première est que votre macbook air ne dispose que de 2 cœurs réels. Il a 4 hyperthreads bien que ce soit pourquoi il rapporte 4 comme cpus maximum. Un hyperthread ne donne généralement que 15% de performances supplémentaires sur un seul cœur plutôt que 100%. Tenez-vous donc à l’parsing comparative de 1 ou 2 processeurs uniquement sur le macbook air!

L’autre raison pourrait être la performance du thread OS X par rapport à Linux. Ils utilisent des modèles de thread différents qui peuvent affecter les performances.

Votre code échantillonne une variable aléatoire binomiale, B (N, p) où N est le nombre d’essais (ici 1M), et p la probabilité d’un essai individuel réussi (ici 0.0003).

Une façon de faire est de créer une table T de probabilités cumulatives, où T [i] contient la probabilité que le nombre total d’essais soit inférieur ou égal à i. Pour produire ensuite un échantillon, vous pouvez choisir une variable aléatoire uniforme (via rand.Float64) et rechercher le premier index de la table contenant une probabilité supérieure ou égale à celle-ci.

C’est un peu plus compliqué ici parce que vous avez un très gros N et un assez petit p, donc si vous essayez de construire la table, vous rencontrerez des problèmes avec de très petits nombres et une précision arithmétique. Mais vous pouvez construire une table plus petite (disons 1000 grandes) et l’échantillonner 1000 fois pour obtenir votre million d’essais.

Voici un code qui fait tout cela. Ce n’est pas trop élégant (1000 est codé en dur), mais il génère 1000 simulations en moins d’une seconde sur mon ancien ordinateur portable. Il est facile d’optimiser davantage, par exemple en retirant la construction de BinomialSampler de la boucle, ou en utilisant une recherche binary plutôt qu’un balayage linéaire pour trouver l’index de la table.

 package main import ( "fmt" "math" "math/rand" ) type BinomialSampler []float64 func (bs BinomialSampler) Sample() int { r := rand.Float64() for i := 0; i < len(bs); i++ { if bs[i] >= r { return i } } return len(bs) } func NewBinomialSampler(N int, p float64) BinomialSampler { r := BinomialSampler(make([]float64, N+1)) T := 0.0 choice := 1.0 for i := 0; i <= N; i++ { T += choice * math.Pow(p, float64(i)) * math.Pow(1-p, float64(Ni)) r[i] = T choice *= float64(Ni) / float64(i+1) } return r } func WowSample(N int, p float64) int { if N%1000 != 0 { panic("N must be a multiple of 1000") } bs := NewBinomialSampler(1000, p) r := 0 for i := 0; i < N; i += 1000 { r += bs.Sample() } return r } func main() { for i := 0; i < 1000; i++ { fmt.Println(WowSample(1000000, 0.0003)) } } 

Mes résultats, qui montrent une concurrence substantielle pour 4 processeurs contre 1 processeur:

Processeur Intel Core 2 Quad Q8300 @ 2,50 GHz x 4

Code source: UPDATE (01/12/13 18:05)

 $ go version go version devel +adf4e96e9aa4 Thu Jan 10 09:57:01 2013 +1100 linux/amd64 $ time go run temp.go Number of CPUs: 1 real 0m30.305s user 0m30.210s sys 0m0.044s $ time go run temp.go Number of CPUs: 4 real 0m9.980s user 0m35.146s sys 0m0.204s