Skip to content

Commit 46cf1ca

Browse files
justinvjosephfacebook-github-bot
authored andcommitted
Add ResizableSemaphore for adaptive pipeline concurrency
Summary: Add `ResizableSemaphore` — an asyncio-compatible semaphore whose max permit count can be adjusted at runtime. This is the foundational primitive for dynamic concurrency control in SPDL pipelines. Key features: - `resize(new_max)` adjusts permits at runtime; resize-up wakes blocked waiters immediately, resize-down drains gracefully (no preemption) - `acquire()`/`release()` semantics match `asyncio.Semaphore` - `max_value` and `active` properties for observability - Thread-safe within asyncio's single-threaded model - Comprehensive error handling for invalid values This is Diff 1 of a 5-diff series implementing a unified adaptive scheduler for SPDL pipelines (T262755626). Differential Revision: D99920401
1 parent 125f3a0 commit 46cf1ca

3 files changed

Lines changed: 945 additions & 0 deletions

File tree

src/spdl/pipeline/_components/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
set_default_queue_class,
2121
StatsQueue,
2222
)
23+
from ._semaphore import ResizableSemaphore
2324

2425
__all__ = [
2526
"_build_pipeline_coro",
@@ -30,6 +31,7 @@
3031
"is_eof",
3132
"is_epoch_end",
3233
"PipelineFailure",
34+
"ResizableSemaphore",
3335
"set_default_hook_class",
3436
"set_default_queue_class",
3537
"TaskHook",
Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
# Copyright (c) Meta Platforms, Inc. and affiliates.
2+
# All rights reserved.
3+
#
4+
# This source code is licensed under the BSD-style license found in the
5+
# LICENSE file in the root directory of this source tree.
6+
7+
# pyre-strict
8+
9+
"""Resizable asyncio semaphore for dynamic concurrency control."""
10+
11+
__all__ = ["ResizableSemaphore"]
12+
13+
import asyncio
14+
from collections import deque
15+
16+
17+
class ResizableSemaphore:
18+
"""asyncio.Semaphore variant whose max value can be changed at runtime.
19+
20+
Thread safety: asyncio is single-threaded per event loop. All methods
21+
MUST be called from coroutines in the same event loop. No locks needed.
22+
23+
Resize semantics:
24+
- Increase: immediately wake up to ``new_max - old_max`` blocked
25+
waiters.
26+
- Decrease: no preemption. Currently acquired permits continue.
27+
New ``acquire()`` calls block until active count drops below
28+
the new max. ``_current_value`` may go negative during drain.
29+
30+
Invariant: at any moment, the number of "active" (acquired but not
31+
yet released) permits equals ``max_value - _current_value``. When
32+
``_current_value`` is negative, more permits are outstanding than
33+
the current max allows -- they drain naturally as tasks
34+
``release()``.
35+
"""
36+
37+
def __init__(self, value: int) -> None:
38+
"""Create a semaphore with *value* initial permits.
39+
40+
Args:
41+
value: Initial max permits. Must be >= 1.
42+
43+
Raises:
44+
ValueError: If value < 1.
45+
"""
46+
if value < 1:
47+
raise ValueError(f"value must be >= 1, got {value}")
48+
self._max_value: int = value
49+
self._current_value: int = value
50+
self._waiters: deque[asyncio.Future[None]] = deque()
51+
52+
@property
53+
def max_value(self) -> int:
54+
"""Current max permits (may differ from initial after resize)."""
55+
return self._max_value
56+
57+
@property
58+
def active(self) -> int:
59+
"""Number of currently acquired (outstanding) permits.
60+
61+
Can exceed ``max_value`` temporarily after a resize-down.
62+
"""
63+
return self._max_value - self._current_value
64+
65+
async def acquire(self) -> None:
66+
"""Acquire one permit. Blocks if no permits available.
67+
68+
Raises:
69+
asyncio.CancelledError: If the waiting coroutine is
70+
cancelled while blocked.
71+
"""
72+
# Fast path: permit available and no one queued ahead of us.
73+
if self._current_value > 0 and not self._waiters:
74+
self._current_value -= 1
75+
return
76+
77+
fut: asyncio.Future[None] = asyncio.get_running_loop().create_future()
78+
self._waiters.append(fut)
79+
try:
80+
await fut
81+
except asyncio.CancelledError:
82+
# PERMIT-LEAK FIX (V5.1):
83+
# Three states are possible at this point:
84+
# (a) fut not done: nobody granted us a permit yet. Just
85+
# remove from the queue.
86+
# (b) fut done with result: release()/resize() handed us a
87+
# permit (direct hand-off — no _current_value increment
88+
# happened). We are about to NOT enter the critical
89+
# section, so we MUST give the permit back. Call
90+
# release() to wake the next waiter (or restore the
91+
# permit to the pool if no waiters remain).
92+
# (c) fut already cancelled before we entered the await:
93+
# same shape as (a) — `fut in self._waiters` is True
94+
# and `self._waiters.remove(fut)` covers it. No permit
95+
# was granted, so nothing to give back.
96+
if fut in self._waiters:
97+
# Case (a) or (c): pre-grant cancellation. No permit
98+
# was handed off, so nothing to release.
99+
self._waiters.remove(fut)
100+
elif fut.done() and not fut.cancelled() and fut.exception() is None:
101+
# Case (b): post-grant cancellation. release() handed us
102+
# a permit via set_result(None) but we're not going to
103+
# use it. Hand it back so the next waiter (or the pool)
104+
# gets it.
105+
self.release()
106+
raise
107+
# Granted via direct hand-off from release()/resize().
108+
# _current_value was NOT decremented (the permit transferred in
109+
# flight from the previous holder), so we are already accounted
110+
# for as "active".
111+
112+
def release(self) -> None:
113+
"""Return a permit. Wakes one blocked waiter if any.
114+
115+
Direct hand-off semantics: when waiters are queued, the permit
116+
transfers from the releaser to the next non-cancelled waiter
117+
without round-tripping through ``_current_value``. This avoids
118+
a window where two concurrent callers could observe
119+
``_current_value > 0`` between waiter-pop and decrement.
120+
121+
After a resize-down, when no waiters remain, released permits
122+
that would push ``_current_value`` above ``max_value`` are
123+
absorbed (clamped). This is correct: the permit belonged to
124+
the old, larger max.
125+
"""
126+
# Try to hand the permit directly to the next non-cancelled
127+
# waiter. The skip-loop drains cancelled waiters whose futures
128+
# are already done() — protecting against the release/cancel
129+
# race where a waiter is cancelled mid-iteration.
130+
while self._waiters:
131+
# pyre-ignore[1001]: Future is granted via set_result(), not awaited.
132+
waiter = self._waiters.popleft()
133+
if not waiter.done():
134+
# Permit transfers atomically: stays "in flight" with
135+
# the new owner. Do NOT touch _current_value.
136+
waiter.set_result(None)
137+
return
138+
# No live waiters; restore one permit to the pool (clamped to
139+
# the current max so resize-down clamps don't drift over).
140+
self._current_value = min(self._current_value + 1, self._max_value)
141+
142+
def resize(self, new_max: int) -> None:
143+
"""Change the maximum number of permits.
144+
145+
Args:
146+
new_max: New maximum. Must be >= 1.
147+
148+
When increasing (``new_max > old_max``):
149+
Additional permits become immediately available. Blocked
150+
waiters are woken (via direct hand-off) to fill the new
151+
capacity. Any leftover permits go to the pool, clamped to
152+
the new max.
153+
154+
When decreasing (``new_max < old_max``):
155+
No preemption -- currently active tasks continue.
156+
``_current_value`` is reduced by the delta, which may make
157+
it negative. Future ``acquire()`` calls block until enough
158+
releases bring ``_current_value`` back above 0.
159+
160+
Raises:
161+
ValueError: If new_max < 1.
162+
"""
163+
if new_max < 1:
164+
raise ValueError(f"new_max must be >= 1, got {new_max}")
165+
delta = new_max - self._max_value
166+
self._max_value = new_max
167+
if delta > 0:
168+
# Increase: hand `delta` permits directly to waiters first.
169+
# Skip cancelled waiters (their futures are already done()).
170+
granted = 0
171+
while self._waiters and granted < delta:
172+
# pyre-ignore[1001]: Future is granted via set_result(), not awaited.
173+
waiter = self._waiters.popleft()
174+
if not waiter.done():
175+
# Direct hand-off: permit transfers in flight; do
176+
# NOT touch _current_value here.
177+
waiter.set_result(None)
178+
granted += 1
179+
# Any permits not handed off go to the pool, clamped to max.
180+
leftover = delta - granted
181+
if leftover > 0:
182+
self._current_value = min(
183+
self._current_value + leftover,
184+
self._max_value,
185+
)
186+
elif delta < 0:
187+
# Decrease: subtract from available pool (may go negative).
188+
# delta is already negative.
189+
self._current_value += delta

0 commit comments

Comments
 (0)