Skip to content

BUG: engine executes ready nodes sequentially — parallel dispatch broken (P1) #37

Description

@addadi

Bug: engine executes ready nodes sequentially — parallel dispatch broken

Severity: P1 — Diamond Pattern (fan-out + fan-in) doesn't get parallel speedup
Filed: 2026-07-28
Sibling: nullboiler-7mn (storage fix, closed), nullboiler-62s (reduce type), nullboiler-9x1 (sequential chain)

Symptom

When a workflow has N independent nodes (no depends_on between them), the engine dispatches them ONE AT A TIME, not concurrently. Total wall time = sum of all node durations, not max(durations).

Measured: 3-node fan-out (codebase_check 56s + noise_patterns 33s + filter_approaches 21s = 110s sequential). If parallel, wall time would be max(56s) = 56s. Nearly 2x speedup lost.

Root cause

src/engine.zig:601:

// 5. Execute ready nodes sequentially
for (ready_nodes) |node_name| {
    // executeTaskNode BLOCKS until the worker responds
    const result = self.executeTaskNode(alloc, ...);
    // apply result, mark completed
}

findReadyNodesFromRoot correctly identifies multiple ready nodes (verified: returns all 3 independent nodes). But the for-loop calls executeTaskNode which dispatches to a worker and BLOCKS waiting for the response. Only after node 1 completes does node 2 dispatch.

Infrastructure already exists for the fix

The engine already has concurrent dispatch infrastructure:

  • FetchSlot (engine.zig:290-303) — per-dispatch state with mutex + condition variable
  • fetchWorker (engine.zig:305-339) — worker thread function that calls doFetch and publishes result to slot
  • doFetchTimed (engine.zig:347-397) — creates FetchSlot, spawns thread, polls slot until result or timeout

Currently used per-node (one dispatch at a time). Fix: dispatch all ready nodes to their own FetchSlots/threads, then collect results.

Proposed fix

Replace the sequential for-loop with a two-phase pattern:

// Phase 1: dispatch all ready nodes concurrently
var slots: []FetchSlot = alloc.alloc(ready_nodes.len);
for (ready_nodes, 0..) |node_name, i| {
    slots[i] = startDispatch(alloc, node_name, ...);  // non-blocking, thread runs
}
// Phase 2: collect results sequentially (state mutation must be serial)
for (slots) |*slot| {
    const result = waitForSlot(slot);  // poll until done
    applyResult(result);               // mutate state
    markCompleted(node_name);
}

Constraints:

  • Result application stays sequential (can't mutate running_state concurrently)
  • Worker max_concurrent must be respected (if worker has max_concurrent=1, queue nodes for same worker)
  • Each FetchSlot needs its own arena allocator (already done in existing pattern)

Acceptance criteria

  • 3 independent nodes dispatch concurrently (all 3 workers receive prompts within same tick)
  • Wall time for N independent nodes ≈ max(node_durations), not sum(node_durations)
  • max_concurrent on workers respected (don't overload a single worker)
  • Sequential chain (depends_on) still works — dependent nodes wait
  • Diamond Pattern (fan-out + fan-in) gets parallel speedup on fan-out phase
  • Timeout still enforced per-node
  • Failure in one parallel node doesn't block others (they complete independently)
  • Test: submit 3 nodes with 10s sleep each → wall time < 20s (not 30s)

Evidence that this is a real problem

Experiment run aa84769e-9e5a-469d-b31e-92e98538ebae (bd_0x2c-tvax, Diamond Pattern):

  • 3 independent nodes completed at: +56s, +33s, +21s (sequential gaps prove one-at-a-time dispatch)
  • Synthesis started only after all 3 completed (correct dependency enforcement)
  • Total wall: 163s. With parallel: ~110s (synthesis still sequential after fan-out)

Complexity estimate

100-150 lines of Zig. Reuses FetchSlot + fetchWorker pattern. No new abstractions. The main change is restructuring the for-loop at engine.zig:601 into dispatch-all-then-collect pattern.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions