Editor/Prism/Compiler/IrOptimizer.cs

IR optimizer for a shader/IR compiler used in the Editor project. It provides options for which optimizations run, runs passes over IR blocks and modules to fold constants, apply algebraic identities, copy-propagate, merge redundant declarations, and remove dead code, and contains numeric evaluation and folding logic for intrinsics and operators.

Native Interop
using Editor.Prism.Compiler.Ir;
using Editor.Prism.Core;

namespace Editor.Prism.Compiler;

/// <summary>Which optimisations run, and how hard.</summary>
public sealed record IrOptimizerOptions
{
	/// <summary>Evaluate operations whose operands are all literals.</summary>
	public bool ConstantFolding { get; init; } = true;

	/// <summary>Apply the identities that cost nothing to check: x*1, x+0, lerp(a,b,0), select on a literal.</summary>
	public bool AlgebraicIdentities { get; init; } = true;

	/// <summary>
	/// Allow the identities that are only true for finite values: <c>x * 0 → 0</c> and
	/// <c>pow( x, 0 ) → 1</c>.
	/// <para>
	/// Every other rule under <see cref="AlgebraicIdentities"/> is exact — <c>x + 0</c> and <c>x * 1</c>
	/// give back the same bits for a NaN or an infinity. These two do not: a graph that masks a division
	/// with <c>rcp( d ) * step( eps, d )</c> folds to exactly zero where the GPU would have produced
	/// NaN. That is usually what the author wanted and it is what DXC does anyway, so this defaults on —
	/// but it is the one place the optimiser is not semantics-preserving, so it is a switch rather than
	/// a hard-coded rule, and turning it off is the second thing to try when the optimised and
	/// unoptimised shaders disagree numerically.
	/// </para>
	/// </summary>
	public bool FastMath { get; init; } = true;

	/// <summary>Inline temps whose initialiser is a literal or a plain reference.</summary>
	public bool CopyPropagation { get; init; } = true;

	/// <summary>Merge declarations that end up holding structurally identical values.</summary>
	public bool RedundantDeclarations { get; init; } = true;

	/// <summary>Delete pure declarations nothing reads, and blocks that end up empty.</summary>
	public bool DeadCodeElimination { get; init; } = true;

	/// <summary>Delete helper functions nothing calls.</summary>
	public bool DropUnusedHelpers { get; init; } = true;

	/// <summary>Keep comments. Off in a release build where nobody reads the generated text.</summary>
	public bool KeepComments { get; init; } = true;

	/// <summary>How many times to sweep before accepting the result.</summary>
	public int MaxPasses { get; init; } = 8;

	/// <summary>Everything on. What a normal compile uses.</summary>
	public static IrOptimizerOptions Default { get; } = new();

	/// <summary>Everything off. Useful when a bug report needs the emitter's raw output.</summary>
	public static IrOptimizerOptions None { get; } = new()
	{
		ConstantFolding = false,
		AlgebraicIdentities = false,
		CopyPropagation = false,
		RedundantDeclarations = false,
		DeadCodeElimination = false,
		DropUnusedHelpers = false,
		FastMath = false,
		MaxPasses = 0
	};

	/// <summary>
	/// Everything on except the rules that assume finite values. Use this to check whether an
	/// optimised shader and its unoptimised twin disagree because of <see cref="FastMath"/>.
	/// </summary>
	public static IrOptimizerOptions Exact { get; } = new()
	{
		FastMath = false
	};
}

/// <summary>What the optimiser managed to remove.</summary>
public readonly record struct OptimizerStats( int Folded, int Propagated, int Merged, int Removed, int Passes )
{
	/// <summary>Total expressions and statements the optimiser eliminated.</summary>
	public int Total => Folded + Propagated + Merged + Removed;

	/// <inheritdoc/>
	public override string ToString() =>
		$"{Folded} folded, {Propagated} propagated, {Merged} merged, {Removed} removed in {Passes} passes";
}

/// <summary>
/// Cleans up the IR after emission.
/// <para>
/// A demand-driven emitter deliberately binds almost everything to a temp: that keeps the source map
/// dense and lets hash-consing merge work across nodes. The cost is a lot of one-line temps and a lot
/// of arithmetic on literals, because the graph is full of default values that never changed. This
/// pass folds the literals, applies the identities that follow, inlines the temps that turned out to
/// be plain references, merges declarations that ended up holding the same value, and deletes whatever
/// nothing reads — typically 20-40% of what the emitter produced.
/// </para>
/// <para>
/// Statement order is never permuted. The emitter already produces a topological, demand-ordered
/// sequence, and preserving it byte for byte is what makes "regenerate, compare text, skip the
/// compile" trustworthy.
/// </para>
/// </summary>
public static class IrOptimizer
{
	/// <summary>Optimise every function in a module.</summary>
	public static OptimizerStats Optimize( IrModule module, IrOptimizerOptions options = null,
		DiagnosticSink diagnostics = null )
	{
		options ??= IrOptimizerOptions.Default;

		if ( module is null ) return default;

		var folded = 0;
		var propagated = 0;
		var merged = 0;
		var removed = 0;
		var passes = 0;

		foreach ( var function in module.Functions )
		{
			if ( function?.Body is null ) continue;

			var stats = Optimize( function.Body, options );

			folded += stats.Folded;
			propagated += stats.Propagated;
			merged += stats.Merged;
			removed += stats.Removed;
			passes = Math.Max( passes, stats.Passes );
		}

		if ( options.DropUnusedHelpers ) removed += DropUnusedHelpers( module );

		_ = diagnostics;

		return new OptimizerStats( folded, propagated, merged, removed, passes );
	}

	/// <summary>Optimise one block in place.</summary>
	public static OptimizerStats Optimize( IrBlock block, IrOptimizerOptions options = null )
	{
		options ??= IrOptimizerOptions.Default;

		if ( block is null ) return default;

		var folded = 0;
		var propagated = 0;
		var merged = 0;
		var removed = 0;
		var passes = 0;

		var assigned = new HashSet<string>( StringComparer.Ordinal );
		CollectAssignedNames( block, assigned );

		for ( ; passes < Math.Max( 0, options.MaxPasses ); passes++ )
		{
			var pass = new Pass( options, assigned );

			pass.Rewrite( block );

			if ( options.DeadCodeElimination ) pass.Removed += Prune( block, options );

			folded += pass.Folded;
			propagated += pass.Propagated;
			merged += pass.Merged;
			removed += pass.Removed;

			if ( pass.Changes == 0 ) break;
		}

		return new OptimizerStats( folded, propagated, merged, removed, passes );
	}

	/// <summary>Fold one expression tree in isolation. Exposed for tests and for the IR panel.</summary>
	public static IrExpr Fold( IrExpr expr )
	{
		if ( expr is null ) return null;

		var pass = new Pass( IrOptimizerOptions.Default, new HashSet<string>( StringComparer.Ordinal ) );

		return pass.Visit( expr );
	}

	static int DropUnusedHelpers( IrModule module )
	{
		if ( module.Helpers.Count == 0 ) return 0;

		var used = new HashSet<string>( StringComparer.Ordinal );

		foreach ( var function in module.Functions )
		{
			CollectHelperCalls( function?.Body, used );
		}

		// A helper another live helper needs stays, transitively.
		var changed = true;

		while ( changed )
		{
			changed = false;

			foreach ( var helper in module.Helpers )
			{
				if ( !used.Contains( helper.Name ) ) continue;

				foreach ( var required in helper.Requires ?? Array.Empty<HelperFunction>() )
				{
					if ( required is null ) continue;
					if ( used.Add( required.Name ) ) changed = true;
				}
			}
		}

		var before = module.Helpers.Count;
		module.Helpers.RemoveAll( x => x is not null && !used.Contains( x.Name ) );

		return before - module.Helpers.Count;
	}

	static void CollectHelperCalls( IrBlock block, HashSet<string> used )
	{
		if ( block is null ) return;

		foreach ( var statement in block.Statements )
		{
			foreach ( var expr in Expressions( statement ) )
			{
				foreach ( var node in IrExprUtil.Walk( expr ) )
				{
					if ( node is IrHelperCall call && call.Fn is not null ) used.Add( call.Fn.Name );
				}
			}

			foreach ( var child in Blocks( statement ) ) CollectHelperCalls( child, used );
		}
	}

	static void CollectAssignedNames( IrBlock block, HashSet<string> names )
	{
		if ( block is null ) return;

		foreach ( var statement in block.Statements )
		{
			if ( statement is IrAssign assign )
			{
				foreach ( var node in IrExprUtil.Walk( assign.Target ) )
				{
					if ( node is IrVar variable ) names.Add( variable.Name );
				}
			}

			if ( statement is IrFor loop ) names.Add( loop.Var );

			foreach ( var child in Blocks( statement ) ) CollectAssignedNames( child, names );
		}
	}

	// ---- dead code --------------------------------------------------------

	static int Prune( IrBlock block, IrOptimizerOptions options )
	{
		// Run to a fixed point rather than once. Use counts are taken up front and never decremented as
		// declarations are deleted, so one sweep only ever removes the *last* link of a dead chain:
		// `t0 = f(); t1 = t0; t2 = t1;` sheds t2 this sweep, t1 the next. Leaving that to the optimiser's
		// own MaxPasses budget means a chain longer than the budget ships its dead temps in the
		// generated text. Recounting is a cheap walk and the loop converges because removal is monotone.
		var total = 0;

		for ( int sweep = 0; sweep < MaxPruneSweeps; sweep++ )
		{
			var uses = new Dictionary<string, int>( StringComparer.Ordinal );
			CountUses( block, uses );

			var removed = Prune( block, uses, options );

			total += removed;

			if ( removed == 0 ) break;
		}

		return total;
	}

	/// <summary>
	/// Ceiling on the dead-code fixed point. Removal is monotone so the loop always converges long
	/// before this; the cap only exists so a future non-monotone rule cannot hang a compile.
	/// </summary>
	const int MaxPruneSweeps = 64;

	static int Prune( IrBlock block, Dictionary<string, int> uses, IrOptimizerOptions options )
	{
		if ( block is null ) return 0;

		var removed = 0;

		for ( int i = block.Statements.Count - 1; i >= 0; i-- )
		{
			var statement = block.Statements[i];

			foreach ( var child in Blocks( statement ) ) removed += Prune( child, uses, options );

			switch ( statement )
			{
				case IrDecl decl when Count( uses, decl.Name ) == 0 && ( decl.Init is null || decl.Init.Pure ):
					block.Statements.RemoveAt( i );
					removed++;
					break;

				case IrComment when !options.KeepComments:
					block.Statements.RemoveAt( i );
					removed++;
					break;

				case IrIf branch when IsEmpty( branch.Then ) && IsEmpty( branch.Else ) && branch.Cond is { Pure: true }:
					block.Statements.RemoveAt( i );
					removed++;
					break;

				// The purity guard matters as much here as on the IrIf above: an empty body does not make
				// the loop bound dead, and a count or condition built from an atomic or a texture write
				// has to run whatever the body does.
				case IrFor loop when IsEmpty( loop.Body ) && loop.Count is null or { Pure: true }:
					block.Statements.RemoveAt( i );
					removed++;
					break;

				case IrWhile loop when IsEmpty( loop.Body ) && loop.Cond is null or { Pure: true }:
					block.Statements.RemoveAt( i );
					removed++;
					break;

				case IrScope scope when IsEmpty( scope.Body ):
					block.Statements.RemoveAt( i );
					removed++;
					break;
			}
		}

		return removed;
	}

	static bool IsEmpty( IrBlock block ) => block is null || block.Statements.Count == 0;

	static int Count( Dictionary<string, int> uses, string name ) =>
		name is not null && uses.TryGetValue( name, out var count ) ? count : 0;

	static void CountUses( IrBlock block, Dictionary<string, int> uses )
	{
		if ( block is null ) return;

		foreach ( var statement in block.Statements )
		{
			foreach ( var expr in Expressions( statement ) )
			{
				foreach ( var node in IrExprUtil.Walk( expr ) )
				{
					if ( node is not IrVar variable ) continue;

					uses.TryGetValue( variable.Name, out var count );
					uses[variable.Name] = count + 1;
				}
			}

			foreach ( var child in Blocks( statement ) ) CountUses( child, uses );
		}
	}

	// ---- statement shape --------------------------------------------------

	static IEnumerable<IrExpr> Expressions( IrStmt statement )
	{
		switch ( statement )
		{
			case IrDecl decl when decl.Init is not null:
				yield return decl.Init;
				break;

			case IrAssign assign:
				// The target's own sub-expressions are reads; the outermost variable is a write.
				if ( assign.Target is not null ) yield return assign.Target;
				if ( assign.Value is not null ) yield return assign.Value;
				break;

			case IrIf branch when branch.Cond is not null:
				yield return branch.Cond;
				break;

			case IrFor loop when loop.Count is not null:
				yield return loop.Count;
				break;

			case IrWhile loop when loop.Cond is not null:
				yield return loop.Cond;
				break;

			case IrReturn ret when ret.Value is not null:
				yield return ret.Value;
				break;

			case IrExprStmt expr when expr.Value is not null:
				yield return expr.Value;
				break;
		}
	}

	static IEnumerable<IrBlock> Blocks( IrStmt statement )
	{
		switch ( statement )
		{
			case IrIf branch:
				if ( branch.Then is not null ) yield return branch.Then;
				if ( branch.Else is not null ) yield return branch.Else;
				break;

			case IrFor loop when loop.Body is not null:
				yield return loop.Body;
				break;

			case IrWhile loop when loop.Body is not null:
				yield return loop.Body;
				break;

			case IrScope scope when scope.Body is not null:
				yield return scope.Body;
				break;

			// A preprocessor guard is a real scope as far as liveness is concerned. Without this a temp
			// declared outside the guard and read only inside it counts as unused, gets pruned, and the
			// generated code then names an undeclared local.
			case IrPreprocessorIf guard:
				if ( guard.Then is not null ) yield return guard.Then;
				if ( guard.Else is not null ) yield return guard.Else;
				break;
		}
	}

	/// <summary>One rewrite sweep over a block, carrying a scoped substitution environment.</summary>
	sealed class Pass
	{
		readonly IrOptimizerOptions _options;
		readonly HashSet<string> _assigned;
		readonly List<Dictionary<string, IrExpr>> _substitutions = new();
		readonly List<Dictionary<IrExpr, string>> _available = new();

		public Pass( IrOptimizerOptions options, HashSet<string> assigned )
		{
			_options = options;
			_assigned = assigned;
		}

		public int Folded;
		public int Propagated;
		public int Merged;
		public int Removed;

		public int Changes => Folded + Propagated + Merged + Removed;

		public void Rewrite( IrBlock block )
		{
			if ( block is null ) return;

			_substitutions.Add( new Dictionary<string, IrExpr>( StringComparer.Ordinal ) );
			_available.Add( new Dictionary<IrExpr, string>() );

			for ( int i = 0; i < block.Statements.Count; i++ )
			{
				block.Statements[i] = Rewrite( block.Statements[i] );
			}

			_substitutions.RemoveAt( _substitutions.Count - 1 );
			_available.RemoveAt( _available.Count - 1 );
		}

		IrStmt Rewrite( IrStmt statement )
		{
			switch ( statement )
			{
				case IrDecl decl:
				{
					var init = Visit( decl.Init );

					// !_assigned is the same guard copy propagation carries below, and for the same
					// reason: substituting every read of a name that is later assigned to makes the
					// assignment dead and the reads answer with the pre-assignment value. The type match
					// pairs with the one on the insert, so a merged name always has the type its
					// initialiser had.
					if ( init is not null && _options.RedundantDeclarations && init.Pure &&
						!_assigned.Contains( decl.Name ) && init.Type == decl.Type &&
						TryFindAvailable( init, out var existing ) && existing != decl.Name )
					{
						Merged++;
						Substitute( decl.Name, new IrVar( decl.Type, existing ) );

						return decl with { Init = init };
					}

					if ( init is not null && _options.CopyPropagation && IsTrivial( init ) &&
						!_assigned.Contains( decl.Name ) && init.Type == decl.Type )
					{
						Propagated++;
						Substitute( decl.Name, init );
					}
					else if ( init is not null && init.Pure && !_assigned.Contains( decl.Name ) &&
						init.Type == decl.Type )
					{
						// Only offer a name whose declared type is its initialiser's. IrBuilder.Declare
						// lets a caller declare a wider or narrower type than the expression it stores,
						// and merging onto one of those would change what the reader sees.
						_available[^1][init] = decl.Name;
					}

					return ReferenceEquals( init, decl.Init ) ? decl : decl with { Init = init };
				}

				case IrAssign assign:
					return assign with { Target = VisitTarget( assign.Target ), Value = Visit( assign.Value ) };

				case IrIf branch:
				{
					var condition = Visit( branch.Cond );

					if ( _options.AlgebraicIdentities && condition is IrConst literal )
					{
						// A branch on a literal is not a branch. Keep the taken side's statements only.
						var taken = literal.Value.IsZero( 1 ) ? branch.Else : branch.Then;

						Folded++;
						Rewrite( taken );

						return new IrScope( branch.Origin, taken ?? new IrBlock() );
					}

					Rewrite( branch.Then );
					Rewrite( branch.Else );

					return branch with { Cond = condition };
				}

				case IrFor loop:
				{
					var count = Visit( loop.Count );
					Rewrite( loop.Body );
					return loop with { Count = count };
				}

				case IrWhile loop:
				{
					var condition = Visit( loop.Cond );
					Rewrite( loop.Body );
					return loop with { Cond = condition };
				}

				case IrReturn ret:
					return ret with { Value = Visit( ret.Value ) };

				case IrExprStmt expr:
					return expr with { Value = Visit( expr.Value ) };

				case IrScope scope:
					Rewrite( scope.Body );
					return scope;

				// Both sides are rewritten, but the condition is deliberately not an IrExpr and never
				// becomes one: a preprocessor condition may only name combo symbols, so there is
				// nothing here to visit or fold.
				case IrPreprocessorIf guard:
					Rewrite( guard.Then );
					Rewrite( guard.Else );
					return guard;

				default:
					return statement;
			}
		}

		void Substitute( string name, IrExpr replacement )
		{
			if ( string.IsNullOrEmpty( name ) || replacement is null ) return;

			_substitutions[^1][name] = replacement;
		}

		bool TryFindAvailable( IrExpr expr, out string name )
		{
			for ( int i = _available.Count - 1; i >= 0; i-- )
			{
				if ( _available[i].TryGetValue( expr, out name ) ) return true;
			}

			name = null;
			return false;
		}

		bool TryLookup( string name, out IrExpr replacement )
		{
			for ( int i = _substitutions.Count - 1; i >= 0; i-- )
			{
				if ( _substitutions[i].TryGetValue( name, out replacement ) ) return true;
			}

			replacement = null;
			return false;
		}

		/// <summary>
		/// Rewrite an assignment target. The outermost variable is a write and must survive; anything
		/// inside it — an index expression, say — is a read and folds normally.
		/// </summary>
		IrExpr VisitTarget( IrExpr expr ) => expr switch
		{
			null => null,
			IrVar => expr,
			IrMember member => member with { V = VisitTarget( member.V ) },
			IrSwizzle swizzle => swizzle with { V = VisitTarget( swizzle.V ) },
			IrIndex index => index with { V = VisitTarget( index.V ), I = Visit( index.I ) },
			_ => expr
		};

		/// <summary>Rewrite one expression tree bottom-up.</summary>
		public IrExpr Visit( IrExpr expr )
		{
			if ( expr is null ) return null;

			var rewritten = expr switch
			{
				IrVar variable when _options.CopyPropagation && TryLookup( variable.Name, out var replacement ) =>
					replacement,

				IrCall call => call with { Args = VisitAll( call.Args ) },
				IrHelperCall helper => helper with { Args = VisitAll( helper.Args ) },
				IrBinary bin => bin with { L = Visit( bin.L ), R = Visit( bin.R ) },
				IrUnary un => un with { V = Visit( un.V ) },
				IrSwizzle sw => sw with { V = Visit( sw.V ) },
				IrConstruct ctor => ctor with { Parts = VisitAll( ctor.Parts ) },
				IrCast cast => cast with { V = Visit( cast.V ) },
				IrSelect sel => sel with { C = Visit( sel.C ), A = Visit( sel.A ), B = Visit( sel.B ) },
				IrIndex index => index with { V = Visit( index.V ), I = Visit( index.I ) },
				IrMember member => member with { V = Visit( member.V ) },
				_ => expr
			};

			var folded = IrFolding.Fold( rewritten, _options );

			if ( !ReferenceEquals( folded, rewritten ) ) Folded++;

			return folded;
		}

		IrExpr[] VisitAll( IrExpr[] items )
		{
			if ( items is null || items.Length == 0 ) return items;

			var result = new IrExpr[items.Length];

			for ( int i = 0; i < items.Length; i++ ) result[i] = Visit( items[i] );

			return result;
		}

		static bool IsTrivial( IrExpr expr ) => expr switch
		{
			IrConst => true,
			IrVar => true,
			IrGlobalRef => true,
			IrBuiltinRef => true,
			IrSwizzle s => s.V is IrVar or IrGlobalRef or IrBuiltinRef,
			IrMember m => m.V is IrVar or IrGlobalRef,
			_ => false
		};
	}
}

/// <summary>
/// Rewrites a single expression node once its children are already folded.
/// <para>
/// Two families of rule live here. Constant evaluation replaces an operation whose operands are all
/// literals with the literal it produces — but only when the result is finite, because emitting a
/// baked-in NaN is worse than leaving the call alone. Algebraic identities remove the work a graph
/// editor generates by construction: multiplying by an untouched default of 1, adding an untouched
/// default of 0, a lerp whose factor is pinned at either end, a select on a fixed condition, and the
/// swizzle-of-construct pattern every split-and-recombine chain produces.
/// </para>
/// </summary>
public static class IrFolding
{
	/// <summary>Fold one node. Children are assumed to be folded already.</summary>
	public static IrExpr Fold( IrExpr expr, IrOptimizerOptions options )
	{
		if ( expr is null ) return null;

		options ??= IrOptimizerOptions.Default;

		return expr switch
		{
			IrCast cast => FoldCast( cast, options ),
			IrSwizzle swizzle => FoldSwizzle( swizzle, options ),
			IrConstruct ctor => FoldConstruct( ctor, options ),
			IrUnary un => FoldUnary( un, options ),
			IrBinary bin => FoldBinary( bin, options ),
			IrSelect sel => FoldSelect( sel, options ),
			IrCall call => FoldCall( call, options ),
			_ => expr
		};
	}

	// ---- casts ------------------------------------------------------------

	static IrExpr FoldCast( IrCast cast, IrOptimizerOptions options )
	{
		if ( cast.V is null ) return cast;
		if ( cast.V.Type == cast.Type ) return cast.V;

		if ( !options.ConstantFolding ) return cast;
		if ( cast.V is not IrConst literal ) return cast;

		var value = Convert( literal.Value, literal.Type, cast.Type, cast.Kind, cast.Fill );

		return Literal( cast.Type, value );
	}

	static ConstValue Convert( ConstValue value, ShaderType from, ShaderType to, CastKind kind, float fill )
	{
		var source = Math.Clamp( from.Components, 1, 4 );
		var target = Math.Clamp( to.Components, 1, 4 );

		var components = new double[4];

		for ( int i = 0; i < 4; i++ )
		{
			if ( kind == CastKind.Splat || source == 1 )
			{
				components[i] = value.X;
			}
			else if ( i < source && i < target )
			{
				components[i] = value[i];
			}
			else if ( i < target )
			{
				components[i] = fill;
			}
			else
			{
				components[i] = 0;
			}
		}

		var result = new ConstValue( components[0], components[1], components[2], components[3] );

		return Quantize( result, to );
	}

	// ---- swizzles ---------------------------------------------------------

	static IrExpr FoldSwizzle( IrSwizzle swizzle, IrOptimizerOptions options )
	{
		if ( swizzle.V is null || string.IsNullOrEmpty( swizzle.Mask ) ) return swizzle;

		// A swizzle that names every component in order is not a swizzle.
		if ( swizzle.V.Type == swizzle.Type && IsIdentity( swizzle.Mask, swizzle.V.Type ) ) return swizzle.V;

		if ( options.ConstantFolding && swizzle.V is IrConst literal )
		{
			var picked = new double[4];

			for ( int i = 0; i < 4; i++ )
			{
				var index = i < swizzle.Mask.Length ? Channel( swizzle.Mask[i] ) : Channel( swizzle.Mask[^1] );
				picked[i] = index < 0 ? 0 : literal.Value[index];
			}

			return Literal( swizzle.Type, new ConstValue( picked[0], picked[1], picked[2], picked[3] ) );
		}

		if ( !options.AlgebraicIdentities ) return swizzle;

		// .xy of .zwxy composes into .zw rather than nesting.
		if ( swizzle.V is IrSwizzle inner && !string.IsNullOrEmpty( inner.Mask ) )
		{
			var composed = new char[swizzle.Mask.Length];

			for ( int i = 0; i < swizzle.Mask.Length; i++ )
			{
				var index = Channel( swizzle.Mask[i] );

				if ( index < 0 || index >= inner.Mask.Length ) return swizzle;

				composed[i] = inner.Mask[index];
			}

			return new IrSwizzle( swizzle.Type, inner.V, new string( composed ) ) { Pure = inner.Pure };
		}

		if ( swizzle.V is IrConstruct ctor ) return FoldSwizzleOfConstruct( swizzle, ctor );

		return swizzle;
	}

	/// <summary>
	/// <c>float3( a, b, c ).xy</c> becomes <c>float2( a, b )</c>. Only applied when nothing is
	/// duplicated and every part is pure, so no side effect is repeated or dropped.
	/// </summary>
	static IrExpr FoldSwizzleOfConstruct( IrSwizzle swizzle, IrConstruct ctor )
	{
		if ( ctor.Parts is null || ctor.Parts.Length == 0 ) return swizzle;
		if ( !ctor.Pure ) return swizzle;
		if ( HasRepeats( swizzle.Mask ) ) return swizzle;

		var sources = new List<IrExpr>( 4 );

		foreach ( var part in ctor.Parts )
		{
			if ( part is null || !part.Type.IsScalarOrVector ) return swizzle;

			var width = Math.Max( 1, part.Type.Components );

			for ( int i = 0; i < width; i++ )
			{
				sources.Add( width == 1
					? part
					: new IrSwizzle( part.Type.WithComponents( 1 ), part, "xyzw"[i].ToString() ) { Pure = part.Pure } );
			}
		}

		var picked = new IrExpr[swizzle.Mask.Length];

		for ( int i = 0; i < swizzle.Mask.Length; i++ )
		{
			var index = Channel( swizzle.Mask[i] );

			if ( index < 0 || index >= sources.Count ) return swizzle;

			picked[i] = sources[index];
		}

		if ( picked.Length == 1 ) return picked[0].Type == swizzle.Type ? picked[0] : swizzle;

		return new IrConstruct( swizzle.Type, picked ) { Pure = true };
	}

	static IrExpr FoldConstruct( IrConstruct ctor, IrOptimizerOptions options )
	{
		if ( ctor.Parts is null || ctor.Parts.Length == 0 ) return ctor;

		if ( ctor.Parts.Length == 1 && ctor.Parts[0] is not null && ctor.Parts[0].Type == ctor.Type )
		{
			return ctor.Parts[0];
		}

		if ( !options.ConstantFolding ) return ctor;

		var components = new double[4];
		var filled = 0;

		foreach ( var part in ctor.Parts )
		{
			if ( part is not IrConst literal ) return ctor;

			var width = Math.Max( 1, literal.Type.Components );

			for ( int i = 0; i < width && filled < 4; i++ ) components[filled++] = literal.Value[i];
		}

		if ( filled == 0 ) return ctor;

		return Literal( ctor.Type, new ConstValue( components[0], components[1], components[2], components[3] ) );
	}

	// ---- operators --------------------------------------------------------

	static IrExpr FoldUnary( IrUnary un, IrOptimizerOptions options )
	{
		if ( un.V is null ) return un;

		if ( options.AlgebraicIdentities && un.V is IrUnary inner && inner.Op == un.Op &&
			un.Op is UnaryOp.Negate or UnaryOp.LogicalNot or UnaryOp.BitNot &&
			inner.V is not null && inner.V.Type == un.Type )
		{
			return inner.V;
		}

		if ( !options.ConstantFolding || un.V is not IrConst literal ) return un;

		var width = Width( un.Type );

		var folded = un.Op switch
		{
			UnaryOp.Negate => Map( literal.Value, width, x => -x ),
			UnaryOp.LogicalNot => Map( literal.Value, width, x => x != 0 ? 0 : 1 ),
			_ => Map( literal.Value, width, x => ~(long)x )
		};

		return Literal( un.Type, folded );
	}

	static IrExpr FoldBinary( IrBinary bin, IrOptimizerOptions options )
	{
		if ( bin.L is null || bin.R is null ) return bin;

		var width = Width( bin.Type );

		if ( options.ConstantFolding && bin.L is IrConst a && bin.R is IrConst b )
		{
			if ( TryFoldBinary( bin.Op, a.Value, b.Value, width, out var value ) )
			{
				return Literal( bin.Type, value );
			}
		}

		if ( !options.AlgebraicIdentities ) return bin;

		var left = bin.L as IrConst;
		var right = bin.R as IrConst;

		switch ( bin.Op )
		{
			case BinaryOp.Add:
				if ( left is not null && left.Value.IsZero( width ) && bin.R.Type == bin.Type ) return bin.R;
				if ( right is not null && right.Value.IsZero( width ) && bin.L.Type == bin.Type ) return bin.L;
				break;

			case BinaryOp.Sub:
				if ( right is not null && right.Value.IsZero( width ) && bin.L.Type == bin.Type ) return bin.L;
				break;

			case BinaryOp.Mul:
				if ( left is not null && left.Value.IsOne( width ) && bin.R.Type == bin.Type ) return bin.R;
				if ( right is not null && right.Value.IsOne( width ) && bin.L.Type == bin.Type ) return bin.L;
				// Zero absorbs only under FastMath: NaN * 0 is NaN, not 0. See IrOptimizerOptions.FastMath.
				if ( options.FastMath && left is not null && left.Value.IsZero( width ) && bin.R.Pure )
					return Literal( bin.Type, ConstValue.Zero );
				if ( options.FastMath && right is not null && right.Value.IsZero( width ) && bin.L.Pure )
					return Literal( bin.Type, ConstValue.Zero );
				break;

			case BinaryOp.Div:
				if ( right is not null && right.Value.IsOne( width ) && bin.L.Type == bin.Type ) return bin.L;
				break;
		}

		return bin;
	}

	static bool TryFoldBinary( BinaryOp op, ConstValue a, ConstValue b, int width, out ConstValue value )
	{
		value = default;

		if ( op is BinaryOp.Div or BinaryOp.Mod )
		{
			for ( int i = 0; i < width; i++ )
			{
				if ( b[i] == 0 ) return false;
			}
		}

		value = op switch
		{
			BinaryOp.Add => Map2( a, b, width, ( x, y ) => x + y ),
			BinaryOp.Sub => Map2( a, b, width, ( x, y ) => x - y ),
			BinaryOp.Mul => Map2( a, b, width, ( x, y ) => x * y ),
			BinaryOp.Div => Map2( a, b, width, ( x, y ) => x / y ),
			BinaryOp.Mod => Map2( a, b, width, ( x, y ) => x - y * Math.Truncate( x / y ) ),
			BinaryOp.Less => Map2( a, b, width, ( x, y ) => x < y ? 1 : 0 ),
			BinaryOp.LessEqual => Map2( a, b, width, ( x, y ) => x <= y ? 1 : 0 ),
			BinaryOp.Greater => Map2( a, b, width, ( x, y ) => x > y ? 1 : 0 ),
			BinaryOp.GreaterEqual => Map2( a, b, width, ( x, y ) => x >= y ? 1 : 0 ),
			BinaryOp.Equal => Map2( a, b, width, ( x, y ) => x == y ? 1 : 0 ),
			BinaryOp.NotEqual => Map2( a, b, width, ( x, y ) => x != y ? 1 : 0 ),
			BinaryOp.LogicalAnd => Map2( a, b, width, ( x, y ) => x != 0 && y != 0 ? 1 : 0 ),
			BinaryOp.LogicalOr => Map2( a, b, width, ( x, y ) => x != 0 || y != 0 ? 1 : 0 ),
			BinaryOp.BitAnd => Map2( a, b, width, ( x, y ) => (long)x & (long)y ),
			BinaryOp.BitOr => Map2( a, b, width, ( x, y ) => (long)x | (long)y ),
			BinaryOp.BitXor => Map2( a, b, width, ( x, y ) => (long)x ^ (long)y ),
			BinaryOp.Shl => Map2( a, b, width, ( x, y ) => (long)x << (int)y ),
			_ => Map2( a, b, width, ( x, y ) => (long)x >> (int)y )
		};

		return IsFinite( value, width );
	}

	static IrExpr FoldSelect( IrSelect sel, IrOptimizerOptions options )
	{
		if ( !options.AlgebraicIdentities ) return sel;
		if ( sel.C is not IrConst condition ) return sel;
		if ( sel.A is null || sel.B is null ) return sel;

		var width = Math.Max( Width( sel.C.Type ), 1 );
		var allTrue = true;
		var allFalse = true;

		for ( int i = 0; i < width; i++ )
		{
			if ( condition.Value[i] != 0 ) allFalse = false;
			else allTrue = false;
		}

		if ( allTrue && sel.B.Pure && sel.A.Type == sel.Type ) return sel.A;
		if ( allFalse && sel.A.Pure && sel.B.Type == sel.Type ) return sel.B;

		return sel;
	}

	// ---- intrinsics -------------------------------------------------------

	static IrExpr FoldCall( IrCall call, IrOptimizerOptions options )
	{
		var args = call.Args ?? Array.Empty<IrExpr>();

		if ( options.AlgebraicIdentities )
		{
			var identity = FoldCallIdentity( call, args );
			if ( identity is not null ) return identity;
		}

		if ( !options.ConstantFolding ) return call;
		if ( !IntrinsicCatalog.Get( call.Id ).Pure ) return call;
		if ( args.Length == 0 ) return call;

		foreach ( var arg in args )
		{
			if ( arg is not IrConst ) return call;
		}

		var values = new ConstValue[args.Length];
		var widths = new int[args.Length];

		for ( int i = 0; i < args.Length; i++ )
		{
			values[i] = ( (IrConst)args[i] ).Value;
			widths[i] = Width( args[i].Type );
		}

		var width = Width( call.Type );

		if ( !TryEvaluate( call.Id, values, widths, width, out var folded ) ) return call;
		if ( !IsFinite( folded, width ) ) return call;

		return Literal( call.Type, folded );
	}

	static IrExpr FoldCallIdentity( IrCall call, IrExpr[] args )
	{
		switch ( call.Id )
		{
			case Intrinsic.Lerp when args.Length == 3 && args[2] is IrConst t:
			{
				var width = Width( args[2].Type );

				if ( t.Value.IsZero( width ) && args[0].Type == call.Type && args[1].Pure ) return args[0];
				if ( t.Value.IsOne( width ) && args[1].Type == call.Type && args[0].Pure ) return args[1];

				break;
			}

			case Intrinsic.Pow when args.Length == 2 && args[1] is IrConst exponent:
			{
				var width = Width( args[1].Type );

				if ( exponent.Value.IsOne( width ) && args[0].Type == call.Type ) return args[0];
				if ( exponent.Value.IsZero( width ) && args[0].Pure ) return Literal( call.Type, ConstValue.One );

				break;
			}

			case Intrinsic.Saturate when args.Length == 1 && args[0] is IrCall inner && inner.Id == Intrinsic.Saturate:
				return inner;

			case Intrinsic.Abs when args.Length == 1 && args[0] is IrCall abs && abs.Id == Intrinsic.Abs:
				return abs;

			case Intrinsic.Normalize when args.Length == 1 && args[0] is IrCall norm && norm.Id == Intrinsic.Normalize:
				return norm;

			case Intrinsic.Min when args.Length == 2 && args[0].Pure && Equals( args[0], args[1] ) &&
				args[0].Type == call.Type:
				return args[0];

			case Intrinsic.Max when args.Length == 2 && args[0].Pure && Equals( args[0], args[1] ) &&
				args[0].Type == call.Type:
				return args[0];
		}

		return null;
	}

	static bool TryEvaluate( Intrinsic id, ConstValue[] a, int[] widths, int width, out ConstValue value )
	{
		value = default;

		switch ( id )
		{
			case Intrinsic.Abs: value = Map( a[0], width, Math.Abs ); return true;
			case Intrinsic.Saturate: value = Map( a[0], width, x => Math.Clamp( x, 0, 1 ) ); return true;
			case Intrinsic.Frac: value = Map( a[0], width, x => x - Math.Floor( x ) ); return true;
			case Intrinsic.Ceil: value = Map( a[0], width, Math.Ceiling ); return true;
			case Intrinsic.Floor: value = Map( a[0], width, Math.Floor ); return true;
			case Intrinsic.Round: value = Map( a[0], width, x => Math.Round( x, MidpointRounding.ToEven ) ); return true;
			case Intrinsic.Trunc: value = Map( a[0], width, Math.Truncate ); return true;
			case Intrinsic.Sign: value = Map( a[0], width, x => Math.Sign( x ) ); return true;
			case Intrinsic.Rcp: value = Map( a[0], width, x => x == 0 ? double.NaN : 1.0 / x ); return true;
			case Intrinsic.Sqrt: value = Map( a[0], width, x => x < 0 ? double.NaN : Math.Sqrt( x ) ); return true;
			case Intrinsic.Rsqrt: value = Map( a[0], width, x => x <= 0 ? double.NaN : 1.0 / Math.Sqrt( x ) ); return true;
			case Intrinsic.Exp: value = Map( a[0], width, Math.Exp ); return true;
			case Intrinsic.Exp2: value = Map( a[0], width, x => Math.Pow( 2, x ) ); return true;
			case Intrinsic.Log: value = Map( a[0], width, x => x <= 0 ? double.NaN : Math.Log( x ) ); return true;
			case Intrinsic.Log2: value = Map( a[0], width, x => x <= 0 ? double.NaN : Math.Log2( x ) ); return true;
			case Intrinsic.Log10: value = Map( a[0], width, x => x <= 0 ? double.NaN : Math.Log10( x ) ); return true;
			case Intrinsic.Sin: value = Map( a[0], width, Math.Sin ); return true;
			case Intrinsic.Cos: value = Map( a[0], width, Math.Cos ); return true;
			case Intrinsic.Tan: value = Map( a[0], width, Math.Tan ); return true;
			case Intrinsic.Asin: value = Map( a[0], width, Math.Asin ); return true;
			case Intrinsic.Acos: value = Map( a[0], width, Math.Acos ); return true;
			case Intrinsic.Atan: value = Map( a[0], width, Math.Atan ); return true;
			case Intrinsic.Sinh: value = Map( a[0], width, Math.Sinh ); return true;
			case Intrinsic.Cosh: value = Map( a[0], width, Math.Cosh ); return true;
			case Intrinsic.Tanh: value = Map( a[0], width, Math.Tanh ); return true;
			case Intrinsic.Degrees: value = Map( a[0], width, x => x * ( 180.0 / Math.PI ) ); return true;
			case Intrinsic.Radians: value = Map( a[0], width, x => x * ( Math.PI / 180.0 ) ); return true;

			case Intrinsic.Min when a.Length >= 2: value = Map2( a[0], a[1], width, Math.Min ); return true;
			case Intrinsic.Max when a.Length >= 2: value = Map2( a[0], a[1], width, Math.Max ); return true;
			case Intrinsic.Pow when a.Length >= 2: value = Map2( a[0], a[1], width, Math.Pow ); return true;
			case Intrinsic.Atan2 when a.Length >= 2: value = Map2( a[0], a[1], width, Math.Atan2 ); return true;
			case Intrinsic.Step when a.Length >= 2: value = Map2( a[0], a[1], width, ( e, x ) => x >= e ? 1 : 0 ); return true;

			case Intrinsic.Fmod when a.Length >= 2:
				for ( int i = 0; i < width; i++ )
				{
					if ( a[1][i] == 0 ) return false;
				}

				value = Map2( a[0], a[1], width, ( x, y ) => x - y * Math.Truncate( x / y ) );
				return true;

			case Intrinsic.Clamp when a.Length >= 3:
				value = Map3( a[0], a[1], a[2], width, ( x, lo, hi ) => Math.Clamp( x, Math.Min( lo, hi ), Math.Max( lo, hi ) ) );
				return true;

			case Intrinsic.Lerp when a.Length >= 3:
				value = Map3( a[0], a[1], a[2], width, ( x, y, t ) => x + t * ( y - x ) );
				return true;

			case Intrinsic.Fma when a.Length >= 3:
			case Intrinsic.Mad when a.Length >= 3:
				value = Map3( a[0], a[1], a[2], width, ( x, y, z ) => x * y + z );
				return true;

			case Intrinsic.SmoothStep when a.Length >= 3:
				value = Map3( a[0], a[1], a[2], width, ( lo, hi, x ) =>
				{
					if ( hi == lo ) return x < lo ? 0 : 1;

					var t = Math.Clamp( ( x - lo ) / ( hi - lo ), 0, 1 );
					return t * t * ( 3 - 2 * t );
				} );

				return true;

			case Intrinsic.Dot when a.Length >= 2:
			{
				var sum = 0.0;
				var n = Math.Max( widths[0], widths[1] );

				for ( int i = 0; i < n; i++ ) sum += a[0][i] * a[1][i];

				value = ConstValue.From( (float)sum );
				return true;
			}

			case Intrinsic.Length when a.Length >= 1:
			{
				var sum = 0.0;

				for ( int i = 0; i < widths[0]; i++ ) sum += a[0][i] * a[0][i];

				value = ConstValue.From( (float)Math.Sqrt( sum ) );
				return true;
			}

			case Intrinsic.Distance when a.Length >= 2:
			{
				var sum = 0.0;
				var n = Math.Max( widths[0], widths[1] );

				for ( int i = 0; i < n; i++ )
				{
					var d = a[0][i] - a[1][i];
					sum += d * d;
				}

				value = ConstValue.From( (float)Math.Sqrt( sum ) );
				return true;
			}

			case Intrinsic.Normalize when a.Length >= 1:
			{
				var sum = 0.0;

				for ( int i = 0; i < widths[0]; i++ ) sum += a[0][i] * a[0][i];

				if ( sum <= 0 ) return false;

				var scale = 1.0 / Math.Sqrt( sum );
				value = Map( a[0], width, x => x * scale );

				return true;
			}

			case Intrinsic.All when a.Length >= 1:
			{
				var all = true;

				for ( int i = 0; i < widths[0]; i++ )
				{
					if ( a[0][i] == 0 ) all = false;
				}

				value = ConstValue.From( all );
				return true;
			}

			case Intrinsic.Any when a.Length >= 1:
			{
				var any = false;

				for ( int i = 0; i < widths[0]; i++ )
				{
					if ( a[0][i] != 0 ) any = true;
				}

				value = ConstValue.From( any );
				return true;
			}

			case Intrinsic.Select when a.Length >= 3:
				value = Map3( a[0], a[1], a[2], width, ( c, x, y ) => c != 0 ? x : y );
				return true;

			default:
				return false;
		}
	}

	// ---- numeric plumbing -------------------------------------------------

	static IrConst Literal( ShaderType type, ConstValue value ) => new( type, Canonical( Quantize( value, type ), type ) );

	/// <summary>
	/// Zero the lanes a type does not use.
	/// <para>
	/// <see cref="Map"/> deliberately evaluates all four components whatever the width, which keeps two
	/// structurally identical folds bit-identical — but it also means folding <c>rcp( float2( 2, 4 ) )</c>
	/// evaluates <c>1/0</c> for the two lanes the literal does not have and stores
	/// <c>(0.5, 0.25, NaN, NaN)</c>. <see cref="Literal"/> prints only the used lanes so the generated
	/// text is right, but <c>IrConst</c>'s record equality and <c>IrBuilder.StructuralHash</c> read all
	/// four — so that literal would never hash-cons or CSE against the same <c>float2(0.5, 0.25)</c>
	/// reached another way. Canonicalising here costs nothing and keeps equality meaning what it says.
	/// </para>
	/// </summary>
	static ConstValue Canonical( ConstValue value, ShaderType type )
	{
		if ( !type.IsScalarOrVector ) return value;

		return Width( type ) switch
		{
			1 => value with { Y = 0, Z = 0, W = 0 },
			2 => value with { Z = 0, W = 0 },
			3 => value with { W = 0 },
			_ => value
		};
	}

	static int Width( ShaderType type ) => Math.Clamp( type.Components, 1, 4 );

	static int Channel( char c ) => c switch
	{
		'x' or 'r' or 's' => 0,
		'y' or 'g' or 't' => 1,
		'z' or 'b' or 'p' => 2,
		'w' or 'a' or 'q' => 3,
		_ => -1
	};

	static bool HasRepeats( string mask )
	{
		for ( int i = 0; i < mask.Length; i++ )
		{
			for ( int j = i + 1; j < mask.Length; j++ )
			{
				if ( Channel( mask[i] ) == Channel( mask[j] ) ) return true;
			}
		}

		return false;
	}

	static bool IsIdentity( string mask, ShaderType type )
	{
		if ( mask.Length != Width( type ) ) return false;

		for ( int i = 0; i < mask.Length; i++ )
		{
			if ( Channel( mask[i] ) != i ) return false;
		}

		return true;
	}

	static bool IsFinite( ConstValue value, int width )
	{
		for ( int i = 0; i < width; i++ )
		{
			if ( double.IsNaN( value[i] ) || double.IsInfinity( value[i] ) ) return false;
		}

		return true;
	}

	/// <summary>Snap a folded value onto what its type can actually hold.</summary>
	static ConstValue Quantize( ConstValue value, ShaderType type )
	{
		if ( type.IsBoolean ) return Map( value, 4, x => x != 0 ? 1 : 0 );
		if ( type.IsIntegral ) return Map( value, 4, Math.Truncate );

		// Everything downstream is 32-bit; rounding here keeps the printed literal honest.
		return Map( value, 4, x => (float)x );
	}

	/// <summary>
	/// Apply a function component-wise. All four components are always evaluated — the unused ones are
	/// never printed, and doing so keeps two structurally identical folds bit-identical, which is what
	/// deterministic emission depends on.
	/// </summary>
	static ConstValue Map( ConstValue a, int width, Func<double, double> f )
	{
		_ = width;

		return new ConstValue( f( a.X ), f( a.Y ), f( a.Z ), f( a.W ) );
	}

	static ConstValue Map2( ConstValue a, ConstValue b, int width, Func<double, double, double> f )
	{
		_ = width;

		return new ConstValue( f( a.X, b.X ), f( a.Y, b.Y ), f( a.Z, b.Z ), f( a.W, b.W ) );
	}

	static ConstValue Map3( ConstValue a, ConstValue b, ConstValue c, int width,
		Func<double, double, double, double> f )
	{
		_ = width;

		return new ConstValue( f( a.X, b.X, c.X ), f( a.Y, b.Y, c.Y ), f( a.Z, b.Z, c.Z ), f( a.W, b.W, c.W ) );
	}
}