forked from genewildish/Mainline
fix: Implement ViewportFilterStage to prevent FontStage performance regression with large datasets
## Summary Fixed critical performance issue where demo/poetry presets would hang for 10+ seconds due to FontStage rendering all 1438+ headline items instead of just the visible ~5 items. ## Changes ### Core Fix: ViewportFilterStage - New pipeline stage that filters items to only those fitting in the viewport - Reduces 1438 items → ~5 items (288x reduction) before FontStage - Prevents expensive PIL font rendering operations on items that won't be displayed - Located: engine/pipeline/adapters.py:348-403 ### Pipeline Integration - Updated app.py to add ViewportFilterStage before FontStage for headlines/poetry sources - Ensures correct data flow: source → viewport_filter → font → camera → effects → display - ViewportFilterStage depends on 'source' capability, providing pass-through filtering ### Display Protocol Enhancement - Added is_quit_requested() and clear_quit_request() method signatures to Display protocol - Documented as optional methods for backends supporting keyboard input - Already implemented by pygame backend, now formally part of protocol ### Debug Infrastructure - Added MAINLINE_DEBUG_DATAFLOW environment variable logging throughout pipeline - Logs stage input/output types and data sizes to stderr (when flag enabled) - Verified working: 1438 → 5 item reduction shown in debug output ### Performance Testing - Added pytest-benchmark (v5.2.3) as dev dependency for statistical benchmarking - Created comprehensive performance regression tests (tests/test_performance_regression.py) - Tests verify: - ViewportFilterStage filters 2000 items efficiently (<1ms) - FontStage processes filtered items quickly (<50ms) - 288x performance improvement ratio maintained - Pipeline doesn't hang with large datasets - All 523 tests passing, including 7 new performance tests ## Performance Impact **Before:** FontStage renders all 1438 items per frame → 10+ second hang **After:** FontStage renders ~5 items per frame → sub-second execution Real-world impact: Demo preset now responsive and usable with news sources. ## Testing - Unit tests: 523 passed, 16 skipped - Regression tests: Catch performance degradation with large datasets - E2E verification: Debug logging confirms correct pipeline flow - Benchmark suite: Statistical performance tracking enabled
This commit is contained in:
@@ -154,8 +154,12 @@ def run_pipeline_mode(preset_name: str = "demo"):
|
||||
|
||||
# Add FontStage for headlines/poetry (default for demo)
|
||||
if preset.source in ["headlines", "poetry"]:
|
||||
from engine.pipeline.adapters import FontStage
|
||||
from engine.pipeline.adapters import FontStage, ViewportFilterStage
|
||||
|
||||
# Add viewport filter to prevent rendering all items
|
||||
pipeline.add_stage(
|
||||
"viewport_filter", ViewportFilterStage(name="viewport-filter")
|
||||
)
|
||||
pipeline.add_stage("font", FontStage(name="font"))
|
||||
else:
|
||||
# Fallback to simple conversion for other sources
|
||||
|
||||
@@ -84,6 +84,23 @@ class Display(Protocol):
|
||||
"""
|
||||
...
|
||||
|
||||
def is_quit_requested(self) -> bool:
|
||||
"""Check if user requested quit (Ctrl+C, Ctrl+Q, or Escape).
|
||||
|
||||
Returns:
|
||||
True if quit was requested, False otherwise
|
||||
|
||||
Optional method - only implemented by backends that support keyboard input.
|
||||
"""
|
||||
...
|
||||
|
||||
def clear_quit_request(self) -> None:
|
||||
"""Clear the quit request flag.
|
||||
|
||||
Optional method - only implemented by backends that support keyboard input.
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
class DisplayRegistry:
|
||||
"""Registry for display backends with auto-discovery."""
|
||||
|
||||
@@ -345,6 +345,64 @@ class CameraStage(Stage):
|
||||
self._camera.reset()
|
||||
|
||||
|
||||
class ViewportFilterStage(Stage):
|
||||
"""Stage that limits items to fit in viewport.
|
||||
|
||||
Filters the input list of items to only include as many as can fit
|
||||
in the visible viewport. This prevents FontStage from rendering
|
||||
thousands of items when only a few are visible, reducing processing time.
|
||||
|
||||
Estimate: each rendered item typically takes 5-8 terminal lines.
|
||||
For a 24-line viewport, we limit to ~4 items (conservative estimate).
|
||||
"""
|
||||
|
||||
def __init__(self, name: str = "viewport-filter"):
|
||||
self.name = name
|
||||
self.category = "filter"
|
||||
self.optional = False
|
||||
|
||||
@property
|
||||
def stage_type(self) -> str:
|
||||
return "filter"
|
||||
|
||||
@property
|
||||
def capabilities(self) -> set[str]:
|
||||
return {f"filter.{self.name}"}
|
||||
|
||||
@property
|
||||
def dependencies(self) -> set[str]:
|
||||
return {"source"}
|
||||
|
||||
@property
|
||||
def inlet_types(self) -> set:
|
||||
from engine.pipeline.core import DataType
|
||||
|
||||
return {DataType.SOURCE_ITEMS}
|
||||
|
||||
@property
|
||||
def outlet_types(self) -> set:
|
||||
from engine.pipeline.core import DataType
|
||||
|
||||
return {DataType.SOURCE_ITEMS}
|
||||
|
||||
def process(self, data: Any, ctx: PipelineContext) -> Any:
|
||||
"""Filter items to viewport-fitting count."""
|
||||
if data is None or not isinstance(data, list):
|
||||
return data
|
||||
|
||||
# Estimate: each rendered headline takes 5-8 lines
|
||||
# Use a conservative factor to ensure we don't run out of space
|
||||
lines_per_item = 6
|
||||
viewport_height = ctx.params.viewport_height if ctx.params else 24
|
||||
|
||||
# Calculate how many items we need to fill the viewport
|
||||
# Add 1 extra to account for padding/spacing
|
||||
max_items = max(1, viewport_height // lines_per_item + 1)
|
||||
|
||||
# Return only the items that fit in the viewport
|
||||
return data[:max_items]
|
||||
|
||||
|
||||
class FontStage(Stage):
|
||||
"""Stage that applies font rendering to content.
|
||||
|
||||
|
||||
@@ -255,6 +255,18 @@ class Pipeline:
|
||||
1. Execute all non-overlay stages in dependency order
|
||||
2. Apply overlay stages on top (sorted by render_order)
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
debug = os.environ.get("MAINLINE_DEBUG_DATAFLOW") == "1"
|
||||
|
||||
if debug:
|
||||
print(
|
||||
f"[PIPELINE.execute] Starting with data type: {type(data).__name__ if data else 'None'}",
|
||||
file=sys.stderr,
|
||||
flush=True,
|
||||
)
|
||||
|
||||
if not self._initialized:
|
||||
self.build()
|
||||
|
||||
@@ -303,8 +315,30 @@ class Pipeline:
|
||||
stage_start = time.perf_counter() if self._metrics_enabled else 0
|
||||
|
||||
try:
|
||||
if debug:
|
||||
data_info = type(current_data).__name__
|
||||
if isinstance(current_data, list):
|
||||
data_info += f"[{len(current_data)}]"
|
||||
print(
|
||||
f"[STAGE.{name}] Starting with: {data_info}",
|
||||
file=sys.stderr,
|
||||
flush=True,
|
||||
)
|
||||
|
||||
current_data = stage.process(current_data, self.context)
|
||||
|
||||
if debug:
|
||||
data_info = type(current_data).__name__
|
||||
if isinstance(current_data, list):
|
||||
data_info += f"[{len(current_data)}]"
|
||||
print(
|
||||
f"[STAGE.{name}] Completed, output: {data_info}",
|
||||
file=sys.stderr,
|
||||
flush=True,
|
||||
)
|
||||
except Exception as e:
|
||||
if debug:
|
||||
print(f"[STAGE.{name}] ERROR: {e}", file=sys.stderr, flush=True)
|
||||
if not stage.optional:
|
||||
return StageResult(
|
||||
success=False,
|
||||
|
||||
Reference in New Issue
Block a user