libcopp(v2) vs goroutine性能测试

本来是没想写这个对比。无奈之前和call_in_stack的作者聊了一阵,发现了一些libcopp的改进空间。然后顺便看了新的boost.context的cc部分的代码,有所启发。想给libcopp做一些优化,主要集中在减少分配次数从而减少内存碎片;在支持的编译器里有些地方用右值引用来减少不必要的拷贝;减少原子操作和减少L1cache miss几个方面。

之后改造了茫茫多流程和接口后出了v2版本,虽然没完全优化完,但是组织结构已经定型了,可以用来做压力测试。为了以后方便顺便还把cppcheck和clang-analyzer的静态分析工具写进了dev脚本。然后万万没想到的是,在大量协程的情况下,benchmark的结果性能居然比原来还下降了大约1/3。

我结合valgrind和perf的报告分析了一下原因,原来的读L1 cache miss大约在68%左右,而v2里的读L1 cache miss到了98%。究其原因,原来的协程、协程任务对象和协程任务的actor是由malloc分配的,而现在在v2里全部优化后放进了分配的执行栈里。这样减少了三次malloc操作。但是这也导致不同协程、任务和actor之间的距离隔得非常远,必超出L1 cache的量(一般是64字节)。那么就必然容易L1 cache miss了。而原来在benchmark里由于是连续分配的,所以他们互相都在比较近的位置,当然原来的性能高了。

这种情况,因该说是原来的benchmark更加不能作为实际使用过程中的性能参考依据。之前也说过,因为在实际应用场景中几乎必然cache miss,因为逻辑会更复杂得多,内存访问也更切换得频繁和多。

这让我突然想到了[go][3]语言的goroutine。不知道这玩意有没有考虑切换时的Cache miss开销,不知道针对这个做优化。因为它是语言级别实现的go程,那么如果要针对缓存做优化则比较容易实现一些。不过它的动态栈总归会有一定的开销。

goroutine压力测试

这里还是用了和libcopp里差不多的测试方法。benchmark的代码如下:

package main

import (
    "fmt"
    "os"
    "strconv"
    "time"
)

func runCallback(in, out chan int64) {
    for n, ok := <-in; ok; n, ok = <-in {
        out <- n
    }
}

func runTest(round int, coroutineNum, switchTimes int64) {
    fmt.Printf("##### Round: %v\n", round)
    start := time.Now()
    channelsIn, channelsOut := make([]chan int64, coroutineNum), make([]chan int64, coroutineNum)
    for i := int64(0); i < coroutineNum; i++ {
        channelsIn[i] = make(chan int64, 1)
        channelsOut[i] = make(chan int64, 1)
    }
    end := time.Now()
    fmt.Printf("Create %v goroutines and channels cost %vns, avg %vns\n", coroutineNum, end.Sub(start).Nanoseconds(), end.Sub(start).Nanoseconds()/coroutineNum)

    start = time.Now()
    for i := int64(0); i < coroutineNum; i++ {
        go runCallback(channelsIn[i], channelsOut[i])
    }
    end = time.Now()
    fmt.Printf("Start %v goroutines and channels cost %vns, avg %vns\n", coroutineNum, end.Sub(start).Nanoseconds(), end.Sub(start).Nanoseconds()/coroutineNum)

    var sum int64 = 0
    start = time.Now()
    for i := int64(0); i < switchTimes; i++ {
        for j := int64(0); j < coroutineNum; j++ {
            channelsIn[j] <- 1
            sum += <-channelsOut[j]
        }
    }
    end = time.Now()
    fmt.Printf("Switch %v goroutines for %v times cost %vns, avg %vns\n", coroutineNum, sum, end.Sub(start).Nanoseconds(), end.Sub(start).Nanoseconds()/sum)

    start = time.Now()
    for i := int64(0); i < coroutineNum; i++ {
        close(channelsIn[i])
        close(channelsOut[i])
    }
    end = time.Now()
    fmt.Printf("Close %v goroutines cost %vns, avg %vns\n", coroutineNum, end.Sub(start).Nanoseconds(), end.Sub(start).Nanoseconds()/coroutineNum)
}

func main() {
    var coroutineNum, switchTimes int64 = 30000, 1000

    fmt.Printf("### Run: ")
    for _, v := range os.Args {
        fmt.Printf(" \"%s\"", v)
    }
    fmt.Printf("\n")

    if (len(os.Args)) > 1 {
        v, _ := strconv.Atoi(os.Args[1])
        coroutineNum = int64(v)
    }

    if (len(os.Args)) > 2 {
        v, _ := strconv.Atoi(os.Args[2])
        switchTimes = int64(v)
    }

    for i := 1; i <= 5; i++ {
        runTest(i, coroutineNum, switchTimes)
    }
}

同时发布在了: https://gist.github.com/owt5008137/2286768f2586521600c9fd1700cbf845

测试结果如下:

这里都是在我家里的Windows机器下跑的结果,在Linux下应该性能能够更好一些,因为我家里的机器比较渣,并且libcopp在Linux下性能就比在Windows下好得多。那么为了对比,还需要同样在这台机器下,同样环境的libcopp的测试结果。

这里用的是go语言推荐的协程间共享数据的方式,应该是最贴近libcopp的流程了。这里面可以看出来创建chan需要的开销并不大,但是其实goroutine的切换开销还是蛮大的,基本上都要超过1us。而且感觉go语言内部还是维护了goroutine的池子,不然创建开销抖动不会那么大。

不过goroutine的内存开销确实小,30000个goroutine的内存占用才300MB。

libcopp的同环境报告和对比

我只贴一样的协程数量和切换次数的结果了

同样对比下,libcopp的切换开销就小的多了,而且比较稳定,但是创建开销也是比较大,特别是第一次要分配栈的情况下(后面都会使用栈池机制,减少系统调用所以会小很多)。

其实这是v2的测试数据,虽然切换开销比原来是要大一些,但是之前在Linux上的结果,这个创建开销已经是原来版本的一半了(Linux上的创建开销原先大约是1us,v2大约是500ns,切换开销忘记了,v2版本大约是300-400ns)。

结论

go语言现在很火了,性能超过goroutine的话肯定是已经有实用价值了,特别是逻辑开销很容易就能抹平这个协程的开销。而且go语言本来还就是目标于高性能分布式系统的,并且很多这种分布式系统的一些逻辑可能并不特别重,都能容忍这个开销,何况libcopp呢。但是libcopp的v2版本细节上仍然还有一些优化点,比如内存布局和原子操作必导致L1 Cache失效的问题等等。等我一并处理完再merge回master。现在还是放在https://github.com/owt5008137/libcopp的v2分支里。

当然哪位大神有更好的建议希望能够不吝赐教。之前针对缓存优化的点,其实优化好了跑分会很好看,但是实用性上还得分场景。像libcopp的定位是比较重量级的场景,能够覆盖比较完整而且复杂的协程流程和逻辑,对于比较复杂的场景(比如我们游戏里),那些缓存优化就没太大意义。而对于那些简单的只是用来临时做上下文切换而且不要求跨平台跨编译器的,我还是建议使用类似call_in_stack这种轻量级的库,毕竟性能搞了一个数量级。

[3]: https://golang.org

Last updated

Was this helpful?