|
| 1 | +# Copyright (c) 2025, NVIDIA CORPORATION & AFFILIATES. All rights reserved. |
| 2 | +# |
| 3 | +# Licensed under the Apache License, Version 2.0 (the "License"); |
| 4 | +# you may not use this file except in compliance with the License. |
| 5 | +# You may obtain a copy of the License at |
| 6 | +# |
| 7 | +# http://www.apache.org/licenses/LICENSE-2.0 |
| 8 | +# |
| 9 | +# Unless required by applicable law or agreed to in writing, software |
| 10 | +# distributed under the License is distributed on an "AS IS" BASIS, |
| 11 | +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| 12 | +# See the License for the specific language governing permissions and |
| 13 | +# limitations under the License. |
| 14 | + |
| 15 | +""" |
| 16 | +Unit tests for MagpieTTSModel._initialize_chunked_attn_prior bounds-check fix. |
| 17 | +
|
| 18 | +PR #15499 introduced a bounds check to prevent out-of-bounds access when |
| 19 | +prior_weights offsets exceed max_text_len (e.g. during Japanese longform TTS). |
| 20 | +""" |
| 21 | + |
| 22 | +import pytest |
| 23 | +import torch |
| 24 | + |
| 25 | +from nemo.collections.tts.models.magpietts import ChunkedInferenceConfig, ChunkState |
| 26 | + |
| 27 | + |
| 28 | +class _StubModel: |
| 29 | + """Minimal stub that exposes only what _initialize_chunked_attn_prior needs.""" |
| 30 | + |
| 31 | + def __init__(self): |
| 32 | + self.chunked_inference_config = ChunkedInferenceConfig() |
| 33 | + |
| 34 | + @staticmethod |
| 35 | + def _to_int(x): |
| 36 | + return int(x) |
| 37 | + |
| 38 | + # Bind the real method to this stub class so we can call it without a full model. |
| 39 | + from nemo.collections.tts.models.magpietts import MagpieTTSModel |
| 40 | + |
| 41 | + _initialize_chunked_attn_prior = MagpieTTSModel._initialize_chunked_attn_prior |
| 42 | + |
| 43 | + |
| 44 | +def _make_chunk_state(batch_size, previous_attn_len, left_offset=None): |
| 45 | + """Helper: create a ChunkState with previous_attn_len populated (non-empty triggers logic).""" |
| 46 | + state = ChunkState(batch_size=batch_size) |
| 47 | + state.previous_attn_len = list(previous_attn_len) |
| 48 | + state.left_offset = list(left_offset) if left_offset is not None else [0] * batch_size |
| 49 | + return state |
| 50 | + |
| 51 | + |
| 52 | +class TestInitializeChunkedAttnPrior: |
| 53 | + """Tests for the bounds-check in _initialize_chunked_attn_prior.""" |
| 54 | + |
| 55 | + prior_epsilon = 1e-8 |
| 56 | + prior_weights = ChunkedInferenceConfig().prior_weights_init # (0.5, 1.0, 0.8, 0.2, 0.2) |
| 57 | + |
| 58 | + def _call(self, chunk_state, current_chunk_len, batch_text_lens, max_text_len, batch_size, use_cfg=False): |
| 59 | + model = _StubModel() |
| 60 | + return model._initialize_chunked_attn_prior( |
| 61 | + chunk_state=chunk_state, |
| 62 | + current_chunk_len=torch.tensor(current_chunk_len, dtype=torch.long), |
| 63 | + batch_text_lens=torch.tensor(batch_text_lens, dtype=torch.long), |
| 64 | + max_text_len=max_text_len, |
| 65 | + batch_size=batch_size, |
| 66 | + use_cfg=use_cfg, |
| 67 | + prior_epsilon=self.prior_epsilon, |
| 68 | + device=torch.device('cpu'), |
| 69 | + ) |
| 70 | + |
| 71 | + @pytest.mark.run_only_on('CPU') |
| 72 | + @pytest.mark.unit |
| 73 | + def test_normal_case_all_weights_written(self): |
| 74 | + """All 5 prior weights are written when starting point leaves enough room.""" |
| 75 | + batch_size = 1 |
| 76 | + max_text_len = 10 |
| 77 | + batch_text_lens = [8] |
| 78 | + current_chunk_len = [3] |
| 79 | + # current_starting_point = 8 - 3 = 5; offsets 0..4 → indices 5..9, all < 10 |
| 80 | + state = _make_chunk_state(batch_size, previous_attn_len=[5]) |
| 81 | + |
| 82 | + result = self._call(state, current_chunk_len, batch_text_lens, max_text_len, batch_size) |
| 83 | + |
| 84 | + assert result is not None |
| 85 | + assert result.shape == (batch_size, 1, max_text_len) |
| 86 | + starting = batch_text_lens[0] - current_chunk_len[0] # 5 |
| 87 | + for offset, weight in enumerate(self.prior_weights[:5]): |
| 88 | + assert result[0, 0, starting + offset].item() == pytest.approx( |
| 89 | + weight |
| 90 | + ), f"Weight mismatch at offset {offset}" |
| 91 | + |
| 92 | + @pytest.mark.run_only_on('CPU') |
| 93 | + @pytest.mark.unit |
| 94 | + def test_bounds_check_prevents_index_error_near_boundary(self): |
| 95 | + """No IndexError when current_starting_point + some offsets exceed max_text_len.""" |
| 96 | + batch_size = 1 |
| 97 | + max_text_len = 7 |
| 98 | + batch_text_lens = [7] |
| 99 | + current_chunk_len = [2] |
| 100 | + # current_starting_point = 7 - 2 = 5; offsets 0..4 → indices 5,6,7,8,9 |
| 101 | + # indices 7,8,9 are >= max_text_len=7 and must be skipped |
| 102 | + state = _make_chunk_state(batch_size, previous_attn_len=[5]) |
| 103 | + |
| 104 | + # Before the fix this would raise an IndexError; now it must succeed silently. |
| 105 | + result = self._call(state, current_chunk_len, batch_text_lens, max_text_len, batch_size) |
| 106 | + |
| 107 | + assert result is not None |
| 108 | + assert result.shape == (batch_size, 1, max_text_len) |
| 109 | + # Weights at valid indices only |
| 110 | + assert result[0, 0, 5].item() == pytest.approx(self.prior_weights[0]) |
| 111 | + assert result[0, 0, 6].item() == pytest.approx(self.prior_weights[1]) |
| 112 | + |
| 113 | + @pytest.mark.run_only_on('CPU') |
| 114 | + @pytest.mark.unit |
| 115 | + def test_batch_mixed_boundary_conditions(self): |
| 116 | + """Batch with one item having room for all weights and one near the boundary.""" |
| 117 | + batch_size = 2 |
| 118 | + max_text_len = 10 |
| 119 | + batch_text_lens = [10, 10] |
| 120 | + current_chunk_len = [5, 1] |
| 121 | + # Item 0: starting = 10-5 = 5; offsets 0..4 → indices 5..9, all valid |
| 122 | + # Item 1: starting = 10-1 = 9; offset 0 → idx 9 (valid), offsets 1..4 → OOB |
| 123 | + state = _make_chunk_state(batch_size, previous_attn_len=[5, 9]) |
| 124 | + |
| 125 | + result = self._call(state, current_chunk_len, batch_text_lens, max_text_len, batch_size) |
| 126 | + |
| 127 | + assert result is not None |
| 128 | + assert result.shape == (batch_size, 1, max_text_len) |
| 129 | + |
| 130 | + # Item 0: all 5 weights present |
| 131 | + for offset, weight in enumerate(self.prior_weights[:5]): |
| 132 | + assert result[0, 0, 5 + offset].item() == pytest.approx( |
| 133 | + weight |
| 134 | + ), f"Item 0: weight mismatch at offset {offset}" |
| 135 | + |
| 136 | + # Item 1: only first weight at index 9; offsets 1..4 were out of bounds |
| 137 | + assert result[1, 0, 9].item() == pytest.approx(self.prior_weights[0]) |
0 commit comments