Skip to content

perf(core): run visual participants (actor, scene) in parallel within a shared decode stream - #129

Open
Mahnoor-Zaffar wants to merge 7 commits into
grayhatdevelopers:mainfrom
Mahnoor-Zaffar:feat/parallel-visual-participants
Open

perf(core): run visual participants (actor, scene) in parallel within a shared decode stream#129
Mahnoor-Zaffar wants to merge 7 commits into
grayhatdevelopers:mainfrom
Mahnoor-Zaffar:feat/parallel-visual-participants

Conversation

@Mahnoor-Zaffar

@Mahnoor-Zaffar Mahnoor-Zaffar commented Aug 18, 2026

Copy link
Copy Markdown

Summary

Runs the actor and scene visual participants in parallel over the shared decode stream: one decode pass (already shared before this change), per-participant bounded queues, and sentinel-based shutdown. Previously participants processed the decoded frames sequentially; now each participant consumes the shared sampled-frame stream concurrently. Actor remains in-order; backpressure stalls decode when a participant falls behind. Also fixes a deadlock where a participant failing mid-stream would hang the pipeline — the participant error now propagates instead, and the worker threads carry the owning workflow's context so the production progress/cancellation callbacks work from them.

Internal-only; no public API or storage changes.

Motivation

Refs grayhatdevelopers#96. The visual participants (scene, actor) already shared a single decode pass over the video; the cost is that they processed the decoded frames one after the other, so the scene participant's model work serialized with the actor's. This change feeds the shared sampled-frame stream to both participants concurrently so the slower participant overlaps with the faster one instead of waiting for it.

What changed

  • src/vidxp/capabilities/visual.py — parallel participant scheduling over the shared decode stream:
    • one decode loop produces frames into per-participant bounded queues;
    • scene and actor consumers run concurrently in their own workers;
    • sentinel shutdown terminates consumers cleanly when decode completes or is cancelled;
    • actor remains in-order while scene may lag;
    • backpressure: if a participant falls behind its queue limit, decode stalls so memory stays bounded;
    • mid-stream participant failures are surfaced as errors instead of deadlocking the pipeline (they are joined and re-raised, not swallowed);
    • each worker runs inside its own copy of the owning thread's context so the DBOS-backed progress and cancellation callbacks work from worker threads.
  • tests/test_visual_threading.py — 8 unit tests covering concurrent processing, cancellation, decode failures, participant failures, ordering, backpressure/shutdown, and a production-style callback test that asserts progress and cancellation callbacks see the owning thread's context.

Design details

  • One shared decode pass: frames advance once per video; participants subscribe to the same sampled-frame stream rather than each running its own pass.
  • Bounded queues: each participant has a finite queue depth, so a slow consumer cannot grow memory unboundedly; when the queue is full, the decode loop pauses (backpressure).
  • Deterministic shutdown: a sentinel object marks end-of-stream; consumers drain remaining frames then exit. Cancellation and error paths use the same sentinel protocol so no worker leaks.
  • Failure propagation: a failing participant's exception is collected during join and raised from the indexing run — reproducing the previously-hanging mid-stream failure now raises the participant error.
  • Context propagation: each worker thread runs inside a contextvars.Context copied from the owning thread, so the DBOS workflow ContextVar is visible to the progress (DBOS.set_event) and cancellation (DBOS.workflow_id) calls the workers make.

Validation

  • uv run --no-sync python -m pytest -q tests/test_visual_threading.py8 passed.
  • uv run --no-sync python -m pytest -q tests/test_runner.py tests/test_manifest.py tests/test_generation_manifest.py — passed (reported 27 in the fork PR).
  • uv run --no-sync ruff check src/vidxp/capabilities/visual.py tests/test_visual_threading.py — clean.
  • Reproduced the mid-stream participant failure that previously deadlocked; it now raises the participant error.
  • Verified the context-propagation test fails without the context wiring and passes with it.

Notes

Internal-only performance/reliability change. No public API, storage schema, CLI, or MCP surface changes. Before/after latency and memory measurements for scene alone and scene,actor are pending a reproducible benchmark, per the discussion in grayhatdevelopers#96.

…gression detection (#1)

* feat(benchmarks): add reproducible indexing-latency benchmark

Adds a  command that generates synthetic
media via FFmpeg testsrc2 and measures per-stage indexing throughput,
per-stage wall time, and peak memory across configurable modalities.
Supports regression detection against a prior baseline report.

- : corpus generation, run orchestrator (drives real
  run_index/ModelRuntime), per-stage aggregation, baseline comparison
- CLI command  with --modalities, --videos,
  --duration-seconds, --resolution, --repetitions, --input-mode
  (transcript/transcribe), --audio-mode, --baseline, --baseline-tolerance
-  documents the protocol, output schema, and limitations
- 23 unit tests for validation, aggregation, clip command building,
  baseline comparison, and corpus spec

* fix(benchmarks): address CodeRabbit review issues

- Reject resolutions with extra components (e.g. 320x180x1)
- Accumulate record_counts across repetitions instead of overwriting
- Move corpus generation inside try block for proper failure handling
- Pass reset parameter through instead of hardcoded True
- Validate baseline configuration compatibility before comparison
… with regression detection (#1)"

This reverts commit 072d16f.
… shared decode stream

Refactors _consume_visual_stream so the decode loop runs on a thread
and each visual participant (actor via OpenCV, scene via torch) gets
its own worker thread consuming from a per-participant queue. This lets
actor and scene overlap on CPU since both libraries release the GIL
during computation.

- Decode thread pushes RGB batches to bounded queues (maxsize=4) for
  backpressure
- Each participant worker filters its own samples and calls process()
  independently
- Exceptions from any thread propagate via a shared error list
- Cancellation checked in both decode and participant workers with a
  0.2s polling timeout on the queue get to stay responsive
- The single-decode property (one pass through iter_frame_batches) is
  preserved; actor stays in-order (single FIFO consumer)

No changes to runner.py, IndexConfig, ResourceScheduler, or any
capability's indexing logic. All 5 new threading tests pass.
- Fix a critical shutdown deadlock when a participant queue is full: the
  sentinel drops, so workers now also exit once decoding is done and their
  queue is empty.
- Stop workers promptly after a sibling records an error instead of
  processing remaining queued batches.
- Normalize duplicate modality names in index_visuals before building
  participant/reporting structures.
- Reorder the cancel timer callback so cancellation happens before unblocking
  the stream.
- Prefer addClassCleanup over tearDownClass and drop an unused rebinding.

Internal-only.

@tulayha tulayha left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Thanks for taking this on. The shared stream implementation and queue/shutdown tests are a useful foundation.

I think the PR description needs one clarification: the existing implementation already decoded a single shared frame stream for all visual participants, so this change parallelizes participant processing rather than removing multiple decode passes.

Before merging, could we also add before/after latency and memory measurements for both scene alone and scene,actor? The discussion on issue #96 placed the benchmark first, and the scene-only case would help show the overhead of the new thread and queues when only one participant is selected.

if errors:
return
cancellation.raise_if_cancelled()
report_progress(

@tulayha tulayha Aug 21, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

One production path may need adjustment here. These worker threads can call the progress and cancellation callbacks outside the thread that owns the DBOS workflow context. The production progress callback uses DBOS.set_event(), while DBOS keeps the active workflow state in a ContextVar that raw threading.Threads do not inherit. This means the first progress update may fail the indexing job. The current tests do not exercise that path because they use progress=None and a plain cancellation token. Could we keep these callbacks on the owning thread, or carry the required context into the workers, and add a production-style callback test?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

on it

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Addressed in 43d4541: each worker thread now runs inside its own contextvars.Context copied from the owning thread (_in_context in _consume_visual_stream), so the DBOS workflow ContextVar is visible to the progress (DBOS.set_event) and cancellation (DBOS.workflow_id) calls made from the decode and participant workers. Each worker gets a separate copy of the context, since a single Context cannot be entered from more than one thread at a time.

Added test_worker_callbacks_inherit_owning_thread_context in tests/test_visual_threading.py: it wires a ContextVar-gated progress callback (like DBOS.set_event) and a context-dependent cancellation event, then runs _consume_visual_stream with them. Verified the test fails without the context wiring and passes with it; tests/test_visual_threading.py now 8 passed.

The decode and participant worker threads invoke the progress and
cancellation callbacks, which in production rely on DBOS state held in
ContextVars that raw threading.Threads do not inherit. Run each worker
inside its own copy of the owning thread's context so progress events
and cancellation checks work from the workers, and cover it with a
production-style callback test.

Internal-only.
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

Successfully merging this pull request may close these issues.

2 participants