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
我不是计算机科学专业的,但你有没有看过 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 后面,我就可以忽略内部的复杂性,专心开发我的应用程序了!
就像你一样,我原本也认为环形缓冲区会更快,但正如我所说,我尝试了几个库,其中一个慢得离谱,而另一个并不比切片实现更好。
这些库中有实现环形缓冲区的吗?
为什么不自己实现一个环形缓冲区呢?这并不太复杂,而且你可以内置你需要的功能。
编辑补充: 队列代码示例 - 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)
}
}
性能优化要点:
- 避免频繁内存分配:使用环形缓冲区或预分配大块内存
- 批量操作:考虑批量处理数据而不是单个操作
- 使用固定大小窗口:如果窗口大小固定,使用数组而不是切片
- 禁用边界检查:在关键循环中使用
//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倍以内。对于金融回测场景,环形缓冲区实现通常是最佳选择。


