jm

Odin for scripts: small packages and a runner, on core: only
Log | Files | Refs | README

commit 08b3ecbe7ebc37c4ecfbbcaab7d11b7a0e240490
parent 84ba2311339b9b36decc453e70010e7586a90e97
Author: Jack Mordaunt <jackmordaunt.dev@gmail.com>
Date:   Wed, 23 Sep 2026 21:30:43 -0300

flow: add the worker-pool package from sonar

Work that splits into independent items wants many threads without the
locks that make threads hard. Every shape here obeys one rule: a worker
owns one slot of the caller's states and never shares it, and the caller
merges the slots once the run is over. width says how many workers a
piece of work deserves, each claims items from a shared counter until
they run out, and manage does the same for work that discovers more of
itself as it goes, with a manager that queues what each finished item
reveals. The package is the copy sonar now imports; the tests cover
every item reached once, early stops, empty input and failed items being
requeued.

Diffstat:
Aflow/flow.odin | 152+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aflow/flow_test.odin | 193+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aflow/manage.odin | 147+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aflow/manage_test.odin | 214+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
4 files changed, 706 insertions(+), 0 deletions(-)

diff --git a/flow/flow.odin b/flow/flow.odin @@ -0,0 +1,152 @@ +/* +Package flow holds concurrency shapes for work that splits into independent items. + +Every shape obeys one rule: a worker owns its state and never shares it. Locks are +then unnecessary, and the caller merges the states once the run is over. + + width how many workers a piece of work deserves + each claim items from a shared counter until they run out + manage the same, for work that discovers more of itself as it goes +*/ +package flow + +import "core:os" +import "core:sync" +import "core:thread" + +/* +Run `work` over every item, across workers that claim one item at a time. + +Each worker owns one slot of `states`, so `work` needs no locks. Merge the slots +afterwards, tolerating untouched ones. Anything else a worker touches must be read +only, or written at disjoint addresses. + +Returning false stops the run; claimed items still finish. + +`len(states)` sets the width, capped by `width(len(items), load)`; pass the `load` +the slice was sized with. One slot runs inline. Workers get a fresh context, so any +allocator the work needs belongs in `State`. + +An item should cost more than the thirty microseconds it takes to start a thread. +*/ +each :: proc( + items: []$I, + states: []$S, + work: proc(item: I, state: ^S) -> bool, + load := Load.Mixed, +) { + if len(items) == 0 || len(states) == 0 { + return + } + + shared := Run(I, S) { + items = items, + states = states, + work = work, + } + + pool := min(len(states), width(len(items), load)) + + if pool == 1 { + claim_loop(&shared, 0) + return + } + + threads := make([]^thread.Thread, pool - 1, context.temp_allocator) + defer delete(threads, context.temp_allocator) + + started := 0 + for i in 0 ..< len(threads) { + t := thread.create_and_start_with_poly_data(Arg(I, S){&shared, i + 1}, worker_entry) + if t == nil { + break + } + threads[i] = t + started += 1 + } + + // The calling thread takes slot 0 rather than idling while the others work. + claim_loop(&shared, 0) + + thread.join_multiple(..threads[:started]) + for t in threads[:started] { + thread.destroy(t) + } +} + +// How a piece of work divides between waiting and computing, which is the part of +// the width decision that only the caller knows. +Load :: enum { + Cpu, // computing throughout: more workers than cores only makes them compete + Mixed, // alternates between the two, the common case + Io, // mostly parked in a device call, using no core while it waits +} + +/* +How many workers `items` pieces of work deserve. Size a state slice with it. + +Computing work wants one worker per core; work parked in a device call wants several +times that, since it holds no core while it waits. `load` picks between them. + +The answer starts from the core count, so no input size can run it away; `items` and +`limit` only reduce it. `limit` is also the cap when each worker needs a large buffer. + +The multipliers are starting points, not measured. +*/ +width :: proc(items: int, load := Load.Mixed, limit := 0) -> int { + cores := os.get_processor_core_count() + if cores < 1 { + cores = 1 + } + n: int + switch load { + case .Cpu: + n = cores + case .Mixed: + n = cores * 2 + case .Io: + n = cores * 4 + } + if limit > 0 && n > limit { + n = limit + } + // One worker is the floor: a caller with no work still needs a runnable answer. + return min(n, max(items, 1)) +} + +@(private) +Run :: struct($I: typeid, $S: typeid) { + items: []I, + states: []S, + work: proc(item: I, state: ^S) -> bool, + next: int, + stop: b32, +} + +@(private) +Arg :: struct($I: typeid, $S: typeid) { + shared: ^Run(I, S), + index: int, +} + +@(private) +worker_entry :: proc(arg: Arg($I, $S)) { + claim_loop(arg.shared, arg.index) +} + +@(private) +claim_loop :: proc(shared: ^Run($I, $S), index: int) { + state := &shared.states[index] + for !sync.atomic_load(&shared.stop) { + // atomic_add returns the value from before the add, so this claims index i + // and leaves the next one for whoever gets here first. + i := sync.atomic_add(&shared.next, 1) + if i >= len(shared.items) { + return + } + if !shared.work(shared.items[i], state) { + sync.atomic_store(&shared.stop, true) + return + } + } +} diff --git a/flow/flow_test.odin b/flow/flow_test.odin @@ -0,0 +1,193 @@ +package flow + +import "core:testing" + +// Counting alone cannot tell "every item once" from "one item twice and another +// never", so each worker also sums the items it saw. The two together pin the run +// down: the count proves how many were handled and the sum proves which. +@(private = "file") +Tally :: struct { + handled: int, + sum: int, +} + +@(private = "file") +ITEMS :: 100_000 + +@(private = "file") +count_up :: proc(item: int, tally: ^Tally) -> bool { + tally.handled += 1 + tally.sum += item + return true +} + +@(private = "file") +sequence :: proc() -> []int { + items := make([]int, ITEMS, context.temp_allocator) + for i in 0 ..< ITEMS { + items[i] = i + } + return items +} + +@(private = "file") +busy :: proc(tallies: []Tally) -> (n: int) { + for tally in tallies { + if tally.handled > 0 { + n += 1 + } + } + return +} + +@(private = "file") +totals :: proc(tallies: []Tally) -> (handled, sum: int) { + for t in tallies { + handled += t.handled + sum += t.sum + } + return +} + +@(test) +test_each_handles_every_item_exactly_once :: proc(t: ^testing.T) { + tallies := make([]Tally, 8, context.temp_allocator) + each(sequence(), tallies, count_up) + + handled, sum := totals(tallies) + testing.expect_value(t, handled, ITEMS) + testing.expect_value(t, sum, ITEMS * (ITEMS - 1) / 2) +} + +@(test) +test_each_shares_the_work_out :: proc(t: ^testing.T) { + // Each item has to cost appreciably more than starting a thread, or the calling + // thread finishes the whole run before the others are scheduled and the split + // says nothing. That is a property of the work, not of the claiming. + tallies := make([]Tally, 4, context.temp_allocator) + items := make([]int, 32, context.temp_allocator) + each( + items, + tallies, + proc(item: int, tally: ^Tally) -> bool { + acc := 0 + for i in 0 ..< 1_000_000 { + acc += i ~ item + } + tally.handled += 1 + tally.sum += acc & 1 // consume acc so the loop cannot be optimised away + return true + }, + ) + + testing.expect(t, busy(tallies) > 1, "work stayed on a single worker") +} + +@(test) +test_each_with_one_slot_runs_inline :: proc(t: ^testing.T) { + tallies := make([]Tally, 1, context.temp_allocator) + each(sequence(), tallies, count_up) + + handled, sum := totals(tallies) + testing.expect_value(t, handled, ITEMS) + testing.expect_value(t, sum, ITEMS * (ITEMS - 1) / 2) +} + +@(test) +test_each_stops_when_work_returns_false :: proc(t: ^testing.T) { + // One slot keeps this deterministic: with several workers a few more items + // finish after the decision to stop, which is the documented behaviour. + tallies := make([]Tally, 1, context.temp_allocator) + each(sequence(), tallies, proc(item: int, tally: ^Tally) -> bool { + if item == 10 { + return false + } + tally.handled += 1 + return true + }) + testing.expect_value(t, tallies[0].handled, 10) +} + +@(test) +test_each_stops_early_across_workers :: proc(t: ^testing.T) { + tallies := make([]Tally, 8, context.temp_allocator) + each(sequence(), tallies, proc(item: int, tally: ^Tally) -> bool { + if item > 100 { + return false + } + tally.handled += 1 + return true + }) + + handled, _ := totals(tallies) + testing.expect(t, handled > 0, "no item was handled before the stop") + testing.expect(t, handled < ITEMS, "stopping did not cut the run short") +} + +@(test) +test_each_tolerates_empty_input :: proc(t: ^testing.T) { + tallies := make([]Tally, 4, context.temp_allocator) + each([]int{}, tallies, count_up) + handled, _ := totals(tallies) + testing.expect_value(t, handled, 0) + + // No slots means no worker can own state, so there is nothing to run on. + each(sequence(), []Tally{}, count_up) +} + +@(test) +test_width_never_exceeds_the_work :: proc(t: ^testing.T) { + // However wide the machine, three items can only keep three workers busy. + testing.expect_value(t, width(3, .Io), 3) + testing.expect_value(t, width(1, .Io), 1) + // No work still has to give a runnable answer rather than zero. + testing.expect_value(t, width(0), 1) +} + +@(test) +test_width_respects_the_limit :: proc(t: ^testing.T) { + testing.expect_value(t, width(1000, .Io, limit = 4), 4) + // The limit is a ceiling, not a target: fewer items still win. + testing.expect_value(t, width(2, .Io, limit = 4), 2) +} + +@(test) +test_width_grows_with_waiting :: proc(t: ^testing.T) { + // Plenty of work, so the load is the only thing deciding the answer. + cpu := width(10_000, .Cpu) + mixed := width(10_000, .Mixed) + io := width(10_000, .Io) + testing.expect(t, cpu >= 1) + testing.expect(t, mixed > cpu, "mixed work should outnumber cpu bound work") + testing.expect(t, io > mixed, "waiting work should outnumber mixed work") + // Derived from the core count, so a huge input cannot produce a huge width. + testing.expect(t, io < 10_000, "width ran away with the input") +} + +@(test) +test_each_ignores_a_pathological_width :: proc(t: ^testing.T) { + // Asking for thousands of workers is a mistake, not an instruction. The run has + // to stay correct and the pool has to stay within what the machine can use. + tallies := make([]Tally, 4000, context.temp_allocator) + each(sequence(), tallies, count_up) + + handled, sum := totals(tallies) + testing.expect_value(t, handled, ITEMS) + testing.expect_value(t, sum, ITEMS * (ITEMS - 1) / 2) + testing.expect( + t, + busy(tallies) <= width(ITEMS, .Mixed), + "the pool grew past the default ceiling", + ) +} + +@(test) +test_each_caps_by_the_load_it_is_given :: proc(t: ^testing.T) { + // Cpu is the narrowest tier, so it has to hold the pool below the default. + tallies := make([]Tally, 4000, context.temp_allocator) + each(sequence(), tallies, count_up, .Cpu) + + handled, _ := totals(tallies) + testing.expect_value(t, handled, ITEMS) + testing.expect(t, busy(tallies) <= width(ITEMS, .Cpu), "the load did not reach the ceiling") +} diff --git a/flow/manage.odin b/flow/manage.odin @@ -0,0 +1,147 @@ +package flow + +import "core:sync" +import "core:thread" + +/* +Run work over a set that grows as the work discovers more of it. + +`seed` starts the queue; every later item arrives through `manager`, which runs one +at a time. `work` runs on many threads at once, taking the next item the moment one +exists rather than in rounds. + +Each worker owns one slot of `states`, so `work` needs no locks, exactly as in `each`. +`manager` sees that slot beside the item that filled it, and queues whatever comes +next, that item included. + +`work` returning false marks the item failed, not the run. `manager` returning false +ends the run; workers finish the item in hand and report through their state. + +Every item reaches `work` or `discard`, never both and never neither. A stopped run +leaves items queued that nothing else can reach, so an item owning memory needs +`discard` to release it. + +`len(states)` sets the width, capped by what the machine can use. +*/ +manage :: proc( + seed: []$I, + states: []$S, + work: proc(item: I, state: ^S) -> bool, + manager: proc(item: I, ok: bool, state: ^S, queue: ^[dynamic]I) -> bool, + discard: proc(item: I) = nil, + load := Load.Io, +) { + if len(seed) == 0 || len(states) == 0 { + return + } + q: Queue(I, S) + q.states = states + q.work = work + q.manager = manager + q.items = make([dynamic]I, context.allocator) + defer delete(q.items) + // Runs before the delete above, while the queue still holds what was never taken. + defer sweep(&q, discard) + append(&q.items, ..seed) + + pool := min(len(states), width(1 << 30, load)) + if pool == 1 { + drain(&q, 0) + return + } + + threads := make([]^thread.Thread, pool - 1, context.temp_allocator) + defer delete(threads, context.temp_allocator) + started := 0 + for i in 0 ..< len(threads) { + t := thread.create_and_start_with_poly_data(Hand(I, S){&q, i + 1}, hand_entry) + if t == nil { + break + } + threads[i] = t + started += 1 + } + drain(&q, 0) + thread.join_multiple(..threads[:started]) + for t in threads[:started] { + thread.destroy(t) + } +} + +// Release what the run never took, once every worker has stopped. +@(private) +sweep :: proc(q: ^Queue($I, $S), discard: proc(item: I)) { + if discard == nil { + return + } + for item in q.items[q.head:] { + discard(item) + } + q.head = len(q.items) +} + +@(private) +Queue :: struct($I: typeid, $S: typeid) { + items: [dynamic]I, + head: int, + active: int, // workers holding an item, which may yet produce more + over: bool, + mutex: sync.Mutex, + wake: sync.Cond, + states: []S, + work: proc(item: I, state: ^S) -> bool, + manager: proc(item: I, ok: bool, state: ^S, queue: ^[dynamic]I) -> bool, +} + +@(private) +Hand :: struct($I: typeid, $S: typeid) { + queue: ^Queue(I, S), + index: int, +} + +@(private) +hand_entry :: proc(h: Hand($I, $S)) { + drain(h.queue, h.index) +} + +@(private) +drain :: proc(q: ^Queue($I, $S), index: int) { + state := &q.states[index] + for { + sync.mutex_lock(&q.mutex) + // Wait while the queue is empty but someone still holds an item, since that + // worker may yet discover more. Empty with nobody working means finished. + for q.head >= len(q.items) && q.active > 0 && !q.over { + sync.cond_wait(&q.wake, &q.mutex) + } + if q.over || q.head >= len(q.items) { + q.over = true + sync.cond_broadcast(&q.wake) + sync.mutex_unlock(&q.mutex) + return + } + item := q.items[q.head] + q.head += 1 + q.active += 1 + // Reclaim the consumed prefix once it dominates, or a deep traversal keeps + // every item it has ever seen. + if q.head > 1024 && q.head * 2 > len(q.items) { + n := copy(q.items[:], q.items[q.head:]) + resize(&q.items, n) + q.head = 0 + } + sync.mutex_unlock(&q.mutex) + + ok := q.work(item, state) + + sync.mutex_lock(&q.mutex) + q.active -= 1 + // The manager runs for a failed item too: deciding what a failure means is + // the whole of its job. + if !q.manager(item, ok, state, &q.items) { + q.over = true + } + sync.cond_broadcast(&q.wake) + sync.mutex_unlock(&q.mutex) + } +} diff --git a/flow/manage_test.odin b/flow/manage_test.odin @@ -0,0 +1,214 @@ +package flow + +import "core:testing" + +// A binary tree flattened into indices: node i holds 2i+1 and 2i+2. Walking it is +// the shape manage is for, since a worker only learns of a node by visiting its +// parent, and it is deterministic enough to check exactly. +@(private = "file") +NODES :: 20_000 + +@(private = "file") +Visit :: struct { + seen: [dynamic]int, + found: [dynamic]int, + stop: int, // visit this node and the run ends; -1 for never + sink: int, // keeps the busy loop below from being optimised away +} + +@(private = "file") +descend :: proc(item: int, v: ^Visit) -> bool { + append(&v.seen, item) + if item == v.stop { + return false + } + for c in ([]int{2 * item + 1, 2 * item + 2}) { + if c < NODES { + append(&v.found, c) + } + } + return true +} + +@(private = "file") +hand_over :: proc(item: int, ok: bool, v: ^Visit, queue: ^[dynamic]int) -> bool { + for c in v.found { + append(queue, c) + } + clear(&v.found) + return ok +} + +/* +Worker states, each holding what its worker saw. + +The dynamic arrays are deliberately not on the temp allocator. A dynamic array +remembers the allocator it was made with, and these are appended to from every +worker at once, so a per-thread arena belonging to whichever thread built them would +be grown from all of them without a lock. That miscounted a node roughly once in +twelve runs. `delete` them with `release`. +*/ +@(private = "file") +visitors :: proc(n: int, stop := -1) -> []Visit { + v := make([]Visit, n, context.temp_allocator) + for i in 0 ..< n { + v[i] = Visit { + seen = make([dynamic]int, context.allocator), + found = make([dynamic]int, context.allocator), + stop = stop, + } + } + return v +} + +@(private = "file") +release :: proc(v: []Visit) { + for s in v { + delete(s.seen) + delete(s.found) + } +} + +@(private = "file") +totals :: proc(v: []Visit) -> (count, sum: int) { + for s in v { + for i in s.seen { + count += 1 + sum += i + } + } + return +} + +@(test) +test_manage_reaches_every_node_once :: proc(t: ^testing.T) { + v := visitors(8) + defer release(v) + manage([]int{0}, v, descend, hand_over) + + // The count proves how many were visited and the sum proves which, so together + // they rule out one node twice and another never. + count, sum := totals(v) + testing.expect_value(t, count, NODES) + testing.expect_value(t, sum, NODES * (NODES - 1) / 2) +} + +@(test) +test_manage_agrees_with_one_worker :: proc(t: ^testing.T) { + // A single worker runs inline with no threads at all, which is the yardstick the + // concurrent run has to match. + one := visitors(1) + defer release(one) + manage([]int{0}, one, descend, hand_over) + count, sum := totals(one) + testing.expect_value(t, count, NODES) + testing.expect_value(t, sum, NODES * (NODES - 1) / 2) +} + +@(private = "file") +HEAVY :: 400 + +// A node has to cost appreciably more than starting a thread, or the calling thread +// drains the queue before the others are scheduled and the split says nothing. That +// is a property of the work, not of the traversal. +@(private = "file") +descend_slowly :: proc(item: int, v: ^Visit) -> bool { + acc := 0 + for i in 0 ..< 400_000 { + acc += i ~ item + } + v.sink += acc & 1 + append(&v.seen, item) + for c in ([]int{2 * item + 1, 2 * item + 2}) { + if c < HEAVY { + append(&v.found, c) + } + } + return true +} + +@(test) +test_manage_spreads_across_workers :: proc(t: ^testing.T) { + v := visitors(4) + defer release(v) + manage([]int{0}, v, descend_slowly, hand_over) + + count, sum := totals(v) + testing.expect_value(t, count, HEAVY) + testing.expect_value(t, sum, HEAVY * (HEAVY - 1) / 2) + + busy := 0 + for s in v { + if len(s.seen) > 0 { + busy += 1 + } + } + testing.expect(t, busy > 1, "the traversal stayed on a single worker") +} + +@(test) +test_manage_stops_when_the_manager_returns_false :: proc(t: ^testing.T) { + // One worker keeps this exact: with several, those already holding an item + // finish it, which is the documented behaviour. + one := visitors(1, stop = 0) + defer release(one) + manage([]int{0}, one, descend, hand_over) + count, _ := totals(one) + testing.expect_value(t, count, 1) +} + +@(test) +test_manage_tolerates_an_empty_seed :: proc(t: ^testing.T) { + v := visitors(4) + defer release(v) + manage([]int{}, v, descend, hand_over) + count, _ := totals(v) + testing.expect_value(t, count, 0) +} + +// A failed item is the manager's to judge, and the judgement it cannot make without +// being told which item failed is to try that one again. +@(private = "file") +Attempt :: struct { + id: int, + tries: int, +} + +@(private = "file") +Attempt_Log :: struct { + seen: [dynamic]Attempt, +} + +@(private = "file") +refuse_twice :: proc(item: Attempt, f: ^Attempt_Log) -> bool { + append(&f.seen, item) + return !(item.id == 1 && item.tries < 2) +} + +@(private = "file") +retry :: proc(item: Attempt, ok: bool, f: ^Attempt_Log, queue: ^[dynamic]Attempt) -> bool { + if !ok { + append(queue, Attempt{id = item.id, tries = item.tries + 1}) + } + return true +} + +@(test) +test_manage_requeues_a_failed_item :: proc(t: ^testing.T) { + // One worker keeps the count exact; the point is the shape, not the width. + f := make([]Attempt_Log, 1, context.temp_allocator) + f[0].seen = make([dynamic]Attempt, context.temp_allocator) + seed := []Attempt{{id = 0}, {id = 1}, {id = 2}} + manage(seed, f, refuse_twice, retry) + + // Three items, one of them attempted three times, and the run carried on past + // the failures rather than ending at the first. + testing.expect_value(t, len(f[0].seen), 5) + tries := 0 + for a in f[0].seen { + if a.id == 1 { + tries += 1 + } + } + testing.expect_value(t, tries, 3) +}