Подтвердить что ты не робот

Почему добавление concurrency замедляет этот код golang?

У меня есть код Go, который я возился, чтобы ответить на небольшое любопытство, связанное с видеоигрой, которую играет мой шурин.

По сути, приведенный ниже код имитирует взаимодействия с монстрами в игре и как часто он может ожидать, что они потеряют предметы после их поражения. Проблема, с которой я сталкиваюсь, заключается в том, что я ожидал бы, что часть кода, подобная этому, идеально подходит для распараллеливания, но когда я добавляю в concurrency время, необходимое для выполнения всех симуляций, как правило, замедляется в 4-6 раз оригинал без concurrency.

Чтобы лучше понять, как работает код, у меня есть три основные функции: функция взаимодействия, которая представляет собой простое взаимодействие между игроком и монстром. Он возвращает 1, если монстр бросает элемент, а 0 - в противном случае. Функция имитации выполняет несколько взаимодействий и возвращает фрагмент результатов взаимодействия (т.е. 1 и 0 представляет успешные/неудачные взаимодействия). Наконец, есть тестовая функция, которая запускает набор симуляций и возвращает срез результатов моделирования, которые представляют собой общее количество взаимодействий, в результате которых выпал элемент. Это последняя функция, которую я пытаюсь запустить параллельно.

Теперь я понял, почему код замедляется, если я создаю goroutine для каждого теста, который я хочу запустить. Предполагая, что я запускаю 100 тестов, переключение контекста между каждым из goroutines по четырем процессорам, которые у меня MacBook Air убьет производительность, но я создаю столько же goroutines, сколько у меня есть процессоры, и деля количество тестов между goroutines. Я ожидал бы, что это фактически ускорит производительность кода, так как я выполняю каждый из своих тестов параллельно, но, конечно, я становлюсь основным замедлителем.

Мне бы хотелось выяснить, почему это происходит, поэтому нам будет очень благодарна любая помощь.

Ниже приведен обычный код без подпрограмм go:

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))
}

А вот вот параллельный код с 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)
}

ОБНОВЛЕНИЕ (01/12/13 18:05)

Я добавил новую версию параллельного кода ниже, который создает новый экземпляр Rand для каждого goroutine в соответствии с предложением "система" ниже. Сейчас я вижу очень небольшую скорость по сравнению с серийной версией кода (около 15-20% сокращения общего времени). Мне бы очень хотелось узнать, почему я не вижу что-то ближе к 75-процентному сокращению времени, так как я распространяю рабочую нагрузку на свои ядра MBA 4. Есть ли у кого-нибудь дополнительные предложения, которые могли бы помочь?

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)
}

ОБНОВЛЕНИЕ (01/13/13 17:58)

Спасибо всем за помощь в выяснении моей проблемы. Я, наконец, получил ответ, который искал, и поэтому я подумал, что просто подведу итог для всех, у кого есть такая же проблема.

По сути, у меня были две основные проблемы: во-первых, хотя мой код был неловко параллельным, он работал медленнее, когда я разбил его среди доступных процессоров, а второй, решение открыло еще одну проблему, и мой серийный код работал в два раза медленнее, чем параллельный код, работающий на одном процессоре, который, как вы ожидали, будет примерно одинаковым. В обоих случаях проблемой была функция генератора случайных чисел rand.Float64. В принципе, это функция удобства, предоставляемая пакетом rand. В этом пакете глобальный экземпляр структуры rand создается и используется каждой из функций удобства. Этот глобальный экземпляр rand имеет связанную с ним блокировку мьютекса. Поскольку я использовал эту функцию удобства, я действительно не мог распараллелить свой код, так как каждый из goroutines должен был выстраиваться в очередь для доступа к глобальному экземпляру rand. Решение (как предлагает ниже система) заключается в создании отдельного экземпляра структуры rand для каждого горутина. Это разрешило первую проблему, но создало вторую.

Вторая проблема заключалась в том, что мой непараллельный параллельный код (т.е. мой параллельный код, работающий только с одним процессором) работал в два раза быстрее, чем последовательный код. Причина этого заключалась в том, что, хотя я работал только с одним процессором и одним goroutine, у этого goroutine был свой собственный экземпляр структуры rand, которую я создал, и я создал его без блокировки мьютекса. Последовательный код по-прежнему использовал функцию удобства rand.Float64, которая использовала глобальный экземпляр rand, защищенный мьютексом. Стоимость приобретения этого замка заставляла последовательный код работать в два раза медленнее.

Итак, мораль истории - всякий раз, когда важна производительность, убедитесь, что вы создаете экземпляр структуры rand и вызываете нужную функцию, а не используете удобные функции, предоставляемые пакетом.

4b9b3361

Ответ 1

Проблема, похоже, связана с использованием rand.Float64(), который использует общий глобальный объект с блокировкой Mutex на нем.

Вместо этого, если для каждого ЦП вы создаете отдельный rand.New(), передайте его interactions() и используйте его для создания Float64(), там будет значительное улучшение.


Обновить, чтобы показать изменения в новом примере кода в вопросе, который теперь использует rand.New()

Функция test() была изменена либо для использования данного канала, либо для возврата результата.

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 
}

Функция main() была обновлена ​​для запуска обоих тестов и вывода результата по времени.

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))
}

Выход я получил:

> Number of CPUs:  2 
>
> Successful interactions:  1000 
> 1m20.39959s
>
> Successful interactions:  1000
> 41.392299s

Ответ 2

Тестирование вашего кода на моем четырехъядерном ядре i7 для Linux Я получаю это

Вот Google Spreadsheet

Screenshot of Google Spreadsheet

Это показывает, что в Linux по крайней мере масштабирование почти линейно на ядро.

Я думаю, может быть две причины, почему вы этого не видите.

Во-первых, ваш компьютер MacBook имеет только 2 реальных ядра. Он имеет 4 hyperthreads, хотя именно поэтому он сообщает 4 как max cpus. Гиперпочта обычно дает дополнительную 15-процентную производительность по одному ядру, а не 100%, которую вы можете ожидать. Поэтому придерживайтесь сравнения 1 или 2 процессоров только на компьютере macbook!

Другой причиной может быть производительность потоковой передачи ОС по сравнению с Linux. Они используют разные модели потоков, которые могут влиять на производительность.

Ответ 3

Ваш код выполняет выборку биномиальной случайной величины, B (N, p), где N - количество испытаний (здесь 1M), а p - вероятность успешного индивидуального испытания (здесь 0,0003).

Один из способов сделать это - построить таблицу T кумулятивных вероятностей, где T [i] содержит вероятность того, что общее количество испытаний меньше или равно i. Чтобы затем создать образец, вы можете выбрать единую случайную переменную (через rand.Float64) и найти первый индекс в таблице, который содержит вероятность, большую или равную ей.

Это немного сложнее, потому что у вас действительно большой N и довольно маленький p, поэтому, если вы попытаетесь построить таблицу, у вас возникнут проблемы с действительно небольшими числами и арифметической точностью. Но вы можете построить меньшую таблицу (скажем, 1000 больших) и попробовать ее 1000 раз, чтобы получить 1 миллион проб.

Вот код, который делает все это. Он не слишком изящный (1000 жестко закодирован), но он генерирует 1000 симуляций менее чем за секунду на моем старом ноутбуке. Легко оптимизировать дальше, например, подняв конструкцию BinomialSampler из цикла или используя двоичный поиск, а не линейное сканирование, чтобы найти индекс таблицы.

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(N-i))
        r[i] = T
        choice *= float64(N-i) / 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))
    }
}

Ответ 4

Мои результаты, которые показывают существенный concurrency для 4 процессоров по сравнению с 1 процессором:

Intel Core 2 Quad CPU Q8300 @2.50GHz x 4

Исходный код: 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