This is an automated email from the ASF dual-hosted git repository.
pitrou pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/arrow.git
The following commit(s) were added to refs/heads/main by this push:
new 39706ed0617 GH-39295: [C++][Python] ConsumingDLPack on Arrays and
Tensor (#51122)
39706ed0617 is described below
commit 39706ed0617da98351f98797048ca228c4f5c15e
Author: Antoine Prouvost <[email protected]>
AuthorDate: Thu Sep 10 17:04:57 2026 +0200
GH-39295: [C++][Python] ConsumingDLPack on Arrays and Tensor (#51122)
### Rationale for this change
There are already utilities to import tensor data from NumPy.
With DLPack standard, it will work across many tensor providers.
### What changes are included in this PR?
- Bind `FixedShapedTensorArray::FromTensor` in Python
- Add `ImportArrayVersionedFromDLPack` and
`ImportTensorVersionedFromDLPack` in C++
- Add in Python `Array.from_dlpack` and `Tensor.from_dlpack`
Similar to the export, multidimensional tensor import require an
explicit step through `Tensor`.
- No `Array::FromTensor` or even `FixedSizeListArray::FromTensor`. IMHO it
does not feel as necessary in this direction but open to anyone's take one it.
### Are these changes tested?
Yes.
### Are there any user-facing changes?
Yes, new APIs.
* GitHub Issue: #39295
Lead-authored-by: AntoinePrv <[email protected]>
Co-authored-by: Antoine Pitrou <[email protected]>
Signed-off-by: Antoine Pitrou <[email protected]>
---
cpp/src/arrow/buffer.cc | 19 +-
cpp/src/arrow/buffer.h | 67 ++++-
cpp/src/arrow/buffer_test.cc | 2 +-
cpp/src/arrow/c/dlpack.cc | 277 +++++++++++++++++++-
cpp/src/arrow/c/dlpack.h | 25 ++
cpp/src/arrow/c/dlpack_test.cc | 386 ++++++++++++++++++++++++++++
cpp/src/arrow/tensor.cc | 55 +++-
cpp/src/arrow/tensor.h | 9 +
docs/source/python/dlpack.rst | 124 ++++++++-
python/pyarrow/array.pxi | 116 ++++++++-
python/pyarrow/includes/libarrow.pxd | 20 ++
python/pyarrow/tensor.pxi | 53 +++-
python/pyarrow/tests/test_dlpack.py | 130 ++++++++++
python/pyarrow/tests/test_extension_type.py | 31 +++
14 files changed, 1259 insertions(+), 55 deletions(-)
diff --git a/cpp/src/arrow/buffer.cc b/cpp/src/arrow/buffer.cc
index 17e74520464..9abe41b4c3a 100644
--- a/cpp/src/arrow/buffer.cc
+++ b/cpp/src/arrow/buffer.cc
@@ -148,22 +148,13 @@ Result<std::shared_ptr<Buffer>> Buffer::ViewOrCopy(
return MemoryManager::CopyBuffer(source, to);
}
-class StlStringBuffer : public Buffer {
- public:
- explicit StlStringBuffer(std::string data) : input_(std::move(data)) {
- if (!input_.empty()) {
- data_ = reinterpret_cast<const uint8_t*>(input_.c_str());
- size_ = static_cast<int64_t>(input_.size());
- capacity_ = size_;
- }
+std::shared_ptr<Buffer> Buffer::FromString(std::string data) {
+ if (data.empty()) {
+ return std::shared_ptr<Buffer>{new Buffer()};
}
- private:
- std::string input_;
-};
-
-std::shared_ptr<Buffer> Buffer::FromString(std::string data) {
- return std::make_shared<StlStringBuffer>(std::move(data));
+ auto size_in_bytes = static_cast<int64_t>(data.size());
+ return TakeOwnership(std::move(data), size_in_bytes);
}
std::shared_ptr<Buffer> SliceMutableBuffer(std::shared_ptr<Buffer> buffer,
diff --git a/cpp/src/arrow/buffer.h b/cpp/src/arrow/buffer.h
index 07f9931eba7..388829eba05 100644
--- a/cpp/src/arrow/buffer.h
+++ b/cpp/src/arrow/buffer.h
@@ -24,6 +24,7 @@
#include <span>
#include <string>
#include <string_view>
+#include <type_traits>
#include <utility>
#include <vector>
@@ -50,6 +51,15 @@ namespace arrow {
///
/// The following invariant is always true: Size <= Capacity
class ARROW_EXPORT Buffer {
+ private:
+ /// \brief Default data accessor used by TakeOwnership.
+ struct DefaultGetData {
+ template <typename T>
+ auto* operator()(T& container) const {
+ return container.data();
+ }
+ };
+
public:
ARROW_DISALLOW_COPY_AND_ASSIGN(Buffer);
@@ -146,14 +156,58 @@ class ARROW_EXPORT Buffer {
}
}
- /// \brief Construct an immutable buffer that takes ownership of the contents
+ /// \brief Construct a buffer that takes ownership of a container.
+ ///
+ /// This operation does not make a copy. If the underlying container is
mutable (as
+ /// detected by the return type of `get_data`) then returned buffer will be
mutable.
+ ///
+ /// \param[in] container The container to own. The container must own its
data as a
+ /// contiguous slice. That data does not need to remain at a
stable address
+ /// across a container move.
+ /// \param[in] nbytes The size of the data, which must not exceed the number
of bytes
+ /// readable from the pointer returned by \p get_data
+ /// \param[in] get_data Callable returning the address of the container's
data. This
+ /// callable is invoked *after* the container has been moved to
its final
+ /// address, to work with types such as `std::string`.
+ /// \return a new Buffer instance
+ template <typename T, typename Func = DefaultGetData>
+ static auto TakeOwnership(T container, int64_t nbytes, Func&& get_data = {})
{
+ using DataPtr = decltype(std::forward<Func>(get_data)(container));
+ constexpr bool kIsMutable =
!std::is_const_v<std::remove_pointer_t<DataPtr>>;
+ using BufferType = std::conditional_t<kIsMutable, MutableBuffer, Buffer>;
+ using Byte = std::conditional_t<kIsMutable, uint8_t, const uint8_t>;
+
+ // Hold the container and the Buffer in a single allocation. Declaration
order
+ // matters: the container is constructed first and destroyed last, so the
Buffer
+ // never outlives the memory it points into.
+ struct ControlBlock {
+ T container;
+ BufferType buffer;
+
+ ControlBlock(T container, int64_t nbytes, Func&& get_data)
+ : container(std::move(container)),
+ // Read the data pointer only once the container has reached its
final
+ // address, since moving it may invalidate the pointer (e.g. in a
small
+ // string optimization).
+
buffer(reinterpret_cast<Byte*>(std::forward<Func>(get_data)(this->container)),
+ nbytes) {}
+ };
+
+ auto owner = std::make_shared<ControlBlock>(std::move(container), nbytes,
+ std::forward<Func>(get_data));
+ // Aliasing constructor
+ auto* buffer = &owner->buffer;
+ return std::shared_ptr<BufferType>{std::move(owner), buffer};
+ }
+
+ /// \brief Construct a mutable buffer that takes ownership of the contents
/// of an std::string (without copying it).
///
/// \param[in] data a string to own
/// \return a new Buffer instance
static std::shared_ptr<Buffer> FromString(std::string data);
- /// \brief Construct an immutable buffer that takes ownership of the contents
+ /// \brief Construct a mutable buffer that takes ownership of the contents
/// of an std::vector (without copying it). Only vectors of TrivialType
objects
/// (integers, floating point numbers, ...) can be wrapped by this function.
///
@@ -168,15 +222,8 @@ class ARROW_EXPORT Buffer {
return std::shared_ptr<Buffer>{new Buffer()};
}
- auto* data = reinterpret_cast<uint8_t*>(vec.data());
auto size_in_bytes = static_cast<int64_t>(vec.size() * sizeof(T));
- return std::shared_ptr<Buffer>{
- new Buffer{data, size_in_bytes},
- // Keep the vector's buffer alive inside the shared_ptr's destructor
until after
- // we have deleted the Buffer. Note we can't use this trick in
FromString since
- // std::string's data is inline for short strings so moving
invalidates pointers
- // into the string's buffer.
- [vec = std::move(vec)](Buffer* buffer) { delete buffer; }};
+ return TakeOwnership(std::move(vec), size_in_bytes);
}
/// \brief Create buffer referencing typed memory with some length without
diff --git a/cpp/src/arrow/buffer_test.cc b/cpp/src/arrow/buffer_test.cc
index 4dd210076ed..bdda0b4a747 100644
--- a/cpp/src/arrow/buffer_test.cc
+++ b/cpp/src/arrow/buffer_test.cc
@@ -586,7 +586,7 @@ TEST(TestBuffer, FromStringRvalue) {
AssertIsCPUBuffer(*buffer);
}
- ASSERT_FALSE(buffer->is_mutable());
+ ASSERT_TRUE(buffer->is_mutable());
ASSERT_EQ(0, memcmp(buffer->data(), expected.c_str(), expected.size()));
ASSERT_EQ(static_cast<int64_t>(expected.size()), buffer->size());
diff --git a/cpp/src/arrow/c/dlpack.cc b/cpp/src/arrow/c/dlpack.cc
index 4e25d50bb56..1b58b5a503c 100644
--- a/cpp/src/arrow/c/dlpack.cc
+++ b/cpp/src/arrow/c/dlpack.cc
@@ -19,21 +19,35 @@
#include <array>
#include <memory>
+#include <numeric>
#include <type_traits>
#include <vector>
#include "arrow/array/array_base.h"
+#include "arrow/array/util.h"
#include "arrow/buffer.h"
#include "arrow/c/dlpack_abi.h"
#include "arrow/device.h"
#include "arrow/tensor.h"
#include "arrow/type.h"
#include "arrow/type_traits.h"
+#include "arrow/util/int_util_overflow.h"
+#include "arrow/util/logging_internal.h"
+#include "arrow/util/macros.h"
namespace arrow::dlpack {
+extern const DLPackVersion kVersion = {
+ .major = DLPACK_MAJOR_VERSION,
+ .minor = DLPACK_MINOR_VERSION,
+};
+
namespace {
+/***************
+ * Producers *
+ ***************/
+
Result<DLDataType> GetDLDataType(const DataType& type) {
auto dtype = DLDataType{};
dtype.lanes = 1;
@@ -108,7 +122,7 @@ DT* ExportBuffer(ExportBufferParams<Vec>&& p) {
// Strides must be non-null when ndim > 0
ctx->tensor.dl_tensor.strides = ctx->strides.data();
if constexpr (std::is_same_v<DT, DLManagedTensorVersioned>) {
- ctx->tensor.version = {.major = DLPACK_MAJOR_VERSION, .minor =
DLPACK_MINOR_VERSION};
+ ctx->tensor.version = kVersion;
ctx->tensor.flags = p.flags;
}
@@ -248,4 +262,265 @@ Result<DLDevice> ExportDevice(const
std::shared_ptr<Tensor>& t) {
return ExportDeviceImpl(t);
}
+/***************
+ * Consumers *
+ ***************/
+
+namespace {
+
+class CppDLTensor {
+ public:
+ using value_type = DLManagedTensorVersioned;
+ using pointer_type = value_type*;
+
+ static Result<CppDLTensor> TakeOwnership(pointer_type ptr) {
+ if (ARROW_PREDICT_FALSE(ptr == nullptr)) {
+ return Status::Invalid("Received null pointer.");
+ }
+ // Create the wrapper before checking the version as the spec mandates
that the
+ // deleter MUST be called on version major mismatch.
+ auto out = CppDLTensor(ptr);
+ if (ARROW_PREDICT_FALSE(out.ptr_->version.major != kVersion.major)) {
+ return Status::Invalid("Unsupported DLPack major version ",
out.ptr_->version.major,
+ ", expected ", kVersion.major);
+ }
+ if (ARROW_PREDICT_FALSE(out.tensor().ndim < 0)) {
+ return Status::Invalid("Invalid DLPack tensor: ndim must be >= 0");
+ }
+ if (ARROW_PREDICT_FALSE(out.tensor().ndim != 0 && out.tensor().shape ==
nullptr)) {
+ return Status::Invalid(
+ "Invalid DLPack tensor: shape must be non-null when ndim != 0");
+ }
+ // Null strides are handled as row major
+ return out;
+ }
+
+ const DLTensor& tensor() const { return ptr_->dl_tensor; }
+
+ int64_t ndim() const {
+ DCHECK_GE(tensor().ndim, 0);
+ return tensor().ndim;
+ }
+
+ template <typename T>
+ T* data_as() {
+ return static_cast<T*>(tensor().data);
+ }
+
+ std::span<const int64_t> shape() const {
+ return {tensor().shape, static_cast<std::size_t>(ndim())};
+ }
+
+ /// Strides or empty span for old DLPack row-major convention.
+ std::span<const int64_t> strides() const {
+ if (auto strides = tensor().strides; strides != nullptr) {
+ return {strides, static_cast<std::size_t>(ndim())};
+ }
+ return {};
+ }
+
+ bool flag_is_set(uint8_t bits) const { return (ptr_->flags & bits) == bits; }
+
+ bool is_readonly() const { return
flag_is_set(DLPACK_FLAG_BITMASK_READ_ONLY); }
+
+ int32_t byte_width() const { return tensor().dtype.bits / 8; }
+
+ /// Number of element in this tensor's buffer.
+ ///
+ /// Possibly more elements than represented in the tensor for non-contiguous
tensors.
+ ///
+ /// A zero dimensional tensor is a scalar, it holds a single element.
+ Result<int64_t> ComputeNumElements() const {
+ const auto strides = this->strides();
+ const auto shape = this->shape();
+ if (strides.size() > 0) {
+ // DLPack strides are in number of elements, so is the size we compute
from them.
+ return internal::ComputeTensorSize(shape, strides, 1);
+ }
+ // DLPack <1.3 may set strides == nullptr for row major
+ return std::reduce(shape.begin(), shape.end(), int64_t{1},
std::multiplies{});
+ }
+
+ /// Number of bytes needed to store this tensor data.
+ Result<int64_t> ComputeNumBytes() const {
+ ARROW_ASSIGN_OR_RAISE(const auto nelements, ComputeNumElements());
+ int64_t nbytes = 0;
+ if (ARROW_PREDICT_FALSE(internal::MultiplyWithOverflow(
+ nelements, static_cast<int64_t>(byte_width()), &nbytes))) {
+ return Status::Invalid("Overflow computing DLPack tensor size in
bytes.");
+ }
+ return nbytes;
+ }
+
+ private:
+ struct Deleter {
+ void operator()(pointer_type ptr) {
+ // Null is valid in DLPack spec
+ if (auto del = ptr->deleter) {
+ del(ptr);
+ }
+ }
+ };
+
+ /// Make a safe wrapper that will delete the resource in case of exception.
+ std::unique_ptr<value_type, Deleter> ptr_;
+
+ explicit CppDLTensor(pointer_type ptr) : ptr_(ptr) {}
+};
+
+Result<std::shared_ptr<FixedWidthType>> DataTypeFromDLPack(DLDataType dtype) {
+ if (dtype.lanes != 1) {
+ return Status::TypeError("Only type with one lane are supported.");
+ }
+
+ auto constexpr as_fw = [](auto dt) {
+ return std::static_pointer_cast<FixedWidthType>(std::move(dt));
+ };
+
+ switch (dtype.code) {
+ case kDLInt: {
+ switch (dtype.bits) {
+ case 8:
+ return as_fw(int8());
+ case 16:
+ return as_fw(int16());
+ case 32:
+ return as_fw(int32());
+ case 64:
+ return as_fw(int64());
+ default:
+ return Status::Invalid("unsupported integer bit width ",
+ static_cast<int>(dtype.bits));
+ }
+ }
+ case kDLUInt: {
+ switch (dtype.bits) {
+ case 8:
+ return as_fw(uint8());
+ case 16:
+ return as_fw(uint16());
+ case 32:
+ return as_fw(uint32());
+ case 64:
+ return as_fw(uint64());
+ default:
+ return Status::Invalid("unsupported unsigned integer bit width ",
+ static_cast<int>(dtype.bits));
+ }
+ }
+ case kDLFloat: {
+ switch (dtype.bits) {
+ case 16:
+ return as_fw(float16());
+ case 32:
+ return as_fw(float32());
+ case 64:
+ return as_fw(float64());
+ default:
+ return Status::Invalid("unsupported float bit width ",
+ static_cast<int>(dtype.bits));
+ }
+ }
+ default: {
+ return Status::Invalid("unsupported DLPack type ",
static_cast<int>(dtype.code));
+ }
+ }
+}
+
+Result<std::vector<int64_t>> StridesInBytes(std::span<const int64_t> strides,
+ int64_t byte_width) {
+ std::vector<int64_t> out{};
+ out.reserve(strides.size());
+ for (const auto& s : strides) {
+ int64_t stride_bytes = 0;
+ if (ARROW_PREDICT_FALSE(
+ internal::MultiplyWithOverflow(s, byte_width, &stride_bytes))) {
+ return Status::Invalid("Overflow computing DLPack tensor stride in
bytes.");
+ }
+ out.push_back(stride_bytes);
+ }
+ return out;
+}
+
+Result<std::shared_ptr<Buffer>> ImportBuffer(CppDLTensor&& dl) {
+ ARROW_ASSIGN_OR_RAISE(const int64_t nbytes, dl.ComputeNumBytes());
+
+ // DLPack mandates a null data pointer when the tensor holds no element, so
there is
+ // neither anything to share nor to copy.
+ uint8_t* data =
+ (nbytes == 0) ? nullptr : dl.data_as<uint8_t>() +
dl.tensor().byte_offset;
+
+ std::shared_ptr<Buffer> buffer = nullptr;
+ if (nbytes == 0) {
+ // DLPack data pointer may be null on empty tensors
+ buffer = std::make_shared<Buffer>(data, nbytes);
+ } else if (const auto byte_offset = dl.tensor().byte_offset;
dl.is_readonly()) {
+ auto get_data = [byte_offset](auto& d) {
+ return d.template data_as<const uint8_t>() + byte_offset;
+ };
+ buffer = Buffer::TakeOwnership(std::move(dl), nbytes, get_data);
+ ARROW_DCHECK(!buffer->is_mutable());
+ } else {
+ auto get_data = [byte_offset](auto& d) {
+ return d.template data_as<uint8_t>() + byte_offset;
+ };
+ buffer = Buffer::TakeOwnership(std::move(dl), nbytes, get_data);
+ ARROW_DCHECK(buffer->is_mutable());
+ }
+
+ return buffer;
+}
+
+} // namespace
+
+Result<std::shared_ptr<Array>> ImportArrayVersioned(DLManagedTensorVersioned*
unmanaged) {
+ ARROW_ASSIGN_OR_RAISE(auto dl, CppDLTensor::TakeOwnership(unmanaged));
+
+ if (dl.tensor().device.device_type != kDLCPU) {
+ return Status::NotImplemented(
+ "DLPack support is implemented only for buffers on CPU device.");
+ }
+
+ const auto strides = dl.strides();
+ if (dl.ndim() != 1 || (!strides.empty() && strides.front() != 1)) {
+ return Status::Invalid(
+ "Only contiguous one dimensional tensor can be imported as arrays."
+ " Try importing to Tensor first.");
+ }
+
+ ARROW_ASSIGN_OR_RAISE(auto type, DataTypeFromDLPack(dl.tensor().dtype));
+ const auto nelements = dl.shape().front();
+ ARROW_ASSIGN_OR_RAISE(auto buffer, ImportBuffer(std::move(dl)));
+ auto data = ArrayData::Make(type, nelements, {nullptr, std::move(buffer)});
+ return MakeArray(std::move(data));
+}
+
+Result<std::shared_ptr<Tensor>> ImportTensorVersioned(
+ DLManagedTensorVersioned* unmanaged) {
+ ARROW_ASSIGN_OR_RAISE(auto dl, CppDLTensor::TakeOwnership(unmanaged));
+
+ if (dl.tensor().device.device_type != kDLCPU) {
+ return Status::NotImplemented(
+ "DLPack support is implemented only for buffers on CPU device.");
+ }
+
+ ARROW_ASSIGN_OR_RAISE(auto type, DataTypeFromDLPack(dl.tensor().dtype));
+ auto shape = std::vector<int64_t>(dl.shape().begin(), dl.shape().end());
+ const auto strides = dl.strides();
+
+ ARROW_ASSIGN_OR_RAISE(auto buffer, ImportBuffer(std::move(dl)));
+ const auto byte_width = type->byte_width();
+
+ // In older DLPack null strides means row major, same as in Arrow.
+ if (strides.empty()) {
+ return Tensor::Make(std::move(type), std::move(buffer), std::move(shape));
+ }
+
+ ARROW_ASSIGN_OR_RAISE(
+ auto strides_bytes,
+ StridesInBytes(std::vector<int64_t>(strides.begin(), strides.end()),
byte_width));
+ return Tensor::Make(std::move(type), std::move(buffer), std::move(shape),
+ std::move(strides_bytes));
+}
+
} // namespace arrow::dlpack
diff --git a/cpp/src/arrow/c/dlpack.h b/cpp/src/arrow/c/dlpack.h
index 8a9084f36c7..29738dff6d1 100644
--- a/cpp/src/arrow/c/dlpack.h
+++ b/cpp/src/arrow/c/dlpack.h
@@ -25,6 +25,9 @@
namespace arrow::dlpack {
+/// The DLPack version used during compilation.
+ARROW_EXPORT extern const DLPackVersion kVersion;
+
/// \brief Export Arrow array as DLPack tensor.
///
/// DLMangedTensor is produced as defined by the DLPack protocol,
@@ -105,4 +108,26 @@ Result<DLDevice> ExportDevice(const
std::shared_ptr<Array>& arr);
ARROW_EXPORT
Result<DLDevice> ExportDevice(const std::shared_ptr<Tensor>& t);
+/// \brief Import a DLPack tensor as an Arrow Array.
+///
+/// Same restrictions on data types as `ExportArrayVersioned`, only row-major
+/// tensors are supported. Takes ownership of the `DLManagedTensorVersioned` in
+/// an error-safe fashion.
+///
+/// \param[in] raw DLPack tensor
+/// \return An Arrow Array
+ARROW_EXPORT
+Result<std::shared_ptr<Array>> ImportArrayVersioned(DLManagedTensorVersioned*
raw);
+
+/// \brief Import a DLPack tensor as an Arrow Tensor.
+///
+/// Same restrictions on data types as `ExportTensorVersioned`.
+/// Takes ownership of the `DLManagedTensorVersioned` in an error-safe fashion.
+/// If the DLPack input is marked as readonly, this will produce an immutable
tensor.
+///
+/// \param[in] raw DLPack tensor
+/// \return An Arrow Tensor
+ARROW_EXPORT
+Result<std::shared_ptr<Tensor>>
ImportTensorVersioned(DLManagedTensorVersioned* raw);
+
} // namespace arrow::dlpack
diff --git a/cpp/src/arrow/c/dlpack_test.cc b/cpp/src/arrow/c/dlpack_test.cc
index 05de22237a9..8cbff0692da 100644
--- a/cpp/src/arrow/c/dlpack_test.cc
+++ b/cpp/src/arrow/c/dlpack_test.cc
@@ -329,4 +329,390 @@ TYPED_TEST(TestExportTensor, TestTensorStrided) {
f_dlpack_strides);
}
+/***************
+ * Consumers *
+ ***************/
+
+/// A DLPack tensor as a foreign library would produce it.
+struct ForeignTensor {
+ DLDataType dtype = {.code = kDLFloat, .bits = 32, .lanes = 1};
+ std::vector<int64_t> shape = {};
+ /// In number of elements, as mandated by DLPack.
+ std::vector<int64_t> strides = {};
+ std::vector<uint8_t> data = {};
+ DLDevice device = {.device_type = kDLCPU, .device_id = 0};
+ uint64_t byte_offset = 0;
+ uint64_t flags = 0;
+ /// Incremented when the consumer releases the tensor.
+ std::shared_ptr<int> deleted = std::make_shared<int>(0);
+
+ DLManagedTensorVersioned managed = {};
+};
+
+template <typename T>
+std::vector<uint8_t> ToBytes(const std::vector<T>& values) {
+ std::vector<uint8_t> bytes(values.size() * sizeof(T));
+ std::memcpy(bytes.data(), values.data(), bytes.size());
+ return bytes;
+}
+
+/// Hand out a DLPack tensor owning ``foreign``, releasing it through its
deleter.
+DLManagedTensorVersioned* Produce(ForeignTensor foreign) {
+ auto owned = std::make_unique<ForeignTensor>(std::move(foreign));
+ owned->managed = {
+ .version = {.major = DLPACK_MAJOR_VERSION, .minor =
DLPACK_MINOR_VERSION},
+ .manager_ctx = owned.get(),
+ .deleter =
+ [](DLManagedTensorVersioned* self) {
+ auto* ctx = static_cast<ForeignTensor*>(self->manager_ctx);
+ ++(*ctx->deleted);
+ delete ctx;
+ },
+ .flags = owned->flags,
+ .dl_tensor =
+ {
+ .data = owned->data.data(),
+ .device = owned->device,
+ .ndim = static_cast<int32_t>(owned->shape.size()),
+ .dtype = owned->dtype,
+ .shape = owned->shape.data(),
+ .strides = owned->strides.data(),
+ .byte_offset = owned->byte_offset,
+ },
+ };
+ return &owned.release()->managed;
+}
+
+struct TensorConsumer {
+ using Imported = std::shared_ptr<Tensor>;
+ static constexpr const char* name = "Tensor";
+
+ static Result<Imported> Import(DLManagedTensorVersioned* raw) {
+ return ImportTensorVersioned(raw);
+ }
+ static std::shared_ptr<DataType> ValueType(const Imported& t) { return
t->type(); }
+ static const uint8_t* RawData(const Imported& t) { return t->raw_data(); }
+ static bool IsMutable(const Imported& t) { return t->is_mutable(); }
+ static int64_t Size(const Imported& t) { return t->size(); }
+ static Status Validate(const Imported& t) { return t->Validate(); }
+
+ /// Import and additionally check the result is a well-formed Arrow object.
+ static Result<Imported> ImportAndValidate(DLManagedTensorVersioned* raw) {
+ ARROW_ASSIGN_OR_RAISE(auto imported, Import(raw));
+ RETURN_NOT_OK(Validate(imported));
+ return imported;
+ }
+};
+
+struct ArrayConsumer {
+ using Imported = std::shared_ptr<Array>;
+ static constexpr const char* name = "Array";
+
+ static Result<Imported> Import(DLManagedTensorVersioned* raw) {
+ return ImportArrayVersioned(raw);
+ }
+ static std::shared_ptr<DataType> ValueType(const Imported& arr) { return
arr->type(); }
+ static const uint8_t* RawData(const Imported& arr) {
+ return arr->data()->buffers[1]->data() + arr->offset() *
arr->type()->byte_width();
+ }
+ static bool IsMutable(const Imported& arr) {
+ return arr->data()->buffers[1]->is_mutable();
+ }
+ static int64_t Size(const Imported& arr) { return arr->length(); }
+ static Status Validate(const Imported& t) { return t->ValidateFull(); }
+
+ /// Import and additionally check the result is a well-formed Arrow object.
+ static Result<Imported> ImportAndValidate(DLManagedTensorVersioned* raw) {
+ ARROW_ASSIGN_OR_RAISE(auto imported, Import(raw));
+ RETURN_NOT_OK(Validate(imported));
+ return imported;
+ }
+};
+
+struct ConsumerNames {
+ template <typename Consumer>
+ static std::string GetName(int) {
+ return Consumer::name;
+ }
+};
+
+using ConsumerTypes = ::testing::Types<TensorConsumer, ArrayConsumer>;
+using TensorConsumerTypes = ::testing::Types<TensorConsumer>;
+using ArrayConsumerTypes = ::testing::Types<ArrayConsumer>;
+
+/// Tests sharing the same expectations for Arrow Tensor and Array imports.
+template <typename Consumer>
+class TestImport : public ::testing::Test {};
+
+TYPED_TEST_SUITE(TestImport, ConsumerTypes, ConsumerNames);
+
+TYPED_TEST(TestImport, Basic) {
+ auto foreign = ForeignTensor{
+ .shape = {6},
+ .strides = {1},
+ .data = ToBytes(std::vector<float>{0, 0, 1, 2, 3, 4, 5, 6}),
+ .byte_offset = 2 * sizeof(float),
+ .flags = DLPACK_FLAG_BITMASK_READ_ONLY,
+ };
+ const auto deleted = foreign.deleted;
+ const auto expected = std::vector<float>{1, 2, 3, 4, 5, 6};
+ const auto* values = foreign.data.data() + foreign.byte_offset;
+
+ ASSERT_OK_AND_ASSIGN(auto imported,
+
TypeParam::ImportAndValidate(Produce(std::move(foreign))));
+
+ AssertTypeEqual(*float32(), *TypeParam::ValueType(imported));
+ ASSERT_EQ(6, TypeParam::Size(imported));
+ ASSERT_FALSE(TypeParam::IsMutable(imported));
+ ASSERT_EQ(0, std::memcmp(TypeParam::RawData(imported), expected.data(),
+ expected.size() * sizeof(float)));
+
+ ASSERT_EQ(TypeParam::RawData(imported), values);
+ // The producer tensor is kept alive by the imported data
+ ASSERT_EQ(0, *deleted);
+ imported.reset();
+ ASSERT_EQ(1, *deleted);
+}
+
+TYPED_TEST(TestImport, Mutable) {
+ auto foreign = ForeignTensor{
+ .shape = {4},
+ .strides = {1},
+ .data = ToBytes(std::vector<float>{1, 2, 3, 4}),
+ .flags = 0,
+ };
+ ASSERT_OK_AND_ASSIGN(auto imported,
+
TypeParam::ImportAndValidate(Produce(std::move(foreign))));
+ ASSERT_TRUE(TypeParam::IsMutable(imported));
+}
+
+TYPED_TEST(TestImport, NullDeleter) {
+ // The DLPack spec allows producers not to set a deleter
+ auto* managed =
+ Produce({.shape = {2}, .strides = {1}, .data = std::vector<uint8_t>(8)});
+ auto* foreign = static_cast<ForeignTensor*>(managed->manager_ctx);
+ managed->deleter = nullptr;
+
+ ASSERT_OK_AND_ASSIGN(auto imported, TypeParam::ImportAndValidate(managed));
+ imported.reset();
+ delete foreign;
+}
+
+TYPED_TEST(TestImport, DataTypes) {
+ const std::vector<std::pair<DLDataType, std::shared_ptr<DataType>>> cases = {
+ {{kDLInt, 8, 1}, int8()}, {{kDLInt, 16, 1}, int16()},
+ {{kDLInt, 32, 1}, int32()}, {{kDLInt, 64, 1}, int64()},
+ {{kDLUInt, 8, 1}, uint8()}, {{kDLUInt, 16, 1}, uint16()},
+ {{kDLUInt, 32, 1}, uint32()}, {{kDLUInt, 64, 1}, uint64()},
+ {{kDLFloat, 16, 1}, float16()}, {{kDLFloat, 32, 1}, float32()},
+ {{kDLFloat, 64, 1}, float64()}};
+
+ for (const auto& [dtype, expected] : cases) {
+ ARROW_SCOPED_TRACE("dtype ", expected->ToString());
+ ASSERT_OK_AND_ASSIGN(
+ auto imported, TypeParam::ImportAndValidate(Produce(
+ {.dtype = dtype,
+ .shape = {3},
+ .strides = {1},
+ .data = std::vector<uint8_t>(3 *
expected->byte_width())})));
+ AssertTypeEqual(*expected, *TypeParam::ValueType(imported));
+ }
+}
+
+TYPED_TEST(TestImport, Empty) {
+ // DLPack mandates a null data pointer when the tensor holds no element
+ auto* managed = Produce({.shape = {0}, .strides = {1}});
+ managed->dl_tensor.data = nullptr;
+
+ ASSERT_OK_AND_ASSIGN(auto imported, TypeParam::ImportAndValidate(managed));
+ ASSERT_EQ(0, TypeParam::Size(imported));
+}
+
+TYPED_TEST(TestImport, Errors) {
+ auto check = [](ForeignTensor foreign, const std::string& message) {
+ const auto deleted = foreign.deleted;
+ const auto status =
+ TypeParam::ImportAndValidate(Produce(std::move(foreign))).status();
+ EXPECT_EQ(message, status.ToStringWithoutContextLines());
+ // Ownership is taken even when the import fails
+ EXPECT_EQ(1, *deleted);
+ };
+
+ ASSERT_RAISES_WITH_MESSAGE(Invalid, "Invalid: Received null pointer.",
+ TypeParam::ImportAndValidate(nullptr));
+ check({.shape = {2},
+ .strides = {1},
+ .data = std::vector<uint8_t>(8),
+ .device = {.device_type = kDLCUDA, .device_id = 0}},
+ "NotImplemented: DLPack support is implemented only for buffers on CPU
device.");
+ check({.dtype = {kDLFloat, 32, 2}, .shape = {2}, .strides = {1}},
+ "Type error: Only type with one lane are supported.");
+ check({.dtype = {kDLInt, 4, 1}, .shape = {2}, .strides = {1}},
+ "Invalid: unsupported integer bit width 4");
+ check({.dtype = {kDLBool, 8, 1}, .shape = {2}, .strides = {1}},
+ "Invalid: unsupported DLPack type " + std::to_string(kDLBool));
+}
+
+TYPED_TEST(TestImport, UnsupportedVersion) {
+ auto* managed =
+ Produce({.shape = {2}, .strides = {1}, .data = std::vector<uint8_t>(8)});
+ const auto deleted =
static_cast<ForeignTensor*>(managed->manager_ctx)->deleted;
+ const auto major = DLPACK_MAJOR_VERSION + 1;
+ managed->version.major = major;
+
+ ASSERT_RAISES_WITH_MESSAGE(Invalid,
+ "Invalid: Unsupported DLPack major version " +
+ std::to_string(major) + ", expected " +
+ std::to_string(DLPACK_MAJOR_VERSION),
+ TypeParam::ImportAndValidate(managed));
+ // The spec mandates the deleter to be called on major version mismatch
+ ASSERT_EQ(1, *deleted);
+}
+
+template <typename Consumer>
+class TestImportTensor : public ::testing::Test {};
+
+TYPED_TEST_SUITE(TestImportTensor, TensorConsumerTypes, ConsumerNames);
+
+TYPED_TEST(TestImportTensor, ShapeAndStrides) {
+ auto foreign = ForeignTensor{
+ .shape = {2, 3},
+ .strides = {3, 1},
+ .data = ToBytes(std::vector<float>{1, 2, 3, 4, 5, 6}),
+ };
+ ASSERT_OK_AND_ASSIGN(auto tensor,
+
TypeParam::ImportAndValidate(Produce(std::move(foreign))));
+
+ ASSERT_THAT(tensor->shape(), ::testing::ElementsAre(2, 3));
+ // Arrow strides are in bytes, DLPack strides in elements
+ ASSERT_THAT(tensor->strides(),
+ ::testing::ElementsAre(3 * sizeof(float), sizeof(float)));
+}
+
+TYPED_TEST(TestImportTensor, Empty) {
+ auto* managed = Produce({.shape = {0, 3}, .strides = {3, 1}});
+ managed->dl_tensor.data = nullptr;
+
+ ASSERT_OK_AND_ASSIGN(auto tensor, TypeParam::ImportAndValidate(managed));
+ ASSERT_THAT(tensor->shape(), ::testing::ElementsAre(0, 3));
+}
+
+TYPED_TEST(TestImportTensor, Strided) {
+ auto column_major = ForeignTensor{
+ .shape = {2, 3},
+ .strides = {1, 2},
+ .data = ToBytes(std::vector<float>{1, 2, 3, 4, 5, 6}),
+ };
+ ASSERT_OK_AND_ASSIGN(auto tensor,
+
TypeParam::ImportAndValidate(Produce(std::move(column_major))));
+ ASSERT_TRUE(tensor->is_column_major());
+
+ // A 2x2 window over every other row of a 4x2 buffer
+ auto non_contiguous = ForeignTensor{
+ .shape = {2, 2},
+ .strides = {4, 1},
+ .data = ToBytes(std::vector<float>{1, 2, 3, 4, 5, 6, 7, 8}),
+ };
+ ASSERT_OK_AND_ASSIGN(tensor,
+
TypeParam::ImportAndValidate(Produce(std::move(non_contiguous))));
+ ASSERT_FALSE(tensor->is_contiguous());
+ ASSERT_EQ(6, tensor->template Value<FloatType>({1, 1}));
+}
+
+TYPED_TEST(TestImportTensor, ZeroDimensionIsScalar) {
+ auto* managed = Produce({
+ .shape = {},
+ .data = ToBytes(std::vector<float>{42}),
+ });
+ managed->dl_tensor.shape = nullptr;
+ managed->dl_tensor.strides = nullptr;
+
+ ASSERT_OK_AND_ASSIGN(auto tensor, TypeParam::ImportAndValidate(managed));
+ ASSERT_THAT(tensor->shape(), ::testing::IsEmpty());
+ ASSERT_EQ(1, tensor->size());
+ ASSERT_EQ(sizeof(float), tensor->data()->size());
+ ASSERT_EQ(42, tensor->template Value<FloatType>({}));
+}
+
+TYPED_TEST(TestImportTensor, NullStrides) {
+ // DLPack < 1.3 uses null strides to mean row major
+ auto* managed = Produce({
+ .shape = {2, 3},
+ .data = ToBytes(std::vector<float>{1, 2, 3, 4, 5, 6}),
+ });
+ managed->dl_tensor.strides = nullptr;
+
+ ASSERT_OK_AND_ASSIGN(auto tensor, TypeParam::ImportAndValidate(managed));
+ ASSERT_THAT(tensor->shape(), ::testing::ElementsAre(2, 3));
+ ASSERT_TRUE(tensor->is_row_major());
+ ASSERT_EQ(6, tensor->template Value<FloatType>({1, 2}));
+}
+
+TYPED_TEST(TestImportTensor, NegativeStrides) {
+ auto foreign = ForeignTensor{
+ .shape = {2, 2},
+ .strides = {-2, 1},
+ .data = std::vector<uint8_t>(16),
+ };
+ ASSERT_RAISES_WITH_MESSAGE(Invalid, "Invalid: negative strides not
supported",
+
TypeParam::ImportAndValidate(Produce(std::move(foreign))));
+}
+
+TYPED_TEST(TestImportTensor, RoundTrip) {
+ const auto original = TensorFromJSON(float64(), "[1, 2, 3, 4, 5, 6]", {3,
2});
+
+ ASSERT_OK_AND_ASSIGN(auto* managed, ExportTensorVersioned(original,
/*copy=*/false));
+ ASSERT_OK_AND_ASSIGN(auto tensor, TypeParam::ImportAndValidate(managed));
+
+ ASSERT_TRUE(tensor->Equals(*original));
+ ASSERT_EQ(original->raw_data(), tensor->raw_data());
+}
+
+template <typename Consumer>
+class TestImportArray : public ::testing::Test {};
+
+TYPED_TEST_SUITE(TestImportArray, ArrayConsumerTypes, ConsumerNames);
+
+TYPED_TEST(TestImportArray, OneDimension) {
+ auto foreign = ForeignTensor{
+ .dtype = {.code = kDLInt, .bits = 32, .lanes = 1},
+ .shape = {4},
+ .strides = {1},
+ .data = ToBytes(std::vector<int32_t>{1, 2, 3, 4}),
+ };
+ ASSERT_OK_AND_ASSIGN(auto array,
+
TypeParam::ImportAndValidate(Produce(std::move(foreign))));
+ AssertArraysEqual(*ArrayFromJSON(int32(), "[1, 2, 3, 4]"), *array);
+}
+
+TYPED_TEST(TestImportArray, Unsupported) {
+ auto check = [](ForeignTensor foreign) {
+ ASSERT_RAISES_WITH_MESSAGE(
+ Invalid,
+ "Invalid: Only contiguous one dimensional tensor can be imported as"
+ " arrays. Try importing to Tensor first.",
+ TypeParam::ImportAndValidate(Produce(std::move(foreign))));
+ };
+
+ // Only a Tensor can hold more than one dimension
+ check({.shape = {2, 3},
+ .strides = {3, 1},
+ .data = ToBytes(std::vector<float>{1, 2, 3, 4, 5, 6})});
+ check({.shape = {0, 3}, .strides = {1, 1}});
+ // Array values are contiguous, whatever the dimension count
+ check(
+ {.shape = {3}, .strides = {2}, .data = ToBytes(std::vector<float>{1, 2,
3, 4, 5})});
+ check({.shape = {2, 2}, .strides = {-2, 1}, .data =
std::vector<uint8_t>(16)});
+}
+
+TYPED_TEST(TestImportArray, RoundTrip) {
+ const auto original = ArrayFromJSON(float64(), "[1, 2, 3, 4, 5, 6]");
+
+ ASSERT_OK_AND_ASSIGN(auto* managed, ExportArrayVersioned(original,
/*copy=*/false));
+ ASSERT_OK_AND_ASSIGN(auto array, TypeParam::ImportAndValidate(managed));
+
+ AssertArraysEqual(*original, *array);
+ ASSERT_EQ(TypeParam::RawData(original), TypeParam::RawData(array));
+}
+
} // namespace arrow::dlpack
diff --git a/cpp/src/arrow/tensor.cc b/cpp/src/arrow/tensor.cc
index f2ff11a4f66..8b3137d66a3 100644
--- a/cpp/src/arrow/tensor.cc
+++ b/cpp/src/arrow/tensor.cc
@@ -109,13 +109,40 @@ Status ComputeColumnMajorStrides(const FixedWidthType&
type,
return Status::OK();
}
-} // namespace internal
+Result<int64_t> ComputeTensorSize(std::span<const int64_t> shape,
+ std::span<const int64_t> strides, int64_t
elem_size) {
+ // Check the largest offset can be computed without overflow
+ const size_t ndim = shape.size();
+ int64_t largest_offset = elem_size;
+ for (size_t i = 0; i < ndim; ++i) {
+ if (shape[i] == 0) continue;
+ if (strides[i] < 0) {
+ // TODO(mrkn): Support negative strides for sharing views
+ return Status::Invalid("negative strides not supported");
+ }
-namespace {
+ int64_t dim_offset = 0;
+ if (!internal::MultiplyWithOverflow(shape[i] - 1, strides[i],
&dim_offset)) {
+ if (!internal::AddWithOverflow(largest_offset, dim_offset,
&largest_offset)) {
+ continue;
+ }
+ }
-inline bool IsTensorStridesRowMajor(const std::shared_ptr<DataType>& type,
- const std::vector<int64_t>& shape,
- const std::vector<int64_t>& strides) {
+ return Status::Invalid(
+ "offsets computed from shape and strides would not fit in 64-bit
integer");
+ }
+
+ // A dimension with no element means empty for which the preceding does not
apply.
+ if (std::find(shape.begin(), shape.end(), 0) != shape.end()) {
+ return 0;
+ }
+ return largest_offset;
+}
+
+namespace {
+bool IsTensorStridesRowMajor(const std::shared_ptr<DataType>& type,
+ const std::vector<int64_t>& shape,
+ const std::vector<int64_t>& strides) {
std::vector<int64_t> c_strides;
const auto& fw_type = checked_cast<const FixedWidthType&>(*type);
if (internal::ComputeRowMajorStrides(fw_type, shape, &c_strides).ok()) {
@@ -125,9 +152,9 @@ inline bool IsTensorStridesRowMajor(const
std::shared_ptr<DataType>& type,
}
}
-inline bool IsTensorStridesColumnMajor(const std::shared_ptr<DataType>& type,
- const std::vector<int64_t>& shape,
- const std::vector<int64_t>& strides) {
+bool IsTensorStridesColumnMajor(const std::shared_ptr<DataType>& type,
+ const std::vector<int64_t>& shape,
+ const std::vector<int64_t>& strides) {
std::vector<int64_t> f_strides;
const auto& fw_type = checked_cast<const FixedWidthType&>(*type);
if (internal::ComputeColumnMajorStrides(fw_type, shape, &f_strides).ok()) {
@@ -137,9 +164,9 @@ inline bool IsTensorStridesColumnMajor(const
std::shared_ptr<DataType>& type,
}
}
-inline Status CheckTensorValidity(const std::shared_ptr<DataType>& type,
- const std::shared_ptr<Buffer>& data,
- const std::vector<int64_t>& shape) {
+Status CheckTensorValidity(const std::shared_ptr<DataType>& type,
+ const std::shared_ptr<Buffer>& data,
+ const std::vector<int64_t>& shape) {
if (!type) {
return Status::Invalid("Null type is supplied");
}
@@ -193,8 +220,8 @@ Status CheckTensorStridesValidity(const
std::shared_ptr<Buffer>& data,
}
return Status::OK();
}
-
} // namespace
+} // namespace internal
namespace internal {
@@ -532,11 +559,11 @@ bool Tensor::is_contiguous() const {
}
bool Tensor::is_row_major() const {
- return IsTensorStridesRowMajor(type_, shape_, strides_);
+ return internal::IsTensorStridesRowMajor(type_, shape_, strides_);
}
bool Tensor::is_column_major() const {
- return IsTensorStridesColumnMajor(type_, shape_, strides_);
+ return internal::IsTensorStridesColumnMajor(type_, shape_, strides_);
}
Type::type Tensor::type_id() const { return type_->id(); }
diff --git a/cpp/src/arrow/tensor.h b/cpp/src/arrow/tensor.h
index f3270313434..2917905049c 100644
--- a/cpp/src/arrow/tensor.h
+++ b/cpp/src/arrow/tensor.h
@@ -71,6 +71,15 @@ bool IsTensorStridesContiguous(const
std::shared_ptr<DataType>& type,
const std::vector<int64_t>& shape,
const std::vector<int64_t>& strides);
+/// Compute the size needed to store the tensor with the given strides and
shape.
+///
+/// If the strides are in number of element, pass `elem_size=1` to compute the
buffer size
+/// in the number of elements. If the strides are in bytes, pass the element
size in byte
+/// to `elem_size` and get the result in bytes.
+ARROW_EXPORT
+Result<int64_t> ComputeTensorSize(std::span<const int64_t> shape,
+ std::span<const int64_t> strides, int64_t
elem_size);
+
ARROW_EXPORT
Status ValidateTensorParameters(const std::shared_ptr<DataType>& type,
const std::shared_ptr<Buffer>& data,
diff --git a/docs/source/python/dlpack.rst b/docs/source/python/dlpack.rst
index 6e74cd5c82c..4e102ce8b0a 100644
--- a/docs/source/python/dlpack.rst
+++ b/docs/source/python/dlpack.rst
@@ -41,31 +41,51 @@ and more about DLPack in the
Implementation of DLPack in PyArrow
-----------------------------------
-The producing side of the DLPack Protocol is implemented for ``pa.Array``
-and can be used to interchange data between PyArrow and other tensor
-libraries. Supported data types are integer, unsigned integer and float. The
-protocol has no missing data support meaning PyArrow arrays with
-missing values cannot be transferred through the DLPack
-protocol. Currently, the Arrow implementation of the protocol only supports
+The protocol is implemented for ``pa.Array`` and ``pa.Tensor`` with different
behaviors.
+``pa.Tensor`` can produce and consume all shapes and strides of a generic
DLPack tensor.
+``pa.Array`` on the other hand is purposely limited to produce and consume
1-dimensional
+contiguous tensors (where the only dimension is the array's length).
+The only exception is ``pa.FixedShapeTensorArray``, which is designed to
represent
+tensors and supports more generic shapes and strides.
+It can produce and consume DLPack tensors whose outermost dimension has the
largest
+stride, that dimension being mapped to the array's length.
+
+For both ``pa.Tensor`` and ``pa.Array``, only numeric data types are
supported: integer,
+unsigned integer and float.
+
+Some array types can be understood as some form of tensor.
+For instance, a nested fixed size list of a numeric data type has the same
memory
+representation as a row major tensor.
+It is possible to get a (zero-copy) tensor from such an array using
+``array.to_tensor()``, and then use DLPack on the resulting tensor.
+
+The DLPack protocol fails on arrays with nulls, though these can be ignored
with
+an explicit conversion to a tensor using ``array.to_tensor(allow_nulls=True)``.
+In that case, the null entries hold an unspecified value.
+This is free, compared to ``pa.compute.fill_null`` which explicitly modifies
the
+array data to replace the null values.
+
+Currently, the Arrow implementation of the protocol only supports
data on a CPU device.
Data interchange syntax of the protocol includes
-1. ``from_dlpack(x)``: consuming an array object that implements a
- ``__dlpack__`` method and creating a new array while sharing the
+1. ``from_dlpack(x, /, *, device=None, copy=None)``: consuming an array object
that
+ implements a ``__dlpack__`` method and creating a new array while sharing
the
memory.
-2. ``__dlpack__(self, stream=None)`` and ``__dlpack_device__``:
+2. ``__dlpack__(self, *, stream=None, max_version=None, dl_device=None,
copy=None)``
+ and ``__dlpack_device__``:
producing a PyCapsule with the DLPack struct which is called from
within ``from_dlpack(x)``.
-
-PyArrow implements the second part of the protocol
-(``__dlpack__(self, stream=None)`` and ``__dlpack_device__``) and can
-thus be consumed by libraries implementing ``from_dlpack``.
+ This method is intended for library authors.
Examples
--------
+Producing
+~~~~~~~~~
+
Convert a PyArrow CPU array into a NumPy array:
.. code-block:: python
@@ -101,3 +121,81 @@ Convert a PyArrow CPU array into a JAX array:
Array([2, 0, 2, 4], dtype=int32)
>>> jax.dlpack.from_dlpack(array) # doctest: +SKIP
Array([2, 0, 2, 4], dtype=int32)
+
+Arrays with a tensor memory layout, such as fixed size lists of a numeric
type, need an
+explicit conversion to a ``pa.Tensor``, which exports its full
multi-dimensional shape:
+
+.. code-block:: python
+
+ >>> list_array = pa.array([[1.0, 2.0], [3.0, 4.0], [5.0, 6.0]],
+ ... pa.list_(pa.float64(), 2))
+ >>> list_array.to_tensor()
+ <pyarrow.Tensor>
+ type: double
+ shape: (3, 2)
+ strides: (16, 8)
+ >>> np.from_dlpack(list_array.to_tensor())
+ array([[1., 2.],
+ [3., 4.],
+ [5., 6.]])
+
+A ``pa.FixedShapeTensorArray`` exports directly, the array length becoming the
outermost
+dimension, followed by the shape of the element tensors:
+
+.. code-block:: python
+
+ >>> nested = pa.array([[[1, 2], [3, 4]], [[5, 6], [7, 8]]],
+ ... pa.list_(pa.list_(pa.int32(), 2), 2))
+ >>> tensor_array = pa.FixedShapeTensorArray.from_tensor(nested.to_tensor())
+ >>> tensor_array.type
+ FixedShapeTensorType(extension<arrow.fixed_shape_tensor[value_type=int32,
shape=[2,2], permutation=[0,1]]>)
+ >>> np.from_dlpack(tensor_array).shape
+ (2, 2, 2)
+
+Arrays with nulls are rejected, unless the conversion to a tensor explicitly
allows
+them, in which case the null entries hold an unspecified value:
+
+.. code-block:: python
+
+ >>> array_with_nulls = pa.array([2, None, 4], pa.int32())
+ >>> np.from_dlpack(array_with_nulls)
+ Traceback (most recent call last):
+ ...
+ pyarrow.lib.ArrowTypeError: Can only use DLPack on arrays with no nulls.
+ >>> np.from_dlpack(array_with_nulls.to_tensor(allow_nulls=True))
+ array([2, ..., 4], dtype=int32)
+
+Consuming
+~~~~~~~~~
+
+Any object implementing the DLPack protocol can be imported, without copying
the data:
+
+.. code-block:: python
+
+ >>> pa.Array.from_dlpack(np.array([2, 0, 2, 4]))
+ <pyarrow.lib.Int64Array object at ...>
+ [
+ 2,
+ 0,
+ 2,
+ 4
+ ]
+ >>> pa.Tensor.from_dlpack(np.array([[2, 0], [2, 4]], np.int32))
+ <pyarrow.Tensor>
+ type: int32
+ shape: (2, 2)
+ strides: (8, 4)
+
+``pa.Array.from_dlpack`` only accepts 1-dimensional contiguous tensors.
+Multi-dimensional data can be imported as a ``pa.FixedShapeTensorArray``, the
outermost
+dimension becoming the length of the array:
+
+.. code-block:: python
+
+ >>> array = pa.FixedShapeTensorArray.from_dlpack(
+ ... np.arange(12, dtype=np.int32).reshape(3, 2, 2)
+ ... )
+ >>> array.type
+ FixedShapeTensorType(extension<arrow.fixed_shape_tensor[value_type=int32,
shape=[2,2], permutation=[0,1]]>)
+ >>> len(array)
+ 3
diff --git a/python/pyarrow/array.pxi b/python/pyarrow/array.pxi
index 691623b87f0..3060c533255 100644
--- a/python/pyarrow/array.pxi
+++ b/python/pyarrow/array.pxi
@@ -15,7 +15,12 @@
# specific language governing permissions and limitations
# under the License.
-from cpython.pycapsule cimport PyCapsule_CheckExact, PyCapsule_GetPointer,
PyCapsule_New
+from cpython.pycapsule cimport (
+ PyCapsule_CheckExact,
+ PyCapsule_GetPointer,
+ PyCapsule_New,
+ PyCapsule_SetName,
+)
from collections.abc import Sequence
import os
@@ -2269,6 +2274,54 @@ cdef class Array(_PandasConvertible):
return pyarrow_wrap_array(array)
+ @staticmethod
+ def from_dlpack(x, /, *, device=None, copy=None):
+ """
+ Construct an Array from an object implementing the DLPack protocol.
+ Only 1-dimensional contiguous tensors are accepted as input.
+ For multi-dimensional tensors, use `Tensor.from_dlpack` or
+ `FixedShapeTensorArray.from_dlpack`.
+
+ Parameters
+ ----------
+ x : object
+ The input object containing array data, following the DLPack
+ protocol (has a ``__dlpack__`` method).
+ device : tuple[enum.Enum, int], optional
+ Designates where the resulting Array should reside, in the
+ format returned by :meth:`Array.__dlpack_device__`. When None,
+ the output Array occupies the same device as the source.
+ Default: None.
+ copy : bool, optional
+ Controls duplication behavior. True mandates copying; False
+ prohibits copying and raises ``BufferError`` if unavoidable;
+ None duplicates only when necessary. Default: None.
+
+ Returns
+ -------
+ Array
+ An Array housing the data from the input object, potentially
+ as a copy or view.
+ """
+ version = (DLPACK_VERSION.major, DLPACK_VERSION.minor)
+ pycapsule = x.__dlpack__(max_version=version, dl_device=device,
copy=copy)
+ if not PyCapsule_CheckExact(pycapsule):
+ raise TypeError("DLPack producer did not return a PyCapsule")
+ cdef DLManagedTensorVersioned* ptr =
<DLManagedTensorVersioned*>PyCapsule_GetPointer(
+ pycapsule, "dltensor_versioned")
+ if ptr == NULL:
+ raise ValueError(
+ 'DLPack producer did not produce a "dltensor_versioned"
PyCapsule')
+ # Mark the capsule as consumed so its destructor does not also invoke
the deleter.
+ # ImportArrayVersionedFromDLPack will take ownership even if it errors
(calling
+ # the deleter in that case).
+ PyCapsule_SetName(pycapsule, "used_dltensor_versioned")
+ with nogil:
+ # Copy handled on producer side
+ result = ImportArrayVersionedFromDLPack(ptr)
+ carray = GetResultValue(result)
+ return pyarrow_wrap_array(carray)
+
def __dlpack__(self, *, stream=None, max_version=None, dl_device=None,
copy=None):
"""
Export a primitive array as a DLPack capsule.
@@ -4969,6 +5022,32 @@ cdef class FixedShapeTensorArray(ExtensionArray):
return self.to_tensor().to_numpy()
+ @staticmethod
+ def from_tensor(Tensor tensor not None):
+ """
+ Convert a pyarrow.Tensor to a fixed shape tensor extension array.
+
+ The first dimension of the tensor becomes the length of the fixed shape
+ tensor array and the remaining dimensions the shape of the individual
+ tensors. If the tensor provides strides, they are used to determine the
+ dimension permutation, otherwise row-major layout is assumed.
+
+ Parameters
+ ----------
+ tensor : pyarrow.Tensor
+
+ Returns
+ -------
+ FixedShapeTensorArray
+ """
+ cdef shared_ptr[CFixedShapeTensorArray] c_array
+
+ with nogil:
+ c_array = GetResultValue(
+ CFixedShapeTensorArray.FromTensor(tensor.sp_tensor))
+
+ return pyarrow_wrap_array(<shared_ptr[CArray]> c_array)
+
@staticmethod
def from_numpy_ndarray(obj, dim_names=None):
"""
@@ -5045,6 +5124,41 @@ cdef class FixedShapeTensorArray(ExtensionArray):
FixedSizeListArray.from_arrays(values, shape[1:].prod())
)
+ @staticmethod
+ def from_dlpack(x, /, *, device=None, copy=None):
+ """
+ Construct a FixedShapeTensorArray from an object implementing the
DLPack
+ protocol.
+
+ The outermost dimension of the input becomes the length of the tensor
+ array, and the remaining dimensions the shape of the individual
tensors.
+ The outermost dimension must have the largest stride.
+
+ Parameters
+ ----------
+ x : object
+ The input object containing array data, following the DLPack
+ protocol (has a ``__dlpack__`` method).
+ device : tuple[enum.Enum, int], optional
+ Designates where the resulting array should reside, in the
+ format returned by :meth:`Array.__dlpack_device__`. When None,
+ the output array occupies the same device as the source.
+ Default: None.
+ copy : bool, optional
+ Controls duplication behavior. True mandates copying; False
+ prohibits copying and raises ``BufferError`` if unavoidable;
+ None duplicates only when necessary. Default: None.
+
+ Returns
+ -------
+ FixedShapeTensorArray
+ An array housing the data from the input object, potentially
+ as a copy or view.
+
+ """
+ return FixedShapeTensorArray.from_tensor(
+ Tensor.from_dlpack(x, device=device, copy=copy))
+
def __dlpack__(self, *, stream=None, max_version=None, dl_device=None,
copy=None):
"""
Export a tensor array as a DLPack capsule.
diff --git a/python/pyarrow/includes/libarrow.pxd
b/python/pyarrow/includes/libarrow.pxd
index d592d4024aa..16784b51ecc 100644
--- a/python/pyarrow/includes/libarrow.pxd
+++ b/python/pyarrow/includes/libarrow.pxd
@@ -1460,6 +1460,10 @@ cdef extern from "arrow/api.h" namespace "arrow" nogil:
cdef extern from "arrow/c/dlpack_abi.h" nogil:
+ ctypedef struct DLPackVersion:
+ uint32_t major
+ uint32_t minor
+
ctypedef enum DLDeviceType:
kDLCPU = 1
@@ -1475,6 +1479,8 @@ cdef extern from "arrow/c/dlpack_abi.h" nogil:
cdef extern from "arrow/c/dlpack.h" namespace "arrow::dlpack" nogil:
+ const DLPackVersion DLPACK_VERSION" arrow::dlpack::kVersion"
+
CResult[DLManagedTensor*] ExportArrayToDLPack" arrow::dlpack::ExportArray"(
const shared_ptr[CArray]& arr)
CResult[DLManagedTensor*] ExportTensorToDLPack"
arrow::dlpack::ExportTensor"(
@@ -1490,6 +1496,13 @@ cdef extern from "arrow/c/dlpack.h" namespace
"arrow::dlpack" nogil:
CResult[DLDevice] ExportDevice(const shared_ptr[CArray]& arr)
CResult[DLDevice] ExportDevice(const shared_ptr[CTensor]& tensor)
+ CResult[shared_ptr[CArray]] \
+ ImportArrayVersionedFromDLPack" arrow::dlpack::ImportArrayVersioned"(
+ DLManagedTensorVersioned* raw)
+ CResult[shared_ptr[CTensor]] \
+ ImportTensorVersionedFromDLPack" arrow::dlpack::ImportTensorVersioned"(
+ DLManagedTensorVersioned* raw)
+
cdef extern from "arrow/builder.h" namespace "arrow" nogil:
@@ -3092,6 +3105,13 @@ cdef extern from "arrow/extension/fixed_shape_tensor.h"
namespace "arrow::extens
const vector[int64_t] permutation()
const vector[c_string] dim_names()
+ cdef cppclass CFixedShapeTensorArray \
+ " arrow::extension::FixedShapeTensorArray"(CExtensionArray):
+
+ @staticmethod
+ CResult[shared_ptr[CFixedShapeTensorArray]] FromTensor(
+ const shared_ptr[CTensor]& tensor)
+
cdef extern from "arrow/extension/opaque.h" namespace "arrow::extension" nogil:
cdef cppclass COpaqueType \
diff --git a/python/pyarrow/tensor.pxi b/python/pyarrow/tensor.pxi
index 521ee0c3f44..7f006f29417 100644
--- a/python/pyarrow/tensor.pxi
+++ b/python/pyarrow/tensor.pxi
@@ -18,6 +18,12 @@
# Avoid name clash with `pa.struct` function
import struct as _struct
+from cpython.pycapsule cimport (
+ PyCapsule_CheckExact,
+ PyCapsule_GetPointer,
+ PyCapsule_SetName,
+)
+
cdef class Tensor(_Weakrefable):
"""
@@ -300,7 +306,52 @@ strides: {self.strides}"""
buffer.strides = <Py_ssize_t *>
cp.PyBytes_AsString(self._ssize_t_strides)
buffer.suboffsets = NULL
- def __dlpack__(self, stream=None, max_version=None, dl_device=None,
copy=None):
+ @staticmethod
+ def from_dlpack(x, /, *, device=None, copy=None):
+ """
+ Construct a Tensor from an object implementing the DLPack protocol.
+
+ Parameters
+ ----------
+ x : object
+ The input object containing array data, following the DLPack
+ protocol (has a ``__dlpack__`` method).
+ device : tuple[enum.Enum, int], optional
+ Designates where the resulting Tensor should reside, in the
+ format returned by :meth:`Tensor.__dlpack_device__`. When None,
+ the output Tensor occupies the same device as the source.
+ Default: None.
+ copy : bool, optional
+ Controls duplication behavior. True mandates copying; False
+ prohibits copying and raises ``BufferError`` if unavoidable;
+ None duplicates only when necessary. Default: None.
+
+ Returns
+ -------
+ Tensor
+ A Tensor housing the data from the input object, potentially
+ as a copy or view.
+ """
+ version = (DLPACK_VERSION.major, DLPACK_VERSION.minor)
+ pycapsule = x.__dlpack__(max_version=version, dl_device=device,
copy=copy)
+ if not PyCapsule_CheckExact(pycapsule):
+ raise TypeError("DLPack producer did not return a PyCapsule")
+ cdef DLManagedTensorVersioned* ptr =
<DLManagedTensorVersioned*>PyCapsule_GetPointer(
+ pycapsule, "dltensor_versioned")
+ if ptr == NULL:
+ raise ValueError(
+ 'DLPack producer did not produce a "dltensor_versioned"
PyCapsule')
+ # Mark the capsule as consumed so its destructor does not also invoke
the deleter.
+ # ImportTensorVersionedFromDLPack will take ownership even if it
errors (calling
+ # the deleter in that case).
+ PyCapsule_SetName(pycapsule, "used_dltensor_versioned")
+ with nogil:
+ # Copy handled on producer side
+ result = ImportTensorVersionedFromDLPack(ptr)
+ ctensor = GetResultValue(result)
+ return pyarrow_wrap_tensor(ctensor)
+
+ def __dlpack__(self, *, stream=None, max_version=None, dl_device=None,
copy=None):
"""
Export a Tensor as a DLPack capsule.
diff --git a/python/pyarrow/tests/test_dlpack.py
b/python/pyarrow/tests/test_dlpack.py
index e3cc2fd3e9e..0c3c082fd6e 100644
--- a/python/pyarrow/tests/test_dlpack.py
+++ b/python/pyarrow/tests/test_dlpack.py
@@ -374,3 +374,133 @@ def test_dlpack_cuda_not_supported():
with pytest.raises(NotImplementedError, match="DLPack support is
implemented "
"only for buffers on CPU device."):
carr.__dlpack_device__()
+
+
+@requires_numpy_version("2.1.0")
+@check_bytes_allocated
[email protected]('np_type',
+ [np.uint8, np.uint16, np.uint32, np.uint64,
+ np.int8, np.int16, np.int32, np.int64,
+ np.float16, np.float32, np.float64])
+def test_tensor_from_dlpack(np_type):
+ def make_array():
+ base = np.arange(24, dtype=np_type).reshape((4, 6))
+ array = base[::2, 1::2]
+ assert not array.flags['C_CONTIGUOUS']
+ return array
+
+ # Non-contiguous, strided slice: DLPack carries explicit strides, so this
+ # should not need a copy on export.
+ tensor = pa.Tensor.from_dlpack(make_array())
+ assert isinstance(tensor, pa.Tensor)
+ gc.collect() # Attempts to free input array memory
+ np.testing.assert_array_equal(tensor.to_numpy(), make_array(), strict=True)
+
+
+@requires_numpy_version("2.1.0")
+@check_bytes_allocated
[email protected]('np_type',
+ [np.uint8, np.uint16, np.uint32, np.uint64,
+ np.int8, np.int16, np.int32, np.int64,
+ np.float16, np.float32, np.float64])
+def test_array_from_dlpack(np_type):
+ expected = np.array([1, 2, 3, 4, 5], dtype=np_type)
+ arr = pa.Array.from_dlpack(expected)
+ arr.validate(full=True)
+ assert isinstance(arr, pa.Array)
+ np.testing.assert_array_equal(arr.to_numpy(), expected, strict=True)
+
+
+@requires_numpy_version("2.1.0")
+@check_bytes_allocated
[email protected]('np_type',
+ [np.uint8, np.uint16, np.uint32, np.uint64,
+ np.int8, np.int16, np.int32, np.int64,
+ np.float16, np.float32, np.float64])
+def test_fixed_shape_tensor_array_from_dlpack(np_type):
+ source = np.arange(12, dtype=np_type).reshape((3, 2, 2))
+ arr = pa.FixedShapeTensorArray.from_dlpack(source)
+ arr.validate(full=True)
+ assert arr.type == pa.fixed_shape_tensor(pa.from_numpy_dtype(np_type), [2,
2])
+ assert arr.to_pylist() == [[0, 1, 2, 3], [4, 5, 6, 7], [8, 9, 10, 11]]
+
+ # Zero-copy import: mutating the source is visible through the array.
+ source[0, 0, 0] = 100
+ assert arr.to_pylist()[0] == [100, 1, 2, 3]
+
+ copied = pa.FixedShapeTensorArray.from_dlpack(source, copy=True)
+ source[0, 0, 0] = 0
+ assert copied.to_pylist()[0] == [100, 1, 2, 3]
+
+
+@requires_numpy_version("2.1.0")
+@check_bytes_allocated
[email protected]('np_type', [np.uint8, np.int32, np.float64])
+def test_fixed_shape_tensor_array_from_dlpack_transposed(np_type):
+ source = np.arange(12, dtype=np_type).reshape((3, 2, 2)).transpose(0, 2, 1)
+ arr = pa.FixedShapeTensorArray.from_dlpack(source)
+ arr.validate(full=True)
+ assert arr.type == pa.fixed_shape_tensor(
+ pa.from_numpy_dtype(np_type), [2, 2], permutation=[1, 0])
+ np.testing.assert_array_equal(arr.to_numpy_ndarray(), source)
+
+ # Zero-copy import: mutating the source is visible through the array.
+ source[0, 0, 0] = 100
+ np.testing.assert_array_equal(arr.to_numpy_ndarray(), source)
+
+ copied = pa.FixedShapeTensorArray.from_dlpack(source, copy=True)
+ expected = source.copy()
+ source[0, 0, 0] = 0
+ np.testing.assert_array_equal(copied.to_numpy_ndarray(), expected)
+
+
+@requires_numpy_version("2.1.0")
+@check_bytes_allocated
+def test_fixed_shape_tensor_array_from_dlpack_not_first_major():
+ # The outermost dimension indexes the tensor elements, so it must remain
+ # the major one.
+ source = np.arange(12, dtype=np.int32).reshape((3, 2, 2)).transpose(1, 0,
2)
+ with pytest.raises(pa.ArrowInvalid,
+ match="Only first-major tensors can be zero-copy"):
+ pa.FixedShapeTensorArray.from_dlpack(source)
+
+
+@requires_numpy_version("2.1.0")
+@check_bytes_allocated
+def test_from_dlpack_zero_copy():
+ expected = np.array([1, 2, 3], dtype=np.int64)
+ tensor = pa.Tensor.from_dlpack(expected)
+ result = tensor.to_numpy()
+ # Zero-copy import: mutating the source is visible through the tensor.
+ expected[0] = 100
+ assert result[0] == 100
+ # Same for mutating the result
+ result[1] = 42
+ assert expected[1] == 42
+
+
+@requires_numpy_version("2.1.0")
+@check_bytes_allocated
+def test_from_dlpack_explicit_copy():
+ expected = np.array([1, 2, 3], dtype=np.int64)
+ tensor = pa.Tensor.from_dlpack(expected, copy=True)
+ result = tensor.to_numpy()
+ expected[0] = 100
+ # The data was copied, so mutating the source is not visible.
+ assert result[0] == 1
+
+
+def test_from_dlpack_no_dlpack_method():
+ with pytest.raises(AttributeError):
+ pa.Tensor.from_dlpack(object())
+
+
+@requires_numpy_version("2.1.0")
+@check_bytes_allocated
+def test_array_from_dlpack_multi_dim_not_supported():
+ expected = np.arange(6, dtype=np.int32).reshape((2, 3))
+ with pytest.raises(
+ pa.ArrowInvalid,
+ match="Only contiguous one dimensional tensor can be imported as
arrays",
+ ):
+ pa.Array.from_dlpack(expected)
diff --git a/python/pyarrow/tests/test_extension_type.py
b/python/pyarrow/tests/test_extension_type.py
index bdd898767b0..a9d15cfec10 100644
--- a/python/pyarrow/tests/test_extension_type.py
+++ b/python/pyarrow/tests/test_extension_type.py
@@ -1708,6 +1708,37 @@ def test_tensor_class_methods(np_type_str):
assert result.to_tensor().strides == (12 * bw, 1 * bw, 3 * bw, 6 * bw)
[email protected]
[email protected](
+ ("transpose", "permutation"),
+ [(False, [0, 1]), (True, [1, 0])]
+)
+def test_tensor_array_from_tensor(transpose, permutation):
+ arr = np.arange(24, dtype=np.int32).reshape(2, 3, 4)
+ arr = arr.transpose(0, 2, 1) if transpose else arr
+
+ result = pa.FixedShapeTensorArray.from_tensor(pa.Tensor.from_numpy(arr))
+ result.validate(full=True)
+
+ assert isinstance(result.type, pa.FixedShapeTensorType)
+ assert result.type.value_type == pa.int32()
+ # Shape is in physical order (unpermuted)
+ assert result.type.shape == [3, 4]
+ assert result.type.permutation == permutation
+ assert len(result) == 2
+ np.testing.assert_array_equal(result.to_numpy_ndarray(), arr)
+
+
[email protected]
[email protected]("permutation", [(1, 0, 2), (1, 2, 0), (2, 1, 0)])
+def test_tensor_array_from_tensor_not_first_major(permutation):
+ arr = np.arange(24, dtype=np.int32).reshape(2, 3,
4).transpose(*permutation)
+
+ with pytest.raises(pa.ArrowInvalid,
+ match="Only first-major tensors can be zero-copy"):
+ pa.FixedShapeTensorArray.from_tensor(pa.Tensor.from_numpy(arr))
+
+
@pytest.mark.numpy
@pytest.mark.parametrize("np_type_str", ("int8", "int64", "float32"))
def test_tensor_array_from_numpy(np_type_str):