Skip to content

Commit fa834a8

Browse files
committed
Implement PyDenseArray
This is a minimal array type to allow dispatching to things like BLAS functions that require `DenseArray`.
1 parent 3f23e2c commit fa834a8

9 files changed

Lines changed: 285 additions & 5 deletions

File tree

docs/src/pythoncall-reference.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,7 @@ PySet
185185
PyDict
186186
PyIterable
187187
PyArray
188+
PyDenseArray
188189
PyIO
189190
PyTable
190191
PyPandasDataFrame

docs/src/pythoncall.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,24 @@ Python: array('i', [0, 4, 5])
254254
It directly wraps the underlying data buffer, so array operations such as indexing are about
255255
as fast as for an ordinary `Array`.
256256

257+
If the data is contiguous in memory then [`PyDenseArray`](@ref) can wrap it as a
258+
`DenseArray` instead, so that it works with code specialised for dense or strided arrays,
259+
such as BLAS. Julia arrays are column-major whereas numpy arrays are row-major by default, so
260+
the dimensions are reversed when the data is row-major:
261+
262+
```julia-repl
263+
julia> x = pyimport("numpy").arange(6.0).reshape(2, 3)
264+
Python:
265+
array([[0., 1., 2.],
266+
[3., 4., 5.]])
267+
268+
julia> PyDenseArray(x)
269+
3×2 PyDenseArray{Float64, 2}:
270+
0.0 3.0
271+
1.0 4.0
272+
2.0 5.0
273+
```
274+
257275
The [`PyIO`](@ref) wrapper type views a Python file object as a Julia IO object:
258276

259277
```julia-repl

src/API/exports.jl

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,7 @@ export pyconvert_unconverted
113113

114114
# Wrap
115115
export PyArray
116+
export PyDenseArray
116117
export PyDict
117118
export PyIO
118119
export PyIterable

src/API/types.jl

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,43 @@ struct PyArray{T,N,M,L,R} <: AbstractArray{T,N}
9090
end
9191
end
9292

93+
"""
94+
PyDenseArray{T,N,M}(x; copy=true, array=true, buffer=true) <: DenseArray
95+
96+
Wrap the Python array `x` as a Julia `DenseArray{T,N}`.
97+
98+
This is like [`PyArray`](@ref) but requires the data to be contiguous in memory, so that the
99+
result can be used wherever a `DenseArray` or `StridedArray` is expected, such as BLAS
100+
routines.
101+
102+
Julia arrays are column-major but most Python arrays (including `numpy.ndarray` by default)
103+
are row-major. If the data is row-major then the dimensions are reversed, so a numpy array
104+
of shape `(2, 3)` becomes a `PyDenseArray` of size `(3, 2)`. Column-major arrays
105+
keep their shape.
106+
107+
The type parameters are all optional, and are identical to the `T`, `N` and `M`
108+
parameters of `PyArray`. The element type `T` is always the element type of the
109+
underlying buffer.
110+
"""
111+
struct PyDenseArray{T,N,M} <: DenseArray{T,N}
112+
ptr::Ptr{T} # pointer to the data
113+
size::NTuple{N,Int} # size of the array (reversed if the data is row-major)
114+
py::Py # underlying python object
115+
handle::Py # the data in this array is valid as long as this handle is alive
116+
function PyDenseArray{T,N,M}(
117+
::Val{:new},
118+
ptr::Ptr{T},
119+
size::NTuple{N,Int},
120+
py::Py,
121+
handle::Py,
122+
) where {T,N,M}
123+
T isa DataType || error("T must be a DataType")
124+
N isa Int || error("N must be an Int")
125+
M isa Bool || error("M must be a Bool")
126+
new{T,N,M}(ptr, size, py, handle)
127+
end
128+
end
129+
93130
"""
94131
PyDict{K=Py,V=Py}([x])
95132

src/Compat/serialization.jl

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -46,18 +46,21 @@ end
4646
Serialization.deserialize(s::AbstractSerializer, ::Type{PyException}) =
4747
PyException(deserialize_py(s))
4848

49-
### PyArray
49+
### PyArray and PyDenseArray
5050
#
51-
# This type holds a pointer and a handle (usually a python memoryview or capsule) which are
51+
# These types hold a pointer and a handle (usually a python memoryview or capsule) which are
5252
# not serializable by default, and even if they were would not be consistent after
5353
# serializing each field independently. So we just serialize the wrapped Python object.
5454

55-
function Serialization.serialize(s::AbstractSerializer, x::PyArray)
55+
function Serialization.serialize(s::AbstractSerializer, x::Union{PyArray,PyDenseArray})
5656
Serialization.serialize_type(s, typeof(x), false)
5757
serialize_py(s, x.py)
5858
end
5959

60-
function Serialization.deserialize(s::AbstractSerializer, ::Type{T}) where {T<:PyArray}
60+
function Serialization.deserialize(
61+
s::AbstractSerializer,
62+
::Type{T},
63+
) where {T<:Union{PyArray,PyDenseArray}}
6164
# TODO: set buffer and array args too?
6265
T(deserialize_py(s); copy = false)
6366
end

src/Wrap/PyArray.jl

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -729,3 +729,110 @@ function pyarray_check_T(::Type{T}, ::Type{R}) where {T,R}
729729
error("invalid eltype T=$T for raw eltype R=$R")
730730
end
731731
end
732+
733+
# PyDenseArray
734+
735+
ispy(::PyDenseArray) = true
736+
Py(x::PyDenseArray) = x.py
737+
Utils.ismutablearray(x::PyDenseArray{T,N,M}) where {T,N,M} = M
738+
739+
function pydensearray_iscontiguous(strides, expected, size)
740+
all(size[i] == 1 || strides[i] == expected[i] for i in eachindex(size))
741+
end
742+
743+
# The size of the dense array if the data of x is contiguous, or nothing if it is not.
744+
# Dimensions are reversed if the data is row-major.
745+
function pydensearray_size(x::PyArray{T,N,M,L,T}) where {T,N,M,L}
746+
if x.length == 0
747+
x.size
748+
elseif pydensearray_iscontiguous(x.strides,
749+
Utils.size_to_fstrides(sizeof(T), x.size),
750+
x.size)
751+
x.size
752+
elseif pydensearray_iscontiguous(x.strides,
753+
Utils.size_to_cstrides(sizeof(T), x.size),
754+
x.size)
755+
reverse(x.size)
756+
else
757+
nothing
758+
end
759+
end
760+
761+
pydensearray_size(::PyArray) = nothing
762+
763+
function PyDenseArray(x::PyArray{T,N,M,L,T}) where {T,N,M,L}
764+
size = pydensearray_size(x)
765+
if isnothing(size)
766+
error("array data is not contiguous")
767+
end
768+
769+
PyDenseArray{T,N,M}(Val(:new), x.ptr, size, x.py, x.handle)
770+
end
771+
772+
function pydensearray_make(
773+
::Type{A},
774+
x::Py;
775+
array::Bool = true,
776+
buffer::Bool = true,
777+
copy::Bool = true,
778+
) where {A<:PyDenseArray}
779+
r = pyarray_make(PyArray, x; array, buffer, copy)
780+
if pyconvert_isunconverted(r)
781+
return pyconvert_unconverted()
782+
end
783+
784+
p = pyconvert_result(PyArray, r)
785+
if pydensearray_size(p) === nothing
786+
return pyconvert_unconverted()
787+
end
788+
789+
d = PyDenseArray(p)
790+
if d isa A
791+
return pyconvert_return(d)
792+
else
793+
return pyconvert_unconverted()
794+
end
795+
end
796+
797+
(::Type{A})(
798+
x;
799+
array::Bool = true,
800+
buffer::Bool = true,
801+
copy::Bool = true,
802+
) where {A<:PyDenseArray} = @autopy x begin
803+
r = pydensearray_make(A, x_; array, buffer, copy)
804+
if pyconvert_isunconverted(r)
805+
error("cannot convert this Python '$(pytype(x_).__name__)' to a '$A'")
806+
else
807+
return pyconvert_result(r)::A
808+
end
809+
end
810+
811+
pyconvert_rule_densearray_nocopy(::Type{A}, x::Py) where {A<:PyDenseArray} =
812+
pydensearray_make(A, x; copy = false)
813+
814+
Base.size(x::PyDenseArray) = x.size
815+
Base.IndexStyle(::Type{<:PyDenseArray}) = Base.IndexLinear()
816+
Base.unsafe_convert(::Type{Ptr{T}}, x::PyDenseArray{T}) where {T} = x.ptr
817+
Base.elsize(::Type{<:PyDenseArray{T}}) where {T} = sizeof(T)
818+
819+
function Base.showarg(io::IO, x::PyDenseArray{T,N}, toplevel::Bool) where {T,N}
820+
if !toplevel
821+
print(io, "::")
822+
end
823+
824+
print(io, "PyDenseArray{")
825+
show(io, T)
826+
print(io, ", ", N, "}")
827+
end
828+
829+
@propagate_inbounds function Base.getindex(x::PyDenseArray, i::Int)
830+
@boundscheck checkbounds(x, i)
831+
unsafe_load(x.ptr, i)
832+
end
833+
834+
@propagate_inbounds function Base.setindex!(x::PyDenseArray{T,N,true}, v, i::Int) where {T,N}
835+
@boundscheck checkbounds(x, i)
836+
unsafe_store!(x.ptr, convert(T, v), i)
837+
return x
838+
end

src/Wrap/Wrap.jl

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ using ..Convert
1414
using ..PyMacro
1515

1616
import ..PythonCall:
17-
PyArray, PyDict, PyIO, PyIterable, PyList, PyPandasDataFrame, PySet, PyTable
17+
PyArray, PyDenseArray, PyDict, PyIO, PyIterable, PyList, PyPandasDataFrame, PySet, PyTable
1818

1919
using Base: @propagate_inbounds
2020
using Tables: Tables
@@ -81,6 +81,10 @@ function __init__()
8181
pyconvert_add_rule("<arrayinterface>", AbstractArray, pyconvert_rule_array, priority)
8282
pyconvert_add_rule("<array>", AbstractArray, pyconvert_rule_array, priority)
8383
pyconvert_add_rule("<buffer>", AbstractArray, pyconvert_rule_array, priority)
84+
pyconvert_add_rule("<arraystruct>", PyDenseArray, pyconvert_rule_densearray_nocopy, priority)
85+
pyconvert_add_rule("<arrayinterface>", PyDenseArray, pyconvert_rule_densearray_nocopy, priority)
86+
pyconvert_add_rule("<array>", PyDenseArray, pyconvert_rule_densearray_nocopy, priority)
87+
pyconvert_add_rule("<buffer>", PyDenseArray, pyconvert_rule_densearray_nocopy, priority)
8488
end
8589

8690
end

test/Project.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
Aqua = "4c88cf16-eb10-579e-8560-4a9242c79595"
33
CondaPkg = "992eb4ea-22a4-4c89-a5bb-47a3300528ab"
44
Dates = "ade2ca70-3891-5945-98fb-dc099432e06a"
5+
LinearAlgebra = "37e2e46d-f89d-539d-b4ee-838fcccc9c8e"
56
Markdown = "d6f4376e-aef5-505a-96c1-9c027394607a"
67
PyCall = "438e738f-606a-5dbb-bf0a-cddfbfd45ab0"
78
PythonCall = "6099a3de-0909-46bc-b1f4-468b9a2dfc0d"

test/Wrap.jl

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,114 @@
101101
end
102102
end
103103

104+
@testitem "PyDenseArray" setup=[Setup] begin
105+
using LinearAlgebra
106+
x = pyimport("array").array("d", pylist(0:5))
107+
y = PyDenseArray(x)
108+
109+
# Helper function to create a row-major Float64 array
110+
function rowmajor(vals, shape)
111+
arr = pyimport("array").array("d", pylist(vals))
112+
bytearr = pybuiltins.bytearray(arr.tobytes())
113+
114+
pybuiltins.memoryview(bytearr).cast("d", pylist(shape))
115+
end
116+
117+
@testset "construct" begin
118+
@test y isa PyDenseArray{Float64,1,true}
119+
@test y isa StridedVector{Float64}
120+
@test Py(y) === x
121+
@test PyDenseArray{Float64,1,true}(x) isa PyDenseArray{Float64,1,true}
122+
@test PyDenseArray(PyArray(x)) isa PyDenseArray{Float64,1,true}
123+
@test PyDenseArray(pybytes(b"abc")) isa PyDenseArray{UInt8,1,false}
124+
125+
@test pyconvert(PyDenseArray, x) isa PyDenseArray{Float64,1,true}
126+
@test pyconvert(PyDenseArray{Float64,1}, x) isa PyDenseArray{Float64,1,true}
127+
# Defaults are unchanged
128+
@test pyconvert(Any, x) isa PyArray
129+
@test pyconvert(DenseArray, x) isa Array
130+
131+
@test_throws Exception PyDenseArray{Int}(x)
132+
@test_throws Exception PyDenseArray{Float64,1,false}(x)
133+
# Non-contiguous
134+
strided = pybuiltins.memoryview(x)[pyslice(nothing, nothing, 2)]
135+
@test_throws Exception PyDenseArray(strided)
136+
@test_throws Exception pyconvert(PyDenseArray, strided)
137+
138+
if Setup.devdeps
139+
np = pyimport("numpy")
140+
# Object arrays have eltype Py, which is not the buffer eltype
141+
@test_throws Exception PyDenseArray(np.array(pylist([1, "a"]), dtype = np.object_))
142+
end
143+
end
144+
145+
@testset "shape" begin
146+
# Row-major data is reversed
147+
c = PyDenseArray(rowmajor(0:5, [2, 3]))
148+
@test size(c) == (3, 2)
149+
@test strides(c) == (1, 3)
150+
@test c == transpose(PyArray(Py(c)))
151+
152+
# Arrays with dimensions of size 1 are not
153+
@test size(PyDenseArray(rowmajor(0:3, [1, 4]))) == (1, 4)
154+
155+
# Nor is column-major data
156+
if Setup.devdeps
157+
np = pyimport("numpy")
158+
f = PyDenseArray(np.asfortranarray(np.arange(6.0).reshape(2, 3)))
159+
@test size(f) == (2, 3)
160+
@test f == PyArray(Py(f))
161+
end
162+
end
163+
164+
@testset "indexing" begin
165+
@test Base.IndexStyle(y) === Base.IndexLinear()
166+
@test length(y) == 6
167+
@test pointer(y) == pointer(PyArray(x))
168+
@test pointer(y, 2) == pointer(y) + sizeof(Float64) # requires elsize()
169+
@test y[2] == 1.0
170+
@test_throws BoundsError y[7]
171+
172+
y[2] = 42
173+
@test pyeq(Bool, x[1], 42.0)
174+
@test_throws Exception PyDenseArray(pybytes(b"abc"))[1] = 0x00
175+
end
176+
177+
@testset "strided dispatch" begin
178+
# dot() has a BLAS method for StridedVector{Float64}
179+
@test which(dot, (typeof(y), typeof(y))) ==
180+
which(dot, (Vector{Float64}, Vector{Float64}))
181+
@test which(dot, (typeof(y), typeof(y))) !=
182+
which(dot, (typeof(PyArray(x)), typeof(PyArray(x))))
183+
184+
a = PyDenseArray(rowmajor(0:5, [2, 3])) # 3×2
185+
b = PyDenseArray(rowmajor(0:5, [3, 2])) # 2×3
186+
@test mul!(zeros(3, 3), a, b) Matrix(a) * Matrix(b)
187+
@test view(a, :, 1:2) isa StridedArray
188+
@test copy(a) isa Matrix{Float64}
189+
end
190+
191+
@testset "serialize" begin
192+
using Serialization: serialize, deserialize
193+
arrays = Any[x]
194+
195+
if Setup.devdeps
196+
np = pyimport("numpy")
197+
push!(arrays, np.arange(6.0).reshape(2, 3))
198+
end
199+
200+
for a in arrays
201+
c = PyDenseArray(a)
202+
io = IOBuffer()
203+
serialize(io, c)
204+
seekstart(io)
205+
c2 = deserialize(io)
206+
@test typeof(c2) == typeof(c)
207+
@test c2 == c
208+
end
209+
end
210+
end
211+
104212
@testitem "PyDict" begin
105213
x = pydict(["foo" => 12])
106214
y = PyDict(x)

0 commit comments

Comments
 (0)