Mandelbrot – avec solutions
L’objectif de ce travail est de vous familiariser avec les outils d’analyse des performances d’un programme et d’expérimenter le parallélisme. Commençons avec un programme d’une septantaine de lignes qui produit l’image (élégante ?) suivante :

Voici le code source du programme correspondant :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 | |
Profiler
Faites un profilage de votre programme pour trouver la partie à optimiser. Vous pouvez encapsuler le profiler avec le code suivant :
type Profiler struct {
f *os.File
}
// Start starts the profiler and saves the result in the file name "name"
func (p *Profiler) Start(name string) {
var err error
p.f, err = os.Create(name)
if err != nil {
log.Fatalf("could not create %v: %v", name, err)
}
if err := pprof.StartCPUProfile(p.f); err != nil {
log.Fatal("could not start CPU profile: ", err)
}
}
// Stop stops the profiler
func (p *Profiler) Stop() {
pprof.StopCPUProfile()
p.f.Close()
}
Dans la fonction main, vous pouvez alors démarrer le profiler :
p := Profiler{}
p.Start("cpu.profile")
et le stopper à la fin :
p.Stop()
Votre programme principal ressemblera donc à ceci :
func main() {
p := Profiler{}
p.Start("cpu.profile")
defer p.Stop()
...
}
Exécutez une nouvelle fois le programme avec le profiler.
Vous pouvez maintenant étudier le fichier cpu.profile avec la commande suivante :
go tool pprof cpu.profile
Expérimentez avec les commandes suivantes :
toppngsvglist main.main
Validez que le point chaud (hot spot) est la méthode buildImage
et le fait qu’elle appelle très souvent la méthode mandelbrot :
Tracer
Remplacez le profiler par un tracer :
type Tracer struct {
f *os.File
}
func (t *Tracer) Start(name string) {
var err error
t.f, err = os.Create(name)
if err != nil {
log.Fatalf("could not create %v: %v", name, err)
}
if err := trace.Start(t.f); err != nil {
log.Fatal("could not start tracer: ", err)
}
}
func (t *Tracer) Stop() {
trace.Stop()
t.f.Close()
}
et générez un fichier mandelbrot.trace :
func main() {
t := Tracer{}
t.Start("mandelbrot.trace")
defer t.Stop()
...
}
Ouvrez le fichier mandelbrot.trace avec l’analyseur de trace
go tool trace mandelbrot.trace
et cliquez sur View trace :

Observez le comportement de la heap, l’effet du garbage collector ainsi que l’utilisation des cœurs (Proc #).
Zoomez sur le garbage collector et admirez sa performance (seulement 237 µs sur mon PC) :

Notez l’utilisation des goroutines, des threads et des cœurs par le garbage collector.
Optimisation 1 : Le parallélisme
Tentez d’améliorer la performance du programme en introduisant du parallélisme. Dans un premier temps, faites calculer chaque pixel de l’image par une goroutine. Notez que pour une image de 2048 x 2048, vous allez créer 4 millions de goroutines ! Est-ce que Go est capable de gérer ça ?
Vérifiez que l’image est toujours correcte. Mesurez la différence de temps. Combien de temps avez-vous gagné ?
Solution
Pour comparer les performances, on peut mesurer le temps que prend une fonction avec la construction suivante :
start := time.Now()
// Call the function you want to time
elapsed := time.Since(start)
log.Printf("Elapsed time : %s", elapsed)
Solution :
func buildImage(m *image.RGBA, width, height int) image.Image {
var w sync.WaitGroup
w.Add(width * height)
for i := 0; i < width; i++ {
for j := 0; j < height; j++ {
go func(i, j int) {
m.Set(i, j, pixelColor(i, j, width, height))
w.Done()
}(i, j)
}
}
w.Wait()
return m
}
Cette solution génère énormément de goroutines, mais Go est capable de gérer ça. Le gain de performance est cependant limité à cause du supplément de travail dû au démarrage des goroutines.
Observez aussi le tracing obtenu avec ce nouveau programme. Est-ce que vous utilisez les cœurs de manière optimale ?
Optimisation 2 : Un peu moins de parallélisme
Modifiez votre programme pour que les goroutines calculent une ligne entière et pas uniquement un pixel. Vous ne générerez donc que 2048 goroutines.
Vérifiez que l’image est toujours correcte. Mesurez la différence de temps. Observez le tracing obtenu avec ce nouveau programme.
Solution
func buildImage(m *image.RGBA, width, height int) image.Image {
var w sync.WaitGroup
w.Add(height)
for j := 0; j < height; j++ {
go func(j int) {
for i := 0; i < width; i++ {
m.Set(i, j, pixelColor(i, j, width, height))
}
w.Done()
}(j)
}
w.Wait()
return m
}
Cette solution donne d’excellents résultats.
Optimisation 3 : Utilisation de Workers
A la place de créer 2048 goroutines, créez-en le nombre correspondant au
nombre de cœurs de votre PC. Vous pouvez obtenir le nombre de CPUs (ou de
cœurs) de votre PC avec la fonction runtime.NumCPU().
Les workers recevront les points à calculer par un canal :
type point struct{ x, y int }
c := make(chan point)
Utilisez un sync.WaitGroup pour que la fonction de calcul attende que les
workers finissent pour terminer à son tour.
Vérifiez que l’image est toujours correcte. Mesurez la différence de temps. Observez le tracing obtenu avec ce nouveau programme.
Solution
func buildImage(m *image.RGBA, width, height int) image.Image {
type point struct {
x, y int
}
ch := make(chan point)
var w sync.WaitGroup
// Start workers
for i := 0; i < runtime.NumCPU(); i++ {
w.Add(1)
go func() {
for p := range ch {
m.Set(p.x, p.y, pixelColor(p.x, p.y, width, height))
}
w.Done()
}()
}
// Produce Pixels
for j := 0; j < height; j++ {
for i := 0; i < width; i++ {
ch <- point{i, j}
}
}
close(ch)
w.Wait()
return m
}
Cette solution n’est pas optimale car le canal n’a qu’une seule entrée et la synchronisation (involontaire) faite par ce canal ralenti considérablement le processus.
Optimisation 4 : Ajout d’un buffer
Le canal créé au point précédent ne peut contenir qu’un seul point à la fois. Modifiez légèrement le programme pour permettre d’envoyer tous les points d’un seul coup :
type point struct{ x, y int }
c := make(chan point, bufferSize)
Vérifiez que l’image est toujours correcte. Mesurez la différence de temps. Observez le tracing obtenu avec ce nouveau programme.
Solution
func buildImage(m *image.RGBA, width, height int) image.Image {
type point struct {
x, y int
}
ch := make(chan point, 512)
var w sync.WaitGroup
// Start workers
for i := 0; i < runtime.NumCPU(); i++ {
w.Add(1)
go func() {
for p := range ch {
m.Set(p.x, p.y, pixelColor(p.x, p.y, width, height))
}
w.Done()
}()
}
// Produce Pixels
for j := 0; j < height; j++ {
for i := 0; i < width; i++ {
ch <- point{i, j}
}
}
close(ch)
w.Wait()
return m
}
Cette solution est meilleure que la précédente, mais les goroutines ne font que très peu de travail et on peut faire mieux en laissant les goroutines calculer une ligne entière.
Optimisation 5 : Des workers plus efficaces
Modifiez encore votre programme pour que les workers travaillent sur une ligne entière et non sur un seul pixel. Le canal sera utilisé pour envoyer des entiers correspondants à la ligne à produire :
c := make(chan int, bufferSIze)
Vérifiez que l’image est toujours correcte. Mesurez la différence de temps. Observez le tracing obtenu avec ce nouveau programme.
Solution
func buildImage(m *image.RGBA, width, height int) image.Image {
ch := make(chan int, 512)
var w sync.WaitGroup
// Start workers
for i := 0; i < runtime.NumCPU(); i++ {
w.Add(1)
go func() {
for j := range ch {
for i := 0; i < width; i++ {
m.Set(i, j, pixelColor(i, j, width, height))
}
}
w.Done()
}()
}
// Produce rows
for j := 0; j < height; j++ {
ch <- j
}
close(ch)
w.Wait()
return m
}
Cette solution est très bonne. Elle n’est cependant pas sensiblement meilleure que celle produite lors de l’optimisation 2 (avec 2048 goroutines)