如何一步步提升Go内存缓存性能

本文详述了从v1.0.5到v1.1.0的Go内存缓存性能提升过程,包括读取性能从100ns/op优化到40ns/op,以及减少GC耗时的策略。通过优化读取策略、减少临时对象和改进双链表结构,实现了性能的显著提升。此外,还解决了时间戳问题和并发安全性问题。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

本文记录了ecache v1.0.5到v1.1.0的性能优化过程

背景介绍

  • ecache是一款极简设计、高性能、并发安全、支持分布式一致性的轻量级内存缓存,支持LRU和LRU-2两种模式

  • 项目地址:https://github.com/orca-zhang/ecache

准备工作

原则

  • 基于真实的度量。——《重构——改善现有代码的设计》P69

哪怕你完全了解系统,也请实际度量它的性能,不要臆测。臆测会让你学到一些东西,但十有八九你是错的。

思路

  • 我期望能够有一个仓库,每次优化以后,都能横向比较同类库之间的性能,并且通过直观的柱状图之类的图表展示出来,于是有了benchplus/gocache项目,它是一个持续基准测试的项目。

  • 第一版我设计了写入和读取整型、写入1K/1M数据、写入小对象(bigcachefreecache需要序列化)、写满以后继续写入整型等用例。第二版又增加了并发读写、GC耗时、命中率、内存占用等用例。

工具

  • golang pprof

  • graphivs(用来生成剖析结果图片)

    • mac下安装命令:brew install graphviz

步骤

  1. 运行一次ecache的测试用例

    sh> GO111MOUDLE=off go test -bench=BenchmarkGetInt_ecache ecache_test.go -cpuprofile=cpu.prof

  2. 剖析结果文件

    sh> go tool pprof benchplus.test cpu.prof
    交互模式下:(pprof) svg

  3. 分析生成的svg图

优化一:读取性能(从100ns/op到40ns/op)

  • 总体还是比较符合预期的,毕竟在性能方面已经有所考量和侧重,但是在最初的测试中,优势依然不是特别明显,比如读取性能,最快的bigcache读取整型值的性能在 80ns/op 左右,但是ecache在第一版只能跑出 100ns/op 左右的性能。

hashCode占了总耗时的50%

3a52b1b82365680262ed7a695d053162.png
  • 分析剖析结果,发现大部分时间花在了string[]byte产生临时对象的产生和销毁上

  • 优化思路:换一种hash方法,按照以前的经验,BKRD和AP的分布性比较好,BKRD实现更简单,性能也不错,所以选择BKRD替代CRC32【commit-0e7aaaae】

    func hashBKRD(s string) (hash int32) {
        for i := 0; i < len(s); i++ {
            hash = hash*131 + int32(s[i])
        }
        return hash
    }

继续剖析——time.Now()占了总耗时的33%

872a77d66e86296931714a4c332f3788.png
  • 优化思路:由于内部只需要时间戳,并且缓存系统要求的时间戳并不一定那么精准,所以考虑用维护一个全局时间戳的方式来优化————短期自增(每100ms)、定期校准(约1s)

  • time.Now()【代码版本快照】改为内部计时器【commit-8dc1fa7d】,获取当前时间使用内部的now()方法可直接获得时间戳,而不再需要使用会产生临时对象的time.Now().UnixNano()

  • 内部计时器最初采用time.Timer实现,实际测试发现定时器会受系统压力影响,精度无法保证,后改为time.Sleep【commit-92245e4b】

    var clock = time.Now().UnixNano()
    
    func now() int64 { return atomic.LoadInt64(&clock) }
    
    func init() {
        go func() {
            for {
                atomic.StoreInt64(&clock, time.Now().UnixNano()) // 每秒校准
                for i := 0; i < 9; i++ {
                    time.Sleep(100 * time.Millisecond)
                    atomic.AddInt64(&clock, int64(100*time.Millisecond))
                }
                time.Sleep(100 * time.Millisecond)
            }
        }()
    }
  • 本次优化完成以后,读取整型性能提升至40ns/op,从设计的指标来看,ecache的数据都已名列前茅

78616cc068124f987c07620e303bdbe2.png

优化二:GC耗时(从3倍耗时到超越)

  • 虽然通过bigcache提供的bench,得到的数据比bigcache本身要好(后分析可能是因为在平时写入时把GC耗时分担到了总耗时,而bench里没有总耗时统计),但是随后又添加的并发读写测试和GC测试中发现ecache优势不明显,比如写整型值的GC耗时是当时最快的bigcache(80ms左右)的2倍多(200ms左右),写1K数据的GC耗时是当时最快的freecache的3倍多。

  • 从剖析结果来看,重点方向在三个方面

    • 减少临时对象产生

    • 减少栈对象逃逸到堆(避免返回指针)

    • interface性能较差(存储小对象时,相比拷贝没有优势)

针对双链表的改进思路

  • 双链表节点实现成不需要产生临时节点指针的形式

    • 用一次性预分配的连续区域存储节点

    • 用索引列表来表达双链表

      type node struct {
          k        string
          v        value
          expireAt int64 // 纳秒时间戳,为0说明被标记删除
      }
      type cache struct {
          dlnk [][2]uint16       // 双链表索引列表,第0个元素存储{尾节点索引,头节点索引},其他元素存{前序节点索引,后继节点索引}
          m    []node            // 预分配连续空间内存
          hmap map[string]uint16 // <key,dlnk中的位置>
          last uint16            // 没有满时,分配到的位置
      }
  • 一些取巧的设计

    • 只用一个last字段和连续节点空间的容量比较来判断是否分配满

      if c.last == uint16(cap(c.m)) // 分配满了
    • 用uint16类型存储索引,节省空间的同时,配合桶的数量,足够大

    • dlnkn+1个元素来存储索引,每个元素都是{前序节点索引, 后继节点索引}

    • 索引为0代表空,刚好dlnk[0]存储的是{尾节点索引,头节点索引}

    • 因为头尾节点和其他节点存储在一起,复用adjust方法,通过参数就能实现将元素移动到头部还是尾部的功能

      • ajust(x, p, n)移动到头部

      • ajust(x, n, p)移动到尾部

    • 删除元素时复用时间戳,设置为0代表删除,并且移动到链表尾部

  • 调整完效果还不错,mallogc缩短了、_refresh时候的gcWriteBarrier也不见了

24b228042650742260f1277c67365e5c.png

进一步优化

  • interface的问题还没有解决,尝试直接用int64存储value,性能好很多,比bigcache要快,但是这并不是ecache设计的初衷,我们期望能够适应不同场景,并且能存储不同类型的对象

  • 先尝试用一个包装器把interface类型和int64类型分开放置

    type value struct {
        v *interface{} // 存放任意类型
        i int64        // 存放整型
    }
  • 但是性能差很多,剖析发现是包装以后的临时对象太多,于是尝试用1000大小的ringbuffer实现了一个对象池,优化了分配性能,结果能和bigcache相同了,感兴趣的可以了解一下源码

  • 不过最终没有使用,因为灵机一动,发现nodevalue字段,不用对象指针(单纯栈对象拷贝赋值)和用指针加ringbuffer性能是一样的(好险!差点就变复杂了😅)

还差最后一步

  • 整型的耗时问题优化完了,还有freecache写入1K的问题不是吗,我一直在想,他为什么能这么快,甚至还看了他的源码,不过偷师没成

  • 经历了将近一整天的各种优化(尝试使用reflect2判断类型;cacheline优化)都没效果,差点就放弃了,终于找到了解决方案————用[]byte类型直接接收!(PS:似曾相识的套路)

    type value struct {
        i *interface{} // 存放任意类型
        b []byte       // 存放字节数组
    }
  • 测试结果很理想,总耗时和GC耗时都超越了最快的freecache,PS:不过也是trade-off,只是较大的对象在ecacheGC上消耗的时间没有freecache拷贝消耗的时间多而已

  • 最后把整型也用encoding/binary.LittleEndian.PutUint64合并进了[]byte,内存占用一样,性能稍慢一点点

其他改进

  • 时间戳原来记录的是写入时间,群友review提出了时间回跳可能会有问题,改为expireAt过期的时间点,保证一定会在设置的过期时间内过期

  • 仔细检查并发场景下node复用可能导致取到错误值的情况

优化结果

e05eb87c58cd15b0fa6efab899f9f883.png

🐌 代表很慢,✈️ 代表快,🚀 代表非常快,可以看到优化以后的ecache,各项测试表现都不错(除大量并发写入整型的GC耗时无法超过bigcache外)。


bigcachecachegoecachefreecachegcachegocache
PutInt✈️
🚀🚀✈️✈️
GetInt✈️✈️🚀
✈️✈️
Put1K✈️✈️🚀🚀🚀✈️
Put1M🐌
🚀🐌✈️✈️
PutTinyObject✈️
🚀🚀✈️
ChangeOutAllInt✈️
🚀🚀✈️✈️
HeavyReadInt🚀🚀🚀

🚀
HeavyReadIntGC✈️🚀🚀
✈️✈️
HeavyWriteInt🚀✈️🚀🚀
✈️
HeavyWriteIntGC🚀
✈️✈️

HeavyWrite1K🐌✈️🚀🚀
✈️
HeavyWrite1KGC🐌✈️🚀🚀
✈️
HeavyMixedInt🚀✈️🚀
✈️🚀

版本对比

基线版本v1.0.5 vs 优化版本v1.1.0:(https://github.com/orca-zhang/ecache/tree/68f69ca9cf3043bf3a9853d320389252e81310b9) vs (https://github.com/orca-zhang/ecache/tree/1cb426fe021959eacb86a675bebff69a2b430b6d

参考资料

  • TinyLFU: A Highly Efficient Cache Admission Policy:https://arxiv.org/abs/1512.00727

  • 设计实现高性能本地内存缓存:https://blog.joway.io/posts/modern-memory-cache/

  • LRU算法及其优化策略——算法篇:https://juejin.cn/post/6844904049263771662

<think>嗯,用户问的是为什么在使用Ollama运行DeepSeek 1.5B模型时,64核的CPU可以正常运行,但换成96核的时候反而会卡死。我需要先理解可能的原因,然后一步步分析。 首先,Ollama是基于Go语言开发的,用来在本地运行大语言模型。DeepSeek 1.5B是一个相对较小的模型,但可能对计算资源有一定要求。用户提到的问题似乎与CPU核心数有关,所以需要从多线程、资源分配、并行计算效率等方面考虑。 可能的原因有几个方面: 1. **多线程效率问题**:模型推理可能使用了多线程并行计算。增加核心数理论上可以提升性能,但超过某个阈值后,线程调度、同步的开销可能超过并行带来的收益,导致性能下降甚至卡死。比如,Amdahl定律指出并行部分的加速受限于串行部分的比例。当核心数过多,可能反而因为同步开销导致效率降低。 2. **内存带宽或缓存竞争**:更多的核心同时运行可能导致内存带宽成为瓶颈。每个核心需要访问内存时,如果带宽不足,就会导致延迟增加,尤其是在高并发的情况下。此外,缓存一致性协议(如MESI)在高核心数下可能导致更多的竞争和通信开销,影响整体性能。 3. **Ollama或底层库的线程管理问题**:Ollama可能使用的底层库(如GGML、LLAMA.cpp)在处理过多线程时存在bug或配置不当。例如,线程池的初始化可能存在问题,或者在分配任务时出现死锁或资源争用。 4. **系统资源限制**:当使用96核时,系统可能因为其他进程或资源(如内存、IO)不足导致卡死。需要检查系统监控数据,比如内存使用情况、CPU占用率、是否有交换(swapping)发生等。 5. **NUMA架构的影响**:在具有多个NUMA节点的服务器上,跨节点访问内存的延迟较高。如果线程被分配到不同的NUMA节点,可能导致性能下降。64核可能在一个NUMA节点内,而96核跨越了多个节点,导致内存访问延迟增加。 6. **软件配置限制**:某些库或框架可能对最大线程数有限制,超过限制后出现异常。比如,OpenMP可能设置线程数上限,或者Ollama本身对线程数有预设值,用户需要手动调整。 接下来需要验证这些可能性: - **检查线程数配置**:用户是否手动设置了线程数?Ollama可能默认使用所有可用核心,但实际使用中可能需要限制线程数。例如,使用环境变量如`OMP_NUM_THREADS`来控制OpenMP的线程数量。 - **监控资源使用情况**:在运行模型时,使用`htop`、`top`或`perf`工具观察CPU、内存的使用情况,是否有内存不足、交换频繁发生,或者某些核心处于高负载而其他空闲的情况。 - **NUMA配置**:如果是多路CPU系统,检查NUMA节点的分布情况。使用`numactl`命令来绑定进程到特定的NUMA节点,或者调整内存分配策略,可能改善性能。 - **软件版本和兼容性**:确认Ollama和相关库的版本是否存在已知问题,尤其是在高核心数环境下的兼容性问题。可能需要更新到最新版本或应用补丁。 - **日志和错误信息**:查看Ollama的运行日志,是否有错误提示或异常抛出。可能涉及内存分配失败、线程创建失败等。 可能的解决方案包括: 1. **限制线程数**:即使有96核,强制设置使用较少的线程,比如32或64,测试是否解决问题。例如,在启动Ollama前设置`export OMP_NUM_THREADS=64`。 2. **调整NUMA策略**:使用`numactl`命令将进程绑定到特定节点,减少跨节点内存访问。 3. **升级软件版本**:确保Ollama、GGML库等是最新版本,可能修复了高并发下的问题。 4. **检查系统配置**:确保内存足够,没有其他进程占用过多资源,关闭不必要的服务。 5. **测试不同模型规模**:确认是否只有DeepSeek 1.5B存在此问题,其他模型是否正常,以排除模型本身的问题。 总结的话,可能的核心问题在于高核心数带来的并行效率下降,资源竞争,或软件本身的线程管理缺陷。需要通过实验和监控逐步排查。</think>在使用 Ollama 运行 DeepSeek 1.5B 模型时,64 核 CPU 正常而 96 核卡死的原因可能与以下因素相关,需逐步排查: --- ### **1. 并行计算效率瓶颈** - **多线程开销增加**:模型推理通常依赖多线程并行加速,但线程数超过一定阈值后,线程调度、同步和通信的开销可能超过并行带来的收益。96 核可能导致**锁竞争**或**负载不均衡**,反而降低效率。 - **Amdahl 定律限制**:模型代码中可能存在无法完全并行的部分(如数据预处理),导致核心数增加后的收益递减甚至负优化。 --- ### **2. 内存带宽与缓存竞争** - **内存带宽瓶颈**:更多核心同时访问内存时,若带宽不足(尤其是 DDR4/DDR5 的带宽限制),会导致延迟飙升。96 核的并发请求可能超出内存控制器的处理能力。 - **缓存失效频繁**:LLM 推理需要频繁读取模型权重,若缓存(L3 Cache)被多核争抢,命中率下降,性能急剧恶化。 --- ### **3. NUMA 架构影响** - **跨节点访问延迟**:96 核 CPU 可能是多路(Multi-Socket)或 NUMA 架构(如双路 48 核)。若线程分布在多个 NUMA 节点,跨节点内存访问延迟显著增加(可能达 2-3 倍),导致卡顿。 - **解决方案**:使用 `numactl` 绑定进程到单一 NUMA 节点,或通过 BIOS 调整内存访问模式。 --- ### **4. 软件配置与限制** - **线程数超限**:Ollama 底层可能依赖 OpenMP 或类似库,默认线程数可能被设置为核心总数。可尝试手动限制线程数: ```bash export OMP_NUM_THREADS=64 # 限制为 64 线程 ollama run deepseek-1.5b ``` - **框架缺陷**:某些版本的 GGML/LLAMA.cpp 在高并发时存在资源泄漏或死锁问题,需升级至最新版本。 --- ### **5. 系统资源争用** - **内存不足/OOM**:检查是否因进程过多导致物理内存耗尽,触发交换(Swap)。使用 `free -h` 或 `htop` 监控内存使用。 - **其他进程干扰**:确保无后台任务(如杀毒软件、日志服务)占用大量资源。 --- ### **排查步骤建议** 1. **监控硬件指标**: - 使用 `perf stat` 或 `vmstat` 分析 CPU 利用率、缓存命中率、内存带宽。 - 通过 `numastat` 检查 NUMA 内存分布是否均衡。 2. **调整线程配置**: - 逐步增加线程数(如 32→64→96),观察性能拐点。 3. **简化测试环境**: - 关闭超线程(Hyper-Threading),排除逻辑核心干扰。 - 在纯净系统(无其他进程)中复现问题。 --- ### **总结** 96 核卡死更可能是**高并发下的资源竞争或框架缺陷**所致,而非单纯算力不足。优先检查内存带宽、NUMA 配置,并限制线程数为物理核心数的 50%~75%(如 64 核),通常能显著改善稳定性。如果问题依旧,建议提交详细日志至 Ollama 社区以进一步诊断。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值