Native XIR to SPIR-V
SkillDev toolsNative Vulkan XIR-to-SPIR-V codegen, legalization, validation, bindings, control flow, and target features.
Available today. Use it from your connected AI after setup.
No other account needed.
Connect ahel once, and every AI you use reads what you have installed.
Then ask your AI: use the Native XIR to SPIR-V skill
What this skill tells your AI
The instructions your AI receives, as published by luisagroup/luisacompute in .agents/skills/spirv_codegen/SKILL.md and read by ahel’s review.
The native code generator lives in
src/backends/common/spirv/spirv_codegen/ and lowers XIR to SPIR-V 1.5 with
glslang's spv::Builder. Vulkan enables it with
LUISA_COMPUTE_ENABLE_VK_XIR_SPIRV (CMake) or
lc_vk_backend_use_xir_spirv (xmake).
This skill describes the native path, not the separate
AST -> LLVM -> SPIR-V implementation in spirv_llvm/.
Source map
| Files | Responsibility |
|---|---|
entry.h/.cpp | Public compile entries, target-feature state, result assembly, validation and optimization |
utils.h/.cpp | AST -> XIR and the backend legalization pipeline |
dialect.h/.cpp | Fail-closed XIR handoff validation |
pointer_legalization.h/.cpp | SPIR-V callable ABI specialization policy |
argument_usage.h/.cpp | Fixed-point function-argument usage shared by legalization and emission |
bindless_usage.h/.cpp | Exact global-heap and per-array metadata requirements by resource opcode |
texture_sampling.h/.cpp | Canonical sampler selector, image dimensionality, and target-contract planning |
structural_closure.h/.cpp | Canonical backend-emitted block closure and malformed-role diagnostics |
call_graph_validation.h/.cpp | Kernel-reachable, callee-before-caller function graph |
control_flow_plan.h/.cpp | Immutable logical-to-physical structured-CFG plan |
instruction_layout.h/.cpp | SPIR-V instruction word-count limits, including OpSwitch and OpPhi |
buffer_layout.h/.cpp | Vulkan typed-SSBO layout compatibility and word-storage fallback planning |
aggregate_index.h/.cpp | Typed GEP/extract/insert index planning and struct-index canonicalization |
optimizer.h/.cpp | SPIRV-Tools validation and optimization presets |
target_feature_mask.h | Persisted required-feature bit contract |
target_features.h | Logical-device feature snapshot and pure lowering decisions |
runtime_target_plan.h/.cpp | Pre-binding descriptor, ray-query, subgroup, and bindless runtime contract |
kernel_argument_role.h | Stable per-argument native acceleration-structure role bits |
src/backends/vk/shader_artifact_codec.h/.cpp | Canonical Vulkan shader-artifact writer/parser, integrity checks, SPIR-V validation and feature reconciliation |
bind.cpp | Descriptor properties, argument buffer, bindless heaps, constant UBO |
type.cpp | Logical and storage-layout type conversion |
emit.cpp | Module/function/block emission, prologues, native Phi emission |
condition_inst.cpp | Emission from the frozen control-flow plan |
instruction.cpp | Non-control instructions, resources, atomics, ray query, builtins |
CMake glob-registers spirv_codegen/*.cpp in luisa-compute-spirv; xmake
does the same for lc-spirv.
Compilation contract
There are two supported entries:
SpirvResult compile_spirv(Function kernel, const ShaderOption &,
SpirvTargetFeatures);
SpirvResult compile_spirv_xir(Function kernel, const xir::Module *,
const ShaderOption &,
SpirvTargetFeatures);
compile_spirv translates and legalizes AST input. compile_spirv_xir is for
an already legalized module and is used by exact-XIR backend tests. The AST
Function remains the external descriptor/argument ABI in both cases.
The compile sequence is:
- Validate the AST/XIR kernel ABI and the native SPIR-V XIR dialect. Generic XIR validity remains whole-module; backend-specific work uses the canonical kernel-reachable call graph and each reachable function's structural closure.
- Freeze the reachable functions, used types/constants, exact bindless usage, atomic-buffer representation, atomic target contract, sampler contract, and runtime-only target-feature plan before descriptor or instruction emission.
- Compute fixed-point argument usage and exact per-argument acceleration and bindless-metadata roles. Merge only the public synchronization usage that is deliberately conservative; descriptor roles remain optimized-XIR exact.
- Generate descriptor properties, the internal argument block, global heaps, and per-argument resource globals from those immutable plans.
- Emit reachable functions through immutable control-flow plans.
- Dump the module and validate it for
SPV_ENV_VULKAN_1_2. - Run the selected SPIRV-Tools optimizer preset and commit its output only if optimization and validation both succeed.
- Validate the selected final binary, reconcile capability-owned target bits, then return the exact properties, argument usage/roles, and feature mask that serialization and runtime binding consume.
The pre-optimization module must be valid. The optimizer is never a repair step for malformed SPIR-V.
The same rule applies inside emission: generic XIR, the native dialect, and
the frozen AST/XIR ABI are hard invariants. Do not coerce invalid arithmetic
operands or synthesize missing kernel arguments. Assert if a verified boolean
select or planned argument buffer does not lower as expected.
_ensure_type is an internal same-shape numeric conversion helper, not a
general cast operation: boolean conversion belongs to XIR STATIC_CAST, and
unsupported class or shape pairs must fail rather than fall through to
OpBitcast.
Emit ordinary XIR arithmetic as ordinary SPIR-V instructions. Do not turn
constant operands into OpSpecConstantOp: the runtime exposes no matching
specialization-constant ABI. Keep optional integer constant folding and
strength reduction in the XIR/SPIRV-Tools optimization layers instead of
embedding partial, always-on peepholes in instruction selection.
SpirvCodegenEntry owns its spv::Builder normally. Its destructor first
clears maps keyed by XIR objects, then the builder is destroyed. Do not add a
release() leak workaround.
AST to XIR legalization
The mandatory final pipeline in utils.cpp establishes backend semantics even
when optional optimization is disabled:
- Lower ray-query loops to ordinary loop form.
- Promote safe read-only reference arguments.
- Optionally optimize still-structured XIR.
- Run
destructure_cfg. - Run SPIR-V pointer/resource-call specialization.
- Optionally run ordinary inlining and scalar/SSA cleanup.
- Temporarily run
reg2membecauserestructure_cfgcurrently requires Phi-free raw CFG. - Run
restructure_cfg. - Clear payload only from blocks outside ordinary all-edge reachability and
immediately run
mem2reg, then audit that no typed reg2mem spill provenance remains; retain every block identity. - Fix self-referential values and validate the dialect.
The temporary reg2mem is a boundary adapter for restructuring, not the
codegen representation. Do not add a final blanket Phi elimination. Native
OpPhi emission owns reconstructed SSA and avoids imposing avoidable memory
traffic on the generated module or on spirv-opt.
Every temporary slot created by generic reg2mem carries
Reg2MemSpillMD with PHI or CROSS_BLOCK provenance. Preserve this typed
metadata through instruction cloning, SROA splitting, and XIR text/bitcode
round-trips; names and comments are diagnostic only. Ordinary final
legalization recovers SSA immediately after restructuring. Pre-autodiff
legalization is the intentional exception: autodiff requires Phi-free IR, so
typed spills remain in memory form across autodiff and SROA. The final
post-restructure audit then examines every metadata owner and fails if any
marker remains or is misplaced. The exact-XIR dialect independently rejects a
tagged alloca in its active structural closure. Untagged user local allocas
remain a supported XIR/SPIR-V construct and must not be rejected by this
boundary.
Generic multi-block inlining must run after destructure_cfg. A target-specific
exception belongs in pointer_legalization.cpp, which uses the generic atomic
inline_call_sites_pass_run_on_module primitive only after preflighting the
whole selected batch.
Dialect boundary
validate_spirv_xir_codegen_dialect is the single fail-closed handoff. When
adding an XIR opcode or type:
- classify it explicitly as supported, semantic no-op, or unsupported;
- validate operand count, types, storage class and backend semantic limits;
- add a focused diagnostic rather than relying on an emitter assertion;
- update the complete opcode-matrix test;
- add an exact codegen test when the construct is accepted.
Keep two scopes deliberately separate. Generic xir_verify_module validity is
a whole-module contract, including unused definitions and orphan blocks. The
native dialect, target/resource planners, and emitter consume only the
canonical kernel-reachable function graph and each function's structural
closure. Never weaken generic verification to reachable-only, and never let an
unused callable or true orphan manufacture descriptors, target features, or
emitted SPIR-V.
Important type boundaries include:
OpTypeArraylength is strictly positive; XIR may still represent a zero-length host type, so the SPIR-V dialect rejects it;- storage
ArrayStrideis strictly positive; buffers with zero-sized elements and nested storage arrays with zero-sized elements are rejected; SPV_EXT_float8permits FP8 values only in its listed transport, storage, conversion, composite and selection instructions. General FP8 arithmetic and comparisons remain invalid. Do not classifyOpTransposeas transport: it belongs to the matrix-instruction category, which the extension does not admit. FP8-to-bool casts must widen each scalar lane to float32 before the unordered comparison so NaN remains truthy;- texture dimensions are 2 or 3 and scalar elements are float32/int32/uint32;
- texture writes consume exactly the verifier-mandated four-component texel vector. The emitter must not pad, smear, or otherwise repair a scalar or short-vector payload that crossed the dialect boundary;
- opaque ray-query values have deliberately restricted lifetime and argument rules;
- kernel reference arguments are not part of the Vulkan descriptor ABI.
Structural closure and inactive payload
All backend analyses and emission use
plan_spirv_codegen_structural_closure. It contains the ordinary CFG reachable
from the function body plus every raw structured role block recursively owned
by that CFG. Function-owned blocks outside this set are true orphans and do not
participate in exact native emission, descriptor analysis, uniformity, or
planner ownership.
A raw role block can belong to the structural closure without being ordinarily reachable. Exact XIR emission accepts such a disconnected role payload only when it has a self-contained, flat value/lifetime contract: no Phi, nested structured owner, Break/Continue, opaque ray-query state, cross-block instruction value, forward same-block use, or branch re-entry. Its terminator must be Return or Unreachable. The emitter and resource/callable analyses must still inspect accepted payload because direct exact-XIR codegen has not run a dead-payload pass.
glslang's mandatory postProcess(false) subsequently rewrites an
ordinary-unreachable merge or continue target to its canonical unreachable
form, so instructions from that payload do not appear in the dumped SPIR-V.
Boundary tests must prove structural closure, callable discovery, and resource
planning before this canonicalization, then expect the dead payload opcodes to
be absent from the final validated binary.
After restructure_cfg, every executable transfer is explicit.
clear_spirv_codegen_inactive_block_payloads may therefore replace payload in
both true orphans and ordinary-unreachable role blocks with Unreachable before
mem2reg. These categories remain distinct: a true orphan is outside the
exact-emission closure, while a disconnected role keeps its structural block
identity even when this mandatory legalization proves its payload dead. Do not
make optional DCE determine closure membership or merge ownership.
Callable pointer and resource legalization
SPIR-V policy must not leak into the generic XIR inliner. The backend computes fixed-point argument usage, then specializes only call sites whose retained callable ABI cannot be represented safely.
Current rules:
- a reference formal receiving a function-local
AllocaInstor compatible reference argument may remain a Function pointer; - a shared alloca is Workgroup storage and must be specialized;
- indirect-dispatch buffer arguments are always specialized;
- used buffer and bindless resource formals are specialized into the call site;
- a writable acceleration structure is specialized;
- a texture used for both read and write is specialized;
- a genuinely unused resource formal need not force specialization.
Legalization is fixed-point because inlining one layer can expose a pointer at another layer. It must preflight recursion, call shape, structured boundaries, and all selected sites before mutating any function. Ordinary switches that do not block a selected inline remain native switches.
Do not solve callable ABI failures by enabling VariablePointers globally.
Descriptor-backed buffer/bindless arguments are specialized, and only safe
opaque/resource modes remain as callable parameters.
Structured control flow and Phi
ControlFlowPlan freezes the final physical graph before instruction emission.
It owns:
- reverse-post-order block schedule;
- construct headers, merge targets and continue targets;
- synthetic loop headers, continues and edge trampolines;
- source-sensitive merge routing;
- physical loop-boundary validation;
- logical Phi incoming paths through synthetic forwarding blocks.
Emission preallocates every physical block. It then predeclares one OpPhi in
the result block and, where necessary, auxiliary OpPhi nodes in forwarding
blocks. Incoming values are resolved at the logical predecessor tail before
its terminator. Finalization checks that planned and actual physical
predecessor sets are identical.
Rules:
- the physical SPIR-V function entry has no predecessor and no Phi;
Loop.preparehas exactly one non-nullBasicBlockrole operand and has exactly one of two terminator forms:Branch(Loop.body)orConditionalBranch(bool, Loop.body, Loop.merge); both are native loop headers and lower directly toOpLoopMergefollowed by the matching branch;- an
OpPhimust be the first non-line instruction in its block; - every physical predecessor appears exactly once;
- a loop header has one entry and one backedge, and the backedge passes through the declared continue target;
- one SPIR-V merge block cannot be owned by multiple constructs;
- forwarding a logical incoming through a synthetic edge requires a Phi in that forwarding block, not reuse of a non-dominating value;
- use
plan_spirv_phi_instructionandplan_spirv_switch_instructionbefore allocating variable-length instructions; SPIR-V word count is 16-bit.
Never rediscover or redirect edges during emission. Add facts to
ControlFlowPlan, validate them there, then consume the frozen plan.
Types and layout literals
_convert_type handles logical SSA types. _convert_laid_out_type recursively
decorates buffer payloads with Offset, ArrayStride, ColMajor, and
MatrixStride.
SPIR-V layout and binding literals are unsigned 32-bit words. glslang's
single-literal convenience overload takes int; do not route a wide ABI value
through it. Use the vector<unsigned> decoration overload for variable
offsets, strides, descriptor sets, and bindings. For makeArrayType, a small
nonzero third argument may mark the type explicitly laid out, followed by the
real unsigned ArrayStride decoration.
Logical bool has no StorageBuffer representation. A second mismatch comes from
64-bit three- and four-component vectors: Vulkan gives them 32-byte standard
storage alignment, while Luisa host vector alignment is capped at 16 bytes.
plan_spirv_typed_buffer_layout recursively checks matrix/array strides,
structure member offsets, structure stride, and the outer runtime-array stride.
Any incompatible non-atomic Buffer<T> uses the byte-exact uint32 word ABI.
Atomic analysis consumes the same layout decision and selects one
representation per Buffer<T> before type conversion; a 64-bit integer atomic
that requires typed storage conflicts with a layout that requires word storage
and must fail at the handoff. Never enable scalar-block layout implicitly: the
runtime does not request that Vulkan feature as part of this ABI.
Direct-buffer StorageBufferMetadata carries a runtime descriptor bias, but
the Vulkan argument preprocessor proves that a typed buffer view's offset and
size are exact multiples of its logical element stride. Preserve the resulting
gcd(element_size, 4) alignment in word-storage reads and writes. Dropping it
to one byte needlessly expands ordinary aligned stores into masked atomic-CAS
loops. Raw byte-buffer operations have no such proof and remain alignment one.
XIR atomics specify atomicity but expose no memory-order operand. Emit SPIR-V
atomics with Relaxed memory semantics while retaining the pointer-derived
Device or Workgroup scope. This matches CUDA/HIP Monotonic and fallback
__ATOMIC_RELAXED; block synchronization and runtime resource barriers own
visibility ordering. Do not attach AcquireRelease or broad memory-class bits
to every RMW: that silently strengthens the cross-backend contract and can
serialize unrelated atomics. Compare-exchange success and failure semantics,
including software float CAS loops, are both relaxed.
Large array constants may use the portable constant UBO planner. Only layouts with an exact host-to-std140 serializer are eligible. Planning is checked for alignment, multiplication, cumulative range, and the portable 16 KiB limit.
Aggregate indices
All GEP, dynamic extract/insert, and atomic address walks use
plan_spirv_aggregate_indices.
- array/vector/matrix/buffer indices retain their legal integer value IDs;
- structure indices must be constant and are canonicalized to unsigned 32-bit
OpConstantIDs; - planning validates the whole walk before emission;
- usage analysis must track the canonical emitted index, not keep an otherwise dead narrow or 64-bit source constant alive.
Bindings and Vulkan ABI
hlsl::Property is a persisted ABI shared by codegen, serialization, Vulkan
layout creation, and dispatch binding. The backend uses:
- set 0: dense local descriptors;
- set 1: sixteen immutable samplers;
- sets 2+: enabled buffer, 2D texture, and 3D texture heaps in that order;
ConstantValue: a descriptor-free push-constant pseudo-property.
All consumers must ignore ConstantValue during descriptor lookup regardless
of property order. Writer and reader both validate the canonical property
shape. Runtime planning separately checks ordinary descriptor limits,
update-after-bind aggregate limits, acceleration-structure limits, set count,
and vkGetDescriptorSetLayoutSupport.
Keep internal emission roles explicit beside each public property. Debug names
such as _Global, _bdarr_*, tex2d_heap, and tex3d_heap may describe the
generated module, but must not select argument-buffer IDs, bindless table
types, texture dimensions, or heap IDs. Unknown public property kinds fail
closed before a NoResult ID can enter the interface list.
Native direct-buffer views use an internal argument buffer. Non-resource
values are host-layout packed first; StorageBufferMetadata records follow at
their natural alignment. HLSL debug validation words use a mutually exclusive
trailer. The common checked argument-block planner is the source of truth for
both sizing and emission.
Storage-buffer alias decorations describe backing memory, not merely one
descriptor access path. Luisa permits the same buffer or overlapping views to
be supplied through multiple arguments, including imported native resources
and bindless arrays. Emit Aliased on every user-bindable storage-buffer
declaration that may participate. A read-only declaration may carry
NonWritable only when the module has no writable user-resource path that
could alias its backing memory; include direct buffers, bindless buffer stores,
writable accel-instance storage, writable textures/external memory, and the
custom indirect-dispatch buffer in that proof. Do not infer immutability from
the declaration's local Usage::READ alone.
Volatile direct-buffer accesses require three matching SPIR-V facts: a
Volatile memory operand on each load/store, the backend's matching device
fence, and Coherent on that exact buffer declaration. Propagate coherence as
an exact fixed-point argument role through callables; do not mark every buffer
with the same element type coherent, because that needlessly disables caching
for unrelated resources. Coherent is not a substitute for an uncertain alias
contract: omit NonWritable when a read declaration may alias writable user
memory, but do not make it coherent. The backend-owned _Global argument block
and bindless metadata blocks cannot alias user resources; keep them
NonWritable and do not decorate them Aliased or Coherent.
Ordinary XIR LoadInst and StoreInst are exact-typed memory operations: the
address is an lvalue of the loaded/stored type, and a stored value is an
rvalue. Enforce that contract at the dialect handoff. Do not smear scalars or
insert bitcasts in the SPIR-V emitter to make a mismatched store validate;
those conversions manufacture semantics for invalid XIR and can hide an
upstream pass defect.
Bindless buffer planning has two independent facts. A real bindless read/write
needs the global unbounded buffer heap and the matching array's local metadata
descriptor; a size-only query needs only that local metadata descriptor.
SpirvResult::useBufferBindless therefore means the global heap only. Fixed-
point argument analysis emits SPIRVBindlessBufferMetadata only beside each
bindless argument that actually needs it; do not turn this back into one
module-wide optional descriptor per bindless argument.
The native XIR dialect currently accepts only ordinary MULTIPLE bindless
layout operations whose uniformity can be proven from XIR. Typed and explicit
uniform-index AST operations remain honest HLSL fallback reasons: XIR resource
instructions do not yet preserve the typed slot layout or the caller's
uniform-index promise. Do not erase those route guards or map typed operations
onto ordinary resource ops. Native support requires first-class orthogonal
layout/index-mode flags through AST↔XIR, cloning, verification, text/bitcode,
callable argument analysis, persisted Vulkan argument roles, runtime layout
checking, and SPIR-V slot resolution before the fallback can be relaxed.
For a divergent descriptor lookup, apply NonUniformEXT to the actual
descriptor-array index and preserve it through the resulting access-chain
pointer, descriptor load, and consuming image/sampler value as required. Do
not decorate prefix structure/array indices. Those are commonly interned
constants such as zero; decorating one contaminates every unrelated use of the
same module-global SPIR-V ID and may unnecessarily pessimize driver analysis.
Acceleration structures likewise have two independent native roles: traversal
uses SPIRVAccel, while instance-property access uses the separate
SPIRVAccelInstance/SPIRVAccelInstanceRW buffer. SpirvResult::argument_roles
is parallel to argument_usages and persists the exact role mask in
SavedArgument::resource_aux. A zero role mask is valid for an unused native
accel. The all-ones sentinel means an older/non-native artifact whose role is
unspecified; native serialization and dispatch must never infer optional accel
descriptors greedily from neighboring properties.
Ray-query traversal
Shortened here. Read the whole file on GitHub.
Signals
- GitHub stars
- 1k
- Forks
- 108
- Last commit
- Sep 2026
Advanced
- Catalog kind
- skill
- Gateway key
spirv-codegen- Source
- github.com/luisagroup/luisacompute