Native XIR to SPIR-V

SkillDev tools

Native Vulkan XIR-to-SPIR-V codegen, legalization, validation, bindings, control flow, and target features.

Available today. Use it from your connected AI after setup.

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

FilesResponsibility
entry.h/.cppPublic compile entries, target-feature state, result assembly, validation and optimization
utils.h/.cppAST -> XIR and the backend legalization pipeline
dialect.h/.cppFail-closed XIR handoff validation
pointer_legalization.h/.cppSPIR-V callable ABI specialization policy
argument_usage.h/.cppFixed-point function-argument usage shared by legalization and emission
bindless_usage.h/.cppExact global-heap and per-array metadata requirements by resource opcode
texture_sampling.h/.cppCanonical sampler selector, image dimensionality, and target-contract planning
structural_closure.h/.cppCanonical backend-emitted block closure and malformed-role diagnostics
call_graph_validation.h/.cppKernel-reachable, callee-before-caller function graph
control_flow_plan.h/.cppImmutable logical-to-physical structured-CFG plan
instruction_layout.h/.cppSPIR-V instruction word-count limits, including OpSwitch and OpPhi
buffer_layout.h/.cppVulkan typed-SSBO layout compatibility and word-storage fallback planning
aggregate_index.h/.cppTyped GEP/extract/insert index planning and struct-index canonicalization
optimizer.h/.cppSPIRV-Tools validation and optimization presets
target_feature_mask.hPersisted required-feature bit contract
target_features.hLogical-device feature snapshot and pure lowering decisions
runtime_target_plan.h/.cppPre-binding descriptor, ray-query, subgroup, and bindless runtime contract
kernel_argument_role.hStable per-argument native acceleration-structure role bits
src/backends/vk/shader_artifact_codec.h/.cppCanonical Vulkan shader-artifact writer/parser, integrity checks, SPIR-V validation and feature reconciliation
bind.cppDescriptor properties, argument buffer, bindless heaps, constant UBO
type.cppLogical and storage-layout type conversion
emit.cppModule/function/block emission, prologues, native Phi emission
condition_inst.cppEmission from the frozen control-flow plan
instruction.cppNon-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:

  1. 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.
  2. 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.
  3. 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.
  4. Generate descriptor properties, the internal argument block, global heaps, and per-argument resource globals from those immutable plans.
  5. Emit reachable functions through immutable control-flow plans.
  6. Dump the module and validate it for SPV_ENV_VULKAN_1_2.
  7. Run the selected SPIRV-Tools optimizer preset and commit its output only if optimization and validation both succeed.
  8. 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:

  1. Lower ray-query loops to ordinary loop form.
  2. Promote safe read-only reference arguments.
  3. Optionally optimize still-structured XIR.
  4. Run destructure_cfg.
  5. Run SPIR-V pointer/resource-call specialization.
  6. Optionally run ordinary inlining and scalar/SSA cleanup.
  7. Temporarily run reg2mem because restructure_cfg currently requires Phi-free raw CFG.
  8. Run restructure_cfg.
  9. 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.
  10. 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:

  • OpTypeArray length is strictly positive; XIR may still represent a zero-length host type, so the SPIR-V dialect rejects it;
  • storage ArrayStride is strictly positive; buffers with zero-sized elements and nested storage arrays with zero-sized elements are rejected;
  • SPV_EXT_float8 permits FP8 values only in its listed transport, storage, conversion, composite and selection instructions. General FP8 arithmetic and comparisons remain invalid. Do not classify OpTranspose as 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 AllocaInst or 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.prepare has exactly one non-null BasicBlock role operand and has exactly one of two terminator forms: Branch(Loop.body) or ConditionalBranch(bool, Loop.body, Loop.merge); both are native loop headers and lower directly to OpLoopMerge followed by the matching branch;
  • an OpPhi must 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_instruction and plan_spirv_switch_instruction before 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 OpConstant IDs;
  • 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