`core` instead of `std` is great too!
This will become useful in one of my sideproject where I use bitmaps to speed up pathfinding, exited to try it out!
They specifies a constant SIMD width so it's non-portable. Well, not performance portable, but why are we using SIMD again?
Another way of looking at it is that our programming environments are not sufficiently powerful and expressive to create the necessary abstractions to make SIMD truly portable.
If you go look at AMD's ISA docs (they're public) you'll see you don't have the equivalent of a __mm256 register like on x86. Each 'thread' just deals with single scalar values like int32 of float32. The hardware, however, groups 32 or 64 threads together which all run the same program and runs them together. Each 'thread' loosely maps to a SIMD lane. The SIMD is implicit, not explicit.
The main difference is that the 'SIMD' execution is somewhat opaque to the program. You just write plain scalar code and the hardware model dispatches it efficiently to SIMD execution units. It's not really an abstraction because to extract maximum performance you have to understand how it works. You can use this kind of programming model on a CPU too, Intel did it with [0] ISPC. It's a C-like language that has execution semantics similar to GPU shader languages but compiles to regular CPU code, and maps threads to your CPUs SIMD lanes like a GPU.
SIMD seems to me, to be very platform specific. Maybe there are times one SIMD unit is not anothers' SIMD unit?
Autovectorization, depending on compiler's cleverness, really portable SIMD operations, and then the CPU specific SIMD ones.
So this should be perfectly doable in crate that advertises as portable, while leaving the non portable stuff to another crate.
False. If they were threads they'd have their own PC. They do not - only the warp has a PC.
> You just write plain scalar code and the hardware model dispatches it efficiently to SIMD execution units.
Absolutely not. If you don't write coalesced loads, bank-conflict free, predication-free, cooperative code you will get worse than CPU performance.
I most often encounter unstable features when I reach for a basic common-sense utility method and discover that it's not stable. Like just earlier today I would have reached for bool::toggle which not only is unstable, but is also newly added as of like a month ago! but some unstable methods have been sitting around for years.
And now that IntelliJ-Rust is proprietary, I can't even make a feature request anymore for the ability to exclude unstable features from the autocomplete. So they will taunt me forever, perfect little helpers just locked away.
There is no reason a portable_simd relu_dot implemention should need to specify the SIMD width.
But the design and documentation of portable_simd makes the fixed size syntactically easy/the default and the width agnostic code harder.
- CUDA kernels and Tiles (e.g. Cudarc, cuda-oxide, rust-gpu etc) - SIMD on the GPU. (E.g. as in the title...)
- CPU SIMD using avx or SSE instructions (And probably thin wrappers for vectors so you can have sane syntax). Or the maybe-upcoming core simd which should abstract over architecture-specific instructions. Magic floats etc which do 4-16 computations at once, but are a bit clumsy to work with
- Rayon thread pools - arbitrary parallel computations, including SIMD, one per CPU core.
It looks like from the code samples like maybe a cleaner syntax for writing code on the GPU than CUDA kernels? E.g. without mucking with serialization, host and device by abstracting over it? And inspired by core::simd. (Good choice if so, in the interest of standardizing on syntax; I did this for my x86 SIMD vector/quaternion lib as well)Well now you could in theory AI generate SIMD, which will be vibe coded, as those devs have no idea of its correctness.
Each family of operations is a trait parameterized by the operation itself:
pub trait EvaluateReduction<Operation, T>: LaneEvaluator {
/// Reduce one distributed definition to an ordinary uniform scalar.
fn evaluate_reduction(&self, value: LaneValue<Self, role::Distributed, T>) -> T;
}
Call sites name the operation: let one = evaluator.splat::<Splat, _>(1_u32);
let two = evaluator.splat::<Splat, _>(2_u32);
let three = evaluator.binary::<Add, _>(one, two);
let total = evaluator.reduce::<Sum, u32>(three); // a uniform u32
let running = <Executor as EvaluateScan<Scan<Sum, Exclusive>, u32>>::scan(&evaluator, three);
Operations like Sum, Max, ReduceXor, Inclusive, and Exclusive are all distinct types.As mentioned in the post, execution shape is typed too. A static shuffle takes its control as a type-level constant, and the shuffle mode constrains which controls are expressible:
// Shift down one lane, keeping our own value where the source is inactive.
let down = <Executor as EvaluateShuffle<Shuffle<Down>, DownOrSelf<1>, u32>>::shuffle(&ev, v);
// Broadcast from lane zero.
let bcast = <Executor as EvaluateShuffle<Shuffle<Broadcast>, WarpLane<0>, u32>>::shuffle(&ev, down);
// Butterfly exchange with the neighbor one bit away.
let bfly = <Executor as EvaluateShuffle<Shuffle<Xor>, Butterfly<1>, u32>>::shuffle(&ev, bcast);
For an example of errors caught, a warp-scoped executor for a device-scoped barrier is a compile error: <ScopedWarpExecutor<'_, WarpUniform> as EvaluateBarrier<Barrier<Device>>>::barrier(evaluator)
// error[E0277]: the trait bound `Device: NvptxBarrierScope` is not satisfied
// help: the trait `NvptxBarrierScope` is implemented for `Warp`
Strip mining is typed on the amount of work and the lane capacity, and it hands back one chunk at a time along with the predicate saying which lanes live in that chunk: // Six work items across four active lanes: two chunks, based at 0 and 4.
<Executor as EvaluateStripMine<StripMine, (WorkItems, ActiveLanes<StripMined<4>>), i32>>::
for_each_strip_mined(
&evaluator,
(WorkItems::new(6)?, ActiveLanes::new(4)?),
|index, active| {
// ...
},
);
Hopefully that gives the flavor of it.If you want to get more pedantic you also need to look at your target hardware and their specific micro-architectural quirks and features to get the best performance. AMD specifically benefits a lot from exploiting the scalar unit over the vector unit, you save loads of register file space if you can keep data in SGPRs over VGPRs. There's lots of traps you can fall into where you can load data from buffers into SGPRs but they get promoted to VGPRs because the scalar unit lacks an opcode for like one math operation you did to the value somewhere.
While each lane isn't truly a thread because it doesn't have its own PC the programming model definitely tries to make it seem that way. The threads can terminate at different points too. And again, the ISA isn't a vector ISA. Your register values are scalar.
Can you say more about the application space you're targeting?
What should it choose then? I have a Zen 3 processor, and benchmarking some simd I did recently says 32 byte or 64 byte chunks was fastest. But I'm sure I'd get a different result on a different Zen, and different again on Intel's.
How would the library decide what SIMD width I should use?
The promotional material likes to label the individual lanes as “cores” because it sounds more impressive. And, it’s not entirely incorrect.
Even the dev docs use the marketing terminology. The description I gave above needs a bit of piecing together.
For many problems, choosing the right instruction or instruction sequence makes a large difference. Portable SIMD abstractions necessarily expose some common semantic layer, but SIMD ISAs don't actually have equivalent capabilities. Instructions like pshufb, for example, enable algorithmic tricks that don't necessarily have an equally efficient analogue on another architecture.
If maximum performance matters, I generally want intrinsics and architecture-specific implementations; if portability matters more, I'd rather move further up the abstraction stack and use something designed to target multiple architectures, such as ISPC. There are certainly cases where portable SIMD gets close enough to optimal, but I don't think there's a compiler or abstraction that can express every useful SIMD idiom and lower it equally efficiently across fundamentally different ISAs.
There are many ways that performance matters without trying to win a F1 race.
Go isn't alone, .NET, Java have similar portable libraries, and C++ is in the process of getting one.
Current CPU cores do two AVX-512 operations per cycle. If you can saturate this you’ll often run out of memory bandwidth on CPUs because of lower bandwidth compared to GPUs. In principle, if you bought a 192-core processor you’d have 6,144 GPU-ish cores of 32-bit operations, and they would run at a significantly higher clock rate than a GPU. It would not be competitive with a GPU for the kinds of things GPUs are good at it but it wouldn’t be as far off as you might assume. For some types of code, AVX-512 is unambiguously better.
Horses for courses. GPUs and CPUs were optimized for different things but their capabilities have slowly been converging over time. They all work from the same transistor budgets, the differences are where the tradeoffs are made.
There is a pithy silicon architecture tradeoff trilemma to be made regarding CPUs, GPUs, and barrel processors.
Note that modern NVIDA GPUs like the 5090 are actually more like 170 SMs on a chip, or 21,760 flop/cycle, or ~20-40x more ops/cycle than your example CPU.
Put GDDR6 vs DDR5 memory on top of that, and it’s easy to see why the GPU can churn through math so fast … as long as it’s GPU-y. For stuff the GPU does well, the CPU typically can’t compete, the GPU is often more than 10x faster. But GPU-y tasks are a subset, and there are CPU-y things the GPU can’t compete on, despite (or even because of) the thread count discrepancy.
What you'd actually want is a matrix of variant implementations burned into the binary, with runtime (or process-boot-time) hardware detection that swaps symbols out to point to the correct variant.
The major one is that there's one layer of indirection that exists on GPUs that doesn't really on CPUs. There's one giant vector register file per for each of these processing cores (that'll be something like 2048 rows x 32 lanes x 32 bits). An individual shader invocation might only need say, 16 rows. While there's hardware for issuing 4 hyperthreads at any given time, there can be a variable number of thread states in the register file. So for the case of each invocation only needing 16 rows, you might be able to fit 128 hyperthread states into processing core. Those four hyperthreads then hardware schedule those 128 states and will execute any that are ready, as well as allocate more from other scheduling hardware as gaps in the register file appear from shaders completing.
Because of this massive amount thread state, you don't depend nearly as much on a cache hierarchy to deal with DRAM latency. There's ostensibly some other thread state sitting around that can be serviced while others wait for the hundreds of cycles of latency to access DRAM.
So the whole model of how you account for the discrepancy between ALU cycle time, and DRAM latency changes versus a CPU. Where a modern CPU spends a lot of area on complex cache hierarchies, speculation, etc, to hide the latency to memory, a GPU focuses on having a lot of thread state around and a lot of ALUs, but balanced ideally, so there's always ALU work to do while other thread states are waiting on memory.
Now, over time, GPUs have gotten more complex hardware, and more complex cache hierarchies to cover the cases that aren't handled well by extremely long access times. But those tend to be very explicit. Additionally, CPU vector files have gotten more similar to GPU cores as architectural features like lane masking/predicates have been added to have the equivalent of CUDA threads in the same warp that take different paths through control flow blocks. That's a lot of what people mean when they say that AVX-512 adds a lot more than just 512-bit registers. The K mask registers let you do a lot of GPU shader tricks to have effective partial residency, and not have to use all the lanes if the data doesn't line up with that.
If that's the case, then the selection logic would be trivial: figure out the full hierarchical ID of the uarch you're running on, then search for the longest prefix match in the table of available impls.
If things work more like you're imagining, though, then I suppose the process-boot impl-selector would narrow down the impl matrix to just the subset that are legal on the running uarch; pick one arbitrarily to be active at first; and then wrap the calls in a handler that gradually re-works the called function in a way reminiscent of a profile-guided JIT, but without the need to actually synthesize any code at runtime — instead, it'd just be a multi-armed bandit passing-through-to and re-ranking competitor impls, with decreasing sampling of the non-first-ranked impls as confidence-in-score-separation increases.
August 10, 202612 min read
Pedantic mode:Off
GPU code can now use Rust's portable SIMD. We share the implementation approach and what this unlocks for GPU programming.
At VectorWare, we are building the first GPU-native software company. Today, we are excited to announce that we can successfully use Rust's portable SIMD (core::simd) on the GPU. This milestone marks a significant step towards our vision of enabling developers to write complex, high-performance applications that leverage the full power of GPU hardware using familiar Rust abstractions.
When we brought Rust threads to the GPU, we mapped each std::thread to a GPU warp. This let us run many concurrent threads on the GPU but did not use the parallel lanes within each thread/warp.
On the CPU, the abstraction for parallelism within a thread is SIMD. A single instruction operates on several data elements packed into a vector unit: where scalar code adds two numbers, a SIMD add takes two vectors of, say, eight f32 values and produces eight sums at once. This data parallelism is inside a single thread, below the level where the operating system schedules anything.
CPU threadSIMD op012N⋯SIMD lanesCPU thread
Historically, writing SIMD in Rust meant reaching for the architecture-specific vendor intrinsics in core::arch, such as _mm256_add_ps on x86-64 or vaddq_f32 on Arm. These intrinsics are specific to a single instruction set, so a program that runs on more than one architecture needs a separate implementation for each.
Rust's portable SIMD instead adds a layer of abstraction above these intrinsics. It provides a single generic type Simd<T, N> that represents a vector of N elements of type T. A program writes its arithmetic, comparisons, reductions, and lane shuffles once against Simd and the compiler lowers them to whatever vector instructions the target CPU has.
At VectorWare, we realized the GPU is just one more piece of vector hardware for portable SIMD to target. As a bonus, portable SIMD lives in core rather than std and it does not even need the std support we brought to the GPU.
GPUs execute in a model NVIDIA calls SIMT, or Single Instruction, Multiple Thread. A warp issues one instruction, and each of its 32 lanes runs that instruction on its own data. One instruction operating on many data elements is exactly what SIMD means, and the per-lane addressing that SIMT adds does not change that. A warp is a wide vector unit and a portable SIMD vector maps onto that unit directly.
CPU thread012N⋯SIMD lanes≈GPU warp012N⋯warp lanes
For example, a Simd<i16, 32> gives one i16 element to each of the warp's 32 lanes, and adding two such vectors compiles to a single warp instruction in which every lane adds its element at once.
CPUlet a: Simd<i16, 32> = [1, 1, 1, ..., 1];let b: Simd<i16, 32> = [2, 2, 2, ..., 2];let c = a + b;compiles tovpaddw %zmm2, %zmm1, %zmm0a0+b0lane 0a1+b1lane 1a2+b2lane 2a31+b31lane 31⋯println!("{c:?}");
GPUlet a: Simd<i16, 32> = [1, 1, 1, ..., 1];let b: Simd<i16, 32> = [2, 2, 2, ..., 2];let c = a + b;compiles toadd.s16 %rs3, %rs1, %rs2;a0+b0lane 0a1+b1lane 1a2+b2lane 2a31+b31lane 31⋯println!("{c:?}");
This new mapping completes the parallelism hierarchy from our earlier work. On the CPU, a thread contains SIMD lanes, and on the GPU our std::thread is a warp whose hardware lanes play the same role. In both cases, core::simd drives those lanes.
CPU⋯thread 0012N⋯thread 1012N⋯thread N012N⋯SIMD lanes≈GPU⋯warp 0012N⋯warp 1012N⋯warp N012N⋯warp lanes
core::simd on the GPUAs with our earlier posts, this is hard to show visually because the code is ordinary Rust. The same core::simd types that lower to x86-64 SIMD on a laptop lower to warp operations on the GPU, with no change to the source.
Here we define a small portable SIMD routine and call it from main. It exercises the core features of the model: elementwise arithmetic, a comparison that produces a lane mask, a select driven by that mask, and a horizontal reduction across lanes.
#![feature(portable_simd)]
use core::simd::cmp::SimdPartialOrd;
use core::simd::num::SimdFloat;
use core::simd::{Select, Simd};
// Portable SIMD. This exact function also compiles and runs on the CPU,
// where it lowers to x86-64, Arm, or scalar code depending on the target.
fn relu_dot(a: Simd<f32, 32>, b: Simd<f32, 32>) -> f32 {
// Elementwise multiply: 32 products computed at once.
let products = a * b;
// Per-lane comparison produces a mask, one boolean per lane.
let positive = products.simd_gt(Simd::splat(0.0));
// Keep the positive products, replace the rest with zero.
let clamped = positive.select(products, Simd::splat(0.0));
// Horizontal add across all lanes down to a single scalar.
clamped.reduce_sum()
}
fn main() {
// Two 32-wide vectors, built with ordinary Rust.
let a = Simd::<f32, 32>::splat(2.0);
let b = Simd::<f32, 32>::from_array(std::array::from_fn(|i| i as f32 - 16.0));
// Elementwise ops, a comparison mask, a select, and a reduction:
// all ordinary portable SIMD, all running on the GPU.
let result = relu_dot(a, b);
// Printed from the GPU using our std support.
println!("relu_dot = {result}");
}
The entry point is a normal fn main with no GPU-specific annotations. Our toolchain compiles it to a GPU kernel, and the result is printed from the device using our std support.
Below is a recording of the program running on the GPU, producing the exact same output as running it on the CPU.
As previously mentioned, the mapping rests on a single observation: a warp is a vector unit whose lanes are individually addressable. Once Simd<T, N> is laid out per lane, each family of operations has a direct warp-level counterpart.
SIMD elementwise operations are the easy case. Addition, multiplication, comparison, and the other lane-wise operators come from ordinary Rust trait implementations on Simd such as Add. The GPU runs them natively.
SIMD reductions such as reduce_sum and reduce_max combine every lane into a scalar. These use the GPU's warp shuffle instructions to exchange and combine values across lanes, producing the same scalar result in every lane.
SIMD cross-lane shuffles, such as simd_swizzle! and rotates, move elements between lanes. Because a SIMD lane is a GPU warp lane, these map onto the same warp shuffle primitives that make GPU lanes so good at exchanging data.
SIMD masks map just as cleanly. A Mask<T, N> gives one predicate to each SIMD lane. Mask::select performs a selection in every warp lane. Horizontal mask queries such as any and all use GPU vote and ballot instructions.
Scalar values in the surrounding code, such as a loop counter or a constant, are computed identically by every lane and so are simply replicated across the warp just like in ordinary CUDA. This is the same uniform-versus-varying distinction that data-parallel languages like ISPC make explicit, except here it falls out of Rust's own types: a plain f32 is uniform, a Simd<f32, 32> is varying.
The one place the abstraction and the hardware do not line up is lane count. On the CPU a Simd<T, N> allows any N from 1 through 64, but GPU hardware has a fixed width: 32 lanes on NVIDIA and 32 or 64 on AMD. The mapping is one to one only when N matches that width. A smaller N leaves some lanes idle while a larger N gives some or all lanes more than one element to process.
When there is more work than the warp is wide, we need a way to say which lanes do what. It helps to think of the warp as a small "machine" of its own: a fixed set of primitives for moving and combining data across lanes, plus invariants about which lanes are active and how much data each one holds. "Programming" it means placing work onto lanes within those rules.
At VectorWare, we give that machine an IR. Rather than a standalone data structure, we encode it in Rust's type system using types, generics, const generics, and trait bounds. A program is composed of typed operations: ballots, shuffles, reductions, scans, gathers, scatters, atomics, and strip mining for vectors wider than the warp. Operands, execution shape, and capacity are typed too. Because the operations carry their shape in the types, many invalid programs cannot be constructed at all.
The IR needs no interpreter on the GPU. Each operation lowers straight to the corresponding instructions with zero cost over hand-written PTX. The same types let us run it on the CPU too. We built a reference interpreter that executes the IR deterministically, a kind of Miri for warp-lane programming. We use it to simulate GPU code and for differential testing.
Our work targets NVIDIA today, but nothing here is CUDA specific. AMD wavefronts and Vulkan subgroups expose similar primitives and semantics. The IR itself is architecture-agnostic Rust.
The same source runs on the CPU and the GPU. Code and libraries that already use portable SIMD become candidates for GPU execution without a rewrite.
Unmodified CPU code can use GPU lane-level parallelism. GPU-aware code can still go further by using core::arch intrinsics that map directly to PTX.
A Simd<T, N> is an ordinary owned value. The borrow checker, lifetimes, and type checking apply to it exactly as they do on the CPU. We are not adding a GPU-specific vector type or a new set of annotations. We are mapping Rust's existing portable SIMD onto the GPU's native execution model. At VectorWare, we are making GPUs behave like a normal Rust platform.
Portable SIMD is still unstable in Rust. It requires the nightly #![feature(portable_simd)], and its surface may change before it stabilizes.
Vectors narrower than the warp leave lanes idle, and vectors wider than the warp turn each operation into more instructions. The abstraction is only zero cost when the vector width matches the number of warp lanes.
Not every cross-lane operation maps to an efficient warp instruction. Shuffles that match the hardware's supported patterns are cheap, but arbitrary permutations may need several instructions or a trip through shared memory. Horizontal operations like reductions and all/any also act as synchronization points within the warp, which constrains how freely the scheduler can overlap work.
We had to change the compiler to make the abstraction sound when interacting with other Rust features. As this is uncharted territory, we are not yet confident we have covered every case.
With SIMD, threads, and async all mapped onto the GPU, the natural next step is composing them: threads spreading work across warps, core::simd spreading data across the lanes within each warp, and async structuring the concurrency between them.
We are also interested in lowering matrix-shaped SIMD onto the GPU's tensor cores, and in auto-vectorizing ordinary scalar Rust loops into Simd operations so that code gets warp-level parallelism without being written against core::simd at all. As members of the Rust compiler team, we are keen to explore how much of this can happen in the compiler itself.
A vector representation shared across the CPU and the GPU is valuable, though it is not clear that today's portable SIMD types are the right basis for one. For one thing, they largely sit in a world of their own within the core and std APIs. More exploration is necessary.
The speed at which we are able to make progress on the GPU is a testament to the power of Rust's abstractions and ecosystem.
As a company, we understand that not everyone uses Rust. Our future products will support multiple programming languages and runtimes. However, we believe Rust is uniquely well suited to building high-performance, reliable GPU-native applications and that is what we are most excited about.
Follow us on X, Bluesky, LinkedIn, or subscribe to our blog to stay updated on our progress. We will be sharing more about our work in the coming months. You can also reach us at hello@vectorware.com.