Skip to content

Commit 62dfdaf

Browse files
committed
Fix overcompilation when inheriting inline defs
When the body of an inline def changes, all its callers need to be recompiled. So when producing an API signature for an inline def to be sent to Zinc, we need to somehow include that body. So far, this was done by simply including the `toString` of the tree in the API signature, but the API of a class includes its inherited members, and when calling `toString` on an inherited inline def, its body ends up being printed as just `TreeUnpickler$LazyReader@123...` which is clearly not what we want, in practice this lead to overcompilation rather than undercompilation because the string includes the identityHashCode of the LazyReader which changes at each compilation run. This commit fixes this by implementing a proper way to hash trees, this is still not perfect since types aren't included in the hash (see comments in the code), but that can be addressed at a later point (and the exact strategy we want to use also depends on how we evolve Zinc).
1 parent bdf634c commit 62dfdaf

File tree

10 files changed

+123
-16
lines changed

10 files changed

+123
-16
lines changed

compiler/src/dotty/tools/dotc/sbt/ExtractAPI.scala

Lines changed: 72 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
package dotty.tools.dotc
22
package sbt
33

4-
import ast.{Trees, tpd}
4+
import ast.{Positioned, Trees, tpd, untpd}
55
import core._
66
import core.Decorators._
77
import Annotations._
@@ -11,6 +11,7 @@ import Phases._
1111
import Trees._
1212
import Types._
1313
import Symbols._
14+
import Names._
1415
import NameOps._
1516
import NameKinds.DefaultGetterName
1617
import typer.Inliner
@@ -582,13 +583,18 @@ private class ExtractAPICollector(using Context) extends ThunkHolder {
582583
val annots = new mutable.ListBuffer[api.Annotation]
583584
val inlineBody = Inliner.bodyToInline(s)
584585
if (!inlineBody.isEmpty) {
585-
// FIXME: If the body of an inlineable method changes, all the reverse
586-
// dependencies of this method need to be recompiled. sbt has no way
587-
// of tracking method bodies, so as a hack we include the printed
588-
// tree of the method as part of the signature we send to sbt.
589-
// To do this properly we would need a way to hash trees and types in
590-
// dotty itself.
591-
annots += marker(inlineBody.toString)
586+
// If the body of an inline def changes, all the reverse dependencies of
587+
// this method need to be recompiled. sbt has no way of tracking method
588+
// bodies, so we include the hash of the body of the method as part of the
589+
// signature we send to sbt.
590+
//
591+
// FIXME: The API of a class we send to Zinc includes the signatures of
592+
// inherited methods, which means that we repeatedly compute the hash of
593+
// an inline def in every class that extends its owner. To avoid this we
594+
// could store the hash as an annotation when pickling an inline def
595+
// and retrieve it here instead of computing it on the fly.
596+
val inlineBodyHash = treeHash(inlineBody)
597+
annots += marker(inlineBodyHash.toString)
592598
}
593599

594600
// In the Scala2 ExtractAPI phase we only extract annotations that extend
@@ -602,16 +608,66 @@ private class ExtractAPICollector(using Context) extends ThunkHolder {
602608
annots.toList
603609
}
604610

611+
/** Produce a hash for a tree that is as stable as possible:
612+
* it should stay the same across compiler runs, compiler instances,
613+
* JVMs, etc.
614+
*/
615+
def treeHash(tree: Tree): Int =
616+
import scala.util.hashing.MurmurHash3
617+
618+
def positionedHash(p: ast.Positioned, initHash: Int): Int =
619+
p match
620+
case p: WithLazyField[?] =>
621+
p.forceIfLazy
622+
case _ =>
623+
// FIXME: If `p` is a tree we should probably take its type into account
624+
// when hashing it, but producing a stable hash for a type is not trivial
625+
// since the same type might have multiple representations, for method
626+
// signatures this is already handled by `computeType` and the machinery
627+
// in Zinc that generates hashes from that, if we can reliably produce
628+
// stable hashes for types ourselves then we could bypass all that and
629+
// send Zinc hashes directly.
630+
val h = MurmurHash3.mix(initHash, p.productPrefix.hashCode)
631+
iteratorHash(p.productIterator, h)
632+
end positionedHash
633+
634+
def iteratorHash(it: Iterator[Any], initHash: Int): Int =
635+
var h = initHash
636+
while it.hasNext do
637+
it.next() match
638+
case p: Positioned =>
639+
h = positionedHash(p, h)
640+
case xs: List[?] =>
641+
h = iteratorHash(xs.iterator, h)
642+
case c: core.Constants.Constant =>
643+
h = MurmurHash3.mix(h, c.tag)
644+
h = MurmurHash3.mix(h, c.value.##) // We can't use `value.hashCode` since value might be null
645+
case n: Name =>
646+
// The hashCode of the name itself is not stable across compiler instances
647+
h = MurmurHash3.mix(h, n.toString.hashCode)
648+
case elem =>
649+
report.warning(
650+
i"""Internal error: Don't know how to produce a stable hash for `$elem` of unknown class ${elem.getClass}
651+
|Incremental compilation might not work correctly.""", tree.sourcePos)
652+
h = MurmurHash3.mix(h, elem.toString.hashCode)
653+
h
654+
end iteratorHash
655+
656+
val seed = 4 // https://xkcd.com/221
657+
val h = positionedHash(tree, seed)
658+
MurmurHash3.finalizeHash(h, 0)
659+
end treeHash
660+
605661
def apiAnnotation(annot: Annotation): api.Annotation = {
606-
// FIXME: To faithfully extract an API we should extract the annotation tree,
607-
// sbt instead wants us to extract the annotation type and its arguments,
608-
// to do this properly we would need a way to hash trees and types in dotty itself,
609-
// instead we use the raw string representation of the annotation tree.
610-
// However, we still need to extract the annotation type in the way sbt expect
611-
// because sbt uses this information to find tests to run (for example
612-
// junit tests are annotated @org.junit.Test).
662+
// Like with inline defs, the whole body of the annotation and not just its
663+
// type is part of its API so we need to store its hash, but Zinc wants us
664+
// to extract the annotation type and its arguments, so we use a dummy
665+
// annotation argument to store the hash of the tree. We still need to
666+
// extract the annotation type in the way Zinc expects because sbt uses this
667+
// information to find tests to run (for example junit tests are
668+
// annotated @org.junit.Test).
613669
api.Annotation.of(
614670
apiType(annot.tree.tpe), // Used by sbt to find tests to run
615-
Array(api.AnnotationArgument.of("FULLTREE", annot.tree.toString)))
671+
Array(api.AnnotationArgument.of("TREE_HASH", treeHash(annot.tree).toString)))
616672
}
617673
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
class A {
2+
inline def getInline: String = {
3+
class Local {
4+
private val y: Int = 1
5+
}
6+
println(new Local)
7+
val x = 1
8+
x.toString
9+
}
10+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
class B extends A
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
class C {
2+
val b = new B
3+
b.getInline
4+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import complete.DefaultParsers._
2+
3+
val checkIterations = inputKey[Unit]("Verifies the accumlated number of iterations of incremental compilation.")
4+
5+
checkIterations := {
6+
val analysis = (compile in Compile).value.asInstanceOf[sbt.internal.inc.Analysis]
7+
8+
val expected: Int = (Space ~> NatBasic).parsed
9+
val actual: Int = analysis.compilations.allCompilations.size
10+
assert(expected == actual, s"Expected $expected compilations, got $actual")
11+
}
12+
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
class B extends A {
2+
val unrelatedChange: Int = 1
3+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
logLevel := Level.Debug
2+
incOptions in ThisBuild ~= { _.withApiDebug(true) }
3+
incOptions in ThisBuild ~= { _.withRelationsDebug(true) }
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import sbt._
2+
import Keys._
3+
4+
object DottyInjectedPlugin extends AutoPlugin {
5+
override def requires = plugins.JvmPlugin
6+
override def trigger = allRequirements
7+
8+
override val projectSettings = Seq(
9+
scalaVersion := sys.props("plugin.scalaVersion")
10+
)
11+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
addSbtPlugin("ch.epfl.lamp" % "sbt-dotty" % sys.props("plugin.version"))
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
> compile
2+
# Force recompilation of B, B.getInline hasn't changed so C shouldn't be recompiled.
3+
$ copy-file changes/B1.scala B.scala
4+
> compile
5+
# One iteration for each call to `compile`
6+
> checkIterations 2

0 commit comments

Comments
 (0)