Skip to content

perf(ci): Bigger install chunks, a shard resource sampler, and lower-priority checks - #2852

Merged
krlmlr merged 4 commits into
mainfrom
claude/revdep2-chunk-400-8mj8p1
Aug 17, 2026
Merged

perf(ci): Bigger install chunks, a shard resource sampler, and lower-priority checks#2852
krlmlr merged 4 commits into
mainfrom
claude/revdep2-chunk-400-8mj8p1

Conversation

@krlmlr

@krlmlr krlmlr commented Aug 16, 2026

Copy link
Copy Markdown
Contributor

Three changes to what the preflight and the shards spend, and to what they say while spending it.

1. A shard samples its resources, and checks at a lower priority

Shard 16 of run 31951756102 died with

The hosted runner lost communication with the server. Anything in your
workflow that terminates the runner process, starves it for CPU/Memory,
or blocks its network access can cause this error.

and there was not one number from that job to say which of those it was.

The preflight has sampled memory, disk, load and the three largest processes every 30 seconds since the incident that motivated watch-resources.sh. The shards, which run for hours rather than minutes, had nothing. A runner that stops answering takes its if: always() steps with it, so the only record that survives is what was already streamed to the log — which is the entire reason the sampler exists, and it was in the wrong job.

It is backgrounded once, before the install: a process started in one step outlives it, and Actions reaps it with the job. RESOURCE_LOG also puts the series in the shard artifact for the jobs that do reach their upload. A final always() step adds the last sample and asks dmesg whether the kernel killed anything — the difference between running out of memory and the host going away.

And the checks now run under nice -n 10, plus ionice -c3 where it exists. Two R CMD check processes run at once, each with children of its own — a test suite that opens a PSOCK cluster, a vignette that knits — and together they can take every core the runner has. The runner agent is a process on that machine too, and it has to reach the service regularly or the job dies. nice costs nothing when there is headroom: the scheduler only consults priority when there is more work than cores, which is exactly the case worth protecting. ionice does the same for the disk, where a check writing its .Rcheck directory competes with the agent writing logs.

This does not prove starvation was the cause. It means the next one says so.

2. 400 packages per pak call, not 100

A chunk pays one resolution whether or not it installs anything. Run 31930350338's preflight, verbatim:

✔ 80 pkgs + 214 deps: kept 294 [44s]
Preflight: chunk 4/4 (80 packages) installed after 0.7 min

44 seconds of pak for a chunk that built nothing. At 100 per chunk the 4406-package universe is 45 chunks — on the order of half an hour of resolution before a single build starts, all of it on the critical path, because every shard waits for the preflight. At 400 it is 12 chunks.

Why it was 100. One pak call for the whole universe killed run 31270092803 — ten minutes inside pak, not one install started, then the runner was shut down. That resolution was a few thousand refs; 400 is an order of magnitude below it, and still small enough that a chunk which dies costs a chunk rather than the job. The cost is that a failed chunk is four times as much to redo. REVDEP2_INSTALL_CHUNK remains the knob.

Chunking is otherwise identical: on 1200 CRAN packages, 3 chunks of 400 instead of 12 of 100, and every strong dependency still precedes the package that needs it — 0 out of order.

3. The load test says what it is doing

It captured everything and printed it once at the end, sorted slowest first. That group answers "what was slow", which arrival order cannot, so it stays — but a sweep of 1173 packages taking half an hour said nothing while it ran.

[load    1/1173] OK      red                              19s
[load    2/1173] OK      PopGenReport                     13s
[load    3/1173] TIMEOUT somethingslow                   600s

Each verdict goes to stdout (the caller's data, unchanged) and to stderr (the live log); the caller captures the first and inherits the second. The running count needs no lock — each finished package appends one byte and reads the size back, and single-byte O_APPEND writes do not interleave.

Verified

  • nice -n 10 + ionice -c3 prefix applies: the child reports niceness 10 and I/O class idle. The ionice half is probed before use, so an image without it degrades to nice alone.
  • The sampler wiring, with SHARD=16: a labelled "before the install" line, then shard 16 samples every interval, both to the log and to RESOURCE_LOG in the results directory.
  • Chunking on 1200 packages: 3 × 400, no out-of-order dependencies.
  • Load test on 8 packages and on a list containing one that does not exist: indices 1..n with no gaps or repeats, ERROR on the live line, FAIL nosuchpackage error 0 on stdout, temp file removed on exit.
  • revdep2.yaml parses; the two shell scripts pass bash -n.

Complements #2854, which bounds what a lost runner costs; this one is about seeing it coming and making it less likely.

🤖 Generated with Claude Code

https://claude.ai/code/session_01D1xpHRV7yVfgtJg4vp9P7z

claude added 2 commits August 16, 2026 14:28
A chunk pays one resolution whether or not it installs anything.
Run 31930350338's preflight logged
`80 pkgs + 214 deps: kept 294 [44s]`
for a chunk that built nothing at all --
44 seconds of pak, start to finish, for no work.

At 100, the 4406-package universe is 45 chunks,
so something like half an hour of resolution
before a single build starts,
and all of it on the critical path:
every shard waits for the preflight.
At 400 it is 12 chunks.

The size was 100 because one pak call for the whole universe
is what killed run 31270092803 --
ten minutes inside pak, no install started, then the runner went away.
That resolution was a few thousand refs.
400 is an order of magnitude below it,
and still small enough that a chunk which dies
costs a chunk rather than the job.

What it does cost: a failed chunk is four times as much to redo.
`REVDEP2_INSTALL_CHUNK` is still the knob,
so a preflight under pressure can be turned back down
without a code change.

Chunking is unchanged otherwise:
1200 packages come out as 3 chunks of 400 instead of 12 of 100,
and every strong dependency inside the set
still precedes the package that needs it -- 0 out of order.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D1xpHRV7yVfgtJg4vp9P7z
The load test captured everything and printed it once at the end,
sorted slowest first.
That group answers "what was slow",
which arrival order cannot,
so it stays --
but a sweep of 1173 packages taking half an hour
said nothing at all while it ran,
except through the resource sampler.

`load-test.sh` now writes each verdict twice:
the machine-readable line to stdout, as before,
and a human one to stderr as it happens, with a running count:

```
[load  123/1173] OK      red                              19s
```

The caller captures stdout and inherits stderr,
so the second copy reaches the job log live
and the first is still the data the summary is built from.

The count is kept without a lock.
Each finished package appends one byte to a temp file
and reads the size back;
single-byte appends to an O_APPEND descriptor do not interleave,
so the number is exact.
It would be cosmetic if it were not --
a progress indicator, not data.

The verdict word is `OK`, `TIMEOUT` or `ERROR`,
which distinguishes the two failure kinds on the live line;
stdout keeps its `FAIL <pkg> timeout|error <secs>` shape untouched,
so nothing downstream changes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D1xpHRV7yVfgtJg4vp9P7z
@krlmlr krlmlr changed the title perf(ci): Install 400 packages per pak call, not 100 perf(ci): Install 400 packages per pak call, and stream the load test Aug 16, 2026
Shard 16 of run 31951756102 died with

```
The hosted runner lost communication with the server. Anything in your
workflow that terminates the runner process, starves it for CPU/Memory,
or blocks its network access can cause this error.
```

and there was not one number from that job to say which of those it was.
The preflight has sampled memory, disk and load every 30 seconds
since the incident that motivated it;
the shards, which run for hours rather than minutes, had nothing.
A runner that stops answering
takes its `if: always()` steps with it,
so the only record that survives
is what was already streamed to the log --
which is the whole reason the sampler exists,
and it was in the wrong job.

It is backgrounded once, before the install:
a process started in one step outlives it,
and Actions reaps it with the job.
`RESOURCE_LOG` also puts the series in the shard artifact,
for the jobs that do reach their upload.
A final `always()` step adds the last sample
and asks dmesg whether the kernel killed anything,
which is the difference between running out of memory
and the host going away.

And the checks now run under `nice -n 10`,
plus `ionice -c3` where it exists.
Two `R CMD check` processes at once,
each with children of its own --
a test suite that opens a PSOCK cluster, a vignette that knits --
can take every core the runner has.
The runner agent is a process on that machine too,
and it has to reach the service regularly or the job dies.
`nice` costs nothing when there is headroom:
the scheduler only consults priority
when there is more work than cores,
which is exactly the case worth protecting.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D1xpHRV7yVfgtJg4vp9P7z
@krlmlr krlmlr changed the title perf(ci): Install 400 packages per pak call, and stream the load test perf(ci): Bigger install chunks, a shard resource sampler, and lower-priority checks Aug 16, 2026
Two things the shard's new sampler made obvious about the preflight's.

It was started and killed inside the "Install and load every
dependency" step, so the two artifact uploads after it went unsampled.
This job's library artifact is gigabytes --
4.1 GB in run 31951756102 --
and packing and uploading it is exactly the kind of work
that runs a runner out of disk or stalls it.
"The runner stopped answering while packing"
would have been as unanswerable as it was for shard 16.
It is backgrounded and left running now, as the shard's is.

And its label was fixed when it started, so it said `installing`
for the whole job. It is not a lie for the first ten minutes and it is
one for the next thirty:

```
Preflight: load-testing 1173 of 2775 installed package(s) ...
[resources] 14:40:12 installing -- mem 1.8/15.6G used ...
```

`watch-resources.sh watch` now reads its label from
$RESOURCE_PHASE_FILE when that is set,
and `preflight.R` writes the phase it is in --
installing, surveying system requirements, load-testing,
packing the library.
Fixed labels still work; the file is optional
and the shard passes none, its `shard <n>` being phase-neutral already.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D1xpHRV7yVfgtJg4vp9P7z
@krlmlr
krlmlr merged commit 51f301e into main Aug 17, 2026
6 checks passed
@krlmlr
krlmlr deleted the claude/revdep2-chunk-400-8mj8p1 branch August 17, 2026 12:41
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.

2 participants