Our previous post showed how to enable duration-based shard balancing in Playwright, cutting WordPress Gutenberg’s test runtime from 35 to 23 minutes.
This post introduces SALT (Setup-Aware LPT) — the shard balancing algorithm behind that result.
Table of contents
Open Table of contents
The problem
Say we have a test suite with known (predicted) test durations, and CI machines — shards. We want to split the tests across shards so that the whole run finishes as early as possible.
The finish time is determined by the slowest shard. Scheduling theory calls it the makespan: if is the set of tests assigned to the -th shard, and is the predicted duration of a test , then
Strictly speaking, this formula models each shard as one serial lane, when in practice Playwright might execute tests in parallel using multiple workers.
The assumption is that every shard has comparable parallelism, so equalizing their work is still a useful approximation for equalizing their finish times.
Minimizing the makespan is the classic multiway number partitioning problem, and it is NP-hard. However, there are heuristics that get close enough for real-world suites.
Naïve approach and its challenges
The typical heuristic is longest-processing-time-first scheduling, or LPT:
- Sort tests by predicted duration, longest first.
- Assign each test to the currently least-loaded shard.
The algorithm costs , and it was proved that the makespan it produces is never worse than times the optimal makespan. For example, on two shards it equals , meaning LPT produces a makespan at most 17% above the best possible split, no matter how adversarial the test durations are.
This is a good starting point, but applying this algorithm to Playwright sharding has a few challenges.
Challenge 1: test groups
Playwright’s native --shard algorithm allocates work per file by default. A file’s tests run in order, and sometimes unintentionally share state: a signed-in session or a file on disk.
When designing a custom balancer, we should follow Playwright balancing rules as closely as possible, since test authors end up relying on them without knowing. Breaking these rules might result in confusing failures caused by test interference.
We initially tried balancing tests, but as we tried to apply it to WordPress’s Gutenberg, we got buried under a huge pile of test interference problems.
So instead of balancing tests, we balance test groups — the smallest sets of tests that must stay together on one shard.
Here’s how Playwright defines them:
- with
fullyParallel: true, every test is its own test group; - without it, a whole spec file is one test group;
- a
test.describe.serial(or.configure({ mode: 'serial' })) suite is always one atomic group, even inside a fully-parallel project; - a
test.describe.parallelblock is shardable per-test, even in a non-parallel project.
Challenge 2: project dependencies
Another important factor is Playwright’s project graph. Projects can declare dependencies — often used for setup work such as preparing a database or a signed-in session — and teardown projects. Both can take significant time. When allocating a test to a shard, its price depends on the tests previously allocated to that shard: a shared dependency only needs to run once there.
With dependencies in play, only the tests of the leaf projects — the ones no other project depends on — can be sharded at all. A dependency project like setup is never split: whichever shard needs it runs it in its entirety, before any of the dependent tests.
Definitions
Before describing the algorithm that can deal with Playwright tests, here’s the required vocabulary (source):
- A test group is an indivisible unit of work, as defined above. Each group knows its
work(summed predicted durations of every test) and its dependency closure — every dependency and teardown project it transitively requires. - A family is the set of all test groups with the same dependency closure.
- For each family , we define — the combined price of every project in ‘s dependency closure — and — the combined price of all of ‘s test groups.
- For each shard , we’ll write for the work already allocated to that shard.
- is the part of family ‘s setup that has not already run on shard . A fresh shard has ; a shard that already runs the whole dependency closure has .
- For any set of shards , is their combined missing setup for family .
- At any point in the algorithm, is the sum of all current shard loads, all not-yet-allocated family work, and one copy of every required dependency that has not run on any shard yet.
- is the combined price of the dependencies in family ‘s closure that have not run on any current shard.
Algorithm
We call the algorithm SALT — Setup-Aware LPT. It is a heuristic built around one strategic idea: allocate families with the heaviest setup first, while there is still freedom to place them well, and let the light, zero-setup families pad the remainders at the end.
So we sort the families descending by their setup price, and then place each family in three steps:
- decide — how many shards the family should span;
- select host shards one at a time;
- run plain LPT to spread the family’s test groups across those shards.
All three steps make their choices in the same way: estimate the makespan each option would lead to, and pick the option with the lowest estimate.
Step 1: how many shards?
We have a family and want to choose a promising number of shards to spread it across. Spanning more shards divides the family’s work but multiplies its setup cost. The candidates range from to , where is the number of test groups in the family: a family cannot occupy more shards than it has indivisible groups.
For every candidate value of , we will estimate a makespan lower bound and then pick the that minimizes it.
For a given family and candidate , consider the following two different makespan lower bounds:
- A local lower bound. Some shard will carry at least a slice of this family plus its setup — the makespan can’t beat that:
-
A global lower bound. The makespan can’t beat perfect balancing of everything. Dividing perfectly across shards gives us this bound — but first we have to account for the extra setup that spreading across hosts may require.
The price is shard-dependent. Sort from lowest to highest:
If must span shards, the cheapest possible hosts are the first in that ordering. Call this optimistic host set . Its combined missing setup is .
already reserves one copy of . Those dependencies also appear in every , so the first reserved copy would be counted twice unless we subtract it:
Putting the two estimates together, side by side:
Notice that the local bound falls as grows, while the global one rises. The best estimate is somewhere in-between: we try every admissible and keep the one with the lowest estimate.
Ties are broken toward the wider : spreading over more shards leaves the light, zero-setup families more room to pad the remainders later.
Step 2: which shards?
At this point, family and its span are fixed. What remains is to select the exact host shards.
We maintain a running set of already-selected shards. It starts empty. While , we score every shard outside , add the lowest-scoring candidate to , and repeat.
In every scoring round, the family , the span , and the already-selected set are fixed; what varies is the candidate shard . We will estimate a makespan projection and then pick the that minimizes it.
For a given candidate shard , consider the following two makespan estimates:
-
A load-aware local projection. The family’s work will be spread across selected hosts, so is the average share. We provisionally give that share to candidate and project its finishing load:
ImportantThe hat over marks this as an estimate, not a genuine bound. Some selected host must receive at least the average share, but there is no guarantee it is candidate — it may receive less. We still use the average because it scales the backpressure with the family’s size: a large family should be more reluctant than a small one to choose an already-loaded shard.
-
A global lower bound. If we add candidate to , we still need
more hosts. Consider all shards outside other than , order them by , and take as many as we still need. Call this set : the optimistic co-hosts for candidate . It is optimistic because it ignores their current loads and picks only the cheapest possible missing setup.
The global lower bound now has a direct interpretation: the already-selected hosts, candidate , and its optimistic co-hosts together form the complete hypothetical host set . Start with , add its combined missing setup, and — as in Step 1 — subtract because already contains the first unseen copy:
Putting the two estimates together, candidate ‘s score is the larger of the two:
After every candidate outside is scored, the one with the lowest estimated makespan joins . We then score the remaining candidates again with the new , and repeat until .
Step 3: LPT, at last
Within the selected shards, the family’s test groups are placed with plain LPT: heaviest test group first, least-loaded shard wins. The only refinement is what “load” means when the shards are compared.
For every test group , each candidate shard bids the price of accepting it:
and goes to the lowest bidder.
Every other shard’s load stays fixed during this choice, so minimizing the winning bid also minimizes the immediate makespan.
The term matters only for a shard’s first test group of this family: such a shard has to bring the family’s setup along, so its bid is higher. Once that first test group lands, the setup price folds into the shard’s load and the shard’s dependency set covers ‘s closure, so drops to zero: every following test group of this family is charged its own work only.
Step 3 does not reserve one test group for each of the selected shards. Every group independently chooses the lowest bidder, so a selected shard may receive no family work at all. In that sense, is the intended span rather than a guarantee: the makespan calculations in Steps 1 and 2 drive the selection, but do not promise that the final allocation will use exactly shards or attain the estimated makespan.
Implementation and runtime complexity
SALT is implemented in @flakiness/playwright:
- The source code is a few hundred well-commented lines.
- We have a thorough test suite that pins down every behavior described in this post, and doubles as a catalog of examples.
Let’s discuss the runtime of the three balancing steps in this implementation, after tests have been grouped into families. Let be the number of tests, the number of projects, and the number of shards.
- Steps 1 and 2 are independent of . They operate only on families, dependency closures, and shards. In this implementation they cost : cubic terms in the two small inputs, plus the logarithmic cost of sorting shard candidates.
- Step 3 is the only test-bound step. Sorting the test groups and placing each one costs in the current straightforward implementation.
For the input sizes this implementation targets, and are very small compared with . The terms from Steps 1 and 2 do not include and are negligible at that scale, so the practical runtime estimate is governed by the Step 3 terms:
The current implementation balances code simplicity with efficiency: the current runtime complexity is adequate to the input sizes it deals with.
Caching missing setup and using a priority queue could reduce Step 3’s -dependent cost to
Example
Let’s make this concrete with a tiny test suite. Each test declares its (predicted) duration right in the title:
import { defineConfig } from "@playwright/test";
export default defineConfig({
fullyParallel: true,
projects: [
{ name: "setup", testMatch: "setup.spec.ts" },
{ name: "e2e", testMatch: "e2e.spec.ts", dependencies: ["setup"] },
{ name: "unit", testMatch: "unit.spec.ts" },
],
});
import { test } from "@playwright/test";
test("seed the database (30s)", async () => {});
import { test } from "@playwright/test";
test.describe.serial("checkout", () => {
test("add to cart (15s)", async () => {});
test("pay (5s)", async () => {});
});
test("search (10s)", async () => {});
test("profile (10s)", async () => {});
import { test } from "@playwright/test";
for (let i = 1; i <= 6; ++i) test(`unit ${i} (5s)`, async () => {});
First, the test groups. The serial checkout suite is indivisible and moves as one 20-second unit; every other test is free to travel on its own:
| test group | tests | work | dependency closure |
|---|---|---|---|
checkout | add to cart, pay | 20s | { setup } |
search | search | 10s | { setup } |
profile | profile | 10s | { setup } |
unit 1 … unit 6 | one test each | 5s each | ∅ |
Notice that seed the database didn’t make it into the table: only leaf tests are shardable, as discussed above. The setup project appears solely as a 30-second price inside the dependency closures.
Now, the families: bucketing the test groups by their dependency closure yields two of them.
| family | test groups | ||
|---|---|---|---|
checkout, search, profile | 30s | 20 + 10 + 10 = 40s | |
unit 1 … unit 6 | 0s | 6 × 5 = 30s |
Families follow closures, not projects: if a firefox project also depended on setup, its test groups would join .
Time to run the algorithm — let’s balance this suite onto shards.
goes first: it has the heaviest setup. At this point both shards are fresh, so and . Step 1 tries both candidate spans, with (all the work, , plus the setup project counted once):
- : , so ;
- : , so .
wins: the estimates claim that paying the 30-second setup on both shards is worth it. Step 2 is trivial here — with both are selected anyway. Step 3 places the test groups with LPT: checkout (20s) lands on shard 1, which now also owes the setup — 50s total; search and profile (10s each) fill shard 2 to the same .
goes next, and its Step 1 is a perfect tie: both and estimate a 65-second makespan. Ties resolve toward the wider span, so the six unit tests pad both shards evenly, 15 seconds each.
The final plan: 65 seconds on each shard.
The algorithm chose to run the setup twice, because splitting ‘s work still won. And this is actually optimal: any plan that keeps on a single shard loads it with , while any plan that splits pays for the second setup — of total work, or 65s per shard at the very best.
Wrapping up
SALT has already proved itself: it is used in the real world by WordPress’s Gutenberg project to cut its test run from 35 to 23 minutes.
However, SALT is a heuristic, and its makespan estimates should be taken with a grain of salt :) Heuristics are best judged by their counterexamples, so if your suite balances worse than you’d expect — we’d love to see it: file an issue.
Happy sharding!
Flakiness.io Team