Skip to content

Optimize TaskGroup.topological_sort for reverse-declared Dags - #67688

Merged
shahar1 merged 2 commits into
apache:mainfrom
shahar1:optimize-dag-topological-sort-rebase
Jun 12, 2026
Merged

Optimize TaskGroup.topological_sort for reverse-declared Dags#67688
shahar1 merged 2 commits into
apache:mainfrom
shahar1:optimize-dag-topological-sort-rebase

Conversation

@shahar1

@shahar1 shahar1 commented May 29, 2026

Copy link
Copy Markdown
Contributor

Human Summary

Further optimization of TaskGroup.topological_sort to handle reverse-declared Dags (where many children are declared before their dependencies) efficiently. This is the follow-up to PR #67288.

Explanation

The idea is to optimize TaskGroup.topological_sort for Dags whose tasks are declared in reverse of their dependency order, for example:

@dag
def dag():
    task3 = EmptyOperator()
    task2 = EmptyOperator()
    task1 = EmptyOperator()

    task1 >> task2 >> task3

Note that we insert task3, then task2, then task1, but their topological order is the other way round.

The issue with this shape is that the current sweep algorithm revisits blocked nodes. In a reversed-declared chain, we end up in a situation where total of N + (N-1) + (N-2) + ... + 1 nodes are revisited, leading to a time complexity of O(N^2).
To solve this, we first use Kahn's algorithm to compute the pass number at which each node would be emitted by the existing sweep algorithm. Once the pass numbers are known, we sort nodes by (pass_number, insertion_order), which produces the exact same ordering as the sweep algorithm while avoiding the repeated rescans.

Since the pass-numbering approach has a higher constant overhead than the sweep algorithm, we only enable it for TaskGroups with a significant number of back edges (nodes_with_back_edge >= 32), where the cost of repeated sweeps is expected to outweigh that overhead (backed up by a benchmark script).

Was generative AI tooling used to co-author this PR?

AI Summary

Click here Addresses the O(N²) worst-case behavior of the greedy-sweep approach on adversarial Dag shapes such as reverse-insertion chains.

Uses a hybrid strategy:

  • Forward-declared Dags (common case): greedy multi-pass sweep, O(V + E)
  • Reverse-declared Dags (many back edges): pass-number traversal via Kahn's algorithm, O((V + E) log V)
  • Mixed Dags where independent children dilute the ratio: the dispatcher also flips once the group has at least 32 back-edge nodes, which catches the padded reverse-chain case raised in review

Both approaches emit the same order: level-by-legacy-pass, ties broken by insertion order.

Benchmark Results

Run the benchmark with: uv run --project task-sdk python dev/bench_topological_sort_comparison.py

See the gist for the benchmark script.

Reverse-Chain Speedup (Worst Case)

N Sweep-only (ms) Hybrid (ms) Speedup
100 0.29 0.07 4.35x
500 6.01 0.43 13.88x
1000 24.15 0.75 32.06x
2000 96.79 1.77 54.58x

Padded Reverse-Chain (Review Case)

Shape Sweep-only (ms) Hybrid (ms) Speedup
1000 reverse + 1000 independent 24.35 1.18 20.72x

Performance Progression

The dispatcher now switches when a group is clearly back-heavy either by ratio or by an absolute back-edge count, so padded reverse-declared Dags no longer fall back to the quadratic sweep.

Test Plan

  • Reran the benchmark from the gist after rebasing onto main
  • Added a reverse-declared order-equivalence regression that asserts _sort_via_pass_numbering matches _sweep_projection
  • Added padded reverse-chain dispatch regressions for both TaskGroup and SerializedTaskGroup
  • uv run --project task-sdk pytest task-sdk/tests/task_sdk/definitions/test_taskgroup.py -k 'reverse_declared_order_matches_sweep or padded_reverse_chain_uses_pass_numbering or topological_sort_shape_correctness' -xvs
  • uv run --project airflow-core pytest airflow-core/tests/unit/utils/test_task_group.py -k 'serialized_padded_reverse_chain_uses_pass_numbering or topological_sort_serialized_layered' -xvs

@shahar1
shahar1 force-pushed the optimize-dag-topological-sort-rebase branch from 645a59d to 8506a3f Compare May 29, 2026 07:50
@shahar1 shahar1 changed the title Optimize TaskGroup.topological_sort for reverse-declared DAGs (follow-up #67288) Optimize TaskGroup.topological_sort for reverse-declared Dags May 29, 2026
@shahar1
shahar1 marked this pull request as draft May 29, 2026 07:52
@shahar1
shahar1 marked this pull request as ready for review May 29, 2026 07:56
Comment thread task-sdk/src/airflow/sdk/definitions/taskgroup.py
Comment thread task-sdk/src/airflow/sdk/definitions/taskgroup.py Outdated
shahar1 added 2 commits May 29, 2026 18:05
A long reverse-declared run could fall back to the sweep when independent children diluted the back-edge ratio. Add an absolute back-edge cutoff and pin the order/dispatch invariants in both TaskGroup implementations.
@shahar1
shahar1 force-pushed the optimize-dag-topological-sort-rebase branch from 8506a3f to a948df8 Compare May 29, 2026 15:25
@shahar1
shahar1 requested a review from kaxil May 29, 2026 16:18
@kaxil

kaxil commented Jun 3, 2026

Copy link
Copy Markdown
Member

cc @uranusjr can you review it too plz

@shahar1

shahar1 commented Jun 12, 2026

Copy link
Copy Markdown
Contributor Author

Merging, edited the PR's description with a concise explanation.

@shahar1
shahar1 merged commit d2f42e6 into apache:main Jun 12, 2026
143 checks passed
@shahar1
shahar1 deleted the optimize-dag-topological-sort-rebase branch June 12, 2026 04:20
@vatsrahul1001 vatsrahul1001 added this to the Airflow 3.3.0 milestone Jun 15, 2026
imrichardwu pushed a commit to imrichardwu/airflow that referenced this pull request Jun 16, 2026
dingo4dev pushed a commit to dingo4dev/airflow that referenced this pull request Jun 16, 2026
jason810496 pushed a commit to hkc-8010/my-airflow-repository that referenced this pull request Jul 28, 2026
…deps

TaskGroup._project_child_deps only looked at a group's own upstream_task_ids,
which stays empty for a direct group-to-group dependency (list or individual
`>>`) and for a task-level dependency that crosses into another group's entry
task. Both cases sorted the group as if it had no upstream at all.

Now also pulls in the group's upstream_group_ids and its root tasks' upstream
task ids before projecting sibling dependencies. Applied to both the
serialization-layer sort and the mirrored design-time sort in task-sdk.

closes: apache#65291

Related: apache#67964 (closed for inactivity, written against the
topological_sort implementation before PR apache#67288/apache#67688 rewrote it) and
apache#65639 (draft, same issue, also predates the rewrite).
jason810496 added a commit that referenced this pull request Jul 28, 2026
…dependencies (#69933)

* Fix grid/graph view topological sort for group-level and cross-group deps

TaskGroup._project_child_deps only looked at a group's own upstream_task_ids,
which stays empty for a direct group-to-group dependency (list or individual
`>>`) and for a task-level dependency that crosses into another group's entry
task. Both cases sorted the group as if it had no upstream at all.

Now also pulls in the group's upstream_group_ids and its root tasks' upstream
task ids before projecting sibling dependencies. Applied to both the
serialization-layer sort and the mirrored design-time sort in task-sdk.

closes: #65291

Related: #67964 (closed for inactivity, written against the
topological_sort implementation before PR #67288/#67688 rewrote it) and
#65639 (draft, same issue, also predates the rewrite).

* Address review feedback: cache get_task_group_dict, describe test intent not issue numbers

viiccwen pointed out that fetching the group map inside topological_sort() rebuilds
the whole DAG's group tree on every nested group's own call, turning a render with
G groups into an O(G^2) cost. get_task_group_dict() is now memoized per DAG instance
(kept behind a small private helper since methodtools.lru_cache has no type stubs and
would otherwise widen the public method's return type to Any for every caller).

Also reworded test comments/docstrings that cited issue numbers to describe what's
actually being verified instead.

* Hoist common logic into shared lib

* Remove caching on get_task_group_dict

* Remove stale get_task_group_dict cache tests

The cache these tests asserted was removed in the previous commit, so the
identity check and the _get_task_group_dict_cached.cache_info() assertions
no longer apply.

* Add call-level task group memo to reduce calc

* Tidy Typy

* Add test for task group memoing

---------

Co-authored-by: TP <uranusjr@apache.org>
Co-authored-by: LIU ZHE YOU <zhu424.dev@gmail.com>
pierrejeambrun pushed a commit that referenced this pull request Jul 28, 2026
…dependencies (#69933) (#70591)

* Fix grid/graph view topological sort for group-level and cross-group deps

TaskGroup._project_child_deps only looked at a group's own upstream_task_ids,
which stays empty for a direct group-to-group dependency (list or individual
`>>`) and for a task-level dependency that crosses into another group's entry
task. Both cases sorted the group as if it had no upstream at all.

Now also pulls in the group's upstream_group_ids and its root tasks' upstream
task ids before projecting sibling dependencies. Applied to both the
serialization-layer sort and the mirrored design-time sort in task-sdk.

closes: #65291

Related: #67964 (closed for inactivity, written against the
topological_sort implementation before PR #67288/#67688 rewrote it) and
#65639 (draft, same issue, also predates the rewrite).

* Address review feedback: cache get_task_group_dict, describe test intent not issue numbers

viiccwen pointed out that fetching the group map inside topological_sort() rebuilds
the whole DAG's group tree on every nested group's own call, turning a render with
G groups into an O(G^2) cost. get_task_group_dict() is now memoized per DAG instance
(kept behind a small private helper since methodtools.lru_cache has no type stubs and
would otherwise widen the public method's return type to Any for every caller).

Also reworded test comments/docstrings that cited issue numbers to describe what's
actually being verified instead.

* Hoist common logic into shared lib

* Remove caching on get_task_group_dict

* Remove stale get_task_group_dict cache tests

The cache these tests asserted was removed in the previous commit, so the
identity check and the _get_task_group_dict_cached.cache_info() assertions
no longer apply.

* Add call-level task group memo to reduce calc

* Tidy Typy

* Add test for task group memoing

---------

Co-authored-by: TP <uranusjr@apache.org>
Co-authored-by: LIU ZHE YOU <zhu424.dev@gmail.com>
(cherry picked from commit d7aa929)

# Conflicts:
#	airflow-core/src/airflow/api_fastapi/core_api/services/ui/task_group.py

Co-authored-by: Hemkumar Chheda <95332229+hkc-8010@users.noreply.github.com>
vatsrahul1001 pushed a commit that referenced this pull request Aug 5, 2026
…dependencies (#69933) (#70591)

* Fix grid/graph view topological sort for group-level and cross-group deps

TaskGroup._project_child_deps only looked at a group's own upstream_task_ids,
which stays empty for a direct group-to-group dependency (list or individual
`>>`) and for a task-level dependency that crosses into another group's entry
task. Both cases sorted the group as if it had no upstream at all.

Now also pulls in the group's upstream_group_ids and its root tasks' upstream
task ids before projecting sibling dependencies. Applied to both the
serialization-layer sort and the mirrored design-time sort in task-sdk.

closes: #65291

Related: #67964 (closed for inactivity, written against the
topological_sort implementation before PR #67288/#67688 rewrote it) and
#65639 (draft, same issue, also predates the rewrite).

* Address review feedback: cache get_task_group_dict, describe test intent not issue numbers

viiccwen pointed out that fetching the group map inside topological_sort() rebuilds
the whole DAG's group tree on every nested group's own call, turning a render with
G groups into an O(G^2) cost. get_task_group_dict() is now memoized per DAG instance
(kept behind a small private helper since methodtools.lru_cache has no type stubs and
would otherwise widen the public method's return type to Any for every caller).

Also reworded test comments/docstrings that cited issue numbers to describe what's
actually being verified instead.

* Hoist common logic into shared lib

* Remove caching on get_task_group_dict

* Remove stale get_task_group_dict cache tests

The cache these tests asserted was removed in the previous commit, so the
identity check and the _get_task_group_dict_cached.cache_info() assertions
no longer apply.

* Add call-level task group memo to reduce calc

* Tidy Typy

* Add test for task group memoing

---------

Co-authored-by: TP <uranusjr@apache.org>
Co-authored-by: LIU ZHE YOU <zhu424.dev@gmail.com>
(cherry picked from commit d7aa929)

# Conflicts:
#	airflow-core/src/airflow/api_fastapi/core_api/services/ui/task_group.py

Co-authored-by: Hemkumar Chheda <95332229+hkc-8010@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants