9. DMF density benchmarking — three placement cases
The headline measurement: how many live 1080p60 streams one worker sustains under each CPU QoS policy. This step answers the core DMF question: how does Kubernetes CPU placement configuration affect the density of DMF media workloads?
Three cases are benchmarked, all running the same FFmpeg command line on the same hardware. Only the Kubernetes placement policy changes.
Run the campaign
scripts/run-campaign.sh campaigns/density.env
Three runs, about 12 minutes total (campaigns/density.env):
baseline --streams 12 --rdt-monitor --warmup 1m --measure 2m
numa-pool --streams 14 --rdt-monitor --warmup 1m --measure 2m
pinned --streams 20 --rdt-monitor --warmup 1m --measure 2m
Then open the summary:
results/summary.html one row per run, colour-coded pass/fail
results/summary.xlsx same data, plus a platform sheet
results/summary.csv same data, for scripting
A single run, if you prefer:
scripts/run.sh pinned --streams 20 --rdt-monitor --warmup 1m --measure 2m
scripts/run.sh pinned --streams 20 --dry-run # render and validate only
The three cases
baseline |
numa-pool |
pinned |
|
|---|---|---|---|
| Kubernetes QoS | Burstable | Burstable | Guaranteed |
| CPU request (dec / enc) | 500m / 500m | 500m / 500m | 1 / 5 |
| CPU affinity | none — scheduler and CFS decide | taskset to one socket's 62 CPUs |
exclusive cores from the kubelet |
| NUMA alignment | none | by hand, sessions alternate sockets | kubelet Topology Manager, single-numa-node |
| Needs step 3 | no | no | yes |
| Encoder threads | 15, slices 2 | 15, slices 2 | 15, slices 2 |
All three run the same FFmpeg command line with the same threading. Only placement changes. That is the point: the difference is configuration, not tuning.
Reference result (reference platform: 2 sockets × 64 cores, see 14-reference-bkc.md)
| Scenario | Streams | Min encoder FPS | CPU per stream (cores) | Cross-socket UPI (GB/s) | L3 hit ratio | DRAM total (GB/s) |
|---|---|---|---|---|---|---|
baseline |
12 | 59.78 | 5.77 | 46.0 | 46.7 % | 113.8 |
numa-pool |
14 | 59.71 | 5.01 | 0.55 | 57.8 % | 119.6 |
pinned |
20 | 59.73 | 4.76 | 1.44 | 52.5 % | 196.5 |
Read it as a chain of cause and effect:
baselinewastes the interconnect. 46 GB/s of cross-socket UPI traffic: FFmpeg threads migrate between sockets and keep reading memory that is now remote. That traffic buys nothing.numa-poolremoves it almost entirely (46 → 0.55 GB/s) with nothing buttaskset. Cost per stream falls from 5.77 to 5.01 cores and the L3 hit ratio rises. Two more streams fit, on any cluster, with no kubelet configuration.pinnedconverts locality into density. Exclusive cores stop CFS from migrating threads at all, so per-stream cost drops again to 4.76 cores and 20 streams fit. The residual 1.44 GB/s UPI is the honest cost of a workload that is spread over both sockets, not thrash.
+67 % density, same silicon, same encoder settings.
Why 20 streams and not more
pinned gives each stream 6 exclusive CPUs — 1 for the decoder, 5 for the
encoder (scenarios/pinned.env). With 124 allocatable CPUs (128 − 4 reserved):
20 streams × 6 CPUs = 120 exclusive CPUs + 20 × 2 sidecars × 5m ≈ 0.2 CPU
21 streams needs 126 and the planner refuses before anything is deployed:
FATAL: Kubernetes CPU Manager placement needs 126 exclusive CPUs plus 0.2 shared
CPUs, but only 124 CPUs are allocatable
The encoder runs 15 threads on 5 cores with slices=2 and
sliced-threads=0 — frame-based threading. Oversubscribing threads to cores is
deliberate: libx264 frame threads spend much of their life waiting on references,
so 3× oversubscription fills the cores without adding slice-boundary quality loss.
Sliced threading with fewer slices than threads is rejected outright:
FATAL: SLICES=2 limits sliced-thread parallelism below ENC_THREADS=15;
use SLICES >= ENC_THREADS or SLICED_THREADS=0 for frame threading
The limit really is CPU count, not memory: at 20 streams the reference worker
draws 137.9 GB/s of the 819.2 GB/s its 16 populated channels can deliver — 16.8 %
of peak. A machine with more cores at the same memory configuration should scale
further, which is why install-platform-probe.sh is worth having.
Finding the limit on your own hardware
Different silicon has a different number. Walk it up:
scripts/run-campaign.sh campaigns/density-sweep.env
Three stream counts per scenario. The limit is the highest count whose row still
shows Pass. Then edit campaigns/density.env to your numbers and use it as your
own regression baseline.
To tune the per-stream core split, change DEC_CORES / ENC_CORES /
ENC_THREADS in scenarios/pinned.env, or override for one run:
scripts/run.sh pinned --streams 24 --enc-cores 4 --enc-threads 12 --measure 2m
Troubleshooting
Too many open files
Decoders come up, then die around the tenth stream, and their logs end with:
inotify_init1 failed: Too many open files
Failed to create instance : inotify_init1 failed: Too many open files
[out#0/mxl] Could not write header (incorrect codec parameters ?): Input/output error
Conversion failed!
Nothing is wrong with the codec or the media. Every MXL flow holds one inotify
instance, and fs.inotify.max_user_instances defaults to 128 for all processes
running as root on the host — kubelet, containerd, systemd and journald have
already taken most of them, so a dense run runs out. Fix it once per worker:
scripts/bootstrap-worker.sh # includes install-worker-limits.sh
scripts/preflight.sh checks the value, so a run never has to fail to find out.
What each run leaves behind
results/<scenario>-<placement>-<N>str-<UTC>/:
| File | Contents |
|---|---|
report.html |
the single-run report: per-stream FPS, CPU, cache, memory |
config.json |
every resolved setting, so the run is reproducible |
workload.yaml |
the exact manifests that were applied |
ffmpeg-commandlines.json |
the exact FFmpeg command line per Pod |
planned-placement.json |
which CPUs each container was meant to get |
metrics.csv |
every metric sample in the measurement window |
host.json |
live hardware probe: lscpu -J, numactl --hardware, meminfo, cmdline |
pod-describe.txt |
kubectl describe pods, including the real cpusets |
rdt-*.json |
RDT groups, masks and per-group counters (with --rdt-monitor) |
noisy-neighbor-*.json |
neighbor evidence before and after (with --noisy-neighbor) |
Warm-up samples are excluded from the score. --warmup 1m --measure 2m means the
first minute is discarded and the pass/fail decision uses the second and third.
The namespace is deleted after each run unless you pass --keep.
Next: 10-metrics.md — understand and interpret your benchmark results.