Skip to content

Commit 56f83b1

Browse files
authored
Merge pull request #7 from NethermindEth/feat/benchmark-stability-improvements
feat: benchmark stability improvements (CPU pinning, swap control, image pinning)
2 parents 60304a8 + 26883d1 commit 56f83b1

4 files changed

Lines changed: 132 additions & 9 deletions

File tree

example-expb.yaml

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
# Pull images before execution
22
pull_images: true
33

4-
# Docker images to use
4+
# Docker images to use (pin to specific versions for reproducible benchmarks)
55
images:
6-
k6: grafana/k6:latest
7-
alloy: grafana/alloy:latest
6+
k6: grafana/k6:1.6.1
7+
alloy: grafana/alloy:v1.13.2
88
payload_server: python:3.12-slim
99

1010
# Paths for the payloads jsonl file, work directory, and outputs directory
@@ -45,6 +45,20 @@ resources:
4545
mem: 32g
4646
download_speed: 50mbit
4747
upload_speed: 15mbit
48+
# Optional: Pin execution client container to specific CPU cores (Docker --cpuset-cpus)
49+
# Eliminates CPU cache thrashing from scheduler migration. Strongly recommended for stable benchmarks.
50+
# Use `lscpu -e=CPU,CORE,SOCKET` to verify your topology. Ensure pinned cores don't share
51+
# physical cores with infra_cpuset. Avoid cores 0-1 as they typically handle hardware interrupts.
52+
# Example for 8-physical/16-logical CPU: cores 2-5 + their HT siblings 10-13 = 8 threads on 4 dedicated physical cores.
53+
cpuset: "2-5,10-13"
54+
# Optional: Pin infrastructure containers (K6, Alloy, payload server) to separate CPU cores
55+
# Prevents infra from stealing cycles from the execution client under test.
56+
# Should use different physical cores than cpuset to avoid contention.
57+
infra_cpuset: "0-1,8-9"
58+
# Optional: Memory swappiness for the execution client container (0-100). Defaults to host setting.
59+
# Low values preserve heap memory but aggressively evict page cache, which can hurt I/O-heavy workloads.
60+
# Only set this if you understand your workload's memory profile.
61+
# mem_swappiness: 10
4862

4963
# Scenarios to execute
5064
scenarios:

src/expb/configs/defaults.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,9 @@
22

33
# Images
44
## Grafana K6
5-
K6_DEFAULT_IMAGE = "grafana/k6:latest"
5+
K6_DEFAULT_IMAGE = "grafana/k6:1.6.1"
66
## Grafana Alloy
7-
ALLOY_DEFAULT_IMAGE = "grafana/alloy:latest"
7+
ALLOY_DEFAULT_IMAGE = "grafana/alloy:v1.13.2"
88
## Payload Server
99
PAYLOAD_SERVER_DEFAULT_IMAGE = "python:3.12-slim"
1010

src/expb/configs/scenarios.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,20 @@ class ScenariosResources(BaseModel):
225225
description="Upload speed for the scenario.",
226226
default=DOCKER_CONTAINER_DEFAULT_UPLOAD_SPEED,
227227
)
228+
cpuset: str | None = Field(
229+
description="CPU cores to pin the execution client container to (e.g., '0-3', '0,1,2,3'). Maps to Docker --cpuset-cpus.",
230+
default=None,
231+
)
232+
infra_cpuset: str | None = Field(
233+
description="CPU cores to pin infrastructure containers (K6, Alloy, payload server) to (e.g., '4-5'). Maps to Docker --cpuset-cpus.",
234+
default=None,
235+
)
236+
mem_swappiness: int | None = Field(
237+
description="Memory swappiness for the execution client container (0-100). None inherits host default. Low values preserve heap but evict page cache, which hurts I/O-heavy workloads.",
238+
default=None,
239+
ge=0,
240+
le=100,
241+
)
228242

229243

230244
class ScenariosImages(BaseModel):

src/expb/payloads/executor/executor.py

Lines changed: 99 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import subprocess
55
import time
66
from concurrent.futures import Future, ThreadPoolExecutor
7+
from pathlib import Path
78

89
import docker
910
import docker.errors
@@ -80,6 +81,79 @@ def clean_system_cache(self) -> None:
8081
self.log.error("Failed to clean system cache", error=e)
8182
raise e
8283

84+
def run_preflight_checks(self) -> None:
85+
"""Run preflight checks and log warnings for suboptimal system configuration."""
86+
self._check_cpu_governor()
87+
self._check_transparent_huge_pages()
88+
self._check_noisy_timers()
89+
90+
def _check_cpu_governor(self) -> None:
91+
"""Log a warning if any CPU is not using the 'performance' governor."""
92+
try:
93+
governor_path = Path("/sys/devices/system/cpu/cpu0/cpufreq/scaling_governor")
94+
if not governor_path.exists():
95+
return
96+
governor = governor_path.read_text().strip()
97+
if governor != "performance":
98+
self.log.warning(
99+
"CPU frequency governor is not set to 'performance', benchmark results may have higher variance. "
100+
"Fix: echo performance | sudo tee /sys/devices/system/cpu/cpu*/cpufreq/scaling_governor",
101+
current_governor=governor,
102+
)
103+
except Exception:
104+
pass
105+
106+
def _check_transparent_huge_pages(self) -> None:
107+
"""Log a warning if Transparent Huge Pages are enabled (causes latency spikes from compaction)."""
108+
try:
109+
thp_path = Path("/sys/kernel/mm/transparent_hugepage/enabled")
110+
if not thp_path.exists():
111+
return
112+
content = thp_path.read_text().strip()
113+
# Format is like: "always [madvise] never" — bracketed value is active
114+
if "[always]" in content:
115+
self.log.warning(
116+
"Transparent Huge Pages are enabled, THP compaction can cause unpredictable latency spikes. "
117+
"Fix: echo madvise | sudo tee /sys/kernel/mm/transparent_hugepage/enabled && "
118+
"echo madvise | sudo tee /sys/kernel/mm/transparent_hugepage/defrag",
119+
current="always",
120+
)
121+
except Exception:
122+
pass
123+
124+
NOISY_TIMERS = [
125+
"sysstat-collect.timer",
126+
"apt-daily.timer",
127+
"apt-daily-upgrade.timer",
128+
"fwupd-refresh.timer",
129+
"fstrim.timer",
130+
]
131+
132+
def _check_noisy_timers(self) -> None:
133+
"""Log a warning if systemd timers known to cause I/O or CPU spikes are active."""
134+
try:
135+
result = subprocess.run(
136+
["systemctl", "list-timers", "--no-pager", "--no-legend"],
137+
capture_output=True,
138+
text=True,
139+
timeout=5,
140+
)
141+
if result.returncode != 0:
142+
return
143+
active_noisy = [
144+
timer
145+
for timer in self.NOISY_TIMERS
146+
if timer in result.stdout
147+
]
148+
if active_noisy:
149+
self.log.warning(
150+
"Active systemd timers may cause benchmark variance. "
151+
f"Fix: systemctl stop {' '.join(active_noisy)}",
152+
active_timers=active_noisy,
153+
)
154+
except Exception:
155+
pass
156+
83157
def pull_docker_images(self) -> None:
84158
self.log.info("updating docker images")
85159
self.config.docker_client.images.pull(self.config.execution_client_image)
@@ -135,7 +209,7 @@ def start_execution_client(
135209
# Run execution container
136210
cpu_count = self.config.resources.cpu if self.config.resources else None
137211
mem_limit = self.config.resources.mem if self.config.resources else None
138-
container = self.config.docker_client.containers.run(
212+
run_kwargs = dict(
139213
image=self.config.execution_client_image,
140214
name=self.config.get_execution_client_container_name(),
141215
volumes=execution_container_volumes,
@@ -152,6 +226,11 @@ def start_execution_client(
152226
group_add=self.config.docker_group_add,
153227
stop_signal=stop_signal,
154228
)
229+
if self.config.resources and self.config.resources.cpuset is not None:
230+
run_kwargs["cpuset_cpus"] = self.config.resources.cpuset
231+
if self.config.resources and self.config.resources.mem_swappiness is not None:
232+
run_kwargs["mem_swappiness"] = self.config.resources.mem_swappiness
233+
container = self.config.docker_client.containers.run(**run_kwargs)
155234
return container
156235

157236
def wait_for_client_json_rpc(
@@ -244,7 +323,7 @@ def start_alloy(
244323
self,
245324
container_network: Network | None = None,
246325
) -> Container:
247-
alloy_container = self.config.docker_client.containers.run(
326+
run_kwargs = dict(
248327
image=self.config.get_alloy_container_image(),
249328
name=self.config.get_alloy_container_name(),
250329
volumes=self.config.get_alloy_volumes(),
@@ -254,6 +333,9 @@ def start_alloy(
254333
restart_policy={"Name": "unless-stopped"},
255334
network=container_network.name if container_network else None,
256335
)
336+
if self.config.resources and self.config.resources.infra_cpuset is not None:
337+
run_kwargs["cpuset_cpus"] = self.config.resources.infra_cpuset
338+
alloy_container = self.config.docker_client.containers.run(**run_kwargs)
257339
return alloy_container
258340

259341
# Payload Server Setup
@@ -269,7 +351,7 @@ def start_payload_server(
269351
self,
270352
container_network: Network | None = None,
271353
) -> Container:
272-
container = self.config.docker_client.containers.run(
354+
run_kwargs = dict(
273355
image=self.config.get_payload_server_container_image(),
274356
name=self.config.get_payload_server_container_name(),
275357
volumes=self.config.get_payload_server_volumes(),
@@ -279,6 +361,9 @@ def start_payload_server(
279361
restart_policy={"Name": "unless-stopped"},
280362
network=container_network.name if container_network else None,
281363
)
364+
if self.config.resources and self.config.resources.infra_cpuset is not None:
365+
run_kwargs["cpuset_cpus"] = self.config.resources.infra_cpuset
366+
container = self.config.docker_client.containers.run(**run_kwargs)
282367
return container
283368

284369
def wait_for_payload_server(
@@ -352,7 +437,7 @@ def run_k6(
352437
k6_container_environment = self.config.get_k6_environment()
353438

354439
# Execute k6 container
355-
container = self.config.docker_client.containers.run(
440+
run_kwargs = dict(
356441
image=self.config.get_k6_container_image(),
357442
name=self.config.get_k6_container_name(),
358443
volumes=k6_container_volumes,
@@ -365,6 +450,9 @@ def run_k6(
365450
group_add=self.config.docker_group_add,
366451
stop_signal="SIGINT",
367452
)
453+
if self.config.resources and self.config.resources.infra_cpuset is not None:
454+
run_kwargs["cpuset_cpus"] = self.config.resources.infra_cpuset
455+
container = self.config.docker_client.containers.run(**run_kwargs)
368456
return container
369457

370458
# Extra Commands Execution
@@ -636,6 +724,7 @@ def execute_scenario(
636724
scenario=self.config.executor_name,
637725
execution_client=self.config.get_execution_client_name(),
638726
)
727+
self.run_preflight_checks()
639728
self.clean_system_cache()
640729
self.prepare_directories()
641730
self.prepare_jwt_secret_file()
@@ -681,9 +770,15 @@ def execute_scenario(
681770
docker_container_cpus=self.config.resources.cpu
682771
if self.config.resources
683772
else None,
773+
docker_container_cpuset=self.config.resources.cpuset
774+
if self.config.resources
775+
else None,
684776
docker_container_mem_limit=self.config.resources.mem
685777
if self.config.resources
686778
else None,
779+
docker_container_mem_swappiness=self.config.resources.mem_swappiness
780+
if self.config.resources
781+
else None,
687782
)
688783
stop_signal = (
689784
# If there are extra commands to execute, use SIGINT to stop the execution client

0 commit comments

Comments
 (0)