
¿El código que se muestra a continuación es equivalente en rendimiento?
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 .

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
:
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:
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)
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 .

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 {
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.