Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Static Widget Refresh Slows Down After Every Update #5156

Closed
jbdyn opened this issue Oct 22, 2024 · 4 comments
Closed

Static Widget Refresh Slows Down After Every Update #5156

jbdyn opened this issue Oct 22, 2024 · 4 comments

Comments

@jbdyn
Copy link
Contributor

jbdyn commented Oct 22, 2024

Hi!

Love your library so much! ❤️

Upon creating a QRCode widget, I noticed that its refresh time increases with each subsequent call of Static().update(qrcode).
So, I threw some sort of benchmark together:

additional requirements
anyio==4.4.0
matplotlib==3.9.2
numpy==2.1.2
qrcode==8.0
🐍 Benchmark Script
# benchmark.py

import io
import sys
import time
import uuid

import anyio
import matplotlib.pyplot as plt
import numpy as np
import qrcode
from textual.app import App
from textual.widgets import Static

VERSION = 1

qr = qrcode.QRCode(
    version=VERSION, error_correction=qrcode.constants.ERROR_CORRECT_L, border=0
)
f = io.StringIO()


def generate_content():
    return str(uuid.uuid4())


def generate_qrcode(qr, f):
    qr.clear()
    f.truncate(0)

    qr.add_data(generate_content())
    qr.print_ascii(out=f)
    f.seek(0)
    return f.read()


RUNS = 200
times = np.zeros(RUNS)


class TestApp(App):
    def compose(self):
        self.label = Static(generate_qrcode(qr, f))
        yield self.label

    def on_mount(self):
        self.run_worker(self.update_qrcode())

    async def update_qrcode(self):
        for r in range(RUNS):
            self.label.update(generate_qrcode(qr, f))
            times[r] = time.time()
            await anyio.sleep(0)
        self.exit()


async def update_qrcode():
    for r in range(RUNS):
        print(generate_qrcode(qr, f))
        times[r] = time.time()
        await anyio.sleep(0)


USAGE = "Set the first argument to either 'app' or 'cli'"

start = time.time()

try:
    match sys.argv[1]:
        case "app":
            label = "app"
            app = TestApp()
            app.run()
        case "cli":
            label = "cli"
            anyio.run(update_qrcode)
        case _:
            raise Exception()
except Exception:
    print(USAGE)
    exit()

end = time.time()

diffs = np.diff(times)

plt.figure()
plt.title(label)
plt.xlabel(r"run $r$")
plt.ylabel(r"time per run $\Delta t_r$ [s/run]")
plt.plot(
    range(1, RUNS),
    diffs,
    label=rf"$n_r$ = {RUNS}, $\bar{{t}}$ = {diffs.mean():.05f} s, $\sigma_t$ = {diffs.std():.05f} s, $\Delta t$ = {end - start:.05f} s",
)
plt.legend()
plt.savefig(f"benchmark-{label}.svg")

The following plots show the time per QRCode calculation ("run") over runs. Please note the ranges of the y-axes.

  1. > python benchmark.py cli
    This is the baseline plot with qrcode printing the QRCode ASCII chars to stdout:
    benchmark-cli

  2. > python benchmark.py app
    For this plot, the QRCode ASCII chars were given to the Static().update() method, which then updated the widget. I suspect that the spikes are related to the layout refreshing.
    benchmark-app

In the actual app, QRCodes are not spammed as in this example script, but updated on changed input by the user. It is still clearly noticable when typing a few more (~50) chars. Most importantly, the lag seems to persist even after typing stops and no new QRCode is created.

Unfortunately, I don't know how to resolve this. 🤷
What do you think?

textual diagnose

Textual Diagnostics

Versions

Name Value
Textual 0.82.0
Rich 13.8.0

Python

Name Value
Version 3.12.6
Implementation CPython
Compiler GCC 14.2.1 20240805
Executable /home/rossbaj96/projects/textual/.venv/bin/python

Operating System

Name Value
System Linux
Release 6.11.2-4-MANJARO
Version #1 SMP PREEMPT_DYNAMIC Tue Oct 8 11:52:01 UTC 2024

Terminal

Name Value
Terminal Application Unknown
TERM foot
COLORTERM truecolor
FORCE_COLOR Not set
NO_COLOR Not set

Rich Console options

Name Value
size width=113, height=83
legacy_windows False
min_width 1
max_width 113
is_terminal False
encoding utf-8
max_height 83
justify None
overflow None
no_wrap False
highlight None
markup None
height None
@Textualize Textualize deleted a comment from github-actions bot Oct 22, 2024
@willmcgugan
Copy link
Collaborator

In all honesty, I don't think you are seeing what you think you are seeing.

Your QR code generation is cpu-bound, which means that unless you run it in a threaded worker it will block the async event loop -- and your app will become unresponsive. You almost certainly want a threaded worker.

If that doesn't get you anywhere, I'd suggest writing a MRE. The simplest possible app that implements the qr code as you type functionality. That would give us a better starting point. I'm sure it is possible to implement this while keeping the UI responsive as you would expect.

@willmcgugan
Copy link
Collaborator

Does this do what you are looking for?

import io

from textual import on, work
from textual.app import App, ComposeResult
from textual.widgets import Static, Input

import qrcode


class QRApp(App):
    def compose(self) -> ComposeResult:
        yield Input()
        yield Static(id="qr")

    @on(Input.Changed)
    def on_input_changed(self, event: Input.Changed):
        self.generate_qr(event.value)

    def refresh_qr(self, qr: str) -> None:
        self.query_one("#qr").update(qr)

    @work(thread=True)
    def generate_qr(self, url: str):
        qr = qrcode.QRCode(
            version=1, error_correction=qrcode.constants.ERROR_CORRECT_L, border=0
        )
        qr.add_data(url)
        f = io.StringIO()
        qr.print_ascii(out=f)
        self.call_from_thread(self.refresh_qr, f.getvalue())


if __name__ == "__main__":
    app = QRApp()
    app.run()

@jbdyn
Copy link
Contributor Author

jbdyn commented Oct 23, 2024

Thank you for your suggestion. After modifying it in various ways, I found the issue with my code. It is basically written like this:

🐍 modified script
import io

import qrcode
from textual import on, work
from textual.app import App, ComposeResult
from textual.widgets import Input, Static


class QRApp(App):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.buffer = io.StringIO()

    def compose(self) -> ComposeResult:
        yield Input()
        yield Static(id="qr")

    @on(Input.Changed)
    def on_input_changed(self, event: Input.Changed):
        self.generate_qr(event.value)

    def refresh_qr(self, qr: str) -> None:
        self.query_one("#qr").update(qr)

    @work(thread=True)
    def generate_qr(self, url: str):
        qr = qrcode.QRCode(
            version=1, error_correction=qrcode.constants.ERROR_CORRECT_L, border=0
        )
        qr.add_data(url)
        f = self.buffer
        f.truncate(0)
        qr.print_ascii(out=f)
        f.seek(0)
        self.call_from_thread(self.refresh_qr, f.read())


if __name__ == "__main__":
    app = QRApp()
    app.run()
@@ -7,6 +7,10 @@ from textual.widgets import Input, Static
 
 
 class QRApp(App):
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        self.buffer = io.StringIO()
+
     def compose(self) -> ComposeResult:
         yield Input()
         yield Static(id="qr")
@@ -24,9 +28,11 @@ class QRApp(App):
             version=1, error_correction=qrcode.constants.ERROR_CORRECT_L, border=0
         )
         qr.add_data(url)
-        f = io.StringIO()
+        f = self.buffer
+        f.truncate(0)
         qr.print_ascii(out=f)
-        self.call_from_thread(self.refresh_qr, f.getvalue())
+        f.seek(0)
+        self.call_from_thread(self.refresh_qr, f.read())
 
 
 if __name__ == "__main__":

Producing a new buffer is obviously faster than truncating the same one over and over. Classic premature optimization on my side.

Thank you @willmcgugan for your lightning-fast help!

@jbdyn jbdyn closed this as completed Oct 23, 2024
Copy link

Don't forget to star the repository!

Follow @textualizeio for Textual updates.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants