---
title: "The MSL backend"
url: https://recursiveai.org/oneshader/docs/msl-backend/
description: "How OneShader writes Metal Shading Language 2.4: functions, argument indices, push constants, function constants, wave intrinsics, precise, 16-bit types, what Metal can’t express, and how the output is checked."
version: "1.2.0"
source: https://github.com/recursive-ai-oss/oneshader-csharp/blob/8896e63be3a4f246295427451f15e4fcfb5535cf/docs/msl-backend.md
---

# The MSL backend

`MslBackend.Emit(IrModule, entryPointName, MslOptions?, DiagnosticBag)` writes one entry point, and everything it
reaches, as a single Metal Shading Language source file (language version 2.4 or newer; macOS 12 and iOS 15).
`Compile` with `CompileTarget.Msl` returns one UTF-8 text blob per entry point. Code is in `src/OneShader/Msl/`.
The same module and options always give the same text. Unsupported constructs are `OSH51xx` errors, never wrong code.

## Mapping decisions

- **Functions.** `vertex`, `fragment` and `kernel` functions. The entry function's name is the HLSL name, except
  reserved words (`main` becomes `main0`, `fragment` becomes `fragment0`): `MslBackend.EntryFunctionName`.
- **Push constants** are a `constant T& name [[buffer(n)]]` argument, where `n` is the next buffer index in declaration order
  like any constant buffer (recorded in reflection's `Msl` slot of the push block). The host fills it with `setVertexBytes`,
  `setFragmentBytes` or `setBytes`; the struct has the same byte layout as the SPIR-V block.
- **Module-scope variables** do not exist in MSL. Stage variables, `static` and `groupshared` variables become locals of
  the entry function; every helper that touches one (directly or through its callees) receives it as an extra reference
  parameter. Resources are entry-function arguments and are passed to helpers the same way.
- **Matrices.** HLSL `floatRxC` is MSL `floatRxC` (R columns of C components), so `m[i]` is row `i`, and `mul(a, b)` is
  `b * a`. Only float and half matrices exist in MSL; others are an error.
- **Buffers keep the byte layout of the SPIR-V target.** Every buffer struct is emitted with `packed_*` vectors, padded
  array elements and explicit `uchar padN[]` members so each member sits at its reflected offset; `bool` is a 32-bit
  `uint`. Matrices are stored as lines (columns when column-major) and converted when loaded or stored. Compile the
  output with `-DONESHADER_CHECK_LAYOUT` to turn on `static_assert`s of every struct size and member offset against the
  reflected layout.
- **Resource indices.** Buffers (constant, structured, byte-address), textures (including `Buffer<T>`) and samplers each
  count up from 0 in declaration order over the whole file, and an array of N resources takes N indices. All entry
  points of a file see the same indices; they are the `Msl` `TargetSlot` in reflection. Direct indices are used rather
  than argument buffers, because they need no encoder objects on the host and are trivially deterministic. Limits: 31
  buffers, 128 textures, 16 samplers per function (`OSH5105`). A vertex function with attributes reads them from buffer
  index `MslBackend.VertexBufferIndex` (30), one interleaved buffer using the reflected offsets and stride; resources
  may not use that index.
- **Stage I/O.** Vertex inputs are a `[[stage_in]]` struct with `[[attribute(n)]]`; vertex outputs and fragment inputs
  use `[[user(locnN)]]` with `[[flat]]`, `[[center_no_perspective]]`, `[[centroid_perspective]]`, `[[sample_perspective]]`;
  fragment outputs use `[[color(n)]]`, `[[depth(any)]]`, `[[sample_mask]]`. `SV_VertexID`/`SV_InstanceID` are
  `[[vertex_id]]` and `[[instance_id]]`, which on Metal already include the draw's base vertex and base instance, as
  `VertexIndex`/`InstanceIndex` do in the SPIR-V target (`tools/metal-runner/probe-draw-ids.swift` shows it). Clip distances are
  `[[clip_distance]]`. Clip-space Y points up in Metal and down in Vulkan: flip with the viewport, the shader does not.
- **Textures.** Textures used with comparison sampling become `depth*` types. Read-write textures get `read`, `write`
  or `read_write` access from their use. Array layers are split from the coordinate. Comparison samplers need their
  compare function set on the host sampler.
- **Control flow.** Rebuilt from the IR's merge/continue markers; short-circuit branches without markers use
  post-dominators. The loop's continue block is repeated before each `continue`.
- **Atomics** cast the address to `atomic_int`/`atomic_uint` with relaxed ordering. Compare-exchange loops over spurious failure.

- **Resource arrays** (`Texture2D T[4]`, `SamplerState S[2]`, arrays of structured, byte-address and constant buffers) are
  `array<texture2d<float>, 4>`, `array<sampler, 2>`, `array<device T*, N>` and `array<constant T*, N>` arguments; element
  `i` has Metal index `base + i`, which is what reflection reports as the slot of the array (its first index).
  `NonUniformResourceIndex(i)` is just `i`: Metal needs no hint.
- **Append and consume buffers** use the hidden counter buffer as a second buffer argument, like the SPIR-V target:
  `Append` adds one with an atomic and stores at the old value, `Consume` subtracts one and loads. `GetDimensions` on
  them is the same error as on any structured buffer.

## Wave and quad intrinsics

SIMD-group and quad-group functions (Metal 2.1 and later): `simd_sum`, `simd_product`, `simd_min`/`max`, `simd_and`/`or`/`xor`, `simd_prefix_exclusive_sum`/`product`, `simd_any`/`all`, `simd_ballot` (the 64-bit vote is split into the `uint4` HLSL returns), `simd_is_first`, `simd_shuffle` (`WaveReadLaneAt`), `simd_broadcast_first`, `quad_shuffle_xor` (`QuadReadAcross*`) and `quad_shuffle` (`QuadReadLaneAt`). `WaveGetLaneIndex` and `WaveGetLaneCount` read the `[[thread_index_in_simdgroup]]` and `[[threads_per_simdgroup]]` entry arguments once; helper functions get them as lifted parameters. `WaveActiveAllEqual` compares with `simd_broadcast_first`; the count-bits functions sum or scan `uint(condition)`.

## Specialization constants

A constant is declared at file scope as `constant T Name [[function_constant(N)]];` (N is the HLSL `constant_id`, MSL 2.4 section 5.8.1) and read through
`constant T Name_value = is_function_constant_defined(Name) ? Name : default;`, so a host that sets nothing gets the HLSL default. A function that declares
function constants cannot build a pipeline state as it is, even when every default is wanted: create it with
`makeFunction(name:constantValues:)`, using an empty `MTLFunctionConstantValues` for the defaults. `SpecializationValues.ToMetal()` gives index, `MTLDataType`
number (float 3, int 29, uint 33, bool 53) and the 32-bit pattern for `setConstantValue(_:type:index:)`; a Metal bool is one byte. Only the constants an
entry point reads are declared.

## 16-bit and 64-bit types

`half`, `short`, `ushort`, `long` and `ulong` are the native MSL types (with `Enable16BitTypes`; without it `half` and min-precision are 32-bit).
The only 64-bit atomics are `atomic_max_explicit` and `atomic_min_explicit` on an `atomic_ulong` in device memory, returning void (MSL
spec 6.16.4.6, Apple silicon); the backend emits exactly that for unsigned `InterlockedMax`/`InterlockedMin` on a buffer whose result is unused. Any other 64-bit
atomic, any use of `double`, and 64-bit integer vectors in buffers (no packed long vectors) are errors (OSH5102) at the line of the first use. Min-precision
`RelaxedPrecision` has no MSL equivalent: values are computed in 32 bits.

## Not supported (errors)

`double` (Metal has no 64-bit float); `SV_CullDistance`; `GetDimensions` on structured buffers (`ArrayLength`: Metal cannot
query it); atomics on textures (Metal 3.1); `WaveMatch` and `WaveMultiPrefix*` (front-end error); the lane index and count in vertex shaders (Metal has no SIMD-group attribute for them); integer/bool matrices; 64-bit integer vectors in
buffers; unbounded resource arrays; jumps out of several nested loops at once; comparison sampling on 1D/3D textures; texel offsets on cube/1D textures; overlapping `packoffset` members.

Known deviations: `discard` in a helper that was not inlined calls `discard_fragment()` and returns, so the caller keeps running with writes suppressed; memory-only barriers are emitted as full threadgroup barriers (MSL 2.4 has no fence); signed integer
overflow is undefined in MSL where HLSL wraps; Metal enables fast-math by default, so NaN and infinity handling is not
guaranteed unless the host disables fast math.

`precise` is honoured whatever the host's math mode. A function with precise arithmetic starts with
`#pragma clang fp reassociate(off)` and `#pragma clang fp contract(off)` (no fused multiply-add, no reordered sums; measured with
`xcrun metal`: fast math sets `fast` on every operation, these two pragmas remove `reassoc` and `contract`), and a precise division is
`precise::divide`, because there is no pragma that stops `a / b` becoming `a * (1 / b)`. The flags `nnan`, `ninf`, `nsz`, `arcp` and
`afn` stay set under fast math. `tools/run-metal-parity.sh run` with `ONESHADER_METAL_MATH=fast` and `safe` both match DXC on
`lang_precise_math`; without the pragmas the fast run differs in 11 bytes.

A `switch` whose cases fall through is written as a C++ `switch` with the same fall-through; values of the case's phis that arrive
from the switch are assigned before it, and a `break` or `continue` inside a nested `if` in a case is written out.

## Checking

`tools/validate-msl.sh <dir>` compiles every `.metal` file in a directory with `xcrun -sdk macosx metal -c` (layout
asserts on) and packs it with `metallib`. Produce the files with
`ONESHADER_MSL_OUT=<dir> dotnet test tests/OneShader.Tests --filter-method '*MslBackendTests*'` (optimized files in
`<dir>`, unoptimized in `<dir>/O0`).

## Running on a Metal GPU

`tools/run-metal-parity.sh` runs every corpus case as MSL on a Metal device and compares it with DXC's SPIR-V on Vulkan.
Three steps share one exchange directory: `export` (a machine with Vulkan and DXC) writes the MSL, the inputs and the
reference outputs; `run` (a Mac with Xcode, no .NET needed) builds `tools/metal-runner` and executes each case with Metal API
and shader validation on, so a validation message fails the case; `compare` prints the per-case table. Allowed differences
are in `corpus/metal-tolerances.txt`; everything else must match as the Vulkan parity suite requires.

The runner draws with a viewport of negative height. Clip-space Y points up in Metal and down in Vulkan, and the corpus
shaders are written for Vulkan; the flipped viewport puts the same vertex positions on the same pixels (including
`SV_Position`). The mirrored viewport reverses the winding Metal sees, so Vulkan's counter-clockwise front face is also
counter-clockwise on Metal (back-face culling on `pbr_forward` shows it). Depth range and the
texture origin already agree. Vertex buffer binding N is Metal buffer 30 - N, described to Metal with a vertex descriptor.
