Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Modernize the package #11

Merged
merged 64 commits into from
Mar 8, 2023
Merged

Modernize the package #11

merged 64 commits into from
Mar 8, 2023

Conversation

maleadt
Copy link
Member

@maleadt maleadt commented Feb 28, 2023

This PR updates and significantly reworks the ObjectiveC.jl package. Basic updates include adding a Project.toml, fixing deprecations, adding some tests, etc. However, I also decided to rework the core calling mechanism to not rely on introspection and dynamic code generation anymore, instead opting for a more static @ccall-esque approach that allows for far better performance. It should also combine nicely with e.g. a Clang.jl-based code generator, but that's future work.

Reworking the core mechanism probably results in breakage of other functionality in this package, but it's hard to avoid that since there were no tests. In addition, because there hasn't even been a release, and because the package was broken on any recent Julia version anyway, it is probably fine to do so and gradually improve/unbreak functionality along the way.

cc @habemus-papadum

@habemus-papadum
Copy link

@maleadt Very nice!

@maleadt
Copy link
Member Author

maleadt commented Mar 1, 2023

Replacing the @eval ccall with a generated :foreigncall significantly improves the performance of message calling to the point that it is viable (although we can still improve it by caching the selector lookups, or performing them at parse time if that's possible).

Before:

julia> f() = @objc [[[NSHost currentHost] localizedName] UTF8String]
f (generic function with 1 method)

julia> @benchmark f()
BenchmarkTools.Trial: 1465 samples with 1 evaluation.
 Range (min … max):  3.287 ms …   9.281 ms  ┊ GC (min … max): 0.00% … 0.00%
 Time  (median):     3.370 ms               ┊ GC (median):    0.00%
 Time  (mean ± σ):   3.409 ms ± 264.106 μs  ┊ GC (mean ± σ):  0.24% ± 2.13%

    ▅▆▇▇█▇▇▇▇▆▅▄▄▃▃▁                                          ▁
  ▇▇████████████████▇████▆▅▆▄▁▄▁▄▅▄▄▁▅▁▄▄▁▄▆▆▆▄▆▅▆▆▆▆▆▅▄▆▆▁▄▄ █
  3.29 ms      Histogram: log(frequency) by time      3.88 ms <

 Memory estimate: 162.00 KiB, allocs estimate: 3229.

After:

julia> @benchmark f()
BenchmarkTools.Trial: 10000 samples with 1 evaluation.
 Range (min … max):  30.583 μs … 152.041 μs  ┊ GC (min … max): 0.00% … 0.00%
 Time  (median):     32.500 μs               ┊ GC (median):    0.00%
 Time  (mean ± σ):   33.221 μs ±   3.030 μs  ┊ GC (mean ± σ):  0.00% ± 0.00%

     ▂▆▂▄▅█▄▂
  ▁▂▆████████▆▅▃▃▃▃▂▂▂▂▂▂▂▂▂▂▂▂▁▁▁▁▁▁▂▁▂▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ ▂
  30.6 μs         Histogram: frequency by time         44.3 μs <

 Memory estimate: 2.06 KiB, allocs estimate: 56.

@maleadt
Copy link
Member Author

maleadt commented Mar 1, 2023

https://gist.github.com/fjolnir/2211379/d77f93efd80690125b933667f79600e67da66c1e is also interesting, where there's caching of class and instance methods, and automatic retain/release based on the function name.

src/call.jl Outdated Show resolved Hide resolved
src/call.jl Outdated Show resolved Hide resolved
@habemus-papadum
Copy link

performing them at parse time if that's possible:

Selector lookups are usually done at load time. For instance this is what the metalcpp wrapper is doing:
#define _MTL_PRIVATE_DEF_SEL(accessor, symbol) SEL s_k##accessor _MTL_PRIVATE_VISIBILITY = sel_registerName(symbol);

namespace MTK::Private::Selector
{

_MTK_PRIVATE_DEF_SEL( autoresizeDrawable,
					 "autoresizeDrawable" );
...

Something similar could be done in an __init__ function for a Julia package, and presumably this is what a wrapper tool would do in generated code.

However, being able to do the selector lookup on the fly with relatively low overhead is quite useful -- for situations where one wants to quickly wrap a portion of a framework by hand and also quick experimentation -- so I find this PR really great.

although we can still improve it by caching the selector lookups

Note: the process of invoking a method in Objective-C is:

  • convert string to selector (opaque SEL object) --> usually at load time.
  • convert object-c instance and SEL to a concrete method (done at runtime). A method is an actual function pointer.
  • call the method with arguments

So caching selector lookups and checking a hashtable in Julia at runtime is not likely to provide any improvement to just calling sel_registername on each method invocation (there will be a hahstable lookup either in Julia or C)
the "convert object-c instance and SEL to a concrete method" is probably the largest portion of typical runtime overhead in objective-C, but it is also the source of its dynamism. The runtime does some caching of frequently used methods (https://developer.apple.com/documentation/objectivec/objective-c_runtime/objc_cache?language=objc) to mitigate this. i.e. it probably is neither correct nor necessary for us to do any other performance optimizations.

What the clang compiler also does is static analysis to make sure that objective-c method invocations will not generate runtime errors and that is what a Julia wrapper could do as well (i.e. not improve performance, but help ensure correct use).

Also, a good place to read about the low level details is: https://developer.apple.com/documentation/objectivec/objective-c_runtime?language=objc

@maleadt
Copy link
Member Author

maleadt commented Mar 2, 2023

However, being able to do the selector lookup on the fly with relatively low overhead is quite useful -- for situations where one wants to quickly wrap a portion of a framework by hand and also quick experimentation -- so I find this PR really great.

I wouldn't remove that, but require type annotations so that we can statically generate code instead of having to introspect into the ObjC runtime in order to build a ccall expression. I'm exploring that right now, and it does look very promising.

This is how the package currently works:

julia> str = @objc [NSString string]
__NSCFConstantString Object

julia> @objc [str length]
0x0000000000000000

julia> f() = @objc [str length]
f (generic function with 1 method)

Current master branch:

julia> @benchmark f()
BenchmarkTools.Trial: 6509 samples with 1 evaluation.
 Range (min … max):  702.417 μs …   3.822 ms  ┊ GC (min … max): 0.00% … 50.89%
 Time  (median):     735.834 μs               ┊ GC (median):    0.00%
 Time  (mean ± σ):   765.605 μs ± 129.942 μs  ┊ GC (mean ± σ):  0.29% ±  1.94%

  ▅███▇▆▅▅▄▃▃▂▂▂ ▁▁ ▁   ▁                                       ▂
  ███████████████████▇█▇███▇▇▇▇▇▇▇▇▇▆▇█▇▇▇▆▇▇▆▆▆▇▃▄▅▅▄▅▁▄▅▄▄▁▅▅ █
  702 μs        Histogram: log(frequency) by time       1.25 ms <

 Memory estimate: 59.15 KiB, allocs estimate: 1169.

This PR (removal of @eval, use of generated ccall):

julia> @benchmark f()
BenchmarkTools.Trial: 10000 samples with 10 evaluations.
 Range (min … max):  1.317 μs … 151.921 μs  ┊ GC (min … max): 0.00% … 97.31%
 Time  (median):     1.354 μs               ┊ GC (median):    0.00%
 Time  (mean ± σ):   1.389 μs ±   1.508 μs  ┊ GC (mean ± σ):  1.06% ±  0.97%

   ▁▄▅▆▆█▆▆▅▄▃▃▃▂▁▁▁▁▂▂▂▂▂▂▂▃▁▁   ▁                           ▂
  ▆████████████████████████████████▇▇█▇▇▆█▆▆▇▆▆▇▆▆▆▄▅▅▆▅▅▄▅▅▆ █
  1.32 μs      Histogram: log(frequency) by time       1.6 μs <

 Memory estimate: 648 bytes, allocs estimate: 16.

Proposed static approach:

julia> str = @objc [NSString string]::id
Ptr{ObjectiveC.OpaqueObject} @0x00000001eb6b4d28

julia> @objc [str::id length]::NSUInteger
0x0000000000000000

julia> f() = @objc [str::Id length]::NSUInteger
f (generic function with 1 method)

julia> @benchmark f()
BenchmarkTools.Trial: 10000 samples with 946 evaluations.
 Range (min  max):  98.661 ns  137.024 ns  ┊ GC (min  max): 0.00%  0.00%
 Time  (median):     99.014 ns               ┊ GC (median):    0.00%
 Time  (mean ± σ):   99.406 ns ±   2.098 ns  ┊ GC (mean ± σ):  0.00% ± 0.00%

  ▃█▇▆▄▂             ▁▁▁▁                                      ▂
  ██████▇▆▅▄▃▅▅▅▅▅▄▅▆█████▅▅▅▅▅▄▄▃▃▅▅▅▄▄▄▄▅▄▄▅▅▆▆▃▅▄▅▄▁▁▃▁▃▄▄▄ █
  98.7 ns       Histogram: log(frequency) by time       109 ns <

 Memory estimate: 0 bytes, allocs estimate: 0.

i.e. keeping most of the syntax, only adding some type assertions so that we have the information we need. Note the use of id, I haven't found a good way of encoding additional type information yet (well, it's obvious to switch ::id to ::NSString, but I haven't found a good Object hierarchy yet that makes this possible without too much boilerplate).

@vtjnash
Copy link

vtjnash commented Mar 7, 2023

Something that may be worth discussing: in a8c859f I decided to 'split' the Julia class definitions we generate with @objcwrapper (a helper macro to generate object classes) into two: an abstract class to implement the hierarchy, and a concrete one that contains the object pointer. The motivation here is that Objective-C has multilevel inheritance, e.g., MTLEvent <: NSObject is a perfectly valid object, but there's also MTLSharedEvent <: MTLEvent which augments MTLEvent. We can't express that in Julia, lacking concrete inheritance, and the traditional alternatives (composition instead of inheritance) break dispatch.

After a8c859f, we have MTLEvent be an abstract class so that MTLSharedEvent can inherit from it, and objects will be put in the MTLEventInstance or MTLSharedEventInstance types, depending on the requested type. To make this all invisible, I generate a pseudo constructor for the abstract classes (so calling MTLEvent(ptr::id) will give an MTLEventInstance). Case in point, the change did not break the WIP wrappers I have over in JuliaGPU/Metal.jl#117, yet it did allow me to remove the 'fake' MTLAbstractEvent class I had introduced there. So this looks like a potentially interesting solution for supporting Objective-C's object model (although I may still be missing things, as I'm not really familiar with the language yet and am just designing things in function of the Metal APIs).

I also considered making everything an Object{T}, but that would make methods ugly (e.g., foo(obj::Object{<:MTLEvent}) instead of now simply foo(obj::MTLEvent)).

I faced this challenge in Gtk.jl and ended up doing something similar to this, with GObject and GObjectLeaf (making the interface the easier type, similar to Ref vs. Base.RefValue). I was never quite satisfied with this though, as it felt awkward, led to excess specialization, was a bit hard to extend in disjoint packages, and never quite mapped properly between the type systems (since calling interface methods requires an explicit type cast in the Julia Gtk.jl wrapper). If I was to revisit that decision, I might have gone with a runtime-traits-based approach instead, perhaps with optional mixins.

mutable struct GObject{Mixin}
    classid::Int # or encoded in gptr
    gptr::Ptr{Cvoid}
    mixins::Mixin
end

Then at runtime, it would be sufficient to do the reflection calls needed to decide if a particular interface was applicable, and perhaps also to provide a custom dispatch table hook for places where it is required. This would have let it provide all of the features (multiple inheritance, concrete inheritance, interfaces with inheritance) that are not viable normally.

Secondly, with the current approach, I think I should have at least implemented many convert-trait methods, and coded them up to be visible to reflection:

abstract type AnyInterface; end
abstract type AnyObject; end
struct Interface{T} <: AnyInterface; obj::T; end
inherits_from(::Type, ::Type) = false
inherits_from(::Type{<:Interface}, ::Type{<:Abstract}) = true
convert(::Type{I}, obj::AnyObject) where {I<:AnyInterface} = I(obj)

then when I auto-generated interface calls such as label, I could also auto-generate dispatch methods that try to select the best matching ccall:

struct HasLabel{T} <: AnyInterface; obj::T; end
label(obj::HasLabel) = ccall
function label(obj)
    inherits_from(HasLabel, obj) && return label(HasLabel(obj))
    throw(InterfaceMissingError(label, obj))
end

@maleadt
Copy link
Member Author

maleadt commented Mar 7, 2023

Thanks for the write-up, I should think about if an how to incorporate that here.

Regarding blocks, I pushed an initial version that simply looks like:

function hello(x)
    println("Hello, world $(x)!")
    return Cint(42)
end

block = @objcblock(hello, Cint, (Cint,))

This works when attaching the block to a class, now I'll see if it works for Metal. I'm not sure I'm correctly managing memory (i.e., I'm not doing much). As our block doesn't contain any GC-managed data (only a Julia function, and IIRC functions aren't garbage collected), I think I can get away by just copying it to the heap and doing nothing (well, not doing any refcounting). I'm not sure who's responsible for releasing the block object itself though.

@vchuravy
Copy link

vchuravy commented Mar 7, 2023

; we can avoid doing so because of Julia's runtime supporting closures with @cfunction

No on AArch64 sadly

@tgymnich
Copy link
Contributor

tgymnich commented Mar 7, 2023

This might be useful for reference.

@maleadt
Copy link
Member Author

maleadt commented Mar 7, 2023

; we can avoid doing so because of Julia's runtime supporting closures with @cfunction

No on AArch64 sadly

I have an alternative, storing the jl_value_t pointing to the callable (it be a singleton function or a closure struct), but that does require memory management. So I'll look into that.

@vtjnash
Copy link

vtjnash commented Mar 7, 2023

only a Julia function, and IIRC functions aren't garbage collected

@vtjnash
Copy link

vtjnash commented Mar 7, 2023

cfunction is garbage collected (that's why it is unsafe to convert it to a pointer)

@maleadt
Copy link
Member Author

maleadt commented Mar 7, 2023

cfunction is garbage collected (that's why it is unsafe to convert it to a pointer)

All of cfunction, so also when not using closures?
But yeah I'll work on adding proper memory management (probably just rooting the Block in a global dict and evicting it from the Block's dispose callback).

@maleadt
Copy link
Member Author

maleadt commented Mar 8, 2023

OK, I got the basic functionality to support Metal.jl working, so I think I'll cut a 0.1 release so that I can more easily test this package in a realistic setting. FWIW, I removed the broken bits that I didn't (yet) need, including support for defining classes, so I'll create an issue to keep track of bringing those bits back. But since ObjectiveC.jl hadn't even been tagged, and was completely broken at this point, I don't feel bad removing it for now.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

5 participants