Skip to content

perf: Use Rich Status for batched spinner rendering instead of per-line updates#14026

Open
KRRT7 wants to merge 41 commits into
pypa:mainfrom
KRRT7:ui-refactor-perf
Open

perf: Use Rich Status for batched spinner rendering instead of per-line updates#14026
KRRT7 wants to merge 41 commits into
pypa:mainfrom
KRRT7:ui-refactor-perf

Conversation

@KRRT7

@KRRT7 KRRT7 commented Jun 2, 2026

Copy link
Copy Markdown
Contributor

in pip/_internal/utils/subprocess.py pip currently reads the child process’s stdout one line at a time (proc.stdout.readline() inside a while True loop).
Every call to readline() triggers a system call and a Python‑level buffer check. When the subprocess produces many small lines (e.g., downloading a large wheel), the loop runs thousands of times

The cumulative overhead of those syscalls and Python function calls dominates the wall‑clock time, even though the actual work (downloading files) is I/O‑bound.

The legacy open_spinner / InteractiveSpinner implementation writes raw back‑space characters to the terminal and rewrites the whole line for every spinner tick.
When showing_subprocess is false (the usual case for quiet installs), the spinner updates on every line of subprocess output, not on a timed interval. That means dozens or hundreds of terminal writes per second, each of which forces the terminal emulator to repaint the line. The repeated cursor‑hiding/showing logic (hidden_cursor) adds extra writes.

The original code used a custom _PipRichSpinner that wrapped Rich’s Live panel. It still performed the low‑level terminal writes itself rather than letting Rich’s own rendering engine batch updates. This prevented Rich from applying its own optimisations (e.g., double‑buffered drawing, minimal refresh rates).

KRRT7 added 5 commits June 2, 2026 04:40
Move spinners.py and progress_bars.py functionality into a new unified
ui.py module. This consolidates UI concerns and prepares for switching
from manual spinner management to Rich's Status context manager for
proper buffering and rate-limited rendering.
Add _detect_no_color() function to check --no-color flag and environment
variables. In setup_logging(), detect if stderr is a TTY and auto-disable
colors when writing to non-TTY streams (like in tests).

Set force_terminal only when stderr is actually a TTY, preventing ANSI
codes from appearing in test output while maintaining proper terminal
rendering in interactive use.
Spinners are now managed through Rich's Status context manager rather
than being passed to call_subprocess. Remove the spinner parameter and
related code that updated the spinner on each line.

This eliminates per-line spinner updates and lets Rich's Status handle
rendering with proper batching and rate limiting, addressing the
syscall and terminal write overhead of the old implementation.
Replace imports of spinners.py and progress_bars.py with ui module.
Update build_env.py and subprocess.py to use status() context manager
instead of open_spinner/open_rich_spinner.

Add logger.info() call to status() function to ensure status messages
(like 'Installing build dependencies') appear in output through the
logging system rather than transient spinner display.
Remove the old spinners.py and progress_bars.py files now that their
functionality has been consolidated into ui.py.

Update test imports to use the new ui module instead of spinners module.
@sepehr-rs

Copy link
Copy Markdown
Member

Hi @KRRT7, thank you for your contribution.
Could you first open an issue for this so we can discuss it there? This PR is also missing a news entry. You can read more about news entries here.

Please let me know if you need any help fixing the failing tests. Also, please note that pip is a volunteer-driven project and reviewer capacity is limited, so it may take some time before a maintainer can take a look at your PR.

@KRRT7

KRRT7 commented Jun 2, 2026

Copy link
Copy Markdown
Contributor Author

Hey @sepehr-rs I'll open an issue shortly, though from what I saw, there's a few open PRs I could just link to that this closes.

@KRRT7

KRRT7 commented Jun 2, 2026

Copy link
Copy Markdown
Contributor Author

closes #14028

@sepehr-rs

Copy link
Copy Markdown
Member

closes #14028

Great! Please update your news file to reflect the issue number you're closing. (e.g. 14028.feature.rst)

@sepehr-rs

Copy link
Copy Markdown
Member

@KRRT7 The news entry filename should be 14028.feature.rst, not 14028.ui-refactor-perf.feature.rst.

@sepehr-rs sepehr-rs left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi, I benchmarked the spinner path under a TTY using runner_with_spinner_message() and a subprocess producing output for ~5 seconds.

In my measurements:

  • main: 434 stdout writes, ~2 KB terminal output
  • PR: 671 stdout writes, ~20 KB terminal output
  • wall-clock time was effectively unchanged

Based on these results, I wasn't able to reproduce the performance improvements described in the PR description. Could you share the benchmark used to support those claims, or clarify whether they refer to a different code path (for example download progress bars rather than subprocess spinners)?

Separately, the PR appears to include a number of changes that don't seem directly related to the spinner/status refactor. Please consider reducing the scope or splitting out unrelated changes, as that would make the review process much easier.

@KRRT7

KRRT7 commented Jun 2, 2026

Copy link
Copy Markdown
Contributor Author

those numbers don't make sense to me, what do your benchmarks look like? I'll share mine in a bit

@sepehr-rs

sepehr-rs commented Jun 2, 2026

Copy link
Copy Markdown
Member

those numbers don't make sense to me, what do your benchmarks look like? I'll share mine in a bit

Sure. I benchmarked the runner_with_spinner_message() path under a TTY with INFO-level logging enabled, using a child process that emits 500 lines to stderr over ~2.5 seconds and running it 10 times.

I compared main and your PR using:

strace -f -e write -o main.trace python benchmark.py
strace -f -e write -o pr.trace python benchmark.py

and then:

grep 'write(1' main.trace | wc -l
grep 'write(1' pr.trace | wc -l

grep 'write(1' main.trace | awk -F',' '{print $3}' | awk '{sum += $1} END {print sum}'
grep 'write(1' pr.trace   | awk -F',' '{print $3}' | awk '{sum += $1} END {print sum}'

Results Results (after a re-run with a ~2.5s workload):

  • main: 236 stdout writes, 1259 bytes
  • PR: 356 stdout writes, 10449 bytes

Wall-clock time was essentially identical (~2.7s).

So for this workload I wasn't able to reproduce the performance improvements described in the PR. It's possible we're measuring different code paths, though, so I'd be interested in seeing the benchmark you used and comparing methodologies.

For reference, here's the benchmark script I used:
https://gist.github.com/sepehr-rs/791e2f13870d5b79b0fac9a59011749a

@KRRT7

KRRT7 commented Jun 2, 2026

Copy link
Copy Markdown
Contributor Author

that'd be why:

https://github.com/KRRT7/pip/tree/benchmarks-only/benchmarks

you're not running it for a long enough time where it gets worse

@KRRT7

KRRT7 commented Jun 2, 2026

Copy link
Copy Markdown
Contributor Author

the workloads is closer to a longer-running install path where subprocess output stays active for much longer, and that's important bc he cost I’m trying to reduce is cumulative, The benchmarks are useful for isolating progress-rendering behavior, which is where the issue surfaces, rich internally has a more efficient way of doing it.

@sepehr-rs

Copy link
Copy Markdown
Member

Thanks for sharing the benchmark suite. I was able to run it and I do see improvements in the progress-bar benchmarks (~20% in the download-related cases).

My earlier measurements were focused on the runner_with_spinner_message() subprocess-spinner path, where I observed increased stdout activity and no measurable wall-clock improvement, so it seems we're looking at different workloads.

One question I still have is about the "long-running cumulative cost" explanation. The benchmark workload itself is relatively small (20 KB / 20 chunks per run), so it's not obvious to me how closely it reflects the longer-running install scenarios described in the PR discussion. Do you have additional benchmarks covering those cases?

Overall, given that the PR description discusses both subprocess output handling and progress rendering, it may be helpful to distinguish which performance claims apply to which code paths.

@KRRT7

KRRT7 commented Jun 8, 2026

Copy link
Copy Markdown
Contributor Author

The benchmark workload itself is relatively small (20 KB / 20 chunks per run)

yes that's on purpose, it's not necessary to have a big benchmark suite to show what I'm trying to show, this, imo, is enough to make my point, plus the numbers there, including iterations and Kbs are configurable.

Comment thread src/pip/_internal/operations/build/wheel.py Outdated
# Conflicts:
#	src/pip/_internal/build_env/installer.py
@KRRT7

KRRT7 commented Jul 4, 2026

Copy link
Copy Markdown
Contributor Author

Before this PR, subprocess execution and spinner rendering were coupled.

On main, call_subprocess() accepted a spinner argument (old subprocess.py L67), decided whether to use it (L118-L120), advanced it after each subprocess output line (L162-L165), and finished it after the subprocess exited (L186-L191).

The spinner code itself lived in spinners.py: a custom spinner interface (old spinners.py L30-L35), manual terminal rewriting/backspacing (L59-L83), separate non-interactive spinner behavior (L91-L114), and a custom Rich Live wrapper (L193-L212).

After this PR, call_subprocess() no longer knows about spinners. Its signature has no spinner parameter (subprocess.py L59-L70), and the output loop only reads/logs subprocess output (L143-L157). Callers that want UI status wrap the operation in ui.status(...) (L224-L230).

The new status() helper lives in pip._internal.cli.ui (ui.py L149-L175). For a TTY, it delegates to Rich Status; for non-TTY output, it preserves pip’s started / finished with status ... messages, covered by tests (test_cli_spinners.py L29-L53, test_wheel.py L195-L209).

Why this matters: call_subprocess() now has one job: run a child process, collect/log output, and raise errors. UI state is handled outside that core path. That makes the code easier to reason about, removes the old custom spinner interface, and lets Rich own interactive status rendering instead of pip manually advancing spinner state from subprocess output lines.

@KRRT7

KRRT7 commented Jul 4, 2026

Copy link
Copy Markdown
Contributor Author

@willmcgugan for your review!

@willmcgugan

Copy link
Copy Markdown

If I've understood correctly. Pip has written a custom wrapper for the progress bars, rather than using using Rich's builtin manager. And this bypasses the logic that Rich's version does to minimize updates.

For context, Rich limits display the progress bar to N updates a second. Rather than rendering on every update. It can be a big win depending on the granularity of writes.

@KRRT7 I think you've spotted a real issue. But I suspect the pip folks are hesitant because they would be exchanging known code that works, with unknown code that may come with its own issues. They have to be very conservative given how pip is essential infrastructure.

Can't speak for the core devs, but maybe break this PR into smaller more digestible pieces. And possibly add a benchmark tool. This kind of thing can vary massively depending on software and hardware combinations.

@KRRT7

KRRT7 commented Jul 11, 2026

Copy link
Copy Markdown
Contributor Author

Thanks, this is helpful.

Yes, that’s the issue I was trying to address: pip’s custom spinner/wrapper code meant we weren’t really letting Rich handle update throttling/rendering itself.

As for the PR itself, I’ve now reduced it to a much smaller, API-preserving version that keeps the existing module boundaries and subprocess interfaces, and just swaps the spinner implementation underneath while preserving the legacy non-interactive output and tests. For now I’ve taken that route in the hope that it makes the change easier to review and merge.

As for a benchmark tool, it’s something I’ve been considering as well. pip’s project/test setup makes it a bit awkward to turn into a proper reusable benchmark harness, but I’d still like to look into it when I have a moment.

@ichard26

Copy link
Copy Markdown
Member

What I still don't understand how our pre-existing Rich based spinner is slow:

class _PipRichSpinner:
    """
    Custom rich spinner that matches the style of the legacy spinners.

    (*) Updates will be handled in a background thread by a rich live panel
        which will call render() automatically at the appropriate time.
    """

    def __init__(self, label: str) -> None:
        self.label = label
        self._spin_cycle = itertools.cycle(SPINNER_CHARS)
        self._spinner_text = ""
        self._finished = False
        self._indent = get_indentation() * " "

    def __rich_console__(
        self, console: Console, options: ConsoleOptions
    ) -> RenderResult:
        yield self.render()

    def __rich_measure__(
        self, console: Console, options: ConsoleOptions
    ) -> Measurement:
        text = self.render()
        return Measurement.get(console, options, text)

    def render(self) -> RenderableType:
        if not self._finished:
            self._spinner_text = next(self._spin_cycle)

        return Text.assemble(self._indent, self.label, " ... ", self._spinner_text)

    def finish(self, status: str) -> None:
        """Stop spinning and set a final status message."""
        self._spinner_text = status
        self._finished = True

@contextlib.contextmanager
def open_rich_spinner(label: str, console: Console | None = None) -> Generator[None]:
    if not logger.isEnabledFor(logging.INFO):
        # Don't show spinner if --quiet is given.
        yield
        return

    console = console or get_console()
    spinner = _PipRichSpinner(label)
    with Live(spinner, refresh_per_second=SPINS_PER_SECOND, console=console):
        try:
            yield
        except KeyboardInterrupt:
            spinner.finish("canceled")
            raise
        except Exception:
            spinner.finish("error")
            raise
        else:
            spinner.finish("done")

This explicitly defers the render() timing to the rich Live instance which is explicitly configured to not exceed a hard limit of refreshes per second (8). And yet, the PR description claims this is also a bottleneck:

The original code used a custom _PipRichSpinner that wrapped Rich’s Live panel. It still performed the low‑level terminal writes itself rather than letting Rich’s own rendering engine batch updates. This prevented Rich from applying its own optimisations (e.g., double‑buffered drawing, minimal refresh rates).

I'll admit that I don't understand what exactly optimizations rich's own spinner is doing, but the refresh rate ought to be the most important part, no? Sure, there may be a little bit more optimization you can achieve if you also know what characters you're writing, but how significant is that?

You're correct to point out that we don't use this spinner everywhere, but that's just a matter of we haven't gotten around to it. I don't see the need to throw out what already works wholesale.

@KRRT7

KRRT7 commented Jul 11, 2026

Copy link
Copy Markdown
Contributor Author

I added an ASV benchmark for the actual open_spinner() path used by runner_with_spinner_message().

It benchmarks the interactive spinner hot loop directly, with a virtual clock so it measures spinner overhead rather than sleep time.

Running:

uv run --group benchmark python -m asv run --bench spinner "main^!"
uv run --group benchmark python -m asv run --bench spinner "HEAD^!"
uv run --group benchmark python -m asv compare main HEAD

gives:

  • time_spinner_hot_loop(1): 6.66ms -> 2.52ms
  • time_spinner_hot_loop(10): 66.8ms -> 25.2ms
  • time_spinner_hot_loop(50): 322ms -> 126ms

and for the larger case:

  • track_write_calls(50): 99496 -> 100
  • track_flush_calls(50): 49698 -> 100
  • track_bytes_written(50): 201083 -> 3341

So this shows the scaling issue more directly: on main, spinner-side terminal work grows substantially with longer / repeated package-status sessions, while this branch keeps that path much flatter.

@notatallshaw

notatallshaw commented Jul 12, 2026

Copy link
Copy Markdown
Member

This PR introduces a behavior change that the tests don't identify: on an interactive tty the new spinner erases its own line when it finishes, since Status is a transient Live. So the "Preparing metadata ... done" and "Building wheel for X ... done" lines that main leaves in the scrollback are gone after the build. Repro under a pty, run once against main and once against this PR:

erase_repro.py (PEP 723; uv run erase_repro.py <venv-python> <source-dir>)
# /// script
# dependencies = ["pyte"]
# ///
# Usage: uv run erase_repro.py <venv-python-with-target-pip> <buildable-source-dir>
import os
import pty
import select
import subprocess
import sys

import pyte

py, pkg = sys.argv[1], sys.argv[2]
cmd = [
    py, "-m", "pip", "wheel", "--no-build-isolation", "--no-index",
    "--no-deps", "--no-cache-dir", "-w", "/tmp/out", os.path.abspath(pkg),
]

master, slave = pty.openpty()
proc = subprocess.Popen(
    cmd,
    stdin=slave,
    stdout=slave,
    stderr=slave,
    env={**os.environ, "TERM": "xterm-256color"},
)
os.close(slave)

buf = b""
while select.select([master], [], [], 20)[0]:
    try:
        chunk = os.read(master, 65536)
    except OSError:  # EIO at pty EOF on Linux
        break
    if not chunk:
        break
    buf += chunk
proc.wait()

screen = pyte.Screen(100, 40)
pyte.ByteStream(screen).feed(buf)

print("erase seqs:", buf.count(b"[1A"), buf.count(b"[2K"))
print("done line kept:", "... done" in "\n".join(screen.display))
main: erase seqs 0 0 | done line kept: True
PR  : erase seqs 2 4 | done line kept: False

The tests don't catch it because they run non-interactively, confirmed on Windows and Linux. Two other behavior changes ride along on the interactive path: the glyph becomes green braille instead of -\|/, and the spinner lines lose pip's 2-space indent. If we were to accept this PR we should have visual side by side comparison of screenshots/gifs.

On the description: the readline point doesn't apply, the diff doesn't touch subprocess.py, so the loop and the per-line spin() are unchanged. The old InteractiveSpinner wasn't writing on every line either, spin() early-returns unless the RateLimiter (8/s) is ready, and _PipRichSpinner did no terminal writes, it returned a Text from render() and let Live draw.

Counting writes to a fake tty over the benchmark's own 50k-spin workload:

import logging
import sys

from pip._internal.cli import spinners


class Clock:
    def __init__(self):
        self.t = 0.0

    def now(self):
        return self.t


class CountingTTY:
    def __init__(self):
        self.writes = 0

    def isatty(self):
        return True

    def write(self, text):
        self.writes += 1
        return len(text)

    def flush(self):
        pass


clock = Clock()
spinners.time.time = clock.now  # virtual clock, as in the benchmark

tty = CountingTTY()
real_stdout = sys.stdout
sys.stdout = tty
spinners.logger.setLevel(logging.INFO)

with spinners.open_spinner("Building wheel") as spinner:
    for _ in range(50_000):  # 50k lines over 125 virtual seconds
        spinner.spin()
        clock.t += 0.0025

sys.stdout = real_stdout
print("writes:", tty.writes)
main: writes 1972    # ~8/s, rate-limited, not per-line
PR  : writes 2       # spin() is a no-op; rendering moved to a background thread

which is also why the benchmark's 2.6x isn't a real-build saving. And it doesn't run as committed anyway:

>>> from benchmarks.spinner import TimeSpinnerHotLoop as T; T()._run(1)
AttributeError: attribute 'encoding' of '_io._TextIOBase' objects is not writable

CountingTTY sets self.encoding on a TextIOBase subclass (read-only) on 3.11-3.13; the 6.66ms -> 2.52ms numbers are from the earlier revision where encoding was a property, the "fix pre-commit issues" commit is what broke it. Matches sepehr-rs's strace (more writes, same wall clock).

Since subprocess.py and progress_bars.py aren't touched here, neither the readline rationale nor the ~20% download number from the earlier version applies to this diff.

My stance is that I would be happy to take logic out of pip and use rich to do more of the UI work, but the motivation of this PR, as it stands are unproven, all the performance numbers so far have misattributed their origin.

There are two things I would need to see before continuing:

  • Side by side UI comparisons
  • Removal of performance claims, or actually meaningful (total cost, not main thread when work is just pushed off thread) reproducible numbers (I strongly suggest before publishing a benchmark or a command, you actually open it up on another directory or machine and try and reproduce it)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants