Incorporación defectuosa de funciones en Go


¿El código que se muestra a continuación es equivalente en rendimiento?


// (A).  HasPrefix  . return strings.HasPrefix(s, "#") // (B).    HasPrefix. return len(s) >= len("#") && s[:len("#")] == "#" 

La respuesta es no .


Para detalles y explicaciones, pregunto bajo cat.




Buen día, antes de abrir el tema, me gustaría presentarme.
Mi nombre es Iskander y de vez en cuando envío confirmaciones al repositorio golang / go .


imagen

Solía ​​hacer esto en nombre del equipo Intel Go , pero nuestros caminos divergieron y ahora soy un colaborador independiente. Recientemente he estado trabajando en vk en el equipo de infraestructura.


En mi tiempo libre hago diferentes herramientas para Go, como go-critical y go-consistente . También dibujo topos .




¡Mídelo!


Proceda inmediatamente a la comparación y describa el punto de referencia:


 package benchmark import ( "strings" "testing" ) var s = "#string" func BenchmarkHasPrefixCall(b *testing.B) { for i := 0; i < bN; i++ { _ = strings.HasPrefix(s, "#") _ = strings.HasPrefix(s, "x") } } func BenchmarkHasPrefixInlined(b *testing.B) { for i := 0; i < bN; i++ { _ = len(s) >= len("#") && s[:len("#")] == "#" _ = len(s) >= len("x") && s[:len("x")] == "x" } } 

En lugar de recomendarte benchstat , te mostraré benchrun .


Con un comando, podemos ejecutar ambos puntos de referencia y obtener una comparación:


 go-benchrun HasPrefixCall HasPrefixInlined -v -count=10 . Benchstat results: name old time/op new time/op delta HasPrefixCall-8 9.15ns ± 1% 0.36ns ± 3% -96.09% (p=0.000 n=10+9) 

La opción con incrustación manual es mucho más rápida que el código que se obtuvo al incrustar el cuerpo de la función con el compilador. Tratemos de descubrir por qué sucede esto.


strings.HasPrefix


Recordemos la implementación de strings.HasPrefix . strings.HasPrefix :


 // HasPrefix tests whether the string s begins with prefix. func HasPrefix(s, prefix string) bool { return len(s) >= len(prefix) && s[0:len(prefix)] == prefix } 

La función HasPrefix integrada por el compilador.
Puede verificar esto de la siguiente manera:


 go build -gcflags='-m=2' strings 2>&1 | grep 'can inline HasPrefix' 

Para llamar a strings.HasPrefix de la opción (A) obtenemos el siguiente código de máquina:


  MOVQ (TLS), CX CMPQ SP, 16(CX) JLS more_stack fn_body: SUBQ $40, SP MOVQ BP, 32(SP) LEAQ 32(SP), BP XCHGL AX, AX MOVQ s+56(SP), AX CMPQ AX, $1 JGE compare_strings XORL AX, AX MOVB AL, ~ret1+64(SP) MOVQ 32(SP), BP ADDQ $40, SP return: RET compare_strings: MOVQ s+48(SP), AX MOVQ AX, (SP) LEAQ go.string."#"(SB), AX MOVQ AX, 8(SP) MOVQ $1, 16(SP) CALL runtime.memequal(SB) MOVBLZX 24(SP), AX JMP return more_stack: CALL runtime.morestack_noctxt(SB) JMP fn_body 

Ignora el hecho de que el código parece fideos.


A qué debe prestar atención:


  • strings.HasPrefix realmente se insertó, no hay llamada.
  • Para comparar cadenas, se runtime.memequal .

Pero, ¿qué se genera entonces para la versión incorporada manualmente, el código del ejemplo (B) ?


  MOVQ s+16(SP), AX CMPQ AX, $1 JLT different_length MOVQ s+8(SP), AX CMPB (AX), $35 // 35 -   "#" SETEQ AL return: MOVB AL, "".~ret1+24(SP) RET different_length: XORL AX, AX JMP 22 

Y aquí el compilador no genera una llamada a runtime.memequal y se compara directamente un solo carácter. Idealmente, debería haber hecho lo mismo para la primera opción.


Observamos el lado débil del optimizador Go y lo analizaremos.


Optimización de expresión constante


La razón por la que se pueden optimizar las strings.HasPrefix(s, "#") llamadas. strings.HasPrefix(s, "#") es porque el argumento prefijo es una constante. Conocemos su longitud y contenido. No tiene sentido llamar a runtime.memequal para cadenas cortas, es más rápido hacer una comparación de caracteres "en su lugar", evitando una llamada adicional.


Como sabe, los compiladores generalmente tienen al menos dos partes: interfaz del compilador y backend del compilador . El primero funciona con una vista de nivel superior, el segundo está más cerca de la máquina y la vista intermedia se verá como una secuencia de instrucciones. Varias versiones de Go ya han utilizado la representación SSA para optimizaciones en la parte del compilador del backend.


El plegado constante, como {10*2 => 20} , se implementa en el backend. En general, la mayoría de las operaciones asociadas con la reducción del costo computacional de las expresiones se encuentran en esta parte del compilador. Pero hay excepciones.


Una excepción es la optimización de las comparaciones de cadenas constantes. Cuando el compilador ve una comparación de cadena (o subcadena) en la que uno o ambos operandos son constantes, se genera un código más eficiente que una llamada a runtime.memequal .


Puede ver el código fuente responsable de esto en el archivo cmd / compile / internal / gc / walk.go: 3362 .


La incorporación de funciones ocurre antes de que se inicien estas optimizaciones, pero también en la parte frontend del compilador.


¿Parecería que de todos modos no permite que esta optimización funcione en nuestro caso?


Cómo ir incrusta llamadas de función


Así es como ocurrirá la incrustación:


 //    : return strings.HasPrefix(s, "#") //  : func HasPrefix(s, prefix string) bool //    : _s, _prefix := s, "#" return len(s) >= len(prefix) && s[:len(prefix)] == prefix 

Al incorporar funciones, el compilador asigna argumentos a variables temporales, lo que rompe las optimizaciones, ya que el algoritmo en walk.go no ve constantes, sino argumentos con variables. Ese es el problema


Por cierto, esto no interfiere con las optimizaciones de backend que la SSA tiene a su disposición. Pero hay otros problemas allí, por ejemplo, la incapacidad de restaurar construcciones de lenguaje de alto nivel para su comparación efectiva (el trabajo para eliminar este inconveniente ha sido "planificado" durante varios años).


Otro ejemplo: análisis de escape


Imagine una función que es importante para asignar un búfer temporal en la pila:


 func businessLogic() error { buf := make([]byte, 0, 16) // buf    //    . return nil } 

Como buf no se "escapa", el compilador podrá asignar estos 16 bytes en la pila, sin asignación en el montón. Nuevamente, todo gracias al valor constante al llamar a make . Para asignar memoria en la pila, es importante que sepamos el tamaño requerido, que formará parte del marco asignado a la llamada a la función.


Supongamos que en el futuro quisiéramos asignar búferes temporales de diferentes tamaños y encapsular cierta lógica en los métodos. Introdujimos una nueva abstracción y decidimos usar el nuevo tipo tmpBuf . La función de diseño es extremadamente simple:


 func newTmpBuf(sizeHint int) tmpBuf { return tmpBuf{buf: make([]byte, 0, sizeHint)} } 

Adaptando el ejemplo original:


 func businessLogic() error { - buf := make([]byte, 0, 16) + buf := newTmpBuf(16) // buf    //    . return nil } 

El constructor estará incrustado, pero la asignación ahora siempre estará en el montón, por la misma razón que los argumentos se pasan a través de variables temporales. El análisis de escape verá make([]byte, 0, _sizeHint) que no cae dentro de sus patrones de reconocimiento para make llamadas optimizadas.


Si tuviéramos "todo como personas", el problema no existiría, después de incorporar el newTmpBuf constructor newTmpBuf sería claro que el tamaño aún se conoce en la etapa de compilación.


Esto molesta casi más que la situación al comparar cadenas.


Horizons Go 1.13


La situación puede corregirse fácilmente y ya envié la primera parte de la decisión .


imagen

Si cree que el problema descrito en el artículo realmente necesita una solución, coloque un pulgar hacia arriba en el problema correspondiente .





Mi posición es que incrustar código con sus manos solo porque funciona más rápido en la versión actual de Go está mal. Es necesario corregir este defecto en el optimizador, al menos hasta el punto en que los ejemplos descritos anteriormente funcionen sin regresiones de rendimiento inesperadas.


Si todo va según lo planeado, esta optimización se incluirá en la versión Go 1.13.


Gracias por su atencion


Adición: solución propuesta


Esta sección es para los más valientes, aquellos que no están cansados ​​de leer.


Entonces, tenemos varios lugares que funcionan peor cuando se usan variables en lugar de sus valores directamente. La solución propuesta es introducir una nueva función en la interfaz de la parte del compilador, que le permite obtener el último valor enlazado por nombre. Después de eso, en cada optimización que espera un valor constante, no se rinda cuando se detecte una variable, sino que reciba este estado previamente guardado.


La firma de nuestra nueva característica podría verse así:


 func getConstValue(n *Node) *Node 

La definición de Node se puede encontrar en el archivo syntax.go .


Cada definición de variable tiene una etiqueta Node con una etiqueta ONAME . Dentro de Node.Name.Defn mayoría de estas variables tienen un valor de inicialización.


Si Node ya Node un literal, no necesita hacer nada y solo devolvemos n . Si esto es ONAME (variable), puede intentar extraer el mismo valor de inicialización de n.Name.Defn .


Pero, ¿qué pasa con las modificaciones entre declarar y leer una variable para la que llamamos getConstValue ? Si nos limitamos a las variables de solo lectura, entonces no hay problema. La interfaz de Go ya tiene indicadores de nodo especiales que marcan nombres similares. Si la variable ha sido modificada, getConstValue no devolverá un valor de inicialización.


Los programadores, como regla, no modifican los argumentos de entrada de los tipos numéricos y de cadena, y esto hace posible cubrir un número bastante grande de casos con este algoritmo primitivo.


Ahora estamos listos para considerar la implementación:


 func getConstValue(n *Node) *Node { //    ONAME    definition. if n.Op != ONAME || n.Name.Defn == nil { return n } //   ,     . // ,    ,     //      escape analysis' . maybeModified := n.Assigned() || n.Name.Defn.Assigned() || n.Addrtaken() if maybeModified { return n } // OAS - Node  . // n.Name.Defn.Left -  LHS. // n.Name.Defn.Right -  RHS. // consttype(v)     . //   CTxxx,      . if n.Name.Defn.Op == OAS { v := n.Name.Defn.Right if v != nil && consttype(v) != CTxxx { return v } } return n } 

Así es como cambia el código, que depende de las constantes:


 - i := indexconst(r) + i := indexconst(getConstValue(r)) 

Genial, e incluso funciona:


 n := 10 xs := make([]int, n) //     ! 

Antes de este cambio, el análisis de escape no podía obtener el valor de 10 a n , razón por la cual asumí la necesidad de colocar xs en el montón.


El código anterior es sintácticamente similar a la situación observada durante la incrustación. n puede ser una variable temporal que se agrega cuando se pasa el argumento.


Lamentablemente, hay matices.


Resolvimos el problema para las variables locales introducidas a través de OAS , pero Go inicializa las variables para las funciones integradas a través de OAS2 . Debido a esto, necesitamos un segundo cambio que amplíe la función getConstValue y modifique ligeramente el código del inliner en sí, porque, entre otras cosas, OAS2 no tiene un campo Defn adecuado.


Eso fueron malas noticias. Buenas noticias: el canal #gocontributing apareció en la holgura del idioma ruso , donde puedes compartir tus ideas y planes, hacer preguntas y discutir todo lo relacionado con la participación en el desarrollo de Go.

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


All Articles