feat(incremental): a scheduler runs sub-circuits on a worker pool; the aggregate and grid stages placed on two units
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
This commit is contained in:
committed by
Edward Langley
parent
c2598f0c60
commit
4ba0cf196e
@@ -504,3 +504,51 @@ change log, no stems. These are the readings taken.
|
||||
- **The two-second limit decided the shape of the tests** more than the design did: the corpus
|
||||
differential over a scanning source is 2.5s untrimmed, so it follows `corpus_eager`'s trimming, and
|
||||
each random test is its own binary.
|
||||
|
||||
## Scheduling: sub-circuits on a worker pool (user ruling 2026-10-11)
|
||||
|
||||
Multi-threading here is **scheduling**. Each sub-circuit stays synchronous and single-threaded
|
||||
(`zset.md`, "Threads"), the only thing that crosses threads is an edge (`zset.md`, "Edges"), and
|
||||
a small executor decides which thread runs which sub-circuit next. This is a ruling recorded here,
|
||||
not an ADR amendment. Native threads are assumed. `sched.rs` and `staged.rs` compile for wasm32
|
||||
(a `Mutex` and a `Condvar`), and `Pool`, the only thing that spawns a thread, is
|
||||
`cfg(not(target_arch = "wasm32"))`. Nothing here runs on wasm.
|
||||
|
||||
1. **A unit is a sub-circuit with the edge ends around it** (`Unit`: `turn(budget)` and
|
||||
`neighbours()`). Placement is manual: which nodes sit in which unit is how the graph was built,
|
||||
and `staged::PLACEMENT` names it for the example. Two node-building functions (`aggregate_nodes`,
|
||||
`grid_nodes`) build the same nodes into one circuit (`single_circuit`, the reference) or into
|
||||
two.
|
||||
2. **Runnable means queued.** `Executor::kick(id)` queues a unit once. A unit is kicked by the
|
||||
host (`Staged::submit`), and by a neighbour whose turn moved a frame (`Turn::progress`, which a
|
||||
unit derives from its edges' `traffic()`). A kick reaches both directions, because a request
|
||||
flows back along an edge. Progress is a count of frames, so it ends: two neighbours cannot kick
|
||||
each other for ever.
|
||||
3. **The ownership token is the unit itself.** A claimed unit is taken out of its slot, so a second
|
||||
worker cannot reach it. A kick that arrives while it runs sets `again`, and the owner queues it
|
||||
when it hands the unit back (`finish`). No lock is held during a turn; one mutex guards the
|
||||
queue and the slots, and a turn takes microseconds beside it.
|
||||
4. **Fairness is the budget and the back of the queue.** A turn handles at most `budget` frames
|
||||
(`EdgeSource::poll(max)`), then says `more`, and `finish` queues it behind the units already
|
||||
waiting. A full edge (`Flow::Backlogged`) is also `more`: the unit retries, and coalesces
|
||||
meanwhile. The resumable `step(deadline)` predicate of `Rebuild` is for a sliced snapshot load,
|
||||
and this example has none.
|
||||
5. **The executor spawns nothing.** `claim` and `finish` are what a worker calls. `Pool` is N
|
||||
threads looping on them; the tests' scheduler calls them in an order a seed chooses, with up to N
|
||||
units held at once and the turn of a held unit already run, so a kick can land on a running
|
||||
unit. Both drive the same code.
|
||||
6. **Results do not depend on N or the order.** The deltas sum, and every operator in the graph is
|
||||
a function of its input's integral. So any schedule settles where the single circuit does
|
||||
(`placed_circuits_equal_one_circuit_for_any_workers_and_order`, 1 to 4 workers, random
|
||||
commands, random order, commands arriving while it runs; and a real pool of 1 to 4 threads).
|
||||
Per-tick outputs (`delay`, `differentiate`) would differ with coalescing, and the example has
|
||||
none.
|
||||
7. **What the example is.** `Staged`: the host's end of the first edge, an aggregate stage (sum of
|
||||
a cell's value per row and column, `aggregate`) and a grid projection (the cell each group draws,
|
||||
a total per row). The incremental backend (`Incremental`) keeps its groups in its own shapes
|
||||
and does not run on a `Circuit`, so the stage is the same computation built as zset nodes rather than a
|
||||
split of `Incremental` itself. Running `Incremental` on circuits is a later step.
|
||||
8. **Not done.** A work-stealing or priority scheme (one FIFO queue); a unit with a `Rebuild` job
|
||||
sliced by the deadline predicate; a timeout that calls `connection_lost` on the edges (the
|
||||
edges here never lose a frame); a panic in a turn (the executor's mutex poisons and the pool
|
||||
reports it).
|
||||
|
||||
@@ -363,3 +363,5 @@ Nothing here spawns a thread; whoever owns each circuit calls `pump` and `poll`.
|
||||
`Moved` correction). A pipe that blocks or notifies, because `try_send` and `try_recv` are
|
||||
polling. Key-sharded data parallelism inside a circuit: its exchange step would be this same
|
||||
edge, and nothing here forbids it.
|
||||
|
||||
11. **Progress is counted.** `EdgeSink::traffic` and `EdgeSource::traffic` count frames sent and read and requests sent and read, so a scheduler can tell that a turn moved something (`improvise-incremental.md`, "Scheduling").
|
||||
|
||||
@@ -957,3 +957,31 @@ differs).
|
||||
|
||||
`Pushdown::new` took a generic `Arc<Q>` that could not take a `SharedSource`; the test builds one from
|
||||
the alias and did not compile against the generic signature.
|
||||
|
||||
### placed_circuits_equal_one_circuit_for_any_workers_and_order
|
||||
|
||||
Random commands (twelve at most), 1 to 4 virtual workers, edge capacity 1 to 3, a budget of 1 to
|
||||
3 frames and a random order of claim and hand-back, with commands arriving between scheduler steps,
|
||||
settle to the grid of one circuit running the whole graph. Rivals, each failing it: a kick of a
|
||||
running unit queues it as well (S1, claiming a running unit panics), a kick while running is lost
|
||||
(S2), a unit that wants more goes to the front of the queue (S3, it never lets the others run), and
|
||||
progress that does not kick the neighbours (S4, the graph stalls).
|
||||
|
||||
### real_worker_threads_equal_one_circuit
|
||||
|
||||
The same graph on a real `Pool` of 1 to 4 threads, 400 commands, capacity 2, budget 3. Rivals S1
|
||||
and S4 fail it.
|
||||
|
||||
### a_unit_that_wants_more_goes_to_the_back_of_the_queue
|
||||
|
||||
Units 0 (wants two more turns), 1 and 2 run as 0, 1, 2, 0, 0. Rival S3 runs 0 again before 1.
|
||||
|
||||
### a_kick_while_a_unit_runs_queues_it_again_and_never_claims_it_twice
|
||||
|
||||
A claimed unit is not claimable; a kick during its turn leaves the executor not idle after it is
|
||||
handed back. Rivals S1 and S2 fail it.
|
||||
|
||||
### at_most_one_worker_runs_a_unit_however_often_it_is_kicked
|
||||
|
||||
Four kicking threads and four workers over one unit that asserts no two turns overlap. Rival S1
|
||||
fails it.
|
||||
|
||||
@@ -689,3 +689,8 @@ with "did not panic".
|
||||
Three hundred commands on a producer thread, a consumer on another, capacity 2, the producer
|
||||
yielding at uneven points and pumping on only two of three commands. The consumer's output equals
|
||||
the combined circuit's. Rival R1 fails it.
|
||||
|
||||
### a_poll_reads_at_most_the_frames_it_is_given
|
||||
|
||||
Three corrections waiting, `poll(c, 2)` reads two and the next reads one, so a unit's turn is
|
||||
bounded by its budget. Rival: the bound removed from the loop fails the first assertion.
|
||||
|
||||
Reference in New Issue
Block a user