El tiempo de un pipeline de datos se va a tres lugares: leer y decodificar los datos, el trabajo realizado en cada fila, y la espera (en almacenamiento, en un candado o en un hilo ocupado). La decodificación ya suele estar optimizada en las bibliotecas. El trabajo en cada fila es lo que tú escribes, y es donde un bucle descuidado cuesta 2–3×.
La mayor parte de este archivo trata sobre el código que consume los lotes de un escaneo. Para el lector, escritor o códec subyacente, y para obtener un número en el que puedas confiar, consulta las dos últimas secciones y sus referencias.
Primero, mide dónde se va el tiempo
Rara vez tienes una segunda implementación (Ray, DuckDB, pyarrow) para comparar. Usa el propio escaneo como referencia:
- Mide el escaneo solo. Ejecuta el mismo escaneo, con las mismas columnas, filtro
y
ScanOptions, y descarta los lotes. Esta es la línea base de solo decodificación: ningún pipeline puede ser más rápido que esto. - Mide tu trabajo por separado. Envuelve la llamada por lote (
fold,add, lo que consuma el lote) enperf_counter_ns()y suma los tiempos. - Compáralos. En un solo hilo, un tiempo total dentro de aproximadamente 1.5× del tiempo de solo decodificación significa que el trabajo es barato; por encima de eso, el trabajo es el cuello de botella y merece la pena optimizarlo. Con muchos hilos, verifica también si el trabajo se ejecuta en un solo hilo mientras la decodificación se ejecuta en muchos.
El ejemplo medido: un group-by sobre 77M viajes tomó 4.6 s en un hilo, de los cuales 2.1 s fue el trabajo por fila (45%). En 8 hilos tomó 2.6 s, de los cuales los mismos 2.1 s fueron el trabajo (82%), porque un hilo consumió cada lote. references/profiling.md tiene la receta y sus trampas.
Reglas para el bucle por fila
Cada regla viene con lo que ahorró en el benchmark de shuffle de dista (77M filas, tabla local, un hilo). El código está en references/hot-loops.md.
| Regla | Efecto medido |
|---|---|
Toma el puntero crudo de cada columna una vez por lote, luego indexa el puntero. No indexes List o Tuple[List, List] dentro del bucle. | 1.40 → 0.78 s para todo el trabajo por fila |
| Mantén todo lo que una clave acumula lado a lado en una sola ranura (array de estructuras), no en arrays paralelos, para que una actualización toque una sola línea de caché. | 2.09 → 1.40 s, junto con la siguiente regla |
Filtra sin bifurcaciones: calcula cada fila, escríbela en un índice kept, y avanza kept en Int(keep). Luego suma las filas mantenidas en un segundo bucle ajustado. | medido junto con la regla anterior |
Cuando las claves son enteros pequeños (zone × zone × hour es 1.7M), indexa un array denso por clave en lugar de usar un Dict. | no medido por separado; ambas versiones usaron un array denso |
| No cambies el orden de las sumas de punto flotante mientras optimizas. Entonces la respuesta debe coincidir con la anterior bit por bit, lo que es una verificación de corrección gratuita. | cada paso anterior coincidió exactamente |
Reglas para todo el pipeline
| Regla | Efecto medido |
|---|---|
| No dejes que un hilo consuma lo que muchos hilos decodifican. Dale a cada trabajador su propio acumulador y combínalos al final, como hacen las tareas map de dista. | 16 hilos solo llevaron un group-by de un proceso de 202 s a 126 s en AWS |
Mantén varias lecturas en vuelo, incluso con un solo hilo de decodificación: ScanOptions.files_at_once (y overlap_io) en iceberg.mojo. | 6.6–6.9× en un hilo leyendo desde S3 |
| Carga una biblioteca C compartida una vez por proceso y mantén el manejador, nunca una vez por llamada. | con zstd abierto en cada llamada, un escaneo pasó el 52% de su tiempo en dyld; solucionado, 2,687 → 262 ms |
| Almacena en caché cualquier cosa que parsees y que se repita byte por byte, como esquemas Avro en manifiestos. | el costo fijo de planificación de manifiestos cayó 3.6× (113.7 → 31.9 µs) |
| No asignes por registro: decodifica en ranuras reutilizadas. | el lector Avro asignaba alrededor de 60 veces por registro antes de su cursor |
| Conoce el techo: más allá de unos pocos núcleos, un escaneo está limitado por el ancho de banda de memoria, y más hilos no ayudarán. Lee menos bytes en su lugar. | un escaneo Iceberg tomó 35.8 ms en un núcleo y 12.1 ms en cuatro, aproximadamente 3× |
references/case-studies.md cuenta cada una de estas historias, con lo que delató el problema.
Lectores, escritores y códecs
Cuando la decodificación misma es la parte lenta, las reglas tratan principalmente de no hacer trabajo que se descarta. Los detalles y sus números están en references/readers-and-codecs.md.
- Verifica que se pueda cumplir la condición de cada ruta rápida por parte de las llamadas que la alcanzan, y demuestra mediante instrumentación que se ejecuta. Una ruta vectorizada cuya condición ninguna columna de lista podía cumplir dejó una columna de 9 KiB al 43% de una lectura.
- No calcules lo que una decisión posterior normalmente descarta. Construir un diccionario solo para elegir codificación simple fue el 67% de una escritura de columna int64; eliminarlo hizo la escritura 3.3× más rápida con una salida byte-idéntica.
- Pide prestado en lugar de copiar, y decodifica directamente en el diseño final, como los niveles de definición en el bitmap de validez.
- Trabaja en lotes donde los datos los tengan: con 1% de nulos, alrededor de 250
memmoves reemplazan alrededor de 25,000 bifurcaciones por ranura. - Reconoce cuándo una etapa ya está al ancho de banda de memoria: una que movió 18.3 MB adentro y afuera en 1.2 ms, alrededor de 30 GB/s, solo podía omitirse.
- Encuentra el elemento más lento antes de añadir hilos. Dividir por bloque de columna limitó una lectura a 1.39×, porque una columna grande se ejecutó todo el tiempo.
Prueba la optimización tanto como la respuesta: rómpela a propósito y verifica
que una prueba falla (references/testing-optimizations.md).
Las trampas propias de Mojo, como valores que mueren en su último uso y
MutUntrackedOrigin, están en references/mojo-hazards.md.
Obtener un número en el que puedas confiar
references/measurement.md tiene las reglas completas. Las que cambiaron conclusiones en magmalake:
- Reporta la mediana (p50) con el p90 a su lado, nunca solo la media. Una primera llamada lenta movió la media un 30% y la mediana nada.
- Construye el benchmark con anticipación; nunca lo cronometres bajo
mojo run. El JIT infló un benchmark 1.9×. - Verifica que la máquina se mantuvo estable. El mismo benchmark tomó 31 ms inactivo y 89 ms junto a 16 hilos ocupados.
- Nombra la API y el número de hilos de la referencia.
pq.read_tablees el escáner de datasets de pyarrow, no la ruta de lectura de parquet-cpp, y la configuración de hilos de pyarrow movió una lectura de archivo 2.9×. - Cuando dos números no coinciden, verifica que se produjeron de la misma manera antes de explicar la diferencia.
Antes de afirmar una aceleración
- Compara las respuestas antes y después, exactamente donde puedas (consulta la última regla del bucle). Una respuesta incorrecta más rápida es peor que una lenta correcta.
- Reporta ambos números y la configuración: filas, hilos, disco local o S3, y si la caché estaba caliente.
- Di lo que queda. Después de la corrección del shuffle, 325 ms de los 777 ms siguen siendo copia de columnas, y con muchos hilos el trabajo sigue estando en un solo hilo.