company_dns

V4 sizing test: two larger replicas, or four smaller ones?

Status: Done (2026-10-11). Run on the real cluster in three rounds; results and the decision (20 x 1000m) are recorded at the end of this document. Owner: michael.hay@mediumroast.io Question: for the same total CPU and memory, is V4 better as a few large replicas or several small ones? V3 production runs 4 replicas of 500m CPU and 1 GiB each; staging runs 2 of the same size.

What it does

perf_tests/run_cluster.py (with the closed-loop generator perf_tests/load.py, standard library only, run on the build node) patches the staging Deployment to each configuration in turn (replicas, CPU and memory limits, and COMPANY_DNS_REPLICAS, in one rollout), runs the same load against it through Traefik with the real host name (so the path is the real one: TLS, Traefik, the Service, the pods), and always puts the Deployment back as it found it. The repository’s manifest is never edited.

A configuration is REPLICASxCPU-LIMIT/MEMORY-LIMIT. The default set compares the same total (2 cores, 4 GiB) two ways, plus today’s size as the baseline:

configuration total CPU limit what it answers
2x500m/1Gi 1 core today’s staging (the baseline)
2x1000m/2Gi 2 cores two larger replicas
4x500m/1Gi 2 cores four of V3’s per-pod size: the same total as the line above

For each, two mixes at concurrency 8, 16, 32 and 64 for 30 seconds each (after a 5-second warm-up that is thrown away):

Per run it records requests per second, p50, p95 and p99 latency, errors and 429s, and, read from inside the pods, the CPU actually used (in cores), the share of scheduling periods that were CPU-throttled, and peak memory. It then names, per configuration, the best sustained throughput (the most requests per second with at most 1% errors and a p95 under one second) and that divided by the cores of limit.

Why these hypotheses, and what would settle them

Two things in the code suggest that bigger is not automatically better, and the test is designed to show it either way:

  1. One embedding at a time per pod. embed_query runs under a Mutex<Embedders> (main.rs), so a pod embeds one query at a time. More concurrent embedding requests than pods queue. If so, embedding throughput should scale with the number of pods more than with the size of each (a larger pod only makes each embedding faster, by using more cores inside it).
  2. Threads versus the CPU limit. Nothing sets the ONNX Runtime’s thread count; its default follows the machine’s cores, not the container’s CPU quota. On a many-core node a 500m pod can run many threads against half a core and be throttled. The throttled share column shows whether that is happening and whether a larger limit relieves it.

The lookups mix tests the opposite case: small, many-way-parallel queries where more, smaller pods spread the work and a few large ones may not use their cores.

Safety (staging shares two nodes with production)

Steps

  1. Deploy the current code (so the test measures what will ship): merge the open PRs, then on the build node git pull && ./scripts/build-and-deploy-v4.sh --suite.
  2. Preflight and plan, changing nothing: python3 perf_tests/run_cluster.py --dry-run. It checks the cluster, the token and production’s readiness and prints the number of runs and the time (about 25 minutes for the defaults, with the rollouts).
  3. Run it: python3 perf_tests/run_cluster.py. Results go to perf_tests/results/cluster-<date>/ (results.json and summary.md; the directory is gitignored).
  4. Read summary.md with the hypotheses above. Compare, per mix, the configurations with the same total (2x1000m/2Gi against 4x500m/1Gi), the throttled share, and the requests per second per core of limit.
  5. Decide the staging size (and later production’s), record the numbers in the plan, and set replicas, resources and COMPANY_DNS_REPLICAS in k8s/staging/deployment.yaml together (the manifest test pins them to each other).

What the test does not tell you

Larger replicas (hackathon sizing)

The first round (2x500m, 4x500m, 2x1000m) showed that half-core pods are heavily CPU-throttled and that fewer, larger pods did better at equal total CPU. To test larger pods:

python3 perf_tests/run_cluster.py --configs 3x4000m/4Gi,6x2000m/2Gi,12x1000m/1Gi --concurrency 32 64 128 256 --processes 8

Round three: pod count and repeated lookups

Results and decision (hackathon sizing)

Measured on the two worker nodes, one run per cell; the nodes also carry other workloads, so differences under about 20% are noise.

configuration (12 cores unless stated) embedding req/s (p95 <= 1 s) lookups req/s at N=256 lookups p95
3 x 4000m/4Gi 76 1196 697 ms
6 x 2000m/2Gi 140 1230 739 ms
12 x 1000m/1Gi 231 1262 560 ms
16 x 1000m/1Gi (16 cores) not run 1600 (repeated lookups 1987) 479 ms
20 x 1000m/1Gi (20 cores) not run 1873 (repeated lookups 2301) 421 ms