为什么添加并发会减慢这个golang代码?

时间:2013-01-12 22:19:04

标签: concurrency go

我已经得到了一些Go代码,我一直在修补我的一点好奇心,我的姐夫玩的视频游戏。

基本上,下面的代码模拟了游戏中与怪物的交互,以及他们在失败后可以预期他们丢弃物品的频率。我遇到的问题是,我希望像这样的代码片段非常适合并行化,但是当我添加并发时,所有模拟所需的时间往往会减慢原始速度的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))
}

并且,这是与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)
}

更新(2013年12月1日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)
}

更新(2013年1月13日17:58)

感谢大家帮忙解决我的问题。我终于得到了我正在寻找的答案,所以我想我会在这里总结一下那些有同样问题的人。

基本上我有两个主要问题:首先,即使我的代码是embarrassingly parallel,当我在可用的处理器中拆分它时运行速度较慢,其次,解决方案打开另一个问题,这是我的串行代码的运行速度是单处理器上运行的并发代码的两倍,您可能会大致相同。在这两种情况下,问题都是随机数生成器函数rand.Float64。基本上,这是rand包提供的便利功能。在该包中,每个便捷函数都创建并使用Rand结构的全局实例。此全局Rand实例具有与之关联的互斥锁。由于我使用了这个便捷功能,因此我无法真正能够并行化我的代码,因为每个goroutine都必须排队访问全局Rand实例。解决方案(如下面的“系统”所示)是为每个goroutine创建一个单独的Rand结构实例。这解决了第一个问题,但创建了第二个问题。

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

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

4 个答案:

答案 0 :(得分:41)

问题似乎来自您使用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))
}

我收到了输出:

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

答案 1 :(得分:7)

在我的Linux四核i7笔记本电脑上测试你的代码我得到了这个

这是Google Spreadsheet

Screenshot of Google Spreadsheet

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

我认为可能有两个原因导致您没有看到这一点。

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

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

答案 2 :(得分: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))
    }
}

答案 3 :(得分: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