diff --git a/nemo/collections/asr/parts/submodules/ctc_decoding.py b/nemo/collections/asr/parts/submodules/ctc_decoding.py
index 67559eccf6e2..70d63c0f8c6f 100644
--- a/nemo/collections/asr/parts/submodules/ctc_decoding.py
+++ b/nemo/collections/asr/parts/submodules/ctc_decoding.py
@@ -213,20 +213,20 @@ def __init__(self, decoding_cfg, blank_id: int):
         self.batch_dim_index = self.cfg.get('batch_dim_index', 0)
         self.word_seperator = self.cfg.get('word_seperator', ' ')

-        possible_strategies = ['greedy', 'beam', 'pyctcdecode', 'flashlight']
+        possible_strategies = ['greedy', 'greedy_batched', 'beam', 'pyctcdecode', 'flashlight']
         if self.cfg.strategy not in possible_strategies:
             raise ValueError(f"Decoding strategy must be one of {possible_strategies}. Given {self.cfg.strategy}")

         # Update preserve alignments
         if self.preserve_alignments is None:
-            if self.cfg.strategy in ['greedy']:
+            if self.cfg.strategy in ['greedy', 'greedy_batched']:
                 self.preserve_alignments = self.cfg.greedy.get('preserve_alignments', False)
             else:
                 self.preserve_alignments = self.cfg.beam.get('preserve_alignments', False)

         # Update compute timestamps
         if self.compute_timestamps is None:
-            if self.cfg.strategy in ['greedy']:
+            if self.cfg.strategy in ['greedy', 'greedy_batched']:
                 self.compute_timestamps = self.cfg.greedy.get('compute_timestamps', False)
             elif self.cfg.strategy in ['beam']:
                 self.compute_timestamps = self.cfg.beam.get('compute_timestamps', False)
@@ -234,10 +234,10 @@ def __init__(self, decoding_cfg, blank_id: int):
         # initialize confidence-related fields
         self._init_confidence(self.cfg.get('confidence_cfg', None))

-        # Confidence estimation is not implemented for strategies other than `greedy`
+        # Confidence estimation is not implemented for strategies other than `greedy` and `greedy_batched`
         if (
             not self.preserve_frame_confidence
-            and self.cfg.strategy != 'greedy'
+            and self.cfg.strategy not in ('greedy', 'greedy_batched')
             and self.cfg.beam.get('preserve_frame_confidence', False)
         ):
             raise NotImplementedError(f"Confidence calculation is not supported for strategy `{self.cfg.strategy}`")
@@ -247,6 +247,10 @@ def __init__(self, decoding_cfg, blank_id: int):
             self.compute_timestamps |= self.preserve_frame_confidence

         if self.cfg.strategy == 'greedy':
+            logging.warning(
+                "CTC decoding strategy 'greedy' is slower than 'greedy_batched', which implements the same exact interface. Consider changing your strategy to 'greedy_batched' for a free performance improvement.",
+                mode=logging_mode.ONCE,
+            )

             self.decoding = ctc_greedy_decoding.GreedyCTCInfer(
                 blank_id=self.blank_id,
@@ -256,6 +260,15 @@ def __init__(self, decoding_cfg, blank_id: int):
                 confidence_method_cfg=self.confidence_method_cfg,
             )

+        elif self.cfg.strategy == "greedy_batched":
+            self.decoding = ctc_greedy_decoding.GreedyBatchedCTCInfer(
+                blank_id=self.blank_id,
+                preserve_alignments=self.preserve_alignments,
+                compute_timestamps=self.compute_timestamps,
+                preserve_frame_confidence=self.preserve_frame_confidence,
+                confidence_method_cfg=self.confidence_method_cfg,
+            )
+
         elif self.cfg.strategy == 'beam':

             self.decoding = ctc_beam_decoding.BeamCTCInfer(
@@ -1287,7 +1300,7 @@ def decode_ids_to_tokens(self, tokens: List[int]) -> List[str]:

 @dataclass
 class CTCDecodingConfig:
-    strategy: str = "greedy"
+    strategy: str = "greedy_batched"

     # preserve decoding alignments
     preserve_alignments: Optional[bool] = None
diff --git a/nemo/collections/asr/parts/submodules/ctc_greedy_decoding.py b/nemo/collections/asr/parts/submodules/ctc_greedy_decoding.py
index ab4b4c40e860..1ef26cd7adf3 100644
--- a/nemo/collections/asr/parts/submodules/ctc_greedy_decoding.py
+++ b/nemo/collections/asr/parts/submodules/ctc_greedy_decoding.py
@@ -110,7 +110,7 @@ class GreedyCTCInfer(Typing, ConfidenceMethodMixin):
     def input_types(self):
         """Returns definitions of module input ports.
         """
-        # Input can be of dimention -
+        # Input can be of dimension -
         # ('B', 'T', 'D') [Log probs] or ('B', 'T') [Labels]

         return {
@@ -266,6 +266,227 @@ def __call__(self, *args, **kwargs):
         return self.forward(*args, **kwargs)

+class GreedyBatchedCTCInfer(Typing, ConfidenceMethodMixin):
+    """A vectorized greedy CTC decoder.
+
+    This is basically always faster than GreedyCTCInfer, and supports
+    the same interface. See issue #8891 on github for what is wrong
+    with GreedyCTCInfer. GreedyCTCInfer loops over each element in the
+    batch, running kernels at batch size one. CPU overheads end up
+    dominating. This implementation does appropriate masking to
+    appropriately do the same operation in a batched manner.
+
+    Args:
+        blank_index: int index of the blank token. Can be 0 or len(vocabulary).
+        preserve_alignments: Bool flag which preserves the history of logprobs generated during
+            decoding (sample / batched). When set to true, the Hypothesis will contain
+            the non-null value for `logprobs` in it. Here, `logprobs` is a torch.Tensors.
+        compute_timestamps: A bool flag, which determines whether to compute the character/subword, or
+                word based timestamp mapping the output log-probabilities to discrite intervals of timestamps.
+                The timestamps will be available in the returned Hypothesis.timestep as a dictionary.
+        preserve_frame_confidence: Bool flag which preserves the history of per-frame confidence scores
+            generated during decoding. When set to true, the Hypothesis will contain
+            the non-null value for `frame_confidence` in it. Here, `frame_confidence` is a List of floats.
+        confidence_method_cfg: A dict-like object which contains the method name and settings to compute per-frame
+            confidence scores.
+
+            name: The method name (str).
+                Supported values:
+                    - 'max_prob' for using the maximum token probability as a confidence.
+                    - 'entropy' for using a normalized entropy of a log-likelihood vector.
+
+            entropy_type: Which type of entropy to use (str). Used if confidence_method_cfg.name is set to `entropy`.
+                Supported values:
+                    - 'gibbs' for the (standard) Gibbs entropy. If the alpha (α) is provided,
+                        the formula is the following: H_α = -sum_i((p^α_i)*log(p^α_i)).
+                        Note that for this entropy, the alpha should comply the following inequality:
+                        (log(V)+2-sqrt(log^2(V)+4))/(2*log(V)) <= α <= (1+log(V-1))/log(V-1)
+                        where V is the model vocabulary size.
+                    - 'tsallis' for the Tsallis entropy with the Boltzmann constant one.
+                        Tsallis entropy formula is the following: H_α = 1/(α-1)*(1-sum_i(p^α_i)),
+                        where α is a parameter. When α == 1, it works like the Gibbs entropy.
+                        More: https://en.wikipedia.org/wiki/Tsallis_entropy
+                    - 'renyi' for the Rényi entropy.
+                        Rényi entropy formula is the following: H_α = 1/(1-α)*log_2(sum_i(p^α_i)),
+                        where α is a parameter. When α == 1, it works like the Gibbs entropy.
+                        More: https://en.wikipedia.org/wiki/R%C3%A9nyi_entropy
+
+            alpha: Power scale for logsoftmax (α for entropies). Here we restrict it to be > 0.
+                When the alpha equals one, scaling is not applied to 'max_prob',
+                and any entropy type behaves like the Shannon entropy: H = -sum_i(p_i*log(p_i))
+
+            entropy_norm: A mapping of the entropy value to the interval [0,1].
+                Supported values:
+                    - 'lin' for using the linear mapping.
+                    - 'exp' for using exponential mapping with linear shift.
+
+    """
+
+    @property
+    def input_types(self):
+        """Returns definitions of module input ports.
+        """
+        # Input can be of dimension -
+        # ('B', 'T', 'D') [Log probs] or ('B', 'T') [Labels]
+
+        return {
+            "decoder_output": NeuralType(None, LogprobsType()),
+            "decoder_lengths": NeuralType(tuple('B'), LengthsType()),
+        }
+
+    @property
+    def output_types(self):
+        """Returns definitions of module output ports.
+        """
+        return {"predictions": [NeuralType(elements_type=HypothesisType())]}
+
+    def __init__(
+        self,
+        blank_id: int,
+        preserve_alignments: bool = False,
+        compute_timestamps: bool = False,
+        preserve_frame_confidence: bool = False,
+        confidence_method_cfg: Optional[DictConfig] = None,
+    ):
+        super().__init__()
+
+        self.blank_id = blank_id
+        self.preserve_alignments = preserve_alignments
+        # we need timestamps to extract non-blank per-frame confidence
+        self.compute_timestamps = compute_timestamps | preserve_frame_confidence
+        self.preserve_frame_confidence = preserve_frame_confidence
+
+        # set confidence calculation method
+        self._init_confidence_method(confidence_method_cfg)
+
+    @typecheck()
+    def forward(
+        self, decoder_output: torch.Tensor, decoder_lengths: torch.Tensor,
+    ):
+        """Returns a list of hypotheses given an input batch of the encoder hidden embedding.
+        Output token is generated auto-repressively.
+
+        Args:
+            decoder_output: A tensor of size (batch, timesteps, features) or (batch, timesteps) (each timestep is a label).
+            decoder_lengths: list of int representing the length of each sequence
+                output sequence.
+
+        Returns:
+            packed list containing batch number of sentences (Hypotheses).
+        """
+        if decoder_output.ndim == 2:
+            hypotheses = self._greedy_decode_labels_batched(decoder_output, decoder_lengths)
+        else:
+            hypotheses = self._greedy_decode_logprobs_batched(decoder_output, decoder_lengths)
+        packed_result = pack_hypotheses(hypotheses, decoder_lengths)
+        return (packed_result,)
+
+    @torch.no_grad()
+    def _greedy_decode_logprobs_batched(self, x: torch.Tensor, out_len: torch.Tensor):
+        # x: [B, T, D]
+        # out_len: [B]
+
+        batch_size = x.shape[0]
+        max_time = x.shape[1]
+
+        predictions = x
+        # In CTC greedy decoding, each output maximum likelihood token
+        # is calculated independent of the other tokens.
+        predictions_logprobs, predictions_labels = predictions.max(dim=-1)
+
+        # Since predictions_logprobs is a padded matrix in the time
+        # dimension, we consider invalid timesteps to be "blank".
+        time_steps = torch.arange(max_time, device=x.device).unsqueeze(0).expand(batch_size, max_time)
+        non_blank_ids_mask = torch.logical_and(predictions_labels != self.blank_id, time_steps < out_len.unsqueeze(1))
+        # Sum the non-blank labels to compute the score of the
+        # transcription. This follows from Eq. (3) of "Connectionist
+        # Temporal Classification: Labelling Unsegmented Sequence Data
+        # with Recurrent Neural Networks".
+        scores = torch.where(non_blank_ids_mask, predictions_logprobs, 0.0).sum(axis=1)
+
+        scores = scores.cpu()
+        predictions_labels = predictions_labels.cpu()
+        out_len = out_len.cpu()
+
+        if self.preserve_alignments or self.preserve_frame_confidence:
+            predictions = predictions.cpu()
+
+        hypotheses = []
+
+        # This mimics the for loop in GreedyCTCInfer::forward.
+        for i in range(batch_size):
+            hypothesis = rnnt_utils.Hypothesis(score=0.0, y_sequence=[], dec_state=None, timestep=[], last_token=None)
+            hypothesis.score = scores[i]
+
+            prediction_labels_no_padding = predictions_labels[i, : out_len[i]].tolist()
+
+            assert predictions_labels.dtype == torch.int64
+            hypothesis.y_sequence = prediction_labels_no_padding
+
+            if self.preserve_alignments:
+                hypothesis.alignments = (
+                    predictions[i, : out_len[i], :].clone(),
+                    predictions_labels[i, : out_len[i]].clone(),
+                )
+            if self.compute_timestamps:
+                # TOOD: Could do this in a vectorized manner... Would
+                # prefer to have nonzero_static, though, for sanity.
+                # Or do a prefix sum on out_len
+                hypothesis.timestep = torch.nonzero(non_blank_ids_mask[i], as_tuple=False)[:, 0].cpu().tolist()
+            if self.preserve_frame_confidence:
+                hypothesis.frame_confidence = self._get_confidence(predictions[i, : out_len[i], :])
+
+            hypotheses.append(hypothesis)
+
+        return hypotheses
+
+    @torch.no_grad()
+    def _greedy_decode_labels_batched(self, x: torch.Tensor, out_len: torch.Tensor):
+        """
+        This does greedy decoding in the case where you have already found the
+        most likely token at each timestep.
+        """
+        # x: [B, T]
+        # out_len: [B]
+
+        batch_size = x.shape[0]
+        max_time = x.shape[1]
+
+        predictions_labels = x
+        time_steps = torch.arange(max_time, device=x.device).unsqueeze(0).expand(batch_size, max_time)
+        non_blank_ids_mask = torch.logical_and(predictions_labels != self.blank_id, time_steps < out_len.unsqueeze(1))
+        predictions_labels = predictions_labels.cpu()
+        out_len = out_len.cpu()
+
+        hypotheses = []
+
+        for i in range(batch_size):
+            hypothesis = rnnt_utils.Hypothesis(score=0.0, y_sequence=[], dec_state=None, timestep=[], last_token=None)
+            hypothesis.y_sequence = predictions_labels[i, : out_len[i]].tolist()
+            hypothesis.score = -1.0
+
+            if self.preserve_alignments:
+                raise ValueError(
+                    "Requested for alignments, but predictions provided were labels, not log probabilities."
+                )
+            if self.compute_timestamps:
+                # TOOD: Could do this in a vectorized manner... Would
+                # prefer to have nonzero_static, though, for sanity.
+                # Or do a prefix sum on out_len
+                hypothesis.timestep = torch.nonzero(non_blank_ids_mask[i], as_tuple=False)[:, 0].cpu().tolist()
+            if self.preserve_frame_confidence:
+                raise ValueError(
+                    "Requested for per-frame confidence, but predictions provided were labels, not log probabilities."
+                )
+
+            hypotheses.append(hypothesis)
+
+        return hypotheses
+
+    def __call__(self, *args, **kwargs):
+        return self.forward(*args, **kwargs)
+
+
 @dataclass
 class GreedyCTCInferConfig:
     preserve_alignments: bool = False

diff --git a/tests/collections/asr/decoding/test_ctc_decoding.py b/tests/collections/asr/decoding/test_ctc_decoding.py
index a3a5689062bf..8eceb822fd38 100644
--- a/tests/collections/asr/decoding/test_ctc_decoding.py
+++ b/tests/collections/asr/decoding/test_ctc_decoding.py
@@ -26,6 +26,7 @@
     CTCDecoding,
     CTCDecodingConfig,
 )
+from nemo.collections.asr.parts.utils.asr_confidence_utils import ConfidenceConfig
 from nemo.collections.asr.parts.utils.rnnt_utils import Hypothesis

@@ -191,3 +192,82 @@ def test_subword_decoding_greedy_forward_hypotheses(self, tmp_tokenizer, alignme
                 # timestamps check
                 if timestamps:
                     check_subword_timestamps(hyp, decoding)
+
+    @pytest.mark.unit
+    @pytest.mark.parametrize('alignments', [False, True])
+    @pytest.mark.parametrize('timestamps', [False, True])
+    @pytest.mark.parametrize('preserve_frame_confidence', [False, True])
+    def test_batched_decoding_logprobs(self, tmp_tokenizer, alignments, timestamps, preserve_frame_confidence):
+        cfg = CTCBPEDecodingConfig(
+            strategy='greedy',
+            preserve_alignments=alignments,
+            compute_timestamps=timestamps,
+            confidence_cfg=ConfidenceConfig(preserve_frame_confidence=preserve_frame_confidence),
+        )
+        unbatched_decoding = CTCBPEDecoding(decoding_cfg=cfg, tokenizer=tmp_tokenizer)
+
+        cfg.strategy = 'greedy_batched'
+        batched_decoding = CTCBPEDecoding(decoding_cfg=cfg, tokenizer=tmp_tokenizer)
+
+        torch.manual_seed(1)
+        B, T = 4, 20
+        V = unbatched_decoding.tokenizer.tokenizer.vocab_size + 1
+        input_signal = torch.randn(size=(B, T, V))
+        # Set the blank index to a very high probability to make sure
+        # that we always handle at least a few blanks.
+        input_signal[:, 0, unbatched_decoding.tokenizer.tokenizer.vocab_size] = 1000
+        input_signal[:, 1, unbatched_decoding.tokenizer.tokenizer.vocab_size] = 1000
+        length = torch.randint(low=1, high=T, size=[B])
+
+        with torch.inference_mode():
+            hyps, _ = unbatched_decoding.ctc_decoder_predictions_tensor(
+                input_signal, length, fold_consecutive=True, return_hypotheses=True
+            )
+
+            batched_hyps, _ = batched_decoding.ctc_decoder_predictions_tensor(
+                input_signal, length, fold_consecutive=True, return_hypotheses=True
+            )
+
+            assert len(hyps) == len(batched_hyps) == B
+            for hyp, batched_hyp in zip(hyps, batched_hyps):
+                assert torch.abs(hyp.score - batched_hyp.score) <= 1e-5
+                assert torch.all(hyp.y_sequence == batched_hyp.y_sequence)
+                if timestamps:
+                    assert hyp.timestep == batched_hyp.timestep
+                if alignments:
+                    assert torch.all(hyp.alignments[0] == batched_hyp.alignments[0])
+                    assert torch.all(hyp.alignments[1] == batched_hyp.alignments[1])
+
+    @pytest.mark.unit
+    @pytest.mark.parametrize('timestamps', [False, True])
+    def test_batched_decoding_labels(self, tmp_tokenizer, timestamps):
+        cfg = CTCBPEDecodingConfig(strategy='greedy', compute_timestamps=timestamps)
+        unbatched_decoding = CTCBPEDecoding(decoding_cfg=cfg, tokenizer=tmp_tokenizer)
+        cfg.strategy = 'greedy_batched'
+        batched_decoding = CTCBPEDecoding(decoding_cfg=cfg, tokenizer=tmp_tokenizer)
+
+        torch.manual_seed(1)
+        B, T = 4, 20
+        V = unbatched_decoding.tokenizer.tokenizer.vocab_size + 1
+        input_labels = torch.randint(V, size=(B, T))
+        # Set some indices to blank to make sure that we always handle
+        # at least a few blanks.
+        input_labels[:, 0] = unbatched_decoding.tokenizer.tokenizer.vocab_size
+        input_labels[:, 1] = unbatched_decoding.tokenizer.tokenizer.vocab_size
+        length = torch.randint(low=1, high=T, size=[B])
+
+        with torch.inference_mode():
+            hyps, _ = unbatched_decoding.ctc_decoder_predictions_tensor(
+                input_labels, length, fold_consecutive=True, return_hypotheses=True
+            )
+
+            batched_hyps, _ = batched_decoding.ctc_decoder_predictions_tensor(
+                input_labels, length, fold_consecutive=True, return_hypotheses=True
+            )
+
+            assert len(hyps) == len(batched_hyps) == B
+            for hyp, batched_hyp in zip(hyps, batched_hyps):
+                assert abs(hyp.score - batched_hyp.score) <= 1e-5
+                assert torch.all(hyp.y_sequence == batched_hyp.y_sequence)
+                if timestamps:
+                    assert hyp.timestep == batched_hyp.timestep
diff --git a/tests/collections/asr/test_asr_ctc_encoder_model_bpe.py b/tests/collections/asr/test_asr_ctc_encoder_model_bpe.py
index 744936263a03..2005c0e8d41c 100644
--- a/tests/collections/asr/test_asr_ctc_encoder_model_bpe.py
+++ b/tests/collections/asr/test_asr_ctc_encoder_model_bpe.py
@@ -269,7 +269,7 @@ def test_vocab_change(self, test_data_dir, asr_model):
     def test_decoding_change(self, asr_model):
         assert asr_model.decoding is not None
         assert isinstance(asr_model.decoding, CTCBPEDecoding)
-        assert asr_model.decoding.cfg.strategy == "greedy"
+        assert asr_model.decoding.cfg.strategy == "greedy_batched"
         assert asr_model.decoding.preserve_alignments is False
         assert asr_model.decoding.compute_timestamps is False

diff --git a/tests/collections/asr/test_asr_ctcencdec_model.py b/tests/collections/asr/test_asr_ctcencdec_model.py
index 98d563a4a688..d2587913b879 100644
--- a/tests/collections/asr/test_asr_ctcencdec_model.py
+++ b/tests/collections/asr/test_asr_ctcencdec_model.py
@@ -150,7 +150,7 @@ def test_vocab_change(self, asr_model):
     def test_decoding_change(self, asr_model):
         assert asr_model.decoding is not None
         assert isinstance(asr_model.decoding, CTCDecoding)
-        assert asr_model.decoding.cfg.strategy == "greedy"
+        assert asr_model.decoding.cfg.strategy == "greedy_batched"
         assert asr_model.decoding.preserve_alignments is False
         assert asr_model.decoding.compute_timestamps is False

diff --git a/tests/collections/asr/test_asr_hybrid_rnnt_ctc_model_bpe.py b/tests/collections/asr/test_asr_hybrid_rnnt_ctc_model_bpe.py
index 55e780c022d8..994d832ec6e5 100644
--- a/tests/collections/asr/test_asr_hybrid_rnnt_ctc_model_bpe.py
+++ b/tests/collections/asr/test_asr_hybrid_rnnt_ctc_model_bpe.py
@@ -297,7 +297,7 @@ def test_decoding_change(self, hybrid_asr_model):

         assert hybrid_asr_model.ctc_decoding is not None
         assert isinstance(hybrid_asr_model.ctc_decoding, CTCBPEDecoding)
-        assert hybrid_asr_model.ctc_decoding.cfg.strategy == "greedy"
+        assert hybrid_asr_model.ctc_decoding.cfg.strategy == "greedy_batched"
         assert hybrid_asr_model.ctc_decoding.preserve_alignments is False
         assert hybrid_asr_model.ctc_decoding.compute_timestamps is False

diff --git a/tests/collections/asr/test_asr_hybrid_rnnt_ctc_model_char.py b/tests/collections/asr/test_asr_hybrid_rnnt_ctc_model_char.py
index 018c9bcc4aa2..923263787def 100644
--- a/tests/collections/asr/test_asr_hybrid_rnnt_ctc_model_char.py
+++ b/tests/collections/asr/test_asr_hybrid_rnnt_ctc_model_char.py
@@ -231,7 +231,7 @@ def test_decoding_change(self, hybrid_asr_model):

         assert hybrid_asr_model.ctc_decoding is not None
         assert isinstance(hybrid_asr_model.ctc_decoding, CTCDecoding)
-        assert hybrid_asr_model.ctc_decoding.cfg.strategy == "greedy"
+        assert hybrid_asr_model.ctc_decoding.cfg.strategy == "greedy_batched"
         assert hybrid_asr_model.ctc_decoding.preserve_alignments is False
         assert hybrid_asr_model.ctc_decoding.compute_timestamps is False

Consider refactoring CTC greedy decoding
**Is your feature request related to a problem? Please describe.**

As part of my work on #8673 , I have been profiling to make sure we have low latency and high throughput. I have observed that this line can sometimes take https://github.com/NVIDIA/NeMo/blob/57444ae9b63afc5419a16948f7d032a1a9f7dc7f/nemo/collections/asr/parts/submodules/ctc_greedy_decoding.py#L218 several milliseconds. To be concrete, running at batch size 10, the model is taking 167 milliseconds, while decoding is taking 29 milliseconds, with almost all of the time spent on this max() operation. This is documented below.

It is not acceptable to have model inference be bottlenecked on non-GPU work like this.

I uploaded a gzipped version of the nsight systems file if you would like to view it.

[report25.nsys-rep.gz](https://github.com/NVIDIA/NeMo/files/14950389/report25.nsys-rep.gz)

The most obvious way to handle this situation is to do the "max()" operation on the GPU. I previously did a refactor that sped up CTC decoding by improving copy from GPU to CPU https://github.com/NVIDIA/NeMo/pull/8521, but it seems that is better to copy only after the max() operation is done. I did not do this in my previous PR, because (1) I didn't think it would be a concern and (2) that change is much more intrusive. _greedy_decode_logprobs is built with running on only a single element at a time rather than a batch. Maybe vmap can save us...

BTW, why is this only sometimes slow? I'm not certain, but it probably has to do with the CPU cache. It's working directly on pinned memory. The cache line may be getting evicted only sometimes?
Consider refactoring CTC greedy decoding
**Is your feature request related to a problem? Please describe.**

As part of my work on #8673 , I have been profiling to make sure we have low latency and high throughput. I have observed that this line can sometimes take https://github.com/NVIDIA/NeMo/blob/57444ae9b63afc5419a16948f7d032a1a9f7dc7f/nemo/collections/asr/parts/submodules/ctc_greedy_decoding.py#L218 several milliseconds. To be concrete, running at batch size 10, the model is taking 167 milliseconds, while decoding is taking 29 milliseconds, with almost all of the time spent on this max() operation. This is documented below.

It is not acceptable to have model inference be bottlenecked on non-GPU work like this.

I uploaded a gzipped version of the nsight systems file if you would like to view it.

[report25.nsys-rep.gz](https://github.com/NVIDIA/NeMo/files/14950389/report25.nsys-rep.gz)

The most obvious way to handle this situation is to do the "max()" operation on the GPU. I previously did a refactor that sped up CTC decoding by improving copy from GPU to CPU https://github.com/NVIDIA/NeMo/pull/8521, but it seems that is better to copy only after the max() operation is done. I did not do this in my previous PR, because (1) I didn't think it would be a concern and (2) that change is much more intrusive. _greedy_decode_logprobs is built with running on only a single element at a time rather than a batch. Maybe vmap can save us...

BTW, why is this only sometimes slow? I'm not certain, but it probably has to do with the CPU cache. It's working directly on pinned memory. The cache line may be getting evicted only sometimes?

@artbataev this may be related to your observation of TDT being faster than CTC in some cases.
If I recall correctly, max does work on GPU so I don't know why I forced it onto CPU. Maybe the slice? Either way, if it fixes the slowdown let's move the .CPU() to after max calculation.

It might have been that the logprobs can be super long for 12 hours so I wanted to preserve memory, in which case the cost of speed is not under consideration but I suppose we can make it a premature optimization and select whether to move the tensor to CPU before or after depending on length of the tensor.
It is pretty common to run long audio CTC - NFA does it constantly. But on average, it is more common to run short audio batches
> It might have been that the logprobs can be super long for 12 hours so I wanted to preserve memory

The tensor pointed to by "prediction" already existed on GPU. It is just being copied to CPU in this code. We should not expect any memory increase from my suggestion. In fact, we will see a CPU memory decrease.
That's true. Can you see if moving it onto CPU at the end before return is sufficient to remove this bottleneck then let's do it in your other pr
@artbataev this may be related to your observation of TDT being faster than CTC in some cases.
If I recall correctly, max does work on GPU so I don't know why I forced it onto CPU. Maybe the slice? Either way, if it fixes the slowdown let's move the .CPU() to after max calculation.

It might have been that the logprobs can be super long for 12 hours so I wanted to preserve memory, in which case the cost of speed is not under consideration but I suppose we can make it a premature optimization and select whether to move the tensor to CPU before or after depending on length of the tensor.
It is pretty common to run long audio CTC - NFA does it constantly. But on average, it is more common to run short audio batches
> It might have been that the logprobs can be super long for 12 hours so I wanted to preserve memory

The tensor pointed to by "prediction" already existed on GPU. It is just being copied to CPU in this code. We should not expect any memory increase from my suggestion. In fact, we will see a CPU memory decrease.
That's true. Can you see if moving it onto CPU at the end before return is sufficient to remove this bottleneck then let's do it in your other pr


diff --git a/lib/streamlit/web/server/server.py b/lib/streamlit/web/server/server.py
index 027db4ab064a..5b3d71f4571f 100644
--- a/lib/streamlit/web/server/server.py
+++ b/lib/streamlit/web/server/server.py
@@ -369,7 +369,12 @@ def _create_app(self) -> tornado.web.Application:
             routes.extend(
                 [
                     (
-                        make_url_path_regex(base, "(.*)", trailing_slash="required"),
+                        # We want to remove paths with a trailing slash, but if the path
+                        # starts with a double slash //, the redirect will point
+                        # the browser to the wrong host.
+                        make_url_path_regex(
+                            base, "(?!/)(.*)", trailing_slash="required"
+                        ),
                         RemoveSlashHandler,
                     ),
                     (

diff --git a/lib/tests/streamlit/web/server/routes_test.py b/lib/tests/streamlit/web/server/routes_test.py
index a4f7a62781fe..b47c678c16a7 100644
--- a/lib/tests/streamlit/web/server/routes_test.py
+++ b/lib/tests/streamlit/web/server/routes_test.py
@@ -196,7 +196,7 @@ def get_app(self):
         return tornado.web.Application(
             [
                 (
-                    r"/(.*)/",
+                    r"^/(?!/)(.*)",
                     RemoveSlashHandler,
                 )
             ]
@@ -210,6 +210,13 @@ def test_parse_url_path_301(self):
             assert r.code == 301
             assert r.headers["Location"] == paths[idx].rstrip("/")

+    def test_parse_url_path_404(self):
+        paths = ["//page1/", "//page2/page3/"]
+        responses = [self.fetch(path, follow_redirects=False) for path in paths]
+
+        for r in responses:
+            assert r.code == 404
+

 class AddSlashHandlerTest(tornado.testing.AsyncHTTPTestCase):
     def get_app(self):

Undesired host redirect with trailing slash and host path
### Checklist

- [X] I have searched the [existing issues](https://github.com/streamlit/streamlit/issues) for similar issues.
- [X] I added a very descriptive title to this issue.
- [X] I have provided sufficient information below to help reproduce this issue.

### Summary

If a "hostname" path is provided with a trailing slash, streamlit will respond with a redirect to the host in the path.
Example url (streamlit running locally):  `http://localhost:8501//authorization.site/`
This will 301 redirect to `authorization.site`
This is undesired behavior and can lead to phishing attacks when an trusted hostname redirects to a malicious page.

This was introduced in the [1.39.0 release](https://github.com/streamlit/streamlit/releases/tag/1.39.0) by https://github.com/streamlit/streamlit/pull/9500

### Reproducible Code Example

_No response_

### Steps To Reproduce

1. Request a path against a streamlit server version 1.39.0 with the format `{server_hostname}//{redirect_hostname}/`, eg (http://localhost:8501//authorization.site/)
2. Observe the redirect to the redirect_hostname

### Expected Behavior

Expected behavior is for the server to return a 403 Forbidden response, and this was the behavior in streamlit release version `1.38.0`
Example Request for Streamlit 1.38.0:
```
curl -v http://localhost:8080//authorization.site/
* Host localhost:8080 was resolved.
* IPv6: ::1
* IPv4: 127.0.0.1
*   Trying [::1]:8080...
* Connected to localhost (::1) port 8080
> GET //authorization.site/ HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/8.7.1
> Accept: */*
>
* Request completely sent off
< HTTP/1.1 403 Forbidden
< Server: TornadoServer/6.4.1
< Content-Type: text/html; charset=UTF-8
< Date: Sun, 20 Oct 2024 17:49:24 GMT
< Content-Length: 69
< Vary: Accept-Encoding
<
* Connection #0 to host localhost left intact
<html><title>403: Forbidden</title><body>403: Forbidden</body></html>%
```

### Current Behavior

Curl response when requesting against Streamlit version 1.39.0:
```
curl -v http://localhost:8080//authorization.site/
* Host localhost:8080 was resolved.
* IPv6: ::1
* IPv4: 127.0.0.1
*   Trying [::1]:8080...
* Connected to localhost (::1) port 8080
> GET //authorization.site/ HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/8.7.1
> Accept: */*
>
* Request completely sent off
< HTTP/1.1 301 Moved Permanently
< Server: TornadoServer/6.4.1
< Content-Type: text/html; charset=UTF-8
< Date: Sun, 20 Oct 2024 17:30:50 GMT
< Location: //authorization.site
< Content-Length: 0
< Vary: Accept-Encoding
<
* Connection #0 to host localhost left intact
 ```

### Is this a regression?

- [X] Yes, this used to work in a previous version.

### Debug info

- Streamlit version: 1.39.0
- Python version: 3.9
- Operating System: MacOs
- Browser: Curl

### Additional Information

_No response_

**If this issue affects you, please react with a 👍 (thumbs up emoji) to the initial post.**

Your feedback helps us prioritize which bugs to investigate and address first.

![Visits](https://hits.seeyoufarm.com/api/count/incr/badge.svg?url=https%3A%2F%2Fgithub.com%2Fstreamlit%2Fstreamlit%2Fissues%2F9690&title=visits&edge_flat=false)

Thank you @a-bro for opening this issue!

@kmcgrady, could you please take a look, consult with security about potential malicious usage of the redirect?
**To help Streamlit prioritize this feature, react with a 👍 (thumbs up emoji) to the initial post.**

Your vote helps us identify which enhancements matter most to our users.

![Visits](https://hits.seeyoufarm.com/api/count/incr/badge.svg?url=https%3A%2F%2Fgithub.com%2Fstreamlit%2Fstreamlit%2Fissues%2F9690&title=visits&edge_flat=false)


diff --git a/lib/streamlit/runtime/state/session_state.py b/lib/streamlit/runtime/state/session_state.py
index 5481cd2be27a..b9f016e19ad7 100644
--- a/lib/streamlit/runtime/state/session_state.py
+++ b/lib/streamlit/runtime/state/session_state.py
@@ -280,6 +280,56 @@ def _missing_key_error_message(key: str) -> str:
     )

+@dataclass
+class KeyIdMapper:
+    """A mapping of user-provided keys to element IDs.
+    It also maps element IDs to user-provided keys so that this reverse mapping
+    does not have to be computed ad-hoc.
+    All built-in dict-operations such as setting and deleting expect the key as the
+    argument, not the element ID.
+    """
+
+    _key_id_mapping: dict[str, str] = field(default_factory=dict)
+    _id_key_mapping: dict[str, str] = field(default_factory=dict)
+
+    def __contains__(self, key: str) -> bool:
+        return key in self._key_id_mapping
+
+    def __setitem__(self, key: str, widget_id: Any) -> None:
+        self._key_id_mapping[key] = widget_id
+        self._id_key_mapping[widget_id] = key
+
+    def __delitem__(self, key: str) -> None:
+        self.delete(key)
+
+    @property
+    def id_key_mapping(self) -> dict[str, str]:
+        return self._id_key_mapping
+
+    def set_key_id_mapping(self, key_id_mapping: dict[str, str]) -> None:
+        self._key_id_mapping = key_id_mapping
+        self._id_key_mapping = {v: k for k, v in key_id_mapping.items()}
+
+    def get_id_from_key(self, key: str, default: Any = None) -> str:
+        return self._key_id_mapping.get(key, default)
+
+    def get_key_from_id(self, widget_id: str) -> str:
+        return self._id_key_mapping[widget_id]
+
+    def update(self, other: KeyIdMapper) -> None:
+        self._key_id_mapping.update(other._key_id_mapping)
+        self._id_key_mapping.update(other._id_key_mapping)
+
+    def clear(self):
+        self._key_id_mapping.clear()
+        self._id_key_mapping.clear()
+
+    def delete(self, key: str):
+        widget_id = self._key_id_mapping[key]
+        del self._key_id_mapping[key]
+        del self._id_key_mapping[widget_id]
+
+
 @dataclass
 class SessionState:
     """SessionState allows users to store values that persist between app
@@ -308,10 +358,11 @@ class SessionState:
     # Widget values from the frontend, usually one changing prompted the script rerun
     _new_widget_state: WStates = field(default_factory=WStates)

-    # Keys used for widgets will be eagerly converted to the matching widget id
-    _key_id_mapping: dict[str, str] = field(default_factory=dict)
+    # Keys used for widgets will be eagerly converted to the matching element id
+    _key_id_mapper: KeyIdMapper = field(default_factory=KeyIdMapper)

-    # query params are stored in session state because query params will be tied with widget state at one point.
+    # query params are stored in session state because query params will be tied with
+    # widget state at one point.
     query_params: QueryParams = field(default_factory=QueryParams)

     def __repr__(self):
@@ -338,13 +389,13 @@ def clear(self) -> None:
         self._old_state.clear()
         self._new_session_state.clear()
         self._new_widget_state.clear()
-        self._key_id_mapping.clear()
+        self._key_id_mapper.clear()

     @property
     def filtered_state(self) -> dict[str, Any]:
         """The combined session and widget state, excluding keyless widgets."""

-        wid_key_map = self._reverse_key_wid_map
+        wid_key_map = self._key_id_mapper.id_key_mapping

         state: dict[str, Any] = {}

@@ -366,12 +417,6 @@ def filtered_state(self) -> dict[str, Any]:

         return state

-    @property
-    def _reverse_key_wid_map(self) -> dict[str, str]:
-        """Return a mapping of widget_id : widget_key."""
-        wid_key_map = {v: k for k, v in self._key_id_mapping.items()}
-        return wid_key_map
-
     def _keys(self) -> set[str]:
         """All keys active in Session State, with widget keys converted
         to widget ids when one is known. (This includes autogenerated keys
@@ -400,7 +445,7 @@ def __len__(self) -> int:
         return len(self._keys())

     def __getitem__(self, key: str) -> Any:
-        wid_key_map = self._reverse_key_wid_map
+        wid_key_map = self._key_id_mapper.id_key_mapping
         widget_id = self._get_widget_id(key)

         if widget_id in wid_key_map and widget_id == key:
@@ -463,7 +508,7 @@ def __setitem__(self, user_key: str, value: Any) -> None:
         ctx = get_script_run_ctx()

         if ctx is not None:
-            widget_id = self._key_id_mapping.get(user_key, None)
+            widget_id = self._key_id_mapper.get_id_from_key(user_key, None)
             widget_ids = ctx.widget_ids_this_run
             form_ids = ctx.form_ids_this_run

@@ -487,8 +532,8 @@ def __delitem__(self, key: str) -> None:
         if key in self._old_state:
             del self._old_state[key]

-        if key in self._key_id_mapping:
-            del self._key_id_mapping[key]
+        if key in self._key_id_mapper:
+            self._key_id_mapper.delete(key)

         if widget_id in self._new_widget_state:
             del self._new_widget_state[widget_id]
@@ -608,10 +653,10 @@ def _get_widget_id(self, k: str) -> str:
         """Turns a value that might be a widget id or a user provided key into
         an appropriate widget id.
         """
-        return self._key_id_mapping.get(k, k)
+        return self._key_id_mapper.get_id_from_key(k, k)

     def _set_key_widget_mapping(self, widget_id: str, user_key: str) -> None:
-        self._key_id_mapping[user_key] = widget_id
+        self._key_id_mapper[user_key] = widget_id

     def register_widget(
         self, metadata: WidgetMetadata[T], user_key: str | None

diff --git a/lib/tests/streamlit/runtime/state/session_state_test.py b/lib/tests/streamlit/runtime/state/session_state_test.py
index a9408a209a9b..6eac40a58a64 100644
--- a/lib/tests/streamlit/runtime/state/session_state_test.py
+++ b/lib/tests/streamlit/runtime/state/session_state_test.py
@@ -38,6 +38,7 @@
 from streamlit.runtime.state import SessionState, get_session_state
 from streamlit.runtime.state.common import GENERATED_ELEMENT_ID_PREFIX
 from streamlit.runtime.state.session_state import (
+    KeyIdMapper,
     Serialized,
     Value,
     WidgetMetadata,
@@ -656,7 +657,9 @@ def test_setitem_disallows_setting_created_widget(self):
             return_value=mock_ctx,
         ):
             with pytest.raises(StreamlitAPIException) as e:
-                self.session_state._key_id_mapping = {"widget_id": "widget_id"}
+                self.session_state._key_id_mapper.set_key_id_mapping(
+                    {"widget_id": "widget_id"}
+                )
                 self.session_state["widget_id"] = "blah"
             assert "`st.session_state.widget_id` cannot be modified" in str(e.value)

@@ -800,8 +803,8 @@ def test_map_set_del(m, key, value1):

 @given(state=stst.session_state())
-def test_key_wid_lookup_equiv(state):
-    k_wid_map = state._key_id_mapping
+def test_key_wid_lookup_equiv(state: SessionState):
+    k_wid_map = state._key_id_mapper._key_id_mapping
     for k, wid in k_wid_map.items():
         assert state[k] == state[wid]

@@ -885,13 +888,17 @@ def test_session_state_stats(self):
         stat = state.get_stats()[0]
         assert stat.category_name == "st_session_state"

+        # The expected size of the session state in bytes.
+        # It composes of the session_state's fields.
+        expected_session_state_size_bytes = 3000
+
         init_size = stat.byte_length
-        assert init_size < 2500
+        assert init_size < expected_session_state_size_bytes

         state["foo"] = 2
         new_size = state.get_stats()[0].byte_length
         assert new_size > init_size
-        assert new_size < 2500
+        assert new_size < expected_session_state_size_bytes

         state["foo"] = 1
         new_size_2 = state.get_stats()[0].byte_length
@@ -900,8 +907,74 @@ def test_session_state_stats(self):
         st.checkbox("checkbox", key="checkbox")
         new_size_3 = state.get_stats()[0].byte_length
         assert new_size_3 > new_size_2
-        assert new_size_3 - new_size_2 < 2500
+        assert new_size_3 - new_size_2 < expected_session_state_size_bytes

         state._compact_state()
         new_size_4 = state.get_stats()[0].byte_length
         assert new_size_4 <= new_size_3
+
+
+class KeyIdMapperTest(unittest.TestCase):
+    def test_key_id_mapping(self):
+        key_id_mapper = KeyIdMapper()
+        key_id_mapper.set_key_id_mapping({"key": "wid"})
+        assert key_id_mapper.get_id_from_key("key") == "wid"
+        assert key_id_mapper.get_key_from_id("wid") == "key"
+
+    def test_key_id_mapping_errors(self):
+        key_id_mapper = KeyIdMapper()
+        key_id_mapper.set_key_id_mapping({"key": "wid"})
+        assert key_id_mapper.get_id_from_key("nonexistent") is None
+        with pytest.raises(KeyError):
+            key_id_mapper.get_key_from_id("nonexistent")
+
+    def test_key_id_mapping_clear(self):
+        key_id_mapper = KeyIdMapper()
+        key_id_mapper.set_key_id_mapping({"key": "wid"})
+        assert key_id_mapper.get_id_from_key("key") == "wid"
+        assert key_id_mapper.get_key_from_id("wid") == "key"
+        key_id_mapper.clear()
+        assert key_id_mapper.get_id_from_key("key") is None
+        with pytest.raises(KeyError):
+            key_id_mapper.get_key_from_id("wid")
+
+    def test_key_id_mapping_delete(self):
+        key_id_mapper = KeyIdMapper()
+        key_id_mapper.set_key_id_mapping({"key": "wid"})
+        assert key_id_mapper.get_id_from_key("key") == "wid"
+        assert key_id_mapper.get_key_from_id("wid") == "key"
+        del key_id_mapper["key"]
+        assert key_id_mapper.get_id_from_key("key") is None
+        with pytest.raises(KeyError):
+            key_id_mapper.get_key_from_id("wid")
+
+    def test_key_id_mapping_set_key_id_mapping(self):
+        key_id_mapper = KeyIdMapper()
+        key_id_mapper.set_key_id_mapping({"key": "wid"})
+        key_id_mapper["key2"] = "wid2"
+        assert key_id_mapper.get_id_from_key("key") == "wid"
+        assert key_id_mapper.get_key_from_id("wid") == "key"
+        assert key_id_mapper.get_id_from_key("key2") == "wid2"
+        assert key_id_mapper.get_key_from_id("wid2") == "key2"
+
+    def test_key_id_mapping_update(self):
+        key_id_mapper = KeyIdMapper()
+        key_id_mapper.set_key_id_mapping({"key": "wid"})
+        assert key_id_mapper.get_id_from_key("key") == "wid"
+        assert key_id_mapper.get_key_from_id("wid") == "key"
+
+        key_id_mapper2 = KeyIdMapper()
+        key_id_mapper2.set_key_id_mapping({"key2": "wid2"})
+        key_id_mapper.update(key_id_mapper2)
+        assert key_id_mapper.get_id_from_key("key2") == "wid2"
+        assert key_id_mapper.get_key_from_id("wid2") == "key2"
+
+        key_id_mapper3 = KeyIdMapper()
+        key_id_mapper3.set_key_id_mapping({"key": "wid3"})
+        key_id_mapper.update(key_id_mapper3)
+        assert key_id_mapper.get_id_from_key("key") == "wid3"
+        assert key_id_mapper.get_key_from_id("wid3") == "key"
+        assert key_id_mapper.get_id_from_key("key2") == "wid2"
+        assert key_id_mapper.get_key_from_id("wid2") == "key2"
+        assert key_id_mapper.get_id_from_key("key") == "wid3"
+        assert key_id_mapper.get_key_from_id("wid") == "key"
diff --git a/lib/tests/streamlit/runtime/state/strategies.py b/lib/tests/streamlit/runtime/state/strategies.py
index 42c2fc3c20d0..2a4702aa70f4 100644
--- a/lib/tests/streamlit/runtime/state/strategies.py
+++ b/lib/tests/streamlit/runtime/state/strategies.py
@@ -82,7 +82,7 @@ def _merge_states(a: SessionState, b: SessionState) -> None:
     a._new_session_state.update(b._new_session_state)
     a._new_widget_state.update(b._new_widget_state)
     a._old_state.update(b._old_state)
-    a._key_id_mapping.update(b._key_id_mapping)
+    a._key_id_mapper.update(b._key_id_mapper)

 # TODO: don't generate states where there is a k-wid mapping where the key exists but the widget doesn't

Exponential slowdown in Streamlit as the number of widgets increases (starting in v1.34)
### Checklist

- [X] I have searched the [existing issues](https://github.com/streamlit/streamlit/issues) for similar issues.
- [X] I added a very descriptive title to this issue.
- [X] I have provided sufficient information below to help reproduce this issue.

### Summary

There is an exponential slowdown in Streamlit as the number of input widgets is increases. This started happening with the 1.34 release and is tied to PR #8529. Calling [`get_session_state()`](https://github.com/streamlit/streamlit/blob/develop/lib/streamlit/elements/widgets/number_input.py#L390) in the widgets is triggering this [dict comp method](https://github.com/streamlit/streamlit/blob/develop/lib/streamlit/runtime/state/session_state.py#L370) exponentially more times than previously. For a simple app containing 200 number inputs, there is a 50x increase in calls to `_reverse_key_wid_map` which causes the app to take over 1s to re-load locally (vs ~0.2s with release 1.33). Here is a plot of the number of calls vs the number of inputs:

![image](https://github.com/user-attachments/assets/4b0ef9c9-49c4-482b-85d9-9684e41f523d)

### Reproducible Code Example

```Python
import streamlit as st
import time
import random

NUM_TABS = 10
NUM_INPUTS = 20

def create_inputs(t):
    for i in range(NUM_INPUTS):
        key = f"tab_{t + 1}_input_{i + 1}_value"
        if key in st.session_state:
            value = st.session_state[key]
        else:
            value = random.randint(0, 100000)
        st.number_input(
            label=f"Number Input {i + 1}",
            value=value,
            min_value=0,
            step=1,
            format="%d",
            key=key,
        )

def main():
    st.title("My dummy app")
    with st.container(border=True, height=500):
        tabs = st.tabs([f"Tab {i + 1}" for i in range(NUM_TABS)])
        for t, tab in enumerate(tabs):
            with tab:
                create_inputs(t)

if __name__ == "__main__":
    import cProfile
    from pstats import Stats

    pr = cProfile.Profile()
    pr.enable()

    start_time = time.time()
    main()
    st.info(f"run time: {time.time() - start_time:.3f} seconds")

    pr.disable()
    stats = Stats(pr)
    stats.sort_stats("tottime").print_stats(10)
```

### Steps To Reproduce

1. Run the app using Streamlit version 1.33
2. Adjust `NUM_TABS` value from 1 to 10 and monitor the `cProfile` output
3. Switch to Streamlit version 1.34 or above
4. Repeat step 2

here is a screenshot of the cProfile output with Streamlit 1.33 using 200 widgets:
![image](https://github.com/user-attachments/assets/2c39e939-5cc9-407a-99f1-e1c9de0257b2)

and here's a screenshot using Streamlit 1.34:
![image](https://github.com/user-attachments/assets/3da40e2d-025c-4752-9a96-6b10b233a0bd)

### Expected Behavior

_No response_

### Current Behavior

_No response_

### Is this a regression?

- [x] Yes, this used to work in a previous version.

### Debug info

- Streamlit version: 1.33 vs 1.34+
- Python version: 3.11
- Operating System: Windows 10
- Browser: Chrome

### Additional Information

_No response_

**If this issue affects you, please react with a 👍 (thumbs up emoji) to the initial post.**

Your feedback helps us prioritize which bugs to investigate and address first.

![Visits](https://hits.seeyoufarm.com/api/count/incr/badge.svg?url=https%3A%2F%2Fgithub.com%2Fstreamlit%2Fstreamlit%2Fissues%2F9415&title=visits&edge_flat=false)

Thanks a lot for the thorough analysis @ianlcassidy! 🚀
I am able to reproduce this increased run time duration for the provided app; when commenting out the lines you are pointing to [here](https://github.com/streamlit/streamlit/blob/develop/lib/streamlit/elements/widgets/number_input.py#L390-L392), the app runs faster for me again. So your analysis seems to be spot on for me. For me, the app run duration is ~300ms with the current code and ~50-110ms without the code lines.
In order to make this faster again but also prevail the behavior for which the lines were added, I believe we would need to maintain both maps - the current `key->id` and the `id->key` one - at the same time instead of computing the latter on the fly every time; basically trading compute time vs. memory footprint. It looks like the first map (`_key_id_mapping`) is not updated in too many places, so I hope this would be a quick endeavour. If we would go down this route, I guess the best way would be to introduce a wrapper dict class that updates both maps (insert, delete, ...) at the same time to avoid sync-issues.
When I print out the size of the map via

```python
import sys
from streamlit.runtime.scriptrunner_utils.script_run_context import (
    get_script_run_ctx,
)

ctx = get_script_run_ctx()
print(f"Session state map size: {sys.getsizeof(ctx.session_state.filtered_state)}")
```

at the end of the script, it says `~9kb`. So we would add around 9kb of additional memory usage (by adding the map in the other direction), which I believe would be a worthy trade if we half the app execution duration.

---

@jrieke @lukasmasuch I believe this is another instance where using a `key` as the widget id would help, because looking at the code it seems like we have this (reverse) mapping logic only because a user-provided `key` is not the widget id.
I don't know if it helps here, but the ID is guaranteed to contain the raw user-specified key. It can be extracted via the `user_key_from_element_id`:

https://github.com/streamlit/streamlit/blob/10028f83114f71e8d826370d34abcc92aa847224/lib/streamlit/runtime/state/common.py#L215


diff --git a/lib/streamlit/runtime/app_session.py b/lib/streamlit/runtime/app_session.py
index dc9a13c17511..f68b216929db 100644
--- a/lib/streamlit/runtime/app_session.py
+++ b/lib/streamlit/runtime/app_session.py
@@ -144,7 +144,8 @@ def __init__(
         self._stop_config_listener: Callable[[], bool] | None = None
         self._stop_pages_listener: Callable[[], None] | None = None

-        self.register_file_watchers()
+        if config.get_option("server.fileWatcherType") != "none":
+            self.register_file_watchers()

         self._run_on_save = config.get_option("server.runOnSave")

diff --git a/lib/tests/streamlit/runtime/app_session_test.py b/lib/tests/streamlit/runtime/app_session_test.py
index ba999fc4d967..a94a00d68468 100644
--- a/lib/tests/streamlit/runtime/app_session_test.py
+++ b/lib/tests/streamlit/runtime/app_session_test.py
@@ -125,13 +125,13 @@ def test_shutdown(self, patched_disconnect):
         session._uploaded_file_mgr = mock_file_mgr

         session.shutdown()
-        self.assertEqual(AppSessionState.SHUTDOWN_REQUESTED, session._state)
+        assert AppSessionState.SHUTDOWN_REQUESTED == session._state
         mock_file_mgr.remove_session_files.assert_called_once_with(session.id)
         patched_disconnect.assert_called_once_with(session._on_secrets_file_changed)

         # A 2nd shutdown call should have no effect.
         session.shutdown()
-        self.assertEqual(AppSessionState.SHUTDOWN_REQUESTED, session._state)
+        assert AppSessionState.SHUTDOWN_REQUESTED == session._state

         mock_file_mgr.remove_session_files.assert_called_once_with(session.id)

@@ -173,11 +173,11 @@ def test_unique_id(self):
         """Each AppSession should have a unique ID"""
         session1 = _create_test_session()
         session2 = _create_test_session()
-        self.assertNotEqual(session1.id, session2.id)
+        assert session1.id != session2.id

     def test_creates_session_state_on_init(self):
         session = _create_test_session()
-        self.assertIsInstance(session.session_state, SessionState)
+        assert isinstance(session.session_state, SessionState)

     def test_creates_fragment_storage_on_init(self):
         session = _create_test_session()
@@ -185,13 +185,13 @@ def test_creates_fragment_storage_on_init(self):
         # isinstance (there's no need to as the static type checker already ensures
         # the field has the correct type), and we don't want to mark
         # MemoryFragmentStorage as @runtime_checkable.
-        self.assertIsNotNone(session._fragment_storage)
+        assert session._fragment_storage is not None

     def test_clear_cache_resets_session_state(self):
         session = _create_test_session()
         session._session_state["foo"] = "bar"
         session._handle_clear_cache_request()
-        self.assertTrue("foo" not in session._session_state)
+        assert "foo" not in session._session_state

     @patch("streamlit.runtime.legacy_caching.clear_cache")
     @patch("streamlit.runtime.caching.cache_data.clear")
@@ -302,7 +302,7 @@ def test_rerun_fragment_requests_existing_scriptrunner(
     def test_create_scriptrunner(self, mock_scriptrunner: MagicMock):
         """Test that _create_scriptrunner does what it should."""
         session = _create_test_session()
-        self.assertIsNone(session._scriptrunner)
+        assert session._scriptrunner is None

         session._create_scriptrunner(initial_rerun_data=RerunData())

@@ -318,7 +318,7 @@ def test_create_scriptrunner(self, mock_scriptrunner: MagicMock):
             fragment_storage=session._fragment_storage,
         )

-        self.assertIsNotNone(session._scriptrunner)
+        assert session._scriptrunner is not None

         # And that the ScriptRunner was initialized and started.
         scriptrunner: MagicMock = cast(MagicMock, session._scriptrunner)
@@ -376,7 +376,7 @@ def test_resets_debug_last_backmsg_id_on_script_finished(self):
                 forward_msg=ForwardMsg(),
             )

-            self.assertIsNone(session._debug_last_backmsg_id)
+            assert session._debug_last_backmsg_id is None

     @patch("streamlit.runtime.app_session.ScriptRunner", MagicMock(spec=ScriptRunner))
     @patch("streamlit.runtime.app_session.AppSession._enqueue_forward_msg", MagicMock())
@@ -395,7 +395,7 @@ def test_sets_state_to_not_running_on_rerun_event(self):
                 forward_msg=ForwardMsg(),
             )

-            self.assertEqual(session._state, AppSessionState.APP_NOT_RUNNING)
+            assert session._state == AppSessionState.APP_NOT_RUNNING

     def test_passes_client_state_on_run_on_save(self):
         session = _create_test_session()
@@ -419,7 +419,7 @@ def test_does_not_rerun_if_not_current_page(self):
         # Clearing the cache should still have been called
         session._script_cache.clear.assert_called_once()

-        self.assertEqual(session.request_rerun.called, False)
+        assert not session.request_rerun.called

     @patch(
         "streamlit.runtime.app_session.source_util.get_pages",
@@ -468,7 +468,7 @@ def test_tags_fwd_msgs_with_last_backmsg_id_if_set(self):
         msg = ForwardMsg()
         session._enqueue_forward_msg(msg)

-        self.assertEqual(msg.debug_last_backmsg_id, "some backmsg id")
+        assert msg.debug_last_backmsg_id == "some backmsg id"

     @patch("streamlit.runtime.app_session.config.on_config_parsed")
     @patch("streamlit.runtime.app_session.source_util.register_pages_changed_callback")
@@ -501,7 +501,12 @@ def test_recreates_local_sources_watcher_if_none(self):
         session._local_sources_watcher = None

         session.register_file_watchers()
-        self.assertIsNotNone(session._local_sources_watcher)
+        assert session._local_sources_watcher
+
+    @patch_config_options({"server.fileWatcherType": "none"})
+    def test_no_local_sources_watcher_if_file_watching_disabled(self):
+        session = _create_test_session()
+        assert not session._local_sources_watcher

     @patch(
         "streamlit.runtime.app_session.secrets_singleton.file_change_listener.disconnect"
@@ -525,9 +530,9 @@ def test_disconnect_file_watchers(self, patched_secrets_disconnect):
                 session._on_secrets_file_changed
             )

-            self.assertIsNone(session._local_sources_watcher)
-            self.assertIsNone(session._stop_config_listener)
-            self.assertIsNone(session._stop_pages_listener)
+            assert session._local_sources_watcher is None
+            assert session._stop_config_listener is None
+            assert session._stop_pages_listener is None

     def test_disconnect_file_watchers_removes_refs(self):
         """Test that calling disconnect_file_watchers on the AppSession
@@ -538,7 +543,7 @@ def test_disconnect_file_watchers_removes_refs(self):

         # Various listeners should have references to session file/pages/secrets changed
         # handlers.
-        self.assertGreater(len(gc.get_referrers(session)), 0)
+        assert len(gc.get_referrers(session)) > 0

         session.disconnect_file_watchers()

@@ -552,7 +557,7 @@ def test_disconnect_file_watchers_removes_refs(self):
         gc.collect(2)
         gc.collect(2)

-        self.assertEqual(len(gc.get_referrers(session)), 0)
+        assert len(gc.get_referrers(session)) == 0

     @patch("streamlit.runtime.app_session.AppSession._enqueue_forward_msg")
     def test_handle_file_urls_request(self, mock_enqueue):
@@ -683,34 +688,31 @@ async def test_enqueue_new_session_message(self):
         await asyncio.sleep(0)

         sent_messages = session._browser_queue._queue
-        self.assertEqual(2, len(sent_messages))  # NewApp and SessionState messages
+        assert len(sent_messages) == 2  # NewApp and SessionState messages
         session._clear_queue.assert_called_once()

         # Note that we're purposefully not very thoroughly testing new_session
         # fields below to avoid getting to the point where we're just
         # duplicating code in tests.
         new_session_msg = sent_messages[0].new_session
-        self.assertEqual("mock_scriptrun_id", new_session_msg.script_run_id)
+        assert new_session_msg.script_run_id == "mock_scriptrun_id"

-        self.assertTrue(new_session_msg.HasField("config"))
-        self.assertEqual(
-            config.get_option("server.allowRunOnSave"),
-            new_session_msg.config.allow_run_on_save,
+        assert new_session_msg.HasField("config")
+        assert (
+            config.get_option("server.allowRunOnSave")
+            == new_session_msg.config.allow_run_on_save
         )

-        self.assertTrue(new_session_msg.HasField("custom_theme"))
-        self.assertEqual("black", new_session_msg.custom_theme.text_color)
+        assert new_session_msg.HasField("custom_theme")
+        assert new_session_msg.custom_theme.text_color == "black"

         init_msg = new_session_msg.initialize
-        self.assertTrue(init_msg.HasField("user_info"))
+        assert init_msg.HasField("user_info")

-        self.assertEqual(
-            list(new_session_msg.app_pages),
-            [
-                AppPage(page_script_hash="hash1", page_name="page1", icon=""),
-                AppPage(page_script_hash="hash2", page_name="page2", icon="🎉"),
-            ],
-        )
+        assert list(new_session_msg.app_pages) == [
+            AppPage(page_script_hash="hash1", page_name="page1", icon=""),
+            AppPage(page_script_hash="hash2", page_name="page2", icon="🎉"),
+        ]

         add_script_run_ctx(ctx=orig_ctx)

@@ -751,11 +753,11 @@ async def test_new_session_message_includes_fragment_ids(self):
         await asyncio.sleep(0)

         sent_messages = session._browser_queue._queue
-        self.assertEqual(2, len(sent_messages))  # NewApp and SessionState messages
+        assert len(sent_messages) == 2  # NewApp and SessionState messages
         session._clear_queue.assert_not_called()

         new_session_msg = sent_messages[0].new_session
-        self.assertEqual(new_session_msg.fragment_ids_this_run, ["my_fragment_id"])
+        assert new_session_msg.fragment_ids_this_run == ["my_fragment_id"]

         add_script_run_ctx(ctx=orig_ctx)

@@ -799,7 +801,7 @@ async def test_event_handler_asserts_if_called_off_event_loop(self):
             "streamlit.runtime.app_session.asyncio.get_running_loop",
             return_value=MagicMock(),
         ):
-            with self.assertRaises(AssertionError):
+            with pytest.raises(AssertionError):
                 session._handle_scriptrunner_event_on_event_loop(
                     sender=MagicMock(), event=ScriptRunnerEvent.SCRIPT_STARTED
                 )
@@ -842,7 +844,7 @@ async def test_handle_backmsg_exception(self):

         # Messages get sent in an eventloop callback, which hasn't had a chance
         # to run yet. Our message queue should be empty.
-        self.assertEqual([], forward_msg_queue_events)
+        assert forward_msg_queue_events == []

         # Run callbacks
         await asyncio.sleep(0)
@@ -874,8 +876,7 @@ async def test_handle_backmsg_exception(self):
                 ]
             )

-        # Assert the results!
-        self.assertEqual(expected_events, forward_msg_queue_events)
+        assert expected_events == forward_msg_queue_events

     async def test_handle_backmsg_handles_exceptions(self):
         """Exceptions raised in handle_backmsg should be sent to
@@ -904,7 +905,7 @@ async def test_handle_backmsg_handles_debug_ids(self):
             rerun_script=session._client_state, debug_last_backmsg_id="some backmsg"
         )
         session.handle_backmsg(msg)
-        self.assertEqual(session._debug_last_backmsg_id, "some backmsg")
+        assert session._debug_last_backmsg_id == "some backmsg"

     @patch("streamlit.runtime.app_session._LOGGER")
     async def test_handles_app_heartbeat_backmsg(self, patched_logger):
@@ -943,7 +944,7 @@ def test_no_custom_theme_prop_if_no_theme(self, patched_config):
         new_session_msg = msg.new_session
         app_session._populate_theme_msg(new_session_msg.custom_theme)

-        self.assertEqual(new_session_msg.HasField("custom_theme"), False)
+        assert not new_session_msg.HasField("custom_theme")

     @patch("streamlit.runtime.app_session.config")
     def test_can_specify_some_options(self, patched_config):
@@ -960,11 +961,11 @@ def test_can_specify_some_options(self, patched_config):
         new_session_msg = msg.new_session
         app_session._populate_theme_msg(new_session_msg.custom_theme)

-        self.assertEqual(new_session_msg.HasField("custom_theme"), True)
-        self.assertEqual(new_session_msg.custom_theme.primary_color, "coral")
+        assert new_session_msg.HasField("custom_theme")
+        assert new_session_msg.custom_theme.primary_color == "coral"
         # In proto3, primitive fields are technically always required and are
         # set to the type's zero value when undefined.
-        self.assertEqual(new_session_msg.custom_theme.background_color, "")
+        assert new_session_msg.custom_theme.background_color == ""

     @patch("streamlit.runtime.app_session.config")
     def test_can_specify_all_options(self, patched_config):
@@ -977,9 +978,9 @@ def test_can_specify_all_options(self, patched_config):
         new_session_msg = msg.new_session
         app_session._populate_theme_msg(new_session_msg.custom_theme)

-        self.assertEqual(new_session_msg.HasField("custom_theme"), True)
-        self.assertEqual(new_session_msg.custom_theme.primary_color, "coral")
-        self.assertEqual(new_session_msg.custom_theme.background_color, "white")
+        assert new_session_msg.HasField("custom_theme")
+        assert new_session_msg.custom_theme.primary_color == "coral"
+        assert new_session_msg.custom_theme.background_color == "white"

     @patch("streamlit.runtime.app_session._LOGGER")
     @patch("streamlit.runtime.app_session.config")
@@ -1028,18 +1029,16 @@ def test_returns_true_if_current_page_changed(self):
         session = _create_test_session()
         session._client_state.page_script_hash = "hash2"

-        self.assertEqual(session._should_rerun_on_file_change("page2.py"), True)
+        assert session._should_rerun_on_file_change("page2.py")

     def test_returns_true_if_changed_file_is_not_page(self):
         session = _create_test_session()
         session._client_state.page_script_hash = "hash1"

-        self.assertEqual(
-            session._should_rerun_on_file_change("some_other_file.py"), True
-        )
+        assert session._should_rerun_on_file_change("some_other_file.py")

     def test_returns_false_if_different_page_changed(self):
         session = _create_test_session()
         session._client_state.page_script_hash = "hash2"

-        self.assertEqual(session._should_rerun_on_file_change("page1.py"), False)
+        assert not session._should_rerun_on_file_change("page1.py")

Streamlit unnecessarily runs the file watcher against every path in imported modules, even when watching is disabled
### Checklist

- [X] I have searched the [existing issues](https://github.com/streamlit/streamlit/issues) for similar issues.
- [X] I added a very descriptive title to this issue.
- [X] I have provided sufficient information below to help reproduce this issue.

### Summary

We are running an application that has a bunch of module dependencies.
Recording some profiles, we often see stack traces like this

![image](https://github.com/streamlit/streamlit/assets/84412986/108c2645-f57f-4d0c-ac48-8c5e0729a6eb)

https://github.com/streamlit/streamlit/blob/ad3dc4e19d261fc66560a25bab92a7a560068338/lib/streamlit/runtime/app_session.py#L604

This happens despite the file watcher type being set to `none`, because the local watcher does not gate on that setting. It is only when the per-file watch request addition happens that the setting is checked and it determines that no watch is required. This is incredibly wasteful as it incurs a `isfile` check (along with an fnmatch) for ~40000 files in our case. If I understand the code correctly, this happens at the end of every successful page load.

This seems like it could easily be avoided if the app session also consulted the setting.

Does this make sense and would you accept the relevant fix?

### Reproducible Code Example

_No response_

### Steps To Reproduce

_No response_

### Expected Behavior

Don't iterate over a bunch of files when file watching is disabled.

### Current Behavior

Lot of compute and disk I/O wasted on "watching" files.

### Is this a regression?

- [ ] Yes, this used to work in a previous version.

### Debug info

- Streamlit version: We are running 1.33, but the same code exists in the latest code
- Python version: 3.8
- Operating System: Linux
- Browser: Any

### Additional Information

_No response_

**If this issue affects you, please react with a 👍 (thumbs up emoji) to the initial post.**

Your feedback helps us prioritize which bugs to investigate and address first.

![Visits](https://hits.seeyoufarm.com/api/count/incr/badge.svg?url=https%3A%2F%2Fgithub.com%2Fstreamlit%2Fstreamlit%2Fissues%2F8738&title=visits&edge_flat=false)

Hi @nikhil-marathe-skydio,

Whoops, this definitely seems like an oversight on our end. I think the ideal thing to do here would be to avoid instantiating a `LocalSourcesWatcher` entirely if file watching is disabled. I'll go ahead and create a PR to fix this myself given it should just be a one line change.


diff --git a/mesonbuild/coredata.py b/mesonbuild/coredata.py
index 98656b27d9b0..c3e435271952 100644
--- a/mesonbuild/coredata.py
+++ b/mesonbuild/coredata.py
@@ -658,9 +658,20 @@ def set_options(self, opts_to_set: T.Dict[OptionKey, T.Any], subproject: str = '
             elif k.machine != MachineChoice.BUILD and not self.optstore.is_compiler_option(k):
                 unknown_options.append(k)
         if unknown_options:
-            unknown_options_str = ', '.join(sorted(str(s) for s in unknown_options))
-            sub = f'In subproject {subproject}: ' if subproject else ''
-            raise MesonException(f'{sub}Unknown options: "{unknown_options_str}"')
+            if subproject:
+                # The subproject may have top-level options that should be used
+                # when it is not a subproject. Ignore those for now. With option
+                # refactor they will get per-subproject values.
+                really_unknown = []
+                for uo in unknown_options:
+                    topkey = uo.evolve(subproject='')
+                    if topkey not in self.optstore:
+                        really_unknown.append(uo)
+                unknown_options = really_unknown
+            if unknown_options:
+                unknown_options_str = ', '.join(sorted(str(s) for s in unknown_options))
+                sub = f'In subproject {subproject}: ' if subproject else ''
+                raise MesonException(f'{sub}Unknown options: "{unknown_options_str}"')

         if not self.is_cross_build():
             dirty |= self.copy_build_options_from_regular_ones()

diff --git a/test cases/unit/123 pkgsubproj/meson.build b/test cases/unit/123 pkgsubproj/meson.build
new file mode 100644
index 000000000000..b4cf89fa0b6d
--- /dev/null
+++ b/test cases/unit/123 pkgsubproj/meson.build
@@ -0,0 +1,3 @@
+project('pkg_opt_test')
+
+subproject('sub')
diff --git a/test cases/unit/123 pkgsubproj/subprojects/sub/meson.build b/test cases/unit/123 pkgsubproj/subprojects/sub/meson.build
new file mode 100644
index 000000000000..99622b681cdd
--- /dev/null
+++ b/test cases/unit/123 pkgsubproj/subprojects/sub/meson.build
@@ -0,0 +1,1 @@
+project('subproject', default_options: 'pkgconfig.relocatable=true')
diff --git a/unittests/linuxliketests.py b/unittests/linuxliketests.py
index 1e9e38d1b323..c8d5a538df17 100644
--- a/unittests/linuxliketests.py
+++ b/unittests/linuxliketests.py
@@ -1863,3 +1863,7 @@ def test_complex_link_cases(self):
         self.assertIn('build t9-e1: c_LINKER t9-e1.p/main.c.o | libt9-s1.a libt9-s2.a libt9-s3.a\n', content)
         self.assertIn('build t12-e1: c_LINKER t12-e1.p/main.c.o | libt12-s1.a libt12-s2.a libt12-s3.a\n', content)
         self.assertIn('build t13-e1: c_LINKER t13-e1.p/main.c.o | libt12-s1.a libt13-s3.a\n', content)
+
+    def test_top_options_in_sp(self):
+        testdir = os.path.join(self.unit_test_dir, '123 pkgsubproj')
+        self.init(testdir)

pkgconfig.relocatable in subproject default_options fails in parent project setup
**Describe the bug**
When setting up a parent project that includes a subproject with `pkgconfig.relocatable=true` in its `default_options`, meson setup fails in the parent project with the error:
```
subprojects/mysubprj/meson.build:1:0: ERROR: In subproject mysubprj: Unknown options: "mysubprj:pkgconfig.relocatable"
```

However, running meson setup directly within the subproject works without issue.
This change in behavior started with the following commit:
```
commit 6e200222957063819a00e3bf767ce28b7489c31f
Author: Jussi Pakkanen <jpakkane@gmail.com>
Date:   Sun Jul 14 19:33:41 2024 +0300

    Remove module type from OptionKey.
```

**To Reproduce**
In the parent project `meson.build`:
```
project('test', version : '1.0')
subproject('mysubprj')
```

In the subproject `meson.build`:
```
project('mysubprj', version : '1.0', default_options: ['pkgconfig.relocatable=true'])
```

Command-line steps:
```sh
cd test
meson setup build
# Error as shown above
cd test/subprojects/mysubprj
meson setup build
# Success
```

**Expected behavior**
The meson setup command in the parent project should successfully handle `pkgconfig.relocatable=true` in subproject `default_options` as it did in previous versions.

**system parameters**
* Is this a [cross build](https://mesonbuild.com/Cross-compilation.html) or just a plain native build (for the same computer)?
native
* what operating system (e.g. MacOS Catalina, Windows 10, CentOS 8.0, Ubuntu 18.04, etc.)
Ubuntu 24.04
* what Python version are you using e.g. 3.8.0
3.12.3
* what `meson --version`
1.6.0

Can confirm when using [librsvg](https://gitlab.gnome.org/gnome/librsvg) as a wrap from within the GStreamer repository.