Dynamo frontend benchmarking
End-to-end harness for measuring and profiling the Dynamo frontend under load
from a configurable client, served by mock workers so the backend isn't the
variable under test. Bundled scripts are in scripts/; they all
source env.sh, which requires DYN_REPO to point at your Dynamo checkout.
TL;DR workflow
export DYN_REPO=/path/to/dynamo # checkout with built .venv
# 0. one-time: request plane up, venv built, FlameGraph cloned (see Setup)
sudo bash scripts/isolate.sh # optional but recommended: CPU isolation
BLOCK_SIZE=512 FRONTEND_LD_PRELOAD=$DYN_REPO/bench/jemalloc/libjemalloc.so \
bash scripts/start.sh # frontend (pinned) + N mockers
WARMUP_REQUESTS=512 bash scripts/run_aiperf.sh # one measured run
python3 scripts/extract_throughput.py $DYN_REPO/bench/results/aiperf-* # robust numbers
bash scripts/stop.sh # teardown + etcd drain
For an A/B: teardown + restart between every run, interleave arms, take the
median of 3+. For profiling: profile_oncpu.sh (non-root) and
capture_offcpu.sh (sudo).
What this measures (and what it doesn't)
- Frontend: HTTP (axum/hyper), tokenization (fastokens or HF), KV-router
block hashing + radix-tree scheduling, request dispatch, SSE response relay.
- Mock workers (
dynamo.mocker): simulate generation with --speedup-ratio
(e.g. 1e6 = ~instant) and KV-cache block bookkeeping. Not a real vLLM
worker — no GPU compute. Use them to remove backend variance, not to model
production backends.
- Closed-loop client (aiperf): fixed
--concurrency, so
throughput ≈ concurrency / request_latency (Little's law). This is the
single most important fact for interpreting results (see Pitfalls).
Setup (one-time)
- Request plane — Dynamo needs etcd (
:2379) + NATS with JetStream (:4222):
- etcd is often a systemd service (survives reboot). Check:
etcdctl endpoint health.
- NATS is usually a user binary that does NOT auto-start on reboot. Start:
nohup nats-server -js > /tmp/nats.log 2>&1 & then confirm ss -ltn | grep 4222.
- Build the bindings into a venv:
uv venv && source .venv/bin/activate && (cd lib/bindings/python && maturin develop --uv --release). Rust changes
require rebuilding this; never run a build concurrently with a benchmark —
it steals cores and contaminates results.
- aiperf:
pip install aiperf (the GenAI-perf successor) in some venv; set AIPERF.
- FlameGraph:
git clone https://github.com/brendangregg/FlameGraph and set
FLAMEGRAPH_DIR.
- jemalloc (optional, for the frontend): get a
libjemalloc.so and pass it
via FRONTEND_LD_PRELOAD to start.sh. Big alloc-churn reductions vs glibc.
- perf access for on-CPU profiling:
sudo sysctl kernel.perf_event_paranoid=-1 kernel.kptr_restrict=0. Off-CPU (sched tracepoints / BPF) still needs root
even with paranoid=-1 (tracefs event files are root-only).
Topology & config (env.sh)
FRONTEND_CORES (e.g. 0-3), OTHER_CORES (e.g. 4-23): frontend is pinned
with taskset; mockers + client share OTHER_CORES. Keep FRONTEND_CORES
small so frontend CPU effects are observable, but give OTHER_CORES enough
headroom that the client doesn't starve the mockers (see Pitfalls).
BLOCK_SIZE: frontend --kv-cache-block-size and mocker --block-size MUST
match. Affects both sides — see "Block size" below.
DYN_TOKENIZER = fastokens (PCRE2+rayon, fast) or default (HF tokenizers).
DYN_TOKENIZER_CACHE / _BYTES: L1 prefix cache (helps with shared system prompts).
Running a throughput benchmark — methodology
The harness encodes hard-won protocol. Follow it or results drift:
- Full teardown + fresh restart between every run (
stop.sh then start.sh).
The KV router and tokenizer cache accumulate state across runs; reusing an
instance inflates later runs.
- Drain etcd to 0 workers between runs (
stop.sh does this; verify with
count_workers). Dead frontends/mockers leave lease-backed keys that expire,
but verify the slate is clean before starting.
- Warmup (
WARMUP_REQUESTS=512) to prime the prefix cache + warm the
allocator before the measured phase. The first run after a fresh build is
still a cold-start outlier — discard it.
- jemalloc on the frontend via
FRONTEND_LD_PRELOAD for stable allocator behavior.
- A/B: same binary serves both arms when the difference is a runtime flag;
otherwise rebuild between arms (never during a run). Interleave arms
(A,B,A,B,…) to cancel drift, run 3+ each, compare medians (means get
dragged by the cold first run).
run_aiperf.sh knobs (env overrides): CONCURRENCY, REQUEST_COUNT,
WARMUP_REQUESTS. Default workload: shared-system-prompt 48000 +
user-context 12000 (≈60k-token prompts), output-tokens-mean 500,
conversation-turn-mean 4.
Profiling
On-CPU (where compute goes) — non-root
bash scripts/profile_oncpu.sh --frontend --conc 2048 # or --mocker, or --pid N --cores 0-3
python3 scripts/analyze_folded.py <out>/oncpu.folded
- Uses
perf record -F 99 --call-graph dwarf. DWARF is required: release
.sos have no frame pointers, so -g (FP unwinding) truncates Rust stacks.
- Also samples the target's cores (
mpstat) and process CPU (pidstat) so you
can see if it saturates. analyze_folded.py prints top self-time leaves.
Off-CPU (what blocked threads wait on) — REQUIRES sudo
sudo DYN_REPO=$DYN_REPO bash scripts/capture_offcpu.sh --frontend --conc 2048
python3 scripts/analyze_folded.py <out>/offcpu_bcc.folded --offcpu
- Captures two ways:
offcputime-bpfcc -df (duration-weighted, user+kernel,
folded) and perf -e sched:sched_switch --call-graph dwarf (backup, reliable
Rust user frames). bcc's folded format uses a literal - frame to separate
user (root→leaf) from kernel stacks; the innermost user frame before - is
what called into the blocking syscall — analyze_folded.py --offcpu aggregates by it.
- Interpreting categories:
futex/park = tokio workers idle (no runnable task)
OR mutex; epoll = waiting on network/backend; __lll_lock_wait = glibc
malloc-arena contention; rayon = fastokens pool idle/spin. Lock contention
in app code shows as parking_lot/Mutex/RwLock frames — if those are ~0%, the
process is idle-waiting, not internally serialized.
Analysis cheatsheet
- Throughput:
extract_throughput.py <artifact_dir> — recompute from raw
JSONL (do NOT trust the finalizer; see Pitfalls). Closed-loop sanity check:
throughput ≈ concurrency / mean_latency.
- Cores busy (avg): from
mpstat per-core %idle → busy = 100 - idle;
or cpu_ms_per_req × req_per_s / 1000. Per-request CPU = Δ(utime+stime from
/proc/<pid>/stat)/CLK_TCK ÷ requests.
- Latency decomposition:
request_latency ≈ TTFT + (output_tokens × ITL).
If TTFT dominates and explodes under load → queueing upstream of generation.
Pitfalls & gotchas (read this)
Benchmark methodology
- Closed-loop, not open-loop. Fixed concurrency means you measure
concurrency / latency, NOT the server's max throughput. Idle frontend cores
usually mean the system is latency-bound (each request spends most of its
life waiting between streamed tokens), not that the frontend is slow. To push
the frontend toward saturation: raise concurrency AND lower per-request
latency (smaller block size → more frontend KV work; shorter outputs).
- Congestion collapse at high concurrency. Pushing concurrency too high can
lower throughput (latency explodes faster than concurrency rises). Sweep
concurrency to find the knee; don't assume "more load = more throughput".
- Client/server core contention. aiperf is CPU-heavy (client-side tokenizes
every prompt, manages every stream across ~25 procs). Co-located with the
mockers on
OTHER_CORES, it can saturate those cores and starve the mockers
— making a "collapse" that's really the load generator running out of CPU.
Always check the CPU split (pidstat mocker vs mpstat on OTHER_CORES);
if cores are pegged but the mocker is low, the client is the bottleneck.
- Cold-start first run is systematically slow even with warmup — discard it.
- Don't build while benchmarking. Compiles steal cores and ruin the run.
aiperf
- The finalizer hangs/deadlocks on large runs ("processing records…"). The
per-request
profile_export.jsonl is written incrementally — kill the
finalizer and use extract_throughput.py. Don't wait for
profile_export_aiperf.json.
- Orphan processes. aiperf's controller spawns many workers; killing the
parent can orphan them. Worse: if you ran a capture with sudo, aiperf ran
as root and a non-root
pkill can't reap it — use sudo pkill -9 -f aiperf.
Stray aiperf workers hold ZMQ/mmap resources and make the next run stall.
--benchmark-duration N (time-based) avoids the giant fixed --request-count
- finalizer problem for profiling loads.
Profiling
- Off-CPU needs root. Tracepoints (
sched:sched_switch) and BPF
(offcputime) require root even at perf_event_paranoid=-1 (tracefs event
files are root-readable only). On-CPU perf -F.. -g works non-root at paranoid≤1.
- Native
perf --off-cpu is often NOT compiled in (needs BUILD_BPF_SKEL=1);
it silently no-ops with a warning. Use offcputime-bpfcc / bpftrace instead.
- No frame pointers in release builds → BPF user-stack walking truncates.
Prefer
perf --call-graph dwarf; bcc still gives good kernel stacks + partial
user frames. analyze_folded.py handles the bcc - separator.
- Async-runtime off-CPU is dominated by worker park (futex) which is benign
idle, not contention. Look for app-level lock frames (parking_lot/Mutex) to
find real serialization. A blocked async task ≠ a blocked thread.
Topology / environment
- Block size must match frontend and mocker. And very large block sizes
break the current mocker: at
BLOCK_SIZE=2048 requests are received but the
mocker never emits a token (40s hang → client cancel, output_tokens=0,
"Failed to publish response"). 512 and 1024 work; 64 is realistic. Smoke-test
a single request after any block-size change.
- Block size is a lever, not just a detail. Smaller blocks → more blocks per
prompt → more frontend KV-routing work (radix tree, hashing) AND more mocker
block bookkeeping. At bs=64 a 60k-token prompt is
940 blocks and the mocker's
KV bookkeeping can dominate (48% of its CPU); at bs=512 (~117 blocks) it drops
to ~3%. Pick the block size deliberately for what you're stressing.
- CPU isolation doesn't survive reboot (
isolate.sh sets runtime cgroup
cpusets on system.slice). Re-run sudo bash scripts/isolate.sh after every
reboot. unisolate.sh reverts. Check: cat /sys/fs/cgroup/system.slice/cpuset.cpus.effective.
- NATS doesn't auto-start after reboot (user binary); etcd usually does
(systemd). After a reboot, restart NATS before
start.sh.
- jemalloc is frontend-only here (via
FRONTEND_LD_PRELOAD); the mocker runs
on glibc, so its alloc churn can show glibc-arena lock contention
(__lll_lock_wait under __libc_free/Vec::finish_grow) in off-CPU. Preload
jemalloc on the mocker too if that matters.
DYN_RUNTIME_NUM_WORKER_THREADS and DYN_RUNTIME_MAX_BLOCKING_THREADS are
applied to every runtime the bindings build, including the one the pyo3 async
bridge builds for itself. Thread counts are still worth checking in
/proc/<pid>/task: if the bridge builds its runtime before a
DistributedRuntime is created, the process ends up with two runtimes and
twice the threads the configuration describes (a warning says so).
Known result (calibration): with mock workers, the Dynamo frontend is
rarely the bottleneck — it's latency/IO-bound, sitting ~60–85% of its pinned
cores with ~0 internal lock contention. Frontend micro-opts therefore show flat
e2e throughput on this setup; their value is CPU-efficiency/headroom. To make
the frontend the bottleneck, use small block size + high concurrency, or
real backends, or move the client off-box.
Script reference (scripts/)
env.sh — config; set DYN_REPO; everything else overridable.
start.sh — launch frontend (pinned, optional FRONTEND_LD_PRELOAD/FASTOKENS_*)
NUM_WORKERS mockers; port preflight, etcd worker-count verify.
stop.sh — teardown both + drain etcd to 0.
run_aiperf.sh — one measured run (CONCURRENCY/REQUEST_COUNT/WARMUP_REQUESTS).
isolate.sh / unisolate.sh — CPU isolation (sudo; Lite by default, --full for max).
smoke.sh — single-request sanity check (use after any topology/block-size change).
profile_oncpu.sh — on-CPU perf + flamegraph (non-root): --frontend/--mocker/--pid.
capture_offcpu.sh — off-CPU bcc + perf (sudo): --frontend/--mocker/--pid.
analyze_folded.py — top self-time (on-CPU) or innermost-frame + category (off-CPU).
extract_throughput.py — robust throughput/latency from raw aiperf JSONL.
1---2name: dynamo-frontend-benchmark3description: Benchmark and profile the Dynamo frontend (dynamo.frontend HTTP + tokenizer + KV router) against mock workers (dynamo.mocker). Use when measuring frontend throughput/latency, A/B-testing a frontend change, or on-CPU/off-CPU profiling the frontend or mock workers to find bottlenecks. Covers topology setup, CPU isolation, aiperf load generation, perf/BPF profiling, throughput analysis, and the sharp edges of this setup.4license: Apache-2.05---67# Dynamo frontend benchmarking89End-to-end harness for measuring and profiling the Dynamo **frontend** under load10from a configurable client, served by **mock workers** so the backend isn't the11variable under test. Bundled scripts are in `scripts/`; they all12`source env.sh`, which requires `DYN_REPO` to point at your Dynamo checkout.1314## TL;DR workflow1516```bash17export DYN_REPO=/path/to/dynamo # checkout with built .venv18# 0. one-time: request plane up, venv built, FlameGraph cloned (see Setup)19sudo bash scripts/isolate.sh # optional but recommended: CPU isolation20BLOCK_SIZE=512 FRONTEND_LD_PRELOAD=$DYN_REPO/bench/jemalloc/libjemalloc.so \21 bash scripts/start.sh # frontend (pinned) + N mockers22WARMUP_REQUESTS=512 bash scripts/run_aiperf.sh # one measured run23python3 scripts/extract_throughput.py $DYN_REPO/bench/results/aiperf-* # robust numbers24bash scripts/stop.sh # teardown + etcd drain25```2627For an A/B: **teardown + restart between every run**, interleave arms, take the28median of 3+. For profiling: `profile_oncpu.sh` (non-root) and29`capture_offcpu.sh` (sudo).3031## What this measures (and what it doesn't)3233- **Frontend**: HTTP (axum/hyper), tokenization (fastokens or HF), KV-router34 block hashing + radix-tree scheduling, request dispatch, SSE response relay.35- **Mock workers** (`dynamo.mocker`): simulate generation with `--speedup-ratio`36 (e.g. 1e6 = ~instant) and KV-cache block bookkeeping. **Not** a real vLLM37 worker — no GPU compute. Use them to remove backend variance, not to model38 production backends.39- **Closed-loop client** (aiperf): fixed `--concurrency`, so40 **throughput ≈ concurrency / request_latency** (Little's law). This is the41 single most important fact for interpreting results (see Pitfalls).4243## Setup (one-time)44451. **Request plane** — Dynamo needs etcd (`:2379`) + NATS with JetStream (`:4222`):46 - etcd is often a systemd service (survives reboot). Check: `etcdctl endpoint health`.47 - **NATS is usually a user binary that does NOT auto-start on reboot.** Start:48 `nohup nats-server -js > /tmp/nats.log 2>&1 &` then confirm `ss -ltn | grep 4222`.492. **Build the bindings** into a venv: `uv venv && source .venv/bin/activate &&50 (cd lib/bindings/python && maturin develop --uv --release)`. Rust changes51 require rebuilding this; **never run a build concurrently with a benchmark** —52 it steals cores and contaminates results.533. **aiperf**: `pip install aiperf` (the GenAI-perf successor) in some venv; set `AIPERF`.544. **FlameGraph**: `git clone https://github.com/brendangregg/FlameGraph` and set55 `FLAMEGRAPH_DIR`.565. **jemalloc** (optional, for the frontend): get a `libjemalloc.so` and pass it57 via `FRONTEND_LD_PRELOAD` to `start.sh`. Big alloc-churn reductions vs glibc.586. **perf access** for on-CPU profiling: `sudo sysctl kernel.perf_event_paranoid=-159 kernel.kptr_restrict=0`. Off-CPU (sched tracepoints / BPF) **still needs root**60 even with paranoid=-1 (tracefs event files are root-only).6162## Topology & config (`env.sh`)6364- `FRONTEND_CORES` (e.g. `0-3`), `OTHER_CORES` (e.g. `4-23`): frontend is pinned65 with `taskset`; mockers + client share `OTHER_CORES`. **Keep `FRONTEND_CORES`66 small so frontend CPU effects are observable**, but give `OTHER_CORES` enough67 headroom that the client doesn't starve the mockers (see Pitfalls).68- `BLOCK_SIZE`: **frontend `--kv-cache-block-size` and mocker `--block-size` MUST69 match.** Affects both sides — see "Block size" below.70- `DYN_TOKENIZER` = `fastokens` (PCRE2+rayon, fast) or `default` (HF tokenizers).71- `DYN_TOKENIZER_CACHE` / `_BYTES`: L1 prefix cache (helps with shared system prompts).7273## Running a throughput benchmark — methodology7475The harness encodes hard-won protocol. Follow it or results drift:76771. **Full teardown + fresh restart between every run** (`stop.sh` then `start.sh`).78 The KV router and tokenizer cache accumulate state across runs; reusing an79 instance inflates later runs.802. **Drain etcd to 0 workers** between runs (`stop.sh` does this; verify with81 `count_workers`). Dead frontends/mockers leave lease-backed keys that expire,82 but verify the slate is clean before starting.833. **Warmup** (`WARMUP_REQUESTS=512`) to prime the prefix cache + warm the84 allocator before the measured phase. The first run after a fresh build is85 still a cold-start outlier — discard it.864. **jemalloc on the frontend** via `FRONTEND_LD_PRELOAD` for stable allocator behavior.875. **A/B**: same binary serves both arms when the difference is a runtime flag;88 otherwise rebuild between arms (never during a run). **Interleave** arms89 (A,B,A,B,…) to cancel drift, run 3+ each, compare **medians** (means get90 dragged by the cold first run).9192`run_aiperf.sh` knobs (env overrides): `CONCURRENCY`, `REQUEST_COUNT`,93`WARMUP_REQUESTS`. Default workload: shared-system-prompt 48000 +94user-context 12000 (≈60k-token prompts), output-tokens-mean 500,95conversation-turn-mean 4.9697## Profiling9899### On-CPU (where compute goes) — non-root100```bash101bash scripts/profile_oncpu.sh --frontend --conc 2048 # or --mocker, or --pid N --cores 0-3102python3 scripts/analyze_folded.py <out>/oncpu.folded103```104- Uses `perf record -F 99 --call-graph dwarf`. **DWARF is required**: release105 `.so`s have no frame pointers, so `-g` (FP unwinding) truncates Rust stacks.106- Also samples the target's cores (`mpstat`) and process CPU (`pidstat`) so you107 can see if it saturates. `analyze_folded.py` prints top **self-time** leaves.108109### Off-CPU (what blocked threads wait on) — REQUIRES sudo110```bash111sudo DYN_REPO=$DYN_REPO bash scripts/capture_offcpu.sh --frontend --conc 2048112python3 scripts/analyze_folded.py <out>/offcpu_bcc.folded --offcpu113```114- Captures two ways: `offcputime-bpfcc -df` (duration-weighted, user+kernel,115 folded) and `perf -e sched:sched_switch --call-graph dwarf` (backup, reliable116 Rust user frames). bcc's folded format uses a literal `-` frame to separate117 user (root→leaf) from kernel stacks; the **innermost user frame before `-`** is118 what called into the blocking syscall — `analyze_folded.py --offcpu` aggregates by it.119- Interpreting categories: `futex/park` = tokio workers idle (no runnable task)120 OR mutex; `epoll` = waiting on network/backend; `__lll_lock_wait` = glibc121 malloc-arena contention; `rayon` = fastokens pool idle/spin. **Lock contention122 in app code shows as parking_lot/Mutex/RwLock frames** — if those are ~0%, the123 process is idle-waiting, not internally serialized.124125## Analysis cheatsheet126127- **Throughput**: `extract_throughput.py <artifact_dir>` — recompute from raw128 JSONL (do NOT trust the finalizer; see Pitfalls). Closed-loop sanity check:129 `throughput ≈ concurrency / mean_latency`.130- **Cores busy** (avg): from `mpstat` per-core `%idle` → `busy = 100 - idle`;131 or `cpu_ms_per_req × req_per_s / 1000`. Per-request CPU = Δ(utime+stime from132 `/proc/<pid>/stat`)/CLK_TCK ÷ requests.133- **Latency decomposition**: `request_latency ≈ TTFT + (output_tokens × ITL)`.134 If TTFT dominates and explodes under load → queueing upstream of generation.135136## Pitfalls & gotchas (read this)137138**Benchmark methodology**139- **Closed-loop, not open-loop.** Fixed concurrency means you measure140 `concurrency / latency`, NOT the server's max throughput. Idle frontend cores141 usually mean the system is **latency-bound** (each request spends most of its142 life waiting between streamed tokens), not that the frontend is slow. To push143 the frontend toward saturation: raise concurrency AND lower per-request144 latency (smaller block size → more frontend KV work; shorter outputs).145- **Congestion collapse at high concurrency.** Pushing concurrency too high can146 *lower* throughput (latency explodes faster than concurrency rises). Sweep147 concurrency to find the knee; don't assume "more load = more throughput".148- **Client/server core contention.** aiperf is CPU-heavy (client-side tokenizes149 every prompt, manages every stream across ~25 procs). Co-located with the150 mockers on `OTHER_CORES`, it can saturate those cores and **starve the mockers**151 — making a "collapse" that's really the *load generator* running out of CPU.152 Always check the CPU split (`pidstat` mocker vs `mpstat` on `OTHER_CORES`);153 if cores are pegged but the mocker is low, the client is the bottleneck.154- **Cold-start first run** is systematically slow even with warmup — discard it.155- **Don't build while benchmarking.** Compiles steal cores and ruin the run.156157**aiperf**158- **The finalizer hangs/deadlocks on large runs** ("processing records…"). The159 per-request `profile_export.jsonl` is written incrementally — kill the160 finalizer and use `extract_throughput.py`. Don't wait for161 `profile_export_aiperf.json`.162- **Orphan processes.** aiperf's controller spawns many workers; killing the163 parent can orphan them. Worse: if you ran a capture **with sudo**, aiperf ran164 **as root** and a non-root `pkill` can't reap it — use `sudo pkill -9 -f aiperf`.165 Stray aiperf workers hold ZMQ/mmap resources and make the *next* run stall.166- `--benchmark-duration N` (time-based) avoids the giant fixed `--request-count`167 + finalizer problem for profiling loads.168169**Profiling**170- **Off-CPU needs root.** Tracepoints (`sched:sched_switch`) and BPF171 (`offcputime`) require root even at `perf_event_paranoid=-1` (tracefs event172 files are root-readable only). On-CPU `perf -F.. -g` works non-root at paranoid≤1.173- **Native `perf --off-cpu` is often NOT compiled in** (needs `BUILD_BPF_SKEL=1`);174 it silently no-ops with a warning. Use `offcputime-bpfcc` / bpftrace instead.175- **No frame pointers in release builds** → BPF user-stack walking truncates.176 Prefer `perf --call-graph dwarf`; bcc still gives good kernel stacks + partial177 user frames. `analyze_folded.py` handles the bcc `-` separator.178- Async-runtime off-CPU is dominated by **worker park (futex)** which is benign179 idle, not contention. Look for app-level lock frames (parking_lot/Mutex) to180 find real serialization. A blocked async *task* ≠ a blocked *thread*.181182**Topology / environment**183- **Block size must match** frontend and mocker. And **very large block sizes184 break the current mocker**: at `BLOCK_SIZE=2048` requests are received but the185 mocker never emits a token (40s hang → client cancel, `output_tokens=0`,186 "Failed to publish response"). 512 and 1024 work; 64 is realistic. Smoke-test187 a single request after any block-size change.188- **Block size is a lever, not just a detail.** Smaller blocks → more blocks per189 prompt → more frontend KV-routing work (radix tree, hashing) AND more mocker190 block bookkeeping. At bs=64 a 60k-token prompt is ~940 blocks and the mocker's191 KV bookkeeping can dominate (~48% of its CPU); at bs=512 (~117 blocks) it drops192 to ~3%. Pick the block size deliberately for what you're stressing.193- **CPU isolation doesn't survive reboot** (`isolate.sh` sets runtime cgroup194 cpusets on system.slice). Re-run `sudo bash scripts/isolate.sh` after every195 reboot. `unisolate.sh` reverts. Check: `cat /sys/fs/cgroup/system.slice/cpuset.cpus.effective`.196- **NATS doesn't auto-start after reboot** (user binary); etcd usually does197 (systemd). After a reboot, restart NATS before `start.sh`.198- **jemalloc is frontend-only** here (via `FRONTEND_LD_PRELOAD`); the mocker runs199 on glibc, so its alloc churn can show glibc-arena lock contention200 (`__lll_lock_wait` under `__libc_free`/`Vec::finish_grow`) in off-CPU. Preload201 jemalloc on the mocker too if that matters.202- `DYN_RUNTIME_NUM_WORKER_THREADS` and `DYN_RUNTIME_MAX_BLOCKING_THREADS` are203 applied to every runtime the bindings build, including the one the pyo3 async204 bridge builds for itself. Thread counts are still worth checking in205 `/proc/<pid>/task`: if the bridge builds its runtime before a206 `DistributedRuntime` is created, the process ends up with two runtimes and207 twice the threads the configuration describes (a warning says so).208209**Known result (calibration)**: with mock workers, the Dynamo **frontend is210rarely the bottleneck** — it's latency/IO-bound, sitting ~60–85% of its pinned211cores with ~0 internal lock contention. Frontend micro-opts therefore show flat212e2e throughput on this setup; their value is CPU-efficiency/headroom. To make213the frontend the bottleneck, use small block size + high concurrency, or214real backends, or move the client off-box.215216## Script reference (`scripts/`)217- `env.sh` — config; **set `DYN_REPO`**; everything else overridable.218- `start.sh` — launch frontend (pinned, optional `FRONTEND_LD_PRELOAD`/`FASTOKENS_*`)219 + `NUM_WORKERS` mockers; port preflight, etcd worker-count verify.220- `stop.sh` — teardown both + drain etcd to 0.221- `run_aiperf.sh` — one measured run (`CONCURRENCY`/`REQUEST_COUNT`/`WARMUP_REQUESTS`).222- `isolate.sh` / `unisolate.sh` — CPU isolation (sudo; Lite by default, `--full` for max).223- `smoke.sh` — single-request sanity check (use after any topology/block-size change).224- `profile_oncpu.sh` — on-CPU perf + flamegraph (non-root): `--frontend`/`--mocker`/`--pid`.225- `capture_offcpu.sh` — off-CPU bcc + perf (sudo): `--frontend`/`--mocker`/`--pid`.226- `analyze_folded.py` — top self-time (on-CPU) or innermost-frame + category (off-CPU).227- `extract_throughput.py` — robust throughput/latency from raw aiperf JSONL.