Testing WebGPU data layouts with Facet

Hacker News Top Tools

Summary

The article explains subtle differences in struct layouts between Rust and WebGPU, and introduces a unit testing approach using naga to verify memory offsets for safer GPU compute shader configuration.

No content available
Original Article
View Cached Full Text

Cached at: 09/30/26, 08:00 AM

# Testing WebGPU data layouts with Facet Source: [https://www.mattkeeter.com/blog/2026-08-23-wgpu-facet/](https://www.mattkeeter.com/blog/2026-08-23-wgpu-facet/) When working on GPU compute shaders, it's common to want to share snippets of configuration data between the host and the shader\. Here's a toy example, adapted from[`fidget\-wgpu`](https://docs.rs/fidget-wgpu/latest/fidget_wgpu/): ``` // WGSL struct Config { /// Screen-to-model transform matrix mat: mat3x3f, /// Image size, in pixels image_size: vec2u, /// Z position at which to render the image z: f32, } ``` ``` // Rust #[derive( zerocopy::IntoBytes, zerocopy::Immutable, zerocopy::FromBytes, zerocopy::KnownLayout )] #[repr(C)] struct Config { mat: [[f32; 3]; 3], image_size: [u32; 2], z: f32, } ``` Thanks to the[`zerocopy`](https://docs.rs/zerocopy/)annotations, we can call`as\_bytes\(\)`and write the configuration data directly into a WebGPU buffer, which is very convenient\! Unfortunately, there are often subtle difference between Rust and WebGPU's layout rules\. Do you know what's wrong with the above example? Even*more*unfortunately, I'm writing bytecode VMs which run in compute shaders, and their failure mode is often "congrats, your GPU now has persistently spinning threads which can only be killed by rebooting your computer"\. After debugging the most recent reboot \(thanks to the[WGSL offset computer](https://webgpufundamentals.org/webgpu/lessons/resources/wgsl-offset-computer.html)\), I decided to fix the problem in a more systematic way\. --- There are existing options:[`wgsl\_to\_wgpu`](https://docs.rs/wgsl_to_wgpu),[`wgsl\_bindgen`](https://docs.rs/wgsl_bindgen), and[`encase`](https://docs.rs/encase/latest/encase/)are all relevant to the problem\. However, I decided to roll my own to avoid more external dependencies and build script wrangling\. Specifically, I decided to write**unit tests**, to add no overhead to a typical build\. \(If you want to be pedantic, using unit tests does have a failure mode of forgetting to test a new configuration object, but I'm not too worried about it\) We can find the WGSL`struct`layout using[`naga`](https://docs.rs/naga/), which is already in our dependency tree for shader compilation: ``` let module = naga::front::wgsl::parse_str(code).expect("valid WGSL"); let members = module .types .iter() .find_map(|(_, ty)| { if ty.name.as_deref() == Some("Config") && let naga::TypeInner::Struct { members, .. } = &ty.inner { Some(members) } else { None } }) .expect("could not find struct"); ``` Then, we can cross\-check against each member of the`struct Config`: ``` let expected_offsets = [ ("mat", std::mem::offset_of!(Config, mat)), ("image_size", std::mem::offset_of!(Config, image_size)), ("z", std::mem::offset_of!(Config, z)), ]; for (field_name, rust_offset) in expected_offsets { let wgsl_member = members .iter() .find(|m| m.name.as_deref() == Some(field_name)) .unwrap_or_else(|| { panic!("field `{field_name}` missing in WGSL struct") }); assert_eq!( wgsl_member.offset as usize, rust_offset, "offset mismatch for field `{field_name}`" ); } ``` Sure enough, this finds an issue\! ``` thread 'pixel::test::blog_test' (532515) panicked at fidget-wgpu/src/pixel/mod.rs:1762:13: assertion `left == right` failed: offset mismatch for field `image_size` left: 48 right: 36 ``` In WGSL, each row of a`mat3x3`has 4 bytes of padding, so each row is 16 bytes in total and the whole matrix is 48 bytes\. In Rust, a`\[\[f32; 3\]; 3\]`object is tightly packed and therefore occupies only 36 bytes\. The test worked, but there are two problems with this approach: - First, we have to hard\-code each member of the`Config`, which is awkward and error\-prone\! If we want to test multiple configuration objects, we'd have to hand\-write each one and keep them in sync\. - Second, the error message isn't great because we can't directly compare sizes of fields\. The*reason*`image\_size`has the wrong offset is because`mat`has a mismatched size; we should report the proximate cause\. \(This also means that we wouldn't report an incorrectly\-sized final member\) What's to be done? --- [`facet`](https://facet.rs/)is a library for Rust which provides run\-time reflection\. By annotating your`struct`with`\#\[derive\(facet::Facet\)\]`, you get a`SHAPE`associated type which can be inspected at runtime\. We can use this to automatically check a`Config`object\! Let's walk through the generic checker function, which is parameterized by a`T: facet::Facet`\. We'll start by parsing the shader and extracting the configuration`struct`by name; this is basically the same as before: ``` pub(crate) fn compare_struct_layout<T: facet::Facet<'static>>( shader: &str, struct_name: &str, ) { // [1] Parse the WGSL, same as before let module = naga::front::wgsl::parse_str(shader) .expect("valid WGSL"); let (members, span) = module .types .iter() .find_map(|(_, ty)| { if ty.name.as_deref() == Some(struct_name) && let naga::TypeInner::Struct { members, span } = &ty.inner { Some((members, *span)) } else { None } }) .expect("could not find struct"); ``` Next, we'll check the overall object size\. There's one subtlety here: I often make use of[runtime\-sized arrays](https://www.w3.org/TR/WGSL/#runtime-sized)as the last member of a configuration object\. \(Think of this as a[flexible array member](https://en.wikipedia.org/wiki/Flexible_array_member)in C or[dynamically sized types](https://doc.rust-lang.org/reference/dynamically-sized-types.html)in Rust\) Here's what it looks like in WGSL: ``` struct VoxelConfig { mat: mat4x4f, axes: vec3u, tape_data_offset: atomic<u32>, render_size: vec3u, tape_data_capacity: u32, image_size: vec3u, root_tape_len: atomic<u32>, tape_data: array<TapeWord>, // <- runtime-sized array } ``` When writing the Rust`struct`equivalent, that last member is omitted\. If we have a runtime\-sized array in`naga`, then we don't check the total size of the object \(as reported by`naga`\); instead, we check that the`offset`of`tape\_data`matches the size of the Rust`struct`\. Here's that section of the checker function: ``` // [2] Check the overall `struct` size // // If the last member of the struct is a runtime-sized // array, we'll treat the beginning offset of the array as // our struct size. let dynamic_array_offset = members.last().and_then(|m| { let ty = &module.types[m.ty]; let naga::TypeInner::Array { base: _, size: naga::ir::ArraySize::Dynamic, stride: _, } = &ty.inner else { return None; }; Some(m.offset) }); if let Some(dynamic_array_offset) = dynamic_array_offset { assert_eq!( dynamic_array_offset as usize, std::mem::size_of::<T>() ); } else { assert_eq!( span as usize, std::mem::size_of::<T>() ); } ``` Next, we'll go ahead and check field sizes and offsets\. This is the same idea as our manual field\-by\-field checking earlier, but uses Facet's introspection data to do the checks at runtime \(and also checks member sizes\)\. ``` // [3] Check field sizes and offset between Rust and WGSL let facet::Type::User(facet::UserType::Struct(shape)) = T::SHAPE.ty else { panic!("must build a struct"); }; let mut shape_field_names = HashSet::new(); // for later for field in shape.fields { let field_name = field.name; shape_field_names.insert(field_name); let wgsl_member = members .iter() .find(|m| m.name.as_deref() == Some(field_name)) .unwrap_or_else(|| { panic!("field `{field_name}` missing in WGSL struct") }); assert_eq!( wgsl_member.offset as usize, field.offset, "offset mismatch for field `{field_name}`" ); assert_eq!( module.types[wgsl_member.ty] .inner .size(module.to_ctx()) as usize, field.shape().layout.sized_layout().unwrap().size(), "size mismatch for field `{field_name}`" ); } ``` Finally, we'll check the opposite direction, confirming that every WGSL member appears in the Rust`struct`\(with the exception of a trailing runtime\-sized array\): ``` // [4] Check that every WGSL member appears in Rust let slice_len = if dynamic_array_offset.is_some() { members.len() - 1 } else { members.len() }; for m in &members[..slice_len] { assert!( shape_field_names.contains( m.name .as_ref() .expect("cannot check unnamed WGSL fields") .as_str(), ), "field `{field_name}` missing in Rust struct" ); } ``` This generic checker function took a little bit of wrangling, but once written, it can be easily applied to all of my configuration objects\. I derive`Facet`conditionally for unit tests only, e\.g\. ``` #[derive( zerocopy::IntoBytes, zerocopy::Immutable, zerocopy::FromBytes, zerocopy::KnownLayout )] #[cfg_attr(test, derive(facet::Facet))] // <- only derived in tests #[repr(C)] struct Config { mat: [[f32; 3]; 3], image_size: [u32; 2], z: f32, } ``` Then, I get the tests almost for free: ``` #[test] fn color_config_layout() { crate::test::compare_struct_layout::<ColorConfig>( &color_shader(16), "ColorConfig", ); } #[test] fn merge_config_layout() { crate::test::compare_struct_layout::<MergeConfig>( &merge_shader(), "MergeConfig", ); } #[test] fn shade_config_layout() { crate::test::compare_struct_layout::<ShadeConfig>( &shade_shader(), "ShadeConfig", ); } #[test] fn ssao_config_layout() { crate::test::compare_struct_layout::<SsaoConfig>( &ssao_shader(), "SsaoConfig", ); } #[test] fn blur_config_layout() { crate::test::compare_struct_layout::<BlurConfig>( &blur_shader(), "BlurConfig", ); } ``` Fortunately, these tests did not discover any pre\-existing issues in my configuration objects – not surprising, because everything works\. Still, next time I add a new compute shader, it will be a lot easier to feel confident in the correctness of data loading\! It's also easy to imagine using these kind of tests for other CPU\-GPU interchange, which is mostly a moot point here \(all other data types in my system are trivial\), but could be relevant for other projects\! As another closing thought,[Facet](https://facet.rs/)is great and people should use it more\. I should probably write another blog post about how it's used elsewhere in Fidget; it provides the backbone for some truly horrific automatically\-generated bindings from Rust shape definitions to[Rhai](https://rhai.rs/)functions\. If that sounds interesting, feel free to subscribe via[RSS](https://www.mattkeeter.com/atom.xml)and/or follow me on[social](https://bsky.app/profile/mattkeeter.com)[media](https://hachyderm.io/@mjk)\.

Similar Articles

Show HN: cuTile Rust: Safe, data-race-free GPU kernels in Rust

Hacker News Top

NVIDIA Labs releases cuTile Rust, a tile-based system for writing memory-safe, data-race-free GPU kernels in idiomatic Rust. It extends Rust's ownership model to GPU kernels, JIT-compiles Rust AST to GPU code, and achieves performance close to native CUDA.

GPU Offload in Rust: Portable, Safe, and Fast

Hacker News Top

This paper presents a zero-overhead, multi-vendor GPU compilation framework built into the Rust compiler, leveraging Rust's ownership model to ensure memory safety and achieve competitive performance with native CUDA and HIP baselines.