Nivel de log más frecuente
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 bucle | ns por elemento |
|---|---|
| recorrer la lista | 0,3 |
+ leer el campo .level | 0,7 |
| + calcular su hash | 1,7 |
+ contar con un switch en cuatro locales | 12,5 |
| + un sondeo de la tabla | 20,9 |
| + dos sondeos de la tabla | 29,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.
| N | groupListsBy | bucle a mano | FxDart | frente a groupListsBy |
frente al bucle |
|---|---|---|---|---|---|
| 10.000 | 351 µs | 291 µs | 199 µs | 1,76× más rápido | 1,46× más rápido |
| 100.000 | 3,6 ms | 2,9 ms | 2,0 ms | 1,83× más rápido | 1,47× más rápido |
| 400.000 | 18,2 ms | 11,6 ms | 7,8 ms | 2,32× más rápido | 1,48× más rápido |
| 1.000.000 | 44,5 ms | 28,8 ms | 19,4 ms | 2,30× más rápido | 1,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.
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:
| N | groupListsBy | bucle a mano | FxDart |
|---|---|---|---|
| 10.000 | 18,8 MB | 14,1 MB | 14,2 MB |
| 100.000 | 33,8 MB | 16,1 MB | 16,2 MB |
| 400.000 | 59,8 MB | 24,7 MB | 24,7 MB |
| 1.000.000 | 88,8 MB | 44,8 MB | 44,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.
Benchmark
N = 100
Tiempo Empate
Memoria pico Empate
N = 10,000
Tiempo Empate
Memoria pico Gana FxDart
N = 1,000,000
Tiempo Gana FxDart
Memoria pico Gana FxDart
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í.