Files
windmill/benchmarks/sim
pyranotaandClaude Opus 4.7 74b662d8de feat(benchmarks): k8s sim mode + util-group dashboard + reliability fixes
Stand up a minikube-backed simulation subsystem for benching Windmill under
realistic multi-node load, with a per-bench measurement pipeline and a
dashboard renderer that consolidates throughput, queue depth, per-node CPU,
PG latency/conns, OOM events, and per-node CPU-util-vs-oversaturation into
one SVG report.

Sim infrastructure (sim/):
- k8s_provisioner: minikube up + heterogeneous node sizing from topology JSON
- helm_deploy: helm install Windmill with smoke.yaml + local.yaml overlays
- image_cache: pre-load required images so bench bringup is offline-safe
- toxiproxy_k8s: per-node toxiproxy DaemonSet for cross-node latency injection
- cpu_sampler_k8s: privileged DS reading per-cgroup cpu.stat at 10Hz, dual-
  writes to stdout AND a host-mounted log file (/var/log/wm-sim-cpu-sampler/
  sampler.tsv) so heavy benches no longer lose early samples to kubelet log
  rotation
- pg_logging: ALTER SYSTEM + SIGHUP to enable verbose PG logging without restart
- pgbadger: post-bench PG log analysis HTML report
- readiness: pre-bench cluster health check (samplers stable ≥30s, workers
  ready, PG responsive, queue empty, **deploy.status rollout-complete**) —
  the rollout-complete check catches mid-rolling-update fires that previously
  starved m04's sampler under cgroup_mutex contention

Per-bench JSONL pollers, started/finalized alongside the bench loop:
- pod_timeline: 1Hz workers-per-node Ready counts (used for the workers panel)
- oom_poller: live OOM event capture (kernel + kubelet evictions + cgroup)
- pg_latency_poller: 4Hz psql \\timing on SELECT 1 vs kubectl-exec roundtrip
- pg_conn_poller: 1Hz pg_stat_activity by state (active/idle/idle_in_xact)
- node_load_poller: 2Hz /proc/loadavg + /proc/stat procs_running per node

Dashboard renderer (sim/render_report.ts + graph.ts):
- Util group: one panel per node with translucent orange oversaturation area
  BEHIND solid blue CPU-util area, 100% reference line, phase-boundary verticals.
  cols:2 grid wraps after 2 panels per row.
- PG node tinted with [PG] flag in legend across the dashboard.
- Phase-boundary verticals + push-window shaded zones layered consistently.
- All x-axes switched from wall-clock HH:MM to relative seconds-from-bench-
  start. Shared origin sourced from meta.json's bench_start_ms so 0s on every
  panel = the same wall-clock moment (previously each chart picked its own
  earliest sample as origin, causing drift between panels).

Oversaturation metric, with explicit fallback:
- Primary: (procs_running - ncpu) / ncpu × 100 — true CPU run-queue pressure.
- Fallback to load1 when procs_running is missing (older reports).
- load1 overcounted previously because it includes uninterruptible D-state
  procs (PG backends in disk I/O, cgroup_mutex waits), inflating "saturation"
  by 5-10x under load.
- Pure helper extracted to sim/util_metrics.ts; 8 unit tests cover the
  procs_running > load1 preference, the clamp-at-zero, invalid-ncpu cases.

Sampler reliability:
- HostPath log file in addition to stdout so the bench's scp-based collector
  bypasses kubelet log rotation entirely.
- main.ts truncates the host log file on every node before pushers start
  (parallel ssh, best-effort) so it doesn't grow unbounded across runs.
- Collector falls back to kubectl-logs when scp fails for any node.

Workloads (workloads/):
- io_4phase: four-phase IO step (idle → 2.5s → 500ms → 150ms jobs)
- io_150ms_flood / io_300ms_flood / io_1s_flood / io_2s_flood: single-phase
  flood configs to isolate the worker-host CFS context-switch storm vs PG
  contention regime
- burst, ops_day, cpu_*, etc. for other scenarios

Tests:
- sim/util_metrics_test.ts — 8 cases for computeOversatPct
- sim/util_panel_snapshot_test.ts — 5 assertions guarding util-panel SVG
  invariants (orange behind blue, 100% ref line, relative-time ticks NOT
  wall-clock, phase-boundary verticals, shared-origin override)

Helm values:
- sim/values/smoke.yaml — bench-tuned: workers w/ no CPU limit & low mem
  request, PG w/ 3-core request + wm-critical priorityClass + oomImmune +
  maxConnections, app w/ wm-critical + oomImmune + no resource limits.
- sim/values/local.example.yaml — template for the gitignored local.yaml
  that carries the EE license key.
- Depends on the wm-critical PriorityClass + oomImmune + maxConnections
  knobs landing in windmill-helm-charts (separate PR).

graph.ts additions:
- areaFills param: ordered list of per-kind translucent area fills drawn
  before lines, used by the util panel for orange-behind-blue layering
- lineColorOverrides: pin per-kind line colors so oversaturation reliably
  renders orange regardless of d3 ordinal-color insertion order
- highlightKindToken: substring-match flag for the PG-node tint in Node CPU
- xRelativeOriginMs: shared bench-start origin for the relative-time x-axis
- DataPointMulti is now exported for downstream tests

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-08 11:43:47 +02:00
..

Windmill benchmark sim

Provisioning layer on top of the existing benchmark harness. Reuses everything in benchmarks/ (workload kinds, runBenchmark, graph primitives) and adds: declarative topology, per-node resource limits, optional per-node DB latency, optional CPU pinning for benchmark accuracy, and per-worker metrics collection.

Three node modes

Every node in a topology runs in one of three modes:

Mode What it is How it's provisioned
native Windmill MODE=standalone — API server + workers in one process. The harness pushes jobs at its API. One docker (or podman) container per node, --cpus / --memory / optional --cpuset-cpus.
agent Windmill MODE=worker — worker-only, no API. Models a worker fleet that scales separately from the API. Same as native, minus the API port mapping. Needs a peer native node so the harness has somewhere to push.
k8s A k3d cluster with Windmill deployed via the helm chart (upstream or a locally-cloned copy). k3d cluster create provisions k3s in containers, then helm install deploys Windmill onto it. Planned for M5 — schema and design are in place.

The provisioner is a single class that handles all three. The container runtime is configurable via SIM_CONTAINER_CMD (defaults to docker, swap to podman if you prefer). For k8s mode, k3d and helm need to be on PATH.

Layered design

sim.ts                          (this layer)
  → loads suite + topology
  → SimProvisioner: PG + toxiproxy (if needed) + per-node provisioning
  → calls existing runBenchmark() against the chosen API node
  → Collector queries v2_job_completed / v2_job_queue from outside the cluster
  → writes JSON + markdown report
  → teardown
        │
        ▼
benchmarks/benchmark_oneoff.ts  (existing, unchanged)
  → login, createBenchScript, push jobs, poll for completion
        │
        ▼
Windmill instance(s)             (containers or k8s pods)
  → BENCHMARK_KIND env loads fixtures via backend bench.rs
  → workers run normal pull loop
        │
        ▼
Postgres container               (shared by every node)

Two config files per run

  1. Topology (e.g. topologies/single_node.json) — pure infra description.
  2. Suite (e.g. configs/baseline.json) — workload, optionally references a topology by relative path.

Suites without a topology reference are first-class and continue to run via benchmark_suite.ts against any existing Windmill at --host. Suites that declare a topology run via sim.ts (this directory), which provisions infra before delegating to the same underlying runner.

Example topologies

  • topologies/single_node.json — 1 PG + 1 native node. Baseline smoke test.
  • topologies/heterogeneous_latency.json — 1 native + 1 agent at 50ms DB RTT. The "does the pull race self-level?" experiment.
  • topologies/k8s_3node.json — k3d cluster shape: 3 worker pods. Helm chart selection is passed on the CLI (see below); the topology only describes the cluster.

k8s mode: chart selection via CLI

The topology specifies cluster shape (pods, workers_per_pod, cluster_agents). The helm chart itself is selected at the sim CLI:

# Upstream chart
deno run -A sim.ts -c configs/k8s.json \
  --helm-chart windmill/windmill \
  --helm-repo https://windmill-labs.github.io/windmill-helm-charts

# Locally-cloned chart (relative to cwd)
deno run -A sim.ts -c configs/k8s.json \
  --helm-chart ../windmill-helm-charts/charts/windmill

# Inline value overrides
deno run -A sim.ts -c configs/k8s.json --helm-chart windmill/windmill \
  --helm-set worker.replicaCount=5 \
  --helm-set windmill.numWorkers=2 \
  --helm-values-file ./extra-values.yaml

Prerequisites

  • A container runtime: docker (default) or podman (set SIM_CONTAINER_CMD=podman).
  • A Windmill image built with the benchmark Cargo feature, tagged windmill-bench:latest. Override the tag via SIM_WM_IMAGE. The image must honour DATABASE_URL, NUM_WORKERS, MODE (standalone or worker), BENCHMARK_KIND, and WORKER_TAGS.
  • Postgres 17 image (default postgres:17, override via SIM_PG_IMAGE).
  • For k8s mode only: k3d and helm on PATH.

Run

cd benchmarks/sim
deno run -A sim.ts -c configs/baseline.json

Useful flags:

  • --factor N — multiply every benchmark's jobs count by N.
  • --keep-infra — leave containers running after the run.
  • --snapshot-interval <ms> — queue-depth sampling cadence (default 1000).

Outputs

Each run writes two files in the working directory:

  • sim_results_<ts>.json — raw structured data: workload results, per-worker stats, queue snapshots, derived throughput.
  • sim_results_<ts>.md — human-readable summary: throughput table, per-worker table sorted by jobs handled, utilization spread headline, ASCII sparkline of queue depth over time.

The headline metric for the question this sim is built to answer ("are workers balanced?") is the utilization spread line. A small spread (e.g. ±5pp) means the queue is self-balancing. A large spread (e.g. close worker 95%, far worker 40%) is the smoking gun for uneven pull dynamics.

Resource validation

sim.ts refuses to run if the topology's total CPU or memory exceeds the host's available budget after the reserved buffer. host_resources is optional in the topology — when omitted, the validator falls back to the live host's logical core count and MemTotal. Declare it explicitly when you want the topology to be portable to a specific target host.

Set pin_cpus: true on the topology to assign each container a disjoint --cpuset-cpus range. This eliminates CFS time-slicing between containers entirely; useful when two nodes' throughput numbers must not be artificially correlated.

Tests

nix develop --command bash -c "cd benchmarks && deno test -A sim/*_test.ts"

38 unit tests cover the validator, config loader, proxy planner, schema modes, and report writer. The provisioner and Postgres collector are intentionally not unit-tested — they need real infrastructure to exercise.

Roadmap

  • M1 ✅: scaffold + single-node baseline + container provisioner + resource validator.
  • M2 ✅: multi-node + per-node toxiproxy latency.
  • M3 ✅: metrics collection from Postgres (per-worker utilization, queue depth over time).
  • M4 ✅: markdown + JSON report. SVG graphs deferred until needed.
  • M5: k3d-based k8s mode with helm install (upstream or local chart).
  • M6+: chaos timeline, assertion engine, resilience test corpus.