From d0dec97af66bccdacb39f2b7fd4519642ac0235f Mon Sep 17 00:00:00 2001 From: Ben Blackburne Date: Wed, 22 Oct 2025 15:35:31 +0100 Subject: [PATCH 1/2] Use Hashes for swiftmodule incremental compilation I missed this in my earlier work on using hashes for Swift Incremental compilation. (#1923) Without this, we can have compiles that skip all recompilation save the emission of an identical Swiftmodule. --- .../FirstWaveComputer.swift | 36 +++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) diff --git a/Sources/SwiftDriver/IncrementalCompilation/FirstWaveComputer.swift b/Sources/SwiftDriver/IncrementalCompilation/FirstWaveComputer.swift index a8a1147d4..28fa3513f 100644 --- a/Sources/SwiftDriver/IncrementalCompilation/FirstWaveComputer.swift +++ b/Sources/SwiftDriver/IncrementalCompilation/FirstWaveComputer.swift @@ -202,9 +202,41 @@ extension IncrementalCompilationState.FirstWaveComputer { return false } - // Ensure that no output is older than any of the inputs + // If all the modules are older than the outputs, we can skip 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 }) + let areModulesOlderThanOutput = try emitModuleJob.inputs.swiftSourceFiles.allSatisfy({ try fileSystem.lastModificationTime(for: $0.typedFile.file) < oldestOutputModTime }) + guard !areModulesOlderThanOutput else { + return true + } + // If we are not using hashes, we cannot skip + guard useHashes else { + return false + } + let inputs:[TypedVirtualPath] = emitModuleJob.inputs + for input:TypedVirtualPath in inputs { + guard let currentDate:FileMetadata = buildRecordInfo.compilationInputModificationDates[input] else { + reporter?.report("Missing file metadata for: \(input)") + return false + } + + guard let currentHash:String = currentDate.hash else { + reporter?.report("Missing file hash data for: \(input)") + return false + } + + let inputInfos:[VirtualPath: InputInfo] = buildRecord.inputInfos + guard let inputInfo:InputInfo = inputInfos[input.file] else { + reporter?.report("Missing incremental info for: \(input)") + return false + } + + if currentHash != inputInfo.hash { + reporter?.report("Changed hash for: \(input)") + return false + } + } + return true + } /// Figure out which compilation inputs are *not* mandatory at the start From 181dc56e93907cc4046b1b2a6625ca4c88548a85 Mon Sep 17 00:00:00 2001 From: Ben Blackburne Date: Tue, 2 Dec 2025 10:00:41 +0000 Subject: [PATCH 2/2] Test Swiftmodules are not unnecessarily rebuilt --- .../IncrementalCompilationTests.swift | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/Tests/SwiftDriverTests/IncrementalCompilationTests.swift b/Tests/SwiftDriverTests/IncrementalCompilationTests.swift index 24c4b95e3..96a609e3d 100644 --- a/Tests/SwiftDriverTests/IncrementalCompilationTests.swift +++ b/Tests/SwiftDriverTests/IncrementalCompilationTests.swift @@ -365,6 +365,33 @@ extension IncrementalCompilationTests { XCTAssertFalse(mandatoryJobInputs.contains("other.swift")) } + // Source file timestamps updated but contents are the same, with file-hashing emit-module job should be skipped + func testNullBuildNoEmitModuleWithHashing() throws { + let extraArguments = ["-experimental-emit-module-separately", "-emit-module", "-enable-incremental-file-hashing"] + try buildInitialState(extraArguments: extraArguments) + touch("main") + touch("other") + touch(try AbsolutePath(validating: explicitSwiftDependenciesPath.appending(component: "E.swiftinterface").pathString)) + let driver = try doABuildWithoutExpectations(arguments: commonArgs + extraArguments + (try XCTUnwrap(Driver.sdkArgumentsForTesting()))) + let mandatoryJobs = try XCTUnwrap(driver.incrementalCompilationState?.mandatoryJobsInOrder) + let mandatoryJobInputs = mandatoryJobs.flatMap { $0.inputs }.map { $0.file.basename } + XCTAssertFalse(mandatoryJobs.contains { $0.kind == .emitModule }, "emit-module should be skipped when using hashes and content unchanged") + XCTAssertFalse(mandatoryJobInputs.contains("main.swift")) + XCTAssertFalse(mandatoryJobInputs.contains("other.swift")) + } + + // Source file updated, emit-module job should not be skipped regardless of file-hashing + func testEmitModuleWithHashingWhenContentChanges() throws { + let extraArguments = ["-experimental-emit-module-separately", "-emit-module", "-enable-incremental-file-hashing"] + try buildInitialState(extraArguments: extraArguments) + replace(contentsOf: "main", with: "let foo = 2") + let driver = try doABuildWithoutExpectations(arguments: commonArgs + extraArguments + (try XCTUnwrap(Driver.sdkArgumentsForTesting()))) + let mandatoryJobs = try XCTUnwrap(driver.incrementalCompilationState?.mandatoryJobsInOrder) + let mandatoryJobInputs = mandatoryJobs.flatMap { $0.inputs }.map { $0.file.basename } + XCTAssertTrue(mandatoryJobs.contains { $0.kind == .emitModule }, "emit-module should run when using hashes and content has changed") + XCTAssertTrue(mandatoryJobInputs.contains("main.swift")) + } + // External deps timestamp updated but contents are the same, and file-hashing is explicitly disabled func testExplicitIncrementalBuildExternalDepsWithoutHashing() throws { replace(contentsOf: "other", with: "import E;let bar = foo")