Skip to content

tracel-ai/cubefx-csgames

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

5 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

cubefx

Cliquez ici pour la version française

Synthwave music is all about that retro-futuristic sound: punchy drums, shimmering synths, deep rumbling bass. Think 80s aesthetics meets modern production. To give audio that neon-soaked character, we apply a per-bin phase shift across the frequency spectrum, an effect that colors the sound with a distinctive retro texture.

Your mission: make this pipeline blazingly fast on both CPU and CUDA, without sacrificing accuracy.

For this, you’ll use CubeCL, a Rust-based framework for high-performance compute kernels that run on both CPU and GPU, letting you optimize parallelism and memory access from a single codebase.

Audio Processing

A song is a continuous wave, also called a signal. To store it on a computer, we discretize it into samples. Each sample represents the amplitude of the signal at one point in time.

Continuous Wave:        ∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿

Discretized Samples:    • • • • • • • • • • • •
                        ↑ ↑ ↑ ↑ ↑ ↑ ↑ ↑ ↑ ↑ ↑ ↑
                      Sample points taken at regular intervals

A typical sample rate is 44,100 Hz, meaning 44,100 samples per second. Audio can be mono, stereo, or more, meaning the signal has multiple channels running in parallel.

The Fourier Transform

To work in the frequency domain, we apply a Fourier Transform. This mathematical operation converts a time-domain signal into its frequency components, which is the set of sine waves at different frequencies whose sum recreates the original signal.

Time Domain (signal):     Frequency Domain (spectrum):

    ∿∿                    |     |              |
   ∿  ∿                   |     |     |        |
  ∿    ∿        ──→       |     |     |   |    |
 ∿      ∿                 └─────┴─────┴───┴────┴──
                          Low           Mid    High
 Complicated wave         Individual frequency components

Each frequency bin is represented as a complex number. In practice, we simply use a pair of floats (real and imaginary parts), or, for tensors, a pair of float tensors. Since we start from real-valued audio samples, we use the Real FFT (RFFT), which exploits symmetry to produce only the non-redundant half of the spectrum. The inverse (IRFFT) transforms back to samples.

Windowing

We can't apply the FFT to the entire song at once because songs aren't periodic, and treating them as such loses information. Instead, we work on small overlapping windows. Neighboring windows overlap so that every part of the song is covered smoothly, avoiding clicks and artifacts at boundaries.

The windowing is pre-applied before your pipeline begins. You receive pre-windowed data and return processed windows. Signal reconstruction is out of scope today.

The Phase Shift Effect

For each frequency bin k in the spectrum, we apply a rotation proportional to the bin index:

spectrum[k]  *=  e^(i·α·k)  =  cos(α·k) + i·sin(α·k)

This gives lower frequencies a small nudge and higher frequencies a larger rotation, creating a characteristic coloring of the signal. A single scalar α controls the strength of the effect.

The Pipeline

Three kernels are executed: RFFT, Phase shift and IRFFT, and the benchmark measures the execution of the whole pipeline. When profiling, you may also see a PRNG (pseudo-random number generator) running to create data to work on, but this is not measured in the evaluated benchmarks.

Pre-windowed input   [num_windows, num_channels, window_length]
        │
        ▼
      RFFT           [num_windows, num_channels, freq_bins]   (complex: two float tensors)
        │
        ▼
  Phase shift        spectrum[k] *= e^(i·α·k)
        │
        ▼
      IRFFT          [num_windows, num_channels, window_length]
        │
        ▼
  Processed windows

For example, with window_length := 2048, the RFFT produces freq_bins := window_length / 2 + 1 = 1025 frequency bins.

Tensors, Shapes, and Memory Layout

A tensor is a multi-dimensional array. You can think of it as a generalization of a matrix to any number of dimensions. Throughout this codebase, audio data is stored as tensors. For example, the input to the pipeline has shape [num_windows, num_channels, window_length]: the first dimension indexes which window, the second which channel, and the third which sample within that window.

The shape tells you how many elements exist along each dimension. The strides tell you how to translate a multi-dimensional index into a flat memory offset. For a row-major tensor with shape [4, 3], the strides are [3, 1]: stepping one index along dimension 0 skips 3 elements in memory, and stepping along dimension 1 skips 1.

Shape [4, 3], strides [3, 1]:

Logical layout:        Memory (flat):
┌───┬───┬───┐
│ 0 │ 1 │ 2 │          0  1  2  3  4  5  6  7  8  9 10 11
├───┼───┼───┤          ▲        ▲        ▲        ▲
│ 3 │ 4 │ 5 │          row 0    row 1    row 2    row 3
├───┼───┼───┤
│ 6 │ 7 │ 8 │
├───┼───┼───┤
│ 9 │10 │11 │
└───┴───┴───┘

All tensors in this codebase are row-major, meaning the last dimension is always contiguous in memory (stride 1). This matters for optimization: when threads access consecutive elements along the last dimension, memory loads can be coalesced on GPU and cache-friendly on CPU.

You won't need to compute memory offsets manually because layout.rs handles strides for you. If you're curious about the details, that's the place to look.

Getting Started

Fork

First, fork this repository (use the Fork button in the upper-right corner of the GitHub page). During the competition, you will be asked to provide the link to your fork. Then make sure to be working on your personal fork, especially for your last commit.

Running the Application

cargo run --release

Always use --release for reliable timings, otherwise it makes a debug build which is often much slower and not representative.

Backend Selection:

# CPU
cargo run --release --features cubecl/cpu

# CUDA
cargo run --release --features cubecl/cuda

# Default: WGPU (no feature flags needed)

You'll be evaluated on both CPU and CUDA, so benchmark with those configurations.

Profiling

Enable the CubeCL profiler by setting stdout to true in cubecl.toml to identify bottleneck kernels. You may also need to set the CUBECL_DEBUG_OPTION environment variable to profile:

export CUBECL_DEBUG_OPTION=profile

More configuration details can be found here although many settings will be overkill for this project (there are no streams nor autotuning).

Testing Correctness

Run the test suite frequently:

cargo test

Backend Selection:

# CPU
cargo test --features cubecl/cpu

# CUDA
cargo test --features cubecl/cuda

# Default: WGPU
cargo test

Critical Tests:

  • large_fft_roundtrip_no_phase_shift: Ensures FFT/IFFT roundtrip accuracy
  • small_fft_round_trip_with_phase_shift: Verifies the complete effect pipeline

These two tests are the only ones that truly matter for evaluation. Tests inside cubefx-engine are there to help you debug along the way. You can look at the test and benchmark inputs to understand what assumptions you can reasonably make about the data (e.g. window size, number of channels).

Debugging

Test Mode:

You can control how tests handle numerical and compilation errors via the CUBE_TEST_MODE environment variable:

  • Correct (default): numerical errors fail, showing only the first error.
  • PrintFail: print all tensor elements, showing which are wrong. Accepts an optional filter suffix to see parts of the data only.
export CUBE_TEST_MODE=Correct                  # default
export CUBE_TEST_MODE=PrintFail:.,10-20        # filter: all first dims, indices 10–20 on second

Filters are comma-separated dimension selectors: . for all indices, M for a single index, M-N for a range. The number of entries must match the tensor rank. For more details, go here.

Generated Code:

For CUDA or WGPU, you can print the generated code of each kernel by setting the CUBECL_DEBUG_LOG environment variable to stdout. More details here. This might not print anything if you run a test that succeeds, so either make the test fail or add the --nocapture flag.

export CUBECL_DEBUG_LOG=stdout
cargo test --features cubecl/cuda -- --nocapture

Set the environment variable to 0 to deactivate it.

export CUBECL_DEBUG_LOG=0

Code Structure

The project is split into two crates: cubefx-eval is the main binary, and cubefx-engine is the library where all your work lives.

cubefx-eval (Do Not Modify)

Handles benchmarking and correctness testing. Your modifications here won't be used during evaluation since we'll use our own version. Notice that the data type (f32) is selected in this crate, so choosing a smaller data type is not a possibily.

cubefx-engine (Your Workspace)

All audio processing logic lives here. You can modify anything as long as:

  1. The API used by cubefx-eval remains compatible
  2. Both critical correctness tests pass

The backend is selected at compile time via CubeCL's TestRuntime, using --features cubecl/cuda or --features cubecl/cpu.

Files:

  • base.rs: Do not modify. Contains the entry point called by cubefx-eval.
  • cube/
    • phase_shift.rs: CubeCL kernel and launch code for per-bin phase shifting
    • layout.rs: Converts tensors to views over a single batch element at a time, handling batch offsets and strides
    • tests/: RFFT and IRFFT tests, verified against a pure Rust reference implementation
    • fft/
      • rfft.rs: CubeCL kernel and launch code for the Real FFT
      • irfft.rs: CubeCL kernel and launch code for the inverse RFFT
      • fft_inner.rs: Shared inner compute for both FFT directions. There are no global memory I/O or dispatch here, just the core arithmetic. More complex to understand and likely harder to optimize; approach with care.

Most of your modifications will be in phase_shift.rs, rfft.rs, and irfft.rs. You may also find opportunities in layout.rs and fft_inner.rs, although it's probably harder.

Optimization Opportunities

Normally, the phase shift kernel should execute much faster than the other two, even in their current suboptimal form. If it's not the case, the phase shift kernel is probably doing something stupid. Once this is fixed, you can move on to less trivial kernel modifications.

Launch Configuration

The default kernels are extremely naive: a single worker processes the entire input sequentially.

CubeCL kernels are launched with a cube count and a cube dim. At the moment, all cube dims and cube counts are hardcoded to 1.

  • cube count: The number of independent tasks that can run on different streaming processors.
    On CUDA, this maps to blocks. On CPU, this creates a loop over all tasks.
    Inside the kernel, you can access the current cube ID via CUBE_POS (or CUBE_POS_X, CUBE_POS_Y, CUBE_POS_Z if using multiple dimensions).

  • cube dim: The number of workers per cube (called units in CubeCL).
    On CUDA, this maps to threads. On CPU, this also maps to threads (cores).

    Within a cube, units are grouped into planes (called warps on CUDA).
    On CPU, the plane size is 1 (one unit = one plane).

    Units within a plane execute in lockstep: they access memory at the same time and follow the same code path (unless there is divergence).

    You can query the plane size at runtime with: client.properties().hardware.plane_size_max

    It is good practice to define cube_dim as 2D:
    (plane_size, number_of_planes)

    Inside the kernel:

    • CUBE_DIM_X: plane size
    • CUBE_DIM_Y: number of planes
    • UNIT_POS_X: unit ID within the plane
    • UNIT_POS_Y: plane ID within the cube

Memory Access Patterns

How your threads access memory has a major impact on performance:

  • GPU: Threads within the same plane (warp) should access consecutive memory addresses. This is called memory coalescing.
    For example, if unit 0 reads address 0, unit 1 reads address 1, … up to unit 31, the GPU can fetch all in a single transaction.
    Strided or scattered access reduces bandwidth efficiency.

  • CPU: Each thread should work on a contiguous block of memory that fits in the CPU cache.
    Strided or scattered access forces threads to load data from different cache lines, increasing cache misses and slowing down execution.
    CPUs prefer sequential access per thread to maximize cache line utilization and prefetching.

These requirements can sometimes conflict, so it is recommended to query hardware properties (e.g., plane size) and design your kernel to handle both backends efficiently.

Vectorization

CubeCL supports a "line" abstraction that lets each thread process multiple elements per memory transaction. None of the kernels currently use this. Enabling it means fewer global memory accesses for the same amount of work, which compounds well with good access patterns. In hardware properties, there is a load_width specifying how many bits can be loaded at the same time by one unit. The maximal vectorization factor for your backend is load_width divided by the number of bits of each element (how many bits are there in an f32?). A good vectorization factor can drastically speedup global memory reads and writes, especially on GPU. On CPU, it's more important at speeding the compute, but the FFT inner compute might be very hard to vectorize.

To be able to vectorize a tensor, the dimension in which elements are contiguous in memory (the last dimension in our case because everything is in row-major order), must be divisible by the vectorization factor. While this should be the case for windows of signal samples which are assumed to be a power of 2 (typically, window_length=2048 elements), this won't be the case for the spectrums, because of the formula freq_bins = window_length / 2 + 1 = 1025. Perhaps padding each window of frequency bins (to have a shape of say, 1032) with zeros would help.

Kernel Fusion

Kernel fusion combines multiple operations into a single kernel, allowing intermediate results to stay in registers or shared memory rather than being written back and forth to global memory.

Currently, the FFT, phase shift, and IRFFT are separate kernel launches.

In principle, these three kernels could be merged into a single kernel (or at least two), since they have similar launch configurations, and data in the FFT is already in shared memory before being written to global memory.

Others

  • Unrolling: CubeCL supports loop unrolling via #[unroll] over for loops, but only when the loop range is a comptime value. Comptime values are a CubeCL concept: constants baked into the kernel at compile time rather than passed as runtime arguments. If a loop bound is a runtime variable, it cannot be unrolled. When applicable, unrolling eliminates branch overhead and can expose more instruction-level parallelism to the compiler.

  • Replace while loops with for loops: while loops can create unpredictable branching within a plane, leading to divergence and reduced performance. Using for loops with known bounds makes execution more predictable.

  • Balance workloads: Ideally, on GPU, the cube count should spread equally across streaming multiprocessors (SMs). The number of SMs is available in the hardware properties.

  • Respect the hardware: Using too many planes at once may exceed what the system can efficiently support. The same applies to registers, shared memory, and cache. These resources are limited, and pushing them too far can silently fallback to less efficient behaviors. In high-performance, everything is a tradeoff.

Evaluation and Ranking

Disqualification Criteria

Teams that produce incorrect results on either CPU or CUDA will be disqualified and excluded from ranking statistics.

Scoring System

All teams will be evaluated on the same machine for each backend. Your score is calculated as:

$$ \text{score} = \frac{\text{mean}_{\text{CPU}} - \text{duration}_{\text{CPU}}}{\text{std}_{\text{CPU}}} + \frac{\text{mean}_{\text{CUDA}} - \text{duration}_{\text{CUDA}}}{\text{std}_{\text{CUDA}}} $$

Where mean and std are computed across all qualifying teams for each backend, and duration is your team's benchmark time (mean of 10 runs after warmup). Higher is better.

Tiebreaker

Due to the nondeterministic nature of benchmarks, scores that are extremely close may be considered tied.

If this occurs, the tied teams’ submissions will be benchmarked again on a different machine using additional backends (AMD and/or Metal). These results will be used to determine the final ranking.

Submission

Make sure to push on your personal fork. Only your final commit will be evaluated. Make sure it:

  1. Passes both critical correctness tests
  2. Includes your optimization work
  3. Does not introduce new dependencies beyond what's provided

If you discover an issue with your final commit after submission, it's worth reaching out, but no guarantees can be made.

Tips

  1. Profile first. Use the CubeCL profiler (set stdout to true in cubecl.toml) to identify bottlenecks before changing anything. The codebase is small enough that you can also reason about where time is spent, but measurements are more reliable.

  2. I/O dominates compute, especially on GPU. Reading from and writing to global memory is far more expensive than the arithmetic itself. On GPU in particular, the biggest gains usually come from reducing how much data you move, not from squeezing more flops out of the compute. On CPU the gap is smaller, but memory access patterns still matter a lot.

  3. Verify correctness. Run cargo test after every change. Incorrect results will get you disqualified! Slower but correct is always better.

  4. Commit often. Each time you have better performance and passing tests, save that state. It's easy to break things; checkpoints let you recover.

  5. Start small. Make incremental changes and test frequently. It's easier to debug one change than a massive rewrite.

  6. Balance backends. Don't over-optimize for one at the expense of the other. Your score depends on both CPU and CUDA.

  7. Accept temporary breakage. Removing code to measure how much it costs is a valid way to build intuition. You'll lose correctness for a moment but gain insight on performance impact of some lines of code. Just don't submit in that state.

  8. Verify if a change is worth pursuing. Following point 7, before optimizing code, check if removing the code altogether actually speeds up anything.


cubefx

La musique synthwave est avant tout une question de son rétro-futuriste : batteries percutantes, synthés scintillants, basse grave et profonde. Pensez à l'esthétique des années 80 rencontre la production moderne. Pour donner à l'audio ce caractère aux néons, on applique un décalage de phase par bin (per-bin phase shift) sur tout le spectre de fréquences — un effet qui colore le son avec une texture rétro caractéristique.

Votre mission : rendre ce pipeline incroyablement rapide sur CPU et CUDA, sans sacrifier la précision.

Pour cela, vous utiliserez CubeCL, un framework Rust pour des kernels de calcul haute performance qui s'exécutent à la fois sur CPU et GPU, vous permettant d'optimiser le parallélisme et les accès mémoire depuis une seule base de code.

Traitement audio

Une chanson est une onde continue, aussi appelée signal. Pour la stocker sur un ordinateur, on la discrétise en échantillons (samples). Chaque échantillon représente l'amplitude du signal à un instant donné.

Onde continue :         ∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿

Échantillons discrets : • • • • • • • • • • • •
                        ↑ ↑ ↑ ↑ ↑ ↑ ↑ ↑ ↑ ↑ ↑ ↑
                      Points d'échantillonnage à intervalles réguliers

Un taux d'échantillonnage typique est de 44 100 Hz, soit 44 100 échantillons par seconde. L'audio peut être mono, stéréo ou plus, ce qui signifie que le signal comporte plusieurs canaux en parallèle.

La transformée de Fourier

Pour travailler dans le domaine fréquentiel, on applique une transformée de Fourier. Cette opération mathématique convertit un signal dans le domaine temporel en ses composantes fréquentielles, c'est-à-dire l'ensemble des ondes sinusoïdales à différentes fréquences dont la somme recrée le signal original.

Domaine temporel (signal) :   Domaine fréquentiel (spectre) :

    ∿∿                        |     |              |
   ∿  ∿                       |     |     |        |
  ∿    ∿        ──→           |     |     |   |    |
 ∿      ∿                     └─────┴─────┴───┴────┴──
                              Bas         Milieu   Haut
 Onde complexe                Composantes fréquentielles individuelles

Chaque bin de fréquence est représenté par un nombre complexe. En pratique, on utilise simplement une paire de nombres flottants (parties réelle et imaginaire), ou, pour les tenseurs, une paire de tenseurs de nombres flottants. Puisqu'on part d'échantillons audio à valeurs réelles, on utilise la Real FFT (RFFT), qui exploite la symétrie pour ne produire que la moitié non redondante du spectre. L'inverse (IRFFT) retransforme en échantillons.

Le fenêtrage (Windowing)

On ne peut pas appliquer la FFT à toute la chanson d'un coup, car les chansons ne sont pas périodiques, et les traiter ainsi entraîne une perte d'information. On travaille donc sur de petites fenêtres qui se chevauchent. Les fenêtres voisines se superposent pour que chaque partie de la chanson soit couverte en douceur, évitant les clics et artefacts aux frontières.

Le fenêtrage est pré-appliqué avant le début de votre pipeline. Vous recevez des données déjà fenêtrées et retournez des fenêtres traitées. La reconstruction du signal est hors sujet aujourd'hui.

L'effet de décalage de phase

Pour chaque bin de fréquence k dans le spectre, on applique une rotation proportionnelle à l'indice du bin :

spectrum[k]  *=  e^(i·α·k)  =  cos(α·k) + i·sin(α·k)

Cela donne aux basses fréquences une légère nudge et aux hautes fréquences une rotation plus importante, créant une coloration caractéristique du signal. Un scalaire unique α contrôle l'intensité de l'effet.

Le pipeline

Trois kernels sont exécutés : RFFT, décalage de phase et IRFFT. Le benchmark mesure l'exécution de l'ensemble du pipeline. Lors du profilage, vous pourrez aussi voir un PRNG (pseudo-random number generator) tournant pour créer des données, mais celui-ci n'est pas mesuré dans les benchmarks évalués.

Entrée pré-fenêtrée   [num_windows, num_channels, window_length]
        │
        ▼
      RFFT           [num_windows, num_channels, freq_bins]   (complexe : deux tenseurs de flottants)
        │
        ▼
  Décalage de phase  spectrum[k] *= e^(i·α·k)
        │
        ▼
      IRFFT          [num_windows, num_channels, window_length]
        │
        ▼
  Fenêtres traitées

Par exemple, avec window_length := 2048, la RFFT produit freq_bins := window_length / 2 + 1 = 1025 bins de fréquence.

Tenseurs, formes et disposition mémoire

Un tenseur est un tableau multidimensionnel. On peut le voir comme une généralisation d'une matrice à un nombre quelconque de dimensions. Dans cette base de code, les données audio sont stockées sous forme de tenseurs. Par exemple, l'entrée du pipeline a la forme [num_windows, num_channels, window_length] : la première dimension indexe la fenêtre, la seconde le canal, et la troisième l'échantillon dans la fenêtre.

La forme (shape) indique combien d'éléments existent le long de chaque dimension. Les strides (strides) indiquent comment traduire un indice multidimensionnel en décalage mémoire plat. Pour un tenseur row-major de forme [4, 3], les strides sont [3, 1] : avancer d'un indice sur la dimension 0 saute 3 éléments en mémoire, et avancer sur la dimension 1 en saute 1.

Forme [4, 3], strides [3, 1] :

Disposition logique :  Mémoire (plate) :
┌───┬───┬───┐
│ 0 │ 1 │ 2 │          0  1  2  3  4  5  6  7  8  9 10 11
├───┼───┼───┤          ▲        ▲        ▲        ▲
│ 3 │ 4 │ 5 │          ligne 0  ligne 1  ligne 2  ligne 3
├───┼───┼───┤
│ 6 │ 7 │ 8 │
├───┼───┼───┤
│ 9 │10 │11 │
└───┴───┴───┘

Tous les tenseurs de cette base de code sont row-major, ce qui signifie que la dernière dimension est toujours contiguë en mémoire (stride 1). Cela importe pour l'optimisation : lorsque les threads accèdent à des éléments consécutifs sur la dernière dimension, les chargements mémoire peuvent être coalescés sur GPU et favorables au cache sur CPU.

Vous n'aurez pas besoin de calculer manuellement les décalages mémoire, car layout.rs gère les strides à votre place. Si vous êtes curieux des détails, c'est l'endroit où regarder.

Démarrage

Commencez par forker ce dépôt (utilisez le bouton Fork dans le coin supérieur droit de la page GitHub). Durant la compétition, on vous demandera de fournir le lien vers votre fork. Assurez-vous de travailler sur votre fork personnelle, en particulier pour votre dernier commit.

Lancer l'application

cargo run --release

Utilisez toujours --release pour des mesures fiables ; sinon, une version de débogage est compilée, souvent bien plus lente et non représentative.

Sélection du backend :

# CPU
cargo run --release --features cubecl/cpu

# CUDA
cargo run --release --features cubecl/cuda

# Défaut : WGPU (aucun flag nécessaire)

Vous serez évalué sur CPU et CUDA, donc faites vos benchmarks avec ces configurations.

Profilage

Activez le profileur CubeCL en mettant stdout à true dans cubecl.toml pour identifier les kernels goulots d'étranglement. Vous devrez peut-être aussi définir la variable d'environnement CUBECL_DEBUG_OPTION à profile :

export CUBECL_DEBUG_OPTION=profile

Plus de détails de configuration sont disponibles ici bien que de nombreux paramètres soient superflus pour ce projet (pas de streams ni d'autotuning).

Tester la correction

Lancez la suite de tests fréquemment :

cargo test

Sélection du backend :

# CPU
cargo test --features cubecl/cpu

# CUDA
cargo test --features cubecl/cuda

# Défaut : WGPU
cargo test

Tests critiques :

  • large_fft_roundtrip_no_phase_shift : vérifie la précision de l'aller-retour FFT/IFFT
  • small_fft_round_trip_with_phase_shift : valide le pipeline d'effet complet

Ces deux tests sont les seuls qui comptent vraiment pour l'évaluation. Les tests dans cubefx-engine sont là pour vous aider à déboguer en cours de route. Consultez les entrées des tests et benchmarks pour comprendre les hypothèses raisonnables sur les données (ex. taille de fenêtre, nombre de canaux).

Débogage

Mode test :

Vous pouvez contrôler la façon dont les tests gèrent les erreurs numériques et de compilation via la variable d'environnement CUBE_TEST_MODE :

  • Correct (défaut) : les erreurs numériques font échouer le test, en affichant uniquement la première erreur.
  • PrintFail : affiche tous les éléments du tenseur, en indiquant lesquels sont erronés. Accepte un suffixe de filtre optionnel pour ne voir qu'une partie des données.
export CUBE_TEST_MODE=Correct                  # défaut
export CUBE_TEST_MODE=PrintFail:.,10-20        # filtre : toutes les premières dims, indices 10–20 sur la seconde

Les filtres sont des sélecteurs de dimension séparés par des virgules : . pour tous les indices, M pour un indice unique, M-N pour une plage. Le nombre d'entrées doit correspondre au rang du tenseur. Pour plus de détails, consultez ici.

Code généré :

Pour CUDA ou WGPU, vous pouvez afficher le code généré de chaque kernel en définissant la variable d'environnement CUBECL_DEBUG_LOG à stdout. Plus de détails ici. Cela peut ne rien afficher si le test réussit ; faites échouer le test ou ajoutez le flag --nocapture.

export CUBECL_DEBUG_LOG=stdout
cargo test --features cubecl/cuda -- --nocapture

Mettez la variable à 0 pour la désactiver.

export CUBECL_DEBUG_LOG=0

Structure du code

Le projet est divisé en deux crates : cubefx-eval est le binaire principal, et cubefx-engine est la bibliothèque où réside tout votre travail.

cubefx-eval (Ne pas modifier)

Gère le benchmarking et les tests de correction. Vos modifications ici ne seront pas utilisées lors de l'évaluation, car nous utiliserons notre propre version. Notez que le type de données (f32) est sélectionné dans cette crate, donc choisir un type plus petit n'est pas une option.

cubefx-engine (Votre espace de travail)

Toute la logique de traitement audio réside ici. Vous pouvez tout modifier, à condition que :

  1. L'API utilisée par cubefx-eval reste compatible
  2. Les deux tests de correction critiques passent

Le backend est sélectionné à la compilation via TestRuntime de CubeCL, en utilisant --features cubecl/cuda ou --features cubecl/cpu.

Fichiers :

  • base.rs : Ne pas modifier. Contient le point d'entrée appelé par cubefx-eval.
  • cube/
    • phase_shift.rs : Kernel CubeCL et code de lancement pour le décalage de phase par bin
    • layout.rs : Convertit les tenseurs en vues sur un seul élément de batch à la fois, gérant les décalages de batch et les strides
    • tests/ : Tests RFFT et IRFFT, vérifiés contre une implémentation de référence en Rust pur
    • fft/
      • rfft.rs : Kernel CubeCL et code de lancement pour la Real FFT
      • irfft.rs : Kernel CubeCL et code de lancement pour l'IRFFT
      • fft_inner.rs : Calcul interne partagé pour les deux directions FFT. Pas d'E/S mémoire globale ni de dispatch ici, seulement l'arithmétique de base. Plus complexe à comprendre et probablement plus difficile à optimiser ; à aborder avec précaution.

La plupart de vos modifications se feront dans phase_shift.rs, rfft.rs et irfft.rs. Vous trouverez peut-être aussi des opportunités dans layout.rs et fft_inner.rs, bien que ce soit probablement plus difficile.

Opportunités d'optimisation

Normalement, le kernel de décalage de phase devrait s'exécuter beaucoup plus rapidement que les deux autres, même dans leur forme sous-optimale actuelle. Si ce n'est pas le cas, le kernel de décalage de phase fait probablement quelque chose de stupide. Une fois cela corrigé, vous pouvez passer à des modifications de kernels moins triviales.

Configuration de lancement

Les kernels par défaut sont extrêmement naïfs : un seul worker traite toute l'entrée séquentiellement.

Les kernels CubeCL sont lancés avec un cube count et un cube dim. Pour l'instant, tous les cube dims et cube counts sont codés en dur à 1.

  • cube count : Le nombre de tâches indépendantes pouvant s'exécuter sur différents processeurs de flux.
    Sur CUDA, cela correspond aux blocks. Sur CPU, cela crée une boucle sur toutes les tâches.
    Dans le kernel, vous pouvez accéder à l'identifiant du cube courant via CUBE_POS (ou CUBE_POS_X, CUBE_POS_Y, CUBE_POS_Z si on utilise plusieurs dimensions).

  • cube dim : Le nombre de workers par cube (appelés units dans CubeCL).
    Sur CUDA, cela correspond aux threads. Sur CPU, cela correspond aussi aux threads (cœurs).

    Dans un cube, les units sont regroupés en planes (appelés warps sur CUDA).
    Sur CPU, la taille de plane est 1 (une unit = un plane).

    Les units dans un plane s'exécutent en lockstep : ils accèdent à la mémoire en même temps et suivent le même chemin de code (sauf en cas de divergence).

    Vous pouvez interroger la taille de plane à l'exécution avec : client.properties().hardware.plane_size_max

    Il est recommandé de définir cube_dim en 2D :
    (plane_size, number_of_planes)

    Dans le kernel :

    • CUBE_DIM_X : taille du plane
    • CUBE_DIM_Y : nombre de planes
    • UNIT_POS_X : identifiant de l'unit dans le plane
    • UNIT_POS_Y : identifiant du plane dans le cube

Motifs d'accès mémoire

La façon dont vos threads accèdent à la mémoire a un impact majeur sur les performances :

  • GPU : Les threads dans le même plane (warp) devraient accéder à des adresses mémoire consécutives. C'est ce qu'on appelle la coalescence mémoire (memory coalescing).
    Par exemple, si l'unit 0 lit l'adresse 0, l'unit 1 lit l'adresse 1, … jusqu'à l'unit 31, le GPU peut tout charger en une seule transaction.
    Un accès strié ou dispersé réduit l'efficacité de la bande passante.

  • CPU : Chaque thread devrait travailler sur un bloc continu de mémoire qui tient dans le cache CPU.
    Un accès strié ou dispersé force les threads à charger des données depuis différentes lignes de cache, augmentant les cache misses et ralentissant l'exécution.
    Les CPU préfèrent un accès séquentiel par thread pour maximiser l'utilisation des lignes de cache et le prefetching.

Ces exigences peuvent parfois être contradictoires, il est donc recommandé d'interroger les propriétés matérielles (ex. taille de plane) et de concevoir votre kernel pour gérer efficacement les deux backends.

Vectorisation

CubeCL supporte une abstraction « line » qui permet à chaque thread de traiter plusieurs éléments par transaction mémoire. Aucun kernel ne l'utilise actuellement. L'activer signifie moins d'accès à la mémoire globale pour la même quantité de travail, ce qui se combine bien avec de bons motifs d'accès. Dans les propriétés matérielles, il y a un load_width spécifiant combien de bits peuvent être chargés en même temps par une unit. Le facteur de vectorisation maximal pour votre backend est load_width divisé par le nombre de bits de chaque élément (combien de bits y a-t-il dans un f32 ?). Un bon facteur de vectorisation peut drastiquement accélérer les lectures et écritures en mémoire globale, surtout sur GPU. Sur CPU, c'est plus utile pour accélérer le calcul, mais le calcul interne de la FFT peut être très difficile à vectoriser.

Pour pouvoir vectoriser un tenseur, la dimension dans laquelle les éléments sont contigus en mémoire (la dernière dimension dans notre cas, car tout est en ordre row-major) doit être divisible par le facteur de vectorisation. Bien que ce soit le cas pour les fenêtres d'échantillons de signal qui sont supposées être une puissance de 2 (typiquement window_length=2048 éléments), ce ne sera pas le cas pour les spectres, à cause de la formule freq_bins = window_length / 2 + 1 = 1025. Peut-être que padder chaque fenêtre de bins de fréquence (pour avoir une forme de, disons, 1032) avec des zéros pourrait aider.

Fusion de kernels (Kernel Fusion)

La fusion de kernels combine plusieurs opérations en un seul kernel, permettant aux résultats intermédiaires de rester dans les registres ou la mémoire partagée plutôt que d'être écrits et relus depuis la mémoire globale.

Actuellement, la FFT, le décalage de phase et l'IRFFT sont des lancements de kernels séparés.

En principe, ces trois kernels pourraient être fusionnés en un seul (ou au moins deux), puisqu'ils ont des configurations de lancement similaires, et les données de la FFT sont déjà en mémoire partagée avant d'être écrites en mémoire globale.

Autres

  • Unrolling : CubeCL supporte le déroulage de boucles via #[unroll] sur les boucles for, mais uniquement lorsque la plage de la boucle est une valeur comptime. Les valeurs comptime sont un concept CubeCL : des constantes intégrées dans le kernel à la compilation plutôt que passées en arguments à l'exécution. Si la borne d'une boucle est une variable à l'exécution, elle ne peut pas être déroulée. Quand applicable, le déroulage élimine le surcoût des branchements et peut exposer davantage de parallélisme au niveau instruction au compilateur.

  • Remplacer les boucles while par des boucles for : les boucles while peuvent créer des branchements imprévisibles dans un plane, entraînant divergence et performances réduites. Utiliser des boucles for avec des bornes connues rend l'exécution plus prévisible.

  • Équilibrer les charges de travail : Idéalement, sur GPU, le cube count devrait se répartir équitablement entre les processeurs de flux (SMsstreaming multiprocessors). Le nombre de SMs est disponible dans les propriétés matérielles.

  • Respecter le matériel : Utiliser trop de planes à la fois peut dépasser ce que le système peut supporter efficacement. Il en va de même pour les registres, la mémoire partagée et le cache. Ces ressources sont limitées, et les pousser trop loin peut silencieusement basculer vers des comportements moins efficaces. En haute performance, tout est un compromis.

Évaluation et classement

Critères de disqualification

Les équipes produisant des résultats incorrects sur CPU ou CUDA seront disqualifiées et exclues des statistiques de classement.

Système de notation

Toutes les équipes seront évaluées sur la même machine pour chaque backend. Votre score est calculé comme suit :

$$ \text{score} = \frac{\text{mean}_{\text{CPU}} - \text{duration}_{\text{CPU}}}{\text{std}_{\text{CPU}}} + \frac{\text{mean}_{\text{CUDA}} - \text{duration}_{\text{CUDA}}}{\text{std}_{\text{CUDA}}} $$

mean et std sont calculés sur toutes les équipes qualifiées pour chaque backend, et duration est le temps de benchmark de votre équipe (moyenne de 10 exécutions après préchauffage). Plus c'est élevé, mieux c'est.

Départage

En raison de la nature non déterministe des benchmarks, des scores extrêmement proches peuvent être considérés comme ex æquo.

Si cela se produit, les soumissions des équipes à égalité seront de nouveau benchmarkées sur une machine différente en utilisant des backends supplémentaires (AMD et/ou Metal). Ces résultats seront utilisés pour déterminer le classement final.

Soumission

Make sure to push on your personal fork. Seul votre dernier commit sera évalué. Assurez-vous qu'il :

  1. Passe les deux tests de correction critiques
  2. Inclut votre travail d'optimisation
  3. N'introduit pas de nouvelles dépendances au-delà de ce qui est fourni

Si vous découvrez un problème avec votre dernier commit après la soumission, cela vaut la peine de nous contacter, mais aucune garantie ne peut être donnée.

Conseils

  1. Profilez d'abord. Utilisez le profileur CubeCL (mettez stdout à true dans cubecl.toml) pour identifier les goulots d'étranglement avant de changer quoi que ce soit. La base de code est assez petite pour raisonner sur les temps d'exécution, mais les mesures sont plus fiables.

  2. Les Entrées/Sorties dominent le calcul, surtout sur GPU. Lire et écrire en mémoire globale est bien plus coûteux que l'arithmétique elle-même. Sur GPU en particulier, les plus grands gains viennent généralement de la réduction du volume de données déplacées, pas d'extraire plus de flops du calcul.

  3. Vérifiez la correction. Lancez cargo test après chaque modification. Des résultats incorrects vous disqualifieront ! Plus lent mais correct vaut toujours mieux.

  4. Commitez souvent. Chaque fois que vous avez de meilleures performances et des tests qui passent, sauvegardez cet état. Il est facile de casser quelque chose ; les points de sauvegarde permettent de récupérer.

  5. Commencez petit. Faites des changements incrémentaux et testez fréquemment. Il est plus facile de déboguer un changement qu'une réécriture massive.

  6. Équilibrez les backends. Ne sur-optimisez pas pour l'un au détriment de l'autre. Votre score dépend des deux, CPU et CUDA.

  7. Acceptez la casse temporaire. Supprimer du code pour mesurer combien il coûte est une façon valide de construire une intuition. Vous perdrez l'aspect correct pendant un moment mais gagnerez en compréhension de l'impact sur les performances de certaines lignes de code. Ne soumettez juste pas dans cet état.

  8. Vérifiez si un changement vaut la peine d'être poursuivi. Suite au point 7, avant d'optimiser du code, vérifiez que supprimer entièrement ce code accélère effectivement quelque chose.

About

Optimizing audio effects with CubeCL

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages