Skip to content

Commit 9ea5ff0

Browse files
committed
add support for pipeline build of scala files
- add '-Ypickle-java' and '-Ypickle-write' flags expected by Zinc - move ExtractAPI phase to after pickler, this way we can do it in parallel with generating TASTy bytes. At the end of this phase we write the TASTy to the early-output destination. - test the pipelining with sbt scripted tests, including for inline methods and macros with pipelining - describe semantics with respect to suspensions, introduce -Yno-suspended-units flag for greater control by the user.
1 parent 96bc0e6 commit 9ea5ff0

File tree

52 files changed

+700
-16
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

52 files changed

+700
-16
lines changed

compiler/src/dotty/tools/dotc/CompilationUnit.scala

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -88,12 +88,15 @@ class CompilationUnit protected (val source: SourceFile) {
8888
// when this unit is unsuspended.
8989
depRecorder.clear()
9090
if !suspended then
91-
if (ctx.settings.XprintSuspension.value)
92-
report.echo(i"suspended: $this")
93-
suspended = true
94-
ctx.run.nn.suspendedUnits += this
95-
if ctx.phase == Phases.inliningPhase then
96-
suspendedAtInliningPhase = true
91+
if ctx.settings.YnoSuspendedUnits.value then
92+
report.error(i"Compilation unit suspended $this (-Yno-suspended-units is set)")
93+
else
94+
if (ctx.settings.XprintSuspension.value)
95+
report.echo(i"suspended: $this")
96+
suspended = true
97+
ctx.run.nn.suspendedUnits += this
98+
if ctx.phase == Phases.inliningPhase then
99+
suspendedAtInliningPhase = true
97100
throw CompilationUnit.SuspendException()
98101

99102
private var myAssignmentSpans: Map[Int, List[Span]] | Null = null

compiler/src/dotty/tools/dotc/Compiler.scala

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,13 +41,13 @@ class Compiler {
4141
List(new semanticdb.ExtractSemanticDB.ExtractSemanticInfo) :: // Extract info into .semanticdb files
4242
List(new PostTyper) :: // Additional checks and cleanups after type checking
4343
List(new sjs.PrepJSInterop) :: // Additional checks and transformations for Scala.js (Scala.js only)
44-
List(new sbt.ExtractAPI) :: // Sends a representation of the API of classes to sbt via callbacks
4544
List(new SetRootTree) :: // Set the `rootTreeOrProvider` on class symbols
4645
Nil
4746

4847
/** Phases dealing with TASTY tree pickling and unpickling */
4948
protected def picklerPhases: List[List[Phase]] =
5049
List(new Pickler) :: // Generate TASTY info
50+
List(new sbt.ExtractAPI) :: // Sends a representation of the API of classes to sbt via callbacks
5151
List(new Inlining) :: // Inline and execute macros
5252
List(new PostInlining) :: // Add mirror support for inlined code
5353
List(new CheckUnused.PostInlining) :: // Check for unused elements

compiler/src/dotty/tools/dotc/config/ScalaSettings.scala

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import Setting.ChoiceWithHelp
1414
import scala.util.chaining.*
1515

1616
import java.util.zip.Deflater
17+
import dotty.tools.io.NoAbstractFile
1718

1819
class ScalaSettings extends SettingGroup with AllScalaSettings
1920

@@ -373,6 +374,7 @@ private sealed trait YSettings:
373374
val YprintPos: Setting[Boolean] = BooleanSetting("-Yprint-pos", "Show tree positions.")
374375
val YprintPosSyms: Setting[Boolean] = BooleanSetting("-Yprint-pos-syms", "Show symbol definitions positions.")
375376
val YnoDeepSubtypes: Setting[Boolean] = BooleanSetting("-Yno-deep-subtypes", "Throw an exception on deep subtyping call stacks.")
377+
val YnoSuspendedUnits: Setting[Boolean] = BooleanSetting("-Yno-suspended-units", "Do not suspend units, e.g. when calling a macro defined in the same run. This will error instead of suspending.")
376378
val YnoPatmatOpt: Setting[Boolean] = BooleanSetting("-Yno-patmat-opt", "Disable all pattern matching optimizations.")
377379
val YplainPrinter: Setting[Boolean] = BooleanSetting("-Yplain-printer", "Pretty-print using a plain printer.")
378380
val YprintSyms: Setting[Boolean] = BooleanSetting("-Yprint-syms", "When printing trees print info in symbols instead of corresponding info in trees.")
@@ -432,4 +434,8 @@ private sealed trait YSettings:
432434
val YforceInlineWhileTyping: Setting[Boolean] = BooleanSetting("-Yforce-inline-while-typing", "Make non-transparent inline methods inline when typing. Emulates the old inlining behavior of 3.0.0-M3.")
433435

434436
val YdebugMacros: Setting[Boolean] = BooleanSetting("-Ydebug-macros", "Show debug info when quote pattern match fails")
437+
438+
// Pipeline compilation options
439+
val YpickleJava: Setting[Boolean] = BooleanSetting("-Ypickle-java", "Pickler phase should compute pickles for .java defined symbols for use by build tools")
440+
val YpickleWrite: Setting[AbstractFile] = OutputSetting("-Ypickle-write", "directory|jar", "destination for generated .sig files containing type signatures.", NoAbstractFile, aliases = List("-Yearly-tasty"))
435441
end YSettings

compiler/src/dotty/tools/dotc/config/Settings.scala

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -308,8 +308,8 @@ object Settings:
308308
def MultiStringSetting(name: String, helpArg: String, descr: String, default: List[String] = Nil, aliases: List[String] = Nil): Setting[List[String]] =
309309
publish(Setting(name, descr, default, helpArg, aliases = aliases))
310310

311-
def OutputSetting(name: String, helpArg: String, descr: String, default: AbstractFile): Setting[AbstractFile] =
312-
publish(Setting(name, descr, default, helpArg))
311+
def OutputSetting(name: String, helpArg: String, descr: String, default: AbstractFile, aliases: List[String] = Nil): Setting[AbstractFile] =
312+
publish(Setting(name, descr, default, helpArg, aliases = aliases))
313313

314314
def PathSetting(name: String, descr: String, default: String, aliases: List[String] = Nil): Setting[String] =
315315
publish(Setting(name, descr, default, aliases = aliases))

compiler/src/dotty/tools/dotc/core/SymbolLoaders.scala

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -451,7 +451,8 @@ class TastyLoader(val tastyFile: AbstractFile) extends SymbolLoader {
451451
val tastyUUID = new TastyHeaderUnpickler(TastyUnpickler.scala3CompilerConfig, tastyBytes).readHeader()
452452
new ClassfileTastyUUIDParser(classfile)(ctx).checkTastyUUID(tastyUUID)
453453
else
454-
// This will be the case in any of our tests that compile with `-Youtput-only-tasty`
454+
// This will be the case in any of our tests that compile with `-Youtput-only-tasty`, or when
455+
// tasty file compiled by `-Ypickle-write` comes from an early output jar.
455456
report.inform(s"No classfiles found for $tastyFile when checking TASTy UUID")
456457

457458
private def mayLoadTreesFromTasty(using Context): Boolean =

compiler/src/dotty/tools/dotc/inlines/Inliner.scala

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1043,6 +1043,9 @@ class Inliner(val call: tpd.Tree)(using Context):
10431043
for sym <- dependencies do
10441044
if ctx.compilationUnit.source.file == sym.associatedFile then
10451045
report.error(em"Cannot call macro $sym defined in the same source file", call.srcPos)
1046+
else if ctx.settings.YnoSuspendedUnits.value then
1047+
val addendum = ", suspension prevented by -Yno-suspended-units"
1048+
report.error(em"Cannot call macro $sym defined in the same compilation run$addendum", call.srcPos)
10461049
if (suspendable && ctx.settings.XprintSuspension.value)
10471050
report.echo(i"suspension triggered by macro call to ${sym.showLocated} in ${sym.associatedFile}", call.srcPos)
10481051
if suspendable then

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

Lines changed: 59 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,8 @@ import NameOps.*
2020
import inlines.Inlines
2121
import transform.ValueClasses
2222
import transform.SymUtils.*
23-
import dotty.tools.io.{File, FileExtension, JarArchive}
23+
import transform.Pickler
24+
import dotty.tools.io.{File, FileExtension, JarArchive, ClassfileWriterOps}
2425
import util.{Property, SourceFile}
2526
import java.io.PrintWriter
2627

@@ -63,16 +64,68 @@ class ExtractAPI extends Phase {
6364
// after `PostTyper` (unlike `ExtractDependencies`, the simplication to trees
6465
// done by `PostTyper` do not affect this phase because it only cares about
6566
// definitions, and `PostTyper` does not change definitions).
66-
override def runsAfter: Set[String] = Set(transform.PostTyper.name)
67+
override def runsAfter: Set[String] = Set(transform.Pickler.name)
6768

6869
override def runOn(units: List[CompilationUnit])(using Context): List[CompilationUnit] =
70+
val sigWriter: Option[Pickler.EarlyFileWriter] = ctx.settings.YpickleWrite.value match
71+
case earlyOut if earlyOut.isDirectory && earlyOut.exists =>
72+
Some(Pickler.EarlyFileWriter(ClassfileWriterOps(earlyOut)))
73+
case _ =>
74+
None
6975
val nonLocalClassSymbols = new mutable.HashSet[Symbol]
70-
val ctx0 = ctx.withProperty(NonLocalClassSymbolsInCurrentUnits, Some(nonLocalClassSymbols))
71-
val units0 = super.runOn(units)(using ctx0)
72-
ctx.withIncCallback(recordNonLocalClasses(nonLocalClassSymbols, _))
73-
units0
76+
val ctx0 = ctx
77+
.withProperty(NonLocalClassSymbolsInCurrentUnits, Some(nonLocalClassSymbols))
78+
inContext(ctx0) {
79+
val units0 = super.runOn(units)
80+
sigWriter.foreach(writeSigFiles(units0, _))
81+
ctx.withIncCallback(recordNonLocalClasses(nonLocalClassSymbols, _))
82+
units0
83+
}
7484
end runOn
7585

86+
// Why we only write to early output in the first run?
87+
// ===================================================
88+
// TL;DR the point of pipeline compilation is to start downstream projects early,
89+
// so we don't want to wait for suspended units to be compiled.
90+
//
91+
// But why is it safe to ignore suspended units?
92+
// If this project contains a transparent macro that is called in the same project,
93+
// the compilation unit of that call will be suspended (if the macro implementation
94+
// is also in this project), causing a second run.
95+
// However before we do that run, we will have already requested sbt to begin
96+
// early downstream compilation. This means that the suspended definitions will not
97+
// be visible in *early* downstream compilation.
98+
//
99+
// However, sbt will by default prevent downstream compilation happening in this scenario,
100+
// due to the existence of macro definitions. So we are protected from failure if user tries
101+
// to use the suspended definitions.
102+
//
103+
// Additionally, it is recommended for the user to move macro implementations to another project
104+
// if they want to force early output. In this scenario the suspensions will no longer occur, so now
105+
// they will become visible in the early-output.
106+
//
107+
// See `sbt-test/pipelining/pipelining-scala-macro` and `sbt-test/pipelining/pipelining-scala-macro-force`
108+
// for examples of this in action.
109+
//
110+
// Therefore we only need to write to early output in the first run. We also provide the option
111+
// to diagnose suspensions with the `-Yno-suspended-units` flag.
112+
private def writeSigFiles(units: List[CompilationUnit], writer: Pickler.EarlyFileWriter)(using Context): Unit = {
113+
try
114+
for
115+
unit <- units
116+
(cls, pickled) <- unit.pickled
117+
if cls.isDefinedInCurrentRun
118+
do
119+
val binaryName = cls.binaryClassName.replace('.', java.io.File.separatorChar).nn
120+
val binaryClassName = if (cls.is(Module)) binaryName.stripSuffix(str.MODULE_SUFFIX).nn else binaryName
121+
writer.writeTasty(binaryClassName, pickled())
122+
finally
123+
writer.close()
124+
if ctx.settings.verbose.value then
125+
report.echo("[sig files written]")
126+
end try
127+
}
128+
76129
private def recordNonLocalClasses(nonLocalClassSymbols: mutable.HashSet[Symbol], cb: interfaces.IncrementalCallback)(using Context): Unit =
77130
for cls <- nonLocalClassSymbols if !cls.isLocal do
78131
val sourceFile = cls.source

compiler/src/dotty/tools/dotc/transform/Pickler.scala

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ import Flags.Module
1515
import reporting.{ThrowingReporter, Profile, Message}
1616
import collection.mutable
1717
import util.concurrent.{Executor, Future}
18+
import util.Property
19+
import io.ClassfileWriterOps
1820
import compiletime.uninitialized
1921

2022
object Pickler {
@@ -26,6 +28,9 @@ object Pickler {
2628
* only in backend.
2729
*/
2830
inline val ParallelPickling = true
31+
32+
class EarlyFileWriter(writer: ClassfileWriterOps):
33+
export writer.{writeTasty, close}
2934
}
3035

3136
/** This phase pickles trees */
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
package dotty.tools.io
2+
3+
import java.io.{DataOutputStream, IOException, PrintWriter, StringWriter}
4+
import java.nio.file.Files
5+
6+
import dotty.tools.io.*
7+
import dotty.tools.dotc.core.Decorators.*
8+
import dotty.tools.dotc.core.Contexts.*
9+
import dotty.tools.dotc.report
10+
import java.nio.channels.ClosedByInterruptException
11+
import scala.language.unsafeNulls
12+
import scala.annotation.constructorOnly
13+
14+
15+
class ClassfileWriterOps(outputDir: AbstractFile)(using @constructorOnly ictx: Context) {
16+
17+
type InternalName = String
18+
19+
// if non-null, classfiles are written to a jar instead of the output directory
20+
private val jarWriter: JarWriter | Null =
21+
val localCtx = ictx
22+
outputDir match {
23+
case jar: JarArchive =>
24+
jar.underlyingSource.map { source =>
25+
if jar.isEmpty then
26+
new Jar(source.file).jarWriter()
27+
else inContext(localCtx) {
28+
// Writing to non-empty JAR might be an undefined behaviour, e.g. in case if other files where
29+
// created using `AbstractFile.bufferedOutputStream`instead of JarWriter
30+
report.warning(em"Tried to write to non-empty JAR: $source")
31+
null
32+
}
33+
}.orNull
34+
35+
case _ => null
36+
}
37+
38+
private def getFile(base: AbstractFile, clsName: String, suffix: String): AbstractFile = {
39+
if (base.file != null) {
40+
fastGetFile(base, clsName, suffix)
41+
} else {
42+
def ensureDirectory(dir: AbstractFile): AbstractFile =
43+
if (dir.isDirectory) dir
44+
else throw new FileConflictException(s"${base.path}/$clsName$suffix: ${dir.path} is not a directory", dir)
45+
var dir = base
46+
val pathParts = clsName.split("[./]").toList
47+
for (part <- pathParts.init) dir = ensureDirectory(dir) subdirectoryNamed part
48+
ensureDirectory(dir) fileNamed pathParts.last + suffix
49+
}
50+
}
51+
52+
private def fastGetFile(base: AbstractFile, clsName: String, suffix: String) = {
53+
val index = clsName.lastIndexOf('/')
54+
val (packageName, simpleName) = if (index > 0) {
55+
(clsName.substring(0, index), clsName.substring(index + 1))
56+
} else ("", clsName)
57+
val directory = base.file.toPath.resolve(packageName)
58+
new PlainFile(Path(directory.resolve(simpleName + suffix)))
59+
}
60+
61+
private def writeBytes(outFile: AbstractFile, bytes: Array[Byte]): Unit = {
62+
if (outFile.file != null) {
63+
val outPath = outFile.file.toPath
64+
try Files.write(outPath, bytes)
65+
catch {
66+
case _: java.nio.file.NoSuchFileException =>
67+
Files.createDirectories(outPath.getParent)
68+
Files.write(outPath, bytes)
69+
}
70+
} else {
71+
val out = new DataOutputStream(outFile.bufferedOutput)
72+
try out.write(bytes, 0, bytes.length)
73+
finally out.close()
74+
}
75+
}
76+
77+
def writeTasty(className: InternalName, bytes: Array[Byte]): Unit =
78+
writeToJarOrFile(className, bytes, ".tasty")
79+
80+
private def writeToJarOrFile(className: InternalName, bytes: Array[Byte], suffix: String): AbstractFile | Null = {
81+
if jarWriter == null then
82+
val outFolder = outputDir
83+
val outFile = getFile(outFolder, className, suffix)
84+
try writeBytes(outFile, bytes)
85+
catch case ex: ClosedByInterruptException =>
86+
try outFile.delete() // don't leave an empty or half-written files around after an interrupt
87+
catch case _: Throwable => ()
88+
finally throw ex
89+
outFile
90+
else
91+
val path = className + suffix
92+
val out = jarWriter.newOutputStream(path)
93+
try out.write(bytes, 0, bytes.length)
94+
finally out.flush()
95+
null
96+
}
97+
98+
def close(): Unit = {
99+
if (jarWriter != null) jarWriter.close()
100+
outputDir match
101+
case jar: JarArchive => jar.close()
102+
case _ =>
103+
}
104+
}
105+
106+
107+
/** Can't output a file due to the state of the file system. */
108+
class FileConflictException(msg: String, val file: AbstractFile) extends IOException(msg)

compiler/src/dotty/tools/io/JarArchive.scala

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import scala.jdk.CollectionConverters.*
1212
*/
1313
class JarArchive private (root: Directory) extends PlainDirectory(root) {
1414
def close(): Unit = jpath.getFileSystem().close()
15+
override def exists: Boolean = jpath.getFileSystem().isOpen() && super.exists
1516
def allFileNames(): Iterator[String] =
1617
java.nio.file.Files.walk(jpath).iterator().asScala.map(_.toString)
1718
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
package a
2+
3+
import scala.quoted.*
4+
5+
object A {
6+
inline def power(x: Double, inline n: Int): Double =
7+
inline if (n == 0) 1.0
8+
else inline if (n % 2 == 1) x * power(x, n - 1)
9+
else power(x * x, n / 2)
10+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
package b
2+
3+
import a.A
4+
5+
object B {
6+
@main def run =
7+
assert(A.power(2.0, 2) == 4.0)
8+
assert(A.power(2.0, 3) == 8.0)
9+
assert(A.power(2.0, 4) == 16.0)
10+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
// defines a inline method
2+
lazy val a = project.in(file("a"))
3+
.settings(
4+
scalacOptions ++= Seq("-Yearly-tasty", ((ThisBuild / baseDirectory).value / "a-early.jar").toString),
5+
scalacOptions += "-Ystop-after:firstTransform",
6+
scalacOptions += "-Ycheck:all",
7+
)
8+
9+
// uses the inline method, this is fine as there is no macro classloader involved
10+
lazy val b = project.in(file("b"))
11+
.settings(
12+
Compile / unmanagedClasspath += Attributed.blank((ThisBuild / baseDirectory).value / "a-early.jar"),
13+
scalacOptions += "-Ycheck:all",
14+
)
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
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+
scalacOptions += "-source:3.0-migration"
11+
)
12+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
> a/compile
2+
# uses the early output jar of a
3+
> b/run
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
package a
2+
3+
object A {
4+
val foo: (1,2,3) = (1,2,3)
5+
}

sbt-test/pipelining/early-out/b-early-out/.keep

Whitespace-only changes.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
package b
2+
3+
object B {
4+
val bar: (1,2,3) = (1,2,3)
5+
}

0 commit comments

Comments
 (0)