Skip to content

Commit c27ad62

Browse files
authored
fix: use non-blocking dispatch to prevent pipeline starvation (#504) (#505)
Replace blocking semaphore acquire in the dispatch loop with a non-blocking try_acquire that breaks out when the semaphore is full. This causes the outer loop to re-query the frontier, picking up newly-ready downstream tasks instead of draining a stale snapshot. Fixes #504 Made-with: Cursor
1 parent 0e90ea6 commit c27ad62

2 files changed

Lines changed: 135 additions & 3 deletions

File tree

packages/data-designer-engine/src/data_designer/engine/dataset_builders/async_scheduler.py

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,13 @@ class TrackingSemaphore(asyncio.Semaphore):
5555
def available_permits(self) -> int:
5656
return self._value # type: ignore[attr-defined]
5757

58+
def try_acquire(self) -> bool:
59+
"""Non-blocking acquire. Returns ``True`` if a permit was taken."""
60+
if self._value > 0: # type: ignore[attr-defined]
61+
self._value -= 1 # type: ignore[attr-defined]
62+
return True
63+
return False
64+
5865

5966
@dataclass
6067
class _RowGroupState:
@@ -296,8 +303,11 @@ async def _main_dispatch_loop(
296303
for t in ready
297304
if (s := self._rg_states.get(t.row_group)) is not None and s.pre_batch_done or t.column in seed_cols
298305
]
306+
semaphore_full = False
299307
for task in ready:
300-
await self._submission_semaphore.acquire()
308+
if not self._submission_semaphore.try_acquire():
309+
semaphore_full = True
310+
break
301311
self._dispatched.add(task)
302312
self._in_flight.add(task)
303313
if (s := self._rg_states.get(task.row_group)) is not None:
@@ -321,7 +331,7 @@ async def _main_dispatch_loop(
321331
if self._all_rgs_admitted:
322332
break
323333

324-
if not ready:
334+
if not ready or semaphore_full:
325335
await self._wake_event.wait()
326336

327337
async def _salvage_rounds(
@@ -400,7 +410,8 @@ async def _drain_frontier(self, seed_cols: frozenset[str], has_pre_batch: bool,
400410
if (s := self._rg_states.get(t.row_group)) is not None and s.pre_batch_done or t.column in seed_cols
401411
]
402412
for task in ready:
403-
await self._submission_semaphore.acquire()
413+
if not self._submission_semaphore.try_acquire():
414+
break
404415
self._dispatched.add(task)
405416
self._in_flight.add(task)
406417
if (s := self._rg_states.get(task.row_group)) is not None:

packages/data-designer-engine/tests/engine/dataset_builders/test_async_scheduler.py

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1430,3 +1430,124 @@ async def test_scheduler_rg_semaphore_deadlock_with_transient_failures() -> None
14301430

14311431
assert tracker.is_row_group_complete(0, 2, ["seed", "col"])
14321432
assert tracker.is_row_group_complete(1, 2, ["seed", "col"])
1433+
1434+
1435+
# -- TrackingSemaphore tests ---------------------------------------------------
1436+
1437+
1438+
def test_tracking_semaphore_try_acquire() -> None:
1439+
"""try_acquire returns True when permits are available, False when exhausted."""
1440+
from data_designer.engine.dataset_builders.async_scheduler import TrackingSemaphore
1441+
1442+
sem = TrackingSemaphore(2)
1443+
assert sem.available_permits == 2
1444+
1445+
assert sem.try_acquire() is True
1446+
assert sem.available_permits == 1
1447+
1448+
assert sem.try_acquire() is True
1449+
assert sem.available_permits == 0
1450+
1451+
assert sem.try_acquire() is False
1452+
assert sem.available_permits == 0
1453+
1454+
sem.release()
1455+
assert sem.available_permits == 1
1456+
assert sem.try_acquire() is True
1457+
assert sem.available_permits == 0
1458+
1459+
1460+
# -- Pipeline parallelism (stale dispatch fix, issue #504) ---------------------
1461+
1462+
1463+
class SlowCellGenerator(ColumnGenerator[ExpressionColumnConfig]):
1464+
"""Cell-by-cell generator with configurable async delay."""
1465+
1466+
def __init__(self, *args: Any, delay: float = 0.05, **kwargs: Any) -> None:
1467+
super().__init__(*args, **kwargs)
1468+
self._delay = delay
1469+
1470+
@staticmethod
1471+
def get_generation_strategy() -> GenerationStrategy:
1472+
return GenerationStrategy.CELL_BY_CELL
1473+
1474+
def generate(self, data: dict) -> dict:
1475+
data[self.config.name] = f"gen_{data.get('seed', '?')}"
1476+
return data
1477+
1478+
async def agenerate(self, data: dict) -> dict:
1479+
await asyncio.sleep(self._delay)
1480+
return self.generate(data)
1481+
1482+
1483+
@pytest.mark.asyncio(loop_scope="session")
1484+
async def test_scheduler_downstream_interleaves_with_upstream() -> None:
1485+
"""Downstream judge tasks begin before all upstream gen tasks complete (issue #504).
1486+
1487+
Mirrors the reported pipeline topology:
1488+
1489+
topic (sampler, instant)
1490+
├── gen_a (slow, 50ms) → judge_a (instant)
1491+
├── gen_b (slow, 50ms) → judge_b (instant)
1492+
└── gen_c (slow, 50ms) → judge_c (instant)
1493+
1494+
With a small semaphore (4) and 10 records, the 30 gen tasks (3 cols x 10 rows)
1495+
saturate the semaphore. The dispatch loop must re-query the frontier when the
1496+
semaphore is full so that judge tasks from completed gen rows are picked up
1497+
before all gen tasks finish.
1498+
"""
1499+
provider = _mock_provider()
1500+
gen_names = ["gen_a", "gen_b", "gen_c"]
1501+
judge_names = ["judge_a", "judge_b", "judge_c"]
1502+
1503+
configs = [
1504+
SamplerColumnConfig(name="topic", sampler_type=SamplerType.CATEGORY, params={"values": ["A"]}),
1505+
*[LLMTextColumnConfig(name=g, prompt="{{ topic }}", model_alias=MODEL_ALIAS) for g in gen_names],
1506+
*[
1507+
LLMTextColumnConfig(name=j, prompt=f"{{{{ {g} }}}}", model_alias=MODEL_ALIAS)
1508+
for j, g in zip(judge_names, gen_names)
1509+
],
1510+
]
1511+
all_col_names = ["topic", *gen_names, *judge_names]
1512+
strategies: dict[str, GenerationStrategy] = {"topic": GenerationStrategy.FULL_COLUMN}
1513+
strategies.update({c: GenerationStrategy.CELL_BY_CELL for c in gen_names + judge_names})
1514+
1515+
generators: dict[str, ColumnGenerator] = {
1516+
"topic": MockSeedGenerator(config=_expr_config("topic"), resource_provider=provider),
1517+
}
1518+
for g in gen_names:
1519+
generators[g] = SlowCellGenerator(config=_expr_config(g), resource_provider=provider, delay=0.05)
1520+
for j in judge_names:
1521+
generators[j] = MockCellGenerator(config=_expr_config(j), resource_provider=provider)
1522+
1523+
graph = ExecutionGraph.create(configs, strategies)
1524+
row_groups = [(0, 10)]
1525+
tracker = CompletionTracker.with_graph(graph, row_groups)
1526+
buffer_manager = RowGroupBufferManager(graph.columns)
1527+
1528+
scheduler = AsyncTaskScheduler(
1529+
generators=generators,
1530+
graph=graph,
1531+
tracker=tracker,
1532+
row_groups=row_groups,
1533+
buffer_manager=buffer_manager,
1534+
max_submitted_tasks=4,
1535+
trace=True,
1536+
)
1537+
await asyncio.wait_for(scheduler.run(), timeout=10.0)
1538+
1539+
assert tracker.is_row_group_complete(0, 10, all_col_names)
1540+
1541+
gen_traces = [t for t in scheduler.traces if t.column in gen_names]
1542+
judge_traces = [t for t in scheduler.traces if t.column in judge_names]
1543+
assert len(gen_traces) == 30 # 3 cols x 10 rows
1544+
assert len(judge_traces) == 30
1545+
1546+
last_gen_dispatched = max(t.dispatched_at for t in gen_traces)
1547+
first_judge_dispatched = min(t.dispatched_at for t in judge_traces)
1548+
1549+
assert first_judge_dispatched < last_gen_dispatched, (
1550+
"Judge tasks should begin before all gen tasks are dispatched. "
1551+
f"First judge dispatched at {first_judge_dispatched:.4f}, "
1552+
f"last gen dispatched at {last_gen_dispatched:.4f}."
1553+
)

0 commit comments

Comments
 (0)