Isolate node and clients on separate CPUs for the locust perf test - #8161
Closed
Amaury Chamayou (achamayou) wants to merge 22 commits into
Closed
Isolate node and clients on separate CPUs for the locust perf test#8161Amaury Chamayou (achamayou) wants to merge 22 commits into
Amaury Chamayou (achamayou) wants to merge 22 commits into
Conversation
Amaury Chamayou (achamayou)
force-pushed
the
achamayou-cpu-isolation-for-perf
branch
3 times, most recently
from
August 14, 2026 16:44
6a803b7 to
82c3896
Compare
Amaury Chamayou (achamayou)
force-pushed
the
locust-blocking-perf
branch
from
August 14, 2026 16:45
9583136 to
d4162fb
Compare
Amaury Chamayou (achamayou)
force-pushed
the
achamayou-cpu-isolation-for-perf
branch
from
August 14, 2026 16:45
82c3896 to
35256d4
Compare
Adds pi_basic_blocking_locust, which measures the same blocking-write workload as pi_basic_blocking but drives it with locust rather than piccolo, so the number of concurrent clients can be varied. The load is defined in tests/infra/basicperf_locustfile.py and uses FastHttpUser, since HttpUser cannot drive enough requests per second to saturate the service. tests/basicperf_locust.py owns the network, runs locust against it, and converts locust statistics into bencher metrics (throughput, latency, memory). The key space helper shared with basicperf.py moves to tests/infra/key_space.py, since basicperf.py can only be imported from tests/infra. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 2da7bd10-ceb0-41a4-b6c4-d574595d91c9
The three files added by this branch were committed with CRLF, unlike every other Python file under tests/. No functional change. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 2da7bd10-ceb0-41a4-b6c4-d574595d91c9
Locust --run-time starts counting when locust starts, so it includes the ramp, and --reset-stats discards the statistics gathered during the ramp without extending the deadline. The measurement window was therefore shorter than requested, and shrank as the spawn rate was lowered, until it disappeared entirely. This mattered because varying the client count is the point of this test: at --users 128 --spawn-rate 4 the run ended mid-ramp and reported 127 tx/s instead of the ~1250 tx/s that 128 users actually sustain, and did so without failing. Start the shutdown timer from locust spawning_complete instead, so the window is the same length whatever the spawn rate is, and rename --run-time-s to --measure-time-s to describe what it now does. --run-time is kept as a backstop against a run which never finishes spawning. Also fail, rather than report, when a run ends without having spawned all users, or when the window measured is shorter than the one asked for. Both produce plausible looking figures that do not describe steady state. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 2da7bd10-ceb0-41a4-b6c4-d574595d91c9
Locust workers reach the master on port 5557 by default, so a second locust run anywhere on the same machine fails to bind. Pick a free port per run instead, via the existing infra.net helper. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 2da7bd10-ceb0-41a4-b6c4-d574595d91c9
Locust writes N/A rather than a number in the statistics CSV when it has too few samples to compute a percentile. float() then raised a bare ValueError, after the network had already been stopped, losing the run with no indication of what had gone wrong. Read the numeric columns through a helper which reports the column and value, and says that the run did not gather enough data. Found by a run which produced almost no samples. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 2da7bd10-ceb0-41a4-b6c4-d574595d91c9
add_piccolo_test passes --snapshot-tx-interval 10000 for every piccolo perf test. add_e2e_test does not pass it at all, and e2e_args defaults it to 10, which is sensible for functional tests but not for a benchmark. This test was therefore writing and fsyncing a ~213KB snapshot every 10 transactions for the whole run, which measures the disk rather than the service, and makes the figure incomparable with Basic Blocking. Found by the vegeta comparison work, which hit the same defect. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 2da7bd10-ceb0-41a4-b6c4-d574595d91c9
Blocking writes return once their transaction commits, and commit cannot outpace the signature interval, so a single interval only measures one regime. Sweeping three separates them: at 1s and 100ms the workload is latency-bound and throughput is simply the client count divided by the interval, while at 5ms the node becomes the limit and the benchmark measures capacity instead. Each interval gets its own network, since the interval is fixed in the node configuration at startup. consensus_update_timeout_ms moves with it, as in commit_latency.py, because commit cannot be observed faster than the primary sends updates. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 2da7bd10-ceb0-41a4-b6c4-d574595d91c9
A benchmark added by a branch has no main runs to build an EWMA baseline from, so render_chart skipped it entirely. That made a new benchmark invisible on the very pull request which adds it, which is when it is most worth seeing. Plot such a benchmark against the branch's own earliest run instead, so its movement across the branch's runs is visible, and mark it as new. It carries no standard deviation band and is never coloured as an improvement or a regression, because there is nothing on main to compare it against. Borrowing a related benchmark's baseline was considered and rejected: an axis normalised against something which measures a different thing shows a difference which is not a change in CCF, and because the chart scale follows the largest axis, one such axis compresses every other benchmark into illegibility. Also truncate long axis labels in the middle rather than at the end. Benchmarks measured at several settings differ only in their suffix, so truncating the end left the 100ms and 1000ms axes indistinguishable. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 2da7bd10-ceb0-41a4-b6c4-d574595d91c9
The blocking workload is latency-bound at the longer signature intervals, so throughput there is set by the client count: 320 clients raises the 100ms point from ~1260 to ~3100 tx/s and the 1s point from ~130 to ~314, both within a few percent of clients divided by interval. Ten sending processes rather than four keeps locust from becoming the limit while driving that many clients, since each process drives all of its users from a single thread. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 2da7bd10-ceb0-41a4-b6c4-d574595d91c9
The shortest interval is the point at which the node, rather than the signature timer, becomes the limit, so it is the one which measures capacity. Shortening it to 2ms pushes further past the latency-bound regime. The node ticks every 1ms in these tests, so a 2ms interval is representable. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 2da7bd10-ceb0-41a4-b6c4-d574595d91c9
The previous version reported a benchmark absent from main by its value alone, marked (new), with no percentage. That read differently from every other axis, and the marker made the label long enough to be truncated, which is exactly what it should not have been for benchmarks whose names differ only in a suffix. Treat such a benchmark like any other axis instead, using this branch's earliest run as its reference in place of the main EWMA baseline, so it is normalized, labelled and coloured identically. The chart description records that the reference differs. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 2da7bd10-ceb0-41a4-b6c4-d574595d91c9
Truncating the middle of the whole label cut out several words at once. Elide word by word from the left instead, keeping each word's first and last letter and replacing the middle with a single ellipsis character, so a label degrades gradually and the last word, which is what distinguishes one setting of a benchmark from another, stays readable longest. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 2da7bd10-ceb0-41a4-b6c4-d574595d91c9
A benchmark with no main history had its standard deviation hardcoded to zero, so all four band curves collapsed to a single point at the baseline while every other axis carried a spread. That left a visible pinch in the band and, because nothing fell inside a zero-width noise threshold, any movement between branch runs was coloured as an improvement or a regression. Measure its spread the same way as for a benchmark with main history, across the runs available, which for such a benchmark are the branch's own. The radial zoom already covered these axes, since their values were always part of the data the scale is fitted to. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 2da7bd10-ceb0-41a4-b6c4-d574595d91c9
Pin the CCF node and the locust clients to disjoint sets of CPUs, so that the Basic Blocking Locust benchmark measures the node rather than how the node and the load generator happened to divide the machine between them. The mechanism is a taskset prefix, which applies the mask before exec so it is inherited by every thread and grandchild. That needs no privileges and no cgroup access, so it works inside the unprivileged CI container. Isolation is off by default and enabled only for pi_basic_blocking_locust, so other tests are unaffected. Where it cannot be applied the test runs unisolated with a warning, and the mode is recorded in bencher.json. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Amaury Chamayou (achamayou)
force-pushed
the
locust-blocking-perf
branch
from
August 18, 2026 16:07
dce652f to
829f0b8
Compare
Amaury Chamayou (achamayou)
force-pushed
the
achamayou-cpu-isolation-for-perf
branch
from
August 18, 2026 16:07
35256d4 to
500605e
Compare
Amaury Chamayou (achamayou)
force-pushed
the
locust-blocking-perf
branch
from
August 18, 2026 18:47
829f0b8 to
1a5905c
Compare
Eddy Ashton (eddyashton)
force-pushed
the
locust-blocking-perf
branch
from
August 19, 2026 08:28
2793ac9 to
5fc8b59
Compare
Amaury Chamayou (achamayou)
force-pushed
the
locust-blocking-perf
branch
from
August 19, 2026 10:08
5fc8b59 to
f6723fd
Compare
Keep main's 2ms, 20ms, and 100ms benchmark intervals while applying CPU isolation, and restore the standalone isolation test to bucket_c. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Register cpu_isolation_test in the checked-in bucket_c snapshot so the CI inventory matches CMake. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: b591ca67-881e-413e-b387-e4f091201815
Keep the generated snapshot's final blank line so the byte-for-byte CI comparison remains stable. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: b591ca67-881e-413e-b387-e4f091201815
…on-for-perf # Conflicts: # tests/basicperf_locust.py
Keep only what isolates the node from the Locust clients: the taskset prefix applied as each process is spawned, and the opt-in flags on the Locust benchmarks. Drop the unit test and its ctest registration, the piccolo client pinning in remote_client.py, the bencher metadata recording, and the CCF_CPU_ISOLATION environment overrides along with the disable() and current() helpers that only existed to support them. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Isolation was opt-in, enabled for one test by a CMake flag. We want it everywhere, so drop the mode and the flags and split the CPUs unconditionally at the top of every Locust benchmark run. That call is now the first thing the benchmark does, so the layout is reported before any other output: CPU isolation: node -> CPUs 16-19 (4 of 20), clients -> CPUs 0-15 (16 of 20) A machine too small to split still warns and runs unisolated rather than failing. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The 16-vCPU CI hosts expose pairs of SMT siblings. Give the node exactly three logical CPUs and leave the unmatched sibling unassigned, preserving isolation without rounding the mask up to four. Refuse isolation when sibling topology is unavailable rather than risk sharing a physical core with clients.\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Perf tests run the CCF node and their load generator on the same machine, so both compete for the same CPUs. What gets measured is then partly a property of the load generator: a client that burns CPU while waiting takes it away from the node.
This pins the node and Locust clients to disjoint CPU sets. The node now gets exactly 3 logical CPUs, matching its two worker threads plus host thread.
How
tasksetprefix is applied as each process is spawned. The mask is inherited by every thread and child process, including Locust workers.sched_getaffinity(0), so outer restrictions such as a container cpuset ortasksetaround the runner are respected.The local 16-vCPU simulation and the VMSS CI host both expose 8 physical cores with adjacent SMT pairs. Their layout is:
Changes
Four files:
tests/infra/cpu_isolation.py, the node hook intests/infra/remote.py, the Locust client hook intests/infra/locust_benchmark.py, andutil-linuxinscripts/setup-ci.shto providetaskset.No
CHANGELOG.mdentry: test infrastructure only.Measurements
Local: 16-vCPU simulation
Local 20-vCPU/10-core host restricted with
taskset -c 0-15, producing the same 16-vCPU/8-core SMT topology as VMSS CI. Basic Blocking Locust used the CI settings: 320 users, spawn rate 320, 10 Locust processes, 20 seconds, and 2ms signatures. Five isolated and five unisolated runs were interleaved. Individual request latencies were captured after dropping the first 3 seconds of each measurement window.The run ranges overlap substantially. The median paired throughput change was +4.0%, with individual pairs ranging from -6.6% to +30.2%, so this is directional rather than clean separation.
CI pool: two VMSS attempts
Workflow run 33183403235 ran twice on
gha-vmss-d16av6-ci. Basic Blocking completed in both attempts. The baseline is the latest 30 unisolatedmainruns on the same pool.These differences are small. The 2ms results sit within the recent
mainthroughput range, while the 20ms and 100ms throughput reductions are about 1-2%.Blocking issue: Basic JS fails with 3 node CPUs
The full perf suite fails reproducibly in Basic JS Locust:
The local 3-CPU and 4-CPU controls give Locust the same 12 CPUs. Only the extra node SMT sibling changes, so the failure is caused by reducing the node from 4 CPUs to 3 rather than by starving the clients.
Conclusion
Do not merge this as configured. Three logical CPUs are enough for Basic Blocking, where isolation is broadly neutral and may improve the fastest-signature case, but they are not enough for the Basic JS workload: it returns HTTP 500s locally and in both CI attempts. Keep the PR draft until node capacity is workload-aware or restored to at least 4 CPUs.