¿Qué métodos se pueden usar para extender eficientemente la duración de la instrucción en x86 moderno?

Imagine que desea alinear una serie de instrucciones de ensamblaje x86 con ciertos límites. Por ejemplo, es posible que desee alinear los bucles en un límite de 16 o 32 bytes, o las instrucciones del paquete para que se coloquen de manera eficiente en el caché uop o lo que sea.

La forma más sencilla de lograr esto es con instrucciones de NOP de un byte, seguido de cerca por NOP de varios bytes . Aunque este último generalmente es más eficiente, ninguno de los métodos es gratuito: los NOP usan recursos de ejecución de front-end y también cuentan contra el límite de 1 cambio de nombre de 4 amplios en x86 moderno.

Otra opción es alargar de alguna manera algunas instrucciones para obtener la alineación que desea. Si esto se hace sin introducir nuevos puestos, parece mejor que el enfoque NOP. ¿Cómo se pueden hacer las instrucciones de manera eficiente por más tiempo en las CPUs x86 recientes?

En el mundo ideal, las técnicas de alargamiento serían simultáneamente:

  • Aplicable a la mayoría de las instrucciones
  • Capaz de alargar la instrucción por una cantidad variable
  • No atascar ni ralentizar los decodificadores
  • Ser eficientemente representado en el caché uop

No es probable que exista un solo método que satisfaga todos los puntos anteriores de forma simultánea, por lo que las respuestas correctas probablemente abordarán diversas compensaciones.


1 El límite es 5 o 6 en AMD Ryzen.

Considere el golf de código leve para reducir el tamaño del código en lugar de expandirlo , especialmente antes de un ciclo. por ejemplo, xor eax,eax / cdq si necesita dos registros a cero, o mov eax, 1 / lea ecx, [rax+1] para establecer los registros a 1 y 2 en solo 8 bytes en total en lugar de 10. Consulte Establecer todos los bits en la CPU Regístrese en 1 de manera eficiente para obtener más información al respecto, y Sugerencias para jugar golf en el código máquina x86 / x64 para obtener ideas más generales. Sin embargo, probablemente todavía desee evitar dependencias falsas.

O rellene espacio adicional creando una constante de vector sobre la marcha en lugar de cargarlo desde la memoria. (Sin embargo, agregar más presión de uop-cache podría ser peor para el bucle más grande que contiene tu configuración + bucle interno. Pero evita las fallas de d-cache para las constantes, por lo que tiene un lado positivo para compensar la ejecución de más uops).

Si aún no los estaba usando para cargar constantes “comprimidas”, pmovsxbd , movddup o vpbroadcastd son más largos que movaps . Las cargas de difusión de dword / qword son gratuitas (sin ALU uop, solo una carga).

Si está preocupado por la alineación del código, probablemente esté preocupado acerca de cómo se encuentra en el caché L1I o dónde están los límites de uopcaché, por lo que solo contar el total de uops ya no es suficiente, y unos pocos uops adicionales en el bloquear antes de que el que te importa no sea un problema en absoluto.

Pero en algunas situaciones, es posible que desee optimizar el rendimiento de deencoding / uso de uop-cache / uops total para las instrucciones antes del bloque que desea alinear.


Instrucciones de relleno, como la pregunta formulada:

Agner Fog tiene una sección completa sobre esto: “10.6 Hacer que las instrucciones sean más largas en aras de la alineación” en su guía “Optimización de subrutinas en lenguaje ensamblador” . (Las ideas lea , push r/m64 y SIB son de allí, y copié una oración / frase o dos; de lo contrario, esta es mi propia tarea, ya sean ideas diferentes o escritas antes de consultar la guía de Agner).

Sin embargo, no se ha actualizado para las CPU actuales: lea eax, [rbx + dword 0] tiene más inconvenientes que antes de vs mov eax, ebx , porque se pierde en la latencia cero / no en la unidad de ejecución mov . Si no está en la ruta crítica, adelante. Simple lea tiene un rendimiento bastante bueno, y un LEA con un gran modo de direccionamiento (y tal vez incluso algunos prefijos de segmento) puede ser mejor para el rendimiento de deencoding / ejecución que mov + nop .

Utilice la forma general en lugar de la forma abreviada (sin ModR / M) de instrucciones como push reg o mov reg,imm . por ejemplo, use la push r/m64 2 bytes push r/m64 para push rbx . O use una instrucción equivalente que sea más larga, como add dst, 1 lugar de inc dst , en los casos en que no hay desventajas de perf en inc por lo que ya estaba usando inc .

Use el byte SIB . Puede hacer que NASM haga eso usando un solo registro como índice, como mov eax, [nosplit rbx*1] ( vea también ), pero eso lastima la latencia de uso de carga vs. simplemente la encoding de mov eax, [rbx] con un byte SIB. Los modos de direccionamiento indexado tienen otras desventajas en la familia SnB, como la no laminación y el no uso de port7 para tiendas .

Por lo tanto , es mejor codificar base=rbx + disp0/8/32=0 usando ModR / M + SIB sin índice de índice . (La encoding SIB para “sin índice” es la encoding que de otro modo significaría idx = RSP). [rsp + x] modos de direccionamiento [rsp + x] ya requieren un SIB (base = RSP es el código de escape que significa que hay un SIB), y eso aparece todo el tiempo en el código generado por el comstackdor. Así que hay buenas razones para esperar que esto sea completamente eficiente para decodificar y ejecutar (incluso para registros base distintos de RSP) ahora y en el futuro. La syntax de NASM no puede express esto, por lo que tendrías que codificar manualmente. GNU gas Intel syntax de objdump -d dice 8b 04 23 mov eax,DWORD PTR [rbx+riz*1] para Agner Fog ejemplo 10.20. ( riz es una notación ficticia de índice cero que significa que hay un SIB sin índice). No he probado si GAS lo acepta como entrada.

Use una forma imm32 y / o disp32 de una instrucción que solo necesitara imm8 o disp0/disp32 . Las pruebas de Agner Fog de la memoria caché uop de Sandybridge ( tabla de guía de microarch 9.1 ) indican que el valor real de una inmediata / desplazamiento es lo que importa, no la cantidad de bytes utilizados en la encoding de la instrucción. No tengo información sobre el caché UOP de Ryzen.

Entonces NASM imul eax, [dword 4 + rdi], strict dword 13 (10 bytes: opcode + modrm + disp32 + imm32) usaría la categoría 32small, 32small y tomaría 1 entrada en el caché uop, a diferencia de si el inmediato o el disp32 en realidad tenía más de 16 bits significativos. (Entonces tomaría 2 entradas, y cargarlo desde la memoria caché uop tomaría un ciclo extra).

De acuerdo con la tabla de Agner, 8/16/32 pequeños son siempre equivalentes para SnB. Y los modos de direccionamiento con un registro son los mismos si no hay desplazamiento en absoluto, o si es 32small, entonces mov dword [dword 0 + rdi], 123456 toma 2 entradas, al igual que mov dword [rdi], 123456789 . No me había dado cuenta [rdi] + full imm32 tomó 2 entradas, pero aparentemente ese es el caso de SnB.

Use jmp / jcc rel32 lugar de rel8 . Lo ideal es intentar expandir las instrucciones en lugares que no requieren codificaciones de salto más largas fuera de la región que está expandiendo. Rellene los objectives de salto para saltos anteriores, rellene antes de saltar objectives para saltos posteriores, si están cerca de necesitar un rel32 en otro lugar. es decir, trate de evitar el relleno entre una twig y su objective, a menos que desee que esa twig use un rel32 de todos modos.


Podría sentirse tentado a codificar mov eax, [symbol] como 6-byte a32 mov eax, [abs symbol] en código de 64 bits, usando un prefijo de tamaño de dirección para usar una dirección absoluta de 32 bits. Pero esto causa un locking de Prefijo de cambio de longitud cuando se decodifica en las CPU de Intel. Afortunadamente, ninguno de NASM / YASM / gas / clang realiza esta optimización de tamaño de código de forma predeterminada si no especifica explícitamente un tamaño de dirección de 32 bits, en lugar de eso usa 7-byte mov r32, r/m32 con un ModR / M + SIB + disp32 modo de direccionamiento absoluto para mov eax, [abs symbol] .

En el código dependiente de posición de 64 bits, el direccionamiento absoluto es una forma económica de usar 1 byte extra frente a RIP-relative . Pero tenga en cuenta que 32 bits absoluta + inmediata toma 2 ciclos para recuperar de uop caché, a diferencia de RIP-relative + imm8 / 16/32 que toma solo 1 ciclo aunque todavía usa 2 entradas para la instrucción. (por ejemplo, para un mov -store o un cmp ). Entonces cmp [abs symbol], 123 es más lento de obtener de la memoria caché uop que cmp [rel symbol], 123 , aunque ambos toman 2 entradas cada uno. Sin una inmediata, no hay un costo adicional para

Tenga en cuenta que los ejecutables PIE permiten ASLR incluso para el ejecutable, y son los predeterminados en muchas distribuciones de Linux , por lo que si puede mantener su código PIC sin ninguna desventaja, entonces es preferible.


Use un prefijo REX cuando no lo necesite, por ejemplo, db 0x40 / add eax, ecx .

En general, no es seguro agregar prefijos como representantes que las CPU actuales ignoran, porque podrían significar algo más en las futuras extensiones de ISA.

A veces es posible repetir el mismo prefijo (aunque no con REX). Por ejemplo, db 0x66, 0x66 / add ax, bx da a la instrucción 3 prefijos de tamaño de operando, que creo que siempre es estrictamente equivalente a una copia del prefijo. Hasta 3 prefijos es el límite para la deencoding eficiente en algunas CPU. Pero esto solo funciona si tienes un prefijo que puedes usar en primer lugar; por lo general, no está utilizando el tamaño de operando de 16 bits y, en general, no desea el tamaño de dirección de 32 bits (aunque es seguro para acceder a datos estáticos en código dependiente de posición).

Un prefijo ds o ss en una instrucción que accede a la memoria no funciona , y probablemente no causa ninguna desaceleración en ninguna CPU actual. (@prl sugirió esto en los comentarios).

De hecho, la guía de microarch de Agner Fog usa un prefijo ds en un movq [esi+ecx],mm0 en el ejemplo 7.1. Organizar bloques de IFETCH para sintonizar un bucle para PII / PIII (sin búfer de bucle o caché uop), acelerando desde 3 iteraciones por reloj a 2.

Algunas CPU (como AMD) se decodifican lentamente cuando las instrucciones tienen más de 3 prefijos. En algunas CPU, esto incluye los prefijos obligatorios en las instrucciones SSE2 y especialmente SSSE3 / SSE4.1. En Silvermont, incluso el byte de escape 0F cuenta.

Las instrucciones AVX pueden usar un prefijo VEX de 2 o 3 bytes . Algunas instrucciones requieren un prefijo VEX de 3 bytes (la segunda fuente es x / ymm8-15, o los prefijos obligatorios para SSSE3 o posterior). Pero una instrucción que podría haber usado un prefijo de 2 bytes siempre se puede codificar con un VEX de 3 bytes. NASM o GAS {vex3} vxorps xmm0,xmm0 . Si AVX512 está disponible, también puede usar EVEX de 4 bytes.


Use un tamaño de operando de 64 bits para mov incluso cuando no lo necesite , por ejemplo mov rax, strict dword 1 fuerza la encoding de 7 bytes sign-extended-imm32 en NASM, que normalmente lo optimizaría a mov eax, 1 5 bytes mov eax, 1 .

 mov eax, 1 ; 5 bytes to encode (B8 imm32) mov rax, strict dword 1 ; 7 bytes: REX mov r/m64, sign-extended-imm32. mov rax, strict qword 1 ; 10 bytes to encode (REX B8 imm64). movabs mnemonic for AT&T. 

Incluso podría usar mov reg, 0 lugar de xor reg,reg .

mov r64, imm64 encaja eficientemente en la memoria caché uop cuando la constante es realmente pequeña (cabe en el signo extendido de 32 bits) 1 entrada uop-cache, y tiempo de carga = 1, lo mismo que para mov r32, imm32 . La deencoding de una instrucción gigante significa que probablemente no haya espacio en un bloque de deencoding de 16 bytes para otras 3 instrucciones de deencoding en el mismo ciclo, a menos que sean todos de 2 bytes. Posiblemente alargar un poco más otras instrucciones puede ser mejor que tener una instrucción larga.


Descifrar penalizaciones por prefijos adicionales:

  • P5: los prefijos evitan el emparejamiento, excepto para la dirección / tamaño de operando en PMMX solamente.
  • PPro a PIII: siempre hay una penalización si una instrucción tiene más de un prefijo. Esta penalización suele ser de un reloj por prefijo adicional. (Guía de microarch de Agner, final de la sección 6.3)
  • Silvermont: probablemente sea la restricción más estricta sobre los prefijos que puede usar, si le importa. Decodifique los puestos en más de 3 prefijos, contando los prefijos obligatorios + 0F byte de escape. Las instrucciones SSSE3 y SSE4 ya tienen 3 prefijos, por lo que incluso un REX las hace lentas para decodificar.
  • Algunos AMD: tal vez un límite de 3 prefijos, sin incluir los bytes de escape, y tal vez no incluya los prefijos obligatorios para las instrucciones de SSE.

… TODO: termina esta sección. Hasta entonces, consulte la guía de microarch de Agner Fog.


Después de codificar las cosas a mano, siempre desmonta tu binario para asegurarte de que lo hiciste bien . Es desafortunado que NASM y otros ensambladores no tengan un mejor soporte para elegir relleno barato en una región de instrucciones para alcanzar un límite de alineación dado.


Sintaxis de ensamblador

NASM tiene alguna syntax de anulación de encoding : {vex3} y {evex} , NOSPLIT y strict byte / dword , y forzando a disp8 / disp32 dentro de los modos de direccionamiento. Tenga en cuenta que [rdi + byte 0] no está permitido, la palabra clave byte tiene que ser lo primero. [byte rdi + 0] está permitido, pero creo que se ve raro.

Listado de nasm -l/dev/stdout -felf64 padding.asm

  line addr machine-code bytes source line num 4 00000000 0F57C0 xorps xmm0,xmm0 ; SSE1 *ps instructions are 1-byte shorter 5 00000003 660FEFC0 pxor xmm0,xmm0 6 7 00000007 C5F058DA vaddps xmm3, xmm1,xmm2 8 0000000B C4E17058DA {vex3} vaddps xmm3, xmm1,xmm2 9 00000010 62F1740858DA {evex} vaddps xmm3, xmm1,xmm2 10 11 12 00000016 FFC0 inc eax 13 00000018 83C001 add eax, 1 14 0000001B 4883C001 add rax, 1 15 0000001F 678D4001 lea eax, [eax+1] ; runs on fewer ports and doesn't set flags 16 00000023 67488D4001 lea rax, [eax+1] ; address-size and REX.W 17 00000028 0501000000 add eax, strict dword 1 ; using the EAX-only encoding with no ModR/M 18 0000002D 81C001000000 db 0x81, 0xC0, 1,0,0,0 ; add eax,0x1 using the ModR/M imm32 encoding 19 00000033 81C101000000 add ecx, strict dword 1 ; non-eax must use the ModR/M encoding 20 00000039 4881C101000000 add rcx, strict qword 1 ; YASM requires strict dword for the immediate, because it's still 32b 21 00000040 67488D8001000000 lea rax, [dword eax+1] 22 23 24 00000048 8B07 mov eax, [rdi] 25 0000004A 8B4700 mov eax, [byte 0 + rdi] 26 0000004D 3E8B4700 mov eax, [ds: byte 0 + rdi] 26 ****************** warning: ds segment base generated, but will be ignored in 64-bit mode 27 00000051 8B8700000000 mov eax, [dword 0 + rdi] 28 00000057 8B043D00000000 mov eax, [NOSPLIT dword 0 + rdi*1] ; 1c extra latency on SnB-family for non-simple addressing mode 

GAS tiene pseudo-prefijos de sobrescritura de encoding {vex3} , {evex} , {disp8} y {disp32} Estos reemplazan los sufijos .s , .d8 y .d32 ahora desaprobados .

GAS no tiene una anulación de tamaño inmediato, solo desplazamientos.

GAS le permite agregar un prefijo ds explícito, con ds mov src,dst

gcc -g -c padding.S && objdump -drwC padding.o -S , con edición manual:

  # no CPUs have separate ps vs. pd domains, so there's no penalty for mixing ps and pd loads/shuffles 0: 0f 28 07 movaps (%rdi),%xmm0 3: 66 0f 28 07 movapd (%rdi),%xmm0 7: 0f 58 c8 addps %xmm0,%xmm1 # not equivalent for SSE/AVX transitions, but sometimes safe to mix with AVX-128 a: c5 e8 58 d9 vaddps %xmm1,%xmm2, %xmm3 # default {vex2} e: c4 e1 68 58 d9 {vex3} vaddps %xmm1,%xmm2, %xmm3 13: 62 f1 6c 08 58 d9 {evex} vaddps %xmm1,%xmm2, %xmm3 19: ff c0 inc %eax 1b: 83 c0 01 add $0x1,%eax 1e: 48 83 c0 01 add $0x1,%rax 22: 67 8d 40 01 lea 1(%eax), %eax # runs on fewer ports and doesn't set flags 26: 67 48 8d 40 01 lea 1(%eax), %rax # address-size and REX # no equivalent for add eax, strict dword 1 # no-ModR/M .byte 0x81, 0xC0; .long 1 # add eax,0x1 using the ModR/M imm32 encoding 2b: 81 c0 01 00 00 00 add $0x1,%eax # manually encoded 31: 81 c1 d2 04 00 00 add $0x4d2,%ecx # large immediate, can't get GAS to encode this way with $1 other than doing it manually 37: 67 8d 80 01 00 00 00 {disp32} lea 1(%eax), %eax 3e: 67 48 8d 80 01 00 00 00 {disp32} lea 1(%eax), %rax mov 0(%rdi), %eax # the 0 optimizes away 46: 8b 07 mov (%rdi),%eax {disp8} mov (%rdi), %eax # adds a disp8 even if you omit the 0 48: 8b 47 00 mov 0x0(%rdi),%eax {disp8} ds mov (%rdi), %eax # with a DS prefix 4b: 3e 8b 47 00 mov %ds:0x0(%rdi),%eax {disp32} mov (%rdi), %eax 4f: 8b 87 00 00 00 00 mov 0x0(%rdi),%eax {disp32} mov 0(,%rdi,1), %eax # 1c extra latency on SnB-family for non-simple addressing mode 55: 8b 04 3d 00 00 00 00 mov 0x0(,%rdi,1),%eax 

GAS es estrictamente menos poderoso que NASM para express codificaciones más largas de lo necesario.

Puedo pensar en cuatro maneras fuera de mi cabeza:

Primero: use codificaciones alternativas para las instrucciones (Peter Cordes mencionó algo similar). Hay muchas maneras de llamar a la operación ADD, por ejemplo, y algunas de ellas ocupan más bytes:

http://www.felixcloutier.com/x86/ADD.html

Por lo general, un ensamblador intentará elegir la “mejor” encoding para la situación, ya sea que esté optimizando la velocidad o la longitud, pero siempre puede usar otra y obtener el mismo resultado.

Segundo: Use otras instrucciones que signifiquen lo mismo y que tengan diferentes longitudes. Estoy seguro de que puedes pensar en innumerables ejemplos en los que podrías incluir una instrucción en el código para reemplazar una existente y obtener los mismos resultados. Las personas que optimizan el código de la mano lo hacen todo el tiempo:

 shl 1 add eax, eax mul 2 etc etc 

Tercero: use la variedad de NOP disponibles para rellenar espacio adicional:

 nop and eax, eax sub eax, 0 etc etc 

En un mundo ideal probablemente tendrías que usar todos estos trucos para que el código sea la longitud exacta de bytes que deseas.

Cuarto: Cambie su algoritmo para obtener más opciones utilizando los métodos anteriores.

Una nota final: Obviamente, orientar los procesadores más modernos le dará mejores resultados debido a la cantidad y complejidad de las instrucciones. Tener acceso a las instrucciones MMX, XMM, SSE, SSE2, punto flotante, etc. podría facilitar su trabajo.

Veamos una pieza específica de código:

  cmp ebx,123456 mov al,0xFF je .foo 

Para este código, ninguna de las instrucciones puede reemplazarse por otra cosa, por lo que las únicas opciones son prefijos redundantes y NOP.

Sin embargo, ¿qué sucede si cambia el orden de las instrucciones?

Podrías convertir el código en esto:

  mov al,0xFF cmp ebx,123456 je .foo 

Después de volver a ordenar las instrucciones; el mov al,0xFF podría reemplazarse con or eax,0x000000FF o or ax,0x00FF .

Para el primer orden de instrucciones solo hay una posibilidad, y para el segundo orden de instrucciones hay 3 posibilidades; entonces hay un total de 4 permutaciones posibles para elegir sin usar prefijos redundantes o NOP.

Para cada una de esas 4 permutaciones puede agregar variaciones con diferentes cantidades de prefijos redundantes y NOP de un solo byte, para hacer que finalice en una / s alineación específica. Soy demasiado perezoso para hacer las matemáticas, así que supongamos que tal vez se expande a 100 posibles permutaciones.

¿Qué pasa si le das una puntuación a cada una de estas 100 permutaciones (en función de cuánto tiempo llevaría ejecutar, qué tan bien alinea la instrucción después de esta pieza, si el tamaño o la velocidad son importantes, …). Esto puede incluir la orientación micro-arquitectónica (por ejemplo, tal vez para algunas CPU la permutación original rompa la fusión de la microoperación y empeora el código).

Podrías generar todas las permutaciones posibles y darles una puntuación, y elegir la permutación con la mejor puntuación. Tenga en cuenta que esta puede no ser la permutación con la mejor alineación (si la alineación es menos importante que otros factores y solo empeora el rendimiento).

Por supuesto, puede dividir progtwigs grandes en muchos grupos pequeños de instrucciones lineales separadas por cambios de flujo de control; y luego haga esta “búsqueda exhaustiva de la permutación con la mejor puntuación” para cada pequeño grupo de instrucciones lineales.

El problema es que el orden de las instrucciones y la selección de instrucciones son co-dependientes.

Para el ejemplo anterior, no puede reemplazar mov al,0xFF hasta después de que hayamos reordenado las instrucciones; y es fácil encontrar casos en los que no pueda reordenar las instrucciones hasta después de haber reemplazado (algunas) las instrucciones. Esto hace que sea difícil hacer una búsqueda exhaustiva de la mejor solución, para cualquier definición de “mejor”, incluso si solo le preocupa la alineación y no le importa el rendimiento en absoluto.

Depende de la naturaleza del código.

Código pesado de Floatingpoint

Prefijo AVX

Uno puede recurrir al prefijo AVX más largo para la mayoría de las instrucciones de SSE. Tenga en cuenta que hay una penalización fija al cambiar entre SSE y AVX en las CPU de Intel [1] [2] . Esto requiere vzeroupper que se puede interpretar como otro NOP para código SSE o código AVX que no requiere los 128 bits superiores.

SSE / AVX NOPS

Los NOP típicos en los que puedo pensar son:

  • XORPS el mismo registro, use variaciones SSE / AVX para enteros de estos
  • ANDA el mismo registro, use variaciones SSE / AVX para enteros de estos