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

本文记录了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

步骤

  • 运行一次ecache的测试用例

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

  • 剖析结果文件

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

  • 分析生成的svg图

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

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

hashCode占了总耗时的50%

image
  • 分析剖析结果,发现大部分时间花在了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%

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

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

  • 内部计时器最初采用time.Timer实现,实际测试发现定时器会受系统压力影响,精度无法保证,后改为time.Sleepcommit-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的数据都已名列前茅
    image

优化二: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也不见了

    image

进一步优化

  • 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复用可能导致取到错误值的情况

优化结果

image

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

bigcache cachego ecache freecache gcache gocache
PutInt ✈️ 🚀 🚀 ✈️ ✈️
GetInt ✈️ ✈️ 🚀 ✈️ ✈️
Put1K ✈️ ✈️ 🚀 🚀 🚀 ✈️
Put1M 🐌 🚀 🐌 ✈️ ✈️
PutTinyObject ✈️ 🚀 🚀 ✈️
ChangeOutAllInt ✈️ 🚀 🚀 ✈️ ✈️
HeavyReadInt 🚀 🚀 🚀 🚀
HeavyReadIntGC ✈️ 🚀 🚀 ✈️ ✈️
HeavyWriteInt 🚀 ✈️ 🚀 🚀 ✈️
HeavyWriteIntGC 🚀 ✈️ ✈️
HeavyWrite1K 🐌 ✈️ 🚀 🚀 ✈️
HeavyWrite1KGC 🐌 ✈️ 🚀 🚀 ✈️
HeavyMixedInt 🚀 ✈️ 🚀 ✈️ 🚀

版本对比

参考资料

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,772评论 6 477
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,458评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,610评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,640评论 1 276
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,657评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,590评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,962评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,631评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,870评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,611评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,704评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,386评论 4 319
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,969评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,944评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,179评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 44,742评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,440评论 2 342

推荐阅读更多精彩内容