We can't find the internet
Attempting to reconnect
Something went wrong!
Attempting to reconnect
← True Parallelism and the Runtime step 5 of 18
Sizing the Pool: process_cpu_count, cgroups, Oversubscription
This is the single most common cause of “our service got slower after we containerised it”, and the fix is one environment variable.
Since 3.13, multiprocessing.Pool and ProcessPoolExecutor default their
worker count from os.process_cpu_count() rather than os.cpu_count(). That
is an improvement — it respects CPU affinity, so taskset finally works.
But here is the trap: process_cpu_count() reflects affinity, not cgroup
quota. A pod with limits.cpu: 500m scheduled on a 64-core node has no
affinity mask at all. Python sees 64, spawns 64 workers, and they share half a
core. You have paid for 64 process start-ups, 64 interpreter heaps and a
storm of context switches to do the work of one worker, slowly.
The truth lives in cgroup v2 at /sys/fs/cgroup/cpu.max:
50000 100000 -> 50 ms of CPU per 100 ms period = 0.5 cores
max 100000 -> unlimited
and in cgroup v1 as cpu.cfs_quota_us over cpu.cfs_period_us, where -1
means unlimited.
CPython gives you the override: PYTHON_CPU_COUNT=4 or
-X cpu_count=4 pins every stdlib default at once — pools, executors,
os.process_cpu_count(). One variable in your deployment manifest fixes the
entire process tree, including libraries you do not control.
The second trap: nested parallelism
You size a pool to 16 processes. Each one imports NumPy. NumPy’s OpenBLAS starts one thread per core. You now have 16 x 64 = 1024 threads fighting over 64 cores, and the machine spends its time in the scheduler.
Set OMP_NUM_THREADS=1 and MKL_NUM_THREADS=1 in the pool initializer
before the first BLAS call — the thread pool is created lazily on first
use, so the environment variable has to be in place first — or size your pool
to cores // blas_threads. Pick one; do not accidentally pick neither.
Your task
The real function reads files, so make it take a cgroup_root — here, the
file contents directly — and it becomes trivially testable. This is not a
concession to the exercise; it is how you would make the production version
testable too.
def solve(
*,
cgroup: dict[str, str], # filename -> contents
affinity: int | None, # len(os.sched_getaffinity(0)), or None off-POSIX
logical: int, # os.cpu_count()
env: dict[str, str],
) -> tuple[float | None, int | None, int, int, Source]:
Return (quota, affinity, logical, recommended_workers, source), resolving in
this order:
-
PYTHON_CPU_COUNTparsing to a positive integer -> source"env". ("0"is ignored — CPython treats it as unset.) -
cpu.maxpresent with a numeric first field -> quota, source"cgroup_v2". A first field ofmaxmeans unlimited: fall through. -
cpu.cfs_quota_uspositive, overcpu.cfs_period_us-> quota, source"cgroup_v1".-1means unlimited: fall through. -
affinity known -> source
"affinity". -
otherwise -> source
"logical".
When a quota exists, recommend max(1, min(floor(quota), ceiling)) where the
ceiling is affinity if known, else the logical count. Half a core still needs
one worker; four cores of quota on a two-core affinity mask still gets two.
The typing lesson
os.process_cpu_count() -> int | None
The Optional is not decorative — it is None when the count is
undeterminable, and code that writes range(os.process_cpu_count()) fails
--strict for a genuinely good reason.
And os.sched_getaffinity does not exist on Windows or macOS. typeshed
guards it with sys.platform, so mypy will reject an unguarded call when
checking for those platforms. The fix is if sys.platform == "linux":, not a
hasattr check — mypy narrows on the former and treats the latter’s result as
Any.
Loading visualization…
Stuck?
Python reference solution
Sign in to attempt this problem and reveal the reference solution.