Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions CLAUDE.md

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -41,13 +41,15 @@ set(TS_CORE_SOURCES
src/guarded.cpp
src/scheduler.cpp
src/static_task_graph.cpp
src/timer.cpp
src/worker_thread.cpp
)

set(TS_SAMPLE_SOURCES
sample/blackboard.cpp
sample/coloring.cpp
sample/events.cpp
sample/fixed_rate.cpp
sample/game_frame.cpp
sample/lazy_bvh.cpp
sample/physics.cpp
Expand Down Expand Up @@ -80,6 +82,7 @@ set(TS_DRIVER_SOURCES
tests/scheduler_tests.cpp
tests/task_tests.cpp
tests/tests.cpp
tests/timer_tests.cpp
tests/versioned_tests.cpp
)

Expand Down
9 changes: 9 additions & 0 deletions benchmarks/game_frame_bench.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ namespace sample
{
void game_frame_stats(int frames, float time_scale, double& avg_ms, double& serial_ms, float& transform0);
void game_frame_free_stats(int frames, float time_scale, double& avg_ms, double& serial_ms, float& transform0);
void game_frame_fixed_stats(int frames, float time_scale, double& avg_ms, double& serial_ms, float& transform0);
}

namespace
Expand Down Expand Up @@ -70,4 +71,12 @@ void run_game_frame_bench()
report_frame("free 1.0", heavy_free, heavy_graph);
report_frame("graph .05", light_graph, 0.0);
report_frame("free .05", light_free, light_graph);

// The optimised frame with physics and networking on their own clocks: a different
// composition, not a different schedule of the same one, so the delta mixes the optimised
// levers with the physics chain leaving the frame. The trace separates the two.
double heavy_fixed = frame_us(&sample::game_frame_fixed_stats, 20, 1.0f);
double light_fixed = frame_us(&sample::game_frame_fixed_stats, 200, 0.05f);
report_frame("fixed 1.0", heavy_fixed, heavy_graph);
report_frame("fixed .05", light_fixed, light_graph);
}
18 changes: 17 additions & 1 deletion docs/TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -379,7 +379,7 @@ IDs — when an item is done, mark it, don't renumber.
per-frame rebuild a measured option instead of an anti-pattern and answers the first
sophisticated-evaluator question ([research-deepdive.md](internals/research-deepdive.md) §9.4,
§12.2). Ties to 10.1.
11. `[ ]` **(P2, author 2026-07) Yield points inside long-running nodes.** The 4-worker
11. `[x]` **(P2, author 2026-07 — DONE 2026-09) Yield points inside long-running nodes.** The 4-worker
game_frame trace made the failure concrete: a ready critical-path node (economy) waited
~0.9 ms behind a long off-path runner (audio) — nothing evicts a runner, and priority
cannot help work that is already running (measured: audio at `low` changed neither its
Expand All @@ -389,6 +389,14 @@ IDs — when an item is done, mark it, don't renumber.
`ts::yield()` polling a "critical work pending" signal vs coroutine nodes (`co_await`
suspension already exists for tasks) vs auto-slicing via `parallel_for` guidance. Relates
to 2.4's keep-out-zone hypothesis and 2.5's rank (what "higher-rank pending" means).
**Landed (2026-09):** `ts::yield()` (task.h) runs one queued entry of a higher class
inline (`normal` -> `high`; `low` -> `high`, then the global `normal` queue) on the
yielding worker's stack and returns - no suspension, so functor nodes and `parallel_for`
bodies can yield, and the continuation keeps its core; `parallel_for` yields at every
chunk claim. Rationale: design.md §3 "Yield points". A resume the nested task causes runs
at the yield point, also inside a resumed segment (the nested dispatch sets the resume
and inline trampolines aside; nesting is bounded to one level). Open: a pending signal
finer than the priority classes (2.5's rank).
12. `[ ]` **(P2, author 2026-07 — raised from the 2.3 adjacency) Frame-boundary overlap for
designated nodes.** Distinct from 2.3 (whole-graph pipelining): let specific off-path
tails (audio mix, streaming finalization) spill past the run's settle into the next
Expand Down Expand Up @@ -645,6 +653,14 @@ IDs — when an item is done, mark it, don't renumber.
concrete.
8. `[ ]` **(P2, author 2026-08) Cache-line alignment audit across components.** A systematic pass over every hot shared structure for cache-line placement: separate fields written by different threads onto distinct lines (`alignas(std::hardware_destructive_interference_size)` where warranted), keep fields read/written together on one line, and check array elements for false sharing between adjacent entries. Inventory to cover: `Task_control_block` (the size-ordered cluster is packing-motivated, not sharing-motivated — line 0 packs the write-hot atomics `refcount`/`num_locks` together AND with the read-only dispatch-read set `execute`/`result_ptr`/`pipe_links`/`flags`/`token`, trading one-line dispatch locality against handle-churn/indegree writes false-sharing that line; `refcount` is ~23% of per-node machinery per the graph-regression callgrind, so measure whether isolating the write-hot atomics onto their own line beats the locality win), the evolved `Pipe` (all queue state now lives under one mutex, so the interesting question shifted: is `writer_owner` — read lock-free by every `commit()` ownership check — on the right line relative to the mutex and the queue head, and does a coroutine frame's embedded link share a line with hot promise state), `Pipe_link` arrays (adjacent links of one task live on one line; different lines' traffic collides — measure before padding, links are per-task not global), scheduler queues/deques (Chase-Lev top/bottom, MPMC slots; `Busy_slot`/`Bucket_row` are already padded — verify the rest), journal slots, `Event_count`. Measure with the existing benchmarks (contention series + R10 pipe fixture) — padding trades memory for isolation, so each change needs a number, not a vibe.
**Reaffirmed + rescoped (author, 2026-08): the WHOLE implementation, not just the inventory above** — treat every hot or shared structure in `include/`+`src/` as in scope, and rederive the field-level analysis from the CURRENT layout rather than the specifics written above, which are now stale: `Task_control_block` changed twice since (the coroutine-first slimming to 264 B, then B2 dropping `dispatch_arg`/collapsing `run_state`→`body_claimed` to 248 B, plus the `TS_DEBUG_NAMES`-gated `Named` field), so the `num_locks`/`refcount` line-sharing claim must be re-checked against the real struct. Dump the actual offsets (a `static_assert(offsetof(...))` probe or the debugger's layout view) as the first step, identify which fields are touched by which threads on the hot paths (settle vs dispatch vs refcount vs the harness), then place the destructive-interference boundaries — measured, per the rule above. Do this after the perf-baseline/graph-regression work settles (it may itself surface a false-sharing culprit worth folding in).
9. `[ ]` **(P3, 2026-09) Allocation-free `Periodic::next()`.** Each `next()` allocates one
timed block: the returned task, with the timer bookkeeping embedded. A `Periodic` could own
one block and re-arm it every tick, the way `Signal::reset()` re-arms a signal, which makes a
steady-state tick allocation-free. It needs a reusable settle for an executable block (result
and state reset while no awaiter is attached), which blocks lack since executable-task reuse
was deleted with the coroutine-first transformation. A 60 Hz clock costs 60 allocations a
second today, so this waits for a workload with many clocks, or for the per-type free-list
(4.1), which would absorb the cost without an API change.

5. **Fork-join / parallel_for**
1. `[ ]` **(P2) Intra-system entity interactions** — ship the primitive menu: `parallel_gather_apply` (mailbox), `parallel_for_colored` + `Interaction_coloring`, `Accumulator` (commutative), `Union_find` helper, + triage docs. Open author questions. [§D5]
Expand Down
116 changes: 116 additions & 0 deletions docs/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -493,6 +493,82 @@ relaxations would be explicit staleness opt-ins, and the real
reader-throughput answer is structural (`Versioned<T>`, §6) rather than
queue policy.

#### Yield points

Priority orders queues but cannot evict a running task, so a ready `high` task
can wait behind long `normal` bodies for their whole duration. The game-frame
dry runs measured exactly that
([profiler-guided-optimization.md](internals/profiler-guided-optimization.md),
experiment 1), and a fixed-rate graph with a deadline (§6.1) turns it from a
tuning question into a requirement. The cooperative remedy is a yield point,
and the design question was what a yield point does.

The obvious shape suspends the yielding task, requeues it, and lets the worker
take the urgent one. That pays a requeue and a resume hop per yield, lets a
thief move the continuation to a cold core, and needs a coroutine. The shape
taken runs the urgent task inline instead: `ts::yield()` pops one queued
entry of a higher class than the yielder's and executes it on the yielding
worker's stack, then returns. The continuation never leaves the stack, and the
yield works in a plain functor body. `normal` yields to `high`; `low` yields to
`high` and then to the global `normal` queue; `high` never yields. A worker's
own `normal` deque stays invisible to a `low` yielder: counting its entries
would add a shared write to the deque push, the scheduler's fast path, and
idle workers steal from it anyway. The common case, nothing queued, reads two
relaxed counters the scheduler keeps for the `high` queue and the global
`normal` queue, each incremented before a push and decremented after a pop, so
neither under-counts. The pair sits alone on one cache line, which every
worker reads at every yield point.

Three properties make the nesting safe. A queued entry has already taken its
pipe turns, so it cannot wait on grants the yielder holds. The nested dispatch
runs inside a scope that clears the thread's ambient task state (current task,
grants, scope children, rule relaxation, trace owner), sets aside the pending
work of the resume and inline-dispatch trampolines, and restores both
afterwards, so the nested task starts as it would at the top of the worker
loop. And yield points reached inside the nested task do nothing, so nesting
is one level deep.

The trampoline part is what lets a yield point release a coroutine. A resumed
coroutine segment runs inside the resume trampoline's drain, and a resume
queued during a drain runs when the drain reaches it. Without the set-aside, a
resume the nested task causes from a yield point inside a resumed segment
would wait for the yielding segment to return, so a tick driver released at a
yield point would wait for the body it was meant to interrupt. With it, the
resume starts a drain of its own at the yield point, and the outer drain
continues where it stood once the nested task returns. The one-level bound is
what keeps that finite: a coroutine resumed at a yield point could otherwise
yield in turn and run another task, one stack level per queued task.

Running a resume at a yield point adds no interleaving the settle paths do not
already face. The awaiter handshake decides which thread resumes a coroutine,
not when, and a resume that runs while its settler is still on the stack is
the ordinary case at the top of a worker loop: `Signal::trigger` keeps its
block alive across the settle, and a graph run's last node keeps the run's
completion handle alive across it, for exactly that reason. The yielding
segment's own pending resumes are delayed by the nested task, as they would
be by any longer segment. The trace subtracts the nested span from the
yielding body's credited time, so body time is counted once. `parallel_for` has a yield point at every chunk claim, which
covers the most common long body without a change to user code.

#### The timer thread

Deadlines are kept by one timer thread with a min-heap, created on first use
([timer-primitive-design.md](internals/timer-primitive-design.md)). The
alternative considered was folding the heap into the scheduler, as Go and
Tokio do: workers check it before the queues and park with a timeout to the
nearest deadline. That saves a thread and one wake hop per fire, but the
eventcount park has no timed wait in standard C++, the `spin` and `handoff`
idle policies would each need their own polling, and deadline ownership would
move between parked workers. On Windows it also costs timing quality: a parked
worker's timeout has the system timer's granularity, while a dedicated thread
can wait on a high-resolution waitable timer. For a 60 Hz tick the saved hop is
tens of microseconds against a 16.7 ms period, well below the jitter that
granularity would add, so the dedicated thread stays. It never runs user code:
a fire is delivered as a task at the sleep's priority, for the same reason
`Frame_gate::open()` releases through the scheduler. The delivered task is the
one the sleep returned, whose block also carries the timer's bookkeeping, so a
wait is one allocation and a fire queues no second task.

---

## 4. The task core
Expand Down Expand Up @@ -1093,6 +1169,21 @@ and `publish()` flips atomically. The load-bearing choices:
provides per-worker slots for staging from inside a `parallel_for`, is the
explicit, localized surrender of cross-thread reproducibility.

`History::current_and_previous` adds a third replica, the version before the
current one, so a consumer can interpolate between the two newest versions of
a producer that publishes on its own clock. The swap becomes a rotation under
the same write grant, with front, shadow and previous exchanging contents, and
the publish stamps its instant and serial. Two choices follow. The resync is by
copy: after the rotation the shadow holds the version before last, and
replaying one batch would bring it only as far as the previous version. And the
pair is read through a view that is itself the read grant, the `Access_guard`
shape, so both references are valid for exactly the bindings' lifetime. The
history is a template parameter, so the third replica and the pair read exist
only where asked for. An earlier sketch took the reading instant as an
argument, which read as a lookup by time although the versions returned never
depend on it; the stamps and a `fraction_at` helper keep the choice of instant
with the caller.

The UE research grounded several choices. `ENQUEUE_RENDER_COMMAND`'s
linear-allocated coarse queue and its splice-in-submit-order parallel
recording, where determinism comes from splice position rather than thread
Expand All @@ -1102,6 +1193,31 @@ tier. The Render Dependency Graph, with passes declaring resource access and
order derived, is production validation of this library's central premise,
applied to GPU resources.

### 6.1 Fixed-rate graphs

A fixed-rate subsystem is a second compiled graph with its own clock, beside
the frame graph on the same scheduler, and the boundary between them is one
`Deferred` for inputs and one `Versioned` for outputs. That is the logical
execution time model of real-time control (Giotto, AUTOSAR's timing
extensions): a task reads its inputs at release and publishes its outputs at
its deadline, whatever its actual duration. The time-triggered architecture's
state message, a version replaced at a known instant and read without
blocking, is what `Versioned` already is. The game-loop accumulator is the
special case where the schedule is inferred from the measured frame time,
which is also why it can spiral: a slow tick raises the next frame's tick
count, which slows the tick further. With a clock of its own, the overload
policy, whether to run missed ticks, drop them or slow simulated time, is one
decision in the driver, separate from rendering.

The library supplies mechanisms only: the timer, the history on `Versioned`,
the graph default priority, and yield points. There is no fixed-rate-graph
type. Which state a subsystem owns, what crosses the boundary, the overload
policy, and how the tick relates to networking are engine decisions a bundled
type would have to guess, and the composition is a few lines
(`sample/fixed_rate.cpp`). The substantial cost of the model sits in the
engine, not the library: a subsystem whose gameplay code writes the same state
from both rates has to be split into owned state and published versions first.

---

## 7. Rejected alternatives
Expand Down
36 changes: 33 additions & 3 deletions docs/example-frame-optimization.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,15 +33,16 @@ two variants of the same ~30-system frame, built from the *same* system bodies:
- **optimised** — the same frame after reading its own trace, with the levers
the visualization makes obvious applied in an `optimise()` section.

Generate both traces (on an 8-worker scheduler) plus the structure dump with:
Generate the traces (on an 8-worker scheduler) plus the structure dump with:

```
macrame_playground --trace 200
show_graph.bat # renders the DOT and opens both average-run SVGs
show_graph.bat # renders the DOT and opens the average-run SVGs
```

producing `sample_game_frame_avg_baseline.svg`,
`sample_game_frame_avg_optimised.svg`, and `sample_game_frame.dot`.
`sample_game_frame_avg_optimised.svg`, `sample_game_frame_avg_fixed_rate.svg`
(the third variant, §5.1), and `sample_game_frame.dot`.

The point of the exercise is not the exact percentage it ends up saving. It is
*which* optimisations the trace says are worth trying, and which ones it says to skip. It ends by showing what a *finished*
Expand Down Expand Up @@ -193,6 +194,35 @@ faster means **cutting work** (a smaller scene, cheaper systems) or **adding
cores**, not restructuring the graph. The trace tells you when you have reached
that point instead of guessing.

### 5.1 A third variant: physics and networking on their own clocks

`Frame_variant::fixed_rate` is the optimised frame with the physics pipeline
(broadphase → narrowphase → solver → finalize) moved into a 60 Hz graph and
networking into a 30 Hz graph, each driven by a `ts::Periodic` clock beside the
frame loop ([guide.md §6.6](guide.md)). The frame stages physics inputs and
outgoing network data through `Deferred`, reads the ticks' published snapshots
through `Versioned`, and propagation interpolates between the two newest
physics ticks. `--trace` writes its average run as
`sample_game_frame_avg_fixed_rate.svg`; the tick graphs run on the same
workers, untraced.

| | baseline | optimised | fixed rate |
|---|---|---|---|
| frame time | 19.01 ms | 16.93 ms | 17.12 ms |
| core utilization | 86.2 % | 95.4 % | 95.6 % |
| critical path dead time | 9.8 % | 9.3 % | 10.6 % |

*(2026-09, 8 workers, 100 traced frames, one run each.)*

The physics chain leaves the frame's critical path, and the frame time does not
move. That is the core-bound reading from above applied to a new lever: the
optimised frame already keeps every core busy, and at one tick per frame the
tick's work still needs those cores, whichever graph it belongs to. On this
fixture the variant's value is structural: the frame graph has one shape
however many ticks fall into a frame, the tick rate no longer depends on the
frame rate, and the overload policy lives in one driver. A frame with idle cores
or a physics chain on its critical path would also gain time.

## 6. What carries over to a real frame

- **Read utilization and dead time together.** Low dead time + low utilization =
Expand Down
Loading
Loading