Golang中双端队列性能不如Rust的问题能解决吗?

Golang中双端队列性能不如Rust的问题能解决吗? 大家好。我是Go语言的新手,非常喜欢我所看到的一切。我重视简洁性,并且觉得在这里会很自在。

但是我在项目早期遇到了一个性能问题,希望能得到一些建议。

使用场景

这是一个基于事件的金融回测应用程序,它将处理数百万到数十亿的报价和K线数据流,并且需要通过计算密集型的优化来跟踪数百个指标计算。

因此,我需要一个高性能的移动窗口来存储最近x个指标结果,其中x的范围大约从10到1000。

Rust基准测试:290毫秒

在尝试Rust时,其标准库提供了VecDeque结构,支持push_back、pop_front和通过索引访问队列内部值。

对于一个包含10个整数的VecDeque进行100,000,000次迭代,运行时间为290毫秒。

Go基准测试:1.2秒

我在Go中没有看到类似的数据结构,所以我用切片基准测试了一个简单的方法:

p := []int{1,1,1,1,1,1,1,1,1,1,}

for i := 0; i < 100_000_000; i++ {
	p = p[1:]
	p = append(p, 10)
}

这运行了1.2秒,比Rust慢了整整4倍。

出于某种原因,使用固定长度的切片更慢,为1.4秒。

从好的方面看,Go比我在C#中的基准测试快了近5倍!

我尝试了几个Go中专门的deque库,以了解可能达到的性能,尽管它们不支持通过索引访问内部值。

拥有500颗星的oleiade/lane库耗时高达14秒,表现不佳。

另一个声称高度优化但不太知名的库给了我1.2秒的结果——并不比我的切片实现更好。

我真的必须接受4倍的速度下降吗?

我意识到使用GC语言我会以性能为代价换取开发的便利性。但这似乎是一个低分配操作,所以我对性能差距之大感到惊讶。

如果我使用Go构建我的应用程序,我真的必须接受4倍的速度下降吗?

这实际上是我用这种语言写的第一段代码,我对内部机制的理解还很模糊,所以我希望我忽略了某些可以带来速度提升的东西。


更多关于Golang中双端队列性能不如Rust的问题能解决吗?的实战教程也可以访问 https://www.itying.com/category-94-b0.html

11 回复

我不是计算机科学专业的,但你有没有看过 ring 包 - container/ring - pkg.go.dev

更多关于Golang中双端队列性能不如Rust的问题能解决吗?的实战系列教程也可以访问 https://www.itying.com/category-94-b0.html


哦,哇,看来这个VecDeque比一开始看起来要复杂得多。毕竟,那几千行代码的存在肯定是有原因的!

我明白你的意思了,是的,如果你有内存可以预先分配,那为什么不这样做呢。

我不是计算机科学专家,但我想尝试回答一下。当你说:

所以我需要一个高性能的移动窗口来存储最近的 x 个指标结果,其中 x 的范围大约从 10 到 1000。

如果你有一个包含 10 个元素的窗口,然后添加了第 11 个元素,集合的大小会增长到 11,还是最旧的元素会被新元素覆盖,从而保持大小为 10?

欢迎来到 Go 语言和 Go 论坛。

你很可能说对了,是垃圾回收器的问题。这段代码……

	p = p[1:]
	p = append(p, 10)
}

……不断地在切片的一端缩短它,又在另一端扩展它。因此,append() 操作经常需要分配新的内存。

环形缓冲区肯定会快得多,因为它在循环内部不需要进行任何内存分配。

你好 Christoph,

我最近一直在研究。

我最初的想法是将 VecDeque 移植到 Go,但结果发现它有数千行代码,分布在 8 个文件中,所以这不太可能实现。

然后我深入研究了 container/ring 库,它更像是一种链表,而不是真正的环形缓冲区。遗憾的是,我们之前使用的方式不对——即使正确使用,它的速度实际上也相当慢。

之后我又尝试了几个声称高效的环形缓冲区,但它们同样很慢。

所以我想,我还是采用我那个简单的想法:预先分配所有内存。有时候简单就是最好的,而且我看不出有什么明显的缺点——我有足够的内存。

如果我把它封装在一个 API 后面,我就可以忽略内部的复杂性,专心开发我的应用程序了!

和你一样,我原本以为环形缓冲区会更快,但正如我所说,我尝试了几个库,其中一个慢得离谱,而另一个并不比切片实现更好。

我曾希望切片能在更大的已分配内存块内部工作。你的意思是说它会在每次 append() 时都进行分配吗?这看起来效率非常低?

肯定有更好的方法来做这件事吧?不幸的是,我是一个高级业务线风格的开发者,所以并没有自己编写算法的背景知识。有人能给我一些指点吗(双关语)?

这对我来说有点关键——如果我找不到提速的方法,可能不得不重新考虑是否迁移到 Go。那将是一个遗憾,因为我非常喜欢这门语言背后的哲学。

func main() {
    fmt.Println("hello world")
}

就像你一样,我原本也认为环形缓冲区会更快,但正如我所说,我尝试了几个库,其中一个慢得离谱,而另一个并不比切片实现更好。

这些库中有实现环形缓冲区的吗?

为什么不自己实现一个环形缓冲区呢?这并不太复杂,而且你可以内置你需要的功能。

编辑补充: 队列代码示例 - Queue/Definition - Rosetta Code

再次编辑补充: 上述链接背后的代码实际上是一个可以增长的环形缓冲区。一个固定大小的环形缓冲区会更简单。

你是说它在每次 append() 时都会分配内存。这看起来效率很低?

不,append() 在内存分配方面实际上相当智能。每次分配时,分配的内存量会翻倍,因此当切片线性增长时,对新分配的需求会呈指数级减少。尽管如此,每次分配都包括将现有数据从旧切片复制到新切片,我猜随着时间的推移这会累积起来。

func main() {
    fmt.Println("hello world")
}
import (
	"container/ring"
	"testing"
)

const rLen = 10

func BenchmarkRing(b *testing.B) {
	r := ring.New(rLen)
	for i := 0; i < b.N; i++ {
		r.Move(1)
		r.Value = 10
	}
}

func BenchmarkSliceDeque(b *testing.B) {
	p := make([]int, rLen)
	for i := 0; i < b.N; i++ {
		p = p[1:]
		p = append(p, 10)
	}
}

我认为这两段代码做的是同样的事情,不是吗?

结果:

goos: linux
goarch: amd64
pkg: silly
cpu: Intel(R) Core(TM) i5-6500 CPU @ 3.20GHz
BenchmarkRing-4         	751359330	         1.502 ns/op	       0 B/op	       0 allocs/op
BenchmarkSliceDeque-4   	187221034	         6.445 ns/op	      16 B/op	       0 allocs/op
PASS

帕特里西奥

感谢你的建议!我错过了那个环形包,因为它的名字相当不常规。

性能令人印象深刻——与 Rust 的 VecDeque 相当。

问题是,与 VecDeque 以及大多数这类环形结构不同,它不提供对内部值的访问,只提供对第一个和最后一个值的访问。正如我在帖子中所说,访问内部值是一个要求。我确实运行了其他几个环形双端队列,但只是为了看看有多大区别。

有没有办法在 Go 中编写一个性能类似的环形结构,让我能够访问其他值?D 和 Nim 也有类似 VecDeque 的快速结构,所以这一定是一个广为人知的算法。

另外,我想出了一个变通方法。它很粗糙但有效——我只需在开始时分配所有需要的内存。显然,这在内存使用上很浪费,但我在一台配置良好的工作站上运行,并且一个概念验证在内存监视器上几乎看不出变化。这也让我能相当高效地访问内部值。

这比 Rust 只慢 1.3 倍,这比我最初尝试的 4 倍减速更容易接受。

简单地通过索引从左到右填充切片并没有更快,而且使用起来不太方便。

我可以接受这个方案,但仍然希望能得到关于更优雅解决方案的任何指点。

基准测试:Go 中 390 毫秒 vs Rust 中 290 毫秒

// 为了维护一个长度为 10 的移动窗口

p := make([]int, 100_000_010)

for i := 0; i < 100_000_000; i++ {
	p[10] = i
	p = p[1:]
}

一个高性能的环形缓冲区,能快速访问内部值

很高兴你提出这个问题,因为你激发了一个想法。

大多数双端队列和环形缓冲区似乎都使用了复杂的算法。

例如,粗略一看,Rust 的 VecDeque 大约有 3000 行代码——不过公平地说,它是一个非常强大的数据结构,比我能在 Go 标准库或 Github 上找到的任何东西都要快得多。

但是,如果你只做最简单的事情会怎样呢?

假设你有一个包含 1 亿个结果的流,你想保留最后 10 个值,将最新的值推入尾部,并从头部弹出最旧的值:

最简单的方法是建立一个 10 个槽位的数组,并在超过末尾时回绕。

内部值的位置可以根据尾部的位置轻松计算出来。

00 01 02 03 04 05 06 07 08 09 | tail = [9] 10 01 02 03 04 05 06 07 08 09 | tail = [0] 10 11 02 03 04 05 06 07 08 09 | tail = [1]

这个窗口化数据结构是我的金融回测应用的关键性能瓶颈,该应用将是数据密集型的。每次运行将涉及数十亿次操作。

这个想法内存效率高且零分配,所以我想尝试一下。

结果证明,它的速度是 Rust VecDeque 的 2 倍以上,几乎是我最初简单切片解决方案速度的 9 倍。

(不知道为什么这不是任何预先知道长度的缓冲区的常见解决方案。也许计算机科学学位会诱使你运用数学智慧,把事情过度复杂化?)

这确实表明,在合理范围内,设计胜过语言和编译器……

基准测试:处理 1 亿个项目耗时 135 毫秒,而 Rust VecDeque 耗时 290 毫秒

这里有一个简单的概念验证。在一个为纯函数设计的实现中,你可能会将长度和尾部位置存储在数组末尾的槽中,这样数据就是自包含的。

package main

import (
	"fmt"
	"time"
)

func main() {

	//====================================
	start := time.Now()
	//====================================

	// 基于数组的环形缓冲区,最新的值在尾部

	// 非常快:处理 100,000,000 个项目耗时 135 毫秒
	// Rust VecDeque 是 290 毫秒

	const l = 10 // 长度
	var d[l]int  // 数据
	t := l -1    // 尾部

	for i := 0; i < 100_000_027; i++ {

		// 递增尾部
		if t == l - 1 {
			t = 0
		} else {
			t++
		}

		// 覆盖最旧的值
		d[t] = i
	}

	//====================================
	elapsed := time.Since(start)
	fmt.Printf("Elapsed: %s\n", elapsed)
	//====================================

	fmt.Printf("Data: %d\n", d)
	fmt.Printf("Tail: %d", t)
}

输出:

Elapsed: 132.3204ms
Data: [100000020 100000021 100000022 100000023 100000024 100000025 100000026 100000017 100000018 100000019]
Tail index: 6

在Go中实现高性能双端队列确实有优化空间。以下是几种优化方案:

1. 环形缓冲区实现(推荐)

type Deque struct {
    data     []int
    head     int
    tail     int
    size     int
    capacity int
}

func NewDeque(capacity int) *Deque {
    return &Deque{
        data:     make([]int, capacity),
        capacity: capacity,
    }
}

func (d *Deque) PushBack(val int) {
    if d.size == d.capacity {
        d.resize()
    }
    d.data[d.tail] = val
    d.tail = (d.tail + 1) % d.capacity
    d.size++
}

func (d *Deque) PopFront() int {
    if d.size == 0 {
        panic("deque is empty")
    }
    val := d.data[d.head]
    d.head = (d.head + 1) % d.capacity
    d.size--
    return val
}

func (d *Deque) At(idx int) int {
    if idx < 0 || idx >= d.size {
        panic("index out of range")
    }
    return d.data[(d.head+idx)%d.capacity]
}

func (d *Deque) resize() {
    newCap := d.capacity * 2
    newData := make([]int, newCap)
    
    for i := 0; i < d.size; i++ {
        newData[i] = d.At(i)
    }
    
    d.data = newData
    d.head = 0
    d.tail = d.size
    d.capacity = newCap
}

2. 预分配切片+索引追踪

type FastDeque struct {
    data []int
    start int
    end   int
}

func NewFastDeque(initialSize int) *FastDeque {
    return &FastDeque{
        data:  make([]int, initialSize),
        start: 0,
        end:   0,
    }
}

func (d *FastDeque) PushBack(val int) {
    if d.end == len(d.data) {
        // 扩展逻辑
        d.grow()
    }
    d.data[d.end] = val
    d.end++
}

func (d *FastDeque) PopFront() int {
    val := d.data[d.start]
    d.start++
    // 定期压缩
    if d.start > len(d.data)/2 {
        d.compact()
    }
    return val
}

func (d *FastDeque) At(idx int) int {
    return d.data[d.start+idx]
}

func (d *FastDeque) grow() {
    newSize := len(d.data) * 2
    newData := make([]int, newSize)
    copy(newData, d.data[d.start:d.end])
    d.end = d.end - d.start
    d.start = 0
    d.data = newData
}

func (d *FastDeque) compact() {
    copy(d.data, d.data[d.start:d.end])
    d.end = d.end - d.start
    d.start = 0
}

3. 使用sync.Pool减少分配

type PoolDeque struct {
    chunks [][]int
    chunkSize int
    headChunk int
    headIdx   int
    tailChunk int
    tailIdx   int
    pool      sync.Pool
}

func NewPoolDeque(chunkSize int) *PoolDeque {
    return &PoolDeque{
        chunkSize: chunkSize,
        pool: sync.Pool{
            New: func() interface{} {
                return make([]int, chunkSize)
            },
        },
    }
}

func (d *PoolDeque) PushBack(val int) {
    if d.tailChunk >= len(d.chunks) {
        d.chunks = append(d.chunks, d.pool.Get().([]int))
    }
    
    chunk := d.chunks[d.tailChunk]
    if d.tailIdx == len(chunk) {
        d.tailChunk++
        d.tailIdx = 0
        if d.tailChunk >= len(d.chunks) {
            d.chunks = append(d.chunks, d.pool.Get().([]int))
        }
        chunk = d.chunks[d.tailChunk]
    }
    
    chunk[d.tailIdx] = val
    d.tailIdx++
}

func (d *PoolDeque) PopFront() int {
    chunk := d.chunks[d.headChunk]
    val := chunk[d.headIdx]
    d.headIdx++
    
    if d.headIdx == len(chunk) {
        d.pool.Put(chunk[:0])
        d.headChunk++
        d.headIdx = 0
    }
    
    return val
}

4. 基准测试对比

func BenchmarkRingBuffer(b *testing.B) {
    deque := NewDeque(1000)
    for i := 0; i < 10; i++ {
        deque.PushBack(1)
    }
    
    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        deque.PopFront()
        deque.PushBack(10)
    }
}

func BenchmarkSliceWindow(b *testing.B) {
    window := make([]int, 0, 1000)
    for i := 0; i < 10; i++ {
        window = append(window, 1)
    }
    
    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        // 更高效的滑动窗口
        if len(window) >= 1000 {
            window = window[1:]
        }
        window = append(window, 10)
    }
}

性能优化要点:

  1. 避免频繁内存分配:使用环形缓冲区或预分配大块内存
  2. 批量操作:考虑批量处理数据而不是单个操作
  3. 使用固定大小窗口:如果窗口大小固定,使用数组而不是切片
  4. 禁用边界检查:在关键循环中使用//go:nobounds指令
func (d *Deque) FastPopFront() int {
    //go:nobounds
    val := d.data[d.head]
    d.head = (d.head + 1) % d.capacity
    d.size--
    return val
}

通过上述优化,Go双端队列性能可以接近Rust的VecDeque,差距可缩小到1.5-2倍以内。对于金融回测场景,环形缓冲区实现通常是最佳选择。

回到顶部