跳到主要内容

F 顺序数组修复

问题陈述

如果 NumPy 数组是 F 顺序的,其 Protobuf 编码/解码将失败

测试

from mcbackend.npproto.utils import ndarray_to_numpy, ndarray_from_numpy

arrC = numpy.array([
[1,2,3],
[4,5,6],
], order="C")

arrF = numpy.array([
[1,2,3],
[4,5,6],
], order="F")

assert arrC.strides != arrF.strides

numpy.testing.assert_array_equal(
arrC,
ndarray_to_numpy(ndarray_from_numpy(arrC)),
)
numpy.testing.assert_array_equal(
arrF,
ndarray_to_numpy(ndarray_from_numpy(arrF)),
)

代码差异

mcbackend/__init__.py

diff --git a/mcbackend/__init__.py b/mcbackend/__init__.py
index 35ef7b9..c6a43eb 100644
--- a/mcbackend/__init__.py
+++ b/mcbackend/__init__.py
@@ -1,23 +1,23 @@
"""
A framework agnostic implementation for storage of MCMC draws.
"""
from .backends.numpy import NumPyBackend
from .core import Backend, Chain, Run
from .meta import ChainMeta, Coordinate, DataVariable, ExtendedValue, RunMeta, Variable

# Backends
try:
from .backends import clickhouse
from .backends.clickhouse import ClickHouseBackend
except ModuleNotFoundError:
pass

# Adapters
try:
from .adapters import pymc
from .adapters.pymc import TraceBackend
except ModuleNotFoundError:
pass


-__version__ = "0.2.3"
+__version__ = "0.2.4"

mcbackend/npproto/utils.py

diff --git a/mcbackend/npproto/utils.py b/mcbackend/npproto/utils.py
index 27bc98c..8ae5396 100644
--- a/mcbackend/npproto/utils.py
+++ b/mcbackend/npproto/utils.py
@@ -1,40 +1,46 @@
"""
Helper functions such as converters between ``ndarray`` and ``Ndarray``.
"""
import numpy

from . import Ndarray


def ndarray_from_numpy(arr: numpy.ndarray) -> Ndarray:
dt = str(arr.dtype)
if "datetime64" in dt:
# datetime64 doesn't support the buffer protocol.
# See https://github.com/numpy/numpy/issues/4983
# This is a hack that automatically encodes it as int64.
arr = arr.astype("int64")
+ # With non-C-ordered arrays (e.g. Fortran-ordered) the underlying buffer
+ # does not match the strides of the original array anymore, because
+ # ``bytes(arr.data)`` always returns the data in C-order.
+ # Therefore, the array is made C-contiguous before extracting data and strides.
+ if not arr.flags.c_contiguous:
+ arr = numpy.ascontiguousarray(arr)
return Ndarray(
shape=list(arr.shape),
dtype=dt,
data=bytes(arr.data),
strides=list(arr.strides),
)


def ndarray_to_numpy(nda: Ndarray) -> numpy.ndarray:
arr: numpy.ndarray
if "datetime64" in nda.dtype:
# Backwards conversion: The data was stored as int64.
arr = numpy.ndarray(
buffer=nda.data,
shape=nda.shape,
dtype="int64",
strides=nda.strides,
).astype(nda.dtype)
else:
arr = numpy.ndarray(
buffer=nda.data,
shape=nda.shape,
dtype=numpy.dtype(nda.dtype),
strides=nda.strides,
)

mcbackend/test_npproto.py

diff --git a/mcbackend/test_npproto.py b/mcbackend/test_npproto.py
index fc88d29..fd88e7c 100644
--- a/mcbackend/test_npproto.py
+++ b/mcbackend/test_npproto.py
@@ -1,45 +1,48 @@
from datetime import datetime

import numpy
import pytest

from mcbackend import npproto
from mcbackend.npproto import utils


class TestUtils:
@pytest.mark.parametrize(
"arr",
[
numpy.arange(5),
numpy.random.uniform(size=(2, 3)),
numpy.array(5),
numpy.array(["hello", "world"]),
numpy.array([datetime(2020, 3, 4, 5, 6, 7, 8), datetime(2020, 3, 4, 5, 6, 7, 9)]),
numpy.array(
[datetime(2020, 3, 4, 5, 6, 7, 8), datetime(2020, 3, 4, 5, 6, 7, 9)],
dtype="datetime64",
),
numpy.array([(1, 2), (3, 2, 1)], dtype=object),
+ numpy.array([[1, 2, 3], [4, 5, 6]], order="C"),
+ numpy.array([[1, 2, 3], [4, 5, 6]], order="F"),
+ numpy.arange(12).reshape(3, 4)[::2, ::2],
],
)
def test_conversion(self, arr: numpy.ndarray):
nda = utils.ndarray_from_numpy(arr)
enc = bytes(nda)
dec = npproto.Ndarray().parse(enc)
assert isinstance(dec.data, bytes)
result = utils.ndarray_to_numpy(dec)
numpy.testing.assert_array_equal(result, arr)
pass

@pytest.mark.parametrize("shape", [(5,), (2, 3), (2, 3, 5), (5, 2, 1, 7)])
@pytest.mark.parametrize("order", "CF")
def test_byteorders(self, shape, order):
arr = numpy.arange(numpy.prod(shape)).reshape(shape, order=order)

nda = utils.ndarray_from_numpy(arr)
assert nda.order == "CF"[arr.flags.f_contiguous]

dec = utils.ndarray_to_numpy(nda)
numpy.testing.assert_array_equal(arr, dec)
pass

提交信息 (Commit message)

修复非连续 NumPy 数组的编码

`bytes(arr.data)` 总是以 C 顺序返回缓冲区内容,但
编码 Fortran 顺序(或其他非 C 连续)数组会导致
通过 `ndarray_from_numpy` 和 `ndarray_to_numpy` 往返时数据损坏。
当保存其原始步长 (strides) 时,数组会变得混乱。

在提取步长和数据之前,使用 `numpy.ascontiguousarray` 使数组变为 C 连续。
现在缓冲区和步长保持一致。

关闭 #2124

注意:版本 `0.2.4` 添加了针对 C 顺序、F 顺序和非连续数组的往返测试。

合并请求 (Pull request)

摘要

当源数组不是 C 连续(例如,Fortran 顺序)时,NumPy 数组的 Protobuf 往返会悄无声息地损坏数据。此修复确保所有内存布局在编码/解码循环中幸存下来。

arrF = numpy.array([[1, 2, 3], [4, 5, 6]], order="F")
numpy.testing.assert_array_equal(
arrF,
ndarray_to_numpy(ndarray_from_numpy(arrF)), # 在此修复前失败
)

根本原因

ndarray_from_numpy 中的编码路径同时存储输入数组的原始缓冲区和步长。问题在于两者不匹配:

  1. 编码bytes(arr.data) 复制底层内存缓冲区。对于非 C 连续数组,此副本会悄无声息地重新排序为 C 顺序
  2. 按原样存储步长:随附的 strides 以其原始(非 C)格式保存,因为没有对它们应用任何重新排序。
  3. 解码numpy.ndarray(buffer=..., strides=...) 通过用原始步长解释 C 顺序缓冲区来重建数组,这种不匹配会产生一个混乱的数组。

修复

在提取缓冲区和步长之前调用 numpy.ascontiguousarray,以便两者反映相同的内存布局:

  • mcbackend/npproto/utils.py:在提取 datastrides 之前使数组变为 C 连续。存储的步长现在始终与存储的缓冲区布局匹配。已经是 C 连续的数组不受影响。
  • mcbackend/test_npproto.py:为 C 顺序、F 顺序和非连续(切片)数组添加显式的往返测试用例。

测试

TestUtils.test_conversion 中的测试矩阵现在涵盖:

  • numpy.array([[1, 2, 3], [4, 5, 6]], order="C")
  • numpy.array([[1, 2, 3], [4, 5, 6]], order="F")
  • numpy.arange(12).reshape(3, 4)[::2, ::2](非连续视图)

所有现有的 dtype 情况(int、float、str、datetime、object)继续原样通过。

备注

  • 版本升至 0.2.4
  • ndarray_to_numpy 未更改;解码端不需要更改。

如何测试

  1. 运行 pytest mcbackend/test_npproto.py。三个新的参数化用例(C 顺序、F 顺序、非连续切片)应该通过。
  2. 验证所有预先存在的 dtype 用例(int、float、str、datetime、object)仍然原样通过。