Julia 中的@inbounds 传播规则

@inbounds propagation rules in Julia

我正在寻找 Julia 中 bounds checking rules 的一些说明。这是否意味着如果我将 @inbounds 放在 for 循环的开头,

@inbounds for ... end

然后仅针对“一层”入站传播,因此如果其中有一个 for 循环,@inbounds 不会关闭那里的边界检查吗?如果我使用 @propagate_inbounds,它会进入嵌套的 for 循环吗?

@inbounds 总是胜过 @boundscheck 这样说对吗?唯一的例外是函数没有内联,但这只是前面“一层”规则的一个例子,所以 @propagate_inbounds 即使在非内联函数调用中也会关闭边界检查?

当手册谈到 @inbounds 通过 "one layer," 传播时,它专门指的是函数调用边界。它只能影响被内联的函数这一事实是一个次要要求,这使得它特别令人困惑且难以测试,所以让我们稍后再担心内联。

@inbounds 宏注释函数调用,以便它们能够省略边界检查。事实上,宏将对传递给它的表达式中的 all 函数调用执行此操作,包括任意数量的嵌套 for 循环、begin 块, if 语句等。当然,索引和索引赋值只是 "sugars" 低于函数调用,因此它以相同的方式影响它们。这一切都是有道理的;作为 @inbounds 封装的代码的作者,您可以查看宏并确保这样做是安全的。

但是 @inbounds 宏告诉 Julia 做一些有趣的事情。它改变了在完全不同的地方编写的代码的行为!例如,当您注释调用时:

julia> f() = @inbounds return getindex(4:5, 10);
       f()
13

宏有效地进入 the standard library 并禁用 @boundscheck 块,允许它计算范围有效区域之外的值。

这是一个令人毛骨悚然的远距离动作……如果不仔细限制它,它最终可能会从库代码中删除边界检查,而这不是有意或完全安全的。这就是为什么有 "one-layer" 限制;我们只想在作者明确意识到可能发生边界检查并选择加入删除时删除边界检查。

现在,作为库作者,在某些情况下,您可能希望选择允许 @inbounds 传播到您在方法中调用的所有函数。这就是使用 Base.@propagate_inbounds 的地方。与注释函数调用的 @inbounds 不同,@propagate_inbounds 注释 方法定义 以允许调用方法的入站状态传播到 all 您在方法实现中进行的函数调用。这个抽象起来有点难描述,我们来看一个具体的例子。

一个例子

让我们创建一个玩具自定义矢量,它只是在它包装的矢量中创建一个随机视图:

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

这非常简单——我们包装任何向量类型,创建一个随机排列,然后在索引时我们只使用排列索引到原始数组。而且我们知道,基于外部构造函数,对数组子部分的所有访问都应该没问题……因此,即使我们自己不检查边界,我们也可以依赖内部索引表达式,如果我们索引超出范围,则会抛出错误。

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

请注意边界错误是如何不是 从索引到 ShuffledVector,而是从索引到置换向量 A.perm[5]。现在我们的 ShuffledVector 的用户可能希望它的访问速度更快,所以他们尝试使用 @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

但他们仍然遇到边界错误!这是因为 @inbounds 注释只试图从我们上面编写的方法中删除 @boundscheck 块。它不会传播到标准库以从 A.perm 数组或 A.data 范围中删除边界检查。这是相当多的开销,即使他们试图删除边界!因此,我们可以改为使用 Base.@propagate_inbounds 注释编写上面的 getindex 方法,这将允许此方法 "inherit" 其调用者的边界状态:

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

您可以验证没有带有@code_llvm f(s, 5)的分支。

但是,实际上,在这种情况下,我认为用它自己的 @boundscheck 块编写此 getindex 方法实现会更好:

@inline function Base.getindex(A::ShuffledVector, i::Int)
    @boundscheck checkbounds(A, i)
    @inbounds r = A.data[A.shuffle[i]]
    return r
end

有点冗长,但现在它实际上会在 ShuffledVector 类型上抛出边界错误,而不是在错误消息中泄露实现细节。

内联的效果

您会注意到我没有在上面的全局范围内测试 @inbounds,而是使用这些小辅助函数。那是因为只有当方法被内联和编译时,边界检查删除才有效。因此,简单地尝试在全局范围内删除边界是行不通的,因为它无法将函数调用内联到交互式 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

这里没有在全局范围内发生编译或内联,因此 Julia 无法删除这些边界。类似地,Julia 无法在类型不稳定时内联方法(比如访问非常量全局变量时),因此它也无法删除这些边界检查:

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

一般来说,删除边界检查应该是您在确保其他一切正常、经过良好测试并遵循通常的性能提示后进行的最后优化。