GPU regression testing for ComfyUI on RunPod serverless, feeding ci.comfy.org.
For every new commit on ComfyUI master, the GPU regression
workflow runs the curated workflows in
manifest/workflows.json on a RunPod serverless
endpoint at that exact commit (the worker checks the commit out at request
time), then compares the fixed-seed outputs against two baselines:
- golden — blessed reference outputs generated from a known-good release tag
- previous — the last completed run on the same branch
Metrics (per frame): MSE, PSNR mean/min, max abs channel diff, % pixels
changed, plus diff heatmaps and side-by-side strips. Everything is published
under regression/ where the dashboard reads it.
Two interchangeable backends behind one path layout (scripts/storage.py,
pick with --storage / REGRESSION_STORAGE):
- github (current default): results live on this repo's orphan
resultsbranch; the dashboard reads them fromhttps://raw.githubusercontent.com/Comfy-Org/comfyci-runpod-worker/results/regression/. Zero extra secrets — the scheduled workflow pushes with its ownGITHUB_TOKEN. Fine at current volume; migrate before the run rate or the workflow set grows much (git history only accumulates). - gcs:
gs://comfy-ci-results/regression/— the long-term home once aGCS_SERVICE_ACCOUNT_JSONwith write access exists. Switching is an env change here plusNEXT_PUBLIC_REGRESSION_BASEon the dashboard.
| Path | What |
|---|---|
Dockerfile, docker/ |
Worker image: CUDA + torch + pre-cloned ComfyUI. Commit under test is checked out per request. |
src/ |
RunPod handler (handler.py), commit checkout, workflow runner, volume model sync |
manifest/workflows.json |
Single source of truth: workflows, models, seeds, thresholds (staged workflow JSONs not yet in the manifest live in manifest/workflows/) |
manifest/lanes.json |
Environment lanes (python / torch / cuda / GPU, cadence, storage prefix) published to the dashboard through the run index |
.github/workflows/build-index.yml |
Backfills or repairs the run index for a branch/lane (workflow_dispatch) |
scripts/ |
Orchestration run on the (CPU) CI runner: submit/poll, compare, publish, golden generation |
.github/workflows/regression.yml |
Scheduled poller that tests each new master commit (interim, until the core CI job below) |
action.yml |
Composite action for ComfyUI's test-ci.yml (GCS phase) |
One PR to this repo:
- Export the workflow from ComfyUI via Workflow → Export (API) and save it
as
manifest/workflows/<id>_api.json. Make sure it ends in aSaveImagenode (PNG output — lossless is what gets compared) withfilename_prefix: <id>. Fix all seeds in the JSON. For template workflows, resolve switch nodes to their default branch and drop the unused branch and preview-only nodes, so only the models that actually run need syncing. - Add an entry under
workflowsinmanifest/workflows.json:workflow_path,seed_overrides(node id → input patch, belt-and-braces for the seed),models(name + download url + ComfyUI models subdirectory, plussha256when the host publishes one), and optionallytimeout_s,gpu_type,thresholds,comfy_flags.- Input images for
LoadImagenodes are listed undermodelstoo, with"directory": "input"(pin the url to a commit, not a branch), and the entry sets"comfy_flags": ["--input-directory", "/runpod-volume/models/input"]so ComfyUI reads them from the volume. - Start with
"enabled": false. python -m pytest scripts/tests -qlints the manifest against every workflow it references (loaded files andmodelsmatch both ways, every node feeds theSaveImage, seed override targets, model folders,SaveImageprefix = workflow id).
- Merge.
sync-models.ymldownloads the new models onto the network volume automatically. - Generate + bless a golden for it: run the Golden baselines workflow with
the current release tag and
workflows: <id>, inspect, bless. - Flip
"enabled": truein a follow-up PR.
| Verdict | Meaning | CI effect |
|---|---|---|
pass |
metrics within thresholds vs golden | green |
fail |
metrics exceed thresholds vs golden | red |
execution_error |
workflow errored on the commit under test | red |
no_baseline |
no blessed golden yet | warning, green |
infra_error |
RunPod/queue/checkout/model-volume problem (after 1 retry) | warning, green |
Thresholds come from the manifest entry if set, otherwise from the manifest
defaults loosened to 3× the golden's own re-run noise floor
(noise_floor.json), so nondeterministic workflows don't false-alarm.
A failure vs golden is also classified against the previous tested run
(detail in summary.json): new_drift when this commit's outputs differ
from the previous run (the change happened here), inherited when they are
bit-identical to a previous run that already failed against the same golden,
and null when there is nothing to compare against or the outputs match a
previous run that did not fail (the golden changed, not the code). Inherited
failures publish no figures (figures: false): the figures of the first
failing commit already show the diff. summary.json carries
schema_version: 2 and has_infra_error (infra errors never turn overall
red). Each workflow also records prior_verdict / prior_commit: its most
recent red or green verdict before this commit, read from the run index, which
(unlike the previous-run pointer) includes runs that had execution errors.
When a commit changes numerics on purpose (a kernel optimisation that moves every pixel by a rounding error, say), the golden is stale, not the code. Re-bless with a reason so the history explains itself:
- From a release tag on master, with a fresh two-run noise floor: run
Golden baselines with
ref=<tag>,workflows=<id>,bless=false, inspectgolden/<id>/<tag>/outputs/andnoise_floor.json, then re-run withbless_only=trueand areason. - Without GPU time, from a run that already exists on the results branch:
from_run=master/<full sha>,workflows=<id>,reason=...,bless=true. The golden is named after the commit (12-char sha; passref=<tag>to name it after a tag that resolves to that same commit), has norun_r2.json, and its noise floor is inherited from the previous golden and markedmeasured: false.
An existing golden/<id>/<tag>/ is never overwritten unless force is set.
golden/<id>/current.json records reason, supersedes and the golden's
output_sha256; golden/<id>/history.json keeps every bless; the run index
is refreshed in the same publish so accepted drift stops being reported as an
open regression.
Every publish maintains a compact index under regression/index/: a per-lane
head file with the newest 500 runs and the first bad commit of each open
failing chain, complete monthly shards, and lanes.json with the lane
registry from manifest/lanes.json, per-branch latest pointers and the
current golden tag and output sha per workflow. Entries carry the commit
subject, author and PR (looked up with the Actions token; the PR title, author
and avatar too when the commit landed through a PR), the tested range
to the previous run (HEAD polling can skip commits), per-workflow verdicts
with the drift class, output sha, metrics and timings. Entries are ordered by
commit time, so re-testing an older commit never reorders history. The
dashboard's history and matrix views read only these files.
Build run index (workflow_dispatch) backfills or repairs a branch/lane from the committed summaries; dispatch it once after the first deploy and whenever an index file needs regenerating.
After each run, scripts/notify_pr.py can leave one comment on the ComfyUI
pull request that landed the tested commit. Its first line starts with
(PULSE); below it, a row per workflow (verdict, new or inherited, PSNR,
mean MSE, % pixels changed, exec time, peak VRAM), the lane and the run's page
on ci.comfy.org.
- When: a workflow's outputs changed at this commit (
detail: new_drift), or it hit an execution error while its prior run (prior_verdict) produced outputs. Never for pass, inherited drift, infra errors, missing goldens, a workflow with no prior run, a re-test of a commit older than the previous run, or a run that covered several new commits (the change cannot be pinned on one PR). - One comment per PR: found again by the hidden marker
<!-- pulse-regression:v1 -->and the token owner's login, and edited in place, so reruns never add a second one. A later clean run of the same commit turns it into a short resolved note. - Whose PRs: only PRs authored by a ComfyUI code owner, i.e. the users on
the
*rule of ComfyUI'sCODEOWNERS(team entries are ignored). The repo variablePULSE_ALLOWED_AUTHORS(comma-separated logins) replaces that list. IfCODEOWNERScannot be read and no override is set, nothing is posted. Only the primary lane on master comments. - Modes (repo variable
PULSE_PR_COMMENTS):dry-run(default) renders the comment into the job summary and posts nothing;onposts;offskips the step's work entirely.onwithout the token falls back to dry-run. - Token (repo secret
PULSE_GH_TOKEN): a personal access token of the account the comments should come from; they appear as that user. Prefer a fine-grained token with resource owner Comfy-Org, repositoryComfy-Org/ComfyUIonly, and Issues and Pull requests set to read and write. Fine-grained tokens for an organisation's repositories may need approval by an org owner, depending on the org's token policy. A classic token with thepublic_reposcope also works, but it can push to every public repository the account can write to, and anyone who can edit this repo's workflows can use it. Reads (PR lookup,CODEOWNERS) use the Actions token. - Non-blocking: the step runs even when the suite fails, has
continue-on-error, and turns every API or network problem into a warning.
To turn it on: add PULSE_GH_TOKEN (Settings → Secrets and variables →
Actions), check the dry-run output in a few job summaries, then set the
variable PULSE_PR_COMMENTS to on. Setting it to off stops it at once.
- Docker image:
build-image.ymlpushesghcr.io/comfy-org/comfyci-runpod-workerusing the built-inGITHUB_TOKEN(no registry secrets). After the first build, make the package public (repo → Packages → package settings → Change visibility) so RunPod can pull it without credentials. - RunPod: create a Network Volume (≥250 GB, datacenter with 4090
availability), then a serverless endpoint from the image with the volume
attached, GPU type
RTX 4090, max workers 3, idle timeout ~60s. Optionally setHF_TOKENas an endpoint env var for gated models. - Repo secrets (this repo):
RUNPOD_API_KEY,RUNPOD_ENDPOINT_ID, and optionallyHF_TOKEN(gated models) andPULSE_GH_TOKEN(PR comments, see above). For the GCS phase, additionallyGCS_SERVICE_ACCOUNT_JSON(write access to the CI bucket) andGCS_BUCKET(comfy-ci-results). - Seed the volume: run
sync-models.yml(workflow_dispatch). - First goldens: run Golden baselines with the latest release tag and
bless: true. - Done —
regression.ymlnow tests each new master commit on its own. - (GCS phase) Wire into core CI: add the job below to ComfyUI's
.github/workflows/test-ci.ymlplus theRUNPOD_API_KEY/RUNPOD_ENDPOINT_IDsecrets there, and retire the scheduled poller:
gpu-regression:
runs-on: ubuntu-latest
steps:
- name: RunPod regression suite
uses: comfy-org/comfyci-runpod-worker@main
with:
comfy-commit: ${{ github.sha }}
branch: ${{ github.ref_name }}
gcs-bucket: comfy-ci-results
env:
RUNPOD_API_KEY: ${{ secrets.RUNPOD_API_KEY }}
RUNPOD_ENDPOINT_ID: ${{ secrets.RUNPOD_ENDPOINT_ID }}
GCS_SERVICE_ACCOUNT_JSON: ${{ secrets.GCS_SERVICE_ACCOUNT_JSON }}Identical on both backends (branch root or bucket root):
regression/
runs/<branch>/<commit>/<workflow_id>/{outputs/, run.json, comparison.json, figures/}
# figures/ only when the frames differ and the
# failure is not inherited
runs/<branch>/<commit>/summary.json # per-commit rollup (dashboard entrypoint)
latest/<branch>.json # previous-run pointer, written last
manifest-snapshot/<commit>.json
golden/<workflow_id>/<tag>/{outputs/, run_r1.json, run_r2.json, noise_floor.json, blessed.json}
golden/<workflow_id>/current.json # active blessed tag (+ reason, supersedes, output_sha256)
golden/<workflow_id>/history.json # every bless
index/lanes.json # lane registry + per-branch latest + golden shas
index/<branch>/<lane>.json # newest 500 runs + first bad commit per workflow
index/<branch>/<lane>/<YYYY-MM>.json # complete monthly shards
Run outputs also carry 256-px WebP previews under <workflow_id>/outputs/thumbs/
for the dashboard's list views.
- Goldens are valid per GPU type and per worker image torch/CUDA: after
changing either, regenerate and re-bless (
blessed.jsonrecords both). - Local dry run without publishing anything:
python scripts/run_regression.py --commit <sha> --branch test --skip-publish - Script tests (run on every PR by the Script tests workflow):
pip install -r scripts/requirements.txt pytest && python -m pytest scripts/tests - The results checkout is a blobless sparse clone: only the aggregate directories are materialised, run directories on first use.
run.jsondelay_sis in seconds from summaryschema_version: 2on; earlier records hold RunPod's raw millisecond value under the same key.