Parallelism
By the end of this page you will know how many worker threads ScramDB spawns under a container or OS CPU limit, how SCRAMDB_MAX_CORES overrides that, how the workers config key shares those threads across one query's morsel dispatch (a different question from how many threads exist), and how to control NUMA and core placement, which is not a ScramDB config key at all but an OS-level lever.
Worker countβ
[execution]
workers = "auto"
| Key | Default | What it controls |
|---|---|---|
workers | "auto" | Either the string "auto" (case-insensitive) or a positive integer. "auto" dispatches a query's morsel scheduling across every core ScramDB detects at startup. |
An explicit integer caps how many of those cores a query's morsel scheduling dispatches across. Setting it higher than the physically (or cgroup) available core count has no effect, ScramDB caps the resolved value to what is actually available. Setting it to 0 is rejected at config-parse time with an error, it is not silently treated as "auto" or "unlimited."
Tune this down to leave CPU headroom for a co-located process (another database, an application server on the same box). There is no benefit to tuning it up past your physical core count. See How many worker threads exist below for that count in the first place, a pool-level question this key does not answer.
How many worker threads existβ
ScramDB's execution engine is thread-per-core: at startup it spawns one long-lived worker thread per core in its resolved core set, and query execution assumes exactly one runnable worker per core. Spawning more workers than cores oversubscribes the CPUs, so runnable threads queue behind each other and pay context-switch and cache-eviction costs instead of running in parallel; spawning fewer leaves cores idle. Getting this count right is not a tuning nicety, it is the difference between the thread-per-core model working as designed and quietly degrading into oversubscribed scheduling.
The count comes from a three-step resolution at startup. First, ScramDB reads the process's cpuset identity from sched_getaffinity, the set of cores the OS or container runtime actually allows it to run on. Second, it sorts that set by NUMA node and then by core id, so that if the set has to be truncated, the cores that are kept stay topologically close instead of scattered across NUMA nodes. Third, it bounds the length of that sorted set by std::thread::available_parallelism(), which is itself cgroup CFS-quota aware, giving the actual worker count. An unconstrained server, one with no container CPU limit and no SCRAMDB_MAX_CORES set, resolves to the same core set as always, byte-identical to before this resolution logic existed.
SCRAMDB_MAX_CORES=N overrides both detection paths, the cpuset identity and the quota-derived bound alike; explicit always beats inferred:
SCRAMDB_MAX_CORES=8 scramdb -c scramdb-config.toml
It selects N topology-compact cores using the same NUMA-node-then-core-id ordering as the automatic path, and if you run more than one ScramDB instance on the same host, each instance rotates onto a different, non-overlapping core window instead of all of them contending for the same cores. It bounds every worker pool, not just the one used for ordinary query execution; a previous version left a secondary pool (used for TPC workloads) unbounded, so it could spawn 36 workers on a host where SCRAMDB_MAX_CORES limited the main pool to 8. That gap is closed: the limit now applies uniformly across pools.
None of this touches memory sizing: the buffer pool and execution memory pools already read the container's cgroup memory limit and are unaffected by any of the above. See Memory.
| Limit style | Workers spawned |
|---|---|
docker run --cpuset-cpus="0-7" ... | 8, pinned exclusively to those 8 cores |
docker run --cpus=8 ... | 8, quota-bounded (workers share physical cores under CFS scheduling) |
Kubernetes: Guaranteed QoS, an integer CPU request, kubelet static CPU manager policy | Exclusive cores, matching the request |
Kubernetes: resources.limits.cpu under the default (none) CPU manager policy | Quota-bounded, same sharing behavior as --cpus |
SCRAMDB_MAX_CORES=N | N, topology-compact, applied to every pool, overrides both detection paths above |
Given a choice, work down this ladder:
- Exclusive cpuset, best. Docker
--cpuset-cpus, or Kubernetes Guaranteed QoS plus an integer CPU request plus the kubeletstaticCPU manager policy. Neither has CFS throttling jitter, and the pinned cores keep their cache and NUMA locality. Confirm the kubelet is actually running thestaticpolicy before relying on this: the defaultnonepolicy silently falls back to quota-bounded sharing even for a Guaranteed pod with an integer request. - Quota (
--cpus/ defaultlimits.cpu), correct. ScramDB now resolves the right worker count under a CFS quota. Workers share physical cores under CFS scheduling, so tail latency is less predictable than an exclusive cpuset. Measured on SF1 TPC-H, single query stream, an 8-core budget on a 36-core host: best-of-sum latency was 2,387.8 ms under an exclusive cpuset versus 2,345.1 ms under a quota, within about 2% of each other, throughput parity at this light, single-stream load. Treat the fix's value as structural, not a throughput win: the right thread count with no oversubscription and predictable behavior as concurrency rises, not a faster single query. SCRAMDB_MAX_CORES, manual override. Use it for an explicit, topology-compact core count regardless of what the container reports, or when you run several ScramDB instances on one host and want each to claim a distinct core window.
Pool size, how many worker threads exist, is set by the container or OS CPU limit, or overridden by SCRAMDB_MAX_CORES. It is not a ScramDB config key. [execution] workers is a ceiling on how many of those already-running threads a single query's morsel dispatch can use; it never changes how many threads exist, and there is no way to raise the pool size from workers.
Mixed transactional and analytical loadβ
ScramDB runs transactional and analytical statements on the same worker pool. There is one pool: no separate query tier, and no separate service for either kind of work.
Classification happens automatically from a statement's dispatch shape alone: any DML statement, or a query dispatching transactional_morsel_threshold morsels or fewer, classifies as transactional and rides the scheduler's priority lane. There is no user-visible setting, hint, or session variable that sets or overrides this.
A transactional statement's tail latency is bounded by the duration of one analytical morsel: an analytical worker checks the priority lane at each morsel boundary and yields to at most one queued transactional task before continuing its own work. Analytical latency is bounded in the other direction by aging_promotion_ms: analytical work that has waited longer than that ceiling is served ahead of further transactional work, so a flood of short statements cannot starve analytical queries indefinitely.
Scheduling changes only the order work runs in, never a query's result. This is property-tested: randomized concurrent interleavings of transactional and analytical work are checked against the serial reference execution and must produce identical results.
See Execution for transactional_morsel_threshold and aging_promotion_ms, their valid ranges, and startup validation.
Background pool priorityβ
Background maintenance (segment compaction, statistics auto-analyze, version-chain garbage collection) runs at the lowest CPU scheduling priority and the idle I/O class, and the JIT compile pool runs at a reduced CPU priority, so neither competes with query workers for CPU or disk bandwidth. The startup log prints one priority probe line per pool, stating the priority it requested versus what it actually got: a container without CAP_SYS_NICE cannot lower a pool's priority, so that pool keeps running at the default priority and its probe line logs a WARN instead of failing startup, a deliberate degrade rather than a bug.
Related execution knobsβ
[execution]
morsel_size = "4MB"
parallel_build_threshold = 10000
| Key | Default | What it controls |
|---|---|---|
morsel_size | "4MB" | Target bytes per morsel for a byte-size-aware table scan. Smaller morsels improve load balance across skewed workers at the cost of more scheduling overhead. |
parallel_build_threshold | 10000 | Minimum build-side row count before a hash join's build phase parallelizes across threads. Below this, the build runs single-threaded, the coordination overhead is not worth it at small sizes. |
NUMA and core placementβ
ScramDB does not expose a NUMA-node or explicit-core-list config key. NUMA topology detection and buffer-pool frame placement across NUMA nodes are fully automatic at startup: ScramDB detects the topology (container-aware, via the OS scheduling affinity mask mapped to NUMA nodes) and places buffer-pool memory accordingly, with no operator input.
The only ScramDB-side lever over how many cores are used is workers, a count of threads to share, not a choice of specific cores; see How many worker threads exist above for how the pool itself gets its core set. Choosing which cores or NUMA nodes ScramDB runs on is an OS or container-level concern. On bare metal or a VM, taskset -c 0-15 scramdb -c scramdb-config.toml ... pins the process before it starts, setting the same cpuset identity (sched_getaffinity) the pool resolution reads. Under Docker or Kubernetes, the cpuset and static-policy mechanisms from the table above pin specific cores as a side effect of also fixing the worker count.
Verifying the resolved core countβ
Every startup logs the resolution as one line:
core set: affinity=A quota=Q resolved=R (reason)
affinity is the cpuset size sched_getaffinity reported, quota is the cgroup CFS-quota-derived bound if one is set, resolved is the actual worker count the pool spawned, and reason names which input decided it (cpuset, cgroup quota, SCRAMDB_MAX_CORES, or unconstrained). The verification recipe is simple: set your limit, start the server, and check resolved against the limit you set. For example, --cpus=8 on a 36-core host should log something like core set: affinity=36 quota=8 resolved=8 (cgroup quota).
Verifying a workers changeβ
There is no per-worker utilization metric on /metrics. Verify with timing and OS-level observation:
- Pick a CPU-bound query, a large hash join or aggregate over a table too big to be a single-morsel scan.
- Run it with
\timing onat your currentworkerssetting, note the latency. - Change
workers, restart, run the same query again.
psql -h 127.0.0.1 -p 5432 -c '\timing on' -c "SELECT customer_id, sum(amount) FROM orders GROUP BY customer_id;"
Expected: latency drops as workers increases, up to the resolved worker count from the section above, then flattens. While the query runs, htop should show CPU utilization scaling with the configured worker count. If it fails: latency does not improve past a low worker count, the query may be I/O-bound rather than CPU-bound, check disk throughput instead, or the table may be too small for parallelism to help at all.
Nextβ
See Memory for the buffer-pool sizing that usually matters more than worker count, or GPU Acceleration for the next lever after CPU parallelism is maxed out.