feat(asm): Phase E — multi-output asm returns tuples

Replaces the N>1 "Phase E" bail with a shared asmResultType helper (lowering +
inferType) that derives the result type from the out_value operands: 0→void,
1→T, N→a named tuple (fields named via the §II.5 effective-name rule).

Key realization: toLLVMType(tuple) already produces a literal struct {T1,…,Tn} —
exactly what LLVM's multi-output inline asm returns — so emit needs NO change.
Building the op with a tuple result type makes the asm call return the struct,
which IS sx's tuple value (destructured by the normal tuple_get path).

inferType's .asm_expr arm now also delegates to asmResultType (single owner), so
`return asm`, `x := asm`, and `q, r := asm` all agree on the type.

Verified end-to-end on aarch64: split(0x1234)→(lo=52,hi=18), a udiv/msub
divmod→(3,2). IR: `call { i64, i64 } asm "divq ${4}",
"={rax},={rdx},{rax},{rdx},r,~{cc}"(…)` → extractvalue → tuple.

1640 → the x86_64 multi-output IR lock (ir-only); 1647 → a multi-output example
that runs on aarch64.

zig build test green (655 corpus, 446 unit).
This commit is contained in:
agra
2026-06-15 21:55:38 +03:00
parent 5a5e04c6d5
commit d3c6ffed5a
15 changed files with 178 additions and 84 deletions

View File

@@ -398,22 +398,11 @@ pub const ExprTyper = struct {
}
break :blk self.l.inferExprType(nc.rhs);
},
// Inline asm result type from the `out_value` operands: 0 → void,
// 1 → that operand's type. N>1 (tuple) is Phase E → `.unresolved`
// here (lowering bails on it anyway). Mirrors `lowerAsmExpr`, so a
// bare `x := asm {…-> T}` binding types correctly.
.asm_expr => |ae| blk: {
var n_out: usize = 0;
var first_out: ?*Node = null;
for (ae.operands) |op| {
if (op.role != .out_value) continue;
n_out += 1;
if (first_out == null) first_out = op.payload;
}
if (n_out == 0) break :blk .void;
if (n_out == 1) break :blk self.l.resolveTypeWithBindings(first_out.?);
break :blk .unresolved;
},
// Inline asm result type (0→void, 1→T, N→named tuple) — the single
// owner is `Lowering.asmResultType`, shared with `lowerAsmExpr` so a
// `return asm`, a `x := asm`, and a `q, r := asm` destructure all
// agree on the type.
.asm_expr => |ae| self.l.asmResultType(&ae),
// Statements don't produce values (`.return_stmt` is handled above
// as `.noreturn` — it diverges rather than yielding `void`).
.assignment, .var_decl, .const_decl, .fn_decl,

View File

@@ -1934,6 +1934,7 @@ pub const Lowering = struct {
pub const resolveOptionalInner = lower_expr.resolveOptionalInner;
pub const lowerExpr = lower_expr.lowerExpr;
pub const lowerAsmExpr = lower_expr.lowerAsmExpr;
pub const asmResultType = lower_expr.asmResultType;
pub const refCapturePointee = lower_expr.refCapturePointee;
pub const lowerBinaryOp = lower_expr.lowerBinaryOp;
pub const lowerTupleOp = lower_expr.lowerTupleOp;

View File

@@ -2205,6 +2205,36 @@ fn pinnedRegister(constraint: []const u8) ?[]const u8 {
return null;
}
/// The asm expression's result type from its `out_value` operands (design
/// §II.5): 0 → `void`; 1 → that operand's type; N → a tuple `(T1,…,Tn)`, named
/// by each operand's effective name (explicit `[name]` else the `{reg}` pin;
/// `.empty` for an anonymous field). Returns `.unresolved` if any output type is
/// unresolvable (the resolver already diagnosed). Shared by `lowerAsmExpr` and
/// `ExprTyper.inferType` so a `return asm`, a `:=` binding, and a `q, r := asm`
/// destructure all agree on the type.
pub fn asmResultType(self: *Lowering, ae: *const ast.AsmExpr) TypeId {
var fields = std.ArrayList(TypeId).empty;
defer fields.deinit(self.alloc);
var names = std.ArrayList(types.StringId).empty;
defer names.deinit(self.alloc);
var has_names = false;
for (ae.operands) |op| {
if (op.role != .out_value) continue;
const fty = self.resolveTypeWithBindings(op.payload);
if (fty == .unresolved) return .unresolved;
fields.append(self.alloc, fty) catch unreachable;
const eff = op.name orelse (pinnedRegister(op.constraint) orelse "");
if (eff.len != 0) has_names = true;
names.append(self.alloc, if (eff.len == 0) types.StringId.empty else self.module.types.internString(eff)) catch unreachable;
}
if (fields.items.len == 0) return .void;
if (fields.items.len == 1) return fields.items[0];
return self.module.types.intern(.{ .tuple = .{
.fields = self.alloc.dupe(TypeId, fields.items) catch unreachable,
.names = if (has_names) self.alloc.dupe(types.StringId, names.items) catch unreachable else null,
} });
}
/// Inline assembly lowering. Phase B (partial): validate the asm shape in the
/// compile path with specific named diagnostics, THEN bail on the not-yet-
/// implemented codegen so the user sees the real problem first (the IR op +
@@ -2297,26 +2327,10 @@ pub fn lowerAsmExpr(self: *Lowering, ae: *const ast.AsmExpr, span: ast.Span) Ref
}
}
// ── Build the IR op (C.1). D emits 0 or 1 value output; N>1 (tuple result)
// is Phase E — bail loudly until then. ──
var n_value_outputs: usize = 0;
for (ae.operands) |op| {
if (op.role == .out_value) n_value_outputs += 1;
}
if (n_value_outputs > 1) {
diags.addFmt(.err, span, "multi-output (tuple-returning) inline assembly is not yet implemented (ASM stream Phase E)", .{});
return self.emitPlaceholder("inline_asm");
}
// Result type: 0 outputs → void; 1 → that operand's resolved type. (The
// resolver diagnoses an unresolvable type and returns `.unresolved`.)
var result_ty: TypeId = .void;
for (ae.operands) |op| {
if (op.role == .out_value) {
result_ty = self.resolveTypeWithBindings(op.payload);
break;
}
}
// ── Build the IR op. Result type from the out_value operands (0→void,
// 1→T, N→named tuple). N outputs → LLVM returns a struct {T1,…,Tn}, which
// is exactly sx's tuple representation, so emit needs no special case. ──
const result_ty = self.asmResultType(ae);
if (result_ty == .unresolved) return self.emitPlaceholder("inline_asm");
// IR operands, in source order (= `%N` index space + LLVM operand order).