Worker Tuning
Status: Current Last updated: 2026-09-15 18:27 EDT
This page explains how the server decides how many workers to run, how memory budgets work, and how to tune the server for your hardware.
The --workers flag
Control how many files are processed in parallel:
batchalign3 --workers 1 transcribe corpus/ -o output/ # One file at a time (safest)
batchalign3 --workers 4 morphotag corpus/ -o output/ # Four files in parallel
batchalign3 transcribe corpus/ -o output/ # Auto-tune (default)
All commands now use a two-stage policy: the runner computes a requested
worker count from file count, CPU, and category caps, then the host-memory
coordinator clamps that request to what the machine can safely fit right now.
GPU-heavy commands (transcribe, align, benchmark) are capped by both
max_gpu_workers and gpu_thread_pool_size.
CPU-only machines (Apple Silicon, no CUDA): the host-facts recommendation now sets
gpu_thread_pool_size = 1automatically when no functional GPU is detected. Leave the field absent inserver.yaml(or set it to0: the legacy “auto” sentinel that still deserializes to “no override”). The recommendation also setsforce_cpu = trueon the same hosts so workers skip GPU detection entirely.PyTorch releases the GIL only during CUDA/MPS native calls; with MPS excluded for batchalign3, every Whisper inference is GIL-bound CPU work and there is no compute parallelism to gain. A higher value lets multiple
execute_v2calls into a single Python process where they fight for cores, slowing each other down by the contention factor. Verify your host’s resolved values withbatchalign3 doctor --check(see Doctor).The Rust-side
dispatch_semaphoremirrorsgpu_thread_pool_sizepermit-for-thread, so Rust dispatch and Python serving share one ceiling. Set this knob to the parallelism your device actually has: 1 on CPU-bound platforms, 2-4 on real GPU. See MPS Exclusion Decision and Worker Protocol V2 § The dispatch semaphore contract.Measured Apple behavior on
dev-machineconfirms that the biggest win is not a larger CPU thread pool. Warm loopback-daemon reuse dominated everything else:align --no-utr --fa-engine wav2vecdropped from17.65sdirect /9.64ssequential to1.06swarm-daemon, andtranscribe --asr-engine whisperdropped from93.92sdirect to13.58swarm-daemon. On Apple CPU-only hosts, preserving warm workers matters much more than tuninggpu_thread_pool_size.
Override with --workers N when you want explicit control, or set
max_workers_per_job in server.yaml for a persistent override.
How worker planning works
When you submit a job, the server decides how many parallel file workers to assign.
- Compute a requested worker count from file count, CPU, and category caps:
- GPU commands:
min(max_gpu_workers, gpu_thread_pool_size) - CPU/IO commands:
max_thread_workers
- GPU commands:
- Ask the host-memory coordinator for a job execution plan.
- The coordinator subtracts active local reservations, preserves
memory_gate_mbas host headroom, and grants the largest safe worker count. - If nothing safe fits, the job is re-queued instead of speculatively running.
For a single file, the server always uses 1 worker, no parallelism needed.
If max_workers_per_job is set in server.yaml, it overrides auto-tuning
(still capped by file count and the category max).
Why GPU commands allow parallelism: GPU-heavy commands share a single
SharedGpuWorker process with a thread pool. While file N’s ASR runs on the
GPU, file N+1 can do post-processing, utseg, or morphosyntax on CPU. The GPU
itself serializes inference, but pipeline stages overlap. On a machine with
256 GB RAM, the coordinator may grant 4-8 parallel files for transcribe.
Worker profiles and host bootstrap mode
The server groups related commands into three worker profiles that share loaded models within a single process:
| Profile | Commands | What it shares |
|---|---|---|
| GPU | align, transcribe, transcribe_s, benchmark | Whisper, Wave2Vec, and speaker models in one process |
| Stanza | morphotag, utseg, coref, compare | Stanza NLP models (POS, constituency, coreference) |
| IO | translate, opensmile, avqi | Lightweight translation and audio analysis |
On large machines, this means running align followed by transcribe reuses
the same GPU worker process, the ASR model loaded for transcription stays in
memory and the FA model for alignment lives in the same process. On a 64 GB
machine, this saves roughly 3 GB compared to loading each model in a separate
process.
On small-memory hosts, the server now resolves a different host execution
policy: local workers use task bootstrap instead of full profile bootstrap.
That lets a weak laptop load only infer:asr or infer:morphosyntax instead
of speculatively loading every model in a profile. The machine trades some
reuse for a much lower idle footprint.
GPU workers handle multiple requests concurrently via internal threading only on CUDA-capable hosts. On CPU-only hosts they stay sequential to avoid oversubscribing OpenMP threads. Stanza and IO workers handle one request at a time but can run multiple processes in parallel for CPU-bound workloads.
Per-command memory profiles
Each command loads different ML models with different memory footprints. These
values come from runtime_constants.toml (generated from crates/batchalign-types/src/command_spec.rs
via xtask gen-runtime-toml; shared between Rust and Python at compile/import time):
| Command | Memory per worker (MB) | What drives it |
|---|---|---|
morphotag | 2,000 | Stanza POS/lemma/depparse models (per language) |
align | 4,000 | Whisper or Wave2Vec forced alignment model |
transcribe | 1,500 | Whisper ASR model |
utseg | 2,000 | Stanza constituency parser |
translate | 4,000 | Translation model (Seamless M4T or Google) |
coref | 2,000 | Stanza coreference model |
opensmile | 500 | Lightweight feature extractor |
avqi | 1,500 | Voice quality analysis |
compare | 2,000 | Stanza models (for normalization) |
These are the thread worker values (shared-model mode). Process worker values are higher because each worker loads its own copy of the models.
Commands in the same profile share a worker process, so the total memory for
a mixed job (e.g., align + transcribe) is roughly the sum of their models
loaded once, not separately. The GPU profile typically uses ~5 GB total for all
its models (ASR + FA + Speaker), regardless of how many commands run.
Worker pre-spawning
There is no startup warmup. Workers are created on demand, by two paths:
Per-job pre-scaling. When a job runs more than one file concurrently, the runner pre-spawns its workers before file dispatch begins, so a batch pays one cold start rather than one per file. The pre-spawned worker is keyed on the job’s own engine selection, so the dispatches that follow reuse it rather than spawning a second process.
Registry adoption. A TCP worker daemon started outside the server registers
itself in workers.json; the server adopts it at startup and routes to it like
any other worker. Each entry records the build that started the daemon
(build_identity, from BATCHALIGN_BUILD_IDENTITY, which batchalign3 worker start and the server’s own daemon spawner set). A server refuses to adopt a
daemon from another build, or one whose entry names no build, and logs the
remedy: stop the daemon (batchalign3 worker stop) and start it again with the
current build. The refused daemon is left running and listed in /health
under refused_registry_workers, with its worker key, pid and reason
(foreign_build or unreported_build).
Idle workers are then reclaimed by memory-pressure eviction (largest resident set first) rather than by a fixed timeout.
Retired: a
--warmupflag and awarmup_commandsconfig key used to pre-load a list of commands’ models when the server started. They were disabled on every real server from 2026-03-26 (following the 2026-03-11 finding that warmed models stay resident for the server’s whole lifetime) and removed on 2026-07-30. Passing--warmupis now an unrecognised-argument error, andwarmup_commandsin aserver.yamlis rejected as an unknown field; delete the key. Nothing else changes: neither had any effect on a real server for the four months before removal.
On-demand loading
No workers are pre-loaded at startup; they spawn lazily on the first job that needs them. Lazy startup does not mean the first real command is allowed to run against unknown infer-task metadata. The current server resolves that by forcing a live capability probe from the worker it is actually about to use. In practice:
/healthmay still show an optimistic command surface immediately after boot- the first real job pays the worker startup cost and records the detected infer-task view
- later jobs reuse that detected capability view instead of the cold-start placeholder
alignis advertised whenever a worker supports forced alignment, even before its FA model has loaded; an align job loads the model first and fails only if the worker still names no FA engine after that- a worker whose capability report was refused is never used, and is listed
with the reason in
/healthunderworker_capability_admissions
server.yaml reference
Key tuning parameters:
# Worker parallelism
max_workers_per_job: 0 # 0 = auto-plan from files, CPU, and category caps
max_concurrent_jobs: 0 # 0 = CPU-based runner slot cap
gpu_thread_pool_size: 4 # Concurrent execute_v2 per shared GPU worker.
# Rust dispatch_semaphore + Python ThreadPoolExecutor
# share this ceiling: set to the device's real
# parallelism (1 on Apple Silicon CPU-only;
# 2-4 on CUDA where the GIL is released).
max_concurrent_worker_startups: 1
# Memory tier override: force a specific tier instead of auto-detecting
# from total RAM. Values: small, medium, large, fleet. Omit to auto-detect.
# memory_tier: small
# Host-memory reserve/headroom (MB) preserved after reservations
# 0 = disable explicit reserve. Default: 2048 MB (one absolute floor on
# every host; the worker-pool admission gate enforces the same number
# live on every spawn attempt, see book/src/batchalign/developer/memory-safety.md)
memory_gate_mb: 4000
# Per-profile startup reservation overrides (MB). 0 = use tier default.
# These control how much memory the coordinator reserves while a worker
# loads its models. Reduce on small machines if the tier defaults are
# too conservative for your actual model sizes.
# gpu_startup_mb: 6000
# stanza_startup_mb: 3000
# io_startup_mb: 2000
# Worker lifecycle
# Idle workers are evicted by host memory pressure (largest-RSS first
# when available memory drops below the eviction threshold); there is
# no fixed idle timeout. Health checks run every `worker_health_interval_s`.
worker_health_interval_s: 30 # Health check frequency
Scenarios
16 GB laptop / shared developer machine
# The Small tier (<24 GB) auto-detects these defaults, so this config
# is only needed if you want to further customize on a small machine.
memory_tier: small # Force small tier (auto-detected on <24 GB)
max_workers_per_job: 1
memory_gate_mb: 2000 # Small tier default
stanza_startup_mb: 3000 # Actual Stanza RSS is ~2-3 GB
gpu_startup_mb: 6000 # Whisper float32 is ~4-5 GB
max_concurrent_worker_startups: 1
gpu_thread_pool_size: 1
The Small tier now also switches local workers to task bootstrap and clamps eligible file-parallel commands to one file at a time. That keeps the execution shape honest for 16 GB laptops: no speculative profile preload, no multi-file GPU stampede, and no assumption that the machine can afford idle models it is not about to use.
32 GB desktop
Default settings usually work well. The coordinator will clamp jobs as host pressure changes.
256 GB server (production)
max_workers_per_job: 0 # Coordinator-backed auto planning
max_concurrent_jobs: 8
max_concurrent_worker_startups: 1
memory_gate_mb: 8000 # Operator override; the default is 2048 MB (MIN_FREE_MEMORY_MB)
With this much RAM, worker profiles let the server run multiple concurrent jobs
efficiently. A GPU worker handling an align job and a Stanza worker handling a
morphotag job run in parallel without duplicating models, leaving plenty of
headroom for additional jobs.
Testing with test-echo workers
For quick iteration during development:
batchalign3 serve start --foreground --test-echo
Workers start instantly (no ML models loaded). Useful for testing server infrastructure without waiting for model initialization.
Troubleshooting
“Job deferred due to memory pressure”
The host-memory coordinator could not fit the requested execution plan. Possible causes:
- Too many concurrent workers. Reduce
max_workers_per_joborgpu_thread_pool_size. - Other processes using RAM. Check system memory usage.
- Idle workers holding memory. Workers that haven’t been used in a while still hold their loaded models. The pool’s pressure-driven eviction releases idle workers automatically when host available memory drops below the eviction threshold; if pressure is genuine but eviction isn’t firing fast enough, restart the server to reclaim immediately.
- Another local batchalign3 server or test run is already holding leases.
Check
/healthforhost_memory_*fields.
Jobs are re-queued when the plan does not fit. /health now reports
host_memory_pressure, current reservations, and active lease labels.
Only 1 worker running
The coordinator decided that only 1 worker currently fits. Check:
/healthhost_memory_pressureandhost_memory_reserved_mbmemory_gate_mbgpu_thread_pool_sizefor GPU commands- other local
batchalign3servers or ML tools on the same host
Override with max_workers_per_job if you know your system can handle more.
The first job takes too long
The first job of a given kind loads its ML models from disk, or downloads them on first run. To speed up:
- The first run after installation is slowest (model downloads)
- Subsequent starts load from the model cache (~5-20 seconds per model)
- Keep the daemon running (
batchalign3 serve start) to avoid repeated cold starts
See also Performance and Server Mode.
This page last changed: 2026-09-16 (commit 197c81e6). The whole book last changed: 2026-09-16 (commit 34d249d8).