November 20, 202512 min

Utiliser des tableaux de taille fixe ou Option pour éviter les allocations dans un chemin critique en performance

m
mayo

Dans un système temps réel, les allocations heap via Box, Vec, ou autres structures dynamiques introduisent de la latence due à l'overhead de gestion mémoire et aux pauses potentielles de garbage collection (bien que Rust évite le GC, allocation/désallocation varie encore). J'utiliserais les fonctionnalités stack-based de Rust comme les tableaux de taille fixe, Option, et structs custom pour éliminer celles-ci dans un chemin critique en performance, assurant une exécution prévisible et faible latence.

Scénario d'Exemple : Remplacer un Buffer Dynamique

Suppose que je construis un processeur audio temps réel qui gère des chunks de 64 échantillons. Une implémentation naïve pourrait utiliser un Vec :

struct AudioProcessor {
    buffer: Vec<f32>, // Alloué sur heap, redimensionnable
}

impl AudioProcessor {
    fn new() -> Self {
        AudioProcessor { buffer: vec![0.0; 64] } // Alloue sur heap
    }

    fn process(&mut self, input: f32) {
        self.buffer.push(input); // Réalloue si plein
        if self.buffer.len() > 64 { self.buffer.remove(0); }
    }
}

Cela fonctionne mais risque des pics de latence dus aux réallocations ou déplacements d'éléments.

Alternative Stack-Based

Je remplacerais Vec par un tableau de taille fixe et une approche de buffer circulaire, tout sur la stack :

struct AudioProcessor {
    buffer: [f32; 64], // Alloué sur stack, taille fixe
    index: usize,      // Position d'écriture actuelle
}

impl AudioProcessor {
    fn new() -> Self {
        AudioProcessor {
            buffer: [0.0; 64], // Initialisé à zéro sur stack
            index: 0,
        }
    }

    fn process(&mut self, input: f32) {
        self.buffer[self.index] = input;         // Pas d'allocation
        self.index = (self.index + 1) % 64;      // Reboucle
    }

    fn get_sample(&self, offset: usize) -> Option<f32> {
        let read_idx = (self.index.wrapping_sub(offset + 1)) % 64;
        Some(self.buffer[read_idx]) // Accès stack, pas heap
    }
}
  • Tableau Taille Fixe : [f32; 64] alloue 64 floats (256 octets) sur la stack pendant la compilation—pas d'allocation runtime.
  • Indexation Circulaire : index suit la position d'écriture, rebouclant avec modulo—pas de déplacement ou redimensionnement.
  • Option : get_sample retourne Option<f32> pour gérer l'accès en sécurité sans types d'erreur basés heap.

Comment Ça Élimine les Allocations

  • Pas de Heap : Le tableau est alloué sur stack, fixé pendant la compilation. Pas d'appels à malloc ou free.
  • Déterminisme : Les écritures et lectures sont O(1) avec des cycles prévisibles—pas de délais de réallocation ou désallocation.
  • Taille Connue : 64 éléments s'adaptent à la contrainte temps réel (ex : une frame audio 1ms à 64kHz), évitant le redimensionnement dynamique.

Techniques Avancées d'Optimisation Stack

1. Structures de Données Stack Spécialisées

// Stack-based queue avec capacité fixe
#[derive(Debug)]
struct StackQueue<T, const N: usize> {
    data: [std::mem::MaybeUninit<T>; N],
    head: usize,
    tail: usize,
    len: usize,
}

impl<T, const N: usize> StackQueue<T, N> {
    fn new() -> Self {
        Self {
            data: unsafe { std::mem::MaybeUninit::uninit().assume_init() },
            head: 0,
            tail: 0,
            len: 0,
        }
    }
    
    fn push(&mut self, value: T) -> Result<(), T> {
        if self.len == N {
            return Err(value); // Queue pleine
        }
        
        self.data[self.tail].write(value);
        self.tail = (self.tail + 1) % N;
        self.len += 1;
        Ok(())
    }
    
    fn pop(&mut self) -> Option<T> {
        if self.len == 0 {
            return None;
        }
        
        let value = unsafe { self.data[self.head].assume_init_read() };
        self.head = (self.head + 1) % N;
        self.len -= 1;
        Some(value)
    }
    
    fn len(&self) -> usize {
        self.len
    }
    
    fn is_full(&self) -> bool {
        self.len == N
    }
    
    fn is_empty(&self) -> bool {
        self.len == 0
    }
}

impl<T, const N: usize> Drop for StackQueue<T, N> {
    fn drop(&mut self) {
        // Nettoie les éléments restants
        while self.pop().is_some() {}
    }
}

// Stack-based ring buffer pour traitement audio
struct RingBuffer<T, const N: usize> {
    data: [T; N],
    write_pos: usize,
}

impl<T: Copy + Default, const N: usize> RingBuffer<T, N> {
    fn new() -> Self {
        Self {
            data: [T::default(); N],
            write_pos: 0,
        }
    }
    
    fn write(&mut self, value: T) {
        self.data[self.write_pos] = value;
        self.write_pos = (self.write_pos + 1) % N;
    }
    
    fn read_delayed(&self, delay: usize) -> T {
        let delay = delay.min(N - 1); // Clamp delay
        let read_pos = (self.write_pos + N - delay - 1) % N;
        self.data[read_pos]
    }
    
    fn read_history(&self, samples_back: usize) -> Option<T> {
        if samples_back >= N {
            return None;
        }
        let read_pos = (self.write_pos + N - samples_back - 1) % N;
        Some(self.data[read_pos])
    }
}

2. Processeur Audio Avancé avec Effets

// Processeur audio multi-effet sans allocations heap
struct MultiEffectProcessor {
    // Delay line pour reverb
    delay_buffer: RingBuffer<f32, 4096>,
    
    // Filtres IIR (biquad) - coefficients stack
    filter_state: BiquadState,
    filter_coeffs: BiquadCoeffs,
    
    // Buffer de travail pour convolution
    work_buffer: [f32; 128],
    
    // États d'effet
    reverb_mix: f32,
    filter_cutoff: f32,
}

#[derive(Copy, Clone)]
struct BiquadCoeffs {
    b0: f32, b1: f32, b2: f32,
    a1: f32, a2: f32,
}

#[derive(Default)]
struct BiquadState {
    x1: f32, x2: f32, // Entrées précédentes
    y1: f32, y2: f32, // Sorties précédentes
}

impl MultiEffectProcessor {
    fn new() -> Self {
        Self {
            delay_buffer: RingBuffer::new(),
            filter_state: BiquadState::default(),
            filter_coeffs: BiquadCoeffs::lowpass(1000.0, 44100.0), // 1kHz lowpass
            work_buffer: [0.0; 128],
            reverb_mix: 0.3,
            filter_cutoff: 1000.0,
        }
    }
    
    fn process_sample(&mut self, input: f32) -> f32 {
        // Filtrage biquad sans allocation
        let filtered = self.biquad_process(input);
        
        // Delay/reverb
        let delayed = self.delay_buffer.read_delayed(2048);
        self.delay_buffer.write(filtered + delayed * 0.4);
        
        // Mix final
        filtered + delayed * self.reverb_mix
    }
    
    fn biquad_process(&mut self, input: f32) -> f32 {
        let coeffs = &self.filter_coeffs;
        let state = &mut self.filter_state;
        
        // Équation différence biquad standard
        let output = coeffs.b0 * input 
                   + coeffs.b1 * state.x1 
                   + coeffs.b2 * state.x2
                   - coeffs.a1 * state.y1
                   - coeffs.a2 * state.y2;
        
        // Mise à jour état
        state.x2 = state.x1;
        state.x1 = input;
        state.y2 = state.y1;
        state.y1 = output;
        
        output
    }
    
    fn process_block(&mut self, input: &[f32], output: &mut [f32]) {
        assert_eq!(input.len(), output.len());
        assert!(input.len() <= self.work_buffer.len());
        
        // Traite par blocs pour optimiser cache
        for (i, (&sample_in, sample_out)) in input.iter().zip(output.iter_mut()).enumerate() {
            self.work_buffer[i] = self.process_sample(sample_in);
        }
        
        // Copie résultats
        output[..input.len()].copy_from_slice(&self.work_buffer[..input.len()]);
    }
}

impl BiquadCoeffs {
    fn lowpass(cutoff: f32, sample_rate: f32) -> Self {
        let omega = 2.0 * std::f32::consts::PI * cutoff / sample_rate;
        let cos_omega = omega.cos();
        let sin_omega = omega.sin();
        let alpha = sin_omega / (2.0 * 0.707); // Q = 0.707
        
        let b0 = (1.0 - cos_omega) / 2.0;
        let b1 = 1.0 - cos_omega;
        let b2 = (1.0 - cos_omega) / 2.0;
        let a0 = 1.0 + alpha;
        let a1 = -2.0 * cos_omega;
        let a2 = 1.0 - alpha;
        
        // Normalise par a0
        Self {
            b0: b0 / a0, b1: b1 / a0, b2: b2 / a0,
            a1: a1 / a0, a2: a2 / a0,
        }
    }
}

3. Gestion d'Erreurs sans Allocation

// Types d'erreur basés sur enums - pas d'allocation
#[derive(Debug, Copy, Clone, PartialEq)]
enum AudioError {
    BufferOverflow,
    InvalidSampleRate,
    ProcessingTimeout,
    HardwareError(u8), // Code d'erreur matériel
}

// Result type personnalisé pour éviter String
type AudioResult<T> = Result<T, AudioError>;

// Gestionnaire d'erreur avec buffer stack
struct ErrorLogger {
    errors: StackQueue<(AudioError, u64), 32>, // 32 erreurs max
    timestamp_counter: u64,
}

impl ErrorLogger {
    fn new() -> Self {
        Self {
            errors: StackQueue::new(),
            timestamp_counter: 0,
        }
    }
    
    fn log_error(&mut self, error: AudioError) {
        self.timestamp_counter += 1;
        
        // Si queue pleine, ignore l'erreur la plus ancienne
        if self.errors.is_full() {
            self.errors.pop();
        }
        
        let _ = self.errors.push((error, self.timestamp_counter));
    }
    
    fn get_recent_errors(&self) -> impl Iterator<Item = &(AudioError, u64)> {
        // Note: ceci nécessiterait une implémentation d'iterator custom
        // pour StackQueue, simplifié ici
        std::iter::empty() // Placeholder
    }
    
    fn clear(&mut self) {
        while self.errors.pop().is_some() {}
    }
}

// Processeur avec gestion d'erreur intégrée
struct RobustAudioProcessor {
    processor: MultiEffectProcessor,
    error_logger: ErrorLogger,
    max_processing_time_us: u64,
}

impl RobustAudioProcessor {
    fn new() -> Self {
        Self {
            processor: MultiEffectProcessor::new(),
            error_logger: ErrorLogger::new(),
            max_processing_time_us: 100, // 100μs timeout
        }
    }
    
    fn process_with_timeout(&mut self, input: f32) -> AudioResult<f32> {
        let start = std::time::Instant::now();
        
        let result = self.processor.process_sample(input);
        
        let elapsed_us = start.elapsed().as_micros() as u64;
        if elapsed_us > self.max_processing_time_us {
            let error = AudioError::ProcessingTimeout;
            self.error_logger.log_error(error);
            return Err(error);
        }
        
        Ok(result)
    }
}

4. Optimisations Temps Réel Spécialisées

// Allocateur stack custom pour objets temporaires
struct StackAllocator<const SIZE: usize> {
    memory: [u8; SIZE],
    offset: usize,
}

impl<const SIZE: usize> StackAllocator<SIZE> {
    fn new() -> Self {
        Self {
            memory: [0; SIZE],
            offset: 0,
        }
    }
    
    fn alloc<T>(&mut self, count: usize) -> Option<&mut [T]> {
        let size_needed = count * std::mem::size_of::<T>();
        let align = std::mem::align_of::<T>();
        
        // Aligne l'offset
        let aligned_offset = (self.offset + align - 1) & !(align - 1);
        
        if aligned_offset + size_needed > SIZE {
            return None; // Pas assez d'espace
        }
        
        let ptr = unsafe { 
            self.memory.as_mut_ptr().add(aligned_offset) as *mut T 
        };
        
        self.offset = aligned_offset + size_needed;
        
        Some(unsafe { std::slice::from_raw_parts_mut(ptr, count) })
    }
    
    fn reset(&mut self) {
        self.offset = 0;
    }
    
    fn bytes_used(&self) -> usize {
        self.offset
    }
    
    fn bytes_remaining(&self) -> usize {
        SIZE - self.offset
    }
}

// Processeur utilisant allocateur stack pour buffers temporaires
struct OptimizedProcessor {
    main_buffer: [f32; 1024],
    stack_alloc: StackAllocator<4096>, // 4KB stack allocator
}

impl OptimizedProcessor {
    fn new() -> Self {
        Self {
            main_buffer: [0.0; 1024],
            stack_alloc: StackAllocator::new(),
        }
    }
    
    fn process_complex_algorithm(&mut self, input: &[f32]) -> Option<Vec<f32>> {
        // Reset allocateur pour cette frame
        self.stack_alloc.reset();
        
        // Alloue buffers temporaires sur notre stack custom
        let temp_buffer1 = self.stack_alloc.alloc::<f32>(input.len())?;
        let temp_buffer2 = self.stack_alloc.alloc::<f32>(input.len())?;
        
        // Traitement complexe utilisant buffers temporaires
        for (i, &sample) in input.iter().enumerate() {
            temp_buffer1[i] = sample * 0.5; // Première étape
        }
        
        for (i, &sample) in temp_buffer1.iter().enumerate() {
            temp_buffer2[i] = sample.sin(); // Deuxième étape
        }
        
        // Copie résultat final (dans un vrai système, on éviterait Vec)
        // Ici juste pour démonstration
        Some(temp_buffer2.to_vec())
    }
}

Assurer la Sécurité

  • Sécurité des Bounds : L'opération modulo (% 64) assure que l'index reste dans [0, 63]. L'indexation de tableau de Rust panic sur out-of-bounds en mode debug, attrapant les erreurs tôt.
  • Contrôle de Lifetime : L'allocation stack lie la lifetime du buffer à AudioProcessor, évitant les références pendantes.
  • Pas d'Overflow : Pour des petits tableaux (256 octets ici), le stack overflow est improbable sur des stacks de thread typiques de 1MB. Pour des tailles plus larges, je vérifierais contre la limite stack de la cible (ex : ulimit -s).

Maintenir les Performances

  • Localité Cache : Le [f32; 64] contigu rentre dans le cache L1 (typiquement 32KB), plus rapide qu'un Vec alloué heap avec fragmentation potentielle.
  • Pas d'Overhead : Pas d'indirection de pointeur ou comptabilité d'allocation—juste accès mémoire direct.
  • Inlining : Les petites méthodes comme process sont facilement inlinées par le compilateur, minimisant le coût d'appel de fonction.

Benchmarking et Validation Temps Réel

Tests de Latence Déterministe

use std::time::Instant;

fn benchmark_deterministic_latency() {
    let mut processor = AudioProcessor::new();
    let test_samples = [1.0f32; 1000];
    let mut latencies = Vec::new();
    
    for &sample in &test_samples {
        let start = Instant::now();
        processor.process(sample);
        let latency = start.elapsed().as_nanos();
        latencies.push(latency);
    }
    
    // Analyse statistique
    latencies.sort_unstable();
    let min = latencies[0];
    let max = latencies[latencies.len() - 1];
    let median = latencies[latencies.len() / 2];
    let p99 = latencies[(latencies.len() as f32 * 0.99) as usize];
    
    println!("Latency stats (ns):");
    println!("  Min: {}", min);
    println!("  Median: {}", median);
    println!("  P99: {}", p99);
    println!("  Max: {}", max);
    println!("  Jitter (max-min): {}", max - min);
    
    // Critères temps réel : jitter < 10% de la latence médiane
    assert!(max - min < median / 10, "Excessive jitter detected");
}

fn benchmark_vs_heap_allocation() {
    use criterion::{black_box, Criterion};
    
    let mut c = Criterion::default();
    
    // Stack-based processor
    let mut stack_processor = AudioProcessor::new();
    c.bench_function("stack_audio_process", |b| {
        b.iter(|| {
            stack_processor.process(black_box(1.0));
        })
    });
    
    // Heap-based alternative (for comparison)
    struct HeapProcessor {
        buffer: Vec<f32>,
        index: usize,
    }
    
    impl HeapProcessor {
        fn new() -> Self {
            Self {
                buffer: vec![0.0; 64],
                index: 0,
            }
        }
        
        fn process(&mut self, input: f32) {
            self.buffer[self.index] = input;
            self.index = (self.index + 1) % 64;
        }
    }
    
    let mut heap_processor = HeapProcessor::new();
    c.bench_function("heap_audio_process", |b| {
        b.iter(|| {
            heap_processor.process(black_box(1.0));
        })
    });
}

Profiling Stack Usage

// Mesure utilisation stack avec guards
fn measure_stack_usage() {
    const STACK_GUARD: u64 = 0xDEADBEEFCAFEBABE;
    
    // Place des guards sur la stack
    let guard1 = STACK_GUARD;
    
    // Crée le processeur (utilise stack)
    let processor = AudioProcessor::new();
    
    // Plus de guards
    let guard2 = STACK_GUARD;
    
    // Vérifie que les guards n'ont pas été corrompus
    assert_eq!(guard1, STACK_GUARD, "Stack corruption detected before processor");
    assert_eq!(guard2, STACK_GUARD, "Stack corruption detected after processor");
    
    println!("AudioProcessor stack usage: ~{} bytes", 
             std::mem::size_of::<AudioProcessor>());
    
    // Test avec utilisation intensive
    let mut proc = processor;
    for i in 0..10000 {
        proc.process(i as f32);
    }
    
    assert_eq!(guard1, STACK_GUARD, "Stack corruption during processing");
    assert_eq!(guard2, STACK_GUARD, "Stack corruption during processing");
}

Compromis et Améliorations

  • Capacité Fixe : Si 64 échantillons ne suffisent pas, j'ajusterais la taille (ex : [f32; 128]) au coût de plus d'espace stack, ou utiliserais une approche hybride avec un Box<[f32]> pré-alloué si les limites stack sont une préoccupation.
  • Perte de Flexibilité : Pas de redimensionnement, mais les systèmes temps réel privilégient souvent la prévisibilité sur l'adaptabilité.
  • Structures Stack Custom : Pour des besoins complexes (ex : une queue allouée stack), j'utiliserais une struct avec tableaux et indices, évitant l'usage heap de VecDeque.

Validation et Profiling

Benchmarking avec Criterion

use criterion::{black_box, Criterion};
fn bench(c: &mut Criterion) {
    let mut proc = AudioProcessor::new();
    c.bench_function("stack_process", |b| b.iter(|| proc.process(black_box(1.0))));
}

Attends-toi à des temps cohérents, sub-microseconde vs les pics occasionnels de Vec.

Profiling Performance

  • perf stat -e cycles confirme l'absence de stalls liés à l'allocation.
  • Usage Stack : Vérifie la taille binaire ou utilise #[inline(never)] sur un wrapper pour inspecter la stack frame avec gdb.

Conclusion

Je remplacerais les allocations heap par des tableaux et indices basés stack, comme dans ce processeur audio, assurant un overhead zéro-latence dans un chemin temps réel. Le système de types de Rust et le dimensionnement pendant la compilation garantissent la sécurité, tandis que les boucles serrées et l'accès cache-friendly maintiennent les performances. Cette approche délivre le comportement déterministe critique pour les applications temps réel, avec le profiling validant le gain.

Retour au blog
Partager ::