portc 0.6.0__py3-none-any.whl

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.
portc/__init__.py ADDED
@@ -0,0 +1,29 @@
1
+ """Copyright (c) 2025 Algorithmiq Development Team. All rights reserved.
2
+
3
+ portc: Parallel Out-of-Core Randomized Tensor-Network Compression.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import logging
9
+
10
+ from ._plan import Resources
11
+ from ._tensor_train import SiteLike
12
+ from ._version import version as __version__
13
+ from ._version import version_tuple as __version_tuple__
14
+ from .apply import apply
15
+ from .compress import compress
16
+ from .stack import src
17
+
18
+ # Stay silent unless the application configures logging.
19
+ logging.getLogger(__name__).addHandler(logging.NullHandler())
20
+
21
+ __all__ = [
22
+ "Resources",
23
+ "SiteLike",
24
+ "__version__",
25
+ "__version_tuple__",
26
+ "apply",
27
+ "compress",
28
+ "src",
29
+ ]
portc/_group.py ADDED
@@ -0,0 +1,295 @@
1
+ """The devices of one sweep, one thread per device, and the collectives between them.
2
+
3
+ A sweep over several devices of a node runs in one process: `DeviceGroup.run`
4
+ starts one thread per device, each running the same per-device function on its
5
+ *rank* (its position in the group). Collectives are called by every rank in the
6
+ same order. They exchange device arrays through peer copies, ordered with the
7
+ kernels by events. A rank that fails aborts the group: every other rank waiting in
8
+ a collective, or for an input core, raises `GroupAbortedError`, and `run` re-raises the
9
+ original error.
10
+
11
+ On the host backend the devices are simulated: the threads share the host, and the
12
+ collectives are plain copies. The result is that of a single device; this mode
13
+ exists to test the distributed logic without GPUs.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import threading
19
+ from concurrent.futures import ThreadPoolExecutor
20
+ from typing import TYPE_CHECKING, Any, NamedTuple, TypeVar
21
+
22
+ from ._plan import device_ids
23
+ from .utils import (
24
+ copy_into,
25
+ current_device,
26
+ current_stream,
27
+ device_count,
28
+ enable_peer_access,
29
+ get_xp,
30
+ use_device,
31
+ use_stream,
32
+ )
33
+
34
+ if TYPE_CHECKING:
35
+ from collections.abc import Callable, Sequence
36
+
37
+ from .utils import Array
38
+
39
+ T = TypeVar("T")
40
+
41
+
42
+ class GroupAbortedError(RuntimeError):
43
+ """Raised on the ranks of a group after another rank failed."""
44
+
45
+
46
+ def blocks(n: int, size: int) -> list[tuple[int, int]]:
47
+ """Split ``range(n)`` into ``size`` contiguous ``(lo, hi)`` blocks, in rank order.
48
+
49
+ Block sizes differ by at most one, the larger blocks first. Blocks may be empty
50
+ when ``n < size``.
51
+ """
52
+ base, extra = divmod(n, size)
53
+ out, lo = [], 0
54
+ for rank in range(size):
55
+ hi = lo + base + (rank < extra)
56
+ out.append((lo, hi))
57
+ lo = hi
58
+ return out
59
+
60
+
61
+ class _Part(NamedTuple):
62
+ """What a rank publishes in a collective: an array and the event it is ready at."""
63
+
64
+ array: Any
65
+ ready: Any
66
+
67
+
68
+ class DeviceGroup:
69
+ """The devices of one sweep and the collectives between them.
70
+
71
+ Args:
72
+ device: ``"cpu"`` or ``"gpu"``, as in `portc.stack.src`.
73
+ devices: A count ``n`` for the first ``n`` visible devices, or the device
74
+ ids in rank order; rank 0 is the root. Defaults to the current device.
75
+
76
+ Raises:
77
+ ValueError: If a device does not exist.
78
+ """
79
+
80
+ def __init__(self, device: str, devices: int | Sequence[int] | None = None) -> None:
81
+ self.xp = get_xp(device)
82
+ ids = device_ids(devices) or (current_device(self.xp),)
83
+ count = device_count(self.xp)
84
+ if count is not None and max(ids) >= count:
85
+ msg = f"Requested devices {list(ids)}, but only {count} are visible."
86
+ raise ValueError(msg)
87
+ self.devices = ids
88
+ self.size = len(self.devices)
89
+ # Uploading 1/size of each core and gathering the rest pays off only when
90
+ # the devices copy directly between them.
91
+ self.split_uploads = self.size > 1 and enable_peer_access(self.xp, ids)
92
+ self._barrier = threading.Barrier(self.size)
93
+ # Two sets of slots, alternating between consecutive collectives: a rank may
94
+ # publish into the next collective while slower ranks still read this one.
95
+ self._parts: list[list[Any]] = [[None] * self.size, [None] * self.size]
96
+ self._done: list[list[Any]] = [[None] * self.size, [None] * self.size]
97
+ self._calls = [0] * self.size
98
+ self._on_abort: list[Callable[[], None]] = []
99
+ self.aborted = threading.Event()
100
+
101
+ def on_abort(self, callback: Callable[[], None]) -> None:
102
+ """Call ``callback`` when a rank fails, to release threads outside the ranks."""
103
+ self._on_abort.append(callback)
104
+
105
+ def abort(self) -> None:
106
+ """Wake every rank waiting in a collective or on a registered resource."""
107
+ if self.aborted.is_set():
108
+ return
109
+ self.aborted.set()
110
+ self._barrier.abort()
111
+ for callback in self._on_abort:
112
+ callback()
113
+
114
+ def run(self, fn: Callable[[int], T]) -> list[T]:
115
+ """Run ``fn(rank)`` on every device, each in its own thread.
116
+
117
+ A group of one runs ``fn(0)`` in the calling thread.
118
+
119
+ Args:
120
+ fn: The per-device function.
121
+
122
+ Returns:
123
+ The results, in rank order.
124
+
125
+ Raises:
126
+ Exception: The first error raised by a rank, after every rank stopped.
127
+ """
128
+ if self.size == 1:
129
+ with use_device(self.xp, self.devices[0]), use_stream(self.xp):
130
+ return [fn(0)]
131
+ first: list[BaseException] = []
132
+ lock = threading.Lock()
133
+
134
+ def worker(rank: int) -> T:
135
+ try:
136
+ with use_device(self.xp, self.devices[rank]), use_stream(self.xp):
137
+ return fn(rank)
138
+ except BaseException as err:
139
+ secondary = isinstance(
140
+ err, GroupAbortedError | threading.BrokenBarrierError
141
+ )
142
+ with lock:
143
+ if not first and not secondary:
144
+ first.append(err)
145
+ self.abort()
146
+ raise
147
+
148
+ with ThreadPoolExecutor(self.size, thread_name_prefix="src-device") as pool:
149
+ futures = [pool.submit(worker, rank) for rank in range(self.size)]
150
+ results, errors = [], []
151
+ for future in futures:
152
+ try:
153
+ results.append(future.result())
154
+ except BaseException as err: # noqa: BLE001 (re-raised below)
155
+ errors.append(err)
156
+ if first:
157
+ raise first[0]
158
+ if errors:
159
+ raise errors[0]
160
+ return results
161
+
162
+ # --- collectives ---
163
+
164
+ def _wait(self) -> None:
165
+ try:
166
+ self._barrier.wait()
167
+ except threading.BrokenBarrierError:
168
+ raise GroupAbortedError from None
169
+
170
+ def _publish(self, rank: int, array: Any) -> tuple[int, list[_Part]]: # noqa: ANN401
171
+ """Publish ``array`` and return the parts of every rank."""
172
+ parity = self._calls[rank] % 2
173
+ self._calls[rank] += 1
174
+ self._parts[parity][rank] = _Part(array, current_stream(self.xp).record())
175
+ self._wait()
176
+ return parity, list(self._parts[parity])
177
+
178
+ def _settle(self, rank: int, parity: int) -> None:
179
+ """Wait, on this rank's stream, until every rank has read what it published.
180
+
181
+ Until then the arrays published by this rank must not be overwritten; the
182
+ memory pool reuses freed blocks in stream order, so making the stream wait
183
+ is enough.
184
+ """
185
+ stream = current_stream(self.xp)
186
+ self._done[parity][rank] = stream.record()
187
+ self._wait()
188
+ for event in self._done[parity]:
189
+ stream.wait_event(event)
190
+ self._parts[parity][rank] = None
191
+
192
+ def _pull(self, part: _Part, dst: Array) -> None:
193
+ stream = current_stream(self.xp)
194
+ stream.wait_event(part.ready)
195
+ copy_into(dst, part.array, stream)
196
+
197
+ def allgather(self, rank: int, local: Array) -> Array:
198
+ """Concatenate the blocks of every rank along the first axis, on every rank.
199
+
200
+ Args:
201
+ rank: The calling rank.
202
+ local: This rank's block, C-contiguous on its device; the blocks may
203
+ differ along the first axis only.
204
+
205
+ Returns:
206
+ The concatenation in rank order, on this rank's device.
207
+ """
208
+ if self.size == 1:
209
+ return local
210
+ parity, parts = self._publish(rank, local)
211
+ full = self._empty_like_parts(parts)
212
+ lo = 0
213
+ for part in parts:
214
+ hi = lo + part.array.shape[0]
215
+ self._pull(part, full[lo:hi])
216
+ lo = hi
217
+ self._settle(rank, parity)
218
+ return full
219
+
220
+ def gather(self, rank: int, local: Array, root: int = 0) -> Array | None:
221
+ """Concatenate the blocks of every rank along the first axis, on ``root``.
222
+
223
+ Args:
224
+ rank: The calling rank.
225
+ local: This rank's block, C-contiguous on its device.
226
+ root: The rank that receives the result.
227
+
228
+ Returns:
229
+ The concatenation on ``root``, ``None`` on the other ranks.
230
+ """
231
+ if self.size == 1:
232
+ return local
233
+ parity, parts = self._publish(rank, local)
234
+ full = None
235
+ if rank == root:
236
+ full = self._empty_like_parts(parts)
237
+ lo = 0
238
+ for part in parts:
239
+ hi = lo + part.array.shape[0]
240
+ self._pull(part, full[lo:hi])
241
+ lo = hi
242
+ self._settle(rank, parity)
243
+ return full
244
+
245
+ def scatter(self, rank: int, full: Array | None, root: int = 0) -> Array:
246
+ """Give every rank its block (see `blocks`) of the first axis of ``full``.
247
+
248
+ Args:
249
+ rank: The calling rank.
250
+ full: The array to split, C-contiguous on ``root``; ignored elsewhere.
251
+ root: The rank that holds ``full``.
252
+
253
+ Returns:
254
+ This rank's block, on its device.
255
+ """
256
+ if self.size == 1:
257
+ assert full is not None # noqa: S101 (the root holds it)
258
+ return full
259
+ parity, parts = self._publish(rank, full if rank == root else None)
260
+ source = parts[root].array
261
+ lo, hi = blocks(source.shape[0], self.size)[rank]
262
+ local = self.xp.empty((hi - lo, *source.shape[1:]), dtype=source.dtype)
263
+ self._pull(_Part(source[lo:hi], parts[root].ready), local)
264
+ self._settle(rank, parity)
265
+ return local
266
+
267
+ def alltoall(self, rank: int, sends: Sequence[Array]) -> Array:
268
+ """Send ``sends[s]`` to rank ``s``; concatenate what every rank sent here.
269
+
270
+ Args:
271
+ rank: The calling rank.
272
+ sends: One C-contiguous block per rank, on this rank's device; the
273
+ blocks sent to one rank may differ along the first axis only.
274
+
275
+ Returns:
276
+ The blocks sent to this rank, concatenated along the first axis in rank
277
+ order, on this rank's device.
278
+ """
279
+ if self.size == 1:
280
+ return sends[0]
281
+ parity, parts = self._publish(rank, list(sends))
282
+ mine = [_Part(part.array[rank], part.ready) for part in parts]
283
+ full = self._empty_like_parts(mine)
284
+ lo = 0
285
+ for part in mine:
286
+ hi = lo + part.array.shape[0]
287
+ self._pull(part, full[lo:hi])
288
+ lo = hi
289
+ self._settle(rank, parity)
290
+ return full
291
+
292
+ def _empty_like_parts(self, parts: Sequence[_Part]) -> Array:
293
+ first = parts[0].array
294
+ rows = sum(part.array.shape[0] for part in parts)
295
+ return self.xp.empty((rows, *first.shape[1:]), dtype=first.dtype)
portc/_kernels.py ADDED
@@ -0,0 +1,199 @@
1
+ """Site kernels of the SRC sweep and the memory they need.
2
+
3
+ Every kernel is one of the contractions of the sweep restricted to a batch: a slice
4
+ of the sketch index for the environments and the sketch, a slice of the rows of
5
+ the new projected environment otherwise. Batching never changes the result beyond
6
+ rounding, because the sketch index and those rows are free indices of their
7
+ contractions.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from functools import cache
13
+ from itertools import count
14
+ from math import prod
15
+ from typing import TYPE_CHECKING, NamedTuple, cast
16
+
17
+ import opt_einsum as oe
18
+ from opt_einsum import contract_expression, get_symbol
19
+
20
+ if TYPE_CHECKING:
21
+ from collections.abc import Sequence
22
+
23
+ from opt_einsum.contract import ContractExpression
24
+ from opt_einsum.paths import PathOptimizer
25
+ from opt_einsum.typing import OptimizeKind
26
+
27
+ from .utils import Array
28
+
29
+ Shape = tuple[int, ...]
30
+
31
+ # The default path optimizer of the kernels. ``dp`` finds the cheapest path: on an
32
+ # A100 it halved the projection time against ``auto``, which at large bonds also
33
+ # plans an intermediate too big for any batch above 16 rows.
34
+ DEFAULT_OPTIMIZE = "dp"
35
+
36
+
37
+ class Equations(NamedTuple):
38
+ """The einsum equations of one sweep, for a fixed stack depth."""
39
+
40
+ ltr: str
41
+ rtl_m: str
42
+ rtl_s: str
43
+ first: str
44
+
45
+
46
+ @cache
47
+ def equations(depth: int) -> Equations:
48
+ """Generate the sweep equations for a stack of ``depth`` layers.
49
+
50
+ Layer ``i`` at a site carries ``(a_i, b_i, x_i, x_{i+1})``: left and right
51
+ bonds, then its upper and lower physical legs, so that consecutive layers share
52
+ ``x``. The output legs are ``x_0`` (up) and ``x_depth`` (down).
53
+ """
54
+ symbols = map(get_symbol, count())
55
+ sketch, eta_right, eta_left = next(symbols), next(symbols), next(symbols)
56
+ left = "".join(next(symbols) for _ in range(depth))
57
+ right = "".join(next(symbols) for _ in range(depth))
58
+ phys = [next(symbols) for _ in range(depth + 1)]
59
+ up, down = phys[0], phys[-1]
60
+ layers = ",".join(
61
+ f"{left[i]}{right[i]}{phys[i]}{phys[i + 1]}" for i in range(depth)
62
+ )
63
+ return Equations(
64
+ ltr=f"{sketch}{left},{sketch}{up}{down},{layers}->{sketch}{right}",
65
+ rtl_m=f"{sketch}{left},{layers},{eta_right}{right}->{eta_right}{up}{down}{sketch}",
66
+ rtl_s=f"{eta_left}{eta_right}{up}{down},{layers},{eta_right}{right}->{eta_left}{left}",
67
+ first=f"{layers},{eta_right}{right}->{left}{eta_right}{up}{down}",
68
+ )
69
+
70
+
71
+ class _Contractions:
72
+ """Compiled contractions for one sweep, keyed on equation and operand shapes.
73
+
74
+ Uniform bulk sites share one entry, so the path is planned once rather than at
75
+ every site. Kept per call: jagged bonds and short last batches add entries that
76
+ are not worth keeping.
77
+
78
+ Args:
79
+ optimize: The opt_einsum path optimizer.
80
+ """
81
+
82
+ def __init__(self, optimize: str | PathOptimizer) -> None:
83
+ self._optimize = cast("OptimizeKind", optimize)
84
+ self._compiled: dict[tuple[str, tuple[Shape, ...]], ContractExpression] = {}
85
+
86
+ def __call__(self, eq: str, *operands: Array) -> Array:
87
+ shapes = tuple(op.shape for op in operands)
88
+ expr = self._compiled.get((eq, shapes))
89
+ if expr is None:
90
+ expr = self._compiled[eq, shapes] = contract_expression(
91
+ eq, *shapes, optimize=self._optimize
92
+ )
93
+ return expr(*operands)
94
+
95
+
96
+ class SiteKernels:
97
+ """The four contractions of the sweep, each applied to one batch.
98
+
99
+ Args:
100
+ depth: The number of layers of the stack.
101
+ optimize: The opt_einsum path optimizer, a name or a ``PathOptimizer``.
102
+ """
103
+
104
+ def __init__(
105
+ self, depth: int, optimize: str | PathOptimizer = DEFAULT_OPTIMIZE
106
+ ) -> None:
107
+ self.eqs = equations(depth)
108
+ self._contract = _Contractions(optimize)
109
+
110
+ def env(self, env: Array, omega: Array, cores: Sequence[Array]) -> Array:
111
+ """Advance a batch of sketch columns of the environment by one site.
112
+
113
+ Args:
114
+ env: Columns ``lo:hi`` of ``C_j``, shape ``(b, *left_bonds)``.
115
+ omega: The same columns of the site's Gaussian tensor, ``(b, up, down)``.
116
+ cores: The padded cores of the site.
117
+
118
+ Returns:
119
+ Columns ``lo:hi`` of ``C_{j+1}``, shape ``(b, *right_bonds)``.
120
+ """
121
+ return self._contract(self.eqs.ltr, env, omega, *cores)
122
+
123
+ def sketch(self, env: Array, cores: Sequence[Array], proj: Array) -> Array:
124
+ """Sketch a batch of columns of the site's running core.
125
+
126
+ Args:
127
+ env: Columns ``lo:hi`` of ``C_j``, shape ``(b, *left_bonds)``.
128
+ cores: The padded cores of the site.
129
+ proj: The projected environment ``S``, ``(eta, *right_bonds)``.
130
+
131
+ Returns:
132
+ Columns ``lo:hi`` of the sketch, shape ``(eta, up, down, b)``.
133
+ """
134
+ return self._contract(self.eqs.rtl_m, env, *cores, proj)
135
+
136
+ def project(self, eta: Array, cores: Sequence[Array], proj: Array) -> Array:
137
+ """Project a batch of rows of the new projected environment.
138
+
139
+ Args:
140
+ eta: Rows ``lo:hi`` of the conjugated output core, ``(b, eta, up, down)``.
141
+ cores: The padded cores of the site.
142
+ proj: The projected environment ``S``, ``(eta, *right_bonds)``.
143
+
144
+ Returns:
145
+ Rows ``lo:hi`` of the new ``S``, shape ``(b, *left_bonds)``.
146
+ """
147
+ return self._contract(self.eqs.rtl_s, eta, *cores, proj)
148
+
149
+ def first(self, cores: Sequence[Array], proj: Array) -> Array:
150
+ """Contract the first site with a batch of rows of ``S``.
151
+
152
+ Args:
153
+ cores: The padded cores of the first site.
154
+ proj: Rows ``lo:hi`` of ``S``, ``(b, *right_bonds)``.
155
+
156
+ Returns:
157
+ The output core for those rows, ``(*left_bonds, b, up, down)``.
158
+ """
159
+ return self._contract(self.eqs.first, *cores, proj)
160
+
161
+
162
+ @cache
163
+ def peak_elements(
164
+ eq: str,
165
+ shapes: tuple[Shape, ...],
166
+ optimize: str | PathOptimizer = DEFAULT_OPTIMIZE,
167
+ ) -> int:
168
+ """Estimate the peak elements a contraction allocates beyond its inputs.
169
+
170
+ Walks the path `opt_einsum` picks for these shapes with ``optimize``, the one
171
+ `SiteKernels` runs with it.
172
+ Each pairwise step holds the intermediates still alive, its output and a
173
+ possible contiguous copy of both operands (``tensordot`` transposes them), so
174
+ the estimate errs on the high side. The final output is included, the inputs
175
+ are not.
176
+
177
+ Args:
178
+ eq: The einsum equation.
179
+ shapes: The operand shapes.
180
+ optimize: The opt_einsum path optimizer.
181
+
182
+ Returns:
183
+ The peak number of elements.
184
+ """
185
+ _, info = oe.contract_path(
186
+ eq, *shapes, shapes=True, optimize=cast("OptimizeKind", optimize)
187
+ )
188
+ sizes = info.size_dict
189
+ # (elements, is_intermediate) for every operand still to be contracted.
190
+ operands = [(prod(shape), False) for shape in shapes]
191
+ peak = 0
192
+ for step in info.contraction_list:
193
+ positions, einsum_str = step[0], step[2]
194
+ live = sum(n for n, is_tmp in operands if is_tmp)
195
+ popped = [operands.pop(i) for i in positions]
196
+ out = prod(sizes[c] for c in einsum_str.split("->")[1])
197
+ peak = max(peak, live + out + sum(n for n, _ in popped))
198
+ operands.append((out, True))
199
+ return peak