CoolFace
Modelpublic

RASMUS/Finnish-ASR-Canary-v2

sourceHugging Facemitupdated 7mo agoView on Hugging Face
0likes1.2kdownloads
oomptimizer.py547 linesDownload Raw Back to speech_recognition
1#!/usr/bin/env python2# Copyright (c) 2025, NVIDIA CORPORATION.  All rights reserved.3#4# Licensed under the Apache License, Version 2.0 (the "License");5# you may not use this file except in compliance with the License.6# You may obtain a copy of the License at7#8#     http://www.apache.org/licenses/LICENSE-2.09#10# Unless required by applicable law or agreed to in writing, software11# distributed under the License is distributed on an "AS IS" BASIS,12# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.13# See the License for the specific language governing permissions and14# limitations under the License.15 16import importlib17import math18import sys19from numbers import Number20from typing import Iterable, Literal21 22import click23import lightning.pytorch as pl24import torch25from lhotse import compute_num_samples26from omegaconf import OmegaConf27 28from nemo.collections.asr.models.asr_model import ASRModel29from nemo.core.neural_types import AudioSignal, LabelsType, LengthsType, MaskType, NeuralType30from nemo.utils import logging31 32 33class ProfilingBatchGenerator:34    """35    ProfilingBatchGenerator is used to generate artificial mini-batches for model training36    and tracking the progress of batch size optimization.37 38    The high-level usage API is the following::39 40        >>> gen = ProfilingBatchGenerator(schema)41        ... finished = False42        ... while not finished:43        ...     batch = gen(input_seq_len, output_seq_len)44        ...     try:45        ...         training_step(model, batch)46        ...         oom = False47        ...     except torch.cuda.OutOfMemoryError:48        ...         oom = True49        ...     finished = gen.advance(oom)50        ... solution = gen.max_batch_size  # The solution of the search problem.51        ... gen.reset()  # Can re-use for other sequence lengths now.52 53    The search terminates once the difference between max working batch size and min OOM batch size54    divided by the latter is smaller than ``rel_gap_thresh`` that difference amounts to a single element.55    For example, a max working batch size is 96 and min OOM batch size is 100 indicates a gap of 0.04,56    which would terminate the search with threshold of 0.05.57 58    In order to generate mini-batches compatible with a given model, the generator:59 60    * accepts a ``schema`` argument in its constructor, and61 62    * accepts input/output sequence lengths in each call to generate a mini-batch.63 64    ``schema`` has the following structure::65 66 67        >>> {68        ...     "cls": tuple | MyBatchType,69        ...     "inputs": [70        ...         {71        ...             "type": NeuralType(...) | Literal["dummy"],72        ...             "seq_length": Literal["input", "output"],73        ...             "vocab_size": int,  # optional, required only for LabelsType74        ...             "name": str,  # optional, indicates kwarg75        ...         },76        ...         ...,77        ...     ]78        ... }79 80    ``cls`` indicates how we should construct the mini-batch. Typically you can just use ``tuple`` for most81    batch schemas. However, if the model expects a specific, e.g., dataclass, you can tell ``ProfilingBatchGenerator``82    to use it. The mini-batch object will be constructed using the items in ``inputs``.83 84    Each element of ``inputs`` specifies a NeMo NeuralType which needs to have a defined ``elements_type``.85    The supported types are ``AudioSignal``, ``LengthsType`` and ``LabelsType``.86    If "type" is not a NeuralType, we interpret that as a placeholder tensor that's not relevant but expected87    by the model/batch constructor. In addition, ``"seq_length"`` key is used to determine whether we should apply88    input or output sequence length to a given tensor.89 90    Optional keys:91 92    * ``vocab_size`` is required for ``LabelsType`` so that we can generate proper label values.93 94    * ``name`` is required if objects of ``cls`` have to be constructed using keyword arguments.95 96    A simple schema example for a model using audio/lengths tensor pair (unsupervised/self-supervised)::97 98        >>> {99        ...     "cls": tuple,100        ...     "inputs": [101        ...         {"type": NeuralType(("B", "T"), AudioSignal()), "seq_length": "input"},102        ...         {"type": NeuralType(("B"), LengthsType()), "seq_length": "input"},103        ...     ]104        ... }105 106    """107 108    def __init__(109        self,110        schema: dict,111        start_batch_size: int = 32,112        rel_gap_thresh: float = 0.05,113        device: str = "cuda",114    ):115        self.schema = schema116        self.start_batch_size = start_batch_size117        self.rel_gap_thresh = rel_gap_thresh118        self.device = device119        self.reset()120 121    def __call__(self, input_seq_length: int, output_seq_length: int):122        B = self._current123        select_seq_length = {"input": input_seq_length, "output": output_seq_length}124        batch = []125        names = []126        for item in self.schema["inputs"]:127            nt = item["type"]128            if isinstance(nt, str) and nt == "constant":129                if isinstance(val := item["value"], str) and val == "batch":130                    tnsr = torch.tensor([B], dtype=torch.long, device=self.device)131                else:132                    tnsr = torch.tensor([val], dtype=torch.long, device=self.device)133            elif not isinstance(nt, NeuralType):  # placeholder134                tnsr = torch.tensor([])135            elif isinstance(nt.elements_type, AudioSignal):136                seq_length = select_seq_length[item["seq_length"]]137                tnsr = torch.randn(B, seq_length, dtype=torch.float32, device=self.device)138            elif isinstance(nt.elements_type, LengthsType):139                seq_length = select_seq_length[item["seq_length"]]140                tnsr = torch.ones(B, dtype=torch.long, device=self.device) * seq_length141            elif isinstance(nt.elements_type, LabelsType):142                seq_length = select_seq_length[item["seq_length"]]143                tnsr = torch.randint(0, item["vocab_size"], size=(B, seq_length), device=self.device)144            elif isinstance(nt.elements_type, MaskType):145                seq_length = select_seq_length[item["seq_length"]]146                tnsr = torch.ones(B, seq_length, device=self.device)147            else:148                raise RuntimeError("Unexpected item in oomptimizer schema: {item}")149            batch.append(tnsr)150            names.append(item.get("name"))151        args = [elem for name, elem in zip(names, batch) if name is None]152        kwargs = {name: elem for name, elem in zip(names, batch) if name is not None}153        if not kwargs and self.schema["cls"] == tuple:154            return tuple(args)155        return self.schema["cls"](*args, **kwargs)156 157    @property158    def max_batch_size(self) -> int | None:159        """160        Return the solution of the batch size search problem.161        It will keep returning None until the search is done.162        """163        if (164            self._max_ok is not None165            and self._min_err is not None166            and (self.current_rel_gap <= self.rel_gap_thresh or self._min_err - self._max_ok <= 1)167        ):168            return self._max_ok169        return None170 171    @property172    def current_rel_gap(self) -> float | None:173        """174        Return the current gap between the largest batch that works and the smallest batch that triggers OOM.175        The gap is defined as the batch size difference divided by the larger element.176        E.g., if the best found batch size is 95 and the smallest that triggers OOM is 100, the gap is 0.05.177        """178        if self._min_err is None or self._max_ok is None:179            return None180        return (self._min_err - self._max_ok) / self._min_err181 182    def reset(self):183        """Reset the generator to prepare it for a new search."""184        self._current = self.start_batch_size185        self._max_ok = None  # max batch size that works186        self._min_err = None  # min batch size that doesn't work187 188    def advance(self, oom: bool) -> bool:189        """190        Adjusts the current batch size based on the outcome.191        Returns a bool indicating whether the calibration is complete.192        """193        if self.max_batch_size is not None:194            return True195 196        if oom:197            # Training step failed with OOM.198            # Update the minimum known batch size that causes an error.199            self._min_err = min(float("inf") if self._min_err is None else self._min_err, self._current)200            # Training step failed on OOM201            if self._max_ok is None:202                # We haven't found a batch size that works yet, keep going 2x down.203                self._current = round(self._current / 2)204            else:205                # Try the middle-point between the known extremes.206                self._current = round((self._max_ok + self._min_err) / 2)207        else:208            # Training step successful.209            # Update the maximum known batch size that works.210            self._max_ok = max(-1 if self._max_ok is None else self._max_ok, self._current)211            if self._min_err is None:212                # We haven't found a batch size that causes an error yet, keep going 2x higher213                self._current *= 2214            else:215                # Try the middle-point between the known extremes.216                self._current = round((self._max_ok + self._min_err) / 2)217 218        return False219 220 221class FloatList(click.Option):222    """Support passing bucket duration bins as [1.1,2.5,5.6,...]"""223 224    name = "list[float]"225 226    def type_cast_value(self, ctx, value):227        if isinstance(value, list) and all(isinstance(v, float) for v in value):228            return value229        try:230            import ast231 232            ans = ast.literal_eval(value)233            if isinstance(ans[0], list):234                ans = [tuple(item) for item in ans]235            return ans236        except ValueError:237            raise click.BadParameter(value)238 239 240@click.command(context_settings={'show_default': True})241@click.option(242    "-n",243    "--pretrained-name",244    type=str,245    default=None,246    help="Name of a pretrained model to use, e.g. 'nvidia/canary-1b'.",247)248@click.option(249    "-m",250    "--module-name",251    type=str,252    default=None,253    help="Full path to NeMo's module corresponding to CONFIG_PATH, e.g. 'nemo.collections.asr.models.EncDecMultiTaskModel'.",254)255@click.option(256    "-c", "--config-path", type=str, default=None, help="Path to the training configuration file for MODULE_NAME."257)258@click.option("-o", "--optimizer-name", type=str, default="adamw", help="Name of optimizer to use.")259@click.option(260    "-b",261    "--buckets",262    cls=FloatList,263    default=[5.0, 10.0, 15.0, 20.0, 25.0, 30.0],264    help="List of upper-bound bucket bins (i.e. first bucket is [0.0 - item0), second bucket is [item0 - item1), etc.). "265    "We also support a nested list for 2D bucketing, e.g. [[2.0, 10],[2.0,20],[4.5,15],[4.5,30],...], "266    "where each item is a pair of (max_input_seq_len, max_output_seq_len) for a given bucket.",267)268@click.option(269    "-t",270    "--threshold",271    type=float,272    default=0.05,273    help="Search stopping criterion in range [0, 1], lower is more precise. Interpret as the uncerainty gap, i.e. (min_oom_batch_size - max_ok_batch_size) / min_oom_batch_size.",274)275@click.option("-s", "--start-batch-size", type=int, default=32, help="Initial batch size to start the search from.")276@click.option(277    "-r",278    "--ratio",279    type=int,280    default=12,  # conservative estimate towards longer transcripts281    help="The output_sequence_length to input_sequence_length ratio for the purpose of determing the maximum output sequence lengths. "282    "The interpretation depends on input and output modalities. Examples: for audio->text it's tokens per second. "283    "For text->audio it's seconds per token. For audio->audio it's output seconds per input second. "284    "For text->text it's output tokens per input token. "285    "In general larger ratio means longer output sequences and increased memory consumption. "286    "The default value is set adequately for automatic speech recognition. "287    "This argument is ignored when 2D buckets are provided to --buckets option.",288)289@click.option(290    "-f",291    "--memory-fraction",292    type=float,293    default=0.9,294    help="Limits the use of CUDA memory for this process to MEMORY_FRACTION of the total device memory. "295    "By default we force 5% memory to be unused to account for non-training-loop related CUDA memory usage"296    "in actual training scripts.",297)298@click.option(299    "-d",300    "--device",301    default="cuda:0",302    help="Device string to be passed to torch.device; due to MEMORY_FRACTION option, "303    "it must specify the device index (e.g. cuda:0). "304    "You can also leave the default index and select a specific GPU using env var CUDA_VISIBLE_DEVICES=<idx>",305)306@click.option(307    "-y",308    "--dtype",309    default="bfloat16",310    help="Float precision to use for computation (used together with autocast).",311)312@click.option(313    "--ddp/--no-ddp",314    type=bool,315    default=True,316    help="Whether we should simulate DDP GPU RAM usage. Stores an extra copy of the model in GPU memory. Enabled by default.",317)318def oomptimizer(319    pretrained_name: str | None,320    module_name: str | None,321    config_path: str | None,322    optimizer_name: str,323    buckets: list[float],324    threshold: float,325    start_batch_size: int,326    ratio: int,327    memory_fraction: float,328    device: str,329    dtype: str,330    ddp: bool,331):332    """333    OOMptimizer finds the optimal batch sizes for training your model with bucketing dataloading.334    It performs a search over batch sizes until it converges by measuring the GPU memory usage for335    a model's training step and optimizer update.336 337    \b338    There are two main usage patterns: for using a pretrained model or an untrained model configuration.339    The latter is more flexible but requires the user to provide two separate arguments. Examples:340    * python oomptimizer.py --pretrained-name nvidia/canary-1b341    * python oomptimizer.py --module-name nemo.collections.asr.models.EncDecMultiTaskModel \342        --config-path examples/asr/conf/speech_multitask/fast-conformer_aed.yaml343 344    Dynamic bucketing is notoriously difficult to tune as you risk running into CUDA OOM many steps into the training.345    In order to simplify finding the optimal settings, OOMptimizer scans each bucket to find the maximum possible346    batch size that doesn't trigger a CUDA OOM.347 348    \b349    The suggested workflow is the following:350    1) Run scripts/speech_recognition/estimate_duration_bins.py to get the duration distribution of your data.351        (consider running estimate_duration_bins_2d.py for models with a strong dependency on output sequence length352        such as attention-encoder-decoder models).353    2) Run OOMptimizer to find the optimal batch sizes for your specific model, optimizer, and GPU.354    3) Use these optimal settings in your actual training script and enjoy optimal GPU utilization OOM-free.355 356    In the unlikely event that OOMptimizer bucket batch sizes are still leading to OOMs,357    please try a lower setting of the MEMORY_FRACTION option, e.g. 0.75 (75% of GPU memory).358    This may be required in very complex setups where there are additional GPU RAM loads that can't be anticipated359    through the combination of training_step and optimizer update.360    """361    if all(opt is None for opt in (pretrained_name, module_name, config_path)):362        click.secho(363            "You need to provide either PRETRAINED_NAME or the pair of MODULE_NAME and CONFIG_PATH.", fg="yellow"364        )365        sys.exit(1)366    logging.setLevel(logging.CRITICAL)367    torch.cuda.set_per_process_memory_fraction(memory_fraction, device)368 369    trainer = pl.Trainer(barebones=True)370    trainer.log_every_n_steps = 1000000371    model_clones = []372    for _ in range(2 if ddp else 1):373        if pretrained_name is not None:374            assert (375                config_path is None and module_name is None376            ), "--pretrained-name cannot be used together with --module-name/--config-path"377            click.echo(f"Intializing ASR model from pretrained checkpoint {pretrained_name}.")378            model = ASRModel.from_pretrained(pretrained_name, trainer=trainer).to(device)379        else:380            assert config_path is not None, "--module-name requires --config-path to be specified as well."381            assert module_name is not None, "--config-path requires --module-name to be specified as well."382            cfg = OmegaConf.load(config_path)383            namespace, name = module_name.rsplit('.', maxsplit=1)384            model_cls = getattr(importlib.import_module(namespace), name)385            model = model_cls(cfg=cfg.model, trainer=trainer).to(device)386        model_clones.append(model)387    model = model_clones[-1]388 389    if not hasattr(model, "oomptimizer_schema"):390        click.secho(391            f"We read model of type {type(model)} which doesn't seem to support OOMptimizer "392            f"(we could not find the property .oomptimizer_schema).",393            fg="red",394        )395        sys.exit(1)396 397    schema = model.oomptimizer_schema398 399    click.echo("Setting up the optimizers.")400    optimizer, _ = model.setup_optimization({"name": optimizer_name, "lr": 1e-7, "weight_decay": 0.0})401 402    is_2d_bucketing = all(403        isinstance(item, (list, tuple)) and len(item) == 2 and all(isinstance(v, Number) for v in item)404        for item in buckets405    )406    # Determine modality for input and output.407    modalities = [408        (409            "text"410            if any(411                isinstance(item["type"], NeuralType)412                and isinstance(item["type"].elements_type, LabelsType)413                and item["seq_length"] == direction414                for item in schema["inputs"]415                if item["type"] != "dummy"416            )417            else "audio"418        )419        for direction in ("input", "output")420    ]421 422    def get_max_seq_lens(buckets):423 424        def _determine_lens_for_bucket(bin):425            if is_2d_bucketing:426                input_len, output_len = bin427            else:428                input_len = bin429                output_len = math.ceil(ratio * input_len)430            sampling_rate = getattr(431                model, "sample_rate", 16000432            )  # TODO: may need to extend schema for broader model coverage433            match modalities:434                case "audio", "audio":435                    return (436                        compute_num_samples(input_len, sampling_rate=sampling_rate),437                        compute_num_samples(output_len, sampling_rate=sampling_rate),438                    )439                case "audio", "text":440                    return (compute_num_samples(input_len, sampling_rate=sampling_rate), output_len)441                case "text", "audio":442                    return (443                        input_len,444                        compute_num_samples(output_len, sampling_rate=sampling_rate),445                    )446                case "text", "text":447                    return input_len, output_len448                case _:449                    raise RuntimeError(f"Unexpected modality combination: {_}")450 451        return [_determine_lens_for_bucket(bin) for bin in buckets]452 453    click.echo("Starting profiling.")454    max_seq_lens = get_max_seq_lens(buckets)455    gen = ProfilingBatchGenerator(schema=schema, start_batch_size=start_batch_size, rel_gap_thresh=threshold)456    profile = {}457 458    # Iterate buckets from the largest to the smallest sequences. This usually ends up creating459    # a tiny bit smaller batches, likely due to worse memory fragmentation.460    with torch.autocast("cuda", getattr(torch, dtype)):461        for bucket, (seq_len_in, seq_len_out) in reversed(list(zip(buckets, max_seq_lens))):462            click.echo(f"The current sequence lengths are: input={seq_len_in} output={seq_len_out}.")463            gen.reset()464            batch_idx = 0465 466            def step():467                click.echo(468                    f"\t[BEGIN step] [CUDA RAM CURRENT: {torch.cuda.memory_allocated() / (1024 * 1024):.1f}MB] [CUDA RAM MAX: {torch.cuda.max_memory_allocated() / (1024*1024):.1f}MB]"469                )470                batch = gen(seq_len_in, seq_len_out)471                oom = False472                try:473                    click.echo(f"\tCurrent gap: {gen.current_rel_gap}... ", nl=False)474                    optimizer.zero_grad()475                    out = model.training_step(batch, batch_idx)476                    out['loss'].sum().backward()477                    optimizer.step()478                except torch.cuda.OutOfMemoryError as e:479                    click.secho(f"OOM!", fg="yellow")480                    oom = True481                except RuntimeError as e:482                    if "cuFFT error: CUFFT_INTERNAL_ERROR" not in str(e):483                        raise484                    click.secho(f"OOM!", fg="yellow")485                    oom = True486                else:487                    click.secho(f"OK!", fg="green")488                finally:489                    click.echo(490                        f"\t[END step] [CUDA RAM CURRENT: {torch.cuda.memory_allocated() / (1024 * 1024):.1f}MB] [CUDA RAM MAX: {torch.cuda.max_memory_allocated() / (1024*1024):.1f}MB]"491                    )492                    del batch493                    # Note: We could call empty_cache() to free up some more memory on the GPU,494                    #       but we have found out empirically that this causes a mismatched condition495                    #       between OOMptimizer and the actual training. During training, there is some496                    #       degree of memory fragmentation and it's better to simulate that in OOMptimizer.497                    # torch.cuda.memory.empty_cache()498                    torch.cuda.reset_max_memory_allocated()499                return oom500 501            oom = step()502            while not (finished := gen.advance(oom)):503                click.echo("\t" + "=" * 80)504                oom = step()505 506            click.secho(507                f"=> Optimal setting for bucket={bucket} (input={seq_len_in} output={seq_len_out}) is max_batch_size={gen.max_batch_size}",508                fg="green",509            )510            profile[(bucket, seq_len_in, seq_len_out)] = gen.max_batch_size511            gen.start_batch_size = gen.max_batch_size * 2512 513    # Reverse the profile to be ascendingly sorted again.514    profile = dict(reversed(list(profile.items())))515 516    click.echo("The 1st stage profile is:")517    for (bucket, seq_len_in, seq_len_out), bs in profile.items():518        click.echo(f"Bucket={bucket} (input={seq_len_in} output={seq_len_out}) => max_batch_size={bs}")519 520    if is_2d_bucketing:521        # 2D bucketing doesn't support bucket merging.522        final_profile = [["[" + ",".join(map(str, b)) + "]", bs] for (b, _, __), bs in profile.items()]523    else:524        click.echo("Bucket merging stage...")525        final_profile = []526        for idx, ((bucket, seq_len_in, seq_len_out), bs) in enumerate(profile.items()):527            if idx == 0:528                final_profile.append([bucket, bs])529                continue530            if bs == final_profile[-1][1]:531                click.echo(f"Merging bucket {idx} with bucket {idx-1} due to identical batch sizes.")532                final_profile[-1][0] = bucket533                continue534            final_profile.append([bucket, bs])535 536    click.secho(f"The profile was created with the following settings:")537    click.secho(f"* using {memory_fraction:.1%} of available GPU RAM.")538    click.secho(f"* {'' if ddp else 'not '}simulating DDP memory overhead.")539    click.secho(f"* using AMP with dtype={dtype}.")540    click.secho("The final profile is:", bold=True)541    click.secho("\tbucket_duration_bins=[" + ",".join(str(seqlen) for seqlen, bs in final_profile) + "]", bold=True)542    click.secho("\tbucket_batch_size=[" + ",".join(str(bs) for seqlen, bs in final_profile) + "]", bold=True)543 544 545if __name__ == "__main__":546    oomptimizer()547