diff --git a/benchmarks/hello/results/client-layout-20261109/layout.py b/benchmarks/hello/results/client-layout-20261109/layout.py index 65d4507..d355c82 100644 --- a/benchmarks/hello/results/client-layout-20261109/layout.py +++ b/benchmarks/hello/results/client-layout-20261109/layout.py @@ -12,6 +12,9 @@ LOCK = "/tmp/base-benchmark-host.lock" VALIDATION = ["--body", "Hello, world!", "--expect-header", "Content-Type: text/plain", "--expect-header", "Content-Length: 13"] CONTROLLER = "0" +# How long the server may take to print its address: generous, so that only a server that never does fails, rather than +# holding the host lock indefinitely. +READY_TIMEOUT = 60 # Layouts: (client process CPUs, worker arguments). Pinned layouts give each worker a CPU with --cpus and the controller CPU 0; # unpinned ones (-u) run `--workers 2` within the process's CPUs, as base-benchmark's --role-cpus would place them. @@ -82,16 +85,39 @@ def stop(process): process.wait() +def ready_line(process, timeout): + """The process's first line of output, or exit if it prints none within the timeout.""" + lines = [] + reader = threading.Thread(target=lambda: lines.append(process.stdout.readline()), daemon=True) + reader.start() + reader.join(timeout) + if reader.is_alive(): + raise SystemExit(f"layout.py: the server printed no address within {timeout} seconds") + return lines[0] + + +def interrupted(signum, frame): + # The server and client run in their own sessions, so a terminal's signals don't reach them: exit through sample's cleanup, + # ignoring further signals so that they cannot interrupt it. + for name in ["SIGHUP", "SIGINT", "SIGTERM"]: + signal.signal(getattr(signal, name), signal.SIG_IGN) + raise SystemExit(128 + signum) + + def sample(arguments): + # A signal inherited as ignored, as under `nohup` or in a background job, stays ignored. + for name in ["SIGHUP", "SIGINT", "SIGTERM"]: + if signal.getsignal(getattr(signal, name)) is not signal.SIG_IGN: + signal.signal(getattr(signal, name), interrupted) configuration = json.loads(arguments.configuration) scenario_extra = SCENARIOS[configuration["scenario"]][1] client_cpus, worker_arguments = LAYOUTS[configuration["layout"]] tick = os.sysconf("SC_CLK_TCK") - server = subprocess.Popen(["taskset", "-c", configuration["server_cpus"], arguments.server, configuration["backend"]], - stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True, start_new_session=True) - client = None + server = client = None try: - address = server.stdout.readline().strip() + server = subprocess.Popen(["taskset", "-c", configuration["server_cpus"], arguments.server, configuration["backend"]], + stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True, start_new_session=True) + address = ready_line(server, READY_TIMEOUT).strip() command = ["taskset", "-c", client_cpus, arguments.load, "closed", "--address", address, "--connections", "64", *worker_arguments, *scenario_extra, *VALIDATION, "--warmup", arguments.warmup, "--duration", arguments.duration, "--window-lead", arguments.window_lead, "--quiet"] @@ -131,7 +157,8 @@ def sample_frequencies(): if client is not None and client.poll() is None: client.kill() client.wait() - stop(server) + if server is not None: + stop(server) result = dict(configuration, client_cpus=client_cpus, command=command[3:], status=status, loadavg=loadavg, stderr=stderr[-2000:] if status else "") if record is None or start is None or stop_snapshot is None: @@ -190,7 +217,8 @@ def campaign(arguments): "--server", arguments.server, "--load", arguments.load, "--warmup", arguments.warmup, "--duration", arguments.duration, "--window-lead", arguments.window_lead, "--configuration", json.dumps(configuration)] + (["--frequencies"] if arguments.frequencies else []) - line = subprocess.run(command, capture_output=True, text=True, check=True).stdout.strip() + # The sample's stderr passes through, so that a failure's reason shows. + line = subprocess.run(command, stdout=subprocess.PIPE, text=True, check=True).stdout.strip() result = dict(json.loads(line), round=round_index, position=position) output.write(json.dumps(result) + "\n") output.flush() diff --git a/benchmarks/hello/results/client-layout-20261109/readme.md b/benchmarks/hello/results/client-layout-20261109/readme.md index 1b09a30..a951a0f 100644 --- a/benchmarks/hello/results/client-layout-20261109/readme.md +++ b/benchmarks/hello/results/client-layout-20261109/readme.md @@ -6,10 +6,10 @@ Screening evidence, gathered 9 November 2026 on [hana](../../../hosts/hana.md): Run two client workers on two physical cores: `--workers 2` with `--role-cpus client=1,2 --role-cpus server=3` on hana, the layout called C-u below. Of the layouts measured, only two workers on two cores drove each server without the client saturated: each worker was busy for about two-thirds of the window in `raw-closed` and about half in `http-closed`. In `raw-closed` that makes it the only layout that measures the server rather than the client. -- **One worker (A)** is the limit in `raw-closed`: busy for the whole window, 5–6% below two workers in every round. In `http-closed` it was busy for 96–98% of the window, flagged as saturated, and within noise of two cores (−0.2% for hyper-tokio, −1.4% for base-native, with a CV of 3.2%). +- **One worker (A)** is the limit in `raw-closed`: busy for the whole window and below two workers on two cores (C) in every round, by 5–6% at the median (3.5–6.7% in single rounds). In `http-closed` it was busy for 96–98% of the window, flagged as saturated, and within noise of two cores (−0.2% for hyper-tokio, −1.4% for base-native, with a CV of 3.2%). - **Two workers on one core's SMT siblings (B)** help over one worker in `raw-closed`, +6–7%, but only by saturating both threads: the second thread gives the core at least that much more capacity, just enough to match what two cores drove at two-thirds busy, with no headroom left for a faster server. In `http-closed` B was 1–2% faster than two cores, but with both workers flagged as saturated (85–90% busy) and the server spending 0.7–2.8% less CPU per request. The server's CPU ran at the same frequency, about 4.0 GHz, in both layouts, so turbo does not explain it. The difference is in the server's workload, consistent with a slower client sending requests in larger batches, though this screen did not test that. A layout that changes the server's work is not the one to compare servers with. The siblings' contention also shows in the client: against C, its 99th percentile dispatch lag rose from 0.7–0.8 µs to 1.1–1.3 µs, and its CPUs' softirq time per request from 1.2 µs to 1.8–2.1 µs. - **Two cores (C, C-u)** had a CV of 0.3–1.4%, except base-native in `http-closed` (C, at 2.4% and 4.0%, from one or two low rounds per cohort); C-u, the form base-benchmark runs, had 1.0% there. B and B-u's CVs were 0.4–2.1%. -- **Pinned or not**: C-u, two workers left to the kernel within CPUs 1 and 2 with the controller unpinned, as `--role-cpus client=1,2` places them, was within 0.6% of C, its workers pinned and its controller on CPU 0, for every backend. B-u was within 1.1% of B. +- **Pinned or not**: C-u, two workers left to the kernel within CPUs 1 and 2 with the controller unpinned, as `--role-cpus client=1,2` places them, was within 0.6% of C, its workers pinned and its controller on CPU 0, for every backend. B-u was within 0.3% of B by the median of each round's ratio, as "vs C" is computed, except for hyper-tokio in `http-closed`: +2.1%, or +1.6% between the two layouts' medians, where B had a CV of 2.1%. - **Server CPUs**: the backends are single-threaded, with helper threads that do little. Giving the server its sibling (3,7 against 3, under C) changed nothing measurable (−0.6% for base-native and base-iouring-raw). Under A, two cores (2,3) or two cores and their siblings (2,3,6,7) were +3.2% and +1.8% against CPU 3 by the median of rounds, but rounds ranged from −8.8% to +9.1% with the client saturated, which is inconclusive. One server CPU stays. The fixed-rate scenarios keep one worker: this screen did not measure them. @@ -80,7 +80,7 @@ Run afterwards, to check whether turbo frequency explains B's lead in `http-clos ## Files -- `layout.py`: the campaign driver, which runs each sample as `flock /tmp/base-benchmark-host.lock layout.py sample ...` after checking `pgrep -af` for other benchmarks, and the summaries above. +- `layout.py`: the campaign driver, which runs each sample as `flock /tmp/base-benchmark-host.lock layout.py sample ...` after checking `pgrep -af` for other benchmarks, and the summaries above. Since the campaigns, a sample also gives up if the server prints no address within 60 seconds, and stops the server and client if it is sent `SIGHUP`, `SIGINT` or `SIGTERM`; what a sample measures is unchanged. - `samples.csv`: every sample's figures, one row each, from `layout.py table`. The full JSON records stayed on hana and were not kept. On hana, from a scratch directory holding a clone of base-http at `eaa13fd` and `layout.py`: diff --git a/benchmarks/hosts/hana.md b/benchmarks/hosts/hana.md index c889729..6fac5f6 100644 --- a/benchmarks/hosts/hana.md +++ b/benchmarks/hosts/hana.md @@ -48,7 +48,7 @@ Earlier investigations used other placements, recorded with their results, such - Take the host lock for every measurement, `flock /tmp/base-benchmark-host.lock`, as base-benchmark does, and check `pgrep -af 'base-benchmark|wrk|hello-benchmark'` for another session's run, waiting for it rather than interfering. - Build and prepare before a campaign, not during one, and run nothing else while measuring. -- Single rounds in the screen still ran up to 8% below their cohort's median, so interleave rounds and read ranges as well as medians. +- Single rounds in the screen still ran up to 9% below their cohort's median, so interleave rounds and read ranges as well as medians. ## Gathered with