All tags across posts, sorted
Requirement
Each blog post carries a list of tags. Build the site's tag index: flatten every post's tags into one sequence, drop duplicates, sort alphabetically, and print them as a single comma-separated line. The data is in the code below; both versions must print the line shown under Expected output.
Expected output
async, concurrency, dart, fp, iterables, recipes, streams
Side by side
Native Dart
FxDart
Why they differ
On the page, barely at all. expand is Dart's
flatMap, toSet() deduplicates, and a cascade
..sort() finishes the job — that chain is honest, idiomatic
Dart and there is nothing wrong with it. FxDart spells the same three
steps as named chain links (flatMap → uniq → sort), which
reads slightly more like the requirement and keeps the order-preserving
uniq explicit rather than a side effect of choosing a
Set. As code, this one is a tie.
The clock is not a tie. The Benchmark bars below have FxDart at 1.47× the speed of the native chain on a million posts — 73.5 ms against 108.0 ms — and the ratio holds all the way down (1.36× at N=10,000, 1.29× at N=100; those two still carry a same speed badge only because the absolute gap there is under the site's 0.6 ms perception floor). Both sides do identical work: three million tag strings pulled through a flattener, hashed into a set that keeps 500 distinct values, then a 500-element sort. Nothing about the algorithm differs.
The whole gap lives in one field of dart:core's
ExpandIterator. It seeds its inner-iterator slot with a
const EmptyIterator<Never>() sentinel so it can defer
the first callback, which means the hot line —
_currentExpansion!.moveNext(), run once per emitted tag —
sees two receiver classes over the loop's life. That is enough
to keep AOT from inlining the inner List iterator, so all
three million inner advances become indirect calls. FxDart's
flatMap holds a plain Iterator<B>? that
only ever contains the real inner iterator and uses null for
"nothing open yet", so the same call site stays monomorphic and inlines.
That is not a guess. Swapping only the flattener — FxDart's
flatMap feeding native's own toSet().toList()..sort()
— already lands at 78 ms; swapping only the other end, core's
expand into uniq and sort, stays at
108 ms. Hand-copying ExpandIterator into the benchmark and
changing nothing but that sentinel (empty-iterator seed → null)
moves it from 105 ms to 77 ms on its own; the other difference in
shape, core's nullable _current read through a cast, costs
nothing measurable. Each variant was AOT-compiled as its own binary,
because putting them in one program makes every moveNext call
site polymorphic and erases the effect being measured.
Two caveats worth keeping. This is an implementation detail of the SDK,
not a law: the day ExpandIterator drops that sentinel,
expand matches FxDart and this page goes back to a tie in
both columns. And a hand-written nested for loop that adds
straight into a Set beats both, at 43 ms — dropping the
iterator protocol entirely is still the fastest thing you can do here.
What the measurement rules out is the assumption that reaching for a
named pipeline costs you speed over the idiomatic core chain. Here it
buys some.
Benchmark
N = 100
Time Tie
Peak memory Tie
N = 10,000
Time Tie
Peak memory Native wins
N = 1,000,000
Time FxDart wins
Peak memory Tie
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.