Golang: problemas de rendimiento específicos

El lenguaje Go está ganando popularidad. Tan seguro de que hay más y más conferencias, por ejemplo, GolangConf , y el lenguaje se encuentra entre las diez tecnologías mejor pagadas. Por lo tanto, ya tiene sentido hablar sobre sus problemas específicos, por ejemplo, el rendimiento. Además de los problemas comunes para todos los idiomas compilados, Go tiene el suyo. Están asociados con el optimizador, la pila, el sistema de tipos y el modelo multitarea. Las formas de resolverlos y las soluciones alternativas a veces son muy específicas.

Daniel Podolsky , aunque el evangelista de Go, también encuentra muchas cosas extrañas en él. Todo lo extraño y, lo más importante, interesante, recopila y prueba , y luego habla de ello en HighLoad ++. La transcripción del informe incluirá números, gráficos, ejemplos de código, resultados del generador de perfiles, una comparación del rendimiento de los mismos algoritmos en diferentes idiomas, y todo lo demás, por lo que odiamos la palabra "optimización". No habrá revelaciones en la transcripción, de dónde provienen en un lenguaje tan simple, y todo lo que se pueda leer en los periódicos.



Sobre los oradores. Daniil Podolsky : 26 años de experiencia, 20 en operación, incluido el líder del grupo, 5 años de programación en Go. Kirill Danshin : creador de Gramework, Maintainer, Fast HTTP, Black Go-mage.

El informe fue preparado conjuntamente por Daniil Podolsky y Kirill Danshin, pero Daniel hizo un informe y Cyril ayudó mentalmente.

Construcciones de lenguaje


Tenemos un estándar de rendimiento: direct . Esta es una función que incrementa una variable y ya no hace nada.

 //   var testInt64 int64 func BenchmarkDirect(b *testing.B) { for i := 0; i < bN; i++ { incDirect() } } func incDirect() { testInt64++ } 

El resultado de la función es 1.46 ns por operación . Esta es la opción mínima. Más rápido que 1.5 ns por operación, probablemente no funcionará.

Diferir cómo lo amamos


Muchos saben y aman usar la construcción de lenguaje diferido. Muy a menudo lo usamos así.

 func BenchmarkDefer(b *testing.B) { for i := 0; i < bN; i++ { incDefer() } } func incDefer() { defer incDirect() } 

¡Pero no puedes usarlo así! Cada aplazador come 40 ns por operación.

 //   BenchmarkDirect-4 2000000000 1.46 / // defer BenchmarkDefer-4 30000000 40.70 / 

Pensé que tal vez esto es por inline? Tal vez en línea es tan rápido?

Direct está en línea y la función diferir no puede estar en línea. Por lo tanto, compiló una función de prueba separada sin en línea.

 func BenchmarkDirectNoInline(b *testing.B) { for i := 0; i < bN; i++ { incDirectNoInline() } } //go:noinline func incDirectNoInline() { testInt64++ } 

Nada ha cambiado, diferir tomó los mismos 40 ns. Diferir querido, pero no catastrófico.

Cuando una función toma menos de 100 ns, puede hacerlo sin diferir.

Pero cuando la función tarda más de un microsegundo, es lo mismo: puede usar aplazar.

Pasando un parámetro por referencia


Considera un mito popular.

 func BenchmarkDirectByPointer(b *testing.B) { for i := 0; i < bN; i++ { incDirectByPointer(&testInt64) } } func incDirectByPointer(n *int64) { *n++ } 

Nada ha cambiado, nada vale la pena.

 //     BenchmarkDirectByPointer-4 2000000000 1.47 / BenchmarkDeferByPointer-4 30000000 43.90 / 

Excepto por 3 ns por aplazamiento, pero esto se cancela por fluctuaciones.

Funciones anónimas


A veces los novatos preguntan: "¿Es costosa una función anónima?"

 func BenchmarkDirectAnonymous(b *testing.B) { for i := 0; i < bN; i++ { func() { testInt64++ }() } } 

Una función anónima no es costosa, toma 40.4 ns.

Interfaces


Hay una interfaz y estructura que lo implementa.

 type testTypeInterface interface { Inc() } type testTypeStruct struct { n int64 } func (s *testTypeStruct) Inc() { s.n++ } 

Hay tres opciones para usar el método de incremento. Directamente desde Struct: var testStruct = testTypeStruct{} .

Desde la interfaz concreta correspondiente: var testInterface testTypeInterface = &testStruct .

Con conversión de interfaz en tiempo de ejecución: var testInterfaceEmpty interface{} = &testStruct .

A continuación se muestra la conversión y el uso de la interfaz en tiempo de ejecución directamente.

 func BenchmarkInterface(b *testing.B) { for i := 0; i < bN; i++ { testInterface.Inc() } } func BenchmarkInterfaceRuntime(b *testing.B) { for i := 0; i < bN; i++ { testInterfaceEmpty.(testTypeInterface).Inc() } } 

La interfaz, como tal, no cuesta nada.

 //  BenchmarkStruct-4 2000000000 1.44 / BenchmarkInterface-4 2000000000 1.88 / BenchmarkInterfaceRuntime-4 200000000 9.23 / 


La conversión de la interfaz de tiempo de ejecución vale la pena, pero no es costosa, no es necesario que se niegue específicamente. Pero trate de prescindir de él cuando sea posible.

Mitos:

  • Desreferencia - punteros de desreferenciación - gratis.
  • Las funciones anónimas son gratuitas.
  • Las interfaces son gratis.
  • Conversión de interfaz de tiempo de ejecución: NO GRATUITA.

Cambiar, mapear y cortar


Cada recién llegado a Go pregunta qué sucede si reemplaza el interruptor con el mapa. ¿Será más rápido?

El interruptor viene en diferentes tamaños. Probé en tres tamaños: pequeño para 10 casos, mediano para 100 y grande para 1000 casos. El interruptor para 1000 casos se encuentra en el código de producción real. Por supuesto, nadie los escribe con sus manos. Este es un código generado automáticamente, generalmente un interruptor de tipo. Probado en dos tipos: int y string. Parecía que resultaría más claro.

Pequeño interruptor. La opción más rápida es el cambio real. A continuación, se corta inmediatamente, donde el índice entero correspondiente contiene una referencia a la función. Map no es un líder en int o string.
BenchmarkSwitchIntSmall-45000000003.26 ns / op
BenchmarkMapIntSmall-4100,000,00011.70 ns / op
BenchmarkSliceIntSmall-45000000003.85 ns / op
BenchmarkSwitchStringSmall-4100,000,00012.70 ns / op
BenchmarkMapStringSmall-4100,000,00015.60 ns / op

El encendido de cadenas es significativamente más lento que en int. Si puede hacer un cambio no a una cadena, sino a int, entonces hágalo.

Interruptor central Switch en sí mismo todavía gobierna int, pero slice lo ha superado un poco. El mapa sigue siendo malo. Pero en una clave de cadena, el mapa es más rápido que el cambio, como se esperaba.
BenchmarkSwitchIntMedium-43000000004.55 ns / op
BenchmarkMapIntMedium-4100,000,00017.10 ns / op
BenchmarkSliceIntMedium-43000000003.76 ns / op
BenchmarkSwitchStringMedium-450,000,00028.50 ns / op
BenchmarkMapStringMedium-4100,000,00020.30 ns / op

Gran cambio Mil casos muestran la victoria incondicional del mapa en la nominación "cambiar por cadena". Teóricamente, rebanada ganó, pero en la práctica le aconsejo que use el mismo interruptor aquí. El mapa sigue siendo lento, incluso teniendo en cuenta que el mapa tiene teclas enteras con una función hash especial. En general, esta función no hace nada. El int en sí tiene un hash para int.
BenchmarkSwitchIntLarge-4100,000,00013,6 ns / op
BenchmarkMapIntLarge-450,000,00034,3 ns / op
BenchmarkSliceIntLarge-4100,000,00012.8 ns / op
BenchmarkSwitchStringLarge-420,000,000100.0 ns / op
BenchmarkMapStringLarge-43000000037.4 ns / op

Conclusiones El mapa solo es mejor en grandes cantidades y no en una condición de entero. Estoy seguro de que en cualquiera de las condiciones, excepto int, se comportará igual que en la cadena. Slice siempre se dirige cuando las condiciones son enteras. Úselo si desea "acelerar" su programa en 2 ns.

Interacción entre rutinas


El tema es complejo, he realizado muchas pruebas y presentaré las más reveladoras. Conocemos los siguientes medios de interacción entre agencias .

  • Atómico Estos son medios de aplicabilidad limitada: puede reemplazar el puntero o usar int.
  • Mutex se ha utilizado ampliamente desde Java.
  • El canal es exclusivo de GO.
  • Canal con búfer: canales con búfer.

Por supuesto, probé en un número significativamente mayor de gorutinas que compiten por un recurso. Pero eligió tres para sí mismo como indicativo: un poco - 100, un medio - 1000 y mucho - 10000.

El perfil de carga es diferente . A veces, todas las gorutinas quieren escribir en una variable, pero esto es raro. Por lo general, después de todo, algunos escriben, otros leen. De la mayoría de los lectores, el 90% lee, de los que escriben, el 90% escribe.

Este es el código que se usa para que la rutina que sirve al canal pueda proporcionar tanto lectura como escritura a una variable.

 go func() { for { select { case n, ok := <-cw: if !ok { wgc.Done() return } testInt64 += n case cr <- testInt64: } } }() 

Si nos llega un mensaje a través del canal a través del cual escribimos, lo ejecutamos. Si el canal está cerrado, terminamos goroutin. En cualquier momento, estamos listos para escribir en el canal que utilizan otras gorutinas para leer.
Benchmarkmutex-4100,000,00016.30 ns / op
Benchmarkatomic-42000000006.72 ns / op
Benchmarkcan-45,000,000239.00 ns / op

Estos son datos para una gorutina. La prueba del canal se realiza en dos goroutines: uno procesa el canal, el otro escribe en este canal. Y estas opciones han sido probadas en una.

  • Escrituras directas a una variable.
  • Mutex toma un registro, escribe en una variable y libera un registro.
  • Atomic escribe en una variable a través de Atomic. No es gratis, pero sigue siendo significativamente más barato que Mutex en una garutina.

Con una pequeña cantidad de gorutina, el Atomic es una forma efectiva y rápida de sincronizar, lo cual no es sorprendente. Direct no está aquí, porque necesitamos sincronización, que no proporciona. Pero Atomic tiene fallas, por supuesto.
BenchmarkMutexFew-43000055894 ns / op
BenchmarkAtomicFew-4100,00014585 ns / op
BenchmarkChanFew-45000323859 ns / op
BenchmarkChanBufferedFew-45000341321 ns / op
BenchmarkChanBufferedFullFew-42000070052 ns / op
BenchmarkMutexMostlyReadFew-43000056402 ns / op
BenchmarkAtomicMostlyReadFew-41,000,0002094 ns / op
BenchmarkChanMostlyReadFew-43000442689 ns / op
BenchmarkChanBufferedMostlyReadFew-43000449,666 ns / op
BenchmarkChanBufferedFullMostlyReadFew-45000442,708 ns / op
BenchmarkMutexMostlyWriteFew-42000079708 ns / op
BenchmarkAtomicMostlyWriteFew-4100,00013358 ns / op
BenchmarkChanMostlyWriteFew-43000449,556 ns / op
BenchmarkChanBufferedMostlyWriteFew-43000445423 ns / op
BenchmarkChanBufferedFullMostlyWriteFew-43000414626 ns / op

El siguiente es Mutex. Esperaba que Channel fuera tan rápido como Mutex, pero no.

Channel es un orden de magnitud más caro que Mutex.

Además, el canal y el canal protegido tienen un precio similar. Y hay Channel, en el que el búfer nunca se desborda. Es un orden de magnitud más barato que aquel cuyo búfer se desborda. Solo si el búfer en Channel no está lleno, entonces cuesta aproximadamente lo mismo en orden de magnitud que Mutex. Esto es lo que esperaba de la prueba.

Esta imagen con la distribución de cuánto cuesta se repite en cualquier perfil de carga, tanto en MostRead como en EverythingWrite. Además, el canal completo mayoría de lectura cuesta lo mismo que el incompleto. Y el canal protegido en su mayoría de WriteWrite, en el que el almacenamiento intermedio no está lleno, cuesta lo mismo que el resto. No puedo decir por qué esto es así: todavía no he estudiado este problema.

Pasando parámetros


¿Cómo pasar parámetros más rápido, por referencia o por valor? Vamos a verlo

Verifiqué lo siguiente: hice tipos anidados del 1 al 10.

 type TP001 struct { I001 int64 } type TV002 struct { I001 int64 S001 TV001 I002 int64 S002 TV001 } 

El décimo tipo anidado tendrá 10 campos int64, y los tipos anidados de la anidación anterior también serán 10.

Luego escribió funciones que crean un tipo de anidamiento.

 func NewTP001() *TP001 { return &TP001{ I001: rand.Int63(), } } func NewTV002() TV002 { return TV002{ I001: rand.Int63(), S001: NewTV001(), I002: rand.Int63(), S002: NewTV001(), } } 

Para las pruebas, utilicé tres opciones del tipo: pequeño con anidación 2, mediano con anidación 3, grande con anidación 5. Tuve que hacer una prueba muy grande con anidación 10 por la noche, pero allí la imagen es exactamente la misma que para 5.

En las funciones, pasar por valor es al menos dos veces más rápido que pasar por referencia . Esto se debe al hecho de que pasar por valor no carga el análisis de escape. En consecuencia, las variables que asignamos están en la pila. Es sustancialmente más barato para el tiempo de ejecución, para el recolector de basura. Aunque puede que no tenga tiempo para conectarse. Estas pruebas continuaron durante unos segundos: el recolector de basura probablemente todavía estaba dormido.
BenchmarkCreateSmallByValue-4200,0008942 ns / op
BenchmarkCreateSmallByPointer-4100,00015985 ns / op
BenchmarkCreateMediuMByValue-42000862317 ns / op
BenchmarkCreateMediuMByPointer-420001228130 ns / op
BenchmarkCreateLargeByValue-43047398456 ns / op
BenchmarkCreateLargeByPointer-42061928751 ns / op

Magia negra


¿Sabes qué generará este programa?

 package main type A struct { a, b int32 } func main() { a := new(A) aa = 0 ab = 1 z := (*(*int64)(unsafe.Pointer(a))) fmt.Println(z) } 

El resultado del programa depende de la arquitectura en la que se ejecuta. En little endian, por ejemplo, AMD64, el programa muestra 232. En Big Endian, uno. El resultado es diferente, porque en little endian esta unidad aparece en el medio del número, y en big endian - al final.

Todavía hay procesadores en el mundo donde los conmutadores endian, por ejemplo, Power PC. Será necesario averiguar qué endian está configurado en su computadora al inicio, antes de hacer inferencias sobre lo que hacen los trucos inseguros de este tipo. Por ejemplo, si escribe un código Go que se ejecutará en algún servidor multiprocesador de IBM.

Cité este código para explicar por qué considero toda la magia negra insegura. No necesitas usarlo. Pero Cyril cree que es necesario. Y aquí está el por qué.

Hay una función que hace lo mismo que GOB: Go Binary Marshaller. Este es el codificador, pero no es seguro.

 func encodeMut(data []uint64) (res []byte) { sz := len(data) * 8 dh := (*header)(unsafe.Pointer(&data)) rh := &header{ data: dh.data, len: sz, cap: sz, } res = *(*[]byte)(unsafe.Pointer(&rh)) return } 

De hecho, toma un pedazo de memoria y extrae una matriz de bytes.

Esto ni siquiera es una orden, son dos órdenes. Por lo tanto, Cyril Danshin, cuando escribe un código de alto rendimiento, no duda en entrar en las entrañas de su programa y hacerlo inseguro.

Benchmark gob-4200,0008466 ns / op120,94 MB / s
BenchmarkUnsafeMut-450,000,00037 ns / op27691.06 MB / s
Discutiremos características más específicas de Go el 7 de octubre en GolangConf , una conferencia para aquellos que usan Go en el desarrollo profesional y para aquellos que consideran este lenguaje como una alternativa. Daniil Podolsky es solo un miembro del Comité del Programa, si desea discutir este artículo o revelar problemas relacionados, presente una solicitud para un informe.

Para todo lo demás, con respecto al alto rendimiento, por supuesto, HighLoad ++ . También aceptamos solicitudes allí. Suscríbase al boletín y manténgase actualizado con las noticias de todas nuestras conferencias para desarrolladores web.

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


All Articles