[Core] Partition parallel CPU DAGs into streams - #29817
Conversation
Automatically split independent CPU paths for ORT_PARALLEL sessions without explicit partition configs. Bound stream creation to inter-op concurrency while preserving sequential, configured, and non-CPU behavior. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
This PR enhances ONNX Runtime’s stream planning for ORT_PARALLEL sessions by automatically partitioning independent CPU-only DAG branches into multiple logical streams (while keeping linear CPU graphs on a single stream), without requiring a user-provided session.node_partition_config_file.
Changes:
- Extend the sequential-planner context to carry a
max_num_streamscap and pass it into graph partitioning. - Update the default
DeviceBasedPartitionerto topology-aware split CPU paths across streams (bounded by inter-op concurrency), while preserving existing non-CPU device grouping and explicit partition config behavior. - Add planner- and session-level tests validating automatic CPU branch partitioning and linear-graph behavior; add
ThreadPool::WorkerThreadCount()to cap streams using actual worker threads (vs hybrid-inflatedDegreeOfParallelism).
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| onnxruntime/test/framework/allocation_planner_test.cc | Adds unit/integration coverage for CPU topology-aware partitioning, stream limits, and sequential-vs-parallel equivalence checks (size/hash). |
| onnxruntime/core/framework/session_state.cc | Computes and supplies max_num_streams to planner context based on inter-op thread pool worker count (+ caller). |
| onnxruntime/core/framework/allocation_planner.h | Adds GetMaxNumStreams() to planner context; extends IGraphPartitioner::PartitionGraph API to accept parallel/max-stream parameters. |
| onnxruntime/core/framework/allocation_planner.cc | Implements topology-aware CPU partitioning inside DeviceBasedPartitioner when auto-generating partitions. |
| onnxruntime/core/common/threadpool.cc | Implements ThreadPool::WorkerThreadCount() as NumThreads() (0 if null). |
| include/onnxruntime/core/platform/threadpool.h | Declares ThreadPool::WorkerThreadCount() API and documents semantics. |
| const auto parallel_result = run_session(ExecutionMode::ORT_PARALLEL); | ||
|
|
||
| EXPECT_EQ(sequential_result.num_streams, 1U); | ||
| EXPECT_EQ(parallel_result.num_streams, 2U); |
There was a problem hiding this comment.
Good call. Relaxed the assertion to EXPECT_GT(parallel_result.num_streams, 1U) in 52830c5 so it validates the intended behavior (automatic >1 stream under ORT_PARALLEL) without pinning the exact count. The output size + 128-bit hash equivalence checks still guard correctness, so the test stays meaningful across benign graph/optimizer or future heuristic changes.
The automatic multi-stream CPU DAG partitioner added in this PR can
produce a multi-stream execution plan for ORT_PARALLEL sessions. In
training-enabled builds the memory optimization plan
(node_execution_order_in_training + CalculateProgramCounter in
allocation_planner.cc) assumes a single logical CPU stream, so a
multi-stream partition trips the ProgramCounter::AddEnd assertion
("No matching 'start' entry", sequential_execution_plan.h:60) during
session initialization for branch-heavy models such as SSD-MobileNetV1.
This failed the "Build Linux x64 Release with training" CI leg.
Gate the multi-stream branch on !defined(ENABLE_TRAINING) so training
builds keep the original single-stream behavior while inference builds
retain the parallel partitioning.
Verified in a local --enable_training build: pre-fix, SSD-MobileNetV1
at inter_op=8 fails with the exact assertion; post-fix it initializes
and runs for inter_op in {2,4,6,8,12,24}.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Address review feedback: assert that automatic ORT_PARALLEL partitioning produces more than one CPU stream rather than pinning the exact count (2). The output size/hash equivalence checks continue to validate correctness, so the test stays meaningful while tolerating benign graph/optimizer or future heuristic changes to the exact stream count. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
| } | ||
|
|
||
| int ThreadPool::WorkerThreadCount(const concurrency::ThreadPool* tp) { | ||
| return tp ? tp->NumThreads() : 0; |
Description
Automatically partition independent CPU paths into multiple logical streams for unconfigured
ORT_PARALLELsessions.DegreeOfParallelismvalue as the stream cap.The existing execution planner already inserts
BarrierStepandTriggerDownstreamStepsynchronization for cross-stream dependencies, so no executor changes are required.Motivation and Context
The default
DeviceBasedPartitionergroups nodes byOrtDevice::DeviceType. Consequently, an all-CPU graph receives one logical stream even inORT_PARALLEL, serializing independent branches unless users provide a hand-authoredsession.node_partition_config_file.This change exposes that branch parallelism automatically while retaining one stream for linear graphs.
Performance
Release CPU build,
ORT_PARALLEL, intra-op=1, inter-op=4. Each condition used eight randomized process rounds with 80 iterations, the first five discarded, for 600 retained measurements. Confidence intervals use 20,000 bootstrap samples over process medians. The reference is an explicit single-stream configuration using the same binary.The SSD P95 improved from 208.362 ms to 157.970 ms.
Relationship to #29700
This is complementary to #29700. This PR changes graph planning to expose parallel CPU work; #29700 changes executor waiting and cancellation after streams have been planned. There is no changed-file overlap. The measurements above use the current executor and should be repeated after #29700 lands, particularly under high request concurrency.
Validation
onnxruntime_test_allbuilt successfully after rebasing onto currentmain.PlannerTest.*: 16 tests passed.git diff --checkare clean.Build configurations
Automatic multi-stream partitioning is limited to inference builds. Training-enabled builds (
--enable_training) compute an additional memory-optimization plan (node_execution_order_in_training/CalculateProgramCounterinallocation_planner.cc) that assumes a single logical CPU stream, so the multi-stream branch is compiled out underENABLE_TRAININGand those builds keep the original single-stream behavior. Without this gate, branch-heavy models such as SSD-MobileNetV1 trip theProgramCounter::AddEndassertion (sequential_execution_plan.h:60, "No matching 'start' entry") during session initialization, which previously failed the training CI leg. Reproduced and fixed against a local--enable_trainingbuild: pre-fix, SSD-MobileNetV1 at inter-op=8 fails with that exact assertion; post-fix it initializes and runs for inter-op in {2, 4, 6, 8, 12, 24}.