Skip to content

Commit

Permalink
Faster pycall. Adds pycall! (JuliaPy#492)
Browse files Browse the repository at this point in the history
* Add PyFuncWrap

* Add pydecref to old ret value, and fix bug in benchmark

* pycall!

* Faster generic pycall

* PyFuncWrap -> pywrapfn

* rename to pycalls.jl

* More tests, better docstrings

* Fix some tests and debugging stuff...

* no longer needed tuplen

* Revert pywrapfn back to a struct

worked out why it was benchmarking slightly slower than the closure version. I think the struct version is clearer.

* rename pycalls.jl to pyfncall.jl

* Ensure only single ref to pyargsptr tuple

* Fix bug in PyWrapFn and change to vararg

* Fix pyargsptr check for empty tuple

* Remove PyWrapFn

* Add `pycall!` tests. Remove pywrapfn tests

* Remove kwargs type in _pycall!

Attempt to fix 0.7 issue

* Stop using NumPy in tests

* 0.7 Deprecations

* Add tests for kwargs

* Ptr{Nothing} to Ptr{Cvoid}

* Move pycall_legacy and pywrapfn to benchmarks
  • Loading branch information
JobJob authored and stevengj committed Jul 11, 2018
1 parent 8e89bc4 commit e42d165
Show file tree
Hide file tree
Showing 7 changed files with 409 additions and 66 deletions.
58 changes: 58 additions & 0 deletions benchmarks/callperf.jl
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
using PyCall, BenchmarkTools, DataStructures
using PyCall: _pycall!

include("pywrapfn.jl")
include("pycall_legacy.jl")

results = OrderedDict{String,Any}()

let
np = pyimport("numpy")
nprand = np["random"]["rand"]
ret = PyNULL()
args_lens = (0,1,2,3,7,12,17)
# args_lens = (7,3,1)
# args_lens = (3,)
arr_sizes = (ntuple(i->1, len) for len in args_lens)

for (i, arr_size) in enumerate(arr_sizes)
nprand_pywrapfn = pywrapfn(nprand, length(arr_size))

pyargsptr = ccall((@pysym :PyTuple_New), PyPtr, (Int,), length(arr_size))
arr_size_str = args_lens[i] < 5 ? "$arr_size" : "$(args_lens[i])*(1,1,...)"

results["pycall_legacy $arr_size_str"] = @benchmark pycall_legacy($nprand, PyObject, $arr_size...)
println("pycall_legacy $arr_size_str:\n"); display(results["pycall_legacy $arr_size_str"])
println("--------------------------------------------------")

results["pycall $arr_size_str"] = @benchmark pycall($nprand, PyObject, $arr_size...)
println("pycall $arr_size_str:\n"); display(results["pycall $arr_size_str"])
println("--------------------------------------------------")

results["pycall! $arr_size_str"] = @benchmark pycall!($ret, $nprand, PyObject, $arr_size...)
println("pycall! $arr_size_str:\n"); display(results["pycall! $arr_size_str"])
println("--------------------------------------------------")

results["_pycall! $arr_size_str"] = @benchmark $_pycall!($ret, $pyargsptr, $nprand, $arr_size)
println("_pycall! $arr_size_str:\n"); display(results["_pycall! $arr_size_str"])
println("--------------------------------------------------")

results["nprand_pywrapfn $arr_size_str"] = @benchmark $nprand_pywrapfn($arr_size...)
println("nprand_pywrapfn $arr_size_str:\n"); display(results["nprand_pywrapfn $arr_size_str"])
println("--------------------------------------------------")

# args already set by nprand_pywrapfn calls above
results["nprand_pywrapfn_noargs $arr_size_str"] = @benchmark $nprand_pywrapfn()
println("nprand_pywrapfn_noargs $arr_size_str:\n"); display(results["nprand_pywrapfn_noargs $arr_size_str"])
println("--------------------------------------------------")
end
end
#
println("")
println("Mean times")
println("----------")
foreach((r)->println(rpad(r[1],33), "\t", mean(r[2])), results)
println("")
println("Median times")
println("----------")
foreach((r)->println(rpad(r[1],33), "\t", median(r[2])), results)
43 changes: 43 additions & 0 deletions benchmarks/pycall_legacy.jl
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
using Base: sigatomic_begin, sigatomic_end
using PyCall: @pycheckz, TypeTuple

"""
Low-level version of `pycall(o, ...)` that always returns `PyObject`.
"""
function _pycall_legacy(o::Union{PyObject,PyPtr}, args...; kwargs...)
oargs = map(PyObject, args)
nargs = length(args)
sigatomic_begin()
try
arg = PyObject(@pycheckn ccall((@pysym :PyTuple_New), PyPtr, (Int,),
nargs))
for i = 1:nargs
@pycheckz ccall((@pysym :PyTuple_SetItem), Cint,
(PyPtr,Int,PyPtr), arg, i-1, oargs[i])
pyincref(oargs[i]) # PyTuple_SetItem steals the reference
end
if isempty(kwargs)
ret = PyObject(@pycheckn ccall((@pysym :PyObject_Call), PyPtr,
(PyPtr,PyPtr,PyPtr), o, arg, C_NULL))
else
#kw = PyObject((AbstractString=>Any)[string(k) => v for (k, v) in kwargs])
kw = PyObject(Dict{AbstractString, Any}([Pair(string(k), v) for (k, v) in kwargs]))
ret = PyObject(@pycheckn ccall((@pysym :PyObject_Call), PyPtr,
(PyPtr,PyPtr,PyPtr), o, arg, kw))
end
return ret::PyObject
finally
sigatomic_end()
end
end

"""
pycall(o::Union{PyObject,PyPtr}, returntype::TypeTuple, args...; kwargs...)
Call the given Python function (typically looked up from a module) with the given args... (of standard Julia types which are converted automatically to the corresponding Python types if possible), converting the return value to returntype (use a returntype of PyObject to return the unconverted Python object reference, or of PyAny to request an automated conversion)
"""
pycall_legacy(o::Union{PyObject,PyPtr}, returntype::TypeTuple, args...; kwargs...) =
return convert(returntype, _pycall_legacy(o, args...; kwargs...)) #::returntype

pycall_legacy(o::Union{PyObject,PyPtr}, ::Type{PyAny}, args...; kwargs...) =
return convert(PyAny, _pycall_legacy(o, args...; kwargs...))
91 changes: 91 additions & 0 deletions benchmarks/pywrapfn.jl
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
using PyCall: @pycheckn, pyincref_, __pycall!

#########################################################################
struct PyWrapFn{N, RT}
o::PyPtr
pyargsptr::PyPtr
ret::PyObject
end

function PyWrapFn(o::Union{PyObject, PyPtr}, nargs::Int, returntype::Type=PyObject)
pyargsptr = ccall((@pysym :PyTuple_New), PyPtr, (Int,), nargs)
ret = PyNULL()
optr = o isa PyPtr ? o : o.o
pyincref_(optr)
return PyWrapFn{nargs, returntype}(optr, pyargsptr, ret)
end

(pf::PyWrapFn{N, RT})(args...) where {N, RT} =
convert(RT, _pycall!(pf.ret, pf.pyargsptr, pf.o, args, N, C_NULL))

(pf::PyWrapFn{N, RT})() where {N, RT} =
convert(RT, __pycall!(pf.ret, pf.pyargsptr, pf.o, C_NULL))

"""
```
pywrapfn(o::PyObject, nargs::Int, returntype::Type{T}=PyObject) where T
```
Wrap a callable PyObject/PyPtr possibly making calling it more performant. The
wrapped version (of type `PyWrapFn`) reduces the number of allocations made for
passing its arguments, and re-uses the same PyObject as its return value each
time it is called.
Mainly useful for functions called in a tight loop. After wrapping, arguments
should be passed in a tuple, rather than directly, e.g. `wrappedfn((a,b))` rather
than `wrappedfn(a,b)`.
Example
```
@pyimport numpy as np
# wrap a 2-arg version of np.random.rand for creating random matrices
randmatfn = pywrapfn(np.random["rand"], 2, PyArray)
# n.b. rand would normally take multiple arguments, like so:
a_random_matrix = np.random["rand"](7, 7)
# but we call the wrapped version with a tuple instead, i.e.
# rand22fn((7, 7)) not
# rand22fn(7, 7)
for i in 1:10^9
arr = rand22fn((7,7))
...
end
```
"""
pywrapfn(o::PyObject, nargs::Int, returntype::Type=PyObject) =
PyWrapFn(o, nargs, returntype)

"""
```
pysetargs!(w::PyWrapFn{N, RT}, args)
```
Set the arguments with which to call a Python function wrapped using
`w = pywrapfn(pyfun, ...)`
"""
function pysetargs!(pf::PyWrapFn{N, RT}, args) where {N, RT}
check_pyargsptr(pf)
pysetargs!(pf.pyargsptr, args, N)
end

"""
```
pysetarg!(w::PyWrapFn{N, RT}, arg, i::Integer=1)
```
Set the `i`th argument to be passed to a Python function previously
wrapped with a call to `w = pywrapfn(pyfun, ...)`
"""
function pysetarg!(pf::PyWrapFn{N, RT}, arg, i::Integer=1) where {N, RT}
check_pyargsptr(pf)
pysetarg!(pf.pyargsptr, arg, i)
end

"""
See check_pyargsptr(nargs::Int) above
"""
function check_pyargsptr(pf::PyWrapFn{N, RT}) where {N, RT}
if unsafe_load(pf.pyargsptr).ob_refcnt > 1
pydecref_(pf.pyargsptr)
pf.pyargsptr =
@pycheckn ccall((@pysym :PyTuple_New), PyPtr, (Int,), nargs)
end
end
76 changes: 10 additions & 66 deletions src/PyCall.jl
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,14 @@ module PyCall

using Compat, VersionParsing

export pycall, pyimport, pyimport_e, pybuiltin, PyObject, PyReverseDims,
export pycall, pycall!, pyimport, pyimport_e, pybuiltin, PyObject, PyReverseDims,
PyPtr, pyincref, pydecref, pyversion, PyArray, PyArray_Info,
pyerr_check, pyerr_clear, pytype_query, PyAny, @pyimport, PyDict,
pyisinstance, pywrap, pytypeof, pyeval, PyVector, pystring, pystr, pyrepr,
pyraise, pytype_mapping, pygui, pygui_start, pygui_stop,
pygui_stop_all, @pylab, set!, PyTextIO, @pysym, PyNULL, ispynull, @pydef,
pyimport_conda, @py_str, @pywith, @pycall, pybytes, pyfunction, pyfunctionret
pyimport_conda, @py_str, @pywith, @pycall, pybytes, pyfunction, pyfunctionret,
pywrapfn, pysetarg!, pysetargs!

import Base: size, ndims, similar, copy, getindex, setindex!, stride,
convert, pointer, summary, convert, show, haskey, keys, values,
Expand Down Expand Up @@ -97,8 +98,13 @@ it is equivalent to a `PyNULL()` object.
"""
ispynull(o::PyObject) = o.o == PyPtr_NULL

function pydecref_(o::PyPtr)
ccall(@pysym(:Py_DecRef), Cvoid, (PyPtr,), o)
return o
end

function pydecref(o::PyObject)
ccall(@pysym(:Py_DecRef), Cvoid, (PyPtr,), o.o)
pydecref_(o.o)
o.o = PyPtr_NULL
o
end
Expand Down Expand Up @@ -689,69 +695,7 @@ function pybuiltin(name)
end

#########################################################################

"""
Low-level version of `pycall(o, ...)` that always returns `PyObject`.
"""
function _pycall(o::Union{PyObject,PyPtr}, args...; kwargs...)
oargs = map(PyObject, args)
nargs = length(args)
sigatomic_begin()
try
arg = PyObject(@pycheckn ccall((@pysym :PyTuple_New), PyPtr, (Int,),
nargs))
for i = 1:nargs
@pycheckz ccall((@pysym :PyTuple_SetItem), Cint,
(PyPtr,Int,PyPtr), arg, i-1, oargs[i])
pyincref(oargs[i]) # PyTuple_SetItem steals the reference
end
if isempty(kwargs)
ret = PyObject(@pycheckn ccall((@pysym :PyObject_Call), PyPtr,
(PyPtr,PyPtr,PyPtr), o, arg, C_NULL))
else
#kw = PyObject((AbstractString=>Any)[string(k) => v for (k, v) in kwargs])
kw = PyObject(Dict{AbstractString, Any}([Pair(string(k), v) for (k, v) in kwargs]))
ret = PyObject(@pycheckn ccall((@pysym :PyObject_Call), PyPtr,
(PyPtr,PyPtr,PyPtr), o, arg, kw))
end
return ret::PyObject
finally
sigatomic_end()
end
end

"""
pycall(o::Union{PyObject,PyPtr}, returntype::TypeTuple, args...; kwargs...)
Call the given Python function (typically looked up from a module) with the given args... (of standard Julia types which are converted automatically to the corresponding Python types if possible), converting the return value to returntype (use a returntype of PyObject to return the unconverted Python object reference, or of PyAny to request an automated conversion)
"""
pycall(o::Union{PyObject,PyPtr}, returntype::TypeTuple, args...; kwargs...) =
return convert(returntype, _pycall(o, args...; kwargs...))::returntype

pycall(o::Union{PyObject,PyPtr}, ::Type{PyAny}, args...; kwargs...) =
return convert(PyAny, _pycall(o, args...; kwargs...))

(o::PyObject)(args...; kws...) = pycall(o, PyAny, args...; kws...)
PyAny(o::PyObject) = convert(PyAny, o)


"""
@pycall func(args...)::T
Convenience macro which turns `func(args...)::T` into pycall(func, T, args...)
"""
macro pycall(ex)
if !(isexpr(ex,:(::)) && isexpr(ex.args[1],:call))
throw(ArgumentError("Usage: @pycall func(args...)::T"))
end
func = ex.args[1].args[1]
args, kwargs = ex.args[1].args[2:end], []
if isexpr(args[1],:parameters)
kwargs, args = args[1], args[2:end]
end
T = ex.args[2]
:(pycall($(map(esc,[kwargs; func; T; args])...)))
end
include("pyfncall.jl")

#########################################################################
# Once Julia lets us overload ".", we will use [] to access items, but
Expand Down
Loading

0 comments on commit e42d165

Please sign in to comment.