Skip to content

perf: make large diffs much cheaper to process - #2934

Open
diogokiss wants to merge 2 commits into
tj-actions:mainfrom
diogokiss:perf/large-diff-performance
Open

perf: make large diffs much cheaper to process#2934
diogokiss wants to merge 2 commits into
tj-actions:mainfrom
diogokiss:perf/large-diff-performance

Conversation

@diogokiss

@diogokiss diogokiss commented Aug 17, 2026

Copy link
Copy Markdown

Note

This Pull Request was opened by an AI agent, which has been previously reviewed and green-lit by a human, @diogokiss.

Fixes causes 1 and 2 of #2933. Cause 3 needs a new input, so it is left for a separate Pull Request
once the name and shape are agreed.

Two commits, one per cause. They fix different symptoms, so they are easier to judge apart:

  1. perf: skip debug serialization when debug logging is off — removes the bytes.
  2. perf: build the other_* file lists with a Set — removes the time.

Commit 1 — debug serialization

@actions/core.debug() always writes to stdout, because it never calls isDebug() itself, so
RUNNER_DEBUG only decides whether the runner displays a line and not whether the action sends it.
The calls here are built from template strings holding whole file lists, so JSON.stringify also ran
on every one of them regardless. A debugJson helper now checks isDebug() before doing any of that
work, and every debug call that holds a value goes through it.

Commit 2 — the other_* lists

other_changed_files, other_modified_files and other_deleted_files were each built by calling
Array.includes inside Array.filter, which rescans the whole list of matched paths for every path
in the diff. All three run once per filter key. Each now tests against a Set built once.

Measurements

Generated repository, one filter key matching every changed file, RUNNER_DEBUG unset, running the
built dist/index.js the way the runner does. The script is in #2933 so you can reproduce it. Each
column is measured at that commit, so the two effects are separated:

Files changed upstream main after commit 1 after commit 2
20,000 5.38 s / 5.20 MiB stdout 5.79 s / 0 bytes 1.91 s / 0 bytes
40,000 22.64 s / 10.51 MiB stdout 23.38 s / 0 bytes 1.78 s / 0 bytes
80,000 91.70 s / 21.33 MiB stdout 74.61 s / 0 bytes 2.61 s / 0 bytes

Worth reading those two columns separately:

  • Commit 1 takes stdout to nothing at every size, but barely moves the clock. Filling the stdout pipe
    is what produces write ENOBUFS, so this is the part that stops the crash.
  • Commit 2 is what flattens the time. Before it, time grew about four times for every doubling of the
    diff.

Below 20,000 files both builds sit around 1.5 s, so the difference only shows up as the diff grows.

Nothing about the results changes

  • On a 20,000 file run, all 37 files written to output_dir are byte for byte identical to the same
    run against upstream main, and so is the whole of GITHUB_OUTPUT.
  • With RUNNER_DEBUG=1, the build emits the same 48 debug lines with the same labels. The guard only
    skips work when nobody is going to read the output.
  • The new tests in src/__tests__/changedFilesOutput.test.ts cover the three other_* lists,
    including a filter that matches everything, a filter that matches nothing, a path reported by more
    than one change type, and renamed paths. They pass against the original code as well, so they
    describe existing behavior rather than the change.

GITHUB_OUTPUT is untouched by this Pull Request. That is cause 3 in #2933.

Checks

yarn all passes at both commits: build, prettier, eslint with --max-warnings 0, ncc package, and
jest with coverage (71 tests, 5 suites). dist/ is rebuilt in each commit, so the bundle matches the
sources at every point in the history.

@codacy-production

Copy link
Copy Markdown

Up to standards ✅

🟢 Issues 0 issues

Results:
0 new issues

View in Codacy

🟢 Metrics 127 complexity

Metric Results
Complexity 127

View in Codacy

NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.

@diogokiss
diogokiss force-pushed the perf/large-diff-performance branch from 0a92df4 to 150dc0f Compare August 17, 2026 13:17
`@actions/core.debug()` always writes to stdout, because it never calls
`isDebug()` itself. `RUNNER_DEBUG` therefore only decides whether the runner
displays a line, not whether the action sends it. The debug calls here are
built from template strings holding whole file lists, so `JSON.stringify` also
ran on every one of them no matter what.

On a large diff that is a lot of work and a lot of bytes for output nobody
reads. A `debugJson` helper now checks `isDebug()` before serializing
anything, and every debug call that holds a value goes through it.

Measured on a generated repository, running the built action the way the
runner does, with one filter matching every changed file and `RUNNER_DEBUG`
unset:

    files    stdout before   stdout after   time before   time after
    20,000       5.20 MiB       0 bytes         5.38s        5.79s
    40,000      10.51 MiB       0 bytes        22.64s       23.38s
    80,000      21.33 MiB       0 bytes        91.70s       74.61s

The bytes are the point. This is what fills the stdout pipe on a large diff,
and the pipe filling up is what produces `write ENOBUFS`. The remaining time
growth has a separate cause and is dealt with in the next commit.

Debug output itself is unchanged. With `RUNNER_DEBUG=1` the build emits the
same 48 debug lines, with the same labels, as before.

References:
https://github.com/actions/toolkit/blob/%40actions/core%402.0.2/packages/core/src/core.ts
https://github.com/tj-actions/changed-files/blob/934b2d2c7e653bb8c968afed5a0428617f09aa24/src/changedFilesOutput.ts#L39
`other_changed_files`, `other_modified_files` and `other_deleted_files` are
the paths in the diff that the filter did not match. Each was worked out by
calling `Array.includes` inside `Array.filter`, which rescans the whole list
of matched paths for every path in the diff. The cost of that grows with the
square of the number of changed files, and all three run once per filter key.

Each one now tests against a `Set` built once.

Measured on a generated repository, running the built action the way the
runner does, with one filter matching every changed file:

    files    before    after
    20,000    5.79s     1.91s
    40,000   23.38s     1.78s
    80,000   74.61s     2.61s

The "before" column is the previous commit, so these numbers isolate this
change. Time was growing about four times for every doubling of the diff and
is now close to flat.

Behavior is unchanged. On a 20,000 file run every file written to
`output_dir` is byte for byte identical to the same run before either commit.
The new tests cover all three lists, including a filter that matches
everything, a filter that matches nothing, a path reported by more than one
change type, and renamed paths. They pass against the original code as well,
so they describe the existing behavior rather than this change.

References:
https://github.com/tj-actions/changed-files/blob/934b2d2c7e653bb8c968afed5a0428617f09aa24/src/changedFilesOutput.ts#L284-L286
https://github.com/tj-actions/changed-files/blob/934b2d2c7e653bb8c968afed5a0428617f09aa24/src/changedFilesOutput.ts#L374-L376
https://github.com/tj-actions/changed-files/blob/934b2d2c7e653bb8c968afed5a0428617f09aa24/src/changedFilesOutput.ts#L474-L476
@diogokiss

diogokiss commented Aug 20, 2026

Copy link
Copy Markdown
Author

Hi, @jackton1 ! 🙋🏻
Could take a look at this Pull Request (and the other 2 related as well) when you have time, please?
Thank you in advance. 🙌🏻

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