Repository navigation
Expand file tree
/
Copy pathtest_gpu_backend.py
More file actions
477 lines (386 loc) · 15.8 KB
/
Copy pathtest_gpu_backend.py
File metadata and controls
477 lines (386 loc) · 15.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
"""GPU (cupy) backend tests.
Skipped automatically when cupy or a CUDA/ROCm device is unavailable.
"""
from __future__ import annotations
from typing import Any
import numpy as np
import pytest
import quimb.tensor as qtn
import src_method._sweep as sweep_module
from src_method import Resources, apply, compress, src
from src_method._kernels import SiteKernels
from src_method._plan import (
GPU_MARGIN_FRACTION,
GPU_MARGIN_MIN,
Budgets,
Plan,
make_plan,
)
from src_method._sites import padded_shapes, site_bytes
cupy = pytest.importorskip("cupy")
def as_mps(arrays: list[np.ndarray]) -> qtn.MatrixProductState:
"""Wrap a list of site arrays returned by src_method into a quimb MPS."""
return qtn.MatrixProductState(arrays)
def as_mpo(arrays: list[np.ndarray]) -> qtn.MatrixProductOperator:
"""Wrap a list of site arrays returned by src_method into a quimb MPO."""
return qtn.MatrixProductOperator(arrays)
@pytest.fixture(autouse=True)
def _require_device() -> None:
"""Skip the whole module when no GPU runtime is reachable."""
try:
cupy.cuda.runtime.getDeviceCount()
except (cupy.cuda.runtime.CUDARuntimeError, RuntimeError) as err:
pytest.skip(f"No usable GPU device: {err}")
@pytest.mark.parametrize("device", ["cpu", "gpu"])
def test_apply_mpo_mps_gpu_matches_cpu(device: str) -> None:
"""``apply`` MPO @ MPS should produce equivalent compressed states on both devices."""
n_sites, phys_dim, chi_out = 5, 2, 8
H = qtn.MPO_rand(n_sites, bond_dim=4, phys_dim=phys_dim, dtype=np.complex128)
psi = qtn.MPS_rand_state(
n_sites, bond_dim=chi_out, phys_dim=phys_dim, dtype=np.complex128
)
out = as_mps(
apply(
H.arrays,
psi.arrays,
chi_out=chi_out,
dtype=np.complex128,
seed=0,
device=device,
)
)
ref = H.apply(psi, compress=False)
np.testing.assert_allclose(ref.distance(out), 0.0, atol=1e-6)
@pytest.mark.parametrize("device", ["cpu", "gpu"])
def test_apply_mpo_mpo_gpu_matches_cpu(device: str) -> None:
"""``apply`` MPO @ MPO should produce equivalent compressed MPOs on both devices."""
n_sites, phys_dim, chi_out = 5, 2, 8
H1 = qtn.MPO_rand(n_sites, bond_dim=chi_out, phys_dim=phys_dim, dtype=np.complex128)
H2 = qtn.MPO_identity(
n_sites, phys_dim=phys_dim, dtype=np.complex128
) + 1e-8 * qtn.MPO_rand(
n_sites, bond_dim=chi_out, phys_dim=phys_dim, dtype=np.complex128
)
out = as_mpo(
apply(
H1.arrays,
H2.arrays,
chi_out=chi_out,
dtype=np.complex128,
seed=0,
device=device,
)
)
ref = H1.apply(H2, compress=False)
np.testing.assert_allclose(ref.distance(out), 0.0, atol=1e-6)
@pytest.mark.parametrize("device", ["cpu", "gpu"])
def test_compress_mpo_gpu_matches_cpu(device: str) -> None:
"""``compress`` on an MPO should match the CPU result on GPU."""
n_sites, phys_dim, chi_out = 5, 2, 8
A = qtn.MPO_rand(n_sites, bond_dim=chi_out, phys_dim=phys_dim, dtype=np.complex128)
B = qtn.MPO_rand(n_sites, bond_dim=5, phys_dim=phys_dim, dtype=np.complex128) / 1e8
C = A + B
out = as_mpo(
compress(C.arrays, chi_out=chi_out, dtype=np.complex128, seed=0, device=device)
)
np.testing.assert_allclose(C.distance(out), 0.0, atol=1e-6)
def test_apply_mpo_mpo_cpu_gpu_equivalent() -> None:
"""CPU and GPU ``apply`` MPO @ MPO must produce equivalent MPOs."""
n_sites, phys_dim, chi_out = 8, 4, 32
H1 = qtn.MPO_rand(n_sites, bond_dim=chi_out, phys_dim=phys_dim, dtype=np.complex128)
H2 = qtn.MPO_rand(n_sites, bond_dim=chi_out, phys_dim=phys_dim, dtype=np.complex128)
cpu_out = as_mpo(
apply(
H1.arrays,
H2.arrays,
chi_out=chi_out,
dtype=np.complex128,
seed=42,
device="cpu",
)
)
gpu_out = as_mpo(
apply(
H1.arrays,
H2.arrays,
chi_out=chi_out,
dtype=np.complex128,
seed=42,
device="gpu",
)
)
# Gauge freedom means individual tensors can differ; compare the contracted MPOs.
# atol=1e-6 accounts for floating-point accumulation across different execution orders.
np.testing.assert_allclose(cpu_out.distance(gpu_out), 0.0, atol=1e-6)
def test_compress_mpo_cpu_gpu_equivalent() -> None:
"""CPU and GPU ``compress`` must produce equivalent MPOs."""
n_sites, phys_dim, chi_out = 8, 4, 32
A = qtn.MPO_rand(n_sites, bond_dim=chi_out, phys_dim=phys_dim, dtype=np.complex128)
cpu_out = as_mpo(
compress(A.arrays, chi_out=chi_out, dtype=np.complex128, seed=42, device="cpu")
)
gpu_out = as_mpo(
compress(A.arrays, chi_out=chi_out, dtype=np.complex128, seed=42, device="gpu")
)
np.testing.assert_allclose(cpu_out.distance(gpu_out), 0.0, atol=1e-6)
def test_invalid_device_raises() -> None:
"""An unrecognised ``device`` must raise ``ValueError``."""
H = qtn.MPO_rand(5, bond_dim=4, phys_dim=2, dtype=np.complex128)
psi = qtn.MPS_rand_state(5, bond_dim=4, phys_dim=2, dtype=np.complex128)
with pytest.raises(ValueError, match="Unknown device"):
apply(H.arrays, psi.arrays, chi_out=4, device="tpu")
@pytest.mark.parametrize("oversample", [None, 1.0, 2.0])
@pytest.mark.parametrize("kind", ["mps", "mpo"])
def test_oversampled_truncation_matches_cpu(
oversample: float | None, kind: str
) -> None:
"""The final truncation on the GPU agrees with the CPU one."""
make_train = qtn.MPS_rand_state if kind == "mps" else qtn.MPO_rand
train = make_train(5, bond_dim=4, dtype=np.complex128, seed=0)
cpu = compress(train.arrays, 2, oversample=oversample, cutoff=0.1, seed=1)
gpu = compress(
train.arrays,
2,
oversample=oversample,
cutoff=0.1,
seed=1,
device="gpu",
resources=Resources(devices=[0]),
)
assert all(isinstance(site, np.ndarray) for site in gpu)
np.testing.assert_allclose(
type(train)(cpu).to_dense(), type(train)(gpu).to_dense(), atol=1e-10
)
@pytest.mark.parametrize("device", ["cpu", "gpu"])
def test_src_stack_gpu_matches_reference(device: str) -> None:
"""A depth-3 ``src`` stack should match the exact product on both devices."""
n_sites, phys_dim, chi_out = 5, 2, 16
H1 = qtn.MPO_rand(
n_sites, bond_dim=2, phys_dim=phys_dim, dtype=np.complex128, seed=1
)
H2 = qtn.MPO_rand(
n_sites, bond_dim=2, phys_dim=phys_dim, dtype=np.complex128, seed=2
)
psi = qtn.MPS_rand_state(
n_sites, bond_dim=4, phys_dim=phys_dim, dtype=np.complex128, seed=3
)
out = as_mps(
src(
H1.arrays,
H2.arrays,
psi.arrays,
chi_out=chi_out,
dtype=np.complex128,
seed=0,
device=device,
)
)
ref = H1.apply(H2.apply(psi, compress=False), compress=False)
np.testing.assert_allclose(ref.distance(out), 0.0, atol=1e-6)
# ---------------------------------------
# --- Budgets, batching and spilling ---
# ---------------------------------------
def random_mpo_arrays(bonds: list[int], rng: np.random.Generator) -> list[np.ndarray]:
"""Complex Gaussian MPO with the given bonds and physical legs of 2."""
def site(*shape: int) -> np.ndarray:
return rng.normal(size=shape) + 1j * rng.normal(size=shape)
lefts, rights = [None, *bonds], [*bonds, None]
return [
site(*(b for b in (lb, rb) if b is not None), 2, 2)
for lb, rb in zip(lefts, rights)
]
def dense_mpo(train: list[np.ndarray]) -> np.ndarray:
"""Contract an MPO into a ``(U, D)`` matrix."""
T = train[0]
for W in train[1:-1]:
T = np.einsum("aUD,abud->bUuDd", T, W)
T = T.reshape(W.shape[1], T.shape[1] * T.shape[2], T.shape[3] * T.shape[4])
T = np.einsum("aUD,aud->UuDd", T, train[-1])
return T.reshape(T.shape[0] * T.shape[1], T.shape[2] * T.shape[3])
@pytest.fixture
def plans(monkeypatch: pytest.MonkeyPatch) -> list[Plan]:
"""Record the plan of every sweep."""
recorded: list[Plan] = []
def spy(*args: Any, **kwargs: Any) -> Plan:
recorded.append(make_plan(*args, **kwargs))
return recorded[-1]
monkeypatch.setattr(sweep_module, "make_plan", spy)
return recorded
def test_gpu_tiers_and_batches_match_cpu(tmp_path, plans: list[Plan]) -> None:
"""Every tier and small batches on the GPU give the operator of the CPU run."""
rng = np.random.default_rng(7)
stack = [random_mpo_arrays([4, 8, 8, 8, 4], rng) for _ in range(4)]
chi = 64
shapes, sizes = padded_shapes(stack), site_bytes(stack)
roomy = make_plan(
shapes,
sizes,
chi,
np.complex128,
Budgets(10**10, 10**10, 10**12, tmp_path, unified=False),
)
env = chi * 8**4 * 16 # one bulk environment
tight = Resources(
gpu_memory=roomy.device_peak // 2,
host_memory=roomy.host_peak + 5 * env,
scratch_dir=tmp_path,
)
cpu = src(*stack, chi_out=chi, seed=3, dtype=np.complex128)
gpu = src(
*stack, chi_out=chi, seed=3, dtype=np.complex128, device="gpu", resources=tight
)
assert {site.tier for site in plans[-1].sites} == {"device", "host", "disk"}
assert min(site.sketch_batch for site in plans[-1].sites[1:]) < chi
reference = dense_mpo(cpu)
error = np.linalg.norm(dense_mpo(gpu) - reference) / np.linalg.norm(reference)
assert error < 1e-10
assert list(tmp_path.iterdir()) == []
def test_gpu_pool_limit_is_restored(monkeypatch: pytest.MonkeyPatch) -> None:
"""The cap on cupy's pool is lifted after the sweep, also after an error."""
rng = np.random.default_rng(8)
stack = [random_mpo_arrays([2, 3, 3, 2], rng) for _ in range(2)]
pool = cupy.get_default_memory_pool()
previous = pool.get_limit()
src(*stack, chi_out=4, seed=0, device="gpu")
assert pool.get_limit() == previous
def boom(*_args: object) -> None:
msg = "boom"
raise RuntimeError(msg)
monkeypatch.setattr(SiteKernels, "sketch", boom)
with pytest.raises(RuntimeError, match="boom"):
src(*stack, chi_out=4, seed=0, device="gpu")
assert pool.get_limit() == previous
def test_gpu_pool_cap_leaves_room_for_fragmentation(tmp_path) -> None:
"""The pool is capped at the GPU budget plus the margin, not at the budget."""
rng = np.random.default_rng(9)
stack = [random_mpo_arrays([2, 3, 3, 2], rng) for _ in range(2)]
pool = cupy.get_default_memory_pool()
limits: list[int] = []
def spy(*_args: object) -> None:
limits.append(pool.get_limit())
msg = "spy"
raise RuntimeError(msg)
budget = 10**8
used = pool.used_bytes()
with pytest.MonkeyPatch.context() as mp:
mp.setattr(SiteKernels, "env", spy)
with pytest.raises(RuntimeError, match="spy"):
src(
*stack,
chi_out=4,
seed=0,
device="gpu",
resources=Resources(gpu_memory=budget, scratch_dir=tmp_path),
)
_, total = cupy.cuda.runtime.memGetInfo()
margin = max(int(GPU_MARGIN_FRACTION * total), GPU_MARGIN_MIN)
assert limits == [used + budget + margin]
# --------------------
# --- Several GPUs ---
# --------------------
@pytest.fixture
def two_gpus() -> None:
"""Skip unless at least two GPUs are visible."""
if cupy.cuda.runtime.getDeviceCount() < 2:
pytest.skip("Needs at least two GPUs.")
@pytest.mark.usefixtures("two_gpus")
@pytest.mark.parametrize("uploads", ["split", "full"])
def test_two_gpus_match_one(
tmp_path, plans: list[Plan], monkeypatch: pytest.MonkeyPatch, uploads: str
) -> None:
"""Two GPUs, with or without split uploads, give the operator of one.
An odd ``chi`` gives uneven blocks, and the budgets every tier.
"""
rng = np.random.default_rng(7)
stack = [random_mpo_arrays([4, 8, 8, 8, 4], rng) for _ in range(4)]
chi = 63
if uploads == "full":
monkeypatch.setattr(
"src_method._group.enable_peer_access", lambda _xp, _devices: False
)
shapes, sizes = padded_shapes(stack), site_bytes(stack)
roomy = make_plan(
shapes,
sizes,
chi,
np.complex128,
Budgets(10**10, 10**10, 10**12, tmp_path, unified=False),
devices=2,
)
env = (chi // 2 + 1) * 8**4 * 16 # one bulk environment on one GPU
# Checked with make_plan: one environment per GPU on the device, two in host
# memory, the oldest two on disk.
tight = Resources(
gpu_memory=roomy.device_peak - 2 * env,
host_memory=roomy.host_peak + 12 * env,
scratch_dir=tmp_path,
devices=2,
)
one = src(*stack, chi_out=chi, seed=3, dtype=np.complex128, device="gpu")
two = src(
*stack, chi_out=chi, seed=3, dtype=np.complex128, device="gpu", resources=tight
)
assert {site.tier for site in plans[-1].sites} == {"device", "host", "disk"}
reference = dense_mpo(one)
error = np.linalg.norm(dense_mpo(two) - reference) / np.linalg.norm(reference)
assert error < 1e-10
assert list(tmp_path.iterdir()) == []
@pytest.mark.usefixtures("two_gpus")
def test_two_gpus_take_device_inputs() -> None:
"""Cores given on one GPU reach the other through the staging buffer."""
rng = np.random.default_rng(10)
stack = [random_mpo_arrays([2, 3, 3, 2], rng) for _ in range(2)]
with cupy.cuda.Device(1):
on_device = [[cupy.asarray(t) for t in train] for train in stack]
host = src(*stack, chi_out=8, seed=0, device="gpu")
both = src(
*on_device, chi_out=8, seed=0, device="gpu", resources=Resources(devices=2)
)
reference = dense_mpo(host)
assert (
np.linalg.norm(dense_mpo(both) - reference) / np.linalg.norm(reference) < 1e-10
)
@pytest.mark.usefixtures("two_gpus")
@pytest.mark.usefixtures("two_gpus")
def test_one_gpu_takes_inputs_from_another() -> None:
"""Cores given on GPU 1 are staged to GPU 0 when the sweep runs there alone."""
rng = np.random.default_rng(11)
stack = [random_mpo_arrays([2, 3, 3, 2], rng) for _ in range(2)]
with cupy.cuda.Device(1):
on_device = [[cupy.asarray(t) for t in train] for train in stack]
host = src(*stack, chi_out=8, seed=0, device="gpu")
other = src(
*on_device, chi_out=8, seed=0, device="gpu", resources=Resources(devices=[0])
)
reference = dense_mpo(host)
assert (
np.linalg.norm(dense_mpo(other) - reference) / np.linalg.norm(reference) < 1e-10
)
@pytest.mark.usefixtures("two_gpus")
def test_two_gpus_raise_the_first_error(monkeypatch: pytest.MonkeyPatch) -> None:
"""An error on one GPU stops both, restores the pool limits and is raised."""
rng = np.random.default_rng(11)
stack = [random_mpo_arrays([2, 3, 3, 2], rng) for _ in range(2)]
limits = []
for d in range(2):
with cupy.cuda.Device(d):
limits.append(cupy.get_default_memory_pool().get_limit())
def boom(self: SiteKernels, *args: Any) -> Any:
if cupy.cuda.runtime.getDevice() == 1:
msg = "boom on GPU 1"
raise RuntimeError(msg)
return original(self, *args)
original = SiteKernels.sketch
monkeypatch.setattr(SiteKernels, "sketch", boom)
with pytest.raises(RuntimeError, match="boom on GPU 1"):
src(*stack, chi_out=4, seed=0, device="gpu", resources=Resources(devices=2))
for d in range(2):
with cupy.cuda.Device(d):
assert cupy.get_default_memory_pool().get_limit() == limits[d]
@pytest.mark.usefixtures("two_gpus")
def test_too_many_devices_raise() -> None:
count = cupy.cuda.runtime.getDeviceCount()
rng = np.random.default_rng(12)
stack = [random_mpo_arrays([2, 3, 3, 2], rng) for _ in range(2)]
with pytest.raises(ValueError, match="visible"):
src(*stack, chi_out=4, device="gpu", resources=Resources(devices=count + 1))