sb_lower_boundmantiene la misma interfaz questd::lower_boundy, cuando la rama de comparación se compila como movimiento condicional (cmov), muestra resultados hasta 2 veces más rápidos que la búsqueda binaria común- El resultado de la comparación en una búsqueda binaria no permite saber de antemano la posición buscada, por lo que son frecuentes las fallas de predicción de ramas; en x86, la opción
clang -mllvm -x86-cmov-converter=falseayuda a reducirlas - Esta implementación reduce
lengtha la mitad en cada iteración y actualiza solofirstsegún el resultado de la comparación, reduciendo la cantidad de instrucciones; en el rango2^k <= n < 2^(k+1)siempre realizak+1comparaciones - En benchmarks con
clang -cmov, los tiempos promedio de ejecución fueronstd::lower_bound61.30 ns,sb_lower_bound33.24 ns ybb_lower_bound32.73 ns; las medias geométricas también mostraron una gran diferencia: 39.17 ns, 19.81 ns y 21.33 ns, respectivamente - En búsquedas de strings de 8 bytes, donde la función de comparación es lenta, hubo casos en los que
std::lower_boundquedó ligeramente por delante; en arreglos grandes, una variante con prefetching fue en promedio unas 2.3 veces más rápida questd::lower_bound
Estructura básica de sb_lower_bound
sb_lower_boundes una función de C++ con la misma forma questd::lower_bound- Las entradas son
first,last,value,comp - El valor de retorno es un iterador a la primera posición donde la comparación falla; si todos los elementos cumplen la condición, devuelve
last
- Las entradas son
- El bucle principal reduce
lengtha la mitad y muevefirsthacia adelante solo cuandocomp(first[length], value)es verdadero - Aquí, “branchless” no significa que desaparezca el
if, sino que eseifse compila como una instrucción de movimiento condicional, comocmov, en lugar de un salto condicional - En
clang, al usar la opción-mllvm -x86-cmov-converter=false, esta forma puede compilarse como movimiento condicional
Dónde se vuelve lento std::lower_bound
- Una búsqueda binaria típica compara el elemento del medio con
valuey luego elige el intervalo izquierdo o derecho - Cuando no se conoce la posición del objetivo de búsqueda,
if (comp(first[half], value))suele convertirse en una rama difícil de predecir - La CPU ejecuta instrucciones por adelantado mediante predicción de ramas, pero si la predicción falla, debe descartar el trabajo realizado
- Con movimientos condicionales, se puede elegir el valor según el resultado de la comparación reduciendo los saltos condicionales
clang -cmovtambién puede convertir algunosif/elsedestd::lower_bounden movimientos condicionales, lo que lo hizo alrededor de un 25% más rápidogccno tiene una buena opción para forzar movimientos condicionales en la misma situación, y actualmente tampoco emite código branchless parasb_lower_bound, independientemente del nivel de optimización
Búsqueda “óptima” desde la perspectiva de la cantidad de comparaciones
- Aquí, “óptima” se refiere a una búsqueda binaria con el mínimo número de comparaciones
- En una lista de tamaño
n, los posibles resultados destd::lower_boundsonn+1: lasnposiciones de elementos más una posición final - Si el tamaño de la lista es
2^k - 1, hay2^kresultados posibles, y como cada comparación aporta 1 bit de información verdadero/falso, el número óptimo de comparaciones esk - En casos “nice” con longitud
2^k - 1, es posible una búsqueda óptima con un bucle muy corto - Si la longitud no coincide, puede producirse un acceso fuera de rango, como cuando
valuees 4 en[0, 1, 2, 3, 4, 5]
Características de rendimiento y restricciones de sb_lower_bound
- Al dividir un intervalo de longitud par,
sb_lower_bounden algunos casos no salta suficientes elementos aunque el resultado de la comparación sea verdadero - En el rango
2^k <= n < 2^(k+1), siempre realizak+1comparaciones - En el mismo rango,
std::lower_boundrealizakok+1comparaciones, con un promedio de aproximadamentelog2(n+1)comparaciones - Aunque puede realizar más comparaciones, la cantidad de instrucciones dentro del bucle es mucho menor, por lo que el tiempo total de ejecución resulta más rápido
- Si la función de comparación es muy lenta, la diferencia entre
k+1ylog2(n+1)comparaciones puede afectar el rendimiento - Para forzar movimientos condicionales en
gcc, se puede usarcmovcon ensamblador inline específico de x86, pero el método simple aumenta la cantidad de instrucciones y las alternativas requieren escribir ensamblador por separado para cada tipo
La variante más rápida bb_lower_bound
bb_lower_bounddivide el intervalo de otra manera hasta que la longitud queda en la forma2^k - 1, y luego busca con un segundo bucle rápidolength & (length + 1)se usa para determinar si la longitud tiene la forma11..1, es decir,2^k - 1- Para longitudes no estándar, usa un valor MAGIC
auto step = length / 8 * 6 + 1para acercarse rápidamente a un intervalo “nice” - En general,
stepdebe ser al menoslength / 2para poder pasar con frecuencia al bucle rápido, pero si queda demasiado cerca delength, se pierde la ventaja de la búsqueda binaria - Debido al
break,bb_lower_boundtiene ramas - Usar una tabla con el
stepmás rápido precalculado para todas las longitudes sigue siendo un camino aún no explorado
La implementación completamente branchless no fue más rápida
- En una máquina de 64 bits, el bucle de
sb_lower_bounditera como máximo 64 veces, por lo que es posible crear una versión “completamente branchless” que elimine incluso la comprobación delengthusandoswitchy fall-through intencional - Esta estructura salta a la posición de código correspondiente al número de comparaciones necesarias mediante
std::bit_width(length) - En rendimiento real, no fue más rápida
- Las CPU x86 modernas manejan bien ramas predecibles como las condiciones de bucle, por lo que eliminar la comprobación de
lengthno aportó beneficios - También se concluyó que el bucle común es mejor porque evita plantillas, macros y copiar-modificar 64 casos
Resultados de benchmark
- Los resultados en tiempo promedio de ejecución (ns) con
clang -cmovfueron los siguientesstd::lower_: 61.30branchless_lower_: 43.43asm_lower_: 54.32sb_lower_: 33.24sbm_lower_: 35.54bb_lower_: 32.73
- En la media geométrica del tiempo de ejecución (ns),
sb_lower_también fue el más bajostd::lower_: 39.17branchless_lower_: 25.14asm_lower_: 31.21sb_lower_: 19.81sbm_lower_: 20.91bb_lower_: 21.33
sbm_lower_boundes una variante que usafirst += comp(first[length], value) * (length + rem)en lugar deif, para inducir agcca generar movimientos condicionales- Como esta optimización podría desaparecer en una próxima versión de
gcc, requiere comentarios y precaución - Los comandos de benchmark usaron
g++-10,clang++-10yclang++-10 -mllvm -x86-cmov-converter=false, con-march=haswell -march=nativeo no especificar-marchno afectó mucho el ranking, y las pruebas se realizaron en un Intel i7 Kaby Lake
Medición de fallas de predicción de ramas
- Una ejecución común de
clangmedida conperfregistró alrededor de 6,940 millones de branches y unos 1,200 millones de branch-misses, con una tasa de branch-misses de 17.34% - La ejecución con
clang -cmovregistró alrededor de 4,070 millones de branches y unos 35.95 millones de branch-misses, reduciendo la tasa de branch-misses a 0.88% -cmovelimina alrededor de 2,900 millones de ramas y unos 1,200 millones de fallos de rama- Las ramas eliminadas eran ramas que fallaban la predicción con una probabilidad aproximada del 41%
- Esto se acerca al 50% esperable en una rama totalmente impredecible
Con funciones de comparación lentas, los resultados cambian
- Para evaluar una situación con una función de comparación más lenta, se probaron búsquedas de strings de 8 bytes
- En tiempo promedio de ejecución (ns),
std::lower_boundfue ligeramente más rápido o similar asb_lower_boundgcc:std::lower_160.01,sb_lower_165.66clang:std::lower_157.71,sb_lower_162.68,bb_lower_157.22clang -cmov:std::lower_156.06,sb_lower_164.71,bb_lower_157.48
- En este caso,
std::lower_boundes apenas, pero de forma consistente, más rápido quesb_lower_bound - Una biblioteca puede apuntar al mejor rendimiento usando
sb_lower_boundcuando opera directamente sobre tipos primitivos, ystd::lower_bounden los demás casos
Diferencias visibles en el ensamblador
- El hot loop de
std::lower_boundconclang -cmovincluye movimientos condicionales comocmovaycmovbe, pero usa varias instrucciones para actualizar la longitud y la posición - El hot loop de
sb_lower_boundcalcula la mitad de la longitud, el resto y el puntero a mover, y luego actualizafirstconcmova - El ensamblador de
branchless_lower_boundes muy corto y limpio, pero en las pruebas de rendimientosb_lower_boundobtuvo mejores resultados con menor overhead
Actualización: sb_lower_bound más corto
- Tras un comentario del autor de orlp.net,
sb_lower_boundpuede refactorizarse para reducir las instrucciones del ensamblador del hot loop de 9 a 8 - La clave es que
length - halfequivale ahalf + length % 2 - La forma refactorizada calcula
half = length / 2, y si la comparación es verdadera ejecutafirst += length - half, luego actualizalength = half - Con
clang -cmov, el tiempo promedio de ejecución mejoró levemente de unos 33 ns a unos 32 ns
En arreglos grandes, el prefetching es efectivo
- El prefetching sugerido en los comentarios consiste en traer a la caché L1/L2 la memoria necesaria antes de tiempo, para reducir la latencia cuando se accede realmente
- Las latencias de ejemplo son aproximadamente 4 ciclos para L1, 12 ciclos para L2, 40 ciclos para L3 y 200 ciclos para memoria
- Tanto
gcccomoclangsoportan__builtin_prefetch() - Al hacer prefetch de la posición
length / 4, 1 de cada 2 se desperdicia; si también se agrega hastalength / 8, 5 de cada 6 se desperdician - El propio cálculo de las posiciones de prefetch y las llamadas también tienen overhead, y en un hot loop acortado ese costo es importante
- Varias estrategias de prefetch no ayudaron en arreglos de menos de 256 KB
- A partir de 256 KB,
sbp_lower_boundcon prefetching mejoró el tiempo promedio de ejecución de unos 32 ns a unos 26 ns en pruebas de hasta aproximadamente 4 millones de entradas, es decir, 16 MB - En pruebas ampliadas luego hasta unos 128 millones de entradas, es decir, 512 MB, la versión con prefetching fue unas 2.3 veces más rápida que
std::lower_bounden tiempo promedio- La comparación fue
std::lower_boundalrededor de 161 ns y la versión con prefetching alrededor de 71 ns
- La comparación fue
Observaciones y alternativas en datasets grandes
- En tamaños muy grandes, el
std::lower_boundbranchless generado porclang -cmovfue más lento que la versión con ramas - Las CPU modernas pueden seguir ramas predichas y avanzar con cargas de memoria y ejecución especulativa, lo que en la práctica puede actuar como prefetching
sbpm_lower_boundes una versión desbm_lower_boundcon prefetching, e induce agcca generar código branchless mediante multiplicación booleana- Entre 1 millón y 10 millones de elementos hubo saltos en la gráfica de rendimiento, lo que sugiere que teóricamente podría haber margen para una implementación más rápida
- Sin embargo, el código de prefetching se vuelve cada vez más complejo y acumula constantes mágicas; se considera que cuanto mayor sea la complejidad, menor será la probabilidad de contribuirlo a
gcc/libstdc++ollvm/libc++ - Una alternativa que rompe las restricciones de
std::lower_boundes Eytzinger Binary Search, que reorganiza el arreglo de entrada en forma de heap de medianas binarias para hacer las consultas más cache-friendly - En una prueba de árbol 16-ario de enteros de Sergey Slotin en CppCon 2022, los resultados fueron de 7 a 15 veces más rápidos que
std::lower_bound
Código y condiciones de uso
- Si la búsqueda o la comparación es la parte más lenta del programa y al procesador le resulta difícil predecir los resultados de las comparaciones, en x86 se puede probar la opción de
clang-mllvm -x86-cmov-converter=false - Si se necesita una búsqueda binaria más rápida, se puede probar
sb_lower_bound; engcc,sbm_lower_boundtambién es una opción - El código está publicado bajo licencia MIT
- El código y los benchmarks pueden consultarse en github.com/mh-dm/sb_lower_bound/
1 comentarios
Opiniones de Hacker News
Cada vez que veo que la gente intenta eliminar ramas, me pregunto si sabe que los fallos de predicción de ramas que detienen pipelines largos no son un elemento indispensable de la arquitectura de CPU.
Los pipelines son largos porque se hacen muchos análisis y transformaciones justo antes de la ejecución, pero como no son algoritmos con mucha dependencia de estado, la mayor parte podría hacerse por adelantado.
La CPU Transmeta Crusoe funcionaba de esa manera, y se puede imaginar un mundo en el que no haya que preocuparse por las ramas.
Si se mira más a fondo, toda operación es una rama que observa el estado de los bits y cambia el resultado, pero esas ramas locales dentro de la ALU no son ramas sobre el pipeline principal, así que no perjudican mucho el rendimiento.
Recuerdo haberle dicho también a srk en esa época que elegir entre IPC y rendimiento como métrica influye en qué se considera bueno o malo.
El lado de IPC asumía que, si se lograba un IPC más alto, el proceso de fabricación subiría la frecuencia y todos ganarían; el lado del rendimiento tomaba un enfoque más realista: la ley de Moore murió, y si haces correr el silicio más rápido se derrite, así que gana quien diseñe la ISA de forma inteligente.
En los últimos 20 años ambos lados tuvieron éxitos y frustraciones, y es interesante que hoy RISC-V esté volviendo a este tipo de preguntas en arquitectura de CPU.
También es un buen lugar para seguir cómo se agregan ideas superescalares modernas sobre la base de la flexibilidad del conjunto de instrucciones, y a largo plazo creo que ese lado va a ganar.
La traducción de Transmeta no eliminaba el costo de las ramas.
Recuerdo que Linus, que trabajaba en Transmeta, dijo en un hilo de comp.arch algo parecido a que “el trabajo de la CPU es generar fallos de caché lo más rápido posible”.
Los fallos de caché obligatorios existen, y ningún JIT puede eliminarlos.
En el mundo real, incluso con cachés enormes como las actuales, tampoco se pueden evitar los fallos por capacidad.
Itanium también creía que podía eliminar el costo de las ramas mediante análisis estático, y basta recordar cómo terminó eso.
Ojalá los programadores leyeran algunos libros de arquitectura de computadoras antes de concluir con tanta confianza que pueden crear fácilmente algo mejor que los procesadores modernos.
Creo que están subestimando por al menos 7 dígitos la escala del esfuerzo intelectual que hay dentro de los procesadores actuales.
Uno de ellos son los datos de entrada que se procesan.
La búsqueda binaria es justamente un caso así: el compilador no sabe en qué posición se encontrará el resultado.
Otro es la microarquitectura, en especial la jerarquía de caché y la configuración de las unidades de ejecución.
Si se cambia a una ISA con instrucciones parecidas a las microoperaciones de las CPU actuales, habría que recompilar para cada microarquitectura.
Dicho eso, técnicamente esto se puede resolver con un JIT del sistema operativo, al estilo de las GPU actuales: distribuir los programas en formato de bytecode (DXBC, SPIR-V, NVPTX) y que el driver de GPU en modo usuario los recompile a instrucciones reales del hardware.
La variable más grande es que otros hilos de CPU ejecutan código desconocido.
Incluso si se elimina el hyperthreading para hacer independientes los núcleos, seguirán existiendo recursos compartidos a nivel de todo el chip, como la caché L3, la memoria externa, el ancho de banda de I/O, la energía y el calor.
Si redefinimos todo como Branch™, entonces algunas Branch™ se pueden calcular por adelantado, incluyendo cosas que en realidad no son ramas.
Pero la eliminación de ramas de la que se suele hablar no trata de casos como if/else, donde el camino de cálculo se divide de verdad.
Incluso en ese mundo podrían hacerse optimizaciones útiles, pero estarían limitadas a las Branch™ que intentan calcular simultáneamente varios resultados futuros.
Cada vez que hay una operación que puede realizarse de forma independiente, aparece la posibilidad de ejecutarla en paralelo.
No hablo solo de decodificar, traer instrucciones y ejecutar.
Si tienes una ALU y un shifter independientes, puedes desplazar mientras sumas; y si tienes un sumador y un multiplicador dedicados, no hay razón para no intentar ambas cosas al mismo tiempo.
Eso, a su vez, hace que quieras tener varias instrucciones en curso a la vez, lo que significa que debes poder traer y decodificar instrucciones más rápido de lo que las procesas.
Además, naturalmente lleva a situaciones en las que quieres reordenar para que N instrucciones Add no impidan ver un Shift independiente.
Puedes pensar que la arquitectura actual es más compleja de lo necesario, y puede que no estés equivocado.
Aun así, hay una cantidad enorme de ingeniería invertida en crear la estructura actual, así que si crees que con otro enfoque se podría hacer algo mucho más rápido, conviene investigar a fondo qué tan precisa es esa afirmación.
En la parte que dice “Ojalá existiera un lenguaje bare metal limpio y rápido para escribir todo esto…”, el autor agregó notas al pie de “BUT RUST..” y “BUT ZIG..”, pero me pregunto qué tal sería Nim
Parece que tiene una implementación de biblioteca nativa de
lowerBound: https://github.com/nim-lang/Nim/blob/version-2-0/lib/pure/al...Estrictamente hablando no es un lenguaje “bare metal”, pero compila a C o C++, así que sería interesante ver a qué código compila aquí
Y también me pregunto cuál es el problema con C
En TigerBeetle usan su propia implementación sin ramas: https://github.com/tigerbeetle/tigerbeetle/blob/e996abcf7154...
Este es justamente el tipo de uso por el que se necesitan las plantillas de C++
C no es limpio
No estoy muy seguro de que esto siga siendo
lower_boundPuede que haya leído mal el código, pero cuando hay duplicados parece devolver cualquier coincidencia, no la primera coincidencia
Si la función de comparación busca un prefijo de cadena específico para autocompletado, incluso en una lista única puede haber varios elementos que coincidan, y en ese caso quieres el primer elemento de la lista
Me da curiosidad por qué piensas que no
Ojalá todas las publicaciones de blog empezaran como esta: “Seguro están ocupados, así que voy directo al grano. Aquí está la implementación de búsqueda binaria en C++ más rápida, general y simple”
La biblioteca estándar de Zig no llama a C++ para hacer búsqueda binaria
La búsqueda binaria actual está aquí: https://github.com/ziglang/zig/blob/b835fd90cef1447904d3b009...
No lo entiendo bien
El problema de la búsqueda binaria y las ramas no es la rama en sí, sino que hasta terminar la comparación no sabes qué ubicación de memoria del arreglo traer después
Da igual si usas una rama u otra cosa; al final el problema es qué quieres que haga el procesador
Hay una dependencia de datos
Antes de leer el índice del medio, no sabes si buscar en la mitad superior o en la inferior
Puedes especular y emitir lecturas para ambos lados, y eso resuelve la dependencia, pero aumenta el tráfico de memoria
La clave es si ese es el compromiso correcto; simplemente eliminar las ramas no es la respuesta
Se trata al final del artículo: https://mhdm.dev/posts/sb_lower_bound/#prefetching
Por eso una búsqueda binaria correctamente más rápida usa la disposición de arreglo de Eytzinger: https://algorithmica.org/en/eytzinger
En mi procesador Cascade Lake,
-mllvm -x86-cmov-converter=falsereduce casi a la mitad el rendimiento de la búsqueda binariaLas cifras son nanosegundos por bsearch en un arreglo
uint32de 100 MBParece que clang 15.0.7 es mucho peor que gcc 13.2.1 en esta optimización específica de código
El ensamblador se puede ver aquí: https://godbolt.org/z/cbx5Kdjs6
El ensamblador de gcc se ve mucho más limpio
100 MB es lo suficientemente grande como para que la versión con ramas salga ligeramente favorecida, pero no porque sea mejor, sino por las características de la ejecución especulativa de x86
¿Alguien sabe a dónde debía apuntar originalmente el enlace “BUT RUST”?
Como no estaba fijado a una versión, parece que ya se rompió, y quizá apuntaba a la mitad del comentario de documentación de
starts_withlet mid = left + size / 2;[1] https://web.archive.org/web/20230602210213/https://doc.rust-...
Era para enlazar a la implementación de búsqueda binaria de Rust
Se actualizó a https://doc.rust-lang.org/1.71.1/src/core/slice/mod.rs.html#...
Es interesante que el resultado no se mantenga con una función de comparación
compmás complejaEn el artículo se pensó en un escenario de búsqueda binaria algo realista, en el que la función de comparación es lenta, como con IDs, números de teléfono, cuentas o palabras clave, y por eso se probó la búsqueda de cadenas de 8 bytes
En este caso,
std::lower_boundes apenas, pero consistentemente, más rápido quesb_lower_bound; y se dice que, para obtener siempre el mejor rendimiento, la biblioteca debería usarsb_lower_boundcuando trata directamente con tipos primitivos, ystd::lower_bounden los demás casosMe gustaría ver el análisis de esto
Si los datos y las entradas son verdaderamente aleatorios, la predicción fallará aproximadamente la mitad de las veces
El enfoque con CMOV queda bloqueado por la dependencia de datos después de la función de comparación
En promedio, el enfoque con ramas realiza dos comparaciones a la vez, mientras que CMOV realiza una, así que se esperaría que haya un punto de cruce cuando el tiempo de comparación supere la penalización por fallo de predicción de ramas
Algo que armé rápidamente con SIMD hace tiempo era 3 veces más rápido que
std::lower_boundantes de topar con el ancho de banda de memoria: https://github.com/matthewkolbe/ThinkingInSimd/tree/main/alg...Se asume que son puramente aleatorios, pero si esas cadenas de 8 bytes no son información pura, los predictores de ramas modernos pueden rendir fácilmente mejor que
cmovParece que el atributo
unpredictableahora afecta el pase de conversión a cmovEs del 1 de junio, así que probablemente entre en clang 17/18: https://reviews.llvm.org/D118118