Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .github/workflows/foundation.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ permissions:
contents: read
jobs:
apple-native:
runs-on: macos-15
runs-on: macos-26
env:
DEVELOPER_DIR: /Applications/Xcode_26.6.app
strategy:
fail-fast: false
matrix:
Expand All @@ -21,6 +23,8 @@ jobs:
env:
EXPECTED_SWIFT: ${{ matrix.swift }}
run: |
xcodebuild -version
xcrun --sdk macosx --show-sdk-version
swift --version
swift --version | grep -F "version $EXPECTED_SWIFT "
- name: Build with strict concurrency
Expand Down
6 changes: 4 additions & 2 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import PackageDescription

let package = Package(
name: "SAGA",
platforms: [.macOS(.v14)],
platforms: [.macOS(.v14), .iOS(.v18), .tvOS(.v18)],
products: [
.executable(name: "SAGAMetalDiagnostic", targets: ["SAGAMetalDiagnostic"]),
.executable(name: "SAGAMetalWindowDiagnostic", targets: ["SAGAMetalWindowDiagnostic"]),
Expand All @@ -13,13 +13,14 @@ let package = Package(
.library(name: "SAGACompiler", targets: ["SAGACompiler"]),
.library(name: "SAGAMetal", targets: ["SAGAMetal"]),
.library(name: "SAGAWebGPU", targets: ["SAGAWebGPU"]),
.library(name: "SAGAWebHost", targets: ["SAGAWebHost"]),
.library(name: "SAGAVulkan", targets: ["SAGAVulkan"]),
.library(name: "SAGAD3D12", targets: ["SAGAD3D12"]),
],
targets: [
.executableTarget(name: "SAGAMetalDiagnostic", dependencies: ["SAGATypes", "SAGACore", "SAGAMetal"]),
.executableTarget(name: "SAGAMetalWindowDiagnostic", dependencies: ["SAGATypes", "SAGACore", "SAGACompiler", "SAGAShaderIR", "SAGAMetal"]),
.executableTarget(name: "SAGAWebDiagnostic", dependencies: ["SAGATypes", "SAGABackend", "SAGACore", "SAGAShaderIR", "SAGACompiler", "SAGAWebGPU"]),
.executableTarget(name: "SAGAWebDiagnostic", dependencies: ["SAGATypes", "SAGABackend", "SAGACore", "SAGAShaderIR", "SAGACompiler", "SAGAWebGPU", "SAGAWebHost"]),
.executableTarget(name: "SAGABenchmark", dependencies: ["SAGATypes", "SAGACore", "SAGACompiler", "SAGAVulkan"],
linkerSettings: [.linkedLibrary("winmm", .when(platforms: [.windows]))]),
.target(name: "SAGATypes"),
Expand All @@ -34,6 +35,7 @@ let package = Package(
.target(name: "SAGACompiler", dependencies: ["SAGAShaderIR", "SAGAValidation", "SAGAFrontends", "SAGABackends", "SAGATypes"]),
.target(name: "SAGAMetal", dependencies: ["SAGATypes", "SAGABackend", "SAGAShaderIR"]),
.target(name: "SAGAWebGPU", dependencies: ["SAGATypes", "SAGABackend", "SAGAShaderIR"]),
.target(name: "SAGAWebHost", dependencies: ["SAGATypes", "SAGABackend", "SAGAShaderIR", "SAGAWebGPU"]),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Register the new host module in the runtime policy

Adding SAGAWebHost without updating docs/runtime-policy.json breaks the mandatory dependency/module audit: running python3 -B docs/tools/verify_docs.py reports target graph, dependency edge, product, and forbidden-import failures. The new JavaScript files also fail the project-file allowlist in docs/tools/runtime_policy.py. The apple-native job in .github/workflows/foundation.yml runs this verification, so its policy-check step cannot pass with this change. Update the reviewed graph, products, imports, and file allowlist alongside the new module.

Useful? React with 👍 / 👎.

.target(name: "SAGAVulkan", dependencies: ["SAGATypes", "SAGABackend", "SAGAShaderIR", "SAGACompiler"]),
.target(name: "SAGAD3D12", dependencies: ["SAGATypes", "SAGABackend", "SAGAShaderIR", "SAGACompiler"]),
.testTarget(name: "SAGAFoundationTests", dependencies: ["SAGA", "SAGATypes", "SAGACore", "SAGABackend", "SAGAShaderIR", "SAGAValidation", "SAGAFrontends", "SAGABackends", "SAGACompiler", "SAGAMetal", "SAGAWebGPU", "SAGAVulkan", "SAGAD3D12"]),
Expand Down
10 changes: 10 additions & 0 deletions Sources/SAGABackend/PresentationBackendDevice.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,14 @@ public protocol PresentationBackendDevice: RenderBackendDevice {

/// Submit the surface copy in queue order and hold the source through GPU completion.
func present(_ submission: Submission, source: ResourceID) async throws

/// Surface zero is the default surface. Backends with dynamic surfaces assign nonzero IDs.
func present(_ submission: Submission, source: ResourceID, surface: UInt32) async throws
}

extension PresentationBackendDevice {
public func present(_ submission: Submission, source: ResourceID, surface: UInt32) async throws {
guard surface == 0 else { throw GPUError.unsupported("Backend does not have the requested surface") }
try await present(submission, source: source)
}
}
177 changes: 117 additions & 60 deletions Sources/SAGABackends/ShaderBodyWriter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4146,17 +4146,119 @@ struct ShaderBodyWriter {
throw unsupported("access outside a supported vector or render structure")
}

private enum ValueStep {
case visit(IRHandle<IRExpression>, parenthesized: Bool)
case leave
case construct(IRHandle<IRType>, count: Int)
case splat(IRHandle<IRType>, width: Int)
case suffix(String)
case cast(IRHandle<IRExpression>, IRScalar, IRHandle<IRType>)
case member(IRHandle<IRExpression>, IRHandle<IRType>, String)
case binary(IRBinaryOperator, IRHandle<IRExpression>, IRHandle<IRExpression>, IRHandle<IRType>, Bool)
}

// Large shader expression trees can overflow a render worker's Swift stack
// even within the shader depth budget. Walk common expression branches on
// the heap, keeping depth and source accounting in the original order.
private mutating func value(
_ handle: IRHandle<IRExpression>, parenthesized: Bool = true
) throws -> String {
try budget.consume(.work)
try budget.enterDepth()
defer { try? budget.leaveDepth() }
if continuingCaptures[handle] != nil {
continuingCaptures[handle] = true
return "saga_loop_value_\(handle.index)"
var steps: [ValueStep] = [.visit(handle, parenthesized: parenthesized)]
var values: [String] = []
var depth = 0
defer {
for _ in 0..<depth { try? budget.leaveDepth() }
}

while let step = steps.popLast() {
switch step {
case .visit(let node, let wrap):
try budget.consume(.work)
try budget.enterDepth()
depth += 1
steps.append(.leave)
if continuingCaptures[node] != nil {
continuingCaptures[node] = true
values.append("saga_loop_value_\(node.index)")
continue
}
let expression = try function.expressions.value(for: node)
switch expression.kind {
case .compose(let type, let components):
try budget.consume(.sourceBytes, amount: components.count + 1)
steps.append(.construct(type, count: components.count))
for child in components.reversed() { steps.append(.visit(child, parenthesized: true)) }
case .splat(let size, let operand):
steps.append(.splat(expression.type, width: Int(size.rawValue)))
steps.append(.visit(operand, parenthesized: true))
case .swizzle(let size, let vector, let pattern):
let suffix = [pattern.x, pattern.y, pattern.z, pattern.w]
.prefix(Int(size.rawValue)).map(\.rawValue).joined()
steps.append(.suffix("." + suffix))
steps.append(.visit(vector, parenthesized: true))
case .accessIndex(let base, let index):
let source = try function.expressions.value(for: base)
if case .functionArgument(let argument) = source.kind, let slot = Int(exactly: argument),
let fields = argumentFields[slot], let field = Int(exactly: index), fields.indices.contains(field) {
values.append(fields[field])
continue
}
steps.append(.member(node, expression.type, try member(index, of: source.type)))
steps.append(.visit(base, parenthesized: true))
case .cast(let operand, let target) where !temporaries.contains(node):
steps.append(.cast(operand, target, expression.type))
steps.append(.visit(operand, parenthesized: true))
case .binary(let op, let left, let right) where !temporaries.contains(node):
try prepareBinaryValue(op, left: left, right: right)
steps.append(.binary(op, left, right, expression.type, wrap))
steps.append(.visit(right, parenthesized: true))
steps.append(.visit(left, parenthesized: true))
default:
values.append(try valueLeaf(node, expression: expression, parenthesized: wrap))
}
case .leave:
try budget.leaveDepth()
depth -= 1
case .construct(let type, let count):
guard values.count >= count else { throw ShaderFailure.invalidHandle }
let components = Array(values.suffix(count))
values.removeLast(count)
values.append(try construct(type, components: components))
case .splat(let type, let width):
guard let lane = values.popLast() else { throw ShaderFailure.invalidHandle }
if outputFormat == .hlsl {
values.append("(\(lane)).\(String(repeating: "x", count: width))")
} else {
values.append(try construct(type, components: [lane]))
}
case .suffix(let suffix):
guard let base = values.popLast() else { throw ShaderFailure.invalidHandle }
values.append(base + suffix)
case .member(let node, let type, let member):
guard let base = values.popLast() else { throw ShaderFailure.invalidHandle }
let reference = base + member
if try packedVectorMember(node) {
values.append("\(try typeName(type))(\(reference))")
} else {
values.append(reference)
}
case .cast(let operand, let target, let resultType):
guard let source = values.popLast() else { throw ShaderFailure.invalidHandle }
values.append(try formatCast(operand, source: source, target: target, resultType: resultType))
case .binary(let op, let left, let right, let resultType, let wrap):
guard let rhs = values.popLast(), let lhs = values.popLast() else { throw ShaderFailure.invalidHandle }
values.append(try formatBinaryValue(op, left: left, right: right, lhs: lhs, rhs: rhs,
resultType: resultType, parenthesized: wrap))
}
}
let expression = try function.expressions.value(for: handle)

guard values.count == 1, let result = values.first else { throw ShaderFailure.invalidHandle }
return result
}

private mutating func valueLeaf(
_ handle: IRHandle<IRExpression>, expression: IRExpression, parenthesized: Bool
) throws -> String {
switch expression.kind {
case .cooperativeLoad where temporaries.contains(handle),
.cooperativeMultiplyAdd where temporaries.contains(handle):
Expand Down Expand Up @@ -4208,49 +4310,20 @@ struct ShaderBodyWriter {
if !arguments[index].isEmpty { return arguments[index] }
guard let fields = argumentFields[index] else { throw ShaderFailure.invalidHandle }
return try construct(expression.type, components: fields)
case .compose(let type, let components):
// Charge before expansion so an expression shared by several constructors stays bounded.
try budget.consume(.sourceBytes, amount: components.count + 1)
let values = try components.map { try value($0) }
return try construct(type, components: values)
case .splat(let size, let operand):
let lane = try value(operand)
if outputFormat == .hlsl {
return "(\(lane)).\(String(repeating: "x", count: Int(size.rawValue)))"
}
return try construct(expression.type, components: [lane])
case .swizzle(let size, let vector, let pattern):
let suffix = [pattern.x, pattern.y, pattern.z, pattern.w]
.prefix(Int(size.rawValue)).map(\.rawValue).joined()
return try value(vector) + "." + suffix
case .accessIndex(let base, let index):
let source = try function.expressions.value(for: base)
if case .functionArgument(let argument) = source.kind, let slot = Int(exactly: argument),
let fields = argumentFields[slot], let field = Int(exactly: index), fields.indices.contains(field) {
return fields[field]
}
let field = try member(index, of: source.type)
let reference = try value(base) + field
if try packedVectorMember(handle) { return "\(try typeName(expression.type))(\(reference))" }
return reference
case .relational(let reduction, let operand) where reduction.isBooleanReduction:
let operandType = try function.expressions.value(for: operand).type
let argument = try value(operand)
if case .scalar(.bool) = try module.types.value(for: operandType).kind { return argument }
return "\(reduction.rawValue)(\(argument))"
case .bitcast(let operand, let target): return try bitcast(operand, to: target)
case .cast(let operand, let target):
return try castValue(operand, target: target, resultType: expression.type)
case .unary(let op, let argument):
return try unaryValue(op, argument: argument, resultType: expression.type, parenthesized: parenthesized)
case .binary(let op, let left, let right):
return try binaryValue(op, left: left, right: right, resultType: expression.type, parenthesized: parenthesized)
default: throw unsupported("expression")
}
}

private mutating func castValue(
_ operand: IRHandle<IRExpression>, target: IRScalar, resultType: IRHandle<IRType>
private func formatCast(
_ operand: IRHandle<IRExpression>, source: String, target: IRScalar, resultType: IRHandle<IRType>
) throws -> String {
let operandType = try function.expressions.value(for: operand).type
let input: IRScalar
Expand All @@ -4263,20 +4336,18 @@ struct ShaderBodyWriter {
input = scalar
width = nil
case .matrix(_, _, let scalar) where [.f16, .f32, .f64].contains(scalar) && [.f16, .f32, .f64].contains(target):
return try numericCast(typeName(resultType), value(operand))
return try numericCast(typeName(resultType), source)
default: throw unsupported("cast operand type")
}
if input == .bool || target == .bool {
let source = try value(operand)
let type = try typeName(resultType)
return booleanCast(type, source)
}
if input == .f64 || target == .f64 {
guard target.kind == .float else { throw unsupported("f64 to integer conversion") }
return try numericCast(typeName(resultType), value(operand))
return try numericCast(typeName(resultType), source)
}
if input == .i16 || input == .u16 || target == .i16 || target == .u16 {
let source = try value(operand)
let type = try typeName(resultType)
if outputFormat == .wgsl { return numericCast(type, source) }
if target == .i16 || target == .u16 {
Expand All @@ -4288,7 +4359,6 @@ struct ShaderBodyWriter {
return numericCast(type, source)
}
if input.width == 8 || target.width == 8 {
let source = try value(operand)
let type = try typeName(resultType)
if outputFormat == .wgsl { return numericCast(type, source) }
if input.kind == .float {
Expand All @@ -4301,20 +4371,19 @@ struct ShaderBodyWriter {
}
if [.f32, .f16].contains(input), [.i32, .u32].contains(target) {
// WGSL conversions already clamp to the destination range and round toward zero.
if outputFormat == .wgsl { return try numericCast(typeName(resultType), value(operand)) }
return try floatToIntegerCast(value(operand), input: input, target: target,
if outputFormat == .wgsl { return try numericCast(typeName(resultType), source) }
return try floatToIntegerCast(source, input: input, target: target,
width: width, resultType: typeName(resultType))
}
if target == .f16, [.f32, .i32, .u32].contains(input) {
return try numericCast(typeName(resultType), value(operand))
return try numericCast(typeName(resultType), source)
}
if input == .f16, target == .f32 {
return try numericCast(typeName(resultType), value(operand))
return try numericCast(typeName(resultType), source)
}
guard [.i32, .u32].contains(input), [.i32, .u32, .f32].contains(target) else {
throw unsupported("cast outside the supported numeric profile")
}
let source = try value(operand)
if target == .f32 { return "\(try typeName(resultType))(\(source))" }
return input == target ? source : integerBits(source, signed: target == .i32, width: width, in: outputFormat)
}
Expand Down Expand Up @@ -4377,18 +4446,6 @@ struct ShaderBodyWriter {
return parenthesized ? "(\(unary))" : unary
}

private mutating func binaryValue(
_ op: IRBinaryOperator, left: IRHandle<IRExpression>, right: IRHandle<IRExpression>,
resultType: IRHandle<IRType>, parenthesized: Bool
) throws -> String {
// Validate and charge before expanding either child, as in the original traversal.
try prepareBinaryValue(op, left: left, right: right)
let lhs = try value(left)
let rhs = try value(right)
return try formatBinaryValue(op, left: left, right: right, lhs: lhs, rhs: rhs,
resultType: resultType, parenthesized: parenthesized)
}

private mutating func prepareBinaryValue(
_ op: IRBinaryOperator, left: IRHandle<IRExpression>, right: IRHandle<IRExpression>
) throws {
Expand Down
7 changes: 6 additions & 1 deletion Sources/SAGACore/Device.swift
Original file line number Diff line number Diff line change
Expand Up @@ -755,6 +755,11 @@ public actor Device {

/// Copy an offscreen color texture to the attached surface in submission order.
public func present(_ source: ResourceID) throws -> SubmissionTicket {
try present(source, surface: 0)
}

/// Copy an offscreen color texture to the selected surface in submission order.
public func present(_ source: ResourceID, surface: UInt32) throws -> SubmissionTicket {
let presentation: any PresentationBackendDevice
do {
try Task.checkCancellation()
Expand All @@ -775,7 +780,7 @@ public actor Device {
let error = normalize(error); record(error); throw error
}
let (sequence, completion) = try enqueue([source]) { submission in
try await presentation.present(submission, source: source)
try await presentation.present(submission, source: source, surface: surface)
}
return SubmissionTicket(sequence: sequence, completion: completion)
}
Expand Down
Loading
Loading