Compare commits

...

2 Commits

Author SHA1 Message Date
kuba f4a1da601f [SpirvTranslator] Structure loops, early exits and single-block programs
Only forward, nested branches were emitted as structured control flow; every program with a
loop, and every single-block program, went through the loop around a switch on the guest PC,
which keeps all registers live and stops the host compiler from optimizing across blocks.

A block that a later block branches back to - on a wave-uniform condition or with s_branch -
now opens a SPIR-V loop whose body is structured like any range, whose continue evaluates
that branch's condition, and whose iterations share the dispatcher's step guard so a guest
loop that never ends still cannot wedge the GPU queue. A conditional branch from the body to
the block after the latch is a break out of the innermost loop. Any other backward edge, or a
break past an outer loop, keeps the dispatcher as before.

Across eight games, programs left on the dispatcher went from 760 to 104 (Poppy, Silent Hill,
Oregon Trail and Dreaming Sarah to none); every emitted module passes spirv-val and the
frames match. Octopath's 12.5M-invocation instanced draw went from 12.2 to 10.8 ms.

(cherry picked from commit 0675cfa892)
2026-09-30 14:36:40 +02:00
kuba 164a9918a3 [SpirvTranslator] Emit forward, nested control flow as structured selections
Every program with more than one basic block ran as a loop around a switch on
the guest PC. That shape keeps every guest register live across iterations and
stops the host compiler from optimizing across blocks, so on Metal the register
files spill.

A program whose branches only skip forward over properly nested ranges -- an
s_cbranch_* skip, an if/else with an s_branch over the else range, a skip to
the end of the program -- is exactly a nest of SPIR-V selections, and is now
emitted as one. The structuring is attempted first without emitting: if any
branch goes backwards, leaves a range it entered, or targets an instruction
that is not a block boundary, nothing is emitted and the program keeps the PC
dispatcher, so no program loses a shape the dispatcher handled. Setting
SHARPEMU_STRUCTURED_CF=0 forces the dispatcher for every program.

Measured on two replayed programs, with identical output: an instanced vertex
shader 8.1 -> 4.8 ms and a per-pixel compute program 10.7 -> 6.6 ms.
2026-09-29 23:55:09 +02:00
2 changed files with 504 additions and 0 deletions

View File

@ -314,6 +314,25 @@ public static partial class Gen5SpirvTranslator
}
EmitInitialState();
if (StructuredControlFlow && blocks.Count > 0 && TryStructureRange(blocks, 0, blocks.Count, blocks.Count, emit: false, out _))
{
// As the dispatcher does before its first block: an out-of-bounds invocation runs nothing.
var runLabel = _module.AllocateId();
var doneLabel = _module.AllocateId();
var isActive = Load(_boolType, _programActive);
_module.AddStatement(SpirvOp.SelectionMerge, doneLabel, 0);
_module.AddStatement(SpirvOp.BranchConditional, isActive, runLabel, doneLabel);
_module.AddLabel(runLabel);
if (!TryStructureRange(blocks, 0, blocks.Count, blocks.Count, emit: true, out error))
{
return false;
}
_module.AddStatement(SpirvOp.Branch, doneLabel);
_module.AddLabel(doneLabel);
}
else
{
var loopHeader = _module.AllocateId();
var switchHeader = _module.AllocateId();
var switchMerge = _module.AllocateId();
@ -388,6 +407,7 @@ public static partial class Gen5SpirvTranslator
loopHeader,
loopMerge);
_module.AddLabel(loopMerge);
}
if (_stage == Gen5SpirvStage.Pixel &&
Environment.GetEnvironmentVariable(
"SHARPEMU_TRACE_TITLE_SHADER_STATE") == "1" &&
@ -1295,6 +1315,20 @@ public static partial class Gen5SpirvTranslator
IReadOnlyList<ShaderBlock> blocks,
int blockIndex,
out string error)
{
if (!TryEmitBlockBody(blocks, blockIndex, out error))
{
return false;
}
return TryEmitBlockTerminator(blocks, blockIndex, out error);
}
// Every instruction of a block except its terminating branch or s_endpgm.
private bool TryEmitBlockBody(
IReadOnlyList<ShaderBlock> blocks,
int blockIndex,
out string error)
{
error = string.Empty;
var block = blocks[blockIndex];
@ -1337,6 +1371,17 @@ public static partial class Gen5SpirvTranslator
}
if (synchronizeSharedMemory && sharedMemoryPhase != SharedMemoryPhase.None) EmitWave64Barrier();
return true;
}
// Stores the next dispatcher block (or ends the program) for a block's terminator.
private bool TryEmitBlockTerminator(
IReadOnlyList<ShaderBlock> blocks,
int blockIndex,
out string error)
{
error = string.Empty;
var block = blocks[blockIndex];
var terminator = _request.Program.Instructions[block.EndIndex - 1];
if (terminator.Opcode == "SEndpgm")
{
@ -1414,6 +1459,315 @@ public static partial class Gen5SpirvTranslator
return true;
}
// Forward-only, properly nested control flow is emitted as structured selections instead of
// the PC-dispatch loop: a loop around a switch keeps every register live across iterations,
// blocks cannot be optimized together, and the Metal compiler spills. SHARPEMU_STRUCTURED_CF=0
// keeps the dispatcher for every program.
private static readonly bool StructuredControlFlow =
Environment.GetEnvironmentVariable("SHARPEMU_STRUCTURED_CF") != "0";
private static bool IsStructuredCondition(string opcode) => opcode is
"SCbranchScc0" or "SCbranchScc1" or "SCbranchVccz" or "SCbranchVccnz" or
"SCbranchExecz" or "SCbranchExecnz" or
"SCbranchCdbgsys" or "SCbranchCdbguser" or "SCbranchCdbgsysOrUser" or "SCbranchCdbgsysAndUser";
// The block a branch continues at, or blocks.Count for a branch past the last instruction.
private bool TryResolveBranchBlock(IReadOnlyList<ShaderBlock> blocks, Gen5ShaderInstruction branch, out int target)
{
target = -1;
if (!TryGetBranchTargetPc(branch, out var targetPc))
{
return false;
}
if (IsExitBranchTarget(_request.Program.Instructions, targetPc))
{
target = blocks.Count;
return true;
}
return TryFindBlock(blocks, targetPc, out target);
}
// Emits (or, without emit, only checks) blocks [begin, end). A branch from the range's last
// block to leave falls through to the range end. Inside a loop body, latch is the block
// whose backward branch the caller emits as the loop's continue, and a branch to end would
// leave the loop, which a structured body cannot express.
private bool TryStructureRange(
IReadOnlyList<ShaderBlock> blocks,
int begin,
int end,
int leave,
bool emit,
out string error,
int latch = -1)
{
error = string.Empty;
var instructions = _request.Program.Instructions;
var index = begin;
while (index < end)
{
// A loop body starts at its own header, which must not open the loop again.
if (!(latch >= 0 && index == begin) && index != latch &&
TryFindLoopLatch(blocks, index, end, out var loopLatch))
{
if (!TryStructureLoop(blocks, index, loopLatch, emit, out error))
{
return false;
}
index = loopLatch + 1;
continue;
}
if (emit && !TryEmitBlockBody(blocks, index, out error))
{
error = $"block=0x{blocks[index].StartPc:X}: {error}";
return false;
}
if (index == latch)
{
return true;
}
var terminator = instructions[blocks[index].EndIndex - 1];
var next = index + 1;
if (terminator.Opcode == "SEndpgm")
{
if (next != blocks.Count)
{
error = "s_endpgm before the last block";
return false;
}
if (emit)
{
Store(_programActive, _module.ConstantBool(false));
}
index = next;
continue;
}
if (terminator.Opcode == "SBranch")
{
if (!TryResolveBranchBlock(blocks, terminator, out var branchTarget) ||
!(branchTarget == next || (next == end && branchTarget == leave)))
{
error = $"unstructured s_branch at 0x{terminator.Pc:X}";
return false;
}
index = next;
continue;
}
if (!terminator.Opcode.StartsWith("SCbranch", StringComparison.Ordinal))
{
index = next;
continue;
}
if (!IsStructuredCondition(terminator.Opcode) ||
!TryResolveBranchBlock(blocks, terminator, out var target) || target <= index)
{
error = $"unstructured {terminator.Opcode} at 0x{terminator.Pc:X}";
return false;
}
// if (taken) break; -- only out of the innermost loop, to the block after its latch.
if (_structuredLoopExit >= 0 && target == _structuredLoopExit)
{
if (emit)
{
TryGetBranchCondition(terminator.Opcode, out var leaveLoop);
var breakLabel = _module.AllocateId();
var stayLabel = _module.AllocateId();
_module.AddStatement(SpirvOp.SelectionMerge, stayLabel, 0);
_module.AddStatement(SpirvOp.BranchConditional, leaveLoop, breakLabel, stayLabel);
_module.AddLabel(breakLabel);
_module.AddStatement(SpirvOp.Branch, _structuredLoopMerge);
_module.AddLabel(stayLabel);
}
index = next;
continue;
}
if (target > end || (latch >= 0 && target == end))
{
error = $"unstructured {terminator.Opcode} at 0x{terminator.Pc:X}";
return false;
}
if (target == next)
{
index = next;
continue;
}
// if/else: the skipped range ends with an s_branch over the taken range.
var thenLast = instructions[blocks[target - 1].EndIndex - 1];
if (target < blocks.Count && target - 1 > index && thenLast.Opcode == "SBranch" &&
TryResolveBranchBlock(blocks, thenLast, out var joinTarget) && joinTarget > target && joinTarget <= end &&
!(latch >= 0 && joinTarget == end))
{
if (!emit)
{
if (!TryStructureRange(blocks, next, target, joinTarget, emit: false, out error) ||
!TryStructureRange(blocks, target, joinTarget, joinTarget, emit: false, out error))
{
return false;
}
}
else
{
TryGetBranchCondition(terminator.Opcode, out var taken);
var thenLabel = _module.AllocateId();
var elseLabel = _module.AllocateId();
var joinLabel = _module.AllocateId();
_module.AddStatement(SpirvOp.SelectionMerge, joinLabel, 0);
_module.AddStatement(SpirvOp.BranchConditional, taken, elseLabel, thenLabel);
_module.AddLabel(thenLabel);
if (!TryStructureRange(blocks, next, target, joinTarget, emit: true, out error))
{
return false;
}
_module.AddStatement(SpirvOp.Branch, joinLabel);
_module.AddLabel(elseLabel);
if (!TryStructureRange(blocks, target, joinTarget, joinTarget, emit: true, out error))
{
return false;
}
_module.AddStatement(SpirvOp.Branch, joinLabel);
_module.AddLabel(joinLabel);
}
index = joinTarget;
continue;
}
// if (!taken) { skipped range }
if (!emit)
{
if (!TryStructureRange(blocks, next, target, target, emit: false, out error))
{
return false;
}
}
else
{
TryGetBranchCondition(terminator.Opcode, out var taken);
var bodyLabel = _module.AllocateId();
var mergeLabel = _module.AllocateId();
_module.AddStatement(SpirvOp.SelectionMerge, mergeLabel, 0);
_module.AddStatement(SpirvOp.BranchConditional, taken, mergeLabel, bodyLabel);
_module.AddLabel(bodyLabel);
if (!TryStructureRange(blocks, next, target, target, emit: true, out error))
{
return false;
}
_module.AddStatement(SpirvOp.Branch, mergeLabel);
_module.AddLabel(mergeLabel);
}
index = target;
}
return true;
}
// The innermost structured loop's exit block and merge label, for its breaks; -1 outside loops.
private int _structuredLoopExit = -1;
private uint _structuredLoopMerge;
// The last block in [header, end) that branches back to header, unconditionally or on a
// wave-uniform condition.
private bool TryFindLoopLatch(IReadOnlyList<ShaderBlock> blocks, int header, int end, out int latch)
{
latch = -1;
var instructions = _request.Program.Instructions;
for (var index = end - 1; index >= header; index--)
{
var terminator = instructions[blocks[index].EndIndex - 1];
if ((IsStructuredCondition(terminator.Opcode) || terminator.Opcode == "SBranch") &&
TryResolveBranchBlock(blocks, terminator, out var target) && target == header)
{
latch = index;
return true;
}
}
return false;
}
// do { blocks [header, latch] } while (latch condition), bounded by the same step guard as
// the dispatcher so a guest loop that never ends cannot wedge the GPU queue.
private bool TryStructureLoop(IReadOnlyList<ShaderBlock> blocks, int header, int latch, bool emit, out string error)
{
var outerExit = _structuredLoopExit;
var outerMerge = _structuredLoopMerge;
try
{
return TryStructureLoopCore(blocks, header, latch, emit, out error);
}
finally
{
_structuredLoopExit = outerExit;
_structuredLoopMerge = outerMerge;
}
}
private bool TryStructureLoopCore(IReadOnlyList<ShaderBlock> blocks, int header, int latch, bool emit, out string error)
{
_structuredLoopExit = latch + 1;
if (!emit)
{
_structuredLoopMerge = 0;
return TryStructureRange(blocks, header, latch + 1, latch + 1, emit: false, out error, latch);
}
var headerLabel = _module.AllocateId();
var bodyLabel = _module.AllocateId();
var continueLabel = _module.AllocateId();
var mergeLabel = _module.AllocateId();
_structuredLoopMerge = mergeLabel;
_module.AddStatement(SpirvOp.Branch, headerLabel);
_module.AddLabel(headerLabel);
_module.AddStatement(SpirvOp.LoopMerge, mergeLabel, continueLabel, 0);
_module.AddStatement(SpirvOp.Branch, bodyLabel);
_module.AddLabel(bodyLabel);
if (!TryStructureRange(blocks, header, latch + 1, latch + 1, emit: true, out error, latch))
{
return false;
}
_module.AddStatement(SpirvOp.Branch, continueLabel);
_module.AddLabel(continueLabel);
var terminator = _request.Program.Instructions[blocks[latch].EndIndex - 1];
var again = _module.ConstantBool(true);
if (terminator.Opcode != "SBranch")
{
TryGetBranchCondition(terminator.Opcode, out again);
}
if (_maxDispatcherSteps > 0)
{
var steps = IAdd(Load(_uintType, _iterationGuard), UInt(1));
Store(_iterationGuard, steps);
var withinLimit = _module.AddInstruction(SpirvOp.ULessThan, _boolType, steps, UInt((uint)_maxDispatcherSteps));
again = _module.AddInstruction(SpirvOp.LogicalAnd, _boolType, again, withinLimit);
}
_module.AddStatement(SpirvOp.BranchConditional, again, headerLabel, mergeLabel);
_module.AddLabel(mergeLabel);
return true;
}
private static string FormatBlockStarts(IReadOnlyList<ShaderBlock> blocks)
{
const int maxBlocks = 32;

View File

@ -0,0 +1,150 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Buffers.Binary;
using System.Diagnostics;
using SharpEmu.HLE;
using SharpEmu.ShaderCompiler.Vulkan;
using Xunit;
using static SharpEmu.ShaderCompiler.Tests.Resources.ResourceTestProgram;
namespace SharpEmu.ShaderCompiler.Tests;
// A loop closed by a backward branch is emitted as a structured SPIR-V loop instead of the PC
// dispatcher, with conditional exits from its body as breaks. Any other backward edge keeps the
// dispatcher.
public sealed class Gen5StructuredLoopTests
{
private const uint MoveFour = 0xBE800384; // s_mov_b32 s0, 4
private const uint SubtractOne = 0x80808100; // s_sub_u32 s0, s0, 1
private const uint CompareNotZero = 0xBF078000; // s_cmp_lg_u32 s0, 0
private const uint CompareTwo = 0xBF068200; // s_cmp_eq_u32 s0, 2
private const uint CompareZero = 0xBF068000; // s_cmp_eq_u32 s0, 0
private const uint EndProgram = 0xBF810000; // s_endpgm
[Fact]
public void BackwardConditionalBranchBecomesAStructuredLoop()
{
var spirv = Compile(MoveFour, SubtractOne, CompareNotZero, 0xBF85FFFD /* s_cbranch_scc1 0x04 */, EndProgram);
var opcodes = Opcodes(spirv);
Assert.Contains(SpirvOp.LoopMerge, opcodes);
Assert.DoesNotContain(SpirvOp.Switch, opcodes);
ValidateWhenAvailable(spirv);
}
[Fact]
public void ConditionalExitFromTheBodyBecomesABreak()
{
var spirv = Compile(
MoveFour, SubtractOne, CompareTwo,
0xBF850002, // s_cbranch_scc1 0x18, past the loop
CompareNotZero,
0xBF85FFFB, // s_cbranch_scc1 0x04
EndProgram);
var opcodes = Opcodes(spirv);
Assert.Contains(SpirvOp.LoopMerge, opcodes);
Assert.DoesNotContain(SpirvOp.Switch, opcodes);
ValidateWhenAvailable(spirv);
}
[Fact]
public void UnconditionalBackBranchLoopLeftByABreakIsStructured()
{
var spirv = Compile(
MoveFour, SubtractOne, CompareZero,
0xBF850001, // s_cbranch_scc1 0x14, past the loop
0xBF82FFFC, // s_branch 0x04
EndProgram);
var opcodes = Opcodes(spirv);
Assert.Contains(SpirvOp.LoopMerge, opcodes);
Assert.DoesNotContain(SpirvOp.Switch, opcodes);
ValidateWhenAvailable(spirv);
}
[Fact]
public void SecondBackEdgeToTheHeaderKeepsTheDispatcher()
{
var spirv = Compile(
MoveFour, SubtractOne, CompareTwo,
0xBF85FFFD, // s_cbranch_scc1 0x04, a continue from the middle of the body
CompareNotZero,
0xBF85FFFB, // s_cbranch_scc1 0x04
EndProgram);
Assert.Contains(SpirvOp.Switch, Opcodes(spirv));
ValidateWhenAvailable(spirv);
}
private static byte[] Compile(params uint[] words)
{
var context = new CpuContext(new InstructionMemory(words), Generation.Gen5);
Assert.True(Gen5ShaderTranslator.TryDecodeProgram(context, 0x1000, out var program, out var decodeError), decodeError);
var (plan, resources, layout) = Prepare(program);
var request = new ShaderCompileRequest(plan, resources, layout) { LocalSizeX = 1, ThreadCountX = 1 };
Assert.True(Gen5SpirvTranslator.TryCompileProgram(request, out var shader, out var error), error);
return shader.Spirv;
}
private static List<SpirvOp> Opcodes(byte[] code)
{
var result = new List<SpirvOp>();
for (var index = 5 * sizeof(uint); index < code.Length;)
{
var word = BinaryPrimitives.ReadUInt32LittleEndian(code.AsSpan(index));
result.Add((SpirvOp)(word & 0xFFFF));
index += (int)(word >> 16) * sizeof(uint);
}
return result;
}
private static void ValidateWhenAvailable(byte[] code)
{
var sdk = Environment.GetEnvironmentVariable("VULKAN_SDK");
var executable = string.IsNullOrWhiteSpace(sdk) ? null
: Path.Combine(sdk, OperatingSystem.IsWindows() ? "Bin/spirv-val.exe" : "bin/spirv-val");
if (executable is null || !File.Exists(executable))
{
return;
}
var path = Path.GetTempFileName();
try
{
File.WriteAllBytes(path, code);
var start = new ProcessStartInfo(executable) { UseShellExecute = false, RedirectStandardError = true, CreateNoWindow = true };
start.ArgumentList.Add("--target-env");
start.ArgumentList.Add("vulkan1.2");
start.ArgumentList.Add(path);
using var process = Process.Start(start)!;
var error = process.StandardError.ReadToEnd();
process.WaitForExit();
Assert.True(process.ExitCode == 0, error);
}
finally
{
File.Delete(path);
}
}
private sealed class InstructionMemory(uint[] words) : ICpuMemory
{
public bool TryRead(ulong address, Span<byte> destination)
{
var index = (address - 0x1000) / sizeof(uint);
if (address < 0x1000 || (address - 0x1000) % sizeof(uint) != 0 || destination.Length != sizeof(uint) ||
index >= (ulong)words.Length)
{
return false;
}
BinaryPrimitives.WriteUInt32LittleEndian(destination, words[(int)index]);
return true;
}
public bool TryWrite(ulong address, ReadOnlySpan<byte> source) => false;
}
}