microsoft/qdk
Publicmirrored fromhttps://github.com/microsoft/qdkAvailable
source/pip/tests/test_gpu_simulator.py
515lines · modecode
| 1 | # Copyright (c) Microsoft Corporation. |
| 2 | # Licensed under the MIT License. |
| 3 | |
| 4 | from collections import Counter |
| 5 | from pathlib import Path |
| 6 | from typing import Sequence, cast |
| 7 | import math |
| 8 | import os |
| 9 | import random |
| 10 | |
| 11 | import pytest |
| 12 | import sys |
| 13 | |
| 14 | from qsharp._native import Result |
| 15 | |
| 16 | # Skip all tests in this module if QDK_GPU_TESTS is not set |
| 17 | if not os.environ.get("QDK_GPU_TESTS"): |
| 18 | pytest.skip("Skipping GPU tests (QDK_GPU_TESTS not set)", allow_module_level=True) |
| 19 | |
| 20 | SKIP_REASON = "GPU is not available" |
| 21 | |
| 22 | gpu_info = "Unknown" |
| 23 | |
| 24 | try: |
| 25 | from qsharp._native import try_create_gpu_adapter |
| 26 | |
| 27 | gpu_info = try_create_gpu_adapter() |
| 28 | # Printing to stderr so that it is visible if CI run fails |
| 29 | print(f"*** USING GPU: {gpu_info}", file=sys.stderr) |
| 30 | |
| 31 | GPU_AVAILABLE = True |
| 32 | except OSError as e: |
| 33 | GPU_AVAILABLE = False |
| 34 | SKIP_REASON = str(e) |
| 35 | |
| 36 | |
| 37 | import qsharp |
| 38 | from qsharp import TargetProfile |
| 39 | from qsharp import openqasm |
| 40 | |
| 41 | from qsharp._simulation import run_qir_gpu, NoiseConfig |
| 42 | |
| 43 | current_file_path = Path(__file__) |
| 44 | # Get the directory of the current file |
| 45 | current_dir = current_file_path.parent |
| 46 | |
| 47 | |
| 48 | def read_file(file_name: str) -> str: |
| 49 | return Path(file_name).read_text(encoding="utf-8") |
| 50 | |
| 51 | |
| 52 | def read_file_relative(file_name: str) -> str: |
| 53 | return Path(current_dir / file_name).read_text(encoding="utf-8") |
| 54 | |
| 55 | |
| 56 | def result_array_to_string(results: Sequence[Result]) -> str: |
| 57 | chars = [] |
| 58 | for value in results: |
| 59 | if value == Result.Zero: |
| 60 | chars.append("0") |
| 61 | elif value == Result.One: |
| 62 | chars.append("1") |
| 63 | else: |
| 64 | chars.append("-") |
| 65 | return "".join(chars) |
| 66 | |
| 67 | |
| 68 | @pytest.mark.skipif(not GPU_AVAILABLE, reason=SKIP_REASON) |
| 69 | def test_gpu_seeding_no_noise(): |
| 70 | qsharp.init(target_profile=TargetProfile.Base) |
| 71 | qsharp.eval( |
| 72 | """ |
| 73 | operation BellTest() : Result[] { |
| 74 | use qs = Qubit[2]; |
| 75 | H(qs[0]); |
| 76 | CNOT(qs[0], qs[1]); |
| 77 | MResetEachZ(qs) |
| 78 | } |
| 79 | """ |
| 80 | ) |
| 81 | |
| 82 | qir = str(qsharp.compile("BellTest()")) |
| 83 | |
| 84 | results = [run_qir_gpu(qir, 1, None, seed)[0] for seed in range(12)] |
| 85 | print(results) |
| 86 | |
| 87 | # Results will be an array of 12 lists [Result, Result] |
| 88 | # Each result should be [Zero, Zero] or [One, One] |
| 89 | # As evident from a manual experiment running with the seeds of 0..11 |
| 90 | # gives 6 results of each. Experiment should be repeatable for fixed seeds. |
| 91 | |
| 92 | # Verify we have 6 of each result |
| 93 | count_00 = sum(1 for r in results if r == [Result.Zero, Result.Zero]) |
| 94 | count_11 = sum(1 for r in results if r == [Result.One, Result.One]) |
| 95 | assert count_00 == 6 |
| 96 | assert count_11 == 6 |
| 97 | |
| 98 | |
| 99 | @pytest.mark.skipif(not GPU_AVAILABLE, reason=SKIP_REASON) |
| 100 | def test_gpu_no_noise(): |
| 101 | """Simple test that GPU simulator works without noise.""" |
| 102 | qsharp.init(target_profile=TargetProfile.Base) |
| 103 | qsharp.eval(read_file_relative("CliffordIsing.qs")) |
| 104 | |
| 105 | input = qsharp.compile( |
| 106 | "IsingModel2DEvolution(5, 5, PI() / 2.0, PI() / 2.0, 10.0, 10)" |
| 107 | ) |
| 108 | |
| 109 | output = run_qir_gpu(str(input)) |
| 110 | print(output) |
| 111 | # Expecting deterministic output, no randomization seed needed. |
| 112 | assert output == [[Result.Zero] * 25], "Expected result of 0s with pi/2 angles." |
| 113 | |
| 114 | |
| 115 | @pytest.mark.skipif(not GPU_AVAILABLE, reason=SKIP_REASON) |
| 116 | def test_gpu_bitflip_noise(): |
| 117 | """Bitflip noise for GPU simulator.""" |
| 118 | qsharp.init(target_profile=TargetProfile.Base) |
| 119 | qsharp.eval(read_file_relative("CliffordIsing.qs")) |
| 120 | |
| 121 | input = qsharp.compile( |
| 122 | "IsingModel2DEvolution(5, 5, PI() / 2.0, PI() / 2.0, 10.0, 10)" |
| 123 | ) |
| 124 | |
| 125 | p_noise = 0.005 |
| 126 | noise = NoiseConfig() |
| 127 | noise.rx.set_bitflip(p_noise) |
| 128 | noise.rzz.set_pauli_noise("XX", p_noise) |
| 129 | noise.mresetz.set_bitflip(p_noise) |
| 130 | |
| 131 | output = run_qir_gpu(str(input), shots=3, noise=noise, seed=17) |
| 132 | result = [result_array_to_string(cast(Sequence[Result], x)) for x in output] |
| 133 | print(result) |
| 134 | # Reasonable results obtained from manual run |
| 135 | assert result == [ |
| 136 | "0000000000011100000000110", |
| 137 | "0001001100000000000100110", |
| 138 | "0000000000011000000000000", |
| 139 | ] |
| 140 | |
| 141 | |
| 142 | @pytest.mark.skipif(not GPU_AVAILABLE, reason=SKIP_REASON) |
| 143 | def test_gpu_mixed_noise(): |
| 144 | qsharp.init(target_profile=TargetProfile.Base) |
| 145 | qsharp.eval(read_file_relative("CliffordIsing.qs")) |
| 146 | |
| 147 | input = qsharp.compile( |
| 148 | "IsingModel2DEvolution(5, 5, PI() / 2.0, PI() / 2.0, 4.0, 4)" |
| 149 | ) |
| 150 | |
| 151 | noise = NoiseConfig() |
| 152 | noise.rz.set_bitflip(0.005) |
| 153 | noise.rz.loss = 0.003 |
| 154 | noise.rzz.set_depolarizing(0.005) |
| 155 | noise.rzz.loss = 0.003 |
| 156 | |
| 157 | output = run_qir_gpu(str(input), shots=3, noise=noise, seed=53) |
| 158 | result = [result_array_to_string(cast(Sequence[Result], x)) for x in output] |
| 159 | print(result) |
| 160 | # Reasonable results obtained from manual run |
| 161 | assert result == [ |
| 162 | "00000-00000000-0000000000", |
| 163 | "00100001000-0000000000-00", |
| 164 | "000000010000000-000000000", |
| 165 | ] |
| 166 | |
| 167 | |
| 168 | @pytest.mark.skipif(not GPU_AVAILABLE, reason=SKIP_REASON) |
| 169 | def test_gpu_isolated_loss(): |
| 170 | qsharp.init(target_profile=TargetProfile.Base) |
| 171 | program = """ |
| 172 | import Std.Math.PI; |
| 173 | operation Main() : Result[] { |
| 174 | use qs = Qubit[3]; |
| 175 | X(qs[0]); |
| 176 | X(qs[1]); |
| 177 | CNOT(qs[0], qs[1]); |
| 178 | // When loss is configured for X gate, qubit 2 should be unaffected. |
| 179 | Rx(PI() / 2.0, qs[2]); |
| 180 | Rx(PI() / 2.0, qs[2]); |
| 181 | MeasureEachZ(qs) |
| 182 | } |
| 183 | """ |
| 184 | qsharp.eval(program) |
| 185 | |
| 186 | input = qsharp.compile("Main()") |
| 187 | |
| 188 | noise = NoiseConfig() |
| 189 | noise.x.loss = 0.1 |
| 190 | |
| 191 | output = run_qir_gpu(str(input), shots=1000, noise=noise) |
| 192 | result = [result_array_to_string(cast(Sequence[Result], x)) for x in output] |
| 193 | histogram = Counter(result) |
| 194 | total = sum(histogram.values()) |
| 195 | allowed_percent = { |
| 196 | "101": 0.81, |
| 197 | "1-1": 0.09, |
| 198 | "-11": 0.09, |
| 199 | "--1": 0.01, |
| 200 | } |
| 201 | tolerance = 0.2 * total |
| 202 | for bitstring, actual_count in histogram.items(): |
| 203 | assert ( |
| 204 | bitstring in allowed_percent |
| 205 | ), f"Unexpected measurement string: '{bitstring}'." |
| 206 | expected_count = allowed_percent[bitstring] * total |
| 207 | assert abs(actual_count - expected_count) <= tolerance, ( |
| 208 | f"Count for {bitstring} outside 20% tolerance. " |
| 209 | f"Actual={actual_count}, Expected≈{expected_count:.0f}, Shots={total}." |
| 210 | ) |
| 211 | # We don't check for missing strings, as low-probability strings may not appear in finite shots. |
| 212 | |
| 213 | |
| 214 | @pytest.mark.skipif(not GPU_AVAILABLE, reason=SKIP_REASON) |
| 215 | def test_gpu_isolated_loss_and_noise(): |
| 216 | qsharp.init(target_profile=TargetProfile.Base) |
| 217 | program = """ |
| 218 | import Std.Math.PI; |
| 219 | operation Main() : Result[] { |
| 220 | use qs = Qubit[5]; |
| 221 | for _ in 1..100 { |
| 222 | X(qs[0]); |
| 223 | X(qs[1]); |
| 224 | CNOT(qs[0], qs[1]); |
| 225 | } |
| 226 | Rx(PI() / 2.0, qs[4]); |
| 227 | Rx(PI() / 2.0, qs[4]); |
| 228 | MeasureEachZ(qs) |
| 229 | } |
| 230 | """ |
| 231 | qsharp.eval(program) |
| 232 | |
| 233 | input = qsharp.compile("Main()") |
| 234 | |
| 235 | noise = NoiseConfig() |
| 236 | noise.x.set_bitflip(0.001) |
| 237 | noise.x.loss = 0.001 |
| 238 | |
| 239 | output = run_qir_gpu(str(input), shots=1000, noise=noise) |
| 240 | result = [result_array_to_string(cast(Sequence[Result], x)) for x in output] |
| 241 | histogram = Counter(result) |
| 242 | total = sum(histogram.values()) |
| 243 | assert total > 0, "No measurement results recorded." |
| 244 | for bitstring in histogram: |
| 245 | assert bitstring.endswith("001"), f"Unexpected suffix in '{bitstring}'." |
| 246 | probability_00001 = histogram.get("00001", 0) / total |
| 247 | assert 0.5 < probability_00001 < 0.8, ( |
| 248 | f"Probability of 00001 outside expected range. " |
| 249 | f"Actual={probability_00001:.2%}, Shots={total}." |
| 250 | ) |
| 251 | |
| 252 | |
| 253 | def build_x_chain_qir(n_instances: int, n_x: int) -> str: |
| 254 | # Construct multiple instances of x gate chains |
| 255 | prefix = f""" |
| 256 | OPENQASM 3.0; |
| 257 | include "stdgates.inc"; |
| 258 | bit[{n_instances}] c; |
| 259 | qubit[{n_instances}] q; |
| 260 | """ |
| 261 | |
| 262 | infix = """ |
| 263 | x q; |
| 264 | """ |
| 265 | |
| 266 | suffix = """ |
| 267 | c = measure q; |
| 268 | """ |
| 269 | |
| 270 | src_parallel = prefix + infix * n_x + suffix |
| 271 | |
| 272 | # Compile resulting program |
| 273 | qsharp.init(target_profile=TargetProfile.Base) |
| 274 | qir_parallel = openqasm.compile(src_parallel) |
| 275 | return str(qir_parallel) |
| 276 | |
| 277 | |
| 278 | @pytest.mark.skipif(not GPU_AVAILABLE, reason=SKIP_REASON) |
| 279 | @pytest.mark.parametrize( |
| 280 | "p_noise, n_x, n_instances, n_shots, max_percent", |
| 281 | [ |
| 282 | (0.0005, 500, 10, 8192, 1.0), |
| 283 | (0.005, 500, 10, 4096, 2.0), |
| 284 | (0.0005, 20, 20, 100, 4.0), # 100 shots is low, so higher error tolerated |
| 285 | ], |
| 286 | ) |
| 287 | def test_gpu_x_chain( |
| 288 | p_noise: float, n_x: int, n_instances: int, n_shots: int, max_percent: float |
| 289 | ): |
| 290 | """ |
| 291 | Simulate multi-instance X-chain with bitflip noise many times |
| 292 | Compare result frequencies with analytically computed probabilities |
| 293 | """ |
| 294 | # Use the GPU simulator with noise |
| 295 | noise = NoiseConfig() |
| 296 | noise.x.set_bitflip(p_noise) |
| 297 | |
| 298 | qir = build_x_chain_qir(n_instances, n_x) |
| 299 | output = run_qir_gpu(qir, shots=n_shots, noise=noise, seed=18) |
| 300 | histogram = [0 for _ in range(n_instances + 1)] |
| 301 | for shot in output: |
| 302 | shot_results = cast(Sequence[Result], shot) |
| 303 | count_1 = shot_results.count(Result.One) |
| 304 | histogram[count_1] += 1 |
| 305 | |
| 306 | # Probability of obtaining 0 and 1 at the end of the X chain. |
| 307 | p_0 = ((2.0 * p_noise - 1.0) ** n_x + 1.0) / 2.0 |
| 308 | p_1 = 1.0 - p_0 |
| 309 | |
| 310 | # Number of results with k ones that should be there. |
| 311 | p_N = [ |
| 312 | p_0 ** ((n_instances - k)) * (p_1**k) * math.comb(n_instances, k) * n_shots |
| 313 | for k in range(n_instances + 1) |
| 314 | ] |
| 315 | |
| 316 | # Error % for deviation from analytical value |
| 317 | error_percent = [abs(a - b) * 100.0 / n_shots for (a, b) in zip(histogram, p_N)] |
| 318 | print(", ".join(f"{a} (Δ≈{b:.1f}%)" for (a, b) in zip(histogram, error_percent))) |
| 319 | |
| 320 | # We tolerate configured percentage error. |
| 321 | assert all( |
| 322 | err < max_percent for err in error_percent |
| 323 | ), f"Error percent too high: {error_percent}" |
| 324 | |
| 325 | |
| 326 | def build_cy_noise_qir(n_cy: int) -> str: |
| 327 | src = """ |
| 328 | OPENQASM 3.0; |
| 329 | include "stdgates.inc"; |
| 330 | bit[2] c; |
| 331 | qubit[2] q; |
| 332 | x q[0]; |
| 333 | h q[1]; |
| 334 | """ |
| 335 | src += "cy q[0], q[1];\n" * n_cy |
| 336 | src += """ |
| 337 | h q[1]; |
| 338 | c = measure q; |
| 339 | """ |
| 340 | |
| 341 | qsharp.init(target_profile=TargetProfile.Base) |
| 342 | # OpenQasm output semantics preserves order of bits in the output register. |
| 343 | qir_program = openqasm.compile( |
| 344 | src, output_semantics=openqasm.OutputSemantics.OpenQasm |
| 345 | ) |
| 346 | return str(qir_program) |
| 347 | |
| 348 | |
| 349 | @pytest.mark.skipif(not GPU_AVAILABLE, reason=SKIP_REASON) |
| 350 | def test_gpu_cy_noise_distribution(): |
| 351 | """ |
| 352 | Apply CY with per-gate Z noise and validate the expected odd-parity flip rate. |
| 353 | """ |
| 354 | n_cy = 10 |
| 355 | p_z = 0.01 |
| 356 | n_shots = 1000 |
| 357 | expected_p1 = (1.0 - (1.0 - 2.0 * p_z) ** n_cy) / 2.0 |
| 358 | |
| 359 | noise = NoiseConfig() |
| 360 | noise.cy.set_pauli_noise("IZ", p_z) |
| 361 | |
| 362 | qir = build_cy_noise_qir(n_cy) |
| 363 | output = run_qir_gpu(qir, shots=n_shots, noise=noise, seed=77) |
| 364 | |
| 365 | count_target_one = 0 |
| 366 | for shot in output: |
| 367 | shot_results = cast(Sequence[Result], shot) |
| 368 | if shot_results[1] == Result.One: |
| 369 | count_target_one += 1 |
| 370 | |
| 371 | actual_p1 = count_target_one / n_shots |
| 372 | tolerance = 0.05 |
| 373 | print( |
| 374 | f"CY noise rate outside tolerance. Expected≈{expected_p1:.3f}, actual={actual_p1:.3f}, tol={tolerance:.3f}" |
| 375 | ) |
| 376 | assert abs(actual_p1 - expected_p1) <= tolerance, "CY noise rate outside tolerance." |
| 377 | |
| 378 | |
| 379 | def generate_op_sequence( |
| 380 | n_qubits: int, n_ops: int, n_rand: int |
| 381 | ) -> list[tuple[int, int]]: |
| 382 | """Return operation tuples and randomly swap neighboring pairs n_rand times.""" |
| 383 | if n_qubits < 0 or n_ops < 0 or n_rand < 0: |
| 384 | raise ValueError("Tuple bounds must be non-negative") |
| 385 | |
| 386 | ops = [(q, op) for op in range(n_ops) for q in range(n_qubits)] |
| 387 | |
| 388 | if len(ops) < 2 or n_rand == 0: |
| 389 | return ops |
| 390 | |
| 391 | max_index = len(ops) - 1 |
| 392 | for _ in range(n_rand): |
| 393 | idx = random.randrange(max_index) |
| 394 | left, right = ops[idx], ops[idx + 1] |
| 395 | if left[0] != right[0]: |
| 396 | ops[idx], ops[idx + 1] = right, left |
| 397 | |
| 398 | return ops |
| 399 | |
| 400 | |
| 401 | @pytest.mark.skipif(not GPU_AVAILABLE, reason=SKIP_REASON) |
| 402 | @pytest.mark.parametrize("noisy_gate, noise_number", [(0, 2), (1, 1), (2, 2), (3, 2)]) |
| 403 | def test_gpu_permuted_rotations(noisy_gate: int, noise_number: int): |
| 404 | qsharp.init(target_profile=TargetProfile.Base) |
| 405 | |
| 406 | n_shots = 2000 |
| 407 | n_qubits = 15 |
| 408 | seed = 2026 |
| 409 | p_loss = 0.1 |
| 410 | tolerance_percent = 2.0 |
| 411 | assert n_qubits >= 2, "Need at least two qubits" |
| 412 | |
| 413 | random.seed(seed) |
| 414 | i1, i2 = random.sample(range(n_qubits), 2) |
| 415 | prefix = f""" |
| 416 | operation tiny_coeffs() : Result[] {{ |
| 417 | use q = Qubit[{n_qubits}]; |
| 418 | let i1 = {i1}; |
| 419 | let i2 = {i2}; |
| 420 | """ |
| 421 | |
| 422 | # The following sequence of rotations is equivalent to identity: |
| 423 | # 0. H <- could be any rotation |
| 424 | # 1. Rx(1.123456789) |
| 425 | # 2. Ry(1.212121212) |
| 426 | # 3. Rz(1.14856940153986) |
| 427 | # 4. Ry(-1.41836046203971) |
| 428 | # 5. Rz(-0.325946593598928) |
| 429 | # 6. H <- adjoint to step 0 |
| 430 | # We will perform these rotations on every qubit, but randomly intermix sequences for different qubits. |
| 431 | # This should still result in identity on all qubits as gates on different qubits commute. |
| 432 | # noise_number = how many times noisy gate appears in sequence. |
| 433 | |
| 434 | n_ops = 7 |
| 435 | ops = generate_op_sequence(n_qubits, n_ops, n_qubits * n_ops * 100) |
| 436 | infix = "" |
| 437 | for qubit, op in ops: |
| 438 | match op: |
| 439 | case 0 | 6: |
| 440 | infix += f" H(q[{qubit}]);\n" |
| 441 | case 1: |
| 442 | infix += f" Rx(1.123456789, q[{qubit}]);\n" |
| 443 | case 2: |
| 444 | infix += f" Ry(1.212121212, q[{qubit}]);\n" |
| 445 | case 3: |
| 446 | infix += f" Rz(1.14856940153986, q[{qubit}]);\n" |
| 447 | case 4: |
| 448 | infix += f" Ry(-1.41836046203971, q[{qubit}]);\n" |
| 449 | case 5: |
| 450 | infix += f" Rz(-0.325946593598928, q[{qubit}]);\n" |
| 451 | |
| 452 | suffix = """ |
| 453 | let m1 = M(q[i1]); |
| 454 | let m2 = M(q[i2]); |
| 455 | ResetAll(q); |
| 456 | return [m1, m2]; |
| 457 | } |
| 458 | """ |
| 459 | |
| 460 | program = prefix + infix + suffix |
| 461 | qsharp.eval(program) |
| 462 | input = qsharp.compile("tiny_coeffs()") |
| 463 | |
| 464 | noise = NoiseConfig() |
| 465 | p_combined_loss = 1.0 - ((1.0 - p_loss) ** noise_number) |
| 466 | match noisy_gate: |
| 467 | case 0: |
| 468 | noise.h.loss = p_loss |
| 469 | case 1: |
| 470 | noise.rx.loss = p_loss |
| 471 | case 2: |
| 472 | noise.ry.loss = p_loss |
| 473 | case 3: |
| 474 | noise.rz.loss = p_loss |
| 475 | case _: |
| 476 | raise ValueError("Invalid noisy_gate value") |
| 477 | |
| 478 | output = run_qir_gpu(str(input), shots=n_shots, noise=noise, seed=seed) |
| 479 | result_strings = [ |
| 480 | result_array_to_string(cast(Sequence[Result], shot)) for shot in output |
| 481 | ] |
| 482 | assert ( |
| 483 | len(result_strings) == n_shots |
| 484 | ), f"Shot count mismatch. Actual={len(result_strings)}, Expected={n_shots}" |
| 485 | |
| 486 | p_minus = p_combined_loss |
| 487 | p_0 = 1.0 - p_minus |
| 488 | allowed = [ |
| 489 | ("00", n_shots * p_0 * p_0), |
| 490 | ("0-", n_shots * p_0 * p_minus), |
| 491 | ("-0", n_shots * p_minus * p_0), |
| 492 | ("--", n_shots * p_minus * p_minus), |
| 493 | ] |
| 494 | |
| 495 | counts = {pattern: 0 for pattern, _ in allowed} |
| 496 | for entry in result_strings: |
| 497 | assert ( |
| 498 | entry in counts |
| 499 | ), f"Unexpected measurement string: '{entry}'. Program={program}." |
| 500 | counts[entry] += 1 |
| 501 | |
| 502 | tolerance = tolerance_percent / 100.0 * n_shots |
| 503 | print( |
| 504 | f"Permuted rotations test: n_qubits={n_qubits}, n_shots={n_shots}, seed={seed}, noise#{noise_number}, Δ<={tolerance:.0f} i1={i1}, i2={i2}" |
| 505 | ) |
| 506 | summary_msg = ", ".join( |
| 507 | f"'{pattern}': {counts[pattern]} (Δ={abs(counts[pattern] - expected_count):.0f})" |
| 508 | for pattern, expected_count in allowed |
| 509 | ) |
| 510 | print(summary_msg) |
| 511 | for pattern, expected_count in allowed: |
| 512 | actual_count = counts[pattern] |
| 513 | assert ( |
| 514 | abs(actual_count - expected_count) <= tolerance |
| 515 | ), f"Count for {pattern} off by more than {tolerance_percent:.1f}% of shots. Actual={actual_count}, Expected={expected_count:.0f}, noise#{noise_number}, Program={program}." |
| 516 | |