第 17 章
Day 17:記憶體效能分析
完成 Day 15 與 Day 16 後,我取得以下 flame graph:
runtime.gcDrain 佔用 25.14 秒(13.9%)CPU 時間,因此我開始關注頻繁建立、回收的短生命週期物件。
Memory Profiling
Benchmark 後,可以分別從 localhost:6060/debug/pprof/allocs 與 localhost:6060/debug/pprof/heap 取得 allocation 與 heap 資料。
注意:這章使用
redis-benchmark -p 16379 -t SET -P 16 -n 10000000 -r 1000000。
Allocation flame graph:
Heap flame graph:
兩者的差異如下:
- allocs:記憶體配置歷史,包含仍存活與已回收的物件。
- heap:目前仍存活、或尚未被 GC 回收的記憶體。
因為 runtime.gcDrain 佔用較多時間,先關注 allocs。
使用 bufio.(*Reader).ReadSlice()
原本的 resp.(*Reader).readLine():
func (r *Reader) readLine() ([]byte, error) {
var buf bytes.Buffer
for {
data, err := r.rd.ReadBytes('\r')
if err != nil {
return nil, err
}
buf.Write(data)
b, err := r.rd.ReadByte()
if err != nil {
return nil, err
}
if b == '\n' {
return buf.Bytes()[:buf.Len()-1], nil
}
buf.WriteByte(b)
}
}
讀取過程可能多次擴充 bytes.Buffer。改用 r.rd.ReadSlice('\n'),重用 bufio.Reader 的 buffer:
func (r *Reader) readLine() ([]byte, error) {
var fragments []byte
for {
data, err := r.rd.ReadSlice('\n')
if errors.Is(err, bufio.ErrBufferFull) {
fragments = append(fragments, data...)
continue
}
if err != nil {
return nil, err
}
if len(data) == 1 && data[0] == '\n' && len(fragments) > 0 && fragments[len(fragments)-1] == '\r' {
return fragments[:len(fragments)-1], nil
}
if len(data) >= 2 && data[len(data)-2] == '\r' {
data = data[:len(data)-2]
if len(fragments) == 0 {
return data, nil
}
return append(fragments, data...), nil
}
fragments = append(fragments, data...)
}
}
結果
使用 go tool pprof -http=:8000 -base ./scripts/out_archive/1-original.allocs ./scripts/out_archive/2-readslice.allocs 比較修改前後:
Interface 與型別轉換
Interface 有助於建立清楚易讀的程式碼,但這裡的 interface 與型別轉換也帶來部分 heap allocation。
resp.ReadCommand() 的流程如下:
rd.Read()回傳resp.Valueinterface。arr, ok := v.(Array)將其轉為resp.Array。cmd, ok := arr.data[0].(BulkString)將第一個元素轉為BulkString。- 其餘元素也逐一轉成
BulkString,形成args。
func ReadCommand(rd *Reader) (*Command, error) {
v, err := rd.Read()
if err != nil {
return nil, fmt.Errorf("%w: %w", ErrProtocol, err)
}
arr, ok := v.(Array)
if !ok {
return nil, fmt.Errorf("%w: expected array, got %T(%+v)", ErrProtocol, v, v)
}
if arr.null || len(arr.data) == 0 {
return nil, fmt.Errorf("%w: empty array", ErrProtocol)
}
cmd, ok := arr.data[0].(BulkString)
if !ok {
return nil, fmt.Errorf("%w: command expected bulk string, got %T(%+v)", ErrProtocol, arr.data[0], arr.data[0])
}
args := make([]BulkString, 0, len(arr.data)-1)
for i := range arr.data[1:] {
arg, ok := arr.data[i+1].(BulkString)
if !ok {
return nil, fmt.Errorf("%w: argument [%d] expected bulk string, got %T(%+v)", ErrProtocol, i, arr.data[i+1], arr.data[i+1])
}
args = append(args, arg)
}
return &Command{
raw: v,
aof: arr.Clone(),
cmd: cmd,
args: args,
}, nil
}
我在 resp.(*Reader).ReadCommand() 建立 fast path,直接讀取由 Bulk Strings 組成的 Array:
func (r *Reader) ReadCommand() ([]BulkString, error) {
t, err := r.rd.ReadByte()
if err != nil {
return nil, err
}
if t != MAGIC_ARRAY {
return nil, fmt.Errorf("expected array, got %c", t)
}
sz, err := r.readInt()
if err != nil {
return nil, err
}
if sz < 0 {
return nil, nil
}
values := make([]BulkString, sz)
for i := range values {
t, err := r.rd.ReadByte()
if err != nil {
return nil, err
}
if t != MAGIC_BULK_STRING {
return nil, fmt.Errorf("element [%d] expected bulk string, got %c", i, t)
}
values[i], err = r.readBulkString()
if err != nil {
return nil, err
}
}
return values, nil
}
func (r *Reader) Buffered() int {
return r.rd.Buffered()
}
resp.ReadCommand() 改為直接呼叫它:
func ReadCommand(rd *Reader) (*Command, error) {
values, err := rd.ReadCommand()
if err != nil {
return nil, fmt.Errorf("%w: %w", ErrProtocol, err)
}
if len(values) == 0 {
return nil, fmt.Errorf("%w: empty array", ErrProtocol)
}
return &Command{
raw: values,
aof: slices.Clone(values),
cmd: values[0],
args: values[1:],
}, nil
}
結果
AOF Copy-on-Write
原本 Command.aof 是 Command.raw 的副本,因為寫入 AOF 時,SET ... EX ... 可能改成 SET ... PXAT ...。但即使 AOF 關閉,也會建立這份副本。
UpdateAOF() 與 MarshalAOF() 只在啟用 AOF 時使用,因此可以將初始化延後到這些 methods,而不在 resp.ReadCommand() 先做:
func (cmd *Command) UpdateAOF(i int, v BulkString) {
if cmd.aof == nil {
cmd.aof = slices.Clone(cmd.raw)
}
if i < len(cmd.aof) {
cmd.aof[i] = v
} else {
cmd.aof = append(cmd.aof, v)
}
}
func (cmd *Command) MarshalAOF() []byte {
if cmd.aof == nil {
return marshalCommand(cmd.raw)
}
return marshalCommand(cmd.aof)
}
結果
重構 Command 欄位
原本的定義:
type Command struct {
raw []BulkString
aof []BulkString
cmd BulkString
args []BulkString
}
重構 resp.ReadCommand() 後,不再需要獨立的 cmd 與 args 欄位:
type Command struct {
raw []BulkString
aof []BulkString
- cmd BulkString
- args []BulkString
}
func (cmd *Command) Command() string {
return strings.ToUpper(string(cmd.raw[0].data))
}
func (cmd *Command) Args() []BulkString {
return cmd.raw[1:]
}
結果
共用 Buffer
多數 BulkString 都很短,但每次讀取前仍執行 buf := make([]byte, sz):
func (r *Reader) readBulkString() (BulkString, error) {
sz, err := r.readInt()
if err != nil {
return BulkString{}, err
}
if sz < 0 { // Ref: https://redis.io/docs/latest/develop/reference/protocol-spec/#null-bulk-strings
return BulkString{null: true}, nil
}
buf := make([]byte, sz)
if _, err := io.ReadFull(r.rd, buf); err != nil {
return BulkString{}, err
}
// Drop tailing "\r\n" from reader.
if cr, err := r.rd.ReadByte(); err != nil || cr != '\r' {
return BulkString{}, errors.New("unexpected sentinel")
}
if nl, err := r.rd.ReadByte(); err != nil || nl != '\n' {
return BulkString{}, errors.New("unexpected sentinel")
}
return BulkString{data: buf}, nil
}
可以在 resp.Reader 加入共用 buffer:
type Reader struct {
rd *bufio.Reader
+ bytesbuf [1 << 12]byte
}
func (r *Reader) readBulkString() (BulkString, error) {
sz, err := r.readInt()
if err != nil {
return BulkString{}, err
}
if sz < 0 { // Ref: https://redis.io/docs/latest/develop/reference/protocol-spec/#null-bulk-strings
return BulkString{null: true}, nil
}
var buf []byte
if sz <= len(r.bytesbuf) {
buf = r.bytesbuf[:sz:sz]
} else {
buf = make([]byte, sz)
}
if _, err := io.ReadFull(r.rd, buf); err != nil {
return BulkString{}, err
}
// Drop tailing "\r\n" from reader.
if cr, err := r.rd.ReadByte(); err != nil || cr != '\r' {
return BulkString{}, errors.New("unexpected sentinel")
}
if nl, err := r.rd.ReadByte(); err != nil || nl != '\n' {
return BulkString{}, errors.New("unexpected sentinel")
}
return BulkString{data: string(buf)}, nil
}
這個 4096-byte 的 bytesbuf 在同一條連線的不同請求間重用。
但若 BulkString.data 直接參照它,下一個請求就會覆寫資料。因此將 BulkString.data 從 []byte 改成 string,建立 BulkString 時複製字串。
valuebuf
同樣可以在 resp.Reader 為 ReadCommand() 加入共用 valuebuf:
type Reader struct {
rd *bufio.Reader
+ valuebuf [8]BulkString
bytesbuf [1 << 12]byte
}
func (r *Reader) ReadCommand() ([]BulkString, error) {
t, err := r.rd.ReadByte()
if err != nil {
return nil, err
}
if t != MAGIC_ARRAY {
return nil, fmt.Errorf("expected array, got %c", t)
}
sz, err := r.readInt()
if err != nil {
return nil, err
}
if sz < 0 {
return nil, nil
}
var values []BulkString
if sz <= len(r.valuebuf) {
clear(r.valuebuf[:])
values = r.valuebuf[:sz:sz]
} else {
values = make([]BulkString, sz)
}
for i := range values {
t, err := r.rd.ReadByte()
if err != nil {
return nil, err
}
if t != MAGIC_BULK_STRING {
return nil, fmt.Errorf("element [%d] expected bulk string, got %c", i, t)
}
values[i], err = r.readBulkString()
if err != nil {
return nil, err
}
}
return values, nil
}
sz <= 8 時使用 r.valuebuf,否則才配置新的 slice:
var values []BulkString
if sz <= len(r.valuebuf) {
clear(r.valuebuf[:])
values = r.valuebuf[:sz:sz]
} else {
values = make([]BulkString, sz)
}
重新指派前要清空 r.valuebuf,避免保留前一個請求的大字串參照。
注意:
ReadCommand()可能將共用valuebuf交給Command.raw。這裡的 command pipeline 依序處理,所以我接受這個設計;若跨 goroutines 分享或保留資料,仍有風險。
結果
其他調整
OK 回應
SET 成功時回傳 +OK\r\n,原本每次都建立新的 resp.SimpleString:
return resp.SimpleString("OK"), nil
可以改用預先定義的 resp.OKValue:
var OKValue = ok{}
type ok struct{}
func (ok) Marshal() []byte {
return []byte("+OK\r\n")
}
Debug Logging
Debug logging 會建立 slog.Any("error", err),消耗 CPU 與配置記憶體。可以移除或停用。
測量結果
我以以下參數比較 Redis、Day 14 的 Olivine 與 profiling 後的版本:
$ redis-benchmark -t SET -P 16 -n 10000000 -r 1000000
測試環境:
- MacBook Pro 2023,14 吋。
- CPU:Apple M2 Pro。
- RAM:32GB。
- SSD:512GB。
測試結果:
- Redis 8.8:1090512.50 req/s。
- Olivine Day 14:70773.91 req/s。
- Olivine profiling 後:1397038.25 req/s。
在上述環境與測試參數下,Olivine 的吞吐量提升接近 20 倍,並在這組測量中高於 Redis。