去年,我写了一篇关于如何在一天(实际上是3小时)内实现您自己的(基于操作符重载的)自动区分(AD)的博客文章。广告有时看起来像魔术,但这次我要谈谈一些黑魔法:源到源自动区分。在Mike Inness的帮助下,我在JuliaCon 2019 hackthon上写下了这篇文章。事实证明,写一篇博客帖子比写一篇源AD;-)要花更长的时间。这基本上只是Zygote的简单版本。
如果您想了解更详细的实现,我在这里将其包装为一个非常简单的包:YASSAD.jl。
如果您使用过基于运算符重载的AD,如PyTorch、Flux/Tracker、AutoGrad,您可能会发现它们有一些限制:
必须使用包提供的张量类型或变量类型来跟踪函数调用。
它们一般不能处理控制流,即使在某些情况下,也可以采取一些解决办法。
但是,没有控制流的编程不是编程!而且,使用跟踪类型重写大量代码通常非常烦人。如果我们想要像严乐村这样的人所提议的那样有一个可区分编程的框架,我们需要解决以上两个问题。
事实上,这些问题从源头到源头的自动区分解决起来是相当简单的,因为我们基本上知道一切都会发生。我将在不处理控制流的情况下实现一个非常简单的AD源代码,您也可以在Zygote.jl中检查完整的实现。
Julia解析器将首先解析字符串以获得抽象语法树(AST)
这个AST中的一些节点是宏,宏就像表达式上的编译时间函数,编译器将展开宏。然后我们得到AST的扩展版本,它没有任何宏。您可以使用@MACROEXPAND检查结果。
现在,我们将降低AST,去掉语法糖,并以静态单一赋值形式(SSA)表示它们,您可以使用@code_lowed获得它,并且您可以使用Julia宏来修改此过程。
当发生函数调用时,我们使用函数签名将函数分派给某个方法,并开始进行类型推理。您可以使用@Generated函数修改此过程,并使用@code_type检查结果。
在我们拥有llvm IR之后,Julia将使用llvm生成本机代码来实际执行此函数。
通过执行该函数,我们将遇到另一个函数调用,因此我们返回到步骤5。
如你所见。Julia不是静态编译语言,它以函数作为编译的边界。
一本关于SSA的完整介绍可以是一本书。但是要实现您自己的源到源AD,只需要三个简单的概念:
如果你读过我上一篇文章,我相信你已经理解了什么是计算图,但现在让我们再看看这个图:这个计算图到底是什么?
在进行自动微分时,我们将计算过程用图表示。每个节点都是一个具有中间值的运算符。并且每个运算符还具有将在反向传递中使用的伴随运算符。这意味着每个节点中的每个变量将只分配一次。这只是SSA表单的一个简单版本,对吗?
然后,该梯度可以被认为是原始程序的伴随程序。我们唯一需要做的就是生成伴随程序。事实上,这通常被称为温格特列表、磁带或图表,正如Zygote的论文中所描述的:不要展开伴随。因此,我们可以直接使用SSA形式作为我们的计算图。此外,由于在Julia中降低了SSA形式IR,这也意味着我们只需要定义几个基本例程,而不是定义大量运算符。
由于向后传递只是原始程序的附加部分,我们可以将其编写为闭包。
Function Forward(::Typeof(Your_Function),Xs.)#Function声明OUTPUT=#Function OUTPUT OUTPUT,Function(Δ)#a闭包结束。
将其定义为闭包的好处是,我们可以让编译器自己处理伴随程序和原始程序之间的共享变量,而不是自己管理它(就像我们在上一篇文章中所做的那样)。我们称这些闭合为回调。
函数FORWARD(::TYPEOF(FOO),x)x1,BACK1=FORWARD(BAZ,x)x2,BACK2=FORWARD(BAR,x1)返回x2,函数(Δ)dx1=BACK2(Δ)DX2=BACK1(DX1)返回DX2 END。
通常,没有控制流的伴随程序只是以相反的顺序应用由它们的前进函数生成的回调。但是我们如何自动做到这一点呢?有人可能会说:让我们使用宏吧!呃,我们可以这么做。但是我们的目标是区分别人定义的任意函数,这样东西就可以组合起来。这不是我们想要的。相反,我们可以调整IR,Julia中生成的函数不仅可以从类型信息返回修改后的AST,还可以返回IR。
它看起来也像一个函数,但不同之处在于,在函数内部,每个函数参数a、b、ci的值都是它们的类型,因为我们在编译时没有它们的值。
为了操纵红外线,我们需要一些工具。幸运的是,IRTools中有一些,我们将使用这个包来生成IR代码。
首先,我们可以使用@code_ir获取IRTools处理的IR对象。其类型为IR。与您从@CODE_LOWERED得到的不同之处在于,它不会存储参数名称,所有变量都用数字表示,并且为该类型实现了一些有用的函数。
在这种形式下,每一行代码都绑定到一个变量,我们称为右手语句和左手变量。您可以使用类似于dict的接口来使用此对象,例如。
它将返回一个Statement对象,该对象存储此语句的表达式、推断的类型(因为我们在类型推断之前使用IR,所以这是ANY)。为简单起见,我们在这篇文章中不使用类型化IR(因为原则上,它们的实现是相似的)。最后一个数字是行号。
整个街区的第一个数字是什么?它的意思是代码块,在SSA形式中,我们用它来表示分支,例如。
Julia>;函数foo(X)if x>;1 bar(X)否则baz(X)end end foo(具有1个方法的泛型函数)julia>;foo(1.0)1:(%1,%2)%3=%2>;1 br 3除非%3 2:%4=(Main.bar)(%2)返回%4 3:%5=(Main.baz)(%2)返回%5。
ifElse只是降低了SSA形式的分支语句,事实上,for循环也是类似的。Julia的for循环只是迭代函数的语法糖。只要我们能通过br实现差异化,我们就能通过控制流实现差异化。
julia>;函数foo(X),其中x在1:10 bar(X)end baz(X)end foo(具有1个方法的泛型函数)julia>;foo(1.0)1:(%1,%2)%3=1:10%4=(Base.iterate)(%3)%5=%4==无%6=(Base.not_int)(%5)br 3除非%6 br 2(%4)2:(%7)%8=(Core.getfield)(%7,1)%9=(Core.getfield)(%7,2)%10=(Main.bar)(%8)%11=(Base.iterate)(%3,%9)%12=%11==无%13=(Base.not_int)(%12)br 3除非%13 br 2(%11)3:%14=(Main.baz)(%2)返回%14
那我们怎么拿到红外线呢?为了获得IR,我们需要知道为该泛型函数调度哪个方法。Julia中的每个泛型函数都有一个方法表,您可以使用函数调用的类型签名来获取此方法,例如当您调用foo(1.0)时,Julia将生成Tuple{typeof(Foo),Float64}来调用相关的方法。我们可以通过提供具有此类型签名的IRTools.meta函数来获取此方法的元信息。
Julia&>推送!(ir,:(1+1))%5 Julia&>IR 1:(%1,%2)%3=(Main.baz)(%2)%4=(Main.bar)(%3)%5=1+1返回%4。
IRTools将在此自动为您添加变量名称。同样,我们可以使用INSERT!要在第四个变量之前插入语句,请执行以下操作:
Julia&>使用IRTools:var Julia&>Insert!(ir,var(4),:(1+1))%5 Julia&>IR 1:(%1,%2)%3=(Main.baz)(%2)%5=1+1%4=(Main.bar)(%3)返回%4。
Julia>;使用IRTools:插入!Julia&>插入后!(ir,var(4),:(2+2))%6 Julia>;ir 1:(%1,%2)%3=(Main.baz)(%2)%5=1+1%4=(Main.bar)(%3)%6=2+2返回%4。
有了这些工具,我们现在可以进行向前传球的转换。我们的目标是将每个函数调用替换为对Forward函数的函数调用,然后收集Forward函数返回的所有回调以生成闭包。但是等等!我没有提到闭合,SSA IR中的闭合是什么?我们稍后再考虑这一点,先实现前向部分的转换。
Julia>;dump(ir[var(3)])IRTools.Statement expr:expr head:Symbol Call args:array{any}((2,))1:GlobalRef mod:模块主名:Symbol Baz 2:IRTools.Variable id:Int64 2类型:Anyline:Int64 1。
实际上,我们只需要检查其表达式的签名是否为call。我们可以使用IRTools中的管道对象进行转换,转换结果存储在其成员to中。
我们将此函数命名为REGISTER,因为它具有与我上一篇文章中的旧REGISTER函数类似的功能。唯一的区别是:现在您不需要为每个操作符手动编写此寄存器函数!我们将自动执行此操作。
警告:因为我是用REPL做这个演示,所以我直接使用main模块,如果您将代码放在您自己的模块中,请将其替换为您的模块名称。
函数寄存器(Ir)pr=管道(Ir)参数!(pr,at=1)for(v,st)in pr ex=st.expr if Meta.isexpr(ex,:call)yj=insert!(pr,v,stmt(xcall(main,:ward,ex.args.),line=ir[v].line))pr[v]=xgetindex(yj,1)end Finish(Pr)end。
我将在这里解释我的工作:首先,因为我们正在为FORWARD函数生成IR,所以现在我们有一个额外的参数。
然后,我们需要迭代所有的变量和语句,如果语句是一个函数调用,那么我们用Callto Forward函数替换它。记住在这里保留行号,因为我们仍然需要一些错误消息。由于Forward的返回值是实际正向求值和回调的元组,为了获得正确的结果,我们需要对此元组进行索引,并用新变量替换原始变量。这里的xgetindex是一个方便的函数,它可以生成getindex的表达式。
Julia&>寄存器(Ir)1:(%3,%1,%2)%4=(Main.Forward)(Main.baz,%2)%5=(Base.getindex)(%4,1)%6=(Main.Forward)(Main.bar,%5)%7=(Base.getindex)(%6,1)返回%7。
现在,是考虑闭合问题的时候了。是的,在这个较低的形式中,我们没有闭包。但是我们可以将它们存储在一个可调用的对象中!
此对象还将存储函数签名,因此当我们调用Pull Back时,我们可以查找原始调用的IR以生成此Pull Back的IR。这里的成员数据将存储所有回调的元组及其前向调用的顺序。为了构造回调,我们需要函数调用的签名,因此我们需要修改我们的实现,如下所示。
函数寄存器(ir,F)pr=管道(Ir)pbs=变量[]参数!(pr,at=1)for(v,st)in pr ex=st.expr if Meta.isexpr(ex,:call)yj=insert!(pr,v,stmt(xcall(main,:ward,ex.args.),line=ir[v].line))pr[v]=xgetindex(yj,1)J=insert tafter!(。line=ir[v].line))PUSH!(PBS,SUBPLACE(Pr,J))End End Pr=Finish(Pr)v=Push!(Pr,xtuple(PBS.))。pbv=推送!(pr,expr(:call,callback{F},v))return pr end。
为了存储回调,我们需要从Forward返回的元组中获取回调,并分配一个列表来记录所有回调。
这里xtuple类似于xgetindex,用来生成构造元组的表达式。
让我们将回调和原始返回值打包为一个元组,然后返回它!
函数寄存器(ir,F)pr=管道(Ir)pbs=变量[]参数!(pr,at=1)for(v,st)in pr ex=st.expr if Meta.isexpr(ex,:call)yj=insert!(pr,v,stmt(xcall(main,:ward,ex.args.),line=ir[v].line))pr[v]=xgetindex(yj,1)J=insert tafter!(。line=ir[v].line))PUSH!(PBS,SUBPLACE(Pr,J))End End Pr=Finish(Pr)v=Push!(Pr,xtuple(PBS.))。pbv=PUSH!(pr,expr(:call,backback{F},v))ret=pr.block[end].Branch[end].args[1]ret=Push!(pr,xtuple(ret,pbv))pr.block[end].Branch[end].args[1]=ret return pr,pbs end。
RETURN语句实际上是一个简单的分支,它是最后一个代码块的最后一个语句的最后一个分支。
Julia>;寄存器(ir,Tuple{typeof(Foo),Float64})1:(%3,%1,%2)%4=(Main.Forward)(Main.baz,%2)%5=(Base.getindex)(%4,1)%6=(Base.getindex)(%4,2)%7=(Main.Forward)(Main.bar,%5)%8=(Base.getindex)(%7,1)%9=(Base.getindex)(%7,2)%10=(Core.tuple)(%9,%6)%11=(回调{Tuple{typeof(Foo),Float64},T}其中T)(%10)%12=(Core.tuple)(%8,%11)返回%12。
函数正向(f,xs.)。t=元组{f,xs.}m=IRTools.meta(T)m==空&;&;返回端
我们会先得到元,如果元不是什么,就意味着这个方法不存在,所以我们就到此为止。如果我们有元数据,那么我们就可以从中获取IR并将其注册。
函数正向(f,xs.)。t=元组{f,xs.}m=IRTools.meta(T)m==空&;&;return frw=register(IR(M),T)end。
但是,对象frw的类型是IR而不是CodeInfo,要为Julia编译器生成CodeInfo,我们需要将参数名称放回。
由于转发函数的第二个参数是vararg,我们需要对其进行标记以让编译器知道,这样编译器就不会向第一个函数调用提供Tuple。
函数正向(f,xs.)。t=tuple{f,xs.}m=IRTools.meta(T)m==空&;&;return frw=register(IR(M),T)argname!(M,Symbol(";#self#";),:f,:xs)frw=varargs!(M,frw,2)return IRTools.update!(M,frw)end。
Julia>;转发(Foo,1.0)1:(%1,%2,%3)%4=(Base.getfield)(%3,1)%5=(Main.Forward)(Main.baz,%4)%6=(Base.getindex)(%5,1)%7=(Base.getindex)(%5,2)%8=(Main.Forward)(Main.bar,%6)%9=(Base.getindex)(%8,1)%10=(Base.getindex)(%8,2)%11=(Core.tuple)(%10,%7)%12=(Main.Pull{Tuple{typeof(Foo),Float64},T}其中T)(%11)%13=(Core.tuple)(%9,%12)返回%13。
Julia>;Forward(foo,1.0)错误:MethodError:没有与getIndex(::Nothing,::Int64)匹配的方法。堆栈跟踪:[1]*at./float.jl:399[inline][2]Forward(::Typeof(*),::Float64,::Float64)位于/Users/roger/.julia/dev/YASSAD/src/compiler.jl:0[3]baz at./repl[4]:1[inlined][4]Forward(::Typeof(Baz),::Float64)位于/Users/roger/.julia/dev/YASSAD/src/compiler.jl:0[5]Foo at./repl[2]:1[内联][6]Forward(::TypeOf(Foo),::Float64)位于/Users/roger/.julia/dev/YASSAD/src/compiler.jl:0[7]顶级作用域None:0。
这是因为Forward将被递归调用,这也意味着我们只需要通过重载Forward函数来定义最内部的(原语)操作符,例如,在本例中我们可以重载*操作符
Julia>;Forward(::Typeof(*),a::Real,b::Real)=a*b,Δ->;(Δ*b,a*Δ)Julia>;Forward(FOO,1.0)(1.0,YASSAD.Pull Back{.}。
但这一回调目前还不能赎回。让我们生成用于回调的IR。同样,我们可以定义。
函数(::PULLBACK{S})(Delta)其中Sm=IRTools.meta(S)m=空&;&;return ir=IR(M)_,Pbs=register(ir,S)back=伴随(ir,PBS)argname!(M,Symbol(";#self#";),:Delta)return IRTools.update!(M,back)end。
因为后向传递是单独调用的,所以我们不再有前向IR,不幸的是,我们需要在这里再次调用register,但是不用担心,这只会在编译时发生一次。在为伴随程序生成IR之前,我们还需要知道哪个变量有回调,因此,我们需要一个DICT来存储它,并将其返回到回调,而不是使用列表。因此,我们需要修改我们的登记册如下。
函数寄存器(ir,F)pr=管道(Ir)pbs=dict{变量,变量}()参数!(pr,at=1)for(v,st)in pr ex=st.expr if Meta.isexpr(ex,:call)yj=insert!(pr,v,stmt(xcall(main,:ward,ex.args.),line=ir[v].line))pr[v]=xgetindex(yj,1)J=。2),line=ir[v].line))Pbs[v]=替换(Pr,J)End End Pr=Finish(Pr)v=Push!(Pr,xtuple(Values(Pbs).))。pbv=PUSH!(pr,expr(:call,backback{F},v))ret=pr.block[end].Branch[end].args[1]ret=Push!(pr,xtuple(ret,pbv))pr.block[end].Branch[end].args[1]=ret return pr,pbs end。
因为伴随程序与原始IR的顺序相反,所以我们在这里不会使用管道,我们可以创建一个空的IR对象,并在这里向其添加两个参数,一个是拉回对象本身,另一个是向后传递(拉回)的输入渐变。
首先,让我们回调一下。我在这里调用的getfield函数是语法SUGER的低级形式。对于获取成员,这等同于self.data。
vars=长度为k的密钥(Ir)(Vars):-1:1 v=vars[k]ex=ir[v].expr if Haskey(pbs,v)pbv=insert!(adj,calllback,xcall(:getindex,backlback,k))g=推送!(adj,expr(:call,pbv,v))end
如果此变量存在于我们的回调字典中,我们将获取它并使用此变量调用它。但是,这种实现有一个问题,如果一个变量有多个梯度,我们需要将它们累加在一起,因此我们也需要记录这些变量的梯度。
xaccum与前面的xgetindex相同,但是Julia中的内置累加函数定义在数组上,我们需要一个函数来累加变量,让我们自己来做吧。
xaccum(Ir)=无xaccum(ir,x)=x xaccum(ir,xs.)=Push!(ir,xcall(YASSAD,:ACCUM,Xs.))。accum()=Nothing accum(X)=x accum(x,y)=x=Nothing?Y:Y==什么都没有?x:x+y accum(x,y,zs.)=accum(accum(x,y),zs.)。accum(x::tuple,y::tuple)=accum.(x,y)accum(x::AbstractArray,y::AbstractArray)=accum.(x,y)。
最后,回调将返回原始程序的每个输入变量的梯度。这意味着它总是有与输入变量相同数量的梯度。但是我们的前向函数有一个额外的变量,那就是函数,我们也会返回它的梯度,在大多数情况下,它是零,但如果它是一个闭包,或者是一个可调用的对象,它可能不是零。
函数ADJE(ir,pbs)adj=Empty(Ir)Self=Argument!(Adj)Delta=Argument!(adj,xcall(:getfield,self,QuoteNode(:Data)grads=dict()grad(x,x̄)=Push!(Get!(grads,x,[]),x̄)grad(X)=xaccum(adj,Get(grads,x,[]))。grad(last(key(Ir)),增量)vars=k长度(Vars)的密钥(Ir):-1:1 v=vars[k]ex=ir[v].expr if Haskey(pbs,v)pbv=insert tter!(adj,calllback,xcall(:getindex,backlback,k))g=Push!(adj,expr(:call,pbv,grad(V)(i,x)in。PUSH!(adj,xgetindex(g,i))END GS=[参数x的grad(X)(Ir)]Δ=Push!(adj,xtuple(gs.))。RETURN!(adj,Δ)RETURN ADJ END。
回顾一下我们刚刚实现的内容,我们可以发现。
..