Dynamic Tensor Chunking: Constant 883MB Memory for Arbitrary-Length Audio
How slicing audio into 30-second tensor chunks prevents OOM on 24-hour recordings while keeping peak memory constant at 883MB.
Project: This post is part of the Local ASR Engine project — a heterogeneous Rust+Python speech recognition system.
TL;DR
Processing a 1-hour audio file naively requires holding the entire feature tensor in memory — 2,573 MB for a 3600-second file. By slicing audio into 30-second chunks before feature extraction, peak memory drops to a constant ~883 MB regardless of input duration. The trick: the model weights are fixed (~800MB), and each chunk’s feature tensor is only ~3MB.
The Problem: Memory Explodes with Duration
The Paraformer model has a fixed memory footprint:
| Component | Memory |
|---|---|
| Model weights (220M params × 4 bytes) | ~800 MB |
| Encoder intermediate buffers | ~40 MB |
| Decoder workspace | ~40 MB |
| Fixed total | ~880 MB |
The variable part is the feature tensor — the mel spectrogram + LFR (Low Frame Rate) stacking output. For a 30-second chunk:
| Component | Size |
|---|---|
| Raw audio (16kHz × 30s × 4 bytes) | 192 KB |
| Mel spectrogram (80 bins × 300 frames) | 96 KB |
| LFR output (80 × 7 frames, stride 6) | ~3 KB |
| Feature tensor per chunk | ~3 MB |
Without chunking, a 1-hour file produces:
1 hour = 3600s → 3600 frames → LFR → 600 feature vectors × 560 dims
Feature tensor: ~134 MB
Total peak: 880 + 134 = 1,014 MB
For a 24-hour file: 2,573 MB — likely to OOM on machines with 8GB RAM.
The Solution: Slice Before Extract
The key insight: don’t load the entire audio into memory at once. Instead:
- Read audio in 30-second windows
- Extract features for each window independently
- Run inference on each chunk
- Free the chunk’s memory before processing the next
Input: 60-second audio
↓
┌───────────┐ ┌───────────┐
│ Chunk 1 │ │ Chunk 2 │
│ 0s–30s │ │ 30s–60s │
│ (192 KB) │ │ (192 KB) │
└─────┬─────┘ └─────┬─────┘
↓ ↓
┌───────────┐ ┌───────────┐
│ Mel + LFR │ │ Mel + LFR │
│ (3 MB) │ │ (3 MB) │
└─────┬─────┘ └─────┬─────┘
↓ ↓
┌───────────┐ ┌───────────┐
│ Inference │ │ Inference │
│ (880 MB) │ │ (880 MB) │
└─────┬─────┘ └─────┬─────┘
↓ ↓
tokens 1 tokens 2
└───────┬────────┘
↓
Concatenate → Final text The model weights (~800 MB) are loaded once and reused across all chunks. Only the feature tensor (~3 MB) is allocated and freed per chunk.
Implementation: The Core Loop
Here’s the Rust implementation of the chunking logic:
/// Process audio in 30-second chunks to keep memory constant
pub fn transcribe_chunked(
model: &mut Paraformer,
audio: &[f32],
sample_rate: u32,
chunk_duration_s: f32,
) -> Result<Vec<i32>> {
let chunk_samples = (sample_rate as f32 * chunk_duration_s) as usize;
let total_samples = audio.len();
let mut all_tokens = Vec::new();
let mut offset = 0;
while offset < total_samples {
// Slice: take up to chunk_samples from current position
let end = (offset + chunk_samples).min(total_samples);
let chunk = &audio[offset..end];
// Feature extraction for this chunk only
let features = model.extract_features(chunk)?;
// Inference on this chunk
let tokens = model.transcribe(&features)?;
all_tokens.extend_from_slice(&tokens);
// chunk + features are dropped here → memory freed
offset = end;
}
Ok(all_tokens)
}
The critical detail: chunk and features are stack-allocated or scope-bound. When the loop iteration ends, they’re dropped, freeing ~3MB before the next iteration allocates new memory.
Measured Results
Tested on Apple M3 Max (48GB) with the Paraformer-large model:
| Duration | Chunks | Peak Memory | Without Chunking | Savings |
|---|---|---|---|---|
| 3s | 1 | 839 MB | 839 MB | 0% |
| 30s | 1 | 842 MB | 842 MB | 0% |
| 60s | 2 | 842 MB | 845 MB | 0.4% |
| 5min | 10 | 842 MB | 868 MB | 3% |
| 10min | 20 | 842 MB | 897 MB | 6% |
| 30min | 60 | 842 MB | 1,013 MB | 17% |
| 1hr | 120 | 842 MB | ~1,146 MB | 27% |
| 24hr | 2,880 | 842 MB | ~2,573 MB | 67% |
The pattern is clear: without chunking, memory grows linearly with duration. With chunking, it plateaus at ~842 MB.
Why 30 Seconds?
The chunk duration is a tradeoff:
| Chunk Size | Pros | Cons |
|---|---|---|
| 5s | Minimal per-chunk memory | Too many chunks → overhead, boundary artifacts |
| 30s | Good balance | Sweet spot for Paraformer’s CIF mechanism |
| 60s | Fewer chunks | CIF predictor degrades on very long segments |
| Full file | No chunking logic | Memory explodes |
Paraformer’s CIF (Continuous Integrate-and-Fire) predictor works best on segments where acoustic boundaries align with natural speech pauses. 30 seconds captures typical sentence-level prosody without crossing too many topic boundaries.
Edge Cases
Audio shorter than 30s: No chunking needed — processes as a single chunk. The min(total_samples, chunk_samples) handles this naturally.
Silence at boundaries: The CIF predictor fires on acoustic boundaries, not time boundaries. A 30s chunk might split mid-sentence, but the bidirectional decoder handles this gracefully because it sees the full chunk context.
Overlap for continuity: The current implementation uses non-overlapping chunks. For higher accuracy on boundary-sensitive tasks, you could add a 5-second overlap between chunks and average the overlapping region — but this increases memory proportionally.
Lessons Learned
- Model weights dominate memory — the 220M-parameter model is ~800MB regardless of input. Feature tensors are tiny by comparison.
- Chunking is免费 for short audio — under 30s, there’s zero overhead. The branching cost is negligible.
- Memory safety ≠ memory efficiency — Rust prevents OOM crashes, but it doesn’t prevent OOM design. You still need to think about allocation patterns.
Next: Heterogeneous Backend: Python + Rust MLX Architecture → How the two languages communicate.