Most frequent log level
Requirement
Given a slice of application logs, count how many entries each level (INFO / WARN / ERROR) has and print the most frequent one with its count. The data is in the code below; both versions must print the line shown under Expected output.
Expected output
Most frequent level: WARN (4 of 9)
Side by side
Native Dart
FxDart
Why they differ
Native Dart has no countBy: the closest is
package:collection's groupListsBy, which builds
a list of every entry per level just so you can take the
lengths — or a hand-written Map.update loop. Picking the
winner then needs a reduce with an explicit comparison.
FxDart names both steps: countBy goes straight to the
counts (it's terminal — it returns a plain Map), and
fx(counts.entries).maxBy(...) re-enters the chain to pick
the largest entry. Two named ideas instead of two hand-built ones.
Where the time actually goes
Counting is almost pure hash-map work, so this case is really a measurement of how many times each element touches the map. Broken down by cost per element at N=1,000,000:
| what the loop does | ns per element |
|---|---|
| walk the list | 0.3 |
+ load the .level field | 0.7 |
| + hash it | 1.7 |
+ count with a switch into four locals | 12.5 |
| + one map probe | 20.9 |
| + two map probes | 29.3 |
Traversal and the key extractor are free — under 1 ns between them. The
map is everything. And the obvious hand-written line,
counts[k] = (counts[k] ?? 0) + 1, probes the map
twice: once to read, once to write back. That second probe is
about 30% of the runtime, and it is the reason a named operator can beat
the loop you would have written. Since 0.8.4 countBy counts
into a mutable cell parked in the map, so the read hands back the cell and
the increment goes through that reference — the map is written once per
distinct level instead of once per entry.
Why the benchmark crosses over
Here is the same case swept across four scales, with the third
implementation the section above mentions but does not chart — a
hand-written counting loop, which is what you would write if you were not
reaching for package:collection at all.
| N | groupListsBy | hand loop | FxDart | vs groupListsBy | vs hand loop |
|---|---|---|---|---|---|
| 10,000 | 351 µs | 291 µs | 199 µs | 1.76× faster | 1.46× faster |
| 100,000 | 3.6 ms | 2.9 ms | 2.0 ms | 1.83× faster | 1.47× faster |
| 400,000 | 18.2 ms | 11.6 ms | 7.8 ms | 2.32× faster | 1.48× faster |
| 1,000,000 | 44.5 ms | 28.8 ms | 19.4 ms | 2.30× faster | 1.48× faster |
Read the last column first, because it is the one that does not move: against a hand-written loop FxDart is ~1.47× faster at every scale, from ten thousand entries to a million. That constant is the single map probe from the section above — the operator can afford a trick that is too fiddly to be worth hand-writing, and it pays the same dividend at every N.
countBy did the same two probes as the loop plus the
chain's overhead, and the honest number here was ~1.4× slower at
every scale. Only the FxDart column moved: re-measured on the same
machine, groupListsBy and the hand loop land within 2% of
their old figures.
The groupListsBy column widens on top of that, and the
memory column is where that shows:
| N | groupListsBy | hand loop | 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 and the hand loop hold the same
memory — within 0.1 MB at every scale — because both keep
four counters and nothing else.
groupListsBy materialises every one of the million
entries into per-level Lists just to take their lengths,
and by N=1,000,000 that is 44 MB of garbage it has to allocate and the
collector has to walk.
That tax is also what makes it erratic. Across 25 samples at
N=1,000,000, groupListsBy ranged 38.8–50.1 ms — an 11 ms
spread — while FxDart ranged 19.1–20.3 ms and the hand loop
27.9–30.4 ms. Its slow samples are collections the other two never
trigger. So its gap is part pipeline and part garbage; the other two
columns are just pipeline.
The bar chart above still reads tie at N=10,000 even though FxDart is comfortably ahead there, because 365 µs against 191 µs is 174 µs — real, but under the harness's 0.6 ms floor. Nobody perceives 174 µs, so the badge refuses to claim a win.
The fair summary, then: countBy gives you a hand
loop's memory profile and beats a hand loop's time by ~1.47×, with a
named operator's readability. It is a rare case where the
library version is simply the better choice on every axis — and the
reason is not clever compilation, it is that the operator only has to be
written carefully once.
Benchmark
N = 100
Time Tie
Peak memory Tie
N = 10,000
Time Tie
Peak memory FxDart wins
N = 1,000,000
Time FxDart wins
Peak memory FxDart wins
Bars are medians of repeated timed iterations in fresh processes per side (small N is batched for timer resolution). Sides within 5% of each other — or within 0.6 ms, a difference no person can perceive — count as a tie; close relative races are re-measured up to 5 runs. In an app, anything under a few milliseconds is invisible to the user regardless of which bar is shorter. Memory is peak process RSS. The Dart VM and the dataset are identical on both sides, so the difference between the two bars is what the pipeline itself holds onto.