- APIs como
strstrystd::string::findasumen una búsqueda única de subcadenas, pero en CPUs modernas el costo de comparar cadenas cortas y vectores es bajo, así que un enfoque SIMD puede ser ventajoso - La idea central es convertir la condición de hash débil de Karp-Rabin en un predicado vectorial y realizar una comparación exacta de la subcadena solo en las posiciones candidatas
- El algoritmo SIMD genérico reduce candidatos comparando en paralelo el primer y el último carácter de la needle, y valida con
memcmpsolo las posiciones con posibilidad de coincidencia - También se comparan los enfoques SSE4.1
MPSADBWy SSE4.2PCMPESTRM, pero las mediciones muestran que el SIMD genérico es más rápido de forma más estable, yPCMPESTRMsuele ser incluso más lento queMPSADBW - El SIMD genérico fue más rápido que
strstrde C en todas las plataformas, pero no es seguro porque puede leer fuera de la cadena de entrada, y además la comparación no es totalmente equivalente porque recibe la longitud por adelantado
Suposiciones de costo que cambiaron en la búsqueda de cadenas
- APIs como
strstren C,std::string::finden C++ ypos,indexde cadenas en Python están pensadas para una búsqueda única de una subcadena dentro de una cadena dada - Los algoritmos clásicos se dividen en dos grandes grupos
- métodos basados en autómatas finitos deterministas como Knuth-Morris-Pratt y Boyer Moore
- métodos basados en comparaciones simples como Karp-Rabin
- Los algoritmos estándar asumen que comparar 1 par de caracteres, consultar una tabla auxiliar y hacer una rama condicional es barato, mientras que comparar dos subcadenas es costoso
- En CPUs modernas de escritorio, esta suposición puede dejar de cumplirse
- en CPUs de 64 bits no hay diferencia de costo entre comparar 1, 2, 4 u 8 bytes
- si hay soporte para instrucciones SIMD, comparar vectores de 16, 32 o 64 bytes puede ser tan barato como comparar un solo byte
- consultar tablas implica un costo de acceso a memoria comparable a una ida y vuelta al caché L1, y leer caracteres individualmente tiene un costo similar
- una mala predicción de rama puede introducir una penalización de aproximadamente 10~20 ciclos
- una cadena corta de dependencias entre lectura de caracteres, comparación y rama condicional dificulta aprovechar la ejecución out-of-order del CPU
Enfoque: usar un predicado vectorial en lugar de un hash débil
- Karp-Rabin realiza una comparación exacta solo cuando el hash débil de la subcadena buscada coincide con el hash del segmento actual de la cadena
- El enfoque SIMD reemplaza esa condición de hash por un predicado vectorial
- el predicado se calcula en paralelo
- solo se realiza una comparación exacta de subcadena en cada posición marcada como verdadera en el vector de predicados
- La especialización por longitud también se usa para mejorar el rendimiento
- una implementación general llama a funciones como
memcmppara comparar subcadenas - si se conoce la longitud de la subcadena a buscar, esa llamada puede reemplazarse por unas pocas instrucciones de CPU, y en algunos casos por una sola instrucción
- esto reduce el costo de la llamada a función y el costo interno de
memcmp
- una implementación general llama a funciones como
Algoritmo 1: SIMD genérico
- El algoritmo SIMD genérico puede aplicarse a varios conjuntos de instrucciones SIMD y también al enfoque SWAR
- El predicado básico verifica si coinciden tanto el primer carácter como el último carácter de la needle
- se llena el registro
Fcon el primer carácter - se llena el registro
Lcon el último carácter - en cada iteración se lee el bloque
Adel haystack en el offset actuali, y el bloqueBeni + k - 1 - luego se calcula
F == AyB == L, y se combinan ambos resultados para crear una máscara de posiciones candidatas - solo en las posiciones donde la máscara es verdadera se realiza la comparación exacta de la subcadena
- se llena el registro
- En el ejemplo de buscar
"cat"dentro de"a_cat_tries", la única posición donde coinciden tanto lacinicial como latfinal es el índice 2, así que solo se hace una comparación exacta - Elegir siempre el primer y último carácter no siempre es buena idea
- si la cadena de entrada está compuesta mayormente por
'A'y la needle es"AjohndoeA", puede haber muchos candidatos - la implementación puede elegir, en lugar del último carácter, el carácter más lejano que sea distinto del primero
- si todos los caracteres de la needle son iguales, puede usarse un procedimiento especial para patrones como
"AAAAA"
- si la cadena de entrada está compuesta mayormente por
Diferencias entre implementaciones
- Las implementaciones con SSE y AVX2 tienen casi la misma estructura y usan el número mínimo de instrucciones
- como ya se sabe que el primer y el último carácter coinciden,
memcmpno necesita volver a compararlos
- como ya se sabe que el primer y el último carácter coinciden,
- El enfoque SWAR aprovecha que si el resultado de XOR es 0, entonces los bytes son iguales
- en vez de hacer AND de resultados parciales como en SSE/AVX2, usa OR
- encontrar las posiciones de bytes en 0 requiere más trabajo
- la implementación en C++ para vectores de 64 bits calcula la máscara exacta de bytes
- AVX512F no tiene operaciones a nivel byte y su elemento vectorial más pequeño es una palabra de 32 bits, por lo que requiere la técnica SWAR
- calcula dos XOR y un OR con instrucciones AVX512F
- encuentra los elementos que contienen un byte 0 dentro de cada elemento de 32 bits
- para cada uno de esos elementos de 32 bits verifica 4 posibles subcadenas candidatas
- La implementación de ARM Neon de 32 bits usa registros SIMD de 128 bits
- el largo viaje de ida y vuelta entre la unidad Neon y el CPU se convierte en un cuello de botella
- por eso guarda los resultados de comparación como palabras de 64 bits en memoria para procesarlos
- al desenrollar 2 veces el bucle interno, el rendimiento mejora cerca de 1.2x
- La implementación AArch64 es casi igual al procedimiento con ARM Neon, pero leer directamente los lanes del registro SIMD es rápido
Algoritmo 2: SSE4.1 MPSADBW
MPSADBWen SSE4.1 y AVX2 calcula la distancia Manhattan entre un subvectores de 4 bytes de un registro y 8 subvectores consecutivos de 4 bytes de otro registro- Si dos subvectores son iguales, la distancia L1 es 0, por lo que puede usarse para buscar posiciones candidatas
- este enfoque usa como predicado la igualdad de los primeros 4 caracteres
- Comparar los primeros 4 caracteres parece más fuerte que comparar el primero y el último, pero en el peor caso no evita la complejidad cuadrática
- si el haystack está lleno de
"a"y la needle es"aaaabcde", el predicado es verdadero en todos los caracteres de entrada
- si el haystack está lleno de
- El enfoque
MPSADBWtiene varias restricciones- en principio no se adapta bien a subcadenas de longitud menor que 4
- sí puede manejar longitud 3, pero requiere código adicional
- en SSE,
MPSADBWprocesa solo 8 bytes por vez - en AVX2,
MPSADBWno opera sobre todo el registro de 256 bits sino por lanes de 128 bits, así que hace falta código extra de carga - la latencia de la instrucción es alta, de 5~7 ciclos según el CPU, aunque su throughput es de 1~2 ciclos, por lo que el desenrollado del bucle puede ocultar esa latencia
- AVX512F no incluye
MPSADBW, pero como soporta elementos de 32 bits de forma nativa, puede imitarse el predicado de igualdad del prefijo de 4 bytes- en cada iteración se construyen 4 vectores AVX512 con los posibles prefijos de 4 bytes
- cada construcción de vector requiere 2 desplazamientos y 1 OR
- el bucle de comparación se vuelve más complejo, y para llenar el último elemento hacen falta 4 bytes más allá del vector
Algoritmo 3: SSE4.2 PCMPESTRM
- SSE4.2 introdujo el conjunto de instrucciones STNI con la idea de servir como bloques de construcción para operaciones con cadenas
- Intel prácticamente abandonó STNI en procesadores posteriores y no introdujo una versión AVX2; además, estas instrucciones son muy lentas
- una latencia de 11 ciclos se considera difícil de aceptar
- Las instrucciones
PCMPxSTRxtienen cuatro variantes según cómo se determine la longitud de la cadena y cómo se almacene el resultado- la longitud puede darse explícitamente o puede considerarse que la cadena termina en el primer byte 0, como en las cadenas C tradicionales
- el resultado puede guardarse como máscara de bits o bytes, o como el número del primer o último bit activado de la máscara
- Aquí se usa el modo de comparación
range ordered- pese al nombre, cuando se supera el ancho del registro o se trata de una subcadena, encuentra el prefijo correspondiente
- en el ejemplo de buscar
"ABCD"dentro de"ABCD_ABC_ABCD_AB", el sufijo"AB"también puede tratarse como coincidencia - por eso, lo único que puede asumirse con seguridad es la coincidencia del primer carácter, y el resto debe verificarse con
memcmp
Entorno de medición de rendimiento
- Se midió el rendimiento de varias implementaciones SIMD, incluyendo especialización en tiempo de ejecución para subcadenas cortas
- Se incluyó
strstrde C como referencia, pero se excluyóstd::string::findde GNU libc en las tablas x64 debido a un bug de rendimiento - Cada programa de prueba se ejecutó tres veces
- Entorno de pruebas x64
- Westmere i5 M540, GCC 6.2.0
- Bulldozer FX-8150, GCC 4.8.4
- Haswell i7 4470, GCC 5.4.1
- Skylake i7 6700, GCC 5.4.1
- Knights Landing 7210, GCC 5.3.0
- Entorno de pruebas ARM
- ARMv7 Raspberry Pi 3, código de 32 bits, GCC 4.9.2
- ARMv8 ARM Cortex A57 / AMD Opteron A1100, código de 64 bits, Clang 3.8.0
Resultados en x64
- La implementación SIMD genérica mostró las mayores mejoras frente a
strstren x64- en Westmere, SSE2 generic marcó 0.74589 segundos, frente a 0.82246 de
strstr - en Haswell, AVX2 generic marcó 0.38653 segundos, frente a 0.52786 de
strstr - en Skylake, AVX2 generic marcó 0.36309 segundos, frente a 0.66148 de
strstr - en KNL, AVX512F generic marcó 1.14057 segundos, frente a 4.94606 de
strstr
- en Westmere, SSE2 generic marcó 0.74589 segundos, frente a 0.82246 de
- La mejora máxima fue de 1.10x en Westmere, 1.37x en Haswell, 1.82x en Skylake y 4.33x en KNL
- El rendimiento de
strstren Bulldozer fue 9.37792 segundos, tan malo que cuesta usarlo como referencia - El enfoque
MPSADBWtuvo un desempeño flojo en general salvo en Skylake, y fue especialmente malo en KNL - El enfoque
PCMPESTRMresultó aún más lento queMPSADBW
Resultados en ARM
- En ARMv7,
std::strstrtardó 7.30405 segundos ystd::string::find4.17131 segundos - En ARMv7, ARM Neon 32-bit generic logró 1.29861 segundos, hasta 3.1x más rápido que
std::string::find - En ARMv8,
std::strstrtardó 3.37546 segundos ystd::string::find1.81368 segundos - En ARMv8, AArch64 64-bit generic logró 0.27897 segundos, hasta 6.5x más rápido que
std::string::find - En ARMv8, SWAR 64-bit generic logró 0.46269 segundos, con un rendimiento cercano al procedimiento SIMD de 32 bits
Conclusiones y límites
- El algoritmo SIMD genérico fue más rápido que
strstrde C en todas las plataformas - Conviene que la implementación elija la versión SIMD más alta disponible en cada CPU específico
MPSADBWno muestra buen rendimiento salvo en el caso de Skylake, y en Knights Landing es muy maloPCMPESTRMrinde peor queMPSADBW- El rendimiento de ARM Neon fue bueno, incluso incluyendo la implementación SWAR
- la versión SWAR es 1.7x más rápida que
string::find - la versión SIMD es 3.1x más rápida que
string::find
- la versión SWAR es 1.7x más rápida que
- La comparación con
strstrpuede no ser totalmente justastrstrprocesa cadenas cuya longitud no conoce- estas implementaciones reciben la longitud como argumento y la aprovechan
- La implementación no es segura
- puede leer datos fuera de la cadena de entrada
- si la cadena está justo antes de memoria no mapeada, puede producirse una violación de acceso
- AddressSanitizer también puede reportar el problema
- hacerla segura sería posible, pero no era el objetivo
- Todas las implementaciones y programas de prueba están en GitHub
Aún no hay comentarios.