ทำ Memory Profiling ด้วย pprof ใน Golang

Chonlasith Jucksriporn
odds.team
Published in
2 min readMar 27, 2023

เรื่องของเรื่องคือ มีโอกาสได้ลองเขียน go และจะต้องทำการแบ่ง String เป็นท่อน ๆ แต่ละท่อนมีความยาวเท่า ๆ กัน โดย String ที่ว่าขนาดประมาณ 300K ตอนเขียนก็ไม่ได้คิดอะไรมาก ก็เขียนไปแบบง่าย ๆ เน้นอ่านเข้าใจเองได้ง่าย ๆ ไว้ก่อน ได้ออกมาประมาณนี้

func (s String) Chunk(n int) []string {
r := []rune(string(s))

if len(r) <= n {
return []string{string(s)}
}

return append([]string{string(r[:n])}, String(string(r[n:])).Chunk(n)...)
}

ลองเขียน test ทดสอบดู ทุกอย่างก็ดูโอเคดี ทำงานได้อย่างที่ต้องการ ลองเอาไปใส่ใน code ที่เรียกใช้ ก็ทำงานได้ตามที่ต้องการ “บนเครื่องตัวเอง” และ “บน Dev environment”

ปัญหาคือ พอ function นี้ถูกเรียกใช้งานจริง ๆ บน Production ส่งผลให้ server ที่อยู่บน k8s ดับไปเฉย ๆ ลองเช็ค log ดูก็เห็นว่า มันทำงานมาได้เรื่อย ๆ จนมาถึงจุดก่อนหน้าที่เรียกใช้งาน function นี้ (แต่ตรงไหนไม่รู้ ไม่ได้ log ละเอียดขนาดนั้น) ดีที่ปอนด์สังเกตเห็นสาเหตุที่ดับ ที่ log เก็บเอาไว้คือ เกิด OOMKilled เลยตั้งเป้าไปที่เรื่อง Memory เป็นหลัก

ไปหาวิธีการทำ Memory Profiling ใน Go ก็เจอว่า Go เองมี package มาให้เรียบร้อยแล้ว ชื่อ pprof (runtime/pprof) สงสัยอะไรตรงไหน เอา package ไปใส่แล้วรัน function ที่สงสัยได้เลย ประมาณนี้

func SomeFunction() {
f, _ := os.Create("heap.out")
defer func() { f.Close() }()

// ...

pprof.WriteHeapProfile(f)
}

ทีนี้ พอเราเรียกใช้งาน SomeFunction() พอถึงบรรทัด pprof.WriteHeapProfile(f) มันก็จะทำการ dump memory ที่ใช้งานอยู่ตอนนั้นลงไฟล์ heap.out เลย

หลังจากที่เราได้ไฟล์ heap.out แล้ว ที่เหลือก็แค่เอามาดูว่ามันเกิดอะไรขึ้น

go tool pprof -svg heap.out

คำสั่งข้างบน จะเป็นการ convert heap.out ออกมาเป็นไฟล์ svg (ไฟล์ภาพแบบ vector) จริง ๆ มันมี format อื่น ๆ ได้อีก ลองสั่ง go tool pprof -help ดู ก็จะเห็น ถ้าลอง convert แล้วมันบอกว่าไม่มี app ที่ชื่อว่า dot ของ Graphviz ก็แค่ install ซะ ก็จบ

พอย้อนกลับไปดูที่ปัญหาแรกเริ่ม ก็เห็นละว่า ปัญหาคือ การที่เขียน function เป็น recursive นี่เอง ทำให้เกิดปัญหา เพราะมันเป็น limitation ของเครื่อง ยิ่ง data ปริมาณมาก เนื้อที่ใน memory ที่จะต้องจองไว้ มันก็เยอะตามไปด้วย กว่าจะ recurse ไปถึง base case นี่ก็เอาเรื่องอยู่ จากในรูปข้างบน เฉพาะตอนรัน Chunk อย่างเดียว ก็กดไปซะ 1.5GB ถึงกับปวดหัวจี๊ด

ดังนั้นวิธีแก้ก็ง่าย ๆ เปลี่ยน Recursive เป็น Loop ธรรมดาซะ อันนี้ไม่เสียเวลาเขียนละ Google เอาเลย

func (s String) Chunk(n int) []string {
strRune := []rune(string(s))

if n >= len(strRune) {
return []string{string(s)}
}

var chunks []string
chunk := make([]rune, n)

chunkSize := 0
for _, r := range strRune {
chunk[chunkSize] = r
chunkSize++

if chunkSize == n {
chunks = append(chunks, string(chunk))
chunkSize = 0
}
}

// collect the remainder in chunk buffer
if chunkSize > 0 {
chunks = append(chunks, string(chunk[:chunkSize]))
}

return chunks
}

เสร็จแล้วทำแบบเดิม คือ dump memory ลง heap.out แล้วเอามา convert เป็นภาพ เพื่อดูว่ามันยังใช้ memory หนักอยู่ไหม ได้ผลลัพธ์เอามาเทียบกันได้แบบนี้

อันซ้ายคือเวอร์ชั่นแบบ Recursive อันขวาคือ เปลี่ยนเป็น Loop ธรรมดาแล้ว จะเห็นได้ว่า จุดที่ใช้ Memory หนัก ๆ ตอน Chunk ไม่มีแล้ว ย้ายกลับไปอยู่ที่ Caller แล้ว ในที่นี้คือ SendPlainWithAttachment ซึ่งใช้ Memory ประมาณ 1MB

จริง ๆ ตัว pprof เอง ก็สามารถ dump อย่างอื่นได้อีก ลอง google ดูคนอื่นเขาใช้ pprof ก็ได้แต่อู้หู ๆ แต่อันนี้เอาแค่ heap ออกมาดูเฉย ๆ ใครอยากลองก็ไปลองเองได้เลย

--

--