Go back

SALT: Efficient Shard Balancing for Playwright

Andrey Lushnikov
Summary

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 NN 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 shardi\textsf{shard}_i is the set of tests assigned to the ii-th shard, and duration(t)\textsf{duration}(t) is the predicted duration of a test tt, then

makespan=max1iNtshardiduration(t)\textsf{makespan} = \operatorname*{\textsf{max}}_{1 \le i \le N} \sum_{t \in \textsf{shard}_i} \textsf{duration}(t)
Note

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:

  1. Sort tests by predicted duration, longest first.
  2. Assign each test to the currently least-loaded shard.

The algorithm costs O(nlogn)O(n \log n), and it was proved that the makespan it produces is never worse than 4313N\frac{4}{3} - \frac{1}{3N} times the optimal makespan. For example, on two shards it equals 76\frac{7}{6}, 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.

Learned from experience

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:

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.

Important

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):

Algorithm

We call the algorithm SALTSetup-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:

  1. decide KK — how many shards the family should span;
  2. select KK host shards one at a time;
  3. run plain LPT to spread the family’s test groups across those KK 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 FF and want to choose a promising number KK of shards to spread it across. Spanning more shards divides the family’s work but multiplies its setup cost. The candidates range from 11 to min(N,F)\min(N, |F|), where F|F| 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 KK, we will estimate a makespan lower bound and then pick the KK that minimizes it.

For a given family FF and candidate KK, consider the following two different makespan lower bounds:

  1. A local lower bound. Some shard will carry at least a 1/K1/K slice of this family plus its setup — the makespan can’t beat that:
makespanwork(F)K+setup(F)\textsf{makespan} \ge \frac{\textsf{work}(F)}{K} + \textsf{setup}(F)
  1. A global lower bound. The makespan can’t beat perfect balancing of everything. Dividing total\textsf{total} perfectly across NN shards gives us this bound — but first we have to account for the extra setup that spreading FF across KK hosts may require.

    The price is shard-dependent. Sort missingi(F)\textsf{missing}_i(F) from lowest to highest:

    missing(1)(F)missing(2)(F)missing(N)(F)\textsf{missing}_{(1)}(F) \le \textsf{missing}_{(2)}(F) \le \cdots \le \textsf{missing}_{(N)}(F)

    If FF must span KK shards, the cheapest possible hosts are the first KK in that ordering. Call this optimistic host set MKM_K. Its combined missing setup is extraSetup(MK,F)\textsf{extraSetup}(M_K,F).

    total\textsf{total} already reserves one copy of unseenSetup(F)\textsf{unseenSetup}(F). Those dependencies also appear in every missingi(F)\textsf{missing}_i(F), so the first reserved copy would be counted twice unless we subtract it:

    makespantotal+extraSetup(MK,F)unseenSetup(F)N\textsf{makespan} \ge \frac{\textsf{total} + \textsf{extraSetup}(M_K,F) - \textsf{unseenSetup}(F)}{N}

Putting the two estimates together, side by side:

makespan    max{  work(F)K+setup(F)  total+extraSetup(MK,F)unseenSetup(F)N\textsf{makespan} \;\ge\; \operatorname{\textsf{max}} \begin{cases} \;\dfrac{\textsf{work}(F)}{K} + \textsf{setup}(F) \\[2.5ex] \;\dfrac{\textsf{total} + \textsf{extraSetup}(M_K,F) - \textsf{unseenSetup}(F)}{N} \end{cases}

Notice that the local bound falls as KK grows, while the global one rises. The best estimate is somewhere in-between: we try every admissible KK and keep the one with the lowest estimate.

Important

Ties are broken toward the wider KK: 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 FF and its span KK are fixed. What remains is to select the exact KK host shards.

We maintain a running set SS of already-selected shards. It starts empty. While S<K|S| < K, we score every shard ii outside SS, add the lowest-scoring candidate to SS, and repeat.

In every scoring round, the family FF, the span KK, and the already-selected set SS are fixed; what varies is the candidate shard ii. We will estimate a makespan projection and then pick the ii that minimizes it.

For a given candidate shard ii, consider the following two makespan estimates:

  1. A load-aware local projection. The family’s work will be spread across KK selected hosts, so work(F)/K\textsf{work}(F)/K is the average share. We provisionally give that share to candidate ii and project its finishing load:

    makespan^ilocal(F)=loadi+missingi(F)+work(F)K\widehat{\textsf{makespan}}^{\,\textsf{local}}_i(F) = \textsf{load}_i + \textsf{missing}_i(F) + \frac{\textsf{work}(F)}{K}
    Important

    The hat over makespan\textsf{makespan} 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 ii — 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.

  2. A global lower bound. If we add candidate ii to SS, we still need

    KS1K - |S| - 1

    more hosts. Consider all shards outside SS other than ii, order them by missingj(F)\textsf{missing}_j(F), and take as many as we still need. Call this set MM: the optimistic co-hosts for candidate ii. 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 ii, and its optimistic co-hosts together form the complete hypothetical host set S{i}MS \cup \{i\} \cup M. Start with total\textsf{total}, add its combined missing setup, and — as in Step 1 — subtract unseenSetup(F)\textsf{unseenSetup}(F) because total\textsf{total} already contains the first unseen copy:

    makespantotal+extraSetup(S{i}M, F)unseenSetup(F)N\textsf{makespan} \ge \frac{ \textsf{total} + \textsf{extraSetup}(S \cup \{i\} \cup M,\ F) - \textsf{unseenSetup}(F) }{N}

Putting the two estimates together, candidate ii‘s score is the larger of the two:

makespan^i(S,F)=max{  loadi+missingi(F)+work(F)K  total+extraSetup(S{i}M, F)unseenSetup(F)N\widehat{\textsf{makespan}}_i(S,F) = \operatorname{\textsf{max}} \begin{cases} \;\textsf{load}_i + \textsf{missing}_i(F) + \dfrac{\textsf{work}(F)}{K} \\[2.5ex] \;\dfrac{\textsf{total} + \textsf{extraSetup}(S \cup \{i\} \cup M,\ F) - \textsf{unseenSetup}(F)}{N} \end{cases}

After every candidate outside SS is scored, the one with the lowest estimated makespan joins SS. We then score the remaining candidates again with the new SS, and repeat until S=K|S| = K.

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 gg, each candidate shard bids the price of accepting it:

loadi+missingi(F)+work(g)\textsf{load}_i + \textsf{missing}_i(F) + \textsf{work}(g)

and gg 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 missingi(F)\textsf{missing}_i(F) 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 FF‘s closure, so missingi(F)\textsf{missing}_i(F) drops to zero: every following test group of this family is charged its own work only.

Selected does not necessarily mean used

Step 3 does not reserve one test group for each of the KK selected shards. Every group independently chooses the lowest bidder, so a selected shard may receive no family work at all. In that sense, KK 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 KK shards or attain the estimated makespan.

Implementation and runtime complexity

SALT is implemented in @flakiness/playwright:

Let’s discuss the runtime of the three balancing steps in this implementation, after tests have been grouped into families. Let TT be the number of tests, PP the number of projects, and NN the number of shards.

For the input sizes this implementation targets, NN and PP are very small compared with TT. The terms from Steps 1 and 2 do not include TT and are negligible at that scale, so the practical runtime estimate is governed by the Step 3 terms:

O(TlogT+TNP+TNlogN)O(T \log T + TNP + TN \log N)
Important

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 TT-dependent cost to

O(TlogT+TlogN)O(T \log T + T \log N)

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 grouptestsworkdependency closure
checkoutadd to cart, pay20s{ setup }
searchsearch10s{ setup }
profileprofile10s{ setup }
unit 1unit 6one test each5s each
Note

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.

familytest groupssetup(F)\textsf{setup}(F)work(F)\textsf{work}(F)
F1F_1checkout, search, profile30s20 + 10 + 10 = 40s
F2F_2unit 1unit 60s6 × 5 = 30s
Note

Families follow closures, not projects: if a firefox project also depended on setup, its test groups would join F1F_1.

Time to run the algorithm — let’s balance this suite onto N=2N = 2 shards.

F1F_1 goes first: it has the heaviest setup. At this point both shards are fresh, so missing(1)(F1)=missing(2)(F1)=30\textsf{missing}_{(1)}(F_1) = \textsf{missing}_{(2)}(F_1) = 30 and unseenSetup(F1)=30\textsf{unseenSetup}(F_1) = 30. Step 1 tries both candidate spans, with total=100\textsf{total} = 100 (all the work, 40+3040 + 30, plus the setup project counted once):

K=2K = 2 wins: the estimates claim that paying the 30-second setup on both shards is worth it. Step 2 is trivial here — with K=N=2K = N = 2 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 30+20=50s30 + 20 = 50\text{s}.

F2F_2 goes next, and its Step 1 is a perfect tie: both K=1K = 1 and K=2K = 2 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 F1F_1‘s work still won. And this is actually optimal: any plan that keeps F1F_1 on a single shard loads it with 30+40=70s30 + 40 = 70\text{s}, while any plan that splits F1F_1 pays for the second setup — 130s130\text{s} 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


Share this post:

Next Post
Balancing Playwright Test Shards