Skip to content
Open
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
125 changes: 123 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,7 @@ jobs:
assert float((tensor * tensor).sum().item()) == 1240.0
print("CuPy and PyTorch CUDA kernels and DLPack passed")
PY
- name: Run xFit GPU parity tests
- name: Run xFit GPU parity and device pipeline tests
run: >-
uv run --no-sync pytest -q -rs
--junitxml="${RUNNER_TEMP}/xfit-gpu.xml"
Expand All @@ -163,6 +163,7 @@ jobs:
tests/xfit/test_models.py::test_fp32_workspace_cast_preserves_subnormals_on_device
tests/xfit/test_models.py::test_fp32_normal_equations_preserve_subnormal_contributions
tests/xfit/test_models.py::test_fp32_solver_preserves_subnormal_curvature
tests/xscan/test_device_pipeline.py::test_persistent_context_pipeline_is_device_resident_and_deterministic
- name: Require all selected tests to execute
run: |
uv run --no-sync python - <<'PY'
Expand All @@ -172,7 +173,7 @@ jobs:

report = ET.parse(Path(os.environ["RUNNER_TEMP"]) / "xfit-gpu.xml")
cases = report.findall(".//testcase")
assert len(cases) == 18, len(cases)
assert len(cases) == 19, len(cases)
assert not report.findall(".//skipped"), "GPU tests must not skip"
assert not report.findall(".//failure")
assert not report.findall(".//error")
Expand Down Expand Up @@ -202,6 +203,119 @@ jobs:
run: >-
uv run --no-sync python examples/run_quickstarts.py
--require-gpu --output-dir "${RUNNER_TEMP}/cuphoton-quickstart"
- name: Run cuTile xFit GPU coverage
run: |
# CPU Torch supports feature imports without its CUDA 13.0 pin.
cutile_env="${RUNNER_TEMP}/cuphoton-cutile"
uv venv --python 3.12 "$cutile_env"
uv pip install --python "$cutile_env/bin/python" \
--index https://download.pytorch.org/whl/cpu 'torch==2.13.0+cpu'
uv pip install --python "$cutile_env/bin/python" --editable '.[cutile]' \
'pytest==9.1.1' 'cupy-cuda13x[ctk]==14.1.1' \
'cuda-tile[tileiras]==1.6.0' \
'cuda-toolkit[nvcc,nvvm,tileiras]==13.2.2' 'nvidia-nvjitlink==13.2.86'
uv pip freeze --python "$cutile_env/bin/python" \
> "${RUNNER_TEMP}/cutile-packages.txt"
"$cutile_env/bin/python" - <<'PYCOMPILER' > "${RUNNER_TEMP}/cutile-compiler.txt" 2>&1
from pathlib import Path
import subprocess
import sysconfig

compiler = Path(sysconfig.get_path("purelib")) / "nvidia/cu13/bin"
for name in ("tileiras", "nvcc", "ptxas"):
executable = compiler / name
print(executable, flush=True)
subprocess.run([str(executable), "--version"], check=True)
PYCOMPILER
cat "${RUNNER_TEMP}/cutile-compiler.txt"
cutile_status=0
"$cutile_env/bin/python" -m pytest -q -s -rs \
--junitxml="${RUNNER_TEMP}/cutile-gpu.xml" \
tests/xfit/test_cutile_fused.py \
tests/xfit/test_models.py::test_cutile_gaussian_fit_matches_cupy \
tests/xfit/test_models.py::test_cutile_gaussian_normal_equations_match_noisy_float32 \
tests/xfit/test_models.py::test_cutile_weighting_and_compacted_indices_match_cupy \
tests/xfit/test_models.py::test_cutile_preserves_rank_and_budget_failure_semantics \
tests/xfit/test_models.py::test_cutile_device_results_survive_worker_reuse \
> "${RUNNER_TEMP}/cutile-gpu.log" 2>&1 || cutile_status=$?
cat "${RUNNER_TEMP}/cutile-gpu.log"
CUTILE_PYTEST_STATUS="$cutile_status" "$cutile_env/bin/python" - <<'PY'
import os
from pathlib import Path
import re
import xml.etree.ElementTree as ET

root = Path(os.environ["RUNNER_TEMP"])
output = (root / "cutile-gpu.log").read_text()
lifecycle_error = re.compile(
r"CUDA_ERROR_|cudaError|CUDADriverError|CUDARuntimeError|CUDA error|CUDA driver error|"
r"CUDA runtime error|PytestUnraisableExceptionWarning|unraisable exception|"
r"Exception ignored in:|Exception ignored while|moduleUnload|Module\.__dealloc__",
re.IGNORECASE,
)
if lifecycle_error.search(output):
raise SystemExit("cuTile tests emitted CUDA or interpreter teardown errors")
if int(os.environ["CUTILE_PYTEST_STATUS"]) != 0:
raise SystemExit("cuTile pytest process failed")
report = ET.parse(root / "cutile-gpu.xml")
cases = report.findall(".//testcase")
if len(cases) != 48 or any(report.findall(".//" + tag) for tag in ("skipped", "failure", "error")):
raise SystemExit("Require all 48 selected cuTile tests without skips, failures or errors")
print("All 48 cuTile tests passed without skips or teardown errors")
PY
- name: Run real cuTile classifier pipeline
run: |
# Keep CUDA Torch's runtime pins; reuse the separate compiler tools.
uv pip install --python .venv/bin/python --no-deps 'cuda-tile==1.6.0'
CUTILE_COMPILER_ROOT="$(
"${RUNNER_TEMP}/cuphoton-cutile/bin/python" -c \
'import sysconfig; print(sysconfig.get_path("purelib") + "/nvidia/cu13")'
)"
export CUTILE_COMPILER_ROOT
export CUDA_HOME="$CUTILE_COMPILER_ROOT"
export PATH="$CUTILE_COMPILER_ROOT/bin:$PATH"
uv pip freeze --python .venv/bin/python \
> "${RUNNER_TEMP}/cutile-pipeline-packages.txt"
.venv/bin/python - <<'PY'
import os
from pathlib import Path
from cuda.tile._compile import _find_compiler_bin

compiler = _find_compiler_bin()
expected = Path(os.environ["CUTILE_COMPILER_ROOT"]) / "bin/tileiras"
assert Path(compiler.path).resolve() == expected.resolve()
print(f"Pipeline tileiras: {compiler.path}")
PY
pipeline_status=0
.venv/bin/python -m pytest -q -s -rs \
--junitxml="${RUNNER_TEMP}/cutile-pipeline-gpu.xml" \
tests/xscan/test_cutile_pipeline.py \
> "${RUNNER_TEMP}/cutile-pipeline-gpu.log" 2>&1 || pipeline_status=$?
cat "${RUNNER_TEMP}/cutile-pipeline-gpu.log"
CUTILE_PIPELINE_STATUS="$pipeline_status" .venv/bin/python - <<'PY'
import os
from pathlib import Path
import re
import xml.etree.ElementTree as ET

root = Path(os.environ["RUNNER_TEMP"])
output = (root / "cutile-pipeline-gpu.log").read_text()
lifecycle_error = re.compile(
r"CUDA_ERROR_|cudaError|CUDADriverError|CUDARuntimeError|CUDA error|CUDA driver error|"
r"CUDA runtime error|PytestUnraisableExceptionWarning|unraisable exception|"
r"Exception ignored in:|Exception ignored while|moduleUnload|Module\.__dealloc__",
re.IGNORECASE,
)
if lifecycle_error.search(output):
raise SystemExit("cuTile pipeline emitted CUDA or interpreter teardown errors")
if int(os.environ["CUTILE_PIPELINE_STATUS"]) != 0:
raise SystemExit("cuTile pipeline pytest process failed")
report = ET.parse(root / "cutile-pipeline-gpu.xml")
cases = report.findall(".//testcase")
if len(cases) != 2 or any(report.findall(".//" + tag) for tag in ("skipped", "failure", "error")):
raise SystemExit("Require both real cuTile pipeline cases without skips, failures or errors")
print("Both real cuTile pipeline cases passed without skips or teardown errors")
PY
- name: Upload test evidence
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
Expand All @@ -210,6 +324,13 @@ jobs:
path: |
${{ runner.temp }}/xfit-gpu.xml
${{ runner.temp }}/xfit-native-gpu.xml
${{ runner.temp }}/cutile-gpu.xml
${{ runner.temp }}/cutile-gpu.log
${{ runner.temp }}/cutile-packages.txt
${{ runner.temp }}/cutile-compiler.txt
${{ runner.temp }}/cutile-pipeline-gpu.xml
${{ runner.temp }}/cutile-pipeline-gpu.log
${{ runner.temp }}/cutile-pipeline-packages.txt
${{ runner.temp }}/cuphoton-quickstart/**/summary.json
if-no-files-found: error
retention-days: 14
Expand Down
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,7 @@ checks in separate fresh containers. Image pulls have bounded retries;
installation and test failures fail the job without retrying.

The GPU job uses one L40G with Python 3.12 and the locked CUDA 13 dependencies.
It checks CuPy/PyTorch execution, runs 18 xFit GPU numerical cases, and runs
It checks CuPy/PyTorch execution, runs 19 xFit GPU and device pipeline cases, and runs
all five synthetic quickstarts with `--require-gpu`. Missing CUDA support or
skipped parity cases fail the job. JUnit results and quickstart summaries are
uploaded with the tested commit SHA. GPU jobs run one at a time; a new
Expand Down
14 changes: 12 additions & 2 deletions docs/components/xfit.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,14 +66,24 @@ applies one mask or variance plane to every channel of each candidate. If the
batch size is three, `(3, y, x)` keeps that per-candidate meaning; use the
explicit `(1, 3, y, x)` shape for per-plane values.

An explicit `backend="cutile"` uses one `cuda.tile` CTA per Gaussian fit to
form its weighted 8-by-8 normal equations directly. Select this backend
With the default `fusion=False`, an explicit `backend="cutile"` uses one
`cuda.tile` CTA per Gaussian fit to form its weighted 8-by-8 normal equations
directly. Select this backend
explicitly and install the `cuphoton[cutile]` extra on Linux with Python
3.12 through 3.14. Final rank and covariance diagnostics use the analytic
Jacobian and a singular-value factorization. Sampled-stamp fits stay on the
NumPy or CuPy backends. The Tile backend requires analytic derivatives and
rejects finite-difference fitting.

Pass `fusion=True` to `fit_dipoles`, or add `--fusion` to
`cuphoton xfit fit-dipoles --backend cutile`, to fuse Gaussian residual and
analytic-Jacobian evaluation, including weighting. Width powers and
normal-equation contractions use CuPy to preserve the reference arithmetic.
Each fit owns its workspace and uses the caller's current
CuPy stream. The default remains
`fusion=False`; CLI artifacts record the selected option in the effective
configuration.

The `cutile` extra installs cuda-tile 1.6 or newer and CuPy. Kernel compilation
also requires a compatible CUDA toolkit. Follow the shared
[cuTile compiler setup](../getting-started.md#optional-runtimes) to configure
Expand Down
21 changes: 17 additions & 4 deletions examples/xfit/benchmark_gaussian.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,9 @@ def _fixture(
return images, initial


def _initialize(images, initial, mode, workers, counter, barrier, gpu):
def _initialize(
images, initial, mode, workers, counter, barrier, gpu, fusion
):
with counter.get_lock():
index = counter.value
counter.value += 1
Expand All @@ -72,6 +74,7 @@ def _initialize(images, initial, mode, workers, counter, barrier, gpu):
_worker.initial = initial[start:stop].copy()
_worker.variance = np.ones_like(_worker.images)
_worker.mode = mode
_worker.fusion = fusion
_worker.barrier = barrier
_worker.stream = None
_worker.pool = None
Expand Down Expand Up @@ -113,6 +116,7 @@ def _run(treatment):
variance=_worker.variance,
mode=_worker.mode,
backend=backend,
fusion=_worker.fusion and backend == "cutile",
)
seconds = time.perf_counter() - start
if result.backend != backend:
Expand Down Expand Up @@ -214,6 +218,9 @@ def main() -> None:
default=180,
help="Per-round wait, including cold compilation (default: 180).",
)
parser.add_argument(
"--fusion", action="store_true", help="Use fused cutile evaluation."
)
args = parser.parse_args()
if not math.isfinite(args.timeout_seconds) or args.timeout_seconds <= 0:
parser.error("--timeout-seconds must be positive and finite")
Expand All @@ -228,6 +235,8 @@ def main() -> None:
if args.compare_native and args.backend:
parser.error("--compare-native cannot be combined with --backend")
backends = args.backend or ["cupy", "cutile"]
if args.fusion and backends != ["cutile"]:
parser.error("--fusion requires --backend cutile")
batches = args.batch or [1, 16, 256, 4096]
if any(batch < args.workers for batch in batches):
parser.error("every batch must contain at least --workers candidates")
Expand All @@ -245,9 +254,11 @@ def main() -> None:
os.sched_setaffinity(0, allowed[: args.cpus])
allowed = sorted(os.sched_getaffinity(0))
treatments = ["cupy", "native"] if args.compare_native else backends
# Native comparisons always validate against CuPy, including when only
# native timings were requested. Reference work is outside measurements.
reference = "cupy" if "native" in treatments else treatments[0]
# Fused and native comparisons validate against independent CuPy fits.
# Reference work is outside measurements.
reference = (
"cupy" if args.fusion or "native" in treatments else treatments[0]
)
dtype = np.dtype(args.dtype)
context = multiprocessing.get_context("spawn")
for batch in batches:
Expand All @@ -274,6 +285,7 @@ def main() -> None:
context.Value("i", 0),
context.Barrier(args.workers),
any(backend != "numpy" for backend in treatments),
args.fusion,
),
**executor_options,
) as pool:
Expand Down Expand Up @@ -345,6 +357,7 @@ def main() -> None:
json.dumps(
{
"backend": treatment,
"fusion": args.fusion,
"execution": args.execution,
"workers": args.workers,
"batch": batch,
Expand Down
Loading
Loading