Parsim Protobuff a> 2 Gb / s: c贸mo aprend铆 a amar la recursividad de la cola en C





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*)&regs[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*)&regs[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谩.



All Articles