Základní optimalizace v Go aneb pomáháme překladači (2)

13. 12. 2022
Doba čtení: 27 minut

Sdílet

 Autor: Depositphotos
Seznámíme se s dalšími optimalizacemi, které je vhodné či nutné provádět na úrovni zdrojového kódu. Jedná se o předávání polí odkazem či hodnotou, optimalizace průchodu poli, řezy i mapami a taktéž použití synchronizace u gorutin.

Obsah

1. Krátké ohlédnutí k problematice předávání parametrů hodnotou a odkazem

2. Role polí v programovacím jazyku Go

3. Řezy (slices) jako forma abstrakce nad „surovými“ poli

4. Řezy i pole se předávají hodnotou – co to ale konkrétně znamená?

5. Předávání polí a řezů: od teorie k praxi

6. Výsledky benchmarku

7. Reálnější benchmark

8. Výsledky benchmarku

9. Průchod všemi prvky pole či řezu

10. Benchmark měřicí dva způsoby průchodu polem

11. Výsledky benchmarku

12. Průchod všemi prvky mapy

13. Benchmark měřicí dva způsoby průchodu mapou

14. Výsledky benchmarku

15. Mutexy vs. kanály v roli synchronizační struktury

16. Benchmark: dva způsoby synchronizace

17. Výsledky benchmarku

18. Shrnutí

19. Repositář s demonstračními příklady

20. Odkazy na Internetu

1. Krátké ohlédnutí k problematice předávání parametrů hodnotou a odkazem

předchozím článku jsme se mj. zabývali technikou volání funkcí v jazyce Go. Řekli jsme si, že všechny parametry jsou předávány hodnotou a nikoli referencí. To by mohlo vést k dojmu, že uvnitř funkce (nebo metod) není možné modifikovat hodnotu parametru tak, aby to bylo zvenčí viditelné (tedy jinými slovy, že Go v tomto případě podporuje neměnitelné struktury). Ovšem ve skutečnosti tomu tak není; typickým příkladem jsou řezy, jejichž obsah je možné uvnitř funkce změnit. Podívejme se na jednoduchý příklad, v němž do funkce předáme řez, který je uvnitř funkce modifikován, přičemž změna je viditelná i vně funkce:

package main
 
import "fmt"
 
func changeMe(slice []int) {
        slice[0] = 42
}
 
func main() {
        // vytvoření řezu (nikoli pole)
        s := []int{1, 2, 3}
        fmt.Println(s)
        changeMe(s)
        fmt.Println(s)
}

Výsledek ukazuje, že byl řez skutečně modifikován, i když je předáván hodnotou a nikoli odkazem:

[1 2 3]
[42 2 3]

Proč vlastně k tomuto chování dochází si vysvětlíme v navazujících kapitolách.

2. Role polí v programovacím jazyku Go

V programovacím jazyce Go se, ostatně podobně jako v mnoha dalších programovacích jazycích, setkáme s poli (array). Pole jsou v pojetí jazyka Go homogenní datovou strukturou, což v tomto kontextu znamená, že prvky pole mají vždy stejný a již v čase překladu známý typ, kterým ovšem může být v krajním případě i prázdné rozhraní (interface{}). Pole mají současně (minimálně v jazyce Go) neměnnou délku, kterou je nutné specifikovat již při vytváření pole a délka pole je součástí typu. Současně jsou ovšem pole plnohodnotnými datovými typy s vlastní sémantikou a dokonce i vlastními metadaty, na rozdíl od programovacího jazyka C, v němž je práce se skutečnými poli v mnoha případech omezena typovým systémem.

Podívejme se nejdříve na způsob definice pole bez jeho přímé inicializace. Prvky pole budou mít v takovém případě nulovou hodnotu (kde pojem „nulová hodnota“ záleží na tom, o jaký datový typ se jedná – může se totiž jednat i o struktury, jiná pole, řezy, ukazatele, řetězce atd.):

var a1 [10]byte

Pole ovšem můžeme současně inicializovat a navíc můžeme využít typové inference:

a3 := [10]int32{1,10,2,9,3,8,4,7,5,6}

„Čtení“ datového typu je zde provedeno přímočaře zleva doprava: „pole deseti prvků typu int32 s hodnotami 1,10 …“

Vytvořit pochopitelně můžeme i vícerozměrná pole:

var matice [10][10]float32

Pro zjištění délky pole použijeme funkci len, tj. například:

x := len(a1)

A pro přístup k prvkům se používají klasické hranaté závorky, přičemž první prvek má nulový index, podobně jako v dalších „céčkovských“ programovacích jazycích:

for i:= 0; i < len(a1); i++ {
        a[i] = i*2;
}

Podívejme se nyní na úplný příklad, v němž se deklaruje a použije několik polí:

package main
 
import "fmt"
 
func main() {
        var a1 [10]byte
        var a2 [10]int32
        a3 := [10]int32{1,10,2,9,3,8,4,7,5,6}
 
        fmt.Printf("Delka pole 1: %d\n", len(a1))
        fmt.Printf("Delka pole 2: %d\n", len(a2))
        fmt.Printf("Delka pole 3: %d\n", len(a3))
 
 
        var a[10]int
 
        fmt.Printf("Pole pred upravou: %v\n", a)
 
        for i:= 0; i < len(a1); i++ {
                a[i] = i*2;
        }
 
        fmt.Printf("Pole po uprave:    %v\n", a)
 
        var matice [10][10]float32
        fmt.Printf("Matice:    %v\n", matice)
}
Pole ve skutečnosti nejsou příliš flexibilním datovým typem, mj. i z toho důvodu, že typová informace o poli v sobě zahrnuje i délku pole (počet prvků). Tím pádem je složité vytvářet například funkce akceptující jako svůj parametr pole (až na speciální případy typu transformační matice atd.). Ovšem to v praxi příliš nevadí, protože pole většinou slouží jako základ pro jiný typ – řez (slice):
package main
 
import "fmt"
 
// je nutné uvést délku pole
func printLength(x [10]int) {
        fmt.Println(len(x))
}
 
func main() {
        // zde je délka pole vypočtena automaticky překladačem a doplněna na místo ...
        x := [...]int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
        printLength(x)
}

Zajímavé bude zjistit, co se stane v případě, že v programu vytvoříme novou proměnnou a přiřadíme jí pole:

a2 := a1

V tomto případě se sémantika odlišuje podle použitého jazyka:

  1. V jazyce Go se provede skutečná kopie pole, takže výsledkem budou dvě na sobě nezávislá pole.
  2. V Javě (například) se jen přiřadí reference, takže dvě proměnné budou ukazovat na stejné pole.

Chování Go si můžeme velmi snadno otestovat na následujícím příkladu, v němž nejprve vytvoříme kopii pole a posléze původní pole změníme:

package main
 
import "fmt"
 
func main() {
        var a1[10]int
 
        a2 := a1
 
        fmt.Printf("Pole 1: %v\n", a1)
        fmt.Printf("Pole 2: %v\n", a2)
 
        for i:= 0; i <len(a1); i++ {
                a1[i] = i*2;
        }
 
        fmt.Printf("Pole 1: %v\n", a1)
        fmt.Printf("Pole 2: %v\n", a2)
}

Výsledek odpovídá předchozímu popisu – pole jsou odlišná:

Pole 1: [0 0 0 0 0 0 0 0 0 0]
Pole 2: [0 0 0 0 0 0 0 0 0 0]
Pole 1: [0 2 4 6 8 10 12 14 16 18]
Pole 2: [0 0 0 0 0 0 0 0 0 0]

Přibližně syntakticky (ne ovšem sémanticky!) ekvivalentní program v Javě by mohl vypadat takto:

import java.util.Arrays;
 
public class Test {
    public static void main(String[] args) {
        int[] a1 = new int[10];
        int[] a2 = a1;
 
        for (int i=0; i<a1.length; i++) {
            a1[i] = i*2;
        }
 
        System.out.println(Arrays.toString(a1));
        System.out.println(Arrays.toString(a2));
    }
}

Po překladu a spuštění se ovšem vypíšou dva stejné řádky – to znamená, že v programové smyčce jsme měnili prvky pole sdíleného mezi proměnnými a1 a a2:

[0, 2, 4, 6, 8, 10, 12, 14, 16, 18]
[0, 2, 4, 6, 8, 10, 12, 14, 16, 18]
Poznámka: sice to nebylo primárním cílem porovnání, ale povšimněte si, že Javovská varianta je více „ukecanější“

3. Řezy (slices) jako forma abstrakce nad „surovými“ poli

Jak jsme si již řekli v předchozí kapitole, nemusí být klasické pole dostatečně flexibilní datovou strukturou, která by plně vyhovovala potřebám vytvářené aplikace popř. implementovaného algoritmu. Pro dosažení větší flexibility byl do programovacího jazyka Go přidán další datový typ nazývaný řez neboli slice. Interně se jedná o referenci na automaticky vytvořené pole nebo na pole, které je explicitně „nasalámováno“ operací, s níž se seznámíme v navazující kapitole. Každý řez je v operační paměti uložen ve formě trojice hodnot (jde o záznam – struct či record):

  1. Ukazatele (reference) na zvolený prvek pole s daty, ke kterým přes řez přistupujeme.
  2. Délky řezu, tj. počtu prvků.
  3. Kapacity řezu (do jaké míry může řez narůstat v důsledku přidávání dalších prvků).

Tato interní struktura řezů s sebou přináší několik zajímavých důsledků. Je totiž možné, aby existovalo větší množství řezů ukazujících na obecně různé prvky jediného pole. Pokud nyní změníme prvek v jednom řezu, znamená to, že se vlastně modifikuje obsah původního pole a i ostatní řezy nový prvek uvidí. Co je však užitečnější – s řezy jako s datovým typem se velmi snadno pracuje; řezy mohou být předávány do funkcí, vráceny z funkcí atd.

Poznámka: s řezy, i když měly poněkud jiné chování, jsme se seznámili například při popisu programovacího jazyka Rust. Viz řezy vektoru a řezy pole.

4. Řezy i pole se předávají hodnotou – co to ale konkrétně znamená?

V jazyce Go se všechny parametry předávají do funkcí a metod hodnotou, což znamená, že interně vždy dochází ke kopii hodnoty. To platí i pro pole (na rozdíl od mnoha jiných programovacích jazyků), takže pole uvnitř funkce je kopií původního pole. V případě, že potřebujeme předat ukazatel na pole, je nutné tuto operaci explicitně zapsat do zdrojového kódu. Jak je tomu u řezů (slices)? Ty se taktéž předávají hodnotou, což znamená, že se provede kopie řezu. Ovšem jak již víme z předchozího textu, je řez reprezentován trojicí hodnot (velikost, kapacita, ukazatel na pole). Tyto hodnoty jsou zkopírovány (tj. například nelze změnit velikost řezu), ovšem samotné pole kopírováno není, protože v řezu je uložen pouze ukazatel na něj. To mj. znamená, že pole, na které se řez odkazuje, je možné uvnitř funkcí modifikovat (jinými slovy – Go nezaručuje absolutní neměnnost hodnot!). To ovšem platí i ve chvíli, kdy je pole součástí datové struktury:

Změna pole interně používaného řezem:

package main
 
import "fmt"
 
type Employee struct {
        id    int
        name  string
        wages []int
}
 
func changeMe(employee Employee) {
        employee.id = -1
        employee.name = "Someone else"
        employee.wages[0] = -1
}
 
func main() {
        franta := Employee{
                id:    1,
                name:  "Franta",
                wages: []int{30000, 32000, 30500, 29900},
        }
 
        fmt.Println(franta)
        changeMe(franta)
        fmt.Println(franta)
}

Výsledky, z nichž je patrné, že pole referencované přes řez lze uvnitř funkce modifikovat:

$ go run pass_by_value_2.go 
 
{1 Franta [30000 32000 30500 29900]}
{1 Franta [-1 32000 30500 29900]}

Předání struktury s polem:

package main
 
import "fmt"
 
const MONTHS = 12
 
type Employee struct {
        id    int
        name  string
        wages [MONTHS]int
}
 
func changeMe(employee Employee) {
        employee.id = -1
        employee.name = "Someone else"
        employee.wages[0] = -1
}
 
func main() {
        franta := Employee{
                id:    1,
                name:  "Franta",
                wages: [...]int{30000, 32000, 30500, 29900, 10000, 35000, 30000, 32000, 30500, 29900, 10000, 35000},
        }
 
        fmt.Println(franta)
        changeMe(franta)
        fmt.Println(franta)
}

Výsledky ukazující, že modifikace pole uvnitř funkce changeMe je pouze lokální:

$ go run pass_by_value_3.go 
 
{1 Franta [30000 32000 30500 29900 10000 35000 30000 32000 30500 29900 10000 35000]}
{1 Franta [30000 32000 30500 29900 10000 35000 30000 32000 30500 29900 10000 35000]}

5. Předávání polí a řezů: od teorie k praxi

Nyní již víme, jakým způsobem se předávají pole a řezy do funkcí a metod. Ajťácký selský rozum by tedy mohl napovídat, že u delších polí se bude provádět časově a paměťově velmi náročná kopie pole. Pojďme si tedy tento předpoklad ověřit na benchmarku, který bude volat (interně) podobné funkce, kterým se bude předávat řez, pole hodnotou a pole přes ukazatel (tedy vlastně odkazem). Tyto funkce budou modifikovat první a poslední prvek pole (protože přes řez se interně předává ukazatel na pole):

package main
 
import (
        "testing"
)
 
const MAX_VALS = 1000
const DEFAULT_VALUE = 0
const FIRST_VALUE = -1
const LAST_VALUE = -2
 
func changeMe1(values []int) {
        values[0] = FIRST_VALUE
        values[MAX_VALS-1] = LAST_VALUE
}
 
func changeMe2(values [MAX_VALS]int) {
        values[0] = FIRST_VALUE
        values[MAX_VALS-1] = LAST_VALUE
}
 
func changeMe3(values *[MAX_VALS]int) {
        values[0] = FIRST_VALUE
        values[MAX_VALS-1] = LAST_VALUE
}
 
func BenchmarkPassSlice(b *testing.B) {
        var values []int = make([]int, MAX_VALS)

        for i := 0; i < b.N; i++ {
                changeMe1(values)
        }
        if values[0] != FIRST_VALUE {
                b.Fatal()
        }
        if values[MAX_VALS-1] != LAST_VALUE {
                b.Fatal()
        }
}
 
func BenchmarkPassArrayByValue(b *testing.B) {
        var values [MAX_VALS]int = [MAX_VALS]int{DEFAULT_VALUE}
 
        for i := 0; i < b.N; i++ {
                changeMe2(values)
        }
        if values[0] != DEFAULT_VALUE {
                b.Fatal()
        }
        if values[MAX_VALS-1] != DEFAULT_VALUE {
                b.Fatal()
        }
}
 
func BenchmarkPassArrayByReference(b *testing.B) {
        var values [MAX_VALS]int = [MAX_VALS]int{DEFAULT_VALUE}
 
        for i := 0; i < b.N; i++ {
                changeMe3(&values)
        }
        if values[0] != FIRST_VALUE {
                b.Fatal()
        }
        if values[MAX_VALS-1] != LAST_VALUE {
                b.Fatal()
        }
}

6. Výsledky benchmarku

Vyzkoušejme si nyní benchmark, jehož zdrojový kód byl uveden v předchozí kapitole, spustit. Výsledky nás možná překvapí:

$ go test -bench=. -benchtime=100000000x -cpuprofile profile.out
 
goos: linux
goarch: amd64
pkg: parameters-benchmark
cpu: Intel(R) Core(TM) i7-8665U CPU @ 1.90GHz
BenchmarkPassSlice-8                    100000000                0.4799 ns/op
BenchmarkPassArrayByValue-8             100000000                0.2371 ns/op
BenchmarkPassArrayByReference-8         100000000                0.4740 ns/op
PASS
ok      parameters-benchmark    0.305s

Z výsledků vyplývá, že předávání polí hodnotou je nejrychlejší operací! Ovšem není tomu tak z toho důvodu, že by neustálé kopie tisíce hodnot typu int nějakým způsobem zrychlovalo operace prováděné mikroprocesorem. Důvod je jednoduchý – překladač programovacího jazyka Go zdetekoval, že operace s polem prováděná uvnitř funkce change2 není vně funkce viditelná (modifikace lokální kopie pole) a celé volání funkce odstranil (a posléze linker odstranil i objektový kód této funkce).

K témuž závěru můžeme dojít po otevření výsledků profileru, který se spustí příkazem:

$ go tool pprof -http=:6060 profile.out

Obrázek 1: Z benchmarku BenchmarkPassArrayByValue se ve skutečnosti žádná testovaná funkce nevolá.

Obrázek 2: I z tohoto výsledku je patrné, že se volají pouze funkce changeMe1 a changeMe3.

7. Reálnější benchmark

Abychom obešli optimalizaci kódu bez vnějšího efektu, upravíme předchozí benchmark do podoby, v níž se bude pole/řez nejenom modifikovat (ať již lokálně či globálně), ale vrátíme i nějakou hodnotu z této datové struktury. Takové funkce již není možné z výsledného kódu jednoduše odstranit a na druhou stranu je překladač jazyka Go dostatečně „hloupý“ na to, aby provedl inlining následovaný eliminací kopie atd.:

package main
 
import (
        "testing"
)
 
const MAX_VALS = 1000
const DEFAULT_VALUE = 0
const FIRST_VALUE = -1
const LAST_VALUE = -2
 
func changeMe1(values []int) int {
        values[0] = FIRST_VALUE
        values[MAX_VALS-1] = LAST_VALUE
        return values[MAX_VALS/2]
}
 
func changeMe2(values [MAX_VALS]int) int {
        values[0] = FIRST_VALUE
        values[MAX_VALS-1] = LAST_VALUE
        return values[MAX_VALS/2]
}
 
func changeMe3(values *[MAX_VALS]int) int {
        values[0] = FIRST_VALUE
        values[MAX_VALS-1] = LAST_VALUE
        return values[MAX_VALS/2]
}
 
func BenchmarkPassSlice(b *testing.B) {
        var values []int = make([]int, MAX_VALS)
 
        for i := 0; i < b.N; i++ {
                r := changeMe1(values)
                if r != DEFAULT_VALUE {
                        b.Fatal()
                }
        }
        if values[0] != FIRST_VALUE {
                b.Fatal()
        }
        if values[MAX_VALS-1] != LAST_VALUE {
                b.Fatal()
        }
}
 
func BenchmarkPassArrayByValue(b *testing.B) {
        var values [MAX_VALS]int = [MAX_VALS]int{DEFAULT_VALUE}
 
        for i := 0; i < b.N; i++ {
                r := changeMe2(values)
                if r != DEFAULT_VALUE {
                        b.Fatal()
                }
        }
        if values[0] != DEFAULT_VALUE {
                b.Fatal()
        }
        if values[MAX_VALS-1] != DEFAULT_VALUE {
                b.Fatal()
        }
}
 
func BenchmarkPassArrayByReference(b *testing.B) {
        var values [MAX_VALS]int = [MAX_VALS]int{DEFAULT_VALUE}
 
        for i := 0; i < b.N; i++ {
                r := changeMe3(&values)
                if r != DEFAULT_VALUE {
                        b.Fatal()
                }
        }
        if values[0] != FIRST_VALUE {
                b.Fatal()
        }
        if values[MAX_VALS-1] != LAST_VALUE {
                b.Fatal()
        }
}

8. Výsledky benchmarku

Opět si benchmark spustíme a zjistíme, jak se budou lišit časy volání funkcí s předáním pole hodnotou, odkazem a nepřímo přes řez:

$ go test -bench=. -benchtime=100000000x -cpuprofile profile.out
 
goos: linux
goarch: amd64
pkg: parameters-benchmark
cpu: Intel(R) Core(TM) i7-8665U CPU @ 1.90GHz
BenchmarkPassSlice-8                    100000000                0.4768 ns/op
BenchmarkPassArrayByValue-8             100000000              135.3 ns/op
BenchmarkPassArrayByReference-8         100000000                0.5629 ns/op
PASS
ok      parameters-benchmark    13.734s

Nyní je vše jasné – předání pole hodnotou je podle očekávání (i podle ajťáckého selského rozumu) nejméně efektivní operací.

Pro úplnost se podívejme na výsledky získané profilerem:

$ go tool pprof -http=:6060 profile.out

Obrázek 3: Profiler zobrazí jen informace o druhém benchmarku, protože ten zabere prakticky 99% strojového času.

Obrázek 4: Tytéž informace jsou zobrazeny i na tomto reportu.

9. Průchod všemi prvky pole či řezu

Ve druhé části dnešního článku se budeme zabývat zdánlivě triviální úlohou. Na vstupu budeme mít pole nebo řez s prvky libovolného typu:

type item struct {
        value   int
        payload [PAYLOAD_SIZE]byte
}
 
const MAX_ITEMS = 10000
 
var items [MAX_ITEMS]item
...
...
...
// inicializace prvků...
...
...
...

Naším úkolem bude projít všemi prvky pole (řezu) a vypočítat součet všech hodnot value. V jazyce Go se pro tento účel používá idiomatický zápis smyčky založený na klíčových slovech for a range:

for _, item := range items {
        sum += item.value
}

Podstata problému spočívá v „maličkosti“ – hodnota každého prvku se bude kopírovat do lokální proměnné item. A to je potenciálně velká výkonnostní brzda, zejména pro interní smyčky (na tento problém jsme již několikrát narazili v praxi).

10. Benchmark měřicí dva způsoby průchodu polem

To, o jak velký problém se jedná, zjistíme dvojicí benchmarků, v nichž budeme jednou procházet prvky pole s využitím výše uvedené idiomatické programové smyčky a podruhé budeme „pouze“ přistupovat k prvkům pole přes jejich index, což je spíše „céčkový“ přístup vypadající následovně:

for j := 0; j < len(items); j++ {
        sum += items[j].value
}

Zdrojový kód s benchmarky vypadá takto:

package main
 
import (
        "testing"
)
 
const PAYLOAD_SIZE = 100
 
type item struct {
        value   int
        payload [PAYLOAD_SIZE]byte
}
 
const MAX_ITEMS = 10000
 
func BenchmarkCountValues1(b *testing.B) {
        b.StopTimer()
 
        var items [MAX_ITEMS]item
 
        for i := 0; i < len(items); i++ {
                items[i].value = i
                items[i].payload = [PAYLOAD_SIZE]byte{}
        }
 
        b.StartTimer()
 
        for i := 0; i < b.N; i++ {
                sum := 0
 
                for _, item := range items {
                        sum += item.value
                }
 
                if sum != MAX_ITEMS/2*(MAX_ITEMS-1) {
                        b.Fatal(sum, MAX_ITEMS/2*(MAX_ITEMS-1))
                }
        }
}
 
func BenchmarkCountValues2(b *testing.B) {
        b.StopTimer()
 
        var items [MAX_ITEMS]item
 
        for i := 0; i < len(items); i++ {
                items[i].value = i
                items[i].payload = [PAYLOAD_SIZE]byte{}
        }
 
        b.StartTimer()
 
        for i := 0; i < b.N; i++ {
                sum := 0
 
                for j := 0; j < len(items); j++ {
                        sum += items[j].value
                }
 
                if sum != MAX_ITEMS/2*(MAX_ITEMS-1) {
                        b.Fatal(sum, MAX_ITEMS/2*(MAX_ITEMS-1))
                }
        }
}
Poznámka: samotná alokace pole a jeho naplnění hodnotami není součástí benchmarku – tento čas se nepočítá.

11. Výsledky benchmarku

Benchmark spustíme pouze 100000×, protože každá testovaná operace spočívá v průchodu celým polem, což je sama o sobě dlouhotrvající operace v porovnání s předchozími benchmarky (které tak byly opakovány vícekrát):

$ go test -bench=. -benchtime=100000x
 
goos: linux
goarch: amd64
pkg: range-val-copy-1
cpu: Intel(R) Core(TM) i7-8665U CPU @ 1.90GHz
BenchmarkCountValues1-8           100000             70310 ns/op
BenchmarkCountValues2-8           100000             10687 ns/op
PASS
ok      range-val-copy-1        8.104s

Z výsledků je patrné, že první způsob průchodu programovou smyčkou (ten idiomatický) je přibližně sedmkrát pomalejší než způsob první. V tomto konkrétním případě jsou rozdíly tak velké zejména proto, že každý prvek pole je poměrně objemný.

Zajímavé bude zjistit, v jaké části kódu se stráví nejvíce času. Opět se, podobně jako tomu bylo v předchozím článku, jedná o rutinu duff copy, která je naprogramovaná v assembleru (rozbalená smyčka) a tedy obecně velmi rychlá:

      File: range-val-copy-1.test
Type: cpu
Time: Dec 9, 2022 at 4:38pm (CET)
Duration: 8.42s, Total samples = 8.21s (97.51%)
Showing nodes accounting for 8.21s, 100% of 8.21s total
----------------------------------------------------------+-------------
      flat  flat%   sum%        cum   cum%   calls calls% + context
----------------------------------------------------------+-------------
                                             6.67s   100% |   testing.(*B).runN /opt/go/src/testing/benchmark.go:193
     3.90s 47.50% 47.50%      6.67s 81.24%                | range-val-copy-1.BenchmarkCountValues1 /home/ptisnovs/src/go-root/article_99/range-val-copy-1/parameters_test.go:31
                                             0.22s  3.30% |   runtime.duffcopy /opt/go/src/runtime/duff_amd64.s:393
                                             0.20s  3.00% |   runtime.duffcopy /opt/go/src/runtime/duff_amd64.s:408
                                             0.18s  2.70% |   runtime.duffcopy /opt/go/src/runtime/duff_amd64.s:420
                                             0.16s  2.40% |   runtime.duffcopy /opt/go/src/runtime/duff_amd64.s:413
                                             0.16s  2.40% |   runtime.duffcopy /opt/go/src/runtime/duff_amd64.s:423
                                             0.14s  2.10% |   runtime.duffcopy /opt/go/src/runtime/duff_amd64.s:418
                                             0.13s  1.95% |   runtime.duffcopy /opt/go/src/runtime/duff_amd64.s:425
                                             0.12s  1.80% |   runtime.duffcopy /opt/go/src/runtime/duff_amd64.s:392
                                             0.11s  1.65% |   runtime.duffcopy /opt/go/src/runtime/duff_amd64.s:403
                                             0.10s  1.50% |   runtime.duffcopy /opt/go/src/runtime/duff_amd64.s:398
                                             0.10s  1.50% |   runtime.duffcopy /opt/go/src/runtime/duff_amd64.s:407
                                             0.10s  1.50% |   runtime.duffcopy /opt/go/src/runtime/duff_amd64.s:412
                                             0.09s  1.35% |   runtime.duffcopy /opt/go/src/runtime/duff_amd64.s:405
                                             0.09s  1.35% |   runtime.duffcopy /opt/go/src/runtime/duff_amd64.s:410
                                             0.08s  1.20% |   runtime.duffcopy /opt/go/src/runtime/duff_amd64.s:395
                                             0.08s  1.20% |   runtime.duffcopy /opt/go/src/runtime/duff_amd64.s:402
                                             0.08s  1.20% |   runtime.duffcopy /opt/go/src/runtime/duff_amd64.s:404
                                             0.08s  1.20% |   runtime.duffcopy /opt/go/src/runtime/duff_amd64.s:414
                                             0.08s  1.20% |   runtime.duffcopy /opt/go/src/runtime/duff_amd64.s:422
                                             0.07s  1.05% |   runtime.duffcopy /opt/go/src/runtime/duff_amd64.s:397
                                             0.07s  1.05% |   runtime.duffcopy /opt/go/src/runtime/duff_amd64.s:417
                                             0.06s   0.9% |   runtime.duffcopy /opt/go/src/runtime/duff_amd64.s:400
                                             0.06s   0.9% |   runtime.duffcopy /opt/go/src/runtime/duff_amd64.s:427
                                             0.05s  0.75% |   runtime.duffcopy /opt/go/src/runtime/duff_amd64.s:415
                                             0.05s  0.75% |   runtime.duffcopy /opt/go/src/runtime/duff_amd64.s:424
                                             0.04s   0.6% |   runtime.duffcopy /opt/go/src/runtime/duff_amd64.s:399
                                             0.04s   0.6% |   runtime.duffcopy /opt/go/src/runtime/duff_amd64.s:409
                                             0.02s   0.3% |   runtime.duffcopy /opt/go/src/runtime/duff_amd64.s:394
                                             0.01s  0.15% |   runtime.duffcopy /opt/go/src/runtime/duff_amd64.s:419
----------------------------------------------------------+-------------
                                             0.96s   100% |   testing.(*B).runN /opt/go/src/testing/benchmark.go:193
     0.96s 11.69% 59.20%      0.96s 11.69%                | range-val-copy-1.BenchmarkCountValues2 /home/ptisnovs/src/go-root/article_99/range-val-copy-1/parameters_test.go:57
----------------------------------------------------------+-------------
                                             0.31s   100% |   testing.(*B).runN /opt/go/src/testing/benchmark.go:193
     0.31s  3.78% 62.97%      0.31s  3.78%                | range-val-copy-1.BenchmarkCountValues1 /home/ptisnovs/src/go-root/article_99/range-val-copy-1/parameters_test.go:32
----------------------------------------------------------+-------------
                                             0.26s   100% |   testing.(*B).runN /opt/go/src/testing/benchmark.go:193
     0.26s  3.17% 66.14%      0.26s  3.17%                | range-val-copy-1.BenchmarkCountValues2 /home/ptisnovs/src/go-root/article_99/range-val-copy-1/parameters_test.go:56

Kumulativní časy zobrazené graficky:

Obrázek 5: Výsledky benchmarku zobrazené graficky (pro odlišný počet běhů).

A konečně kumulativní časy zobrazené v tabulce:

Obrázek 6: Kumulativní časy benchmarků zobrazené v tabulkové podobě.

12. Průchod všemi prvky mapy

Programovací jazyk Go nabízí programátorům i možnost idiomatického zápisu programové smyčky určené pro průchod klíči a prvky mapy. Zápis je prakticky totožný, jako v případě polí či řezů, ovšem namísto indexů se vrací klíče:

for key, value := range items {
        ...
        ...
        ...
}

Ovšem opět platí, že klíče a hodnoty jsou v tomto případě kopírovány do lokálních proměnných key a value, což může u objemnějších datových struktur být dosti významným faktorem ovlivňujícím celkový výkon aplikace. Alternativně lze procházet jen klíči (což samo o sobě může vést ke kopiím, ovšem klíče nebývají příliš objemné) a k hodnotám mapy přistupovat přímo, ideálně k jejich adrese:

for key, _ := range items {
        ...
        ...
        ...
        items[key]
        ...
        ...
        ...
}
Poznámka: situace je o něco složitější kvůli tomu, že se v jazyce Go mapy běžně používají i pro implementaci dalších datových struktur, například množin.

13. Benchmark měřicí dva způsoby průchodu mapou

Abychom si oba výše zmíněné způsoby průchodu mapou otestovali a změřili jejich výpočetní složitost, použijeme opět benchmark, jenž nejdříve naplní mapu strukturami, z nichž každá má velikost přesahující 100 bajtů. Následně se ve smyčkách mapami projde a provede se výpočet, který zjistí, zda se skutečně prošlo všemi prvky mapy. Opět se ovšem měří pouze čas průchodu mapou, nikoli samotná alokace a inicializace mapy.:

package main
 
import (
        "testing"
)
 
const PAYLOAD_SIZE = 100
 
type item struct {
        value   int
        payload [PAYLOAD_SIZE]byte
}
 
const MAX_ITEMS = 10000
 
func BenchmarkCountValues1(b *testing.B) {
        b.StopTimer()
 
        var items map[int]item = make(map[int]item, MAX_ITEMS)
 
        for i := 0; i < MAX_ITEMS; i++ {
                items[i] = item{
                        value:   i,
                        payload: [PAYLOAD_SIZE]byte{},
                }
        }
 
        b.StartTimer()
 
        for i := 0; i < b.N; i++ {
                sum := 0
 
                for _, item := range items {
                        sum += item.value
                }
 
                if sum != MAX_ITEMS/2*(MAX_ITEMS-1) {
                        b.Fatal(sum, MAX_ITEMS/2*(MAX_ITEMS-1))
                }
        }
}
 
func BenchmarkCountValues2(b *testing.B) {
        b.StopTimer()
 
        var items [MAX_ITEMS]item
 
        for i := 0; i < len(items); i++ {
                items[i].value = i
                items[i].payload = [PAYLOAD_SIZE]byte{}
        }
 
        b.StartTimer()
 
        for i := 0; i < b.N; i++ {
                sum := 0
 
                for key, _ := range items {
                        sum += items[key].value
                }
 
                if sum != MAX_ITEMS/2*(MAX_ITEMS-1) {
                        b.Fatal(sum, MAX_ITEMS/2*(MAX_ITEMS-1))
                }
        }
}

14. Výsledky benchmarku

Po spuštění benchmarku uvidíme ještě výraznější rozdíly v rychlosti, než tomu bylo v příkladu, v němž jsme procházeli (iterovali) prvky pole:

$ go test -bench=. -benchtime=100000x -cpuprofile profile.out
 
goos: linux
goarch: amd64
pkg: range-val-copy-1
cpu: Intel(R) Core(TM) i7-8665U CPU @ 1.90GHz
BenchmarkCountValues1-8           100000            144225 ns/op
BenchmarkCountValues2-8           100000             12300 ns/op
PASS
ok      range-val-copy-1        15.853s

Z grafického vyjádření je zřejmé, že největší množství času se strávilo ve funkci mapiternext, za níž následuje nám již známá (optimalizovaná) operace duff copy pro kopii hodnot.

Obrázek 7: Volání mapiternext a rutiny duff copy.

15. Mutexy vs. kanály v roli synchronizační struktury

Mezi nejlepší (a možná taktéž nejvíce diskutované) vlastnosti programovacího jazyka Go patří podpora takzvaných gorutin zajišťujících souběžný běh více funkcí, přičemž počet gorutin není prakticky omezen (bez problémů lze mít souběžně spuštěno například 100000 gorutin). Ovšem pochopitelně je nutné v aplikaci, v níž dochází k souběhu, dokázat mezi gorutinami předávat data. K tomu slouží buď kanály (channels) nebo sdílená paměť. V případě použití sdílené paměti je nutné použít buď atomické hodnoty nebo mutexy, které umožňují uzamčení části kódu tak, že k němu bude mít v daný okamžik přístup jen jediná gorutina (a v této části kódu je realizován přístup ke sdílené paměti).

Někdy se ovšem namísto mutexů používají pro zajištění exkluzivního přístupu taktéž kanály, které se ovšem nepoužijí pro přenosy dat mezi gorutinami, ale využívá se toho, že zápis i čtení z kanálů jsou blokující operace: čtení z prázdného kanálu vede k čekání na zápis jinou gorutinou a naopak zápis do plného kanálu vede k čekání na přečtení předchozí hodnoty jinou gorutinou.

16. Benchmark: dva způsoby synchronizace

Oba dva způsoby synchronizace mezi gorutinami se pravděpodobně budou lišit rychlostí operací. To si ostatně ověříme na benchmarku, jehož zdrojový kód je uveden pod tímto odstavcem. V tomto benchmarku je proveden souběžný (a většinou i paralelní) výpočet v několika subrutinách (souběžně může být spuštěno až 100 subrutin), které přistupují ke stejnému prvku i zvolené datové struktury. A právě při operaci přístupu je použit zámek (mutex nebo kanál) pro zajištění exkluzivity přístupu (alternativně lze použít i atomické typy):

package main
 
import (
        "sync"
        "testing"
)
 
type valueWithMutex struct {
        i     int
        mutex *sync.Mutex
}
 
type valueWithChannel struct {
        i       int
        channel chan struct{}
}
 
func (value *valueWithMutex) lock() {
        value.mutex.Lock()
}
 
func (value *valueWithMutex) unlock() {
        value.mutex.Unlock()
}
 
func (value *valueWithChannel) lock() {
        // blokující přečtení hodnoty z kanálu
        <-value.channel
}
 
func (value *valueWithChannel) unlock() {
        // (blokující) zápis hodnoty do kanálu
        value.channel <- struct{}{}
}
 
const MAX_ITEMS = 100
const EXPECTED_SUM = MAX_ITEMS / 2 * (MAX_ITEMS - 1)
 
func BenchmarkAccumulationMutexVariant(b *testing.B) {
        var waitgroup sync.WaitGroup
        value := &valueWithMutex{
                mutex: new(sync.Mutex),
                i:     0,
        }
 
        for n := 0; n < b.N; n++ {
                value.i = 0
                for i := 0; i < MAX_ITEMS; i++ {
                        waitgroup.Add(1)
                        go func(i int) {
                                value.lock()
                                defer value.unlock()
                                defer waitgroup.Done()
                                value.i += i
                        }(i)
                }
                waitgroup.Wait()
                if value.i != EXPECTED_SUM {
                        b.Fatal(value.i, EXPECTED_SUM)
                }
        }
}
 
func BenchmarkAccumulationChannelVariant(b *testing.B) {
        var waitgroup sync.WaitGroup
        value := &valueWithChannel{
                channel: make(chan struct{}, 1),
                i:       0,
        }
        // ve výchozím stavu obsahuje kanál hodnotu
        value.channel <- struct{}{}
 
        for n := 0; n < b.N; n++ {
                value.i = 0
 
                for i := 0; i < MAX_ITEMS; i++ {
                        waitgroup.Add(1)
                        go func(i int) {
                                value.lock()
                                defer value.unlock()
                                defer waitgroup.Done()
                                value.i += i
                        }(i)
                }
                waitgroup.Wait()
                if value.i != EXPECTED_SUM {
                        b.Fatal(value.i, EXPECTED_SUM)
                }
        }
}

17. Výsledky benchmarku

Po spuštění benchmarku, jehož zdrojový kód byl uveden v předchozí kapitole, získáme výsledky, které ukazují, že použití kanálu ve funkci synchronizační struktury není nejrychlejší způsob, protože klasické mutexy jsou přibližně dvakrát rychlejší:

bitcoin_skoleni

$ go test -bench=. -benchtime=100000x
 
goos: linux
goarch: amd64
pkg: mutex-channel
cpu: Intel(R) Core(TM) i7-8665U CPU @ 1.90GHz
BenchmarkAccumulationMutexVariant-8               100000             29044 ns/op
BenchmarkAccumulationChannelVariant-8             100000             49570 ns/op
PASS
ok      mutex-channel   7.864s
Poznámka: to však neznamená, že by se kanály neměly používat! Je tomu právě naopak – pokud je vyžadováno předávání dat mezi gorutinami (a to prakticky vždycky je zapotřebí), zajistí kanály mnohem snadnější, bezpečnější a snadněji testovatelné operace, než by tomu bylo v případě využití sdílené paměti se zajištěním výhradního přístupu k ní

18. Shrnutí

Zajímavé je, že všechny optimalizace, které jsme si v dnešním článku ukázali, vlastně nutí programátory k opuštění idiomatického způsobu zápisu zdrojového kódu, tedy takového způsobu, který je doporučován a používán v oficiální dokumentaci, v učebnicích, v kurzech atd. Tyto optimalizace má tedy (většinou) smysl provádět až ve chvíli, kdy je takový kód volán velmi často (ve vnitřní smyčce atd.) a zejména tehdy, pokud jsou oba možné způsoby zápisu programu (idiomatický a optimalizovaný) ověřeny benchmarkem. Teprve v případě, že benchmark ukáže výrazné zlepšení, je vhodné zdrojový kód změnit, ideálně s případnými poznámkami pro kolegy (nebo budoucí já), v nichž bude vysvětleno, z jakého důvodu je daná část zdrojového kódu zkonstruována neidiomatickým způsobem. Neboli: pravidla je vhodné dodržovat, ovšem odborník by měl vědět, kdy je vhodné je porušit.

19. Repositář s demonstračními příklady

Zdrojové kódy všech minule i dnes použitých demonstračních příkladů byly uloženy do nového Git repositáře, který je dostupný na adrese https://github.com/tisnik/go-root (stále na GitHubu :-). V případě, že nebudete chtít klonovat celý repositář (ten je ovšem – alespoň prozatím – velmi malý, dnes má přibližně stovku kilobajtů), můžete namísto toho použít odkazy na jednotlivé demonstrační příklady, které naleznete v následující tabulce:

# Příklad/soubor Stručný popis Cesta
1 maps/map1_test.go benchmark pro mapy, jejichž klíče jsou typu UUID a hodnoty jsou časovými razítky https://github.com/tisnik/go-root/blob/master/article98/map­s/map1_test.go
2 maps/map2_test.go benchmark pro mapy, jejichž klíče jsou typu int a i hodnoty jsou stejného typu https://github.com/tisnik/go-root/blob/master/article98/map­s/map2_test.go
3 maps/map3_test.go benchmark pro mapy, jejichž prvky jsou prázdnými strukturami https://github.com/tisnik/go-root/blob/master/article98/map­s/map3_test.go
4 maps/map4_test.go benchmark pro mapy, jejichž klíče mají velikost 100 bajtů https://github.com/tisnik/go-root/blob/master/article98/map­s/map4_test.go
5 maps/go.mod projektový soubor s definicí modulu https://github.com/tisnik/go-root/blob/master/article98/map­s/go.mod
6 maps/go.sum seznam všech přímých i nepřímých závislostí https://github.com/tisnik/go-root/blob/master/article98/map­s/go.sum
       
7 map_or_slice/map_or_slice_test.go benchmark porovnávající použití řezů a map pro podobné operace vyhledání hodnoty https://github.com/tisnik/go-root/blob/master/article98/map_or_sli­ce/map_or_slice_test.go
8 map_or_slice/go.mod projektový soubor s definicí modulu https://github.com/tisnik/go-root/blob/master/article98/map_or_sli­ce/go.mod
9 map_or_slice/go.sum seznam všech přímých i nepřímých závislostí https://github.com/tisnik/go-root/blob/master/article98/map_or_sli­ce/go.sum
       
10 sets/map_or_slice_test.go benchmark: je lepší použít mapu nebo řez pro implementaci množiny? https://github.com/tisnik/go-root/blob/master/article98/set­s/map_or_slice_test.go
11 sets/go.mod projektový soubor s definicí modulu https://github.com/tisnik/go-root/blob/master/article98/set­s/go.mod
       
12 parameter_value_reference/main.go předávání rozsáhlých parametrů: hlavní program https://github.com/tisnik/go-root/blob/master/article98/pa­rameter_value_reference/ma­in.go
13 parameter_value_reference/config.toml konfigurační soubor, který je načítán programem https://github.com/tisnik/go-root/blob/master/article98/pa­rameter_value_reference/con­fig.toml
14 parameter_value_reference/go.mod projektový soubor s definicí modulu https://github.com/tisnik/go-root/blob/master/article98/pa­rameter_value_reference/go­.mod
15 parameter_value_reference/go.sum seznam všech přímých i nepřímých závislostí https://github.com/tisnik/go-root/blob/master/article98/pa­rameter_value_reference/go­.sum
16 parameter_value_reference/con­f/config.go definice datové struktury s načítanou konfigurací https://github.com/tisnik/go-root/blob/master/article98/pa­rameter_value_reference/con­f/config.go
17 parameter_value_reference/con­f/config.toml konfigurační soubor, který je načítán benchmarkem https://github.com/tisnik/go-root/blob/master/article98/pa­rameter_value_reference/con­f/config.toml
18 parameter_value_reference/con­f/config_benchmark_test.go benchmark: předávání velké struktury hodnotou nebo referencí? https://github.com/tisnik/go-root/blob/master/article98/pa­rameter_value_reference/con­f/config_benchmark_test.go
       
19 config_to_asm.go soubor, jehož překlad do assembleru se použil v dnešním článku https://github.com/tisnik/go-root/blob/master/article98/con­fig_to_asm.go
       
20 pass_by_value1.go předání řezu hodnotou, změna řezu uvnitř funkce https://github.com/tisnik/go-root/blob/master/article99/pas­s_by_value1.go
21 pass_by_value2.go předání struktury obsahující řez hodnotou https://github.com/tisnik/go-root/blob/master/article99/pas­s_by_value2.go
22 pass_by_value3.go předání struktury obsahující pole hodnotou https://github.com/tisnik/go-root/blob/master/article99/pas­s_by_value3.go
23 parameters_benchmark1/para­meters_test.go benchmark: předání řezu a pole do funkce, která řez/pole modifikuje https://github.com/tisnik/go-root/blob/master/article99/pa­rameters_benchmark1/parame­ters_test.go
24 parameters_benchmark1/go.mod projektový soubor s definicí modulu https://github.com/tisnik/go-root/blob/master/article99/pa­rameters_benchmark1/go.mod
25 parameters_benchmark2/para­meters_test.go benchmark: předání řezu a pole do funkce, která vrací prvek z pole https://github.com/tisnik/go-root/blob/master/article99/pa­rameters_benchmark2/parame­ters_test.go
26 parameters_benchmark2/go.mod projektový soubor s definicí modulu https://github.com/tisnik/go-root/blob/master/article99/pa­rameters_benchmark2/go.mod
27 range-val-copy-1/range_val_copy_test.go iterace přes všechny prvky pole https://github.com/tisnik/go-root/blob/master/article99/range-val-copy-1/range_val_copy_test.go
28 range-val-copy-1/go.mod projektový soubor s definicí modulu https://github.com/tisnik/go-root/blob/master/article99/range-val-copy-1/go.mod
29 range-val-copy-2/range_val_copy_test.go iterace přes všechny prvky mapy https://github.com/tisnik/go-root/blob/master/article99/range-val-copy-2/range_val_copy_test.go
30 range-val-copy-2/go.mod projektový soubor s definicí modulu https://github.com/tisnik/go-root/blob/master/article99/range-val-copy-2/go.mod
31 mutex_channel/mutex_channel_test.go synchronizace přes mutex nebo kanál https://github.com/tisnik/go-root/blob/master/article99/mu­tex_channel/mutex_channel_tes­t.go
32 mutex_channel/go.mod projektový soubor s definicí modulu https://github.com/tisnik/go-root/blob/master/article99/mu­tex_channel/go.mod

20. Odkazy na Internetu

  1. An Introduction to Benchmarking Your Go Programs
    https://tutorialedge.net/go­lang/benchmarking-your-go-programs/
  2. How the Go runtime implements maps efficiently (without generics)
    https://dave.cheney.net/2018/05/29/how-the-go-runtime-implements-maps-efficiently-without-generics
  3. Go18DS (Go 1.18+ Data Structures)
    https://github.com/daichi-m/go18ds
  4. TreeMap v2
    https://github.com/igrmk/treemap
  5. Go Data Structures: Binary Search Tree
    https://flaviocopes.com/golang-data-structure-binary-search-tree/
  6. Generics in Go
    https://bitfieldconsultin­g.com/golang/generics
  7. Tutorial: Getting started with generics
    https://go.dev/doc/tutorial/generics
  8. Gobs of data
    https://blog.golang.org/gobs-of-data
  9. Performance at Scale: MinIO Pushes Past 1.4 terabits per second with 256 NVMe Drives
    https://blog.min.io/performance-at-scale-minio-pushes-past-1–3-terabits-per-second-with-256-nvme-drives/
  10. Benchmarking MinIO vs. AWS S3 for Apache Spark
    https://blog.min.io/benchmarking-apache-spark-vs-aws-s3/
  11. Know Go: Generics (Kniha)
    https://bitfieldconsultin­g.com/books/generics
  12. Go 1.18 Generics based slice package
    https://golangexample.com/go-1–18-generics-based-slice-package/
  13. Highly extensible Go source code linter providing checks currently missing from other linters
    https://github.com/go-critic/go-critic
  14. Fast linters runner for Go
    https://github.com/golangci/golangci-lint
  15. Checkers from the “performance” group
    https://go-critic.com/overview#checkers-from-the-performance-group
  16. rangeValCopy
    https://go-critic.com/overview#rangeValCopy-ref
  17. C vs Rust vs Go: performance analysis
    https://medium.com/@marek.michalik/c-vs-rust-vs-go-performance-analysis-945ab749056c
  18. Golang Performance Comparison | Why is GO Fast?
    https://www.golinuxcloud.com/golang-performance/
  19. Go mutex vs channels benchmark
    https://github.com/danil/go_mu­tex_vs_channels_benchmark

Autor článku

Vystudoval VUT FIT a v současné době pracuje na projektech vytvářených v jazycích Python a Go.