Repository: NVIDIA/NeMo

Issue:
Feature Normalization in the ASR preprocessor is too slow.
Another issue I discovered while working on #8673 . 

Right now, when running Parakeet CTC 1.1B at batch size 32, the preprocessor takes about 10milliseconds (of course, this depends on the lengths of the inputs, but this was with librispeech test other). 90% of the time is taken by feature normalization, and the GPU is hardly active during that time.

<img width="580" alt="image" src="https://github.com/NVIDIA/NeMo/assets/4767568/26ccbafb-5810-437a-9bf4-4f44787aa379">

You can reproduce via running this:

```
nsys profile -c cudaProfilerApi $(which python) examples/asr/speech_to_text_eval.py  pretrained_name=nvidia/parakeet-ctc-1.1b dataset_manifest=/home/dgalvez/scratch/data/test_other_sorted_downward.json  \
batch_size=$batch_size  output_filename=test_other_decoded.jsonl  amp=$amp  amp_dtype=bfloat16  use_cer=false num_workers=1
```

The culprit is this code that sequentially does a for loop over the batch: https://github.com/NVIDIA/NeMo/blob/468d5b6d369733909524d42b80a514f33bc19263/nemo/collections/asr/parts/preprocessing/features.py#L65-L73

First of all, it's actually copying a single scalar value of seq_len to CPU three times per loop. If you have to do a GPU to CPU copy, please do it just once via `seq_len = seq_len.cpu()`. But the actual kernels being launched are likely to go faster if we just do a single batched operation. Note that the slowness of this sequential for loop operation only increases as you run at larger batch sizes. An extra 10ms of latency during streaming inference is not really acceptable. The person who wrote the code initially probably wasn't sure how to deal with a ragged tensor for doing mean and std reductions, but we can do it straightforwardly with torch.where() and a mask built from seq_len, without ever communicating with the CPU (which is important for cuda stream capture to a graph).

I will either fix this as part of #8673 or make a separate PR, if the change is larger and riskier than I initially think.

You can download the .nsys-rep file I took a screenshot of here:
[report6.nsys-rep.gz](https://github.com/NVIDIA/NeMo/files/15003685/report6.nsys-rep.gz)



Discussion:
Could you put it to a separate pr ? Let's keep prs small / medium sized and focussed so we can debug them if they cause issues later. 
Also, don't make the assumption that a GPU will always be there (or that cuda graphs are supported). We can have fast paths for those cases when GPU is available or cudapython 12.3 and above is installed via conda / pip but they aren't guaranteed. 
> [don't make the assumption] that cuda graphs are supported

It is good practice to avoid synchronizing with the CPU in general, with GPU code. If we do that, we can get cuda graphs "for free". No part of that is building in cuda graphs as a requirement to do execution.

Resolving patch:
diff --git a/nemo/collections/asr/parts/preprocessing/features.py b/nemo/collections/asr/parts/preprocessing/features.py
--- a/nemo/collections/asr/parts/preprocessing/features.py
+++ b/nemo/collections/asr/parts/preprocessing/features.py
@@ -60,17 +60,33 @@ def normalize_batch(x, seq_len, normalize_type):
     x_mean = None
     x_std = None
     if normalize_type == "per_feature":
-        x_mean = torch.zeros((seq_len.shape[0], x.shape[1]), dtype=x.dtype, device=x.device)
-        x_std = torch.zeros((seq_len.shape[0], x.shape[1]), dtype=x.dtype, device=x.device)
-        for i in range(x.shape[0]):
-            if x[i, :, : seq_len[i]].shape[1] == 1:
-                raise ValueError(
-                    "normalize_batch with `per_feature` normalize_type received a tensor of length 1. This will result "
-                    "in torch.std() returning nan. Make sure your audio length has enough samples for a single "
-                    "feature (ex. at least `hop_length` for Mel Spectrograms)."
-                )
-            x_mean[i, :] = x[i, :, : seq_len[i]].mean(dim=1)
-            x_std[i, :] = x[i, :, : seq_len[i]].std(dim=1)
+        batch_size = x.shape[0]
+        max_time = x.shape[2]
+
+        # When doing stream capture to a graph, item() is not allowed
+        # becuase it calls cudaStreamSynchronize(). Therefore, we are
+        # sacrificing some error checking when running with cuda graphs.
+        if (
+            torch.cuda.is_available()
+            and not torch.cuda.is_current_stream_capturing()
+            and torch.any(seq_len == 1).item()
+        ):
+            raise ValueError(
+                "normalize_batch with `per_feature` normalize_type received a tensor of length 1. This will result "
+                "in torch.std() returning nan. Make sure your audio length has enough samples for a single "
+                "feature (ex. at least `hop_length` for Mel Spectrograms)."
+            )
+        time_steps = torch.arange(max_time, device=x.device).unsqueeze(0).expand(batch_size, max_time)
+        valid_mask = time_steps < seq_len.unsqueeze(1)
+        x_mean_numerator = torch.where(valid_mask.unsqueeze(1), x, 0.0).sum(axis=2)
+        x_mean_denominator = valid_mask.sum(axis=1)
+        x_mean = x_mean_numerator / x_mean_denominator.unsqueeze(1)
+
+        # Subtract 1 in the denominator to correct for the bias.
+        x_std = torch.sqrt(
+            torch.sum(torch.where(valid_mask.unsqueeze(1), x - x_mean.unsqueeze(2), 0.0) ** 2, axis=2)
+            / (x_mean_denominator.unsqueeze(1) - 1.0)
+        )
         # make sure x_std is not zero
         x_std += CONSTANT
         return (x - x_mean.unsqueeze(2)) / x_std.unsqueeze(2), x_mean, x_std
@@ -457,7 +473,7 @@ def forward(self, x, seq_len, linear_spec=False):
 
         # mask to zero any values beyond seq_len in batch, pad to multiple of `pad_to` (for efficiency)
         max_len = x.size(-1)
-        mask = torch.arange(max_len).to(x.device)
+        mask = torch.arange(max_len, device=x.device)
         mask = mask.repeat(x.size(0), 1) >= seq_len.unsqueeze(1)
         x = x.masked_fill(mask.unsqueeze(1).type(torch.bool).to(device=x.device), self.pad_value)
         del mask


Test patch: