Repository navigation
perf(workflows): reuse one run-scoped thread pool for executor-less runs - #3052
Merged
PawelPeczek-Roboflow merged 6 commits intoSep 25, 2026
Merged
PawelPeczek-Roboflow merged 6 commits into
PawelPeczek-Roboflow merged 6 commits into
Conversation
davidnichols-ops
requested review from
PawelPeczek-Roboflow,
dkosowski87,
grzegorz-roboflow,
hansent,
probicheaux,
rafel-roboflow and
yeldarby
as code owners
September 23, 2026 01:56
davidnichols-ops
force-pushed
the
feat/workflows-latency-pr
branch
2 times, most recently
from
September 23, 2026 03:39
6e67f95 to
36ed442
Compare
Build a single ThreadPoolExecutor per workflow run when the host passes no executor instead of one per DAG wave: create it lazily on the first wave that runs and dispatch each wave flat (flat_dispatch - submit the whole wave, collect results) so scheduling matches the historical executor-less path. A step error drains in-flight siblings before leaving run_steps_in_parallel, exactly where the per-wave with-blocks used to join, and the pool shuts down in a nested finally before stream-pipeline close so pipeline cleanup still runs if shutdown itself raises. Host-provided pools keep batched dispatch and are never built or shut by the engine, so the HTTP server and streaming pipeline are unchanged. Worker threads live for exactly one run. Framework context stays isolated per task through context snapshots and the unconditional rebinds in safe_execute_step; third-party threading.local set inside one step may be seen by later steps of the same run (the reuse profile hosted pools have had since roboflow#1717), and cannot cross runs because the threads are destroyed at run end. On a six-round counterbalanced benchmark, pool constructions fall from the wave count to one and median run latency drops from 1.10 to 0.71 ms on a 10-step chain (-35%) and from 5.08 to 3.01 ms on a 50-step chain (-41%), with single-wave, host-provided, and compute-heavy cells unchanged within noise. The 17 regression tests fail on unpatched base code with 11 failures (pool counts, dispatch shape, shutdown ordering, early-termination teardown) and pass here; suites report 6607 passed, 113 skipped (workflows package) and 1238 passed, 30 skipped (root server workflows).
davidnichols-ops
force-pushed
the
feat/workflows-latency-pr
branch
from
September 24, 2026 01:30
eca3416 to
9f52400
Compare
PawelPeczek-Roboflow
approved these changes
Sep 25, 2026
PawelPeczek-Roboflow
left a comment
Collaborator
There was a problem hiding this comment.
Looks like really good observation and patch. Thank you.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Reviewer quick-sheet
executor/core.pyand +13 inexecutor/utils.pyof code — 75 added and 34 removed, much of it re-indentation for the nested cleanup — plus 50 lines of in-code boundary comments.flat_dispatchdefaults toFalse, they keep their existing batched dispatch, and neitherhttp_api.pynorinference_pipeline.pyappears in this diff.make check_code_qualityexit 0 — details below.Summary
Executor-less workflow runs now reuse one
ThreadPoolExecutorfor the duration of a run instead of constructing and destroying a pool for every DAG wave.This is the default path for standalone
roboflow-workflows, SDK, and embedded usage. Hosts that already provide an executor, including the HTTP server and streaming pipeline, retain their existing behavior.In a six-round counterbalanced benchmark, pool construction fell from one pool per wave to one pool per run:
contours-detectionworkflowSingle-wave, host-provided-executor, and compute-heavy runs were unchanged within noise. The practical saving on the gallery workflow is about 0.09 ms per run; deep orchestration-bound workflows benefit most.
Why
When
**executor=**None****,run_steps_in_parallelpreviously created a fresh pool inside awithblock for every wave. A sequential 10-step workflow therefore paid for ten pool construction, thread startup, shutdown, and join cycles.The standalone package extraction made this executor-less path a normal public use case. The engine is also the only component that can own this pool without requiring callers to implement a new executor lifecycle.
This does not overlap with #2448, #2940, or #2947. Those changes address server-side contention in a shared host executor during model loading. This PR only changes the path where no host executor is supplied.
Changes
_run_workflowlazily creates one run-owned pool when the first wave executes and shuts it down infinally.flush_stream_pipeline_workflowuses the same ownership model.run_steps_in_parallelgainsflat_dispatch=False:withbehavior.Compatibility and ownership boundary
max_concurrent_steps.contextvarssnapshot and receives the existing framework context rebinds.threading.localstate uncleared may expose it to another step in the same run. Hosted executors already have this reuse profile; block state is expected to live on block instances or managed caches.Verification
Added 17 focused ownership and behavior tests covering:
ContextVar, request-thread, and cross-run thread-local isolation;Falsification against the unpatched implementation produces 11 failures / 6 passes in this file. The patched implementation produces 17 passes. Bypassing the context snapshot fails the two isolation detectors.
Repository checks run locally:
cd workflows && pytest tests/unit_tests: 6607 passed, 113 skippedmake check_code_quality: exit 0Risks and rollback
The behavioral change is that worker lifetime extends from one wave to one run on the executor-less path. Framework context remains task-isolated, and the pool is destroyed at run completion. The hosted path is untouched.
The test suite currently inspects CPython
ThreadPoolExecutorinternals (_shutdownand_threads) for several lifecycle assertions. These attributes are stable across the supported Python 3.10–3.12 interpreters; they can be replaced with recorded-shutdown fixtures if maintainers prefer avoiding internal attributes. Happy to swap the private CPython attribute assertions for public recorded-shutdown mocks if you prefer zero reliance on internal interpreter attributes.Rollback is a direct revert of this PR, restoring one pool per wave.
Scope
Four files: the implementation in
executor/core.pyandexecutor/utils.py, one 17-test ownership suite, and the changelog entry. No server, streaming-pipeline, model-loading, or public engine lifecycle code is changed.