Métodos simples para optimizar los programas Go

Siempre me importa el rendimiento. No sé exactamente por qué. Pero me molestan los servicios y programas lentos. Parece que no estoy solo .

En las pruebas A / B, intentamos ralentizar la producción de páginas en incrementos de 100 milisegundos y descubrimos que incluso retrasos muy pequeños conducen a una caída significativa en los ingresos. - Greg Linden, Amazon.com

Por experiencia, la baja productividad se manifiesta de una de dos maneras:

  • Las operaciones que funcionan bien a pequeña escala se vuelven inviables con un número creciente de usuarios. Por lo general, estas son operaciones O (N) u O (N²). Cuando la base de usuarios es pequeña, todo funciona bien. El producto tiene prisa por llevarlo al mercado. A medida que la base crece, surgen situaciones patológicas cada vez más inesperadas, y el servicio se detiene.
  • Muchas fuentes individuales de trabajo subóptimo, "muerte por mil cortes".

Durante la mayor parte de mi carrera, estudié ciencia de datos con Python o creé servicios en Go. En el segundo caso, tengo mucha más experiencia en optimización. Go generalmente no es un cuello de botella en los servicios que escribo: los programas de bases de datos a menudo están limitados por E / S. Sin embargo, en las canalizaciones por lotes de aprendizaje automático que desarrollé, el programa a menudo está limitado por la CPU. Si Go usa demasiado el procesador, hay varias estrategias.

Este artículo explica algunos métodos que pueden usarse para aumentar significativamente la productividad sin mucho esfuerzo. Ignoro deliberadamente los métodos que requieren un esfuerzo significativo o grandes cambios en la estructura del programa.

Antes de empezar


Antes de realizar cualquier cambio en el programa, tómese el tiempo para crear una línea base adecuada para la comparación. Si no lo hace, entonces vagará en la oscuridad, preguntándose si hay algún beneficio de los cambios realizados. Primero, escriba puntos de referencia y tome perfiles para usar en pprof. Es mejor escribir el punto de referencia también en Go : esto hace que sea más fácil usar pprof y perfiles de memoria. Utilice también benchcmp: una herramienta útil para comparar diferencias de rendimiento entre pruebas.

Si el código no es muy compatible con los puntos de referencia, simplemente comience con algo que se pueda medir. Puede perfilar el código manualmente con runtime / pprof .

¡Entonces comencemos!

Use sync.Pool para reutilizar objetos previamente seleccionados


sync.Pool implementa una lista de lanzamiento . Esto le permite reutilizar estructuras previamente asignadas y amortiza la distribución del objeto en muchos usos, reduciendo el trabajo del recolector de basura. La API es muy simple. Implemente una función que asigne una nueva instancia de un objeto. La API devolverá el tipo de puntero.

var bufpool = sync.Pool{ New: func() interface{} { buf := make([]byte, 512) return &buf }} 

Después de eso, puede hacer Get() objetos del grupo y volver a colocarlos Put() cuando haya terminado.

 // sync.Pool returns a interface{}: you must cast it to the underlying type // before you use it. b := *bufpool.Get().(*[]byte) defer bufpool.Put(&b) // Now, go do interesting things with your byte buffer. buf := bytes.NewBuffer(b) 

Hay matices Antes de Go 1.13, el grupo se borraba con cada recolección de basura. Esto puede afectar negativamente el rendimiento de los programas que asignan mucha memoria. A partir de 1.13, parece que más objetos sobreviven después del GC .

!!! Antes de devolver un objeto al grupo, asegúrese de restablecer los campos de estructura.

Si no lo hace, puede obtener un objeto sucio del grupo que contiene datos del uso anterior. ¡Este es un grave riesgo de seguridad!

 type AuthenticationResponse { Token string UserID string } rsp := authPool.Get().(*AuthenticationResponse) defer authPool.Put(rsp) // If we don't hit this if statement, we might return data from other users! if blah { rsp.UserID = "user-1" rsp.Token = "super-secret" } return rsp 

Una forma segura de garantizar siempre cero memoria es hacer esto explícitamente:

 // reset resets all fields of the AuthenticationResponse before pooling it. func (a* AuthenticationResponse) reset() { a.Token = "" a.UserID = "" } rsp := authPool.Get().(*AuthenticationResponse) defer func() { rsp.reset() authPool.Put(rsp) }() 

El único caso cuando esto no es un problema es cuando usa la memoria exacta en la que escribió. Por ejemplo:

 var ( r io.Reader w io.Writer ) // Obtain a buffer from the pool. buf := *bufPool.Get().(*[]byte) defer bufPool.Put(&buf) // We only write to w exactly what we read from r, and no more. nr, er := r.Read(buf) if nr > 0 { nw, ew := w.Write(buf[0:nr]) } 

Evite usar estructuras que contengan punteros como claves para un mapa grande


Fuh, yo era demasiado prolijo. Lo siento A menudo hablaban (incluido mi antiguo colega Phil Pearl ) sobre el rendimiento de Go con un gran tamaño de almacenamiento dinámico . Durante la recolección de basura, el tiempo de ejecución escanea objetos con punteros y los rastrea. Si tiene un mapa muy grande map map[string]int , entonces GC debería verificar cada línea. Esto sucede con cada recolección de basura, porque las líneas contienen punteros.

En este ejemplo, escribimos 10 millones de elementos para map[string]int y medir la duración de la recolección de basura. Asignamos nuestro mapa en el área del paquete para garantizar la asignación de memoria desde el montón.

 package main import ( "fmt" "runtime" "strconv" "time" ) const ( numElements = 10000000 ) var foo = map[string]int{} func timeGC() { t := time.Now() runtime.GC() fmt.Printf("gc took: %s\n", time.Since(t)) } func main() { for i := 0; i < numElements; i++ { foo[strconv.Itoa(i)] = i } for { timeGC() time.Sleep(1 * time.Second) } } 

Al ejecutar el programa, veremos lo siguiente:

  inthash → vaya a instalar && inthash
 gc tomó: 98.726321 ms
 gc tomó: 105.524633 ms
 gc tomó: 102.829451ms
 gc tomó: 102.71908ms
 gc tomó: 103.084104ms
 gc tomó: 104.821989ms 

¡Esto es bastante tiempo en un país de computadoras!

¿Qué se puede hacer para optimizar? Eliminar punteros en todas partes es una buena idea, para no cargar el recolector de basura. Hay punteros en las líneas ; así que implementemos esto como map[int]int .

 package main import ( "fmt" "runtime" "time" ) const ( numElements = 10000000 ) var foo = map[int]int{} func timeGC() { t := time.Now() runtime.GC() fmt.Printf("gc took: %s\n", time.Since(t)) } func main() { for i := 0; i < numElements; i++ { foo[i] = i } for { timeGC() time.Sleep(1 * time.Second) } } 

Ejecutando el programa nuevamente, vemos:

  inthash → vaya a instalar && inthash
 gc tomó: 3.608993ms
 gc tomó: 3.926913ms
 gc tomó: 3.955706ms
 gc tomó: 4.063795ms
 gc tomó: 3.91519ms
 gc tomó: 3.75226ms 

Mucho mejor Hemos acelerado la recolección de basura en 35 veces. Cuando se usa en producción, será necesario dividir las cadenas en enteros antes de insertarlas en la tarjeta.

Por cierto, hay muchas más formas de evitar la GC. Si asigna matrices gigantescas de estructuras, ints o bytes sin sentido, el GC no escaneará esto : es decir, ahorrará tiempo en el GC. Tales métodos generalmente requieren una revisión sustancial del programa, por lo que hoy no profundizaremos en este tema.

Como con cualquier optimización, el efecto puede variar. Vea el hilo de tweets de Damian Gryski para ver un ejemplo interesante de cómo eliminar líneas de un mapa grande en favor de una estructura de datos más inteligente realmente aumentó el consumo de memoria. En general, lee todo lo que publica.

Generación de código de clasificación para evitar la reflexión en tiempo de ejecución


Organizar y desarmar su estructura en varios formatos de serialización, como JSON, es una operación típica, especialmente al crear microservicios. Para muchos microservicios, este es generalmente el único trabajo. Funciones como json.Marshal y json.Unmarshal basan en la reflexión en tiempo de ejecución para serializar los campos de estructura en bytes y viceversa. Esto puede funcionar lentamente: la reflexión no es tan eficiente como el código explícito.

Sin embargo, hay opciones de optimización. La mecánica de cálculo de referencias de JSON se parece a esto:

 package json // Marshal take an object and returns its representation in JSON. func Marshal(obj interface{}) ([]byte, error) { // Check if this object knows how to marshal itself to JSON // by satisfying the Marshaller interface. if m, is := obj.(json.Marshaller); is { return m.MarshalJSON() } // It doesn't know how to marshal itself. Do default reflection based marshallling. return marshal(obj) } 

Si conocemos el proceso de clasificación en JSON, tenemos una pista para evitar la reflexión en tiempo de ejecución. Pero no queremos escribir manualmente todo el código de clasificación, entonces, ¿qué debemos hacer? ¡Deje que la computadora genere este código! Los generadores de código como easyjson observan la estructura y generan código altamente optimizado que es totalmente compatible con las interfaces de json.Marshaller existentes como json.Marshaller .

Descargue el paquete y escriba el siguiente comando en $file.go , que contiene las estructuras para las que desea generar código.

  easyjson -todos $ file.go 

Se debe generar el archivo $file_easyjson.go . Como easyjson implementado la interfaz json.Marshaller para usted, estas funciones se json.Marshaller de forma predeterminada en lugar de reflexionar. Felicitaciones: acabas de acelerar tu código JSON tres veces. Hay muchos trucos para aumentar aún más la productividad.

Recomiendo este paquete porque lo he usado antes y con éxito. Pero ten cuidado. No tome esto como una invitación para iniciar debates agresivos conmigo sobre los paquetes JSON más rápidos.

Asegúrese de volver a generar el código de clasificación cuando cambie la estructura. Si olvida hacer esto, los campos recién agregados no se serializarán, ¡lo que generará confusión! Puede usar go generate para estas tareas. Para mantener la sincronización con las estructuras, prefiero colocar generate.go en la raíz del paquete, lo que provoca que go generate todos los archivos del paquete: esto puede ayudar cuando tiene muchos archivos que necesitan generar dicho código. El consejo principal: para asegurarse de que las estructuras se actualicen, llame a go generate en el CI y verifique que no haya diferencia con el código registrado.

Use strings.Builder para construir cadenas


En Go, las cadenas son inmutables: considérelas como bytes de solo lectura. Esto significa que cada vez que crea una cadena, asigna memoria y potencialmente crea más trabajo para el recolector de basura.

Ir 1.10 cadenas implementadas. Constructor como una forma eficiente de crear cadenas. Internamente, escribe en un búfer de bytes. Solo cuando se llama a String() en el generador, se crea una cadena. Se basa en algunos trucos inseguros para devolver los bytes base como una cadena con una asignación cero: consulte este blog para obtener más información sobre cómo funciona.

Compare el rendimiento de los dos enfoques:

 // main.go package main import "strings" var strs = []string{ "here's", "a", "some", "long", "list", "of", "strings", "for", "you", } func buildStrNaive() string { var s string for _, v := range strs { s += v } return s } func buildStrBuilder() string { b := strings.Builder{} // Grow the buffer to a decent length, so we don't have to continually // re-allocate. b.Grow(60) for _, v := range strs { b.WriteString(v) } return b.String() } 

 // main_test.go package main import ( "testing" ) var str string func BenchmarkStringBuildNaive(b *testing.B) { for i := 0; i < bN; i++ { str = buildStrNaive() } } func BenchmarkStringBuildBuilder(b *testing.B) { for i := 0; i < bN; i++ { str = buildStrBuilder() } 

Aquí están los resultados en mi Macbook Pro:

  strbuild -> go test -bench =.  -benchmem
 goos: darwin
 goarch: amd64
 paquete: github.com/sjwhitworth/perfblog/strbuild
 BenchmarkStringBuildNaive-8 5,000,000 255 ns / op 216 B / op 8 allocs / op
 BenchmarkStringBuildBuilder-8 20,000,000 54.9 ns / op 64 B / op 1 allocs / op 

Como puede ver, strings.Builder es 4.7 veces más rápido, causa ocho veces menos asignaciones y ocupa cuatro veces menos memoria.

Cuando el rendimiento es importante, use strings.Builder . En general, recomiendo usarlo en todas partes, excepto en los casos más triviales de construir cadenas.

Use strconv en lugar de fmt


fmt es uno de los paquetes más famosos de Go. Probablemente lo usó en su primer programa para mostrar "hola, mundo". Pero cuando se trata de convertir enteros y flotadores en cadenas, no es tan eficiente como su hermano menor strconv . Este paquete muestra un rendimiento decente con muy pocos cambios en la API.

fmt básicamente toma la interface{} como argumentos de función. Hay dos inconvenientes:

  • Estás perdiendo seguridad tipo. Para mi es muy importante.
  • Esto puede aumentar la cantidad de secreciones necesarias. Pasar un tipo sin puntero como interface{} generalmente resulta en una asignación de montón. Esta publicación de blog explica por qué esto es así.
  • El siguiente programa muestra la diferencia en el rendimiento:

     // main.go package main import ( "fmt" "strconv" ) func strconvFmt(a string, b int) string { return a + ":" + strconv.Itoa(b) } func fmtFmt(a string, b int) string { return fmt.Sprintf("%s:%d", a, b) } func main() {} 

     // main_test.go package main import ( "testing" ) var ( a = "boo" blah = 42 box = "" ) func BenchmarkStrconv(b *testing.B) { for i := 0; i < bN; i++ { box = strconvFmt(a, blah) } a = box } func BenchmarkFmt(b *testing.B) { for i := 0; i < bN; i++ { box = fmtFmt(a, blah) } a = box } 

    Puntos de referencia en Macbook Pro:

      strfmt → ir prueba -bench =.  -benchmem
     goos: darwin
     goarch: amd64
     paquete: github.com/sjwhitworth/perfblog/strfmt
     BenchmarkStrconv-8 30,000,000 39.5 ns / op 32 B / op 1 allocs / op
     Benchmark Fmt-8 10,000,000 143 ns / op 72 B / op 3 allocs / op 

    Como puede ver, la opción strconv es 3.5 veces más rápida, causa tres veces menos asignaciones y ocupa la mitad de la memoria.

    Asigne el tanque de corte con marca para evitar la redistribución


    Antes de pasar a mejorar el rendimiento, actualice rápidamente la información cortada en la memoria. Una rebanada es una construcción muy útil en Go. Proporciona una matriz escalable con la capacidad de aceptar diferentes vistas en la misma memoria base sin reasignación. Si miras debajo del capó, la rebanada consta de tres elementos:

     type slice struct { // pointer to underlying data in the slice. data uintptr // the number of elements in the slice. len int // the number of elements that the slice can // grow to before a new underlying array // is allocated. cap int } 

    ¿Qué son estos campos?

    • data : puntero a los datos subyacentes en el segmento
    • len : número actual de elementos en el segmento
    • cap : número de elementos a los que puede crecer un segmento antes de redistribuir

    Las secciones debajo del capó son conjuntos de longitud fija. Cuando se alcanza el valor máximo ( cap ), se asigna una nueva matriz con un valor doble, la memoria se copia de la división anterior a la nueva y la matriz anterior se descarta.

    A menudo veo algo como este código donde se asigna un segmento con una capacidad límite cero si la capacidad del segmento se conoce de antemano:

     var userIDs []string for _, bar := range rsp.Users { userIDs = append(userIDs, bar.ID) } 

    En este caso, el segmento comienza con el tamaño cero len y el límite de capacidad límite cero. Una vez recibida la respuesta, agregamos los elementos al segmento, al mismo tiempo que alcanzamos la capacidad límite: se selecciona una nueva matriz base, donde cap duplica el cap y se copian los datos. Si obtenemos 8 elementos en la respuesta, esto lleva a 5 redistribuciones.

    El siguiente método es mucho más eficiente:

     userIDs := make([]string, 0, len(rsp.Users)) for _, bar := range rsp.Users { userIDs = append(userIDs, bar.ID) } 

    Aquí asignamos explícitamente la capacidad para el segmento usando make. Ahora podemos agregar datos de manera segura allí, sin redistribución y copia adicionales.

    Si no sabe cuánta memoria asignar, debido a que la capacidad es dinámica o se calcula más tarde en el programa, mida la distribución final del tamaño de segmento después de que se ejecute el programa. Por lo general, tomo el percentil 90 o 99 y codifico el valor en el programa. En los casos en que la CPU sea más costosa que la RAM para usted, configure este valor más alto de lo que cree que es necesario.

    La sugerencia también se aplica a los mapas: make(map[string]string, len(foo)) asignará suficiente memoria para evitar la redistribución.

    Vea este artículo sobre cómo funcionan realmente las rebanadas.

    Use métodos para transferir segmentos de bytes


    Cuando use paquetes, use métodos que permitan la transmisión de un segmento de bytes: estos métodos generalmente dan más control sobre la distribución.

    Un buen ejemplo es comparar time.Format y time.AppendFormat . El primero devuelve una cadena. Debajo del capó, esto selecciona un nuevo segmento de byte y llama al tiempo. El segundo toma un búfer de bytes, escribe una representación de tiempo formateada y devuelve un segmento de bytes extendido. Esto a menudo se encuentra en otros paquetes en la biblioteca estándar: vea strconv.AppendFloat o bytes.NewBuffer .

    ¿Por qué esto aumenta la productividad? Bueno, ahora puede pasar las porciones de bytes que recibió de sync.Pool , en lugar de asignar un nuevo búfer cada vez. O puede aumentar el tamaño inicial del búfer a un valor que sea más adecuado para su programa a fin de reducir el número de copias repetidas de la porción.

    Resumen


    Puede aplicar todos estos métodos a su base de código. Con el tiempo, creará un modelo mental para razonar sobre el rendimiento en los programas Go. Esto será de gran ayuda en su diseño.

    Pero úsalos dependiendo de la situación. Estos son consejos, no el evangelio. Mida y verifique todo con puntos de referencia.

    Y saber cuándo parar. El aumento de la productividad es un buen ejercicio: la tarea es interesante y los resultados son visibles de inmediato. Sin embargo, la utilidad de aumentar la productividad depende en gran medida de la situación. Si su servicio da una respuesta en 10 ms, y el retraso de la red es de 90 ms, probablemente no debería intentar reducir estos 10 ms a 5 ms: todavía tiene 95 ms. Incluso si optimiza el servicio al máximo hasta 1 ms, el retraso total seguirá siendo de 91 ms. Probablemente coma pescado más grande.

    ¡Optimice sabiamente!

    Referencias


    Si desea más información, aquí hay excelentes fuentes de inspiración:

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


All Articles