Worker pools and groups
CE::Workers::Pool owns physical CPU threads. CE::Workers::Group is a copyable
workload handle sharing that capacity; a group does not create another set of
threads. Pools, groups, CPU policies and topology form the reusable CE::Workers
facility. Applications can use it without a game session or runtime context. The
default pool has one worker so a CE::Runtime::Context need not reserve
machine-wide capacity accidentally.
Jobs
Jobs own their callables and return futures. Callback exceptions reach those
futures, without stopping another job. FIFO selection within a group does not
promise completion order when its concurrency is greater than one. A group’s
max_concurrency enforces its running-job limit; zero uses the pool’s capacity.
Eligible groups receive smooth weighted fair selection. Weight (1..1024) times
priority+1 (priority 0..7) determines job-selection share, not OS thread priority.
A concurrency cap or empty queue removes a group from that selection round.
Preferred previous-worker reuse breaks otherwise equal choices.
Pool lifetime
group.close() stops acceptance without cancelling ordinary accepted work.
group.drain() requires closure, waits for pending/running work and capture
release, and leaves other groups available. pool.close() closes all groups;
shutdown() also joins. Destruction drains and joins. Saved group handles reject
safely after the pool disappears. Destroying the pool from one of its own jobs is
invalid; explicit self-join and same-pool drain reject before blocking.
Do not block a worker on child futures from the same saturated pool. Dependency scheduling/work stealing is separate work. No worker is forcibly terminated. Thread-start failure wakes and joins every thread already created. Scheduler locks protect queue/accounting changes; user work and its captured destructors run outside them. Submitted jobs retain their group even if its handle is dropped.
Topology providers and capacity
Neutral topology descriptions, pool/group options and scheduling live in
CE::Workers and are supplied by Cheryl::Engine. The optional
Cheryl::WorkerTopology::Hwloc target supplies
CE::Workers::Hwloc::make_topology_provider(); Cheryl::WorkerTopology::Json
supplies configuration loading independently of hwloc. Both live under projects/engine/support/worker-topology/.
Applications link the selected support targets; Engine does not link back to them.
Public contracts expose neither hwloc nor JSON library types. The static hwloc
support library propagates its required final link dependency.
Supply a CE::Workers::TopologyProvider through CE::Workers::PoolOptions::topology_provider.
The pool retains it through worker joins. There is no global registrar, entry-point
hook or implicit discovery. The existing numeric constructor still defaults to one
worker and requires a positive count. CE::Workers::PoolOptions::worker_count = 0 explicitly
selects automatic capacity: eligible physical cores when all eligible CPUs have core
information, eligible logical CPUs otherwise, or one worker when eligibility is unknown.
Group concurrency zero uses pool capacity; a larger requested cap is limited to that
capacity and creates no extra threads.
#include <cheryl/core/engine/worker-pool.h>
#include <cheryl/core/engine/worker-topology/hwloc.h>
#include <memory>
#include <utility>
CE::Workers::PoolOptions options;
options.worker_count = 0;
options.topology_provider = CE::Workers::Hwloc::make_topology_provider();
auto pool = std::make_shared<CE::Workers::Pool>(std::move(options));
CE::Workers::GroupOptions simulation_options;
simulation_options.max_concurrency = 1;
simulation_options.cpu.locality = CE::Workers::LocalityPolicy::Reuse;
auto simulation = pool->make_group(simulation_options);
topology() returns an owned snapshot of eligible OS CPU IDs, physical-core/SMT
relationships and reported cache domains. Each cache has a snapshot-scoped ID,
level, data/instruction/unified type, member CPUs and optional capacity/line size.
Private and shared caches at all reported levels are represented; missing values
are omitted. CPU IDs need not be consecutive, and core widths/cache sizes need not
be uniform. No policy assumes a particular CPU vendor or one processor package.
The provider discovers the executing machine at runtime. The pool intersects its
advertised CPUs with the pool creator’s inherited thread mask, validates IDs and
memberships, and retains that restriction for later binding/restoration. Discovery
does not run per job, change creator affinity or place memory. Invalid structural
snapshots reject before worker startup. Missing runtime capabilities remain explicit
in capabilities() and limitations.
CPU policy and capabilities
CE::Workers::Group owns its CPU/locality policy. The neutral root scheduler enforces the
policy before each job, so groups are more than labels on a shared queue. Explicit
CPU IDs must fit the inherited eligible CPU set; required unavailable CPUs reject.
Preferred eligibility may fall back, and policy() describes the requested and
effective set. capabilities() exposes unsupported affinity/topology facilities.
Capabilities are a startup snapshot, not a live OS-eligibility query; each job’s
native policy application supplies its execution-time check. policy() and
status() return owned values. Status counters observe progress without locking
application state or establishing completion; closed-group drainage is the barrier.
shared_cache_domain selects a domain from this pool’s snapshot and intersects its
members with the group’s CPU eligibility. Missing/conflicting required domains
reject; preferred requests report fallback. Required explicit locality also needs
thread binding and physical-core information for every selected CPU. Without a
provider, existing explicit affinity remains available, preferred locality falls
back and unavailable hard requirements reject. An unspecified locality policy uses
Automatic with a provider and ordinary affinity without one.
Locality |
CPU targeting |
|---|---|
Automatic |
Reuses for effective concurrency one; otherwise spreads across eligible cores/CPUs. |
Reuse |
Prefers the group’s last verified CPU, then its physical core, subject to current load. |
Spread |
Rotates equal candidates and favors idle physical cores before SMT siblings. |
Busy-core/CPU load takes precedence over soft reuse. Fair selection and concurrency
limits remain authoritative, so locality alone does not stall available work.
policy() reports effective locality, cache domain, concurrency, eligibility and
fallback; status().last_cpu is the last verified singleton mask at job entry,
not a live residency observation. A CPU mask is eligibility, not exclusive core
ownership. Applications identify related jobs and own their working sets; the
facility cannot infer that relationship from callable captures, inspect cache
contents or select internal cache banks. Discovery/binding do not establish a speedup.
Representative workload measurements are required before making performance claims.
NUMA discovery as an application facility, memory allocation/binding, first-touch
and migration remain indefinitely deferred.
numa_placement remains false; required NUMA requests reject and preferred requests
report fallback. CPU binding must not enable memory placement as a side effect.
Without a provider, the Linux adapter queries pthread eligibility, sets and verifies
the actual mask, and reports errors through job futures. That adapter retains its
CPU_SETSIZE limit; platforms without it report unsupported affinity. The hwloc
provider uses hwloc CPU sets without that fixed limit, retains instruction-cache
objects, queries thread-specific binding and uses strict calling-thread binding with
HWLOC_CPUBIND_NOMEMBIND. Missing support or failed verification is reported rather
than pretending to honor a hard request. Native OS/real-time priority remains
separate from scheduler priority.
On Windows, the hwloc provider retains discovered CPUs, cores and caches but reports
cpu_affinity = false: its backend rejects HWLOC_CPUBIND_NOMEMBIND. Preferred
CPU/cache locality uses portable scheduling; required explicit CPU, cache-domain or
locality requests reject. Windows binding that preserves memory placement is a
phase 4 deferral.
Workers retain an unchanged verified mask between compatible jobs. Before another
group’s work they apply its eligibility, restoring inherited eligibility for an
unconstrained group. A required job checks that cached eligibility remains valid.
Failed policy application never runs a job under an unverified mask; preferred
policy attempts an inherited-mask fallback and records policy_failures. Live OS
restrictions can still reject even a previously valid group.
For two groups restricted to overlapping CPUs, both use the same physical workers; the scheduler chooses an eligible uncapped group by fair share, then verifies its mask on that worker before execution. This is CPU eligibility, not reserved core ownership. Different masks can require migration; stable reuse is a preference.
Native references: pthread affinity and Linux cpuset constraints. The kernel can restrict an otherwise successful set operation, which is why the adapter reads back the effective mask.
Typed and JSON configuration
CE::Workers::Configuration owns pool options and a map of named group options.
CE::Workers::validate_group_options() and CE::Workers::validate_configuration() perform
structural checks without discovering topology, creating threads or binding CPUs.
Runtime availability and CPU/cache intersections are resolved by pool/group
construction. Named groups match [a-z][a-z0-9_]*.
Link Cheryl::WorkerTopology::Json and include
<cheryl/core/engine/worker-topology/configuration.h> for
CE::Workers::Json::parse_configuration(document, source) or CE::Workers::Json::load_configuration(path);
the latter also accepts a stream and source path. The supported version is "1.0":
{
"version": "1.0",
"workers": "auto",
"groups": {
"simulation": {"locality": "reuse", "concurrency": 1},
"asset_preparation": {"locality": "spread", "concurrency": "auto"}
}
}
Omitted workers default to one; omitted group concurrency defaults to pool capacity.
workers/concurrency accept "auto" or a positive integer within size_t range,
not numeric zero. Groups also accept weight 1..1024, priority 0..7 and a cpu object
with distinct nonnegative OS cpus, strength (preferred/required),
cache_domain and prefer_same_worker. Select domain IDs from the pool’s
advertised snapshot; do not assume they are portable between snapshots or machines.
NUMA fields are not in this format.
The schema
and example
define the exact shape.
Unknown versions/fields, duplicate JSON properties, invalid names/types/ranges and invalid structural options fail with a source/field diagnostic. C++ and JSON use the same structural validation. Loading is inert: callers attach a provider, create the pool and select groups themselves. It neither silently starts work nor changes existing pools/groups. It supplies no live resize/reconfiguration or process-wide affinity change. JSON loading works with hwloc omitted.
Context ownership
CE::Runtime::Context accepts CE::Runtime::ExecutionOptions: owned worker_count
(default one) or an application-supplied shared_pool. Its owned root is created
only when make_worker_group() is first called. Groups obtained there belong to that
context’s shutdown domain. Shutdown closes/drains those groups and joins an owned
root; unrelated groups on an injected pool remain open. A saved context group
rejects after context shutdown, while separately owned physical pools remain
independent. Do not destroy a context from one of its jobs. The runtime’s
shutdown sequence settles accepted
dependencies before game/resource cleanup.
The normal GLFW/OpenGL factory forwards CE::Application::GLFWOpenGL::Options::execution to the
context. Set config.execution.worker_count or config.execution.shared_pool
there before creating the backend graph; owned and borrowed input use the same
execution configuration.
The shared-pool example composes loaded or direct settings with an application-supplied backend factory and named context-owned groups. It is a separately buildable example, not a global hook.
Policy example
An application can choose a share and cap independently of CPU eligibility:
CE::Workers::GroupOptions preparation;
preparation.max_concurrency = 2;
preparation.weight = 3;
preparation.cpu.cpus = known_eligible_cpus;
preparation.cpu.strength = CE::Workers::PolicyStrength::Required;
auto group = context.make_worker_group(preparation);
auto result = group.submit([] { return prepare_owned_asset_data(); });
The CPU list must come from the pool’s advertised eligible set; it does not reserve those cores. Outside runtime, the owner must quiesce producers and service accepted platform dependencies before waiting for group drainage.
Worker regression coverage covers caps, weighted shares, capture release before drain completion, saved-handle rejection, mask switching/revalidation, actual inherited restrictions and kernel rejection, plus controlled affinity and thread-start failures. See architecture-validation.md for procedures and host limits.