From 9b8c33821ff35473346f93746e13fb37947aad1a Mon Sep 17 00:00:00 2001 From: Arthur Pastel Date: Sat, 16 May 2026 15:22:02 -0700 Subject: [PATCH 1/9] Add art049 submission --- submissions/art049.py | 98 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 submissions/art049.py diff --git a/submissions/art049.py b/submissions/art049.py new file mode 100644 index 0000000..25ffa4a --- /dev/null +++ b/submissions/art049.py @@ -0,0 +1,98 @@ +"""Free-threaded build scheduler. + +Strategy: +- Spawn NUM_WORKERS threads (24, matching the eval machine's core count). + Free-threaded Python 3.14t/3.15t removes the GIL, so the CPU-bound + Target.build() calls actually run in parallel. +- Each Target keeps its own scheduling state as instance attributes + (`_my_result`, `_rem_deps`, `_dependents`, `_dep_objs`). No shared + scheduling dicts: in FT mode every shared dict carries a per-object + mutex, and a single hot one becomes the bottleneck under contention. +- A single sched_lock + Condition guards the ready deque and dep counters. + Its critical section is the per-completion decrement loop and the + remaining-target counter — tiny in walltime. +- Inline next-task fast path: when a finished target enables exactly one + successor, the same worker runs it immediately. Saves a cvwait+wakeup + pair per chain step (~50us each on macOS pthreads). +- Extras (>1 newly-ready) get pushed onto the deque. Use notify_all when + there are many; cheaper than repeated notify() once the wake count is + large enough. +""" + +from __future__ import annotations + +import threading +from collections import deque + +from graph import BuildGraph + + +NUM_WORKERS = 24 + + +def build_all(graph: BuildGraph) -> dict[str, bytes]: + targets = graph.targets + + for t in targets.values(): + t._rem_deps = len(t.deps) + t._dependents = [] + t._dep_objs = tuple(t.deps) + t._my_result = None + for t in targets.values(): + for dep in t.deps: + dep._dependents.append(t) + for t in targets.values(): + t._dependents = tuple(t._dependents) + + ready: deque = deque() + sched_lock = threading.Lock() + sched_cv = threading.Condition(sched_lock) + remaining = [len(targets)] + + for t in targets.values(): + if t._rem_deps == 0: + ready.append(t) + + def worker(): + cv = sched_cv + while True: + with cv: + while not ready and remaining[0] > 0: + cv.wait() + if remaining[0] == 0: + cv.notify_all() + return + target = ready.popleft() + + while target is not None: + dep_results = {d.name: d._my_result for d in target._dep_objs} + target._my_result = target.build(dep_results) + + next_target = None + extras_count = 0 + with cv: + for dep in target._dependents: + dep._rem_deps -= 1 + if dep._rem_deps == 0: + if next_target is None: + next_target = dep + else: + ready.append(dep) + extras_count += 1 + if extras_count >= 4: + cv.notify_all() + elif extras_count: + for _ in range(extras_count): + cv.notify() + remaining[0] -= 1 + if remaining[0] == 0: + cv.notify_all() + target = next_target + + threads = [threading.Thread(target=worker) for _ in range(NUM_WORKERS)] + for t in threads: + t.start() + for t in threads: + t.join() + + return {t.name: t._my_result for t in targets.values()} From f12845d554500bd8bf5ed0184808b2ddcb3eb278 Mon Sep 17 00:00:00 2001 From: Arthur Pastel Date: Sat, 16 May 2026 15:40:12 -0700 Subject: [PATCH 2/9] Add LPT dependents ordering and sequential fast-path --- submissions/art049.py | 44 +++++++++++++++++++++++++++++++++++++------ 1 file changed, 38 insertions(+), 6 deletions(-) diff --git a/submissions/art049.py b/submissions/art049.py index 25ffa4a..fa29ff3 100644 --- a/submissions/art049.py +++ b/submissions/art049.py @@ -2,7 +2,7 @@ Strategy: - Spawn NUM_WORKERS threads (24, matching the eval machine's core count). - Free-threaded Python 3.14t/3.15t removes the GIL, so the CPU-bound + Free-threaded Python 3.14t removes the GIL, so the CPU-bound Target.build() calls actually run in parallel. - Each Target keeps its own scheduling state as instance attributes (`_my_result`, `_rem_deps`, `_dependents`, `_dep_objs`). No shared @@ -13,10 +13,16 @@ remaining-target counter — tiny in walltime. - Inline next-task fast path: when a finished target enables exactly one successor, the same worker runs it immediately. Saves a cvwait+wakeup - pair per chain step (~50us each on macOS pthreads). -- Extras (>1 newly-ready) get pushed onto the deque. Use notify_all when - there are many; cheaper than repeated notify() once the wake count is - large enough. + pair per chain step. +- LPT ordering: dependents are pre-sorted by work descending. On wide + fanouts (e.g. diamond's expand levels), this dispatches the heaviest + task first, minimizing the per-level makespan. +- Sequential fast-path: if the graph has no parallelism (every target + has ≤1 predecessor AND ≤1 successor — chain-like), skip threading and + walk the topological order on the calling thread. Avoids thread spawn + + handoff overhead on graphs where threading can't help. +- Extras notification: notify_all when many extras become ready, else + individual notify() per added task. """ from __future__ import annotations @@ -41,8 +47,34 @@ def build_all(graph: BuildGraph) -> dict[str, bytes]: for t in targets.values(): for dep in t.deps: dep._dependents.append(t) + max_fanout = 0 + max_indeg = 0 for t in targets.values(): - t._dependents = tuple(t._dependents) + # Sort dependents by work descending: LPT heuristic. On wide + # fanouts, this makes the current worker pick the heaviest as its + # inline next_target, and other workers pop heaviest-first from + # the deque. Minimizes makespan for diamond-style join/expand. + t._dependents = tuple(sorted(t._dependents, key=lambda d: -d.work)) + if len(t._dependents) > max_fanout: + max_fanout = len(t._dependents) + if len(t._dep_objs) > max_indeg: + max_indeg = len(t._dep_objs) + + # Chain-like graphs (every target has at most one predecessor AND one + # successor) have zero parallelism; thread spawn + sched_cv handoff + # only adds overhead. Walk the topological order on the calling thread. + if max_fanout <= 1 and max_indeg <= 1: + results: dict[str, bytes] = {} + q = deque(t for t in targets.values() if t._rem_deps == 0) + while q: + t = q.popleft() + dep_results = {d.name: results[d.name] for d in t._dep_objs} + results[t.name] = t.build(dep_results) + for nxt in t._dependents: + nxt._rem_deps -= 1 + if nxt._rem_deps == 0: + q.append(nxt) + return results ready: deque = deque() sched_lock = threading.Lock() From 23a1a1bb4b1249ca4eeefea2e9eb19009c14684a Mon Sep 17 00:00:00 2001 From: Arthur Pastel Date: Sat, 16 May 2026 15:46:32 -0700 Subject: [PATCH 3/9] LPT-sort initial ready set by work descending --- submissions/art049.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/submissions/art049.py b/submissions/art049.py index fa29ff3..2cd547a 100644 --- a/submissions/art049.py +++ b/submissions/art049.py @@ -81,9 +81,9 @@ def build_all(graph: BuildGraph) -> dict[str, bytes]: sched_cv = threading.Condition(sched_lock) remaining = [len(targets)] - for t in targets.values(): - if t._rem_deps == 0: - ready.append(t) + initial = [t for t in targets.values() if t._rem_deps == 0] + initial.sort(key=lambda t: -t.work) + ready.extend(initial) def worker(): cv = sched_cv From 47bfe970e565520a1ada57e8b567481c220a40d8 Mon Sep 17 00:00:00 2001 From: Arthur Pastel Date: Sat, 16 May 2026 15:52:52 -0700 Subject: [PATCH 4/9] Skip LPT sort for single-element dependent lists --- submissions/art049.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/submissions/art049.py b/submissions/art049.py index 2cd547a..6c266c7 100644 --- a/submissions/art049.py +++ b/submissions/art049.py @@ -50,13 +50,15 @@ def build_all(graph: BuildGraph) -> dict[str, bytes]: max_fanout = 0 max_indeg = 0 for t in targets.values(): - # Sort dependents by work descending: LPT heuristic. On wide - # fanouts, this makes the current worker pick the heaviest as its - # inline next_target, and other workers pop heaviest-first from - # the deque. Minimizes makespan for diamond-style join/expand. - t._dependents = tuple(sorted(t._dependents, key=lambda d: -d.work)) - if len(t._dependents) > max_fanout: - max_fanout = len(t._dependents) + d_list = t._dependents + if len(d_list) > 1: + # LPT heuristic: pop heaviest first to minimize per-level + # makespan on wide fanouts. Skip the sort for trivial lists + # so we don't pay it on tree/chain-like graphs. + d_list.sort(key=lambda d: -d.work) + t._dependents = tuple(d_list) + if len(d_list) > max_fanout: + max_fanout = len(d_list) if len(t._dep_objs) > max_indeg: max_indeg = len(t._dep_objs) From 9dc7297a68bf699d607c4423e0a0c66a8b458682 Mon Sep 17 00:00:00 2001 From: Arthur Pastel Date: Sat, 16 May 2026 15:59:24 -0700 Subject: [PATCH 5/9] Lock-free dec for low-in-degree targets --- submissions/art049.py | 101 +++++++++++++++++++++++++----------------- 1 file changed, 61 insertions(+), 40 deletions(-) diff --git a/submissions/art049.py b/submissions/art049.py index 6c266c7..0220ec8 100644 --- a/submissions/art049.py +++ b/submissions/art049.py @@ -2,27 +2,32 @@ Strategy: - Spawn NUM_WORKERS threads (24, matching the eval machine's core count). - Free-threaded Python 3.14t removes the GIL, so the CPU-bound - Target.build() calls actually run in parallel. -- Each Target keeps its own scheduling state as instance attributes - (`_my_result`, `_rem_deps`, `_dependents`, `_dep_objs`). No shared - scheduling dicts: in FT mode every shared dict carries a per-object - mutex, and a single hot one becomes the bottleneck under contention. -- A single sched_lock + Condition guards the ready deque and dep counters. - Its critical section is the per-completion decrement loop and the - remaining-target counter — tiny in walltime. -- Inline next-task fast path: when a finished target enables exactly one - successor, the same worker runs it immediately. Saves a cvwait+wakeup - pair per chain step. -- LPT ordering: dependents are pre-sorted by work descending. On wide - fanouts (e.g. diamond's expand levels), this dispatches the heaviest - task first, minimizing the per-level makespan. -- Sequential fast-path: if the graph has no parallelism (every target - has ≤1 predecessor AND ≤1 successor — chain-like), skip threading and - walk the topological order on the calling thread. Avoids thread spawn - + handoff overhead on graphs where threading can't help. -- Extras notification: notify_all when many extras become ready, else - individual notify() per added task. + Free-threaded Python 3.14t removes the GIL, so CPU-bound Target.build() + calls actually run in parallel. +- Per-target scheduling state stored on each Target instance + (`_my_result`, `_rem_deps`, `_dependents`, `_dep_objs`, `_dec_lock`). + No shared scheduling dicts: in FT mode every shared dict carries a + per-object mutex that becomes a bottleneck under contention. +- Lock-free dep decrement for low-in-degree targets. Targets with in-deg + ≤ 1 can only ever be decremented by a single thread (their lone + predecessor), so no synchronization is needed. Only high-in-degree + targets get a `_dec_lock` (drawn from a 64-lock shard pool, so we + don't pay per-target allocation on large graphs). + - Diamond.json: 99% of targets have in-deg ≤ 1 → almost all decs are + lock-free. This is the main lever for diamond's 13.4x → ~16.8x ceiling. +- A single sched_lock + Condition still guards the ready deque, the + remaining counter, and worker wake-ups. Its critical section shrinks + to: push extras, dec remaining, notify. No dep loop inside. +- Inline next-task fast path: the heaviest newly-ready successor stays + on the same thread (no cvwait+wakeup pair per chain step). +- LPT ordering: dependents pre-sorted by work descending. The current + worker picks the heaviest as its inline next; other workers pop + heaviest-first from the deque. Minimizes makespan on wide fanouts + (e.g. diamond's expand levels). +- Sequential fast-path: chain-like graphs (in-deg ≤ 1 AND fan-out ≤ 1 + everywhere) get walked on the calling thread to skip thread-spawn + and sched_cv handoff costs. +- Extras notification: notify_all when many ready, else N×notify(). """ from __future__ import annotations @@ -34,16 +39,24 @@ NUM_WORKERS = 24 +SHARD_COUNT = 64 +_SHARD_MASK = SHARD_COUNT - 1 def build_all(graph: BuildGraph) -> dict[str, bytes]: targets = graph.targets + shard_locks = [threading.Lock() for _ in range(SHARD_COUNT)] + for t in targets.values(): t._rem_deps = len(t.deps) t._dependents = [] t._dep_objs = tuple(t.deps) t._my_result = None + # Only targets with >1 predecessor can race on their counter. + t._dec_lock = ( + shard_locks[id(t) & _SHARD_MASK] if t._rem_deps > 1 else None + ) for t in targets.values(): for dep in t.deps: dep._dependents.append(t) @@ -52,9 +65,6 @@ def build_all(graph: BuildGraph) -> dict[str, bytes]: for t in targets.values(): d_list = t._dependents if len(d_list) > 1: - # LPT heuristic: pop heaviest first to minimize per-level - # makespan on wide fanouts. Skip the sort for trivial lists - # so we don't pay it on tree/chain-like graphs. d_list.sort(key=lambda d: -d.work) t._dependents = tuple(d_list) if len(d_list) > max_fanout: @@ -62,9 +72,7 @@ def build_all(graph: BuildGraph) -> dict[str, bytes]: if len(t._dep_objs) > max_indeg: max_indeg = len(t._dep_objs) - # Chain-like graphs (every target has at most one predecessor AND one - # successor) have zero parallelism; thread spawn + sched_cv handoff - # only adds overhead. Walk the topological order on the calling thread. + # Chain-like: walk topologically on the calling thread. if max_fanout <= 1 and max_indeg <= 1: results: dict[str, bytes] = {} q = deque(t for t in targets.values() if t._rem_deps == 0) @@ -102,22 +110,35 @@ def worker(): dep_results = {d.name: d._my_result for d in target._dep_objs} target._my_result = target.build(dep_results) - next_target = None - extras_count = 0 - with cv: - for dep in target._dependents: + # Decrement dep counters outside the global cv lock when + # the dep can't race (in-deg ≤ 1). + extras = [] + for dep in target._dependents: + dl = dep._dec_lock + if dl is None: dep._rem_deps -= 1 if dep._rem_deps == 0: - if next_target is None: - next_target = dep + extras.append(dep) + else: + with dl: + dep._rem_deps -= 1 + ready_now = dep._rem_deps == 0 + if ready_now: + extras.append(dep) + + next_target = None + with cv: + if extras: + # extras is in fanout-sorted order (LPT): heaviest first. + next_target = extras[0] + if len(extras) > 1: + ready.extend(extras[1:]) + rest = len(extras) - 1 + if rest >= 4: + cv.notify_all() else: - ready.append(dep) - extras_count += 1 - if extras_count >= 4: - cv.notify_all() - elif extras_count: - for _ in range(extras_count): - cv.notify() + for _ in range(rest): + cv.notify() remaining[0] -= 1 if remaining[0] == 0: cv.notify_all() From 0fa11c3aaa9fe13bc4753d929758b029cf6f2c35 Mon Sep 17 00:00:00 2001 From: Arthur Pastel Date: Sat, 16 May 2026 16:04:46 -0700 Subject: [PATCH 6/9] Lock-free pop + split done counter from sched_cv --- submissions/art049.py | 92 ++++++++++++++++++++++--------------------- 1 file changed, 47 insertions(+), 45 deletions(-) diff --git a/submissions/art049.py b/submissions/art049.py index 0220ec8..f834060 100644 --- a/submissions/art049.py +++ b/submissions/art049.py @@ -1,33 +1,19 @@ """Free-threaded build scheduler. Strategy: -- Spawn NUM_WORKERS threads (24, matching the eval machine's core count). - Free-threaded Python 3.14t removes the GIL, so CPU-bound Target.build() - calls actually run in parallel. -- Per-target scheduling state stored on each Target instance - (`_my_result`, `_rem_deps`, `_dependents`, `_dep_objs`, `_dec_lock`). - No shared scheduling dicts: in FT mode every shared dict carries a - per-object mutex that becomes a bottleneck under contention. -- Lock-free dep decrement for low-in-degree targets. Targets with in-deg - ≤ 1 can only ever be decremented by a single thread (their lone - predecessor), so no synchronization is needed. Only high-in-degree - targets get a `_dec_lock` (drawn from a 64-lock shard pool, so we - don't pay per-target allocation on large graphs). - - Diamond.json: 99% of targets have in-deg ≤ 1 → almost all decs are - lock-free. This is the main lever for diamond's 13.4x → ~16.8x ceiling. -- A single sched_lock + Condition still guards the ready deque, the - remaining counter, and worker wake-ups. Its critical section shrinks - to: push extras, dec remaining, notify. No dep loop inside. -- Inline next-task fast path: the heaviest newly-ready successor stays - on the same thread (no cvwait+wakeup pair per chain step). -- LPT ordering: dependents pre-sorted by work descending. The current - worker picks the heaviest as its inline next; other workers pop - heaviest-first from the deque. Minimizes makespan on wide fanouts - (e.g. diamond's expand levels). -- Sequential fast-path: chain-like graphs (in-deg ≤ 1 AND fan-out ≤ 1 - everywhere) get walked on the calling thread to skip thread-spawn - and sched_cv handoff costs. -- Extras notification: notify_all when many ready, else N×notify(). +- 24 long-lived worker threads. +- Per-target state on Target instances (`_my_result`, `_rem_deps`, + `_dependents`, `_dep_objs`, `_dec_lock`). +- Lock-free dep decrement for low-in-degree targets (in-deg ≤ 1 can't + race). High-in-degree targets share a 64-lock shard pool. +- Lock-free deque popleft via best-effort attempt; fall back to + Condition.wait only when the queue is genuinely empty. +- Separate `done_lock` for the remaining counter so the sched_cv is + only acquired when we actually need to wake or be woken — most + tasks do 0 cv acquires. +- Inline next-task fast path on the heaviest newly-ready successor. +- LPT ordering of dependents (descending by work). +- Sequential fast-path for chain-like graphs. """ from __future__ import annotations @@ -53,7 +39,6 @@ def build_all(graph: BuildGraph) -> dict[str, bytes]: t._dependents = [] t._dep_objs = tuple(t.deps) t._my_result = None - # Only targets with >1 predecessor can race on their counter. t._dec_lock = ( shard_locks[id(t) & _SHARD_MASK] if t._rem_deps > 1 else None ) @@ -72,7 +57,6 @@ def build_all(graph: BuildGraph) -> dict[str, bytes]: if len(t._dep_objs) > max_indeg: max_indeg = len(t._dep_objs) - # Chain-like: walk topologically on the calling thread. if max_fanout <= 1 and max_indeg <= 1: results: dict[str, bytes] = {} q = deque(t for t in targets.values() if t._rem_deps == 0) @@ -89,7 +73,9 @@ def build_all(graph: BuildGraph) -> dict[str, bytes]: ready: deque = deque() sched_lock = threading.Lock() sched_cv = threading.Condition(sched_lock) + done_lock = threading.Lock() remaining = [len(targets)] + all_done = threading.Event() initial = [t for t in targets.values() if t._rem_deps == 0] initial.sort(key=lambda t: -t.work) @@ -98,20 +84,30 @@ def build_all(graph: BuildGraph) -> dict[str, bytes]: def worker(): cv = sched_cv while True: - with cv: - while not ready and remaining[0] > 0: - cv.wait() - if remaining[0] == 0: - cv.notify_all() - return + target = None + # Lock-free best-effort pop. deque.popleft is atomic in CPython. + try: target = ready.popleft() + except IndexError: + pass + + if target is None: + if all_done.is_set(): + return + with cv: + while not ready and not all_done.is_set(): + cv.wait() + if all_done.is_set(): + return + try: + target = ready.popleft() + except IndexError: + continue while target is not None: dep_results = {d.name: d._my_result for d in target._dep_objs} target._my_result = target.build(dep_results) - # Decrement dep counters outside the global cv lock when - # the dep can't race (in-deg ≤ 1). extras = [] for dep in target._dependents: dl = dep._dec_lock @@ -127,21 +123,27 @@ def worker(): extras.append(dep) next_target = None - with cv: - if extras: - # extras is in fanout-sorted order (LPT): heaviest first. - next_target = extras[0] - if len(extras) > 1: - ready.extend(extras[1:]) - rest = len(extras) - 1 + if extras: + next_target = extras[0] # LPT: heaviest + if len(extras) > 1: + # deque.extend is atomic; signal waiters separately. + ready.extend(extras[1:]) + rest = len(extras) - 1 + with cv: if rest >= 4: cv.notify_all() else: for _ in range(rest): cv.notify() + + with done_lock: remaining[0] -= 1 - if remaining[0] == 0: + done_now = remaining[0] == 0 + if done_now: + all_done.set() + with cv: cv.notify_all() + target = next_target threads = [threading.Thread(target=worker) for _ in range(NUM_WORKERS)] From 4222dd97d0a279c55536eaebdb91c91927e09f2d Mon Sep 17 00:00:00 2001 From: Arthur Pastel Date: Sat, 16 May 2026 16:07:08 -0700 Subject: [PATCH 7/9] Revert split-lock; keep LPT + sequential fast-path with single sched_cv --- submissions/art049.py | 111 +++++++++++++++--------------------------- 1 file changed, 40 insertions(+), 71 deletions(-) diff --git a/submissions/art049.py b/submissions/art049.py index f834060..3cb8745 100644 --- a/submissions/art049.py +++ b/submissions/art049.py @@ -1,19 +1,25 @@ """Free-threaded build scheduler. Strategy: -- 24 long-lived worker threads. -- Per-target state on Target instances (`_my_result`, `_rem_deps`, - `_dependents`, `_dep_objs`, `_dec_lock`). -- Lock-free dep decrement for low-in-degree targets (in-deg ≤ 1 can't - race). High-in-degree targets share a 64-lock shard pool. -- Lock-free deque popleft via best-effort attempt; fall back to - Condition.wait only when the queue is genuinely empty. -- Separate `done_lock` for the remaining counter so the sched_cv is - only acquired when we actually need to wake or be woken — most - tasks do 0 cv acquires. -- Inline next-task fast path on the heaviest newly-ready successor. -- LPT ordering of dependents (descending by work). -- Sequential fast-path for chain-like graphs. +- 24 long-lived worker threads. Free-threaded Python 3.14t removes the + GIL, so CPU-bound Target.build() calls actually run in parallel. +- Per-target state stored as Target instance attributes (`_my_result`, + `_rem_deps`, `_dependents`, `_dep_objs`). No shared scheduling dicts: + in FT mode each shared dict carries a per-object mutex, and a single + hot one becomes the bottleneck. +- A single sched_lock + Condition guards the ready deque, the dep + counters, and the remaining counter. Critical section per task is + just: dec deps, push extras (if any), dec remaining, notify. +- Inline next-task fast path: the first newly-ready successor (the + heaviest under LPT ordering) stays on the same thread. Saves a + cv-wait+wakeup pair per chain step. +- LPT ordering: dependents are pre-sorted by work descending so the + current worker picks the heaviest as its inline next and other + workers pop heaviest-first from the deque. Reduces makespan on wide + fanouts (e.g. diamond's expand levels). Skip the sort for trivial + lists so tree-shaped graphs don't pay it. +- Sequential fast-path: chain-like graphs (in-deg ≤ 1 AND fan-out ≤ 1) + skip thread spawn entirely and run on the calling thread. """ from __future__ import annotations @@ -25,23 +31,16 @@ NUM_WORKERS = 24 -SHARD_COUNT = 64 -_SHARD_MASK = SHARD_COUNT - 1 def build_all(graph: BuildGraph) -> dict[str, bytes]: targets = graph.targets - shard_locks = [threading.Lock() for _ in range(SHARD_COUNT)] - for t in targets.values(): t._rem_deps = len(t.deps) t._dependents = [] t._dep_objs = tuple(t.deps) t._my_result = None - t._dec_lock = ( - shard_locks[id(t) & _SHARD_MASK] if t._rem_deps > 1 else None - ) for t in targets.values(): for dep in t.deps: dep._dependents.append(t) @@ -57,6 +56,7 @@ def build_all(graph: BuildGraph) -> dict[str, bytes]: if len(t._dep_objs) > max_indeg: max_indeg = len(t._dep_objs) + # Chain-like: walk topologically on the calling thread. if max_fanout <= 1 and max_indeg <= 1: results: dict[str, bytes] = {} q = deque(t for t in targets.values() if t._rem_deps == 0) @@ -73,9 +73,7 @@ def build_all(graph: BuildGraph) -> dict[str, bytes]: ready: deque = deque() sched_lock = threading.Lock() sched_cv = threading.Condition(sched_lock) - done_lock = threading.Lock() remaining = [len(targets)] - all_done = threading.Event() initial = [t for t in targets.values() if t._rem_deps == 0] initial.sort(key=lambda t: -t.work) @@ -84,66 +82,37 @@ def build_all(graph: BuildGraph) -> dict[str, bytes]: def worker(): cv = sched_cv while True: - target = None - # Lock-free best-effort pop. deque.popleft is atomic in CPython. - try: - target = ready.popleft() - except IndexError: - pass - - if target is None: - if all_done.is_set(): + with cv: + while not ready and remaining[0] > 0: + cv.wait() + if remaining[0] == 0: + cv.notify_all() return - with cv: - while not ready and not all_done.is_set(): - cv.wait() - if all_done.is_set(): - return - try: - target = ready.popleft() - except IndexError: - continue + target = ready.popleft() while target is not None: dep_results = {d.name: d._my_result for d in target._dep_objs} target._my_result = target.build(dep_results) - extras = [] - for dep in target._dependents: - dl = dep._dec_lock - if dl is None: + next_target = None + extras_count = 0 + with cv: + for dep in target._dependents: dep._rem_deps -= 1 if dep._rem_deps == 0: - extras.append(dep) - else: - with dl: - dep._rem_deps -= 1 - ready_now = dep._rem_deps == 0 - if ready_now: - extras.append(dep) - - next_target = None - if extras: - next_target = extras[0] # LPT: heaviest - if len(extras) > 1: - # deque.extend is atomic; signal waiters separately. - ready.extend(extras[1:]) - rest = len(extras) - 1 - with cv: - if rest >= 4: - cv.notify_all() + if next_target is None: + next_target = dep else: - for _ in range(rest): - cv.notify() - - with done_lock: + ready.append(dep) + extras_count += 1 + if extras_count >= 4: + cv.notify_all() + elif extras_count: + for _ in range(extras_count): + cv.notify() remaining[0] -= 1 - done_now = remaining[0] == 0 - if done_now: - all_done.set() - with cv: + if remaining[0] == 0: cv.notify_all() - target = next_target threads = [threading.Thread(target=worker) for _ in range(NUM_WORKERS)] From 9dc1eda85fdde1673e3b6d97d543110a68ab7a9f Mon Sep 17 00:00:00 2001 From: Arthur Pastel Date: Sat, 16 May 2026 16:11:01 -0700 Subject: [PATCH 8/9] Lazy LPT sort; skip dependents-tuple conversion to cut setup overhead --- submissions/art049.py | 72 +++++++++++++++++++++++-------------------- 1 file changed, 38 insertions(+), 34 deletions(-) diff --git a/submissions/art049.py b/submissions/art049.py index 3cb8745..3f2b5ff 100644 --- a/submissions/art049.py +++ b/submissions/art049.py @@ -3,23 +3,20 @@ Strategy: - 24 long-lived worker threads. Free-threaded Python 3.14t removes the GIL, so CPU-bound Target.build() calls actually run in parallel. -- Per-target state stored as Target instance attributes (`_my_result`, - `_rem_deps`, `_dependents`, `_dep_objs`). No shared scheduling dicts: - in FT mode each shared dict carries a per-object mutex, and a single - hot one becomes the bottleneck. -- A single sched_lock + Condition guards the ready deque, the dep - counters, and the remaining counter. Critical section per task is - just: dec deps, push extras (if any), dec remaining, notify. -- Inline next-task fast path: the first newly-ready successor (the - heaviest under LPT ordering) stays on the same thread. Saves a - cv-wait+wakeup pair per chain step. -- LPT ordering: dependents are pre-sorted by work descending so the - current worker picks the heaviest as its inline next and other - workers pop heaviest-first from the deque. Reduces makespan on wide - fanouts (e.g. diamond's expand levels). Skip the sort for trivial - lists so tree-shaped graphs don't pay it. -- Sequential fast-path: chain-like graphs (in-deg ≤ 1 AND fan-out ≤ 1) - skip thread spawn entirely and run on the calling thread. +- Per-target state on Target instances (`_my_result`, `_rem_deps`, + `_dependents`, `_dep_objs`). No shared scheduling dicts. +- A single sched_lock + Condition. Critical section per task: dep + decrements, push extras (if any), dec remaining, notify. +- Inline next-task fast path: the first newly-ready successor stays + on the same thread. Saves a cv-wait+wakeup pair per chain step. +- LPT ordering of dependents (descending by work) — sorted lazily, + only for targets that actually have multiple dependents. Helps wide + fanouts (e.g. diamond's expand levels). +- Sequential fast-path for chain-like graphs (every target has + in-deg ≤ 1 AND fan-out ≤ 1): walk on the calling thread. +- Setup keeps a tight loop: build dependents lists once, track whether + any high-fanout / high-indeg target exists, and only sort when there + is something to sort. Tree-shaped graphs skip the sort pass entirely. """ from __future__ import annotations @@ -36,28 +33,26 @@ def build_all(graph: BuildGraph) -> dict[str, bytes]: targets = graph.targets + any_high_indeg = False for t in targets.values(): t._rem_deps = len(t.deps) t._dependents = [] - t._dep_objs = tuple(t.deps) + t._dep_objs = t.deps t._my_result = None + if t._rem_deps > 1: + any_high_indeg = True + + any_high_fanout = False for t in targets.values(): for dep in t.deps: - dep._dependents.append(t) - max_fanout = 0 - max_indeg = 0 - for t in targets.values(): - d_list = t._dependents - if len(d_list) > 1: - d_list.sort(key=lambda d: -d.work) - t._dependents = tuple(d_list) - if len(d_list) > max_fanout: - max_fanout = len(d_list) - if len(t._dep_objs) > max_indeg: - max_indeg = len(t._dep_objs) - - # Chain-like: walk topologically on the calling thread. - if max_fanout <= 1 and max_indeg <= 1: + d_dep = dep._dependents + d_dep.append(t) + if len(d_dep) > 1: + any_high_fanout = True + + # Chain-like graphs (every target has in-deg ≤ 1 AND fan-out ≤ 1) + # have zero parallelism; skip threading entirely. + if not any_high_indeg and not any_high_fanout: results: dict[str, bytes] = {} q = deque(t for t in targets.values() if t._rem_deps == 0) while q: @@ -70,13 +65,22 @@ def build_all(graph: BuildGraph) -> dict[str, bytes]: q.append(nxt) return results + # LPT-sort only the dependents lists that actually have >1 entries. + # Tree-shaped graphs (max fan-out = 1) skip this entire pass. + if any_high_fanout: + for t in targets.values(): + d_list = t._dependents + if len(d_list) > 1: + d_list.sort(key=lambda d: -d.work) + ready: deque = deque() sched_lock = threading.Lock() sched_cv = threading.Condition(sched_lock) remaining = [len(targets)] initial = [t for t in targets.values() if t._rem_deps == 0] - initial.sort(key=lambda t: -t.work) + if len(initial) > 1: + initial.sort(key=lambda t: -t.work) ready.extend(initial) def worker(): From ddb9537d9cef8ed1928991b8515f734515dd9b6f Mon Sep 17 00:00:00 2001 From: Arthur Pastel Date: Sat, 16 May 2026 16:13:21 -0700 Subject: [PATCH 9/9] Drop redundant _my_result init and _dep_objs alias --- submissions/art049.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/submissions/art049.py b/submissions/art049.py index 3f2b5ff..8a143a4 100644 --- a/submissions/art049.py +++ b/submissions/art049.py @@ -37,8 +37,6 @@ def build_all(graph: BuildGraph) -> dict[str, bytes]: for t in targets.values(): t._rem_deps = len(t.deps) t._dependents = [] - t._dep_objs = t.deps - t._my_result = None if t._rem_deps > 1: any_high_indeg = True @@ -57,7 +55,7 @@ def build_all(graph: BuildGraph) -> dict[str, bytes]: q = deque(t for t in targets.values() if t._rem_deps == 0) while q: t = q.popleft() - dep_results = {d.name: results[d.name] for d in t._dep_objs} + dep_results = {d.name: results[d.name] for d in t.deps} results[t.name] = t.build(dep_results) for nxt in t._dependents: nxt._rem_deps -= 1 @@ -95,7 +93,7 @@ def worker(): target = ready.popleft() while target is not None: - dep_results = {d.name: d._my_result for d in target._dep_objs} + dep_results = {d.name: d._my_result for d in target.deps} target._my_result = target.build(dep_results) next_target = None