Notes for people working on the SlowInferenceChain rule itself. For how to
use the rule, see the README.
sbt rules/compile # compile the rule
sbt tests/test # run the Scalafix testkit suite (input/ fixtures)Fixtures live in input/; positive and negative cases are documented in the
README under "Current fixture coverage". Add a fixture there when you change what
the rule flags.
The rule is a heuristic: it flags call sites that structurally match the scala/scala3#18763 pattern. That is not the same as proving a given call site actually compiles slowly. When you suspect a false positive — or want to confirm a real one — measure the typer phase with the Scala 3 compiler's built-in profiler.
The key is an isolated A/B compile: the same code, same classpath, differing
only in the annotation the rule wants you to add (e.g. foo[IO](...) vs
foo(...)). Compare the typer time between the two.
-Vprofile only prints a complexity summary — not timing. For timing use
-Yprofile-enabled, which writes a CSV of per-phase wall-clock nanoseconds:
scalac -Yprofile-enabled -Yprofile-destination out.prof Foo.scala
# with scala-cli, pass compiler flags via -O:
scala-cli compile Foo.scala --server=false \
-O -Yprofile-enabled -O -Yprofile-destination -O out.profUse --server=false so each run is a fresh compile (no Bloop/BSP caching
skewing the numbers).
The typer row is the one that matters for #18763. Wall-clock nanos are in
column 11:
awk -F, '$6=="typer"{print $11}' out.prof # nanosecondsCreate two files identical except for the annotation, then:
for v in With Without; do
scala-cli compile "$v.scala" --server=false \
-O -Yprofile-enabled -O -Yprofile-destination -O "$v.prof" >/dev/null 2>&1
ns=$(awk -F, '$6=="typer"{print $11}' "$v.prof" | head -1)
echo "$v typer_ms=$(( ns / 1000000 ))"
doneRun each a few times — the first compile includes JIT warmup, so take the median, not the minimum.
- A reproducible gap (e.g. consistently ~1.5× / a few hundred ms on the typer row, identical across runs) is a real penalty — the annotation is worth adding.
- A gap within run-to-run noise means this particular site doesn't hit the
pathology, even if the rule flagged it. That happens when the flagged def's own
body already fully determines
F(so the expensive inference is paid once at the def, not re-driven at the call site) — the compiler solvesFat the call site cheaply from the surrounding context.
The effect is superlinear in the depth of the chained, omitted-annotation
inference: a single omitted site whose def leaks an unsolved F into a deep
chain of further Resource[F, X[F]] calls compounds; a single site backed by a
def that fixes F internally usually does not. When building a minimal repro to
study this, nest several levels of def stepN[F[_]: Async: ...](...): Resource[F, Handler[F, Int]] where each level's body is a for-comprehension of calls to
the level below, and vary whether those calls carry the [F]/[IO] argument.
To profile an actual project file rather than a minimal repro, inject the flags
into that module's scalacOptions and force a recompile of just that file:
touch path/to/Target.scala
sbt \
'set myproject/Compile/scalacOptions += "-Yprofile-enabled"' \
'set myproject/Compile/scalacOptions ++= Seq("-Yprofile-destination", "/tmp/target.prof")' \
'myproject/Compile/compile'
awk -F, '$6=="typer"{print $11}' /tmp/target.prof | tail -1Note the whole-file typer time can swamp a single call site's contribution, so a minimal repro is usually the clearer signal for the mechanism; the real-file measurement answers "does this file compile slowly", the repro answers "does this pattern compile slowly".
rules/— the rule implementationinput/— test fixtures consumed by Scalafix testkitoutput/— unused for this linter, kept for the standard testkit layouttests/— the Scalafix test suite