Skip to content

Commit 2075b9f

Browse files
authored
shadow-gossipsub: select muxer and discovery (static | kad-dht) (#316)
Expose muxer, discovery and start_sleep on ExpConfig and wire them into shadow.yaml.
1 parent 80a84ff commit 2075b9f

4 files changed

Lines changed: 260 additions & 37 deletions

File tree

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,29 @@
1-
# Drives shadow-gossipsub at multiple scales via the Multiple meta-experiment.
2-
# Run with: uv run python deployment.py multi-shadow-gossipsub --skip-check
1+
# Drives shadow-gossipsub across scales and/or muxers via the Multiple meta-experiment.
2+
# Defaults give the scale sweep; env vars turn it into the muxer regression matrix, e.g.
3+
# SHADOW_MUXERS=mplex,yamux,quic SHADOW_SIZES=1000 SHADOW_DISCOVERY=kad-dht \
4+
# uv run python deployment.py multi-shadow-gossipsub --values <base.yaml> --skip-check
5+
# The base (message count, timing, image, resources) comes from --values; the runner
6+
# only overlays the swept dimensions on top.
7+
import os
38
from typing import Any, Iterable, List
49

510
from src.deployments.experiments.libp2p.shadow_gossipsub import ShadowGossipsubExperiment
611
from src.deployments.experiments.multi_experiment import Multiple
712
from src.deployments.registry import experiment
813

914

15+
def _env_list(name: str, default: str) -> list:
16+
return [x.strip() for x in os.environ.get(name, default).split(",") if x.strip()]
17+
18+
19+
SIZES = [int(x) for x in _env_list("SHADOW_SIZES", "10,30,100")]
20+
MUXERS = _env_list("SHADOW_MUXERS", "yamux")
21+
DISCOVERY = os.environ.get("SHADOW_DISCOVERY", "static")
22+
23+
1024
@experiment(name="multi-shadow-gossipsub")
1125
class MultiShadowGossipsub(Multiple):
12-
"""Run shadow-gossipsub multiple times at different scales."""
26+
"""Run shadow-gossipsub across scales and/or muxers (see env vars above)."""
1327

1428
def model_post_init(self, __context: Any) -> None:
1529
self.config.name = ShadowGossipsubExperiment.name
@@ -20,8 +34,10 @@ def get_params_list(self) -> List[dict]:
2034
return list(self.exp_params())
2135

2236
def exp_params(self) -> Iterable[dict]:
23-
for num_nodes in (10, 30, 100):
24-
yield {"num_nodes": num_nodes}
37+
for size in SIZES:
38+
for muxer in MUXERS:
39+
yield {"num_nodes": size, "muxer": muxer, "discovery": DISCOVERY}
2540

2641
def get_name_from_params(self, params: dict) -> str:
27-
return f"num_nodes_{params['num_nodes']}"
42+
disc = "kad" if params["discovery"] == "kad-dht" else params["discovery"]
43+
return f"n{params['num_nodes']}-{params['muxer']}-{disc}"

src/deployments/experiments/libp2p/shadow_gossipsub.py

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# Shadow GossipSub experiment: N nim libp2p peers + 1 publisher inside Shadow on a
22
# single k8s pod. See the "Using Shadow at DST" runbook in Notion.
33
import logging
4-
from typing import Optional
4+
from typing import Literal, Optional
55

66
from pydantic import BaseModel, ConfigDict, NonNegativeFloat, NonNegativeInt
77

@@ -31,11 +31,24 @@ class ExpConfig(BaseModel):
3131
message_size_bytes: NonNegativeInt = 1000
3232
delay_seconds: NonNegativeFloat = 2.0
3333
connect_to: NonNegativeInt = 2
34+
muxer: Literal["yamux", "mplex", "quic"] = "yamux"
35+
discovery: Literal["static", "kad-dht"] = "static" # CONNECTTO dial vs kad bootstrap
36+
start_sleep: NonNegativeInt = 60 # node STARTSLEEP before mesh formation
3437
# Timing (simulated seconds). Publisher starts after the mesh forms (~60s).
3538
publisher_start_s: NonNegativeInt = 90
3639
sim_stop_time_s: NonNegativeInt = 180
3740
# storeMetrics scrape cadence (s); short so the last scrape is post-traffic.
3841
metrics_interval_s: NonNegativeInt = 15
42+
# Determinism + diagnostics. seed is rendered into shadow.yaml (Shadow default 1);
43+
# strace is global and heavy — small-N diagnosis only.
44+
seed: NonNegativeInt = 1
45+
model_unblocked_syscall_latency: bool = False
46+
strace_logging_mode: str = "off" # off | standard | deterministic
47+
# Floor (µs) for lsquic engine tick re-arms; needs the tick-floor node image.
48+
# 0 = stock lsquic behavior (which livelocks quic under Shadow).
49+
lsquic_tick_floor_us: NonNegativeInt = 0
50+
# Per-pod process start stagger (pod-i starts at 5000 + i*jitter ms); 0 = lockstep.
51+
start_jitter_ms: NonNegativeInt = 0
3952
# Job-pod resources, sized for ~10 peers; bump for bigger sims.
4053
cpu_request: str = "2"
4154
cpu_limit: str = "4"
@@ -59,8 +72,10 @@ async def _run(self):
5972
self.log_event("run_start")
6073
cfg = self.config
6174
namespace = self.namespace
62-
# unique per run (output folder name carries a random suffix)
63-
run_id = self.output_folder.name.lower().replace("_", "-")[:50].strip("-")
75+
# unique per run (output folder name carries a random suffix). Cap the length
76+
# so the derived `shadow-<run_id>-reader` log-reader pod stays within the k8s
77+
# 63-char name limit.
78+
run_id = self.output_folder.name.lower().replace("_", "-")[:45].strip("-")
6479
cm_name = f"shadow-{run_id}"
6580
job_name = f"shadow-{run_id}"
6681
pvc_name = f"shadow-{run_id}-data"
@@ -70,7 +85,15 @@ async def _run(self):
7085
sim_stop_time_s=cfg.sim_stop_time_s,
7186
publisher_start_s=cfg.publisher_start_s,
7287
connect_to=cfg.connect_to,
88+
muxer=cfg.muxer,
89+
discovery=cfg.discovery,
90+
start_sleep=cfg.start_sleep,
7391
metrics_interval_s=cfg.metrics_interval_s,
92+
seed=cfg.seed,
93+
model_unblocked_syscall_latency=cfg.model_unblocked_syscall_latency,
94+
strace_logging_mode=cfg.strace_logging_mode,
95+
lsquic_tick_floor_us=cfg.lsquic_tick_floor_us,
96+
start_jitter_ms=cfg.start_jitter_ms,
7497
)
7598
publisher_config = render_publisher_config(
7699
num_nodes=cfg.num_nodes,

src/deployments/shadow/builders.py

Lines changed: 136 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Builders for Shadow simulator runs: config values -> kubernetes-client objects
22
# and yaml dicts. Pure data, no I/O. See the "Using Shadow at DST" runbook.
3-
from typing import Optional
3+
from typing import Literal, Optional
44

55
from kubernetes.client import (
66
V1Capabilities,
@@ -42,45 +42,95 @@
4242
)
4343

4444

45-
def render_shadow_yaml(
45+
def _peer_env(
4646
*,
4747
num_nodes: int,
48-
sim_stop_time_s: int,
49-
publisher_start_s: int,
50-
connect_to: int = 2,
51-
muxer: str = "yamux",
52-
metrics_interval_s: int = 15,
53-
requester_app_path: str = _REQUESTER_APP_PATH,
48+
connect_to: int,
49+
muxer: str,
50+
discovery: str,
51+
start_sleep: int,
52+
metrics_interval_s: int,
53+
lsquic_tick_floor_us: int,
5454
) -> dict:
55-
"""Build the shadow.yaml dict: N peer hosts running `./main` + a publisher host
56-
running the pod-api-requester in batch mode against the peers' `/publish`
57-
endpoints. The traffic shape (message count, size, pacing) lives in the
58-
requester's own config (see `render_publisher_config`), mounted at
59-
`{_CONFIG_MOUNT}/{_PUBLISHER_CONFIG}`."""
60-
if connect_to >= num_nodes:
61-
raise ValueError(f"connect_to ({connect_to}) must be smaller than num_nodes ({num_nodes}).")
62-
63-
peer_env = {
55+
"""Environment for a relay (pod-*) node."""
56+
env = {
6457
"PEERS": str(num_nodes),
6558
"CONNECTTO": str(connect_to),
6659
"SHADOWENV": "true", # env.nim requires the literal string "true"
6760
"MUXER": muxer,
61+
"DISCOVERY": discovery,
62+
"STARTSLEEP": str(start_sleep),
6863
"METRICS_INTERVAL_S": str(metrics_interval_s),
6964
}
70-
peer_process = {
71-
"path": "./main",
72-
"start_time": "5s",
73-
"expected_final_state": "running", # daemon: don't error when alive at stop_time
74-
"environment": peer_env,
75-
}
76-
hosts = {
65+
if lsquic_tick_floor_us > 0:
66+
# Needs the tick-floor test-node image (stock images ignore the env var).
67+
env["LSQUIC_TICK_FLOOR_US"] = str(lsquic_tick_floor_us)
68+
if discovery == "kad-dht":
69+
env["NODE_ROLE"] = "RoleNormal"
70+
env["SERVICE"] = "bootstrap-0"
71+
return env
72+
73+
74+
def _peer_hosts(num_nodes: int, peer_env: dict, start_jitter_ms: int) -> dict:
75+
"""The N relay hosts (pod-0..pod-(N-1)). start_jitter_ms staggers per-pod process
76+
start so peers don't wake and dial at one simulated instant (lockstep wakes force
77+
simultaneous-dial collisions that never occur on real hosts)."""
78+
return {
7779
f"pod-{i}": {
7880
"network_node_id": 0,
79-
"processes": [peer_process],
81+
"processes": [
82+
{
83+
"path": "./main",
84+
"start_time": f"{5000 + i * start_jitter_ms}ms",
85+
# daemon: don't error when alive at stop_time
86+
"expected_final_state": "running",
87+
"environment": peer_env,
88+
}
89+
],
8090
}
8191
for i in range(num_nodes)
8292
}
83-
hosts["publisher"] = {
93+
94+
95+
def _bootstrap_host(
96+
*,
97+
num_nodes: int,
98+
muxer: str,
99+
start_sleep: int,
100+
metrics_interval_s: int,
101+
lsquic_tick_floor_us: int,
102+
) -> dict:
103+
"""The kad-dht anchor (bootstrap-0); peers discover through it by hostname."""
104+
env = {
105+
"PEERS": str(num_nodes),
106+
"SHADOWENV": "true",
107+
"MUXER": muxer,
108+
"DISCOVERY": "kad-dht",
109+
"NODE_ROLE": "RoleBootstrap",
110+
# the single anchor must accept every node's bootstrap dial, so lift its cap
111+
# above the network size (default is 250).
112+
"MAXCONNECTIONS": str(num_nodes + 100),
113+
"STARTSLEEP": str(start_sleep),
114+
"METRICS_INTERVAL_S": str(metrics_interval_s),
115+
}
116+
if lsquic_tick_floor_us > 0:
117+
env["LSQUIC_TICK_FLOOR_US"] = str(lsquic_tick_floor_us)
118+
return {
119+
"network_node_id": 0,
120+
"processes": [
121+
{
122+
"path": "./main",
123+
"start_time": "5s",
124+
"expected_final_state": "running",
125+
"environment": env,
126+
}
127+
],
128+
}
129+
130+
131+
def _publisher_host(publisher_start_s: int, requester_app_path: str) -> dict:
132+
"""The publisher host: runs the pod-api-requester in batch mode against the peers."""
133+
return {
84134
"network_node_id": 0,
85135
"processes": [
86136
{
@@ -94,16 +144,75 @@ def render_shadow_yaml(
94144
}
95145
],
96146
}
97-
return {
147+
148+
149+
def render_shadow_yaml(
150+
*,
151+
num_nodes: int,
152+
sim_stop_time_s: int,
153+
publisher_start_s: int,
154+
connect_to: int = 2,
155+
muxer: Literal["yamux", "mplex", "quic"] = "yamux",
156+
discovery: Literal["static", "kad-dht"] = "static",
157+
start_sleep: int = 60,
158+
metrics_interval_s: int = 15,
159+
seed: int = 1,
160+
model_unblocked_syscall_latency: bool = False,
161+
strace_logging_mode: str = "off",
162+
lsquic_tick_floor_us: int = 0,
163+
start_jitter_ms: int = 0,
164+
requester_app_path: str = _REQUESTER_APP_PATH,
165+
) -> dict:
166+
"""Build the shadow.yaml dict: N peer hosts running `./main` + a publisher host
167+
running the pod-api-requester in batch mode against the peers' `/publish`
168+
endpoints. The traffic shape (message count, size, pacing) lives in the
169+
requester's own config (see `render_publisher_config`), mounted at
170+
`{_CONFIG_MOUNT}/{_PUBLISHER_CONFIG}`.
171+
172+
discovery selects mesh formation: "static" dials CONNECTTO peers by pod-N
173+
hostname; "kad-dht" adds a `bootstrap-0` anchor host that peers discover
174+
through (Shadow resolves it by hostname, so no k8s Service is needed)."""
175+
if connect_to >= num_nodes:
176+
raise ValueError(f"connect_to ({connect_to}) must be smaller than num_nodes ({num_nodes}).")
177+
178+
peer_env = _peer_env(
179+
num_nodes=num_nodes,
180+
connect_to=connect_to,
181+
muxer=muxer,
182+
discovery=discovery,
183+
start_sleep=start_sleep,
184+
metrics_interval_s=metrics_interval_s,
185+
lsquic_tick_floor_us=lsquic_tick_floor_us,
186+
)
187+
hosts = _peer_hosts(num_nodes, peer_env, start_jitter_ms)
188+
if discovery == "kad-dht":
189+
hosts["bootstrap-0"] = _bootstrap_host(
190+
num_nodes=num_nodes,
191+
muxer=muxer,
192+
start_sleep=start_sleep,
193+
metrics_interval_s=metrics_interval_s,
194+
lsquic_tick_floor_us=lsquic_tick_floor_us,
195+
)
196+
hosts["publisher"] = _publisher_host(publisher_start_s, requester_app_path)
197+
# Always render the seed so the run's shadow.yaml records it (Shadow defaults to 1).
198+
config = {
98199
"general": {
99200
"stop_time": f"{sim_stop_time_s}s",
100201
"progress": True,
202+
"seed": seed,
101203
},
102204
"network": {
103205
"graph": {"type": "1_gbit_switch"},
104206
},
105207
"hosts": hosts,
106208
}
209+
if model_unblocked_syscall_latency:
210+
config["general"]["model_unblocked_syscall_latency"] = True
211+
if strace_logging_mode != "off":
212+
# Global (all hosts) and voluminous — a straced host writes ~100s of MB per
213+
# simulated minute of activity. Diagnostics at small N only.
214+
config["experimental"] = {"strace_logging_mode": strace_logging_mode}
215+
return config
107216

108217

109218
def render_publisher_config(

0 commit comments

Comments
 (0)