Cap CPU accelerator memory at the cgroup limit of the process (#8930)
**Issue**
`CPU_Accelerator.total_memory()` and `available_memory()` return host
RAM from `psutil.virtual_memory()`. In a container or a Kubernetes pod,
the process can use only its cgroup limit. Callers that plan memory from
these values on the CPU accelerator include ZeRO 1/2 flattening
(`stage_1_and_2.py:451`) and the autotuner. Fixes #8929.
Related: #1891 asks for cgroup-aware CPU memory limits. #3458 (Docker),
#2632 and #2788 (Slurm) report processes killed by a cgroup limit
without a message.
**Fix**
Only the CPU accelerator, its tests and its docs change.
- `total_memory()` returns the smallest memory limit from the process
cgroup up to the root, capped at host RAM.
- `available_memory()` returns the smallest free headroom over every
limited cgroup on that path. A parent limit also counts the usage of its
other children, so each limited level is checked. Usage excludes the
inactive page cache, which the kernel can reclaim.
- The cgroup mount point and mount root come from
`/proc/self/mountinfo`. When several mounts match, the one whose root
contains the process cgroup is used. This supports mounts that expose a
subtree, cgroup v1 co-mounts such as `cpu,memory`, and delegated
subtrees mounted before the main mount.
- On by default, as asked in review. `RESPECT_CGROUP_MEMORY_BY_DEFAULT =
True` is the one line that sets the default.
`DS_CPU_RESPECT_CGROUP_MEMORY=0` turns it off, `=1` turns it on.
`export_envs()` returns the variable, so the launcher forwards it to
every node.
- The accelerator setup guide documents the variable.
`abstract_accelerator.py` does not change.
**How to test**
Needs Docker with cgroup v2. Save the first script from the collapsed
sections at the end as `check_cgroup_memory.sh`. One command installs
`master` (before) and this branch (after) in the same container with 1
GiB. It prints `total_memory()` and `available_memory()` from three
cgroups with `DS_CPU_RESPECT_CGROUP_MEMORY` unset, and once more with
`=0`.
```sh
sh check_cgroup_memory.sh "git+https://github.com/deepspeedai/DeepSpeed@master" \
"git+https://github.com/prabhkodes/DeepSpeed@cpu-accelerator-cgroup-memory"
```
Output (Ubuntu 24.04 VM, kernel 6.8, 3.8 GiB, cgroup v2, Docker 29.1
with the systemd driver):
```
process cgroup (container limit 1 GiB) total available
before A. container root 3904 MiB 2773 MiB
before B. child cgroup, limit 256 MiB 3904 MiB 2774 MiB
before C. child 600 MiB; parent 800 MiB, sibling uses 500 3904 MiB 2685 MiB
after A. container root 1024 MiB 342 MiB
after B. child cgroup, limit 256 MiB 256 MiB 90 MiB
after C. child 600 MiB; parent 800 MiB, sibling uses 500 600 MiB 129 MiB
after A. container root, DS_CPU_RESPECT_CGROUP_MEMORY=0 3904 MiB 2755 MiB
```
Before, every case reports the host. After, `total` is the limit of each
case. In C, `available` is the free headroom of the 800 MiB parent,
which the 500 MiB sibling also uses. In A, the container's 1 GiB also
holds that sibling. With `=0`, the values are host RAM again.
On a cgroup v1 host, the second script, `check_cgroup_v1.sh`, runs the
same cases with the same arguments. Same VM booted with
`systemd.unified_cgroup_hierarchy=0`, Docker 29.1 with the cgroupfs
driver and its default host cgroup namespace:
```
process cgroup (container limit 1 GiB) total available
before A. container root 3904 MiB 2850 MiB
before B. child cgroup, limit 256 MiB 3904 MiB 2814 MiB
before C. child 600 MiB; parent 800 MiB, sibling uses 500 3904 MiB 2781 MiB
after A. container root 1024 MiB 175 MiB
after B. child cgroup, limit 256 MiB 256 MiB 89 MiB
after C. child 600 MiB; parent 800 MiB, sibling uses 500 600 MiB 129 MiB
after A. container root, DS_CPU_RESPECT_CGROUP_MEMORY=0 3904 MiB 2672 MiB
```
Each script ran twice per cgroup version. `total` and the C row are
identical in all runs. `available` in A and B reads live usage, so it
moves between runs.
**Proof**
ZeRO training on the CPU accelerator, three Adam steps, flag `0` and
`1`, two fresh containers each:
| processes | precision | ZeRO 1 | ZeRO 2 | ZeRO 3 |
|---|---|---|---|---|
| 1 | fp32 | identical losses | identical losses | identical losses |
| 1 | bf16 | identical losses | identical losses | identical losses |
| 2 (`deepspeed` launcher, gloo) | fp32 | identical losses | identical
losses | identical losses |
**Performance**
Throughput and peak RSS, flag `0` and `1` (`1` is the code path that is
now the default), 30 timed steps on a 4×1024 MLP. Four runs per cell,
two fresh containers, flag alternated. Median steps/s with range:
| processes | precision | ZeRO | steps/s, flag 0 | steps/s, flag 1 |
peak RSS, flag 0 / 1 |
|---|---|---|---|---|---|
| 1 | fp32 | 1 | 23.8 (22.9–24.7) | 24.2 (23.2–25.2) | 575 / 575 MiB |
| 1 | fp32 | 2 | 24.3 (24.0–24.9) | 23.3 (22.4–24.1) | 572 / 571 MiB |
| 1 | fp32 | 3 | 22.1 (21.1–26.5) | 22.1 (20.9–22.7) | 606 / 606 MiB |
| 1 | bf16 | 1 | 41.5 (37.7–42.4) | 41.2 (40.7–42.5) | 530 / 530 MiB |
| 1 | bf16 | 2 | 42.0 (40.1–42.5) | 41.6 (33.5–42.6) | 530 / 530 MiB |
| 1 | bf16 | 3 | 54.1 (45.2–55.4) | 54.2 (51.3–56.7) | 556 / 556 MiB |
| 2 | fp32 | 1 | 11.1 (10.8–11.4) | 11.4 (10.4–11.7) | 524 / 524 MiB |
| 2 | fp32 | 2 | 11.2 (10.3–12.4) | 11.9 (11.2–12.6) | 524 / 522 MiB |
| 2 | fp32 | 3 | 11.0 (10.7–11.1) | 10.6 (9.9–11.0) | 564 / 564 MiB |
Peak RSS does not change. The throughput differences go in both
directions and stay inside the run-to-run range. A call counter shows
why: ZeRO 1 and 2 call the memory queries once at setup, ZeRO 3 does not
call them, and no configuration calls them during training steps.
Cost of one query, best of 5 × 5000 calls, process three levels below
the cgroup root:
| | `total_memory()` | `available_memory()` |
|---|---|---|
| master | 12.4 µs | 12.5 µs |
| this PR, `=0` | 13.1 µs | 12.9 µs |
| this PR, default (on) | 81.5 µs | 80.7 µs |
Measured on ca6a5a6, before the default changed; the two rows are the
same code paths. With the cap on, a query reads `/proc/self/cgroup`,
`/proc/self/mountinfo` and the cgroup files: about 70 µs more, once per
run.
```
pytest tests/unit/v1/accelerator/test_accelerator.py # cgroup v1 and v2 host: 33 passed, 2 skipped
# in a 1 GiB container: 30 passed, 5 skipped (the pin_memory op cannot be built there)
yapf 0.43.0, flake8 5.0.4 --config=.flake8 # the pre-commit versions: no changes, no findings
```
Twelve new tests. They cover a limit on a parent cgroup, the smallest
limit, the v1 sentinel, the headroom of every limited level, mountinfo
resolution with several mounts, the launcher export, and default ×
override. One test uses `/proc/self/cgroup`, the cgroup mountinfo lines
and the memory files captured in a Docker container on the cgroup v1
host above, so the v1 path is checked against a real layout. With the
mount-root strip removed from the code, that test fails and the
hand-written v1 tests still pass.
**Hardware.** Apple M4 host, Docker Desktop Linux 6.10 VM (aarch64, 10
CPUs, 7.8 GiB, cgroup v2). Python 3.12.16, torch 2.14.1+cpu,
`DS_BUILD_OPS=0`. No GPU. cgroup v1, the v2 re-run and the unit tests
above: Lima VM on the same host, Ubuntu 24.04, kernel 6.8.0-142
(aarch64, 4 vCPU, 3.8 GiB), Docker 29.1.3, Python 3.12.16, torch
2.15.0+cpu.
**Compatibility matrix.**
- Validated: CPU accelerator; ZeRO 1, 2 and 3; fp32 and bf16; 1 and 2
processes; no offload. Memory values on cgroup v1 and v2.
- Not reachable on the CPU accelerator: `deepspeed.compile`
(`is_deepcompile_supported()` requires CUDA) and the inference v2 KV
cache (CUDA kernels only).
- Not validated: CPU and NVMe offload, fp16, multi-node runs (the
launcher export is unit-tested only).
- Not affected: CUDA, XPU, NPU, MLU, HPU, SDAA, SUPA and MPS read their
own device memory. `see_memory_usage()` and the timer read host RAM only
to log it.
No open PR changes the CPU accelerator memory functions (searched
`cgroup`, `cpu_accelerator available_memory`, `cpu accelerator memory`,
`1891 in:body` on 2026-10-27).
AI assistance was used: an AI coding agent (Claude Code) wrote the
change and the tests; I reviewed each changed line and ran the tests,
the reproduction and the ZeRO runs.
<details>
<summary>check_cgroup_memory.sh (cgroup v2)</summary>
```sh
#!/bin/sh
# Before/after check of DeepSpeed's CPU accelerator memory values inside cgroups. Needs Docker with cgroup v2.
# sh check_cgroup_memory.sh <before pip spec> <after pip spec>
# Example:
# sh check_cgroup_memory.sh "git+https://github.com/deepspeedai/DeepSpeed@master" \
# "git+https://github.com/prabhkodes/DeepSpeed@cpu-accelerator-cgroup-memory"
# A pip spec can also be a source tarball in the directory given as SRC_DIR (mounted at /src).
# IMG replaces the python:3.12-slim image, e.g. with one that has torch installed.
set -eu
docker run --rm -i --privileged --cgroupns=private --memory=1g ${EXTRA_DOCKER_ARGS:-} ${SRC_DIR:+-v "$SRC_DIR:/src:ro"} \
-e BEFORE="${1:?before spec}" -e AFTER="${2:?after spec}" "${IMG:-python:3.12-slim}" sh -s <<'INNER'
set -e
export DEBIAN_FRONTEND=noninteractive
apt-get -qq update >/dev/null 2>&1 && apt-get -qq install -y git >/dev/null 2>&1
pip install -q torch --index-url https://download.pytorch.org/whl/cpu >/dev/null 2>&1
pip install -q psutil numpy pydantic packaging py-cpuinfo tqdm hjson msgpack ninja einops >/dev/null 2>&1
cat > /tmp/probe.py <<'PY'
from deepspeed.accelerator import get_accelerator
a = get_accelerator()
print(f"{a.total_memory() >> 20:>7} MiB {a.available_memory() >> 20:>7} MiB")
PY
cat > /tmp/hold.py <<'PY'
import time
b = b"\x01" * (500 << 20)
time.sleep(3600)
PY
C=/sys/fs/cgroup
mkdir -p $C/init; for p in $(cat $C/cgroup.procs); do echo $p > $C/init/cgroup.procs 2>/dev/null || true; done
echo "+memory" > $C/cgroup.subtree_control
mkdir -p $C/nested; echo $((256 << 20)) > $C/nested/memory.max
mkdir -p $C/shared; echo $((800 << 20)) > $C/shared/memory.max; echo 0 > $C/shared/memory.swap.max
echo "+memory" > $C/shared/cgroup.subtree_control
mkdir -p $C/shared/sibling $C/shared/me; echo $((600 << 20)) > $C/shared/me/memory.max
sh -c "echo \$\$ > $C/shared/sibling/cgroup.procs; echo -1000 > /proc/self/oom_score_adj; exec python /tmp/hold.py" &
sleep 3
# run <version> <label> <cgroup> [VAR=value]: the probe runs with the flag unset unless given.
run() {
printf "%-7s %-56s" "$1" "$2"
sh -c "echo \$\$ > $C/$3/cgroup.procs; DS_ACCELERATOR=cpu ${4:-} exec python /tmp/probe.py 2>/dev/null"
}
printf "%-7s %-56s %11s %11s\n" "" "process cgroup (container limit 1 GiB)" "total" "available"
for v in before after; do
if [ $v = before ]; then spec=$BEFORE; else spec=$AFTER; fi
DS_BUILD_OPS=0 pip install -q --no-deps --force-reinstall "$spec" >/dev/null 2>&1
run $v "A. container root" init
run $v "B. child cgroup, limit 256 MiB" nested
run $v "C. child 600 MiB; parent 800 MiB, sibling uses 500" shared/me
done
run after "A. container root, DS_CPU_RESPECT_CGROUP_MEMORY=0" init DS_CPU_RESPECT_CGROUP_MEMORY=0
INNER
```
</details>
<details>
<summary>check_cgroup_v1.sh (cgroup v1)</summary>
```sh
#!/bin/sh
# Before/after check of DeepSpeed's CPU accelerator memory values on a cgroup v1 host. Run as root on the host.
# Docker's default on cgroup v1 is the host cgroup namespace: /proc/self/cgroup shows the host path of the
# container, and the cgroup mount inside the container starts at the container's own cgroup.
# sh check_cgroup_v1.sh <before pip spec> <after pip spec>
# Example:
# sh check_cgroup_v1.sh "git+https://github.com/deepspeedai/DeepSpeed@master" \
# "git+https://github.com/prabhkodes/DeepSpeed@cpu-accelerator-cgroup-memory"
# A pip spec can also be a source tarball in the directory given as SRC_DIR (mounted at /src).
# IMG replaces the python:3.12-slim image, e.g. with one that has torch installed.
set -eu
[ "$(stat -fc %T /sys/fs/cgroup)" = tmpfs ] || { echo "needs a cgroup v1 host" >&2; exit 1; }
docker run --rm -i --privileged --memory=1g ${SRC_DIR:+-v "$SRC_DIR:/src:ro"} \
-e BEFORE="${1:?before spec}" -e AFTER="${2:?after spec}" "${IMG:-python:3.12-slim}" sh -s <<'INNER'
set -e
export DEBIAN_FRONTEND=noninteractive
apt-get -qq update >/dev/null 2>&1 && apt-get -qq install -y git >/dev/null 2>&1
pip install -q torch --index-url https://download.pytorch.org/whl/cpu >/dev/null 2>&1
pip install -q psutil numpy pydantic packaging py-cpuinfo tqdm hjson msgpack ninja einops >/dev/null 2>&1
cat > /tmp/probe.py <<'PY'
from deepspeed.accelerator import get_accelerator
a = get_accelerator()
print(f"{a.total_memory() >> 20:>7} MiB {a.available_memory() >> 20:>7} MiB")
PY
cat > /tmp/hold.py <<'PY'
import time
b = b"\x01" * (500 << 20)
time.sleep(3600)
PY
C=/sys/fs/cgroup/memory
mkdir -p $C/nested; echo $((256 << 20)) > $C/nested/memory.limit_in_bytes
mkdir -p $C/shared/sibling $C/shared/me
echo $((800 << 20)) > $C/shared/memory.limit_in_bytes
echo $((600 << 20)) > $C/shared/me/memory.limit_in_bytes
sh -c "echo \$\$ > $C/shared/sibling/cgroup.procs; echo -1000 > /proc/self/oom_score_adj; exec python /tmp/hold.py" &
sleep 3
# run <version> <label> <cgroup under the container> [VAR=value]: the probe runs with the flag unset unless given.
run() {
printf "%-7s %-56s" "$1" "$2"
sh -c "echo \$\$ > $C/$3/cgroup.procs; DS_ACCELERATOR=cpu ${4:-} exec python /tmp/probe.py 2>/dev/null"
}
printf "%-7s %-56s %11s %11s\n" "" "process cgroup (container limit 1 GiB)" "total" "available"
for v in before after; do
if [ $v = before ]; then spec=$BEFORE; else spec=$AFTER; fi
DS_BUILD_OPS=0 pip install -q --no-deps --force-reinstall "$spec" >/dev/null 2>&1
run $v "A. container root" .
run $v "B. child cgroup, limit 256 MiB" nested
run $v "C. child 600 MiB; parent 800 MiB, sibling uses 500" shared/me
done
run after "A. container root, DS_CPU_RESPECT_CGROUP_MEMORY=0" . DS_CPU_RESPECT_CGROUP_MEMORY=0
# The files the CPU accelerator reads, as seen from cgroup C.
sh -c "echo \$\$ > $C/shared/me/cgroup.procs
echo '### /proc/self/cgroup'; cat /proc/self/cgroup
echo '### /proc/self/mountinfo (cgroup lines)'; grep -E ' - cgroup2? ' /proc/self/mountinfo
for d in $C/shared/me $C/shared $C; do
echo \"### \$d\"
for f in memory.limit_in_bytes memory.usage_in_bytes; do echo \"\$f \$(cat \$d/\$f)\"; done
grep -E '^total_inactive_file ' \$d/memory.stat
done
echo '### /sys/fs/cgroup/unified'; ls /sys/fs/cgroup/unified | tr '\n' ' '; echo"
INNER
```
</details>
**Precedent.** The same walk is already in production elsewhere. Rust's
`std::thread::available_parallelism` takes the smallest `cpu.max` from
the process cgroup up to the mount
([`quota_v2`](https://github.com/rust-lang/rust/blob/41089d07a0a2afc16adfe28a760d94d73d7298a4/library/std/src/sys/thread/unix.rs#L908-L953)).
The JDK fixed limits on parent nested cgroups in
[JDK-8322420](https://bugs.openjdk.org/browse/JDK-8322420) (2024) and
the hierarchical memory limit in
[JDK-8370572](https://bugs.openjdk.org/browse/JDK-8370572) (2025). Go
1.25 finds the process cgroup through `/proc/self/cgroup` and
`/proc/self/mountinfo`
([`cgroup_linux.go`](https://github.com/golang/go/blob/63313a0fc5b68d86399a64e323113667d8aa7823/src/runtime/cgroup_linux.go#L11-L24)),
reading the leaf only. vLLM's CPU-quota reader walks the hierarchy
([`_cgroup_cpu_limit`](https://github.com/vllm-project/vllm/blob/ed6b8f4cd2b47e4240e7ff5feb5105444245b0c2/vllm/utils/torch_utils.py#L180-L231));
vllm-project/vllm#64104 adds the same for memory. The same fix for
OpenObserve merged on 2026-10-28 (openobserve/openobserve#15906).
---------
Signed-off-by: Prabhsharan Singh <228956661+prabhkodes@users.noreply.github.com>
Co-authored-by: Prabhsharan Singh <228956661+prabhkodes@users.noreply.github.com>