ColtenOuO opened a new pull request, #71203:
URL: https://github.com/apache/airflow/pull/71203

   ### Summary
   
   `partial_subset` walks the downstream relatives of every matched task, and 
for each one
   asks whether that relative is itself among the matched tasks:
   
   ```python
   matched_tasks = [t for t in self.tasks if t.task_id in task_ids]   # a list
   
   for t in matched_tasks:
       if include_downstream:
           for rel in t.get_flat_relatives(upstream=False, depth=depth):
               also_include_ids.add(rel.task_id)
               if rel not in matched_tasks:   # linear scan, inside a nested 
loop
   ```
   
   `matched_tasks` is a list because `SerializedBaseOperator` sets `__hash__ = 
None`, so the
   operators cannot go in a set at all. Its `__eq__` returns `NotImplemented`, 
which makes
   `in` fall back to an identity scan — cheap per comparison, but still 
`O(len(matched_tasks))`
   per relative.
   
   That puts the check at `O(matched x relatives)`. Both factors grow with the 
Dag, so on a Dag
   whose 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_relatives` 
yields are the very
   objects `matched_tasks` holds, so the identity scan and a set lookup on 
`task_id` answer the
   same 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
   
   <img width="1348" height="320" alt="image" 
src="https://github.com/user-attachments/assets/9742fcac-e2e2-4c1b-8fea-8419929d4fdd";
 />
   
   Every task reaches every later one, so relatives grow as $\frac{N(N-1)}{2}$. 
This is the ceiling.
   
   | tasks | relatives | before | after | speedup |
   |---|---|---|---|---|
   | 100 | 4,950 | 0.028s | 0.009s | 3.1x |
   | 200 | 19,900 | 0.175s | 0.027s | 6.5x |
   | 400 | 79,800 | **1.335s** | **0.096s** | **13.9x** |
   
   Doubling the task count multiplies the old timing by ~7.6 and the new one by 
~3.6 —
   cubic against quadratic.
   
   ---
   
   #### Layered
   
   <img width="769" height="468" alt="image" 
src="https://github.com/user-attachments/assets/b8ec10bd-6ad9-40cb-827a-70b6e6b30d6e";
 />
   
   Width does not help on its own. Connecting each layer fully to the next 
still lets every
   task reach everything downstream of it, so relatives stay near 
$\frac{N^2}{2}$ and the timings
   land almost on top of the chain.
   
   | tasks | relatives | before | after | speedup |
   |---|---|---|---|---|
   | 100 | 4,000 | 0.024s | 0.008s | 3.0x |
   | 200 | 18,000 | 0.170s | 0.027s | 6.3x |
   | 400 | 76,000 | **1.312s** | **0.116s** | **11.3x** |
   
   ---
   
   #### Parallel chains
   
   <img width="558" height="475" alt="image" 
src="https://github.com/user-attachments/assets/50ef67f5-3c4d-49dc-87d2-42ef11535a29";
 />
   
   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.
   
   | tasks | relatives | before | after | speedup |
   |---|---|---|---|---|
   | 100 | 450 | 0.005s | 0.003s | 1.7x |
   | 200 | 1,900 | 0.024s | 0.008s | 3.0x |
   | 400 | 7,800 | 0.147s | 0.022s | **6.7x** |
   
   ---
   
   #### Fan-out
   
   <img width="452" height="511" alt="image" 
src="https://github.com/user-attachments/assets/1ed98cc7-0537-49c0-8aa8-7266942c3c71";
 />
   
   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.
   
   | tasks | relatives | before | after | speedup |
   |---|---|---|---|---|
   | 100 | 99 | 0.003s | 0.003s | 1.0x |
   | 200 | 199 | 0.007s | 0.006s | 1.2x |
   | 400 | 399 | 0.022s | 0.012s | 1.8x |
   
   #### 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.
   
   
   <details>
   <summary>Benchmark script</summary>
   
   ```python
   from __future__ import annotations
   
   import time
   
   import pendulum
   
   from airflow.providers.standard.operators.empty import EmptyOperator
   from airflow.sdk import DAG
   from airflow.serialization.serialized_objects import DagSerialization
   
   
   def _serialize(dag):
       return DagSerialization.from_dict(DagSerialization.to_dict(dag))
   
   
   def build_chain(n: int):
       """t0 -> t1 -> ... -> tn. Every task reaches every later one."""
       with DAG("bench", start_date=pendulum.datetime(2024, 1, 1), 
schedule=None) as dag:
           previous = None
           for i in range(n):
               task = EmptyOperator(task_id=f"t{i:04d}")
               if previous is not None:
                   previous >> task
               previous = task
       return _serialize(dag)
   
   
   def build_parallel_chains(n: int, streams: int = 10):
       """`streams` independent chains side by side."""
       with DAG("bench", start_date=pendulum.datetime(2024, 1, 1), 
schedule=None) as dag:
           tails: dict[int, object] = {}
           for i in range(n):
               task = EmptyOperator(task_id=f"t{i:04d}")
               stream = i % streams
               if (previous := tails.get(stream)) is not None:
                   previous >> task
               tails[stream] = task
       return _serialize(dag)
   
   
   def build_layered(n: int, width: int = 20):
       """Layers of `width` tasks, each fully connected to the next layer."""
       with DAG("bench", start_date=pendulum.datetime(2024, 1, 1), 
schedule=None) as dag:
           tasks = [EmptyOperator(task_id=f"t{i:04d}") for i in range(n)]
           for start in range(0, n - width, width):
               for upstream in tasks[start : start + width]:
                   for downstream in tasks[start + width : start + 2 * width]:
                       upstream >> downstream
       return _serialize(dag)
   
   
   def build_fanout(n: int):
       """One root feeding every other task -- the shallow extreme."""
       with DAG("bench", start_date=pendulum.datetime(2024, 1, 1), 
schedule=None) as dag:
           root = EmptyOperator(task_id="root")
           for i in range(n - 1):
               root >> EmptyOperator(task_id=f"t{i:04d}")
       return _serialize(dag)
   
   
   SHAPES = [
       ("chain", build_chain),
       ("parallel chains x10", build_parallel_chains),
       ("layered (w=20)", build_layered),
       ("fan-out", build_fanout),
   ]
   
   
   def timed(dag, task_ids) -> float:
       started = time.monotonic()
       dag.partial_subset(task_ids=task_ids, include_downstream=True, 
include_upstream=False)
       return time.monotonic() - started
   
   
   print(f"{'shape':<16} {'tasks':>6} {'relatives':>11} {'partial_subset':>16}")
   for label, build in SHAPES:
       for n in (100, 200, 400):
           dag = build(n)
           task_ids = {t.task_id for t in dag.tasks}
           relatives = sum(len(t.get_flat_relatives(upstream=False, 
depth=None)) for t in dag.tasks)
           best = min(timed(dag, task_ids) for _ in range(3))
           print(f"{label:<16} {n:>6} {relatives:>11,} {best:>15.3f}s")
       print()
   ```
   
   </details>
   
   ### Result
   
   | shape | relatives at N=400 | speedup at N=100 | at N=200 | at N=400 |
   |---|---|---|---|---|
   | chain | 79,800 | 3.1x | 6.5x | **13.9x** |
   | layered (width 20) | 76,000 | 3.0x | 6.3x | **11.3x** |
   | parallel chains x10 | 7,800 | 1.7x | 3.0x | **6.7x** |
   | fan-out | 399 | 1.0x | 1.2x | **1.8x** |
   


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to