Skip to content

Phân tích hiệu suất

Khi một chương trình được viết xong yêu cầu của chúng ta đối với nó không chỉ là có thể chạy mà còn hy vọng nó là một ứng dụng ổn định và hiệu quả. Thông qua nhiều loại kiểm tra khác nhau chúng ta có thể đảm bảo phần lớn tính ổn định của chương trình còn việc chương trình có hiệu quả hay không cần chúng ta phân tích hiệu suất cho nó trong nội dung trước đó phương tiện duy nhất để phân tích hiệu suất chương trình chỉ có thể thông qua Benchmark để kiểm tra thời gian thực thi trung bình tình hình phân bổ bộ nhớ của một đơn vị chức năng nào đó tuy nhiên trong thực tế nhu cầu phân tích hiệu suất chương trình vượt xa điều này đôi khi chúng ta cần phân tích mức sử dụng CPU tổng thể của chương trình mức sử dụng bộ nhớ tình hình phân bổ heap trạng thái goroutine đường dẫn mã nguồn nóng v.v. đây là những gì Benchmark không thể đáp ứng được. May mắn thay chuỗi công cụ go đã tích hợp nhiều công cụ phân tích hiệu suất để nhà phát triển sử dụng dưới đây sẽ lần lượt giải thích.

Phân tích escape

Trong go việc phân bổ bộ nhớ của biến do trình biên dịch quyết định nói chung là phân bổ trên stack và heap. Nếu một biến đáng lẽ nên phân bổ trên stack lại được phân bổ trên heap thì trường hợp này được gọi là escape phân tích escape là để phân tích tình hình phân bổ bộ nhớ trong chương trình vì nó được thực hiện trong thời gian biên dịch nên là một loại phân tích tĩnh.

TIP

Đến bài viết Phân bổ bộ nhớ để hiểu go cụ thể phân bổ bộ nhớ như thế nào.

Con trỏ local tham chiếu

go
package main

func main() {
  GetPerson()
}

type Person struct {
  Name string
  Mom  *Person
}

func GetPerson() Person {
  mom := Person{Name: "lili"}
  son := Person{Name: "jack", Mom: &mom}
  return son
}

Hàm GetPerson tạo biến mom vì nó được tạo trong hàm lẽ ra nên phân bổ nó trên stack nhưng nó được trường Mom của son tham chiếu và son được làm giá trị trả về của hàm trả ra ngoài nên trình biên dịch phân bổ nó trên heap. Đây là một ví dụ rất đơn giản nên hiểu không cần tốn quá nhiều sức nhưng nếu là một dự án lớn hơn một chút số dòng mã có hàng vạn dòng việc phân tích thủ công sẽ không dễ dàng như vậy do đó cần sử dụng công cụ để phân tích escape. Như đã đề cập trước đó việc phân bổ bộ nhớ do trình biên dịch chủ đạo nên phân tích escape cũng do trình biên dịch hoàn thành sử dụng rất đơn giản chỉ cần thực hiện lệnh sau

bash
$ go build -gcflags="-m -m -l"

gcflags tức tham số của trình biên dịch gc

  • -m in ra đề xuất tối ưu hóa mã đồng thời xuất hiện hai cái sẽ xuất chi tiết hơn
  • -l vô hiệu hóa tối ưu hóa inline

Kết quả xuất như sau

bash
$ go build -gcflags="-m -m -l" .
# golearn/example
./main.go:13:2: mom escapes to heap:
./main.go:13:2:   flow: son = &mom:
./main.go:13:2:     from &mom (address-of) at ./main.go:14:35
./main.go:13:2:     from Person{...} (struct literal element) at ./main.go:14:15
./main.go:13:2:     from son := Person{...} (assign) at ./main.go:14:6
./main.go:13:2:   flow: ~r0 = son:
./main.go:13:2:     from return son (return) at ./main.go:15:2
./main.go:13:2: moved to heap: mom

Trình biên dịch nói rõ cho chúng ta biết biến mom đã xảy ra escape lý do dẫn đến là vì giá trị trả về bao gồm con trỏ local của hàm ngoài tình huống này ra còn có các tình huống khác có thể xảy ra hiện tượng escape

::: tips

Nếu bạn quan tâm đến chi tiết của phân tích escape có thể tìm hiểu thêm nội dung trong cmd/compile/internal/escape/escape.go của thư viện chuẩn.

:::

Closure tham chiếu

Closure tham chiếu biến bên ngoài hàm thì biến đó cũng sẽ escape lên heap điều này rất dễ hiểu.

go
package main

func main() {
  a := make([]string, 0)
  do(func() []string {
    return a
  })
}

func do(f func() []string) []string {
  return f()
}

Kết quả xuất

$ go build -gcflags="-m -m -l" .
# golearn/example
./main.go:10:9: f does not escape
./main.go:4:2: main capturing by value: a (addr=false assign=false width=24)
./main.go:4:11: make([]string, 0) escapes to heap:
./main.go:4:11:   flow: a = &{storage for make([]string, 0)}:
./main.go:4:11:     from make([]string, 0) (spill) at ./main.go:4:11
./main.go:4:11:     from a := make([]string, 0) (assign) at ./main.go:4:4
./main.go:4:11:   flow: ~r0 = a:
./main.go:4:11:     from return a (return) at ./main.go:6:3
./main.go:4:11: make([]string, 0) escapes to heap
./main.go:5:5: func literal does not escape

Không đủ không gian

Khi không gian stack không đủ cũng sẽ xảy ra hiện tượng escape slice được tạo dưới đây đã xin dung lượng 1<<15

go
package main

func main() {
  _ = make([]int, 0, 1<<15)
}

Kết quả xuất

$ go build -gcflags="-m -m -l" .
# golearn/example
./main.go:4:10: make([]int, 0, 32768) escapes to heap:
./main.go:4:10:   flow: {heap} = &{storage for make([]int, 0, 32768)}:
./main.go:4:10:     from make([]int, 0, 32768) (too large for stack) at ./main.go:4:10
./main.go:4:10: make([]int, 0, 32768) escapes to heap

Độ dài không xác định

Khi độ dài của slice là một biến thì do độ dài không xác định nên sẽ xảy ra hiện tượng escape (map sẽ không)

go
package main

func main() {
  n := 100
  _ = make([]int, n)
}

Kết quả xuất

$ go build -gcflags="-m -m -l" .
# golearn/example
./main.go:5:10: make([]int, n) escapes to heap:
./main.go:5:10:   flow: {heap} = &{storage for make([]int, n)}:
./main.go:5:10:     from make([]int, n) (non-constant size) at ./main.go:5:10
./main.go:5:10: make([]int, n) escapes to heap

Còn có một trường hợp đặc biệt là khi tham số hàm là loại ...any cũng có thể xảy ra escape

go
package main

import "fmt"

func main() {
  n := 100
  fmt.Println(n)
}

Kết quả xuất

$ go build -gcflags="-m -m -l" .
# golearn/example
./main.go:7:14: n escapes to heap:
./main.go:7:14:   flow: {storage for ... argument} = &{storage for n}:
./main.go:7:14:     from n (spill) at ./main.go:7:14
./main.go:7:14:     from ... argument (slice-literal-element) at ./main.go:7:13
./main.go:7:14:   flow: {heap} = {storage for ... argument}:
./main.go:7:14:     from ... argument (spill) at ./main.go:7:13
./main.go:7:14:     from fmt.Println(... argument...) (call parameter) at ./main.go:7:13
./main.go:7:13: ... argument does not escape
./main.go:7:14: n escapes to heap

Lý do chúng ta cần phân tích escape kiểm soát phân bổ bộ nhớ chi tiết như vậy chủ yếu là để giảm áp lực cho GC nhưng go không phải là ngôn ngữ C quyền quyết định cuối cùng của phân bổ bộ nhớ vẫn nằm trong tay trình biên dịch ngoài tình huống yêu cầu hiệu suất cực đoan phần lớn thời gian chúng ta cũng không cần quá chuyên tâm vào chi tiết phân bổ bộ nhớ dù sao mục đích sinh ra của GC là để giải phóng nhà phát triển.

Chi tiết nhỏ

Đối với một số loại tham chiếu khi xác định sau này không còn dùng đến nó nữa chúng ta có thể đặt nó thành nil để nói cho GC biết có thể thu hồi nó.

go
type Writer struct {
  buf []byte
}

func (w Writer) Close() error {
  w.buff = nil
  return nil
}

pprof

pprof (program profiling) là một công cụ mạnh mẽ để phân tích hiệu suất chương trình nó sẽ lấy mẫu một phần dữ liệu runtime của chương trình bao gồm cpu bộ nhớ goroutine lock thông tin stack và nhiều khía cạnh khác sau đó sử dụng công cụ để phân tích dữ liệu đã lấy mẫu và hiển thị kết quả.

Do đó các bước sử dụng pprof chỉ có hai bước

  1. Thu thập dữ liệu
  2. Phân tích kết quả

Thu thập

Có hai cách thu thập dữ liệu tự động và thủ công mỗi cách có ưu nhược điểm. Trước đó viết một hàm đơn giản để mô phỏng tiêu thụ bộ nhớ và cpu

go
func Do() {
  for i := 0; i < 10; i++ {
    slice := makeSlice()
    sortSlice(slice)
  }
}

func makeSlice() []int {
  var s []int
  for range 1 << 24 {
    s = append(s, rand.Int())
  }
  return s
}

func sortSlice(s []int) {
  slices.Sort(s)
}

Thủ công

Thu thập thủ công là kiểm soát thông qua mã ưu điểm là có thể kiểm soát linh hoạt có thể tùy chỉnh trực tiếp sử dụng pprof trong mã cần import gói runtime/pprof

go
package main

import (
  "log"
  "os"
  "runtime/pprof"
)

func main() {
    Do()
  w, _ := os.Create("heap.pb")
  heapProfile := pprof.Lookup("heap")
  err := heapProfile.WriteTo(w, 0)
  if err != nil {
    log.Fatal(err)
  }
}

Các tham số được hỗ trợ bởi pprof.Lookup như mã dưới đây

go
profiles.m = map[string]*Profile{
    "goroutine":    goroutineProfile,
    "threadcreate": threadcreateProfile,
    "heap":         heapProfile,
    "allocs":       allocsProfile,
    "block":        blockProfile,
    "mutex":        mutexProfile,
}

Hàm này sẽ ghi dữ liệu thu thập được vào file chỉ định khi ghi số truyền vào có các ý nghĩa sau

  • 0 ghi dữ liệu Protobuf đã nén không có khả năng đọc
  • 1 ghi dữ liệu định dạng văn bản có thể đọc được giao diện http trả về là loại dữ liệu này
  • 2 chỉ goroutine có thể sử dụng biểu thị in thông tin stack phong cách panic

Thu thập dữ liệu cpu cần sử dụng riêng hàm pprof.StartCPUProfile nó cần một khoảng thời gian để lấy mẫu và dữ liệu gốc không thể đọc được như sau

go
package main

import (
  "log"
  "os"
  "runtime/pprof"
  "time"
)

func main() {
    Do()
  w, _ := os.Create("cpu.out")
  err := pprof.StartCPUProfile(w)
  if err != nil {
    log.Fatal(err)
  }
  time.Sleep(time.Second * 10)
  pprof.StopCPUProfile()
}

Thu thập dữ liệu trace cũng tương tự

go
package main

import (
  "log"
  "os"
  "runtime/trace"
  "time"
)

func main() {
    Do()
  w, _ := os.Create("trace.out")
  err := trace.Start(w)
  if err != nil {
    log.Fatal(err)
  }
  time.Sleep(time.Second * 10)
  trace.Stop()
}

Tự động

Gói net/http/pprof đóng gói các hàm phân tích ở trên thành giao diện http và đăng ký vào route mặc định như sau

go
package pprof

import ...

func init() {
    http.HandleFunc("/debug/pprof/", Index)
    http.HandleFunc("/debug/pprof/cmdline", Cmdline)
    http.HandleFunc("/debug/pprof/profile", Profile)
    http.HandleFunc("/debug/pprof/symbol", Symbol)
    http.HandleFunc("/debug/pprof/trace", Trace)
}

Điều này cho phép chúng ta trực tiếp chạy thu thập dữ liệu pprof bằng một nút

go
package main

import (
  "net/http"
    // Nhớ import gói này
  _ "net/http/pprof"
)

func main() {
    go func(){
        http.ListenAndServe(":8080", nil)
    }
    for {
        Do()
    }
}

Lúc này mở trình duyệt truy cập http://127.0.0.1:8080/debug/pprof sẽ xuất hiện trang như thế này

Trong trang có vài tùy chọn có thể chọn chúng lần lượt đại diện cho

  • allocs lấy mẫu phân bổ bộ nhớ
  • block theo dõi block của nguyên lý đồng bộ
  • cmdline gọi dòng lệnh của chương trình hiện tại
  • goroutine theo dõi tất cả goroutine
  • heap lấy mẫu phân bổ bộ nhớ cho các đối tượng đang tồn tại
  • mutex theo dõi thông tin liên quan đến mutex
  • profile phân tích cpu sẽ phân tích một khoảng thời gian và tải xuống một file
  • threadcreate phân tích lý do dẫn đến việc tạo thread OS mới
  • trace theo dõi tình hình thực thi của chương trình hiện tại cũng sẽ tải xuống một file

Dữ liệu ở đây phần lớn khả năng đọc không cao chủ yếu là để công cụ phân tích sử dụng như hình dưới

Công việc phân tích cụ thể để lại sau này thực hiện ngoài hai tùy chọn profiletrace nếu bạn muốn tải xuống file dữ liệu trong trang web có thể bỏ tham số querydebug=1. Cũng có thể tích hợp các giao diện này vào route của bạn thay vì sử dụng route mặc định như sau

go
package main

import (
  "net/http"
  "net/http/pprof"
)

func main() {
  mux := http.NewServeMux()
  mux.HandleFunc("/trace", pprof.Trace)
  servre := &http.Server{
    Addr:    ":8080",
    Handler: mux,
  }
  servre.ListenAndServe()
}

Như vậy cũng có thể tích hợp vào các framework web khác như gin iris v.v.

Phân tích

Sau khi có được file dữ liệu thu thập có hai cách để phân tích dòng lệnh hoặc trang web cả hai đều cần借助 công cụ dòng lệnh pprof go mặc định tích hợp công cụ này nên không cần tải xuống thêm.

Mã nguồn mở của pprof google/pprof: pprof is a tool for visualization and analysis of profiling data (github.com)

Dòng lệnh

Lấy file dữ liệu thu thập được trước đó làm tham số

bash
$ go tool pprof heap.pb

Nếu dữ liệu do web thu thập thì dùng web url thay thế tên file là được.

bash
$ go tool pprof -http :8080 http://127.0.0.1/debug/pprof/heap

Sau đó sẽ xuất hiện một dòng lệnh tương tác

bash
15:27:38.3266862 +0800 CST
Type: inuse_space
Time: Apr 15, 2024 at 3:27pm (CST)
No samples were found with the default sample value type.
Try "sample_index" command to analyze different sample values.
Entering interactive mode (type "help" for commands, "o" for options)
(pprof)

Nhập help có thể xem các lệnh khác

  Commands:
    callgrind        Outputs a graph in callgrind format
    comments         Output all profile comments
    disasm           Output assembly listings annotated with samples
    dot              Outputs a graph in DOT format
    eog              Visualize graph through eog
    evince           Visualize graph through evince
  ...

Trong dòng lệnh xem dữ liệu nói chung sử dụng lệnh top cũng có thể dùng lệnh traces nhưng kết quả xuất của nó rất dài dòng lệnh top chỉ đơn giản xem qua đại khái.

(pprof) top 5
Showing nodes accounting for 117.49MB, 100% of 117.49MB total
      flat  flat%   sum%        cum   cum%
  117.49MB   100%   100%   117.49MB   100%  main.makeSlice (inline)
         0     0%   100%   117.49MB   100%  main.Do
         0     0%   100%   117.49MB   100%  main.main
         0     0%   100%   117.49MB   100%  runtime.main

Giới thiệu đơn giản một số chỉ tiêu trong đó (cpu tương tự)

  • flat đại diện cho tài nguyên mà hàm hiện tại tiêu thụ
  • cum tổng tài nguyên tiêu thụ của hàm hiện tại và chuỗi gọi tiếp theo của nó
  • flat% flat/total
  • cum% cum/total

Chúng ta có thể thấy rõ tổng mức sử dụng bộ nhớ của toàn bộ stack gọi là 117.49MB vì hàm Do tự nó không làm gì cả chỉ gọi các hàm khác nên chỉ số flat của nó là 0 việc tạo slice do hàm makeSlice phụ trách nên chỉ số flat của nó là 100%.

Chúng ta có thể chuyển đổi thành định dạng trực quan hóa pprof hỗ trợ khá nhiều định dạng như pdf svg png gif v.v. (cần cài đặt Graphviz).

(pprof) png
Generating report in profile001.png

Thông qua hình ảnh chúng ta có thể thấy rõ hơn tình hình bộ nhớ của toàn bộ stack gọi.

Thông qua lệnh list để xem dưới dạng mã nguồn

(pprof) list Do
Total: 117.49MB
ROUTINE ======================== main.Do in D:\WorkSpace\Code\GoLeran\golearn\example\main.go
         0   117.49MB (flat, cum)   100% of Total
         .          .     21:func Do() {
         .          .     22:   for i := 0; i < 10; i++ {
         .   117.49MB     23:           slice := makeSlice()
         .          .     24:           sortSlice(slice)
         .          .     25:   }
         .          .     26:}
         .          .     27:
         .          .     28:func makeSlice() []int {

Đối với hình ảnh và mã nguồn còn có thể dùng lệnh webweblist để xem hình ảnh và mã nguồn trong trình duyệt.

Trang web

Trước đó để dữ liệu đa dạng hơn sửa đổi một chút hàm mô phỏng

go
func Do1() {
  for i := 0; i < 10; i++ {
    slice := makeSlice()
    sortSlice(slice)
  }
}

func Do2() {
  for i := 0; i < 10; i++ {
    slice := makeSlice()
    sortSlice(slice)
  }
}

func makeSlice() []int {
  var s []int
  for range 1 << 12 {
    s = append(s, rand.Int())
  }
  return s
}

func sortSlice(s []int) {
  slices.Sort(s)
}

Phân tích trang web có thể trực quan hóa kết quả miễn chúng ta thao tác dòng lệnh thủ công khi sử dụng phân tích trang web chỉ cần thực hiện lệnh sau

bash
$ go tool pprof -http :8080 heap.pb

Nếu dữ liệu do web thu thập thì thay web url vào tên file là được

bash
$ go tool pprof -http :8080 http://127.0.0.1:9090/debug/pprof/heap
$ go tool pprof -http :8080 http://127.0.0.1:9090/debug/pprof/profile
$ go tool pprof -http :8080 http://127.0.0.1:9090/debug/pprof/goroutine

TIP

Về cách phân tích dữ liệu đến pprof: How to read the graph để biết thêm

Trong trang web tổng cộng có 6 mục có thể xem

  • Top giống lệnh top
  • Graph biểu đồ đường thẳng
  • Flame Graph biểu đồ ngọn lửa
  • Peek
  • Source xem mã nguồn
  • Disassemble dịch ngược

Đối với bộ nhớ có bốn chiều có thể phân tích

  • alloc_objects số lượng tất cả đối tượng đã phân bổ hiện tại bao gồm đã giải phóng
  • alloc_spcae tất cả không gian bộ nhớ đã phân bổ cho đến nay bao gồm đã giải phóng
  • inuse_objects số lượng đối tượng đang sử dụng
  • inuse_space không gian bộ nhớ đang sử dụng

Biểu đồ phân tích bộ nhớ

Các nút lá màu trắng ở dưới cùng của hình trên đại diện cho các đối tượng có kích thước khác nhau chiếm dụng.

Biểu đồ phân tích cpu

Về biểu đồ折 tuyến có vài điểm cần lưu ý

  • Màu của khối càng đậm mức sử dụng càng cao đường càng粗 mức sử dụng càng cao
  • Đường liền đại diện cho gọi trực tiếp đường nét đứt đại diện cho bỏ qua một số chuỗi gọi.

Biểu đồ ngọn lửa bộ nhớ

Biểu đồ ngọn lửa cpu

Đối với biểu đồ ngọn lửa nhìn từ trên xuống dưới là chuỗi gọi nhìn từ trái sang phải là tỷ lệ phần trăm chiếm dụng cum.

trace

pprof chủ yếu phụ trách phân tích mức sử dụng tài nguyên của chương trình còn trace phù hợp hơn để theo dõi chi tiết thực thi của chương trình nó không tương thích với file dữ liệu của cái trước công việc phân tích liên quan do lệnh go tool trace hoàn thành.

Nếu là dữ liệu thu thập thủ công có thể lấy tên file làm tham số

$ go tool trace trace.out

Nếu là thu thập tự động cũng tương tự

bash
$ curl http://127.0.0.1:8080/debug/pprof/trace > trace.out && go tool trace trace.out

Thực hiện sau sẽ khởi động một web server

2024/04/15 17:15:40 Preparing trace for viewer...
2024/04/15 17:15:40 Splitting trace for viewer...
2024/04/15 17:15:40 Opening browser. Trace viewer is listening on http://127.0.0.1:51805

Mở ra trang đại khái như sau

Trong này chủ yếu bao gồm các phần sau dữ liệu này muốn xem hiểu còn khá không dễ dàng.

  • Event timelines for running goroutines

    • trace by proc hiển thị timeline của goroutine chạy trên bộ xử lý đó tại mỗi thời điểm

    • trace by thread hiển thị timeline của goroutine chạy trên thread OS tại mỗi thời điểm

    • Goroutine analysis hiển thị thông tin thống kê liên quan đến goroutine của mỗi nhóm hàm chính

  • Profiles

    • Network blocking profile thông tin goroutine bị block do IO mạng
    • Synchronization blocking profile thông tin goroutine bị block do nguyên lý đồng bộ
    • Syscall profile thông tin goroutine bị block do gọi hệ thống
  • User-defined tasks and regions

    • User-defined tasks thông tin goroutine liên quan đến task do người dùng định nghĩa
    • User-defined regions thông tin goroutine liên quan đến vùng mã do người dùng định nghĩa
  • Garbage collection metrics

    • Minimum mutator utilization hiển thị thời gian tiêu thụ tối đa của GC gần đây

Golang by www.golangdev.cn edit