CPU Task Scheduler - limztudio/NWBLot GitHub Wiki
CPU Task Scheduler
Core::CpuTaskScheduler is the runtime's shared CPU execution service. Frame owns one instance, constructed before graphics and project initialization. ECS worlds, graphics, the loader, and standalone tools own CpuTaskScope lifetimes and borrow that service. They do not create private thread pools.
CpuTaskScheduler is a peer of GpuTaskScheduler. CPU completion does not imply GPU completion. GPU resource readiness continues to use the GPU runtime's submission and device-wait contracts. See GPU Task Graph for GPU recording, packets, and acceptance.
Task Contract
Include <core/task/cpu/scheduler.h> and link nwb_cpu_task. Allocator primitives remain in core/alloc. Scheduler arena identities live in core/task/cpu/arena_names.h:
core/task/cpu/scheduler, exposed byTaskArenaScope::s_CpuTaskSchedulerincore/task/cpu/arena_names.h
CpuTaskOptions declare cost, priority, and execution target independently:
| Option | Meaning |
|---|---|
CpuTaskCost::Heavy |
Prefer performance workers for substantial CPU work. |
CpuTaskCost::Light |
Prefer efficiency workers for smaller work. |
CpuTaskCost::Any |
Allow either class without a capacity preference. |
CpuTaskPriority::Critical |
Prefer work needed promptly by the frame. |
CpuTaskPriority::Normal |
Ordinary ready work. |
CpuTaskPriority::Background |
Lower urgency, with periodic admission to prevent permanent starvation. |
CpuTaskTarget::MainThread |
Execute only on the thread that created the scheduler. |
Cost is a placement hint. Main-thread execution is a hard constraint. Priority cannot preempt a running callback; keep expensive work in bounded batches. Matching-class workers get first opportunity, then other workers may take the work so the shared CPU budget stays busy. Cooperative joins can also cross cost preferences to guarantee progress.
CpuTaskSchedulerConfig selects a worker count, a default main-thread reservation, heterogeneous scheduling, and an optional profile-event capacity. The automatic worker count is the number of usable logical processors minus reservedThreadCount (one by default). An explicit count is honored, including zero for caller-driven execution. Frame accepts this typed configuration in its constructor.
Ownership
Frame owns CpuTaskScheduler and GpuTaskScheduler
GraphicsRuntime borrows both
loader owns a project CpuTaskScope
World owns a world CpuTaskScope
tools construct one process scheduler at entry
Frame::cpuTasks() and Frame::gpuTasks() expose the same instances borrowed by GraphicsRuntime and ProjectRuntimeContext. The project context also exposes the loader-owned project scope as tasks. Testbed constructs World with the context object arena and cpuTasks.
Graphics owns a scope for async resource setup and resource-lifetime joins. GPU recording uses the shared CPU scheduler while retaining worker-local command storage. The loader joins the project scope before project callbacks are unloaded. Normal world clearing joins only its world scope.
Standalone tools own one scheduler for process work. asset_builder and nwb_fbx_to_nwb each construct a scheduler at entry and pass it to consumers.
Heterogeneous Placement
Topology discovery respects Windows process groups, hard affinity and default CPU sets, and Linux inherited CPU affinity. Processor identities include their group or full Linux index, so indices beyond 63 are not truncated. Windows efficiency classes and available Linux CPU-capacity data preserve all reported capacity tiers. The fastest tier is Performance and lower tiers Efficiency; homogeneous or unknown capacity is Any. Workers are pinned to discovered allowed processors. Failed pinning is counted and the worker falls back to unclassified OS scheduling.
Submission And Completion
submit() accepts concurrent calls from the engine main thread, scheduler callbacks, and external OS threads. Producers do not register with the scheduler or own a worker lane. Multiple producers may share one task scope.
Ready queues are multiple-producer, multiple-consumer, protected by the engine Futex. Task reservation, dependency publication, queue insertion, and consumer claims synchronize through the scheduler mutex. A ready task is claimed once; its callback runs after releasing the mutex. User callable construction and capture retirement stay outside that mutex. The implementation is mutex-protected, not lock-free.
Submission thread and execution target are independent. Worker tasks run on configured workers or an eligible cooperative caller. MainThread tasks submitted anywhere still execute only on the scheduler owner. External waiters do not become extra worker consumers or borrow a GPU recording slot. With zero workers, the owner must pump or wait to execute submitted work.
CpuTaskScope::submit() returns a generation-tagged handle in the scheduler's single identity domain. Dependencies may cross system scopes. Foreign scheduler handles are rejected. Completed handles remain valid prerequisites after node reuse.
Tasks submitted from a running callback automatically become children of that callback's task. Completion means the callback, all descendants, and the task's captured objects have finished. A child's explicit dependency must not introduce a cycle through its parent, an ancestor, or their dependent tasks.
A task cannot wait on itself, an ancestor, dependent work that needs its completion, or a scope containing any ancestor. Scope joins reject the same wait cycles. Join children before automatic variables they reference leave scope. Capture destructors must not implicitly attach new asynchronous work to the task being retired; submit that work in a separately owned scope.
Normal scope and scheduler destruction join outstanding work through a throwing wait. drain() is reserved for terminal cleanup: it stops admission across the shared scheduler and skips queued callbacks before joining active work and retiring captures. Scope drain() also aborts the shared service, because application unwind is terminal. Use cancel() followed by wait() for ordinary scope-local cancellation. During an existing exception, destructors use terminal drain so cleanup cannot invoke another queued throwing callback.
Unexpected native-worker exceptions terminate at the native thread. Inline failures unwind to the application-entry boundary. Exceptions are never stored for deferred delivery.
A scope or scheduler wait() observes admitted work. It does not close admission or wait for an external producer that has not submitted yet. Stop producers before destroying a scope, scheduler, or referenced task data.
Parallel Loops
parallelFor() partitions a range into ordinary CPU tasks and joins a local scope. Chunk nodes are reserved and published under one scheduler lock. A single eligible chunk can run directly through the ordinary execution and retirement machinery. Nested loops work with one worker. Cooperative scope joins select work that contributes to the joined scope, including its prerequisites, so unrelated queued work does not become part of a subsystem lifetime barrier.
The main thread must pump the scheduler while main-thread tasks are outstanding. Frame pumps at its update boundary; scheduler and task-scope waits on the main thread also make progress. Blocking an OS thread on an external latch does not pump this queue. Avoid blocking I/O, GPU completion waits, and unbounded callbacks in ordinary CPU tasks.
Use bounded batches or parallelFor for fine-grained collections. Individual tiny submissions pay more accounting cost than a batched range.
ECS And Graphics
ECS preparation remains a serial caller phase because preparation can create entities and change component storage. Updates are dependency tasks: every conflicting component access preserves system registration order; read/read accesses can overlap. UI updates explicitly target the main thread. View::parallelEach takes the owning CpuTaskScope.
GraphicsRuntime borrows the frame-owned CPU and GPU schedulers. It binds the GPU scheduler after device creation and before resource-validation callbacks, joins GPU work before invalidation, and detaches before device destruction. A failed detach leaves the device binding intact. Swap-chain resizing keeps the same binding.
Profiling
Profiling is disabled by default. Steady-state disabled scheduling reads no profiling clocks and allocates no event or ready-timer buffers. CpuTaskOptions::profileLabel and scope labels are compact handles; register owned names once through registerProfileLabel() during subsystem setup.
setProfiling(enabled, frameIndex) enables or disables collection and updates frame attribution. CpuTaskSchedulerConfig::profileEventCapacity bounds storage (default 4096 events; zero disables capture). A full buffer retains existing events and counts newly dropped samples. Changing capture state discards buffered samples and advances an epoch.
Event kinds are queue delay, execution, handle/scope/scheduler joins, and worker idle. Named execution scopes cover graphics setup/frame work, ECS worlds, and project work; other events use cpu.task.queue_delay, cpu.task.execution, cpu.task.handle_join, cpu.task.scope_join, cpu.task.scheduler_join, and cpu.worker.idle.
Frame CPU perf capture enables scheduler profiling automatically. Perf::CollectCpuTaskProfile() drains only the initial pending-count snapshot on the timing-sink owner thread, before perf publication. Worker threads never call the non-thread-safe timing recorder. Existing perf telemetry transports these timing scopes without a new wire schema. Failed or quit frames discard unpublished profiling data. See Diagnostics and Telemetry.
Source Map
core/task/README.md: CPU and GPU domain layout and CMake dependency directioncore/task/cpu/scheduler.h:CpuTaskSchedulerandCpuTaskScopecore/task/cpu/types.h: cost, priority, target, handle, and config typescore/task/cpu/profiling.h: profile event kindscore/task/cpu/arena_names.h: scheduler arena identitiescore/task/cpu/scheduler.cpp,scheduler_range.cpp,scheduler_wait.cpp,scheduler_notify.cpp,scheduler_profile.cpp: submission, ranges, waits, wakeups, and capturecore/frame/module.h: frame-owned CPU and GPU schedulersloader/project_entry.h:cpuTasks,gpuTasks, and the projectCpuTaskScopecore/ecs/world.h: world-owned task scopecore/graphics/runtime/runtime.h: borrowed schedulers and GPU device attach/detachcore/perf/cpu_task_profile.h: owner-thread profile drain into perf timingglobal/cpu_topology.h: processor discovery and placementdocs/cpu_task_scheduler.md: scheduler contracts, profiling, and verification procedurestests/unit/task/cpu/:nwb_cpu_task_tests
See Runtime and ECS for world ticking, Frame Life Cycle for startup and shutdown, and GPU Task Graph for GPU submission.