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

Posted

技术标签:

【中文标题】为啥添加并发会减慢这个 golang 代码?【英文标题】:Why does adding concurrency slow down this golang code?为什么添加并发会减慢这个 golang 代码? 【发布时间】:2012-12-27 05:41:15 【问题描述】:

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

基本上,下面的代码模拟了游戏中与怪物的互动,以及他期望怪物在被击败后掉落物品的频率。我遇到的问题是,我希望这样的一段代码非常适合并行化,但是当我添加并发性时,进行所有模拟所需的时间往往会减慢原来的 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)

更新(2013 年 1 月 12 日 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 结构的实例并从中调用所需的函数,而不是使用包提供的便利函数。

【问题讨论】:

使用不同的算法可以在不到一秒的时间内产生 1000 次 1000000 次交互的模拟(详细信息在下面我的回答中)。虽然它不能回答您关于并发的问题,但它确实可以更有效地解决您的问题。 【参考方案1】:

问题似乎来自您对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

【讨论】:

感谢您的提示,我更新了代码,为每个 goroutine 创建了一个 Rand 实例并将其传递给 interaction 函数,它似乎确实加快了并发代码的速度。不过,我仍然没有得到重大的加速。我有点期望看到时间减少接近 4 倍(因为我的机器上有 4 个内核),但相反,我只看到时间减少了大约 1.2 倍。 我继续添加新代码以及您对上述问题的建议更改。如果我做错了什么,请随意查看并告诉我。 另外,我一直在玩代码,似乎我看到的最好的加速是当我将 CPU 数量设置为 1 而不是使用 runtime.NumCPU 函数时来确定正确的数量。当我这样做时,我看到所花费的时间减少到大约是串行代码时间的 1/2。这更接近我希望看到的分布在 4 个内核上的工作,但奇怪的是,当降低可用 CPU 的数量时,我会看到这种时间减少。任何想法为什么会这样? @ChristopherRoach:当我在测试时,我的双核笔记本电脑减少了 40-50%,但后来我也改变了一些关于如何使用通道的事情。您对rand 的使用与我使用的几乎相同。我将弄乱你更新的示例,看看它是如何在我的机器上运行的。 @ChristopherRoach:关于线程,打开另一个问题可能是个好主意。您正在体验不同之处,这非常有趣。关于互斥量,我查了docs for the rand.Float64(),然后点击方法名到the source,然后跟着它到the globalRand,最后是the lockedSource,里面有互斥量。【参考方案2】:

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

英特尔酷睿 2 四核 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

【讨论】:

感谢 PeterSO,我切换到 Ubuntu 来运行代码并开始看到同样的情况,所以看起来我在 OS X 上错误地计时了代码。一切似乎都按照我的方式工作预计到现在。【参考方案3】:

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

这是Google Spreadsheet

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

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

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

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

【讨论】:

谢谢尼克,实际上我看到的性能与您上面列出的相似。当我之前报告我的发现时,我似乎没有正确地计时代码。也就是说,我想要一些建议,说明为什么我的纯串行代码和单处理器的并发代码之间存在如此巨大的差异(请参阅上面答案中的最后一条评论)。因此,您可能提出的任何建议将不胜感激。干杯。 区别完全在于随机数生成器。如果您将var source = rand.NewSource(time.Now().UnixNano())var generator = rand.New(source) 放在原始源代码的顶部,并将调用替换为generator.Float64(),您将看到原始代码与maxCpus = 1 的并发代码所用的时间完全相同。我不知道为什么它们之间有区别! 我尝试了您的建议,现在我看到串行和并发 (MAXPROCESSORS=1) 代码之间的时间相等。我查看了 Go 源代码并注意到 rand.Float64 函数使用的 globalRand 对象正在使用锁定的 source (正如他在上面的回答中建议的“系统”)。我将该代码复制到我的串行代码示例中,并尝试使用和不使用获取源对象锁定的调用,这完全不同。看起来获取锁的成本是为我的顺序代码示例增加额外时间的原因。谜团已揭开!干杯!【参考方案4】:

您的代码正在对二项式随机变量 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))
    

【讨论】:

以上是关于为啥添加并发会减慢这个 golang 代码?的主要内容,如果未能解决你的问题,请参考以下文章

为啥这个脚本会随着输入量的增加而减慢每个项目的速度?

为啥 TensorFlow 的 `tf.data` 包会减慢我的代码速度?

向表中添加了新列并减慢了整个数据库的速度。为啥?

通俗易懂!图解Go协程原理及实战

oracle中加索引会不会加快更新的速度?有人说会减慢更新速度?谁知道为啥吗?

当计算相同时,为啥 R 会随着时间的推移而减慢?