diff --git a/Fixtures/Metal/SimpleLibrary/Package.swift b/Fixtures/Metal/SimpleLibrary/Package.swift new file mode 100644 index 00000000000..36228ca965d --- /dev/null +++ b/Fixtures/Metal/SimpleLibrary/Package.swift @@ -0,0 +1,19 @@ +// swift-tools-version: 6.2 +// The swift-tools-version declares the minimum version of Swift required to build this package. +import PackageDescription + +let package = Package( + name: "MyRenderer", + products: [ + .library( + name: "MyRenderer", + targets: ["MyRenderer"]), + ], + targets: [ + .target( + name: "MyRenderer", + dependencies: ["MySharedTypes"]), + + .target(name: "MySharedTypes") + ] +) diff --git a/Fixtures/Metal/SimpleLibrary/Sources/MyRenderer/Renderer.swift b/Fixtures/Metal/SimpleLibrary/Sources/MyRenderer/Renderer.swift new file mode 100644 index 00000000000..816eb50119b --- /dev/null +++ b/Fixtures/Metal/SimpleLibrary/Sources/MyRenderer/Renderer.swift @@ -0,0 +1,4 @@ +import MySharedTypes + + +let vertex = AAPLVertex(position: .init(250, -250), color: .init(1, 0, 0, 1)) diff --git a/Fixtures/Metal/SimpleLibrary/Sources/MyRenderer/Shaders.metal b/Fixtures/Metal/SimpleLibrary/Sources/MyRenderer/Shaders.metal new file mode 100644 index 00000000000..491edf63048 --- /dev/null +++ b/Fixtures/Metal/SimpleLibrary/Sources/MyRenderer/Shaders.metal @@ -0,0 +1,12 @@ +// A relative path to SharedTypes.h. +#import "../MySharedTypes/include/SharedTypes.h" + +#include +using namespace metal; + +vertex float4 simpleVertexShader(const device AAPLVertex *vertices [[buffer(0)]], + uint vertexID [[vertex_id]]) { + AAPLVertex in = vertices[vertexID]; + return float4(in.position.x, in.position.y, 0.0, 1.0); +} + diff --git a/Fixtures/Metal/SimpleLibrary/Sources/MySharedTypes/include/SharedTypes.h b/Fixtures/Metal/SimpleLibrary/Sources/MySharedTypes/include/SharedTypes.h new file mode 100644 index 00000000000..ea51fd839d4 --- /dev/null +++ b/Fixtures/Metal/SimpleLibrary/Sources/MySharedTypes/include/SharedTypes.h @@ -0,0 +1,14 @@ +#ifndef SharedTypes_h +#define SharedTypes_h + + +#import + + +typedef struct { + vector_float2 position; + vector_float4 color; +} AAPLVertex; + + +#endif /* SharedTypes_h */ diff --git a/Fixtures/Metal/SimpleLibrary/Tests/MyRendererTests/MyRendererTests.swift b/Fixtures/Metal/SimpleLibrary/Tests/MyRendererTests/MyRendererTests.swift new file mode 100644 index 00000000000..fad1d528b79 --- /dev/null +++ b/Fixtures/Metal/SimpleLibrary/Tests/MyRendererTests/MyRendererTests.swift @@ -0,0 +1,6 @@ +import Testing +@testable import MyRenderer + +@Test func example() async throws { + // Write your test here and use APIs like `#expect(...)` to check expected conditions. +} diff --git a/Package.swift b/Package.swift index 63bf3f76921..2e68dae34de 100644 --- a/Package.swift +++ b/Package.swift @@ -1017,6 +1017,13 @@ let package = Package( name: "SwiftBuildSupportTests", dependencies: ["SwiftBuildSupport", "_InternalTestSupport", "_InternalBuildTestSupport"] ), + .testTarget( + name: "BuildMetalTests", + dependencies: [ + "_InternalTestSupport", + "Basics" + ] + ), // Examples (These are built to ensure they stay up to date with the API.) .executableTarget( name: "package-info", diff --git a/Sources/PackageModel/Toolchain.swift b/Sources/PackageModel/Toolchain.swift index fb2fbce9f17..b6d5a3f478e 100644 --- a/Sources/PackageModel/Toolchain.swift +++ b/Sources/PackageModel/Toolchain.swift @@ -47,6 +47,12 @@ public protocol Toolchain { /// The manifest and library locations used by this toolchain. var swiftPMLibrariesLocation: ToolchainConfiguration.SwiftPMLibrariesLocation { get } + /// Path to the Metal toolchain if available. + var metalToolchainPath: AbsolutePath? { get } + + // Metal toolchain ID if available. + var metalToolchainId: String? { get } + /// Path of the `clang` compiler. func getClangCompiler() throws -> AbsolutePath diff --git a/Sources/PackageModel/ToolchainConfiguration.swift b/Sources/PackageModel/ToolchainConfiguration.swift index 79d49a1aa04..4d0e4e7dba5 100644 --- a/Sources/PackageModel/ToolchainConfiguration.swift +++ b/Sources/PackageModel/ToolchainConfiguration.swift @@ -46,6 +46,14 @@ public struct ToolchainConfiguration { /// Currently computed only for Windows. public var swiftTestingPath: AbsolutePath? + /// Path to the Metal toolchain. + /// This is optional and only available on Darwin platforms. + public var metalToolchainPath: AbsolutePath? + + /// Metal toolchain identifier + /// This is optional and only available on Darwin platforms. + public var metalToolchainId: String? + /// Creates the set of manifest resources associated with a `swiftc` executable. /// /// - Parameters: @@ -56,6 +64,8 @@ public struct ToolchainConfiguration { /// - swiftPMLibrariesRootPath: Custom path for SwiftPM libraries. Computed based on the compiler path by default. /// - sdkRootPath: Optional path to SDK root. /// - xctestPath: Optional path to XCTest. + /// - swiftTestingPath: Optional path to swift-testing. + /// - metalToolchainPath: Optional path to Metal toolchain. public init( librarianPath: AbsolutePath, swiftCompilerPath: AbsolutePath, @@ -64,7 +74,9 @@ public struct ToolchainConfiguration { swiftPMLibrariesLocation: SwiftPMLibrariesLocation? = nil, sdkRootPath: AbsolutePath? = nil, xctestPath: AbsolutePath? = nil, - swiftTestingPath: AbsolutePath? = nil + swiftTestingPath: AbsolutePath? = nil, + metalToolchainPath: AbsolutePath? = nil, + metalToolchainId: String? = nil ) { let swiftPMLibrariesLocation = swiftPMLibrariesLocation ?? { return .init(swiftCompilerPath: swiftCompilerPath) @@ -78,6 +90,8 @@ public struct ToolchainConfiguration { self.sdkRootPath = sdkRootPath self.xctestPath = xctestPath self.swiftTestingPath = swiftTestingPath + self.metalToolchainPath = metalToolchainPath + self.metalToolchainId = metalToolchainId } } diff --git a/Sources/PackageModel/UserToolchain.swift b/Sources/PackageModel/UserToolchain.swift index e2dc852ace7..36c27c03389 100644 --- a/Sources/PackageModel/UserToolchain.swift +++ b/Sources/PackageModel/UserToolchain.swift @@ -931,6 +931,8 @@ public final class UserToolchain: Toolchain { ) } + let metalToolchain = try? Self.deriveMetalToolchainPath(fileSystem: fileSystem, triple: triple, environment: environment) + self.configuration = .init( librarianPath: librarianPath, swiftCompilerPath: swiftCompilers.manifest, @@ -939,7 +941,9 @@ public final class UserToolchain: Toolchain { swiftPMLibrariesLocation: swiftPMLibrariesLocation, sdkRootPath: self.swiftSDK.pathsConfiguration.sdkRootPath, xctestPath: xctestPath, - swiftTestingPath: swiftTestingPath + swiftTestingPath: swiftTestingPath, + metalToolchainPath: metalToolchain?.path, + metalToolchainId: metalToolchain?.identifier ) self.fileSystem = fileSystem @@ -1071,6 +1075,55 @@ public final class UserToolchain: Toolchain { return (platform, info) } + private static func deriveMetalToolchainPath( + fileSystem: FileSystem, + triple: Basics.Triple, + environment: Environment + ) throws -> (path: AbsolutePath, identifier: String)? { + guard triple.isDarwin() else { + return nil + } + + let xcrunCmd = ["/usr/bin/xcrun", "--find", "metal"] + guard let output = try? AsyncProcess.checkNonZeroExit(arguments: xcrunCmd, environment: environment).spm_chomp() else { + return nil + } + + guard let metalPath = try? AbsolutePath(validating: output) else { + return nil + } + + guard let toolchainPath: AbsolutePath = { + var currentPath = metalPath + while currentPath != currentPath.parentDirectory { + if currentPath.basename == "Metal.xctoolchain" { + return currentPath + } + currentPath = currentPath.parentDirectory + } + return nil + }() else { + return nil + } + + let toolchainInfoPlist = toolchainPath.appending(component: "ToolchainInfo.plist") + + struct MetalToolchainInfo: Decodable { + let Identifier: String + } + + let toolchainIdentifier: String + do { + let data: Data = try fileSystem.readFileContents(toolchainInfoPlist) + let info = try PropertyListDecoder().decode(MetalToolchainInfo.self, from: data) + toolchainIdentifier = info.Identifier + } catch { + return nil + } + + return (path: toolchainPath.parentDirectory, identifier: toolchainIdentifier) + } + // TODO: We should have some general utility to find tools. private static func deriveXCTestPath( swiftSDK: SwiftSDK, @@ -1254,6 +1307,14 @@ public final class UserToolchain: Toolchain { configuration.sdkRootPath } + public var metalToolchainPath: AbsolutePath? { + configuration.metalToolchainPath + } + + public var metalToolchainId: String? { + configuration.metalToolchainId + } + public var swiftCompilerEnvironment: Environment { configuration.swiftCompilerEnvironment } diff --git a/Sources/SwiftBuildSupport/SwiftBuildSystem.swift b/Sources/SwiftBuildSupport/SwiftBuildSystem.swift index 8f98a7b031b..9e10380367d 100644 --- a/Sources/SwiftBuildSupport/SwiftBuildSystem.swift +++ b/Sources/SwiftBuildSupport/SwiftBuildSystem.swift @@ -64,14 +64,21 @@ package func withService( public func createSession( service: SWBBuildService, name: String, - toolchainPath: Basics.AbsolutePath, + toolchain: Toolchain, packageManagerResourcesDirectory: Basics.AbsolutePath? ) async throws-> (SWBBuildServiceSession, [SwiftBuildMessage.DiagnosticInfo]) { + + var buildSessionEnv: [String: String]? = nil + if let metalToolchainPath = toolchain.metalToolchainPath { + buildSessionEnv = ["EXTERNAL_TOOLCHAINS_DIR": metalToolchainPath.pathString] + } + let toolchainPath = try toolchain.toolchainDir + // SWIFT_EXEC and SWIFT_EXEC_MANIFEST may need to be overridden in debug scenarios in order to pick up Open Source toolchains let sessionResult = if toolchainPath.components.contains(where: { $0.hasSuffix(".app") }) { - await service.createSession(name: name, developerPath: nil, resourceSearchPaths: packageManagerResourcesDirectory.map { [$0.pathString] } ?? [], cachePath: nil, inferiorProductsPath: nil, environment: nil) + await service.createSession(name: name, developerPath: nil, resourceSearchPaths: packageManagerResourcesDirectory.map { [$0.pathString] } ?? [], cachePath: nil, inferiorProductsPath: nil, environment: buildSessionEnv) } else { - await service.createSession(name: name, swiftToolchainPath: toolchainPath.pathString, resourceSearchPaths: packageManagerResourcesDirectory.map { [$0.pathString] } ?? [], cachePath: nil, inferiorProductsPath: nil, environment: nil) + await service.createSession(name: name, swiftToolchainPath: toolchainPath.pathString, resourceSearchPaths: packageManagerResourcesDirectory.map { [$0.pathString] } ?? [], cachePath: nil, inferiorProductsPath: nil, environment: buildSessionEnv) } switch sessionResult { case (.success(let session), let diagnostics): @@ -84,14 +91,14 @@ public func createSession( func withSession( service: SWBBuildService, name: String, - toolchainPath: Basics.AbsolutePath, + toolchain: Toolchain, packageManagerResourcesDirectory: Basics.AbsolutePath?, body: @escaping ( _ session: SWBBuildServiceSession, _ diagnostics: [SwiftBuild.SwiftBuildMessage.DiagnosticInfo] ) async throws -> Void ) async throws { - let (session, diagnostics) = try await createSession(service: service, name: name, toolchainPath: toolchainPath, packageManagerResourcesDirectory: packageManagerResourcesDirectory) + let (session, diagnostics) = try await createSession(service: service, name: name, toolchain: toolchain, packageManagerResourcesDirectory: packageManagerResourcesDirectory) do { try await body(session, diagnostics) } catch let bodyError { @@ -546,7 +553,7 @@ public final class SwiftBuildSystem: SPMBuildCore.BuildSystem { var serializedDiagnosticPathsByTargetName: [String: [Basics.AbsolutePath]] = [:] do { - try await withSession(service: service, name: self.buildParameters.pifManifest.pathString, toolchainPath: self.buildParameters.toolchain.toolchainDir, packageManagerResourcesDirectory: self.packageManagerResourcesDirectory) { session, _ in + try await withSession(service: service, name: self.buildParameters.pifManifest.pathString, toolchain: self.buildParameters.toolchain, packageManagerResourcesDirectory: self.packageManagerResourcesDirectory) { session, _ in self.outputStream.send("Building for \(self.buildParameters.configuration == .debug ? "debugging" : "production")...\n") // Load the workspace, and set the system information to the default @@ -881,9 +888,8 @@ public final class SwiftBuildSystem: SPMBuildCore.BuildSystem { if setToolchainSetting { // If the SwiftPM toolchain corresponds to a toolchain registered with the lower level build system, add it to the toolchain stack. // Otherwise, apply overrides for each component of the SwiftPM toolchain. - if let toolchainID = try await session.lookupToolchain(at: buildParameters.toolchain.toolchainDir.pathString) { - settings["TOOLCHAINS"] = "\(toolchainID.rawValue) $(inherited)" - } else { + let toolchainID = try await session.lookupToolchain(at: buildParameters.toolchain.toolchainDir.pathString) + if toolchainID == nil { // FIXME: This list of overrides is incomplete. // An error with determining the override should not be fatal here. settings["CC"] = try? buildParameters.toolchain.getClangCompiler().pathString @@ -891,6 +897,11 @@ public final class SwiftBuildSystem: SPMBuildCore.BuildSystem { // native build system. settings["SWIFT_EXEC"] = buildParameters.toolchain.swiftCompilerPath.pathString } + + let overrideToolchains = [buildParameters.toolchain.metalToolchainId, toolchainID?.rawValue].compactMap { $0 } + if !overrideToolchains.isEmpty { + settings["TOOLCHAINS"] = (overrideToolchains + ["$(inherited)"]).joined(separator: " ") + } } for sanitizer in buildParameters.sanitizers.sanitizers { @@ -1250,7 +1261,7 @@ public final class SwiftBuildSystem: SPMBuildCore.BuildSystem { package func createLongLivedSession(name: String) async throws -> LongLivedBuildServiceSession { let service = try await SWBBuildService(connectionMode: .inProcessStatic(swiftbuildServiceEntryPoint)) do { - let (session, diagnostics) = try await createSession(service: service, name: name, toolchainPath: buildParameters.toolchain.toolchainDir, packageManagerResourcesDirectory: packageManagerResourcesDirectory) + let (session, diagnostics) = try await createSession(service: service, name: name, toolchain: buildParameters.toolchain, packageManagerResourcesDirectory: packageManagerResourcesDirectory) let teardownHandler = { try await session.close() await service.close() diff --git a/Sources/_InternalTestSupport/MockBuildTestHelper.swift b/Sources/_InternalTestSupport/MockBuildTestHelper.swift index bcb24f05a18..ecbdf5db37d 100644 --- a/Sources/_InternalTestSupport/MockBuildTestHelper.swift +++ b/Sources/_InternalTestSupport/MockBuildTestHelper.swift @@ -20,6 +20,8 @@ import SPMBuildCore import TSCUtility public struct MockToolchain: PackageModel.Toolchain { + public let metalToolchainId: String? + public let metalToolchainPath: Basics.AbsolutePath? #if os(Windows) public let librarianPath = AbsolutePath("/fake/path/to/link.exe") #elseif canImport(Darwin) @@ -54,6 +56,8 @@ public struct MockToolchain: PackageModel.Toolchain { public init(swiftResourcesPath: AbsolutePath? = nil) { self.swiftResourcesPath = swiftResourcesPath + self.metalToolchainPath = nil + self.metalToolchainId = nil } } diff --git a/Tests/BuildMetalTests/BuildMetalTests.swift b/Tests/BuildMetalTests/BuildMetalTests.swift new file mode 100644 index 00000000000..d33481571f1 --- /dev/null +++ b/Tests/BuildMetalTests/BuildMetalTests.swift @@ -0,0 +1,71 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift open source project +// +// Copyright (c) 2025 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See http://swift.org/LICENSE.txt for license information +// See http://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +import _InternalTestSupport +import Testing +import Basics +import Foundation +#if os(macOS) +import Metal +#endif + +@Suite +struct BuildMetalTests { + +#if os(macOS) + @Test( + .disabled("Require downloadable Metal toolchain"), + .tags(.TestSize.large), + .requireHostOS(.macOS), + arguments: getBuildData(for: [.swiftbuild]) + ) + func simpleLibrary(data: BuildData) async throws { + let buildSystem = data.buildSystem + let configuration = data.config + + try await fixture(name: "Metal/SimpleLibrary") { fixturePath in + + // Build the package + let (_, _) = try await executeSwiftBuild( + fixturePath, + configuration: configuration, + buildSystem: buildSystem, + throwIfCommandFails: true + ) + + // Get the bin path + let (binPathOutput, _) = try await executeSwiftBuild( + fixturePath, + configuration: configuration, + extraArgs: ["--show-bin-path"], + buildSystem: buildSystem, + throwIfCommandFails: true + ) + + let binPath = try AbsolutePath(validating: binPathOutput.trimmingCharacters(in: .whitespacesAndNewlines)) + + // Check that default.metallib exists + let metallibPath = binPath.appending(components:["MyRenderer_MyRenderer.bundle", "Contents", "Resources", "default.metallib"]) + #expect( + localFileSystem.exists(metallibPath), + "Expected default.metallib to exist at \(metallibPath)" + ) + + // Verify we can load the metal library + let device = try #require(MTLCreateSystemDefaultDevice()) + let library = try device.makeLibrary(URL: URL(fileURLWithPath: metallibPath.pathString)) + + #expect(library.functionNames.contains("simpleVertexShader")) + } + } +#endif +}