When the manual says that @inbounds propagates through a "single layer", it specifically refers to the boundaries of function calls. The fact that it can only affect functions that are built-in is a minor requirement, which makes it particularly confusing and difficult to test, so let's not worry about pasting up to a later point.
The @inbounds macro annotates function calls so that they can exclude border checks. In fact, the macro will do this for all function calls in the expression that is passed to it, including any number of nested for loops, begin blocks, if , etc. And, of course, indexing and indexed assignments are just “sugars” that are lower than function calls, so they affect them the same way. All this makes sense; as the author of the code wrapped by @inbounds , you can see the macro and make sure it is safe.
But the @inbounds macro tells Julia to do something funny. This changes the behavior of code written in a completely different place! For example, when you comment on a call:
julia> f() = @inbounds return getindex(4:5, 10); f() 13
The macro effectively enters the standard library and disables this @boundscheck block, allowing it to compute values outside the valid range.
This is a creepy action at a distance ... and if it is not carefully limited, it can end up by removing border checks from the library code where it is not intended or completely safe for this. That is why there is a “single layer” restriction; We want to remove border checks only when the authors clearly know that this can happen, and enable deletion.
Now, as the author of the library, there may be times when you want to include @inbounds for all the functions that you call in the method. This is where Base.@propagate _inbounds used. Unlike @inbounds , which annotates function calls, @propagate_inbounds annotates method definitions to take into account the inbounds state with which the method is called, to propagate to all function calls that you make in the method implementation. This is a bit difficult to describe in an abstract, so let's look at a specific example.
Example
Let's create a toy user vector that just creates a mixed view in the vector that it wraps:
julia> module M using Random struct ShuffledVector{A,T} <: AbstractVector{T} data::A shuffle::Vector{Int} end ShuffledVector(A::AbstractVector{T}) where {T} = ShuffledVector{typeof(A), T}(A, randperm(length(A))) Base.size(A::ShuffledVector) = size(A.data) Base.@inline function Base.getindex(A::ShuffledVector, i::Int) A.data[A.shuffle[i]] end end
It's quite simple - we wrap any vector type, create a random permutation, and then, when indexing, we simply index into the original array using the permutation. And we know that all accesses to parts of the array should be in order based on an external constructor ... therefore, even if we ourselves do not check the boundaries, we can rely on internal indexing expressions that throw errors if we index outside the borders.
julia> s = M.ShuffledVector(1:4) 4-element Main.M.ShuffledVector{UnitRange{Int64},Int64}: 1 3 4 2 julia> s[5] ERROR: BoundsError: attempt to access 4-element Array{Int64,1} at index [5] Stacktrace: [1] getindex at ./array.jl:728 [inlined] [2] getindex(::Main.M.ShuffledVector{UnitRange{Int64},Int64}, ::Int64) at ./REPL[10]:10 [3] top-level scope at REPL[15]:1
Note that the boundary error is not associated with indexing in the ShuffledVector, but rather with indexing in the permutation vector A.perm[5] . Now, perhaps the user of our ShuffledVector wants his access to be faster, so he is trying to disable border checking with @inbounds :
julia> f(A, i) = @inbounds return A[i] f (generic function with 1 method) julia> f(s, 5) ERROR: BoundsError: attempt to access 4-element Array{Int64,1} at index [5] Stacktrace: [1] getindex at ./array.jl:728 [inlined] [2] getindex at ./REPL[10]:10 [inlined] [3] f(::Main.M.ShuffledVector{UnitRange{Int64},Int64}, ::Int64) at ./REPL[16]:1 [4] top-level scope at REPL[17]:1
But they still get marginal errors! This is because the @inbounds annotation tried to remove only @boundscheck blocks from the method we wrote above. It does not extend to the standard library for removing border checks from either the A.perm array or the A.data range. This is quite a bit of overhead, although they tried to push the boundaries! Thus, instead, we can write the above getindex method with the Base.@propagate _inbounds , which will allow this method to "inherit" its internal state of the caller:
julia> module M using Random struct ShuffledVector{A,T} <: AbstractVector{T} data::A shuffle::Vector{Int} end ShuffledVector(A::AbstractVector{T}) where {T} = ShuffledVector{typeof(A), T}(A, randperm(length(A))) Base.size(A::ShuffledVector) = size(A.data) Base.@propagate _inbounds function Base.getindex(A::ShuffledVector, i::Int) A.data[A.shuffle[i]] end end WARNING: replacing module M. Main.M julia> s = M.ShuffledVector(1:4); julia> s[5] ERROR: BoundsError: attempt to access 4-element Array{Int64,1} at index [5] Stacktrace: [1] getindex at ./array.jl:728 [inlined] [2] getindex(::Main.M.ShuffledVector{UnitRange{Int64},Int64}, ::Int64) at ./REPL[20]:10 [3] top-level scope at REPL[22]:1 julia> f(s, 5) # That @inbounds now affects the inner indexing calls, too! 0
With @code_llvm f(s, 5) you can check that there are no branches.
But, in fact, in this case, I think it would be much better to write an implementation of this getindex method with its own @boundscheck block:
@inline function Base.getindex(A::ShuffledVector, i::Int) @boundscheck checkbounds(A, i) @inbounds r = A.data[A.shuffle[i]] return r end
This is a bit more verbose, but now it will actually ShuffledVector border error for the ShuffledVector type instead of leaking implementation details in the error message.
Embed effect
You will notice that I am not testing @inbounds in the global scope above, but using these little helper functions instead. This is because deleting border validation only works when the method is inline and compiled. Therefore, a simple attempt to remove boundaries in the global area will not work, because it cannot embed a function call in an interactive REPL:
julia> @inbounds getindex(4:5, 10) ERROR: BoundsError: attempt to access 2-element UnitRange{Int64} at index [10] Stacktrace: [1] throw_boundserror(::UnitRange{Int64}, ::Int64) at ./abstractarray.jl:538 [2] getindex(::UnitRange{Int64}, ::Int64) at ./range.jl:617 [3] top-level scope at REPL[24]:1
There is no global compilation or insertion, so Julia cannot remove these boundaries. In the same way, Julia cannot inline methods when type instability occurs (for example, when accessing a mutable global element), so she also cannot remove these border checks:
julia> r = 1:2; julia> g() = @inbounds return r[3] g (generic function with 1 method) julia> g() ERROR: BoundsError: attempt to access 2-element UnitRange{Int64} at index [3] Stacktrace: [1] throw_boundserror(::UnitRange{Int64}, ::Int64) at ./abstractarray.jl:538 [2] getindex(::UnitRange{Int64}, ::Int64) at ./range.jl:617 [3] g() at ./REPL[26]:1 [4] top-level scope at REPL[27]:1
In general, border check removal should be the last optimization that you perform after everything else works, well tested and follows the usual performance tips.