Skip to content

Preserve Julia's struct layouts in SPIR-V on 32-bit x86 - #1063

Open
maleadt wants to merge 2 commits into
tb/runtime-sretfrom
tb/spirv32-layout
Open

maleadt wants to merge 2 commits into
tb/runtime-sretfrom
tb/spirv32-layout

Conversation

@maleadt

@maleadt maleadt commented Oct 25, 2026

Copy link
Copy Markdown
Member

Stacked on #1062.

On 32-bit x86 Linux, Julia aligns Int64 and Float64 to 4 bytes: struct Params; a::Int32; b::Int64; c::Float64; d::Int32; end has its fields at offsets 0, 4, 12 and 20, and is 24 bytes. SPIR-V kernels compiled for such a host read those structs wrong, in two ways.

The spirv32 data layout aligned i64 and double to 8, and Julia 1.10 and 1.11 map byte offsets onto LLVM struct elements using the module's data layout. On Julia 1.10, the IR GPUCompiler produced for p.b + p.c + p.d loaded p.b from offset 0, p.c from 8 and p.d from 16. On a KernelAbstractions PoCL kernel with Params(1, 1000, 0.5, 7), p.b came out as (1000 << 32) | 1. Julia 1.12 addresses fields by byte offset, but array strides, allocas, aggregate loads and stores and globals still follow the data layout.

SPIR-V also carries no data layout: consumers lay out OpTypeStructs by OpenCL C's rules, which align long and double to 8. So even with Julia's byte offsets, the struct types of kernel arguments, allocas and globals describe memory that the host lays out differently. KernelAbstractions passes its kernel state as a byval struct to every kernel, and any user struct with an Int64 or Float64 field after a smaller one is affected.

This PR generates code for Julia's alignments, and before handing the module to the back-end, replaces every struct type whose layout differs from the consumers' by a packed struct with explicit padding wherever it describes memory: kernel arguments (when wrap_byval wraps them), other byval-like arguments, allocas and globals with their initializers. Addresses into such structs become byte offsets, and aggregate loads and stores are split into their parts. Both SPIR-V back-ends encode packed structs as CPacked, which the translator, IGC, rusticl and PoCL honour. This is what Clang does when compiling single-source offload code (HIP, SYCL) to spirv32 for an i386 host: it uses the host's alignments for the device and packs records that LLVM's natural layout can't express. Aggregate values in registers keep their types, and on 64-bit hosts, where both layouts agree, nothing changes.

Rewriting every struct type into a packed one with renumbered fields (instead of byte addressing) would have to touch every GEP, extractvalue and function signature, and with typed pointers every pointer type. Accesses to an 8-byte field at a 4-byte offset remain underaligned, which OpenCL leaves undefined but CPU devices (PoCL) handle. Splitting them is left for when a strict device on a 32-bit host shows up. Int128 is not handled: Julia 1.12 aligns it to 16 but neither data layout says so, which also affects 64-bit hosts and needs a separate change (SPIR-V has no 128-bit integers without an extension anyway).

The second commit fixes the triple of the Khronos back-end on 32-bit hosts: it was spirv-unknown-unknown, which LLVM treats as logical SPIR-V with 64-bit pointers, so the translator emitted the Physical64 addressing model, which 32-bit OpenCL devices reject. It is now spir-unknown-unknown.

The tests check that LLVM's field offsets under the SPIR-V target's data layout match fieldoffset, the address a consumer computes for a field of an array element, that the SPIR-V uses CPacked exactly when the host aligns Int64 to 4, the addressing model of both back-ends, and the rewriting itself on hand-written IR with typed and opaque pointers. The SPIR-V suites pass on i686 Julia 1.10, 1.12 and 1.13 and on x86_64 1.10 and 1.12.

End to end, with PoCL built with 32-bit SPIR-V support (maleadt/pocl#2) and a KernelAbstractions fix for its exception-info address on 32-bit hosts, AcceleratedKernels' test suite on the PoCL back-end passes (59973/59973) on i686 Julia 1.10 through 1.13. Without this PR, kernels reading struct fields or arrays of structs return wrong values (21–22 of 31 struct and memory checks pass instead of 29, the remaining two being Int128), and AK's reductions corrupt the heap.

@codecov

codecov Bot commented Oct 25, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 94.61538% with 14 lines in your changes missing coverage. Please review.
✅ Project coverage is 91.07%. Comparing base (edc4b6d) to head (65a4291).

Files with missing lines Patch % Lines
src/spirv.jl 94.61% 14 Missing ⚠️
Additional details and impacted files
@@                 Coverage Diff                 @@
##           tb/runtime-sret    #1063      +/-   ##
===================================================
- Coverage            92.39%   91.07%   -1.32%     
===================================================
  Files                   31       31              
  Lines                 7327     7578     +251     
===================================================
+ Hits                  6770     6902     +132     
- Misses                 557      676     +119     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

On 32-bit x86 (other than Windows), Julia aligns Int64 and Float64 to 4
bytes, so e.g. `struct P; a::Int32; b::Int64; end` has `b` at offset 4.
The spirv32 data layout aligned them to 8 instead, and Julia's codegen
relies on the module's data layout: Julia 1.10 and 1.11 map byte offsets
onto struct elements with it, so `p.b` was loaded from offset 0. Julia
1.12 and later address fields by byte offset, but the strides of
`unsafe_load` and the layout of allocas, aggregate loads and stores and
globals still follow the data layout. SPIR-V consumers furthermore lay
out structs by OpenCL C's rules, aligning long and double to 8, so the
struct types in kernel arguments, allocas and globals described memory
that the host lays out differently. This only went unnoticed with PoCL
because it swaps in the host's data layout, which is a bug of its own.

Generate code with Julia's alignments, and before handing the module to
the back-end, replace every struct type whose layout differs from the
consumers' by a packed struct with explicit padding wherever it
describes memory, which the back-ends encode as CPacked. Addresses into
such structs become byte offsets, and aggregate loads and stores are
split into their parts. Nothing changes on 64-bit hosts, where both
layouts agree.
On 32-bit hosts, the Khronos translator got the triple
spirv-unknown-unknown. In LLVM, bare spirv denotes logical SPIR-V with
64-bit pointers, so the translator emitted the Physical64 addressing
model even though pointers are 32 bits wide. Use spir-unknown-unknown,
the 32-bit counterpart of the spir64 triple used on 64-bit hosts, which
gives Physical32 like the LLVM back-end does.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant