Golang中从磁盘读取大型切片的最快方法是什么

Golang中从磁盘读取大型切片的最快方法是什么 嗨 - 我在这方面的底层知识有所欠缺,所以希望能得到一些指导。

需求

  1. 我有一个扁平结构体,包含多个 int 和 float64 类型的值。
  2. 我有一个已排序的切片,其中包含大约 1 亿个这样的结构体。
  3. 我想将这些切片缓存到磁盘上以便于重用,因为构建它们的成本很高。
  4. 这是一个一次写入、多次读取的操作,因此读取速度是优先考虑的事项。
  5. 文件将在同一工作站上写入和读取 - 安全性和可移植性不是问题。

迄今为止失败的尝试…

我已经用 gob 让它工作了,但速度慢得令人痛苦。压缩文件也没有太大帮助。

有很多序列化包,其中大多数文档记录都很差。基准测试结果相互矛盾。尝试它们的过程被证明是一种痛苦的经历,而且我怀疑它们无论如何都不是正确的解决方案。

我能直接从内存写入磁盘吗?

考虑到我不需要安全性或可移植性,是否有更直接的方式从内存写入磁盘?

不幸的是,二进制文件超出了我的知识范围,谷歌搜索也毫无结果 - 我找到了一些博客,但代码无法编译。

因此,我非常感谢任何能帮助加快寻找最快解决方案的指点。

附言:经过进一步挖掘,我意识到将整个结构体作为一个整体处理正变得受内存限制。所以我不得不拆分文件。Gob 在处理较小文件时表现相对更好,但我仍在寻找最快的可能途径,因为这现在是我系统中的关键性能瓶颈。


更多关于Golang中从磁盘读取大型切片的最快方法是什么的实战教程也可以访问 https://www.itying.com/category-94-b0.html

14 回复

你的数据已经排序。你可以将其分割成多个文件,只需要在内存中保留文件名以及该文件所包含的数据范围。

如果你不需要进行连接操作,或者任何复杂的查询,这种方法可能是可行的。

更多关于Golang中从磁盘读取大型切片的最快方法是什么的实战系列教程也可以访问 https://www.itying.com/category-94-b0.html


感谢

我怀疑,在我处理数据时,采用一种在后台智能预加载的方法,可能与原始的反序列化速度同等重要。

我有很多核心可以利用,所以我会先专注于这一点,然后如果仍然感觉迟缓,再按照你的建议进行分析。这将给我一个机会深入了解 Go 那些很酷的并发功能!

你是如何读取文件的?是否使用了缓冲读取器来减少内核往返次数?是否分块读取,并可能从不同的偏移量开始并行读取?

也许下面的链接能给你一些关于并行读取的提示。 https://medium.com/swlh/processing-16gb-file-in-seconds-go-lang-3982c235dfa2

明白了。当即使是专用数据库也太慢时,你就需要一个定制化的解决方案(除非你可以通过投入更快的硬件来解决问题)。另一方面,试图比专用数据库更快可能是一项艰巨的任务。

如果你决定使用 HDF5,似乎有一些 Go 包可用

看起来你已经找到了一个可行的解决方案。不过,内存映射文件也值得一看。它们允许处理比可用内存更大的文件。操作系统负责缓存被访问的部分并释放内存。很可能,它比大多数试图缓解相同问题(至少以安全的方式)的解决方案性能更好。bbolt 使用了内存映射文件,并且对于读多写少的场景非常出色。此外,如果能够批量写入,它对于写多读少的场景也同样适用。

go-memdump 看起来非常有趣。它似乎使用 reflect 包进行特定类型的序列化。

一个快速且可能有些天真的想法:数据库能解决问题吗?

  • 你的数据结构统一,因此可以很好地映射到数据库表
  • 数据库读取通常很快,许多数据库系统都针对速度进行了优化
  • 数据库甚至可以为你完成初始的排序工作
  • 像 sqlite(或其 Go 移植版本 modernc.org/sqlite)这样简单的数据库,或者可能只是一个像 bbolt 这样的纯 KV 存储,可能就足够了

这里有两个因素需要考虑。

  1. 从文件中读取实际的字节数据。对于这一点,之前关于使用缓冲读取器并按块读取的建议仍然适用。你需要根据你的系统分析确定最佳的缓冲区/块大小。
  2. 将字节解码为实际的切片。我怀疑这会是 Gob 解码的瓶颈。先进行性能分析,如果 Gob 解码确实是瓶颈,那么也许可以考虑使用类似 GitHub - alexflint/go-memdump: 用于 Go 的非常快速、非常不安全的序列化 这样的工具。要想做得更好,你将不得不使用 unsafe 包来处理原始字节和指针,并编写一个针对你的结构体定制的编码器/解码器。(这并不推荐)

感谢您的回复。

正如我在编辑中所说,我原本计划为了简单起见缓存一个单一的大文件,但测试表明数据集太大,无法完全放入内存。

这个切片是一个时间序列,所以现在的计划是将缓存分成更小的时段,并在处理记录序列时进行预读。

但这仍然需要找出将切片保存到磁盘并读回内存的最有效方法。我强烈怀疑使用 glob 并不是最优方案。

对于这些小文件,glob 比 github.com/vmihailenco/msgpack/v5 快 4 倍,但如果我牺牲可移植性和安全性,难道没有更直接的方法将内存写入磁盘吗?

正如我所说,我不关心写入时间,也不关心磁盘空间。

我唯一感兴趣的是尽可能快地反序列化数据。

克里斯托夫

感谢你的建议。

我之前在另一个语言版本的应用程序迭代中尝试过这个方法。

我处理的是金融逐笔数据,这意味着数据量在数千万到数十亿条记录之间。

我尝试过 SQL 和专门的 OS 时间序列数据库,但它们实在太慢了。使用二进制文件处理要快几个数量级。

这在金融编程社区中被广泛讨论——处理数据是回测中比较棘手的问题之一——几乎所有的个人项目和消费级平台最终都选择使用二进制文件。

大多数只是像我一样自己实现,有些人则使用像 HDF5 这样的科学数据格式。

企业确实使用非常专业的数据库来处理他们数万亿条记录,但许可证费用每年起价超过 10 万美元,所以这不是普通人能承受的……

现在它已经基本完成,并且运行效果令人满意。

我已经按月构建了最终的柱状图数据。一些商业应用程序会按周甚至按天处理,但这似乎有些过度,因为我的内存使用还远未达到极限。

这是一个事件驱动的应用程序,所以我需要将我回测范围内的每个工具的柱状图数据加载到一个单独的切片中,然后按时间戳排序。

主要的瓶颈在于这个追加和排序的过程,但通过缓存可以轻松解决,我只需要做一次。后续的运行速度会快得多。

运行缓存的数据集相当快——我在执行交易规则的同时,在后台将数据预加载到缓冲区中。现在主要的限制是磁盘读取速度。如果这个项目发展起来,我会投资一大块内存,并在RAM磁盘上运行。

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

HT5的优势在于它允许你通过查询来灵活地分析和处理数据。

但一种技术含量较低的方法,简单来说,就是将文件按年份、月份、交易品种和K线类型进行分割。这样你就可以轻松访问所需的任何时间范围数据。

所有主流的消费级平台似乎都采用了这种方法,例如MT5、JForex、Tickblaze、Zorro等。

我在GitHub上找到的几乎所有开源项目都采取了相同的策略。不过,很少有项目是真正完成的——编写交易应用程序绝非易事!

总的来说,我发现简单性胜过复杂性,尤其是在涉及海量数据时。

这不仅体现在数据处理上——我的第一个Java版本应用就因过于复杂而陷入停滞。

我转而使用Go进行第二次迭代,部分原因是它几乎强制你简化设计,这是一种极好的约束。这一次,应用变得简单得多、清晰得多、也快得多,同时功能一样丰富,灵活性却大大增强……

我已经厌倦了Java风格的面向对象编程——我正在回归到将数据和代码分离的方式,就像在面向对象编程成为主流之前我习惯做的那样,并且开始更加享受编码过程。再融入一些函数式编程的思想,你就会感觉动力十足……

更新 - 一个高性能的解决方案

对于其他走这条路的人,以下是我最终采用的方案。

因为我在同一台机器上读写数据,所以我使用了不安全的包 Memdump:

https://github.com/alexflint/go-memdump

与我尝试过的 gob 和安全包相比,它在读取速度上要快得多。

但它会产生巨大的文件,这些文件会占用大量空间,并且从磁盘加载的速度很慢。

因此,我还使用了压缩包 go-quicklz,它是 QuickLZ 算法的一个简洁、干净的单文件实现。它的运行速度比原生压缩快得多,并且我看到了令人印象深刻的压缩比,大约为 25:1。

该包提供两种压缩级别:1 和 3。级别 3 的读取时间稍好一些,但写入速度极其缓慢,所以我选择了级别 1。

https://github.com/dgryski/go-quicklz

理论上,LZ4 算法的基准测试结果稍好一些,但我在使用大文件时,与我能找到的唯一原生 Go 实现遇到了问题。

我使用的是旧硬盘,解压时间少于它节省的磁盘读取时间,而且我现在可以将所有数据都放在磁盘上了。

可能还存在稍微快一点的解决方案,但我怀疑任何性能提升都将是成倍地难以获得,并且这个设置的性能已经优于大多数商业回测应用,并且比某些应用好得多。

感谢这个建议。最终我选择了使用许多小文件——这样更实用、更灵活。

为了开始工作,我拥有140亿个外汇数据点(tick),按交易周和货币对组织成切片。

我有生成器可以将这些数据转换为时间聚合、tick柱、范围柱和各种类型的砖形图(renko bar),并将它们存储到存档中。生成过程运行得足够快,而且是一次写入,多次读取。

目前最大的瓶颈仅仅是将生成的柱状数据从磁盘加载到内存中进行回测。

最大的速度提升来自于切换到了另一个压缩库。

ZSDT是LZ算法作者的一个相当新的算法,对于中等大小的文件(大约40兆字节)来说,它是我发现的最快的。相比我之前的最佳方案,速度提升了300%,这真是个惊喜。

此外,它非常易于使用。我使用的是第1级压缩,这是最快的,并且仍然能提供良好的压缩率。

GitHub - DataDog/zstd: Zstd wrapper for Go

GitHub - DataDog/zstd: Zstd wrapper for Go

Go语言的Zstd包装器。通过在GitHub上创建账户来为DataDog/zstd的开发做出贡献。

虽然它是一个C语言库的包装器,而且我在Windows系统上,但 > go get 命令可以无缝安装它。为Golang点赞!

仅仅切换压缩库就比各种巧妙的缓存方案、作业队列等方法带来了更大的速度提升,尽管那些方法也有一点帮助。

对于读取大型切片,最快的方法是直接使用内存映射文件(mmap)配合二进制序列化。这种方法避免了额外的内存拷贝,并且允许操作系统高效地管理磁盘到内存的加载。

以下是一个完整示例,展示如何将结构体切片直接写入文件,并通过内存映射快速读取:

package main

import (
    "fmt"
    "os"
    "unsafe"
    "golang.org/x/sys/unix"
)

// 确保结构体没有填充,使用紧凑对齐
type Data struct {
    ID    int32
    Value float64
    Count int64
}

func main() {
    // 1. 写入二进制文件
    data := []Data{
        {ID: 1, Value: 100.5, Count: 1000},
        {ID: 2, Value: 200.8, Count: 2000},
    }
    
    // 写入文件
    f, _ := os.Create("data.bin")
    defer f.Close()
    
    // 将切片转换为字节数组
    header := (*reflect.SliceHeader)(unsafe.Pointer(&data))
    header.Len *= int(unsafe.Sizeof(Data{}))
    header.Cap *= int(unsafe.Sizeof(Data{}))
    bytes := *(*[]byte)(unsafe.Pointer(header))
    
    f.Write(bytes)
    
    // 2. 使用mmap读取文件
    readMmap("data.bin")
}

func readMmap(filename string) []Data {
    // 打开文件
    f, _ := os.Open(filename)
    defer f.Close()
    
    // 获取文件大小
    fi, _ := f.Stat()
    size := fi.Size()
    
    // 内存映射
    data, err := unix.Mmap(int(f.Fd()), 0, int(size), unix.PROT_READ, unix.MAP_SHARED)
    if err != nil {
        panic(err)
    }
    defer unix.Munmap(data)
    
    // 将字节切片转换回Data切片
    var d Data
    sliceHeader := &reflect.SliceHeader{
        Data: uintptr(unsafe.Pointer(&data[0])),
        Len:  int(size) / int(unsafe.Sizeof(d)),
        Cap:  int(size) / int(unsafe.Sizeof(d)),
    }
    
    return *(*[]Data)(unsafe.Pointer(sliceHeader))
}

如果需要更简单的方案,可以使用标准库的二进制读写:

package main

import (
    "encoding/binary"
    "fmt"
    "os"
)

func writeBinary(filename string, data []Data) error {
    f, err := os.Create(filename)
    if err != nil {
        return err
    }
    defer f.Close()
    
    // 写入数据长度
    binary.Write(f, binary.LittleEndian, int64(len(data)))
    
    // 批量写入所有结构体
    for i := range data {
        binary.Write(f, binary.LittleEndian, data[i])
    }
    return nil
}

func readBinary(filename string) ([]Data, error) {
    f, err := os.Open(filename)
    if err != nil {
        return nil, err
    }
    defer f.Close()
    
    // 读取数据长度
    var count int64
    binary.Read(f, binary.LittleEndian, &count)
    
    // 预分配切片
    data := make([]Data, count)
    
    // 批量读取所有结构体
    binary.Read(f, binary.LittleEndian, &data)
    return data, nil
}

关键优化点:

  1. 使用内存映射避免数据拷贝
  2. 确保结构体字段顺序和大小固定(使用int32而不是int
  3. 批量读写而非逐个处理
  4. 考虑使用sync.Pool重用缓冲区

对于1亿个结构体,建议分块处理(例如每1000万个结构体一个文件),这样内存映射可以更高效地工作。

回到顶部