Mike Pall explica por qué un compilador de C pierde en el bucle de un intérprete
El autor de LuaJIT detalla en la lista de Lua qué decisiones toman mal los compiladores en el bucle de un intérprete y qué gana al escribirlo en ensamblador
Mike Pall, autor de LuaJIT, explicó en la lista de correo de Lua por qué el bucle principal de un intérprete es terreno hostil para un compilador de C y qué hace él en ensamblador para exprimirlo. La respuesta es de 2011 y sigue apareciendo en agregadores porque el problema no ha cambiado: los compiladores modernos están afinados para código corriente, y un bucle de despacho no es código corriente.
El grafo que ahoga al compilador
Un intérprete con despacho por switch encadena carga de instrucción, despacho, decodificación de operandos, ejecución y vuelta a empezar. Pall describe ese grafo de control como una sucesión de rombos anidados: cada instrucción lleva su ruta rápida y decenas de rutas lentas. Es el peor caso posible para casi cualquier optimización y para la asignación de registros.
El compilador no distingue lo que es camino rápido de lo que es camino lento, porque nadie se lo dice. Ve un único grafo gigante en el que todo puede afectar a todo, así que no puede elevar ni eliminar casi nada. Las rutas lentas matan las oportunidades de las rápidas, y las instrucciones complejas matan las de las simples. A esa escala, las heurísticas de asignación de registros fallan y las variables importantes acaban fuera de los registros.
El truco del goto calculado de GCC, que permite intérpretes con despacho threaded incluso en C, ayuda al predictor de saltos porque replica la carga y el despacho. Pero arrastra otros problemas: no hay bucle que el compilador reconozca, cualquiera de esos goto puede saltar casi a cualquier parte y un almacén con aliasing en una ruta lenta tira por tierra el hoisting. El asignador de registros trata cada segmento por separado y hace un trabajo malo. El tail-merging y el CSE se encargan además de fusionar los finales comunes y dejar un único punto de despacho.
Lo que cambia al bajar a ensamblador
En ensamblador, dice Pall, se puede fijar una asignación de registros para todas las instrucciones, mantener todo en registros en las rutas rápidas y derramar o recargar solo en las lentas. Las rutas lentas se mueven a otro sitio, lo que ayuda a la densidad de la I-cache, y se precargan instrucciones y se predecodifican operandos. El resultado cabe en unas pocas instrucciones de máquina.
En PPC/e500 necesitó algún truco más: fusionar la decodificación de operandos con el escalado del índice —ahí entra la instrucción rlwinm—, planificar a mano la carga de la instrucción por encima de los almacenes de la ruta rápida o usar instrucciones vectoriales para las comprobaciones de tipo. También enlaza un ejemplo del intérprete x86 de LuaJIT.
Su diagnóstico final: un compilador moderno es un conjunto de heurísticas que interactúan de formas raras y que se han afinado para código de tipo medio. Un intérprete es otra bestia, así que el resultado decepciona. Y recuerda que cada por ciento cuenta.
Para quien escribe intérpretes, emuladores o motores JIT, el hilo es material de referencia más que una novedad: describe dónde se pierde el rendimiento y qué palancas quedan cuando se baja al metal.
