Preludio
Este es el tercero de cuatro artículos de una serie que proporcionará información sobre la mecánica y el diseño de punteros, pilas, montones, análisis de escape y semántica de valor / puntero en Go. Esta publicación trata sobre la creación de perfiles de memoria.
Índice del ciclo de artículos:
- Mecánica del lenguaje en pilas y punteros ( traducción )
- Mecánica del lenguaje en análisis de escape ( traducción )
- Mecánica del lenguaje en la creación de perfiles de memoria
- Filosofía de diseño sobre datos y semántica
Mire este video para ver una demostración de este código:
DGopherCon Singapur (2017) - Análisis de escape
Introducción
En una publicación anterior, enseñé los conceptos básicos del análisis de escape usando un ejemplo que divide un valor en una pila de gorutinas. No le he mostrado ningún otro escenario que pueda conducir a valores de montón. Para ayudarlo con esto, voy a depurar un programa que hace asignaciones de formas inesperadas.
Programa
Quería aprender más sobre el paquete io, así que se me ocurrió una pequeña tarea para mí. Dado un flujo de bytes, escriba una función que pueda encontrar la cadena elvis y reemplazarla con la cadena en mayúscula Elvis. Estamos hablando de un rey, por lo que su nombre siempre debe estar en mayúscula.
Aquí hay un enlace a la solución: play.golang.org/p/n_SzF4Cer4
Aquí hay un enlace a los puntos de referencia: play.golang.org/p/TnXrxJVfLV
La lista muestra dos funciones diferentes que realizan esta tarea. Esta publicación se centrará en la función algOne, ya que utiliza el paquete io. Utilice la función algTwo para experimentar usted mismo con los perfiles de memoria y procesador.
Aquí está la entrada que vamos a usar y la salida esperada de la función algOne.
Listado 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
A continuación se muestra una lista de la función algOne.
Listado 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 }
Quiero saber qué tan bien funciona esta función y cuánta presión ejerce sobre el montón. Para averiguarlo, ejecutemos un punto de referencia.
Benchmarking
Escribí un punto de referencia que llama a la función algOne para realizar el procesamiento en el flujo de datos.
Listado 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 }
Podemos ejecutar este punto de referencia usando go test con los modificadores -bench, -benchtime y -benchmem.
Listado 4
$ go test -run none -bench AlgorithmOne -benchtime 3s -benchmem
BenchmarkAlgorithmOne-8 2000000 2522 ns/op 117 B/op 2 allocs/op
Después de ejecutar el punto de referencia, vemos que la función algOne asigna 2 valores con un costo total de 117 bytes por operación. Esto es genial, pero necesitamos saber qué líneas de código en la función están causando estas asignaciones. Para averiguarlo, necesitamos generar datos de perfiles para esta prueba.
Perfilado
Para generar los datos de perfil, ejecute el punto de referencia nuevamente, pero esta vez consultamos el perfil de memoria usando el modificador -memprofile.
Listado 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
Después de completar el punto de referencia, la herramienta de prueba creó dos archivos nuevos.
Listado 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
El código fuente se encuentra en la carpeta memcpu en la función algOne de stream.go y la función de referencia en stream_test.go. Los dos nuevos archivos creados se denominan mem.out y memcpu.test. El archivo mem.out contiene los datos del perfil y el archivo memcpu.test, que lleva el nombre de la carpeta, contiene el binario de prueba que necesitamos para acceder a los símbolos cuando miramos los datos del perfil.
Con los datos del perfil y el binario de prueba en su lugar, podemos ejecutar la herramienta pprof para examinar los datos del perfil.
Listado 7
$ go tool pprof -alloc_space memcpu.test mem.out
Entering interactive mode (type "help" for commands)
(pprof) _
Cuando se crea un perfil de memoria y se buscan frutos bajos, puede usar la opción -alloc_space en lugar de la opción predeterminada -inuse_space. Esto le mostrará dónde está sucediendo cada asignación, ya sea en la memoria o no cuando toma el perfil.
En el cuadro de entrada (pprof) podemos verificar la función algOne con el comando list. Este comando toma una expresión regular como argumento para encontrar la función o funciones que desea ver.
Listado 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) _
Con base en este perfil, ahora sabemos que input y buf se asignan en el montón. Dado que la entrada es una variable de puntero, el perfil realmente dice que se asigna el valor de bytes.Buffer al que apunta el puntero de entrada. Así que centrémonos primero en la asignación de entrada y comprendamos por qué sucede.
Podríamos suponer que la asignación ocurre porque la llamada a bytes.NewBuffer comparte el valor bytes.Buffer que crea la pila de llamadas. Sin embargo, la presencia del valor en la columna plana (la primera columna en la salida de pprof) me dice que el valor se está asignando porque la función algOne lo divide de una manera que hace que se acumule.
Sé que la columna plana representa asignaciones en la función, así que eche un vistazo a lo que muestra el comando list para la función Benchmark que llama a algOne.
Listado 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) _
Dado que solo hay un valor en la columna cum (segunda columna), esto me dice que Benchmark no está asignando nada directamente. Todas las asignaciones provienen de llamadas a funciones que se ejecutan dentro de este ciclo. Puede ver que todos los números de asignación de estas dos llamadas a listar son todos iguales.
Todavía no sabemos por qué se asigna el valor de bytes.Buffer. Aquí es donde el comando go build -gcflags "-m -m" es útil. El generador de perfiles solo puede decirle qué valores se están moviendo al montón, mientras que la compilación puede decirle por qué.
Informes del compilador
Preguntémosle al compilador qué decisiones toma para el análisis de escape en el código.
Listado 10
$ go build -gcflags "-m -m"
Este comando produce una gran cantidad de resultados. Solo necesitamos buscar la salida para cualquier stream.go: 83 que tenga, porque stream.go es el nombre del archivo que contiene este código, y la línea 83 contiene la construcción del valor bytes.buffer. Después de buscar, encontramos 6 líneas.
Listado 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
estamos interesados en la primera línea que encontramos al buscar stream.go: 83.
Listado 12
./stream.go:83: inlining call to bytes.NewBuffer func([]byte) *bytes.Buffer { return &bytes.Buffer literal }
Esto confirma que el valor de bytes.Buffer no desapareció cuando fue empujado a la pila de llamadas. Esto sucedió porque la llamada a bytes.NewBuffer nunca sucedió, el código dentro de la función estaba en línea.
Aquí está la línea de código en cuestión:
Listado 13
83 input := bytes.NewBuffer(data)
Debido a que el compilador decidió alinear los bytes.La llamada a la función NewBuffer, el código que escribí se convierte en esto:
Listado 14
input := &bytes.Buffer{buf: data}
Esto significa que la función algOne crea el valor bytes.Buffer directamente. Entonces, ahora la pregunta es, ¿qué hace que el valor salga del marco de pila algOne? Esta respuesta está en las otras 5 líneas que encontramos en el informe.
Listado 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
Estas líneas nos dicen que el escape de pila se produce en la línea 93 del código. La variable de entrada se asigna al valor de la interfaz.
Interfaces
No recuerdo haber hecho ninguna asignación de valor de interfaz en el código. Sin embargo, si observa la línea 93, queda claro lo que está sucediendo.
Listado 16
93 if n, err := io.ReadFull(input, buf[:end]); err != nil {
94 output.Write(buf[:n])
95 return
96 }
La llamada io.ReadFull invoca la asignación de la interfaz. Si observa la definición de la función io.ReadFull, puede ver que acepta una variable de entrada a través de un tipo de interfaz.
Listado 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))
}
Parece que pasar la dirección bytes.Buffer por la pila de llamadas y almacenarla en el valor de la interfaz del lector provoca un escape. Ahora sabemos que el costo de usar una interfaz es alto: asignación e indirección. Entonces, si no está claro exactamente cómo una interfaz mejora su código, probablemente no necesite usarlo. Aquí hay algunas pautas que sigo para probar el uso de interfaces en mi código.
Utilice la interfaz cuando:
- Los usuarios de la API deben proporcionar detalles de implementación.
- La API tiene varias implementaciones que deben admitir internamente.
- Se han identificado partes de la API que pueden cambiar y requerir separación.
No use la interfaz:
- por el simple hecho de utilizar la interfaz.
- para generalizar el algoritmo.
- cuando los usuarios pueden declarar sus propias interfaces.
Ahora podemos preguntarnos, ¿este algoritmo realmente necesita la función io.ReadFull? La respuesta es no, porque el tipo bytes.Buffer tiene un conjunto de métodos que podemos usar. El uso de métodos contra el valor que posee la función puede evitar asignaciones.
Cambiemos el código para eliminar el paquete io y usemos el método Read directamente en la variable de entrada.
Este cambio de código elimina la necesidad de importar el paquete io, por lo que para mantener todos los números de línea iguales, utilizo un identificador vacío para importar el paquete io. Esto mantendrá las importaciones en la lista.
Listado 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 }
Cuando comparamos este cambio de código, podemos ver que no hay más asignación para el valor de bytes.Buffer.
Listado 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
También vemos una mejora del rendimiento de alrededor del 29%. El tiempo cambió de 2570 ns / op a 1814 ns / op. Ahora que esto está resuelto, podemos concentrarnos en asignar un segmento auxiliar para buf. Si usamos el generador de perfiles nuevamente para los nuevos datos de perfil que acabamos de crear, podemos determinar qué está causando exactamente las asignaciones restantes.
Listado 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])
La única asignación restante está en la línea 89, que es para crear un segmento auxiliar.
Marcos de pila
Queremos saber por qué ocurre la asignación para el segmento auxiliar de buf. Ejecutemos la compilación nuevamente con la opción -gcflags "-m -m" y busquemos stream.go: 89.
Listado 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
El informe indica que la matriz auxiliar es "demasiado grande para la pila". Este mensaje es engañoso. El punto no es que la matriz sea demasiado grande, sino que el compilador no sabe qué tamaño tiene la matriz auxiliar en el momento de la compilación.
Los valores solo se pueden insertar en la pila si el compilador conoce el tamaño del valor en el momento de la compilación. Esto se debe a que el tamaño de cada marco de pila para cada función se calcula en tiempo de compilación. Si el compilador no conoce el tamaño de un valor, se amontona.
Para demostrar esto, codifiquemos temporalmente el tamaño de la porción a 5 y ejecutemos el punto de referencia nuevamente.
Listado 22
89 buf := make([]byte, 5)
Esta vez no hay más asignaciones.
Listado 23
$ go test -run none -bench AlgorithmOne -benchtime 3s -benchmem
BenchmarkAlgorithmOne-8 3000000 1720 ns/op 0 B/op 0 allocs/op
Si echa otro vistazo al informe del compilador, verá que no se está moviendo nada al montón.
Listado 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
Obviamente, no podemos codificar el tamaño de la porción, por lo que tendremos que vivir con 1 asignación para este algoritmo.
Asignaciones y desempeño
Compare las ganancias de rendimiento que hemos logrado con cada refactorización.
Listado 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
Obtuvimos un aumento de rendimiento de aproximadamente un 29% debido al hecho de que eliminamos la asignación de bytes, búfer y una aceleración de ~ 33% después de eliminar todas las asignaciones. Las asignaciones son donde el rendimiento de la aplicación puede verse afectado.
Conclusión
Go tiene algunas herramientas increíbles para ayudarlo a comprender las decisiones que toma el compilador sobre el análisis de escape. Basado en esta información, puede refactorizar su código para ayudar a mantener valores en la pila que no deberían estar en el montón. No debe escribir un programa con asignaciones cero, pero debe esforzarse por minimizar las asignaciones siempre que sea posible.
No hagas del rendimiento una prioridad máxima al escribir código, porque no querrás adivinar qué debería estar funcionando. Escriba el código y optimícelo para lograr el rendimiento de la primera tarea prioritaria. Esto significa centrarse principalmente en la integridad, la legibilidad y la simplicidad. Una vez que tenga un programa que funcione, determine si es lo suficientemente rápido. De lo contrario, utilice las herramientas proporcionadas por el idioma para encontrar y solucionar problemas de rendimiento.