How map stays unboxed
An elementwise combinator is one inline hop (guaranteed compile-time
inlining) to a generated …Impl, and the impl is where the element
kind gets resolved. map's impl is one big
summonFrom, a compile-time match on which Repr evidence exists for
A, with an outer branch per input kind and, nested inside, one per result kind.
When the kinds line up (the common case) the branch is a one-liner into a specialized leaf method; when they don't, it's a generic loop. The all 81 branches toggle shows the full cross-product you'll never write:
That leaf method is where the loop lives:
A genuine while loop over int[], and f is an IntToIntFn, a specialized SAM rather than a boxed
Function1 (Scala's generic lambda interface, which boxes primitives like a java.util.function
interface without the primitive specializations). The IntArr case is the fast path; a tree input falls through the case _ to the shared
traverser, the one direction-aware walk every op reuses. At a concrete
FArray[Int] call site the compiler knows A = Int, so summonFrom keeps the IntRepr branch and
deletes the other eighty. So this:
xs.map(_ + 1) where xs: FArray[Int]
resolves entirely in the compiler:
xs.map(_ + 1)summonFrom: A = Int → keep the IntRepr branch, delete the other 80mapLeafIntInt(xs, (v: Int) => v + 1): one shared int[] loop, unboxed IntToIntFnHere's the actual lowering: the whole
xs.map(_ + 1), dispatch and kind resolution included, became one line. (The dead bindings
the expansion leaves behind are elided; the raw expansion toggle shows the verbatim tree.)
Three things in that one line:
mapLeafIntInt: the resolution is in the name.summonFrompicked theInt → Intleaf at compile time and deleted the other eighty branches; the dispatch is gone before the program runs.It's a call, not the loop. The
whileloop lives insidemapLeafIntInt, which is notinline: compiled once and shared, never copied into your code. That's why the loop isn't in the line above. (Inlined at every call site it would bloat straight into the JVM's method-size cliff, the trade.fuseopts into.)(v: Int) => v + 1: your lambda, materialized once as a specializedIntToIntFnSAM and passed to that loop. TypedIntthroughout, noFunction1, noInteger; the unboxing is in the type, not a JIT bet. It is not spliced into the loop body; it's a function value the loop calls.
That last point is a consequence of how inline works here. mapImpl is inline, so the dispatch
and your lambda are resolved and lifted at the call site; mapLeafIntInt is not, so the
one heavy loop is compiled a single time and shared. The price of crossing that hand-off is that the
lambda has to become a function value, hence the SAM. But it's a specialized SAM, so nothing boxes,
and it's one allocation per map call, not per element; the JIT inlines its apply into the shared
loop at runtime. In the bytecode it's a single anonfun lifted by one invokedynamic, emitted once,
not twice.
The version that splices your lambda straight into the while loop, with no SAM at all, is .fuse,
which is why fusion is a separate gear. What the inline layer buys on
its own is narrower but still decisive. A generic map[B](f: A => B) compiles once, generically, and
leans on the JIT to inline and specialize it afresh at each call site. That holds until several
distinct lambdas make the site megamorphic, at which point the JIT gives up and the loop turns
generic and virtual-dispatched (and, for element types it can't specialize, boxing on top). FArray
never writes that method down: the specialization is in the call site from compile time, so it holds
however large the surrounding method grows.
The machinery, measured: map over a hundred thousand elements, Int beside
String. On its own, map is no rout:
Where it stops being a tie
Measured against a bare array, those two charts are a wash: a few percent shy on Int, a dead tie
on String. That's the texture of a micro-benchmark — one op over one array, where a lone
Array.map (the shared generic map Scala bolts onto raw arrays) is a call the JIT inlines fully,
and FArray's opaque wrapper plus specialized SAM are pure overhead. FArray isn't built to win those
by a percent; it's built for what happens once the benchmark stops being micro, and it doesn't take
much.
Write eight of that same map with eight distinct lambdas in one method — the profile any
nontrivial method builds up — and on Int at 100k IArray drops to a third of FArray's speed. It
isn't boxing: the allocator shows both at byte-for-byte the same eight int[] per call, because an
Int => Int lambda is specialized (apply$mcII$sp). The gap is pure dispatch. IArray funnels all
eight lambdas through its one shared Array.map, whose call site now sees eight Function1 (Scala's
lambda interface) classes — past the JIT's ≥3-type megamorphic threshold, so it can't inline the map,
and every element runs one generic loop with a virtual apply. On trivial work (_ + 1) that
overhead is the cost, so it shows as a multiple; on String, where each element already allocates,
it's a rounding error — the tell that the cost is dispatch, not allocation. (It ties through 1k, where
fixed per-call costs still dominate.)
FArray has no shared site to poison: each lambda was bound to its own specialized loop at compile
time, so eight maps are eight tight int[] loops, however many you write.
One sibling deserves a name here: mapConserve, the identity-preserving map used by compilers and
transformers (if (f(x) eq x) for every element, return this). FArray's version keeps
the reference-reuse semantics and the specialized loop, and returns the original array untouched
when nothing changed: no allocation at all on the no-op pass.