46

義理の兄弟がプレイしているビデオ ゲームに関連する私のちょっとした好奇心に答えるために、いじくり回してきた Go コードが少しあります。

基本的に、以下のコードは、ゲーム内のモンスターとの相互作用と、倒したときにモンスターがアイテムをドロップする頻度をシミュレートします。私が抱えている問題は、このようなコードが並列化に最適であると期待することですが、同時実行を追加すると、すべてのシミュレーションを実行するのにかかる時間が元の 4 ~ 6 倍遅くなる傾向があります。同時実行なし。

コードがどのように機能するかをよりよく理解できるように、3 つの主な機能があります。 プレイヤーとモンスターの間の単純な相互作用である相互作用機能。モンスターがアイテムをドロップする場合は 1、そうでない場合は 0 を返します。シミュレーション関数は、いくつかの相互作用を実行し、相互作用結果のスライスを返します (つまり、相互作用の成功/失敗を表す 1 と 0)。最後に、一連のシミュレーションを実行し、アイテムのドロップにつながったインタラクションの総数であるシミュレーション結果のスライスを返すテスト関数があります。これは、並行して実行しようとしている最後の関数です。

これで、実行したいテストごとにゴルーチンを作成すると、コードが遅くなる理由が理解できました。100 個のテストを実行していると仮定すると、私の MacBook Air に搭載されている 4 つの CPU にまたがるゴルーチンのそれぞれの間でコンテキストが切り替わると、パフォーマンスが低下しますが、プロセッサの数だけゴルーチンを作成し、テストの数をプロセッサ間で分割しています。ゴルーチン。各テストを並行して実行しているため、これにより実際にコードのパフォーマンスが向上すると予想されますが、もちろん、代わりに大幅な速度低下が発生しています。

なぜこれが起こっているのかを理解したいので、どんな助けでも大歓迎です。

以下は、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))
}

そして、ゴルーチンとの並行コードは次のとおりです。

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 インスタンスを作成する、以下の並行コードの新しいバージョンを追加しました。コードのシリアル バージョンと比較して、非常にわずかに速度が向上しています (全体の所要時間が約 15 ~ 20% 短縮されています)。MBA の 4 つのコアにワークロードを分散させているため、時間が 75% 近く短縮されない理由を知りたいです。他に役立つ提案はありますか?

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)

私の問題を理解するのを手伝ってくれてありがとう。探していた答えがついに得られたので、同じ問題を抱えている人のためにここで要約すると思いました.

基本的に、私には 2 つの主な問題がありました。1 つ目は、私のコードは恥ずかしいほど並列でしたが、使用可能なプロセッサ間で分割すると実行速度が遅くなったことです。2 つ目は、別の問題が発生しました。これは、シリアル コードが 2 回実行されていたことですシングル プロセッサで実行されている同時実行コードと同じくらい遅く、ほぼ同じであると予想されます。どちらの場合も、問題は乱数ジェネレーター関数rand.Float64でした。rand基本的に、これはパッケージが提供する便利な機能です。そのパッケージでは、Rand構造体のグローバル インスタンスが作成され、各コンビニエンス関数によって使用されます。このグローバルRandインスタンスにはミューテックス ロックが関連付けられています。Randこの便利な関数を使用していたため、グローバルインスタンスにアクセスするために各ゴルーチンを整列させる必要があったため、コードを実際に並列化することはできませんでした。Rand解決策 (「システム」が以下に示すように) は、ゴルーチンごとに構造体の個別のインスタンスを作成することです。これにより、最初の問題は解決しましたが、2 つ目の問題が発生しました。

2 つ目の問題は、非並列並列コード (つまり、単一のプロセッサのみで実行される並列コード) が順次コードの 2 倍の速さで実行されることでした。この理由は、単一のプロセッサと単一のゴルーチンでしか実行していなかったにもかかわらず、そのゴルーチンには、Rand私が作成した構造体の独自のインスタンスがあり、ミューテックス ロックなしで作成したためです。シーケンシャル コードはrand.Float64、グローバル ミューテックスで保護されたRandインスタンスを利用する便利な関数をまだ使用していました。そのロックを取得するためのコストが原因で、順次コードの実行速度が 2 倍遅くなりました。

したがって、この話の教訓は、パフォーマンスが重要な場合は常に、Rand構造体のインスタンスを作成し、パッケージが提供する便利な関数を使用するのではなく、必要な関数を呼び出すようにすることです。

4

4 に答える 4

44

この問題はrand.Float64()、Mutex ロックが設定された共有グローバル オブジェクトを使用する の使用に起因しているようです。

代わりに、CPU ごとに個別の を作成し、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))
}

出力は私が受け取ったものです:

> CPU 数: 2
>
> 成功したインタラクション: 1000
> 1分20.39959秒
>
> 成功したインタラクション: 1000
> 41.392299秒
于 2013-01-12T23:56:03.947 に答える
7

私の Linux クアッド コア i7 ラップトップでコードをテストする

ここにGoogleスプレッドシートがあります

Google スプレッドシートのスクリーンショット

これは、少なくとも Linux では、スケーリングがコアごとにほぼ線形であることを示しています。

これが表示されない理由は 2 つあります。

1 つ目は、MacBook Air には実際のコアが 2 つしかないことです。ただし、 4 つのハイパースレッドがあるため、4 が最大 CPU として報告されます。通常、ハイパースレッドは、予想される 100% ではなく、1 つのコアに対して 15% の追加パフォーマンスしか提供しません。そのため、Macbook Air でのみ 1 つまたは 2 つの CPU のベンチマークを実施してください。

もう 1 つの理由は、Linux と比較した OS X スレッドのパフォーマンスである可能性があります。それらは、パフォーマンスに影響を与える可能性のある異なるスレッド モデルを使用します。

于 2013-01-13T08:17:16.040 に答える
3

コードは二項確率変数 B(N, p) をサンプリングしています。ここで、N は試行回数 (ここでは 1M)、p は個々の試行が成功する確率 (ここでは 0.0003) です。

これを行う 1 つの方法は、累積確率のテーブル T を作成することです。ここで、T[i] には試行の合計数が i 以下である確率が含まれます。サンプルを生成するには、(rand.Float64 を介して) 一様確率変数を選択し、それ以上の確率を含むテーブル内の最初のインデックスを見つけることができます。

ここでは、非常に大きな N とかなり小さな p があるため、もう少し複雑です。そのため、テーブルを作成しようとすると、非常に小さな数値と算術精度で問題が発生します。ただし、より小さなテーブル (たとえば 1000 の大きなテーブル) を作成し、それを 1000 回サンプリングして 100 万回の試行を行うことができます。

これをすべて行うコードを次に示します。あまりエレガントではありませんが (1000 はハードコードされています)、私の古いラップトップでは 1 秒以内に 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))
    }
}
于 2013-01-13T16:54:38.200 に答える
1

私の結果では、4 つの CPU と 1 つの CPU の実質的な同時実行性が示されています。

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
于 2013-01-13T02:18:31.770 に答える