Skip to content

Fix/ exponential compile time in builder chains - #2

Merged
stivens merged 9 commits into
mainfrom
improve-performance-2026-08-06
Aug 7, 2026
Merged

Fix/ exponential compile time in builder chains#2
stivens merged 9 commits into
mainfrom
improve-performance-2026-08-06

Conversation

@stivens

@stivens stivens commented Aug 6, 2026

Copy link
Copy Markdown
Owner

Motivation

A downstream project (~3,000 files) reported that files with long CaseComplete builder chains
dominated its build: ~21 s of a 130 s build was spent in the compiler's posttyper phase across
four files, versus ~2.5 s of posttyper for the other ~3,000 files combined. A 16-step chain cost
13.5 s; the same file at 10 steps had cost 0.3 s a few months earlier.

Chain length had become a compile-time cliff — every field added to a long chain roughly doubled
that file's compile time.

Root cause

usingNonEmpty was a transparent inline extension method.

A transparent inline extension binds its receiver to a parameter proxy carrying the
transparent-refined type of the entire preceding chain. Chaining those makes compilation
exponential in chain length (~1.9× per step), even though the generated tree stays linear — the cost
is in repeated traversal of the resulting nested singleton-type chain, which is why it lands in
posttyper rather than in macro expansion.

Measured per-variant (posttyper, ms), which isolates it cleanly:

chain built from N=4 N=8 N=12 N=16
ignoring (method on the class) 54 77 191 130
using (method on the class) 61 89 109 128
usingNonEmpty (extension) 68 250 1433 5184

The blowup is independent of handler body size — a chain of usingNonEmpty(_.f)(v => v) costs
the same as one with multi-line handler bodies.

A previously suspected cause that turned out to be wrong

usingImpl spliced $builder twice, which looked like it would duplicate the receiver tree per step
and give O(2ⁿ). It does not: the inliner binds the inline receiver to a CaseCompleteBuilder_this
val, so a second splice copies an Ident, not the chain. Verified three ways — the step-1 handler
marker appears exactly once in a 16-step -Xprint:posttyper dump; an ablation binding
handlers to a single val changed nothing (4033 ms → 5086 ms); and plain .using chains measured
flat from N=4 to N=20 against the pre-change library.

The fix

usingNonEmpty moves from a companion extension block onto CaseCompleteBuilder, using a match
type so the handler keeps exactly the same expected result type it had before:

type OptionPayload[T] = T match {
  case Option[payload] => payload
  case _               => Any
}

An evidence parameter (Option[P] <:< TARGET_TYPE) was rejected: it would infer the payload from
the lambda body instead of propagating it as the expected type, silently changing inference for
targets like Option[Any]. The Any fallback is deliberate — it keeps the match type reducible for
non-Option targets so the macro can report an actionable error instead of the raw
"match type reduction failed".

The existing usingNonEmpty test passes unmodified — that's the drop-in check.

Results

posttyper, ms:

N before after
8 243 72
12 1312 85
16 4033 87
20 54372 109
24 126
32 245
40 339

Linear through N=40. At 20 steps: 54 s → 0.11 s.

Two further scaling fixes found along the way

getHandledFields decoded Handled with quoted type patterns. Every chain step ran
'[head *: tail] + Type.valueOfConstant over the whole accumulated tuple to check for duplicate
fields — one type-comparer invocation per element, so quadratic over a chain. It is now a structural
TypeRepr walk. Measured on a 96-step using chain, 3 runs each, fresh compiler per run:

wall clock (min of 3)
before 6.67 s
after 4.55 s

Noise at 32 steps; ~31% at 96. The explicit "not a tuple" abort is preserved — silently returning
partial results would turn a malformed Handled into a spurious missing-handler error.

CaseCompleteImpl.eval re-sorted the handler map on every call, recomputing an ordering fixed at
construction. It now sorts once. Measured at 32 fields, 1M iterations after 200k warmup, JVM 21:

ns/op bytes/op
before 2365 3801
after 443 961

5.3× faster, 2.8 KB less garbage per call. Ordering is unchanged and now has an explicit test.

Hardening: Handled can no longer be forged

The macro plumbing (addHandler / markHandled) is private[casecomplete], and so are the
constructor and handlers. Without the latter two the guard was decorative — a user outside the
package could write new CaseCompleteBuilder[F, T, ("a", "b")](map).compile and claim fields that
have no handler, defeating the library's core guarantee. Generated code still reaches these because
quoted calls resolve at macro-definition site rather than at the splice site.

Nested selectors are rejected. The selector's receiver must now be the lambda's own parameter.
Previously using(_.a.b) accepted any Select and registered the innermost name — so it marked
the source type's field b as handled while the handler read a.b, silently defeating the
completeness check (using(_.a.b) … .ignoring(_.a).compile passed with b unhandled). Such
selectors now fail with the existing "expected a field selector" error.

Also in this change

  • usingImpl split into usingImpl / usingNonEmptyImpl / ignoringImpl, all delegating to a
    single registerField that owns the shared pipeline: selector extraction, duplicate check, and the
    emit under the extended Handled type. The old shape encoded ignoring as usingImpl with
    '{ None } plus a runtime .fold in the generated code to express a purely compile-time
    distinction; that and the per-step Some/None allocation are gone. Every Expr is spliced
    exactly once.
  • usingNonEmpty rejects target types that are strict subtypes of Option (e.g.
    Some[String]) with the same readable error. The quoted pattern '[Option[payload]] matches by
    conformance, so without the =:= guard those types passed the match and crashed the expansion
    with an ExprCastException.
  • Hand-built AppliedType / @unchecked destructuring replaced with the quoted type API.
  • Members outside the primary constructor can still be handled, as in 0.2.2 — a mid-branch
    revision briefly rejected such selectors, but registering e.g. a body val is harmless: it can
    never share a name with a constructor field, so it can never satisfy another field's completeness
    obligation. Both directions are now pinned by tests: extra members can be handled and evaluated,
    and they do not count toward completeness.
  • timeout-minutes: 15 on the CI job. LongChainSpec fails by hanging, so under GitHub's
    360-minute default a regression would burn a 6-hour job and read as flaky infra rather than as this
    test failing.

Tests

4 → 24.

  • LongChainSpec — a 32-step regression guard interleaving usingNonEmpty, using and
    ignoring (all three are equally at risk). Pre-fix this would take hours to compile; post-fix the
    file costs ~0.25 s.
  • ExternalAccessSpec — every other test lives inside io.github.stivens.casecomplete, where
    private[casecomplete] is indistinguishable from public, so the access story was untested from
    where users actually sit. This spec sits outside the package and pins both directions: a full chain
    compiles and evaluates, while markHandled and the constructor are unreachable — asserted on the
    "cannot be accessed" diagnostic, so an unrelated error mentioning the member name cannot pass.
  • Twelve compile-time tests in CaseCompleteSpec — missing field, duplicate field (both
    using-then-using and ignoring-then-using), non-selector expression, nested selector (for
    using and ignoring alike), non-Option target, strict-Option-subtype target, selector
    errors taking precedence over usingNonEmpty's target check, a body val not counting toward
    completeness, a readable diagnostic for a builder ascribed a widened type, plus a positive
    control so the negatives cannot pass vacuously. This path had zero coverage before. The negatives assert the
    error message text, not merely that compilation fails: the Any fallback in OptionPayload
    exists only to make that message readable, and could be deleted with a failure-only test still
    green.
  • One ordering test — alphabetical evaluation order across repeated eval calls, the invariant
    the sort-once change must preserve.

Compatibility

  • Version bumped 0.2.2 → 0.3.0. usingNonEmpty moved from the companion object to the class,
    usingImpl's signature changed, and the builder constructor and handlers became package-private
    — source-compatible for normal usage, but not binary/TASTy-compatible. Already-compiled call sites
    are unaffected (they are already expanded); users must recompile. The README keeps 0.2.2 until
    0.3.0 is published.
  • One error message changed. usingNonEmpty on a non-Option target previously emitted two
    copies of Match type reduction failed since selector String; it now emits
    usingNonEmpty requires the target type to be an Option, but it is String. Use ``using`` instead.
  • Nested selectors like _.a.b no longer compile (see the hardening section) — previously
    accepted, but with semantics that broke the completeness guarantee.
  • Runtime semantics unchanged: same handler map, same alphabetical eval ordering, ignoringd
    fields still contribute nothing.

stivens and others added 9 commits August 6, 2026 16:17
…en comments

- usingNonEmptyImpl now requires TARGET_TYPE =:= Option[payload]; a strict
  subtype like Some[String] previously escaped the quoted pattern's
  conformance check and crashed the expansion with an ExprCastException.
  Covered by a new compile-time test.
- getHandledFields identifies *: by symbol instead of by name string.
- ExternalAccessSpec asserts the "cannot be accessed" diagnostic, so the
  tests cannot pass on an unrelated error mentioning the member name.
- Comment pass: dropped the addHandlerCall splice-once note (contradicted
  by the PR's own analysis), stale narration in compileImpl, and the doc
  blocks that restated signatures; trimmed the survivors.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The three impls each hand-assembled extract -> duplicate-check -> emit, and
the pre-3.4 `t & Tuple` bound recovery had two emit sites. registerField now
owns the pipeline (handler absent = ignoring), absorbing newHandledType and
addHandlerCall. getHandledFields takes Handled directly instead of a Type[?]
round-trip, and its terminal EmptyTuple check compares symbols instead of
invoking the type comparer.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ameter

extractFieldName accepted any Select, so `using(_.a.b)` registered the
source type's field "b" while the handler read a.b -- marking b handled
with no real handler and defeating the completeness check. The receiver
must now be the lambda's own parameter.

Also: usingNonEmpty's non-Option error prints short type names
(String, not scala.Predef.String), its scaladoc no longer claims the
method is unavailable for non-Option targets, and the duplicate check
gains an ignoring-then-using test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- registerField splices `name *: Handled` directly; the `'[t]` re-match
  with the `t & Tuple` bound recovery was never needed since `Handled`'s
  Tuple bound is statically known, and getHandledFields loses its
  AndType-stripping case with it.
- extractFieldNameOrAbort uses `underlyingArgument` and the `Lambda`
  extractor instead of hand-rolled Inlined/Typed/Block stripping and
  DefDef dissection.
- checkNotAlreadyHandled inlined into registerField (single caller).
- CaseCompleteImpl drops pair-destructuring ceremony.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…nostics

Review fixes:
- Selectors must name a case field: `_.productArity` or a body val is a
  Select on the lambda parameter too, and previously registered a phantom
  handler that ran on every eval outside the completeness check's universe.
- usingNonEmpty's error no longer reads as self-contradicting for
  Some[String] ("must be exactly Option[...]" instead of "an Option").
- getHandledFields states the invariant its structural decoding relies on,
  and the abort distinguishes a user-widened builder type (with actionable
  advice) from a genuine internal error. The widened case is reachable by
  ascribing a chain to `CaseCompleteBuilder[..., ?]` and compiling.
- ExternalAccessSpec now pins all four package-private members
  (addHandler and handlers joined markHandled and the constructor).

Cleanups:
- caseFieldNames is the single definition of the handleable-field universe,
  consulted by both registerField's gate and compileImpl's completeness check.
- registerField takes the handler by name, so usingNonEmpty's target-type
  check runs after the shared selector/case-field/duplicate checks and all
  three entry points report selector errors with the same precedence.
- The Option-target check matches the type constructor's symbol structurally,
  replacing the quoted pattern + `=:=` guard pair.
- The 14 copies of the typeCheckErrors assertion scaffold collapsed into
  inline helpers whose failures print the actual compiler messages.
- Two new tests pin that ignoring and usingNonEmpty route through the shared
  pipeline rather than only using.

Tests 17 -> 24.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Handling a body val or parameterless method is harmless: it cannot share
a name with a constructor field, so it can never satisfy another field's
completeness obligation, and compileImpl only demands the constructor
fields. Drop registerField's case-field gate and pin the new contract:
extra members can be handled and evaluated, but do not count toward
completeness. Nested selectors stay rejected.

Also, from review:
- Fix the self-recursive MovieFilter test fixture (its object
  initializer called its own companion apply, blowing the stack on any
  runtime instantiation).
- Share one compile-error assertion helper between the specs.
- Hoist the duplicated four-step handler chain in the extra-fields
  tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@stivens
stivens merged commit 63deceb into main Aug 7, 2026
1 check passed
@stivens
stivens deleted the improve-performance-2026-08-06 branch August 7, 2026 12:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant