Recientemente se agreg贸 una gran caracter铆stica a la l铆nea principal del compilador de Clang . Con los atributos
[[clang::musttail]]
o ,
__attribute__((musttail))
ahora puede obtener llamadas finales garantizadas en C, C ++ y Objective-C.
int g(int);
int f(int x) {
__attribute__((musttail)) return g(x);
}
( Compilador en l铆nea )
Por lo general, estas llamadas est谩n asociadas con la programaci贸n funcional, pero solo me interesan desde el punto de vista del rendimiento. En algunos casos, con su ayuda, puede obtener un mejor c贸digo del compilador, al menos con las tecnolog铆as de compilaci贸n disponibles, sin recurrir a un ensamblador.
El uso de este enfoque para analizar el protocolo de serializaci贸n dio resultados sorprendentes: pudimos analizar a velocidades superiores a 2 Gb / s. , m谩s del doble de r谩pido que la mejor soluci贸n anterior. Esta aceleraci贸n ser谩 煤til en muchas situaciones, por lo que ser铆a incorrecto limitarse a la declaraci贸n "tail calls == double speedup". Sin embargo, estos desaf铆os son un elemento clave que ha hecho posible ese aumento de velocidad.
Te dir茅 cu谩les son las ventajas de las llamadas finales, c贸mo analizamos el protocolo us谩ndolas y c贸mo podemos extender esta t茅cnica a los int茅rpretes. Creo que gracias a 茅l, los int茅rpretes de los principales lenguajes escritos en C (Python, Ruby, PHP, Lua, etc.) pueden obtener ganancias de rendimiento significativas. El principal inconveniente est谩 relacionado con la portabilidad: hoy
musttail
es una extensi贸n de compilador no est谩ndar. Aunque espero que se ponga al d铆a, pasar谩 alg煤n tiempo antes de que la extensi贸n se extienda lo suficiente para que el compilador de C de su sistema la admita. Al construir, puede sacrificar la eficiencia a cambio de la portabilidad si resulta que
musttail
no est谩 disponible.
Conceptos b谩sicos de Tail Call
Una llamada de cola es una llamada a cualquier funci贸n en la posici贸n de cola, la 煤ltima acci贸n antes de que la funci贸n devuelva un resultado. Al optimizar las llamadas de cola, el compilador compila la instrucci贸n para la llamada de cola
jmp
, no
call
. Esto no realiza acciones generales que normalmente permitir铆an
g()
a la persona que llama regresar a la persona que llama
f()
, como crear un nuevo marco de pila o pasar una direcci贸n de retorno. En cambio, se
f()
refiere directamente a
g()
茅l como si fuera parte de s铆 mismo y
g()
devuelve el resultado directamente a la persona que llama
f()
. Esta optimizaci贸n es segura porque el marco de la pila
f()
ya no es necesario despu茅s del inicio de la llamada de cola, porque se hizo imposible acceder a cualquier variable local
f()
.
Incluso si parece trivial, esta optimizaci贸n tiene dos caracter铆sticas importantes que abren nuevas posibilidades para escribir algoritmos. Primero, al ejecutar n llamadas de cola consecutivas, la pila de memoria se reduce de O (n) a O (1). Esto es importante porque la pila es limitada y el desbordamiento puede bloquear el programa. Entonces, sin esta optimizaci贸n, algunos algoritmos son peligrosos. En segundo lugar,
jmp
elimina la sobrecarga de
call
y como resultado, la llamada a la funci贸n se vuelve tan eficiente como cualquier otra rama. Ambas caracter铆sticas permiten que las llamadas de cola se utilicen como una alternativa eficiente a las estructuras de control iterativo convencionales como
for
y
while
.
Esta idea no es nueva en absoluto y se remonta a 1977, cuando Guy Steele escribi贸 un art铆culo completo en el que argument贸 que las llamadas a procedimientos son m谩s limpias que las arquitecturas
GOTO
, mientras que la optimizaci贸n de las llamadas finales no pierde velocidad. Fue una de las "Obras Lambda" escritas entre 1975 y 1980, que formul贸 muchas de las ideas detr谩s de Lisp y Scheme.
La optimizaci贸n de llamadas de cola no es nada nuevo, incluso para Clang. Pod铆a optimizarlos antes, como GCC y muchos otros compiladores. De hecho, el atributo
musttail
en este ejemplo no cambia la salida del compilador en absoluto: Clang ya ha optimizado las llamadas de cola para
-O2
.
Lo nuevo aqu铆 es una garant铆a . Si bien los compiladores suelen tener 茅xito en la optimizaci贸n de las llamadas finales, esto es lo "mejor posible" y no se puede confiar en ello. En particular, la optimizaci贸n seguramente no funcionar谩 en compilaciones no optimizadas: el compilador en l铆nea . En este ejemplo, la llamada de cola se compila
call
, por lo que estamos de vuelta en una pila de tama帽o O (n). Es por eso que necesitamos
musttail
: Hasta que tengamos una garant铆a del compilador de que nuestras llamadas de cola siempre estar谩n optimizadas en todos los modos de ensamblaje, no ser谩 seguro escribir algoritmos con tales llamadas para iteraci贸n. Y ce帽irse al c贸digo que solo funciona con optimizaciones habilitadas es una restricci贸n bastante dif铆cil.
El problema con los bucles de int茅rprete
Los compiladores son una tecnolog铆a incre铆ble, pero no son perfectos. Mike Pall, el autor de LuaJIT, decidi贸 escribir el int茅rprete de LuaJIT 2.x en lenguaje ensamblador, no en C, y lo llam贸 el factor principal que hizo que el int茅rprete fuera tan r谩pido . Paul explic贸 m谩s tarde con m谩s detalle por qu茅 los compiladores de C tienen dificultades para encontrar los bucles principales del int茅rprete . Dos puntos principales:
- , .
- , .
Estas observaciones reflejan bien nuestra experiencia en la optimizaci贸n del an谩lisis del protocolo de serializaci贸n. Y las llamadas de cola nos ayudar谩n a resolver ambos problemas.
Puede que le resulte extra帽o comparar los bucles del int茅rprete con los analizadores del protocolo de serializaci贸n. Sin embargo, su similitud inesperada est谩 determinada por la naturaleza del formato de cable del protocolo: es un conjunto de pares clave-valor en el que la clave contiene el n煤mero de campo y su tipo. La clave funciona como un c贸digo de operaci贸n en el int茅rprete: nos dice qu茅 operaci贸n se debe realizar para analizar este campo. Los n煤meros de campo en el protocolo pueden ir en cualquier orden, por lo que debe estar listo para saltar a cualquier parte del c贸digo en cualquier momento.
Ser铆a l贸gico escribir un analizador de este tipo utilizando un bucle
while
con una expresi贸n anidada
switch
... Este ha sido el mejor enfoque para analizar un protocolo de serializaci贸n durante la vida 煤til del protocolo. Por ejemplo, aqu铆 est谩 el c贸digo de an谩lisis de la versi贸n actual de C ++ . Si representamos el flujo de control gr谩ficamente, obtenemos el siguiente esquema:
Pero el diagrama no est谩 completo, porque pueden surgir problemas en casi todas las etapas. Es posible que el tipo de campo sea incorrecto, que los datos est茅n da帽ados o que simplemente saltemos al final del b煤fer actual. El diagrama completo se ve as铆:
Necesitamos permanecer en las rutas r谩pidas (azul) el mayor tiempo posible, pero cuando nos enfrentemos a una situaci贸n dif铆cil, tendremos que manejarla usando el c贸digo de reserva. Estas rutas suelen ser m谩s largas y complejas que las r谩pidas, involucran m谩s datos y, a menudo, usan llamadas inc贸modas a otras funciones para manejar casos a煤n m谩s complejos.
En teor铆a, si combina este esquema con el perfil, el compilador obtendr谩 toda la informaci贸n que necesita para generar el c贸digo m谩s 贸ptimo. Pero en la pr谩ctica, con este tama帽o de funci贸n y el n煤mero de conexiones, a menudo tenemos que luchar con el compilador. Arroja una variable importante que queremos almacenar en el registro. Aplica la manipulaci贸n del marco de pila que queremos envolver alrededor de la llamada a la funci贸n de reserva. Concatena rutas de c贸digo id茅nticas que queremos mantener separadas para la predicci贸n de ramas. Todo parece como intentar tocar el piano con guantes.
Mejora de bucles de int茅rprete con llamadas de cola
El razonamiento descrito anteriormente es en gran parte una reformulaci贸n de las observaciones de Pablo sobre los ciclos principales del int茅rprete . Pero en lugar de lanzarnos al ensamblador, descubrimos que la arquitectura personalizada puede brindarnos el control que necesitamos para obtener un c贸digo casi 贸ptimo de C. Trabaj茅 en esto con mi colega Gerben Stavenga, a quien se le ocurri贸 la mayor parte de la arquitectura. Nuestro enfoque es similar al int茅rprete wasm3 WebAssembly , que llama a este patr贸n "metamachine" .
Ponemos el c贸digo para nuestro analizador de 2 gigabits en upb, una peque帽a biblioteca protobuf en C. Est谩 en pleno funcionamiento y pasa todas las pruebas de conformidad con el protocolo de serializaci贸n, pero a煤n no se ha implementado en ning煤n lugar y la arquitectura no se ha implementado en la versi贸n C ++ del protocolo. Pero cuando la extensi贸n lleg贸 a Clang
musttail
(y upb se actualiz贸 para usarla ), una de las principales barreras para la implementaci贸n completa de nuestro analizador r谩pido desapareci贸.
Nos hemos alejado de una gran funci贸n de an谩lisis y aplicamos su propia funci贸n peque帽a para cada operaci贸n. Cada funci贸n de cola llama a la siguiente operaci贸n de la secuencia. Por ejemplo, aqu铆 hay una funci贸n para analizar un solo campo de un tama帽o fijo (el c贸digo est谩 simplificado en comparaci贸n con upb, he eliminado muchos peque帽os detalles de la arquitectura).
El c贸digo
#include <stdint.h>
#include <stddef.h>
#include <string.h>
typedef void *upb_msg;
struct upb_decstate;
typedef struct upb_decstate upb_decstate;
// The standard set of arguments passed to each parsing function.
// Thanks to x86-64 calling conventions, these will be passed in registers.
#define UPB_PARSE_PARAMS \
upb_decstate *d, const char *ptr, upb_msg *msg, intptr_t table, \
uint64_t hasbits, uint64_t data
#define UPB_PARSE_ARGS d, ptr, msg, table, hasbits, data
#define UNLIKELY(x) __builtin_expect(x, 0)
#define MUSTTAIL __attribute__((musttail))
const char *fallback(UPB_PARSE_PARAMS);
const char *dispatch(UPB_PARSE_PARAMS);
// Code to parse a 4-byte fixed field that uses a 1-byte tag (field 1-15).
const char *upb_pf32_1bt(UPB_PARSE_PARAMS) {
// Decode "data", which contains information about this field.
uint8_t hasbit_index = data >> 24;
size_t ofs = data >> 48;
if (UNLIKELY(data & 0xff)) {
// Wire type mismatch (the dispatch function xor's the expected wire type
// with the actual wire type, so data & 0xff == 0 indicates a match).
MUSTTAIL return fallback(UPB_PARSE_ARGS);
}
ptr += 1; // Advance past tag.
// Store data to message.
hasbits |= 1ull << hasbit_index;
memcpy((char*)msg + ofs, ptr, 4);
ptr += 4; // Advance past data.
// Call dispatch function, which will read the next tag and branch to the
// correct field parser function.
MUSTTAIL return dispatch(UPB_PARSE_ARGS);
}
Para una funci贸n tan peque帽a y simple, Clang genera c贸digo que es casi imposible de superar:
upb_pf32_1bt: # @upb_pf32_1bt
mov rax, r9
shr rax, 24
bts r8, rax
test r9b, r9b
jne .LBB0_1
mov r10, r9
shr r10, 48
mov eax, dword ptr [rsi + 1]
mov dword ptr [rdx + r10], eax
add rsi, 5
jmp dispatch # TAILCALL
.LBB0_1:
jmp fallback # TAILCALL
Tenga en cuenta que no hay pr贸logo ni ep铆logo, ni preferencia de registro, ni uso de pila en absoluto. Las 煤nicas salidas son
jmp
de dos llamadas de cola, pero no se necesita c贸digo para pasar par谩metros, porque los argumentos ya est谩n en los registros correctos. Quiz谩s la 煤nica mejora posible que vemos aqu铆 es un salto condicional para una llamada de cola en
jne fallback
lugar de
jne
una llamada posterior
jmp
.
Si viera un desmontaje de este c贸digo sin informaci贸n simb贸lica, no se dar铆a cuenta de que esta era la funci贸n completa. Tambi茅n podr铆a ser la unidad base de una funci贸n mayor. Y eso es exactamente lo que hacemos: tomamos el bucle del int茅rprete, que es una funci贸n grande y compleja, y lo programamos bloque por bloque, pasando el flujo de control entre ellos a trav茅s de llamadas de cola. Tenemos un control completo sobre la distribuci贸n de registros en el l铆mite de cada bloque (al menos seis registros), y dado que la funci贸n es lo suficientemente simple y no reemplaza los registros, hemos logrado nuestro objetivo de almacenar el estado m谩s importante a lo largo de todos los registros r谩pidos. rutas.
Podemos optimizar de forma independiente cada secuencia de instrucciones. Y el compilador tambi茅n manejar谩 todas las secuencias de forma independiente, ya que est谩n ubicadas en funciones separadas (si es necesario, puede evitar alinearse con
noinline
). As铆 es como resolvemos el problema descrito anteriormente, en el que el c贸digo de las rutas de respaldo degrada la calidad del c贸digo de las rutas r谩pidas. Al colocar las rutas lentas en funciones completamente separadas, se puede garantizar la estabilidad de velocidad de las rutas r谩pidas. El hermoso ensamblador permanece sin cambios, no se ve afectado por ning煤n cambio en otras partes del analizador.
Si aplicamos este patr贸n al ejemplo de Paul de LuaJIT , entonces podemos aproximadamente correlacionar su ensamblador escrito a mano con peque帽as degradaciones en la calidad del c贸digo :
#define PARAMS unsigned RA, void *table, unsigned inst, \
int *op_p, double *consts, double *regs
#define ARGS RA, table, inst, op_p, consts, regs
typedef void (*op_func)(PARAMS);
void fallback(PARAMS);
#define UNLIKELY(x) __builtin_expect(x, 0)
#define MUSTTAIL __attribute__((musttail))
void ADDVN(PARAMS) {
op_func *op_table = table;
unsigned RC = inst & 0xff;
unsigned RB = (inst >> 8) & 0xff;
unsigned type;
memcpy(&type, (char*)®s[RB] + 4, 4);
if (UNLIKELY(type > -13)) {
return fallback(ARGS);
}
regs[RA] += consts[RC];
inst = *op_p++;
unsigned op = inst & 0xff;
RA = (inst >> 8) & 0xff;
inst >>= 16;
MUSTTAIL return op_table[op](ARGS);
}
El ensamblador resultante:
ADDVN: # @ADDVN
movzx eax, dh
cmp dword ptr [r9 + 8*rax + 4], -12
jae .LBB0_1
movzx eax, dl
movsd xmm0, qword ptr [r8 + 8*rax] # xmm0 = mem[0],zero
mov eax, edi
addsd xmm0, qword ptr [r9 + 8*rax]
movsd qword ptr [r9 + 8*rax], xmm0
mov edx, dword ptr [rcx]
add rcx, 4
movzx eax, dl
movzx edi, dh
shr edx, 16
mov rax, qword ptr [rsi + 8*rax]
jmp rax # TAILCALL
.LBB0_1:
jmp fallback
La 煤nica mejora que veo aqu铆 adem谩s de lo anterior es
jne fallback
que, por alguna raz贸n, el compilador no quiere generar
jmp qword ptr [rsi + 8*rax]
, sino que carga
rax
y luego ejecuta
jmp rax
. Estos son problemas menores de codificaci贸n que espero que Clang solucione pronto sin demasiada dificultad.
Limitaciones
Uno de los principales inconvenientes de este enfoque es que todas estas hermosas secuencias de lenguaje ensamblador est谩n catastr贸ficamente pesimizadas en ausencia de llamadas finales. Cualquier llamada no personalizada crear谩 un marco de pila y enviar谩 una gran cantidad de datos a la pila.
#define PARAMS unsigned RA, void *table, unsigned inst, \
int *op_p, double *consts, double *regs
#define ARGS RA, table, inst, op_p, consts, regs
typedef void (*op_func)(PARAMS);
void fallback(PARAMS);
#define UNLIKELY(x) __builtin_expect(x, 0)
#define MUSTTAIL __attribute__((musttail))
void ADDVN(PARAMS) {
op_func *op_table = table;
unsigned RC = inst & 0xff;
unsigned RB = (inst >> 8) & 0xff;
unsigned type;
memcpy(&type, (char*)®s[RB] + 4, 4);
if (UNLIKELY(type > -13)) {
// When we leave off "return", things get real bad.
fallback(ARGS);
}
regs[RA] += consts[RC];
inst = *op_p++;
unsigned op = inst & 0xff;
RA = (inst >> 8) & 0xff;
inst >>= 16;
MUSTTAIL return op_table[op](ARGS);
}
De repente obtenemos esto
ADDVN: # @ADDVN
push rbp
push r15
push r14
push r13
push r12
push rbx
push rax
mov r15, r9
mov r14, r8
mov rbx, rcx
mov r12, rsi
mov ebp, edi
movzx eax, dh
cmp dword ptr [r9 + 8*rax + 4], -12
jae .LBB0_1
.LBB0_2:
movzx eax, dl
movsd xmm0, qword ptr [r14 + 8*rax] # xmm0 = mem[0],zero
mov eax, ebp
addsd xmm0, qword ptr [r15 + 8*rax]
movsd qword ptr [r15 + 8*rax], xmm0
mov edx, dword ptr [rbx]
add rbx, 4
movzx eax, dl
movzx edi, dh
shr edx, 16
mov rax, qword ptr [r12 + 8*rax]
mov rsi, r12
mov rcx, rbx
mov r8, r14
mov r9, r15
add rsp, 8
pop rbx
pop r12
pop r13
pop r14
pop r15
pop rbp
jmp rax # TAILCALL
.LBB0_1:
mov edi, ebp
mov rsi, r12
mov r13d, edx
mov rcx, rbx
mov r8, r14
mov r9, r15
call fallback
mov edx, r13d
jmp .LBB0_2
Para evitar esto, intentamos llamar a otras funciones solo a trav茅s de inlining o tail calls. Esto puede resultar tedioso si la operaci贸n tiene muchos lugares en los que puede ocurrir una situaci贸n inusual que no sea un error. Por ejemplo, cuando analizamos el protocolo de serializaci贸n, las variables enteras suelen tener un byte de longitud, pero las m谩s largas no son un error. Incluir el manejo de tales situaciones puede degradar la calidad de la ruta r谩pida si el c贸digo de reserva es demasiado complejo. Pero la llamada de cola de la funci贸n de reserva no facilita el regreso a la operaci贸n cuando se maneja una anomal铆a, por lo que la funci贸n de reserva debe poder completar la operaci贸n. Esto conduce a la duplicaci贸n y complicaci贸n del c贸digo.
Idealmente, este problema se puede resolver agregando __attribute __ ((preserve_most))en una funci贸n de reserva seguida de una llamada normal, no una llamada de cola. El atributo
preserve_most
hace que el destinatario de la llamada sea responsable de conservar casi todos los registros. Esto le permite delegar la tarea de apropiaci贸n de registros a funciones de respaldo, si es necesario. Experimentamos con este atributo, pero encontramos problemas misteriosos que no pudimos resolver. Quiz谩s cometimos un error en alguna parte, volveremos a esto en el futuro.
La principal limitaci贸n es que
musttail
no es port谩til. Realmente espero que el atributo se arraigue, se implemente en GCC, Visual C ++ y otros compiladores, y alg煤n d铆a incluso se estandarizar谩. Pero esto no suceder谩 pronto, pero 驴qu茅 debemos hacer ahora?
Cuando la expansi贸n
musttail
no est谩 disponible, debe ejecutar al menos uno correcto
return
sin una llamada de cola para cada iteraci贸n te贸rica del bucle . A煤n no hemos implementado tal respaldo en la biblioteca upb, pero creo que se convertir谩 en una macro que, dependiendo de la disponibilidad,
musttail
har谩 llamadas finales o simplemente regresar谩.