- El cuello de botella de 1BRC era parsear a velocidad extrema 1.000 millones de valores de temperatura en CSV, y el código SWAR de merykitty de Quân Anh Mai llamó la atención por convertir temperaturas a enteros con operaciones ALU fijas, sin
if
- Este código usa SWAR (SIMD Within A Register), una técnica que maneja de una vez los 8 bytes contenidos en un solo
long, procesando varios caracteres casi en paralelo dentro de un registro común de CPU
- El flujo de procesamiento pasa por detectar el signo menos, eliminar el signo, encontrar la posición del punto decimal, alinear a
XY.Z, convertir dígitos ASCII, hacer una multiplicación mágica y aplicar el signo
- Los formatos de entrada son cuatro:
-XX.X, -X.X, X.X, XX.X; según la posición del punto decimal, se desplazan bytes para ajustar longitudes distintas a la misma disposición de bits
- En lugar de reducir bifurcaciones y bucles, implementa un parser de alto rendimiento aprovechando al máximo las propiedades de los códigos ASCII, el complemento a dos, las máscaras de bits y la naturaleza de desplazamientos y sumas de la multiplicación
El parsing de temperaturas que fue el cuello de botella en 1BRC
- En One Billion Row Challenge (1BRC), parsear muy rápido los valores de temperatura de un archivo CSV se convirtió en el cuello de botella principal
- Solo con optimizaciones previas, el código Java paralelo idiomático ya había acelerado de 71 segundos a 1,7 segundos
- El formato de temperatura es simple, pero al parsear 1.000 millones de valores en menos de un segundo, incluso costos pequeños se acumulan mucho
- Los formatos posibles son
-XX.X, -X.X, X.X, XX.X
- Los primeros participantes usaron
Double.parseDouble(), pero luego aparecieron parsers personalizados sin bucles
- Parte de la solución de Quân Anh Mai, @merykitty, procesaba con una sola lectura de archivo y sin
if, y se difundió como un componente casi estándar entre las mejores soluciones de 1BRC
- El ganador, Thomas Wuerthinger, mencionó explícitamente a Quân Anh como parte del equipo que contribuyó a su solución
Qué hace el código de merykitty
- El código recibe un
long que contiene 8 bytes de entrada CSV y devuelve el valor entero de la temperatura, equivalente a 10 veces la temperatura real
- La entrada llega desde un archivo CSV con
mmap mediante lectura directa de memoria nativa, y esa parte se separa como un tema aparte
- Las operaciones consisten en 18 instrucciones ALU en un orden fijo
- Desplazamientos de bits, AND, NOT, XOR
- Sumas, restas y multiplicaciones
Long.numberOfTrailingZeros()
numberOfTrailingZeros() usa una instrucción especial de CPU mediante un intrinsic del compilador del JDK
- Como no usa instrucciones SIMD dedicadas, sino registros e instrucciones comunes de CPU para manejar varios bytes, corresponde a la técnica SWAR
- El código de ejemplo es una versión levemente modificada del original para facilitar la lectura; el original está en CalculateAverage_merykitty.java
Etapas completas del procesamiento
- El código parsea la temperatura en el siguiente orden
- Revisa si el primer carácter es
- para detectar si es negativa
- Si existe el carácter de signo, convierte ese byte en 0
- Encuentra la posición del punto decimal
.
- Desplaza los bits dentro del
long para que los números encajen en la plantilla XY.Z
- Convierte los caracteres ASCII a valores numéricos reales
- Multiplica cada dígito por sus pesos
1x, 10x, 100x y los suma
- Aplica el signo al final
- Aunque por fuera parece un problema de parsing de alto nivel, cada etapa se implementa solo con operaciones ALU
Paso 1: detectar el signo menos
- La detección del signo empieza con este código
long negatedInput = ~inputData;
long broadcastSign = (negatedInput << 59) >> 63;
- Si se cambia el orden para explicarlo, puede verse como
( ~(inputData << 59) ) >> 63
- Aprovecha la propiedad de ASCII de que el signo menos
- tiene el bit 4 en 0, mientras que los caracteres numéricos tienen ese bit en 1
- Al desplazar la entrada 59 bits a la izquierda, el bit distintivo del primer carácter pasa al bit más significativo
- Después de invertir los bits con NOT y hacer un desplazamiento aritmético a la derecha de 63 bits, el bit más significativo se propaga por todo el
long
- El resultado,
broadcastSign, queda con todos los bits en 1 si hay un signo menos, o con todos los bits en 0 si no lo hay
Paso 2: eliminar el carácter de signo
- Como la información de si es negativo ya quedó guardada en
broadcastSign, se elimina el carácter de signo de los datos de entrada
long maskToRemoveSign = ~(broadcastSign & 0xFF);
long withSignRemoved = inputData & maskToRemoveSign;
- Si
broadcastSign está lleno de 1, broadcastSign & 0xFF deja solo los 8 bits menos significativos en 1
- Al aplicar NOT, se crea una máscara donde solo los 8 bits menos significativos son 0
- Al hacer AND con
inputData, se elimina el - del byte menos significativo
- Si no hay signo menos,
broadcastSign es 0, por lo que la máscara queda con todos los bits en 1 y se conservan los bytes numéricos
Paso 3: encontrar la posición del punto decimal
- La posición del punto decimal se calcula con este código
int dotPos = Long.numberOfTrailingZeros(negatedInput & DOT_DETECTOR);
- El carácter
. comparte con el signo menos la propiedad de tener el bit 4 en 0
- Para revisar solo el bit 4 de las posibles posiciones del punto decimal, se usa la máscara
DOT_DETECTOR = 0x10101000
- En
negatedInput, que es la entrada original invertida, el bit correspondiente a la posición del punto decimal queda en 1
Long.numberOfTrailingZeros() devuelve la posición de ese bit en 1
- En el ejemplo
-10.8, el punto decimal está en la posición de bit 28, así que dotPos = 28
Paso 4: alinear a una plantilla fija
- Se desplaza la entrada a la izquierda según la posición del punto decimal para que siempre encaje en la misma plantilla
long alignedToTemplate = withSignRemoved << (28 - dotPos);
- La plantilla objetivo es la siguiente
0 0 0 Z . Y X 0
- Aquí,
X es la decena, Y es la unidad y Z es el primer decimal
0 no significa el ASCII "0", sino un byte con valor 0
- Después de eliminar el signo, la entrada puede estar en una de cuatro disposiciones
0 0 0 Z . Y X 0
0 0 0 0 Z . Y 0
0 0 0 0 Z . Y X
0 0 0 0 0 Z . Y
-10.8 ya tiene dotPos = 28, por lo que el desplazamiento es 0
- En
-7.7, el punto decimal está en la posición de bit 20, así que se desplaza 8 bits, es decir, un byte a la izquierda, y queda un 0 en el lugar de X
Paso 5: convertir los dígitos ASCII en valores
- Después de alinear, se dejan solo los valores numéricos de los caracteres ASCII
long digits = alignedToTemplate & ASCII_TO_DIGIT_MASK;
- Los dígitos ASCII del
0 al 9 van de 0x30 a 0x39 en hexadecimal
- Si se dejan solo los 4 bits inferiores, el código de carácter se convierte en el valor numérico real
- Se aplica una máscara que tiene
F solo en las posiciones numéricas de la plantilla
0 0 0 Z . Y X 0
000000F000F0F00
- En el ejemplo
-10.8, después de aplicar la máscara solo quedan los valores que representan Z=8, Y=0, X=1
Paso 6: sumar los valores posicionales con una multiplicación mágica
- El valor absoluto final debe calcularse como
100 * X + 10 * Y + Z
- Aprovechando que la multiplicación es una combinación de desplazamientos y sumas, el cálculo ponderado de varios dígitos se procesa con una sola multiplicación
- Si primero pensamos en
X + Y + Z, se pueden sumar los valores de digits desplazados a las posiciones de 0, 16 y 24 bits para reunir la suma en cierto tramo de bits
- Esa combinación de desplazamientos y sumas puede expresarse como la siguiente multiplicación
0x1 + 0x10000 + 0x1000000
- En la práctica, como cada dígito tiene un peso distinto,
MAGIC_MULTIPLIER se construye así
MAGIC_MULTIPLIER = 0x1 + 10 * 0x10000 + 100 * 0x1000000;
- La fórmula es la siguiente
absValue = ((digits * MAGIC_MULTIPLIER) >>> 32) & 0x3FF;
0x3FF es una máscara que separa solo un resultado de 10 bits de ancho
100 * X puede crecer hasta 10 bits y solaparse con bits vecinos, pero gracias a que los dos bits de la derecha de Y * 100 quedan en 0, se asegura el espacio de bits necesario
- merykitty dejó en esta parte el comentario
// That was close :)
Paso 7: aplicar el signo sin bifurcaciones
- En este punto están el valor absoluto
absValue y la información de signo broadcastSign
broadcastSign funciona como 0 si es positivo y como -1 si es negativo
- En complemento a dos, un número negativo se expresa con la siguiente fórmula
-n = NOT(n) + 1
- XOR puede usarse como un NOT condicional
n XOR -1 es NOT(n)
n XOR 0 es n
- El
+1 opcional se maneja con -broadcastSign
temperature = (absValue ^ broadcastSign) - broadcastSign;
- Como resultado, sin usar
if, los positivos quedan igual y los negativos se convierten a su valor negativo en complemento a dos
Bonus: calcular la posición inicial de la siguiente fila CSV
- En la solución completa de 1BRC, también hay que calcular de forma barata el inicio de la siguiente línea CSV
- Después del punto decimal siempre vienen un dígito decimal y un salto de línea, así que se obtiene el inicio de la siguiente fila a partir de la posición del punto decimal
- Como
dotPos es una posición en bits, se usa un desplazamiento de 3 bits a la derecha para dividir por 8
nextLineStart = (dotPos >>> 3) + 3;
- El
+3 es el valor necesario para apuntar al primer byte después del punto decimal, el dígito decimal y el salto de línea
Conclusión
- El código SWAR de merykitty parsea de forma unificada cuatro formatos de cadenas de temperatura usando solo operaciones de bits fijas
- Las claves son las propiedades de bits de los códigos ASCII, la alineación basada en la posición del punto decimal, la extracción de dígitos con máscaras, la suma de valores posicionales mediante multiplicación y la aplicación del signo con complemento a dos
- Separado por etapas, su funcionamiento se puede seguir, pero sigue siendo impresionante que se haya combinado todo esto en unos pocos días durante un desafío online
1 comentarios
Opiniones en Hacker News
Hace más de 2 años descubrí que byte array view var handle es bastante adecuado para crear rutinas SWAR eficientes en Java/Scala
También hay muchos ejemplos de uso de SWAR aquí, como parseo de cadenas Base16/64,
java.time.*, parseo de valores numéricos directamente desde arreglos de bytes: https://github.com/plokhotnyuk/jsoniter-scala/blob/master/js...Gran parte del valor de un parser curtido en producción está en la verificación y recuperación de errores eficientes
Y también me da curiosidad cuánto trabajo haría falta para detectarla y devolver algún valor centinela de error, al estilo del código actual
Aunque no lo suficiente como para intentarlo yo mismo ;-)
MULpara hacer desplazamientos/sumas es bastante conocidaVer el artículo de Lemire: https://lemire.me/blog/2023/11/28/parsing-8-bit-integers-qui...
Paper: https://arxiv.org/abs/1902.08318
Github: https://github.com/simdjson/simdjson
Además, el 1BRC oficial especifica que evalúa los resultados desde un disco RAM para excluir por completo la velocidad de E/S: https://github.com/gunnarmorling/1brc?tab=readme-ov-file#eva...
“Programs are run from a RAM disk (i.o. the IO overhead for loading the file from disk is not relevant)”
Según mi comprensión limitada, se trae un archivo de texto grande de forma secuencial a L1 y se lee una vez por cada valor. En la mayoría de los procesadores se pueden hacer dos de esas lecturas por ciclo. La parte lenta sería traerlo de la RAM a L1, pero la lectura secuencial es bastante rápida
Luego se procesa cada lectura. A primera vista, en una versión optimizada eso parecería tomar alrededor de 4 ciclos. Después hay que escribir el resultado en algún lado, y probablemente antes de eso haga falta una o dos lecturas aleatorias. ¿Esa es la parte que ves como cuello de botella de E/S?
No digo que sea obvio que esté limitado por CPU, pero tampoco parece obvio que no lo esté
Edición: no consideré que tal vez te referías a “E/S de disco”. Como dijeron otros, aquí prácticamente no es un factor
Es decir, todos los datos quedan en RAM; más precisamente, en la page cache
Si no recuerdo mal, manejar el overflow era complicado. Este artículo me gustó mucho
Todavía hay gente que sabe programar realmente el CPU y entiende lo que está haciendo
El verdadero misterio es que la gran mayoría de quienes se llaman programadores carecen de una comprensión profunda, e incluso parecen no saber que tienen una carencia grave
Que de hecho funciona bien se puede comprobar en la solución en C# que, hasta donde se ha publicado, parece ser la más rápida de 1BRC: https://hotforknowledge.com/2024/01/13/1brc-in-dotnet-among-...
El problema es si el costo de construir el vector inicial y extraer los resultados no sería excesivo
Aunque dudo que HotSpot lo logre por sí solo, y aparte está el hecho de que la mayoría de los envíos de 1BRC se ejecutaron con Graal para reducir el overhead de arranque
SSE2 básico no tiene multiplicación de 32 ni de 64 bits, así que la multiplicación 32×32→64 bits es un problema, pero SSE4.1 agrega exactamente el
pmuldqque se necesita. Eso sí, como el resultado es de 64 bits, para procesar un vector completo de enteros de 32 bits harían falta dos operaciones de este tipoAdemás, el campo de temperatura tiene longitud variable, así que incluso si estuviera almacenado por columnas, es posible que no hubiera ganancia
Sin embargo, SSE sí se aplicó con éxito para encontrar el separador entre el nombre y la temperatura