- El problema del carry (acarreo) que aparece en las operaciones con enteros grandes es una de las principales razones por las que resulta difícil paralelizar los cálculos
- En la arquitectura x86, la instrucción
adc para manejar carry es más lenta que la instrucción normal add, y el procesamiento continuo de carry limita la ejecución en paralelo
- Si se usa la representación en radix 2^51, se puede retrasar la propagación del carry y realizar más sumas rápidamente
- A cada limb (valor parcial) se le asignan solo 51 o 52 bits, y el espacio restante de bits superiores se usa como almacenamiento temporal para carry
- Esta técnica, a pesar del uso adicional de registros y del costo de conversión, en la práctica ofrece mejor rendimiento que la base 2^64
Suma y resta rápidas: el problema del carry
- En la suma de enteros grandes, igual que una persona maneja el carry dígito por dígito al hacer cuentas a mano, la computadora también tiene dificultades para paralelizar el algoritmo de suma debido al carry
- Básicamente, sumamos desde la derecha (dígitos menos significativos) uno por uno, y el carry generado en cada posición se transfiere a la izquierda (dígitos más significativos)
- Si la suma empezara desde la izquierda, el carry previo afectaría la operación de la siguiente posición, por lo que no se podría paralelizar el orden de cálculo
Cómo se maneja el carry en la computadora
- La computadora procesa sumas en unidades de enteros de 64 bits
- Puede parecer que un entero de 256 bits puede dividirse en 4 limbs de 64 bits y sumarse en paralelo, pero en la práctica hay que manejar el overflow (carry) para obtener el resultado correcto
- En x86 existe la instrucción
adc (add with carry), que maneja automáticamente el carry
Causa de la pérdida de rendimiento
- La instrucción
adc requiere un valor de entrada adicional llamado carry flag, por lo que rinde peor que una add simple
- En la arquitectura Haswell,
add puede ejecutarse en paralelo en varios puertos, mientras que adc inevitablemente requiere ejecución serial (secuencial)
- En particular, al usar instrucciones SIMD (
vpaddq, etc.), la suma paralela sin carry es mucho más rápida
La idea de retrasar el carry (ejemplo en papel)
- Para reducir el carry, se puede ampliar el rango de dígitos (por ejemplo, de 0-9 a 0-9, A-Z y *) y así sumar temporalmente varios números sin carry
- De esta forma, se pueden hacer varias sumas sin propagar carry y al final ordenar el carry de una sola vez (normalization)
- Este concepto separa la acumulación y la propagación del carry para que solo se procese al final
- En la operación real, si aparece un valor por encima del valor base del dígito, el carry se acumula y refleja en orden de derecha a izquierda
Aplicación en computadora del carry diferido (truco radix 2^51)
- Para reducir la propagación del carry en la computadora, se usa la representación radix 2^51
- En lugar de dividir 256 bits en 4 limbs de 64 bits, se divide en 5 limbs de 51~52 bits cada uno
- Los 12~13 bits superiores de cada limb funcionan como almacenamiento temporal para carry
- Con este método, cada limb mantiene el rango de valores de 2^64 y, como durante la operación real el carry no aparece fácilmente, se pueden ejecutar varias operaciones en paralelo sin carry
- Se necesita una normalization (normalización) una vez cada aproximadamente 2^13 operaciones consecutivas
- En CPUs Haswell, después de aplicar radix 2^51, al ejecutar repetidamente solo sumas simples sin carry, el rendimiento mejora notablemente frente al radix 2^64 normal
- La carry propagation para normalization se realiza solo una vez al final
Ejemplo de código
- Al repartir el valor en 5 registros, es posible hacer sumas sin carry
- La normalization repite el proceso de extraer los bits superiores de cada limb, sumarlos al limb vecino y dejar en 0 su propio valor de carry
Extensión a la resta
- La resta también puede aplicarse de manera similar
- En este caso, el carry también puede ser negativo, por lo que el limb se trata como signed integer
- El bit más alto del limb se asigna como bit de signo, así que la cantidad de operaciones que pueden procesarse de una vez se reduce ligeramente en comparación con la suma
Conclusión
- La técnica de resistencia al carry (o carry diferido), a pesar de aumentar la cantidad de limbs y agregar trabajo de conversión, realmente mejora mucho el rendimiento total de las operaciones
- El truco radix 2^51 se usa ampliamente en campos que requieren alto rendimiento, como operaciones con enteros grandes y criptografía
- Esta técnica es una idea importante para optimizar el rendimiento real del hardware y de los algoritmos
1 comentarios
Opiniones en Hacker News
Al ver el número 2^51, al principio pensé que sería sobre almacenar enteros en tipo
double, pero luego recordé que el valor máximo que puede representarse exactamente como entero endoublees 2^53-1AVX512 (y en cierta medida también AVX2) ofrece un entorno donde es posible implementar sumas de 256 bits de forma bastante eficiente, con la ventaja adicional de poder meter más números en registros
Un ejemplo directo funciona como en el siguiente código
Incluso muestra mejoras en throughput, así que el ejemplo de código real puede verse en godbolt.org
Extender esta lógica hasta sumas de 512 bits también es muy simple
En particular, se señala que en ciertas arquitecturas de CPU Intel, con solo usar instrucciones AVX512 ya puede bajar el reloj total del procesador, lo que lleva a un rendimiento general inconsistente o incluso peor
La referencia relacionada puede verse en este enlace de Stack Overflow
Ante la duda de “¿por qué no usar 12 bits en lugar de 13?”, aquí se ignora el manejo del carry del bit más alto (limb), para que en caso de overflow funcione con wraparound
Como resultado, al limb más alto se le asignan 52 bits, así que aunque tiene la desventaja de quedarse sin espacio antes que los otros limbs, funciona de forma parecida a una suma de enteros sin signo en C
Entonces se propone: ¿qué tal asignar 64 bits al limb más alto y 48 bits a los otros cuatro limbs?
Eso permitiría acumular más operaciones antes de normalizar, y además tendría ventajas como la alineación por palabra
La característica del manejo de overflow sería la misma
Si solo al limb más alto se le asignan 64 bits, al sumar los limbs de dos números el overflow ocurre demasiado pronto
Por ejemplo, si ambos valen 2^63, se produce overflow de inmediato
En aritmética con wraparound eso no importa, pero en el caso general no es viable
Con esa estructura se necesitarían 6 palabras en lugar de 5, como en el OP
También harían falta más instrucciones
El objetivo es resolver aritmética de 256 bits con 5 registros de 64 bits
Es decir, lo ideal sería repartir 256/5 = 51.2 bits por palabra
Si esto se limita a 256 bits, está bien, pero no es lo óptimo para una biblioteca big-int de propósito general
Antes existía el antecedente de querer usar exactamente 1 byte por carry, y en la época sin barrel shifter se prefería usar solo 56 de 64 bits para facilitar la alineación
En RISC-V esta discusión es aún más importante porque no hay flags en hardware
En CPUs x86 modernas (por ejemplo, Intel Broadwell y AMD Ryzen), usando instrucciones Intel ADX, la representación radix 2^51 puede incluso ser más rápida en situaciones donde tradicionalmente dominaba (por ejemplo, Curve25519)
Como material de discusión relacionado
La lección clave es que, si las operaciones son independientes entre sí, ejecutar más operaciones en paralelo puede terminar siendo más rápido
Por el contrario, si por dependencias hay que ejecutarlas secuencialmente, entonces incluso con menos operaciones puede ser más lento
Esta idea puede aplicarse no solo a enteros largos, sino también a muchas otras áreas
Se propone dividir en chunks de 64 bits y ejecutar por adelantado, en paralelo, los dos casos según haya carry o no, para luego elegir la operación correcta según el resultado real del carry
Este método duplica la cantidad de sumas, pero acelera la propagación a un nivel de log(bits) en vez de lineal
La parte que no había entendido bien de esta técnica es que, en esencia, hace que el ripple carry, que al sumar N valores se ejecutaría N-1 veces, se ejecute una sola vez
El manejo de carry se vuelve más complejo, pero la suma sí puede paralelizarse
Eso sí, para que la eficiencia total valga la pena, la propia división de los números de entrada en unidades de 5 registros también tendría que paralelizarse
Esta regla puede extenderse incluso al nivel de supercomputadoras o la nube con decenas de miles de nodos
Si se pueden usar muchos núcleos, el overhead pasa a ser despreciable
A NVidia también le interesa esta idea y está dando buenos resultados en varias áreas
Aunque las guías de HN dicen que no se debe agregar opinión en el título, no me gustan los títulos exagerados tipo clickbait
Creo que algo como “el truco de radix 2^51 para suma paralela de enteros de 64 bits sin dependencia de carry en algunas arquitecturas x86” sería más preciso
Da pena pensar que me habría servido leer este artículo unos meses antes
Tuve la experiencia de que, al codificar y decodificar un buffer en una base arbitraria, el carry se propagaba por todo el buffer y volvía el algoritmo mucho más lento
Al final dejé 'espacio libre' y lo dividí en chunks para manejar el carry, y parece tener similitudes con este truco
En la práctica elegí desperdiciar algunos bits a cambio de ahorrar cómputo o ancho de banda de red
Me pregunto si este tipo de manejo de carry también podría agruparse como postprocesamiento
Ojalá haya una estructura con la que se puedan obtener prácticamente todas las ventajas
Mi experiencia usando solo entornos x86_64 deja claro que, en RISC-V, no tener carry flag tampoco implica necesariamente un mal enfoque
uint64_tEl flujo sería algo así
La clave es que, cuando el resultado de la suma (limb) no es todo 1s, el carry out de ese limb no depende del carry in, sino solo del resultado de sumar los valores originales
En cambio, si el valor es todo 1s, entonces carry out = carry in
Si la estructura de ramas casi no necesita predicción, puede ejecutarse completamente en paralelo
Probabilísticamente solo se vuelve más lenta en 1 de cada 2^64 casos, aunque en una máquina de 4 vías no hay gran ganancia
En una máquina de 8 vías o con una estructura de 8 limbs sí puede haber una mejora de rendimiento significativa
No encaja en x86_64, pero en máquinas de 8 vías como la serie Apple M* podría ser aprovechable
Hay expectativas de futuro en el procesador RISC-V Ascalon de 8 vías de Tenstorrent, así como en Ventana, Rivos, XiangShan y otros
En arquitecturas SIMD o con instrucciones rápidas de shift (slideup) de 1 lane, el efecto se maximiza
La suma carry-save no siempre supera a add-with-carry
Los dos tipos de algoritmos de suma multi-word no son intercambiables y cada uno tiene ventajas y desventajas
Por eso las instrucciones ADC/SBB deberían venir incluidas de base en la ISA, y también es posible guardar flags en registros
Una desventaja más seria en RISC-V es que no tiene integer overflow flag
Cuando se necesita un workaround por software para detectar overflow, la caída de rendimiento es mucho peor que la de emular el bit de carry
La ausencia de carry flag en RISC-V se deriva de que el lenguaje C ignoró el carry flag binario
En la práctica, la frecuencia de uso del carry flag es bastante baja
No fui el único en pensar “si el carry flag de todos modos es lento, entonces ¿por qué existió la polémica sobre risc5 gmp?”
El 'radix trick' también puede aplicarse a estructuras de datos
Hay un ejemplo interesante en el libro 'Purely Functional Data Structures' de Okasaki