zipstrain 1.2.2__tar.gz → 1.2.3__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {zipstrain-1.2.2 → zipstrain-1.2.3}/PKG-INFO +1 -1
- {zipstrain-1.2.2 → zipstrain-1.2.3}/pyproject.toml +1 -1
- {zipstrain-1.2.2 → zipstrain-1.2.3}/src/zipstrain/cli.py +11 -0
- zipstrain-1.2.3/src/zipstrain/matrix_gemm.py +750 -0
- {zipstrain-1.2.2 → zipstrain-1.2.3}/src/zipstrain/matrix_pairs.py +52 -0
- {zipstrain-1.2.2 → zipstrain-1.2.3}/src/zipstrain/utils.py +72 -21
- {zipstrain-1.2.2 → zipstrain-1.2.3}/LICENSE +0 -0
- {zipstrain-1.2.2 → zipstrain-1.2.3}/README.md +0 -0
- {zipstrain-1.2.2 → zipstrain-1.2.3}/src/zipstrain/__init__.py +0 -0
- {zipstrain-1.2.2 → zipstrain-1.2.3}/src/zipstrain/build_db.py +0 -0
- {zipstrain-1.2.2 → zipstrain-1.2.3}/src/zipstrain/compare.py +0 -0
- {zipstrain-1.2.2 → zipstrain-1.2.3}/src/zipstrain/database.py +0 -0
- {zipstrain-1.2.2 → zipstrain-1.2.3}/src/zipstrain/healthcheck.py +0 -0
- {zipstrain-1.2.2 → zipstrain-1.2.3}/src/zipstrain/mapping.py +0 -0
- {zipstrain-1.2.2 → zipstrain-1.2.3}/src/zipstrain/matrix_workflow.py +0 -0
- {zipstrain-1.2.2 → zipstrain-1.2.3}/src/zipstrain/profile.py +0 -0
- {zipstrain-1.2.2 → zipstrain-1.2.3}/src/zipstrain/profile_backends.py +0 -0
- {zipstrain-1.2.2 → zipstrain-1.2.3}/src/zipstrain/resource_limits.py +0 -0
- {zipstrain-1.2.2 → zipstrain-1.2.3}/src/zipstrain/run_logger.py +0 -0
- {zipstrain-1.2.2 → zipstrain-1.2.3}/src/zipstrain/task_manager.py +0 -0
- {zipstrain-1.2.2 → zipstrain-1.2.3}/src/zipstrain/visualize.py +0 -0
- {zipstrain-1.2.2 → zipstrain-1.2.3}/src/zipstrain/zipstrain.code-workspace +0 -0
|
@@ -882,6 +882,14 @@ def append_matrix_db(profile_dir, matrix_db_file, memory_limit_gb, export_batch_
|
|
|
882
882
|
show_default=True,
|
|
883
883
|
help="Compute backend. Torch backends use CPU/CUDA/MPS depending on selection.",
|
|
884
884
|
)
|
|
885
|
+
@click.option(
|
|
886
|
+
'--compare-kernel',
|
|
887
|
+
type=click.Choice(mp.MATRIX_COMPARE_KERNELS),
|
|
888
|
+
default="pairwise",
|
|
889
|
+
show_default=True,
|
|
890
|
+
help="Comparison algorithm. 'gemm' computes genome-level popANI/conANI for whole sample blocks "
|
|
891
|
+
"with matrix multiplications (much faster; requires --calculate ani). 'pairwise' supports all metrics.",
|
|
892
|
+
)
|
|
885
893
|
def matrix_compare(
|
|
886
894
|
matrix_db_file,
|
|
887
895
|
output_file,
|
|
@@ -895,6 +903,7 @@ def matrix_compare(
|
|
|
895
903
|
writer_executor,
|
|
896
904
|
calculate,
|
|
897
905
|
backend,
|
|
906
|
+
compare_kernel,
|
|
898
907
|
):
|
|
899
908
|
"""
|
|
900
909
|
Run resumable matrix compare on all non-redundant, non-self sample pairs.
|
|
@@ -955,6 +964,7 @@ def matrix_compare(
|
|
|
955
964
|
calculate=calculate,
|
|
956
965
|
emit_writer_logs=False,
|
|
957
966
|
progress_callback=_progress_callback,
|
|
967
|
+
compare_kernel=compare_kernel,
|
|
958
968
|
)
|
|
959
969
|
else:
|
|
960
970
|
summary = mp.matrix_compare(
|
|
@@ -972,6 +982,7 @@ def matrix_compare(
|
|
|
972
982
|
calculate=calculate,
|
|
973
983
|
emit_writer_logs=True,
|
|
974
984
|
progress_callback=None,
|
|
985
|
+
compare_kernel=compare_kernel,
|
|
975
986
|
)
|
|
976
987
|
click.echo(
|
|
977
988
|
f"wrote={summary.output_file} "
|
|
@@ -0,0 +1,750 @@
|
|
|
1
|
+
"""Blocked-GEMM genome-level popANI / conANI for HDF5 matrix stores.
|
|
2
|
+
|
|
3
|
+
The pairwise kernels in :mod:`zipstrain.matrix_pairs` compare one anchor
|
|
4
|
+
sample against blocks of targets position by position. That is bandwidth
|
|
5
|
+
bound: every anchor/target pair re-reads both rows. For genome-level ANI the
|
|
6
|
+
per-pair quantities are plain sums over positions, so a whole block of
|
|
7
|
+
sample pairs can be computed with dense matrix multiplications instead.
|
|
8
|
+
|
|
9
|
+
Every supported matrix layout is reduced to a 4-bit allele mask per position
|
|
10
|
+
(bit ``b`` set when allele ``b`` is present after the build-time coverage
|
|
11
|
+
filter; for conANI, when allele ``b`` is a consensus allele). With ``X`` the
|
|
12
|
+
``samples x positions`` mask matrix and ``C = (X != 0)``:
|
|
13
|
+
|
|
14
|
+
``total_positions = C @ C.T``
|
|
15
|
+
positions covered in both samples.
|
|
16
|
+
|
|
17
|
+
``share_allele_pos = total_positions - mismatch``
|
|
18
|
+
a covered-in-both position is a mismatch when the two masks share no bit.
|
|
19
|
+
Mismatches can only happen at *informative* positions, where the covered
|
|
20
|
+
samples of the whole cohort do not share a common allele. On those
|
|
21
|
+
positions ``shared = sum_b A_b @ A_b.T`` corrected by inclusion-exclusion
|
|
22
|
+
over multi-allele masks (``A_m = (X & m) == m``), and
|
|
23
|
+
``mismatch = C_I @ C_I.T - shared_I``.
|
|
24
|
+
|
|
25
|
+
All GEMMs run in float32, which is exact for integer sums below ``2**24``;
|
|
26
|
+
partial sums are flushed into int64 host accumulators before that bound
|
|
27
|
+
can be reached. Results are bit-identical to the pairwise kernel.
|
|
28
|
+
|
|
29
|
+
Only genome-level ANI is produced; IBS (``max_consecutive_length``) and
|
|
30
|
+
gene-level ANI depend on position order or per-gene masks and must use the
|
|
31
|
+
pairwise kernel.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
import itertools
|
|
37
|
+
from dataclasses import dataclass
|
|
38
|
+
from pathlib import Path
|
|
39
|
+
from typing import Callable, Optional
|
|
40
|
+
|
|
41
|
+
import duckdb
|
|
42
|
+
import numpy as np
|
|
43
|
+
import pyarrow as pa
|
|
44
|
+
|
|
45
|
+
from zipstrain import matrix_pairs as mp
|
|
46
|
+
|
|
47
|
+
GEMM_SUPPORTED_ANI_KINDS = ("popani", "conani")
|
|
48
|
+
#: float32 represents every integer up to 2**24 exactly.
|
|
49
|
+
GEMM_EXACT_FP32_LIMIT = 1 << 24
|
|
50
|
+
#: Default number of positions multiplied per GEMM call.
|
|
51
|
+
GEMM_POSITION_CHUNK_DEFAULT = 32_768
|
|
52
|
+
#: Largest sample block considered by the planner. Diagonal block pairs are
|
|
53
|
+
#: computed in full, so larger blocks add ~n*B/2 redundant pair counts.
|
|
54
|
+
GEMM_MAX_BLOCK_SAMPLES = 4_096
|
|
55
|
+
#: Bound raw count reads and their mask-conversion temporaries independently
|
|
56
|
+
#: of the size of the final, one-byte-per-position mask cache.
|
|
57
|
+
GEMM_READ_BUFFER_BYTES_DEFAULT = 128 * 1024**2
|
|
58
|
+
|
|
59
|
+
_ALLELE_BITS = tuple(int(bit) for bit in mp.BITMASK_BASE_BITS)
|
|
60
|
+
# Inclusion-exclusion terms for |{b : b in mask_i and b in mask_j}| > 0:
|
|
61
|
+
# + singles - pairs + triples - quad. Each entry is (sign, combined mask).
|
|
62
|
+
_COMBO_TERMS = tuple(
|
|
63
|
+
(sign, int(sum(combo)))
|
|
64
|
+
for size, sign in ((2, -1.0), (3, 1.0), (4, -1.0))
|
|
65
|
+
for combo in itertools.combinations(_ALLELE_BITS, size)
|
|
66
|
+
)
|
|
67
|
+
_POPCOUNT = np.array([bin(value).count("1") for value in range(256)], dtype=np.uint8)
|
|
68
|
+
_FULL_MASK = np.uint8(sum(_ALLELE_BITS))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass(frozen=True)
|
|
72
|
+
class GemmPlan:
|
|
73
|
+
"""Sample block size and position chunk used by the GEMM kernel."""
|
|
74
|
+
|
|
75
|
+
block_samples: int
|
|
76
|
+
position_chunk: int
|
|
77
|
+
cache_all_rows: bool
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def rows_to_allele_bitmask(
|
|
81
|
+
rows: np.ndarray,
|
|
82
|
+
*,
|
|
83
|
+
matrix_value_semantics: str,
|
|
84
|
+
ani_kind: str,
|
|
85
|
+
) -> np.ndarray:
|
|
86
|
+
"""Convert a batch of stored matrix rows to ``(B, L)`` uint8 allele masks.
|
|
87
|
+
|
|
88
|
+
``rows`` is what ``_Hdf5GenomeMatrixNumpyDataset._load_batch_numpy``
|
|
89
|
+
returns: ``(B, L)`` packed bitmasks, or ``(B, L, 4)`` counts / presence.
|
|
90
|
+
"""
|
|
91
|
+
if matrix_value_semantics == mp.BITMASK_MATRIX_VALUE_SEMANTICS:
|
|
92
|
+
if ani_kind != "popani":
|
|
93
|
+
raise ValueError("Bitmask matrix stores support only popANI.")
|
|
94
|
+
return np.ascontiguousarray(rows, dtype=np.uint8)
|
|
95
|
+
if rows.ndim != 3 or rows.shape[-1] != len(_ALLELE_BITS):
|
|
96
|
+
raise ValueError(f"Expected (samples, positions, 4) matrix rows, got shape {rows.shape}.")
|
|
97
|
+
if ani_kind == "conani":
|
|
98
|
+
if matrix_value_semantics != mp.COUNT_MATRIX_VALUE_SEMANTICS:
|
|
99
|
+
raise ValueError("conANI requires a count matrix store.")
|
|
100
|
+
maxima = rows.max(axis=2, keepdims=True)
|
|
101
|
+
selected = (rows == maxima) & (maxima > 0)
|
|
102
|
+
elif ani_kind == "popani":
|
|
103
|
+
if matrix_value_semantics not in (
|
|
104
|
+
mp.COUNT_MATRIX_VALUE_SEMANTICS,
|
|
105
|
+
mp.FILTERED_PRESENCE_MATRIX_VALUE_SEMANTICS,
|
|
106
|
+
):
|
|
107
|
+
raise ValueError(f"Unsupported matrix value semantics {matrix_value_semantics!r}.")
|
|
108
|
+
selected = rows > 0
|
|
109
|
+
else:
|
|
110
|
+
raise ValueError(f"compare_kernel='gemm' does not support ani_method {ani_kind!r}.")
|
|
111
|
+
out = np.zeros(rows.shape[:2], dtype=np.uint8)
|
|
112
|
+
for channel, bit in enumerate(_ALLELE_BITS):
|
|
113
|
+
out |= selected[:, :, channel].astype(np.uint8) * np.uint8(bit)
|
|
114
|
+
return out
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _plan_gemm_blocks(
|
|
118
|
+
*,
|
|
119
|
+
sample_count: int,
|
|
120
|
+
matrix_length: int,
|
|
121
|
+
memory_limit_bytes: int,
|
|
122
|
+
device_memory_bytes: Optional[int] = None,
|
|
123
|
+
informative_count: Optional[int] = None,
|
|
124
|
+
) -> GemmPlan:
|
|
125
|
+
"""Pick the largest sample block that fits the memory budget.
|
|
126
|
+
|
|
127
|
+
The whole genome matrix is cached on the host when it fits in a third of
|
|
128
|
+
the budget; row blocks are then zero-copy views. Otherwise two uint8 row
|
|
129
|
+
blocks are resident. Each block also keeps a copy of its informative
|
|
130
|
+
columns. Compute uses ``B x W`` float32 tiles (uploaded per position
|
|
131
|
+
chunk for torch backends) and three ``B x B`` accumulators, plus int64
|
|
132
|
+
host copies of the accumulators.
|
|
133
|
+
"""
|
|
134
|
+
length = int(matrix_length)
|
|
135
|
+
width = max(1, min(length, GEMM_POSITION_CHUNK_DEFAULT))
|
|
136
|
+
informative = length if informative_count is None else int(informative_count)
|
|
137
|
+
budget = int(memory_limit_bytes * 0.7)
|
|
138
|
+
full_matrix = int(sample_count) * length
|
|
139
|
+
cache_all = full_matrix <= memory_limit_bytes // 3
|
|
140
|
+
if cache_all:
|
|
141
|
+
budget -= full_matrix
|
|
142
|
+
device_budget = None if device_memory_bytes is None else int(device_memory_bytes * 0.7)
|
|
143
|
+
|
|
144
|
+
def host_bytes(block: int) -> int:
|
|
145
|
+
rows = 0 if cache_all else 2 * block * length
|
|
146
|
+
return rows + 2 * block * informative + 12 * block * width * 4 + 3 * block * block * 12
|
|
147
|
+
|
|
148
|
+
def device_bytes(block: int) -> int:
|
|
149
|
+
return 12 * block * width * 4 + 3 * block * block * 4
|
|
150
|
+
|
|
151
|
+
block = max(1, min(int(sample_count), GEMM_MAX_BLOCK_SAMPLES))
|
|
152
|
+
while block > 1 and (
|
|
153
|
+
host_bytes(block) > budget
|
|
154
|
+
or (device_budget is not None and device_bytes(block) > device_budget)
|
|
155
|
+
):
|
|
156
|
+
block = max(1, block // 2) if block > 64 else block - 1
|
|
157
|
+
return GemmPlan(block_samples=block, position_chunk=width, cache_all_rows=cache_all)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
class _GemmOps:
|
|
161
|
+
"""Tiny numpy/torch abstraction for the handful of tensor ops we need."""
|
|
162
|
+
|
|
163
|
+
def __init__(self, compute_backend) -> None:
|
|
164
|
+
self.kind = compute_backend.kind
|
|
165
|
+
self.torch = compute_backend.torch
|
|
166
|
+
self.device = compute_backend.device
|
|
167
|
+
self._saved_precision = None
|
|
168
|
+
self._saved_tf32 = None
|
|
169
|
+
self._product = None
|
|
170
|
+
|
|
171
|
+
def __enter__(self) -> "_GemmOps":
|
|
172
|
+
if self.kind == "torch":
|
|
173
|
+
torch = self.torch
|
|
174
|
+
# TF32 / reduced precision would break exactness.
|
|
175
|
+
self._saved_precision = torch.get_float32_matmul_precision()
|
|
176
|
+
torch.set_float32_matmul_precision("highest")
|
|
177
|
+
if hasattr(torch.backends, "cuda"):
|
|
178
|
+
self._saved_tf32 = torch.backends.cuda.matmul.allow_tf32
|
|
179
|
+
torch.backends.cuda.matmul.allow_tf32 = False
|
|
180
|
+
return self
|
|
181
|
+
|
|
182
|
+
def __exit__(self, *exc) -> None:
|
|
183
|
+
if self.kind == "torch":
|
|
184
|
+
self.torch.set_float32_matmul_precision(self._saved_precision)
|
|
185
|
+
if self._saved_tf32 is not None:
|
|
186
|
+
self.torch.backends.cuda.matmul.allow_tf32 = self._saved_tf32
|
|
187
|
+
|
|
188
|
+
def tile(self, host_block: np.ndarray, start: int, stop: int):
|
|
189
|
+
"""Columns ``start:stop`` of a host uint8 block, on the compute device.
|
|
190
|
+
|
|
191
|
+
Torch backends upload one contiguous ``B x W`` tile at a time, so the
|
|
192
|
+
device never holds whole-genome row blocks.
|
|
193
|
+
"""
|
|
194
|
+
view = host_block[:, start:stop]
|
|
195
|
+
if self.kind == "numpy":
|
|
196
|
+
return view
|
|
197
|
+
return self.torch.from_numpy(np.ascontiguousarray(view)).to(self.device)
|
|
198
|
+
|
|
199
|
+
def zeros(self, rows: int, cols: int):
|
|
200
|
+
if self.kind == "numpy":
|
|
201
|
+
return np.zeros((rows, cols), dtype=np.float32)
|
|
202
|
+
return self.torch.zeros((rows, cols), dtype=self.torch.float32, device=self.device)
|
|
203
|
+
|
|
204
|
+
def covered(self, tile):
|
|
205
|
+
if self.kind == "numpy":
|
|
206
|
+
return (tile != 0).astype(np.float32)
|
|
207
|
+
return (tile != 0).to(self.torch.float32)
|
|
208
|
+
|
|
209
|
+
def has_bit(self, tile, bit: int):
|
|
210
|
+
if self.kind == "numpy":
|
|
211
|
+
return ((tile & np.uint8(bit)) != 0).astype(np.float32)
|
|
212
|
+
return ((tile & bit) != 0).to(self.torch.float32)
|
|
213
|
+
|
|
214
|
+
def has_all_bits(self, tile, bits: int):
|
|
215
|
+
if self.kind == "numpy":
|
|
216
|
+
return ((tile & np.uint8(bits)) == np.uint8(bits)).astype(np.float32)
|
|
217
|
+
return ((tile & bits) == bits).to(self.torch.float32)
|
|
218
|
+
|
|
219
|
+
def addmm_(self, acc, left, right, alpha: float = 1.0) -> None:
|
|
220
|
+
"""``acc += alpha * left @ right.T`` in float32."""
|
|
221
|
+
if self.kind == "numpy":
|
|
222
|
+
if self._product is None or self._product.shape != acc.shape:
|
|
223
|
+
self._product = np.empty(acc.shape, dtype=np.float32)
|
|
224
|
+
np.matmul(left, right.T, out=self._product)
|
|
225
|
+
if alpha == 1.0:
|
|
226
|
+
acc += self._product
|
|
227
|
+
else:
|
|
228
|
+
acc -= self._product if alpha == -1.0 else -np.float32(alpha) * self._product
|
|
229
|
+
return
|
|
230
|
+
acc.addmm_(left, right.T, alpha=alpha)
|
|
231
|
+
|
|
232
|
+
def to_int64(self, acc) -> np.ndarray:
|
|
233
|
+
if self.kind == "numpy":
|
|
234
|
+
return np.rint(acc).astype(np.int64)
|
|
235
|
+
return self.torch.round(acc).to(self.torch.int64).cpu().numpy()
|
|
236
|
+
|
|
237
|
+
def device_memory_bytes(self) -> Optional[int]:
|
|
238
|
+
if self.kind == "torch" and str(self.device).startswith("cuda"):
|
|
239
|
+
free, _total = self.torch.cuda.mem_get_info(self.torch.device(self.device))
|
|
240
|
+
return int(free)
|
|
241
|
+
return None
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
class _ExactAccumulator:
|
|
245
|
+
"""float32 device accumulator with periodic exact flushes to int64."""
|
|
246
|
+
|
|
247
|
+
def __init__(self, ops: _GemmOps, rows: int, cols: int, magnitude_per_position: int) -> None:
|
|
248
|
+
self.ops = ops
|
|
249
|
+
self.rows = rows
|
|
250
|
+
self.cols = cols
|
|
251
|
+
self.magnitude = int(magnitude_per_position)
|
|
252
|
+
self.acc = ops.zeros(rows, cols)
|
|
253
|
+
self.host = np.zeros((rows, cols), dtype=np.int64)
|
|
254
|
+
self.pending_positions = 0
|
|
255
|
+
|
|
256
|
+
def reserve(self, positions: int) -> None:
|
|
257
|
+
"""Flush first if adding ``positions`` could exceed exact float32 range."""
|
|
258
|
+
if (self.pending_positions + positions) * self.magnitude >= GEMM_EXACT_FP32_LIMIT:
|
|
259
|
+
self.flush()
|
|
260
|
+
self.pending_positions += positions
|
|
261
|
+
|
|
262
|
+
def flush(self) -> None:
|
|
263
|
+
if self.pending_positions == 0:
|
|
264
|
+
return
|
|
265
|
+
self.host += self.ops.to_int64(self.acc)
|
|
266
|
+
self.acc = self.ops.zeros(self.rows, self.cols)
|
|
267
|
+
self.pending_positions = 0
|
|
268
|
+
|
|
269
|
+
def result(self) -> np.ndarray:
|
|
270
|
+
self.flush()
|
|
271
|
+
return self.host
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _block_pair_counts(
|
|
275
|
+
ops: _GemmOps,
|
|
276
|
+
*,
|
|
277
|
+
block_i,
|
|
278
|
+
block_j,
|
|
279
|
+
inf_i,
|
|
280
|
+
inf_j,
|
|
281
|
+
multi_positions: np.ndarray,
|
|
282
|
+
matrix_length: int,
|
|
283
|
+
position_chunk: int,
|
|
284
|
+
) -> tuple[np.ndarray, np.ndarray]:
|
|
285
|
+
"""Return int64 ``(total_positions, share_allele_pos)`` for two row blocks.
|
|
286
|
+
|
|
287
|
+
All blocks are host uint8 arrays. ``inf_i`` / ``inf_j`` are the
|
|
288
|
+
informative-position columns of the blocks; ``multi_positions`` indexes
|
|
289
|
+
the informative columns that carry at least one multi-allele mask anywhere
|
|
290
|
+
in the cohort.
|
|
291
|
+
"""
|
|
292
|
+
rows_i = int(block_i.shape[0])
|
|
293
|
+
rows_j = int(block_j.shape[0])
|
|
294
|
+
width = max(1, min(int(position_chunk), GEMM_EXACT_FP32_LIMIT // 8))
|
|
295
|
+
# Diagonal block pairs multiply a block with itself: convert each tile once.
|
|
296
|
+
same = block_i is block_j
|
|
297
|
+
|
|
298
|
+
def tiles(host_i, host_j, start, stop):
|
|
299
|
+
tile_i = ops.tile(host_i, start, stop)
|
|
300
|
+
return tile_i, (tile_i if same else ops.tile(host_j, start, stop))
|
|
301
|
+
|
|
302
|
+
def pair(convert, tile_i, tile_j, *args):
|
|
303
|
+
left = convert(tile_i, *args)
|
|
304
|
+
return left, (left if same else convert(tile_j, *args))
|
|
305
|
+
|
|
306
|
+
total = _ExactAccumulator(ops, rows_i, rows_j, magnitude_per_position=1)
|
|
307
|
+
for start in range(0, matrix_length, width):
|
|
308
|
+
stop = min(start + width, matrix_length)
|
|
309
|
+
total.reserve(stop - start)
|
|
310
|
+
ops.addmm_(total.acc, *pair(ops.covered, *tiles(block_i, block_j, start, stop)))
|
|
311
|
+
totals = total.result()
|
|
312
|
+
|
|
313
|
+
informative = int(inf_i.shape[1])
|
|
314
|
+
if informative == 0:
|
|
315
|
+
return totals, totals.copy()
|
|
316
|
+
# mismatch = covered_both_I - shared_I; inclusion-exclusion partial sums
|
|
317
|
+
# are bounded by 4 per position (4 single-bit terms of magnitude 1).
|
|
318
|
+
mismatch = _ExactAccumulator(ops, rows_i, rows_j, magnitude_per_position=5)
|
|
319
|
+
for start in range(0, informative, width):
|
|
320
|
+
stop = min(start + width, informative)
|
|
321
|
+
tile_i, tile_j = tiles(inf_i, inf_j, start, stop)
|
|
322
|
+
mismatch.reserve(stop - start)
|
|
323
|
+
ops.addmm_(mismatch.acc, *pair(ops.covered, tile_i, tile_j))
|
|
324
|
+
for bit in _ALLELE_BITS:
|
|
325
|
+
ops.addmm_(mismatch.acc, *pair(ops.has_bit, tile_i, tile_j, bit), alpha=-1.0)
|
|
326
|
+
if multi_positions.size:
|
|
327
|
+
multi_i = np.ascontiguousarray(inf_i[:, multi_positions])
|
|
328
|
+
multi_j = multi_i if same else np.ascontiguousarray(inf_j[:, multi_positions])
|
|
329
|
+
for start in range(0, int(multi_positions.size), width):
|
|
330
|
+
stop = min(start + width, int(multi_positions.size))
|
|
331
|
+
tile_i, tile_j = tiles(multi_i, multi_j, start, stop)
|
|
332
|
+
# 11 combo terms of magnitude <= 1 each per position.
|
|
333
|
+
mismatch.reserve((stop - start) * 3)
|
|
334
|
+
for sign, bits in _COMBO_TERMS:
|
|
335
|
+
# shared gets ``sign`` -> mismatch gets ``-sign``.
|
|
336
|
+
ops.addmm_(mismatch.acc, *pair(ops.has_all_bits, tile_i, tile_j, bits), alpha=-sign)
|
|
337
|
+
mismatches = mismatch.result()
|
|
338
|
+
return totals, totals - mismatches
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
class _GenomeRows:
|
|
342
|
+
"""Loads (and optionally caches) allele-mask row blocks for one genome."""
|
|
343
|
+
|
|
344
|
+
def __init__(
|
|
345
|
+
self,
|
|
346
|
+
*,
|
|
347
|
+
matrix_db_file: Path,
|
|
348
|
+
spec,
|
|
349
|
+
dtype_name: str,
|
|
350
|
+
matrix_value_semantics: str,
|
|
351
|
+
ani_kind: str,
|
|
352
|
+
sample_count: int,
|
|
353
|
+
cache_all_rows: bool,
|
|
354
|
+
read_memory_limit_bytes: int = GEMM_READ_BUFFER_BYTES_DEFAULT,
|
|
355
|
+
) -> None:
|
|
356
|
+
self.dataset = mp._Hdf5GenomeMatrixNumpyDataset(
|
|
357
|
+
matrix_hdf5_file=matrix_db_file,
|
|
358
|
+
genome_idx=spec.genome_idx,
|
|
359
|
+
matrix_length=spec.matrix_length,
|
|
360
|
+
dtype_name=dtype_name,
|
|
361
|
+
matrix_value_semantics=matrix_value_semantics,
|
|
362
|
+
)
|
|
363
|
+
self.semantics = matrix_value_semantics
|
|
364
|
+
self.ani_kind = ani_kind
|
|
365
|
+
self.sample_count = sample_count
|
|
366
|
+
self.matrix_length = int(spec.matrix_length)
|
|
367
|
+
self.read_memory_limit_bytes = int(read_memory_limit_bytes)
|
|
368
|
+
if self.read_memory_limit_bytes < 1:
|
|
369
|
+
raise ValueError("read_memory_limit_bytes must be >= 1")
|
|
370
|
+
self._cache: Optional[np.ndarray] = None
|
|
371
|
+
if cache_all_rows:
|
|
372
|
+
try:
|
|
373
|
+
self._cache = self._read(0, sample_count)
|
|
374
|
+
except BaseException:
|
|
375
|
+
self.dataset.close()
|
|
376
|
+
raise
|
|
377
|
+
|
|
378
|
+
def _read(self, start: int, stop: int) -> np.ndarray:
|
|
379
|
+
dataset = self.dataset
|
|
380
|
+
dataset._ensure_open()
|
|
381
|
+
if self.semantics == mp.BITMASK_MATRIX_VALUE_SEMANTICS:
|
|
382
|
+
if dataset._matrix_dataset is not None:
|
|
383
|
+
return np.asarray(dataset._matrix_dataset[start:stop, ...], dtype=np.uint8)
|
|
384
|
+
return dataset._load_batch_numpy(np.arange(start, stop, dtype=np.int64))
|
|
385
|
+
|
|
386
|
+
# The planner budgets for packed masks. Reading all count rows before
|
|
387
|
+
# converting them would temporarily require 8--32 times as much memory.
|
|
388
|
+
# Include raw counts, consensus maxima, selection masks and conversion
|
|
389
|
+
# temporaries in a separate bounded workspace, including uncached reads.
|
|
390
|
+
itemsize = np.dtype(dataset.numpy_dtype).itemsize
|
|
391
|
+
bytes_per_position = 5 * itemsize + 16
|
|
392
|
+
if dataset._matrix_dataset is None:
|
|
393
|
+
# Sparse expansion also reads up to four int64 indices and four
|
|
394
|
+
# values per position alongside the dense count buffer.
|
|
395
|
+
bytes_per_position = max(bytes_per_position, 8 * itemsize + 32)
|
|
396
|
+
workspace_bytes = min(self.read_memory_limit_bytes, GEMM_READ_BUFFER_BYTES_DEFAULT)
|
|
397
|
+
positions_per_read = workspace_bytes // bytes_per_position
|
|
398
|
+
if positions_per_read < 1:
|
|
399
|
+
raise MemoryError("GEMM memory budget cannot fit one allele-count position.")
|
|
400
|
+
if dataset._matrix_dataset is None and positions_per_read < self.matrix_length:
|
|
401
|
+
if self.matrix_length * bytes_per_position > self.read_memory_limit_bytes:
|
|
402
|
+
raise MemoryError(
|
|
403
|
+
"GEMM memory budget cannot fit one expanded sparse count row plus "
|
|
404
|
+
"mask-conversion buffers. Increase --memory-limit-gb."
|
|
405
|
+
)
|
|
406
|
+
# Sparse rows expand as a whole. Permit one row above the preferred
|
|
407
|
+
# 128 MiB chunk size when it still fits the reserved workspace.
|
|
408
|
+
positions_per_read = self.matrix_length
|
|
409
|
+
batch_samples = max(1, positions_per_read // max(1, self.matrix_length))
|
|
410
|
+
batch_positions = max(1, min(self.matrix_length, positions_per_read))
|
|
411
|
+
masks = np.empty((stop - start, self.matrix_length), dtype=np.uint8)
|
|
412
|
+
for row_start in range(start, stop, batch_samples):
|
|
413
|
+
row_stop = min(row_start + batch_samples, stop)
|
|
414
|
+
for col_start in range(0, self.matrix_length, batch_positions):
|
|
415
|
+
col_stop = min(col_start + batch_positions, self.matrix_length)
|
|
416
|
+
if dataset._matrix_dataset is not None:
|
|
417
|
+
raw = np.asarray(
|
|
418
|
+
dataset._matrix_dataset[row_start:row_stop, col_start:col_stop, :],
|
|
419
|
+
dtype=dataset.numpy_dtype,
|
|
420
|
+
)
|
|
421
|
+
else:
|
|
422
|
+
raw = dataset._load_batch_numpy(np.arange(row_start, row_stop, dtype=np.int64))
|
|
423
|
+
masks[row_start - start:row_stop - start, col_start:col_stop] = rows_to_allele_bitmask(
|
|
424
|
+
raw, matrix_value_semantics=self.semantics, ani_kind=self.ani_kind,
|
|
425
|
+
)
|
|
426
|
+
del raw
|
|
427
|
+
return masks
|
|
428
|
+
|
|
429
|
+
def block(self, start: int, stop: int) -> np.ndarray:
|
|
430
|
+
if self._cache is not None:
|
|
431
|
+
return self._cache[start:stop]
|
|
432
|
+
return self._read(start, stop)
|
|
433
|
+
|
|
434
|
+
def close(self) -> None:
|
|
435
|
+
self._cache = None
|
|
436
|
+
self.dataset.close()
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def _informative_positions(rows: _GenomeRows, block_samples: int) -> tuple[np.ndarray, np.ndarray]:
|
|
440
|
+
"""Cohort-wide informative positions and, among them, multi-allele positions.
|
|
441
|
+
|
|
442
|
+
A position is informative when at least two samples cover it and the
|
|
443
|
+
covered masks share no common allele bit. Non-informative positions can
|
|
444
|
+
never be mismatches for any pair, so restricting the mismatch GEMMs to the
|
|
445
|
+
informative set is exact.
|
|
446
|
+
"""
|
|
447
|
+
length = rows.matrix_length
|
|
448
|
+
common = np.full(length, _FULL_MASK, dtype=np.uint8)
|
|
449
|
+
covered_count = np.zeros(length, dtype=np.int32)
|
|
450
|
+
any_multi = np.zeros(length, dtype=bool)
|
|
451
|
+
for start in range(0, rows.sample_count, block_samples):
|
|
452
|
+
block = rows.block(start, min(start + block_samples, rows.sample_count))
|
|
453
|
+
# Column chunks keep the boolean/uint8 temporaries to ~256 MB.
|
|
454
|
+
width = max(1, (1 << 28) // max(1, block.shape[0]))
|
|
455
|
+
for col in range(0, length, width):
|
|
456
|
+
tile = block[:, col:col + width]
|
|
457
|
+
covered = tile != 0
|
|
458
|
+
common[col:col + width] &= np.bitwise_and.reduce(np.where(covered, tile, _FULL_MASK), axis=0)
|
|
459
|
+
covered_count[col:col + width] += covered.sum(axis=0, dtype=np.int32)
|
|
460
|
+
any_multi[col:col + width] |= (_POPCOUNT[tile] >= 2).any(axis=0)
|
|
461
|
+
informative = np.flatnonzero((common == 0) & (covered_count >= 2))
|
|
462
|
+
multi = np.flatnonzero(any_multi[informative])
|
|
463
|
+
return informative.astype(np.int64), multi.astype(np.int64)
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def _completed_by_block_pair(
|
|
467
|
+
completed_pairs: set[tuple[int, int]],
|
|
468
|
+
row_of_sample: dict[int, int],
|
|
469
|
+
block_samples: int,
|
|
470
|
+
) -> dict[tuple[int, int], list[tuple[int, int]]]:
|
|
471
|
+
buckets: dict[tuple[int, int], list[tuple[int, int]]] = {}
|
|
472
|
+
for sample_a, sample_b in completed_pairs:
|
|
473
|
+
row_a = row_of_sample.get(int(sample_a))
|
|
474
|
+
row_b = row_of_sample.get(int(sample_b))
|
|
475
|
+
if row_a is None or row_b is None or row_a == row_b:
|
|
476
|
+
continue
|
|
477
|
+
if row_a > row_b:
|
|
478
|
+
row_a, row_b = row_b, row_a
|
|
479
|
+
key = (row_a // block_samples, row_b // block_samples)
|
|
480
|
+
buckets.setdefault(key, []).append((row_a, row_b))
|
|
481
|
+
return buckets
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def _insert_completed_arrays(
|
|
485
|
+
compare_conn: duckdb.DuckDBPyConnection,
|
|
486
|
+
sample_idx_1: np.ndarray,
|
|
487
|
+
sample_idx_2: np.ndarray,
|
|
488
|
+
genome_idx: int,
|
|
489
|
+
) -> None:
|
|
490
|
+
if sample_idx_1.size == 0:
|
|
491
|
+
return
|
|
492
|
+
table = pa.table(
|
|
493
|
+
{
|
|
494
|
+
"sample_idx_1": pa.array(sample_idx_1, type=pa.int64()),
|
|
495
|
+
"sample_idx_2": pa.array(sample_idx_2, type=pa.int64()),
|
|
496
|
+
"genome_idx": pa.array(np.full(sample_idx_1.size, genome_idx, dtype=np.int64), type=pa.int64()),
|
|
497
|
+
}
|
|
498
|
+
)
|
|
499
|
+
compare_conn.register("_matrix_compare_completed_pair_genome_batch", table)
|
|
500
|
+
try:
|
|
501
|
+
compare_conn.execute(
|
|
502
|
+
"""
|
|
503
|
+
INSERT OR REPLACE INTO matrix_compare_completed_pair_genomes
|
|
504
|
+
SELECT DISTINCT sample_idx_1, sample_idx_2, genome_idx
|
|
505
|
+
FROM _matrix_compare_completed_pair_genome_batch
|
|
506
|
+
"""
|
|
507
|
+
)
|
|
508
|
+
finally:
|
|
509
|
+
compare_conn.unregister("_matrix_compare_completed_pair_genome_batch")
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def _result_table(
|
|
513
|
+
*,
|
|
514
|
+
compare_id_1: np.ndarray,
|
|
515
|
+
compare_id_2: np.ndarray,
|
|
516
|
+
names_1: np.ndarray,
|
|
517
|
+
names_2: np.ndarray,
|
|
518
|
+
genome_id: int,
|
|
519
|
+
genome: str,
|
|
520
|
+
totals: np.ndarray,
|
|
521
|
+
shared: np.ndarray,
|
|
522
|
+
) -> pa.Table:
|
|
523
|
+
"""Arrow rows in ``matrix_compare_result_db_schema`` order.
|
|
524
|
+
|
|
525
|
+
Pairs are canonicalized like the pairwise kernel: ``sample_idx_1`` is the
|
|
526
|
+
smaller compare id and ``sample_1`` its name.
|
|
527
|
+
"""
|
|
528
|
+
swap = compare_id_1 > compare_id_2
|
|
529
|
+
first_id = np.where(swap, compare_id_2, compare_id_1)
|
|
530
|
+
second_id = np.where(swap, compare_id_1, compare_id_2)
|
|
531
|
+
first_name = np.where(swap, names_2, names_1)
|
|
532
|
+
second_name = np.where(swap, names_1, names_2)
|
|
533
|
+
rows = int(totals.size)
|
|
534
|
+
return pa.Table.from_arrays(
|
|
535
|
+
[
|
|
536
|
+
pa.array(first_id, type=pa.int64()),
|
|
537
|
+
pa.array(second_id, type=pa.int64()),
|
|
538
|
+
pa.array(first_name.tolist(), type=pa.string()),
|
|
539
|
+
pa.array(second_name.tolist(), type=pa.string()),
|
|
540
|
+
pa.array(np.full(rows, genome_id, dtype=np.int64), type=pa.int64()),
|
|
541
|
+
pa.array([genome] * rows, type=pa.string()),
|
|
542
|
+
pa.array(totals, type=pa.int64()),
|
|
543
|
+
pa.array(shared, type=pa.int64()),
|
|
544
|
+
pa.array(shared / totals * 100.0, type=pa.float64()),
|
|
545
|
+
pa.nulls(rows, type=pa.int64()),
|
|
546
|
+
],
|
|
547
|
+
schema=mp.matrix_compare_result_db_schema(),
|
|
548
|
+
)
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
def matrix_compare_gemm(
|
|
552
|
+
*,
|
|
553
|
+
matrix_db_file: Path,
|
|
554
|
+
output_file: Path,
|
|
555
|
+
compare_conn: duckdb.DuckDBPyConnection,
|
|
556
|
+
compare_ids,
|
|
557
|
+
samples: list[tuple[int, str]],
|
|
558
|
+
genomes: list,
|
|
559
|
+
genome_scaffolds: list,
|
|
560
|
+
completed_pairs_by_genome: dict[int, set[tuple[int, int]]],
|
|
561
|
+
requested_pairs: int,
|
|
562
|
+
total_work: int,
|
|
563
|
+
metadata: dict[str, str],
|
|
564
|
+
matrix_value_semantics: str,
|
|
565
|
+
ani_method: str,
|
|
566
|
+
compute_backend,
|
|
567
|
+
memory_limit_bytes: int,
|
|
568
|
+
memory_limit_gb: float,
|
|
569
|
+
min_cov: int,
|
|
570
|
+
genome_scope: Optional[str],
|
|
571
|
+
emit_writer_logs: bool,
|
|
572
|
+
run_start_time: float,
|
|
573
|
+
progress_callback: Optional[Callable[[dict[str, object]], None]],
|
|
574
|
+
):
|
|
575
|
+
"""Run the GEMM kernel on a prepared compare DB; see module docstring.
|
|
576
|
+
|
|
577
|
+
Called by :func:`zipstrain.matrix_pairs.matrix_compare` after the compare
|
|
578
|
+
DB, id catalog and resume state are prepared. Each sample-block pair is
|
|
579
|
+
written in one transaction (result rows with ``total_positions > 0`` plus
|
|
580
|
+
completion keys for every pair in the block pair), so interrupted runs
|
|
581
|
+
resume exactly like the pairwise kernel.
|
|
582
|
+
"""
|
|
583
|
+
ani_kind, _cos = mp.parse_matrix_ani_method(ani_method)
|
|
584
|
+
if ani_kind not in GEMM_SUPPORTED_ANI_KINDS:
|
|
585
|
+
raise ValueError(
|
|
586
|
+
f"compare_kernel='gemm' supports ani_method popani or conani, not {ani_method!r}."
|
|
587
|
+
)
|
|
588
|
+
dtype_name = mp._matrix_dtype_name_from_metadata(metadata)
|
|
589
|
+
sample_count = len(samples)
|
|
590
|
+
sample_matrix_ids = np.array([int(sample_idx) for sample_idx, _name in samples], dtype=np.int64)
|
|
591
|
+
sample_names = np.array([str(name) for _idx, name in samples], dtype=object)
|
|
592
|
+
compare_sample_ids = np.asarray(compare_ids.samples, dtype=np.int64)[sample_matrix_ids]
|
|
593
|
+
row_of_sample = {int(sample_idx): row for row, sample_idx in enumerate(sample_matrix_ids.tolist())}
|
|
594
|
+
|
|
595
|
+
completed_work = 0
|
|
596
|
+
written_rows = 0
|
|
597
|
+
block_pairs_done = 0
|
|
598
|
+
sample_blocks_total = 0
|
|
599
|
+
|
|
600
|
+
with _GemmOps(compute_backend) as ops:
|
|
601
|
+
device_memory = ops.device_memory_bytes()
|
|
602
|
+
for spec in genomes:
|
|
603
|
+
completed = completed_pairs_by_genome.get(spec.genome_idx, set())
|
|
604
|
+
if len(completed) >= sample_count * (sample_count - 1) // 2:
|
|
605
|
+
continue
|
|
606
|
+
# Conservative plan (informative count unknown) for the cache decision
|
|
607
|
+
# and the informative-position pass, then the final plan.
|
|
608
|
+
plan = _plan_gemm_blocks(
|
|
609
|
+
sample_count=sample_count,
|
|
610
|
+
matrix_length=spec.matrix_length,
|
|
611
|
+
memory_limit_bytes=memory_limit_bytes,
|
|
612
|
+
device_memory_bytes=device_memory,
|
|
613
|
+
)
|
|
614
|
+
compare_genome_id = int(compare_ids.genomes[spec.genome_idx])
|
|
615
|
+
rows = _GenomeRows(
|
|
616
|
+
matrix_db_file=matrix_db_file,
|
|
617
|
+
spec=spec,
|
|
618
|
+
dtype_name=dtype_name,
|
|
619
|
+
matrix_value_semantics=matrix_value_semantics,
|
|
620
|
+
ani_kind=ani_kind,
|
|
621
|
+
sample_count=sample_count,
|
|
622
|
+
cache_all_rows=plan.cache_all_rows,
|
|
623
|
+
# The planner leaves 30% headroom; use at most 20% for raw
|
|
624
|
+
# count reads and conversion, normally in <=128 MiB chunks.
|
|
625
|
+
read_memory_limit_bytes=max(1, memory_limit_bytes // 5),
|
|
626
|
+
)
|
|
627
|
+
try:
|
|
628
|
+
informative, multi = _informative_positions(rows, plan.block_samples)
|
|
629
|
+
plan = _plan_gemm_blocks(
|
|
630
|
+
sample_count=sample_count,
|
|
631
|
+
matrix_length=spec.matrix_length,
|
|
632
|
+
memory_limit_bytes=memory_limit_bytes,
|
|
633
|
+
device_memory_bytes=device_memory,
|
|
634
|
+
informative_count=int(informative.size),
|
|
635
|
+
)
|
|
636
|
+
block = plan.block_samples
|
|
637
|
+
starts = list(range(0, sample_count, block))
|
|
638
|
+
sample_blocks_total += len(starts)
|
|
639
|
+
done_buckets = _completed_by_block_pair(completed, row_of_sample, block)
|
|
640
|
+
for bi, start_i in enumerate(starts):
|
|
641
|
+
stop_i = min(start_i + block, sample_count)
|
|
642
|
+
host_i = rows.block(start_i, stop_i)
|
|
643
|
+
inf_i = np.ascontiguousarray(host_i[:, informative])
|
|
644
|
+
for bj in range(bi, len(starts)):
|
|
645
|
+
start_j = starts[bj]
|
|
646
|
+
stop_j = min(start_j + block, sample_count)
|
|
647
|
+
pending = np.ones((stop_i - start_i, stop_j - start_j), dtype=bool)
|
|
648
|
+
if bi == bj:
|
|
649
|
+
pending = np.triu(pending, k=1)
|
|
650
|
+
for row_a, row_b in done_buckets.get((bi, bj), ()):
|
|
651
|
+
pending[row_a - start_i, row_b - start_j] = False
|
|
652
|
+
if not pending.any():
|
|
653
|
+
continue
|
|
654
|
+
if bi == bj:
|
|
655
|
+
host_j, inf_j = host_i, inf_i
|
|
656
|
+
else:
|
|
657
|
+
host_j = rows.block(start_j, stop_j)
|
|
658
|
+
inf_j = np.ascontiguousarray(host_j[:, informative])
|
|
659
|
+
totals, shared = _block_pair_counts(
|
|
660
|
+
ops,
|
|
661
|
+
block_i=host_i,
|
|
662
|
+
block_j=host_j,
|
|
663
|
+
inf_i=inf_i,
|
|
664
|
+
inf_j=inf_j,
|
|
665
|
+
multi_positions=multi,
|
|
666
|
+
matrix_length=int(spec.matrix_length),
|
|
667
|
+
position_chunk=plan.position_chunk,
|
|
668
|
+
)
|
|
669
|
+
local_i, local_j = np.nonzero(pending)
|
|
670
|
+
global_i = local_i + start_i
|
|
671
|
+
global_j = local_j + start_j
|
|
672
|
+
pair_totals = totals[local_i, local_j]
|
|
673
|
+
pair_shared = shared[local_i, local_j]
|
|
674
|
+
ids_i = compare_sample_ids[global_i]
|
|
675
|
+
ids_j = compare_sample_ids[global_j]
|
|
676
|
+
keep = pair_totals > 0
|
|
677
|
+
table = _result_table(
|
|
678
|
+
compare_id_1=ids_i[keep],
|
|
679
|
+
compare_id_2=ids_j[keep],
|
|
680
|
+
names_1=sample_names[global_i[keep]],
|
|
681
|
+
names_2=sample_names[global_j[keep]],
|
|
682
|
+
genome_id=compare_genome_id,
|
|
683
|
+
genome=spec.genome,
|
|
684
|
+
totals=pair_totals[keep],
|
|
685
|
+
shared=pair_shared[keep],
|
|
686
|
+
)
|
|
687
|
+
compare_conn.commit()
|
|
688
|
+
compare_conn.execute("BEGIN")
|
|
689
|
+
try:
|
|
690
|
+
mp._insert_matrix_compare_result_table(compare_conn, table)
|
|
691
|
+
_insert_completed_arrays(
|
|
692
|
+
compare_conn,
|
|
693
|
+
np.minimum(ids_i, ids_j),
|
|
694
|
+
np.maximum(ids_i, ids_j),
|
|
695
|
+
compare_genome_id,
|
|
696
|
+
)
|
|
697
|
+
compare_conn.execute("COMMIT")
|
|
698
|
+
except Exception:
|
|
699
|
+
compare_conn.execute("ROLLBACK")
|
|
700
|
+
raise
|
|
701
|
+
batch_pairs = int(local_i.size)
|
|
702
|
+
completed_work += batch_pairs
|
|
703
|
+
written_rows += table.num_rows
|
|
704
|
+
block_pairs_done += 1
|
|
705
|
+
anchor_name = str(sample_names[start_i])
|
|
706
|
+
if emit_writer_logs:
|
|
707
|
+
mp._emit_matrix_compare_writer_log(
|
|
708
|
+
start_time=run_start_time,
|
|
709
|
+
completed=completed_work,
|
|
710
|
+
total=total_work,
|
|
711
|
+
batch_pairs=batch_pairs,
|
|
712
|
+
batch_rows=table.num_rows,
|
|
713
|
+
anchor_name=anchor_name,
|
|
714
|
+
genome=spec.genome,
|
|
715
|
+
targets_completed=stop_j,
|
|
716
|
+
targets_total=sample_count,
|
|
717
|
+
target_chunks=block_pairs_done,
|
|
718
|
+
)
|
|
719
|
+
if progress_callback is not None:
|
|
720
|
+
progress_callback(
|
|
721
|
+
{
|
|
722
|
+
"phase": "advance",
|
|
723
|
+
"completed": completed_work,
|
|
724
|
+
"total": total_work,
|
|
725
|
+
"anchor_name": anchor_name,
|
|
726
|
+
"genome": spec.genome,
|
|
727
|
+
"scaffold": "",
|
|
728
|
+
"targets_completed": stop_j,
|
|
729
|
+
"targets_total": sample_count,
|
|
730
|
+
"target_chunks": block_pairs_done,
|
|
731
|
+
}
|
|
732
|
+
)
|
|
733
|
+
finally:
|
|
734
|
+
rows.close()
|
|
735
|
+
|
|
736
|
+
return mp.MatrixCompareSummary(
|
|
737
|
+
output_file=output_file,
|
|
738
|
+
requested_pairs=requested_pairs,
|
|
739
|
+
written_rows=written_rows,
|
|
740
|
+
scaffold_count=len(genome_scaffolds),
|
|
741
|
+
genome_count=len(genomes),
|
|
742
|
+
anchor_groups=sample_blocks_total,
|
|
743
|
+
target_chunks=block_pairs_done,
|
|
744
|
+
min_cov=min_cov,
|
|
745
|
+
genome_scope=genome_scope or metadata.get("genome_scope", "all"),
|
|
746
|
+
backend=compute_backend.kind,
|
|
747
|
+
device=compute_backend.device,
|
|
748
|
+
memory_limit_gb=memory_limit_gb,
|
|
749
|
+
ani_method=ani_method,
|
|
750
|
+
)
|
|
@@ -52,6 +52,7 @@ CURRENT_MATRIX_HDF5_BITMASK_SPARSE_LAYOUT = "per_genome_sample_major_sparse_bitm
|
|
|
52
52
|
MATRIX_HDF5_FILE_VERSION = "1"
|
|
53
53
|
MATRIX_PAIR_BACKENDS = ("numpy", "torch", "torch-cpu", "torch-cuda", "torch-mps")
|
|
54
54
|
MATRIX_IO_EXECUTOR_KINDS = ("thread", "process")
|
|
55
|
+
MATRIX_COMPARE_KERNELS = ("pairwise", "gemm")
|
|
55
56
|
MATRIX_COMPARE_PIPELINE_DEPTH = 2
|
|
56
57
|
MATRIX_COMPARE_WRITE_MAX_ROWS = 250_000
|
|
57
58
|
MATRIX_COMPARE_WRITE_MAX_BYTES = 32 * 1024 * 1024
|
|
@@ -5771,12 +5772,22 @@ def matrix_compare(
|
|
|
5771
5772
|
calculate: Optional[str] = "all",
|
|
5772
5773
|
emit_writer_logs: bool = False,
|
|
5773
5774
|
progress_callback: Optional[CompareProgressCallback] = None,
|
|
5775
|
+
compare_kernel: str = "pairwise",
|
|
5774
5776
|
) -> MatrixCompareSummary:
|
|
5775
5777
|
"""Run resumable all-vs-all matrix comparison on the current matrix store.
|
|
5776
5778
|
|
|
5777
5779
|
Results are written into a DuckDB compare database. Re-running the same
|
|
5778
5780
|
command against an existing compare database resumes from the remaining
|
|
5779
5781
|
uncompleted pairs.
|
|
5782
|
+
|
|
5783
|
+
``compare_kernel`` selects the comparison algorithm. ``"pairwise"`` (the
|
|
5784
|
+
default) compares one anchor against target blocks and supports every
|
|
5785
|
+
metric. ``"gemm"`` computes genome-level popANI/conANI for whole blocks
|
|
5786
|
+
of sample pairs with dense matrix multiplications (see
|
|
5787
|
+
:mod:`zipstrain.matrix_gemm`); it requires ``calculate="ani"`` and gives
|
|
5788
|
+
identical ``total_positions``/``share_allele_pos``/``genome_ani`` values.
|
|
5789
|
+
Anchor/target queue, transfer-batch and executor options are ignored by
|
|
5790
|
+
the GEMM kernel.
|
|
5780
5791
|
"""
|
|
5781
5792
|
if min_cov is not None and min_cov < 1:
|
|
5782
5793
|
raise ValueError("min_cov must be >= 1")
|
|
@@ -5795,6 +5806,15 @@ def matrix_compare(
|
|
|
5795
5806
|
raise ValueError(
|
|
5796
5807
|
f"writer_executor_kind must be one of {', '.join(MATRIX_IO_EXECUTOR_KINDS)}"
|
|
5797
5808
|
)
|
|
5809
|
+
if compare_kernel not in MATRIX_COMPARE_KERNELS:
|
|
5810
|
+
raise ValueError(
|
|
5811
|
+
f"compare_kernel must be one of {', '.join(MATRIX_COMPARE_KERNELS)}"
|
|
5812
|
+
)
|
|
5813
|
+
if compare_kernel == "gemm" and normalized_ani_method not in ("popani", "conani"):
|
|
5814
|
+
raise ValueError(
|
|
5815
|
+
"compare_kernel='gemm' supports ani_method popani or conani; "
|
|
5816
|
+
f"use compare_kernel='pairwise' for {normalized_ani_method}."
|
|
5817
|
+
)
|
|
5798
5818
|
matrix_db_file = matrix_db_file.resolve()
|
|
5799
5819
|
output_file = output_file.resolve()
|
|
5800
5820
|
output_file.parent.mkdir(parents=True, exist_ok=True)
|
|
@@ -5860,6 +5880,11 @@ def matrix_compare(
|
|
|
5860
5880
|
"Gene ANI was requested, but this matrix store does not contain gene annotations. "
|
|
5861
5881
|
"Rebuild it with --gene-range-table."
|
|
5862
5882
|
)
|
|
5883
|
+
if compare_kernel == "gemm" and tuple(calculations) != ("ani",):
|
|
5884
|
+
raise ValueError(
|
|
5885
|
+
"compare_kernel='gemm' computes genome-level ANI only; pass calculate='ani' "
|
|
5886
|
+
f"(requested: {'+'.join(calculations)}). IBS and gene ANI need compare_kernel='pairwise'."
|
|
5887
|
+
)
|
|
5863
5888
|
compare_conn, compare_ids = _prepare_matrix_compare_db(
|
|
5864
5889
|
output_file=output_file,
|
|
5865
5890
|
matrix_metadata=metadata,
|
|
@@ -5893,6 +5918,33 @@ def matrix_compare(
|
|
|
5893
5918
|
ani_method=normalized_ani_method,
|
|
5894
5919
|
)
|
|
5895
5920
|
|
|
5921
|
+
if compare_kernel == "gemm":
|
|
5922
|
+
from zipstrain import matrix_gemm
|
|
5923
|
+
|
|
5924
|
+
return matrix_gemm.matrix_compare_gemm(
|
|
5925
|
+
matrix_db_file=matrix_db_file,
|
|
5926
|
+
output_file=output_file,
|
|
5927
|
+
compare_conn=compare_conn,
|
|
5928
|
+
compare_ids=compare_ids,
|
|
5929
|
+
samples=samples,
|
|
5930
|
+
genomes=genomes,
|
|
5931
|
+
genome_scaffolds=genome_scaffolds,
|
|
5932
|
+
completed_pairs_by_genome=completed_pairs_by_genome,
|
|
5933
|
+
requested_pairs=requested_pairs,
|
|
5934
|
+
total_work=total_work,
|
|
5935
|
+
metadata=metadata,
|
|
5936
|
+
matrix_value_semantics=matrix_value_semantics,
|
|
5937
|
+
ani_method=normalized_ani_method,
|
|
5938
|
+
compute_backend=compute_backend,
|
|
5939
|
+
memory_limit_bytes=memory_limit_bytes,
|
|
5940
|
+
memory_limit_gb=memory_limit_gb,
|
|
5941
|
+
min_cov=min_cov,
|
|
5942
|
+
genome_scope=genome_scope,
|
|
5943
|
+
emit_writer_logs=emit_writer_logs,
|
|
5944
|
+
run_start_time=run_start_time,
|
|
5945
|
+
progress_callback=progress_callback,
|
|
5946
|
+
)
|
|
5947
|
+
|
|
5896
5948
|
if compute_backend.kind == "torch":
|
|
5897
5949
|
compare_conn.close()
|
|
5898
5950
|
compare_conn = None
|
|
@@ -771,18 +771,20 @@ def process_read_location(output_file:str, batch_size:int=10000)->None:
|
|
|
771
771
|
writer.close()
|
|
772
772
|
|
|
773
773
|
|
|
774
|
-
def
|
|
774
|
+
def extract_scaffold_length(bed_table: pl.LazyFrame) -> pl.LazyFrame:
|
|
775
775
|
"""
|
|
776
|
-
Extract
|
|
776
|
+
Extract per-scaffold lengths from a BED table.
|
|
777
|
+
|
|
778
|
+
``make_the_bed`` can split one long scaffold across several BED rows, so the
|
|
779
|
+
per-row spans are summed back together per scaffold.
|
|
777
780
|
|
|
778
781
|
Parameters:
|
|
779
|
-
stb (pl.LazyFrame): Scaffold-to-bin mapping table.
|
|
780
782
|
bed_table (pl.LazyFrame): BED table containing genomic regions.
|
|
781
783
|
|
|
782
784
|
Returns:
|
|
783
|
-
pl.LazyFrame: A LazyFrame
|
|
785
|
+
pl.LazyFrame: A LazyFrame with one row per scaffold and its total length.
|
|
784
786
|
"""
|
|
785
|
-
|
|
787
|
+
return bed_table.select(
|
|
786
788
|
pl.col("scaffold"),
|
|
787
789
|
(pl.col("end") - pl.col("start")).alias("scaffold_length")
|
|
788
790
|
).group_by("scaffold").agg(
|
|
@@ -790,7 +792,20 @@ def extract_genome_length(stb: pl.LazyFrame, bed_table: pl.LazyFrame) -> pl.Lazy
|
|
|
790
792
|
).select(
|
|
791
793
|
pl.col("scaffold").alias("scaffold"),
|
|
792
794
|
pl.col("scaffold_length")
|
|
793
|
-
)
|
|
795
|
+
)
|
|
796
|
+
|
|
797
|
+
def extract_genome_length(stb: pl.LazyFrame, bed_table: pl.LazyFrame) -> pl.LazyFrame:
|
|
798
|
+
"""
|
|
799
|
+
Extract the genome length information from the scaffold-to-genome mapping table.
|
|
800
|
+
|
|
801
|
+
Parameters:
|
|
802
|
+
stb (pl.LazyFrame): Scaffold-to-bin mapping table.
|
|
803
|
+
bed_table (pl.LazyFrame): BED table containing genomic regions.
|
|
804
|
+
|
|
805
|
+
Returns:
|
|
806
|
+
pl.LazyFrame: A LazyFrame containing the genome lengths.
|
|
807
|
+
"""
|
|
808
|
+
lf= extract_scaffold_length(bed_table).join(
|
|
794
809
|
stb.select(
|
|
795
810
|
pl.col("scaffold").alias("scaffold"),
|
|
796
811
|
pl.col("genome").alias("genome")
|
|
@@ -1469,6 +1484,8 @@ def get_genome_gaps(
|
|
|
1469
1484
|
read_loc_table: pl.LazyFrame,
|
|
1470
1485
|
stb: pl.LazyFrame,
|
|
1471
1486
|
genome_length: pl.LazyFrame,
|
|
1487
|
+
scaffold_length: pl.LazyFrame,
|
|
1488
|
+
avg_read_length: int = 0,
|
|
1472
1489
|
)-> pl.LazyFrame:
|
|
1473
1490
|
# The read-location table can be much larger than the profile itself. Stream it to a
|
|
1474
1491
|
# temporary parquet and let DuckDB handle the global sort/window with disk spill.
|
|
@@ -1486,6 +1503,10 @@ def get_genome_gaps(
|
|
|
1486
1503
|
"genome_length_src",
|
|
1487
1504
|
genome_length.select("genome", "genome_length").collect().to_arrow(),
|
|
1488
1505
|
)
|
|
1506
|
+
conn.register(
|
|
1507
|
+
"scaffold_length_src",
|
|
1508
|
+
scaffold_length.select("scaffold", "scaffold_length").collect().to_arrow(),
|
|
1509
|
+
)
|
|
1489
1510
|
read_loc_sql = _duckdb_quote_sql_string(str(read_loc_path))
|
|
1490
1511
|
table = conn.execute(
|
|
1491
1512
|
f"""
|
|
@@ -1507,19 +1528,47 @@ def get_genome_gaps(
|
|
|
1507
1528
|
CAST(genome_length AS DOUBLE) AS genome_length
|
|
1508
1529
|
FROM genome_length_src
|
|
1509
1530
|
),
|
|
1531
|
+
scaffold_length AS (
|
|
1532
|
+
SELECT
|
|
1533
|
+
CAST(scaffold AS VARCHAR) AS scaffold,
|
|
1534
|
+
CAST(scaffold_length AS BIGINT) AS scaffold_length
|
|
1535
|
+
FROM scaffold_length_src
|
|
1536
|
+
),
|
|
1537
|
+
positions AS (
|
|
1538
|
+
SELECT scaffold, loc FROM read_loc
|
|
1539
|
+
UNION ALL
|
|
1540
|
+
-- End-of-scaffold sentinel, one per covered scaffold. Metapresence
|
|
1541
|
+
-- appends (sequence_length - avg_read_length) to its position list so a
|
|
1542
|
+
-- trailing uncovered region is charged as a gap; without it a genome
|
|
1543
|
+
-- whose reads stop early looks perfectly uniform.
|
|
1544
|
+
SELECT
|
|
1545
|
+
rs.scaffold,
|
|
1546
|
+
GREATEST(sl.scaffold_length - {int(avg_read_length)}, 0)::BIGINT AS loc
|
|
1547
|
+
FROM (SELECT DISTINCT scaffold FROM read_loc) rs
|
|
1548
|
+
INNER JOIN scaffold_length sl ON rs.scaffold = sl.scaffold
|
|
1549
|
+
),
|
|
1510
1550
|
mapped AS (
|
|
1511
1551
|
SELECT
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1552
|
+
p.scaffold,
|
|
1553
|
+
p.loc,
|
|
1554
|
+
p.loc - LAG(p.loc) OVER (PARTITION BY p.scaffold ORDER BY p.loc) AS gap_length,
|
|
1515
1555
|
stb.genome
|
|
1556
|
+
FROM positions p
|
|
1557
|
+
LEFT JOIN stb ON p.scaffold = stb.scaffold
|
|
1558
|
+
),
|
|
1559
|
+
read_count AS (
|
|
1560
|
+
-- Sentinels must not inflate reads_mapped, so count real reads only.
|
|
1561
|
+
SELECT
|
|
1562
|
+
stb.genome,
|
|
1563
|
+
COUNT(*)::BIGINT AS rn
|
|
1516
1564
|
FROM read_loc rl
|
|
1517
1565
|
LEFT JOIN stb ON rl.scaffold = stb.scaffold
|
|
1566
|
+
GROUP BY stb.genome
|
|
1518
1567
|
),
|
|
1519
1568
|
delta AS (
|
|
1520
1569
|
SELECT
|
|
1521
1570
|
m.genome,
|
|
1522
|
-
COUNT(
|
|
1571
|
+
COUNT(m.gap_length)::BIGINT AS gap_count,
|
|
1523
1572
|
AVG(m.gap_length)::DOUBLE AS gap_mean,
|
|
1524
1573
|
STDDEV_SAMP(m.gap_length)::DOUBLE AS gap_std,
|
|
1525
1574
|
ROUND(gl.genome_length / COUNT(*))::DOUBLE AS delta
|
|
@@ -1532,23 +1581,24 @@ def get_genome_gaps(
|
|
|
1532
1581
|
m.genome,
|
|
1533
1582
|
m.gap_length,
|
|
1534
1583
|
d.delta,
|
|
1535
|
-
d.
|
|
1584
|
+
d.gap_count,
|
|
1536
1585
|
d.gap_mean,
|
|
1537
1586
|
d.gap_std,
|
|
1538
|
-
(COUNT(*)::DOUBLE / NULLIF(d.
|
|
1587
|
+
(COUNT(*)::DOUBLE / NULLIF(d.gap_count, 0)) * (m.gap_length - d.delta) AS pd
|
|
1539
1588
|
FROM mapped m
|
|
1540
1589
|
INNER JOIN delta d ON m.genome = d.genome
|
|
1541
1590
|
WHERE m.gap_length > d.delta
|
|
1542
|
-
GROUP BY m.genome, m.gap_length, d.delta, d.
|
|
1591
|
+
GROUP BY m.genome, m.gap_length, d.delta, d.gap_count, d.gap_mean, d.gap_std
|
|
1543
1592
|
)
|
|
1544
1593
|
SELECT
|
|
1545
|
-
CAST(genome AS VARCHAR) AS genome,
|
|
1546
|
-
(MIN(delta) - SUM(pd)) / MIN(delta) AS fug,
|
|
1547
|
-
MIN(rn)::BIGINT AS rn,
|
|
1548
|
-
MIN(gap_mean)::DOUBLE AS gap_mean,
|
|
1549
|
-
MIN(gap_std)::DOUBLE AS gap_std
|
|
1550
|
-
FROM gap_excess
|
|
1551
|
-
|
|
1594
|
+
CAST(ge.genome AS VARCHAR) AS genome,
|
|
1595
|
+
(MIN(ge.delta) - SUM(ge.pd)) / MIN(ge.delta) AS fug,
|
|
1596
|
+
MIN(rc.rn)::BIGINT AS rn,
|
|
1597
|
+
MIN(ge.gap_mean)::DOUBLE AS gap_mean,
|
|
1598
|
+
MIN(ge.gap_std)::DOUBLE AS gap_std
|
|
1599
|
+
FROM gap_excess ge
|
|
1600
|
+
LEFT JOIN read_count rc ON ge.genome = rc.genome
|
|
1601
|
+
GROUP BY ge.genome
|
|
1552
1602
|
"""
|
|
1553
1603
|
).fetch_arrow_table()
|
|
1554
1604
|
finally:
|
|
@@ -1569,7 +1619,8 @@ def get_genome_stats(
|
|
|
1569
1619
|
)->pl.LazyFrame:
|
|
1570
1620
|
include_reference_ani = REF_BASE_BITMASK_COLUMN in set(profile.collect_schema().names())
|
|
1571
1621
|
genome_lengths = extract_genome_length(stb, bed)
|
|
1572
|
-
|
|
1622
|
+
scaffold_lengths = extract_scaffold_length(bed)
|
|
1623
|
+
genome_gap_stats = get_genome_gaps(read_loc_table, stb, genome_lengths, scaffold_lengths)
|
|
1573
1624
|
profile_columns = [
|
|
1574
1625
|
pl.col("genome").cast(pl.Utf8).alias("genome"),
|
|
1575
1626
|
pl.col("A"),
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|