|
20 | 20 |
|
21 | 21 | from cuda.bindings import nvml |
22 | 22 | from cuda.core import system |
23 | | -from cuda.core.system._async_events import _SLICE_MS, EventSetWaiting |
| 23 | +from cuda.core.system import _async_events |
| 24 | +from cuda.core.system._async_events import _MAX_WORKERS, _SLICE_MS, EventSetWaiting, _Dispatcher |
24 | 25 | from cuda.core.system.typing import EventType, SystemEventType |
25 | 26 |
|
26 | 27 | TIMEOUT = nvml.TimeoutError(nvml.Return.ERROR_TIMEOUT) |
@@ -437,3 +438,105 @@ async def main(): |
437 | 438 | await task |
438 | 439 |
|
439 | 440 | asyncio.run(main()) |
| 441 | + |
| 442 | + |
| 443 | +# ============================================================================ |
| 444 | +# Resource accounting, scheduling and cross-loop reuse |
| 445 | +# ============================================================================ |
| 446 | + |
| 447 | + |
| 448 | +def test_registration_failure_frees_the_event_set_once(monkeypatch): |
| 449 | + """N16: a failure after the native set exists must not leak or double free.""" |
| 450 | + gc.collect() # drain sets owned by earlier tests before the fake allocator |
| 451 | + created, freed = [], [] |
| 452 | + |
| 453 | + def create(): |
| 454 | + created.append(0x5150) |
| 455 | + return 0x5150 |
| 456 | + |
| 457 | + def register(*args): |
| 458 | + raise nvml.InvalidArgumentError(nvml.Return.ERROR_INVALID_ARGUMENT) |
| 459 | + |
| 460 | + monkeypatch.setattr(nvml, "event_set_create", create) |
| 461 | + monkeypatch.setattr(nvml, "event_set_free", freed.append) |
| 462 | + monkeypatch.setattr(nvml, "device_register_events", register) |
| 463 | + |
| 464 | + with pytest.raises(nvml.InvalidArgumentError): |
| 465 | + system.Device(index=0).register_events([EventType.CLOCK]) |
| 466 | + gc.collect() |
| 467 | + assert created == [0x5150] |
| 468 | + assert freed.count(0x5150) == 1, f"our event set was freed {freed.count(0x5150)} times" |
| 469 | + |
| 470 | + |
| 471 | +def test_saturated_dispatcher_serialises_slices(monkeypatch): |
| 472 | + """N21: one worker serves one slice at a time, and queueing is inside the budget.""" |
| 473 | + dispatcher = _Dispatcher(max_workers=1) |
| 474 | + monkeypatch.setattr(_async_events, "_dispatcher", dispatcher) |
| 475 | + |
| 476 | + holding = FakeWait(hold=True, deliver_at=0) |
| 477 | + queued = FakeWait(deliver_at=0) |
| 478 | + first, second = EventSetWaiting(), EventSetWaiting() |
| 479 | + |
| 480 | + async def main(): |
| 481 | + held = asyncio.create_task(first.wait_async(holding, 0)) |
| 482 | + await spin_until(lambda: len(holding.calls) == 1) |
| 483 | + waiting = asyncio.create_task(second.wait_async(queued, 400)) |
| 484 | + await asyncio.sleep(0.05) |
| 485 | + assert queued.calls == [], "a queued slice must not reach the driver out of turn" |
| 486 | + holding.release.set() |
| 487 | + await held |
| 488 | + return await waiting |
| 489 | + |
| 490 | + started = time.monotonic() |
| 491 | + assert asyncio.run(main()) is EVENT |
| 492 | + elapsed_ms = (time.monotonic() - started) * 1000 |
| 493 | + assert holding.peak_in_flight == 1, "the bound must hold" |
| 494 | + assert queued.peak_in_flight == 1 |
| 495 | + assert elapsed_ms < 400 + 4 * _SLICE_MS, f"queueing must stay inside the budget ({elapsed_ms:.0f} ms)" |
| 496 | + |
| 497 | + |
| 498 | +def test_event_set_is_reusable_across_event_loops(): |
| 499 | + """N22: serial reuse across loops is fine; a concurrent loop is rejected.""" |
| 500 | + state = EventSetWaiting() |
| 501 | + fake = FakeWait(deliver_at=0) |
| 502 | + assert asyncio.run(state.wait_async(fake, 100)) is EVENT |
| 503 | + assert asyncio.run(state.wait_async(fake, 100)) is EVENT, "a fresh loop must not see a stale future" |
| 504 | + assert state.is_waiting is False |
| 505 | + |
| 506 | + holding = FakeWait(hold=True, deliver_at=0) |
| 507 | + outcome = {} |
| 508 | + |
| 509 | + def other_loop(): |
| 510 | + async def run(): |
| 511 | + outcome["result"] = await state.wait_async(holding, 0) |
| 512 | + |
| 513 | + asyncio.run(run()) |
| 514 | + |
| 515 | + thread = threading.Thread(target=other_loop, name="other-loop") |
| 516 | + thread.start() |
| 517 | + try: |
| 518 | + while not holding.calls: |
| 519 | + time.sleep(0.001) |
| 520 | + with pytest.raises(RuntimeError, match="already in flight"): |
| 521 | + asyncio.run(state.wait_async(fake, 10)) |
| 522 | + finally: |
| 523 | + holding.release.set() |
| 524 | + thread.join() |
| 525 | + assert outcome["result"] is EVENT |
| 526 | + |
| 527 | + |
| 528 | +def test_parameter_bounds_match_the_signatures(): |
| 529 | + """N23: the type and range errors come from the annotated signatures.""" |
| 530 | + events = system.Device(index=0).register_events([EventType.CLOCK]) |
| 531 | + with pytest.raises(TypeError): |
| 532 | + events.wait(timeout_ms="soon") |
| 533 | + with pytest.raises(TypeError): |
| 534 | + asyncio.run(events.wait_async(timeout_ms="soon")) # async def converts at await time |
| 535 | + with pytest.raises(ValueError, match="timeout_ms"): |
| 536 | + asyncio.run(events.wait_async(timeout_ms=-1)) |
| 537 | + with pytest.raises(OverflowError): |
| 538 | + events.wait(timeout_ms=-1) |
| 539 | + |
| 540 | + |
| 541 | +def test_dispatcher_bound_is_finite(): |
| 542 | + assert 0 < _MAX_WORKERS <= 64 |
0 commit comments