17°
GoBenchmarksPythonRendimiento

Go 1.27 trae SIMD portátil: empata con NumPy fuera de caché y pierde dentro

Medí el paquete simd experimental de Go 1.27 contra NumPy en una tarea real. Empatan cuando el corpus no cabe en caché, y NumPy gana 2.4 veces cuando sí cabe. Por el camino casi publico dos comparaciones falsas, y esas son la parte útil.

Efrain Garay 19 de agosto de 2026

Estaba montando un buscador de voz. Cada grabación pasa por un codificador de hablante y sale un vector de 192 números que representa la voz, no lo que dice. Buscar a alguien es comparar su vector contra todos los demás y quedarse con los más cercanos.

Con 346 mil grabaciones eso son 66 millones de multiplicaciones por consulta. La pregunta práctica era con qué escribirlo, y no tenía idea. Así que lo medí, y de esa medición salió este artículo.

En 55 segundos: qué cambia con los vectores nuevos de Go, por qué empatan con NumPy fuera de caché y por qué pierden dentro, y la comparación falsa que casi publico. Sin sonido por defecto: actívalo en los controles.Verlo en el visualizador de reels →

Aviso sobre los números de este artículo. Todos los que aparecen abajo están medidos con un arnés que fija núcleos, intercala las implementaciones y descarta la corrida si la máquina no está quieta. Llegué a ese arnés después de que dos tandas anteriores salieran mal, y esa historia está al final, en una sección aparte, para no mezclar cifras buenas con cifras descartadas. Ninguna cifra del cuerpo viene de aquellas tandas.

Lo que trae Go 1.27

Go 1.27 salió con un paquete simd experimental en la biblioteca estándar. Da tipos vectoriales portátiles e independientes del ancho: se escribe Float32s, LoadFloat32s y MulAdd, y el mismo código fuente usa las instrucciones anchas que tenga la máquina.

Va detrás de una bandera. Sin ella el compilador ni lo ve:

package probe
	imports simd: build constraints exclude all Go files in .../src/simd

Con GOEXPERIMENT=simd compila. Al preguntarle al propio paquete qué había elegido apareció la primera sorpresa:

ancho 16 float32 = 512 bits · emulado false

Dieciséis carriles, o sea AVX-512, en un Ryzen 7 7800X3D. El código no menciona AVX por ningún lado.

Un carril contra dieciséisLa misma orden, aplicada a un número o a dieciséis a la vez. Los anchos son los que el paquete eligió de verdad en cada máquina.

Go sin vectorizar · 1 carril

Go no vectoriza solo, así que el bucle mueve un número por vuelta. Es el punto de partida contra el que se mide todo lo demás.

Ryzen 7 7800X3D · 16 carriles

El paquete eligió 16 carriles de float32 sin que el código mencione AVX. Es lo más ancho que existe hoy en x86.

Core i7-9750H · 4 carriles

Cuatro carriles, o sea 128 bits, en una máquina que tiene AVX2 y podría usar ocho. Portátil sí; óptimo, no siempre.

Cuánto rinde, y dónde deja de rendir

Antes de la tarea real hice un banco sintético con dos operaciones y tres tamaños: uno que cabe en L1, uno en L3 y uno que no cabe en ningún lado. Esa elección es la que decide qué mide un banco. Solo arreglos pequeños miden la unidad de cálculo; solo arreglos enormes miden la memoria.

Producto punto, escalar contra SIMD, según dónde viva el datoveces más rápido que el bucle sin vectorizar · más es mejor
  1. 8 KB, vive en L115.2×2 281 ns contra 150 ns.
  2. 4 MB, vive en L311.4×1.17 ms contra 102 µs.
  3. 64 MB, no cabe en caché4.4×18.8 ms contra 4.29 ms. Aquí manda el bus de memoria, no la unidad de cálculo.

Ryzen 7 7800X3D, Go 1.27.0, mediana de cinco corridas con un solo hilo. Se verificó que ambos caminos dan el mismo resultado, incluidos los tamaños 1, 7, 15, 16, 17, 1000 y 10 001 que no llenan un vector.

Con saxpy, que es y = a·x + y, la escalera es la misma pero más pronunciada: 22.4× en L1, 20.3× en L3 y 6.7× en RAM.

Esa caída es el resultado más útil del banco. Una medición que anuncie «veintidós veces más rápido» sin decir el tamaño del arreglo está midiendo la caché y llamándolo SIMD.

Un matiz necesario: Go no vectoriza automáticamente, así que el bucle escalar es literalmente un elemento por iteración. Parte de la ventaja es pasar de un carril a dieciséis, no mérito del diseño del paquete.

Por qué el resultado depende del tamaño

Antes de las tablas conviene tener a mano la razón de que haya dos. La ventaja de vectorizar no es una propiedad del código: es una propiedad de dónde cae el arreglo en la jerarquía de memoria.

La aceleración no depende del código sino de dónde cae el arregloCada piso está a escala de su capacidad y lleva la aceleración que medí con un arreglo de ese tamaño. Ryzen 7 7800X3D.

El arreglo entra completo en la caché más cercana al núcleo. Aquí el procesador nunca espera datos, así que lo único que decide es cuántas operaciones caben por instrucción: dieciséis carriles contra uno.

Aquí SIMD rinde todo lo que puede rendir.

Piso intermedio. No lo medí por separado: los tamaños del banco caen a un lado o al otro, y publicar una interpolación como si fuera medición sería justo lo que este blog no hace.

Sin dato propio. Se dice, no se rellena.

La caché grande del 7800X3D, la que hace especial a este procesador. El corpus de 40 000 embeddings pesa 31 MB y cabe entero. Todavía manda el cálculo, y por eso aquí OpenBLAS le saca 2.4 veces al paquete de Go: cuando el cuello es la unidad aritmética, la calidad del código decide.

Cabe el corpus chico. Gana la implementación mejor afinada.

El arreglo ya no cabe en ninguna caché y hay que traerlo por el bus en cada pasada. El procesador pasa la mayor parte del tiempo esperando, así que da casi igual cómo esté escrito el bucle. Aquí el paquete portátil de Go y el ensamblador afinado de OpenBLAS empatan.

Manda la memoria. Las dos implementaciones se igualan.

Con eso a la vista, las dos mediciones que siguen dejan de parecer contradictorias.

Corpus grande: empate

Una búsqueda contra 346 059 vectores de 192 dimensionesmilisegundos, menos es mejor · menos es mejor
  1. NumPy BLAS, 8 hilos3.24 msDispersión 3.2%, la medición más estable de todas.
  2. Go SIMD, 8 goroutines3.29 msDispersión 16%. La diferencia con NumPy cabe entera dentro del ruido.
  3. Go SIMD, un hilo9.54 msDispersión 14%.
  4. NumPy BLAS, un hilo9.76 msDispersión 10.4%. Un 2% más lento que Go, o sea empate.
  5. Go escalar61.2 msEl mismo cálculo sin vectorizar.

253 MB de corpus contra 96 MB de caché L3: no cabe. Ryzen 7800X3D con núcleos 0-7 fijados, siete rondas intercaladas, mediana. Python 3.12.10, NumPy 2.5.2 sobre scipy-openblas.

Es un empate, no una victoria. Go sale 2% más rápido que NumPy con un hilo, 9.54 contra 9.76, pero la dispersión de esa medición es del 14%: la diferencia cabe entera dentro del ruido y en otra corrida podría invertirse. Lo mismo con ocho hilos, 3.29 contra 3.24.

Eso ya es un resultado. Veinte líneas de un paquete portátil recién salido igualan a OpenBLAS, que lleva años de ensamblador afinado a mano por microarquitectura.

La razón del empate está en otro número: paralelizar dio 2.9 veces en Go y 3.0 en NumPy, sobre ocho núcleos físicos. Si el trabajo fuera de cálculo puro daría cerca de ocho, y que las dos se queden en tres es la señal más clara de que el techo no es el código. No lo da porque el corpus pesa 253 MB y no cabe en ninguna caché, así que los dos programas terminan esperando a la memoria. Cuando dos implementaciones tocan ese techo, la calidad del código deja de importar.

Corpus chico: NumPy gana

Cambié los vectores aleatorios por embeddings reales: 40 000 grabaciones en español de Common Voice pasadas por ECAPA-TDNN, a 292 clips por segundo en la tarjeta.

Los vectores son de verdad y se comprueba: norma media 1.000, similitud media de 0.563 entre clips del mismo hablante contra 0.119 entre hablantes distintos. El par más parecido de todos, con 0.921, resultó ser efectivamente del mismo hablante.

La misma búsqueda con 40 000 embeddings reales, que sí caben en cachémilisegundos, menos es mejor · menos es mejor
  1. NumPy BLAS, 8 hilos0.060 ms3.2 veces más rápido que Go con los mismos ocho núcleos.
  2. Go SIMD, 8 goroutines0.191 msDispersión 45%. A esta escala sospecho que pesa el propio arranque de las goroutines, pero no lo medí.
  3. NumPy BLAS, un hilo0.393 ms2.4 veces más rápido que Go. Ahora sí, fuera del ruido.
  4. Go SIMD, un hilo0.943 ms
  5. Go escalar4.62 msDispersión 78%, la peor de todas.

31 MB de corpus contra 96 MB de L3. Mismo arnés: núcleos 0-7 fijados, nueve rondas intercaladas, mediana.

Cuando el corpus cabe en la caché, el cuello vuelve a ser el cálculo, y ahí los años de afinado de OpenBLAS se notan: 2.4 veces con un hilo y 3.2 con ocho.

El control que hacía falta

Entre las dos tablas cambiaron dos cosas a la vez: el tamaño del corpus y la naturaleza de los datos. Atribuir la inversión a la caché sin separarlas habría sido una conclusión sin sostén, así que hice el control: 40 000 vectores aleatorios, exactamente el mismo tamaño que los reales.

Control: mismo tamaño, datos aleatorios en vez de realesmilisegundos con un hilo, menos es mejor · menos es mejor
  1. NumPy BLAS, aleatorios0.398 msContra 0.393 ms con embeddings reales: la misma cifra.
  2. Go SIMD, aleatorios1.009 msContra 0.943 ms con reales: 7% de diferencia, que es del tamaño de la propia dispersión.

40 000 vectores aleatorios de 192 dimensiones, 31 MB. Nueve rondas intercaladas.

La ventaja de NumPy se mantiene en 2.5 veces con datos aleatorios y 2.4 con reales, y las dos cifras de cada implementación quedan dentro del ruido de la otra. La naturaleza del dato no influye; el tamaño sí. La inversión entre las dos tablas es de caché, no de embeddings.

La segunda máquina

Todo lo anterior es un procesador. Repetí en el otro que tengo, un Intel i7-9750H de 2019 con AVX2 y sin AVX-512:

corpus: 346059 vectores de 192 · ancho SIMD 4 (emulado false)

Cuatro carriles, o sea 128 bits, en una máquina que tiene AVX2 y podría usar ocho. El paquete portátil funcionó, pero no eligió lo más ancho disponible. La ventaja de SIMD sobre escalar cayó a 1.8 veces, contra 6.4 en el Ryzen con ese mismo corpus.

Parte de esa caída son los cuatro carriles contra dieciséis, y parte es que se trata de una máquina de 2019 con memoria más lenta que además estaba con carga 4.4 durante la medición. Esos números son un piso, no un techo, y no pasaron por el arnés: no fijé núcleos ni intercalé, así que valen para el ancho de vector elegido y poco más. No medí NumPy en esa máquina.

Lo que sí queda establecido es que el mismo código fuente compiló y corrió sin cambios en Linux y en macOS, eligiendo anchos distintos. Portátil sí; óptimo, no siempre.

Qué me llevo

El paquete cumple y es cómodo. Veinte líneas, sin ensamblador, sin etiquetas de compilación por arquitectura, y en el régimen limitado por memoria iguala a una biblioteca con años de afinado manual. Para código Go que hoy hace bucles numéricos a mano, es una mejora clara.

No reemplaza a NumPy para trabajo de datos. Cuando el corpus cabe en caché, BLAS va 2.4 veces adelante con un hilo y 3.2 con ocho, y encima paraleliza sin que se lo pidan.

El número que decide no es la aceleración sino dónde vive el dato. La misma comparación da empate o derrota según el corpus quepa o no en la caché de tercer nivel.

Cuándo lo usaría y cuándo no

Lo usaría en un servicio en Go que ya existe y hace cálculo numérico en bucles, donde meter Python sería absurdo, y para procesar audio o imagen elemento a elemento. No lo usaría para reemplazar NumPy en trabajo de datos, ni contando con que elija el ancho máximo, ni en producción mientras siga detrás de una bandera experimental.

Cómo llegué a ese arnés

Esta sección no contiene ninguna cifra del artículo. Está aquí porque las dos formas en que me equivoqué son más fáciles de repetir que de detectar.

La primera vez comparé ocho núcleos contra uno. La lectura inicial daba a NumPy el doble de rápido que Go, y estuve a punto de escribirlo así. OpenBLAS reparte el trabajo entre todos los núcleos sin anunciarlo, mientras mi programa en Go corría en un hilo. Al fijar OMP_NUM_THREADS y OPENBLAS_NUM_THREADS en 1, NumPy se volvió tres veces más lento y la comparación cambió de sentido.

La segunda vez medí una máquina ocupada sin saberlo. Corregido lo anterior, saqué conclusiones y las di por buenas. Al repetirlas por costumbre, los mismos números salieron diez veces peores. La máquina tenía trece procesos ajenos al 100% de CPU: la primera tanda se había tomado con el equipo quieto y la de comprobación con el equipo saturado, y ninguna de las dos lo sabía.

Ahí escribí el arnés. Fija los núcleos con taskset a los ocho físicos. Intercala las implementaciones ronda a ronda en vez de medir todo A y luego todo B, para que una degradación a mitad de la prueba reparta el daño en lugar de castigar a la segunda. Mira la carga antes y después y aborta si supera 2.0; no exige quietud absoluta, y en la corrida del corpus grande pasó de 0.50 a 0.61 y se dio por buena. Y reporta mediana y dispersión en vez del mejor tiempo, porque el mejor mide el mejor caso y esconde justo la inestabilidad que aquí importa.

Su primer intento dio a NumPy 0.000 ms. No era un récord: perf_counter devuelve segundos y yo los imprimía como milisegundos. Un cero sospechoso siempre es un error de unidades antes que un prodigio.

Lo que esta medición no prueba

Dos máquinas domésticas, no un banco de servidores, y solo una de las dos pasó por el arnés. Un modelo de embeddings, una dimensión de vector y dos tamaños de corpus. Mi implementación en Go recorre vector por vector mientras OpenBLAS resuelve la multiplicación matriz-vector completa con bloqueo y desenrollado: no estoy midiendo el techo del paquete sino una versión ingenua contra una experta, y ese es justamente el escenario de quien lo usaría recién salido.

Tampoco medí si un compilador de C vectorizaría solo el bucle escalar. Lo di por hecho en un borrador de este artículo y lo quité al no poder respaldarlo: para reducciones de punto flotante los compiladores suelen necesitar permiso explícito para reasociar.

Los números exactos son de este hardware. La forma de la curva se repitió en las dos máquinas, con datos sintéticos y reales, y es lo único que yo daría por general.

Fuentes

Comentarios

Todavía no hay comentarios. El primero es tuyo.

Se revisa antes de publicarse. El correo no se guarda ni aparece en ninguna parte.