Skip to content

benchmarking: spread actors over several worker pools - #1760

Open
Chenyi Wang (chw120) wants to merge 1 commit into
agent-substrate:mainfrom
chw120:multi-pool-actor-placement
Open

Chenyi Wang (chw120) wants to merge 1 commit into
agent-substrate:mainfrom
chw120:multi-pool-actor-placement

Conversation

@chw120

Copy link
Copy Markdown
Collaborator

Motivation

Large-scale benchmarks that span multiple node pools / machine families (e.g. n4, c4, c3) require two layers of placement constraints:

  1. Kubernetes layer (spec.template.nodeSelector): each WorkerPool's worker pods must be scheduled onto the matching node pool.
  2. Actor placement layer (Actor.worker_selector): a suspended micro-VM memory snapshot captures the CPU features observed by the guest and cannot be resumed on an incompatible CPU model. While the Substrate scheduler already supports per-actor Actor.worker_selector, the benchmark harness previously only deployed a single WorkerPool and left Actor.worker_selector unset on every actor.

This PR updates both sides of the benchmark harness:

  • Workload deployment (deploy.sh): creates one WorkerPool per entry with metadata.labels.pool=<name> and optional spec.template.nodeSelector.
  • Load generator (boomer): assigns each actor a pool at creation (weighted by pool capacity) and populates Actor.worker_selector: {pool: <name>} so the actor stays within that pool across its entire createsuspendresume lifecycle.

Changes

  • internal/benchmarking/boomer/userclass/pools.go: Add PoolPicker to parse name:weight[,...] specs, draw a pool weighted by capacity (Pick()), and build Actor.worker_selector (SelectorFor() matching pool=<name>). All methods are nil-safe so single-pool runs incur no branching.
  • internal/benchmarking/boomer/glutton/{lifecycle,durdir}.go & cmd/benchmarking/boomer-worker/main.go: Add --worker-pools, draw one pool per actor when minted in startUser(), and attach Actor.worker_selector on CreateActor.
  • benchmarking/workloads/deploy.sh & manifests/workerpool.yaml.tmpl: Accept --worker-pools name:weight[:nodeSelectorKey=value], split --worker-count across pools proportionally by weight, and render one WorkerPool per entry with metadata.labels.pool=<name> and optional spec.template.nodeSelector.
  • benchmarking/deploy_locust.sh, benchmarking/locust/*, hack/install-ate.sh: Forward --worker-pools to both the workload deployer (full spec with nodeSelector) and the boomer workers (name:weight list) from a single flag so the two halves cannot drift.

Backwards Compatibility

When --worker-pools is omitted (default):

  • PoolPicker is nil and Actor.worker_selector remains unset (nil).
  • workloads/deploy.sh deploys a single WorkerPool named benchmark-ateom with replicas: ${WORKER_COUNT} and no nodeSelector.
  • All existing single-pool benchmarks and CI jobs behave identically to before.

Testing

  • Unit tests for pool parsing, validation, nil safety, and weighted distribution (internal/benchmarking/boomer/userclass/pools_test.go).

  • Wiring tests asserting the CreateActorRequest protobuf payloads for glutton and durdir (internal/benchmarking/boomer/glutton/pools_wiring_test.go).

  • Validated rendered single-pool and multi-pool manifests against the WorkerPool CRD with kubectl apply --validate=strict.

  • Tests pass

  • Appropriate changes to documentation are included in the PR

The benchmark stack creates a single WorkerPool, so every actor a run makes
is interchangeable with every other and the scheduler may place it on any
worker. That blocks two things a scale run needs.

A run cannot compare machine types. Measuring n4 against c4 means two runs,
which measures the machine types and the day's cluster weather together.

More importantly, an actor cannot safely move between machine types, and
nothing today stops it. Suspending an actor writes its guest memory to a
snapshot; resuming restores that memory on whichever worker the scheduler
picks. The snapshot carries the CPU features the guest observed, and no layer
masks them to a common baseline, so an actor that resumes on a different CPU
model can fail to restore. The single pool hides this only because every
worker in it is identical -- the moment a run spans machine types it is a live
bug, and the failure surfaces as an unexplained actor crash rather than as a
placement error.

So make the pool a first-class dimension of a run, and pin each actor to one:

  benchmarking/deploy_locust.sh --deploy --worker-count 100 \
    --worker-pools 'n4:1:cloud.google.com/machine-family=n4,c4:1:cloud.google.com/machine-family=c4'

That creates one WorkerPool per entry, labelled pool=<name> and optionally
constrained to a node shape, splits --worker-count between them by weight, and
sends each actor to one pool for its whole life.

The pinning rides on Actor.worker_selector, which the API already has and the
scheduler already ANDs with the ActorTemplate's own workerSelector. So the
template keeps bounding actors to the benchmark pools and the new selector only
narrows an actor to one of them; no scheduler or API change is needed. A
WorkerPool's labels propagate to its workers, which is what the scheduler
matches.

An actor draws its pool once, at creation, weighted by the pool weights -- per
actor rather than per VU, so a VU running several actors still spreads them.
Weights are usually the pools' vCPU counts, so each pool gets the share of
actors it can hold.

A pool name is a class of interchangeable workers rather than the name of one
WorkerPool: it is matched as a label value, so several WorkerPools may carry
it, and an actor assigned to it may run on any worker in any of them. This
script gives each entry its own value because each entry is a distinct node
shape, but a deployment that splits one machine type across several pools for
capacity can share a value, and pinning stays exactly as strict as correctness
needs. The key is `pool` and not something like `cpu-class` because CPU
compatibility is only today's reason to separate workers; a label key flag
renames it for a deployment that separates them on some other axis.

Everything defaults to the previous behaviour: no --worker-pools means one pool
named benchmark-ateom, no per-actor selector, and the same deployment name the
existing tooling waits on.

The one hazard is the two halves disagreeing: the pools that exist and the
pools the actors ask for are configured by different scripts, and an actor
naming a pool that was never created never schedules. deploy_locust.sh and
install-ate.sh therefore take one flag and forward it to both halves, dropping
the node selector on the way to the boomer workers, which have no use for it.

Testing: new table tests cover the pool parsing, the weighted draw and the
selector it produces, and new tests against the fake control client assert the
wiring end to end -- that a created actor carries the expected worker_selector,
that it carries none when no pools are configured, that an actor's pool never
changes across creates, and that every actor a VU starts lands on a configured
pool with all pools represented. Those tests were mutation-checked: removing
the selector assignment, redrawing the pool per create, and drawing once per VU
instead of per actor each make a test fail. The shell splitting and manifest
rendering were exercised outside a cluster over the weighted, rounding,
one-worker-minimum and malformed-spec cases.

Not yet run against a live multi-pool cluster. Two things still need verifying
there: that an actor is really placed on a worker of its pool, and that a
suspend/resume cycle keeps it there.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants