Skip to main content

RadixSortPipeline

Struct RadixSortPipeline 

Source
pub struct RadixSortPipeline { /* private fields */ }
Expand description

§Introduction

This implementation of the radix-sort algorithm is based on the paper: Fast 4-way parallel radix sorting on GPUs

The radix sort algorithm can be divided into 3 steps:

  1. Count the number of each radix in the block and generate a histogram (count_radix_pipeline).
  2. Perform prefix sum operations on the histogram (scan_upsweep_pipeline, scan_dnsweep_pipeline, scan_last_block_pipeline).
  3. Based on the histogram information, write the key values in the block to new ordered positions (scatter_pipeline).

§Modification

I have made modifications to the Step 2: Compute prefix sum on the histogram in the paper to improve performance.

The memory layout of global_histogram_buffer in the paper is as follows:

              workgroup0      workgroup1        ...           workgroupN                                                                    
          +---------------+---------------+---------------+---------------+                                                                 
radix 0   |      V_0      |      V_1      |     ...       |     V_n-1     |                                                                 
          +---------------+---------------+---------------+---------------+                                                                 
radix 1   |      V_n      |      V_n+1    |     ...       |     V_2n-1    |                                                                 
          +---------------+---------------+---------------+---------------+                                                                 
 ...      |                              ...                              |                                                                 
          +---------------+---------------+---------------+---------------+                                                                 
radix 255 |     V_255n    |     V_255n+1  |     ...       |    V_256n-1   |                                                                 
          +---------------+---------------+---------------+---------------+                                                                 

This memory layout is beneficial for Step 2 prefix sum calculations, but not for Step 1 and Step 3, because the memory access to global_histogram_buffer in Step 1 and Step 3 is Non-Coalesced Memory Access.

Non-Coalesced Memory Access can cause significant delays, leading to a drastic decrease in the performance of radix_sort. To address this issue, I redesigned the memory layout of global_histogram_buffer:

                 radix 0         radix 1          ...            radix 255                                                                    
            +---------------+---------------+---------------+---------------+                                                                 
workgroup0  |      V_0      |      V_1      |     ...       |     V_255     |                                                                 
            +---------------+---------------+---------------+---------------+                                                                 
workgroup1  |     V_256     |     V_257     |     ...       |     V_511     |                                                                 
            +---------------+---------------+---------------+---------------+                                                                 
   ...      |                              ...                              |                                                                 
            +---------------+---------------+---------------+---------------+                                                                 
workgroupN  |     V_256n    |    V_256n+1   |     ...       |  V_256n+255   |                                                                 
            +---------------+---------------+---------------+---------------+                                                         

This solves the delay problem caused by Non-Coalesced Memory Access, but brings a new problem: how to perform prefix sum calculations on such a memory layout?

The idea is simple: change the unit of the prefix sum operation from radix to histogram, and perform the prefix sum operation on the histogram. This means accumulating histogram-workgroup0 to histogram-workgroup1, histogram-workgroup1 to histogram-workgroup2, and so on.

Of course, we cannot use a serial prefix sum as it would degrade performance. Therefore, we use the Blelloch parallel prefix sum algorithm, which consists of an up-sweep and a down-sweep step:

§Up-Sweep

          ┌──┐ ┌──┐ ┌──┐ ┌──┐ ┌──┐ ┌──┐ ┌──┐ ┌──┐                                                       
          │H0│ │H1│ │H2│ │H3│ │H4│ │H5│ │H6│ │H7│                                                       
          └┬─┘ └┬─┘ └┬─┘ └┬─┘ └┬─┘ └┬─┘ └┬─┘ └┬─┘                                                       
           └───┬▼─┐  └───┬▼─┐  └───┬▼─┐  └───┬▼─┐                                                       
Round 0        │H1│      │H3│      │H5│      │H7│                                                       
               └┴─┴──────┴┬─┘      └──┴──────┴┬─┘                                                       
                         ┌▼─┐                ┌▼─┐                                                       
Round 1                  │H3│                │H7│                                                       
                         └──┴────────────────┴┬─┘                                                       
                                             ┌▼─┐                                                       
Round 2                                      │H7│                                                       
                                             └──┘   

§Down-Sweep

    Round 1   Round 0   Round 1                                                                          
    ┌────┐    ┌────┐    ┌────┐                                                                           
┌──┐│┌──┐│┌──┐│┌──┐│┌──┐│┌──┐│┌──┐ ┌──┐                                                                  
│H0│││H1│││H2│││H3│││H4│││H5│││H6│ │H7│                                                                  
└──┘│└──┘│└▲─┘│└──┘│└▲─┘│└──┘│└▲─┘ └──┘                                                                  
    │┌──┐│ │  │┌──┐│ │  │┌──┐│ │   ┌──┐                                                                  
    ││H1├┼─┘  ││H3├┼─┘  ││H5├┼─┘   │H7│                                                                  
    │└──┘│    │└──┘│    │└▲─┘│     └──┘                                                                  
    └────┘    │┌──┐│    └─┼──┘     ┌──┐                                                                  
              ││H3├┼──────┘        │H7│                                                                  
              │└──┘│               └──┘                                                                  
              └────┘               ┌──┐                                                                  
                                   │H7│                                                                  
                                   └──┘  

The performance of parallel algorithms on GPUs is often bandwidth-sensitive. Therefore, the number of memory accesses is a crucial indicator of the algorithm’s performance.

The memory access count of the Blelloch algorithm is quite high: 2R1W + 2R1W = 4R2W, which is a rather poor situation. Moreover, since it executes on device-memory, it cannot hide latency through shared memory. (General parallel prefix sum algorithms can achieve 2R1W memory access count, so the performance of device-scope Blelloch is not good, only half of the pre-modified performance)

However, the performance degradation brought by Blelloch is not a big issue here, because we can reduce the overall cost of Step 2 by reducing the number of histograms. To reduce the number of histograms, the method is very straightforward: increase the number of keys processed during the statistics phase (Step 1).

Previously, one workgroup processed 256 keys and output a histogram of length 256. It can be modified to one workgroup processing 16*256 keys and outputting a histogram of length 256. This reduces the number of histograms by 16x, thereby reducing the memory access overhead of Step 2.

§Advanced?

In fact, the algorithm can be further optimized!

We can use the decoupled look-back algorithm to halve memory access overhead, achieving nearly 100% performance improvement. However, the problem is that decoupled look-back relies on communication between workgroups, and there are two ways to achieve workgroup communication:

  1. device-scope storage-buffer memory barrier.
  2. forward progress guarantees.

The rendering layer of bevy relies on wgpu, which is an implementation of the webgpu standard. Unfortunately, webgpu does not support either of these methods. This is mainly due to Apple’s Metal graphics API not supporting these two features, which leads to webgpu, aiming for full compatibility, also not supporting them.

For more details, see atomic concerns.

But fortunately, for the vast majority of desktop GPUs (Nvidia/AMD), forward progress guarantees are implicitly supported. So if your target platform does not include Apple and mobile devices, you can still use webgpu to implement the decoupled look-back radix sort algorithm.

However, for compatibility reasons, a more traditional radix sort algorithm is used here.

§Tips

The number of pass these 3 steps are executed depends on the number of bits in the keys and the number of bits processed per pass. For keys of type u32 and RADIX_BITS_PER_PASS set to 8, 4 passes are required.

If the effective number of bits is less than 32 (but the key type is still u32), for example, if all keys are less than 256, then only 1 pass is needed.

You can customize the number of pass and positions for processing the keys according to your specific requirements to improve performance.

Implementations§

Trait Implementations§

Source§

impl Clone for RadixSortPipeline

Source§

fn clone(&self) -> RadixSortPipeline

Returns a duplicate of the value. Read more
1.0.0 (const: unstable) · Source§

fn clone_from(&mut self, source: &Self)

Performs copy-assignment from source. Read more
Source§

impl Debug for RadixSortPipeline

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl FromWorld for RadixSortPipeline

Source§

fn from_world(world: &mut World) -> Self

Creates Self using data from the given World.
Source§

impl Resource for RadixSortPipeline
where Self: Send + Sync + 'static,

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T, U> AsBindGroupShaderType<U> for T
where U: ShaderType, &'a T: for<'a> Into<U>,

Source§

fn as_bind_group_shader_type(&self, _images: &RenderAssets<GpuImage>) -> U

Return the T ShaderType for self. When used in AsBindGroup derives, it is safe to assume that all images in self exist.
Source§

impl<T> Borrow<T> for T
where T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<ST, DT> CastableFrom<ST, Initialized, Initialized> for DT
where ST: ?Sized, DT: ?Sized,

Source§

impl<ST, DT> CastableFrom<ST, Uninit, Uninit> for DT
where ST: ?Sized, DT: ?Sized,

Source§

impl<T> CloneToUninit for T
where T: Clone,

Source§

unsafe fn clone_to_uninit(&self, dest: *mut u8)

🔬This is a nightly-only experimental API. (clone_to_uninit)
Performs copy-assignment from self to dest. Read more
Source§

impl<T> ConditionalSend for T
where T: Send,

Source§

impl<T> Downcast for T
where T: Any,

Source§

fn into_any(self: Box<T>) -> Box<dyn Any>

Convert Box<dyn Trait> (where Trait: Downcast) to Box<dyn Any>. Box<dyn Any> can then be further downcast into Box<ConcreteType> where ConcreteType implements Trait.
Source§

fn into_any_rc(self: Rc<T>) -> Rc<dyn Any>

Convert Rc<Trait> (where Trait: Downcast) to Rc<Any>. Rc<Any> can then be further downcast into Rc<ConcreteType> where ConcreteType implements Trait.
Source§

fn as_any(&self) -> &(dyn Any + 'static)

Convert &Trait (where Trait: Downcast) to &Any. This is needed since Rust cannot generate &Any’s vtable from &Trait’s.
Source§

fn as_any_mut(&mut self) -> &mut (dyn Any + 'static)

Convert &mut Trait (where Trait: Downcast) to &Any. This is needed since Rust cannot generate &mut Any’s vtable from &mut Trait’s.
Source§

impl<T> Downcast<T> for T

Source§

fn downcast(&self) -> &T

Source§

impl<T> DowncastSync for T
where T: Any + Send + Sync,

Source§

fn into_any_arc(self: Arc<T>) -> Arc<dyn Any + Send + Sync>

Convert Arc<Trait> (where Trait: Downcast) to Arc<Any>. Arc<Any> can then be further downcast into Arc<ConcreteType> where ConcreteType implements Trait.
Source§

impl<S, T> Duplex<S> for T
where T: FromSample<S> + ToSample<S>,

Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

Source§

impl<S> FromSample<S> for S

Source§

fn from_sample_(s: S) -> S

Source§

impl<T, W> HasTypeWitness<W> for T
where W: MakeTypeWitness<Arg = T>, T: ?Sized,

Source§

const WITNESS: W = W::MAKE

A constant of the type witness
Source§

impl<T> Identity for T
where T: ?Sized,

Source§

const TYPE_EQ: TypeEq<T, <T as Identity>::Type> = TypeEq::NEW

Proof that Self is the same type as Self::Type, provides methods for casting between Self and Self::Type.
Source§

type Type = T

The same type as Self, used to emulate type equality bounds (T == U) with associated type equality constraints (T: Identity<Type = U>).
Source§

impl<T> Instrument for T

Source§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided Span, returning an Instrumented wrapper. Read more
Source§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

That is, this conversion is whatever the implementation of From<T> for U chooses to do.

Source§

impl<T> IntoEither for T

Source§

fn into_either(self, into_left: bool) -> Either<Self, Self>

Converts self into a Left variant of Either<Self, Self> if into_left is true. Converts self into a Right variant of Either<Self, Self> otherwise. Read more
Source§

fn into_either_with<F>(self, into_left: F) -> Either<Self, Self>
where F: FnOnce(&Self) -> bool,

Converts self into a Left variant of Either<Self, Self> if into_left(&self) returns true. Converts self into a Right variant of Either<Self, Self> otherwise. Read more
Source§

impl<T> Pointable for T

Source§

const ALIGN: usize

The alignment of pointer.
Source§

type Init = T

The type for initializers.
Source§

unsafe fn init(init: <T as Pointable>::Init) -> usize

Initializes a with the given initializer. Read more
Source§

unsafe fn deref<'a>(ptr: usize) -> &'a T

Dereferences the given pointer. Read more
Source§

unsafe fn deref_mut<'a>(ptr: usize) -> &'a mut T

Mutably dereferences the given pointer. Read more
Source§

unsafe fn drop(ptr: usize)

Drops the object pointed to by the given pointer. Read more
Source§

impl<T> Read<Exclusive, BecauseExclusive> for T
where T: ?Sized,

Source§

impl<T> Settings for T
where T: 'static + Send + Sync,

Source§

impl<T> ToOwned for T
where T: Clone,

Source§

type Owned = T

The resulting type after obtaining ownership.
Source§

fn to_owned(&self) -> T

Creates owned data from borrowed data, usually by cloning. Read more
Source§

fn clone_into(&self, target: &mut T)

Uses borrowed data to replace owned data, usually by cloning. Read more
Source§

impl<T, U> ToSample<U> for T
where U: FromSample<T>,

Source§

fn to_sample_(self) -> U

Source§

impl<T, U> TryFrom<U> for T
where U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
Source§

impl<T> TypeData for T
where T: 'static + Send + Sync + Clone,

Source§

impl<T> Upcast<T> for T

Source§

fn upcast(&self) -> Option<&T>

Source§

impl<V, T> VZip<V> for T
where V: MultiLane<T>,

Source§

fn vzip(self) -> V

Source§

impl<T> WasmNotSend for T
where T: Send,

Source§

impl<T> WasmNotSendSync for T

Source§

impl<T> WasmNotSync for T
where T: Sync,

Source§

impl<T> WithSubscriber for T

Source§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a WithDispatch wrapper. Read more
Source§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a WithDispatch wrapper. Read more