Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions .github/workflows/bench.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@ on:
image:
description: 'Base image the container scenarios run in. Any glibc distro works - uv is mounted in'
default: 'ghcr.io/astral-sh/uv:python3.13-bookworm'
cgroup_v1:
description: 'Also run the bench in a guest booted on cgroup v1 (adds a few minutes)'
type: choice
options: ['off', hybrid, legacy]
default: 'off'

run-name: 'bench: ${{ inputs.ref }} / ${{ inputs.scenarios }} / cpus ${{ inputs.cpu_budgets }}'

Expand Down Expand Up @@ -65,3 +70,46 @@ jobs:
with:
name: results-${{ matrix.cpus }}cpu
path: results/

bench-v1:
# The runner is cgroup v2 only, and a controller cannot be mounted as v1 while the unified hierarchy owns
# it, so the v1 side of the library is only reachable inside a guest booted in legacy mode.
if: inputs.cgroup_v1 != 'off'
name: bench (cgroup v1 guest)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: astral-sh/setup-uv@v5

- name: Allow KVM
run: |
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' \
| sudo tee /etc/udev/rules.d/99-kvm4all.rules
sudo udevadm control --reload-rules
sudo udevadm trigger --name-match=kvm

- name: Install qemu
run: sudo apt-get install -y --no-install-recommends qemu-system-x86 cloud-image-utils

- name: Boot the guest and run the bench inside it
env:
CRAWLEE_REPO: ${{ inputs.repo }}
CRAWLEE_REF: ${{ inputs.ref }}
V1_MODE: ${{ inputs.cgroup_v1 }}
run: ./v1-guest.sh results/

- name: Report
if: always()
run: python3 report.py results/ >> "$GITHUB_STEP_SUMMARY"

- name: Check
if: always()
run: python3 report.py results/ --check >/dev/null

- name: Upload raw results
if: always()
uses: actions/upload-artifact@v4
with:
name: results-cgroup-v1-${{ inputs.cgroup_v1 }}
path: results/
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,19 @@ Scenarios whose prerequisites are missing (docker, passwordless sudo, a systemd
cores) are recorded as `skipped` with the reason; only a crashed probe reddens a run. The `k8s-*` scenarios
bring up a kind cluster named `cgroups-bench` and leave it running — `kind delete cluster --name cgroups-bench`.

**cgroup v1** cannot be produced on a modern host: a controller belongs to one hierarchy at a time, and on a
unified system it cannot be mounted as v1 even by root — inside a user namespace, not at all. So the v1 half of
the library is reachable only from a guest kernel, which is what `./v1-guest.sh` does: it boots Ubuntu 22.04
(systemd 249, the last releases that still honour the flags), runs the same scenarios inside and copies the
results back into the same report. `V1_MODE` picks what the guest boots into — `hybrid` puts the v1 controllers
next to a controller-less cgroup2, so the library has to notice the unified hierarchy is empty and fall back
per controller, while `legacy` is plain v1. In the workflow both are the `cgroup_v1` input.

It needs `qemu-system-x86 cloud-image-utils` and a usable `/dev/kvm` (`sudo usermod -aG kvm $USER` locally, a
udev rule on a runner); `V1_ACCEL=tcg` emulates instead, which is slow but needs no privileges. The report's
`v2` column says what the guest actually came up as, and a v1 host with no limit reports the sentinel that
shows up in the table as `8.00 EB`.

## Files

| file | what it does |
Expand All @@ -39,6 +52,7 @@ bring up a kind cluster named `cgroups-bench` and leave it running — `kind del
| [probe.py](probe.py) | Runs inside the prepared environment and prints one JSON object with what Crawlee sees there. |
| [wrap.py](wrap.py) | Folds the probe's output and the scenario's configuration into one result file. |
| [report.py](report.py) | Turns a results directory into the tables. `--check` is the only gate. |
| [v1-guest.sh](v1-guest.sh) | Boots a guest on cgroup v1 and runs the bench inside it, bringing the results back. |
| [.github/workflows/bench.yaml](.github/workflows/bench.yaml) | Manual trigger only, one job per CPU budget. |

## Scenarios
Expand Down
69 changes: 56 additions & 13 deletions probe.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@

SCHEMA = 1

_EVIDENCE_FILES = (
_V2_EVIDENCE_FILES = (
'memory.max',
'memory.current',
'cpu.max',
Expand All @@ -18,6 +18,17 @@
'cgroup.controllers',
)

_V1_EVIDENCE_FILES = (
'memory.limit_in_bytes',
'memory.usage_in_bytes',
'cpu.cfs_quota_us',
'cpu.cfs_period_us',
'cpuacct.usage',
'cpuset.cpus',
)

_V1_CONTROLLERS = ('memory', 'cpu', 'cpuacct', 'cpuset')

_MAX_LEVELS = 20
"""How far up the cgroup chain the evidence dump walks. Deeper than any real hierarchy, so it only bounds a loop."""

Expand Down Expand Up @@ -60,26 +71,58 @@ def _read_control_file(path: Path) -> str | None:
return None


def _walk_levels(mount: Path, own_path: str, names: tuple[str, ...]) -> list[dict[str, Any]]:
"""Read the named control files at the process's own cgroup and at every level above it, leaf first."""
levels = []
directory = mount / own_path.lstrip('/') if '..' not in own_path else mount
for _ in range(_MAX_LEVELS):
files = {name: content for name in names if (content := _read_control_file(directory / name))}
levels.append({'path': str(directory), 'files': files})
if directory in (mount, directory.parent):
break
directory = directory.parent
return levels


def _evidence() -> dict[str, Any]:
"""Dump raw cgroup file contents along the chain, verbatim - receipts, not a verdict, never interpreted."""
"""Dump raw cgroup file contents along the chain, verbatim - receipts, not a verdict, never interpreted.

Both cgroup versions are dumped the same way, from the standard mount points: the unified hierarchy sits at
/sys/fs/cgroup, while a v1 controller has a directory of its own under it. Resolving mounts properly is the
library's job, and this deliberately does not repeat it - the receipts only have to be readable next to the
values the library reported.
"""
evidence: dict[str, Any] = {'proc_cgroup': None, 'levels': []}
try:
evidence['proc_cgroup'] = Path('/proc/self/cgroup').read_text().strip()
except OSError:
return evidence

own_path = next((line[3:] for line in evidence['proc_cgroup'].splitlines() if line.startswith('0::')), None)
if own_path is None:
return evidence

mount = Path('/sys/fs/cgroup')
directory = mount / own_path.lstrip('/') if '..' not in own_path else mount
for _ in range(_MAX_LEVELS):
files = {name: content for name in _EVIDENCE_FILES if (content := _read_control_file(directory / name))}
evidence['levels'].append({'path': str(directory), 'files': files})
if directory in (mount, directory.parent):
break
directory = directory.parent
seen: set[str] = set()

for line in evidence['proc_cgroup'].splitlines():
parts = line.split(':', 2)
if len(parts) != 3:
continue
_hierarchy_id, controllers, own_path = parts

if not controllers:
levels = _walk_levels(mount, own_path, _V2_EVIDENCE_FILES)
else:
# A v1 line can name several controllers sharing one mount, as `cpu,cpuacct` usually does.
names = [name for name in controllers.split(',') if name in _V1_CONTROLLERS]
levels = [
level
for name in names
for level in _walk_levels(mount / name, own_path, _V1_EVIDENCE_FILES)
]

for level in levels:
if level['path'] not in seen:
seen.add(level['path'])
evidence['levels'].append(level)

return evidence


Expand Down
11 changes: 6 additions & 5 deletions report.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,17 @@
from pathlib import Path
from typing import Any

_BYTES_PER_GB = 1024**3
_BYTES_PER_MB = 1024**2
_BYTE_UNITS = (('EB', 1024**6), ('PB', 1024**5), ('TB', 1024**4), ('GB', 1024**3), ('MB', 1024**2))


def _bytes_h(value: Any) -> str:
"""Format a byte count. The largest units only ever show up as the v1 sentinel for "no limit at all"."""
if not isinstance(value, (int, float)):
return '-'
if value >= _BYTES_PER_GB:
return f'{value / _BYTES_PER_GB:.2f} GB'
return f'{value / _BYTES_PER_MB:.2f} MB'
for unit, size in _BYTE_UNITS:
if value >= size:
return f'{value / size:.2f} {unit}'
return f'{value / _BYTE_UNITS[-1][1]:.2f} MB'


def _num(value: Any) -> str:
Expand Down
6 changes: 6 additions & 0 deletions scenario-helpers.sh
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@ have() { command -v "$1" >/dev/null 2>&1; }
cpuset_list() { [ "$BENCH_CPUS" -le 1 ] && echo '0' || echo "0-$((BENCH_CPUS - 1))"; }

have_engine() { "$ENGINE" info >/dev/null 2>&1; }
# True when the unified hierarchy is the one carrying the controllers. Under cgroup v1 - and under the hybrid
# layout, where a controller-less cgroup2 sits next to the v1 mounts - systemd puts resource limits only on
# system units, and properties that exist solely for the unified hierarchy are accepted and then ignored.
# Read it rather than test its size: every file in cgroupfs reports zero bytes, so -s is always false here.
have_unified() { [ -n "$(cat /sys/fs/cgroup/cgroup.controllers 2>/dev/null)" ]; }
have_sudo() { sudo -n true 2>/dev/null; }
have_systemd_user() { systemd-run --user --scope -q true 2>/dev/null; }
cgroup_driver() { "$ENGINE" info -f '{{.CgroupDriver}}' 2>/dev/null; }
Expand All @@ -31,6 +36,7 @@ unmet_requirement() {
engine) have_engine || { echo "container engine '$ENGINE' is not available"; return; } ;;
sudo) have_sudo || { echo "passwordless sudo is not available"; return; } ;;
systemd-user) have_systemd_user || { echo "systemd user manager is not available"; return; } ;;
unified) have_unified || { echo "the controllers are not on the unified hierarchy, and systemd gives user scopes no cgroup of their own under cgroup v1"; return; } ;;
systemd-driver)
[ "$(cgroup_driver)" = systemd ] || { echo "$ENGINE uses the '$(cgroup_driver)' cgroup driver, this scenario needs 'systemd'"; return; } ;;
kind)
Expand Down
2 changes: 1 addition & 1 deletion scenarios/systemd-ancestor.sh
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# shellcheck shell=bash
SCENARIO_DESC="limit on an ancestor, leaf without memory controller files"
REQUIRES="systemd-user"
REQUIRES="systemd-user unified"
INNER_STYLE=local

SET_MEMORY_BYTES=$((512 * 1024 * 1024))
Expand Down
2 changes: 1 addition & 1 deletion scenarios/systemd-memory-above-host.sh
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# shellcheck shell=bash
SCENARIO_DESC="a memory limit larger than the machine has RAM"
REQUIRES="systemd-user"
REQUIRES="systemd-user unified"
INNER_STYLE=local

# The memory twin of systemd-quota-above-host, and the axis where the library already guards itself: a limit at
Expand Down
11 changes: 9 additions & 2 deletions scenarios/systemd-own.sh
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,21 @@ QUOTA_CORES=$((BENCH_CPUS + 1))

SET_MEMORY_BYTES=$((512 * 1024 * 1024))
SET_CPU_CORES=$QUOTA_CORES
SET_CPUSET_CORES=$BENCH_CPUS

# AllowedCPUs= exists only for the unified hierarchy: under cgroup v1 systemd accepts it and silently applies
# nothing, so the ask is left out there rather than recorded as a limit that was never set.
CPUSET_PROPS=()
if have_unified; then
SET_CPUSET_CORES=$BENCH_CPUS
CPUSET_PROPS=(-p "AllowedCPUs=$(cpuset_list)")
fi

UNIT="bench-systemd-own-$$"

# sudo strips the environment, so the probe needs PATH and a cache root can write to.
scenario_exec() {
sudo systemd-run --scope -q --unit "$UNIT" \
-p "MemoryMax=$SET_MEMORY_BYTES" -p "CPUQuota=$((QUOTA_CORES * 100))%" -p "AllowedCPUs=$(cpuset_list)" \
-p "MemoryMax=$SET_MEMORY_BYTES" -p "CPUQuota=$((QUOTA_CORES * 100))%" "${CPUSET_PROPS[@]}" \
env "PATH=$PATH" "HOME=/root" "UV_CACHE_DIR=$BENCH_UV_CACHE/root" sh -c "$1"
}

Expand Down
2 changes: 1 addition & 1 deletion scenarios/systemd-quota-above-host.sh
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# shellcheck shell=bash
SCENARIO_DESC="a cpu quota larger than the machine has cores"
REQUIRES="systemd-user"
REQUIRES="systemd-user unified"
INNER_STYLE=local

# Nothing rejects a quota above the machine's core count: systemd writes it as asked, and a kubernetes limit
Expand Down
Loading
Loading