From fd6d67ac60573a75d128c819a8bbeb9c20d07a52 Mon Sep 17 00:00:00 2001 From: Artem Chikin Date: Mon, 19 May 2025 14:46:42 -0700 Subject: [PATCH] [Incremental Builds] Separately check whether we can skip 'emit-module' on an incremental module-only build Resolves rdar://151626629 --- .../FirstWaveComputer.swift | 46 +++++++++++-- .../ExplicitModuleBuildTests.swift | 4 ++ .../IncrementalCompilationTests.swift | 67 +++++++++++++++++++ 3 files changed, 110 insertions(+), 7 deletions(-) diff --git a/Sources/SwiftDriver/IncrementalCompilation/FirstWaveComputer.swift b/Sources/SwiftDriver/IncrementalCompilation/FirstWaveComputer.swift index 770cde663..5ec66658f 100644 --- a/Sources/SwiftDriver/IncrementalCompilation/FirstWaveComputer.swift +++ b/Sources/SwiftDriver/IncrementalCompilation/FirstWaveComputer.swift @@ -134,11 +134,23 @@ extension IncrementalCompilationState.FirstWaveComputer { jobCreatingPch: jobCreatingPch) // In the case where there are no compilation jobs to run on this build (no source-files were changed), - // we can skip running `beforeCompiles` jobs if we also ensure that none of the `afterCompiles` jobs - // have any dependencies on them. - let skipAllJobs = batchedCompilationJobs.isEmpty ? !nonVerifyAfterCompileJobsDependOnBeforeCompileJobs() : false - let beforeCompileJobs = skipAllJobs ? [] : jobsInPhases.beforeCompiles - var skippedNonCompileJobs = skipAllJobs ? jobsInPhases.beforeCompiles : [] + // and the emit-module task does not need to be re-run, we can skip running `beforeCompiles` jobs if we + // also ensure that none of the `afterCompiles` jobs have any dependencies on them. + let skippingAllCompileJobs = batchedCompilationJobs.isEmpty + let skipEmitModuleJobs = try skippingAllCompileJobs && computeCanSkipEmitModuleTasks(buildRecord) + let skipAllJobs = skippingAllCompileJobs && skipEmitModuleJobs && !nonVerifyAfterCompileJobsDependOnBeforeCompileJobs() + + let beforeCompileJobs: [Job] + var skippedNonCompileJobs: [Job] = [] + if skipAllJobs { + beforeCompileJobs = [] + skippedNonCompileJobs = jobsInPhases.beforeCompiles + } else if skipEmitModuleJobs { + beforeCompileJobs = jobsInPhases.beforeCompiles.filter { $0.kind != .emitModule } + skippedNonCompileJobs.append(contentsOf: jobsInPhases.beforeCompiles.filter { $0.kind == .emitModule }) + } else { + beforeCompileJobs = jobsInPhases.beforeCompiles + } // Schedule emitModule job together with verify module interface job. let afterCompileJobs = jobsInPhases.afterCompiles.compactMap { job -> Job? in @@ -170,6 +182,27 @@ extension IncrementalCompilationState.FirstWaveComputer { } } + /// Figure out if the emit-module tasks are *not* mandatory. This functionality only runs if there are not actual + /// compilation tasks to be run in this build, for example on an emit-module-only build. + private func computeCanSkipEmitModuleTasks(_ buildRecord: BuildRecord) throws -> Bool { + guard let emitModuleJob = jobsInPhases.beforeCompiles.first(where: { $0.kind == .emitModule }) else { + return false // Nothing to skip, so no special handling is required + } + // If a non-emit-module task exists in 'beforeCompiles', it may be another kind of + // changed dependency so we should re-run the module task as well + guard jobsInPhases.beforeCompiles.allSatisfy({ $0.kind == .emitModule }) else { + return false + } + // If any of the outputs do not exist, they must be re-computed + guard try emitModuleJob.outputs.allSatisfy({ try fileSystem.exists($0.file) }) else { + return false + } + + // Ensure that no output is older than any of the inputs + let oldestOutputModTime: TimePoint = try emitModuleJob.outputs.map { try fileSystem.lastModificationTime(for: $0.file) }.min() ?? .distantPast + return try emitModuleJob.inputs.swiftSourceFiles.allSatisfy({ try fileSystem.lastModificationTime(for: $0.typedFile.file) < oldestOutputModTime }) + } + /// Figure out which compilation inputs are *not* mandatory at the start private func computeInitiallySkippedCompilationInputs( inputsInvalidatedByExternals: TransitivelyInvalidatedSwiftSourceFileSet, @@ -178,7 +211,7 @@ extension IncrementalCompilationState.FirstWaveComputer { ) -> Set { let allCompileJobs = jobsInPhases.compileJobs // Input == source file - let changedInputs = computeChangedInputs(moduleDependencyGraph, buildRecord) + let changedInputs = computeChangedInputs(buildRecord) if let reporter = reporter { for input in inputsInvalidatedByExternals { @@ -274,7 +307,6 @@ extension IncrementalCompilationState.FirstWaveComputer { // Find the inputs that have changed since last compilation, or were marked as needed a build private func computeChangedInputs( - _ moduleDependencyGraph: ModuleDependencyGraph, _ outOfDateBuildRecord: BuildRecord ) -> [ChangedInput] { jobsInPhases.compileJobs.compactMap { job in diff --git a/Tests/SwiftDriverTests/ExplicitModuleBuildTests.swift b/Tests/SwiftDriverTests/ExplicitModuleBuildTests.swift index b52b3ab00..490be0023 100644 --- a/Tests/SwiftDriverTests/ExplicitModuleBuildTests.swift +++ b/Tests/SwiftDriverTests/ExplicitModuleBuildTests.swift @@ -869,6 +869,10 @@ final class ExplicitModuleBuildTests: XCTestCase { } """ ) + // After writing out the inputs, ensure we do not immediately produce an + // output, as unfortunately on some platforms the time interval precision + // of filesystem update checks is rather coarse for this test + Thread.sleep(forTimeInterval: 1) let outputFileMap = path.appending(component: "output-file-map.json") try localFileSystem.writeFileContents(outputFileMap, bytes: ByteString(encodingAsUTF8: """ { diff --git a/Tests/SwiftDriverTests/IncrementalCompilationTests.swift b/Tests/SwiftDriverTests/IncrementalCompilationTests.swift index b9beff87b..4c73b4a0b 100644 --- a/Tests/SwiftDriverTests/IncrementalCompilationTests.swift +++ b/Tests/SwiftDriverTests/IncrementalCompilationTests.swift @@ -645,6 +645,55 @@ extension IncrementalCompilationTests { linking } } + + func testExplicitIncrementalEmitModuleOnly() throws { + guard let sdkArgumentsForTesting = try Driver.sdkArgumentsForTesting() + else { + throw XCTSkip("Cannot perform this test on this host") + } + + let args = [ + "swiftc", + "-module-name", module, + "-emit-module", "-emit-module-path", + derivedDataPath.appending(component: module + ".swiftmodule").pathString, + "-incremental", + "-driver-show-incremental", + "-driver-show-job-lifecycle", + "-save-temps", + "-output-file-map", OFM.pathString, + "-no-color-diagnostics" + ] + inputPathsAndContents.map {$0.0.pathString}.sorted() + explicitBuildArgs + sdkArgumentsForTesting + + // Initial build + _ = try doABuildWithoutExpectations(arguments: args) + + // Subsequent build, ensure module does not get re-emitted since inputs have not changed + _ = try doABuild( + whenAutolinking: autolinkLifecycleExpectedDiags, + arguments: args + ) { + readGraph + explicitIncrementalScanReuseCache(serializedDepScanCachePath.pathString) + explicitIncrementalScanCacheSerialized(serializedDepScanCachePath.pathString) + queuingInitial("main", "other") + } + + touch("main") + touch("other") + // Subsequent build, ensure module re-emitted since inputs changed + _ = try doABuild( + whenAutolinking: autolinkLifecycleExpectedDiags, + arguments: args + ) { + readGraph + explicitIncrementalScanReuseCache(serializedDepScanCachePath.pathString) + explicitIncrementalScanCacheSerialized(serializedDepScanCachePath.pathString) + queuingInitial("main", "other") + emittingModule(module) + schedulingPostCompileJobs + } + } } extension IncrementalCompilationTests { @@ -1770,6 +1819,14 @@ extension IncrementalCompilationTests { } } + private func doABuild( + whenAutolinking autolinkExpectedDiags: [Diagnostic.Message], + arguments: [String], + @DiagsBuilder expecting expectedDiags: () -> [Diagnostic.Message] + ) throws -> Driver { + try doABuild(whenAutolinking: autolinkExpectedDiags, expecting: expectedDiags(), arguments: arguments) + } + private func doABuildWithoutExpectations(arguments: [String]) throws -> Driver { // If not checking, print out the diagnostics let diagnosticEngine = DiagnosticsEngine(handlers: [ @@ -1859,6 +1916,16 @@ extension DiagVerifiable { @DiagsBuilder func explicitDependencyModuleOlderThanInput(_ dependencyModuleName: String) -> [Diagnostic.Message] { "Dependency module \(dependencyModuleName) is older than input file" } + @DiagsBuilder func startEmitModule(_ moduleName: String) -> [Diagnostic.Message] { + "Starting Emitting module for \(moduleName)" + } + @DiagsBuilder func finishEmitModule(_ moduleName: String) -> [Diagnostic.Message] { + "Finished Emitting module for \(moduleName)" + } + @DiagsBuilder func emittingModule(_ moduleName: String) -> [Diagnostic.Message] { + startEmitModule(moduleName) + finishEmitModule(moduleName) + } @DiagsBuilder func startCompilingExplicitClangDependency(_ dependencyModuleName: String) -> [Diagnostic.Message] { "Starting Compiling Clang module \(dependencyModuleName)" }