46

我有一些我一直在修补的围棋代码来回答我对我姐夫玩的电子游戏的一点好奇。

从本质上讲,下面的代码模拟了与游戏中怪物的互动,以及他可以期望它们在被击败后多久掉落一次物品。我遇到的问题是,我希望这样的一段代码非常适合并行化,但是当我添加并发时,执行所有模拟所需的时间往往会减慢原始代码的 4-6 倍没有并发。

为了让您更好地理解代码是如何工作的,我提供了三个主要功能: 交互功能,即玩家与怪物之间的简单交互。如果怪物掉落物品,则返回 1,否则返回 0。模拟函数运行多个交互并返回一段交互结果(即,1 和 0 代表成功/不成功的交互)。最后,还有一个测试函数,它运行一组模拟并返回一段模拟结果,这些结果是导致物品掉落的交互总数。这是我试图并行运行的最后一个函数。

现在,我可以理解为什么如果我为每个要运行的测试创建一个 goroutine 代码会变慢。假设我正在运行 100 个测试,我的 MacBook Air 拥有的 4 个 CPU 上的每个 goroutine 之间的上下文切换会降低性能,但我只创建与处理器数量一样多的 goroutine,并将测试数量划分为协程。我希望这实际上可以加快代码的性能,因为我正在并行运行每个测试,但是,当然,我的速度大大降低了。

我很想知道为什么会这样,所以任何帮助将不胜感激。

下面是没有 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))
}

而且,这里是 goroutine 的并发代码:

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)

我在下面添加了一个新版本的并发代码,它根据下面的“系统”建议为每个 goroutine 创建一个新的 Rand 实例。与代码的串行版本相比,我现在看到了非常轻微的加速(总时间减少了大约 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实例有一个与之关联的互斥锁。由于我使用了这个便利功能,我并不能真正并行化我的代码,因为每个 goroutine 都必须排队才能访问全局Rand实例。Rand解决方案(如下面的“系统”建议)是为每个 goroutine创建一个单独的结构实例。这解决了第一个问题,但产生了第二个问题。

第二个问题是我的非并行并发代码(即我的并发代码只使用一个处理器运行)的运行速度是顺序代码的两倍。这样做的原因是,即使我只使用一个处理器和一个 goroutine 运行,该 goroutine 也有Rand我创建的结构的自己的实例,并且我在没有互斥锁的情况下创建了它。顺序代码仍在使用rand.Float64利用全局互斥保护Rand实例的便利功能。获取该锁的成本导致顺序代码运行速度变慢了两倍。

因此,故事的寓意是,每当性能很重要时,请确保创建Rand结构的实例并从中调用所需的函数,而不是使用包提供的便利函数。

4

4 回答 4

44

问题似乎来自您对 的使用rand.Float64(),它使用了一个带有互斥锁的共享全局对象。

相反,如果您为每个 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
> 1m20.39959s
>
> 成功互动:1000
> 41.392299s
于 2013-01-12T23:56:03.947 回答
7

在我的 Linux 四核 i7 笔记本电脑上测试你的代码我明白了

这是一个谷歌电子表格

Google 电子表格的屏幕截图

这表明,至少在 Linux 下,每个内核的扩展几乎是线性的。

我认为你没有看到这个可能有两个原因。

首先是你的 macbook air 只有 2 个真正的核心。它有 4 个超线程,这就是为什么它将 4 报告为最大 cpu 的原因。超线程通常仅比单个内核提供额外 15% 的性能,而不是您可能期望的 100%。所以坚持只在 macbook air 上对 1 或 2 个 CPU 进行基准测试!

另一个原因可能是 OS X 线程性能与 Linux 相比。他们使用不同的线程模型,这可能会影响性能。

于 2013-01-13T08:17:16.040 回答
3

您的代码正在对二项式随机变量 B(N, p) 进行采样,其中 N 是试验次数(此处为 1M),p 是单个试验成功的概率(此处为 0.0003)。

一种方法是建立一个累积概率表 T,其中 T[i] 包含试验总数小于或等于 i 的概率。然后生成一个样本,您可以选择一个统一的随机变量(通过 rand.Float64)并找到表中包含大于或等于它的概率的第一个索引。

这里有点复杂,因为你有一个非常大的 N 和一个相当小的 p,所以如果你尝试构建表格,你会遇到非常小的数字和算术准确性的问题。但是您可以构建一个较小的表(例如 1000 个大表)并对其进行 1000 次采样以获得 100 万次试验。

这是完成所有这些的一些代码。它不是很优雅(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))
    }
}
于 2013-01-13T16:54:38.200 回答
1

我的结果显示 4 个 CPU 与 1 个 CPU 的大量并发:

Intel Core 2 四核 CPU Q8300 @ 2.50GHz x 4

源代码:更新(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 回答