Expands regular inline def bodies — where a macro's chosen implementation actually gets substituted in
Usually cost #2, esp. circe/Magnolia
erasure
Strips generics, preps for bytecode
Rarely dominant
genBCode
Emits JVM bytecode
Scales with code size
wartremover
Third-party lint plugin
Pure overhead on every local build
Step 01 — payoff
Sum it, rank it, and you have your priority list
The recipe
Sum run ns per module per phase. Rank modules by
typer + inlining time.
That ranking is your priority list — no guessing required.
What we found
producer-api — 16.6s typer, largest single module common-lib — 12.8s typer, 5.9s inlining, most files (672) worker-service — 9.5s typer — 86% of its own compile time
sorted-by:complexity ranks files by a lines/tokens/TASTy-size ratio —
a rough proxy for "this file produces more generated code than its source size suggests."
-Vprofile-details:N goes one level deeper: the N most complex
methods, not just files.
The one that took several iterations to get right
Step 03 — The real evidence
-Xprint:typer,inlining
zsh — monorepo/
$ ./mill common-lib.compile -Xprint:typer,inlining
[[syntax trees at end of typer]] // .../File.scala
[[syntax trees at end of inlining]] // .../File.scala
Prints the full syntax tree of every file, twice —
once per phase. This is where you see exactly what the compiler generated.
The trap
Two dumps. Two different truths.
typer dump shows
The macro's call site, plus the Mirror.SumOf[T]
argument being constructed — a type-level listing of every case.
Its size scales with case count no matter what the
macro's implementation does. Unavoidable, fast or slow.
inlining dump shows
What the macro's body actually expanded to — this is where
you can tell a cheap implementation from an expensive one.
This is the dump that reflects your fix.
How we found out the hard way
Four attempts, one wrong assumption
1Wrote a fast path. Diffed the typer dump. 0 of 164 call sites changed. Looked like total failure.
2Rewrote it two more times, same measurement, same "0 changed" result every time.
3Realized: the typer dump's size is dominated by the Mirror argument — it was never going to change, regardless of the fix.
4Re-ran with -Xprint:typer,inlining and diffed the inlining dump instead.
Once we measured the right phase
The fix was real all along
2,737 → 105
lines generated for ValidationError (24 cases)
2,945 → 116
lines generated for SyncTaskType (20 cases)
✓ Confirmed identically in both the typer and
inlining dumps — proof the new method is no longer inline at all.
Takeaway
The rule
If the method you're optimizing is inline,
you must diff the inlining phase dump — not typer — to see if your fix took effect.
If you rewrite it as a plain def,
typer and inlining will show the same (small) size — an instant, built-in
confirmation that the fix is active.
Step 04 — Before you trust timing at all
Prove it functionally first
SimpleEnum.scala
summonFrom {
case given (T <:< scala.reflect.Enum) =>
println(s"fast path: $label")
fromRealEnum(m, label)
case _ =>
println(s"slow path: $label")
deriveSimple { ... }
}
Compile just the smallest affected module — seconds, not minutes.
Confirm in the output which branch actually fired for a known type.
Remove the prints once confirmed.
Why: a silent no-op branch is easy to write —
it happened to us three times before we landed on a version that
actually worked.
Step 05 — Before you trust any single number
Establish the noise floor
+2.8% to -9.3%
observed deltas between builds of what should have been closely related code
Before trusting a before/after number:
Run the same, unchanged code 2–3 times. Record the natural variance.
Only trust a delta that clearly exceeds that noise band.
For a confident final answer, average 3 runs on each side.
Step 06 — Turning data into priorities
Prioritize using the ratio, not just the size
Pattern
What it means
What to do
inlining >> typer
Macro/derivation cost dominates
Hand-write or reflect instead of deriving
inlining ≈ typer
The file's own code is just large
Split the file — a structural fix, not a macro fix
High per-file allocation, few files
Possible wide-implicit-scope import
Needs a sampling profiler to confirm — see next slide
Limits of this method
Some costs never show up in a printed tree
One of our modules showed ~984MB of typer allocation per file —
10–100× higher than modules with far more files. The AST dumps showed
nothing unusual.
The likely cause: implicit search exploring and
discarding candidates from wide wildcard imports — work that's genuinely
transient. It allocates heavily during typechecking but leaves
no trace in the final tree, by definition.
Next tool: a sampling profiler (async-profiler / JFR) attached to the
compiler process, with stack samples grouped by package.
Recap
The one-page cheat sheet
cheatsheet.sh
# 1. cheapest — always start here$ ./mill _.compile -Vprofile
# 2. narrow down to suspect files$ ./mill <module>.compile -Vprofile -Vprofile-sorted-by:complexity -Vprofile-details:20
# 3. see exactly what got generated (inline → diff the INLINING dump)$ ./mill <module>.compile -Xprint:typer,inlining
# golden rulesinline def → diff inlining, not typer
plain def → typer size == inlining size confirms the fix
always → prove functionally (println) before trusting timing
always → measure the noise floor before trusting a delta
Takeaways
Four things to carry out of this room
1Start cheap (-Vprofile), escalate only once you have a real suspect.
2The typer dump can lie about inline macros — diff inlining, not typer.
3Prove a fix functionally (a println) before trusting a single timing number.
4Measure the noise floor first — a single before/after build is not evidence.
Thank you
Questions?
zsh — monorepo/
$ ./mill _.compile -Vprofile # try it on your module▌