bytes.Buffer en Go: optimizaciones que no funcionan

Muchos programadores de Go están familiarizados con bytes . Una de sus ventajas es que le permite evitar asignar memoria en el montón de la misma manera que la " optimización de tamaño / buffer pequeño ":


type Buffer struct { bootstrap [64]byte //        // ...   } 

Solo hay un problema. Esta optimización no funciona .


Al final de este artículo, descubrirá por qué esta optimización no funciona y qué podemos hacer al respecto.


Según lo previsto, "pequeña optimización del búfer"


Vamos a introducir una definición ligeramente simplificada de bytes.Buffer .


 const smallBufSize int = 64 type Buffer struct { bootstrap [smallBufSize]byte buf []byte } 

Cuando realizamos acciones en el Buffer , por ejemplo, llamamos al método Buffer.Write , el registro siempre se realiza en buf , sin embargo, antes de este registro, Buffer.grow(n) inicia dentro de cada método similar, lo que garantiza que haya suficiente espacio en este segmento para siguientes n bytes.


Grow puede verse más o menos así:


 func (b *Buffer) grow(n int) { //         bytes.Buffer. l := len(b.buf) //   Buffer need := n + l have := cap(b.buf) - l if have >= need { b.buf = b.buf[:need] return } if need <= smallBufSize { //     , //   . b.buf = b.bootstrap[:] } else { // growFactor -     . //     need  need*2. newBuf := make([]byte, need, growFactor(need)) copy(newBuf, b.buf) b.buf = newBuf } } 

Supuestos utilizados en nuestra implementación de Buffer.grow


Suponemos que len(b.buf) es la longitud real de los datos en Buffer, lo que requeriría que Write use métodos append para agregar nuevos bytes al segmento. Este no es el caso en bytes.Buffer de la biblioteca estándar, pero como ejemplo, este es un detalle de implementación irrelevante.




Si b asignado en la pila, el bootstrap dentro de él está asignado en la pila, lo que significa que el segmento b.buf reutilizará la memoria dentro de b sin requerir una asignación adicional.


Cuando grow revela que la matriz bootstrap ya es insuficiente, se creará un nuevo segmento "real", donde se copiarán los elementos del almacenamiento anterior (del "búfer pequeño"). Después de eso, Buffer.bootstrap perderá su relevancia. Si se Buffer.Reset , cap(b.buf) seguirá siendo el mismo y ya no habrá necesidad de una matriz bootstrap .


Memoria corriendo en el montón


Además, se espera que el lector esté al menos superficialmente familiarizado con lo que es el análisis de escape en Go.


Considere la siguiente situación:


 func f() *Buffer { var b bytes.Buffer // leak.go:11:6: moved to heap: b return &b // leak.go:12:9: &b escapes to heap } 

Aquí b se asignará en el montón. La razón de esto es el puntero con fugas a b :


 $ go tool compile -m leak.go leak.go:12:9: &b escapes to heap leak.go:11:6: moved to heap: b 

Terminología


En este artículo, "fugas" y "escapes" se usan casi como sinónimos.


Hay alguna diferencia en el compilador en sí mismo, por ejemplo, el valor "escapa al montón", pero los parámetros de la función son "fuga param x".


Un parámetro con fugas significa que el argumento pasado para este parámetro se asignará en el montón. En otras palabras, el parámetro de fuga hace que los argumentos escapen a un montón.




Lo anterior fue un caso obvio, pero ¿qué pasa con esto?


 func length() int { var b bytes.Buffer b.WriteString("1") return b.Len() } 

Aquí solo necesitamos 1 byte, todo cabe en bootstrap , el búfer en sí es local y no "escapa" de la función. Puede que se sorprenda, pero el resultado será el mismo, la asignación b en el montón.



Para estar seguro, puede verificar esto utilizando el punto de referencia:


 BenchmarkLength-8 20000000 90.1 ns/op 112 B/op 1 allocs/op 

Listado de referencia


 package p import ( "bytes" "testing" ) func length() int { var b bytes.Buffer b.WriteString("1") return b.Len() } func BenchmarkLength(b *testing.B) { for i := 0; i < bN; i++ { _ = length() } } 



Explicación 112 B / op


Cuando el tiempo de ejecución le pide al asignador N bytes, no es necesario que se asignen exactamente N bytes.


Todos los resultados a continuación son para la combinación de GOOS=linux y GOARCH=AMD64 .

 package benchmark import "testing" //go:noinline func alloc9() []byte { return make([]byte, 9) } func BenchmarkAlloc9(b *testing.B) { for i := 0; i < bN; i++ { _ = alloc9() } } 

Si ejecuta, go test -bench=. -benchmem go test -bench=. -benchmem con esta prueba:


 BenchmarkAlloc9-8 50000000 33.5 ns/op 16 B/op 1 allocs/op 

9 bytes solicitados, 16 asignados. Ahora de vuelta a bytes.Buffer .


 fmt.Println(unsafe.Sizeof(bytes.Buffer{})) => 104 

Veamos $ GOROOT / src / runtime / sizeclasses.go :


 // class bytes/obj bytes/span objects tail waste max waste // 1 8 8192 1024 0 87.50% // 2 16 8192 512 0 43.75% // 3 32 8192 256 0 46.88% // 4 48 8192 170 32 31.52% // 5 64 8192 128 0 23.44% // 6 80 8192 102 32 19.07% // 7 96 8192 85 32 15.95% // 8 112 8192 73 16 13.56% // ...  

No cabe en 96 bytes, se selecciona 112.




¿Pero por qué está pasando esto?


¿Qué está pasando y por qué?


Se puede encontrar un análisis de la situación en el tema mencionado al principio.
También hay un reproductor simple .


El lugar del problema es solo en la asignación b.buf = b.bootstrap[:] . Este código hace que el análisis de escape suponga que b.bootstrap "escapando", y dado que es una matriz, se almacena dentro del propio objeto, lo que significa que todo b debe asignarse al montón.


Si bootstrap fuera un segmento, no una matriz, entonces esto no sucedería, porque existe una optimización ad hoc para asignar segmentos del objeto al propio objeto:


 //   ,     , // object      . object.buf1 = object.buf2[a:b] 

La respuesta por la que esta optimización no funciona para las matrices ya se ha formulado anteriormente, pero aquí hay un resumen del propio esc.go # L835-L866 (el código de optimización completo está resaltado por referencia):


 // Note, this optimization does not apply to OSLICEARR, // because it does introduce a new pointer into b that was not already there // (pointer to b itself). After such assignment, if b contents escape, // b escapes as well. If we ignore such OSLICEARR, we will conclude // that b does not escape when b contents do. 

Vale la pena agregar aquí que para el analizador de puntero hay varios niveles de "fugas", la principal de ellas:


  1. El objeto en sí mismo escapa (b escapa). En este caso, el objeto en sí mismo debe asignarse en el montón.
  2. Los elementos del objeto (escape de contenido b) escapan. En este caso, los punteros en el objeto se consideran de escape.

El caso con el conjunto es especial porque si el conjunto tiene fugas, el objeto que lo contiene también debe tener fugas.


El análisis de escape muestra una decisión sobre si es posible colocar un objeto en la pila o no, confiando solo en la información disponible en el cuerpo de la función analizada. El método Buffer.grow toma b por puntero, por lo que necesita calcular un lugar adecuado para colocar. Como en el caso de una matriz no podemos distinguir "b escape" de "b contents escape" , tenemos que ser más pesimistas y llegar a la conclusión de que b no b seguro colocarlo en la pila.


Suponga lo contrario, que el patrón de autoasignación resuelve lo mismo para las matrices que para los sectores:


 package example var sink interface{} type bad struct { array [10]byte slice []byte } func (b *bad) bug() { b.slice = b.array[:] // ignoring self-assignment to b.slice sink = b.array // b.array escapes to heap // b does not escape } 

La decisión de colocar b en la pila en esta situación conducirá al desastre: después de salir de la función dentro de la cual se creó b , la memoria a la que se referirá el sink no será más que basura.


Punteros de matriz


Imagina que nuestro Buffer fue declarado un poco diferente:


 const smallBufSize int = 64 type Buffer struct { - bootstrap [smallBufSize]byte + bootstrap *[smallBufSize]byte buf []byte } 

A diferencia de una matriz regular, un puntero a una matriz no almacenará todos los elementos dentro del Buffer . Esto significa que si la asignación de bootstrap en el montón no implica Buffer asignación de Buffer en el montón. Dado que el análisis de escape puede asignar campos de puntero en la pila cuando sea posible, podemos suponer que dicha definición de Buffer es más exitosa.


Pero esto es en teoría. En la práctica, un puntero a una matriz no tiene mucho procesamiento y cae en la misma categoría que un segmento de una matriz regular, lo que no es del todo correcto. CL133375: cmd / compile / internal / gc: manejar la asignación automática de la porción de matriz en esc.go tiene como objetivo corregir esta situación.


Supongamos que este cambio ha sido aceptado en el compilador Go.


Valor cero que perdimos


Desafortunadamente, la transición de [64]byte a *[64]byte tiene un problema: ahora no podemos usar bootstrap sin inicializarlo explícitamente, un valor cero de Buffer deja de ser útil, necesitamos un constructor.


 func NewBuffer() Buffer { return Buffer{bootstrap: new(*[smallBufSize]byte)} } 

Devolvemos Buffer , no *Buffer , para evitar problemas con el análisis de punteros (es muy conservador en Go), y teniendo en cuenta el hecho de que NewBuffer siempre NewBuffer integrado en el lugar de una llamada, no habrá copias innecesarias.


Después de incrustar el cuerpo NewBuffer en el lugar de la llamada, el análisis de escape puede intentar probar que new(*[smallBufSize]byte) no excede la vida útil del marco de la función en la que se llama. Si es así, la asignación estará en la pila.


Intel bytebuf


La optimización descrita anteriormente se aplica en el paquete intel-go / bytebuf .


Esta biblioteca exporta el tipo bytebuf.Buffer , que duplica 99.9% bytes.Buffer . Todos los cambios se reducen a la introducción de un constructor ( bytebuf.New ) y un puntero a una matriz en lugar de una matriz regular:


 type Buffer struct { buf []byte // contents are the bytes buf[off : len(buf)] off int // read at &buf[off], write at &buf[len(buf)] - bootstrap [64]byte // helps small buffers avoid allocation. + bootstrap *[64]byte // helps small buffers avoid allocation. lastRead readOp // last read operation (for Unread*). } 

Aquí hay una comparación de rendimiento con bytes.Buffer .


 name old time/op new time/op delta String/empty-8 138ns ±13% 24ns ± 0% -82.94% (p=0.000 n=10+8) String/5-8 186ns ±11% 60ns ± 1% -67.82% (p=0.000 n=10+10) String/64-8 225ns ±10% 108ns ± 6% -52.26% (p=0.000 n=10+10) String/128-8 474ns ±17% 338ns ±13% -28.57% (p=0.000 n=10+10) String/1024-8 889ns ± 0% 740ns ± 1% -16.78% (p=0.000 n=9+10) name old alloc/op new alloc/op delta String/empty-8 112B ± 0% 0B -100.00% (p=0.000 n=10+10) String/5-8 117B ± 0% 5B ± 0% -95.73% (p=0.000 n=10+10) String/64-8 176B ± 0% 64B ± 0% -63.64% (p=0.000 n=10+10) String/128-8 368B ± 0% 256B ± 0% -30.43% (p=0.000 n=10+10) String/1024-8 2.16kB ± 0% 2.05kB ± 0% -5.19% (p=0.000 n=10+10) name old allocs/op new allocs/op delta String/empty-8 1.00 ± 0% 0.00 -100.00% (p=0.000 n=10+10) String/5-8 2.00 ± 0% 1.00 ± 0% -50.00% (p=0.000 n=10+10) String/64-8 2.00 ± 0% 1.00 ± 0% -50.00% (p=0.000 n=10+10) String/128-8 3.00 ± 0% 2.00 ± 0% -33.33% (p=0.000 n=10+10) String/1024-8 3.00 ± 0% 2.00 ± 0% -33.33% (p=0.000 n=10+10) 

Toda otra información está disponible en README .


Debido a la imposibilidad de utilizar el valor cero y el enlace a la función de construcción New , no es posible aplicar esta optimización a bytes.Buffer .


¿Es esta la única forma de hacer bytes.Buffer más bytes.Buffer ? La respuesta es no. Pero este es definitivamente un método que requiere cambios mínimos en la implementación.


Planes de análisis de escape


En su forma actual, el análisis de escape en Go es bastante débil. Casi cualquier operación con valores de puntero conduce a asignaciones en el montón, incluso si esta no es una decisión razonable.


Intentaré dirigir la mayor parte del tiempo que dedico al proyecto golang / go para resolver estos problemas, por lo que son posibles algunas mejoras en la próxima versión (1.12).


Puede leer sobre los resultados y los detalles de la estructura interna de esta parte del compilador en uno de mis próximos artículos. Intentaré proporcionar un conjunto de recomendaciones que ayudarán en algunos casos a estructurar el código para que tenga menos asignaciones de memoria no deseadas.

Source: https://habr.com/ru/post/es422447/


All Articles