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
71 changes: 69 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,9 @@ jobs:
tests/xfit/test_models.py::test_cupy_finite_difference_theta_fix_matches_numpy
tests/xfit/test_models.py::test_fit_dipoles_device_matches_portable_result_without_bulk_d2h
tests/xfit/test_solver.py::test_cupy_tall_jacobian_diagnostics_match_numpy
tests/xfit/test_models.py::test_fp32_public_covariance_matches_with_variance
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
- name: Require all selected tests to execute
run: |
uv run --no-sync python - <<'PY'
Expand All @@ -166,11 +169,11 @@ jobs:

report = ET.parse(Path(os.environ["RUNNER_TEMP"]) / "xfit-gpu.xml")
cases = report.findall(".//testcase")
assert len(cases) == 6, len(cases)
assert len(cases) == 13, len(cases)
assert not report.findall(".//skipped"), "GPU tests must not skip"
assert not report.findall(".//failure")
assert not report.findall(".//error")
print("All 6 selected tests passed without skips")
print("All 13 selected tests passed without skips")
PY
- name: Run native xFit GPU tests
run: >-
Expand All @@ -196,6 +199,66 @@ 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: Upload test evidence
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
Expand All @@ -204,6 +267,10 @@ 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 }}/cuphoton-quickstart/**/summary.json
if-no-files-found: error
retention-days: 14
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
13 changes: 12 additions & 1 deletion 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,
)
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 Down Expand Up @@ -274,6 +283,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 +355,7 @@ def main() -> None:
json.dumps(
{
"backend": treatment,
"fusion": args.fusion,
"execution": args.execution,
"workers": args.workers,
"batch": batch,
Expand Down
Loading
Loading