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