Skip to content

FoundationThreading

Bounded reusable CPU task graphs, with native worker pools and the same serial executor on native/browser builds. Link Ludus::FoundationThreading and include <ludus/foundation/threading/jobs.hpp>. See the architecture and reference review.

#include <ludus/foundation/threading/jobs.hpp>

namespace jobs = ludus::foundation::threading;

jobs::JobOutcome Compute(void* context) noexcept
{
    ++*static_cast<ludus::foundation::uint32*>(context);
    return jobs::JobOutcome::Succeeded;
}

int main()
{
    ludus::foundation::uint32 output = 0;
    // Contexts must outlive the graph/system, including failure paths.
    jobs::JobGraph graph;
    jobs::JobSystem system;
    jobs::JobHandle handle;
    if (graph.Initialize({1, 0}) != jobs::JobStatus::Ok ||
        system.Initialize({2}) != jobs::JobStatus::Ok ||
        graph.Add(Compute, &output, handle) != jobs::JobStatus::Ok ||
        graph.Seal() != jobs::JobStatus::Ok ||
        system.Submit(graph) != jobs::JobStatus::Ok)
    {
        return 1;
    }
    jobs::JobOutcome result;
    if (system.Wait(result) != jobs::JobStatus::Ok || result != jobs::JobOutcome::Succeeded)
    {
        return 1;
    }
    return output == 1 ? 0 : 1;
}

The default worker budget is zero. Native consumers explicitly select their CPU budget; a component lent a system reads that budget with GetWorkerCount() to size its graphs instead of taking a second configuration value. Browser builds reject a nonzero worker count. One graph remains admitted until Wait, even after callbacks finish. Submit of a sealed graph may be repeated; each job keeps the context pointer given to Add, so update the data it references between Wait and the next Submit. Reset invalidates its handles before rebuilding. Cancel skips callbacks that have not started and reports them Cancelled, even when a failed prerequisite would otherwise report them Blocked; running callbacks finish. The graph outcome is Failed if any job failed, otherwise Cancelled if any job was cancelled. GetOutcome and Snapshot expose progress; Cancel and Snapshot may also be called from callbacks.

Build/reset and pool lifecycle belong to one application owner. Wait may help execute CPU work on its caller; use help=false to preserve caller responsiveness or TLS expectations. Jobs never wait on jobs or synchronous I/O and never call pool lifecycle methods. The owner retains immutable inputs and exclusive output ranges through Wait, then applies results in stable order. All errors use statuses; the engine library compiles with exceptions disabled.

Execution traces

Jobs can carry an optional JobLabel (a borrowed name and a site ID), given as the last argument of Add. A JobSystem can observe its own scheduling through JobSystemConfig::Hooks: worker start/stop hooks that run once on each worker during Initialize and Shutdown, a trace function and a per-submission filter. Hooks run outside the scheduler lock and cannot call pool lifecycle methods. The architecture gives the event order and the lock and lifetime argument.

To record Perfetto traces, link Ludus::FoundationThreadingProfiling and use the profiler hooks:

#include <ludus/foundation/profiling/trace_system.hpp>
#include <ludus/foundation/threading/job_profiling.hpp>

namespace jobs = ludus::foundation::threading;
namespace profiling = ludus::foundation::profiling;

profiling::BeginCapture();
jobs::JobSystem system;
// Workers register as profiler threads "Bake worker 0", "Bake worker 1", ...
if (system.Initialize({ .WorkerCount = 3, .Hooks = jobs::MakeProfilerJobHooks("Bake worker") }) !=
    jobs::JobStatus::Ok)
{
    return 1;
}
jobs::JobHandle handle;
if (graph.Add(Compute, &output, handle, LUDUS_JOB_LABEL(Compute)) != jobs::JobStatus::Ok)
{
    return 1;
}
// ... Seal, Submit and Wait as above ...
profiling::EndCapture(); // publishes every thread's partial trace chunk
profiling::ExportPerfettoTrace("jobs.json");

Submissions are traced only while a profiler capture is active; otherwise each job costs one predictable branch. LUDUS_JOB_LABEL(Name) registers Name as a profiler site once and never allocates. A capture contains:

Track Event Meaning
Owner thread JobSystem::Submit slice Admission; starts the dispatch flow of every job without prerequisites.
Owner thread JobSystem::Wait slice Time the owner spends in Wait, including jobs it helps execute.
Executing thread Slice named by the job's label (Job when unlabelled) The callback's execution.
Flow arrows JobDispatch From Submit to a root's start, and from a succeeded prerequisite to the start of each dependent it released. The arrow's length is the dispatch-to-start latency.
Counter JobReadyDepth Jobs waiting in the ready queue, sampled at Submit and at each job start.

Jobs skipped as Blocked or Cancelled have no slice. A dependent with several prerequisites receives the arrow of the prerequisite that released it.

Dedicated worker and owner completions

<ludus/foundation/threading/worker_queue.hpp> provides WorkerQueue for native blocking preparation that belongs outside the CPU dependency scheduler, including driver pipeline creation. Initialize allocates fixed storage and starts one thread. Submit(execute, complete, context) admits at most MAX_WORKER_QUEUE_TASKS tasks, including running and uncollected completions. It returns an explicit status when unavailable or full; it never executes work on the caller. Browser initialization returns Unavailable without a serial fallback.

The execution callback runs on that worker. Call Poll between frames to deliver at most one completed callback on the owner thread, with the execution writes visible. Keep context storage alive through completion. Neither callback may reenter its queue. Submission and polling allocate nothing and never wait for execution; their internal gate protects only bounded bookkeeping, never a driver call. Shutdown joins the worker and delivers remaining completions on the owner; use it during orderly shutdown, outside frame-critical work.