Nivel de log más frecuente

Gana FxDart

Requisito

Dado un fragmento de los logs de una aplicación, cuenta cuántas entradas tiene cada nivel (INFO / WARN / ERROR) e imprime el más frecuente junto con su recuento. Los datos están en el código de abajo; ambas versiones deben imprimir la línea que aparece bajo Salida esperada.

Salida esperada
Most frequent level: WARN (4 of 9)

Lado a lado

Dart nativo

FxDart

Por qué difieren

Dart nativo no tiene countBy: lo más parecido es el groupListsBy de package:collection, que construye una lista con todas las entradas de cada nivel solo para que puedas quedarte con sus longitudes — o un bucle con Map.update escrito a mano. Elegir después al ganador requiere un reduce con una comparación explícita. FxDart pone nombre a ambos pasos: countBy va directo a los recuentos (es terminal — devuelve un Map corriente), y fx(counts.entries).maxBy(...) vuelve a entrar en la cadena para elegir la entrada más grande. Dos ideas con nombre en lugar de dos construidas a mano.

Adónde se va el tiempo en realidad

Contar es casi puro trabajo de tabla hash, así que este caso mide en realidad cuántas veces toca la tabla cada elemento. Desglosado por coste por elemento con N=1.000.000:

lo que hace el buclens por elemento
recorrer la lista0,3
+ leer el campo .level0,7
+ calcular su hash1,7
+ contar con un switch en cuatro locales12,5
+ un sondeo de la tabla20,9
+ dos sondeos de la tabla29,3

El recorrido y el extractor de clave son gratis: menos de 1 ns entre los dos. La tabla lo es todo. Y la línea obvia escrita a mano, counts[k] = (counts[k] ?? 0) + 1, sondea la tabla dos veces: una para leer y otra para volver a escribir. Ese segundo sondeo es cerca del 30% del tiempo de ejecución, y es la razón por la que un operador con nombre puede ganarle al bucle que habrías escrito. Desde 0.8.4 countBy cuenta en una celda mutable alojada en la tabla, así que la lectura devuelve la celda y el incremento va por esa referencia: la tabla se escribe una vez por nivel distinto en lugar de una vez por entrada.

Por qué el benchmark se invierte

Aquí está el mismo caso recorrido en cuatro escalas, con la tercera implementación que el párrafo anterior menciona pero no grafica: un bucle de conteo escrito a mano, que es lo que escribirías si no estuvieras recurriendo a package:collection.

NgroupListsBybucle a mano FxDartfrente a groupListsBy frente al bucle
10.000351 µs291 µs199 µs 1,76× más rápido1,46× más rápido
100.0003,6 ms2,9 ms2,0 ms 1,83× más rápido1,47× más rápido
400.00018,2 ms11,6 ms7,8 ms 2,32× más rápido1,48× más rápido
1.000.00044,5 ms28,8 ms19,4 ms 2,30× más rápido1,48× más rápido

Lee primero la última columna, porque es la que no se mueve: frente a un bucle escrito a mano FxDart es ~1,47× más rápido en todas las escalas, de diez mil entradas a un millón. Esa constante es el único sondeo de la sección anterior: el operador puede permitirse un truco demasiado engorroso para escribirlo a mano, y rinde lo mismo con cualquier N.

Esta página decía lo contrario. Antes de 0.8.4, countBy hacía los mismos dos sondeos que el bucle más el coste de la cadena, y el número honesto aquí era ~1,4× más lento en todas las escalas. Solo se movió la columna de FxDart: al volver a medir en la misma máquina, groupListsBy y el bucle a mano quedan a menos del 2% de sus cifras anteriores.

La columna de groupListsBy abre la brecha todavía más por encima de eso, y la columna de memoria es donde eso se ve:

NgroupListsBybucle a manoFxDart
10.00018,8 MB14,1 MB14,2 MB
100.00033,8 MB16,1 MB16,2 MB
400.00059,8 MB24,7 MB24,7 MB
1.000.00088,8 MB44,8 MB44,8 MB

countBy y el bucle a mano ocupan la misma memoria — con menos de 0,1 MB de diferencia en cada escala — porque ambos guardan cuatro contadores y nada más. groupListsBy materializa cada una del millón de entradas en Lists por nivel solo para tomar sus longitudes, y con N=1.000.000 eso son 44 MB de basura que hay que reservar y que el recolector debe recorrer.

Ese impuesto es además lo que lo vuelve errático. A lo largo de 25 muestras con N=1.000.000, groupListsBy osciló entre 38,8 y 50,1 ms — 11 ms de dispersión — mientras que FxDart se movió entre 19,1 y 20,3 ms y el bucle a mano entre 27,9 y 30,4 ms. Sus muestras lentas son recolecciones que los otros dos nunca provocan. Así que su brecha es en parte tubería y en parte basura; las otras dos columnas son solo tubería.

La barra de arriba sigue marcando empate con N=10.000 aunque FxDart va holgadamente por delante, porque 365 µs frente a 191 µs son 174 µs: reales, pero por debajo del umbral de 0,6 ms del banco de pruebas. Nadie percibe 174 µs, así que la insignia se niega a reclamar la victoria.

El resumen justo, entonces: countBy te da el perfil de memoria de un bucle a mano y le gana en tiempo por ~1,47×, con la legibilidad de un operador con nombre. Es uno de esos casos raros en que la versión de la biblioteca es sencillamente la mejor opción en todos los ejes, y la razón no es una compilación ingeniosa: es que el operador solo hay que escribirlo con cuidado una vez.

Método: medido en la máquina indicada en la sección Benchmark — 5 rondas intercaladas × 5 iteraciones medidas = 25 muestras por implementación y escala, compilado AOT, un proceso nuevo por muestra, medianas reportadas. Las tres implementaciones devuelven un checksum idéntico en todas las escalas.

Benchmark

Apple M1 Max, 32 GB de RAM · Dart 3.12.2 (compilado AOT) · 2026-08-24

N = 100

Tiempo Empate

Dart nativo 3.9 µs
FxDart 2.1 µs

Memoria pico Empate

Dart nativo 16.5 MB
FxDart 16.5 MB

N = 10,000

Tiempo Empate

Dart nativo 353 µs
FxDart 193 µs

Memoria pico Gana FxDart

Dart nativo 20.0 MB
FxDart 14.2 MB

N = 1,000,000

Tiempo Gana FxDart

Dart nativo 44.8 ms
FxDart 19.7 ms

Memoria pico Gana FxDart

Dart nativo 85.7 MB
FxDart 44.9 MB

Las barras son medianas de iteraciones cronometradas repetidas en procesos nuevos por lado (los N pequeños se agrupan por resolución del temporizador). Dos lados a menos del 5% entre sí — o a menos de 0.6 ms, una diferencia que nadie puede percibir — cuentan como empate; las carreras relativas ajustadas se vuelven a medir hasta 5 veces. En una app, cualquier cosa por debajo de unos pocos milisegundos es invisible para el usuario, gane la barra que gane. La memoria es el RSS pico del proceso. La VM de Dart y el dataset son idénticos en ambos lados, así que la diferencia entre las dos barras es lo que retiene el pipeline en sí.