-
-
Notifications
You must be signed in to change notification settings - Fork 5.5k
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
WIP: ImmutableArrays (using EA in Base) #44381
Conversation
This commit ports [EscapeAnalysis.jl](https://github.com/aviatesk/EscapeAnalysis.jl) into Julia base. You can find the documentation of this escape analysis at [this GitHub page](https://aviatesk.github.io/EscapeAnalysis.jl/dev/)[^1]. [^1]: The same documentation will be included into Julia's developer documentation by this commit. This escape analysis will hopefully be an enabling technology for various memory-related optimizations at Julia's high level compilation pipeline. Possible target optimization includes alias aware SROA (JuliaLang#43888), array SROA (JuliaLang#43909), `mutating_arrayfreeze` optimization (JuliaLang#42465), stack allocation of mutables, finalizer elision and so on[^2]. [^2]: It would be also interesting if LLVM-level optimizations can consume IPO information derived by this escape analysis to broaden optimization possibilities. The primary motivation for porting EA in this PR is to check its impact on latency as well as to get feedbacks from a broader range of developers. The plan is that we first introduce EA in this commit, and then merge the depending PRs built on top of this commit like JuliaLang#43888, JuliaLang#43909 and JuliaLang#42465 This commit simply defines and runs EA inside Julia base compiler and enables the existing test suite with it. In this commit, we just run EA before inlining to generate IPO cache. The depending PRs, EA will be invoked again after inlining to be used for various local optimizations.
Enhances SROA of mutables using the novel Julia-level escape analysis (on top of JuliaLang#43800): 1. alias-aware SROA, mutable ϕ-node elimination 2. `isdefined` check elimination 3. load-forwarding for non-eliminable but analyzable mutables --- 1. alias-aware SROA, mutable ϕ-node elimination EA's alias analysis allows this new SROA to handle nested mutables allocations pretty well. Now we can eliminate the heap allocations completely from this insanely nested examples by the single analysis/optimization pass: ```julia julia> function refs(x) (Ref(Ref(Ref(Ref(Ref(Ref(Ref(Ref(Ref(Ref((x))))))))))))[][][][][][][][][][] end refs (generic function with 1 method) julia> refs("julia"); @allocated refs("julia") 0 ``` EA can also analyze escape of ϕ-node as well as its aliasing. Mutable ϕ-nodes would be eliminated even for a very tricky case as like: ```julia julia> code_typed((Bool,String,)) do cond, x # these allocation form multiple ϕ-nodes if cond ϕ2 = ϕ1 = Ref{Any}("foo") else ϕ2 = ϕ1 = Ref{Any}("bar") end ϕ2[] = x y = ϕ1[] # => x return y end 1-element Vector{Any}: CodeInfo( 1 ─ goto JuliaLang#3 if not cond 2 ─ goto JuliaLang#4 3 ─ nothing::Nothing 4 ┄ return x ) => Any ``` Combined with the alias analysis and ϕ-node handling above, allocations in the following "realistic" examples will be optimized: ```julia julia> # demonstrate the power of our field / alias analysis with realistic end to end examples # adapted from http://wiki.luajit.org/Allocation-Sinking-Optimization#implementation%5B abstract type AbstractPoint{T} end julia> struct Point{T} <: AbstractPoint{T} x::T y::T end julia> mutable struct MPoint{T} <: AbstractPoint{T} x::T y::T end julia> add(a::P, b::P) where P<:AbstractPoint = P(a.x + b.x, a.y + b.y); julia> function compute_point(T, n, ax, ay, bx, by) a = T(ax, ay) b = T(bx, by) for i in 0:(n-1) a = add(add(a, b), b) end a.x, a.y end; julia> function compute_point(n, a, b) for i in 0:(n-1) a = add(add(a, b), b) end a.x, a.y end; julia> function compute_point!(n, a, b) for i in 0:(n-1) a′ = add(add(a, b), b) a.x = a′.x a.y = a′.y end end; julia> compute_point(MPoint, 10, 1+.5, 2+.5, 2+.25, 4+.75); julia> compute_point(MPoint, 10, 1+.5im, 2+.5im, 2+.25im, 4+.75im); julia> @allocated compute_point(MPoint, 10000, 1+.5, 2+.5, 2+.25, 4+.75) 0 julia> @allocated compute_point(MPoint, 10000, 1+.5im, 2+.5im, 2+.25im, 4+.75im) 0 julia> compute_point(10, MPoint(1+.5, 2+.5), MPoint(2+.25, 4+.75)); julia> compute_point(10, MPoint(1+.5im, 2+.5im), MPoint(2+.25im, 4+.75im)); julia> @allocated compute_point(10000, MPoint(1+.5, 2+.5), MPoint(2+.25, 4+.75)) 0 julia> @allocated compute_point(10000, MPoint(1+.5im, 2+.5im), MPoint(2+.25im, 4+.75im)) 0 julia> af, bf = MPoint(1+.5, 2+.5), MPoint(2+.25, 4+.75); julia> ac, bc = MPoint(1+.5im, 2+.5im), MPoint(2+.25im, 4+.75im); julia> compute_point!(10, af, bf); julia> compute_point!(10, ac, bc); julia> @allocated compute_point!(10000, af, bf) 0 julia> @allocated compute_point!(10000, ac, bc) 0 ``` 2. `isdefined` check elimination This commit also implements a simple optimization to eliminate `isdefined` call by checking load-fowardability. This optimization may be especially useful to eliminate extra allocation involved with a capturing closure, e.g.: ```julia julia> callit(f, args...) = f(args...); julia> function isdefined_elim() local arr::Vector{Any} callit() do arr = Any[] end return arr end; julia> code_typed(isdefined_elim) 1-element Vector{Any}: CodeInfo( 1 ─ %1 = $(Expr(:foreigncall, :(:jl_alloc_array_1d), Vector{Any}, svec(Any, Int64), 0, :(:ccall), Vector{Any}, 0, 0))::Vector{Any} └── goto JuliaLang#3 if not true 2 ─ goto JuliaLang#4 3 ─ $(Expr(:throw_undef_if_not, :arr, false))::Any 4 ┄ return %1 ) => Vector{Any} ``` 3. load-forwarding for non-eliminable but analyzable mutables EA also allows us to forward loads even when the mutable allocation can't be eliminated but still its fields are known precisely. The load forwarding might be useful since it may derive new type information that succeeding optimization passes can use (or just because it allows simpler code transformations down the load): ```julia julia> code_typed((Bool,String,)) do c, s r = Ref{Any}(s) if c return r[]::String # adce_pass! will further eliminate this type assert call also else return r end end 1-element Vector{Any}: CodeInfo( 1 ─ %1 = %new(Base.RefValue{Any}, s)::Base.RefValue{Any} └── goto JuliaLang#3 if not c 2 ─ return s 3 ─ return %1 ) => Union{Base.RefValue{Any}, String} ``` --- Please refer to the newly added test cases for more examples. Also, EA's alias analysis already succeeds to reason about arrays, and so this EA-based SROA will hopefully be generalized for array SROA as well.
9c84ddc
to
cdef102
Compare
Status? |
Just doing some old PR cleanup: we no longer have |
Wait so are ImmutableArrays dead? This has been in the works since #31630 in 2019... |
I am really confused, why was this closed? @vtjnash what do you mean, we no longer have arrays? Surely that's not true as stated, I am guessing you meant something else, but what? |
This PR would need to be completely redone to handle |
It is extremely dissappointing that this effort has been killed off, after being in the works since 2019 over many PRs, with no obvious continuation. Immutable arrays seem like such an obvious win for both performance and memory safety. I'm sad to hear there has not been more interest in this direction. For those with immediate interest in this, if there are no other options, perhaps we can revive ReadOnlyArrays.jl. I just made a PR to add some caching information on the shapes JuliaArrays/ReadOnlyArrays.jl#13 which might get some performance wins. Perhaps some of the work done in this PR and the ones it has forked can be moved over to this or another package. |
That's a very uncharitable take. Clearly it would be better to build this functionality on the new Memory type #51319 because that would have less overhead and it's a simpler primitive, like how Array is now built on Memory. However, that would be a rather massive change. On top of that, there have been 2 or 3 LLVM major version upgrades since this PR was last modified, and so a lot of the LLVM parts would be rather stale as well. So it should be very clear why a code that does this would likely be better off starting almost from scratch, or using this PR more for ideas than for code. But that isn't necessarily a bad thing. One of the big purposes of the Memory PR was to make such implementations of new array primitives, like immutable arrays and string backed arrays, much simpler to represent to the compiler so that the optimizations would be easier to achieve. As such, you can think of the merge of Memory as a major step forward in this direction, as a lot of what is done in here now is just a standard Julia thing, using the Memory type and its API. So because there have been many strides there, this PR that starts from a position that completely ignores all of the other changes in the last 2 years is simply not a good idea |
Yeah, closing a PR that has had no work done on it for years and is completely outdated cannot be described as "this effort has been killed off". It was already dead! |
I thought part of the reason this approach was abandoned was because we decided the memory model was inherently wrong. With the new |
Yes exactly. So you'd just never expose |
Fair enough. I’m just bummed out by the news I guess. |
The Memory type is probably much closer to a solution already than this ever was. |
That's good. I have my fingers crossed someone can take this up. Immutable arrays would be amazing. |
FWIW, I think a SubArray of a Memory looks roughly equivalent to a ReadOnlyArray already (in some ways lighter, some heavier, some more powerful, so not exactly equivalent--but mostly in the ways that SubArray already was roughly equivalent to it--but now lighter weight since it doesn't carry around the mutable Array) I also have observed short lived Arrays get entirely removed by the compiler now and the bounds hoisted into the caller. So sometimes this doesn't need any special care anymore also, just a short lived Array. |
That's great to hear. I think an immutable array would also be very useful from a memory safety standpoint. The dream here would be to enable packages to fully emulate rust-like ownership for regular mutable arrays. |
Rust ownership is problematic in its own ways. Tracking that sort of thing requires it's own additional compiler pass which is likely to result in nontrivial compile time costs. I think we'll find that other paradigms for memory safety will emerge in the future as we solidify when to apply assume effects and escape analysis better |
I would in principle be happy with that but from your wording it sounds like this might be far into the future (?) which worries me, especially given this direction technically started 5 years ago in #31630. I'm not expecting to convince anybody but I guess all I can say is that I think immutable arrays/memory safety should be a driving goal, rather than a side effect which emerges as a result of other developments... Funnily enough, as you might have heard, US leadership thinks so too (and makes explicit mention of Rust in the report). Right now the only options for (partial) memory safety in Julia arrays are:
It would great if someone wants to take up translating this PR to the new |
There might be some confusion here about what "memory safe" means. Julia is a memory safe language: by default, if you access out-of-bounds memory, you get an error. Like all other languages, there are ways to opt out of default memory saftey (e.g. when people write code with |
I might push back a bit against thinking of memory safety as a binary attribute like that... Out-of-bounds checking contributes to more memory safety than without. But there are other ways to improve here. Currently you are allowed to have multiple mutable references to arrays, and there is no way to make an immutable version. In Rust you can only have a single mutable reference but multiple immutable references. If Julia had immutable arrays we could emulate parts of this – improving memory safety. For what it's worth I had in mind the broader definition as "safety from memory errors". See https://en.wikipedia.org/wiki/Memory_safety#Types_of_memory_errors – race conditions are included in this list. But I could totally see that some people might use "memory safety" to refer specifically to bounds checking (this is not what I meant). |
For reference, copying that down. In that, the only one that is possible is race conditions. But of course, no language can help that in general. See:
I don't understand the straw man that you're trying to attack here. Everybody here is for having a form of immutable arrays. Closing this PR is getting us closer to immutable arrays, the specialized copy removal is not needed as much with new LLVM optimizations on Memory, it just needs a completely different PR for a high level type. Isn't that what you wanted? |
I’m not sure how data races are a strawman. Prevention of such issues is exactly the reason I'm interested in immutable arrays in the first place. Anyways let me restate my argument to help clarify, as it seems we have gotten a bit caught up over details. I would like to petition for ImmutableArray, in whatever form, to be prioritised. Increased memory safety in Julia would be hugely useful for large projects. In the past I have spent months debugging data races and it has been very painful in Julia:
Rust (Safe Rust) is guaranteed to prevent data races b/c of its ownership system (max 1 mutable ref) which is very nice for writing robust code. Note the first sentence of that page you shared:
The part which you highlighted refers to slightly difference concept. Improved memory safety is favored by industry which is partly why Rust has taken off (because data races are so hard to debug). I think ImmutableArray would be a huge step towards making Julia more memory safe.
Yeah it is definitely a good thing. But I got the sense that nobody was actively working on the 'different PR for a high level type' and the effort has stalled. So I wanted to give some words of encouragment that this is a highly desired feature (hence all my comments here). |
I don't think it's so much a problem of motivation as it is priority and available time. There's still a lot of things to do with improving memory management (safety, runtime speed, GC, etc.). If someone where to make a PR putting together the complete and tested (but unoptimized) interface for an If your really eager, try putting together a small personal package using the Julia version supporting struct ImmutableArray{T, N} <: DenseArray{T, N}
mem::Memory{T}
size::NTuple{N, Int}
end
Base.size(x::ImmutableArray) = getfield(x, :size)
# TODO add inaccessiblememonly effect where `Base.ismutationfree(T)`
@propagate_inbounds Base.getindex(x::ImmutableArray{T}, i::Int) where {T} = x[i]
function Base.setindex!(x::ImmutableArray, val, inds...)
throw(ArgumentError("Cannot change values of ImmutableArray"))
end There are some rough edges where Julia relies on |
I agree with @MilesCranmer in spirit, but I think half-measures aren't that useful. C and C++ have const references. They're useful, but far less so than Rust. Given the declaration: void foo(std::vector<double> &x, const std::vector<double> &y); Is No... void bar(std::vector<double> &x){
foo(x, x);
} Thus, just because a function signature says a variable is It is still a useful feature IMO, but it is limited compared to Rust. This is especially helpful with multi-threading! Unlike for C++, where the compilers can do extremely little to optimize While I've complained before about how So, my point here is that just an immutable wrapper isn't good enough, if it is possible for that memory to alias something that isn't mutable. I put impossible in quotes, because presumably we could still pass pointers to C routines like BLAS, and that makes it difficult. But I would prefer some heavy handedness -- even forbidding |
We definitely need to have a better way of dealing with pointers and private vs public access to struct members. I think there are multiple issues for both of those and I don't recall anyone being against these in the past. So I'm assuming comments here are not arguing against making more progress on those or being dismissive of their importance. They just don't need to be solved entirely before implementing an Personally, I think the best next step would be defining another It would be easier to implement |
I don't think we even need |
That's what I'm primarily interested in but I can't speak for others. We might even be able to get away with |
Just a drive-by note that |
I agree that we need to figure other things out before asserting effects, which is why I didn't actually implement it. I just included that note to give an idea of what kind of optimizations we might be implementing later on. |
Yes, that is why I am pointing out that no additional optimizations are valid to implement on that later on. |
This rebases #42465 against #43888, which is where we are currently staging optimizations that utilize the version of EscapeAnalysis that's in Base.
CC: @aviatesk