From da03d27ad234cd9d8f73c4a7b19ae0ccc499461a Mon Sep 17 00:00:00 2001 From: HatPdotS Date: Fri, 24 Jul 2026 14:34:15 +0900 Subject: [PATCH 01/15] Fixed inconsistent data access routines. --- docs/changelog.rst | 4 + pyproject.toml | 2 +- ...st_cli_difference_refine_mismatched_mtz.py | 110 ++++++ tests/unit/io/test_reflection_data_reindex.py | 148 ++++++++ torchref/__init__.py | 2 +- torchref/io/datasets/reflection_data.py | 334 ++++++++++++------ 6 files changed, 498 insertions(+), 102 deletions(-) create mode 100644 tests/integration/test_cli_difference_refine_mismatched_mtz.py create mode 100644 tests/unit/io/test_reflection_data_reindex.py diff --git a/docs/changelog.rst b/docs/changelog.rst index 4e4d40f..7660f6a 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -1,6 +1,10 @@ Changelog ========= +Version 0.6.2 +------------- +- Fixed crash in difference refinement with mismatched reflection files: HKL reindexing (``validate_hkl``/``remap``/``reduce_to_spacegroup``) now carries all per-reflection fields (including the anomalous bookkeeping read by ``hkl_for_sf()``) instead of a hardcoded subset. + Version 0.6.1 ------------- - Fixed bug in beta estimation that caused instability in GPU refinement diff --git a/pyproject.toml b/pyproject.toml index 1ecb1db..22107c0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "torchref" -version = "0.6.1" +version = "0.6.2" description = "Pytorch based crystallographic refinement" readme = "README.md" requires-python = ">=3.10" diff --git a/tests/integration/test_cli_difference_refine_mismatched_mtz.py b/tests/integration/test_cli_difference_refine_mismatched_mtz.py new file mode 100644 index 0000000..a1a2723 --- /dev/null +++ b/tests/integration/test_cli_difference_refine_mismatched_mtz.py @@ -0,0 +1,110 @@ +"""CPU end-to-end test for ``torchref.difference-refine`` with MISMATCHED MTZ files. + +Difference refinement builds a ``DatasetCollection`` from two independent MTZ +files and aligns the light dataset onto the dark reference via +``ReflectionData.validate_hkl``. When the two files had different reflection +sets, alignment left ``hkl_anomalous`` (read by ``hkl_for_sf``) at the +pre-alignment length and the run crashed inside the collection difference target +(``RuntimeError: The size of tensor a (...) must match the size of tensor b``). + +This test generates two MTZ files with genuinely different reflection sets from +the smallest fixture (3GR5) and asserts the CLI now completes. It is the +end-to-end guard for the 0.6.2 fix; pre-fix it exited non-zero. + +Wall-clock budget: a couple of minutes on CPU (1 macro-cycle, 1 step). +""" + +import json +import subprocess +import sys +from pathlib import Path + +import pytest + + +@pytest.fixture +def diff_refine_cli_script(project_root) -> Path: + script = project_root / "torchref" / "cli" / "collection_difference_refine.py" + if not script.exists(): + pytest.skip(f"difference-refine CLI script not found: {script}") + return script + + +@pytest.fixture +def mismatched_mtz_pair(test_files_dir, tmp_path): + """Two MTZ files sharing a cell/spacegroup but with different reflections. + + Built from 3GR5: ``dark`` drops the last 10% of reflections, ``light`` drops + the first 15%, so each contains reflections the other lacks and the two + counts differ -- exactly the condition that triggered the crash. + """ + pdb_file = test_files_dir / "pdb" / "3GR5.pdb" + mtz_file = test_files_dir / "mtz" / "3GR5.mtz" + if not pdb_file.exists() or not mtz_file.exists(): + pytest.skip("3GR5 test files not found") + + import torch + + from torchref.io.datasets.reflection_data import ReflectionData + + full = ReflectionData(device="cpu", verbose=0).load_mtz(str(mtz_file)) + n = len(full.hkl) + idx = torch.arange(n) + dark = full.__select__(idx < int(n * 0.90)) + light = full.__select__(idx >= int(n * 0.15)) + assert len(dark.hkl) != len(light.hkl), "subsets must differ in size" + + dark_mtz = tmp_path / "dark.mtz" + light_mtz = tmp_path / "light.mtz" + dark.write_mtz(str(dark_mtz)) + light.write_mtz(str(light_mtz)) + return {"pdb": pdb_file, "dark": dark_mtz, "light": light_mtz} + + +@pytest.mark.integration +def test_difference_refine_mismatched_mtz_cpu( + diff_refine_cli_script, mismatched_mtz_pair, tmp_path +): + outdir = tmp_path / "diff_out" + result = subprocess.run( + [ + sys.executable, + str(diff_refine_cli_script), + "-dm", str(mismatched_mtz_pair["pdb"]), + "-lm", str(mismatched_mtz_pair["pdb"]), + "-dsf", str(mismatched_mtz_pair["dark"]), + "-lsf", str(mismatched_mtz_pair["light"]), + "--fraction", "0.3", + "--n-cycles", "1", + "--n-steps", "1", + "--max-iter", "5", + "-o", str(outdir), + "--device", "cpu", + "--verbose", "1", + ], + capture_output=True, + text=True, + timeout=1800, + ) + + if result.returncode != 0: + print("STDOUT:", result.stdout[-3000:]) + print("STDERR:", result.stderr[-3000:]) + + assert result.returncode == 0, ( + f"difference-refine exited with {result.returncode} on mismatched MTZ " + f"files. stderr tail: {result.stderr[-800:]}" + ) + + # The exact stale-tensor shape mismatch must not reappear. + assert "must match the size of tensor" not in result.stderr + + prefix = "fractions_70_30" + summary = outdir / f"{prefix}_summary.json" + diff_mtz = outdir / f"{prefix}_difference_data.mtz" + assert summary.exists(), "summary JSON not written" + assert diff_mtz.exists(), "difference MTZ not written" + + with open(summary) as f: + data = json.load(f) + assert "results" in data and "r_factor_light" in data["results"] diff --git a/tests/unit/io/test_reflection_data_reindex.py b/tests/unit/io/test_reflection_data_reindex.py new file mode 100644 index 0000000..b5a1243 --- /dev/null +++ b/tests/unit/io/test_reflection_data_reindex.py @@ -0,0 +1,148 @@ +"""Regression tests for per-reflection field reindexing. + +``validate_hkl`` / ``remap`` / ``reduce_to_spacegroup`` must carry EVERY +per-reflection field onto the new HKL grid, not a hand-maintained subset. The +historical bug left ``hkl_anomalous`` (read by ``hkl_for_sf``) at the +pre-alignment length, which crashed difference refinement whenever the dark and +light datasets had different reflection sets. See docs/changelog.rst 0.6.2. +""" + +import pytest +import torch + +from torchref.io.datasets.reflection_data import ReflectionData + + +def _base_grid(h=10, k=10, lmax=10): + """A block of Miller indices with strictly-positive l (already in the ASU).""" + hs = torch.arange(-h, h + 1) + ks = torch.arange(-k, k + 1) + ls = torch.arange(1, lmax + 1) + return ( + torch.stack(torch.meshgrid(hs, ks, ls, indexing="ij"), dim=-1) + .reshape(-1, 3) + .to(torch.int32) + ) + + +def _synthetic(hkl, seed=0, device="cpu"): + n = hkl.shape[0] + g = torch.Generator().manual_seed(seed) + F = torch.rand(n, generator=g) * 100.0 + 1.0 + return ReflectionData.from_tensors( + hkl, + F, + F * 0.1, + (60.0, 60.0, 60.0, 90.0, 90.0, 90.0), + "P 21 21 21", + device=device, + verbose=0, + ) + + +class TestValidateHklReindex: + """The reported crash lives in validate_hkl (collection HKL alignment).""" + + def test_carries_all_per_reflection_fields(self): + grid = _base_grid() + n = grid.shape[0] + # ``light`` lacks the last 10%; the reference grid lacks the first 10%, + # so the two sets genuinely differ (each has reflections the other lacks). + light = _synthetic(grid[: int(n * 0.9)], seed=1) + ref_hkl = grid[int(n * 0.1):].clone() + assert len(light.hkl_anomalous) == len(light.hkl) # sane before + + light.validate_hkl(ref_hkl) + + m = len(light.hkl) + assert m == len(ref_hkl) + # The field the old code left stale (the direct cause of the crash): + assert light.hkl_anomalous.shape[0] == m + assert light.hkl_for_sf().shape[0] == m + # Derived-from-HKL fields recompute for the new grid: + assert light.centric.shape[0] == m + assert light.friedel_flags.shape[0] == m + # Global invariant: no per-reflection tensor left at the old length. + light._assert_per_reflection_consistent() + + def test_identical_hkl_preserves_count(self): + grid = _base_grid(6, 6, 6) + d = _synthetic(grid, seed=2) + n0 = len(d.hkl) + d.validate_hkl(d.hkl.clone()) + assert len(d.hkl) == n0 + d._assert_per_reflection_consistent() + + +class TestP1RoundTripReindex: + """The same class of bug lived latently in remap/expand_to_p1 and + reduce_to_spacegroup (silent data loss rather than a crash).""" + + def test_expand_to_p1_carries_validation_flags(self): + grid = _base_grid(6, 6, 6) + d = _synthetic(grid, seed=3) + d.generate_validation_set(val_fraction_of_free=0.5, seed=0) + assert d.validation_flags is not None + + p1 = d.expand_to_p1() + # validation_flags used to be dropped to None by remap; now carried. + assert p1.validation_flags is not None + assert p1.validation_flags.shape[0] == len(p1.hkl) + p1._assert_per_reflection_consistent() + + def test_reduce_to_spacegroup_consistent(self): + grid = _base_grid(6, 6, 6) + d = _synthetic(grid, seed=4) + p1 = d.expand_to_p1() + back = p1.reduce_to_spacegroup("P 21 21 21") + back._assert_per_reflection_consistent() + + +@pytest.mark.integration +class TestCollectionDifferenceMismatchedHKL: + """In-process reproduction of the reported failure: a DatasetCollection + built from two different reflection sets, run through the collection + difference target's forward (the exact call site that crashed).""" + + def test_forward_finite_with_different_reflection_sets(self, pdb_dir, mtz_dir): + pdb = pdb_dir / "3GR5.pdb" + mtz = mtz_dir / "3GR5.mtz" + if not (pdb.exists() and mtz.exists()): + pytest.skip("3GR5 fixture not present") + + from torchref.cli._common import load_model + from torchref.config import get_default_device + from torchref.io.datasets.collection import DatasetCollection + from torchref.io.datasets.reflection_data import ReflectionData + from torchref.model.model_collection import ModelCollection + from torchref.refinement.targets.collection import CollectionDifferenceTarget + from torchref.scaling.collection_scaler import CollectionScaler + + dev = get_default_device() + full = ReflectionData(device=dev, verbose=0).load_mtz(str(mtz)) + n = len(full.hkl) + idx = torch.arange(n, device=full.hkl.device) + # Two DIFFERENT reflection sets with DIFFERENT counts (drops the last + # 10% vs the first 15%) -> reproduces the shape-mismatch crash pre-fix. + dark = full.__select__(idx < int(n * 0.90)) + light = full.__select__(idx >= int(n * 0.15)) + assert len(dark.hkl) != len(light.hkl) + + dc = DatasetCollection(device=dev, verbose=0) + dc.add_dataset("dark", dark, set_as_reference=True) + dc.add_dataset("light", light) # -> validate_hkl aligns onto dark grid + + # High-resolution limit for the model FFT grid (covers the data). + d_min = float(full.resolution.min()) + model_dark = load_model(str(pdb), max_res=d_min, device=dev, verbose=0) + model_light = load_model(str(pdb), max_res=d_min, device=dev, verbose=0) + mc = ModelCollection([model_dark, model_light], dark_key="dark", verbose=0) + mc.add_dark() + mc.add_timepoint("light", [0.7, 0.3]) + + scaler = CollectionScaler(dc, mc, verbose=0) + scaler.initialize() + + target = CollectionDifferenceTarget(dc, mc, scaler=scaler, verbose=0) + loss = target.forward() + assert torch.isfinite(loss) diff --git a/torchref/__init__.py b/torchref/__init__.py index 092bfef..2ff89bb 100644 --- a/torchref/__init__.py +++ b/torchref/__init__.py @@ -51,7 +51,7 @@ General utilities and debugging tools. """ -__version__ = "0.6.1" +__version__ = "0.6.2" import os diff --git a/torchref/io/datasets/reflection_data.py b/torchref/io/datasets/reflection_data.py index 4fb74f6..e3b052f 100644 --- a/torchref/io/datasets/reflection_data.py +++ b/torchref/io/datasets/reflection_data.py @@ -331,6 +331,153 @@ def _tv(t): self._corrected_fp = fp return self._corrected_cache + # ===================== per-reflection field reindexing ===================== + # + # Any routine that changes the HKL set (expand onto a reference grid, remap, + # symmetry reduction) must carry EVERY per-reflection field along, not a + # hand-maintained subset. Historically ``validate_hkl`` / ``remap`` / + # ``reduce_to_spacegroup`` each hardcoded their own field list and silently + # dropped fields added later (notably ``hkl_anomalous``, read by + # ``hkl_for_sf``), which broke difference refinement on mismatched reflection + # files. The single source of truth below is shared by all of them. + + # Fill value used for MISSING output rows (index == -1) per field. Missing + # rows are always masked out downstream (``hkl_present`` / ``missing``), so + # these fills only need to be shape-correct and non-poisonous. Fields not + # listed default to 0. NOTE: ``hkl_anomalous`` is special-cased (filled with + # the reference HKL row, never 0 which would be a spurious Miller index) and + # the derived-from-HKL fields below are recomputed, never gathered. + _REINDEX_FILL = { + "F": 0.0, + "I": 0.0, + "phase": 0.0, + "fom": 0.0, + "E": 0.0, + "E_squared": 0.0, + "F_squared_corrected": 0.0, + "radial_shell_indices": 0, + "F_sigma": 1.0, + "I_sigma": 1.0, + "rfree_flags": 1, # missing reflections default to the work set + "friedel_flags": False, + "validation_flags": False, + "outlier_flags": False, + } + + # Per-reflection fields that are pure functions of (hkl, cell, spacegroup): + # never gathered/aggregated, always recomputed or invalidated after an HKL + # change (``resolution`` recomputed; ``bin_indices`` / ``_centric_flags`` + # lazily rebuilt by ``get_bins`` / the ``centric`` property). + _REINDEX_DERIVED = ("resolution", "bin_indices", "_centric_flags") + + # Tensor dataclass fields that are NOT per-reflection (exempt from the + # length invariant): overall anisotropy parameters have shape (6,). + _NON_PER_REFLECTION_TENSORS = frozenset({"U_aniso"}) + + def _reindex_per_reflection( + self, + index_map: torch.Tensor, + new_hkl: torch.Tensor, + target: Optional["ReflectionData"] = None, + ) -> torch.Tensor: + """Gather every per-reflection field from ``self`` onto ``new_hkl``. + + Enumerates dataclass fields generically (same ``shape[0] == n`` guard as + :meth:`__select__` / :meth:`_canonicalize_in_place`) so any current or + future per-reflection field is carried automatically. + + Parameters + ---------- + index_map : torch.Tensor + LongTensor of length ``len(new_hkl)`` mapping each output row to a + source row index into ``self``, or ``-1`` for reflections absent + from the source (filled per :attr:`_REINDEX_FILL`). + new_hkl : torch.Tensor + Miller indices for the reindexed dataset, shape ``(M, 3)``. + target : ReflectionData, optional + Object to write the gathered fields onto. Defaults to ``self`` + (in-place). Pass a fresh instance for out-of-place reindexing + (``remap``); the caller is responsible for setting ``target.cell`` / + ``target.spacegroup`` before calling so resolution can be recomputed. + + Returns + ------- + torch.Tensor + Boolean presence mask (``index_map >= 0``), for the caller's use in + building ``hkl_present`` / ``missing`` masks. + """ + from dataclasses import fields as dc_fields + + if target is None: + target = self + + n_src = len(self.hkl) if self.hkl is not None else 0 + new_hkl = new_hkl.to(dtype=dtypes.int, device=self.device) + n_out = len(new_hkl) + index_map = index_map.to(device=self.device, dtype=torch.long) + present = index_map >= 0 + src_idx = index_map[present] + + derived = set(self._REINDEX_DERIVED) + for f in dc_fields(self): + name = f.name + if name == "hkl" or name in derived: + continue + val = getattr(self, name) + if not isinstance(val, torch.Tensor): + continue + if not (val.shape and val.shape[0] == n_src): + # Non-per-reflection tensor (e.g. U_aniso (6,)): leave target's + # own value untouched (fresh default when target is new). + continue + if name == "hkl_anomalous": + # Present rows keep their signed (anomalous) index; missing rows + # fall back to the canonical reference HKL (never a 0,0,0 row). + out = new_hkl.clone() + out[present] = val[src_idx] + else: + fill = self._REINDEX_FILL.get(name, 0) + out = torch.full( + (n_out,) + tuple(val.shape[1:]), + fill, + dtype=val.dtype, + device=self.device, + ) + out[present] = val[src_idx] + setattr(target, name, out) + + # Install the new HKL and recompute / invalidate derived-from-HKL fields. + target.hkl = new_hkl + target.bin_indices = None + target._centric_flags = None + if target.cell is not None: + target._calculate_resolution() + else: + target.resolution = None + return present + + def _assert_per_reflection_consistent(self) -> None: + """Invariant: every per-reflection tensor matches ``len(self.hkl)``. + + A cheap post-condition for the reindex routines. Turns any future + hardcoded-list regression (a field silently left at the old length) into + a loud failure instead of a downstream shape mismatch. + """ + from dataclasses import fields as dc_fields + + n = len(self.hkl) if self.hkl is not None else 0 + bad = [] + for f in dc_fields(self): + if f.name in self._NON_PER_REFLECTION_TENSORS: + continue + val = getattr(self, f.name) + if isinstance(val, torch.Tensor) and val.ndim >= 1 and val.shape[0] != n: + bad.append((f.name, tuple(val.shape))) + if bad: + raise RuntimeError( + f"ReflectionData per-reflection length mismatch (n_hkl={n}): {bad}" + ) + def _canonicalize_in_place(self) -> None: """Remap HKL to canonical CCP4 ASU form and reorder all data in-place.""" from dataclasses import fields as dc_fields @@ -2223,63 +2370,15 @@ def validate_hkl(self, hkl_ref: torch.Tensor) -> "ReflectionData": [data_hkl_to_idx.get(tuple(hkl), -1) for hkl in hkl_ref_np], dtype=np.int64 ) - # Create presence mask: True where data exists - presence_mask = torch.from_numpy(ref_to_data_idx >= 0).to(device=self.device) + # Index map into this dataset for each reference row (-1 where missing). valid_indices = torch.from_numpy(ref_to_data_idx).to(device=self.device) - # Helper to expand a tensor to reference size - def expand_tensor(tensor, fill_value=0.0): - if tensor is None: - return None - expanded = torch.full( - (n_ref,) + tensor.shape[1:], - fill_value, - dtype=tensor.dtype, - device=self.device, - ) - # Copy existing data to correct positions - mask = valid_indices >= 0 - expanded[mask] = tensor[valid_indices[mask]] - return expanded - - # Expand all data arrays - old_F = self.F - old_F_sigma = self.F_sigma - old_I = self.I - old_I_sigma = getattr(self, "I_sigma", None) - old_rfree = self.rfree_flags - old_phase = getattr(self, "phase", None) - old_fom = getattr(self, "fom", None) - - # Replace HKL with reference - self.hkl = hkl_ref - - # Expand data tensors - self.F = expand_tensor(old_F, fill_value=0.0) - self.F_sigma = expand_tensor(old_F_sigma, fill_value=1.0) - - if old_I is not None: - self.I = expand_tensor(old_I, fill_value=0.0) - if old_I_sigma is not None: - self.I_sigma = expand_tensor(old_I_sigma, fill_value=1.0) - - # For rfree, default missing to work set (1) - if old_rfree is not None: - rfree_expanded = torch.ones( - n_ref, dtype=old_rfree.dtype, device=self.device - ) - mask = valid_indices >= 0 - rfree_expanded[mask] = old_rfree[valid_indices[mask]] - self.rfree_flags = rfree_expanded - - # Recalculate resolution for new HKL set - self._calculate_resolution() - - # Expand phase and fom if present - if old_phase is not None: - self.phase = expand_tensor(old_phase, fill_value=0.0) - if old_fom is not None: - self.fom = expand_tensor(old_fom, fill_value=0.0) + # Reindex EVERY per-reflection field onto the reference grid via the + # shared primitive (carries hkl_anomalous / friedel_flags / centric / + # validation / outlier that this routine used to silently drop, which + # broke difference refinement on mismatched reflection files). Masks are + # handled separately below because they are not dataclass fields. + presence_mask = self._reindex_per_reflection(valid_indices, hkl_ref) # Transfer existing masks to new indexing old_masks = dict(self.masks.items()) @@ -2308,6 +2407,7 @@ def expand_tensor(tensor, fill_value=0.0): print(f" Present in data: {n_present} ({100*n_present/n_ref:.1f}%)") print(f" Missing (masked): {n_missing} ({100*n_missing/n_ref:.1f}%)") + self._assert_per_reflection_consistent() return self def find_outliers( @@ -3046,8 +3146,9 @@ def remap( """ from torchref.symmetry.spacegroup import SpaceGroup - # Helper function for remapping tensors with missing handling - def _remap_tensor(tensor, fill_value): + # Mask remapper. Masks are not dataclass fields, so the shared + # per-reflection reindexer below does not touch them. + def _remap_mask(tensor, fill_value): if tensor is None: return None valid_mask = index_mapping >= 0 @@ -3060,56 +3161,36 @@ def _remap_tensor(tensor, fill_value): result[valid_mask] = tensor[index_mapping[valid_mask]] return result - # Create new ReflectionData + # Create new ReflectionData; set cell/spacegroup first so the shared + # reindexer can recompute resolution on the new grid. remapped = ReflectionData(verbose=self.verbose, device=self.device) - - # Set new HKL - remapped.hkl = new_hkl.to(device=self.device) - - # Remap amplitude and intensity fields - remapped.F = _remap_tensor(self.F, fill_value=0.0) - remapped.F_sigma = _remap_tensor(self.F_sigma, fill_value=1.0) - remapped.I = _remap_tensor(self.I, fill_value=0.0) - remapped.I_sigma = _remap_tensor(self.I_sigma, fill_value=1.0) - remapped.fom = _remap_tensor(self.fom, fill_value=0.0) - - # Carry forward prior mask if available - prior_mask = self.masks() - if prior_mask is not None: - remapped.masks["prior_flagged"] = _remap_tensor( - prior_mask.to(dtype=dtypes.int), fill_value=0 - ).to(torch.bool) - - # Handle rfree_flags (True = include in Rfree for missing) - if self.rfree_flags is not None: - remapped.rfree_flags = _remap_tensor( - self.rfree_flags.to(dtype=dtypes.int), fill_value=1 - ).to(torch.bool) - - # Handle phases with optional shifts - if self.phase is not None: - remapped.phase = _remap_tensor(self.phase, fill_value=0.0) - if phase_shifts is not None: - remapped.phase = remapped.phase + phase_shifts.to(device=self.device) - elif phase_shifts is not None: - # Store phase shifts even if no original phases - remapped._expansion_phase_shifts = phase_shifts.to(device=self.device) - - # Clone cell remapped.cell = self.cell.clone() if self.cell is not None else None - - # Set spacegroup if spacegroup is not None: remapped.spacegroup = SpaceGroup(spacegroup) else: remapped.spacegroup = self.spacegroup - # Recalculate resolution - if remapped.cell is not None and remapped.hkl is not None: - remapped._calculate_resolution() + # Reindex ALL per-reflection dataclass fields onto new_hkl. This carries + # the anomalous / validation / centric / outlier fields the old hardcoded + # list silently dropped, and sets hkl / resolution while invalidating + # bin_indices and _centric_flags. Missing rows (index -1) get the + # per-field fills in _REINDEX_FILL. + self._reindex_per_reflection(index_mapping, new_hkl, target=remapped) - # Invalidate dependent fields - remapped.bin_indices = None + # Apply optional phase shifts (e.g. from symmetry translations). + if remapped.phase is not None: + if phase_shifts is not None: + remapped.phase = remapped.phase + phase_shifts.to(device=self.device) + elif phase_shifts is not None: + # No original phases: store the shifts for later phase reconstruction. + remapped._expansion_phase_shifts = phase_shifts.to(device=self.device) + + # Carry forward prior combined mask if available. + prior_mask = self.masks() + if prior_mask is not None: + remapped.masks["prior_flagged"] = _remap_mask( + prior_mask.to(dtype=dtypes.int), fill_value=0 + ).to(torch.bool) # Copy metadata sources remapped.amplitude_source = self.amplitude_source @@ -3126,6 +3207,7 @@ def _remap_tensor(tensor, fill_value): if missing_mask.any(): remapped.masks["missing"] = ~missing_mask.to(device=self.device) + remapped._assert_per_reflection_consistent() return remapped def fill(self, d_min: Optional[float] = None) -> "ReflectionData": @@ -3440,6 +3522,54 @@ def _aggregate_sigma(tensor, agg_func="mean"): # 0 = free, non-zero = work. Take min to get free if any is free. reduced.rfree_flags = rfree_gathered.min(dim=1).values != 0 + # Boolean per-reflection flags: an equivalent's flag propagates to the + # merged reflection if ANY contributor has it set (validation/outlier + # are conservative "exclude if any"). + def _aggregate_any(tensor): + if tensor is None: + return None + gathered = tensor[reduction_indices.clamp(min=0)].to(torch.bool) + gathered = gathered & valid_mask + return gathered.any(dim=1) + + if self.validation_flags is not None: + reduced.validation_flags = _aggregate_any(self.validation_flags) + if self.outlier_flags is not None: + reduced.outlier_flags = _aggregate_any(self.outlier_flags) + + # Completeness pass: carry any remaining per-reflection dataclass tensor + # field not handled above so the merge never silently drops data (the + # historical bug). Derived-from-HKL fields are recomputed/invalidated + # below, not aggregated; 'first' is a safe representative for the rest. + from dataclasses import fields as dc_fields + + _already_set = { + "hkl", + "F", + "F_sigma", + "I", + "I_sigma", + "phase", + "fom", + "rfree_flags", + "validation_flags", + "outlier_flags", + } + _recomputed = set(self._REINDEX_DERIVED) | {"hkl_anomalous", "friedel_flags"} + n_src = len(self.hkl) + for f in dc_fields(self): + name = f.name + if name in _already_set or name in _recomputed: + continue + if name in self._NON_PER_REFLECTION_TENSORS: + continue + val = getattr(self, name) + if not isinstance(val, torch.Tensor): + continue + if not (val.shape and val.shape[0] == n_src): + continue + setattr(reduced, name, _aggregate_tensor(val, "first")) + # Clone cell reduced.cell = self.cell.clone() if self.cell is not None else None @@ -3450,8 +3580,11 @@ def _aggregate_sigma(tensor, agg_func="mean"): if reduced.cell is not None and reduced.hkl is not None: reduced._calculate_resolution() - # Invalidate dependent fields + # Invalidate derived-from-HKL fields (recomputed lazily for the new ASU). + # hkl_anomalous / friedel_flags are left unset: the merged ASU is + # Friedel-merged, so hkl_for_sf() correctly falls back to hkl. reduced.bin_indices = None + reduced._centric_flags = None # Copy metadata sources reduced.amplitude_source = self.amplitude_source @@ -3465,6 +3598,7 @@ def _aggregate_sigma(tensor, agg_func="mean"): f"reduce_to_spacegroup({spacegroup}, aggregation={aggregation})" ) + reduced._assert_per_reflection_consistent() return reduced def canonicalize(self, include_friedel: bool = True) -> "ReflectionData": From bab30dff4471f6a6443d47039960869a1566e11f Mon Sep 17 00:00:00 2001 From: HatPdotS Date: Fri, 24 Jul 2026 16:46:56 +0900 Subject: [PATCH 02/15] Added mps shader for electorn density calculation --- tests/integration/test_variable_radius_mps.py | 198 ++++++++ tests/unit/base/test_kernel_import_shims.py | 6 + tests/unit/base/test_max_eig_sym3.py | 72 +++ .../electron_density/kernels/mps/__init__.py | 25 + .../electron_density/kernels/mps/_shaders.py | 434 ++++++++++++++++++ .../electron_density/kernels/mps/compile.py | 87 ++++ .../kernels/mps/variable_radius.py | 192 ++++++++ torchref/base/electron_density/main.py | 72 ++- .../base/electron_density/radius_policy.py | 46 +- 9 files changed, 1118 insertions(+), 14 deletions(-) create mode 100644 tests/integration/test_variable_radius_mps.py create mode 100644 tests/unit/base/test_max_eig_sym3.py create mode 100644 torchref/base/electron_density/kernels/mps/__init__.py create mode 100644 torchref/base/electron_density/kernels/mps/_shaders.py create mode 100644 torchref/base/electron_density/kernels/mps/compile.py create mode 100644 torchref/base/electron_density/kernels/mps/variable_radius.py diff --git a/tests/integration/test_variable_radius_mps.py b/tests/integration/test_variable_radius_mps.py new file mode 100644 index 0000000..94d198b --- /dev/null +++ b/tests/integration/test_variable_radius_mps.py @@ -0,0 +1,198 @@ +"""MPS variable-radius density path: native Metal kernels (Engine.AUTO) vs the +portable plain-scatter reference (Engine.EAGER), on the same MPS device. + +Both truncate each atom at its own per-atom radius, so the forward maps must +agree to float32 + truncation-shape tolerance and the gradients (xyz / adp / u / +occ) must be parallel. Requires an Apple-silicon GPU (MPS); skipped elsewhere. + +Markers: ``@pytest.mark.gpu`` (skipped without ``--run-gpu``), ``integration``. +""" + +import math + +import pytest +import torch + +from torchref.base.electron_density.kernels.mps import mps_kernels_available +from torchref.base.electron_density.main import build_electron_density +from torchref.utils import Engine, use_engine + +pytestmark = [pytest.mark.gpu, pytest.mark.integration] + + +@pytest.fixture +def mps_device(gpu_device): + if gpu_device.type != "mps": + pytest.skip("MPS-specific test (Metal kernels)") + if not mps_kernels_available(): + pytest.skip("Metal splat kernels failed to compile") + return gpu_device + + +def _cell(): + a, b, c = 30.0, 25.0, 20.0 + nx, ny, nz = 60, 50, 40 + frac = torch.diag(torch.tensor([a, b, c])) + inv_frac = torch.diag(torch.tensor([1 / a, 1 / b, 1 / c])) + voxel = torch.tensor([a / nx, b / ny, c / nz]) + ii, jj, kk = torch.meshgrid( + torch.arange(nx), torch.arange(ny), torch.arange(nz), indexing="ij" + ) + rsg = (torch.stack([ii / nx, jj / ny, kk / nz], -1).to(torch.float32)) @ frac.T + return (a, b, c), (nx, ny, nz), frac.float(), inv_frac.float(), voxel.float(), rsg + + +def _cell_monoclinic(): + """Non-orthogonal (beta ~ 100 deg) cell: exercises the per-axis box and the + off-diagonal coordinate math (u_c gains an x-component).""" + a, b, c = 30.0, 25.0, 20.0 + nx, ny, nz = 60, 50, 40 + beta = math.radians(100.0) + frac = torch.tensor( + [[a, 0.0, c * math.cos(beta)], + [0.0, b, 0.0], + [0.0, 0.0, c * math.sin(beta)]], dtype=torch.float64) + inv_frac = torch.linalg.inv(frac) + voxel = (frac.norm(dim=0) / torch.tensor([nx, ny, nz], dtype=torch.float64)).float() + ii, jj, kk = torch.meshgrid( + torch.arange(nx), torch.arange(ny), torch.arange(nz), indexing="ij" + ) + fc = torch.stack([ii / nx, jj / ny, kk / nz], -1).double() + rsg = (fc @ frac.T).float() + return (a, b, c), (nx, ny, nz), frac.float(), inv_frac.float(), voxel, rsg + + +def _iso_atoms(cell, n=60, seed=0): + g = torch.Generator().manual_seed(seed) + a, b, c = cell + xyz = torch.rand(n, 3, generator=g) * torch.tensor([a, b, c]) + adp = torch.rand(n, generator=g) * 35 + 3 + occ = torch.ones(n) + A = torch.rand(n, 5, generator=g) * 5 + B = torch.rand(n, 5, generator=g) * 20 + 2 + return [t.float() for t in (xyz, adp, occ, A, B)] + + +def _aniso_atoms(cell, n=30, seed=1): + g = torch.Generator().manual_seed(seed) + a, b, c = cell + xyz = torch.rand(n, 3, generator=g) * torch.tensor([a, b, c]) + u = torch.zeros(n, 6) + u[:, :3] = torch.rand(n, 3, generator=g) * 0.12 + 0.02 + occ = torch.ones(n) + A = torch.rand(n, 5, generator=g) * 5 + B = torch.rand(n, 5, generator=g) * 20 + 2 + return [t.float() for t in (xyz, u, occ, A, B)] + + +def _cos(a, b): + a, b = a.reshape(-1).double(), b.reshape(-1).double() + return float((a @ b) / (a.norm() * b.norm() + 1e-12)) + + +def _rel_map(x, y): + return float((x - y).abs().max() / (y.abs().max() + 1e-8)) + + +def _to(dev, *ts): + return [t.to(dev) for t in ts] + + +def test_iso_metal_matches_plain(mps_device): + _, grid, frac, inv_frac, voxel, rsg = _cell() + xyz, adp, occ, A, B = _to(mps_device, *_iso_atoms(_cell()[0])) + rsg, frac, inv_frac, voxel = _to(mps_device, rsg, frac, inv_frac, voxel) + + def run(engine): + with use_engine(engine): + return build_electron_density(rsg, xyz, adp, occ, A, B, inv_frac, frac, voxel) + + ref = run(Engine.EAGER) # portable plain splat + got = run(Engine.AUTO) # Metal + assert _rel_map(got.cpu(), ref.cpu()) < 2e-2 + assert _cos(got.cpu(), ref.cpu()) > 0.9995 + + +def test_iso_metal_matches_plain_monoclinic(mps_device): + _, grid, frac, inv_frac, voxel, rsg = _cell_monoclinic() + xyz, adp, occ, A, B = _to(mps_device, *_iso_atoms(_cell_monoclinic()[0])) + rsg, frac, inv_frac, voxel = _to(mps_device, rsg, frac, inv_frac, voxel) + + def run(engine): + with use_engine(engine): + return build_electron_density(rsg, xyz, adp, occ, A, B, inv_frac, frac, voxel) + + ref = run(Engine.EAGER) + got = run(Engine.AUTO) + assert _rel_map(got.cpu(), ref.cpu()) < 2e-2 + assert _cos(got.cpu(), ref.cpu()) > 0.9995 + + +def test_aniso_metal_matches_plain(mps_device): + _, grid, frac, inv_frac, voxel, rsg = _cell() + xa, ua, oa, Aa, Ba = _to(mps_device, *_aniso_atoms(_cell()[0])) + rsg, frac, inv_frac, voxel = _to(mps_device, rsg, frac, inv_frac, voxel) + empty = torch.zeros(0, 3, device=mps_device) + z0 = torch.zeros(0, device=mps_device) + z05 = torch.zeros(0, 5, device=mps_device) + + def run(engine): + with use_engine(engine): + return build_electron_density( + rsg, empty, z0, z0, z05, z05, inv_frac, frac, voxel, + xyz_aniso=xa, u_aniso=ua, occ_aniso=oa, A_aniso=Aa, B_aniso=Ba, + ) + + ref = run(Engine.EAGER) + got = run(Engine.AUTO) + assert _rel_map(got.cpu(), ref.cpu()) < 2e-2 + assert _cos(got.cpu(), ref.cpu()) > 0.9995 + + +def test_iso_gradients_match_plain(mps_device): + _, grid, frac, inv_frac, voxel, rsg = _cell() + xyz0, adp0, occ0, A, B = _iso_atoms(_cell()[0]) + rsg, frac, inv_frac, voxel, A, B = _to(mps_device, rsg, frac, inv_frac, voxel, A, B) + w = torch.randn(grid, device=mps_device) + + def run(engine): + xx = xyz0.to(mps_device).clone().requires_grad_() + aa = adp0.to(mps_device).clone().requires_grad_() + oo = occ0.to(mps_device).clone().requires_grad_() + with use_engine(engine): + dm = build_electron_density(rsg, xx, aa, oo, A, B, inv_frac, frac, voxel) + (dm * w).sum().backward() + return xx.grad, aa.grad, oo.grad + + gx_r, ga_r, go_r = run(Engine.EAGER) + gx_m, ga_m, go_m = run(Engine.AUTO) + assert _cos(gx_m.cpu(), gx_r.cpu()) > 0.999 + assert _cos(ga_m.cpu(), ga_r.cpu()) > 0.999 + assert _cos(go_m.cpu(), go_r.cpu()) > 0.999 + + +def test_aniso_gradients_match_plain(mps_device): + _, grid, frac, inv_frac, voxel, rsg = _cell() + xa0, ua0, oa0, Aa, Ba = _aniso_atoms(_cell()[0]) + rsg, frac, inv_frac, voxel, Aa, Ba = _to(mps_device, rsg, frac, inv_frac, voxel, Aa, Ba) + empty = torch.zeros(0, 3, device=mps_device) + z0 = torch.zeros(0, device=mps_device) + z05 = torch.zeros(0, 5, device=mps_device) + w = torch.randn(grid, device=mps_device) + + def run(engine): + xx = xa0.to(mps_device).clone().requires_grad_() + uu = ua0.to(mps_device).clone().requires_grad_() + with use_engine(engine): + dm = build_electron_density( + rsg, empty, z0, z0, z05, z05, inv_frac, frac, voxel, + xyz_aniso=xx, u_aniso=uu, occ_aniso=oa0.to(mps_device), + A_aniso=Aa, B_aniso=Ba, + ) + (dm * w).sum().backward() + return xx.grad, uu.grad + + gx_r, gu_r = run(Engine.EAGER) + gx_m, gu_m = run(Engine.AUTO) + assert _cos(gx_m.cpu(), gx_r.cpu()) > 0.999 + assert _cos(gu_m.cpu(), gu_r.cpu()) > 0.999 diff --git a/tests/unit/base/test_kernel_import_shims.py b/tests/unit/base/test_kernel_import_shims.py index 6f1bb2c..b6faf27 100644 --- a/tests/unit/base/test_kernel_import_shims.py +++ b/tests/unit/base/test_kernel_import_shims.py @@ -92,6 +92,12 @@ def test_solvent_radius_offsets_import_path(): "torchref.base.electron_density.kernels.cpu.variable_radius", "torchref.base.electron_density.kernels.cuda.fused", "torchref.base.electron_density.kernels.cuda.variable_radius", + # MPS Metal kernels: importing must not trigger shader compilation, so + # these resolve cleanly on every platform (compile is deferred to first use). + "torchref.base.electron_density.kernels.mps", + "torchref.base.electron_density.kernels.mps.compile", + "torchref.base.electron_density.kernels.mps.variable_radius", + "torchref.base.electron_density.kernels.mps._shaders", ], ) def test_new_kernel_modules_import(modname): diff --git a/tests/unit/base/test_max_eig_sym3.py b/tests/unit/base/test_max_eig_sym3.py new file mode 100644 index 0000000..3bc1da5 --- /dev/null +++ b/tests/unit/base/test_max_eig_sym3.py @@ -0,0 +1,72 @@ +"""Closed-form largest eigenvalue of a symmetric 3x3 vs torch.linalg.eigvalsh. + +``_max_eig_sym3`` replaces ``eigvalsh`` in the anisotropic splat-radius policy so +it runs natively on MPS; it must agree with the reference eigendecomposition, +including on diagonal and near-degenerate matrices. +""" + +import pytest +import torch + +from torchref.base.electron_density.radius_policy import ( + _max_eig_sym3, + _u6_to_u3, + per_atom_radius_aniso, +) + +pytestmark = pytest.mark.unit + + +def _sym(n, seed, scale=1.0): + g = torch.Generator().manual_seed(seed) + M = torch.randn(n, 3, 3, generator=g) * scale + return (M + M.transpose(1, 2)) / 2 + + +def test_matches_eigvalsh_random(): + S = _sym(4000, 0) + ref = torch.linalg.eigvalsh(S).max(dim=1).values + got = _max_eig_sym3(S) + assert torch.allclose(got, ref, atol=1e-4, rtol=1e-4) + + +def test_matches_eigvalsh_diagonal(): + # purely diagonal (off-diagonals zero) -> largest diagonal entry + d = torch.tensor([[3.0, -1.0, 2.0], [5.0, 5.0, 5.0], [0.1, 0.2, 0.05]]) + S = torch.diag_embed(d) + ref = torch.linalg.eigvalsh(S).max(dim=1).values + got = _max_eig_sym3(S) + assert torch.allclose(got, ref, atol=1e-5) + assert torch.allclose(got, d.max(dim=1).values, atol=1e-5) + + +def test_matches_eigvalsh_near_degenerate(): + # near-isotropic (all eigenvalues nearly equal) stresses the p2->0 branch + S = torch.eye(3).expand(50, 3, 3).clone() + S += _sym(50, 7, scale=1e-4) + ref = torch.linalg.eigvalsh(S).max(dim=1).values + got = _max_eig_sym3(S) + assert torch.allclose(got, ref, atol=1e-4) + + +def test_radius_matches_eigvalsh_path(): + # The (quantized) aniso radius must be identical to the eigvalsh-based one. + g = torch.Generator().manual_seed(3) + n = 500 + u = torch.zeros(n, 6) + u[:, :3] = torch.rand(n, 3, generator=g) * 0.12 + 0.02 + u[:, 3:] = (torch.rand(n, 3, generator=g) - 0.5) * 0.02 + B = torch.rand(n, 5, generator=g) * 20 + 2 + + rad_closed = per_atom_radius_aniso(B, u, n_sigma=3.0) + + # Reference radius using eigvalsh directly. + from torchref.base.electron_density.radius_policy import ( + EIGHT_PI2, _ceil_round, R_LO, R_HI, + ) + b_form = B.max(dim=1).values + lam = torch.linalg.eigvalsh(_u6_to_u3(u)).max(dim=1).values + sigma = torch.sqrt((b_form / EIGHT_PI2 + lam).clamp(min=1e-6)) + rad_ref = _ceil_round(3.0 * sigma).clamp(min=R_LO, max=R_HI) + + assert torch.equal(rad_closed, rad_ref) diff --git a/torchref/base/electron_density/kernels/mps/__init__.py b/torchref/base/electron_density/kernels/mps/__init__.py new file mode 100644 index 0000000..d324da6 --- /dev/null +++ b/torchref/base/electron_density/kernels/mps/__init__.py @@ -0,0 +1,25 @@ +"""Metal (MPS) variable-radius electron-density splat kernels. + +Native Metal kernels (compiled at runtime via ``torch.mps.compile_shader``) for +the density splat on Apple-silicon GPUs, replacing the portable eager splat that +dominates fcalc time on MPS. Gated behind ``device.type == 'mps'`` in +``electron_density.main``; every other platform is unaffected. +""" + +from torchref.base.electron_density.kernels.mps.compile import ( + clear_cache, + mps_kernels_available, + warmup, +) +from torchref.base.electron_density.kernels.mps.variable_radius import ( + add_anisotropic_mps_var, + add_isotropic_mps_var, +) + +__all__ = [ + "add_isotropic_mps_var", + "add_anisotropic_mps_var", + "mps_kernels_available", + "warmup", + "clear_cache", +] diff --git a/torchref/base/electron_density/kernels/mps/_shaders.py b/torchref/base/electron_density/kernels/mps/_shaders.py new file mode 100644 index 0000000..facd694 --- /dev/null +++ b/torchref/base/electron_density/kernels/mps/_shaders.py @@ -0,0 +1,434 @@ +"""Metal Shading Language (MSL) source for the variable-radius density splat. + +Compiled at runtime via ``torch.mps.compile_shader`` (see ``compile.py``). One +GPU thread per atom (dispatched ``threads=[n_atoms]``): each thread sizes its +own cubic bounding box from ``r2cut`` and the inverse-cell row norms, iterates +the box, truncates to the per-atom sphere (``r2 <= r2cut``), evaluates the +5-term ITC92 Gaussian, and accumulates into the grid via a portable +compare-exchange float atomic-add (``atomic_add_f``) that works on every Metal +GPU family (Apple7 / M1 onward), not just Metal-3 native ``atomic_float``. + +The math mirrors the CUDA variable-radius kernels +(``kernels/cuda/variable_radius.py``) and is validated against the portable CPU +reference (``add_isotropic_plain_var`` / ``add_anisotropic_plain_var``). Because +each thread owns one atom, the backward kernels accumulate per-atom gradients +with no atomics. + +Coordinates: work in Cartesian offsets. ``frac`` is the fractional->Cartesian +matrix (its columns are the cell vectors a,b,c); ``inv_frac`` is its inverse +(Cartesian->fractional). For voxel offset ``o`` from the atom's grid anchor, +``w = frac @ (o/n - residual)`` is the Cartesian atom->voxel vector and +``r2 = w.w``. + +Constants match the reference bit-for-bit: PI_1P5 = pi^1.5, PI_SQ = pi^2. +""" + +# NOTE: kept as a single translation unit so one compile_shader call yields all +# kernels. Backward (iso/aniso) and anisotropic kernels are appended below as +# each is validated. +MSL_SOURCE = r""" +#include +#include +using namespace metal; + +constant float PI_1P5 = 5.568327996831708f; // pi^1.5 +constant float PI_SQ = 9.869604401089358f; // pi^2 + +// Portable float atomic-add via compare-exchange on uint. Works on EVERY Metal +// GPU family (incl. Apple7 / M1), not just Metal-3 native atomic_float. Measured +// identical to native on Apple8/M2 -- the splat's per-atom locality keeps +// contention low, so the CAS loop essentially never retries. +inline void atomic_add_f(device atomic_uint* addr, float val) { + uint old = atomic_load_explicit(addr, memory_order_relaxed), nxt; + do { nxt = as_type(as_type(old) + val); } + while (!atomic_compare_exchange_weak_explicit( + addr, &old, nxt, memory_order_relaxed, memory_order_relaxed)); +} + +// --------------------------------------------------------------------------- +// Isotropic forward: accumulate 5-Gaussian density into grid (atomic add). +// --------------------------------------------------------------------------- +kernel void iso_splat_fwd( + device atomic_uint* grid [[buffer(0)]], // (nx*ny*nz,) accumulator + device const float* xyz [[buffer(1)]], // (n,3) Cartesian + device const float* adp [[buffer(2)]], // (n,) isotropic B + device const float* occ [[buffer(3)]], // (n,) + device const float* A [[buffer(4)]], // (n,5) ITC92 amplitudes + device const float* B [[buffer(5)]], // (n,5) ITC92 widths + device const float* r2cut [[buffer(6)]], // (n,) squared radius + device const float* mask [[buffer(7)]], // (n,5) per-Gaussian mask + device const float* inv_frac [[buffer(8)]], // 9 (row-major 3x3) + device const float* frac [[buffer(9)]], // 9 (row-major 3x3) + constant int& n_atoms [[buffer(10)]], + constant int& nx [[buffer(11)]], + constant int& ny [[buffer(12)]], + constant int& nz [[buffer(13)]], + uint a [[thread_position_in_grid]]) +{ + if (a >= (uint)n_atoms) return; + + // Cell geometry (frac columns are cell vectors a,b,c). + float f0=frac[0],f1=frac[1],f2=frac[2], + f3=frac[3],f4=frac[4],f5=frac[5], + f6=frac[6],f7=frac[7],f8=frac[8]; + float i0=inv_frac[0],i1=inv_frac[1],i2=inv_frac[2], + i3=inv_frac[3],i4=inv_frac[4],i5=inv_frac[5], + i6=inv_frac[6],i7=inv_frac[7],i8=inv_frac[8]; + float fnx=(float)nx, fny=(float)ny, fnz=(float)nz; + + // Per-axis Cartesian voxel step vectors (cell vector / n). + float uax=f0/fnx, uay=f3/fnx, uaz=f6/fnx; + float ubx=f1/fny, uby=f4/fny, ubz=f7/fny; + float ucx=f2/fnz, ucy=f5/fnz, ucz=f8/fnz; + + float rc2 = r2cut[a]; + float r = sqrt(rc2); + // Triclinic-correct half-widths: max|off_axis| = n_axis * r * ||inv_frac row||. + int bhx = (int)ceil(r*fnx*sqrt(i0*i0+i1*i1+i2*i2)); + int bhy = (int)ceil(r*fny*sqrt(i3*i3+i4*i4+i5*i5)); + int bhz = (int)ceil(r*fnz*sqrt(i6*i6+i7*i7+i8*i8)); + + // Atom -> fractional -> wrap into [0,1) -> nearest grid node + residual. + float ax=xyz[3*a+0], ay=xyz[3*a+1], az=xyz[3*a+2]; + float fx=ax*i0+ay*i1+az*i2; + float fy=ax*i3+ay*i4+az*i5; + float fz=ax*i6+ay*i7+az*i8; + fx-=floor(fx); fy-=floor(fy); fz-=floor(fz); + int cix=(int)rint(fx*fnx), ciy=(int)rint(fy*fny), ciz=(int)rint(fz*fnz); + float sx=fx-(float)cix/fnx, sy=fy-(float)ciy/fny, sz=fz-(float)ciz/fnz; + float w0x=f0*sx+f1*sy+f2*sz; // residual -> Cartesian + float w0y=f3*sx+f4*sy+f5*sz; + float w0z=f6*sx+f7*sy+f8*sz; + + // Per-Gaussian amplitude / width. + float occa=occ[a], b_iso=adp[a]; + float Bt[5], An[5]; + for (int g=0; g<5; ++g) { + float Bt_g = max((B[5*a+g]+b_iso)*0.25f, 0.1f); + Bt[g] = Bt_g; + An[g] = mask[5*a+g]*A[5*a+g]*occa*PI_1P5/(Bt_g*sqrt(Bt_g)); + } + + for (int ox=-bhx; ox<=bhx; ++ox) { + float fox=(float)ox; + for (int oy=-bhy; oy<=bhy; ++oy) { + float foy=(float)oy; + for (int oz=-bhz; oz<=bhz; ++oz) { + float foz=(float)oz; + float wx=fox*uax+foy*ubx+foz*ucx - w0x; + float wy=fox*uay+foy*uby+foz*ucy - w0y; + float wz=fox*uaz+foy*ubz+foz*ucz - w0z; + float r2=wx*wx+wy*wy+wz*wz; + if (r2 > rc2) continue; + float dens=0.0f; + for (int g=0; g<5; ++g) dens += An[g]*fast::exp(-PI_SQ*r2/Bt[g]); + int vix=cix+ox; vix=((vix%nx)+nx)%nx; + int viy=ciy+oy; viy=((viy%ny)+ny)%ny; + int viz=ciz+oz; viz=((viz%nz)+nz)%nz; + int idx=(vix*ny+viy)*nz+viz; + atomic_add_f(&grid[idx], dens); + } + } + } +} + +// --------------------------------------------------------------------------- +// Isotropic backward: analytic grads to xyz, adp, occ. One thread per atom, so +// each thread owns its atom's gradient slots -> no atomics. Recomputes the +// forward geometry and gathers the upstream grad from the grid. +// --------------------------------------------------------------------------- +kernel void iso_splat_bwd( + device float* grad_xyz [[buffer(0)]], // (n,3) out + device float* grad_adp [[buffer(1)]], // (n,) out + device float* grad_occ [[buffer(2)]], // (n,) out + device const float* grad_out [[buffer(3)]], // (nx*ny*nz,) upstream grad + device const float* xyz [[buffer(4)]], + device const float* adp [[buffer(5)]], + device const float* occ [[buffer(6)]], + device const float* A [[buffer(7)]], + device const float* B [[buffer(8)]], + device const float* r2cut [[buffer(9)]], + device const float* mask [[buffer(10)]], + device const float* inv_frac [[buffer(11)]], + device const float* frac [[buffer(12)]], + constant int& n_atoms [[buffer(13)]], + constant int& nx [[buffer(14)]], + constant int& ny [[buffer(15)]], + constant int& nz [[buffer(16)]], + uint a [[thread_position_in_grid]]) +{ + if (a >= (uint)n_atoms) return; + + float f0=frac[0],f1=frac[1],f2=frac[2], + f3=frac[3],f4=frac[4],f5=frac[5], + f6=frac[6],f7=frac[7],f8=frac[8]; + float i0=inv_frac[0],i1=inv_frac[1],i2=inv_frac[2], + i3=inv_frac[3],i4=inv_frac[4],i5=inv_frac[5], + i6=inv_frac[6],i7=inv_frac[7],i8=inv_frac[8]; + float fnx=(float)nx, fny=(float)ny, fnz=(float)nz; + float uax=f0/fnx, uay=f3/fnx, uaz=f6/fnx; + float ubx=f1/fny, uby=f4/fny, ubz=f7/fny; + float ucx=f2/fnz, ucy=f5/fnz, ucz=f8/fnz; + + float rc2 = r2cut[a]; + float r = sqrt(rc2); + int bhx = (int)ceil(r*fnx*sqrt(i0*i0+i1*i1+i2*i2)); + int bhy = (int)ceil(r*fny*sqrt(i3*i3+i4*i4+i5*i5)); + int bhz = (int)ceil(r*fnz*sqrt(i6*i6+i7*i7+i8*i8)); + + float ax=xyz[3*a+0], ay=xyz[3*a+1], az=xyz[3*a+2]; + float fx=ax*i0+ay*i1+az*i2; + float fy=ax*i3+ay*i4+az*i5; + float fz=ax*i6+ay*i7+az*i8; + fx-=floor(fx); fy-=floor(fy); fz-=floor(fz); + int cix=(int)rint(fx*fnx), ciy=(int)rint(fy*fny), ciz=(int)rint(fz*fnz); + float sx=fx-(float)cix/fnx, sy=fy-(float)ciy/fny, sz=fz-(float)ciz/fnz; + float w0x=f0*sx+f1*sy+f2*sz; + float w0y=f3*sx+f4*sy+f5*sz; + float w0z=f6*sx+f7*sy+f8*sz; + + float occa=occ[a], b_iso=adp[a]; + float Bt[5], An[5], clampf[5]; + for (int g=0; g<5; ++g) { + float raw = (B[5*a+g]+b_iso)*0.25f; + float Bt_g = max(raw, 0.1f); + Bt[g] = Bt_g; + An[g] = mask[5*a+g]*A[5*a+g]*occa*PI_1P5/(Bt_g*sqrt(Bt_g)); + clampf[g] = (raw > 0.1f) ? 1.0f : 0.0f; // d(clamped Bt)/d(adp)=0 in clamp region + } + + float gx=0.0f, gy=0.0f, gz=0.0f, gb=0.0f, go=0.0f; + for (int ox=-bhx; ox<=bhx; ++ox) { + float fox=(float)ox; + for (int oy=-bhy; oy<=bhy; ++oy) { + float foy=(float)oy; + for (int oz=-bhz; oz<=bhz; ++oz) { + float foz=(float)oz; + float wx=fox*uax+foy*ubx+foz*ucx - w0x; + float wy=fox*uay+foy*uby+foz*ucy - w0y; + float wz=fox*uaz+foy*ubz+foz*ucz - w0z; + float r2=wx*wx+wy*wy+wz*wz; + if (r2 > rc2) continue; + int vix=cix+ox; vix=((vix%nx)+nx)%nx; + int viy=ciy+oy; viy=((viy%ny)+ny)%ny; + int viz=ciz+oz; viz=((viz%nz)+nz)%nz; + float g_out = grad_out[(vix*ny+viy)*nz+viz]; + float dens=0.0f, coeff_xyz=0.0f, db_sum=0.0f; + for (int g=0; g<5; ++g) { + float e = fast::exp(-PI_SQ*r2/Bt[g]); + float Ae = An[g]*e; + dens += Ae; + coeff_xyz += Ae/Bt[g]; + db_sum += Ae*(-1.5f/Bt[g] + PI_SQ*r2/(Bt[g]*Bt[g]))*clampf[g]; + } + float sxyz = g_out*2.0f*PI_SQ*coeff_xyz; + gx += sxyz*wx; gy += sxyz*wy; gz += sxyz*wz; + gb += g_out*0.25f*db_sum; + go += g_out*dens; + } + } + } + grad_xyz[3*a+0]=gx; grad_xyz[3*a+1]=gy; grad_xyz[3*a+2]=gz; + grad_adp[a]=gb; + grad_occ[a]=(occa!=0.0f) ? go/occa : 0.0f; +} + +constant float TWO_PI_SQ = 19.739208802178716f; // 2*pi^2 (= 8*pi^2 / 4) + +// --------------------------------------------------------------------------- +// Anisotropic forward. Per Gaussian g: M_g = (B_g*I + 8*pi^2*U)/4 (symmetric +// 3x3), inverted analytically; A_norm_g = mask*A*occ*pi^1.5/sqrt(det M_g); +// q_g = w^T Minv_g w; density = sum_g A_norm_g fast::exp(-pi^2 q_g). Truncation +// is a per-axis bounding box (from r2cut + inv-cell row norms) with a sphere +// cull (r2 <= r2cut) -- far tighter than a full cube on anisotropic high-res +// cells. Uses metal::fast::exp (accurate to ~1e-4, well under the grid floor). +// --------------------------------------------------------------------------- +kernel void aniso_splat_fwd( + device atomic_uint* grid [[buffer(0)]], + device const float* xyz [[buffer(1)]], // (n,3) + device const float* u [[buffer(2)]], // (n,6) U11,U22,U33,U12,U13,U23 + device const float* occ [[buffer(3)]], + device const float* A [[buffer(4)]], // (n,5) + device const float* B [[buffer(5)]], // (n,5) + device const float* r2cut [[buffer(6)]], // (n,) squared truncation radius + device const float* mask [[buffer(7)]], // (n,5) + device const float* inv_frac [[buffer(8)]], + device const float* frac [[buffer(9)]], + constant int& n_atoms [[buffer(10)]], + constant int& nx [[buffer(11)]], + constant int& ny [[buffer(12)]], + constant int& nz [[buffer(13)]], + uint a [[thread_position_in_grid]]) +{ + if (a >= (uint)n_atoms) return; + + float f0=frac[0],f1=frac[1],f2=frac[2], + f3=frac[3],f4=frac[4],f5=frac[5], + f6=frac[6],f7=frac[7],f8=frac[8]; + float i0=inv_frac[0],i1=inv_frac[1],i2=inv_frac[2], + i3=inv_frac[3],i4=inv_frac[4],i5=inv_frac[5], + i6=inv_frac[6],i7=inv_frac[7],i8=inv_frac[8]; + float fnx=(float)nx, fny=(float)ny, fnz=(float)nz; + float uax=f0/fnx, uay=f3/fnx, uaz=f6/fnx; + float ubx=f1/fny, uby=f4/fny, ubz=f7/fny; + float ucx=f2/fnz, ucy=f5/fnz, ucz=f8/fnz; + + float ax=xyz[3*a+0], ay=xyz[3*a+1], az=xyz[3*a+2]; + float fx=ax*i0+ay*i1+az*i2, fy=ax*i3+ay*i4+az*i5, fz=ax*i6+ay*i7+az*i8; + fx-=floor(fx); fy-=floor(fy); fz-=floor(fz); + int cix=(int)rint(fx*fnx), ciy=(int)rint(fy*fny), ciz=(int)rint(fz*fnz); + float sx=fx-(float)cix/fnx, sy=fy-(float)ciy/fny, sz=fz-(float)ciz/fnz; + float w0x=f0*sx+f1*sy+f2*sz, w0y=f3*sx+f4*sy+f5*sz, w0z=f6*sx+f7*sy+f8*sz; + + float occa=occ[a]; + float u11=u[6*a+0],u22=u[6*a+1],u33=u[6*a+2],u12=u[6*a+3],u13=u[6*a+4],u23=u[6*a+5]; + float p00[5],p11[5],p22[5],p01[5],p02[5],p12[5],An[5]; + for (int g=0; g<5; ++g) { + float Bg=B[5*a+g]; + float ma=0.25f*Bg+TWO_PI_SQ*u11, mb=0.25f*Bg+TWO_PI_SQ*u22, mc=0.25f*Bg+TWO_PI_SQ*u33; + float md=TWO_PI_SQ*u12, me=TWO_PI_SQ*u13, mf=TWO_PI_SQ*u23; + float det=ma*(mb*mc-mf*mf)-md*(md*mc-me*mf)+me*(md*mf-me*mb); + float inv=1.0f/det; + p00[g]=(mb*mc-mf*mf)*inv; p11[g]=(ma*mc-me*me)*inv; p22[g]=(ma*mb-md*md)*inv; + p01[g]=(me*mf-md*mc)*inv; p02[g]=(md*mf-me*mb)*inv; p12[g]=(md*me-ma*mf)*inv; + An[g]=mask[5*a+g]*A[5*a+g]*occa*PI_1P5/sqrt(max(det,1e-10f)); + } + + float rc2=r2cut[a], r=sqrt(rc2); + int bhx=(int)ceil(r*fnx*sqrt(i0*i0+i1*i1+i2*i2)); + int bhy=(int)ceil(r*fny*sqrt(i3*i3+i4*i4+i5*i5)); + int bhz=(int)ceil(r*fnz*sqrt(i6*i6+i7*i7+i8*i8)); + for (int ox=-bhx; ox<=bhx; ++ox) { + float fox=(float)ox; + for (int oy=-bhy; oy<=bhy; ++oy) { + float foy=(float)oy; + for (int oz=-bhz; oz<=bhz; ++oz) { + float foz=(float)oz; + float wx=fox*uax+foy*ubx+foz*ucx - w0x; + float wy=fox*uay+foy*uby+foz*ucy - w0y; + float wz=fox*uaz+foy*ubz+foz*ucz - w0z; + if (wx*wx+wy*wy+wz*wz > rc2) continue; + float dens=0.0f; + for (int g=0; g<5; ++g) { + float q=p00[g]*wx*wx+p11[g]*wy*wy+p22[g]*wz*wz + +2.0f*(p01[g]*wx*wy+p02[g]*wx*wz+p12[g]*wy*wz); + dens += An[g]*fast::exp(-PI_SQ*q); + } + int vix=cix+ox; vix=((vix%nx)+nx)%nx; + int viy=ciy+oy; viy=((viy%ny)+ny)%ny; + int viz=ciz+oz; viz=((viz%nz)+nz)%nz; + atomic_add_f(&grid[(vix*ny+viy)*nz+viz], dens); + } + } + } +} + +// --------------------------------------------------------------------------- +// Anisotropic backward: grads to xyz, U (6), occ. v_g = Minv_g w; dg_g = +// A_norm_g exp(-pi^2 w.v_g). grad_xyz = go*2pi^2*sum dg v; grad_U diag = +// go*2pi^2*sum dg(-0.5 p_ii + pi^2 v_i^2); grad_U offdiag = go*4pi^2*sum +// dg(-0.5 p_ij + pi^2 v_i v_j); grad_occ = go*sum dg / occ. One thread/atom. +// --------------------------------------------------------------------------- +kernel void aniso_splat_bwd( + device float* grad_xyz [[buffer(0)]], // (n,3) + device float* grad_u [[buffer(1)]], // (n,6) + device float* grad_occ [[buffer(2)]], // (n,) + device const float* grad_out [[buffer(3)]], + device const float* xyz [[buffer(4)]], + device const float* u [[buffer(5)]], + device const float* occ [[buffer(6)]], + device const float* A [[buffer(7)]], + device const float* B [[buffer(8)]], + device const float* r2cut [[buffer(9)]], + device const float* mask [[buffer(10)]], + device const float* inv_frac [[buffer(11)]], + device const float* frac [[buffer(12)]], + constant int& n_atoms [[buffer(13)]], + constant int& nx [[buffer(14)]], + constant int& ny [[buffer(15)]], + constant int& nz [[buffer(16)]], + uint a [[thread_position_in_grid]]) +{ + if (a >= (uint)n_atoms) return; + + float f0=frac[0],f1=frac[1],f2=frac[2], + f3=frac[3],f4=frac[4],f5=frac[5], + f6=frac[6],f7=frac[7],f8=frac[8]; + float i0=inv_frac[0],i1=inv_frac[1],i2=inv_frac[2], + i3=inv_frac[3],i4=inv_frac[4],i5=inv_frac[5], + i6=inv_frac[6],i7=inv_frac[7],i8=inv_frac[8]; + float fnx=(float)nx, fny=(float)ny, fnz=(float)nz; + float uax=f0/fnx, uay=f3/fnx, uaz=f6/fnx; + float ubx=f1/fny, uby=f4/fny, ubz=f7/fny; + float ucx=f2/fnz, ucy=f5/fnz, ucz=f8/fnz; + + float ax=xyz[3*a+0], ay=xyz[3*a+1], az=xyz[3*a+2]; + float fx=ax*i0+ay*i1+az*i2, fy=ax*i3+ay*i4+az*i5, fz=ax*i6+ay*i7+az*i8; + fx-=floor(fx); fy-=floor(fy); fz-=floor(fz); + int cix=(int)rint(fx*fnx), ciy=(int)rint(fy*fny), ciz=(int)rint(fz*fnz); + float sx=fx-(float)cix/fnx, sy=fy-(float)ciy/fny, sz=fz-(float)ciz/fnz; + float w0x=f0*sx+f1*sy+f2*sz, w0y=f3*sx+f4*sy+f5*sz, w0z=f6*sx+f7*sy+f8*sz; + + float occa=occ[a]; + float u11=u[6*a+0],u22=u[6*a+1],u33=u[6*a+2],u12=u[6*a+3],u13=u[6*a+4],u23=u[6*a+5]; + float p00[5],p11[5],p22[5],p01[5],p02[5],p12[5],An[5]; + for (int g=0; g<5; ++g) { + float Bg=B[5*a+g]; + float ma=0.25f*Bg+TWO_PI_SQ*u11, mb=0.25f*Bg+TWO_PI_SQ*u22, mc=0.25f*Bg+TWO_PI_SQ*u33; + float md=TWO_PI_SQ*u12, me=TWO_PI_SQ*u13, mf=TWO_PI_SQ*u23; + float det=ma*(mb*mc-mf*mf)-md*(md*mc-me*mf)+me*(md*mf-me*mb); + float inv=1.0f/det; + p00[g]=(mb*mc-mf*mf)*inv; p11[g]=(ma*mc-me*me)*inv; p22[g]=(ma*mb-md*md)*inv; + p01[g]=(me*mf-md*mc)*inv; p02[g]=(md*mf-me*mb)*inv; p12[g]=(md*me-ma*mf)*inv; + An[g]=mask[5*a+g]*A[5*a+g]*occa*PI_1P5/sqrt(max(det,1e-10f)); + } + + float gx=0,gy=0,gz=0,gu0=0,gu1=0,gu2=0,gu3=0,gu4=0,gu5=0,go=0; + float rc2=r2cut[a], r=sqrt(rc2); + int bhx=(int)ceil(r*fnx*sqrt(i0*i0+i1*i1+i2*i2)); + int bhy=(int)ceil(r*fny*sqrt(i3*i3+i4*i4+i5*i5)); + int bhz=(int)ceil(r*fnz*sqrt(i6*i6+i7*i7+i8*i8)); + for (int ox=-bhx; ox<=bhx; ++ox) { + float fox=(float)ox; + for (int oy=-bhy; oy<=bhy; ++oy) { + float foy=(float)oy; + for (int oz=-bhz; oz<=bhz; ++oz) { + float foz=(float)oz; + float wx=fox*uax+foy*ubx+foz*ucx - w0x; + float wy=fox*uay+foy*uby+foz*ucy - w0y; + float wz=fox*uaz+foy*ubz+foz*ucz - w0z; + if (wx*wx+wy*wy+wz*wz > rc2) continue; + int vix=cix+ox; vix=((vix%nx)+nx)%nx; + int viy=ciy+oy; viy=((viy%ny)+ny)%ny; + int viz=ciz+oz; viz=((viz%nz)+nz)%nz; + float g_out=grad_out[(vix*ny+viy)*nz+viz]; + float sxx=0,syy=0,szz=0,dens=0; + float su0=0,su1=0,su2=0,su3=0,su4=0,su5=0; + for (int g=0; g<5; ++g) { + float vx=p00[g]*wx+p01[g]*wy+p02[g]*wz; + float vy=p01[g]*wx+p11[g]*wy+p12[g]*wz; + float vz=p02[g]*wx+p12[g]*wy+p22[g]*wz; + float q=wx*vx+wy*vy+wz*vz; + float dg=An[g]*fast::exp(-PI_SQ*q); + dens+=dg; sxx+=dg*vx; syy+=dg*vy; szz+=dg*vz; + su0+=dg*(-0.5f*p00[g]+PI_SQ*vx*vx); + su1+=dg*(-0.5f*p11[g]+PI_SQ*vy*vy); + su2+=dg*(-0.5f*p22[g]+PI_SQ*vz*vz); + su3+=dg*(-0.5f*p01[g]+PI_SQ*vx*vy); + su4+=dg*(-0.5f*p02[g]+PI_SQ*vx*vz); + su5+=dg*(-0.5f*p12[g]+PI_SQ*vy*vz); + } + float s=g_out*2.0f*PI_SQ; + gx+=s*sxx; gy+=s*syy; gz+=s*szz; + gu0+=s*su0; gu1+=s*su1; gu2+=s*su2; + gu3+=g_out*4.0f*PI_SQ*su3; gu4+=g_out*4.0f*PI_SQ*su4; gu5+=g_out*4.0f*PI_SQ*su5; + go+=g_out*dens; + } + } + } + grad_xyz[3*a+0]=gx; grad_xyz[3*a+1]=gy; grad_xyz[3*a+2]=gz; + grad_u[6*a+0]=gu0; grad_u[6*a+1]=gu1; grad_u[6*a+2]=gu2; + grad_u[6*a+3]=gu3; grad_u[6*a+4]=gu4; grad_u[6*a+5]=gu5; + grad_occ[a]=(occa!=0.0f) ? go/occa : 0.0f; +} +""" diff --git a/torchref/base/electron_density/kernels/mps/compile.py b/torchref/base/electron_density/kernels/mps/compile.py new file mode 100644 index 0000000..d61dbbe --- /dev/null +++ b/torchref/base/electron_density/kernels/mps/compile.py @@ -0,0 +1,87 @@ +"""Lazy compilation + availability probe for the Metal (MPS) splat kernels. + +Mirrors the memoize / permanent-failure / graceful-fallback structure of +``kernels/cpu/scatter.py`` (``_get_module``), but the build is a single +in-process ``torch.mps.compile_shader`` call -- no ninja, build directory, or +file locking, since PyTorch caches the compiled pipeline-state objects itself. + +``mps_kernels_available()`` is the gate the dispatcher checks; it returns False +(and the caller falls back to the portable plain splat) whenever MPS is absent, +``compile_shader`` is missing (torch < 2.9), or the shader fails to build. +""" + +from __future__ import annotations + +import traceback +from typing import Optional, Tuple + +import torch + +from torchref.base.electron_density.kernels.mps._shaders import MSL_SOURCE + +# Memoized compile state (attempted at most once per process). +_lib = None +_lib_failed = False +_lib_error: Optional[Tuple[str, str]] = None # (message, traceback) + + +def _mps_shader_supported() -> bool: + """True iff this torch build can compile+run Metal shaders on this host.""" + return ( + hasattr(torch, "mps") + and hasattr(torch.mps, "compile_shader") + and hasattr(torch.backends, "mps") + and torch.backends.mps.is_available() + ) + + +def _get_lib(): + """Return the compiled shader library, or None if unavailable. + + Short-circuits both prior outcomes so compilation is attempted exactly once. + Any failure (unsupported torch, MPS off, MSL compile error) is recorded and + turned into a None return so callers fall back to the plain splat. + """ + global _lib, _lib_failed, _lib_error + if _lib is not None: + return _lib + if _lib_failed: + return None + if not _mps_shader_supported(): + _lib_failed = True + _lib_error = ( + "torch.mps.compile_shader unavailable or MPS not available", + "", + ) + return None + try: + _lib = torch.mps.compile_shader(MSL_SOURCE) + except Exception as e: # noqa: BLE001 - any build failure -> fall back + _lib_failed = True + _lib_error = (f"{type(e).__name__}: {e}", traceback.format_exc()) + return None + return _lib + + +def mps_kernels_available() -> bool: + """Whether the Metal splat kernels compiled and are ready to dispatch.""" + return _get_lib() is not None + + +def warmup() -> bool: + """Eagerly trigger compilation (e.g. to move the one-time cost off the + first refinement step). Returns availability.""" + return _get_lib() is not None + + +def clear_cache() -> None: + """Forget the compiled library and failure state (recompiled on next use).""" + global _lib, _lib_failed, _lib_error + _lib = None + _lib_failed = False + _lib_error = None + + +def last_error() -> Optional[Tuple[str, str]]: + """The (message, traceback) of the last compile failure, if any.""" + return _lib_error diff --git a/torchref/base/electron_density/kernels/mps/variable_radius.py b/torchref/base/electron_density/kernels/mps/variable_radius.py new file mode 100644 index 0000000..e11f2c2 --- /dev/null +++ b/torchref/base/electron_density/kernels/mps/variable_radius.py @@ -0,0 +1,192 @@ +"""Metal (MPS) variable-radius electron-density splat, autograd-wrapped. + +``add_isotropic_mps_var`` / ``add_anisotropic_mps_var`` mirror the signatures of +the portable CPU reference (``add_isotropic_plain_var`` / +``add_anisotropic_plain_var`` in ``kernels/cpu/variable_radius.py``) so the +dispatch site in ``main.py`` is a near-copy of the CPU branch. Each delegates to +a ``torch.autograd.Function`` that dispatches the compiled Metal kernels +(one thread per atom) for the forward and analytic backward. + +Gradients flow to ``xyz``, ``adp``/``u``, and ``occ`` (and identity to the input +``density_map``); ``A``/``B`` and the cell matrices receive no gradient -- the +same set as the CUDA kernels. Backward is first-order only (like CUDA); double +backward must use ``Engine.EAGER`` (the plain splat). +""" + +from __future__ import annotations + +import torch + +from torchref.base.electron_density.kernels.mps.compile import _get_lib + + +def _quantized_r2cut(radius_per_atom, voxel_size): + """Per-atom squared cutoff quantized to a voxel multiple, matching the plain + reference (``_box_radius_per_atom`` * ``min_voxel`` in + ``kernels/cpu/variable_radius.py``).""" + min_voxel = voxel_size.min() + r_eff = torch.ceil(radius_per_atom / min_voxel) * min_voxel + return r_eff * r_eff + + +class MetalGridDensity(torch.autograd.Function): + """Isotropic per-atom Metal splat: returns ``density_map + splat``.""" + + @staticmethod + def forward(ctx, density_map, xyz, adp, occ, A, B, r2cut, mask, inv_frac, frac): + lib = _get_lib() + if lib is None: + raise RuntimeError("Metal splat kernels unavailable") + nx, ny, nz = (int(s) for s in density_map.shape) + out = density_map.contiguous().clone() + n = xyz.shape[0] + if n > 0: + lib.iso_splat_fwd( + out.view(-1), + xyz.contiguous(), + adp.contiguous(), + occ.contiguous(), + A.contiguous(), + B.contiguous(), + r2cut.contiguous(), + mask.contiguous(), + inv_frac.contiguous().view(-1), + frac.contiguous().view(-1), + n, nx, ny, nz, + threads=[n], + ) + ctx.save_for_backward(xyz, adp, occ, A, B, r2cut, mask, inv_frac, frac) + ctx.grid_shape = (nx, ny, nz) + return out + + @staticmethod + def backward(ctx, grad_out): + xyz, adp, occ, A, B, r2cut, mask, inv_frac, frac = ctx.saved_tensors + nx, ny, nz = ctx.grid_shape + n = xyz.shape[0] + grad_xyz = torch.zeros_like(xyz) + grad_adp = torch.zeros_like(adp) + grad_occ = torch.zeros_like(occ) + if n > 0: + lib = _get_lib() + lib.iso_splat_bwd( + grad_xyz.view(-1), + grad_adp, + grad_occ, + grad_out.contiguous().view(-1), + xyz.contiguous(), + adp.contiguous(), + occ.contiguous(), + A.contiguous(), + B.contiguous(), + r2cut.contiguous(), + mask.contiguous(), + inv_frac.contiguous().view(-1), + frac.contiguous().view(-1), + n, nx, ny, nz, + threads=[n], + ) + # forward returned density_map + splat -> grad wrt density_map is identity. + # order: density_map, xyz, adp, occ, A, B, r2cut, mask, inv_frac, frac + return (grad_out, grad_xyz, grad_adp, grad_occ, + None, None, None, None, None, None) + + +def add_isotropic_mps_var( + density_map, xyz, adp, occ, A, B, + inv_frac_matrix, frac_matrix, grid_shape_tuple, voxel_size, radius_per_atom, +): + """Isotropic variable-radius Metal splat; adds into ``density_map``. + + Signature mirrors ``add_isotropic_plain_var`` (``grid_shape_tuple`` is + unused -- the grid shape comes from ``density_map``). + + The truncation radius is quantized up to a voxel multiple + (``ceil(radius/min_voxel)*min_voxel``) to exactly match the plain-splat + reference's per-atom cutoff, so the Metal and fallback paths agree. + """ + r2cut = _quantized_r2cut(radius_per_atom, voxel_size) + mask = torch.ones(xyz.shape[0], 5, dtype=xyz.dtype, device=xyz.device) + return MetalGridDensity.apply( + density_map, xyz, adp, occ, A, B, r2cut, mask, inv_frac_matrix, frac_matrix + ) + + +class MetalGridDensityAniso(torch.autograd.Function): + """Anisotropic per-atom Metal splat: returns ``density_map + splat``.""" + + @staticmethod + def forward(ctx, density_map, xyz, u, occ, A, B, r2cut, mask, inv_frac, frac): + lib = _get_lib() + if lib is None: + raise RuntimeError("Metal splat kernels unavailable") + nx, ny, nz = (int(s) for s in density_map.shape) + out = density_map.contiguous().clone() + n = xyz.shape[0] + if n > 0: + lib.aniso_splat_fwd( + out.view(-1), + xyz.contiguous(), + u.contiguous(), + occ.contiguous(), + A.contiguous(), + B.contiguous(), + r2cut.contiguous(), + mask.contiguous(), + inv_frac.contiguous().view(-1), + frac.contiguous().view(-1), + n, nx, ny, nz, + threads=[n], + ) + ctx.save_for_backward(xyz, u, occ, A, B, r2cut, mask, inv_frac, frac) + ctx.grid_shape = (nx, ny, nz) + return out + + @staticmethod + def backward(ctx, grad_out): + xyz, u, occ, A, B, r2cut, mask, inv_frac, frac = ctx.saved_tensors + nx, ny, nz = ctx.grid_shape + n = xyz.shape[0] + grad_xyz = torch.zeros_like(xyz) + grad_u = torch.zeros_like(u) + grad_occ = torch.zeros_like(occ) + if n > 0: + lib = _get_lib() + lib.aniso_splat_bwd( + grad_xyz.view(-1), + grad_u.view(-1), + grad_occ, + grad_out.contiguous().view(-1), + xyz.contiguous(), + u.contiguous(), + occ.contiguous(), + A.contiguous(), + B.contiguous(), + r2cut.contiguous(), + mask.contiguous(), + inv_frac.contiguous().view(-1), + frac.contiguous().view(-1), + n, nx, ny, nz, + threads=[n], + ) + # order: density_map, xyz, u, occ, A, B, r2cut, mask, inv_frac, frac + return (grad_out, grad_xyz, grad_u, grad_occ, + None, None, None, None, None, None) + + +def add_anisotropic_mps_var( + real_space_grid, density_map, xyz, u, occ, A, B, + inv_frac_matrix, frac_matrix, radius_per_atom, voxel_size, +): + """Anisotropic variable-radius Metal splat; adds into ``density_map``. + + Signature mirrors ``add_anisotropic_plain_var`` (``real_space_grid`` is used + only for its grid shape). Each atom is truncated at its per-axis bounding box + with a sphere cull at the (quantized) per-atom radius -- far tighter than the + plain reference's full cube on anisotropic high-resolution cells. + """ + r2cut = _quantized_r2cut(radius_per_atom, voxel_size) + mask = torch.ones(xyz.shape[0], 5, dtype=xyz.dtype, device=xyz.device) + return MetalGridDensityAniso.apply( + density_map, xyz, u, occ, A, B, r2cut, mask, inv_frac_matrix, frac_matrix + ) diff --git a/torchref/base/electron_density/main.py b/torchref/base/electron_density/main.py index 621ad98..9c1b0d2 100644 --- a/torchref/base/electron_density/main.py +++ b/torchref/base/electron_density/main.py @@ -10,13 +10,15 @@ - ``Engine.AUTO`` — fastest available per device, all variable-radius: CUDA+float32 -> the work-queue Triton kernels - (``WorkQueueGridDensity`` / ``WorkQueueGridDensityAniso``); CPU -> the + (``WorkQueueGridDensity`` / ``WorkQueueGridDensityAniso``); CPU+float32 -> the grouped-separable variable-radius splat (``add_isotropic_cpu_separable_var`` / ``add_anisotropic_cpu_var``); - everything else (CUDA+float64, MPS) -> the portable plain-scatter - variable-radius splat (``add_isotropic_plain_var`` / - ``add_anisotropic_plain_var``). On a Triton kernel failure under AUTO it falls - through to the plain-scatter variable-radius splat. + MPS+float32 -> the native Metal kernels + (``add_isotropic_mps_var`` / ``add_anisotropic_mps_var``, compiled via + ``torch.mps.compile_shader``); everything else (CUDA/MPS float64) -> the + portable plain-scatter variable-radius splat (``add_isotropic_plain_var`` / + ``add_anisotropic_plain_var``). On a Triton/Metal kernel failure or + unavailability under AUTO it falls through to the plain-scatter splat. - ``Engine.EAGER`` — the portable plain-scatter variable-radius splat on every device. Double-differentiable; use it for Hessians / debugging. Force it with ``with use_engine(Engine.EAGER): ...``. @@ -206,8 +208,10 @@ def _add_isotropic( variable-radius Triton kernel (``WorkQueueGridDensity``). On kernel failure under AUTO it falls through to the portable splat; under ``Engine.TRITON`` it raises (never silently degrade). - - CPU + AUTO -> the fast C++-scatter grouped-separable splat. - - Everything else (``Engine.EAGER`` on any device, CUDA float64, MPS) -> the + - CPU + float32 + AUTO -> the fast C++-scatter grouped-separable splat. + - MPS + float32 + AUTO -> the native Metal kernel (``add_isotropic_mps_var``); + falls through to the portable splat if the shader is unavailable. + - Everything else (``Engine.EAGER`` on any device, CUDA/MPS float64) -> the portable plain-``scatter_add`` grouped splat: identical per-atom radius, double-differentiable, float64-capable, device-agnostic. @@ -251,6 +255,26 @@ def _add_isotropic( density_map, xyz, adp, occ, A, B, inv_frac_matrix, frac_matrix, grid_shape_tuple, voxel_size, radius_per_atom, ) + # Native Metal splat on Apple-silicon GPUs (float32 only). Falls through to + # the portable plain splat if the shader is unavailable or fails. + if ( + get_engine() is Engine.AUTO + and density_map.device.type == "mps" + and density_map.dtype == torch.float32 + ): + from torchref.base.electron_density.kernels.mps import ( + add_isotropic_mps_var, + mps_kernels_available, + ) + + if mps_kernels_available(): + try: + return add_isotropic_mps_var( + density_map, xyz, adp, occ, A, B, + inv_frac_matrix, frac_matrix, grid_shape_tuple, voxel_size, radius_per_atom, + ) + except Exception: + pass # fall through to the portable splat return add_isotropic_plain_var( density_map, xyz, adp, occ, A, B, inv_frac_matrix, frac_matrix, grid_shape_tuple, voxel_size, radius_per_atom, @@ -274,12 +298,14 @@ def _add_anisotropic( The per-atom radius is the isotropic bounding radius of the ellipsoid (largest principal axis, ``per_atom_radius_aniso``). - CUDA+float32 (engine permitting) -> ``WorkQueueGridDensityAniso``. CPU + AUTO - -> the fast C++-scatter grouped box-splat ``add_anisotropic_cpu_var``. - Everything else (``Engine.EAGER`` on any device, CUDA float64, MPS) -> the - portable plain-``scatter_add`` box-splat ``add_anisotropic_plain_var`` - (double-diff, float64-capable, device-agnostic). All paths use the per-atom - radius (isotropic bounding sphere of the ellipsoid). + CUDA+float32 (engine permitting) -> ``WorkQueueGridDensityAniso``. CPU + + float32 + AUTO -> the fast C++-scatter grouped box-splat + ``add_anisotropic_cpu_var``. MPS + float32 + AUTO -> the native Metal kernel + ``add_anisotropic_mps_var`` (falls through if unavailable). Everything else + (``Engine.EAGER`` on any device, CUDA/MPS float64) -> the portable + plain-``scatter_add`` box-splat ``add_anisotropic_plain_var`` (double-diff, + float64-capable, device-agnostic). All paths use the per-atom radius + (isotropic bounding sphere of the ellipsoid). """ n_sigma = get_sigma_cutoff_ed() radius_per_atom = per_atom_radius_aniso(B, u, n_sigma=n_sigma) @@ -318,6 +344,26 @@ def _add_anisotropic( real_space_grid, density_map, xyz, u, occ, A, B, inv_frac_matrix, frac_matrix, radius_per_atom, voxel_size, ) + # Native Metal splat on Apple-silicon GPUs (float32 only). Falls through to + # the portable plain splat if the shader is unavailable or fails. + if ( + get_engine() is Engine.AUTO + and density_map.device.type == "mps" + and density_map.dtype == torch.float32 + ): + from torchref.base.electron_density.kernels.mps import ( + add_anisotropic_mps_var, + mps_kernels_available, + ) + + if mps_kernels_available(): + try: + return add_anisotropic_mps_var( + real_space_grid, density_map, xyz, u, occ, A, B, + inv_frac_matrix, frac_matrix, radius_per_atom, voxel_size, + ) + except Exception: + pass # fall through to the portable splat return add_anisotropic_plain_var( real_space_grid, density_map, xyz, u, occ, A, B, inv_frac_matrix, frac_matrix, radius_per_atom, voxel_size, diff --git a/torchref/base/electron_density/radius_policy.py b/torchref/base/electron_density/radius_policy.py index ffcb5af..0b28055 100644 --- a/torchref/base/electron_density/radius_policy.py +++ b/torchref/base/electron_density/radius_policy.py @@ -48,6 +48,49 @@ def _u6_to_u3(u: torch.Tensor) -> torch.Tensor: return U3 +def _max_eig_sym3(A: torch.Tensor) -> torch.Tensor: + """Largest eigenvalue of a batch of symmetric 3x3 matrices, in closed form. + + Uses the analytic (trigonometric) solution of the characteristic cubic + (Smith 1961) so no ``torch.linalg.eigvalsh`` is needed -- that op is + unimplemented on MPS, and the eigendecomposition here feeds only the + (quantized) splat radius, so an exact decomposition is overkill. + + Parameters + ---------- + A : torch.Tensor + Symmetric matrices, shape (n, 3, 3). + + Returns + ------- + torch.Tensor + Largest eigenvalue per matrix, shape (n,). + """ + a00 = A[:, 0, 0]; a11 = A[:, 1, 1]; a22 = A[:, 2, 2] + a01 = A[:, 0, 1]; a02 = A[:, 0, 2]; a12 = A[:, 1, 2] + + p1 = a01 * a01 + a02 * a02 + a12 * a12 + q = (a00 + a11 + a22) / 3.0 + p2 = (a00 - q) ** 2 + (a11 - q) ** 2 + (a22 - q) ** 2 + 2.0 * p1 + p = torch.sqrt((p2 / 6.0).clamp(min=1e-30)) + + # B = (A - q I) / p ; r = det(B) / 2 in [-1, 1] + b00 = (a00 - q) / p; b11 = (a11 - q) / p; b22 = (a22 - q) / p + b01 = a01 / p; b02 = a02 / p; b12 = a12 / p + detB = ( + b00 * (b11 * b22 - b12 * b12) + - b01 * (b01 * b22 - b12 * b02) + + b02 * (b01 * b12 - b11 * b02) + ) + r = (detB / 2.0).clamp(-1.0, 1.0) + phi = torch.acos(r) / 3.0 + eig_max = q + 2.0 * p * torch.cos(phi) + + # Diagonal matrices (p1 == 0): eigenvalues are the diagonal entries. + diag_max = torch.maximum(torch.maximum(a00, a11), a22) + return torch.where(p1 <= 1e-30, diag_max, eig_max) + + def sigma_eff_iso(adp: torch.Tensor, B_widths: torch.Tensor) -> torch.Tensor: """``sigma_eff_i = sqrt((max_k B_widths[i,k] + adp_i) / 8pi^2)``, shape (n,). @@ -93,7 +136,8 @@ def sigma_eff_aniso(B_widths: torch.Tensor, u: torch.Tensor) -> torch.Tensor: Anisotropic U parameters [U11,U22,U33,U12,U13,U23], shape (n, 6). """ b_form = B_widths.detach().max(dim=1).values # broadest ITC92 width - lam_max = torch.linalg.eigvalsh(_u6_to_u3(u.detach())).max(dim=1).values + # Closed-form largest eigenvalue (no eigvalsh -> runs natively on MPS). + lam_max = _max_eig_sym3(_u6_to_u3(u.detach())) return torch.sqrt((b_form / EIGHT_PI2 + lam_max).clamp(min=1e-6)) From b0d1faadd59733e38f7551b8dd5506b7e42bd640 Mon Sep 17 00:00:00 2001 From: HatPdotS Date: Sat, 25 Jul 2026 20:48:56 +0900 Subject: [PATCH 03/15] Fixed some test failures and bug in multiplicity calculation --- tests/unit/base/test_kernel_import_shims.py | 17 +- tests/unit/refinement/test_ml_sigmaa.py | 17 +- .../unit/symmetry/test_hkl_symmetry_gemmi.py | 158 ++++++++++++++++++ torchref/base/targets/xray_ml_sigmaa.py | 29 ++-- torchref/symmetry/spacegroup.py | 19 ++- 5 files changed, 215 insertions(+), 25 deletions(-) create mode 100644 tests/unit/symmetry/test_hkl_symmetry_gemmi.py diff --git a/tests/unit/base/test_kernel_import_shims.py b/tests/unit/base/test_kernel_import_shims.py index b6faf27..4bb8fc7 100644 --- a/tests/unit/base/test_kernel_import_shims.py +++ b/tests/unit/base/test_kernel_import_shims.py @@ -11,6 +11,12 @@ import pytest +from torchref.utils.triton_dispatch import triton_available + +_needs_triton = pytest.mark.skipif( + not triton_available(), reason="CUDA/Triton backend requires the triton package" +) + def test_kernels_public_api_resolves(): kernels = importlib.import_module("torchref.base.kernels") @@ -90,8 +96,15 @@ def test_solvent_radius_offsets_import_path(): "torchref.base.electron_density.kernels.cpu.scatter_dispatch", "torchref.base.electron_density.kernels.cpu.jit_reference", "torchref.base.electron_density.kernels.cpu.variable_radius", - "torchref.base.electron_density.kernels.cuda.fused", - "torchref.base.electron_density.kernels.cuda.variable_radius", + # CUDA/Triton backend: importing pulls in `triton`, absent on non-CUDA + # hosts (e.g. macOS), so gate these on triton availability. + pytest.param( + "torchref.base.electron_density.kernels.cuda.fused", marks=_needs_triton + ), + pytest.param( + "torchref.base.electron_density.kernels.cuda.variable_radius", + marks=_needs_triton, + ), # MPS Metal kernels: importing must not trigger shader compilation, so # these resolve cleanly on every platform (compile is deferred to first use). "torchref.base.electron_density.kernels.mps", diff --git a/tests/unit/refinement/test_ml_sigmaa.py b/tests/unit/refinement/test_ml_sigmaa.py index a264d2d..405836d 100644 --- a/tests/unit/refinement/test_ml_sigmaa.py +++ b/tests/unit/refinement/test_ml_sigmaa.py @@ -189,15 +189,20 @@ def test_beta_physical_and_falls_with_resolution(self, refinement): assert (beta[v] > 0).all() and torch.isfinite(beta[v]).all() # The falling-with-resolution trend is an estimator-math property, - # checked in float64 so it is device-independent. + # checked in float64 so it is device-independent. Move to CPU before + # casting -- MPS has no float64, so the double() must happen off-device. data = refinement.reflection_data with torch.no_grad(): - fc = torch.abs(t._scaled_F_calc_full()).double().reshape(-1) - fo = data.get_corrected_data()[0].double().reshape(-1) - epsd = epsilon_from_hkl(data.hkl, getattr(data, "spacegroup", None)).double() + fc = torch.abs(t._scaled_F_calc_full()).cpu().double().reshape(-1) + fo = data.get_corrected_data()[0].cpu().double().reshape(-1) + epsd = epsilon_from_hkl( + data.hkl, getattr(data, "spacegroup", None) + ).cpu().double() s = get_scattering_vectors(data.hkl, data.cell) - dss = (torch.norm(s, dim=1) ** 2).double() - _b, bbin, _ = estimate_beta(fo, fc, data.centric, epsd, dss, data.free.mask) + dss = (torch.norm(s, dim=1) ** 2).cpu().double() + _b, bbin, _ = estimate_beta( + fo, fc, data.centric.cpu(), epsd, dss, data.free.mask.cpu() + ) assert (bbin > 0).all() and torch.isfinite(bbin).all() # beta is an absolute model-error variance in F^2 units (~(1-sigma_A^2)* # Sigma_N); Sigma_N ~= decays steeply with resolution, so absolute diff --git a/tests/unit/symmetry/test_hkl_symmetry_gemmi.py b/tests/unit/symmetry/test_hkl_symmetry_gemmi.py new file mode 100644 index 0000000..8953b20 --- /dev/null +++ b/tests/unit/symmetry/test_hkl_symmetry_gemmi.py @@ -0,0 +1,158 @@ +"""Regression tests for reciprocal-space (Miller-index) symmetry against gemmi. + +These guard the ``h' = h·R = Rᵀ·h`` reciprocal-space convention used by +``SpaceGroup.apply_to_hkl``. A previous bug applied the real-space transform +``R·h`` instead, which is only correct when the fractional rotation matrix is +symmetric. For space groups whose rotation matrices are non-symmetric +(trigonal, hexagonal, and permutation-type cubic operations) ``R·h`` produced +the wrong set of symmetry equivalents, corrupting the centric flags +(``is_centric_from_hkl``) and epsilon multiplicities (``epsilon_from_hkl``) +that feed French-Wilson intensity conversion and ML sigma_A weighting. + +Ground truth comes from gemmi: +- transform: ``gemmi.Op.apply_to_hkl`` +- centric: ``GroupOps.is_reflection_centric`` +- epsilon: ``GroupOps.epsilon_factor_without_centering`` (Friedel-doubled + for centric reflections, matching the Friedel-aware count in + ``epsilon_from_hkl``) +""" + +import numpy as np +import pytest +import torch + +from torchref.config import get_default_device, get_float_dtype, get_int_dtype + +# Space groups whose rotation matrices are non-symmetric — these are the ones +# that regress if apply_to_hkl uses R·h instead of Rᵀ·h. Plus orthorhombic / +# tetragonal controls (symmetric matrices) that must remain correct either way. +_TRIGONAL_HEXAGONAL = ["P 32 2 1", "P 31 2 1", "P 61 2 2", "P 6 2 2", "P 3 1 2"] +_CONTROLS = ["P 21 21 21", "P 43 21 2", "P 1"] +_SPACE_GROUPS = _TRIGONAL_HEXAGONAL + _CONTROLS + +# A spread of reflections including negative indices and axial/general cases. +_HKLS = [ + (1, 0, 0), (0, 1, 0), (0, 0, 1), (1, 1, 0), (2, 1, 0), (1, -1, 0), + (1, 2, 3), (0, 1, 2), (3, 1, 0), (0, 0, 2), (1, 0, 1), (-1, 2, -3), + (2, 2, 1), (1, 1, 1), +] + + +def _hkl_tensor(): + """Miller indices on the configured default device and integer dtype. + + Kept at the config defaults rather than a hardcoded float64/CPU so the test + exercises the same precision and device production does, and stays + MPS-compatible (MPS has no float64). The arithmetic here is exact in + float32 regardless: fractional rotation matrices are integer-valued + (gemmi's ``op.rot / 24``) and the Miller indices are small, so every + product and sum is well inside the exactly-representable integer range. + """ + return torch.tensor(_HKLS, dtype=get_int_dtype(), device=get_default_device()) + + +@pytest.mark.unit +@pytest.mark.parametrize("sg_name", _SPACE_GROUPS) +def test_apply_to_hkl_matches_gemmi_transform(sg_name): + """apply_to_hkl must reproduce gemmi's per-operation reciprocal transform. + + Compared as the *set* of equivalents per reflection so the test is + insensitive to operation ordering between torchref and gemmi. + """ + import gemmi + + from torchref.symmetry import SpaceGroup + + sg = SpaceGroup(sg_name) + hkl = _hkl_tensor() + + # torchref: (N, 3, ops) -> per-reflection set of equivalents + out = sg.apply_to_hkl(hkl.to(get_float_dtype())) + out_int = torch.round(out).to(torch.int64).cpu().numpy() # (N, 3, ops) + + gemmi_ops = list(gemmi.SpaceGroup(sg_name).operations().sym_ops) + + for n, h in enumerate(_HKLS): + got = {tuple(out_int[n, :, o]) for o in range(out_int.shape[2])} + expected = {tuple(op.apply_to_hkl(h)) for op in gemmi_ops} + assert got == expected, ( + f"{sg_name} hkl={h}: apply_to_hkl equivalents {sorted(got)} " + f"!= gemmi {sorted(expected)}" + ) + + +@pytest.mark.unit +@pytest.mark.parametrize("sg_name", _SPACE_GROUPS) +def test_is_centric_from_hkl_matches_gemmi(sg_name): + """Centric flags must match gemmi's is_reflection_centric.""" + import gemmi + + from torchref.base.french_wilson import is_centric_from_hkl + + ops = gemmi.SpaceGroup(sg_name).operations() + hkl = _hkl_tensor() + + got = is_centric_from_hkl(hkl, sg_name).cpu().numpy().astype(bool) + expected = np.array([ops.is_reflection_centric(h) for h in _HKLS], dtype=bool) + + assert np.array_equal(got, expected), ( + f"{sg_name}: centric {got.astype(int)} != gemmi {expected.astype(int)}" + ) + + +@pytest.mark.unit +@pytest.mark.parametrize("sg_name", _SPACE_GROUPS) +def test_epsilon_from_hkl_matches_gemmi(sg_name): + """Friedel-aware epsilon must match gemmi's epsilon (doubled for centrics).""" + import gemmi + + from torchref.base.targets.xray_ml_sigmaa import epsilon_from_hkl + from torchref.symmetry import SpaceGroup + + ops = gemmi.SpaceGroup(sg_name).operations() + sg = SpaceGroup(sg_name) + hkl = _hkl_tensor() + + got = epsilon_from_hkl(hkl, sg).cpu().numpy().round().astype(int) + expected = np.array( + [ + ops.epsilon_factor_without_centering(h) + * (2 if ops.is_reflection_centric(h) else 1) + for h in _HKLS + ], + dtype=int, + ) + + assert np.array_equal(got, expected), ( + f"{sg_name}: epsilon {got} != gemmi-derived {expected}" + ) + + +@pytest.mark.unit +def test_apply_to_hkl_is_transpose_not_plain_rotation(): + """Explicit guard on the exact bug: apply_to_hkl == Rᵀ·h, not R·h. + + Uses P3, whose 3-fold rotation matrix is non-symmetric, so R·h and Rᵀ·h + give genuinely different results. + """ + from torchref.symmetry import SpaceGroup + + sg = SpaceGroup("P 3") + # Use the SpaceGroup's own dtype (config default) -- no hardcoded float64, + # so this runs on MPS as well as CPU/CUDA. + mats = sg.matrices # (ops, 3, 3) + hkl = torch.tensor([[1, 2, 3]], dtype=mats.dtype, device=mats.device) + + out = sg.apply_to_hkl(hkl) # (1, 3, ops) + + # Correct reciprocal transform: Rᵀ·h + expected_t = torch.einsum("oji,nj->nio", mats, hkl) + # The old (buggy) real-space transform: R·h + plain_r = torch.einsum("oij,nj->nio", mats, hkl) + + assert torch.allclose(out, expected_t), "apply_to_hkl should compute Rᵀ·h" + # For P3 the two conventions must actually differ (non-symmetric matrix), + # otherwise this test would not detect a regression. + assert not torch.allclose(expected_t, plain_r), ( + "P3 rotation matrices should be non-symmetric; test cannot detect the bug" + ) diff --git a/torchref/base/targets/xray_ml_sigmaa.py b/torchref/base/targets/xray_ml_sigmaa.py index cb92f09..f08e306 100644 --- a/torchref/base/targets/xray_ml_sigmaa.py +++ b/torchref/base/targets/xray_ml_sigmaa.py @@ -36,6 +36,8 @@ import numpy as np import torch +from torchref.config import get_float_dtype + # ===================================================================== # Loss math (mean = |Fc|, variance = epsilon*beta) # ===================================================================== @@ -112,19 +114,22 @@ def epsilon_from_hkl(hkl: torch.Tensor, spacegroup) -> torch.Tensor: ``apply_to_hkl``. """ n = hkl.shape[0] - ones = torch.ones(n, device=hkl.device) + float_dtype = get_float_dtype() if spacegroup is None or not hasattr(spacegroup, "apply_to_hkl"): - return ones - try: - with torch.no_grad(): - Hs = spacegroup.apply_to_hkl(hkl.to(torch.float64)) # (N,3,ops) - h0 = hkl.to(torch.float64).unsqueeze(-1) # (N,3,1) - same = (Hs == h0).all(dim=1) - friedel = (Hs == -h0).all(dim=1) - eps = (same | friedel).sum(dim=1).clamp(min=1).to(torch.get_default_dtype()) - return eps.to(hkl.device) - except Exception: - return ones + return torch.ones(n, device=hkl.device, dtype=float_dtype) + + with torch.no_grad(): + # Configured float dtype, not float64: MPS has no float64 and casting + # there raises. Symmetry arithmetic on Miller indices is exact in + # float32 (integer-valued rotation matrices, small indices), so the + # exact `==` comparisons below remain valid. + h = hkl.to(float_dtype) + Hs = spacegroup.apply_to_hkl(h) # (N,3,ops) + h0 = h.unsqueeze(-1) # (N,3,1) + same = (Hs == h0).all(dim=1) + friedel = (Hs == -h0).all(dim=1) + eps = (same | friedel).sum(dim=1).clamp(min=1).to(float_dtype) + return eps # ===================================================================== diff --git a/torchref/symmetry/spacegroup.py b/torchref/symmetry/spacegroup.py index 02f4ecd..a622e71 100644 --- a/torchref/symmetry/spacegroup.py +++ b/torchref/symmetry/spacegroup.py @@ -864,9 +864,15 @@ def apply_to_hkl(self, hkl: torch.Tensor) -> torch.Tensor: """ Apply symmetry operations to Miller indices (rotation only, no translation). - For reciprocal space, only the rotational part of symmetry operations - applies to Miller indices: h' = R·h. The translation vector affects the - phase of structure factors, not the indices themselves. + For reciprocal space, the symmetry-equivalent Miller index is obtained + by the *transpose* of the (real-space, fractional) rotation matrix: + ``h' = h·R = Rᵀ·h``. This is NOT the same as the real-space transform + ``x' = R·x`` unless ``R`` is symmetric: for space groups whose fractional + rotation matrices are non-symmetric (trigonal, hexagonal, and permutation- + type cubic operations) applying ``R·h`` instead of ``Rᵀ·h`` yields the + wrong set of equivalents, corrupting centric-flag and epsilon-multiplicity + determination. The translation vector affects the phase of structure + factors, not the indices themselves, so it is not applied here. Parameters ---------- @@ -881,9 +887,12 @@ def apply_to_hkl(self, hkl: torch.Tensor) -> torch.Tensor: See Also -------- - apply : For real space coordinates (rotation + translation). + apply : For real space coordinates (rotation + translation), which uses + ``R·x`` and is the transpose of the reciprocal-space convention here. """ - return self.apply(hkl, apply_translation=False) + coords = hkl.to(self.matrices.device).to(self.matrices.dtype) + # result[n, i, o] = sum_j matrices[o, j, i] * coords[n, j] = (Rᵀ·h)_i + return torch.einsum("oji,nj->nio", self.matrices, coords) def expand_coords_to_P1(self, xyz_fractional: torch.Tensor) -> torch.Tensor: """ From c52387cebf01d3284db7baab59a0bb3b57b971ec Mon Sep 17 00:00:00 2001 From: HatPdotS Date: Tue, 28 Jul 2026 16:29:43 +0900 Subject: [PATCH 04/15] Reworked structure factor calculation and reworked vdw pair list creation --- docs/changelog.rst | 4 + pyproject.toml | 20 + tests/conftest.py | 190 +++++++- tests/helpers/__init__.py | 1 + tests/helpers/device_asserts.py | 292 +++++++++++ tests/helpers/device_cases.py | 337 +++++++++++++ tests/helpers/device_inventory.py | 70 +++ tests/integration/test_ds_triton_vs_eager.py | 2 +- tests/integration/test_sfds_device.py | 2 +- .../test_triton_vs_eager_targets.py | 14 +- tests/integration/test_variable_radius_gpu.py | 2 +- tests/integration/test_variable_radius_mps.py | 2 +- tests/pytest.ini | 14 +- tests/unit/base/test_math_torch.py | 4 +- tests/unit/io/test_data.py | 8 +- tests/unit/model/test_model.py | 2 +- tests/unit/model/test_parameter_wrappers.py | 2 +- .../unit/refinement/test_forcefield_target.py | 15 + tests/unit/refinement/test_targets.py | 2 +- tests/unit/restraints/test_restraints.py | 2 +- tests/unit/scaling/test_scaler.py | 2 +- tests/unit/symmetry/test_symmetry.py | 4 +- tests/unit/test_device_conformance.py | 255 ++++++++++ tests/unit/test_device_mixin.py | 70 +++ tests/unit/test_gradient_correctness.py | 12 +- tests/unit/utils/test_device_resolution.py | 2 +- torchref/config.py | 66 ++- torchref/io/datasets/reflection_data.py | 17 +- torchref/model/model.py | 33 +- torchref/model/parameter_wrappers.py | 96 +++- torchref/model/rigid_xyz.py | 25 +- torchref/model/sf_ds.py | 17 +- torchref/model/sf_fft.py | 24 +- torchref/refinement/loss_state.py | 50 +- torchref/refinement/targets/adp/base.py | 3 +- torchref/refinement/targets/adp/locality.py | 56 ++- torchref/refinement/targets/adp/similarity.py | 28 +- torchref/refinement/targets/base.py | 95 +++- torchref/refinement/targets/geometry/base.py | 3 +- .../refinement/targets/geometry/non_bonded.py | 58 ++- torchref/refinement/targets/similarity.py | 40 +- torchref/refinement/targets/xray/base.py | 5 +- .../refinement/targets/xray/bhattacharyya.py | 45 +- .../refinement/targets/xray/least_squares.py | 2 + .../targets/xray/maximum_likelihood.py | 10 +- torchref/scaling/scaler.py | 8 + torchref/scaling/scaler_base.py | 7 + torchref/utils/device_mixin.py | 453 ++++++++++++++++-- torchref/utils/device_resolution.py | 23 +- torchref/utils/utils.py | 14 +- 50 files changed, 2220 insertions(+), 288 deletions(-) create mode 100644 tests/helpers/__init__.py create mode 100644 tests/helpers/device_asserts.py create mode 100644 tests/helpers/device_cases.py create mode 100644 tests/helpers/device_inventory.py create mode 100644 tests/unit/test_device_conformance.py diff --git a/docs/changelog.rst b/docs/changelog.rst index 7660f6a..b56d031 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -4,6 +4,10 @@ Changelog Version 0.6.2 ------------- - Fixed crash in difference refinement with mismatched reflection files: HKL reindexing (``validate_hkl``/``remap``/``reduce_to_spacegroup``) now carries all per-reflection fields (including the anomalous bookkeeping read by ``hkl_for_sf()``) instead of a hardcoded subset. +- Added a metal shader for structure factor calculation +- Standardized structure factor calculation geometry +- Split gpu tests into cuda and mps +- Reworked VDW pair list creation Version 0.6.1 ------------- diff --git a/pyproject.toml b/pyproject.toml index 22107c0..d7de55b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -98,8 +98,28 @@ profile = "black" line_length = 88 [tool.pytest.ini_options] +# Used when pytest is invoked from the repo root. ``tests/pytest.ini`` takes +# over when invoked from inside ``tests/``; keep the two in step so behaviour +# does not depend on the working directory. Accelerator tests are gated by +# ``tests/conftest.py``, which runs whatever the host supports -- do not add a +# default ``-m`` expression here, as it deselects at collection time and no +# flag can undo it. testpaths = ["tests"] python_files = "test_*.py" +python_classes = "Test*" +python_functions = "test_*" +pythonpath = ["."] +markers = [ + "unit: Unit tests (fast, no I/O, isolated)", + "integration: Integration tests (slower, real I/O, pipelines)", + "gpu: Needs any accelerator (CUDA or MPS); auto-skipped if the host has none", + "cuda: Needs CUDA specifically (e.g. Triton kernels); auto-skipped if absent", + "mps: Needs MPS specifically (Metal kernels); auto-skipped if absent", + "cuda_only: Deprecated alias for 'cuda'", + "slow: Long-running tests (skipped in quick runs)", + "openmm: Tests requiring OpenMM (the [amber] extra); skipped if absent", + "amber: Tests requiring OpenMM + AmberTools (antechamber/tleap); skipped if absent", +] [tool.ruff.lint] ignore = ["F841","E741","F841","E402","E722"] \ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py index 905a195..d5afc48 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -5,6 +5,7 @@ """ import importlib.util import shutil +import warnings import pytest import torchref @@ -21,13 +22,40 @@ _HAS_AMBERTOOLS = bool(shutil.which("antechamber") and shutil.which("tleap")) +def _cuda_available() -> bool: + return torch.cuda.is_available() + + +def _mps_available() -> bool: + return hasattr(torch.backends, "mps") and torch.backends.mps.is_available() + + def pytest_addoption(parser): """Add custom command line options.""" + parser.addoption( + "--run-cuda", + action="store_true", + default=False, + help=( + "Require CUDA: fail instead of skipping if it is unavailable. " + "CUDA tests already run automatically when a CUDA device is " + "present; use this in CI to catch a runner that lost its GPU." + ), + ) + parser.addoption( + "--run-mps", + action="store_true", + default=False, + help=( + "Require MPS: fail instead of skipping if it is unavailable. " + "MPS tests already run automatically on Apple silicon." + ), + ) parser.addoption( "--run-gpu", action="store_true", default=False, - help="Run GPU tests" + help="Deprecated no-op: accelerator tests now run automatically.", ) parser.addoption( "--run-slow", @@ -41,26 +69,77 @@ def pytest_configure(config): """Configure pytest markers.""" config.addinivalue_line("markers", "unit: Unit tests (fast, no I/O)") config.addinivalue_line("markers", "integration: Integration tests (slower, real I/O)") - config.addinivalue_line("markers", "gpu: GPU-requiring tests (CUDA or MPS; skipped by default)") - config.addinivalue_line("markers", "cuda_only: Tests that specifically require CUDA (e.g. Triton)") + config.addinivalue_line("markers", "gpu: Needs any accelerator (CUDA or MPS); auto-skipped if none") + config.addinivalue_line("markers", "cuda: Needs CUDA specifically (e.g. Triton); auto-skipped if absent") + config.addinivalue_line("markers", "mps: Needs MPS specifically (Metal kernels); auto-skipped if absent") + config.addinivalue_line("markers", "cuda_only: Deprecated alias for 'cuda'") config.addinivalue_line("markers", "slow: Slow tests (skipped by default)") config.addinivalue_line("markers", "openmm: Needs OpenMM (the [amber] extra); skipped if absent") config.addinivalue_line("markers", "amber: Needs OpenMM + AmberTools (antechamber/tleap); skipped if absent") + if config.getoption("--run-gpu"): + # UserWarning, not DeprecationWarning: pytest.ini filters the latter, + # and a silently-swallowed notice is worse than none when the whole + # point is telling someone their flag no longer does anything. + warnings.warn( + "--run-gpu is deprecated and does nothing: accelerator tests now " + "run automatically wherever the backend is available. Use " + "--run-cuda / --run-mps to *require* a backend (fail rather than " + "skip when it is missing).", + UserWarning, + stacklevel=2, + ) + def pytest_collection_modifyitems(config, items): - """Skip GPU/slow tests unless explicitly requested.""" - skip_gpu = pytest.mark.skip(reason="Need --run-gpu option to run") + """Gate tests on what this host can actually do. + + Accelerator tests are **not** opt-in: a ``cuda``-marked test runs whenever + CUDA is present, an ``mps``-marked test whenever MPS is, and a ``gpu``-marked + (backend-agnostic) test whenever either is. Anything the host cannot run is + skipped with a reason naming the missing backend. + + ``--run-cuda`` / ``--run-mps`` invert the *absence* case from skip to + failure, for CI that expects a specific backend and would otherwise go + green on a runner that quietly lost its GPU. + """ + has_cuda = _cuda_available() + has_mps = _mps_available() + + require_cuda = config.getoption("--run-cuda") + require_mps = config.getoption("--run-mps") + if require_cuda and not has_cuda: + raise pytest.UsageError("--run-cuda given but no CUDA device is available") + if require_mps and not has_mps: + raise pytest.UsageError("--run-mps given but MPS is not available") + skip_slow = pytest.mark.skip(reason="Need --run-slow option to run") skip_openmm = pytest.mark.skip(reason="OpenMM not installed (pip install '.[amber]')") skip_amber = pytest.mark.skip( reason="AmberTools (antechamber/tleap) not on PATH (conda install ambertools)" ) + skip_cuda = pytest.mark.skip(reason="No CUDA device on this host") + skip_mps = pytest.mark.skip(reason="No MPS device on this host") + skip_gpu = pytest.mark.skip(reason="No accelerator (CUDA or MPS) on this host") for item in items: - if "gpu" in item.keywords and not config.getoption("--run-gpu"): + keywords = item.keywords + # ``cuda_only`` is the retired spelling of ``cuda``. + wants_cuda = "cuda" in keywords or "cuda_only" in keywords + wants_mps = "mps" in keywords + if wants_cuda and not has_cuda: + item.add_marker(skip_cuda) + if wants_mps and not has_mps: + item.add_marker(skip_mps) + # A bare ``gpu`` mark means "any accelerator"; a test that also names a + # specific backend has already been gated on the stricter condition. + if ( + "gpu" in keywords + and not (wants_cuda or wants_mps) + and not (has_cuda or has_mps) + ): item.add_marker(skip_gpu) - if "slow" in item.keywords and not config.getoption("--run-slow"): + if "slow" in keywords and not config.getoption("--run-slow"): item.add_marker(skip_slow) # Amber stack gates: "amber" needs OpenMM + AmberTools; "openmm" needs # just OpenMM. Skip with the most specific missing-dependency reason. @@ -135,26 +214,93 @@ def cpu_device() -> torch.device: return torch.device("cpu") -def _gpu_available() -> bool: - """Return True if any GPU backend (CUDA or MPS) is available.""" - if torch.cuda.is_available(): - return True - if hasattr(torch.backends, "mps") and torch.backends.mps.is_available(): - return True - return False - - @pytest.fixture(scope="session") def gpu_device() -> torch.device: """GPU torch device (only use with @pytest.mark.gpu). Prefers CUDA, falls back to MPS; skips if neither is available. """ - if torch.cuda.is_available(): - return torch.device("cuda") - if hasattr(torch.backends, "mps") and torch.backends.mps.is_available(): - return torch.device("mps") - pytest.skip("No GPU (CUDA or MPS) available") + accel = _accelerator() + if accel is None: + pytest.skip("No accelerator (CUDA or MPS) on this host") + return accel + + +def _accelerator() -> "torch.device | None": + """The canonical accelerator this host can actually use, or ``None``. + + Indices are filled in (``cuda:0`` / ``mps:0``) so the value compares equal + to a device read back off a real tensor -- ``torch.device('mps')`` and + ``torch.device('mps:0')`` are *not* equal even though they name the same + physical device. + """ + if _cuda_available(): + return torch.device("cuda", torch.cuda.current_device()) + if _mps_available(): + return torch.device("mps", 0) + return None + + +# Built at import time so the ``gpu`` mark is attached during *collection*. +# Adding it later (e.g. via ``request.node.add_marker`` inside the fixture) is +# too late for ``pytest_collection_modifyitems`` to gate on. +_DEVICE_PARAMS = [pytest.param(torch.device("cpu"), id="cpu")] +_ACCELERATOR = _accelerator() +if _ACCELERATOR is not None: + _DEVICE_PARAMS.append( + pytest.param( + _ACCELERATOR, + id=_ACCELERATOR.type, + # Backend-specific mark, so a CUDA-less host skips the cuda leg and + # a non-Mac skips the mps leg, each with an accurate reason. + marks=getattr(pytest.mark, _ACCELERATOR.type), + ) + ) + + +@pytest.fixture(params=_DEVICE_PARAMS) +def any_device(request) -> torch.device: + """Every device this host can actually use, one test run per device. + + The CPU leg always runs. The accelerator leg is ``gpu``-marked, so a plain + ``pytest`` run skips it and ``pytest --run-gpu`` picks up CUDA on a CUDA + box or MPS on a Mac. On a CPU-only host the accelerator parameter does not + exist at all, so there is no skip noise. + """ + return request.param + + +@pytest.fixture(scope="session") +def _device_model_cache() -> dict: + """``{device_str: ModelFT}`` built at most once per device, per session.""" + return {} + + +@pytest.fixture +def device_model_bundle(_device_model_cache, pdb_dir, any_device): + """A loaded model on ``any_device``, for target conformance tests. + + The existing ``loaded_model`` / ``model_and_data`` fixtures are + function-scoped and construct on the process default, so a + device-parametrized sweep over them would reload the structure once per + test per device. This caches one model per device instead. + + Shared mutable state: callers must treat the bundle as read-only. A test + that moves a *target* will drag the borrowed model with it, poisoning every + later test on that device -- see ``test_target_device_round_trip``, which + deliberately builds its own. + """ + key = str(any_device) + if key not in _device_model_cache: + pdb = pdb_dir / "1DAW.pdb" + if not pdb.exists(): + pytest.skip("1DAW.pdb fixture not present") + from torchref.model import ModelFT + + _device_model_cache[key] = ModelFT(device=any_device, verbose=0).load_pdb( + str(pdb) + ) + return {"model": _device_model_cache[key]} @pytest.fixture @@ -171,7 +317,7 @@ def device(request) -> torch.device: markers = {m.name for m in request.node.iter_markers()} if "cuda_only" in markers and not torch.cuda.is_available(): pytest.skip("Test requires CUDA") - if "gpu" in markers and not _gpu_available(): + if "gpu" in markers and not (_cuda_available() or _mps_available()): pytest.skip("No GPU (CUDA or MPS) available") return get_default_device() diff --git a/tests/helpers/__init__.py b/tests/helpers/__init__.py new file mode 100644 index 0000000..52060c2 --- /dev/null +++ b/tests/helpers/__init__.py @@ -0,0 +1 @@ +"""Shared test helpers for the torchref suite.""" diff --git a/tests/helpers/device_asserts.py b/tests/helpers/device_asserts.py new file mode 100644 index 0000000..20fdd4b --- /dev/null +++ b/tests/helpers/device_asserts.py @@ -0,0 +1,292 @@ +"""Device-consistency assertions for torchref objects. + +The walker here is written **independently** of +:func:`torchref.utils.device_mixin._apply_to_obj`. That is deliberate: an oracle +that mirrors the implementation it checks can only ever confirm the +implementation's own idea of what is reachable, so it would be blind to exactly +the tensors ``DeviceMixin`` forgets to move. This walker instead enumerates +everything it can reach by generic Python/PyTorch means -- ``__dict__``, +``__slots__``, module parameters/buffers/children, containers, dataclass +fields, and :class:`~torchref.utils.utils.ModuleReference` wrappers. + +Exports +------- +assert_device_consistent + Fail unless every reachable tensor *and* every device tracker sits on the + expected device. Reports all mismatches at once. +collect_device_map + The underlying survey, returned as ``{path: torch.device}``. Useful when a + test wants to assert something more specific than "all on one device". +HOST_SIDE + Paths that are intentionally CPU-resident regardless of the object's + device. Every entry needs a comment justifying it. +""" + +from __future__ import annotations + +import dataclasses +from typing import Any, Dict, Iterable, List, Optional, Set, Tuple + +import torch +from torch import nn + +from torchref.config import canonical_device + +__all__ = [ + "assert_device_consistent", + "collect_device_map", + "HOST_SIDE", +] + + +# Attribute *leaf names* that are legitimately CPU-resident even when the +# owning object lives on an accelerator. Keep this list short and justified -- +# it is the escape hatch that makes the conformance suite meaningful rather +# than a rubber stamp. +# +# NOTE: a registered ``nn.Module`` buffer can never be host-side in practice, +# because ``module.to(device)`` relocates it regardless of what this set says. +# Anything that genuinely must stay on the host belongs in plain Python state +# (or ``get_extra_state``), not in a buffer. If you find yourself wanting to add +# a buffer name here, convert the buffer instead. +HOST_SIDE: Set[str] = set() + + +# Attributes on ``nn.Module`` that hold PyTorch's own bookkeeping. They are +# traversed explicitly via ``named_parameters``/``named_buffers``/ +# ``named_children``, so walking the raw dicts as well would only duplicate work +# and produce confusing double paths. +_MODULE_INTERNALS = frozenset( + { + "_parameters", + "_buffers", + "_modules", + "_non_persistent_buffers_set", + "_backward_hooks", + "_backward_pre_hooks", + "_forward_hooks", + "_forward_hooks_with_kwargs", + "_forward_pre_hooks", + "_forward_pre_hooks_with_kwargs", + "_state_dict_hooks", + "_state_dict_pre_hooks", + "_load_state_dict_pre_hooks", + "_load_state_dict_post_hooks", + "_is_full_backward_hook", + "training", + } +) + +_TRACKER_ATTRS = ("device", "_device") +_DTYPE_TRACKER_ATTRS = ("dtype_float", "_dtype") + +_MAX_DEPTH = 12 + + +def _slot_names(obj: Any) -> Iterable[str]: + """Every ``__slots__`` entry declared anywhere in ``type(obj)``'s MRO.""" + for klass in type(obj).__mro__: + for name in getattr(klass, "__slots__", ()) or (): + yield name + + +def _iter_attributes(obj: Any) -> Iterable[Tuple[str, Any]]: + """Yield ``(name, value)`` for the object's own state. + + Covers ``__dict__`` (skipping ``nn.Module`` bookkeeping), ``__slots__``, and + dataclass fields -- a dataclass with ``__slots__`` exposes neither through + ``__dict__``, which is how ``_ReflectionSubset``-style views hide tensors + from a naive walk. + """ + seen: Set[str] = set() + + for name, value in list(getattr(obj, "__dict__", {}).items()): + if name in _MODULE_INTERNALS: + continue + seen.add(name) + yield name, value + + for name in _slot_names(obj): + if name in seen or name.startswith("__"): + continue + seen.add(name) + try: + yield name, getattr(obj, name) + except AttributeError: + continue # declared slot, never assigned + + if dataclasses.is_dataclass(obj) and not isinstance(obj, type): + for field in dataclasses.fields(obj): + if field.name in seen: + continue + seen.add(field.name) + try: + yield field.name, getattr(obj, field.name) + except AttributeError: + continue + + +def _walk( + obj: Any, + path: str, + found: Dict[str, torch.device], + trackers: Dict[str, torch.device], + dtypes: Dict[str, torch.dtype], + dtype_trackers: Dict[str, torch.dtype], + visited: Set[int], + depth: int, +) -> None: + if obj is None or depth > _MAX_DEPTH: + return + if isinstance(obj, (str, bytes, int, float, bool, complex, torch.dtype, torch.device)): + return + + if isinstance(obj, torch.Tensor): + found[path] = obj.device + # Only floating/complex tensors carry the configured float dtype; + # integer index buffers and boolean masks have their own and must not + # be swept into the comparison. + if obj.is_floating_point() or obj.is_complex(): + dtypes[path] = obj.dtype + return + + if id(obj) in visited: + return + visited.add(id(obj)) + + args = (found, trackers, dtypes, dtype_trackers, visited) + + if isinstance(obj, nn.Module): + for name, tensor in list(obj.named_parameters(recurse=False)) + list( + obj.named_buffers(recurse=False) + ): + if tensor is None: + continue + found[f"{path}.{name}"] = tensor.device + if tensor.is_floating_point() or tensor.is_complex(): + dtypes[f"{path}.{name}"] = tensor.dtype + for name, child in obj.named_children(): + _walk(child, f"{path}.{name}", *args, depth + 1) + + # ``ModuleReference`` deliberately hides its payload from PyTorch's module + # tree, so a parameters/buffers sweep never sees it -- but the referenced + # object still has to agree on a device with the referrer. + wrapped = getattr(obj, "_wrapped_module", None) + if wrapped is not None and not isinstance(obj, nn.Module): + _walk(wrapped, f"{path}->ref", *args, depth + 1) + return + + for name, value in _iter_attributes(obj): + if name in _TRACKER_ATTRS and isinstance(value, (torch.device, str)): + try: + trackers[f"{path}.{name}"] = torch.device(value) + except (RuntimeError, TypeError): + pass # a ``device``-named attribute that isn't a device + continue + if name in _DTYPE_TRACKER_ATTRS and isinstance(value, torch.dtype): + dtype_trackers[f"{path}.{name}"] = value + continue + if name in HOST_SIDE: + continue + _walk(value, f"{path}.{name}", *args, depth + 1) + + if isinstance(obj, dict): + for key, value in list(obj.items()): + _walk(value, f"{path}[{key!r}]", *args, depth + 1) + elif isinstance(obj, (list, tuple, set, frozenset)): + for index, value in enumerate(obj): + _walk(value, f"{path}[{index}]", *args, depth + 1) + + +def collect_device_map(obj: Any, name: str = "obj") -> Tuple[Dict, Dict, Dict, Dict]: + """Survey ``obj`` along both the device and dtype axes. + + Returns ``(tensor_devices, tracker_devices, tensor_dtypes, tracker_dtypes)``, + each ``{path: value}``. Paths are dotted attribute chains, with ``[i]`` / + ``['k']`` for container elements and ``->ref`` where the walk crossed a + :class:`ModuleReference`. + + The dtype maps cover only floating/complex tensors and the + ``dtype_float`` / ``_dtype`` trackers, mirroring the mixin's rule that the + two axes are resolved independently -- an integer index buffer says nothing + about the configured float dtype. + """ + tensors: Dict[str, torch.device] = {} + trackers: Dict[str, torch.device] = {} + tensor_dtypes: Dict[str, torch.dtype] = {} + tracker_dtypes: Dict[str, torch.dtype] = {} + _walk(obj, name, tensors, trackers, tensor_dtypes, tracker_dtypes, set(), 0) + return tensors, trackers, tensor_dtypes, tracker_dtypes + + +def assert_device_consistent( + obj: Any, + expected: Any, + *, + name: str = "obj", + ignore: Optional[Iterable[str]] = None, + expected_dtype: Optional[torch.dtype] = None, +) -> None: + """Assert every reachable tensor and tracker sits on ``expected``. + + Parameters + ---------- + obj + Object to survey. + expected + Target device. Compared through + :func:`torchref.config.canonical_device` on both sides, so a bare + ``mps`` and an indexed ``mps:0`` are treated as equal -- the helper + tests placement, not spelling. + name + Root label used to build the reported paths. + ignore + Path substrings to exclude. Use for genuinely borrowed state, not to + paper over a split object. + expected_dtype + When given, also assert every floating/complex tensor and every + ``dtype_float`` / ``_dtype`` tracker matches. This is the only way to + catch the ``torch.tensor(float(x))`` class of bug, where a tunable is + hardcoded float32 under a float64 configuration. + + Raises + ------ + AssertionError + Listing **every** mismatch. A half-moved object usually has several, + and reporting only the first turns one debugging session into five. + """ + expected_dev = canonical_device(expected) + ignore = tuple(ignore or ()) + + tensors, trackers, tensor_dtypes, tracker_dtypes = collect_device_map(obj, name) + problems: List[str] = [] + + def skipped(path: str) -> bool: + return any(frag in path for frag in ignore) + + for path, dev in sorted(tensors.items()): + if not skipped(path) and canonical_device(dev) != expected_dev: + problems.append(f"{path}: tensor on {dev}, expected {expected_dev}") + + for path, dev in sorted(trackers.items()): + if not skipped(path) and canonical_device(dev) != expected_dev: + problems.append(f"{path}: tracker == {dev}, expected {expected_dev}") + + if expected_dtype is not None: + for path, dt in sorted(tensor_dtypes.items()): + if not skipped(path) and dt != expected_dtype: + problems.append(f"{path}: tensor dtype {dt}, expected {expected_dtype}") + for path, dt in sorted(tracker_dtypes.items()): + if not skipped(path) and dt != expected_dtype: + problems.append( + f"{path}: dtype tracker == {dt}, expected {expected_dtype}" + ) + + if problems: + target = f"{expected_dev}" + if expected_dtype is not None: + target += f" / {expected_dtype}" + raise AssertionError( + f"{type(obj).__name__} is not consistently on {target} " + f"({len(problems)} mismatch(es)):\n " + "\n ".join(problems) + ) diff --git a/tests/helpers/device_cases.py b/tests/helpers/device_cases.py new file mode 100644 index 0000000..321a982 --- /dev/null +++ b/tests/helpers/device_cases.py @@ -0,0 +1,337 @@ +"""Registry of cheaply-constructible device-bearing objects for conformance tests. + +Each :class:`DeviceCase` builds one object on a requested device. The suite in +``tests/unit/test_device_conformance.py`` then asserts that everything the +object owns actually landed there, and that it survives a device round trip. + +``UNCOVERED`` is the other half of the contract: every device-bearing class in +the source tree must appear either here or there, with a reason. That is what +keeps the registry from quietly rotting as the package grows -- see +``test_every_device_bearing_class_is_accounted_for``. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, Callable, Dict, List + +import torch + +__all__ = [ + "DeviceCase", + "CASES", + "TargetDeviceCase", + "TARGET_CASES", + "UNCOVERED", + "UNCOVERED_PREFIXES", +] + +_CELL = [50.0, 60.0, 70.0, 90.0, 90.0, 90.0] +_SG = "P 21 21 21" + + +@dataclass(frozen=True) +class DeviceCase: + """One constructible object under device test. + + Parameters + ---------- + name + Test id. + build + ``device -> object``. Must place everything on ``device``. + cls_name + Class this case covers, for the coverage guard. + tensor_free + True when the object legitimately owns no tensors (an empty shell). + Such objects can only be checked via their tracker. + ignore + Path fragments to exclude from the walk (borrowed state). + """ + + name: str + build: Callable[[torch.device], Any] + cls_name: str + tensor_free: bool = False + ignore: tuple = field(default_factory=tuple) + + +def _cell(device): + from torchref.symmetry import Cell + + return Cell(_CELL, device=device) + + +CASES: List[DeviceCase] = [ + DeviceCase("Cell", _cell, "Cell"), + DeviceCase( + "SpaceGroup", + lambda d: __import__( + "torchref.symmetry", fromlist=["SpaceGroup"] + ).SpaceGroup(_SG, device=d), + "SpaceGroup", + ), + # D4: device implied by the cell; the SpaceGroup used to be built from the + # raw (None) device argument and land on the process default instead. + DeviceCase( + "SfFFT_from_cell", + lambda d: __import__( + "torchref.model.sf_fft", fromlist=["SfFFT"] + ).SfFFT(cell=_cell(d), spacegroup=_SG, max_res=2.0), + "SfFFT", + ), + # D4: explicit device disagreeing with the supplied cell. + DeviceCase( + "SfFFT_explicit_device", + lambda d: __import__( + "torchref.model.sf_fft", fromlist=["SfFFT"] + ).SfFFT(cell=_cell("cpu"), spacegroup=_SG, max_res=2.0, device=d), + "SfFFT", + ), + DeviceCase( + "SfDS_from_cell", + lambda d: __import__( + "torchref.model.sf_ds", fromlist=["SfDS"] + ).SfDS(cell=_cell(d), spacegroup=_SG), + "SfDS", + ), + # D1: tensor-free shells, whose tracker is the only thing to check. + DeviceCase( + "ScalerBase_empty", + lambda d: __import__( + "torchref.scaling", fromlist=["ScalerBase"] + ).ScalerBase(device=d), + "ScalerBase", + tensor_free=True, + ), + DeviceCase( + "LossState", + lambda d: __import__( + "torchref.refinement.loss_state", fromlist=["LossState"] + ).LossState(device=d), + "LossState", + tensor_free=True, + ), + DeviceCase( + "SolventModel", + lambda d: __import__( + "torchref.scaling.solvent", fromlist=["SolventModel"] + ).SolventModel(device=d), + "SolventModel", + ), + # D9: empty-init wrappers used to drop the requested device entirely. + DeviceCase( + "MixedTensor_empty", + lambda d: __import__( + "torchref.model.parameter_wrappers", fromlist=["MixedTensor"] + ).MixedTensor(device=d), + "MixedTensor", + ), + DeviceCase( + "MixedTensor_populated", + lambda d: __import__( + "torchref.model.parameter_wrappers", fromlist=["MixedTensor"] + ).MixedTensor( + torch.arange(12, dtype=torch.float32).reshape(4, 3), + # A 2D value tensor takes a 1D per-row mask. + refinable_mask=torch.zeros(4, dtype=torch.bool), + device=d, + ), + "MixedTensor", + ), + DeviceCase( + "PositiveMixedTensor", + lambda d: __import__( + "torchref.model.parameter_wrappers", fromlist=["PositiveMixedTensor"] + ).PositiveMixedTensor( + torch.arange(1, 5, dtype=torch.float32), + refinable_mask=torch.zeros(4, dtype=torch.bool), + device=d, + ), + "PositiveMixedTensor", + ), + DeviceCase( + "OccupancyTensor_empty", + lambda d: __import__( + "torchref.model.parameter_wrappers", fromlist=["OccupancyTensor"] + ).OccupancyTensor(device=d), + "OccupancyTensor", + ), + DeviceCase( + "RigidXYZTensor_empty", + lambda d: __import__( + "torchref.model.rigid_xyz", fromlist=["RigidXYZTensor"] + ).RigidXYZTensor(device=d), + "RigidXYZTensor", + ), + DeviceCase( + "ReciprocalSymmetryGrid", + lambda d: __import__( + "torchref.symmetry", fromlist=["ReciprocalSymmetryGrid"] + ).ReciprocalSymmetryGrid(_SG, grid_shape=(16, 16, 16), device=d), + "ReciprocalSymmetryGrid", + ), + DeviceCase( + "MapSymmetryDirect", + lambda d: __import__( + "torchref.symmetry", fromlist=["MapSymmetryDirect"] + ).MapSymmetryDirect( + _SG, map_shape=(16, 16, 16), cell_params=_CELL, device=d + ), + "MapSymmetryDirect", + ), + DeviceCase( + "TensorMasks", + lambda d: __import__( + "torchref.utils", fromlist=["TensorMasks"] + ).TensorMasks(device=d), + "TensorMasks", + tensor_free=True, + ), + DeviceCase( + "ReflectionData_empty", + lambda d: __import__( + "torchref.io", fromlist=["ReflectionData"] + ).ReflectionData(device=d), + "ReflectionData", + ), + DeviceCase( + "ManualWeighting", + lambda d: __import__( + "torchref.refinement.weighting", fromlist=["ManualWeighting"] + ).ManualWeighting({"geometry": 1.0}, device=d), + "ManualWeighting", + tensor_free=True, + ), +] + + +@dataclass(frozen=True) +class TargetDeviceCase: + """A refinement target, which needs a real model/data/scaler to construct. + + Separate from :class:`DeviceCase` because ``build`` takes a fixture-supplied + bundle as well as a device. The bundle is already on ``device``; ``build`` + must not cache or mutate it. + + ``ignore`` defaults to the wrapped objects: a target *borrows* its model and + data, and the conformance walk would otherwise re-check the entire structure + through every target that points at it. + """ + + name: str + build: Callable[[Dict[str, Any], torch.device], Any] + cls_name: str + needs: tuple = ("model",) + ignore: tuple = ("_model", "_data", "_scaler", "_model_dark", "_model_light") + + +TARGET_CASES: List[TargetDeviceCase] = [ + TargetDeviceCase( + "NonBondedTarget", + lambda b, d: __import__( + "torchref.refinement.targets.geometry.non_bonded", + fromlist=["NonBondedTarget"], + ).NonBondedTarget(b["model"]), + "NonBondedTarget", + ), + TargetDeviceCase( + "ADPSimilarityTarget", + lambda b, d: __import__( + "torchref.refinement.targets.adp.similarity", + fromlist=["ADPSimilarityTarget"], + ).ADPSimilarityTarget(b["model"]), + "ADPSimilarityTarget", + ), + TargetDeviceCase( + "ADPLocalityTarget", + lambda b, d: __import__( + "torchref.refinement.targets.adp.locality", fromlist=["ADPLocalityTarget"] + ).ADPLocalityTarget(b["model"]), + "ADPLocalityTarget", + ), + # Owns no tensors at all -- the case that exercises the request-driven + # tracker path rather than the owned-tensor path. + TargetDeviceCase( + "BondTarget", + lambda b, d: __import__( + "torchref.refinement.targets.geometry.bonds", fromlist=["BondTarget"] + ).BondTarget(b["model"]), + "BondTarget", + ), +] + + +# Device-bearing classes deliberately not in CASES. Every entry needs a reason; +# "hard to build" is a reason, "didn't get to it" is not. +UNCOVERED: Dict[str, str] = { + # --- abstract / mixin bases: never instantiated directly ----------------- + "Target": "abstract base; covered through its concrete subclasses", + "ModelTarget": "abstract base; needs a loaded model", + "DataTarget": "abstract base; needs model + data + scaler", + "XrayTarget": "abstract base; needs model + data + scaler", + "GeometryTarget": "abstract base; needs a model with restraints", + "CrystalDataset": "abstract dataclass base; covered via ReflectionData", + "BaseWeighting": "abstract base; covered via ManualWeighting", + "Refinement": "abstract base; covered via LBFGSRefinement in integration", + "PassThroughTensor": "documented non-functional stub (parameter_wrappers.py)", + "ADPTarget": "abstract base; needs a model with ADPs", + "CombinedTargets": "composite container; needs its component targets", + "CombinedModelTargets": "composite container; needs a loaded model", + "CollectionXrayTarget": "abstract base; needs a dataset collection", + # --- need loaded structures/data: covered by integration tests ----------- + "Model": "needs a PDB; covered by tests/unit/model/test_model_state_dict_device.py", + "ModelFT": "needs a PDB; covered by tests/unit/model/test_model_state_dict_device.py", + "MixedModel": "needs two loaded models", + "ModelCollection": "needs several loaded models", + "_SharedMixedModel": "internal view owned by ModelCollection", + "Scaler": "needs a loaded model + data; covered in integration", + "RestraintsNew": "needs a model + monomer library", + "HydrogenTopology": "needs a built restraint topology", + "FrenchWilson": "needs loaded intensities", + "DatasetCollection": "needs several loaded datasets", + "FcalcDataset": "needs computed structure factors", + "Map": "needs data + model", + "DifferenceMap": "needs two datasets + a model", + "LBFGSRefinement": "full pipeline; covered in integration", + "MapSymmetry": "interpolation variant; needs a real map grid", + "CholeskyMixedTensor": "needs a valid ADP tensor; shares MixedTensor's paths", + "CollectionScaler": "needs a dataset collection", + "CollectionDifferenceTarget": "needs a dataset collection", + "CollectionMLTarget": "needs a dataset collection", + "CollectionRiceTarget": "needs a dataset collection", + "ADPEntropyTarget": "needs a model with ADPs", + "AngleTarget": "needs a model with restraints", + "ChiralTarget": "needs a model with restraints", + "BhattacharyyaXrayTarget": "needs a scaler with initialised bins; buffers audited", + "ReciprocalSymmetryExtractor": "needs an hkl tensor + SpaceGroup; device is " + "intentionally inherited from hkl, so the generic 'requested device' " + "assertion does not apply", + "CoordinateSimilarityTarget": "needs two loaded models", + "MultiModelADPTarget": "needs a model collection", + "MultiModelGeometryTarget": "needs a model collection", + "TotalGeometryTarget": "composite; needs a model with restraints", + "TotalADPTarget": "composite; needs a model with restraints", + "NonBondedHTarget": "needs a model with hydrogen topology", + "PlanarityTarget": "needs a model with restraints", + "RamachandranTarget": "needs a model with restraints", + "TorsionTarget": "needs a model with restraints", + "RigidBondTarget": "needs a model with restraints", + "ScalerLogScaleTrendTarget": "needs a scaler", + "ScalerURegularizationTarget": "needs a scaler", + "GaussianXrayTarget": "needs model + data + scaler", + "LeastSquaresXrayTarget": "needs model + data + scaler", + "MaximumLikelihoodXrayTarget": "needs model + data + scaler", + "RiceXrayTarget": "needs model + data + scaler", + "DifferenceXrayTarget": "needs two datasets", + "PhaseInformedDifferenceTarget": "needs two datasets + phases", + "RiceDifferenceTarget": "needs two datasets", + "TaylorCorrectedDifferenceTarget": "needs two datasets", + "RigidTransform": "alignment helper; needs a coordinate set", + "RigidBodyRefinement": "experimental; needs model + data", +} + +# Everything under torchref/experimental is out of scope for the conformance +# sweep: it is explicitly unstable, and several modules need optional +# dependencies (OpenMM, AmberTools, JAX) that are not installed in CI. +UNCOVERED_PREFIXES = ("torchref/experimental/",) diff --git a/tests/helpers/device_inventory.py b/tests/helpers/device_inventory.py new file mode 100644 index 0000000..a404ac8 --- /dev/null +++ b/tests/helpers/device_inventory.py @@ -0,0 +1,70 @@ +"""Static inventory of ``DeviceMixin`` subclasses in the torchref source tree. + +Why AST rather than ``DeviceMixin.__subclasses__()``: the runtime hook only +sees classes whose defining module has been imported, so a coverage guard built +on it silently shrinks whenever an import is removed or an optional dependency +is missing -- exactly when you most want to be told about a gap. Parsing the +source finds every class whether or not it is importable here. + +Inheritance is resolved **transitively**: ``PositiveMixedTensor(MixedTensor)`` +never names a mixin alias itself, but it is still a device-bearing class and +still needs conformance coverage. +""" + +from __future__ import annotations + +import ast +from pathlib import Path +from typing import Dict, Set, Tuple + +__all__ = ["MIXIN_ALIASES", "device_mixin_classes"] + +# Every spelling of the one mixin. ``DeviceMovementMixin`` and +# ``_NonModuleDeviceMixin`` are aliases kept for backward compatibility. +MIXIN_ALIASES = frozenset( + {"DeviceMixin", "DeviceMovementMixin", "_NonModuleDeviceMixin"} +) + + +def _base_names(node: ast.ClassDef) -> Set[str]: + """Base-class names for ``node``, ignoring subscripts and keywords.""" + names = set() + for base in node.bases: + if isinstance(base, ast.Name): + names.add(base.id) + elif isinstance(base, ast.Attribute): + names.add(base.attr) + return names + + +def device_mixin_classes(package_root: Path) -> Dict[str, str]: + """Return ``{class_name: "relative/path.py:lineno"}`` for device-bearing classes. + + A class qualifies if any of its bases is a mixin alias, or is another + qualifying class -- iterated to a fixed point so multi-level hierarchies + are captured. + """ + definitions: Dict[str, Tuple[Set[str], str]] = {} + + for path in sorted(package_root.rglob("*.py")): + try: + tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + except (SyntaxError, UnicodeDecodeError): # pragma: no cover + continue + rel = path.relative_to(package_root.parent) + for node in ast.walk(tree): + if isinstance(node, ast.ClassDef): + definitions[node.name] = (_base_names(node), f"{rel}:{node.lineno}") + + qualifying: Dict[str, str] = {} + changed = True + while changed: + changed = False + for name, (bases, where) in definitions.items(): + if name in qualifying: + continue + if bases & MIXIN_ALIASES or bases & qualifying.keys(): + qualifying[name] = where + changed = True + + return qualifying diff --git a/tests/integration/test_ds_triton_vs_eager.py b/tests/integration/test_ds_triton_vs_eager.py index 6e68a8a..cddc707 100644 --- a/tests/integration/test_ds_triton_vs_eager.py +++ b/tests/integration/test_ds_triton_vs_eager.py @@ -4,7 +4,7 @@ eager backend evaluated in float64 (downcast for comparison), for both the isolated P1 kernels and the full ``SfDS`` symmetry loop. -Markers: ``@pytest.mark.gpu`` (skipped without ``--run-gpu``) and +Markers: ``@pytest.mark.cuda`` (skipped without ``--run-gpu``) and ``@pytest.mark.integration``. Requires a CUDA device. """ diff --git a/tests/integration/test_sfds_device.py b/tests/integration/test_sfds_device.py index e12d18f..8599e79 100644 --- a/tests/integration/test_sfds_device.py +++ b/tests/integration/test_sfds_device.py @@ -38,7 +38,7 @@ def test_sfds_same_device_cpu(): assert torch.isfinite(F.real).all() and torch.isfinite(F.imag).all() -@pytest.mark.gpu +@pytest.mark.cuda @pytest.mark.integration def test_sfds_hkl_on_different_device(): """hkl on CPU while the module + atoms are on CUDA must not crash.""" diff --git a/tests/integration/test_triton_vs_eager_targets.py b/tests/integration/test_triton_vs_eager_targets.py index 3267118..0b795d8 100644 --- a/tests/integration/test_triton_vs_eager_targets.py +++ b/tests/integration/test_triton_vs_eager_targets.py @@ -12,7 +12,7 @@ Toggling is done with the shared ``torchref.utils.use_engine`` context manager (``Engine.EAGER`` vs ``Engine.TRITON``). No process restart needed. -Markers: ``@pytest.mark.gpu`` (skipped by default) and +Markers: ``@pytest.mark.cuda`` (skipped by default) and ``@pytest.mark.integration``. Run on a CUDA box with ``pytest tests/integration/test_triton_vs_eager_targets.py -m gpu``. """ @@ -177,7 +177,7 @@ def _target_names(state): return list(state.targets.keys()) -@pytest.mark.gpu +@pytest.mark.cuda @pytest.mark.integration @pytest.mark.parametrize( "target_name", @@ -218,7 +218,7 @@ def test_triton_matches_eager_per_target(target_name, gpu_refinement, gpu_state) _assert_close(target_name, eager, triton, atol, rtol) -@pytest.mark.gpu +@pytest.mark.cuda @pytest.mark.integration @pytest.mark.parametrize("target_mode", ["bhattacharyya", "rice", "ls", "gaussian"]) def test_triton_matches_eager_xray_modes(target_mode, _1daw_pair): @@ -255,7 +255,7 @@ def test_triton_matches_eager_xray_modes(target_mode, _1daw_pair): _assert_close(name, eager, triton, atol, rtol) -@pytest.mark.gpu +@pytest.mark.cuda @pytest.mark.integration @pytest.mark.parametrize("n_atoms", [4, 5, 6]) def test_planarity_triton_per_atom_sigma(n_atoms): @@ -298,7 +298,7 @@ def test_planarity_triton_per_atom_sigma(n_atoms): assert torch.allclose(ge, gt, atol=1e-4, rtol=1e-3) -@pytest.mark.gpu +@pytest.mark.cuda @pytest.mark.integration def test_geometry_degenerate_finite_grads(): """At degenerate geometry, BOTH eager and Triton give finite gradients. @@ -403,7 +403,7 @@ def grad_at(xv): return cos, rel -@pytest.mark.gpu +@pytest.mark.cuda @pytest.mark.integration def test_eager_gpu_hessian_iso(tmp_path): """`use_engine(Engine.EAGER)` gives correct GPU second derivatives (iso). @@ -450,7 +450,7 @@ def test_eager_gpu_hessian_iso(tmp_path): dtypes.float, dtypes.complex = f0, c0 -@pytest.mark.gpu +@pytest.mark.cuda @pytest.mark.integration def test_eager_gpu_hessian_aniso(pdb_dir): """Same as above but for anisotropic ADPs (real ANISOU structure).""" diff --git a/tests/integration/test_variable_radius_gpu.py b/tests/integration/test_variable_radius_gpu.py index 23a2c07..127b198 100644 --- a/tests/integration/test_variable_radius_gpu.py +++ b/tests/integration/test_variable_radius_gpu.py @@ -5,7 +5,7 @@ forward maps must agree to float32 + analytic-vs-gathered-coord tolerance and the gradients (xyz / adp / u / occ) must be parallel. Requires CUDA + Triton. -Markers: ``@pytest.mark.gpu`` (skipped without ``--run-gpu``), ``integration``. +Markers: ``@pytest.mark.cuda`` (skipped without ``--run-gpu``), ``integration``. """ import math diff --git a/tests/integration/test_variable_radius_mps.py b/tests/integration/test_variable_radius_mps.py index 94d198b..d250df7 100644 --- a/tests/integration/test_variable_radius_mps.py +++ b/tests/integration/test_variable_radius_mps.py @@ -5,7 +5,7 @@ agree to float32 + truncation-shape tolerance and the gradients (xyz / adp / u / occ) must be parallel. Requires an Apple-silicon GPU (MPS); skipped elsewhere. -Markers: ``@pytest.mark.gpu`` (skipped without ``--run-gpu``), ``integration``. +Markers: ``@pytest.mark.mps`` (skipped without ``--run-gpu``), ``integration``. """ import math diff --git a/tests/pytest.ini b/tests/pytest.ini index c8c990e..8896f04 100644 --- a/tests/pytest.ini +++ b/tests/pytest.ini @@ -12,17 +12,25 @@ pythonpath = .. markers = unit: Unit tests (fast, no I/O, isolated) integration: Integration tests (slower, real I/O, pipelines) - gpu: Tests requiring CUDA GPU (skipped by default) + gpu: Needs any accelerator (CUDA or MPS); auto-skipped if the host has none + cuda: Needs CUDA specifically (e.g. Triton kernels); auto-skipped if absent + mps: Needs MPS specifically (Metal kernels); auto-skipped if absent + cuda_only: Deprecated alias for 'cuda' slow: Long-running tests (skipped in quick runs) openmm: Tests requiring OpenMM (the [amber] extra); skipped if absent amber: Tests requiring OpenMM + AmberTools (antechamber/tleap); skipped if absent -# Default options (exclude GPU tests by default) +# Default options. +# +# Accelerator tests are gated by ``pytest_collection_modifyitems`` in +# conftest.py, which runs whatever the host supports and skips the rest with a +# reason naming the missing backend. Do NOT add a default ``-m`` expression +# here: a mark expression *deselects* at collection time, which no flag can +# undo, so the gating would silently stop working. addopts = -v --tb=short --strict-markers - -m "not gpu" -ra # Ignore warnings from dependencies diff --git a/tests/unit/base/test_math_torch.py b/tests/unit/base/test_math_torch.py index 9ac12c1..3bf19be 100644 --- a/tests/unit/base/test_math_torch.py +++ b/tests/unit/base/test_math_torch.py @@ -92,8 +92,8 @@ def test_coordinate_transforms_gpu(self, mock_cell, random_coordinates, gpu_devi frac = cartesian_to_fractional_torch(coords, cell) cart_back = fractional_to_cartesian_torch(frac, cell) - assert frac.device.type == "cuda" - assert cart_back.device.type == "cuda" + assert frac.device.type == gpu_device.type + assert cart_back.device.type == gpu_device.type class TestGridFunctions: diff --git a/tests/unit/io/test_data.py b/tests/unit/io/test_data.py index 2cbd4c5..a615bfb 100644 --- a/tests/unit/io/test_data.py +++ b/tests/unit/io/test_data.py @@ -80,14 +80,14 @@ def test_reflection_data_cpu(self): @pytest.mark.unit @pytest.mark.gpu - def test_reflection_data_cuda(self, gpu_device): - """Test CUDA movement.""" + def test_reflection_data_accelerator(self, gpu_device): + """Movement onto whichever accelerator this host has.""" from torchref.io import ReflectionData data = ReflectionData() - data = data.cuda() + data = data.to(gpu_device) - assert data.device.type == "cuda" + assert data.device.type == gpu_device.type class TestReflectionDataAttributes: diff --git a/tests/unit/model/test_model.py b/tests/unit/model/test_model.py index 7f58454..1781a89 100644 --- a/tests/unit/model/test_model.py +++ b/tests/unit/model/test_model.py @@ -101,7 +101,7 @@ def test_model_gpu_device(self, gpu_device): model = Model(device=gpu_device) - assert model.device.type == 'cuda' + assert model.device.type == gpu_device.type class TestModelGetSelectionMask: diff --git a/tests/unit/model/test_parameter_wrappers.py b/tests/unit/model/test_parameter_wrappers.py index bb1b6b0..5cfeb03 100644 --- a/tests/unit/model/test_parameter_wrappers.py +++ b/tests/unit/model/test_parameter_wrappers.py @@ -133,7 +133,7 @@ def test_mixed_tensor_gpu(self, random_coordinates, gpu_device): mixed = MixedTensor(values, device=gpu_device) result = mixed() - assert result.device.type == 'cuda' + assert result.device.type == gpu_device.type class TestMixedTensorSet: diff --git a/tests/unit/refinement/test_forcefield_target.py b/tests/unit/refinement/test_forcefield_target.py index dacebdf..df5db22 100644 --- a/tests/unit/refinement/test_forcefield_target.py +++ b/tests/unit/refinement/test_forcefield_target.py @@ -35,6 +35,21 @@ def __init__(self, n_atoms=5, has_hydrogens=True): # Create coordinates as a parameter for gradient testing self._coords = nn.Parameter(torch.randn(len(self.Z), 3)) + @property + def device(self): + """Device of the model's coordinates. + + Real ``Model`` / ``ModelFT`` expose this, and ``ModelTarget`` now + reconciles its own device against the model's, so a stand-in has to + carry it too. + """ + return self._coords.device + + @property + def dtype_float(self): + """Float dtype of the model's coordinates (mirrors ``Model``).""" + return self._coords.dtype + def xyz(self): """Return current coordinates.""" return self._coords diff --git a/tests/unit/refinement/test_targets.py b/tests/unit/refinement/test_targets.py index e8d8794..fe2728e 100644 --- a/tests/unit/refinement/test_targets.py +++ b/tests/unit/refinement/test_targets.py @@ -160,7 +160,7 @@ def test_target_gpu_tensors(self, mock_F_obs, mock_F_sigma, gpu_device): loss = torch.mean((fobs / sigma) ** 2) - assert loss.device.type == 'cuda' + assert loss.device.type == gpu_device.type assert torch.isfinite(loss) diff --git a/tests/unit/restraints/test_restraints.py b/tests/unit/restraints/test_restraints.py index a4ba9d3..1ec929c 100644 --- a/tests/unit/restraints/test_restraints.py +++ b/tests/unit/restraints/test_restraints.py @@ -176,7 +176,7 @@ def test_bond_distance_gpu(self, random_coordinates, gpu_device): distance = torch.sqrt(torch.sum((coords[0] - coords[1]) ** 2)) - assert distance.device.type == 'cuda' + assert distance.device.type == gpu_device.type class TestRestraintNumericStability: diff --git a/tests/unit/scaling/test_scaler.py b/tests/unit/scaling/test_scaler.py index 77d5aa6..4435385 100644 --- a/tests/unit/scaling/test_scaler.py +++ b/tests/unit/scaling/test_scaler.py @@ -81,7 +81,7 @@ def test_scaler_gpu_device(self, gpu_device): scaler = Scaler(device=gpu_device) - assert scaler.device.type == 'cuda' + assert scaler.device.type == gpu_device.type class TestScalingCalculations: diff --git a/tests/unit/symmetry/test_symmetry.py b/tests/unit/symmetry/test_symmetry.py index d04044e..1ddab2b 100644 --- a/tests/unit/symmetry/test_symmetry.py +++ b/tests/unit/symmetry/test_symmetry.py @@ -212,8 +212,8 @@ def test_spacegroup_gpu(self, gpu_device): sg = SpaceGroup("P21", device=gpu_device) - assert sg.matrices.device.type == 'cuda' - assert sg.translations.device.type == 'cuda' + assert sg.matrices.device.type == gpu_device.type + assert sg.translations.device.type == gpu_device.type @pytest.mark.unit def test_spacegroup_dtype(self): diff --git a/tests/unit/test_device_conformance.py b/tests/unit/test_device_conformance.py new file mode 100644 index 0000000..ca9ac4b --- /dev/null +++ b/tests/unit/test_device_conformance.py @@ -0,0 +1,255 @@ +"""Device conformance: constructors place tensors where they were told, and +``.to()`` moves *everything* -- tensors and trackers alike. + +The package resolves a single default device at import and expects every object +to live on it, with ``self.device`` as the authority for where new tensors are +allocated (200+ call sites pass ``device=self.device``). Two failure modes +follow, and both used to be untested: + +* a constructor that builds a sub-object on the *global* default rather than + the device it was handed, producing a split object that only errors much + later, deep inside some unrelated op; +* a ``.to()`` that moves the tensors but leaves ``self.device`` stale, so the + object keeps allocating on the device it just left. + +Before this file, the only test that exercised a real device transition was +CUDA-gated -- meaning zero coverage on CPU-only CI and on Apple silicon. +""" + +from pathlib import Path + +import pytest +import torch + +from torchref.config import canonical_device + +# Absolute ``tests.`` imports, not bare ``helpers.``: the repo carries two +# pytest configs -- ``tests/pytest.ini`` (rootdir ``tests/``, ``pythonpath = ..``) +# and ``[tool.pytest.ini_options]`` in ``pyproject.toml`` (rootdir the repo +# root). Only the repo root is on ``sys.path`` under both, so a bare +# ``helpers.`` import works from ``tests/`` and fails from the repo root. +from tests.helpers.device_asserts import assert_device_consistent, collect_device_map +from tests.helpers.device_cases import ( + CASES, + TARGET_CASES, + UNCOVERED, + UNCOVERED_PREFIXES, +) +from tests.helpers.device_inventory import device_mixin_classes + +_IDS = [c.name for c in CASES] + + +@pytest.mark.unit +@pytest.mark.parametrize("case", CASES, ids=_IDS) +def test_constructed_on_requested_device(case, any_device): + """Everything a constructor builds lands on the device it was given.""" + obj = case.build(any_device) + assert_device_consistent(obj, any_device, name=case.name, ignore=case.ignore) + + +@pytest.mark.unit +@pytest.mark.parametrize("case", CASES, ids=_IDS) +def test_device_round_trip(case, any_device): + """cpu -> device -> cpu, with tensors and trackers following every leg.""" + obj = case.build(torch.device("cpu")) + assert_device_consistent(obj, "cpu", name=case.name, ignore=case.ignore) + + obj.to(any_device) + assert_device_consistent(obj, any_device, name=case.name, ignore=case.ignore) + + obj.to("cpu") + assert_device_consistent(obj, "cpu", name=case.name, ignore=case.ignore) + + +@pytest.mark.unit +@pytest.mark.parametrize( + "case", [c for c in CASES if not c.tensor_free], ids=[c.name for c in CASES if not c.tensor_free] +) +def test_tracker_agrees_with_owned_tensor(case, any_device): + """``obj.device`` must equal a real tensor's device, index and all. + + ``torch.device('mps') != torch.device('mps:0')``, so a tracker carrying the + un-indexed spelling compares unequal to every tensor the object owns even + though nothing is actually misplaced. Callers do write + ``if t.device != self.device``, so the two spellings have to agree. + """ + obj = case.build(any_device) + tensors, _, _, _ = collect_device_map(obj, case.name) + if not tensors: + pytest.skip(f"{case.name} owns no tensors on this path") + tracker = getattr(obj, "device", None) + assert isinstance(tracker, torch.device), f"{case.name}.device is {tracker!r}" + for path, dev in tensors.items(): + assert tracker == dev, ( + f"{case.name}.device == {tracker!r} but {path} is on {dev!r}; " + "these must compare equal, not merely name the same backend" + ) + + +@pytest.mark.unit +def test_every_device_bearing_class_is_accounted_for(): + """A new device-bearing class must be given a case or an explicit excuse. + + Uses a static AST inventory rather than ``DeviceMixin.__subclasses__()``: + the runtime hook only sees classes whose module happens to be imported, so + it would silently under-report exactly when coverage regressed. + """ + package_root = Path(__file__).resolve().parents[2] / "torchref" + found = device_mixin_classes(package_root) + + covered = ( + {c.cls_name for c in CASES} + | {c.cls_name for c in TARGET_CASES} + | set(UNCOVERED) + ) + missing = { + name: where + for name, where in found.items() + if name not in covered + and not any(where.startswith(p) for p in UNCOVERED_PREFIXES) + } + + assert not missing, ( + "device-bearing classes with no conformance case and no entry in " + "helpers.device_cases.UNCOVERED:\n " + + "\n ".join(f"{n} ({w})" for n, w in sorted(missing.items())) + ) + + +@pytest.mark.unit +def test_uncovered_entries_still_exist(): + """Stop ``UNCOVERED`` accumulating excuses for classes that are long gone.""" + package_root = Path(__file__).resolve().parents[2] / "torchref" + found = set(device_mixin_classes(package_root)) + stale = sorted(set(UNCOVERED) - found) + assert not stale, f"UNCOVERED lists classes that no longer exist: {stale}" + + +# --------------------------------------------------------------------------- +# Refinement targets +# +# Targets were the largest hole in the device story: none of them carried a +# ``device`` tracker at all, so ``DeviceMixin``'s machinery was a no-op for +# every one, and their scalar tunables were allocated on CPU in float32 +# regardless of the model's device or the configured dtype. +# --------------------------------------------------------------------------- + +_TARGET_IDS = [c.name for c in TARGET_CASES] + + +@pytest.mark.unit +@pytest.mark.parametrize("case", TARGET_CASES, ids=_TARGET_IDS) +def test_target_follows_its_model_device(case, device_model_bundle, any_device): + """A target's own buffers land beside the model it acts on.""" + target = case.build(device_model_bundle, any_device) + assert_device_consistent(target, any_device, name=case.name, ignore=case.ignore) + + +@pytest.mark.unit +@pytest.mark.parametrize("case", TARGET_CASES, ids=_TARGET_IDS) +def test_target_tracker_is_set(case, device_model_bundle, any_device): + """``target.device`` exists and is truthful. + + Tensor-free targets (``BondTarget``) can only be checked this way -- and + they are exactly the ones that used to have no tracker at all. + """ + target = case.build(device_model_bundle, any_device) + assert isinstance(target.device, torch.device) + assert canonical_device(target.device) == canonical_device(any_device) + + +@pytest.mark.unit +@pytest.mark.parametrize("case", TARGET_CASES, ids=_TARGET_IDS) +def test_target_forward_does_not_reallocate_buffers(case, device_model_bundle, any_device): + """``forward()`` must not move or re-register buffers. + + Three targets used to repair their CPU-resident buffers lazily on the first + forward. That is what this pins shut: allocation inside forward breaks + CUDA-graph capture, and it silently costs a transfer on the hot path. The + old code fails this on the first call and passes on the second. + """ + target = case.build(device_model_bundle, any_device) + + def snapshot(): + return { + n: (b.data_ptr(), b.device, b.dtype) + for n, b in target.named_buffers(recurse=False) + if b is not None + } + + try: + target() + except Exception as exc: # pragma: no cover - target needs state we lack + pytest.skip(f"{case.name} cannot run forward() here: {exc}") + before = snapshot() + target() + assert snapshot() == before, f"{case.name} mutated its buffers during forward()" + + +@pytest.mark.unit +@pytest.mark.parametrize("case", TARGET_CASES, ids=_TARGET_IDS) +def test_empty_target_state_dict_round_trip(case): + """An empty target must round-trip its own state dict strictly. + + The empty-init path exists precisely so ``load_state_dict`` has something + to load into. ``CoordinateSimilarityTarget`` used to fail this: its index + buffers were registered only inside ``_build_atom_map``, which never ran + without models. + """ + cls = type(case.build({"model": None}, torch.device("cpu"))) + shell = cls() + other = cls() + other.load_state_dict(shell.state_dict(), strict=True) + + +@pytest.mark.unit +def test_target_scalar_buffers_follow_config_dtype(pdb_dir, monkeypatch): + """Scalar tunables honour ``dtypes.float``, not a hardcoded float32. + + CPU-only: MPS has no float64. Before the migration every one of these was + ``torch.tensor(float(x))`` -- silently float32 under a float64 config. + """ + pdb = pdb_dir / "1DAW.pdb" + if not pdb.exists(): + pytest.skip("1DAW.pdb fixture not present") + + from torchref.config import dtypes + from torchref.model import ModelFT + from torchref.refinement.targets.geometry.non_bonded import NonBondedTarget + + monkeypatch.setattr(dtypes, "_float", torch.float64) + model = ModelFT(device="cpu", dtype_float=torch.float64, verbose=0).load_pdb(str(pdb)) + target = NonBondedTarget(model) + + assert target.dtype_float == torch.float64 + for name in ("_sigma_vdw", "_r_exp", "_c_rep"): + assert getattr(target, name).dtype == torch.float64, name + + +@pytest.mark.unit +def test_register_target_warns_on_device_mismatch(pdb_dir): + """A mismatched target is reported, not silently relocated. + + Moving it would drag the model and data it borrows onto the state's + device -- registering a loss term must not have that side effect. + """ + pdb = pdb_dir / "1DAW.pdb" + if not pdb.exists(): + pytest.skip("1DAW.pdb fixture not present") + + from torchref.model import ModelFT + from torchref.refinement.loss_state import LossState + from torchref.refinement.targets.geometry.non_bonded import NonBondedTarget + + model = ModelFT(device="cpu", verbose=0).load_pdb(str(pdb)) + target = NonBondedTarget(model) + assert target.device.type == "cpu" + + state = LossState(device=torch.device("meta")) + with pytest.warns(UserWarning, match="is on cpu but the state is on"): + state.register_target("nb", target, probe=False) + + # ...and the target must not have been moved. + assert target.device.type == "cpu" + assert target._sigma_vdw.device.type == "cpu" diff --git a/tests/unit/test_device_mixin.py b/tests/unit/test_device_mixin.py index 5b52efc..11f49c7 100644 --- a/tests/unit/test_device_mixin.py +++ b/tests/unit/test_device_mixin.py @@ -248,3 +248,73 @@ def test_cell_cache_repopulates_on_target_dtype(): assert "volume" not in cell._cache, "cache must be cleared after .to()" new_volume = cell.volume assert new_volume.dtype == torch.float64 + + +# --------------------------------------------------------------------------- +# Probe-driven tracker inference (objects that own no tensors) +# --------------------------------------------------------------------------- + + +class _TensorFreeTracker(DeviceMixin, nn.Module): + """Carries device/dtype trackers but owns no tensor to read them from. + + This is the shape of every geometry target: the trackers say where the + object *will* allocate, and nothing it currently holds can confirm it. Such + objects are the only consumers of ``_probe_target``. + """ + + def __init__(self, dtype=torch.float64): + super().__init__() + self.device = torch.device("cpu") + self.dtype_float = dtype + + +@pytest.mark.unit +def test_device_move_does_not_clobber_dtype_tracker(any_device): + """A device-only move must leave ``dtype_float`` alone. + + Regression: the probe used a single float32 scratch for the dtype axis, so + a plain ``.to(device)`` reported ``float32`` and overwrote a float64 + tracker. Both axes need their own contrast pair. + """ + mod = _TensorFreeTracker(dtype=torch.float64) + mod.to(any_device) + assert mod.dtype_float == torch.float64, "device-only move changed dtype_float" + assert mod.device.type == any_device.type + + +@pytest.mark.unit +def test_dtype_only_move_does_not_clobber_device_tracker(): + """Mirror case: ``.float()`` must leave the device tracker alone.""" + mod = _TensorFreeTracker(dtype=torch.float64) + mod.device = torch.device("cpu") + mod.float() + assert mod.dtype_float == torch.float32 + assert mod.device == torch.device("cpu"), "dtype-only move changed device" + + +@pytest.mark.unit +@pytest.mark.parametrize( + "fn,expected_device,expected_dtype", + [ + (lambda t: t.to("cpu"), torch.device("cpu"), None), + (lambda t: t.double(), None, torch.float64), + (lambda t: t.float(), None, torch.float32), + (lambda t: t.half(), None, torch.float16), + (lambda t: t, None, None), + ], + ids=["to_cpu", "double", "float", "half", "identity"], +) +def test_probe_target_resolves_axes_independently(fn, expected_device, expected_dtype): + """``_probe_target`` reports only the axis ``fn`` actually transforms. + + ``.double()`` is the case that pins the CPU-only dtype pair: MPS has no + float64, so contrasting dtype on an accelerator scratch would make it + unprobeable on Apple silicon. + """ + from torchref.utils.device_mixin import _probe_target + + device, dtype = _probe_target(fn) + assert device == expected_device + assert dtype == expected_dtype + diff --git a/tests/unit/test_gradient_correctness.py b/tests/unit/test_gradient_correctness.py index e98eb08..8ec71bf 100644 --- a/tests/unit/test_gradient_correctness.py +++ b/tests/unit/test_gradient_correctness.py @@ -363,8 +363,7 @@ def loss_fn(): # 6. Triton kernels (CUDA float32, hand-written backward) vs eager autograd # Metric: cosine similarity + gradnorm ratio. Skipped without --run-gpu. # ============================================================================= -@pytest.mark.gpu -@pytest.mark.cuda_only +@pytest.mark.cuda @pytest.mark.skipif(not _HAS_CUDA, reason="Triton kernels require CUDA") def test_triton_sf_iso_matches_eager_cosine(): """DS isotropic Triton kernel gradient == eager autograd (CUDA float32).""" @@ -388,8 +387,7 @@ def test_triton_sf_iso_matches_eager_cosine(): assert_grads_agree(g_triton, g_eager, min_cos=0.999, ratio_tol=1e-2, ctx="iso ") -@pytest.mark.gpu -@pytest.mark.cuda_only +@pytest.mark.cuda @pytest.mark.skipif(not _HAS_CUDA, reason="Triton kernels require CUDA") def test_triton_sf_aniso_matches_eager_cosine(): """DS anisotropic Triton kernel gradient == eager autograd (CUDA float32).""" @@ -412,8 +410,7 @@ def test_triton_sf_aniso_matches_eager_cosine(): assert_grads_agree(g_triton, g_eager, min_cos=0.999, ratio_tol=1e-2, ctx="aniso ") -@pytest.mark.gpu -@pytest.mark.cuda_only +@pytest.mark.cuda @pytest.mark.skipif(not _HAS_CUDA, reason="Triton kernels require CUDA") def test_triton_gaussian_xray_matches_eager_cosine(): """Gaussian X-ray Triton kernel gradient == eager autograd (CUDA float32).""" @@ -435,8 +432,7 @@ def test_triton_gaussian_xray_matches_eager_cosine(): assert_grads_agree([g_t], [g_e], min_cos=0.999, ratio_tol=1e-2, ctx="xray ") -@pytest.mark.gpu -@pytest.mark.cuda_only +@pytest.mark.cuda @pytest.mark.skipif(not _HAS_CUDA, reason="Triton kernels require CUDA") def test_triton_bond_matches_eager_cosine(): """Bond restraint Triton kernel gradient == eager autograd (CUDA float32).""" diff --git a/tests/unit/utils/test_device_resolution.py b/tests/unit/utils/test_device_resolution.py index 5ef428a..7e5baa2 100644 --- a/tests/unit/utils/test_device_resolution.py +++ b/tests/unit/utils/test_device_resolution.py @@ -96,7 +96,7 @@ def test_explicit_overrides_skips_nones(self): # GPU tests (cuda required for the cross-device branches) # --------------------------------------------------------------------------- -@pytest.mark.gpu +@pytest.mark.cuda class TestMixedDevices: def test_inconsistent_warns_and_moves_to_first(self): if not torch.cuda.is_available(): diff --git a/torchref/config.py b/torchref/config.py index 94b253f..33eceb6 100644 --- a/torchref/config.py +++ b/torchref/config.py @@ -358,6 +358,65 @@ def _auto_detect_device() -> torch.device: return torch.device("cpu") +def canonical_device(dev): + """Return ``dev`` with its default index filled in. + + ``torch.device('cuda') != torch.device('cuda:0')`` even though both name the + same physical device. Devices read back off a real tensor always carry an + index, so any comparison between a *requested* device and an *observed* one + needs a shared normal form -- otherwise ``obj.device == tensor.device`` is + False on a freshly constructed object and True after its first ``.to()``. + + ``cpu`` deliberately stays bare: ``torch.empty(0, device='cpu').device`` has + no index, so canonicalising it to ``cpu:0`` would recreate the very mismatch + this function exists to remove. + + This is the allocation-free form of ``torch.empty(0, device=d).device``, + which matters because it is called on constructor and comparison paths. + Deliberately *not* memoised: ``torch.cuda.current_device()`` is mutable via + ``torch.cuda.set_device``, so a cache would freeze a stale answer. + + Parameters + ---------- + dev : torch.device or str or int or None + Device to normalise. ``None`` passes through so callers can chain. + + Returns + ------- + torch.device or None + """ + if dev is None: + return None + d = dev if isinstance(dev, torch.device) else torch.device(dev) + if d.index is not None: + return d + if d.type == "cpu": + return d + if d.type == "cuda": + return torch.device("cuda", torch.cuda.current_device()) + if d.type == "mps": + return torch.device("mps", 0) + # Unknown/future backend: fall back to asking PyTorch directly. + return torch.empty(0, device=d).device + + +def normalize_device(dev=None) -> torch.device: + """Coerce a user-supplied ``device`` argument to a canonical device. + + ``None`` resolves to :func:`get_default_device`. This is the pure, + side-effect-free counterpart of :func:`torchref.utils.resolve_device`: + + * one device source (or none) -> ``normalize_device`` + * several device-bearing inputs to reconcile -> ``resolve_device`` + + ``resolve_device`` *moves* the objects it is given, so using it for a + single-input constructor with nothing to reconcile is overreach. + """ + if dev is None: + return get_default_device() + return canonical_device(dev) + + class DeviceConfig: """ Device configuration with property-based access. @@ -381,7 +440,10 @@ class DeviceConfig: def __init__(self): override = os.environ.get("TORCHREF_DEVICE", "auto").lower() if override == "auto": - self._device = _auto_detect_device() + # Canonicalise here too, not just in ``_coerce``: the ``auto`` + # branch bypasses ``_coerce`` entirely, and it is the branch almost + # every user takes. + self._device = canonical_device(_auto_detect_device()) else: self._device = self._coerce(override) self._warn_if_mps_dtype_mismatch() @@ -408,7 +470,7 @@ def _coerce(value) -> torch.device: hasattr(torch.backends, "mps") and torch.backends.mps.is_available() ): raise RuntimeError("MPS requested but not available on this system.") - return dev + return canonical_device(dev) def _warn_if_mps_dtype_mismatch(self) -> None: if self._device.type == "mps" and dtypes.float == torch.float64: diff --git a/torchref/io/datasets/reflection_data.py b/torchref/io/datasets/reflection_data.py index e3b052f..fcf2855 100644 --- a/torchref/io/datasets/reflection_data.py +++ b/torchref/io/datasets/reflection_data.py @@ -602,7 +602,12 @@ def load(self, reader, french_wilson: bool = True): ) if spacegroup is not None: - self.spacegroup = SpaceGroup(spacegroup) + # Build on the dataset's device, not the process default: a + # ``ReflectionData(device='cpu')`` on an MPS host would otherwise + # end up with mps-resident symmetry matrices while every other + # tensor it owns is on CPU, and nothing downstream would notice + # until an op mixed the two. + self.spacegroup = SpaceGroup(spacegroup, device=self.device) self._calculate_resolution() # Prefer intensities (via French-Wilson) only when French-Wilson is @@ -804,7 +809,9 @@ def _prep(t: torch.Tensor) -> torch.Tensor: else Cell(cell, device=data.device) ) data.spacegroup = ( - spacegroup if isinstance(spacegroup, SpaceGroup) else SpaceGroup(spacegroup) + spacegroup + if isinstance(spacegroup, SpaceGroup) + else SpaceGroup(spacegroup, device=data.device) ) if rfree_flags is not None: @@ -3166,7 +3173,7 @@ def _remap_mask(tensor, fill_value): remapped = ReflectionData(verbose=self.verbose, device=self.device) remapped.cell = self.cell.clone() if self.cell is not None else None if spacegroup is not None: - remapped.spacegroup = SpaceGroup(spacegroup) + remapped.spacegroup = SpaceGroup(spacegroup, device=remapped.device) else: remapped.spacegroup = self.spacegroup @@ -3573,8 +3580,8 @@ def _aggregate_any(tensor): # Clone cell reduced.cell = self.cell.clone() if self.cell is not None else None - # Set spacegroup - reduced.spacegroup = SpaceGroup(spacegroup) + # Set spacegroup (on the reduced dataset's device, not the global default) + reduced.spacegroup = SpaceGroup(spacegroup, device=reduced.device) # Recalculate resolution if reduced.cell is not None and reduced.hkl is not None: diff --git a/torchref/model/model.py b/torchref/model/model.py index 94d4c00..d11c5c8 100644 --- a/torchref/model/model.py +++ b/torchref/model/model.py @@ -1091,21 +1091,28 @@ def get_vdw_radii(self): ), f"vdW radii length mismatch with number of atoms {len(self.vdw_radii)} != {len(self.pdb)}" return self.vdw_radii - def to(self, *args, **kwargs): - """Move Model and rebuild device-specific SF indices. + def _after_device_apply( + self, old_device, new_device, old_dtype, new_dtype, *, + device_changed, dtype_changed, + ): + """Rebuild the precomputed SF indices after a real device/dtype change. - Delegates to :class:`~torchref.utils.device_mixin.DeviceMovementMixin`, which - walks ``self.__dict__`` (picking up ``self.cell``, ``self.altloc_pairs``, + :class:`~torchref.utils.device_mixin.DeviceMixin` walks + ``self.__dict__`` (picking up ``self.cell``, ``self.altloc_pairs``, ``self._restraints`` and all registered parameters / buffers), refreshes - the ``self.device`` tracker, and invalidates caches. Afterwards this - override rebuilds the precomputed SF indices on the new device. - """ - result = super().to(*args, **kwargs) - if hasattr(result, "aniso_flag") and result.aniso_flag is not None: - result._rebuild_sf_indices() - if result.verbose > 0: - print(f"Model moved to device: {result.device}") - return result + the ``self.device`` tracker and invalidates caches; this hook then + regenerates the iso/aniso index tensors on the new device. + + This lives on the movement hook rather than a ``to()`` override because + a parent module reaches ``Model`` through ``_apply``, which never calls + ``to()``. It is deliberately not ``reset_cache()``: that hook fires + after every optimizer step (see ``LossState.reset_caches``), and index + reconstruction does not belong on that path. + """ + if getattr(self, "aniso_flag", None) is not None: + self._rebuild_sf_indices() + if self.verbose > 0: + print(f"Model moved to device: {self.device}") def copy(self): """ diff --git a/torchref/model/parameter_wrappers.py b/torchref/model/parameter_wrappers.py index 50289f8..accd7b1 100644 --- a/torchref/model/parameter_wrappers.py +++ b/torchref/model/parameter_wrappers.py @@ -8,6 +8,7 @@ import torch from torch import nn +from torchref.config import get_float_dtype, normalize_device from torchref.utils.caching import CachedForwardMixin from torchref.utils.device_mixin import DeviceMixin @@ -133,12 +134,19 @@ def __init__( # Empty initialization if initial_values is None: + # Honour the requested device/dtype even with no values yet: the + # empty shell is the documented ``load_state_dict`` entry point, and + # a caller that asked for a device should not get a CPU parameter + # (nor a ``.device`` property that raises because every buffer is + # ``None``). + device = normalize_device(device) + dtype = dtype if dtype is not None else get_float_dtype() self.register_buffer("refinable_mask", None) self.register_buffer("fixed_mask", None) self.register_buffer("fixed_values", None) - self.register_buffer("_shape", None) self.refinable_params = nn.Parameter( - torch.empty(0), requires_grad=requires_grad + torch.empty(0, device=device, dtype=dtype), + requires_grad=requires_grad, ) self._has_refinable = False self._refinable_indices = None @@ -200,8 +208,12 @@ def __init__( refinable_values, requires_grad=requires_grad ) - # Store shape for reconstruction - self.register_buffer("_shape", torch.tensor(initial_values.shape)) + # Shape is host-side metadata, deliberately *not* a registered buffer: + # a buffer would be dragged onto the accelerator by ``.to()`` and then + # read back with ``.tolist()``, i.e. a device sync on every ``.shape`` + # access, purely to recover numbers we already have. It is also fully + # derivable from ``fixed_values`` (a clone of ``initial_values``), so + # storing it separately only creates something that can disagree. # Pre-compute index cache to avoid boolean indexing at runtime self._build_index_cache() @@ -476,17 +488,30 @@ def set(self, values: torch.Tensor, mask: torch.Tensor) -> None: @property def shape(self): """Return the shape of the full tensor.""" - return tuple(self._shape.tolist()) + if self.fixed_values is None: + return () + return tuple(self.fixed_values.shape) + + # ``fixed_values`` is ``None`` on the empty-shell path, so both properties + # fall back to the (empty) parameter, which always exists and carries the + # device/dtype the constructor was given. Without the fallback the + # ``AttributeError`` raised by ``None.device`` is intercepted by + # ``nn.Module.__getattr__`` and re-raised as the thoroughly misleading + # "'MixedTensor' object has no attribute 'device'". @property def dtype(self): """Return the dtype of the tensor.""" - return self.fixed_values.dtype + if self.fixed_values is not None: + return self.fixed_values.dtype + return self.refinable_params.dtype @property def device(self): """Return the device of the tensor.""" - return self.fixed_values.device + if self.fixed_values is not None: + return self.fixed_values.device + return self.refinable_params.device def get_refinable_count(self) -> int: """Return the number of refinable parameters.""" @@ -517,6 +542,16 @@ def update_fixed_values(self, new_values: torch.Tensor): ) self.fixed_values = new_values.to(dtype=self.dtype, device=self.device).detach() + def _normalize_refinable_mask(self, new_mask: torch.Tensor) -> torch.Tensor: + """Coerce an incoming mask onto this wrapper's device as ``bool``. + + Must be applied *before* deriving ``fixed_mask = ~new_mask``: negating + the caller's un-migrated tensor leaves ``refinable_mask`` and + ``fixed_mask`` on different devices, which then fails far away from + here, in whichever op first uses both. + """ + return new_mask.to(device=self.device, dtype=torch.bool) + def update_refinable_mask( self, new_mask: torch.Tensor, reset_refinable: bool = False ): @@ -542,7 +577,8 @@ def update_refinable_mask( current_full = self.forward().detach() - self.refinable_mask = new_mask.to(device=self.device) + new_mask = self._normalize_refinable_mask(new_mask) + self.refinable_mask = new_mask self.fixed_mask = ~new_mask if reset_refinable: @@ -609,11 +645,27 @@ def clip(self, min_value=None, max_value=None) -> "MixedTensor": ) return new_mixed - def to(self, *args, **kwargs): - """Move via :class:`DeviceMixin` and rebuild the index cache.""" - result = super().to(*args, **kwargs) - result._build_index_cache() - return result + def _load_from_state_dict(self, state_dict, prefix, *args, **kwargs): + """Drop the legacy ``_shape`` buffer key before delegating. + + ``_shape`` used to be a registered buffer; it is now derived from + ``fixed_values``. Checkpoints written before that change still carry the + key, and a ``strict=True`` load would reject it as unexpected. + """ + state_dict.pop(prefix + "_shape", None) + return super()._load_from_state_dict(state_dict, prefix, *args, **kwargs) + + def _after_device_apply(self, *args, device_changed, dtype_changed, **kwargs): + """Rebuild the index cache after a real device/dtype change. + + ``_refinable_indices`` / ``_refinable_idx_1d`` are plain attributes + holding tensors, so they must be regenerated on the new device. Using + the movement hook rather than a ``to()`` override matters twice over: + ``to()`` is bypassed when a parent module reaches this wrapper through + ``_apply``, and ``reset_cache()`` would put this rebuild on the + per-optimizer-step path. + """ + self._build_index_cache() def refine( self, selection: Union[slice, torch.Tensor, tuple], reset_values: bool = False @@ -676,6 +728,7 @@ def refine( new_mask |= temp_mask # Update the mask + new_mask = self._normalize_refinable_mask(new_mask) self.refinable_mask = new_mask self.fixed_mask = ~new_mask @@ -755,6 +808,7 @@ def fix( new_mask &= ~temp_mask # Update the mask + new_mask = self._normalize_refinable_mask(new_mask) self.refinable_mask = new_mask self.fixed_mask = ~new_mask @@ -1180,7 +1234,8 @@ def update_refinable_mask( # Convert to log space current_log = torch.log(current_normal.clamp(min=self.epsilon)) - self.refinable_mask = new_mask.to(device=self.device) + new_mask = self._normalize_refinable_mask(new_mask) + self.refinable_mask = new_mask self.fixed_mask = ~new_mask # Update fixed_values with log-space values @@ -1407,7 +1462,8 @@ def update_refinable_mask( ) with torch.no_grad(): current_raw = self._u6_to_raw6(self.forward()) - self.refinable_mask = new_mask.to(device=self.device) + new_mask = self._normalize_refinable_mask(new_mask) + self.refinable_mask = new_mask self.fixed_mask = ~new_mask self.fixed_values = current_raw.clone() new_refinable = current_raw[self.refinable_mask].clone() @@ -1556,16 +1612,22 @@ def __init__( self._name = name or "occupancy" # Empty initialization + # + # ``OccupancyTensor`` builds its own shell rather than delegating to + # ``MixedTensor``, so the device/dtype handling has to be repeated here + # -- fixing only the base class would leave this path on CPU. if initial_values is None: + device = normalize_device(device) + dtype = dtype if dtype is not None else get_float_dtype() self._full_shape = 0 self._collapsed_shape = 0 self.register_buffer("refinable_mask", None) self.register_buffer("fixed_mask", None) self.register_buffer("fixed_values", None) - self.register_buffer("_shape", None) self.register_buffer("expansion_mask", None) self.refinable_params = nn.Parameter( - torch.empty(0), requires_grad=requires_grad + torch.empty(0, device=device, dtype=dtype), + requires_grad=requires_grad, ) self._has_refinable = False self._refinable_indices = None diff --git a/torchref/model/rigid_xyz.py b/torchref/model/rigid_xyz.py index e99f56e..1c40feb 100644 --- a/torchref/model/rigid_xyz.py +++ b/torchref/model/rigid_xyz.py @@ -18,6 +18,7 @@ from torch import nn from torchref.base.alignment.rotation import rotation_matrix_euler_xyz +from torchref.config import get_float_dtype, normalize_device from torchref.utils.caching import CachedForwardMixin from torchref.utils.device_mixin import DeviceMixin @@ -56,14 +57,22 @@ def __init__( self._name = name if original_xyz is None: - # Empty init for state_dict loading. - self.register_buffer("original_xyz", torch.empty(0, 3)) - self.register_buffer("chain_indices", torch.empty(0, dtype=torch.long)) - self.register_buffer("chain_centers", torch.empty(0, 3)) - self.register_buffer("mobile_mask", torch.empty(0, dtype=torch.bool)) - self.register_buffer("atom_weights", torch.empty(0)) - self.euler_angles = nn.Parameter(torch.empty(0, 3)) - self.translations = nn.Parameter(torch.empty(0, 3)) + # Empty init for state_dict loading. Honour the requested + # device/dtype: otherwise every buffer lands on CPU regardless of + # what the caller asked for, and only a later ``.to()`` repairs it. + device = normalize_device(device) + dtype = dtype if dtype is not None else get_float_dtype() + self.register_buffer("original_xyz", torch.empty(0, 3, device=device, dtype=dtype)) + self.register_buffer( + "chain_indices", torch.empty(0, dtype=torch.long, device=device) + ) + self.register_buffer("chain_centers", torch.empty(0, 3, device=device, dtype=dtype)) + self.register_buffer( + "mobile_mask", torch.empty(0, dtype=torch.bool, device=device) + ) + self.register_buffer("atom_weights", torch.empty(0, device=device, dtype=dtype)) + self.euler_angles = nn.Parameter(torch.empty(0, 3, device=device, dtype=dtype)) + self.translations = nn.Parameter(torch.empty(0, 3, device=device, dtype=dtype)) self._n_chains = 0 self._chain_id_order: list = [] return diff --git a/torchref/model/sf_ds.py b/torchref/model/sf_ds.py index e122c5f..6deee28 100644 --- a/torchref/model/sf_ds.py +++ b/torchref/model/sf_ds.py @@ -129,10 +129,11 @@ def __init__( super().__init__() if dtype_float is None: dtype_float = dtypes.float - if device is None: - device = get_default_device() self.dtype_float = dtype_float - self.device = device + # Derive from ``cell`` when no device is given, instead of jumping to + # the global default and leaving a caller-supplied cell behind on + # another device. An explicit ``device`` still wins and moves the cell. + self.device = resolve_device(cell, device=device) self.verbose = verbose self.max_memory_gb = max_memory_gb self.engine = engine @@ -142,7 +143,9 @@ def __init__( self._spacegroup = None if spacegroup is not None or cell is not None: - self._spacegroup = SpaceGroup(spacegroup, dtype=dtype_float, device=device) + self._spacegroup = SpaceGroup( + spacegroup, dtype=dtype_float, device=self.device + ) # Cache reciprocal basis matrix self._recB: Optional[torch.Tensor] = None @@ -201,7 +204,13 @@ def set_cell_and_spacegroup(self, cell: Cell, spacegroup: SpaceGroupLike = None) Unit cell object. spacegroup : SpaceGroupLike, optional Space group specification. + + Notes + ----- + Receiver wins: an incoming cell on another device is moved to match + this module rather than the other way round. """ + self.device = resolve_device(self, cell) self._cell = cell self._recB = None # Invalidate cache self.spacegroup = spacegroup diff --git a/torchref/model/sf_fft.py b/torchref/model/sf_fft.py index 182f6de..0d80b11 100644 --- a/torchref/model/sf_fft.py +++ b/torchref/model/sf_fft.py @@ -25,6 +25,7 @@ from torchref.symmetry.spacegroup import SpaceGroupLike from torchref.utils.caching import ParameterFingerprint from torchref.utils.device_mixin import DeviceMovementMixin +from torchref.utils.device_resolution import resolve_device class SfFFT(DeviceMovementMixin, nn.Module): @@ -127,11 +128,10 @@ def __init__( dtype_float = dtypes.float self.dtype_float = dtype_float - self.device = ( - device - if device is not None - else cell.device if cell is not None else get_default_device() - ) + # One device for the module and everything it builds. ``resolve_device`` + # also moves ``cell`` when an explicit ``device`` disagrees with it, so + # the cell and the SpaceGroup below cannot end up split. + self.device = resolve_device(cell, device=device) self.verbose = verbose self.use_late_symmetry = use_late_symmetry @@ -141,7 +141,12 @@ def __init__( self._spacegroup = None if spacegroup is not None or cell is not None: - self._spacegroup = SpaceGroup(spacegroup, dtype=dtype_float, device=device) + # ``self.device``, not the raw ``device`` argument: the latter is + # ``None`` on the derive-from-cell path, which would silently put + # the symmetry matrices on the global default instead. + self._spacegroup = SpaceGroup( + spacegroup, dtype=dtype_float, device=self.device + ) # Buffers (registered during setup_grid) self.register_buffer("gridsize", None) @@ -227,7 +232,14 @@ def set_cell_and_spacegroup(self, cell: Cell, spacegroup: SpaceGroupLike = None) Unit cell object. spacegroup : SpaceGroupLike, optional Space group specification. + + Notes + ----- + Receiver wins: this module may already own grid buffers, so an incoming + cell on another device is moved to match rather than dragging the + module after it. """ + self.device = resolve_device(self, cell) self._cell = cell self.spacegroup = spacegroup diff --git a/torchref/refinement/loss_state.py b/torchref/refinement/loss_state.py index dad7444..4fc5308 100644 --- a/torchref/refinement/loss_state.py +++ b/torchref/refinement/loss_state.py @@ -31,7 +31,7 @@ import torch from torch import nn -from torchref.config import get_default_device +from torchref.config import canonical_device, get_default_device from torchref.utils.autograd_introspection import collect_loss_leaves, _iter_roots from torchref.utils.device_mixin import DeviceMovementMixin from torchref.utils.loss_validation import validate_loss @@ -276,8 +276,33 @@ def register_target( if probe: self._probe_and_merge_leaves(target) self._collect_resettable_modules(target) + self._warn_on_device_mismatch(key, target) return self + def _warn_on_device_mismatch(self, key: str, target: Callable) -> None: + """Warn if ``target`` does not agree with this state's device. + + Deliberately a warning and not a move. ``target.to(self.device)`` would + drag the model, data and scaler the target *borrows* along with it, so + registering a loss term could silently relocate an entire + ``ReflectionData``. Targets resolve their own device at construction + (see ``Target._adopt_device``); a disagreement here means something + upstream built them inconsistently, which is worth surfacing rather + than papering over. + """ + target_device = getattr(target, "device", None) + if target_device is None or not isinstance(self.device, torch.device): + return + if canonical_device(target_device) == canonical_device(self.device): + return + warnings.warn( + f"LossState: target {key!r} is on {target_device} but the state is " + f"on {self.device}. Not moving it -- a target borrows its model and " + "data, so relocating it here would move those too. Construct the " + "target on the right device instead.", + stacklevel=3, + ) + def _collect_resettable_modules(self, target: Callable) -> None: """Walk ``target``'s submodules and collect any that expose a ``reset_cache`` method, deduplicating against modules already @@ -914,23 +939,12 @@ def summary(self) -> None: # ========================================================================= # Device Management # ========================================================================= - - def to(self, *args, **kwargs): - """Move via :class:`DeviceMixin`; honour an explicit device when no tensors exist yet.""" - result = super().to(*args, **kwargs) - # If no tensor was found to refresh ``self.device``, fall back to the - # explicit device argument so subsequent allocations land correctly. - if not isinstance(result.device, torch.device): - from torchref.utils.device_mixin import _parse_to_args - - device, _ = _parse_to_args(args, kwargs) - if device is not None: - result.device = ( - torch.device(device) - if not isinstance(device, torch.device) - else device - ) - return result + # + # ``LossState`` normally owns no tensors (targets are callables, weights are + # floats), so its ``device`` tracker used to need a bespoke ``to()`` + # override to survive a move. ``DeviceMixin`` now carries the parsed request + # down the traversal and updates tensor-free objects itself, so the override + # is gone; ``self.device`` follows ``.to()`` via the shared machinery. # ========================================================================= # Utility diff --git a/torchref/refinement/targets/adp/base.py b/torchref/refinement/targets/adp/base.py index 3e8940c..68675a9 100644 --- a/torchref/refinement/targets/adp/base.py +++ b/torchref/refinement/targets/adp/base.py @@ -39,6 +39,7 @@ def __init__( self, model: "Model" = None, verbose: int = 0, + device=None, **kwargs, ): - super().__init__(model, verbose) + super().__init__(model, verbose, device=device) diff --git a/torchref/refinement/targets/adp/locality.py b/torchref/refinement/targets/adp/locality.py index 9dbf41a..b43ec18 100644 --- a/torchref/refinement/targets/adp/locality.py +++ b/torchref/refinement/targets/adp/locality.py @@ -60,18 +60,22 @@ def __init__( sigma_aniso: float = 0.5, exclude_bonded: bool = True, verbose: int = 0, + device=None, ): - super().__init__(model, verbose) - self.register_buffer( - "_k_neighbors", torch.tensor(k_neighbors, dtype=torch.int64) - ) - self.register_buffer("_correlation_length", torch.tensor(correlation_length)) - self.register_buffer("_scale", torch.tensor(scale)) + super().__init__(model, verbose, device=device) + # Host-side, deliberately not buffers: all three are only ever reached + # through the properties below, which immediately ``.item()`` them, and + # their consumers (k-NN sizing, the exp() falloff, stats reporting) are + # host-side. Keeping them on the device bought a sync per access. + self._k_neighbors = int(k_neighbors) + self._correlation_length = float(correlation_length) + self._scale = float(scale) # Sigma for the deviatoric (anisotropy) channel; only used when # anisotropic atoms are present. Dimensionless and on the same scale as # the magnitude channel's fixed 0.5 log-sigma (the channel restrains - # fractional anisotropy dev/B_eq, the analogue of log B_eq). - self.register_buffer("_sigma_aniso", torch.tensor(sigma_aniso)) + # fractional anisotropy dev/B_eq, the analogue of log B_eq). This one + # *is* a buffer: it is handed to adp_locality_aniso_math as a tensor. + self._register_scalar("_sigma_aniso", float(sigma_aniso)) self.exclude_bonded = exclude_bonded # Cache for neighbor indices and distances @@ -79,29 +83,48 @@ def __init__( self._neighbor_distances = None # (N, k_neighbors) self._last_xyz_hash = None + def _load_from_state_dict(self, state_dict, prefix, *args, **kwargs): + """Absorb the retired ``_k_neighbors`` / ``_correlation_length`` / + ``_scale`` buffers. + + All three are host-side Python scalars now (see ``__init__``). + Checkpoints predating that change still carry them, and a + ``strict=True`` load would reject them as unexpected keys -- so restore + their values rather than discarding a user's tuning. + """ + for legacy, cast in ( + ("_k_neighbors", int), + ("_correlation_length", float), + ("_scale", float), + ): + saved = state_dict.pop(prefix + legacy, None) + if saved is not None: + setattr(self, legacy, cast(saved.item())) + return super()._load_from_state_dict(state_dict, prefix, *args, **kwargs) + @property def k_neighbors(self) -> int: - return self._k_neighbors.item() + return self._k_neighbors @k_neighbors.setter def k_neighbors(self, value: int): - self._k_neighbors.fill_(value) + self._k_neighbors = int(value) @property def correlation_length(self) -> float: - return self._correlation_length.item() + return self._correlation_length @correlation_length.setter def correlation_length(self, value: float): - self._correlation_length.fill_(value) + self._correlation_length = float(value) @property def scale(self) -> float: - return self._scale.item() + return self._scale @scale.setter def scale(self, value: float): - self._scale.fill_(value) + self._scale = float(value) @property def sigma_aniso(self) -> float: @@ -287,11 +310,6 @@ def forward(self, recompute_neighbors: bool = False) -> torch.Tensor: # deviatoric/anisotropy channel). Isotropic-only models keep the # original B-factor path (numerically unchanged, zero overhead). if not getattr(self.model, "_aniso_is_empty", True): - if (self._sigma_aniso.device != device - or self._sigma_aniso.dtype != adp.dtype): - self._sigma_aniso = self._sigma_aniso.to( - device=device, dtype=adp.dtype - ) u6 = self.model.adp_u6() return adp_locality_aniso_math( u6, indices, distances, self._sigma_aniso diff --git a/torchref/refinement/targets/adp/similarity.py b/torchref/refinement/targets/adp/similarity.py index 78335a1..45dd914 100644 --- a/torchref/refinement/targets/adp/similarity.py +++ b/torchref/refinement/targets/adp/similarity.py @@ -42,12 +42,15 @@ class ADPSimilarityTarget(ADPTarget): def __init__( self, model: "Model" = None, simu_sigma: float = 2.0, - simu_sigma_aniso: float = 1.0, verbose: int = 0 + simu_sigma_aniso: float = 1.0, verbose: int = 0, device=None ): - super().__init__(model, verbose) - # Register simu-specific sigma as buffer (separate from base sigma) - self.register_buffer("_simu_sigma", torch.tensor(simu_sigma)) - self.register_buffer("_simu_sigma_aniso", torch.tensor(simu_sigma_aniso)) + super().__init__(model, verbose, device=device) + # Both sigmas are handed to adp_simu_math / adp_simu_aniso_math, which + # dispatch to Triton on CUDA float32. Allocating them on the target's + # resolved device and dtype is what lets the lazy repair inside + # forward() go away. + self._register_scalar("_simu_sigma", float(simu_sigma)) + self._register_scalar("_simu_sigma_aniso", float(simu_sigma_aniso)) @property def simu_sigma(self) -> float: @@ -96,25 +99,10 @@ def forward(self) -> torch.Tensor: adp_t = self.model.adp() if pair_indices.shape[0] == 0: return torch.zeros((), device=adp_t.device, dtype=adp_t.dtype) - # Lazily move the ``_simu_sigma`` buffer onto the model's device - # the first time we reach here. Once moved, subsequent forwards - # (and CUDA-Graph captures) skip the device transfer — calling - # ``.to()`` on a CPU buffer inside a capture region triggers a - # ``cudaErrorStreamCaptureUnsupported``. - if (self._simu_sigma.device != adp_t.device - or self._simu_sigma.dtype != adp_t.dtype): - self._simu_sigma = self._simu_sigma.to( - device=adp_t.device, dtype=adp_t.dtype, - ) # Anisotropic atoms present: restrain the full U tensors. Isotropic-only # models keep the original (Triton-accelerated) B-factor path so they # pay nothing and are numerically unchanged. if not getattr(self.model, "_aniso_is_empty", True): - if (self._simu_sigma_aniso.device != adp_t.device - or self._simu_sigma_aniso.dtype != adp_t.dtype): - self._simu_sigma_aniso = self._simu_sigma_aniso.to( - device=adp_t.device, dtype=adp_t.dtype, - ) u6 = self.model.adp_u6() return adp_simu_aniso_math( u6, pair_indices, self._simu_sigma, self._simu_sigma_aniso diff --git a/torchref/refinement/targets/base.py b/torchref/refinement/targets/base.py index 237b53a..7ea97cd 100644 --- a/torchref/refinement/targets/base.py +++ b/torchref/refinement/targets/base.py @@ -21,7 +21,9 @@ from torch import nn from torch.special import i0 +from torchref.config import get_float_dtype, normalize_device from torchref.utils.device_mixin import DeviceMixin +from torchref.utils.device_resolution import resolve_device from torchref.utils.stats import ( VERBOSITY_DEBUG, VERBOSITY_DETAILED, @@ -80,6 +82,7 @@ class Target(DeviceMixin, nn.Module): def __init__( self, verbose: int = 0, + device=None, **kwargs, ): """ @@ -89,9 +92,79 @@ def __init__( ---------- verbose : int, optional Verbosity level. Default is 0. + device : torch.device, optional + Where this target allocates. Defaults to the configured default; + :meth:`_adopt_device` refines it from whatever model / data / + scaler the subclass is given. """ super().__init__() self.verbose = verbose + # Seeded here for two distinct reasons. + # + # (1) Subclasses allocate their tunables in ``__init__``, so + # ``device=self.device`` / ``dtype=self.dtype_float`` must already + # have an answer at that point. Before this existed, every scalar + # tunable in the package landed on CPU float32 regardless of the + # model's device or the configured dtype. + # + # (2) ``DeviceMixin._refresh_device_trackers`` early-returns unless one + # of these names is already in ``obj.__dict__``. Merely defining + # them is what switches the mixin on for targets -- it was + # previously a complete no-op for every target in the package. + self.device = normalize_device(device) + self.dtype_float = get_float_dtype() + + # ---- device / dtype resolution -------------------------------------- + + def _adopt_device(self, *sources, device=None): + """Reconcile this target with the device-bearing objects it wraps. + + Call *after* the sources are attached and *before* allocating any + buffer. ``None`` sources are dropped, so the empty-init path used by + ``load_state_dict`` keeps the seeded default, and the method stays + re-runnable if a source is attached later. + + ``self`` is deliberately **not** passed to + :func:`~torchref.utils.resolve_device` -- unlike + ``ScalerBase.set_data``, which does pass it. A freshly constructed + target owns no tensors, so there is nothing of its own to reconcile, + and a target's state is a handful of scalars against a ``Model``'s + entire structure. Letting an empty shell's default device outvote a + model already on the accelerator would move megabytes to satisfy five + floats. + + Returns + ------- + torch.device + The resolved device (also stored on ``self.device``). + """ + present = [s for s in sources if s is not None] + if device is None and not present: + return self.device + self.device = resolve_device(*present, device=device) + for src in present: + src_dtype = getattr(src, "dtype_float", None) + if isinstance(src_dtype, torch.dtype): + self.dtype_float = src_dtype + break + return self.device + + def _register_scalar(self, name: str, value, dtype=None) -> None: + """Register a 0-dim tunable on this target's device and float dtype. + + Scalar tunables reach Triton kernels as tensors, where a CPU-resident + buffer is a raw pointer into host memory rather than a promotable + scalar. Allocating them correctly here is what lets the lazy ``.to()`` + repairs inside ``forward()`` go away. + """ + self.register_buffer( + name, + torch.tensor( + value, + device=self.device, + dtype=self.dtype_float if dtype is None else dtype, + ), + ) def forward(self) -> torch.Tensor: """Compute and return the loss. Override in subclasses.""" @@ -181,6 +254,7 @@ def __init__( self, model: "Model" = None, verbose: int = 0, + device=None, **kwargs, ): """ @@ -192,11 +266,16 @@ def __init__( Reference to the Model object (optional for empty init). verbose : int, optional Verbosity level. Default is 0. + device : torch.device, optional + Explicit device. When omitted, follows ``model``. """ - super().__init__(verbose=verbose) + super().__init__(verbose=verbose, device=device) # Register model as a proper submodule (not in state_dict but handles device) # Use add_module to allow None values self.add_module("_model", model) + # Follow the model rather than the global default, so subclass buffers + # allocated below this line land beside the coordinates they act on. + self._adopt_device(model, device=device) @property def model(self) -> "Model": @@ -268,6 +347,7 @@ def __init__( model: "Model" = None, scaler: "Scaler" = None, verbose: int = 0, + device=None, **kwargs, ): """ @@ -284,12 +364,21 @@ def __init__( Reference to the Scaler object. verbose : int, optional Verbosity level. Default is 0. + device : torch.device, optional + Explicit device. When omitted, model / data / scaler are + reconciled onto one device, the model winning on disagreement. """ - super().__init__(verbose=verbose) - # Register as proper submodules (allows None values) + super().__init__(verbose=verbose, device=device) + # Register as proper submodules (allows None values). Note ``_data`` is + # a plain attribute: ReflectionData is a dataclass, not an nn.Module, + # so add_module would reject it -- but DeviceMixin still walks it. self.add_module("_model", model) self._data = data self.add_module("_scaler", scaler) + # The forward path mixes tensors from all three, so pin them together + # here. Order is precedence: ``resolve_device`` is first-wins, and the + # model is what the rest of the pipeline follows. + self._adopt_device(model, data, scaler, device=device) @property def model(self) -> "Model": diff --git a/torchref/refinement/targets/geometry/base.py b/torchref/refinement/targets/geometry/base.py index d990859..04c7c79 100644 --- a/torchref/refinement/targets/geometry/base.py +++ b/torchref/refinement/targets/geometry/base.py @@ -39,9 +39,10 @@ def __init__( self, model: "Model" = None, verbose: int = 0, + device=None, **kwargs, ): - super().__init__(model, verbose) + super().__init__(model, verbose, device=device) def stats(self) -> Dict[str, StatEntry]: """ diff --git a/torchref/refinement/targets/geometry/non_bonded.py b/torchref/refinement/targets/geometry/non_bonded.py index d4f457b..1d61abc 100644 --- a/torchref/refinement/targets/geometry/non_bonded.py +++ b/torchref/refinement/targets/geometry/non_bonded.py @@ -109,6 +109,7 @@ def __init__( rebuild_threshold: float = 1.0, verbose: int = 0, scale: float = 10.0, + device=None, ): """ Initialize non-bonded target. @@ -142,23 +143,50 @@ def __init__( Public attribute stored as ``self.scale``. Default is 10.0. Set but not consumed by ``forward()`` (informational only). """ - super().__init__(model, verbose) + super().__init__(model, verbose, device=device) self.mode = mode self.scale = scale - # Register sigma / r_exp / buffer as buffers so .to(device) moves them. - self.register_buffer("_sigma_vdw", torch.tensor(float(sigma))) - self.register_buffer("_r_exp", torch.tensor(float(r_exp))) - self.register_buffer("_buffer", torch.tensor(float(buffer))) - self.register_buffer( - "_rebuild_threshold", torch.tensor(float(rebuild_threshold)) - ) + # Tunables that reach the kernel live on the target's resolved device + # and float dtype: the prolsq branch hands these straight to a Triton + # kernel, where a CPU tensor is a raw pointer into host memory rather + # than a promotable scalar. + self._register_scalar("_sigma_vdw", float(sigma)) + self._register_scalar("_r_exp", float(r_exp)) # c_rep: back-door override; by default derived from sigma so that # PROLSQ shape term equals v^p / (p * sigma^p). if c_rep is None: c_rep_val = 1.0 / (float(r_exp) * float(sigma) ** float(r_exp)) else: c_rep_val = float(c_rep) - self.register_buffer("_c_rep", torch.tensor(c_rep_val)) + self._register_scalar("_c_rep", c_rep_val) + # Host-side, deliberately not buffers. + # + # ``buffer`` is consumed as a Python float -- forward() already called + # ``float(self._buffer)`` before handing it to the kernel, where it + # becomes a compile-time constant. Storing it on the device only bought + # a device->host sync on the hot path. + # + # ``rebuild_threshold`` is only ever compared against an already + # ``.item()``-ed displacement in maintenance(). + self._buffer = float(buffer) + self._rebuild_threshold = float(rebuild_threshold) + + def _load_from_state_dict(self, state_dict, prefix, *args, **kwargs): + """Absorb the retired ``_buffer`` / ``_rebuild_threshold`` buffers. + + Both are host-side Python floats now (see ``__init__``). Checkpoints + written before that change still carry them, and a ``strict=True`` load + would reject them as unexpected keys -- so restore their values rather + than discarding a user's tuning. + """ + for legacy, attr in ( + ("_buffer", "_buffer"), + ("_rebuild_threshold", "_rebuild_threshold"), + ): + saved = state_dict.pop(prefix + legacy, None) + if saved is not None: + setattr(self, attr, float(saved.item())) + return super()._load_from_state_dict(state_dict, prefix, *args, **kwargs) @property def c_rep(self) -> float: @@ -199,13 +227,13 @@ def r_exp(self, value: float): @property def buffer(self) -> float: - """Get distance buffer.""" - return self._buffer.item() + """Get distance buffer (host-side; see ``__init__``).""" + return self._buffer @buffer.setter def buffer(self, value: float): """Set distance buffer.""" - self._buffer.fill_(value) + self._buffer = float(value) def maintenance(self) -> None: """Rebuild the VDW pair list if any ASU atom drifted too far. @@ -245,12 +273,12 @@ def maintenance(self) -> None: max_disp_sq = (delta * delta).sum(dim=-1).max() thresh_sq = self._rebuild_threshold * self._rebuild_threshold - if max_disp_sq.item() <= thresh_sq.item(): + if max_disp_sq.item() <= thresh_sq: return # within slack — nothing to do if self.verbose > 0: max_disp = float(max_disp_sq.item()) ** 0.5 - thresh = float(self._rebuild_threshold.item()) + thresh = self._rebuild_threshold print( f" VDW rebuild: max drift {max_disp:.2f} Å > " f"threshold {thresh:.2f} Å" @@ -358,7 +386,7 @@ def forward(self) -> torch.Tensor: self.model.cell.fractional_matrix, self.model.cell.inv_fractional_matrix, self._c_rep, self._r_exp, - float(self._buffer), self._sigma_vdw, + self._buffer, self._sigma_vdw, ) # Compute positions (handles symmetry transparently) diff --git a/torchref/refinement/targets/similarity.py b/torchref/refinement/targets/similarity.py index 01872a9..83edc2c 100644 --- a/torchref/refinement/targets/similarity.py +++ b/torchref/refinement/targets/similarity.py @@ -72,11 +72,26 @@ def __init__( model_light: "Model" = None, alpha: float = 2.0, verbose: int = 0, + device=None, ): - super().__init__(verbose=verbose) + super().__init__(verbose=verbose, device=device) self.add_module("_model_dark", model_dark) self.add_module("_model_light", model_light) - self.register_buffer("_alpha", torch.tensor(alpha)) + self._adopt_device(model_dark, model_light, device=device) + self._register_scalar("_alpha", float(alpha)) + # Registered unconditionally, before the map is built. They used to be + # created only inside ``_build_atom_map``, which runs only when both + # models are present -- so on the empty-init path (the one that exists + # for ``load_state_dict``) they never existed at all: ``forward()`` + # raised AttributeError and a strict load of a populated checkpoint + # failed on unexpected keys. ``_build_atom_map`` now overwrites rather + # than creates. + self.register_buffer( + "_idx_dark", torch.zeros(0, dtype=torch.long, device=self.device) + ) + self.register_buffer( + "_idx_light", torch.zeros(0, dtype=torch.long, device=self.device) + ) if model_dark is not None and model_light is not None: self._build_atom_map() @@ -148,10 +163,10 @@ def _build_atom_map(self): "dark and light models" ) self.register_buffer( - "_idx_dark", torch.zeros(0, dtype=torch.long) + "_idx_dark", torch.zeros(0, dtype=torch.long, device=self.device) ) self.register_buffer( - "_idx_light", torch.zeros(0, dtype=torch.long) + "_idx_light", torch.zeros(0, dtype=torch.long, device=self.device) ) return @@ -168,13 +183,21 @@ def _build_atom_map(self): f"(dark={n_dark}, light={n_light})" ) + # On ``self.device``: these are 1-D index tensors, so unlike the 0-dim + # scalars they are not covered by PyTorch's scalar-promotion rule. A + # CPU-resident index against accelerator coordinates costs a host sync + # on every forward. self.register_buffer( "_idx_dark", - torch.tensor(merged["_idx_dark"].values, dtype=torch.long), + torch.tensor( + merged["_idx_dark"].values, dtype=torch.long, device=self.device + ), ) self.register_buffer( "_idx_light", - torch.tensor(merged["_idx_light"].values, dtype=torch.long), + torch.tensor( + merged["_idx_light"].values, dtype=torch.long, device=self.device + ), ) def forward(self) -> torch.Tensor: @@ -186,8 +209,9 @@ def forward(self) -> torch.Tensor: Scalar mean loss over all matched atom pairs. """ if len(self._idx_dark) == 0: - device = self._alpha.device - return torch.tensor(0.0, device=device) + # ``self.device``, not ``self._alpha.device``: an empty target must + # still hand back a loss on the refinement's device. + return torch.tensor(0.0, device=self.device, dtype=self.dtype_float) xyz_dark = self._model_dark.xyz() xyz_light = self._model_light.xyz() diff --git a/torchref/refinement/targets/xray/base.py b/torchref/refinement/targets/xray/base.py index c0ccb42..b74f606 100644 --- a/torchref/refinement/targets/xray/base.py +++ b/torchref/refinement/targets/xray/base.py @@ -67,6 +67,7 @@ def __init__( use_work_set: bool = True, verbose: int = 0, use_set: str = None, + device=None, ): """ Initialize X-ray target. @@ -89,7 +90,9 @@ def __init__( verbose : int, optional Verbosity level. Default is 0. """ - super().__init__(data=data, model=model, scaler=scaler, verbose=verbose) + super().__init__( + data=data, model=model, scaler=scaler, verbose=verbose, device=device + ) # ``use_set`` (3-way: "work"/"free"/"val") is the canonical subset # selector; the legacy ``use_work_set`` bool maps onto it. When # ``use_set`` is not given explicitly, fall back to the bool so older diff --git a/torchref/refinement/targets/xray/bhattacharyya.py b/torchref/refinement/targets/xray/bhattacharyya.py index 04f3aeb..fe3cb70 100644 --- a/torchref/refinement/targets/xray/bhattacharyya.py +++ b/torchref/refinement/targets/xray/bhattacharyya.py @@ -107,30 +107,49 @@ def __init__( verbose=verbose, use_set=use_set, ) - # log-spaced B grid + # log-spaced B grid. ``log_b_grid`` itself is a construction-time + # local, not a buffer: it is read twice below for the min/max and never + # touched again, so registering it only created something for ``.to()`` + # to carry around. log_b_grid = torch.linspace( - math.log(b_grid_min), math.log(b_grid_max), b_grid_n + math.log(b_grid_min), + math.log(b_grid_max), + b_grid_n, + device=self.device, + dtype=self.dtype_float, ) - self.register_buffer("log_b_grid", log_b_grid) self.register_buffer("b_grid", torch.exp(log_b_grid)) self._log_b_min = float(log_b_grid[0].item()) self._log_b_max = float(log_b_grid[-1].item()) self._log_b_step = (self._log_b_max - self._log_b_min) / (b_grid_n - 1) # Global σ_m scale (tunable, non-learnable) + self._register_scalar("sigma_m_scale", float(sigma_m_scale)) + # Populated by _initialize_cache() on first forward(). Empty, but still + # allocated on this target's device: a 0-element tensor reports a + # device, and a CPU one here reads as a split object. + empty = dict(device=self.device, dtype=self.dtype_float) + self.register_buffer("exp_table", torch.empty(0, **empty)) # (b_grid_n, N_refl) + self.register_buffer("s_sq_per_refl", torch.empty(0, **empty)) # (N_refl,) + self.register_buffer("s_4_per_refl", torch.empty(0, **empty)) # (N_refl,) + self.register_buffer("f_sq_kh", torch.empty(0, **empty)) # (K, N_refl) + self.register_buffer("g_w_table", torch.empty(0, **empty)) # (K, b_grid_n) + self.register_buffer("g_4_table", torch.empty(0, **empty)) # (K, b_grid_n) self.register_buffer( - "sigma_m_scale", torch.tensor(float(sigma_m_scale)) + "atom_to_element", torch.empty(0, dtype=torch.long, device=self.device) ) - # Populated by _initialize_cache() on first forward() - self.register_buffer("exp_table", torch.empty(0)) # (b_grid_n, N_refl) - self.register_buffer("s_sq_per_refl", torch.empty(0)) # (N_refl,) - self.register_buffer("s_4_per_refl", torch.empty(0)) # (N_refl,) - self.register_buffer("f_sq_kh", torch.empty(0)) # (K, N_refl) - self.register_buffer("g_w_table", torch.empty(0)) # (K, b_grid_n) - self.register_buffer("g_4_table", torch.empty(0)) # (K, b_grid_n) - self.register_buffer("atom_to_element", torch.empty(0, dtype=torch.long)) - self.register_buffer("sigma_d_mean", torch.tensor(0.0)) + self._register_scalar("sigma_d_mean", 0.0) self._initialized = False + def _load_from_state_dict(self, state_dict, prefix, *args, **kwargs): + """Drop the retired ``log_b_grid`` buffer key. + + It is a construction-time local now (derived from the same + ``b_grid_min``/``max``/``n`` the constructor already has), so a + ``strict=True`` load of an older checkpoint would reject it. + """ + state_dict.pop(prefix + "log_b_grid", None) + return super()._load_from_state_dict(state_dict, prefix, *args, **kwargs) + # ------------------------------------------------------------------ # Cache initialisation (called once, on first forward) # ------------------------------------------------------------------ diff --git a/torchref/refinement/targets/xray/least_squares.py b/torchref/refinement/targets/xray/least_squares.py index 4dbe340..5fdff64 100644 --- a/torchref/refinement/targets/xray/least_squares.py +++ b/torchref/refinement/targets/xray/least_squares.py @@ -46,6 +46,7 @@ def __init__( n_bins: int = 20, verbose: int = 0, use_set: str = None, + device=None, ): if scale_mode not in ("scaler", "binwise_optimal"): raise ValueError( @@ -64,6 +65,7 @@ def __init__( use_work_set=use_work_set, verbose=verbose, use_set=use_set, + device=device, ) self.weighting = weighting self.scale_mode = scale_mode diff --git a/torchref/refinement/targets/xray/maximum_likelihood.py b/torchref/refinement/targets/xray/maximum_likelihood.py index af36f00..65878db 100644 --- a/torchref/refinement/targets/xray/maximum_likelihood.py +++ b/torchref/refinement/targets/xray/maximum_likelihood.py @@ -8,7 +8,6 @@ epsilon_from_hkl, ml_xray_loss_beta_math, ) -from torchref.utils.device_resolution import resolve_device from .base import XrayTarget from .gaussian import GaussianXrayTarget @@ -178,10 +177,10 @@ def create_xray_target( ) mode = "ml" - # Pin model/data/scaler onto one device before constructing the - # target — its forward path mixes tensors from all three. - resolve_device(model, data, scaler, device=device) - + # Device reconciliation is ``DataTarget.__init__``'s job now (it calls + # ``_adopt_device(model, data, scaler)`` before allocating anything), so + # doing it again here would be a second copy of the same policy, free to + # drift. ``device`` is forwarded instead. kwargs = dict( data=data, model=model, @@ -189,6 +188,7 @@ def create_xray_target( use_work_set=use_work_set, verbose=verbose, use_set=use_set, + device=device, ) if mode == "gaussian": return GaussianXrayTarget(**kwargs) diff --git a/torchref/scaling/scaler.py b/torchref/scaling/scaler.py index 59a8b3b..c691581 100644 --- a/torchref/scaling/scaler.py +++ b/torchref/scaling/scaler.py @@ -157,7 +157,15 @@ def set_model_and_data(self, model: "Model", data: ReflectionData): Model object for structure factor calculation. data : ReflectionData ReflectionData object with observed data. + + Notes + ----- + Receiver wins: the scaler already owns buffers by this point, so + ``model`` and ``data`` are reconciled onto *its* device. This is the + state-dict restore path, where the three objects are built separately + and can easily disagree. """ + resolve_device(self, model, data) # Set _model_ref directly: nn.Module.__setattr__ intercepts assignments # of nn.Module instances (like `self.model = model`) and registers them # as submodules, bypassing the property setter entirely. diff --git a/torchref/scaling/scaler_base.py b/torchref/scaling/scaler_base.py index 1870606..f317483 100644 --- a/torchref/scaling/scaler_base.py +++ b/torchref/scaling/scaler_base.py @@ -158,7 +158,14 @@ def set_data(self, data: "ReflectionData"): ---------- data : ReflectionData ReflectionData object with observed data. + + Notes + ----- + Receiver wins: this scaler may already hold buffers, so ``data`` is + moved onto the scaler's device rather than the buffers being registered + from whatever device ``data`` happened to be on. """ + self.device = resolve_device(self, data) self._data = ModuleReference(data) if data.cell is not None: self.cell = data.cell diff --git a/torchref/utils/device_mixin.py b/torchref/utils/device_mixin.py index f353330..72f8ecf 100644 --- a/torchref/utils/device_mixin.py +++ b/torchref/utils/device_mixin.py @@ -35,11 +35,33 @@ class MyDataclass(DeviceMixin): from __future__ import annotations +import inspect import threading import torch from torch import nn +from torchref.config import canonical_device + +# ``torch._C._nn._parse_to`` is the same parser ``nn.Module.to`` uses, so it +# accepts every overload PyTorch does -- including ``.to(other_tensor)`` and +# ``.to(0)``, which the hand-rolled ``_parse_to_args`` fallback below cannot +# express. It is a private API, so probe once at import rather than catching +# per call: catching ``TypeError``/``RuntimeError`` around each invocation +# would turn a genuinely invalid user argument into a silent no-op. +try: + _PARSE_TO = torch._C._nn._parse_to +except AttributeError: # pragma: no cover - defensive against API drift + _PARSE_TO = None + +# Whether this torch exposes ``nn.Module._apply(fn, recurse=...)``. Detected +# once by signature rather than by catching ``TypeError`` at the call site: a +# blanket catch there would also swallow a genuine ``TypeError`` raised *inside* +# ``_apply`` after some tensors had already moved, and retry the move. +_MODULE_APPLY_TAKES_RECURSE = ( + "recurse" in inspect.signature(nn.Module._apply).parameters +) + # --------------------------------------------------------------------------- # Thread-local traversal state (cycle detection across one top-level .to()) # --------------------------------------------------------------------------- @@ -71,11 +93,50 @@ class MyDataclass(DeviceMixin): ) +class _ToRequest: + """The ``(device, dtype)`` a top-level ``.to()`` asked for. + + :meth:`DeviceMixin._apply` receives only an opaque tensor-to-tensor ``fn``, + so an object that owns no tensors cannot work out where it was just asked + to go. Recording the parsed request on the traversal thread-local lets + :func:`_refresh_device_trackers` answer that question at *every* node -- + including tensor-free children, which are reached through + ``child._apply(fn)`` and therefore never pass through their own ``to()``. + + ``probe`` caches a :func:`_probe_target` result for the traversal so a + graph with many tensor-free nodes probes at most once. + """ + + __slots__ = ("device", "dtype", "probe") + + def __init__(self, device=None, dtype=None): + self.device = canonical_device(device) + self.dtype = dtype + self.probe = None + + def _current_visited(): """Return the active visited set, or ``None`` if not in a traversal.""" return getattr(_traversal_state, "visited", None) +def _current_request(): + """Return the active :class:`_ToRequest`, or ``None``.""" + return getattr(_traversal_state, "request", None) + + +def _push_request(request): + """Install ``request`` for the current traversal, returning the previous.""" + prev = getattr(_traversal_state, "request", None) + _traversal_state.request = request + return prev + + +def _pop_request(prev): + """Restore the request saved by :func:`_push_request`.""" + _traversal_state.request = prev + + def _enter_traversal(): """Begin a top-level traversal. Returns a token for :func:`_exit_traversal`. @@ -85,6 +146,9 @@ def _enter_traversal(): prev = getattr(_traversal_state, "visited", None) if prev is None: _traversal_state.visited = set() + # Never inherit a request leaked by an earlier traversal that unwound + # abnormally: a stale target would silently misdirect this one. + _traversal_state.request = None return "owner" return None @@ -93,6 +157,7 @@ def _exit_traversal(token): """End a top-level traversal.""" if token == "owner": _traversal_state.visited = None + _traversal_state.request = None def _parse_to_args(args, kwargs): @@ -116,6 +181,15 @@ def _parse_to_args(args, kwargs): return device, dtype +def _accepts_single_arg(func) -> bool: + """Whether ``func`` can be called with exactly one positional argument.""" + try: + inspect.signature(func).bind(None) + except (TypeError, ValueError): + return False + return True + + def _apply_to_obj(val, fn, visited): """Apply ``fn`` to any tensors inside ``val``, recursing as needed. @@ -147,12 +221,13 @@ def _apply_to_obj(val, fn, visited): if callable(apply_method) and not isinstance(val, type): if id(val) in visited: return val - try: + # Check the signature up front instead of catching ``TypeError`` from + # the call: a catch there cannot distinguish "wrong signature" from a + # real ``TypeError`` raised mid-traversal, and would silently leave the + # object partially moved. + if _accepts_single_arg(apply_method): apply_method(fn) return val - except TypeError: - # Object has an _apply with a different signature; skip silently. - pass if isinstance(val, list): new_list = [_apply_to_obj(v, fn, visited) for v in val] @@ -180,68 +255,259 @@ def _apply_to_obj(val, fn, visited): def _invalidate_caches(obj): - """Call ``reset_forward_cache`` / ``reset_cache`` if present.""" - reset_fwd = getattr(obj, "reset_forward_cache", None) - if callable(reset_fwd): - try: - reset_fwd() - except Exception: - pass - reset_full = getattr(obj, "reset_cache", None) - if callable(reset_full): + """Call ``reset_forward_cache`` / ``reset_cache`` if present. + + Failures propagate rather than being swallowed. A cache that fails to clear + keeps tensors from the *previous* device, which produces either a + cross-device error much later or -- worse -- silently stale numbers; that + is precisely the corruption this mixin exists to prevent, so it must not be + hidden behind a bare ``except``. + + Raises + ------ + RuntimeError + Chained from the hook's own exception, naming the object and hook. + Note that movement is **not atomic**: by the time a cache hook runs, + some of the object's tensors have already been transformed, so the + object may be left partially moved. + """ + for hook_name in ("reset_forward_cache", "reset_cache"): + hook = getattr(obj, hook_name, None) + if not callable(hook): + continue try: - reset_full() - except Exception: - pass - - -def _representative_tensor(obj): - """Return one tensor that reflects the current device/dtype of *obj*. - - Prefers buffers (their dtype tracks ``dtype_float`` for crystallographic - code) and falls back to parameters, then to a plain ``torch.Tensor`` - attribute found in ``__dict__``. + hook() + except Exception as exc: + raise RuntimeError( + f"{type(obj).__name__}.{hook_name}() failed during device/dtype " + f"movement: {type(exc).__name__}: {exc}. The object may be " + "partially moved and its caches may still hold tensors on the " + "previous device." + ) from exc + + +def _owned_tensors(obj): + """Yield the tensors *obj* itself owns (never a child's). + + ``recurse=False`` is deliberate: the trackers describe where this object + allocates, so inheriting a device from a submodule would report a + half-moved graph as consistent. """ if isinstance(obj, nn.Module): - for buf in obj.buffers(): - return buf - for param in obj.parameters(): - return param + # Read the registration dicts rather than ``buffers()``/``parameters()``: + # several classes here override those accessors with a no-argument + # signature (``SolventModel.parameters``, ``MixedTensor.parameters``), + # and the dicts are in any case the literal "owned, not inherited" set. + for buf in obj._buffers.values(): + if buf is not None: + yield buf + for param in obj._parameters.values(): + if param is not None: + yield param for val in obj.__dict__.values(): if isinstance(val, torch.Tensor): - return val - if hasattr(val, "_data") and isinstance(getattr(val, "_data"), torch.Tensor): - return val._data + yield val + else: + data = getattr(val, "_data", None) + if isinstance(data, torch.Tensor): + yield data + # Container subclasses (``TensorMasks`` is a ``dict``) keep their tensors in + # the container's own storage, not in ``__dict__``. + if isinstance(obj, dict): + for val in obj.values(): + if isinstance(val, torch.Tensor): + yield val + elif isinstance(obj, (list, tuple)): + for val in obj: + if isinstance(val, torch.Tensor): + yield val + + +def _representative_tensor(obj): + """Return one tensor reflecting *obj*'s own device, or ``None``.""" + for tensor in _owned_tensors(obj): + return tensor return None -def _refresh_device_trackers(obj): +def _observed_state(obj): + """Return ``(device, floating_dtype)`` observed from *obj*'s own tensors. + + The two axes are resolved **independently**: the first owned tensor fixes + the device, but only a floating/complex tensor may fix ``dtype_float``. + Deciding both from a single representative tensor lets an integer buffer + that happens to be registered first (``hkl``, ``aniso_flag``) veto the + dtype answer. + """ + device = None + dtype = None + for tensor in _owned_tensors(obj): + if device is None: + device = tensor.device + if dtype is None and (tensor.is_floating_point() or tensor.is_complex()): + dtype = tensor.dtype + if device is not None and dtype is not None: + break + return device, dtype + + +def _probe_target(fn): + """Discover what ``fn`` does to a tensor's device and floating dtype. + + Needed only on the paths that bypass :meth:`DeviceMixin.to` and therefore + record no request: ``.float()`` / ``.double()`` / ``.half()``, and an + ``_apply`` driven by a plain (non-mixin) ``nn.Module`` parent. + + ``fn`` is probed on scratch tensors whose devices are **known to be + valid**, never on the object's own tracker -- a tensor-free object can be + carrying a stale tracker naming a backend this host does not have (say + ``cuda:0`` on a CPU-only machine), and allocating there would fail exactly + when the answer matters most. + + **Both** axes need two reference points, and the scratches must differ on + both. A transforming ``fn`` funnels its two inputs to a single value on the + axis it touches, while a preserving ``fn`` hands each input's own value + back: + + ================== =================== =================== + ``fn`` devices agree? dtypes agree? + ================== =================== =================== + ``.to(device=D)`` yes -> device is D no -> dtype is None + ``.half()`` no -> None yes -> dtype is f16 + ``.to(D, f32)`` yes -> D yes -> f32 + ================== =================== =================== + + Probing one axis with a single reference is what makes a device-only move + report the scratch's own dtype and clobber ``dtype_float``. + + Scratch devices are always ones **known to be valid**, never the object's + own tracker -- a tensor-free object can carry a stale tracker naming a + backend this host does not have (``cuda:0`` on a CPU-only machine), and + allocating there would fail exactly when the answer matters most. + + Returns + ------- + tuple + ``(device, dtype)``; either is ``None`` when ``fn`` leaves that axis + untouched, or when ``fn`` could not be probed at all. + """ + accel = None + if torch.cuda.is_available(): + accel = torch.device("cuda", torch.cuda.current_device()) + elif hasattr(torch.backends, "mps") and torch.backends.mps.is_available(): + accel = torch.device("mps", 0) + + def run(device, dtype): + """Apply ``fn`` to one scratch tensor; ``None`` if that is not possible.""" + try: + scratch = torch.empty(0, device=device, dtype=dtype) + except (RuntimeError, TypeError): + return None # backend advertised but unusable for this dtype + # ``fn`` is arbitrary caller-supplied code, so a broad catch is correct + # here -- unlike the cache hooks above, a non-conforming ``fn`` is an + # expected outcome, not a corruption signal. The caller decides what an + # unprobeable ``fn`` means. + try: + out = fn(scratch) + except Exception: + return None + return out if isinstance(out, torch.Tensor) else None + + # The two axes get their own contrast pair, and each pair varies only along + # the axis it measures. Sharing one pair for both would couple them: an + # accelerator scratch cannot be cast to float64 on MPS, so probing dtype on + # the device pair makes ``.double()`` unprobeable on Apple silicon. + base = run(torch.device("cpu"), torch.float32) + if base is None: + return None, None + + device = base.device + if accel is not None: + # Contrast pair for the device axis: same dtype, different device. + other = run(accel, torch.float32) + if other is None: + device = None + elif other.device != base.device: + device = None # each kept its own device -> device-preserving + + dtype = None + if base.is_floating_point() or base.is_complex(): + # Contrast pair for the dtype axis: same device, different dtype. + # float16 rather than float64 so this stays cheap and universally + # supported; the CPU pin means ``.double()`` remains probeable. + other = run(torch.device("cpu"), torch.float16) + if other is not None and other.dtype == base.dtype: + dtype = base.dtype + + return device, dtype + + +_DEVICE_TRACKERS = ("device", "_device") +_DTYPE_TRACKERS = ("dtype_float", "_dtype") + + +def _refresh_device_trackers(obj, fn=None): """Refresh ``device`` / ``_device`` / ``dtype_float`` / ``_dtype`` trackers. - Only updates attributes whose current value is already a ``torch.device`` - / ``torch.dtype`` (or ``None`` / ``str`` / ``int``) — never overwrites - unrelated attributes that happen to share the same name. The tracker - controls where new tensors are subsequently allocated, so it must follow - ``.to()`` moves. + The tracker decides where the object allocates *next* -- over 200 call + sites in the package pass ``device=self.device`` -- so leaving it stale on + a tensor-free object silently misplaces every tensor that object later + creates. + + Resolution order, applied per axis: + + 1. a tensor the object itself owns (authoritative, already moved), + 2. the ``.to()`` request recorded on the traversal thread-local, which is + the only source available to an object holding no tensors, + 3. a probe of ``fn`` (see :func:`_probe_target`), for the ``.float()`` and + plain-parent paths that record no request. + + Only attributes already present in ``obj.__dict__`` and already holding a + device/dtype-shaped value are written, so an unrelated attribute that + happens to be named ``device`` is never clobbered. Written values are + canonical, so ``obj.device == some_tensor.device`` holds. """ - rep = _representative_tensor(obj) - if rep is None: + has_device_tracker = any(a in obj.__dict__ for a in _DEVICE_TRACKERS) + has_dtype_tracker = any(a in obj.__dict__ for a in _DTYPE_TRACKERS) + if not (has_device_tracker or has_dtype_tracker): return - for attr_name in ("device", "_device"): - if attr_name not in obj.__dict__: - continue - current = obj.__dict__[attr_name] - if current is None or isinstance(current, (torch.device, str, int)): - obj.__dict__[attr_name] = rep.device + device, dtype = _observed_state(obj) + + if device is None or dtype is None: + request = _current_request() + if request is not None: + if device is None: + device = request.device + if dtype is None: + dtype = request.dtype + if (device is None or dtype is None) and fn is not None: + if request.probe is None: + request.probe = _probe_target(fn) + probed_device, probed_dtype = request.probe + device = device if device is not None else probed_device + dtype = dtype if dtype is not None else probed_dtype + elif fn is not None: + probed_device, probed_dtype = _probe_target(fn) + device = device if device is not None else probed_device + dtype = dtype if dtype is not None else probed_dtype + + if device is not None: + device = canonical_device(device) + for attr_name in _DEVICE_TRACKERS: + if attr_name not in obj.__dict__: + continue + current = obj.__dict__[attr_name] + if current is None or isinstance(current, (torch.device, str, int)): + obj.__dict__[attr_name] = device - if rep.is_floating_point() or rep.is_complex(): - for attr_name in ("dtype_float", "_dtype"): + if dtype is not None: + for attr_name in _DTYPE_TRACKERS: if attr_name not in obj.__dict__: continue current = obj.__dict__[attr_name] if current is None or isinstance(current, torch.dtype): - obj.__dict__[attr_name] = rep.dtype + obj.__dict__[attr_name] = dtype def _safe_setattr(obj, name, value): @@ -293,19 +559,39 @@ def to(self, *args, **kwargs): # type: ignore[override] pair is parsed and applied via :meth:`_apply`. A call that resolves to neither a device nor a dtype is a no-op that returns ``self``. """ + if _PARSE_TO is not None: + # Let PyTorch's own parser raise on invalid arguments. + device, dtype = _PARSE_TO(*args, **kwargs)[:2] + else: # pragma: no cover - only on a torch build without the private API + device, dtype = _parse_to_args(args, kwargs) + + # Build the request BEFORE claiming the traversal. ``_ToRequest`` + # canonicalises the device, which raises for a backend this host does + # not have (``.to('cuda')`` on a CUDA-less machine). Constructing it + # after ``_enter_traversal`` but outside the ``try`` leaked the + # thread-local visited set on that raise, and every later ``.to()`` on + # the thread then short-circuited as "already visited" -- moving + # nothing, silently. + request = _ToRequest(device, dtype) + token = _enter_traversal() + prev_request = _push_request(request) try: if isinstance(self, nn.Module): return super().to(*args, **kwargs) - device, dtype = _parse_to_args(args, kwargs) if device is None and dtype is None: return self + # Forward the caller's original arguments rather than the parsed + # pair, so overloads and options the parser folds away -- + # ``.to(other_tensor)``, ``non_blocking=``, ``memory_format=`` -- + # reach the tensors intact. def fn(t): - return t.to(device=device, dtype=dtype) + return t.to(*args, **kwargs) return self._apply(fn) finally: + _pop_request(prev_request) _exit_traversal(token) def cuda(self, device=None): # type: ignore[override] @@ -356,11 +642,15 @@ def _apply(self, fn, recurse=True): # type: ignore[override] return self visited.add(id(self)) + # Snapshot before anything moves, so step 5 can tell a real + # transformation from a ``.to()`` that targets the current state. + old_device, old_dtype = _observed_state(self) + # 1. Standard nn.Module traversal (params, buffers, child modules). if isinstance(self, nn.Module): - try: + if _MODULE_APPLY_TAKES_RECURSE: super()._apply(fn, recurse=recurse) - except TypeError: + else: # pragma: no cover - older torch without the kwarg super()._apply(fn) # Mark registered children as visited so that other plain @@ -379,15 +669,72 @@ def _apply(self, fn, recurse=True): # type: ignore[override] # 3. Refresh ``device`` / ``_device`` / ``dtype`` trackers so # subsequent tensor allocations target the new device. - _refresh_device_trackers(self) + _refresh_device_trackers(self, fn) # 4. Invalidate caches. _invalidate_caches(self) + + # 5. Notify the object iff something actually changed, so index + # rebuilds do not fire on a no-op ``.to(current_device)``. + new_device, new_dtype = _observed_state(self) + device_changed = ( + old_device is not None + and new_device is not None + and canonical_device(old_device) != canonical_device(new_device) + ) + dtype_changed = ( + old_dtype is not None + and new_dtype is not None + and old_dtype != new_dtype + ) + if device_changed or dtype_changed: + self._after_device_apply( + old_device, + new_device, + old_dtype, + new_dtype, + device_changed=device_changed, + dtype_changed=dtype_changed, + ) return self finally: if token is not None: _exit_traversal(token) + def _after_device_apply( + self, + old_device, + new_device, + old_dtype, + new_dtype, + *, + device_changed, + dtype_changed, + ): + """Hook called once per object after a *real* device/dtype change. + + No-op by default. Override to rebuild state derived from tensor + placement -- precomputed index tensors, device-specific caches -- that + the generic walk cannot repair on its own. + + Use this rather than ``reset_cache()``: ``reset_cache`` is a + functional-cache hook that :meth:`LossState.reset_caches` fires after + *every* optimizer step, so rebuilding indices there would put index + reconstruction and GPU syncs on the hot path. This hook only fires when + movement actually happened. + + Parameters + ---------- + old_device, new_device : torch.device or None + Device before and after. ``None`` when the object owned no tensors + on that side of the move. + old_dtype, new_dtype : torch.dtype or None + Floating/complex dtype before and after, resolved independently of + the device. + device_changed, dtype_changed : bool + Which axes actually changed. At least one is always True. + """ + # --------------------------------------------------------------------------- # Backwards-compatibility aliases diff --git a/torchref/utils/device_resolution.py b/torchref/utils/device_resolution.py index f968002..a027832 100644 --- a/torchref/utils/device_resolution.py +++ b/torchref/utils/device_resolution.py @@ -18,19 +18,10 @@ import torch +from torchref.config import canonical_device as _canonical from torchref.config import get_default_device -def _canonical(device: torch.device) -> torch.device: - """Return ``device`` with its default index filled in. - - ``torch.device('cuda') != torch.device('cuda:0')`` even though both - point to the same physical device. Materialising an empty tensor - on the device is the cheapest way to get the canonical form. - """ - return torch.empty(0, device=device).device - - def resolve_device( *modules: Any, device: Optional[Union[torch.device, str]] = None, @@ -90,9 +81,17 @@ def resolve_device( device(type='cuda') """ if device is not None: - resolved = torch.device(device) if not isinstance(device, torch.device) else device + resolved = _canonical(device) for m in modules: - if m is not None: + # Skip modules already on target. ``.to()`` invalidates caches and + # fires ``_after_device_apply``, so a no-op move is not free -- + # ``SfDS.forward`` calls ``resolve_device`` on every evaluation. + # + # This is only safe because ``DeviceMixin`` now keeps ``m.device`` + # truthful; before that, the unconditional ``.to()`` here was + # accidentally repairing objects whose tracker lied about where + # their tensors were. + if m is not None and _canonical(m.device) != resolved: m.to(resolved) return resolved diff --git a/torchref/utils/utils.py b/torchref/utils/utils.py index 3e5d036..382e1cd 100644 --- a/torchref/utils/utils.py +++ b/torchref/utils/utils.py @@ -314,12 +314,14 @@ def _apply(self, fn): self._cache = None self._updated = True - # Refresh the ``device`` tracker so future ``__setitem__`` calls - # (which migrate incoming tensors to ``self.device``) land correctly. - for v in self.values(): - if isinstance(v, torch.Tensor): - self.device = v.device - break + # Refresh the ``device`` tracker (future ``__setitem__`` calls migrate + # incoming tensors to it) through the shared helper rather than a local + # copy: it also handles the empty-``TensorMasks`` case, where there is + # no mask tensor to read a device from and the tracker has to come from + # the recorded ``.to()`` request. + from torchref.utils.device_mixin import _refresh_device_trackers + + _refresh_device_trackers(self, fn) return self def reset_cache(self) -> None: From f228939e736e662e8cf598fe7648a67275098085 Mon Sep 17 00:00:00 2001 From: HatPdotS Date: Tue, 28 Jul 2026 16:45:51 +0900 Subject: [PATCH 05/15] Cleaned up testing errors --- tests/integration/test_ds_triton_vs_eager.py | 4 +- tests/integration/test_variable_radius_gpu.py | 4 +- tests/integration/test_variable_radius_mps.py | 4 +- tests/unit/test_device_mixin.py | 53 ++++++++++++++----- tests/unit/test_gradient_correctness.py | 6 +-- 5 files changed, 49 insertions(+), 22 deletions(-) diff --git a/tests/integration/test_ds_triton_vs_eager.py b/tests/integration/test_ds_triton_vs_eager.py index cddc707..373f2d4 100644 --- a/tests/integration/test_ds_triton_vs_eager.py +++ b/tests/integration/test_ds_triton_vs_eager.py @@ -4,14 +4,14 @@ eager backend evaluated in float64 (downcast for comparison), for both the isolated P1 kernels and the full ``SfDS`` symmetry loop. -Markers: ``@pytest.mark.cuda`` (skipped without ``--run-gpu``) and +Markers: ``@pytest.mark.cuda`` (auto-skipped without CUDA) and ``@pytest.mark.integration``. Requires a CUDA device. """ import pytest import torch -pytestmark = [pytest.mark.gpu, pytest.mark.integration] +pytestmark = [pytest.mark.cuda, pytest.mark.integration] _ATOL = 1e-2 _RTOL = 1e-3 diff --git a/tests/integration/test_variable_radius_gpu.py b/tests/integration/test_variable_radius_gpu.py index 127b198..5f3ca85 100644 --- a/tests/integration/test_variable_radius_gpu.py +++ b/tests/integration/test_variable_radius_gpu.py @@ -5,7 +5,7 @@ forward maps must agree to float32 + analytic-vs-gathered-coord tolerance and the gradients (xyz / adp / u / occ) must be parallel. Requires CUDA + Triton. -Markers: ``@pytest.mark.cuda`` (skipped without ``--run-gpu``), ``integration``. +Markers: ``@pytest.mark.cuda`` (auto-skipped without CUDA), ``integration``. """ import math @@ -16,7 +16,7 @@ from torchref.base.electron_density.main import build_electron_density from torchref.utils import Engine, use_engine -pytestmark = [pytest.mark.gpu, pytest.mark.integration] +pytestmark = [pytest.mark.cuda, pytest.mark.integration] def _cell(): diff --git a/tests/integration/test_variable_radius_mps.py b/tests/integration/test_variable_radius_mps.py index d250df7..cba7553 100644 --- a/tests/integration/test_variable_radius_mps.py +++ b/tests/integration/test_variable_radius_mps.py @@ -5,7 +5,7 @@ agree to float32 + truncation-shape tolerance and the gradients (xyz / adp / u / occ) must be parallel. Requires an Apple-silicon GPU (MPS); skipped elsewhere. -Markers: ``@pytest.mark.mps`` (skipped without ``--run-gpu``), ``integration``. +Markers: ``@pytest.mark.mps`` (auto-skipped without MPS), ``integration``. """ import math @@ -17,7 +17,7 @@ from torchref.base.electron_density.main import build_electron_density from torchref.utils import Engine, use_engine -pytestmark = [pytest.mark.gpu, pytest.mark.integration] +pytestmark = [pytest.mark.mps, pytest.mark.integration] @pytest.fixture diff --git a/tests/unit/test_device_mixin.py b/tests/unit/test_device_mixin.py index 11f49c7..32009ca 100644 --- a/tests/unit/test_device_mixin.py +++ b/tests/unit/test_device_mixin.py @@ -295,26 +295,53 @@ def test_dtype_only_move_does_not_clobber_device_tracker(): @pytest.mark.unit @pytest.mark.parametrize( - "fn,expected_device,expected_dtype", + "fn,expected_dtype", [ - (lambda t: t.to("cpu"), torch.device("cpu"), None), - (lambda t: t.double(), None, torch.float64), - (lambda t: t.float(), None, torch.float32), - (lambda t: t.half(), None, torch.float16), - (lambda t: t, None, None), + (lambda t: t.to("cpu"), None), + (lambda t: t.double(), torch.float64), + (lambda t: t.float(), torch.float32), + (lambda t: t.half(), torch.float16), + (lambda t: t, None), ], ids=["to_cpu", "double", "float", "half", "identity"], ) -def test_probe_target_resolves_axes_independently(fn, expected_device, expected_dtype): - """``_probe_target`` reports only the axis ``fn`` actually transforms. +def test_probe_target_dtype_axis(fn, expected_dtype): + """The dtype axis reports only what ``fn`` actually casts to. - ``.double()`` is the case that pins the CPU-only dtype pair: MPS has no - float64, so contrasting dtype on an accelerator scratch would make it - unprobeable on Apple silicon. + Host-independent: the dtype contrast pair is pinned to CPU, which is also + what keeps ``.double()`` probeable on Apple silicon (MPS has no float64, so + contrasting dtype on an accelerator scratch would fail outright). """ from torchref.utils.device_mixin import _probe_target - device, dtype = _probe_target(fn) - assert device == expected_device + _, dtype = _probe_target(fn) assert dtype == expected_dtype + +@pytest.mark.unit +@pytest.mark.gpu +@pytest.mark.parametrize( + "fn,preserves_device", + [ + (lambda t: t.to("cpu"), False), + (lambda t: t.double(), True), + (lambda t: t.float(), True), + (lambda t: t, True), + ], + ids=["to_cpu", "double", "float", "identity"], +) +def test_probe_target_device_axis(fn, preserves_device): + """The device axis distinguishes "moves to D" from "keeps its input's device". + + Requires an accelerator: telling those two apart needs two devices to + contrast. On a single-device host they are genuinely indistinguishable -- + and reporting ``cpu`` there is correct, since that is where everything is + anyway -- so there is nothing to assert. + """ + from torchref.utils.device_mixin import _probe_target + + device, _ = _probe_target(fn) + if preserves_device: + assert device is None, "device-preserving fn must not pin a device" + else: + assert device == torch.device("cpu") diff --git a/tests/unit/test_gradient_correctness.py b/tests/unit/test_gradient_correctness.py index 8ec71bf..364ad49 100644 --- a/tests/unit/test_gradient_correctness.py +++ b/tests/unit/test_gradient_correctness.py @@ -29,8 +29,8 @@ validated with the same cosine/gradnorm metric against central finite differences of a scalar loss (:func:`test_model_forward_xyz_adp_*`). -CPU-only by default; the Triton comparisons are ``gpu``/``cuda_only`` and skip -unless ``--run-gpu`` is passed on a CUDA host. +CPU-only by default; the Triton comparisons are marked ``cuda`` and are +auto-skipped unless the host actually has a CUDA device. """ import itertools @@ -361,7 +361,7 @@ def loss_fn(): # ============================================================================= # 6. Triton kernels (CUDA float32, hand-written backward) vs eager autograd -# Metric: cosine similarity + gradnorm ratio. Skipped without --run-gpu. +# Metric: cosine similarity + gradnorm ratio. Auto-skipped without CUDA. # ============================================================================= @pytest.mark.cuda @pytest.mark.skipif(not _HAS_CUDA, reason="Triton kernels require CUDA") From affeaa7490c838903653346f41d260a9a0651c01 Mon Sep 17 00:00:00 2001 From: HatPdotS Date: Tue, 28 Jul 2026 17:46:53 +0900 Subject: [PATCH 06/15] Fixed further device handling inconsistencies --- tests/integration/test_device_mixin.py | 12 ++------ tests/integration/test_refinement_pipeline.py | 4 +-- torchref/base/alignment/normalization.py | 5 ++-- torchref/base/alignment/rotation.py | 5 ++-- torchref/base/reciprocal/symmetry.py | 9 ++++-- torchref/cli/_common.py | 10 +++---- torchref/cli/validate_ded.py | 5 ++-- torchref/io/datasets/base.py | 12 ++++---- torchref/io/datasets/fcalc_data.py | 17 +++++++---- torchref/io/datasets/reflection_data.py | 28 +++++++++++++------ torchref/io/ihm.py | 5 ++-- torchref/maps/difference_map.py | 1 - torchref/model/mixed_model.py | 9 +++--- torchref/model/model.py | 13 +++++---- torchref/model/model_collection.py | 9 ++++-- torchref/model/model_ft.py | 5 ++-- torchref/model/parameter_wrappers.py | 7 +++-- torchref/model/sf_fft.py | 6 ++-- torchref/refinement/base_refinement.py | 5 ++-- torchref/restraints/hydrogen_topology.py | 25 +++++++++++------ torchref/restraints/restraints.py | 2 +- torchref/scaling/collection_scaler.py | 8 ++++-- torchref/scaling/scaler.py | 1 - torchref/scaling/scaler_base.py | 2 +- torchref/scaling/solvent.py | 10 +++++-- torchref/symmetry/cell.py | 5 ++-- torchref/symmetry/map_symmetry.py | 28 ++++++++++++++----- .../symmetry/map_symmetry_interpolation.py | 5 ++-- torchref/symmetry/reciprocal_symmetry.py | 5 ++-- torchref/symmetry/spacegroup.py | 8 ++---- torchref/utils/device_resolution.py | 22 +++++++++++++++ torchref/utils/utils.py | 9 +++--- 32 files changed, 180 insertions(+), 117 deletions(-) diff --git a/tests/integration/test_device_mixin.py b/tests/integration/test_device_mixin.py index 7e58644..788c1dc 100644 --- a/tests/integration/test_device_mixin.py +++ b/tests/integration/test_device_mixin.py @@ -13,9 +13,6 @@ import pytest import torch -CUDA_AVAILABLE = torch.cuda.is_available() - - def _load_model_ft(pdb_file, mtz_file): """Helper: load a ModelFT and matching reflection data on CPU. @@ -36,6 +33,7 @@ def _load_model_ft(pdb_file, mtz_file): @pytest.mark.integration +@pytest.mark.cuda def test_modelft_cpu_gpu_cpu_sf_round_trip(sample_pdb_file, sample_mtz_file): """CPU -> GPU -> CPU structure-factor round-trip via the unified mixin. @@ -46,9 +44,6 @@ def test_modelft_cpu_gpu_cpu_sf_round_trip(sample_pdb_file, sample_mtz_file): 3. Move back to CPU, recompute Fcalc; verify CPU placement and values match the original CPU result. """ - if not CUDA_AVAILABLE: - pytest.skip("CUDA device not available") - model, data = _load_model_ft(sample_pdb_file, sample_mtz_file) hkl, *_ = data() @@ -66,9 +61,8 @@ def test_modelft_cpu_gpu_cpu_sf_round_trip(sample_pdb_file, sample_mtz_file): assert fcalc_cuda.device.type == "cuda" assert model.device.type == "cuda" assert model.cell.device.type == "cuda", "Cell did not migrate to GPU" - for buf in model.buffers(): - assert buf.device.type == "cuda", "buffer left behind on CPU" - break + for name, buf in model.named_buffers(): + assert buf.device.type == "cuda", f"buffer {name} left behind on CPU" # Values must agree within numerical tolerance after the round-trip. The CPU # (C++ box-separable splat) and GPU (Triton work-queue splat) paths differ diff --git a/tests/integration/test_refinement_pipeline.py b/tests/integration/test_refinement_pipeline.py index 78c1639..4f38557 100644 --- a/tests/integration/test_refinement_pipeline.py +++ b/tests/integration/test_refinement_pipeline.py @@ -166,5 +166,5 @@ def test_gpu_refinement_setup(self, sample_structure_pair, gpu_device): data.load_mtz(sample_structure_pair["reflections"]) # Everything should be on GPU - assert model.xyz().device.type == 'cuda' - assert data.hkl.device.type == 'cuda' + assert model.xyz().device.type == gpu_device.type + assert data.hkl.device.type == gpu_device.type diff --git a/torchref/base/alignment/normalization.py b/torchref/base/alignment/normalization.py index 2599a47..e51e600 100644 --- a/torchref/base/alignment/normalization.py +++ b/torchref/base/alignment/normalization.py @@ -14,7 +14,7 @@ import torch from torchref.base.math_torch import U_to_matrix -from torchref.config import get_default_device +from torchref.config import normalize_device def compute_radial_shells( @@ -46,8 +46,7 @@ def compute_radial_shells( shell_centers : torch.Tensor Shell centers in Angstroms^-1, shape (n_shells,). """ - if device is None: - device = get_default_device() + device = normalize_device(device) s_min = 1.0 / d_max # Low resolution end s_max = 1.0 / d_min # High resolution end diff --git a/torchref/base/alignment/rotation.py b/torchref/base/alignment/rotation.py index a07f825..ad0d439 100644 --- a/torchref/base/alignment/rotation.py +++ b/torchref/base/alignment/rotation.py @@ -8,7 +8,7 @@ import numpy as np import torch -from torchref.config import dtypes, get_default_device, get_float_dtype +from torchref.config import dtypes, get_float_dtype, normalize_device def rotate_coords_torch(coords, phi, rho): @@ -261,8 +261,7 @@ def random_rotation_uniform( torch.Tensor Rotation matrices with shape (n, 3, 3) or (3, 3) if n=1. """ - if device is None: - device = get_default_device() + device = normalize_device(device) if dtype is None: dtype = get_float_dtype() # Sample uniform random numbers diff --git a/torchref/base/reciprocal/symmetry.py b/torchref/base/reciprocal/symmetry.py index e99e0f0..902afdb 100644 --- a/torchref/base/reciprocal/symmetry.py +++ b/torchref/base/reciprocal/symmetry.py @@ -34,7 +34,7 @@ import numpy as np import torch -from torchref.config import get_float_dtype +from torchref.config import canonical_device, get_float_dtype from torchref.utils.autograd_ops import gather_with_index_add from .grid_operations import extract_structure_factor_from_grid @@ -251,7 +251,12 @@ def __init__( grid_shape: tuple, device: Optional[torch.device] = None, ): - self.device = device or hkl.device + # ``is not None``, not ``or``: ``device=0`` means cuda:0/mps:0 and is + # falsy, so ``or`` silently discarded it. ``hkl`` is a bare tensor, so + # this reads its device rather than going through ``resolve_device``. + self.device = canonical_device( + device if device is not None else hkl.device + ) self.hkl = hkl.to(device=self.device) self.symmetry = symmetry self.n_ops = symmetry.n_ops diff --git a/torchref/cli/_common.py b/torchref/cli/_common.py index f91b854..67a9060 100644 --- a/torchref/cli/_common.py +++ b/torchref/cli/_common.py @@ -633,10 +633,9 @@ def load_model( ModelFT """ from torchref import ModelFT - from torchref.config import get_default_device + from torchref.config import normalize_device - if device is None: - device = get_default_device() + device = normalize_device(device) model = ModelFT(max_res=max_res, device=device, verbose=verbose) suffix = Path(path).suffix.lower() if suffix in (".cif", ".mmcif"): @@ -674,10 +673,9 @@ def load_reflection_data( ReflectionData """ from torchref import ReflectionData - from torchref.config import get_default_device + from torchref.config import normalize_device - if device is None: - device = get_default_device() + device = normalize_device(device) data = ReflectionData(device=str(device), verbose=verbose) suffix = Path(path).suffix.lower() if suffix in (".cif",): diff --git a/torchref/cli/validate_ded.py b/torchref/cli/validate_ded.py index 60156f6..1f457df 100644 --- a/torchref/cli/validate_ded.py +++ b/torchref/cli/validate_ded.py @@ -292,10 +292,9 @@ def setup_ded_context( from torchref.symmetry.grid_utils import calculate_optimal_grid_size from torchref.symmetry.reciprocal_symmetry import expand_hkl - if device is None: - from torchref.config import get_default_device + from torchref.config import normalize_device - device = get_default_device() + device = normalize_device(device) # Load and scale data_dark = load_reflection_data( diff --git a/torchref/io/datasets/base.py b/torchref/io/datasets/base.py index dd7013b..53271a7 100644 --- a/torchref/io/datasets/base.py +++ b/torchref/io/datasets/base.py @@ -20,7 +20,7 @@ import gemmi import torch -from torchref.config import get_default_device +from torchref.config import get_default_device, normalize_device from torchref.symmetry import Cell from torchref.utils.device_mixin import DeviceMovementMixin @@ -121,9 +121,10 @@ class CrystalDataset(DeviceMovementMixin): def __post_init__(self): """Initialize non-field attributes after dataclass init.""" - # Ensure device is a torch.device object - if isinstance(self.device, str): - object.__setattr__(self, "device", torch.device(self.device)) + # Canonicalise, don't merely coerce: ``torch.device("mps")`` has no + # index and so compares unequal to the ``mps:0`` every tensor reports, + # which makes ``resolve_device``'s equality checks misfire. + object.__setattr__(self, "device", normalize_device(self.device)) # Import here to avoid circular imports from torchref.utils.utils import TensorMasks @@ -204,8 +205,7 @@ def _from_state(cls, state: Dict[str, Any], device=None) -> "CrystalDataset": """ from torchref.utils.utils import TensorMasks - if device is None: - device = get_default_device() + device = normalize_device(device) # Extract masks before creating object masks_state = state.pop("masks", {}) diff --git a/torchref/io/datasets/fcalc_data.py b/torchref/io/datasets/fcalc_data.py index d513979..2184b4e 100644 --- a/torchref/io/datasets/fcalc_data.py +++ b/torchref/io/datasets/fcalc_data.py @@ -14,7 +14,7 @@ import pandas as pd import torch -from torchref.config import get_default_device, get_float_dtype +from torchref.config import get_default_device, get_float_dtype, normalize_device from torchref.symmetry import Cell, SpaceGroup, SpaceGroupLike from .base import CrystalDataset @@ -131,13 +131,21 @@ def from_cell_and_resolution( from torchref.base.reciprocal import get_d_spacing - if device is None: - device = get_default_device() + # ``Cell`` is the unit-cell carrier: when one is handed in and no device + # is requested, follow it rather than the global default -- otherwise a + # CPU-default host silently relocates the caller's cell. + if device is None and isinstance(cell, Cell): + device = cell.device + device = normalize_device(device) if dtype is None: dtype = get_float_dtype() # Handle Cell input - convert to Cell object if needed if isinstance(cell, Cell): + # ``Cell.to`` is in-place, so this moves the *caller's* object when + # an explicit device disagrees with it. That is the documented + # resolve_device contract; the branch above avoids it entirely when + # no device was requested. cell_obj = cell.to(device=device) cell_tensor = cell_obj.data else: @@ -376,8 +384,7 @@ def _from_state(cls, state: Dict[str, Any], device=None) -> "FcalcDataset": """ from torchref.utils.utils import TensorMasks - if device is None: - device = get_default_device() + device = normalize_device(device) # Extract masks before creating object masks_state = state.pop("masks", {}) diff --git a/torchref/io/datasets/reflection_data.py b/torchref/io/datasets/reflection_data.py index fcf2855..006f0da 100644 --- a/torchref/io/datasets/reflection_data.py +++ b/torchref/io/datasets/reflection_data.py @@ -8,7 +8,8 @@ import warnings from dataclasses import dataclass, field -from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple +from pathlib import Path +from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple, Union import numpy as np import pandas as pd @@ -17,7 +18,7 @@ from torchref.base import math_torch from torchref.base.french_wilson import FrenchWilson -from torchref.config import dtypes, get_default_device +from torchref.config import dtypes, get_default_device, normalize_device from torchref.io import cif, mtz from torchref.io.datasets.base import CrystalDataset from torchref.symmetry import Cell, SpaceGroup @@ -792,9 +793,14 @@ def from_tensors( ReflectionData Fully initialized reflection data with all cleanup applied. """ - if device is None: - device = get_default_device() - data = cls(device=device, verbose=verbose) + # Follow the incoming tensors when no device is given, rather than + # the global default -- otherwise building from accelerator-resident + # arrays on a CPU-default host silently round-trips every one of them + # through host memory. ``hkl`` is a bare tensor, so read its device + # directly; ``resolve_device`` is for objects it can move in place. + if device is None and isinstance(hkl, torch.Tensor): + device = hkl.device + data = cls(device=normalize_device(device), verbose=verbose) def _prep(t: torch.Tensor) -> torch.Tensor: # Detach (constant data) unless the caller wants the graph preserved. @@ -845,7 +851,7 @@ def _prep(t: torch.Tensor) -> torch.Tensor: def load_mtz( self, - path: str, + path: Union[str, Path], column_names: Optional[dict] = None, french_wilson: bool = True, anomalous: Optional[bool] = None, @@ -877,14 +883,18 @@ def load_mtz( ReflectionData Self, for method chaining. """ + # ``str(path)``: gemmi's readers take a str, and the public + # ``torchref.io.read_mtz`` already advertises ``Union[str, Path]``, so + # accepting a Path here too keeps the two entry points consistent + # instead of failing deep inside gemmi with an argument-type error. reader = mtz.MTZReader( verbose=self.verbose, column_names=column_names, anomalous=anomalous - ).read(path) + ).read(str(path)) return self.load(reader, french_wilson=french_wilson) def load_cif( self, - path: str, + path: Union[str, Path], data_block: Optional[str] = None, anomalous: Optional[bool] = None, ) -> "ReflectionData": @@ -910,7 +920,7 @@ def load_cif( Self, for method chaining. """ self.reader = cif.ReflectionCIFReader( - path, verbose=self.verbose, data_block=data_block, anomalous=anomalous + str(path), verbose=self.verbose, data_block=data_block, anomalous=anomalous ) return self.load(self.reader) diff --git a/torchref/io/ihm.py b/torchref/io/ihm.py index 5b1e858..a054dc6 100644 --- a/torchref/io/ihm.py +++ b/torchref/io/ihm.py @@ -474,10 +474,9 @@ def build_model_collection( "Call read_atom_data() first." ) - if device is None: - from torchref.config import get_default_device + from torchref.config import normalize_device - device = get_default_device() + device = normalize_device(device) # Build one ModelFT per state base_models = [] diff --git a/torchref/maps/difference_map.py b/torchref/maps/difference_map.py index 44a19e1..6051c5e 100644 --- a/torchref/maps/difference_map.py +++ b/torchref/maps/difference_map.py @@ -12,7 +12,6 @@ import torch from torchref.base.reciprocal.grid_operations import place_on_grid -from torchref.config import get_default_device from torchref.io.datasets.collection import DatasetCollection from torchref.maps.map import Map from torchref.symmetry.reciprocal_symmetry import expand_hkl diff --git a/torchref/model/mixed_model.py b/torchref/model/mixed_model.py index 787cebe..e24e443 100644 --- a/torchref/model/mixed_model.py +++ b/torchref/model/mixed_model.py @@ -12,6 +12,7 @@ from torch import nn from torchref.utils.device_mixin import DeviceMovementMixin +from torchref.utils.device_resolution import resolve_device if TYPE_CHECKING: from torchref.model.model_ft import ModelFT @@ -104,12 +105,12 @@ def __init__( self.verbose = verbose - # Infer device from first model if not specified - if device is None: - device = models[0].device + # First-wins reconciliation across the supplied models, and the move + # itself: ``resolve_device`` warns when they disagree, which the manual + # loop this replaces did silently. + device = resolve_device(*models, device=device) # Store models as ModuleList for proper PyTorch handling - models = [model.to(device=device) for model in models] self.models = nn.ModuleList(models) # Validate model compatibility diff --git a/torchref/model/model.py b/torchref/model/model.py index d11c5c8..6056a1d 100644 --- a/torchref/model/model.py +++ b/torchref/model/model.py @@ -19,7 +19,7 @@ import torch.nn as nn from torchref.base import math_torch -from torchref.config import get_default_device, get_float_dtype +from torchref.config import get_float_dtype, normalize_device from torchref.io import cif, pdb from torchref.model.parameter_wrappers import ( CholeskyMixedTensor, @@ -151,8 +151,7 @@ def __init__( # ``dtypes.float`` / ``device.current`` change is honored. if dtype_float is None: dtype_float = get_float_dtype() - if device is None: - device = get_default_device() + device = normalize_device(device) # Configuration self.dtype_float = dtype_float self.verbose = verbose @@ -287,7 +286,10 @@ def spacegroup(self, value): a space group name string, or a space group number. """ if value is not None: - self._spacegroup = SpaceGroup(value) + # ``device=self.device``: SpaceGroup falls back to the global + # default otherwise, so setting a spacegroup on a CPU-pinned Model + # would silently plant accelerator-resident matrices on it. + self._spacegroup = SpaceGroup(value, device=self.device) else: self._spacegroup = None @@ -2941,8 +2943,7 @@ def create_from_state_dict( """ # Resolve dtype/device at call time so the fallbacks below use the # current config, not the import-time default. - if device is None: - device = get_default_device() + device = normalize_device(device) if dtype_float is None: dtype_float = get_float_dtype() # Extract metadata (non-tensor data that we handle specially) diff --git a/torchref/model/model_collection.py b/torchref/model/model_collection.py index 34084c2..c798872 100644 --- a/torchref/model/model_collection.py +++ b/torchref/model/model_collection.py @@ -11,8 +11,8 @@ import torch from torch import nn -from torchref.config import get_default_device from torchref.utils.device_mixin import DeviceMovementMixin +from torchref.utils.device_resolution import resolve_device if TYPE_CHECKING: from torchref.model.model_ft import ModelFT @@ -69,8 +69,11 @@ def __init__( # Normalize to handle floating point drift initial_fractions = [f / total for f in initial_fractions] - if device is None: - device = base_models[0].device if hasattr(base_models[0], "device") else get_default_device() + # Reconcile across *all* base models, not just the first: reading + # ``base_models[0].device`` left a mixed-device list unreconciled, so + # ``fractions_tensor`` below could land on a device the later models + # were not on. + device = resolve_device(*base_models, device=device) # Match base models' float dtype (consistent under a float64 config). fractions_tensor = torch.tensor( diff --git a/torchref/model/model_ft.py b/torchref/model/model_ft.py index 214fc0c..a4195ed 100644 --- a/torchref/model/model_ft.py +++ b/torchref/model/model_ft.py @@ -6,7 +6,7 @@ import torch from torchref.base.fourier import fft, ifft -from torchref.config import dtypes, get_default_device, get_float_dtype +from torchref.config import dtypes, get_float_dtype, normalize_device from torchref.model.model import Model from torchref.model.sf_fft import SfFFT from torchref.symmetry import SpaceGroup @@ -1151,8 +1151,7 @@ def create_from_state_dict( # Resolve dtype/device at call time so the fallback below uses the # current config rather than an import-time default. - if device is None: - device = get_default_device() + device = normalize_device(device) if dtype_float is None: dtype_float = get_float_dtype() diff --git a/torchref/model/parameter_wrappers.py b/torchref/model/parameter_wrappers.py index accd7b1..550d68c 100644 --- a/torchref/model/parameter_wrappers.py +++ b/torchref/model/parameter_wrappers.py @@ -1706,8 +1706,11 @@ def __init__( refinable_values, requires_grad=requires_grad ) - # Store collapsed shape - self.register_buffer("_shape", torch.tensor([self._collapsed_shape])) + # Collapsed shape is host-side metadata, not a buffer -- same reasoning + # as ``MixedTensor`` (a buffer gets dragged onto the accelerator by + # ``.to()`` and then read back with a sync, purely to recover a number + # we already have). ``_collapsed_shape`` already holds it as an int; + # the ``shape`` property below derives from it. # Pre-compute index cache to avoid boolean indexing at runtime self._build_index_cache() diff --git a/torchref/model/sf_fft.py b/torchref/model/sf_fft.py index 0d80b11..d2deb8f 100644 --- a/torchref/model/sf_fft.py +++ b/torchref/model/sf_fft.py @@ -317,8 +317,10 @@ def compute_real_space_grid( torch.Tensor Real-space grid with shape (nx, ny, nz, 3). """ - if device is None: - device = get_default_device() + # Forward ``device`` as-is, including ``None``. ``get_real_grid`` + # already infers from ``fractional_matrix`` when no device is given; + # resolving the global default here meant it never saw ``None`` and + # its inference was dead code. return get_real_grid( fractional_matrix=fractional_matrix, gridsize=gridsize, device=device ) diff --git a/torchref/refinement/base_refinement.py b/torchref/refinement/base_refinement.py index 79c21ab..bfff288 100644 --- a/torchref/refinement/base_refinement.py +++ b/torchref/refinement/base_refinement.py @@ -7,7 +7,7 @@ import torch from torch.nn import Module as nnModule -from torchref.config import get_default_device +from torchref.config import normalize_device from torchref.io import ReflectionData from torchref.model.model_ft import ModelFT from torchref.refinement.logger import Logger @@ -1207,8 +1207,7 @@ def create_from_state_dict( print(f"Restored at R-work={rwork:.4f}, R-free={rfree:.4f}") """ - if device is None: - device = get_default_device() + device = normalize_device(device) # Helper to extract submodule state from flattened state_dict def extract_submodule_state(state_dict: dict, prefix: str) -> dict: diff --git a/torchref/restraints/hydrogen_topology.py b/torchref/restraints/hydrogen_topology.py index b4b8e94..9d0ad64 100644 --- a/torchref/restraints/hydrogen_topology.py +++ b/torchref/restraints/hydrogen_topology.py @@ -17,7 +17,8 @@ import torch from torch import nn -from torchref.config import dtypes, get_default_device +from torchref.config import dtypes, normalize_device +from torchref.utils.device_resolution import resolve_device from torchref.utils.device_mixin import DeviceMixin # --------------------------------------------------------------------------- @@ -83,9 +84,14 @@ class HydrogenTopology(DeviceMixin, nn.Module): ``build_h_candidate_pairs`` and checked via :attr:`has_candidates`. """ - def __init__(self): + def __init__(self, device=None): super().__init__() - # Buffers are registered by build_hydrogen_topology() + # Buffers are registered later by build_hydrogen_topology(), so this + # object is tensor-free at construction. The tracker still has to exist: + # ``DeviceMixin._refresh_device_trackers`` only maintains attributes + # already present in ``__dict__``, and callers reconcile against + # ``h_topo.device`` before attaching buffers to it. + self.device = normalize_device(device) @property def n_hydrogens(self) -> int: @@ -250,8 +256,7 @@ def build_hydrogen_topology( HydrogenTopology Module with registered buffer tensors. """ - if device is None: - device = get_default_device() + device = normalize_device(device) cache = _load_cif_hydrogen_info(pdb, verbose) model_names = pdb["name"].astype(str).str.strip().values @@ -366,7 +371,9 @@ def build_hydrogen_topology( acc_chainid_enc.append(chainid_enc) acc_resseq.append(resseq) - topo = HydrogenTopology() + # Seed the tracker with the device its buffers are about to be built on, + # so a later ``resolve_device(h_topo, ...)`` sees the truth. + topo = HydrogenTopology(device=device) n_h_total = len(acc_parent_idx) fdtype = dtypes.float @@ -719,8 +726,10 @@ def build_h_candidate_pairs( device : torch.device verbose : int """ - if device is None: - device = get_default_device() + # Buffers are registered onto ``h_topo`` below, so follow it rather than + # the global default -- otherwise they attach to a module living somewhere + # else. + device = resolve_device(h_topo, device=device) n_h = h_topo.n_hydrogens n_heavy = len(pdb) diff --git a/torchref/restraints/restraints.py b/torchref/restraints/restraints.py index ada6ef8..f3da1fa 100644 --- a/torchref/restraints/restraints.py +++ b/torchref/restraints/restraints.py @@ -29,7 +29,7 @@ read_cif, read_link_definitions, ) -from torchref.config import get_default_device, get_float_dtype +from torchref.config import get_float_dtype from torchref.utils.debug_utils import DebugMixin from torchref.utils.utils import TensorDict from torchref.utils.device_mixin import DeviceMixin diff --git a/torchref/scaling/collection_scaler.py b/torchref/scaling/collection_scaler.py index 2683ea4..7b8a82e 100644 --- a/torchref/scaling/collection_scaler.py +++ b/torchref/scaling/collection_scaler.py @@ -26,7 +26,7 @@ epsilon_from_hkl, ml_xray_loss_beta_math, ) -from torchref.config import get_default_device, get_float_dtype +from torchref.config import get_float_dtype from torchref.scaling.scaler_base import ScalerBase from torchref.scaling.solvent import SolventModel from torchref.utils.utils import ModuleReference @@ -80,10 +80,12 @@ def __init__( verbose: int = 1, device: torch.device = None, ): - if device is None: - device = get_default_device() # Bind to the dark/reference dataset for bins and scattering vectors dark_data = dataset_collection[model_collection.dark_key] + # Forward ``device`` through rather than resolving the global default + # here: a resolved default arrives at ``ScalerBase`` as an *explicit* + # device, which then drags ``dark_data`` onto it. Passing ``None`` + # lets ScalerBase derive from the data, which is the point. super().__init__( data=dark_data, nbins=nbins, diff --git a/torchref/scaling/scaler.py b/torchref/scaling/scaler.py index c691581..d29ff0f 100644 --- a/torchref/scaling/scaler.py +++ b/torchref/scaling/scaler.py @@ -19,7 +19,6 @@ import torch import torch.nn as nn -from torchref.config import get_default_device from torchref.io import ReflectionData from torchref.base.reciprocal import get_scattering_vectors from torchref.scaling.scaler_base import ScalerBase diff --git a/torchref/scaling/scaler_base.py b/torchref/scaling/scaler_base.py index f317483..b94f967 100644 --- a/torchref/scaling/scaler_base.py +++ b/torchref/scaling/scaler_base.py @@ -30,7 +30,7 @@ epsilon_from_hkl, ml_xray_loss_beta_math, ) -from torchref.config import get_complex_dtype, get_default_device, get_float_dtype +from torchref.config import get_complex_dtype, get_float_dtype from torchref.utils.autograd_ops import gather_with_index_add from torchref.utils.debug_utils import DebugMixin from torchref.utils.device_mixin import DeviceMixin diff --git a/torchref/scaling/solvent.py b/torchref/scaling/solvent.py index 131d036..90a6c65 100644 --- a/torchref/scaling/solvent.py +++ b/torchref/scaling/solvent.py @@ -11,9 +11,10 @@ ifft, ) from torchref.base.electron_density.main import _get_radius_offsets -from torchref.config import get_default_device, get_float_dtype +from torchref.config import get_float_dtype from torchref.utils.debug_utils import DebugMixin from torchref.utils.device_mixin import DeviceMixin +from torchref.utils.device_resolution import resolve_device from torchref.utils.utils import ModuleReference, TensorDict @@ -113,8 +114,11 @@ def __init__( super(SolventModel, self).__init__() if float_type is None: float_type = get_float_dtype() - if device is None: - device = get_default_device() + # Follow the model when no device is given. ``realspace.py`` constructs + # a SolventModel with only a model, so falling back to the global + # default here put the solvent grids on a different device than the + # structure they are computed from. + device = resolve_device(model, device=device) self.device = device self.verbose = verbose self.float_type = float_type diff --git a/torchref/symmetry/cell.py b/torchref/symmetry/cell.py index 18d42f0..88f5104 100644 --- a/torchref/symmetry/cell.py +++ b/torchref/symmetry/cell.py @@ -14,8 +14,8 @@ from torchref.config import ( NYQUIST_OVERSAMPLING, - get_default_device, get_float_dtype, + normalize_device, ) from torchref.utils.device_mixin import _NonModuleDeviceMixin @@ -76,8 +76,7 @@ def __init__( """ if dtype is None: dtype = get_float_dtype() - if device is None: - device = get_default_device() + device = normalize_device(device) # Convert to tensor first to get shape if isinstance(data, torch.Tensor): tensor = data.to(dtype=dtype, device=device) diff --git a/torchref/symmetry/map_symmetry.py b/torchref/symmetry/map_symmetry.py index ae5dc61..457641e 100644 --- a/torchref/symmetry/map_symmetry.py +++ b/torchref/symmetry/map_symmetry.py @@ -14,7 +14,7 @@ import torch import torch.nn as nn -from torchref.config import get_default_device, get_float_dtype +from torchref.config import get_float_dtype, normalize_device from torchref.symmetry.spacegroup import SpaceGroup, SpaceGroupLike from torchref.utils.device_mixin import DeviceMixin @@ -57,8 +57,12 @@ def MapSymmetry( """ if dtype_float is None: dtype_float = get_float_dtype() - if device is None: - device = get_default_device() + # ``cell_params`` is documented as a tensor, so follow it when no device is + # given rather than jumping to the global default and leaving the caller's + # cell behind. + if device is None and isinstance(cell_params, torch.Tensor): + device = cell_params.device + device = normalize_device(device) # Check grid compatibility symmetry = SpaceGroup(space_group, dtype=dtype_float, device=device) compat = symmetry.check_grid_compatibility(map_shape) @@ -117,14 +121,24 @@ def __init__( super().__init__() if dtype_float is None: dtype_float = get_float_dtype() - if device is None: - device = get_default_device() + if device is None and isinstance(cell_params, torch.Tensor): + device = cell_params.device self.dtype_float = dtype_float self.space_group = space_group self.map_shape = tuple(map_shape) - self.cell_params = cell_params self.verbose = verbose - self.device = device + self.device = normalize_device(device) + # Store ``cell_params`` *on* this module's device. It used to be kept + # exactly as handed in -- a plain attribute, so ``DeviceMixin`` could + # neither see it nor repair it, and ``self.device`` could disagree with + # it indefinitely. + if isinstance(cell_params, torch.Tensor): + cell_params = cell_params.to(device=self.device, dtype=self.dtype_float) + else: + cell_params = torch.as_tensor( + cell_params, device=self.device, dtype=self.dtype_float + ) + self.cell_params = cell_params self.symmetry = SpaceGroup( space_group, dtype=self.dtype_float, device=self.device diff --git a/torchref/symmetry/map_symmetry_interpolation.py b/torchref/symmetry/map_symmetry_interpolation.py index 3debb3a..b11213a 100644 --- a/torchref/symmetry/map_symmetry_interpolation.py +++ b/torchref/symmetry/map_symmetry_interpolation.py @@ -10,7 +10,7 @@ import torch.nn as nn import torch.nn.functional as F -from torchref.config import get_default_device, get_float_dtype +from torchref.config import get_float_dtype, normalize_device from torchref.symmetry.spacegroup import SpaceGroup from torchref.utils.device_mixin import DeviceMixin @@ -82,8 +82,7 @@ def __init__( super().__init__() if dtype_float is None: dtype_float = get_float_dtype() - if device is None: - device = get_default_device() + device = normalize_device(device) self.dtype_float = dtype_float self.space_group = space_group self.map_shape = tuple(map_shape) diff --git a/torchref/symmetry/reciprocal_symmetry.py b/torchref/symmetry/reciprocal_symmetry.py index ad7070b..9745ca0 100644 --- a/torchref/symmetry/reciprocal_symmetry.py +++ b/torchref/symmetry/reciprocal_symmetry.py @@ -30,7 +30,7 @@ import torch import torch.nn as nn -from torchref.config import get_default_device, get_float_dtype +from torchref.config import get_float_dtype, normalize_device from torchref.symmetry.spacegroup import SpaceGroup, SpaceGroupLike from torchref.utils.device_mixin import DeviceMixin @@ -133,8 +133,7 @@ def __init__( super().__init__() if dtype_float is None: dtype_float = get_float_dtype() - if device is None: - device = get_default_device() + device = normalize_device(device) self.dtype_float = dtype_float self.space_group = space_group self.grid_shape = tuple(grid_shape) diff --git a/torchref/symmetry/spacegroup.py b/torchref/symmetry/spacegroup.py index a622e71..dda5067 100644 --- a/torchref/symmetry/spacegroup.py +++ b/torchref/symmetry/spacegroup.py @@ -36,7 +36,7 @@ import torch import torch.nn as nn -from torchref.config import get_default_device, get_float_dtype +from torchref.config import get_float_dtype, normalize_device from torchref.utils.debug_utils import DebugMixin from torchref.utils.device_mixin import DeviceMovementMixin @@ -212,8 +212,7 @@ def get_operations_as_tensors( """ if dtype is None: dtype = get_float_dtype() - if device is None: - device = get_default_device() + device = normalize_device(device) sg = _normalize_spacegroup(spacegroup) # Extract rotation matrices and translations from gemmi operations @@ -692,8 +691,7 @@ def __init__( super(SpaceGroup, self).__init__() if dtype is None: dtype = get_float_dtype() - if device is None: - device = get_default_device() + device = normalize_device(device) self._device = device self._dtype = dtype diff --git a/torchref/utils/device_resolution.py b/torchref/utils/device_resolution.py index a027832..8029439 100644 --- a/torchref/utils/device_resolution.py +++ b/torchref/utils/device_resolution.py @@ -62,6 +62,17 @@ def resolve_device( torch.device The resolved device. + Raises + ------ + TypeError + If a bare ``torch.Tensor`` (or ``nn.Parameter``) is passed. Such an + object satisfies the ``.device`` / ``.to()`` precondition + *syntactically* but violates it semantically, because + ``Tensor.to()`` returns a new tensor rather than moving in place -- + so the move would be dropped without a word. Use + :func:`torchref.config.normalize_device` to read a device off a + tensor; use this function only to reconcile owning objects. + Examples -------- Empty call returns the configured default:: @@ -80,6 +91,17 @@ def resolve_device( >>> resolve_device(cuda_model, cpu_data) # doctest: +SKIP device(type='cuda') """ + for m in modules: + if isinstance(m, torch.Tensor): + raise TypeError( + "resolve_device() moves its inputs in place and returns only " + f"the resolved device, but a bare {type(m).__name__} was " + "passed. torch.Tensor.to() is out-of-place, so the move would " + "be silently discarded and the tensor left where it was. Pass " + "the object that owns the tensor, or move it yourself: " + "t = t.to(normalize_device(...))." + ) + if device is not None: resolved = _canonical(device) for m in modules: diff --git a/torchref/utils/utils.py b/torchref/utils/utils.py index 382e1cd..06a4f85 100644 --- a/torchref/utils/utils.py +++ b/torchref/utils/utils.py @@ -257,11 +257,12 @@ class TensorMasks(DeviceMovementMixin, dict): def __init__(self, data=None, device=None): super().__init__() - if device is None: - from torchref.config import get_default_device + from torchref.config import normalize_device - device = get_default_device() - self.device = torch.device(device) + # ``normalize_device`` rather than ``torch.device(...)``: the latter + # keeps an un-indexed spelling ("mps"), which compares unequal to the + # indexed device every real tensor reports. + self.device = normalize_device(device) self._cache = None self._updated = True From b9780a2edd096e0399f7f83f281b106f2fa2c430 Mon Sep 17 00:00:00 2001 From: HatPdotS Date: Tue, 28 Jul 2026 19:29:05 +0900 Subject: [PATCH 07/15] Add Engine.METAL, fix Metal test vacuity and two reindex/device gaps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Metal splat's test suite could pass while the kernel was dead code. _add_isotropic/_add_anisotropic swallowed any dispatch exception with `except Exception: pass`, so Engine.AUTO fell back to the same add_isotropic_plain_var that Engine.EAGER selects — every comparison then trivially agreed. The CUDA tests are safe from this only because Engine.TRITON re-raises; there was no equivalent for Metal. Engine.METAL + should_use_metal() - Fourth Engine member mirroring TRITON: forces the Metal path and raises rather than degrading (wrong device/dtype, uncompiled shader, or a dispatch failure). - should_use_metal() owns device, dtype and shader availability together. Folding availability in is what makes the force strict: as a nested `if` at the dispatch site, an unavailable shader slipped past the gate onto the portable splat even under a forced engine. - should_use_triton()'s implicit-AUTO tail is now explicit, with a ValueError fallthrough — that tail is why any new member silently selected Triton. - compile.last_error() is exported and consumed, so the raised message carries the compile diagnosis instead of being dead code. Kernel test coverage (both backends) - Cells, atom sets and metrics move to tests/helpers/kernel_cases.py; the two files had byte-identical private copies, which is how two gaps survived in both at once: unit occupancies hid the grad/occ rescaling, and zero off-diagonal U left every ellipsoid axis-aligned, so the cross-terms and the 4*pi^2 off-diagonal U gradients were never run. - Gradients now check magnitude, not just direction: assert_grads_agree moves to tests/helpers/grad_asserts.py. A bare cosine passes a kernel that returns 2*grad. - Dispatch provenance uses a call recorder, not map comparison: the portable splat's scatter_add is not order-reproducible on MPS (~1e-7 run to run), so torch.equal against it proves nothing either way. - Aniso now also runs on the sheared cell; only iso did before. - MPS forward tolerances re-measured (iso 5.9e-3, aniso 1.0e-3) and set from the measurements. CUDA tolerances left alone — retightening needs a CUDA host to measure on. Backend gating is the marker's job alone - --run-cuda/--run-mps warn and let the marked tests run, so they error with the real backend error. A collection-time UsageError aborted the whole session and said nothing about which call failed. - Removed 13 redundant availability checks (in-body skips and skipif decorators) sitting inside already-marked tests; a second layer can only mask a forgotten marker, turning "this host cannot run it" into a silent pass. Added cuda_device/mps_device fixtures that do no checking. - Promoted two tests mismarked @pytest.mark.gpu while hardcoding CUDA. ReflectionData: exempt non-per-reflection fields by name _reindex_per_reflection and __select__ decided "is this per-reflection?" by shape[0] == n_hkl. That collides when the dataset has exactly as many reflections as the field is long — U_aniso is (6,), so a 6-reflection dataset had it gathered and reordered as per-reflection data. _assert_per_reflection_consistent and reduce_to_spacegroup already exempted by name; all four are consistent now. epsilon_from_hkl: answer on hkl's device apply_to_hkl moves its input onto the symmetry matrices' device, so a spacegroup built elsewhere than the reflection data made the Hs == h0 comparison raise. Build h on the symmetry device, return on hkl's. --- tests/README.md | 17 +- tests/conftest.py | 65 ++- tests/helpers/grad_asserts.py | 69 ++++ tests/helpers/kernel_cases.py | 127 ++++++ tests/integration/test_cli_refinement.py | 4 +- tests/integration/test_ds_triton_vs_eager.py | 7 +- tests/integration/test_gpu_scale_stability.py | 4 +- tests/integration/test_sfds_device.py | 2 - .../test_triton_vs_eager_targets.py | 12 - tests/integration/test_variable_radius_gpu.py | 310 +++++++-------- tests/integration/test_variable_radius_mps.py | 375 ++++++++++-------- tests/unit/io/test_reflection_data_reindex.py | 31 ++ .../test_estimate_beta_determinism.py | 2 +- tests/unit/refinement/test_ml_sigmaa.py | 39 ++ tests/unit/test_gradient_correctness.py | 56 +-- tests/unit/utils/test_triton_dispatch.py | 77 ++++ .../electron_density/kernels/mps/__init__.py | 8 +- .../electron_density/kernels/mps/compile.py | 8 +- torchref/base/electron_density/main.py | 103 ++--- torchref/base/targets/xray_ml_sigmaa.py | 14 +- torchref/io/datasets/reflection_data.py | 20 +- torchref/utils/__init__.py | 2 + torchref/utils/triton_dispatch.py | 124 +++++- 23 files changed, 1003 insertions(+), 473 deletions(-) create mode 100644 tests/helpers/grad_asserts.py create mode 100644 tests/helpers/kernel_cases.py diff --git a/tests/README.md b/tests/README.md index 7b66ea9..fd28310 100644 --- a/tests/README.md +++ b/tests/README.md @@ -92,7 +92,10 @@ Tests are marked with the following pytest markers: - `@pytest.mark.unit` - Fast unit tests, no I/O - `@pytest.mark.integration` - Integration tests with file I/O -- `@pytest.mark.gpu` - Tests requiring GPU (not run by default) +- `@pytest.mark.gpu` - Needs any accelerator (CUDA *or* MPS); runs automatically + wherever one is present, skipped otherwise +- `@pytest.mark.cuda` - Needs CUDA specifically (e.g. Triton kernels) +- `@pytest.mark.mps` - Needs MPS specifically (Metal kernels) - `@pytest.mark.slow` - Slow tests (>30 seconds) ### Running by marker @@ -146,8 +149,16 @@ See `.github/workflows/tests.yml` for configuration. - Can use real files from `tests/files/` - Mark with `@pytest.mark.integration` -3. **GPU tests**: Add `@pytest.mark.gpu` marker - - Will be skipped if no GPU available +3. **Accelerator tests**: pick the marker that matches what the test actually + needs -- `@pytest.mark.cuda` if it hardcodes CUDA, `@pytest.mark.mps` if it + hardcodes MPS, `@pytest.mark.gpu` only if it works on either (take the device + from the `cuda_device` / `mps_device` / `gpu_device` fixture accordingly). + - The marker is the *only* gate. Do not also check availability inside the + test: a second `pytest.skip` can only mask a forgotten or wrong marker, + turning "this host cannot run it" into a silent pass. + - Missing backend -> skipped with a reason naming it. `--run-cuda` / + `--run-mps` instead warn and let the tests run, so they error with the real + backend error -- for CI that must not go green on a runner that lost its GPU. ## Test Data diff --git a/tests/conftest.py b/tests/conftest.py index d5afc48..e12270d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -100,18 +100,42 @@ def pytest_collection_modifyitems(config, items): skipped with a reason naming the missing backend. ``--run-cuda`` / ``--run-mps`` invert the *absence* case from skip to - failure, for CI that expects a specific backend and would otherwise go - green on a runner that quietly lost its GPU. + error, for CI that expects a specific backend and would otherwise go green + on a runner that quietly lost its GPU. They do it by *not* adding the skip + marker, so the backend tests run and fail with the real error from torch. + + This function is the **only** place that decides what runs. Tests must not + re-check availability themselves: a second layer of ``pytest.skip`` can only + mask a forgotten marker, and turns "this host cannot run it" into a silent + pass instead of the visible skip or the real error. """ has_cuda = _cuda_available() has_mps = _mps_available() + # Forced-but-absent is a warning, not a ``pytest.UsageError``. A UsageError + # aborts the entire session -- every unrelated test with it -- and says + # nothing about which backend call actually broke. Warning and letting the + # marked tests run gives a precise per-test failure and still runs the rest + # of the suite. UserWarning, not DeprecationWarning: pytest.ini filters the + # latter (see the --run-gpu note in pytest_configure). require_cuda = config.getoption("--run-cuda") require_mps = config.getoption("--run-mps") if require_cuda and not has_cuda: - raise pytest.UsageError("--run-cuda given but no CUDA device is available") + warnings.warn( + "--run-cuda given but no CUDA device is available: running the " + "cuda-marked tests anyway so they error with the real backend " + "error instead of being skipped.", + UserWarning, + stacklevel=2, + ) if require_mps and not has_mps: - raise pytest.UsageError("--run-mps given but MPS is not available") + warnings.warn( + "--run-mps given but MPS is not available: running the mps-marked " + "tests anyway so they error with the real backend error instead of " + "being skipped.", + UserWarning, + stacklevel=2, + ) skip_slow = pytest.mark.skip(reason="Need --run-slow option to run") skip_openmm = pytest.mark.skip(reason="OpenMM not installed (pip install '.[amber]')") @@ -127,9 +151,9 @@ def pytest_collection_modifyitems(config, items): # ``cuda_only`` is the retired spelling of ``cuda``. wants_cuda = "cuda" in keywords or "cuda_only" in keywords wants_mps = "mps" in keywords - if wants_cuda and not has_cuda: + if wants_cuda and not has_cuda and not require_cuda: item.add_marker(skip_cuda) - if wants_mps and not has_mps: + if wants_mps and not has_mps and not require_mps: item.add_marker(skip_mps) # A bare ``gpu`` mark means "any accelerator"; a test that also names a # specific backend has already been gated on the stricter condition. @@ -218,7 +242,12 @@ def cpu_device() -> torch.device: def gpu_device() -> torch.device: """GPU torch device (only use with @pytest.mark.gpu). - Prefers CUDA, falls back to MPS; skips if neither is available. + Prefers CUDA, falls back to MPS; skips if neither is available. Prefer the + backend-specific ``cuda_device`` / ``mps_device`` below when a test needs one + particular backend -- this fixture's preference order means a + ``cuda``-marked test asking for it on a dual-backend host could be handed + MPS, which is why the MPS tests used to carry a ``type != 'mps'`` skip to + undo it. """ accel = _accelerator() if accel is None: @@ -226,6 +255,28 @@ def gpu_device() -> torch.device: return accel +@pytest.fixture(scope="session") +def cuda_device() -> torch.device: + """Canonical CUDA device for ``cuda``-marked tests. + + Deliberately unguarded. What runs is decided by the ``cuda`` marker in + :func:`pytest_collection_modifyitems` and nowhere else, so this fixture does + not re-check availability: on a host without CUDA the test is *meant* to + error with the real backend error rather than be quietly skipped here. + """ + return torch.device("cuda", 0) + + +@pytest.fixture(scope="session") +def mps_device() -> torch.device: + """Canonical MPS device for ``mps``-marked tests. + + Unguarded for the same reason as :func:`cuda_device` -- the ``mps`` marker + owns the decision. + """ + return torch.device("mps", 0) + + def _accelerator() -> "torch.device | None": """The canonical accelerator this host can actually use, or ``None``. diff --git a/tests/helpers/grad_asserts.py b/tests/helpers/grad_asserts.py new file mode 100644 index 0000000..3f2f2e5 --- /dev/null +++ b/tests/helpers/grad_asserts.py @@ -0,0 +1,69 @@ +"""Gradient-agreement assertions shared across kernel comparison tests. + +Comparing two kernels' gradients needs **two** metrics, not one. Cosine +similarity alone passes a systematic scale error -- a kernel that returns +``2 * grad`` is perfectly parallel to the reference -- so every assertion here +checks direction *and* magnitude. + +Extracted from ``tests/unit/test_gradient_correctness.py`` so the accelerator +kernel tests (``test_variable_radius_gpu.py`` / ``test_variable_radius_mps.py``) +can reuse them instead of hand-rolling a bare cosine check. +""" + +from __future__ import annotations + +import torch + +__all__ = [ + "cosine_similarity", + "gradnorm_ratio", + "assert_grads_agree", + "got_ref_pairs", +] + + +def _flat_real(t: torch.Tensor) -> torch.Tensor: + """Flatten to a real 1-D CPU float64 vector, splitting complex into (re, im). + + The CPU hop is required, not cosmetic: these helpers accumulate in float64 + and MPS has no float64, so comparing accelerator gradients in place would + fail in the backend instead of in the assertion. + """ + t = t.detach().cpu() + if t.is_complex(): + return torch.view_as_real(t).reshape(-1).double() + return t.reshape(-1).double() + + +def cosine_similarity(a: torch.Tensor, b: torch.Tensor) -> float: + """Cosine similarity of two gradients (direction agreement).""" + return torch.nn.functional.cosine_similarity( + _flat_real(a), _flat_real(b), dim=0 + ).item() + + +def gradnorm_ratio(a: torch.Tensor, b: torch.Tensor) -> float: + """Ratio of gradient norms ``||a|| / ||b||`` (magnitude agreement).""" + nb = _flat_real(b).norm() + return (_flat_real(a).norm() / nb).item() + + +def got_ref_pairs(got, ref): + """Yield ``(name, got_grad, ref_grad)`` triples for a dict or sequence.""" + if isinstance(got, dict): + for k in got: + yield str(k), got[k], ref[k] + else: + for i, (g, r) in enumerate(zip(got, ref)): + yield str(i), g, r + + +def assert_grads_agree(got, ref, *, min_cos=0.9999, ratio_tol=1e-3, ctx=""): + """Assert per-leaf cosine ≈ 1 and gradnorm ratio ≈ 1 for matched grads.""" + for name, g, r in got_ref_pairs(got, ref): + cos = cosine_similarity(g, r) + ratio = gradnorm_ratio(g, r) + assert cos >= min_cos, f"{ctx}{name}: cosine {cos:.8f} < {min_cos}" + assert ( + abs(ratio - 1.0) <= ratio_tol + ), f"{ctx}{name}: gradnorm ratio {ratio:.8f} off 1 by > {ratio_tol}" diff --git a/tests/helpers/kernel_cases.py b/tests/helpers/kernel_cases.py new file mode 100644 index 0000000..e4c9678 --- /dev/null +++ b/tests/helpers/kernel_cases.py @@ -0,0 +1,127 @@ +"""Shared cells, atom sets and map metrics for the accelerator splat tests. + +``test_variable_radius_gpu.py`` (Triton) and ``test_variable_radius_mps.py`` +(Metal) compare an accelerator density splat against the portable reference. +They had byte-identical private copies of everything below, which is how the two +degeneracies fixed here survived in both at once: + +* every atom had ``occ == 1``, so the kernels' ``grad_occ = grad_sum / occ`` + scaling was only ever exercised as a division by one; +* anisotropic ``u`` had **zero off-diagonals**, i.e. every ellipsoid was + axis-aligned. That left the cross-term arithmetic completely uncovered -- + the ``p01``/``p02``/``p12`` entries of the inverted 3x3, and the backward's + off-diagonal U gradients, which carry a ``4*pi^2`` factor where the diagonal + ones carry ``2*pi^2``. + +Both are now non-degenerate, following the shapes already used by +``_sf_leaves`` / ``_rand_u6`` in ``tests/unit/test_gradient_correctness.py``. +""" + +from __future__ import annotations + +import math + +import torch + +__all__ = [ + "cell_orthorhombic", + "cell_monoclinic", + "iso_atoms", + "aniso_atoms", + "cos_sim", + "rel_map", + "to_device", +] + + +def cell_orthorhombic(): + """Orthorhombic P1 cell + grid. Returns ``(abc, grid, frac, inv_frac, voxel, rsg)``.""" + a, b, c = 30.0, 25.0, 20.0 + nx, ny, nz = 60, 50, 40 + frac = torch.diag(torch.tensor([a, b, c])) + inv_frac = torch.diag(torch.tensor([1 / a, 1 / b, 1 / c])) + voxel = torch.tensor([a / nx, b / ny, c / nz]) + ii, jj, kk = torch.meshgrid( + torch.arange(nx), torch.arange(ny), torch.arange(nz), indexing="ij" + ) + rsg = (torch.stack([ii / nx, jj / ny, kk / nz], -1).to(torch.float32)) @ frac.T + return (a, b, c), (nx, ny, nz), frac.float(), inv_frac.float(), voxel.float(), rsg + + +def cell_monoclinic(): + """Non-orthogonal (beta ~ 100 deg) cell: exercises the per-axis bounding box + and the off-diagonal coordinate math (the c voxel step gains an x-component).""" + a, b, c = 30.0, 25.0, 20.0 + nx, ny, nz = 60, 50, 40 + beta = math.radians(100.0) + frac = torch.tensor( + [[a, 0.0, c * math.cos(beta)], + [0.0, b, 0.0], + [0.0, 0.0, c * math.sin(beta)]], dtype=torch.float64) + inv_frac = torch.linalg.inv(frac) + voxel = (frac.norm(dim=0) / torch.tensor([nx, ny, nz], dtype=torch.float64)).float() + ii, jj, kk = torch.meshgrid( + torch.arange(nx), torch.arange(ny), torch.arange(nz), indexing="ij" + ) + fc = torch.stack([ii / nx, jj / ny, kk / nz], -1).double() + rsg = (fc @ frac.T).float() + return (a, b, c), (nx, ny, nz), frac.float(), inv_frac.float(), voxel, rsg + + +def iso_atoms(cell, n=60, seed=0): + """Isotropic atoms. Returns ``(xyz, adp, occ, A, B)``, all float32. + + Occupancies are in ``[0.6, 1.0)``, never exactly 1: the kernels divide the + accumulated gradient by ``occ`` to recover ``d/d occ``, and at ``occ == 1`` + that division is a no-op that hides a wrong scaling. + """ + g = torch.Generator().manual_seed(seed) + a, b, c = cell + xyz = torch.rand(n, 3, generator=g) * torch.tensor([a, b, c]) + adp = torch.rand(n, generator=g) * 35 + 3 + occ = torch.rand(n, generator=g) * 0.4 + 0.6 + A = torch.rand(n, 5, generator=g) * 5 + B = torch.rand(n, 5, generator=g) * 20 + 2 + return [t.float() for t in (xyz, adp, occ, A, B)] + + +def aniso_atoms(cell, n=30, seed=1): + """Anisotropic atoms. Returns ``(xyz, u, occ, A, B)``, all float32. + + ``u`` is ``[U11, U22, U33, U12, U13, U23]`` with a positive diagonal and + **signed, non-zero off-diagonals** (magnitudes chosen to keep every U + comfortably positive-definite, as the shader inverts ``M_g`` analytically + without a positive-definiteness guard). Occupancies are non-unit, as above. + """ + g = torch.Generator().manual_seed(seed) + a, b, c = cell + xyz = torch.rand(n, 3, generator=g) * torch.tensor([a, b, c]) + u = torch.zeros(n, 6) + u[:, :3] = torch.rand(n, 3, generator=g) * 0.12 + 0.02 + u[:, 3:] = (torch.rand(n, 3, generator=g) - 0.5) * 0.02 + occ = torch.rand(n, generator=g) * 0.4 + 0.6 + A = torch.rand(n, 5, generator=g) * 5 + B = torch.rand(n, 5, generator=g) * 20 + 2 + return [t.float() for t in (xyz, u, occ, A, B)] + + +def cos_sim(a, b): + """Cosine similarity of two maps, flattened, in float64. + + Moves to CPU first: the accumulation wants float64, and MPS has no float64, + so a caller who forgot ``.cpu()`` would otherwise get a confusing dtype + error from the backend rather than a comparison. + """ + a, b = a.detach().cpu().reshape(-1).double(), b.detach().cpu().reshape(-1).double() + return float((a @ b) / (a.norm() * b.norm() + 1e-12)) + + +def rel_map(x, y): + """Max absolute map difference, relative to the reference's peak.""" + x, y = x.detach().cpu(), y.detach().cpu() + return float((x - y).abs().max() / (y.abs().max() + 1e-8)) + + +def to_device(dev, *ts): + """Move every tensor in ``ts`` to ``dev``.""" + return [t.to(dev) for t in ts] diff --git a/tests/integration/test_cli_refinement.py b/tests/integration/test_cli_refinement.py index d7f3278..c332587 100644 --- a/tests/integration/test_cli_refinement.py +++ b/tests/integration/test_cli_refinement.py @@ -72,7 +72,7 @@ def test_cli_refine_missing_inputs(self, cli_script, tmp_path): @pytest.mark.integration @pytest.mark.slow - @pytest.mark.gpu + @pytest.mark.cuda def test_cli_refine_cuda_end_to_end(self, cli_script, h_structure_pair, tmp_path): """Run the refine CLI on CUDA end-to-end on an H-bearing structure. @@ -83,8 +83,6 @@ def test_cli_refine_cuda_end_to_end(self, cli_script, h_structure_pair, tmp_path previously crashed when VDW buffers were left on CPU after a mid-refinement rebuild (PR #19). """ - if not torch.cuda.is_available(): - pytest.skip("CUDA not available") outdir = tmp_path / "refine_cuda" diff --git a/tests/integration/test_ds_triton_vs_eager.py b/tests/integration/test_ds_triton_vs_eager.py index 373f2d4..96a7c47 100644 --- a/tests/integration/test_ds_triton_vs_eager.py +++ b/tests/integration/test_ds_triton_vs_eager.py @@ -25,9 +25,10 @@ def _rel(a, b): @pytest.fixture def cuda(): - if not torch.cuda.is_available(): - pytest.skip("CUDA required for the Triton DS kernels") - return torch.device("cuda") + # No availability check: the module-level ``cuda`` marker is the only gate + # (see conftest.pytest_collection_modifyitems). A second check here could + # only turn a forgotten marker into a silent pass. + return torch.device("cuda", 0) def _asym_loss(F, wr, wi): diff --git a/tests/integration/test_gpu_scale_stability.py b/tests/integration/test_gpu_scale_stability.py index 0cd5045..a1f553a 100644 --- a/tests/integration/test_gpu_scale_stability.py +++ b/tests/integration/test_gpu_scale_stability.py @@ -14,11 +14,9 @@ @pytest.mark.integration -@pytest.mark.gpu +@pytest.mark.cuda @pytest.mark.slow def test_gpu_5cycle_refinement_does_not_collapse_and_matches_cpu(pdb_dir, mtz_dir): - if not torch.cuda.is_available(): - pytest.skip("CUDA required") pdb = pdb_dir / "1DAW.pdb" mtz = mtz_dir / "1DAW.mtz" if not pdb.exists() or not mtz.exists(): diff --git a/tests/integration/test_sfds_device.py b/tests/integration/test_sfds_device.py index 8599e79..7a6ac70 100644 --- a/tests/integration/test_sfds_device.py +++ b/tests/integration/test_sfds_device.py @@ -42,8 +42,6 @@ def test_sfds_same_device_cpu(): @pytest.mark.integration def test_sfds_hkl_on_different_device(): """hkl on CPU while the module + atoms are on CUDA must not crash.""" - if not torch.cuda.is_available(): - pytest.skip("CUDA required") cuda = torch.device("cuda") cell = Cell(_CELL, device=cuda) sf = SfDS(cell, spacegroup="P212121").to(cuda) diff --git a/tests/integration/test_triton_vs_eager_targets.py b/tests/integration/test_triton_vs_eager_targets.py index 0b795d8..a302be5 100644 --- a/tests/integration/test_triton_vs_eager_targets.py +++ b/tests/integration/test_triton_vs_eager_targets.py @@ -150,8 +150,6 @@ def _1daw_pair(pdb_dir, mtz_dir): @pytest.fixture(scope="module") def gpu_refinement(_1daw_pair): """Build a CUDA refinement once for the whole module.""" - if not torch.cuda.is_available(): - pytest.skip("CUDA not available") from torchref import LBFGSRefinement device = torch.device("cuda") @@ -227,8 +225,6 @@ def test_triton_matches_eager_xray_modes(target_mode, _1daw_pair): a constructor argument. (The default 'ml' σ_A target is eager-only, so it has no Triton path to compare.) """ - if not torch.cuda.is_available(): - pytest.skip("CUDA not available") from torchref import LBFGSRefinement from torchref.utils import Engine, use_engine @@ -267,8 +263,6 @@ def test_planarity_triton_per_atom_sigma(n_atoms): non-uniform per-atom sigmas the old kernel diverged badly (>50% loss error). This pins eager == Triton for non-uniform sigmas. """ - if not torch.cuda.is_available(): - pytest.skip("CUDA not available") from torchref.base.targets.planarity import ( _planarity_math_eager, planarity_math, @@ -308,8 +302,6 @@ def test_geometry_degenerate_finite_grads(): singularities). Both backends are now floored with EPS so the gradient is finite (CPU == GPU behavior), avoiding NaN-poisoned refinement steps. """ - if not torch.cuda.is_available(): - pytest.skip("CUDA not available") from torchref.base.targets.angle import angle_math from torchref.base.targets.bond import bond_math from torchref.base.targets.torsion import torsion_omega_math @@ -414,8 +406,6 @@ def test_eager_gpu_hessian_iso(tmp_path): torch (scatter_add) path, which composes under autograd. A Hessian-vector product through ``ModelFT.forward`` must then match finite differences. """ - if not torch.cuda.is_available(): - pytest.skip("CUDA not available") import itertools from torchref.config import device, dtypes @@ -454,8 +444,6 @@ def test_eager_gpu_hessian_iso(tmp_path): @pytest.mark.integration def test_eager_gpu_hessian_aniso(pdb_dir): """Same as above but for anisotropic ADPs (real ANISOU structure).""" - if not torch.cuda.is_available(): - pytest.skip("CUDA not available") import itertools from torchref.config import dtypes diff --git a/tests/integration/test_variable_radius_gpu.py b/tests/integration/test_variable_radius_gpu.py index 5f3ca85..427beab 100644 --- a/tests/integration/test_variable_radius_gpu.py +++ b/tests/integration/test_variable_radius_gpu.py @@ -3,194 +3,174 @@ Both backends truncate each atom at its own ``N_sigma * sigma_eff`` radius, so the forward maps must agree to float32 + analytic-vs-gathered-coord tolerance and the -gradients (xyz / adp / u / occ) must be parallel. Requires CUDA + Triton. +gradients (xyz / adp / u / occ) must agree in direction *and* magnitude. -Markers: ``@pytest.mark.cuda`` (auto-skipped without CUDA), ``integration``. -""" +The candidate side runs under ``Engine.TRITON``, which raises rather than +degrading to the portable splat -- that is what keeps this file able to fail. + +Cells, atom sets and metrics come from ``tests/helpers/kernel_cases.py``, shared +with the MPS/Metal equivalent. They used to be a private copy here, which is how +two coverage gaps survived in both files at once: unit occupancies (hiding the +``grad / occ`` rescaling) and zero off-diagonal ``u`` (hiding every ellipsoid +cross-term). Both are non-degenerate now. -import math +Markers: ``@pytest.mark.cuda`` -- the sole gate, see ``conftest.py``'s +``pytest_collection_modifyitems``; this file deliberately does no availability +checking of its own. Also ``integration``. +""" import pytest import torch +from tests.helpers.grad_asserts import assert_grads_agree +from tests.helpers.kernel_cases import ( + aniso_atoms, + cell_monoclinic, + cell_orthorhombic, + cos_sim, + iso_atoms, + rel_map, + to_device, +) from torchref.base.electron_density.main import build_electron_density from torchref.utils import Engine, use_engine pytestmark = [pytest.mark.cuda, pytest.mark.integration] - -def _cell(): - a, b, c = 30.0, 25.0, 20.0 - nx, ny, nz = 60, 50, 40 - frac = torch.diag(torch.tensor([a, b, c])) - inv_frac = torch.diag(torch.tensor([1 / a, 1 / b, 1 / c])) - voxel = torch.tensor([a / nx, b / ny, c / nz]) - ii, jj, kk = torch.meshgrid( - torch.arange(nx), torch.arange(ny), torch.arange(nz), indexing="ij" - ) - rsg = (torch.stack([ii / nx, jj / ny, kk / nz], -1).to(torch.float32)) @ frac.T - return (a, b, c), (nx, ny, nz), frac.float(), inv_frac.float(), voxel.float(), rsg - - -def _cell_monoclinic(): - """Non-orthogonal (beta ~ 100 deg) cell to exercise the inv-frac-norm per-axis - box and the off-diagonal coordinate math (u_c gains an x-component).""" - a, b, c = 30.0, 25.0, 20.0 - nx, ny, nz = 60, 50, 40 - beta = math.radians(100.0) - frac = torch.tensor( - [[a, 0.0, c * math.cos(beta)], - [0.0, b, 0.0], - [0.0, 0.0, c * math.sin(beta)]], dtype=torch.float64) - inv_frac = torch.linalg.inv(frac) - voxel = (frac.norm(dim=0) / torch.tensor([nx, ny, nz], dtype=torch.float64)).float() - ii, jj, kk = torch.meshgrid( - torch.arange(nx), torch.arange(ny), torch.arange(nz), indexing="ij" +# Left at the values this file has always used. Unlike the MPS tolerances, these +# have not been re-measured against the new non-degenerate atom sets -- that +# needs a CUDA host. Tighten only with measurements in hand. +_REL_TOL = 2e-2 +_COS_TOL = 0.9995 +_GRAD_KW = dict(min_cos=0.999, ratio_tol=1e-2) + + +def _empty_iso(device): + """Zero-length isotropic arguments, for an aniso-only structure.""" + return ( + torch.zeros(0, 3, device=device), + torch.zeros(0, device=device), + torch.zeros(0, device=device), + torch.zeros(0, 5, device=device), + torch.zeros(0, 5, device=device), ) - fc = torch.stack([ii / nx, jj / ny, kk / nz], -1).double() - rsg = (fc @ frac.T).float() - return (a, b, c), (nx, ny, nz), frac.float(), inv_frac.float(), voxel, rsg - - -def _iso_atoms(cell, n=60, seed=0): - g = torch.Generator().manual_seed(seed) - a, b, c = cell - xyz = torch.rand(n, 3, generator=g) * torch.tensor([a, b, c]) - adp = torch.rand(n, generator=g) * 35 + 3 - occ = torch.ones(n) - A = torch.rand(n, 5, generator=g) * 5 - B = torch.rand(n, 5, generator=g) * 20 + 2 - return [t.float() for t in (xyz, adp, occ, A, B)] - - -def _aniso_atoms(cell, n=30, seed=1): - g = torch.Generator().manual_seed(seed) - a, b, c = cell - xyz = torch.rand(n, 3, generator=g) * torch.tensor([a, b, c]) - u = torch.zeros(n, 6) - u[:, :3] = torch.rand(n, 3, generator=g) * 0.12 + 0.02 - occ = torch.ones(n) - A = torch.rand(n, 5, generator=g) * 5 - B = torch.rand(n, 5, generator=g) * 20 + 2 - return [t.float() for t in (xyz, u, occ, A, B)] -def _cos(a, b): - a, b = a.reshape(-1).double(), b.reshape(-1).double() - return float((a @ b) / (a.norm() * b.norm() + 1e-12)) - - -def _rel_map(x, y): - return float((x - y).abs().max() / (y.abs().max() + 1e-8)) - +def _run_iso(engine, device, cell_fn, atoms): + _, _, frac, inv_frac, voxel, rsg = cell_fn() + xyz, adp, occ, A, B = to_device(device, *atoms) + rsg, frac, inv_frac, voxel = to_device(device, rsg, frac, inv_frac, voxel) + with use_engine(engine): + return build_electron_density( + rsg, xyz, adp, occ, A, B, inv_frac, frac, voxel, dtype=torch.float32 + ) -def test_iso_triton_matches_cpu_grouped(gpu_device): - cell, grid, frac, inv_frac, voxel, rsg = _cell() - xyz, adp, occ, A, B = _iso_atoms(cell) - with use_engine(Engine.AUTO): - cpu = build_electron_density( - rsg, xyz, adp, occ, A, B, inv_frac, frac, voxel - ) - with use_engine(Engine.TRITON): - gpu = build_electron_density( - rsg.to(gpu_device), xyz.to(gpu_device), adp.to(gpu_device), occ.to(gpu_device), - A.to(gpu_device), B.to(gpu_device), inv_frac.to(gpu_device), frac.to(gpu_device), voxel.to(gpu_device), +def _run_aniso(engine, device, cell_fn, atoms): + _, _, frac, inv_frac, voxel, rsg = cell_fn() + xyz, u, occ, A, B = to_device(device, *atoms) + rsg, frac, inv_frac, voxel = to_device(device, rsg, frac, inv_frac, voxel) + xi, ai, oi, Ai, Bi = _empty_iso(device) + with use_engine(engine): + return build_electron_density( + rsg, xi, ai, oi, Ai, Bi, inv_frac, frac, voxel, + xyz_aniso=xyz, u_aniso=u, occ_aniso=occ, A_aniso=A, B_aniso=B, + dtype=torch.float32, ) - assert _rel_map(gpu.cpu(), cpu) < 2e-2 - assert _cos(gpu.cpu(), cpu) > 0.9995 -def test_aniso_triton_matches_cpu_grouped(gpu_device): - cell, grid, frac, inv_frac, voxel, rsg = _cell() - xa, ua, oa, Aa, Ba = _aniso_atoms(cell) - empty = torch.zeros(0, 3) +# --------------------------------------------------------------------------- +# Forward +# --------------------------------------------------------------------------- - with use_engine(Engine.AUTO): - cpu = build_electron_density( - rsg, empty, torch.zeros(0), torch.zeros(0), torch.zeros(0, 5), - torch.zeros(0, 5), inv_frac, frac, voxel, - xyz_aniso=xa, u_aniso=ua, occ_aniso=oa, A_aniso=Aa, B_aniso=Ba, - ) - with use_engine(Engine.TRITON): - gpu = build_electron_density( - rsg.to(gpu_device), empty.to(gpu_device), torch.zeros(0, device=gpu_device), - torch.zeros(0, device=gpu_device), torch.zeros(0, 5, device=gpu_device), - torch.zeros(0, 5, device=gpu_device), inv_frac.to(gpu_device), frac.to(gpu_device), - voxel.to(gpu_device), - xyz_aniso=xa.to(gpu_device), u_aniso=ua.to(gpu_device), occ_aniso=oa.to(gpu_device), - A_aniso=Aa.to(gpu_device), B_aniso=Ba.to(gpu_device), - ) - assert _rel_map(gpu.cpu(), cpu) < 2e-2 - assert _cos(gpu.cpu(), cpu) > 0.9995 +def test_iso_triton_matches_cpu_grouped(cuda_device): + cell_fn = cell_orthorhombic + atoms = iso_atoms(cell_fn()[0]) + cpu = _run_iso(Engine.AUTO, "cpu", cell_fn, atoms) + gpu = _run_iso(Engine.TRITON, cuda_device, cell_fn, atoms) + assert rel_map(gpu, cpu) < _REL_TOL + assert cos_sim(gpu, cpu) > _COS_TOL -def test_iso_triton_matches_eager_monoclinic(gpu_device): + +def test_iso_triton_matches_eager_monoclinic(cuda_device): """Guard the per-axis (inv-frac-norm) box + direct coords on a sheared cell: TRITON (CUDA) vs the portable EAGER plain-scatter reference (independent box).""" - cell, grid, frac, inv_frac, voxel, rsg = _cell_monoclinic() - xyz, adp, occ, A, B = _iso_atoms(cell) - - with use_engine(Engine.EAGER): - ref = build_electron_density( - rsg, xyz, adp, occ, A, B, inv_frac, frac, voxel + cell_fn = cell_monoclinic + atoms = iso_atoms(cell_fn()[0]) + ref = _run_iso(Engine.EAGER, "cpu", cell_fn, atoms) + gpu = _run_iso(Engine.TRITON, cuda_device, cell_fn, atoms) + assert rel_map(gpu, ref) < _REL_TOL + assert cos_sim(gpu, ref) > _COS_TOL + + +@pytest.mark.parametrize( + "cell_fn", [cell_orthorhombic, cell_monoclinic], ids=["orthorhombic", "monoclinic"] +) +def test_aniso_triton_matches_cpu_grouped(cuda_device, cell_fn): + """Both cells, so the ellipsoid cross-terms are exercised on a sheared cell + too -- previously only the isotropic path saw a non-orthogonal cell.""" + atoms = aniso_atoms(cell_fn()[0]) + cpu = _run_aniso(Engine.AUTO, "cpu", cell_fn, atoms) + gpu = _run_aniso(Engine.TRITON, cuda_device, cell_fn, atoms) + assert rel_map(gpu, cpu) < _REL_TOL + assert cos_sim(gpu, cpu) > _COS_TOL + + +# --------------------------------------------------------------------------- +# Backward +# --------------------------------------------------------------------------- + + +def _iso_grads(engine, device, cell_fn, atoms, weights): + xyz, adp, occ, A, B = to_device(device, *atoms) + xyz, adp, occ = (t.clone().requires_grad_() for t in (xyz, adp, occ)) + _, _, frac, inv_frac, voxel, rsg = cell_fn() + rsg, frac, inv_frac, voxel = to_device(device, rsg, frac, inv_frac, voxel) + with use_engine(engine): + dm = build_electron_density( + rsg, xyz, adp, occ, A, B, inv_frac, frac, voxel, dtype=torch.float32 ) - with use_engine(Engine.TRITON): - gpu = build_electron_density( - rsg.to(gpu_device), xyz.to(gpu_device), adp.to(gpu_device), occ.to(gpu_device), - A.to(gpu_device), B.to(gpu_device), inv_frac.to(gpu_device), frac.to(gpu_device), - voxel.to(gpu_device), + (dm * weights.to(device)).sum().backward() + return {"xyz": xyz.grad, "adp": adp.grad, "occ": occ.grad} + + +def _aniso_grads(engine, device, cell_fn, atoms, weights): + xyz, u, occ, A, B = to_device(device, *atoms) + xyz, u, occ = (t.clone().requires_grad_() for t in (xyz, u, occ)) + _, _, frac, inv_frac, voxel, rsg = cell_fn() + rsg, frac, inv_frac, voxel = to_device(device, rsg, frac, inv_frac, voxel) + xi, ai, oi, Ai, Bi = _empty_iso(device) + with use_engine(engine): + dm = build_electron_density( + rsg, xi, ai, oi, Ai, Bi, inv_frac, frac, voxel, + xyz_aniso=xyz, u_aniso=u, occ_aniso=occ, A_aniso=A, B_aniso=B, + dtype=torch.float32, ) - assert _rel_map(gpu.cpu(), ref) < 2e-2 - assert _cos(gpu.cpu(), ref) > 0.9995 - - -def test_iso_gradients_match_cpu_grouped(gpu_device): - cell, grid, frac, inv_frac, voxel, rsg = _cell() - xyz, adp, occ, A, B = _iso_atoms(cell) - w = torch.randn(grid) - - def run(device, engine): - xx = xyz.to(device).clone().requires_grad_() - aa = adp.to(device).clone().requires_grad_() - oo = occ.to(device).clone().requires_grad_() - with use_engine(engine): - dm = build_electron_density( - rsg.to(device), xx, aa, oo, A.to(device), B.to(device), - inv_frac.to(device), frac.to(device), voxel.to(device), - ) - (dm * w.to(device)).sum().backward() - return xx.grad, aa.grad, oo.grad - - gx_c, ga_c, go_c = run("cpu", Engine.AUTO) - gx_g, ga_g, go_g = run(gpu_device, Engine.TRITON) - assert _cos(gx_g.cpu(), gx_c) > 0.999 - assert _cos(ga_g.cpu(), ga_c) > 0.999 - assert _cos(go_g.cpu(), go_c) > 0.999 - - -def test_aniso_gradients_match_cpu_grouped(gpu_device): - cell, grid, frac, inv_frac, voxel, rsg = _cell() - xa, ua, oa, Aa, Ba = _aniso_atoms(cell) - empty = torch.zeros(0, 3) - w = torch.randn(grid) - - def run(device, engine): - xx = xa.to(device).clone().requires_grad_() - uu = ua.to(device).clone().requires_grad_() - with use_engine(engine): - dm = build_electron_density( - rsg.to(device), empty.to(device), torch.zeros(0, device=device), - torch.zeros(0, device=device), torch.zeros(0, 5, device=device), - torch.zeros(0, 5, device=device), inv_frac.to(device), - frac.to(device), voxel.to(device), - xyz_aniso=xx, u_aniso=uu, occ_aniso=oa.to(device), - A_aniso=Aa.to(device), B_aniso=Ba.to(device), - ) - (dm * w.to(device)).sum().backward() - return xx.grad, uu.grad - - gx_c, gu_c = run("cpu", Engine.AUTO) - gx_g, gu_g = run(gpu_device, Engine.TRITON) - assert _cos(gx_g.cpu(), gx_c) > 0.999 - assert _cos(gu_g.cpu(), gu_c) > 0.999 + (dm * weights.to(device)).sum().backward() + return {"xyz": xyz.grad, "u": u.grad, "occ": occ.grad} + + +def test_iso_gradients_match_cpu_grouped(cuda_device): + cell_fn = cell_orthorhombic + atoms = iso_atoms(cell_fn()[0]) + w = torch.randn(cell_fn()[1], generator=torch.Generator().manual_seed(3)) + cpu = _iso_grads(Engine.AUTO, "cpu", cell_fn, atoms, w) + gpu = _iso_grads(Engine.TRITON, cuda_device, cell_fn, atoms, w) + assert_grads_agree(gpu, cpu, ctx="iso ", **_GRAD_KW) + + +def test_aniso_gradients_match_cpu_grouped(cuda_device): + """Covers the two paths the old zero-off-diagonal / unit-occupancy atoms + could not reach: the off-diagonal U gradients and the ``grad / occ`` + rescaling, which at ``occ == 1`` is a division by one.""" + cell_fn = cell_orthorhombic + atoms = aniso_atoms(cell_fn()[0]) + w = torch.randn(cell_fn()[1], generator=torch.Generator().manual_seed(4)) + cpu = _aniso_grads(Engine.AUTO, "cpu", cell_fn, atoms, w) + gpu = _aniso_grads(Engine.TRITON, cuda_device, cell_fn, atoms, w) + assert_grads_agree(gpu, cpu, ctx="aniso ", **_GRAD_KW) + + # The off-diagonal block must actually be exercised, else this test could + # pass on an all-zero comparison. + assert gpu["u"][:, 3:].abs().max() > 0, "off-diagonal U gradients are all zero" diff --git a/tests/integration/test_variable_radius_mps.py b/tests/integration/test_variable_radius_mps.py index cba7553..9ba1a21 100644 --- a/tests/integration/test_variable_radius_mps.py +++ b/tests/integration/test_variable_radius_mps.py @@ -1,198 +1,251 @@ -"""MPS variable-radius density path: native Metal kernels (Engine.AUTO) vs the -portable plain-scatter reference (Engine.EAGER), on the same MPS device. +"""MPS variable-radius density path: native Metal kernels vs the portable +plain-scatter reference, on the same MPS device. Both truncate each atom at its own per-atom radius, so the forward maps must agree to float32 + truncation-shape tolerance and the gradients (xyz / adp / u / -occ) must be parallel. Requires an Apple-silicon GPU (MPS); skipped elsewhere. - -Markers: ``@pytest.mark.mps`` (auto-skipped without MPS), ``integration``. +occ) must agree in direction *and* magnitude. + +The candidate side runs under ``Engine.METAL``, never ``Engine.AUTO``. That is +load-bearing, not stylistic: under AUTO a Metal kernel that fails to dispatch +falls back to the very same ``add_isotropic_plain_var`` the reference uses, so +every assertion here would pass with ``rel_map == 0.0`` and ``cos == 1.0`` while +measuring nothing at all. ``Engine.METAL`` raises instead of degrading, which is +what makes this file able to fail. + +``dtype=torch.float32`` is passed explicitly rather than inherited from the +ambient ``TORCHREF_DTYPE_FLOAT``: under a float64 config the Metal gate never +fires, which is a second, independent way for this file to go vacuous. + +Markers: ``@pytest.mark.mps`` (the sole gate -- see ``conftest.py``'s +``pytest_collection_modifyitems``; this file deliberately does no availability +checking of its own), ``integration``. """ -import math - import pytest import torch -from torchref.base.electron_density.kernels.mps import mps_kernels_available +from tests.helpers.grad_asserts import assert_grads_agree +from tests.helpers.kernel_cases import ( + aniso_atoms, + cell_monoclinic, + cell_orthorhombic, + cos_sim, + iso_atoms, + rel_map, + to_device, +) from torchref.base.electron_density.main import build_electron_density from torchref.utils import Engine, use_engine pytestmark = [pytest.mark.mps, pytest.mark.integration] - -@pytest.fixture -def mps_device(gpu_device): - if gpu_device.type != "mps": - pytest.skip("MPS-specific test (Metal kernels)") - if not mps_kernels_available(): - pytest.skip("Metal splat kernels failed to compile") - return gpu_device - - -def _cell(): - a, b, c = 30.0, 25.0, 20.0 - nx, ny, nz = 60, 50, 40 - frac = torch.diag(torch.tensor([a, b, c])) - inv_frac = torch.diag(torch.tensor([1 / a, 1 / b, 1 / c])) - voxel = torch.tensor([a / nx, b / ny, c / nz]) - ii, jj, kk = torch.meshgrid( - torch.arange(nx), torch.arange(ny), torch.arange(nz), indexing="ij" - ) - rsg = (torch.stack([ii / nx, jj / ny, kk / nz], -1).to(torch.float32)) @ frac.T - return (a, b, c), (nx, ny, nz), frac.float(), inv_frac.float(), voxel.float(), rsg - - -def _cell_monoclinic(): - """Non-orthogonal (beta ~ 100 deg) cell: exercises the per-axis box and the - off-diagonal coordinate math (u_c gains an x-component).""" - a, b, c = 30.0, 25.0, 20.0 - nx, ny, nz = 60, 50, 40 - beta = math.radians(100.0) - frac = torch.tensor( - [[a, 0.0, c * math.cos(beta)], - [0.0, b, 0.0], - [0.0, 0.0, c * math.sin(beta)]], dtype=torch.float64) - inv_frac = torch.linalg.inv(frac) - voxel = (frac.norm(dim=0) / torch.tensor([nx, ny, nz], dtype=torch.float64)).float() - ii, jj, kk = torch.meshgrid( - torch.arange(nx), torch.arange(ny), torch.arange(nz), indexing="ij" +# Measured Metal-vs-portable agreement on an M-series GPU with the +# non-degenerate atom sets (non-unit occupancy, signed off-diagonal U): +# +# iso orthorhombic rel_map 5.9e-3 cos 0.9999838 +# iso monoclinic rel_map 5.2e-3 cos 0.9999727 +# aniso orthorhombic rel_map 7.7e-4 cos 0.9999990 +# aniso monoclinic rel_map 1.0e-3 cos 0.9999990 +# +# The iso path is an order of magnitude looser than the aniso one because its +# per-atom cutoff is quantized to a whole number of voxels, so a voxel shell can +# land exactly on the ``r2 <= r2cut`` boundary and be included by one +# implementation and not the other. Those are low-density tail voxels, which is +# why the cosine stays tight while the max-relative figure does not. Tolerances +# are ~3-5x the measured maxima; do not tighten without re-measuring. +_ISO_REL_TOL = 2e-2 +_ANISO_REL_TOL = 5e-3 +_COS_TOL = 0.9999 + +# Gradient thresholds: the values ``tests/unit/test_gradient_correctness.py`` +# uses for "float32 + distinct kernel arithmetic". The ratio check is what a bare +# cosine cannot do -- a kernel returning ``2 * grad`` is perfectly parallel. +_GRAD_KW = dict(min_cos=0.999, ratio_tol=1e-2) + + +def _empty_iso(device): + """Zero-length isotropic arguments, for an aniso-only structure.""" + return ( + torch.zeros(0, 3, device=device), + torch.zeros(0, device=device), + torch.zeros(0, device=device), + torch.zeros(0, 5, device=device), + torch.zeros(0, 5, device=device), ) - fc = torch.stack([ii / nx, jj / ny, kk / nz], -1).double() - rsg = (fc @ frac.T).float() - return (a, b, c), (nx, ny, nz), frac.float(), inv_frac.float(), voxel, rsg - - -def _iso_atoms(cell, n=60, seed=0): - g = torch.Generator().manual_seed(seed) - a, b, c = cell - xyz = torch.rand(n, 3, generator=g) * torch.tensor([a, b, c]) - adp = torch.rand(n, generator=g) * 35 + 3 - occ = torch.ones(n) - A = torch.rand(n, 5, generator=g) * 5 - B = torch.rand(n, 5, generator=g) * 20 + 2 - return [t.float() for t in (xyz, adp, occ, A, B)] -def _aniso_atoms(cell, n=30, seed=1): - g = torch.Generator().manual_seed(seed) - a, b, c = cell - xyz = torch.rand(n, 3, generator=g) * torch.tensor([a, b, c]) - u = torch.zeros(n, 6) - u[:, :3] = torch.rand(n, 3, generator=g) * 0.12 + 0.02 - occ = torch.ones(n) - A = torch.rand(n, 5, generator=g) * 5 - B = torch.rand(n, 5, generator=g) * 20 + 2 - return [t.float() for t in (xyz, u, occ, A, B)] +def _run_iso(engine, mps_device, cell_fn, atoms): + _, _, frac, inv_frac, voxel, rsg = cell_fn() + xyz, adp, occ, A, B = to_device(mps_device, *atoms) + rsg, frac, inv_frac, voxel = to_device(mps_device, rsg, frac, inv_frac, voxel) + with use_engine(engine): + return build_electron_density( + rsg, xyz, adp, occ, A, B, inv_frac, frac, voxel, dtype=torch.float32 + ) + + +def _run_aniso(engine, mps_device, cell_fn, atoms): + _, _, frac, inv_frac, voxel, rsg = cell_fn() + xyz, u, occ, A, B = to_device(mps_device, *atoms) + rsg, frac, inv_frac, voxel = to_device(mps_device, rsg, frac, inv_frac, voxel) + xi, ai, oi, Ai, Bi = _empty_iso(mps_device) + with use_engine(engine): + return build_electron_density( + rsg, xi, ai, oi, Ai, Bi, inv_frac, frac, voxel, + xyz_aniso=xyz, u_aniso=u, occ_aniso=occ, A_aniso=A, B_aniso=B, + dtype=torch.float32, + ) + + +# --------------------------------------------------------------------------- +# Forward +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "cell_fn", [cell_orthorhombic, cell_monoclinic], ids=["orthorhombic", "monoclinic"] +) +def test_iso_metal_matches_plain(mps_device, cell_fn): + atoms = iso_atoms(cell_fn()[0]) + ref = _run_iso(Engine.EAGER, mps_device, cell_fn, atoms) + got = _run_iso(Engine.METAL, mps_device, cell_fn, atoms) + assert rel_map(got, ref) < _ISO_REL_TOL + assert cos_sim(got, ref) > _COS_TOL + + +@pytest.mark.parametrize( + "cell_fn", [cell_orthorhombic, cell_monoclinic], ids=["orthorhombic", "monoclinic"] +) +def test_aniso_metal_matches_plain(mps_device, cell_fn): + """Both cells, so the ellipsoid cross-terms are exercised on a sheared cell + too -- previously only the isotropic path saw a non-orthogonal cell.""" + atoms = aniso_atoms(cell_fn()[0]) + ref = _run_aniso(Engine.EAGER, mps_device, cell_fn, atoms) + got = _run_aniso(Engine.METAL, mps_device, cell_fn, atoms) + assert rel_map(got, ref) < _ANISO_REL_TOL + assert cos_sim(got, ref) > _COS_TOL + + +# --------------------------------------------------------------------------- +# Backward +# --------------------------------------------------------------------------- + + +def _iso_grads(engine, mps_device, cell_fn, atoms, weights): + xyz, adp, occ, A, B = to_device(mps_device, *atoms) + xyz, adp, occ = (t.clone().requires_grad_() for t in (xyz, adp, occ)) + _, _, frac, inv_frac, voxel, rsg = cell_fn() + rsg, frac, inv_frac, voxel = to_device(mps_device, rsg, frac, inv_frac, voxel) + with use_engine(engine): + dm = build_electron_density( + rsg, xyz, adp, occ, A, B, inv_frac, frac, voxel, dtype=torch.float32 + ) + (dm * weights).sum().backward() + return {"xyz": xyz.grad, "adp": adp.grad, "occ": occ.grad} + + +def _aniso_grads(engine, mps_device, cell_fn, atoms, weights): + xyz, u, occ, A, B = to_device(mps_device, *atoms) + xyz, u, occ = (t.clone().requires_grad_() for t in (xyz, u, occ)) + _, _, frac, inv_frac, voxel, rsg = cell_fn() + rsg, frac, inv_frac, voxel = to_device(mps_device, rsg, frac, inv_frac, voxel) + xi, ai, oi, Ai, Bi = _empty_iso(mps_device) + with use_engine(engine): + dm = build_electron_density( + rsg, xi, ai, oi, Ai, Bi, inv_frac, frac, voxel, + xyz_aniso=xyz, u_aniso=u, occ_aniso=occ, A_aniso=A, B_aniso=B, + dtype=torch.float32, + ) + (dm * weights).sum().backward() + return {"xyz": xyz.grad, "u": u.grad, "occ": occ.grad} -def _cos(a, b): - a, b = a.reshape(-1).double(), b.reshape(-1).double() - return float((a @ b) / (a.norm() * b.norm() + 1e-12)) - +def test_iso_gradients_match_plain(mps_device): + cell_fn = cell_orthorhombic + atoms = iso_atoms(cell_fn()[0]) + w = torch.randn(cell_fn()[1], generator=torch.Generator().manual_seed(3)).to( + mps_device + ) + ref = _iso_grads(Engine.EAGER, mps_device, cell_fn, atoms, w) + got = _iso_grads(Engine.METAL, mps_device, cell_fn, atoms, w) + assert_grads_agree(got, ref, ctx="iso ", **_GRAD_KW) -def _rel_map(x, y): - return float((x - y).abs().max() / (y.abs().max() + 1e-8)) +def test_aniso_gradients_match_plain(mps_device): + """Covers the two paths the old zero-off-diagonal / unit-occupancy atoms + could not reach: the off-diagonal U gradients (which carry a ``4*pi^2`` + factor where the diagonal ones carry ``2*pi^2``) and the ``grad / occ`` + rescaling, which at ``occ == 1`` is a division by one.""" + cell_fn = cell_orthorhombic + atoms = aniso_atoms(cell_fn()[0]) + w = torch.randn(cell_fn()[1], generator=torch.Generator().manual_seed(4)).to( + mps_device + ) + ref = _aniso_grads(Engine.EAGER, mps_device, cell_fn, atoms, w) + got = _aniso_grads(Engine.METAL, mps_device, cell_fn, atoms, w) + assert_grads_agree(got, ref, ctx="aniso ", **_GRAD_KW) -def _to(dev, *ts): - return [t.to(dev) for t in ts] + # The off-diagonal block must actually be exercised, else this test could + # pass on an all-zero comparison. + assert got["u"][:, 3:].abs().max() > 0, "off-diagonal U gradients are all zero" -def test_iso_metal_matches_plain(mps_device): - _, grid, frac, inv_frac, voxel, rsg = _cell() - xyz, adp, occ, A, B = _to(mps_device, *_iso_atoms(_cell()[0])) - rsg, frac, inv_frac, voxel = _to(mps_device, rsg, frac, inv_frac, voxel) +# --------------------------------------------------------------------------- +# Dispatch provenance and strict-mode behaviour +# --------------------------------------------------------------------------- - def run(engine): - with use_engine(engine): - return build_electron_density(rsg, xyz, adp, occ, A, B, inv_frac, frac, voxel) - ref = run(Engine.EAGER) # portable plain splat - got = run(Engine.AUTO) # Metal - assert _rel_map(got.cpu(), ref.cpu()) < 2e-2 - assert _cos(got.cpu(), ref.cpu()) > 0.9995 +@pytest.mark.parametrize("engine", [Engine.AUTO, Engine.METAL]) +def test_engine_dispatches_to_metal_kernel(mps_device, monkeypatch, engine): + """On MPS float32, both AUTO and METAL must actually call the Metal kernel. + Verified with a call recorder rather than by comparing maps. Comparing is not + sound here: the portable reference uses ``scatter_add`` on MPS, whose + accumulation order is not reproducible, so two portable runs differ at ~1e-7 + and ``torch.equal`` against one proves nothing either way. -def test_iso_metal_matches_plain_monoclinic(mps_device): - _, grid, frac, inv_frac, voxel, rsg = _cell_monoclinic() - xyz, adp, occ, A, B = _to(mps_device, *_iso_atoms(_cell_monoclinic()[0])) - rsg, frac, inv_frac, voxel = _to(mps_device, rsg, frac, inv_frac, voxel) + The patch target is the **package** attribute, not a ``main`` global: + ``_add_isotropic`` does a function-local ``from ...kernels.mps import + add_isotropic_mps_var`` on every call, so it re-reads the package each time. + Patching ``main.add_isotropic_mps_var`` would silently no-op and leave this + test as vacuous as the bug it guards. + """ + from torchref.base.electron_density import kernels - def run(engine): - with use_engine(engine): - return build_electron_density(rsg, xyz, adp, occ, A, B, inv_frac, frac, voxel) + real = kernels.mps.add_isotropic_mps_var + calls = [] - ref = run(Engine.EAGER) - got = run(Engine.AUTO) - assert _rel_map(got.cpu(), ref.cpu()) < 2e-2 - assert _cos(got.cpu(), ref.cpu()) > 0.9995 + def recording(*args, **kwargs): + calls.append(1) + return real(*args, **kwargs) + monkeypatch.setattr(kernels.mps, "add_isotropic_mps_var", recording) -def test_aniso_metal_matches_plain(mps_device): - _, grid, frac, inv_frac, voxel, rsg = _cell() - xa, ua, oa, Aa, Ba = _to(mps_device, *_aniso_atoms(_cell()[0])) - rsg, frac, inv_frac, voxel = _to(mps_device, rsg, frac, inv_frac, voxel) - empty = torch.zeros(0, 3, device=mps_device) - z0 = torch.zeros(0, device=mps_device) - z05 = torch.zeros(0, 5, device=mps_device) + cell_fn = cell_orthorhombic + _run_iso(engine, mps_device, cell_fn, iso_atoms(cell_fn()[0])) + assert calls, f"{engine} did not dispatch to the Metal kernel" - def run(engine): - with use_engine(engine): - return build_electron_density( - rsg, empty, z0, z0, z05, z05, inv_frac, frac, voxel, - xyz_aniso=xa, u_aniso=ua, occ_aniso=oa, A_aniso=Aa, B_aniso=Ba, - ) - ref = run(Engine.EAGER) - got = run(Engine.AUTO) - assert _rel_map(got.cpu(), ref.cpu()) < 2e-2 - assert _cos(got.cpu(), ref.cpu()) > 0.9995 +def test_metal_raises_when_shader_unavailable(mps_device, monkeypatch): + """``Engine.METAL`` must raise, never degrade, when the shader is missing. + ``monkeypatch`` reverts the module globals at teardown, so this needs no + ``try``/``finally`` -- and must not have one, since a swallowed failure here + is exactly the behaviour under test. + """ + from torchref.base.electron_density.kernels.mps import compile as mps_compile -def test_iso_gradients_match_plain(mps_device): - _, grid, frac, inv_frac, voxel, rsg = _cell() - xyz0, adp0, occ0, A, B = _iso_atoms(_cell()[0]) - rsg, frac, inv_frac, voxel, A, B = _to(mps_device, rsg, frac, inv_frac, voxel, A, B) - w = torch.randn(grid, device=mps_device) - - def run(engine): - xx = xyz0.to(mps_device).clone().requires_grad_() - aa = adp0.to(mps_device).clone().requires_grad_() - oo = occ0.to(mps_device).clone().requires_grad_() - with use_engine(engine): - dm = build_electron_density(rsg, xx, aa, oo, A, B, inv_frac, frac, voxel) - (dm * w).sum().backward() - return xx.grad, aa.grad, oo.grad - - gx_r, ga_r, go_r = run(Engine.EAGER) - gx_m, ga_m, go_m = run(Engine.AUTO) - assert _cos(gx_m.cpu(), gx_r.cpu()) > 0.999 - assert _cos(ga_m.cpu(), ga_r.cpu()) > 0.999 - assert _cos(go_m.cpu(), go_r.cpu()) > 0.999 + monkeypatch.setattr(mps_compile, "_lib", None) + monkeypatch.setattr(mps_compile, "_lib_failed", True) + monkeypatch.setattr(mps_compile, "_lib_error", ("forced test failure", "")) + cell_fn = cell_orthorhombic + atoms = iso_atoms(cell_fn()[0]) + with pytest.raises(RuntimeError, match="forced test failure"): + _run_iso(Engine.METAL, mps_device, cell_fn, atoms) -def test_aniso_gradients_match_plain(mps_device): - _, grid, frac, inv_frac, voxel, rsg = _cell() - xa0, ua0, oa0, Aa, Ba = _aniso_atoms(_cell()[0]) - rsg, frac, inv_frac, voxel, Aa, Ba = _to(mps_device, rsg, frac, inv_frac, voxel, Aa, Ba) - empty = torch.zeros(0, 3, device=mps_device) - z0 = torch.zeros(0, device=mps_device) - z05 = torch.zeros(0, 5, device=mps_device) - w = torch.randn(grid, device=mps_device) - - def run(engine): - xx = xa0.to(mps_device).clone().requires_grad_() - uu = ua0.to(mps_device).clone().requires_grad_() - with use_engine(engine): - dm = build_electron_density( - rsg, empty, z0, z0, z05, z05, inv_frac, frac, voxel, - xyz_aniso=xx, u_aniso=uu, occ_aniso=oa0.to(mps_device), - A_aniso=Aa, B_aniso=Ba, - ) - (dm * w).sum().backward() - return xx.grad, uu.grad - - gx_r, gu_r = run(Engine.EAGER) - gx_m, gu_m = run(Engine.AUTO) - assert _cos(gx_m.cpu(), gx_r.cpu()) > 0.999 - assert _cos(gu_m.cpu(), gu_r.cpu()) > 0.999 + # ...while AUTO still degrades gracefully to the portable splat. Compared + # loosely, not bitwise: see the note in the provenance test above. + auto = _run_iso(Engine.AUTO, mps_device, cell_fn, atoms) + eager = _run_iso(Engine.EAGER, mps_device, cell_fn, atoms) + assert rel_map(auto, eager) < 1e-5 diff --git a/tests/unit/io/test_reflection_data_reindex.py b/tests/unit/io/test_reflection_data_reindex.py index b5a1243..691a552 100644 --- a/tests/unit/io/test_reflection_data_reindex.py +++ b/tests/unit/io/test_reflection_data_reindex.py @@ -146,3 +146,34 @@ def test_forward_finite_with_different_reflection_sets(self, pdb_dir, mtz_dir): target = CollectionDifferenceTarget(dc, mc, scaler=scaler, verbose=0) loss = target.forward() assert torch.isfinite(loss) + + +@pytest.mark.unit +def test_reindex_preserves_non_per_reflection_u_aniso(): + """``U_aniso`` must be exempt by *name*, not by a shape coincidence. + + The reindexer decides "is this per-reflection?" by ``shape[0] == n_hkl``. + That heuristic collides when the dataset happens to have exactly as many + reflections as the field is long -- ``U_aniso`` is ``(6,)``, so a + 6-reflection dataset would have it gathered and reordered as if it were + per-reflection data. + """ + # Exactly 6 reflections: the same length as U_aniso. + hkl = torch.tensor( + [[1, 0, 1], [0, 1, 1], [0, 0, 1], [1, 1, 1], [1, 0, 2], [0, 1, 2]], + dtype=torch.int32, + ) + data = _synthetic(hkl) + u_aniso = torch.tensor([0.1, 0.2, 0.3, 0.01, 0.02, 0.03]) + data.U_aniso = u_aniso.clone().to(device=data.device) + assert len(data.hkl) == data.U_aniso.shape[0] == 6, "precondition: lengths collide" + + # Reindex onto a different HKL ordering/size; U_aniso must not follow. + ref_hkl = torch.tensor( + [[0, 1, 2], [1, 0, 2], [1, 1, 1], [0, 0, 1], [0, 1, 1], [1, 0, 1], [2, 0, 1]], + dtype=torch.int32, + ) + data.validate_hkl(ref_hkl.to(data.device)) + + assert data.U_aniso.shape == (6,), f"U_aniso reshaped to {tuple(data.U_aniso.shape)}" + assert torch.allclose(data.U_aniso.cpu(), u_aniso), "U_aniso values were permuted" diff --git a/tests/unit/refinement/test_estimate_beta_determinism.py b/tests/unit/refinement/test_estimate_beta_determinism.py index 4b84bdc..ef1f7a4 100644 --- a/tests/unit/refinement/test_estimate_beta_determinism.py +++ b/tests/unit/refinement/test_estimate_beta_determinism.py @@ -47,7 +47,7 @@ def _hard_inputs(dtype=torch.float32): @pytest.mark.unit -@pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA required") +@pytest.mark.cuda class TestEstimateBetaGPUDeterminism: def test_gpu_bit_identical_across_calls(self): """Same input -> identical beta on every GPU call (was: 15x spread).""" diff --git a/tests/unit/refinement/test_ml_sigmaa.py b/tests/unit/refinement/test_ml_sigmaa.py index 405836d..3aa8e55 100644 --- a/tests/unit/refinement/test_ml_sigmaa.py +++ b/tests/unit/refinement/test_ml_sigmaa.py @@ -221,3 +221,42 @@ def test_gradient_reaches_model(self, refinement): refinement.xray_target_work.forward().backward() xyz = refinement.xray_target_work._model.xyz.refinable_params assert xyz.grad is not None and torch.isfinite(xyz.grad).all() + + +@pytest.mark.unit +def test_epsilon_from_hkl_returns_on_hkl_device(): + """``epsilon_from_hkl`` must answer on ``hkl``'s device, not the spacegroup's. + + ``SpaceGroup.apply_to_hkl`` moves its input onto the symmetry matrices' + device, so a spacegroup built elsewhere than the reflection data would + otherwise either raise inside the ``Hs == h0`` comparison or hand back a + tensor the caller cannot multiply against its per-reflection data. + """ + from torchref.base.targets.xray_ml_sigmaa import epsilon_from_hkl + from torchref.symmetry import SpaceGroup + + hkl = torch.tensor([[1, 0, 0], [0, 2, 0], [1, 1, 1]], dtype=torch.int32) + sg = SpaceGroup("P 21 21 21", device="cpu") + + eps = epsilon_from_hkl(hkl, sg) + assert eps.device == hkl.device + assert eps.shape == (3,) + assert (eps >= 1).all() + + # No spacegroup: the early-return path must honour the same contract. + assert epsilon_from_hkl(hkl, None).device == hkl.device + + +@pytest.mark.mps +def test_epsilon_from_hkl_cross_device(): + """hkl on the accelerator, spacegroup on CPU: still answers on hkl's device.""" + from torchref.base.targets.xray_ml_sigmaa import epsilon_from_hkl + from torchref.symmetry import SpaceGroup + + hkl = torch.tensor( + [[1, 0, 0], [0, 2, 0], [1, 1, 1]], dtype=torch.int32, device="mps" + ) + sg = SpaceGroup("P 21 21 21", device="cpu") + eps = epsilon_from_hkl(hkl, sg) + assert eps.device.type == "mps" + assert (eps.cpu() >= 1).all() diff --git a/tests/unit/test_gradient_correctness.py b/tests/unit/test_gradient_correctness.py index 364ad49..0db0a01 100644 --- a/tests/unit/test_gradient_correctness.py +++ b/tests/unit/test_gradient_correctness.py @@ -54,6 +54,8 @@ pytestmark = pytest.mark.unit +# Kept for readability in the section header below; backend gating itself is +# the ``cuda`` marker's job (see tests/conftest.py). _HAS_CUDA = torch.cuda.is_available() @@ -81,46 +83,16 @@ def double_cpu(): device.current = d0 -def _flat_real(t: torch.Tensor) -> torch.Tensor: - """Flatten to a real 1-D vector, splitting complex into (re, im).""" - if t.is_complex(): - return torch.view_as_real(t).reshape(-1).double() - return t.reshape(-1).double() - - -def cosine_similarity(a: torch.Tensor, b: torch.Tensor) -> float: - """Cosine similarity of two gradients (direction agreement).""" - return torch.nn.functional.cosine_similarity( - _flat_real(a), _flat_real(b), dim=0 - ).item() - - -def gradnorm_ratio(a: torch.Tensor, b: torch.Tensor) -> float: - """Ratio of gradient norms ``||a|| / ||b||`` (magnitude agreement).""" - nb = _flat_real(b).norm() - return (_flat_real(a).norm() / nb).item() - - -def assert_grads_agree(got, ref, *, min_cos=0.9999, ratio_tol=1e-3, ctx=""): - """Assert per-leaf cosine ≈ 1 and gradnorm ratio ≈ 1 for matched grads.""" - for name, g, r in got_ref_pairs(got, ref): - cos = cosine_similarity(g, r) - ratio = gradnorm_ratio(g, r) - assert cos >= min_cos, f"{ctx}{name}: cosine {cos:.8f} < {min_cos}" - assert ( - abs(ratio - 1.0) <= ratio_tol - ), f"{ctx}{name}: gradnorm ratio {ratio:.8f} off 1 by > {ratio_tol}" - - -def got_ref_pairs(got, ref): - """Yield ``(name, got_grad, ref_grad)`` triples for a dict or sequence.""" - if isinstance(got, dict): - for k in got: - yield str(k), got[k], ref[k] - else: - for i, (g, r) in enumerate(zip(got, ref)): - yield str(i), g, r - +# These live in ``tests/helpers/grad_asserts.py`` so the accelerator kernel +# comparison tests can share them; re-exported here because this module is where +# they originated and several tests below use them unqualified. +from tests.helpers.grad_asserts import ( # noqa: E402 + _flat_real, + assert_grads_agree, + cosine_similarity, + got_ref_pairs, + gradnorm_ratio, +) # --- synthetic structure-factor inputs (P1, ~10-15 atoms) ------------------ def _sf_inputs(N=12, R=18, dtype=torch.float64, device="cpu"): @@ -364,7 +336,6 @@ def loss_fn(): # Metric: cosine similarity + gradnorm ratio. Auto-skipped without CUDA. # ============================================================================= @pytest.mark.cuda -@pytest.mark.skipif(not _HAS_CUDA, reason="Triton kernels require CUDA") def test_triton_sf_iso_matches_eager_cosine(): """DS isotropic Triton kernel gradient == eager autograd (CUDA float32).""" from torchref.base.direct_summation.dispatch import ds_iso @@ -388,7 +359,6 @@ def test_triton_sf_iso_matches_eager_cosine(): @pytest.mark.cuda -@pytest.mark.skipif(not _HAS_CUDA, reason="Triton kernels require CUDA") def test_triton_sf_aniso_matches_eager_cosine(): """DS anisotropic Triton kernel gradient == eager autograd (CUDA float32).""" from torchref.base.direct_summation.dispatch import ds_aniso @@ -411,7 +381,6 @@ def test_triton_sf_aniso_matches_eager_cosine(): @pytest.mark.cuda -@pytest.mark.skipif(not _HAS_CUDA, reason="Triton kernels require CUDA") def test_triton_gaussian_xray_matches_eager_cosine(): """Gaussian X-ray Triton kernel gradient == eager autograd (CUDA float32).""" dev = "cuda" @@ -433,7 +402,6 @@ def test_triton_gaussian_xray_matches_eager_cosine(): @pytest.mark.cuda -@pytest.mark.skipif(not _HAS_CUDA, reason="Triton kernels require CUDA") def test_triton_bond_matches_eager_cosine(): """Bond restraint Triton kernel gradient == eager autograd (CUDA float32).""" dev = "cuda" diff --git a/tests/unit/utils/test_triton_dispatch.py b/tests/unit/utils/test_triton_dispatch.py index 20f0769..e42d6f7 100644 --- a/tests/unit/utils/test_triton_dispatch.py +++ b/tests/unit/utils/test_triton_dispatch.py @@ -7,6 +7,7 @@ Engine, get_engine, set_engine, + should_use_metal, should_use_triton, triton_available, use_engine, @@ -84,3 +85,79 @@ def test_should_use_triton_reads_global_engine_by_default(): assert should_use_triton(torch.zeros(3)) is False finally: set_engine(prev) + + +# --------------------------------------------------------------------------- +# Engine.METAL / should_use_metal +# +# All CPU-safe, so they run on every host: the point is the *strict* contract, +# which is observable without an accelerator because "wrong device" is one of +# the conditions that must raise. +# --------------------------------------------------------------------------- + + +def test_metal_engine_roundtrip(): + prev = get_engine() + try: + set_engine(Engine.METAL) + assert get_engine() is Engine.METAL + finally: + set_engine(prev) + + +def test_should_use_triton_false_under_metal(): + """METAL must not leak into the Triton gate. + + Before the explicit-AUTO tail below, ``should_use_triton`` ended in an + implicit ``else``, so a new member fell through and selected Triton -- on a + CUDA host, ``Engine.METAL`` would have run the Triton kernel. + """ + assert should_use_triton(torch.zeros(3), engine=Engine.METAL) is False + + +def test_should_use_triton_rejects_unknown_engine(): + """An unhandled member must fail loudly rather than silently pick Triton.""" + + class _NotAnEngine: + pass + + with pytest.raises(ValueError, match="unhandled engine"): + should_use_triton(torch.zeros(3), engine=_NotAnEngine()) + + +def test_should_use_metal_rejects_unknown_engine(): + class _NotAnEngine: + pass + + with pytest.raises(ValueError, match="unhandled engine"): + should_use_metal(torch.zeros(3), engine=_NotAnEngine()) + + +@pytest.mark.parametrize("engine", [Engine.EAGER, Engine.TRITON]) +def test_should_use_metal_false_for_non_metal_engines(engine): + assert should_use_metal(torch.zeros(3), engine=engine) is False + + +def test_should_use_metal_false_on_cpu_under_auto(): + """AUTO never raises -- it just declines the Metal path off MPS.""" + assert should_use_metal(torch.zeros(3), engine=Engine.AUTO) is False + + +def test_should_use_metal_raises_on_cpu_under_metal(): + """Forcing METAL on a non-MPS tensor is an error, mirroring TRITON.""" + with pytest.raises(RuntimeError, match="requires MPS float32"): + should_use_metal(torch.zeros(3), engine=Engine.METAL) + + +def test_should_use_metal_raises_on_wrong_dtype_under_metal(): + """A forced engine must not silently downcast: float64 has no Metal kernel.""" + with pytest.raises(RuntimeError, match="requires MPS float32"): + should_use_metal(torch.zeros(3, dtype=torch.float64), engine=Engine.METAL) + + +def test_should_use_metal_skips_none_but_still_checks_real_tensors(): + """``None`` entries are optional inputs and are skipped, but a real tensor + beside them is still probed -- host-independent, since a CPU tensor is + wrong for Metal everywhere.""" + with pytest.raises(RuntimeError, match="requires MPS float32"): + should_use_metal(None, torch.zeros(3), None, engine=Engine.METAL) diff --git a/torchref/base/electron_density/kernels/mps/__init__.py b/torchref/base/electron_density/kernels/mps/__init__.py index d324da6..9e58376 100644 --- a/torchref/base/electron_density/kernels/mps/__init__.py +++ b/torchref/base/electron_density/kernels/mps/__init__.py @@ -2,12 +2,15 @@ Native Metal kernels (compiled at runtime via ``torch.mps.compile_shader``) for the density splat on Apple-silicon GPUs, replacing the portable eager splat that -dominates fcalc time on MPS. Gated behind ``device.type == 'mps'`` in -``electron_density.main``; every other platform is unaffected. +dominates fcalc time on MPS. Selection goes through +``torchref.utils.should_use_metal``, which gates on MPS + float32 + a compiled +shader; ``Engine.METAL`` forces the path and raises rather than degrading. +Every other platform is unaffected. """ from torchref.base.electron_density.kernels.mps.compile import ( clear_cache, + last_error, mps_kernels_available, warmup, ) @@ -22,4 +25,5 @@ "mps_kernels_available", "warmup", "clear_cache", + "last_error", ] diff --git a/torchref/base/electron_density/kernels/mps/compile.py b/torchref/base/electron_density/kernels/mps/compile.py index d61dbbe..8b319e3 100644 --- a/torchref/base/electron_density/kernels/mps/compile.py +++ b/torchref/base/electron_density/kernels/mps/compile.py @@ -5,9 +5,11 @@ in-process ``torch.mps.compile_shader`` call -- no ninja, build directory, or file locking, since PyTorch caches the compiled pipeline-state objects itself. -``mps_kernels_available()`` is the gate the dispatcher checks; it returns False -(and the caller falls back to the portable plain splat) whenever MPS is absent, -``compile_shader`` is missing (torch < 2.9), or the shader fails to build. +``mps_kernels_available()`` is what ``torchref.utils.should_use_metal`` consults; +it returns False whenever MPS is absent, ``compile_shader`` is missing +(torch < 2.9), or the shader fails to build. Under ``Engine.AUTO`` the caller +then falls back to the portable plain splat; under ``Engine.METAL`` it raises +instead, quoting :func:`last_error`. """ from __future__ import annotations diff --git a/torchref/base/electron_density/main.py b/torchref/base/electron_density/main.py index 9c1b0d2..a3b5679 100644 --- a/torchref/base/electron_density/main.py +++ b/torchref/base/electron_density/main.py @@ -4,9 +4,9 @@ Every atom is splatted at its own per-atom truncation radius (``N_sigma * sigma_eff``, with ``N_sigma = torchref.sigma_cutoff_ed``); there is no single global splat radius. The capability-based ``Engine`` in -:mod:`torchref.utils.triton_dispatch` (AUTO/TRITON/EAGER) is the *only* switch -selecting which variable-radius kernel runs — there is no environment-variable -dispatch and no parallel "tier" knobs: +:mod:`torchref.utils.triton_dispatch` (AUTO/TRITON/METAL/EAGER) is the *only* +switch selecting which variable-radius kernel runs — there is no +environment-variable dispatch and no parallel "tier" knobs: - ``Engine.AUTO`` — fastest available per device, all variable-radius: CUDA+float32 -> the work-queue Triton kernels @@ -24,6 +24,10 @@ ``with use_engine(Engine.EAGER): ...``. - ``Engine.TRITON`` — force the CUDA work-queue Triton kernel (raises if not CUDA+float32). +- ``Engine.METAL`` — force the native Metal kernel (raises if not MPS+float32, + or if the shader did not compile). Use it to benchmark or test the Metal path: + under ``AUTO`` a broken kernel degrades silently to the portable splat, so a + comparison against ``EAGER`` would pass while measuring nothing. The production variable-radius splats live in ``kernels/cuda/variable_radius.py`` and ``kernels/cpu/variable_radius.py``; the @@ -40,7 +44,12 @@ import torch from torchref.config import get_float_dtype, get_sigma_cutoff_ed -from torchref.utils.triton_dispatch import Engine, get_engine, should_use_triton +from torchref.utils.triton_dispatch import ( + Engine, + get_engine, + should_use_metal, + should_use_triton, +) # --- Shared splat helpers (re-imported to preserve this namespace; reused by the # variable-radius kernels and, for _get_radius_offsets, by scaling/solvent.py) --- @@ -100,9 +109,10 @@ def build_electron_density( ``N_sigma = torchref.sigma_cutoff_ed``) via the per-atom variable-radius kernels: the CUDA float32 work-queue kernels (``WorkQueueGridDensity{,Aniso}``) under ``Engine.AUTO``/``Engine.TRITON``, - the grouped-separable variable-radius splat on CPU+AUTO, and the portable - plain-scatter variable-radius splat everywhere else (``Engine.EAGER``, CUDA - float64, MPS). + the grouped-separable variable-radius splat on CPU+AUTO, the native Metal + kernels on MPS+float32 under ``Engine.AUTO``/``Engine.METAL``, and the + portable plain-scatter variable-radius splat everywhere else + (``Engine.EAGER``, CUDA/MPS float64). Parameters ---------- @@ -209,8 +219,10 @@ def _add_isotropic( under AUTO it falls through to the portable splat; under ``Engine.TRITON`` it raises (never silently degrade). - CPU + float32 + AUTO -> the fast C++-scatter grouped-separable splat. - - MPS + float32 + AUTO -> the native Metal kernel (``add_isotropic_mps_var``); - falls through to the portable splat if the shader is unavailable. + - ``should_use_metal`` (MPS + float32 + compiled shader, engine AUTO/METAL) + -> the native Metal kernel (``add_isotropic_mps_var``). Mirrors the Triton + branch: on kernel failure under AUTO it falls through to the portable + splat; under ``Engine.METAL`` it raises (never silently degrade). - Everything else (``Engine.EAGER`` on any device, CUDA/MPS float64) -> the portable plain-``scatter_add`` grouped splat: identical per-atom radius, double-differentiable, float64-capable, device-agnostic. @@ -255,26 +267,23 @@ def _add_isotropic( density_map, xyz, adp, occ, A, B, inv_frac_matrix, frac_matrix, grid_shape_tuple, voxel_size, radius_per_atom, ) - # Native Metal splat on Apple-silicon GPUs (float32 only). Falls through to - # the portable plain splat if the shader is unavailable or fails. - if ( - get_engine() is Engine.AUTO - and density_map.device.type == "mps" - and density_map.dtype == torch.float32 - ): - from torchref.base.electron_density.kernels.mps import ( - add_isotropic_mps_var, - mps_kernels_available, - ) + # Native Metal splat on Apple-silicon GPUs (float32 only). ``should_use_metal`` + # owns the device/dtype/availability decision, so an unavailable shader under + # ``Engine.METAL`` raises there rather than slipping past this gate. + # Import stays function-local: it loads the MSL source, which no other + # platform should pay for. + if should_use_metal(density_map, xyz, adp, occ, A, B): + from torchref.base.electron_density.kernels.mps import add_isotropic_mps_var - if mps_kernels_available(): - try: - return add_isotropic_mps_var( - density_map, xyz, adp, occ, A, B, - inv_frac_matrix, frac_matrix, grid_shape_tuple, voxel_size, radius_per_atom, - ) - except Exception: - pass # fall through to the portable splat + try: + return add_isotropic_mps_var( + density_map, xyz, adp, occ, A, B, + inv_frac_matrix, frac_matrix, grid_shape_tuple, voxel_size, radius_per_atom, + ) + except Exception: + if get_engine() is Engine.METAL: + raise + # AUTO: fall through to the portable splat return add_isotropic_plain_var( density_map, xyz, adp, occ, A, B, inv_frac_matrix, frac_matrix, grid_shape_tuple, voxel_size, radius_per_atom, @@ -300,8 +309,9 @@ def _add_anisotropic( CUDA+float32 (engine permitting) -> ``WorkQueueGridDensityAniso``. CPU + float32 + AUTO -> the fast C++-scatter grouped box-splat - ``add_anisotropic_cpu_var``. MPS + float32 + AUTO -> the native Metal kernel - ``add_anisotropic_mps_var`` (falls through if unavailable). Everything else + ``add_anisotropic_cpu_var``. MPS + float32 + AUTO/METAL (via + ``should_use_metal``) -> the native Metal kernel ``add_anisotropic_mps_var``; + falls through under AUTO, raises under ``Engine.METAL``. Everything else (``Engine.EAGER`` on any device, CUDA/MPS float64) -> the portable plain-``scatter_add`` box-splat ``add_anisotropic_plain_var`` (double-diff, float64-capable, device-agnostic). All paths use the per-atom radius @@ -344,26 +354,21 @@ def _add_anisotropic( real_space_grid, density_map, xyz, u, occ, A, B, inv_frac_matrix, frac_matrix, radius_per_atom, voxel_size, ) - # Native Metal splat on Apple-silicon GPUs (float32 only). Falls through to - # the portable plain splat if the shader is unavailable or fails. - if ( - get_engine() is Engine.AUTO - and density_map.device.type == "mps" - and density_map.dtype == torch.float32 - ): - from torchref.base.electron_density.kernels.mps import ( - add_anisotropic_mps_var, - mps_kernels_available, - ) + # Native Metal splat on Apple-silicon GPUs (float32 only). See the iso path: + # ``should_use_metal`` owns device/dtype/availability, and the import is + # function-local so only Apple silicon loads the MSL source. + if should_use_metal(density_map, xyz, u, occ, A, B): + from torchref.base.electron_density.kernels.mps import add_anisotropic_mps_var - if mps_kernels_available(): - try: - return add_anisotropic_mps_var( - real_space_grid, density_map, xyz, u, occ, A, B, - inv_frac_matrix, frac_matrix, radius_per_atom, voxel_size, - ) - except Exception: - pass # fall through to the portable splat + try: + return add_anisotropic_mps_var( + real_space_grid, density_map, xyz, u, occ, A, B, + inv_frac_matrix, frac_matrix, radius_per_atom, voxel_size, + ) + except Exception: + if get_engine() is Engine.METAL: + raise + # AUTO: fall through to the portable splat return add_anisotropic_plain_var( real_space_grid, density_map, xyz, u, occ, A, B, inv_frac_matrix, frac_matrix, radius_per_atom, voxel_size, diff --git a/torchref/base/targets/xray_ml_sigmaa.py b/torchref/base/targets/xray_ml_sigmaa.py index f08e306..560f9c1 100644 --- a/torchref/base/targets/xray_ml_sigmaa.py +++ b/torchref/base/targets/xray_ml_sigmaa.py @@ -112,6 +112,10 @@ def epsilon_from_hkl(hkl: torch.Tensor, spacegroup) -> torch.Tensor: Mirrors ``ReciprocalSymmetry.get_epsilon`` (Friedel-aware) but works directly on the scattered HKL list. Returns ones if ``spacegroup`` is None or lacks ``apply_to_hkl``. + + Always returns on ``hkl.device``, whatever device the space group's symmetry + matrices live on: the caller multiplies this against per-reflection data + sitting beside ``hkl``. """ n = hkl.shape[0] float_dtype = get_float_dtype() @@ -123,13 +127,19 @@ def epsilon_from_hkl(hkl: torch.Tensor, spacegroup) -> torch.Tensor: # there raises. Symmetry arithmetic on Miller indices is exact in # float32 (integer-valued rotation matrices, small indices), so the # exact `==` comparisons below remain valid. - h = hkl.to(float_dtype) + # + # ``apply_to_hkl`` moves its input onto the matrices' device, so build + # ``h`` there too -- otherwise ``Hs`` and ``h0`` land on different + # devices and the comparisons below raise. The space group wins for the + # arithmetic; the result is handed back on the caller's device. + sym_device = getattr(spacegroup, "matrices", hkl).device + h = hkl.to(device=sym_device, dtype=float_dtype) Hs = spacegroup.apply_to_hkl(h) # (N,3,ops) h0 = h.unsqueeze(-1) # (N,3,1) same = (Hs == h0).all(dim=1) friedel = (Hs == -h0).all(dim=1) eps = (same | friedel).sum(dim=1).clamp(min=1).to(float_dtype) - return eps + return eps.to(hkl.device) # ===================================================================== diff --git a/torchref/io/datasets/reflection_data.py b/torchref/io/datasets/reflection_data.py index 006f0da..1daebeb 100644 --- a/torchref/io/datasets/reflection_data.py +++ b/torchref/io/datasets/reflection_data.py @@ -424,12 +424,21 @@ def _reindex_per_reflection( name = f.name if name == "hkl" or name in derived: continue + # Declared non-per-reflection fields are exempt by *name*, not by + # shape. The shape test below is a heuristic and collides whenever + # n_src equals the field's own length -- ``U_aniso`` is (6,), so a + # 6-reflection dataset would have it gathered as if it were + # per-reflection. ``_assert_per_reflection_consistent`` and + # ``reduce_to_spacegroup`` already exempt by name; this keeps all + # three routines consistent. + if name in self._NON_PER_REFLECTION_TENSORS: + continue val = getattr(self, name) if not isinstance(val, torch.Tensor): continue if not (val.shape and val.shape[0] == n_src): - # Non-per-reflection tensor (e.g. U_aniso (6,)): leave target's - # own value untouched (fresh default when target is new). + # Non-per-reflection tensor: leave target's own value untouched + # (a fresh default when target is a new instance). continue if name == "hkl_anomalous": # Present rows keep their signed (anomalous) index; missing rows @@ -2233,7 +2242,12 @@ def __select__(self, indices: torch.Tensor, op=None) -> "ReflectionData": if val is None: continue if isinstance(val, torch.Tensor): - if val.shape and val.shape[0] == n_refl: + # Exempt by name first: the shape test is a heuristic that + # collides when n_refl equals the field's own length (see + # ``_reindex_per_reflection``). + if f.name in self._NON_PER_REFLECTION_TENSORS: + setattr(selected, f.name, val.clone()) + elif val.shape and val.shape[0] == n_refl: setattr(selected, f.name, val[indices]) else: # Non-matching tensor (e.g. U_aniso shape (6,)): copy as-is diff --git a/torchref/utils/__init__.py b/torchref/utils/__init__.py index 03db106..184d2da 100644 --- a/torchref/utils/__init__.py +++ b/torchref/utils/__init__.py @@ -76,6 +76,7 @@ class MyRefinement(DebugMixin): Engine, get_engine, set_engine, + should_use_metal, should_use_triton, triton_available, use_engine, @@ -132,4 +133,5 @@ class MyRefinement(DebugMixin): "use_engine", "triton_available", "should_use_triton", + "should_use_metal", ] diff --git a/torchref/utils/triton_dispatch.py b/torchref/utils/triton_dispatch.py index f6d2dc2..e885352 100644 --- a/torchref/utils/triton_dispatch.py +++ b/torchref/utils/triton_dispatch.py @@ -1,4 +1,4 @@ -"""Shared Triton/eager backend dispatch for TorchRef. +"""Shared accelerator/eager backend dispatch for TorchRef. A single capability-based selector used by every dispatch site (direct summation, geometry/X-ray targets, FFT electron density). For a given @@ -6,7 +6,8 @@ *derived* rather than configured: - CUDA + float32 + Triton available -> Triton kernel -- everything else (CPU, float64, MPS, no Triton) -> eager fallback +- MPS + float32 + shader compiles -> native Metal kernel (density splat only) +- everything else (CPU, float64, no Triton) -> eager fallback The :class:`Engine` enum exists only to *force* a path for tests and benchmarks. There is **no environment-variable dispatch** — selection is via @@ -43,19 +44,29 @@ "use_engine", "triton_available", "should_use_triton", + "should_use_metal", ] class Engine(enum.Enum): - """Backend selector shared by all Triton dispatch sites. - - ``AUTO`` derives the backend from device/dtype/availability. ``TRITON`` - and ``EAGER`` force a path; ``TRITON`` raises if CUDA+float32+availability - is not met (force = strict, never silently degrade). + """Backend selector shared by all accelerator dispatch sites. + + ``AUTO`` derives the backend from device/dtype/availability. ``TRITON``, + ``METAL`` and ``EAGER`` force a path; the two accelerator forces are + strict and never silently degrade -- ``TRITON`` raises if + CUDA+float32+availability is not met, ``METAL`` if MPS+float32+a compiled + shader is not met. + + ``METAL`` selects the native Metal *density splat* only; there are no + Metal direct-summation or target kernels, so those sites treat it as + eager. Prefer scoping it with :func:`use_engine` around the density call + over a process-wide :func:`set_engine`, which would also send every target + math function down the eager path and skew a benchmark. """ AUTO = "auto" TRITON = "triton" + METAL = "metal" EAGER = "eager" @@ -131,9 +142,19 @@ def should_use_triton(*tensors: torch.Tensor, engine: Optional[Engine] = None) - RuntimeError If ``engine`` is ``Engine.TRITON`` but the inputs are not CUDA float32, or Triton is unavailable. + ValueError + If ``engine`` is a member this function does not handle. Deliberately + loud: the AUTO case used to be an implicit ``else``, so *any* new + member silently selected Triton. + + Notes + ----- + ``Engine.METAL`` returns False here rather than raising -- it forces the + Metal density splat (see :func:`should_use_metal`), and every other + dispatch site correctly runs eager under it. """ eng = engine if engine is not None else get_engine() - if eng is Engine.EAGER: + if eng is Engine.EAGER or eng is Engine.METAL: return False cuda_f32 = all( @@ -149,5 +170,88 @@ def should_use_triton(*tensors: torch.Tensor, engine: Optional[Engine] = None) - raise RuntimeError("Triton is not available") return True - # AUTO - return cuda_f32 and triton_available() + if eng is Engine.AUTO: + return cuda_f32 and triton_available() + + raise ValueError(f"should_use_triton: unhandled engine {eng!r}") + + +def should_use_metal(*tensors: torch.Tensor, engine: Optional[Engine] = None) -> bool: + """Metal-vs-portable gate for the MPS electron-density splat. + + The Metal counterpart of :func:`should_use_triton`, and the sole decision + point for the Metal path: it probes device, dtype **and** shader + availability together. Folding availability in here rather than leaving it + as a nested ``if`` at the dispatch site is what makes ``Engine.METAL`` + genuinely strict -- otherwise an uncompiled shader under a forced engine + would fall past the gate onto the portable splat, silently degrading the + very thing the caller asked to force. + + Parameters + ---------- + *tensors : torch.Tensor + Tensors whose device/dtype gate the Metal path. ``None`` entries are + ignored. + engine : Engine, optional + Per-call override. Defaults to the process-wide engine. + + Returns + ------- + bool + True if the Metal kernels should be used. + + Raises + ------ + RuntimeError + If ``engine`` is ``Engine.METAL`` but the inputs are not MPS float32, + or the shader library is unavailable. The latter message carries the + recorded compile error, since that is the one failure a user can act on + (torch < 2.9, or an older Metal rejecting the MSL). + """ + eng = engine if engine is not None else get_engine() + # EAGER means portable everywhere. TRITON has already raised at the Triton + # gate on any MPS input, so reaching here under it means a non-MPS host. + if eng is Engine.EAGER or eng is Engine.TRITON: + return False + if eng is not Engine.AUTO and eng is not Engine.METAL: + raise ValueError(f"should_use_metal: unhandled engine {eng!r}") + + mps_f32 = all( + t is None or (t.device.type == "mps" and t.dtype is torch.float32) + for t in tensors + ) + if not mps_f32: + if eng is Engine.METAL: + raise RuntimeError( + "engine=Engine.METAL requires MPS float32 inputs" + ) + return False + + # Imported lazily and only once the cheap checks pass: this module is + # imported very early via ``torchref.utils``, and pulling in the mps + # package eagerly would load the MSL source on every platform. + try: + from torchref.base.electron_density.kernels.mps.compile import ( + last_error, + mps_kernels_available, + ) + except Exception: + # A stripped install or a torch without ``torch.mps`` must not make a + # predicate raise under AUTO. + if eng is Engine.METAL: + raise + return False + + if mps_kernels_available(): + return True + + if eng is Engine.METAL: + err = last_error() + reason = err[0] if err else "unknown reason" + raise RuntimeError( + f"engine=Engine.METAL requested but the Metal splat kernels are " + f"not available ({reason}). See " + "torchref.base.electron_density.kernels.mps.compile._lib_error " + "for the full traceback." + ) + return False From 7d2b0f00555585babadab496e1e151ea4852eb4f Mon Sep 17 00:00:00 2001 From: HatPdotS Date: Wed, 29 Jul 2026 14:57:31 +0900 Subject: [PATCH 08/15] Added standardized SF tests DS is the oracle, confirmed against gemmi and than used to confirm the the respective kernels. --- tests/conftest.py | 31 + tests/helpers/grad_asserts.py | 64 ++ tests/integration/test_ds_triton_vs_eager.py | 1 - .../integration/test_dtype_config_float64.py | 16 - tests/unit/base/test_canonical_sphere_cpu.py | 377 ++++++++ tests/unit/base/test_ds_dispatch.py | 43 - tests/unit/scattering/test_anomalous.py | 69 +- tests/unit/sf_oracle/__init__.py | 235 +++++ tests/unit/sf_oracle/conftest.py | 213 +++++ tests/unit/sf_oracle/helpers.py | 543 ++++++++++++ tests/unit/sf_oracle/test_forward.py | 389 +++++++++ tests/unit/sf_oracle/test_gradients.py | 294 +++++++ tests/unit/sf_oracle/test_second_order.py | 339 ++++++++ tests/unit/symmetry/test_phase_convention.py | 233 +++++ tests/unit/test_gradient_correctness.py | 172 ---- tests/unit/test_kernel_fixes.py | 189 ---- .../kernels/cpu/_cpp_build.py | 133 +++ .../electron_density/kernels/cpu/scatter.py | 105 +-- .../kernels/cpu/sphere_splat.py | 807 ++++++++++++++++++ .../kernels/cpu/variable_radius.py | 237 +++-- .../kernels/mps/variable_radius.py | 37 +- torchref/base/electron_density/main.py | 129 +-- torchref/symmetry/reciprocal_symmetry.py | 44 +- 23 files changed, 4028 insertions(+), 672 deletions(-) create mode 100644 tests/unit/base/test_canonical_sphere_cpu.py create mode 100644 tests/unit/sf_oracle/__init__.py create mode 100644 tests/unit/sf_oracle/conftest.py create mode 100644 tests/unit/sf_oracle/helpers.py create mode 100644 tests/unit/sf_oracle/test_forward.py create mode 100644 tests/unit/sf_oracle/test_gradients.py create mode 100644 tests/unit/sf_oracle/test_second_order.py create mode 100644 tests/unit/symmetry/test_phase_convention.py create mode 100644 torchref/base/electron_density/kernels/cpu/_cpp_build.py create mode 100644 torchref/base/electron_density/kernels/cpu/sphere_splat.py diff --git a/tests/conftest.py b/tests/conftest.py index e12270d..84715a8 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -606,3 +606,34 @@ def model_with_restraints(loaded_model): ) restraints.build_restraints() return {"model": loaded_model, "restraints": restraints} + +@pytest.fixture +def double_cpu(): + """float64/complex128 on CPU for the duration of a test; restore afterwards. + + Required rather than cosmetic for anything touching eager structure factors: + ``iso_structure_factor_torched`` casts ``hkl`` to the *global* ``dtypes.float`` + (``torchref/base/direct_summation/isotropic.py:121``), so under the default float32 + config a float64 leaf produces a dtype-mismatched matmul. + + Promoted here from three byte-similar copies in ``tests/unit/test_kernel_fixes.py``, + ``tests/unit/test_gradient_correctness.py`` and + ``tests/integration/test_dtype_config_float64.py``. This version also restores + ``sigma_cutoff_ed``, which none of those did -- so a test that changed the cutoff + leaked it into everything that ran afterwards. + """ + import torchref + from torchref.config import device as _device, dtypes as _dtypes + + f0, c0, d0 = _dtypes.float, _dtypes.complex, _device.current + s0 = torchref.sigma_cutoff_ed.value + _dtypes.float = torch.float64 + _dtypes.complex = torch.complex128 + _device.current = torch.device("cpu") + try: + yield + finally: + _dtypes.float = f0 + _dtypes.complex = c0 + _device.current = d0 + torchref.sigma_cutoff_ed.value = s0 diff --git a/tests/helpers/grad_asserts.py b/tests/helpers/grad_asserts.py index 3f2f2e5..f61eca9 100644 --- a/tests/helpers/grad_asserts.py +++ b/tests/helpers/grad_asserts.py @@ -19,6 +19,10 @@ "gradnorm_ratio", "assert_grads_agree", "got_ref_pairs", + "rel_error", + "central_fd_grad", + "hvp", + "hvp_central_fd", ] @@ -67,3 +71,63 @@ def assert_grads_agree(got, ref, *, min_cos=0.9999, ratio_tol=1e-3, ctx=""): assert ( abs(ratio - 1.0) <= ratio_tol ), f"{ctx}{name}: gradnorm ratio {ratio:.8f} off 1 by > {ratio_tol}" + + +def rel_error(a: torch.Tensor, b: torch.Tensor) -> float: + """Relative L2 error ``||a - b|| / ||b||`` in float64, device-agnostic.""" + fa, fb = _flat_real(a), _flat_real(b) + return ((fa - fb).norm() / fb.norm()).item() + + +# --------------------------------------------------------------------------- +# Finite differences and Hessian-vector products +# --------------------------------------------------------------------------- +# Promoted here from three divergent private copies -- ``test_gradient_correctness.py`` +# (``_central_fd_grad``), ``test_triton_vs_eager_targets.py`` (``_hvp_vs_fd``) and +# ``test_kernel_fixes.py`` (inline, twice). They disagreed on eps and on whether to +# detach, which made their tolerances incomparable. +# +# Caveat on where these are valid: a central difference is only a sound reference for a +# smooth function. The electron-density map route is hard-truncated at a per-atom radius +# and the cull surface moves with the atom, so a voxel crossing it contributes a fixed +# jump to the difference. Use finite differences against direct summation, which is +# analytic, and use direct summation as the reference for the map route. +def central_fd_grad(loss_fn, x0: torch.Tensor, eps: float) -> torch.Tensor: + """Central-difference gradient of a scalar ``loss_fn(x)`` at ``x0``. + + Costs ``2 * x0.numel()`` forward evaluations, so keep ``x0`` small. + """ + flat = x0.detach().reshape(-1).clone() + out = torch.zeros_like(flat) + for i in range(flat.numel()): + plus, minus = flat.clone(), flat.clone() + plus[i] += eps + minus[i] -= eps + with torch.no_grad(): + hi = loss_fn(plus.view_as(x0)) + lo = loss_fn(minus.view_as(x0)) + out[i] = (hi - lo) / (2.0 * eps) + return out.view_as(x0) + + +def hvp(loss_fn, x0: torch.Tensor, v: torch.Tensor) -> torch.Tensor: + """Hessian-vector product by double backward: ``d/dx (grad(loss) . v)``.""" + x = x0.detach().clone().requires_grad_(True) + (g1,) = torch.autograd.grad(loss_fn(x), x, create_graph=True) + (out,) = torch.autograd.grad((g1 * v).sum(), x) + return out.detach() + + +def hvp_central_fd(loss_fn, x0: torch.Tensor, v: torch.Tensor, eps: float): + """Hessian-vector product by central-differencing the *gradient* along ``v``. + + Two gradient evaluations rather than ``2 * numel`` forwards, so this stays cheap even + on realistic scenes. + """ + + def grad_at(xv): + x = xv.detach().clone().requires_grad_(True) + (g,) = torch.autograd.grad(loss_fn(x), x) + return g.detach() + + return (grad_at(x0 + eps * v) - grad_at(x0 - eps * v)) / (2.0 * eps) diff --git a/tests/integration/test_ds_triton_vs_eager.py b/tests/integration/test_ds_triton_vs_eager.py index 96a7c47..2e0fda4 100644 --- a/tests/integration/test_ds_triton_vs_eager.py +++ b/tests/integration/test_ds_triton_vs_eager.py @@ -13,7 +13,6 @@ pytestmark = [pytest.mark.cuda, pytest.mark.integration] -_ATOL = 1e-2 _RTOL = 1e-3 diff --git a/tests/integration/test_dtype_config_float64.py b/tests/integration/test_dtype_config_float64.py index 02c029e..942cca3 100644 --- a/tests/integration/test_dtype_config_float64.py +++ b/tests/integration/test_dtype_config_float64.py @@ -10,22 +10,6 @@ import pytest import torch -from torchref.config import device, dtypes - - -@pytest.fixture -def double_cpu(): - """float64/complex128 on CPU for the duration of a test; restore after.""" - f0, c0, d0 = dtypes.float, dtypes.complex, device.current - dtypes.float = torch.float64 - dtypes.complex = torch.complex128 - device.current = torch.device("cpu") - try: - yield - finally: - dtypes.float = f0 - dtypes.complex = c0 - device.current = d0 @pytest.mark.unit diff --git a/tests/unit/base/test_canonical_sphere_cpu.py b/tests/unit/base/test_canonical_sphere_cpu.py new file mode 100644 index 0000000..22a3f0c --- /dev/null +++ b/tests/unit/base/test_canonical_sphere_cpu.py @@ -0,0 +1,377 @@ +"""One truncation contract, checked on CPU with no accelerator required. + +Every production density kernel truncates each atom at the same spherical cutoff: + + voxel v gets atom i's density iff ||w||^2 <= r_i^2, with w the minimum-image + Cartesian atom->voxel vector (sphere centred on the ATOM, not on its anchor + node) and r_i the raw radius_policy radius, + +enumerated over the per-axis box ceil(r * n_axis * ||inv_frac row_axis||). This file +pins that contract down where it can actually run in CI on a laptop. + +Why this file exists +-------------------- +Until now every variable-radius cross-check was gated on hardware -- +``test_variable_radius_gpu.py`` needs CUDA, ``test_variable_radius_mps.py`` needs +Apple silicon -- so on a CPU-only machine *nothing* tested the splat geometry. That +is how three different cutoffs came to coexist: the fast CPU path splatted a cube, +the portable CPU path used a node-centred diagonal metric at a voxel-rounded +radius, and Metal inflated its radius to a whole voxel. On a beta=115 deg cell those +differed by ~5e-3 rel L2 -- more than the 1.7e-3 truncation error the cutoff exists +to deliver -- and the MPS test absorbed it in a 2e-2 tolerance. + +The reference here is a direct O(atoms x voxels) evaluation of the contract as +written above (``_brute_iso`` / ``_brute_aniso``), not another kernel: comparing two +kernels to each other cannot catch a shared misreading of the geometry. + +Non-orthogonal cells are the point. An orthogonal cell hides a diagonal-metric +error entirely, so every geometry assertion runs at beta = 90, 100 and 115 degrees. +""" + +import math + +import pytest +import torch + +from torchref.base.electron_density.kernels.cpu import sphere_splat +from torchref.base.electron_density.kernels.cpu.variable_radius import ( + add_anisotropic_plain_var, + add_isotropic_plain_var, +) +from torchref.base.electron_density.main import build_electron_density +from torchref.base.electron_density.radius_policy import ( + per_atom_radius_aniso, + per_atom_radius_iso, +) +from torchref.base.scattering.scattering_table import get_scattering_params_by_z +from torchref.utils import Engine, use_engine + +pytestmark = pytest.mark.unit + +# float32 agreement floor. The fused kernel uses a fast 2^x exp (the CPU analogue of +# the metal::fast::exp the Metal kernels already use), measured at 2e-5 rel L2 +# against std::exp; the portable splat uses torch.exp. Both are far below the 7.9e-4 +# amplitude-truncation error at the default 3 sigma, so this tolerance bounds +# arithmetic noise while still failing on any geometry disagreement (the smallest of +# which, node- vs atom-centring on an orthogonal cell, is 9.9e-4). +_F32_TOL = 2e-4 +_BETAS = (90.0, 100.0, 115.0) + + +def _cell(beta_deg, dtype=torch.float32, dims=(48, 40, 34), abc=(28.0, 24.0, 20.0)): + """Monoclinic-family P1 cell; beta=90 degenerates to orthorhombic.""" + a, b, c = abc + beta = math.radians(beta_deg) + f64 = torch.tensor( + [[a, 0.0, c * math.cos(beta)], + [0.0, b, 0.0], + [0.0, 0.0, c * math.sin(beta)]], dtype=torch.float64) + return f64.to(dtype), torch.linalg.inv(f64).to(dtype), dims, f64 + + +def _voxel_size(f64, dims): + """``voxel_size`` as ``sf_fft`` derives it; unused by the kernels, still in the + ``build_electron_density`` signature.""" + return (f64.norm(dim=0) / torch.tensor(dims, dtype=torch.float64)).float() + + +def _iso_atoms(f64, n=36, dtype=torch.float32, seed=0): + g = torch.Generator().manual_seed(seed) + z = torch.tensor([6, 7, 8, 16]).repeat(n // 4 + 1)[:n] + A, B = get_scattering_params_by_z(z, dtype=dtype) + xyz = (torch.rand(n, 3, generator=g, dtype=torch.float64) @ f64.T).to(dtype) + adp = (torch.rand(n, generator=g) * 35 + 8).to(dtype) + # never exactly 1.0: the kernels recover d/d_occ by dividing the accumulated + # gradient by occ, and at occ == 1 a wrong scaling is invisible + occ = (torch.rand(n, generator=g) * 0.4 + 0.6).to(dtype) + return xyz, adp, occ, A, B + + +def _aniso_atoms(f64, n=24, dtype=torch.float32, seed=1): + g = torch.Generator().manual_seed(seed) + z = torch.tensor([6, 7, 8, 16]).repeat(n // 4 + 1)[:n] + A, B = get_scattering_params_by_z(z, dtype=dtype) + xyz = (torch.rand(n, 3, generator=g, dtype=torch.float64) @ f64.T).to(dtype) + u = torch.zeros(n, 6) + u[:, :3] = torch.rand(n, 3, generator=g) * 0.12 + 0.02 + # signed, non-zero off-diagonals: the p01/p02/p12 entries of the analytically + # inverted 3x3 and the 4*pi^2 off-diagonal U gradients are dead code without them + u[:, 3:] = (torch.rand(n, 3, generator=g) - 0.5) * 0.02 + occ = (torch.rand(n, generator=g) * 0.4 + 0.6).to(dtype) + return xyz, u.to(dtype), occ, A, B + + +def _frac_grid(dims, dtype): + """Voxel fractional coordinates, computed *in* ``dtype``. + + ``torch.arange(n) / n`` divides an integer tensor by a Python int, which lands in + the default float32 and would cap a float64 reference's accuracy at ~1e-7. + """ + ii, jj, kk = torch.meshgrid( + *[torch.arange(n, dtype=dtype) for n in dims], indexing="ij") + return torch.stack([ii / dims[0], jj / dims[1], kk / dims[2]], -1).reshape(-1, 3) + + +def _brute_iso(dims, xyz, adp, occ, A, B, inv_frac, frac, r): + """Direct evaluation of the canonical contract, atom by atom.""" + dtype = xyz.dtype + fc = _frac_grid(dims, dtype) + out = torch.zeros(dims[0] * dims[1] * dims[2], dtype=dtype) + xyz_frac = xyz @ inv_frac.T + B_total = ((B + adp[:, None]) * 0.25).clamp(min=0.1) + A_norm = A * occ[:, None] * (math.pi ** 1.5) / (B_total * torch.sqrt(B_total)) + for i in range(xyz.shape[0]): + d = fc - (xyz_frac[i] % 1.0) + d = d - torch.round(d) # minimum image + w = d @ frac.T + r2 = (w * w).sum(-1) + m = r2 <= r[i] * r[i] + e = torch.exp(-(math.pi ** 2) * r2[m][:, None] / B_total[i][None, :]) + out[m] = out[m] + (e * A_norm[i][None, :]).sum(-1) + return out.view(*dims) + + +def _brute_aniso(dims, xyz, u, occ, A, B, inv_frac, frac, r): + """As ``_brute_iso``, with the Mahalanobis density and a Euclidean cutoff.""" + dtype = xyz.dtype + fc = _frac_grid(dims, dtype) + out = torch.zeros(dims[0] * dims[1] * dims[2], dtype=dtype) + xyz_frac = xyz @ inv_frac.T + U3 = torch.zeros(u.shape[0], 3, 3, dtype=dtype) + U3[:, 0, 0], U3[:, 1, 1], U3[:, 2, 2] = u[:, 0], u[:, 1], u[:, 2] + U3[:, 0, 1] = U3[:, 1, 0] = u[:, 3] + U3[:, 0, 2] = U3[:, 2, 0] = u[:, 4] + U3[:, 1, 2] = U3[:, 2, 1] = u[:, 5] + M = (B[:, :, None, None] * torch.eye(3, dtype=dtype) + + 8 * math.pi ** 2 * U3[:, None]) / 4.0 + Minv = torch.linalg.inv(M) + A_norm = (A * occ[:, None] * (math.pi ** 1.5) + / torch.sqrt(torch.linalg.det(M).clamp(min=1e-10))) + for i in range(xyz.shape[0]): + d = fc - (xyz_frac[i] % 1.0) + d = d - torch.round(d) + w = d @ frac.T + m = (w * w).sum(-1) <= r[i] * r[i] # cutoff is the Euclidean sphere + ww = w[m] + q = torch.einsum("vi,gij,vj->vg", ww, Minv[i], ww) + out[m] = out[m] + (A_norm[i][None, :] * torch.exp(-(math.pi ** 2) * q)).sum(-1) + return out.view(*dims) + + +def _rel_l2(x, y): + return float((x - y).double().norm() / y.double().norm()) + + +def _empty_iso(dtype): + """Zero-length isotropic arguments, for an aniso-only structure.""" + return (torch.zeros(0, 3, dtype=dtype), torch.zeros(0, dtype=dtype), + torch.zeros(0, dtype=dtype), torch.zeros(0, 5, dtype=dtype), + torch.zeros(0, 5, dtype=dtype)) + + +# =========================================================================== +# The contract: each kernel vs a direct evaluation of the spec +# =========================================================================== + +@pytest.mark.parametrize("beta", _BETAS) +def test_fused_iso_matches_contract(beta): + if not sphere_splat.sphere_splat_available(): + pytest.skip(f"fused CPU splat unavailable: {sphere_splat.last_error()}") + frac, inv_frac, dims, f64 = _cell(beta) + xyz, adp, occ, A, B = _iso_atoms(f64) + r = per_atom_radius_iso(adp, B, n_sigma=3.0) + got = sphere_splat.add_isotropic_cpu_sphere_var( + torch.zeros(dims), xyz, adp, occ, A, B, inv_frac, frac, r) + assert _rel_l2(got, _brute_iso(dims, xyz, adp, occ, A, B, inv_frac, frac, r)) < _F32_TOL + + +@pytest.mark.parametrize("beta", _BETAS) +def test_fused_aniso_matches_contract(beta): + if not sphere_splat.sphere_splat_available(): + pytest.skip(f"fused CPU splat unavailable: {sphere_splat.last_error()}") + frac, inv_frac, dims, f64 = _cell(beta) + xyz, u, occ, A, B = _aniso_atoms(f64) + r = per_atom_radius_aniso(B, u, n_sigma=3.0) + got = sphere_splat.add_anisotropic_cpu_sphere_var( + torch.zeros(dims), xyz, u, occ, A, B, inv_frac, frac, r) + assert _rel_l2(got, _brute_aniso(dims, xyz, u, occ, A, B, inv_frac, frac, r)) < _F32_TOL + + +@pytest.mark.parametrize("beta", _BETAS) +def test_portable_iso_matches_contract(beta): + """The portable splat used a node-centred diagonal metric at a voxel-rounded + radius; on beta=115 that alone was 5e-3 rel L2.""" + frac, inv_frac, dims, f64 = _cell(beta) + xyz, adp, occ, A, B = _iso_atoms(f64) + r = per_atom_radius_iso(adp, B, n_sigma=3.0) + got = add_isotropic_plain_var( + torch.zeros(dims), xyz, adp, occ, A, B, inv_frac, frac, r) + assert _rel_l2(got, _brute_iso(dims, xyz, adp, occ, A, B, inv_frac, frac, r)) < _F32_TOL + + +@pytest.mark.parametrize("beta", _BETAS) +def test_portable_aniso_matches_contract(beta): + """The portable aniso splat used a full cube -- ~2.3x the sphere's voxels.""" + frac, inv_frac, dims, f64 = _cell(beta) + xyz, u, occ, A, B = _aniso_atoms(f64) + r = per_atom_radius_aniso(B, u, n_sigma=3.0) + got = add_anisotropic_plain_var( + torch.zeros(dims), xyz, u, occ, A, B, inv_frac, frac, r) + assert _rel_l2(got, _brute_aniso(dims, xyz, u, occ, A, B, inv_frac, frac, r)) < _F32_TOL + + +def test_fused_float64_is_exact(): + """float64 uses std::exp, so only fp rounding separates it from the reference.""" + if not sphere_splat.sphere_splat_available(): + pytest.skip("fused CPU splat unavailable") + frac, inv_frac, dims, f64 = _cell(100.0, dtype=torch.float64, dims=(32, 28, 24)) + xyz, adp, occ, A, B = _iso_atoms(f64, dtype=torch.float64) + r = per_atom_radius_iso(adp, B, n_sigma=3.0) + got = sphere_splat.add_isotropic_cpu_sphere_var( + torch.zeros(dims, dtype=torch.float64), xyz, adp, occ, A, B, inv_frac, frac, r) + want = _brute_iso(dims, xyz, adp, occ, A, B, inv_frac, frac, r) + assert _rel_l2(got, want) < 1e-13 + + +# =========================================================================== +# AUTO vs EAGER through the real dispatch: no accelerator needed +# =========================================================================== + +def _build(engine, dims, frac, inv_frac, voxel, dtype, iso=None, aniso=None): + rsg = torch.zeros(*dims, 3, dtype=dtype) # shape only; no kernel reads its values + xi, ai, oi, Ai, Bi = iso if iso is not None else _empty_iso(dtype) + kw = {} + if aniso is not None: + xa, ua, oa, Aa, Ba = aniso + kw = dict(xyz_aniso=xa, u_aniso=ua, occ_aniso=oa, A_aniso=Aa, B_aniso=Ba) + with use_engine(engine): + return build_electron_density( + rsg, xi, ai, oi, Ai, Bi, inv_frac, frac, voxel, dtype=dtype, **kw) + + +@pytest.mark.parametrize("beta", _BETAS) +@pytest.mark.parametrize("dtype", [torch.float32, torch.float64], + ids=["float32", "float64"]) +def test_auto_matches_eager_iso(beta, dtype): + frac, inv_frac, dims, f64 = _cell(beta, dtype=dtype) + voxel = _voxel_size(f64, dims) + atoms = _iso_atoms(f64, dtype=dtype) + ref = _build(Engine.EAGER, dims, frac, inv_frac, voxel, dtype, iso=atoms) + got = _build(Engine.AUTO, dims, frac, inv_frac, voxel, dtype, iso=atoms) + tol = _F32_TOL if dtype is torch.float32 else 1e-12 + assert _rel_l2(got, ref) < tol + + +@pytest.mark.parametrize("beta", _BETAS) +def test_auto_matches_eager_aniso(beta): + frac, inv_frac, dims, f64 = _cell(beta) + voxel = _voxel_size(f64, dims) + atoms = _aniso_atoms(f64) + ref = _build(Engine.EAGER, dims, frac, inv_frac, voxel, torch.float32, aniso=atoms) + got = _build(Engine.AUTO, dims, frac, inv_frac, voxel, torch.float32, aniso=atoms) + assert _rel_l2(got, ref) < _F32_TOL + + +@pytest.mark.parametrize("kind", ["iso", "aniso"]) +def test_auto_matches_eager_gradients(kind): + """Direction *and* magnitude: a kernel returning ``2 * grad`` is perfectly + parallel, so cosine alone cannot catch it.""" + frac, inv_frac, dims, f64 = _cell(100.0, dtype=torch.float64, dims=(32, 28, 24)) + voxel = _voxel_size(f64, dims) + w = torch.randn(dims, generator=torch.Generator().manual_seed(7), + dtype=torch.float64) + if kind == "iso": + xyz, p, occ, A, B = _iso_atoms(f64, dtype=torch.float64) + else: + xyz, p, occ, A, B = _aniso_atoms(f64, dtype=torch.float64) + + def grads(engine): + x, pp, o = (t.clone().requires_grad_() for t in (xyz, p, occ)) + pack = (x, pp, o, A, B) + dm = _build(engine, dims, frac, inv_frac, voxel, torch.float64, + **({"iso": pack} if kind == "iso" else {"aniso": pack})) + (dm * w).sum().backward() + return x.grad, pp.grad, o.grad + + for g_auto, g_eager in zip(grads(Engine.AUTO), grads(Engine.EAGER)): + assert torch.allclose(g_auto, g_eager, rtol=1e-9, atol=0) + + +def test_cutoff_is_grid_independent(): + """The cutoff must not be requantized to the grid. + + Metal used to round the radius up to a whole voxel, which made the *same* + ``sigma_cutoff_ed`` truncate at different radii on different grid samplings. With + the radius used raw, refining the grid must converge the map rather than move the + cutoff: the total mass a coarse and a fine grid assign is set by the same sphere. + """ + frac, inv_frac, _, f64 = _cell(100.0) + xyz, adp, occ, A, B = _iso_atoms(f64, n=24) + r = per_atom_radius_iso(adp, B, n_sigma=3.0) + masses = [] + for dims in ((40, 34, 28), (60, 51, 42)): + dm = add_isotropic_plain_var( + torch.zeros(dims), xyz, adp, occ, A, B, inv_frac, frac, r) + cell_vol = float(torch.linalg.det(f64)) + masses.append(float(dm.double().sum()) * cell_vol / (dims[0] * dims[1] * dims[2])) + # Same truncation sphere, so the integrated mass agrees to grid-sampling error. + assert abs(masses[1] - masses[0]) / masses[0] < 5e-3 + + +@pytest.mark.parametrize("dtype", [torch.float32, torch.float64], + ids=["float32", "float64"]) +def test_auto_actually_dispatches_the_fused_kernel(dtype): + """Guard against the AUTO-vs-EAGER tests going vacuous. + + If ``should_use_sphere_splat`` ever stopped firing, AUTO would fall through to + the very same portable splat EAGER uses and every equivalence assertion above + would pass at ``rel_l2 == 0`` while measuring nothing -- the exact failure mode + ``test_variable_radius_mps.py`` documents for its own Metal gate. float64 is + parametrized because the old dispatch was float32-only, so a regression to a + dtype gate would be invisible on float32 alone. + + ``main`` is patched, not the kernel module: the dispatch site resolves the name + from its own module globals at import time. + """ + import torchref.base.electron_density.main as main_mod + + if not sphere_splat.sphere_splat_available(): + pytest.skip("fused CPU splat unavailable") + frac, inv_frac, dims, f64 = _cell(100.0, dtype=dtype) + calls = [] + real = main_mod.add_isotropic_cpu_sphere_var + + def recording(*args, **kwargs): + calls.append(1) + return real(*args, **kwargs) + + main_mod.add_isotropic_cpu_sphere_var = recording + try: + _build(Engine.AUTO, dims, frac, inv_frac, _voxel_size(f64, dims), dtype, + iso=_iso_atoms(f64, dtype=dtype)) + finally: + main_mod.add_isotropic_cpu_sphere_var = real + assert calls, f"Engine.AUTO did not reach the fused CPU splat for {dtype}" + + +def test_empty_atom_sets(): + """A structure with no isotropic (or no anisotropic) atoms must not crash.""" + frac, inv_frac, dims, f64 = _cell(90.0) + voxel = _voxel_size(f64, dims) + only_aniso = _build(Engine.AUTO, dims, frac, inv_frac, voxel, torch.float32, + aniso=_aniso_atoms(f64)) + assert torch.isfinite(only_aniso).all() and float(only_aniso.abs().sum()) > 0 + both_empty = _build(Engine.AUTO, dims, frac, inv_frac, voxel, torch.float32) + assert float(both_empty.abs().sum()) == 0.0 + + +def test_density_map_accumulates_not_overwrites(): + """Both passes add into one map, so the aniso pass must not clobber the iso one.""" + frac, inv_frac, dims, f64 = _cell(90.0) + voxel = _voxel_size(f64, dims) + iso, aniso = _iso_atoms(f64), _aniso_atoms(f64) + a = _build(Engine.AUTO, dims, frac, inv_frac, voxel, torch.float32, iso=iso) + b = _build(Engine.AUTO, dims, frac, inv_frac, voxel, torch.float32, aniso=aniso) + both = _build(Engine.AUTO, dims, frac, inv_frac, voxel, torch.float32, + iso=iso, aniso=aniso) + assert _rel_l2(both, a + b) < 1e-6 diff --git a/tests/unit/base/test_ds_dispatch.py b/tests/unit/base/test_ds_dispatch.py index 2416c4e..fe7de9d 100644 --- a/tests/unit/base/test_ds_dispatch.py +++ b/tests/unit/base/test_ds_dispatch.py @@ -49,41 +49,6 @@ def _leaves(N=4, seed=1, dtype=None): return xyz, occ, adp, U -def test_checkpointed_iso_matches_eager(): - hkl, s, _, A, B = _inputs() - xyz, occ, adp, _ = _leaves() - F = D._checkpointed_iso(hkl, s, xyz, occ, adp, A, B, max_memory_gb=None) - ((F.real**2 + 2 * F.imag).sum()).backward() - gck = (xyz.grad.clone(), occ.grad.clone(), adp.grad.clone()) - - xyz2, occ2, adp2, _ = _leaves() - Fe = iso_structure_factor_torched( - hkl=hkl, s=s, xyz_fractional=xyz2, occ=occ2, scattering_factors=None, - adp=adp2, spacegroup=_p1, max_memory_gb=2.0, A=A, B_coeff=B, - ) - ((Fe.real**2 + 2 * Fe.imag).sum()).backward() - assert torch.allclose(F, Fe, atol=_eager_atol, rtol=1e-3) - for g, ge in zip(gck, (xyz2.grad, occ2.grad, adp2.grad)): - assert torch.allclose(g, ge, atol=_eager_atol, rtol=1e-3) - - -def test_checkpointed_aniso_matches_eager(): - hkl, _, svec, A, B = _inputs() - xyz, occ, _, U = _leaves() - F = D._checkpointed_aniso(hkl, svec, xyz, occ, U, A, B, max_memory_gb=None) - ((F.real**2 + 2 * F.imag).sum()).backward() - gck = (xyz.grad.clone(), occ.grad.clone(), U.grad.clone()) - - xyz2, occ2, _, U2 = _leaves() - Fe = aniso_structure_factor_torched( - hkl=hkl, s_vector=svec, xyz_fractional=xyz2, occ=occ2, - scattering_factors=None, U=U2, spacegroup=_p1, max_memory_gb=2.0, - A=A, B_coeff=B, - ) - ((Fe.real**2 + 2 * Fe.imag).sum()).backward() - assert torch.allclose(F, Fe, atol=_eager_atol, rtol=1e-3) - for g, ge in zip(gck, (xyz2.grad, occ2.grad, U2.grad)): - assert torch.allclose(g, ge, atol=_eager_atol, rtol=1e-3) def test_checkpointed_chunking_is_exact(): @@ -100,14 +65,6 @@ def test_checkpointed_chunking_is_exact(): assert torch.allclose(F_full, F_chunk, rtol=1e-10, atol=1e-12) -def test_checkpointed_iso_gradcheck(): - hkl, s, _, A, B = _inputs(dtype=torch.float64) - xyz, occ, adp, _ = _leaves(dtype=torch.float64) - - def f(x, o, a): - return D._checkpointed_iso(hkl, s, x, o, a, A, B, max_memory_gb=1e-7) - - assert torch.autograd.gradcheck(f, (xyz, occ, adp), eps=1e-6, atol=1e-5) def test_empty_atoms_returns_zeros(): diff --git a/tests/unit/scattering/test_anomalous.py b/tests/unit/scattering/test_anomalous.py index 191e0dc..145f9b3 100644 --- a/tests/unit/scattering/test_anomalous.py +++ b/tests/unit/scattering/test_anomalous.py @@ -293,18 +293,65 @@ def test_friedel_pair_asymmetry(self, test_pdb_file): sf_plus = model.get_structure_factor(hkl, apply_anomalous=True, recalc=True) sf_minus = model.get_structure_factor(-hkl, apply_anomalous=True, recalc=True) - # With anomalous scattering, F(h) != F(-h)* (not complex conjugates) - # due to the imaginary f'' contribution - # Note: The difference might be small, but should exist - is_conjugate = torch.allclose(sf_plus, sf_minus.conj(), atol=1e-6) - - # If Fe contributes significant anomalous scattering, they should not be conjugates - # We check this indirectly - the cache should have anomalous scatterers + # Friedel's law is governed by f'', which is imaginary and does not change sign + # with h, so it makes F(h) != F(-h)*. f' is real and dispersive and leaves the + # conjugate relation intact. + # + # In this model f'' is gated on ``apply_bijvoet`` (``ModelFT.__init__``, applied at + # ``model_ft.py:951`` via ``include_fdp``), which defaults to False because merged + # data is the usual target and Friedel-preserving F is correct for it. So the + # default path deliberately does *not* break Friedel's law -- both branches are + # asserted here rather than only the one this test originally assumed. + # + # History: this test previously computed ``is_conjugate`` and then ended in + # ``pass``, asserting nothing. A first attempt to fix it asserted breakdown on the + # default path and failed, because that path is Friedel-preserving by design. mask, _, _, _, _ = model._get_anomalous_cache() - if mask.any(): - # There are anomalous scatterers, so Friedel pairs should differ - # (in principle - the actual difference depends on the positions) - pass # The test passes if no exception was raised + assert mask.any(), ( + "no anomalous scatterers in this structure, so neither branch below is " + "meaningful -- pick a structure with an anomalous element" + ) + + def asymmetry(fp, fm): + return float( + (fp - fm.conj()).abs().norm().detach() / fp.abs().norm().detach() + ) + + # --- default branch: f' only, Friedel's law preserved ------------------- + sf_plus_no = model.get_structure_factor(hkl, apply_anomalous=False, recalc=True) + floor = asymmetry(sf_plus_no, model.get_structure_factor( + -hkl, apply_anomalous=False, recalc=True)) + default_asym = asymmetry(sf_plus, sf_minus) + + assert not bool(model.anomalous_bijvoet), "fixture unexpectedly enabled f''" + assert default_asym < 10.0 * max(floor, 1e-9), ( + f"the default path broke Friedel's law ({default_asym:.3e} against a " + f"no-anomalous floor of {floor:.3e}). With apply_bijvoet=False, f'' is zeroed " + "and F(h) must stay conjugate to F(-h)" + ) + # ...but f' must still be reaching F, or "anomalous" is doing nothing at all. + dispersive = float( + (sf_plus - sf_plus_no).abs().norm().detach() / sf_plus_no.abs().norm().detach() + ) + assert dispersive > 1e-4, ( + f"apply_anomalous=True changed F by only {dispersive:.3e}, so the dispersive " + "f' term is not reaching the structure factors either" + ) + + # --- apply_bijvoet=True: f'' included, Friedel's law broken ------------- + bij = ModelFT( + wavelength=1.0, anomalous_threshold=0.5, apply_bijvoet=True, verbose=0 + ) + bij.load_pdb(test_pdb_file) + bij_asym = asymmetry( + bij.get_structure_factor(hkl, apply_anomalous=True, recalc=True), + bij.get_structure_factor(-hkl, apply_anomalous=True, recalc=True), + ) + assert bij_asym > 100.0 * max(floor, 1e-9), ( + f"with apply_bijvoet=True the Friedel asymmetry is only {bij_asym:.3e} " + f"against a floor of {floor:.3e}, so f'' is not reaching the structure " + "factors and Bijvoet differences would be absent" + ) def test_gradient_flow(self, test_pdb_file): """Test that gradients flow through anomalous correction.""" diff --git a/tests/unit/sf_oracle/__init__.py b/tests/unit/sf_oracle/__init__.py new file mode 100644 index 0000000..7f598b3 --- /dev/null +++ b/tests/unit/sf_oracle/__init__.py @@ -0,0 +1,235 @@ +"""Structure-factor correctness, gated against an oracle at every derivative order. + +Every accuracy gate in this package traces back to a reference that is *independent* +of the code under test. That is the whole point: before this package existed, the +density and SF coverage was + +* kernel-A-vs-kernel-B parity at ``rel < 2e-2`` (``test_variable_radius_{gpu,mps}.py``), + which cannot detect an error the two kernels share; +* splat-vs-brute-force-splat (``test_canonical_sphere_cpu.py``), which validates the + truncation *contract* rather than the physics; +* finite differences of the map route against itself, which is self-consistency. + +Nothing checked that the FFT route reproduces an analytic ``F(hkl)``, and nothing +checked its absolute scale at all -- every gate was ratio-, cosine- or parity-based, so +a factor-of-volume slip passed the entire suite. + +The chain +--------- +:: + + gemmi -> direct summation -> the FFT/splat route (amplitudes) + finite differences -> direct summation -> the FFT/splat route (grad, HVP) + +**Direct summation is the oracle.** It evaluates ``F(h) = sum_j occ_j f_j(s) DW_j +exp(2 pi i h.x_j)`` analytically -- no grid, no truncation radius -- so finite differences +are a valid reference for it (measured 1.19e-10). It is then an *independent* reference for +the map route, which is the part finite differences cannot supply: FD differentiates the +same discretized function the kernel does, so it confirms only that autograd is correct, +never that the kernel is. Measured, the map route's HVP agrees with itself to 5e-04 while +sitting 2.3e-02 from the analytic answer -- see +``test_second_order.py::test_finite_differences_cannot_detect_map_route_error``. + +**gemmi anchors the forward values.** It is a separate codebase, is a pip dependency +(so it runs in CI, unlike conda-only cctbx), and exposes no derivatives -- hence FD +remains the derivative link. + +What the gemmi comparison does and does not prove +------------------------------------------------- +torchref's ITC92 table was *generated from gemmi* +(``torchref/scripts/generate_scattering_table.py`` writes +``torchref/data/itc92_scattering_factors.pt``). Verified here: torchref stores gemmi's +four ITC92 Gaussians plus the constant ``c`` folded in as a fifth with ``B = 0``, and +``f(0)`` agrees to every printed digit. + +So ``f(s)`` is a **shared** input and cancels out of a DS-vs-gemmi comparison. That test +does **not** validate the form factors. What it does validate is everything layered on +top of them, which is where the error-prone conventions live: the ``2 pi`` factors, the +phase sign, Debye-Waller in both the B and U conventions, the +``[U11,U22,U33,U12,U13,U23]`` ordering, occupancy weighting, and -- in the non-P1 case -- +the symmetry algebra against a second implementation. + +Two consequences, both acted on: + +1. **The gate is tight, not loose.** With the coefficients shared there is no table + discrepancy to absorb. See the measured numbers on ``RTOL_VS_GEMMI`` below. +2. **The table needs its own check**, since the SF comparison cannot provide one. + ``test_forward.py::test_stored_table_matches_gemmi`` compares the stored ``.pt`` + against gemmi's live table element by element -- the one place gemmi *is* an + independent reference for ``f(s)``, and the only thing that would catch table drift, + a corrupted regeneration, or a gemmi release that changed coefficients. + +Why the oracle is ``_eager_*`` and never ``ds_iso``/``SfDS`` +------------------------------------------------------------ +``ds_iso``, ``ds_aniso`` and ``SfDS`` all route through ``_CheckpointedSF``, whose +``backward`` calls ``torch.autograd.grad`` **without** ``create_graph=True`` on detached +copies (``torchref/base/direct_summation/dispatch.py:197``). They are exact at first +order -- measured agreement with the eager path is 2.3e-16 -- but a second derivative +through them raises ``element 0 of tensors does not require grad``. ``Engine.EAGER`` +does not help; it only steers away from Triton and still lands on ``_CheckpointedSF``. + +So the oracle is ``_eager_iso`` / ``_eager_aniso``, which are pure torch and therefore +compose to any order. ``test_second_order.py`` asserts both facts -- that the eager path +``gradgradcheck``s and that the public API raises -- so the constraint is documented +behaviour rather than a trap. +""" + +from __future__ import annotations + +# --------------------------------------------------------------------------- +# Outer link: gemmi -> DS. Forward only; gemmi exposes no derivatives. +# --------------------------------------------------------------------------- +# Measured on first calibration (float64, CPU), rel L2 on complex F: +# +# iso, P1, 3GR5 (1329 atoms, 240 refl) 3.28e-08 +# aniso, P1, 7L84 (1209 ANISOU atoms, 200 refl) 2.91e-08 +# iso, P 65 2 2, 3GR5 via SfDS (12 ops, 200 refl) 3.22e-08 +# +# The floor is the **stored table's float32 precision**. ``torchref/data/ +# itc92_scattering_factors.pt`` holds float32 ``(104, 5)`` tensors, while gemmi's Python +# ``it92`` returns doubles; the worst relative coefficient difference over Z=1..103 is +# 5.9e-08, which is half of float32 eps. Asking ``get_scattering_params_by_z`` for +# float64 widens those float32 values rather than recovering gemmi's doubles, so even the +# "float64" oracle carries ~6e-8 relative error in ``f(s)``. +# +# That is the correct trade for a float32 production path, not a defect -- but it means +# tightening below ~1e-7 would gate on table storage precision rather than on any +# structure-factor logic. ``test_stored_table_matches_gemmi`` pins the rounding exactly +# (bit-exact in float32) so this floor cannot drift unnoticed. +# +# Non-vacuity, measured with the same harness: permuting the ADP off-diagonals to +# swap U12<->U13 moves rel L2 to 1.35e-02, and reversing them to 6.08e-03 -- five +# orders of magnitude clear of the gate. The comparison genuinely constrains the +# convention. +RTOL_VS_GEMMI = 1e-6 # rel L2 on complex F; 30x margin on the worst measurement +MAXREL_VS_GEMMI = 1e-4 # max per-reflection; 48x margin (worst was 2.06e-06) +SCALE_TOL_GEMMI = 1e-6 # |least-squares scale - 1|; worst measured 1.3e-08 + +# Stored table vs gemmi's live table. Both hold the same float32-rounded coefficients, +# so this is expected to be exact; a nonzero result is a finding, not a tolerance to +# widen. +ATOL_TABLE_VS_GEMMI = 0.0 + +# --------------------------------------------------------------------------- +# Oracle soundness: FD -> DS. DS is analytic and smooth, so gate hard. +# --------------------------------------------------------------------------- +EPS_GRADCHECK = 1e-6 +ATOL_GRADCHECK = 1e-5 +RTOL_GRADCHECK = 1e-3 +RTOL_DS_HVP_VS_FD = 1e-5 # measured 1.19e-10 on a 6-atom scene + +# --------------------------------------------------------------------------- +# The FFT/splat route vs the DS oracle. +# --------------------------------------------------------------------------- +# These gates bound a real discretization error rather than eliminating it: the map route +# carries both a truncation and a grid-sampling residual, and gradients are markedly more +# sensitive to both than amplitudes are, because each xyz derivative brings a factor +# ~``2*pi*h`` and so re-weights the comparison toward high-resolution reflections -- the +# ones the grid resolves worst. +# +# **Production runs in float32.** So float32 is the load-bearing case throughout this +# package, not a variant bolted on beside float64 -- every parametrization lists it +# first, and these gates are calibrated on it. float64 is kept because it separates a +# genuine truncation residual from float32 accumulation noise: if a float32 result misses +# a gate and float64 passes it comfortably, the cause is precision, not the kernel. The +# oracle itself always stays in float64 regardless of the candidate's dtype -- a +# reference should not inherit the precision of the thing it is judging. +# Measured at the **production** grid (``max_res = d_min``, spacing ``d_min/3``) with the +# default 3.0 sigma cutoff, differentiating ``ls_target`` against pseudo-observations +# offset from the oracle by 10% relative noise. Identical to 3 significant figures across +# float32/float64 and AUTO/EAGER, so these are properties of the discretization, not of +# precision or of a kernel: +# +# amplitude g_xyz g_occ g_adp/g_U HVP HVP cos +# iso 5.89e-03 8.51e-02 2.68e-02 4.45e-02 2.26e-02 0.99980 +# aniso 5.20e-03 8.17e-02 1.20e-01 2.24e-01 1.39e-02 0.99991 +# +# The anisotropic ADP gradient is the outlier at 2.2e-01 -- it converges to 5.6e-03 by +# fineness 1.6, so it is grid-sampling error rather than a defect in the U derivative, but +# it does not fit inside 1e-1 at production sampling. Rather than raise the shared gate +# (which would stop it constraining anything) or silently test on a finer grid than +# production, aniso ADP gets its own documented constant. +# **Authoritative gates are calibrated on a real structure, not a synthetic scene.** +# Measured on 7L84 forced to P1 -- 1209 atoms all carrying ANISOU, 799 reflections to +# 1.5 A -- at production oversampling 3, float32 candidate against the float64 oracle: +# +# amplitude rel L2 2.28e-03 +# xyz gradient 1.35e-02 cos 0.999909 +# U gradient 2.62e-02 cos 0.999657 gradnorm ratio 0.9997 +# +# Synthetic small-cell scenes are markedly *pessimistic* here and must not be used to set +# absolute gates. Aliasing contamination in the derivative cancels across atoms roughly as +# 1/sqrt(N), because with few atoms each reflection's F is dominated by a handful of +# coherent contributions while with many the inter-atom phases are quasi-random. Measured +# deviatoric-U gradnorm ratio at production oversampling, all else equal: +# +# 10 atoms 1.43 - 1.70 (independent of how anisotropic U actually is) +# 60 atoms 0.972 +# 300 atoms 0.997 +# 7L84, 1209 0.9995 +# +# An earlier revision of this package calibrated the aniso gates on a 10-atom scene and +# recorded a "systematic 54% deviatoric ADP gradient bias" as a production finding. It was +# an atom-count artifact and does not exist on real structures. The synthetic scenes are +# retained for cheap parametrized coverage -- dtype, engine, cell angle -- where the +# comparison is *between* backends and scene realism is irrelevant. +RTOL_AMPLITUDE = 1e-2 # 7L84 2.28e-03; synthetic worst 5.50e-03 +RTOL_GRADIENT = 1e-1 # 7L84 worst leaf 2.62e-02 +RTOL_HVP = 1e-1 # synthetic worst 2.22e-02 +COS_MIN = 0.999 # forward complex F +COS_MIN_GRADIENT = 0.999 # 7L84 worst 0.999657 +SCALE_TOL = 1e-3 # |best-fit scale - 1|; worst measured 3.7e-05 + +# Synthetic-scene coverage sweeps only, never as an accuracy statement about production. +# Small cells with few atoms overstate the discretization residual and which leaf is worst +# moves around with atom count (10 atoms: aniso U; 60 atoms: iso adp at 1.36e-01). +RTOL_GRADIENT_SYNTHETIC = 2e-1 +COS_MIN_GRADIENT_SYNTHETIC = 0.99 + +# --------------------------------------------------------------------------- +# Cross-backend parity. Same truncation contract on every path, so near-exact. +# --------------------------------------------------------------------------- +RTOL_BACKEND_F32 = 2e-4 +RTOL_BACKEND_F64 = 1e-12 + +# Gradients need their own float32 constant. The fused C++ kernel's hand-written backward +# and the portable splat's autograd accumulate in different orders, and float32 does not +# forgive that the way the forward pass does. Measured AUTO-vs-EAGER on ``scene_fine`` +# (60 atoms), float32: +# +# xyz occ adp/U +# iso 2.07e-04 7.63e-04 1.43e-03 +# aniso 1.95e-04 6.79e-04 6.29e-04 +# +# so ~7x above the 2e-04 that suffices for forward values, worst case on the iso ADP +# gradient. float64 stays exact to 1e-12, which is the evidence that this is accumulation +# order rather than a genuine difference in what the two kernels compute. +# +# Unlike the accuracy gates, this one is *comparative* -- two backends on identical inputs +# -- so it is legitimately calibrated on a synthetic scene: the atom-count effect that +# makes small scenes unrepresentative for DS comparisons cancels when both sides share it. +RTOL_BACKEND_GRAD_F32 = 5e-3 +RTOL_BACKEND_GRAD_F64 = 1e-10 + +__all__ = [ + "RTOL_VS_GEMMI", + "MAXREL_VS_GEMMI", + "SCALE_TOL_GEMMI", + "ATOL_TABLE_VS_GEMMI", + "EPS_GRADCHECK", + "ATOL_GRADCHECK", + "RTOL_GRADCHECK", + "RTOL_DS_HVP_VS_FD", + "RTOL_AMPLITUDE", + "RTOL_GRADIENT", + "RTOL_GRADIENT_SYNTHETIC", + "RTOL_HVP", + "COS_MIN", + "COS_MIN_GRADIENT", + "COS_MIN_GRADIENT_SYNTHETIC", + "SCALE_TOL", + "RTOL_BACKEND_F32", + "RTOL_BACKEND_F64", + "RTOL_BACKEND_GRAD_F32", + "RTOL_BACKEND_GRAD_F64", +] diff --git a/tests/unit/sf_oracle/conftest.py b/tests/unit/sf_oracle/conftest.py new file mode 100644 index 0000000..3e1f80b --- /dev/null +++ b/tests/unit/sf_oracle/conftest.py @@ -0,0 +1,213 @@ +"""Fixtures for the structure-factor oracle package. + +Package-scoped fixtures capture the real redundancy -- three test modules otherwise +recomputing the same oracle -- while being recomputed on every run, so there is no +invalidation logic and no staleness risk at all. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +import torch + +import torchref +from torchref.config import device as device_cfg, dtypes + +from . import helpers as H + + +# --------------------------------------------------------------------------- +# Global config +# --------------------------------------------------------------------------- +@pytest.fixture(scope="package", autouse=True) +def _float64_cpu(): + """float64/complex128 on CPU for this package; restore afterwards. + + Required, not cosmetic: ``iso_structure_factor_torched`` casts ``hkl`` to the + *global* ``dtypes.float`` (``torchref/base/direct_summation/isotropic.py:121``), so + under the default float32 config a float64 leaf produces a dtype-mismatched matmul. + That is why the pre-existing tests wrapped every eager-SF call in a ``double_cpu`` + fixture. + + ``sigma_cutoff_ed`` is restored here too -- the three copies of ``double_cpu`` this + replaces did not, so a test that changed the cutoff leaked it into everything that + ran after it. + """ + f0, c0, d0 = dtypes.float, dtypes.complex, device_cfg.current + s0 = torchref.sigma_cutoff_ed.value + dtypes.float = torch.float64 + dtypes.complex = torch.complex128 + device_cfg.current = torch.device("cpu") + try: + yield + finally: + dtypes.float = f0 + dtypes.complex = c0 + device_cfg.current = d0 + torchref.sigma_cutoff_ed.value = s0 + + +@pytest.fixture +def sigma_cutoff(): + """``sigma_cutoff(value)`` sets ``torchref.sigma_cutoff_ed`` and restores after.""" + original = torchref.sigma_cutoff_ed.value + + def _set(value: float) -> None: + torchref.sigma_cutoff_ed.value = value + + try: + yield _set + finally: + torchref.sigma_cutoff_ed.value = original + + +# --------------------------------------------------------------------------- +# Scenes +# --------------------------------------------------------------------------- +@pytest.fixture(scope="package") +def scene_small() -> H.Scene: + """5 atoms, few reflections -- sized for ``gradcheck``'s O(n_params) cost.""" + return H.synthetic_scene(n_atoms=5, a=14.0, d_min=3.0, max_refl=24, seed=3) + + +@pytest.fixture(scope="package") +def scene_fine() -> H.Scene: + """Synthetic P1 scene for parametrized coverage sweeps. + + 60 atoms rather than 10. Aliasing contamination in the *derivatives* cancels across + atoms roughly as 1/sqrt(N), so a 10-atom scene overstates it badly -- the deviatoric-U + gradnorm ratio is 1.43-1.70 at 10 atoms against 0.997 at 300 and 0.9995 on 7L84. See + the note on the gate constants in ``__init__.py``. + + Even at 60 atoms this remains pessimistic relative to a real structure, so it backs the + *comparative* tests -- backend parity, dtype coverage, cell angle -- while the absolute + accuracy gates are calibrated on ``gemmi_aniso_p1``. + """ + return H.synthetic_scene(n_atoms=60, a=24.0, d_min=1.6, max_refl=600, seed=0) + + +@pytest.fixture(scope="package") +def scene_monoclinic() -> H.Scene: + """beta = 115 deg. The non-orthogonal metric is where a node-centred or diagonal + truncation diverges most from the true Cartesian sphere.""" + return H.synthetic_scene( + n_atoms=60, a=24.0, beta=115.0, d_min=1.6, max_refl=600, seed=1 + ) + + +@pytest.fixture(scope="package") +def scene_coarse() -> H.Scene: + """Deliberately under-sampled: pins the sampling-dominated regime so the fine-grid + gate cannot pass by accident.""" + return H.synthetic_scene(n_atoms=60, a=24.0, d_min=3.5, max_refl=200, seed=0) + + +# --------------------------------------------------------------------------- +# gemmi scenes, from real deposited structures +# --------------------------------------------------------------------------- +_PDB_DIR = Path(__file__).resolve().parents[2] / "files" / "pdb" + + +@pytest.fixture(scope="package") +def gemmi_iso_p1(): + """3GR5 forced to P1. 1329 atoms after hydrogen removal.""" + pytest.importorskip("gemmi") + return H.gemmi_scene(_PDB_DIR / "3GR5.pdb", p1=True, d_min=3.0, max_refl=200) + + +@pytest.fixture(scope="package") +def gemmi_aniso_p1(): + """7L84 forced to P1 -- every one of its 1209 atoms carries ANISOU.""" + pytest.importorskip("gemmi") + return H.gemmi_scene(_PDB_DIR / "7L84.pdb", p1=True, d_min=3.0, max_refl=200) + + +@pytest.fixture(scope="package") +def gemmi_aniso_grad(): + """7L84 P1 at 1.5 A with enough reflections to calibrate derivative gates. + + Separate from ``gemmi_aniso_p1`` (which is sized for the cheap forward gemmi + comparison) because the absolute gradient and HVP gates must be set on a scene that is + representative, and representativeness here is driven by atom count and resolution. + """ + pytest.importorskip("gemmi") + return H.gemmi_scene(_PDB_DIR / "7L84.pdb", p1=True, d_min=1.5, max_refl=500) + + +@pytest.fixture(scope="package") +def oracle_aniso_grad(gemmi_aniso_grad): + """DS oracle for the real-structure scene: F, obs, gradients and an HVP.""" + scene, _ = gemmi_aniso_grad + return _oracle_bundle(scene) + + +@pytest.fixture(scope="package") +def gemmi_iso_symmetry(): + """3GR5 in its deposited P 65 2 2. + + Hexagonal on purpose. ``h' = h.R`` and ``h' = R.h`` coincide whenever the rotation + matrix is symmetric, so orthorhombic and tetragonal groups cannot distinguish them; + trigonal/hexagonal groups can. This is the same reasoning that drives the group + choice in ``tests/unit/symmetry/test_hkl_symmetry_gemmi.py``. + """ + pytest.importorskip("gemmi") + return H.gemmi_scene(_PDB_DIR / "3GR5.pdb", p1=False, d_min=3.0, max_refl=200) + + +# --------------------------------------------------------------------------- +# Memoized oracle results +# --------------------------------------------------------------------------- +@pytest.fixture(scope="package") +def oracle_fine(scene_fine): + """Forward ``F``, first-order grads and an HVP from the DS oracle, computed once.""" + return _oracle_bundle(scene_fine) + + +@pytest.fixture(scope="package") +def oracle_monoclinic(scene_monoclinic): + return _oracle_bundle(scene_monoclinic) + + +def _oracle_bundle(scene: H.Scene) -> dict: + """Forward ``F``, pseudo-observations, gradients and an HVP, all from the oracle. + + ``obs`` is derived here and handed to the tests, so the candidate and the oracle are + scored against *identical* pseudo-observations. Regenerating it per test would make + the two sides differentiate slightly different targets and the comparison would + measure that instead. + """ + out: dict = {} + v = _direction(scene) + for aniso in (False, True): + key = "aniso" if aniso else "iso" + fn = H.ds_aniso_oracle if aniso else H.ds_iso_oracle + + with torch.no_grad(): + F = fn(scene) + obs = H.synthetic_obs(F) + out[f"{key}_F"] = F + out[f"{key}_obs"] = obs + out[f"{key}_v"] = v + + xyz, occ, third = scene.leaves(aniso=aniso) + grads = torch.autograd.grad( + H.ls_target(fn(scene, xyz, occ, third), obs), (xyz, occ, third) + ) + out[f"{key}_grads"] = tuple(g.detach() for g in grads) + + xyz, occ, third = scene.leaves(aniso=aniso) + (g1,) = torch.autograd.grad( + H.ls_target(fn(scene, xyz, occ, third), obs), xyz, create_graph=True + ) + (hv,) = torch.autograd.grad((g1 * v).sum(), xyz) + out[f"{key}_hvp"] = hv.detach() + return out + + +def _direction(scene: H.Scene) -> torch.Tensor: + """Fixed unit direction for HVPs. Seeded, so the oracle and the candidate agree.""" + g = torch.Generator().manual_seed(17) + v = torch.randn(scene.xyz.shape, generator=g, dtype=scene.xyz.dtype) + return v / v.norm() diff --git a/tests/unit/sf_oracle/helpers.py b/tests/unit/sf_oracle/helpers.py new file mode 100644 index 0000000..33448eb --- /dev/null +++ b/tests/unit/sf_oracle/helpers.py @@ -0,0 +1,543 @@ +"""Shared scene construction, oracle wrappers and comparison metrics. + +Two scene sources: + +* :func:`synthetic_scene` -- a small random P1 scene, for the ``gradcheck`` / + ``gradgradcheck`` links where the cost is O(n_params) forward evaluations and the + scene size is what makes the difference between milliseconds and minutes. +* :func:`gemmi_scene` -- a real deposited structure, with the torchref scene built + **from** the gemmi structure so both sides are driven by the same atoms. + +That second point is load-bearing. If the two sides are populated in parallel from the +same file by two different readers, the test silently measures reader agreement instead +of structure-factor agreement, and a single differing atom shows up as a tolerance +problem rather than as the bug it is. So there is exactly one traversal of the gemmi +model, and torchref's tensors are filled from it. +""" + +from __future__ import annotations + +import itertools +from dataclasses import dataclass +from typing import Optional, Sequence + +import torch + +from torchref.base.direct_summation.dispatch import _eager_aniso, _eager_iso +from torchref.base.reciprocal import get_scattering_vectors, reciprocal_basis_matrix +from torchref.base.scattering.scattering_table import get_scattering_params_by_z +from torchref.symmetry.cell import Cell + +__all__ = [ + "Scene", + "GRID_FINENESS", + "synthetic_scene", + "gemmi_scene", + "gemmi_sf", + "ds_iso_oracle", + "ds_aniso_oracle", + "sf_fft_for", + "fft_sf", + "synthetic_obs", + "ls_target", + "best_fit_scale", + "rel_l2", + "max_rel", +] + +# Grids are built at ``max_res = d_min / GRID_FINENESS``, i.e. a voxel spacing of +# ``d_min / (3 * GRID_FINENESS)`` given ``NYQUIST_OVERSAMPLING = 3.0``. +# +# ``GRID_FINENESS = 1.0`` is the **production** configuration: ``max_res = d_min``, voxel +# spacing ``d_min / 3``. These tests default to it, because a gate on a finer grid than +# production ships would not bound anything that matters. +# +# Measured on ``scene_fine`` (60 atoms, a = 24 A, d_min 1.6), float64, against the DS +# oracle. Amplitudes are rel L2 on complex F; derivatives are of ``ls_target``: +# +# fineness spacing gridsize amplitude g_xyz g_xyz cos HVP HVP cos +# 0.667 d_min/2 (30, 36, 27) 1.04e-01 1.25e+00 0.4207 2.77e-01 0.9622 +# 1.000 d_min/3 (45, 50, 40) 4.11e-03 4.30e-02 0.999077 2.06e-02 0.999814 +# 1.300 d_min/3.9 (60, 64, 54) 8.01e-04 5.86e-03 0.999983 1.16e-03 0.999999 +# 1.600 d_min/4.8 (72, 80, 64) 8.03e-04 5.87e-03 0.999983 1.16e-03 0.999999 +# 2.200 d_min/6.6 (100,108, 90) 7.98e-04 6.00e-03 0.999982 1.16e-03 0.999999 +# +# Three things follow. +# +# 1. **Bare Nyquist is unusable**, which is why ``NYQUIST_OVERSAMPLING`` is 3 and not 2. At +# oversampling 2 the xyz gradient cosine against the analytic answer collapses to 0.42 +# and amplitudes are 10% out. The factor of 3 is buying a great deal. +# 2. **Production sits one step before convergence.** Everything is converged from fineness +# 1.3. At production the residuals are ~5x larger in amplitude and ~7x in the xyz +# gradient, but direction stays excellent (cos 0.999) so the residual is predominantly +# magnitude. On a *real* structure the production numbers are better still -- 7L84 gives +# amplitude 2.28e-03 and xyz gradient 1.04e-02 -- because derivative aliasing cancels +# across atoms as ~1/sqrt(N). See the gate constants in ``__init__.py``; absolute +# accuracy gates are calibrated there rather than here. +# 3. **The sigma cutoff is not the binding constraint at production.** Sweeping n_sigma at +# fineness 1.0 moves the amplitude residual 5.43e-3 -> 4.11e-3 -> 4.05e-3 and then +# flatlines; grid sampling dominates. Tests that mean to exercise the cutoff therefore +# pass an explicit finer ``fineness`` -- see +# ``test_forward.py::test_nsigma_reduces_truncation_error``. +GRID_FINENESS = 1.0 + + +# --------------------------------------------------------------------------- +# Scene +# --------------------------------------------------------------------------- +@dataclass(frozen=True) +class Scene: + """Everything both routes need, in one immutable bundle. + + ``xyz`` is Cartesian (what ``SfFFT`` / ``SfDS`` take) and ``xyz_frac`` is + fractional (what the ``direct_summation`` functions take). Both are carried rather + than converted at the call site, because getting that conversion wrong in one place + produces a plausible-looking wrong answer instead of an error. + """ + + cell: Cell + spacegroup: str + d_min: float + hkl: torch.Tensor # (R, 3) float + hkl_list: tuple # (R,) tuples of int, for gemmi + s: torch.Tensor # (R,) |s| = 1/d + s_vec: torch.Tensor # (R, 3) + xyz: torch.Tensor # (N, 3) Cartesian + xyz_frac: torch.Tensor # (N, 3) fractional + occ: torch.Tensor # (N,) + adp: torch.Tensor # (N,) B convention, A^2 + u6: torch.Tensor # (N, 6) [U11,U22,U33,U12,U13,U23], A^2 + A: torch.Tensor # (N, 5) ITC92 + B: torch.Tensor # (N, 5) ITC92 + frac_matrix: torch.Tensor # (3, 3) fractional -> Cartesian + inv_frac_matrix: torch.Tensor # (3, 3) Cartesian -> fractional + + @property + def n_atoms(self) -> int: + return int(self.xyz.shape[0]) + + @property + def n_refl(self) -> int: + return int(self.hkl.shape[0]) + + def leaves(self, *, aniso: bool = False, requires_grad: bool = True): + """Fresh differentiable copies of ``(xyz, occ, adp_or_u6)``.""" + third = self.u6 if aniso else self.adp + return tuple( + t.clone().requires_grad_(requires_grad) for t in (self.xyz, self.occ, third) + ) + + +def _hkl_within(cell: Cell, d_min: float, dtype: torch.dtype, cap: Optional[int]): + """Every integer hkl inside the ``d_min`` shell, ordered, ``F(000)`` excluded. + + ``F(000)`` is dropped because the FFT route cannot reproduce it: the grid carries a + truncated density, so the zeroth term is the truncated electron count rather than + the true one. + """ + a, b, c = (float(cell.data[i]) for i in range(3)) + lim = [int(v / d_min) + 1 for v in (a, b, c)] + recB = reciprocal_basis_matrix(cell.data) + cand = [ + h + for h in itertools.product( + range(-lim[0], lim[0] + 1), + range(-lim[1], lim[1] + 1), + range(-lim[2], lim[2] + 1), + ) + if any(h) + ] + hkl = torch.tensor(cand, dtype=dtype) + s = get_scattering_vectors(hkl, cell.data, recB).norm(dim=1) + keep = (s > 0) & (s <= 1.0 / d_min) + hkl = hkl[keep] + if cap is not None and hkl.shape[0] > cap: + # Even stride, so the kept set still spans the full resolution range rather + # than clustering at low angle where agreement is easiest. + step = hkl.shape[0] // cap + 1 + hkl = hkl[::step] + return hkl + + +def _finish( + cell: Cell, + spacegroup: str, + d_min: float, + hkl: torch.Tensor, + z: torch.Tensor, + xyz_frac: torch.Tensor, + occ: torch.Tensor, + adp: torch.Tensor, + u6: torch.Tensor, + dtype: torch.dtype, + device: torch.device, +) -> Scene: + recB = reciprocal_basis_matrix(cell.data) + s_vec = get_scattering_vectors(hkl, cell.data, recB) + frac_matrix = cell.fractional_matrix.to(dtype) + inv_frac_matrix = cell.inv_fractional_matrix.to(dtype) + A, B = get_scattering_params_by_z(z, dtype=dtype) + to = lambda t: t.to(device=device, dtype=dtype) # noqa: E731 + return Scene( + cell=cell, + spacegroup=spacegroup, + d_min=d_min, + hkl=to(hkl), + hkl_list=tuple(tuple(int(v) for v in row) for row in hkl), + s=to(s_vec.norm(dim=1)), + s_vec=to(s_vec), + xyz=to(xyz_frac @ frac_matrix.T), + xyz_frac=to(xyz_frac), + occ=to(occ), + adp=to(adp), + u6=to(u6), + A=to(A), + B=to(B), + frac_matrix=to(frac_matrix), + inv_frac_matrix=to(inv_frac_matrix), + ) + + +def synthetic_scene( + *, + n_atoms: int = 10, + a: float = 24.0, + beta: float = 90.0, + d_min: float = 1.6, + seed: int = 0, + max_refl: Optional[int] = None, + dtype: torch.dtype = torch.float64, + device: torch.device = torch.device("cpu"), +) -> Scene: + """A small random P1 scene. + + Degeneracies to avoid are documented in ``tests/helpers/kernel_cases.py``: ``occ`` + is never 1.0 and the ADP off-diagonals are never zero, because both hide sign and + ordering errors -- a wrong ``occ`` gradient is invisible when every ``occ`` is 1, + and a ``U12``/``U13`` swap is invisible when both are 0. + """ + g = torch.Generator().manual_seed(seed) + cell = Cell( + torch.tensor([a, a * 1.1, a * 0.9, 90.0, beta, 90.0], dtype=torch.float64) + ) + hkl = _hkl_within(cell, d_min, torch.float64, max_refl) + + z = torch.tensor(([6, 7, 8, 16, 6, 8, 7, 26] * ((n_atoms // 8) + 1))[:n_atoms]) + # Keep atoms off the cell walls so a wrapped-vs-unwrapped bug in the map route + # cannot be confused with a truncation residual. + xyz_frac = 0.15 + 0.7 * torch.rand(n_atoms, 3, generator=g, dtype=torch.float64) + occ = 0.55 + 0.4 * torch.rand(n_atoms, generator=g, dtype=torch.float64) + adp = 8.0 + 22.0 * torch.rand(n_atoms, generator=g, dtype=torch.float64) + + u6 = torch.zeros(n_atoms, 6, dtype=torch.float64) + u6[:, :3] = (adp[:, None] / (8.0 * torch.pi**2)) * ( + 0.75 + 0.5 * torch.rand(n_atoms, 3, generator=g, dtype=torch.float64) + ) + off = 0.35 * u6[:, :3].min(dim=1).values + u6[:, 3:] = off[:, None] * ( + 2.0 * torch.rand(n_atoms, 3, generator=g, dtype=torch.float64) - 1.0 + ) + return _finish( + cell, "P 1", d_min, hkl, z, xyz_frac, occ, adp, u6, dtype, device + ) + + +def gemmi_scene( + pdb_path, + *, + p1: bool = True, + d_min: float = 3.0, + max_refl: Optional[int] = 200, + dtype: torch.dtype = torch.float64, + device: torch.device = torch.device("cpu"), +): + """Build a :class:`Scene` **from** a gemmi structure; return ``(scene, structure)``. + + Hydrogens are removed on the gemmi side before the traversal, so both routes see + the identical atom list -- ``remove_hydrogens`` after extraction would leave + torchref summing over atoms gemmi no longer has. + + ``p1=True`` rewrites the spacegroup to ``P 1`` and rebuilds ``cell.images``, which + is the mechanism gemmi's calculator uses for symmetry: ``calculate_sf_from_model`` + sums each atom over ``cell.images``, so an empty image list is a true P1 sum. + Verified: forcing P1 takes ``len(cell.images)`` from 11 to 0 on a P 65 2 2 entry. + """ + import gemmi + + st = gemmi.read_structure(str(pdb_path)) + st.setup_entities() + st.remove_hydrogens() + if p1: + st.spacegroup_hm = "P 1" + st.setup_cell_images() + + zs, frac, occ, adp, u6 = [], [], [], [], [] + for chain in st[0]: + for res in chain: + for at in res: + f = st.cell.fractionalize(at.pos) + an = at.aniso + zs.append(at.element.atomic_number) + frac.append([f.x, f.y, f.z]) + occ.append(at.occ) + adp.append(at.b_iso) + u6.append([an.u11, an.u22, an.u33, an.u12, an.u13, an.u23]) + + c = st.cell + cell = Cell( + torch.tensor([c.a, c.b, c.c, c.alpha, c.beta, c.gamma], dtype=torch.float64) + ) + hkl = _hkl_within(cell, d_min, torch.float64, max_refl) + scene = _finish( + cell, + st.spacegroup_hm, + d_min, + hkl, + torch.tensor(zs), + torch.tensor(frac, dtype=torch.float64), + torch.tensor(occ, dtype=torch.float64), + torch.tensor(adp, dtype=torch.float64), + torch.tensor(u6, dtype=torch.float64), + dtype, + device, + ) + return scene, st + + +def gemmi_sf(structure, hkl_list: Sequence, *, dtype=torch.complex128) -> torch.Tensor: + """``F(hkl)`` from gemmi's X-ray structure-factor calculator. + + ``addends`` is left untouched (zero), so there is no anomalous term to account for + on the torchref side. + """ + import gemmi + + calc = gemmi.StructureFactorCalculatorX(structure.cell) + return torch.tensor( + [complex(calc.calculate_sf_from_model(structure[0], h)) for h in hkl_list], + dtype=dtype, + ) + + +# --------------------------------------------------------------------------- +# Oracles +# --------------------------------------------------------------------------- +def ds_iso_oracle(scene: Scene, xyz=None, occ=None, adp=None) -> torch.Tensor: + """Isotropic ``F(hkl)`` from the pure-torch eager path. + + ``_eager_iso`` and not ``ds_iso``: the public entry points wrap ``_CheckpointedSF``, + whose backward omits ``create_graph=True``, so they raise on double backward. See + the package docstring. + + ``xyz`` is **Cartesian** here, matching :attr:`Scene.xyz`; it is fractionalized + inside so a differentiable leaf stays a leaf in Cartesian space, which is what the + FFT route differentiates with respect to. + """ + xyz = scene.xyz if xyz is None else xyz + occ = scene.occ if occ is None else occ + adp = scene.adp if adp is None else adp + return _eager_iso( + scene.hkl, + scene.s, + xyz @ scene.inv_frac_matrix.T, + occ, + adp, + scene.A, + scene.B, + None, + ) + + +def ds_aniso_oracle(scene: Scene, xyz=None, occ=None, u6=None) -> torch.Tensor: + """Anisotropic ``F(hkl)`` from the pure-torch eager path.""" + xyz = scene.xyz if xyz is None else xyz + occ = scene.occ if occ is None else occ + u6 = scene.u6 if u6 is None else u6 + return _eager_aniso( + scene.hkl, + scene.s_vec, + xyz @ scene.inv_frac_matrix.T, + occ, + u6, + scene.A, + scene.B, + None, + ) + + +def sf_fft_for( + scene: Scene, + dtype: torch.dtype = torch.float64, + *, + fineness: float = GRID_FINENESS, + spacegroup: str = "P 1", +): + """An ``SfFFT`` with its grid already set up, at the fineness policy above. + + Pass ``fineness=1.0`` for a deliberately under-sampled grid; see + :data:`GRID_FINENESS` for why that is the sampling-limited regime. + """ + from torchref.model.sf_fft import SfFFT + + sf = SfFFT( + cell=scene.cell, + spacegroup=spacegroup, + max_res=scene.d_min / fineness, + dtype_float=dtype, + device=torch.device("cpu"), + ) + sf.setup_grid() + return sf + + +def fft_sf(scene: Scene, sf_fft, xyz=None, occ=None, third=None, *, aniso=False): + """``F(hkl)`` through the density-splat + FFT route, in P1. + + ``apply_symmetry=False`` on both calls: P1 isolates the truncation and sampling + budget, and a symmetric comparison would cancel the symmetry algebra anyway, since + both routes call the same ``compute_symmetry_equivalent_hkls`` / + ``compute_translation_phases``. Symmetry is validated against gemmi instead, in + ``test_forward.py``. + """ + xyz = scene.xyz if xyz is None else xyz + occ = scene.occ if occ is None else occ + if third is None: + third = scene.u6 if aniso else scene.adp + + # Match the grid's dtype. Scenes are built in float64 so the DS oracle keeps full + # precision, but production runs float32 and ``SfFFT`` derives its cell matrices from + # ``dtype_float`` -- feeding float64 atoms into a float32 grid raises inside + # ``_canonical_setup``. Casting is differentiable, so gradient tests still reach the + # original float64 leaf. + target = sf_fft.dtype_float + if xyz.dtype != target: + xyz, occ, third = xyz.to(target), occ.to(target), third.to(target) + A, B = scene.A.to(target), scene.B.to(target) + + empty1 = xyz.new_zeros(0) + empty3 = xyz.new_zeros(0, 3) + empty5 = A.new_zeros(0, 5) + if aniso: + dm = sf_fft.build_density_map( + empty3, + empty1, + empty1, + empty5, + empty5, + xyz_aniso=xyz, + u_aniso=third, + occ_aniso=occ, + A_aniso=A, + B_aniso=B, + apply_symmetry=False, + ) + else: + dm = sf_fft.build_density_map(xyz, third, occ, A, B, apply_symmetry=False) + return sf_fft.map_to_structure_factors(dm, scene.hkl, apply_symmetry=False) + + +# --------------------------------------------------------------------------- +# Metrics +# --------------------------------------------------------------------------- +def synthetic_obs( + F_ref: torch.Tensor, *, rel_sigma: float = 0.10, seed: int = 101 +) -> torch.Tensor: + """Pseudo-observed amplitudes: ``|F_ref|`` perturbed by ``rel_sigma`` relative noise. + + This exists to make :func:`ls_target` well-posed. A least-squares residual taken + directly against ``|F_ref|`` has its minimum exactly where the two routes agree, so + near convergence the gradient comparison becomes partly self-referential -- the + quantity being differentiated is the very difference under test. Offsetting the + target by a fixed random 10% breaks that: the residual is O(0.1|F|), dominated by + synthetic misfit rather than by the FFT-vs-DS discrepancy, which is the regime a + refinement actually operates in. + + Deterministic by seed, and generated once from the oracle, so the candidate and the + oracle are scored against the *same* target. Multiplicative rather than additive + noise, so weak reflections are not swamped. + """ + g = torch.Generator().manual_seed(seed) + amp = F_ref.abs() + noise = torch.randn(amp.shape, generator=g, dtype=amp.dtype) + return (amp * (1.0 + rel_sigma * noise)).clamp_min(0.0) + + +def ls_target( + F: torch.Tensor, f_obs: torch.Tensor, weights: Optional[torch.Tensor] = None +) -> torch.Tensor: + """Least-squares amplitude residual ``sum(w * (|F| - f_obs)^2)``. + + The quantitative target for the gradient and HVP gates. Unweighted by default, so it + is dominated by strong reflections in the way an unweighted crystallographic LS + residual is. + + Well-conditioned in a way :func:`phase_sensitive_loss` is not: the per-reflection + residuals are random-signed at O(0.1|F|), so the sum grows like sqrt(N) instead of + cancelling, and the relative-error metric measures accuracy rather than the + conditioning of a near-cancelled sum. + """ + r = F.abs().to(f_obs.dtype) - f_obs + return (r * r).sum() if weights is None else (weights * r * r).sum() + + +# Why there is no phase-sensitive loss here +# ----------------------------------------- +# An earlier draft used ``(F.real**2 + 2*F.imag).sum()`` -- asymmetric in the imaginary +# part -- on the theory that a squared target cannot detect a kernel that conjugates F. +# Both premises were wrong. +# +# It is not testable this way. A crystallographic amplitude target is phase-blind by +# construction, and so are its derivatives: +# +# d|F|/dx = Re(F* dF/dx) / |F| +# +# Conjugate the whole calculation -- F -> F*, dF/dx -> (dF/dx)* -- and +# Re(F* dF/dx) -> Re(F (dF/dx)*) = Re((F* dF/dx)*) = Re(F* dF/dx), unchanged. A global +# phase convention is an unobservable gauge choice for such a target, so asserting a +# particular imaginary sign through it tests nothing physical. +# +# And it actively harmed the measurement. A linear term in F.imag sums signed quantities +# that largely cancel, so its gradient is a small difference of large numbers and every +# per-reflection error is amplified in relative terms. Measured on the same FFT-vs-DS xyz +# gradient comparison: 2.7e-01 under the asymmetric functional against ~6e-03 under +# ``ls_target`` -- a 40x artefact of conditioning, not of accuracy. +# +# Phase *convention* still matters, because anomalous scattering and difference maps are +# phase-sensitive and because the two routes must agree with each other. It is gated where +# it is observable: on the forward complex F, against gemmi and between routes, in +# ``test_forward.py``. See ``test_ls_target_is_phase_blind`` there, which pins the +# invariance above so this reasoning cannot quietly rot. + + +def rel_l2(got: torch.Tensor, ref: torch.Tensor) -> float: + return float((got - ref).norm() / ref.norm()) + + +def max_rel(got: torch.Tensor, ref: torch.Tensor, *, significance: float = 1e-3) -> float: + """Worst per-reflection relative error, over *significant* reflections only. + + Reflections with ``|F_ref| <= significance * max|F_ref|`` are excluded. Without that + filter this metric is dominated by systematic absences: in ``P 65 2 2`` the + extinguished reflections have ``|F|`` at round-off, so a ratio there reports ~6e-3 + while the rel-L2 over the same set is 4e-8. That is a property of dividing by zero, + not a disagreement, and gating on it would mean either a meaningless tolerance or + deleting the symmetry test. + + Reported alongside :func:`rel_l2` rather than instead of it -- rel L2 can hide a + single badly wrong strong reflection, and this cannot. + """ + scale = ref.abs().max() + keep = ref.abs() > significance * scale + if not bool(keep.any()): + return 0.0 + return float(((got[keep] - ref[keep]).abs() / ref[keep].abs()).max()) + + +def best_fit_scale(got: torch.Tensor, ref: torch.Tensor) -> float: + """Least-squares scale ``k`` minimizing ``||k*|ref| - |got|||``. + + Compared on amplitudes so a phase convention difference does not leak into the + scale estimate; phase is gated separately. + """ + g, r = got.abs().reshape(-1), ref.abs().reshape(-1) + return float((r * g).sum() / (r * r).sum()) diff --git a/tests/unit/sf_oracle/test_forward.py b/tests/unit/sf_oracle/test_forward.py new file mode 100644 index 0000000..feb4f45 --- /dev/null +++ b/tests/unit/sf_oracle/test_forward.py @@ -0,0 +1,389 @@ +"""Forward structure factors: gemmi -> direct summation -> the FFT/splat route. + +Read the package docstring first for the oracle chain and, in particular, for what the +gemmi comparison does and does not prove -- the ITC92 table was generated from gemmi, so +``f(s)`` is shared and cancels. :func:`test_stored_table_matches_gemmi` is the only test +here that constrains the form factors themselves. +""" + +from __future__ import annotations + +import pytest +import torch + +from torchref.model.sf_ds import SfDS +from torchref.utils import Engine, use_engine + +from . import ( + ATOL_TABLE_VS_GEMMI, + COS_MIN, + MAXREL_VS_GEMMI, + RTOL_AMPLITUDE, + RTOL_BACKEND_F32, + RTOL_BACKEND_F64, + RTOL_VS_GEMMI, + SCALE_TOL, + SCALE_TOL_GEMMI, +) +from . import helpers as H + +pytestmark = pytest.mark.unit + +# float32 first: it is the production dtype, so it is the case that matters and the one +# these gates are calibrated on. float64 separates truncation error from float32 noise. +_DTYPES = [torch.float32, torch.float64] + + +def _report(tag, got, ref): + """Print the three metrics, so a widened tolerance always has a number behind it.""" + r, m, k = H.rel_l2(got, ref), H.max_rel(got, ref), H.best_fit_scale(got, ref) + print(f" {tag:34s} relL2 {r:.3e} maxrel {m:.3e} scale {k:.9f}") + return r, m, k + + +# --------------------------------------------------------------------------- +# The one place gemmi is an independent reference for f(s) +# --------------------------------------------------------------------------- +def test_stored_table_matches_gemmi(): + """The stored ITC92 ``.pt`` must still equal gemmi's live table. + + ``torchref/data/itc92_scattering_factors.pt`` is generated from gemmi by + ``torchref/scripts/generate_scattering_table.py``, then used at runtime so gemmi is + not a runtime dependency. Nothing re-checked the two after generation, so a + corrupted regeneration or a gemmi release that revised coefficients would go + unnoticed -- and would be invisible to every other test in this package, because + those compare torchref against gemmi using the *same* coefficients on both sides. + + Also pins the storage convention: torchref keeps gemmi's four Gaussians plus the + constant ``c`` folded in as a fifth with ``B = 0``, so ``exp(0) = 1`` reproduces the + additive term. + + Compared in **float32**, and required to be bit-exact there. The stored table is + float32 (``(104, 5)``), while gemmi's Python ``it92`` returns doubles -- so a + float64 comparison can only ever agree to float32 rounding, measured at 5.9e-08 + worst case over Z=1..103, which is half of float32 eps. Asserting exact equality + after rounding gemmi's doubles to float32 is the stronger statement: it permits the + intended precision loss and nothing else, where a 1e-7 rtol would also permit a + genuinely wrong coefficient that happened to land nearby. + + Note this caps the oracle: asking ``get_scattering_params_by_z`` for float64 widens + float32 values rather than recovering the doubles, so ``f(s)`` in the "float64" + oracle carries ~6e-8 relative error. That is the floor behind ``RTOL_VS_GEMMI`` and + it is the correct trade for a float32 production path. + """ + gemmi = pytest.importorskip("gemmi") + from torchref.base.scattering.scattering_table import get_scattering_params_by_z + + checked = 0 + for z in range(1, 104): + element = gemmi.Element(z) + coef = element.it92 + if coef is None: + continue + A, B = get_scattering_params_by_z(torch.tensor([z]), dtype=torch.float32) + assert A.shape == (1, 5) and B.shape == (1, 5), f"Z={z} unexpected table shape" + + want_a = torch.tensor(list(coef.a), dtype=torch.float32) + want_b = torch.tensor(list(coef.b), dtype=torch.float32) + want_c = torch.tensor(coef.c, dtype=torch.float32) + + assert torch.equal(A[0, :4], want_a), ( + f"Z={z} ({element.name}): 'a' drifted from gemmi -- " + f"stored {A[0, :4].tolist()} vs gemmi {want_a.tolist()}" + ) + assert torch.equal(B[0, :4], want_b), ( + f"Z={z} ({element.name}): 'b' drifted from gemmi -- " + f"stored {B[0, :4].tolist()} vs gemmi {want_b.tolist()}" + ) + assert torch.equal(A[0, 4], want_c), ( + f"Z={z} ({element.name}): constant term c not in slot 4 -- " + f"stored {float(A[0, 4])} vs gemmi {float(want_c)}" + ) + assert float(B[0, 4]) == 0.0, ( + f"Z={z} ({element.name}): slot 4 must have B=0 so exp(0)=1 gives +c" + ) + checked += 1 + + assert checked >= 90, f"only {checked} elements checked -- table lookup is not working" + + +def test_ls_target_is_phase_blind(scene_small): + """An amplitude target and its gradients are invariant under conjugating ``F``. + + This is why phase convention is gated on forward complex values -- here and against + gemmi -- and *not* through the derivative tests. Since + + d|F|/dx = Re(F* dF/dx) / |F| + + sending ``F -> F*`` and ``dF/dx -> (dF/dx)*`` leaves ``Re(F* dF/dx)`` unchanged, so a + global phase convention is an unobservable gauge choice for such a target. An earlier + draft tried to catch a conjugated kernel with a functional linear in ``F.imag``; that + cannot work for a physical target and cost a 40x conditioning artefact in the relative + error (2.7e-01 vs ~6e-03 for the same comparison). See the note in ``helpers.py``. + + Phase still matters -- anomalous scattering and difference maps are phase-sensitive, + and the two routes must agree with each other -- which is exactly what + :func:`test_ds_matches_gemmi_iso_p1` and the complex-cosine assertion in + :func:`test_fft_matches_ds_amplitudes` cover. + """ + s = scene_small + with torch.no_grad(): + F = H.ds_iso_oracle(s) + obs = H.synthetic_obs(F) + + def target(x, conjugate): + Fx = H.ds_iso_oracle(s, x, s.occ, s.adp) + return H.ls_target(Fx.conj() if conjugate else Fx, obs) + + x1 = s.xyz.clone().requires_grad_(True) + x2 = s.xyz.clone().requires_grad_(True) + v1, v2 = target(x1, False), target(x2, True) + (g1,) = torch.autograd.grad(v1, x1) + (g2,) = torch.autograd.grad(v2, x2) + + assert v1.item() == pytest.approx(v2.item(), rel=1e-12), ( + "conjugating F changed the amplitude target value" + ) + assert H.rel_l2(g2, g1) < 1e-12, ( + "conjugating F changed the amplitude target's gradient, so this target is not " + "phase-blind after all -- the derivative tests could then gate phase convention " + "and the reasoning in helpers.py needs revisiting" + ) + + +# --------------------------------------------------------------------------- +# gemmi -> DS +# --------------------------------------------------------------------------- +def test_ds_matches_gemmi_iso_p1(gemmi_iso_p1): + """Isotropic P1. Constrains the exponential sum, the 2*pi factors and the B + convention, with symmetry taken out of the picture.""" + scene, structure = gemmi_iso_p1 + F_gemmi = H.gemmi_sf(structure, scene.hkl_list) + with torch.no_grad(): + F_ds = H.ds_iso_oracle(scene) + + print(f"\n{scene.n_atoms} atoms, {scene.n_refl} reflections, P1") + rel, mrel, scale = _report("DS vs gemmi (iso, P1)", F_ds, F_gemmi) + assert rel < RTOL_VS_GEMMI + assert mrel < MAXREL_VS_GEMMI + assert abs(scale - 1.0) < SCALE_TOL_GEMMI + + +def test_ds_matches_gemmi_aniso_p1(gemmi_aniso_p1): + """Anisotropic P1 on a structure where every atom carries ANISOU. + + Compares complex ``F``, not ``|F|``: an amplitude-only check passes a phase-sign + error, and the aniso Debye-Waller factor is real, so a sign slip elsewhere would + hide behind it. + """ + scene, structure = gemmi_aniso_p1 + assert (scene.u6.abs().sum(dim=1) > 0).all(), ( + "every atom must carry ANISOU or this test degenerates to the isotropic case" + ) + F_gemmi = H.gemmi_sf(structure, scene.hkl_list) + with torch.no_grad(): + F_ds = H.ds_aniso_oracle(scene) + + print(f"\n{scene.n_atoms} ANISOU atoms, {scene.n_refl} reflections, P1") + rel, mrel, scale = _report("DS vs gemmi (aniso, P1)", F_ds, F_gemmi) + assert rel < RTOL_VS_GEMMI + assert mrel < MAXREL_VS_GEMMI + assert abs(scale - 1.0) < SCALE_TOL_GEMMI + + +@pytest.mark.parametrize( + "perm,label", + [((0, 1, 2, 4, 3, 5), "U12<->U13"), ((0, 1, 2, 5, 4, 3), "reversed off-diagonals")], +) +def test_aniso_ordering_is_actually_constrained(gemmi_aniso_p1, perm, label): + """Non-vacuity guard for the test above. + + A tight tolerance is only meaningful if a wrong convention would breach it. Permuting + the ADP off-diagonals must move the residual far outside ``RTOL_VS_GEMMI`` -- measured + 1.35e-02 for the ``U12<->U13`` swap and 6.08e-03 for the reversal, five orders of + magnitude clear. Without this, ``[U11,U22,U33,U12,U13,U23]`` would be an assumption + rather than a verified fact. + """ + scene, structure = gemmi_aniso_p1 + F_gemmi = H.gemmi_sf(structure, scene.hkl_list) + with torch.no_grad(): + F_perm = H.ds_aniso_oracle(scene, u6=scene.u6[:, list(perm)]) + + rel = H.rel_l2(F_perm, F_gemmi) + print(f"\n permuted {label:24s} relL2 {rel:.3e} (gate is {RTOL_VS_GEMMI:.0e})") + assert rel > 100 * RTOL_VS_GEMMI, ( + f"permuting {label} changed the result by only {rel:.2e}; the aniso comparison " + "does not constrain the off-diagonal ordering" + ) + + +def test_sfds_matches_gemmi_with_symmetry(gemmi_iso_symmetry): + """Symmetry algebra against a second implementation, in a hexagonal group. + + ``P 65 2 2`` is chosen because ``h.R`` and ``R.h`` agree for symmetric rotation + matrices and differ for trigonal/hexagonal ones -- so an orthorhombic or tetragonal + entry could not detect the transpose error that + ``tests/unit/symmetry/test_hkl_symmetry_gemmi.py`` documents. + + This is also the only symmetric comparison in the package. A DS-vs-FFT check cannot + validate symmetry, because both routes call the same + ``compute_symmetry_equivalent_hkls`` / ``compute_translation_phases`` and the shared + algebra cancels; gemmi does not share it. + """ + scene, structure = gemmi_iso_symmetry + assert len(structure.cell.images) > 0, "structure was not set up with symmetry" + + F_gemmi = H.gemmi_sf(structure, scene.hkl_list) + ds = SfDS( + cell=scene.cell, + spacegroup=scene.spacegroup, + dtype_float=torch.float64, + device=torch.device("cpu"), + ) + with torch.no_grad(): + F_sym, _ = ds.compute_structure_factors( + scene.hkl, scene.xyz, scene.adp, scene.occ, scene.A, scene.B, + apply_symmetry=True, + ) + + print(f"\n{scene.spacegroup}, {len(structure.cell.images) + 1} operations") + rel, mrel, scale = _report("SfDS(sym) vs gemmi", F_sym, F_gemmi) + assert rel < RTOL_VS_GEMMI + assert mrel < MAXREL_VS_GEMMI + assert abs(scale - 1.0) < SCALE_TOL_GEMMI + + +# --------------------------------------------------------------------------- +# DS -> FFT +# --------------------------------------------------------------------------- +@pytest.mark.parametrize("engine", [Engine.AUTO, Engine.EAGER], ids=["auto", "eager"]) +@pytest.mark.parametrize("dtype", _DTYPES, ids=["f32", "f64"]) +@pytest.mark.parametrize("kind", ["iso", "aniso"]) +def test_fft_matches_ds_amplitudes(scene_fine, oracle_fine, kind, dtype, engine): + """The headline gate: the map route reproduces the analytic answer. + + The DS reference stays in float64 whatever the candidate's dtype -- an oracle should + not inherit the precision of the thing it is judging. + """ + aniso = kind == "aniso" + sf = H.sf_fft_for(scene_fine, dtype) + with use_engine(engine), torch.no_grad(): + F_fft = H.fft_sf(scene_fine, sf, aniso=aniso).to(torch.complex128) + F_ds = oracle_fine[f"{kind}_F"] + + print(f"\ngrid {tuple(int(v) for v in sf.gridsize)}, {kind}, {dtype}, {engine.value}") + rel, mrel, scale = _report(f"FFT vs DS ({kind})", F_fft, F_ds) + assert rel < RTOL_AMPLITUDE, f"{kind}/{dtype}/{engine.value}: rel L2 {rel:.3e}" + cos = float( + (F_fft.conj() * F_ds).real.sum() + / (F_fft.abs().norm() * F_ds.abs().norm()).clamp_min(1e-30) + ) + assert cos > COS_MIN, f"phase disagreement: cos {cos:.6f}" + + +def test_fft_absolute_scale(scene_fine, oracle_fine): + """Absolute scale, which nothing in the suite checked before. + + The FFT route picks up a voxel-volume factor from ``ifft`` + (``torchref/base/fourier/fft.py``) while DS is a bare atom sum. A factor of ``V``, + of ``N`` voxels, or of ``V/N`` would leave every ratio-, cosine- and parity-based + gate in the repo perfectly happy -- correlation stays 1.0 under a global rescale. + """ + sf = H.sf_fft_for(scene_fine) + with torch.no_grad(): + F_fft = H.fft_sf(scene_fine, sf) + scale = H.best_fit_scale(F_fft, oracle_fine["iso_F"]) + print(f"\n best-fit scale {scale:.9f}") + assert abs(scale - 1.0) < SCALE_TOL, ( + f"absolute scale off by {abs(scale - 1.0):.3e}; suspect a voxel-volume or " + f"grid-size normalization factor" + ) + + +def test_fft_matches_ds_monoclinic(scene_monoclinic, oracle_monoclinic): + """beta = 115 deg. A truncation that is spherical in fractional rather than + Cartesian space, or centred on a grid node rather than the atom, diverges most in a + strongly non-orthogonal metric -- measured 5.0e-3 rel L2 for the node-centred + variant at this angle, against 9.9e-4 orthorhombic.""" + sf = H.sf_fft_for(scene_monoclinic) + with torch.no_grad(): + F_fft = H.fft_sf(scene_monoclinic, sf) + rel, _, _ = _report("FFT vs DS (beta=115)", F_fft, oracle_monoclinic["iso_F"]) + assert rel < RTOL_AMPLITUDE + + +@pytest.mark.parametrize("dtype", _DTYPES, ids=["f32", "f64"]) +@pytest.mark.parametrize("kind", ["iso", "aniso"]) +def test_backend_parity_auto_vs_eager(scene_fine, kind, dtype): + """AUTO and EAGER implement the same truncation contract, so they must agree to + float noise -- far tighter than either agrees with DS. This separates a kernel bug + from the shared truncation error that ``test_fft_matches_ds_amplitudes`` bounds.""" + aniso = kind == "aniso" + sf = H.sf_fft_for(scene_fine, dtype) + with torch.no_grad(): + with use_engine(Engine.AUTO): + auto = H.fft_sf(scene_fine, sf, aniso=aniso).to(torch.complex128) + with use_engine(Engine.EAGER): + eager = H.fft_sf(scene_fine, sf, aniso=aniso).to(torch.complex128) + tol = RTOL_BACKEND_F32 if dtype is torch.float32 else RTOL_BACKEND_F64 + rel = H.rel_l2(auto, eager) + print(f"\n AUTO vs EAGER ({kind}, {dtype}): relL2 {rel:.3e} gate {tol:.0e}") + assert rel < tol + + +# --------------------------------------------------------------------------- +# Truncation budget +# --------------------------------------------------------------------------- +def test_nsigma_reduces_truncation_error(scene_fine, oracle_fine, sigma_cutoff): + """Raising the cutoff must monotonically improve agreement with DS. + + Asserted as a trend rather than an absolute number, for two reasons: it puts a test + behind the N_sigma claim at ``torchref/config.py:196`` which until now existed only + as a comment, and it stays valid whichever default the cutoff ends up at, so this + test does not have to be retuned alongside that decision. + """ + # Explicitly finer than production: at d_min/3 the cutoff is not the binding + # constraint (5.89e-3 -> 5.86e-3 then flat), so a monotonicity assertion there + # would be measuring grid sampling. See GRID_FINENESS in helpers.py. + sf = H.sf_fft_for(scene_fine, fineness=1.6) + residuals = [] + for n_sigma in (2.5, 3.0, 3.5, 4.5): + sigma_cutoff(n_sigma) + with torch.no_grad(): + F_fft = H.fft_sf(scene_fine, sf) + residuals.append((n_sigma, H.rel_l2(F_fft, oracle_fine["iso_F"]))) + + print() + for n_sigma, rel in residuals: + print(f" n_sigma {n_sigma:4.1f} relL2 vs DS {rel:.3e}") + + values = [r for _, r in residuals] + assert values == sorted(values, reverse=True), ( + f"truncation error is not monotone in n_sigma: {residuals}" + ) + assert values[0] > values[-1] * 2, ( + "tightening the cutoff barely changed the residual, so this scene is " + "sampling-limited rather than truncation-limited and does not test the cutoff" + ) + + +def test_coarse_grid_is_measurably_worse(scene_coarse, scene_fine, oracle_fine): + """Non-vacuity guard for ``RTOL_AMPLITUDE``. + + If an under-sampled grid also passed the gate, the gate would be measuring nothing. + This pins the sampling-dominated regime separately so a regression that degraded + grid sampling cannot hide behind the fine-grid tolerance. + """ + # Bare Nyquist (oversampling 2 rather than production's 3). + sf_coarse = H.sf_fft_for(scene_fine, fineness=2.0 / 3.0) + with torch.no_grad(): + F_coarse = H.fft_sf(scene_fine, sf_coarse) + rel = H.rel_l2(F_coarse, oracle_fine["iso_F"]) + print( + f"\n coarse grid {tuple(int(v) for v in sf_coarse.gridsize)}: " + f"relL2 {rel:.3e} (fine-grid gate is {RTOL_AMPLITUDE:.0e})" + ) + assert rel > RTOL_AMPLITUDE, ( + "an under-sampled grid passes the amplitude gate, so the gate is not " + "constraining grid sampling at all" + ) diff --git a/tests/unit/sf_oracle/test_gradients.py b/tests/unit/sf_oracle/test_gradients.py new file mode 100644 index 0000000..f6c4549 --- /dev/null +++ b/tests/unit/sf_oracle/test_gradients.py @@ -0,0 +1,294 @@ +"""First-order gradients: FD -> direct summation -> the FFT/splat route. + +Three links, in order: + +1. ``gradcheck`` proves the eager DS path's gradients against finite differences. DS is + analytic, so FD converges against it and this gate is tight. +2. The *production* DS path (``_checkpointed_*``, what ``ds_iso`` and ``SfDS`` actually + call) is tied to the eager oracle. This link is load-bearing: without it, "the oracle + is correct" would say nothing about the code that ships. +3. The FFT/splat route is compared against the oracle, at the loose gate the truncation + and sampling error actually warrants. +""" + +from __future__ import annotations + +import math + +import pytest +import torch + +from tests.helpers.grad_asserts import ( + assert_grads_agree, + cosine_similarity, + gradnorm_ratio, + rel_error, +) +from torchref.base.direct_summation.dispatch import ( + _checkpointed_aniso, + _checkpointed_iso, + _eager_aniso, + _eager_iso, +) +from torchref.utils import Engine, use_engine + +from . import ( + ATOL_GRADCHECK, + COS_MIN_GRADIENT, + COS_MIN_GRADIENT_SYNTHETIC, + EPS_GRADCHECK, + RTOL_BACKEND_GRAD_F32, + RTOL_BACKEND_GRAD_F64, + RTOL_GRADCHECK, + RTOL_GRADIENT, + RTOL_GRADIENT_SYNTHETIC, +) +from . import helpers as H + +pytestmark = pytest.mark.unit + +_DTYPES = [torch.float32, torch.float64] # float32 first: production dtype +_LEAF_NAMES = ("xyz", "occ", "adp_or_u") + + +# --------------------------------------------------------------------------- +# 1. FD -> DS +# --------------------------------------------------------------------------- +@pytest.mark.parametrize("kind", ["iso", "aniso"]) +def test_ds_gradcheck(scene_small, kind): + """The oracle's own gradients, against finite differences. + + Run on the deliberately tiny ``scene_small`` because ``gradcheck`` costs + O(n_params) forward evaluations -- 430 ms at 10 atoms, 75 s at 60. The property being + proved (that eager DS differentiates correctly) is scene-independent, so there is + nothing to gain from a larger scene and a great deal of wall-clock to lose. + """ + s = scene_small + if kind == "iso": + fn = lambda x, o, a: _eager_iso(s.hkl, s.s, x, o, a, s.A, s.B, None) # noqa: E731 + third = s.adp + else: + fn = lambda x, o, u: _eager_aniso( # noqa: E731 + s.hkl, s.s_vec, x, o, u, s.A, s.B, None + ) + third = s.u6 + + args = tuple( + t.clone().requires_grad_(True) for t in (s.xyz_frac, s.occ, third) + ) + assert torch.autograd.gradcheck( + fn, args, eps=EPS_GRADCHECK, atol=ATOL_GRADCHECK, rtol=RTOL_GRADCHECK + ) + + +# --------------------------------------------------------------------------- +# 2. The production DS path matches the oracle +# --------------------------------------------------------------------------- +@pytest.mark.parametrize("chunked", [False, True], ids=["one_chunk", "chunked"]) +@pytest.mark.parametrize("kind", ["iso", "aniso"]) +def test_checkpointed_matches_eager_oracle(scene_fine, kind, chunked): + """``_checkpointed_*`` -- reached by every ``ds_iso``/``ds_aniso``/``SfDS`` call on + CPU, MPS or float64 -- must reproduce the eager oracle in both value and gradient. + + This is the link that makes ``_eager_*`` a legitimate stand-in for the shipping code. + ``_CheckpointedSF`` recomputes each chunk under ``enable_grad`` in its backward, so + the gradient path is genuinely different from eager even though the maths is the + same; ``chunked`` forces multiple chunks via a tiny memory budget so that recompute + loop is actually exercised rather than degenerating to one pass. + + Measured agreement is 2.3e-16, so this is gated near machine precision. It is + deliberately far tighter than anything involving the map route: any drift here is a + bug, not an approximation. + """ + s = scene_fine + max_mem = 1e-7 if chunked else None + if kind == "iso": + eager = lambda x, o, a: _eager_iso(s.hkl, s.s, x, o, a, s.A, s.B, max_mem) # noqa: E731 + prod = lambda x, o, a: _checkpointed_iso( # noqa: E731 + s.hkl, s.s, x, o, a, s.A, s.B, max_mem + ) + third = s.adp + else: + eager = lambda x, o, u: _eager_aniso( # noqa: E731 + s.hkl, s.s_vec, x, o, u, s.A, s.B, max_mem + ) + prod = lambda x, o, u: _checkpointed_aniso( # noqa: E731 + s.hkl, s.s_vec, x, o, u, s.A, s.B, max_mem + ) + third = s.u6 + + with torch.no_grad(): + obs = H.synthetic_obs(eager(s.xyz_frac, s.occ, third)) + + def run(fn): + leaves = tuple( + t.clone().requires_grad_(True) for t in (s.xyz_frac, s.occ, third) + ) + F = fn(*leaves) + grads = torch.autograd.grad(H.ls_target(F, obs), leaves) + return F.detach(), grads + + F_eager, g_eager = run(eager) + F_prod, g_prod = run(prod) + + assert rel_error(F_prod, F_eager) < 1e-13, "checkpointed forward drifted from eager" + assert_grads_agree( + dict(zip(_LEAF_NAMES, g_prod)), + dict(zip(_LEAF_NAMES, g_eager)), + min_cos=1.0 - 1e-12, + ratio_tol=1e-10, + ctx=f"{kind}/{'chunked' if chunked else 'one_chunk'} ", + ) + + +def test_public_ds_api_matches_oracle(scene_fine): + """The same check one level up, through ``ds_iso``'s own dispatch. + + Guards against the dispatch layer -- not the kernel -- introducing a discrepancy: + a wrong ``max_memory_gb`` default, or an ``Engine`` gate that silently routes + somewhere unintended on CPU. + """ + from torchref.base.direct_summation.dispatch import ds_iso + + s = scene_fine + with torch.no_grad(): + obs = H.synthetic_obs(_eager_iso(s.hkl, s.s, s.xyz_frac, s.occ, s.adp, s.A, s.B, None)) + + leaves = tuple(t.clone().requires_grad_(True) for t in (s.xyz_frac, s.occ, s.adp)) + F = ds_iso(s.hkl, s.s, *leaves, s.A, s.B) + g_public = torch.autograd.grad(H.ls_target(F, obs), leaves) + + leaves2 = tuple(t.clone().requires_grad_(True) for t in (s.xyz_frac, s.occ, s.adp)) + F2 = _eager_iso(s.hkl, s.s, *leaves2, s.A, s.B, None) + g_oracle = torch.autograd.grad(H.ls_target(F2, obs), leaves2) + + assert rel_error(F, F2) < 1e-13 + assert_grads_agree( + dict(zip(_LEAF_NAMES, g_public)), + dict(zip(_LEAF_NAMES, g_oracle)), + min_cos=1.0 - 1e-12, + ratio_tol=1e-10, + ctx="ds_iso ", + ) + + +# --------------------------------------------------------------------------- +# 3. DS -> FFT +# --------------------------------------------------------------------------- +@pytest.mark.parametrize("engine", [Engine.AUTO, Engine.EAGER], ids=["auto", "eager"]) +@pytest.mark.parametrize("dtype", _DTYPES, ids=["f32", "f64"]) +@pytest.mark.parametrize("kind", ["iso", "aniso"]) +def test_fft_gradients_match_ds(scene_fine, oracle_fine, kind, dtype, engine): + """Gradients of the map route against the analytic oracle, for all three leaves. + + ``occ`` is included deliberately. Before this package, occupancy gradients through + the FFT route were untested at any order -- the previous FFT gradient test + parametrized only ``xyz`` and ``adp`` -- and ``occ`` is the one leaf whose gradient a + scene with ``occ == 1`` everywhere cannot constrain, which is why + :func:`helpers.synthetic_scene` never uses unit occupancy. + + Gradients are roughly 10x more truncation-sensitive than amplitudes, because each + ``xyz`` derivative brings a factor ~``2*pi*h``. Hence ``RTOL_GRADIENT`` an order of + magnitude above ``RTOL_AMPLITUDE`` rather than the same number. + """ + aniso = kind == "aniso" + sf = H.sf_fft_for(scene_fine, dtype) + leaves = scene_fine.leaves(aniso=aniso) + with use_engine(engine): + F = H.fft_sf(scene_fine, sf, *leaves, aniso=aniso) + got = torch.autograd.grad(H.ls_target(F, oracle_fine[f"{kind}_obs"]), leaves) + ref = oracle_fine[f"{kind}_grads"] + + gate, cos_gate = RTOL_GRADIENT_SYNTHETIC, COS_MIN_GRADIENT_SYNTHETIC + print(f"\n{kind}, {dtype}, {engine.value} (rel gate {gate:.0e}, cos gate {cos_gate})") + for name, g, r in zip(_LEAF_NAMES, got, ref): + rel, cos = rel_error(g, r), cosine_similarity(g, r) + print(f" {name:10s} rel {rel:.3e} cos {cos:.8f} ratio {gradnorm_ratio(g, r):.4f}") + assert rel < gate, f"{kind}/{dtype}/{engine.value} {name}: rel {rel:.3e}" + assert cos > cos_gate, f"{kind}/{dtype}/{engine.value} {name}: cos {cos:.6f}" + + +@pytest.mark.parametrize("dtype", _DTYPES, ids=["f32", "f64"]) +@pytest.mark.parametrize("kind", ["iso", "aniso"]) +def test_backend_parity_of_gradients(scene_fine, oracle_fine, kind, dtype): + """AUTO and EAGER share one truncation contract, so their gradients must agree far + more tightly than either agrees with DS. Separates a kernel-specific backward bug + from the shared truncation residual.""" + aniso = kind == "aniso" + sf = H.sf_fft_for(scene_fine, dtype) + + def run(engine): + leaves = scene_fine.leaves(aniso=aniso) + with use_engine(engine): + F = H.fft_sf(scene_fine, sf, *leaves, aniso=aniso) + return torch.autograd.grad(H.ls_target(F, oracle_fine[f"{kind}_obs"]), leaves) + + auto, eager = run(Engine.AUTO), run(Engine.EAGER) + tol = RTOL_BACKEND_GRAD_F32 if dtype is torch.float32 else RTOL_BACKEND_GRAD_F64 + for name, a, e in zip(_LEAF_NAMES, auto, eager): + rel = rel_error(a, e) + print(f" {kind}/{dtype} {name:10s} AUTO vs EAGER rel {rel:.3e} (gate {tol:.0e})") + assert rel < tol, f"{kind}/{dtype} {name}: AUTO vs EAGER rel {rel:.3e}" + + +def test_occupancy_gradient_is_not_degenerate(scene_fine, oracle_fine): + """Non-vacuity guard: the ``occ`` gradient must be non-trivial and atom-dependent. + + An all-equal or all-zero ``occ`` gradient would satisfy the cosine and relative gates + above while carrying no information, which is exactly what a scene with uniform + occupancy produces. + """ + g_occ = oracle_fine["iso_grads"][1] + assert g_occ.abs().min() > 0, "some occupancy gradient is exactly zero" + spread = float(g_occ.std() / g_occ.abs().mean()) + print(f"\n occ gradient relative spread across atoms: {spread:.3f}") + assert spread > 0.05, ( + "occupancy gradients are nearly identical across atoms, so the comparison " + "cannot distinguish a correct per-atom gradient from a constant" + ) + + +# --------------------------------------------------------------------------- +# 4. The authoritative gate: a real structure +# --------------------------------------------------------------------------- +def test_fft_gradients_match_ds_real_structure(gemmi_aniso_grad, oracle_aniso_grad): + """Gradients on 7L84 -- 1209 atoms, all with ANISOU -- at production sampling. + + This is the gate that states something about production; the synthetic sweeps above + exist for dtype/engine/cell coverage and are calibrated loosely because small scenes + overstate the discretization residual (see ``__init__.py``). + + Run in float32, the production dtype, against the float64 oracle. Nothing here is + tuned: the numbers came out at 1.35e-02 (xyz) and 2.62e-02 (U) against the 1e-01 gate + you specified, so there is ~4x headroom without the gate being vacuous -- the + under-sampled control in ``test_forward.py`` shows what breaching it looks like. + """ + scene, _ = gemmi_aniso_grad + sf = H.sf_fft_for(scene, torch.float32) + leaves = scene.leaves(aniso=True) + got = torch.autograd.grad( + H.ls_target(H.fft_sf(scene, sf, *leaves, aniso=True), oracle_aniso_grad["aniso_obs"]), + leaves, + ) + ref = oracle_aniso_grad["aniso_grads"] + + print(f"\n7L84 P1, {scene.n_atoms} ANISOU atoms, {scene.n_refl} refl, float32") + print(f" grid {tuple(int(v) for v in sf.gridsize)}") + for name, g, r in zip(_LEAF_NAMES, got, ref): + rel, cos, ratio = rel_error(g, r), cosine_similarity(g, r), gradnorm_ratio(g, r) + print(f" {name:10s} rel {rel:.3e} cos {cos:.8f} ratio {ratio:.4f}") + assert rel < RTOL_GRADIENT, f"7L84 {name}: rel {rel:.3e}" + assert cos > COS_MIN_GRADIENT, f"7L84 {name}: cos {cos:.6f}" + + # The deviatoric part specifically -- an earlier revision of this package recorded a + # 54% bias here from a 10-atom synthetic scene. On a real structure it is not present, + # and this assertion is what keeps that claim from creeping back. + dev = lambda g: g[:, :3] - g[:, :3].mean(dim=1, keepdim=True) # noqa: E731 + dev_ratio = gradnorm_ratio(dev(got[2]), dev(ref[2])) + print(f" deviatoric U ratio {dev_ratio:.4f}") + assert abs(dev_ratio - 1.0) < 0.05, ( + f"deviatoric anisotropic ADP gradient is biased by {abs(dev_ratio - 1.0):.1%} on a " + "real structure. That would justify revisiting the grid oversampling or a B_extra " + "smearing correction; it was measured at 0.9995 when this test was written." + ) + diff --git a/tests/unit/sf_oracle/test_second_order.py b/tests/unit/sf_oracle/test_second_order.py new file mode 100644 index 0000000..c78a235 --- /dev/null +++ b/tests/unit/sf_oracle/test_second_order.py @@ -0,0 +1,339 @@ +"""Second derivatives: FD -> direct summation -> the FFT/splat route. + +Why direct summation and not finite differences for the map route +---------------------------------------------------------------- +The two tests this module replaces -- +``test_kernel_fixes.py::test_cpu_plain_scatter_second_derivative_correct`` and +``::test_cpu_cpp_scatter_second_derivative_correct`` -- compared the map route's HVP +against central differences *of the map route*, and were failing. + +The reference was the problem, not the kernel -- but not for the reason an earlier draft +of this module claimed. That draft argued finite differences *diverge* on a truncated +density, because the cull surface moves with the atom and each voxel crossing contributes +a fixed jump. Measured, that does not happen: FD converges normally, reaching rel 5.2e-04 +against the map route's own autograd HVP. At a 3 sigma cutoff the surface density is ~1% +of peak, so the jumps are too small to matter. + +The actual problem is that FD asks the wrong question. It differentiates the *same* +discretized function the kernel does, so it can only confirm that autograd correctly +differentiates whatever the kernel computes. Measured here: the map route's HVP agrees +with itself to 5e-04 while sitting 2.3e-02 from the analytic answer. An FD-based gate at +5e-03 -- what the replaced tests used -- passes comfortably while the derivative under +test is 2.3e-02 wrong. + +Direct summation has no grid and no truncation, so FD *is* a valid reference for it +(measured 1.19e-10), and it is then an independent reference for the map route. +:func:`test_finite_differences_cannot_detect_map_route_error` measures all of this, so the +reasoning is pinned by numbers rather than asserted in prose. + +The regression the original two tests guarded is preserved: a graph-less C++ backward +that produced a measured cosine of ~0.57 while first-order gradients stayed correct. +That is still caught here, against a reference that converges. +""" + +from __future__ import annotations + +import pytest +import torch + +from tests.helpers.grad_asserts import cosine_similarity, hvp, hvp_central_fd, rel_error +from torchref.base.direct_summation.dispatch import _eager_aniso, _eager_iso +from torchref.utils import Engine, use_engine + +from . import ( + ATOL_GRADCHECK, + COS_MIN, + EPS_GRADCHECK, + RTOL_DS_HVP_VS_FD, + RTOL_GRADCHECK, + RTOL_HVP, +) +from . import helpers as H + +pytestmark = pytest.mark.unit + +_DTYPES = [torch.float32, torch.float64] # float32 first: production dtype + + +# --------------------------------------------------------------------------- +# 1. The oracle is sound at second order +# --------------------------------------------------------------------------- +@pytest.mark.parametrize("kind", ["iso", "aniso"]) +def test_ds_gradgradcheck(scene_small, kind): + """``gradgradcheck`` on the eager DS path -- the first in this repo. + + ``grep gradgradcheck`` over ``torchref/`` and ``tests/`` previously returned nothing, + so no second derivative anywhere had been checked against finite differences at the + autograd level. Everything second-order was either cross-backend parity or an HVP + against an FD reference on the truncated map route. + """ + s = scene_small + if kind == "iso": + fn = lambda x, o, a: _eager_iso(s.hkl, s.s, x, o, a, s.A, s.B, None) # noqa: E731 + third = s.adp + else: + fn = lambda x, o, u: _eager_aniso( # noqa: E731 + s.hkl, s.s_vec, x, o, u, s.A, s.B, None + ) + third = s.u6 + + args = tuple(t.clone().requires_grad_(True) for t in (s.xyz_frac, s.occ, third)) + assert torch.autograd.gradgradcheck( + fn, args, eps=EPS_GRADCHECK, atol=ATOL_GRADCHECK, rtol=RTOL_GRADCHECK + ) + + +@pytest.mark.parametrize("kind", ["iso", "aniso"]) +def test_ds_hvp_matches_finite_differences(scene_small, kind): + """The oracle's HVP against central differences of its own gradient. + + Complements ``gradgradcheck``: that checks the full double-backward machinery + elementwise on a tiny input, this checks the specific contraction the map-route tests + use, so the two links are compared like for like. + """ + s = scene_small + fn = H.ds_aniso_oracle if kind == "aniso" else H.ds_iso_oracle + third = s.u6 if kind == "aniso" else s.adp + with torch.no_grad(): + obs = H.synthetic_obs(fn(s, s.xyz, s.occ, third)) + loss = lambda x: H.ls_target(fn(s, x, s.occ, third), obs) # noqa: E731 + + g = torch.Generator().manual_seed(17) + v = torch.randn(s.xyz.shape, generator=g, dtype=s.xyz.dtype) + v /= v.norm() + + got = hvp(loss, s.xyz, v) + ref = hvp_central_fd(loss, s.xyz, v, eps=1e-6) + rel, cos = rel_error(got, ref), cosine_similarity(got, ref) + print(f"\n {kind}: DS HVP vs central FD -- rel {rel:.3e} cos {cos:.10f}") + assert rel < RTOL_DS_HVP_VS_FD + assert cos > 1.0 - 1e-8 + + +# --------------------------------------------------------------------------- +# 2. The first-order-only contract of the public DS API, made explicit +# --------------------------------------------------------------------------- +def test_public_ds_api_raises_on_double_backward(scene_small): + """``ds_iso`` and ``SfDS`` are first-order only. Assert it, so it is documented. + + ``_CheckpointedSF.backward`` calls ``torch.autograd.grad`` without + ``create_graph=True`` on detached copies + (``torchref/base/direct_summation/dispatch.py:197``), so the returned gradients carry + no graph. It is *not* decorated ``@once_differentiable``, so nothing warns you; the + failure surfaces only when a second derivative is requested. + + It does at least fail loudly rather than returning zeros -- verified: it raises + ``element 0 of tensors does not require grad``. This test pins that, so if anyone + later makes the checkpointed path double-differentiable it fails here and the oracle + guidance in this package's docstring gets revisited rather than quietly going stale. + """ + from torchref.base.direct_summation.dispatch import ds_iso + + s = scene_small + x = s.xyz_frac.clone().requires_grad_(True) + F = ds_iso(s.hkl, s.s, x, s.occ, s.adp, s.A, s.B) + with torch.no_grad(): + obs = H.synthetic_obs(F) + (g1,) = torch.autograd.grad(H.ls_target(F, obs), x, create_graph=True) + + v = torch.ones_like(x) + with pytest.raises(RuntimeError, match="does not require grad"): + torch.autograd.grad((g1 * v).sum(), x) + + +def test_eager_ds_survives_double_backward_where_public_api_does_not(scene_small): + """The positive half of the pair above: the eager path *does* compose. + + Together these two tests state the rule this package depends on -- use ``_eager_*`` + as the second-order oracle, never ``ds_*``/``SfDS`` -- as executable fact rather than + as a comment that can drift. + """ + s = scene_small + x = s.xyz_frac.clone().requires_grad_(True) + F = _eager_iso(s.hkl, s.s, x, s.occ, s.adp, s.A, s.B, None) + with torch.no_grad(): + obs = H.synthetic_obs(F.detach()) + (g1,) = torch.autograd.grad(H.ls_target(F, obs), x, create_graph=True) + (out,) = torch.autograd.grad((g1 * torch.ones_like(x)).sum(), x) + assert torch.isfinite(out).all() and out.abs().sum() > 0 + + +# --------------------------------------------------------------------------- +# 3. DS -> FFT at second order +# --------------------------------------------------------------------------- +@pytest.mark.parametrize("engine", [Engine.AUTO, Engine.EAGER], ids=["auto", "eager"]) +@pytest.mark.parametrize("dtype", _DTYPES, ids=["f32", "f64"]) +@pytest.mark.parametrize("kind", ["iso", "aniso"]) +def test_fft_hvp_matches_ds(scene_fine, oracle_fine, kind, dtype, engine): + """The map route's HVP against the DS oracle's HVP. + + This is the replacement for the two failing FD-based tests, and it covers strictly + more: both dtypes and both engines, where the originals covered float64-plain and + float32-C++ only, each with its own hand-tuned ``eps``. + + Both sides contract with the same seeded direction ``v`` (from the oracle fixture), + since an HVP is only comparable along a shared direction. + """ + aniso = kind == "aniso" + sf = H.sf_fft_for(scene_fine, dtype) + v = oracle_fine[f"{kind}_v"] + _, occ, third = scene_fine.leaves(aniso=aniso, requires_grad=False) + + def loss(x): + return H.ls_target( + H.fft_sf(scene_fine, sf, x, occ, third, aniso=aniso), + oracle_fine[f"{kind}_obs"], + ) + + with use_engine(engine): + got = hvp(loss, scene_fine.xyz, v) + ref = oracle_fine[f"{kind}_hvp"] + + rel, cos = rel_error(got, ref), cosine_similarity(got, ref) + print(f"\n {kind}/{dtype}/{engine.value}: HVP vs DS -- rel {rel:.3e} cos {cos:.8f}") + assert cos > COS_MIN, f"{kind}/{dtype}/{engine.value}: HVP direction, cos {cos:.6f}" + assert rel < RTOL_HVP, f"{kind}/{dtype}/{engine.value}: HVP magnitude, rel {rel:.3e}" + + +@pytest.mark.parametrize("dtype", _DTYPES, ids=["f32", "f64"]) +def test_fused_cpu_kernel_uses_the_double_backward_fallback( + scene_fine, oracle_fine, dtype, monkeypatch +): + """Non-vacuity guard for the AUTO second-order path. + + The fused C++ sphere splat has a hand-written first-order backward with no graph, so + under ``create_graph=True`` it routes through ``_double_backward_vjp``, which re-runs + the forward through the portable torch splat on the *saved* leaves and differentiates + that. Without this guard, ``test_fft_hvp_matches_ds[auto]`` would still pass if AUTO + silently fell back to the eager path for the whole forward -- and would then be + testing EAGER twice under two names. + + Asserts the fallback is entered, which is only meaningful alongside a separate + assertion that the fused kernel was used in the first place. + """ + from torchref.base.electron_density.kernels.cpu import sphere_splat + + if not sphere_splat.sphere_splat_available(): + pytest.skip(f"fused CPU sphere splat unavailable: {sphere_splat.last_error()}") + + calls = {"fallback": 0} + original = sphere_splat._double_backward_vjp + + def counting(*args, **kwargs): + calls["fallback"] += 1 + return original(*args, **kwargs) + + monkeypatch.setattr(sphere_splat, "_double_backward_vjp", counting) + + sf = H.sf_fft_for(scene_fine, dtype) + v = torch.ones_like(scene_fine.xyz) + _, occ, adp = scene_fine.leaves(requires_grad=False) + obs = oracle_fine["iso_obs"] + + def loss(x): + return H.ls_target(H.fft_sf(scene_fine, sf, x, occ, adp), obs) + + with use_engine(Engine.AUTO): + out = hvp(loss, scene_fine.xyz, v) + + assert calls["fallback"] > 0, ( + "AUTO produced an HVP without entering _double_backward_vjp, so the fused " + "kernel was not on the path and this dtype's second-order coverage is vacuous" + ) + assert torch.isfinite(out).all() and out.abs().sum() > 0 + + +# --------------------------------------------------------------------------- +# 4. The measurement that justifies choosing DS over finite differences +# --------------------------------------------------------------------------- +def test_finite_differences_cannot_detect_map_route_error(scene_fine, oracle_fine): + """Finite differences confirm self-consistency, not correctness. Measured. + + This test was originally written to demonstrate an *FD plateau* on the map route -- + the argument being that a hard truncation whose cull surface moves with the atom + contributes a fixed jump per crossing, so the spurious term shrinks no faster than the + divisor. That argument is inherited from an earlier draft and the measurement does not + support it: FD converges normally here, reaching rel 5.2e-04 against the map route's + own autograd HVP at ``eps = 1e-2``, with the usual V-shape (truncation-dominated at + large ``eps``, round-off at small). At the 3 sigma cutoff the density at the surface is + ~1% of peak, so the jumps are evidently too small to matter. + + The measurement supports a stronger conclusion instead. The map route's HVP agrees + with *itself* to 5e-04 while sitting 2.3e-02 away from the analytic answer. So an + FD-based test gated at the 5e-03 the replaced tests used passes comfortably while the + derivative under test is 2.3e-02 wrong -- FD cannot see that error at all, because it + differentiates the same discretized function. Only an independent reference can. + + That is why this package uses DS. Not because FD diverges, but because FD is measuring + the wrong thing: it validates that autograd correctly differentiates whatever the + kernel computes, and is blind by construction to whether the kernel computes the right + thing. + """ + sf = H.sf_fft_for(scene_fine) + v = oracle_fine["iso_v"] + _, occ, adp = scene_fine.leaves(requires_grad=False) + obs = oracle_fine["iso_obs"] + + def loss(x): + return H.ls_target(H.fft_sf(scene_fine, sf, x, occ, adp), obs) + + auto = hvp(loss, scene_fine.xyz, v) + ref_ds = oracle_fine["iso_hvp"] + + print("\n eps rel vs autograd(map) rel vs DS oracle") + rows = [] + for eps in (1e-4, 1e-3, 1e-2, 1e-1): + fd = hvp_central_fd(loss, scene_fine.xyz, v, eps=eps) + r_self, r_ds = rel_error(fd, auto), rel_error(fd, ref_ds) + rows.append((eps, r_self, r_ds)) + print(f" {eps:<9.0e} {r_self:18.3e} {r_ds:16.3e}") + + print(f" autograd(map) vs DS oracle: {rel_error(auto, ref_ds):.3e}") + + best_self = min(r for _, r, _ in rows) + best_vs_ds = min(r for _, _, r in rows) + map_vs_ds = rel_error(auto, ref_ds) + + # 1. FD does converge against the map route's own autograd. + assert best_self < 5e-3, ( + f"FD only reached rel {best_self:.2e} against the map route's autograd HVP. This " + "test's premise is that it converges; if that has stopped being true the " + "docstring's reasoning needs rewriting, not the tolerance." + ) + # 2. And is nonetheless blind to the real error, which is what matters. + assert best_vs_ds > 4 * best_self, ( + f"FD sits {best_vs_ds:.2e} from the analytic answer but {best_self:.2e} from the " + "map route's autograd. Those being comparable would mean the map route is now " + "accurate enough that FD and DS are interchangeable references, and this test " + "no longer demonstrates anything -- re-examine rather than retune." + ) + assert map_vs_ds > 5e-3, ( + f"the map route's HVP is now within {map_vs_ds:.2e} of the analytic answer, " + "which is inside the 5e-3 gate the replaced FD-based tests used -- so those " + "tests would no longer have been misleading and this rationale is stale" + ) + + +def test_fft_hvp_matches_ds_real_structure(gemmi_aniso_grad, oracle_aniso_grad): + """Second derivatives on 7L84 at production sampling, in the production dtype. + + The synthetic HVP sweep above covers dtype and engine combinations; this is the one + that speaks to production, for the same reason as its first-order counterpart in + ``test_gradients.py``. + """ + scene, _ = gemmi_aniso_grad + sf = H.sf_fft_for(scene, torch.float32) + v = oracle_aniso_grad["aniso_v"] + _, occ, u6 = scene.leaves(aniso=True, requires_grad=False) + obs = oracle_aniso_grad["aniso_obs"] + + def loss(x): + return H.ls_target(H.fft_sf(scene, sf, x, occ, u6, aniso=True), obs) + + got = hvp(loss, scene.xyz, v) + ref = oracle_aniso_grad["aniso_hvp"] + rel, cos = rel_error(got, ref), cosine_similarity(got, ref) + print(f"\n7L84 P1 HVP vs DS: rel {rel:.3e} cos {cos:.8f}") + assert cos > COS_MIN, f"7L84 HVP direction: cos {cos:.6f}" + assert rel < RTOL_HVP, f"7L84 HVP magnitude: rel {rel:.3e}" + diff --git a/tests/unit/symmetry/test_phase_convention.py b/tests/unit/symmetry/test_phase_convention.py new file mode 100644 index 0000000..2d6436e --- /dev/null +++ b/tests/unit/symmetry/test_phase_convention.py @@ -0,0 +1,233 @@ +"""Phase-shift sign convention for reciprocal-space symmetry operations. + +These tests pin the phase contract of :func:`expand_hkl`, :func:`canonicalize_hkl` +and :func:`reduce_hkl` against an *independent* reference: a direct structure-factor +summation over an explicitly symmetry-expanded atom set. That is the definition of a +structure factor, so it cannot drift in step with the implementation the way a +path-vs-path comparison can. + +Why this file exists +-------------------- +All three functions computed ``+2π h·t`` where the correct shift is ``-2π h·t`` +(and, for the Friedel-flipped rows of ``canonicalize_hkl``, ``+2π h·t`` -- the sign +is *not* uniform there; see that function's comment). The bug survived because the +residual error of the wrong sign is ``4π h·t mod 2π``, which is **exactly zero** for +2₁ screw axes and for centring translations -- and the pre-existing phase test +(``test_canonicalize_hkl.py::test_phase_roundtrip``) parametrised only over +``P21, P212121, C2, P4``, every one of which falls in that blind spot. + +The parametrisation below deliberately spans all three regimes: + +=================================== =========================== +translation error if the sign is flipped +=================================== =========================== +2₁ screws, centring (P21, C2, ...) 0 (invisible) +4₁/4₃ screws (P41, P43, P43212) π +3₁/6₁ screws (P31, P61, P3121, ...) 2π/3 +=================================== =========================== + +Do not narrow this list. ``test_wrong_sign_would_be_detected`` guards against it +becoming vacuous. +""" + +import numpy as np +import pytest +import torch + +from torchref.config import get_float_dtype +from torchref.symmetry.reciprocal_symmetry import ( + canonicalize_hkl, + expand_hkl, + reduce_hkl, +) +from torchref.symmetry.spacegroup import SpaceGroup + +# Groups spanning the three regimes above. P1 is the degenerate control (no +# translations at all); the screw-axis groups are the ones with real signal. +SPACE_GROUPS = [ + "P 1", + "P 21", + "P 21 21 21", + "C 1 2 1", + "P 41", + "P 43", + "P 31", + "P 61", + "P 43 21 2", + "P 31 2 1", + "P 65 2 2", + "I 41", +] + +# Groups where a flipped sign produces a non-zero phase error. Kept separate so the +# anti-vacuity test can assert the suite is actually sensitive to the regression. +SENSITIVE_GROUPS = ["P 41", "P 43", "P 31", "P 61", "P 43 21 2", "P 31 2 1", "P 65 2 2"] + +# Comfortably tighter than the smallest real error (2π/3 ≈ 2.09 rad) yet loose +# enough for the float32 default dtype the functions compute in (~1e-6 observed). +ATOL_RAD = 1e-4 + + +def _wrap(a): + """Wrap angles into (-π, π] so 2π-equivalent phases compare equal.""" + return (np.asarray(a) + np.pi) % (2 * np.pi) - np.pi + + +def _reference_model(spacegroup, n_atoms=10, seed=1): + """A symmetry-obeying atom set plus an exact ``F(h)`` evaluator. + + The atom list is the asymmetric unit expanded by every symmetry operation, so + the resulting structure genuinely has the space-group symmetry and its + structure factors must obey ``arg F(hR) - arg F(h) = -2π h·t``. Evaluated in + float64 regardless of the library's configured dtype -- this is the reference. + """ + rng = np.random.default_rng(seed) + sg = SpaceGroup(spacegroup, dtype=get_float_dtype(), device=torch.device("cpu")) + rot = sg.matrices.cpu().numpy().astype(np.float64) + trans = sg.translations.cpu().numpy().astype(np.float64) + + xyz_asu = rng.random((n_atoms, 3)) + f_asu = rng.uniform(4.0, 10.0, n_atoms) + xyz = np.concatenate([xyz_asu @ rot[i].T + trans[i] for i in range(len(rot))]) + f = np.concatenate([f_asu] * len(rot)) + + def structure_factor(hkl): + hkl = np.atleast_2d(np.asarray(hkl, dtype=np.float64)) + return (f[None, :] * np.exp(2j * np.pi * (hkl @ xyz.T))).sum(axis=1) + + return structure_factor + + +def _random_hkl(n=8, seed=3): + """Random non-zero Miller indices.""" + rng = np.random.default_rng(seed) + out = [] + while len(out) < n: + h = tuple(int(x) for x in rng.integers(-6, 7, 3)) + if h != (0, 0, 0): + out.append(h) + return torch.tensor(np.array(out), dtype=torch.int32) + + +@pytest.mark.unit +@pytest.mark.parametrize("spacegroup", SPACE_GROUPS) +def test_expand_hkl_phase_contract(spacegroup): + """``phi_expanded = phi_orig[indices] + phase_shifts`` must be the true phase. + + This is the contract stated in :func:`expand_hkl`'s own docstring. Checked with + ``include_friedel=False``: the Friedel half cannot be expressed as an additive + shift at all (``phi(-h) = -phi(h)`` is a conjugation, not an offset), so the + documented contract only applies to the pure-rotation expansion. + """ + fcalc = _reference_model(spacegroup) + hkl = _random_hkl() + phi_in = np.angle(fcalc(hkl.numpy())) + + hkl_p1, indices, shifts = expand_hkl( + hkl, spacegroup, include_friedel=False, remove_absences=True + ) + got = phi_in[indices.cpu().numpy()] + shifts.cpu().numpy() + expected = np.angle(fcalc(hkl_p1.numpy())) + + err = np.abs(_wrap(got - expected)).max() + assert err < ATOL_RAD, ( + f"{spacegroup}: expand_hkl phase contract violated by {err:.4f} rad. " + f"Expected arg F(hR) - arg F(h) == -2*pi*h.t." + ) + + +@pytest.mark.unit +@pytest.mark.parametrize("spacegroup", SPACE_GROUPS) +def test_canonicalize_hkl_phase_contract(spacegroup): + """``phi_canonical = where(friedel, -phi_in, phi_in) + phase_shifts``. + + This is the form :meth:`ReflectionData._canonicalize_in_place` applies. Note the + sign of the shift differs between the Friedel and non-Friedel halves, so a + uniform sign is necessarily wrong for one of them. + """ + fcalc = _reference_model(spacegroup) + # Feed a full-sphere, deliberately non-canonical set so real work is required. + hkl_all, _, _ = expand_hkl( + _random_hkl(), spacegroup, include_friedel=True, remove_absences=True + ) + phi_in = np.angle(fcalc(hkl_all.numpy())) + + canonical, shifts, friedel, sort_idx = canonicalize_hkl( + hkl_all, spacegroup, include_friedel=True + ) + f = friedel.cpu().numpy() + base = np.where(f, -phi_in[sort_idx.cpu().numpy()], phi_in[sort_idx.cpu().numpy()]) + got = base + shifts.cpu().numpy() + expected = np.angle(fcalc(canonical.numpy())) + + err = np.abs(_wrap(got - expected)).max() + assert err < ATOL_RAD, ( + f"{spacegroup}: canonicalize_hkl phase contract violated by {err:.4f} rad " + f"({f.sum()} of {len(f)} rows were Friedel-flipped)." + ) + + +@pytest.mark.unit +@pytest.mark.parametrize("spacegroup", SPACE_GROUPS) +def test_reduce_hkl_phase_contract(spacegroup): + """Every equivalent must reconstruct the ASU phase, not just the first. + + ``reduce_hkl`` returns one row per ASU reflection and a column per equivalent. + Column 0 is typically the identity operation (zero shift), so checking only that + column would pass regardless of the sign -- iterate over all of them. + """ + fcalc = _reference_model(spacegroup) + hkl_p1, _, _ = expand_hkl( + _random_hkl(), spacegroup, include_friedel=False, remove_absences=True + ) + phi_p1 = np.angle(fcalc(hkl_p1.numpy())) + + hkl_asu, red_idx, red_shift = reduce_hkl( + hkl_p1, spacegroup, include_friedel=False + ) + phi_asu = np.angle(fcalc(hkl_asu.numpy())) + idx, sh = red_idx.cpu().numpy(), red_shift.cpu().numpy() + + errs, n_checked = [], 0 + for row in range(idx.shape[0]): + for col in range(idx.shape[1]): + src = idx[row, col] + if src < 0: + continue + n_checked += 1 + errs.append(_wrap(phi_p1[src] + sh[row, col] - phi_asu[row])) + + assert n_checked >= idx.shape[0], "no equivalents were checked" + err = np.abs(errs).max() + assert err < ATOL_RAD, ( + f"{spacegroup}: reduce_hkl phase contract violated by {err:.4f} rad " + f"over {n_checked} equivalents." + ) + + +@pytest.mark.unit +@pytest.mark.parametrize("spacegroup", SENSITIVE_GROUPS) +def test_wrong_sign_would_be_detected(spacegroup): + """Anti-vacuity guard: the tests above must be *able* to fail. + + Re-runs the ``expand_hkl`` check with the shift negated and asserts it breaks. If + someone narrows ``SPACE_GROUPS`` back to only 2₁/centring groups, or a refactor + makes the shifts identically zero, the contract tests would pass trivially and + this test is what notices. + """ + fcalc = _reference_model(spacegroup) + hkl = _random_hkl() + phi_in = np.angle(fcalc(hkl.numpy())) + + hkl_p1, indices, shifts = expand_hkl( + hkl, spacegroup, include_friedel=False, remove_absences=True + ) + expected = np.angle(fcalc(hkl_p1.numpy())) + flipped = phi_in[indices.cpu().numpy()] - shifts.cpu().numpy() + + err = np.abs(_wrap(flipped - expected)).max() + assert err > 0.5, ( + f"{spacegroup}: negating the phase shift changed nothing (max err " + f"{err:.2e} rad), so this space group cannot detect a sign regression " + f"and does not belong in SENSITIVE_GROUPS." + ) diff --git a/tests/unit/test_gradient_correctness.py b/tests/unit/test_gradient_correctness.py index 0db0a01..28a2ff9 100644 --- a/tests/unit/test_gradient_correctness.py +++ b/tests/unit/test_gradient_correctness.py @@ -62,25 +62,6 @@ # --------------------------------------------------------------------------- # Fixtures / helpers # --------------------------------------------------------------------------- -@pytest.fixture -def double_cpu(): - """Run the body in double precision on CPU, restoring global config after. - - ``gradcheck`` needs float64 leaves, and the eager SF reference casts ``hkl`` - to the configured ``dtypes.float`` internally, so the global dtype must be - float64 too. Pinning CPU keeps the eager paths off the Triton dispatch on - GPU hosts. - """ - f0, c0, d0 = dtypes.float, dtypes.complex, device.current - dtypes.float = torch.float64 - dtypes.complex = torch.complex128 - device.current = torch.device("cpu") - try: - yield - finally: - dtypes.float = f0 - dtypes.complex = c0 - device.current = d0 # These live in ``tests/helpers/grad_asserts.py`` so the accelerator kernel @@ -115,34 +96,6 @@ def _sf_leaves(N=12, dtype=torch.float64, device="cpu", requires_grad=True): return xyz, occ, adp, U -# ============================================================================= -# 1. Structure-factor calculation — eager autograd vs finite differences -# ============================================================================= -def test_sf_iso_gradcheck(double_cpu): - """Isotropic SF: autograd matches numerical d/d(xyz, occ, adp).""" - hkl, s, _, A, B = _sf_inputs() - xyz, occ, adp, _ = _sf_leaves() - - def f(x, o, a): - return _eager_iso(hkl, s, x, o, a, A, B, max_memory_gb=2.0) - - assert torch.autograd.gradcheck(f, (xyz, occ, adp), eps=1e-6, atol=1e-5) - - -def test_sf_aniso_gradcheck(double_cpu): - """Anisotropic SF: autograd matches numerical d/d(xyz, occ, U).""" - hkl, _, svec, A, B = _sf_inputs() - xyz, occ, _, U = _sf_leaves() - - def f(x, o, u): - return _eager_aniso(hkl, svec, x, o, u, A, B, max_memory_gb=2.0) - - assert torch.autograd.gradcheck(f, (xyz, occ, U), eps=1e-6, atol=1e-5) - - -# ============================================================================= -# 2. X-ray loss target — eager autograd vs finite differences -# ============================================================================= def test_gaussian_xray_gradcheck(double_cpu): """Gaussian X-ray NLL (GaussianXrayTarget.forward body): d/d(F_obs, F_calc). @@ -208,133 +161,8 @@ def _grads(fn, leaves): return torch.autograd.grad(out, leaves) -def test_checkpointed_iso_matches_eager_cosine(double_cpu): - """Isotropic ``_CheckpointedSF`` (recompute-on-backward) == eager autograd.""" - hkl, s, _, A, B = _sf_inputs() - xyz, occ, adp, _ = _sf_leaves() - xyz2, occ2, adp2, _ = _sf_leaves() - g_ckpt = _grads( - lambda x, o, a: _checkpointed_iso(hkl, s, x, o, a, A, B, max_memory_gb=1e-7), - (xyz, occ, adp), - ) - g_eager = _grads( - lambda x, o, a: _eager_iso(hkl, s, x, o, a, A, B, max_memory_gb=2.0), - (xyz2, occ2, adp2), - ) - assert_grads_agree(g_ckpt, g_eager, ctx="iso ") - - -def test_checkpointed_aniso_matches_eager_cosine(double_cpu): - """Anisotropic ``_CheckpointedSF`` == eager autograd.""" - hkl, _, svec, A, B = _sf_inputs() - xyz, occ, _, U = _sf_leaves() - xyz2, occ2, _, U2 = _sf_leaves() - g_ckpt = _grads( - lambda x, o, u: _checkpointed_aniso( - hkl, svec, x, o, u, A, B, max_memory_gb=1e-7 - ), - (xyz, occ, U), - ) - g_eager = _grads( - lambda x, o, u: _eager_aniso(hkl, svec, x, o, u, A, B, max_memory_gb=2.0), - (xyz2, occ2, U2), - ) - assert_grads_agree(g_ckpt, g_eager, ctx="aniso ") - - -# ============================================================================= -# 5. Production FFT path ModelFT.forward — autograd vs central finite diff -# Metric: cosine similarity + gradnorm ratio of a scalar loss. -# ============================================================================= -_P1_PDB_HEADER = ( - "CRYST1 20.000 20.000 20.000 90.00 90.00 90.00 P 1 1" -) - - -def _write_p1_pdb(n=12): - g = torch.Generator().manual_seed(0) - elems = "C N O S C N O C N O C S".split()[:n] - lines = [_P1_PDB_HEADER] - for i in range(n): - x, y, z = (torch.rand(3, generator=g) * 15 + 2.5).tolist() - e = elems[i] - lines.append( - f"ATOM {i + 1:5d} {e:<2s} GLY A{i + 1:4d} " - f"{x:8.3f}{y:8.3f}{z:8.3f} 1.00 20.00 {e:>2s}" - ) - lines.append("END") - f = tempfile.NamedTemporaryFile("w", suffix=".pdb", delete=False) - f.write("\n".join(lines) + "\n") - f.close() - return f.name - - -def _central_fd_grad(loss_fn, model, param, eps): - """Central finite-difference gradient of ``loss_fn()`` w.r.t. ``param``.""" - base = param.detach().clone() - flat = base.reshape(-1) - grad = torch.zeros_like(flat) - for i in range(flat.numel()): - for sign in (+1, -1): - pert = flat.clone() - pert[i] += sign * eps - with torch.no_grad(): - param.copy_(pert.view_as(base)) - model.reset_cache() - val = loss_fn() - if sign == +1: - vp = val - else: - vm = val - grad[i] = (vp - vm) / (2 * eps) - with torch.no_grad(): - param.copy_(base) - model.reset_cache() - return grad.view_as(base) - - -@pytest.mark.parametrize("which,eps", [("xyz", 5e-3), ("adp", 1e-2)]) -def test_model_forward_fft_gradient(which, eps): - """``ModelFT.forward`` (FFT path) autograd grad agrees with finite diff. - - Honors the paper's headline check — ``model.forward(hkl)`` w.r.t. ``xyz`` - and ``adp`` — for the production FFT engine. The FFT output is float32 and - the on-grid splat defeats element-wise gradcheck, so we compare a scalar - loss's autograd gradient against central finite differences via cosine - similarity and gradnorm ratio (robust where atol/rtol are not). - """ - from torchref.model.model_ft import ModelFT - - d0 = device.current - device.current = torch.device("cpu") # keep float32 default; avoid Triton - try: - model = ModelFT(max_res=1.5, verbose=0, device=torch.device("cpu")) - model.load_pdb(_write_p1_pdb()) - hkls = [h for h in itertools.product(range(-3, 4), repeat=3) if any(h)] - hkl = torch.tensor(hkls, dtype=torch.float32) - - def loss_fn(): - sf = model(hkl, recalc=True) - return (sf.real**2 + sf.imag**2).sum() - - param = getattr(model, which).refinable_params - g_auto = torch.autograd.grad(loss_fn(), param)[0] - g_fd = _central_fd_grad(loss_fn, model, param, eps) - - cos = cosine_similarity(g_auto, g_fd) - ratio = gradnorm_ratio(g_auto, g_fd) - assert cos >= 0.999, f"{which}: cosine {cos:.6f} < 0.999" - assert abs(ratio - 1.0) <= 0.02, f"{which}: gradnorm ratio {ratio:.6f}" - finally: - device.current = d0 - - -# ============================================================================= -# 6. Triton kernels (CUDA float32, hand-written backward) vs eager autograd -# Metric: cosine similarity + gradnorm ratio. Auto-skipped without CUDA. -# ============================================================================= @pytest.mark.cuda def test_triton_sf_iso_matches_eager_cosine(): """DS isotropic Triton kernel gradient == eager autograd (CUDA float32).""" diff --git a/tests/unit/test_kernel_fixes.py b/tests/unit/test_kernel_fixes.py index ea59670..71208bc 100644 --- a/tests/unit/test_kernel_fixes.py +++ b/tests/unit/test_kernel_fixes.py @@ -23,21 +23,6 @@ pytestmark = pytest.mark.unit -@pytest.fixture -def double_cpu(): - """float64/complex128 on CPU for the duration of a test; restore after.""" - f0, c0, d0 = dtypes.float, dtypes.complex, device.current - dtypes.float = torch.float64 - dtypes.complex = torch.complex128 - device.current = torch.device("cpu") - try: - yield - finally: - dtypes.float = f0 - dtypes.complex = c0 - device.current = d0 - - # --------------------------------------------------------------------------- # Fix 5 — None scattering factors on the non-batched eager path # --------------------------------------------------------------------------- @@ -208,177 +193,3 @@ def test_forward_dtype_mismatch_raises(tmp_path): device.current = d0 -# --------------------------------------------------------------------------- -# CPU structured scatter: second derivatives must be CORRECT (not silently wrong) -# --------------------------------------------------------------------------- -def test_cpu_plain_scatter_second_derivative_correct(double_cpu, tmp_path): - """A Hessian-vector product through ``model.forward`` must match central - finite differences under a float64 config. - - Under ``double_cpu`` the density grid is float64, so the electron-density - dispatch routes to the portable plain ``scatter_add`` splat (the C++ fast - scatter is float32-only). This guards double-backward correctness of that - portable path -- the sanctioned float64 / EAGER Hessian route. The C++ fast - scatter's own double-backward is covered separately (float32) by - ``test_cpu_cpp_scatter_second_derivative_correct``. - """ - import itertools - - from torchref.model.model_ft import ModelFT - - pdb = tmp_path / "p1.pdb" - g = torch.Generator().manual_seed(0) - lines = ["CRYST1 20.000 20.000 20.000 90.00 90.00 90.00 P 1 1"] - for i in range(8): - x, y, z = (torch.rand(3, generator=g) * 15 + 2.5).tolist() - lines.append( - f"ATOM {i + 1:5d} C GLY A{i + 1:4d} " - f"{x:8.3f}{y:8.3f}{z:8.3f} 1.00 20.00 C" - ) - lines.append("END") - pdb.write_text("\n".join(lines) + "\n") - - model = ModelFT(max_res=2.0, verbose=0) - model.load_pdb(str(pdb)) - hkl = torch.tensor( - [h for h in itertools.product(range(-2, 3), repeat=3) if any(h)], - dtype=torch.float64, - ) - x = model.xyz.refinable_params - x0 = x.detach().clone() - gen = torch.Generator().manual_seed(1) - v = torch.randn(x0.shape, generator=gen) - v /= v.norm() - - def grad_at(xval): - with torch.no_grad(): - x.copy_(xval) - model.reset_cache() - sf = model(hkl, recalc=True) - return ( - torch.autograd.grad((sf.real**2 + sf.imag**2).sum(), x)[0].detach().clone() - ) - - # Autograd Hessian-vector product (double backward through the scatter). - with torch.no_grad(): - x.copy_(x0) - model.reset_cache() - sf = model(hkl, recalc=True) - (g1,) = torch.autograd.grad((sf.real**2 + sf.imag**2).sum(), x, create_graph=True) - (hvp_auto,) = torch.autograd.grad((g1 * v).sum(), x) - hvp_auto = hvp_auto.detach().clone() - - # Finite-difference reference. - eps = 3e-4 - hvp_fd = (grad_at(x0 + eps * v) - grad_at(x0 - eps * v)) / (2 * eps) - with torch.no_grad(): - x.copy_(x0) - model.reset_cache() - - cos = torch.nn.functional.cosine_similarity( - hvp_auto.flatten(), hvp_fd.flatten(), dim=0 - ).item() - rel = ((hvp_auto - hvp_fd).norm() / hvp_fd.norm()).item() - assert cos > 0.999, f"HVP direction wrong: cosine={cos:.6f}" - assert rel < 5e-3, f"HVP magnitude wrong: rel_err={rel:.3e}" - - -def test_cpu_cpp_scatter_second_derivative_correct(tmp_path): - """The CPU C++ fast-scatter double-backward must match central finite - differences (float32). - - Regression for the silent-wrong-Hessian bug: ``_StructuredScatterAdd`` - computed its backward with an imperative C++ gather (no ``grad_fn``), which - did not error under ``create_graph=True`` but dropped the second-order term - through the scatter transpose (measured cosine ~0.57 vs finite diff). The - fix routes the backward through the adjoint ``_StructuredGather`` Function - (scatter/gather are linear mutual adjoints), restoring a correct Hessian. - - This runs under a float32 config so the density dispatch stays on the C++ - fast scatter (the float64 config routes to the plain path instead -- see - ``test_cpu_plain_scatter_second_derivative_correct``). Finite differences - are float32 here, so the eps is larger and the tolerance looser than the - float64 plain-path test, but still far tighter than the ~0.57 regression. - """ - import itertools - - from torchref.base.electron_density.kernels.cpu.scatter_dispatch import ( - _get_cpp_scatter, - ) - from torchref.config import device, dtypes - from torchref.model.model_ft import ModelFT - - if _get_cpp_scatter() is None: - pytest.skip("C++ scatter extension not available; nothing to test here") - - f0, c0, d0 = dtypes.float, dtypes.complex, device.current - dtypes.float = torch.float32 - dtypes.complex = torch.complex64 - device.current = torch.device("cpu") - try: - pdb = tmp_path / "p1.pdb" - g = torch.Generator().manual_seed(0) - lines = ["CRYST1 20.000 20.000 20.000 90.00 90.00 90.00 P 1 1"] - for i in range(8): - x, y, z = (torch.rand(3, generator=g) * 15 + 2.5).tolist() - lines.append( - f"ATOM {i + 1:5d} C GLY A{i + 1:4d} " - f"{x:8.3f}{y:8.3f}{z:8.3f} 1.00 20.00 C" - ) - lines.append("END") - pdb.write_text("\n".join(lines) + "\n") - - model = ModelFT(max_res=2.0, verbose=0) - model.load_pdb(str(pdb)) - # The C++ fast scatter only runs on a float32 density grid. - assert model.xyz.refinable_params.dtype == torch.float32 - hkl = torch.tensor( - [h for h in itertools.product(range(-2, 3), repeat=3) if any(h)], - dtype=torch.float32, - ) - x = model.xyz.refinable_params - x0 = x.detach().clone() - gen = torch.Generator().manual_seed(1) - v = torch.randn(x0.shape, generator=gen) - v /= v.norm() - - def grad_at(xval): - with torch.no_grad(): - x.copy_(xval) - model.reset_cache() - sf = model(hkl, recalc=True) - return ( - torch.autograd.grad((sf.real**2 + sf.imag**2).sum(), x)[0] - .detach() - .clone() - ) - - # Autograd Hessian-vector product (double backward through the scatter). - with torch.no_grad(): - x.copy_(x0) - model.reset_cache() - sf = model(hkl, recalc=True) - (g1,) = torch.autograd.grad( - (sf.real**2 + sf.imag**2).sum(), x, create_graph=True - ) - (hvp_auto,) = torch.autograd.grad((g1 * v).sum(), x) - hvp_auto = hvp_auto.detach().clone() - - # Central finite-difference reference. Larger eps than the float64 test: - # in float32 a too-small step is swamped by subtraction noise. - eps = 3e-3 - hvp_fd = (grad_at(x0 + eps * v) - grad_at(x0 - eps * v)) / (2 * eps) - with torch.no_grad(): - x.copy_(x0) - model.reset_cache() - - cos = torch.nn.functional.cosine_similarity( - hvp_auto.flatten(), hvp_fd.flatten(), dim=0 - ).item() - rel = ((hvp_auto - hvp_fd).norm() / hvp_fd.norm()).item() - assert cos > 0.9999, f"HVP direction wrong: cosine={cos:.6f}" - assert rel < 5e-3, f"HVP magnitude wrong: rel_err={rel:.3e}" - finally: - dtypes.float = f0 - dtypes.complex = c0 - device.current = d0 diff --git a/torchref/base/electron_density/kernels/cpu/_cpp_build.py b/torchref/base/electron_density/kernels/cpu/_cpp_build.py new file mode 100644 index 0000000..d039ad0 --- /dev/null +++ b/torchref/base/electron_density/kernels/cpu/_cpp_build.py @@ -0,0 +1,133 @@ +"""Shared ``load_inline`` harness for the CPU C++ kernels. + +Extracted from ``scatter.py`` so the fused sphere splat (``sphere_splat.py``) does +not carry a second copy of it. Everything here is cluster-deployment plumbing that +must not drift between the two extensions: + +* **POSIX ``lockf`` locking** instead of PyTorch's ``FileBaton``. FileBaton is a + file-existence lock, so a process killed mid-compile leaves it behind and blocks + every future import. ``fcntl.lockf`` record locks are enforced by the filesystem + (so they work across NFS/GPFS nodes) and released by the kernel on process death, + even SIGKILL. +* **Per-microarchitecture build directory**, keyed on the CPU model. Without it a + ``-march=native`` binary built on one cluster node raises Illegal Instruction on + a node with a different CPU (e.g. AMD vs Intel). +* **ninja on PATH** — pip-installed ninja lives next to ``sys.executable``, which + is not on PATH on compute nodes. +* **GCC >= 9** via ``/opt/rh/gcc-toolset-*``, required by PyTorch C++ extensions. +* **No ``-fopenmp`` on macOS** — Apple Clang rejects it (no bundled OpenMP + runtime), so kernels must provide a ``std::thread`` fallback under + ``#ifndef _OPENMP``. + +Failures are returned, never raised: every caller degrades to a slower pure-torch +path, so a missing compiler is a performance problem and not an outage. +""" + +from __future__ import annotations + +import os +import sys +import traceback +from typing import Optional, Tuple + +from torch.utils.cpp_extension import load_inline + + +def cpu_tag() -> str: + """A tag identifying this CPU's microarchitecture, for the build directory.""" + import platform + + tag = platform.machine() + try: + with open("/proc/cpuinfo") as f: + for line in f: + if line.startswith("model name"): + # e.g. "EPYC_7443P" or "Xeon_Gold_6248" + return line.split(":")[1].strip().replace(" ", "_") + except OSError: + pass + return tag + + +def build_extension( + name: str, + cpp_source: str, + extra_cflags: Optional[list] = None, +) -> Tuple[object, Optional[Tuple[str, str]]]: + """Compile ``cpp_source`` into an extension module. + + Parameters + ---------- + name : str + Extension name; also the ``PYBIND11_MODULE`` name the source must use. + cpp_source : str + Complete translation unit, including its own ``PYBIND11_MODULE`` block. + extra_cflags : list of str, optional + Appended after the shared ``-O3 -march=native``. ``-fopenmp`` is added + automatically on every platform except macOS. + + Returns + ------- + (module, error) + ``(module, None)`` on success, ``(None, (message, traceback))`` on any + failure. Never raises. + """ + try: + import fcntl + except ImportError: + # Non-POSIX (Windows): no record locks, so don't attempt a build at all. + return None, ("fcntl unavailable (non-POSIX platform)", "") + + try: + # pip-installed ninja sits next to the interpreter, not on PATH. + bin_dir = os.path.dirname(sys.executable) + if bin_dir not in os.environ.get("PATH", ""): + os.environ["PATH"] = bin_dir + ":" + os.environ.get("PATH", "") + # PyTorch C++ extensions need GCC >= 9. + for toolset in ("14", "13", "12"): + gcc = f"/opt/rh/gcc-toolset-{toolset}/root/usr/bin/g++" + if os.path.isfile(gcc): + os.environ["CXX"] = gcc + os.environ["CC"] = gcc.replace("g++", "gcc") + break + + build_dir = os.path.join( + os.environ.get( + "TORCH_EXTENSIONS_DIR", + os.path.join(os.path.expanduser("~"), ".cache", "torch_extensions"), + ), + f"{name}_{cpu_tag()}", + ) + os.makedirs(build_dir, exist_ok=True) + + cflags = ["-O3", "-march=native"] + list(extra_cflags or []) + ldflags: list = [] + if sys.platform != "darwin": # Apple Clang has no bundled OpenMP runtime + cflags.append("-fopenmp") + ldflags.append("-fopenmp") + + lock_fd = os.open( + os.path.join(build_dir, "compile.lock"), os.O_CREAT | os.O_RDWR + ) + try: + fcntl.lockf(lock_fd, fcntl.LOCK_EX) + # Clear a stale PyTorch FileBaton lock left by a killed process. + try: + os.unlink(os.path.join(build_dir, "lock")) + except FileNotFoundError: + pass + module = load_inline( + name=name, + cpp_sources=[cpp_source], + extra_cflags=cflags, + extra_ldflags=ldflags, + build_directory=build_dir, + verbose=False, + ) + finally: + fcntl.lockf(lock_fd, fcntl.LOCK_UN) + os.close(lock_fd) + except Exception as e: # noqa: BLE001 - any build failure degrades gracefully + return None, (f"{type(e).__name__}: {e}", traceback.format_exc()) + + return module, None diff --git a/torchref/base/electron_density/kernels/cpu/scatter.py b/torchref/base/electron_density/kernels/cpu/scatter.py index 0016a5c..e39e43c 100644 --- a/torchref/base/electron_density/kernels/cpu/scatter.py +++ b/torchref/base/electron_density/kernels/cpu/scatter.py @@ -31,7 +31,8 @@ import warnings import torch -from torch.utils.cpp_extension import load_inline + +from torchref.base.electron_density.kernels.cpu._cpp_build import build_extension _WARNED_CAST_DTYPES: set = set() @@ -269,14 +270,10 @@ """ # --------------------------------------------------------------------------- -# Lazy compilation with POSIX lockf locking (works across cluster nodes). -# -# PyTorch's load_inline uses FileBaton (file-existence lock) which stays -# behind when a process is killed mid-compile, blocking all future imports. -# We wrap it with fcntl.lockf (POSIX record locks) which: -# - are enforced by the filesystem → work across NFS/GPFS cluster nodes -# - are released by the kernel on process death (even SIGKILL) -# First process compiles; all others (same node or different) reuse cache. +# Lazy compilation. The build itself (POSIX lockf locking, per-microarchitecture +# build directory, ninja/GCC discovery, no -fopenmp on macOS) lives in +# ``_cpp_build.build_extension``, shared with ``sphere_splat.py`` so the two +# extensions cannot drift apart on cluster deployment details. # --------------------------------------------------------------------------- _module = None _module_failed = False @@ -292,95 +289,9 @@ def _get_module(): return _module if _module_failed: return None - - import os - import sys - import traceback - - try: - import fcntl - except ImportError: - # fcntl is not available on non-POSIX platforms (e.g. Windows) + _module, _module_error = build_extension("cpu_scatter", _CPP_SRC) + if _module is None: _module_failed = True - _module_error = ("fcntl unavailable (non-POSIX platform)", "") - return None - - try: - # Ensure ninja (installed via pip) is on PATH for compute nodes - bin_dir = os.path.dirname(sys.executable) - if bin_dir not in os.environ.get("PATH", ""): - os.environ["PATH"] = bin_dir + ":" + os.environ.get("PATH", "") - # Need GCC >= 9 for PyTorch C++ extensions - for toolset in ("14", "13", "12"): - gcc = f"/opt/rh/gcc-toolset-{toolset}/root/usr/bin/g++" - if os.path.isfile(gcc): - os.environ["CXX"] = gcc - os.environ["CC"] = gcc.replace("g++", "gcc") - break - - # Per-microarchitecture build directory — prevents Illegal Instruction - # when different cluster nodes have different CPUs (e.g., AMD vs Intel). - import platform - - cpu_tag = platform.machine() - try: - # Use the CPU model to distinguish microarchitectures - with open("/proc/cpuinfo") as f: - for line in f: - if line.startswith("model name"): - # e.g. "EPYC_7443P" or "Xeon_Gold_6248" - cpu_tag = line.split(":")[1].strip().replace(" ", "_") - break - except OSError: - pass - build_dir = os.path.join( - os.environ.get( - "TORCH_EXTENSIONS_DIR", - os.path.join(os.path.expanduser("~"), ".cache", "torch_extensions"), - ), - f"cpu_scatter_{cpu_tag}", - ) - os.makedirs(build_dir, exist_ok=True) - - # Apple Clang on macOS rejects -fopenmp (no bundled OpenMP runtime). - # Kernel falls back to std::thread via #ifndef _OPENMP — no libomp, - # no Homebrew, no external dependency required. - is_apple_clang = sys.platform == "darwin" - extra_cflags = ["-O3", "-march=native"] - extra_ldflags: list[str] = [] - if not is_apple_clang: - extra_cflags.append("-fopenmp") - extra_ldflags.append("-fopenmp") - - # fcntl.lockf uses POSIX record locks (fcntl F_SETLKW) which are: - # 1. filesystem-level → work across NFS/GPFS cluster nodes - # 2. released by kernel on process death, even SIGKILL - lock_fd = os.open( - os.path.join(build_dir, "compile.lock"), os.O_CREAT | os.O_RDWR - ) - try: - fcntl.lockf(lock_fd, fcntl.LOCK_EX) - # Clear any stale PyTorch FileBaton lock from a killed process - try: - os.unlink(os.path.join(build_dir, "lock")) - except FileNotFoundError: - pass - _module = load_inline( - name="cpu_scatter", - cpp_sources=[_CPP_SRC], - extra_cflags=extra_cflags, - extra_ldflags=extra_ldflags, - build_directory=build_dir, - verbose=False, - ) - finally: - fcntl.lockf(lock_fd, fcntl.LOCK_UN) - os.close(lock_fd) - except Exception as e: - _module_failed = True - _module_error = (f"{type(e).__name__}: {e}", traceback.format_exc()) - return None - return _module diff --git a/torchref/base/electron_density/kernels/cpu/sphere_splat.py b/torchref/base/electron_density/kernels/cpu/sphere_splat.py new file mode 100644 index 0000000..bee2a73 --- /dev/null +++ b/torchref/base/electron_density/kernels/cpu/sphere_splat.py @@ -0,0 +1,807 @@ +"""Fused per-atom spherical-cutoff density splat for CPU (C++), with autograd. + +This is the production CPU path for :func:`build_electron_density`. It is a +transliteration of the Metal kernels in ``kernels/mps/_shaders.py`` -- deliberately +so: the point is that CPU, CUDA and Metal implement *one* truncation contract, so +``torchref.sigma_cutoff_ed`` means the same thing on every device. + +The canonical contract, shared with the Triton and Metal kernels: + + voxel v receives atom i's full 5-Gaussian density iff ||w||^2 <= r_i^2, + +where ``w`` is the minimum-image **Cartesian atom->voxel** vector (so the sphere is +centred on the atom, not on its nearest grid node) and ``r_i`` is the raw +``radius_policy`` radius. Enumeration bound is the triclinic-correct per-axis +half-width ``ceil(r_i * n_axis * ||inv_frac row_axis||)``. There is no +grid-dependent requantization of the radius and no diagonal-metric approximation. + +Why fused rather than the older grouped-separable splat +------------------------------------------------------ +The separable kernel (``variable_radius.py::add_isotropic_cpu_separable_var``, +still present but no longer dispatched) factorizes the Gaussian into 1D per-axis +exponentials, which needs a *uniform box per launch* -- hence a cube cutoff and all +the bucket-by-box-size machinery. Measured on 4000 atoms / 2.3M voxels / 0.40 A +sampling, the cube touches 19.3M voxels where the sphere touches 8.3M, and +materializing the ``(C,L,L,L)`` intermediate cube dominates the runtime: this fused +kernel is ~1.5x faster than the separable one even using ``std::exp``, and ~2.5x +faster with ``fast_exp``. So the spherical cutoff is not a performance concession +here -- it is both the correct geometry and the faster one. + +Threading +--------- +Forward partitions the **output** by x-plane via ``at::parallel_for``: each thread +owns a disjoint set of planes, writes only into them, and needs no atomics (a +cheap wrapped-interval test rejects atoms that miss the partition). Backward +partitions over **atoms**, so each thread owns its atom's gradient slots +exclusively -- also no atomics, exactly as in the Metal backward. + +Precision +--------- +float32 uses ``fast_exp`` (branchless 2^x bit-trick plus a degree-5 minimax +polynomial), the CPU analogue of the ``metal::fast::exp`` the Metal kernels already +use. Measured agreement with ``std::exp`` is rel L2 1.9e-5 -- some 40x below the +7.9e-4 amplitude-truncation floor at the default 3 sigma. float64 uses ``std::exp``, +since a float64 caller is precision-motivated by definition. + +Gradients flow to ``xyz``, ``adp``/``u`` and ``occ``, with identity to the incoming +``density_map``; ``A``/``B`` and the cell matrices get none -- the same set as the +CUDA and Metal kernels. Backward is first-order only; double backward must use +``Engine.EAGER`` (the plain splat in ``variable_radius.py``). + +Unlike the CUDA and Metal entry points this kernel takes no per-Gaussian +``coeff_mask``: that argument is all-ones at every existing call site, so it is +omitted rather than allocated. +""" + +from __future__ import annotations + +from typing import Optional, Tuple + +import torch + +from torchref.base.electron_density.kernels.cpu._cpp_build import build_extension + +_CPP_SRC = r""" +#include +#include +#include +#include +#include + +// --------------------------------------------------------------------------- +// exp: fast branchless variant for float, libm for double. +// +// exp(x) = 2^(x*log2(e)): the integer part goes straight into the IEEE exponent +// field, the fractional part through a degree-5 minimax polynomial on [0,1). +// No branches, so the innermost voxel loop stays vectorizable. This mirrors +// metal::fast::exp, which the Metal kernels already use. +// --------------------------------------------------------------------------- +static inline float fast_exp(float x) { + x = x < -87.0f ? -87.0f : x; // below this, exp underflows + const float t = x * 1.44269504088896341f; // log2(e) + const float n = std::floor(t); + const float f = t - n; + const float p = 1.0f + f * (0.6931471805f + f * (0.2402265069f + + f * (0.0555041087f + f * (0.0096181291f + f * 0.0013333558f)))); + const int32_t bits = (int32_t)((n + 127.0f) * 8388608.0f) & 0x7f800000; + float scale; + std::memcpy(&scale, &bits, sizeof(scale)); + return p * scale; +} + +// Overloads rather than explicit specializations: the templated kernels below +// call kexp() unqualified and overload resolution picks by scalar_t. +static inline float kexp(float x) { return fast_exp(x); } +static inline double kexp(double x) { return std::exp(x); } + +template struct K { + static constexpr scalar_t PI_1P5 = (scalar_t)5.568327996831708; // pi^1.5 + static constexpr scalar_t PI_SQ = (scalar_t)9.869604401089358; // pi^2 + static constexpr scalar_t TWO_PI_SQ= (scalar_t)19.739208802178716; // 2*pi^2 +}; + +// Cell geometry + this atom's anchor, shared by every kernel below. +template +struct Cell { + scalar_t uax,uay,uaz, ubx,uby,ubz, ucx,ucy,ucz; // Cartesian voxel steps + scalar_t inva,invb,invc; // inv_frac row norms + scalar_t Auc; // |u_c|^2 + scalar_t fnx,fny,fnz; + int nx,ny,nz; +}; + +template +static Cell make_cell(const scalar_t* fm, const scalar_t* im, + int nx, int ny, int nz) { + Cell c; + c.nx=nx; c.ny=ny; c.nz=nz; + c.fnx=(scalar_t)nx; c.fny=(scalar_t)ny; c.fnz=(scalar_t)nz; + // frac columns are the cell vectors a,b,c; divided by n gives the voxel step. + c.uax=fm[0]/c.fnx; c.uay=fm[3]/c.fnx; c.uaz=fm[6]/c.fnx; + c.ubx=fm[1]/c.fny; c.uby=fm[4]/c.fny; c.ubz=fm[7]/c.fny; + c.ucx=fm[2]/c.fnz; c.ucy=fm[5]/c.fnz; c.ucz=fm[8]/c.fnz; + c.inva=std::sqrt(im[0]*im[0]+im[1]*im[1]+im[2]*im[2]); + c.invb=std::sqrt(im[3]*im[3]+im[4]*im[4]+im[5]*im[5]); + c.invc=std::sqrt(im[6]*im[6]+im[7]*im[7]+im[8]*im[8]); + c.Auc=c.ucx*c.ucx+c.ucy*c.ucy+c.ucz*c.ucz; + return c; +} + +template +struct Anchor { + int cix,ciy,ciz, bhx,bhy,bhz; + scalar_t w0x,w0y,w0z, rc2; +}; + +template +static inline Anchor make_anchor( + const Cell& c, const scalar_t* xyz, const scalar_t* fm, + const scalar_t* im, int64_t a, scalar_t rc2) +{ + Anchor g; + g.rc2 = rc2; + const scalar_t r = std::sqrt(rc2); + // Per-axis bounding box of the Cartesian r-sphere in index space; the sphere + // test culls the corners. Same formula as the Triton / Metal kernels. + g.bhx = (int)std::ceil(r*c.fnx*c.inva); + g.bhy = (int)std::ceil(r*c.fny*c.invb); + g.bhz = (int)std::ceil(r*c.fnz*c.invc); + const scalar_t ax=xyz[3*a+0], ay=xyz[3*a+1], az=xyz[3*a+2]; + scalar_t fx=ax*im[0]+ay*im[1]+az*im[2]; + scalar_t fy=ax*im[3]+ay*im[4]+az*im[5]; + scalar_t fz=ax*im[6]+ay*im[7]+az*im[8]; + fx-=std::floor(fx); fy-=std::floor(fy); fz-=std::floor(fz); // wrap to [0,1) + g.cix=(int)std::nearbyint(fx*c.fnx); + g.ciy=(int)std::nearbyint(fy*c.fny); + g.ciz=(int)std::nearbyint(fz*c.fnz); + // Sub-voxel residual, taken to Cartesian: the sphere is centred HERE, on the + // atom, not on the anchor node. + const scalar_t sx=fx-(scalar_t)g.cix/c.fnx; + const scalar_t sy=fy-(scalar_t)g.ciy/c.fny; + const scalar_t sz=fz-(scalar_t)g.ciz/c.fnz; + g.w0x=fm[0]*sx+fm[1]*sy+fm[2]*sz; + g.w0y=fm[3]*sx+fm[4]*sy+fm[5]*sz; + g.w0z=fm[6]*sx+fm[7]*sy+fm[8]*sz; + return g; +} + +static inline int wrap_idx(int i, int n) { return ((i % n) + n) % n; } + +// Does any x-plane this atom touches fall in [xlo, xhi)? The plane set is the +// wrapped interval [cix-bhx, cix+bhx] mod nx, so at most two pieces. +static inline bool touches_x(int cix, int bhx, int nx, int64_t xlo, int64_t xhi) { + if (2*bhx+1 >= nx) return true; + const int lo = wrap_idx(cix-bhx, nx), hi = lo + 2*bhx; + if (hi < nx) return (lo < xhi) && (hi >= xlo); + return (lo < xhi) || ((hi - nx) >= xlo); +} + +// In-sphere oz run for one (ox,oy) column. r2(oz) is a convex quadratic in oz, so +// the in-sphere set is one contiguous run; solving for it keeps the innermost loop +// branchless. Widened by one voxel each way, with the exact r2 <= rc2 test retained +// as a 0/1 multiply, so the accepted voxel set is bit-identical to the GPU kernels' +// straight comparison over the full box. +template +static inline bool oz_run(const Cell& c, scalar_t qx, scalar_t qy, + scalar_t qz, scalar_t rc2, int bhz, int& zlo, int& zhi) { + const scalar_t Bq = (scalar_t)2*(qx*c.ucx+qy*c.ucy+qz*c.ucz); + const scalar_t Cq = qx*qx+qy*qy+qz*qz - rc2; + const scalar_t disc = Bq*Bq - (scalar_t)4*c.Auc*Cq; + if (disc < 0) return false; + const scalar_t sq = std::sqrt(disc), inv2A = (scalar_t)0.5/c.Auc; + zlo = (int)std::ceil((-Bq-sq)*inv2A) - 1; + zhi = (int)std::floor((-Bq+sq)*inv2A) + 1; + if (zlo < -bhz) zlo = -bhz; + if (zhi > bhz) zhi = bhz; + return zlo <= zhi; +} + +// =========================================================================== +// ISOTROPIC +// =========================================================================== +template +static void iso_fwd_impl(scalar_t* out, const scalar_t* xyz, const scalar_t* adp, + const scalar_t* occ, const scalar_t* Ac, const scalar_t* Bc, + const scalar_t* r2cut, const scalar_t* im, const scalar_t* fm, + int64_t n_at, int nx, int ny, int nz) +{ + const Cell c = make_cell(fm, im, nx, ny, nz); + // Output partitioned by x-plane: disjoint writes, so no atomics. + at::parallel_for(0, nx, 1, [&](int64_t xlo, int64_t xhi) { + for (int64_t a = 0; a < n_at; ++a) { + const Anchor g = make_anchor(c, xyz, fm, im, a, r2cut[a]); + if (!touches_x(g.cix, g.bhx, nx, xlo, xhi)) continue; + scalar_t Bt[5], An[5]; + const scalar_t bi=adp[a], oc=occ[a]; + for (int k=0;k<5;++k) { + scalar_t bt=(Bc[5*a+k]+bi)*(scalar_t)0.25; + bt = bt > (scalar_t)0.1 ? bt : (scalar_t)0.1; + Bt[k]=bt; + An[k]=Ac[5*a+k]*oc*K::PI_1P5/(bt*std::sqrt(bt)); + } + for (int ox=-g.bhx; ox<=g.bhx; ++ox) { + const int vix = wrap_idx(g.cix+ox, nx); + if (vix < xlo || vix >= xhi) continue; + const scalar_t fox=(scalar_t)ox; + const scalar_t px=fox*c.uax-g.w0x, py=fox*c.uay-g.w0y, pz=fox*c.uaz-g.w0z; + for (int oy=-g.bhy; oy<=g.bhy; ++oy) { + const scalar_t foy=(scalar_t)oy; + const scalar_t qx=px+foy*c.ubx, qy=py+foy*c.uby, qz=pz+foy*c.ubz; + int zlo, zhi; + if (!oz_run(c, qx, qy, qz, g.rc2, g.bhz, zlo, zhi)) continue; + scalar_t* row = out + ((int64_t)vix*ny + wrap_idx(g.ciy+oy, ny))*(int64_t)nz; + for (int oz=zlo; oz<=zhi; ++oz) { + const scalar_t foz=(scalar_t)oz; + const scalar_t wx=qx+foz*c.ucx, wy=qy+foz*c.ucy, wz=qz+foz*c.ucz; + const scalar_t r2=wx*wx+wy*wy+wz*wz; + const scalar_t keep = r2 <= g.rc2 ? (scalar_t)1 : (scalar_t)0; + scalar_t dens=0; + for (int k=0;k<5;++k) dens += An[k]*kexp(-K::PI_SQ*r2/Bt[k]); + row[wrap_idx(g.ciz+oz, nz)] += keep*dens; + } + } + } + } + }); +} + +template +static void iso_bwd_impl(scalar_t* g_xyz, scalar_t* g_adp, scalar_t* g_occ, + const scalar_t* go, const scalar_t* xyz, const scalar_t* adp, + const scalar_t* occ, const scalar_t* Ac, const scalar_t* Bc, + const scalar_t* r2cut, const scalar_t* im, const scalar_t* fm, + int64_t n_at, int nx, int ny, int nz) +{ + const Cell c = make_cell(fm, im, nx, ny, nz); + // Partitioned over atoms: each thread owns its atoms' gradient slots. + at::parallel_for(0, n_at, 1, [&](int64_t a0, int64_t a1) { + for (int64_t a = a0; a < a1; ++a) { + const Anchor g = make_anchor(c, xyz, fm, im, a, r2cut[a]); + scalar_t Bt[5], An[5], clampf[5]; + const scalar_t bi=adp[a], oc=occ[a]; + for (int k=0;k<5;++k) { + const scalar_t raw=(Bc[5*a+k]+bi)*(scalar_t)0.25; + const scalar_t bt = raw > (scalar_t)0.1 ? raw : (scalar_t)0.1; + Bt[k]=bt; + An[k]=Ac[5*a+k]*oc*K::PI_1P5/(bt*std::sqrt(bt)); + // in the clamp region d(Bt)/d(adp) = 0 + clampf[k] = raw > (scalar_t)0.1 ? (scalar_t)1 : (scalar_t)0; + } + scalar_t gx=0,gy=0,gz=0,gb=0,gocc=0; + for (int ox=-g.bhx; ox<=g.bhx; ++ox) { + const int vix = wrap_idx(g.cix+ox, nx); + const scalar_t fox=(scalar_t)ox; + const scalar_t px=fox*c.uax-g.w0x, py=fox*c.uay-g.w0y, pz=fox*c.uaz-g.w0z; + for (int oy=-g.bhy; oy<=g.bhy; ++oy) { + const scalar_t foy=(scalar_t)oy; + const scalar_t qx=px+foy*c.ubx, qy=py+foy*c.uby, qz=pz+foy*c.ubz; + int zlo, zhi; + if (!oz_run(c, qx, qy, qz, g.rc2, g.bhz, zlo, zhi)) continue; + const scalar_t* row = go + ((int64_t)vix*ny + wrap_idx(g.ciy+oy, ny))*(int64_t)nz; + for (int oz=zlo; oz<=zhi; ++oz) { + const scalar_t foz=(scalar_t)oz; + const scalar_t wx=qx+foz*c.ucx, wy=qy+foz*c.ucy, wz=qz+foz*c.ucz; + const scalar_t r2=wx*wx+wy*wy+wz*wz; + if (r2 > g.rc2) continue; + const scalar_t gout = row[wrap_idx(g.ciz+oz, nz)]; + scalar_t dens=0, coeff=0, dbs=0; + for (int k=0;k<5;++k) { + const scalar_t Ae = An[k]*kexp(-K::PI_SQ*r2/Bt[k]); + dens += Ae; + coeff += Ae/Bt[k]; + dbs += Ae*(-(scalar_t)1.5/Bt[k] + + K::PI_SQ*r2/(Bt[k]*Bt[k]))*clampf[k]; + } + const scalar_t s = gout*(scalar_t)2*K::PI_SQ*coeff; + gx += s*wx; gy += s*wy; gz += s*wz; + gb += gout*(scalar_t)0.25*dbs; + gocc += gout*dens; + } + } + } + g_xyz[3*a+0]=gx; g_xyz[3*a+1]=gy; g_xyz[3*a+2]=gz; + g_adp[a]=gb; + g_occ[a]= oc != (scalar_t)0 ? gocc/oc : (scalar_t)0; + } + }); +} + +// =========================================================================== +// ANISOTROPIC. M_g = (B_g*I + 8*pi^2*U)/4, inverted analytically; density uses +// the Mahalanobis form q = w^T Minv w, the cutoff stays the Euclidean sphere. +// =========================================================================== +template +static inline void aniso_minv(const scalar_t* Bc, const scalar_t* u, int64_t a, + const scalar_t* Ac, scalar_t oc, + scalar_t* p00, scalar_t* p11, scalar_t* p22, + scalar_t* p01, scalar_t* p02, scalar_t* p12, + scalar_t* An) +{ + const scalar_t T = K::TWO_PI_SQ; // 8*pi^2 / 4 + const scalar_t u11=u[6*a+0],u22=u[6*a+1],u33=u[6*a+2], + u12=u[6*a+3],u13=u[6*a+4],u23=u[6*a+5]; + const scalar_t md=T*u12, me=T*u13, mf=T*u23; + for (int k=0;k<5;++k) { + const scalar_t Bg=Bc[5*a+k]; + const scalar_t ma=(scalar_t)0.25*Bg+T*u11; + const scalar_t mb=(scalar_t)0.25*Bg+T*u22; + const scalar_t mc=(scalar_t)0.25*Bg+T*u33; + const scalar_t det=ma*(mb*mc-mf*mf)-md*(md*mc-me*mf)+me*(md*mf-me*mb); + const scalar_t inv=(scalar_t)1/det; + p00[k]=(mb*mc-mf*mf)*inv; p11[k]=(ma*mc-me*me)*inv; p22[k]=(ma*mb-md*md)*inv; + p01[k]=(me*mf-md*mc)*inv; p02[k]=(md*mf-me*mb)*inv; p12[k]=(md*me-ma*mf)*inv; + const scalar_t d = det > (scalar_t)1e-10 ? det : (scalar_t)1e-10; + An[k]=Ac[5*a+k]*oc*K::PI_1P5/std::sqrt(d); + } +} + +template +static void aniso_fwd_impl(scalar_t* out, const scalar_t* xyz, const scalar_t* u, + const scalar_t* occ, const scalar_t* Ac, const scalar_t* Bc, + const scalar_t* r2cut, const scalar_t* im, const scalar_t* fm, + int64_t n_at, int nx, int ny, int nz) +{ + const Cell c = make_cell(fm, im, nx, ny, nz); + at::parallel_for(0, nx, 1, [&](int64_t xlo, int64_t xhi) { + for (int64_t a = 0; a < n_at; ++a) { + const Anchor g = make_anchor(c, xyz, fm, im, a, r2cut[a]); + if (!touches_x(g.cix, g.bhx, nx, xlo, xhi)) continue; + scalar_t p00[5],p11[5],p22[5],p01[5],p02[5],p12[5],An[5]; + aniso_minv(Bc, u, a, Ac, occ[a], p00,p11,p22,p01,p02,p12,An); + for (int ox=-g.bhx; ox<=g.bhx; ++ox) { + const int vix = wrap_idx(g.cix+ox, nx); + if (vix < xlo || vix >= xhi) continue; + const scalar_t fox=(scalar_t)ox; + const scalar_t px=fox*c.uax-g.w0x, py=fox*c.uay-g.w0y, pz=fox*c.uaz-g.w0z; + for (int oy=-g.bhy; oy<=g.bhy; ++oy) { + const scalar_t foy=(scalar_t)oy; + const scalar_t qx=px+foy*c.ubx, qy=py+foy*c.uby, qz=pz+foy*c.ubz; + int zlo, zhi; + if (!oz_run(c, qx, qy, qz, g.rc2, g.bhz, zlo, zhi)) continue; + scalar_t* row = out + ((int64_t)vix*ny + wrap_idx(g.ciy+oy, ny))*(int64_t)nz; + for (int oz=zlo; oz<=zhi; ++oz) { + const scalar_t foz=(scalar_t)oz; + const scalar_t wx=qx+foz*c.ucx, wy=qy+foz*c.ucy, wz=qz+foz*c.ucz; + if (wx*wx+wy*wy+wz*wz > g.rc2) continue; + const scalar_t xx=wx*wx, yy=wy*wy, zz=wz*wz; + const scalar_t xy=wx*wy, xz=wx*wz, yz=wy*wz; + scalar_t dens=0; + for (int k=0;k<5;++k) { + const scalar_t q = p00[k]*xx+p11[k]*yy+p22[k]*zz + + (scalar_t)2*(p01[k]*xy+p02[k]*xz+p12[k]*yz); + dens += An[k]*kexp(-K::PI_SQ*q); + } + row[wrap_idx(g.ciz+oz, nz)] += dens; + } + } + } + } + }); +} + +template +static void aniso_bwd_impl(scalar_t* g_xyz, scalar_t* g_u, scalar_t* g_occ, + const scalar_t* go, const scalar_t* xyz, const scalar_t* u, + const scalar_t* occ, const scalar_t* Ac, const scalar_t* Bc, + const scalar_t* r2cut, const scalar_t* im, const scalar_t* fm, + int64_t n_at, int nx, int ny, int nz) +{ + const Cell c = make_cell(fm, im, nx, ny, nz); + at::parallel_for(0, n_at, 1, [&](int64_t a0, int64_t a1) { + for (int64_t a = a0; a < a1; ++a) { + const Anchor g = make_anchor(c, xyz, fm, im, a, r2cut[a]); + const scalar_t oc = occ[a]; + scalar_t p00[5],p11[5],p22[5],p01[5],p02[5],p12[5],An[5]; + aniso_minv(Bc, u, a, Ac, oc, p00,p11,p22,p01,p02,p12,An); + scalar_t gx=0,gy=0,gz=0,gu0=0,gu1=0,gu2=0,gu3=0,gu4=0,gu5=0,gocc=0; + for (int ox=-g.bhx; ox<=g.bhx; ++ox) { + const int vix = wrap_idx(g.cix+ox, nx); + const scalar_t fox=(scalar_t)ox; + const scalar_t px=fox*c.uax-g.w0x, py=fox*c.uay-g.w0y, pz=fox*c.uaz-g.w0z; + for (int oy=-g.bhy; oy<=g.bhy; ++oy) { + const scalar_t foy=(scalar_t)oy; + const scalar_t qx=px+foy*c.ubx, qy=py+foy*c.uby, qz=pz+foy*c.ubz; + int zlo, zhi; + if (!oz_run(c, qx, qy, qz, g.rc2, g.bhz, zlo, zhi)) continue; + const scalar_t* row = go + ((int64_t)vix*ny + wrap_idx(g.ciy+oy, ny))*(int64_t)nz; + for (int oz=zlo; oz<=zhi; ++oz) { + const scalar_t foz=(scalar_t)oz; + const scalar_t wx=qx+foz*c.ucx, wy=qy+foz*c.ucy, wz=qz+foz*c.ucz; + if (wx*wx+wy*wy+wz*wz > g.rc2) continue; + const scalar_t gout = row[wrap_idx(g.ciz+oz, nz)]; + scalar_t dens=0,sx=0,sy=0,sz=0,s0=0,s1=0,s2=0,s3=0,s4=0,s5=0; + for (int k=0;k<5;++k) { + const scalar_t vx=p00[k]*wx+p01[k]*wy+p02[k]*wz; + const scalar_t vy=p01[k]*wx+p11[k]*wy+p12[k]*wz; + const scalar_t vz=p02[k]*wx+p12[k]*wy+p22[k]*wz; + const scalar_t q = wx*vx+wy*vy+wz*vz; + const scalar_t dg = An[k]*kexp(-K::PI_SQ*q); + dens+=dg; sx+=dg*vx; sy+=dg*vy; sz+=dg*vz; + const scalar_t P = K::PI_SQ; + s0+=dg*(-(scalar_t)0.5*p00[k]+P*vx*vx); + s1+=dg*(-(scalar_t)0.5*p11[k]+P*vy*vy); + s2+=dg*(-(scalar_t)0.5*p22[k]+P*vz*vz); + s3+=dg*(-(scalar_t)0.5*p01[k]+P*vx*vy); + s4+=dg*(-(scalar_t)0.5*p02[k]+P*vx*vz); + s5+=dg*(-(scalar_t)0.5*p12[k]+P*vy*vz); + } + const scalar_t s2pi = gout*(scalar_t)2*K::PI_SQ; + const scalar_t s4pi = gout*(scalar_t)4*K::PI_SQ; + gx+=s2pi*sx; gy+=s2pi*sy; gz+=s2pi*sz; + gu0+=s2pi*s0; gu1+=s2pi*s1; gu2+=s2pi*s2; // diagonal U + gu3+=s4pi*s3; gu4+=s4pi*s4; gu5+=s4pi*s5; // off-diagonal U + gocc+=gout*dens; + } + } + } + g_xyz[3*a+0]=gx; g_xyz[3*a+1]=gy; g_xyz[3*a+2]=gz; + g_u[6*a+0]=gu0; g_u[6*a+1]=gu1; g_u[6*a+2]=gu2; + g_u[6*a+3]=gu3; g_u[6*a+4]=gu4; g_u[6*a+5]=gu5; + g_occ[a]= oc != (scalar_t)0 ? gocc/oc : (scalar_t)0; + } + }); +} + +// =========================================================================== +// Bindings. Tensors arrive contiguous and pre-validated from Python. +// =========================================================================== +#define PTR(T, t) (t).data_ptr() + +void iso_fwd(torch::Tensor out, torch::Tensor xyz, torch::Tensor adp, + torch::Tensor occ, torch::Tensor A, torch::Tensor B, + torch::Tensor r2cut, torch::Tensor inv_frac, torch::Tensor frac, + int64_t nx, int64_t ny, int64_t nz) +{ + AT_DISPATCH_FLOATING_TYPES(out.scalar_type(), "iso_fwd", [&] { + iso_fwd_impl(PTR(scalar_t,out), PTR(scalar_t,xyz), PTR(scalar_t,adp), + PTR(scalar_t,occ), PTR(scalar_t,A), PTR(scalar_t,B), PTR(scalar_t,r2cut), + PTR(scalar_t,inv_frac), PTR(scalar_t,frac), xyz.size(0), nx, ny, nz); + }); +} + +void iso_bwd(torch::Tensor g_xyz, torch::Tensor g_adp, torch::Tensor g_occ, + torch::Tensor go, torch::Tensor xyz, torch::Tensor adp, + torch::Tensor occ, torch::Tensor A, torch::Tensor B, + torch::Tensor r2cut, torch::Tensor inv_frac, torch::Tensor frac, + int64_t nx, int64_t ny, int64_t nz) +{ + AT_DISPATCH_FLOATING_TYPES(go.scalar_type(), "iso_bwd", [&] { + iso_bwd_impl(PTR(scalar_t,g_xyz), PTR(scalar_t,g_adp), + PTR(scalar_t,g_occ), PTR(scalar_t,go), PTR(scalar_t,xyz), + PTR(scalar_t,adp), PTR(scalar_t,occ), PTR(scalar_t,A), PTR(scalar_t,B), + PTR(scalar_t,r2cut), PTR(scalar_t,inv_frac), PTR(scalar_t,frac), + xyz.size(0), nx, ny, nz); + }); +} + +void aniso_fwd(torch::Tensor out, torch::Tensor xyz, torch::Tensor u, + torch::Tensor occ, torch::Tensor A, torch::Tensor B, + torch::Tensor r2cut, torch::Tensor inv_frac, torch::Tensor frac, + int64_t nx, int64_t ny, int64_t nz) +{ + AT_DISPATCH_FLOATING_TYPES(out.scalar_type(), "aniso_fwd", [&] { + aniso_fwd_impl(PTR(scalar_t,out), PTR(scalar_t,xyz), PTR(scalar_t,u), + PTR(scalar_t,occ), PTR(scalar_t,A), PTR(scalar_t,B), PTR(scalar_t,r2cut), + PTR(scalar_t,inv_frac), PTR(scalar_t,frac), xyz.size(0), nx, ny, nz); + }); +} + +void aniso_bwd(torch::Tensor g_xyz, torch::Tensor g_u, torch::Tensor g_occ, + torch::Tensor go, torch::Tensor xyz, torch::Tensor u, + torch::Tensor occ, torch::Tensor A, torch::Tensor B, + torch::Tensor r2cut, torch::Tensor inv_frac, torch::Tensor frac, + int64_t nx, int64_t ny, int64_t nz) +{ + AT_DISPATCH_FLOATING_TYPES(go.scalar_type(), "aniso_bwd", [&] { + aniso_bwd_impl(PTR(scalar_t,g_xyz), PTR(scalar_t,g_u), + PTR(scalar_t,g_occ), PTR(scalar_t,go), PTR(scalar_t,xyz), + PTR(scalar_t,u), PTR(scalar_t,occ), PTR(scalar_t,A), PTR(scalar_t,B), + PTR(scalar_t,r2cut), PTR(scalar_t,inv_frac), PTR(scalar_t,frac), + xyz.size(0), nx, ny, nz); + }); +} + +PYBIND11_MODULE(TORCH_EXTENSION_NAME, m) { + m.def("iso_fwd", &iso_fwd, "Fused isotropic spherical-cutoff splat (forward)"); + m.def("iso_bwd", &iso_bwd, "Fused isotropic spherical-cutoff splat (backward)"); + m.def("aniso_fwd", &aniso_fwd, "Fused anisotropic spherical-cutoff splat (forward)"); + m.def("aniso_bwd", &aniso_bwd, "Fused anisotropic spherical-cutoff splat (backward)"); +} +""" + +# --------------------------------------------------------------------------- +# Lazy compilation, attempted at most once per process. Mirrors +# ``scatter.py::_get_module``: any failure returns None so the caller degrades to +# the portable plain splat rather than dying. +# --------------------------------------------------------------------------- +_module = None +_module_failed = False +_module_error: Optional[Tuple[str, str]] = None + + +def _get_module(): + """The compiled fused-splat extension, or None if it could not be built.""" + global _module, _module_failed, _module_error + if _module is not None: + return _module + if _module_failed: + return None + _module, _module_error = build_extension("sphere_splat", _CPP_SRC) + if _module is None: + _module_failed = True + return _module + + +def sphere_splat_available() -> bool: + """Whether the fused CPU splat compiled and is ready to dispatch.""" + return _get_module() is not None + + +def should_use_sphere_splat(*tensors: torch.Tensor) -> bool: + """Fused-vs-portable gate for the CPU density splat. + + The CPU counterpart of ``should_use_triton`` / ``should_use_metal``: probes + device, dtype **and** extension availability together, so an uncompiled + extension is a predicate returning False rather than an exception at the + dispatch site. ``None`` entries are ignored. + + Unlike the accelerator gates this one does not consult the ``Engine`` -- the + caller owns that, because ``Engine.EAGER`` must reach the portable splat. + + Every tensor must be CPU and share one float32/float64 dtype: the kernel + reads raw pointers under a single ``AT_DISPATCH_FLOATING_TYPES``, so a mixed + set (e.g. a float64 map with float32 atoms) has to take the portable path, + which promotes as usual. + """ + present = [t for t in tensors if t is not None] + if not present: + return False + dtype = present[0].dtype + if dtype not in (torch.float32, torch.float64): + return False + if any(t.device.type != "cpu" or t.dtype is not dtype for t in present): + return False + return sphere_splat_available() + + +def warmup() -> bool: + """Eagerly compile, to move the one-time cost off the first refinement step.""" + return _get_module() is not None + + +def clear_cache() -> None: + """Forget the compiled module and failure state (rebuilt on next use).""" + global _module, _module_failed, _module_error + _module = None + _module_failed = False + _module_error = None + + +def last_error() -> Optional[Tuple[str, str]]: + """The ``(message, traceback)`` of the last build failure, if any.""" + return _module_error + + +def _require_module(): + mod = _get_module() + if mod is None: + err = _module_error[0] if _module_error else "unknown reason" + raise RuntimeError( + f"fused CPU sphere_splat extension not available ({err}). See " + "torchref.base.electron_density.kernels.cpu.sphere_splat.last_error()." + ) + return mod + + +def _double_backward_vjp(plain_fn, ctx, grad_out, leaves, statics, r2cut): + """Recompute this VJP through the portable differentiable splat. + + The C++ backward is a closed-form first-order formula with no autograd graph, so + on its own it cannot supply a second derivative -- the same limitation the CUDA + and Metal kernels have. Rather than lose double backward on the CPU default path + (``test_kernel_fixes.py`` covers it, and a *silently wrong* Hessian was a real bug + here once), detect the double-backward context and re-derive the identical VJP + from the portable splat, which is built from differentiable ops. + + ``torch.is_grad_enabled()`` is the detector: autograd runs ``backward`` under + ``no_grad`` unless the caller passed ``create_graph=True``. ``grad_out``'s own + ``requires_grad`` is *not* a usable signal -- at the top of a + ``create_graph=True`` backward it is a plain ``ones`` tensor. + + The gradients are taken w.r.t. the **saved** leaves, not detached copies, so the + returned VJP stays connected to the caller's graph -- detaching here would + silently drop the second-order term, which is precisely the failure mode this + guards against. Both paths implement the same truncation contract, so the + first-order values agree to float noise; the only cost is that a Hessian + workflow runs at the portable splat's speed. + """ + A, B, inv_frac, frac = statics + # Only the leaves that actually require grad may be differentiated; asking for + # the others raises regardless of allow_unused. + wanted = [t for t in leaves if t.requires_grad] + out = [None] * len(leaves) + if wanted: + zeros = torch.zeros( + ctx.grid_shape, dtype=grad_out.dtype, device=grad_out.device + ) + with torch.enable_grad(): + dm = plain_fn(zeros, *leaves, A, B, inv_frac, frac, r2cut.sqrt()) + grads = torch.autograd.grad( + dm, wanted, grad_out, create_graph=True, allow_unused=True + ) + it = iter(grads) + out = [next(it) if t.requires_grad else None for t in leaves] + # forward returned density_map + splat -> grad wrt density_map is the identity + return (grad_out, *out) + (None,) * 5 + + +def _prep(density_map, xyz, radius_per_atom, *tensors): + """Shared validation + contiguity for both entry points.""" + if density_map.device.type != "cpu": + raise ValueError( + f"sphere_splat is a CPU kernel; got device {density_map.device}" + ) + dtype = density_map.dtype + if dtype not in (torch.float32, torch.float64): + raise ValueError(f"sphere_splat supports float32/float64, got {dtype}") + for t in (xyz, radius_per_atom) + tensors: + if t.dtype != dtype: + raise ValueError( + f"every input must match density_map.dtype ({dtype}); got {t.dtype}" + ) + r2cut = (radius_per_atom * radius_per_atom).detach().contiguous() + return dtype, r2cut + + +class _FusedIsoSplat(torch.autograd.Function): + """Isotropic fused CPU splat: returns ``density_map + splat``.""" + + @staticmethod + def forward(ctx, density_map, xyz, adp, occ, A, B, r2cut, inv_frac, frac): + mod = _require_module() + nx, ny, nz = (int(s) for s in density_map.shape) + out = density_map.contiguous().clone() + if xyz.shape[0] > 0: + mod.iso_fwd( + out.view(-1), + xyz.detach().contiguous(), adp.detach().contiguous(), + occ.detach().contiguous(), A.contiguous(), B.contiguous(), + r2cut, inv_frac.contiguous().view(-1), frac.contiguous().view(-1), + nx, ny, nz, + ) + ctx.save_for_backward(xyz, adp, occ, A, B, r2cut, inv_frac, frac) + ctx.grid_shape = (nx, ny, nz) + return out + + @staticmethod + def backward(ctx, grad_out): + xyz, adp, occ, A, B, r2cut, inv_frac, frac = ctx.saved_tensors + nx, ny, nz = ctx.grid_shape + if torch.is_grad_enabled(): # create_graph=True -> need a differentiable VJP + from torchref.base.electron_density.kernels.cpu.variable_radius import ( + add_isotropic_plain_var, + ) + + return _double_backward_vjp( + add_isotropic_plain_var, ctx, grad_out, + (xyz, adp, occ), (A, B, inv_frac, frac), r2cut, + ) + g_xyz = torch.zeros_like(xyz) + g_adp = torch.zeros_like(adp) + g_occ = torch.zeros_like(occ) + if xyz.shape[0] > 0: + _require_module().iso_bwd( + g_xyz.view(-1), g_adp, g_occ, + grad_out.contiguous().view(-1), + xyz.detach().contiguous(), adp.detach().contiguous(), + occ.detach().contiguous(), A.contiguous(), B.contiguous(), + r2cut, inv_frac.contiguous().view(-1), frac.contiguous().view(-1), + nx, ny, nz, + ) + # out = density_map + splat, so grad wrt density_map is the identity. + return (grad_out, g_xyz, g_adp, g_occ, None, None, None, None, None) + + +class _FusedAnisoSplat(torch.autograd.Function): + """Anisotropic fused CPU splat: returns ``density_map + splat``.""" + + @staticmethod + def forward(ctx, density_map, xyz, u, occ, A, B, r2cut, inv_frac, frac): + mod = _require_module() + nx, ny, nz = (int(s) for s in density_map.shape) + out = density_map.contiguous().clone() + if xyz.shape[0] > 0: + mod.aniso_fwd( + out.view(-1), + xyz.detach().contiguous(), u.detach().contiguous(), + occ.detach().contiguous(), A.contiguous(), B.contiguous(), + r2cut, inv_frac.contiguous().view(-1), frac.contiguous().view(-1), + nx, ny, nz, + ) + ctx.save_for_backward(xyz, u, occ, A, B, r2cut, inv_frac, frac) + ctx.grid_shape = (nx, ny, nz) + return out + + @staticmethod + def backward(ctx, grad_out): + xyz, u, occ, A, B, r2cut, inv_frac, frac = ctx.saved_tensors + nx, ny, nz = ctx.grid_shape + if torch.is_grad_enabled(): # see _FusedIsoSplat.backward + from torchref.base.electron_density.kernels.cpu.variable_radius import ( + add_anisotropic_plain_var, + ) + + return _double_backward_vjp( + add_anisotropic_plain_var, ctx, grad_out, + (xyz, u, occ), (A, B, inv_frac, frac), r2cut, + ) + g_xyz = torch.zeros_like(xyz) + g_u = torch.zeros_like(u) + g_occ = torch.zeros_like(occ) + if xyz.shape[0] > 0: + _require_module().aniso_bwd( + g_xyz.view(-1), g_u.view(-1), g_occ, + grad_out.contiguous().view(-1), + xyz.detach().contiguous(), u.detach().contiguous(), + occ.detach().contiguous(), A.contiguous(), B.contiguous(), + r2cut, inv_frac.contiguous().view(-1), frac.contiguous().view(-1), + nx, ny, nz, + ) + return (grad_out, g_xyz, g_u, g_occ, None, None, None, None, None) + + +def add_isotropic_cpu_sphere_var( + density_map, xyz, adp, occ, A, B, inv_frac_matrix, frac_matrix, radius_per_atom +): + """Fused isotropic spherical-cutoff splat; adds into ``density_map``. + + Parameters + ---------- + density_map : torch.Tensor + Running density map, shape (nx, ny, nz), CPU float32/float64. Not mutated; + the sum is returned. + xyz : torch.Tensor + Cartesian atom positions, shape (n, 3). + adp : torch.Tensor + Isotropic B-factors, shape (n,). + occ : torch.Tensor + Occupancies, shape (n,). + A, B : torch.Tensor + ITC92 amplitudes / widths, shape (n, 5). + inv_frac_matrix, frac_matrix : torch.Tensor + Cartesian<->fractional matrices, shape (3, 3). The truncation box is + derived from these, so no ``voxel_size`` argument is needed. + radius_per_atom : torch.Tensor + Per-atom cutoff radius in Angstrom, shape (n,), from + :func:`radius_policy.per_atom_radius_iso`. Used raw -- no grid-dependent + requantization, so the cutoff means the same thing at any grid sampling. + + Returns + ------- + torch.Tensor + ``density_map + splat``, shape (nx, ny, nz). + """ + _, r2cut = _prep(density_map, xyz, radius_per_atom, adp, occ, A, B) + return _FusedIsoSplat.apply( + density_map, xyz, adp, occ, A, B, r2cut, inv_frac_matrix, frac_matrix + ) + + +def add_anisotropic_cpu_sphere_var( + density_map, xyz, u, occ, A, B, inv_frac_matrix, frac_matrix, radius_per_atom +): + """Fused anisotropic spherical-cutoff splat; adds into ``density_map``. + + Identical contract to :func:`add_isotropic_cpu_sphere_var`, but ``u`` carries + the 6 anisotropic components ``[U11, U22, U33, U12, U13, U23]`` and the density + is the full 3D Gaussian ``exp(-pi^2 w^T Minv w)`` with + ``M_g = (B_g*I + 8*pi^2*U)/4``. The *cutoff* remains the Euclidean sphere at + ``radius_per_atom`` (the ellipsoid's isotropic bounding radius, from + :func:`radius_policy.per_atom_radius_aniso`) -- matching the CUDA and Metal + kernels, which likewise cull on Euclidean distance and evaluate the + Mahalanobis form. + """ + _, r2cut = _prep(density_map, xyz, radius_per_atom, u, occ, A, B) + return _FusedAnisoSplat.apply( + density_map, xyz, u, occ, A, B, r2cut, inv_frac_matrix, frac_matrix + ) diff --git a/torchref/base/electron_density/kernels/cpu/variable_radius.py b/torchref/base/electron_density/kernels/cpu/variable_radius.py index 0be1a8d..2944942 100644 --- a/torchref/base/electron_density/kernels/cpu/variable_radius.py +++ b/torchref/base/electron_density/kernels/cpu/variable_radius.py @@ -159,49 +159,137 @@ def add_isotropic_cpu_separable_var(density_map, xyz, adp, occ, A, B, # ========================================================================= -# Isotropic fused sphere (Engine.EAGER CPU path) +# Portable canonical-sphere splats # ========================================================================= -def _splat_chunked_sphere(density_flat, G, inv_grid, grid_dims, voxel_size, device, dtype, - xyz_frac, center_idx, B_total, A_norm, spans, chunk=_CHUNK): - """Per box-bucket x chunk: sphere offsets + r^2 + 5-Gaussian + plain scatter_add_.""" - grid_shape = torch.tensor(grid_dims, device=device) - min_voxel = float(voxel_size.min()) +# Reached by ``Engine.EAGER`` on any device, by CUDA/MPS float64, and whenever the +# fused C++ kernel could not be built. They implement the SAME truncation contract +# as the Triton, Metal and fused-CPU kernels, so AUTO and EAGER agree to float +# noise on every device: +# +# voxel v gets atom i's density iff ||w||^2 <= r_i^2, where w is the Cartesian +# atom->voxel vector (sphere centred on the ATOM, not on its anchor node) and +# r_i is the raw radius_policy radius, +# +# enumerated over the triclinic-correct per-axis box +# ``ceil(r * n_axis * ||inv_frac row_axis||)``. See ``sphere_splat.py`` for the +# canonical statement. +# +# These used to diverge from that in three ways at once: the iso path selected +# voxels by ``||offset * voxel_size||`` -- a diagonal metric, wrong for any +# non-orthogonal cell -- measured from the anchor node rather than from the atom, +# at a radius rounded up to a whole voxel; and the aniso path splatted a full cube. +# On a beta=115 deg cell that mis-selected ~12% of each sphere's voxels, a 5e-3 rel +# L2 map error, i.e. larger than the 1.7e-3 truncation error the cutoff exists to +# deliver. +# +# Out-of-sphere voxels are zeroed rather than dropped, keeping the box dense so one +# ``scatter_add`` covers the chunk. That wastes some writes, which is the right +# trade for the portable reference: plain ``scatter_add`` only, so it runs on every +# device, supports float64, and is double-differentiable. + + +def _bucket_by_radius(radius: torch.Tensor, center_1d: torch.Tensor): + """Sort atoms by (radius, center_1d); return ``(order, [(radius, start, end)])``. + + Buckets on the radius itself rather than a voxel-derived box size: the policy + quantizes to 0.25 A and clamps to [2, 7], so there are at most 21 distinct + values and the per-axis box follows from the radius alone. Sorting by 1D centre + within a bucket keeps the scatter cache-friendly. + """ + order_parts, spans, cursor = [], [], 0 + for r in torch.unique(radius).tolist(): + idx = (radius == r).nonzero(as_tuple=True)[0] + idx = idx[torch.argsort(center_1d[idx])] + order_parts.append(idx) + spans.append((float(r), cursor, cursor + idx.numel())) + cursor += idx.numel() + order = (torch.cat(order_parts) if order_parts + else torch.zeros(0, dtype=torch.long, device=radius.device)) + return order, spans + + +def _axis_half_widths(r: float, inv_frac: torch.Tensor, grid_dims): + """``ceil(r * n_axis * ||inv_frac row_axis||)`` -- the kernels' enumeration box. + + The norm is taken in float64 **on the CPU**, never on the input's device: this path + also serves ``Engine.EAGER`` on MPS, which has no float64, and ``.double()`` in place + raises there. Hopping a 3x3 matrix to the CPU is free, and float64 matters because the + result feeds a ``ceil`` -- a value landing a hair under an integer in float32 would + shrink the box by one voxel and silently clip the sphere. + """ + row_norms = torch.linalg.norm(inv_frac.detach().cpu().double(), dim=1) + return tuple( + int(math.ceil(r * float(n) * float(row_norms[i]))) + for i, n in enumerate(grid_dims) + ) + + +def _box_offsets(bh, frac, grid_dims, device, dtype): + """Per-axis box offsets plus their Cartesian displacements. + + Returns ``(offsets (R,3) int64, off_cart (R,3))`` with + ``off_cart = frac @ (offset / n)`` -- the same ``ox*u_a + oy*u_b + oz*u_c`` the + kernels accumulate. + """ + axes = [torch.arange(-b, b + 1, device=device) for b in bh] + gx, gy, gz = torch.meshgrid(*axes, indexing="ij") + offsets = torch.stack((gx, gy, gz), dim=-1).reshape(-1, 3) + inv_grid = 1.0 / torch.tensor(grid_dims, device=device, dtype=dtype) + return offsets, (offsets.to(dtype) * inv_grid) @ frac.T + + +def _canonical_setup(xyz, inv_frac, frac, grid_dims, radius_per_atom, dtype): + """Anchor index, Cartesian sub-voxel residual ``w0``, and radius buckets.""" + device = xyz.device nx, ny, nz = grid_dims - ny_nz = ny * nz - strides = torch.tensor([ny_nz, nz, 1], device=device, dtype=torch.long) - for box_radius, b0, b1 in spans: - offsets = _get_radius_offsets(voxel_size, box_radius * min_voxel, device) # (R,3) - for s in range(b0, b1, chunk): - e = min(s + chunk, b1) - vi = (center_idx[s:e].unsqueeze(1) + offsets.unsqueeze(0)) % grid_shape - voxel_frac = vi.to(dtype) * inv_grid - diff = voxel_frac - xyz_frac[s:e].unsqueeze(1) - diff = diff - torch.round(diff) - r_sq = torch.einsum("avi,ij,avj->av", diff, G, diff) - expo = -_PI_SQ * r_sq.unsqueeze(2) / B_total[s:e].unsqueeze(1) - dens = torch.einsum("ag,avg->av", A_norm[s:e], torch.exp(expo)) - idx_flat = (vi.to(torch.long) * strides).sum(-1).view(-1) - density_flat = density_flat.scatter_add(0, idx_flat, dens.reshape(-1)) - return density_flat + grid_f = torch.tensor(grid_dims, device=device, dtype=dtype) + xyz_frac = (xyz @ inv_frac.T) % 1.0 + center_idx = torch.round(xyz_frac * grid_f).to(torch.long) + # w0: atom position relative to its anchor node, in Cartesian. This is what + # centres the sphere on the atom rather than on the node. + w0 = (xyz_frac - center_idx.to(dtype) / grid_f) @ frac.T + center_1d = ((center_idx[:, 0] % nx) * (ny * nz) + + (center_idx[:, 1] % ny) * nz + (center_idx[:, 2] % nz)) + order, spans = _bucket_by_radius(radius_per_atom, center_1d) + return order, spans, center_idx[order], w0[order] def add_isotropic_plain_var(density_map, xyz, adp, occ, A, B, - inv_frac_matrix, frac_matrix, grid_shape_tuple, - voxel_size, radius_per_atom): - """Variable-radius grouped fused-sphere isotropic splat; adds into ``density_map``. + inv_frac_matrix, frac_matrix, radius_per_atom): + """Portable canonical-sphere isotropic splat; adds into ``density_map``. + + Signature mirrors + :func:`~torchref.base.electron_density.kernels.cpu.sphere_splat.add_isotropic_cpu_sphere_var` + exactly, so the two are interchangeable at the dispatch site. There is no + ``voxel_size`` or ``grid_shape`` argument: the grid shape comes from + ``density_map`` and the truncation box from ``inv_frac_matrix``. + """ + device, dtype = xyz.device, density_map.dtype + nx, ny, nz = (int(s) for s in density_map.shape) + grid_dims = (nx, ny, nz) + strides = torch.tensor([ny * nz, nz, 1], device=device, dtype=torch.long) + grid_shape = torch.tensor(grid_dims, device=device, dtype=torch.long) + + order, spans, center_idx, w0 = _canonical_setup( + xyz, inv_frac_matrix, frac_matrix, grid_dims, radius_per_atom, dtype) + B_total = ((B + adp[:, None]) * 0.25).clamp(min=0.1)[order] + A_norm = (A * occ[:, None])[order] * _PI_1P5 / (B_total * torch.sqrt(B_total)) + r2cut = (radius_per_atom * radius_per_atom)[order] - Uses only plain ``scatter_add`` (no custom C++ scatter), so it runs on every - device (CPU/CUDA/MPS), supports float64, and is double-differentiable -- the - portable / eager per-atom path.""" - nx, ny, nz = grid_shape_tuple - st = _iso_setup(xyz, adp, occ, A, B, inv_frac_matrix, frac_matrix, - grid_shape_tuple, voxel_size, radius_per_atom) density_flat = density_map.reshape(-1) - density_flat = _splat_chunked_sphere( - density_flat, st["G"], st["inv_grid"], st["grid_dims"], voxel_size, - xyz.device, xyz.dtype, st["xyz_frac"], st["center_idx"], - st["B_total"], st["A_norm"], st["spans"], - ) + for r, b0, b1 in spans: + bh = _axis_half_widths(r, inv_frac_matrix, grid_dims) + offsets, off_cart = _box_offsets(bh, frac_matrix, grid_dims, device, dtype) + for s in range(b0, b1, _CHUNK): + e = min(s + _CHUNK, b1) + w = off_cart.unsqueeze(0) - w0[s:e].unsqueeze(1) # (C,R,3) + r_sq = (w * w).sum(-1) # (C,R) + expo = -_PI_SQ * r_sq.unsqueeze(2) / B_total[s:e].unsqueeze(1) + dens = torch.einsum("ag,avg->av", A_norm[s:e], torch.exp(expo)) + dens = torch.where(r_sq <= r2cut[s:e, None], dens, dens.new_zeros(())) + vi = (center_idx[s:e].unsqueeze(1) + offsets.unsqueeze(0)) % grid_shape + idx_flat = (vi * strides).sum(-1).reshape(-1) + density_flat = density_flat.scatter_add(0, idx_flat, dens.reshape(-1)) return density_flat.view(nx, ny, nz) @@ -277,44 +365,49 @@ def add_anisotropic_cpu_var(real_space_grid, density_map, xyz, u, occ, A, B, return density_flat.view(nx, ny, nz) -def _splat_chunked_aniso_plain(density_flat, frac, inv_grid, grid_dims, device, dtype, - xyz_frac, center_idx, Minv, A_norm, spans, chunk=_CHUNK): - """Per box-bucket x chunk: full 3D aniso cube + plain ``scatter_add`` (portable).""" - nx, ny, nz = grid_dims - ny_nz = ny * nz - for box_radius, b0, b1 in spans: - axis = _axis_offsets(box_radius, device, dtypes.int) - axis_frac = axis.to(dtype).unsqueeze(0) * inv_grid.unsqueeze(1) - for s in range(b0, b1, chunk): - e = min(s + chunk, b1) - ci = center_idx[s:e] - sub = xyz_frac[s:e] - ci.to(dtype) * inv_grid - d_frac = axis_frac.unsqueeze(0) - sub.unsqueeze(2) - d_frac = d_frac - torch.round(d_frac) - cube = _aniso_density_cube(d_frac, frac, Minv[s:e], A_norm[s:e]) # (C,L,L,L) - ix = (ci[:, 0:1] + axis.unsqueeze(0)) % nx # (C,L) - iy = (ci[:, 1:2] + axis.unsqueeze(0)) % ny - iz = (ci[:, 2:3] + axis.unsqueeze(0)) % nz - flat = (ix[:, :, None, None].to(torch.long) * ny_nz - + iy[:, None, :, None].to(torch.long) * nz - + iz[:, None, None, :].to(torch.long)) # (C,L,L,L) - density_flat = density_flat.scatter_add(0, flat.reshape(-1), cube.reshape(-1)) - return density_flat +def add_anisotropic_plain_var(density_map, xyz, u, occ, A, B, + inv_frac_matrix, frac_matrix, radius_per_atom): + """Portable canonical-sphere anisotropic splat; adds into ``density_map``. + Signature mirrors + :func:`~torchref.base.electron_density.kernels.cpu.sphere_splat.add_anisotropic_cpu_sphere_var`. + Density is the full 3D Gaussian ``exp(-pi^2 w^T Minv w)`` with + ``M_g = (B_g*I + 8*pi^2*U)/4``; the *cutoff* is the Euclidean sphere at + ``radius_per_atom`` (the ellipsoid's isotropic bounding radius), matching the + CUDA and Metal kernels, which likewise cull on Euclidean distance and evaluate + the Mahalanobis form. -def add_anisotropic_plain_var(real_space_grid, density_map, xyz, u, occ, A, B, - inv_frac_matrix, frac_matrix, radius_per_atom, voxel_size): - """Variable-radius anisotropic box-splat using plain ``scatter_add``. + This replaces a full-cube splat: the cube was ~2.3x the sphere's voxel count + and disagreed with every accelerator path. + """ + device, dtype = xyz.device, density_map.dtype + nx, ny, nz = (int(s) for s in density_map.shape) + grid_dims = (nx, ny, nz) + strides = torch.tensor([ny * nz, nz, 1], device=device, dtype=torch.long) + grid_shape = torch.tensor(grid_dims, device=device, dtype=torch.long) + + order, spans, center_idx, w0 = _canonical_setup( + xyz, inv_frac_matrix, frac_matrix, grid_dims, radius_per_atom, dtype) + eye = torch.eye(3, dtype=dtype, device=device) + M = (B[:, :, None, None] * eye + _EIGHT_PI_SQ * _u6_to_u3(u)[:, None, :, :]) / 4.0 + Minv = torch.linalg.inv(M)[order] # (N,5,3,3) + det = torch.linalg.det(M).clamp(min=1e-10)[order] + A_norm = (A * occ[:, None])[order] * _PI_1P5 / torch.sqrt(det) # (N,5) + r2cut = (radius_per_atom * radius_per_atom)[order] - The portable / eager anisotropic per-atom path: no custom C++ scatter, so it - runs on every device, supports float64, and is double-differentiable.""" - nx, ny, nz = real_space_grid.shape[:3] - st = _aniso_setup(xyz, u, occ, A, B, inv_frac_matrix, frac_matrix, - (nx, ny, nz), voxel_size, radius_per_atom) density_flat = density_map.reshape(-1) - density_flat = _splat_chunked_aniso_plain( - density_flat, st["frac"], st["inv_grid"], st["grid_dims"], - xyz.device, xyz.dtype, st["xyz_frac"], st["center_idx"], - st["Minv"], st["A_norm"], st["spans"], - ) + for r, b0, b1 in spans: + bh = _axis_half_widths(r, inv_frac_matrix, grid_dims) + offsets, off_cart = _box_offsets(bh, frac_matrix, grid_dims, device, dtype) + for s in range(b0, b1, _CHUNK): + e = min(s + _CHUNK, b1) + w = off_cart.unsqueeze(0) - w0[s:e].unsqueeze(1) # (C,R,3) + r_sq = (w * w).sum(-1) # (C,R) + # q[a,v,g] = w^T Minv_g w + q = torch.einsum("avi,agij,avj->avg", w, Minv[s:e], w) + dens = torch.einsum("ag,avg->av", A_norm[s:e], torch.exp(-_PI_SQ * q)) + dens = torch.where(r_sq <= r2cut[s:e, None], dens, dens.new_zeros(())) + vi = (center_idx[s:e].unsqueeze(1) + offsets.unsqueeze(0)) % grid_shape + idx_flat = (vi * strides).sum(-1).reshape(-1) + density_flat = density_flat.scatter_add(0, idx_flat, dens.reshape(-1)) return density_flat.view(nx, ny, nz) diff --git a/torchref/base/electron_density/kernels/mps/variable_radius.py b/torchref/base/electron_density/kernels/mps/variable_radius.py index e11f2c2..c740798 100644 --- a/torchref/base/electron_density/kernels/mps/variable_radius.py +++ b/torchref/base/electron_density/kernels/mps/variable_radius.py @@ -20,13 +20,19 @@ from torchref.base.electron_density.kernels.mps.compile import _get_lib -def _quantized_r2cut(radius_per_atom, voxel_size): - """Per-atom squared cutoff quantized to a voxel multiple, matching the plain - reference (``_box_radius_per_atom`` * ``min_voxel`` in - ``kernels/cpu/variable_radius.py``).""" - min_voxel = voxel_size.min() - r_eff = torch.ceil(radius_per_atom / min_voxel) * min_voxel - return r_eff * r_eff +def _r2cut(radius_per_atom): + """Per-atom squared cutoff: the policy radius, used raw. + + This used to round ``radius_per_atom`` up to a whole voxel, to match the old + plain-splat reference whose offset list was built in voxel units. That made the + effective cutoff **grid-dependent** -- inflating it by up to one voxel (~0.4 A, + i.e. ~0.4 sigma at 0.4 A sampling), so Metal truncated systematically later than + the CUDA kernel and ``torchref.sigma_cutoff_ed`` meant different things on the two + devices. The radius arriving from + :mod:`torchref.base.electron_density.radius_policy` is already quantized (0.25 A) + and clamped, so it is used as-is; every backend now shares one cutoff. + """ + return radius_per_atom * radius_per_atom class MetalGridDensity(torch.autograd.Function): @@ -98,14 +104,13 @@ def add_isotropic_mps_var( ): """Isotropic variable-radius Metal splat; adds into ``density_map``. - Signature mirrors ``add_isotropic_plain_var`` (``grid_shape_tuple`` is - unused -- the grid shape comes from ``density_map``). + Signature mirrors ``add_isotropic_plain_var`` (``grid_shape_tuple`` and + ``voxel_size`` are unused -- the grid shape comes from ``density_map`` and the + truncation box from the inverse-cell row norms). - The truncation radius is quantized up to a voxel multiple - (``ceil(radius/min_voxel)*min_voxel``) to exactly match the plain-splat - reference's per-atom cutoff, so the Metal and fallback paths agree. + The truncation radius is the policy radius, used raw; see :func:`_r2cut`. """ - r2cut = _quantized_r2cut(radius_per_atom, voxel_size) + r2cut = _r2cut(radius_per_atom) mask = torch.ones(xyz.shape[0], 5, dtype=xyz.dtype, device=xyz.device) return MetalGridDensity.apply( density_map, xyz, adp, occ, A, B, r2cut, mask, inv_frac_matrix, frac_matrix @@ -182,10 +187,10 @@ def add_anisotropic_mps_var( Signature mirrors ``add_anisotropic_plain_var`` (``real_space_grid`` is used only for its grid shape). Each atom is truncated at its per-axis bounding box - with a sphere cull at the (quantized) per-atom radius -- far tighter than the - plain reference's full cube on anisotropic high-resolution cells. + with a sphere cull at the per-atom radius -- the same canonical cutoff the + CUDA and fused-CPU kernels apply. """ - r2cut = _quantized_r2cut(radius_per_atom, voxel_size) + r2cut = _r2cut(radius_per_atom) mask = torch.ones(xyz.shape[0], 5, dtype=xyz.dtype, device=xyz.device) return MetalGridDensityAniso.apply( density_map, xyz, u, occ, A, B, r2cut, mask, inv_frac_matrix, frac_matrix diff --git a/torchref/base/electron_density/main.py b/torchref/base/electron_density/main.py index a3b5679..32ece4c 100644 --- a/torchref/base/electron_density/main.py +++ b/torchref/base/electron_density/main.py @@ -1,27 +1,47 @@ """ Central electron density building, dispatched solely by the shared ``Engine``. +One truncation contract, every backend +------------------------------------- Every atom is splatted at its own per-atom truncation radius (``N_sigma * sigma_eff``, with ``N_sigma = torchref.sigma_cutoff_ed``); there is -no single global splat radius. The capability-based ``Engine`` in -:mod:`torchref.utils.triton_dispatch` (AUTO/TRITON/METAL/EAGER) is the *only* -switch selecting which variable-radius kernel runs — there is no -environment-variable dispatch and no parallel "tier" knobs: - -- ``Engine.AUTO`` — fastest available per device, all variable-radius: +no single global splat radius. Crucially, every production kernel now applies the +*same* cutoff, so ``sigma_cutoff_ed`` means the same thing on every device: + + voxel v receives atom i's density iff ``||w||^2 <= r_i^2``, where ``w`` is the + minimum-image **Cartesian atom->voxel** vector (the sphere is centred on the + atom, not on its nearest grid node) and ``r_i`` is the raw + :mod:`~torchref.base.electron_density.radius_policy` radius, + +enumerated over the triclinic-correct per-axis box +``ceil(r_i * n_axis * ||inv_frac row_axis||)``. No grid-dependent requantization of +the radius, no diagonal-metric approximation, no cube. Historically the backends +disagreed here -- Metal inflated the radius to a whole voxel, the portable CPU +splat used a node-centred diagonal metric, and the CPU fast path splatted a cube -- +by amounts comparable to or larger than the truncation error itself. + +Engine dispatch +--------------- +The capability-based ``Engine`` in :mod:`torchref.utils.triton_dispatch` +(AUTO/TRITON/METAL/EAGER) is the *only* switch selecting which kernel runs — there +is no environment-variable dispatch and no parallel "tier" knobs: + +- ``Engine.AUTO`` — fastest available per device: CUDA+float32 -> the work-queue Triton kernels - (``WorkQueueGridDensity`` / ``WorkQueueGridDensityAniso``); CPU+float32 -> the - grouped-separable variable-radius splat - (``add_isotropic_cpu_separable_var`` / ``add_anisotropic_cpu_var``); + (``WorkQueueGridDensity`` / ``WorkQueueGridDensityAniso``); + CPU float32 **or float64** -> the fused C++ spherical-cutoff splat + (``add_isotropic_cpu_sphere_var`` / ``add_anisotropic_cpu_sphere_var``); MPS+float32 -> the native Metal kernels (``add_isotropic_mps_var`` / ``add_anisotropic_mps_var``, compiled via ``torch.mps.compile_shader``); everything else (CUDA/MPS float64) -> the - portable plain-scatter variable-radius splat (``add_isotropic_plain_var`` / + portable plain-scatter splat (``add_isotropic_plain_var`` / ``add_anisotropic_plain_var``). On a Triton/Metal kernel failure or - unavailability under AUTO it falls through to the plain-scatter splat. -- ``Engine.EAGER`` — the portable plain-scatter variable-radius splat on every - device. Double-differentiable; use it for Hessians / debugging. Force it with - ``with use_engine(Engine.EAGER): ...``. + unavailability under AUTO it falls through to the portable splat. +- ``Engine.EAGER`` — the portable plain-scatter splat on every device. + Double-differentiable; use it for Hessians / debugging. Force it with + ``with use_engine(Engine.EAGER): ...``. Because it now shares the truncation + contract above, AUTO-vs-EAGER is a genuine equivalence check rather than a + comparison of two different geometries. - ``Engine.TRITON`` — force the CUDA work-queue Triton kernel (raises if not CUDA+float32). - ``Engine.METAL`` — force the native Metal kernel (raises if not MPS+float32, @@ -29,14 +49,15 @@ under ``AUTO`` a broken kernel degrades silently to the portable splat, so a comparison against ``EAGER`` would pass while measuring nothing. -The production variable-radius splats live in -``kernels/cuda/variable_radius.py`` and ``kernels/cpu/variable_radius.py``; the -per-atom radius policy is in :mod:`torchref.base.electron_density.radius_policy`. -Several legacy fixed-radius kernels are re-imported here so the historical +The production splats live in ``kernels/cuda/variable_radius.py``, +``kernels/cpu/sphere_splat.py``, ``kernels/mps/variable_radius.py`` and (portable) +``kernels/cpu/variable_radius.py``; the per-atom radius policy is in +:mod:`torchref.base.electron_density.radius_policy`. Legacy kernels — the +fixed-radius Triton/JIT ones and the grouped-separable cube splats +(``add_isotropic_cpu_separable_var`` / ``add_anisotropic_cpu_var``, superseded by +the fused sphere kernel) — are re-imported here so the historical ``torchref.base.electron_density.main`` namespace is unchanged and they remain -callable directly for benchmarking, but they are *not* on the production -dispatch path. This module keeps only the ``Engine``-based variable-radius -dispatch. +callable for benchmarking, but they are *not* on the production dispatch path. """ from typing import Optional @@ -81,6 +102,11 @@ add_isotropic_cpu_separable_var, add_isotropic_plain_var, ) +from torchref.base.electron_density.kernels.cpu.sphere_splat import ( + add_anisotropic_cpu_sphere_var, + add_isotropic_cpu_sphere_var, + should_use_sphere_splat, +) def build_electron_density( @@ -218,16 +244,18 @@ def _add_isotropic( variable-radius Triton kernel (``WorkQueueGridDensity``). On kernel failure under AUTO it falls through to the portable splat; under ``Engine.TRITON`` it raises (never silently degrade). - - CPU + float32 + AUTO -> the fast C++-scatter grouped-separable splat. + - ``should_use_sphere_splat`` (CPU + float32/float64 + built extension, AUTO) + -> the fused C++ spherical-cutoff splat. - ``should_use_metal`` (MPS + float32 + compiled shader, engine AUTO/METAL) -> the native Metal kernel (``add_isotropic_mps_var``). Mirrors the Triton branch: on kernel failure under AUTO it falls through to the portable splat; under ``Engine.METAL`` it raises (never silently degrade). - Everything else (``Engine.EAGER`` on any device, CUDA/MPS float64) -> the - portable plain-``scatter_add`` grouped splat: identical per-atom radius, - double-differentiable, float64-capable, device-agnostic. + portable plain-``scatter_add`` splat: double-differentiable, float64-capable, + device-agnostic. - Every path truncates each atom at its own ``N_sigma * sigma_eff`` radius. + Every path applies the identical spherical cutoff documented in the module + docstring, so these branches differ only in speed, not in result. """ n_sigma = get_sigma_cutoff_ed() radius_per_atom = per_atom_radius_iso(adp, B, n_sigma=n_sigma) @@ -256,16 +284,17 @@ def _add_isotropic( # AUTO: fall through to the portable splat grid_shape_tuple = real_space_grid.shape[:3] - # The C++-scatter fast path is float32-only; non-float32 (float64 config, - # complex-derived double grids) falls through to the portable plain splat. - if ( - get_engine() is Engine.AUTO - and density_map.device.type == "cpu" - and density_map.dtype == torch.float32 + # Fused C++ spherical-cutoff splat: the CPU production path, float32 AND float64 + # (the kernel is templated on the scalar type, so a float64 config no longer + # drops to the slow portable splat). ``should_use_sphere_splat`` owns the + # device/dtype/availability decision; if the extension did not build it returns + # False and the portable splat below -- same truncation contract -- takes over. + if get_engine() is Engine.AUTO and should_use_sphere_splat( + density_map, xyz, adp, occ, A, B ): - return add_isotropic_cpu_separable_var( + return add_isotropic_cpu_sphere_var( density_map, xyz, adp, occ, A, B, - inv_frac_matrix, frac_matrix, grid_shape_tuple, voxel_size, radius_per_atom, + inv_frac_matrix, frac_matrix, radius_per_atom, ) # Native Metal splat on Apple-silicon GPUs (float32 only). ``should_use_metal`` # owns the device/dtype/availability decision, so an unavailable shader under @@ -286,7 +315,7 @@ def _add_isotropic( # AUTO: fall through to the portable splat return add_isotropic_plain_var( density_map, xyz, adp, occ, A, B, - inv_frac_matrix, frac_matrix, grid_shape_tuple, voxel_size, radius_per_atom, + inv_frac_matrix, frac_matrix, radius_per_atom, ) @@ -308,14 +337,17 @@ def _add_anisotropic( (largest principal axis, ``per_atom_radius_aniso``). CUDA+float32 (engine permitting) -> ``WorkQueueGridDensityAniso``. CPU + - float32 + AUTO -> the fast C++-scatter grouped box-splat - ``add_anisotropic_cpu_var``. MPS + float32 + AUTO/METAL (via + float32/float64 + AUTO -> the fused C++ sphere splat + ``add_anisotropic_cpu_sphere_var``. MPS + float32 + AUTO/METAL (via ``should_use_metal``) -> the native Metal kernel ``add_anisotropic_mps_var``; falls through under AUTO, raises under ``Engine.METAL``. Everything else (``Engine.EAGER`` on any device, CUDA/MPS float64) -> the portable - plain-``scatter_add`` box-splat ``add_anisotropic_plain_var`` (double-diff, - float64-capable, device-agnostic). All paths use the per-atom radius - (isotropic bounding sphere of the ellipsoid). + plain-``scatter_add`` splat ``add_anisotropic_plain_var`` (double-diff, + float64-capable, device-agnostic). + + Every path culls on the Euclidean sphere at the per-atom radius (the + ellipsoid's isotropic bounding radius) and evaluates the Mahalanobis form + inside it -- one contract, as for the isotropic pass. """ n_sigma = get_sigma_cutoff_ed() radius_per_atom = per_atom_radius_aniso(B, u, n_sigma=n_sigma) @@ -343,16 +375,13 @@ def _add_anisotropic( raise # AUTO: fall back to the portable splat - # The C++-scatter fast path is float32-only; non-float32 falls through to - # the portable plain splat (see _add_isotropic). - if ( - get_engine() is Engine.AUTO - and density_map.device.type == "cpu" - and density_map.dtype == torch.float32 + # Fused C++ spherical-cutoff splat, float32 and float64 (see _add_isotropic). + if get_engine() is Engine.AUTO and should_use_sphere_splat( + density_map, xyz, u, occ, A, B ): - return add_anisotropic_cpu_var( - real_space_grid, density_map, xyz, u, occ, A, B, - inv_frac_matrix, frac_matrix, radius_per_atom, voxel_size, + return add_anisotropic_cpu_sphere_var( + density_map, xyz, u, occ, A, B, + inv_frac_matrix, frac_matrix, radius_per_atom, ) # Native Metal splat on Apple-silicon GPUs (float32 only). See the iso path: # ``should_use_metal`` owns device/dtype/availability, and the import is @@ -370,6 +399,6 @@ def _add_anisotropic( raise # AUTO: fall through to the portable splat return add_anisotropic_plain_var( - real_space_grid, density_map, xyz, u, occ, A, B, - inv_frac_matrix, frac_matrix, radius_per_atom, voxel_size, + density_map, xyz, u, occ, A, B, + inv_frac_matrix, frac_matrix, radius_per_atom, ) diff --git a/torchref/symmetry/reciprocal_symmetry.py b/torchref/symmetry/reciprocal_symmetry.py index 9745ca0..527fe7a 100644 --- a/torchref/symmetry/reciprocal_symmetry.py +++ b/torchref/symmetry/reciprocal_symmetry.py @@ -771,8 +771,23 @@ def expand_hkl( hkl_transformed = torch.round(torch.matmul(hkl_float, recip_matrices[i].T)).to( torch.int32 ) - # Phase shift from translation: 2π × h·t - phase_shift = 2.0 * np.pi * torch.matmul(hkl_float, translations[i]) + # Phase shift from translation: -2π × h·t + # + # Derivation (do not "simplify" the sign away -- it was +2π here until + # 2026-07 and the error is invisible in P21/P212121/C2, which is why it + # survived). For a structure invariant under x -> Rx + t, and with the + # convention F(h) = Σ_j f_j exp(+2πi h·x_j): + # + # F(h) = Σ_j f_j exp(2πi h·(R x_j + t)) + # = exp(2πi h·t) · F(hR) + # => F(hR) = F(h) · exp(-2πi h·t) + # => arg F(h') - arg F(h) = -2π h·t for h' = hR = R^T h + # + # Verified against direct structure-factor summation and + # ``gemmi.Op.phase_shift``. The residual error of the wrong sign is + # 4π h·t mod 2π: zero for 2₁ screws and centring, π for 4₁/4₃, and + # 2π/3 for 3₁/6₁ -- see ``tests/unit/symmetry/test_phase_convention.py``. + phase_shift = -2.0 * np.pi * torch.matmul(hkl_float, translations[i]) all_hkl.append(hkl_transformed) all_phases.append(phase_shift) @@ -1266,7 +1281,8 @@ def get_canonical_hkl(hkl_single): t = translations[equiv_idx] hkl_trans = torch.round(torch.matmul(hkl_single, R.T)).to(torch.int32) - phase_shift = 2.0 * np.pi * torch.matmul(hkl_single, t) + # -2π h·t, same convention as expand_hkl (see the derivation there). + phase_shift = -2.0 * np.pi * torch.matmul(hkl_single, t) if tuple(hkl_trans.cpu().numpy()) == canonical: asu_reflections[canonical].append( @@ -1500,10 +1516,28 @@ def canonicalize_hkl( ) # --- Compute phase shifts vectorially --- - # phase_shift[i] = 2*pi * hkl_orig[i] . translations[op_idx[i]] + # The consumer applies these as + # phi_canonical = where(friedel_flags, -phi_input, phi_input) + phase_shifts + # so the required sign *depends on whether the row was Friedel-flipped*. + # With h' = hR (see the derivation in ``expand_hkl``), arg F(h') = arg F(h) - 2π h·t: + # + # non-Friedel, h_c = h R: + # phi_c = phi_h - 2π h·t => shift = -2π h·t + # Friedel, h_c = -(h R): + # phi_c = -phi_(hR) = -phi_h + 2π h·t + # and the consumer already supplies -phi_h => shift = +2π h·t + # + # A single uniform sign is therefore wrong for one of the two halves. This was + # +2π for both until 2026-07, which happened to be correct for the Friedel half + # and wrong for the rest; it is undetectable in P21/P212121/C2 because every + # shift there is 0 or π. See tests/unit/symmetry/test_phase_convention.py. t_selected = translations_np[op_idx] # (N, 3) + friedel_sign = np.where(friedel_np, 1.0, -1.0).astype(np.float32) phase_shifts_np = ( - 2.0 * np.pi * np.sum(hkl_np.astype(np.float32) * t_selected, axis=1) + friedel_sign + * 2.0 + * np.pi + * np.sum(hkl_np.astype(np.float32) * t_selected, axis=1) ).astype(np.float32) # --- Convert to tensors and sort --- From c686a4d5f680c7383fa9fa570ad01f04dde81295 Mon Sep 17 00:00:00 2001 From: HatPdotS Date: Wed, 29 Jul 2026 17:25:19 +0900 Subject: [PATCH 09/15] Finished kernel test stratification for structure factor calculation --- tests/helpers/grad_asserts.py | 8 +- tests/helpers/kernel_cases.py | 127 ---- tests/integration/test_ds_triton_vs_eager.py | 129 ---- tests/integration/test_variable_radius_gpu.py | 176 ------ tests/integration/test_variable_radius_mps.py | 251 -------- tests/unit/base/test_aniso_map_building.py | 93 --- tests/unit/base/test_canonical_sphere_cpu.py | 181 +++++- tests/unit/base/test_cpu_scatter.py | 577 ------------------ tests/unit/base/test_ds_dispatch.py | 84 --- tests/unit/base/test_vectorized_add_to_map.py | 446 -------------- .../__init__.py | 66 ++ .../conftest.py | 80 +++ .../helpers.py | 285 ++++++++- tests/unit/structure_factor/test_dispatch.py | 387 ++++++++++++ .../unit/structure_factor/test_ds_dispatch.py | 123 ++++ .../test_forward.py | 128 ++++ .../test_gradients.py | 118 ++++ .../test_second_order.py | 84 +++ tests/unit/test_gradient_correctness.py | 94 +-- tests/unit/test_kernel_fixes.py | 27 - .../kernels/cuda/variable_radius.py | 82 +++ .../kernels/mps/variable_radius.py | 27 +- torchref/base/electron_density/main.py | 82 +-- 23 files changed, 1574 insertions(+), 2081 deletions(-) delete mode 100644 tests/helpers/kernel_cases.py delete mode 100644 tests/integration/test_ds_triton_vs_eager.py delete mode 100644 tests/integration/test_variable_radius_gpu.py delete mode 100644 tests/integration/test_variable_radius_mps.py delete mode 100644 tests/unit/base/test_aniso_map_building.py delete mode 100644 tests/unit/base/test_cpu_scatter.py delete mode 100644 tests/unit/base/test_ds_dispatch.py delete mode 100644 tests/unit/base/test_vectorized_add_to_map.py rename tests/unit/{sf_oracle => structure_factor}/__init__.py (76%) rename tests/unit/{sf_oracle => structure_factor}/conftest.py (71%) rename tests/unit/{sf_oracle => structure_factor}/helpers.py (63%) create mode 100644 tests/unit/structure_factor/test_dispatch.py create mode 100644 tests/unit/structure_factor/test_ds_dispatch.py rename tests/unit/{sf_oracle => structure_factor}/test_forward.py (72%) rename tests/unit/{sf_oracle => structure_factor}/test_gradients.py (68%) rename tests/unit/{sf_oracle => structure_factor}/test_second_order.py (77%) diff --git a/tests/helpers/grad_asserts.py b/tests/helpers/grad_asserts.py index f61eca9..9b9d877 100644 --- a/tests/helpers/grad_asserts.py +++ b/tests/helpers/grad_asserts.py @@ -5,9 +5,11 @@ ``2 * grad`` is perfectly parallel to the reference -- so every assertion here checks direction *and* magnitude. -Extracted from ``tests/unit/test_gradient_correctness.py`` so the accelerator -kernel tests (``test_variable_radius_gpu.py`` / ``test_variable_radius_mps.py``) -can reuse them instead of hand-rolling a bare cosine check. +Extracted from ``tests/unit/test_gradient_correctness.py`` so that other suites could +reuse them instead of hand-rolling a bare cosine check. The original consumers were the +accelerator kernel tests; those are now ``tests/unit/structure_factor/``, which compares +every production kernel against a direct-summation oracle rather than against another +kernel. """ from __future__ import annotations diff --git a/tests/helpers/kernel_cases.py b/tests/helpers/kernel_cases.py deleted file mode 100644 index e4c9678..0000000 --- a/tests/helpers/kernel_cases.py +++ /dev/null @@ -1,127 +0,0 @@ -"""Shared cells, atom sets and map metrics for the accelerator splat tests. - -``test_variable_radius_gpu.py`` (Triton) and ``test_variable_radius_mps.py`` -(Metal) compare an accelerator density splat against the portable reference. -They had byte-identical private copies of everything below, which is how the two -degeneracies fixed here survived in both at once: - -* every atom had ``occ == 1``, so the kernels' ``grad_occ = grad_sum / occ`` - scaling was only ever exercised as a division by one; -* anisotropic ``u`` had **zero off-diagonals**, i.e. every ellipsoid was - axis-aligned. That left the cross-term arithmetic completely uncovered -- - the ``p01``/``p02``/``p12`` entries of the inverted 3x3, and the backward's - off-diagonal U gradients, which carry a ``4*pi^2`` factor where the diagonal - ones carry ``2*pi^2``. - -Both are now non-degenerate, following the shapes already used by -``_sf_leaves`` / ``_rand_u6`` in ``tests/unit/test_gradient_correctness.py``. -""" - -from __future__ import annotations - -import math - -import torch - -__all__ = [ - "cell_orthorhombic", - "cell_monoclinic", - "iso_atoms", - "aniso_atoms", - "cos_sim", - "rel_map", - "to_device", -] - - -def cell_orthorhombic(): - """Orthorhombic P1 cell + grid. Returns ``(abc, grid, frac, inv_frac, voxel, rsg)``.""" - a, b, c = 30.0, 25.0, 20.0 - nx, ny, nz = 60, 50, 40 - frac = torch.diag(torch.tensor([a, b, c])) - inv_frac = torch.diag(torch.tensor([1 / a, 1 / b, 1 / c])) - voxel = torch.tensor([a / nx, b / ny, c / nz]) - ii, jj, kk = torch.meshgrid( - torch.arange(nx), torch.arange(ny), torch.arange(nz), indexing="ij" - ) - rsg = (torch.stack([ii / nx, jj / ny, kk / nz], -1).to(torch.float32)) @ frac.T - return (a, b, c), (nx, ny, nz), frac.float(), inv_frac.float(), voxel.float(), rsg - - -def cell_monoclinic(): - """Non-orthogonal (beta ~ 100 deg) cell: exercises the per-axis bounding box - and the off-diagonal coordinate math (the c voxel step gains an x-component).""" - a, b, c = 30.0, 25.0, 20.0 - nx, ny, nz = 60, 50, 40 - beta = math.radians(100.0) - frac = torch.tensor( - [[a, 0.0, c * math.cos(beta)], - [0.0, b, 0.0], - [0.0, 0.0, c * math.sin(beta)]], dtype=torch.float64) - inv_frac = torch.linalg.inv(frac) - voxel = (frac.norm(dim=0) / torch.tensor([nx, ny, nz], dtype=torch.float64)).float() - ii, jj, kk = torch.meshgrid( - torch.arange(nx), torch.arange(ny), torch.arange(nz), indexing="ij" - ) - fc = torch.stack([ii / nx, jj / ny, kk / nz], -1).double() - rsg = (fc @ frac.T).float() - return (a, b, c), (nx, ny, nz), frac.float(), inv_frac.float(), voxel, rsg - - -def iso_atoms(cell, n=60, seed=0): - """Isotropic atoms. Returns ``(xyz, adp, occ, A, B)``, all float32. - - Occupancies are in ``[0.6, 1.0)``, never exactly 1: the kernels divide the - accumulated gradient by ``occ`` to recover ``d/d occ``, and at ``occ == 1`` - that division is a no-op that hides a wrong scaling. - """ - g = torch.Generator().manual_seed(seed) - a, b, c = cell - xyz = torch.rand(n, 3, generator=g) * torch.tensor([a, b, c]) - adp = torch.rand(n, generator=g) * 35 + 3 - occ = torch.rand(n, generator=g) * 0.4 + 0.6 - A = torch.rand(n, 5, generator=g) * 5 - B = torch.rand(n, 5, generator=g) * 20 + 2 - return [t.float() for t in (xyz, adp, occ, A, B)] - - -def aniso_atoms(cell, n=30, seed=1): - """Anisotropic atoms. Returns ``(xyz, u, occ, A, B)``, all float32. - - ``u`` is ``[U11, U22, U33, U12, U13, U23]`` with a positive diagonal and - **signed, non-zero off-diagonals** (magnitudes chosen to keep every U - comfortably positive-definite, as the shader inverts ``M_g`` analytically - without a positive-definiteness guard). Occupancies are non-unit, as above. - """ - g = torch.Generator().manual_seed(seed) - a, b, c = cell - xyz = torch.rand(n, 3, generator=g) * torch.tensor([a, b, c]) - u = torch.zeros(n, 6) - u[:, :3] = torch.rand(n, 3, generator=g) * 0.12 + 0.02 - u[:, 3:] = (torch.rand(n, 3, generator=g) - 0.5) * 0.02 - occ = torch.rand(n, generator=g) * 0.4 + 0.6 - A = torch.rand(n, 5, generator=g) * 5 - B = torch.rand(n, 5, generator=g) * 20 + 2 - return [t.float() for t in (xyz, u, occ, A, B)] - - -def cos_sim(a, b): - """Cosine similarity of two maps, flattened, in float64. - - Moves to CPU first: the accumulation wants float64, and MPS has no float64, - so a caller who forgot ``.cpu()`` would otherwise get a confusing dtype - error from the backend rather than a comparison. - """ - a, b = a.detach().cpu().reshape(-1).double(), b.detach().cpu().reshape(-1).double() - return float((a @ b) / (a.norm() * b.norm() + 1e-12)) - - -def rel_map(x, y): - """Max absolute map difference, relative to the reference's peak.""" - x, y = x.detach().cpu(), y.detach().cpu() - return float((x - y).abs().max() / (y.abs().max() + 1e-8)) - - -def to_device(dev, *ts): - """Move every tensor in ``ts`` to ``dev``.""" - return [t.to(dev) for t in ts] diff --git a/tests/integration/test_ds_triton_vs_eager.py b/tests/integration/test_ds_triton_vs_eager.py deleted file mode 100644 index 2e0fda4..0000000 --- a/tests/integration/test_ds_triton_vs_eager.py +++ /dev/null @@ -1,129 +0,0 @@ -"""Triton-vs-eager equivalence for direct-summation structure factors. - -The custom Triton kernels (float32) are compared against the checkpointed -eager backend evaluated in float64 (downcast for comparison), for both the -isolated P1 kernels and the full ``SfDS`` symmetry loop. - -Markers: ``@pytest.mark.cuda`` (auto-skipped without CUDA) and -``@pytest.mark.integration``. Requires a CUDA device. -""" - -import pytest -import torch - -pytestmark = [pytest.mark.cuda, pytest.mark.integration] - -_RTOL = 1e-3 - - -def _rel(a, b): - num = (a - b.to(a.dtype)).abs().max() - den = b.abs().max().to(num.dtype) + 1e-6 - return (num / den).item() - - -@pytest.fixture -def cuda(): - # No availability check: the module-level ``cuda`` marker is the only gate - # (see conftest.pytest_collection_modifyitems). A second check here could - # only turn a forgotten marker into a silent pass. - return torch.device("cuda", 0) - - -def _asym_loss(F, wr, wi): - return (wr * F.real).sum() + (wi * F.imag).sum() - - -def test_iso_triton_vs_eager(cuda): - from torchref.base.direct_summation.triton_ds import ds_iso_triton - from torchref.base.direct_summation import dispatch as D - - torch.manual_seed(0) - R, N = 400, 23 - hkl = torch.randint(-6, 7, (R, 3), device=cuda).float() - s = torch.rand(R, device=cuda) * 0.6 - A = torch.rand(N, 5, device=cuda) - B = torch.rand(N, 5, device=cuda) + 0.5 - wr = torch.randn(R, device=cuda) - wi = torch.randn(R, device=cuda) # asymmetric -> exposes a wrong imag sign - - xyz0 = torch.rand(N, 3, device=cuda, dtype=torch.float64) - occ0 = torch.rand(N, device=cuda, dtype=torch.float64) * 0.4 + 0.6 - adp0 = torch.rand(N, device=cuda, dtype=torch.float64) * 10 + 5 - - xt, ot, at = (t.float().detach().requires_grad_() for t in (xyz0, occ0, adp0)) - Ft = ds_iso_triton(hkl, s, xt, ot, at, A, B) - _asym_loss(Ft, wr, wi).backward() - - xr, orf, arf = (t.detach().requires_grad_() for t in (xyz0, occ0, adp0)) - Fr = D._checkpointed_iso(hkl, s, xr, orf, arf, A.double(), B.double(), max_memory_gb=None) - _asym_loss(Fr, wr.double(), wi.double()).backward() - - assert _rel(Ft, Fr) < _RTOL - assert _rel(xt.grad, xr.grad) < _RTOL - assert _rel(ot.grad, orf.grad) < _RTOL - assert _rel(at.grad, arf.grad) < _RTOL - - -def test_aniso_triton_vs_eager(cuda): - from torchref.base.direct_summation.triton_ds import ds_aniso_triton - from torchref.base.direct_summation import dispatch as D - - torch.manual_seed(1) - R, N = 400, 23 - hkl = torch.randint(-6, 7, (R, 3), device=cuda).float() - svec = torch.randn(R, 3, device=cuda) * 0.3 - A = torch.rand(N, 5, device=cuda) - B = torch.rand(N, 5, device=cuda) + 0.5 - wr = torch.randn(R, device=cuda) - wi = torch.randn(R, device=cuda) - - xyz0 = torch.rand(N, 3, device=cuda, dtype=torch.float64) - occ0 = torch.rand(N, device=cuda, dtype=torch.float64) * 0.4 + 0.6 - U0 = torch.rand(N, 6, device=cuda, dtype=torch.float64) * 0.04 + 0.01 - - xt, ot, Ut = (t.float().detach().requires_grad_() for t in (xyz0, occ0, U0)) - Ft = ds_aniso_triton(hkl, svec, xt, ot, Ut, A, B) - _asym_loss(Ft, wr, wi).backward() - - xr, orf, Ur = (t.detach().requires_grad_() for t in (xyz0, occ0, U0)) - Fr = D._checkpointed_aniso(hkl, svec, xr, orf, Ur, A.double(), B.double(), max_memory_gb=None) - _asym_loss(Fr, wr.double(), wi.double()).backward() - - assert _rel(Ft, Fr) < _RTOL - assert _rel(xt.grad, xr.grad) < _RTOL - assert _rel(ot.grad, orf.grad) < _RTOL - assert _rel(Ut.grad, Ur.grad) < _RTOL - - -def test_sfds_engine_toggle_end_to_end(cuda): - """Full SfDS symmetry loop: Engine.TRITON vs Engine.EAGER agree.""" - from torchref.symmetry import Cell - from torchref.model.sf_ds import SfDS - from torchref.base.direct_summation import Engine - - torch.manual_seed(2) - cell = Cell([50.0, 60.0, 70.0, 90.0, 90.0, 90.0]) - N, R = 30, 200 - hkl = torch.randint(-6, 7, (R, 3), device=cuda).float() - A = torch.rand(N, 5, device=cuda) - B = torch.rand(N, 5, device=cuda) + 0.5 - xyz0 = torch.rand(N, 3, device=cuda) * 20 - occ0 = torch.rand(N, device=cuda) * 0.4 + 0.6 - adp0 = torch.rand(N, device=cuda) * 10 + 5 - wr = torch.randn(R, device=cuda) - wi = torch.randn(R, device=cuda) - - def run(engine): - sf = SfDS(cell, spacegroup="P212121", engine=engine, max_memory_gb=2.0) - xyz = xyz0.clone().requires_grad_() - occ = occ0.clone().requires_grad_() - adp = adp0.clone().requires_grad_() - F, _ = sf.compute_structure_factors(hkl, xyz, adp, occ, A, B) - _asym_loss(F, wr, wi).backward() - return F.detach(), xyz.grad, occ.grad, adp.grad - - Ft = run(Engine.TRITON) - Fe = run(Engine.EAGER) - for t, e in zip(Ft, Fe): - assert _rel(t, e) < _RTOL diff --git a/tests/integration/test_variable_radius_gpu.py b/tests/integration/test_variable_radius_gpu.py deleted file mode 100644 index 427beab..0000000 --- a/tests/integration/test_variable_radius_gpu.py +++ /dev/null @@ -1,176 +0,0 @@ -"""GPU variable-radius density path: Triton (wq_grid / wq_grid_aniso) vs the CPU -grouped splat, which share the identical per-atom radius policy. - -Both backends truncate each atom at its own ``N_sigma * sigma_eff`` radius, so the -forward maps must agree to float32 + analytic-vs-gathered-coord tolerance and the -gradients (xyz / adp / u / occ) must agree in direction *and* magnitude. - -The candidate side runs under ``Engine.TRITON``, which raises rather than -degrading to the portable splat -- that is what keeps this file able to fail. - -Cells, atom sets and metrics come from ``tests/helpers/kernel_cases.py``, shared -with the MPS/Metal equivalent. They used to be a private copy here, which is how -two coverage gaps survived in both files at once: unit occupancies (hiding the -``grad / occ`` rescaling) and zero off-diagonal ``u`` (hiding every ellipsoid -cross-term). Both are non-degenerate now. - -Markers: ``@pytest.mark.cuda`` -- the sole gate, see ``conftest.py``'s -``pytest_collection_modifyitems``; this file deliberately does no availability -checking of its own. Also ``integration``. -""" - -import pytest -import torch - -from tests.helpers.grad_asserts import assert_grads_agree -from tests.helpers.kernel_cases import ( - aniso_atoms, - cell_monoclinic, - cell_orthorhombic, - cos_sim, - iso_atoms, - rel_map, - to_device, -) -from torchref.base.electron_density.main import build_electron_density -from torchref.utils import Engine, use_engine - -pytestmark = [pytest.mark.cuda, pytest.mark.integration] - -# Left at the values this file has always used. Unlike the MPS tolerances, these -# have not been re-measured against the new non-degenerate atom sets -- that -# needs a CUDA host. Tighten only with measurements in hand. -_REL_TOL = 2e-2 -_COS_TOL = 0.9995 -_GRAD_KW = dict(min_cos=0.999, ratio_tol=1e-2) - - -def _empty_iso(device): - """Zero-length isotropic arguments, for an aniso-only structure.""" - return ( - torch.zeros(0, 3, device=device), - torch.zeros(0, device=device), - torch.zeros(0, device=device), - torch.zeros(0, 5, device=device), - torch.zeros(0, 5, device=device), - ) - - -def _run_iso(engine, device, cell_fn, atoms): - _, _, frac, inv_frac, voxel, rsg = cell_fn() - xyz, adp, occ, A, B = to_device(device, *atoms) - rsg, frac, inv_frac, voxel = to_device(device, rsg, frac, inv_frac, voxel) - with use_engine(engine): - return build_electron_density( - rsg, xyz, adp, occ, A, B, inv_frac, frac, voxel, dtype=torch.float32 - ) - - -def _run_aniso(engine, device, cell_fn, atoms): - _, _, frac, inv_frac, voxel, rsg = cell_fn() - xyz, u, occ, A, B = to_device(device, *atoms) - rsg, frac, inv_frac, voxel = to_device(device, rsg, frac, inv_frac, voxel) - xi, ai, oi, Ai, Bi = _empty_iso(device) - with use_engine(engine): - return build_electron_density( - rsg, xi, ai, oi, Ai, Bi, inv_frac, frac, voxel, - xyz_aniso=xyz, u_aniso=u, occ_aniso=occ, A_aniso=A, B_aniso=B, - dtype=torch.float32, - ) - - -# --------------------------------------------------------------------------- -# Forward -# --------------------------------------------------------------------------- - - -def test_iso_triton_matches_cpu_grouped(cuda_device): - cell_fn = cell_orthorhombic - atoms = iso_atoms(cell_fn()[0]) - cpu = _run_iso(Engine.AUTO, "cpu", cell_fn, atoms) - gpu = _run_iso(Engine.TRITON, cuda_device, cell_fn, atoms) - assert rel_map(gpu, cpu) < _REL_TOL - assert cos_sim(gpu, cpu) > _COS_TOL - - -def test_iso_triton_matches_eager_monoclinic(cuda_device): - """Guard the per-axis (inv-frac-norm) box + direct coords on a sheared cell: - TRITON (CUDA) vs the portable EAGER plain-scatter reference (independent box).""" - cell_fn = cell_monoclinic - atoms = iso_atoms(cell_fn()[0]) - ref = _run_iso(Engine.EAGER, "cpu", cell_fn, atoms) - gpu = _run_iso(Engine.TRITON, cuda_device, cell_fn, atoms) - assert rel_map(gpu, ref) < _REL_TOL - assert cos_sim(gpu, ref) > _COS_TOL - - -@pytest.mark.parametrize( - "cell_fn", [cell_orthorhombic, cell_monoclinic], ids=["orthorhombic", "monoclinic"] -) -def test_aniso_triton_matches_cpu_grouped(cuda_device, cell_fn): - """Both cells, so the ellipsoid cross-terms are exercised on a sheared cell - too -- previously only the isotropic path saw a non-orthogonal cell.""" - atoms = aniso_atoms(cell_fn()[0]) - cpu = _run_aniso(Engine.AUTO, "cpu", cell_fn, atoms) - gpu = _run_aniso(Engine.TRITON, cuda_device, cell_fn, atoms) - assert rel_map(gpu, cpu) < _REL_TOL - assert cos_sim(gpu, cpu) > _COS_TOL - - -# --------------------------------------------------------------------------- -# Backward -# --------------------------------------------------------------------------- - - -def _iso_grads(engine, device, cell_fn, atoms, weights): - xyz, adp, occ, A, B = to_device(device, *atoms) - xyz, adp, occ = (t.clone().requires_grad_() for t in (xyz, adp, occ)) - _, _, frac, inv_frac, voxel, rsg = cell_fn() - rsg, frac, inv_frac, voxel = to_device(device, rsg, frac, inv_frac, voxel) - with use_engine(engine): - dm = build_electron_density( - rsg, xyz, adp, occ, A, B, inv_frac, frac, voxel, dtype=torch.float32 - ) - (dm * weights.to(device)).sum().backward() - return {"xyz": xyz.grad, "adp": adp.grad, "occ": occ.grad} - - -def _aniso_grads(engine, device, cell_fn, atoms, weights): - xyz, u, occ, A, B = to_device(device, *atoms) - xyz, u, occ = (t.clone().requires_grad_() for t in (xyz, u, occ)) - _, _, frac, inv_frac, voxel, rsg = cell_fn() - rsg, frac, inv_frac, voxel = to_device(device, rsg, frac, inv_frac, voxel) - xi, ai, oi, Ai, Bi = _empty_iso(device) - with use_engine(engine): - dm = build_electron_density( - rsg, xi, ai, oi, Ai, Bi, inv_frac, frac, voxel, - xyz_aniso=xyz, u_aniso=u, occ_aniso=occ, A_aniso=A, B_aniso=B, - dtype=torch.float32, - ) - (dm * weights.to(device)).sum().backward() - return {"xyz": xyz.grad, "u": u.grad, "occ": occ.grad} - - -def test_iso_gradients_match_cpu_grouped(cuda_device): - cell_fn = cell_orthorhombic - atoms = iso_atoms(cell_fn()[0]) - w = torch.randn(cell_fn()[1], generator=torch.Generator().manual_seed(3)) - cpu = _iso_grads(Engine.AUTO, "cpu", cell_fn, atoms, w) - gpu = _iso_grads(Engine.TRITON, cuda_device, cell_fn, atoms, w) - assert_grads_agree(gpu, cpu, ctx="iso ", **_GRAD_KW) - - -def test_aniso_gradients_match_cpu_grouped(cuda_device): - """Covers the two paths the old zero-off-diagonal / unit-occupancy atoms - could not reach: the off-diagonal U gradients and the ``grad / occ`` - rescaling, which at ``occ == 1`` is a division by one.""" - cell_fn = cell_orthorhombic - atoms = aniso_atoms(cell_fn()[0]) - w = torch.randn(cell_fn()[1], generator=torch.Generator().manual_seed(4)) - cpu = _aniso_grads(Engine.AUTO, "cpu", cell_fn, atoms, w) - gpu = _aniso_grads(Engine.TRITON, cuda_device, cell_fn, atoms, w) - assert_grads_agree(gpu, cpu, ctx="aniso ", **_GRAD_KW) - - # The off-diagonal block must actually be exercised, else this test could - # pass on an all-zero comparison. - assert gpu["u"][:, 3:].abs().max() > 0, "off-diagonal U gradients are all zero" diff --git a/tests/integration/test_variable_radius_mps.py b/tests/integration/test_variable_radius_mps.py deleted file mode 100644 index 9ba1a21..0000000 --- a/tests/integration/test_variable_radius_mps.py +++ /dev/null @@ -1,251 +0,0 @@ -"""MPS variable-radius density path: native Metal kernels vs the portable -plain-scatter reference, on the same MPS device. - -Both truncate each atom at its own per-atom radius, so the forward maps must -agree to float32 + truncation-shape tolerance and the gradients (xyz / adp / u / -occ) must agree in direction *and* magnitude. - -The candidate side runs under ``Engine.METAL``, never ``Engine.AUTO``. That is -load-bearing, not stylistic: under AUTO a Metal kernel that fails to dispatch -falls back to the very same ``add_isotropic_plain_var`` the reference uses, so -every assertion here would pass with ``rel_map == 0.0`` and ``cos == 1.0`` while -measuring nothing at all. ``Engine.METAL`` raises instead of degrading, which is -what makes this file able to fail. - -``dtype=torch.float32`` is passed explicitly rather than inherited from the -ambient ``TORCHREF_DTYPE_FLOAT``: under a float64 config the Metal gate never -fires, which is a second, independent way for this file to go vacuous. - -Markers: ``@pytest.mark.mps`` (the sole gate -- see ``conftest.py``'s -``pytest_collection_modifyitems``; this file deliberately does no availability -checking of its own), ``integration``. -""" - -import pytest -import torch - -from tests.helpers.grad_asserts import assert_grads_agree -from tests.helpers.kernel_cases import ( - aniso_atoms, - cell_monoclinic, - cell_orthorhombic, - cos_sim, - iso_atoms, - rel_map, - to_device, -) -from torchref.base.electron_density.main import build_electron_density -from torchref.utils import Engine, use_engine - -pytestmark = [pytest.mark.mps, pytest.mark.integration] - -# Measured Metal-vs-portable agreement on an M-series GPU with the -# non-degenerate atom sets (non-unit occupancy, signed off-diagonal U): -# -# iso orthorhombic rel_map 5.9e-3 cos 0.9999838 -# iso monoclinic rel_map 5.2e-3 cos 0.9999727 -# aniso orthorhombic rel_map 7.7e-4 cos 0.9999990 -# aniso monoclinic rel_map 1.0e-3 cos 0.9999990 -# -# The iso path is an order of magnitude looser than the aniso one because its -# per-atom cutoff is quantized to a whole number of voxels, so a voxel shell can -# land exactly on the ``r2 <= r2cut`` boundary and be included by one -# implementation and not the other. Those are low-density tail voxels, which is -# why the cosine stays tight while the max-relative figure does not. Tolerances -# are ~3-5x the measured maxima; do not tighten without re-measuring. -_ISO_REL_TOL = 2e-2 -_ANISO_REL_TOL = 5e-3 -_COS_TOL = 0.9999 - -# Gradient thresholds: the values ``tests/unit/test_gradient_correctness.py`` -# uses for "float32 + distinct kernel arithmetic". The ratio check is what a bare -# cosine cannot do -- a kernel returning ``2 * grad`` is perfectly parallel. -_GRAD_KW = dict(min_cos=0.999, ratio_tol=1e-2) - - -def _empty_iso(device): - """Zero-length isotropic arguments, for an aniso-only structure.""" - return ( - torch.zeros(0, 3, device=device), - torch.zeros(0, device=device), - torch.zeros(0, device=device), - torch.zeros(0, 5, device=device), - torch.zeros(0, 5, device=device), - ) - - -def _run_iso(engine, mps_device, cell_fn, atoms): - _, _, frac, inv_frac, voxel, rsg = cell_fn() - xyz, adp, occ, A, B = to_device(mps_device, *atoms) - rsg, frac, inv_frac, voxel = to_device(mps_device, rsg, frac, inv_frac, voxel) - with use_engine(engine): - return build_electron_density( - rsg, xyz, adp, occ, A, B, inv_frac, frac, voxel, dtype=torch.float32 - ) - - -def _run_aniso(engine, mps_device, cell_fn, atoms): - _, _, frac, inv_frac, voxel, rsg = cell_fn() - xyz, u, occ, A, B = to_device(mps_device, *atoms) - rsg, frac, inv_frac, voxel = to_device(mps_device, rsg, frac, inv_frac, voxel) - xi, ai, oi, Ai, Bi = _empty_iso(mps_device) - with use_engine(engine): - return build_electron_density( - rsg, xi, ai, oi, Ai, Bi, inv_frac, frac, voxel, - xyz_aniso=xyz, u_aniso=u, occ_aniso=occ, A_aniso=A, B_aniso=B, - dtype=torch.float32, - ) - - -# --------------------------------------------------------------------------- -# Forward -# --------------------------------------------------------------------------- - - -@pytest.mark.parametrize( - "cell_fn", [cell_orthorhombic, cell_monoclinic], ids=["orthorhombic", "monoclinic"] -) -def test_iso_metal_matches_plain(mps_device, cell_fn): - atoms = iso_atoms(cell_fn()[0]) - ref = _run_iso(Engine.EAGER, mps_device, cell_fn, atoms) - got = _run_iso(Engine.METAL, mps_device, cell_fn, atoms) - assert rel_map(got, ref) < _ISO_REL_TOL - assert cos_sim(got, ref) > _COS_TOL - - -@pytest.mark.parametrize( - "cell_fn", [cell_orthorhombic, cell_monoclinic], ids=["orthorhombic", "monoclinic"] -) -def test_aniso_metal_matches_plain(mps_device, cell_fn): - """Both cells, so the ellipsoid cross-terms are exercised on a sheared cell - too -- previously only the isotropic path saw a non-orthogonal cell.""" - atoms = aniso_atoms(cell_fn()[0]) - ref = _run_aniso(Engine.EAGER, mps_device, cell_fn, atoms) - got = _run_aniso(Engine.METAL, mps_device, cell_fn, atoms) - assert rel_map(got, ref) < _ANISO_REL_TOL - assert cos_sim(got, ref) > _COS_TOL - - -# --------------------------------------------------------------------------- -# Backward -# --------------------------------------------------------------------------- - - -def _iso_grads(engine, mps_device, cell_fn, atoms, weights): - xyz, adp, occ, A, B = to_device(mps_device, *atoms) - xyz, adp, occ = (t.clone().requires_grad_() for t in (xyz, adp, occ)) - _, _, frac, inv_frac, voxel, rsg = cell_fn() - rsg, frac, inv_frac, voxel = to_device(mps_device, rsg, frac, inv_frac, voxel) - with use_engine(engine): - dm = build_electron_density( - rsg, xyz, adp, occ, A, B, inv_frac, frac, voxel, dtype=torch.float32 - ) - (dm * weights).sum().backward() - return {"xyz": xyz.grad, "adp": adp.grad, "occ": occ.grad} - - -def _aniso_grads(engine, mps_device, cell_fn, atoms, weights): - xyz, u, occ, A, B = to_device(mps_device, *atoms) - xyz, u, occ = (t.clone().requires_grad_() for t in (xyz, u, occ)) - _, _, frac, inv_frac, voxel, rsg = cell_fn() - rsg, frac, inv_frac, voxel = to_device(mps_device, rsg, frac, inv_frac, voxel) - xi, ai, oi, Ai, Bi = _empty_iso(mps_device) - with use_engine(engine): - dm = build_electron_density( - rsg, xi, ai, oi, Ai, Bi, inv_frac, frac, voxel, - xyz_aniso=xyz, u_aniso=u, occ_aniso=occ, A_aniso=A, B_aniso=B, - dtype=torch.float32, - ) - (dm * weights).sum().backward() - return {"xyz": xyz.grad, "u": u.grad, "occ": occ.grad} - - -def test_iso_gradients_match_plain(mps_device): - cell_fn = cell_orthorhombic - atoms = iso_atoms(cell_fn()[0]) - w = torch.randn(cell_fn()[1], generator=torch.Generator().manual_seed(3)).to( - mps_device - ) - ref = _iso_grads(Engine.EAGER, mps_device, cell_fn, atoms, w) - got = _iso_grads(Engine.METAL, mps_device, cell_fn, atoms, w) - assert_grads_agree(got, ref, ctx="iso ", **_GRAD_KW) - - -def test_aniso_gradients_match_plain(mps_device): - """Covers the two paths the old zero-off-diagonal / unit-occupancy atoms - could not reach: the off-diagonal U gradients (which carry a ``4*pi^2`` - factor where the diagonal ones carry ``2*pi^2``) and the ``grad / occ`` - rescaling, which at ``occ == 1`` is a division by one.""" - cell_fn = cell_orthorhombic - atoms = aniso_atoms(cell_fn()[0]) - w = torch.randn(cell_fn()[1], generator=torch.Generator().manual_seed(4)).to( - mps_device - ) - ref = _aniso_grads(Engine.EAGER, mps_device, cell_fn, atoms, w) - got = _aniso_grads(Engine.METAL, mps_device, cell_fn, atoms, w) - assert_grads_agree(got, ref, ctx="aniso ", **_GRAD_KW) - - # The off-diagonal block must actually be exercised, else this test could - # pass on an all-zero comparison. - assert got["u"][:, 3:].abs().max() > 0, "off-diagonal U gradients are all zero" - - -# --------------------------------------------------------------------------- -# Dispatch provenance and strict-mode behaviour -# --------------------------------------------------------------------------- - - -@pytest.mark.parametrize("engine", [Engine.AUTO, Engine.METAL]) -def test_engine_dispatches_to_metal_kernel(mps_device, monkeypatch, engine): - """On MPS float32, both AUTO and METAL must actually call the Metal kernel. - - Verified with a call recorder rather than by comparing maps. Comparing is not - sound here: the portable reference uses ``scatter_add`` on MPS, whose - accumulation order is not reproducible, so two portable runs differ at ~1e-7 - and ``torch.equal`` against one proves nothing either way. - - The patch target is the **package** attribute, not a ``main`` global: - ``_add_isotropic`` does a function-local ``from ...kernels.mps import - add_isotropic_mps_var`` on every call, so it re-reads the package each time. - Patching ``main.add_isotropic_mps_var`` would silently no-op and leave this - test as vacuous as the bug it guards. - """ - from torchref.base.electron_density import kernels - - real = kernels.mps.add_isotropic_mps_var - calls = [] - - def recording(*args, **kwargs): - calls.append(1) - return real(*args, **kwargs) - - monkeypatch.setattr(kernels.mps, "add_isotropic_mps_var", recording) - - cell_fn = cell_orthorhombic - _run_iso(engine, mps_device, cell_fn, iso_atoms(cell_fn()[0])) - assert calls, f"{engine} did not dispatch to the Metal kernel" - - -def test_metal_raises_when_shader_unavailable(mps_device, monkeypatch): - """``Engine.METAL`` must raise, never degrade, when the shader is missing. - - ``monkeypatch`` reverts the module globals at teardown, so this needs no - ``try``/``finally`` -- and must not have one, since a swallowed failure here - is exactly the behaviour under test. - """ - from torchref.base.electron_density.kernels.mps import compile as mps_compile - - monkeypatch.setattr(mps_compile, "_lib", None) - monkeypatch.setattr(mps_compile, "_lib_failed", True) - monkeypatch.setattr(mps_compile, "_lib_error", ("forced test failure", "")) - - cell_fn = cell_orthorhombic - atoms = iso_atoms(cell_fn()[0]) - with pytest.raises(RuntimeError, match="forced test failure"): - _run_iso(Engine.METAL, mps_device, cell_fn, atoms) - - # ...while AUTO still degrades gracefully to the portable splat. Compared - # loosely, not bitwise: see the note in the provenance test above. - auto = _run_iso(Engine.AUTO, mps_device, cell_fn, atoms) - eager = _run_iso(Engine.EAGER, mps_device, cell_fn, atoms) - assert rel_map(auto, eager) < 1e-5 diff --git a/tests/unit/base/test_aniso_map_building.py b/tests/unit/base/test_aniso_map_building.py deleted file mode 100644 index c343510..0000000 --- a/tests/unit/base/test_aniso_map_building.py +++ /dev/null @@ -1,93 +0,0 @@ -"""Unit tests for anisotropic electron-density map building (CPU). - -Certifies the eager ``vectorized_add_to_map_aniso`` reference (iso-reduction -identity + float64 gradcheck). The production variable-radius CPU/GPU splats are -covered separately by ``test_variable_radius_gpu`` and the CPU grouped-splat tests. -""" - -import math - -import pytest -import torch - -from torchref.base.electron_density.map_building import vectorized_add_to_map_aniso -from torchref.base.electron_density.voxel_utils import find_relevant_voxels -from torchref.base.kernels import vectorized_add_to_map -from torchref.model.sf_fft import SfFFT -from torchref.symmetry import Cell - -pytestmark = pytest.mark.unit - -_EIGHT_PI_SQ = 8.0 * math.pi**2 - - -def _grid(dtype=torch.float64): - cell = Cell([12.0, 13.0, 14.0, 90.0, 90.0, 90.0]) - fft = SfFFT(cell, spacegroup="P1", max_res=3.0, - dtype_float=dtype, device="cpu") - fft.setup_grid(max_res=3.0) - return ( - fft.real_space_grid.to(dtype), - fft.inv_fractional_matrix.to(dtype), - fft.fractional_matrix.to(dtype), - 3.0, # splat radius for the legacy fixed-radius reference kernels - ) - - -def _atoms(dtype=torch.float64): - # fractional positions chosen away from voxel-center half-boundaries - xyz = torch.tensor([[4.1, 4.3, 5.2], [7.7, 8.9, 9.1]], dtype=dtype) - A = torch.rand(2, 5, dtype=dtype) + 0.5 - B = torch.rand(2, 5, dtype=dtype) + 1.0 - occ = torch.rand(2, dtype=dtype) + 0.5 - b = torch.tensor([8.0, 14.0], dtype=dtype) # isotropic B-factors - return xyz, A, B, occ, b - - -def test_aniso_reduces_to_isotropic(): - """U = b/(8π²)·I (diagonal, isotropic) must reproduce the iso splat.""" - grid, inv_frac, frac, rad = _grid() - xyz, A, B, occ, b = _atoms() - - surr, idx = find_relevant_voxels(grid, xyz, radius_angstrom=rad, inv_frac_matrix=inv_frac) - iso_map = vectorized_add_to_map( - surr, idx, torch.zeros(grid.shape[:3], dtype=torch.float64), - xyz, b, inv_frac, frac, A, B, occ, - ) - - u_iso = torch.zeros(2, 6, dtype=torch.float64) - u_iso[:, 0] = u_iso[:, 1] = u_iso[:, 2] = b / _EIGHT_PI_SQ - aniso_map = vectorized_add_to_map_aniso( - surr, idx, torch.zeros(grid.shape[:3], dtype=torch.float64), - xyz, u_iso, inv_frac, frac, A, B, occ, - ) - assert torch.allclose(iso_map, aniso_map, atol=1e-8, rtol=1e-6) - - -def test_aniso_map_gradcheck(): - grid, inv_frac, frac, rad = _grid() - xyz0, A, B, occ0, _ = _atoms() - # genuinely anisotropic U (positive-definite) - u0 = torch.tensor( - [[0.12, 0.10, 0.14, 0.02, 0.01, 0.015], - [0.09, 0.13, 0.11, -0.01, 0.02, 0.005]], - dtype=torch.float64, - ) - w = torch.randn(grid.shape[:3], dtype=torch.float64) - - xyz = xyz0.clone().requires_grad_() - u = u0.clone().requires_grad_() - occ = occ0.clone().requires_grad_() - - def f(xyz, u, occ): - surr, idx = find_relevant_voxels( - grid, xyz, radius_angstrom=rad, inv_frac_matrix=inv_frac - ) - dm = vectorized_add_to_map_aniso( - surr, idx, torch.zeros(grid.shape[:3], dtype=torch.float64), - xyz, u, inv_frac, frac, A, B, occ, - ) - return (dm * w).sum() - - assert torch.autograd.gradcheck(f, (xyz, u, occ), eps=1e-6, atol=1e-4) - diff --git a/tests/unit/base/test_canonical_sphere_cpu.py b/tests/unit/base/test_canonical_sphere_cpu.py index 22a3f0c..240a75d 100644 --- a/tests/unit/base/test_canonical_sphere_cpu.py +++ b/tests/unit/base/test_canonical_sphere_cpu.py @@ -11,14 +11,18 @@ Why this file exists -------------------- -Until now every variable-radius cross-check was gated on hardware -- -``test_variable_radius_gpu.py`` needs CUDA, ``test_variable_radius_mps.py`` needs -Apple silicon -- so on a CPU-only machine *nothing* tested the splat geometry. That -is how three different cutoffs came to coexist: the fast CPU path splatted a cube, -the portable CPU path used a node-centred diagonal metric at a voxel-rounded -radius, and Metal inflated its radius to a whole voxel. On a beta=115 deg cell those -differed by ~5e-3 rel L2 -- more than the 1.7e-3 truncation error the cutoff exists -to deliver -- and the MPS test absorbed it in a 2e-2 tolerance. +Every variable-radius cross-check used to be gated on hardware, so on a CPU-only +machine *nothing* tested the splat geometry. That is how three different cutoffs came to +coexist: the fast CPU path splatted a cube, the portable CPU path used a node-centred +diagonal metric at a voxel-rounded radius, and Metal inflated its radius to a whole +voxel. On a beta=115 deg cell those differed by ~5e-3 rel L2 -- more than the 1.7e-3 +truncation error the cutoff exists to deliver -- and the accelerator tests absorbed it in +a 2e-2 kernel-vs-kernel tolerance. + +Accuracy against an independent reference now lives in ``tests/unit/structure_factor/``, +which checks every production kernel on every device against direct summation. This file +remains the *geometry* contract: a from-spec brute force, cheap, CPU-only, and +independent of both kernels. The reference here is a direct O(atoms x voxels) evaluation of the contract as written above (``_brute_iso`` / ``_brute_aniso``), not another kernel: comparing two @@ -326,7 +330,7 @@ def test_auto_actually_dispatches_the_fused_kernel(dtype): If ``should_use_sphere_splat`` ever stopped firing, AUTO would fall through to the very same portable splat EAGER uses and every equivalence assertion above would pass at ``rel_l2 == 0`` while measuring nothing -- the exact failure mode - ``test_variable_radius_mps.py`` documents for its own Metal gate. float64 is + the deleted MPS accelerator test documented for its own Metal gate. float64 is parametrized because the old dispatch was float32-only, so a regression to a dtype gate would be invisible on float32 alone. @@ -375,3 +379,162 @@ def test_density_map_accumulates_not_overwrites(): both = _build(Engine.AUTO, dims, frac, inv_frac, voxel, torch.float32, iso=iso, aniso=aniso) assert _rel_l2(both, a + b) < 1e-6 + + +# ========================================================================= +# Ported from now-deleted tests of orphaned kernels +# ========================================================================= +# ``tests/unit/base/test_aniso_map_building.py`` and ``test_cpu_scatter.py`` covered the +# legacy fixed-radius splat and the C++ structured scatter. Both are now unreachable from +# ``build_electron_density``: the dispatch routes CPU to the fused sphere splat or the +# portable one, so ``add_isotropic_cpu_separable_var`` / ``add_anisotropic_cpu_var`` -- and +# with them ``_separable_density``, ``_aniso_density_cube`` and the structured scatter -- +# are imported but never called. The source is deliberately retained; the tests were +# deleted, and the two checks worth keeping are re-pointed at the live path here. + + +@pytest.mark.parametrize("beta", _BETAS) +@pytest.mark.parametrize("engine", [Engine.AUTO, Engine.EAGER], ids=["auto", "eager"]) +def test_aniso_reduces_to_isotropic(beta, engine): + """``U = b/(8*pi^2) I`` must reproduce the isotropic splat, on the live dispatch. + + An analytic identity rather than a comparison against another implementation: a + spherical anisotropic tensor *is* an isotropic B factor, so the two code paths must + agree exactly whatever the cell. It constrains the ``8*pi^2`` conversion and the + diagonal handling of the Mahalanobis form in one assertion, and it fails for a whole + class of index and factor errors that cross-backend parity cannot see because both + sides would share them. + + Ported from ``test_aniso_map_building.py::test_aniso_reduces_to_isotropic``, which + asserted the same identity against ``vectorized_add_to_map_aniso`` -- a kernel no + longer on any dispatch path. + """ + frac, inv_frac, dims, f64 = _cell(beta, dtype=torch.float64) + xyz, adp, occ, A, B = _iso_atoms(f64, n=24, dtype=torch.float64) + voxel = _voxel_size(f64, dims) + grid = torch.zeros(*dims, 3, dtype=torch.float64) + + u_sph = torch.zeros(xyz.shape[0], 6, dtype=torch.float64) + u_sph[:, :3] = (adp / (8.0 * math.pi**2)).unsqueeze(1) + + with use_engine(engine): + iso_map = build_electron_density( + grid, xyz, adp, occ, A, B, inv_frac, frac, voxel, dtype=torch.float64 + ) + aniso_map = build_electron_density( + grid, + xyz[:0], adp[:0], occ[:0], A[:0], B[:0], + inv_frac, frac, voxel, + xyz_aniso=xyz, u_aniso=u_sph, occ_aniso=occ, A_aniso=A, B_aniso=B, + dtype=torch.float64, + ) + + rel = _rel_l2(aniso_map, iso_map) + assert rel < 1e-12, ( + f"beta={beta}, {engine.value}: a spherical U does not reproduce the iso splat " + f"(rel L2 {rel:.3e}). Suspect the 8*pi^2 conversion or the diagonal of the " + f"Mahalanobis form." + ) + assert float(iso_map.abs().sum()) > 0, "both maps are empty; the identity is vacuous" + + +@pytest.mark.parametrize("n_threads", [1, 2, 4]) +def test_fused_kernel_is_thread_invariant(n_threads): + """The fused splat must give the same map at any thread count. + + It partitions the *output* across threads via ``at::parallel_for`` rather than using + atomics, so a race or a partition-boundary error would show up as a thread-count + dependence. Deliberately non-orthogonal and dense enough that many atoms' spheres + overlap, since a partitioning bug is invisible when no two atoms touch the same voxel. + + **Weak on macOS.** ``_cpp_build.py`` omits ``-fopenmp`` there, and the extension + measurably does not parallelize on this host -- 3000 atoms on a 120x108x96 grid take + 132/131/131 ms at 1/2/4 threads. So this passes locally largely because there is only + one thread to disagree with. It still earns its place: it is real coverage wherever the + extension is built with OpenMP (Linux, CI), and it costs milliseconds. + + Bit-exactness is the right assertion and is not merely aspirational -- verified to hold + on this scene and on a 6x denser one at 2, 4 and 8 threads. Output partitioning means + each voxel is accumulated by one thread over a fixed atom order, so a nonzero + difference is an ordering change worth investigating, not float noise. (When checking + this by hand, compare the maps, not ``dm.sum()``: ``Tensor.sum`` uses a + thread-count-dependent tree reduction and will show a spurious difference of its own.) + + Ported from the ``n_threads``-parametrized thread-safety test in + ``test_cpu_scatter.py``, which exercised the C++ structured scatter -- no longer + reachable from the dispatch. + """ + if not sphere_splat.sphere_splat_available(): + pytest.skip(f"fused CPU sphere splat unavailable: {sphere_splat.last_error()}") + + frac, inv_frac, dims, f64 = _cell(115.0, dtype=torch.float32) + xyz, adp, occ, A, B = _iso_atoms(f64, n=96, dtype=torch.float32, seed=7) + voxel = _voxel_size(f64, dims) + grid = torch.zeros(*dims, 3, dtype=torch.float32) + + original = torch.get_num_threads() + try: + torch.set_num_threads(1) + with use_engine(Engine.AUTO): + ref = build_electron_density( + grid, xyz, adp, occ, A, B, inv_frac, frac, voxel, dtype=torch.float32 + ) + torch.set_num_threads(n_threads) + with use_engine(Engine.AUTO): + got = build_electron_density( + grid, xyz, adp, occ, A, B, inv_frac, frac, voxel, dtype=torch.float32 + ) + finally: + torch.set_num_threads(original) + + # Bit-exact: output partitioning means each voxel is summed by exactly one thread in + # the same order regardless of thread count. Any drift is a real ordering change. + assert torch.equal(got, ref), ( + f"{n_threads} threads changed the map (max abs diff " + f"{float((got - ref).abs().max()):.3e}); the fused splat should partition the " + "output, so results must not depend on thread count" + ) + + +def test_fused_extension_compiles(): + """The fused sphere splat must actually build. Fails rather than skipping. + + Every other test in this file -- and in ``tests/unit/structure_factor`` -- calls + ``pytest.skip`` when ``sphere_splat_available()`` is False, which is right for them: + they are testing numerics, and without the extension there is nothing to test. But if + *every* test skips, a build that has stopped working produces an all-green run while + the CPU production path has silently degraded to the portable eager splat. The engine + dispatch is designed to degrade quietly under ``Engine.AUTO`` (see + ``main.py::_add_isotropic``), which is correct for users and dangerous for CI. + + So exactly one test asserts the extension builds, and reports the captured diagnostic + plus environment when it does not -- the same stance, and most of the same diagnostic + surface, as the ``TestCompilation`` class in the now-deleted ``test_cpu_scatter.py``. + That guard previously protected the C++ structured scatter, a helper; it now protects + the production CPU splat, so it matters more than it did. + """ + if sphere_splat.sphere_splat_available(): + return + + import os + import shutil + import sys + + err = sphere_splat.last_error() + env_info = ( + f" python: {sys.executable}\n" + f" ninja: {shutil.which('ninja')}\n" + f" CXX env: {os.environ.get('CXX', '')}\n" + f" CC env: {os.environ.get('CC', '')}\n" + f" PATH head: {os.environ.get('PATH', '').split(':')[:5]}\n" + f" TORCH_EXTENSIONS_DIR: " + f"{os.environ.get('TORCH_EXTENSIONS_DIR', '')}\n" + ) + pytest.fail( + "The fused CPU sphere-splat extension failed to build, so Engine.AUTO on CPU is " + "silently falling back to the portable eager splat for every density " + "calculation.\n" + f"Error: {err}\n\n" + f"Environment:\n{env_info}" + ) + diff --git a/tests/unit/base/test_cpu_scatter.py b/tests/unit/base/test_cpu_scatter.py deleted file mode 100644 index 3341f15..0000000 --- a/tests/unit/base/test_cpu_scatter.py +++ /dev/null @@ -1,577 +0,0 @@ -"""Tests for C++ parallel scatter_add (cpu_scatter.py). - -Verifies correctness and gradient accuracy of structured_scatter_add -against PyTorch's native scatter_add_ for all valid use cases. -""" - -import math - -import pytest -import torch - - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - -def _pytorch_scatter_reference(density_cube, wa, wbwc, map_size): - """Reference implementation using PyTorch scatter_add_.""" - C, nx, ny, nz = density_cube.shape - idx_flat = wa[:, :, None, None] + wbwc[:, None, :, :] # (C, nx, ny, nz) - result = torch.zeros(map_size, dtype=density_cube.dtype) - result.scatter_add_(0, idx_flat.reshape(-1), density_cube.reshape(-1)) - return result - - -def _make_valid_indices(C, nx, ny, nz, grid_shape, seed=42): - """Generate valid structured indices that stay within [0, map_size). - - Mimics how production code computes wa and wbwc from center_idx - with modular arithmetic for periodic boundary conditions. - """ - torch.manual_seed(seed) - gx, gy, gz = grid_shape - ny_nz = gy * gz - - # Random center voxels within the grid - center_x = torch.randint(0, gx, (C,)) - center_y = torch.randint(0, gy, (C,)) - center_z = torch.randint(0, gz, (C,)) - - # Axis offsets (symmetric around center, like real code) - half = nx // 2 - offsets = torch.arange(-half, -half + nx) - - # wa: (C, nx) — x-axis indices with PBC wrapping - wa = ((center_x[:, None] + offsets[None, :]) % gx) * ny_nz - - # wbwc: (C, ny, nz) — yz-plane indices with PBC wrapping - half_y = ny // 2 - half_z = nz // 2 - offsets_y = torch.arange(-half_y, -half_y + ny) - offsets_z = torch.arange(-half_z, -half_z + nz) - wb = ((center_y[:, None] + offsets_y[None, :]) % gy) * gz # (C, ny) - wc = ((center_z[:, None] + offsets_z[None, :]) % gz) # (C, nz) - wbwc = wb[:, :, None] + wc[:, None, :] # (C, ny, nz) - - return wa.long(), wbwc.long() - - -# --------------------------------------------------------------------------- -# Fixtures -# --------------------------------------------------------------------------- - -@pytest.fixture(scope="module") -def cpp_scatter(): - """Import structured_scatter_add (compiles C++ on first call).""" - try: - from torchref.base.electron_density.kernels.cpu.scatter import structured_scatter_add - return structured_scatter_add - except Exception as e: - pytest.skip(f"C++ scatter not available: {e}") - - -# --------------------------------------------------------------------------- -# Compile-availability test -# --------------------------------------------------------------------------- - -class TestCompilation: - """Verify the C++ extension can be compiled in the current environment. - - This test does NOT skip on failure — failure here is the actual signal - we want from CI / SLURM jobs. The full diagnostic captured by - ``_get_module()`` is included in the assertion message. - """ - - def test_module_compiles(self): - """C++ cpu_scatter extension must build successfully.""" - from torchref.base.electron_density.kernels.cpu import scatter as cpu_scatter - - # Reset any cached failure from a previous test in the same process so - # we get a fresh attempt with up-to-date diagnostics. - cpu_scatter._module_failed = False - cpu_scatter._module_error = None - - mod = cpu_scatter._get_module() - - if mod is None: - err_summary, err_tb = cpu_scatter._module_error or ("unknown", "") - # Surface environment context that commonly differs on SLURM nodes. - import os - import shutil - import sys - env_info = ( - f" python: {sys.executable}\n" - f" ninja: {shutil.which('ninja')}\n" - f" CXX env: {os.environ.get('CXX', '')}\n" - f" CC env: {os.environ.get('CC', '')}\n" - f" PATH head: {os.environ.get('PATH', '').split(':')[:5]}\n" - f" HOME: {os.environ.get('HOME', '')}\n" - f" TORCH_EXTENSIONS_DIR: " - f"{os.environ.get('TORCH_EXTENSIONS_DIR', '')}\n" - ) - pytest.fail( - "C++ cpu_scatter compilation failed.\n" - f"Error: {err_summary}\n\n" - f"Environment:\n{env_info}\n" - f"Full traceback:\n{err_tb}" - ) - - # Sanity-check the built module exposes both entry points - # (one binding per index dtype: int32 fast path + int64 fallback). - for name in ( - "structured_scatter_add_i32", - "structured_scatter_add_i64", - "structured_gather_i32", - "structured_gather_i64", - ): - assert hasattr(mod, name), f"compiled module is missing {name}" - - -# --------------------------------------------------------------------------- -# Correctness tests -# --------------------------------------------------------------------------- - -class TestCorrectnessVsPyTorch: - """Verify C++ scatter matches PyTorch scatter_add_ for all valid cases.""" - - def test_basic_small(self, cpp_scatter): - """Small problem: 4 atoms, 5x5x5 cube, 20^3 grid.""" - C, nx, ny, nz = 4, 5, 5, 5 - grid_shape = (20, 20, 20) - map_size = 20 * 20 * 20 - - torch.manual_seed(0) - density_cube = torch.randn(C, nx, ny, nz) - wa, wbwc = _make_valid_indices(C, nx, ny, nz, grid_shape, seed=0) - - ref = _pytorch_scatter_reference(density_cube, wa, wbwc, map_size) - got = cpp_scatter(density_cube, wa, wbwc, map_size) - - assert got.shape == (map_size,) - assert torch.allclose(ref, got, atol=1e-6), \ - f"max err = {(ref - got).abs().max().item():.2e}" - - def test_single_atom(self, cpp_scatter): - """Edge case: single atom (C=1).""" - C, nx, ny, nz = 1, 7, 7, 7 - grid_shape = (30, 30, 30) - map_size = 30 * 30 * 30 - - torch.manual_seed(1) - density_cube = torch.randn(C, nx, ny, nz) - wa, wbwc = _make_valid_indices(C, nx, ny, nz, grid_shape, seed=1) - - ref = _pytorch_scatter_reference(density_cube, wa, wbwc, map_size) - got = cpp_scatter(density_cube, wa, wbwc, map_size) - - assert torch.allclose(ref, got, atol=1e-6) - - def test_large_chunk(self, cpp_scatter): - """Typical production chunk: 1024 atoms, 17^3 cube.""" - C, nx, ny, nz = 1024, 17, 17, 17 - grid_shape = (216, 90, 72) - map_size = 216 * 90 * 72 - - torch.manual_seed(2) - density_cube = torch.randn(C, nx, ny, nz) - wa, wbwc = _make_valid_indices(C, nx, ny, nz, grid_shape, seed=2) - - ref = _pytorch_scatter_reference(density_cube, wa, wbwc, map_size) - got = cpp_scatter(density_cube, wa, wbwc, map_size) - - assert torch.allclose(ref, got, atol=1e-5), \ - f"max err = {(ref - got).abs().max().item():.2e}" - - def test_asymmetric_cube(self, cpp_scatter): - """Non-cubic ROI: nx != ny != nz.""" - C, nx, ny, nz = 50, 9, 13, 7 - grid_shape = (64, 64, 64) - map_size = 64 ** 3 - - torch.manual_seed(3) - density_cube = torch.randn(C, nx, ny, nz) - wa, wbwc = _make_valid_indices(C, nx, ny, nz, grid_shape, seed=3) - - ref = _pytorch_scatter_reference(density_cube, wa, wbwc, map_size) - got = cpp_scatter(density_cube, wa, wbwc, map_size) - - assert torch.allclose(ref, got, atol=1e-6) - - def test_asymmetric_grid(self, cpp_scatter): - """Non-cubic grid (common in crystallography: a >> b,c).""" - C, nx, ny, nz = 100, 11, 11, 11 - grid_shape = (300, 60, 48) - map_size = 300 * 60 * 48 - - torch.manual_seed(4) - density_cube = torch.randn(C, nx, ny, nz) - wa, wbwc = _make_valid_indices(C, nx, ny, nz, grid_shape, seed=4) - - ref = _pytorch_scatter_reference(density_cube, wa, wbwc, map_size) - got = cpp_scatter(density_cube, wa, wbwc, map_size) - - assert torch.allclose(ref, got, atol=1e-5) - - def test_boundary_wrapping(self, cpp_scatter): - """Atoms near grid boundaries where indices wrap around via PBC.""" - C, nx, ny, nz = 20, 17, 17, 17 - grid_shape = (32, 32, 32) - map_size = 32 ** 3 - - torch.manual_seed(5) - density_cube = torch.randn(C, nx, ny, nz) - - # Force atoms near corners of the grid - gx, gy, gz = grid_shape - ny_nz = gy * gz - half = nx // 2 - offsets = torch.arange(-half, -half + nx) - offsets_y = torch.arange(-half, -half + ny) - offsets_z = torch.arange(-half, -half + nz) - - # Place atoms at grid boundaries (0, last, near-last) - corners = torch.tensor([0, 1, gx - 1, gx - 2, gx // 2]) - center_x = corners.repeat(C // len(corners) + 1)[:C] - center_y = corners.repeat(C // len(corners) + 1)[:C] - center_z = corners.repeat(C // len(corners) + 1)[:C] - - wa = ((center_x[:, None] + offsets[None, :]) % gx) * ny_nz - wb = ((center_y[:, None] + offsets_y[None, :]) % gy) * gz - wc = ((center_z[:, None] + offsets_z[None, :]) % gz) - wbwc = wb[:, :, None] + wc[:, None, :] - - ref = _pytorch_scatter_reference(density_cube, wa, wbwc, map_size) - got = cpp_scatter(density_cube, wa, wbwc, map_size) - - assert torch.allclose(ref, got, atol=1e-6), \ - f"Boundary wrapping failed: max err = {(ref - got).abs().max().item():.2e}" - - def test_overlapping_rois(self, cpp_scatter): - """Multiple atoms with heavily overlapping scatter regions.""" - C, nx, ny, nz = 50, 17, 17, 17 - grid_shape = (40, 40, 40) - map_size = 40 ** 3 - - torch.manual_seed(6) - density_cube = torch.randn(C, nx, ny, nz) - - # All atoms at the same center → maximal overlap - gx, gy, gz = grid_shape - ny_nz = gy * gz - half = nx // 2 - offsets = torch.arange(-half, -half + nx) - offsets_y = torch.arange(-half, -half + ny) - offsets_z = torch.arange(-half, -half + nz) - - center_x = torch.full((C,), gx // 2, dtype=torch.long) - center_y = torch.full((C,), gy // 2, dtype=torch.long) - center_z = torch.full((C,), gz // 2, dtype=torch.long) - - wa = ((center_x[:, None] + offsets[None, :]) % gx) * ny_nz - wb = ((center_y[:, None] + offsets_y[None, :]) % gy) * gz - wc = ((center_z[:, None] + offsets_z[None, :]) % gz) - wbwc = wb[:, :, None] + wc[:, None, :] - - ref = _pytorch_scatter_reference(density_cube, wa, wbwc, map_size) - got = cpp_scatter(density_cube, wa, wbwc, map_size) - - assert torch.allclose(ref, got, atol=1e-5), \ - f"Overlapping ROIs failed: max err = {(ref - got).abs().max().item():.2e}" - - def test_all_positive_values(self, cpp_scatter): - """All density values positive (physical electron density).""" - C, nx, ny, nz = 100, 11, 11, 11 - grid_shape = (64, 64, 64) - map_size = 64 ** 3 - - torch.manual_seed(7) - density_cube = torch.rand(C, nx, ny, nz) * 10.0 # strictly positive - wa, wbwc = _make_valid_indices(C, nx, ny, nz, grid_shape, seed=7) - - ref = _pytorch_scatter_reference(density_cube, wa, wbwc, map_size) - got = cpp_scatter(density_cube, wa, wbwc, map_size) - - assert torch.allclose(ref, got, atol=1e-5) - # Result should also be non-negative - assert got.min() >= -1e-6 - - def test_zero_density(self, cpp_scatter): - """All-zero density cube (should produce zero map).""" - C, nx, ny, nz = 10, 5, 5, 5 - grid_shape = (20, 20, 20) - map_size = 20 ** 3 - - density_cube = torch.zeros(C, nx, ny, nz) - wa, wbwc = _make_valid_indices(C, nx, ny, nz, grid_shape, seed=8) - - got = cpp_scatter(density_cube, wa, wbwc, map_size) - assert torch.all(got == 0.0) - - def test_sparse_density(self, cpp_scatter): - """Mostly-zero density with a few nonzero entries.""" - C, nx, ny, nz = 32, 9, 9, 9 - grid_shape = (50, 50, 50) - map_size = 50 ** 3 - - torch.manual_seed(9) - density_cube = torch.zeros(C, nx, ny, nz) - # Set only ~1% of entries to nonzero - mask = torch.rand_like(density_cube) < 0.01 - density_cube[mask] = torch.randn(mask.sum()) - - wa, wbwc = _make_valid_indices(C, nx, ny, nz, grid_shape, seed=9) - - ref = _pytorch_scatter_reference(density_cube, wa, wbwc, map_size) - got = cpp_scatter(density_cube, wa, wbwc, map_size) - - assert torch.allclose(ref, got, atol=1e-6) - - def test_large_values(self, cpp_scatter): - """Large magnitude values (stress test float32 accumulation).""" - C, nx, ny, nz = 200, 11, 11, 11 - grid_shape = (80, 80, 80) - map_size = 80 ** 3 - - torch.manual_seed(10) - density_cube = torch.randn(C, nx, ny, nz) * 1e4 - wa, wbwc = _make_valid_indices(C, nx, ny, nz, grid_shape, seed=10) - - ref = _pytorch_scatter_reference(density_cube, wa, wbwc, map_size) - got = cpp_scatter(density_cube, wa, wbwc, map_size) - - # Looser tolerance for large values - assert torch.allclose(ref, got, rtol=1e-5, atol=1e-2), \ - f"Large values: max err = {(ref - got).abs().max().item():.2e}" - - def test_sorted_atoms(self, cpp_scatter): - """Atoms sorted by 1D center (production configuration).""" - C, nx, ny, nz = 512, 17, 17, 17 - grid_shape = (216, 90, 72) - map_size = 216 * 90 * 72 - - torch.manual_seed(11) - density_cube = torch.randn(C, nx, ny, nz) - wa, wbwc = _make_valid_indices(C, nx, ny, nz, grid_shape, seed=11) - - # Sort by first wa entry (approximates sorting by 1D center) - order = torch.argsort(wa[:, wa.shape[1] // 2]) - density_cube = density_cube[order] - wa = wa[order] - wbwc = wbwc[order] - - ref = _pytorch_scatter_reference(density_cube, wa, wbwc, map_size) - got = cpp_scatter(density_cube, wa, wbwc, map_size) - - assert torch.allclose(ref, got, atol=1e-5) - - def test_remainder_chunk(self, cpp_scatter): - """Non-power-of-2 atom count (tests remainder handling).""" - C, nx, ny, nz = 37, 9, 9, 9 - grid_shape = (50, 50, 50) - map_size = 50 ** 3 - - torch.manual_seed(12) - density_cube = torch.randn(C, nx, ny, nz) - wa, wbwc = _make_valid_indices(C, nx, ny, nz, grid_shape, seed=12) - - ref = _pytorch_scatter_reference(density_cube, wa, wbwc, map_size) - got = cpp_scatter(density_cube, wa, wbwc, map_size) - - assert torch.allclose(ref, got, atol=1e-6) - - -# --------------------------------------------------------------------------- -# Gradient tests -# --------------------------------------------------------------------------- - -class TestGradients: - """Verify gradient correctness of structured_scatter_add.""" - - def test_gradcheck_small(self, cpp_scatter): - """Numerical gradient check via finite differences (float32). - - Uses a small problem and generous tolerances appropriate for float32. - """ - C, nx, ny, nz = 3, 5, 5, 5 - grid_shape = (20, 20, 20) - map_size = 20 ** 3 - - torch.manual_seed(20) - wa, wbwc = _make_valid_indices(C, nx, ny, nz, grid_shape, seed=20) - - # Compute analytical gradient - dc = torch.randn(C, nx, ny, nz, requires_grad=True) - grad_output = torch.randn(map_size) - result = cpp_scatter(dc, wa, wbwc, map_size) - result.backward(grad_output) - analytical = dc.grad.clone() - - # Compute numerical gradient via finite differences - eps = 1e-4 - numerical = torch.zeros_like(dc) - dc_flat = dc.data.view(-1) - for i in range(dc_flat.numel()): - dc_flat[i] += eps - f_plus = cpp_scatter(dc.data.view(C, nx, ny, nz), wa, wbwc, map_size) - dc_flat[i] -= 2 * eps - f_minus = cpp_scatter(dc.data.view(C, nx, ny, nz), wa, wbwc, map_size) - dc_flat[i] += eps # restore - numerical.view(-1)[i] = ((f_plus - f_minus) * grad_output).sum() / (2 * eps) - - assert torch.allclose(analytical, numerical, atol=1e-3, rtol=1e-3), \ - f"Gradcheck failed: max err = {(analytical - numerical).abs().max().item():.2e}" - - def test_gradient_vs_pytorch(self, cpp_scatter): - """Compare gradients from C++ scatter vs PyTorch scatter.""" - C, nx, ny, nz = 64, 11, 11, 11 - grid_shape = (64, 64, 64) - map_size = 64 ** 3 - - torch.manual_seed(21) - wa, wbwc = _make_valid_indices(C, nx, ny, nz, grid_shape, seed=21) - - # Random upstream gradient - grad_output = torch.randn(map_size) - - # PyTorch path - dc_pt = torch.randn(C, nx, ny, nz, requires_grad=True) - ref = _pytorch_scatter_reference(dc_pt, wa, wbwc, map_size) - ref.backward(grad_output) - grad_pt = dc_pt.grad.clone() - - # C++ path - dc_cpp = dc_pt.data.clone().requires_grad_(True) - got = cpp_scatter(dc_cpp, wa, wbwc, map_size) - got.backward(grad_output) - grad_cpp = dc_cpp.grad.clone() - - assert torch.allclose(grad_pt, grad_cpp, atol=1e-6), \ - f"Gradient mismatch: max err = {(grad_pt - grad_cpp).abs().max().item():.2e}" - - def test_gradient_accumulation(self, cpp_scatter): - """Gradients accumulate correctly across multiple scatter calls (like chunk loop).""" - C, nx, ny, nz = 32, 9, 9, 9 - grid_shape = (40, 40, 40) - map_size = 40 ** 3 - - torch.manual_seed(22) - wa, wbwc = _make_valid_indices(C, nx, ny, nz, grid_shape, seed=22) - - # Two chunks summed (simulates the chunk loop) - dc1 = torch.randn(C, nx, ny, nz, requires_grad=True) - dc2 = torch.randn(C, nx, ny, nz, requires_grad=True) - - result = cpp_scatter(dc1, wa, wbwc, map_size) + \ - cpp_scatter(dc2, wa, wbwc, map_size) - loss = result.sum() - loss.backward() - - # Reference: expected gradient of sum() through gather is all-ones gathered - # grad_dc[c, ix, iy, iz] = 1.0 for all entries (since loss = sum of all) - expected_grad = torch.ones(C, nx, ny, nz) - - assert torch.allclose(dc1.grad, expected_grad, atol=1e-6), \ - f"dc1 grad err: {(dc1.grad - expected_grad).abs().max().item():.2e}" - assert torch.allclose(dc2.grad, expected_grad, atol=1e-6), \ - f"dc2 grad err: {(dc2.grad - expected_grad).abs().max().item():.2e}" - - def test_gradient_with_downstream_loss(self, cpp_scatter): - """Gradient through a realistic loss: MSE between scattered result and target.""" - C, nx, ny, nz = 16, 7, 7, 7 - grid_shape = (32, 32, 32) - map_size = 32 ** 3 - - torch.manual_seed(23) - wa, wbwc = _make_valid_indices(C, nx, ny, nz, grid_shape, seed=23) - target = torch.randn(map_size) - - # C++ path - dc_cpp = torch.randn(C, nx, ny, nz, requires_grad=True) - result_cpp = cpp_scatter(dc_cpp, wa, wbwc, map_size) - loss_cpp = ((result_cpp - target) ** 2).mean() - loss_cpp.backward() - grad_cpp = dc_cpp.grad.clone() - - # PyTorch reference - dc_pt = dc_cpp.data.clone().requires_grad_(True) - idx_flat = wa[:, :, None, None] + wbwc[:, None, :, :] - result_pt = torch.zeros(map_size) - result_pt.scatter_add_(0, idx_flat.reshape(-1), dc_pt.reshape(-1)) - loss_pt = ((result_pt - target) ** 2).mean() - loss_pt.backward() - grad_pt = dc_pt.grad.clone() - - assert abs(loss_cpp.item() - loss_pt.item()) < 1e-5, \ - f"Loss mismatch: {loss_cpp.item():.6f} vs {loss_pt.item():.6f}" - assert torch.allclose(grad_cpp, grad_pt, atol=1e-5), \ - f"Gradient mismatch: max err = {(grad_cpp - grad_pt).abs().max().item():.2e}" - - def test_gradient_overlapping_indices(self, cpp_scatter): - """Gradient correct when multiple atoms scatter to the same voxel.""" - C, nx, ny, nz = 20, 11, 11, 11 - grid_shape = (30, 30, 30) - map_size = 30 ** 3 - - torch.manual_seed(24) - - # All atoms at the same center → all scatter to same voxels - gx, gy, gz = grid_shape - ny_nz = gy * gz - half = nx // 2 - offsets = torch.arange(-half, -half + nx) - offsets_y = torch.arange(-half, -half + ny) - offsets_z = torch.arange(-half, -half + nz) - - center = torch.full((C,), gx // 2, dtype=torch.long) - wa = ((center[:, None] + offsets[None, :]) % gx) * ny_nz - wb = ((center[:, None] + offsets_y[None, :]) % gy) * gz - wc = ((center[:, None] + offsets_z[None, :]) % gz) - wbwc = wb[:, :, None] + wc[:, None, :] - - grad_output = torch.randn(map_size) - - # C++ path - dc_cpp = torch.randn(C, nx, ny, nz, requires_grad=True) - result_cpp = cpp_scatter(dc_cpp, wa, wbwc, map_size) - result_cpp.backward(grad_output) - grad_cpp = dc_cpp.grad.clone() - - # PyTorch reference - dc_pt = dc_cpp.data.clone().requires_grad_(True) - ref = _pytorch_scatter_reference(dc_pt, wa, wbwc, map_size) - ref.backward(grad_output) - grad_pt = dc_pt.grad.clone() - - assert torch.allclose(grad_cpp, grad_pt, atol=1e-6), \ - f"Overlapping gradient err: {(grad_cpp - grad_pt).abs().max().item():.2e}" - - -# --------------------------------------------------------------------------- -# Thread safety -# --------------------------------------------------------------------------- - -class TestThreadSafety: - """Verify results are independent of thread count.""" - - @pytest.mark.parametrize("n_threads", [1, 2, 4, 8]) - def test_thread_count_invariance(self, cpp_scatter, n_threads): - """Result must be identical regardless of OMP thread count.""" - C, nx, ny, nz = 200, 13, 13, 13 - grid_shape = (100, 80, 60) - map_size = 100 * 80 * 60 - - torch.manual_seed(30) - density_cube = torch.randn(C, nx, ny, nz) - wa, wbwc = _make_valid_indices(C, nx, ny, nz, grid_shape, seed=30) - - ref = _pytorch_scatter_reference(density_cube, wa, wbwc, map_size) - - prev_threads = torch.get_num_threads() - try: - torch.set_num_threads(n_threads) - got = cpp_scatter(density_cube, wa, wbwc, map_size) - assert torch.allclose(ref, got, atol=1e-5), \ - f"{n_threads} threads: max err = {(ref - got).abs().max().item():.2e}" - finally: - torch.set_num_threads(prev_threads) diff --git a/tests/unit/base/test_ds_dispatch.py b/tests/unit/base/test_ds_dispatch.py deleted file mode 100644 index fe7de9d..0000000 --- a/tests/unit/base/test_ds_dispatch.py +++ /dev/null @@ -1,84 +0,0 @@ -"""Unit tests for the direct-summation dispatch / checkpointed backend. - -CPU-only (no GPU required). Verifies that the recompute-on-backward -checkpointed backend matches the eager-autograd reference to machine -precision, that reflection-chunking is exact, that gradients pass -``gradcheck``, and that the engine guards behave. -""" - -import pytest -import torch - -from torchref.base.direct_summation import Engine, ds_aniso, ds_iso -from torchref.base.direct_summation import dispatch as D -from torchref.base.direct_summation.anisotropic import aniso_structure_factor_torched -from torchref.base.direct_summation.isotropic import iso_structure_factor_torched -from torchref.config import dtypes - -pytestmark = pytest.mark.unit - -# The eager reference functions internally cast hkl to ``dtypes.float`` so the -# eager-parity comparisons run in the configured float dtype (float32 by -# default). gradcheck, which never calls the eager fn, runs in float64. -_F = dtypes.float -_eager_atol = 1e-4 if _F == torch.float32 else 1e-10 - - -def _p1(c): - return c.unsqueeze(2) - - -def _inputs(N=4, R=7, seed=0, dtype=None): - dtype = dtype or _F - torch.manual_seed(seed) - hkl = torch.randint(-3, 4, (R, 3)).to(dtype) - s = torch.rand(R, dtype=dtype) * 0.5 - svec = torch.randn(R, 3, dtype=dtype) * 0.3 - A = torch.rand(N, 5, dtype=dtype) - B = torch.rand(N, 5, dtype=dtype) + 0.5 - return hkl, s, svec, A, B - - -def _leaves(N=4, seed=1, dtype=None): - dtype = dtype or _F - torch.manual_seed(seed) - xyz = torch.rand(N, 3, dtype=dtype, requires_grad=True) - occ = (torch.rand(N, dtype=dtype) * 0.4 + 0.6).requires_grad_() - adp = (torch.rand(N, dtype=dtype) * 10 + 5).requires_grad_() - U = (torch.rand(N, 6, dtype=dtype) * 0.04 + 0.01).requires_grad_() - return xyz, occ, adp, U - - - - -def test_checkpointed_chunking_is_exact(): - # Chunking slices the reflection dimension, so each reflection's atom sum is - # computed within a single chunk -- the result is mathematically identical - # regardless of chunk size. It is not, however, guaranteed bit-exact: the - # phase matmul dispatches to different BLAS kernels for (R,3)x(3,N) vs - # (1,3)x(3,N), and last-ULP rounding there is CPU/BLAS-dependent. Assert - # numerical equivalence, not bitwise equality. - hkl, s, _, A, B = _inputs(R=11, dtype=torch.float64) - xyz, occ, adp, _ = _leaves(dtype=torch.float64) - F_full = D._checkpointed_iso(hkl, s, xyz, occ, adp, A, B, max_memory_gb=None) - F_chunk = D._checkpointed_iso(hkl, s, xyz, occ, adp, A, B, max_memory_gb=1e-7) - assert torch.allclose(F_full, F_chunk, rtol=1e-10, atol=1e-12) - - - - -def test_empty_atoms_returns_zeros(): - hkl, s, _, _, _ = _inputs() - empty = torch.zeros(0, 3, dtype=torch.float64) - z = torch.zeros(0, dtype=torch.float64) - z5 = torch.zeros(0, 5, dtype=torch.float64) - F = ds_iso(hkl, s, empty, z, z, z5, z5, engine=Engine.AUTO) - assert F.shape == (hkl.shape[0],) - assert F.abs().sum().item() == 0.0 - - -def test_explicit_triton_engine_rejects_cpu(): - hkl, s, _, A, B = _inputs() - xyz, occ, adp, _ = _leaves() - with pytest.raises(RuntimeError): - ds_iso(hkl, s, xyz, occ, adp, A, B, engine=Engine.TRITON) diff --git a/tests/unit/base/test_vectorized_add_to_map.py b/tests/unit/base/test_vectorized_add_to_map.py deleted file mode 100644 index 5624451..0000000 --- a/tests/unit/base/test_vectorized_add_to_map.py +++ /dev/null @@ -1,446 +0,0 @@ -"""Tests for vectorized_add_to_map with automatic compilation.""" -import pytest -import torch -import numpy as np -import time - -from torchref.base.kernels import ( - vectorized_add_to_map, - compute_metric_tensor, - precompute_fractional_coords, - warmup, -) -from torchref.base.math_torch import ( - vectorized_add_to_map as original_add_to_map, -) - - -class TestValidityAgainstOriginal: - """Compare compiled version against original math_torch implementation.""" - - @pytest.fixture - def setup_tensors(self): - """Create test tensors matching both function signatures.""" - def _setup(n_atoms=100, n_voxels=500, grid_shape=(64, 64, 64), - device="cpu", dtype=torch.float64, seed=42): - torch.manual_seed(seed) - - # Cell parameters (orthorhombic for simplicity) - cell = torch.tensor( - [50.0, 60.0, 70.0, 90.0, 90.0, 90.0], dtype=dtype, device=device - ) - frac_matrix = torch.diag(cell[:3]) - inv_frac_matrix = torch.diag(1.0 / cell[:3]) - - # Atom parameters - xyz = torch.rand(n_atoms, 3, device=device, dtype=dtype) * 30 + 10 - b = torch.rand(n_atoms, device=device, dtype=dtype) * 40 + 10 - A = torch.rand(n_atoms, 5, device=device, dtype=dtype) * 5 + 1 - B = torch.rand(n_atoms, 5, device=device, dtype=dtype) * 10 + 2 - occ = torch.rand(n_atoms, device=device, dtype=dtype) * 0.5 + 0.5 - - # Voxel coordinates (Cartesian, around atoms) - surrounding_coords = ( - xyz[:, None, :] - + torch.randn(n_atoms, n_voxels, 3, device=device, dtype=dtype) * 2 - ) - voxel_indices = torch.randint( - 0, min(grid_shape), (n_atoms, n_voxels, 3), device=device - ) - - return { - "surrounding_coords": surrounding_coords, - "voxel_indices": voxel_indices, - "grid_shape": grid_shape, - "xyz": xyz, - "b": b, - "frac_matrix": frac_matrix, - "inv_frac_matrix": inv_frac_matrix, - "A": A, - "B": B, - "occ": occ, - } - return _setup - - def test_output_correlation_with_original(self, setup_tensors): - """Compiled output correlates highly with original. - - Note: The compiled version uses softplus for smooth gradients while - the original uses hard clamp. This gives slightly different B_total - values and thus different outputs, but they should be highly correlated. - """ - data = setup_tensors(n_atoms=50, n_voxels=200) - - # Original - map_orig = torch.zeros(data["grid_shape"], dtype=data["xyz"].dtype) - map_orig = original_add_to_map( - data["surrounding_coords"], data["voxel_indices"], map_orig, - data["xyz"], data["b"], data["inv_frac_matrix"], data["frac_matrix"], - data["A"], data["B"], data["occ"] - ) - - # Auto-compiled version (same API now) - map_compiled = torch.zeros(data["grid_shape"], dtype=data["xyz"].dtype) - map_compiled = vectorized_add_to_map( - data["surrounding_coords"], data["voxel_indices"], map_compiled, - data["xyz"], data["b"], data["inv_frac_matrix"], data["frac_matrix"], - data["A"], data["B"], data["occ"] - ) - - # Check correlation instead of exact match (softplus vs clamp causes differences) - orig_flat = map_orig.flatten() - compiled_flat = map_compiled.flatten() - - # Remove zeros for correlation - mask = (orig_flat != 0) | (compiled_flat != 0) - if mask.sum() > 0: - correlation = torch.corrcoef( - torch.stack([orig_flat[mask], compiled_flat[mask]]) - )[0, 1] - assert correlation > 0.999, f"Correlation too low: {correlation}" - - # Check max relative difference is reasonable (within 1%) - max_rel_diff = ( - (map_orig - map_compiled).abs().max() / (map_orig.abs().max() + 1e-10) - ) - assert max_rel_diff < 0.01, f"Max relative difference too high: {max_rel_diff}" - - @pytest.mark.parametrize("n_atoms,n_voxels", [ - (10, 100), - (100, 500), - (500, 1000) - ]) - def test_dynamic_shapes(self, setup_tensors, n_atoms, n_voxels): - """Different sizes should work without errors.""" - data = setup_tensors(n_atoms=n_atoms, n_voxels=n_voxels) - - map_out = torch.zeros(data["grid_shape"], dtype=data["xyz"].dtype) - result = vectorized_add_to_map( - data["surrounding_coords"], data["voxel_indices"], map_out, - data["xyz"], data["b"], data["inv_frac_matrix"], data["frac_matrix"], - data["A"], data["B"], data["occ"] - ) - - assert result.shape == data["grid_shape"] - assert torch.isfinite(result).all() - - def test_output_in_place(self, setup_tensors): - """Verify that the function modifies the map in-place (like the original).""" - data = setup_tensors(n_atoms=20, n_voxels=100) - - map_in = torch.zeros(data["grid_shape"], dtype=data["xyz"].dtype) - - result = vectorized_add_to_map( - data["surrounding_coords"], data["voxel_indices"], map_in, - data["xyz"], data["b"], data["inv_frac_matrix"], data["frac_matrix"], - data["A"], data["B"], data["occ"] - ) - - # Function modifies in-place and returns same tensor (like original scatter_add_nd) - assert result is map_in - # Map should now contain density values - assert result.abs().sum() > 0 - - -class TestGradientValidity: - """Test gradient flow through compiled function.""" - - @pytest.fixture - def grad_tensors(self): - """Create tensors with requires_grad=True.""" - def _setup(n_atoms=50, n_voxels=200, grid_shape=(32, 32, 32), seed=42): - torch.manual_seed(seed) - dtype = torch.float64 - device = "cpu" - - frac_matrix = torch.diag( - torch.tensor([50.0, 60.0, 70.0], dtype=dtype, device=device) - ) - inv_frac_matrix = torch.diag( - 1.0 / torch.tensor([50.0, 60.0, 70.0], dtype=dtype, device=device) - ) - - xyz = torch.rand(n_atoms, 3, dtype=dtype, device=device).requires_grad_(True) - b = (torch.rand(n_atoms, dtype=dtype, device=device) * 40 + 10).requires_grad_(True) - A = torch.rand(n_atoms, 5, dtype=dtype, device=device).requires_grad_(True) - B = (torch.rand(n_atoms, 5, dtype=dtype, device=device) * 10 + 2).requires_grad_(True) - occ = torch.rand(n_atoms, dtype=dtype, device=device).requires_grad_(True) - - surrounding_coords = ( - xyz.detach()[:, None, :] - + torch.randn(n_atoms, n_voxels, 3, dtype=dtype, device=device) * 2 - ) - voxel_indices = torch.randint( - 0, min(grid_shape), (n_atoms, n_voxels, 3), device=device - ) - - return { - "surrounding_coords": surrounding_coords, - "voxel_indices": voxel_indices, - "grid_shape": grid_shape, - "xyz": xyz, - "b": b, - "frac_matrix": frac_matrix, - "inv_frac_matrix": inv_frac_matrix, - "A": A, - "B": B, - "occ": occ, - } - return _setup - - def test_gradients_exist(self, grad_tensors): - """All gradient-requiring params should have gradients after backward.""" - data = grad_tensors() - - density_map = torch.zeros(data["grid_shape"], dtype=data["xyz"].dtype) - result = vectorized_add_to_map( - data["surrounding_coords"], data["voxel_indices"], density_map, - data["xyz"], data["b"], data["inv_frac_matrix"], data["frac_matrix"], - data["A"], data["B"], data["occ"] - ) - - loss = result.sum() - loss.backward() - - # Check all gradients exist and are finite - for name in ["xyz", "b", "A", "B", "occ"]: - grad = data[name].grad - assert grad is not None, f"Gradient for {name} is None" - assert torch.isfinite(grad).all(), ( - f"Gradient for {name} has non-finite values" - ) - assert grad.abs().sum() > 0, f"Gradient for {name} is all zeros" - - def test_gradients_match_original(self, grad_tensors): - """Gradients from compiled version match original implementation.""" - data = grad_tensors(n_atoms=30, n_voxels=100) - - # Clone tensors for original - xyz_orig = data["xyz"].detach().clone().requires_grad_(True) - b_orig = data["b"].detach().clone().requires_grad_(True) - A_orig = data["A"].detach().clone().requires_grad_(True) - B_orig = data["B"].detach().clone().requires_grad_(True) - occ_orig = data["occ"].detach().clone().requires_grad_(True) - - # Create same surrounding coords for both - torch.manual_seed(123) - surrounding_coords = ( - xyz_orig.detach()[:, None, :] - + torch.randn(30, 100, 3, dtype=data["xyz"].dtype) * 2 - ) - - # Original backward - map_orig = torch.zeros(data["grid_shape"], dtype=xyz_orig.dtype) - map_orig = original_add_to_map( - surrounding_coords, data["voxel_indices"], map_orig, - xyz_orig, b_orig, data["inv_frac_matrix"], data["frac_matrix"], - A_orig, B_orig, occ_orig - ) - loss_orig = map_orig.sum() - loss_orig.backward() - - # Compiled backward - map_compiled = torch.zeros(data["grid_shape"], dtype=data["xyz"].dtype) - map_compiled = vectorized_add_to_map( - surrounding_coords, data["voxel_indices"], map_compiled, - data["xyz"], data["b"], data["inv_frac_matrix"], data["frac_matrix"], - data["A"], data["B"], data["occ"] - ) - loss_compiled = map_compiled.sum() - loss_compiled.backward() - - # Compare gradients - looser tolerance due to softplus vs clamp differences - assert torch.allclose( - data["A"].grad, A_orig.grad, rtol=5e-2, atol=1e-3 - ), "A gradients don't match" - assert torch.allclose( - data["B"].grad, B_orig.grad, rtol=5e-2, atol=1e-3 - ), "B gradients don't match" - assert torch.allclose( - data["occ"].grad, occ_orig.grad, rtol=5e-2, atol=1e-3 - ), "occ gradients don't match" - - def test_gradients_finite_differences(self, grad_tensors): - """Verify gradients match finite differences for a simple case.""" - data = grad_tensors(n_atoms=5, n_voxels=20, grid_shape=(8, 8, 8)) - - def func(A): - density_map = torch.zeros(data["grid_shape"], dtype=A.dtype) - result = vectorized_add_to_map( - data["surrounding_coords"], data["voxel_indices"], density_map, - data["xyz"].detach(), data["b"].detach(), - data["inv_frac_matrix"], data["frac_matrix"], - A, data["B"].detach(), data["occ"].detach() - ) - return result.sum() - - # Check gradients for A using finite differences - A_test = data["A"].detach().clone().requires_grad_(True) - eps = 1e-5 - - # Compute analytical gradient - loss = func(A_test) - loss.backward() - analytical_grad = A_test.grad.clone() - - # Compute numerical gradient for a few elements - for i in range(min(3, A_test.shape[0])): - for j in range(min(2, A_test.shape[1])): - A_plus = A_test.detach().clone() - A_minus = A_test.detach().clone() - A_plus[i, j] += eps - A_minus[i, j] -= eps - - numerical_grad = (func(A_plus) - func(A_minus)) / (2 * eps) - relative_error = abs( - numerical_grad - analytical_grad[i, j] - ) / (abs(numerical_grad) + 1e-8) - - assert relative_error < 0.01, ( - f"Gradient mismatch at [{i},{j}]: " - f"numerical={numerical_grad:.6f}, " - f"analytical={analytical_grad[i,j]:.6f}" - ) - - -class TestPerformance: - """Performance benchmarks.""" - - @pytest.fixture - def setup_tensors(self): - """Create test tensors for benchmarking.""" - def _setup(n_atoms=100, n_voxels=500, grid_shape=(64, 64, 64), - device="cpu", dtype=torch.float64, seed=42): - torch.manual_seed(seed) - - cell = torch.tensor( - [50.0, 60.0, 70.0, 90.0, 90.0, 90.0], dtype=dtype, device=device - ) - frac_matrix = torch.diag(cell[:3]) - inv_frac_matrix = torch.diag(1.0 / cell[:3]) - - xyz = torch.rand(n_atoms, 3, device=device, dtype=dtype) * 30 + 10 - b = torch.rand(n_atoms, device=device, dtype=dtype) * 40 + 10 - A = torch.rand(n_atoms, 5, device=device, dtype=dtype) * 5 + 1 - B = torch.rand(n_atoms, 5, device=device, dtype=dtype) * 10 + 2 - occ = torch.rand(n_atoms, device=device, dtype=dtype) * 0.5 + 0.5 - - surrounding_coords = ( - xyz[:, None, :] - + torch.randn(n_atoms, n_voxels, 3, device=device, dtype=dtype) * 2 - ) - voxel_indices = torch.randint( - 0, min(grid_shape), (n_atoms, n_voxels, 3), device=device - ) - - return { - "surrounding_coords": surrounding_coords, - "voxel_indices": voxel_indices, - "grid_shape": grid_shape, - "xyz": xyz, - "b": b, - "frac_matrix": frac_matrix, - "inv_frac_matrix": inv_frac_matrix, - "A": A, - "B": B, - "occ": occ, - } - return _setup - - @pytest.mark.slow - def test_performance_improvement(self, setup_tensors): - """Auto-compiled version should be faster after warmup.""" - data = setup_tensors( - n_atoms=1000, n_voxels=2000, grid_shape=(128, 128, 128) - ) - - n_warmup = 3 - n_trials = 10 - - # Warmup both - for _ in range(n_warmup): - map_orig = torch.zeros(data["grid_shape"], dtype=data["xyz"].dtype) - original_add_to_map( - data["surrounding_coords"], data["voxel_indices"], map_orig, - data["xyz"], data["b"], data["inv_frac_matrix"], - data["frac_matrix"], data["A"], data["B"], data["occ"] - ) - - map_compiled = torch.zeros(data["grid_shape"], dtype=data["xyz"].dtype) - vectorized_add_to_map( - data["surrounding_coords"], data["voxel_indices"], map_compiled, - data["xyz"], data["b"], data["inv_frac_matrix"], data["frac_matrix"], - data["A"], data["B"], data["occ"] - ) - - # Benchmark original - times_orig = [] - for _ in range(n_trials): - map_orig = torch.zeros(data["grid_shape"], dtype=data["xyz"].dtype) - t0 = time.perf_counter() - original_add_to_map( - data["surrounding_coords"], data["voxel_indices"], map_orig, - data["xyz"], data["b"], data["inv_frac_matrix"], - data["frac_matrix"], data["A"], data["B"], data["occ"] - ) - times_orig.append(time.perf_counter() - t0) - - # Benchmark auto-compiled - times_compiled = [] - for _ in range(n_trials): - map_compiled = torch.zeros(data["grid_shape"], dtype=data["xyz"].dtype) - t0 = time.perf_counter() - vectorized_add_to_map( - data["surrounding_coords"], data["voxel_indices"], map_compiled, - data["xyz"], data["b"], data["inv_frac_matrix"], data["frac_matrix"], - data["A"], data["B"], data["occ"] - ) - times_compiled.append(time.perf_counter() - t0) - - mean_orig = np.mean(times_orig) - mean_compiled = np.mean(times_compiled) - speedup = mean_orig / mean_compiled - - print(f"\nOriginal: {mean_orig*1000:.2f} ms") - print(f"Auto-compiled: {mean_compiled*1000:.2f} ms") - print(f"Speedup: {speedup:.2f}x") - - # Assert some speedup (at least not significantly slower) - assert speedup > 0.8, f"Compiled version too slow: {speedup:.2f}x" - - -class TestWarmup: - """Test warmup function.""" - - def test_warmup_no_error(self): - """warmup should not raise errors.""" - warmup(device="cpu") - - def test_warmup_enables_fast_execution(self): - """After warmup, function should work correctly.""" - # Warmup - warmup(device="cpu") - - # Create test data and verify function works - n_atoms, n_voxels = 10, 50 - grid_shape = (16, 16, 16) - dtype = torch.float32 - - surrounding_coords = torch.randn(n_atoms, n_voxels, 3, dtype=dtype) - voxel_indices = torch.randint(0, 16, (n_atoms, n_voxels, 3)) - density_map = torch.zeros(grid_shape, dtype=dtype) - xyz = torch.randn(n_atoms, 3, dtype=dtype) - b = torch.rand(n_atoms, dtype=dtype) * 50 + 10 - inv_frac_matrix = torch.eye(3, dtype=dtype) * 0.02 - frac_matrix = torch.eye(3, dtype=dtype) * 50 - A = torch.rand(n_atoms, 5, dtype=dtype) - B = torch.rand(n_atoms, 5, dtype=dtype) * 10 + 1 - occ = torch.ones(n_atoms, dtype=dtype) - - result = vectorized_add_to_map( - surrounding_coords, voxel_indices, density_map, xyz, b, - inv_frac_matrix, frac_matrix, A, B, occ - ) - - assert result.shape == grid_shape - assert torch.isfinite(result).all() diff --git a/tests/unit/sf_oracle/__init__.py b/tests/unit/structure_factor/__init__.py similarity index 76% rename from tests/unit/sf_oracle/__init__.py rename to tests/unit/structure_factor/__init__.py index 7f598b3..3f982d9 100644 --- a/tests/unit/sf_oracle/__init__.py +++ b/tests/unit/structure_factor/__init__.py @@ -211,6 +211,69 @@ RTOL_BACKEND_GRAD_F32 = 5e-3 RTOL_BACKEND_GRAD_F64 = 1e-10 +# --------------------------------------------------------------------------- +# Direct-summation kernels vs the eager oracle. +# --------------------------------------------------------------------------- +# Distinct from the map-route gates above: these compare one analytic implementation +# against another, with no grid and no truncation in between, so the only source of +# disagreement is float arithmetic. They are gated near precision accordingly. +# +# Measured on the 60-atom scene, candidate vs the CPU float64 ``_eager_*`` oracle: +# +# device dtype kernel F rel L2 worst grad rel L2 grad cos +# cpu float32 checkpointed 1.71e-06 2.39e-05 1.0000000 +# cpu float64 checkpointed 2.44e-16 2.69e-15 1.0000000 +# mps float32 checkpointed 1.72e-06 2.41e-05 1.0000000 +# +# The MPS row is a path that had no coverage of any kind before: there is no Metal +# direct-summation kernel, so ``Engine.METAL`` and any MPS device both land on +# ``_checkpointed_*`` running on-device. It agrees with the CPU float32 row to the digit. +# +# ``ds_triton`` is **unmeasured** -- no CUDA on the calibration host. It shares these gates, +# which is a prediction. The deleted ``test_ds_triton_vs_eager.py`` used a max-abs-diff +# gate of 1e-3 on a different metric; if a Triton leg misses 1e-4 here, measure before +# widening. +RTOL_DS_F32 = 1e-4 # worst measured 2.4e-05 +RTOL_DS_F64 = 1e-13 # worst measured 2.7e-15 +COS_MIN_DS = 0.9999 + +# --------------------------------------------------------------------------- +# Accelerators need no constants of their own. Measured, not assumed. +# --------------------------------------------------------------------------- +# The device legs were expected to need looser gates than the CPU ones, because an +# accelerator adds its own kernel arithmetic on top of the shared discretization error. +# They do not. Measured on the 60-atom synthetic scene against the CPU float64 DS oracle, +# iso then aniso, worst case per column: +# +# device dtype kernel amplitude g_xyz g_occ g_U/adp HVP +# cpu float32 cpu_sphere 4.37e-03 6.97e-02 3.61e-02 8.02e-02 1.62e-02 +# cpu float64 cpu_sphere 4.37e-03 6.98e-02 3.63e-02 8.09e-02 1.62e-02 +# cpu float64 portable 4.37e-03 6.98e-02 3.63e-02 8.09e-02 1.62e-02 +# mps float32 portable 4.37e-03 6.98e-02 3.63e-02 8.09e-02 1.62e-02 +# mps float32 mps_metal 4.37e-03 6.98e-02 3.63e-02 8.09e-02 raises +# +# Every row agrees to the printed digits. That is the truncation-contract standardization +# showing up as a measurement: with all four kernels applying the same atom-centred +# Cartesian sphere at the same raw policy radius, what is left over is *shared* +# discretization error, not per-kernel divergence. So the CPU constants above bound the +# accelerators, and adding ``RTOL_*_MPS`` / ``RTOL_*_CUDA`` would have created numbers with +# nothing behind them. +# +# ``mps_metal`` raising on the HVP is the documented contract, not a gap -- see +# ``mps/variable_radius.py:12``. ``test_second_order.py`` asserts both halves: which +# kernels give a correct second derivative, and which raise rather than returning a wrong +# one. +# +# **CUDA is unmeasured.** This host has no CUDA, so the Triton legs are written and marked +# but have never run. They share the parametrization and the tolerances with MPS, which is +# defensible given every other backend landed on the same numbers -- but it is a prediction +# until someone runs: +# +# pytest tests/unit/structure_factor -v -s -k cuda +# +# on a CUDA host. If a Triton leg misses a gate there, establish whether it is kernel +# arithmetic or a real geometry difference before touching a constant. + __all__ = [ "RTOL_VS_GEMMI", "MAXREL_VS_GEMMI", @@ -232,4 +295,7 @@ "RTOL_BACKEND_F64", "RTOL_BACKEND_GRAD_F32", "RTOL_BACKEND_GRAD_F64", + "RTOL_DS_F32", + "RTOL_DS_F64", + "COS_MIN_DS", ] diff --git a/tests/unit/sf_oracle/conftest.py b/tests/unit/structure_factor/conftest.py similarity index 71% rename from tests/unit/sf_oracle/conftest.py rename to tests/unit/structure_factor/conftest.py index 3e1f80b..bcbf9be 100644 --- a/tests/unit/sf_oracle/conftest.py +++ b/tests/unit/structure_factor/conftest.py @@ -15,9 +15,89 @@ import torchref from torchref.config import device as device_cfg, dtypes +from tests.conftest import _accelerator + from . import helpers as H +# --------------------------------------------------------------------------- +# Device axis +# --------------------------------------------------------------------------- +# Built at **import time**, copying ``tests/conftest.py:295``. That is load-bearing: the +# backend mark has to be attached during *collection*, because +# ``pytest_collection_modifyitems`` is what gates on it and cannot see a mark added later +# from inside a fixture. On a CPU-only host the accelerator param does not exist at all, +# so there is no skip noise -- and on this host the ``cuda`` leg likewise never appears. +# +# The accelerator carries its *specific* backend mark (``cuda`` or ``mps``), not the +# generic ``gpu`` one, so a CUDA-less host skips the cuda leg with an accurate reason. +_DEVICES = [pytest.param(torch.device("cpu"), id="cpu")] +_ACCELERATOR = _accelerator() +if _ACCELERATOR is not None: + _DEVICES.append( + pytest.param( + _ACCELERATOR, + id=_ACCELERATOR.type, + marks=getattr(pytest.mark, _ACCELERATOR.type), + ) + ) + +#: float32 first: the production dtype. MPS cannot hold float64 at all, which +#: ``helpers.device_supports_dtype`` filters -- so ``(mps, float64)`` yields no kernels +#: and therefore no test, rather than a test that skips or silently passes. +_DTYPES = [torch.float32, torch.float64] + + +def device_dtype_kernels(): + """Every ``(device, dtype, kernel_name)`` that names a real production path. + + Enumerated from the kernel registry rather than written out, so a kernel added to + ``helpers._KERNEL_SPECS`` is covered automatically and an unsupported combination + produces no test instead of a vacuous one. + """ + out = [] + for dev_param in _DEVICES: + device = dev_param.values[0] + for dtype in _DTYPES: + for name in H.kernels_for(device, dtype): + out.append( + pytest.param( + device, + dtype, + name, + id=f"{device.type}-{str(dtype).replace('torch.float', 'f')}-{name}", + marks=dev_param.marks, + ) + ) + return out + + +def ds_device_dtype_kernels(): + """Every ``(device, dtype, ds_kernel_name)`` naming a real direct-summation path. + + Separate from the splat list because the two families have different device/dtype + envelopes -- notably there is no Metal DS kernel, so the MPS leg here is + ``_checkpointed_*`` running on-device rather than a native shader. + """ + out = [] + for dev_param in _DEVICES: + device = dev_param.values[0] + for dtype in _DTYPES: + for name in H.ds_kernels_for(device, dtype): + out.append( + pytest.param( + device, dtype, name, + id=f"{device.type}-{str(dtype).replace('torch.float', 'f')}-{name}", + marks=dev_param.marks, + ) + ) + return out + + +DEVICE_DTYPE_KERNELS = device_dtype_kernels() +DS_DEVICE_DTYPE_KERNELS = ds_device_dtype_kernels() + + # --------------------------------------------------------------------------- # Global config # --------------------------------------------------------------------------- diff --git a/tests/unit/sf_oracle/helpers.py b/tests/unit/structure_factor/helpers.py similarity index 63% rename from tests/unit/sf_oracle/helpers.py rename to tests/unit/structure_factor/helpers.py index 33448eb..24ce062 100644 --- a/tests/unit/sf_oracle/helpers.py +++ b/tests/unit/structure_factor/helpers.py @@ -18,7 +18,7 @@ from __future__ import annotations import itertools -from dataclasses import dataclass +from dataclasses import dataclass, fields from typing import Optional, Sequence import torch @@ -38,6 +38,14 @@ "ds_aniso_oracle", "sf_fft_for", "fft_sf", + "splat_kernel", + "device_supports_dtype", + "kernels_for", + "splat_direct", + "ds_kernel", + "ds_kernels_for", + "ds_direct", + "density_to_F", "synthetic_obs", "ls_target", "best_fit_scale", @@ -127,6 +135,28 @@ def leaves(self, *, aniso: bool = False, requires_grad: bool = True): t.clone().requires_grad_(requires_grad) for t in (self.xyz, self.occ, third) ) + def to(self, device=None, dtype=None) -> "Scene": + """A new :class:`Scene` with every tensor moved and cast. + + Scenes are built CPU/float64 and stay that way, because the oracle is computed + from them and a reference must not inherit the precision of the thing it judges. + Only the *candidate* side is moved, so an MPS float32 kernel and a CPU float64 + oracle describe the same physical structure. + + ``hkl_list`` is untouched -- it is Python ints for gemmi, not a tensor. + """ + if device is None and dtype is None: + return self + real = {"hkl", "s", "s_vec", "xyz", "xyz_frac", "occ", "adp", "u6", "A", "B", + "frac_matrix", "inv_frac_matrix"} + moved = {} + for f in fields(self): + v = getattr(self, f.name) + moved[f.name] = ( + v.to(device=device, dtype=dtype) if f.name in real else v + ) + return Scene(**moved) + def _hkl_within(cell: Cell, d_min: float, dtype: torch.dtype, cap: Optional[int]): """Every integer hkl inside the ``d_min`` shell, ordered, ``F(000)`` excluded. @@ -211,10 +241,24 @@ def synthetic_scene( ) -> Scene: """A small random P1 scene. - Degeneracies to avoid are documented in ``tests/helpers/kernel_cases.py``: ``occ`` - is never 1.0 and the ADP off-diagonals are never zero, because both hide sign and - ordering errors -- a wrong ``occ`` gradient is invisible when every ``occ`` is 1, - and a ``U12``/``U13`` swap is invisible when both are 0. + Two degeneracies are deliberately avoided, both of which once survived in two test + files at the same time: + + * ``occ`` is never exactly 1.0. The kernels recover ``d/d_occ`` by dividing the + accumulated gradient by ``occ``, and at ``occ == 1`` that division is a no-op that + hides a wrong scaling. + * the ADP off-diagonals are non-zero **and signed**. Zero off-diagonals mean every + ellipsoid is axis-aligned, which leaves the cross-term arithmetic completely + uncovered -- the ``p01``/``p02``/``p12`` entries of the inverted 3x3, and the + backward's off-diagonal U gradients, which carry a ``4*pi^2`` factor where the + diagonal ones carry ``2*pi^2``. Magnitudes stay well below the diagonal so every U + remains comfortably positive-definite, since the Metal shader inverts ``M_g`` + analytically with no positive-definiteness guard. + + (Recorded here rather than in ``tests/helpers/kernel_cases.py``, which documented them + for the two accelerator test files this package replaced and has been removed with + them. ``test_dispatch.py::test_aniso_scene_exercises_off_diagonal_u`` asserts the + second one rather than trusting this docstring.) """ g = torch.Generator().manual_seed(seed) cell = Cell( @@ -390,6 +434,237 @@ def sf_fft_for( return sf +# --------------------------------------------------------------------------- +# Direct kernel access +# --------------------------------------------------------------------------- +# Every production splat is called *directly* here rather than through +# ``build_electron_density`` + ``use_engine``. Two reasons: +# +# 1. **No vacuity risk.** Under ``Engine.AUTO`` a failed accelerator kernel silently +# falls back to the portable splat (``main.py`` catches and falls through), so a +# dispatch-driven test can pass while measuring a different kernel than the one it +# names. Calling the kernel directly settles that by construction. +# 2. **No global-config coupling.** ``SfFFT`` builds its grid through ``get_real_grid``, +# which reads the *global* ``dtypes.float`` and takes no dtype argument -- so an MPS +# ``SfFFT`` under this package's float64 pin would try to allocate float64 on MPS and +# fail. ``ifft`` and ``extract_structure_factor_from_grid`` read no global config at +# all, so :func:`density_to_F` needs no config switching. +# +# The dispatch ladder is a separate concern, tested in ``test_dispatch.py``. + +#: ``name -> (fn, kind, devices, dtypes)``. All six wrappers share one signature, +#: ``(density_map, xyz, adp_or_u, occ, A, B, inv_frac, frac, radius_per_atom)``, so +#: :func:`splat_direct` needs no per-kernel adapter. They did not before the +#: standardization -- the Metal pair took extra unused arguments in a different order, +#: and CUDA had no wrapper at all. +_KERNEL_SPECS = ( + # name device dtypes kind + ("cpu_sphere", "cpu", (torch.float32, torch.float64), "both"), + ("portable", "any", (torch.float32, torch.float64), "both"), + ("mps_metal", "mps", (torch.float32,), "both"), + ("cuda_triton", "cuda", (torch.float32,), "both"), +) + + +def splat_kernel(name: str, aniso: bool): + """Resolve a kernel name to its wrapper. Imports are local per backend. + + Backend imports stay inside the function: the Metal module loads MSL source and the + CUDA one imports Triton, neither of which a CPU-only host should pay for at + collection time. + """ + if name == "cpu_sphere": + from torchref.base.electron_density.kernels.cpu import sphere_splat as m + + return m.add_anisotropic_cpu_sphere_var if aniso else m.add_isotropic_cpu_sphere_var + if name == "portable": + from torchref.base.electron_density.kernels.cpu import variable_radius as m + + return m.add_anisotropic_plain_var if aniso else m.add_isotropic_plain_var + if name == "mps_metal": + from torchref.base.electron_density.kernels.mps import variable_radius as m + + return m.add_anisotropic_mps_var if aniso else m.add_isotropic_mps_var + if name == "cuda_triton": + from torchref.base.electron_density.kernels.cuda import variable_radius as m + + return m.add_anisotropic_cuda_var if aniso else m.add_isotropic_cuda_var + raise ValueError(f"unknown kernel {name!r}") + + +def device_supports_dtype(device: torch.device, dtype: torch.dtype) -> bool: + """Whether ``device`` can hold ``dtype`` at all. + + MPS has no float64 — it is a *device* limitation, not a kernel one, so it belongs + here and not in the per-kernel table. The portable splat is float64-capable and + device-agnostic, and without this check it would be offered for ``(mps, float64)`` + and fail inside ``Scene.to``. + """ + if device.type == "mps" and dtype is torch.float64: + return False + return True + + +def kernels_for(device: torch.device, dtype: torch.dtype): + """Kernel names that actually run on this ``(device, dtype)``. + + Filtering here rather than skipping inside the test keeps a wrong entry visible: an + unsupported combination produces no test rather than a passing one. + """ + if not device_supports_dtype(device, dtype): + return [] + out = [] + for name, dev, dtypes_ok, _ in _KERNEL_SPECS: + if dtype not in dtypes_ok: + continue + if dev != "any" and dev != device.type: + continue + out.append(name) + return out + + +def splat_direct(scene: Scene, name: str, xyz=None, occ=None, third=None, *, aniso=False): + """Call one splat kernel directly and return the density map. + + ``scene`` must already be on the target device and dtype (see :meth:`Scene.to`). + The per-atom radius is computed here because the kernels take it as an argument -- + ``build_electron_density`` normally does this, and bypassing the dispatch means the + caller owns it. Same policy call the dispatch makes, so the truncation contract is + unchanged. + """ + from torchref.base.electron_density.radius_policy import ( + per_atom_radius_aniso, + per_atom_radius_iso, + ) + from torchref.config import get_sigma_cutoff_ed + + xyz = scene.xyz if xyz is None else xyz + occ = scene.occ if occ is None else occ + if third is None: + third = scene.u6 if aniso else scene.adp + + n_sigma = get_sigma_cutoff_ed() + radius = ( + per_atom_radius_aniso(scene.B, third, n_sigma=n_sigma) + if aniso + else per_atom_radius_iso(third, scene.B, n_sigma=n_sigma) + ) + dims = _grid_dims(scene) + density_map = torch.zeros(*dims, dtype=xyz.dtype, device=xyz.device) + fn = splat_kernel(name, aniso) + return fn( + density_map, xyz, third, occ, scene.A, scene.B, + scene.inv_frac_matrix, scene.frac_matrix, radius, + ) + + +# --------------------------------------------------------------------------- +# Direct-summation kernels, also called directly +# --------------------------------------------------------------------------- +#: ``name -> (device, dtypes)``. ``eager`` is the oracle and is excluded from the +#: candidate list by :func:`ds_kernels_for`; it is registered here so the signature +#: adapter has one place to live. +#: +#: There is **no Metal/MPS direct-summation kernel**: ``Engine.METAL`` returns False from +#: the Triton gate and an MPS device fails ``t.is_cuda``, so DS on MPS *is* +#: ``_checkpointed_*`` running on-device. That is a real production path and it is what the +#: ``mps`` leg of ``checkpointed`` covers. +_DS_KERNEL_SPECS = ( + ("eager", "any", (torch.float32, torch.float64)), + ("checkpointed", "any", (torch.float32, torch.float64)), + ("ds_triton", "cuda", (torch.float32,)), +) + + +def ds_kernel(name: str, aniso: bool): + """Resolve a direct-summation kernel name to its function. + + The Triton import is function-local: it pulls in ``triton``, which a CPU-only host + should not need at collection time. + """ + from torchref.base.direct_summation import dispatch as D + + if name == "eager": + return D._eager_aniso if aniso else D._eager_iso + if name == "checkpointed": + return D._checkpointed_aniso if aniso else D._checkpointed_iso + if name == "ds_triton": + from torchref.base.direct_summation.triton_ds import ( + ds_aniso_triton, + ds_iso_triton, + ) + + return ds_aniso_triton if aniso else ds_iso_triton + raise ValueError(f"unknown DS kernel {name!r}") + + +def ds_kernels_for(device: torch.device, dtype: torch.dtype): + """Candidate DS kernels on this ``(device, dtype)``. Excludes the oracle.""" + if not device_supports_dtype(device, dtype): + return [] + return [ + name + for name, dev, dtypes_ok in _DS_KERNEL_SPECS + if name != "eager" + and dtype in dtypes_ok + and (dev == "any" or dev == device.type) + ] + + +def ds_direct(scene: Scene, name: str, xyz_frac=None, occ=None, third=None, *, aniso=False): + """Call one direct-summation kernel directly and return complex ``F(hkl)``. + + Coordinates are **fractional** here -- unlike the splat kernels, which take Cartesian. + Carrying both on :class:`Scene` is what keeps that from being a silent error. + + ``ds_iso_triton`` takes no ``max_memory_gb``; the eager and checkpointed paths do. That + is the only signature difference, and it is absorbed here rather than at call sites. + """ + xyz_frac = scene.xyz_frac if xyz_frac is None else xyz_frac + occ = scene.occ if occ is None else occ + if third is None: + third = scene.u6 if aniso else scene.adp + geom = scene.s_vec if aniso else scene.s + fn = ds_kernel(name, aniso) + args = (scene.hkl, geom, xyz_frac, occ, third, scene.A, scene.B) + if name == "ds_triton": + return fn(*args) + return fn(*args, None) + + +def _grid_dims(scene: Scene, fineness: float = GRID_FINENESS): + """Grid dimensions matching what ``SfFFT.setup_grid`` would choose. + + Derived arithmetically instead of by constructing an ``SfFFT``, so no global dtype is + read and nothing float64 is allocated on a device that cannot hold it. Rounded to + even numbers, which is all the kernels care about (they take the shape from + ``density_map``); FFT-friendliness only matters for speed here. + """ + spacing = scene.d_min / (3.0 * fineness) + lengths = [float(scene.cell.data[i]) for i in range(3)] + return tuple(max(4, 2 * int(round(L / spacing / 2.0))) for L in lengths) + + +def density_to_F(scene: Scene, density_map: torch.Tensor) -> torch.Tensor: + """``F(hkl)`` from a density map, without ``SfFFT``. + + ``ifft`` applies the ``V_cell / N`` voxel-volume scaling that makes the result + directly comparable to a direct-summation atom sum -- no scale factor, no offset. + Neither this nor ``extract_structure_factor_from_grid`` reads the global dtype + config, which is what lets an accelerator candidate run while the package is pinned + to float64 for the oracle. + """ + from torchref.base.fourier.fft import ifft + from torchref.base.reciprocal.grid_operations import ( + extract_structure_factor_from_grid, + ) + + volume = float(scene.cell.volume) + return extract_structure_factor_from_grid( + ifft(density_map, volume), scene.hkl + ) + + def fft_sf(scene: Scene, sf_fft, xyz=None, occ=None, third=None, *, aniso=False): """``F(hkl)`` through the density-splat + FFT route, in P1. diff --git a/tests/unit/structure_factor/test_dispatch.py b/tests/unit/structure_factor/test_dispatch.py new file mode 100644 index 0000000..c91fa4c --- /dev/null +++ b/tests/unit/structure_factor/test_dispatch.py @@ -0,0 +1,387 @@ +"""The dispatch ladder itself: which engine selects which kernel, and failure policy. + +Deliberately separate from the accuracy tests. Those call each kernel **directly**, so +they say nothing about whether ``build_electron_density`` would have chosen it — and that +separation is the point. Entangling the two is what forced the old accelerator tests to +carry a monkeypatched call recorder just to prove they were not measuring the fallback. + +What is pinned here: + +* under ``Engine.AUTO`` *and* the strict engine, an accelerator host really reaches its + native kernel rather than the portable splat; +* a strict engine **raises** when its kernel is unavailable, while ``AUTO`` degrades + quietly — the two halves of the never-silently-degrade contract; +* ``Engine.EAGER`` reaches the portable splat on every device. + +Ported from ``tests/integration/test_variable_radius_{gpu,mps}.py``, which are deleted: +their accuracy coverage is superseded by the oracle legs in this package, but these +dispatch contracts are not accuracy and had no replacement. +""" + +from __future__ import annotations + +import pytest +import torch + +from torchref.base.electron_density.main import build_electron_density +from torchref.base.electron_density.radius_policy import per_atom_radius_iso +from torchref.utils import Engine, use_engine + +from tests.helpers.grad_asserts import rel_error + +from . import RTOL_DS_F32 +from . import helpers as H + +pytestmark = pytest.mark.unit + + +def _build(scene, device, dtype, engine, aniso=False): + """Run one dispatch through ``build_electron_density`` on ``device``. + + ``dtype`` is passed explicitly rather than inherited from the ambient config. That is + load-bearing for the accelerator branches: under this package's float64 pin the Metal + and Triton gates would never fire, which is a silent way for every assertion below to + become vacuous. + """ + s = scene.to(device=device, dtype=dtype) + dims = H._grid_dims(s) + grid = torch.zeros(*dims, 3, dtype=dtype, device=device) + voxel = torch.tensor( + [float(s.cell.data[i]) / dims[i] for i in range(3)], dtype=dtype, device=device + ) + empty1 = s.xyz.new_zeros(0) + empty3 = s.xyz.new_zeros(0, 3) + empty5 = s.A.new_zeros(0, 5) + kw = dict( + real_space_grid=grid, + inv_frac_matrix=s.inv_frac_matrix, + frac_matrix=s.frac_matrix, + voxel_size=voxel, + dtype=dtype, + ) + with use_engine(engine): + if aniso: + return build_electron_density( + xyz_iso=empty3, adp_iso=empty1, occ_iso=empty1, + A_iso=empty5, B_iso=empty5, + xyz_aniso=s.xyz, u_aniso=s.u6, occ_aniso=s.occ, + A_aniso=s.A, B_aniso=s.B, **kw, + ) + return build_electron_density( + xyz_iso=s.xyz, adp_iso=s.adp, occ_iso=s.occ, + A_iso=s.A, B_iso=s.B, **kw, + ) + + +# --------------------------------------------------------------------------- +# Provenance: the named kernel actually runs +# --------------------------------------------------------------------------- +@pytest.mark.mps +@pytest.mark.parametrize("engine", [Engine.AUTO, Engine.METAL]) +@pytest.mark.parametrize("kind", ["iso", "aniso"]) +def test_metal_kernel_is_actually_dispatched(scene_small, engine, kind, monkeypatch): + """On MPS float32, both ``AUTO`` and ``METAL`` must call the Metal kernel. + + Verified with a call recorder, not by comparing maps: the portable reference uses + ``scatter_add`` on MPS, whose accumulation order is not reproducible, so two portable + runs already differ at ~1e-7 and an equality check against one would prove nothing + either way. + + **The patch target matters.** ``_add_isotropic`` does a *function-local* + ``from ...kernels.mps import add_isotropic_mps_var`` on every call, so it re-reads the + package each time. Patching the ``main`` module's namespace would silently no-op and + leave this test as vacuous as the bug it guards against. + + The recorder delegates to the real kernel rather than stubbing it, so the dispatch is + still exercised end to end. + + The submodule is imported explicitly rather than reached as ``kernels.mps``: the parent + package does not import it eagerly, so that attribute only exists once something has + already triggered the function-local import. Relying on that ordering is how this test + would pass in a full run and fail in isolation. + """ + from torchref.base.electron_density.kernels import mps as kernels_mps + + name = f"add_{'anisotropic' if kind == 'aniso' else 'isotropic'}_mps_var" + real = getattr(kernels_mps, name) + calls = [] + + def recording(*args, **kwargs): + calls.append(1) + return real(*args, **kwargs) + + monkeypatch.setattr(kernels_mps, name, recording) + _build(scene_small, torch.device("mps"), torch.float32, engine, aniso=kind == "aniso") + assert calls, f"{engine} did not dispatch to {name}" + + +@pytest.mark.cuda +@pytest.mark.parametrize("engine", [Engine.AUTO, Engine.TRITON]) +@pytest.mark.parametrize("kind", ["iso", "aniso"]) +def test_triton_kernel_is_actually_dispatched(scene_small, engine, kind, monkeypatch): + """CUDA float32 equivalent of the Metal provenance test. + + Patch target differs: ``main.py`` imports the CUDA wrappers at module scope, not + per-call, so the name to patch is the one bound in ``main`` -- the mirror image of the + Metal case, and the reason each needs its own test rather than one parametrized helper. + """ + from torchref.base.electron_density import main as ed_main + + name = f"add_{'anisotropic' if kind == 'aniso' else 'isotropic'}_cuda_var" + real = getattr(ed_main, name) + calls = [] + + def recording(*args, **kwargs): + calls.append(1) + return real(*args, **kwargs) + + monkeypatch.setattr(ed_main, name, recording) + _build(scene_small, torch.device("cuda"), torch.float32, engine, aniso=kind == "aniso") + assert calls, f"{engine} did not dispatch to {name}" + + +@pytest.mark.parametrize("kind", ["iso", "aniso"]) +def test_eager_reaches_the_portable_splat(scene_small, kind, monkeypatch): + """``Engine.EAGER`` must reach the portable splat, on any device. + + The counterpart to the provenance tests above: EAGER is the documented escape hatch + for double backward and debugging, so it has to be the *portable* kernel that runs, + not whatever AUTO would have picked. + """ + from torchref.base.electron_density import main as ed_main + + name = f"add_{'anisotropic' if kind == 'aniso' else 'isotropic'}_plain_var" + real = getattr(ed_main, name) + calls = [] + + def recording(*args, **kwargs): + calls.append(1) + return real(*args, **kwargs) + + monkeypatch.setattr(ed_main, name, recording) + _build(scene_small, torch.device("cpu"), torch.float32, Engine.EAGER, aniso=kind == "aniso") + assert calls, f"Engine.EAGER did not dispatch to {name}" + + +# --------------------------------------------------------------------------- +# Failure policy: strict engines raise, AUTO degrades +# --------------------------------------------------------------------------- +@pytest.mark.mps +def test_metal_raises_when_shader_unavailable(scene_small, monkeypatch): + """``Engine.METAL`` must raise, never degrade, when the shader is missing. + + ``monkeypatch`` reverts the module globals at teardown, so this needs no + ``try``/``finally`` -- and must not have one, since a swallowed failure here is + precisely the behaviour under test. + + The AUTO half is the other side of the contract: a user gets a correct answer from the + portable splat, while a benchmark or a test asking for METAL gets an error instead of a + quietly slower result. + """ + from torchref.base.electron_density.kernels.mps import compile as mps_compile + + monkeypatch.setattr(mps_compile, "_lib", None) + monkeypatch.setattr(mps_compile, "_lib_failed", True) + monkeypatch.setattr(mps_compile, "_lib_error", ("forced test failure", "")) + + mps = torch.device("mps") + with pytest.raises(RuntimeError, match="forced test failure"): + _build(scene_small, mps, torch.float32, Engine.METAL) + + # ...while AUTO still degrades to a working splat. Compared against EAGER on the same + # device, not against the oracle: with the shader forced to fail both engines now reach + # the *same* portable kernel, so they must agree closely, and an oracle comparison here + # would instead be measuring this scene's discretization error (which is large -- it is + # deliberately tiny and coarse for gradcheck) and tell us nothing about the fallback. + # Loose rather than bitwise because MPS ``scatter_add`` accumulation order is not + # reproducible, so two portable runs already differ at ~1e-7. + auto = _build(scene_small, mps, torch.float32, Engine.AUTO) + eager = _build(scene_small, mps, torch.float32, Engine.EAGER) + rel = H.rel_l2(auto.cpu().to(torch.float64), eager.cpu().to(torch.float64)) + print(f"\n AUTO vs EAGER after forced shader failure: relL2 {rel:.3e}") + assert rel < 1e-5, ( + f"AUTO did not degrade to the portable splat after the shader failed (rel {rel:.3e})" + ) + + +@pytest.mark.parametrize("engine", [Engine.TRITON, Engine.METAL]) +def test_strict_engine_rejects_cpu(scene_small, engine): + """A strict accelerator engine on CPU must raise, not fall back. + + Both gates refuse CPU inputs, by different routes: ``should_use_triton`` raises + directly, and ``should_use_metal`` raises on non-MPS. Either way the failure is loud, + which is what lets the provenance tests above be meaningful — a strict engine that + quietly degraded would make them untestable. + """ + with pytest.raises(RuntimeError): + _build(scene_small, torch.device("cpu"), torch.float32, engine) + + +# --------------------------------------------------------------------------- +# Non-vacuity +# --------------------------------------------------------------------------- +def test_aniso_scene_exercises_off_diagonal_u(scene_small, scene_fine): + """The anisotropic scenes must have non-zero off-diagonal U. + + Ported from ``test_variable_radius_gpu.py``. Every aniso comparison in this package + would pass on axis-aligned ellipsoids while leaving the cross-term arithmetic + untouched -- the ``p01``/``p02``/``p12`` entries of the inverted 3x3, and the backward's + off-diagonal U gradients, which carry a ``4*pi^2`` factor where the diagonal ones carry + ``2*pi^2``. A scene-level assertion, since the scenes are what the tests share. + """ + for name, scene in (("scene_small", scene_small), ("scene_fine", scene_fine)): + off = scene.u6[:, 3:] + assert off.abs().max() > 0, f"{name}: off-diagonal U is all zero" + assert (off < 0).any() and (off > 0).any(), ( + f"{name}: off-diagonal U is single-signed; a sign error in the cross terms " + "would not show up" + ) + + +def test_radius_policy_is_the_same_on_every_device(scene_small): + """The per-atom truncation radius must not depend on device or dtype. + + ``splat_direct`` computes the radius itself because it bypasses the dispatch. If that + ever diverged from what ``build_electron_density`` computes, every accuracy test in + this package would be measuring a different truncation than production uses -- and + would still pass, because both sides would share it. + """ + from torchref.config import get_sigma_cutoff_ed + + ns = get_sigma_cutoff_ed() + ref = per_atom_radius_iso(scene_small.adp, scene_small.B, n_sigma=ns) + for device in (torch.device("cpu"), torch.device("mps")): + if device.type == "mps" and not torch.backends.mps.is_available(): + continue + s = scene_small.to(device=device, dtype=torch.float32) + got = per_atom_radius_iso(s.adp, s.B, n_sigma=ns) + assert torch.equal(got.cpu().to(ref.dtype), ref), ( + f"radius policy differs on {device.type}: the truncation contract is not " + "device-independent" + ) + + +# --------------------------------------------------------------------------- +# Under-probed gates (CUDA only) +# --------------------------------------------------------------------------- +# Both of these assert the *intended* contract, not current behaviour. They were written +# from reading the gates on a host with no CUDA, so they have never run. If one fails on a +# GPU host, that is the finding -- do not loosen it to match what the code does. + + +@pytest.mark.cuda +def test_triton_density_gate_probes_every_tensor(scene_small): + """A float64 ``density_map`` must not reach the float32 Triton kernel. + + ``main.py`` gates the density Triton branch on ``should_use_triton(xyz)`` -- **only + xyz** -- while its sibling gates probe six tensors + (``should_use_sphere_splat(density_map, xyz, adp, occ, A, B)``, and the same for Metal). + So a float32 ``xyz`` with a float64 ``density_map`` passes, and + ``WorkQueueGridDensity.forward`` hands that float64 buffer to a kernel doing float32 + ``tl.atomic_add``. + + The contract asserted here is that such a call fails loudly. Either outcome is + acceptable -- a raise from the gate, or a raise from the kernel -- but silently + returning a number is not. + """ + cuda = torch.device("cuda") + s = scene_small.to(device=cuda, dtype=torch.float32) + dims = H._grid_dims(s) + dm_f64 = torch.zeros(*dims, dtype=torch.float64, device=cuda) + from torchref.base.electron_density.kernels.cuda.variable_radius import ( + add_isotropic_cuda_var, + ) + from torchref.config import get_sigma_cutoff_ed + + radius = per_atom_radius_iso(s.adp, s.B, n_sigma=get_sigma_cutoff_ed()) + + with pytest.raises(Exception): + out = add_isotropic_cuda_var( + dm_f64, s.xyz, s.adp, s.occ, s.A, s.B, + s.inv_frac_matrix, s.frac_matrix, radius, + ) + # If it did not raise, it must at least not have silently produced garbage. + assert torch.isfinite(out).all() and out.abs().sum() > 0, ( + "float64 density_map + float32 Triton kernel returned a non-finite or empty " + "map instead of raising" + ) + + +@pytest.mark.cuda +def test_triton_ds_does_not_silently_truncate_hkl(scene_small): + """The Triton DS kernel must not silently downcast a float64 ``hkl``. + + ``triton_ds.py`` gates on ``xyz_frac`` alone and then force-casts every other input to + float32 (``_cols_f32``), including ``hkl``. ``hkl`` feeds the phase + ``phi = 2*pi*(h.r)``, the most precision-sensitive quantity in the calculation, and a + float64 ``hkl`` is exactly what a float64 config produces (``sf_ds.py:473``). + + Asserted against the eager oracle rather than against the float32 result: the question + is not whether the two Triton calls agree with each other, it is whether the truncation + costs accuracy that the caller asked for by supplying float64. + """ + from torchref.base.direct_summation.dispatch import _eager_iso + from torchref.base.direct_summation.triton_ds import ds_iso_triton + + cuda = torch.device("cuda") + s64 = scene_small.to(device=cuda, dtype=torch.float64) + s32 = scene_small.to(device=cuda, dtype=torch.float32) + + ref = _eager_iso( + s64.hkl, s64.s, s64.xyz_frac, s64.occ, s64.adp, s64.A, s64.B, None + ) + # float64 hkl with float32 xyz_frac: the gate passes, and hkl gets truncated. + got = ds_iso_triton( + s64.hkl, s32.s, s32.xyz_frac, s32.occ, s32.adp, s32.A, s32.B + ) + rel = H.rel_l2(got.cpu().to(torch.complex128), ref.cpu().to(torch.complex128)) + print(f"\n Triton DS with float64 hkl: relL2 vs eager oracle {rel:.3e}") + assert rel < 1e-4, ( + f"a float64 hkl into the Triton DS kernel costs {rel:.3e} against the oracle. The " + "kernel truncates it to float32 with no warning; either it should preserve the " + "precision or the gate should refuse the call." + ) + + +@pytest.mark.cuda +def test_sfds_engine_toggle_end_to_end(scene_fine): + """``SfDS`` through a full symmetry loop: ``Engine.TRITON`` vs ``Engine.EAGER``. + + Restores the end-to-end coverage lost with ``test_ds_triton_vs_eager.py``. Distinct + from the per-kernel DS legs in ``test_forward.py`` / ``test_gradients.py``: those call + the kernels directly in P1, so nothing there exercises ``SfDS``'s symmetry accumulation + or its per-call ``engine=`` argument, which **overrides** ``use_engine`` rather than + deferring to it. + + A non-P1 group on purpose. Both engines share the symmetry algebra, so this does not + validate the convention -- ``test_forward.py::test_sfds_matches_gemmi_with_symmetry`` + does that against gemmi. What it validates is that swapping the engine underneath a + symmetry loop changes nothing. + """ + from torchref.model.sf_ds import SfDS + + cuda = torch.device("cuda") + s = scene_fine.to(device=cuda, dtype=torch.float32) + obs = H.synthetic_obs(H.ds_direct(scene_fine, "eager").detach()).to(cuda, torch.float32) + + def run(engine): + sf = SfDS( + cell=s.cell, spacegroup="P212121", engine=engine, + dtype_float=torch.float32, device=cuda, max_memory_gb=2.0, + ) + leaves = tuple(t.clone().requires_grad_(True) for t in (s.xyz, s.adp, s.occ)) + xyz, adp, occ = leaves + F, _ = sf.compute_structure_factors(s.hkl, xyz, adp, occ, s.A, s.B) + grads = torch.autograd.grad(H.ls_target(F, obs), leaves) + return F.detach(), grads + + F_t, g_t = run(Engine.TRITON) + F_e, g_e = run(Engine.EAGER) + + rel_F = H.rel_l2(F_t.cpu().to(torch.complex128), F_e.cpu().to(torch.complex128)) + print(f"\n SfDS P212121 TRITON vs EAGER: F relL2 {rel_F:.3e}") + assert rel_F < RTOL_DS_F32, f"SfDS forward differs by engine: rel {rel_F:.3e}" + for name, a, b in zip(("xyz", "adp", "occ"), g_t, g_e): + rel = rel_error(a, b) + print(f" {name:4s} grad relL2 {rel:.3e}") + assert rel < RTOL_DS_F32, f"SfDS {name} gradient differs by engine: rel {rel:.3e}" diff --git a/tests/unit/structure_factor/test_ds_dispatch.py b/tests/unit/structure_factor/test_ds_dispatch.py new file mode 100644 index 0000000..eb9b84d --- /dev/null +++ b/tests/unit/structure_factor/test_ds_dispatch.py @@ -0,0 +1,123 @@ +"""Direct-summation dispatch *mechanics*, as distinct from its accuracy. + +CPU-only. What is left here after the oracle package absorbed the numerics: that +reflection-chunking is exact, that an empty atom set returns zeros of the right shape, +and that the engine guards refuse rather than silently degrade. + +The accuracy questions this file used to own -- checkpointed-vs-eager parity and +``gradcheck`` -- moved to ``test_gradients.py`` in this package, which ties the shipping +``_checkpointed_*`` path to the ``_eager_*`` oracle and then to finite differences. They +were duplicated in ``tests/unit/test_gradient_correctness.py`` as well, at a different +strictness, which is why they were consolidated. +""" + +import pytest +import torch + +from torchref.base.direct_summation import Engine, ds_iso +from torchref.base.direct_summation import dispatch as D +from torchref.config import dtypes + +pytestmark = pytest.mark.unit + +# Ambient configured dtype, used only to build inputs. The eager-parity comparisons +# that needed a dtype-dependent tolerance here now live in ``test_gradients.py``, where +# the whole package is pinned to float64 by a fixture rather than reading the global at +# import time -- an import-time read made this file's strictness depend silently on +# ``TORCHREF_DTYPE_FLOAT``. +_F = dtypes.float + + +def _inputs(N=4, R=7, seed=0, dtype=None): + dtype = dtype or _F + torch.manual_seed(seed) + hkl = torch.randint(-3, 4, (R, 3)).to(dtype) + s = torch.rand(R, dtype=dtype) * 0.5 + svec = torch.randn(R, 3, dtype=dtype) * 0.3 + A = torch.rand(N, 5, dtype=dtype) + B = torch.rand(N, 5, dtype=dtype) + 0.5 + return hkl, s, svec, A, B + + +def _leaves(N=4, seed=1, dtype=None): + dtype = dtype or _F + torch.manual_seed(seed) + xyz = torch.rand(N, 3, dtype=dtype, requires_grad=True) + occ = (torch.rand(N, dtype=dtype) * 0.4 + 0.6).requires_grad_() + adp = (torch.rand(N, dtype=dtype) * 10 + 5).requires_grad_() + U = (torch.rand(N, 6, dtype=dtype) * 0.04 + 0.01).requires_grad_() + return xyz, occ, adp, U + + + + +def test_checkpointed_chunking_is_exact(): + # Chunking slices the reflection dimension, so each reflection's atom sum is + # computed within a single chunk -- the result is mathematically identical + # regardless of chunk size. It is not, however, guaranteed bit-exact: the + # phase matmul dispatches to different BLAS kernels for (R,3)x(3,N) vs + # (1,3)x(3,N), and last-ULP rounding there is CPU/BLAS-dependent. Assert + # numerical equivalence, not bitwise equality. + hkl, s, _, A, B = _inputs(R=11, dtype=torch.float64) + xyz, occ, adp, _ = _leaves(dtype=torch.float64) + F_full = D._checkpointed_iso(hkl, s, xyz, occ, adp, A, B, max_memory_gb=None) + F_chunk = D._checkpointed_iso(hkl, s, xyz, occ, adp, A, B, max_memory_gb=1e-7) + assert torch.allclose(F_full, F_chunk, rtol=1e-10, atol=1e-12) + + + + +def test_empty_atoms_returns_zeros(): + hkl, s, _, _, _ = _inputs() + empty = torch.zeros(0, 3, dtype=torch.float64) + z = torch.zeros(0, dtype=torch.float64) + z5 = torch.zeros(0, 5, dtype=torch.float64) + F = ds_iso(hkl, s, empty, z, z, z5, z5, engine=Engine.AUTO) + assert F.shape == (hkl.shape[0],) + assert F.abs().sum().item() == 0.0 + + +def test_explicit_triton_engine_rejects_cpu(): + hkl, s, _, A, B = _inputs() + xyz, occ, adp, _ = _leaves() + with pytest.raises(RuntimeError): + ds_iso(hkl, s, xyz, occ, adp, A, B, engine=Engine.TRITON) + + +def test_eager_none_scattering_factors_and_no_batching(): + """``max_memory_gb=None`` with ``scattering_factors=None`` must compute from A/B. + + Two independently optional arguments meeting: with no batching *and* no precomputed + scattering factors, the eager path takes a branch that derives ``f(s)`` from the ITC92 + A/B coefficients inline. Chunking must not change the answer. + + Moved from ``tests/unit/test_kernel_fixes.py``, where it sat among dtype-switch and + NaN-safety tests. It is the only structure-factor test in that file, and this package is + where structure-factor coverage lives. + """ + from torchref.base.direct_summation.dispatch import _eager_aniso, _eager_iso + + g = torch.Generator().manual_seed(0) + N, R = 8, 12 + d = torch.float64 + hkl = torch.randint(-3, 4, (R, 3), generator=g).to(d) + s = torch.rand(R, generator=g, dtype=d) * 0.4 + svec = torch.randn(R, 3, generator=g, dtype=d) * 0.3 + A = torch.rand(N, 5, generator=g, dtype=d) + B = torch.rand(N, 5, generator=g, dtype=d) + 0.5 + xyz = torch.rand(N, 3, generator=g, dtype=d) + occ = torch.rand(N, generator=g, dtype=d) * 0.4 + 0.6 + adp = torch.rand(N, generator=g, dtype=d) * 10 + 5 + U = torch.rand(N, 6, generator=g, dtype=d) * 0.04 + 0.01 + + for tag, fn, geom, third in ( + ("iso", _eager_iso, s, adp), + ("aniso", _eager_aniso, svec, U), + ): + f_none = fn(hkl, geom, xyz, occ, third, A, B, None) + f_batch = fn(hkl, geom, xyz, occ, third, A, B, 2.0) + assert torch.isfinite(f_none).all(), f"{tag}: non-finite F with no batching" + torch.testing.assert_close( + f_none, f_batch, rtol=1e-12, atol=1e-12, + msg=f"{tag}: chunking changed the answer", + ) diff --git a/tests/unit/sf_oracle/test_forward.py b/tests/unit/structure_factor/test_forward.py similarity index 72% rename from tests/unit/sf_oracle/test_forward.py rename to tests/unit/structure_factor/test_forward.py index feb4f45..8afe38e 100644 --- a/tests/unit/sf_oracle/test_forward.py +++ b/tests/unit/structure_factor/test_forward.py @@ -15,6 +15,9 @@ from torchref.utils import Engine, use_engine from . import ( + COS_MIN_DS, + RTOL_DS_F32, + RTOL_DS_F64, ATOL_TABLE_VS_GEMMI, COS_MIN, MAXREL_VS_GEMMI, @@ -26,6 +29,7 @@ SCALE_TOL_GEMMI, ) from . import helpers as H +from .conftest import DEVICE_DTYPE_KERNELS, DS_DEVICE_DTYPE_KERNELS pytestmark = pytest.mark.unit @@ -34,6 +38,16 @@ _DTYPES = [torch.float32, torch.float64] +def _complex_cos(got, ref): + """Cosine of two complex F vectors, treating them as real 2R-vectors. + + Catches a conjugated result, which an amplitude comparison cannot. + """ + g = torch.view_as_real(got.reshape(-1)).reshape(-1).double() + r = torch.view_as_real(ref.reshape(-1)).reshape(-1).double() + return float((g @ r) / (g.norm() * r.norm()).clamp_min(1e-30)) + + def _report(tag, got, ref): """Print the three metrics, so a widened tolerance always has a number behind it.""" r, m, k = H.rel_l2(got, ref), H.max_rel(got, ref), H.best_fit_scale(got, ref) @@ -387,3 +401,117 @@ def test_coarse_grid_is_measurably_worse(scene_coarse, scene_fine, oracle_fine): "an under-sampled grid passes the amplitude gate, so the gate is not " "constraining grid sampling at all" ) + + +# --------------------------------------------------------------------------- +# Every production kernel, on every device it ships on +# --------------------------------------------------------------------------- +# These call each splat kernel **directly** rather than through +# ``build_electron_density`` + ``use_engine``. Under ``Engine.AUTO`` a failed accelerator +# kernel silently falls back to the portable splat, so a dispatch-driven test can pass +# while measuring a different kernel than the one it names; a direct call settles that by +# construction. The dispatch ladder is tested separately in ``test_dispatch.py``. +# +# The oracle stays CPU float64 whatever the candidate is -- a reference must not inherit +# the precision of the thing it judges. +# +# Measured when written (60-atom synthetic scene, worst case over the whole matrix): +# +# amplitude rel L2 5.68e-03 gradient rel 9.51e-02 +# amplitude cos 0.999990 gradient cos 0.995544 +# +# and, notably, **every device agrees to the printed digits**: Metal float32, CPU fused +# float32/float64 and the portable splat on either device all land on the same residual +# against the oracle. That is the truncation-contract standardization showing up as a +# measurement -- what is left is shared discretization error, not per-kernel divergence. +# Hence no accelerator-specific tolerances: the CPU constants already bound it. + + +@pytest.mark.parametrize("kind", ["iso", "aniso"]) +@pytest.mark.parametrize("device,dtype,kernel", DEVICE_DTYPE_KERNELS) +def test_kernel_matches_ds_amplitudes(scene_fine, oracle_fine, device, dtype, kernel, kind): + """Forward amplitudes from one production kernel against the DS oracle.""" + aniso = kind == "aniso" + scene = scene_fine.to(device=device, dtype=dtype) + with torch.no_grad(): + got = H.density_to_F( + scene, H.splat_direct(scene, kernel, aniso=aniso) + ).cpu().to(torch.complex128) + ref = oracle_fine[f"{kind}_F"] + + rel, cos = H.rel_l2(got, ref), _complex_cos(got, ref) + print(f"\n {device.type}/{dtype}/{kernel}/{kind}: relL2 {rel:.3e} cos {cos:.8f}") + assert rel < RTOL_AMPLITUDE, f"{device.type}/{dtype}/{kernel}/{kind}: rel {rel:.3e}" + assert cos > COS_MIN, f"{device.type}/{dtype}/{kernel}/{kind}: cos {cos:.6f}" + + +@pytest.mark.parametrize("device,dtype,kernel", DEVICE_DTYPE_KERNELS) +def test_kernel_absolute_scale(scene_fine, oracle_fine, device, dtype, kernel): + """Absolute scale per kernel. + + Runs on every backend because the voxel-volume factor comes from ``ifft``, which is + shared, but the *density* each kernel accumulates is not -- a kernel that normalized + its Gaussian differently would show up only here. Correlation and cosine are both + invariant to a global rescale. + """ + scene = scene_fine.to(device=device, dtype=dtype) + with torch.no_grad(): + got = H.density_to_F(scene, H.splat_direct(scene, kernel)).cpu().to(torch.complex128) + scale = H.best_fit_scale(got, oracle_fine["iso_F"]) + print(f"\n {device.type}/{dtype}/{kernel}: scale {scale:.9f}") + assert abs(scale - 1.0) < SCALE_TOL, ( + f"{device.type}/{dtype}/{kernel}: absolute scale off by {abs(scale - 1.0):.3e}" + ) + + +@pytest.mark.parametrize("kind", ["iso", "aniso"]) +@pytest.mark.parametrize("device,dtype,kernel", DEVICE_DTYPE_KERNELS) +def test_kernels_agree_with_each_other(scene_fine, device, dtype, kernel, kind): + """Every kernel on a given ``(device, dtype)`` must agree with the portable splat. + + Complements the oracle comparison rather than duplicating it. The oracle gate is + ``1e-2``, loose enough to hide a kernel that is subtly wrong in the same direction as + the discretization error; this one is tight, because two kernels implementing the same + truncation contract on the same inputs have nothing to disagree about beyond float + accumulation order. + """ + if kernel == "portable": + pytest.skip("portable is the reference for this comparison") + aniso = kind == "aniso" + scene = scene_fine.to(device=device, dtype=dtype) + with torch.no_grad(): + got = H.splat_direct(scene, kernel, aniso=aniso) + ref = H.splat_direct(scene, "portable", aniso=aniso) + tol = RTOL_BACKEND_F32 if dtype is torch.float32 else RTOL_BACKEND_F64 + rel = H.rel_l2(got.cpu().to(torch.float64), ref.cpu().to(torch.float64)) + print(f"\n {device.type}/{dtype}/{kernel} vs portable ({kind}): relL2 {rel:.3e}") + assert rel < tol, f"{device.type}/{dtype}/{kernel}/{kind}: vs portable rel {rel:.3e}" + + +# --------------------------------------------------------------------------- +# Direct-summation kernels vs the eager oracle +# --------------------------------------------------------------------------- +# Restores coverage lost when ``tests/integration/test_ds_triton_vs_eager.py`` was deleted, +# and extends it: that file only ran on CUDA, so ``_checkpointed_*`` -- what every CPU, MPS +# and float64 call actually executes -- was compared against eager on CPU only, and DS on +# MPS had no coverage at all. +# +# No grid and no truncation on either side, so the only source of disagreement is float +# arithmetic and these gate near precision. See ``RTOL_DS_F32`` / ``RTOL_DS_F64``. + + +@pytest.mark.parametrize("kind", ["iso", "aniso"]) +@pytest.mark.parametrize("device,dtype,kernel", DS_DEVICE_DTYPE_KERNELS) +def test_ds_kernel_matches_eager_oracle(scene_fine, device, dtype, kernel, kind): + """Forward ``F(hkl)`` from one DS kernel against the pure-torch eager oracle.""" + aniso = kind == "aniso" + scene = scene_fine.to(device=device, dtype=dtype) + with torch.no_grad(): + got = H.ds_direct(scene, kernel, aniso=aniso).cpu().to(torch.complex128) + ref = H.ds_direct(scene_fine, "eager", aniso=aniso) + + tol = RTOL_DS_F32 if dtype is torch.float32 else RTOL_DS_F64 + rel, cos = H.rel_l2(got, ref), _complex_cos(got, ref) + print(f"\n {device.type}/{dtype}/{kernel}/{kind}: relL2 {rel:.3e} cos {cos:.9f}") + assert rel < tol, f"{device.type}/{dtype}/{kernel}/{kind}: rel {rel:.3e}" + assert cos > COS_MIN_DS, f"{device.type}/{dtype}/{kernel}/{kind}: cos {cos:.7f}" diff --git a/tests/unit/sf_oracle/test_gradients.py b/tests/unit/structure_factor/test_gradients.py similarity index 68% rename from tests/unit/sf_oracle/test_gradients.py rename to tests/unit/structure_factor/test_gradients.py index f6c4549..0e84f62 100644 --- a/tests/unit/sf_oracle/test_gradients.py +++ b/tests/unit/structure_factor/test_gradients.py @@ -33,6 +33,9 @@ from torchref.utils import Engine, use_engine from . import ( + COS_MIN_DS, + RTOL_DS_F32, + RTOL_DS_F64, ATOL_GRADCHECK, COS_MIN_GRADIENT, COS_MIN_GRADIENT_SYNTHETIC, @@ -44,6 +47,7 @@ RTOL_GRADIENT_SYNTHETIC, ) from . import helpers as H +from .conftest import DEVICE_DTYPE_KERNELS, DS_DEVICE_DTYPE_KERNELS pytestmark = pytest.mark.unit @@ -292,3 +296,117 @@ def test_fft_gradients_match_ds_real_structure(gemmi_aniso_grad, oracle_aniso_gr "smearing correction; it was measured at 0.9995 when this test was written." ) + + +# --------------------------------------------------------------------------- +# 5. Every production kernel, on every device it ships on +# --------------------------------------------------------------------------- +# Direct kernel calls, not dispatch -- see the note in ``test_forward.py``. Worst case +# over the whole matrix when written: gradient rel 9.51e-02, cos 0.995544, and every +# device agreeing to the printed digits, so the CPU constants bound the accelerators too. + + +@pytest.mark.parametrize("kind", ["iso", "aniso"]) +@pytest.mark.parametrize("device,dtype,kernel", DEVICE_DTYPE_KERNELS) +def test_kernel_gradients_match_ds(scene_fine, oracle_fine, device, dtype, kernel, kind): + """Gradients from one production kernel against the DS oracle, all three leaves. + + The candidate's leaves live on the target device in the target dtype, while the + oracle's are CPU float64. Both describe the same structure because + :meth:`Scene.to` moves and casts a single source scene, and the comparison helpers + promote to CPU float64 before measuring. + """ + aniso = kind == "aniso" + scene = scene_fine.to(device=device, dtype=dtype) + obs = oracle_fine[f"{kind}_obs"].to(device=device, dtype=dtype) + leaves = scene.leaves(aniso=aniso) + F = H.density_to_F(scene, H.splat_direct(scene, kernel, *leaves, aniso=aniso)) + got = torch.autograd.grad(H.ls_target(F, obs), leaves) + ref = oracle_fine[f"{kind}_grads"] + + print(f"\n {device.type}/{dtype}/{kernel}/{kind}") + for name, g, r in zip(_LEAF_NAMES, got, ref): + rel, cos = rel_error(g, r), cosine_similarity(g, r) + print(f" {name:10s} rel {rel:.3e} cos {cos:.8f} ratio {gradnorm_ratio(g, r):.4f}") + assert rel < RTOL_GRADIENT_SYNTHETIC, ( + f"{device.type}/{dtype}/{kernel}/{kind} {name}: rel {rel:.3e}" + ) + assert cos > COS_MIN_GRADIENT_SYNTHETIC, ( + f"{device.type}/{dtype}/{kernel}/{kind} {name}: cos {cos:.6f}" + ) + + +@pytest.mark.parametrize("kind", ["iso", "aniso"]) +@pytest.mark.parametrize("device,dtype,kernel", DEVICE_DTYPE_KERNELS) +def test_kernel_gradients_agree_with_portable(scene_fine, oracle_fine, device, dtype, kernel, kind): + """Kernel-vs-portable gradients on the same device, gated tightly. + + The oracle gate above is loose by necessity -- it absorbs the real discretization + error. This one is tight, because two kernels applying the same truncation contract to + the same inputs differ only in accumulation order. It is what would catch a backward + that is wrong in the same direction as the discretization residual. + """ + if kernel == "portable": + pytest.skip("portable is the reference for this comparison") + aniso = kind == "aniso" + scene = scene_fine.to(device=device, dtype=dtype) + obs = oracle_fine[f"{kind}_obs"].to(device=device, dtype=dtype) + + def grads(name): + leaves = scene.leaves(aniso=aniso) + F = H.density_to_F(scene, H.splat_direct(scene, name, *leaves, aniso=aniso)) + return torch.autograd.grad(H.ls_target(F, obs), leaves) + + got, ref = grads(kernel), grads("portable") + tol = RTOL_BACKEND_GRAD_F32 if dtype is torch.float32 else RTOL_BACKEND_GRAD_F64 + for name, g, r in zip(_LEAF_NAMES, got, ref): + rel = rel_error(g, r) + print(f" {device.type}/{dtype}/{kernel}/{kind} {name:10s} vs portable rel {rel:.3e}") + assert rel < tol, f"{device.type}/{dtype}/{kernel}/{kind} {name}: rel {rel:.3e}" + + +# --------------------------------------------------------------------------- +# 6. Direct-summation kernels vs the eager oracle +# --------------------------------------------------------------------------- +@pytest.mark.parametrize("kind", ["iso", "aniso"]) +@pytest.mark.parametrize("device,dtype,kernel", DS_DEVICE_DTYPE_KERNELS) +def test_ds_kernel_gradients_match_eager_oracle(scene_fine, device, dtype, kernel, kind): + """Gradients from one DS kernel against the eager oracle, all three leaves. + + Absorbs ``test_gradient_correctness.py``'s two Triton DS gradient tests, which lived + beside geometry and ADP-restraint tests and compared against ``_eager_*`` at + ``min_cos=0.999, ratio_tol=1e-2`` while never checking forward values. Here the forward + comparison is :func:`test_forward.test_ds_kernel_matches_eager_oracle` and the gate is + near precision, because these are two analytic implementations with no discretization + between them. + + Coordinates are **fractional** -- direct summation's convention, unlike the splat + kernels' Cartesian. + """ + aniso = kind == "aniso" + scene = scene_fine.to(device=device, dtype=dtype) + third_ref = scene_fine.u6 if aniso else scene_fine.adp + + with torch.no_grad(): + obs = H.synthetic_obs(H.ds_direct(scene_fine, "eager", aniso=aniso)) + + def grads(sc, name, third_src, obs_local): + leaves = tuple( + t.clone().requires_grad_(True) + for t in (sc.xyz_frac, sc.occ, third_src) + ) + F = H.ds_direct(sc, name, *leaves, aniso=aniso) + return torch.autograd.grad(H.ls_target(F, obs_local), leaves) + + ref = grads(scene_fine, "eager", third_ref, obs) + got = grads( + scene, kernel, scene.u6 if aniso else scene.adp, obs.to(device=device, dtype=dtype) + ) + + tol = RTOL_DS_F32 if dtype is torch.float32 else RTOL_DS_F64 + print(f"\n {device.type}/{dtype}/{kernel}/{kind}") + for name, g, r in zip(("xyz_frac", "occ", "adp_or_u"), got, ref): + rel, cos = rel_error(g, r), cosine_similarity(g, r) + print(f" {name:10s} rel {rel:.3e} cos {cos:.9f}") + assert rel < tol, f"{device.type}/{dtype}/{kernel}/{kind} {name}: rel {rel:.3e}" + assert cos > COS_MIN_DS, f"{device.type}/{dtype}/{kernel}/{kind} {name}: cos {cos:.7f}" diff --git a/tests/unit/sf_oracle/test_second_order.py b/tests/unit/structure_factor/test_second_order.py similarity index 77% rename from tests/unit/sf_oracle/test_second_order.py rename to tests/unit/structure_factor/test_second_order.py index c78a235..6721d86 100644 --- a/tests/unit/sf_oracle/test_second_order.py +++ b/tests/unit/structure_factor/test_second_order.py @@ -49,6 +49,7 @@ RTOL_HVP, ) from . import helpers as H +from .conftest import DEVICE_DTYPE_KERNELS pytestmark = pytest.mark.unit @@ -337,3 +338,86 @@ def loss(x): assert cos > COS_MIN, f"7L84 HVP direction: cos {cos:.6f}" assert rel < RTOL_HVP, f"7L84 HVP magnitude: rel {rel:.3e}" + + +# --------------------------------------------------------------------------- +# 5. Every production kernel, on every device it ships on +# --------------------------------------------------------------------------- +# Second order is where the kernels genuinely differ, so this section is not simply the +# gradient section with another derivative. Only two of the four families are +# double-differentiable: +# +# add_*_cpu_sphere_var yes -- via ``_double_backward_vjp``, which re-derives the VJP +# through the portable splat on the saved leaves +# add_*_plain_var yes -- pure autograd over ``scatter_add`` +# add_*_mps_var no -- ``mps/variable_radius.py:12``: "Backward is first-order +# only (like CUDA); double backward must use Engine.EAGER" +# WorkQueueGridDensity* no -- same, no ``create_graph`` path in the Triton backward +# +# So an accelerator HVP cannot be compared against the oracle: there is no HVP to compare. +# What is testable, and tested below, is that those kernels **raise** rather than returning +# a silently wrong (or zero) second derivative -- the same failure mode this repo already +# fixed once for the C++ scatter, where a graph-less backward gave cosine 0.57 while first +# derivatives stayed correct. + +_DOUBLE_DIFFERENTIABLE = {"cpu_sphere", "portable"} + + +@pytest.mark.parametrize("kind", ["iso", "aniso"]) +@pytest.mark.parametrize("device,dtype,kernel", DEVICE_DTYPE_KERNELS) +def test_kernel_hvp_matches_ds(scene_fine, oracle_fine, device, dtype, kernel, kind): + """HVP of a double-differentiable kernel against the oracle's, on every device. + + Covers the portable splat running *on an accelerator*, which nothing tested before: + it is what ``Engine.EAGER`` and the CUDA/MPS float64 fallthrough actually execute, and + it is the path a Hessian-based optimizer lands on there. + """ + if kernel not in _DOUBLE_DIFFERENTIABLE: + pytest.skip(f"{kernel} is first-order only; see test_kernel_rejects_double_backward") + aniso = kind == "aniso" + scene = scene_fine.to(device=device, dtype=dtype) + obs = oracle_fine[f"{kind}_obs"].to(device=device, dtype=dtype) + v = oracle_fine[f"{kind}_v"].to(device=device, dtype=dtype) + _, occ, third = scene.leaves(aniso=aniso, requires_grad=False) + + def loss(x): + return H.ls_target( + H.density_to_F(scene, H.splat_direct(scene, kernel, x, occ, third, aniso=aniso)), + obs, + ) + + got = hvp(loss, scene.xyz, v) + ref = oracle_fine[f"{kind}_hvp"] + rel, cos = rel_error(got, ref), cosine_similarity(got, ref) + print(f"\n {device.type}/{dtype}/{kernel}/{kind}: HVP rel {rel:.3e} cos {cos:.8f}") + assert cos > COS_MIN, f"{device.type}/{dtype}/{kernel}/{kind}: HVP cos {cos:.6f}" + assert rel < RTOL_HVP, f"{device.type}/{dtype}/{kernel}/{kind}: HVP rel {rel:.3e}" + + +@pytest.mark.parametrize("kind", ["iso", "aniso"]) +@pytest.mark.parametrize("device,dtype,kernel", DEVICE_DTYPE_KERNELS) +def test_kernel_rejects_double_backward(scene_fine, oracle_fine, device, dtype, kernel, kind): + """First-order-only kernels must raise under ``create_graph=True``, not return garbage. + + The accelerator kernels have hand-written backwards that return tensors carrying no + graph, and neither is decorated ``@once_differentiable`` -- so nothing warns, and the + only signal is whatever autograd does when asked for a second derivative. Pinning that + it *raises* is what distinguishes "unsupported" from "silently wrong". + + The positive half of the pair is :func:`test_kernel_hvp_matches_ds`; together they say + which kernels an optimizer may take a Hessian through. + """ + if kernel in _DOUBLE_DIFFERENTIABLE: + pytest.skip(f"{kernel} supports double backward; see test_kernel_hvp_matches_ds") + aniso = kind == "aniso" + scene = scene_fine.to(device=device, dtype=dtype) + obs = oracle_fine[f"{kind}_obs"].to(device=device, dtype=dtype) + _, occ, third = scene.leaves(aniso=aniso, requires_grad=False) + + x = scene.xyz.clone().requires_grad_(True) + F = H.density_to_F(scene, H.splat_direct(scene, kernel, x, occ, third, aniso=aniso)) + (g1,) = torch.autograd.grad(H.ls_target(F, obs), x, create_graph=True) + + with pytest.raises(RuntimeError) as exc: + torch.autograd.grad((g1 * torch.ones_like(x)).sum(), x) + print(f"\n {device.type}/{dtype}/{kernel}/{kind} raised: {str(exc.value)[:70]}") diff --git a/tests/unit/test_gradient_correctness.py b/tests/unit/test_gradient_correctness.py index 28a2ff9..17de199 100644 --- a/tests/unit/test_gradient_correctness.py +++ b/tests/unit/test_gradient_correctness.py @@ -33,18 +33,10 @@ auto-skipped unless the host actually has a CUDA device. """ -import itertools -import tempfile import pytest import torch -from torchref.base.direct_summation.dispatch import ( - _checkpointed_aniso, - _checkpointed_iso, - _eager_aniso, - _eager_iso, -) from torchref.base.targets.bond import _bond_math_eager, bond_math from torchref.base.targets.xray_gaussian import ( _gaussian_xray_loss_math_eager, @@ -67,35 +59,9 @@ # These live in ``tests/helpers/grad_asserts.py`` so the accelerator kernel # comparison tests can share them; re-exported here because this module is where # they originated and several tests below use them unqualified. -from tests.helpers.grad_asserts import ( # noqa: E402 - _flat_real, - assert_grads_agree, - cosine_similarity, - got_ref_pairs, - gradnorm_ratio, -) +from tests.helpers.grad_asserts import assert_grads_agree # noqa: E402 # --- synthetic structure-factor inputs (P1, ~10-15 atoms) ------------------ -def _sf_inputs(N=12, R=18, dtype=torch.float64, device="cpu"): - g = torch.Generator().manual_seed(0) - hkl = torch.randint(-3, 4, (R, 3), generator=g).to(dtype=dtype, device=device) - s = (torch.rand(R, generator=g) * 0.4).to(dtype=dtype, device=device) - svec = (torch.randn(R, 3, generator=g) * 0.3).to(dtype=dtype, device=device) - A = torch.rand(N, 5, generator=g).to(dtype=dtype, device=device) - B = (torch.rand(N, 5, generator=g) + 0.5).to(dtype=dtype, device=device) - return hkl, s, svec, A, B - - -def _sf_leaves(N=12, dtype=torch.float64, device="cpu", requires_grad=True): - g = torch.Generator().manual_seed(1) - mk = lambda t: t.to(dtype=dtype, device=device).requires_grad_(requires_grad) - xyz = mk(torch.rand(N, 3, generator=g)) - occ = mk(torch.rand(N, generator=g) * 0.4 + 0.6) - adp = mk(torch.rand(N, generator=g) * 10 + 5) - U = mk(torch.rand(N, 6, generator=g) * 0.04 + 0.01) - return xyz, occ, adp, U - - def test_gaussian_xray_gradcheck(double_cpu): """Gaussian X-ray NLL (GaussianXrayTarget.forward body): d/d(F_obs, F_calc). @@ -150,64 +116,6 @@ def f(x): # 4. Optimized SF path (hand-written backward) vs eager autograd # Metric: cosine similarity + gradnorm ratio (CPU, always runs). # ============================================================================= -def _scalar(F): - """A non-trivial real scalar of a complex SF vector (mixes re & im).""" - return (F.real**2 + 2.0 * F.imag).sum() - - -def _grads(fn, leaves): - """Gradients of ``_scalar(fn(*leaves))`` w.r.t. each leaf.""" - out = _scalar(fn(*leaves)) - return torch.autograd.grad(out, leaves) - - - - -@pytest.mark.cuda -def test_triton_sf_iso_matches_eager_cosine(): - """DS isotropic Triton kernel gradient == eager autograd (CUDA float32).""" - from torchref.base.direct_summation.dispatch import ds_iso - from torchref.utils import Engine - - dev = "cuda" - hkl, s, _, A, B = _sf_inputs(dtype=torch.float32, device=dev) - xyz, occ, adp, _ = _sf_leaves(dtype=torch.float32, device=dev) - xyz2, occ2, adp2, _ = _sf_leaves(dtype=torch.float32, device=dev) - - g_triton = _grads( - lambda x, o, a: ds_iso(hkl, s, x, o, a, A, B, engine=Engine.TRITON), - (xyz, occ, adp), - ) - g_eager = _grads( - lambda x, o, a: _eager_iso(hkl, s, x, o, a, A, B, max_memory_gb=2.0), - (xyz2, occ2, adp2), - ) - # Looser thresholds: float32 + distinct kernel arithmetic. - assert_grads_agree(g_triton, g_eager, min_cos=0.999, ratio_tol=1e-2, ctx="iso ") - - -@pytest.mark.cuda -def test_triton_sf_aniso_matches_eager_cosine(): - """DS anisotropic Triton kernel gradient == eager autograd (CUDA float32).""" - from torchref.base.direct_summation.dispatch import ds_aniso - from torchref.utils import Engine - - dev = "cuda" - hkl, _, svec, A, B = _sf_inputs(dtype=torch.float32, device=dev) - xyz, occ, _, U = _sf_leaves(dtype=torch.float32, device=dev) - xyz2, occ2, _, U2 = _sf_leaves(dtype=torch.float32, device=dev) - - g_triton = _grads( - lambda x, o, u: ds_aniso(hkl, svec, x, o, u, A, B, engine=Engine.TRITON), - (xyz, occ, U), - ) - g_eager = _grads( - lambda x, o, u: _eager_aniso(hkl, svec, x, o, u, A, B, max_memory_gb=2.0), - (xyz2, occ2, U2), - ) - assert_grads_agree(g_triton, g_eager, min_cos=0.999, ratio_tol=1e-2, ctx="aniso ") - - @pytest.mark.cuda def test_triton_gaussian_xray_matches_eager_cosine(): """Gaussian X-ray Triton kernel gradient == eager autograd (CUDA float32).""" diff --git a/tests/unit/test_kernel_fixes.py b/tests/unit/test_kernel_fixes.py index 71208bc..362a53b 100644 --- a/tests/unit/test_kernel_fixes.py +++ b/tests/unit/test_kernel_fixes.py @@ -26,33 +26,6 @@ # --------------------------------------------------------------------------- # Fix 5 — None scattering factors on the non-batched eager path # --------------------------------------------------------------------------- -def test_eager_sf_none_scattering_no_batch(double_cpu): - """`max_memory_gb=None` + `scattering_factors=None` computes from A/B.""" - from torchref.base.direct_summation.dispatch import _eager_aniso, _eager_iso - - g = torch.Generator().manual_seed(0) - N, R = 8, 12 - hkl = torch.randint(-3, 4, (R, 3), generator=g).double() - s = torch.rand(R, generator=g).double() * 0.4 - svec = torch.randn(R, 3, generator=g).double() * 0.3 - A = torch.rand(N, 5, generator=g).double() - B = torch.rand(N, 5, generator=g).double() + 0.5 - xyz = torch.rand(N, 3, generator=g).double() - occ = torch.rand(N, generator=g).double() * 0.4 + 0.6 - adp = torch.rand(N, generator=g).double() * 10 + 5 - U = torch.rand(N, 6, generator=g).double() * 0.04 + 0.01 - - f_none = _eager_iso(hkl, s, xyz, occ, adp, A, B, max_memory_gb=None) - f_batch = _eager_iso(hkl, s, xyz, occ, adp, A, B, max_memory_gb=2.0) - assert torch.isfinite(f_none).all() - assert torch.allclose(f_none, f_batch) - - a_none = _eager_aniso(hkl, svec, xyz, occ, U, A, B, max_memory_gb=None) - a_batch = _eager_aniso(hkl, svec, xyz, occ, U, A, B, max_memory_gb=2.0) - assert torch.isfinite(a_none).all() - assert torch.allclose(a_none, a_batch) - - # --------------------------------------------------------------------------- # Fix 3 — eager geometry math is NaN-safe at degenerate geometry # --------------------------------------------------------------------------- diff --git a/torchref/base/electron_density/kernels/cuda/variable_radius.py b/torchref/base/electron_density/kernels/cuda/variable_radius.py index 8c9ba5a..2bc2f60 100644 --- a/torchref/base/electron_density/kernels/cuda/variable_radius.py +++ b/torchref/base/electron_density/kernels/cuda/variable_radius.py @@ -811,3 +811,85 @@ def backward(ctx, grad_density_map): # out = density_map + splat -> grad wrt density_map is identity. return (grad_density_map, None, grad_xyz, grad_u, grad_occ, None, None, None, None, None, None) + + +# --------------------------------------------------------------------------- +# Canonical-signature wrappers +# --------------------------------------------------------------------------- +# Every production splat exposes the same signature: +# +# (density_map, xyz, adp_or_u, occ, A, B, inv_frac_matrix, frac_matrix, +# radius_per_atom) +# +# so the dispatch in ``electron_density/main.py`` is four structurally identical +# calls per ladder and a test can drive any backend through one code path. These +# wrappers do for CUDA what ``add_*_mps_var`` already did for Metal: square the +# radius and build the coefficient mask, rather than leaving that to the caller. +# +# ``density_map`` is passed where the ``autograd.Function`` wants +# ``real_space_grid``. That is exact, not a convenience: ``forward``/``backward`` +# use that argument only for ``.shape[:3]`` and for a ``grid_flat`` pointer handed +# to the kernel as ``grid_ptr`` -- which none of the four Triton kernels ever +# loads, because voxel coordinates are derived arithmetically from ``frac`` and the +# grid dims. ``density_map`` has the same ``(nx, ny, nz)`` shape, so both uses are +# satisfied. If a kernel is ever changed to actually read ``grid_ptr``, this breaks +# and the right fix is to delete that dead parameter, not to thread a real grid +# back through here. + + +def _coeff_mask(xyz): + """All-ones per-atom coefficient mask, shape ``(n, 5)``. + + Every call site in the codebase passes all ones -- the mask exists so a caller + *could* disable individual ITC92 Gaussians, and nothing does. Kept because it is + part of the kernel's argument list. + """ + return torch.ones(xyz.shape[0], 5, dtype=xyz.dtype, device=xyz.device) + + +def add_isotropic_cuda_var( + density_map, xyz, adp, occ, A, B, inv_frac_matrix, frac_matrix, radius_per_atom +): + """Isotropic variable-radius Triton splat; returns ``density_map + splat``. + + Canonical splat signature, identical to ``add_isotropic_plain_var``, + ``add_isotropic_cpu_sphere_var`` and ``add_isotropic_mps_var``. CUDA float32 + only -- the gate is ``should_use_triton``; this wrapper does not re-check. + """ + return WorkQueueGridDensity.apply( + density_map, + density_map, # stands in for real_space_grid; see the note above + xyz, + adp, + occ, + A, + B, + radius_per_atom * radius_per_atom, + _coeff_mask(xyz), + inv_frac_matrix, + frac_matrix, + ) + + +def add_anisotropic_cuda_var( + density_map, xyz, u, occ, A, B, inv_frac_matrix, frac_matrix, radius_per_atom +): + """Anisotropic variable-radius Triton splat; returns ``density_map + splat``. + + Canonical splat signature. ``u`` carries ``[U11, U22, U33, U12, U13, U23]``; the + cutoff stays the Euclidean sphere at ``radius_per_atom`` while the density is the + full Mahalanobis form, matching the Metal and fused-CPU kernels. + """ + return WorkQueueGridDensityAniso.apply( + density_map, + density_map, # stands in for real_space_grid; see the note above + xyz, + u, + occ, + A, + B, + radius_per_atom * radius_per_atom, + _coeff_mask(xyz), + inv_frac_matrix, + frac_matrix, + ) diff --git a/torchref/base/electron_density/kernels/mps/variable_radius.py b/torchref/base/electron_density/kernels/mps/variable_radius.py index c740798..eecd9a1 100644 --- a/torchref/base/electron_density/kernels/mps/variable_radius.py +++ b/torchref/base/electron_density/kernels/mps/variable_radius.py @@ -99,14 +99,15 @@ def backward(ctx, grad_out): def add_isotropic_mps_var( - density_map, xyz, adp, occ, A, B, - inv_frac_matrix, frac_matrix, grid_shape_tuple, voxel_size, radius_per_atom, + density_map, xyz, adp, occ, A, B, inv_frac_matrix, frac_matrix, radius_per_atom ): """Isotropic variable-radius Metal splat; adds into ``density_map``. - Signature mirrors ``add_isotropic_plain_var`` (``grid_shape_tuple`` and - ``voxel_size`` are unused -- the grid shape comes from ``density_map`` and the - truncation box from the inverse-cell row norms). + Signature is the canonical splat signature, identical to + ``add_isotropic_plain_var`` and ``add_isotropic_cpu_sphere_var`` -- the grid shape + comes from ``density_map`` and the truncation box from the inverse-cell row norms, + so no ``grid_shape_tuple`` or ``voxel_size`` is needed. Both used to be accepted + and ignored. The truncation radius is the policy radius, used raw; see :func:`_r2cut`. """ @@ -180,15 +181,19 @@ def backward(ctx, grad_out): def add_anisotropic_mps_var( - real_space_grid, density_map, xyz, u, occ, A, B, - inv_frac_matrix, frac_matrix, radius_per_atom, voxel_size, + density_map, xyz, u, occ, A, B, inv_frac_matrix, frac_matrix, radius_per_atom ): """Anisotropic variable-radius Metal splat; adds into ``density_map``. - Signature mirrors ``add_anisotropic_plain_var`` (``real_space_grid`` is used - only for its grid shape). Each atom is truncated at its per-axis bounding box - with a sphere cull at the per-atom radius -- the same canonical cutoff the - CUDA and fused-CPU kernels apply. + Signature is the canonical splat signature, identical to + ``add_anisotropic_plain_var`` and ``add_anisotropic_cpu_sphere_var``. It used to + take ``real_space_grid`` first and ``voxel_size`` last; neither was read -- the + grid shape comes from ``density_map``, which is what gets forwarded to + ``MetalGridDensityAniso``. The old docstring claimed ``real_space_grid`` was "used + only for its grid shape", which had stopped being true. + + Each atom is truncated at its per-axis bounding box with a sphere cull at the + per-atom radius -- the same canonical cutoff the CUDA and fused-CPU kernels apply. """ r2cut = _r2cut(radius_per_atom) mask = torch.ones(xyz.shape[0], 5, dtype=xyz.dtype, device=xyz.device) diff --git a/torchref/base/electron_density/main.py b/torchref/base/electron_density/main.py index 32ece4c..639b3ee 100644 --- a/torchref/base/electron_density/main.py +++ b/torchref/base/electron_density/main.py @@ -84,10 +84,13 @@ # --- Per-atom variable-radius density path --- # The splat radius is no longer a single scalar; each atom is truncated at its own -# N_sigma * sigma_eff radius (N_sigma = torchref.sigma_cutoff_ed). CUDA+float32 uses -# the variable-radius Triton kernels (WorkQueueGridDensity{,Aniso}); CPU + AUTO uses -# the grouped-separable structured-scatter splat; everything else (EAGER any device, -# CUDA float64, MPS) uses the portable plain-scatter splat. +# N_sigma * sigma_eff radius (N_sigma = torchref.sigma_cutoff_ed). Every wrapper below +# takes the same canonical signature +# (density_map, xyz, adp_or_u, occ, A, B, inv_frac_matrix, frac_matrix, radius_per_atom) +# so the two dispatch ladders differ only in which kernel they name. CUDA+float32 -> +# the Triton kernels; CPU+AUTO -> the fused C++ sphere splat; MPS+float32 -> Metal; +# everything else (EAGER any device, CUDA/MPS float64) -> the portable plain-scatter +# splat. from torchref.base.electron_density.radius_policy import ( per_atom_radius_aniso, per_atom_radius_iso, @@ -95,6 +98,8 @@ from torchref.base.electron_density.kernels.cuda.variable_radius import ( WorkQueueGridDensity, WorkQueueGridDensityAniso, + add_anisotropic_cuda_var, + add_isotropic_cuda_var, ) from torchref.base.electron_density.kernels.cpu.variable_radius import ( add_anisotropic_cpu_var, @@ -134,10 +139,10 @@ def build_electron_density( per-atom truncation radius (``N_sigma * sigma_eff``, with ``N_sigma = torchref.sigma_cutoff_ed``) via the per-atom variable-radius kernels: the CUDA float32 work-queue kernels - (``WorkQueueGridDensity{,Aniso}``) under ``Engine.AUTO``/``Engine.TRITON``, - the grouped-separable variable-radius splat on CPU+AUTO, the native Metal - kernels on MPS+float32 under ``Engine.AUTO``/``Engine.METAL``, and the - portable plain-scatter variable-radius splat everywhere else + (``add_isotropic_cuda_var`` / ``add_anisotropic_cuda_var``) under + ``Engine.AUTO``/``Engine.TRITON``, the fused C++ sphere splat on CPU+AUTO, the + native Metal kernels on MPS+float32 under ``Engine.AUTO``/``Engine.METAL``, and + the portable plain-scatter variable-radius splat everywhere else (``Engine.EAGER``, CUDA/MPS float64). Parameters @@ -157,8 +162,11 @@ def build_electron_density( frac_matrix : torch.Tensor Fractional-to-Cartesian matrix, shape (3, 3). voxel_size : torch.Tensor - Voxel dimensions, shape (3,). The per-atom truncation radius is derived - internally from each atom's B/U and ``torchref.sigma_cutoff_ed``. + Voxel dimensions, shape (3,). **Unused by every splat** -- the per-atom + truncation radius comes from each atom's B/U and ``torchref.sigma_cutoff_ed`` + via :mod:`~torchref.base.electron_density.radius_policy`, and the enumeration + box from ``inv_frac_matrix``. Retained because ``SfFFT`` passes it + positionally; dropping it is a wider API change. xyz_aniso : torch.Tensor, optional Anisotropic atom positions, shape (n_aniso, 3). u_aniso : torch.Tensor, optional @@ -188,7 +196,6 @@ def build_electron_density( # --- isotropic atoms --- if len(xyz_iso) > 0: density_map = _add_isotropic( - real_space_grid, density_map, xyz_iso, adp_iso, @@ -197,13 +204,11 @@ def build_electron_density( B_iso, inv_frac_matrix, frac_matrix, - voxel_size, ) # --- anisotropic atoms --- if xyz_aniso is not None and len(xyz_aniso) > 0: density_map = _add_anisotropic( - real_space_grid, density_map, xyz_aniso, u_aniso, @@ -212,7 +217,6 @@ def build_electron_density( B_aniso, inv_frac_matrix, frac_matrix, - voxel_size, ) return density_map @@ -224,7 +228,6 @@ def build_electron_density( def _add_isotropic( - real_space_grid, density_map, xyz, adp, @@ -233,7 +236,6 @@ def _add_isotropic( B, inv_frac_matrix, frac_matrix, - voxel_size, ): """Add isotropic atoms with a per-atom variable radius. ``Engine`` is the switch. @@ -260,30 +262,21 @@ def _add_isotropic( n_sigma = get_sigma_cutoff_ed() radius_per_atom = per_atom_radius_iso(adp, B, n_sigma=n_sigma) + # Every branch below takes the same canonical splat signature, so these differ + # only in which kernel they name. Radius squaring and coefficient-mask + # construction live inside each wrapper, not here. if should_use_triton(xyz): try: - r2cut = radius_per_atom * radius_per_atom - coeff_mask = torch.ones(xyz.shape[0], 5, dtype=xyz.dtype, device=xyz.device) # kernel accumulates into (a copy of) density_map -> no extra grid buffer + add - return WorkQueueGridDensity.apply( - density_map, - real_space_grid, - xyz, - adp, - occ, - A, - B, - r2cut, - coeff_mask, - inv_frac_matrix, - frac_matrix, + return add_isotropic_cuda_var( + density_map, xyz, adp, occ, A, B, + inv_frac_matrix, frac_matrix, radius_per_atom, ) except Exception: if get_engine() is Engine.TRITON: raise # AUTO: fall through to the portable splat - grid_shape_tuple = real_space_grid.shape[:3] # Fused C++ spherical-cutoff splat: the CPU production path, float32 AND float64 # (the kernel is templated on the scalar type, so a float64 config no longer # drops to the slow portable splat). ``should_use_sphere_splat`` owns the @@ -307,7 +300,7 @@ def _add_isotropic( try: return add_isotropic_mps_var( density_map, xyz, adp, occ, A, B, - inv_frac_matrix, frac_matrix, grid_shape_tuple, voxel_size, radius_per_atom, + inv_frac_matrix, frac_matrix, radius_per_atom, ) except Exception: if get_engine() is Engine.METAL: @@ -320,7 +313,6 @@ def _add_isotropic( def _add_anisotropic( - real_space_grid, density_map, xyz, u, @@ -329,7 +321,6 @@ def _add_anisotropic( B, inv_frac_matrix, frac_matrix, - voxel_size, ): """Add anisotropic atoms with a per-atom variable radius (mirrors the iso path). @@ -352,23 +343,14 @@ def _add_anisotropic( n_sigma = get_sigma_cutoff_ed() radius_per_atom = per_atom_radius_aniso(B, u, n_sigma=n_sigma) + # As in _add_isotropic: one canonical signature, so the branches differ only in + # which kernel they name. if should_use_triton(xyz): try: - r2cut = radius_per_atom * radius_per_atom - coeff_mask = torch.ones(xyz.shape[0], 5, dtype=xyz.dtype, device=xyz.device) # kernel accumulates into (a copy of) density_map -> no extra grid buffer + add - return WorkQueueGridDensityAniso.apply( - density_map, - real_space_grid, - xyz, - u, - occ, - A, - B, - r2cut, - coeff_mask, - inv_frac_matrix, - frac_matrix, + return add_anisotropic_cuda_var( + density_map, xyz, u, occ, A, B, + inv_frac_matrix, frac_matrix, radius_per_atom, ) except Exception: if get_engine() is Engine.TRITON: @@ -391,8 +373,8 @@ def _add_anisotropic( try: return add_anisotropic_mps_var( - real_space_grid, density_map, xyz, u, occ, A, B, - inv_frac_matrix, frac_matrix, radius_per_atom, voxel_size, + density_map, xyz, u, occ, A, B, + inv_frac_matrix, frac_matrix, radius_per_atom, ) except Exception: if get_engine() is Engine.METAL: From 0917e94e582ae56b486b2af6d5f744c2dd0d8dd9 Mon Sep 17 00:00:00 2001 From: HatPdotS Date: Thu, 30 Jul 2026 05:23:38 +0300 Subject: [PATCH 10/15] Formalized backend switching structure --- tests/integration/test_device_mixin.py | 21 +- tests/scripts/submit_gpu_tests.sbatch | 12 +- tests/unit/base/test_canonical_sphere_cpu.py | 75 ++- tests/unit/base/test_kernel_import_shims.py | 21 +- tests/unit/structure_factor/helpers.py | 124 ++-- tests/unit/structure_factor/test_dispatch.py | 59 +- .../unit/structure_factor/test_ds_dispatch.py | 54 ++ .../structure_factor/test_second_order.py | 9 +- tests/unit/test_imports_smoke.py | 46 ++ tests/unit/utils/test_backend_tables.py | 168 ++++++ tests/unit/utils/test_backends.py | 394 +++++++++++++ tests/unit/utils/test_triton_dispatch.py | 60 ++ torchref/base/direct_summation/_backends.py | 130 ++++ torchref/base/direct_summation/dispatch.py | 110 ++-- torchref/base/electron_density/_backends.py | 108 ++++ .../base/electron_density/kernels/__init__.py | 36 +- .../electron_density/kernels/cpu/aniso.py | 63 -- .../electron_density/kernels/cpu/scatter.py | 558 ------------------ .../kernels/cpu/scatter_dispatch.py | 80 --- .../electron_density/kernels/cpu/separable.py | 155 ----- .../kernels/cpu/sphere_splat.py | 52 +- .../kernels/cpu/variable_radius.py | 276 +-------- .../kernels/cuda/variable_radius.py | 21 + .../electron_density/kernels/mps/compile.py | 26 +- torchref/base/electron_density/main.py | 264 ++------- torchref/base/kernels/__init__.py | 22 +- torchref/base/targets/_dispatch.py | 102 +++- torchref/model/sf_ds.py | 15 +- torchref/restraints/hydrogen_topology.py | 12 +- torchref/utils/backends.py | 402 +++++++++++++ torchref/utils/triton_dispatch.py | 121 ++-- 31 files changed, 1911 insertions(+), 1685 deletions(-) create mode 100644 tests/unit/utils/test_backend_tables.py create mode 100644 tests/unit/utils/test_backends.py create mode 100644 torchref/base/direct_summation/_backends.py create mode 100644 torchref/base/electron_density/_backends.py delete mode 100644 torchref/base/electron_density/kernels/cpu/aniso.py delete mode 100644 torchref/base/electron_density/kernels/cpu/scatter.py delete mode 100644 torchref/base/electron_density/kernels/cpu/scatter_dispatch.py delete mode 100644 torchref/base/electron_density/kernels/cpu/separable.py create mode 100644 torchref/utils/backends.py diff --git a/tests/integration/test_device_mixin.py b/tests/integration/test_device_mixin.py index 788c1dc..b538e10 100644 --- a/tests/integration/test_device_mixin.py +++ b/tests/integration/test_device_mixin.py @@ -64,13 +64,20 @@ def test_modelft_cpu_gpu_cpu_sf_round_trip(sample_pdb_file, sample_mtz_file): for name, buf in model.named_buffers(): assert buf.device.type == "cuda", f"buffer {name} left behind on CPU" - # Values must agree within numerical tolerance after the round-trip. The CPU - # (C++ box-separable splat) and GPU (Triton work-queue splat) paths differ - # slightly in truncation shape (box vs sphere), which surfaces mostly on the - # strongest low-resolution reflections in absolute terms. Aggregate agreement - # is excellent (~0.3% RMS, ~0.12% R-factor), so use a combined - # relative-OR-absolute tolerance rather than max-abs-vs-mean, which a single - # very strong reflection (|F| >> mean |F|) can trip on a tiny relative error. + # Values must agree within numerical tolerance after the round-trip. + # + # This tolerance is loose for a reason that no longer applies. It was set when the CPU + # path was a box-separable splat and the GPU path a sphere-truncated work-queue kernel, + # so the two genuinely differed in truncation shape (~0.3% RMS, ~0.12% R-factor, + # concentrated on the strongest low-resolution reflections). Every production kernel now + # applies the identical spherical cutoff -- see ``electron_density/main.py`` -- so the + # residual should be float32 kernel arithmetic only, which is far smaller. + # + # It is deliberately NOT tightened here: this test is CUDA-only and has not been run + # since the contract was unified, so any number picked now would be a guess. Re-measure + # on a GPU host and tighten then. The combined relative-OR-absolute form stays either + # way -- max-abs-vs-mean trips on a single very strong reflection (|F| >> mean |F|) for + # a tiny relative error. fcalc_cuda_cpu = fcalc_cuda.cpu() magnitude = fcalc_cpu_initial.abs().mean().item() max_abs_diff = (fcalc_cpu_initial - fcalc_cuda_cpu).abs().max().item() diff --git a/tests/scripts/submit_gpu_tests.sbatch b/tests/scripts/submit_gpu_tests.sbatch index 9d91951..66435d9 100644 --- a/tests/scripts/submit_gpu_tests.sbatch +++ b/tests/scripts/submit_gpu_tests.sbatch @@ -50,9 +50,17 @@ echo "==========================================" echo "Running GPU tests..." echo "==========================================" -# Run GPU tests +# Run GPU tests. +# +# ``gpu or cuda`` rather than ``gpu`` alone: a mark expression *deselects* at collection +# time, so ``-m "gpu"`` never even collected the `cuda`-marked tests -- which is most of the +# accelerator coverage, including every direct-call kernel leg and the Triton provenance and +# gate tests. They were silently absent from this job rather than failing in it. +# +# ``mps`` is deliberately not included: this is a CUDA cluster, and those tests are skipped +# by ``pytest_collection_modifyitems`` on a host without Metal anyway. python -m pytest tests/ \ - -m "gpu" \ + -m "gpu or cuda" \ --cov=torchref \ --cov-report=term-missing \ -v \ diff --git a/tests/unit/base/test_canonical_sphere_cpu.py b/tests/unit/base/test_canonical_sphere_cpu.py index 240a75d..30b55e6 100644 --- a/tests/unit/base/test_canonical_sphere_cpu.py +++ b/tests/unit/base/test_canonical_sphere_cpu.py @@ -324,37 +324,33 @@ def test_cutoff_is_grid_independent(): @pytest.mark.parametrize("dtype", [torch.float32, torch.float64], ids=["float32", "float64"]) -def test_auto_actually_dispatches_the_fused_kernel(dtype): +def test_auto_actually_dispatches_the_fused_kernel(dtype, monkeypatch): """Guard against the AUTO-vs-EAGER tests going vacuous. - If ``should_use_sphere_splat`` ever stopped firing, AUTO would fall through to - the very same portable splat EAGER uses and every equivalence assertion above - would pass at ``rel_l2 == 0`` while measuring nothing -- the exact failure mode - the deleted MPS accelerator test documented for its own Metal gate. float64 is - parametrized because the old dispatch was float32-only, so a regression to a - dtype gate would be invisible on float32 alone. + If the ``cpu_sphere`` row ever stopped matching, AUTO would fall through to the very + same portable splat EAGER uses and every equivalence assertion above would pass at + ``rel_l2 == 0`` while measuring nothing. float64 is parametrized because the dispatch + was once float32-only, so a regression to a dtype gate would be invisible on float32 + alone. - ``main`` is patched, not the kernel module: the dispatch site resolves the name - from its own module globals at import time. + The kernel's **defining module** is patched -- the same rule as every other provenance + test, since dispatch resolves each kernel by ``(module_path, attr)`` at call time. This + used to patch ``main`` instead, because the ladder there resolved the name from its own + globals. """ - import torchref.base.electron_density.main as main_mod - if not sphere_splat.sphere_splat_available(): pytest.skip("fused CPU splat unavailable") frac, inv_frac, dims, f64 = _cell(100.0, dtype=dtype) calls = [] - real = main_mod.add_isotropic_cpu_sphere_var + real = sphere_splat.add_isotropic_cpu_sphere_var def recording(*args, **kwargs): calls.append(1) return real(*args, **kwargs) - main_mod.add_isotropic_cpu_sphere_var = recording - try: - _build(Engine.AUTO, dims, frac, inv_frac, _voxel_size(f64, dims), dtype, - iso=_iso_atoms(f64, dtype=dtype)) - finally: - main_mod.add_isotropic_cpu_sphere_var = real + monkeypatch.setattr(sphere_splat, "add_isotropic_cpu_sphere_var", recording) + _build(Engine.AUTO, dims, frac, inv_frac, _voxel_size(f64, dims), dtype, + iso=_iso_atoms(f64, dtype=dtype)) assert calls, f"Engine.AUTO did not reach the fused CPU splat for {dtype}" @@ -385,12 +381,11 @@ def test_density_map_accumulates_not_overwrites(): # Ported from now-deleted tests of orphaned kernels # ========================================================================= # ``tests/unit/base/test_aniso_map_building.py`` and ``test_cpu_scatter.py`` covered the -# legacy fixed-radius splat and the C++ structured scatter. Both are now unreachable from -# ``build_electron_density``: the dispatch routes CPU to the fused sphere splat or the -# portable one, so ``add_isotropic_cpu_separable_var`` / ``add_anisotropic_cpu_var`` -- and -# with them ``_separable_density``, ``_aniso_density_cube`` and the structured scatter -- -# are imported but never called. The source is deliberately retained; the tests were -# deleted, and the two checks worth keeping are re-pointed at the live path here. +# legacy fixed-radius splat and the C++ structured scatter. The dispatch routes CPU to the +# fused sphere splat or the portable one, so that whole chain -- the grouped-separable and +# cube splats, the separable density core, the structured scatter -- became unreachable +# from ``build_electron_density`` and has since been deleted. The two checks worth keeping +# are re-pointed at the live path here. @pytest.mark.parametrize("beta", _BETAS) @@ -496,6 +491,38 @@ def test_fused_kernel_is_thread_invariant(n_threads): ) +def test_fused_gate_requires_one_shared_dtype(): + """The fused kernel takes a *uniform* dtype, not any mix of f32 and f64. + + The C++ selects one ``scalar_t`` from the output map via + ``AT_DISPATCH_FLOATING_TYPES(out.scalar_type(), ...)`` and then reads every other + tensor through a raw pointer of that type. So a float64 map beside float32 atoms would + reinterpret the coordinate buffer as doubles -- garbage values and a 2x out-of-bounds + read -- and the gate has to refuse it. + + Written down because the rule is easy to get wrong when it is restated as a set of + permitted dtypes: "each tensor's dtype is in {f32, f64}" *admits* the mixed case, while + the actual requirement is "all tensors share one dtype drawn from {f32, f64}". The two + read almost identically and only one is memory-safe. In the table that difference is the + ``require_uniform_dtype`` flag, and this asserts it against the row that ships. + """ + from torchref.base.electron_density._backends import DENSITY_BACKENDS + + row = DENSITY_BACKENDS.by_name("cpu_sphere") + f64 = torch.zeros(4, dtype=torch.float64) + f32 = torch.zeros(4, dtype=torch.float32) + six = lambda *ts: list(ts) + [ts[-1]] * (6 - len(ts)) # noqa: E731 + + # Uniform sets satisfy the device/dtype contract. + assert row.mismatch(six(f32, f32, f32)) is None + assert row.mismatch(six(f64, f64, f64)) is None + + # Mixed sets never do, and the message says why. + for mixed in (six(f64, f32, f32), six(f32, f64, f32)): + why = row.mismatch(mixed) + assert why is not None and "single dtype" in why, why + + def test_fused_extension_compiles(): """The fused sphere splat must actually build. Fails rather than skipping. diff --git a/tests/unit/base/test_kernel_import_shims.py b/tests/unit/base/test_kernel_import_shims.py index 4bb8fc7..bbb0eee 100644 --- a/tests/unit/base/test_kernel_import_shims.py +++ b/tests/unit/base/test_kernel_import_shims.py @@ -59,19 +59,20 @@ def test_math_torch_legacy_reexports_resolve(): def test_main_namespace_preserves_moved_symbols(): - """``main`` re-exports the shared splat helpers so its namespace is stable. + """``main`` keeps the names other modules and tests reach for. - These are the LIVE helpers reused by the variable-radius kernels (and - ``_get_radius_offsets`` by ``torchref.scaling.solvent``); the old fixed-radius - entry points were removed in the kernel cleanup. + Only two things need to resolve here now. ``_get_radius_offsets`` because + ``torchref.scaling.solvent`` imports it from ``main`` rather than from its defining + module, and the two dispatchers because they are ``main``'s own API. + + The list used to also carry ``_do_structured_scatter``, ``_get_cpp_scatter``, + ``_separable_density`` and ``_aniso_density_cube``. Those were re-exported for a + grouped-separable/cube splat chain that the fused sphere kernel superseded; nothing + called it, and it has been deleted along with the four modules behind it. """ main = importlib.import_module("torchref.base.electron_density.main") moved = [ "_get_radius_offsets", - "_do_structured_scatter", - "_get_cpp_scatter", - "_separable_density", - "_aniso_density_cube", # dispatchers stay defined here "_add_isotropic", "_add_anisotropic", @@ -90,10 +91,6 @@ def test_solvent_radius_offsets_import_path(): [ "torchref.base.electron_density.kernels", "torchref.base.electron_density.kernels.offsets", - "torchref.base.electron_density.kernels.cpu.separable", - "torchref.base.electron_density.kernels.cpu.aniso", - "torchref.base.electron_density.kernels.cpu.scatter", - "torchref.base.electron_density.kernels.cpu.scatter_dispatch", "torchref.base.electron_density.kernels.cpu.jit_reference", "torchref.base.electron_density.kernels.cpu.variable_radius", # CUDA/Triton backend: importing pulls in `triton`, absent on non-CUDA diff --git a/tests/unit/structure_factor/helpers.py b/tests/unit/structure_factor/helpers.py index 24ce062..582b12e 100644 --- a/tests/unit/structure_factor/helpers.py +++ b/tests/unit/structure_factor/helpers.py @@ -23,7 +23,9 @@ import torch +from torchref.base.direct_summation._backends import DS_BACKENDS from torchref.base.direct_summation.dispatch import _eager_aniso, _eager_iso +from torchref.base.electron_density._backends import DENSITY_BACKENDS from torchref.base.reciprocal import get_scattering_vectors, reciprocal_basis_matrix from torchref.base.scattering.scattering_table import get_scattering_params_by_z from torchref.symmetry.cell import Cell @@ -452,44 +454,28 @@ def sf_fft_for( # # The dispatch ladder is a separate concern, tested in ``test_dispatch.py``. -#: ``name -> (fn, kind, devices, dtypes)``. All six wrappers share one signature, +#: The production density table, used directly rather than mirrored. +#: +#: This was a hand-maintained copy -- ``name, device, dtypes`` for each of the four splat +#: wrappers -- and copies of a policy drift. It already had: it encoded the six-tensor dtype +#: rule while the production Triton gate probed one tensor. Deriving the parametrization from +#: the table that ships means the matrix cannot claim coverage the dispatch does not have. +#: +#: All four wrappers share one signature, #: ``(density_map, xyz, adp_or_u, occ, A, B, inv_frac, frac, radius_per_atom)``, so #: :func:`splat_direct` needs no per-kernel adapter. They did not before the #: standardization -- the Metal pair took extra unused arguments in a different order, #: and CUDA had no wrapper at all. -_KERNEL_SPECS = ( - # name device dtypes kind - ("cpu_sphere", "cpu", (torch.float32, torch.float64), "both"), - ("portable", "any", (torch.float32, torch.float64), "both"), - ("mps_metal", "mps", (torch.float32,), "both"), - ("cuda_triton", "cuda", (torch.float32,), "both"), -) +_KERNEL_TABLE = DENSITY_BACKENDS def splat_kernel(name: str, aniso: bool): - """Resolve a kernel name to its wrapper. Imports are local per backend. + """Resolve a kernel name to its wrapper, via the production table. - Backend imports stay inside the function: the Metal module loads MSL source and the - CUDA one imports Triton, neither of which a CPU-only host should pay for at - collection time. + ``Backend.resolve`` does the import lazily, which is what a CPU-only host needs at + collection time: the Metal module loads MSL source and the CUDA one imports Triton. """ - if name == "cpu_sphere": - from torchref.base.electron_density.kernels.cpu import sphere_splat as m - - return m.add_anisotropic_cpu_sphere_var if aniso else m.add_isotropic_cpu_sphere_var - if name == "portable": - from torchref.base.electron_density.kernels.cpu import variable_radius as m - - return m.add_anisotropic_plain_var if aniso else m.add_isotropic_plain_var - if name == "mps_metal": - from torchref.base.electron_density.kernels.mps import variable_radius as m - - return m.add_anisotropic_mps_var if aniso else m.add_isotropic_mps_var - if name == "cuda_triton": - from torchref.base.electron_density.kernels.cuda import variable_radius as m - - return m.add_anisotropic_cuda_var if aniso else m.add_isotropic_cuda_var - raise ValueError(f"unknown kernel {name!r}") + return _KERNEL_TABLE.by_name(name).resolve(aniso) def device_supports_dtype(device: torch.device, dtype: torch.dtype) -> bool: @@ -505,24 +491,34 @@ def device_supports_dtype(device: torch.device, dtype: torch.dtype) -> bool: return True -def kernels_for(device: torch.device, dtype: torch.dtype): - """Kernel names that actually run on this ``(device, dtype)``. +def _table_names_for(table, device: torch.device, dtype: torch.dtype): + """Backends in ``table`` whose declared device/dtype envelope covers this pair. - Filtering here rather than skipping inside the test keeps a wrong entry visible: an - unsupported combination produces no test rather than a passing one. + Reads ``Backend.device`` / ``Backend.dtypes`` rather than calling ``select``: the point + is to enumerate every kernel that *can* run here so each gets its own direct-call + accuracy test, whereas ``select`` answers the narrower question of which one wins. """ if not device_supports_dtype(device, dtype): return [] out = [] - for name, dev, dtypes_ok, _ in _KERNEL_SPECS: - if dtype not in dtypes_ok: + for b in table.backends: + if b.device is not None and b.device != device.type: continue - if dev != "any" and dev != device.type: + if b.dtypes is not None and dtype not in b.dtypes: continue - out.append(name) + out.append(b.name) return out +def kernels_for(device: torch.device, dtype: torch.dtype): + """Kernel names that actually run on this ``(device, dtype)``. + + Filtering here rather than skipping inside the test keeps a wrong entry visible: an + unsupported combination produces no test rather than a passing one. + """ + return _table_names_for(_KERNEL_TABLE, device, dtype) + + def splat_direct(scene: Scene, name: str, xyz=None, occ=None, third=None, *, aniso=False): """Call one splat kernel directly and return the density map. @@ -561,54 +557,34 @@ def splat_direct(scene: Scene, name: str, xyz=None, occ=None, third=None, *, ani # --------------------------------------------------------------------------- # Direct-summation kernels, also called directly # --------------------------------------------------------------------------- -#: ``name -> (device, dtypes)``. ``eager`` is the oracle and is excluded from the -#: candidate list by :func:`ds_kernels_for`; it is registered here so the signature -#: adapter has one place to live. +#: The production direct-summation table, plus ``eager`` -- which is deliberately *not* in +#: it. ``_eager_*`` is the double-differentiable oracle, not a dispatch target, so it has no +#: business in a table describing what production selects; it is resolved here instead. #: -#: There is **no Metal/MPS direct-summation kernel**: ``Engine.METAL`` returns False from -#: the Triton gate and an MPS device fails ``t.is_cuda``, so DS on MPS *is* -#: ``_checkpointed_*`` running on-device. That is a real production path and it is what the -#: ``mps`` leg of ``checkpointed`` covers. -_DS_KERNEL_SPECS = ( - ("eager", "any", (torch.float32, torch.float64)), - ("checkpointed", "any", (torch.float32, torch.float64)), - ("ds_triton", "cuda", (torch.float32,)), -) +#: There is **no Metal/MPS direct-summation kernel**, which the table now says outright by +#: listing ``Engine.METAL`` among ``checkpointed``'s engines. So DS on MPS *is* +#: ``_checkpointed_*`` running on-device -- a real production path, and what the ``mps`` leg +#: of ``checkpointed`` covers. +_DS_KERNEL_TABLE = DS_BACKENDS def ds_kernel(name: str, aniso: bool): """Resolve a direct-summation kernel name to its function. - The Triton import is function-local: it pulls in ``triton``, which a CPU-only host - should not need at collection time. + ``Backend.resolve`` keeps the table's imports lazy: the Triton path pulls in ``triton``, + which a CPU-only host should not need at collection time. """ - from torchref.base.direct_summation import dispatch as D - if name == "eager": - return D._eager_aniso if aniso else D._eager_iso - if name == "checkpointed": - return D._checkpointed_aniso if aniso else D._checkpointed_iso - if name == "ds_triton": - from torchref.base.direct_summation.triton_ds import ( - ds_aniso_triton, - ds_iso_triton, - ) - - return ds_aniso_triton if aniso else ds_iso_triton - raise ValueError(f"unknown DS kernel {name!r}") + return _eager_aniso if aniso else _eager_iso + return _DS_KERNEL_TABLE.by_name(name).resolve(aniso) def ds_kernels_for(device: torch.device, dtype: torch.dtype): - """Candidate DS kernels on this ``(device, dtype)``. Excludes the oracle.""" - if not device_supports_dtype(device, dtype): - return [] - return [ - name - for name, dev, dtypes_ok in _DS_KERNEL_SPECS - if name != "eager" - and dtype in dtypes_ok - and (dev == "any" or dev == device.type) - ] + """Candidate DS kernels on this ``(device, dtype)``. + + Excludes the oracle by construction -- it is not in the production table. + """ + return _table_names_for(_DS_KERNEL_TABLE, device, dtype) def ds_direct(scene: Scene, name: str, xyz_frac=None, occ=None, third=None, *, aniso=False): diff --git a/tests/unit/structure_factor/test_dispatch.py b/tests/unit/structure_factor/test_dispatch.py index c91fa4c..b0c7a64 100644 --- a/tests/unit/structure_factor/test_dispatch.py +++ b/tests/unit/structure_factor/test_dispatch.py @@ -87,20 +87,16 @@ def test_metal_kernel_is_actually_dispatched(scene_small, engine, kind, monkeypa runs already differ at ~1e-7 and an equality check against one would prove nothing either way. - **The patch target matters.** ``_add_isotropic`` does a *function-local* - ``from ...kernels.mps import add_isotropic_mps_var`` on every call, so it re-reads the - package each time. Patching the ``main`` module's namespace would silently no-op and - leave this test as vacuous as the bug it guards against. + **The patch target is the defining module**, and it is the same rule for every backend: + dispatch resolves each kernel by ``(module_path, attr)`` at call time, so the name to + patch is the one the table points at. That uniformity is new -- this test and the CUDA + one used to need different targets (a package here, ``main`` there) because the two + ladders bound their kernels differently. The recorder delegates to the real kernel rather than stubbing it, so the dispatch is still exercised end to end. - - The submodule is imported explicitly rather than reached as ``kernels.mps``: the parent - package does not import it eagerly, so that attribute only exists once something has - already triggered the function-local import. Relying on that ordering is how this test - would pass in a full run and fail in isolation. """ - from torchref.base.electron_density.kernels import mps as kernels_mps + from torchref.base.electron_density.kernels.mps import variable_radius as kernels_mps name = f"add_{'anisotropic' if kind == 'aniso' else 'isotropic'}_mps_var" real = getattr(kernels_mps, name) @@ -121,21 +117,22 @@ def recording(*args, **kwargs): def test_triton_kernel_is_actually_dispatched(scene_small, engine, kind, monkeypatch): """CUDA float32 equivalent of the Metal provenance test. - Patch target differs: ``main.py`` imports the CUDA wrappers at module scope, not - per-call, so the name to patch is the one bound in ``main`` -- the mirror image of the - Metal case, and the reason each needs its own test rather than one parametrized helper. + Same patch target rule as the Metal case -- the module that defines the kernel. The two + used to differ, which is why they are separate tests rather than one parametrized + helper; that reason is gone, and merging them is a follow-up worth doing on a host that + has both backends to run it on. """ - from torchref.base.electron_density import main as ed_main + from torchref.base.electron_density.kernels.cuda import variable_radius as kernels_cuda name = f"add_{'anisotropic' if kind == 'aniso' else 'isotropic'}_cuda_var" - real = getattr(ed_main, name) + real = getattr(kernels_cuda, name) calls = [] def recording(*args, **kwargs): calls.append(1) return real(*args, **kwargs) - monkeypatch.setattr(ed_main, name, recording) + monkeypatch.setattr(kernels_cuda, name, recording) _build(scene_small, torch.device("cuda"), torch.float32, engine, aniso=kind == "aniso") assert calls, f"{engine} did not dispatch to {name}" @@ -148,17 +145,19 @@ def test_eager_reaches_the_portable_splat(scene_small, kind, monkeypatch): for double backward and debugging, so it has to be the *portable* kernel that runs, not whatever AUTO would have picked. """ - from torchref.base.electron_density import main as ed_main + from torchref.base.electron_density.kernels.cpu import ( + variable_radius as kernels_portable, + ) name = f"add_{'anisotropic' if kind == 'aniso' else 'isotropic'}_plain_var" - real = getattr(ed_main, name) + real = getattr(kernels_portable, name) calls = [] def recording(*args, **kwargs): calls.append(1) return real(*args, **kwargs) - monkeypatch.setattr(ed_main, name, recording) + monkeypatch.setattr(kernels_portable, name, recording) _build(scene_small, torch.device("cpu"), torch.float32, Engine.EAGER, aniso=kind == "aniso") assert calls, f"Engine.EAGER did not dispatch to {name}" @@ -273,16 +272,20 @@ def test_radius_policy_is_the_same_on_every_device(scene_small): def test_triton_density_gate_probes_every_tensor(scene_small): """A float64 ``density_map`` must not reach the float32 Triton kernel. - ``main.py`` gates the density Triton branch on ``should_use_triton(xyz)`` -- **only - xyz** -- while its sibling gates probe six tensors - (``should_use_sphere_splat(density_map, xyz, adp, occ, A, B)``, and the same for Metal). - So a float32 ``xyz`` with a float64 ``density_map`` passes, and - ``WorkQueueGridDensity.forward`` hands that float64 buffer to a kernel doing float32 - ``tl.atomic_add``. + Written when the density Triton branch gated on ``should_use_triton(xyz)`` -- **only + xyz** -- while its sibling gates probed six tensors. A float32 ``xyz`` with a float64 + ``density_map`` therefore passed, and ``WorkQueueGridDensity.forward`` handed that + float64 buffer to a kernel doing float32 ``tl.atomic_add``. + + The ``cuda_triton`` row now declares ``probes`` covering all six, so the mixed set + resolves to ``portable`` instead and this should never reach the Triton kernel at all. + Kept as an end-to-end guard rather than deleted: the fix is a table entry, and a table + entry can be edited. It has still never executed -- there is no CUDA host here -- so + treat a failure as information, not as a broken test. - The contract asserted here is that such a call fails loudly. Either outcome is - acceptable -- a raise from the gate, or a raise from the kernel -- but silently - returning a number is not. + The contract asserted is that such a call fails loudly. Either outcome is acceptable -- + a refusal at selection, or a raise from the kernel -- but silently returning a number is + not. """ cuda = torch.device("cuda") s = scene_small.to(device=cuda, dtype=torch.float32) diff --git a/tests/unit/structure_factor/test_ds_dispatch.py b/tests/unit/structure_factor/test_ds_dispatch.py index eb9b84d..b2b3606 100644 --- a/tests/unit/structure_factor/test_ds_dispatch.py +++ b/tests/unit/structure_factor/test_ds_dispatch.py @@ -84,6 +84,60 @@ def test_explicit_triton_engine_rejects_cpu(): ds_iso(hkl, s, xyz, occ, adp, A, B, engine=Engine.TRITON) +def test_metal_engine_runs_eager_rather_than_raising(): + """``Engine.METAL`` at a direct-summation site means eager, not an error. + + There is no Metal DS kernel: ``Engine.METAL`` returns False from the Triton gate and + an MPS tensor fails ``is_cuda``, so DS under METAL *is* ``_checkpointed_*``. That is + deliberate -- the ``Engine`` docstring recommends scoping METAL with ``use_engine`` + around the density call, which necessarily leaves it set for any DS in the same + block -- but nothing asserted it, so a dispatcher that rejected engines no backend + claims would break this silently. + + Compared against ``Engine.AUTO`` on the same inputs rather than merely checked for + finiteness, so a future path that returned zeros could not pass. + """ + hkl, s, _, A, B = _inputs(dtype=torch.float64) + xyz, occ, adp, _ = _leaves(dtype=torch.float64) + F_metal = ds_iso(hkl, s, xyz, occ, adp, A, B, engine=Engine.METAL) + F_auto = ds_iso(hkl, s, xyz, occ, adp, A, B, engine=Engine.AUTO) + assert torch.equal(F_metal, F_auto) + + +def test_integer_hkl_is_accepted_and_lossless(): + """An ``int32`` ``hkl`` must keep working, and must give the float32 answer exactly. + + Miller indices arrive as ``int32`` straight from the MTZ reader + (``torchref/io/mtz.py``), and every DS path casts ``hkl`` itself -- ``_cols_f32`` in + the Triton kernel, ``hkl_c.to(xyz_frac.dtype)`` in the chunk math. So integer input + is not merely tolerated, it is the production case. + + This matters for how the dtype gate may be tightened. The gate's test is + ``t.dtype is torch.float32``, an identity check, so probing ``hkl`` with that rule + would reject the production dtype -- declining Triton under AUTO and raising under + ``Engine.TRITON``. The rule has to exempt integer tensors, and this test is what + fails if it does not. + + Worth recording alongside: casting ``hkl`` is not merely tolerable, it is *free*. + Miller indices are integer-valued in every dtype that holds them (symmetry maps + ``h -> h.R`` with integer ``R``), so the f64->f32 round-trip is bit-exact for any + \\|h\\| < 2**24 and downcasting ``hkl`` alone was measured to change ``F`` by exactly + 0.0. An earlier version of this comment claimed the cast truncated the phase; it does + not, and the gate correspondingly does not probe ``hkl``. + """ + _, s, _, A, B = _inputs(dtype=torch.float64) + xyz, occ, adp, _ = _leaves(dtype=torch.float64) + torch.manual_seed(0) + hkl_int = torch.randint(-3, 4, (7, 3), dtype=torch.int32) + s = s[: hkl_int.shape[0]] + + F_int = ds_iso(hkl_int, s, xyz, occ, adp, A, B, engine=Engine.AUTO) + F_float = ds_iso( + hkl_int.to(torch.float64), s, xyz, occ, adp, A, B, engine=Engine.AUTO + ) + assert torch.equal(F_int, F_float), "casting integer Miller indices must be exact" + + def test_eager_none_scattering_factors_and_no_batching(): """``max_memory_gb=None`` with ``scattering_factors=None`` must compute from A/B. diff --git a/tests/unit/structure_factor/test_second_order.py b/tests/unit/structure_factor/test_second_order.py index 6721d86..39ad643 100644 --- a/tests/unit/structure_factor/test_second_order.py +++ b/tests/unit/structure_factor/test_second_order.py @@ -38,6 +38,7 @@ from tests.helpers.grad_asserts import cosine_similarity, hvp, hvp_central_fd, rel_error from torchref.base.direct_summation.dispatch import _eager_aniso, _eager_iso +from torchref.base.electron_density._backends import DENSITY_BACKENDS from torchref.utils import Engine, use_engine from . import ( @@ -360,7 +361,13 @@ def loss(x): # fixed once for the C++ scatter, where a graph-less backward gave cosine 0.57 while first # derivatives stayed correct. -_DOUBLE_DIFFERENTIABLE = {"cpu_sphere", "portable"} +#: Read from the production table rather than restated. This was a hand-written set, i.e. a +#: third copy of the same fact (the other two being the kernel docstrings and the table), +#: and the kind that goes stale silently: a kernel gaining a ``create_graph`` path would +#: leave its HVP untested, and one losing it would fail confusingly. +_DOUBLE_DIFFERENTIABLE = { + b.name for b in DENSITY_BACKENDS.backends if b.second_order +} @pytest.mark.parametrize("kind", ["iso", "aniso"]) diff --git a/tests/unit/test_imports_smoke.py b/tests/unit/test_imports_smoke.py index d380058..ed8f56b 100644 --- a/tests/unit/test_imports_smoke.py +++ b/tests/unit/test_imports_smoke.py @@ -15,6 +15,7 @@ """ import importlib +import os import pkgutil import pytest @@ -95,3 +96,48 @@ def test_kinetic_subpackage_imports(): """ importlib.import_module("torchref.experimental.kinetic") importlib.import_module("torchref.experimental.kinetic.refinement") + + +@pytest.mark.unit +def test_import_survives_a_broken_triton_install(tmp_path): + """A Triton that *raises* on import must not take ``import torchref`` down with it. + + Absent Triton was always handled; a **broken** one was not. The optional-kernel guard + caught only ``ImportError``, so a Triton whose import raises anything else -- a driver + or LLVM version skew, which is the common real-world failure -- propagated straight out + of ``import torchref``, making the whole package unusable on a host whose GPU stack had + drifted. ``triton_available()`` was written to absorb exactly this and never got the + chance. + + Run in a subprocess with a deliberately-exploding ``triton`` shadowing the real one: + the failure happens at *import* time, so it cannot be provoked in-process once + ``torchref`` is already loaded. + """ + import subprocess + import sys + + pkg = tmp_path / "triton" + pkg.mkdir() + boom = 'raise RuntimeError("simulated driver/LLVM version skew")\n' + (pkg / "__init__.py").write_text(boom) + (pkg / "language.py").write_text(boom) + + env = dict(os.environ, PYTHONPATH=str(tmp_path)) + proc = subprocess.run( + [ + sys.executable, + "-c", + "import torchref\n" + "from torchref.utils import triton_available\n" + "assert triton_available() is False, 'a broken triton must read as absent'\n" + "print('ok')\n", + ], + capture_output=True, + text=True, + env=env, + ) + assert proc.returncode == 0, ( + "import torchref failed with a broken triton on the path:\n" + f"{proc.stderr[-2000:]}" + ) + assert "ok" in proc.stdout diff --git a/tests/unit/utils/test_backend_tables.py b/tests/unit/utils/test_backend_tables.py new file mode 100644 index 0000000..fde4b9a --- /dev/null +++ b/tests/unit/utils/test_backend_tables.py @@ -0,0 +1,168 @@ +"""The three production dispatch tables, checked as policy rather than as numerics. + +Separate from ``test_backends.py``, which exercises the resolver against synthetic tables. +This file asserts things about the tables that actually ship: that every engine is handled +somewhere, that each backend is available on the hosts where it is declared to be, and that +selection returns what the docs claim for each reachable ``(device, dtype, engine)``. + +Everything here runs on any host. That is the point -- the accelerator provenance tests can +only cover the backend the machine happens to have, whereas ``select`` is a pure function of +the table and can be interrogated for combinations this host cannot execute. +""" + +import pytest +import torch + +from torchref.base.direct_summation._backends import DS_BACKENDS +from torchref.base.electron_density._backends import DENSITY_BACKENDS +from torchref.base.targets._dispatch import TARGET_BACKENDS +from torchref.utils.backends import select +from torchref.utils.triton_dispatch import Engine + +pytestmark = pytest.mark.unit + +ALL_TABLES = (DENSITY_BACKENDS, DS_BACKENDS, TARGET_BACKENDS) + +ALL_BACKENDS = [ + pytest.param(t, b, id=f"{t.name.split()[0]}-{b.name}") + for t in ALL_TABLES + for b in t.backends +] + + +# --------------------------------------------------------------------------- +# Table-level invariants +# --------------------------------------------------------------------------- +@pytest.mark.parametrize("table", ALL_TABLES, ids=lambda t: t.name.split()[0]) +def test_every_table_handles_every_engine(table): + """No engine may fall through a table with nothing claiming it. + + Constructing a ``BackendTable`` already asserts this, so importing the tables is most of + the test. Stated explicitly anyway because it is the invariant the whole design exists + for, and because the failure it prevents is invisible: ``Engine.METAL`` means "run the + Metal density splat" at one site and "run eager" at every other, and if a table stopped + absorbing it, ``with use_engine(Engine.METAL): loss = bond_target(x)`` would go from + working to raising with no test noticing. + """ + covered = frozenset().union(*(b.engines for b in table.backends)) + assert covered == frozenset(Engine), ( + f"{table.name}: {sorted(e.name for e in frozenset(Engine) - covered)} " + "handled by no backend" + ) + + +@pytest.mark.parametrize("table", ALL_TABLES, ids=lambda t: t.name.split()[0]) +def test_forcing_engines_are_not_absorbed_by_the_base_case(table): + """``TRITON``/``METAL`` must be absent from the base case, or forcing stops being strict. + + This is the mechanism, not a detail: a forced engine raises precisely because no + fallback lists it. Add either to the base case and every "strict engine refuses" test + starts passing for the wrong reason. + """ + for engine in (Engine.TRITON, Engine.METAL): + if any(engine in b.engines and b is not table.base for b in table.backends): + assert engine not in table.base.engines, ( + f"{table.name}: base case {table.base.name} absorbs {engine}, " + "so forcing it can silently degrade" + ) + + +# --------------------------------------------------------------------------- +# Availability: declared expectations must hold on the hosts that declare them +# --------------------------------------------------------------------------- +@pytest.mark.parametrize("table,backend", ALL_BACKENDS) +def test_backend_is_available_where_it_is_expected(table, backend): + """A backend declared required on this host must actually be usable here. + + One parametrized test in place of a bespoke compile check per kernel, which is what + ``expect_available`` on the table row is for. + + It **fails rather than skips**, and that asymmetry is deliberate. Dispatch under + ``Engine.AUTO`` degrades quietly -- correct for users, dangerous for CI -- so if every + test skipped when a kernel went missing, a broken build would produce an all-green run + while production had silently fallen back to a ~100x slower path. A host that is not + expected to provide the backend simply generates no assertion. + """ + if not backend.expected_here(): + pytest.skip(f"{backend.name} is not expected on this host") + why = backend.unavailable() + assert why is None, ( + f"{table.name}/{backend.name} is expected to work on this host but is not " + f"available: {why}" + ) + + +# --------------------------------------------------------------------------- +# Selection, enumerated +# --------------------------------------------------------------------------- +def _six(device, dtype): + return [torch.zeros(4, device=device, dtype=dtype)] * 6 + + +_CPU_CASES = [ + (torch.float32, Engine.AUTO, "cpu_sphere"), + (torch.float64, Engine.AUTO, "cpu_sphere"), + (torch.float32, Engine.EAGER, "portable"), + (torch.float64, Engine.EAGER, "portable"), +] + + +@pytest.mark.parametrize("dtype,engine,expected", _CPU_CASES, + ids=[f"{d}-{e.name}" for d, e, _ in _CPU_CASES]) +def test_density_selection_on_cpu(dtype, engine, expected): + """The density policy, asserted directly instead of inferred from a call recorder. + + ``select`` is a pure function of the table, so this replaces most of what previously + needed a monkeypatched spy -- and unlike a spy it can assert the float64 leg, which the + accelerator provenance tests cannot reach at all. + """ + got = select(DENSITY_BACKENDS, _six("cpu", dtype), engine).name + assert got == expected + + +@pytest.mark.mps +@pytest.mark.parametrize( + "engine,expected", [(Engine.AUTO, "mps_metal"), (Engine.EAGER, "portable")] +) +def test_density_selection_on_mps(engine, expected): + got = select(DENSITY_BACKENDS, _six("mps", torch.float32), engine).name + assert got == expected + + +@pytest.mark.parametrize("engine", [Engine.TRITON, Engine.METAL]) +def test_forced_accelerator_engines_refuse_cpu_inputs(engine): + """Host-independent: a CPU tensor is wrong for both accelerators everywhere.""" + with pytest.raises(RuntimeError, match="requires (CUDA|MPS) float32"): + select(DENSITY_BACKENDS, _six("cpu", torch.float32), engine) + + +def test_metal_engine_selects_eager_at_the_other_two_sites(): + """``Engine.METAL`` is density-only, and must mean "run eager" elsewhere. + + The characterization test in ``test_ds_dispatch.py`` covers this end to end; here it is + asserted against the tables, where it is now a declared fact rather than an early + ``return False`` inside a predicate. + """ + cpu = _six("cpu", torch.float32) + assert select(DS_BACKENDS, cpu, Engine.METAL).name == "checkpointed" + assert select(TARGET_BACKENDS, cpu, Engine.METAL).name == "eager" + + +def test_ds_selection_ignores_integer_hkl(): + """An ``int32`` ``hkl`` must not change what direct summation selects. + + Miller indices are ``int32`` in production and every DS kernel casts them itself, + exactly. ``hkl`` is outside the probe set for that reason, and the dtype rule exempts + integers regardless -- two independent guards against the production dtype reading as a + capability failure. + """ + n = 4 + f32 = lambda: torch.zeros(n, dtype=torch.float32) # noqa: E731 + hkl_int = torch.zeros(n, 3, dtype=torch.int32) + hkl_f32 = torch.zeros(n, 3, dtype=torch.float32) + args_int = [hkl_int, f32(), torch.zeros(n, 3), f32(), f32(), f32(), f32()] + args_f32 = [hkl_f32, f32(), torch.zeros(n, 3), f32(), f32(), f32(), f32()] + assert ( + select(DS_BACKENDS, args_int, Engine.AUTO).name + == select(DS_BACKENDS, args_f32, Engine.AUTO).name + ) diff --git a/tests/unit/utils/test_backends.py b/tests/unit/utils/test_backends.py new file mode 100644 index 0000000..1b0f5ea --- /dev/null +++ b/tests/unit/utils/test_backends.py @@ -0,0 +1,394 @@ +"""The dispatch resolver, tested against synthetic tables rather than real kernels. + +Synthetic on purpose: this file is about the *policy* machinery -- engine admission, +device/dtype matching, probe ordering, failure handling -- and a synthetic table can +express cases no real host can reach (a CUDA row on a CPU-only machine, a probe that +raises if called at all). The real tables are checked where they live, against the kernels +they name. +""" + +import pytest +import torch + +from torchref.utils.backends import Backend, BackendTable, run_or_degrade, select +from torchref.utils.triton_dispatch import Engine + +pytestmark = pytest.mark.unit + +_THIS = "tests.unit.utils.test_backends" + + +# --- kernels the synthetic tables point at ------------------------------- +def accel_iso(*a, **k): + return "accel_iso" + + +def accel_aniso(*a, **k): + return "accel_aniso" + + +def base_iso(*a, **k): + return "base_iso" + + +def base_aniso(*a, **k): + return "base_aniso" + + +def boom(*a, **k): + raise RuntimeError("kernel exploded") + + +def probe_ok(): + return None + + +def probe_missing(): + return "the widget is not installed" + + +def probe_must_not_run(): + raise AssertionError("availability probed before device/dtype was checked") + + +def _table( + *, + accel_probe=(_THIS, "probe_ok"), + accel_on_failure="degrade", + accel_device="cuda", + base_engines=(Engine.AUTO, Engine.EAGER, Engine.METAL), +): + return BackendTable( + name="synthetic", + backends=( + Backend( + name="accel", + kernel=(_THIS, "accel_iso", "accel_aniso"), + engines=frozenset({Engine.AUTO, Engine.TRITON}), + device=accel_device, + dtypes=(torch.float32,), + probe=accel_probe, + on_failure=accel_on_failure, + second_order=False, + ), + Backend( + name="base", + kernel=(_THIS, "base_iso", "base_aniso"), + engines=frozenset(base_engines), + ), + ), + ) + + +# --------------------------------------------------------------------------- +# Table invariants, asserted at construction +# --------------------------------------------------------------------------- +def test_table_rejects_an_unhandled_engine(): + """A table that no backend claims for some engine is a construction error. + + This is the invariant the whole design is for. ``Engine.METAL`` selects the Metal + density splat and must mean "run eager" everywhere else; if a table forgets to let its + base case absorb it, calls that work today start raising. Catching it at import turns a + silent behavioural regression into a failure at the definition site. + """ + with pytest.raises(ValueError, match="no backend handles"): + _table(base_engines=(Engine.AUTO, Engine.EAGER)) # METAL unclaimed + + +def test_table_requires_exactly_one_base_case(): + with pytest.raises(ValueError, match="exactly one unrestricted base"): + BackendTable( + name="two-bases", + backends=( + Backend("a", (_THIS, "base_iso", "base_aniso"), frozenset(Engine)), + Backend("b", (_THIS, "base_iso", "base_aniso"), frozenset(Engine)), + ), + ) + + +def test_backend_rejects_an_unknown_failure_policy(): + with pytest.raises(ValueError, match="on_failure"): + Backend( + "x", + (_THIS, "base_iso", "base_aniso"), + frozenset(Engine), + on_failure="explode", + ) + + +def test_backend_rejects_an_unknown_expectation(): + with pytest.raises(ValueError, match="expect_available"): + Backend( + "x", + (_THIS, "base_iso", "base_aniso"), + frozenset(Engine), + expect_available="on tuesdays", + ) + + +def test_expectation_conditions_are_evaluated_against_the_host(): + """``expect_available`` is a host predicate, not a static flag.""" + always = Backend( + "a", (_THIS, "base_iso", "base_aniso"), frozenset(Engine), + expect_available="always", + ) + never = Backend( + "n", (_THIS, "base_iso", "base_aniso"), frozenset(Engine), + expect_available="never", + ) + cuda = Backend( + "c", (_THIS, "base_iso", "base_aniso"), frozenset(Engine), + expect_available="cuda", + ) + assert always.expected_here() is True + assert never.expected_here() is False + assert cuda.expected_here() is torch.cuda.is_available() + + +def test_expectation_does_not_influence_selection(): + """A backend that "should" work still loses to an honest probe saying it does not. + + Keeping these separate is the point: ``expect_available`` is what CI asserts, + ``probe`` is what dispatch obeys. Conflating them would make a mis-declared + expectation route real work to a kernel that cannot run. + """ + t = BackendTable( + name="expectation-vs-fact", + backends=( + Backend( + "accel", + (_THIS, "accel_iso", "accel_aniso"), + frozenset({Engine.AUTO, Engine.TRITON}), + device="cpu", + probe=(_THIS, "probe_missing"), + expect_available="always", + ), + Backend("base", (_THIS, "base_iso", "base_aniso"), frozenset(Engine)), + ), + ) + assert t.by_name("accel").expected_here() is True + assert select(t, [torch.zeros(2)], Engine.AUTO).name == "base" + + +# --------------------------------------------------------------------------- +# Selection +# --------------------------------------------------------------------------- +def test_auto_falls_through_to_the_base_case_on_a_wrong_device(): + t = _table() + cpu = torch.zeros(2) + assert select(t, [cpu], Engine.AUTO).name == "base" + + +def test_auto_selects_the_accelerator_when_everything_matches(): + """Device match is expressed through the table, not the host, so this runs anywhere.""" + t = _table(accel_device="cpu") + assert select(t, [torch.zeros(2)], Engine.AUTO).name == "accel" + + +def test_eager_reaches_the_base_case_even_when_the_accelerator_would_match(): + t = _table(accel_device="cpu") + assert select(t, [torch.zeros(2)], Engine.EAGER).name == "base" + + +def test_a_forcing_engine_raises_rather_than_degrading(): + """The base case does not list TRITON, so nothing absorbs a failed match.""" + t = _table() + with pytest.raises(RuntimeError, match="requires CUDA float32"): + select(t, [torch.zeros(2)], Engine.TRITON) + + +def test_forcing_engine_message_names_the_engine_and_the_requirement(): + t = _table() + with pytest.raises(RuntimeError) as exc: + select(t, [torch.zeros(2)], Engine.TRITON) + assert "engine=Engine.TRITON" in str(exc.value) + + +def test_unavailability_degrades_under_auto_and_raises_when_forced(): + t = _table(accel_probe=(_THIS, "probe_missing"), accel_device="cpu") + cpu = torch.zeros(2) + assert select(t, [cpu], Engine.AUTO).name == "base" + with pytest.raises(RuntimeError, match="the widget is not installed"): + select(t, [cpu], Engine.TRITON) + + +def test_non_engine_values_are_rejected_loudly(): + """An unrecognised engine must not fall through to a default. + + The predicates this replaces had an explicit guard for the same reason: the AUTO case + was once an implicit ``else``, so any new or bogus value silently selected Triton. + """ + + class NotAnEngine: + pass + + with pytest.raises(ValueError, match="unhandled engine"): + select(_table(), [torch.zeros(2)], NotAnEngine()) + + +def test_none_entries_are_ignored(): + t = _table(accel_device="cpu") + assert select(t, [None, torch.zeros(2), None], Engine.AUTO).name == "accel" + + +def test_probes_restrict_which_arguments_carry_the_contract(): + """A tensor outside ``probes`` must not affect selection. + + This is what lets direct summation police the refinable leaves while ignoring the + float32 stored scattering-factor table, which would otherwise decline the kernel + unconditionally. + """ + table = BackendTable( + name="probed", + backends=( + Backend( + "accel", + (_THIS, "accel_iso", "accel_aniso"), + frozenset({Engine.AUTO, Engine.TRITON}), + device="cpu", + dtypes=(torch.float32,), + probes=(0,), + ), + Backend("base", (_THIS, "base_iso", "base_aniso"), frozenset(Engine)), + ), + ) + f32, f64 = torch.zeros(2), torch.zeros(2, dtype=torch.float64) + # position 1 is float64 but unprobed + assert select(table, [f32, f64], Engine.AUTO).name == "accel" + # probing position 0 still bites + assert select(table, [f64, f32], Engine.AUTO).name == "base" + + +# --------------------------------------------------------------------------- +# Two-phase ordering +# --------------------------------------------------------------------------- +def test_availability_is_not_probed_when_device_already_mismatches(): + """Device/dtype must be rejected before the probe is consulted. + + Enforced with a probe that raises if reached, because the consequences of getting the + order wrong are invisible to a return-value assertion: an MPS host would compile the + CPU C++ extension it never uses, and a forced engine would report a compile failure it + never got far enough to observe instead of the device mismatch. + """ + t = _table(accel_probe=(_THIS, "probe_must_not_run")) + cpu = torch.zeros(2) + assert select(t, [cpu], Engine.AUTO).name == "base" + with pytest.raises(RuntimeError, match="requires CUDA float32"): + select(t, [cpu], Engine.TRITON) + + +def test_a_broken_probe_import_is_a_reason_not_a_crash(): + t = _table(accel_probe=("torchref.no.such.module", "nope"), accel_device="cpu") + assert select(t, [torch.zeros(2)], Engine.AUTO).name == "base" + with pytest.raises(RuntimeError, match="could not be imported"): + select(t, [torch.zeros(2)], Engine.TRITON) + + +# --------------------------------------------------------------------------- +# dtype uniformity +# --------------------------------------------------------------------------- +def test_require_uniform_dtype_rejects_a_mixed_set_that_membership_would_admit(): + """The distinction is memory safety, not taste. + + ``dtypes=(f32, f64)`` read as membership admits a float64 map beside float32 atoms, + which the fused CPU kernel would reinterpret through a raw pointer of the map's type. + """ + mixed = BackendTable( + name="uniform", + backends=( + Backend( + "fused", + (_THIS, "accel_iso", "accel_aniso"), + frozenset({Engine.AUTO}), + device="cpu", + dtypes=(torch.float32, torch.float64), + require_uniform_dtype=True, + ), + Backend("base", (_THIS, "base_iso", "base_aniso"), frozenset(Engine)), + ), + ) + f32, f64 = torch.zeros(2), torch.zeros(2, dtype=torch.float64) + assert select(mixed, [f32, f32], Engine.AUTO).name == "fused" + assert select(mixed, [f64, f64], Engine.AUTO).name == "fused" + assert select(mixed, [f64, f32], Engine.AUTO).name == "base" + + +def test_integer_tensors_are_exempt_from_the_dtype_contract(): + """Integer inputs must not disqualify a float32 backend. + + Miller indices are ``int32`` in production and every kernel casts them itself, exactly + and losslessly. An identity test against float32 would reject the production dtype and + disable the very kernel the rule protects, so the contract applies to floats only. + """ + t = _table(accel_device="cpu") + hkl_int = torch.zeros(4, 3, dtype=torch.int32) + f32 = torch.zeros(2) + assert select(t, [hkl_int, f32], Engine.AUTO).name == "accel" + # a float in the wrong precision still disqualifies + f64 = torch.zeros(2, dtype=torch.float64) + assert select(t, [hkl_int, f64], Engine.AUTO).name == "base" + + +# --------------------------------------------------------------------------- +# Running +# --------------------------------------------------------------------------- +def test_run_resolves_the_kernel_late_so_it_stays_patchable(monkeypatch): + """The kernel is looked up at call time, at its defining module. + + Load-bearing: the provenance tests prove a named kernel really ran by patching it. A + table that captured function objects at import would leave those tests green while + measuring nothing. + """ + import tests.unit.utils.test_backends as self_mod + + t = _table(accel_device="cpu") + monkeypatch.setattr(self_mod, "accel_iso", lambda *a, **k: "patched") + assert run_or_degrade(t, t.by_name("accel"), False, engine=Engine.AUTO) == "patched" + + +def test_run_picks_the_anisotropic_variant(): + t = _table(accel_device="cpu") + b = t.by_name("accel") + assert run_or_degrade(t, b, False, engine=Engine.AUTO) == "accel_iso" + assert run_or_degrade(t, b, True, engine=Engine.AUTO) == "accel_aniso" + + +def test_degrade_backend_falls_back_under_auto(monkeypatch): + import tests.unit.utils.test_backends as self_mod + + t = _table(accel_device="cpu", accel_on_failure="degrade") + monkeypatch.setattr(self_mod, "accel_iso", boom) + assert run_or_degrade(t, t.by_name("accel"), False, engine=Engine.AUTO) == "base_iso" + + +def test_degrade_backend_still_raises_under_a_forcing_engine(monkeypatch): + """Forcing a kernel and silently getting another one would make a benchmark lie.""" + import tests.unit.utils.test_backends as self_mod + + t = _table(accel_device="cpu", accel_on_failure="degrade") + monkeypatch.setattr(self_mod, "accel_iso", boom) + with pytest.raises(RuntimeError, match="kernel exploded"): + run_or_degrade(t, t.by_name("accel"), False, engine=Engine.TRITON) + + +def test_raise_backend_does_not_fall_back_even_under_auto(monkeypatch): + """A production kernel that built fine and then threw is a bug, not a capability miss. + + Degrading would convert a wrong-results bug into a large silent slowdown whose output + still looks plausible, because the fallback implements the same contract. + """ + import tests.unit.utils.test_backends as self_mod + + t = _table(accel_device="cpu", accel_on_failure="raise") + monkeypatch.setattr(self_mod, "accel_iso", boom) + with pytest.raises(RuntimeError, match="kernel exploded"): + run_or_degrade(t, t.by_name("accel"), False, engine=Engine.AUTO) + + +def test_base_case_failure_always_propagates(monkeypatch): + import tests.unit.utils.test_backends as self_mod + + t = _table() + monkeypatch.setattr(self_mod, "base_iso", boom) + with pytest.raises(RuntimeError, match="kernel exploded"): + run_or_degrade(t, t.base, False, engine=Engine.AUTO) diff --git a/tests/unit/utils/test_triton_dispatch.py b/tests/unit/utils/test_triton_dispatch.py index e42d6f7..467f89c 100644 --- a/tests/unit/utils/test_triton_dispatch.py +++ b/tests/unit/utils/test_triton_dispatch.py @@ -161,3 +161,63 @@ def test_should_use_metal_skips_none_but_still_checks_real_tensors(): wrong for Metal everywhere.""" with pytest.raises(RuntimeError, match="requires MPS float32"): should_use_metal(None, torch.zeros(3), None, engine=Engine.METAL) + + +# --------------------------------------------------------------------------- +# Characterization: facts that hold today and are not asserted anywhere else +# --------------------------------------------------------------------------- +# ``Engine.METAL`` selects the native Metal *density splat* and nothing else. Every +# other dispatch site is expected to run eager under it -- see the ``Engine`` docstring, +# which recommends scoping METAL with ``use_engine`` precisely because a process-wide +# METAL sends target math down the eager path. +# +# Today that fact exists only as ``if eng is Engine.EAGER or eng is Engine.METAL: +# return False`` inside ``should_use_triton``. Nothing asserts it, so a dispatch rework +# that treats "no backend claims this engine" as an error would turn +# ``with use_engine(Engine.METAL): loss = bond_target(xyz)`` from working into a +# RuntimeError, silently, in a documented usage pattern. These two tests are the guard. + + +def test_metal_engine_means_eager_at_non_density_sites(): + """``Engine.METAL`` must be *permissive* elsewhere, not an error. + + There are no Metal direct-summation or target kernels, so METAL at those sites means + "run eager", not "fail". Asserted through the shared gate and through the targets + wrapper, which is the entry point all twelve target math functions use. + """ + from torchref.base.targets._dispatch import use_triton + + cuda_like = torch.zeros(3) # CPU here; the engine short-circuits before device + assert should_use_triton(cuda_like, engine=Engine.METAL) is False + + with use_engine(Engine.METAL): + assert use_triton(cuda_like) is False + + +def test_metal_gate_does_not_probe_availability_on_a_wrong_device(monkeypatch): + """Device/dtype must be rejected *before* shader availability is consulted. + + Two things depend on the ordering. Under a forced engine the error has to name the + device mismatch (``requires MPS float32``) rather than a compile failure, and on a + non-Apple host nothing should pay the cost of the availability probe. The same + ordering is what keeps an MPS host from compiling the CPU C++ extension it will never + use. + + Asserted by making the probe *fail loudly if reached*, rather than by inspecting + ``sys.modules``: a module cannot be unimported, so an import check silently weakens to + a no-op as soon as any earlier test has loaded the package -- which in a full run is + always. + """ + from torchref.base.electron_density.kernels.mps import compile as mps_compile + + def must_not_be_called(): + raise AssertionError( + "availability was probed for a CPU tensor; the device check must " + "short-circuit first" + ) + + monkeypatch.setattr(mps_compile, "mps_kernels_available", must_not_be_called) + + assert should_use_metal(torch.zeros(3), engine=Engine.AUTO) is False + with pytest.raises(RuntimeError, match="requires MPS float32"): + should_use_metal(torch.zeros(3), engine=Engine.METAL) diff --git a/torchref/base/direct_summation/_backends.py b/torchref/base/direct_summation/_backends.py new file mode 100644 index 0000000..da0e930 --- /dev/null +++ b/torchref/base/direct_summation/_backends.py @@ -0,0 +1,130 @@ +"""The direct-summation dispatch policy, as one table. + +Companion to ``electron_density/_backends.py``; see :mod:`torchref.utils.backends` for what +each field means. + +Two things about this table are worth reading before changing it. + +**There is no Metal direct-summation kernel.** ``Engine.METAL`` selects the Metal *density +splat* and nothing else, so at this site it has to mean "run eager" -- which is why +``checkpointed`` lists it. That was previously an early ``return False`` buried in a +predicate; here it is a declared fact, and the table refuses to be constructed if some +engine ends up handled by nothing. DS on MPS is therefore ``_checkpointed_*`` running +on-device, a real production path. + +**The float32 requirement here is policy, not capability**, and the policy is mild. The +Triton kernel casts every input to float32 itself (``triton_ds._cols_f32``), so it would +happily consume float64; the gate declines instead, so that a float64 configuration is not +silently served at float32 precision. That is a consistency rule, not a rescue: measured on +a 40-atom / 60-reflection scene, downcasting the real-valued inputs moves ``F`` by 7.2e-06 +relative to ``mean|F|``, three orders of magnitude inside the 1e-2 amplitude tolerance this +package works to. + +It matters even less than that suggests, because the interesting case was never reachable. +Under a float64 configuration ``xyz_frac`` is float64, and the previous gate probed +``xyz_frac`` -- so it already declined. The only thing widening the probe set catches is +hand-mixed dtypes (float32 coordinates beside a float64 ``s`` or ``adp``), which no caller in +the repo produces. Treat this as making the gate check what its kernel actually consumes, +consistent with the density table, rather than as a fix for anything observed. + +``hkl`` is deliberately **not** probed, and probing it would be wrong. Miller indices are +integers -- ``int32`` from the MTZ reader, and integer-valued even in a float dtype, since +symmetry maps ``h -> h.R`` with integer ``R``. The f64->f32 round-trip is bit-exact for any +\\|h\\| < 2**24 (real structures reach a few hundred), and measurably so: downcasting ``hkl`` +alone changes ``F`` by exactly 0.0. Probing it would only manufacture a false negative. + +The dtype rule exempts integer tensors for the same reason -- without that, the ``int32`` +production dtype would read as a capability failure and disable the kernel outright. +""" + +from __future__ import annotations + +import torch + +from torchref.utils.backends import Backend, BackendTable +from torchref.utils.triton_dispatch import Engine, triton_available + +_THIS = "torchref.base.direct_summation._backends" + + +def why_unavailable(): + """``None`` if the direct-summation Triton kernels can run, else why they cannot. + + Two separable questions, and both have to be asked. ``triton_available()`` answers "is + the ``triton`` package importable"; this additionally answers "does ``triton_ds`` + itself import", which can fail on its own -- the module evaluates + ``tl.constexpr(...)`` at import time and that is version-sensitive. The density + backend has the same split. + """ + if not triton_available(): + return "triton is not importable" + try: + from torchref.base.direct_summation import triton_ds # noqa: F401 + except Exception as exc: # noqa: BLE001 - any import failure is a reason, not a crash + return ( + "the direct-summation Triton kernels could not be imported " + f"({type(exc).__name__}: {exc})" + ) + return None + + +# --------------------------------------------------------------------------- +# Signature adapters +# --------------------------------------------------------------------------- +# The two backends do not share a signature: the checkpointed path takes a trailing +# ``max_memory_gb`` reflection-chunk budget and the Triton kernel has nothing to bound -- +# it forms only a ``(BLOCK_H, N)`` tile in registers. Rather than add a parameter the +# kernel ignores (a lie in a public signature, and the exact drift just removed from the +# Metal wrappers), the budget is dropped in a named adapter. Named, not a lambda, so it +# appears in a traceback and can be patched. +def _ds_iso_triton(hkl, s, xyz_frac, occ, adp, A, B, max_memory_gb): + """Isotropic Triton DS; ``max_memory_gb`` is not applicable and is dropped here.""" + from torchref.base.direct_summation.triton_ds import ds_iso_triton + + return ds_iso_triton(hkl, s, xyz_frac, occ, adp, A, B) + + +def _ds_aniso_triton(hkl, s_vec, xyz_frac, occ, U, A, B, max_memory_gb): + """Anisotropic Triton DS; ``max_memory_gb`` is not applicable and is dropped here.""" + from torchref.base.direct_summation.triton_ds import ds_aniso_triton + + return ds_aniso_triton(hkl, s_vec, xyz_frac, occ, U, A, B) + + +DS_BACKENDS = BackendTable( + name="direct-summation structure factors", + backends=( + Backend( + name="ds_triton", + kernel=(_THIS, "_ds_iso_triton", "_ds_aniso_triton"), + engines=frozenset({Engine.AUTO, Engine.TRITON}), + device="cuda", + dtypes=(torch.float32,), + # Every argument except ``hkl`` (position 0), whose dtype provably costs + # nothing -- see the module docstring. + probes=(1, 2, 3, 4, 5, 6), + probe=(_THIS, "why_unavailable"), + expect_available="cuda", + on_failure="degrade", + second_order=False, + ), + Backend( + name="checkpointed", + kernel=( + "torchref.base.direct_summation.dispatch", + "_checkpointed_iso", + "_checkpointed_aniso", + ), + # METAL is here because there is no Metal DS kernel; see the module docstring. + # TRITON is absent, which is what makes that engine strict. + engines=frozenset({Engine.AUTO, Engine.EAGER, Engine.METAL}), + expect_available="always", + on_failure="raise", + # First-order only: the backward replays each chunk under ``enable_grad`` but + # without ``create_graph``, so a second derivative raises rather than returning + # something wrong. ``_eager_*`` is the double-differentiable reference and is + # deliberately not in this table -- it is not a production dispatch target. + second_order=False, + ), + ), +) diff --git a/torchref/base/direct_summation/dispatch.py b/torchref/base/direct_summation/dispatch.py index 18a862f..a1d51c3 100644 --- a/torchref/base/direct_summation/dispatch.py +++ b/torchref/base/direct_summation/dispatch.py @@ -1,12 +1,10 @@ """Backend dispatch for direct-summation structure factors. -Selection uses the shared, capability-based :class:`~torchref.utils.Engine` -(see :mod:`torchref.utils.triton_dispatch`): for a given ``(device, dtype)`` -there is exactly one best path, so the backend is *derived* rather than -configured. - -- CUDA + float32 + Triton available -> custom Triton kernels (``triton_ds``) -- everything else (CPU, MPS, non-float32, no Triton) -> checkpointed eager +Which backend runs is read from +:data:`torchref.base.direct_summation._backends.DS_BACKENDS` -- device, dtype, which +engines admit each path, how availability is probed, and what a failure means all live +there, so this module holds the maths and the entry points but no selection logic. The +mechanics are in :mod:`torchref.utils.backends`. An explicit ``Engine`` override (per-call ``engine=`` or the process-wide ``use_engine``/``set_engine``) forces a path for tests and benchmarks. @@ -30,37 +28,13 @@ _estimate_batch_size_aniso, aniso_structure_factor_torched, ) -from torchref.utils.triton_dispatch import Engine, should_use_triton +from torchref.utils.backends import run_or_degrade, select +from torchref.utils.triton_dispatch import Engine TWO_PI = 2.0 * math.pi NEG_TWO_PI_SQ = -2.0 * (math.pi**2) -# --------------------------------------------------------------------------- -# Lazy Triton probe (mirrors electron_density/main.py:_get_*_triton) -# --------------------------------------------------------------------------- -_triton_iso_fn = None -_triton_aniso_fn = None -_triton_checked = False - - -def _load_triton(): - """Import the Triton kernels once; leave the fns ``None`` if unavailable.""" - global _triton_iso_fn, _triton_aniso_fn, _triton_checked - if not _triton_checked: - try: - from torchref.base.direct_summation.triton_ds import ( - ds_iso_triton, - ds_aniso_triton, - ) - - _triton_iso_fn = ds_iso_triton - _triton_aniso_fn = ds_aniso_triton - except Exception: - pass - _triton_checked = True - - # --------------------------------------------------------------------------- # P1 identity symmetry (lives here, not in sf_ds) # --------------------------------------------------------------------------- @@ -247,40 +221,44 @@ def _eager_aniso(hkl, s_vec, xyz_frac, occ, U, A, B, max_memory_gb): # --------------------------------------------------------------------------- # Dispatch entry points # --------------------------------------------------------------------------- -# The coarse Triton-vs-eager gate is the shared ``should_use_triton`` (device, -# dtype, engine). The DS-kernel-module availability is then checked here: under -# AUTO a missing/failing kernel falls back to checkpointed eager; under -# Engine.TRITON it raises. -def ds_iso(hkl, s, xyz_frac, occ, adp, A, B, *, engine=Engine.AUTO, max_memory_gb=None): - """Isotropic P1 structure factors via the selected backend.""" +def _dispatch(aniso, hkl, geom, xyz_frac, occ, third, A, B, engine, max_memory_gb): + """Select a backend from ``DS_BACKENDS`` and run it. + + Imported inside the call rather than at module scope: the table names this module as a + kernel source, so a module-level import would close a cycle. + """ + from torchref.base.direct_summation._backends import DS_BACKENDS + + args = (hkl, geom, xyz_frac, occ, third, A, B) + backend = select(DS_BACKENDS, args, engine) + return run_or_degrade( + DS_BACKENDS, backend, aniso, *args, max_memory_gb, engine=engine + ) + + +def ds_iso(hkl, s, xyz_frac, occ, adp, A, B, *, engine=None, max_memory_gb=None): + """Isotropic P1 structure factors via the selected backend. + + ``engine=None`` means *defer to the process-wide engine*, which is what makes + ``with use_engine(Engine.EAGER): ...`` actually reach this call. The default used to be + ``Engine.AUTO``, and because an explicit engine argument suppresses the global, that + silently made the documented escape hatch inert here: a forced-eager block still ran + Triton on a CUDA host. + """ if xyz_frac.shape[0] == 0: return torch.zeros(hkl.shape[0], dtype=torch.complex64, device=hkl.device) - if should_use_triton(xyz_frac, engine=engine): - _load_triton() - if _triton_iso_fn is not None: - try: - return _triton_iso_fn(hkl, s, xyz_frac, occ, adp, A, B) - except Exception: - if engine is Engine.TRITON: - raise - # AUTO: fall back to checkpointed eager - elif engine is Engine.TRITON: - raise RuntimeError("Direct-summation Triton kernels are unavailable") - return _checkpointed_iso(hkl, s, xyz_frac, occ, adp, A, B, max_memory_gb) - - -def ds_aniso(hkl, s_vec, xyz_frac, occ, U, A, B, *, engine=Engine.AUTO, max_memory_gb=None): - """Anisotropic P1 structure factors via the selected backend.""" + return _dispatch( + False, hkl, s, xyz_frac, occ, adp, A, B, engine, max_memory_gb + ) + + +def ds_aniso(hkl, s_vec, xyz_frac, occ, U, A, B, *, engine=None, max_memory_gb=None): + """Anisotropic P1 structure factors via the selected backend. + + See :func:`ds_iso` on ``engine=None``. + """ if xyz_frac.shape[0] == 0: return torch.zeros(hkl.shape[0], dtype=torch.complex64, device=hkl.device) - if should_use_triton(xyz_frac, engine=engine): - _load_triton() - if _triton_aniso_fn is not None: - try: - return _triton_aniso_fn(hkl, s_vec, xyz_frac, occ, U, A, B) - except Exception: - if engine is Engine.TRITON: - raise - elif engine is Engine.TRITON: - raise RuntimeError("Direct-summation Triton kernels are unavailable") - return _checkpointed_aniso(hkl, s_vec, xyz_frac, occ, U, A, B, max_memory_gb) + return _dispatch( + True, hkl, s_vec, xyz_frac, occ, U, A, B, engine, max_memory_gb + ) diff --git a/torchref/base/electron_density/_backends.py b/torchref/base/electron_density/_backends.py new file mode 100644 index 0000000..52e94a8 --- /dev/null +++ b/torchref/base/electron_density/_backends.py @@ -0,0 +1,108 @@ +"""The electron-density splat dispatch policy, as one table. + +Every criterion for choosing a density kernel is a field in a row below: which device, +which dtypes, which engines admit it, how availability is probed, whether a runtime failure +may degrade, and whether the kernel composes to second order. Reading this file answers +"which kernel runs for MPS + float64 + ``Engine.AUTO``?" without tracing an if/elif ladder +through three modules. + +All four wrappers share one signature -- +``(density_map, xyz, adp_or_u, occ, A, B, inv_frac, frac, radius_per_atom)`` -- which is +what lets the dispatch site be a single call rather than a per-kernel adapter. + +Ordering within the table is not load-bearing. The three non-base backends are pairwise +device-disjoint (CUDA / CPU / MPS), so at most one can ever match; the base case matches +everything and is what ``AUTO`` falls through to. + +See :mod:`torchref.utils.backends` for what each field means and why it exists. +""" + +from __future__ import annotations + +import torch + +from torchref.utils.backends import Backend, BackendTable +from torchref.utils.triton_dispatch import Engine + +_CUDA = "torchref.base.electron_density.kernels.cuda.variable_radius" +_MPS = "torchref.base.electron_density.kernels.mps.variable_radius" +_SPHERE = "torchref.base.electron_density.kernels.cpu.sphere_splat" +_PORTABLE = "torchref.base.electron_density.kernels.cpu.variable_radius" + +#: Argument positions carrying the device/dtype contract: +#: ``density_map, xyz, adp_or_u, occ, A, B``. The trailing three -- the two cell matrices +#: and the per-atom radius -- are excluded because no gate has ever probed them, and +#: widening the contract to cover them would make two currently-working backends stricter +#: for no demonstrated bug. They *are* read as raw pointers by the C++ and CUDA kernels, so +#: that remains an open question rather than a settled one. +_ATOM_ARGS = (0, 1, 2, 3, 4, 5) + +DENSITY_BACKENDS = BackendTable( + name="electron-density splat", + backends=( + Backend( + name="cuda_triton", + kernel=(_CUDA, "add_isotropic_cuda_var", "add_anisotropic_cuda_var"), + engines=frozenset({Engine.AUTO, Engine.TRITON}), + device="cuda", + dtypes=(torch.float32,), + probes=_ATOM_ARGS, + probe=(_CUDA, "why_unavailable"), + expect_available="cuda", + # A failed launch on an available GPU is a capability miss worth degrading + # from; the kernel clones the density map before accumulating, so the + # fallback cannot double-count. Under a forcing engine it still raises. + on_failure="degrade", + second_order=False, + ), + Backend( + name="mps_metal", + kernel=(_MPS, "add_isotropic_mps_var", "add_anisotropic_mps_var"), + engines=frozenset({Engine.AUTO, Engine.METAL}), + device="mps", + dtypes=(torch.float32,), + probes=_ATOM_ARGS, + probe=("torchref.base.electron_density.kernels.mps.compile", + "why_unavailable"), + expect_available="mps", + on_failure="degrade", + second_order=False, + ), + Backend( + name="cpu_sphere", + kernel=(_SPHERE, "add_isotropic_cpu_sphere_var", + "add_anisotropic_cpu_sphere_var"), + # AUTO only. There is no Engine member that forces the fused CPU splat, which + # is why proving it ran needs a call recorder rather than a strict engine. + engines=frozenset({Engine.AUTO}), + device="cpu", + dtypes=(torch.float32, torch.float64), + # Uniformity, not membership: the kernel picks one ``scalar_t`` from the output + # map and then reads every other tensor through a raw pointer of that type, so a + # float64 map beside float32 atoms would be a 2x out-of-bounds read. + require_uniform_dtype=True, + probes=_ATOM_ARGS, + probe=(_SPHERE, "why_unavailable"), + # A pure C++ build with no hardware requirement, so it must work everywhere. The + # expectation is what makes a broken build a CI failure instead of a skip. + expect_available="always", + # Deliberately not "degrade". The extension having failed to *build* already + # yields a reason from the probe, so this governs only a runtime throw from a + # kernel that compiled -- a genuine bug. Falling back would turn wrong results + # into a ~100x slowdown whose numbers still look plausible, because the portable + # splat implements the same truncation contract. + on_failure="raise", + second_order=True, + ), + Backend( + name="portable", + kernel=(_PORTABLE, "add_isotropic_plain_var", "add_anisotropic_plain_var"), + # The base case, and the reason forcing works: TRITON and METAL are absent + # here, so nothing absorbs them and a forced engine that cannot run raises. + engines=frozenset({Engine.AUTO, Engine.EAGER}), + expect_available="always", + on_failure="raise", + second_order=True, + ), + ), +) diff --git a/torchref/base/electron_density/kernels/__init__.py b/torchref/base/electron_density/kernels/__init__.py index 9bf5df4..77a5bbd 100644 --- a/torchref/base/electron_density/kernels/__init__.py +++ b/torchref/base/electron_density/kernels/__init__.py @@ -2,11 +2,9 @@ Optimized density-splatting kernels, organized by device. Layout: -- ``cpu/`` — the per-atom variable-radius CPU splats - (``variable_radius.py``: the production CPU AUTO grouped-separable and the - portable plain-scatter splats), the shared separable density core - (``separable.py``), the aniso splat, the C++ parallel scatter - (``scatter.py`` / ``scatter_dispatch.py``), and the JIT reference. +- ``cpu/`` — the production fused C++ spherical-cutoff splat + (``sphere_splat.py``), the portable plain-scatter splats + (``variable_radius.py``), and the JIT reference. - ``cuda/`` — the production variable-radius work-queue kernels (``variable_radius.py``: ``WorkQueueGridDensity{,Aniso}``) plus the legacy fixed-radius fused Triton kernel (``fused.py``, benchmark-only). @@ -28,13 +26,6 @@ clear_cache, ) -# Triton kernels are optional (require the triton package / CUDA). -try: - from .cuda.fused import fused_add_to_map_gpu - _HAS_TRITON = True -except ImportError: - _HAS_TRITON = False - __all__ = [ "vectorized_add_to_map", "build_electron_density", @@ -43,5 +34,24 @@ "warmup", "get_cache_dir", "clear_cache", - "fused_add_to_map_gpu", ] + +# Triton kernels are optional (they require the triton package and a GPU). +# +# ``except Exception``, not ``except ImportError``: this runs during ``import torchref``, +# and a Triton install that is present but broken -- a driver or LLVM version skew, the +# common real-world failure -- raises something other than ImportError on import. Catching +# only ImportError meant such a host could not import torchref at all, even though +# ``torchref.utils.triton_available()`` was written to absorb exactly this and would have +# reported False. The sibling guard in ``cuda/variable_radius.py`` already used the wider +# clause. +try: + from .cuda.fused import fused_add_to_map_gpu + + # Appended rather than listed unconditionally. ``fused_add_to_map_gpu`` is bound only + # if the import succeeded, so naming it in a static ``__all__`` made + # ``from torchref.base.electron_density.kernels import *`` raise AttributeError on any + # host without Triton. + __all__.append("fused_add_to_map_gpu") +except Exception: # pragma: no cover - depends on the host's triton install + pass diff --git a/torchref/base/electron_density/kernels/cpu/aniso.py b/torchref/base/electron_density/kernels/cpu/aniso.py deleted file mode 100644 index 7dbb879..0000000 --- a/torchref/base/electron_density/kernels/cpu/aniso.py +++ /dev/null @@ -1,63 +0,0 @@ -"""Anisotropic Gaussian box-splat for CPU / MPS (``Engine.AUTO``). - -Mirrors the isotropic separable box-splat (center index -> local voxel cube -> -per-axis ``d_frac`` -> structured scatter) but evaluates the full 3D anisotropic -Gaussian over the cube (the cross-terms U12/U13/U23 prevent 1D factorization). -""" - -import math - -import torch - -from torchref.config import dtypes - - -def _aniso_density_cube(d_frac, frac_matrix, Minv, A_norm): - """Anisotropic Gaussian density over the local voxel cube. - - The aniso analogue of ``_separable_density`` — but the 3D Gaussian - does NOT factorize across axes (cross-terms U12/U13/U23), so it builds the - full Cartesian offset cube and evaluates the quadratic form directly. The - component loop keeps peak memory at O(C*n^3). - - Parameters - ---------- - d_frac : (C, 3, n) — PBC-wrapped fractional offsets per axis. - frac_matrix : (3, 3) — fractional -> Cartesian. - Minv : (C, 5, 3, 3) — inverse of M_g = (B_g*I + 8*pi^2*U)/4. - A_norm : (C, 5) — A * occ * pi^1.5 / sqrt(det M_g). - - Returns - ------- - (C, n, n, n) density cube. - """ - pi_sq = math.pi * math.pi - da = d_frac[:, 0, :][:, :, None, None] # (C, n, 1, 1) - db = d_frac[:, 1, :][:, None, :, None] # (C, 1, n, 1) - dc = d_frac[:, 2, :][:, None, None, :] # (C, 1, 1, n) - fm = frac_matrix - # Cartesian offset cube r = frac_matrix @ d_frac (each (C, n, n, n)) - cx = fm[0, 0] * da + fm[0, 1] * db + fm[0, 2] * dc - cy = fm[1, 0] * da + fm[1, 1] * db + fm[1, 2] * dc - cz = fm[2, 0] * da + fm[2, 1] * db + fm[2, 2] * dc - - C = d_frac.shape[0] - n = d_frac.shape[2] - density_cube = d_frac.new_zeros(C, n, n, n) - for g in range(Minv.shape[1]): - m00 = Minv[:, g, 0, 0][:, None, None, None] - m11 = Minv[:, g, 1, 1][:, None, None, None] - m22 = Minv[:, g, 2, 2][:, None, None, None] - m01 = Minv[:, g, 0, 1][:, None, None, None] - m02 = Minv[:, g, 0, 2][:, None, None, None] - m12 = Minv[:, g, 1, 2][:, None, None, None] - q = ( - m00 * cx * cx - + m11 * cy * cy - + m22 * cz * cz - + 2.0 * (m01 * cx * cy + m02 * cx * cz + m12 * cy * cz) - ) - density_cube = density_cube + A_norm[:, g, None, None, None] * torch.exp( - -pi_sq * q - ) - return density_cube diff --git a/torchref/base/electron_density/kernels/cpu/scatter.py b/torchref/base/electron_density/kernels/cpu/scatter.py deleted file mode 100644 index e39e43c..0000000 --- a/torchref/base/electron_density/kernels/cpu/scatter.py +++ /dev/null @@ -1,558 +0,0 @@ -"""Parallel partitioned scatter_add for structured (wa + wbwc) indices. - -This kernel is float32-only by design: the C++ kernel hardcodes -``torch::kFloat32`` for the density/output buffers. The Python wrapper -auto-casts non-float32 inputs to float32 and emits a one-time warning per -input dtype — so callers running with ``dtypes.float = torch.float64`` keep -working, just at float32 precision through this kernel. - -Uses a C++ kernel compiled via torch.utils.cpp_extension.load_inline. -Each thread owns a contiguous output partition and only accumulates scatter -elements that fall in its range. With atoms sorted by 1D center, per-thread -early-exit skips ~(T-1)/T of atoms — giving near-linear scaling. - -Threading backend is selected at compile time: - * OpenMP on Linux (and any compiler that accepts -fopenmp) - * std::thread fallback otherwise (notably Apple Clang on macOS, whose - -fopenmp is rejected because the OpenMP runtime is not bundled) - -Two index dtypes are exposed: - * int32 — default fast path. Fits any realistic crystallographic grid - (nx*ny*nz < 2**31, roughly 1300^3 voxels) and halves the - index bandwidth of the inner loop. - * int64 — fallback for the rare edge cases that exceed INT32_MAX - (e.g. very large cryo-EM grids). -The Python wrapper picks the binding from wa.dtype. - -Autograd: forward is the custom C++ scatter, backward is a standard gather -(embarrassingly parallel, no custom kernel needed). -""" - -import warnings - -import torch - -from torchref.base.electron_density.kernels.cpu._cpp_build import build_extension - -_WARNED_CAST_DTYPES: set = set() - -_CPP_SRC = r""" -#include -#include - -#ifdef _OPENMP -#include -#else -#include -#include -#endif - -// Parallel partitioned scatter_add from structured indices. -// -// Output space is divided among threads. Each thread only writes to its own -// [lo, hi) segment — zero synchronization. -// Atoms sorted by 1D center give efficient early-exit per thread. -// -// IdxT is int32_t (default fast path) or int64_t (for huge grids). -template -torch::Tensor structured_scatter_add_impl( - torch::Tensor output, - torch::Tensor wa, - torch::Tensor wbwc, - torch::Tensor values, - int64_t nx, int64_t ny, int64_t nz) -{ - TORCH_CHECK(output.is_contiguous() && output.scalar_type() == torch::kFloat32); - TORCH_CHECK(values.is_contiguous() && values.scalar_type() == torch::kFloat32); - TORCH_CHECK(wa.is_contiguous()); - TORCH_CHECK(wbwc.is_contiguous()); - - const int64_t M = output.size(0); - const int64_t C = wa.size(0); - const int64_t ny_nz = ny * nz; - const int64_t nxyz = nx * ny_nz; - - float* __restrict__ out_p = output.data_ptr(); - const IdxT* __restrict__ wa_p = wa.data_ptr(); - const IdxT* __restrict__ wbwc_p = wbwc.data_ptr(); - const float* __restrict__ val_p = values.data_ptr(); - - // Global wbwc bounds (for atom-level early exit) - IdxT wbwc_min = wbwc_p[0], wbwc_max = wbwc_p[0]; - for (int64_t i = 1; i < C * ny_nz; i++) { - IdxT v = wbwc_p[i]; - if (v < wbwc_min) wbwc_min = v; - if (v > wbwc_max) wbwc_max = v; - } - - auto worker = [&](int64_t lo, int64_t hi) { - for (int64_t c = 0; c < C; c++) { - const IdxT* wa_row = wa_p + c * nx; - - // Atom-level early exit: check wa range vs partition - IdxT amin = wa_row[0], amax = wa_row[0]; - for (int64_t i = 1; i < nx; i++) { - IdxT v = wa_row[i]; - if (v < amin) amin = v; - if (v > amax) amax = v; - } - if ((int64_t)amax + wbwc_max < lo || (int64_t)amin + wbwc_min >= hi) continue; - - const IdxT* wbwc_row = wbwc_p + c * ny_nz; - const float* val_base = val_p + c * nxyz; - - for (int64_t ix = 0; ix < nx; ix++) { - IdxT wa_val = wa_row[ix]; - // x-offset early exit - if ((int64_t)wa_val + wbwc_max < lo || (int64_t)wa_val + wbwc_min >= hi) continue; - - const float* val_ix = val_base + ix * ny_nz; - - for (int64_t iyz = 0; iyz < ny_nz; iyz++) { - int64_t idx = (int64_t)wa_val + (int64_t)wbwc_row[iyz]; - if (idx >= lo && idx < hi) { - out_p[idx] += val_ix[iyz]; - } - } - } - } - }; - -#ifdef _OPENMP - #pragma omp parallel - { - int tid = omp_get_thread_num(); - int nth = omp_get_num_threads(); - int64_t lo = (int64_t)tid * M / nth; - int64_t hi = (int64_t)(tid + 1) * M / nth; - worker(lo, hi); - } -#else - int nth = (int)std::thread::hardware_concurrency(); - if (nth < 1) nth = 1; - std::vector threads; - threads.reserve(nth); - for (int tid = 0; tid < nth; tid++) { - int64_t lo = (int64_t)tid * M / nth; - int64_t hi = (int64_t)(tid + 1) * M / nth; - threads.emplace_back(worker, lo, hi); - } - for (auto& t : threads) t.join(); -#endif - - return output; -} - -// Parallel structured gather (backward of scatter_add). -// -// grad_cube[c, ix, iyz] = grad_output[wa[c,ix] + wbwc[c,iyz]] -// -// Each atom's output is independent — parallelize over atoms directly. -// No index tensor allocation, no fancy indexing overhead. -// Note: the worker_gather lambda below is used only by the non-OpenMP branch; -// under _OPENMP the same loop body is duplicated inline in the parallel-for. -template -torch::Tensor structured_gather_impl( - torch::Tensor grad_output, - torch::Tensor wa, - torch::Tensor wbwc, - int64_t nx, int64_t ny, int64_t nz) -{ - TORCH_CHECK(grad_output.is_contiguous() && grad_output.scalar_type() == torch::kFloat32); - TORCH_CHECK(wa.is_contiguous()); - TORCH_CHECK(wbwc.is_contiguous()); - - const int64_t C = wa.size(0); - const int64_t ny_nz = ny * nz; - const int64_t nxyz = nx * ny_nz; - - auto grad_cube = torch::empty({C, nxyz}, grad_output.options()); - - const float* __restrict__ go_p = grad_output.data_ptr(); - const IdxT* __restrict__ wa_p = wa.data_ptr(); - const IdxT* __restrict__ wbwc_p = wbwc.data_ptr(); - float* __restrict__ gc_p = grad_cube.data_ptr(); - - auto worker_gather = [&](int64_t c_lo, int64_t c_hi) { - for (int64_t c = c_lo; c < c_hi; c++) { - const IdxT* wa_row = wa_p + c * nx; - const IdxT* wbwc_row = wbwc_p + c * ny_nz; - float* out_row = gc_p + c * nxyz; - - for (int64_t ix = 0; ix < nx; ix++) { - IdxT wa_val = wa_row[ix]; - float* dst = out_row + ix * ny_nz; - - for (int64_t iyz = 0; iyz < ny_nz; iyz++) { - dst[iyz] = go_p[(int64_t)wa_val + (int64_t)wbwc_row[iyz]]; - } - } - } - }; - -#ifdef _OPENMP - #pragma omp parallel for schedule(static) - for (int64_t c = 0; c < C; c++) { - const IdxT* wa_row = wa_p + c * nx; - const IdxT* wbwc_row = wbwc_p + c * ny_nz; - float* out_row = gc_p + c * nxyz; - - for (int64_t ix = 0; ix < nx; ix++) { - IdxT wa_val = wa_row[ix]; - float* dst = out_row + ix * ny_nz; - - for (int64_t iyz = 0; iyz < ny_nz; iyz++) { - dst[iyz] = go_p[(int64_t)wa_val + (int64_t)wbwc_row[iyz]]; - } - } - } -#else - int nth = (int)std::thread::hardware_concurrency(); - if (nth < 1) nth = 1; - std::vector threads; - threads.reserve(nth); - for (int tid = 0; tid < nth; tid++) { - int64_t c_lo = (int64_t)tid * C / nth; - int64_t c_hi = (int64_t)(tid + 1) * C / nth; - threads.emplace_back(worker_gather, c_lo, c_hi); - } - for (auto& t : threads) t.join(); -#endif - - return grad_cube; -} - -// Wrappers that enforce the matching dtype, so a wrong-dtype tensor gets a -// clear error instead of being reinterpreted by data_ptr(). -torch::Tensor structured_scatter_add_i32( - torch::Tensor output, torch::Tensor wa, torch::Tensor wbwc, torch::Tensor values, - int64_t nx, int64_t ny, int64_t nz) -{ - TORCH_CHECK(wa.scalar_type() == torch::kInt32, "wa must be int32"); - TORCH_CHECK(wbwc.scalar_type() == torch::kInt32, "wbwc must be int32"); - return structured_scatter_add_impl(output, wa, wbwc, values, nx, ny, nz); -} -torch::Tensor structured_scatter_add_i64( - torch::Tensor output, torch::Tensor wa, torch::Tensor wbwc, torch::Tensor values, - int64_t nx, int64_t ny, int64_t nz) -{ - TORCH_CHECK(wa.scalar_type() == torch::kInt64, "wa must be int64"); - TORCH_CHECK(wbwc.scalar_type() == torch::kInt64, "wbwc must be int64"); - return structured_scatter_add_impl(output, wa, wbwc, values, nx, ny, nz); -} -torch::Tensor structured_gather_i32( - torch::Tensor grad_output, torch::Tensor wa, torch::Tensor wbwc, - int64_t nx, int64_t ny, int64_t nz) -{ - TORCH_CHECK(wa.scalar_type() == torch::kInt32, "wa must be int32"); - TORCH_CHECK(wbwc.scalar_type() == torch::kInt32, "wbwc must be int32"); - return structured_gather_impl(grad_output, wa, wbwc, nx, ny, nz); -} -torch::Tensor structured_gather_i64( - torch::Tensor grad_output, torch::Tensor wa, torch::Tensor wbwc, - int64_t nx, int64_t ny, int64_t nz) -{ - TORCH_CHECK(wa.scalar_type() == torch::kInt64, "wa must be int64"); - TORCH_CHECK(wbwc.scalar_type() == torch::kInt64, "wbwc must be int64"); - return structured_gather_impl(grad_output, wa, wbwc, nx, ny, nz); -} - -PYBIND11_MODULE(TORCH_EXTENSION_NAME, m) { - m.def("structured_scatter_add_i32", &structured_scatter_add_i32, - "Partitioned scatter_add with int32 structured (wa, wbwc) indices"); - m.def("structured_scatter_add_i64", &structured_scatter_add_i64, - "Partitioned scatter_add with int64 structured (wa, wbwc) indices"); - m.def("structured_gather_i32", &structured_gather_i32, - "Structured gather (backward) with int32 indices"); - m.def("structured_gather_i64", &structured_gather_i64, - "Structured gather (backward) with int64 indices"); -} -""" - -# --------------------------------------------------------------------------- -# Lazy compilation. The build itself (POSIX lockf locking, per-microarchitecture -# build directory, ninja/GCC discovery, no -fopenmp on macOS) lives in -# ``_cpp_build.build_extension``, shared with ``sphere_splat.py`` so the two -# extensions cannot drift apart on cluster deployment details. -# --------------------------------------------------------------------------- -_module = None -_module_failed = False -# Captured diagnostic from the most recent failed compile attempt. Populated -# by _get_module() on failure so test/debug code can surface the underlying -# error instead of just "module not available". Format: (error_str, traceback_str). -_module_error: "tuple[str, str] | None" = None - - -def _get_module(): - global _module, _module_failed, _module_error - if _module is not None: - return _module - if _module_failed: - return None - _module, _module_error = build_extension("cpu_scatter", _CPP_SRC) - if _module is None: - _module_failed = True - return _module - - -# --------------------------------------------------------------------------- -# Autograd wrapper -# --------------------------------------------------------------------------- - - -class _StructuredScatterAdd(torch.autograd.Function): - """scatter_add with structured (wa, wbwc) indices. - - Forward: C++ partitioned scatter (parallel, no conflicts) - Backward: standard gather (embarrassingly parallel) - """ - - @staticmethod - def forward(ctx, density_cube, wa, wbwc, map_size): - # density_cube: (C, nx, ny, nz) — requires grad, must be float32 - # wa: (C, nx) — int32 or int64, no grad - # wbwc: (C, ny, nz) — same dtype as wa - if density_cube.dtype != torch.float32: - if density_cube.dtype not in _WARNED_CAST_DTYPES: - warnings.warn( - f"cpu_scatter is float32-only; casting density_cube from " - f"{density_cube.dtype} to float32 (precision will be reduced).", - stacklevel=3, - ) - _WARNED_CAST_DTYPES.add(density_cube.dtype) - density_cube = density_cube.to(torch.float32) - if wa.dtype != wbwc.dtype: - raise TypeError( - f"wa.dtype ({wa.dtype}) and wbwc.dtype ({wbwc.dtype}) must match" - ) - if wa.dtype == torch.int32: - if map_size > _INT32_MAX: - raise RuntimeError( - f"map_size {map_size} exceeds INT32_MAX ({_INT32_MAX}); " - "pass int64 indices for grids this large." - ) - scatter_fn_name = "structured_scatter_add_i32" - gather_fn_name = "structured_gather_i32" - elif wa.dtype == torch.int64: - scatter_fn_name = "structured_scatter_add_i64" - gather_fn_name = "structured_gather_i64" - else: - raise TypeError(f"wa.dtype must be int32 or int64, got {wa.dtype}") - - C, nx, ny, nz = density_cube.shape - ctx.save_for_backward(wa, wbwc) - ctx.cube_shape = density_cube.shape - ctx.gather_fn_name = gather_fn_name - - mod = _get_module() - if mod is None: - err = _module_error[0] if _module_error else "unknown reason" - raise RuntimeError( - f"C++ cpu_scatter module not available ({err}). " - "See torchref.base.electron_density.kernels.cpu.scatter._module_error for the full traceback." - ) - result = torch.zeros( - map_size, dtype=density_cube.dtype, device=density_cube.device - ) - getattr(mod, scatter_fn_name)( - result, - wa.contiguous(), - wbwc.reshape(C, ny * nz).contiguous(), - density_cube.reshape(C, nx * ny * nz).contiguous(), - nx, - ny, - nz, - ) - return result - - @staticmethod - def backward(ctx, grad_output): - wa, wbwc = ctx.saved_tensors - C, nx, ny, nz = ctx.cube_shape - # The scatter is linear, so its VJP is the adjoint gather. Routing - # through the ``_StructuredGather`` Function (rather than calling the - # C++ gather imperatively) keeps the operation in the autograd graph, - # so higher-order derivatives compose: gather's own backward is the - # scatter again (the two are mutual adjoints). Without this, a C++ - # gather returns a graph-less tensor and ``create_graph=True`` silently - # drops the second-order term through the scatter transpose. - grad_cube = _StructuredGather.apply(grad_output, wa, wbwc, ctx.cube_shape) - return grad_cube, None, None, None - - -class _StructuredGather(torch.autograd.Function): - """Adjoint of :class:`_StructuredScatterAdd` (structured gather). - - ``gather(grid)[c] = grid[scatter_index(c)]``. Linear, so its VJP is the - scatter. Pairing the two as mutual adjoints makes both double-(and higher-) - differentiable with no Hessian math. - """ - - @staticmethod - def forward(ctx, grid, wa, wbwc, cube_shape): - if grid.dtype != torch.float32: - if grid.dtype not in _WARNED_CAST_DTYPES: - warnings.warn( - f"cpu_scatter is float32-only; casting from {grid.dtype} to " - f"float32 (precision will be reduced).", - stacklevel=3, - ) - _WARNED_CAST_DTYPES.add(grid.dtype) - grid = grid.to(torch.float32) - C, nx, ny, nz = cube_shape - mod = _get_module() - if mod is None: - err = _module_error[0] if _module_error else "unknown reason" - raise RuntimeError( - f"C++ cpu_scatter module not available ({err}). " - "See torchref.base.electron_density.kernels.cpu.scatter._module_error for the full traceback." - ) - # int32 / int64 gather fn name matches the scatter's index dtype. - gather_fn_name = ( - "structured_gather_i32" - if wa.dtype == torch.int32 - else "structured_gather_i64" - ) - grad_cube = getattr(mod, gather_fn_name)( - grid.contiguous(), - wa.contiguous(), - wbwc.reshape(C, ny * nz).contiguous(), - nx, - ny, - nz, - ).reshape(C, nx, ny, nz) - ctx.save_for_backward(wa, wbwc) - ctx.map_size = int(grid.numel()) - return grad_cube - - @staticmethod - def backward(ctx, grad_cube): - wa, wbwc = ctx.saved_tensors - # Adjoint of the gather is the scatter — reuse the differentiable - # Function so a third-order graph would also compose. - grad_grid = _StructuredScatterAdd.apply(grad_cube, wa, wbwc, ctx.map_size) - return grad_grid, None, None, None - - -_INT32_MAX = 2**31 - 1 - - -def structured_scatter_add(density_cube, wa, wbwc, map_size): - """Differentiable parallel scatter_add using structured indices. - - Dispatches to the int32 or int64 kernel based on ``wa.dtype``. int32 is - the default fast path (halves index bandwidth); int64 is available for - grids larger than INT32_MAX voxels. - - Parameters - ---------- - density_cube : Tensor (C, nx, ny, nz) float32 - Values to scatter (from _separable_density). - wa : Tensor (C, nx) int32 or int64 - Precomputed x-axis scatter indices. - wbwc : Tensor (C, ny, nz) same dtype as wa - Precomputed yz-plane scatter indices. - map_size : int - Total number of voxels in flat density map. For int32 indices, - must be <= INT32_MAX. - - Returns - ------- - Tensor (map_size,) float32 - Scattered result. Differentiable w.r.t. density_cube. - """ - return _StructuredScatterAdd.apply(density_cube, wa, wbwc, map_size) - - -def structured_scatter_add_inplace(out_flat, density_cube, wa, wbwc): - """Low-level in-place structured scatter: ``out_flat[idx] += cube`` (no autograd). - - The raw building block for :class:`_ScatterAccumulate`. The C++ kernel already - does ``out[idx] += ...``, so passing ``out_flat`` straight in as the output - buffer accumulates in place — no fresh ``zeros(map_size)`` and no out-of-place - ``out + result`` add (the two full-grid touches per chunk that the functional - :func:`structured_scatter_add` incurs). - - Returns ``out_flat`` (mutated), or ``None`` if the C++ module is unavailable - (caller should fall back to the functional path). - """ - mod = _get_module() - if mod is None: - return None - if density_cube.dtype != torch.float32: - density_cube = density_cube.to(torch.float32) - C, nx, ny, nz = density_cube.shape - if wa.dtype == torch.int32: - if out_flat.numel() > _INT32_MAX: - raise RuntimeError( - f"map_size {out_flat.numel()} exceeds INT32_MAX ({_INT32_MAX}); " - "pass int64 indices for grids this large." - ) - fn_name = "structured_scatter_add_i32" - elif wa.dtype == torch.int64: - fn_name = "structured_scatter_add_i64" - else: - raise TypeError(f"wa.dtype must be int32 or int64, got {wa.dtype}") - getattr(mod, fn_name)( - out_flat, - wa.contiguous(), - wbwc.reshape(C, ny * nz).contiguous(), - density_cube.reshape(C, nx * ny * nz).contiguous(), - nx, - ny, - nz, - ) - return out_flat - - -class _ScatterAccumulate(torch.autograd.Function): - """Differentiable in-place accumulating scatter: ``out += scatter(cube)``. - - ``out_flat`` is mutated in place (one shared buffer accumulated across chunks), - so there is NO per-chunk ``zeros(map_size)`` and NO per-chunk out-of-place - ``out + result`` add — the two full-grid touches that dominate scatter-bound, - many-chunk structures in the functional path. - - Correctness under autograd: the scatter is linear, so for ``out = out_in + - scatter(cube)`` the VJP is ``grad_out`` w.r.t. ``out_in`` (identity) and - ``gather(grad_out)`` w.r.t. ``cube``. Crucially the backward needs ONLY the - (un-mutated) indices ``wa``/``wbwc`` and ``grad_out`` — never the mutated - buffer — so the in-place version bump invalidates no saved tensor and the - chunk-to-chunk in-place chain differentiates correctly. The gather is routed - through :class:`_StructuredGather` so higher-order graphs still compose. - """ - - @staticmethod - def forward(ctx, out_flat, density_cube, wa, wbwc): - if density_cube.dtype != torch.float32: - if density_cube.dtype not in _WARNED_CAST_DTYPES: - warnings.warn( - f"cpu_scatter is float32-only; casting density_cube from " - f"{density_cube.dtype} to float32 (precision will be reduced).", - stacklevel=3, - ) - _WARNED_CAST_DTYPES.add(density_cube.dtype) - density_cube = density_cube.to(torch.float32) - ctx.save_for_backward(wa, wbwc) - ctx.cube_shape = density_cube.shape - structured_scatter_add_inplace(out_flat, density_cube, wa, wbwc) - ctx.mark_dirty(out_flat) - return out_flat - - @staticmethod - def backward(ctx, grad_out): - wa, wbwc = ctx.saved_tensors - # d out / d out_in = identity -> grad_out passes through unchanged. - # d out / d cube = adjoint gather of grad_out. - grad_cube = _StructuredGather.apply(grad_out, wa, wbwc, ctx.cube_shape) - return grad_out, grad_cube, None, None - - -def structured_scatter_accumulate(out_flat, density_cube, wa, wbwc): - """Differentiable ``out_flat += scatter(density_cube)``, accumulated in place. - - Returns the (mutated) ``out_flat``. Differentiable w.r.t. both ``out_flat`` and - ``density_cube``. Use in the chunk loop to accumulate every chunk into one - shared buffer with no per-chunk full-grid allocation or add. - """ - return _ScatterAccumulate.apply(out_flat, density_cube, wa, wbwc) diff --git a/torchref/base/electron_density/kernels/cpu/scatter_dispatch.py b/torchref/base/electron_density/kernels/cpu/scatter_dispatch.py deleted file mode 100644 index 6975069..0000000 --- a/torchref/base/electron_density/kernels/cpu/scatter_dispatch.py +++ /dev/null @@ -1,80 +0,0 @@ -"""Device-aware structured scatter for the box-splat density kernels. - -On CPU, dispatches to the custom C++ parallel scatter (``cpu_scatter``) when the -extension built successfully; otherwise (and on every non-CPU device) falls back -to PyTorch's ``scatter_add_``. -""" - -import torch - -# Lazy-loaded C++ parallel scatter for CPU -_cpp_scatter_fn = None -_cpp_scatter_accumulate_fn = None -_cpp_scatter_checked = False - - -def _get_cpp_scatter(): - """Return the C++ parallel scatter_add, or None if unavailable. - - Eagerly triggers the C++ compilation so that failures (missing ninja, - unsupported compiler flags, etc.) are caught here rather than mid-calculation. - Also caches the in-place variant used by the no-grad fast path. - """ - global _cpp_scatter_fn, _cpp_scatter_accumulate_fn, _cpp_scatter_checked - if not _cpp_scatter_checked: - try: - from torchref.base.electron_density.kernels.cpu.scatter import ( - _get_module, - structured_scatter_accumulate, - structured_scatter_add, - ) - - # Trigger compilation now — _get_module returns None on failure - if _get_module() is not None: - _cpp_scatter_fn = structured_scatter_add - _cpp_scatter_accumulate_fn = structured_scatter_accumulate - except Exception: - pass - _cpp_scatter_checked = True - return _cpp_scatter_fn - - -def _do_structured_scatter( - density_cube: torch.Tensor, - wa: torch.Tensor, - wbwc: torch.Tensor, - density_flat: torch.Tensor, - map_size: int, -) -> torch.Tensor: - """Pick the fastest structured scatter for the device. - - On CPU, dispatches to the custom C++ kernel (``cpu_scatter``) when - available — partitioned, no atomics, ~2× faster than PyTorch's stock - ``scatter_add_``. On every other device (MPS, CUDA, CPU without the - extension built) falls back to PyTorch ``scatter_add_`` with int64 - indices. - - Returns the resulting flat density tensor. The C++ path accumulates - out-of-place; the ``scatter_add_`` fallback mutates ``density_flat`` - in place. Both return the up-to-date tensor. - """ - if density_cube.device.type == "cpu": - cpp_fn = _get_cpp_scatter() - if cpp_fn is not None: - # Differentiable in-place accumulate: density_flat += scatter(cube), - # mutating one shared buffer across chunks. Avoids the per-chunk - # zeros(map_size) + out-of-place full-grid add (two full-grid touches - # per chunk) that the functional path incurs and that dominate - # scatter-bound, many-chunk structures. Gradients are preserved - # (the scatter is linear; see _ScatterAccumulate). - if _cpp_scatter_accumulate_fn is not None: - return _cpp_scatter_accumulate_fn(density_flat, density_cube, wa, wbwc) - return density_flat + cpp_fn(density_cube, wa, wbwc, map_size) - # Fallback: PyTorch scatter_add_ requires int64 indices. - idx_flat = wa[:, :, None, None] + wbwc[:, None, :, :] - density_flat.scatter_add_( - 0, - idx_flat.reshape(-1).to(torch.int64), - density_cube.reshape(-1), - ) - return density_flat diff --git a/torchref/base/electron_density/kernels/cpu/separable.py b/torchref/base/electron_density/kernels/cpu/separable.py deleted file mode 100644 index 99ee87d..0000000 --- a/torchref/base/electron_density/kernels/cpu/separable.py +++ /dev/null @@ -1,155 +0,0 @@ -"""Separable Gaussian box-splat for isotropic atoms (CPU / MPS shared core). - -Factorizes exp(-alpha * r^T G r) into 1D Gaussians per axis with 2D cross-term -corrections for non-orthogonal cells, keeping peak memory low. ``_separable_density`` -is the shared core reused by the variable-radius CPU splat -(``cpu/variable_radius.py::add_isotropic_cpu_separable_var``). -""" - -import math - -import torch - -from torchref.config import dtypes - -# Peak-element budget for the triclinic general path's batched (C, N_comp, n^3) -# exp intermediate. Atoms are sub-batched so this is the memory ceiling -# regardless of the caller's chunk size. 16M floats ~= 64 MB. -_GENERAL_BUDGET = 16_000_000 - - -def _separable_density( - d_frac: torch.Tensor, - alpha: torch.Tensor, - A_norm: torch.Tensor, - G: torch.Tensor, - has_ab: bool, - has_ac: bool, - has_bc: bool, -) -> torch.Tensor: - """Separable Gaussian density evaluation. - - Factorizes exp(-alpha * r^T G r) into 1D Gaussians per axis with 2D - cross-term corrections for non-orthogonal cells. Batches all corrections - across the 5 ITC92 components and uses einsum where possible. - - For non-orthogonal cells, cross-term exponents are combined with the - relevant diagonal exponents before taking exp() to avoid float32 overflow - (exp(-big) * exp(+big) = 0 * inf = NaN). Each combined 2D block - exponent corresponds to a principal sub-matrix of G (positive definite), - guaranteeing the exponent is always <= 0 and exp() is in (0, 1]. - - Dispatch by crystal system for optimal performance: - - Orthogonal (no cross terms): separable 1D products + einsum - - Hexagonal (ab only): combined ab exponent + einsum with e_c - - Monoclinic (ac only): combined ac exponent + einsum with e_b - - General (bc, or multiple cross terms): full 3D exponent per component - - Parameters - ---------- - d_frac : (C, 3, n_axis) — fractional distances per axis, PBC-wrapped. - alpha : (C, N_comp) — pi^2 / B_total. - A_norm : (C, N_comp) — weighted amplitudes. - G : (3, 3) — metric tensor frac_matrix.T @ frac_matrix. - has_ab : bool — whether G[0,1] cross-term is significant. - has_ac : bool — whether G[0,2] cross-term is significant. - has_bc : bool — whether G[1,2] cross-term is significant. - - Returns - ------- - (C, n_axis, n_axis, n_axis) density cube. - """ - # --- Convert fractional → Cartesian per-axis --- - cell_lengths = torch.sqrt(torch.diagonal(G)) - d_cart = d_frac * cell_lengths[None, :, None] # (C, 3, n) - - # --- 1D exponents (always <= 0) --- - da2 = d_cart[:, 0, :] ** 2 - db2 = d_cart[:, 1, :] ** 2 - dc2 = d_cart[:, 2, :] ** 2 - - log_a = -alpha.unsqueeze(2) * da2.unsqueeze(1) # (C, Nc, n) - log_b = -alpha.unsqueeze(2) * db2.unsqueeze(1) - log_c = -alpha.unsqueeze(2) * dc2.unsqueeze(1) - - if not (has_ab or has_ac or has_bc): - # ---- Orthogonal cells: pure separable, all exp() args <= 0 ---- - e_a = torch.exp(log_a) - e_b = torch.exp(log_b) - e_c = torch.exp(log_c) - e_ab = e_a.unsqueeze(3) * e_b.unsqueeze(2) - return torch.einsum("cg,cgij,cgk->cijk", A_norm, e_ab, e_c) - - # --- Cross-term coefficients --- - cos_gamma = G[0, 1] / (cell_lengths[0] * cell_lengths[1]) - cos_beta = G[0, 2] / (cell_lengths[0] * cell_lengths[2]) - cos_alpha = G[1, 2] / (cell_lengths[1] * cell_lengths[2]) - - da = d_cart[:, 0, :] - db = d_cart[:, 1, :] - dc = d_cart[:, 2, :] - alpha_4d = alpha[:, :, None, None] # (C, Nc, 1, 1) - - if has_ab and not has_ac and not has_bc: - # ---- Hexagonal / trigonal: only ab cross-term ---- - # Combined 2D exponent: -alpha*(da2 + db2 + 2*cos_gamma*da*db) - # = -alpha * d_ab^T G_ab d_ab <= 0 (G_ab positive definite) - prod_ab = da.unsqueeze(2) * db.unsqueeze(1) - log_ab = ( - log_a[:, :, :, None] - + log_b[:, :, None, :] - + (-2.0 * alpha_4d * cos_gamma * prod_ab[:, None, :, :]) - ) - slice_ab = torch.exp(log_ab) # (C, Nc, n, n), all in (0, 1] - e_c = torch.exp(log_c) - return torch.einsum("cg,cgij,cgk->cijk", A_norm, slice_ab, e_c) - - if has_ac and not has_ab and not has_bc: - # ---- Monoclinic (beta != 90): only ac cross-term ---- - # Combined 2D exponent: -alpha*(da2 + dc2 + 2*cos_beta*da*dc) - # = -alpha * d_ac^T G_ac d_ac <= 0 (G_ac positive definite) - prod_ac = da.unsqueeze(2) * dc.unsqueeze(1) - log_ac = ( - log_a[:, :, :, None] - + log_c[:, :, None, :] - + (-2.0 * alpha_4d * cos_beta * prod_ac[:, None, :, :]) - ) - e_ac = torch.exp(log_ac) # (C, Nc, n_a, n_c), all in (0, 1] - e_b = torch.exp(log_b) - return torch.einsum("cg,cgj,cgik->cijk", A_norm, e_b, e_ac) - - # ---- General path (triclinic, or multiple cross-terms) ---- - # The per-component exponent is -alpha[:, g] * Q, where the quadratic form - # Q = da^2 + db^2 + dc^2 + 2*cos_gamma*da*db + 2*cos_beta*da*dc + 2*cos_alpha*db*dc - # is the cell geometry (r^T G r in cell-length-normalized units) and is - # INDEPENDENT of the ITC92 component g (alpha[:, g] factors out entirely). - # So build Q ONCE (it is positive semidefinite -> -alpha*Q <= 0, no overflow), - # then evaluate exp(-alpha[:, g] * Q) for all components in a SINGLE batched - # exp + einsum rather than a per-component loop with repeated geometry rebuilds. - # The batched (C, N_comp, n, n, n) intermediate is 5x the cube, so atoms are - # processed in sub-batches sized to keep peak memory under _GENERAL_BUDGET - # floats (the result is identical to the loop, just fewer/larger exp calls -- - # ~1.5-2x faster on the triclinic path which is dominated by the exp). - C = d_frac.shape[0] - n = d_frac.shape[2] - Nc = alpha.shape[1] - sub = max(1, _GENERAL_BUDGET // max(1, Nc * n * n * n)) - parts = [] - for s in range(0, C, sub): - e = min(s + sub, C) - Q = ( - da2[s:e, :, None, None] - + db2[s:e, None, :, None] - + dc2[s:e, None, None, :] - ) # (c, n, n, n) — broadcasting materializes a full contiguous cube - if has_ab: - Q = Q + (2.0 * cos_gamma * da[s:e].unsqueeze(2) * db[s:e].unsqueeze(1)).unsqueeze(3) - if has_ac: - Q = Q + (2.0 * cos_beta * da[s:e].unsqueeze(2) * dc[s:e].unsqueeze(1)).unsqueeze(2) - if has_bc: - Q = Q + (2.0 * cos_alpha * db[s:e].unsqueeze(2) * dc[s:e].unsqueeze(1)).unsqueeze(1) - # (c, N_comp, n, n, n): one exp over all components, then weighted sum. - comp = torch.exp(-alpha[s:e, :, None, None, None] * Q.unsqueeze(1)) - parts.append(torch.einsum("cg,cgijk->cijk", A_norm[s:e], comp)) - - return parts[0] if len(parts) == 1 else torch.cat(parts, dim=0) diff --git a/torchref/base/electron_density/kernels/cpu/sphere_splat.py b/torchref/base/electron_density/kernels/cpu/sphere_splat.py index bee2a73..75b5ab6 100644 --- a/torchref/base/electron_density/kernels/cpu/sphere_splat.py +++ b/torchref/base/electron_density/kernels/cpu/sphere_splat.py @@ -17,10 +17,9 @@ Why fused rather than the older grouped-separable splat ------------------------------------------------------ -The separable kernel (``variable_radius.py::add_isotropic_cpu_separable_var``, -still present but no longer dispatched) factorizes the Gaussian into 1D per-axis -exponentials, which needs a *uniform box per launch* -- hence a cube cutoff and all -the bucket-by-box-size machinery. Measured on 4000 atoms / 2.3M voxels / 0.40 A +The separable kernel this replaced (since deleted) factorized the Gaussian into 1D +per-axis exponentials, which needs a *uniform box per launch* -- hence a cube cutoff and +all the bucket-by-box-size machinery. Measured on 4000 atoms / 2.3M voxels / 0.40 A sampling, the cube touches 19.3M voxels where the sphere touches 8.3M, and materializing the ``(C,L,L,L)`` intermediate cube dominates the runtime: this fused kernel is ~1.5x faster than the separable one even using ``std::exp``, and ~2.5x @@ -532,36 +531,31 @@ def _get_module(): return _module -def sphere_splat_available() -> bool: - """Whether the fused CPU splat compiled and is ready to dispatch.""" - return _get_module() is not None - +def why_unavailable() -> Optional[str]: + """``None`` if the fused CPU splat is usable, else why it is not. -def should_use_sphere_splat(*tensors: torch.Tensor) -> bool: - """Fused-vs-portable gate for the CPU density splat. + The single availability probe for this backend -- the shape every backend implements, + consumed by :mod:`torchref.utils.backends`. A missing compiler and a compile error are + different problems, and the captured diagnostic is the only thing that separates them. + """ + if _get_module() is not None: + return None + reason = _module_error[0] if _module_error else "unknown reason" + return ( + f"the fused CPU sphere_splat extension is not available ({reason}); see " + "torchref.base.electron_density.kernels.cpu.sphere_splat.last_error()" + ) - The CPU counterpart of ``should_use_triton`` / ``should_use_metal``: probes - device, dtype **and** extension availability together, so an uncompiled - extension is a predicate returning False rather than an exception at the - dispatch site. ``None`` entries are ignored. - Unlike the accelerator gates this one does not consult the ``Engine`` -- the - caller owns that, because ``Engine.EAGER`` must reach the portable splat. +def sphere_splat_available() -> bool: + """Whether the fused CPU splat compiled and is ready to dispatch. - Every tensor must be CPU and share one float32/float64 dtype: the kernel - reads raw pointers under a single ``AT_DISPATCH_FLOATING_TYPES``, so a mixed - set (e.g. a float64 map with float32 atoms) has to take the portable path, - which promotes as usual. + Derived from :func:`why_unavailable` rather than re-testing, so there is one + availability check here, not two that can drift. """ - present = [t for t in tensors if t is not None] - if not present: - return False - dtype = present[0].dtype - if dtype not in (torch.float32, torch.float64): - return False - if any(t.device.type != "cpu" or t.dtype is not dtype for t in present): - return False - return sphere_splat_available() + return why_unavailable() is None + + def warmup() -> bool: diff --git a/torchref/base/electron_density/kernels/cpu/variable_radius.py b/torchref/base/electron_density/kernels/cpu/variable_radius.py index 2944942..f717df7 100644 --- a/torchref/base/electron_density/kernels/cpu/variable_radius.py +++ b/torchref/base/electron_density/kernels/cpu/variable_radius.py @@ -1,40 +1,42 @@ -"""CPU per-atom variable-radius density splatting (grouped-separable / fused / aniso). - -Each atom is truncated at its own ``N_sigma * sigma_eff`` radius instead of a -single structure-wide radius. The separable CPU path factorizes -``exp(-alpha * r^T G r)`` into 1D per-axis Gaussians (O(r) exp calls, not O(r^3)) -which needs a *uniform* box per batch, so a per-atom radius forces grouping atoms -by box. Two choices keep the grouping cheap: - -* **Bucket by integer box size** ``box_radius = ceil(r_i / min_voxel)`` (NOT the - nominal 0.25-A radius): the kernels truncate by the box, so atoms rounding to - the same box are identical and share one launch. -* **One global sort by (box, center_1d)** -> each bucket is a contiguous, - cache-sorted slice; then **chunk atoms within a bucket** for L3 locality. - -Work drops from ``N * max_box^3`` to ``sum_bucket n * box^3`` while the -factorization is preserved. The per-voxel cores (``_separable_density``, -``_aniso_density_cube``) and the structured scatter are reused verbatim from the -single-radius kernels, so a single-radius plan reproduces them bit-for-bit. No -``torch.compile``. - -These functions ADD into the supplied ``density_map`` (so the isotropic and -anisotropic passes accumulate into the same map) and are autograd-connected in -xyz / adp / u / occ. +"""Portable per-atom variable-radius density splatting. + +Reached by ``Engine.EAGER`` on any device, by CUDA/MPS float64, and whenever the fused +C++ kernel could not be built. Plain ``scatter_add`` only, so it runs on every device, +supports float64, and is double-differentiable -- which makes it the reference the +accelerator kernels are checked against. + +One truncation contract, shared with the Triton, Metal and fused-CPU kernels, so AUTO +and EAGER agree to float noise on every device: + + voxel v gets atom i's density iff ``||w||^2 <= r_i^2``, where ``w`` is the Cartesian + atom->voxel vector (sphere centred on the ATOM, not on its anchor node) and ``r_i`` + is the raw radius_policy radius, + +enumerated over the triclinic-correct per-axis box +``ceil(r * n_axis * ||inv_frac row_axis||)``. See ``sphere_splat.py`` for the canonical +statement. + +These used to diverge from that in three ways at once: the iso path selected voxels by +``||offset * voxel_size||`` -- a diagonal metric, wrong for any non-orthogonal cell -- +measured from the anchor node rather than from the atom, at a radius rounded up to a +whole voxel; and the aniso path splatted a full cube. On a beta=115 deg cell that +mis-selected ~12% of each sphere's voxels, a 5e-3 rel L2 map error, i.e. larger than the +1.7e-3 truncation error the cutoff exists to deliver. + +Out-of-sphere voxels are zeroed rather than dropped, keeping the box dense so one +``scatter_add`` covers the chunk. That wastes some writes, which is the right trade for a +portable reference. + +These functions ADD into the supplied ``density_map`` (so the isotropic and anisotropic +passes accumulate into the same map) and are autograd-connected in xyz / adp / u / occ. """ from __future__ import annotations import math -from typing import List, Tuple import torch -from torchref.config import dtypes -from torchref.base.electron_density.kernels.offsets import _get_radius_offsets -from torchref.base.electron_density.kernels.cpu.separable import _separable_density -from torchref.base.electron_density.kernels.cpu.aniso import _aniso_density_cube -from torchref.base.electron_density.kernels.cpu.scatter_dispatch import _do_structured_scatter from torchref.base.electron_density.radius_policy import _u6_to_u3 _PI = math.pi @@ -44,150 +46,6 @@ _CHUNK = 1024 -# ========================================================================= -# Shared grouping helpers -# ========================================================================= -def _cross_term_flags(G: torch.Tensor) -> Tuple[bool, bool, bool]: - tol = 1e-3 * torch.norm(torch.diagonal(G)) - return ( - bool(torch.abs(G[0, 1]) > tol), - bool(torch.abs(G[0, 2]) > tol), - bool(torch.abs(G[1, 2]) > tol), - ) - - -def _box_radius_per_atom(radius: torch.Tensor, voxel_size: torch.Tensor) -> torch.Tensor: - """Integer box radius per atom = ceil(r_i / min_voxel) -- the true bucket key.""" - min_voxel = float(voxel_size.min()) - return torch.ceil(radius / min_voxel).to(torch.int64) - - -def _bucket_by_box(box_radius: torch.Tensor, center_1d: torch.Tensor): - """Sort atoms by (box_radius, center_1d); return (order, [(box_radius, start, end)]). - - ONE global sort -> each distinct-box bucket is a contiguous, cache-sorted slice. - """ - uniq = torch.unique(box_radius) - order_parts, spans, cursor = [], [], 0 - for b in uniq.tolist(): - idx = (box_radius == b).nonzero(as_tuple=True)[0] - idx = idx[torch.argsort(center_1d[idx])] - order_parts.append(idx) - spans.append((int(b), cursor, cursor + idx.numel())) - cursor += idx.numel() - order = (torch.cat(order_parts) if order_parts - else box_radius.new_zeros(0, dtype=torch.long)) - return order, spans - - -def _axis_offsets(box_radius: int, device, int_dtype): - return torch.arange(-box_radius, box_radius + 1, device=device).to(int_dtype) - - -# ========================================================================= -# Isotropic separable (Engine.AUTO CPU path) -# ========================================================================= -def _splat_chunked(density_flat, map_size, G, flags, inv_grid, grid_dims, device, dtype, - xyz_frac, center_idx, alpha, A_norm, spans, chunk=_CHUNK): - """Bucket loop (per box size) x chunk loop: factorized cube + structured scatter.""" - nx, ny, nz = grid_dims - ny_nz = ny * nz - for box_radius, b0, b1 in spans: - axis = _axis_offsets(box_radius, device, dtypes.int) - axis_frac = axis.to(dtype).unsqueeze(0) * inv_grid.unsqueeze(1) - for s in range(b0, b1, chunk): - e = min(s + chunk, b1) - ci = center_idx[s:e] - center_frac = ci.to(dtype) * inv_grid - sub = xyz_frac[s:e] - center_frac - d_frac = axis_frac.unsqueeze(0) - sub.unsqueeze(2) - d_frac = d_frac - torch.round(d_frac) - cube = _separable_density(d_frac, alpha[s:e], A_norm[s:e], G, *flags) - wa = (ci[:, 0:1] + axis.unsqueeze(0)) % nx * ny_nz - wb = (ci[:, 1:2] + axis.unsqueeze(0)) % ny * nz - wc = (ci[:, 2:3] + axis.unsqueeze(0)) % nz - wbwc = wb.unsqueeze(2) + wc.unsqueeze(1) - density_flat = _do_structured_scatter(cube, wa, wbwc, density_flat, map_size) - return density_flat - - -def _iso_setup(xyz, adp, occ, A, B, inv_frac, frac, grid_shape, voxel_size, - radius_per_atom): - """Per-atom + shared setup; returns sorted tensors + bucket spans.""" - device, dtype = xyz.device, xyz.dtype - nx, ny, nz = grid_shape - ny_nz = ny * nz - grid_f = torch.tensor(grid_shape, device=device, dtype=dtype) - inv_grid = 1.0 / grid_f - - G = frac.T @ frac - flags = _cross_term_flags(G) - - xyz_frac = xyz @ inv_frac.T - center_idx = torch.round((xyz_frac % 1.0) * grid_f).to(dtypes.int) - B_total = ((B + adp[:, None]) * 0.25).clamp(min=0.1) - A_norm = A * occ[:, None] * _PI_1P5 / (B_total * torch.sqrt(B_total)) - alpha = _PI_SQ / B_total - - box_radius = _box_radius_per_atom(radius_per_atom, voxel_size) - center_1d = (center_idx[:, 0].long() * ny_nz - + center_idx[:, 1].long() * nz + center_idx[:, 2].long()) - order, spans = _bucket_by_box(box_radius, center_1d) - return dict( - G=G, flags=flags, inv_grid=inv_grid, grid_dims=(nx, ny, nz), - xyz_frac=xyz_frac[order], center_idx=center_idx[order], - alpha=alpha[order], A_norm=A_norm[order], B_total=B_total[order], - spans=spans, - ) - - -def add_isotropic_cpu_separable_var(density_map, xyz, adp, occ, A, B, - inv_frac_matrix, frac_matrix, grid_shape_tuple, - voxel_size, radius_per_atom): - """Variable-radius grouped-separable isotropic splat; adds into ``density_map``.""" - nx, ny, nz = grid_shape_tuple - map_size = nx * ny * nz - st = _iso_setup(xyz, adp, occ, A, B, inv_frac_matrix, frac_matrix, - grid_shape_tuple, voxel_size, radius_per_atom) - density_flat = density_map.reshape(-1) - density_flat = _splat_chunked( - density_flat, map_size, st["G"], st["flags"], st["inv_grid"], - st["grid_dims"], xyz.device, xyz.dtype, - st["xyz_frac"], st["center_idx"], st["alpha"], st["A_norm"], st["spans"], - ) - return density_flat.view(nx, ny, nz) - - -# ========================================================================= -# Portable canonical-sphere splats -# ========================================================================= -# Reached by ``Engine.EAGER`` on any device, by CUDA/MPS float64, and whenever the -# fused C++ kernel could not be built. They implement the SAME truncation contract -# as the Triton, Metal and fused-CPU kernels, so AUTO and EAGER agree to float -# noise on every device: -# -# voxel v gets atom i's density iff ||w||^2 <= r_i^2, where w is the Cartesian -# atom->voxel vector (sphere centred on the ATOM, not on its anchor node) and -# r_i is the raw radius_policy radius, -# -# enumerated over the triclinic-correct per-axis box -# ``ceil(r * n_axis * ||inv_frac row_axis||)``. See ``sphere_splat.py`` for the -# canonical statement. -# -# These used to diverge from that in three ways at once: the iso path selected -# voxels by ``||offset * voxel_size||`` -- a diagonal metric, wrong for any -# non-orthogonal cell -- measured from the anchor node rather than from the atom, -# at a radius rounded up to a whole voxel; and the aniso path splatted a full cube. -# On a beta=115 deg cell that mis-selected ~12% of each sphere's voxels, a 5e-3 rel -# L2 map error, i.e. larger than the 1.7e-3 truncation error the cutoff exists to -# deliver. -# -# Out-of-sphere voxels are zeroed rather than dropped, keeping the box dense so one -# ``scatter_add`` covers the chunk. That wastes some writes, which is the right -# trade for the portable reference: plain ``scatter_add`` only, so it runs on every -# device, supports float64, and is double-differentiable. - - def _bucket_by_radius(radius: torch.Tensor, center_1d: torch.Tensor): """Sort atoms by (radius, center_1d); return ``(order, [(radius, start, end)])``. @@ -293,78 +151,6 @@ def add_isotropic_plain_var(density_map, xyz, adp, occ, A, B, return density_flat.view(nx, ny, nz) -# ========================================================================= -# Anisotropic box-splat (full 3D Gaussian, no factorization) -# ========================================================================= -def _splat_chunked_aniso(density_flat, map_size, frac, inv_grid, grid_dims, device, dtype, - xyz_frac, center_idx, Minv, A_norm, spans, chunk=_CHUNK): - """Per box-bucket x chunk: full 3D aniso cube (`_aniso_density_cube`) + scatter.""" - nx, ny, nz = grid_dims - ny_nz = ny * nz - for box_radius, b0, b1 in spans: - axis = _axis_offsets(box_radius, device, dtypes.int) - axis_frac = axis.to(dtype).unsqueeze(0) * inv_grid.unsqueeze(1) - for s in range(b0, b1, chunk): - e = min(s + chunk, b1) - ci = center_idx[s:e] - sub = xyz_frac[s:e] - ci.to(dtype) * inv_grid - d_frac = axis_frac.unsqueeze(0) - sub.unsqueeze(2) - d_frac = d_frac - torch.round(d_frac) - cube = _aniso_density_cube(d_frac, frac, Minv[s:e], A_norm[s:e]) - wa = (ci[:, 0:1] + axis.unsqueeze(0)) % nx * ny_nz - wb = (ci[:, 1:2] + axis.unsqueeze(0)) % ny * nz - wc = (ci[:, 2:3] + axis.unsqueeze(0)) % nz - wbwc = wb.unsqueeze(2) + wc.unsqueeze(1) - density_flat = _do_structured_scatter(cube, wa, wbwc, density_flat, map_size) - return density_flat - - -def _aniso_setup(xyz, u, occ, A, B, inv_frac, frac, grid_shape, voxel_size, - radius_per_atom): - """Per-atom aniso M/Minv/A_norm + box-size buckets.""" - device, dtype = xyz.device, xyz.dtype - nx, ny, nz = grid_shape - ny_nz = ny * nz - grid_f = torch.tensor(grid_shape, device=device, dtype=dtype) - inv_grid = 1.0 / grid_f - N = xyz.shape[0] - - xyz_frac = xyz @ inv_frac.T - center_idx = torch.round((xyz_frac % 1.0) * grid_f).to(dtypes.int) - eye = torch.eye(3, dtype=dtype, device=device) - U3 = _u6_to_u3(u) - M = (B[:, :, None, None] * eye + _EIGHT_PI_SQ * U3[:, None, :, :]) / 4.0 # (N,5,3,3) - Minv = torch.linalg.inv(M) - det = torch.linalg.det(M).clamp(min=1e-10) - A_norm = A * occ[:, None] * _PI_1P5 / torch.sqrt(det) # (N,5) - - box_radius = _box_radius_per_atom(radius_per_atom, voxel_size) - center_1d = (center_idx[:, 0].long() * ny_nz - + center_idx[:, 1].long() * nz + center_idx[:, 2].long()) - order, spans = _bucket_by_box(box_radius, center_1d) - return dict( - frac=frac, inv_grid=inv_grid, grid_dims=(nx, ny, nz), - xyz_frac=xyz_frac[order], center_idx=center_idx[order], - Minv=Minv[order], A_norm=A_norm[order], spans=spans, - ) - - -def add_anisotropic_cpu_var(real_space_grid, density_map, xyz, u, occ, A, B, - inv_frac_matrix, frac_matrix, radius_per_atom, voxel_size): - """Variable-radius grouped anisotropic box-splat; adds into ``density_map``.""" - nx, ny, nz = real_space_grid.shape[:3] - map_size = nx * ny * nz - st = _aniso_setup(xyz, u, occ, A, B, inv_frac_matrix, frac_matrix, - (nx, ny, nz), voxel_size, radius_per_atom) - density_flat = density_map.reshape(-1) - density_flat = _splat_chunked_aniso( - density_flat, map_size, st["frac"], st["inv_grid"], st["grid_dims"], - xyz.device, xyz.dtype, st["xyz_frac"], st["center_idx"], - st["Minv"], st["A_norm"], st["spans"], - ) - return density_flat.view(nx, ny, nz) - - def add_anisotropic_plain_var(density_map, xyz, u, occ, A, B, inv_frac_matrix, frac_matrix, radius_per_atom): """Portable canonical-sphere anisotropic splat; adds into ``density_map``. diff --git a/torchref/base/electron_density/kernels/cuda/variable_radius.py b/torchref/base/electron_density/kernels/cuda/variable_radius.py index 2bc2f60..755757c 100644 --- a/torchref/base/electron_density/kernels/cuda/variable_radius.py +++ b/torchref/base/electron_density/kernels/cuda/variable_radius.py @@ -837,6 +837,27 @@ def backward(ctx, grad_density_map): # back through here. +def why_unavailable(): + """``None`` if these kernels can run, else why they cannot. + + The reason-returning half of the availability protocol shared by every backend (see + :mod:`torchref.utils.backends`). + + This probe closes a real gap rather than restating ``triton_available()``. The + ``@triton.jit`` kernel bodies live inside ``if _HAVE_TRITON:`` above, but + ``WorkQueueGridDensity`` and these wrappers are defined *unconditionally*, so on a host + without Triton the module imports cleanly and the failure surfaces as a bare + ``NameError`` from deep inside ``_launch_grid_fwd``. Every other backend answers + "can I run" before being called; this one had nothing to ask. + """ + if not _HAVE_TRITON: + return ( + "triton is not importable, so the work-queue kernel bodies were never " + "compiled into this module" + ) + return None + + def _coeff_mask(xyz): """All-ones per-atom coefficient mask, shape ``(n, 5)``. diff --git a/torchref/base/electron_density/kernels/mps/compile.py b/torchref/base/electron_density/kernels/mps/compile.py index 8b319e3..cc0e702 100644 --- a/torchref/base/electron_density/kernels/mps/compile.py +++ b/torchref/base/electron_density/kernels/mps/compile.py @@ -65,9 +65,31 @@ def _get_lib(): return _lib +def why_unavailable() -> Optional[str]: + """``None`` if the Metal kernels are usable, else why they are not. + + The single availability probe for this backend -- the shape every backend implements, + consumed by :mod:`torchref.utils.backends`. It returns the *reason* rather than a bool + because a forced ``Engine.METAL`` has to explain itself, and "torch has no + ``compile_shader``" and "the MSL failed to compile" are different problems with + different fixes. + """ + if _get_lib() is not None: + return None + reason = _lib_error[0] if _lib_error else "unknown reason" + return ( + f"the Metal splat kernels are not available ({reason}); see " + "torchref.base.electron_density.kernels.mps.compile.last_error()" + ) + + def mps_kernels_available() -> bool: - """Whether the Metal splat kernels compiled and are ready to dispatch.""" - return _get_lib() is not None + """Whether the Metal splat kernels compiled and are ready to dispatch. + + Derived from :func:`why_unavailable` rather than re-testing, so there is one + availability check here, not two that can drift. + """ + return why_unavailable() is None def warmup() -> bool: diff --git a/torchref/base/electron_density/main.py b/torchref/base/electron_density/main.py index 639b3ee..c13831d 100644 --- a/torchref/base/electron_density/main.py +++ b/torchref/base/electron_density/main.py @@ -22,42 +22,25 @@ Engine dispatch --------------- -The capability-based ``Engine`` in :mod:`torchref.utils.triton_dispatch` -(AUTO/TRITON/METAL/EAGER) is the *only* switch selecting which kernel runs — there -is no environment-variable dispatch and no parallel "tier" knobs: - -- ``Engine.AUTO`` — fastest available per device: - CUDA+float32 -> the work-queue Triton kernels - (``WorkQueueGridDensity`` / ``WorkQueueGridDensityAniso``); - CPU float32 **or float64** -> the fused C++ spherical-cutoff splat - (``add_isotropic_cpu_sphere_var`` / ``add_anisotropic_cpu_sphere_var``); - MPS+float32 -> the native Metal kernels - (``add_isotropic_mps_var`` / ``add_anisotropic_mps_var``, compiled via - ``torch.mps.compile_shader``); everything else (CUDA/MPS float64) -> the - portable plain-scatter splat (``add_isotropic_plain_var`` / - ``add_anisotropic_plain_var``). On a Triton/Metal kernel failure or - unavailability under AUTO it falls through to the portable splat. -- ``Engine.EAGER`` — the portable plain-scatter splat on every device. - Double-differentiable; use it for Hessians / debugging. Force it with - ``with use_engine(Engine.EAGER): ...``. Because it now shares the truncation - contract above, AUTO-vs-EAGER is a genuine equivalence check rather than a - comparison of two different geometries. -- ``Engine.TRITON`` — force the CUDA work-queue Triton kernel (raises if not - CUDA+float32). -- ``Engine.METAL`` — force the native Metal kernel (raises if not MPS+float32, - or if the shader did not compile). Use it to benchmark or test the Metal path: - under ``AUTO`` a broken kernel degrades silently to the portable splat, so a - comparison against ``EAGER`` would pass while measuring nothing. +Which kernel runs is decided from a table, not from an if/elif ladder: see +:data:`torchref.base.electron_density._backends.DENSITY_BACKENDS`. That table is the only +place the criteria are written down -- device, dtype, which engines admit each backend, how +availability is probed, and whether a runtime failure may degrade -- so it is also the only +place to look, or to edit when adding a backend. The mechanics of reading it live in +:mod:`torchref.utils.backends`. + +The capability-based ``Engine`` (AUTO/TRITON/METAL/EAGER) is the *only* switch: no +environment-variable dispatch, no parallel "tier" knobs. ``AUTO`` picks the fastest +available path per device and degrades quietly if an accelerator kernel is missing or +throws; ``EAGER`` pins the portable splat everywhere, which is the double-differentiable +route to use for Hessians; ``TRITON`` and ``METAL`` force their kernel and raise rather than +degrade, so a benchmark or an A/B comparison cannot silently measure something else. Pass +``engine=`` per call, or scope it with ``with use_engine(...)``. The production splats live in ``kernels/cuda/variable_radius.py``, ``kernels/cpu/sphere_splat.py``, ``kernels/mps/variable_radius.py`` and (portable) ``kernels/cpu/variable_radius.py``; the per-atom radius policy is in -:mod:`torchref.base.electron_density.radius_policy`. Legacy kernels — the -fixed-radius Triton/JIT ones and the grouped-separable cube splats -(``add_isotropic_cpu_separable_var`` / ``add_anisotropic_cpu_var``, superseded by -the fused sphere kernel) — are re-imported here so the historical -``torchref.base.electron_density.main`` namespace is unchanged and they remain -callable for benchmarking, but they are *not* on the production dispatch path. +:mod:`torchref.base.electron_density.radius_policy`. """ from typing import Optional @@ -65,53 +48,18 @@ import torch from torchref.config import get_float_dtype, get_sigma_cutoff_ed -from torchref.utils.triton_dispatch import ( - Engine, - get_engine, - should_use_metal, - should_use_triton, -) +from torchref.utils.backends import run_or_degrade, select +from torchref.utils.triton_dispatch import Engine -# --- Shared splat helpers (re-imported to preserve this namespace; reused by the -# variable-radius kernels and, for _get_radius_offsets, by scaling/solvent.py) --- -from torchref.base.electron_density.kernels.offsets import _get_radius_offsets -from torchref.base.electron_density.kernels.cpu.scatter_dispatch import ( - _do_structured_scatter, - _get_cpp_scatter, -) -from torchref.base.electron_density.kernels.cpu.separable import _separable_density -from torchref.base.electron_density.kernels.cpu.aniso import _aniso_density_cube +from torchref.base.electron_density._backends import DENSITY_BACKENDS -# --- Per-atom variable-radius density path --- -# The splat radius is no longer a single scalar; each atom is truncated at its own -# N_sigma * sigma_eff radius (N_sigma = torchref.sigma_cutoff_ed). Every wrapper below -# takes the same canonical signature -# (density_map, xyz, adp_or_u, occ, A, B, inv_frac_matrix, frac_matrix, radius_per_atom) -# so the two dispatch ladders differ only in which kernel they name. CUDA+float32 -> -# the Triton kernels; CPU+AUTO -> the fused C++ sphere splat; MPS+float32 -> Metal; -# everything else (EAGER any device, CUDA/MPS float64) -> the portable plain-scatter -# splat. +# Re-imported to preserve this namespace: ``scaling/solvent.py`` imports +# ``_get_radius_offsets`` from here, not from its defining module. +from torchref.base.electron_density.kernels.offsets import _get_radius_offsets from torchref.base.electron_density.radius_policy import ( per_atom_radius_aniso, per_atom_radius_iso, ) -from torchref.base.electron_density.kernels.cuda.variable_radius import ( - WorkQueueGridDensity, - WorkQueueGridDensityAniso, - add_anisotropic_cuda_var, - add_isotropic_cuda_var, -) -from torchref.base.electron_density.kernels.cpu.variable_radius import ( - add_anisotropic_cpu_var, - add_anisotropic_plain_var, - add_isotropic_cpu_separable_var, - add_isotropic_plain_var, -) -from torchref.base.electron_density.kernels.cpu.sphere_splat import ( - add_anisotropic_cpu_sphere_var, - add_isotropic_cpu_sphere_var, - should_use_sphere_splat, -) def build_electron_density( @@ -130,20 +78,14 @@ def build_electron_density( A_aniso: Optional[torch.Tensor] = None, B_aniso: Optional[torch.Tensor] = None, dtype: torch.dtype = None, + engine: Optional[Engine] = None, ) -> torch.Tensor: """ Build an electron density map from atomic parameters. - Dispatches the isotropic and anisotropic splats through the shared - ``Engine`` (see the module docstring). Each atom is splatted at its own - per-atom truncation radius (``N_sigma * sigma_eff``, with - ``N_sigma = torchref.sigma_cutoff_ed``) via the per-atom variable-radius - kernels: the CUDA float32 work-queue kernels - (``add_isotropic_cuda_var`` / ``add_anisotropic_cuda_var``) under - ``Engine.AUTO``/``Engine.TRITON``, the fused C++ sphere splat on CPU+AUTO, the - native Metal kernels on MPS+float32 under ``Engine.AUTO``/``Engine.METAL``, and - the portable plain-scatter variable-radius splat everywhere else - (``Engine.EAGER``, CUDA/MPS float64). + Each atom is splatted at its own per-atom truncation radius + (``N_sigma * sigma_eff``, with ``N_sigma = torchref.sigma_cutoff_ed``). Which kernel + does the splatting is decided from ``DENSITY_BACKENDS``; see the module docstring. Parameters ---------- @@ -178,6 +120,11 @@ def build_electron_density( dtype : torch.dtype, optional Float dtype for the density map. Defaults to the configured float dtype (``get_float_dtype()``), which may be float64. + engine : Engine, optional + Per-call backend override; defaults to the process-wide engine. Prefer this over + ``set_engine`` when you only mean to steer the density splat -- a process-wide + ``Engine.METAL`` also sends every target math function down the eager path, which + would skew a benchmark. Returns ------- @@ -204,6 +151,7 @@ def build_electron_density( B_iso, inv_frac_matrix, frac_matrix, + engine=engine, ) # --- anisotropic atoms --- @@ -217,6 +165,7 @@ def build_electron_density( B_aniso, inv_frac_matrix, frac_matrix, + engine=engine, ) return density_map @@ -236,80 +185,25 @@ def _add_isotropic( B, inv_frac_matrix, frac_matrix, + engine=None, ): - """Add isotropic atoms with a per-atom variable radius. ``Engine`` is the switch. + """Add isotropic atoms with a per-atom variable radius. The per-atom radius is ``clamp(ceil(N_sigma * sigma_eff), [2,7])`` with ``N_sigma = torchref.sigma_cutoff_ed``. - - ``should_use_triton`` (CUDA + float32 + Triton, engine AUTO/TRITON) -> the - variable-radius Triton kernel (``WorkQueueGridDensity``). On kernel failure - under AUTO it falls through to the portable splat; under ``Engine.TRITON`` - it raises (never silently degrade). - - ``should_use_sphere_splat`` (CPU + float32/float64 + built extension, AUTO) - -> the fused C++ spherical-cutoff splat. - - ``should_use_metal`` (MPS + float32 + compiled shader, engine AUTO/METAL) - -> the native Metal kernel (``add_isotropic_mps_var``). Mirrors the Triton - branch: on kernel failure under AUTO it falls through to the portable - splat; under ``Engine.METAL`` it raises (never silently degrade). - - Everything else (``Engine.EAGER`` on any device, CUDA/MPS float64) -> the - portable plain-``scatter_add`` splat: double-differentiable, float64-capable, - device-agnostic. + Which kernel runs, and what happens if it fails, are read from ``DENSITY_BACKENDS`` + rather than restated here -- there is no branch in this function to keep in sync with + the table. Every path applies the identical spherical cutoff, so the choice affects + speed, not result. - Every path applies the identical spherical cutoff documented in the module - docstring, so these branches differ only in speed, not in result. + Only the first six arguments carry the device/dtype contract; the table names them. """ - n_sigma = get_sigma_cutoff_ed() - radius_per_atom = per_atom_radius_iso(adp, B, n_sigma=n_sigma) - - # Every branch below takes the same canonical splat signature, so these differ - # only in which kernel they name. Radius squaring and coefficient-mask - # construction live inside each wrapper, not here. - if should_use_triton(xyz): - try: - # kernel accumulates into (a copy of) density_map -> no extra grid buffer + add - return add_isotropic_cuda_var( - density_map, xyz, adp, occ, A, B, - inv_frac_matrix, frac_matrix, radius_per_atom, - ) - except Exception: - if get_engine() is Engine.TRITON: - raise - # AUTO: fall through to the portable splat - - # Fused C++ spherical-cutoff splat: the CPU production path, float32 AND float64 - # (the kernel is templated on the scalar type, so a float64 config no longer - # drops to the slow portable splat). ``should_use_sphere_splat`` owns the - # device/dtype/availability decision; if the extension did not build it returns - # False and the portable splat below -- same truncation contract -- takes over. - if get_engine() is Engine.AUTO and should_use_sphere_splat( - density_map, xyz, adp, occ, A, B - ): - return add_isotropic_cpu_sphere_var( - density_map, xyz, adp, occ, A, B, - inv_frac_matrix, frac_matrix, radius_per_atom, - ) - # Native Metal splat on Apple-silicon GPUs (float32 only). ``should_use_metal`` - # owns the device/dtype/availability decision, so an unavailable shader under - # ``Engine.METAL`` raises there rather than slipping past this gate. - # Import stays function-local: it loads the MSL source, which no other - # platform should pay for. - if should_use_metal(density_map, xyz, adp, occ, A, B): - from torchref.base.electron_density.kernels.mps import add_isotropic_mps_var - - try: - return add_isotropic_mps_var( - density_map, xyz, adp, occ, A, B, - inv_frac_matrix, frac_matrix, radius_per_atom, - ) - except Exception: - if get_engine() is Engine.METAL: - raise - # AUTO: fall through to the portable splat - return add_isotropic_plain_var( - density_map, xyz, adp, occ, A, B, - inv_frac_matrix, frac_matrix, radius_per_atom, - ) + radius_per_atom = per_atom_radius_iso(adp, B, n_sigma=get_sigma_cutoff_ed()) + args = (density_map, xyz, adp, occ, A, B, + inv_frac_matrix, frac_matrix, radius_per_atom) + backend = select(DENSITY_BACKENDS, args, engine) + return run_or_degrade(DENSITY_BACKENDS, backend, False, *args, engine=engine) def _add_anisotropic( @@ -321,66 +215,20 @@ def _add_anisotropic( B, inv_frac_matrix, frac_matrix, + engine=None, ): """Add anisotropic atoms with a per-atom variable radius (mirrors the iso path). - The per-atom radius is the isotropic bounding radius of the ellipsoid - (largest principal axis, ``per_atom_radius_aniso``). + The per-atom radius is the isotropic bounding radius of the ellipsoid (largest + principal axis, ``per_atom_radius_aniso``). Every path culls on the Euclidean sphere at + that radius and evaluates the Mahalanobis form inside it -- one contract, as for the + isotropic pass. - CUDA+float32 (engine permitting) -> ``WorkQueueGridDensityAniso``. CPU + - float32/float64 + AUTO -> the fused C++ sphere splat - ``add_anisotropic_cpu_sphere_var``. MPS + float32 + AUTO/METAL (via - ``should_use_metal``) -> the native Metal kernel ``add_anisotropic_mps_var``; - falls through under AUTO, raises under ``Engine.METAL``. Everything else - (``Engine.EAGER`` on any device, CUDA/MPS float64) -> the portable - plain-``scatter_add`` splat ``add_anisotropic_plain_var`` (double-diff, - float64-capable, device-agnostic). - - Every path culls on the Euclidean sphere at the per-atom radius (the - ellipsoid's isotropic bounding radius) and evaluates the Mahalanobis form - inside it -- one contract, as for the isotropic pass. + Same table, same selection: the two passes differ only in the radius policy and in + which variant of each kernel the table names. """ - n_sigma = get_sigma_cutoff_ed() - radius_per_atom = per_atom_radius_aniso(B, u, n_sigma=n_sigma) - - # As in _add_isotropic: one canonical signature, so the branches differ only in - # which kernel they name. - if should_use_triton(xyz): - try: - # kernel accumulates into (a copy of) density_map -> no extra grid buffer + add - return add_anisotropic_cuda_var( - density_map, xyz, u, occ, A, B, - inv_frac_matrix, frac_matrix, radius_per_atom, - ) - except Exception: - if get_engine() is Engine.TRITON: - raise - # AUTO: fall back to the portable splat - - # Fused C++ spherical-cutoff splat, float32 and float64 (see _add_isotropic). - if get_engine() is Engine.AUTO and should_use_sphere_splat( - density_map, xyz, u, occ, A, B - ): - return add_anisotropic_cpu_sphere_var( - density_map, xyz, u, occ, A, B, - inv_frac_matrix, frac_matrix, radius_per_atom, - ) - # Native Metal splat on Apple-silicon GPUs (float32 only). See the iso path: - # ``should_use_metal`` owns device/dtype/availability, and the import is - # function-local so only Apple silicon loads the MSL source. - if should_use_metal(density_map, xyz, u, occ, A, B): - from torchref.base.electron_density.kernels.mps import add_anisotropic_mps_var - - try: - return add_anisotropic_mps_var( - density_map, xyz, u, occ, A, B, - inv_frac_matrix, frac_matrix, radius_per_atom, - ) - except Exception: - if get_engine() is Engine.METAL: - raise - # AUTO: fall through to the portable splat - return add_anisotropic_plain_var( - density_map, xyz, u, occ, A, B, - inv_frac_matrix, frac_matrix, radius_per_atom, - ) + radius_per_atom = per_atom_radius_aniso(B, u, n_sigma=get_sigma_cutoff_ed()) + args = (density_map, xyz, u, occ, A, B, + inv_frac_matrix, frac_matrix, radius_per_atom) + backend = select(DENSITY_BACKENDS, args, engine) + return run_or_degrade(DENSITY_BACKENDS, backend, True, *args, engine=engine) diff --git a/torchref/base/kernels/__init__.py b/torchref/base/kernels/__init__.py index 1ab60db..74c4252 100644 --- a/torchref/base/kernels/__init__.py +++ b/torchref/base/kernels/__init__.py @@ -16,16 +16,8 @@ warmup, get_cache_dir, clear_cache, - _HAS_TRITON, ) -try: - from torchref.base.electron_density.kernels import ( # noqa: F401 - fused_add_to_map_gpu, - ) -except ImportError: - pass - __all__ = [ "vectorized_add_to_map", "build_electron_density", @@ -34,5 +26,17 @@ "warmup", "get_cache_dir", "clear_cache", - "fused_add_to_map_gpu", ] + +# Optional, and only advertised when it actually resolved -- listing it +# unconditionally made ``from torchref.base.kernels import *`` raise on a Triton-less +# host. The former ``_HAS_TRITON`` re-export is gone: nothing read it, and +# ``torchref.utils.triton_available()`` is the answer to that question. +try: + from torchref.base.electron_density.kernels import ( # noqa: F401 + fused_add_to_map_gpu, + ) + + __all__.append("fused_add_to_map_gpu") +except ImportError: + pass diff --git a/torchref/base/targets/_dispatch.py b/torchref/base/targets/_dispatch.py index b34b14b..b3dcd50 100644 --- a/torchref/base/targets/_dispatch.py +++ b/torchref/base/targets/_dispatch.py @@ -1,31 +1,103 @@ -"""Shared dispatch helper for the target math functions. +"""Shared dispatch gate for the target math functions. -When called on a CUDA float32 tensor and Triton is importable, the math -functions in this package transparently route to their Triton-kernel -implementations in :mod:`torchref.base.targets.triton`. CPU tensors, -non-float32 tensors, or environments without Triton fall back to the -plain eager implementation. +When called on a CUDA float32 tensor and the Triton kernels are importable, the math +functions in this package route to their implementations in +:mod:`torchref.base.targets.triton`. CPU tensors, non-float32 tensors, and environments +without a usable Triton fall back to the plain eager implementation. -Selection is governed by the shared, capability-based ``Engine`` in -:mod:`torchref.utils.triton_dispatch` (no environment variables). To force -the eager path for an A/B comparison or to sidestep a flaky Triton install:: +The criteria live in :data:`TARGET_BACKENDS` rather than in a predicate body, for the same +reason as the density and direct-summation tables: so ``Engine.METAL`` meaning "run eager +here" is a declared fact rather than an early ``return False``, and so a new ``Engine`` +member cannot be added without something claiming it. + +This is a **gate-only** table -- the twelve call sites each do their own +``from .triton. import `` three lines from the ``if``, which is more legible than a +registry lookup for a single-kernel choice. The table's job is the decision, not the +dispatch, so its rows carry no ``kernel``. + +To force the eager path for an A/B comparison or to sidestep a flaky Triton install:: from torchref.utils import use_engine, Engine with use_engine(Engine.EAGER): ... """ +from typing import Optional + import torch -from torchref.utils.triton_dispatch import should_use_triton +from torchref.utils.backends import Backend, BackendTable, admits +from torchref.utils.triton_dispatch import Engine, triton_available + +_THIS = "torchref.base.targets._dispatch" + + +def why_unavailable() -> Optional[str]: + """``None`` if the target Triton kernels can run, else why they cannot. + + Closes a real gap. ``triton_available()`` answers "is the ``triton`` package + importable", which is not the same question as "do *these* kernels import" -- and + nothing asked the second one. Each of the twelve call sites does an unguarded + function-local import, so a Triton present but skewed against the installed driver or + LLVM raised straight through a refinement step, where the density and direct-summation + paths would have degraded. Importing the package is the shared prerequisite for all + twelve, so one probe covers them. + + Deliberately does *not* re-check ``torch.cuda.is_available()``. Selection is two-phase, + so the device criterion has already matched by the time a probe runs -- and the presence + of a CUDA tensor is stronger evidence than the query. Asking again would also make this + probe disagree with ``triton_available()`` on a host that has Triton installed but no + GPU, which is an ordinary CI configuration. + """ + if not triton_available(): + return "triton is not importable" + try: + import torchref.base.targets.triton # noqa: F401 + except Exception as exc: # noqa: BLE001 - an import failure is a reason, not a crash + return ( + "the target Triton kernels could not be imported " + f"({type(exc).__name__}: {exc})" + ) + return None + + +TARGET_BACKENDS = BackendTable( + name="target math", + backends=( + Backend( + name="triton", + kernel=None, # gate-only; see the module docstring + engines=frozenset({Engine.AUTO, Engine.TRITON}), + device="cuda", + dtypes=(torch.float32,), + probe=(_THIS, "why_unavailable"), + expect_available="cuda", + # Availability is handled by the probe above, so this governs only a kernel + # that imported and then threw -- a bug in pure math on already-validated + # tensors. Degrading would buy a silent ~50x slowdown with subtly different + # numbers, which is worse than the exception. + on_failure="raise", + second_order=False, + ), + Backend( + name="eager", + kernel=None, + # METAL is here because there are no Metal target kernels: at this site it has + # to mean "run eager". TRITON is absent, which is what makes it strict. + engines=frozenset({Engine.AUTO, Engine.EAGER, Engine.METAL}), + expect_available="always", + on_failure="raise", + second_order=True, + ), + ), +) def use_triton(*tensors: torch.Tensor) -> bool: """Decide whether to route a call to the Triton kernel. - Thin wrapper over :func:`torchref.utils.triton_dispatch.should_use_triton` - using the process-wide engine: Triton is used only when the engine permits - it and every probed tensor is CUDA float32 (the only configuration the - kernels are written for). + Asks the ``triton`` row of :data:`TARGET_BACKENDS` whether it would run, using the + process-wide engine. ``None`` entries among ``tensors`` are ignored, so a caller may + pass optional inputs straight through. """ - return should_use_triton(*tensors) + return admits(TARGET_BACKENDS, "triton", tensors) diff --git a/torchref/model/sf_ds.py b/torchref/model/sf_ds.py index 6deee28..6b23627 100644 --- a/torchref/model/sf_ds.py +++ b/torchref/model/sf_ds.py @@ -101,7 +101,7 @@ def __init__( device: torch.device = None, verbose: int = 0, max_memory_gb: float = 2.0, - engine: Engine = Engine.AUTO, + engine: Optional[Engine] = None, ): """ Initialize the SfDS module with cell and spacegroup. @@ -121,10 +121,15 @@ def __init__( max_memory_gb : float, optional Maximum memory for intermediate tensors in GB. Default is 2.0. engine : Engine, optional - Structure-factor backend selector. ``Engine.AUTO`` (default) - derives the backend from device/dtype/availability (Triton on - CUDA+float32, else checkpointed eager). ``Engine.TRITON`` and - ``Engine.EAGER`` force a path (for tests/benchmarks). + Structure-factor backend selector, applied per call so two instances can + differ within one process. ``None`` (default) defers to the process-wide + engine, so ``with use_engine(...)`` steers an unconfigured instance; pass an + explicit ``Engine`` to override that. + + The default was ``Engine.AUTO``, which read as harmless but was not: an + explicit engine argument suppresses the global, so a + ``with use_engine(Engine.EAGER)`` block never reached direct summation and + still ran Triton on a CUDA host. """ super().__init__() if dtype_float is None: diff --git a/torchref/restraints/hydrogen_topology.py b/torchref/restraints/hydrogen_topology.py index 9d0ad64..a2cbc02 100644 --- a/torchref/restraints/hydrogen_topology.py +++ b/torchref/restraints/hydrogen_topology.py @@ -638,6 +638,10 @@ def place_riding_hydrogens( ------- xyz_h : (N_h, 3) float tensor, differentiable w.r.t. xyz_heavy """ + # Function-local, matching every other target dispatch site: importing the gate at + # module scope would pull ``torchref.base.targets`` into ``torchref.restraints``. + from torchref.base.targets._dispatch import use_triton + N_h = topo.h_parent_idx.shape[0] if N_h == 0: return torch.zeros(0, 3, dtype=xyz_heavy.dtype, device=xyz_heavy.device) @@ -658,7 +662,13 @@ def place_riding_hydrogens( # backward (the H-VDW backward is a significant fraction of the # non-bonded fwd+bw cost). Falls back to the JIT-scripted eager # helper otherwise. - if xyz_heavy.is_cuda and xyz_heavy.dtype == torch.float32: + # + # Gated by the shared ``use_triton`` rather than a hand-rolled + # ``is_cuda and dtype == float32``. The inline check ignored the ``Engine`` entirely, so + # ``with use_engine(Engine.EAGER): ...`` still ran the Triton kernel here -- and since + # EAGER is the documented double-differentiable route, a Hessian taken through hydrogen + # placement was silently going through a first-order-only kernel. + if use_triton(xyz_heavy): try: from torchref.base.targets.triton.place_hydrogens import ( place_riding_hydrogens_triton, diff --git a/torchref/utils/backends.py b/torchref/utils/backends.py new file mode 100644 index 0000000..544e08d --- /dev/null +++ b/torchref/utils/backends.py @@ -0,0 +1,402 @@ +"""Declarative backend selection: the dispatch criteria, in one place, as data. + +Every dispatch site in TorchRef answers the same question -- given these tensors and this +:class:`~torchref.utils.triton_dispatch.Engine`, which kernel runs? This module holds the +machinery for answering it from a table, so each criterion (device, dtype, engine, +availability, failure policy) is written down exactly once, next to the kernels it selects. +The tables themselves live with their kernels: see +``torchref/base/electron_density/_backends.py`` and +``torchref/base/direct_summation/_backends.py``. + +There is no chain +---------------- +The accelerator gates are pairwise device-disjoint -- CUDA, CPU and MPS -- so for any +given input **at most one** non-base backend can match. :func:`select` therefore returns a +single :class:`Backend`, not an ordered candidate list, and the only fallback is the +table's base case. An earlier design walked a chain; that models a control structure the +problem does not have. + +Strictness is one boolean +------------------------- +A forcing engine (``TRITON``, ``METAL``) is admitted by exactly one row, so if that row +does not match, nothing does and :func:`select` raises with the reason. The base case +deliberately does **not** list the forcing engines in its ``engines`` set -- that omission +is the whole mechanism by which forcing is strict. Failure handling follows the same +principle: under ``AUTO`` a backend marked ``on_failure="degrade"`` falls back, under any +forcing engine everything propagates (see :func:`run_or_degrade`). + +Why kernels are stored as ``(module, attr)`` rather than as functions +-------------------------------------------------------------------- +Resolving ``getattr(import_module(path), attr)`` on every call costs a ``sys.modules`` hit +and a ``getattr``, and buys four things a captured function object would break: + +* ``torchref.utils`` is imported very early and must not reach into + ``torchref.base.electron_density`` at module scope; +* the Metal MSL source and ``import triton`` stay off hosts that never dispatch to them; +* availability stays readable *late*, so a test that flips + ``mps.compile._lib_failed`` is still seen; +* the kernel stays monkeypatchable at its defining module, which is what keeps the + provenance tests in ``tests/unit/structure_factor/test_dispatch.py`` meaningful. A table + holding function objects captured at import time would make those tests pass while + measuring nothing. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from importlib import import_module +from typing import Callable, Optional, Sequence, Tuple + +import torch + +from torchref.utils.triton_dispatch import Engine, get_engine + +__all__ = ["Backend", "BackendTable", "admits", "select", "run_or_degrade"] + + +def _mps_present() -> bool: + return hasattr(torch.backends, "mps") and torch.backends.mps.is_available() + + +#: Host conditions a backend can declare via ``Backend.expect_available``. Parametrized +#: rather than hand-written per kernel: "this must work here" is the same question for the +#: fused C++ splat, the Metal shader and the Triton kernels, and only the condition differs. +_EXPECTATIONS = { + "never": lambda: False, + "always": lambda: True, + "cuda": torch.cuda.is_available, + "mps": _mps_present, +} + + +@dataclass(frozen=True) +class Backend: + """One row of a dispatch table: a kernel plus every criterion for choosing it. + + Parameters + ---------- + name : str + Stable identifier, used in error messages and by tests to name a path. + kernel : (str, str, str), optional + ``(module_path, isotropic_attr, anisotropic_attr)``, resolved per call. For + single-variant backends pass the same attribute twice. + + ``None`` marks a **gate-only** backend: one whose selection criteria are worth + declaring in a table even though the call site names its own kernel. The geometry + targets are the case -- twelve modules each with a single ``if use_triton(x): from + .triton.X import f`` call site, where the three-line local import is more legible + than a registry hop, but "which engines and devices admit Triton here" still needs + to be stated once. :func:`run_or_degrade` refuses such a backend; only + :func:`select` and :func:`admits` accept it. + engines : frozenset[Engine] + Which engines admit this backend. A forcing engine listed here makes this the + *only* candidate under it; omitting a forcing engine from the base case is what + makes that engine strict. + device : str, optional + Required device type (``"cuda"``/``"mps"``/``"cpu"``). ``None`` means any, which + is what marks the base case. + dtypes : tuple[torch.dtype, ...], optional + Permitted **floating-point** dtypes. ``None`` means any. + + Integer tensors are exempt, deliberately. Miller indices arrive as ``int32`` from + the MTZ reader and every kernel casts them itself; that cast is exact for + \\|h\\| < 2**24, so an identity test against ``float32`` would reject the + production dtype and disable the kernel it was meant to protect. What the rule + catches is a *float* in the wrong precision -- a float64 ``hkl`` whose silent + downcast would truncate the phase. + require_uniform_dtype : bool + Whether every probed floating-point tensor must share **one** dtype, as opposed to + each independently being in ``dtypes``. + + Not the same condition, and the difference is memory safety rather than taste. The + fused CPU kernel selects one ``scalar_t`` from the output map via + ``AT_DISPATCH_FLOATING_TYPES`` and then reads every other tensor through a raw + pointer of that type, so a float64 map beside float32 atoms reinterprets the + coordinate buffer as doubles -- a 2x out-of-bounds read. ``dtypes=(f32, f64)`` + alone *admits* that call. + probes : tuple[int, ...], optional + Which argument positions carry the device/dtype contract. ``None`` probes them all. + + Per-table because the requirement means different things in different tables. For + the density splats float32 is a *capability* -- the C++ and the MSL shader cannot + consume anything else. For direct summation it is *policy*: the Triton kernel casts + everything itself, so the gate polices only the leaves whose precision the caller + chose, and must not probe the float32 stored scattering-factor table (which would + decline the kernel unconditionally). + probe : (str, str), optional + ``(module_path, attr)`` of a zero-argument callable returning ``None`` when the + backend is usable, or a human-readable reason when it is not. ``None`` means + always available. Resolved late, so a cache the callable consults can still be + invalidated. + + One function per backend, deliberately: a separate boolean ``*_available()`` would + be the same test written twice. Where such a bool already exists as public API it + derives from this probe rather than repeating it. + expect_available : {"never", "always", "cuda", "mps"} + The host condition under which this backend *must* work. Distinct from ``probe``, + which reports what is true; this states what ought to be. + + The two together are what let a broken build fail rather than skip. Dispatch under + ``AUTO`` degrades quietly, which is right for users and dangerous for CI: if every + test skips when a kernel is missing, a build that stopped working produces an + all-green run while production has silently fallen back. Declaring the expectation + here means one parametrized test covers every backend, instead of each kernel + needing its own bespoke compile check. + + Not consulted by :func:`select` -- availability is a fact at dispatch time, never an + expectation. + on_failure : {"raise", "degrade"} + What a *runtime* exception from the kernel means under ``Engine.AUTO``. Under any + forcing engine this is ignored and the exception propagates. + + ``"degrade"`` carries a precondition: the kernel must not have mutated its inputs + before failing, or the fallback would double-count. Both accelerator splats satisfy + it by cloning the density map before accumulating. + second_order : bool + Whether the kernel composes under ``create_graph=True``. Not consulted by + :func:`select`; it is here so the test matrix can be derived from this table rather + than maintaining its own copy. + """ + + name: str + kernel: Optional[Tuple[str, str, str]] + engines: frozenset + device: Optional[str] = None + dtypes: Optional[Tuple[torch.dtype, ...]] = None + require_uniform_dtype: bool = False + probes: Optional[Tuple[int, ...]] = None + probe: Optional[Tuple[str, str]] = None + expect_available: str = "never" + on_failure: str = "raise" + second_order: bool = True + + def __post_init__(self): + if self.on_failure not in ("raise", "degrade"): + raise ValueError( + f"{self.name}: on_failure must be 'raise' or 'degrade', " + f"got {self.on_failure!r}" + ) + if self.expect_available not in _EXPECTATIONS: + raise ValueError( + f"{self.name}: expect_available must be one of " + f"{sorted(_EXPECTATIONS)}, got {self.expect_available!r}" + ) + + def expected_here(self) -> bool: + """Whether this host is one where this backend is required to work.""" + return _EXPECTATIONS[self.expect_available]() + + # -- criteria --------------------------------------------------------- + def requirement(self) -> str: + """The device/dtype contract as an error fragment, e.g. ``requires MPS float32``.""" + parts = [] + if self.device is not None: + parts.append(self.device.upper()) + if self.dtypes is not None: + parts.append("/".join(str(d).replace("torch.", "") for d in self.dtypes)) + if not parts: + return "accepts any inputs" + return "requires " + " ".join(parts) + " inputs" + + def mismatch(self, tensors: Sequence[Optional[torch.Tensor]]) -> Optional[str]: + """Why these tensors fail this backend's device/dtype contract, or ``None``.""" + probed = _probed(self, tensors) + if self.device is not None and any( + t.device.type != self.device for t in probed + ): + return self.requirement() + if self.dtypes is not None: + floats = [t for t in probed if t.is_floating_point()] + if any(t.dtype not in self.dtypes for t in floats): + return self.requirement() + if self.require_uniform_dtype and len({t.dtype for t in floats}) > 1: + return ( + f"{self.requirement()} sharing a single dtype (got " + + ", ".join( + sorted(str(d).replace("torch.", "") for d in {t.dtype for t in floats}) + ) + + ")" + ) + return None + + def unavailable(self) -> Optional[str]: + """Why this backend cannot run on this host, or ``None``.""" + if self.probe is None: + return None + module, attr = self.probe + try: + fn = getattr(import_module(module), attr) + except Exception as exc: # noqa: BLE001 - a stripped install must not crash a gate + return f"its availability probe could not be imported ({type(exc).__name__}: {exc})" + return fn() + + def resolve(self, aniso: bool) -> Callable: + """The kernel function, looked up now rather than captured at import.""" + if self.kernel is None: + raise TypeError( + f"{self.name} is gate-only (kernel=None): its call site names its own " + "kernel, so there is nothing here to run." + ) + module, iso_attr, aniso_attr = self.kernel + return getattr(import_module(module), aniso_attr if aniso else iso_attr) + + +@dataclass(frozen=True) +class BackendTable: + """An ordered set of backends plus the invariants that make it a complete policy. + + Two things are checked at import, both of which are currently unwritable against + hand-rolled if/elif ladders: + + * **Every** :class:`Engine` member is handled by at least one backend. Without this, + adding an engine -- or forgetting to let the base case absorb one -- silently turns a + working call into a ``RuntimeError``. ``Engine.METAL`` is the live example: it selects + the Metal density splat, and at *every other* dispatch site it must mean "run eager", + a fact that otherwise exists only as an early ``return False`` inside a predicate. + * Exactly one base case, i.e. one backend with no device and no dtype restriction. + Selection under ``AUTO`` must always terminate somewhere. + """ + + name: str + backends: Tuple[Backend, ...] + base: Backend = field(init=False) + + def __post_init__(self): + covered = frozenset().union(*(b.engines for b in self.backends)) + missing = frozenset(Engine) - covered + if missing: + raise ValueError( + f"{self.name}: no backend handles " + f"{sorted(e.name for e in missing)}. Every engine must select " + "something -- if it should run the fallback, add it to that " + "backend's `engines`." + ) + bases = [b for b in self.backends if b.device is None and b.dtypes is None] + if len(bases) != 1: + raise ValueError( + f"{self.name}: expected exactly one unrestricted base backend, " + f"found {[b.name for b in bases]}" + ) + object.__setattr__(self, "base", bases[0]) + + def by_name(self, name: str) -> Backend: + for b in self.backends: + if b.name == name: + return b + raise KeyError(f"{self.name}: no backend named {name!r}") + + +def _probed(backend: Backend, tensors: Sequence[Optional[torch.Tensor]]): + """The tensors this backend's contract applies to: selected, then ``None``-filtered.""" + if backend.probes is None: + chosen = tensors + else: + chosen = [tensors[i] for i in backend.probes if i < len(tensors)] + return [t for t in chosen if t is not None] + + +def select( + table: BackendTable, + tensors: Sequence[Optional[torch.Tensor]], + engine: Optional[Engine] = None, +) -> Backend: + """The one backend that runs, or a ``RuntimeError`` explaining why none can. + + Two-phase by contract: device and dtype are checked for every candidate *before* any + availability probe is called. That ordering is load-bearing twice over. It keeps an MPS + host from compiling the CPU C++ extension it will never use, and it makes a forced + engine report the device mismatch (``requires MPS float32``) rather than a compile + failure it never got far enough to observe. + + Parameters + ---------- + table : BackendTable + The policy to apply. + tensors : sequence of torch.Tensor or None + Positional arguments to probe. ``None`` entries are ignored, so a site may pass + optional inputs straight through. + engine : Engine, optional + Per-call override; defaults to the process-wide engine. + """ + eng = engine if engine is not None else get_engine() + if not isinstance(eng, Engine): + # Loud on purpose. This used to be an implicit ``else`` that selected Triton, so + # any unrecognised value silently chose an accelerator. + raise ValueError(f"{table.name}: unhandled engine {eng!r}") + + reasons = [] + for backend in table.backends: + if eng not in backend.engines: + continue + why = backend.mismatch(tensors) + if why is None: + why = backend.unavailable() + if why is None: + return backend + reasons.append((backend, why)) + + detail = "; ".join(f"{b.name} {why}" for b, why in reasons) or ( + "no backend admits this engine" + ) + raise RuntimeError(f"engine=Engine.{eng.name} {detail}") + + +def admits( + table: BackendTable, + name: str, + tensors: Sequence[Optional[torch.Tensor]], + engine: Optional[Engine] = None, +) -> bool: + """Whether one named backend would run -- the shape the public predicates need. + + Differs from :func:`select` in what a non-match means. ``select`` asks "what runs?" and + raises when the answer is nothing. This asks "would *this* one run?", which has a third + answer: an engine that does not admit this backend at all is not an error, it simply is + not this backend's turn. ``should_use_metal`` under ``Engine.TRITON`` is False, not a + failure. + + Strictness still applies where it should: if the engine *does* admit this backend and + forces it, a failed criterion raises rather than returning False, because a forced + engine that quietly declines has silently degraded. + """ + eng = engine if engine is not None else get_engine() + if not isinstance(eng, Engine): + raise ValueError(f"{table.name}: unhandled engine {eng!r}") + backend = table.by_name(name) + if eng not in backend.engines: + return False + why = backend.mismatch(tensors) + if why is None: + why = backend.unavailable() + if why is None: + return True + if eng is not Engine.AUTO: + raise RuntimeError(f"engine=Engine.{eng.name} {why}") + return False + + +def run_or_degrade( + table: BackendTable, + backend: Backend, + aniso: bool, + *args, + engine: Optional[Engine] = None, + **kwargs, +): + """Run ``backend``'s kernel, falling back to the table's base case only if allowed. + + A forcing engine never degrades: the caller asked for a specific kernel, and quietly + substituting another would make an A/B comparison or a benchmark measure the wrong + thing. Under ``Engine.AUTO`` a backend marked ``on_failure="degrade"`` falls back, + which is what keeps an unavailable accelerator a performance problem rather than an + outage. + """ + eng = engine if engine is not None else get_engine() + fn = backend.resolve(aniso) + strict = eng is not Engine.AUTO + if strict or backend.on_failure == "raise" or backend is table.base: + return fn(*args, **kwargs) + try: + return fn(*args, **kwargs) + except Exception: + return table.base.resolve(aniso)(*args, **kwargs) diff --git a/torchref/utils/triton_dispatch.py b/torchref/utils/triton_dispatch.py index e885352..350b636 100644 --- a/torchref/utils/triton_dispatch.py +++ b/torchref/utils/triton_dispatch.py @@ -117,12 +117,14 @@ def triton_available() -> bool: def should_use_triton(*tensors: torch.Tensor, engine: Optional[Engine] = None) -> bool: - """Coarse triton-vs-eager gate shared by every dispatch site. + """Coarse triton-vs-eager gate. - Reads the process-wide engine unless an explicit ``engine`` is passed - (direct summation / ``SfDS`` use the per-call override). Probes the given - tensors: the Triton path requires every non-None tensor to be CUDA - float32. + Asks the ``triton`` row of ``TARGET_BACKENDS`` whether it would run, so the + device/dtype/availability criteria are stated once, in that table, rather than a second + time here. The target-math table is the right one to defer to: it is the *generic* + Triton question, with no kernel-family-specific requirement attached. The density and + direct-summation sites have their own tables because their availability probes and probe + sets genuinely differ. Parameters ---------- @@ -143,49 +145,30 @@ def should_use_triton(*tensors: torch.Tensor, engine: Optional[Engine] = None) - If ``engine`` is ``Engine.TRITON`` but the inputs are not CUDA float32, or Triton is unavailable. ValueError - If ``engine`` is a member this function does not handle. Deliberately - loud: the AUTO case used to be an implicit ``else``, so *any* new - member silently selected Triton. + If ``engine`` is not an ``Engine`` member. Deliberately loud: the AUTO case used to + be an implicit ``else``, so *any* unrecognised value silently selected Triton. Notes ----- ``Engine.METAL`` returns False here rather than raising -- it forces the Metal density splat (see :func:`should_use_metal`), and every other - dispatch site correctly runs eager under it. + dispatch site correctly runs eager under it. In the table that is the ``eager`` row + listing METAL among its engines, which is checked for completeness at import. """ - eng = engine if engine is not None else get_engine() - if eng is Engine.EAGER or eng is Engine.METAL: - return False + # Function-local: ``torchref.utils`` loads before ``torchref.base``. + from torchref.base.targets._dispatch import TARGET_BACKENDS + from torchref.utils.backends import admits - cuda_f32 = all( - t is None or (t.is_cuda and t.dtype is torch.float32) for t in tensors - ) - - if eng is Engine.TRITON: - if not cuda_f32: - raise RuntimeError( - "engine=Engine.TRITON requires CUDA float32 inputs" - ) - if not triton_available(): - raise RuntimeError("Triton is not available") - return True - - if eng is Engine.AUTO: - return cuda_f32 and triton_available() - - raise ValueError(f"should_use_triton: unhandled engine {eng!r}") + return admits(TARGET_BACKENDS, "triton", tensors, engine) def should_use_metal(*tensors: torch.Tensor, engine: Optional[Engine] = None) -> bool: """Metal-vs-portable gate for the MPS electron-density splat. - The Metal counterpart of :func:`should_use_triton`, and the sole decision - point for the Metal path: it probes device, dtype **and** shader - availability together. Folding availability in here rather than leaving it - as a nested ``if`` at the dispatch site is what makes ``Engine.METAL`` - genuinely strict -- otherwise an uncompiled shader under a forced engine - would fall past the gate onto the portable splat, silently degrading the - very thing the caller asked to force. + Retained as public API, but no longer a second statement of the criteria: it asks the + ``mps_metal`` row of ``DENSITY_BACKENDS`` whether it would run. The device, dtype and + shader-availability conditions live in that table, so this cannot drift from what + dispatch actually does. Parameters ---------- @@ -198,60 +181,22 @@ def should_use_metal(*tensors: torch.Tensor, engine: Optional[Engine] = None) -> Returns ------- bool - True if the Metal kernels should be used. + True if the Metal kernels should be used. ``False`` -- not an error -- for an + engine that does not admit Metal at all, since it is simply not Metal's turn. Raises ------ RuntimeError - If ``engine`` is ``Engine.METAL`` but the inputs are not MPS float32, - or the shader library is unavailable. The latter message carries the - recorded compile error, since that is the one failure a user can act on - (torch < 2.9, or an older Metal rejecting the MSL). + If ``engine`` is ``Engine.METAL`` but the inputs are not MPS float32, or the shader + library is unavailable. The message carries the recorded compile error, since that + is the one failure a user can act on (torch < 2.9, or an older Metal rejecting the + MSL). + ValueError + If ``engine`` is not an ``Engine`` member. """ - eng = engine if engine is not None else get_engine() - # EAGER means portable everywhere. TRITON has already raised at the Triton - # gate on any MPS input, so reaching here under it means a non-MPS host. - if eng is Engine.EAGER or eng is Engine.TRITON: - return False - if eng is not Engine.AUTO and eng is not Engine.METAL: - raise ValueError(f"should_use_metal: unhandled engine {eng!r}") - - mps_f32 = all( - t is None or (t.device.type == "mps" and t.dtype is torch.float32) - for t in tensors - ) - if not mps_f32: - if eng is Engine.METAL: - raise RuntimeError( - "engine=Engine.METAL requires MPS float32 inputs" - ) - return False - - # Imported lazily and only once the cheap checks pass: this module is - # imported very early via ``torchref.utils``, and pulling in the mps - # package eagerly would load the MSL source on every platform. - try: - from torchref.base.electron_density.kernels.mps.compile import ( - last_error, - mps_kernels_available, - ) - except Exception: - # A stripped install or a torch without ``torch.mps`` must not make a - # predicate raise under AUTO. - if eng is Engine.METAL: - raise - return False - - if mps_kernels_available(): - return True - - if eng is Engine.METAL: - err = last_error() - reason = err[0] if err else "unknown reason" - raise RuntimeError( - f"engine=Engine.METAL requested but the Metal splat kernels are " - f"not available ({reason}). See " - "torchref.base.electron_density.kernels.mps.compile._lib_error " - "for the full traceback." - ) - return False + # Imported here, not at module scope: ``torchref.utils`` loads very early and must not + # reach into ``torchref.base`` while it is still initialising. + from torchref.base.electron_density._backends import DENSITY_BACKENDS + from torchref.utils.backends import admits + + return admits(DENSITY_BACKENDS, "mps_metal", tensors, engine) From 0f6e7ccfe9e4547d1d6dc83f564139c861081886 Mon Sep 17 00:00:00 2001 From: HatPdotS Date: Thu, 30 Jul 2026 13:39:06 +0200 Subject: [PATCH 11/15] removed engine semantics for backend swaps --- .../test_triton_vs_eager_targets.py | 71 ++-- tests/pytest.ini | 7 + tests/unit/base/test_canonical_sphere_cpu.py | 60 +-- tests/unit/base/test_kernel_import_shims.py | 2 +- tests/unit/structure_factor/__init__.py | 4 +- tests/unit/structure_factor/helpers.py | 26 +- tests/unit/structure_factor/test_dispatch.py | 174 +++++---- .../unit/structure_factor/test_ds_dispatch.py | 53 +-- tests/unit/structure_factor/test_forward.py | 22 +- tests/unit/structure_factor/test_gradients.py | 28 +- .../structure_factor/test_second_order.py | 28 +- tests/unit/utils/test_backend_tables.py | 178 ++++----- tests/unit/utils/test_backends.py | 342 ++++++++++-------- tests/unit/utils/test_triton_dispatch.py | 223 ------------ torchref/__init__.py | 2 +- torchref/base/direct_summation/__init__.py | 3 +- torchref/base/direct_summation/_backends.py | 13 +- torchref/base/direct_summation/dispatch.py | 37 +- torchref/base/electron_density/__init__.py | 2 +- torchref/base/electron_density/_backends.py | 19 +- .../kernels/cpu/jit_reference.py | 16 +- .../kernels/cpu/sphere_splat.py | 2 +- .../kernels/cpu/variable_radius.py | 4 +- .../electron_density/kernels/mps/__init__.py | 2 +- .../electron_density/kernels/mps/compile.py | 6 +- .../kernels/mps/variable_radius.py | 2 +- torchref/base/electron_density/main.py | 59 ++- torchref/base/targets/_dispatch.py | 22 +- torchref/model/sf_ds.py | 29 +- torchref/restraints/hydrogen_topology.py | 4 +- torchref/utils/__init__.py | 26 +- torchref/utils/backends.py | 261 +++++++------ torchref/utils/triton_dispatch.py | 202 ----------- 33 files changed, 804 insertions(+), 1125 deletions(-) delete mode 100644 tests/unit/utils/test_triton_dispatch.py delete mode 100644 torchref/utils/triton_dispatch.py diff --git a/tests/integration/test_triton_vs_eager_targets.py b/tests/integration/test_triton_vs_eager_targets.py index a302be5..33e5306 100644 --- a/tests/integration/test_triton_vs_eager_targets.py +++ b/tests/integration/test_triton_vs_eager_targets.py @@ -9,8 +9,8 @@ and assert that loss values and parameter gradients agree to within a fp32 tolerance accounting for atomic-scatter non-determinism. -Toggling is done with the shared ``torchref.utils.use_engine`` context manager -(``Engine.EAGER`` vs ``Engine.TRITON``). No process restart needed. +Toggling is done with the shared ``torchref.utils.use_portable`` context manager +(``use_portable()`` vs the default). No process restart needed. Markers: ``@pytest.mark.cuda`` (skipped by default) and ``@pytest.mark.integration``. Run on a CUDA box with @@ -23,6 +23,8 @@ from pathlib import Path from typing import Dict, Tuple +import contextlib + import pytest import torch @@ -189,28 +191,39 @@ def _target_names(state): "geometry/nonbonded", "geometry/ramachandran", "adp/simu", - "adp/locality", - "adp/KL", - "adp/scaler_U", - "adp/scaler_log_scale", ], ) def test_triton_matches_eager_per_target(target_name, gpu_refinement, gpu_state): - """For each registered target, forward + backward must match between - Triton and eager paths (up to the per-target tolerance).""" + """For each registered target, forward + backward must match between the Triton and + portable paths (up to the per-target tolerance). + + Four params were removed when this migrated off the ``Engine`` enum: + ``adp/locality``, ``adp/KL``, ``adp/scaler_U`` and ``adp/scaler_log_scale`` have **no + Triton implementation at all**, so they were comparing the eager path against itself and + passing at ``rel == 0``. That was true before the migration too -- forcing an engine had + never made them non-vacuous, because there was nothing on the other side. + + What keeps the remaining params honest is not the toggle. On a CUDA host + ``test_backend_is_available_where_it_is_expected`` fails if the Triton kernels *should* + work and do not, and a runtime fallback raises through the degradation warning. Either + fires before this test could quietly compare the reference against itself. + + Note the ``xray`` param is broader than a loss-math A/B: it runs through + ``LBFGSRefinement`` -> ``ModelFT`` -> ``build_electron_density``, so it also exercises the + density splat. That is deliberate coverage, not an accident of the harness. + """ if target_name not in gpu_state.targets: pytest.skip(f"target {target_name!r} not in this state") target = gpu_state.targets[target_name] - from torchref.utils import Engine, use_engine + from torchref.utils import use_portable - # ----- eager (Triton dispatch disabled) ----- - with use_engine(Engine.EAGER): + # ----- reference (dispatch pinned to the portable path) ----- + with use_portable(): eager = _run_target_capture_grads(target, gpu_refinement) - # ----- Triton (dispatch enabled) ----- - with use_engine(Engine.TRITON): - triton = _run_target_capture_grads(target, gpu_refinement) + # ----- Triton (the default on CUDA float32) ----- + triton = _run_target_capture_grads(target, gpu_refinement) atol, rtol = _tol_for(target_name) _assert_close(target_name, eager, triton, atol, rtol) @@ -226,7 +239,7 @@ def test_triton_matches_eager_xray_modes(target_mode, _1daw_pair): it has no Triton path to compare.) """ from torchref import LBFGSRefinement - from torchref.utils import Engine, use_engine + from torchref.utils import use_portable device = torch.device("cuda") pdb, mtz = _1daw_pair @@ -241,9 +254,9 @@ def test_triton_matches_eager_xray_modes(target_mode, _1daw_pair): ref.scaler.refine_lbfgs() target = ref.xray_target_work - with use_engine(Engine.EAGER): + with use_portable(): eager = _run_target_capture_grads(target, ref) - with use_engine(Engine.TRITON): + with contextlib.nullcontext(): # the default: Triton on CUDA float32 triton = _run_target_capture_grads(target, ref) name = f"xray/{target_mode}" @@ -267,7 +280,7 @@ def test_planarity_triton_per_atom_sigma(n_atoms): _planarity_math_eager, planarity_math, ) - from torchref.utils import Engine, use_engine + from torchref.utils import use_portable dev = torch.device("cuda") g = torch.Generator(device=dev).manual_seed(7) @@ -279,10 +292,10 @@ def test_planarity_triton_per_atom_sigma(n_atoms): xe = base.clone().requires_grad_(True) xt = base.clone().requires_grad_(True) - with use_engine(Engine.EAGER): + with use_portable(): le = _planarity_math_eager(xe, [(idx, sigmas)]) (ge,) = torch.autograd.grad(le, xe) - with use_engine(Engine.TRITON): + with contextlib.nullcontext(): # the default: Triton on CUDA float32 lt = planarity_math(xt, [(idx, sigmas)]) (gt,) = torch.autograd.grad(lt, xt) @@ -305,7 +318,7 @@ def test_geometry_degenerate_finite_grads(): from torchref.base.targets.angle import angle_math from torchref.base.targets.bond import bond_math from torchref.base.targets.torsion import torsion_omega_math - from torchref.utils import Engine, use_engine + from torchref.utils import use_portable dev = torch.device("cuda") # Collinear chain of points -> degenerate angle & torsion; plus a @@ -344,9 +357,9 @@ def test_geometry_degenerate_finite_grads(): ), ] for name, fn, args in cases: - for engine in (Engine.EAGER, Engine.TRITON): + for pin in (True, False): x = xyz0.clone().requires_grad_(True) - with use_engine(engine): + with (use_portable() if pin else contextlib.nullcontext()): loss = fn(x, *args) (grad,) = torch.autograd.grad(loss, x) assert torch.isfinite(grad).all(), f"{name}/{engine}: non-finite grad" @@ -355,9 +368,9 @@ def test_geometry_degenerate_finite_grads(): def _hvp_vs_fd(model, hkl, eps=1e-5): """Return (cosine, rel_err) of the autograd Hessian-vector product vs a central finite difference of the gradient, through ``model.forward`` under - ``use_engine(Engine.EAGER)`` (the genuine pure-torch, double-differentiable + ``use_portable()`` (the genuine pure-torch, double-differentiable ED path).""" - from torchref.utils import Engine, use_engine + from torchref.utils import use_portable x = model.xyz.refinable_params x0 = x.detach().clone() @@ -369,7 +382,7 @@ def grad_at(xv): with torch.no_grad(): x.copy_(xv) model.reset_cache() - with use_engine(Engine.EAGER): + with use_portable(): sf = model(hkl, recalc=True) return ( torch.autograd.grad((sf.real**2 + sf.imag**2).sum(), x)[0].detach().clone() @@ -378,7 +391,7 @@ def grad_at(xv): with torch.no_grad(): x.copy_(x0) model.reset_cache() - with use_engine(Engine.EAGER): + with use_portable(): sf = model(hkl, recalc=True) (g1,) = torch.autograd.grad((sf.real**2 + sf.imag**2).sum(), x, create_graph=True) (hvp,) = torch.autograd.grad((g1 * v).sum(), x) @@ -398,11 +411,11 @@ def grad_at(xv): @pytest.mark.cuda @pytest.mark.integration def test_eager_gpu_hessian_iso(tmp_path): - """`use_engine(Engine.EAGER)` gives correct GPU second derivatives (iso). + """``use_portable()`` gives correct GPU second derivatives (iso). The fast GPU density splat is first-order only (custom-Function backward with no grad_fn) and silently drops the second-order term under - create_graph. Under ``Engine.EAGER`` the iso splat now routes to the pure- + create_graph. Under ``use_portable()`` the iso splat now routes to the pure- torch (scatter_add) path, which composes under autograd. A Hessian-vector product through ``ModelFT.forward`` must then match finite differences. """ diff --git a/tests/pytest.ini b/tests/pytest.ini index 8896f04..46417e4 100644 --- a/tests/pytest.ini +++ b/tests/pytest.ini @@ -38,3 +38,10 @@ filterwarnings = ignore::DeprecationWarning ignore::PendingDeprecationWarning ignore::FutureWarning +# A backend degradation is an error under test and a warning in production. Dispatch falls +# back to the portable kernel when an accelerator throws, which is right for a user -- the +# answer stays correct, it just gets slower -- and wrong for CI, because a comparison between +# an accelerator and the reference would then run the reference twice and pass at zero +# difference while measuring nothing. Mirrored in ``pyproject.toml``, which is what applies +# when pytest is invoked from the repo root. + error::torchref.utils.backends.TorchRefDegradationWarning diff --git a/tests/unit/base/test_canonical_sphere_cpu.py b/tests/unit/base/test_canonical_sphere_cpu.py index 30b55e6..943baac 100644 --- a/tests/unit/base/test_canonical_sphere_cpu.py +++ b/tests/unit/base/test_canonical_sphere_cpu.py @@ -32,6 +32,7 @@ error entirely, so every geometry assertion runs at beta = 90, 100 and 115 degrees. """ +import contextlib import math import pytest @@ -48,7 +49,7 @@ per_atom_radius_iso, ) from torchref.base.scattering.scattering_table import get_scattering_params_by_z -from torchref.utils import Engine, use_engine +from torchref.utils import use_portable pytestmark = pytest.mark.unit @@ -241,14 +242,14 @@ def test_fused_float64_is_exact(): # AUTO vs EAGER through the real dispatch: no accelerator needed # =========================================================================== -def _build(engine, dims, frac, inv_frac, voxel, dtype, iso=None, aniso=None): +def _build(pin, dims, frac, inv_frac, voxel, dtype, iso=None, aniso=None): rsg = torch.zeros(*dims, 3, dtype=dtype) # shape only; no kernel reads its values xi, ai, oi, Ai, Bi = iso if iso is not None else _empty_iso(dtype) kw = {} if aniso is not None: xa, ua, oa, Aa, Ba = aniso kw = dict(xyz_aniso=xa, u_aniso=ua, occ_aniso=oa, A_aniso=Aa, B_aniso=Ba) - with use_engine(engine): + with (use_portable() if pin else contextlib.nullcontext()): return build_electron_density( rsg, xi, ai, oi, Ai, Bi, inv_frac, frac, voxel, dtype=dtype, **kw) @@ -260,8 +261,8 @@ def test_auto_matches_eager_iso(beta, dtype): frac, inv_frac, dims, f64 = _cell(beta, dtype=dtype) voxel = _voxel_size(f64, dims) atoms = _iso_atoms(f64, dtype=dtype) - ref = _build(Engine.EAGER, dims, frac, inv_frac, voxel, dtype, iso=atoms) - got = _build(Engine.AUTO, dims, frac, inv_frac, voxel, dtype, iso=atoms) + ref = _build(True, dims, frac, inv_frac, voxel, dtype, iso=atoms) + got = _build(False, dims, frac, inv_frac, voxel, dtype, iso=atoms) tol = _F32_TOL if dtype is torch.float32 else 1e-12 assert _rel_l2(got, ref) < tol @@ -271,8 +272,8 @@ def test_auto_matches_eager_aniso(beta): frac, inv_frac, dims, f64 = _cell(beta) voxel = _voxel_size(f64, dims) atoms = _aniso_atoms(f64) - ref = _build(Engine.EAGER, dims, frac, inv_frac, voxel, torch.float32, aniso=atoms) - got = _build(Engine.AUTO, dims, frac, inv_frac, voxel, torch.float32, aniso=atoms) + ref = _build(True, dims, frac, inv_frac, voxel, torch.float32, aniso=atoms) + got = _build(False, dims, frac, inv_frac, voxel, torch.float32, aniso=atoms) assert _rel_l2(got, ref) < _F32_TOL @@ -289,15 +290,15 @@ def test_auto_matches_eager_gradients(kind): else: xyz, p, occ, A, B = _aniso_atoms(f64, dtype=torch.float64) - def grads(engine): + def grads(pin): x, pp, o = (t.clone().requires_grad_() for t in (xyz, p, occ)) pack = (x, pp, o, A, B) - dm = _build(engine, dims, frac, inv_frac, voxel, torch.float64, + dm = _build(pin, dims, frac, inv_frac, voxel, torch.float64, **({"iso": pack} if kind == "iso" else {"aniso": pack})) (dm * w).sum().backward() return x.grad, pp.grad, o.grad - for g_auto, g_eager in zip(grads(Engine.AUTO), grads(Engine.EAGER)): + for g_auto, g_eager in zip(grads(False), grads(True)): assert torch.allclose(g_auto, g_eager, rtol=1e-9, atol=0) @@ -349,19 +350,19 @@ def recording(*args, **kwargs): return real(*args, **kwargs) monkeypatch.setattr(sphere_splat, "add_isotropic_cpu_sphere_var", recording) - _build(Engine.AUTO, dims, frac, inv_frac, _voxel_size(f64, dims), dtype, + _build(False, dims, frac, inv_frac, _voxel_size(f64, dims), dtype, iso=_iso_atoms(f64, dtype=dtype)) - assert calls, f"Engine.AUTO did not reach the fused CPU splat for {dtype}" + assert calls, f"the default did not reach the fused CPU splat for {dtype}" def test_empty_atom_sets(): """A structure with no isotropic (or no anisotropic) atoms must not crash.""" frac, inv_frac, dims, f64 = _cell(90.0) voxel = _voxel_size(f64, dims) - only_aniso = _build(Engine.AUTO, dims, frac, inv_frac, voxel, torch.float32, + only_aniso = _build(False, dims, frac, inv_frac, voxel, torch.float32, aniso=_aniso_atoms(f64)) assert torch.isfinite(only_aniso).all() and float(only_aniso.abs().sum()) > 0 - both_empty = _build(Engine.AUTO, dims, frac, inv_frac, voxel, torch.float32) + both_empty = _build(False, dims, frac, inv_frac, voxel, torch.float32) assert float(both_empty.abs().sum()) == 0.0 @@ -370,9 +371,9 @@ def test_density_map_accumulates_not_overwrites(): frac, inv_frac, dims, f64 = _cell(90.0) voxel = _voxel_size(f64, dims) iso, aniso = _iso_atoms(f64), _aniso_atoms(f64) - a = _build(Engine.AUTO, dims, frac, inv_frac, voxel, torch.float32, iso=iso) - b = _build(Engine.AUTO, dims, frac, inv_frac, voxel, torch.float32, aniso=aniso) - both = _build(Engine.AUTO, dims, frac, inv_frac, voxel, torch.float32, + a = _build(False, dims, frac, inv_frac, voxel, torch.float32, iso=iso) + b = _build(False, dims, frac, inv_frac, voxel, torch.float32, aniso=aniso) + both = _build(False, dims, frac, inv_frac, voxel, torch.float32, iso=iso, aniso=aniso) assert _rel_l2(both, a + b) < 1e-6 @@ -389,8 +390,8 @@ def test_density_map_accumulates_not_overwrites(): @pytest.mark.parametrize("beta", _BETAS) -@pytest.mark.parametrize("engine", [Engine.AUTO, Engine.EAGER], ids=["auto", "eager"]) -def test_aniso_reduces_to_isotropic(beta, engine): +@pytest.mark.parametrize("pin", [False, True], ids=["default", "portable"]) +def test_aniso_reduces_to_isotropic(beta, pin): """``U = b/(8*pi^2) I`` must reproduce the isotropic splat, on the live dispatch. An analytic identity rather than a comparison against another implementation: a @@ -412,7 +413,7 @@ class of index and factor errors that cross-backend parity cannot see because bo u_sph = torch.zeros(xyz.shape[0], 6, dtype=torch.float64) u_sph[:, :3] = (adp / (8.0 * math.pi**2)).unsqueeze(1) - with use_engine(engine): + with (use_portable() if pin else contextlib.nullcontext()): iso_map = build_electron_density( grid, xyz, adp, occ, A, B, inv_frac, frac, voxel, dtype=torch.float64 ) @@ -426,7 +427,7 @@ class of index and factor errors that cross-backend parity cannot see because bo rel = _rel_l2(aniso_map, iso_map) assert rel < 1e-12, ( - f"beta={beta}, {engine.value}: a spherical U does not reproduce the iso splat " + f"beta={beta}, {'portable' if pin else 'default'}: a spherical U does not reproduce the iso splat " f"(rel L2 {rel:.3e}). Suspect the 8*pi^2 conversion or the diagonal of the " f"Mahalanobis form." ) @@ -470,12 +471,12 @@ def test_fused_kernel_is_thread_invariant(n_threads): original = torch.get_num_threads() try: torch.set_num_threads(1) - with use_engine(Engine.AUTO): + with contextlib.nullcontext(): ref = build_electron_density( grid, xyz, adp, occ, A, B, inv_frac, frac, voxel, dtype=torch.float32 ) torch.set_num_threads(n_threads) - with use_engine(Engine.AUTO): + with contextlib.nullcontext(): got = build_electron_density( grid, xyz, adp, occ, A, B, inv_frac, frac, voxel, dtype=torch.float32 ) @@ -530,13 +531,18 @@ def test_fused_extension_compiles(): ``pytest.skip`` when ``sphere_splat_available()`` is False, which is right for them: they are testing numerics, and without the extension there is nothing to test. But if *every* test skips, a build that has stopped working produces an all-green run while - the CPU production path has silently degraded to the portable eager splat. The engine - dispatch is designed to degrade quietly under ``Engine.AUTO`` (see - ``main.py::_add_isotropic``), which is correct for users and dangerous for CI. + the CPU production path has silently degraded to the portable splat. Dispatch is designed + to degrade quietly, which is correct for users and dangerous for CI. So exactly one test asserts the extension builds, and reports the captured diagnostic plus environment when it does not -- the same stance, and most of the same diagnostic surface, as the ``TestCompilation`` class in the now-deleted ``test_cpu_scatter.py``. + + Now partly redundant with + ``tests/unit/utils/test_backend_tables.py::test_backend_is_available_where_it_is_expected``, + which generalizes this to every backend from the ``expect_available`` column. This one is + kept for its diagnostics: it prints ninja/CXX/PATH/``TORCH_EXTENSIONS_DIR``, which is what + you actually need when a build breaks. That guard previously protected the C++ structured scatter, a helper; it now protects the production CPU splat, so it matters more than it did. """ @@ -558,7 +564,7 @@ def test_fused_extension_compiles(): f"{os.environ.get('TORCH_EXTENSIONS_DIR', '')}\n" ) pytest.fail( - "The fused CPU sphere-splat extension failed to build, so Engine.AUTO on CPU is " + "The fused CPU sphere-splat extension failed to build, so CPU dispatch is " "silently falling back to the portable eager splat for every density " "calculation.\n" f"Error: {err}\n\n" diff --git a/tests/unit/base/test_kernel_import_shims.py b/tests/unit/base/test_kernel_import_shims.py index bbb0eee..6483981 100644 --- a/tests/unit/base/test_kernel_import_shims.py +++ b/tests/unit/base/test_kernel_import_shims.py @@ -11,7 +11,7 @@ import pytest -from torchref.utils.triton_dispatch import triton_available +from torchref.utils.backends import triton_available _needs_triton = pytest.mark.skipif( not triton_available(), reason="CUDA/Triton backend requires the triton package" diff --git a/tests/unit/structure_factor/__init__.py b/tests/unit/structure_factor/__init__.py index 3f982d9..c22c118 100644 --- a/tests/unit/structure_factor/__init__.py +++ b/tests/unit/structure_factor/__init__.py @@ -65,7 +65,7 @@ ``backward`` calls ``torch.autograd.grad`` **without** ``create_graph=True`` on detached copies (``torchref/base/direct_summation/dispatch.py:197``). They are exact at first order -- measured agreement with the eager path is 2.3e-16 -- but a second derivative -through them raises ``element 0 of tensors does not require grad``. ``Engine.EAGER`` +through them raises ``element 0 of tensors does not require grad``. ``force_portable`` does not help; it only steers away from Triton and still lands on ``_CheckpointedSF``. So the oracle is ``_eager_iso`` / ``_eager_aniso``, which are pure torch and therefore @@ -226,7 +226,7 @@ # mps float32 checkpointed 1.72e-06 2.41e-05 1.0000000 # # The MPS row is a path that had no coverage of any kind before: there is no Metal -# direct-summation kernel, so ``Engine.METAL`` and any MPS device both land on +# direct-summation kernel, so any MPS device lands on # ``_checkpointed_*`` running on-device. It agrees with the CPU float32 row to the digit. # # ``ds_triton`` is **unmeasured** -- no CUDA on the calibration host. It shares these gates, diff --git a/tests/unit/structure_factor/helpers.py b/tests/unit/structure_factor/helpers.py index 582b12e..c385625 100644 --- a/tests/unit/structure_factor/helpers.py +++ b/tests/unit/structure_factor/helpers.py @@ -17,12 +17,16 @@ from __future__ import annotations +import contextlib + import itertools from dataclasses import dataclass, fields from typing import Optional, Sequence import torch +from torchref.utils import use_portable + from torchref.base.direct_summation._backends import DS_BACKENDS from torchref.base.direct_summation.dispatch import _eager_aniso, _eager_iso from torchref.base.electron_density._backends import DENSITY_BACKENDS @@ -440,9 +444,9 @@ def sf_fft_for( # Direct kernel access # --------------------------------------------------------------------------- # Every production splat is called *directly* here rather than through -# ``build_electron_density`` + ``use_engine``. Two reasons: +# ``build_electron_density``. Two reasons: # -# 1. **No vacuity risk.** Under ``Engine.AUTO`` a failed accelerator kernel silently +# 1. **No vacuity risk.** Under the default a failed accelerator kernel silently # falls back to the portable splat (``main.py`` catches and falls through), so a # dispatch-driven test can pass while measuring a different kernel than the one it # names. Calling the kernel directly settles that by construction. @@ -478,6 +482,22 @@ def splat_kernel(name: str, aniso: bool): return _KERNEL_TABLE.by_name(name).resolve(aniso) +def maybe_portable(pin: bool): + """``use_portable()`` when ``pin``, otherwise a no-op context. + + Lets a test parametrize over ``[False, True]`` and get the default backend versus the + pinned reference from one ``with``. Replaces the ``use_engine(engine)`` idiom that the + ``[Engine.AUTO, Engine.EAGER]`` parametrizations used. + + Those parametrizations are all still two *distinct* kernels, which is worth stating + because it is easy to assume otherwise: on CPU the default selects the fused C++ sphere + splat and the pin selects the portable ``scatter_add`` one. Even at second order they + differ -- only the double-backward *re-derivation* borrows the portable splat, while the + forward and first derivative stay in C++. + """ + return use_portable() if pin else contextlib.nullcontext() + + def device_supports_dtype(device: torch.device, dtype: torch.dtype) -> bool: """Whether ``device`` can hold ``dtype`` at all. @@ -562,7 +582,7 @@ def splat_direct(scene: Scene, name: str, xyz=None, occ=None, third=None, *, ani #: business in a table describing what production selects; it is resolved here instead. #: #: There is **no Metal/MPS direct-summation kernel**, which the table now says outright by -#: listing ``Engine.METAL`` among ``checkpointed``'s engines. So DS on MPS *is* +#: there being no Metal DS kernel at all. So DS on MPS *is* #: ``_checkpointed_*`` running on-device -- a real production path, and what the ``mps`` leg #: of ``checkpointed`` covers. _DS_KERNEL_TABLE = DS_BACKENDS diff --git a/tests/unit/structure_factor/test_dispatch.py b/tests/unit/structure_factor/test_dispatch.py index b0c7a64..7283154 100644 --- a/tests/unit/structure_factor/test_dispatch.py +++ b/tests/unit/structure_factor/test_dispatch.py @@ -1,21 +1,22 @@ -"""The dispatch ladder itself: which engine selects which kernel, and failure policy. +"""Dispatch end to end, as distinct from the selection *decision*. -Deliberately separate from the accuracy tests. Those call each kernel **directly**, so -they say nothing about whether ``build_electron_density`` would have chosen it — and that -separation is the point. Entangling the two is what forced the old accelerator tests to -carry a monkeypatched call recorder just to prove they were not measuring the fallback. +Deliberately separate from the accuracy tests. Those call each kernel **directly**, so they +say nothing about whether ``build_electron_density`` would have chosen it — and that +separation is the point. -What is pinned here: +Also distinct from ``tests/unit/utils/test_backend_tables.py``, which asserts what ``select`` +returns. That is a pure function and can be interrogated for any ``(device, dtype)``; what it +cannot show is that the chosen kernel was then actually *called*. So what is pinned here is +provenance and fallback behaviour, both of which need a real dispatch on a real device: -* under ``Engine.AUTO`` *and* the strict engine, an accelerator host really reaches its - native kernel rather than the portable splat; -* a strict engine **raises** when its kernel is unavailable, while ``AUTO`` degrades - quietly — the two halves of the never-silently-degrade contract; -* ``Engine.EAGER`` reaches the portable splat on every device. +* on an accelerator host, the default really reaches the native kernel rather than the + portable splat (call recorder, patched at the kernel's defining module); +* ``force_portable`` reaches the portable splat on every device; +* with an accelerator forced dead, the fallback produces a numerically correct map. -Ported from ``tests/integration/test_variable_radius_{gpu,mps}.py``, which are deleted: -their accuracy coverage is superseded by the oracle legs in this package, but these -dispatch contracts are not accuracy and had no replacement. +Ported from ``tests/integration/test_variable_radius_{gpu,mps}.py``, which are deleted: their +accuracy coverage is superseded by the oracle legs in this package, but these dispatch +contracts are not accuracy and had no replacement. """ from __future__ import annotations @@ -25,7 +26,6 @@ from torchref.base.electron_density.main import build_electron_density from torchref.base.electron_density.radius_policy import per_atom_radius_iso -from torchref.utils import Engine, use_engine from tests.helpers.grad_asserts import rel_error @@ -35,7 +35,7 @@ pytestmark = pytest.mark.unit -def _build(scene, device, dtype, engine, aniso=False): +def _build(scene, device, dtype, force_portable=False, aniso=False): """Run one dispatch through ``build_electron_density`` on ``device``. ``dtype`` is passed explicitly rather than inherited from the ambient config. That is @@ -59,28 +59,27 @@ def _build(scene, device, dtype, engine, aniso=False): voxel_size=voxel, dtype=dtype, ) - with use_engine(engine): - if aniso: - return build_electron_density( - xyz_iso=empty3, adp_iso=empty1, occ_iso=empty1, - A_iso=empty5, B_iso=empty5, - xyz_aniso=s.xyz, u_aniso=s.u6, occ_aniso=s.occ, - A_aniso=s.A, B_aniso=s.B, **kw, - ) + kw["force_portable"] = force_portable + if aniso: return build_electron_density( - xyz_iso=s.xyz, adp_iso=s.adp, occ_iso=s.occ, - A_iso=s.A, B_iso=s.B, **kw, + xyz_iso=empty3, adp_iso=empty1, occ_iso=empty1, + A_iso=empty5, B_iso=empty5, + xyz_aniso=s.xyz, u_aniso=s.u6, occ_aniso=s.occ, + A_aniso=s.A, B_aniso=s.B, **kw, ) + return build_electron_density( + xyz_iso=s.xyz, adp_iso=s.adp, occ_iso=s.occ, + A_iso=s.A, B_iso=s.B, **kw, + ) # --------------------------------------------------------------------------- # Provenance: the named kernel actually runs # --------------------------------------------------------------------------- @pytest.mark.mps -@pytest.mark.parametrize("engine", [Engine.AUTO, Engine.METAL]) @pytest.mark.parametrize("kind", ["iso", "aniso"]) -def test_metal_kernel_is_actually_dispatched(scene_small, engine, kind, monkeypatch): - """On MPS float32, both ``AUTO`` and ``METAL`` must call the Metal kernel. +def test_metal_kernel_is_actually_dispatched(scene_small, kind, monkeypatch): + """On MPS float32, the default must call the Metal kernel. Verified with a call recorder, not by comparing maps: the portable reference uses ``scatter_add`` on MPS, whose accumulation order is not reproducible, so two portable @@ -107,14 +106,13 @@ def recording(*args, **kwargs): return real(*args, **kwargs) monkeypatch.setattr(kernels_mps, name, recording) - _build(scene_small, torch.device("mps"), torch.float32, engine, aniso=kind == "aniso") - assert calls, f"{engine} did not dispatch to {name}" + _build(scene_small, torch.device("mps"), torch.float32, aniso=kind == "aniso") + assert calls, f"the default did not dispatch to {name}" @pytest.mark.cuda -@pytest.mark.parametrize("engine", [Engine.AUTO, Engine.TRITON]) @pytest.mark.parametrize("kind", ["iso", "aniso"]) -def test_triton_kernel_is_actually_dispatched(scene_small, engine, kind, monkeypatch): +def test_triton_kernel_is_actually_dispatched(scene_small, kind, monkeypatch): """CUDA float32 equivalent of the Metal provenance test. Same patch target rule as the Metal case -- the module that defines the kernel. The two @@ -133,17 +131,18 @@ def recording(*args, **kwargs): return real(*args, **kwargs) monkeypatch.setattr(kernels_cuda, name, recording) - _build(scene_small, torch.device("cuda"), torch.float32, engine, aniso=kind == "aniso") - assert calls, f"{engine} did not dispatch to {name}" + _build(scene_small, torch.device("cuda"), torch.float32, aniso=kind == "aniso") + assert calls, f"the default did not dispatch to {name}" @pytest.mark.parametrize("kind", ["iso", "aniso"]) -def test_eager_reaches_the_portable_splat(scene_small, kind, monkeypatch): - """``Engine.EAGER`` must reach the portable splat, on any device. +def test_force_portable_reaches_the_portable_splat(scene_small, kind, monkeypatch): + """``force_portable`` must reach the portable splat, on any device. - The counterpart to the provenance tests above: EAGER is the documented escape hatch - for double backward and debugging, so it has to be the *portable* kernel that runs, - not whatever AUTO would have picked. + The counterpart to the provenance tests above, and the only override the dispatcher has: + it exists so you can pin the reference implementation when you suspect an accelerator is + returning wrong numbers, so it has to be the *portable* kernel that runs and not whatever + the default would have picked. """ from torchref.base.electron_density.kernels.cpu import ( variable_radius as kernels_portable, @@ -158,24 +157,30 @@ def recording(*args, **kwargs): return real(*args, **kwargs) monkeypatch.setattr(kernels_portable, name, recording) - _build(scene_small, torch.device("cpu"), torch.float32, Engine.EAGER, aniso=kind == "aniso") - assert calls, f"Engine.EAGER did not dispatch to {name}" + _build(scene_small, torch.device("cpu"), torch.float32, force_portable=True, + aniso=kind == "aniso") + assert calls, f"force_portable did not dispatch to {name}" # --------------------------------------------------------------------------- # Failure policy: strict engines raise, AUTO degrades # --------------------------------------------------------------------------- @pytest.mark.mps -def test_metal_raises_when_shader_unavailable(scene_small, monkeypatch): - """``Engine.METAL`` must raise, never degrade, when the shader is missing. +def test_default_degrades_correctly_when_the_shader_is_unavailable(scene_small, monkeypatch): + """With the shader dead, dispatch must still produce a *correct* map. - ``monkeypatch`` reverts the module globals at teardown, so this needs no - ``try``/``finally`` -- and must not have one, since a swallowed failure here is - precisely the behaviour under test. + Selection tests prove the *decision*; this proves the fallback's numbers. It is the only + place that runs the degradation end to end rather than asserting which row was chosen. + + The half of this test that asserted ``Engine.METAL`` raises is gone with the enum. Its + job -- making a dead accelerator loud rather than silently slow -- is now done by two + other things: ``test_backend_is_available_where_it_is_expected`` fails on any run on an + MPS host where the shader should work and doesn't, and the degradation warning is promoted + to an error under pytest. Both are strictly earlier and broader than a forced engine was. - The AUTO half is the other side of the contract: a user gets a correct answer from the - portable splat, while a benchmark or a test asking for METAL gets an error instead of a - quietly slower result. + ``monkeypatch`` reverts the module globals at teardown, so this needs no + ``try``/``finally`` -- and must not have one, since a swallowed failure here is precisely + the behaviour under test. """ from torchref.base.electron_density.kernels.mps import compile as mps_compile @@ -184,38 +189,23 @@ def test_metal_raises_when_shader_unavailable(scene_small, monkeypatch): monkeypatch.setattr(mps_compile, "_lib_error", ("forced test failure", "")) mps = torch.device("mps") - with pytest.raises(RuntimeError, match="forced test failure"): - _build(scene_small, mps, torch.float32, Engine.METAL) - - # ...while AUTO still degrades to a working splat. Compared against EAGER on the same + # The default degrades to a working splat. Compared against the pinned portable path # device, not against the oracle: with the shader forced to fail both engines now reach # the *same* portable kernel, so they must agree closely, and an oracle comparison here # would instead be measuring this scene's discretization error (which is large -- it is # deliberately tiny and coarse for gradcheck) and tell us nothing about the fallback. # Loose rather than bitwise because MPS ``scatter_add`` accumulation order is not # reproducible, so two portable runs already differ at ~1e-7. - auto = _build(scene_small, mps, torch.float32, Engine.AUTO) - eager = _build(scene_small, mps, torch.float32, Engine.EAGER) - rel = H.rel_l2(auto.cpu().to(torch.float64), eager.cpu().to(torch.float64)) - print(f"\n AUTO vs EAGER after forced shader failure: relL2 {rel:.3e}") + auto = _build(scene_small, mps, torch.float32) + portable = _build(scene_small, mps, torch.float32, force_portable=True) + rel = H.rel_l2(auto.cpu().to(torch.float64), portable.cpu().to(torch.float64)) + print(f"\n default vs portable after forced shader failure: relL2 {rel:.3e}") assert rel < 1e-5, ( - f"AUTO did not degrade to the portable splat after the shader failed (rel {rel:.3e})" + f"dispatch did not degrade to the portable splat after the shader failed " + f"(rel {rel:.3e})" ) -@pytest.mark.parametrize("engine", [Engine.TRITON, Engine.METAL]) -def test_strict_engine_rejects_cpu(scene_small, engine): - """A strict accelerator engine on CPU must raise, not fall back. - - Both gates refuse CPU inputs, by different routes: ``should_use_triton`` raises - directly, and ``should_use_metal`` raises on non-MPS. Either way the failure is loud, - which is what lets the provenance tests above be meaningful — a strict engine that - quietly degraded would make them untestable. - """ - with pytest.raises(RuntimeError): - _build(scene_small, torch.device("cpu"), torch.float32, engine) - - # --------------------------------------------------------------------------- # Non-vacuity # --------------------------------------------------------------------------- @@ -347,19 +337,23 @@ def test_triton_ds_does_not_silently_truncate_hkl(scene_small): @pytest.mark.cuda -def test_sfds_engine_toggle_end_to_end(scene_fine): - """``SfDS`` through a full symmetry loop: ``Engine.TRITON`` vs ``Engine.EAGER``. - - Restores the end-to-end coverage lost with ``test_ds_triton_vs_eager.py``. Distinct - from the per-kernel DS legs in ``test_forward.py`` / ``test_gradients.py``: those call - the kernels directly in P1, so nothing there exercises ``SfDS``'s symmetry accumulation - or its per-call ``engine=`` argument, which **overrides** ``use_engine`` rather than - deferring to it. - - A non-P1 group on purpose. Both engines share the symmetry algebra, so this does not - validate the convention -- ``test_forward.py::test_sfds_matches_gemmi_with_symmetry`` - does that against gemmi. What it validates is that swapping the engine underneath a - symmetry loop changes nothing. +def test_sfds_backend_toggle_end_to_end(scene_fine): + """``SfDS`` through a full symmetry loop: the Triton DS kernel vs the reference. + + Restores the end-to-end coverage lost with ``test_ds_triton_vs_eager.py``. Distinct from + the per-kernel DS legs in ``test_forward.py`` / ``test_gradients.py``: those call the + kernels directly in P1, so nothing there exercises ``SfDS``'s symmetry accumulation or its + per-call ``force_portable`` argument. + + A non-P1 group on purpose. Both backends share the symmetry algebra, so this does not + validate the convention -- ``test_forward.py::test_sfds_matches_gemmi_with_symmetry`` does + that against gemmi. What it validates is that swapping the backend underneath a symmetry + loop changes nothing. + + Non-vacuity does not depend on being able to *force* Triton: on a CUDA host + ``test_backend_is_available_where_it_is_expected`` fails if ``ds_triton`` should work and + does not, and a runtime fallback raises via the degradation warning. Both fire before this + test could quietly compare the reference against itself. """ from torchref.model.sf_ds import SfDS @@ -367,9 +361,9 @@ def test_sfds_engine_toggle_end_to_end(scene_fine): s = scene_fine.to(device=cuda, dtype=torch.float32) obs = H.synthetic_obs(H.ds_direct(scene_fine, "eager").detach()).to(cuda, torch.float32) - def run(engine): + def run(force_portable): sf = SfDS( - cell=s.cell, spacegroup="P212121", engine=engine, + cell=s.cell, spacegroup="P212121", force_portable=force_portable, dtype_float=torch.float32, device=cuda, max_memory_gb=2.0, ) leaves = tuple(t.clone().requires_grad_(True) for t in (s.xyz, s.adp, s.occ)) @@ -378,11 +372,11 @@ def run(engine): grads = torch.autograd.grad(H.ls_target(F, obs), leaves) return F.detach(), grads - F_t, g_t = run(Engine.TRITON) - F_e, g_e = run(Engine.EAGER) + F_t, g_t = run(False) # default: the Triton DS kernel on CUDA float32 + F_e, g_e = run(True) # pinned to the checkpointed reference rel_F = H.rel_l2(F_t.cpu().to(torch.complex128), F_e.cpu().to(torch.complex128)) - print(f"\n SfDS P212121 TRITON vs EAGER: F relL2 {rel_F:.3e}") + print(f"\n SfDS P212121 triton vs portable: F relL2 {rel_F:.3e}") assert rel_F < RTOL_DS_F32, f"SfDS forward differs by engine: rel {rel_F:.3e}" for name, a, b in zip(("xyz", "adp", "occ"), g_t, g_e): rel = rel_error(a, b) diff --git a/tests/unit/structure_factor/test_ds_dispatch.py b/tests/unit/structure_factor/test_ds_dispatch.py index b2b3606..e329403 100644 --- a/tests/unit/structure_factor/test_ds_dispatch.py +++ b/tests/unit/structure_factor/test_ds_dispatch.py @@ -1,8 +1,15 @@ """Direct-summation dispatch *mechanics*, as distinct from its accuracy. CPU-only. What is left here after the oracle package absorbed the numerics: that -reflection-chunking is exact, that an empty atom set returns zeros of the right shape, -and that the engine guards refuse rather than silently degrade. +reflection-chunking is exact, that an empty atom set returns zeros of the right shape, and +that an integer ``hkl`` -- the production dtype -- is accepted losslessly. + +Two tests were deleted rather than migrated when the ``Engine`` enum went away. Both had +forcing as their *subject*: one asserted that a forced Triton engine refuses CPU inputs, and +one that a forced Metal engine runs eager instead of raising. With no forcing engine there is +nothing to refuse, and the second would have degenerated into ``assert torch.equal(F, F)``. +Selection is now asserted directly against the tables in +``tests/unit/utils/test_backend_tables.py``. The accuracy questions this file used to own -- checkpointed-vs-eager parity and ``gradcheck`` -- moved to ``test_gradients.py`` in this package, which ties the shipping @@ -14,7 +21,7 @@ import pytest import torch -from torchref.base.direct_summation import Engine, ds_iso +from torchref.base.direct_summation import ds_iso from torchref.base.direct_summation import dispatch as D from torchref.config import dtypes @@ -72,38 +79,11 @@ def test_empty_atoms_returns_zeros(): empty = torch.zeros(0, 3, dtype=torch.float64) z = torch.zeros(0, dtype=torch.float64) z5 = torch.zeros(0, 5, dtype=torch.float64) - F = ds_iso(hkl, s, empty, z, z, z5, z5, engine=Engine.AUTO) + F = ds_iso(hkl, s, empty, z, z, z5, z5) assert F.shape == (hkl.shape[0],) assert F.abs().sum().item() == 0.0 -def test_explicit_triton_engine_rejects_cpu(): - hkl, s, _, A, B = _inputs() - xyz, occ, adp, _ = _leaves() - with pytest.raises(RuntimeError): - ds_iso(hkl, s, xyz, occ, adp, A, B, engine=Engine.TRITON) - - -def test_metal_engine_runs_eager_rather_than_raising(): - """``Engine.METAL`` at a direct-summation site means eager, not an error. - - There is no Metal DS kernel: ``Engine.METAL`` returns False from the Triton gate and - an MPS tensor fails ``is_cuda``, so DS under METAL *is* ``_checkpointed_*``. That is - deliberate -- the ``Engine`` docstring recommends scoping METAL with ``use_engine`` - around the density call, which necessarily leaves it set for any DS in the same - block -- but nothing asserted it, so a dispatcher that rejected engines no backend - claims would break this silently. - - Compared against ``Engine.AUTO`` on the same inputs rather than merely checked for - finiteness, so a future path that returned zeros could not pass. - """ - hkl, s, _, A, B = _inputs(dtype=torch.float64) - xyz, occ, adp, _ = _leaves(dtype=torch.float64) - F_metal = ds_iso(hkl, s, xyz, occ, adp, A, B, engine=Engine.METAL) - F_auto = ds_iso(hkl, s, xyz, occ, adp, A, B, engine=Engine.AUTO) - assert torch.equal(F_metal, F_auto) - - def test_integer_hkl_is_accepted_and_lossless(): """An ``int32`` ``hkl`` must keep working, and must give the float32 answer exactly. @@ -114,9 +94,8 @@ def test_integer_hkl_is_accepted_and_lossless(): This matters for how the dtype gate may be tightened. The gate's test is ``t.dtype is torch.float32``, an identity check, so probing ``hkl`` with that rule - would reject the production dtype -- declining Triton under AUTO and raising under - ``Engine.TRITON``. The rule has to exempt integer tensors, and this test is what - fails if it does not. + would reject the production dtype and disable the Triton path outright. The rule has to + exempt integer tensors, and this test is what fails if it does not. Worth recording alongside: casting ``hkl`` is not merely tolerable, it is *free*. Miller indices are integer-valued in every dtype that holds them (symmetry maps @@ -131,10 +110,8 @@ def test_integer_hkl_is_accepted_and_lossless(): hkl_int = torch.randint(-3, 4, (7, 3), dtype=torch.int32) s = s[: hkl_int.shape[0]] - F_int = ds_iso(hkl_int, s, xyz, occ, adp, A, B, engine=Engine.AUTO) - F_float = ds_iso( - hkl_int.to(torch.float64), s, xyz, occ, adp, A, B, engine=Engine.AUTO - ) + F_int = ds_iso(hkl_int, s, xyz, occ, adp, A, B) + F_float = ds_iso(hkl_int.to(torch.float64), s, xyz, occ, adp, A, B) assert torch.equal(F_int, F_float), "casting integer Miller indices must be exact" diff --git a/tests/unit/structure_factor/test_forward.py b/tests/unit/structure_factor/test_forward.py index 8afe38e..0d4caa0 100644 --- a/tests/unit/structure_factor/test_forward.py +++ b/tests/unit/structure_factor/test_forward.py @@ -12,7 +12,6 @@ import torch from torchref.model.sf_ds import SfDS -from torchref.utils import Engine, use_engine from . import ( COS_MIN_DS, @@ -33,6 +32,11 @@ pytestmark = pytest.mark.unit + +def _lbl(pin): + """Label for the backend a pin parametrization selected.""" + return "portable" if pin else "default" + # float32 first: it is the production dtype, so it is the case that matters and the one # these gates are calibrated on. float64 separates truncation error from float32 noise. _DTYPES = [torch.float32, torch.float64] @@ -270,10 +274,10 @@ def test_sfds_matches_gemmi_with_symmetry(gemmi_iso_symmetry): # --------------------------------------------------------------------------- # DS -> FFT # --------------------------------------------------------------------------- -@pytest.mark.parametrize("engine", [Engine.AUTO, Engine.EAGER], ids=["auto", "eager"]) +@pytest.mark.parametrize("pin", [False, True], ids=["default", "portable"]) @pytest.mark.parametrize("dtype", _DTYPES, ids=["f32", "f64"]) @pytest.mark.parametrize("kind", ["iso", "aniso"]) -def test_fft_matches_ds_amplitudes(scene_fine, oracle_fine, kind, dtype, engine): +def test_fft_matches_ds_amplitudes(scene_fine, oracle_fine, kind, dtype, pin): """The headline gate: the map route reproduces the analytic answer. The DS reference stays in float64 whatever the candidate's dtype -- an oracle should @@ -281,13 +285,13 @@ def test_fft_matches_ds_amplitudes(scene_fine, oracle_fine, kind, dtype, engine) """ aniso = kind == "aniso" sf = H.sf_fft_for(scene_fine, dtype) - with use_engine(engine), torch.no_grad(): + with H.maybe_portable(pin), torch.no_grad(): F_fft = H.fft_sf(scene_fine, sf, aniso=aniso).to(torch.complex128) F_ds = oracle_fine[f"{kind}_F"] - print(f"\ngrid {tuple(int(v) for v in sf.gridsize)}, {kind}, {dtype}, {engine.value}") + print(f"\ngrid {tuple(int(v) for v in sf.gridsize)}, {kind}, {dtype}, {_lbl(pin)}") rel, mrel, scale = _report(f"FFT vs DS ({kind})", F_fft, F_ds) - assert rel < RTOL_AMPLITUDE, f"{kind}/{dtype}/{engine.value}: rel L2 {rel:.3e}" + assert rel < RTOL_AMPLITUDE, f"{kind}/{dtype}/{_lbl(pin)}: rel L2 {rel:.3e}" cos = float( (F_fft.conj() * F_ds).real.sum() / (F_fft.abs().norm() * F_ds.abs().norm()).clamp_min(1e-30) @@ -335,9 +339,9 @@ def test_backend_parity_auto_vs_eager(scene_fine, kind, dtype): aniso = kind == "aniso" sf = H.sf_fft_for(scene_fine, dtype) with torch.no_grad(): - with use_engine(Engine.AUTO): + with H.maybe_portable(False): auto = H.fft_sf(scene_fine, sf, aniso=aniso).to(torch.complex128) - with use_engine(Engine.EAGER): + with H.maybe_portable(True): eager = H.fft_sf(scene_fine, sf, aniso=aniso).to(torch.complex128) tol = RTOL_BACKEND_F32 if dtype is torch.float32 else RTOL_BACKEND_F64 rel = H.rel_l2(auto, eager) @@ -407,7 +411,7 @@ def test_coarse_grid_is_measurably_worse(scene_coarse, scene_fine, oracle_fine): # Every production kernel, on every device it ships on # --------------------------------------------------------------------------- # These call each splat kernel **directly** rather than through -# ``build_electron_density`` + ``use_engine``. Under ``Engine.AUTO`` a failed accelerator +# ``build_electron_density``. Under the default a failed accelerator # kernel silently falls back to the portable splat, so a dispatch-driven test can pass # while measuring a different kernel than the one it names; a direct call settles that by # construction. The dispatch ladder is tested separately in ``test_dispatch.py``. diff --git a/tests/unit/structure_factor/test_gradients.py b/tests/unit/structure_factor/test_gradients.py index 0e84f62..70604fd 100644 --- a/tests/unit/structure_factor/test_gradients.py +++ b/tests/unit/structure_factor/test_gradients.py @@ -30,7 +30,6 @@ _eager_aniso, _eager_iso, ) -from torchref.utils import Engine, use_engine from . import ( COS_MIN_DS, @@ -51,6 +50,11 @@ pytestmark = pytest.mark.unit + +def _lbl(pin): + """Label for the backend a pin parametrization selected.""" + return "portable" if pin else "default" + _DTYPES = [torch.float32, torch.float64] # float32 first: production dtype _LEAF_NAMES = ("xyz", "occ", "adp_or_u") @@ -149,7 +153,7 @@ def test_public_ds_api_matches_oracle(scene_fine): """The same check one level up, through ``ds_iso``'s own dispatch. Guards against the dispatch layer -- not the kernel -- introducing a discrepancy: - a wrong ``max_memory_gb`` default, or an ``Engine`` gate that silently routes + a wrong ``max_memory_gb`` default, or a dispatch table that silently routes somewhere unintended on CPU. """ from torchref.base.direct_summation.dispatch import ds_iso @@ -179,10 +183,10 @@ def test_public_ds_api_matches_oracle(scene_fine): # --------------------------------------------------------------------------- # 3. DS -> FFT # --------------------------------------------------------------------------- -@pytest.mark.parametrize("engine", [Engine.AUTO, Engine.EAGER], ids=["auto", "eager"]) +@pytest.mark.parametrize("pin", [False, True], ids=["default", "portable"]) @pytest.mark.parametrize("dtype", _DTYPES, ids=["f32", "f64"]) @pytest.mark.parametrize("kind", ["iso", "aniso"]) -def test_fft_gradients_match_ds(scene_fine, oracle_fine, kind, dtype, engine): +def test_fft_gradients_match_ds(scene_fine, oracle_fine, kind, dtype, pin): """Gradients of the map route against the analytic oracle, for all three leaves. ``occ`` is included deliberately. Before this package, occupancy gradients through @@ -198,18 +202,18 @@ def test_fft_gradients_match_ds(scene_fine, oracle_fine, kind, dtype, engine): aniso = kind == "aniso" sf = H.sf_fft_for(scene_fine, dtype) leaves = scene_fine.leaves(aniso=aniso) - with use_engine(engine): + with H.maybe_portable(pin): F = H.fft_sf(scene_fine, sf, *leaves, aniso=aniso) got = torch.autograd.grad(H.ls_target(F, oracle_fine[f"{kind}_obs"]), leaves) ref = oracle_fine[f"{kind}_grads"] gate, cos_gate = RTOL_GRADIENT_SYNTHETIC, COS_MIN_GRADIENT_SYNTHETIC - print(f"\n{kind}, {dtype}, {engine.value} (rel gate {gate:.0e}, cos gate {cos_gate})") + print(f"\n{kind}, {dtype}, {_lbl(pin)} (rel gate {gate:.0e}, cos gate {cos_gate})") for name, g, r in zip(_LEAF_NAMES, got, ref): rel, cos = rel_error(g, r), cosine_similarity(g, r) print(f" {name:10s} rel {rel:.3e} cos {cos:.8f} ratio {gradnorm_ratio(g, r):.4f}") - assert rel < gate, f"{kind}/{dtype}/{engine.value} {name}: rel {rel:.3e}" - assert cos > cos_gate, f"{kind}/{dtype}/{engine.value} {name}: cos {cos:.6f}" + assert rel < gate, f"{kind}/{dtype}/{_lbl(pin)} {name}: rel {rel:.3e}" + assert cos > cos_gate, f"{kind}/{dtype}/{_lbl(pin)} {name}: cos {cos:.6f}" @pytest.mark.parametrize("dtype", _DTYPES, ids=["f32", "f64"]) @@ -221,13 +225,13 @@ def test_backend_parity_of_gradients(scene_fine, oracle_fine, kind, dtype): aniso = kind == "aniso" sf = H.sf_fft_for(scene_fine, dtype) - def run(engine): + def run(pin): leaves = scene_fine.leaves(aniso=aniso) - with use_engine(engine): + with H.maybe_portable(pin): F = H.fft_sf(scene_fine, sf, *leaves, aniso=aniso) return torch.autograd.grad(H.ls_target(F, oracle_fine[f"{kind}_obs"]), leaves) - auto, eager = run(Engine.AUTO), run(Engine.EAGER) + auto, eager = run(False), run(True) tol = RTOL_BACKEND_GRAD_F32 if dtype is torch.float32 else RTOL_BACKEND_GRAD_F64 for name, a, e in zip(_LEAF_NAMES, auto, eager): rel = rel_error(a, e) @@ -259,7 +263,7 @@ def test_fft_gradients_match_ds_real_structure(gemmi_aniso_grad, oracle_aniso_gr """Gradients on 7L84 -- 1209 atoms, all with ANISOU -- at production sampling. This is the gate that states something about production; the synthetic sweeps above - exist for dtype/engine/cell coverage and are calibrated loosely because small scenes + exist for dtype/backend/cell coverage and are calibrated loosely because small scenes overstate the discretization residual (see ``__init__.py``). Run in float32, the production dtype, against the float64 oracle. Nothing here is diff --git a/tests/unit/structure_factor/test_second_order.py b/tests/unit/structure_factor/test_second_order.py index 39ad643..b840f3c 100644 --- a/tests/unit/structure_factor/test_second_order.py +++ b/tests/unit/structure_factor/test_second_order.py @@ -39,7 +39,6 @@ from tests.helpers.grad_asserts import cosine_similarity, hvp, hvp_central_fd, rel_error from torchref.base.direct_summation.dispatch import _eager_aniso, _eager_iso from torchref.base.electron_density._backends import DENSITY_BACKENDS -from torchref.utils import Engine, use_engine from . import ( ATOL_GRADCHECK, @@ -54,6 +53,11 @@ pytestmark = pytest.mark.unit + +def _lbl(pin): + """Label for the backend a pin parametrization selected.""" + return "portable" if pin else "default" + _DTYPES = [torch.float32, torch.float64] # float32 first: production dtype @@ -163,14 +167,14 @@ def test_eager_ds_survives_double_backward_where_public_api_does_not(scene_small # --------------------------------------------------------------------------- # 3. DS -> FFT at second order # --------------------------------------------------------------------------- -@pytest.mark.parametrize("engine", [Engine.AUTO, Engine.EAGER], ids=["auto", "eager"]) +@pytest.mark.parametrize("pin", [False, True], ids=["default", "portable"]) @pytest.mark.parametrize("dtype", _DTYPES, ids=["f32", "f64"]) @pytest.mark.parametrize("kind", ["iso", "aniso"]) -def test_fft_hvp_matches_ds(scene_fine, oracle_fine, kind, dtype, engine): +def test_fft_hvp_matches_ds(scene_fine, oracle_fine, kind, dtype, pin): """The map route's HVP against the DS oracle's HVP. This is the replacement for the two failing FD-based tests, and it covers strictly - more: both dtypes and both engines, where the originals covered float64-plain and + more: both dtypes and both backends, where the originals covered float64-plain and float32-C++ only, each with its own hand-tuned ``eps``. Both sides contract with the same seeded direction ``v`` (from the oracle fixture), @@ -187,14 +191,14 @@ def loss(x): oracle_fine[f"{kind}_obs"], ) - with use_engine(engine): + with H.maybe_portable(pin): got = hvp(loss, scene_fine.xyz, v) ref = oracle_fine[f"{kind}_hvp"] rel, cos = rel_error(got, ref), cosine_similarity(got, ref) - print(f"\n {kind}/{dtype}/{engine.value}: HVP vs DS -- rel {rel:.3e} cos {cos:.8f}") - assert cos > COS_MIN, f"{kind}/{dtype}/{engine.value}: HVP direction, cos {cos:.6f}" - assert rel < RTOL_HVP, f"{kind}/{dtype}/{engine.value}: HVP magnitude, rel {rel:.3e}" + print(f"\n {kind}/{dtype}/{_lbl(pin)}: HVP vs DS -- rel {rel:.3e} cos {cos:.8f}") + assert cos > COS_MIN, f"{kind}/{dtype}/{_lbl(pin)}: HVP direction, cos {cos:.6f}" + assert rel < RTOL_HVP, f"{kind}/{dtype}/{_lbl(pin)}: HVP magnitude, rel {rel:.3e}" @pytest.mark.parametrize("dtype", _DTYPES, ids=["f32", "f64"]) @@ -235,7 +239,7 @@ def counting(*args, **kwargs): def loss(x): return H.ls_target(H.fft_sf(scene_fine, sf, x, occ, adp), obs) - with use_engine(Engine.AUTO): + with H.maybe_portable(False): out = hvp(loss, scene_fine.xyz, v) assert calls["fallback"] > 0, ( @@ -319,7 +323,7 @@ def loss(x): def test_fft_hvp_matches_ds_real_structure(gemmi_aniso_grad, oracle_aniso_grad): """Second derivatives on 7L84 at production sampling, in the production dtype. - The synthetic HVP sweep above covers dtype and engine combinations; this is the one + The synthetic HVP sweep above covers dtype and backend combinations; this is the one that speaks to production, for the same reason as its first-order counterpart in ``test_gradients.py``. """ @@ -352,7 +356,7 @@ def loss(x): # through the portable splat on the saved leaves # add_*_plain_var yes -- pure autograd over ``scatter_add`` # add_*_mps_var no -- ``mps/variable_radius.py:12``: "Backward is first-order -# only (like CUDA); double backward must use Engine.EAGER" +# only (like CUDA); double backward needs the portable splat" # WorkQueueGridDensity* no -- same, no ``create_graph`` path in the Triton backward # # So an accelerator HVP cannot be compared against the oracle: there is no HVP to compare. @@ -376,7 +380,7 @@ def test_kernel_hvp_matches_ds(scene_fine, oracle_fine, device, dtype, kernel, k """HVP of a double-differentiable kernel against the oracle's, on every device. Covers the portable splat running *on an accelerator*, which nothing tested before: - it is what ``Engine.EAGER`` and the CUDA/MPS float64 fallthrough actually execute, and + it is what ``force_portable`` and the CUDA/MPS float64 fallthrough actually execute, and it is the path a Hessian-based optimizer lands on there. """ if kernel not in _DOUBLE_DIFFERENTIABLE: diff --git a/tests/unit/utils/test_backend_tables.py b/tests/unit/utils/test_backend_tables.py index fde4b9a..1e1cbfa 100644 --- a/tests/unit/utils/test_backend_tables.py +++ b/tests/unit/utils/test_backend_tables.py @@ -1,9 +1,9 @@ """The three production dispatch tables, checked as policy rather than as numerics. Separate from ``test_backends.py``, which exercises the resolver against synthetic tables. -This file asserts things about the tables that actually ship: that every engine is handled -somewhere, that each backend is available on the hosts where it is declared to be, and that -selection returns what the docs claim for each reachable ``(device, dtype, engine)``. +This file asserts things about the tables that actually ship: that each backend is available +on the hosts where it is declared to be, that each row resolves to the kernel its name claims, +and that selection returns what the docs say for every reachable ``(device, dtype)``. Everything here runs on any host. That is the point -- the accelerator provenance tests can only cover the backend the machine happens to have, whereas ``select`` is a pure function of @@ -17,7 +17,6 @@ from torchref.base.electron_density._backends import DENSITY_BACKENDS from torchref.base.targets._dispatch import TARGET_BACKENDS from torchref.utils.backends import select -from torchref.utils.triton_dispatch import Engine pytestmark = pytest.mark.unit @@ -30,58 +29,27 @@ ] -# --------------------------------------------------------------------------- -# Table-level invariants -# --------------------------------------------------------------------------- -@pytest.mark.parametrize("table", ALL_TABLES, ids=lambda t: t.name.split()[0]) -def test_every_table_handles_every_engine(table): - """No engine may fall through a table with nothing claiming it. - - Constructing a ``BackendTable`` already asserts this, so importing the tables is most of - the test. Stated explicitly anyway because it is the invariant the whole design exists - for, and because the failure it prevents is invisible: ``Engine.METAL`` means "run the - Metal density splat" at one site and "run eager" at every other, and if a table stopped - absorbing it, ``with use_engine(Engine.METAL): loss = bond_target(x)`` would go from - working to raising with no test noticing. - """ - covered = frozenset().union(*(b.engines for b in table.backends)) - assert covered == frozenset(Engine), ( - f"{table.name}: {sorted(e.name for e in frozenset(Engine) - covered)} " - "handled by no backend" - ) - - -@pytest.mark.parametrize("table", ALL_TABLES, ids=lambda t: t.name.split()[0]) -def test_forcing_engines_are_not_absorbed_by_the_base_case(table): - """``TRITON``/``METAL`` must be absent from the base case, or forcing stops being strict. - - This is the mechanism, not a detail: a forced engine raises precisely because no - fallback lists it. Add either to the base case and every "strict engine refuses" test - starts passing for the wrong reason. - """ - for engine in (Engine.TRITON, Engine.METAL): - if any(engine in b.engines and b is not table.base for b in table.backends): - assert engine not in table.base.engines, ( - f"{table.name}: base case {table.base.name} absorbs {engine}, " - "so forcing it can silently degrade" - ) # --------------------------------------------------------------------------- -# Availability: declared expectations must hold on the hosts that declare them +# Availability: declared expectations must match reality # --------------------------------------------------------------------------- @pytest.mark.parametrize("table,backend", ALL_BACKENDS) def test_backend_is_available_where_it_is_expected(table, backend): """A backend declared required on this host must actually be usable here. - One parametrized test in place of a bespoke compile check per kernel, which is what + One parametrized test in place of a bespoke compile check per kernel -- that is what ``expect_available`` on the table row is for. - It **fails rather than skips**, and that asymmetry is deliberate. Dispatch under - ``Engine.AUTO`` degrades quietly -- correct for users, dangerous for CI -- so if every - test skipped when a kernel went missing, a broken build would produce an all-green run - while production had silently fallen back to a ~100x slower path. A host that is not - expected to provide the backend simply generates no assertion. + It **fails rather than skips**, and that asymmetry is deliberate. Dispatch degrades + quietly, which is correct for users and dangerous for CI: if every test skipped when a + kernel went missing, a broken build would produce an all-green run while production had + silently fallen back to a ~100x slower path. + + This is also what keeps the accelerator-vs-reference comparisons honest now that forcing + an engine is no longer possible. A skewed Triton install used to be caught by + ``Engine.TRITON`` raising; it is now caught here, on any run on that host, which is + strictly earlier and louder. """ if not backend.expected_here(): pytest.skip(f"{backend.name} is not expected on this host") @@ -92,6 +60,47 @@ def test_backend_is_available_where_it_is_expected(table, backend): ) +# --------------------------------------------------------------------------- +# Provenance without a call recorder +# --------------------------------------------------------------------------- +@pytest.mark.parametrize("table", ALL_TABLES, ids=lambda t: t.name.split()[0]) +def test_every_row_resolves_to_a_distinct_kernel(table): + """No two rows may resolve to the same function, and iso must differ from aniso. + + Catches the one failure mode nothing else can: a row naming the *wrong but valid* kernel. + If ``cpu_sphere`` pointed at ``add_isotropic_plain_var``, the accuracy tests would pass + (portable *is* the reference implementation), the selection tests would pass (the name it + returns is still right), and the availability test would pass (the extension built fine) -- + while the CPU fast path was silently gone. Here it collides with ``portable`` and fails. + + Asserting distinctness rather than comparing against the declared attribute is what makes + this non-tautological: re-reading ``backend.kernel`` would only prove ``getattr`` works. + + Runs on every host, unlike the monkeypatched provenance tests, because resolving a kernel + imports its module without launching it -- the CUDA module is guarded on ``_HAVE_TRITON`` + and the Metal one defers shader compilation to first use. + """ + seen = {} + for backend in table.backends: + if backend.kernel is None: # gate-only row; the call site owns the kernel + continue + iso, aniso = backend.resolve(False), backend.resolve(True) + assert iso is not aniso, ( + f"{table.name}/{backend.name} resolves iso and aniso to the same function " + f"({iso.__module__}.{iso.__name__})" + ) + for variant, fn in (("iso", iso), ("aniso", aniso)): + key = (fn.__module__, fn.__name__) + if key in seen: + other, other_variant = seen[key] + pytest.fail( + f"{table.name}: {backend.name}/{variant} and {other}/{other_variant} " + f"both resolve to {key[0]}.{key[1]} -- one of these rows names the " + "wrong kernel, and every other test would still pass" + ) + seen[key] = (backend.name, variant) + + # --------------------------------------------------------------------------- # Selection, enumerated # --------------------------------------------------------------------------- @@ -100,52 +109,47 @@ def _six(device, dtype): _CPU_CASES = [ - (torch.float32, Engine.AUTO, "cpu_sphere"), - (torch.float64, Engine.AUTO, "cpu_sphere"), - (torch.float32, Engine.EAGER, "portable"), - (torch.float64, Engine.EAGER, "portable"), + (torch.float32, False, "cpu_sphere"), + (torch.float64, False, "cpu_sphere"), + (torch.float32, True, "portable"), + (torch.float64, True, "portable"), ] -@pytest.mark.parametrize("dtype,engine,expected", _CPU_CASES, - ids=[f"{d}-{e.name}" for d, e, _ in _CPU_CASES]) -def test_density_selection_on_cpu(dtype, engine, expected): +@pytest.mark.parametrize( + "dtype,pin,expected", + _CPU_CASES, + ids=[f"{d}-{'portable' if p else 'default'}" for d, p, _ in _CPU_CASES], +) +def test_density_selection_on_cpu(dtype, pin, expected): """The density policy, asserted directly instead of inferred from a call recorder. ``select`` is a pure function of the table, so this replaces most of what previously - needed a monkeypatched spy -- and unlike a spy it can assert the float64 leg, which the + needed a monkeypatched spy -- and unlike a spy it can assert the float64 legs, which the accelerator provenance tests cannot reach at all. """ - got = select(DENSITY_BACKENDS, _six("cpu", dtype), engine).name + got = select(DENSITY_BACKENDS, _six("cpu", dtype), force_portable=pin).name assert got == expected @pytest.mark.mps -@pytest.mark.parametrize( - "engine,expected", [(Engine.AUTO, "mps_metal"), (Engine.EAGER, "portable")] -) -def test_density_selection_on_mps(engine, expected): - got = select(DENSITY_BACKENDS, _six("mps", torch.float32), engine).name +@pytest.mark.parametrize("pin,expected", [(False, "mps_metal"), (True, "portable")], + ids=["default", "portable"]) +def test_density_selection_on_mps(pin, expected): + got = select(DENSITY_BACKENDS, _six("mps", torch.float32), force_portable=pin).name assert got == expected -@pytest.mark.parametrize("engine", [Engine.TRITON, Engine.METAL]) -def test_forced_accelerator_engines_refuse_cpu_inputs(engine): - """Host-independent: a CPU tensor is wrong for both accelerators everywhere.""" - with pytest.raises(RuntimeError, match="requires (CUDA|MPS) float32"): - select(DENSITY_BACKENDS, _six("cpu", torch.float32), engine) - - -def test_metal_engine_selects_eager_at_the_other_two_sites(): - """``Engine.METAL`` is density-only, and must mean "run eager" elsewhere. +@pytest.mark.parametrize("table", ALL_TABLES, ids=lambda t: t.name.split()[0]) +def test_force_portable_reaches_the_base_case_in_every_table(table): + """The override is uniform: one flag, same meaning at every dispatch site. - The characterization test in ``test_ds_dispatch.py`` covers this end to end; here it is - asserted against the tables, where it is now a declared fact rather than an early - ``return False`` inside a predicate. + Not true of what it replaced -- ``Engine.METAL`` selected the Metal splat at the density + site and silently meant "run eager" at the other two, a per-site asymmetry that existed + only as an early ``return False`` inside a predicate. """ - cpu = _six("cpu", torch.float32) - assert select(DS_BACKENDS, cpu, Engine.METAL).name == "checkpointed" - assert select(TARGET_BACKENDS, cpu, Engine.METAL).name == "eager" + ts = _six("cpu", torch.float32) + assert select(table, ts, force_portable=True) is table.base def test_ds_selection_ignores_integer_hkl(): @@ -158,11 +162,23 @@ def test_ds_selection_ignores_integer_hkl(): """ n = 4 f32 = lambda: torch.zeros(n, dtype=torch.float32) # noqa: E731 - hkl_int = torch.zeros(n, 3, dtype=torch.int32) - hkl_f32 = torch.zeros(n, 3, dtype=torch.float32) - args_int = [hkl_int, f32(), torch.zeros(n, 3), f32(), f32(), f32(), f32()] - args_f32 = [hkl_f32, f32(), torch.zeros(n, 3), f32(), f32(), f32(), f32()] + args_int = [torch.zeros(n, 3, dtype=torch.int32), f32(), torch.zeros(n, 3)] + [f32()] * 4 + args_f32 = [torch.zeros(n, 3, dtype=torch.float32), f32(), torch.zeros(n, 3)] + [f32()] * 4 assert ( - select(DS_BACKENDS, args_int, Engine.AUTO).name - == select(DS_BACKENDS, args_f32, Engine.AUTO).name + select(DS_BACKENDS, args_int).name == select(DS_BACKENDS, args_f32).name ) + + +def test_second_order_capability_is_declared_not_guessed(): + """``second_order`` is the single source for which kernels compose under ``create_graph``. + + ``test_second_order.py`` derives its skip list from this, so a kernel gaining or losing a + double-backward path cannot leave the test matrix stale. + """ + density = {b.name: b.second_order for b in DENSITY_BACKENDS.backends} + assert density == { + "cuda_triton": False, + "mps_metal": False, + "cpu_sphere": True, + "portable": True, + } diff --git a/tests/unit/utils/test_backends.py b/tests/unit/utils/test_backends.py index 1b0f5ea..833e8ec 100644 --- a/tests/unit/utils/test_backends.py +++ b/tests/unit/utils/test_backends.py @@ -1,17 +1,32 @@ """The dispatch resolver, tested against synthetic tables rather than real kernels. -Synthetic on purpose: this file is about the *policy* machinery -- engine admission, -device/dtype matching, probe ordering, failure handling -- and a synthetic table can -express cases no real host can reach (a CUDA row on a CPU-only machine, a probe that -raises if called at all). The real tables are checked where they live, against the kernels -they name. +Synthetic on purpose: this file is about the *policy* machinery -- device/dtype matching, +probe ordering, failure handling, the portable override -- and a synthetic table can express +cases no real host can reach (a CUDA row on a CPU-only machine, a probe that raises if called +at all). The shipping tables are checked where they live, against the kernels they name, in +``test_backend_tables.py``. + +The three ``use_portable`` tests at the top are what survived the deletion of +``test_triton_dispatch.py``. Of its nineteen tests, only its state-management ones had a +subject that outlived the ``Engine`` enum; everything else asserted behaviour of forcing an +*accelerator*, which is no longer a concept, or device/dtype rules now asserted directly +against the shipping tables. """ import pytest import torch -from torchref.utils.backends import Backend, BackendTable, run_or_degrade, select -from torchref.utils.triton_dispatch import Engine +from torchref.utils.backends import ( + Backend, + BackendTable, + TorchRefDegradationWarning, + force_portable, + run_or_degrade, + select, + set_force_portable, + use_portable, + will_use, +) pytestmark = pytest.mark.unit @@ -56,7 +71,6 @@ def _table( accel_probe=(_THIS, "probe_ok"), accel_on_failure="degrade", accel_device="cuda", - base_engines=(Engine.AUTO, Engine.EAGER, Engine.METAL), ): return BackendTable( name="synthetic", @@ -64,93 +78,135 @@ def _table( Backend( name="accel", kernel=(_THIS, "accel_iso", "accel_aniso"), - engines=frozenset({Engine.AUTO, Engine.TRITON}), device=accel_device, dtypes=(torch.float32,), probe=accel_probe, on_failure=accel_on_failure, second_order=False, ), - Backend( - name="base", - kernel=(_THIS, "base_iso", "base_aniso"), - engines=frozenset(base_engines), - ), + Backend(name="base", kernel=(_THIS, "base_iso", "base_aniso")), ), ) # --------------------------------------------------------------------------- -# Table invariants, asserted at construction +# The portable override # --------------------------------------------------------------------------- -def test_table_rejects_an_unhandled_engine(): - """A table that no backend claims for some engine is a construction error. - - This is the invariant the whole design is for. ``Engine.METAL`` selects the Metal - density splat and must mean "run eager" everywhere else; if a table forgets to let its - base case absorb it, calls that work today start raising. Catching it at import turns a - silent behavioural regression into a failure at the definition site. +def test_force_portable_roundtrip(): + previous = force_portable() + try: + set_force_portable(True) + assert force_portable() is True + set_force_portable(False) + assert force_portable() is False + finally: + set_force_portable(previous) + + +def test_use_portable_nests_and_restores(): + previous = force_portable() + try: + set_force_portable(False) + with use_portable(): + assert force_portable() is True + with use_portable(): + assert force_portable() is True + assert force_portable() is True + assert force_portable() is False + finally: + set_force_portable(previous) + + +def test_use_portable_restores_on_exception(): + """A raise inside the block must not leave dispatch pinned for the rest of the process. + + Worth its own test because the failure is invisible and contagious: every later call in + the process would silently run the reference kernel, and a benchmark would report a + regression with no obvious cause. + """ + previous = force_portable() + try: + set_force_portable(False) + with pytest.raises(ValueError): + with use_portable(): + raise ValueError("boom") + assert force_portable() is False + finally: + set_force_portable(previous) + + +def test_force_portable_selects_the_base_case_over_a_matching_accelerator(): + """The override's whole job: pin the reference even when something faster would run. + + This is the one thing automatic selection cannot do for you. A fallback covers an + accelerator that is missing or throws; it cannot cover one that runs and returns *wrong + numbers*, which is what you are checking for when you reach for this. """ - with pytest.raises(ValueError, match="no backend handles"): - _table(base_engines=(Engine.AUTO, Engine.EAGER)) # METAL unclaimed + t = _table(accel_device="cpu") + ts = [torch.zeros(2)] + assert select(t, ts).name == "accel" + assert select(t, ts, force_portable=True).name == "base" + with use_portable(): + assert select(t, ts).name == "base" +def test_per_call_argument_overrides_the_process_wide_setting(): + previous = force_portable() + try: + t = _table(accel_device="cpu") + ts = [torch.zeros(2)] + set_force_portable(True) + assert select(t, ts).name == "base" + assert select(t, ts, force_portable=False).name == "accel" + finally: + set_force_portable(previous) + + +# --------------------------------------------------------------------------- +# Table invariants, asserted at construction +# --------------------------------------------------------------------------- def test_table_requires_exactly_one_base_case(): + """Totality depends on it: with no unrestricted row, some inputs would match nothing. + + ``select`` has no error path, and that is structural rather than optimistic -- so the + condition that makes it true is checked where the table is defined. + """ with pytest.raises(ValueError, match="exactly one unrestricted base"): BackendTable( name="two-bases", backends=( - Backend("a", (_THIS, "base_iso", "base_aniso"), frozenset(Engine)), - Backend("b", (_THIS, "base_iso", "base_aniso"), frozenset(Engine)), + Backend("a", (_THIS, "base_iso", "base_aniso")), + Backend("b", (_THIS, "base_iso", "base_aniso")), ), ) def test_backend_rejects_an_unknown_failure_policy(): with pytest.raises(ValueError, match="on_failure"): - Backend( - "x", - (_THIS, "base_iso", "base_aniso"), - frozenset(Engine), - on_failure="explode", - ) + Backend("x", (_THIS, "base_iso", "base_aniso"), on_failure="explode") def test_backend_rejects_an_unknown_expectation(): with pytest.raises(ValueError, match="expect_available"): - Backend( - "x", - (_THIS, "base_iso", "base_aniso"), - frozenset(Engine), - expect_available="on tuesdays", - ) + Backend("x", (_THIS, "base_iso", "base_aniso"), expect_available="on tuesdays") def test_expectation_conditions_are_evaluated_against_the_host(): """``expect_available`` is a host predicate, not a static flag.""" - always = Backend( - "a", (_THIS, "base_iso", "base_aniso"), frozenset(Engine), - expect_available="always", + mk = lambda exp: Backend( # noqa: E731 + "b", (_THIS, "base_iso", "base_aniso"), expect_available=exp ) - never = Backend( - "n", (_THIS, "base_iso", "base_aniso"), frozenset(Engine), - expect_available="never", - ) - cuda = Backend( - "c", (_THIS, "base_iso", "base_aniso"), frozenset(Engine), - expect_available="cuda", - ) - assert always.expected_here() is True - assert never.expected_here() is False - assert cuda.expected_here() is torch.cuda.is_available() + assert mk("always").expected_here() is True + assert mk("never").expected_here() is False + assert mk("cuda").expected_here() is torch.cuda.is_available() def test_expectation_does_not_influence_selection(): """A backend that "should" work still loses to an honest probe saying it does not. - Keeping these separate is the point: ``expect_available`` is what CI asserts, - ``probe`` is what dispatch obeys. Conflating them would make a mis-declared - expectation route real work to a kernel that cannot run. + Keeping these separate is the point: ``expect_available`` is what CI asserts, ``probe`` + is what dispatch obeys. Conflating them would route real work to a kernel that cannot + run, on the strength of a mis-declared expectation. """ t = BackendTable( name="expectation-vs-fact", @@ -158,85 +214,61 @@ def test_expectation_does_not_influence_selection(): Backend( "accel", (_THIS, "accel_iso", "accel_aniso"), - frozenset({Engine.AUTO, Engine.TRITON}), device="cpu", probe=(_THIS, "probe_missing"), expect_available="always", ), - Backend("base", (_THIS, "base_iso", "base_aniso"), frozenset(Engine)), + Backend("base", (_THIS, "base_iso", "base_aniso")), ), ) assert t.by_name("accel").expected_here() is True - assert select(t, [torch.zeros(2)], Engine.AUTO).name == "base" + assert select(t, [torch.zeros(2)]).name == "base" # --------------------------------------------------------------------------- # Selection # --------------------------------------------------------------------------- -def test_auto_falls_through_to_the_base_case_on_a_wrong_device(): - t = _table() - cpu = torch.zeros(2) - assert select(t, [cpu], Engine.AUTO).name == "base" +def test_falls_through_to_the_base_case_on_a_wrong_device(): + assert select(_table(), [torch.zeros(2)]).name == "base" -def test_auto_selects_the_accelerator_when_everything_matches(): +def test_selects_the_accelerator_when_everything_matches(): """Device match is expressed through the table, not the host, so this runs anywhere.""" - t = _table(accel_device="cpu") - assert select(t, [torch.zeros(2)], Engine.AUTO).name == "accel" - - -def test_eager_reaches_the_base_case_even_when_the_accelerator_would_match(): - t = _table(accel_device="cpu") - assert select(t, [torch.zeros(2)], Engine.EAGER).name == "base" - - -def test_a_forcing_engine_raises_rather_than_degrading(): - """The base case does not list TRITON, so nothing absorbs a failed match.""" - t = _table() - with pytest.raises(RuntimeError, match="requires CUDA float32"): - select(t, [torch.zeros(2)], Engine.TRITON) - - -def test_forcing_engine_message_names_the_engine_and_the_requirement(): - t = _table() - with pytest.raises(RuntimeError) as exc: - select(t, [torch.zeros(2)], Engine.TRITON) - assert "engine=Engine.TRITON" in str(exc.value) - - -def test_unavailability_degrades_under_auto_and_raises_when_forced(): - t = _table(accel_probe=(_THIS, "probe_missing"), accel_device="cpu") - cpu = torch.zeros(2) - assert select(t, [cpu], Engine.AUTO).name == "base" - with pytest.raises(RuntimeError, match="the widget is not installed"): - select(t, [cpu], Engine.TRITON) + assert select(_table(accel_device="cpu"), [torch.zeros(2)]).name == "accel" -def test_non_engine_values_are_rejected_loudly(): - """An unrecognised engine must not fall through to a default. +def test_selection_never_fails(): + """There is no input for which nothing is selected. - The predicates this replaces had an explicit guard for the same reason: the AUTO case - was once an implicit ``else``, so any new or bogus value silently selected Triton. + The base case declares no device and no dtype and carries no probe, so it matches + unconditionally. Asserted against deliberately hostile inputs -- wrong device, wrong + dtype, an unavailable accelerator, and no tensors at all. """ + t = _table(accel_probe=(_THIS, "probe_missing")) + for ts in ( + [torch.zeros(2)], + [torch.zeros(2, dtype=torch.float64)], + [None, None], + [], + ): + assert select(t, ts).name == "base" - class NotAnEngine: - pass - with pytest.raises(ValueError, match="unhandled engine"): - select(_table(), [torch.zeros(2)], NotAnEngine()) +def test_unavailability_falls_through(): + t = _table(accel_probe=(_THIS, "probe_missing"), accel_device="cpu") + assert select(t, [torch.zeros(2)]).name == "base" def test_none_entries_are_ignored(): t = _table(accel_device="cpu") - assert select(t, [None, torch.zeros(2), None], Engine.AUTO).name == "accel" + assert select(t, [None, torch.zeros(2), None]).name == "accel" def test_probes_restrict_which_arguments_carry_the_contract(): """A tensor outside ``probes`` must not affect selection. - This is what lets direct summation police the refinable leaves while ignoring the - float32 stored scattering-factor table, which would otherwise decline the kernel - unconditionally. + This is what lets direct summation police the refinable leaves while ignoring ``hkl``, + whose dtype provably costs nothing. """ table = BackendTable( name="probed", @@ -244,19 +276,24 @@ def test_probes_restrict_which_arguments_carry_the_contract(): Backend( "accel", (_THIS, "accel_iso", "accel_aniso"), - frozenset({Engine.AUTO, Engine.TRITON}), device="cpu", dtypes=(torch.float32,), probes=(0,), ), - Backend("base", (_THIS, "base_iso", "base_aniso"), frozenset(Engine)), + Backend("base", (_THIS, "base_iso", "base_aniso")), ), ) f32, f64 = torch.zeros(2), torch.zeros(2, dtype=torch.float64) - # position 1 is float64 but unprobed - assert select(table, [f32, f64], Engine.AUTO).name == "accel" - # probing position 0 still bites - assert select(table, [f64, f32], Engine.AUTO).name == "base" + assert select(table, [f32, f64]).name == "accel" # position 1 unprobed + assert select(table, [f64, f32]).name == "base" # position 0 still bites + + +def test_will_use_names_the_selected_backend(): + t = _table(accel_device="cpu") + ts = [torch.zeros(2)] + assert will_use(t, "accel", ts) is True + assert will_use(t, "base", ts) is False + assert will_use(t, "base", ts, force_portable=True) is True # --------------------------------------------------------------------------- @@ -265,33 +302,29 @@ def test_probes_restrict_which_arguments_carry_the_contract(): def test_availability_is_not_probed_when_device_already_mismatches(): """Device/dtype must be rejected before the probe is consulted. - Enforced with a probe that raises if reached, because the consequences of getting the - order wrong are invisible to a return-value assertion: an MPS host would compile the - CPU C++ extension it never uses, and a forced engine would report a compile failure it - never got far enough to observe instead of the device mismatch. + Enforced with a probe that raises if reached, because getting the order wrong is + invisible to a return-value assertion: an MPS host would compile the CPU C++ extension it + never uses, and a CPU-only host would import Triton to answer a question about a CPU + tensor. """ t = _table(accel_probe=(_THIS, "probe_must_not_run")) - cpu = torch.zeros(2) - assert select(t, [cpu], Engine.AUTO).name == "base" - with pytest.raises(RuntimeError, match="requires CUDA float32"): - select(t, [cpu], Engine.TRITON) + assert select(t, [torch.zeros(2)]).name == "base" def test_a_broken_probe_import_is_a_reason_not_a_crash(): t = _table(accel_probe=("torchref.no.such.module", "nope"), accel_device="cpu") - assert select(t, [torch.zeros(2)], Engine.AUTO).name == "base" - with pytest.raises(RuntimeError, match="could not be imported"): - select(t, [torch.zeros(2)], Engine.TRITON) + assert select(t, [torch.zeros(2)]).name == "base" + assert "could not be imported" in t.by_name("accel").unavailable() # --------------------------------------------------------------------------- -# dtype uniformity +# dtype rules # --------------------------------------------------------------------------- def test_require_uniform_dtype_rejects_a_mixed_set_that_membership_would_admit(): """The distinction is memory safety, not taste. - ``dtypes=(f32, f64)`` read as membership admits a float64 map beside float32 atoms, - which the fused CPU kernel would reinterpret through a raw pointer of the map's type. + ``dtypes=(f32, f64)`` read as membership admits a float64 map beside float32 atoms, which + the fused CPU kernel would reinterpret through a raw pointer of the map's type. """ mixed = BackendTable( name="uniform", @@ -299,18 +332,17 @@ def test_require_uniform_dtype_rejects_a_mixed_set_that_membership_would_admit() Backend( "fused", (_THIS, "accel_iso", "accel_aniso"), - frozenset({Engine.AUTO}), device="cpu", dtypes=(torch.float32, torch.float64), require_uniform_dtype=True, ), - Backend("base", (_THIS, "base_iso", "base_aniso"), frozenset(Engine)), + Backend("base", (_THIS, "base_iso", "base_aniso")), ), ) f32, f64 = torch.zeros(2), torch.zeros(2, dtype=torch.float64) - assert select(mixed, [f32, f32], Engine.AUTO).name == "fused" - assert select(mixed, [f64, f64], Engine.AUTO).name == "fused" - assert select(mixed, [f64, f32], Engine.AUTO).name == "base" + assert select(mixed, [f32, f32]).name == "fused" + assert select(mixed, [f64, f64]).name == "fused" + assert select(mixed, [f64, f32]).name == "base" def test_integer_tensors_are_exempt_from_the_dtype_contract(): @@ -322,11 +354,9 @@ def test_integer_tensors_are_exempt_from_the_dtype_contract(): """ t = _table(accel_device="cpu") hkl_int = torch.zeros(4, 3, dtype=torch.int32) - f32 = torch.zeros(2) - assert select(t, [hkl_int, f32], Engine.AUTO).name == "accel" + assert select(t, [hkl_int, torch.zeros(2)]).name == "accel" # a float in the wrong precision still disqualifies - f64 = torch.zeros(2, dtype=torch.float64) - assert select(t, [hkl_int, f64], Engine.AUTO).name == "base" + assert select(t, [hkl_int, torch.zeros(2, dtype=torch.float64)]).name == "base" # --------------------------------------------------------------------------- @@ -343,35 +373,51 @@ def test_run_resolves_the_kernel_late_so_it_stays_patchable(monkeypatch): t = _table(accel_device="cpu") monkeypatch.setattr(self_mod, "accel_iso", lambda *a, **k: "patched") - assert run_or_degrade(t, t.by_name("accel"), False, engine=Engine.AUTO) == "patched" + assert run_or_degrade(t, t.by_name("accel"), False) == "patched" def test_run_picks_the_anisotropic_variant(): t = _table(accel_device="cpu") b = t.by_name("accel") - assert run_or_degrade(t, b, False, engine=Engine.AUTO) == "accel_iso" - assert run_or_degrade(t, b, True, engine=Engine.AUTO) == "accel_aniso" + assert run_or_degrade(t, b, False) == "accel_iso" + assert run_or_degrade(t, b, True) == "accel_aniso" -def test_degrade_backend_falls_back_under_auto(monkeypatch): - import tests.unit.utils.test_backends as self_mod +def test_gate_only_backend_cannot_be_run(): + """A row with ``kernel=None`` declares criteria for a call site that names its own kernel. + + The geometry targets are the case. Selecting such a row is fine; running it is a + programming error and says so, rather than failing with an opaque unpack. + """ + t = BackendTable( + name="gate-only", + backends=( + Backend("gate", kernel=None, device="cpu"), + Backend("base", kernel=(_THIS, "base_iso", "base_aniso")), + ), + ) + with pytest.raises(TypeError, match="gate-only"): + run_or_degrade(t, t.by_name("gate"), False) - t = _table(accel_device="cpu", accel_on_failure="degrade") - monkeypatch.setattr(self_mod, "accel_iso", boom) - assert run_or_degrade(t, t.by_name("accel"), False, engine=Engine.AUTO) == "base_iso" +def test_degrade_backend_falls_back_and_warns(monkeypatch): + """The fallback runs -- and says so. -def test_degrade_backend_still_raises_under_a_forcing_engine(monkeypatch): - """Forcing a kernel and silently getting another one would make a benchmark lie.""" + Asserted with ``pytest.warns`` because a silent fallback is the failure mode the warning + exists to prevent: both pytest configs promote ``TorchRefDegradationWarning`` to an error, + so a degradation anywhere else in the suite fails the run. This is the one place it is + expected, so it opts in. + """ import tests.unit.utils.test_backends as self_mod t = _table(accel_device="cpu", accel_on_failure="degrade") monkeypatch.setattr(self_mod, "accel_iso", boom) - with pytest.raises(RuntimeError, match="kernel exploded"): - run_or_degrade(t, t.by_name("accel"), False, engine=Engine.TRITON) + with pytest.warns(TorchRefDegradationWarning, match="kernel exploded"): + got = run_or_degrade(t, t.by_name("accel"), False) + assert got == "base_iso" -def test_raise_backend_does_not_fall_back_even_under_auto(monkeypatch): +def test_raise_backend_does_not_fall_back(monkeypatch): """A production kernel that built fine and then threw is a bug, not a capability miss. Degrading would convert a wrong-results bug into a large silent slowdown whose output @@ -382,7 +428,7 @@ def test_raise_backend_does_not_fall_back_even_under_auto(monkeypatch): t = _table(accel_device="cpu", accel_on_failure="raise") monkeypatch.setattr(self_mod, "accel_iso", boom) with pytest.raises(RuntimeError, match="kernel exploded"): - run_or_degrade(t, t.by_name("accel"), False, engine=Engine.AUTO) + run_or_degrade(t, t.by_name("accel"), False) def test_base_case_failure_always_propagates(monkeypatch): @@ -391,4 +437,4 @@ def test_base_case_failure_always_propagates(monkeypatch): t = _table() monkeypatch.setattr(self_mod, "base_iso", boom) with pytest.raises(RuntimeError, match="kernel exploded"): - run_or_degrade(t, t.base, False, engine=Engine.AUTO) + run_or_degrade(t, t.base, False) diff --git a/tests/unit/utils/test_triton_dispatch.py b/tests/unit/utils/test_triton_dispatch.py deleted file mode 100644 index 467f89c..0000000 --- a/tests/unit/utils/test_triton_dispatch.py +++ /dev/null @@ -1,223 +0,0 @@ -"""Unit tests for the shared Triton/eager dispatch engine (CPU-only).""" - -import pytest -import torch - -from torchref.utils import ( - Engine, - get_engine, - set_engine, - should_use_metal, - should_use_triton, - triton_available, - use_engine, -) - -pytestmark = pytest.mark.unit - - -def test_engine_roundtrip(): - prev = get_engine() - try: - set_engine(Engine.TRITON) - assert get_engine() is Engine.TRITON - set_engine(Engine.EAGER) - assert get_engine() is Engine.EAGER - finally: - set_engine(prev) - - -def test_use_engine_nests_and_restores(): - prev = get_engine() - try: - set_engine(Engine.AUTO) - with use_engine(Engine.EAGER): - assert get_engine() is Engine.EAGER - with use_engine(Engine.TRITON): - assert get_engine() is Engine.TRITON - assert get_engine() is Engine.EAGER - assert get_engine() is Engine.AUTO - finally: - set_engine(prev) - - -def test_use_engine_restores_on_exception(): - prev = get_engine() - try: - set_engine(Engine.AUTO) - with pytest.raises(ValueError): - with use_engine(Engine.EAGER): - raise ValueError("boom") - assert get_engine() is Engine.AUTO - finally: - set_engine(prev) - - -def test_eager_engine_never_uses_triton(): - cpu = torch.zeros(3) - assert should_use_triton(cpu, engine=Engine.EAGER) is False - # even with a (hypothetical) cuda fp32 tensor, EAGER forces eager - assert should_use_triton(None, engine=Engine.EAGER) is False - - -def test_auto_false_on_cpu_and_fp64(): - assert should_use_triton(torch.zeros(3, dtype=torch.float32), engine=Engine.AUTO) is False - assert should_use_triton(torch.zeros(3, dtype=torch.float64), engine=Engine.AUTO) is False - - -def test_triton_engine_raises_on_cpu_or_fp64(): - with pytest.raises(RuntimeError): - should_use_triton(torch.zeros(3, dtype=torch.float32), engine=Engine.TRITON) - with pytest.raises(RuntimeError): - should_use_triton(torch.zeros(3, dtype=torch.float64), engine=Engine.TRITON) - - -def test_none_tensors_are_ignored(): - # all-None probes: capability is vacuously satisfied; AUTO depends only on - # triton availability (False on a CPU-only box, True if triton importable). - assert should_use_triton(None, None, engine=Engine.AUTO) is triton_available() - - -def test_should_use_triton_reads_global_engine_by_default(): - prev = get_engine() - try: - set_engine(Engine.EAGER) - assert should_use_triton(torch.zeros(3)) is False - finally: - set_engine(prev) - - -# --------------------------------------------------------------------------- -# Engine.METAL / should_use_metal -# -# All CPU-safe, so they run on every host: the point is the *strict* contract, -# which is observable without an accelerator because "wrong device" is one of -# the conditions that must raise. -# --------------------------------------------------------------------------- - - -def test_metal_engine_roundtrip(): - prev = get_engine() - try: - set_engine(Engine.METAL) - assert get_engine() is Engine.METAL - finally: - set_engine(prev) - - -def test_should_use_triton_false_under_metal(): - """METAL must not leak into the Triton gate. - - Before the explicit-AUTO tail below, ``should_use_triton`` ended in an - implicit ``else``, so a new member fell through and selected Triton -- on a - CUDA host, ``Engine.METAL`` would have run the Triton kernel. - """ - assert should_use_triton(torch.zeros(3), engine=Engine.METAL) is False - - -def test_should_use_triton_rejects_unknown_engine(): - """An unhandled member must fail loudly rather than silently pick Triton.""" - - class _NotAnEngine: - pass - - with pytest.raises(ValueError, match="unhandled engine"): - should_use_triton(torch.zeros(3), engine=_NotAnEngine()) - - -def test_should_use_metal_rejects_unknown_engine(): - class _NotAnEngine: - pass - - with pytest.raises(ValueError, match="unhandled engine"): - should_use_metal(torch.zeros(3), engine=_NotAnEngine()) - - -@pytest.mark.parametrize("engine", [Engine.EAGER, Engine.TRITON]) -def test_should_use_metal_false_for_non_metal_engines(engine): - assert should_use_metal(torch.zeros(3), engine=engine) is False - - -def test_should_use_metal_false_on_cpu_under_auto(): - """AUTO never raises -- it just declines the Metal path off MPS.""" - assert should_use_metal(torch.zeros(3), engine=Engine.AUTO) is False - - -def test_should_use_metal_raises_on_cpu_under_metal(): - """Forcing METAL on a non-MPS tensor is an error, mirroring TRITON.""" - with pytest.raises(RuntimeError, match="requires MPS float32"): - should_use_metal(torch.zeros(3), engine=Engine.METAL) - - -def test_should_use_metal_raises_on_wrong_dtype_under_metal(): - """A forced engine must not silently downcast: float64 has no Metal kernel.""" - with pytest.raises(RuntimeError, match="requires MPS float32"): - should_use_metal(torch.zeros(3, dtype=torch.float64), engine=Engine.METAL) - - -def test_should_use_metal_skips_none_but_still_checks_real_tensors(): - """``None`` entries are optional inputs and are skipped, but a real tensor - beside them is still probed -- host-independent, since a CPU tensor is - wrong for Metal everywhere.""" - with pytest.raises(RuntimeError, match="requires MPS float32"): - should_use_metal(None, torch.zeros(3), None, engine=Engine.METAL) - - -# --------------------------------------------------------------------------- -# Characterization: facts that hold today and are not asserted anywhere else -# --------------------------------------------------------------------------- -# ``Engine.METAL`` selects the native Metal *density splat* and nothing else. Every -# other dispatch site is expected to run eager under it -- see the ``Engine`` docstring, -# which recommends scoping METAL with ``use_engine`` precisely because a process-wide -# METAL sends target math down the eager path. -# -# Today that fact exists only as ``if eng is Engine.EAGER or eng is Engine.METAL: -# return False`` inside ``should_use_triton``. Nothing asserts it, so a dispatch rework -# that treats "no backend claims this engine" as an error would turn -# ``with use_engine(Engine.METAL): loss = bond_target(xyz)`` from working into a -# RuntimeError, silently, in a documented usage pattern. These two tests are the guard. - - -def test_metal_engine_means_eager_at_non_density_sites(): - """``Engine.METAL`` must be *permissive* elsewhere, not an error. - - There are no Metal direct-summation or target kernels, so METAL at those sites means - "run eager", not "fail". Asserted through the shared gate and through the targets - wrapper, which is the entry point all twelve target math functions use. - """ - from torchref.base.targets._dispatch import use_triton - - cuda_like = torch.zeros(3) # CPU here; the engine short-circuits before device - assert should_use_triton(cuda_like, engine=Engine.METAL) is False - - with use_engine(Engine.METAL): - assert use_triton(cuda_like) is False - - -def test_metal_gate_does_not_probe_availability_on_a_wrong_device(monkeypatch): - """Device/dtype must be rejected *before* shader availability is consulted. - - Two things depend on the ordering. Under a forced engine the error has to name the - device mismatch (``requires MPS float32``) rather than a compile failure, and on a - non-Apple host nothing should pay the cost of the availability probe. The same - ordering is what keeps an MPS host from compiling the CPU C++ extension it will never - use. - - Asserted by making the probe *fail loudly if reached*, rather than by inspecting - ``sys.modules``: a module cannot be unimported, so an import check silently weakens to - a no-op as soon as any earlier test has loaded the package -- which in a full run is - always. - """ - from torchref.base.electron_density.kernels.mps import compile as mps_compile - - def must_not_be_called(): - raise AssertionError( - "availability was probed for a CPU tensor; the device check must " - "short-circuit first" - ) - - monkeypatch.setattr(mps_compile, "mps_kernels_available", must_not_be_called) - - assert should_use_metal(torch.zeros(3), engine=Engine.AUTO) is False - with pytest.raises(RuntimeError, match="requires MPS float32"): - should_use_metal(torch.zeros(3), engine=Engine.METAL) diff --git a/torchref/__init__.py b/torchref/__init__.py index 2ff89bb..1ac09fe 100644 --- a/torchref/__init__.py +++ b/torchref/__init__.py @@ -51,7 +51,7 @@ General utilities and debugging tools. """ -__version__ = "0.6.2" +__version__ = "0.6.3" import os diff --git a/torchref/base/direct_summation/__init__.py b/torchref/base/direct_summation/__init__.py index 3df4873..9993b8e 100644 --- a/torchref/base/direct_summation/__init__.py +++ b/torchref/base/direct_summation/__init__.py @@ -65,7 +65,7 @@ def compute_scattering_factors_batch( # Capability-based backend dispatch (Triton on CUDA+fp32, else checkpointed # eager). Keep ``triton_ds`` itself lazy (loaded inside dispatch) so a broken # Triton install never breaks ``import torchref``. -from .dispatch import Engine, ds_aniso, ds_iso +from .dispatch import ds_aniso, ds_iso __all__ = [ # Scattering factor batch helper @@ -82,7 +82,6 @@ def compute_scattering_factors_batch( "core_deformation", "multiplication_quasi_complex_tensor", # Dispatch - "Engine", "ds_iso", "ds_aniso", ] diff --git a/torchref/base/direct_summation/_backends.py b/torchref/base/direct_summation/_backends.py index da0e930..e77720b 100644 --- a/torchref/base/direct_summation/_backends.py +++ b/torchref/base/direct_summation/_backends.py @@ -5,12 +5,9 @@ Two things about this table are worth reading before changing it. -**There is no Metal direct-summation kernel.** ``Engine.METAL`` selects the Metal *density -splat* and nothing else, so at this site it has to mean "run eager" -- which is why -``checkpointed`` lists it. That was previously an early ``return False`` buried in a -predicate; here it is a declared fact, and the table refuses to be constructed if some -engine ends up handled by nothing. DS on MPS is therefore ``_checkpointed_*`` running -on-device, a real production path. +**There is no Metal direct-summation kernel.** The Metal shader is a *density splat* only, so +DS on MPS is ``_checkpointed_*`` running on-device -- a real production path, and one the +table states by simply having no MPS row. **The float32 requirement here is policy, not capability**, and the policy is mild. The Triton kernel casts every input to float32 itself (``triton_ds._cols_f32``), so it would @@ -42,7 +39,7 @@ import torch from torchref.utils.backends import Backend, BackendTable -from torchref.utils.triton_dispatch import Engine, triton_available +from torchref.utils.backends import triton_available _THIS = "torchref.base.direct_summation._backends" @@ -97,7 +94,6 @@ def _ds_aniso_triton(hkl, s_vec, xyz_frac, occ, U, A, B, max_memory_gb): Backend( name="ds_triton", kernel=(_THIS, "_ds_iso_triton", "_ds_aniso_triton"), - engines=frozenset({Engine.AUTO, Engine.TRITON}), device="cuda", dtypes=(torch.float32,), # Every argument except ``hkl`` (position 0), whose dtype provably costs @@ -117,7 +113,6 @@ def _ds_aniso_triton(hkl, s_vec, xyz_frac, occ, U, A, B, max_memory_gb): ), # METAL is here because there is no Metal DS kernel; see the module docstring. # TRITON is absent, which is what makes that engine strict. - engines=frozenset({Engine.AUTO, Engine.EAGER, Engine.METAL}), expect_available="always", on_failure="raise", # First-order only: the backward replays each chunk under ``enable_grad`` but diff --git a/torchref/base/direct_summation/dispatch.py b/torchref/base/direct_summation/dispatch.py index a1d51c3..d3005af 100644 --- a/torchref/base/direct_summation/dispatch.py +++ b/torchref/base/direct_summation/dispatch.py @@ -2,12 +2,12 @@ Which backend runs is read from :data:`torchref.base.direct_summation._backends.DS_BACKENDS` -- device, dtype, which -engines admit each path, how availability is probed, and what a failure means all live -there, so this module holds the maths and the entry points but no selection logic. The -mechanics are in :mod:`torchref.utils.backends`. +device and dtype, how availability is probed, and what a failure means all live there, so +this module holds the maths and the entry points but no selection logic. The mechanics are in +:mod:`torchref.utils.backends`. -An explicit ``Engine`` override (per-call ``engine=`` or the process-wide -``use_engine``/``set_engine``) forces a path for tests and benchmarks. +``force_portable`` (per call, or scoped with ``use_portable()``) pins the portable reference +path; otherwise the fastest usable backend is chosen automatically. All backends compute a **P1** structure-factor sum only. Crystallographic symmetry is applied outside, in :meth:`SfDS.compute_structure_factors`. @@ -29,7 +29,6 @@ aniso_structure_factor_torched, ) from torchref.utils.backends import run_or_degrade, select -from torchref.utils.triton_dispatch import Engine TWO_PI = 2.0 * math.pi NEG_TWO_PI_SQ = -2.0 * (math.pi**2) @@ -221,7 +220,7 @@ def _eager_aniso(hkl, s_vec, xyz_frac, occ, U, A, B, max_memory_gb): # --------------------------------------------------------------------------- # Dispatch entry points # --------------------------------------------------------------------------- -def _dispatch(aniso, hkl, geom, xyz_frac, occ, third, A, B, engine, max_memory_gb): +def _dispatch(aniso, hkl, geom, xyz_frac, occ, third, A, B, force_portable, max_memory_gb): """Select a backend from ``DS_BACKENDS`` and run it. Imported inside the call rather than at module scope: the table names this module as a @@ -230,35 +229,31 @@ def _dispatch(aniso, hkl, geom, xyz_frac, occ, third, A, B, engine, max_memory_g from torchref.base.direct_summation._backends import DS_BACKENDS args = (hkl, geom, xyz_frac, occ, third, A, B) - backend = select(DS_BACKENDS, args, engine) - return run_or_degrade( - DS_BACKENDS, backend, aniso, *args, max_memory_gb, engine=engine - ) + backend = select(DS_BACKENDS, args, force_portable=force_portable) + return run_or_degrade(DS_BACKENDS, backend, aniso, *args, max_memory_gb) -def ds_iso(hkl, s, xyz_frac, occ, adp, A, B, *, engine=None, max_memory_gb=None): +def ds_iso(hkl, s, xyz_frac, occ, adp, A, B, *, force_portable=None, max_memory_gb=None): """Isotropic P1 structure factors via the selected backend. - ``engine=None`` means *defer to the process-wide engine*, which is what makes - ``with use_engine(Engine.EAGER): ...`` actually reach this call. The default used to be - ``Engine.AUTO``, and because an explicit engine argument suppresses the global, that - silently made the documented escape hatch inert here: a forced-eager block still ran - Triton on a CUDA host. + ``force_portable=None`` defers to the process-wide setting, so + ``with use_portable(): ...`` reaches this call. Passing ``True`` pins the checkpointed + reference path regardless. """ if xyz_frac.shape[0] == 0: return torch.zeros(hkl.shape[0], dtype=torch.complex64, device=hkl.device) return _dispatch( - False, hkl, s, xyz_frac, occ, adp, A, B, engine, max_memory_gb + False, hkl, s, xyz_frac, occ, adp, A, B, force_portable, max_memory_gb ) -def ds_aniso(hkl, s_vec, xyz_frac, occ, U, A, B, *, engine=None, max_memory_gb=None): +def ds_aniso(hkl, s_vec, xyz_frac, occ, U, A, B, *, force_portable=None, max_memory_gb=None): """Anisotropic P1 structure factors via the selected backend. - See :func:`ds_iso` on ``engine=None``. + See :func:`ds_iso` on ``force_portable=None``. """ if xyz_frac.shape[0] == 0: return torch.zeros(hkl.shape[0], dtype=torch.complex64, device=hkl.device) return _dispatch( - True, hkl, s_vec, xyz_frac, occ, U, A, B, engine, max_memory_gb + True, hkl, s_vec, xyz_frac, occ, U, A, B, force_portable, max_memory_gb ) diff --git a/torchref/base/electron_density/__init__.py b/torchref/base/electron_density/__init__.py index bc23b98..ddd3a62 100644 --- a/torchref/base/electron_density/__init__.py +++ b/torchref/base/electron_density/__init__.py @@ -8,7 +8,7 @@ - Scatter operations for map building The headline entry point is :func:`build_electron_density` (from ``main``): the -central, ``Engine``-dispatched per-atom variable-radius density builder that +central, table-dispatched per-atom variable-radius density builder that takes atomic parameters and returns the full density map. """ diff --git a/torchref/base/electron_density/_backends.py b/torchref/base/electron_density/_backends.py index 52e94a8..eed753e 100644 --- a/torchref/base/electron_density/_backends.py +++ b/torchref/base/electron_density/_backends.py @@ -1,9 +1,9 @@ """The electron-density splat dispatch policy, as one table. Every criterion for choosing a density kernel is a field in a row below: which device, -which dtypes, which engines admit it, how availability is probed, whether a runtime failure +which dtypes, how availability is probed, whether a runtime failure may degrade, and whether the kernel composes to second order. Reading this file answers -"which kernel runs for MPS + float64 + ``Engine.AUTO``?" without tracing an if/elif ladder +"which kernel runs for MPS + float64?" without tracing an if/elif ladder through three modules. All four wrappers share one signature -- @@ -12,7 +12,7 @@ Ordering within the table is not load-bearing. The three non-base backends are pairwise device-disjoint (CUDA / CPU / MPS), so at most one can ever match; the base case matches -everything and is what ``AUTO`` falls through to. +everything and is what selection falls through to. See :mod:`torchref.utils.backends` for what each field means and why it exists. """ @@ -22,7 +22,6 @@ import torch from torchref.utils.backends import Backend, BackendTable -from torchref.utils.triton_dispatch import Engine _CUDA = "torchref.base.electron_density.kernels.cuda.variable_radius" _MPS = "torchref.base.electron_density.kernels.mps.variable_radius" @@ -43,7 +42,6 @@ Backend( name="cuda_triton", kernel=(_CUDA, "add_isotropic_cuda_var", "add_anisotropic_cuda_var"), - engines=frozenset({Engine.AUTO, Engine.TRITON}), device="cuda", dtypes=(torch.float32,), probes=_ATOM_ARGS, @@ -51,14 +49,13 @@ expect_available="cuda", # A failed launch on an available GPU is a capability miss worth degrading # from; the kernel clones the density map before accumulating, so the - # fallback cannot double-count. Under a forcing engine it still raises. + # fallback cannot double-count. on_failure="degrade", second_order=False, ), Backend( name="mps_metal", kernel=(_MPS, "add_isotropic_mps_var", "add_anisotropic_mps_var"), - engines=frozenset({Engine.AUTO, Engine.METAL}), device="mps", dtypes=(torch.float32,), probes=_ATOM_ARGS, @@ -72,9 +69,6 @@ name="cpu_sphere", kernel=(_SPHERE, "add_isotropic_cpu_sphere_var", "add_anisotropic_cpu_sphere_var"), - # AUTO only. There is no Engine member that forces the fused CPU splat, which - # is why proving it ran needs a call recorder rather than a strict engine. - engines=frozenset({Engine.AUTO}), device="cpu", dtypes=(torch.float32, torch.float64), # Uniformity, not membership: the kernel picks one ``scalar_t`` from the output @@ -97,9 +91,8 @@ Backend( name="portable", kernel=(_PORTABLE, "add_isotropic_plain_var", "add_anisotropic_plain_var"), - # The base case, and the reason forcing works: TRITON and METAL are absent - # here, so nothing absorbs them and a forced engine that cannot run raises. - engines=frozenset({Engine.AUTO, Engine.EAGER}), + # The base case: no device or dtype restriction, so it always matches. That is + # what makes ``select`` unable to fail. expect_available="always", on_failure="raise", second_order=True, diff --git a/torchref/base/electron_density/kernels/cpu/jit_reference.py b/torchref/base/electron_density/kernels/cpu/jit_reference.py index f96b9a8..5de7259 100644 --- a/torchref/base/electron_density/kernels/cpu/jit_reference.py +++ b/torchref/base/electron_density/kernels/cpu/jit_reference.py @@ -8,7 +8,7 @@ Architecture: - CPU: JIT-scripted kernel using einsum with metric tensor (efficient for CPU) -- GPU: when the shared ``Engine`` permits Triton (CUDA + float32, engine +- GPU: when the shared targets gate permits Triton (CUDA + float32, dispatch AUTO/TRITON), ``vectorized_add_to_map`` selects the Triton fused branch (``fused_add_to_map_gpu``) first; otherwise it falls back to the pure-torch, double-differentiable ``_add_to_map_gpu_simple`` (JIT-scripted batch matmul). @@ -29,7 +29,7 @@ import os import torch -from torchref.utils.triton_dispatch import should_use_triton +from torchref.base.targets._dispatch import use_triton # ============================================================================= # Cache directory for JIT kernels @@ -379,10 +379,10 @@ def vectorized_add_to_map( """ Add atoms to density map using ITC92 Gaussian parameterization. - Backend is chosen by the shared ``Engine`` via ``should_use_triton``: on + Backend is chosen by the shared targets gate via ``use_triton``: on GPU it uses the Triton fused kernel when Triton is permitted (CUDA+float32, engine AUTO/TRITON) and the pure-torch ``_add_to_map_gpu_simple`` otherwise - (Engine.EAGER, float64, or Triton unavailable). CPU uses the JIT kernel. + (force_portable, float64, or Triton unavailable). CPU uses the JIT kernel. Parameters ---------- @@ -417,11 +417,11 @@ def vectorized_add_to_map( input unchanged. Callers must always use the returned value. """ if density_map.device.type == "cuda": - # The shared Engine is the only switch: use the Triton kernel when it - # permits (CUDA + float32, engine AUTO/TRITON); otherwise — Engine.EAGER, + # The shared targets gate is the only switch: use the Triton kernel when it + # permits (CUDA + float32); otherwise — force_portable, # float64, or Triton unavailable — the pure-torch, double-differentiable # ``_add_to_map_gpu_simple``. - if should_use_triton(xyz): + if use_triton(xyz): triton_fn = _get_triton_kernel() if triton_fn is not None: return triton_fn( @@ -489,7 +489,7 @@ def build_electron_density( This alias takes the *voxel-level* signature (precomputed ``surrounding_coords`` / ``voxel_indices``) and is distinct from the top-level :func:`torchref.base.electron_density.main.build_electron_density`, - which takes *atomic* parameters and performs the full ``Engine``-based + which takes *atomic* parameters and performs the full table-based variable-radius dispatch. Parameters diff --git a/torchref/base/electron_density/kernels/cpu/sphere_splat.py b/torchref/base/electron_density/kernels/cpu/sphere_splat.py index 75b5ab6..e54dbe5 100644 --- a/torchref/base/electron_density/kernels/cpu/sphere_splat.py +++ b/torchref/base/electron_density/kernels/cpu/sphere_splat.py @@ -45,7 +45,7 @@ Gradients flow to ``xyz``, ``adp``/``u`` and ``occ``, with identity to the incoming ``density_map``; ``A``/``B`` and the cell matrices get none -- the same set as the CUDA and Metal kernels. Backward is first-order only; double backward must use -``Engine.EAGER`` (the plain splat in ``variable_radius.py``). +``force_portable`` (the plain splat in ``variable_radius.py``). Unlike the CUDA and Metal entry points this kernel takes no per-Gaussian ``coeff_mask``: that argument is all-ones at every existing call site, so it is diff --git a/torchref/base/electron_density/kernels/cpu/variable_radius.py b/torchref/base/electron_density/kernels/cpu/variable_radius.py index f717df7..595dea1 100644 --- a/torchref/base/electron_density/kernels/cpu/variable_radius.py +++ b/torchref/base/electron_density/kernels/cpu/variable_radius.py @@ -1,6 +1,6 @@ """Portable per-atom variable-radius density splatting. -Reached by ``Engine.EAGER`` on any device, by CUDA/MPS float64, and whenever the fused +Reached by ``force_portable`` on any device, by CUDA/MPS float64, and whenever the fused C++ kernel could not be built. Plain ``scatter_add`` only, so it runs on every device, supports float64, and is double-differentiable -- which makes it the reference the accelerator kernels are checked against. @@ -70,7 +70,7 @@ def _axis_half_widths(r: float, inv_frac: torch.Tensor, grid_dims): """``ceil(r * n_axis * ||inv_frac row_axis||)`` -- the kernels' enumeration box. The norm is taken in float64 **on the CPU**, never on the input's device: this path - also serves ``Engine.EAGER`` on MPS, which has no float64, and ``.double()`` in place + also serves ``force_portable`` on MPS, which has no float64, and ``.double()`` in place raises there. Hopping a 3x3 matrix to the CPU is free, and float64 matters because the result feeds a ``ceil`` -- a value landing a hair under an integer in float32 would shrink the box by one voxel and silently clip the sphere. diff --git a/torchref/base/electron_density/kernels/mps/__init__.py b/torchref/base/electron_density/kernels/mps/__init__.py index 9e58376..0ffdc5d 100644 --- a/torchref/base/electron_density/kernels/mps/__init__.py +++ b/torchref/base/electron_density/kernels/mps/__init__.py @@ -4,7 +4,7 @@ the density splat on Apple-silicon GPUs, replacing the portable eager splat that dominates fcalc time on MPS. Selection goes through ``torchref.utils.should_use_metal``, which gates on MPS + float32 + a compiled -shader; ``Engine.METAL`` forces the path and raises rather than degrading. +shader; a runtime failure degrades to the portable splat and warns. Every other platform is unaffected. """ diff --git a/torchref/base/electron_density/kernels/mps/compile.py b/torchref/base/electron_density/kernels/mps/compile.py index cc0e702..3e13d8f 100644 --- a/torchref/base/electron_density/kernels/mps/compile.py +++ b/torchref/base/electron_density/kernels/mps/compile.py @@ -7,8 +7,8 @@ ``mps_kernels_available()`` is what ``torchref.utils.should_use_metal`` consults; it returns False whenever MPS is absent, ``compile_shader`` is missing -(torch < 2.9), or the shader fails to build. Under ``Engine.AUTO`` the caller -then falls back to the portable plain splat; under ``Engine.METAL`` it raises +(torch < 2.9), or the shader fails to build. The caller then falls back to the +portable plain splat and warns; ``why_unavailable()`` reports instead, quoting :func:`last_error`. """ @@ -70,7 +70,7 @@ def why_unavailable() -> Optional[str]: The single availability probe for this backend -- the shape every backend implements, consumed by :mod:`torchref.utils.backends`. It returns the *reason* rather than a bool - because a forced ``Engine.METAL`` has to explain itself, and "torch has no + because the availability test has to explain itself, and "torch has no ``compile_shader``" and "the MSL failed to compile" are different problems with different fixes. """ diff --git a/torchref/base/electron_density/kernels/mps/variable_radius.py b/torchref/base/electron_density/kernels/mps/variable_radius.py index eecd9a1..be5d3f8 100644 --- a/torchref/base/electron_density/kernels/mps/variable_radius.py +++ b/torchref/base/electron_density/kernels/mps/variable_radius.py @@ -10,7 +10,7 @@ Gradients flow to ``xyz``, ``adp``/``u``, and ``occ`` (and identity to the input ``density_map``); ``A``/``B`` and the cell matrices receive no gradient -- the same set as the CUDA kernels. Backward is first-order only (like CUDA); double -backward must use ``Engine.EAGER`` (the plain splat). +backward must use ``force_portable`` (the plain splat). """ from __future__ import annotations diff --git a/torchref/base/electron_density/main.py b/torchref/base/electron_density/main.py index c13831d..c47ba21 100644 --- a/torchref/base/electron_density/main.py +++ b/torchref/base/electron_density/main.py @@ -1,5 +1,5 @@ """ -Central electron density building, dispatched solely by the shared ``Engine``. +Central electron density building, dispatched from one declarative table. One truncation contract, every backend ------------------------------------- @@ -20,22 +20,22 @@ splat used a node-centred diagonal metric, and the CPU fast path splatted a cube -- by amounts comparable to or larger than the truncation error itself. -Engine dispatch ---------------- +Backend dispatch +---------------- Which kernel runs is decided from a table, not from an if/elif ladder: see :data:`torchref.base.electron_density._backends.DENSITY_BACKENDS`. That table is the only -place the criteria are written down -- device, dtype, which engines admit each backend, how -availability is probed, and whether a runtime failure may degrade -- so it is also the only -place to look, or to edit when adding a backend. The mechanics of reading it live in -:mod:`torchref.utils.backends`. - -The capability-based ``Engine`` (AUTO/TRITON/METAL/EAGER) is the *only* switch: no -environment-variable dispatch, no parallel "tier" knobs. ``AUTO`` picks the fastest -available path per device and degrades quietly if an accelerator kernel is missing or -throws; ``EAGER`` pins the portable splat everywhere, which is the double-differentiable -route to use for Hessians; ``TRITON`` and ``METAL`` force their kernel and raise rather than -degrade, so a benchmark or an A/B comparison cannot silently measure something else. Pass -``engine=`` per call, or scope it with ``with use_engine(...)``. +place the criteria are written down -- device, dtype, how availability is probed, and whether +a runtime failure may degrade -- so it is also the only place to look, or to edit when adding +a backend. The mechanics of reading it live in :mod:`torchref.utils.backends`. + +Selection needs no configuration: the fastest kernel that can run for the given device and +dtype is chosen, and an accelerator that is missing or throws degrades to the portable splat +(with a warning). There is no environment-variable dispatch and no "tier" knobs. + +The one override is ``force_portable``, which pins the portable reference splat. Pass it per +call or scope it with ``with use_portable(): ...``. Its purpose is the single failure that +automatic fallback cannot detect -- an accelerator kernel that runs and returns *wrong +numbers* rather than raising. The production splats live in ``kernels/cuda/variable_radius.py``, ``kernels/cpu/sphere_splat.py``, ``kernels/mps/variable_radius.py`` and (portable) @@ -49,7 +49,6 @@ from torchref.config import get_float_dtype, get_sigma_cutoff_ed from torchref.utils.backends import run_or_degrade, select -from torchref.utils.triton_dispatch import Engine from torchref.base.electron_density._backends import DENSITY_BACKENDS @@ -78,7 +77,7 @@ def build_electron_density( A_aniso: Optional[torch.Tensor] = None, B_aniso: Optional[torch.Tensor] = None, dtype: torch.dtype = None, - engine: Optional[Engine] = None, + force_portable: Optional[bool] = None, ) -> torch.Tensor: """ Build an electron density map from atomic parameters. @@ -120,11 +119,11 @@ def build_electron_density( dtype : torch.dtype, optional Float dtype for the density map. Defaults to the configured float dtype (``get_float_dtype()``), which may be float64. - engine : Engine, optional - Per-call backend override; defaults to the process-wide engine. Prefer this over - ``set_engine`` when you only mean to steer the density splat -- a process-wide - ``Engine.METAL`` also sends every target math function down the eager path, which - would skew a benchmark. + force_portable : bool, optional + Pin the portable reference splat instead of the fastest available kernel. ``None`` + defers to the process-wide setting (see ``torchref.utils.use_portable``). Use it to + check whether an accelerator kernel is producing wrong numbers -- the one failure + automatic fallback cannot detect. Returns ------- @@ -151,7 +150,7 @@ def build_electron_density( B_iso, inv_frac_matrix, frac_matrix, - engine=engine, + force_portable=force_portable, ) # --- anisotropic atoms --- @@ -165,7 +164,7 @@ def build_electron_density( B_aniso, inv_frac_matrix, frac_matrix, - engine=engine, + force_portable=force_portable, ) return density_map @@ -185,7 +184,7 @@ def _add_isotropic( B, inv_frac_matrix, frac_matrix, - engine=None, + force_portable=None, ): """Add isotropic atoms with a per-atom variable radius. @@ -202,8 +201,8 @@ def _add_isotropic( radius_per_atom = per_atom_radius_iso(adp, B, n_sigma=get_sigma_cutoff_ed()) args = (density_map, xyz, adp, occ, A, B, inv_frac_matrix, frac_matrix, radius_per_atom) - backend = select(DENSITY_BACKENDS, args, engine) - return run_or_degrade(DENSITY_BACKENDS, backend, False, *args, engine=engine) + backend = select(DENSITY_BACKENDS, args, force_portable=force_portable) + return run_or_degrade(DENSITY_BACKENDS, backend, False, *args) def _add_anisotropic( @@ -215,7 +214,7 @@ def _add_anisotropic( B, inv_frac_matrix, frac_matrix, - engine=None, + force_portable=None, ): """Add anisotropic atoms with a per-atom variable radius (mirrors the iso path). @@ -230,5 +229,5 @@ def _add_anisotropic( radius_per_atom = per_atom_radius_aniso(B, u, n_sigma=get_sigma_cutoff_ed()) args = (density_map, xyz, u, occ, A, B, inv_frac_matrix, frac_matrix, radius_per_atom) - backend = select(DENSITY_BACKENDS, args, engine) - return run_or_degrade(DENSITY_BACKENDS, backend, True, *args, engine=engine) + backend = select(DENSITY_BACKENDS, args, force_portable=force_portable) + return run_or_degrade(DENSITY_BACKENDS, backend, True, *args) diff --git a/torchref/base/targets/_dispatch.py b/torchref/base/targets/_dispatch.py index b3dcd50..4a925eb 100644 --- a/torchref/base/targets/_dispatch.py +++ b/torchref/base/targets/_dispatch.py @@ -6,9 +6,8 @@ without a usable Triton fall back to the plain eager implementation. The criteria live in :data:`TARGET_BACKENDS` rather than in a predicate body, for the same -reason as the density and direct-summation tables: so ``Engine.METAL`` meaning "run eager -here" is a declared fact rather than an early ``return False``, and so a new ``Engine`` -member cannot be added without something claiming it. +reason as the density and direct-summation tables: device, dtype and availability stated once, +as data, next to the kernels they select. This is a **gate-only** table -- the twelve call sites each do their own ``from .triton. import `` three lines from the ``if``, which is more legible than a @@ -17,8 +16,8 @@ To force the eager path for an A/B comparison or to sidestep a flaky Triton install:: - from torchref.utils import use_engine, Engine - with use_engine(Engine.EAGER): + from torchref.utils import use_portable + with use_portable(): ... """ @@ -26,8 +25,7 @@ import torch -from torchref.utils.backends import Backend, BackendTable, admits -from torchref.utils.triton_dispatch import Engine, triton_available +from torchref.utils.backends import Backend, BackendTable, triton_available, will_use _THIS = "torchref.base.targets._dispatch" @@ -67,7 +65,6 @@ def why_unavailable() -> Optional[str]: Backend( name="triton", kernel=None, # gate-only; see the module docstring - engines=frozenset({Engine.AUTO, Engine.TRITON}), device="cuda", dtypes=(torch.float32,), probe=(_THIS, "why_unavailable"), @@ -84,7 +81,6 @@ def why_unavailable() -> Optional[str]: kernel=None, # METAL is here because there are no Metal target kernels: at this site it has # to mean "run eager". TRITON is absent, which is what makes it strict. - engines=frozenset({Engine.AUTO, Engine.EAGER, Engine.METAL}), expect_available="always", on_failure="raise", second_order=True, @@ -96,8 +92,8 @@ def why_unavailable() -> Optional[str]: def use_triton(*tensors: torch.Tensor) -> bool: """Decide whether to route a call to the Triton kernel. - Asks the ``triton`` row of :data:`TARGET_BACKENDS` whether it would run, using the - process-wide engine. ``None`` entries among ``tensors`` are ignored, so a caller may - pass optional inputs straight through. + Asks the ``triton`` row of :data:`TARGET_BACKENDS` whether it is the backend that would + actually be selected. ``None`` entries among ``tensors`` are ignored, so a caller may pass + optional inputs straight through. """ - return admits(TARGET_BACKENDS, "triton", tensors) + return will_use(TARGET_BACKENDS, "triton", tensors) diff --git a/torchref/model/sf_ds.py b/torchref/model/sf_ds.py index 6b23627..eacbdaf 100644 --- a/torchref/model/sf_ds.py +++ b/torchref/model/sf_ds.py @@ -16,7 +16,6 @@ import torch.nn as nn from torchref.base.direct_summation import ( - Engine, ds_aniso, ds_iso, ) @@ -101,7 +100,7 @@ def __init__( device: torch.device = None, verbose: int = 0, max_memory_gb: float = 2.0, - engine: Optional[Engine] = None, + force_portable: Optional[bool] = None, ): """ Initialize the SfDS module with cell and spacegroup. @@ -120,16 +119,14 @@ def __init__( Verbosity level for logging. Default is 0. max_memory_gb : float, optional Maximum memory for intermediate tensors in GB. Default is 2.0. - engine : Engine, optional - Structure-factor backend selector, applied per call so two instances can - differ within one process. ``None`` (default) defers to the process-wide - engine, so ``with use_engine(...)`` steers an unconfigured instance; pass an - explicit ``Engine`` to override that. - - The default was ``Engine.AUTO``, which read as harmless but was not: an - explicit engine argument suppresses the global, so a - ``with use_engine(Engine.EAGER)`` block never reached direct summation and - still ran Triton on a CUDA host. + force_portable : bool, optional + Pin the portable reference path instead of the fastest usable backend, applied + per call so two instances can differ within one process. ``None`` (default) + defers to the process-wide setting, so ``with use_portable():`` steers an + unconfigured instance. + + ``SfFFT`` deliberately has no equivalent -- it never had a backend selector, and + ``use_portable()`` covers the same need by scoping the call. """ super().__init__() if dtype_float is None: @@ -141,7 +138,7 @@ def __init__( self.device = resolve_device(cell, device=device) self.verbose = verbose self.max_memory_gb = max_memory_gb - self.engine = engine + self.force_portable = force_portable # Store cell and spacegroup self._cell = cell @@ -536,7 +533,7 @@ def _compute_p1_sf( adp_iso, A_iso, B_iso, - engine=self.engine, + force_portable=self.force_portable, max_memory_gb=self.max_memory_gb, ) sf_total = sf_total + sf_iso.to(sf_total.dtype) @@ -551,7 +548,7 @@ def _compute_p1_sf( u_aniso, A_aniso, B_aniso, - engine=self.engine, + force_portable=self.force_portable, max_memory_gb=self.max_memory_gb, ) sf_total = sf_total + sf_aniso.to(sf_total.dtype) @@ -590,7 +587,7 @@ def copy(self) -> "SfDS": device=self.device, verbose=self.verbose, max_memory_gb=self.max_memory_gb, - engine=self.engine, + force_portable=self.force_portable, ) return new_ds diff --git a/torchref/restraints/hydrogen_topology.py b/torchref/restraints/hydrogen_topology.py index a2cbc02..ecd6f0d 100644 --- a/torchref/restraints/hydrogen_topology.py +++ b/torchref/restraints/hydrogen_topology.py @@ -664,8 +664,8 @@ def place_riding_hydrogens( # helper otherwise. # # Gated by the shared ``use_triton`` rather than a hand-rolled - # ``is_cuda and dtype == float32``. The inline check ignored the ``Engine`` entirely, so - # ``with use_engine(Engine.EAGER): ...`` still ran the Triton kernel here -- and since + # ``is_cuda and dtype == float32``. The inline check ignored the shared gate entirely, so + # a block that pinned the portable path still ran the Triton kernel here -- and since # EAGER is the documented double-differentiable route, a Hessian taken through hydrogen # placement was silently going through a first-order-only kernel. if use_triton(xyz_heavy): diff --git a/torchref/utils/__init__.py b/torchref/utils/__init__.py index 184d2da..10e0bd9 100644 --- a/torchref/utils/__init__.py +++ b/torchref/utils/__init__.py @@ -11,7 +11,7 @@ - Loss-finiteness validation (validate_loss) - Autograd introspection (collect_loss_leaves) - JSON serialization helpers (convert_to_serializable) -- Triton/eager backend dispatch (Engine) +- Backend dispatch (force_portable / use_portable) The names re-exported here are the package's public surface (see ``__all__``). Some submodule-only helpers (e.g. @@ -71,15 +71,12 @@ class MyRefinement(DebugMixin): # Serialization from .serialization import convert_to_serializable -# Triton/eager backend dispatch -from .triton_dispatch import ( - Engine, - get_engine, - set_engine, - should_use_metal, - should_use_triton, +# Backend dispatch +from .backends import ( + force_portable, + set_force_portable, triton_available, - use_engine, + use_portable, ) # Core utilities @@ -126,12 +123,9 @@ class MyRefinement(DebugMixin): "reset_diagnostic_budget", # Autograd introspection "collect_loss_leaves", - # Triton/eager backend dispatch - "Engine", - "get_engine", - "set_engine", - "use_engine", + # Backend dispatch + "force_portable", + "set_force_portable", + "use_portable", "triton_available", - "should_use_triton", - "should_use_metal", ] diff --git a/torchref/utils/backends.py b/torchref/utils/backends.py index 544e08d..559ab68 100644 --- a/torchref/utils/backends.py +++ b/torchref/utils/backends.py @@ -1,9 +1,9 @@ """Declarative backend selection: the dispatch criteria, in one place, as data. -Every dispatch site in TorchRef answers the same question -- given these tensors and this -:class:`~torchref.utils.triton_dispatch.Engine`, which kernel runs? This module holds the -machinery for answering it from a table, so each criterion (device, dtype, engine, -availability, failure policy) is written down exactly once, next to the kernels it selects. +Every dispatch site in TorchRef answers the same question -- given these tensors, which +kernel runs? This module holds the machinery for answering it from a table, so each criterion +(device, dtype, availability, failure policy) is written down exactly once, next to the +kernels it selects. The tables themselves live with their kernels: see ``torchref/base/electron_density/_backends.py`` and ``torchref/base/direct_summation/_backends.py``. @@ -16,14 +16,17 @@ table's base case. An earlier design walked a chain; that models a control structure the problem does not have. -Strictness is one boolean -------------------------- -A forcing engine (``TRITON``, ``METAL``) is admitted by exactly one row, so if that row -does not match, nothing does and :func:`select` raises with the reason. The base case -deliberately does **not** list the forcing engines in its ``engines`` set -- that omission -is the whole mechanism by which forcing is strict. Failure handling follows the same -principle: under ``AUTO`` a backend marked ``on_failure="degrade"`` falls back, under any -forcing engine everything propagates (see :func:`run_or_degrade`). +No configuration +---------------- +Selection takes no mode, tier or engine: for a given device and dtype there is exactly one +fastest usable kernel, so the answer is *derived*. The single override is ``force_portable``, +which pins the reference implementation -- see :func:`use_portable`. It exists for the one +failure automatic fallback cannot detect: an accelerator that runs and returns wrong numbers +rather than raising. + +This replaced a four-member selector enum whose two *forcing* members could never reach a +kernel the default would not already have picked -- they could only refuse when one was +unusable, which is information, not capability. Why kernels are stored as ``(module, attr)`` rather than as functions -------------------------------------------------------------------- @@ -43,15 +46,99 @@ from __future__ import annotations +import contextlib +import warnings from dataclasses import dataclass, field from importlib import import_module -from typing import Callable, Optional, Sequence, Tuple +from typing import Callable, Iterator, Optional, Sequence, Tuple import torch -from torchref.utils.triton_dispatch import Engine, get_engine -__all__ = ["Backend", "BackendTable", "admits", "select", "run_or_degrade"] +__all__ = [ + "Backend", + "BackendTable", + "TorchRefDegradationWarning", + "force_portable", + "run_or_degrade", + "select", + "set_force_portable", + "use_portable", + "will_use", +] + + +# --------------------------------------------------------------------------- +# Forcing the portable reference kernel +# --------------------------------------------------------------------------- +# Named after the table row it selects (``portable``), so the flag and the backend cannot +# drift apart in the reader's head. +# +# One boolean is the whole override surface. Selection already picks the fastest backend that +# can run, and forcing an *accelerator* could never reach a kernel the default would not have +# picked -- it could only refuse when the kernel was unusable. What is genuinely useful is the +# other direction: pin the portable reference when you suspect an accelerator is producing +# wrong numbers, which no automatic fallback can detect. +_FORCE_PORTABLE: bool = False + + +def force_portable() -> bool: + """Whether dispatch is currently pinned to the portable reference kernel.""" + return _FORCE_PORTABLE + + +def set_force_portable(value: bool) -> None: + """Pin (or unpin) dispatch to the portable reference kernel, process-wide.""" + global _FORCE_PORTABLE + _FORCE_PORTABLE = bool(value) + + +@contextlib.contextmanager +def use_portable() -> Iterator[None]: + """Pin the portable reference kernel for the duration of the block. + + Restores the previous setting on exit, including on exception. + """ + previous = force_portable() + set_force_portable(True) + try: + yield + finally: + set_force_portable(previous) + + +class TorchRefDegradationWarning(UserWarning): + """An accelerator kernel failed at runtime and dispatch fell back to the reference. + + Its own category so tests can promote it to an error while production keeps running: a + fallback is a performance problem for a user and a correctness signal for CI. Before this + existed the fallback was entirely silent, and the only way to discover it was to force the + accelerator and watch it raise -- which meant you had to already suspect the problem. + """ + + +# --------------------------------------------------------------------------- +# Availability +# --------------------------------------------------------------------------- +_has_triton: Optional[bool] = None + + +def triton_available() -> bool: + """Whether ``triton`` is importable (cheap, cached for the process). + + Lives here beside the per-backend probes that consume it. ``except Exception``, not + ``except ImportError``: a Triton that is installed but skewed against the driver or LLVM + raises something else, and that must read as "unavailable" rather than propagating. + """ + global _has_triton + if _has_triton is None: + try: + import triton # noqa: F401 + + _has_triton = True + except Exception: + _has_triton = False + return _has_triton def _mps_present() -> bool: @@ -85,13 +172,9 @@ class Backend: declaring in a table even though the call site names its own kernel. The geometry targets are the case -- twelve modules each with a single ``if use_triton(x): from .triton.X import f`` call site, where the three-line local import is more legible - than a registry hop, but "which engines and devices admit Triton here" still needs - to be stated once. :func:`run_or_degrade` refuses such a backend; only - :func:`select` and :func:`admits` accept it. - engines : frozenset[Engine] - Which engines admit this backend. A forcing engine listed here makes this the - *only* candidate under it; omitting a forcing engine from the base case is what - makes that engine strict. + than a registry hop, but "which devices and dtypes admit Triton here" still needs to + be stated once. :func:`run_or_degrade` refuses such a backend; only :func:`select` and + :func:`will_use` accept it. device : str, optional Required device type (``"cuda"``/``"mps"``/``"cpu"``). ``None`` means any, which is what marks the base case. @@ -146,8 +229,8 @@ class Backend: Not consulted by :func:`select` -- availability is a fact at dispatch time, never an expectation. on_failure : {"raise", "degrade"} - What a *runtime* exception from the kernel means under ``Engine.AUTO``. Under any - forcing engine this is ignored and the exception propagates. + What a *runtime* exception from the kernel means: ``"degrade"`` falls back to the + base case (with a warning), ``"raise"`` propagates. ``"degrade"`` carries a precondition: the kernel must not have mutated its inputs before failing, or the fallback would double-count. Both accelerator splats satisfy @@ -160,7 +243,6 @@ class Backend: name: str kernel: Optional[Tuple[str, str, str]] - engines: frozenset device: Optional[str] = None dtypes: Optional[Tuple[torch.dtype, ...]] = None require_uniform_dtype: bool = False @@ -243,18 +325,11 @@ def resolve(self, aniso: bool) -> Callable: @dataclass(frozen=True) class BackendTable: - """An ordered set of backends plus the invariants that make it a complete policy. - - Two things are checked at import, both of which are currently unwritable against - hand-rolled if/elif ladders: - - * **Every** :class:`Engine` member is handled by at least one backend. Without this, - adding an engine -- or forgetting to let the base case absorb one -- silently turns a - working call into a ``RuntimeError``. ``Engine.METAL`` is the live example: it selects - the Metal density splat, and at *every other* dispatch site it must mean "run eager", - a fact that otherwise exists only as an early ``return False`` inside a predicate. - * Exactly one base case, i.e. one backend with no device and no dtype restriction. - Selection under ``AUTO`` must always terminate somewhere. + """An ordered set of backends plus the invariant that makes it a total policy. + + Checked at import: exactly one base case, i.e. one backend with no device and no dtype + restriction. That is what makes :func:`select` unable to fail -- with no unrestricted row + there would be inputs no backend matched, and selection would need an error path. """ name: str @@ -262,15 +337,6 @@ class BackendTable: base: Backend = field(init=False) def __post_init__(self): - covered = frozenset().union(*(b.engines for b in self.backends)) - missing = frozenset(Engine) - covered - if missing: - raise ValueError( - f"{self.name}: no backend handles " - f"{sorted(e.name for e in missing)}. Every engine must select " - "something -- if it should run the fallback, add it to that " - "backend's `engines`." - ) bases = [b for b in self.backends if b.device is None and b.dtypes is None] if len(bases) != 1: raise ValueError( @@ -298,15 +364,19 @@ def _probed(backend: Backend, tensors: Sequence[Optional[torch.Tensor]]): def select( table: BackendTable, tensors: Sequence[Optional[torch.Tensor]], - engine: Optional[Engine] = None, + force_portable: Optional[bool] = None, ) -> Backend: - """The one backend that runs, or a ``RuntimeError`` explaining why none can. + """The one backend that runs. Cannot fail. + + Totality is structural, not defensive: the base case declares no device and no dtype and + carries no probe, so both its criteria return ``None`` unconditionally and the loop always + terminates there. There is nothing to raise about -- selection is asked "what is fastest + here", and the answer is never "nothing". Two-phase by contract: device and dtype are checked for every candidate *before* any - availability probe is called. That ordering is load-bearing twice over. It keeps an MPS - host from compiling the CPU C++ extension it will never use, and it makes a forced - engine report the device mismatch (``requires MPS float32``) rather than a compile - failure it never got far enough to observe. + availability probe is called. That ordering is load-bearing -- it keeps an MPS host from + compiling the CPU C++ extension it will never use, and a CUDA host from importing Triton + to answer a question about a CPU tensor. Parameters ---------- @@ -315,64 +385,31 @@ def select( tensors : sequence of torch.Tensor or None Positional arguments to probe. ``None`` entries are ignored, so a site may pass optional inputs straight through. - engine : Engine, optional - Per-call override; defaults to the process-wide engine. + force_portable : bool, optional + Pin the portable reference kernel. ``None`` defers to the process-wide setting. """ - eng = engine if engine is not None else get_engine() - if not isinstance(eng, Engine): - # Loud on purpose. This used to be an implicit ``else`` that selected Triton, so - # any unrecognised value silently chose an accelerator. - raise ValueError(f"{table.name}: unhandled engine {eng!r}") + pin = force_portable if force_portable is not None else _FORCE_PORTABLE + if pin: + return table.base - reasons = [] for backend in table.backends: - if eng not in backend.engines: - continue - why = backend.mismatch(tensors) - if why is None: - why = backend.unavailable() - if why is None: + if backend.mismatch(tensors) is None and backend.unavailable() is None: return backend - reasons.append((backend, why)) - - detail = "; ".join(f"{b.name} {why}" for b, why in reasons) or ( - "no backend admits this engine" - ) - raise RuntimeError(f"engine=Engine.{eng.name} {detail}") + return table.base -def admits( +def will_use( table: BackendTable, name: str, tensors: Sequence[Optional[torch.Tensor]], - engine: Optional[Engine] = None, + force_portable: Optional[bool] = None, ) -> bool: - """Whether one named backend would run -- the shape the public predicates need. + """Whether ``name`` is the backend that would actually run for these inputs. - Differs from :func:`select` in what a non-match means. ``select`` asks "what runs?" and - raises when the answer is nothing. This asks "would *this* one run?", which has a third - answer: an engine that does not admit this backend at all is not an error, it simply is - not this backend's turn. ``should_use_metal`` under ``Engine.TRITON`` is False, not a - failure. - - Strictness still applies where it should: if the engine *does* admit this backend and - forces it, a failed criterion raises rather than returning False, because a forced - engine that quietly declines has silently degraded. + A thin wrapper over :func:`select`: with nothing to force there is no third answer, so + the question reduces to "is the selected backend this one". """ - eng = engine if engine is not None else get_engine() - if not isinstance(eng, Engine): - raise ValueError(f"{table.name}: unhandled engine {eng!r}") - backend = table.by_name(name) - if eng not in backend.engines: - return False - why = backend.mismatch(tensors) - if why is None: - why = backend.unavailable() - if why is None: - return True - if eng is not Engine.AUTO: - raise RuntimeError(f"engine=Engine.{eng.name} {why}") - return False + return select(table, tensors, force_portable=force_portable).name == name def run_or_degrade( @@ -380,23 +417,31 @@ def run_or_degrade( backend: Backend, aniso: bool, *args, - engine: Optional[Engine] = None, **kwargs, ): """Run ``backend``'s kernel, falling back to the table's base case only if allowed. - A forcing engine never degrades: the caller asked for a specific kernel, and quietly - substituting another would make an A/B comparison or a benchmark measure the wrong - thing. Under ``Engine.AUTO`` a backend marked ``on_failure="degrade"`` falls back, - which is what keeps an unavailable accelerator a performance problem rather than an - outage. + ``on_failure`` alone decides. A backend marked ``"degrade"`` falls back, which keeps a + misbehaving accelerator a performance problem rather than an outage; one marked + ``"raise"`` propagates, because a kernel that built fine and then threw is a bug, and + substituting the reference would convert wrong results into a large silent slowdown whose + numbers still look plausible. + + A fallback always **warns**. It is a performance problem for a user and a correctness + signal for CI, and it used to be entirely silent -- discoverable only by already + suspecting it and forcing the accelerator to watch it raise. """ - eng = engine if engine is not None else get_engine() fn = backend.resolve(aniso) - strict = eng is not Engine.AUTO - if strict or backend.on_failure == "raise" or backend is table.base: + if backend.on_failure == "raise" or backend is table.base: return fn(*args, **kwargs) try: return fn(*args, **kwargs) - except Exception: + except Exception as exc: # noqa: BLE001 - the fallback is the point + warnings.warn( + f"{table.name}: the {backend.name} kernel failed and dispatch fell back to " + f"{table.base.name} ({type(exc).__name__}: {exc}). Results are correct but " + "slower; this is worth investigating.", + TorchRefDegradationWarning, + stacklevel=2, + ) return table.base.resolve(aniso)(*args, **kwargs) diff --git a/torchref/utils/triton_dispatch.py b/torchref/utils/triton_dispatch.py deleted file mode 100644 index 350b636..0000000 --- a/torchref/utils/triton_dispatch.py +++ /dev/null @@ -1,202 +0,0 @@ -"""Shared accelerator/eager backend dispatch for TorchRef. - -A single capability-based selector used by every dispatch site (direct -summation, geometry/X-ray targets, FFT electron density). For a given -``(device, dtype)`` there is exactly one best path, so the backend is -*derived* rather than configured: - -- CUDA + float32 + Triton available -> Triton kernel -- MPS + float32 + shader compiles -> native Metal kernel (density splat only) -- everything else (CPU, float64, no Triton) -> eager fallback - -The :class:`Engine` enum exists only to *force* a path for tests and -benchmarks. There is **no environment-variable dispatch** — selection is via -the engine object (process-wide default + per-call override) only. - -Examples --------- -:: - - from torchref.utils import Engine, use_engine, should_use_triton - - # force eager for an A/B block (e.g. in a test) - with use_engine(Engine.EAGER): - loss = target() - - # inside a dispatch site - if should_use_triton(xyz): # reads the global engine - return _triton_path(...) - return _eager_path(...) -""" - -from __future__ import annotations - -import contextlib -import enum -from typing import Iterator, Optional - -import torch - -__all__ = [ - "Engine", - "get_engine", - "set_engine", - "use_engine", - "triton_available", - "should_use_triton", - "should_use_metal", -] - - -class Engine(enum.Enum): - """Backend selector shared by all accelerator dispatch sites. - - ``AUTO`` derives the backend from device/dtype/availability. ``TRITON``, - ``METAL`` and ``EAGER`` force a path; the two accelerator forces are - strict and never silently degrade -- ``TRITON`` raises if - CUDA+float32+availability is not met, ``METAL`` if MPS+float32+a compiled - shader is not met. - - ``METAL`` selects the native Metal *density splat* only; there are no - Metal direct-summation or target kernels, so those sites treat it as - eager. Prefer scoping it with :func:`use_engine` around the density call - over a process-wide :func:`set_engine`, which would also send every target - math function down the eager path and skew a benchmark. - """ - - AUTO = "auto" - TRITON = "triton" - METAL = "metal" - EAGER = "eager" - - -# Process-wide default engine. No environment-variable read — change via -# set_engine()/use_engine() or a per-call engine= override. -_ENGINE: Engine = Engine.AUTO - - -def get_engine() -> Engine: - """Return the current process-wide engine.""" - return _ENGINE - - -def set_engine(engine: Engine) -> None: - """Set the process-wide engine.""" - global _ENGINE - _ENGINE = engine - - -@contextlib.contextmanager -def use_engine(engine: Engine) -> Iterator[None]: - """Temporarily force an engine for the duration of the ``with`` block. - - Restores the previous engine on exit (including on exception). - """ - prev = get_engine() - set_engine(engine) - try: - yield - finally: - set_engine(prev) - - -_has_triton: Optional[bool] = None - - -def triton_available() -> bool: - """Whether ``triton`` is importable (cheap, cached for the process).""" - global _has_triton - if _has_triton is None: - try: - import triton # noqa: F401 - - _has_triton = True - except Exception: - _has_triton = False - return _has_triton - - -def should_use_triton(*tensors: torch.Tensor, engine: Optional[Engine] = None) -> bool: - """Coarse triton-vs-eager gate. - - Asks the ``triton`` row of ``TARGET_BACKENDS`` whether it would run, so the - device/dtype/availability criteria are stated once, in that table, rather than a second - time here. The target-math table is the right one to defer to: it is the *generic* - Triton question, with no kernel-family-specific requirement attached. The density and - direct-summation sites have their own tables because their availability probes and probe - sets genuinely differ. - - Parameters - ---------- - *tensors : torch.Tensor - Tensors whose device/dtype gate the Triton path. ``None`` entries are - ignored (a target may pass optional tensors). - engine : Engine, optional - Per-call override. Defaults to the process-wide engine. - - Returns - ------- - bool - True if the Triton kernel should be used. - - Raises - ------ - RuntimeError - If ``engine`` is ``Engine.TRITON`` but the inputs are not CUDA - float32, or Triton is unavailable. - ValueError - If ``engine`` is not an ``Engine`` member. Deliberately loud: the AUTO case used to - be an implicit ``else``, so *any* unrecognised value silently selected Triton. - - Notes - ----- - ``Engine.METAL`` returns False here rather than raising -- it forces the - Metal density splat (see :func:`should_use_metal`), and every other - dispatch site correctly runs eager under it. In the table that is the ``eager`` row - listing METAL among its engines, which is checked for completeness at import. - """ - # Function-local: ``torchref.utils`` loads before ``torchref.base``. - from torchref.base.targets._dispatch import TARGET_BACKENDS - from torchref.utils.backends import admits - - return admits(TARGET_BACKENDS, "triton", tensors, engine) - - -def should_use_metal(*tensors: torch.Tensor, engine: Optional[Engine] = None) -> bool: - """Metal-vs-portable gate for the MPS electron-density splat. - - Retained as public API, but no longer a second statement of the criteria: it asks the - ``mps_metal`` row of ``DENSITY_BACKENDS`` whether it would run. The device, dtype and - shader-availability conditions live in that table, so this cannot drift from what - dispatch actually does. - - Parameters - ---------- - *tensors : torch.Tensor - Tensors whose device/dtype gate the Metal path. ``None`` entries are - ignored. - engine : Engine, optional - Per-call override. Defaults to the process-wide engine. - - Returns - ------- - bool - True if the Metal kernels should be used. ``False`` -- not an error -- for an - engine that does not admit Metal at all, since it is simply not Metal's turn. - - Raises - ------ - RuntimeError - If ``engine`` is ``Engine.METAL`` but the inputs are not MPS float32, or the shader - library is unavailable. The message carries the recorded compile error, since that - is the one failure a user can act on (torch < 2.9, or an older Metal rejecting the - MSL). - ValueError - If ``engine`` is not an ``Engine`` member. - """ - # Imported here, not at module scope: ``torchref.utils`` loads very early and must not - # reach into ``torchref.base`` while it is still initialising. - from torchref.base.electron_density._backends import DENSITY_BACKENDS - from torchref.utils.backends import admits - - return admits(DENSITY_BACKENDS, "mps_metal", tensors, engine) From 5a4bc8ab6d79279af03958e5a511635e66b50c5b Mon Sep 17 00:00:00 2001 From: HatPdotS Date: Thu, 30 Jul 2026 15:07:25 +0200 Subject: [PATCH 12/15] Updated changelog, changed version back to 0.6.2 (claude got a bit excited) --- docs/changelog.rst | 4 +++- pyproject.toml | 13 +++++++++++++ tests/unit/structure_factor/__init__.py | 6 +++--- torchref/__init__.py | 2 +- 4 files changed, 20 insertions(+), 5 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index b56d031..d622c89 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -7,7 +7,9 @@ Version 0.6.2 - Added a metal shader for structure factor calculation - Standardized structure factor calculation geometry - Split gpu tests into cuda and mps -- Reworked VDW pair list creation +- Reworked VDW pair list creation +- Reworked backend dispatch: which kernel runs is now read from one declarative table per kernel family (device, dtype, availability probe, failure policy), replacing two hand-written if/elif ladders. + Version 0.6.1 ------------- diff --git a/pyproject.toml b/pyproject.toml index d7de55b..96f8630 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -120,6 +120,19 @@ markers = [ "openmm: Tests requiring OpenMM (the [amber] extra); skipped if absent", "amber: Tests requiring OpenMM + AmberTools (antechamber/tleap); skipped if absent", ] +# Keep in step with ``tests/pytest.ini``; see the note above on why both exist. +# +# A backend degradation is an error under test and a warning in production. Dispatch falls +# back to the portable kernel when an accelerator throws, which is right for a user -- the +# answer stays correct, it just gets slower -- and wrong for CI, because a comparison between +# an accelerator and the reference would then run the reference twice and pass at zero +# difference while measuring nothing. +filterwarnings = [ + "ignore::DeprecationWarning", + "ignore::PendingDeprecationWarning", + "ignore::FutureWarning", + "error::torchref.utils.backends.TorchRefDegradationWarning", +] [tool.ruff.lint] ignore = ["F841","E741","F841","E402","E722"] \ No newline at end of file diff --git a/tests/unit/structure_factor/__init__.py b/tests/unit/structure_factor/__init__.py index c22c118..851a3f2 100644 --- a/tests/unit/structure_factor/__init__.py +++ b/tests/unit/structure_factor/__init__.py @@ -137,8 +137,8 @@ # Measured at the **production** grid (``max_res = d_min``, spacing ``d_min/3``) with the # default 3.0 sigma cutoff, differentiating ``ls_target`` against pseudo-observations # offset from the oracle by 10% relative noise. Identical to 3 significant figures across -# float32/float64 and AUTO/EAGER, so these are properties of the discretization, not of -# precision or of a kernel: +# float32/float64 and across both CPU kernels, so these are properties of the +# discretization, not of precision or of a kernel: # # amplitude g_xyz g_occ g_adp/g_U HVP HVP cos # iso 5.89e-03 8.51e-02 2.68e-02 4.45e-02 2.26e-02 0.99980 @@ -194,7 +194,7 @@ # Gradients need their own float32 constant. The fused C++ kernel's hand-written backward # and the portable splat's autograd accumulate in different orders, and float32 does not -# forgive that the way the forward pass does. Measured AUTO-vs-EAGER on ``scene_fine`` +# forgive that the way the forward pass does. Measured fused-vs-portable on ``scene_fine`` # (60 atoms), float32: # # xyz occ adp/U diff --git a/torchref/__init__.py b/torchref/__init__.py index 1ac09fe..2ff89bb 100644 --- a/torchref/__init__.py +++ b/torchref/__init__.py @@ -51,7 +51,7 @@ General utilities and debugging tools. """ -__version__ = "0.6.3" +__version__ = "0.6.2" import os From da7af47a73148e4749b1e1ec6ea38e24118b9632 Mon Sep 17 00:00:00 2001 From: HatPdotS Date: Thu, 30 Jul 2026 15:10:00 +0200 Subject: [PATCH 13/15] merged in preconditioned L-BFGS addition --- docs/changelog.rst | 1 + docs/doctest_output.out | 5545 +++++++++++++++++ pyproject.toml | 8 +- tests/unit/test_seeded_lbfgs.py | 199 + torchref/refinement/optimizers/__init__.py | 11 + torchref/refinement/optimizers/curvature.py | 256 + .../refinement/optimizers/seeded_lbfgs.py | 300 + 7 files changed, 6313 insertions(+), 7 deletions(-) create mode 100644 docs/doctest_output.out create mode 100644 tests/unit/test_seeded_lbfgs.py create mode 100644 torchref/refinement/optimizers/curvature.py create mode 100644 torchref/refinement/optimizers/seeded_lbfgs.py diff --git a/docs/changelog.rst b/docs/changelog.rst index d622c89..a05b1bf 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -9,6 +9,7 @@ Version 0.6.2 - Split gpu tests into cuda and mps - Reworked VDW pair list creation - Reworked backend dispatch: which kernel runs is now read from one declarative table per kernel family (device, dtype, availability probe, failure policy), replacing two hand-written if/elif ladders. +- Added preconditioned L-BFGS optimizer for joined refinement. Version 0.6.1 diff --git a/docs/doctest_output.out b/docs/doctest_output.out new file mode 100644 index 0000000..82a11f1 --- /dev/null +++ b/docs/doctest_output.out @@ -0,0 +1,5545 @@ +Running Sphinx v8.2.3 +loading translations [en]... done +loading pickled environment... done +[autosummary] generating autosummary for: api/io.rst, api/math_functions.rst, api/model.rst, api/refinement.rst, api/restraints.rst, api/scaling.rst, api/symmetrie.rst, api/utils.rst, changelog.rst, contributing.rst, index.rst, installation.rst, quickstart.rst, user_guide/refinement.rst, user_guide/restraints.rst, user_guide/scaling.rst, user_guide/targets.rst +building [mo]: targets for 0 po files that are out of date +writing output... +building [doctest]: targets for 17 source files that are out of date +updating environment: 0 added, 3 changed, 0 removed +reading sources... [ 33%] api/io +reading sources... [ 67%] api/refinement +reading sources... [100%] api/symmetrie + +looking for now-outdated files... none found +pickling environment... done +checking consistency... done +preparing documents... done +copying assets... +copying assets: done +running tests... + +Document: api/io +---------------- +********************************************************************** +File "../torchref/io/__init__.py", line ?, in default +Failed example: + data.load_mtz('structure.mtz') +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + data.load_mtz('structure.mtz') + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/datasets/reflection_data.py", line 233, in load_mtz + reader = mtz.MTZReader(verbose=self.verbose).read(path) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/mtz.py", line 158, in read + self.mtz_data = rs.read_mtz(filepath) + ^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/site-packages/reciprocalspaceship/io/mtz.py", line 186, in read_mtz + gemmi_mtz = gemmi.read_mtz_file(mtzfile) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + RuntimeError: Failed to open structure.mtz: No such file or directory: structure.mtz +********************************************************************** +File "../torchref/io/__init__.py", line ?, in default +Failed example: + collection.add_dataset('native', native_data) +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + collection.add_dataset('native', native_data) + ^^^^^^^^^^^ + NameError: name 'native_data' is not defined +********************************************************************** +File "../torchref/io/__init__.py", line ?, in default +Failed example: + collection.add_dataset('derivative', derivative_data) +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + collection.add_dataset('derivative', derivative_data) + ^^^^^^^^^^^^^^^ + NameError: name 'derivative_data' is not defined +********************************************************************** +File "../torchref/io/__init__.py", line ?, in default +Failed example: + reader = mtz.read('data.mtz') +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + reader = mtz.read('data.mtz') + ^^^^^^^^^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/mtz.py", line 347, in read + return MTZReader(verbose=verbose).read(filepath) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/mtz.py", line 158, in read + self.mtz_data = rs.read_mtz(filepath) + ^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/site-packages/reciprocalspaceship/io/mtz.py", line 186, in read_mtz + gemmi_mtz = gemmi.read_mtz_file(mtzfile) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + RuntimeError: Failed to open data.mtz: No such file or directory: data.mtz +********************************************************************** +File "../torchref/io/__init__.py", line ?, in default +Failed example: + data_dict, cell, spacegroup = reader() +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + data_dict, cell, spacegroup = reader() + ^^^^^^ + NameError: name 'reader' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + data = CrystalDataset(device='cuda') +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + data = CrystalDataset(device='cuda') + ^^^^^^^^^^^^^^ + NameError: name 'CrystalDataset' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + data.cpu() # Move all tensors to CPU +Expected nothing +Got: + ReflectionData(n=2, sg=None) +********************************************************************** +File "None", line ?, in default +Failed example: + data.save('data.pt') # Serialize to file +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + data.save('data.pt') # Serialize to file + ^^^^^^^^^ + AttributeError: 'ReflectionData' object has no attribute 'save' +********************************************************************** +File "None", line ?, in default +Failed example: + data = ReflectionData.load_state('reflection_data.pt', device='cuda') +Expected nothing +Got: + Loaded ReflectionData from reflection_data.pt +********************************************************************** +File "None", line ?, in default +Failed example: + data.save_state('reflection_data.pt') +Expected nothing +Got: + Saved ReflectionData to reflection_data.pt +********************************************************************** +File "None", line ?, in default +Failed example: + data.load_mtz('data.mtz') +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + data.load_mtz('data.mtz') + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/datasets/reflection_data.py", line 233, in load_mtz + reader = mtz.MTZReader(verbose=self.verbose).read(path) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/mtz.py", line 158, in read + self.mtz_data = rs.read_mtz(filepath) + ^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/site-packages/reciprocalspaceship/io/mtz.py", line 186, in read_mtz + gemmi_mtz = gemmi.read_mtz_file(mtzfile) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + RuntimeError: Failed to open data.mtz: No such file or directory: data.mtz +********************************************************************** +File "None", line ?, in default +Failed example: + print(f"Loaded {len(data.hkl)} reflections") +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + print(f"Loaded {len(data.hkl)} reflections") + ^^^^^^^^^^^^^ + TypeError: object of type 'NoneType' has no len() +********************************************************************** +File "None", line ?, in default +Failed example: + print(f"Resolution range: {data.resolution.min():.2f} - {data.resolution.max():.2f} Å") +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + print(f"Resolution range: {data.resolution.min():.2f} - {data.resolution.max():.2f} Å") + ^^^^^^^^^^^^^^^^^^^ + AttributeError: 'NoneType' object has no attribute 'min' +********************************************************************** +File "None", line ?, in default +Failed example: + hkl, F, sigma, rfree = data() +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + hkl, F, sigma, rfree = data() + ^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/datasets/reflection_data.py", line 1318, in __call__ + F = MaskedTensor(F.detach().clone(), to_mask) + ^^^^^^^^ + AttributeError: 'NoneType' object has no attribute 'detach' +********************************************************************** +File "None", line ?, in default +Failed example: + print(F.shape) # Full shape +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + print(F.shape) # Full shape + ^ + NameError: name 'F' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + print(F.sum()) # Only sums valid (unmasked) values +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + print(F.sum()) # Only sums valid (unmasked) values + ^ + NameError: name 'F' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + valid_mask = F.get_mask() +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + valid_mask = F.get_mask() + ^ + NameError: name 'F' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + F_values = F.get_data()[valid_mask] +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + F_values = F.get_data()[valid_mask] + ^ + NameError: name 'F' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + filtered = data.cut_res(highres=1.5, lowres=50.0) +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + filtered = data.cut_res(highres=1.5, lowres=50.0) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/datasets/reflection_data.py", line 1071, in cut_res + return self.filter_by_resolution(d_min=highres, d_max=lowres) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/datasets/reflection_data.py", line 1014, in filter_by_resolution + self._calculate_resolution() + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/datasets/reflection_data.py", line 630, in _calculate_resolution + raise ValueError( + ValueError: Miller indices (hkl) are required to calculate resolution +********************************************************************** +File "None", line ?, in default +Failed example: + high_res = data.cut_res(highres=1.0, lowres=2.0) +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + high_res = data.cut_res(highres=1.0, lowres=2.0) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/datasets/reflection_data.py", line 1071, in cut_res + return self.filter_by_resolution(d_min=highres, d_max=lowres) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/datasets/reflection_data.py", line 1014, in filter_by_resolution + self._calculate_resolution() + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/datasets/reflection_data.py", line 630, in _calculate_resolution + raise ValueError( + ValueError: Miller indices (hkl) are required to calculate resolution +********************************************************************** +File "None", line ?, in default +Failed example: + hkl, F, sigma, rfree = data.forward_indexed() +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + hkl, F, sigma, rfree = data.forward_indexed() + ^^^^^^^^^^^^^^^^^^^^ + AttributeError: 'ReflectionData' object has no attribute 'forward_indexed' +********************************************************************** +File "None", line ?, in default +Failed example: + F_np = F.cpu().numpy() # Safe for writing to files +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + F_np = F.cpu().numpy() # Safe for writing to files + ^ + NameError: name 'F' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + data = ReflectionData().load_mtz('data.mtz') +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + data = ReflectionData().load_mtz('data.mtz') + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/datasets/reflection_data.py", line 233, in load_mtz + reader = mtz.MTZReader(verbose=self.verbose).read(path) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/mtz.py", line 158, in read + self.mtz_data = rs.read_mtz(filepath) + ^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/site-packages/reciprocalspaceship/io/mtz.py", line 186, in read_mtz + gemmi_mtz = gemmi.read_mtz_file(mtzfile) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + RuntimeError: Failed to open data.mtz: No such file or directory: data.mtz +********************************************************************** +File "None", line ?, in default +Failed example: + print(f"Original: {len(data)} reflections, {data.spacegroup}") +Expected nothing +Got: + Original: 0 reflections, None +********************************************************************** +File "None", line ?, in default +Failed example: + data_p1 = data.expand_to_p1() +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + data_p1 = data.expand_to_p1() + ^^^^^^^^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/datasets/reflection_data.py", line 2270, in expand_to_p1 + raise ValueError("ReflectionData has no Miller indices loaded") + ValueError: ReflectionData has no Miller indices loaded +********************************************************************** +File "None", line ?, in default +Failed example: + print(f"Expanded: {len(data_p1)} reflections, {data_p1.spacegroup}") +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + print(f"Expanded: {len(data_p1)} reflections, {data_p1.spacegroup}") + ^^^^^^^ + NameError: name 'data_p1' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + data_p1_anom = data.expand_to_p1(include_friedel=False) +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + data_p1_anom = data.expand_to_p1(include_friedel=False) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/datasets/reflection_data.py", line 2270, in expand_to_p1 + raise ValueError("ReflectionData has no Miller indices loaded") + ValueError: ReflectionData has no Miller indices loaded +********************************************************************** +File "None", line ?, in default +Failed example: + data = ReflectionData().load_mtz('data.mtz') +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + data = ReflectionData().load_mtz('data.mtz') + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/datasets/reflection_data.py", line 233, in load_mtz + reader = mtz.MTZReader(verbose=self.verbose).read(path) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/mtz.py", line 158, in read + self.mtz_data = rs.read_mtz(filepath) + ^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/site-packages/reciprocalspaceship/io/mtz.py", line 186, in read_mtz + gemmi_mtz = gemmi.read_mtz_file(mtzfile) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + RuntimeError: Failed to open data.mtz: No such file or directory: data.mtz +********************************************************************** +File "None", line ?, in default +Failed example: + data_filled = data.fill(d_min=2.0) +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + data_filled = data.fill(d_min=2.0) + ^^^^^^^^^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/datasets/reflection_data.py", line 2191, in fill + raise ValueError("ReflectionData has no Miller indices loaded") + ValueError: ReflectionData has no Miller indices loaded +********************************************************************** +File "None", line ?, in default +Failed example: + print(f"Original: {len(data)}, Filled: {len(data_filled)}") +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + print(f"Original: {len(data)}, Filled: {len(data_filled)}") + ^^^^^^^^^^^ + NameError: name 'data_filled' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + F, sigma_F = data.get_structure_factors_with_sigma() +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + F, sigma_F = data.get_structure_factors_with_sigma() + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/datasets/reflection_data.py", line 971, in get_structure_factors_with_sigma + raise ValueError("No amplitude data loaded") + ValueError: No amplitude data loaded +********************************************************************** +File "None", line ?, in default +Failed example: + if sigma_F is not None: + weighted_residual = (F_obs - F_calc) / sigma_F +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + if sigma_F is not None: + ^^^^^^^ + NameError: name 'sigma_F' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + print(f"{mask.sum()} of {len(mask)} reflections are valid") +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + print(f"{mask.sum()} of {len(mask)} reflections are valid") + ^^^^^^^^ + AttributeError: 'NoneType' object has no attribute 'sum' +********************************************************************** +File "None", line ?, in default +Failed example: + blocks = ReflectionData.list_cif_data_blocks('1VLM-sf.cif') +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + blocks = ReflectionData.list_cif_data_blocks('1VLM-sf.cif') + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/datasets/reflection_data.py", line 283, in list_cif_data_blocks + return cif.list_data_blocks(path) + ^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/cif.py", line 163, in list_data_blocks + reader = CIFReader(filepath) + ^^^^^^^^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/cif_readers.py", line 80, in __init__ + self.load(filepath) + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/cif_readers.py", line 92, in load + with open(filepath, "r", encoding="utf-8", errors="ignore") as f: + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + FileNotFoundError: [Errno 2] No such file or directory: '1VLM-sf.cif' +********************************************************************** +File "None", line ?, in default +Failed example: + print(blocks) +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + print(blocks) + ^^^^^^ + NameError: name 'blocks' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + data = ReflectionData().load_cif('1VLM-sf.cif', data_block=blocks[1]) +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + data = ReflectionData().load_cif('1VLM-sf.cif', data_block=blocks[1]) + ^^^^^^ + NameError: name 'blocks' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + data_p1 = data.expand_to_p1() +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + data_p1 = data.expand_to_p1() + ^^^^^^^^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/datasets/reflection_data.py", line 2270, in expand_to_p1 + raise ValueError("ReflectionData has no Miller indices loaded") + ValueError: ReflectionData has no Miller indices loaded +********************************************************************** +File "None", line ?, in default +Failed example: + data_merged = data_p1.reduce_to_spacegroup('P21') +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + data_merged = data_p1.reduce_to_spacegroup('P21') + ^^^^^^^ + NameError: name 'data_p1' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + data_summed = data_p1.reduce_to_spacegroup('P21', aggregation='sum') +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + data_summed = data_p1.reduce_to_spacegroup('P21', aggregation='sum') + ^^^^^^^ + NameError: name 'data_p1' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + data.regenerate_rfree_flags(free_fraction=0.02, n_bins=20, seed=42) +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + data.regenerate_rfree_flags(free_fraction=0.02, n_bins=20, seed=42) + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/datasets/reflection_data.py", line 611, in regenerate_rfree_flags + self._generate_rfree_flags( + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/datasets/reflection_data.py", line 323, in _generate_rfree_flags + raise ValueError("Resolution information required to generate R-free flags") + ValueError: Resolution information required to generate R-free flags +********************************************************************** +File "None", line ?, in default +Failed example: + data.regenerate_rfree_flags(free_fraction=0.05, n_bins=10, force=True) +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + data.regenerate_rfree_flags(free_fraction=0.05, n_bins=10, force=True) + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/datasets/reflection_data.py", line 611, in regenerate_rfree_flags + self._generate_rfree_flags( + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/datasets/reflection_data.py", line 323, in _generate_rfree_flags + raise ValueError("Resolution information required to generate R-free flags") + ValueError: Resolution information required to generate R-free flags +********************************************************************** +File "None", line ?, in default +Failed example: + reference_hkl = data1.hkl.clone() +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + reference_hkl = data1.hkl.clone() + ^^^^^ + NameError: name 'data1' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + data1.validate_hkl(reference_hkl) +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + data1.validate_hkl(reference_hkl) + ^^^^^ + NameError: name 'data1' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + data2.validate_hkl(reference_hkl) +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + data2.validate_hkl(reference_hkl) + ^^^^^ + NameError: name 'data2' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + assert data1.hkl.shape == data2.hkl.shape +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + assert data1.hkl.shape == data2.hkl.shape + ^^^^^ + NameError: name 'data1' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + data = ReflectionData().load_mtz('observed.mtz') +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + data = ReflectionData().load_mtz('observed.mtz') + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/datasets/reflection_data.py", line 233, in load_mtz + reader = mtz.MTZReader(verbose=self.verbose).read(path) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/mtz.py", line 158, in read + self.mtz_data = rs.read_mtz(filepath) + ^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/site-packages/reciprocalspaceship/io/mtz.py", line 186, in read_mtz + gemmi_mtz = gemmi.read_mtz_file(mtzfile) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + RuntimeError: Failed to open observed.mtz: No such file or directory: observed.mtz +********************************************************************** +File "None", line ?, in default +Failed example: + model = Model().load_pdb('model.pdb') +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + model = Model().load_pdb('model.pdb') + ^^^^^ + NameError: name 'Model' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + model_ft = ModelFT(model, data.cell, data.spacegroup) +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + model_ft = ModelFT(model, data.cell, data.spacegroup) + ^^^^^^^ + NameError: name 'ModelFT' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + fcalc = model_ft.forward(data.hkl) +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + fcalc = model_ft.forward(data.hkl) + ^^^^^^^^ + NameError: name 'model_ft' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + data.write_mtz('output.mtz', fcalc=fcalc) +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + data.write_mtz('output.mtz', fcalc=fcalc) + ^^^^^ + NameError: name 'fcalc' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + native = ReflectionData().load_mtz('native.mtz') +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + native = ReflectionData().load_mtz('native.mtz') + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/datasets/reflection_data.py", line 233, in load_mtz + reader = mtz.MTZReader(verbose=self.verbose).read(path) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/mtz.py", line 158, in read + self.mtz_data = rs.read_mtz(filepath) + ^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/site-packages/reciprocalspaceship/io/mtz.py", line 186, in read_mtz + gemmi_mtz = gemmi.read_mtz_file(mtzfile) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + RuntimeError: Failed to open native.mtz: No such file or directory: native.mtz +********************************************************************** +File "None", line ?, in default +Failed example: + derivative = ReflectionData().load_mtz('derivative.mtz') +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + derivative = ReflectionData().load_mtz('derivative.mtz') + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/datasets/reflection_data.py", line 233, in load_mtz + reader = mtz.MTZReader(verbose=self.verbose).read(path) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/mtz.py", line 158, in read + self.mtz_data = rs.read_mtz(filepath) + ^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/site-packages/reciprocalspaceship/io/mtz.py", line 186, in read_mtz + gemmi_mtz = gemmi.read_mtz_file(mtzfile) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + RuntimeError: Failed to open derivative.mtz: No such file or directory: derivative.mtz +********************************************************************** +File "None", line ?, in default +Failed example: + collection.add_dataset('native', native, set_as_reference=True) +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + collection.add_dataset('native', native, set_as_reference=True) + ^^^^^^ + NameError: name 'native' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + collection.add_dataset('derivative', derivative) +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + collection.add_dataset('derivative', derivative) + ^^^^^^^^^^ + NameError: name 'derivative' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + native_F = collection['native'].F +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + native_F = collection['native'].F + ~~~~~~~~~~^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/datasets/collection.py", line 186, in __getitem__ + return self._datasets[name] + ~~~~~~~~~~~~~~^^^^^^ + KeyError: 'native' +********************************************************************** +File "None", line ?, in default +Failed example: + collection.add_dataset('native', native_data, set_as_reference=True) +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + collection.add_dataset('native', native_data, set_as_reference=True) + ^^^^^^^^^^^ + NameError: name 'native_data' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + collection.add_dataset('derivative', derivative_data) +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + collection.add_dataset('derivative', derivative_data) + ^^^^^^^^^^^^^^^ + NameError: name 'derivative_data' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + reader = mtz.read('data.mtz', verbose=1) +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + reader = mtz.read('data.mtz', verbose=1) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/mtz.py", line 347, in read + return MTZReader(verbose=verbose).read(filepath) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/mtz.py", line 158, in read + self.mtz_data = rs.read_mtz(filepath) + ^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/site-packages/reciprocalspaceship/io/mtz.py", line 186, in read_mtz + gemmi_mtz = gemmi.read_mtz_file(mtzfile) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + RuntimeError: Failed to open data.mtz: No such file or directory: data.mtz +********************************************************************** +File "None", line ?, in default +Failed example: + data_dict, cell, spacegroup = reader() +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + data_dict, cell, spacegroup = reader() + ^^^^^^ + NameError: name 'reader' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + print(f"Found {len(data_dict['HKL'])} reflections in {spacegroup.short_name()}") +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + print(f"Found {len(data_dict['HKL'])} reflections in {spacegroup.short_name()}") + ^^^^^^^^^ + NameError: name 'data_dict' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + reader = pdb.read('structure.pdb', verbose=1) +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + reader = pdb.read('structure.pdb', verbose=1) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/pdb.py", line 381, in read + return PDBReader(verbose=verbose).read(filepath) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/pdb.py", line 339, in read + self.dataframe = load_as_dataframe(filepath) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/pdb.py", line 149, in load_as_dataframe + skipheader = find_header_length(filepath) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/pdb.py", line 67, in find_header_length + with open(filepath, "r") as f: + ^^^^^^^^^^^^^^^^^^^ + FileNotFoundError: [Errno 2] No such file or directory: 'structure.pdb' +********************************************************************** +File "None", line ?, in default +Failed example: + df, cell, spacegroup = reader() +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + df, cell, spacegroup = reader() + ^^^^^^ + NameError: name 'reader' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + print(f"Loaded {len(df)} atoms") +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + print(f"Loaded {len(df)} atoms") + ^^ + NameError: name 'df' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + router = DataRouter("structure.cif") +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + router = DataRouter("structure.cif") + ^^^^^^^^^^ + NameError: name 'DataRouter' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + reader = router.get_reader() +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + reader = router.get_reader() + ^^^^^^ + NameError: name 'router' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + print(router.data_type) # 'structure' +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + print(router.data_type) # 'structure' + ^^^^^^ + NameError: name 'router' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + reader, data_type = DataRouter.route("7JI4-sf.cif") +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + reader, data_type = DataRouter.route("7JI4-sf.cif") + ^^^^^^^^^^ + NameError: name 'DataRouter' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + if data_type == 'reflections': + data_dict, cell, spacegroup = reader() +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + if data_type == 'reflections': + ^^^^^^^^^ + NameError: name 'data_type' is not defined +********************************************************************** +1 items had failures: + 68 of 81 in default +81 tests in 1 items. +13 passed and 68 failed. +***Test Failed*** 68 failures. + +Document: api/math_functions +---------------------------- +********************************************************************** +File "None", line ?, in default +Failed example: + fw = FrenchWilson(spacegroup='P21', cell=cell) +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + fw = FrenchWilson(spacegroup='P21', cell=cell) + ^^^^ + NameError: name 'cell' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + F_french_wilson = fw(I_obs, sigma_I) +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + F_french_wilson = fw(I_obs, sigma_I) + ^^ + NameError: name 'fw' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + frac_coords = math_torch.cartesian_to_fractional_torch(cart_coords, cell) +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + frac_coords = math_torch.cartesian_to_fractional_torch(cart_coords, cell) + ^^^^^^^^^^^ + NameError: name 'cart_coords' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + fw_module = FrenchWilson(hkl, unit_cell, 'P212121') +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + fw_module = FrenchWilson(hkl, unit_cell, 'P212121') + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/math_functions/french_wilson.py", line 1347, in __init__ + d_spacings = math_torch.get_d_spacing(hkl, unit_cell) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/math_functions/math_torch.py", line 965, in get_d_spacing + s = get_scattering_vectors(hkl, unit_cell, recB) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/math_functions/math_torch.py", line 942, in get_scattering_vectors + recB = reciprocal_basis_matrix(unit_cell) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/math_functions/math_torch.py", line 885, in reciprocal_basis_matrix + angles_rad = torch.deg2rad(unit_cell[3:]) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + TypeError: deg2rad(): argument 'input' (position 1) must be Tensor, not list +********************************************************************** +File "None", line ?, in default +Failed example: + F, sigma_F = fw_module(I, sigma_I) +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + F, sigma_F = fw_module(I, sigma_I) + ^^^^^^^^^ + NameError: name 'fw_module' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + hkl = generate_possible_hkl(cell, d_min=2.0) +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + hkl = generate_possible_hkl(cell, d_min=2.0) + ^^^^^^^^^^^^^^^^^^^^^ + NameError: name 'generate_possible_hkl' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + print(f"Generated {len(hkl)} reflections") +Expected nothing +Got: + Generated 4 reflections +********************************************************************** +File "None", line ?, in default +Failed example: + voids = find_solvent_voids(mask) +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + voids = find_solvent_voids(mask) + ^^^^^^^^^^^^^^^^^^ + NameError: name 'find_solvent_voids' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + print(voids) +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + print(voids) + ^^^^^ + NameError: name 'voids' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + from french_wilson_pytorch import FrenchWilsonModule +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + from french_wilson_pytorch import FrenchWilsonModule + ModuleNotFoundError: No module named 'french_wilson_pytorch' +********************************************************************** +File "None", line ?, in default +Failed example: + fw_module = FrenchWilsonModule(hkl, unit_cell, space_group='P212121') +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + fw_module = FrenchWilsonModule(hkl, unit_cell, space_group='P212121') + ^^^^^^^^^^^^^^^^^^ + NameError: name 'FrenchWilsonModule' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + F, sigma_F = fw_module(I, sigma_I) +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + F, sigma_F = fw_module(I, sigma_I) + ^^^^^^^^^ + NameError: name 'fw_module' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + print(f"F = {F}") +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + print(f"F = {F}") + ^ + NameError: name 'F' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + from french_wilson_pytorch import french_wilson_auto +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + from french_wilson_pytorch import french_wilson_auto + ModuleNotFoundError: No module named 'french_wilson_pytorch' +********************************************************************** +File "None", line ?, in default +Failed example: + F, sigma_F, valid = french_wilson_auto( + I, sigma_I, hkl, d_spacings, space_group='P212121' + ) +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + F, sigma_F, valid = french_wilson_auto( + ^^^^^^^^^^^^^^^^^^ + NameError: name 'french_wilson_auto' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + F, sigma_F, valid = french_wilson(I, sigma_I, mean_I) +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + F, sigma_F, valid = french_wilson(I, sigma_I, mean_I) + ^^^^^^^^^^^^^ + NameError: name 'french_wilson' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + print(f"F = {F}") +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + print(f"F = {F}") + ^ + NameError: name 'F' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + F, sigma_F, valid = french_wilson_auto(I, sigma_I, hkl, d_spacings, "P212121") +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + F, sigma_F, valid = french_wilson_auto(I, sigma_I, hkl, d_spacings, "P212121") + ^^^^^^^^^^^^^^^^^^ + NameError: name 'french_wilson_auto' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + fw_module = FrenchWilson(hkl, unit_cell, 'P212121') +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + fw_module = FrenchWilson(hkl, unit_cell, 'P212121') + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/math_functions/french_wilson.py", line 1347, in __init__ + d_spacings = math_torch.get_d_spacing(hkl, unit_cell) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/math_functions/math_torch.py", line 965, in get_d_spacing + s = get_scattering_vectors(hkl, unit_cell, recB) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/math_functions/math_torch.py", line 942, in get_scattering_vectors + recB = reciprocal_basis_matrix(unit_cell) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/math_functions/math_torch.py", line 885, in reciprocal_basis_matrix + angles_rad = torch.deg2rad(unit_cell[3:]) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + TypeError: deg2rad(): argument 'input' (position 1) must be Tensor, not list +********************************************************************** +File "None", line ?, in default +Failed example: + F, sigma_F = fw_module(I, sigma_I) +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + F, sigma_F = fw_module(I, sigma_I) + ^^^^^^^^^ + NameError: name 'fw_module' is not defined +********************************************************************** +1 items had failures: + 20 of 47 in default +47 tests in 1 items. +27 passed and 20 failed. +***Test Failed*** 20 failures. + +Document: api/model +------------------- +********************************************************************** +File "None", line ?, in default +Failed example: + model.load_pdb('structure.pdb') +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + model.load_pdb('structure.pdb') + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model.py", line 231, in load_pdb + reader = pdb.PDBReader(verbose=self.verbose).read(file) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/pdb.py", line 339, in read + self.dataframe = load_as_dataframe(filepath) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/pdb.py", line 149, in load_as_dataframe + skipheader = find_header_length(filepath) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/pdb.py", line 67, in find_header_length + with open(filepath, "r") as f: + ^^^^^^^^^^^^^^^^^^^ + FileNotFoundError: [Errno 2] No such file or directory: 'structure.pdb' +********************************************************************** +File "None", line ?, in default +Failed example: + model_ft = ModelFT(data, device='cuda') +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + model_ft = ModelFT(data, device='cuda') + ^^^^ + NameError: name 'data' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + F_calc = model_ft.get_F_calc() +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + F_calc = model_ft.get_F_calc() + ^^^^^^^^ + NameError: name 'model_ft' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + model.load_state_dict(torch.load('model.pt')) +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + model.load_state_dict(torch.load('model.pt')) + ^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/site-packages/torch/serialization.py", line 1484, in load + with _open_file_like(f, "rb") as opened_file: + ^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/site-packages/torch/serialization.py", line 759, in _open_file_like + return _open_file(name_or_buffer, mode) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/site-packages/torch/serialization.py", line 740, in __init__ + super().__init__(open(name, mode)) + ^^^^^^^^^^^^^^^^ + FileNotFoundError: [Errno 2] No such file or directory: 'model.pt' +********************************************************************** +File "None", line ?, in default +Failed example: + model.load_pdb('structure.pdb') +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + model.load_pdb('structure.pdb') + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model_ft.py", line 140, in load_pdb + super().load_pdb(filename) + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model.py", line 231, in load_pdb + reader = pdb.PDBReader(verbose=self.verbose).read(file) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/pdb.py", line 339, in read + self.dataframe = load_as_dataframe(filepath) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/pdb.py", line 149, in load_as_dataframe + skipheader = find_header_length(filepath) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/pdb.py", line 67, in find_header_length + with open(filepath, "r") as f: + ^^^^^^^^^^^^^^^^^^^ + FileNotFoundError: [Errno 2] No such file or directory: 'structure.pdb' +********************************************************************** +File "None", line ?, in default +Failed example: + model = ModelFT().load_pdb('structure.pdb') +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + model = ModelFT().load_pdb('structure.pdb') + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model_ft.py", line 140, in load_pdb + super().load_pdb(filename) + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model.py", line 231, in load_pdb + reader = pdb.PDBReader(verbose=self.verbose).read(file) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/pdb.py", line 339, in read + self.dataframe = load_as_dataframe(filepath) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/pdb.py", line 149, in load_as_dataframe + skipheader = find_header_length(filepath) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/pdb.py", line 67, in find_header_length + with open(filepath, "r") as f: + ^^^^^^^^^^^^^^^^^^^ + FileNotFoundError: [Errno 2] No such file or directory: 'structure.pdb' +********************************************************************** +File "None", line ?, in default +Failed example: + model_copy = model.copy() +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + model_copy = model.copy() + ^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model_ft.py", line 670, in copy + raise RuntimeError("Cannot copy an uninitialized ModelFT. Load data first.") + RuntimeError: Cannot copy an uninitialized ModelFT. Load data first. +********************************************************************** +File "None", line ?, in default +Failed example: + model = Model().load_pdb('structure.pdb') +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + model = Model().load_pdb('structure.pdb') + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model.py", line 231, in load_pdb + reader = pdb.PDBReader(verbose=self.verbose).read(file) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/pdb.py", line 339, in read + self.dataframe = load_as_dataframe(filepath) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/pdb.py", line 149, in load_as_dataframe + skipheader = find_header_length(filepath) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/pdb.py", line 67, in find_header_length + with open(filepath, "r") as f: + ^^^^^^^^^^^^^^^^^^^ + FileNotFoundError: [Errno 2] No such file or directory: 'structure.pdb' +********************************************************************** +File "None", line ?, in default +Failed example: + chain_a = model.select("chain A") +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + chain_a = model.select("chain A") + ^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model_ft.py", line 146, in select + selection = super().select(selection) + ^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model.py", line 1473, in select + raise RuntimeError( + RuntimeError: Cannot select from an uninitialized Model. Load data first. +********************************************************************** +File "None", line ?, in default +Failed example: + backbone = model.select("name CA or name C or name N or name O") +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + backbone = model.select("name CA or name C or name N or name O") + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model_ft.py", line 146, in select + selection = super().select(selection) + ^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model.py", line 1473, in select + raise RuntimeError( + RuntimeError: Cannot select from an uninitialized Model. Load data first. +********************************************************************** +File "None", line ?, in default +Failed example: + region = model.select("chain B and resseq 10:50") +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + region = model.select("chain B and resseq 10:50") + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model_ft.py", line 146, in select + selection = super().select(selection) + ^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model.py", line 1473, in select + raise RuntimeError( + RuntimeError: Cannot select from an uninitialized Model. Load data first. +********************************************************************** +File "None", line ?, in default +Failed example: + no_water = model.select("not resname HOH") +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + no_water = model.select("not resname HOH") + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model_ft.py", line 146, in select + selection = super().select(selection) + ^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model.py", line 1473, in select + raise RuntimeError( + RuntimeError: Cannot select from an uninitialized Model. Load data first. +********************************************************************** +File "None", line ?, in default +Failed example: + complex_sel = model.select("chain A and (resname ALA or resname GLY)") +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + complex_sel = model.select("chain A and (resname ALA or resname GLY)") + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model_ft.py", line 146, in select + selection = super().select(selection) + ^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model.py", line 1473, in select + raise RuntimeError( + RuntimeError: Cannot select from an uninitialized Model. Load data first. +********************************************************************** +File "None", line ?, in default +Failed example: + model.load_state_dict(torch.load('model.pt')) +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + model.load_state_dict(torch.load('model.pt')) + ^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/site-packages/torch/serialization.py", line 1484, in load + with _open_file_like(f, "rb") as opened_file: + ^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/site-packages/torch/serialization.py", line 759, in _open_file_like + return _open_file(name_or_buffer, mode) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/site-packages/torch/serialization.py", line 740, in __init__ + super().__init__(open(name, mode)) + ^^^^^^^^^^^^^^^^ + FileNotFoundError: [Errno 2] No such file or directory: 'model.pt' +********************************************************************** +File "None", line ?, in default +Failed example: + model.load_pdb('structure.pdb') +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + model.load_pdb('structure.pdb') + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model.py", line 231, in load_pdb + reader = pdb.PDBReader(verbose=self.verbose).read(file) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/pdb.py", line 339, in read + self.dataframe = load_as_dataframe(filepath) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/pdb.py", line 149, in load_as_dataframe + skipheader = find_header_length(filepath) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/pdb.py", line 67, in find_header_length + with open(filepath, "r") as f: + ^^^^^^^^^^^^^^^^^^^ + FileNotFoundError: [Errno 2] No such file or directory: 'structure.pdb' +********************************************************************** +File "None", line ?, in default +Failed example: + loss = xray_loss + w_adp * model.adp_kl_divergence_loss(0.2) +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + loss = xray_loss + w_adp * model.adp_kl_divergence_loss(0.2) + ^^^^^^^^^ + NameError: name 'xray_loss' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + structure_factor_loss = compute_structure_factor_loss() +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + structure_factor_loss = compute_structure_factor_loss() + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + NameError: name 'compute_structure_factor_loss' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + nll_reg = model.adp_nll_loss(target_log_std=0.2) +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + nll_reg = model.adp_nll_loss(target_log_std=0.2) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model.py", line 986, in adp_nll_loss + log_b = super(PositiveMixedTensor, self.b).forward() + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + AttributeError: 'super' object has no attribute 'forward' +********************************************************************** +File "None", line ?, in default +Failed example: + total_loss = structure_factor_loss + 0.01 * nll_reg +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + total_loss = structure_factor_loss + 0.01 * nll_reg + ^^^^^^^^^^^^^^^^^^^^^ + NameError: name 'structure_factor_loss' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + total_loss.backward() +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + total_loss.backward() + ^^^^^^^^^^ + NameError: name 'total_loss' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + atom_nll = model.adp_nll_loss_per_atom(target_log_std=0.2) +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + atom_nll = model.adp_nll_loss_per_atom(target_log_std=0.2) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model.py", line 1041, in adp_nll_loss_per_atom + log_b = super(PositiveMixedTensor, self.b).forward() + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + AttributeError: 'super' object has no attribute 'forward' +********************************************************************** +File "None", line ?, in default +Failed example: + threshold = atom_nll.mean() + 2 * atom_nll.std() +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + threshold = atom_nll.mean() + 2 * atom_nll.std() + ^^^^^^^^ + NameError: name 'atom_nll' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + outliers = atom_nll > threshold +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + outliers = atom_nll > threshold + ^^^^^^^^ + NameError: name 'atom_nll' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + model.update_mask_from_selection("chain A", "xyz", freeze=True) +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + model.update_mask_from_selection("chain A", "xyz", freeze=True) + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model.py", line 646, in update_mask_from_selection + current_mask = getattr(self, mask_name) + ^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/site-packages/torch/nn/modules/module.py", line 1964, in __getattr__ + raise AttributeError( + AttributeError: 'Model' object has no attribute 'xyz_mask' +********************************************************************** +File "None", line ?, in default +Failed example: + model.apply_mask_to_parameter("xyz") +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + model.apply_mask_to_parameter("xyz") + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model.py", line 699, in apply_mask_to_parameter + self.xyz.update_refinable_mask(self.xyz_mask) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + AttributeError: 'NoneType' object has no attribute 'update_refinable_mask' +********************************************************************** +File "None", line ?, in default +Failed example: + model = Model().load_pdb('structure.pdb') +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + model = Model().load_pdb('structure.pdb') + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model.py", line 231, in load_pdb + reader = pdb.PDBReader(verbose=self.verbose).read(file) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/pdb.py", line 339, in read + self.dataframe = load_as_dataframe(filepath) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/pdb.py", line 149, in load_as_dataframe + skipheader = find_header_length(filepath) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/pdb.py", line 67, in find_header_length + with open(filepath, "r") as f: + ^^^^^^^^^^^^^^^^^^^ + FileNotFoundError: [Errno 2] No such file or directory: 'structure.pdb' +********************************************************************** +File "None", line ?, in default +Failed example: + model_copy = model.copy() +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + model_copy = model.copy() + ^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model.py", line 490, in copy + raise RuntimeError("Cannot copy an uninitialized Model. Load data first.") + RuntimeError: Cannot copy an uninitialized Model. Load data first. +********************************************************************** +File "None", line ?, in default +Failed example: + model.freeze_selection("chain A", targets='all') +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + model.freeze_selection("chain A", targets='all') + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model.py", line 752, in freeze_selection + self.update_mask_from_selection( + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model.py", line 646, in update_mask_from_selection + current_mask = getattr(self, mask_name) + ^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/site-packages/torch/nn/modules/module.py", line 1964, in __getattr__ + raise AttributeError( + AttributeError: 'Model' object has no attribute 'xyz_mask' +********************************************************************** +File "None", line ?, in default +Failed example: + model.freeze_selection("resseq 10:20", targets='xyz') +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + model.freeze_selection("resseq 10:20", targets='xyz') + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model.py", line 752, in freeze_selection + self.update_mask_from_selection( + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model.py", line 646, in update_mask_from_selection + current_mask = getattr(self, mask_name) + ^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/site-packages/torch/nn/modules/module.py", line 1964, in __getattr__ + raise AttributeError( + AttributeError: 'Model' object has no attribute 'xyz_mask' +********************************************************************** +File "None", line ?, in default +Failed example: + model = Model().load_pdb('structure.pdb') +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + model = Model().load_pdb('structure.pdb') + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model.py", line 231, in load_pdb + reader = pdb.PDBReader(verbose=self.verbose).read(file) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/pdb.py", line 339, in read + self.dataframe = load_as_dataframe(filepath) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/pdb.py", line 149, in load_as_dataframe + skipheader = find_header_length(filepath) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/pdb.py", line 67, in find_header_length + with open(filepath, "r") as f: + ^^^^^^^^^^^^^^^^^^^ + FileNotFoundError: [Errno 2] No such file or directory: 'structure.pdb' +********************************************************************** +File "None", line ?, in default +Failed example: + mask = model.get_selection_mask("chain A") +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + mask = model.get_selection_mask("chain A") + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model.py", line 1407, in get_selection_mask + raise RuntimeError( + RuntimeError: Cannot get selection mask from an uninitialized Model. Load data first. +********************************************************************** +File "None", line ?, in default +Failed example: + new_coords = model.xyz()[mask] + translation +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + new_coords = model.xyz()[mask] + translation + ^^^^^^^^^^^ + TypeError: 'NoneType' object is not callable +********************************************************************** +File "None", line ?, in default +Failed example: + model.xyz.set(new_coords, mask) +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + model.xyz.set(new_coords, mask) + ^^^^^^^^^^^^^ + AttributeError: 'NoneType' object has no attribute 'set' +********************************************************************** +File "None", line ?, in default +Failed example: + backbone_mask = model.get_selection_mask("name CA or name C or name N or name O") +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + backbone_mask = model.get_selection_mask("name CA or name C or name N or name O") + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model.py", line 1407, in get_selection_mask + raise RuntimeError( + RuntimeError: Cannot get selection mask from an uninitialized Model. Load data first. +********************************************************************** +File "None", line ?, in default +Failed example: + mask = model.get_selection_mask("chain A and (resname ALA or resname GLY)") +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + mask = model.get_selection_mask("chain A and (resname ALA or resname GLY)") + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model.py", line 1407, in get_selection_mask + raise RuntimeError( + RuntimeError: Cannot get selection mask from an uninitialized Model. Load data first. +********************************************************************** +File "None", line ?, in default +Failed example: + model = Model().load_pdb('structure.pdb') +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + model = Model().load_pdb('structure.pdb') + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model.py", line 231, in load_pdb + reader = pdb.PDBReader(verbose=self.verbose).read(file) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/pdb.py", line 339, in read + self.dataframe = load_as_dataframe(filepath) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/pdb.py", line 149, in load_as_dataframe + skipheader = find_header_length(filepath) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/pdb.py", line 67, in find_header_length + with open(filepath, "r") as f: + ^^^^^^^^^^^^^^^^^^^ + FileNotFoundError: [Errno 2] No such file or directory: 'structure.pdb' +********************************************************************** +File "None", line ?, in default +Failed example: + chain_a = model.select("chain A") +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + chain_a = model.select("chain A") + ^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model.py", line 1473, in select + raise RuntimeError( + RuntimeError: Cannot select from an uninitialized Model. Load data first. +********************************************************************** +File "None", line ?, in default +Failed example: + backbone = model.select("name CA or name C or name N or name O") +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + backbone = model.select("name CA or name C or name N or name O") + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model.py", line 1473, in select + raise RuntimeError( + RuntimeError: Cannot select from an uninitialized Model. Load data first. +********************************************************************** +File "None", line ?, in default +Failed example: + region = model.select("chain B and resseq 10:50") +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + region = model.select("chain B and resseq 10:50") + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model.py", line 1473, in select + raise RuntimeError( + RuntimeError: Cannot select from an uninitialized Model. Load data first. +********************************************************************** +File "None", line ?, in default +Failed example: + no_water = model.select("not resname HOH") +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + no_water = model.select("not resname HOH") + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model.py", line 1473, in select + raise RuntimeError( + RuntimeError: Cannot select from an uninitialized Model. Load data first. +********************************************************************** +File "None", line ?, in default +Failed example: + complex_sel = model.select("chain A and (resname ALA or resname GLY)") +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + complex_sel = model.select("chain A and (resname ALA or resname GLY)") + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model.py", line 1473, in select + raise RuntimeError( + RuntimeError: Cannot select from an uninitialized Model. Load data first. +********************************************************************** +File "None", line ?, in default +Failed example: + model.unfreeze_selection("chain A", targets='all') +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + model.unfreeze_selection("chain A", targets='all') + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model.py", line 792, in unfreeze_selection + self.update_mask_from_selection( + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model.py", line 646, in update_mask_from_selection + current_mask = getattr(self, mask_name) + ^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/site-packages/torch/nn/modules/module.py", line 1964, in __getattr__ + raise AttributeError( + AttributeError: 'Model' object has no attribute 'xyz_mask' +********************************************************************** +File "None", line ?, in default +Failed example: + model.unfreeze_selection("name CA or name C or name N", targets='xyz') +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + model.unfreeze_selection("name CA or name C or name N", targets='xyz') + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model.py", line 792, in unfreeze_selection + self.update_mask_from_selection( + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model.py", line 646, in update_mask_from_selection + current_mask = getattr(self, mask_name) + ^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/site-packages/torch/nn/modules/module.py", line 1964, in __getattr__ + raise AttributeError( + AttributeError: 'Model' object has no attribute 'xyz_mask' +********************************************************************** +File "None", line ?, in default +Failed example: + model.update_mask_from_selection("chain A", "xyz", mode='set', freeze=True) +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + model.update_mask_from_selection("chain A", "xyz", mode='set', freeze=True) + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model.py", line 646, in update_mask_from_selection + current_mask = getattr(self, mask_name) + ^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/site-packages/torch/nn/modules/module.py", line 1964, in __getattr__ + raise AttributeError( + AttributeError: 'Model' object has no attribute 'xyz_mask' +********************************************************************** +File "None", line ?, in default +Failed example: + model.apply_mask_to_parameter("xyz") +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + model.apply_mask_to_parameter("xyz") + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model.py", line 699, in apply_mask_to_parameter + self.xyz.update_refinable_mask(self.xyz_mask) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + AttributeError: 'NoneType' object has no attribute 'update_refinable_mask' +********************************************************************** +File "None", line ?, in default +Failed example: + model.update_mask_from_selection("name CA or name C or name N", "xyz", freeze=False) +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + model.update_mask_from_selection("name CA or name C or name N", "xyz", freeze=False) + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model.py", line 646, in update_mask_from_selection + current_mask = getattr(self, mask_name) + ^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/site-packages/torch/nn/modules/module.py", line 1964, in __getattr__ + raise AttributeError( + AttributeError: 'Model' object has no attribute 'xyz_mask' +********************************************************************** +File "None", line ?, in default +Failed example: + model.apply_mask_to_parameter("xyz") +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + model.apply_mask_to_parameter("xyz") + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model.py", line 699, in apply_mask_to_parameter + self.xyz.update_refinable_mask(self.xyz_mask) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + AttributeError: 'NoneType' object has no attribute 'update_refinable_mask' +********************************************************************** +File "None", line ?, in default +Failed example: + mixed.load_state_dict(torch.load('mixed.pt')) +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + mixed.load_state_dict(torch.load('mixed.pt')) + ^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/site-packages/torch/serialization.py", line 1484, in load + with _open_file_like(f, "rb") as opened_file: + ^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/site-packages/torch/serialization.py", line 759, in _open_file_like + return _open_file(name_or_buffer, mode) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/site-packages/torch/serialization.py", line 740, in __init__ + super().__init__(open(name, mode)) + ^^^^^^^^^^^^^^^^ + FileNotFoundError: [Errno 2] No such file or directory: 'mixed.pt' +********************************************************************** +File "None", line ?, in default +Failed example: + model.b[5] # Get B-factor for atom 5 +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + model.b[5] # Get B-factor for atom 5 + ~~~~~~~^^^ + TypeError: 'NoneType' object is not subscriptable +********************************************************************** +File "None", line ?, in default +Failed example: + model.b[5:10] # Get B-factors for atoms 5-9 +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + model.b[5:10] # Get B-factors for atoms 5-9 + ~~~~~~~^^^^^^ + TypeError: 'NoneType' object is not subscriptable +********************************************************************** +File "None", line ?, in default +Failed example: + model.b[mask] # Get B-factors where mask is True +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + model.b[mask] # Get B-factors where mask is True + ~~~~~~~^^^^^^ + TypeError: 'NoneType' object is not subscriptable +********************************************************************** +File "None", line ?, in default +Failed example: + model.xyz[:, 0] # Get all x-coordinates +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + model.xyz[:, 0] # Get all x-coordinates + ~~~~~~~~~^^^^^^ + TypeError: 'NoneType' object is not subscriptable +********************************************************************** +File "None", line ?, in default +Failed example: + model.b[:] = 30.0 # Set all B-factors to 30 +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + model.b[:] = 30.0 # Set all B-factors to 30 + ~~~~~~~^^^ + TypeError: 'NoneType' object does not support item assignment +********************************************************************** +File "None", line ?, in default +Failed example: + model.b[5:10] = 25.0 # Set B-factors 5-9 to 25 +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + model.b[5:10] = 25.0 # Set B-factors 5-9 to 25 + ~~~~~~~^^^^^^ + TypeError: 'NoneType' object does not support item assignment +********************************************************************** +File "None", line ?, in default +Failed example: + model.b[mask] = new_values # Set B-factors where mask is True +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + model.b[mask] = new_values # Set B-factors where mask is True + ^^^^^^^^^^ + NameError: name 'new_values' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + model.xyz[mask] = new_coords # Set coordinates for masked atoms +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + model.xyz[mask] = new_coords # Set coordinates for masked atoms + ^^^^^^^^^^ + NameError: name 'new_coords' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + model.xyz[:, 0] += 1.0 # Shift all x-coordinates (read-modify-write) +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + model.xyz[:, 0] += 1.0 # Shift all x-coordinates (read-modify-write) + ~~~~~~~~~^^^^^^ + TypeError: 'NoneType' object is not subscriptable +********************************************************************** +File "None", line ?, in default +Failed example: + mask = model.get_selection_mask("chain A") +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + mask = model.get_selection_mask("chain A") + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model.py", line 1407, in get_selection_mask + raise RuntimeError( + RuntimeError: Cannot get selection mask from an uninitialized Model. Load data first. +********************************************************************** +File "None", line ?, in default +Failed example: + new_coords = original_coords[mask] + shift +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + new_coords = original_coords[mask] + shift + ^^^^^^^^^^^^^^^ + NameError: name 'original_coords' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + model.xyz.set(new_coords, mask) +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + model.xyz.set(new_coords, mask) + ^^^^^^^^^^^^^ + AttributeError: 'NoneType' object has no attribute 'set' +********************************************************************** +File "None", line ?, in default +Failed example: + mask = model.get_selection_mask("resseq 10:20") +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + mask = model.get_selection_mask("resseq 10:20") + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model.py", line 1407, in get_selection_mask + raise RuntimeError( + RuntimeError: Cannot get selection mask from an uninitialized Model. Load data first. +********************************************************************** +File "None", line ?, in default +Failed example: + model.b.set(new_b, mask) +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + model.b.set(new_b, mask) + ^^^^^^^^^^^ + AttributeError: 'NoneType' object has no attribute 'set' +********************************************************************** +File "None", line ?, in default +Failed example: + b = PositiveMixedTensor() +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + b = PositiveMixedTensor() + ^^^^^^^^^^^^^^^^^^^ + NameError: name 'PositiveMixedTensor' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + b.load_state_dict(torch.load('b_factors.pt')) +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + b.load_state_dict(torch.load('b_factors.pt')) + ^^^^^^^^^^^^^^^^^ + AttributeError: 'NoneType' object has no attribute 'load_state_dict' +********************************************************************** +File "None", line ?, in default +Failed example: + b = PositiveMixedTensor(initial_b) +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + b = PositiveMixedTensor(initial_b) + ^^^^^^^^^^^^^^^^^^^ + NameError: name 'PositiveMixedTensor' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + output = b() # Returns exp(log_b) = positive values +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + output = b() # Returns exp(log_b) = positive values + ^^^ + TypeError: 'NoneType' object is not callable +********************************************************************** +File "None", line ?, in default +Failed example: + assert (b() > 0).all() +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + assert (b() > 0).all() + ^^^ + TypeError: 'NoneType' object is not callable +********************************************************************** +File "None", line ?, in default +Failed example: + mask = model.get_selection_mask("name CA") +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + mask = model.get_selection_mask("name CA") + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model.py", line 1407, in get_selection_mask + raise RuntimeError( + RuntimeError: Cannot get selection mask from an uninitialized Model. Load data first. +********************************************************************** +File "None", line ?, in default +Failed example: + model.b.set(new_b, mask) +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + model.b.set(new_b, mask) + ^^^^^^^^^^^ + AttributeError: 'NoneType' object has no attribute 'set' +********************************************************************** +File "None", line ?, in default +Failed example: + occ = OccupancyTensor( + initial_values=torch.tensor([1.0, 1.0, 0.7, 0.7, 0.3, 0.3]), + sharing_groups=sharing_groups, + altloc_groups=[([2, 3], [4, 5])], + ) +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + occ = OccupancyTensor( + ^^^^^^^^^^^^^^^ + NameError: name 'OccupancyTensor' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + result = occ() # Atoms 2-3 and 4-5 will sum to 1.0 +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + result = occ() # Atoms 2-3 and 4-5 will sum to 1.0 + ^^^ + NameError: name 'occ' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + freeze_mask = torch.zeros(n_atoms, dtype=torch.bool) +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + freeze_mask = torch.zeros(n_atoms, dtype=torch.bool) + ^^^^^^^ + NameError: name 'n_atoms' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + freeze_mask[0:11] = True +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + freeze_mask[0:11] = True + ^^^^^^^^^^^ + NameError: name 'freeze_mask' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + occ.freeze(freeze_mask) +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + occ.freeze(freeze_mask) + ^^^ + NameError: name 'occ' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + occ.freeze() +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + occ.freeze() + ^^^ + NameError: name 'occ' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + unfreeze_mask = torch.zeros(n_atoms, dtype=torch.bool) +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + unfreeze_mask = torch.zeros(n_atoms, dtype=torch.bool) + ^^^^^^^ + NameError: name 'n_atoms' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + unfreeze_mask[100:201] = True +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + unfreeze_mask[100:201] = True + ^^^^^^^^^^^^^ + NameError: name 'unfreeze_mask' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + occ.unfreeze(unfreeze_mask) +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + occ.unfreeze(unfreeze_mask) + ^^^ + NameError: name 'occ' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + occ.unfreeze() +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + occ.unfreeze() + ^^^ + NameError: name 'occ' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + atom_mask = torch.zeros(n_atoms, dtype=torch.bool) +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + atom_mask = torch.zeros(n_atoms, dtype=torch.bool) + ^^^^^^^ + NameError: name 'n_atoms' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + atom_mask[:100] = True +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + atom_mask[:100] = True + ^^^^^^^^^ + NameError: name 'atom_mask' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + occ.update_refinable_mask(atom_mask, in_compressed_space=False) +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + occ.update_refinable_mask(atom_mask, in_compressed_space=False) + ^^^ + NameError: name 'occ' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + group_mask = torch.zeros(n_groups, dtype=torch.bool) +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + group_mask = torch.zeros(n_groups, dtype=torch.bool) + ^^^^^^^^ + NameError: name 'n_groups' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + group_mask[::2] = True +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + group_mask[::2] = True + ^^^^^^^^^^ + NameError: name 'group_mask' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + occ.update_refinable_mask(group_mask, in_compressed_space=True) +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + occ.update_refinable_mask(group_mask, in_compressed_space=True) + ^^^ + NameError: name 'occ' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + model.load_state_dict(torch.load('model.pt')) +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + model.load_state_dict(torch.load('model.pt')) + ^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/site-packages/torch/serialization.py", line 1484, in load + with _open_file_like(f, "rb") as opened_file: + ^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/site-packages/torch/serialization.py", line 759, in _open_file_like + return _open_file(name_or_buffer, mode) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/site-packages/torch/serialization.py", line 740, in __init__ + super().__init__(open(name, mode)) + ^^^^^^^^^^^^^^^^ + FileNotFoundError: [Errno 2] No such file or directory: 'model.pt' +********************************************************************** +File "None", line ?, in default +Failed example: + model.load_pdb('structure.pdb') +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + model.load_pdb('structure.pdb') + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model.py", line 231, in load_pdb + reader = pdb.PDBReader(verbose=self.verbose).read(file) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/pdb.py", line 339, in read + self.dataframe = load_as_dataframe(filepath) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/pdb.py", line 149, in load_as_dataframe + skipheader = find_header_length(filepath) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/pdb.py", line 67, in find_header_length + with open(filepath, "r") as f: + ^^^^^^^^^^^^^^^^^^^ + FileNotFoundError: [Errno 2] No such file or directory: 'structure.pdb' +********************************************************************** +File "None", line ?, in default +Failed example: + model = Model().load_pdb('structure.pdb') +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + model = Model().load_pdb('structure.pdb') + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model.py", line 231, in load_pdb + reader = pdb.PDBReader(verbose=self.verbose).read(file) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/pdb.py", line 339, in read + self.dataframe = load_as_dataframe(filepath) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/pdb.py", line 149, in load_as_dataframe + skipheader = find_header_length(filepath) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/pdb.py", line 67, in find_header_length + with open(filepath, "r") as f: + ^^^^^^^^^^^^^^^^^^^ + FileNotFoundError: [Errno 2] No such file or directory: 'structure.pdb' +********************************************************************** +File "None", line ?, in default +Failed example: + model_copy = model.copy() +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + model_copy = model.copy() + ^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model.py", line 490, in copy + raise RuntimeError("Cannot copy an uninitialized Model. Load data first.") + RuntimeError: Cannot copy an uninitialized Model. Load data first. +********************************************************************** +File "None", line ?, in default +Failed example: + model.update_mask_from_selection("chain A", "xyz", mode='set', freeze=True) +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + model.update_mask_from_selection("chain A", "xyz", mode='set', freeze=True) + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model.py", line 646, in update_mask_from_selection + current_mask = getattr(self, mask_name) + ^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/site-packages/torch/nn/modules/module.py", line 1964, in __getattr__ + raise AttributeError( + AttributeError: 'Model' object has no attribute 'xyz_mask' +********************************************************************** +File "None", line ?, in default +Failed example: + model.apply_mask_to_parameter("xyz") +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + model.apply_mask_to_parameter("xyz") + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model.py", line 699, in apply_mask_to_parameter + self.xyz.update_refinable_mask(self.xyz_mask) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + AttributeError: 'NoneType' object has no attribute 'update_refinable_mask' +********************************************************************** +File "None", line ?, in default +Failed example: + model.update_mask_from_selection("name CA or name C or name N", "xyz", freeze=False) +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + model.update_mask_from_selection("name CA or name C or name N", "xyz", freeze=False) + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model.py", line 646, in update_mask_from_selection + current_mask = getattr(self, mask_name) + ^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/site-packages/torch/nn/modules/module.py", line 1964, in __getattr__ + raise AttributeError( + AttributeError: 'Model' object has no attribute 'xyz_mask' +********************************************************************** +File "None", line ?, in default +Failed example: + model.apply_mask_to_parameter("xyz") +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + model.apply_mask_to_parameter("xyz") + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model.py", line 699, in apply_mask_to_parameter + self.xyz.update_refinable_mask(self.xyz_mask) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + AttributeError: 'NoneType' object has no attribute 'update_refinable_mask' +********************************************************************** +File "None", line ?, in default +Failed example: + model.update_mask_from_selection("chain A", "xyz", freeze=True) +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + model.update_mask_from_selection("chain A", "xyz", freeze=True) + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model.py", line 646, in update_mask_from_selection + current_mask = getattr(self, mask_name) + ^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/site-packages/torch/nn/modules/module.py", line 1964, in __getattr__ + raise AttributeError( + AttributeError: 'Model' object has no attribute 'xyz_mask' +********************************************************************** +File "None", line ?, in default +Failed example: + model.apply_mask_to_parameter("xyz") +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + model.apply_mask_to_parameter("xyz") + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model.py", line 699, in apply_mask_to_parameter + self.xyz.update_refinable_mask(self.xyz_mask) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + AttributeError: 'NoneType' object has no attribute 'update_refinable_mask' +********************************************************************** +File "None", line ?, in default +Failed example: + model.freeze_selection("chain A", targets='all') +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + model.freeze_selection("chain A", targets='all') + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model.py", line 752, in freeze_selection + self.update_mask_from_selection( + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model.py", line 646, in update_mask_from_selection + current_mask = getattr(self, mask_name) + ^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/site-packages/torch/nn/modules/module.py", line 1964, in __getattr__ + raise AttributeError( + AttributeError: 'Model' object has no attribute 'xyz_mask' +********************************************************************** +File "None", line ?, in default +Failed example: + model.freeze_selection("resseq 10:20", targets='xyz') +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + model.freeze_selection("resseq 10:20", targets='xyz') + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model.py", line 752, in freeze_selection + self.update_mask_from_selection( + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model.py", line 646, in update_mask_from_selection + current_mask = getattr(self, mask_name) + ^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/site-packages/torch/nn/modules/module.py", line 1964, in __getattr__ + raise AttributeError( + AttributeError: 'Model' object has no attribute 'xyz_mask' +********************************************************************** +File "None", line ?, in default +Failed example: + model.unfreeze_selection("chain A", targets='all') +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + model.unfreeze_selection("chain A", targets='all') + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model.py", line 792, in unfreeze_selection + self.update_mask_from_selection( + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model.py", line 646, in update_mask_from_selection + current_mask = getattr(self, mask_name) + ^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/site-packages/torch/nn/modules/module.py", line 1964, in __getattr__ + raise AttributeError( + AttributeError: 'Model' object has no attribute 'xyz_mask' +********************************************************************** +File "None", line ?, in default +Failed example: + model.unfreeze_selection("name CA or name C or name N", targets='xyz') +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + model.unfreeze_selection("name CA or name C or name N", targets='xyz') + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model.py", line 792, in unfreeze_selection + self.update_mask_from_selection( + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model.py", line 646, in update_mask_from_selection + current_mask = getattr(self, mask_name) + ^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/site-packages/torch/nn/modules/module.py", line 1964, in __getattr__ + raise AttributeError( + AttributeError: 'Model' object has no attribute 'xyz_mask' +********************************************************************** +File "None", line ?, in default +Failed example: + structure_factor_loss = compute_structure_factor_loss() +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + structure_factor_loss = compute_structure_factor_loss() + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + NameError: name 'compute_structure_factor_loss' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + nll_reg = model.adp_nll_loss(target_log_std=0.2) +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + nll_reg = model.adp_nll_loss(target_log_std=0.2) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model.py", line 986, in adp_nll_loss + log_b = super(PositiveMixedTensor, self.b).forward() + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + AttributeError: 'super' object has no attribute 'forward' +********************************************************************** +File "None", line ?, in default +Failed example: + total_loss = structure_factor_loss + 0.01 * nll_reg +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + total_loss = structure_factor_loss + 0.01 * nll_reg + ^^^^^^^^^^^^^^^^^^^^^ + NameError: name 'structure_factor_loss' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + total_loss.backward() +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + total_loss.backward() + ^^^^^^^^^^ + NameError: name 'total_loss' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + atom_nll = model.adp_nll_loss_per_atom(target_log_std=0.2) +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + atom_nll = model.adp_nll_loss_per_atom(target_log_std=0.2) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model.py", line 1041, in adp_nll_loss_per_atom + log_b = super(PositiveMixedTensor, self.b).forward() + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + AttributeError: 'super' object has no attribute 'forward' +********************************************************************** +File "None", line ?, in default +Failed example: + threshold = atom_nll.mean() + 2 * atom_nll.std() +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + threshold = atom_nll.mean() + 2 * atom_nll.std() + ^^^^^^^^ + NameError: name 'atom_nll' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + outliers = atom_nll > threshold +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + outliers = atom_nll > threshold + ^^^^^^^^ + NameError: name 'atom_nll' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + loss = xray_loss + w_adp * model.adp_kl_divergence_loss(0.2) +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + loss = xray_loss + w_adp * model.adp_kl_divergence_loss(0.2) + ^^^^^^^^^ + NameError: name 'xray_loss' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + model = Model().load_pdb('structure.pdb') +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + model = Model().load_pdb('structure.pdb') + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model.py", line 231, in load_pdb + reader = pdb.PDBReader(verbose=self.verbose).read(file) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/pdb.py", line 339, in read + self.dataframe = load_as_dataframe(filepath) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/pdb.py", line 149, in load_as_dataframe + skipheader = find_header_length(filepath) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/pdb.py", line 67, in find_header_length + with open(filepath, "r") as f: + ^^^^^^^^^^^^^^^^^^^ + FileNotFoundError: [Errno 2] No such file or directory: 'structure.pdb' +********************************************************************** +File "None", line ?, in default +Failed example: + mask = model.get_selection_mask("chain A") +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + mask = model.get_selection_mask("chain A") + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model.py", line 1407, in get_selection_mask + raise RuntimeError( + RuntimeError: Cannot get selection mask from an uninitialized Model. Load data first. +********************************************************************** +File "None", line ?, in default +Failed example: + new_coords = model.xyz()[mask] + translation +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + new_coords = model.xyz()[mask] + translation + ^^^^^^^^^^^ + TypeError: 'NoneType' object is not callable +********************************************************************** +File "None", line ?, in default +Failed example: + model.xyz.set(new_coords, mask) +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + model.xyz.set(new_coords, mask) + ^^^^^^^^^^^^^ + AttributeError: 'NoneType' object has no attribute 'set' +********************************************************************** +File "None", line ?, in default +Failed example: + backbone_mask = model.get_selection_mask("name CA or name C or name N or name O") +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + backbone_mask = model.get_selection_mask("name CA or name C or name N or name O") + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model.py", line 1407, in get_selection_mask + raise RuntimeError( + RuntimeError: Cannot get selection mask from an uninitialized Model. Load data first. +********************************************************************** +File "None", line ?, in default +Failed example: + mask = model.get_selection_mask("chain A and (resname ALA or resname GLY)") +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + mask = model.get_selection_mask("chain A and (resname ALA or resname GLY)") + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model.py", line 1407, in get_selection_mask + raise RuntimeError( + RuntimeError: Cannot get selection mask from an uninitialized Model. Load data first. +********************************************************************** +File "None", line ?, in default +Failed example: + model = Model().load_pdb('structure.pdb') +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + model = Model().load_pdb('structure.pdb') + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model.py", line 231, in load_pdb + reader = pdb.PDBReader(verbose=self.verbose).read(file) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/pdb.py", line 339, in read + self.dataframe = load_as_dataframe(filepath) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/pdb.py", line 149, in load_as_dataframe + skipheader = find_header_length(filepath) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/pdb.py", line 67, in find_header_length + with open(filepath, "r") as f: + ^^^^^^^^^^^^^^^^^^^ + FileNotFoundError: [Errno 2] No such file or directory: 'structure.pdb' +********************************************************************** +File "None", line ?, in default +Failed example: + chain_a = model.select("chain A") +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + chain_a = model.select("chain A") + ^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model.py", line 1473, in select + raise RuntimeError( + RuntimeError: Cannot select from an uninitialized Model. Load data first. +********************************************************************** +File "None", line ?, in default +Failed example: + backbone = model.select("name CA or name C or name N or name O") +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + backbone = model.select("name CA or name C or name N or name O") + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model.py", line 1473, in select + raise RuntimeError( + RuntimeError: Cannot select from an uninitialized Model. Load data first. +********************************************************************** +File "None", line ?, in default +Failed example: + region = model.select("chain B and resseq 10:50") +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + region = model.select("chain B and resseq 10:50") + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model.py", line 1473, in select + raise RuntimeError( + RuntimeError: Cannot select from an uninitialized Model. Load data first. +********************************************************************** +File "None", line ?, in default +Failed example: + no_water = model.select("not resname HOH") +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + no_water = model.select("not resname HOH") + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model.py", line 1473, in select + raise RuntimeError( + RuntimeError: Cannot select from an uninitialized Model. Load data first. +********************************************************************** +File "None", line ?, in default +Failed example: + complex_sel = model.select("chain A and (resname ALA or resname GLY)") +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + complex_sel = model.select("chain A and (resname ALA or resname GLY)") + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model.py", line 1473, in select + raise RuntimeError( + RuntimeError: Cannot select from an uninitialized Model. Load data first. +********************************************************************** +File "None", line ?, in default +Failed example: + model.load_state_dict(torch.load('model.pt')) +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + model.load_state_dict(torch.load('model.pt')) + ^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/site-packages/torch/serialization.py", line 1484, in load + with _open_file_like(f, "rb") as opened_file: + ^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/site-packages/torch/serialization.py", line 759, in _open_file_like + return _open_file(name_or_buffer, mode) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/site-packages/torch/serialization.py", line 740, in __init__ + super().__init__(open(name, mode)) + ^^^^^^^^^^^^^^^^ + FileNotFoundError: [Errno 2] No such file or directory: 'model.pt' +********************************************************************** +File "None", line ?, in default +Failed example: + model.load_pdb('structure.pdb') +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + model.load_pdb('structure.pdb') + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model_ft.py", line 140, in load_pdb + super().load_pdb(filename) + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model.py", line 231, in load_pdb + reader = pdb.PDBReader(verbose=self.verbose).read(file) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/pdb.py", line 339, in read + self.dataframe = load_as_dataframe(filepath) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/pdb.py", line 149, in load_as_dataframe + skipheader = find_header_length(filepath) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/pdb.py", line 67, in find_header_length + with open(filepath, "r") as f: + ^^^^^^^^^^^^^^^^^^^ + FileNotFoundError: [Errno 2] No such file or directory: 'structure.pdb' +********************************************************************** +File "None", line ?, in default +Failed example: + model = Model().load_pdb('structure.pdb') +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + model = Model().load_pdb('structure.pdb') + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model.py", line 231, in load_pdb + reader = pdb.PDBReader(verbose=self.verbose).read(file) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/pdb.py", line 339, in read + self.dataframe = load_as_dataframe(filepath) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/pdb.py", line 149, in load_as_dataframe + skipheader = find_header_length(filepath) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/pdb.py", line 67, in find_header_length + with open(filepath, "r") as f: + ^^^^^^^^^^^^^^^^^^^ + FileNotFoundError: [Errno 2] No such file or directory: 'structure.pdb' +********************************************************************** +File "None", line ?, in default +Failed example: + chain_a = model.select("chain A") +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + chain_a = model.select("chain A") + ^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model_ft.py", line 146, in select + selection = super().select(selection) + ^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model.py", line 1473, in select + raise RuntimeError( + RuntimeError: Cannot select from an uninitialized Model. Load data first. +********************************************************************** +File "None", line ?, in default +Failed example: + backbone = model.select("name CA or name C or name N or name O") +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + backbone = model.select("name CA or name C or name N or name O") + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model_ft.py", line 146, in select + selection = super().select(selection) + ^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model.py", line 1473, in select + raise RuntimeError( + RuntimeError: Cannot select from an uninitialized Model. Load data first. +********************************************************************** +File "None", line ?, in default +Failed example: + region = model.select("chain B and resseq 10:50") +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + region = model.select("chain B and resseq 10:50") + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model_ft.py", line 146, in select + selection = super().select(selection) + ^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model.py", line 1473, in select + raise RuntimeError( + RuntimeError: Cannot select from an uninitialized Model. Load data first. +********************************************************************** +File "None", line ?, in default +Failed example: + no_water = model.select("not resname HOH") +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + no_water = model.select("not resname HOH") + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model_ft.py", line 146, in select + selection = super().select(selection) + ^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model.py", line 1473, in select + raise RuntimeError( + RuntimeError: Cannot select from an uninitialized Model. Load data first. +********************************************************************** +File "None", line ?, in default +Failed example: + complex_sel = model.select("chain A and (resname ALA or resname GLY)") +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + complex_sel = model.select("chain A and (resname ALA or resname GLY)") + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model_ft.py", line 146, in select + selection = super().select(selection) + ^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model.py", line 1473, in select + raise RuntimeError( + RuntimeError: Cannot select from an uninitialized Model. Load data first. +********************************************************************** +File "None", line ?, in default +Failed example: + model = ModelFT().load_pdb('structure.pdb') +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + model = ModelFT().load_pdb('structure.pdb') + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model_ft.py", line 140, in load_pdb + super().load_pdb(filename) + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model.py", line 231, in load_pdb + reader = pdb.PDBReader(verbose=self.verbose).read(file) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/pdb.py", line 339, in read + self.dataframe = load_as_dataframe(filepath) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/pdb.py", line 149, in load_as_dataframe + skipheader = find_header_length(filepath) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/pdb.py", line 67, in find_header_length + with open(filepath, "r") as f: + ^^^^^^^^^^^^^^^^^^^ + FileNotFoundError: [Errno 2] No such file or directory: 'structure.pdb' +********************************************************************** +File "None", line ?, in default +Failed example: + model_copy = model.copy() +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + model_copy = model.copy() + ^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model_ft.py", line 670, in copy + raise RuntimeError("Cannot copy an uninitialized ModelFT. Load data first.") + RuntimeError: Cannot copy an uninitialized ModelFT. Load data first. +********************************************************************** +File "None", line ?, in default +Failed example: + mixed.load_state_dict(torch.load('mixed.pt')) +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + mixed.load_state_dict(torch.load('mixed.pt')) + ^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/site-packages/torch/serialization.py", line 1484, in load + with _open_file_like(f, "rb") as opened_file: + ^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/site-packages/torch/serialization.py", line 759, in _open_file_like + return _open_file(name_or_buffer, mode) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/site-packages/torch/serialization.py", line 740, in __init__ + super().__init__(open(name, mode)) + ^^^^^^^^^^^^^^^^ + FileNotFoundError: [Errno 2] No such file or directory: 'mixed.pt' +********************************************************************** +File "None", line ?, in default +Failed example: + model.b[5] # Get B-factor for atom 5 +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + model.b[5] # Get B-factor for atom 5 + ~~~~~~~^^^ + TypeError: 'NoneType' object is not subscriptable +********************************************************************** +File "None", line ?, in default +Failed example: + model.b[5:10] # Get B-factors for atoms 5-9 +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + model.b[5:10] # Get B-factors for atoms 5-9 + ~~~~~~~^^^^^^ + TypeError: 'NoneType' object is not subscriptable +********************************************************************** +File "None", line ?, in default +Failed example: + model.b[mask] # Get B-factors where mask is True +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + model.b[mask] # Get B-factors where mask is True + ~~~~~~~^^^^^^ + TypeError: 'NoneType' object is not subscriptable +********************************************************************** +File "None", line ?, in default +Failed example: + model.xyz[:, 0] # Get all x-coordinates +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + model.xyz[:, 0] # Get all x-coordinates + ~~~~~~~~~^^^^^^ + TypeError: 'NoneType' object is not subscriptable +********************************************************************** +File "None", line ?, in default +Failed example: + model.b[:] = 30.0 # Set all B-factors to 30 +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + model.b[:] = 30.0 # Set all B-factors to 30 + ~~~~~~~^^^ + TypeError: 'NoneType' object does not support item assignment +********************************************************************** +File "None", line ?, in default +Failed example: + model.b[5:10] = 25.0 # Set B-factors 5-9 to 25 +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + model.b[5:10] = 25.0 # Set B-factors 5-9 to 25 + ~~~~~~~^^^^^^ + TypeError: 'NoneType' object does not support item assignment +********************************************************************** +File "None", line ?, in default +Failed example: + model.b[mask] = new_values # Set B-factors where mask is True +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + model.b[mask] = new_values # Set B-factors where mask is True + ^^^^^^^^^^ + NameError: name 'new_values' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + model.xyz[mask] = new_coords # Set coordinates for masked atoms +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + model.xyz[mask] = new_coords # Set coordinates for masked atoms + ^^^^^^^^^^ + NameError: name 'new_coords' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + model.xyz[:, 0] += 1.0 # Shift all x-coordinates (read-modify-write) +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + model.xyz[:, 0] += 1.0 # Shift all x-coordinates (read-modify-write) + ~~~~~~~~~^^^^^^ + TypeError: 'NoneType' object is not subscriptable +********************************************************************** +File "None", line ?, in default +Failed example: + mask = model.get_selection_mask("chain A") +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + mask = model.get_selection_mask("chain A") + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model.py", line 1407, in get_selection_mask + raise RuntimeError( + RuntimeError: Cannot get selection mask from an uninitialized Model. Load data first. +********************************************************************** +File "None", line ?, in default +Failed example: + new_coords = original_coords[mask] + shift +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + new_coords = original_coords[mask] + shift + ^^^^^^^^^^^^^^^ + NameError: name 'original_coords' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + model.xyz.set(new_coords, mask) +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + model.xyz.set(new_coords, mask) + ^^^^^^^^^^^^^ + AttributeError: 'NoneType' object has no attribute 'set' +********************************************************************** +File "None", line ?, in default +Failed example: + mask = model.get_selection_mask("resseq 10:20") +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + mask = model.get_selection_mask("resseq 10:20") + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model.py", line 1407, in get_selection_mask + raise RuntimeError( + RuntimeError: Cannot get selection mask from an uninitialized Model. Load data first. +********************************************************************** +File "None", line ?, in default +Failed example: + model.b.set(new_b, mask) +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + model.b.set(new_b, mask) + ^^^^^^^^^^^ + AttributeError: 'NoneType' object has no attribute 'set' +********************************************************************** +File "None", line ?, in default +Failed example: + b = PositiveMixedTensor() +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + b = PositiveMixedTensor() + ^^^^^^^^^^^^^^^^^^^ + NameError: name 'PositiveMixedTensor' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + b.load_state_dict(torch.load('b_factors.pt')) +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + b.load_state_dict(torch.load('b_factors.pt')) + ^^^^^^^^^^^^^^^^^ + AttributeError: 'NoneType' object has no attribute 'load_state_dict' +********************************************************************** +File "None", line ?, in default +Failed example: + b = PositiveMixedTensor(initial_b) +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + b = PositiveMixedTensor(initial_b) + ^^^^^^^^^^^^^^^^^^^ + NameError: name 'PositiveMixedTensor' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + output = b() # Returns exp(log_b) = positive values +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + output = b() # Returns exp(log_b) = positive values + ^^^ + TypeError: 'NoneType' object is not callable +********************************************************************** +File "None", line ?, in default +Failed example: + assert (b() > 0).all() +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + assert (b() > 0).all() + ^^^ + TypeError: 'NoneType' object is not callable +********************************************************************** +File "None", line ?, in default +Failed example: + mask = model.get_selection_mask("name CA") +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + mask = model.get_selection_mask("name CA") + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model.py", line 1407, in get_selection_mask + raise RuntimeError( + RuntimeError: Cannot get selection mask from an uninitialized Model. Load data first. +********************************************************************** +File "None", line ?, in default +Failed example: + model.b.set(new_b, mask) +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + model.b.set(new_b, mask) + ^^^^^^^^^^^ + AttributeError: 'NoneType' object has no attribute 'set' +********************************************************************** +File "None", line ?, in default +Failed example: + occ = OccupancyTensor( + initial_values=torch.tensor([1.0, 1.0, 0.7, 0.7, 0.3, 0.3]), + sharing_groups=sharing_groups, + altloc_groups=[([2, 3], [4, 5])], + ) +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + occ = OccupancyTensor( + ^^^^^^^^^^^^^^^ + NameError: name 'OccupancyTensor' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + result = occ() # Atoms 2-3 and 4-5 will sum to 1.0 +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + result = occ() # Atoms 2-3 and 4-5 will sum to 1.0 + ^^^ + NameError: name 'occ' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + freeze_mask = torch.zeros(n_atoms, dtype=torch.bool) +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + freeze_mask = torch.zeros(n_atoms, dtype=torch.bool) + ^^^^^^^ + NameError: name 'n_atoms' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + freeze_mask[0:11] = True +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + freeze_mask[0:11] = True + ^^^^^^^^^^^ + NameError: name 'freeze_mask' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + occ.freeze(freeze_mask) +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + occ.freeze(freeze_mask) + ^^^ + NameError: name 'occ' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + occ.freeze() +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + occ.freeze() + ^^^ + NameError: name 'occ' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + unfreeze_mask = torch.zeros(n_atoms, dtype=torch.bool) +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + unfreeze_mask = torch.zeros(n_atoms, dtype=torch.bool) + ^^^^^^^ + NameError: name 'n_atoms' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + unfreeze_mask[100:201] = True +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + unfreeze_mask[100:201] = True + ^^^^^^^^^^^^^ + NameError: name 'unfreeze_mask' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + occ.unfreeze(unfreeze_mask) +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + occ.unfreeze(unfreeze_mask) + ^^^ + NameError: name 'occ' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + occ.unfreeze() +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + occ.unfreeze() + ^^^ + NameError: name 'occ' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + atom_mask = torch.zeros(n_atoms, dtype=torch.bool) +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + atom_mask = torch.zeros(n_atoms, dtype=torch.bool) + ^^^^^^^ + NameError: name 'n_atoms' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + atom_mask[:100] = True +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + atom_mask[:100] = True + ^^^^^^^^^ + NameError: name 'atom_mask' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + occ.update_refinable_mask(atom_mask, in_compressed_space=False) +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + occ.update_refinable_mask(atom_mask, in_compressed_space=False) + ^^^ + NameError: name 'occ' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + group_mask = torch.zeros(n_groups, dtype=torch.bool) +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + group_mask = torch.zeros(n_groups, dtype=torch.bool) + ^^^^^^^^ + NameError: name 'n_groups' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + group_mask[::2] = True +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + group_mask[::2] = True + ^^^^^^^^^^ + NameError: name 'group_mask' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + occ.update_refinable_mask(group_mask, in_compressed_space=True) +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + occ.update_refinable_mask(group_mask, in_compressed_space=True) + ^^^ + NameError: name 'occ' is not defined +********************************************************************** +1 items had failures: + 167 of 207 in default +207 tests in 1 items. +40 passed and 167 failed. +***Test Failed*** 167 failures. + +Document: api/refinement +------------------------ +********************************************************************** +File "None", line ?, in default +Failed example: + refinement = Refinement( + data_file='reflections.mtz', + pdb='structure.pdb', + device='cuda' + ) +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + refinement = Refinement( + ^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/refinement/base_refinement.py", line 195, in __init__ + raise e + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/refinement/base_refinement.py", line 145, in __init__ + self.reflection_data.load_mtz(data_file) + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/datasets/reflection_data.py", line 233, in load_mtz + reader = mtz.MTZReader(verbose=self.verbose).read(path) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/mtz.py", line 158, in read + self.mtz_data = rs.read_mtz(filepath) + ^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/site-packages/reciprocalspaceship/io/mtz.py", line 186, in read_mtz + gemmi_mtz = gemmi.read_mtz_file(mtzfile) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + RuntimeError: Failed to open reflections.mtz: No such file or directory: reflections.mtz +********************************************************************** +File "None", line ?, in default +Failed example: + refinement.run_refinement(macro_cycles=10) +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + refinement.run_refinement(macro_cycles=10) + ^^^^^^^^^^ + NameError: name 'refinement' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + refinement.load_state_dict(torch.load('refinement.pt')) +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + refinement.load_state_dict(torch.load('refinement.pt')) + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/site-packages/torch/nn/modules/module.py", line 2629, in load_state_dict + raise RuntimeError( + RuntimeError: Error(s) in loading state_dict for Refinement: + Unexpected key(s) in state_dict: "model.pdb", "model.spacegroup", "model.initialized", "model.dtype_float", "model.device", "model.strip_H", "model.altloc_pairs", "model.max_res", "model.radius_angstrom", "scaler.nbins", "scaler.verbose", "scaler.frozen". +********************************************************************** +File "None", line ?, in default +Failed example: + refinement = Refinement(data_file='data.mtz', pdb='model.pdb') +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + refinement = Refinement(data_file='data.mtz', pdb='model.pdb') + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/refinement/base_refinement.py", line 195, in __init__ + raise e + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/refinement/base_refinement.py", line 145, in __init__ + self.reflection_data.load_mtz(data_file) + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/datasets/reflection_data.py", line 233, in load_mtz + reader = mtz.MTZReader(verbose=self.verbose).read(path) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/mtz.py", line 158, in read + self.mtz_data = rs.read_mtz(filepath) + ^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/site-packages/reciprocalspaceship/io/mtz.py", line 186, in read_mtz + gemmi_mtz = gemmi.read_mtz_file(mtzfile) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + RuntimeError: Failed to open data.mtz: No such file or directory: data.mtz +********************************************************************** +File "None", line ?, in default +Failed example: + refinement = Refinement.create_from_state_dict(state) +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + refinement = Refinement.create_from_state_dict(state) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/refinement/base_refinement.py", line 1096, in create_from_state_dict + scaler = Scaler(model, reflection_data, verbose=verbose, device=device) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/scaling/scaler.py", line 119, in __init__ + self.register_buffer("s", get_scattering_vectors(data.hkl, self.cell)) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/math_functions/math_torch.py", line 942, in get_scattering_vectors + recB = reciprocal_basis_matrix(unit_cell) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/math_functions/math_torch.py", line 885, in reciprocal_basis_matrix + angles_rad = torch.deg2rad(unit_cell[3:]) + ~~~~~~~~~^^^^ + TypeError: 'NoneType' object is not subscriptable +********************************************************************** +File "None", line ?, in default +Failed example: + rwork, rfree = refinement.get_rfactor() +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + rwork, rfree = refinement.get_rfactor() + ^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/refinement/base_refinement.py", line 951, in get_rfactor + return self.scaler.rfactor() + ^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/scaling/scaler.py", line 358, in rfactor + hkl, fobs, _, rfree = self._data() + ^^^^^^^^^^^^ + TypeError: 'NoneType' object is not callable +********************************************************************** +File "None", line ?, in default +Failed example: + print(f"Restored at R-work={rwork:.4f}, R-free={rfree:.4f}") +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + print(f"Restored at R-work={rwork:.4f}, R-free={rfree:.4f}") + ^^^^^ + NameError: name 'rwork' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + from torchref.refinement.loss_weighting import ResolutionDependentWeighting +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + from torchref.refinement.loss_weighting import ResolutionDependentWeighting + ModuleNotFoundError: No module named 'torchref.refinement.loss_weighting' +********************************************************************** +File "None", line ?, in default +Failed example: + weighter = ResolutionDependentWeighting() +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + weighter = ResolutionDependentWeighting() + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + NameError: name 'ResolutionDependentWeighting' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + refinement = LBFGSRefinement(mtz_file, pdb_file, weighter=weighter, target_mode='ml') +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + refinement = LBFGSRefinement(mtz_file, pdb_file, weighter=weighter, target_mode='ml') + ^^^^^^^^ + NameError: name 'mtz_file' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + refinement.refine(macro_cycles=2) +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + refinement.refine(macro_cycles=2) + ^^^^^^^^^^^^^^^^^ + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/site-packages/torch/nn/modules/module.py", line 1964, in __getattr__ + raise AttributeError( + AttributeError: 'Refinement' object has no attribute 'refine' +********************************************************************** +File "None", line ?, in default +Failed example: + policy = PolicyComponentWeighting( + refinement, policy_path='policy.pt', + sample=True, temperature=1.0 + ) +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + policy = PolicyComponentWeighting( + ^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/refinement/weighting/policy_weighting.py", line 199, in __init__ + self._load_policy(policy_path) + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/refinement/weighting/policy_weighting.py", line 301, in _load_policy + checkpoint = torch.load(checkpoint_path, map_location="cpu", weights_only=False) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/site-packages/torch/serialization.py", line 1484, in load + with _open_file_like(f, "rb") as opened_file: + ^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/site-packages/torch/serialization.py", line 759, in _open_file_like + return _open_file(name_or_buffer, mode) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/site-packages/torch/serialization.py", line 740, in __init__ + super().__init__(open(name, mode)) + ^^^^^^^^^^^^^^^^ + FileNotFoundError: [Errno 2] No such file or directory: 'policy.pt' +********************************************************************** +File "None", line ?, in default +Failed example: + trajectory = refinement.run_training_trajectory( + policy, n_steps=10, pdb_id='3GR5' + ) +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + trajectory = refinement.run_training_trajectory( + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/site-packages/torch/nn/modules/module.py", line 1964, in __getattr__ + raise AttributeError( + AttributeError: 'Refinement' object has no attribute 'run_training_trajectory' +********************************************************************** +File "None", line ?, in default +Failed example: + with open('trajectory.json', 'w') as f: + json.dump(trajectory_to_dict(trajectory), f) +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 2, in + json.dump(trajectory_to_dict(trajectory), f) + ^^^^^^^^^^ + NameError: name 'trajectory' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + refinement.load_state_dict(torch.load('refinement.pt')) +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + refinement.load_state_dict(torch.load('refinement.pt')) + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/site-packages/torch/nn/modules/module.py", line 2629, in load_state_dict + raise RuntimeError( + RuntimeError: Error(s) in loading state_dict for Refinement: + Unexpected key(s) in state_dict: "model.pdb", "model.spacegroup", "model.initialized", "model.dtype_float", "model.device", "model.strip_H", "model.altloc_pairs", "model.max_res", "model.radius_angstrom", "scaler.nbins", "scaler.verbose", "scaler.frozen". +********************************************************************** +File "None", line ?, in default +Failed example: + refinement = Refinement(data_file='data.mtz', pdb='model.pdb') +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + refinement = Refinement(data_file='data.mtz', pdb='model.pdb') + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/refinement/base_refinement.py", line 195, in __init__ + raise e + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/refinement/base_refinement.py", line 145, in __init__ + self.reflection_data.load_mtz(data_file) + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/datasets/reflection_data.py", line 233, in load_mtz + reader = mtz.MTZReader(verbose=self.verbose).read(path) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/mtz.py", line 158, in read + self.mtz_data = rs.read_mtz(filepath) + ^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/site-packages/reciprocalspaceship/io/mtz.py", line 186, in read_mtz + gemmi_mtz = gemmi.read_mtz_file(mtzfile) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + RuntimeError: Failed to open data.mtz: No such file or directory: data.mtz +********************************************************************** +File "None", line ?, in default +Failed example: + refinement = Refinement.create_from_state_dict(state) +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + refinement = Refinement.create_from_state_dict(state) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/refinement/base_refinement.py", line 1096, in create_from_state_dict + scaler = Scaler(model, reflection_data, verbose=verbose, device=device) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/scaling/scaler.py", line 119, in __init__ + self.register_buffer("s", get_scattering_vectors(data.hkl, self.cell)) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/math_functions/math_torch.py", line 942, in get_scattering_vectors + recB = reciprocal_basis_matrix(unit_cell) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/math_functions/math_torch.py", line 885, in reciprocal_basis_matrix + angles_rad = torch.deg2rad(unit_cell[3:]) + ~~~~~~~~~^^^^ + TypeError: 'NoneType' object is not subscriptable +********************************************************************** +File "None", line ?, in default +Failed example: + rwork, rfree = refinement.get_rfactor() +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + rwork, rfree = refinement.get_rfactor() + ^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/refinement/base_refinement.py", line 951, in get_rfactor + return self.scaler.rfactor() + ^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/scaling/scaler.py", line 358, in rfactor + hkl, fobs, _, rfree = self._data() + ^^^^^^^^^^^^ + TypeError: 'NoneType' object is not callable +********************************************************************** +File "None", line ?, in default +Failed example: + print(f"Restored at R-work={rwork:.4f}, R-free={rfree:.4f}") +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + print(f"Restored at R-work={rwork:.4f}, R-free={rfree:.4f}") + ^^^^^ + NameError: name 'rwork' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + target = Target() # Creates empty shell +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + target = Target() # Creates empty shell + ^^^^^^ + NameError: name 'Target' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + target.load_state_dict(torch.load('target.pt')) +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + target.load_state_dict(torch.load('target.pt')) + ^^^^^^ + NameError: name 'target' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + target = Target(refinement) +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + target = Target(refinement) + ^^^^^^ + NameError: name 'Target' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + geom_target = TotalGeometryTarget(refinement) +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + geom_target = TotalGeometryTarget(refinement) + ^^^^^^^^^^^^^^^^^^^ + NameError: name 'TotalGeometryTarget' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + loss = geom_target() +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + loss = geom_target() + ^^^^^^^^^^^ + NameError: name 'geom_target' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + bond_loss = geom_target['bond']() +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + bond_loss = geom_target['bond']() + ^^^^^^^^^^^ + NameError: name 'geom_target' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + for name, target in geom_target.items(): + print(f"{name}: {target()}") +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + for name, target in geom_target.items(): + ^^^^^^^^^^^ + NameError: name 'geom_target' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + adp_target = TotalADPTarget(refinement) +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + adp_target = TotalADPTarget(refinement) + ^^^^^^^^^^^^^^ + NameError: name 'TotalADPTarget' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + loss = adp_target() +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + loss = adp_target() + ^^^^^^^^^^ + NameError: name 'adp_target' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + simu_loss = adp_target['simu']() +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + simu_loss = adp_target['simu']() + ^^^^^^^^^^ + NameError: name 'adp_target' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + for name, target in adp_target.items(): + print(f"{name}: {target()}") +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + for name, target in adp_target.items(): + ^^^^^^^^^^ + NameError: name 'adp_target' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + from torchref.refinement.loss_weighting import ResolutionDependentWeighting +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + from torchref.refinement.loss_weighting import ResolutionDependentWeighting + ModuleNotFoundError: No module named 'torchref.refinement.loss_weighting' +********************************************************************** +File "None", line ?, in default +Failed example: + weighter = ResolutionDependentWeighting() +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + weighter = ResolutionDependentWeighting() + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + NameError: name 'ResolutionDependentWeighting' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + refinement = LBFGSRefinement(mtz_file, pdb_file, weighter=weighter, target_mode='ml') +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + refinement = LBFGSRefinement(mtz_file, pdb_file, weighter=weighter, target_mode='ml') + ^^^^^^^^ + NameError: name 'mtz_file' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + refinement.refine(macro_cycles=2) +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + refinement.refine(macro_cycles=2) + ^^^^^^^^^^^^^^^^^ + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/site-packages/torch/nn/modules/module.py", line 1964, in __getattr__ + raise AttributeError( + AttributeError: 'Refinement' object has no attribute 'refine' +********************************************************************** +File "None", line ?, in default +Failed example: + policy = PolicyComponentWeighting( + refinement, policy_path='policy.pt', + sample=True, temperature=1.0 + ) +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + policy = PolicyComponentWeighting( + ^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/refinement/weighting/policy_weighting.py", line 199, in __init__ + self._load_policy(policy_path) + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/refinement/weighting/policy_weighting.py", line 301, in _load_policy + checkpoint = torch.load(checkpoint_path, map_location="cpu", weights_only=False) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/site-packages/torch/serialization.py", line 1484, in load + with _open_file_like(f, "rb") as opened_file: + ^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/site-packages/torch/serialization.py", line 759, in _open_file_like + return _open_file(name_or_buffer, mode) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/site-packages/torch/serialization.py", line 740, in __init__ + super().__init__(open(name, mode)) + ^^^^^^^^^^^^^^^^ + FileNotFoundError: [Errno 2] No such file or directory: 'policy.pt' +********************************************************************** +File "None", line ?, in default +Failed example: + trajectory = refinement.run_training_trajectory( + policy, n_steps=10, pdb_id='3GR5' + ) +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + trajectory = refinement.run_training_trajectory( + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/site-packages/torch/nn/modules/module.py", line 1964, in __getattr__ + raise AttributeError( + AttributeError: 'Refinement' object has no attribute 'run_training_trajectory' +********************************************************************** +File "None", line ?, in default +Failed example: + with open('trajectory.json', 'w') as f: + json.dump(trajectory_to_dict(trajectory), f) +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 2, in + json.dump(trajectory_to_dict(trajectory), f) + ^^^^^^^^^^ + NameError: name 'trajectory' is not defined +********************************************************************** +1 items had failures: + 37 of 53 in default +53 tests in 1 items. +16 passed and 37 failed. +***Test Failed*** 37 failures. + +Document: api/restraints +------------------------ +********************************************************************** +File "None", line ?, in default +Failed example: + iterator = ResidueIterator(model.pdb) +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + iterator = ResidueIterator(model.pdb) + ^^^^^^^^^^^^^^^ + NameError: name 'ResidueIterator' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + for chain_id, resseq, residue_df in iterator: + print(f"Processing {chain_id}:{resseq}") +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + for chain_id, resseq, residue_df in iterator: + ^^^^^^^^ + NameError: name 'iterator' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + builder = BondRestraintBuilder() +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + builder = BondRestraintBuilder() + ^^^^^^^^^^^^^^^^^^^^ + NameError: name 'BondRestraintBuilder' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + for residue in residues: + builder.process_residue(residue, cif_bonds) +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + for residue in residues: + ^^^^^^^^ + NameError: name 'residues' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + result = builder.finalize(device) +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + result = builder.finalize(device) + ^^^^^^^ + NameError: name 'builder' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + print(result['indices'].shape) # (n_bonds, 2) +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + print(result['indices'].shape) # (n_bonds, 2) + ^^^^^^ + NameError: name 'result' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + builder = AngleRestraintBuilder() +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + builder = AngleRestraintBuilder() + ^^^^^^^^^^^^^^^^^^^^^ + NameError: name 'AngleRestraintBuilder' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + for residue in residues: + builder.process_residue(residue, cif_angles) +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + for residue in residues: + ^^^^^^^^ + NameError: name 'residues' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + result = builder.finalize(device) +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + result = builder.finalize(device) + ^^^^^^^ + NameError: name 'builder' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + print(result['indices'].shape) # (n_angles, 3) +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + print(result['indices'].shape) # (n_angles, 3) + ^^^^^^ + NameError: name 'result' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + builder = TorsionRestraintBuilder() +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + builder = TorsionRestraintBuilder() + ^^^^^^^^^^^^^^^^^^^^^^^ + NameError: name 'TorsionRestraintBuilder' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + for residue in residues: + builder.process_residue(residue, cif_torsions) +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + for residue in residues: + ^^^^^^^^ + NameError: name 'residues' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + result = builder.finalize(device) +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + result = builder.finalize(device) + ^^^^^^^ + NameError: name 'builder' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + print(result['indices'].shape) # (n_torsions, 4) +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + print(result['indices'].shape) # (n_torsions, 4) + ^^^^^^ + NameError: name 'result' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + print(result['periods'].shape) # (n_torsions,) +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + print(result['periods'].shape) # (n_torsions,) + ^^^^^^ + NameError: name 'result' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + builder = PlaneRestraintBuilder() +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + builder = PlaneRestraintBuilder() + ^^^^^^^^^^^^^^^^^^^^^ + NameError: name 'PlaneRestraintBuilder' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + for residue in residues: + builder.process_residue(residue, cif_planes) +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + for residue in residues: + ^^^^^^^^ + NameError: name 'residues' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + result = builder.finalize(device) +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + result = builder.finalize(device) + ^^^^^^^ + NameError: name 'builder' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + builder = ChiralRestraintBuilder(ideal_volume=2.5, sigma=0.2) +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + builder = ChiralRestraintBuilder(ideal_volume=2.5, sigma=0.2) + ^^^^^^^^^^^^^^^^^^^^^^ + NameError: name 'ChiralRestraintBuilder' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + for residue in residues: + builder.process_residue(residue, cif_chirals) +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + for residue in residues: + ^^^^^^^^ + NameError: name 'residues' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + result = builder.finalize(device) +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + result = builder.finalize(device) + ^^^^^^^ + NameError: name 'builder' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + print(result['indices'].shape) # (n_chirals, 4) +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + print(result['indices'].shape) # (n_chirals, 4) + ^^^^^^ + NameError: name 'result' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + print(result['ideal_volumes'].shape) # (n_chirals,) +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + print(result['ideal_volumes'].shape) # (n_chirals,) + ^^^^^^ + NameError: name 'result' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + builder = InterResidueBondBuilder() +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + builder = InterResidueBondBuilder() + ^^^^^^^^^^^^^^^^^^^^^^^ + NameError: name 'InterResidueBondBuilder' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + for res_i, res_next in iterator.get_consecutive_pairs(): + builder.process_peptide_bond(res_i, res_next, trans_link) +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + for res_i, res_next in iterator.get_consecutive_pairs(): + ^^^^^^^^ + NameError: name 'iterator' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + result = builder.finalize(device) +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + result = builder.finalize(device) + ^^^^^^^ + NameError: name 'builder' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + model.load_pdb_from_file('structure.pdb') +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + model.load_pdb_from_file('structure.pdb') + ^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/site-packages/torch/nn/modules/module.py", line 1964, in __getattr__ + raise AttributeError( + AttributeError: 'Model' object has no attribute 'load_pdb_from_file' +********************************************************************** +File "None", line ?, in default +Failed example: + restraints = Restraints(model) +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + restraints = Restraints(model) + ^^^^^^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/restraints/restraints_new.py", line 86, in __init__ + self.unique_residues = model.pdb.resname.unique() + ^^^^^^^^^^^^^^^^^ + AttributeError: 'NoneType' object has no attribute 'resname' +********************************************************************** +File "None", line ?, in default +Failed example: + bond_indices = restraints.restraints['bond']['intra']['indices'] +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + bond_indices = restraints.restraints['bond']['intra']['indices'] + ^^^^^^^^^^ + NameError: name 'restraints' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + angle_refs = restraints.restraints['angle']['peptide']['references'] +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + angle_refs = restraints.restraints['angle']['peptide']['references'] + ^^^^^^^^^^ + NameError: name 'restraints' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + torsion_periods = restraints.restraints['torsion']['intra']['periods'] +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + torsion_periods = restraints.restraints['torsion']['intra']['periods'] + ^^^^^^^^^^ + NameError: name 'restraints' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + plane_4atom_indices = restraints.restraints['plane']['4_atoms']['indices'] +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + plane_4atom_indices = restraints.restraints['plane']['4_atoms']['indices'] + ^^^^^^^^^^ + NameError: name 'restraints' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + bond_indices = restraints.bond_indices # intra-residue bonds +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + bond_indices = restraints.bond_indices # intra-residue bonds + ^^^^^^^^^^ + NameError: name 'restraints' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + bond_indices_inter = restraints.bond_indices_inter # peptide bonds +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + bond_indices_inter = restraints.bond_indices_inter # peptide bonds + ^^^^^^^^^^ + NameError: name 'restraints' is not defined +********************************************************************** +1 items had failures: + 34 of 37 in default +37 tests in 1 items. +3 passed and 34 failed. +***Test Failed*** 34 failures. + +Document: api/scaling +--------------------- +********************************************************************** +File "None", line ?, in default +Failed example: + F_calc_scaled = scaler(F_calc, F_obs, s_squared) +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + F_calc_scaled = scaler(F_calc, F_obs, s_squared) + ^^^^^^ + NameError: name 'F_calc' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + F_calc_total = solvent(F_calc, F_mask, s_squared) +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + F_calc_total = solvent(F_calc, F_mask, s_squared) + ^^^^^^ + NameError: name 'F_calc' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + metrics = scaler.refine_lbfgs(nsteps=5, verbose=True) +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + metrics = scaler.refine_lbfgs(nsteps=5, verbose=True) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/scaling/scaler.py", line 613, in refine_lbfgs + optimizer = torch.optim.LBFGS( + ^^^^^^^^^^^^^^^^^^ + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/site-packages/torch/optim/lbfgs.py", line 243, in __init__ + super().__init__(params, defaults) + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/site-packages/torch/optim/optimizer.py", line 396, in __init__ + raise ValueError("optimizer got an empty parameter list") + ValueError: optimizer got an empty parameter list +********************************************************************** +File "None", line ?, in default +Failed example: + solvent.load_state_dict(torch.load('solvent.pt')) +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + solvent.load_state_dict(torch.load('solvent.pt')) + ^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/site-packages/torch/serialization.py", line 1484, in load + with _open_file_like(f, "rb") as opened_file: + ^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/site-packages/torch/serialization.py", line 759, in _open_file_like + return _open_file(name_or_buffer, mode) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/site-packages/torch/serialization.py", line 740, in __init__ + super().__init__(open(name, mode)) + ^^^^^^^^^^^^^^^^ + FileNotFoundError: [Errno 2] No such file or directory: 'solvent.pt' +********************************************************************** +File "None", line ?, in default +Failed example: + solvent = SolventModel(model, k_solvent=0.35, b_solvent=46.0) +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + solvent = SolventModel(model, k_solvent=0.35, b_solvent=46.0) + ^^^^^ + NameError: name 'model' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + metrics = scaler.refine_lbfgs(nsteps=5, verbose=True) +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + metrics = scaler.refine_lbfgs(nsteps=5, verbose=True) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/scaling/scaler.py", line 613, in refine_lbfgs + optimizer = torch.optim.LBFGS( + ^^^^^^^^^^^^^^^^^^ + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/site-packages/torch/optim/lbfgs.py", line 243, in __init__ + super().__init__(params, defaults) + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/site-packages/torch/optim/optimizer.py", line 396, in __init__ + raise ValueError("optimizer got an empty parameter list") + ValueError: optimizer got an empty parameter list +********************************************************************** +File "None", line ?, in default +Failed example: + solvent.load_state_dict(torch.load('solvent.pt')) +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + solvent.load_state_dict(torch.load('solvent.pt')) + ^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/site-packages/torch/serialization.py", line 1484, in load + with _open_file_like(f, "rb") as opened_file: + ^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/site-packages/torch/serialization.py", line 759, in _open_file_like + return _open_file(name_or_buffer, mode) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/site-packages/torch/serialization.py", line 740, in __init__ + super().__init__(open(name, mode)) + ^^^^^^^^^^^^^^^^ + FileNotFoundError: [Errno 2] No such file or directory: 'solvent.pt' +********************************************************************** +File "None", line ?, in default +Failed example: + solvent = SolventModel(model, k_solvent=0.35, b_solvent=46.0) +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + solvent = SolventModel(model, k_solvent=0.35, b_solvent=46.0) + ^^^^^ + NameError: name 'model' is not defined +********************************************************************** +1 items had failures: + 8 of 17 in default +17 tests in 1 items. +9 passed and 8 failed. +***Test Failed*** 8 failures. + +Document: api/utils +------------------- +********************************************************************** +File "None", line ?, in default +Failed example: + masks['backbone'] = backbone_mask +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + masks['backbone'] = backbone_mask + ^^^^^^^^^^^^^ + NameError: name 'backbone_mask' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + grad_norm = gradnorm(loss, model.parameters()) +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + grad_norm = gradnorm(loss, model.parameters()) + ^^^^ + NameError: name 'loss' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + masks['rfree'] = rfree_flags > 0 +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + masks['rfree'] = rfree_flags > 0 + ^^^^^^^^^^^ + NameError: name 'rfree_flags' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + masks.cpu() # Move all to CPU +Expected nothing +Got: + TensorMasks({'valid': shape=torch.Size([100])}, device=cpu) +********************************************************************** +File "None", line ?, in default +Failed example: + model = MyModel() +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + model = MyModel() + ^^^^^^^ + NameError: name 'MyModel' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + scaler = Scaler() +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + scaler = Scaler() + ^^^^^^ + NameError: name 'Scaler' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + scaler._model = ModuleReference(model) # Won't register as submodule +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + scaler._model = ModuleReference(model) # Won't register as submodule + ^^^^^^^^^^^^^^^ + NameError: name 'ModuleReference' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + output = scaler._model.module(input_data) +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + output = scaler._model.module(input_data) + ^^^^^^ + NameError: name 'scaler' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + model.load_cif('structure.cif') +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + model.load_cif('structure.cif') + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model.py", line 252, in load_cif + cif_reader = cif.ModelCIFReader(file) + ^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/cif_readers.py", line 1263, in __init__ + self.cif = CIFReader(filepath) + ^^^^^^^^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/cif_readers.py", line 80, in __init__ + self.load(filepath) + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/cif_readers.py", line 92, in load + with open(filepath, "r", encoding="utf-8", errors="ignore") as f: + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + FileNotFoundError: [Errno 2] No such file or directory: 'structure.cif' +********************************************************************** +File "None", line ?, in default +Failed example: + model.pdb = sanitize_pdb_dataframe(model.pdb, verbose=1) +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + model.pdb = sanitize_pdb_dataframe(model.pdb, verbose=1) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/utils/utils.py", line 797, in sanitize_pdb_dataframe + pdb = pdb.copy() + ^^^^^^^^ + AttributeError: 'NoneType' object has no attribute 'copy' +********************************************************************** +File "None", line ?, in default +Failed example: + mask = parse_phenix_selection("chain A", pdb_df) +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + mask = parse_phenix_selection("chain A", pdb_df) + ^^^^^^^^^^^^^^^^^^^^^^ + NameError: name 'parse_phenix_selection' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + mask = parse_phenix_selection("chain A and resseq 10:20", pdb_df) +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + mask = parse_phenix_selection("chain A and resseq 10:20", pdb_df) + ^^^^^^^^^^^^^^^^^^^^^^ + NameError: name 'parse_phenix_selection' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + mask = parse_phenix_selection("name CA", pdb_df) +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + mask = parse_phenix_selection("name CA", pdb_df) + ^^^^^^^^^^^^^^^^^^^^^^ + NameError: name 'parse_phenix_selection' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + mask = parse_phenix_selection("name CA or name C or name N or name O", pdb_df) +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + mask = parse_phenix_selection("name CA or name C or name N or name O", pdb_df) + ^^^^^^^^^^^^^^^^^^^^^^ + NameError: name 'parse_phenix_selection' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + mask = parse_phenix_selection("not resname HOH", pdb_df) +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + mask = parse_phenix_selection("not resname HOH", pdb_df) + ^^^^^^^^^^^^^^^^^^^^^^ + NameError: name 'parse_phenix_selection' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + mask = parse_phenix_selection("chain A and (name CA or name CB)", pdb_df) +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + mask = parse_phenix_selection("chain A and (name CA or name CB)", pdb_df) + ^^^^^^^^^^^^^^^^^^^^^^ + NameError: name 'parse_phenix_selection' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + mask = create_selection_mask("chain A", pdb_df, mode='set') +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + mask = create_selection_mask("chain A", pdb_df, mode='set') + ^^^^^^^^^^^^^^^^^^^^^ + NameError: name 'create_selection_mask' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + mask = create_selection_mask("resseq 10:20", pdb_df, current_mask=mask, mode='add') +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + mask = create_selection_mask("resseq 10:20", pdb_df, current_mask=mask, mode='add') + ^^^^^^^^^^^^^^^^^^^^^ + NameError: name 'create_selection_mask' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + mask = create_selection_mask("resname HOH", pdb_df, current_mask=mask, mode='remove') +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + mask = create_selection_mask("resname HOH", pdb_df, current_mask=mask, mode='remove') + ^^^^^^^^^^^^^^^^^^^^^ + NameError: name 'create_selection_mask' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + class MyTarget(HyperparameterMixin, nn.Module): + def __init__(self, sigma=1.0, target_value=0.0): + nn.Module.__init__(self) + HyperparameterMixin.__init__(self) + self.register_hyperparameter('sigma', sigma) + self.register_hyperparameter('target_value', target_value) +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + class MyTarget(HyperparameterMixin, nn.Module): + ^^^^^^^^^^^^^^^^^^^ + NameError: name 'HyperparameterMixin' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + target = MyTarget(sigma=2.5) +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + target = MyTarget(sigma=2.5) + ^^^^^^^^ + NameError: name 'MyTarget' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + dict(target.hyperparameters()) +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + dict(target.hyperparameters()) + ^^^^^^ + NameError: name 'target' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + params = module.hyperparameter_dict() +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + params = module.hyperparameter_dict() + ^^^^^^ + NameError: name 'module' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + json.dumps(params) # JSON serializable +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + json.dumps(params) # JSON serializable + ^^^^^^ + NameError: name 'params' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + hp_state = module.hyperparameter_state_dict() +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + hp_state = module.hyperparameter_state_dict() + ^^^^^^ + NameError: name 'module' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + torch.save(hp_state, 'hyperparameters.pt') +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + torch.save(hp_state, 'hyperparameters.pt') + ^^^^^^^^ + NameError: name 'hp_state' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + for name, hp in module.hyperparameters(): + print(f"{name}: {hp.item()}") +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + for name, hp in module.hyperparameters(): + ^^^^^^ + NameError: name 'module' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + hp_state = torch.load('hyperparameters.pt') +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + hp_state = torch.load('hyperparameters.pt') + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/site-packages/torch/serialization.py", line 1484, in load + with _open_file_like(f, "rb") as opened_file: + ^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/site-packages/torch/serialization.py", line 759, in _open_file_like + return _open_file(name_or_buffer, mode) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/site-packages/torch/serialization.py", line 740, in __init__ + super().__init__(open(name, mode)) + ^^^^^^^^^^^^^^^^ + FileNotFoundError: [Errno 2] No such file or directory: 'hyperparameters.pt' +********************************************************************** +File "None", line ?, in default +Failed example: + module.load_hyperparameter_state_dict(hp_state) +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + module.load_hyperparameter_state_dict(hp_state) + ^^^^^^ + NameError: name 'module' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + self.register_hyperparameter('sigma', 2.0) +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + self.register_hyperparameter('sigma', 2.0) + ^^^^ + NameError: name 'self' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + self.sigma # Access via property (if defined) or _hp_sigma +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + self.sigma # Access via property (if defined) or _hp_sigma + ^^^^ + NameError: name 'self' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + loss = model(input) +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + loss = model(input) + ^^^^^^^^^^^^ + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/site-packages/torch/nn/modules/module.py", line 1775, in _wrapped_call_impl + return self._call_impl(*args, **kwargs) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/site-packages/torch/nn/modules/module.py", line 1786, in _call_impl + return forward_call(*args, **kwargs) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/site-packages/torch/nn/modules/module.py", line 399, in _forward_unimplemented + raise NotImplementedError( + NotImplementedError: Module [Model] is missing the required "forward" function +********************************************************************** +File "None", line ?, in default +Failed example: + grad_norm = gradnorm(loss, model.parameters()) +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + grad_norm = gradnorm(loss, model.parameters()) + ^^^^ + NameError: name 'loss' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + print(f"Gradient norm: {grad_norm:.4f}") +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + print(f"Gradient norm: {grad_norm:.4f}") + ^^^^^^^^^ + NameError: name 'grad_norm' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + model = MyModel() +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + model = MyModel() + ^^^^^^^ + NameError: name 'MyModel' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + scaler = Scaler() +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + scaler = Scaler() + ^^^^^^ + NameError: name 'Scaler' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + scaler._model = ModuleReference(model) # Won't register as submodule +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + scaler._model = ModuleReference(model) # Won't register as submodule + ^^^^^^^^^^^^^^^ + NameError: name 'ModuleReference' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + output = scaler._model.module(input_data) +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + output = scaler._model.module(input_data) + ^^^^^^ + NameError: name 'scaler' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + masks['rfree'] = rfree_flags > 0 +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + masks['rfree'] = rfree_flags > 0 + ^^^^^^^^^^^ + NameError: name 'rfree_flags' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + masks.cpu() # Move all to CPU +Expected nothing +Got: + TensorMasks({'valid': shape=torch.Size([100])}, device=cpu) +********************************************************************** +File "None", line ?, in default +Failed example: + model.load_cif('structure.cif') +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + model.load_cif('structure.cif') + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model.py", line 252, in load_cif + cif_reader = cif.ModelCIFReader(file) + ^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/cif_readers.py", line 1263, in __init__ + self.cif = CIFReader(filepath) + ^^^^^^^^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/cif_readers.py", line 80, in __init__ + self.load(filepath) + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/cif_readers.py", line 92, in load + with open(filepath, "r", encoding="utf-8", errors="ignore") as f: + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + FileNotFoundError: [Errno 2] No such file or directory: 'structure.cif' +********************************************************************** +File "None", line ?, in default +Failed example: + model.pdb = sanitize_pdb_dataframe(model.pdb, verbose=1) +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + model.pdb = sanitize_pdb_dataframe(model.pdb, verbose=1) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/utils/utils.py", line 797, in sanitize_pdb_dataframe + pdb = pdb.copy() + ^^^^^^^^ + AttributeError: 'NoneType' object has no attribute 'copy' +********************************************************************** +File "None", line ?, in default +Failed example: + mask = parse_phenix_selection("chain A", pdb_df) +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + mask = parse_phenix_selection("chain A", pdb_df) + ^^^^^^^^^^^^^^^^^^^^^^ + NameError: name 'parse_phenix_selection' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + mask = parse_phenix_selection("chain A and resseq 10:20", pdb_df) +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + mask = parse_phenix_selection("chain A and resseq 10:20", pdb_df) + ^^^^^^^^^^^^^^^^^^^^^^ + NameError: name 'parse_phenix_selection' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + mask = parse_phenix_selection("name CA", pdb_df) +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + mask = parse_phenix_selection("name CA", pdb_df) + ^^^^^^^^^^^^^^^^^^^^^^ + NameError: name 'parse_phenix_selection' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + mask = parse_phenix_selection("name CA or name C or name N or name O", pdb_df) +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + mask = parse_phenix_selection("name CA or name C or name N or name O", pdb_df) + ^^^^^^^^^^^^^^^^^^^^^^ + NameError: name 'parse_phenix_selection' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + mask = parse_phenix_selection("not resname HOH", pdb_df) +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + mask = parse_phenix_selection("not resname HOH", pdb_df) + ^^^^^^^^^^^^^^^^^^^^^^ + NameError: name 'parse_phenix_selection' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + mask = parse_phenix_selection("chain A and (name CA or name CB)", pdb_df) +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + mask = parse_phenix_selection("chain A and (name CA or name CB)", pdb_df) + ^^^^^^^^^^^^^^^^^^^^^^ + NameError: name 'parse_phenix_selection' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + mask = create_selection_mask("chain A", pdb_df, mode='set') +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + mask = create_selection_mask("chain A", pdb_df, mode='set') + ^^^^^^^^^^^^^^^^^^^^^ + NameError: name 'create_selection_mask' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + mask = create_selection_mask("resseq 10:20", pdb_df, current_mask=mask, mode='add') +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + mask = create_selection_mask("resseq 10:20", pdb_df, current_mask=mask, mode='add') + ^^^^^^^^^^^^^^^^^^^^^ + NameError: name 'create_selection_mask' is not defined +********************************************************************** +File "None", line ?, in default +Failed example: + mask = create_selection_mask("resname HOH", pdb_df, current_mask=mask, mode='remove') +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 1, in + mask = create_selection_mask("resname HOH", pdb_df, current_mask=mask, mode='remove') + ^^^^^^^^^^^^^^^^^^^^^ + NameError: name 'create_selection_mask' is not defined +********************************************************************** +1 items had failures: + 51 of 67 in default +67 tests in 1 items. +16 passed and 51 failed. +***Test Failed*** 51 failures. + +Document: quickstart +-------------------- +********************************************************************** +File "quickstart.rst", line 117, in default +Failed example: + from torchref import ModelFT, ROOT_TORCHREF + + model = ModelFT() + model.load_pdb(f"{ROOT_TORCHREF}/example_notebooks/1DAW.pdb") + + print(f"Number of atoms: {model.n_atoms}") +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 6, in + print(f"Number of atoms: {model.n_atoms}") + ^^^^^^^^^^^^^ + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/site-packages/torch/nn/modules/module.py", line 1964, in __getattr__ + raise AttributeError( + AttributeError: 'ModelFT' object has no attribute 'n_atoms' +********************************************************************** +File "quickstart.rst", line 299, in default +Failed example: + from torchref import LBFGSRefinement, ROOT_TORCHREF + + refinement = LBFGSRefinement( + data_file=f"{ROOT_TORCHREF}/example_notebooks/1DAW.mtz", + pdb=f"{ROOT_TORCHREF}/example_notebooks/1DAW.pdb", + ) + + # Perturb coordinates to simulate errors + refinement.model.shake_coords(0.1) + + # Run xyz refinement (3 cycles for quick test) + refinement.refine_xyz(n_cycles=1) + + rwork, rfree = refinement.get_rfactor() + print(f"After refinement - Rwork: {rwork:.3f}") +Exception raised: + Traceback (most recent call last): + File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run + exec(compile(example.source, filename, "single", + File "", line 12, in + refinement.refine_xyz(n_cycles=1) + TypeError: LBFGSRefinement.refine_xyz() got an unexpected keyword argument 'n_cycles' diff --git a/pyproject.toml b/pyproject.toml index 96f8630..7c03266 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -120,13 +120,7 @@ markers = [ "openmm: Tests requiring OpenMM (the [amber] extra); skipped if absent", "amber: Tests requiring OpenMM + AmberTools (antechamber/tleap); skipped if absent", ] -# Keep in step with ``tests/pytest.ini``; see the note above on why both exist. -# -# A backend degradation is an error under test and a warning in production. Dispatch falls -# back to the portable kernel when an accelerator throws, which is right for a user -- the -# answer stays correct, it just gets slower -- and wrong for CI, because a comparison between -# an accelerator and the reference would then run the reference twice and pass at zero -# difference while measuring nothing. + filterwarnings = [ "ignore::DeprecationWarning", "ignore::PendingDeprecationWarning", diff --git a/tests/unit/test_seeded_lbfgs.py b/tests/unit/test_seeded_lbfgs.py new file mode 100644 index 0000000..83147f4 --- /dev/null +++ b/tests/unit/test_seeded_lbfgs.py @@ -0,0 +1,199 @@ +"""Unit tests for SeededLBFGS and the Hessian-diagonal curvature helper. + +Covers: +1. A diagonal quadratic bowl: an exact inverse-diagonal seed makes the first + (single) inner iteration land on the minimum, where plain steepest-descent + L-BFGS with the same one-iteration budget does not. +2. ``init_hess_diag=None`` reproduces ``torch.optim.LBFGS`` bit-for-bit; an + all-ones seed reproduces the first *direction* but not the first step length. +3. The Hutchinson diagonal helper matches the true Hessian diagonal (exact + ``probe="basis"``; statistical ``probe="rademacher"``). + +All tests are pure-CPU (the analytic double-backward HVP runs under +``use_portable()`` on plain torch), so no GPU marker is needed. +""" + +import pytest +import torch + +from torchref.refinement.optimizers import ( + SeededLBFGS, + hessian_diagonal, + preconditioner_from_diagonal, +) + +pytestmark = pytest.mark.unit + + +# ============================================================================= +# 1. Diagonal quadratic bowl — one seeded step reaches the minimum +# ============================================================================= +def test_seeded_step_solves_diagonal_quadratic_in_one_iter(): + """f(x) = 0.5 * sum(h_i x_i^2); seed = 1/h => d0 = -x, t = 1 => x -> 0.""" + h = torch.tensor([1.0, 4.0, 9.0, 16.0], dtype=torch.float64) + x0 = torch.tensor([3.0, -2.0, 1.0, 0.5], dtype=torch.float64) + + # --- seeded --- + x = torch.nn.Parameter(x0.clone()) + opt = SeededLBFGS( + [x], lr=1.0, max_iter=1, history_size=100, line_search_fn="strong_wolfe" + ) + opt.set_init_hess_diag(1.0 / h) # exact inverse-diagonal (lambda = 0) + + def closure(): + opt.zero_grad() + loss = 0.5 * (h * x * x).sum() + loss.backward() + return loss + + opt.step(closure) + assert x.detach().abs().max().item() < 1e-8 + + # --- stock steepest descent, same one-iteration budget: does NOT solve --- + x2 = torch.nn.Parameter(x0.clone()) + stock = torch.optim.LBFGS( + [x2], lr=1.0, max_iter=1, history_size=100, line_search_fn="strong_wolfe" + ) + + def closure2(): + stock.zero_grad() + loss = 0.5 * (h * x2 * x2).sum() + loss.backward() + return loss + + stock.step(closure2) + # one steepest-descent line search along -g cannot zero all four coords + assert x2.detach().abs().max().item() > 1e-2 + + +# ============================================================================= +# 2. Equivalence / divergence vs stock torch.optim.LBFGS +# ============================================================================= +def _quartic_closure(opt, x, a): + def closure(): + opt.zero_grad() + loss = ((x - a) ** 4).sum() + loss.backward() + return loss + + return closure + + +def test_none_seed_is_bit_identical_to_stock_lbfgs(): + """With init_hess_diag=None the whole trajectory matches torch.optim.LBFGS.""" + a = torch.tensor([1.0, -2.0, 0.5, 3.0], dtype=torch.float64) + x0 = torch.zeros(4, dtype=torch.float64) + + kw = dict(lr=1.0, max_iter=20, history_size=10, line_search_fn="strong_wolfe") + + x_seed = torch.nn.Parameter(x0.clone()) + opt_seed = SeededLBFGS([x_seed], **kw) # no seed set -> None + c_seed = _quartic_closure(opt_seed, x_seed, a) + + x_stock = torch.nn.Parameter(x0.clone()) + opt_stock = torch.optim.LBFGS([x_stock], **kw) + c_stock = _quartic_closure(opt_stock, x_stock, a) + + for _ in range(5): + l_seed = float(opt_seed.step(c_seed)) + l_stock = float(opt_stock.step(c_stock)) + assert l_seed == l_stock + assert torch.equal(x_seed.detach(), x_stock.detach()) + + +def test_ones_seed_matches_stock_direction_but_not_step_length(): + """An all-ones seed gives the stock first direction (-g) but t = lr, not the + ``min(1, 1/||g||_1)`` heuristic.""" + a = torch.tensor([1.0, -2.0, 0.5, 3.0], dtype=torch.float64) + x0 = torch.zeros(4, dtype=torch.float64) + kw = dict(lr=1.0, max_iter=1, history_size=10, line_search_fn="strong_wolfe") + + x_seed = torch.nn.Parameter(x0.clone()) + opt_seed = SeededLBFGS([x_seed], **kw) + opt_seed.set_init_hess_diag(torch.ones(4, dtype=torch.float64)) + opt_seed.step(_quartic_closure(opt_seed, x_seed, a)) + + x_stock = torch.nn.Parameter(x0.clone()) + opt_stock = torch.optim.LBFGS([x_stock], **kw) + opt_stock.step(_quartic_closure(opt_stock, x_stock, a)) + + d_seed = opt_seed.state[opt_seed._params[0]]["d"] + d_stock = opt_stock.state[opt_stock._params[0]]["d"] + # same first direction (steepest descent, since precond = ones) + assert torch.allclose(d_seed, d_stock) + # but the initial trial step length differs by design + t_seed = opt_seed.state[opt_seed._params[0]]["t"] + t_stock = opt_stock.state[opt_stock._params[0]]["t"] + # (post-line-search t may coincide only by accident; assert the *first-guess* + # rule differs by checking the resulting parameter points are not identical) + assert not torch.equal(x_seed.detach(), x_stock.detach()) or t_seed != t_stock + + +# ============================================================================= +# 3. Hessian-diagonal helper vs the true diagonal +# ============================================================================= +def test_hessian_diagonal_basis_matches_true_diagonal(): + """Exact (basis-probe) diagonal matches torch.autograd.functional.hessian.""" + g = torch.Generator().manual_seed(0) + n = 6 + M = torch.randn(n, n, generator=g, dtype=torch.float64) + M = 0.5 * (M + M.t()) # symmetric (off-diagonal curvature present) + c = torch.rand(n, generator=g, dtype=torch.float64) + 0.1 + x_val = torch.randn(n, generator=g, dtype=torch.float64) + + def f(v): + return 0.5 * (v @ M @ v) + (c * v**4).sum() + + x = torch.nn.Parameter(x_val.clone()) + + diag_est = hessian_diagonal(lambda: f(x), [x], probe="basis") + + H = torch.autograd.functional.hessian(f, x_val.clone()) + true_diag = torch.diagonal(H) + assert torch.allclose(diag_est, true_diag, atol=1e-8) + + +def test_per_group_floor_does_not_crush_small_group(): + """A two-group diagonal with a 1000:1 magnitude gap: the global floor crushes + the small group's preconditioner, but the per-group floor gives each group its + own Newton scale.""" + # group A: large curvature (~1e3), group B: small curvature (~1.0) + diag = torch.tensor([1000.0, 800.0, 1.0, 0.8], dtype=torch.float64) + sizes = [2, 2] + lam = 1e-2 + + glob = preconditioner_from_diagonal(diag, lam=lam) # global floor + per = preconditioner_from_diagonal(diag, lam=lam, group_sizes=sizes) + + # Global floor = lam*max|diag| = 10 -> small group clamped to 1/10 = 0.1, + # i.e. its true 1/1.0=1.0 preconditioner is crushed ~10x. + assert torch.allclose(glob[2:], torch.tensor([0.1, 0.1], dtype=torch.float64)) + # Per-group: small group floored by its OWN max (1.0) -> ~1/|diag| preserved. + assert per[2] > 0.9 # ~1/1.0, not crushed + assert per[3] > 0.9 # 1/0.8 clamped by its own floor 1e-2*1.0 -> ~1/0.8=1.25 + # large group identical either way (it sets the global max) + assert torch.allclose(per[:2], glob[:2]) + + +def test_hessian_diagonal_rademacher_approximates_true_diagonal(): + """Stochastic (Rademacher) diagonal converges to the truth within tolerance.""" + g = torch.Generator().manual_seed(1) + n = 6 + M = torch.randn(n, n, generator=g, dtype=torch.float64) + M = 0.5 * (M + M.t()) + c = torch.rand(n, generator=g, dtype=torch.float64) + 0.1 + x_val = torch.randn(n, generator=g, dtype=torch.float64) + + def f(v): + return 0.5 * (v @ M @ v) + (c * v**4).sum() + + x = torch.nn.Parameter(x_val.clone()) + + probe_gen = torch.Generator().manual_seed(2) + diag_hutch = hessian_diagonal( + lambda: f(x), [x], n_probes=4000, generator=probe_gen, probe="rademacher" + ) + + true_diag = torch.diagonal(torch.autograd.functional.hessian(f, x_val.clone())) + rel = (diag_hutch - true_diag).norm() / true_diag.norm() + assert rel < 0.1 diff --git a/torchref/refinement/optimizers/__init__.py b/torchref/refinement/optimizers/__init__.py index 6e202c7..5cf1a3b 100644 --- a/torchref/refinement/optimizers/__init__.py +++ b/torchref/refinement/optimizers/__init__.py @@ -6,12 +6,19 @@ - ExploratoryLBFGS: LBFGS with automatic landscape exploration via Lanczos - LangevinSA: BAOAB Langevin dynamics with simulated annealing - MomentumStochasticSA: Phenix-style SA (gradient descent + momentum + noise) +- SeededLBFGS: L-BFGS whose first step is a diagonal-Newton step """ from .adam_noise import AdamWithAdaptiveNoise +from .curvature import ( + hessian_diagonal, + hessian_diagonal_preconditioner, + preconditioner_from_diagonal, +) from .exploratory_lbfgs import ExploratoryLBFGS from .langevin_sa import LangevinSA from .momentum_sa import MomentumStochasticSA +from .seeded_lbfgs import SeededLBFGS __all__ = [ @@ -19,4 +26,8 @@ "ExploratoryLBFGS", "LangevinSA", "MomentumStochasticSA", + "SeededLBFGS", + "hessian_diagonal", + "hessian_diagonal_preconditioner", + "preconditioner_from_diagonal", ] diff --git a/torchref/refinement/optimizers/curvature.py b/torchref/refinement/optimizers/curvature.py new file mode 100644 index 0000000..ec6a2d3 --- /dev/null +++ b/torchref/refinement/optimizers/curvature.py @@ -0,0 +1,256 @@ +"""Hessian-diagonal estimation for seeding :class:`SeededLBFGS`. + +Provides a stochastic (Hutchinson) estimate of the diagonal of the loss Hessian +and the derived inverse-curvature preconditioner ``1/(|diag|+lambda)`` used to +seed the first L-BFGS step. + +The estimate uses *analytic* double-backward Hessian-vector products:: + + (g,) = torch.autograd.grad(loss, params, create_graph=True) # once + (Hv,) = torch.autograd.grad((g * v).sum(), params) # per probe + +which is only correct on the pure-torch electron-density path, so everything runs +under ``use_portable()``. Finite-difference HVPs are deliberately NOT +used: the production Triton/CUDA kernels emit float32 gradients, so central +differences ``(g(x+eps v) - g(x-eps v))`` catastrophically cancel. The analytic +HVP has no such cancellation and float32 is adequate for a preconditioner. + +The returned diagonal is flat, aligned to ``cat(p.reshape(-1) for p in params)`` +-- the same layout as ``torch.optim.LBFGS._gather_flat_grad`` -- so it can be fed +straight to :meth:`SeededLBFGS.set_init_hess_diag`. +""" + +from __future__ import annotations + +from typing import Callable, Optional, Sequence + +import torch + +from torchref.utils import use_portable + + +def _sample_probe( + numel: int, + probe: str, + generator: Optional[torch.Generator], + device: torch.device, + dtype: torch.dtype, +) -> torch.Tensor: + """Draw one Hutchinson probe vector of length ``numel``.""" + if probe == "rademacher": + r = torch.randint( + 0, 2, (numel,), generator=generator, device=device, dtype=torch.int64 + ) + return r.to(dtype).mul_(2.0).sub_(1.0) # {0,1} -> {-1,+1} + if probe == "gaussian": + return torch.randn(numel, generator=generator, device=device, dtype=dtype) + raise ValueError(f"unknown probe type {probe!r}") + + +def _flatten(tensors: Sequence[torch.Tensor]) -> torch.Tensor: + return torch.cat([t.reshape(-1) for t in tensors]) + + +def hessian_diagonal( + aggregate: Callable[[], torch.Tensor], + params: Sequence[torch.nn.Parameter], + *, + n_probes: int = 16, + generator: Optional[torch.Generator] = None, + probe: str = "rademacher", + basis_max_numel: int = 4096, +) -> torch.Tensor: + """Estimate the diagonal of the Hessian of ``aggregate()`` w.r.t. ``params``. + + Parameters + ---------- + aggregate : callable + Zero-argument callable returning the scalar loss tensor (e.g. + ``LossState.aggregate``). Called once; the graph is retained so that + ``n_probes`` Hessian-vector products can reuse the first-order backward. + params : sequence of nn.Parameter + The leaves to differentiate w.r.t., in the SAME order they are passed to + the optimizer (so the flat result matches ``_gather_flat_grad``). + n_probes : int + Number of Hutchinson probes (ignored when ``probe="basis"``). + generator : torch.Generator, optional + For deterministic probes. + probe : {"rademacher", "gaussian", "basis"} + ``"basis"`` computes the EXACT diagonal in ``numel`` HVPs (verification / + small problems only; capped by ``basis_max_numel``). + + Notes + ----- + The double-backward runs under :func:`torchref.utils.use_portable`, which + pins the portable reference kernel -- the only path that composes correctly + under ``create_graph=True`` for the Hessian-vector products. + + Returns + ------- + torch.Tensor + Flat detached estimate of ``diag(H)`` aligned to + ``cat(p.reshape(-1) for p in params)``. + """ + params = list(params) + if any(torch.is_complex(p) for p in params): + raise ValueError("hessian_diagonal does not support complex parameters") + # Differentiate only through leaves that require grad; frozen leaves (e.g. + # occupancy held fixed, or aniso U on an isotropic model) get zero curvature + # -> a bounded 1/lam seed. Harmless: their gradient is zero, so the optimizer + # never moves them regardless of the seed value. The flat layout still spans + # the FULL params list so it matches the optimizer's _gather_flat_grad order. + active = [p for p in params if p.requires_grad] + if not active: + raise ValueError("no params require grad; nothing to differentiate") + + def _assemble_full(active_parts): + """Map a per-active-param list back to a full-length flat tensor in + ``params`` order, filling detached zeros for frozen params.""" + it = iter(active_parts) + pieces = [] + for p in params: + if p.requires_grad: + pieces.append(next(it).reshape(-1)) + else: + pieces.append(torch.zeros(p.numel(), device=p.device, dtype=p.dtype)) + return torch.cat(pieces) + + with use_portable(): + loss = aggregate() + # allow_unused: an active leaf may still not enter the loss. + grads = torch.autograd.grad( + loss, active, create_graph=True, allow_unused=True + ) + grads = [ + g if g is not None else torch.zeros_like(p) + for g, p in zip(grads, active) + ] + g_flat = _assemble_full(grads) # graph-attached for active slices + numel = g_flat.numel() + device, dtype = g_flat.device, g_flat.dtype + + def _hvp(v: torch.Tensor, retain: bool) -> torch.Tensor: + parts = torch.autograd.grad( + (g_flat * v).sum(), + active, + retain_graph=retain, + create_graph=False, + allow_unused=True, + ) + parts = [ + pt if pt is not None else torch.zeros_like(p) + for pt, p in zip(parts, active) + ] + return _assemble_full(parts) + + diag = torch.zeros(numel, device=device, dtype=dtype) + + if probe == "basis": + if numel > basis_max_numel: + raise ValueError( + f"probe='basis' needs {numel} HVPs; exceeds basis_max_numel " + f"({basis_max_numel}). Use rademacher/gaussian for large problems." + ) + for i in range(numel): + v = torch.zeros(numel, device=device, dtype=dtype) + v[i] = 1.0 + diag[i] = _hvp(v, retain=(i < numel - 1))[i] + return _sanitize_diag(diag.detach()) + + for k in range(n_probes): + v = _sample_probe(numel, probe, generator, device, dtype) + diag.add_(v * _hvp(v, retain=(k < n_probes - 1))) + diag.div_(n_probes) + return _sanitize_diag(diag.detach()) + + +def _sanitize_diag(diag: torch.Tensor) -> torch.Tensor: + """Replace sporadic non-finite diagonal entries with the max finite |diag| + (→ stiffest, i.e. smallest, safest seeded step for those directions). If NO + entry is finite (the whole HVP blew up), leave it non-finite so the caller + can detect it and skip seeding this cycle. + """ + finite = torch.isfinite(diag) + if finite.all() or not finite.any(): + return diag + fill = diag[finite].abs().max() + return torch.where(finite, diag, fill) + + +def preconditioner_from_diagonal( + diag: torch.Tensor, lam: float = 1e-2, group_sizes=None +) -> torch.Tensor: + """Turn a Hessian diagonal into a positive inverse-curvature preconditioner. + + ``precond = 1 / max(|diag|, lam * max|diag|)`` -- the absolute value makes + negative (indefinite) curvature still yield a descent direction, and the + **relative** floor ``lam * max|diag|`` caps the preconditioner ratio at + ``1/lam`` (default 100:1). This is what keeps the seeded first step safe far + from the minimum: an absolute floor lets a near-zero-curvature (flat) + direction get an unbounded ``1/lam`` preconditioner, so the Newton-scaled + ``t=1`` step blows those directions to non-finite geometry. The relative + floor bounds the worst-conditioned direction's step to ``1/lam`` times the + stiffest direction's, keeping the whole step in a trust-region-like range. + + Parameters + ---------- + group_sizes : sequence of int, optional + Contiguous group lengths summing to ``diag.numel()`` (e.g. one entry per + ``nn.Parameter``). When given, the relative floor is computed **per group** + (each slice floored by its own ``max|diag|``) instead of globally. This + matters when groups have curvatures of very different magnitude -- e.g. + the few scaler params each touch all reflections and have far larger + loss-curvature than a single coordinate, so a global floor would crush the + body group to a near-zero step. Per-group flooring gives each group its own + Newton scale. + """ + if group_sizes is None: + return _precond_slice(diag, lam) + if int(sum(group_sizes)) != diag.numel(): + raise ValueError( + f"group_sizes sum {int(sum(group_sizes))} != diag numel {diag.numel()}" + ) + out = torch.empty_like(diag) + off = 0 + for n in group_sizes: + out[off:off + n] = _precond_slice(diag[off:off + n], lam) + off += n + return out + + +def _precond_slice(diag: torch.Tensor, lam: float) -> torch.Tensor: + if diag.numel() == 0: # empty group (e.g. absent aniso U / occupancy leaf) + return diag + a = diag.abs() + amax = a.max() + # abs backstop for the degenerate all-flat case (amax == 0) + floor = torch.clamp(lam * amax, min=torch.finfo(a.dtype).eps) + return 1.0 / torch.clamp(a, min=floor) + + +def hessian_diagonal_preconditioner( + aggregate: Callable[[], torch.Tensor], + params: Sequence[torch.nn.Parameter], + *, + n_probes: int = 16, + lam: float = 1e-2, + per_group: bool = False, + generator: Optional[torch.Generator] = None, + probe: str = "rademacher", +) -> torch.Tensor: + """Convenience: :func:`hessian_diagonal` then :func:`preconditioner_from_diagonal`. + + When ``per_group=True`` the relative floor is applied per ``params`` entry + (each ``nn.Parameter`` floored by its own ``max|diag|``) -- use this when the + params span groups of very different curvature magnitude (e.g. body + scaler). + """ + params = list(params) + diag = hessian_diagonal( + aggregate, + params, + n_probes=n_probes, + generator=generator, + probe=probe, + ) + group_sizes = [p.numel() for p in params] if per_group else None + return preconditioner_from_diagonal(diag, lam=lam, group_sizes=group_sizes) diff --git a/torchref/refinement/optimizers/seeded_lbfgs.py b/torchref/refinement/optimizers/seeded_lbfgs.py new file mode 100644 index 0000000..f05ca29 --- /dev/null +++ b/torchref/refinement/optimizers/seeded_lbfgs.py @@ -0,0 +1,300 @@ +"""SeededLBFGS: L-BFGS whose first step is a diagonal-Newton step. + +Stock :class:`torch.optim.LBFGS` initialises its inverse-Hessian approximation +with a *scalar* (``H_diag = ys / y.y``), so its very first search direction is +plain steepest descent ``d = -g``. That is badly conditioned when the parameter +blocks have very different curvature scales -- e.g. joint refinement of atomic +coordinates (Angstrom), ADPs (Angstrom^2 / log) and occupancies (sigmoid-logit) +in a single L-BFGS call. + +``SeededLBFGS`` accepts a per-parameter *inverse-curvature preconditioner* +``precond_i = 1 / (|H_ii| + lambda)`` (the diagonal of the loss Hessian, +regularised) and uses it to seed the **first** inner iteration only: + + d_0 = -(precond .* g) # diagonal-Newton direction + t_0 = lr # Newton scale -> start line search at t = 1 + +Every subsequent iteration is identical to stock L-BFGS (scalar ``H_diag`` seed, +strong-Wolfe line search, two-loop recursion). The seed fires exactly while the +curvature history is empty (``state["n_iter"] == 1``), which resets whenever +``optimizer.state.clear()`` is called -- so re-seeding a fresh block is just +``clear()`` + :meth:`set_init_hess_diag`. + +The diagonal itself is produced by +:mod:`torchref.refinement.optimizers.curvature` (Hutchinson estimate via analytic +double-backward Hessian-vector products under ``use_portable()``); this optimizer is +agnostic to how the preconditioner was obtained. + +Notes +----- +* With ``init_hess_diag=None`` the trajectory is **bit-identical** to + :class:`torch.optim.LBFGS` (both changed branches fall through to the exact base + expressions). +* With an all-ones preconditioner the first *direction* equals stock steepest + descent, but the first trial step length differs (``t = lr`` vs the + ``min(1, 1/||g||_1)`` heuristic), so the trajectory is *not* bit-identical -- + this is deliberate (the diagonal supplies Newton scale). +""" + +from __future__ import annotations + +from typing import Optional + +import torch +from torch.optim.lbfgs import _strong_wolfe, _to_scalar + + +class SeededLBFGS(torch.optim.LBFGS): + """L-BFGS that seeds its first step from the diagonal of the Hessian. + + Constructed exactly like :class:`torch.optim.LBFGS`. Before running, hand it + the inverse-curvature preconditioner via :meth:`set_init_hess_diag` (a flat + tensor in ``_gather_flat_grad`` order over the optimizer's parameters). Pass + ``None`` (the default) to behave exactly like stock L-BFGS. + """ + + def __init__(self, *args, init_hess_diag: Optional[torch.Tensor] = None, **kwargs): + super().__init__(*args, **kwargs) + self._init_hess_diag: Optional[torch.Tensor] = None + if init_hess_diag is not None: + self.set_init_hess_diag(init_hess_diag) + + def set_init_hess_diag(self, precond: Optional[torch.Tensor]) -> None: + """Set the flat inverse-curvature preconditioner ``1/(|diag|+lambda)``. + + Parameters + ---------- + precond : torch.Tensor or None + Flat tensor aligned to ``cat(p.view(-1) for p in params)`` (the same + layout as :meth:`_gather_flat_grad`). ``None`` disables seeding + (stock L-BFGS behaviour). The seed is applied to the first inner + iteration of the next :meth:`step` call whose curvature history is + empty. + """ + if precond is None: + self._init_hess_diag = None + return + if precond.dim() != 1: + precond = precond.reshape(-1) + expected = self._numel() + if precond.numel() != expected: + raise ValueError( + f"init_hess_diag numel {precond.numel()} != param numel {expected}" + ) + self._init_hess_diag = precond + + @torch.no_grad() + def step(self, closure): # type: ignore[override] + """Perform a single optimization step. + + Identical to :meth:`torch.optim.LBFGS.step` except that, on the first + inner iteration when a seed is set, the search direction is the + diagonal-Newton step ``-(precond .* g)`` and the initial trial step + length is ``lr``. + + Args: + closure (Callable): A closure that reevaluates the model and returns + the loss. + """ + assert len(self.param_groups) == 1 + + # Make sure the closure is always called with grad enabled + closure = torch.enable_grad()(closure) + + group = self.param_groups[0] + lr = _to_scalar(group["lr"]) + max_iter = group["max_iter"] + max_eval = group["max_eval"] + tolerance_grad = group["tolerance_grad"] + tolerance_change = group["tolerance_change"] + line_search_fn = group["line_search_fn"] + history_size = group["history_size"] + + # NOTE: LBFGS has only global state, but we register it as state for + # the first param, because this helps with casting in load_state_dict + state = self.state[self._params[0]] + state.setdefault("func_evals", 0) + state.setdefault("n_iter", 0) + + # evaluate initial f(x) and df/dx + orig_loss = closure() + loss = float(orig_loss) + current_evals = 1 + state["func_evals"] += 1 + + flat_grad = self._gather_flat_grad() + opt_cond = flat_grad.abs().max() <= tolerance_grad + + # optimal condition + if opt_cond: + return orig_loss + + # tensors cached in state (for tracing) + d = state.get("d") + t = state.get("t") + old_dirs = state.get("old_dirs") + old_stps = state.get("old_stps") + ro = state.get("ro") + H_diag = state.get("H_diag") + prev_flat_grad = state.get("prev_flat_grad") + prev_loss = state.get("prev_loss") + + # Resolve the diagonal seed once for this .step() call. Only consulted on + # the first inner iteration (empty history); cast to the grad's layout. + seed = self._init_hess_diag + if seed is not None: + seed = seed.to(device=flat_grad.device, dtype=flat_grad.dtype) + + n_iter = 0 + # optimize for a max of max_iter iterations + while n_iter < max_iter: + # keep track of nb of iterations + n_iter += 1 + state["n_iter"] += 1 + + ############################################################ + # compute gradient descent direction + ############################################################ + if state["n_iter"] == 1: + if seed is not None: + # ---- seeded first step: diagonal-Newton direction ---- + d = flat_grad.neg().mul_(seed) + else: + d = flat_grad.neg() + old_dirs = [] + old_stps = [] + ro = [] + H_diag = 1 + else: + # do lbfgs update (update memory) + y = flat_grad.sub(prev_flat_grad) + s = d.mul(t) + ys = y.dot(s) # y*s + if ys > 1e-10: + # updating memory + if len(old_dirs) == history_size: + # shift history by one (limited-memory) + old_dirs.pop(0) + old_stps.pop(0) + ro.pop(0) + + # store new direction/step + old_dirs.append(y) + old_stps.append(s) + ro.append(1.0 / ys) + + # update scale of initial Hessian approximation + H_diag = ys / y.dot(y) # (y*y) + + # compute the approximate (L-BFGS) inverse Hessian + # multiplied by the gradient + num_old = len(old_dirs) + + if "al" not in state: + state["al"] = [None] * history_size + al = state["al"] + + # iteration in L-BFGS loop collapsed to use just one buffer + q = flat_grad.neg() + for i in range(num_old - 1, -1, -1): + al[i] = old_stps[i].dot(q) * ro[i] + q.add_(old_dirs[i], alpha=-al[i]) + + # multiply by initial Hessian + # r/d is the final direction + d = r = torch.mul(q, H_diag) + for i in range(num_old): + be_i = old_dirs[i].dot(r) * ro[i] + r.add_(old_stps[i], alpha=al[i] - be_i) + + if prev_flat_grad is None: + prev_flat_grad = flat_grad.clone(memory_format=torch.contiguous_format) + else: + prev_flat_grad.copy_(flat_grad) + prev_loss = loss + + ############################################################ + # compute step length + ############################################################ + # reset initial guess for step size + if state["n_iter"] == 1: + if seed is not None: + # diagonal seed already carries Newton scale -> t = 1 (lr) + t = lr + else: + t = min(1.0, 1.0 / flat_grad.abs().sum()) * lr + else: + t = lr + + # directional derivative + gtd = flat_grad.dot(d) # g * d + + # directional derivative is below tolerance + if gtd > -tolerance_change: + break + + # optional line search: user function + ls_func_evals = 0 + if line_search_fn is not None: + # perform line search, using user function + if line_search_fn != "strong_wolfe": + raise RuntimeError("only 'strong_wolfe' is supported") + else: + x_init = self._clone_param() + + def obj_func(x, t, d): + return self._directional_evaluate(closure, x, t, d) + + loss, flat_grad, t, ls_func_evals = _strong_wolfe( + obj_func, x_init, t, d, loss, flat_grad, gtd + ) + self._add_grad(t, d) + opt_cond = flat_grad.abs().max() <= tolerance_grad + else: + # no line search, simply move with fixed-step + self._add_grad(t, d) + if n_iter != max_iter: + # re-evaluate function only if not in last iteration + # the reason we do this: in a stochastic setting, + # no use to re-evaluate that function here + with torch.enable_grad(): + loss = closure() + loss = float(loss) + flat_grad = self._gather_flat_grad() + opt_cond = flat_grad.abs().max() <= tolerance_grad + ls_func_evals = 1 + + # update func eval + current_evals += ls_func_evals + state["func_evals"] += ls_func_evals + + ############################################################ + # check conditions + ############################################################ + if n_iter == max_iter: + break + + if current_evals >= max_eval: + break + + # optimal condition + if opt_cond: + break + + # lack of progress + if d.mul(t).abs().max() <= tolerance_change: + break + + if abs(loss - prev_loss) < tolerance_change: + break + + state["d"] = d + state["t"] = t + state["old_dirs"] = old_dirs + state["old_stps"] = old_stps + state["ro"] = ro + state["H_diag"] = H_diag + state["prev_flat_grad"] = prev_flat_grad + state["prev_loss"] = prev_loss + + return orig_loss From d112f8e0f654f913e6ca43d2e3ec8f3afe408f81 Mon Sep 17 00:00:00 2001 From: HatPdotS Date: Thu, 30 Jul 2026 15:11:06 +0200 Subject: [PATCH 14/15] Deleted accidentally added doctest output --- docs/doctest_output.out | 5545 --------------------------------------- 1 file changed, 5545 deletions(-) delete mode 100644 docs/doctest_output.out diff --git a/docs/doctest_output.out b/docs/doctest_output.out deleted file mode 100644 index 82a11f1..0000000 --- a/docs/doctest_output.out +++ /dev/null @@ -1,5545 +0,0 @@ -Running Sphinx v8.2.3 -loading translations [en]... done -loading pickled environment... done -[autosummary] generating autosummary for: api/io.rst, api/math_functions.rst, api/model.rst, api/refinement.rst, api/restraints.rst, api/scaling.rst, api/symmetrie.rst, api/utils.rst, changelog.rst, contributing.rst, index.rst, installation.rst, quickstart.rst, user_guide/refinement.rst, user_guide/restraints.rst, user_guide/scaling.rst, user_guide/targets.rst -building [mo]: targets for 0 po files that are out of date -writing output... -building [doctest]: targets for 17 source files that are out of date -updating environment: 0 added, 3 changed, 0 removed -reading sources... [ 33%] api/io -reading sources... [ 67%] api/refinement -reading sources... [100%] api/symmetrie - -looking for now-outdated files... none found -pickling environment... done -checking consistency... done -preparing documents... done -copying assets... -copying assets: done -running tests... - -Document: api/io ----------------- -********************************************************************** -File "../torchref/io/__init__.py", line ?, in default -Failed example: - data.load_mtz('structure.mtz') -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - data.load_mtz('structure.mtz') - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/datasets/reflection_data.py", line 233, in load_mtz - reader = mtz.MTZReader(verbose=self.verbose).read(path) - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/mtz.py", line 158, in read - self.mtz_data = rs.read_mtz(filepath) - ^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/site-packages/reciprocalspaceship/io/mtz.py", line 186, in read_mtz - gemmi_mtz = gemmi.read_mtz_file(mtzfile) - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - RuntimeError: Failed to open structure.mtz: No such file or directory: structure.mtz -********************************************************************** -File "../torchref/io/__init__.py", line ?, in default -Failed example: - collection.add_dataset('native', native_data) -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - collection.add_dataset('native', native_data) - ^^^^^^^^^^^ - NameError: name 'native_data' is not defined -********************************************************************** -File "../torchref/io/__init__.py", line ?, in default -Failed example: - collection.add_dataset('derivative', derivative_data) -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - collection.add_dataset('derivative', derivative_data) - ^^^^^^^^^^^^^^^ - NameError: name 'derivative_data' is not defined -********************************************************************** -File "../torchref/io/__init__.py", line ?, in default -Failed example: - reader = mtz.read('data.mtz') -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - reader = mtz.read('data.mtz') - ^^^^^^^^^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/mtz.py", line 347, in read - return MTZReader(verbose=verbose).read(filepath) - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/mtz.py", line 158, in read - self.mtz_data = rs.read_mtz(filepath) - ^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/site-packages/reciprocalspaceship/io/mtz.py", line 186, in read_mtz - gemmi_mtz = gemmi.read_mtz_file(mtzfile) - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - RuntimeError: Failed to open data.mtz: No such file or directory: data.mtz -********************************************************************** -File "../torchref/io/__init__.py", line ?, in default -Failed example: - data_dict, cell, spacegroup = reader() -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - data_dict, cell, spacegroup = reader() - ^^^^^^ - NameError: name 'reader' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - data = CrystalDataset(device='cuda') -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - data = CrystalDataset(device='cuda') - ^^^^^^^^^^^^^^ - NameError: name 'CrystalDataset' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - data.cpu() # Move all tensors to CPU -Expected nothing -Got: - ReflectionData(n=2, sg=None) -********************************************************************** -File "None", line ?, in default -Failed example: - data.save('data.pt') # Serialize to file -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - data.save('data.pt') # Serialize to file - ^^^^^^^^^ - AttributeError: 'ReflectionData' object has no attribute 'save' -********************************************************************** -File "None", line ?, in default -Failed example: - data = ReflectionData.load_state('reflection_data.pt', device='cuda') -Expected nothing -Got: - Loaded ReflectionData from reflection_data.pt -********************************************************************** -File "None", line ?, in default -Failed example: - data.save_state('reflection_data.pt') -Expected nothing -Got: - Saved ReflectionData to reflection_data.pt -********************************************************************** -File "None", line ?, in default -Failed example: - data.load_mtz('data.mtz') -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - data.load_mtz('data.mtz') - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/datasets/reflection_data.py", line 233, in load_mtz - reader = mtz.MTZReader(verbose=self.verbose).read(path) - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/mtz.py", line 158, in read - self.mtz_data = rs.read_mtz(filepath) - ^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/site-packages/reciprocalspaceship/io/mtz.py", line 186, in read_mtz - gemmi_mtz = gemmi.read_mtz_file(mtzfile) - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - RuntimeError: Failed to open data.mtz: No such file or directory: data.mtz -********************************************************************** -File "None", line ?, in default -Failed example: - print(f"Loaded {len(data.hkl)} reflections") -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - print(f"Loaded {len(data.hkl)} reflections") - ^^^^^^^^^^^^^ - TypeError: object of type 'NoneType' has no len() -********************************************************************** -File "None", line ?, in default -Failed example: - print(f"Resolution range: {data.resolution.min():.2f} - {data.resolution.max():.2f} Å") -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - print(f"Resolution range: {data.resolution.min():.2f} - {data.resolution.max():.2f} Å") - ^^^^^^^^^^^^^^^^^^^ - AttributeError: 'NoneType' object has no attribute 'min' -********************************************************************** -File "None", line ?, in default -Failed example: - hkl, F, sigma, rfree = data() -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - hkl, F, sigma, rfree = data() - ^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/datasets/reflection_data.py", line 1318, in __call__ - F = MaskedTensor(F.detach().clone(), to_mask) - ^^^^^^^^ - AttributeError: 'NoneType' object has no attribute 'detach' -********************************************************************** -File "None", line ?, in default -Failed example: - print(F.shape) # Full shape -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - print(F.shape) # Full shape - ^ - NameError: name 'F' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - print(F.sum()) # Only sums valid (unmasked) values -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - print(F.sum()) # Only sums valid (unmasked) values - ^ - NameError: name 'F' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - valid_mask = F.get_mask() -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - valid_mask = F.get_mask() - ^ - NameError: name 'F' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - F_values = F.get_data()[valid_mask] -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - F_values = F.get_data()[valid_mask] - ^ - NameError: name 'F' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - filtered = data.cut_res(highres=1.5, lowres=50.0) -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - filtered = data.cut_res(highres=1.5, lowres=50.0) - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/datasets/reflection_data.py", line 1071, in cut_res - return self.filter_by_resolution(d_min=highres, d_max=lowres) - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/datasets/reflection_data.py", line 1014, in filter_by_resolution - self._calculate_resolution() - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/datasets/reflection_data.py", line 630, in _calculate_resolution - raise ValueError( - ValueError: Miller indices (hkl) are required to calculate resolution -********************************************************************** -File "None", line ?, in default -Failed example: - high_res = data.cut_res(highres=1.0, lowres=2.0) -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - high_res = data.cut_res(highres=1.0, lowres=2.0) - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/datasets/reflection_data.py", line 1071, in cut_res - return self.filter_by_resolution(d_min=highres, d_max=lowres) - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/datasets/reflection_data.py", line 1014, in filter_by_resolution - self._calculate_resolution() - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/datasets/reflection_data.py", line 630, in _calculate_resolution - raise ValueError( - ValueError: Miller indices (hkl) are required to calculate resolution -********************************************************************** -File "None", line ?, in default -Failed example: - hkl, F, sigma, rfree = data.forward_indexed() -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - hkl, F, sigma, rfree = data.forward_indexed() - ^^^^^^^^^^^^^^^^^^^^ - AttributeError: 'ReflectionData' object has no attribute 'forward_indexed' -********************************************************************** -File "None", line ?, in default -Failed example: - F_np = F.cpu().numpy() # Safe for writing to files -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - F_np = F.cpu().numpy() # Safe for writing to files - ^ - NameError: name 'F' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - data = ReflectionData().load_mtz('data.mtz') -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - data = ReflectionData().load_mtz('data.mtz') - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/datasets/reflection_data.py", line 233, in load_mtz - reader = mtz.MTZReader(verbose=self.verbose).read(path) - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/mtz.py", line 158, in read - self.mtz_data = rs.read_mtz(filepath) - ^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/site-packages/reciprocalspaceship/io/mtz.py", line 186, in read_mtz - gemmi_mtz = gemmi.read_mtz_file(mtzfile) - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - RuntimeError: Failed to open data.mtz: No such file or directory: data.mtz -********************************************************************** -File "None", line ?, in default -Failed example: - print(f"Original: {len(data)} reflections, {data.spacegroup}") -Expected nothing -Got: - Original: 0 reflections, None -********************************************************************** -File "None", line ?, in default -Failed example: - data_p1 = data.expand_to_p1() -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - data_p1 = data.expand_to_p1() - ^^^^^^^^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/datasets/reflection_data.py", line 2270, in expand_to_p1 - raise ValueError("ReflectionData has no Miller indices loaded") - ValueError: ReflectionData has no Miller indices loaded -********************************************************************** -File "None", line ?, in default -Failed example: - print(f"Expanded: {len(data_p1)} reflections, {data_p1.spacegroup}") -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - print(f"Expanded: {len(data_p1)} reflections, {data_p1.spacegroup}") - ^^^^^^^ - NameError: name 'data_p1' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - data_p1_anom = data.expand_to_p1(include_friedel=False) -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - data_p1_anom = data.expand_to_p1(include_friedel=False) - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/datasets/reflection_data.py", line 2270, in expand_to_p1 - raise ValueError("ReflectionData has no Miller indices loaded") - ValueError: ReflectionData has no Miller indices loaded -********************************************************************** -File "None", line ?, in default -Failed example: - data = ReflectionData().load_mtz('data.mtz') -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - data = ReflectionData().load_mtz('data.mtz') - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/datasets/reflection_data.py", line 233, in load_mtz - reader = mtz.MTZReader(verbose=self.verbose).read(path) - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/mtz.py", line 158, in read - self.mtz_data = rs.read_mtz(filepath) - ^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/site-packages/reciprocalspaceship/io/mtz.py", line 186, in read_mtz - gemmi_mtz = gemmi.read_mtz_file(mtzfile) - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - RuntimeError: Failed to open data.mtz: No such file or directory: data.mtz -********************************************************************** -File "None", line ?, in default -Failed example: - data_filled = data.fill(d_min=2.0) -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - data_filled = data.fill(d_min=2.0) - ^^^^^^^^^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/datasets/reflection_data.py", line 2191, in fill - raise ValueError("ReflectionData has no Miller indices loaded") - ValueError: ReflectionData has no Miller indices loaded -********************************************************************** -File "None", line ?, in default -Failed example: - print(f"Original: {len(data)}, Filled: {len(data_filled)}") -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - print(f"Original: {len(data)}, Filled: {len(data_filled)}") - ^^^^^^^^^^^ - NameError: name 'data_filled' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - F, sigma_F = data.get_structure_factors_with_sigma() -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - F, sigma_F = data.get_structure_factors_with_sigma() - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/datasets/reflection_data.py", line 971, in get_structure_factors_with_sigma - raise ValueError("No amplitude data loaded") - ValueError: No amplitude data loaded -********************************************************************** -File "None", line ?, in default -Failed example: - if sigma_F is not None: - weighted_residual = (F_obs - F_calc) / sigma_F -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - if sigma_F is not None: - ^^^^^^^ - NameError: name 'sigma_F' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - print(f"{mask.sum()} of {len(mask)} reflections are valid") -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - print(f"{mask.sum()} of {len(mask)} reflections are valid") - ^^^^^^^^ - AttributeError: 'NoneType' object has no attribute 'sum' -********************************************************************** -File "None", line ?, in default -Failed example: - blocks = ReflectionData.list_cif_data_blocks('1VLM-sf.cif') -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - blocks = ReflectionData.list_cif_data_blocks('1VLM-sf.cif') - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/datasets/reflection_data.py", line 283, in list_cif_data_blocks - return cif.list_data_blocks(path) - ^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/cif.py", line 163, in list_data_blocks - reader = CIFReader(filepath) - ^^^^^^^^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/cif_readers.py", line 80, in __init__ - self.load(filepath) - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/cif_readers.py", line 92, in load - with open(filepath, "r", encoding="utf-8", errors="ignore") as f: - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - FileNotFoundError: [Errno 2] No such file or directory: '1VLM-sf.cif' -********************************************************************** -File "None", line ?, in default -Failed example: - print(blocks) -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - print(blocks) - ^^^^^^ - NameError: name 'blocks' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - data = ReflectionData().load_cif('1VLM-sf.cif', data_block=blocks[1]) -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - data = ReflectionData().load_cif('1VLM-sf.cif', data_block=blocks[1]) - ^^^^^^ - NameError: name 'blocks' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - data_p1 = data.expand_to_p1() -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - data_p1 = data.expand_to_p1() - ^^^^^^^^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/datasets/reflection_data.py", line 2270, in expand_to_p1 - raise ValueError("ReflectionData has no Miller indices loaded") - ValueError: ReflectionData has no Miller indices loaded -********************************************************************** -File "None", line ?, in default -Failed example: - data_merged = data_p1.reduce_to_spacegroup('P21') -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - data_merged = data_p1.reduce_to_spacegroup('P21') - ^^^^^^^ - NameError: name 'data_p1' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - data_summed = data_p1.reduce_to_spacegroup('P21', aggregation='sum') -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - data_summed = data_p1.reduce_to_spacegroup('P21', aggregation='sum') - ^^^^^^^ - NameError: name 'data_p1' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - data.regenerate_rfree_flags(free_fraction=0.02, n_bins=20, seed=42) -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - data.regenerate_rfree_flags(free_fraction=0.02, n_bins=20, seed=42) - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/datasets/reflection_data.py", line 611, in regenerate_rfree_flags - self._generate_rfree_flags( - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/datasets/reflection_data.py", line 323, in _generate_rfree_flags - raise ValueError("Resolution information required to generate R-free flags") - ValueError: Resolution information required to generate R-free flags -********************************************************************** -File "None", line ?, in default -Failed example: - data.regenerate_rfree_flags(free_fraction=0.05, n_bins=10, force=True) -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - data.regenerate_rfree_flags(free_fraction=0.05, n_bins=10, force=True) - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/datasets/reflection_data.py", line 611, in regenerate_rfree_flags - self._generate_rfree_flags( - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/datasets/reflection_data.py", line 323, in _generate_rfree_flags - raise ValueError("Resolution information required to generate R-free flags") - ValueError: Resolution information required to generate R-free flags -********************************************************************** -File "None", line ?, in default -Failed example: - reference_hkl = data1.hkl.clone() -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - reference_hkl = data1.hkl.clone() - ^^^^^ - NameError: name 'data1' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - data1.validate_hkl(reference_hkl) -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - data1.validate_hkl(reference_hkl) - ^^^^^ - NameError: name 'data1' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - data2.validate_hkl(reference_hkl) -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - data2.validate_hkl(reference_hkl) - ^^^^^ - NameError: name 'data2' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - assert data1.hkl.shape == data2.hkl.shape -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - assert data1.hkl.shape == data2.hkl.shape - ^^^^^ - NameError: name 'data1' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - data = ReflectionData().load_mtz('observed.mtz') -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - data = ReflectionData().load_mtz('observed.mtz') - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/datasets/reflection_data.py", line 233, in load_mtz - reader = mtz.MTZReader(verbose=self.verbose).read(path) - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/mtz.py", line 158, in read - self.mtz_data = rs.read_mtz(filepath) - ^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/site-packages/reciprocalspaceship/io/mtz.py", line 186, in read_mtz - gemmi_mtz = gemmi.read_mtz_file(mtzfile) - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - RuntimeError: Failed to open observed.mtz: No such file or directory: observed.mtz -********************************************************************** -File "None", line ?, in default -Failed example: - model = Model().load_pdb('model.pdb') -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - model = Model().load_pdb('model.pdb') - ^^^^^ - NameError: name 'Model' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - model_ft = ModelFT(model, data.cell, data.spacegroup) -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - model_ft = ModelFT(model, data.cell, data.spacegroup) - ^^^^^^^ - NameError: name 'ModelFT' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - fcalc = model_ft.forward(data.hkl) -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - fcalc = model_ft.forward(data.hkl) - ^^^^^^^^ - NameError: name 'model_ft' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - data.write_mtz('output.mtz', fcalc=fcalc) -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - data.write_mtz('output.mtz', fcalc=fcalc) - ^^^^^ - NameError: name 'fcalc' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - native = ReflectionData().load_mtz('native.mtz') -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - native = ReflectionData().load_mtz('native.mtz') - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/datasets/reflection_data.py", line 233, in load_mtz - reader = mtz.MTZReader(verbose=self.verbose).read(path) - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/mtz.py", line 158, in read - self.mtz_data = rs.read_mtz(filepath) - ^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/site-packages/reciprocalspaceship/io/mtz.py", line 186, in read_mtz - gemmi_mtz = gemmi.read_mtz_file(mtzfile) - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - RuntimeError: Failed to open native.mtz: No such file or directory: native.mtz -********************************************************************** -File "None", line ?, in default -Failed example: - derivative = ReflectionData().load_mtz('derivative.mtz') -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - derivative = ReflectionData().load_mtz('derivative.mtz') - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/datasets/reflection_data.py", line 233, in load_mtz - reader = mtz.MTZReader(verbose=self.verbose).read(path) - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/mtz.py", line 158, in read - self.mtz_data = rs.read_mtz(filepath) - ^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/site-packages/reciprocalspaceship/io/mtz.py", line 186, in read_mtz - gemmi_mtz = gemmi.read_mtz_file(mtzfile) - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - RuntimeError: Failed to open derivative.mtz: No such file or directory: derivative.mtz -********************************************************************** -File "None", line ?, in default -Failed example: - collection.add_dataset('native', native, set_as_reference=True) -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - collection.add_dataset('native', native, set_as_reference=True) - ^^^^^^ - NameError: name 'native' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - collection.add_dataset('derivative', derivative) -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - collection.add_dataset('derivative', derivative) - ^^^^^^^^^^ - NameError: name 'derivative' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - native_F = collection['native'].F -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - native_F = collection['native'].F - ~~~~~~~~~~^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/datasets/collection.py", line 186, in __getitem__ - return self._datasets[name] - ~~~~~~~~~~~~~~^^^^^^ - KeyError: 'native' -********************************************************************** -File "None", line ?, in default -Failed example: - collection.add_dataset('native', native_data, set_as_reference=True) -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - collection.add_dataset('native', native_data, set_as_reference=True) - ^^^^^^^^^^^ - NameError: name 'native_data' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - collection.add_dataset('derivative', derivative_data) -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - collection.add_dataset('derivative', derivative_data) - ^^^^^^^^^^^^^^^ - NameError: name 'derivative_data' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - reader = mtz.read('data.mtz', verbose=1) -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - reader = mtz.read('data.mtz', verbose=1) - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/mtz.py", line 347, in read - return MTZReader(verbose=verbose).read(filepath) - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/mtz.py", line 158, in read - self.mtz_data = rs.read_mtz(filepath) - ^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/site-packages/reciprocalspaceship/io/mtz.py", line 186, in read_mtz - gemmi_mtz = gemmi.read_mtz_file(mtzfile) - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - RuntimeError: Failed to open data.mtz: No such file or directory: data.mtz -********************************************************************** -File "None", line ?, in default -Failed example: - data_dict, cell, spacegroup = reader() -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - data_dict, cell, spacegroup = reader() - ^^^^^^ - NameError: name 'reader' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - print(f"Found {len(data_dict['HKL'])} reflections in {spacegroup.short_name()}") -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - print(f"Found {len(data_dict['HKL'])} reflections in {spacegroup.short_name()}") - ^^^^^^^^^ - NameError: name 'data_dict' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - reader = pdb.read('structure.pdb', verbose=1) -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - reader = pdb.read('structure.pdb', verbose=1) - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/pdb.py", line 381, in read - return PDBReader(verbose=verbose).read(filepath) - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/pdb.py", line 339, in read - self.dataframe = load_as_dataframe(filepath) - ^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/pdb.py", line 149, in load_as_dataframe - skipheader = find_header_length(filepath) - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/pdb.py", line 67, in find_header_length - with open(filepath, "r") as f: - ^^^^^^^^^^^^^^^^^^^ - FileNotFoundError: [Errno 2] No such file or directory: 'structure.pdb' -********************************************************************** -File "None", line ?, in default -Failed example: - df, cell, spacegroup = reader() -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - df, cell, spacegroup = reader() - ^^^^^^ - NameError: name 'reader' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - print(f"Loaded {len(df)} atoms") -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - print(f"Loaded {len(df)} atoms") - ^^ - NameError: name 'df' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - router = DataRouter("structure.cif") -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - router = DataRouter("structure.cif") - ^^^^^^^^^^ - NameError: name 'DataRouter' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - reader = router.get_reader() -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - reader = router.get_reader() - ^^^^^^ - NameError: name 'router' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - print(router.data_type) # 'structure' -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - print(router.data_type) # 'structure' - ^^^^^^ - NameError: name 'router' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - reader, data_type = DataRouter.route("7JI4-sf.cif") -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - reader, data_type = DataRouter.route("7JI4-sf.cif") - ^^^^^^^^^^ - NameError: name 'DataRouter' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - if data_type == 'reflections': - data_dict, cell, spacegroup = reader() -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - if data_type == 'reflections': - ^^^^^^^^^ - NameError: name 'data_type' is not defined -********************************************************************** -1 items had failures: - 68 of 81 in default -81 tests in 1 items. -13 passed and 68 failed. -***Test Failed*** 68 failures. - -Document: api/math_functions ----------------------------- -********************************************************************** -File "None", line ?, in default -Failed example: - fw = FrenchWilson(spacegroup='P21', cell=cell) -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - fw = FrenchWilson(spacegroup='P21', cell=cell) - ^^^^ - NameError: name 'cell' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - F_french_wilson = fw(I_obs, sigma_I) -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - F_french_wilson = fw(I_obs, sigma_I) - ^^ - NameError: name 'fw' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - frac_coords = math_torch.cartesian_to_fractional_torch(cart_coords, cell) -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - frac_coords = math_torch.cartesian_to_fractional_torch(cart_coords, cell) - ^^^^^^^^^^^ - NameError: name 'cart_coords' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - fw_module = FrenchWilson(hkl, unit_cell, 'P212121') -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - fw_module = FrenchWilson(hkl, unit_cell, 'P212121') - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/math_functions/french_wilson.py", line 1347, in __init__ - d_spacings = math_torch.get_d_spacing(hkl, unit_cell) - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/math_functions/math_torch.py", line 965, in get_d_spacing - s = get_scattering_vectors(hkl, unit_cell, recB) - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/math_functions/math_torch.py", line 942, in get_scattering_vectors - recB = reciprocal_basis_matrix(unit_cell) - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/math_functions/math_torch.py", line 885, in reciprocal_basis_matrix - angles_rad = torch.deg2rad(unit_cell[3:]) - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - TypeError: deg2rad(): argument 'input' (position 1) must be Tensor, not list -********************************************************************** -File "None", line ?, in default -Failed example: - F, sigma_F = fw_module(I, sigma_I) -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - F, sigma_F = fw_module(I, sigma_I) - ^^^^^^^^^ - NameError: name 'fw_module' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - hkl = generate_possible_hkl(cell, d_min=2.0) -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - hkl = generate_possible_hkl(cell, d_min=2.0) - ^^^^^^^^^^^^^^^^^^^^^ - NameError: name 'generate_possible_hkl' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - print(f"Generated {len(hkl)} reflections") -Expected nothing -Got: - Generated 4 reflections -********************************************************************** -File "None", line ?, in default -Failed example: - voids = find_solvent_voids(mask) -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - voids = find_solvent_voids(mask) - ^^^^^^^^^^^^^^^^^^ - NameError: name 'find_solvent_voids' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - print(voids) -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - print(voids) - ^^^^^ - NameError: name 'voids' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - from french_wilson_pytorch import FrenchWilsonModule -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - from french_wilson_pytorch import FrenchWilsonModule - ModuleNotFoundError: No module named 'french_wilson_pytorch' -********************************************************************** -File "None", line ?, in default -Failed example: - fw_module = FrenchWilsonModule(hkl, unit_cell, space_group='P212121') -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - fw_module = FrenchWilsonModule(hkl, unit_cell, space_group='P212121') - ^^^^^^^^^^^^^^^^^^ - NameError: name 'FrenchWilsonModule' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - F, sigma_F = fw_module(I, sigma_I) -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - F, sigma_F = fw_module(I, sigma_I) - ^^^^^^^^^ - NameError: name 'fw_module' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - print(f"F = {F}") -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - print(f"F = {F}") - ^ - NameError: name 'F' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - from french_wilson_pytorch import french_wilson_auto -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - from french_wilson_pytorch import french_wilson_auto - ModuleNotFoundError: No module named 'french_wilson_pytorch' -********************************************************************** -File "None", line ?, in default -Failed example: - F, sigma_F, valid = french_wilson_auto( - I, sigma_I, hkl, d_spacings, space_group='P212121' - ) -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - F, sigma_F, valid = french_wilson_auto( - ^^^^^^^^^^^^^^^^^^ - NameError: name 'french_wilson_auto' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - F, sigma_F, valid = french_wilson(I, sigma_I, mean_I) -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - F, sigma_F, valid = french_wilson(I, sigma_I, mean_I) - ^^^^^^^^^^^^^ - NameError: name 'french_wilson' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - print(f"F = {F}") -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - print(f"F = {F}") - ^ - NameError: name 'F' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - F, sigma_F, valid = french_wilson_auto(I, sigma_I, hkl, d_spacings, "P212121") -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - F, sigma_F, valid = french_wilson_auto(I, sigma_I, hkl, d_spacings, "P212121") - ^^^^^^^^^^^^^^^^^^ - NameError: name 'french_wilson_auto' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - fw_module = FrenchWilson(hkl, unit_cell, 'P212121') -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - fw_module = FrenchWilson(hkl, unit_cell, 'P212121') - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/math_functions/french_wilson.py", line 1347, in __init__ - d_spacings = math_torch.get_d_spacing(hkl, unit_cell) - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/math_functions/math_torch.py", line 965, in get_d_spacing - s = get_scattering_vectors(hkl, unit_cell, recB) - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/math_functions/math_torch.py", line 942, in get_scattering_vectors - recB = reciprocal_basis_matrix(unit_cell) - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/math_functions/math_torch.py", line 885, in reciprocal_basis_matrix - angles_rad = torch.deg2rad(unit_cell[3:]) - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - TypeError: deg2rad(): argument 'input' (position 1) must be Tensor, not list -********************************************************************** -File "None", line ?, in default -Failed example: - F, sigma_F = fw_module(I, sigma_I) -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - F, sigma_F = fw_module(I, sigma_I) - ^^^^^^^^^ - NameError: name 'fw_module' is not defined -********************************************************************** -1 items had failures: - 20 of 47 in default -47 tests in 1 items. -27 passed and 20 failed. -***Test Failed*** 20 failures. - -Document: api/model -------------------- -********************************************************************** -File "None", line ?, in default -Failed example: - model.load_pdb('structure.pdb') -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - model.load_pdb('structure.pdb') - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model.py", line 231, in load_pdb - reader = pdb.PDBReader(verbose=self.verbose).read(file) - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/pdb.py", line 339, in read - self.dataframe = load_as_dataframe(filepath) - ^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/pdb.py", line 149, in load_as_dataframe - skipheader = find_header_length(filepath) - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/pdb.py", line 67, in find_header_length - with open(filepath, "r") as f: - ^^^^^^^^^^^^^^^^^^^ - FileNotFoundError: [Errno 2] No such file or directory: 'structure.pdb' -********************************************************************** -File "None", line ?, in default -Failed example: - model_ft = ModelFT(data, device='cuda') -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - model_ft = ModelFT(data, device='cuda') - ^^^^ - NameError: name 'data' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - F_calc = model_ft.get_F_calc() -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - F_calc = model_ft.get_F_calc() - ^^^^^^^^ - NameError: name 'model_ft' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - model.load_state_dict(torch.load('model.pt')) -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - model.load_state_dict(torch.load('model.pt')) - ^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/site-packages/torch/serialization.py", line 1484, in load - with _open_file_like(f, "rb") as opened_file: - ^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/site-packages/torch/serialization.py", line 759, in _open_file_like - return _open_file(name_or_buffer, mode) - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/site-packages/torch/serialization.py", line 740, in __init__ - super().__init__(open(name, mode)) - ^^^^^^^^^^^^^^^^ - FileNotFoundError: [Errno 2] No such file or directory: 'model.pt' -********************************************************************** -File "None", line ?, in default -Failed example: - model.load_pdb('structure.pdb') -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - model.load_pdb('structure.pdb') - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model_ft.py", line 140, in load_pdb - super().load_pdb(filename) - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model.py", line 231, in load_pdb - reader = pdb.PDBReader(verbose=self.verbose).read(file) - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/pdb.py", line 339, in read - self.dataframe = load_as_dataframe(filepath) - ^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/pdb.py", line 149, in load_as_dataframe - skipheader = find_header_length(filepath) - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/pdb.py", line 67, in find_header_length - with open(filepath, "r") as f: - ^^^^^^^^^^^^^^^^^^^ - FileNotFoundError: [Errno 2] No such file or directory: 'structure.pdb' -********************************************************************** -File "None", line ?, in default -Failed example: - model = ModelFT().load_pdb('structure.pdb') -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - model = ModelFT().load_pdb('structure.pdb') - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model_ft.py", line 140, in load_pdb - super().load_pdb(filename) - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model.py", line 231, in load_pdb - reader = pdb.PDBReader(verbose=self.verbose).read(file) - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/pdb.py", line 339, in read - self.dataframe = load_as_dataframe(filepath) - ^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/pdb.py", line 149, in load_as_dataframe - skipheader = find_header_length(filepath) - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/pdb.py", line 67, in find_header_length - with open(filepath, "r") as f: - ^^^^^^^^^^^^^^^^^^^ - FileNotFoundError: [Errno 2] No such file or directory: 'structure.pdb' -********************************************************************** -File "None", line ?, in default -Failed example: - model_copy = model.copy() -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - model_copy = model.copy() - ^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model_ft.py", line 670, in copy - raise RuntimeError("Cannot copy an uninitialized ModelFT. Load data first.") - RuntimeError: Cannot copy an uninitialized ModelFT. Load data first. -********************************************************************** -File "None", line ?, in default -Failed example: - model = Model().load_pdb('structure.pdb') -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - model = Model().load_pdb('structure.pdb') - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model.py", line 231, in load_pdb - reader = pdb.PDBReader(verbose=self.verbose).read(file) - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/pdb.py", line 339, in read - self.dataframe = load_as_dataframe(filepath) - ^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/pdb.py", line 149, in load_as_dataframe - skipheader = find_header_length(filepath) - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/pdb.py", line 67, in find_header_length - with open(filepath, "r") as f: - ^^^^^^^^^^^^^^^^^^^ - FileNotFoundError: [Errno 2] No such file or directory: 'structure.pdb' -********************************************************************** -File "None", line ?, in default -Failed example: - chain_a = model.select("chain A") -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - chain_a = model.select("chain A") - ^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model_ft.py", line 146, in select - selection = super().select(selection) - ^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model.py", line 1473, in select - raise RuntimeError( - RuntimeError: Cannot select from an uninitialized Model. Load data first. -********************************************************************** -File "None", line ?, in default -Failed example: - backbone = model.select("name CA or name C or name N or name O") -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - backbone = model.select("name CA or name C or name N or name O") - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model_ft.py", line 146, in select - selection = super().select(selection) - ^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model.py", line 1473, in select - raise RuntimeError( - RuntimeError: Cannot select from an uninitialized Model. Load data first. -********************************************************************** -File "None", line ?, in default -Failed example: - region = model.select("chain B and resseq 10:50") -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - region = model.select("chain B and resseq 10:50") - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model_ft.py", line 146, in select - selection = super().select(selection) - ^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model.py", line 1473, in select - raise RuntimeError( - RuntimeError: Cannot select from an uninitialized Model. Load data first. -********************************************************************** -File "None", line ?, in default -Failed example: - no_water = model.select("not resname HOH") -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - no_water = model.select("not resname HOH") - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model_ft.py", line 146, in select - selection = super().select(selection) - ^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model.py", line 1473, in select - raise RuntimeError( - RuntimeError: Cannot select from an uninitialized Model. Load data first. -********************************************************************** -File "None", line ?, in default -Failed example: - complex_sel = model.select("chain A and (resname ALA or resname GLY)") -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - complex_sel = model.select("chain A and (resname ALA or resname GLY)") - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model_ft.py", line 146, in select - selection = super().select(selection) - ^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model.py", line 1473, in select - raise RuntimeError( - RuntimeError: Cannot select from an uninitialized Model. Load data first. -********************************************************************** -File "None", line ?, in default -Failed example: - model.load_state_dict(torch.load('model.pt')) -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - model.load_state_dict(torch.load('model.pt')) - ^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/site-packages/torch/serialization.py", line 1484, in load - with _open_file_like(f, "rb") as opened_file: - ^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/site-packages/torch/serialization.py", line 759, in _open_file_like - return _open_file(name_or_buffer, mode) - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/site-packages/torch/serialization.py", line 740, in __init__ - super().__init__(open(name, mode)) - ^^^^^^^^^^^^^^^^ - FileNotFoundError: [Errno 2] No such file or directory: 'model.pt' -********************************************************************** -File "None", line ?, in default -Failed example: - model.load_pdb('structure.pdb') -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - model.load_pdb('structure.pdb') - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model.py", line 231, in load_pdb - reader = pdb.PDBReader(verbose=self.verbose).read(file) - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/pdb.py", line 339, in read - self.dataframe = load_as_dataframe(filepath) - ^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/pdb.py", line 149, in load_as_dataframe - skipheader = find_header_length(filepath) - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/pdb.py", line 67, in find_header_length - with open(filepath, "r") as f: - ^^^^^^^^^^^^^^^^^^^ - FileNotFoundError: [Errno 2] No such file or directory: 'structure.pdb' -********************************************************************** -File "None", line ?, in default -Failed example: - loss = xray_loss + w_adp * model.adp_kl_divergence_loss(0.2) -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - loss = xray_loss + w_adp * model.adp_kl_divergence_loss(0.2) - ^^^^^^^^^ - NameError: name 'xray_loss' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - structure_factor_loss = compute_structure_factor_loss() -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - structure_factor_loss = compute_structure_factor_loss() - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - NameError: name 'compute_structure_factor_loss' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - nll_reg = model.adp_nll_loss(target_log_std=0.2) -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - nll_reg = model.adp_nll_loss(target_log_std=0.2) - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model.py", line 986, in adp_nll_loss - log_b = super(PositiveMixedTensor, self.b).forward() - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - AttributeError: 'super' object has no attribute 'forward' -********************************************************************** -File "None", line ?, in default -Failed example: - total_loss = structure_factor_loss + 0.01 * nll_reg -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - total_loss = structure_factor_loss + 0.01 * nll_reg - ^^^^^^^^^^^^^^^^^^^^^ - NameError: name 'structure_factor_loss' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - total_loss.backward() -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - total_loss.backward() - ^^^^^^^^^^ - NameError: name 'total_loss' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - atom_nll = model.adp_nll_loss_per_atom(target_log_std=0.2) -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - atom_nll = model.adp_nll_loss_per_atom(target_log_std=0.2) - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model.py", line 1041, in adp_nll_loss_per_atom - log_b = super(PositiveMixedTensor, self.b).forward() - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - AttributeError: 'super' object has no attribute 'forward' -********************************************************************** -File "None", line ?, in default -Failed example: - threshold = atom_nll.mean() + 2 * atom_nll.std() -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - threshold = atom_nll.mean() + 2 * atom_nll.std() - ^^^^^^^^ - NameError: name 'atom_nll' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - outliers = atom_nll > threshold -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - outliers = atom_nll > threshold - ^^^^^^^^ - NameError: name 'atom_nll' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - model.update_mask_from_selection("chain A", "xyz", freeze=True) -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - model.update_mask_from_selection("chain A", "xyz", freeze=True) - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model.py", line 646, in update_mask_from_selection - current_mask = getattr(self, mask_name) - ^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/site-packages/torch/nn/modules/module.py", line 1964, in __getattr__ - raise AttributeError( - AttributeError: 'Model' object has no attribute 'xyz_mask' -********************************************************************** -File "None", line ?, in default -Failed example: - model.apply_mask_to_parameter("xyz") -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - model.apply_mask_to_parameter("xyz") - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model.py", line 699, in apply_mask_to_parameter - self.xyz.update_refinable_mask(self.xyz_mask) - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - AttributeError: 'NoneType' object has no attribute 'update_refinable_mask' -********************************************************************** -File "None", line ?, in default -Failed example: - model = Model().load_pdb('structure.pdb') -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - model = Model().load_pdb('structure.pdb') - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model.py", line 231, in load_pdb - reader = pdb.PDBReader(verbose=self.verbose).read(file) - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/pdb.py", line 339, in read - self.dataframe = load_as_dataframe(filepath) - ^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/pdb.py", line 149, in load_as_dataframe - skipheader = find_header_length(filepath) - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/pdb.py", line 67, in find_header_length - with open(filepath, "r") as f: - ^^^^^^^^^^^^^^^^^^^ - FileNotFoundError: [Errno 2] No such file or directory: 'structure.pdb' -********************************************************************** -File "None", line ?, in default -Failed example: - model_copy = model.copy() -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - model_copy = model.copy() - ^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model.py", line 490, in copy - raise RuntimeError("Cannot copy an uninitialized Model. Load data first.") - RuntimeError: Cannot copy an uninitialized Model. Load data first. -********************************************************************** -File "None", line ?, in default -Failed example: - model.freeze_selection("chain A", targets='all') -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - model.freeze_selection("chain A", targets='all') - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model.py", line 752, in freeze_selection - self.update_mask_from_selection( - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model.py", line 646, in update_mask_from_selection - current_mask = getattr(self, mask_name) - ^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/site-packages/torch/nn/modules/module.py", line 1964, in __getattr__ - raise AttributeError( - AttributeError: 'Model' object has no attribute 'xyz_mask' -********************************************************************** -File "None", line ?, in default -Failed example: - model.freeze_selection("resseq 10:20", targets='xyz') -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - model.freeze_selection("resseq 10:20", targets='xyz') - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model.py", line 752, in freeze_selection - self.update_mask_from_selection( - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model.py", line 646, in update_mask_from_selection - current_mask = getattr(self, mask_name) - ^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/site-packages/torch/nn/modules/module.py", line 1964, in __getattr__ - raise AttributeError( - AttributeError: 'Model' object has no attribute 'xyz_mask' -********************************************************************** -File "None", line ?, in default -Failed example: - model = Model().load_pdb('structure.pdb') -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - model = Model().load_pdb('structure.pdb') - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model.py", line 231, in load_pdb - reader = pdb.PDBReader(verbose=self.verbose).read(file) - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/pdb.py", line 339, in read - self.dataframe = load_as_dataframe(filepath) - ^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/pdb.py", line 149, in load_as_dataframe - skipheader = find_header_length(filepath) - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/pdb.py", line 67, in find_header_length - with open(filepath, "r") as f: - ^^^^^^^^^^^^^^^^^^^ - FileNotFoundError: [Errno 2] No such file or directory: 'structure.pdb' -********************************************************************** -File "None", line ?, in default -Failed example: - mask = model.get_selection_mask("chain A") -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - mask = model.get_selection_mask("chain A") - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model.py", line 1407, in get_selection_mask - raise RuntimeError( - RuntimeError: Cannot get selection mask from an uninitialized Model. Load data first. -********************************************************************** -File "None", line ?, in default -Failed example: - new_coords = model.xyz()[mask] + translation -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - new_coords = model.xyz()[mask] + translation - ^^^^^^^^^^^ - TypeError: 'NoneType' object is not callable -********************************************************************** -File "None", line ?, in default -Failed example: - model.xyz.set(new_coords, mask) -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - model.xyz.set(new_coords, mask) - ^^^^^^^^^^^^^ - AttributeError: 'NoneType' object has no attribute 'set' -********************************************************************** -File "None", line ?, in default -Failed example: - backbone_mask = model.get_selection_mask("name CA or name C or name N or name O") -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - backbone_mask = model.get_selection_mask("name CA or name C or name N or name O") - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model.py", line 1407, in get_selection_mask - raise RuntimeError( - RuntimeError: Cannot get selection mask from an uninitialized Model. Load data first. -********************************************************************** -File "None", line ?, in default -Failed example: - mask = model.get_selection_mask("chain A and (resname ALA or resname GLY)") -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - mask = model.get_selection_mask("chain A and (resname ALA or resname GLY)") - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model.py", line 1407, in get_selection_mask - raise RuntimeError( - RuntimeError: Cannot get selection mask from an uninitialized Model. Load data first. -********************************************************************** -File "None", line ?, in default -Failed example: - model = Model().load_pdb('structure.pdb') -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - model = Model().load_pdb('structure.pdb') - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model.py", line 231, in load_pdb - reader = pdb.PDBReader(verbose=self.verbose).read(file) - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/pdb.py", line 339, in read - self.dataframe = load_as_dataframe(filepath) - ^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/pdb.py", line 149, in load_as_dataframe - skipheader = find_header_length(filepath) - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/pdb.py", line 67, in find_header_length - with open(filepath, "r") as f: - ^^^^^^^^^^^^^^^^^^^ - FileNotFoundError: [Errno 2] No such file or directory: 'structure.pdb' -********************************************************************** -File "None", line ?, in default -Failed example: - chain_a = model.select("chain A") -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - chain_a = model.select("chain A") - ^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model.py", line 1473, in select - raise RuntimeError( - RuntimeError: Cannot select from an uninitialized Model. Load data first. -********************************************************************** -File "None", line ?, in default -Failed example: - backbone = model.select("name CA or name C or name N or name O") -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - backbone = model.select("name CA or name C or name N or name O") - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model.py", line 1473, in select - raise RuntimeError( - RuntimeError: Cannot select from an uninitialized Model. Load data first. -********************************************************************** -File "None", line ?, in default -Failed example: - region = model.select("chain B and resseq 10:50") -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - region = model.select("chain B and resseq 10:50") - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model.py", line 1473, in select - raise RuntimeError( - RuntimeError: Cannot select from an uninitialized Model. Load data first. -********************************************************************** -File "None", line ?, in default -Failed example: - no_water = model.select("not resname HOH") -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - no_water = model.select("not resname HOH") - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model.py", line 1473, in select - raise RuntimeError( - RuntimeError: Cannot select from an uninitialized Model. Load data first. -********************************************************************** -File "None", line ?, in default -Failed example: - complex_sel = model.select("chain A and (resname ALA or resname GLY)") -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - complex_sel = model.select("chain A and (resname ALA or resname GLY)") - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model.py", line 1473, in select - raise RuntimeError( - RuntimeError: Cannot select from an uninitialized Model. Load data first. -********************************************************************** -File "None", line ?, in default -Failed example: - model.unfreeze_selection("chain A", targets='all') -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - model.unfreeze_selection("chain A", targets='all') - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model.py", line 792, in unfreeze_selection - self.update_mask_from_selection( - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model.py", line 646, in update_mask_from_selection - current_mask = getattr(self, mask_name) - ^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/site-packages/torch/nn/modules/module.py", line 1964, in __getattr__ - raise AttributeError( - AttributeError: 'Model' object has no attribute 'xyz_mask' -********************************************************************** -File "None", line ?, in default -Failed example: - model.unfreeze_selection("name CA or name C or name N", targets='xyz') -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - model.unfreeze_selection("name CA or name C or name N", targets='xyz') - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model.py", line 792, in unfreeze_selection - self.update_mask_from_selection( - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model.py", line 646, in update_mask_from_selection - current_mask = getattr(self, mask_name) - ^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/site-packages/torch/nn/modules/module.py", line 1964, in __getattr__ - raise AttributeError( - AttributeError: 'Model' object has no attribute 'xyz_mask' -********************************************************************** -File "None", line ?, in default -Failed example: - model.update_mask_from_selection("chain A", "xyz", mode='set', freeze=True) -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - model.update_mask_from_selection("chain A", "xyz", mode='set', freeze=True) - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model.py", line 646, in update_mask_from_selection - current_mask = getattr(self, mask_name) - ^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/site-packages/torch/nn/modules/module.py", line 1964, in __getattr__ - raise AttributeError( - AttributeError: 'Model' object has no attribute 'xyz_mask' -********************************************************************** -File "None", line ?, in default -Failed example: - model.apply_mask_to_parameter("xyz") -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - model.apply_mask_to_parameter("xyz") - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model.py", line 699, in apply_mask_to_parameter - self.xyz.update_refinable_mask(self.xyz_mask) - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - AttributeError: 'NoneType' object has no attribute 'update_refinable_mask' -********************************************************************** -File "None", line ?, in default -Failed example: - model.update_mask_from_selection("name CA or name C or name N", "xyz", freeze=False) -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - model.update_mask_from_selection("name CA or name C or name N", "xyz", freeze=False) - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model.py", line 646, in update_mask_from_selection - current_mask = getattr(self, mask_name) - ^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/site-packages/torch/nn/modules/module.py", line 1964, in __getattr__ - raise AttributeError( - AttributeError: 'Model' object has no attribute 'xyz_mask' -********************************************************************** -File "None", line ?, in default -Failed example: - model.apply_mask_to_parameter("xyz") -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - model.apply_mask_to_parameter("xyz") - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model.py", line 699, in apply_mask_to_parameter - self.xyz.update_refinable_mask(self.xyz_mask) - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - AttributeError: 'NoneType' object has no attribute 'update_refinable_mask' -********************************************************************** -File "None", line ?, in default -Failed example: - mixed.load_state_dict(torch.load('mixed.pt')) -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - mixed.load_state_dict(torch.load('mixed.pt')) - ^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/site-packages/torch/serialization.py", line 1484, in load - with _open_file_like(f, "rb") as opened_file: - ^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/site-packages/torch/serialization.py", line 759, in _open_file_like - return _open_file(name_or_buffer, mode) - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/site-packages/torch/serialization.py", line 740, in __init__ - super().__init__(open(name, mode)) - ^^^^^^^^^^^^^^^^ - FileNotFoundError: [Errno 2] No such file or directory: 'mixed.pt' -********************************************************************** -File "None", line ?, in default -Failed example: - model.b[5] # Get B-factor for atom 5 -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - model.b[5] # Get B-factor for atom 5 - ~~~~~~~^^^ - TypeError: 'NoneType' object is not subscriptable -********************************************************************** -File "None", line ?, in default -Failed example: - model.b[5:10] # Get B-factors for atoms 5-9 -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - model.b[5:10] # Get B-factors for atoms 5-9 - ~~~~~~~^^^^^^ - TypeError: 'NoneType' object is not subscriptable -********************************************************************** -File "None", line ?, in default -Failed example: - model.b[mask] # Get B-factors where mask is True -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - model.b[mask] # Get B-factors where mask is True - ~~~~~~~^^^^^^ - TypeError: 'NoneType' object is not subscriptable -********************************************************************** -File "None", line ?, in default -Failed example: - model.xyz[:, 0] # Get all x-coordinates -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - model.xyz[:, 0] # Get all x-coordinates - ~~~~~~~~~^^^^^^ - TypeError: 'NoneType' object is not subscriptable -********************************************************************** -File "None", line ?, in default -Failed example: - model.b[:] = 30.0 # Set all B-factors to 30 -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - model.b[:] = 30.0 # Set all B-factors to 30 - ~~~~~~~^^^ - TypeError: 'NoneType' object does not support item assignment -********************************************************************** -File "None", line ?, in default -Failed example: - model.b[5:10] = 25.0 # Set B-factors 5-9 to 25 -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - model.b[5:10] = 25.0 # Set B-factors 5-9 to 25 - ~~~~~~~^^^^^^ - TypeError: 'NoneType' object does not support item assignment -********************************************************************** -File "None", line ?, in default -Failed example: - model.b[mask] = new_values # Set B-factors where mask is True -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - model.b[mask] = new_values # Set B-factors where mask is True - ^^^^^^^^^^ - NameError: name 'new_values' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - model.xyz[mask] = new_coords # Set coordinates for masked atoms -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - model.xyz[mask] = new_coords # Set coordinates for masked atoms - ^^^^^^^^^^ - NameError: name 'new_coords' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - model.xyz[:, 0] += 1.0 # Shift all x-coordinates (read-modify-write) -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - model.xyz[:, 0] += 1.0 # Shift all x-coordinates (read-modify-write) - ~~~~~~~~~^^^^^^ - TypeError: 'NoneType' object is not subscriptable -********************************************************************** -File "None", line ?, in default -Failed example: - mask = model.get_selection_mask("chain A") -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - mask = model.get_selection_mask("chain A") - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model.py", line 1407, in get_selection_mask - raise RuntimeError( - RuntimeError: Cannot get selection mask from an uninitialized Model. Load data first. -********************************************************************** -File "None", line ?, in default -Failed example: - new_coords = original_coords[mask] + shift -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - new_coords = original_coords[mask] + shift - ^^^^^^^^^^^^^^^ - NameError: name 'original_coords' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - model.xyz.set(new_coords, mask) -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - model.xyz.set(new_coords, mask) - ^^^^^^^^^^^^^ - AttributeError: 'NoneType' object has no attribute 'set' -********************************************************************** -File "None", line ?, in default -Failed example: - mask = model.get_selection_mask("resseq 10:20") -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - mask = model.get_selection_mask("resseq 10:20") - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model.py", line 1407, in get_selection_mask - raise RuntimeError( - RuntimeError: Cannot get selection mask from an uninitialized Model. Load data first. -********************************************************************** -File "None", line ?, in default -Failed example: - model.b.set(new_b, mask) -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - model.b.set(new_b, mask) - ^^^^^^^^^^^ - AttributeError: 'NoneType' object has no attribute 'set' -********************************************************************** -File "None", line ?, in default -Failed example: - b = PositiveMixedTensor() -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - b = PositiveMixedTensor() - ^^^^^^^^^^^^^^^^^^^ - NameError: name 'PositiveMixedTensor' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - b.load_state_dict(torch.load('b_factors.pt')) -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - b.load_state_dict(torch.load('b_factors.pt')) - ^^^^^^^^^^^^^^^^^ - AttributeError: 'NoneType' object has no attribute 'load_state_dict' -********************************************************************** -File "None", line ?, in default -Failed example: - b = PositiveMixedTensor(initial_b) -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - b = PositiveMixedTensor(initial_b) - ^^^^^^^^^^^^^^^^^^^ - NameError: name 'PositiveMixedTensor' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - output = b() # Returns exp(log_b) = positive values -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - output = b() # Returns exp(log_b) = positive values - ^^^ - TypeError: 'NoneType' object is not callable -********************************************************************** -File "None", line ?, in default -Failed example: - assert (b() > 0).all() -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - assert (b() > 0).all() - ^^^ - TypeError: 'NoneType' object is not callable -********************************************************************** -File "None", line ?, in default -Failed example: - mask = model.get_selection_mask("name CA") -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - mask = model.get_selection_mask("name CA") - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model.py", line 1407, in get_selection_mask - raise RuntimeError( - RuntimeError: Cannot get selection mask from an uninitialized Model. Load data first. -********************************************************************** -File "None", line ?, in default -Failed example: - model.b.set(new_b, mask) -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - model.b.set(new_b, mask) - ^^^^^^^^^^^ - AttributeError: 'NoneType' object has no attribute 'set' -********************************************************************** -File "None", line ?, in default -Failed example: - occ = OccupancyTensor( - initial_values=torch.tensor([1.0, 1.0, 0.7, 0.7, 0.3, 0.3]), - sharing_groups=sharing_groups, - altloc_groups=[([2, 3], [4, 5])], - ) -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - occ = OccupancyTensor( - ^^^^^^^^^^^^^^^ - NameError: name 'OccupancyTensor' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - result = occ() # Atoms 2-3 and 4-5 will sum to 1.0 -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - result = occ() # Atoms 2-3 and 4-5 will sum to 1.0 - ^^^ - NameError: name 'occ' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - freeze_mask = torch.zeros(n_atoms, dtype=torch.bool) -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - freeze_mask = torch.zeros(n_atoms, dtype=torch.bool) - ^^^^^^^ - NameError: name 'n_atoms' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - freeze_mask[0:11] = True -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - freeze_mask[0:11] = True - ^^^^^^^^^^^ - NameError: name 'freeze_mask' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - occ.freeze(freeze_mask) -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - occ.freeze(freeze_mask) - ^^^ - NameError: name 'occ' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - occ.freeze() -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - occ.freeze() - ^^^ - NameError: name 'occ' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - unfreeze_mask = torch.zeros(n_atoms, dtype=torch.bool) -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - unfreeze_mask = torch.zeros(n_atoms, dtype=torch.bool) - ^^^^^^^ - NameError: name 'n_atoms' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - unfreeze_mask[100:201] = True -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - unfreeze_mask[100:201] = True - ^^^^^^^^^^^^^ - NameError: name 'unfreeze_mask' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - occ.unfreeze(unfreeze_mask) -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - occ.unfreeze(unfreeze_mask) - ^^^ - NameError: name 'occ' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - occ.unfreeze() -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - occ.unfreeze() - ^^^ - NameError: name 'occ' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - atom_mask = torch.zeros(n_atoms, dtype=torch.bool) -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - atom_mask = torch.zeros(n_atoms, dtype=torch.bool) - ^^^^^^^ - NameError: name 'n_atoms' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - atom_mask[:100] = True -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - atom_mask[:100] = True - ^^^^^^^^^ - NameError: name 'atom_mask' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - occ.update_refinable_mask(atom_mask, in_compressed_space=False) -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - occ.update_refinable_mask(atom_mask, in_compressed_space=False) - ^^^ - NameError: name 'occ' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - group_mask = torch.zeros(n_groups, dtype=torch.bool) -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - group_mask = torch.zeros(n_groups, dtype=torch.bool) - ^^^^^^^^ - NameError: name 'n_groups' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - group_mask[::2] = True -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - group_mask[::2] = True - ^^^^^^^^^^ - NameError: name 'group_mask' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - occ.update_refinable_mask(group_mask, in_compressed_space=True) -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - occ.update_refinable_mask(group_mask, in_compressed_space=True) - ^^^ - NameError: name 'occ' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - model.load_state_dict(torch.load('model.pt')) -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - model.load_state_dict(torch.load('model.pt')) - ^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/site-packages/torch/serialization.py", line 1484, in load - with _open_file_like(f, "rb") as opened_file: - ^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/site-packages/torch/serialization.py", line 759, in _open_file_like - return _open_file(name_or_buffer, mode) - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/site-packages/torch/serialization.py", line 740, in __init__ - super().__init__(open(name, mode)) - ^^^^^^^^^^^^^^^^ - FileNotFoundError: [Errno 2] No such file or directory: 'model.pt' -********************************************************************** -File "None", line ?, in default -Failed example: - model.load_pdb('structure.pdb') -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - model.load_pdb('structure.pdb') - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model.py", line 231, in load_pdb - reader = pdb.PDBReader(verbose=self.verbose).read(file) - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/pdb.py", line 339, in read - self.dataframe = load_as_dataframe(filepath) - ^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/pdb.py", line 149, in load_as_dataframe - skipheader = find_header_length(filepath) - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/pdb.py", line 67, in find_header_length - with open(filepath, "r") as f: - ^^^^^^^^^^^^^^^^^^^ - FileNotFoundError: [Errno 2] No such file or directory: 'structure.pdb' -********************************************************************** -File "None", line ?, in default -Failed example: - model = Model().load_pdb('structure.pdb') -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - model = Model().load_pdb('structure.pdb') - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model.py", line 231, in load_pdb - reader = pdb.PDBReader(verbose=self.verbose).read(file) - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/pdb.py", line 339, in read - self.dataframe = load_as_dataframe(filepath) - ^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/pdb.py", line 149, in load_as_dataframe - skipheader = find_header_length(filepath) - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/pdb.py", line 67, in find_header_length - with open(filepath, "r") as f: - ^^^^^^^^^^^^^^^^^^^ - FileNotFoundError: [Errno 2] No such file or directory: 'structure.pdb' -********************************************************************** -File "None", line ?, in default -Failed example: - model_copy = model.copy() -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - model_copy = model.copy() - ^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model.py", line 490, in copy - raise RuntimeError("Cannot copy an uninitialized Model. Load data first.") - RuntimeError: Cannot copy an uninitialized Model. Load data first. -********************************************************************** -File "None", line ?, in default -Failed example: - model.update_mask_from_selection("chain A", "xyz", mode='set', freeze=True) -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - model.update_mask_from_selection("chain A", "xyz", mode='set', freeze=True) - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model.py", line 646, in update_mask_from_selection - current_mask = getattr(self, mask_name) - ^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/site-packages/torch/nn/modules/module.py", line 1964, in __getattr__ - raise AttributeError( - AttributeError: 'Model' object has no attribute 'xyz_mask' -********************************************************************** -File "None", line ?, in default -Failed example: - model.apply_mask_to_parameter("xyz") -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - model.apply_mask_to_parameter("xyz") - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model.py", line 699, in apply_mask_to_parameter - self.xyz.update_refinable_mask(self.xyz_mask) - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - AttributeError: 'NoneType' object has no attribute 'update_refinable_mask' -********************************************************************** -File "None", line ?, in default -Failed example: - model.update_mask_from_selection("name CA or name C or name N", "xyz", freeze=False) -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - model.update_mask_from_selection("name CA or name C or name N", "xyz", freeze=False) - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model.py", line 646, in update_mask_from_selection - current_mask = getattr(self, mask_name) - ^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/site-packages/torch/nn/modules/module.py", line 1964, in __getattr__ - raise AttributeError( - AttributeError: 'Model' object has no attribute 'xyz_mask' -********************************************************************** -File "None", line ?, in default -Failed example: - model.apply_mask_to_parameter("xyz") -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - model.apply_mask_to_parameter("xyz") - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model.py", line 699, in apply_mask_to_parameter - self.xyz.update_refinable_mask(self.xyz_mask) - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - AttributeError: 'NoneType' object has no attribute 'update_refinable_mask' -********************************************************************** -File "None", line ?, in default -Failed example: - model.update_mask_from_selection("chain A", "xyz", freeze=True) -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - model.update_mask_from_selection("chain A", "xyz", freeze=True) - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model.py", line 646, in update_mask_from_selection - current_mask = getattr(self, mask_name) - ^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/site-packages/torch/nn/modules/module.py", line 1964, in __getattr__ - raise AttributeError( - AttributeError: 'Model' object has no attribute 'xyz_mask' -********************************************************************** -File "None", line ?, in default -Failed example: - model.apply_mask_to_parameter("xyz") -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - model.apply_mask_to_parameter("xyz") - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model.py", line 699, in apply_mask_to_parameter - self.xyz.update_refinable_mask(self.xyz_mask) - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - AttributeError: 'NoneType' object has no attribute 'update_refinable_mask' -********************************************************************** -File "None", line ?, in default -Failed example: - model.freeze_selection("chain A", targets='all') -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - model.freeze_selection("chain A", targets='all') - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model.py", line 752, in freeze_selection - self.update_mask_from_selection( - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model.py", line 646, in update_mask_from_selection - current_mask = getattr(self, mask_name) - ^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/site-packages/torch/nn/modules/module.py", line 1964, in __getattr__ - raise AttributeError( - AttributeError: 'Model' object has no attribute 'xyz_mask' -********************************************************************** -File "None", line ?, in default -Failed example: - model.freeze_selection("resseq 10:20", targets='xyz') -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - model.freeze_selection("resseq 10:20", targets='xyz') - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model.py", line 752, in freeze_selection - self.update_mask_from_selection( - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model.py", line 646, in update_mask_from_selection - current_mask = getattr(self, mask_name) - ^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/site-packages/torch/nn/modules/module.py", line 1964, in __getattr__ - raise AttributeError( - AttributeError: 'Model' object has no attribute 'xyz_mask' -********************************************************************** -File "None", line ?, in default -Failed example: - model.unfreeze_selection("chain A", targets='all') -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - model.unfreeze_selection("chain A", targets='all') - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model.py", line 792, in unfreeze_selection - self.update_mask_from_selection( - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model.py", line 646, in update_mask_from_selection - current_mask = getattr(self, mask_name) - ^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/site-packages/torch/nn/modules/module.py", line 1964, in __getattr__ - raise AttributeError( - AttributeError: 'Model' object has no attribute 'xyz_mask' -********************************************************************** -File "None", line ?, in default -Failed example: - model.unfreeze_selection("name CA or name C or name N", targets='xyz') -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - model.unfreeze_selection("name CA or name C or name N", targets='xyz') - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model.py", line 792, in unfreeze_selection - self.update_mask_from_selection( - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model.py", line 646, in update_mask_from_selection - current_mask = getattr(self, mask_name) - ^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/site-packages/torch/nn/modules/module.py", line 1964, in __getattr__ - raise AttributeError( - AttributeError: 'Model' object has no attribute 'xyz_mask' -********************************************************************** -File "None", line ?, in default -Failed example: - structure_factor_loss = compute_structure_factor_loss() -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - structure_factor_loss = compute_structure_factor_loss() - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - NameError: name 'compute_structure_factor_loss' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - nll_reg = model.adp_nll_loss(target_log_std=0.2) -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - nll_reg = model.adp_nll_loss(target_log_std=0.2) - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model.py", line 986, in adp_nll_loss - log_b = super(PositiveMixedTensor, self.b).forward() - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - AttributeError: 'super' object has no attribute 'forward' -********************************************************************** -File "None", line ?, in default -Failed example: - total_loss = structure_factor_loss + 0.01 * nll_reg -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - total_loss = structure_factor_loss + 0.01 * nll_reg - ^^^^^^^^^^^^^^^^^^^^^ - NameError: name 'structure_factor_loss' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - total_loss.backward() -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - total_loss.backward() - ^^^^^^^^^^ - NameError: name 'total_loss' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - atom_nll = model.adp_nll_loss_per_atom(target_log_std=0.2) -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - atom_nll = model.adp_nll_loss_per_atom(target_log_std=0.2) - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model.py", line 1041, in adp_nll_loss_per_atom - log_b = super(PositiveMixedTensor, self.b).forward() - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - AttributeError: 'super' object has no attribute 'forward' -********************************************************************** -File "None", line ?, in default -Failed example: - threshold = atom_nll.mean() + 2 * atom_nll.std() -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - threshold = atom_nll.mean() + 2 * atom_nll.std() - ^^^^^^^^ - NameError: name 'atom_nll' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - outliers = atom_nll > threshold -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - outliers = atom_nll > threshold - ^^^^^^^^ - NameError: name 'atom_nll' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - loss = xray_loss + w_adp * model.adp_kl_divergence_loss(0.2) -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - loss = xray_loss + w_adp * model.adp_kl_divergence_loss(0.2) - ^^^^^^^^^ - NameError: name 'xray_loss' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - model = Model().load_pdb('structure.pdb') -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - model = Model().load_pdb('structure.pdb') - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model.py", line 231, in load_pdb - reader = pdb.PDBReader(verbose=self.verbose).read(file) - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/pdb.py", line 339, in read - self.dataframe = load_as_dataframe(filepath) - ^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/pdb.py", line 149, in load_as_dataframe - skipheader = find_header_length(filepath) - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/pdb.py", line 67, in find_header_length - with open(filepath, "r") as f: - ^^^^^^^^^^^^^^^^^^^ - FileNotFoundError: [Errno 2] No such file or directory: 'structure.pdb' -********************************************************************** -File "None", line ?, in default -Failed example: - mask = model.get_selection_mask("chain A") -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - mask = model.get_selection_mask("chain A") - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model.py", line 1407, in get_selection_mask - raise RuntimeError( - RuntimeError: Cannot get selection mask from an uninitialized Model. Load data first. -********************************************************************** -File "None", line ?, in default -Failed example: - new_coords = model.xyz()[mask] + translation -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - new_coords = model.xyz()[mask] + translation - ^^^^^^^^^^^ - TypeError: 'NoneType' object is not callable -********************************************************************** -File "None", line ?, in default -Failed example: - model.xyz.set(new_coords, mask) -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - model.xyz.set(new_coords, mask) - ^^^^^^^^^^^^^ - AttributeError: 'NoneType' object has no attribute 'set' -********************************************************************** -File "None", line ?, in default -Failed example: - backbone_mask = model.get_selection_mask("name CA or name C or name N or name O") -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - backbone_mask = model.get_selection_mask("name CA or name C or name N or name O") - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model.py", line 1407, in get_selection_mask - raise RuntimeError( - RuntimeError: Cannot get selection mask from an uninitialized Model. Load data first. -********************************************************************** -File "None", line ?, in default -Failed example: - mask = model.get_selection_mask("chain A and (resname ALA or resname GLY)") -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - mask = model.get_selection_mask("chain A and (resname ALA or resname GLY)") - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model.py", line 1407, in get_selection_mask - raise RuntimeError( - RuntimeError: Cannot get selection mask from an uninitialized Model. Load data first. -********************************************************************** -File "None", line ?, in default -Failed example: - model = Model().load_pdb('structure.pdb') -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - model = Model().load_pdb('structure.pdb') - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model.py", line 231, in load_pdb - reader = pdb.PDBReader(verbose=self.verbose).read(file) - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/pdb.py", line 339, in read - self.dataframe = load_as_dataframe(filepath) - ^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/pdb.py", line 149, in load_as_dataframe - skipheader = find_header_length(filepath) - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/pdb.py", line 67, in find_header_length - with open(filepath, "r") as f: - ^^^^^^^^^^^^^^^^^^^ - FileNotFoundError: [Errno 2] No such file or directory: 'structure.pdb' -********************************************************************** -File "None", line ?, in default -Failed example: - chain_a = model.select("chain A") -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - chain_a = model.select("chain A") - ^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model.py", line 1473, in select - raise RuntimeError( - RuntimeError: Cannot select from an uninitialized Model. Load data first. -********************************************************************** -File "None", line ?, in default -Failed example: - backbone = model.select("name CA or name C or name N or name O") -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - backbone = model.select("name CA or name C or name N or name O") - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model.py", line 1473, in select - raise RuntimeError( - RuntimeError: Cannot select from an uninitialized Model. Load data first. -********************************************************************** -File "None", line ?, in default -Failed example: - region = model.select("chain B and resseq 10:50") -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - region = model.select("chain B and resseq 10:50") - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model.py", line 1473, in select - raise RuntimeError( - RuntimeError: Cannot select from an uninitialized Model. Load data first. -********************************************************************** -File "None", line ?, in default -Failed example: - no_water = model.select("not resname HOH") -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - no_water = model.select("not resname HOH") - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model.py", line 1473, in select - raise RuntimeError( - RuntimeError: Cannot select from an uninitialized Model. Load data first. -********************************************************************** -File "None", line ?, in default -Failed example: - complex_sel = model.select("chain A and (resname ALA or resname GLY)") -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - complex_sel = model.select("chain A and (resname ALA or resname GLY)") - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model.py", line 1473, in select - raise RuntimeError( - RuntimeError: Cannot select from an uninitialized Model. Load data first. -********************************************************************** -File "None", line ?, in default -Failed example: - model.load_state_dict(torch.load('model.pt')) -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - model.load_state_dict(torch.load('model.pt')) - ^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/site-packages/torch/serialization.py", line 1484, in load - with _open_file_like(f, "rb") as opened_file: - ^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/site-packages/torch/serialization.py", line 759, in _open_file_like - return _open_file(name_or_buffer, mode) - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/site-packages/torch/serialization.py", line 740, in __init__ - super().__init__(open(name, mode)) - ^^^^^^^^^^^^^^^^ - FileNotFoundError: [Errno 2] No such file or directory: 'model.pt' -********************************************************************** -File "None", line ?, in default -Failed example: - model.load_pdb('structure.pdb') -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - model.load_pdb('structure.pdb') - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model_ft.py", line 140, in load_pdb - super().load_pdb(filename) - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model.py", line 231, in load_pdb - reader = pdb.PDBReader(verbose=self.verbose).read(file) - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/pdb.py", line 339, in read - self.dataframe = load_as_dataframe(filepath) - ^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/pdb.py", line 149, in load_as_dataframe - skipheader = find_header_length(filepath) - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/pdb.py", line 67, in find_header_length - with open(filepath, "r") as f: - ^^^^^^^^^^^^^^^^^^^ - FileNotFoundError: [Errno 2] No such file or directory: 'structure.pdb' -********************************************************************** -File "None", line ?, in default -Failed example: - model = Model().load_pdb('structure.pdb') -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - model = Model().load_pdb('structure.pdb') - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model.py", line 231, in load_pdb - reader = pdb.PDBReader(verbose=self.verbose).read(file) - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/pdb.py", line 339, in read - self.dataframe = load_as_dataframe(filepath) - ^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/pdb.py", line 149, in load_as_dataframe - skipheader = find_header_length(filepath) - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/pdb.py", line 67, in find_header_length - with open(filepath, "r") as f: - ^^^^^^^^^^^^^^^^^^^ - FileNotFoundError: [Errno 2] No such file or directory: 'structure.pdb' -********************************************************************** -File "None", line ?, in default -Failed example: - chain_a = model.select("chain A") -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - chain_a = model.select("chain A") - ^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model_ft.py", line 146, in select - selection = super().select(selection) - ^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model.py", line 1473, in select - raise RuntimeError( - RuntimeError: Cannot select from an uninitialized Model. Load data first. -********************************************************************** -File "None", line ?, in default -Failed example: - backbone = model.select("name CA or name C or name N or name O") -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - backbone = model.select("name CA or name C or name N or name O") - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model_ft.py", line 146, in select - selection = super().select(selection) - ^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model.py", line 1473, in select - raise RuntimeError( - RuntimeError: Cannot select from an uninitialized Model. Load data first. -********************************************************************** -File "None", line ?, in default -Failed example: - region = model.select("chain B and resseq 10:50") -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - region = model.select("chain B and resseq 10:50") - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model_ft.py", line 146, in select - selection = super().select(selection) - ^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model.py", line 1473, in select - raise RuntimeError( - RuntimeError: Cannot select from an uninitialized Model. Load data first. -********************************************************************** -File "None", line ?, in default -Failed example: - no_water = model.select("not resname HOH") -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - no_water = model.select("not resname HOH") - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model_ft.py", line 146, in select - selection = super().select(selection) - ^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model.py", line 1473, in select - raise RuntimeError( - RuntimeError: Cannot select from an uninitialized Model. Load data first. -********************************************************************** -File "None", line ?, in default -Failed example: - complex_sel = model.select("chain A and (resname ALA or resname GLY)") -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - complex_sel = model.select("chain A and (resname ALA or resname GLY)") - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model_ft.py", line 146, in select - selection = super().select(selection) - ^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model.py", line 1473, in select - raise RuntimeError( - RuntimeError: Cannot select from an uninitialized Model. Load data first. -********************************************************************** -File "None", line ?, in default -Failed example: - model = ModelFT().load_pdb('structure.pdb') -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - model = ModelFT().load_pdb('structure.pdb') - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model_ft.py", line 140, in load_pdb - super().load_pdb(filename) - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model.py", line 231, in load_pdb - reader = pdb.PDBReader(verbose=self.verbose).read(file) - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/pdb.py", line 339, in read - self.dataframe = load_as_dataframe(filepath) - ^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/pdb.py", line 149, in load_as_dataframe - skipheader = find_header_length(filepath) - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/pdb.py", line 67, in find_header_length - with open(filepath, "r") as f: - ^^^^^^^^^^^^^^^^^^^ - FileNotFoundError: [Errno 2] No such file or directory: 'structure.pdb' -********************************************************************** -File "None", line ?, in default -Failed example: - model_copy = model.copy() -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - model_copy = model.copy() - ^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model_ft.py", line 670, in copy - raise RuntimeError("Cannot copy an uninitialized ModelFT. Load data first.") - RuntimeError: Cannot copy an uninitialized ModelFT. Load data first. -********************************************************************** -File "None", line ?, in default -Failed example: - mixed.load_state_dict(torch.load('mixed.pt')) -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - mixed.load_state_dict(torch.load('mixed.pt')) - ^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/site-packages/torch/serialization.py", line 1484, in load - with _open_file_like(f, "rb") as opened_file: - ^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/site-packages/torch/serialization.py", line 759, in _open_file_like - return _open_file(name_or_buffer, mode) - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/site-packages/torch/serialization.py", line 740, in __init__ - super().__init__(open(name, mode)) - ^^^^^^^^^^^^^^^^ - FileNotFoundError: [Errno 2] No such file or directory: 'mixed.pt' -********************************************************************** -File "None", line ?, in default -Failed example: - model.b[5] # Get B-factor for atom 5 -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - model.b[5] # Get B-factor for atom 5 - ~~~~~~~^^^ - TypeError: 'NoneType' object is not subscriptable -********************************************************************** -File "None", line ?, in default -Failed example: - model.b[5:10] # Get B-factors for atoms 5-9 -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - model.b[5:10] # Get B-factors for atoms 5-9 - ~~~~~~~^^^^^^ - TypeError: 'NoneType' object is not subscriptable -********************************************************************** -File "None", line ?, in default -Failed example: - model.b[mask] # Get B-factors where mask is True -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - model.b[mask] # Get B-factors where mask is True - ~~~~~~~^^^^^^ - TypeError: 'NoneType' object is not subscriptable -********************************************************************** -File "None", line ?, in default -Failed example: - model.xyz[:, 0] # Get all x-coordinates -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - model.xyz[:, 0] # Get all x-coordinates - ~~~~~~~~~^^^^^^ - TypeError: 'NoneType' object is not subscriptable -********************************************************************** -File "None", line ?, in default -Failed example: - model.b[:] = 30.0 # Set all B-factors to 30 -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - model.b[:] = 30.0 # Set all B-factors to 30 - ~~~~~~~^^^ - TypeError: 'NoneType' object does not support item assignment -********************************************************************** -File "None", line ?, in default -Failed example: - model.b[5:10] = 25.0 # Set B-factors 5-9 to 25 -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - model.b[5:10] = 25.0 # Set B-factors 5-9 to 25 - ~~~~~~~^^^^^^ - TypeError: 'NoneType' object does not support item assignment -********************************************************************** -File "None", line ?, in default -Failed example: - model.b[mask] = new_values # Set B-factors where mask is True -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - model.b[mask] = new_values # Set B-factors where mask is True - ^^^^^^^^^^ - NameError: name 'new_values' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - model.xyz[mask] = new_coords # Set coordinates for masked atoms -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - model.xyz[mask] = new_coords # Set coordinates for masked atoms - ^^^^^^^^^^ - NameError: name 'new_coords' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - model.xyz[:, 0] += 1.0 # Shift all x-coordinates (read-modify-write) -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - model.xyz[:, 0] += 1.0 # Shift all x-coordinates (read-modify-write) - ~~~~~~~~~^^^^^^ - TypeError: 'NoneType' object is not subscriptable -********************************************************************** -File "None", line ?, in default -Failed example: - mask = model.get_selection_mask("chain A") -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - mask = model.get_selection_mask("chain A") - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model.py", line 1407, in get_selection_mask - raise RuntimeError( - RuntimeError: Cannot get selection mask from an uninitialized Model. Load data first. -********************************************************************** -File "None", line ?, in default -Failed example: - new_coords = original_coords[mask] + shift -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - new_coords = original_coords[mask] + shift - ^^^^^^^^^^^^^^^ - NameError: name 'original_coords' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - model.xyz.set(new_coords, mask) -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - model.xyz.set(new_coords, mask) - ^^^^^^^^^^^^^ - AttributeError: 'NoneType' object has no attribute 'set' -********************************************************************** -File "None", line ?, in default -Failed example: - mask = model.get_selection_mask("resseq 10:20") -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - mask = model.get_selection_mask("resseq 10:20") - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model.py", line 1407, in get_selection_mask - raise RuntimeError( - RuntimeError: Cannot get selection mask from an uninitialized Model. Load data first. -********************************************************************** -File "None", line ?, in default -Failed example: - model.b.set(new_b, mask) -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - model.b.set(new_b, mask) - ^^^^^^^^^^^ - AttributeError: 'NoneType' object has no attribute 'set' -********************************************************************** -File "None", line ?, in default -Failed example: - b = PositiveMixedTensor() -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - b = PositiveMixedTensor() - ^^^^^^^^^^^^^^^^^^^ - NameError: name 'PositiveMixedTensor' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - b.load_state_dict(torch.load('b_factors.pt')) -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - b.load_state_dict(torch.load('b_factors.pt')) - ^^^^^^^^^^^^^^^^^ - AttributeError: 'NoneType' object has no attribute 'load_state_dict' -********************************************************************** -File "None", line ?, in default -Failed example: - b = PositiveMixedTensor(initial_b) -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - b = PositiveMixedTensor(initial_b) - ^^^^^^^^^^^^^^^^^^^ - NameError: name 'PositiveMixedTensor' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - output = b() # Returns exp(log_b) = positive values -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - output = b() # Returns exp(log_b) = positive values - ^^^ - TypeError: 'NoneType' object is not callable -********************************************************************** -File "None", line ?, in default -Failed example: - assert (b() > 0).all() -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - assert (b() > 0).all() - ^^^ - TypeError: 'NoneType' object is not callable -********************************************************************** -File "None", line ?, in default -Failed example: - mask = model.get_selection_mask("name CA") -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - mask = model.get_selection_mask("name CA") - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model.py", line 1407, in get_selection_mask - raise RuntimeError( - RuntimeError: Cannot get selection mask from an uninitialized Model. Load data first. -********************************************************************** -File "None", line ?, in default -Failed example: - model.b.set(new_b, mask) -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - model.b.set(new_b, mask) - ^^^^^^^^^^^ - AttributeError: 'NoneType' object has no attribute 'set' -********************************************************************** -File "None", line ?, in default -Failed example: - occ = OccupancyTensor( - initial_values=torch.tensor([1.0, 1.0, 0.7, 0.7, 0.3, 0.3]), - sharing_groups=sharing_groups, - altloc_groups=[([2, 3], [4, 5])], - ) -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - occ = OccupancyTensor( - ^^^^^^^^^^^^^^^ - NameError: name 'OccupancyTensor' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - result = occ() # Atoms 2-3 and 4-5 will sum to 1.0 -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - result = occ() # Atoms 2-3 and 4-5 will sum to 1.0 - ^^^ - NameError: name 'occ' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - freeze_mask = torch.zeros(n_atoms, dtype=torch.bool) -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - freeze_mask = torch.zeros(n_atoms, dtype=torch.bool) - ^^^^^^^ - NameError: name 'n_atoms' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - freeze_mask[0:11] = True -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - freeze_mask[0:11] = True - ^^^^^^^^^^^ - NameError: name 'freeze_mask' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - occ.freeze(freeze_mask) -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - occ.freeze(freeze_mask) - ^^^ - NameError: name 'occ' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - occ.freeze() -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - occ.freeze() - ^^^ - NameError: name 'occ' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - unfreeze_mask = torch.zeros(n_atoms, dtype=torch.bool) -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - unfreeze_mask = torch.zeros(n_atoms, dtype=torch.bool) - ^^^^^^^ - NameError: name 'n_atoms' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - unfreeze_mask[100:201] = True -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - unfreeze_mask[100:201] = True - ^^^^^^^^^^^^^ - NameError: name 'unfreeze_mask' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - occ.unfreeze(unfreeze_mask) -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - occ.unfreeze(unfreeze_mask) - ^^^ - NameError: name 'occ' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - occ.unfreeze() -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - occ.unfreeze() - ^^^ - NameError: name 'occ' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - atom_mask = torch.zeros(n_atoms, dtype=torch.bool) -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - atom_mask = torch.zeros(n_atoms, dtype=torch.bool) - ^^^^^^^ - NameError: name 'n_atoms' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - atom_mask[:100] = True -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - atom_mask[:100] = True - ^^^^^^^^^ - NameError: name 'atom_mask' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - occ.update_refinable_mask(atom_mask, in_compressed_space=False) -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - occ.update_refinable_mask(atom_mask, in_compressed_space=False) - ^^^ - NameError: name 'occ' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - group_mask = torch.zeros(n_groups, dtype=torch.bool) -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - group_mask = torch.zeros(n_groups, dtype=torch.bool) - ^^^^^^^^ - NameError: name 'n_groups' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - group_mask[::2] = True -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - group_mask[::2] = True - ^^^^^^^^^^ - NameError: name 'group_mask' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - occ.update_refinable_mask(group_mask, in_compressed_space=True) -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - occ.update_refinable_mask(group_mask, in_compressed_space=True) - ^^^ - NameError: name 'occ' is not defined -********************************************************************** -1 items had failures: - 167 of 207 in default -207 tests in 1 items. -40 passed and 167 failed. -***Test Failed*** 167 failures. - -Document: api/refinement ------------------------- -********************************************************************** -File "None", line ?, in default -Failed example: - refinement = Refinement( - data_file='reflections.mtz', - pdb='structure.pdb', - device='cuda' - ) -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - refinement = Refinement( - ^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/refinement/base_refinement.py", line 195, in __init__ - raise e - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/refinement/base_refinement.py", line 145, in __init__ - self.reflection_data.load_mtz(data_file) - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/datasets/reflection_data.py", line 233, in load_mtz - reader = mtz.MTZReader(verbose=self.verbose).read(path) - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/mtz.py", line 158, in read - self.mtz_data = rs.read_mtz(filepath) - ^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/site-packages/reciprocalspaceship/io/mtz.py", line 186, in read_mtz - gemmi_mtz = gemmi.read_mtz_file(mtzfile) - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - RuntimeError: Failed to open reflections.mtz: No such file or directory: reflections.mtz -********************************************************************** -File "None", line ?, in default -Failed example: - refinement.run_refinement(macro_cycles=10) -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - refinement.run_refinement(macro_cycles=10) - ^^^^^^^^^^ - NameError: name 'refinement' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - refinement.load_state_dict(torch.load('refinement.pt')) -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - refinement.load_state_dict(torch.load('refinement.pt')) - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/site-packages/torch/nn/modules/module.py", line 2629, in load_state_dict - raise RuntimeError( - RuntimeError: Error(s) in loading state_dict for Refinement: - Unexpected key(s) in state_dict: "model.pdb", "model.spacegroup", "model.initialized", "model.dtype_float", "model.device", "model.strip_H", "model.altloc_pairs", "model.max_res", "model.radius_angstrom", "scaler.nbins", "scaler.verbose", "scaler.frozen". -********************************************************************** -File "None", line ?, in default -Failed example: - refinement = Refinement(data_file='data.mtz', pdb='model.pdb') -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - refinement = Refinement(data_file='data.mtz', pdb='model.pdb') - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/refinement/base_refinement.py", line 195, in __init__ - raise e - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/refinement/base_refinement.py", line 145, in __init__ - self.reflection_data.load_mtz(data_file) - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/datasets/reflection_data.py", line 233, in load_mtz - reader = mtz.MTZReader(verbose=self.verbose).read(path) - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/mtz.py", line 158, in read - self.mtz_data = rs.read_mtz(filepath) - ^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/site-packages/reciprocalspaceship/io/mtz.py", line 186, in read_mtz - gemmi_mtz = gemmi.read_mtz_file(mtzfile) - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - RuntimeError: Failed to open data.mtz: No such file or directory: data.mtz -********************************************************************** -File "None", line ?, in default -Failed example: - refinement = Refinement.create_from_state_dict(state) -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - refinement = Refinement.create_from_state_dict(state) - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/refinement/base_refinement.py", line 1096, in create_from_state_dict - scaler = Scaler(model, reflection_data, verbose=verbose, device=device) - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/scaling/scaler.py", line 119, in __init__ - self.register_buffer("s", get_scattering_vectors(data.hkl, self.cell)) - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/math_functions/math_torch.py", line 942, in get_scattering_vectors - recB = reciprocal_basis_matrix(unit_cell) - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/math_functions/math_torch.py", line 885, in reciprocal_basis_matrix - angles_rad = torch.deg2rad(unit_cell[3:]) - ~~~~~~~~~^^^^ - TypeError: 'NoneType' object is not subscriptable -********************************************************************** -File "None", line ?, in default -Failed example: - rwork, rfree = refinement.get_rfactor() -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - rwork, rfree = refinement.get_rfactor() - ^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/refinement/base_refinement.py", line 951, in get_rfactor - return self.scaler.rfactor() - ^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/scaling/scaler.py", line 358, in rfactor - hkl, fobs, _, rfree = self._data() - ^^^^^^^^^^^^ - TypeError: 'NoneType' object is not callable -********************************************************************** -File "None", line ?, in default -Failed example: - print(f"Restored at R-work={rwork:.4f}, R-free={rfree:.4f}") -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - print(f"Restored at R-work={rwork:.4f}, R-free={rfree:.4f}") - ^^^^^ - NameError: name 'rwork' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - from torchref.refinement.loss_weighting import ResolutionDependentWeighting -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - from torchref.refinement.loss_weighting import ResolutionDependentWeighting - ModuleNotFoundError: No module named 'torchref.refinement.loss_weighting' -********************************************************************** -File "None", line ?, in default -Failed example: - weighter = ResolutionDependentWeighting() -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - weighter = ResolutionDependentWeighting() - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - NameError: name 'ResolutionDependentWeighting' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - refinement = LBFGSRefinement(mtz_file, pdb_file, weighter=weighter, target_mode='ml') -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - refinement = LBFGSRefinement(mtz_file, pdb_file, weighter=weighter, target_mode='ml') - ^^^^^^^^ - NameError: name 'mtz_file' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - refinement.refine(macro_cycles=2) -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - refinement.refine(macro_cycles=2) - ^^^^^^^^^^^^^^^^^ - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/site-packages/torch/nn/modules/module.py", line 1964, in __getattr__ - raise AttributeError( - AttributeError: 'Refinement' object has no attribute 'refine' -********************************************************************** -File "None", line ?, in default -Failed example: - policy = PolicyComponentWeighting( - refinement, policy_path='policy.pt', - sample=True, temperature=1.0 - ) -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - policy = PolicyComponentWeighting( - ^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/refinement/weighting/policy_weighting.py", line 199, in __init__ - self._load_policy(policy_path) - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/refinement/weighting/policy_weighting.py", line 301, in _load_policy - checkpoint = torch.load(checkpoint_path, map_location="cpu", weights_only=False) - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/site-packages/torch/serialization.py", line 1484, in load - with _open_file_like(f, "rb") as opened_file: - ^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/site-packages/torch/serialization.py", line 759, in _open_file_like - return _open_file(name_or_buffer, mode) - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/site-packages/torch/serialization.py", line 740, in __init__ - super().__init__(open(name, mode)) - ^^^^^^^^^^^^^^^^ - FileNotFoundError: [Errno 2] No such file or directory: 'policy.pt' -********************************************************************** -File "None", line ?, in default -Failed example: - trajectory = refinement.run_training_trajectory( - policy, n_steps=10, pdb_id='3GR5' - ) -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - trajectory = refinement.run_training_trajectory( - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/site-packages/torch/nn/modules/module.py", line 1964, in __getattr__ - raise AttributeError( - AttributeError: 'Refinement' object has no attribute 'run_training_trajectory' -********************************************************************** -File "None", line ?, in default -Failed example: - with open('trajectory.json', 'w') as f: - json.dump(trajectory_to_dict(trajectory), f) -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 2, in - json.dump(trajectory_to_dict(trajectory), f) - ^^^^^^^^^^ - NameError: name 'trajectory' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - refinement.load_state_dict(torch.load('refinement.pt')) -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - refinement.load_state_dict(torch.load('refinement.pt')) - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/site-packages/torch/nn/modules/module.py", line 2629, in load_state_dict - raise RuntimeError( - RuntimeError: Error(s) in loading state_dict for Refinement: - Unexpected key(s) in state_dict: "model.pdb", "model.spacegroup", "model.initialized", "model.dtype_float", "model.device", "model.strip_H", "model.altloc_pairs", "model.max_res", "model.radius_angstrom", "scaler.nbins", "scaler.verbose", "scaler.frozen". -********************************************************************** -File "None", line ?, in default -Failed example: - refinement = Refinement(data_file='data.mtz', pdb='model.pdb') -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - refinement = Refinement(data_file='data.mtz', pdb='model.pdb') - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/refinement/base_refinement.py", line 195, in __init__ - raise e - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/refinement/base_refinement.py", line 145, in __init__ - self.reflection_data.load_mtz(data_file) - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/datasets/reflection_data.py", line 233, in load_mtz - reader = mtz.MTZReader(verbose=self.verbose).read(path) - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/mtz.py", line 158, in read - self.mtz_data = rs.read_mtz(filepath) - ^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/site-packages/reciprocalspaceship/io/mtz.py", line 186, in read_mtz - gemmi_mtz = gemmi.read_mtz_file(mtzfile) - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - RuntimeError: Failed to open data.mtz: No such file or directory: data.mtz -********************************************************************** -File "None", line ?, in default -Failed example: - refinement = Refinement.create_from_state_dict(state) -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - refinement = Refinement.create_from_state_dict(state) - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/refinement/base_refinement.py", line 1096, in create_from_state_dict - scaler = Scaler(model, reflection_data, verbose=verbose, device=device) - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/scaling/scaler.py", line 119, in __init__ - self.register_buffer("s", get_scattering_vectors(data.hkl, self.cell)) - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/math_functions/math_torch.py", line 942, in get_scattering_vectors - recB = reciprocal_basis_matrix(unit_cell) - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/math_functions/math_torch.py", line 885, in reciprocal_basis_matrix - angles_rad = torch.deg2rad(unit_cell[3:]) - ~~~~~~~~~^^^^ - TypeError: 'NoneType' object is not subscriptable -********************************************************************** -File "None", line ?, in default -Failed example: - rwork, rfree = refinement.get_rfactor() -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - rwork, rfree = refinement.get_rfactor() - ^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/refinement/base_refinement.py", line 951, in get_rfactor - return self.scaler.rfactor() - ^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/scaling/scaler.py", line 358, in rfactor - hkl, fobs, _, rfree = self._data() - ^^^^^^^^^^^^ - TypeError: 'NoneType' object is not callable -********************************************************************** -File "None", line ?, in default -Failed example: - print(f"Restored at R-work={rwork:.4f}, R-free={rfree:.4f}") -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - print(f"Restored at R-work={rwork:.4f}, R-free={rfree:.4f}") - ^^^^^ - NameError: name 'rwork' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - target = Target() # Creates empty shell -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - target = Target() # Creates empty shell - ^^^^^^ - NameError: name 'Target' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - target.load_state_dict(torch.load('target.pt')) -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - target.load_state_dict(torch.load('target.pt')) - ^^^^^^ - NameError: name 'target' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - target = Target(refinement) -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - target = Target(refinement) - ^^^^^^ - NameError: name 'Target' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - geom_target = TotalGeometryTarget(refinement) -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - geom_target = TotalGeometryTarget(refinement) - ^^^^^^^^^^^^^^^^^^^ - NameError: name 'TotalGeometryTarget' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - loss = geom_target() -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - loss = geom_target() - ^^^^^^^^^^^ - NameError: name 'geom_target' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - bond_loss = geom_target['bond']() -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - bond_loss = geom_target['bond']() - ^^^^^^^^^^^ - NameError: name 'geom_target' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - for name, target in geom_target.items(): - print(f"{name}: {target()}") -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - for name, target in geom_target.items(): - ^^^^^^^^^^^ - NameError: name 'geom_target' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - adp_target = TotalADPTarget(refinement) -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - adp_target = TotalADPTarget(refinement) - ^^^^^^^^^^^^^^ - NameError: name 'TotalADPTarget' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - loss = adp_target() -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - loss = adp_target() - ^^^^^^^^^^ - NameError: name 'adp_target' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - simu_loss = adp_target['simu']() -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - simu_loss = adp_target['simu']() - ^^^^^^^^^^ - NameError: name 'adp_target' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - for name, target in adp_target.items(): - print(f"{name}: {target()}") -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - for name, target in adp_target.items(): - ^^^^^^^^^^ - NameError: name 'adp_target' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - from torchref.refinement.loss_weighting import ResolutionDependentWeighting -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - from torchref.refinement.loss_weighting import ResolutionDependentWeighting - ModuleNotFoundError: No module named 'torchref.refinement.loss_weighting' -********************************************************************** -File "None", line ?, in default -Failed example: - weighter = ResolutionDependentWeighting() -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - weighter = ResolutionDependentWeighting() - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - NameError: name 'ResolutionDependentWeighting' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - refinement = LBFGSRefinement(mtz_file, pdb_file, weighter=weighter, target_mode='ml') -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - refinement = LBFGSRefinement(mtz_file, pdb_file, weighter=weighter, target_mode='ml') - ^^^^^^^^ - NameError: name 'mtz_file' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - refinement.refine(macro_cycles=2) -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - refinement.refine(macro_cycles=2) - ^^^^^^^^^^^^^^^^^ - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/site-packages/torch/nn/modules/module.py", line 1964, in __getattr__ - raise AttributeError( - AttributeError: 'Refinement' object has no attribute 'refine' -********************************************************************** -File "None", line ?, in default -Failed example: - policy = PolicyComponentWeighting( - refinement, policy_path='policy.pt', - sample=True, temperature=1.0 - ) -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - policy = PolicyComponentWeighting( - ^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/refinement/weighting/policy_weighting.py", line 199, in __init__ - self._load_policy(policy_path) - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/refinement/weighting/policy_weighting.py", line 301, in _load_policy - checkpoint = torch.load(checkpoint_path, map_location="cpu", weights_only=False) - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/site-packages/torch/serialization.py", line 1484, in load - with _open_file_like(f, "rb") as opened_file: - ^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/site-packages/torch/serialization.py", line 759, in _open_file_like - return _open_file(name_or_buffer, mode) - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/site-packages/torch/serialization.py", line 740, in __init__ - super().__init__(open(name, mode)) - ^^^^^^^^^^^^^^^^ - FileNotFoundError: [Errno 2] No such file or directory: 'policy.pt' -********************************************************************** -File "None", line ?, in default -Failed example: - trajectory = refinement.run_training_trajectory( - policy, n_steps=10, pdb_id='3GR5' - ) -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - trajectory = refinement.run_training_trajectory( - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/site-packages/torch/nn/modules/module.py", line 1964, in __getattr__ - raise AttributeError( - AttributeError: 'Refinement' object has no attribute 'run_training_trajectory' -********************************************************************** -File "None", line ?, in default -Failed example: - with open('trajectory.json', 'w') as f: - json.dump(trajectory_to_dict(trajectory), f) -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 2, in - json.dump(trajectory_to_dict(trajectory), f) - ^^^^^^^^^^ - NameError: name 'trajectory' is not defined -********************************************************************** -1 items had failures: - 37 of 53 in default -53 tests in 1 items. -16 passed and 37 failed. -***Test Failed*** 37 failures. - -Document: api/restraints ------------------------- -********************************************************************** -File "None", line ?, in default -Failed example: - iterator = ResidueIterator(model.pdb) -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - iterator = ResidueIterator(model.pdb) - ^^^^^^^^^^^^^^^ - NameError: name 'ResidueIterator' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - for chain_id, resseq, residue_df in iterator: - print(f"Processing {chain_id}:{resseq}") -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - for chain_id, resseq, residue_df in iterator: - ^^^^^^^^ - NameError: name 'iterator' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - builder = BondRestraintBuilder() -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - builder = BondRestraintBuilder() - ^^^^^^^^^^^^^^^^^^^^ - NameError: name 'BondRestraintBuilder' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - for residue in residues: - builder.process_residue(residue, cif_bonds) -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - for residue in residues: - ^^^^^^^^ - NameError: name 'residues' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - result = builder.finalize(device) -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - result = builder.finalize(device) - ^^^^^^^ - NameError: name 'builder' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - print(result['indices'].shape) # (n_bonds, 2) -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - print(result['indices'].shape) # (n_bonds, 2) - ^^^^^^ - NameError: name 'result' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - builder = AngleRestraintBuilder() -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - builder = AngleRestraintBuilder() - ^^^^^^^^^^^^^^^^^^^^^ - NameError: name 'AngleRestraintBuilder' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - for residue in residues: - builder.process_residue(residue, cif_angles) -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - for residue in residues: - ^^^^^^^^ - NameError: name 'residues' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - result = builder.finalize(device) -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - result = builder.finalize(device) - ^^^^^^^ - NameError: name 'builder' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - print(result['indices'].shape) # (n_angles, 3) -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - print(result['indices'].shape) # (n_angles, 3) - ^^^^^^ - NameError: name 'result' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - builder = TorsionRestraintBuilder() -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - builder = TorsionRestraintBuilder() - ^^^^^^^^^^^^^^^^^^^^^^^ - NameError: name 'TorsionRestraintBuilder' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - for residue in residues: - builder.process_residue(residue, cif_torsions) -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - for residue in residues: - ^^^^^^^^ - NameError: name 'residues' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - result = builder.finalize(device) -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - result = builder.finalize(device) - ^^^^^^^ - NameError: name 'builder' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - print(result['indices'].shape) # (n_torsions, 4) -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - print(result['indices'].shape) # (n_torsions, 4) - ^^^^^^ - NameError: name 'result' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - print(result['periods'].shape) # (n_torsions,) -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - print(result['periods'].shape) # (n_torsions,) - ^^^^^^ - NameError: name 'result' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - builder = PlaneRestraintBuilder() -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - builder = PlaneRestraintBuilder() - ^^^^^^^^^^^^^^^^^^^^^ - NameError: name 'PlaneRestraintBuilder' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - for residue in residues: - builder.process_residue(residue, cif_planes) -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - for residue in residues: - ^^^^^^^^ - NameError: name 'residues' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - result = builder.finalize(device) -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - result = builder.finalize(device) - ^^^^^^^ - NameError: name 'builder' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - builder = ChiralRestraintBuilder(ideal_volume=2.5, sigma=0.2) -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - builder = ChiralRestraintBuilder(ideal_volume=2.5, sigma=0.2) - ^^^^^^^^^^^^^^^^^^^^^^ - NameError: name 'ChiralRestraintBuilder' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - for residue in residues: - builder.process_residue(residue, cif_chirals) -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - for residue in residues: - ^^^^^^^^ - NameError: name 'residues' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - result = builder.finalize(device) -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - result = builder.finalize(device) - ^^^^^^^ - NameError: name 'builder' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - print(result['indices'].shape) # (n_chirals, 4) -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - print(result['indices'].shape) # (n_chirals, 4) - ^^^^^^ - NameError: name 'result' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - print(result['ideal_volumes'].shape) # (n_chirals,) -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - print(result['ideal_volumes'].shape) # (n_chirals,) - ^^^^^^ - NameError: name 'result' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - builder = InterResidueBondBuilder() -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - builder = InterResidueBondBuilder() - ^^^^^^^^^^^^^^^^^^^^^^^ - NameError: name 'InterResidueBondBuilder' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - for res_i, res_next in iterator.get_consecutive_pairs(): - builder.process_peptide_bond(res_i, res_next, trans_link) -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - for res_i, res_next in iterator.get_consecutive_pairs(): - ^^^^^^^^ - NameError: name 'iterator' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - result = builder.finalize(device) -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - result = builder.finalize(device) - ^^^^^^^ - NameError: name 'builder' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - model.load_pdb_from_file('structure.pdb') -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - model.load_pdb_from_file('structure.pdb') - ^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/site-packages/torch/nn/modules/module.py", line 1964, in __getattr__ - raise AttributeError( - AttributeError: 'Model' object has no attribute 'load_pdb_from_file' -********************************************************************** -File "None", line ?, in default -Failed example: - restraints = Restraints(model) -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - restraints = Restraints(model) - ^^^^^^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/restraints/restraints_new.py", line 86, in __init__ - self.unique_residues = model.pdb.resname.unique() - ^^^^^^^^^^^^^^^^^ - AttributeError: 'NoneType' object has no attribute 'resname' -********************************************************************** -File "None", line ?, in default -Failed example: - bond_indices = restraints.restraints['bond']['intra']['indices'] -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - bond_indices = restraints.restraints['bond']['intra']['indices'] - ^^^^^^^^^^ - NameError: name 'restraints' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - angle_refs = restraints.restraints['angle']['peptide']['references'] -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - angle_refs = restraints.restraints['angle']['peptide']['references'] - ^^^^^^^^^^ - NameError: name 'restraints' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - torsion_periods = restraints.restraints['torsion']['intra']['periods'] -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - torsion_periods = restraints.restraints['torsion']['intra']['periods'] - ^^^^^^^^^^ - NameError: name 'restraints' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - plane_4atom_indices = restraints.restraints['plane']['4_atoms']['indices'] -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - plane_4atom_indices = restraints.restraints['plane']['4_atoms']['indices'] - ^^^^^^^^^^ - NameError: name 'restraints' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - bond_indices = restraints.bond_indices # intra-residue bonds -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - bond_indices = restraints.bond_indices # intra-residue bonds - ^^^^^^^^^^ - NameError: name 'restraints' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - bond_indices_inter = restraints.bond_indices_inter # peptide bonds -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - bond_indices_inter = restraints.bond_indices_inter # peptide bonds - ^^^^^^^^^^ - NameError: name 'restraints' is not defined -********************************************************************** -1 items had failures: - 34 of 37 in default -37 tests in 1 items. -3 passed and 34 failed. -***Test Failed*** 34 failures. - -Document: api/scaling ---------------------- -********************************************************************** -File "None", line ?, in default -Failed example: - F_calc_scaled = scaler(F_calc, F_obs, s_squared) -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - F_calc_scaled = scaler(F_calc, F_obs, s_squared) - ^^^^^^ - NameError: name 'F_calc' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - F_calc_total = solvent(F_calc, F_mask, s_squared) -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - F_calc_total = solvent(F_calc, F_mask, s_squared) - ^^^^^^ - NameError: name 'F_calc' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - metrics = scaler.refine_lbfgs(nsteps=5, verbose=True) -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - metrics = scaler.refine_lbfgs(nsteps=5, verbose=True) - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/scaling/scaler.py", line 613, in refine_lbfgs - optimizer = torch.optim.LBFGS( - ^^^^^^^^^^^^^^^^^^ - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/site-packages/torch/optim/lbfgs.py", line 243, in __init__ - super().__init__(params, defaults) - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/site-packages/torch/optim/optimizer.py", line 396, in __init__ - raise ValueError("optimizer got an empty parameter list") - ValueError: optimizer got an empty parameter list -********************************************************************** -File "None", line ?, in default -Failed example: - solvent.load_state_dict(torch.load('solvent.pt')) -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - solvent.load_state_dict(torch.load('solvent.pt')) - ^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/site-packages/torch/serialization.py", line 1484, in load - with _open_file_like(f, "rb") as opened_file: - ^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/site-packages/torch/serialization.py", line 759, in _open_file_like - return _open_file(name_or_buffer, mode) - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/site-packages/torch/serialization.py", line 740, in __init__ - super().__init__(open(name, mode)) - ^^^^^^^^^^^^^^^^ - FileNotFoundError: [Errno 2] No such file or directory: 'solvent.pt' -********************************************************************** -File "None", line ?, in default -Failed example: - solvent = SolventModel(model, k_solvent=0.35, b_solvent=46.0) -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - solvent = SolventModel(model, k_solvent=0.35, b_solvent=46.0) - ^^^^^ - NameError: name 'model' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - metrics = scaler.refine_lbfgs(nsteps=5, verbose=True) -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - metrics = scaler.refine_lbfgs(nsteps=5, verbose=True) - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/scaling/scaler.py", line 613, in refine_lbfgs - optimizer = torch.optim.LBFGS( - ^^^^^^^^^^^^^^^^^^ - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/site-packages/torch/optim/lbfgs.py", line 243, in __init__ - super().__init__(params, defaults) - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/site-packages/torch/optim/optimizer.py", line 396, in __init__ - raise ValueError("optimizer got an empty parameter list") - ValueError: optimizer got an empty parameter list -********************************************************************** -File "None", line ?, in default -Failed example: - solvent.load_state_dict(torch.load('solvent.pt')) -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - solvent.load_state_dict(torch.load('solvent.pt')) - ^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/site-packages/torch/serialization.py", line 1484, in load - with _open_file_like(f, "rb") as opened_file: - ^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/site-packages/torch/serialization.py", line 759, in _open_file_like - return _open_file(name_or_buffer, mode) - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/site-packages/torch/serialization.py", line 740, in __init__ - super().__init__(open(name, mode)) - ^^^^^^^^^^^^^^^^ - FileNotFoundError: [Errno 2] No such file or directory: 'solvent.pt' -********************************************************************** -File "None", line ?, in default -Failed example: - solvent = SolventModel(model, k_solvent=0.35, b_solvent=46.0) -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - solvent = SolventModel(model, k_solvent=0.35, b_solvent=46.0) - ^^^^^ - NameError: name 'model' is not defined -********************************************************************** -1 items had failures: - 8 of 17 in default -17 tests in 1 items. -9 passed and 8 failed. -***Test Failed*** 8 failures. - -Document: api/utils -------------------- -********************************************************************** -File "None", line ?, in default -Failed example: - masks['backbone'] = backbone_mask -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - masks['backbone'] = backbone_mask - ^^^^^^^^^^^^^ - NameError: name 'backbone_mask' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - grad_norm = gradnorm(loss, model.parameters()) -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - grad_norm = gradnorm(loss, model.parameters()) - ^^^^ - NameError: name 'loss' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - masks['rfree'] = rfree_flags > 0 -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - masks['rfree'] = rfree_flags > 0 - ^^^^^^^^^^^ - NameError: name 'rfree_flags' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - masks.cpu() # Move all to CPU -Expected nothing -Got: - TensorMasks({'valid': shape=torch.Size([100])}, device=cpu) -********************************************************************** -File "None", line ?, in default -Failed example: - model = MyModel() -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - model = MyModel() - ^^^^^^^ - NameError: name 'MyModel' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - scaler = Scaler() -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - scaler = Scaler() - ^^^^^^ - NameError: name 'Scaler' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - scaler._model = ModuleReference(model) # Won't register as submodule -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - scaler._model = ModuleReference(model) # Won't register as submodule - ^^^^^^^^^^^^^^^ - NameError: name 'ModuleReference' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - output = scaler._model.module(input_data) -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - output = scaler._model.module(input_data) - ^^^^^^ - NameError: name 'scaler' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - model.load_cif('structure.cif') -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - model.load_cif('structure.cif') - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model.py", line 252, in load_cif - cif_reader = cif.ModelCIFReader(file) - ^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/cif_readers.py", line 1263, in __init__ - self.cif = CIFReader(filepath) - ^^^^^^^^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/cif_readers.py", line 80, in __init__ - self.load(filepath) - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/cif_readers.py", line 92, in load - with open(filepath, "r", encoding="utf-8", errors="ignore") as f: - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - FileNotFoundError: [Errno 2] No such file or directory: 'structure.cif' -********************************************************************** -File "None", line ?, in default -Failed example: - model.pdb = sanitize_pdb_dataframe(model.pdb, verbose=1) -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - model.pdb = sanitize_pdb_dataframe(model.pdb, verbose=1) - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/utils/utils.py", line 797, in sanitize_pdb_dataframe - pdb = pdb.copy() - ^^^^^^^^ - AttributeError: 'NoneType' object has no attribute 'copy' -********************************************************************** -File "None", line ?, in default -Failed example: - mask = parse_phenix_selection("chain A", pdb_df) -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - mask = parse_phenix_selection("chain A", pdb_df) - ^^^^^^^^^^^^^^^^^^^^^^ - NameError: name 'parse_phenix_selection' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - mask = parse_phenix_selection("chain A and resseq 10:20", pdb_df) -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - mask = parse_phenix_selection("chain A and resseq 10:20", pdb_df) - ^^^^^^^^^^^^^^^^^^^^^^ - NameError: name 'parse_phenix_selection' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - mask = parse_phenix_selection("name CA", pdb_df) -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - mask = parse_phenix_selection("name CA", pdb_df) - ^^^^^^^^^^^^^^^^^^^^^^ - NameError: name 'parse_phenix_selection' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - mask = parse_phenix_selection("name CA or name C or name N or name O", pdb_df) -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - mask = parse_phenix_selection("name CA or name C or name N or name O", pdb_df) - ^^^^^^^^^^^^^^^^^^^^^^ - NameError: name 'parse_phenix_selection' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - mask = parse_phenix_selection("not resname HOH", pdb_df) -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - mask = parse_phenix_selection("not resname HOH", pdb_df) - ^^^^^^^^^^^^^^^^^^^^^^ - NameError: name 'parse_phenix_selection' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - mask = parse_phenix_selection("chain A and (name CA or name CB)", pdb_df) -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - mask = parse_phenix_selection("chain A and (name CA or name CB)", pdb_df) - ^^^^^^^^^^^^^^^^^^^^^^ - NameError: name 'parse_phenix_selection' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - mask = create_selection_mask("chain A", pdb_df, mode='set') -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - mask = create_selection_mask("chain A", pdb_df, mode='set') - ^^^^^^^^^^^^^^^^^^^^^ - NameError: name 'create_selection_mask' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - mask = create_selection_mask("resseq 10:20", pdb_df, current_mask=mask, mode='add') -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - mask = create_selection_mask("resseq 10:20", pdb_df, current_mask=mask, mode='add') - ^^^^^^^^^^^^^^^^^^^^^ - NameError: name 'create_selection_mask' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - mask = create_selection_mask("resname HOH", pdb_df, current_mask=mask, mode='remove') -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - mask = create_selection_mask("resname HOH", pdb_df, current_mask=mask, mode='remove') - ^^^^^^^^^^^^^^^^^^^^^ - NameError: name 'create_selection_mask' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - class MyTarget(HyperparameterMixin, nn.Module): - def __init__(self, sigma=1.0, target_value=0.0): - nn.Module.__init__(self) - HyperparameterMixin.__init__(self) - self.register_hyperparameter('sigma', sigma) - self.register_hyperparameter('target_value', target_value) -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - class MyTarget(HyperparameterMixin, nn.Module): - ^^^^^^^^^^^^^^^^^^^ - NameError: name 'HyperparameterMixin' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - target = MyTarget(sigma=2.5) -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - target = MyTarget(sigma=2.5) - ^^^^^^^^ - NameError: name 'MyTarget' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - dict(target.hyperparameters()) -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - dict(target.hyperparameters()) - ^^^^^^ - NameError: name 'target' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - params = module.hyperparameter_dict() -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - params = module.hyperparameter_dict() - ^^^^^^ - NameError: name 'module' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - json.dumps(params) # JSON serializable -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - json.dumps(params) # JSON serializable - ^^^^^^ - NameError: name 'params' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - hp_state = module.hyperparameter_state_dict() -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - hp_state = module.hyperparameter_state_dict() - ^^^^^^ - NameError: name 'module' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - torch.save(hp_state, 'hyperparameters.pt') -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - torch.save(hp_state, 'hyperparameters.pt') - ^^^^^^^^ - NameError: name 'hp_state' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - for name, hp in module.hyperparameters(): - print(f"{name}: {hp.item()}") -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - for name, hp in module.hyperparameters(): - ^^^^^^ - NameError: name 'module' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - hp_state = torch.load('hyperparameters.pt') -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - hp_state = torch.load('hyperparameters.pt') - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/site-packages/torch/serialization.py", line 1484, in load - with _open_file_like(f, "rb") as opened_file: - ^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/site-packages/torch/serialization.py", line 759, in _open_file_like - return _open_file(name_or_buffer, mode) - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/site-packages/torch/serialization.py", line 740, in __init__ - super().__init__(open(name, mode)) - ^^^^^^^^^^^^^^^^ - FileNotFoundError: [Errno 2] No such file or directory: 'hyperparameters.pt' -********************************************************************** -File "None", line ?, in default -Failed example: - module.load_hyperparameter_state_dict(hp_state) -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - module.load_hyperparameter_state_dict(hp_state) - ^^^^^^ - NameError: name 'module' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - self.register_hyperparameter('sigma', 2.0) -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - self.register_hyperparameter('sigma', 2.0) - ^^^^ - NameError: name 'self' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - self.sigma # Access via property (if defined) or _hp_sigma -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - self.sigma # Access via property (if defined) or _hp_sigma - ^^^^ - NameError: name 'self' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - loss = model(input) -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - loss = model(input) - ^^^^^^^^^^^^ - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/site-packages/torch/nn/modules/module.py", line 1775, in _wrapped_call_impl - return self._call_impl(*args, **kwargs) - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/site-packages/torch/nn/modules/module.py", line 1786, in _call_impl - return forward_call(*args, **kwargs) - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/site-packages/torch/nn/modules/module.py", line 399, in _forward_unimplemented - raise NotImplementedError( - NotImplementedError: Module [Model] is missing the required "forward" function -********************************************************************** -File "None", line ?, in default -Failed example: - grad_norm = gradnorm(loss, model.parameters()) -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - grad_norm = gradnorm(loss, model.parameters()) - ^^^^ - NameError: name 'loss' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - print(f"Gradient norm: {grad_norm:.4f}") -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - print(f"Gradient norm: {grad_norm:.4f}") - ^^^^^^^^^ - NameError: name 'grad_norm' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - model = MyModel() -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - model = MyModel() - ^^^^^^^ - NameError: name 'MyModel' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - scaler = Scaler() -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - scaler = Scaler() - ^^^^^^ - NameError: name 'Scaler' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - scaler._model = ModuleReference(model) # Won't register as submodule -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - scaler._model = ModuleReference(model) # Won't register as submodule - ^^^^^^^^^^^^^^^ - NameError: name 'ModuleReference' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - output = scaler._model.module(input_data) -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - output = scaler._model.module(input_data) - ^^^^^^ - NameError: name 'scaler' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - masks['rfree'] = rfree_flags > 0 -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - masks['rfree'] = rfree_flags > 0 - ^^^^^^^^^^^ - NameError: name 'rfree_flags' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - masks.cpu() # Move all to CPU -Expected nothing -Got: - TensorMasks({'valid': shape=torch.Size([100])}, device=cpu) -********************************************************************** -File "None", line ?, in default -Failed example: - model.load_cif('structure.cif') -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - model.load_cif('structure.cif') - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/model/model.py", line 252, in load_cif - cif_reader = cif.ModelCIFReader(file) - ^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/cif_readers.py", line 1263, in __init__ - self.cif = CIFReader(filepath) - ^^^^^^^^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/cif_readers.py", line 80, in __init__ - self.load(filepath) - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/io/cif_readers.py", line 92, in load - with open(filepath, "r", encoding="utf-8", errors="ignore") as f: - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - FileNotFoundError: [Errno 2] No such file or directory: 'structure.cif' -********************************************************************** -File "None", line ?, in default -Failed example: - model.pdb = sanitize_pdb_dataframe(model.pdb, verbose=1) -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - model.pdb = sanitize_pdb_dataframe(model.pdb, verbose=1) - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/das/work/units/LBR-FEL/p17490/Peter/Library/torchref/torchref/utils/utils.py", line 797, in sanitize_pdb_dataframe - pdb = pdb.copy() - ^^^^^^^^ - AttributeError: 'NoneType' object has no attribute 'copy' -********************************************************************** -File "None", line ?, in default -Failed example: - mask = parse_phenix_selection("chain A", pdb_df) -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - mask = parse_phenix_selection("chain A", pdb_df) - ^^^^^^^^^^^^^^^^^^^^^^ - NameError: name 'parse_phenix_selection' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - mask = parse_phenix_selection("chain A and resseq 10:20", pdb_df) -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - mask = parse_phenix_selection("chain A and resseq 10:20", pdb_df) - ^^^^^^^^^^^^^^^^^^^^^^ - NameError: name 'parse_phenix_selection' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - mask = parse_phenix_selection("name CA", pdb_df) -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - mask = parse_phenix_selection("name CA", pdb_df) - ^^^^^^^^^^^^^^^^^^^^^^ - NameError: name 'parse_phenix_selection' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - mask = parse_phenix_selection("name CA or name C or name N or name O", pdb_df) -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - mask = parse_phenix_selection("name CA or name C or name N or name O", pdb_df) - ^^^^^^^^^^^^^^^^^^^^^^ - NameError: name 'parse_phenix_selection' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - mask = parse_phenix_selection("not resname HOH", pdb_df) -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - mask = parse_phenix_selection("not resname HOH", pdb_df) - ^^^^^^^^^^^^^^^^^^^^^^ - NameError: name 'parse_phenix_selection' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - mask = parse_phenix_selection("chain A and (name CA or name CB)", pdb_df) -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - mask = parse_phenix_selection("chain A and (name CA or name CB)", pdb_df) - ^^^^^^^^^^^^^^^^^^^^^^ - NameError: name 'parse_phenix_selection' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - mask = create_selection_mask("chain A", pdb_df, mode='set') -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - mask = create_selection_mask("chain A", pdb_df, mode='set') - ^^^^^^^^^^^^^^^^^^^^^ - NameError: name 'create_selection_mask' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - mask = create_selection_mask("resseq 10:20", pdb_df, current_mask=mask, mode='add') -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - mask = create_selection_mask("resseq 10:20", pdb_df, current_mask=mask, mode='add') - ^^^^^^^^^^^^^^^^^^^^^ - NameError: name 'create_selection_mask' is not defined -********************************************************************** -File "None", line ?, in default -Failed example: - mask = create_selection_mask("resname HOH", pdb_df, current_mask=mask, mode='remove') -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 1, in - mask = create_selection_mask("resname HOH", pdb_df, current_mask=mask, mode='remove') - ^^^^^^^^^^^^^^^^^^^^^ - NameError: name 'create_selection_mask' is not defined -********************************************************************** -1 items had failures: - 51 of 67 in default -67 tests in 1 items. -16 passed and 51 failed. -***Test Failed*** 51 failures. - -Document: quickstart --------------------- -********************************************************************** -File "quickstart.rst", line 117, in default -Failed example: - from torchref import ModelFT, ROOT_TORCHREF - - model = ModelFT() - model.load_pdb(f"{ROOT_TORCHREF}/example_notebooks/1DAW.pdb") - - print(f"Number of atoms: {model.n_atoms}") -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 6, in - print(f"Number of atoms: {model.n_atoms}") - ^^^^^^^^^^^^^ - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/site-packages/torch/nn/modules/module.py", line 1964, in __getattr__ - raise AttributeError( - AttributeError: 'ModelFT' object has no attribute 'n_atoms' -********************************************************************** -File "quickstart.rst", line 299, in default -Failed example: - from torchref import LBFGSRefinement, ROOT_TORCHREF - - refinement = LBFGSRefinement( - data_file=f"{ROOT_TORCHREF}/example_notebooks/1DAW.mtz", - pdb=f"{ROOT_TORCHREF}/example_notebooks/1DAW.pdb", - ) - - # Perturb coordinates to simulate errors - refinement.model.shake_coords(0.1) - - # Run xyz refinement (3 cycles for quick test) - refinement.refine_xyz(n_cycles=1) - - rwork, rfree = refinement.get_rfactor() - print(f"After refinement - Rwork: {rwork:.3f}") -Exception raised: - Traceback (most recent call last): - File "/das/work/p17/p17490/CONDA/torchref/lib/python3.11/doctest.py", line 1355, in __run - exec(compile(example.source, filename, "single", - File "", line 12, in - refinement.refine_xyz(n_cycles=1) - TypeError: LBFGSRefinement.refine_xyz() got an unexpected keyword argument 'n_cycles' From 6d0cfebf11948db65adbc8ef2d337efd018bbd64 Mon Sep 17 00:00:00 2001 From: HatPdotS Date: Fri, 31 Jul 2026 10:59:18 +0200 Subject: [PATCH 15/15] Added logos --- logo/ASCII.txt | 21 ++++++++++++++ logo/torchref-a-ink-cage-red-helix-small.svg | 23 +++++++++++++++ logo/torchref-a-ink-cage-red-helix.svg | 29 +++++++++++++++++++ logo/torchref-b-red-cage-ink-helix-small.svg | 23 +++++++++++++++ logo/torchref-b-red-cage-ink-helix.svg | 29 +++++++++++++++++++ logo/torchref-c-all-red-small.svg | 23 +++++++++++++++ logo/torchref-c-all-red.svg | 29 +++++++++++++++++++ logo/torchref-d-mono-no-accent-small.svg | 23 +++++++++++++++ logo/torchref-d-mono-no-accent.svg | 29 +++++++++++++++++++ logo/torchref-e-knockout-on-ink-small.svg | 24 ++++++++++++++++ logo/torchref-e-knockout-on-ink.svg | 30 ++++++++++++++++++++ logo/torchref-f-on-red-field-small.svg | 24 ++++++++++++++++ logo/torchref-f-on-red-field.svg | 30 ++++++++++++++++++++ 13 files changed, 337 insertions(+) create mode 100644 logo/ASCII.txt create mode 100644 logo/torchref-a-ink-cage-red-helix-small.svg create mode 100644 logo/torchref-a-ink-cage-red-helix.svg create mode 100644 logo/torchref-b-red-cage-ink-helix-small.svg create mode 100644 logo/torchref-b-red-cage-ink-helix.svg create mode 100644 logo/torchref-c-all-red-small.svg create mode 100644 logo/torchref-c-all-red.svg create mode 100644 logo/torchref-d-mono-no-accent-small.svg create mode 100644 logo/torchref-d-mono-no-accent.svg create mode 100644 logo/torchref-e-knockout-on-ink-small.svg create mode 100644 logo/torchref-e-knockout-on-ink.svg create mode 100644 logo/torchref-f-on-red-field-small.svg create mode 100644 logo/torchref-f-on-red-field.svg diff --git a/logo/ASCII.txt b/logo/ASCII.txt new file mode 100644 index 0000000..132134b --- /dev/null +++ b/logo/ASCII.txt @@ -0,0 +1,21 @@ + oo + oo + oo # + oo # + oo # + oo ## o + oo ##### oo + oo ###### oo + oo ##### oo + o ##====== o + oo =========== oo + oo ==#### oo + o ########## o + o ########## o + o #####== o + o #=========== o + oo ======== oo + oo ==== oo + ooo ooo + oooo oooo + oooooooooo \ No newline at end of file diff --git a/logo/torchref-a-ink-cage-red-helix-small.svg b/logo/torchref-a-ink-cage-red-helix-small.svg new file mode 100644 index 0000000..38413d5 --- /dev/null +++ b/logo/torchref-a-ink-cage-red-helix-small.svg @@ -0,0 +1,23 @@ + + TorchRef — small mark + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/logo/torchref-a-ink-cage-red-helix.svg b/logo/torchref-a-ink-cage-red-helix.svg new file mode 100644 index 0000000..4ffb8e0 --- /dev/null +++ b/logo/torchref-a-ink-cage-red-helix.svg @@ -0,0 +1,29 @@ + + TorchRef + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/logo/torchref-b-red-cage-ink-helix-small.svg b/logo/torchref-b-red-cage-ink-helix-small.svg new file mode 100644 index 0000000..4bbe659 --- /dev/null +++ b/logo/torchref-b-red-cage-ink-helix-small.svg @@ -0,0 +1,23 @@ + + TorchRef — small mark + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/logo/torchref-b-red-cage-ink-helix.svg b/logo/torchref-b-red-cage-ink-helix.svg new file mode 100644 index 0000000..8877805 --- /dev/null +++ b/logo/torchref-b-red-cage-ink-helix.svg @@ -0,0 +1,29 @@ + + TorchRef + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/logo/torchref-c-all-red-small.svg b/logo/torchref-c-all-red-small.svg new file mode 100644 index 0000000..0426073 --- /dev/null +++ b/logo/torchref-c-all-red-small.svg @@ -0,0 +1,23 @@ + + TorchRef — small mark + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/logo/torchref-c-all-red.svg b/logo/torchref-c-all-red.svg new file mode 100644 index 0000000..242d09a --- /dev/null +++ b/logo/torchref-c-all-red.svg @@ -0,0 +1,29 @@ + + TorchRef + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/logo/torchref-d-mono-no-accent-small.svg b/logo/torchref-d-mono-no-accent-small.svg new file mode 100644 index 0000000..07d4bc4 --- /dev/null +++ b/logo/torchref-d-mono-no-accent-small.svg @@ -0,0 +1,23 @@ + + TorchRef — small mark + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/logo/torchref-d-mono-no-accent.svg b/logo/torchref-d-mono-no-accent.svg new file mode 100644 index 0000000..a6a679d --- /dev/null +++ b/logo/torchref-d-mono-no-accent.svg @@ -0,0 +1,29 @@ + + TorchRef + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/logo/torchref-e-knockout-on-ink-small.svg b/logo/torchref-e-knockout-on-ink-small.svg new file mode 100644 index 0000000..548c8bf --- /dev/null +++ b/logo/torchref-e-knockout-on-ink-small.svg @@ -0,0 +1,24 @@ + + TorchRef — small mark + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/logo/torchref-e-knockout-on-ink.svg b/logo/torchref-e-knockout-on-ink.svg new file mode 100644 index 0000000..b25f2e3 --- /dev/null +++ b/logo/torchref-e-knockout-on-ink.svg @@ -0,0 +1,30 @@ + + TorchRef + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/logo/torchref-f-on-red-field-small.svg b/logo/torchref-f-on-red-field-small.svg new file mode 100644 index 0000000..c5904df --- /dev/null +++ b/logo/torchref-f-on-red-field-small.svg @@ -0,0 +1,24 @@ + + TorchRef — small mark + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/logo/torchref-f-on-red-field.svg b/logo/torchref-f-on-red-field.svg new file mode 100644 index 0000000..216fe55 --- /dev/null +++ b/logo/torchref-f-on-red-field.svg @@ -0,0 +1,30 @@ + + TorchRef + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file