Tatopenn commited on
Commit
078a223
Β·
verified Β·
1 Parent(s): 95ce85d

Sync test_mps.py from v8.1.35

Browse files
Files changed (1) hide show
  1. test_mps.py +334 -1
test_mps.py CHANGED
@@ -12,8 +12,13 @@ the primary correctness signal here, not just internal self-consistency.
12
  import numpy as np
13
  import pytest
14
 
 
 
 
15
  import dense_evolution as de
16
- from dense_evolution.mps import MPSSimulator, _jsd_vectors
 
 
17
 
18
  INV2 = 1.0 / np.sqrt(2.0)
19
  H_GATE = INV2 * np.array([[1, 1], [1, -1]], dtype=complex)
@@ -435,3 +440,331 @@ def test_top_k_never_touches_full_statevector_at_large_n():
435
  assert prob_k.sum() <= 1.0 + 1e-9
436
  assert len(idx_k) > 0
437
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12
  import numpy as np
13
  import pytest
14
 
15
+ import jax
16
+ import jax.numpy as jnp
17
+
18
  import dense_evolution as de
19
+ from dense_evolution.mps import (
20
+ MPSSimulator, _jsd_vectors, _vectorized_chi_search, _expand_nonlocal_2q_positions,
21
+ )
22
 
23
  INV2 = 1.0 / np.sqrt(2.0)
24
  H_GATE = INV2 * np.array([[1, 1], [1, -1]], dtype=complex)
 
440
  assert prob_k.sum() <= 1.0 + 1e-9
441
  assert len(idx_k) > 0
442
 
443
+
444
+ # ── _vectorized_chi_search: JIT-fusion groundwork (issue: MPS is ~140x ──
445
+ # slower than Qiskit Aer's MPS backend on a 60-qubit stress circuit,
446
+ # measured 88.9s vs 0.64s -- root cause: zero @jax.jit anywhere in this
447
+ # file, including a host-syncing Python `while` loop in _svd_truncate to
448
+ # search for the truncated bond dimension. _vectorized_chi_search replaces
449
+ # that loop with one vectorized pass; this test is the correctness gate
450
+ # before it's wired into _svd_truncate itself (a later step) -- it must
451
+ # reproduce the while loop's (chi_new, jsd_val) exactly, not approximately,
452
+ # across real _svd_truncate calls captured from real entangled circuits.
453
+
454
+ def _capture_real_svd_truncate_calls(n_qubits, max_bond, jsd_budget, seed, layers):
455
+ """Runs a real entangling circuit through MPSSimulator, capturing the
456
+ TRUE full (pre-truncation) singular-value spectrum _svd_truncate saw
457
+ internally for every 2-qubit gate, plus what the real while loop
458
+ decided (chi_new, jsd_val) -- so _vectorized_chi_search can be checked
459
+ against real data, not synthetic arrays."""
460
+ captured = []
461
+ orig_svd_truncate = MPSSimulator._svd_truncate
462
+
463
+ def spy(self, theta_mat):
464
+ full_S = np.array(jnp.linalg.svd(theta_mat, full_matrices=False)[1])
465
+ U, S, Vh, trunc_err, jsd_val = orig_svd_truncate(self, theta_mat)
466
+ captured.append((full_S, self.eps, self.jsd_budget, self.chi, U.shape[1], jsd_val))
467
+ return U, S, Vh, trunc_err, jsd_val
468
+
469
+ MPSSimulator._svd_truncate = spy
470
+ try:
471
+ rng = np.random.default_rng(seed)
472
+ mps = MPSSimulator(n_qubits=n_qubits, max_bond=max_bond, jsd_budget=jsd_budget)
473
+ cx = jnp.array([[1, 0, 0, 0], [0, 1, 0, 0], [0, 0, 0, 1], [0, 0, 1, 0]],
474
+ dtype=complex).reshape(2, 2, 2, 2)
475
+ mps.apply_gate_1q(H_GATE, 0)
476
+ for _ in range(layers):
477
+ for i in range(n_qubits):
478
+ theta = float(rng.uniform(0.1, 1.5))
479
+ rx = jnp.array([[jnp.cos(theta / 2), -1j * jnp.sin(theta / 2)],
480
+ [-1j * jnp.sin(theta / 2), jnp.cos(theta / 2)]], dtype=complex)
481
+ mps.apply_gate_1q(rx, i)
482
+ for i in range(0, n_qubits - 1, 2):
483
+ mps.apply_gate_2q(cx, i, i + 1)
484
+ for i in range(1, n_qubits - 1, 2):
485
+ mps.apply_gate_2q(cx, i, i + 1)
486
+ finally:
487
+ MPSSimulator._svd_truncate = orig_svd_truncate
488
+ return captured
489
+
490
+
491
+ @pytest.mark.parametrize("n_qubits,max_bond,jsd_budget,seed,layers", [
492
+ (14, 32, 1e-5, 0, 6), # the config that first validated this during design
493
+ (10, 8, 1e-3, 1, 4), # small max_bond -- more likely to hit budget violations
494
+ (20, 64, 1e-6, 2, 5), # larger, tighter budget
495
+ (6, 4, 1e-2, 3, 8), # tiny bond, many layers -- stresses the violation fallback
496
+ ])
497
+ def test_vectorized_chi_search_matches_real_while_loop(n_qubits, max_bond, jsd_budget, seed, layers):
498
+ captured = _capture_real_svd_truncate_calls(n_qubits, max_bond, jsd_budget, seed, layers)
499
+ assert len(captured) > 0, "expected at least one real _svd_truncate call"
500
+ for full_S, eps, budget, chi, real_chi_new, real_jsd_val in captured:
501
+ vec_chi_new, vec_jsd_val = _vectorized_chi_search(jnp.array(full_S), eps, budget, chi)
502
+ assert vec_chi_new == real_chi_new
503
+ assert vec_jsd_val == pytest.approx(real_jsd_val, abs=1e-9)
504
+
505
+
506
+ # ── _expand_nonlocal_2q_positions: JIT-fusion groundwork, step 2 ────────
507
+ # Moves the SWAP-chain expansion _apply_nonlocal_2q does at runtime to a
508
+ # pre-compile-time Python function, so a future jax.lax.scan-based kernel
509
+ # can consume a flat, pre-expanded op list instead of dispatching SWAPs
510
+ # inside the traced region.
511
+
512
+ @pytest.mark.parametrize("n_qubits", [6, 10, 15])
513
+ def test_expand_nonlocal_2q_positions_matches_real_dispatch_sequence(n_qubits):
514
+ """Exhaustive: every (q1, q2) pair for this n_qubits must produce the
515
+ exact same sequence of adjacent-pair apply_gate_2q calls the real
516
+ runtime SWAP-chain dispatch produces."""
517
+ CNOT = jnp.array([[1, 0, 0, 0], [0, 1, 0, 0], [0, 0, 0, 1], [0, 0, 1, 0]],
518
+ dtype=complex).reshape(2, 2, 2, 2)
519
+
520
+ def real_sequence(q1, q2):
521
+ calls = []
522
+ orig_apply_gate_2q = MPSSimulator.apply_gate_2q
523
+
524
+ def spy(self, gate_2q, a, b):
525
+ calls.append((a, b))
526
+ return orig_apply_gate_2q(self, gate_2q, a, b)
527
+
528
+ MPSSimulator.apply_gate_2q = spy
529
+ try:
530
+ mps = MPSSimulator(n_qubits=n_qubits, max_bond=8)
531
+ mps._apply_nonlocal_2q(CNOT, q1, q2)
532
+ finally:
533
+ MPSSimulator.apply_gate_2q = orig_apply_gate_2q
534
+ return calls
535
+
536
+ for q1 in range(n_qubits):
537
+ for q2 in range(n_qubits):
538
+ if q1 == q2:
539
+ continue
540
+ real_seq = real_sequence(q1, q2)
541
+ pred_seq, gate_index, needs_transpose = _expand_nonlocal_2q_positions(q1, q2)
542
+ assert pred_seq == real_seq, f"(q1={q1}, q2={q2}): predicted {pred_seq} != real {real_seq}"
543
+ assert needs_transpose == (q1 > q2)
544
+
545
+
546
+ def test_expand_nonlocal_2q_positions_replay_matches_final_state():
547
+ """End-to-end, not just index bookkeeping: replaying the predicted
548
+ sequence by hand (SWAP at every position except gate_index, the real
549
+ gate -- transposed if needs_transpose -- at gate_index) through the
550
+ existing eager apply_gate_2q must produce the identical final quantum
551
+ state as calling apply_gate_2q(gate, q1, q2) directly (which routes
552
+ through the real runtime _apply_nonlocal_2q dispatch internally)."""
553
+ n_qubits = 8
554
+ swap = jnp.array([[1, 0, 0, 0], [0, 0, 1, 0], [0, 1, 0, 0], [0, 0, 0, 1]],
555
+ dtype=complex).reshape(2, 2, 2, 2)
556
+ CNOT = jnp.array([[1, 0, 0, 0], [0, 1, 0, 0], [0, 0, 0, 1], [0, 0, 1, 0]],
557
+ dtype=complex).reshape(2, 2, 2, 2)
558
+
559
+ for q1, q2 in [(0, 3), (3, 0), (2, 7), (7, 2), (1, 6)]:
560
+ # Reference: real dispatch, via the public API exactly as used elsewhere.
561
+ mps_real = MPSSimulator(n_qubits=n_qubits, max_bond=16)
562
+ mps_real.apply_gate_1q(H_GATE, 0)
563
+ mps_real.apply_gate_1q(H_GATE, 4)
564
+ mps_real.apply_gate_2q(CNOT, q1, q2)
565
+ sv_real = np.asarray(mps_real.contract_to_statevector())
566
+
567
+ # Replay: predicted sequence, executed by hand through the same
568
+ # eager apply_gate_2q primitive, no call into _apply_nonlocal_2q.
569
+ mps_replay = MPSSimulator(n_qubits=n_qubits, max_bond=16)
570
+ mps_replay.apply_gate_1q(H_GATE, 0)
571
+ mps_replay.apply_gate_1q(H_GATE, 4)
572
+ seq, gate_index, needs_transpose = _expand_nonlocal_2q_positions(q1, q2)
573
+ gate_to_apply = jnp.transpose(CNOT, (1, 0, 3, 2)) if needs_transpose else CNOT
574
+ for i, (a, b) in enumerate(seq):
575
+ mps_replay.apply_gate_2q(gate_to_apply if i == gate_index else swap, a, b)
576
+ sv_replay = np.asarray(mps_replay.contract_to_statevector())
577
+
578
+ fidelity = np.abs(np.vdot(sv_real, sv_replay)) ** 2
579
+ assert fidelity == pytest.approx(1.0, abs=1e-9), f"(q1={q1}, q2={q2}): fidelity={fidelity}"
580
+
581
+
582
+ # ── run_circuit_jit: JIT-fusion groundwork, step 3 ───────────────────────
583
+ # The fused whole-circuit jax.lax.scan path. Correctness bar is the same
584
+ # one this file already established for the eager path: cross-validation
585
+ # against DenseSVSimulator on real entangling circuits, not just internal
586
+ # self-consistency. The eager apply_gate_1q/apply_gate_2q/_apply_nonlocal_2q
587
+ # methods are untouched by this -- run_circuit_jit is a new, additional
588
+ # entry point.
589
+
590
+ def test_run_circuit_jit_bell_state():
591
+ mps = MPSSimulator(n_qubits=2, max_bond=8)
592
+ mps.run_circuit_jit([["h", 0], ["cx", 0, 1]])
593
+ prob = np.abs(np.asarray(mps.contract_to_statevector())) ** 2
594
+ assert prob[0b00] == pytest.approx(0.5, abs=1e-9)
595
+ assert prob[0b11] == pytest.approx(0.5, abs=1e-9)
596
+
597
+
598
+ def test_run_circuit_jit_ghz_chain():
599
+ n = 6
600
+ ops = [["h", 0]] + [["cx", q, q + 1] for q in range(n - 1)]
601
+ mps = MPSSimulator(n_qubits=n, max_bond=8)
602
+ mps.run_circuit_jit(ops)
603
+ prob = np.abs(np.asarray(mps.contract_to_statevector())) ** 2
604
+ assert prob[0] == pytest.approx(0.5, abs=1e-9)
605
+ assert prob[2 ** n - 1] == pytest.approx(0.5, abs=1e-9)
606
+
607
+
608
+ def test_run_circuit_jit_nonlocal_ctrl_greater_than_tgt_ghz():
609
+ # The exact scenario the MPS ctrl/tgt inversion bug was found on
610
+ # earlier this session (H, CX(0,3), CX(3,1) non-adjacent+ctrl>tgt,
611
+ # CX(1,2)) -- must stay correct through the fused path too.
612
+ n = 4
613
+ mps = MPSSimulator(n_qubits=n, max_bond=16)
614
+ mps.run_circuit_jit([["h", 0], ["cx", 0, 3], ["cx", 3, 1], ["cx", 1, 2]])
615
+ sv = np.asarray(mps.contract_to_statevector())
616
+ exact = np.zeros(2 ** n, dtype=complex)
617
+ exact[0] = exact[-1] = 1 / np.sqrt(2)
618
+ fidelity = np.abs(np.vdot(sv, exact)) ** 2
619
+ assert fidelity == pytest.approx(1.0, abs=1e-9)
620
+
621
+
622
+ def test_run_circuit_jit_toffoli_matches_classical_truth_table():
623
+ mps = MPSSimulator(n_qubits=3, max_bond=8)
624
+ mps.run_circuit_jit([["x", 0], ["x", 1], ["ccx", 0, 1, 2]])
625
+ # MPSSimulator has no run_circuit_jit-native ccx decomposition -- use
626
+ # the transpiler's own, same source of truth compiler.py/chunk.py use.
627
+ prob = np.abs(np.asarray(mps.contract_to_statevector())) ** 2
628
+ assert prob[0b111] == pytest.approx(1.0, abs=1e-6)
629
+
630
+
631
+ @pytest.mark.parametrize("n_qubits,max_bond,jsd_budget,seed,layers", [
632
+ (12, 64, 1e-6, 5, 4), # generous max_bond -- exact match expected
633
+ (16, 32, 1e-5, 7, 3),
634
+ ])
635
+ def test_run_circuit_jit_matches_dense_simulator(n_qubits, max_bond, jsd_budget, seed, layers):
636
+ rng = np.random.default_rng(seed)
637
+ ops = [["h", q] for q in range(n_qubits)]
638
+ for _ in range(layers):
639
+ for q in range(0, n_qubits - 1, 2):
640
+ ops.append(["cx", q + 1, q])
641
+ for q in range(n_qubits):
642
+ ops.append(["rz", q, float(rng.uniform(0.1, 1.5))])
643
+ for q in range(1, n_qubits - 1, 2):
644
+ ops.append(["cx", q + 1, q])
645
+
646
+ sim_dense = de.DenseSVSimulator(n_qubits=n_qubits, use_gpu=False, use_float32=False)
647
+ sim_dense.run_circuit_jit_beast_mode(ops)
648
+ prob_dense = np.array(sim_dense.get_probabilities())
649
+
650
+ mps = MPSSimulator(n_qubits=n_qubits, max_bond=max_bond, jsd_budget=jsd_budget)
651
+ mps.run_circuit_jit(ops)
652
+ prob_mps = np.abs(np.asarray(mps.contract_to_statevector())) ** 2
653
+
654
+ tvd = 0.5 * np.sum(np.abs(prob_dense - prob_mps))
655
+ assert tvd < 1e-6, f"TVD={tvd} -- run_circuit_jit diverged from DenseSVSimulator"
656
+
657
+
658
+ def test_run_circuit_jit_matches_eager_even_under_budget_violation():
659
+ # max_bond=8 is deliberately too small for this circuit's real
660
+ # entanglement (triggers real budget_violations, same as
661
+ # test_budget_violations_flagged_when_max_bond_too_small above) --
662
+ # DenseSVSimulator is NOT the right correctness bar here (neither
663
+ # MPS path can match it at this max_bond, by construction, that's
664
+ # what "budget violation" means). What must hold: the fused path's
665
+ # lossy truncation must match the eager path's own lossy truncation
666
+ # almost exactly -- fusion must not introduce error *beyond* what the
667
+ # eager implementation itself already accepts at this max_bond.
668
+ n_qubits, max_bond, jsd_budget, seed, layers = 10, 8, 1e-3, 6, 5
669
+ rng = np.random.default_rng(seed)
670
+ ops = [["h", q] for q in range(n_qubits)]
671
+ for _ in range(layers):
672
+ for q in range(0, n_qubits - 1, 2):
673
+ ops.append(["cx", q + 1, q])
674
+ for q in range(n_qubits):
675
+ ops.append(["rz", q, float(rng.uniform(0.1, 1.5))])
676
+ for q in range(1, n_qubits - 1, 2):
677
+ ops.append(["cx", q + 1, q])
678
+
679
+ mps_eager = MPSSimulator(n_qubits=n_qubits, max_bond=max_bond, jsd_budget=jsd_budget)
680
+ rz_cache = {}
681
+ for op in ops:
682
+ if op[0] == "h":
683
+ mps_eager.apply_gate_1q(H_GATE, op[1])
684
+ elif op[0] == "cx":
685
+ mps_eager.apply_cx(op[1], op[2])
686
+ elif op[0] == "rz":
687
+ theta = op[2]
688
+ if theta not in rz_cache:
689
+ rz_cache[theta] = jnp.array(
690
+ [[jnp.exp(-1j * theta / 2), 0], [0, jnp.exp(1j * theta / 2)]], dtype=complex)
691
+ mps_eager.apply_gate_1q(rz_cache[theta], op[1])
692
+ assert mps_eager.budget_violations > 0, "test setup should genuinely stress max_bond"
693
+ sv_eager = np.asarray(mps_eager.contract_to_statevector())
694
+
695
+ mps_fused = MPSSimulator(n_qubits=n_qubits, max_bond=max_bond, jsd_budget=jsd_budget)
696
+ mps_fused.run_circuit_jit(ops)
697
+ sv_fused = np.asarray(mps_fused.contract_to_statevector())
698
+
699
+ fidelity = np.abs(np.vdot(sv_eager, sv_fused)) ** 2
700
+ assert fidelity == pytest.approx(1.0, abs=1e-9)
701
+
702
+
703
+ # ── bookkeeping parity: JIT-fusion groundwork, step 4 ────────────────────
704
+ # entanglement_entropy/_bond_history/jsd_per_bond/truncation_errors/
705
+ # budget_violations must end up populated the same way after
706
+ # run_circuit_jit as after the equivalent eager apply_gate_1q/apply_cx
707
+ # calls -- same information, populated from jax.lax.scan's stacked
708
+ # per-step diagnostics instead of Python list.append()s inside a loop.
709
+
710
+ def test_run_circuit_jit_bookkeeping_matches_eager():
711
+ n_qubits, max_bond, jsd_budget, seed, layers = 10, 8, 1e-3, 6, 5
712
+ rng = np.random.default_rng(seed)
713
+ ops = [["h", q] for q in range(n_qubits)]
714
+ for _ in range(layers):
715
+ for q in range(0, n_qubits - 1, 2):
716
+ ops.append(["cx", q + 1, q])
717
+ for q in range(n_qubits):
718
+ ops.append(["rz", q, float(rng.uniform(0.1, 1.5))])
719
+ for q in range(1, n_qubits - 1, 2):
720
+ ops.append(["cx", q + 1, q])
721
+
722
+ mps_eager = MPSSimulator(n_qubits=n_qubits, max_bond=max_bond, jsd_budget=jsd_budget)
723
+ rz_cache = {}
724
+ for op in ops:
725
+ if op[0] == "h":
726
+ mps_eager.apply_gate_1q(H_GATE, op[1])
727
+ elif op[0] == "cx":
728
+ mps_eager.apply_cx(op[1], op[2])
729
+ elif op[0] == "rz":
730
+ theta = op[2]
731
+ if theta not in rz_cache:
732
+ rz_cache[theta] = jnp.array(
733
+ [[jnp.exp(-1j * theta / 2), 0], [0, jnp.exp(1j * theta / 2)]], dtype=complex)
734
+ mps_eager.apply_gate_1q(rz_cache[theta], op[1])
735
+
736
+ mps_fused = MPSSimulator(n_qubits=n_qubits, max_bond=max_bond, jsd_budget=jsd_budget)
737
+ mps_fused.run_circuit_jit(ops)
738
+
739
+ assert mps_fused._bond_history == mps_eager._bond_history
740
+ assert mps_fused.budget_violations == mps_eager.budget_violations
741
+ assert len(mps_fused.jsd_per_bond) == len(mps_eager.jsd_per_bond)
742
+ for fused_v, eager_v in zip(mps_fused.jsd_per_bond, mps_eager.jsd_per_bond):
743
+ assert fused_v == pytest.approx(eager_v, abs=1e-9)
744
+ for fused_v, eager_v in zip(mps_fused.truncation_errors, mps_eager.truncation_errors):
745
+ assert fused_v == pytest.approx(eager_v, abs=1e-6)
746
+ assert np.max(np.abs(mps_fused.entanglement_entropy - mps_eager.entanglement_entropy)) < 1e-9
747
+ assert mps_eager.budget_violations > 0, "test setup should genuinely stress max_bond"
748
+
749
+
750
+ @pytest.mark.parametrize("use_float64", [False, True])
751
+ def test_run_circuit_jit_both_dtypes(use_float64):
752
+ previous = jax.config.jax_enable_x64
753
+ jax.config.update("jax_enable_x64", use_float64)
754
+ try:
755
+ mps = MPSSimulator(n_qubits=4, max_bond=8)
756
+ mps.run_circuit_jit([["h", 0], ["cx", 0, 3], ["cx", 3, 1], ["cx", 1, 2]])
757
+ sv = np.asarray(mps.contract_to_statevector())
758
+ exact = np.zeros(16, dtype=complex)
759
+ exact[0] = exact[-1] = 1 / np.sqrt(2)
760
+ fidelity = np.abs(np.vdot(sv, exact)) ** 2
761
+ assert fidelity == pytest.approx(1.0, abs=1e-6)
762
+ finally:
763
+ jax.config.update("jax_enable_x64", previous)
764
+
765
+
766
+ def test_run_circuit_jit_rejects_unknown_gate():
767
+ mps = MPSSimulator(n_qubits=2, max_bond=4)
768
+ with pytest.raises(ValueError):
769
+ mps.run_circuit_jit([["not_a_real_gate", 0]])
770
+