reliable-transport/experiments.py
2026-10-19 13:24:25 -06:00

140 lines
5.4 KiB
Python

"""Measure the three protocols over a real lossy link. Given; do not change it.
Run it inside the course container (see README.md), as root:
python3 experiments.py # every experiment, about four minutes
python3 experiments.py loss # goodput against loss rate only
python3 experiments.py window # goodput against window size only
It builds the smallest possible network: two network namespaces, h1 and h2,
joined by one virtual cable (a veth pair), with netem on each end adding the
delay and the loss. That is exactly what Mininet builds for two hosts and a
link. h1 sends a file to h2 with transfer.py, and the run counts only if the
file arrives byte for byte. Loss is random, so every setting runs three times
and the tables show the median run. Results print as tables for FINDINGS.md
and go to results.json with every run kept.
netem's loss applies in both directions: at 5%, data packets and ACKs are
each lost 5% of the time.
References:
https://man7.org/linux/man-pages/man8/ip-netns.8.html
https://man7.org/linux/man-pages/man8/tc-netem.8.html
"""
import hashlib
import json
import os
import subprocess
import sys
import time
FILE_BYTES = 100_000
DELAY = "10ms" # each way, so the round trip is about 20 ms
TIMEOUT = 0.1
TRIALS = 3
HERE = os.path.dirname(os.path.abspath(__file__))
ADDR = {"h1": "10.0.0.1", "h2": "10.0.0.2"}
def sh(*cmd):
subprocess.run(cmd, check=True, capture_output=True, text=True)
def build(loss):
"""Two namespaces joined by a veth pair, with netem delay and loss on both ends."""
teardown()
for host in ADDR:
sh("ip", "netns", "add", host)
sh("ip", "link", "add", "h1-eth0", "netns", "h1", "type", "veth", "peer", "name", "h2-eth0", "netns", "h2")
for host, addr in ADDR.items():
dev = f"{host}-eth0"
sh("ip", "-n", host, "addr", "add", f"{addr}/24", "dev", dev)
sh("ip", "-n", host, "link", "set", dev, "up")
sh("ip", "-n", host, "link", "set", "lo", "up")
sh("ip", "netns", "exec", host, "tc", "qdisc", "add", "dev", dev, "root",
"netem", "delay", DELAY, "loss", f"{loss}%")
def teardown():
for host in ADDR:
subprocess.run(["ip", "netns", "del", host], capture_output=True)
def run(proto, loss, window):
build(loss)
src, dst = "/tmp/p3_send.bin", "/tmp/p3_recv.bin"
with open(src, "wb") as f:
f.write(os.urandom(FILE_BYTES))
if os.path.exists(dst):
os.remove(dst)
receiver = subprocess.Popen(["ip", "netns", "exec", "h2", sys.executable, "transfer.py", "recv",
"--proto", proto, "--window", str(window), "--out", dst], cwd=HERE)
time.sleep(0.3) # let the receiver bind its port first
try:
sent = subprocess.run(["ip", "netns", "exec", "h1", sys.executable, "transfer.py", "send",
"--proto", proto, "--window", str(window), "--timeout", str(TIMEOUT),
ADDR["h2"], "5000", src], cwd=HERE, capture_output=True, text=True, timeout=300)
out = sent.stdout + sent.stderr
except subprocess.TimeoutExpired:
out = "the sender ran for 300 s without finishing"
try:
receiver.wait(5) # 5 s to finish after the sender is done
except subprocess.TimeoutExpired:
receiver.kill()
receiver.wait()
teardown()
try:
stats = json.loads(out.strip().splitlines()[-1])
except (ValueError, IndexError):
return {"proto": proto, "loss": loss, "window": window, "ok": False, "error": out.strip()[-300:]}
same = os.path.exists(dst) and _digest(src) == _digest(dst)
stats.update(proto=proto, loss=loss, window=window, ok=same,
goodput_kbps=round(FILE_BYTES * 8 / 1000 / stats["seconds"], 1))
return stats
def median_run(proto, loss, window):
runs = [run(proto, loss, window) for _ in range(TRIALS)]
good = sorted((r for r in runs if r["ok"]), key=lambda r: r["goodput_kbps"])
best = dict(good[len(good) // 2]) if len(good) == TRIALS else dict(runs[0], ok=False)
best["runs"] = runs
return best
def _digest(path):
with open(path, "rb") as f:
return hashlib.sha256(f.read()).hexdigest()
def table(rows, key):
print(f"\n| protocol | {key} | goodput (kbit/s) | transmissions | retransmissions | intact |")
print("| :--- | --: | --: | --: | --: | :--- |")
for r in rows:
if not r["ok"]:
print(f"| {r['proto']} | {r[key]} | failed | | | no |")
continue
print(f"| {r['proto']} | {r[key]} | {r['goodput_kbps']} | {r['transmissions']} "
f"| {r['retransmissions']} | yes |")
def main():
if os.geteuid() != 0:
sys.exit("run this as root inside the course container: creating namespaces needs it")
wanted = sys.argv[1:] or ["loss", "window"]
results = {}
if "loss" in wanted:
rows = [median_run(p, loss, 8) for p in ("sw", "gbn", "sr") for loss in (0, 1, 5, 10, 20)]
table(rows, "loss")
results["loss"] = rows
if "window" in wanted:
rows = [median_run(p, 5, w) for p in ("gbn", "sr") for w in (1, 2, 4, 8, 16, 32)]
table(rows, "window")
results["window"] = rows
with open(os.path.join(HERE, "results.json"), "w") as f:
json.dump(results, f, indent=1)
print("\nwrote results.json")
if __name__ == "__main__":
main()