Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 9 additions & 8 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,10 @@ name: CI
# tests/test_cell_stream.py, tests/test_inmem_external_ref_gpu.py) each
# carry a module-level `pytest.importorskip("cellstream")`, so all three files
# skip entirely without the optional `[streaming]` extra. Between them that
# is 141 cases, 70 of which need no GPU -- and since cellstream is on PyPI
# those 70 are now runnable on a stock runner, which wants its own job.
# is 147 cases, 76 of which need no GPU (measured 2026-08-20; per suite,
# test_shard_stream 32/37, test_cell_stream 44/8, test_inmem_external_ref_gpu
# 0/26) -- and since cellstream is on PyPI those 76 are now runnable on a
# stock runner, which wants its own job.
# - The real-data tests (tests/test_real_data.py) skip when the dataset path
# is absent (it always is on CI).
# So this job goes green on a stock CPU runner with no GPU and without the
Expand Down Expand Up @@ -71,17 +73,16 @@ jobs:
cache-dependency-glob: pyproject.toml

# `uv pip install` does pip-style resolution of only the requested
# extras. It used to be mandatory: a fresh `uv sync` resolves EVERY source
# in [tool.uv.sources], and that table carried a private cellstream (then
# shardad) git-SSH entry the no-SSH runner could not clone, even when the
# `streaming` extra wasn't requested. That entry is gone since the rename,
# so `uv sync` would work now too. torch resolves from PyPI here (the cu126
# extras. It used to be mandatory, back when [tool.uv.sources] carried a
# git-SSH entry a no-SSH runner could not clone and `uv sync` resolved every
# source in that table whether or not the extra was requested. That entry is
# gone, so `uv sync` would work now too. torch resolves from PyPI here (the cu126
# source is skipped), which is fine: CI is CPU-only and GPU tests skip.
# `[fast]` (numba) IS installed so the numba CSR kernel — a CPU code path —
# is exercised rather than the scipy fallback.
#
# `[streaming]` is deliberately NOT installed here: this job's purpose is
# to prove gpudge's CORE install needs nothing optional. Covering the 70
# to prove gpudge's CORE install needs nothing optional. Covering the 76
# non-CUDA cellstream cases is now possible on a stock runner and wants its
# own job rather than widening this one.
- name: Install dependencies
Expand Down
183 changes: 182 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ jobs:
set -euo pipefail
expected=$(printf '%s\n' \
.gemini .github .gitignore .python-version \
CHANGELOG.md CITATION.cff CODE_OF_CONDUCT.md CONTRIBUTING.md \
AUTHORS CHANGELOG.md CITATION.cff CODE_OF_CONDUCT.md CONTRIBUTING.md \
LICENSE PKG-INFO README.md SECURITY.md \
benchmarks docs environment.yml examples pyproject.toml src tests \
| sort)
Expand Down Expand Up @@ -102,4 +102,185 @@ jobs:
echo "::error::tag $GITHUB_REF_NAME does not match built version $built"; exit 1; }
echo "tag and built version agree: $built"

# Four files carry the release version independently of pyproject.toml --
# CITATION.cff, CHANGELOG.md, environment.yml, README.md -- and nothing
# used to check any of them: CITATION.cff silently shipped `0.8.0` INSIDE
# the 0.9.0 release, because "these files move together" lived only in a
# human's head. Asserted here instead.
#
# The two YAML files are PARSED and the two Markdown files are matched as
# whole lines. Both decisions are findings rather than preferences: three
# rounds of review produced file contents that passed a text-matching
# draft of this step while publishing the wrong thing.
#
# grep -F matched a version named in a COMMENT while the real pin
# sat one release behind.
# grep -c counts matching LINES, so an exact heading string buried
# in a sentence of prose read as the heading.
# digits a date checked for SHAPE let `2026-02-31` through, and
# `fromisoformat` alone lets `20260820` through -- it
# canonicalises, and CFF 1.2 wants the dashes.
# ERE interpolating a version into a regex breaks on the `+`
# that PEP 440 permits.
# .strip() made the checks indentation-blind, so a pin de-indented
# out of the `pip:` block (where it installs nothing) and a
# heading indented into a code block both passed.
# startswith a duplicate `"version":` key, which YAML resolves to the
# LAST value, hid behind the first one being right.
# substring counting occurrences of the install pin accepted one
# inside an HTML comment, with none left in the document.
# truthiness an emptied or fully-commented-out YAML file parses to
# None, and a bare `{}` parses to an empty mapping; both are
# falsy, and both skipped their structural check entirely.
# `.*?-->` an HTML comment opener with the closer forgotten hides the
# rest of the document when rendered and stripped nothing.
#
# Every one of those was demonstrated against this step. (Codex review,
# gpudge_arc#149.)
- name: The other files that carry the version agree with it
run: |
set -euo pipefail
uv run --with pyyaml --no-project python - <<'PY'
import glob, re, sys
from datetime import date
from pathlib import Path

import yaml

fail = []

class Strict(yaml.SafeLoader):
"""Rejects duplicate keys, which SafeLoader resolves to the last."""

def _no_dupes(loader, node, deep=False):
seen = set()
for k, _ in node.value:
key = loader.construct_object(k, deep=deep)
if key in seen:
raise yaml.YAMLError(f"duplicate key {key!r}")
seen.add(key)
return yaml.SafeLoader.construct_mapping(loader, node, deep)

Strict.add_constructor(
yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, _no_dupes)

def load(name):
"""A mapping, or None with the reason already recorded.

Everything that is not a mapping is a failure, `None` included: an
emptied or fully-commented-out file parses to None, and letting
that through skipped the whole structural check below. Callers
test `is not None`, not truthiness -- `{}` is a mapping, parses
from a plain `{}`, and is falsy, which skipped the check just the
same.
"""
try:
doc = yaml.load(Path(name).read_text(encoding="utf-8"), Strict)
except yaml.YAMLError as exc:
fail.append(f"{name} is not valid YAML: {exc}")
return None
if not isinstance(doc, dict):
fail.append(f"{name} did not parse to a mapping, got {type(doc).__name__}")
return None
return doc

def lines(name):
return Path(name).read_text(encoding="utf-8").splitlines()

sdists = sorted(glob.glob("dist/*.tar.gz"))
if len(sdists) != 1:
print(f"::error::want exactly one dist/*.tar.gz, found {len(sdists)}: {sdists}")
sys.exit(1)
stem = Path(sdists[0]).name.removesuffix(".tar.gz")
if not stem.startswith("gpudge-"):
print(f"::error::cannot read a version out of {sdists[0]}")
sys.exit(1)
v = stem[len("gpudge-"):]

# CITATION.cff, as a citation consumer sees it. `date-released` is a
# date in CFF, so an unquoted value parses to a date object; a quoted
# one must be the canonical YYYY-MM-DD, which is what CFF 1.2 asks for
# and is stricter than `fromisoformat`, whose 3.11+ form also accepts
# `20260820` and `2026-W34-4` and quietly canonicalises them.
cff, cff_date = load("CITATION.cff"), None
if cff is not None:
if str(cff.get("version")) != v:
fail.append(f"CITATION.cff version is {cff.get('version')!r}, want {v}")
raw = cff.get("date-released")
if isinstance(raw, date):
cff_date = raw.isoformat()
else:
try:
parsed = date.fromisoformat(str(raw)).isoformat()
except (ValueError, TypeError) as exc:
fail.append(f"CITATION.cff date-released {raw!r} is not a real date: {exc}")
else:
if parsed != str(raw):
fail.append(f"CITATION.cff date-released {raw!r} is not written as {parsed}")
else:
cff_date = parsed

# environment.yml: the pin has to be in the PIP list. Checked
# structurally because a pin de-indented into the conda list is still
# a well-formed list entry -- it just never installs gpudge.
env = load("environment.yml")
want = f"gpudge[fast]=={v}"
if env is not None:
pips = [item["pip"] for item in env.get("dependencies") or []
if isinstance(item, dict) and "pip" in item]
if len(pips) != 1:
fail.append(f"environment.yml has {len(pips)} pip: blocks, want exactly 1")
elif [r for r in pips[0] if r == want] != [want]:
fail.append(f"environment.yml pip: block does not carry exactly one {want!r}: {pips[0]}")

# And no OTHER version pinned anywhere in either file, including in a
# comment a parser cannot see. Every occurrence is extracted and
# compared: deciding per LINE pardoned a stale pin sitting in a
# trailing comment beside the right one, and a substring test let
# `==0.9.10` satisfy a check for `==0.9.1`.
for name in ("environment.yml", "README.md"):
found = re.findall(r"gpudge\[fast\]==([0-9A-Za-z._!+-]*)",
Path(name).read_text(encoding="utf-8"))
other = sorted({f for f in found if f != v})
if other:
fail.append(f"{name} pins gpudge[fast] at {other} as well as {v}")

# The changelog heading, whole-line and UNINDENTED: `.strip()` accepts
# four leading spaces, which CommonMark renders as a code block, so the
# release's sections would stay under Unreleased.
if cff_date:
want_h = f"## [{v}] \u2014 {cff_date}"
n = sum(1 for ln in lines("CHANGELOG.md") if ln == want_h)
if n != 1:
fail.append(f"want exactly one unindented CHANGELOG heading {want_h!r}, found {n}")

# README's install pin is the one version in this tree NOT edited by
# hand: the mirror build substitutes it from a version argument typed
# on the command line, so it can name a different tag while every
# hand-edited file agrees -- and it is the line readers copy. HTML
# comments are stripped first: the pin has to be in the document a
# reader sees, and commenting the install block out (delimiters on
# their own lines, pin untouched) satisfied a plain line match. An
# opener runs to `-->` OR to end of file, because that is what
# CommonMark does with an unclosed one -- and `.*?-->` strips nothing
# at all when the closer was forgotten.
text = re.sub(r"<!--.*?(?:-->|\Z)", "",
Path("README.md").read_text(encoding="utf-8"), flags=re.S)
want_pin = f'"gpudge[fast] @ git+https://github.com/ArcInstitute/gpudge.git@v{v}"'
n = sum(1 for ln in text.splitlines() if ln.strip() == want_pin)
total = text.count("ArcInstitute/gpudge.git@")
if n != 1 or total != 1:
fail.append(
f"README.md: want exactly one uncommented line {want_pin} and no "
f"other 'ArcInstitute/gpudge.git@'; found {n} and {total}. The "
f"mirror build's version argument disagrees with the tag")

for f in fail:
print(f"::error::{f}")
if fail:
sys.exit(1)
print(f"version parity ok: {v}, dated {cff_date}, in CITATION.cff, "
f"CHANGELOG.md, environment.yml and README.md")
PY

- uses: pypa/gh-action-pypi-publish@release/v1
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,4 @@ htmlcov/
uv.lock



83 changes: 83 additions & 0 deletions AUTHORS
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
gpudge — authors and contributors
=================================

Maintainer
----------

Alexander Dobin (@alexdobin)
Author and maintainer.


Inspiration and prior art
-------------------------

Abhinav Adduri (@abhinadduri)
gpudge's direct inspiration is the GPU fork of pdex — [`abhinadduri/pdex`, branch
`gpu`](https://github.com/abhinadduri/pdex/tree/gpu), where
[`138335ae`](https://github.com/abhinadduri/pdex/commit/138335ae97b8) "Add CUDA backend for
ref-mode MWU" added `src/pdex/_torch_mwu.py` on 2026-05-19. gpudge's own design document is
dated two days later. That branch put reference-mode Mann–Whitney on CUDA — sorting the
fixed reference pool once per gene chunk and reusing it across every target group — and
added `mwu_backend={"auto","cpu","torch"}` and a `gpu_gene_chunk_size` knob to
`pdex.pdex(...)` to drive it. As of 2026-08-20 those interfaces are the fork's alone:
upstream pdex has no `_torch_mwu.py`, and no `mwu_backend`, `gpu_gene_chunk_size` or
`default_gene_chunk_size`.

gpudge took two concrete things from that branch as well as the idea. The name of the
`gpu_gene_chunk_size` parameter is that branch's, and so is the shape of gpudge's gene-chunk
sizer: a working-set estimate of ~24 bytes per reference cell per gene, capped at 16 GB and
at a fraction of free device memory, floored and rounded down to a multiple of 64. gpudge
takes 20% of free memory where the fork takes 18%, and additionally accounts for the
per-chunk accumulators the reference path holds resident — but it is the same heuristic,
adopted rather than reinvented. The attribution comment at that call site said plain "pdex",
which read as upstream; it now names the fork.

Thanks also for a number of useful suggestions along the way.

gpudge exists to replace the parts of [pdex](https://github.com/ArcInstitute/pdex) that one
production DE stage used, and pdex is the CPU baseline behind gpudge's parity and speedup
claims. It is prior art in the strong sense: those numbers only mean anything because there
was a correct, well-tested CPU implementation to measure against. (What the committed
real-data check asserts is a bounded agreement, not bit-identity — see the tolerances in
`tests/test_real_data.py`.)


Production evaluation
---------------------

Kevin Feng (@kfenggg)
Evaluated gpudge in a production single-cell QC pipeline and adopted it as that pipeline's
default differential-expression engine, reporting close log2-fold-change agreement with CPU
pdex on real data at production scale, and a large reduction in wall time against the CPU
path it replaced. An evaluation run by someone other than this library's author, on data
and a workload chosen without reference to gpudge's own test suite, is a different kind of
evidence from the in-repo benchmarks — complementary to them, not a replacement.

Establishing that agreement meant working out the correspondence between the two engines,
which is his. On log1p input, pdex's default target and reference means are, in exact
arithmetic, `expm1` of gpudge's ARITHMETIC means — and log2FC then has to be recomputed
from those transformed means, because gpudge's arithmetic-mode `log2_fold_change` is a
ratio of the untransformed means and is not on pdex's scale. Arithmetic is the mode to use
here because of a real gpudge constraint: gpudge refuses `mean_calc="geometric"` together
with `ALL_OTHERS`, the rest-mean being computed by sum-subtraction, which is implemented
for the arithmetic case only.


AI tooling
----------

Much of this codebase was written with Claude Code (Anthropic), and OpenAI Codex was used inside
those same sessions to review the work before it reached human review — a deliberate
cross-provider check, on the theory that a second model from a second vendor is harder to talk
into agreement than the one that wrote the code. Gemini Code Assist and GitHub Copilot review
every pull request on top of that. The development history carries `Co-Authored-By` trailers
naming the specific Claude models.

Design decisions, rulings and acceptance of every change are the maintainer's.


Copyright
---------

Copyright (c) 2026 Arc Research Institute.
MIT — see LICENSE.
Loading
Loading