Source code for brainstate.transform._progress_bar

# Copyright 2024 BrainX Ecosystem Limited. All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ==============================================================================


import copy
import importlib.util
import shutil
import sys
import time
from typing import Optional, Callable, Any, Tuple, Dict

import jax

tqdm_installed = importlib.util.find_spec('tqdm') is not None

__all__ = [
    'ProgressBar',
]

Index = int
Carray = Any
Output = Any


class _FallbackProgressBar:
    """
    A minimal, dependency-free progress bar used when :mod:`tqdm` is unavailable.

    This class reproduces the small slice of the :mod:`tqdm` API that
    :class:`ProgressBarRunner` relies on (construction from an iterable,
    :meth:`set_description`, :meth:`update`, and :meth:`close`), so it can be
    used as a drop-in replacement. It renders a single-line, tqdm-like progress
    display containing a description, percentage, a Unicode bar, the
    iteration count, elapsed time, an ETA, and an iteration rate.

    The renderer is terminal aware. When the output stream is an interactive
    terminal, the line is redrawn in place using a carriage return. When the
    output is redirected (a file, a CI log, or a notebook), each update is
    written on its own line so no carriage-return control characters leak into
    the captured text.

    Parameters
    ----------
    iterable : iterable, optional
        Iterable whose length provides ``total`` when ``total`` is not given.
        Only its length is used; it is not consumed.
    total : int, optional
        Total number of iterations. Falls back to ``len(iterable)`` and finally
        to ``None`` (an unknown-total display) when neither is available.
    desc : str, optional
        Description shown as a prefix (``"desc: "``).
    file : file-like, optional
        Output stream. Defaults to :data:`sys.stderr` (matching :mod:`tqdm`).
    disable : bool, default False
        When ``True`` every method becomes a no-op and nothing is written.
    ncols : int, optional
        Fixed total width in columns. When omitted the terminal width is queried
        via :func:`shutil.get_terminal_size`.
    _time : callable, optional
        Zero-argument clock returning seconds, used for elapsed/rate/ETA. Defaults
        to :func:`time.monotonic`. Exposed mainly to make tests deterministic.
    **ignored
        Any further :mod:`tqdm`-specific keyword arguments (for example
        ``colour``, ``leave``, ``unit``, ``position``). They are accepted and
        ignored so code written for :mod:`tqdm` does not break.

    See Also
    --------
    ProgressBar : The public progress-bar configuration object.

    Notes
    -----
    Only a single bar is supported; nested/positioned bars are not. The
    iteration rate is an exponential moving average (smoothing ``0.3``) of the
    instantaneous rate, matching the feel of :mod:`tqdm`, and falls back to the
    overall average until enough data is available.

    Examples
    --------
    .. code-block:: python

        >>> import io
        >>> from brainstate.transform._progress_bar import _FallbackProgressBar
        >>> buf = io.StringIO()
        >>> bar = _FallbackProgressBar(total=4, desc="Train", file=buf)
        >>> for _ in range(4):
        ...     bar.update(1)
        >>> bar.close()
        >>> "4/4" in buf.getvalue()
        True
    """
    __module__ = "brainstate.transform"

    def __init__(
        self,
        iterable=None,
        *,
        total: Optional[int] = None,
        desc: Optional[str] = None,
        file=None,
        disable: bool = False,
        ncols: Optional[int] = None,
        _time: Optional[Callable[[], float]] = None,
        **ignored,
    ):
        if total is None and iterable is not None:
            try:
                total = len(iterable)
            except TypeError:
                total = None
        self.total = total
        self.n = 0
        self.desc = desc or ''
        self.file = file if file is not None else sys.stderr
        self.disable = bool(disable)
        self.ncols = ncols
        self._time = _time if _time is not None else time.monotonic

        self._start = self._time()
        self._last_time = self._start
        self._last_n = 0
        self._ema_rate: Optional[float] = None
        self._last_line_len = 0
        self._closed = False

        self._is_tty = bool(getattr(self.file, 'isatty', lambda: False)())
        enc = getattr(self.file, 'encoding', None)
        self._ascii = bool(enc) and 'utf' not in enc.lower()

    # -- public tqdm-compatible API --

    def set_description(self, desc=None, refresh: bool = True):
        """Set the description prefix, optionally redrawing immediately."""
        self.desc = desc or ''
        if refresh and not self.disable and not self._closed:
            self._render()

    def update(self, n: int = 1):
        """Advance the counter by ``n`` iterations and redraw."""
        if self.disable or self._closed:
            return
        self.n += n
        self._render()

    def close(self):
        """Render the final state and terminate the line."""
        if self.disable or self._closed:
            return
        self._closed = True
        self._render(final=True)

    # -- internals --

    def _update_rate(self):
        if self.n <= self._last_n:
            return
        now = self._time()
        dt = now - self._last_time
        if dt <= 0:
            return
        inst = (self.n - self._last_n) / dt
        if self._ema_rate is None:
            self._ema_rate = inst
        else:
            self._ema_rate = 0.3 * inst + 0.7 * self._ema_rate
        self._last_time = now
        self._last_n = self.n

    def _current_rate(self) -> Optional[float]:
        if self._ema_rate is not None:
            return self._ema_rate
        elapsed = self._time() - self._start
        if elapsed > 0 and self.n > 0:
            return self.n / elapsed
        return None

    @staticmethod
    def _format_interval(seconds: float) -> str:
        # Guard against NaN / +-inf, then clamp negatives to zero.
        if seconds != seconds or seconds in (float('inf'), float('-inf')):
            return '?'
        total = max(int(seconds), 0)
        h, rem = divmod(total, 3600)
        m, s = divmod(rem, 60)
        if h:
            return f'{h:d}:{m:02d}:{s:02d}'
        return f'{m:02d}:{s:02d}'

    def _make_bar(self, frac: float, reserved: int) -> str:
        if self.ncols is not None:
            cols = self.ncols
        else:
            cols = shutil.get_terminal_size(fallback=(80, 20)).columns
        width = cols - reserved
        if width <= 0:
            return ''
        charset = ' 123456789#' if self._ascii else ' ▏▎▍▌▋▊▉█'
        full_char = charset[-1]
        n_parts = len(charset) - 1
        filled = frac * width
        full = int(filled)
        if full >= width:
            return full_char * width
        bar = full_char * full
        idx = int((filled - full) * n_parts)
        bar += charset[idx]
        bar += ' ' * (width - full - 1)
        return bar

    def _format_line(self) -> str:
        prefix = f'{self.desc}: ' if self.desc else ''
        elapsed = self._format_interval(self._time() - self._start)
        rate = self._current_rate()
        rate_str = f'{rate:.2f}it/s' if rate else '?it/s'
        if self.total:
            frac = min(max(self.n / self.total, 0.0), 1.0)
            pct = int(frac * 100)
            if rate and rate > 0:
                eta = self._format_interval((self.total - self.n) / rate)
            else:
                eta = '?'
            right = f'{self.n}/{self.total} [{elapsed}<{eta}, {rate_str}]'
            left = f'{prefix}{pct:3d}%|'
            bar = self._make_bar(frac, len(left) + len(right) + 2)
            return f'{left}{bar}| {right}'
        return f'{prefix}{self.n}it [{elapsed}, {rate_str}]'

    def _write(self, text: str):
        try:
            self.file.write(text)
        except UnicodeEncodeError:
            enc = getattr(self.file, 'encoding', None) or 'ascii'
            self.file.write(text.encode(enc, errors='replace').decode(enc))
        flush = getattr(self.file, 'flush', None)
        if flush is not None:
            flush()

    def _render(self, final: bool = False):
        if self.disable:
            return
        self._update_rate()
        line = self._format_line()
        if self._is_tty:
            pad = max(self._last_line_len - len(line), 0)
            self._write('\r' + line + ' ' * pad)
            self._last_line_len = len(line)
            if final:
                self._write('\n')
        else:
            self._write(line + '\n')


class ProgressBar(object):
    """
    A progress bar for tracking the progress of a jitted for-loop computation.

    It can be used in :py:func:`for_loop`, :py:func:`checkpointed_for_loop`, :py:func:`scan`,
    and :py:func:`checkpointed_scan` functions. Or any other jitted function that uses
    a for-loop.

    The message displayed in the progress bar can be customized by the following two methods:

    1. By passing a string to the `desc` argument.
    2. By passing a tuple with a string and a callable function to the `desc` argument. The callable
       function should take a dictionary as input and return a dictionary. The returned dictionary
       will be used to format the string.

    In the second case, ``"i"`` denotes the iteration number and other keys can be computed from the
    loop outputs and carry values.

    Parameters
    ----------
    freq : int, optional
        The frequency at which to print the progress bar. If not specified, the progress
        bar will be printed every 5% of the total iterations.
    count : int, optional
        The number of times to print the progress bar. If not specified, the progress
        bar will be printed every 5% of the total iterations. Cannot be used together with `freq`.
    desc : str or tuple, optional
        A description of the progress bar. If not specified, a default message will be
        displayed. Can be either a string or a tuple of (format_string, format_function).
    **kwargs
        Additional keyword arguments to pass to the progress bar.

    Notes
    -----
    ``tqdm`` is an optional dependency. When it is installed, the bar is rendered
    with :mod:`tqdm`. When it is not installed, a built-in pure-Python fallback
    (:class:`_FallbackProgressBar`) renders an equivalent terminal progress bar,
    so :class:`ProgressBar` works in either environment with no code changes.

    Examples
    --------
    Basic usage with default description:

    .. code-block:: python

        >>> import brainstate
        >>> import jax.numpy as jnp
        >>>
        >>> def loop_fn(x):
        ...     return x ** 2
        >>>
        >>> xs = jnp.arange(100)
        >>> pbar = brainstate.transform.ProgressBar()
        >>> results = brainstate.transform.for_loop(loop_fn, xs, pbar=pbar)

    With custom description string:

    .. code-block:: python

        >>> pbar = brainstate.transform.ProgressBar(desc="Running 1000 iterations")
        >>> results = brainstate.transform.for_loop(loop_fn, xs, pbar=pbar)

    With frequency control:

    .. code-block:: python

        >>> # Update every 10 iterations
        >>> pbar = brainstate.transform.ProgressBar(freq=10)
        >>> results = brainstate.transform.for_loop(loop_fn, xs, pbar=pbar)
        >>>
        >>> # Update exactly 20 times during execution
        >>> pbar = brainstate.transform.ProgressBar(count=20)
        >>> results = brainstate.transform.for_loop(loop_fn, xs, pbar=pbar)

    With dynamic description based on loop variables:

    .. code-block:: python

        >>> state = brainstate.State(1.0)
        >>>
        >>> def loop_fn(x):
        ...     state.value += x
        ...     loss = jnp.sum(x ** 2)
        ...     return loss
        >>>
        >>> def format_desc(data):
        ...     return {"i": data["i"], "loss": data["y"], "state": data["carry"]}
        >>>
        >>> pbar = brainstate.transform.ProgressBar(
        ...     desc=("Iteration {i}, loss = {loss:.4f}, state = {state:.2f}", format_desc)
        ... )
        >>> results = brainstate.transform.for_loop(loop_fn, xs, pbar=pbar)

    With scan function:

    .. code-block:: python

        >>> def scan_fn(carry, x):
        ...     new_carry = carry + x
        ...     return new_carry, new_carry ** 2
        >>>
        >>> init_carry = 0.0
        >>> pbar = brainstate.transform.ProgressBar(freq=5)
        >>> final_carry, ys = brainstate.transform.scan(scan_fn, init_carry, xs, pbar=pbar)
    """
    __module__ = "brainstate.transform"

[docs] def __init__( self, freq: Optional[int] = None, count: Optional[int] = None, desc: Optional[Tuple[str, Callable[[Dict], Dict]] | str] = None, **kwargs ): # print rate self.print_freq = freq if isinstance(freq, int) and freq <= 0: raise ValueError(f"Print rate (freq) should be > 0, but got {freq}.") # print count self.print_count = count if self.print_freq is not None and self.print_count is not None: raise ValueError("Cannot specify both count and freq.") # other parameters for kwarg in ("total", "mininterval", "maxinterval", "miniters"): kwargs.pop(kwarg, None) self.kwargs = kwargs # description -- ``desc`` is user-supplied, so validate with real # exceptions: ``assert`` is stripped under ``python -O``, which would let # a malformed ``desc`` reach the formatting callbacks and fail cryptically. if desc is not None: if isinstance(desc, str): pass else: if not isinstance(desc, (tuple, list)): raise TypeError( f'desc must be a string or a (format_string, callable) ' f'tuple/list, but got {type(desc).__name__}.' ) if len(desc) != 2: raise ValueError( f'desc tuple/list must have exactly two elements ' f'(format_string, callable), but got {len(desc)}.' ) if not isinstance(desc[0], str): raise TypeError( f'The first element of a desc tuple must be a format ' f'string, but got {type(desc[0]).__name__}.' ) if not callable(desc[1]): raise TypeError( f'The second element of a desc tuple must be callable, ' f'but got {type(desc[1]).__name__}.' ) self.desc = desc
def init(self, n: int): kwargs = copy.copy(self.kwargs) freq = self.print_freq count = self.print_count if count is not None: freq = n // count if freq == 0: raise ValueError(f"Count {count} is too large for n {n}.") # The leftover added by ``_close_tqdm`` must be ``n % freq`` (the # iterations not covered by the floor(n/freq) regular ``update(freq)`` # ticks), NOT ``n % count``. Using ``n % count`` overshoots the bar # past 100% whenever ``count`` does not evenly divide ``n``. Match the # other two branches, which correctly use ``n % freq``. remainder = n % freq elif freq is None: if n > 20: freq = int(n / 20) else: freq = 1 remainder = n % freq else: if freq < 1: raise ValueError(f"Print rate should be > 0 got {freq}") elif freq > n: raise ValueError("Print rate should be less than the " f"number of steps {n}, got {freq}") remainder = n % freq message = f"Running for {n:,} iterations" if self.desc is None else self.desc return ProgressBarRunner(n, freq, remainder, message, **kwargs) class ProgressBarRunner(object): __module__ = "brainstate.transform" def __init__( self, n: int, print_freq: int, remainder: int, message: str | Tuple[str, Callable[[Dict], Dict]], **kwargs ): self.tqdm_bars = {} self.kwargs = kwargs self.n = n self.print_freq = print_freq self.remainder = remainder self.message = message def _define_tqdm(self, x: dict): # Read the module flag at call time so it can be monkeypatched in tests. if tqdm_installed: from tqdm.auto import tqdm as bar_cls else: bar_cls = _FallbackProgressBar self.tqdm_bars[0] = bar_cls(range(self.n), **self.kwargs) if isinstance(self.message, str): self.tqdm_bars[0].set_description(self.message, refresh=False) else: self.tqdm_bars[0].set_description(self.message[0].format(**x), refresh=True) def _update_tqdm(self, x: dict): self.tqdm_bars[0].update(self.print_freq) if not isinstance(self.message, str): self.tqdm_bars[0].set_description(self.message[0].format(**x), refresh=True) def _close_tqdm(self, x: dict): if self.remainder > 0: self.tqdm_bars[0].update(self.remainder) if not isinstance(self.message, str): self.tqdm_bars[0].set_description(self.message[0].format(**x), refresh=True) self.tqdm_bars[0].close() def __call__(self, iter_num, **kwargs): data = dict() if isinstance(self.message, str) else self.message[1](dict(i=iter_num, **kwargs)) # The description callback is user-supplied; validate its return with a # real exception (``assert`` is stripped under ``python -O``), which would # otherwise let a non-dict reach ``str.format(**data)`` and fail cryptically. if not isinstance(data, dict): raise TypeError( f'The desc format function must return a dict, but got ' f'{type(data).__name__}.' ) _ = jax.lax.cond( iter_num == 0, lambda x: jax.debug.callback(self._define_tqdm, x, ordered=True), lambda x: None, data ) # Guard the update on ``iter_num < self.n``. ``checkpointed_scan`` runs # through ``_bounded_while_loop``, whose skip path advances the counter # PAST ``self.n - 1`` (by whole sub-blocks) after the loop is done and # still invokes this runner. Without the guard those post-completion # calls can satisfy ``iter_num % print_freq == print_freq - 1`` and fire # ``update()`` after ``close()`` already ran, pushing the bar past 100%. _ = jax.lax.cond( (iter_num < self.n) & (iter_num % self.print_freq == (self.print_freq - 1)), lambda x: jax.debug.callback(self._update_tqdm, x, ordered=True), lambda x: None, data ) _ = jax.lax.cond( iter_num == self.n - 1, lambda x: jax.debug.callback(self._close_tqdm, x, ordered=True), lambda x: None, data )