diff --git a/app.py b/app.py index d631675..52778c4 100644 --- a/app.py +++ b/app.py @@ -59,7 +59,12 @@ import gradio as gr import torch from obliteratus import device as dev from obliteratus.credential_sources import resolve_first, resolve_secret, secret_available -from obliteratus.ui_vram import render_vram_html +from obliteratus.ui_vram import ( + DEFAULT_VRAM_REFRESH_INTERVAL, + VRAM_REFRESH_CHOICES, + render_vram_html, + resolve_vram_refresh_interval, +) from obliteratus.model_load_settings import ( DTYPE_CHOICES, QUANTIZATION_CHOICES, @@ -3957,6 +3962,28 @@ html:not(.dark) body::after { display: none; } margin-bottom: 8px; } +/* ---- GPU MEMORY MONITOR ---- */ +.vram-monitor-row { align-items: flex-end !important; } +.vram-display { flex-grow: 1 !important; } +.vram-refresh-control { + max-width: 108px !important; + margin-left: auto !important; + opacity: 0.72; +} +.vram-refresh-control:hover, +.vram-refresh-control:focus-within { opacity: 1; } +.vram-refresh-control label span { + font-size: 0.62rem !important; + letter-spacing: 0.06em !important; +} +.vram-refresh-control .wrap { min-height: 30px !important; } +@media (max-width: 640px) { + .vram-refresh-control { + max-width: 100px !important; + margin-left: auto !important; + } +} + /* ---- TAB STYLING ---- */ .tabs { border-bottom: 1px solid #1a1f2e !important; } button.tab-nav { @@ -4207,8 +4234,24 @@ with gr.Blocks(title="OBLITERATUS", fill_height=True) as demo: """) - # GPU VRAM monitor — refreshed on page load and after key operations - vram_display = gr.HTML(value=_get_vram_html()) + # GPU VRAM monitor — periodically refreshed and updated after key operations + with gr.Row(elem_classes=["vram-monitor-row"]): + vram_display = gr.HTML( + value=_get_vram_html(), + scale=12, + elem_classes=["vram-display"], + ) + vram_refresh_interval = gr.Dropdown( + choices=VRAM_REFRESH_CHOICES, + value=DEFAULT_VRAM_REFRESH_INTERVAL, + label="GPU refresh", + filterable=False, + allow_custom_value=False, + scale=1, + min_width=96, + elem_classes=["vram-refresh-control"], + ) + vram_refresh_timer = gr.Timer(value=DEFAULT_VRAM_REFRESH_INTERVAL, active=True) # ZeroGPU info — only shown when running on HF Spaces with ZeroGPU if _ZEROGPU_AVAILABLE: @@ -5497,6 +5540,25 @@ Built on the shoulders of: # Refresh VRAM on page load demo.load(fn=_get_vram_html, outputs=[vram_display]) + # Poll independently of long-running queued model work. Updating the one + # timer's interval avoids accumulating polling loops when the selector changes. + vram_refresh_timer.tick( + fn=_get_vram_html, + outputs=[vram_display], + queue=False, + concurrency_limit=1, + concurrency_id="vram-refresh", + api_visibility="private", + ) + vram_refresh_interval.change( + fn=resolve_vram_refresh_interval, + inputs=[vram_refresh_interval], + outputs=[vram_refresh_timer], + queue=False, + show_progress="hidden", + api_visibility="private", + ) + # --------------------------------------------------------------------------- # Launch diff --git a/obliteratus/ui_vram.py b/obliteratus/ui_vram.py index ad67c83..a3e265a 100644 --- a/obliteratus/ui_vram.py +++ b/obliteratus/ui_vram.py @@ -5,14 +5,24 @@ from __future__ import annotations from typing import Any +VRAM_REFRESH_CHOICES = (("10 sec", 10.0), ("30 sec", 30.0), ("1 min", 60.0)) +DEFAULT_VRAM_REFRESH_INTERVAL = 30.0 + + +def resolve_vram_refresh_interval(value: float) -> float: + """Return a supported VRAM polling interval, falling back to the default.""" + allowed = {interval for _label, interval in VRAM_REFRESH_CHOICES} + return value if value in allowed else DEFAULT_VRAM_REFRESH_INTERVAL + + def render_vram_html(device: Any) -> str: """Return per-device GPU/accelerator memory usage as styled bars.""" - if not device.is_gpu_available(): - return ( - '
CPU ONLY — NO GPU DETECTED
' - ) try: + if not device.is_gpu_available(): + return ( + '
CPU ONLY — NO GPU DETECTED
' + ) cuda_count = device.device_count() if device.is_cuda() else 0 device_indices = range(cuda_count) if cuda_count else (None,) rows = [] diff --git a/tests/conditional/test_operator_ui.py b/tests/conditional/test_operator_ui.py index 3ac4c17..ec262f3 100644 --- a/tests/conditional/test_operator_ui.py +++ b/tests/conditional/test_operator_ui.py @@ -31,3 +31,72 @@ def test_gradio_application_constructs_without_public_listener(): assert probe.returncode == 0, probe.stderr assert probe.stdout.strip() == "operator UI constructed without a listener" assert probe.stderr == "" + + +def test_gpu_memory_refresh_controls_have_one_configurable_polling_loop(): + """Exercise the rendered Gradio graph rather than relying on source shape.""" + pytest.importorskip("gradio", reason="install the locked spaces extra") + probe = subprocess.run( + [ + sys.executable, + "-c", + r''' +import asyncio +import app + +config = app.demo.get_config_file() +components = config["components"] +timers = [item for item in components if item["type"] == "timer"] +assert len(timers) == 1 +timer = timers[0] +assert timer["props"]["value"] == 30.0 +assert timer["props"]["active"] is True + +selectors = [ + item for item in components + if "vram-refresh-control" in item["props"].get("elem_classes", []) +] +assert len(selectors) == 1 +selector = selectors[0] +assert selector["type"] == "dropdown" +assert selector["props"]["label"] == "GPU refresh" +assert selector["props"]["choices"] == [ + ("10 sec", 10.0), ("30 sec", 30.0), ("1 min", 60.0) +] +assert selector["props"]["value"] == 30.0 +assert selector["props"]["filterable"] is False +assert selector["props"]["allow_custom_value"] is False +assert selector["props"]["min_width"] == 96 + +timer_ticks = [ + dependency for dependency in config["dependencies"] + if (timer["id"], "tick") in dependency["targets"] +] +assert len(timer_ticks) == 1 +assert timer_ticks[0]["queue"] is False +assert timer_ticks[0]["outputs"] == [app.vram_display._id] + +interval_changes = [ + dependency for dependency in config["dependencies"] + if (selector["id"], "change") in dependency["targets"] + and dependency["outputs"] == [timer["id"]] +] +assert len(interval_changes) == 1 +assert interval_changes[0]["queue"] is False + +async def verify_interval_changes(): + for seconds in (10.0, 30.0, 60.0): + result = await app.demo.process_api( + interval_changes[0]["id"], [seconds], state=None + ) + assert result["data"] == [seconds] + +asyncio.run(verify_interval_changes()) +''', + ], + capture_output=True, + text=True, + timeout=120, + check=False, + ) + assert probe.returncode == 0, probe.stdout + probe.stderr diff --git a/tests/test_app_vram.py b/tests/test_app_vram.py index 8bfa9f4..ecd70d5 100644 --- a/tests/test_app_vram.py +++ b/tests/test_app_vram.py @@ -4,7 +4,12 @@ from __future__ import annotations from types import SimpleNamespace -from obliteratus.ui_vram import render_vram_html +from obliteratus.ui_vram import ( + DEFAULT_VRAM_REFRESH_INTERVAL, + VRAM_REFRESH_CHOICES, + render_vram_html, + resolve_vram_refresh_interval, +) def _memory(used, reserved, total, name): @@ -79,3 +84,19 @@ def test_vram_html_handles_memory_errors(): get_memory_info=lambda _index: (_ for _ in ()).throw(RuntimeError("unavailable")), ) assert "Memory: unavailable" in render_vram_html(device) + + device.is_gpu_available = lambda: (_ for _ in ()).throw(RuntimeError("unavailable")) + assert "Memory: unavailable" in render_vram_html(device) + + +def test_vram_refresh_interval_contract(): + assert VRAM_REFRESH_CHOICES == ( + ("10 sec", 10.0), + ("30 sec", 30.0), + ("1 min", 60.0), + ) + assert DEFAULT_VRAM_REFRESH_INTERVAL == 30.0 + assert resolve_vram_refresh_interval(10.0) == 10.0 + assert resolve_vram_refresh_interval(30.0) == 30.0 + assert resolve_vram_refresh_interval(60.0) == 60.0 + assert resolve_vram_refresh_interval(15.0) == 30.0