Speed up clearing downstream tasks on large Dags - #71203
Conversation
partial_subset decided whether each downstream relative was already among the matched tasks by scanning a list, once per relative. Both the list and the relative count grow with the Dag, so the check cost O(matched x relatives) -- cubic in the task count for a Dag whose tasks mostly reach one another, which is exactly what clearing with downstream on a deep Dag looks like. Task ids are unique within a Dag, and the relatives are the same objects the matched list already holds, so the identity scan answers the same question as a constant-time lookup against a set of those ids.
jason810496
left a comment
There was a problem hiding this comment.
Thanks for the improvement.
Why not make the existing matched_tasks a set (which is still a hash map under the hook with constant time lookup)?
By this way, we can avoid the additional memory usage.
…list Keeping both a list of matched task objects and a parallel set of their ids duplicated the same information in memory for no benefit. Task objects can't go in a set directly (SerializedBaseOperator sets __hash__ = None), but everywhere the objects are actually needed they can be looked up from task_dict, same as the existing also_include and direct_upstreams code already does. Addresses review feedback on apache#71203.
|
It looks like we might run into an issue using a set here. Since SerializedBaseOperator has As a workaround, we could take a different approach: rely on I've changed the code to this approach. Let me know if this is what you were looking for >< thanks again! |
Summary
partial_subsetwalks the downstream relatives of every matched task, and for each oneasks whether that relative is itself among the matched tasks:
matched_tasksis a list becauseSerializedBaseOperatorsets__hash__ = None, so theoperators cannot go in a set at all. Its
__eq__returnsNotImplemented, which makesinfall back to an identity scan — cheap per comparison, but stillO(len(matched_tasks))per relative.
That puts the check at
O(matched x relatives). Both factors grow with the Dag, so on a Dagwhose tasks mostly reach one another the whole call goes cubic in the task count.
Task ids are unique within a Dag, and the relatives
get_flat_relativesyields are the veryobjects
matched_tasksholds, so the identity scan and a set lookup ontask_idanswer thesame question. The set lookup is constant time, dropping the check to
O(matched+ relatives).Although it seems like a small change, I looked into it further and gathered some data. Here's what I found:
What drives the cost
The improvement tracks the number of downstream relatives, which is set by the Dag's
depth, not its task count. Four shapes, each measured at the same three task counts.
Chain
Every task reaches every later one, so relatives grow as$\frac{N(N-1)}{2}$ . This is the ceiling.
Doubling the task count multiplies the old timing by ~7.6 and the new one by ~3.6 —
cubic against quadratic.
Layered
Width does not help on its own. Connecting each layer fully to the next still lets every$\frac{N^2}{2}$ and the timings
task reach everything downstream of it, so relatives stay near
land almost on top of the chain.
Parallel chains
Splitting one Dag into$K$ independent pipelines divides the relatives by $K$ — several
unrelated flows sharing a Dag file is a common shape, and it still gains meaningfully.
Fan-out
The control. One task has relatives and the rest have none, so there is almost no
scanning to remove and the change should do nothing — which is what it does.
Reading the four together
The speedup ranks exactly with the relative count — 79,800 → 13.9x, 76,000 → 11.3x,
7,800 → 6.7x, 399 → 1.8x — and within each shape the ratio doubles as the task count
doubles: 3.1x, 6.5x, 13.9x on the chain. That is what one extra linear factor looks like,
and the flat fan-out row is the check that the gain is coming from the scan rather than
from the benchmark.
Benchmark script
Result