Skip to content

Commit d916a08

Browse files
committed
Add micro-benchmarks for the task SDK's supervisor comms
Hot paths such as CommsDecoder.send/asend have no automated performance check, so a change that makes them slower is only noticed if someone measures it by hand. This adds task-sdk/benchmarks/ with pytest-codspeed benchmarks of one sync round trip, one async round trip and fifty concurrent async round trips, a "benchmarks" dependency group, and a workflow that runs them in CodSpeed's simulation mode, which counts instructions and compares each PR with main. The workflow also runs on a change of the task SDK's dependencies, keeps a run for every commit on main as CodSpeed's baseline, and stops after 15 minutes; the fake supervisor closes its socket whatever ends it, so a benchmark fails instead of hanging when a request is not what it expects.
1 parent 417a33b commit d916a08

4 files changed

Lines changed: 223 additions & 0 deletions

File tree

‎.github/workflows/benchmarks.yml‎

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
# Licensed to the Apache Software Foundation (ASF) under one
2+
# or more contributor license agreements. See the NOTICE file
3+
# distributed with this work for additional information
4+
# regarding copyright ownership. The ASF licenses this file
5+
# to you under the Apache License, Version 2.0 (the
6+
# "License"); you may not use this file except in compliance
7+
# with the License. You may obtain a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing,
12+
# software distributed under the License is distributed on an
13+
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14+
# KIND, either express or implied. See the License for the
15+
# specific language governing permissions and limitations
16+
# under the License.
17+
#
18+
---
19+
name: "Benchmarks"
20+
on: # yamllint disable-line rule:truthy
21+
push:
22+
branches: ["main"]
23+
pull_request:
24+
paths:
25+
- "task-sdk/src/airflow/sdk/execution_time/**"
26+
- "task-sdk/benchmarks/**"
27+
# A dependency bump (msgspec frames every request) changes what is measured.
28+
- "task-sdk/pyproject.toml"
29+
- "uv.lock"
30+
- ".github/workflows/benchmarks.yml"
31+
# Lets CodSpeed run the benchmarks on older commits to build its baseline.
32+
workflow_dispatch:
33+
permissions:
34+
# All other permissions are set to none by default
35+
contents: read
36+
concurrency:
37+
# A group per commit on main, so none of its runs is cancelled or left pending behind a newer one:
38+
# CodSpeed compares each PR with the run of its base commit.
39+
group: benchmarks-${{ github.event.pull_request.number || github.sha }}
40+
cancel-in-progress: true
41+
42+
jobs:
43+
44+
task-sdk:
45+
name: "Task SDK micro-benchmarks"
46+
runs-on: ubuntu-24.04
47+
timeout-minutes: 15
48+
permissions:
49+
contents: read
50+
# OpenID Connect authentication with CodSpeed, no token secret needed.
51+
id-token: write
52+
steps:
53+
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
54+
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
55+
with:
56+
persist-credentials: false
57+
58+
- name: "Install uv"
59+
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
60+
with:
61+
version-file: uv.lock
62+
63+
- name: "Install the benchmark dependencies"
64+
run: uv sync --frozen --project task-sdk --group benchmarks
65+
66+
# Simulation mode counts CPU instructions instead of timing, so results do not depend on
67+
# how busy the shared runner is; CodSpeed compares them with the base branch on each PR.
68+
- name: "Run the benchmarks"
69+
uses: CodSpeedHQ/action@373d6868929f444bc08d901fd0eb0ad52a8875ea # v5.2.1
70+
with:
71+
mode: simulation
72+
run: uv run --frozen --project task-sdk --group benchmarks pytest task-sdk/benchmarks --codspeed
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
# Licensed to the Apache Software Foundation (ASF) under one
2+
# or more contributor license agreements. See the NOTICE file
3+
# distributed with this work for additional information
4+
# regarding copyright ownership. The ASF licenses this file
5+
# to you under the Apache License, Version 2.0 (the
6+
# "License"); you may not use this file except in compliance
7+
# with the License. You may obtain a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing,
12+
# software distributed under the License is distributed on an
13+
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14+
# KIND, either express or implied. See the License for the
15+
# specific language governing permissions and limitations
16+
# under the License.
17+
"""
18+
Micro-benchmarks of the task's channel to its supervisor.
19+
20+
Every SDK call a task makes (a Variable, a Connection, an XCom, a state store key) is one round
21+
trip through ``CommsDecoder``. Async tasks make them concurrently on one event loop, and an
22+
iterated task makes them for every item, so the cost of one round trip multiplies with the
23+
number of calls. The supervisor side is a thread on the other end of a socket pair that answers
24+
each request at once, so what is measured is the decoder: framing, locking and the thread hops.
25+
"""
26+
27+
from __future__ import annotations
28+
29+
import asyncio
30+
import socket
31+
import threading
32+
from collections.abc import Iterator
33+
34+
import msgspec
35+
import pytest
36+
import structlog
37+
38+
from airflow.sdk.execution_time.comms import CommsDecoder, GetVariable, _RequestFrame, _ResponseFrame
39+
40+
41+
def _recv_exactly(sock: socket.socket, size: int) -> bytes:
42+
buffer = bytearray()
43+
while len(buffer) < size:
44+
chunk = sock.recv(size - len(buffer))
45+
if not chunk:
46+
raise EOFError
47+
buffer.extend(chunk)
48+
return bytes(buffer)
49+
50+
51+
def _answer_every_request(sock: socket.socket) -> None:
52+
"""Act as the supervisor: answer each ``GetVariable`` with its key, until the socket closes."""
53+
try:
54+
while True:
55+
length = int.from_bytes(_recv_exactly(sock, 4), "big")
56+
request = msgspec.msgpack.decode(_recv_exactly(sock, length), type=_RequestFrame)
57+
assert request.body is not None
58+
response = {"type": "VariableResult", "key": request.body["key"], "value": "v"}
59+
encoded = msgspec.msgpack.encode(_ResponseFrame(request.id, response, None))
60+
sock.sendall(len(encoded).to_bytes(4, "big") + encoded)
61+
except (EOFError, OSError):
62+
pass
63+
finally:
64+
# Whatever ends this loop, the decoder gets EOF and the benchmark fails instead of waiting forever.
65+
sock.close()
66+
67+
68+
@pytest.fixture
69+
def decoder() -> Iterator[CommsDecoder]:
70+
task_side, supervisor_side = socket.socketpair()
71+
supervisor = threading.Thread(target=_answer_every_request, args=(supervisor_side,), daemon=True)
72+
supervisor.start()
73+
yield CommsDecoder(socket=task_side, log=structlog.get_logger())
74+
task_side.close()
75+
supervisor_side.close()
76+
supervisor.join(timeout=5)
77+
78+
79+
@pytest.fixture
80+
def loop() -> Iterator[asyncio.AbstractEventLoop]:
81+
event_loop = asyncio.new_event_loop()
82+
yield event_loop
83+
event_loop.close()
84+
85+
86+
def test_send_round_trip(benchmark, decoder):
87+
"""One synchronous request and its response, as a sync task's SDK call makes it."""
88+
result = benchmark(lambda: decoder.send(GetVariable(key="key")))
89+
assert result.key == "key"
90+
91+
92+
def test_asend_round_trip(benchmark, decoder, loop):
93+
"""One asynchronous request and its response, as an async task's SDK call makes it."""
94+
result = benchmark(lambda: loop.run_until_complete(decoder.asend(GetVariable(key="key"))))
95+
assert result.key == "key"
96+
97+
98+
def test_asend_round_trips_from_concurrent_coroutines(benchmark, decoder, loop):
99+
"""Fifty coroutines each making one request on the same loop, as iterated async items do."""
100+
101+
async def fifty_concurrent_requests():
102+
return await asyncio.gather(*(decoder.asend(GetVariable(key=f"key{i}")) for i in range(50)))
103+
104+
results = benchmark(lambda: loop.run_until_complete(fifty_concurrent_requests()))
105+
assert [result.key for result in results] == [f"key{i}" for i in range(50)]

‎task-sdk/pyproject.toml‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,10 @@ dev = [
229229
"pandas>=2.2.3; python_version >=\"3.13\" and python_version <\"3.14\"",
230230
"pandas>=2.3.3; python_version >=\"3.14\""
231231
]
232+
benchmarks = [
233+
"apache-airflow-devel-common",
234+
"pytest-codspeed>=5.0.3",
235+
]
232236
docs = [
233237
"apache-airflow-devel-common[docs]",
234238
]

‎uv.lock‎

Lines changed: 42 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)