Skip to content

Commit

Permalink
[0.46] Finalise support for Numpy 2.0 (#12189)
Browse files Browse the repository at this point in the history
* Finalise support for Numpy 2.0

This commit brings the Qiskit test suite to a passing state (with all
optionals installed) with Numpy 2.0.0b1, building on previous commits
that handled much of the rest of the changing requirements:

- gh-10890
- gh-10891
- gh-10892
- gh-10897
- gh-11023

Notably, this commit did not actually require a rebuild of Qiskit,
despite us compiling against Numpy; it seems to happen that the C API
stuff we use via `rust-numpy` (which loads the Numpy C extensions
dynamically during module initialisation) hasn't changed.

The main changes are:

- adapting to the changed `copy=None` and `copy=False` semantics in
  `array` and `asarray`.
- making sure all our implementers of `__array__` accept both `dtype`
  and `copy` arguments.

Co-authored-by: Lev S. Bishop <[email protected]>

* Update `__array__` methods for Numpy 2.0 compatibility

As of Numpy 2.0, implementers of `__array__` are expected and required
to have a signature

    def __array__(self, dtype=None, copy=None): ...

In Numpys before 2.0, the `copy` argument will never be passed, and the
expected signature was

    def __array__(self, dtype=None): ...

Because of this, we have latitude to set `copy` in our implementations
to anything we like if we're running against Numpy 1.x, but we should
default to `copy=None` if we're running against Numpy 2.0.

The semantics of the `copy` argument to `np.array` changed in Numpy 2.0.
Now, `copy=False` means "raise a `ValueError` if a copy is required" and
`copy=None` means "copy only if required".  In Numpy 1.x, `copy=False`
meant "copy only if required".  In _both_ Numpy 1.x and 2.0,
`ndarray.astype` takes a `copy` argument, and in both, `copy=False`
means "copy only if required".  In Numpy 2.0 only, `np.asarray` gained a
`copy` argument with the same semantics as the `np.array` copy argument
from Numpy 2.0.

Further, the semantics of the `__array__` method changed in Numpy 2.0,
particularly around copying.  Now, Numpy will assume that it can pass
`copy=True` and the implementer will handle this.  If `copy=False` is
given and a copy or calculation is required, then the implementer is
required to raise `ValueError`.  We have a few places where the
`__array__` method may (or always does) calculate the array, so in all
these, we must forbid `copy=False`.

With all this in mind: this PR sets up all our implementers of
`__array__` to either default to `copy=None` if they will never actually
need to _use_ the `copy` argument within themselves (except perhaps to
test if it was set by Numpy 2.0 to `False`, as Numpy 1.x will never set
it), or to a compatibility shim `_numpy_compat.COPY_ONLY_IF_NEEDED` if
they do naturally want to use it with those semantics.  The pattern

    def __array__(self, dtype=None, copy=_numpy_compat.COPY_ONLY_IF_NEEDED):
        dtype = self._array.dtype if dtype is None else dtype
        return np.array(self._array, dtype=dtype, copy=copy)

using `array` instead of `asarray` lets us achieve all the desired
behaviour between the interactions of `dtype` and `copy` in a way that
is compatible with both Numpy 1.x and 2.x.

---------

Co-authored-by: Lev S. Bishop <[email protected]>
Co-authored-by: Raynel Sanchez <[email protected]>
  • Loading branch information
3 people authored Jun 18, 2024
1 parent 86acb27 commit 44b3146
Show file tree
Hide file tree
Showing 42 changed files with 319 additions and 154 deletions.
1 change: 1 addition & 0 deletions qiskit/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
)

import qiskit._accelerate
import qiskit._numpy_compat

# Globally define compiled submodules. The normal import mechanism will not find compiled submodules
# in _accelerate because it relies on file paths, but PyO3 generates only one shared library file.
Expand Down
73 changes: 73 additions & 0 deletions qiskit/_numpy_compat.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
# This code is part of Qiskit.
#
# (C) Copyright IBM 2024.
#
# This code is licensed under the Apache License, Version 2.0. You may
# obtain a copy of this license in the LICENSE.txt file in the root directory
# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0.
#
# Any modifications or derivative works of this code must retain this
# copyright notice, and modified files need to carry a notice indicating
# that they have been altered from the originals.

"""Compatiblity helpers for the Numpy 1.x to 2.0 transition."""

import re
import typing
import warnings

import numpy as np

# This version pattern is taken from the pypa packaging project:
# https://github.com/pypa/packaging/blob/21.3/packaging/version.py#L223-L254 which is dual licensed
# Apache 2.0 and BSD see the source for the original authors and other details.
_VERSION_PATTERN = r"""
v?
(?:
(?:(?P<epoch>[0-9]+)!)? # epoch
(?P<release>[0-9]+(?:\.[0-9]+)*) # release segment
(?P<pre> # pre-release
[-_\.]?
(?P<pre_l>(a|b|c|rc|alpha|beta|pre|preview))
[-_\.]?
(?P<pre_n>[0-9]+)?
)?
(?P<post> # post release
(?:-(?P<post_n1>[0-9]+))
|
(?:
[-_\.]?
(?P<post_l>post|rev|r)
[-_\.]?
(?P<post_n2>[0-9]+)?
)
)?
(?P<dev> # dev release
[-_\.]?
(?P<dev_l>dev)
[-_\.]?
(?P<dev_n>[0-9]+)?
)?
)
(?:\+(?P<local>[a-z0-9]+(?:[-_\.][a-z0-9]+)*))? # local version
"""

VERSION = np.lib.NumpyVersion(np.__version__)
VERSION_PARTS: typing.Tuple[int, ...]
"""The numeric parts of the Numpy release version, e.g. ``(2, 0, 0)``. Does not include pre- or
post-release markers (e.g. ``rc1``)."""
if match := re.fullmatch(_VERSION_PATTERN, np.__version__, flags=re.VERBOSE | re.IGNORECASE):
# Assuming Numpy won't ever introduce epochs, and we don't care about pre/post markers.
VERSION_PARTS = tuple(int(x) for x in match["release"].split("."))
else:
# Just guess a version. We know all existing Numpys have good version strings, so the only way
# this should trigger is from a new or a dev version.
warnings.warn(
f"Unrecognized version string for Numpy: '{np.__version__}'. Assuming Numpy 2.0.",
RuntimeWarning,
)
VERSION_PARTS = (2, 0, 0)

COPY_ONLY_IF_NEEDED = None if VERSION_PARTS >= (2, 0, 0) else False
"""The sentinel value given to ``np.array`` and ``np.ndarray.astype`` (etc) to indicate that a copy
should be made only if required."""
1 change: 1 addition & 0 deletions qiskit/circuit/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -382,6 +382,7 @@
"""

from .exceptions import CircuitError
from . import _utils
from .quantumcircuit import QuantumCircuit
from .classicalregister import ClassicalRegister, Clbit
from .quantumregister import QuantumRegister, Qubit, AncillaRegister, AncillaQubit
Expand Down
23 changes: 16 additions & 7 deletions qiskit/circuit/_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
"""

import numpy

from qiskit import _numpy_compat
from qiskit.exceptions import QiskitError
from qiskit.circuit.exceptions import CircuitError
from .parametervector import ParameterVectorElement
Expand Down Expand Up @@ -116,8 +118,9 @@ def with_gate_array(base_array):
nonwritable = numpy.array(base_array, dtype=numpy.complex128)
nonwritable.setflags(write=False)

def __array__(_self, dtype=None):
return numpy.asarray(nonwritable, dtype=dtype)
def __array__(_self, dtype=None, copy=_numpy_compat.COPY_ONLY_IF_NEEDED):
dtype = nonwritable.dtype if dtype is None else dtype
return numpy.array(nonwritable, dtype=dtype, copy=copy)

def decorator(cls):
if hasattr(cls, "__array__"):
Expand Down Expand Up @@ -148,15 +151,21 @@ def matrix_for_control_state(state):
if cached_states is None:
nonwritables = [matrix_for_control_state(state) for state in range(2**num_ctrl_qubits)]

def __array__(self, dtype=None):
return numpy.asarray(nonwritables[self.ctrl_state], dtype=dtype)
def __array__(self, dtype=None, copy=_numpy_compat.COPY_ONLY_IF_NEEDED):
arr = nonwritables[self.ctrl_state]
dtype = arr.dtype if dtype is None else dtype
return numpy.array(arr, dtype=dtype, copy=copy)

else:
nonwritables = {state: matrix_for_control_state(state) for state in cached_states}

def __array__(self, dtype=None):
if (out := nonwritables.get(self.ctrl_state)) is not None:
return numpy.asarray(out, dtype=dtype)
def __array__(self, dtype=None, copy=_numpy_compat.COPY_ONLY_IF_NEEDED):
if (arr := nonwritables.get(self.ctrl_state)) is not None:
dtype = arr.dtype if dtype is None else dtype
return numpy.array(arr, dtype=dtype, copy=copy)

if copy is False and copy is not _numpy_compat.COPY_ONLY_IF_NEEDED:
raise ValueError("could not produce matrix without calculation")
return numpy.asarray(
_compute_control_matrix(base, num_ctrl_qubits, self.ctrl_state), dtype=dtype
)
Expand Down
6 changes: 2 additions & 4 deletions qiskit/circuit/delay.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,11 @@
from qiskit.circuit.exceptions import CircuitError
from qiskit.circuit.instruction import Instruction
from qiskit.circuit.gate import Gate
from qiskit.circuit import _utils
from qiskit.circuit.parameterexpression import ParameterExpression


@_utils.with_gate_array(np.eye(2, dtype=complex))
class Delay(Instruction):
"""Do nothing and just delay/wait/idle for a specified duration."""

Expand Down Expand Up @@ -49,10 +51,6 @@ def duration(self, duration):
"""Set the duration of this delay."""
self.params = [duration]

def __array__(self, dtype=None):
"""Return the identity matrix."""
return np.array([[1, 0], [0, 1]], dtype=dtype)

def to_matrix(self) -> np.ndarray:
"""Return a Numpy.array for the unitary matrix. This has been
added to enable simulation without making delay a full Gate type.
Expand Down
4 changes: 2 additions & 2 deletions qiskit/circuit/library/generalized_gates/pauli.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,13 +63,13 @@ def inverse(self):
r"""Return inverted pauli gate (itself)."""
return PauliGate(self.params[0]) # self-inverse

def __array__(self, dtype=None):
def __array__(self, dtype=None, copy=None):
"""Return a Numpy.array for the pauli gate.
i.e. tensor product of the paulis"""
# pylint: disable=cyclic-import
from qiskit.quantum_info.operators import Pauli

return Pauli(self.params[0]).__array__(dtype=dtype)
return Pauli(self.params[0]).__array__(dtype=dtype, copy=copy)

def validate_parameter(self, parameter):
if isinstance(parameter, str):
Expand Down
5 changes: 4 additions & 1 deletion qiskit/circuit/library/generalized_gates/permutation.py
Original file line number Diff line number Diff line change
Expand Up @@ -147,8 +147,11 @@ def __init__(

super().__init__(name="permutation", num_qubits=num_qubits, params=[pattern])

def __array__(self, dtype=None):
def __array__(self, dtype=None, copy=None):
"""Return a numpy.array for the Permutation gate."""
if copy is False:
raise ValueError("cannot produce matrix without calculation")

nq = len(self.pattern)
mat = np.zeros((2**nq, 2**nq), dtype=dtype)

Expand Down
7 changes: 4 additions & 3 deletions qiskit/circuit/library/generalized_gates/unitary.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
import typing
import numpy

from qiskit import _numpy_compat
from qiskit.circuit.gate import Gate
from qiskit.circuit.controlledgate import ControlledGate
from qiskit.circuit.quantumcircuit import QuantumCircuit
Expand Down Expand Up @@ -115,10 +116,10 @@ def __eq__(self, other):
# up to global phase?
return matrix_equal(self.params[0], other.params[0], ignore_phase=True)

def __array__(self, dtype=None):
def __array__(self, dtype=None, copy=_numpy_compat.COPY_ONLY_IF_NEEDED):
"""Return matrix for the unitary."""
# pylint: disable=unused-argument
return self.params[0]
dtype = self.params[0].dtype if dtype is None else dtype
return numpy.array(self.params[0], dtype=dtype, copy=copy)

def inverse(self):
"""Return the adjoint of the unitary."""
Expand Down
11 changes: 8 additions & 3 deletions qiskit/circuit/library/hamiltonian_gate.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
from numbers import Number
import numpy as np

from qiskit import _numpy_compat
from qiskit.circuit.gate import Gate
from qiskit.circuit.quantumcircuit import QuantumCircuit
from qiskit.circuit.quantumregister import QuantumRegister
Expand Down Expand Up @@ -92,18 +93,22 @@ def __eq__(self, other):
times_eq = self.params[1] == other.params[1]
return operators_eq and times_eq

def __array__(self, dtype=None):
def __array__(self, dtype=None, copy=None):
"""Return matrix for the unitary."""
# pylint: disable=unused-argument
import scipy.linalg

if copy is False:
raise ValueError("cannot produce matrix without calculation")
try:
return scipy.linalg.expm(-1j * self.params[0] * float(self.params[1]))
time = float(self.params[1])
except TypeError as ex:
raise TypeError(
"Unable to generate Unitary matrix for "
"unbound t parameter {}".format(self.params[1])
) from ex
arr = scipy.linalg.expm(-1j * self.params[0] * time)
dtype = complex if dtype is None else dtype
return np.array(arr, dtype=dtype, copy=_numpy_compat.COPY_ONLY_IF_NEEDED)

def inverse(self):
"""Return the adjoint of the unitary."""
Expand Down
6 changes: 4 additions & 2 deletions qiskit/circuit/library/standard_gates/global_phase.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,9 @@ def inverse(self):
"""
return GlobalPhaseGate(-self.params[0])

def __array__(self, dtype=complex):
def __array__(self, dtype=None, copy=None):
"""Return a numpy.array for the global_phase gate."""
if copy is False:
raise ValueError("cannot produce matrix without calculation")
theta = self.params[0]
return numpy.array([[numpy.exp(1j * theta)]], dtype=dtype)
return numpy.array([[numpy.exp(1j * theta)]], dtype=dtype or complex)
8 changes: 6 additions & 2 deletions qiskit/circuit/library/standard_gates/p.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,8 +123,10 @@ def inverse(self):
r"""Return inverted Phase gate (:math:`Phase(\lambda)^{\dagger} = Phase(-\lambda)`)"""
return PhaseGate(-self.params[0])

def __array__(self, dtype=None):
def __array__(self, dtype=None, copy=None):
"""Return a numpy.array for the Phase gate."""
if copy is False:
raise ValueError("cannot produce matrix without calculation")
lam = float(self.params[0])
return numpy.array([[1, 0], [0, exp(1j * lam)]], dtype=dtype)

Expand Down Expand Up @@ -249,8 +251,10 @@ def inverse(self):
r"""Return inverted CPhase gate (:math:`CPhase(\lambda)^{\dagger} = CPhase(-\lambda)`)"""
return CPhaseGate(-self.params[0], ctrl_state=self.ctrl_state)

def __array__(self, dtype=None):
def __array__(self, dtype=None, copy=None):
"""Return a numpy.array for the CPhase gate."""
if copy is False:
raise ValueError("cannot produce matrix without calculation")
eith = exp(1j * float(self.params[0]))
if self.ctrl_state:
return numpy.array(
Expand Down
4 changes: 3 additions & 1 deletion qiskit/circuit/library/standard_gates/r.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,8 +86,10 @@ def inverse(self):
"""
return RGate(-self.params[0], self.params[1])

def __array__(self, dtype=None):
def __array__(self, dtype=None, copy=None):
"""Return a numpy.array for the R gate."""
if copy is False:
raise ValueError("cannot produce matrix without calculation")
theta, phi = float(self.params[0]), float(self.params[1])
cos = math.cos(theta / 2)
sin = math.sin(theta / 2)
Expand Down
8 changes: 6 additions & 2 deletions qiskit/circuit/library/standard_gates/rx.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,8 +102,10 @@ def inverse(self):
"""
return RXGate(-self.params[0])

def __array__(self, dtype=None):
def __array__(self, dtype=None, copy=None):
"""Return a numpy.array for the RX gate."""
if copy is False:
raise ValueError("cannot produce matrix without calculation")
cos = math.cos(self.params[0] / 2)
sin = math.sin(self.params[0] / 2)
return numpy.array([[cos, -1j * sin], [-1j * sin, cos]], dtype=dtype)
Expand Down Expand Up @@ -231,8 +233,10 @@ def inverse(self):
"""Return inverse CRX gate (i.e. with the negative rotation angle)."""
return CRXGate(-self.params[0], ctrl_state=self.ctrl_state)

def __array__(self, dtype=None):
def __array__(self, dtype=None, copy=None):
"""Return a numpy.array for the CRX gate."""
if copy is False:
raise ValueError("cannot produce matrix without calculation")
half_theta = float(self.params[0]) / 2
cos = math.cos(half_theta)
isin = 1j * math.sin(half_theta)
Expand Down
4 changes: 3 additions & 1 deletion qiskit/circuit/library/standard_gates/rxx.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,8 +112,10 @@ def inverse(self):
"""Return inverse RXX gate (i.e. with the negative rotation angle)."""
return RXXGate(-self.params[0])

def __array__(self, dtype=None):
def __array__(self, dtype=None, copy=None):
"""Return a Numpy.array for the RXX gate."""
if copy is False:
raise ValueError("cannot produce matrix without calculation")
theta2 = float(self.params[0]) / 2
cos = math.cos(theta2)
isin = 1j * math.sin(theta2)
Expand Down
8 changes: 6 additions & 2 deletions qiskit/circuit/library/standard_gates/ry.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,8 +101,10 @@ def inverse(self):
"""
return RYGate(-self.params[0])

def __array__(self, dtype=None):
def __array__(self, dtype=None, copy=None):
"""Return a numpy.array for the RY gate."""
if copy is False:
raise ValueError("cannot produce matrix without calculation")
cos = math.cos(self.params[0] / 2)
sin = math.sin(self.params[0] / 2)
return numpy.array([[cos, -sin], [sin, cos]], dtype=dtype)
Expand Down Expand Up @@ -226,8 +228,10 @@ def inverse(self):
"""Return inverse CRY gate (i.e. with the negative rotation angle)."""
return CRYGate(-self.params[0], ctrl_state=self.ctrl_state)

def __array__(self, dtype=None):
def __array__(self, dtype=None, copy=None):
"""Return a numpy.array for the CRY gate."""
if copy is False:
raise ValueError("cannot produce matrix without calculation")
half_theta = float(self.params[0]) / 2
cos = math.cos(half_theta)
sin = math.sin(half_theta)
Expand Down
4 changes: 3 additions & 1 deletion qiskit/circuit/library/standard_gates/ryy.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,8 +112,10 @@ def inverse(self):
"""Return inverse RYY gate (i.e. with the negative rotation angle)."""
return RYYGate(-self.params[0])

def __array__(self, dtype=None):
def __array__(self, dtype=None, copy=None):
"""Return a numpy.array for the RYY gate."""
if copy is False:
raise ValueError("cannot produce matrix without calculation")
theta = float(self.params[0])
cos = math.cos(theta / 2)
isin = 1j * math.sin(theta / 2)
Expand Down
8 changes: 6 additions & 2 deletions qiskit/circuit/library/standard_gates/rz.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,10 +112,12 @@ def inverse(self):
"""
return RZGate(-self.params[0])

def __array__(self, dtype=None):
def __array__(self, dtype=None, copy=None):
"""Return a numpy.array for the RZ gate."""
import numpy as np

if copy is False:
raise ValueError("cannot produce matrix without calculation")
ilam2 = 0.5j * float(self.params[0])
return np.array([[exp(-ilam2), 0], [0, exp(ilam2)]], dtype=dtype)

Expand Down Expand Up @@ -244,10 +246,12 @@ def inverse(self):
"""Return inverse CRZ gate (i.e. with the negative rotation angle)."""
return CRZGate(-self.params[0], ctrl_state=self.ctrl_state)

def __array__(self, dtype=None):
def __array__(self, dtype=None, copy=None):
"""Return a numpy.array for the CRZ gate."""
import numpy

if copy is False:
raise ValueError("cannot produce matrix without calculation")
arg = 1j * float(self.params[0]) / 2
if self.ctrl_state:
return numpy.array(
Expand Down
Loading

0 comments on commit 44b3146

Please sign in to comment.