#2076·librosa

[Bug]: chroma_stft (via estimate_tuning/piptrack) leaves unreturned memory in the glibc arena, growing RSS across repeated calls

Author: imrulhudaCreated Jul 13, 2026Updated Jul 31, 2026

Bug summary

Repeated calls to librosa.feature.chroma_stft() cause process RSS (resident memory) to grow over time in a long-lived process, especially when input length varies call-to-call (as real audio files typically do). This is not a Python object leak or a growing cache — chroma_stft internally calls estimate_tuning()piptrack(), which allocates ~6 full-spectrogram-sized temporary arrays (including a redundant np.abs(S) copy, since the input power spectrogram is already non-negative) to compute a single scalar tuning offset. These are freed correctly at the Python level every call, but glibc's malloc does not return the freed memory to the OS under this allocation pattern. A single ctypes call to malloc_trim(0) reliably reclaims the excess, confirming this is arena fragmentation rather than a reference leak.

Code for reproduction

python
import ctypes
import numpy as np
import librosa

libc = ctypes.CDLL("libc.so.6")

def vmrss_mb():
    with open("/proc/self/status") as f:
        for line in f:
            if line.startswith("VmRSS:"):
                return int(line.split()[1]) / 1024

sr = 16000
rng = np.random.default_rng(0)
base_len = 3_964_032  # ~248s at 16kHz

# Fixed-length input: RSS should plateau quickly if this is just one-time setup cost.
y0 = rng.standard_normal(base_len).astype(np.float32)
librosa.feature.chroma_stft(y=y0, sr=sr, hop_length=256)  # warmup
print(f"[fixed length] warmup: {vmrss_mb():.1f} MB")
for i in range(1, 11):
    librosa.feature.chroma_stft(y=y0, sr=sr, hop_length=256)
    print(f"[fixed length] call {i}: {vmrss_mb():.1f} MB")

libc.malloc_trim(0)
print(f"[fixed length] after malloc_trim(0): {vmrss_mb():.1f} MB")

# Varying-length input, closer to real-world usage (real files are rarely identical length):
print()
for i in range(1, 21):
    length = base_len + rng.integers(-500_000, 500_000)
    y = rng.standard_normal(length).astype(np.float32)
    librosa.feature.chroma_stft(y=y, sr=sr, hop_length=256)
    print(f"[varying length] call {i} (len={length}): {vmrss_mb():.1f} MB")

libc.malloc_trim(0)
print(f"[varying length] after malloc_trim(0): {vmrss_mb():.1f} MB")

Actual outcome

Real output from the exact script above, on our machine:

[fixed length] warmup: 323.8 MB
[fixed length] call 1: 323.9 MB
[fixed length] call 2: 323.9 MB
...
[fixed length] call 10: 323.7 MB
[fixed length] after malloc_trim(0): 268.1 MB

[varying length] call 1 (len=3747151): 329.1 MB
[varying length] call 2 (len=4134037): 342.3 MB
[varying length] call 3 (len=4226114): 359.9 MB
...
[varying length] call 16 (len=4129722): 360.1 MB
[varying length] call 20 (len=4082139): 342.3 MB
[varying length] after malloc_trim(0): 285.2 MB

Fixed-length calls plateau almost immediately (~323.7–323.9 MB), yet malloc_trim(0) still recovers 55.6 MB that accumulated invisibly during warmup. Varying-length calls push the ceiling meaningfully higher (up to ~360 MB) before malloc_trim(0) recovers most of it. In both cases, none of this memory is referenced by Python — it's sitting in glibc's arena.

Expected outcome

RSS should stay close to a stable baseline across repeated calls, without requiring an explicit malloc_trim(0) to reclaim tens of megabytes — especially for a function whose actual return value here is a single scalar float per call. At minimum, we'd expect the growth to be negligible relative to the ~63MB scratch arrays being churned, not a meaningful fraction of it.

Additional information

Traced via bisection: isolated chroma_stft's four internal steps (STFT, estimate_tuning, filters.chroma filterbank lookup, final einsum contraction) — only estimate_tuning (via piptrack) shows this behavior; the others are flat. piptrack's implementation (librosa/core/pitch.py) computes S = np.abs(S) (redundant when S is already a non-negative power spectrogram), then allocates pitches = np.zeros_like(S), mags = np.zeros_like(S), avg = np.gradient(S, axis=-2), shift = _parabolic_interpolation(S, axis=-2), and dskew = 0.5 * avg * shift — 6 full-size temporary arrays just to return a scalar tuning estimate via estimate_tuning.

A possible fix: skip the redundant np.abs(S) copy when the caller can guarantee non-negative input (e.g. a power=2 spectrogram), and/or give estimate_tuning/chroma_stft a lighter-weight path that doesn't require full pitch/magnitude maps when only a scalar tuning value is needed. Happy to help prototype either approach if a maintainer can weigh in on which direction is preferred — didn't want to open a PR for a behavior/design change like this without checking first.

This may be related to #1286, which reported a similar RSS-growth pattern in librosa.load() — that thread concluded librosa's own code holds no cross-call state, consistent with what we found here: it's not a logic bug, it's an allocator-interaction side effect of allocating more scratch memory than the task strictly needs.

Operating system

Linux (Ubuntu-based, WSL2) — Linux-6.6.87.2-microsoft-standard-WSL2-x86_64-with-glibc2.39

software versions

python: 3.12.13 | packaged by conda-forge | (main, Mar  5 2026, 16:50:00) [GCC 14.3.0]
librosa: 0.11.0
numpy: 2.2.6
scipy: 1.18.0
numba: 0.66.0
soundfile: 0.14.0
joblib: 1.5.3

Installation

conda