Files
nix-estimator/tests/test_schedule.py
T

201 lines
8.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Unit tests for the pure scheduling analysis on toy DAGs."""
from nix_estimator import schedule
def test_linear_chain_has_no_parallelism():
# a -> b -> c, 1 min each: span == work, peak == 1
dur = {"a": 1.0, "b": 1.0, "c": 1.0}
preds = {"a": [], "b": ["a"], "c": ["b"]}
span, chain = schedule.critical_path(dur, preds)
assert span == 3.0
assert chain == ["a", "b", "c"]
assert schedule.work(dur) == 3.0
assert schedule.peak_concurrency(dur, preds) == 1
# more machines cannot beat the chain
assert schedule.makespan(dur, preds, 1) == 3.0
assert schedule.makespan(dur, preds, 8) == 3.0
def test_wide_fanout_parallelizes():
# root -> {l1..l4}, then sink depends on all leaves
dur = {"root": 1.0, "l1": 2.0, "l2": 2.0, "l3": 2.0, "l4": 2.0, "sink": 1.0}
preds = {"root": [], "l1": ["root"], "l2": ["root"], "l3": ["root"],
"l4": ["root"], "sink": ["l1", "l2", "l3", "l4"]}
assert schedule.work(dur) == 10.0 # 1 + 4×2 + 1
span, _ = schedule.critical_path(dur, preds)
assert span == 4.0 # root(1) + one leaf(2) + sink(1)
assert schedule.peak_concurrency(dur, preds) == 4 # 4 leaves at once
# 1 machine == total work
assert schedule.makespan(dur, preds, 1) == 10.0
# 4 machines: root, then 4 leaves in parallel (2), then sink -> 4
assert schedule.makespan(dur, preds, 4) == 4.0
# a 5th machine cannot help beyond the width
assert schedule.makespan(dur, preds, 5) == schedule.makespan(dur, preds, 4)
def test_long_pole_dominates_span():
# one 40-min derivation gates a pile of tiny ones
dur = {"onnx": 40.0, **{f"lib{i}": 1.0 for i in range(20)}, "img": 1.0}
preds = {"onnx": [], **{f"lib{i}": [] for i in range(20)},
"img": ["onnx"] + [f"lib{i}" for i in range(20)]}
span, _ = schedule.critical_path(dur, preds)
assert span == 41.0 # onnx -> img
# even with many machines, makespan is pinned by the long pole
assert schedule.makespan(dur, preds, 16) == 41.0
def test_critical_path_keeps_zero_duration_predecessor():
# Issue #9: a 0-minute node (e.g. a cached/history entry) must not be
# dropped from the reconstructed chain. z(0) -> a(1) -> b(1).
dur = {"z": 0.0, "a": 1.0, "b": 1.0}
preds = {"z": [], "a": ["z"], "b": ["a"]}
span, chain = schedule.critical_path(dur, preds)
assert span == 2.0 # 0 + 1 + 1
assert chain == ["z", "a", "b"] # full chain, z not truncated
def test_makespan_return_schedule_is_consistent():
# Issue #16: return_schedule exposes per-machine (task, start, finish) rows.
dur = {"root": 1.0, "l1": 2.0, "l2": 2.0, "l3": 2.0, "l4": 2.0, "sink": 1.0}
preds = {"root": [], "l1": ["root"], "l2": ["root"], "l3": ["root"],
"l4": ["root"], "sink": ["l1", "l2", "l3", "l4"]}
ms, sched = schedule.makespan(dur, preds, 4, return_schedule=True)
# scalar makespan matches the back-compat return
assert ms == schedule.makespan(dur, preds, 4)
assert set(sched) == {0, 1, 2, 3}
seen = []
for machine, rows in sched.items():
prev_finish = -1.0
for task, start, finish in rows:
# finish == start + dur, and nothing runs past the makespan
assert finish == start + dur[task]
assert start >= 0.0 and finish <= ms + 1e-9
# no overlap on a single machine: rows are ordered, non-overlapping
assert start >= prev_finish - 1e-9
prev_finish = finish
seen.append(task)
# every task placed exactly once
assert sorted(seen) == sorted(dur)
def test_makespan_schedule_respects_dependencies():
# A task cannot start before every predecessor has finished.
dur = {"a": 1.0, "b": 2.0, "c": 1.0}
preds = {"a": [], "b": ["a"], "c": ["b"]}
_, sched = schedule.makespan(dur, preds, 2, return_schedule=True)
starts = {task: start for rows in sched.values() for task, start, _ in rows}
finish = {task: fin for rows in sched.values() for task, _, fin in rows}
for node, ps in preds.items():
for p in ps:
assert starts[node] >= finish[p] - 1e-9
def test_max_jobs_speeds_wide_graph_of_small_drvs():
# Issue #13: many independent small builds on a single node. With one job
# slot they serialize; max_jobs>1 runs several at once -> shorter makespan.
dur = {f"n{i}": 1.0 for i in range(8)}
preds = {f"n{i}": [] for i in range(8)}
one = schedule.makespan(dur, preds, machines=1, max_jobs=1)
four = schedule.makespan(dur, preds, machines=1, max_jobs=4)
assert one == 8.0 # 8 builds serialize on one slot
assert four == 2.0 # 4 lanes -> two rounds of four
assert four < one
def test_max_jobs_lanes_exposed_in_schedule():
# machines*max_jobs distinct lanes appear in the returned schedule.
dur = {f"n{i}": 1.0 for i in range(6)}
preds = {f"n{i}": [] for i in range(6)}
_, sched = schedule.makespan(
dur, preds, machines=2, max_jobs=3, return_schedule=True
)
assert set(sched) == set(range(6)) # 2 nodes * 3 jobs = 6 lanes
def test_ram_budget_caps_concurrency():
# Issue #15: 4 job slots on one node, but only enough RAM for 2 of these
# 3-GB builds at a time -> they run two-at-a-time, doubling the makespan
# versus the RAM-unconstrained case.
dur = {f"n{i}": 1.0 for i in range(4)}
preds = {f"n{i}": [] for i in range(4)}
gb = {f"n{i}": 3.0 for i in range(4)}
free = schedule.makespan(dur, preds, 1, max_jobs=4) # no RAM cap
capped = schedule.makespan(
dur, preds, 1, max_jobs=4, gb_per_job=gb, node_ram_gb=6.0
)
assert free == 1.0 # all 4 at once
assert capped == 2.0 # 2 + 2 -> two rounds
assert capped > free
def test_ram_oversized_job_still_runs_alone():
# A single build needing more than the whole node budget must not deadlock:
# it runs alone (need clamped to the budget).
dur = {"big": 1.0, "small": 1.0}
preds = {"big": [], "small": []}
gb = {"big": 100.0, "small": 1.0}
ms = schedule.makespan(
dur, preds, 1, max_jobs=2, gb_per_job=gb, node_ram_gb=4.0
)
# big monopolises RAM -> small waits -> serialized: 2.0
assert ms == 2.0
def test_ram_schedule_no_overlap_within_budget():
# With the RAM cap the returned schedule stays consistent and never exceeds
# the node budget at any instant.
dur = {f"n{i}": 1.0 + 0.1 * i for i in range(6)}
preds = {f"n{i}": [] for i in range(6)}
gb = {f"n{i}": 2.0 for i in range(6)}
ms, sched = schedule.makespan(
dur, preds, 2, max_jobs=3, gb_per_job=gb, node_ram_gb=4.0,
return_schedule=True,
)
# reconstruct per-node concurrent RAM over time from the intervals
intervals_by_node: dict[int, list[tuple[float, float, float]]] = {}
for lane, rows in sched.items():
node = lane // 3
for task, start, finish in rows:
intervals_by_node.setdefault(node, []).append((start, finish, gb[task]))
edges = sorted({t for ivs in intervals_by_node.values()
for s, f, _ in ivs for t in (s, f)})
for node, ivs in intervals_by_node.items():
for probe in edges:
used = sum(g for s, f, g in ivs if s <= probe < f)
assert used <= 4.0 + 1e-9
def test_max_jobs_zero_rejected():
dur = {"a": 1.0}
preds = {"a": []}
try:
schedule.makespan(dur, preds, 1, max_jobs=0)
except ValueError:
pass
else:
raise AssertionError("max_jobs=0 must raise")
def test_makespan_perf_smoke_10k_nodes():
# Issue #10: heap-based dispatch must handle a large DAG quickly.
import random
import time
rng = random.Random(1234) # seeded: deterministic across runs
n = 10_000
dur = {f"n{i}": float(rng.randint(1, 5)) for i in range(n)}
preds: dict[str, list[str]] = {}
for i in range(n):
# each node depends on a few earlier nodes -> acyclic by construction
k = rng.randint(0, 3) if i else 0
preds[f"n{i}"] = [f"n{rng.randint(0, i - 1)}" for _ in range(k)] if i else []
preds[f"n{i}"] = list(dict.fromkeys(preds[f"n{i}"])) # dedupe
start = time.perf_counter()
ms = schedule.makespan(dur, preds, machines=32)
elapsed = time.perf_counter() - start
assert ms > 0.0
assert elapsed < 5.0, f"makespan too slow: {elapsed:.2f}s"