Skip to content

Commit d05855d

Browse files
committed
Add step-aligned garbage collection to SALMAutomodel
1 parent 4a4f436 commit d05855d

7 files changed

Lines changed: 169 additions & 0 deletions

File tree

docs/source/speechlm2/configs.rst

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,22 @@ SALMAutomodel-Specific Options
170170
The SALMAutomodel config exposes a few extra knobs that pass through to NeMo
171171
Automodel. All are optional — defaults preserve standard behavior.
172172

173+
**Garbage collection:**
174+
175+
.. code-block:: yaml
176+
177+
model:
178+
# Optional positive optimizer-step interval; null keeps automatic GC.
179+
gc_every_steps: null
180+
181+
Setting ``gc_every_steps`` to a positive integer disables Python's automatic
182+
garbage collector at fit start and uses NeMo Automodel's generation-1 collector
183+
at that optimizer-step cadence. This avoids an occasional generation-2 scan on
184+
one distributed rank delaying all peers at the next collective. The cadence is
185+
counted in optimizer steps, so gradient accumulation does not increase the
186+
collection frequency. Leave it ``null`` unless profiling shows GC-related rank
187+
stragglers.
188+
173189
**MoE training:**
174190

175191
.. code-block:: yaml

examples/speechlm2/conf/salm_automodel.yaml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,11 @@ model:
1414
# Set to true to use SALMAutomodel (NeMo Automodel backend) instead of SALM (HF Transformers backend).
1515
use_nemo_automodel: true
1616

17+
# Optional: disable asynchronous full-heap Python GC and run deterministic
18+
# generation-1 collections every N optimizer steps. null preserves Python's
19+
# default automatic GC behavior.
20+
gc_every_steps: null
21+
1722
# Regexp (re.compile) patterns matching parameters to be frozen.
1823
freeze_params:
1924
# Frozen LLM (embed_tokens stays inside llm, so this pattern covers it too)

examples/speechlm2/conf/salm_automodel_pee.yaml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,11 @@ model:
1414
# Set to true to use SALMAutomodel (NeMo Automodel backend) instead of SALM (HF Transformers backend).
1515
use_nemo_automodel: true
1616

17+
# Optional: disable asynchronous full-heap Python GC and run deterministic
18+
# generation-1 collections every N optimizer steps. null preserves Python's
19+
# default automatic GC behavior.
20+
gc_every_steps: null
21+
1722
# Regexp (re.compile) patterns matching parameters to be frozen.
1823
# PEE recipe: freeze the LLM and the Sortformer diarizer expert; keep the ASR
1924
# Conformer encoder (perception.encoder.asr_encoder) and the fusion layers

nemo/collections/speechlm2/models/salm_automodel.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
from nemo.collections.speechlm2.models.salm import _resolve_audios_in_prompt, replace_placeholders_and_build_targets
3333
from nemo.collections.speechlm2.parts.automodel_lora import ensure_lora_trainable, make_peft_config, maybe_install_lora
3434
from nemo.collections.speechlm2.parts.encoder_chunking import encode_audio_with_optional_chunking
35+
from nemo.collections.speechlm2.parts.gc import GarbageCollectionManager
3536
from nemo.collections.speechlm2.parts.hf_hub import HFHubMixin
3637
from nemo.collections.speechlm2.parts.multispeaker import build_speaker_tokens, maybe_init_lss_loss
3738
from nemo.collections.speechlm2.parts.optim_setup import configure_optimizers, is_frozen
@@ -69,6 +70,7 @@ def __init__(self, cfg) -> None:
6970

7071
self._use_fsdp = False
7172
self._use_tp = False
73+
self._garbage_collection = GarbageCollectionManager(self.cfg.get("gc_every_steps", None))
7274

7375
if self.cfg.get("init_configure_model", False):
7476
self.configure_model()
@@ -379,6 +381,12 @@ def on_fit_start(self) -> None:
379381
averaging (see ``_configure_moe_aux_loss_scaler``)."""
380382
self._validate_parallelism_compatibility()
381383
self._configure_moe_aux_loss_scaler()
384+
self._garbage_collection.on_fit_start()
385+
386+
def optimizer_step(self, epoch, batch_idx, optimizer, optimizer_closure=None) -> None:
387+
"""Run configured manual GC after each completed optimizer step."""
388+
super().optimizer_step(epoch, batch_idx, optimizer, optimizer_closure)
389+
self._garbage_collection.on_optimizer_step()
382390

383391
def _validate_parallelism_compatibility(self) -> None:
384392
"""Raise on known-incompatible THD/CP/backend configurations.
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
# Copyright (c) 2026, NVIDIA CORPORATION. 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+
from nemo.utils import logging
16+
17+
18+
class GarbageCollectionManager:
19+
"""Manage deterministic Python garbage collection during distributed training.
20+
21+
When enabled, automatic garbage collection is replaced at fit start by
22+
NeMo Automodel's generation-1 collector. The manager owns the optimizer-step
23+
counter so model implementations only need to forward lifecycle events.
24+
"""
25+
26+
def __init__(self, gc_every_steps: int | None) -> None:
27+
if gc_every_steps is not None and (
28+
isinstance(gc_every_steps, bool)
29+
or not isinstance(gc_every_steps, int)
30+
or gc_every_steps <= 0
31+
):
32+
raise ValueError(
33+
f"model.gc_every_steps must be a positive integer or null, got {gc_every_steps!r}"
34+
)
35+
self.gc_every_steps = gc_every_steps
36+
self._collector = None
37+
self._optimizer_step_count = 0
38+
39+
def on_fit_start(self) -> None:
40+
"""Disable automatic GC and initialize the configured manual collector."""
41+
if self.gc_every_steps is None:
42+
return
43+
44+
from nemo_automodel.components.training.garbage_collection import (
45+
GarbageCollection,
46+
)
47+
48+
self._collector = GarbageCollection(gc_every_steps=self.gc_every_steps)
49+
self._optimizer_step_count = 0
50+
logging.info(
51+
"Automatic Python GC disabled; generation-1 collection will run every %d optimizer steps",
52+
self.gc_every_steps,
53+
)
54+
55+
def on_optimizer_step(self) -> None:
56+
"""Advance the manual collector after a completed optimizer step."""
57+
if self._collector is None:
58+
return
59+
self._optimizer_step_count += 1
60+
self._collector.run(self._optimizer_step_count)
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
# Copyright (c) 2026, NVIDIA CORPORATION. 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+
import pytest
16+
17+
from nemo.collections.speechlm2.parts.gc import GarbageCollectionManager
18+
19+
20+
def test_garbage_collection_manager_owns_step_state(monkeypatch):
21+
calls = []
22+
23+
class FakeGarbageCollection:
24+
def __init__(self, gc_every_steps):
25+
calls.append(("init", gc_every_steps))
26+
27+
def run(self, step_count):
28+
calls.append(("run", step_count))
29+
30+
import nemo_automodel.components.training.garbage_collection as gc_module
31+
32+
monkeypatch.setattr(gc_module, "GarbageCollection", FakeGarbageCollection)
33+
manager = GarbageCollectionManager(gc_every_steps=10)
34+
35+
manager.on_fit_start()
36+
manager.on_optimizer_step()
37+
manager.on_optimizer_step()
38+
39+
assert calls == [("init", 10), ("run", 1), ("run", 2)]
40+
41+
42+
def test_garbage_collection_manager_is_noop_when_disabled():
43+
manager = GarbageCollectionManager(gc_every_steps=None)
44+
45+
manager.on_fit_start()
46+
manager.on_optimizer_step()
47+
48+
assert manager._collector is None
49+
assert manager._optimizer_step_count == 0
50+
51+
52+
@pytest.mark.parametrize("value", [True, False, 0, -1, 1.5, "10"])
53+
def test_garbage_collection_manager_rejects_invalid_interval(value):
54+
with pytest.raises(ValueError, match="gc_every_steps"):
55+
GarbageCollectionManager(gc_every_steps=value)

tests/collections/speechlm2/test_salm_automodel.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
import torch
1919
from lhotse import CutSet, SupervisionSegment
2020
from lhotse.testing.dummies import dummy_cut, dummy_recording
21+
from lightning import LightningModule
2122
from transformers import GenerationConfig
2223

2324
from nemo.collections.common.data.lhotse import NeMoMultimodalConversation
@@ -205,6 +206,25 @@ def test_salm_automodel_training_step_uses_dataloader_iter_signature():
205206
assert list(inspect.signature(SALMAutomodel.training_step).parameters) == ["self", "dataloader_iter"]
206207

207208

209+
def test_salm_automodel_notifies_garbage_collection_after_optimizer_step(monkeypatch):
210+
calls = []
211+
212+
class FakeGarbageCollectionManager:
213+
def on_optimizer_step(self):
214+
calls.append("gc")
215+
216+
model = SALMAutomodel.__new__(SALMAutomodel)
217+
torch.nn.Module.__init__(model)
218+
model._garbage_collection = FakeGarbageCollectionManager()
219+
monkeypatch.setattr(
220+
LightningModule,
221+
"optimizer_step",
222+
lambda *args, **kwargs: calls.append("optimizer"),
223+
)
224+
model.optimizer_step(0, 0, object())
225+
assert calls == ["optimizer", "gc"]
226+
227+
208228
def test_salm_automodel_record_training_stats_uses_thd_metadata():
209229
model = SALMAutomodel.__new__(SALMAutomodel)
210230
batch = {"input_ids": torch.zeros(3, 7, dtype=torch.long)}

0 commit comments

Comments
 (0)