Skip to content

ci(polars): parallelize the polars lane so the coverage cell stops racing its 10-minute timeout - #1814

Merged
lmeyerov merged 2 commits into
masterfrom
ci/polars-lane-xdist
Jul 28, 2026
Merged

ci(polars): parallelize the polars lane so the coverage cell stops racing its 10-minute timeout#1814
lmeyerov merged 2 commits into
masterfrom
ci/polars-lane-xdist

Conversation

@lmeyerov

@lmeyerov lmeyerov commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

The problem

test-polars (3.12) is the only matrix cell that runs the coverage-instrumented pass and the per-file coverage audit. It has already been cancelled twice at its 10-minute budget (615s, 611s) with zero test failures — pure budget exhaustion, and the same commit passes or times out depending on which runner it draws.

Measured on the master CI run that produced this PR's base commit (run 30300891340, head 233b64c8), the cell is 501s, of which 484s is the single bin/test-polars.sh step — 97% of the cell. So the lane's runtime is the script's runtime; everything else is noise.

timeout-minutes lives in .github/workflows/ci.yml, which cannot be pushed with the current token. This PR raises no budget — it makes the work smaller, entirely inside bin/test-polars.sh.

The fix

The main pytest phase now runs under pytest-xdist: -n auto --maxprocesses 4 --dist load.

No dependency changes: pytest-xdist is already in the [test] extra (setup.py:44), and test-gfql-core (3.12) already runs -n auto under --cov and feeds the same changed-line-coverage combine step — so coverage+xdist is an established combination in this repo, not a new bet. It was still verified from scratch here (below), because "established elsewhere" is not evidence about this lane.

Worker count, deliberately: auto, capped at 4.

  • GitHub-hosted ubuntu-latest is a 4-vCPU runner, so auto (= os.cpu_count()) resolves to 4 there — the right number, discovered rather than hardcoded.
  • auto also scales down: on a 2-vCPU runner it picks 2, where a fixed -n 4 risks being slower than serial. A 2-worker run was measured (below) and is still a 1.67x win, so the small-runner case degrades gracefully instead of regressing.
  • --maxprocesses 4 caps the other direction, so a 24-core dev box does not fan out 24 polars processes whose own thread pools then fight each other.

POLARS_XDIST=0 restores the serial path (used for the A/B below); POLARS_XDIST_DIST picks another distribution mode.

Measured ON CI — this branch's own runs

This is the number that decides the question, and it is smaller than the local speedup. Reported as measured, over two runs of this branch (30322706850, 30323434758) against the master run of the base commit (30300891340). Cell time first, bin/test-polars.sh step time in parentheses.

cell master PR run 1 PR run 2 script speedup
test-polars (3.12) — coverage + audit 501s (484s) 322s (297s) 273s (256s) 1.63x – 1.89x
test-polars (3.9) 247s (222s) 139s (115s) 146s (115s) 1.93x
test-polars (3.10) 266s (243s) 140s (120s) 136s (119s) 2.03x
test-polars (3.11) 298s (278s) 145s (124s) 187s (170s) 1.64x – 2.24x
test-polars (3.13) 340s (318s) 139s (121s) 125s (106s) 2.63x – 3.00x
test-polars (3.14) 314s (299s) 186s (172s) 194s (173s) 1.73x

All six cells green in both runs. Runner-to-runner spread is real (it is the same spread that made the timeouts intermittent), so the slowest observation is the one used for the #1805 projection below.

Why the coverage cell gains less than its siblings (~1.6–1.9x vs ~2x), and less than local (2.98x): coverage tracing is per-worker CPU cost that does not parallelize away; the second --cov-append phase stays serial (~8s locally, more under CI's coverage tracing); and four interpreter startups + four import graphistry are fixed overhead. The local box also has faster cores than a GitHub runner. Quote 1.63–1.89x, not 2.98x.

Local A/B (context only, not the CI claim)

py3.12 / polars 1.43.1 / pandas 3.0.5, POLARS_COV=1, whole-script wall clock:

config wall vs serial
serial (POLARS_XDIST=0), pinned to 4 CPUs 325.65s 1.00x
-n auto --maxprocesses 4 --dist load, pinned to 4 CPUs 109.34s 2.98x
serial, unpinned (24 cores) 354.20s 1.00x
4 workers, unpinned 111.83s / 129.17s (two runs) 3.2x / 2.7x
POLARS_XDIST=2, unpinned 211.85s 1.67x
--dist loadfile, 4 workers, unpinned 260.46s 1.36x

Why --dist load and not loadfile: aggregating per-test durations, test_engine_polars_chain.py is 307.9s of the lane's 447.1s (69%). File-level distribution is bounded by the largest module, so loadfile cannot exceed ~1.4x — measured at 1.36x.

Does #1805 now fit inside the budget, with margin? Yes.

#1805's 3.12 cell measured 9m57s = 597s against a 600s timeout — green by 3 seconds. Its script step is therefore ~580s. Applying the CI-measured 1.63x:

script step cell total 10-min budget
master today 484s 501s (8m21s) 84% used
master + this PR 297s / 256s (measured, 2 runs) 322s / 273s (5m22s / 4m33s) 54% / 46% used
#1805 today ~580s 597s (9m57s) 99.5% used — green by 3s
#1805 + this PR ~356s (projected at the SLOWER 1.63x) ~373s (~6m13s) ~62% used

That takes #1805 from 3 seconds of headroom to roughly 3.8 minutes. The #1805 row is the only projected number here; every other figure is measured. Even if #1805's added tests parallelize worse than the current set and it only realizes 1.3x, its cell lands near 465s — still inside 600s.

Verification — what was proven, not assumed

1. Coverage + xdist actually work here. Full lane run with POLARS_COV=1 under 4 workers, then coverage json on both data files and a per-file executed-line-set diff against the serial run:

  • serial: 28,478 covered lines / 57,249 statements
  • parallel: 28,480 covered lines / 57,249 statements
  • zero files dropped, zero lines lost. The parallel set is a strict superset; the only difference is 2 lines in graphistry/_version.py (versioneer's git fallback, which workers reach and the parent does not). Nothing under graphistry/compute differs at all.

2. The per-file coverage audit still passes, and agrees. bin/coverage_audit.py --profile gfql-polars was run against the serial data file and against each parallel data file. All exit 0, and the emitted gfql-polars-coverage-audit.md is byte-identical apart from its Generated: timestamp line for the 4-worker, 2-worker and 4-CPU-pinned parallel runs. Additionally, replaying what changed-line-coverage itself does — coverage combine over the artifact, then coverage report — gives identical totals from the serial and parallel artifacts (57,249 statements, 28,769 missing, 50%). The audit step also ran green on CI in this PR's own 3.12 cell.

3. The second (--cov-append) invocation still appends correctly. Isolated: ran phase 1 alone under xdist into a fresh data file (26,689 lines), copied it, then ran phase 2 with --cov-append into the copy. Result: +1,791 lines added, 0 lines lost, and the final line sets are identical to the full-script parallel run in every file. Phase 2 is deliberately left serial — it is one module and ~8s, so worker startup would eat the gain, and loadfile could not split it anyway.

4. Identical pass/skip/xfail sets — node ids, not counts. Both -vv streams (serial and xdist print in different formats) were parsed into {nodeid: outcome} maps, anchored on the progress marker so parametrized ids containing spaces are not truncated. Every parallel configuration gives exactly the same 2417 node ids with the same outcomes (2404 PASSED, 13 SKIPPED): 0 only-in-serial, 0 only-in-parallel, 0 outcome mismatches — for 4-worker load, 2-worker load, 4-worker loadfile, and the 4-CPU-pinned run. The 13 skips are the same cuDF/GPU-gated node ids in every run.

5. Order-dependent tests. Audited and none found: the lane's modules carry no autouse fixtures, no module-level mutable state, no global cache/counter assertions, and no environment or filesystem mutation. The only scope="module" fixtures (index/test_index.py) are pure deterministic data builders (seeded default_rng) — worker-local recomputation is correct, just repeated. The empirical check backs the audit: four different distributions/worker counts, including one that keeps modules whole (loadfile) and two that split them (load), all produce identical outcomes, and all six CI cells passed in both CI runs. POLARS_XDIST_DIST=loadfile (or loadscope) is the one-env-var escape hatch if a future test does become order-dependent.

6. Worker count — see "Worker count, deliberately" above; auto matches the 4-vCPU runner, --maxprocesses caps oversubscription, and the 2-worker measurement (1.67x) shows the small-runner case is still a win rather than a regression.

Relationship to #1805

Mechanically separate on purpose. #1805 also touches bin/test-polars.sh, but only inside the POLARS_TEST_FILES array (widening the file list) and in the comment directly above it. This PR touches only the block after the array's closing paren, plus CHANGELOG.md. Non-overlapping hunks with unchanged lines between them, so either can rebase onto the other cleanly.

Also checked for a semantic collision: #1805's test_polars_lane_completeness.py parses the lane script with re.search(r"^POLARS_TEST_FILES=\((.*?)^\)", ...). That anchored, non-greedy match is unaffected by the new XDIST_ARGS=( ... ) array, which is declared after it.

Suggested landing order: this PR, then #1805. That inverts the ordering constraint recorded for #1805 — it no longer has to wait on a ci.yml job split that only a human with workflow scope can push.

What this does not do

It does not remove the argument for the ci.yml job split (a shared timeout-minutes across six cells with very different work is still a blunt instrument, and 3.12 doing strictly more work than its siblings is still a design smell). It removes the urgency: the lane goes from 84–99% of its budget to ~54%, so the split can land on its own schedule rather than as a prerequisite.


🤖 Generated with Claude Code

https://claude.ai/code/session_015YsqAZQLbqjSDrYSFz2GoB

lmeyerov and others added 2 commits July 27, 2026 19:59
…cing its timeout

`test-polars (3.12)` is the only cell that runs the coverage-instrumented pass plus the
per-file coverage audit, and it had already been cancelled twice at its 10-minute budget
(615s, 611s) with zero test failures — pure budget exhaustion, the same commit passing or
failing depending on the runner it drew. Measured on the master run that produced this
base commit, the script step alone is 484s of the cell's 501s.

The lever that needs no workflow edit: pytest-xdist is already in the [test] extra, and
test-gfql-core already runs `-n auto` under `--cov`, so coverage+xdist is established here.
The main pytest phase now runs `-n auto --maxprocesses 4 --dist load`.

Measured (py3.12, polars 1.43.1, pandas 3.0.5, POLARS_COV=1), pinned to 4 CPUs to stand in
for a GitHub-hosted ubuntu-latest runner: 325.65s serial -> 109.34s parallel, 2.98x.

Verified, not assumed:
 - pass/skip NODE-ID SETS (not counts) identical serial vs parallel: 2417 node ids,
   2404 passed / 13 skipped, across 4-worker, 2-worker, --dist loadfile and 4-CPU-pinned runs
 - merged coverage is a strict superset of serial (28,480 vs 28,478 covered lines; zero lines
   lost, zero files dropped; the 2 extra are versioneer fallback lines in _version.py)
 - the second `--cov-append` invocation still appends into the xdist-produced data file
   (+1,791 lines, nothing lost) and the result is line-identical to the whole-script run
 - `bin/coverage_audit.py --profile gfql-polars` emits a byte-identical report (modulo its
   timestamp) from the parallel data, so `changed-line-coverage` sees no change

`--dist load` rather than `loadfile` because one module (test_engine_polars_chain.py) is 69%
of the lane's test time, which caps file-level distribution at ~1.4x; no test in this lane
depends on execution order or cross-test module state. POLARS_XDIST=0 restores the serial
path and POLARS_XDIST_DIST selects another distribution mode if that ever changes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015YsqAZQLbqjSDrYSFz2GoB
The first commit quoted a local 4-CPU-pinned A/B (326s -> 109s, 2.98x). CI has now run
this branch and realizes less: the `test-polars (3.12)` cell goes 501s -> 322s and its
script step 484s -> 297s (1.63x), with the five non-coverage cells at 222-318s -> 115-172s.

Coverage tracing is per-worker CPU cost that does not parallelize away, and the ~8s serial
second phase plus 4x interpreter startup are fixed, so the coverage cell gains less than
the plain cells (which see ~2x). The CI figure is the one that decides whether #1805 fits,
so it is the one the changelog quotes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015YsqAZQLbqjSDrYSFz2GoB
@lmeyerov
lmeyerov force-pushed the ci/polars-lane-xdist branch from 077867b to 1e499f5 Compare July 28, 2026 03:01
@lmeyerov
lmeyerov merged commit 1537e46 into master Jul 28, 2026
77 checks passed
@lmeyerov
lmeyerov deleted the ci/polars-lane-xdist branch July 28, 2026 06:28
lmeyerov added a commit that referenced this pull request Jul 28, 2026
Resolves both conflicts and corrects two defects the conflict exposed.

bin/test-polars.sh -- take master's version unchanged. This branch adds
graphistry/tests/compute/gfql/test_rows_table_named_middle.py and
test_rewrite_param_discard.py to POLARS_TEST_FILES, but #1805 (49db91c,
this branch's own merge-base) already added both. Keeping the branch side
would list each file twice, so the polars lane would run both modules twice
-- and that lane's py3.12 cell is the coverage cell, currently 442s against
a 600s cap. Master's version also carries #1814's xdist plumbing, which the
branch side predates.

CHANGELOG.md -- the entry claimed "neither file was in bin/test-polars.sh,
so test_rows_table_named_middle.py ... ran in no CI lane at all". That is
false at this branch's base: both files are listed there at lines 43 and 50.
Rewritten to the residual that is true -- the suites were parametrized over
pandas + polars only, so cuDF and polars-gpu never exercised the table
guards; and the module-level pytest.importorskip("polars") skipped the whole
file in test-gfql-core, so its pandas cases ran only in the polars lane.
The measured claims (45 passed / 3 xfailed / 0 skipped on GB10; 24 mutation
failures, 6 per engine) are unchanged.

Also types engine_skip_reason, per review: the engine -> required-modules
map is hoisted to a module-level Mapping[str, Tuple[str, ...]] constant
rather than rebuilt inline per call, the signature is
(engine: str, smoke: Callable[[], object]) -> Optional[str], and
gpu_environment_reason's "str | None" string annotation is normalized to
Optional[str] to match (this file is imported under py3.8 lanes).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YYZRXegrALuXd3NHH5evqx
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant