Files
nvidia-nemo--speech/tests/collections/asr/decoding/test_streaming_buffer.py
T
wehub-resource-sync ba4be087d5
Create PR to main with cherry-pick from release / cherry-pick (push) Failing after 0s
CICD NeMo / pre-flight (push) Failing after 0s
CICD NeMo / configure (push) Has been skipped
Build, validate, and release Neural Modules / pre-flight (push) Failing after 1s
CICD NeMo / code-linting (push) Has been skipped
Build, validate, and release Neural Modules / release (push) Has been skipped
Build, validate, and release Neural Modules / release-summary (push) Has been cancelled
CICD NeMo / cicd-test-container-build (push) Has been cancelled
CICD NeMo / cicd-import-tests (push) Has been cancelled
CICD NeMo / L0_Setup_Test_Data_And_Models (push) Has been cancelled
CICD NeMo / cicd-main-unit-tests (push) Has been cancelled
CICD NeMo / cicd-main-speech (push) Has been cancelled
CICD NeMo / Nemo_CICD_Test (push) Has been cancelled
CICD NeMo / Coverage (e2e) (push) Has been cancelled
CICD NeMo / Coverage (unit-test) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
CICD NeMo / cicd-wait-in-queue (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:28:58 +08:00

470 lines
20 KiB
Python

# Copyright (c) 2025, NVIDIA CORPORATION. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Unit tests for `StreamingBatchedAudioBuffer` and accompanying helper
classes defined in
`nemo.collections.asr.parts.utils.streaming_utils`.
"""
from __future__ import annotations
import math
import pytest
import torch
from nemo.collections.asr.parts.utils.streaming_utils import (
ContextSize,
ContextSizeBatch,
DynamicLengthTensor,
StreamingBatchedAudioBuffer,
)
# -----------------------------------------------------------------------------
# Helper constants / fixtures
# -----------------------------------------------------------------------------
DEVICES: list[torch.device] = [torch.device("cpu")]
if torch.cuda.is_available():
DEVICES.append(torch.device("cuda:0"))
def _create_audio_batch(batch_size: int, length: int, device: torch.device, dtype: torch.dtype = torch.float32):
"""Create a dummy audio batch of shape (batch_size, length)."""
# Use a simple ramp signal to ease debugging.
vals = torch.arange(batch_size * length, device=device, dtype=dtype)
return vals.view(batch_size, length)
def _make_chunk(batch_size: int, length: int, channels: int, start: float, device: torch.device) -> torch.Tensor:
"""Create a deterministic chunk of shape (batch_size, length, channels)."""
n = batch_size * length * channels
return (start + torch.arange(n, device=device, dtype=torch.float32)).view(batch_size, length, channels)
def _make_ndim_chunk(
batch_size: int, length: int, dim_shape: list[int], start: float, device: torch.device
) -> torch.Tensor:
"""Create a deterministic chunk of shape (batch_size, length, *dim_shape)."""
n = batch_size * length * math.prod(dim_shape)
return (start + torch.arange(n, device=device, dtype=torch.float32)).view(batch_size, length, *dim_shape)
# -----------------------------------------------------------------------------
# Tests for ContextSize and ContextSizeBatch
# -----------------------------------------------------------------------------
class TestContextSize:
@pytest.mark.unit
def test_context_size_total_and_subsample(self):
ctx = ContextSize(left=4, chunk=2, right=1)
assert ctx.total() == 7
half_ctx = ctx.subsample(factor=2)
assert isinstance(half_ctx, ContextSize)
assert half_ctx.left == 2 and half_ctx.chunk == 1 and half_ctx.right == 0
assert half_ctx.total() == math.floor(7 / 2)
@pytest.mark.unit
@pytest.mark.parametrize("device", DEVICES)
def test_context_size_batch_total_and_subsample(self, device: torch.device):
left = torch.tensor([4, 4], dtype=torch.long, device=device)
chunk = torch.tensor([2, 2], dtype=torch.long, device=device)
right = torch.tensor([2, 2], dtype=torch.long, device=device)
batch_ctx = ContextSizeBatch(left=left, chunk=chunk, right=right)
# total() should equal element-wise sum
expected_total = left + chunk + right
assert torch.equal(batch_ctx.total(), expected_total)
# After subsampling by 2 each component should be halved (floor division)
half_ctx = batch_ctx.subsample(2)
assert torch.equal(half_ctx.left, left // 2)
assert torch.equal(half_ctx.chunk, chunk // 2)
assert torch.equal(half_ctx.right, right // 2)
# -----------------------------------------------------------------------------
# Tests for StreamingBatchedAudioBuffer
# -----------------------------------------------------------------------------
class TestStreamingBatchedAudioBuffer:
@pytest.mark.unit
@pytest.mark.parametrize("device", DEVICES)
def test_streaming_batched_audio_buffer(self, device: torch.device):
batch_size = 2
expected_ctx = ContextSize(left=4, chunk=2, right=1) # total = 7
buffer = StreamingBatchedAudioBuffer(
batch_size=batch_size,
context_samples=expected_ctx,
dtype=torch.float32,
device=device,
)
# ------------------------------------------------------------------
# First add : chunk + right (filling initial buffer)
# ------------------------------------------------------------------
first_len = expected_ctx.chunk + expected_ctx.right # 3
audio_batch = _create_audio_batch(batch_size, first_len, device)
audio_lens = torch.full(
[
batch_size,
],
first_len,
dtype=torch.long,
device=device,
)
buffer.add_audio_batch_(
audio_batch=audio_batch,
audio_lengths=audio_lens,
is_last_chunk=False,
is_last_chunk_batch=torch.zeros(batch_size, dtype=torch.bool, device=device),
)
# Validate context sizes
assert buffer.context_size.left == 0
assert buffer.context_size.chunk == expected_ctx.chunk
assert buffer.context_size.right == expected_ctx.right
assert buffer.samples.shape[1] == first_len # No truncation yet
# ------------------------------------------------------------------
# Second add : only chunk length
# ------------------------------------------------------------------
chunk_len = expected_ctx.chunk # 2
audio_batch = _create_audio_batch(batch_size, chunk_len, device)
audio_lens.fill_(chunk_len)
buffer.add_audio_batch_(
audio_batch=audio_batch,
audio_lengths=audio_lens,
is_last_chunk=False,
is_last_chunk_batch=torch.zeros(batch_size, dtype=torch.bool, device=device),
)
# After second add, left should have grown by previous chunk (2)
assert buffer.context_size.left == 2
assert buffer.context_size.chunk == expected_ctx.chunk
assert buffer.context_size.right == expected_ctx.right
assert buffer.samples.shape[1] == 5 # 2 (left) + 2 (chunk) + 1 (right)
# ------------------------------------------------------------------
# Third add : another chunk, buffer should now reach full capacity (7)
# ------------------------------------------------------------------
buffer.add_audio_batch_(
audio_batch=audio_batch,
audio_lengths=audio_lens,
is_last_chunk=False,
is_last_chunk_batch=torch.zeros(batch_size, dtype=torch.bool, device=device),
)
assert buffer.samples.shape[1] == expected_ctx.total()
assert buffer.context_size.total() == expected_ctx.total()
# ------------------------------------------------------------------
# Fourth add : buffer overflows by 2 samples; implementation should
# drop the excess from the left context.
# ------------------------------------------------------------------
buffer.add_audio_batch_(
audio_batch=audio_batch,
audio_lengths=audio_lens,
is_last_chunk=False,
is_last_chunk_batch=torch.zeros(batch_size, dtype=torch.bool, device=device),
)
# Buffer length remains constant (total context size)
assert buffer.samples.shape[1] == expected_ctx.total()
assert buffer.context_size.total() == expected_ctx.total()
# Left context should have been clipped by 2 samples (from 6 to 4)
assert buffer.context_size.left == expected_ctx.left # 4
# ------------------------------------------------------------------
# Final add : mark last chunk with shorter length; right context
# should go to 0 afterwards.
# ------------------------------------------------------------------
last_len = 1
audio_batch = _create_audio_batch(batch_size, last_len, device)
audio_lens.fill_(last_len)
buffer.add_audio_batch_(
audio_batch=audio_batch,
audio_lengths=audio_lens,
is_last_chunk=True,
is_last_chunk_batch=torch.ones(batch_size, dtype=torch.bool, device=device),
)
# After last chunk, right context must be zero and total size preserved
assert buffer.context_size.right == 0
assert buffer.context_size.total() == expected_ctx.total()
assert buffer.samples.shape[1] == expected_ctx.total()
@pytest.mark.unit
@pytest.mark.parametrize("device", DEVICES)
def test_streaming_batched_audio_buffer_raises_on_too_long_chunk(self, device: torch.device):
"""`add_audio_batch_` should raise if provided chunk is larger than chunk + right."""
expected_ctx = ContextSize(left=0, chunk=2, right=1)
buffer = StreamingBatchedAudioBuffer(
batch_size=1,
context_samples=expected_ctx,
dtype=torch.float32,
device=device,
)
# Attempt to add a chunk that is too long (4 > 3)
too_long_chunk_size = expected_ctx.chunk + expected_ctx.right + 1
audio = _create_audio_batch(1, too_long_chunk_size, device)
audio_lens = torch.tensor([too_long_chunk_size], dtype=torch.long, device=device)
with pytest.raises(ValueError):
buffer.add_audio_batch_(
audio_batch=audio,
audio_lengths=audio_lens,
is_last_chunk=False,
is_last_chunk_batch=torch.tensor([False], dtype=torch.bool, device=device),
)
# -----------------------------------------------------------------------------
# Tests for DynamicLengthTensor
# -----------------------------------------------------------------------------
class TestDynamicLengthTensor:
@pytest.mark.unit
@pytest.mark.parametrize("device", DEVICES)
@pytest.mark.parametrize(
"dim_shape, expected_dim_shape",
[
(None, []),
(3, [3]),
([4, 5], [4, 5]),
],
)
def test_init(self, device, dim_shape, expected_dim_shape):
batch_size, init_length = 2, 5
t = DynamicLengthTensor(
batch_size=batch_size,
init_length=init_length,
dim_shape=dim_shape,
device=device,
dtype=torch.float32,
)
assert t.dim_shape == expected_dim_shape
assert list(t.data.shape) == [batch_size, init_length, *expected_dim_shape]
assert list(t.lengths.shape) == [batch_size]
assert t.lengths.dtype == torch.long
assert t.data.dtype == torch.float32
# Freshly created storage is zeroed and reports no content.
assert torch.count_nonzero(t.lengths) == 0
assert torch.count_nonzero(t.data) == 0
@pytest.mark.unit
def test_init_minimum_length(self):
"""`init_length` is clamped to at least 1 so doubling-based growth works."""
t = DynamicLengthTensor(batch_size=2, init_length=0, dim_shape=1)
assert t._max_length == 1
assert t.data.shape[1] == 1
@pytest.mark.unit
@pytest.mark.parametrize("device", DEVICES)
def test_append_with_lengths(self, device):
"""Per-batch `lengths` control how many frames become valid for each item."""
t = DynamicLengthTensor(batch_size=2, init_length=4, dim_shape=1, device=device, dtype=torch.float32)
# First chunk: batch item 0 keeps 2 frames, item 1 keeps 1 frame.
chunk1 = _make_chunk(batch_size=2, length=2, channels=1, start=10.0, device=device)
# chunk1 == [[[10], [11]], [[12], [13]]]
t.append_(data=chunk1, lengths=torch.tensor([2, 1], device=device))
assert t.lengths.tolist() == [2, 1]
# Second chunk: item 0 keeps 1 frame, item 1 keeps 2 frames. Item 1 should
# overwrite the previously written "garbage" frame at position 1.
chunk2 = _make_chunk(batch_size=2, length=2, channels=1, start=30.0, device=device)
# chunk2 == [[[30], [31]], [[32], [33]]]
t.append_(data=chunk2, lengths=torch.tensor([1, 2], device=device))
assert t.lengths.tolist() == [3, 3]
# Valid frames are everything up to the per-item length.
item0 = t.data[0, : t.lengths[0], 0].tolist()
item1 = t.data[1, : t.lengths[1], 0].tolist()
assert item0 == [10.0, 11.0, 30.0]
assert item1 == [12.0, 32.0, 33.0]
@pytest.mark.unit
@pytest.mark.parametrize("device", DEVICES)
def test_append_without_lengths(self, device):
"""Without `lengths`, every frame in the chunk is appended for all items."""
t = DynamicLengthTensor(batch_size=2, init_length=2, dim_shape=1, device=device, dtype=torch.float32)
chunk = _make_chunk(batch_size=2, length=3, channels=1, start=0.0, device=device)
t.append_(data=chunk)
assert t.lengths.tolist() == [3, 3]
assert torch.equal(t.data[:, :3], chunk)
@pytest.mark.unit
@pytest.mark.parametrize("device", DEVICES)
@pytest.mark.parametrize("dim_shape", [[], [3], [2, 3], [2, 3, 4]])
def test_append_without_lengths_multidim(self, device, dim_shape):
"""Append must scatter the whole feature vector for arbitrary trailing `dim_shape`."""
batch_size = 2
# init_length < appended length so this also exercises the growth path with multi-dim shapes.
t = DynamicLengthTensor(
batch_size=batch_size, init_length=2, dim_shape=dim_shape, device=device, dtype=torch.float32
)
chunk = _make_ndim_chunk(batch_size, length=3, dim_shape=dim_shape, start=0.0, device=device)
t.append_(data=chunk)
assert t.lengths.tolist() == [3, 3]
assert torch.equal(t.data[:, :3], chunk)
@pytest.mark.unit
@pytest.mark.parametrize("device", DEVICES)
def test_append_with_lengths_multidim(self, device):
"""Per-batch `lengths` must place the full multi-dim feature vectors at the right offsets."""
dim_shape = [2, 3]
t = DynamicLengthTensor(batch_size=2, init_length=4, dim_shape=dim_shape, device=device, dtype=torch.float32)
# First chunk: item 0 keeps 2 frames, item 1 keeps 1 frame.
chunk1 = _make_ndim_chunk(2, length=2, dim_shape=dim_shape, start=10.0, device=device)
t.append_(data=chunk1, lengths=torch.tensor([2, 1], device=device))
assert t.lengths.tolist() == [2, 1]
# Second chunk: item 0 keeps 1 frame, item 1 keeps 2 frames. Item 1's second frame
# must overwrite the previously written "garbage" frame at position 1.
chunk2 = _make_ndim_chunk(2, length=2, dim_shape=dim_shape, start=100.0, device=device)
t.append_(data=chunk2, lengths=torch.tensor([1, 2], device=device))
assert t.lengths.tolist() == [3, 3]
# Item 0: chunk1[0, 0], chunk1[0, 1], chunk2[0, 0]; each is a full (2, 3) feature vector.
assert torch.equal(t.data[0, 0], chunk1[0, 0])
assert torch.equal(t.data[0, 1], chunk1[0, 1])
assert torch.equal(t.data[0, 2], chunk2[0, 0])
# Item 1: chunk1[1, 0], chunk2[1, 0] (overwrites garbage at pos 1), chunk2[1, 1].
assert torch.equal(t.data[1, 0], chunk1[1, 0])
assert torch.equal(t.data[1, 1], chunk2[1, 0])
assert torch.equal(t.data[1, 2], chunk2[1, 1])
@pytest.mark.unit
@pytest.mark.parametrize("device", DEVICES)
def test_growth_preserves_data(self, device):
"""Appending more than the initial capacity reallocates and keeps content."""
t = DynamicLengthTensor(batch_size=1, init_length=2, dim_shape=1, device=device, dtype=torch.float32)
initial_capacity = t._max_length
big_len = 10
chunk = _make_chunk(batch_size=1, length=big_len, channels=1, start=0.0, device=device)
t.append_(data=chunk, lengths=torch.tensor([big_len], device=device))
assert t._max_length > initial_capacity
assert t._max_length >= big_len
assert t.lengths.tolist() == [big_len]
assert t.data[0, :big_len, 0].tolist() == list(range(big_len))
@pytest.mark.unit
@pytest.mark.parametrize("device", DEVICES)
def test_incremental_appends_double_capacity(self, device):
"""Repeated single-frame appends grow capacity geometrically (amortized O(1))."""
n_appends = 9
t = DynamicLengthTensor(batch_size=1, init_length=1, dim_shape=1, device=device, dtype=torch.float32)
capacities = []
for i in range(n_appends):
frame = torch.full((1, 1, 1), float(i), device=device)
t.append_(data=frame)
capacities.append(t._max_length)
# Everything that was appended is retained, in order.
assert t.lengths.tolist() == [n_appends]
assert t.data[0, :n_appends, 0].tolist() == [float(i) for i in range(n_appends)]
# Capacity is always at least what is stored, and grew far less than linearly.
assert t._max_length >= n_appends
assert len(set(capacities)) < n_appends # capacity reused across appends, not bumped every time
@pytest.mark.unit
@pytest.mark.parametrize("device", DEVICES)
def test_clear(self, device):
"""`clear_` resets both lengths and storage to zero while keeping capacity."""
t = DynamicLengthTensor(batch_size=2, init_length=4, dim_shape=1, device=device, dtype=torch.float32)
t.append_(data=_make_chunk(2, 3, 1, start=1.0, device=device), lengths=torch.tensor([3, 3], device=device))
capacity_before = t._max_length
t.clear_()
assert t.lengths.tolist() == [0, 0]
assert torch.count_nonzero(t.data) == 0
assert t._max_length == capacity_before
@pytest.mark.unit
@pytest.mark.parametrize("device", DEVICES)
def test_merge(self, device):
"""`merge_` concatenates another tensor's content along the length dim."""
a = DynamicLengthTensor(batch_size=2, init_length=2, dim_shape=1, device=device, dtype=torch.float32)
a.append_(data=_make_chunk(2, 2, 1, start=1.0, device=device), lengths=torch.tensor([2, 2], device=device))
b = DynamicLengthTensor(batch_size=2, init_length=2, dim_shape=1, device=device, dtype=torch.float32)
b.append_(data=_make_chunk(2, 2, 1, start=100.0, device=device), lengths=torch.tensor([1, 2], device=device))
a_item0_before = a.data[0, : a.lengths[0], 0].tolist()
a_item1_before = a.data[1, : a.lengths[1], 0].tolist()
b_item0 = b.data[0, : b.lengths[0], 0].tolist()
b_item1 = b.data[1, : b.lengths[1], 0].tolist()
ret = a.merge_(b)
assert ret is a # in-place, returns self
assert a.lengths.tolist() == [3, 4]
assert a.data[0, : a.lengths[0], 0].tolist() == a_item0_before + b_item0
assert a.data[1, : a.lengths[1], 0].tolist() == a_item1_before + b_item1
@pytest.mark.unit
@pytest.mark.parametrize("device", DEVICES)
def test_clone_is_independent(self, device):
"""`clone` returns a deep copy: same data/lengths, but independent storage."""
t = DynamicLengthTensor(batch_size=2, init_length=4, dim_shape=3, device=device, dtype=torch.float32)
t.append_(data=_make_chunk(2, 2, 3, start=1.0, device=device), lengths=torch.tensor([2, 1], device=device))
clone = t.clone()
assert clone is not t
assert clone.dim_shape == t.dim_shape
assert clone.data.shape == t.data.shape
assert torch.equal(clone.lengths, t.lengths)
assert torch.equal(clone.data, t.data)
# Mutating the clone must not affect the original.
clone.append_(
data=_make_chunk(2, 1, 3, start=50.0, device=device), lengths=torch.tensor([1, 1], device=device)
)
assert clone.lengths.tolist() == [3, 2]
assert t.lengths.tolist() == [2, 1]
@pytest.mark.unit
@pytest.mark.skipif(not torch.cuda.is_available(), reason="requires CUDA to verify cross-device move")
def test_to_device(self):
"""`to_device` moves the underlying storage (not just the bookkeeping attr)."""
t = DynamicLengthTensor(
batch_size=2, init_length=4, dim_shape=1, device=torch.device("cpu"), dtype=torch.float32
)
t.append_(data=_make_chunk(2, 2, 1, start=1.0, device=torch.device("cpu")), lengths=torch.tensor([2, 2]))
ret = t.to_device("cuda:0")
assert ret is t
assert t.device == "cuda:0"
assert t.data.device.type == "cuda"
assert t.lengths.device.type == "cuda"
# Content survives the move.
assert t.data[0, :2, 0].tolist() == [1.0, 2.0]