内存分析的语言机制

序幕



这是本系列文章中的四篇文章的第三篇,将深入了解Go中的指针,堆栈,堆,转义分析以及值/指针语义的机制和设计。这篇文章是关于内存分析的。



文章周期表的内容表:



  1. 堆栈和指针上的语言力学翻译
  2. 逃生分析的语言力学翻译
  3. 记忆剖析的语言机制
  4. 数据与语义设计哲学


观看此视频以观看此代码的演示:

DGopherCon Singapore(2017)-Escape Analysis



介绍



在上一篇文章中,我使用一个在goroutine堆栈上拆分一个值的示例讲解了转义分析的基础。我没有向您展示任何其他可能导致堆值的情况。为了帮助您解决此问题,我将调试一个以意外方式进行分配的程序。



程序



我想了解有关io软件包的更多信息,所以我自己做了一个小任务。给定字节流,编写一个函数,该函数可以找到字符串elvis并将其替换为大写的字符串Elvis。我们在谈论国王,所以他的名字应该总是大写。



这里是解决方案的链接:play.golang.org/p/n_SzF4Cer4

这里是基准测试的链接:play.golang.org/p/TnXrxJVfLV



此清单显示了完成此任务的两个不同功能。由于使用io包,因此本文将重点介绍algOne函数。使用algTwo函数可以自己尝试使用内存和处理器配置文件。



这是我们将要使用的输入以及algOne函数的预期输出。



清单1



Input:
abcelvisaElvisabcelviseelvisaelvisaabeeeelvise l v i saa bb e l v i saa elvi
selvielviselvielvielviselvi1elvielviselvis

Output:
abcElvisaElvisabcElviseElvisaElvisaabeeeElvise l v i saa bb e l v i saa elvi
selviElviselvielviElviselvi1elviElvisElvis


下面是algOne函数的清单。



清单2



 80 func algOne(data []byte, find []byte, repl []byte, output *bytes.Buffer) {
 81
 82     // Use a bytes Buffer to provide a stream to process.
 83     input := bytes.NewBuffer(data)
 84
 85     // The number of bytes we are looking for.
 86     size := len(find)
 87
 88     // Declare the buffers we need to process the stream.
 89     buf := make([]byte, size)
 90     end := size - 1
 91
 92     // Read in an initial number of bytes we need to get started.
 93     if n, err := io.ReadFull(input, buf[:end]); err != nil {
 94         output.Write(buf[:n])
 95         return
 96     }
 97
 98     for {
 99
100         // Read in one byte from the input stream.
101         if _, err := io.ReadFull(input, buf[end:]); err != nil {
102
103             // Flush the reset of the bytes we have.
104             output.Write(buf[:end])
105             return
106         }
107
108         // If we have a match, replace the bytes.
109         if bytes.Compare(buf, find) == 0 {
110             output.Write(repl)
111
112             // Read a new initial number of bytes.
113             if n, err := io.ReadFull(input, buf[:end]); err != nil {
114                 output.Write(buf[:n])
115                 return
116             }
117
118             continue
119         }
120
121         // Write the front byte since it has been compared.
122         output.WriteByte(buf[0])
123
124         // Slice that front byte out.
125         copy(buf, buf[1:])
126     }
127 }


我想知道此函数的工作情况以及它对堆施加的压力。为了找出答案,让我们运行一个基准。



标杆管理



我编写了一个基准,该基准调用algOne函数对数据流进行处理。



清单3



15 func BenchmarkAlgorithmOne(b *testing.B) {
16     var output bytes.Buffer
17     in := assembleInputStream()
18     find := []byte("elvis")
19     repl := []byte("Elvis")
20
21     b.ResetTimer()
22
23     for i := 0; i < b.N; i++ {
24         output.Reset()
25         algOne(in, find, repl, &output)
26     }
27 }


我们可以使用带有-bench,-benchtime和-benchmem开关的go测试来运行此基准测试。



清单4



$ go test -run none -bench AlgorithmOne -benchtime 3s -benchmem
BenchmarkAlgorithmOne-8        2000000          2522 ns/op       117 B/op            2 allocs/op


运行基准测试后,我们看到algOne函数分配2个值,每个操作的总成本为117个字节。很好,但是我们需要知道函数中的哪几行代码导致了这些分配。为了找出答案,我们需要为此测试生成概要分析数据。



剖析



要生成分析数据,请再次运行基准测试,但是这次我们将使用-memprofile开关查询内存配置文件。



清单5



$ go test -run none -bench AlgorithmOne -benchtime 3s -benchmem -memprofile mem.out
BenchmarkAlgorithmOne-8        2000000          2570 ns/op       117 B/op            2 allocs/op


完成基准测试后,测试工具将创建两个新文件。



清单6



~/code/go/src/.../memcpu
$ ls -l
total 9248
-rw-r--r--  1 bill  staff      209 May 22 18:11 mem.out       (NEW)
-rwxr-xr-x  1 bill  staff  2847600 May 22 18:10 memcpu.test   (NEW)
-rw-r--r--  1 bill  staff     4761 May 22 18:01 stream.go
-rw-r--r--  1 bill  staff      880 May 22 14:49 stream_test.go


源代码位于stream.go文件的algOne函数的memcpu文件夹中,以及stream_test.go文件的基准函数中。创建的两个新文件名为mem.out和memcpu.test。mem.out文件包含配置文件数据,memcpu.test文件(以文件夹命名)包含测试二进制文件,我们在查看配置文件数据时需要访问这些符号。



有了概要文件数据和测试二进制文件后,我们可以运行pprof工具来检查概要文件数据。



清单7



$ go tool pprof -alloc_space memcpu.test mem.out
Entering interactive mode (type "help" for commands)
(pprof) _


在对内存进行概要分析并寻找难得的成果时,可以使用-alloc_space选项代替默认的-inuse_space选项。这将向您显示每次分配发生的位置,无论在获取概要文件时它是否在内存中。



在输入框(pprof)中,我们可以使用list命令检查algOne函数。该命令以正则表达式为参数来查找要查看的函数。



清单8



(pprof) list algOne
Total: 335.03MB
ROUTINE ======================== .../memcpu.algOne in code/go/src/.../memcpu/stream.go
 335.03MB   335.03MB (flat, cum)   100% of Total
        .          .     78:
        .          .     79:// algOne is one way to solve the problem.
        .          .     80:func algOne(data []byte, find []byte, repl []byte, output *bytes.Buffer) {
        .          .     81:
        .          .     82: // Use a bytes Buffer to provide a stream to process.
 318.53MB   318.53MB     83: input := bytes.NewBuffer(data)
        .          .     84:
        .          .     85: // The number of bytes we are looking for.
        .          .     86: size := len(find)
        .          .     87:
        .          .     88: // Declare the buffers we need to process the stream.
  16.50MB    16.50MB     89: buf := make([]byte, size)
        .          .     90: end := size - 1
        .          .     91:
        .          .     92: // Read in an initial number of bytes we need to get started.
        .          .     93: if n, err := io.ReadFull(input, buf[:end]); err != nil || n < end {
        .          .     94:       output.Write(buf[:n])
(pprof) _


基于此配置文件,我们现在知道输入和buf在堆上分配了。由于input是指针变量,因此配置文件确实指示已分配由输入指针指向的bytes.Buffer值。因此,让我们首先关注输入分配并了解它为什么会发生。



我们可能会假设发生分配是因为对bytes.NewBuffer的调用共享了创建调用堆栈的bytes.Buffer值。但是,在flat列(pprof输出的第一列)中存在该值,这告诉我该值已分配,因为algOne函数以使其堆积的方式对其进行了拆分。



我知道flat列表示函数中的分配,因此请看一下list命令显示的基准函数调用algOne的内容。



清单9



(pprof) list Benchmark
Total: 335.03MB
ROUTINE ======================== .../memcpu.BenchmarkAlgorithmOne in code/go/src/.../memcpu/stream_test.go
        0   335.03MB (flat, cum)   100% of Total
        .          .     18: find := []byte("elvis")
        .          .     19: repl := []byte("Elvis")
        .          .     20:
        .          .     21: b.ResetTimer()
        .          .     22:
        .   335.03MB     23: for i := 0; i < b.N; i++ {
        .          .     24:       output.Reset()
        .          .     25:       algOne(in, find, repl, &output)
        .          .     26: }
        .          .     27:}
        .          .     28:
(pprof) _


由于cum列(第二列)中只有一个值,因此这告诉我Benchmark不会直接分配任何东西。所有分配都来自在此循环内执行的函数调用。您可以看到这两个对list的调用的所有分配号都相同。



我们仍然不知道为什么要分配bytes.Buffer值。这是go build命令的-gcflags“ -m -m”开关派上用场的地方。探查器只能告诉您将哪些值移到堆中,而构建可以告诉您原因。



编译器报告



让我们问一下编译器为代码中的转义分析做出了哪些决定。



清单10



$ go build -gcflags "-m -m"


此命令产生大量输出。我们只需要在输出中搜索任何stream.go:83,因为stream.go是包含此代码的文件的名称,并且第83行包含bytes.buffer值构造。搜索后,我们发现6条线。



清单11



./stream.go:83: inlining call to bytes.NewBuffer func([]byte) *bytes.Buffer { return &bytes.Buffer literal }

./stream.go:83: &bytes.Buffer literal escapes to heap
./stream.go:83:   from ~r0 (assign-pair) at ./stream.go:83
./stream.go:83:   from input (assigned) at ./stream.go:83
./stream.go:83:   from input (interface-converted) at ./stream.go:93
./stream.go:83:   from input (passed to call[argument escapes]) at ./stream.go:93


我们对通过搜索stream.go找到的第一行感兴趣:83.



清单12



./stream.go:83: inlining call to bytes.NewBuffer func([]byte) *bytes.Buffer { return &bytes.Buffer literal }


这确认了bytes.Buffer值在被推送到调用堆栈时没有消失。发生这种情况是因为bytes.NewBuffer调用从未发生,该函数内部的代码是内联的。



这是有问题的代码行:



清单13



83     input := bytes.NewBuffer(data)


因为编译器决定内联bytes.NewBuffer函数调用,所以我编写的代码将转换为此:



清单14



input := &bytes.Buffer{buf: data}


这意味着algOne函数直接创建bytes.Buffer值。所以现在的问题是,是什么导致值从algOne堆栈框架弹出?这个答案在我们在报告中找到的其他5行中。



清单15



./stream.go:83: &bytes.Buffer literal escapes to heap
./stream.go:83:   from ~r0 (assign-pair) at ./stream.go:83
./stream.go:83:   from input (assigned) at ./stream.go:83
./stream.go:83:   from input (interface-converted) at ./stream.go:93
./stream.go:83:   from input (passed to call[argument escapes]) at ./stream.go:93


这些行告诉我们,堆转义发生在代码的第93行中。输入变量已分配给接口值。



介面



我完全不记得在代码中进行接口值分配。但是,如果您查看第93行,将会清楚发生了什么。



清单16



 93     if n, err := io.ReadFull(input, buf[:end]); err != nil {
 94         output.Write(buf[:n])
 95         return
 96     }


io.ReadFull调用调用接口的分配。如果查看io.ReadFull函数的定义,您会发现它通过接口类型接受输入变量。



清单17



type Reader interface {
      Read(p []byte) (n int, err error)
}

func ReadFull(r Reader, buf []byte) (n int, err error) {
      return ReadAtLeast(r, buf, len(buf))
}


看起来像传递字节。缓冲区地址向下调用堆栈并将其存储在Reader接口的值中会导致转义。现在我们知道使用接口的成本很高:分配和间接。因此,如果不清楚接口如何使您的代码更好,则可能不需要使用它。这是我遵循的一些准则,用于测试代码中接口的使用。



在以下情况下使用界面:



  • API用户必须提供实施细节。
  • API有一些内部必须支持的实现。
  • 已确定API的某些部分可能会更改并需要分开。


不要使用界面:



  • 为了使用界面。
  • 概括算法。
  • 用户可以声明自己的接口时。


现在我们可以问问自己,这个算法真的需要io.ReadFull函数吗?答案是否定的,因为bytes.Buffer类型有一组可以使用的方法。对函数拥有的值使用方法可能会阻止分配。



让我们更改代码以删除io包,并直接在输入变量上使用Read方法。



此代码更改消除了导入io包的需要,因此为了使所有行号保持相同,我使用一个空标识符导入io包。这将使导入保持在列表中。



清单18



 12 import (
 13     "bytes"
 14     "fmt"
 15     _ "io"
 16 )

 80 func algOne(data []byte, find []byte, repl []byte, output *bytes.Buffer) {
 81
 82     // Use a bytes Buffer to provide a stream to process.
 83     input := bytes.NewBuffer(data)
 84
 85     // The number of bytes we are looking for.
 86     size := len(find)
 87
 88     // Declare the buffers we need to process the stream.
 89     buf := make([]byte, size)
 90     end := size - 1
 91
 92     // Read in an initial number of bytes we need to get started.
 93     if n, err := input.Read(buf[:end]); err != nil || n < end {
 94         output.Write(buf[:n])
 95         return
 96     }
 97
 98     for {
 99
100         // Read in one byte from the input stream.
101         if _, err := input.Read(buf[end:]); err != nil {
102
103             // Flush the reset of the bytes we have.
104             output.Write(buf[:end])
105             return
106         }
107
108         // If we have a match, replace the bytes.
109         if bytes.Compare(buf, find) == 0 {
110             output.Write(repl)
111
112             // Read a new initial number of bytes.
113             if n, err := input.Read(buf[:end]); err != nil || n < end {
114                 output.Write(buf[:n])
115                 return
116             }
117
118             continue
119         }
120
121         // Write the front byte since it has been compared.
122         output.WriteByte(buf[0])
123
124         // Slice that front byte out.
125         copy(buf, buf[1:])
126     }
127 }


当我们对代码更改进行基准测试时,可以看到没有更多的分配给bytes.Buffer值。



清单19



$ go test -run none -bench AlgorithmOne -benchtime 3s -benchmem -memprofile mem.out
BenchmarkAlgorithmOne-8        2000000          1814 ns/op         5 B/op            1 allocs/op


我们还看到性能提高了约29%。时间从2570 ns / op更改为1814 ns / op。现在,这已解决,我们可以集中精力为buf分配辅助切片。如果我们对刚刚创建的新配置文件数据再次使用探查器,则可以确定到底是什么导致了剩余分配。



清单20



$ go tool pprof -alloc_space memcpu.test mem.out
Entering interactive mode (type "help" for commands)
(pprof) list algOne
Total: 7.50MB
ROUTINE ======================== .../memcpu.BenchmarkAlgorithmOne in code/go/src/.../memcpu/stream_test.go
     11MB       11MB (flat, cum)   100% of Total
        .          .     84:
        .          .     85: // The number of bytes we are looking for.
        .          .     86: size := len(find)
        .          .     87:
        .          .     88: // Declare the buffers we need to process the stream.
     11MB       11MB     89: buf := make([]byte, size)
        .          .     90: end := size - 1
        .          .     91:
        .          .     92: // Read in an initial number of bytes we need to get started.
        .          .     93: if n, err := input.Read(buf[:end]); err != nil || n < end {
        .          .     94:       output.Write(buf[:n])


唯一剩下的分配是在第89行,该行用于创建辅助切片。



堆叠框架



我们想知道为什么buf的辅助切片发生分配?让我们再次使用-gcflags“ -m -m”选项运行构建,并搜索stream.go:89。



清单21



$ go build -gcflags "-m -m"
./stream.go:89: make([]byte, size) escapes to heap
./stream.go:89:   from make([]byte, size) (too large for stack) at ./stream.go:89


该报告指出辅助阵列“对于堆栈而言太大”。此消息具有误导性。关键不是数组太大,而是编译器不知道辅助数组在编译时的大小。



如果编译器在编译时知道值的大小,则只能将值压入堆栈。这是因为每个函数的每个堆栈帧的大小是在编译时计算的。如果编译器不知道值的大小,则会对其进行堆放。



为了演示这一点,让我们暂时将切片大小硬编码为5,然后再次运行基准测试。



清单22



89     buf := make([]byte, 5)


这次没有更多分配了。



清单23



$ go test -run none -bench AlgorithmOne -benchtime 3s -benchmem
BenchmarkAlgorithmOne-8        3000000          1720 ns/op         0 B/op            0 allocs/op


如果再看一下编译器报告,则可以看到什么都没有移到堆中。



清单24



$ go build -gcflags "-m -m"
./stream.go:83: algOne &bytes.Buffer literal does not escape
./stream.go:89: algOne make([]byte, 5) does not escape


显然,我们无法对片段大小进行硬编码,因此对于该算法,我们将只能使用1种分配。



分配和绩效



比较我们在每次重构中获得的性能提升。



清单25



Before any optimization
BenchmarkAlgorithmOne-8        2000000          2570 ns/op       117 B/op            2 allocs/op

Removing the bytes.Buffer allocation
BenchmarkAlgorithmOne-8        2000000          1814 ns/op         5 B/op            1 allocs/op

Removing the backing array allocation
BenchmarkAlgorithmOne-8        3000000          1720 ns/op         0 B/op            0 allocs/op


由于删除了字节这一事实,我们的性能提高了约29%。缓冲区分配和删除所有分配后的〜33%的加速。分配是影响应用程序性能的地方。



结论



Go提供了一些出色的工具来帮助您了解编译器做出的转义分析决策。根据这些信息,您可以重构代码以帮助将不应保留在堆上的值保留在堆栈上。您不应该编写分配为零的程序,但应尽可能减少分配。



编写代码时,不要将性能放在第一位,因为您不想猜测应该执行什么。编写代码并对其进行优化,以实现第一任务的性能。这意味着主要关注完整性,可读性和简单性。有了工作程序后,请确定它是否足够快。如果不是,请使用该语言提供的工具来查找和修复性能问题。



All Articles