buildHALConfigurationPassPipeline
addCleanupPatterns
createAssignTargetDevicesPass
在最外层的module上添加device targets属性,可以指定多个targetdevices。
1 | module attributes {hal.device.targets = [ |
createVerifyTargetEnvironmentPass
验证device tagets是否正确设置,以及编译后端是否被注册过。
createMaterializeInterfacesPass
为每个executable创建device target相关的变体(variant),每一种devicetarget对应一个executable variant。将executable的export和sourcefunc都转换为无参数的func,统一dispatch、export和sourcefunc的调用接口,dispatch指定输入和bindings的关系,sourcefunc则通过binding id来获取输入参数。
1 | stream.executable private @test_dispatch_0 { |
转换为
1 | hal.executable private @test_dispatch_0 { |
createTranslateExecutablesPass
根据每一个hal.executable.variant 的targetdevice调用对应的后端进行编译。比如cuda会调用CUDATargetBackend,CUDATargetBackend实际执行的是下面一序列passes。
buildLLVMGPUTransformPassPipeline
createTypePropagationPass
对integer的element type进行标准化,并传播修改过的type。
createBufferizeCopyOnlyDispatchesPass
将纯数据拷贝的dispatch(只有tensor load和store)转换成linalg genericop,并bufferize化。
1 | func.func @test_dispatch_0() { |
转换成
1 | func.func @test_dispatch_0() { |
createEraseHALDescriptorTypeFromMemRefPass
将memory space为hal descriptor type的value转换成memref。
createLLVMGPULowerExecutableTargetPass
initGPULaunchConfig
根据具体的计算负载和类型,计算gpu launch的配置,包括分块策略、groupcount、thread num以及后续lowering分发的流程等。
1 | hal.executable.variant public @cuda_nvptx_fb, target = <"cuda", "cuda-nvptx-fb", {target_arch = "sm_35"}> { |
转换成
1 | hal.executable.variant public @cuda_nvptx_fb, target = <"cuda", "cuda-nvptx-fb", {target_arch = "sm_35"}> { |
可以看到exportfunc多了translation_info和workgroup_size两个属性,而sourcefunc也多了一个lowering_config属性。translation_info表示后续lowering分发到LLVMGPUVectorize这个pipeline。workgroup_size可以认为是3维的gpublockdim,这里表示每个线程块有64个线程。lowering_config指明了每层循环的分块策略,这里表示一个线程块计算256个100xf32的数据,而且每个线程一次计算一个4xf32的向量。
DispatchLoweringPassPipeline
根据translation_info分发到下面的pipeline继续lowering。
GPUSimpleDistributePassPipeline
GPUVectorizationPassPipeline
getTileAndDistributeConfig
定位到dispatch的root节点(一般是最后一个linalg reductionop,如果没有reduction op,则会选择最后一个linalg genericop),从节点属性中取出lowering_config(tile size),将非parallelloop对应的tile size置0,表示接下来只会对parallelloop进行vectorize,并计算parallel loop的loop range。
LowerDispatchWorkgroupCountForDagRootOp
根据loop range和tile size计算workgroup count。
1 | hal.executable.export public @test_dispatch_0_generic_100000x100 ordinal(0) layout(#hal.pipeline.layout<push_constants = 0, sets = [<0, bindings = [<0, storage_buffer, ReadOnly>, <1, storage_buffer, ReadOnly>, <2, storage_buffer>]>]>) attributes {translation_info = #iree_codegen.translation_info<LLVMGPUVectorize>, workgroup_size = [64 : index, 1 : index, 1 : index]} { |
转换成
1 | hal.executable.export public @test_dispatch_0_generic_100000x100 ordinal(0) layout(#hal.pipeline.layout<push_constants = 0, sets = [<0, bindings = [<0, storage_buffer, ReadOnly>, <1, storage_buffer, ReadOnly>, <2, storage_buffer>]>]>) attributes {translation_info = #iree_codegen.translation_info<LLVMGPUVectorize>, workgroup_size = [64 : index, 1 : index, 1 : index]} { |
可以看到计算的group count为(391, 1, 1)。391 = UDIV(100000,256)。
populateTileAndDistributeToWorkgroupsPatterns
对parallel loop进行分块,将source func转换成单个workgroup的计算逻辑。
1 | func.func @test_dispatch_0_generic_100000x100() { |
转换成
1 | func.func @test_dispatch_0_generic_100000x100() { |
createWorkgroupSpecializationPass
将分块之后的计算逻辑分成固定形状和剩余部分动态形状两部分计算逻辑。
1 | func.func @test_dispatch_0_generic_100000x100() { |
会转换成
1 | func.func @test_dispatch_0_generic_100000x100() { |
createRemoveSingleIterationLoopPass
移除确信只会循环1次的loop。比如上面的scf.for %arg0 = %3 to %c100000 step %4就只会被循环一次,因为step= 256 * 391 = 100096 >100000,因此这个循环会被消除,转换成如下代码。
1 | func.func @test_dispatch_0_generic_100000x100() { |
createLLVMGPUTileTensor
前面pass主要针对的是外层parallelloop的vectorize,生成的是一个线程块的计算逻辑,接下来继续将负载分布到每一个线程,并且对内层的reduction也做vectorize。上面的代码继续转换成如下代码,
1 | func.func @test_dispatch_0_generic_100000x100() { |
createRemoveSingleIterationLoopPass
createGPUVectorizationPass
将内层可被向量化的linalg op转换成vector op。
1 | %11 = scf.for %arg2 = %c0 to %c100 step %c4 iter_args(%arg3 = %extracted_slice_1) -> (tensor<4xf32>) { |
转换成
1 | %11 = vector.transfer_read %extracted_slice_1[%c0], %cst {in_bounds = [true]} : tensor<4xf32>, vector<4xf32> |
addBufferizePasses
将tensor语义转换成memref语义。上面完整的sourcefunc代码会转换成如下代码:
1 | func.func @test_dispatch_0_generic_100000x100() { |
createLLVMGPUDistribute
将任务分配到每一个线程,sourcefunc从线程块的计算逻辑转换成每个线程的计算逻辑,即用gpu.thread_id(x, y,z)替换scf.foreach_thread。
1 | func.func @test_dispatch_0_generic_100000x100() { |
createLoopInvariantCodeMotionPass
memref::createFoldMemRefAliasOpsPass
createOptimizeVectorTransferPass
GPUMatmulSimtPassPipeline
GPUMatmulTensorCorePassPipeline
GPUTransposePassPipeline
GPUWarpReductionPassPipeline
GPUTransformDialectPasses
addLowerToLLVMGPUPasses
继续将device代码递降到affine和gpu dialect,最终转换到NVVM IR或ROCDLIR。
IREE::LinalgExt::createLinalgExtToLoopsPass
将LinalgExt op转换成loops。
createMemrefCopyToLinalgPass
将memref.copy转换成linalg generic op。
createConvertLinalgToLoopsPass
将linalg generic op转换成loops。
createPadDynamicAlloc
以pad的方式申请动态大小的内存。比如需要申请的内存大小和dim相关,%dim = affine_max(0, %src),那么这里就会以%dim = %src的最大size来申请内存。
createLowerAffinePass
将affine op(比如affine.for, affine.if andaffine.apply等) 递降成更低层的arith、memref和scfop。上面完整的source func代码会转换成如下代码,
1 | func.func @test_dispatch_0_generic_100000x100() { |
arith::createConstantBufferizePass
createFoldTensorExtractOpPass
createLLVMGPUVectorLoweringPass
将多维vector op展开成一维的vector op。上面完整的sourcefunc代码会转换成如下代码,
1 | func.func @test_dispatch_0_generic_100000x100() { |
createConvertSCFToCFPass
将structure的control flow转换成CFG的控制流。上面完整的sourcefunc代码会转换成如下代码,
1 | func.func @test_dispatch_0_generic_100000x100() { |
createPolynomialApproximationPass
arith::createArithExpandOpsPass
memref::createExpandOpsPass
memref::createExpandStridedMetadataPass
createLowerAffinePass
createStripDebugInfoPass
createConvertToROCDLPass或createConvertToNVVMPass
转换到ROCDL IR或NVVM IR。上面完整的sourcefunc代码会转换成如下代码,
1 | llvm.func @test_dispatch_0_generic_100000x100(%arg0: !llvm.ptr<f32> {llvm.align = 16 : i32}, %arg1: !llvm.ptr<f32> {llvm.align = 16 : i32}, %arg2: !llvm.ptr<f32> {llvm.align = 16 : i32}) { |
createConvertToHALPass
createFixupLegacySyncPass
addCleanupPatterns
createLinkExecutablesPass
createResolveExportOrdinalsPass
createMaterializeResourceCachesPass
createInlineDeviceSwitchesPass
createMemoizeDeviceQueriesPass
addCleanupPatterns
createElideRedundantCommandsPass
mlir::createLowerAffinePass
mlir::createConvertSCFToCFPass
IREE::Util::createCombineInitializersPass
addCleanupPatterns
createSerializeExecutablesPass
mlir::createSymbolDCEPass
buildStreamTensorPassPipeline
IREE::Stream::createVerifyInputPass
检查program的合法性。
IREE::Stream::createOutlineConstantsPass
将module内部的dense constant转换成global dense constant。
1 | func.func @test(%arg0: !hal.buffer_view) -> !hal.buffer_view attributes {iree.abi.stub} { |
转换成,
1 | util.global private @_constant {noinline} = dense<[0.000000e+00, 0.00999999977, 2.000000e-02, 3.000000e-02, 4.000000e-02, 5.000000e-02, 6.000000e-02, 7.000000e-02, 8.000000e-02, 9.000000e-02]> : tensor<10xf32> |
addCleanupPatterns
IREE::Stream::createConvertToStreamPass
将IREE::Util、IREE::Flow、IREE::HAL以及stddialect转换到IREE::Stream dialect。
1 | module { |
转换为
1 | module { |
可以看到除了flow.executable,module中tensortype都被转换成stream.resource和index,但hal.buffer_viewtype仍然被保留。初始值为tensor的util.globalconstant被转换为不带初始值的stream.resource和index,同时生成了一个util.initializer对stream.resource和index进行初始化。util.global.load被转换成util.global.load +stream.async.transfer,hal.tensor.import被转换成stream.tensor.import+stream.async.transfer,hal.tensor.export被转换为stream.async.transfer+stream.tensor.export,flow.tensor.reshape被转换成stream.tensor.clone,flow.executable转换为stream.executable,内部的flow.executable.export转换为stream.executable.export,内部的funcop的argument由flow.dispatch.tensor转换为stream.binding。
IREE::Stream::createVerifyLoweringToTensorsPass
检查program的合法性。
addCleanupPatterns
IREE::Util::createCombineInitializersPass
合并所有的util.initializer ops。
buildStreamAsyncPassPipeline
IREE::Stream::createEncodeHostTensorsPass
主要作用是将tensor的元素位宽(bit)扩充为2的幂大小,并按字节对齐。其中i1~i7转换为i8(1byte),i9~i15转换为i16 (2 bytes),i17~i31转换为i32 (4bytes),i33~i63转换为i64(8 bytes)。
1 | util.initializer { |
转换为
1 | util.initializer { |
%cst的类型从i4转成了i8,此外stream.tensor.constant转换成了stream.async.constant,%0 = stream.resource.size %cst : !stream.resource<constant>直接被替换成了常量%c10。
IREE::Stream::createEncodeDeviceTensorsPass
和createEncodeHostTensorsPass作用一样,区别是createEncodeDeviceTensorsPass作用的是stream.executable中的op。
1 | builtin.module { |
转换为,
1 | builtin.module { |
可以看到stream.binding.subspan的resulttype从i4转换成了i8,并且在flow.dispatch.tensor.load之后插入了一个arith.trunci,将i8截断为i4,进而参与linalg.generic中的计算。
IREE::Stream::createMaterializeBuiltinsPass
addCleanupPatterns
IREE::Stream::createMaterializeCopyOnWritePass
写入时插入一次拷贝,以更有效地支持inplace更新,并且确保正确的执行语义。
IREE::Stream::createElideAsyncCopiesPass
消除MaterializeCopyOnWritePass中插入的不必要的拷贝。
mlir::createCanonicalizerPass
IREE::Stream::createEmplaceAllocationsPass
尝试消除stream.async.dispatch后的stream.async.updateop。当stream.async.dispatch的结果没有绑定一个value时,就可以把stream.async.update的target绑定到stream.async.dispatch的结果,使得stream.async.dispatch直接把计算结果更新到target。
IREE::Stream::createRefineUsagePass
确定每个stream.resource的生命期,推导stream.resource的类型。stream.resource类型包括:
stream.resource<*>stream.resource<external>由外部程序管理的内存stream.resource<staging>用于上传/下载的暂存缓冲区stream.resource<transient>跨stream的一段临时值stream.resource<variable>跨stream的一段持续值stream.resource<constant>整个程序中持续存在的立即值(常量)。除此之外还消除了冗余的stream.async.transfer。
1 | func.func @test(%arg0: !hal.buffer_view) -> !hal.buffer_view attributes {iree.abi.stub} { |
转换为
1 | func.func @test(%arg0: !hal.buffer_view) -> !hal.buffer_view attributes {iree.abi.stub} { |
可以看到!stream.resource<*>{ %c40}被推导为!stream.resource<external>{ %c40},并且有两处stream.async.transfer被删除了。
addCleanupPatterns
IREE::Stream::createScheduleExecutionPass
根据启发式算法将每个callable(包括util.initializer)划分成多个part进行调度,每个part独立构成一个stream.async.execute,并且每个stream.async.execute后面都跟了一个stream.timepoint.await操作用于同步执行结果。
1 | func.func @test(%arg0: !hal.buffer_view) -> !hal.buffer_view attributes {iree.abi.stub} { |
转换成,
1 | func.func @test(%arg0: !hal.buffer_view) -> !hal.buffer_view attributes {iree.abi.stub} { |
注意:该示例中只有一个part。
IREE::Stream::createScheduleConcurrencyPass
继续将stream.async.execute划分为多个并行调度区,每个并行调度区构成一个stream.async.concurrent。
IREE::Stream::createPropagateTimepointsPass
给stream.resource 绑定一个stream.timepoint,在代码中用stream.resource + stream.timepoint的pair替换原来的stream.resource,并在需要的地方插入await。
util.global
1 | util.global private @_constant : !stream.resource<constant> |
转换成
1 | util.global private mutable @_constant__timepoint = |
util.global.load
1 | %_constant = util.global.load @_constant : !stream.resource<constant> |
转换成
1 | %_constant__timepoint = util.global.load @_constant__timepoint : !stream.timepoint |
util.global.store
1 | util.global.store %0, @_constant : !stream.resource<constant> |
转换成
1 | util.global.store %result_timepoint, @_constant__timepoint : !stream.timepoint |
func.func
1 | func.func @foo(%0: !stream.resource) { |
转换成
1 | func.func @foo(%t: !stream.timepoint, %0: !stream.resource) { |
call
由于func内部已经插入了await,因此call之前的冗余await可以删除,call之后需要再插入一个func返回值的await。
1 | %1 = stream.timepoint.await %t, %0 |
转换成
1 | %rt, %r = call @foo(%t, %0) |
return
1 | %1 = stream.timepoint.await %t, %0 |
转换成
1 | return %t, %0 |
branch
将参数的await挪到branch里面。
1 | %1 = stream.timepoint.await %t, %0 |
转换成
1 | br ^bb1(%t, %0) |
stream.async.execute
为每个未绑定stream.timepoint的输入参数绑定一个stream.timepoint,并在stream.async.execute之前计算参数的最大timepoint,stream.async.execute则await这个最大timepoint。
1 | %results, %result_timepoint = stream.async.execute with(%0 as %arg1: !stream.resource<external>{%c40}, %_constant as %arg2: !stream.resource<constant>{%c40}) -> !stream.resource<external>{%c40} { |
转换成
1 | %3 = stream.timepoint.join max(%2, %_constant__timepoint) => !stream.timepoint |
addCleanupPatterns
IREE::Stream::createVerifyLoweringToAsyncPass
验证LoweringToAsyncPass阶段program的合法性。
buildStreamCmdPassPipeline
IREE::Stream::createScheduleAllocationPas
首先将所有常量op聚合成一个stream.resource.constants,并移出该region,stream.resource.constants的结果会被append到该region的输入参数中(原本直接yield的常量除外)。
1 | %results, %result_timepoint = stream.async.execute with() -> !stream.resource<constant>{%c40} { |
转换成
1 | %results, %result_timepoint = stream.resource.constants : |
分析stream.async.executeregion中resource的类型和他们之间的alias关系,按照resource的类型统一分配空间。对于没有被Tied到输入(即非inplace)的results,会统一在region外面由stream.resource.alloc申请一段external空间,region再通过Tied的方式消费alloc的结果。对于中间临时的resource,经过stream.resource.pack计算需要分配的空间大小后统一由stream.resource.alloca申请一段transient空间,并会在region后面插入stream.resource.dealloca释放申请的临时空间。
1 | func.func @predict(%arg0: !hal.buffer_view) -> !hal.buffer_view attributes {iree.abi.stub} { |
转换成,
1 | func.func @predict(%arg0: !hal.buffer_view) -> !hal.buffer_view attributes {iree.abi.stub} { |
IREE::Stream::createPackConstantsPass
将stream.resource.constants的结果根据lifetime类型分成Constant和Variable两种,每一种都替换成一个util.buffer.constant。
1 | util.initializer { |
转换成,
1 | util.initializer { |
IREE::Stream::createPackAllocationsPass
将包含多个resource的stream.resource.alloc 转换成stream.resource.pack + stream.resource.alloc,并通过stream.resource.subview获取每一个resource。
IREE::Stream::createLayoutSlicesPass
将stream.resource.pack转化为具体的内存复用算法计算过程。
1 | func.func @predict(%arg0: !hal.buffer_view) -> !hal.buffer_view attributes {iree.abi.stub} { |
转换成,
1 | func.func @predict(%arg0: !hal.buffer_view) -> !hal.buffer_view attributes {iree.abi.stub} { |
IREE::Util::createPropagateSubrangesPass
把resource转换成 (resource, size, offset, length)的元组。
util.global
1 | util.global private @_constant : !stream.resource<constant> |
转换成
1 | util.global private @_constant : !stream.resource<constant> |
util.global.load
1 | %0 = util.global.load @foo : !stream.resource |
转换成
1 | %0 = util.global.load @foo : !stream.resource |
util.global.store
1 | %1 = stream.resource.subview %0[%o] : |
转换成
1 | util.global.store %0, @foo : !stream.resource // 这里语义是正确的吗??? |
func.func
1 | func.func @foo(%0: !stream.resource) { |
转换成
1 | func.func @foo(%0: !stream.resource, %sz: index, %o: index, %l: index) { |
call
1 | %1 = stream.resource.subview %0[%o] : {%sz} -> {%l} |
转换成
1 | %r, %rsz, %ro, %rl = call @foo(%0, %sz, %o, %l) |
return
1 | %1 = stream.resource.subview %0[%o] : {%sz} -> {%l} |
转换成
1 | return %0, %sz, %o, %l |
branch
1 | %1 = stream.resource.subview %0[%o] : {%sz} -> {%l} |
转换成
1 | br ^bb1(%0, %sz, %o, %l) |
cond_branch
addCleanupPatterns
IREE::Stream::createVerifyLoweringToCmdPass
验证program的合法性。
buildStreamOptimizationPassPipeline
addCleanupPatterns
mlir::createConvertSCFToCFPass
将structured control flow算子转换成更低层基础块形式的controlflow算子。
1 | func.func @test(%pred: i32, %arg1: tensor<2x10xf32>, %arg2: tensor<2x10xf32>) -> tensor<2x10xf32> { |
转换成
1 | func.func @test(%pred: i32, %arg1: tensor<2x10xf32>, %arg2: tensor<2x10xf32>) -> tensor<2x10xf32> { |
addCleanupPatterns
IREE::Stream::createElideTimepointsPass
消除已经确信到达的等待。比如
1 | %timepoint0 = ... |
timepoint1到达时timepoint0一定已经达到过,因此可以转换成,
1 | %timepoint0 = ... |
canonicalization之后最终是
1 | %timepoint0 = ... |
IREE::Util::createFixedPointIteratorPass
该pass触发重复执行一个passpipeline,直到达到固定迭代次数或最大迭代次数。这里的pipeline包括前面的addCleanupPatterns和createElideTimepointsPass两个子pass。
IREE::Stream::createFuseDispatchBindingsPass
根据stream.cmd.dispatch 的resource关系合并dispatchexecutable的bindings,比如stream.cmd.dispatch两个resource是同一个地址的不同range,则可以计算每个resource在base地址上的偏移,并将这两个resource合并成一个binding,在dispatchexecutable中根据偏移来截取每个被合并的binding。该操作默认只合并readonly的resource。
1 | stream.executable private @predict_dispatch_2 { |
转换成
1 | stream.executable private @predict_dispatch_2 { |
可以看到stream.cmd.dispatch @predict_dispatch_2的resource被合并为2个,predict_dispatch_2_generic_1x10dispatchexecutable参数中的binding也减少为2个,但增加了3个表示offset的index,被合并的binding根据offset来截取。
IREE::Stream::createPackDispatchOperandsPass
将dispatch executable参数中的标量/index类型转换成i32或i64类型。
1 | func.func @predict_dispatch_2_generic_1x10(%arg0: !stream.binding, %arg1: !stream.binding, %arg2: index, %arg3: index, %arg4: index) { |
转换成
1 | func.func @predict_dispatch_2_generic_1x10(%arg0: !stream.binding, %arg1: !stream.binding, %arg2: i32, %arg3: i32, %arg4: i32) { |
mlir::createCSEPass
IREE::Stream::createFoldUniformOperandsPass
折叠dispatch executable的所有调用中相同的参数。
1 | stream.cmd.dispatch @foo(%c1, %c100 : index, index) |
转换成
1 | stream.cmd.dispatch @foo(%c100 : index) |
@foo内联了%c1,@foo2内联了%c1和%c101。
IREE::Stream::createAnnotateDispatchArgumentsPass
给dispatch executable的参数添加potential value和alignment信息。
1 | func.func @predict_dispatch_2_generic_1x10(%arg0: !stream.binding, %arg1: !stream.binding) { |
转换为
1 | func.func @predict_dispatch_2_generic_1x10(%arg0: !stream.binding {stream.alignment = 64 : index}, %arg1: !stream.binding {stream.alignment = 64 : index}) { |
IREE::Stream::createMemoizeChannelsPass
找出所有stream.channel.defaultops,为每一个stream.channel.defaultop创建一个全局缓冲区,同时在初始化时创建对应的channel,并将channel结果写入全局缓冲区,最后将该stream.channel.defaultop替换为全局缓冲区的util.global.load op。
addCleanupPatterns
mlir::createSymbolDCEPass
flow.executable。相关的passes及其作用如下。IREE::Util::createDemoteF64ToF32Pass
将F64类型窄化为F32。
IREE::Flow::createConvertConv2D1x1ToMatmulPass
将1x1的linalg.conv_2d_nhwc_hwcf转换成linalg.matmul。
1 | // func.func @conv(%input : tensor<1x2x2x3xf32>, %filter: tensor<1x1x3x4xf32>) -> tensor<1x2x2x4xf32> { |
转换成,
1 | func.func @conv(%arg0: !hal.buffer_view, %arg1: !hal.buffer_view) -> !hal.buffer_view attributes {iree.abi.stub} { |
IREE::Flow::createConvertConv2DToImg2ColPass
将conv2d转换成img2col。默认不开启。
1 | // %0 = mhlo.convolution(%input, %filter) |
转换成,
1 | func.func @conv(%arg0: !hal.buffer_view, %arg1: !hal.buffer_view) -> !hal.buffer_view attributes {iree.abi.stub} { |
IREE::Flow::createDetachElementwiseFromNamedOpsPass
将buffer = linalg.generic_op + linalg.named_payload_op转换成tmp_buffer = linalg.named_payload_op; buffer = linalg.generic_op + tmp_buffer,主要目的是将上游的generic op和named_payload_op分隔开,使得named_payload_op的结果写到一块新的buffer。
1 | func.func @test(%arg0: !hal.buffer_view, %arg1: !hal.buffer_view, %arg2: !hal.buffer_view) -> !hal.buffer_view attributes {iree.abi.stub} { |
转换成,
1 | func.func @test(%arg0: !hal.buffer_view, %arg1: !hal.buffer_view, %arg2: !hal.buffer_view) -> !hal.buffer_view attributes {iree.abi.stub} { |
IREE::Flow::createVerifyInputLegalityPass
验证program是否合法。
IREE::Flow::createConvertLinalgMatmulToMmt4DPass
将2d的linalg.matmultiling成linalg.mmt4d。默认不开启,可通过--iree-flow-mmt4d-target-options="enable_generic_slow arch=cuda"选项开启。
1 | func.func @test(%arg0: !hal.buffer_view, %arg1: !hal.buffer_view) -> !hal.buffer_view attributes {iree.abi.stub} { |
转换成,
1 | func.func @test(%arg0: !hal.buffer_view, %arg1: !hal.buffer_view) -> !hal.buffer_view attributes {iree.abi.stub} { |
IREE::Flow::createPadLinalgOpsToIntegerMultiplePass
将matmul的M、N和K扩充到paddingSize的整数倍,paddingSize默认为4。
mlir::createLinalgNamedOpConversionPass
将depth_multiplier=1的linalg.depthwise_conv_2d_nhwc_hwcm转换成linalg.depthwise_conv_2d_nhwc_hwc,将depth_multiplier=1的linalg.depthwise_conv_2d_nhwc_hwcm_q转换成linalg.depthwise_conv_2d_nhwc_hwc_q。
depth_multiplier的作用见https://www.tensorflow.org/api_docs/python/tf/keras/layers/DepthwiseConv2D。
1 | The number of depthwise convolution output channels for each input channel. The total number of depthwise convolution output channels will be equal to filters_in * depth_multiplier. |
IREE::Flow::createExpandTensorShapesPass
将dynamic tensor扩充为tensor + dynamicdim的对偶形式,这么做的一个好处是动态维度可以直接参与计算和推导。比如
1 | // func.func private @add(%arg0 : tensor<?x2xf32>, %arg1 : tensor<?x2xf32>) -> tensor<?x2xf32> |
被转换成,
1 | func.func private @add(!hal.buffer_view, !hal.buffer_view) -> !hal.buffer_view attributes {iree.abi.stub} |
从中可以看出几点变化:
global tensor增加了一个表示动态维度的global index。
1 | util.global private mutable @param : tensor<?x2xf32> |
global load
1 | %param = util.global.load @param : tensor<?x2xf32> |
global store
1 | util.global.store %3, @param : tensor<?x2xf32> |
buildGlobalOptimizationPassPipeline
IREE::Util::createSimplifyGlobalAccessesPass
这个pass主要做这几件事:
将不可变global tensor的load提前到了block的开头,将globaltensor的store安全地挪到block的结尾。
进行以下化简:
如果load after store,则把load直接替换成store的source。比如,
1 | store %0, @p |
会被转换成,
1 | store %0, @p |
如果store after store,则直接消除前一个store
1 | store %0, @p |
会被转换成,
1 | store %1, @p |
如果load after load,则消除后一个load
1 | %0 = load @p |
会被转换成,
1 | %0 = load @p |
一个完整的例子:
1 | func.func private @add(!hal.buffer_view, !hal.buffer_view) -> !hal.buffer_view attributes {iree.abi.stub} |
转换成,
1 | func.func private @add(!hal.buffer_view, !hal.buffer_view) -> !hal.buffer_view attributes {iree.abi.stub} |
这个例子中将param1的load操作提前,并且将%param0_0 = util.global.load @param0 : tensor<1x2xf32>直接替换为%2。
IREE::Util::createApplyPatternsPass
执行IREE::Util dialect ODS中定义的CanonicalizationPatterns,并执行block和跳转命令参数化简操作。
block参数化简
1 | br ^bb1(%0, %0 : index, index) |
折叠相同的参数,化简为
1 | br ^bb1(%0 : index) |
跳转命令参数消除
1 | func.func @foo(%arg0: index) { |
消除参数后,
1 | func.func @foo(%arg0: index) { |
IREE::Util::createFoldGlobalsPass
这个pass继续对global tensor的load和store操作进行优化,主要包括:
内联常量store,比如
1 | util.global mutable @a : i32 |
转换成,
1 | util.global @a = 5 : i32 |
內联常量load,比如
1 | util.global @a = 5 : i32 |
转换成,
1 | func.func @fool { |
重命名互为链式的global tensor。
如果一个mutable globaltensor只在init函数中被store过,则将它修改为immutable。
删除没有load过的global tensor。
合并相同初始值的immutable global tensor。
IREE::Util::createHoistIntoGlobalsPass
IREE::Flow::createTensorPadToTensorInsertSlicePass
将tensor.pad转换为linalg.fill +tensor.insert_slice。
1 | func.func @foo(%arg0: !hal.buffer_view) -> !hal.buffer_view attributes {iree.abi.stub} { |
转换为,
1 | func.func @foo(%arg0: !hal.buffer_view) -> !hal.buffer_view attributes {iree.abi.stub} { |
mlir::createConvertElementwiseToLinalgPass
把elementwise算子(带有Elementwise traits的op)转换成linalg genericop,方便后续对elementwise op做算子融合。arith dialect和mathdialect的op都是Elementwise的,所以实际上这个pass会把arith dialect和mathdialect lower到linalg dialect。
1 | func.func @foo(%arg0: !hal.buffer_view) -> !hal.buffer_view attributes {iree.abi.stub} { |
转换成,
1 | func.func @foo(%arg0: !hal.buffer_view) -> !hal.buffer_view attributes {iree.abi.stub} { |
mlir::createLinalgFoldUnitExtentDimsPass
消除长度为1的维度或者循环。
1 | func.func @foo(%arg0: !hal.buffer_view) -> !hal.buffer_view attributes {iree.abi.stub} { |
转换成,
1 | func.func @foo(%arg0: !hal.buffer_view) -> !hal.buffer_view attributes {iree.abi.stub} { |
可以看到其中的linalg.generic由2层循环缩减成了单层循环。
createInterchangeGenericOpsPass
循环维度变换。将reduction循环维度交换到最内层,相应的parallel循环维度被交换到外层。
1 | // sum(%arg0: tensor<2x3xf32>, 0) -> tensor<3xf32> |
交换循环之后转换成,
1 | func.func @foo(%arg0: !hal.buffer_view) -> !hal.buffer_view attributes {iree.abi.stub} { |
memref::createResolveShapedTypeResultDimsPass
mlir::createCanonicalizerPass
mlir::createCSEPass
createFusionOfTensorOpsPass
主要做elementwise的算子融合,其次也会将tensor.expand_shape转换成linalg generic op,方便进行算子融合。
elementwise算子融合的条件:
1 | // reduce(mul(arg0, arg1), 0) |
融合mul和reduce之后转换成,
1 | // result = 0; |
mlir::createLinalgDetensorizePass
将0-D Tensor转换为它的基础元素类型。
mlir::createCanonicalizerPass
mlir::createCSEPass
createSplitReductionPass
将matmul和topk的单次reduce分成两次reduce操作(一次batchmatmul和一次add)。默认不开启,设置--iree-flow-split-matmul-reduction选项>=2可开启。
1 | func.func @test(%arg0: !hal.buffer_view, %arg1: !hal.buffer_view) -> !hal.buffer_view attributes {iree.abi.stub} { |
--iree-flow-split-matmul-reduction=2转换成,
1 | func.func @test(%arg0: !hal.buffer_view, %arg1: !hal.buffer_view) -> !hal.buffer_view attributes {iree.abi.stub} { |
createInterchangeGenericOpsPass
循环维度变换。将reduction循环维度交换到最内层,相应的parallel循环维度被交换到外层。
createInterchangeTransposeGenericOpsPass
当输入indexing map是permutation时,交换循环维度使得输入的indexingmap是identity的,其作用是使得输入尽可能变成连续访存。
createDispatchWithTransformDialect
根据transform dialect对算子进行调度和派遣,需要另外加载一个transformdialect的module文件,默认不做该变换。transformdialect定义了一套调度规则,用于引导目标IR进行变换,比如循环展开、tiling等。
createFormDispatchRegionsPass
以包含reduction loop的linalg op或named linalgop为中心(root),按一定规则合并producers和comsumers,划分出dispatchregion子图。dispatch region是IREE中的原子执行单元,dispatchregion内部可以直接复用输入和输出的内存,从而避免了内部的内存分配操作,内存分配只发生在dispatchregion的边界,同时dispatch region之间会自动插入同步操作。
1 | func.func @predict(%arg0: !hal.buffer_view, %arg1: !hal.buffer_view, %arg2: !hal.buffer_view) -> !hal.buffer_view attributes {iree.abi.stub} { |
转换成,
1 | func.func @predict(%arg0: !hal.buffer_view, %arg1: !hal.buffer_view, %arg2: !hal.buffer_view) -> !hal.buffer_view attributes {iree.abi.stub} { |
createFormDispatchWorkgroupsPass
将dispatch region转换成dispatch workgroup的形式,并将cloneable的op(比如tensor.fill、tensor.empty等)拷贝到workgroup中。如果在linalg层做了tiling,该pass也会把tiling引入的tensor.extract_slice和tensor.insert_slice尽可能转换成flow.tensor.slice和flow.tensor.update,转换不了的后续再转换成flow.dispatch.tensor.load和flow.dispatch.tensor.store。这里上一步的结果会被转换成,
1 | func.func @predict(%arg0: !hal.buffer_view, %arg1: !hal.buffer_view, %arg2: !hal.buffer_view) -> !hal.buffer_view attributes {iree.abi.stub} { |
createCaptureDispatchDynamicDimsPass
由于flow.dispatch.workgroups的参数中动态形状tensor被替换成了!flow.dispatch.tensor和相应的动态维度index,该pass捕获workgroups参数中的动态维度index,插入flow.dispatch.tie_shape将参数中的动态维度index和!flow.dispatch.tensor进行绑定。
1 | // func.func @test(%arg0: tensor<?xf32>, %arg1: tensor<?xf32>) -> tensor<?xf32> { |
会被转换成,
1 | func.func @test(%arg0: !hal.buffer_view, %arg1: !hal.buffer_view) -> !hal.buffer_view attributes {iree.abi.stub} { |
mlir::createCanonicalizerPass
createCSEPass
createInitializeEmptyTensorsPass
如果tensor.empty op的user中存在非linalg或IREE LinalgExtop,则把该tensor.emptyop转换成flow.tensor.empty或flow.tensor.splatop。
IREE::Flow::createOutlineDispatchRegionsPass
把每个dispatch region转换成flow.executable +flow.dispatch op。
1 | func.func @test(%arg0: !hal.buffer_view, %arg1: !hal.buffer_view) -> !hal.buffer_view attributes {iree.abi.stub} { |
转换成
1 | flow.executable private @test_dispatch_0 { |
IREE::Util::createStripDebugOpsPass
消除DebugOnly op。
mlir::createCanonicalizerPass
IREE::Flow::createDeduplicateExecutablesPass
消除重复的flow.executable。
IREE::Flow::createInjectDispatchTracingPass
注入跟踪运行时dispatch函数输入和输出信息的op。默认不开启。
IREE::Flow::createCleanupTensorShapesPass
删除flow.tensor.tie_shapeop,并确认module中不再包含tensor.dim和tensor.rank这两类形状查询op。
mlir::createCanonicalizerPass
mlir::createCSEPass
mlir::createCanonicalizerPass
mlir::createCSEPass
mlir::createSymbolDCEPass
hal.buffer_view类型(hal.buffer_view对应tensor),包含以下几个passes。createWrapEntryPointsPass
给external func生成一个内部函数,函数中调用原始的externalfunc,同时将public func的函数体包装成一个新的函数,原publicfunc中调用该函数。该pass最终的目的是将外部导入的接口和本module导出到外部的接口参数统一成标准标量类型或hal.buffer_view(hal.buffer_view对应tensor类型)。
1 | // external/imported func |
转换成,
1 | func.func private @add(!hal.buffer_view, !hal.buffer_view) -> !hal.buffer_view attributes {iree.abi.stub} |
mlir::createInlinerPass
将WrapEntryPointsPass中生成的wrap函数内联起来。最终转换成,
1 | func.func private @add(!hal.buffer_view, !hal.buffer_view) -> !hal.buffer_view attributes {iree.abi.stub} |
mlir::createCanonicalizerPass
mlir::createCSEPass
mlir::createSymbolDCEPass
将IREE::Input dialect转换成IREE::Util、IREE::Flow和IREE::HALdialect,并转换func的属性和signature中输入输出类型。比如,
1 | iree_input.global private mutable @param : tensor<1x2xf32> |
转换成(iree_input.global.load->util.global.load,iree_input.global.store->util.global.store,iree_input.tensor.clone->flow.tensor.clone):
1 | util.global private mutable @param : tensor<1x2xf32> |
将ml_program dialect转换到IREE::Util dialect。
createSanitizeModuleNamesPass
将module name中的.替换为_,以符合mliridentifiers的命名规范。
1 | module @iree.module { |
转换成
1 | module @iree_module { |
mhlo::createLegalizeControlFlowPass
将TF1.0中的控制流原语(http://download.tensorflow.org/paper/white_paper_tf_control_flow_implementation_2017_11_1.pdf)规范化成HLO中的控制流算子。
createTopLevelSCFToCFGPass
将顶层的structured controlflow表示的控制流图转换成更底层基础块的控制流图(CFG)。
createMHLOToMHLOPreprocessingPass
mlir::createCanonicalizerPass
mlir::createShapeToShapeLowering
将 shape.num_elements 转换成shape.reduce。
mlir::createConvertShapeToStandardPass
将shape dialect lower成arith dialect、scf dialect和tensordialect。比如
1 | func.func @test(%arg0: tensor<1x?xf32>, %arg1: tensor<?xf32>) -> index { |
转换成
1 | func.func @test(%arg0: tensor<1x?xf32>, %arg1: tensor<?xf32>) -> index { |
mlir::createCanonicalizerPass
mlir::createInlinerPass
内联calls和callable operations,并删除dead callables。比如:
1 | func.func @test(%arg0: tensor<1xf32>, %arg1: tensor<1xf32>) -> tensor<1xf32> { |
私有的add函数被内联之后删除,
1 | func.func @test(%arg0: tensor<1xf32>, %arg1: tensor<1xf32>) -> tensor<1xf32> { |
IREE::Util::createDemoteI64ToI32Pass
IREE::Util::createDemoteF64ToF32Pass
mlir::createCanonicalizerPass
mlir::createCSEPass
mhlo::createLegalizeShapeComputationsPass
把scalar tensor op转换成scalar op + fromElements op。比如
1 | func.func @test(%arg0: f32, %arg1: f32) -> tensor<1xf32> { |
转换成:
1 | func.func @test(%arg0: f32, %arg1: f32) -> tensor<1xf32> { |
createConvertMHLOToLinalgExtPass
将mhlo::sort、mhlo.scatter、mhlo.fft、mhlo.reverse、mhlo.topk转换到IREE::LinalgExtdialect,同时将在IREE::LinalgExt dialect区域内部的mhlo op转换成linalgdialect,mhlo.return则转换成iree_linalg_ext.yield。比如,
1 | func.func @test(%arg0: tensor<10xf32>) -> tensor<10xf32> { |
转换成,
1 | func.func @test(%arg0: tensor<10xf32>) -> tensor<10xf32> { |
createMHLOToLinalgOnTensorsPass
将外层剩余的mhlo op转换到linalg dialect。比如
1 | func.func @test(%arg0: tensor<1xf32>, %arg1: tensor<1xf32>) -> tensor<1xf32> { |
转换成,
1 | func.func @test(%arg0: tensor<1xf32>, %arg1: tensor<1xf32>) -> tensor<1xf32> { |
mlir::createReconcileUnrealizedCastsPass
消除unrealized conversion cast操作。算法过程描述:如果unrealizedconversion cast是dead节点(没有user或所有users也都是unrealizedconversioncast),则直接删除该dead节点;如果是live节点(至少有一个非unrealizedconversioncast的user),则遍历其所有子节点,如果其子节点中所有unrealizedconversion cast的result type与该op的inputtype相同(即不存在真实意义的type cast操作),则将所有遍历到的unrealizedconversioncast都折叠成该op的输入,否则报错live unrealized conversion cast。
mlir::createCanonicalizerPass
createVerifyCompilerMHLOInputLegality
验证program是否合法。
IREE编译的入口是IREEVMTransformPassPipeline,IREEVMTransformPassPipeline又被分成InputConversionPassPipeline、CommonInputConversionPassPipeline、ABI::TransformPassPipeline、Flow::FlowTransformPassPipeline、Stream::StreamTransformPassPipeline(仅CUDA后端)、HAL::HALTransformPassPipeline、VM::VMTransformPassPipeline等几个阶段。
InputConversionPassPipeline
IREE编译流程解析(一)主要作用是将不同的输入(MHLO或XLA、TorchTensor和TOSA)统一lower成linalg dialect和builtin的arith dialect、scfdialect和tensor dialect。
CommonInputConversionPassPipeline
IREE编译流程解析(二)主要作用是将IREE::Input dialectlower成IREE::Util、IREE::Flow和IREE::HAL dialect。
ABI::TransformPassPipeline
IREE编译流程解析(三)主要作用是将外部导入的接口和本module导出到外部的接口参数统一成标准标量类型或hal.buffer_view类型(hal.buffer_view对应tensor)。
Flow::FlowTransformPassPipeline
IREE编译流程解析(四)主要作用是执行一系列窥孔优化,比如1x1的conv2d转换成matmul、tiling、opfusion等,最终将workload拆分成flow.executable。
Stream::StreamTransformPassPipeline
IREE编译流程解析(五)主要作用是将program转换到streamdialect,优化变量编码方式,划分调度子图,生成异步调度策略,并实现内存规划策略。
HAL::HALTransformPassPipeline
IREE编译流程解析(六)主要作用是进行tiling、vectorization和bufferization等操作,分配计算负载,最终生成targetdevice的代码。比如cuda target的dispatch source code会被递降为NVVMIR。
VM::VMTransformPassPipeline
得益于上层统一的抽象和模块化的设计,后端引擎只需要处理一些差异化的接口,并且这些差异化通常只体现在子图的编译和executablelaunch接口的具体实现上。
我们把XRT的每个子图都看成是一个function,function包含输入和输出参数,以及对应的函数体(DAG表示的计算图),比如下面表示的是只包含一个relu节点的XRT子图,其中node表示计算节点,input和output分别表示子图的输入和输出。
1 | function { |
在runtime阶段function首先需要被编译成executable,执行function实际上就是feed对应的输入参数去launch这个编译好的executable,同时得到执行的结果,即function的返回值。
在XRT框架下每个后端引擎都有一个与之相对应的executable(比如XLA的XlaExecutable和TensorRT的TrtExecutable),和将function编译成对应executable的compiler(比如XLA的XlaGraphCompiler和TensorRT的TrtGraphCompiler),因此添加一个新的后端引擎,通常只需要添加一个对应的executable和compiler。下面以添加一个自定义的后端引擎Toy为例,详细介绍在XRT框架下支持新的后端引擎的具体过程。
首先在xrt.proto文件中XrtEngine下增加一个Toy引擎字段。
1 | enum XrtEngine { |
如果Toy引擎针对的硬件不在XrtDevice中,则需要在XrtDevice中增加对应的设备字段。这里我们假设自定义的Toy引擎只支持GPU_CUDA,因此就不需要修改XrtDevice了。
接下来,与XLA和TensorRT一样,我们在oneflow_xrt/compiler目录下创建一个toy目录,其余所有与Toy引擎相关的代码都将放在该目录下。
在增加任何一个后端引擎之前,我们都需要仔细考虑该后端引擎所需的最小执行环境,一个最简单的执行环境包括输入输出、中间结果以及执行具体计算逻辑的硬件代码,这个代码可以是通过codegen自动生成的,也可以是手工实现的。
接下来我们给自定义的Toy引擎增加一个对应的ToyExecutable。在oneflow_xrt/compiler/toy目录下,我们创建文件toy_executable.h和toy_executable.cpp。
toy_executable.h中定义ToyExecutable,ToyExecutable必须继承自Executable,并实现Run接口。为了尽可能简单,ToyExecutable只包含输出outputs、中间结果tmp_buffers和编排好的函数调用列表func_codes,以及每个函数的输入输出参数对应的buffer序号func_args_。
1 |
|
在toy_executable.cpp中实现Run方法,这里我们只是简单的顺序执行编排好的函数func_codes。
1 |
|
目前为止我们已经完成了一个最简单的运行时executable,这个executable甚至有点类似其他框架中提供的最简单的图执行器(graphexecutor)。接下来我们要介绍如何将一个XRT的子图编译成上面的ToyExecutable。
每个后端引擎都对应一个compiler,当我们希望使用某个后端引擎来执行一个XRT子图时,就需要有一个对应的compiler将该子图编译成后端引擎对应的executable。Compiler通常都非常注重编译产物的执行性能,而性能以外的关切点也导致了不同的技术路线,比如对算法通用性、跨平台有高度关切的TVM和XLA采用了LLVM传统编译器的路线,而对于过分看重性能但硬件平台单一的TensorRT更多的则是采用手工优化和tuning相结合的策略。不过这两种技术路线并不是完全对立的,也是在不断地相互借鉴和融合。
在XRT中,所有这些技术方案都是可以被兼容的,你可以根据实际情况自由切换,你也可以把XRT当成实验场所,实现一个自定义的compiler,并在同一套框架下对比不同compiler、不同技术方案的优劣。
回到本文的主题,我们现在需要实现一个ToyExecutable对应的compiler,我们也把该compiler叫做ToyGraphCompiler。
首先在oneflow_xrt/compiler/toy目录下新建两个文件toy_graph_compiler.h和toy_graph_compiler.cpp。在toy_graph_compiler.h文件中定义类ToyGraphCompiler,ToyGraphCompiler必须继承自类GraphCompiler::Impl,并实现对应的Compile接口。
1 | class ToyGraphCompiler : public GraphCompiler::Impl { |
在toy_graph_compiler.cpp中实现Compile接口,并注册一个新的graphcompiler。在动手实现该接口之前,有必要先解释一下该接口的参数列表,graph表示的是function子图,entry_params表示子图的输入,return_params表示子图的输出,aliases通常在包含模型更新操作时会用到,表明输出和输入是一对别名关系。被alias的输入将生命期延长到了整个子图,并且与对应的输出共享内存,因此也就间接实现了inplace计算的目的。
我们按拓扑顺序遍历子图中的每个节点(或op),依次将节点编译成具体的执行代码,并在合适的位置插入临时buffer。为了方便处理不同类型的op,我们在下面的代码中引入了ToyOpContext和ToyOpKernel的概念。
1 | // Register a new graph compiler for TOY engine. |
ToyOpContext临时存储编译需要的元信息和编译结果,为ToyOpKernel提供必要的接口,ToyOpKernel则根据op类型完成单个op的编译过程。上述代码中我们实现了一个将XRT子图编译成ToyExecutable的最简单的graphcompiler,下面我们将以ReLUop为例,介绍ToyOpContext和ToyOpKernel是如何对op进行编译的。
我们回过头再仔细研究一下ToyGraphCompiler的Compile实现,ToyOpContext接受两个输入,node和当前所有已经创建过的parameters,经过OpKernel编译后输出函数代码(func_code_)、中间buffer(tmp_buffers_),以及函数代码输入和输出对应的parameternames。因此在这个例子中,ToyOpContext被设计成如下形式:
1 | class ToyOpContext { |
对于ToyOpKernel,为了处理不同类型的op,我们采用工厂注册模式,并且这种模式还有另一个用处,就是在XRT划分子图时可以用来判断该引擎是否支持某个类型的op。XRT已经将kernel注册接口封装成了一个辅助类OpKernelRegistrar,但同时也要求ToyOpKernel必须继承基类OpKernel。
1 | class ToyOpKernel : public OpKernel<ToyOpContext> { |
使用OpKernelRegistrar定义一个用来注册ToyOpKernel的宏。
1 |
最后我们实现一个Relu的OpKernel,填充ToyOpContext的func_code_、tmp_buffers_以及输入输出arguments。
1 | void ComputeRelu(const Parameter &input, const Parameter &output) { |
最后将ToyReluOpKernel注册到Toy引擎对应的OpKernel工厂下。
1 | REGISTER_TOY_OP_KERNEL(relu, ToyReluOpKernel) |
EnableTrainPhase表示该op支持训练,OpKernelRegistrar也提供了其他一些接口,比如设置支持的device列表,mutablevariables(inplace更新)和是否是model update op(model updateop会影响子图划分)。
在CMakeList.txt中添加一个BUILD_TOY的选项,并在oneflow_xrt/CMakeLists.txt中添加如下toy引擎模块的编译代码,
1 | if(BUILD_TOY) |
之后在oneflow_xrt/python目录中添加导出Python模块的代码toy_stub.cpp,
1 |
|
并在oneflow_xrt/python/CMakeLists.txt中增加如下代码,
1 | if(BUILD_TOY) |
修改setup.py文件,新增一个toyextension的编译,并在build_ext函数中开启BUILD_TOY选项,
1 | setup_extension( |
执行命令python3 setup.py install完成wheel包的编译和安装,最后执行如下代码测试添加的toy引擎是否可以正常执行,
1 | import oneflow as flow |
为了便于Python和C++混合编程,TVM使用了统一的PackedFunc机制。PackedFunc可以将C++中的各类函数打包成统一的函数接口,并自动导出到Python模块中进行调用,并且也支持从Python中注册一个函数,并伪装成PackedFunc在C++和Python中调用。

ctypes是Python自带的跨语言函数调用库,ctypes提供了简单的C数据类型,可以将C/C++动态库中的函数包装成Python函数进行调用。
导出C++函数
首先在C++中定义一个全局函数,并编译生成C++动态库。
1 | // test.h |
1 | // test.cc |
用ctypes模块在Python中加载生成的动态库(test.so),并调用C++中的函数。
1 | import ctypes |
传递Python函数到C++
ctypes也支持将Python函数转换成C类型的函数,并在C/C++中进行调用。
1 | def add(a, b): |
Pythonadd有两个参数a和b,返回值类型与a和b的类型一致。在C++中可以为Pythonadd定义一个函数原型 int(int, int)。
1 | extern "C" { |
1 |
|
使用ctypes将Python函数转换成C function,传入C++中进行调用。
1 | import ctypes |
ctypes可以很方便的将C/C++中的函数导出到Python,调用时直接传入对应的参数即可,但如果需要将Python函数导入到C/C++,则需要在C/C++中提前定义好对应的函数原型(比如上面的PyCFunc),并提供对应函数的调用入口(call_py_func)。为了支持更加灵活的函数定义,TVM将不同类型的函数包装成统一的函数原型。
1 | void(TVMArgs args, TVMRetValue *rv); |
统一的函数原型被封装成PackedFunc对象,提供通用的调用接口,直接与调用者进行交互。
1 | class PackedFunc { |
当获得一个PackedFunc对象时,我们就可以像调用普通函数一样调用PackedFunc打包的函数。比如:
1 | PackedFunc f; |
TVM支持对各类函数进行打包,包括一般的函数、类的成员函数以及lamda表达式。
函数原型萃取
萃取函数原型是为了得到函数的参数和返回值类型。TVM中使用decltype和模版结构体function_signature来实现。
比如定义一个简单的C函数,
1 | int add(int a, int b) { |
接下来就可以使用如下的代码来萃取add的函数原型,
1 | template <typename R, typename ...Args> |
此外只需要特化function_signature就可以支持函数指针和lambda表达式。注意:TVMfunction_signature不支持普通成员函数的类型萃取,因此TVM需要借助一个辅助function_signature_helper来对lambda表达式类型进行萃取,而我们这里的function_signature支持普通成员函数,因此lambda表达式类型萃取可以通过递归的function_signature来实现。
1 | // 普通函数指针 |
函数打包
一旦萃取到了函数原型,TVM就利用TypedPackedFunc对普通函数或lambda表达式进行打包。TypedPackedFunc只支持对R(Args...)类型的函数打包,所以如果被打包的函数是一个函数指针,则需要创建一个lambda表达式,转换成R(Args...)类型之后再用TypedPackedFunc对创建的lambda表达式进行打包。
1 | template<typename R, typename ...Args> |
当被打包的函数用来实例化TypedPackedFunc对象时,会立刻调用AssignTypedLambda将被打包的函数打包成PackedFunc。
1 | template<typename R, typename ...Args> |
AssignTypedLambda实际上是将被打包的函数先封装成了一个函数原型为void(constTVMArgs &args, TVMRetValue*rv)的lambda表达式,然后将这个lambda表达式作为PackedFunc对象的一个成员,通过设置合适的接口(重载operator()),使得PackedFunc与被打包的源函数表现的完全一样了。
TVM将需要从C++自动导出的函数打包成PackedFunc,然后通过宏TVM_REGISTER_GLOBAL注册到全局的一个map中。比如:1
2
3
4TVM_REGISTER_GLOBAL("_Var")
.set_body_typed([](std::string s, DataType t) {
return VarNode::make(t, s);
});
当Python加载编译好的动态库时,会自动查询map中静态注册的函数,每个函数都包装成Python中的Function对象,最终添加到Python模块中。Function重定义了函数调用接口,自动完成参数打包过程。如果是在Python中动态注册的函数,则需要在Python中通过函数名和来查询PackedFunc,返回一个PackedFunc的handle(函数指针),并封装成Function。
1 | def get_global_func(name, allow_missing=False): |
注:TVMFuncGetGlobal是通过ctypes导出的C++接口,FunctionHandle是ctypes中表示void指针类型(c_void_p)。
由于TVM中PackedFunc的精心设计,我们只需要将Python中的函数转换成统一的函数原型void(constTVMArgs,TVMRetValue),然后将函数转换成PackedFunc并动态地注册到全局的map中。
先将Python函数用ctypes转成int(TVMValue , int , int, void, void )的C函数。
1 | TVMPackedCFunc = ctypes.CFUNCTYPE( |
然后通过TVMFuncCreateFromCFunc将上面的C函数转换成统一的PackedFunc函数。
1 | int TVMFuncCreateFromCFunc(TVMPackedCFunc func, |
最后通过接口TVMFuncRegisterGlobal注册到全局的map中。下面是从Python中注册一个函数,并在Python中调用的例子。
1 | targs = (10, 10.0, "hello") |
图替换(或者叫图改写)是一种重要的图优化技术,几乎在所有的开源框架(尤其是移动端框架)中都有应用。比如tensorflowr1.14版本中就包含了155个替换子,而且实现这些替换子的总代码量接近53k行。
一些常见的图优化技术:
DCE
CSE(公共子表达式消除)
常量折叠
数学公式简化
Op融合
Layout变换
内存优化(swap-in/swap-out、重计算)
由于目前的编译器技术通常基于low-level的中间表达,注重对局部计算的优化,对于跨多个粗粒度op的优化要不无能为力,要不就得增加编译器的分析难度并导致代码膨胀。一般来说AI框架支持的粗粒度op非常有限,而且这些op的组合常常也比较固定,比如convolution通常和bias_add、relu组合使用,因此基于高层中间表达的图替换成为一种比较可行的优化方案。经过图替换优化后的计算图再经过编译器的优化后,生成最终的硬件代码。
目前主流开源框架的图替换都是基于经验和手工设置的替换子来实现的,在这里统称为经典图替换技术。
图替换是将原始计算图替换成另一个优化后的等价计算图,替换后的计算图通常是硬件友好的,比如可以消除中间结果,降低内存占用,减少访存和计算量,并且不影响最终的计算结果。
在进行图替换之前,首先需要定义出源计算图到目标计算图的替换规则(替换子),由于这些替换规则往往需要依靠人的经验一条条手工去定义,因此称之为经典图替换。给出一条替换子,我们需要在原始计算图中不断地去匹配替换子的源计算子图,一旦匹配到满足要求的子图后,就将源计算子图重新映射为替换子中的目标计算图。
在一些开源框架中,替换子的定义形式不尽相同。在TensorFlow中源图匹配和替换的定义是非常松散的,它甚至没有直接定义出替换子的源图,而是定义一系列约束来判断是否匹配。PaddlePaddle中则是将一个替换过程定义为一个pass,pass执行时动态构建相应的替换子源图,执行匹配算法并回调源图到目标图的替换函数。比如下面是TensorFlow中将Conv+BiasAdd替换成FusedConv的过程。
定义匹配约束
1 | struct ContractionWithBiasAdd { |

定义替换过程
1 | // pattern为输入的ContractionWithBiasAdd, |

TensorFlow采用的定义匹配约束的方式与直接定义出子图的方式本质上是等价的,但相比后者可读性较差,而优点就是代码可复用性高,比如上面的FindContractionWithBias可以同时匹配Conv+BiasAdd和MatMul+BiasAdd两种子图,并且这些约束便于嵌套使用。
无论是TensorFlow还是PaddlePaddle,图替换都是不完全的。比如说对于Conv+BiasAdd+BiasAdd这种计算图,第一次只能匹配到Conv+BiasAdd,替换后又变成了一个Conv+BiasAdd的计算图,因此TensorFlow中默认采用了两遍优化。根据TensorFlow公开的一些数据,基本上第二次优化的机会已经非常少了。
InceptionV3

Seq2Seq

超优化(Superoptimization)是现代编译器中的一种指令优化技术,其主要工作原理是通过随机生成指令序列以及暴力搜索的方式自动找到一组优化的指令序列,并等价替换原有的指令序列。1992年第一个Superoptimizer被集成到了GCC编译器,之后Google也为LLVM开发了一个Superoptimizer,取名为Souper。
依靠人工设定的编译器往往对代码的优化不够彻底,给生成的code留下了大量的优化空隙,而且人工设定的优化规则往往没有经过充分验证,经常导致各种极端条件下的代码bug。Superoptimization将指令序列优化问题转换为自动搜索问题,并加入了自动化验证和一阶逻辑验证,在发现代码优化空隙的同时优化结果也更加可靠。
TASO(Tensor AlgebraSuperOptimizer)将Superoptimization用于DNN高层中间表达的图优化,在大多数模型上取得了比XLA和TensorRT更优的效果。TASO的工作是MetaFlow(作者另一个基于人工规则的图替换框架)的延续,因此也采用了与MetaFlow一致的替换子定义。MetaFlow替换子的定义包括:源图、目标图、输入和输出的映射关系。

TASO相比其他开源框架最大的区别就是不需要手工去设定各种各样的替换子,只需要像设计硬件指令一样设计出基本的算子定义(或者计算逻辑),之后系统会根据指定的算子集自动生成满足条件的替换子,经过验证的替换子最终作用于图替换过程。基于高度抽象的替换子定义,TASO可以独立于具体的训练或预测框架,离线完成替换子的生成和验证,并在图优化阶段加载到程序中进行图替换。尽管手工设计有很多弊端,但TASO在代码实现过程中并没有完全抛弃手工设计的方式,而是采用了手工设计和替换子自动生成相结合的方式。

替换子包含三个部分,源图、目标图、输入和输出tensor的映射关系。并且替换子通常是与shape无关的,源图和目标图都是由算子构成的,每个算子都可以指定一些配置,比如kernel指定卷积核的大小、axis指定reduce的维度等等。
但需要注意的是concat和split两个算子,在图替换中这两个算子通常用于算子融合,比如下图对两个不同的输入B和C进行相同的MatMul操作,就可以替换为先将输入B和C进行一次合并,然后调用一次MatMul后,对结果进行切分得到两个输出X和Y。

为了能正确切分出X和Y,在Concat时我们需要给每个维度维护一个分割树(splittree)。一个行分割的例子如下,图中需要将A和B按照第0维进行concat,因此输入A在第0维有一个原始的分割树[0,\(S_{A}\)],表示对于tensorA,第0维从0到

替换子生成包含两个阶段:构建搜索空间,以及对潜在的替换子进行测试。
构建搜索空间
搜索空间由任意合法的计算图构成,而计算图由给定的算子集中的算子组成。TASO向我们表明了一种暴力枚举、深度优先递归构建的方法。
给定算子集和初始化的inputtensor集合,对于每一个输入tensor,每次从算子集中选择一个合法的算子构建graph,并计算当前graph的输出tensor,将输出tensor加入到inputtensor集合,保存graph以及graph的fingerprint(对输出tensor计算hash值),接着重复上面的过程继续加入算子,直到递归的深度达到设定的上限。
对于同样的输入tensor,如果构建的两个计算图的输出tensor相同,则这两个计算图构成了一个潜在的替换子。为了避免出现浮点计算异常的情况,构建计算图时所有的tensor都是int类型。
测试潜在替换子
为了进一步验证潜在替换子的合法性,TASO设计了一系列cases来测试潜在替换子。每个测试case都使用随机初始化的输入tensor,当两个计算图结果一致时才认为测试通过,只有所有测试cases都通过的潜在替换子才是合法的替换子。
与构建计算图时使用int类型的tensor不一样,所有测试case的输入tensor都是-1到1之间的浮点数。由于relu对于所有小于0的值都返回0,因此可能导致非法的替换子通过测试cases,作者认为可以使用任意一个非线性函数来代替relu,TASO中使用

TASO同时使用一阶逻辑表达的算子属性对替换子进行进一步验证,这些属性通常是由人工定义,并且经过充分review和大量测试验证过的。
在定义算子属性之前,首先需要对算子进行符号建模,算子模型通常包含参数和输入tensors。比如
自动生成的替换子往往存在大量的冗余,TASO使用了两种策略消除冗余。
Input tensor renaming
对输入进行重命名的方式消除不同替换子之间的冗余。比如下面两个替换子,
替换子a:

替换子b:

将替换子a的一个输入tensorA改名为C,就得到了替换子b,说明这两个替换子存在冗余,因此最终只会保留更加通用的替换子b。
Common subgraph
如果替换子的源图和目标图包含同样的子图,则可以用一个相同的tensor替换掉公共子图。比如下面的一个替换子,

source graph和target graph包含同一个子图(B x C),将sourcegraph替换成targetgraph时,公共子图没有任何变化,因此可以将子图消除。
实验结果显示,裁剪可以消除大量的冗余替换子。

输入HloModule,经过以下三个阶段,最终输出LLVM IR。
论文主要针对XLAFusion算法进行了改进,提出了实现Block合并策略的Schedule和Shared MemoryPlanning技术,以及实现对应的IR Emitter。
利用Work/Span analysis,将instruction划分到不同的layer,然后DeepFusion模块在Schedule ConsistencyChecker的指导下完成跨layer的instruction合并。该过程是迭代进行的,直到完全没有合并机会。
Work/Spananalysis通常用于并行算法的分析。假设每个基本运算执行时间都是单位时间,则Work表示的是所有基本运算时间总和,Span表示最长依赖路径上的基本运算时间总和。对于一个计算图来说,可以简单认为图中所有的计算节点总执行时间表示Work,而计算图的最大深度的路径上的节点的顺序执行总时间表示Span。
在这里作者用Span来表示每个节点到root节点的深度。
经过Work/Spananalysis后,HloModule中的Instruction被划分到了不同的layer,相同Span值的Instruction的layer相同,并且同一layer的Instruction没有依赖关系。
基于Work/Spananalysis计算得到的Span值,作者提出了不同于XLA的Fusion算法。

SchdConsistent用来判断fusion_root和hlo是否应该合并,其具体的执行逻辑如下:
- 算法简单高效,Work/SpanAnalysis的作用其实相当于对Instruction做了一遍拓扑排序,通过简单的合并规则确保circlefree。
- 不区分expensive op,可以通过sharedmemory来缓存中间结果,因此不需要重计算。
- 由于第一条约束的强制性,导致合并不完全。
Schedule通常指的是将算法指定的计算过程分配给计算资源的方法。这些计算过程可能包括线程、进程以及数据流等。
常见的一些Schedule配置: - Reorder 循环顺序重排,比如for x for y ->for y for x - Tile - Unroll - Vectorize - Parallel - some CUDA-specific比如blocks、threads、shared memory size等。
对于包含多个计算stage的算法,Schedule通常是由Consumer驱动,并指定何时对Consumer计算Producer(Specifywhen the producer is computed with respect to the consumer)。
论文中将Instruction大致分成Elementwise、Transpose、Reduce、BatchDot、Reshape和Broadcast这几种,然后基于这些op定义了一套用来表示对数据分块的Shedule配置。通过一个定义好的Shedule配置和数据的shape,我们就可以知道需要切成多少个数据块,映射到GPU硬件上就是多少个线程块(threadblocks)。
Shedule定义在输出shape上,包含三个字段:split_dim、sword和sched_type。split_dim表示切割的维度,取值[0,num_dims)。sword表示在split_dim维度上切分多少块,sword要求能被split_dim维度K整除。sched_type表示行切割还是列切割,取值Row或者Column。给定一个Instruction,其Schedule空间即所有合法的三元组(split_dim、sword和sched_type)。
上图表示Reduce Instruction的两种合法Schedule,通过split_dim和reducedim来区分Row Schedule和Column Schedule。
与Instruction的Schedule定义在输出shape上一样,Computation的Schedule也定义在RootInstruction的输出上,因为Root Instruction是整个Computation的输出。
对于一个Fused Computation,需要满足Shedule相容约束:即对于RootInstruction,给定一个合法的Shedule,该Shedule需要同时被其他Instruction相容。论文中提出后向传播的方法来判断Shedule约束的相容性。
任意一个Instruction,其合法的Schedule可以根据Instruction类型和outputshape来确定。如果给定的Schedule满足约束(是合法的),则把Schedule后向传播到输入shape(s),也就是输入Instruction的输出shape。否则从RootInstruction传播过来的Schedule在整个FusedCompution上不满足相容性约束。
在Subgraph Fusion算法中,两个Instruction能否合并除了需要满足circlefree约束外,还需要满足后端CodeGen模块的支持。只有Schedule满足约束,CodeGen才能正确发射代码,否则CodeGen无法处理。
Table.1表明了不同Instruction的Schedule后向传播规则。Schedule约束判断结果会反馈到SubgraphFusion过程,Fusion不应该破坏Schedule相容性约束。
任意一个Instruction,split_dim=0和sword=1的RowSchedule总是合法的,也就是只有一个数据块,并且只用一个GPU线程块来计算。这样做的问题也很明显,就是无法充分利用GPU硬件资源。每个Instruction可能有多个合法的Schedule,ScheduleTuning用来选择一个合适的Schedule。
如果Computation中只有一个Root,遍历该RootInstructon所有合法的满足约束的Schedule,在performancelibrary中查找每个kernel的执行时间,并统计总耗时。总耗时最少的Schedule会被选择用来CodeGeneration。
如果Computation中有多个Roots,则采取一种two-stage的方法加速Schedule的搜索过程。
第一步:遍历所有的Roots,计算blocks和blocks对应的Schedule两个序列。对所有Roots对应的blocks序列求交集,结果对应的Schedule即合法的候选Schedule。
第二步:遍历所有的候选Schedule,计算每个Schedule下所有kernel的耗时,选择耗时最少的Schedule。论文中还提到可以忽略部分ops和earlystop的搜索策略,加速第二步的搜索过程。
标记出所有可能需要用到SharedMemory的候选ops,当Memory不足时优先满足most critical ops。
Size Requirement Analysis
直接分配 对于非RootInstruction的Reduce和BatchDot,必须将中间结果放在Shared Memory,allowingconsumer ops to use seperate parallel loop emitters to generatecode。
按优先级分配 对于有多个Users的Elementwiseop,为了避免重计算,可以选择将结果缓存到SharedMemory。在memory受限的情况下,按照优先级(expensive op > 非expensiveop)确定使用Shared Memory。
有时对于只有一个User的expensive op也需要用到SharedMemory,比如如果expensiveop后面接了一个BatchDot,由于BatchDot本身对数据的复用性比较高,将expensiveop的结果缓存到Shared Memory会带来非常好的性能优化。
Size Shrinking
Size Shrinking与上面RequirementAnalysis的第2点类似。当每个线程Block分到的数据块非常大时,可能存在SharedMemory受限的问题。解决办法就是让一些ops退化为重计算。
从inexpensive ops开始,然后expensive ops,之后是带有BtachDot的expensiveops,最后按照靠近RootInstruction的程度选择候选ops,并优先选择靠近输出的ops。
Space Sharing
不同ops分配的Shared Memory是可以复用的,论文中作者提出从RootInstruction开始构造一颗支配树,支配节点可以复用被支配节点申请的SharedMemory。
XLA使用GpuElementalIrEmitter来实现线程合并的Computation。基于XLA的GpuElementalIrEmitter,作者实现了用于Block合并的IrEmitter(论文中称作IrEmitterStitched)。

IrEmitterStitched输入有hlo、root、shared、schedule和generators。
基本逻辑如下:
Consumer本身支持合并
特定op不支持与Producer合并,比如Parameter、While、Conditional、Call等,以及op本身hasa side effect或者调用了has a sideeffect的op。此外被标记为tracing的op也无法合并。
Consumer与Producer之间支持合并
nvidia的Pascal和Volta系列显卡除了支持标准的单精度计算外,也支持了低精度的计算,比如最新的TeslaV100硬件支持了FP16的计算加速,P4和P40支持INT8的计算加速,而且低精度计算的峰值要远高于单精浮点的计算峰值。

为了加速训练过程以及减少显存开销,baiduResearch和nvidia在这篇论文中合作提出了一种FP16和FP32混合精度训练的方法,并且在CNN分类和检测、语音识别和语言模型任务上进行了验证,实验过程中使用的GPU就是TeslaV100。
训练过程中每层的权重都存成FP32格式(Mater-Weights),每次训练时都会将FP32的权重降精度至FP16(a mastercopy),前向输出和后向梯度都使用FP16进行计算,更新时将FP16的梯度累加到FP32的Mater-Weight上。

由于FP16所能表示的subnormal最小正数是
混合精度训练可以解决权重更新量很小的问题,但无法解决梯度本身很小的问题。在一些网络中(比如SSD),梯度大部分都在FP16的表示范围之外,因此需要将梯度平移到FP16的表示范围内。

平移实际上就是对梯度值乘以一个系数(等于
论文中提到他们在实验过程中使用的scale是8~32K,最终取得了与FP32一致的收敛结果。对于scale的选择,论文没有统一的方法,只是提到scale并没有下界,只要选择的scale不会在后向计算时导致溢出就行。
图像分类

物体检测

语音识别

机器翻译

语言模型

半精度(16bit)分为半精度浮点(FP16)和半精度定点(INT16),FP16和INT16提供不同的精度和表示范围。INT16相比FP16的动态范围低,但精度更高,因此INT16相比FP16会带来更低的精度误差。
现在深度学习领域公认的数据类型是单精度浮点(float),半精和单精除了在直观感觉上的数据类型不同之外,在计算(algorithmic)和语义(semantic)上也会有很多的不同,比如说FP16的乘加操作得到的结果是FP32。因此在讨论半精度训练时,对于整个tensor的表达、乘加操作、低精度转换、缩放和规整方法和溢出处理都是需要同时考虑的。
intel的这篇论文主要受到之前flexpoint和混合精度训练的启发,从而提出了一种共享指数位的动态定点表达(dynamicfixed pointrepresentation)方法,使用INT16和float混合精度训练,在完全不进行任何调参的情况下,在多个CNN的模型上取得了当前所有低精度训练方法中最好的效果。
这篇论文主要涉及的技术点有:

一个DFPtensor由一个定点的tensor和该tensor共享的指数组成,更通用的表示形式为DFP-P= \(<I, E_{s}>\),P表示定点tensor\(I\)的位宽,
DFP-16和fp32的数据转换
共享指数位需要根据tensor中的绝对值最大的数和定点化的位宽来确定,计算公式如下:
\[E_{s} = E_{fmax} - (P - 2)\]
\(E_{s}\)表示DFP-P的共享指数,
因此fp32的tensor与DFP的tensor有以下关系:
\[\forall i_{n} \in I, \ \ \ f_{n} = i_{n}\times 2^{E_{s}}, \ \ \ where f_{n} \in F\]
也就是说\(i_{n} =rounding(\frac{f_{n}}{2^{E_{s}}})\),这本质上与lossscaling思想是一样的,用平移的思想来解决动态范围不够的问题。
DFP-16 tensor的乘加运算规则
1、两个DFP-16 tensor相乘,结果存为DFP-32。
\[i_{ab} = i_{a} \times i_{b} , \ \ \E_{s}^{ab} = E_{s}^{a} + E_{s}^{b}\]
2、两个DFP-16 tensor相加,结果存为DFP-32。
\[i_{ab} = \left\{\begin{aligned} i_{a} +(i_{b} >> (E_{s}^{a} - E_{s}^{b})) \ \ \ when E_{s}^{a} >E_{s}^{b} \\ i_{b}+(i_{a} >> (E_{s}^{b}-E_{s}^{a})) \ \ \ whenE_{s}^{a} < E_{s}^{b} \end{aligned}\right.\]
\[E_{s}^{a+b} = max(E_{s}^{a},E_{s}^{b})\]
3、两个DFP-32 tensor相加,结果保存为fp32。
DFP-32和DFP-16的数据转换
\[R_{s} = P - LZC(max_{\forall i_{ab} \inI^{32}}|i_{ab}|)\]
\[i_{ab}^{d} = i_{ab} >> R_{s} , \ \\ E_{s}^{ab} += R_{s}\]

intel的VNNI指令集中有一条DFP-16乘加的指令QVNNI16,这条指令的第一个操作数是DFP-16内存指针,第二个操作数是4个512位的向量寄存器(每个寄存器可以存储32个DFP-16),结果是一个512位的向量寄存器(该寄存器能存储16个DFP-32)。

上面的QVNNI16指令集实际上对mem输入做了两路并行展开,vinp2中一个寄存器支持同时对输入featuremap的两个channel进行计算。在论文中,卷积层输入的格式为(N,C/16,H,W,16),权重的格式为(C/16,K/16,KH,KW,8c,16k,2c),C表示输入featuremap的通道数,K表示输出通道数,KH和KW分别表示卷积核的height和width。
卷积计算过程伪代码:

每次对输入的ICBLK个通道进行计算,ICBLK个通道又会分成(ICBLK/16)组,每组计算16个通道,由于QVNNI指令每次只能对输入的8个通道进行计算,因此每组调用2次QVNNI16指令,计算结果vout会转换成FP32后与output累加。
baseline和DFP-16的实验均在intel最新的Knights-MillCPU上进行,DFP-16相比FP32训练加速1.8X。

Dropout
如果考虑激活函数为tanh和relu,则dropout的输出:
\[r=m*a(Wv)=a(m*(Wv))\]
inference时混合模型的输出:
\(o=E_{M}[a(M*(Wv))] \approxa(E_{M}[(M*W)v])=a(pWv)\)
\(M\)是
DropConnect
随机地将全连接层的权重值置0,即输出为:
\[r=a((M*W)v)\]
\(M\)是与
inference时混合模型的输出:
\[o=E_{M}[a((M*W)v)] \approx E_{u}[a(u)]\]
where \(u\sim N(pWv,p(1-p)(W*W)(v*v))\)
注:对于
训练时的伪代码:
inference时的伪代码:
实验结果
总结
DropConnect的初衷是解决过拟合问题的,DropConnect虽然在训练时可以将稠密矩阵乘转化成稀疏乘的方式,减少计算量,但在inference时还是需要完整的计算一遍,然后再利用正态分布多次采样后计算均值得到下一层的输入,因此inference的计算量反而增加了。论文给出的实验结果表明DropConnect在tanh和relu激活函数时会比dropout带来更低的测试错误率,sigmoid时会比dropout差点。DropConnect给模型压缩提供了一些思路,在训练时我们都倾向于选择更复杂的模型而需要非常大的计算量,DropConnect的做法表明这些复杂的模型实际上有大量的冗余,而去除这些冗余后并不会对模型产生任何伤害,反而会增强模型的泛化能力,因此在模型压缩中,对模型进行剪枝成了一个重要的研究方向。
##Learning bothWeights and Connections for Efficient NeuralNetwork
作者首先关注到神经网络预测时的能耗问题,下面给出了一个45nm的CMOS处理器能耗表。
内存读取的能量消耗比其他数学指令高出三个数量级,因此论文提出对神经网络进行剪枝以压缩模型大小,减少内存读取消耗并降低计算量。剪枝不仅降低了模型复杂度,也减少了过拟合。除了剪枝,文中也提到可以借鉴HashedNets的方法进行模型参数共享,进一步降低模型大小。
模型剪枝分成三步:
1、正常训练模型,得到每个连接的重要程度(重要程度可以用权值的绝对值表示)
2、删除重要程度低的连接,将稠密网络转换成稀疏网络
3、使用保留下来的连接重训模型
第2步和第3步迭代进行。
正则化
关于正则化对剪枝结果的影响,论文给出的结论是:剪枝后重训前L1正则比L2效果好,但重训后L2比L1效果好。
Dropout Ratio调整
Dropout仍然被用来抑制过拟合,但是由于剪枝会减小模型大小,因此重训时Dropoutratio也应该更小。
\[C_{i}=N_{i}N_{i-1}\]
其中\(D_{r}\)为重训的ratio,
重训参数
由于神经网络的连续层往往保持耦合性,因此重训模型时最好保持连接的权重,而不是重新初始化。并且卷积层和全连接层的剪枝是交替进行的,对fc进行剪枝重训时需要保持conv不变,反之对conv进行剪枝重训时需要保持fc不变。
迭代剪枝
迭代剪枝的方式可以最大程度的压缩模型大小。在不损失效果的前提下,相比单次剪枝,多次迭代的方式可以将AlexNet的压缩率从5X提高到9X。
裁剪神经元
每次剪枝可以将那些没有输入连接或没有输出连接的神经元移除。无输出的神经元对最终模型结果没有任何影响,因此移除也不会对模型效果产生影响,而那些没有输入连接的神经元由于梯度下降和正则化最终也会变成无输出的神经元。
实验结果
文中将裁剪门限设置为一个质量参数乘以这一层权重的标准差,并在LeNet、AlexNet和VGG-16上进行了相关实验,卷积层也可以跟全连接层一样使用相同的剪枝策略,重训模型时会有一次调整学习率的过程,比如LeNet重训时学习率会衰减到原来的1/10,AlexNet会衰减至原来的1/100。
AlexNet各层的压缩情况:
剪枝与其他模型压缩方法的对比:
模型保存
稀疏矩阵在保存时需要同时保存indices,比如按照CSR格式保存时,我们除了保存所有的非零元素外,还需要保存每个元素对应的列号以及每行第一个非零元素在所有元素中的位置。为了压缩保存indices带来的开销,文中提到使用相对indices代替绝对indices,全连接层可以使用5bit来表示相对indices,而卷积层也可以只使用8bit。
总结
由于卷积层本身就是稀疏连接,相比fc对剪枝更敏感,因此剪枝方法对于全连接层的压缩率更高。剪枝只能压缩模型大小,但inference时并不会带来预测速度提升。intel在16年提出另一个剪枝与嫁接相结合的方法
##Channel Pruning for Accelerating Very Deep Neural Networks
]]>主要学习的是论文Neural machine translation by jointly learning toalign and translate (Dzmitry Bahdanau、Yoshua Bengio等,2016.05)和Neuralmachine translation (Minh-ThangLuong,2016.12)。
神经机器翻译的目的是将一门语言的文本序列翻译成另一门语言的文本序列,因此机器翻译的训练语料一般是源语言和目标语言组成的一对文本,也叫做平行语料(parallelcorpus)。我们通常将输入和输出都是序列的模型叫做seq2seq,seq2seq不仅应用在机器翻译领域,也用于当前热门的自动问答系统以及文本摘要的自动生成等领域。
2014年Dzmitry Bahdanau、Yoshua Bengio等人在论文Learning PhraseRepresentations using RNN Encoder–Decoder for Statistical MachineTranslation中首次提出将RNNEncoder-Decoder结构来计算双语短语对的条件概率,用于改进统计机器翻译的效果。Encoder-Decoder是由encoder和decoder两部分组成,encoder将输入序列编码成定长的语义向量,decoder将语义向量进行解码得到目标序列。

在NMT中Encoder-Decoder试图直接对并行语料的条件概率
\[\log p(Y|X)=\sum_{t=1}^{T_{y}}\logp(y_{t}|y_{<t}, c)\]
对于不定长度序列的编码和解码,我们很自然会想到RNN,实际上RNNEncoder–Decoder就是正反两组RNN拼接在一起组成的编码解码网络。经典的RNNEncoder–Decoder示意图如下:

我们可以用下面公式描述编码过程:
函数
对于解码过程,生成
其中,
在Encoder-Decoder中每个目标词生成时使用的都是同一个向量

第一篇论文在Encoder-Decoder的基础上引入注意力机制,来解决上述注意力分散的问题。在论文中提出,每个目标词生成时使用的语义向量是不同的,也就是说Encoder-Decoder将会学会在生成目标词时给每个源语词分配权重,这个权重表示该源语词对当前目标词的重要程度。增加了attention机制的Encoder-Decoder框架如下图:

在基于attention的模型中,每个目标词生成时的条件概率可以写成:
在RNN中每个时刻的隐状态

下图是Bahdanau在论文中给出的一个模拟图,图中模拟的是在给定源语序列(

在Bahdanau的论文中Encoder和Decoder使用的都是GRU(Gated RecurrentUnit),GRU与LSTM一样都是RNN众多变体中比较常见的一种,也可以使用其他变体RNN,比如在ThangLuong的论文中主要用的就是LSTM。
我们知道传统的RNN理论上可以记忆无限长的序列,但由于递归权重对每个时刻的输入都是一样的,这就导致一个二选一的问题:(1)模型发散,无法收敛(2)梯度消失,无法产生长时记忆。GRU和LSTM一样,都是通过引入门(gate)的机制来解决传统RNN梯度消失的问题,gate打开和关闭是由当前时刻的输入和前一时刻的隐层状态控制的,也就是说每个时刻gate的状态都是不同的,一些需要长时间记忆的信息会通过gate一直传递下去,从而学习到长距离依赖。
传统RNN的隐层计算公式:
下面是GRU结构的示意图,输入为

GRU的计算过程:
1、首先计算重置门\(r_{t}\)和更新门
实际上在Bahdanau的论文中使用的是双向RNN(BiRNN),BiRNN在前向RNN的基础上增加了一个反向RNN,使得RNN可以同时看到历史和未来的信息,最终前向RNN的隐层状态和反向RNN的隐层状态拼接后输出。
\[h_{i}=\left [ \begin{align} &\vec{h_{i}} \\ & \stackrel{\leftarrow}{h_{i}} \end{align}\right]\]
在Bahdanau的论文中decoder采用是一个前向的GRU,但与encoderGRU不同的是decoder GRU需要额外输入语义向量
\[\widetilde s_{i}=tanh(Wy_{i-1}+U[r_{i}\odots_{i-1}]+Cc_{i})\]
主要对一些通用分布式计算框架进行比较。
Hadoop:只提供了一些粗粒度的操作,比如Map、Reduce和Join等。很多限制导致基于Hadoop的机器学习算法效率都非常低,这些限制包括中间结果会落盘、只能在shuffling阶段进行数据交换等。
Spark:使用RDD弥补了Hadoop的一些缺点,提供MLlib库,MLlib整合了很多机器学习算法,并且非常容易使用。但MLlib只支持中等规模的特征,计算和通信效率都比较低。一些公司使用第三方组件来弥补Spark的缺陷,但至今没有一个完美的方案。
GraphLab和GraphX:基于图的并行计算框架,允许用户进行细粒度的控制,但并不适合通用的机器学习算法,比如LR、深度学习等,并且也存在效率低的问题。
MPI:接口灵活高效,代码自由度比较高,比如在代码中所有进程之间可以随时通信。但使用MPI开发一个新算法的开销非常大,比如一个复杂的异步矩阵分解算法需要2000多行代码。MPI没有提供分布式ML平台通用的组件,比如分布式数据读取,内存管理和多线程并行的组件。更重要的是MPI没有提供单点失败的本地解决方案,根据他们的统计数据显示MPI作业在节点数越多时失败率越高。
parameterserver框架:包含无状态的workers和有状态的servers,workers负责大部分的计算任务,servers负责保存和更新模型参数。servers可以定期将模型参数快照保存到一个缓存位置,一旦有节点失败,parameterserver会自动从最新的checkpoint中恢复模型参数。parameterserver框架只支持pserver和worker之间通信,而pserver和pserver、worker和worker之间无法进行点对点通信,并且由于细粒度的接口导致用户编程比较复杂,因此现有的parameterserver框架还存在几个问题:一是通信接口比较单一,没有MPI灵活;二是对于用户来说没有Spark易于编程使用。
正是由于上述框架的种种缺点,他们开发了一个产品级的分布式学习系统—KunPeng。KunPeng结合了parameterserver和MPI的优点,提供鲁棒的failover机制,高效的稀疏数据通信接口和与MPI类似的通用接口,并且提供一个C++和Python的SDK,该SDK提供了一个类似单机的开发环境。KunPeng也与阿里的Apsara平台深度对接,提供ML的全工具集,包括基于SQL和MapReduce的数据预处理、预测、评估等等。
Apsara是阿里开发的一个大规模分布式操作系统,目前已运行在跨数十个机房的十几万台服务器上。下图中天蓝色部分就是Apsara的模块,白色部分为运行在Apsara之上的各种云服务,KunPeng就属于图中白色部分,运行在Apsara上,由Apsara提供任务调度和监控、文件系统等服务。
KunPeng分为ML-Bridge和PS-Core两个子系统,ML-Bridge是KunPeng提供的高级编程模型,用户通过脚本编程的workflow可以方便地实现数据预处理、训练、预测和评估等算法,PS-Core是一个分布式键值对存储的paramterserver框架。
PS-Core不仅支持数据并行和模型并行,同时还支持模型同步更新(BSP)、ASP和SSP,稀疏表达和容错机制。PS-Core在传统的worker和server基础上,增加了一个用于迭代控制的coordinator。coordinator声明了数据计算和参数更新的操作,构建了整个MLworkerflows的作业图,并将这些作业调度到worker和server上运行,并参与servers和workers的failover过程。coordinator在迭代结束时会与Apsara的meta对迭代状态进行同步,并且由Fuxi监控管理,因此不存在SPOF(单点失败)的问题。
KunPeng也给出了servers和workers的容错解决方案。对于servers,它们会异步地将参数快照保存到分布式文件系统,并且它们会在内存中对参数进行两备份,支持hotfailover加速恢复过程。大多数情况下(比如接收到coordinator的恢复请求),servers可以立刻通过内存备份的参数中恢复。即使是servers或整个任务被中断或被kill,servers也可以通过最近一次保存的参数进行恢复训练。对于stateless的workers,failover非常简单,只需要从servers上pull对应的参数。对于stateful的workers,同样提供保存快照的接口,因此对于一些workers有本地状态的算法(比如LDA),faliover也非常简单。
总的来说,KunPeng的failover过程是当Fuxi检测到有节点失败时,重新调度新的节点,同时给coordinator发送异步节点失败的消息,coordinator接收消息后给servers和workers发送恢复请求,对于正常的servers接收请求后会直接从内存中恢复,而对于新调度的servers会从checkpoint中恢复,对于workers需要先从servers上pull对应的参数,stateful的workers还需要从保存的checkpoint中恢复状态。
这里的调度指的是coordinator对servers和workers的调度。由于coordinator节点会根据算法的workflow构建对应的作业DAG,并将DAG调度到servers和workers上进行执行。为了提高机器资源利用率和作业效率,DAG中相同深度的节点可以并行执行,比如下图中的Calculatefor Block 0节点和Load Data for Block1节点。通过DAG接口用户可以自定义IO操作、计算和通信过程,可以很方便地实现各种模型更新算法。
下图表示了PS-Core中bounded delayASGD算法的C++实现,用户可以重写下面的Iterate函数实现自定义的算法。图中的mServerParam和mServerGrad对应servers上的模型参数和梯度,mWorkerParam和mWorkerGrad对应workers本地的模型参数和梯度,mSubDatasetPtr对应当前worker的数据子集。nSync为最大延迟迭代次数,nPull和nPush分别为从servers获取最新参数和将梯度发送给servers的频率。通过设置nSync、nPull和nPush可以很方便地在BSP和SSP之间切换,而去除SyncBarrier就成了ASP算法的实现。
由于集群中机器的底层硬件和运行状态存在差异,因此一个任务的执行效率很大程度上取决于运行最慢的那个机器,针对这种情况可以有多种负载均衡的方法,比如可以对负载较高的机器分配更少的数据和计算量,PS-Core也为此设计了一个Backupinstance机制。当某个节点被确定为慢节点时,coordinator会把慢节点标记为"dead"节点,请求Fuxi重新调度一个新的节点作为该节点的备份节点,并将该节点的负载转移到备份节点上。这种机制通常可以带来10%-20%的效率提升。
KunPeng对不同稀疏度和不同数据类型的数据通信做了深度优化,并且提供workers之间点对点的通信接口,比如AllReduce,ReduceTo和Bcast,这些灵活的通信接口使得KunPeng可以拓展更多的功能,比如模型并行。
\[w_{t+1}=\begin{cases}0& if\{\vert}z_{i}{\vert}{\leq}\lambda_{1}\\-(\frac{\beta+\sqrt{n_{i}}}{\alpha}+\lambda_{2})^{-1}(z_{i}-sign(z_{i})\lambda_{1})&otherwise\end{cases}\] 下图表明了LRFTRL-Proximal算法单机更新过程。
这个算法在单机时很容易实现,但在分布式环境必须要考虑通信效率、servers的负载和算法收敛性问题。考虑到BSP的低效和ASP可能不收敛的问题,他们使用了boundeddelay的SSP更新方法,并且设置trustregion来调节参数范围,避免模型发散。整个算法具体过程如下:
workers向servers传递\(z\)和
MART(多增量回归树)又叫做GBDT,是一种应用比较广泛的机器学习算法。KunPeng实现了一个通用的MART算法,支持千亿级样本量和上千维的特征,并在MART的基础上实现了LambdaMART算法。
重复上述过程,可以得到整棵树。然后只要按照gradientboosting方法一棵一棵地建树,最终得到MART。随着特征维度和树深度的增加,查找分裂点过程中的计算和通信都可能成为性能瓶颈。为了解决这个问题,他们提到使用KunPeng的通信模式去减少合并局部直方图的开销,但并没有透露具体的方法。
下面的实验都是在一个拥有5000台服务器的正式集群上进行的,每台机器12个IntelXeon CPU E5-2430 (2.2 GHz) CPU和96GB内存。
不同平台的LR都采用L-BFGS算法更新,并且memory historyparameter都设置为10,并且使用同一个集群相同的CPU资源,在7个不同的数据集上KunPeng在效率和内存占用上都取得非常明显的优势。
在另外一个18 billion样本和 7billion特征的数据集上,他们统计了KunPeng在不同workers数量时的加速比。
KunPeng仅使用25个workers就可以训练这么大的数据,workers增加时依然能保持较高的加速比,并且内存占用随着workers增加而近乎直线降低。
下图分别为KunPeng-MAR和XGBoost在不同任务上的峰值内存占用和训练时间对比。
下面是在单机情况下的训练效果对比,并没有训练时间的对比数据和多机实验相关的数据。
1、Ad Click Prediction: a View from the Trenches.
]]>1 |
|
编译:
1 | g++ test.cpp -o test -lpython |
执行:./test
1 | hello c++! |
1 | # test_add.py |
1 |
|
编译:
1 | g++ test.cpp -o test -lpython |
执行:./test 3 4
1 | hello c++! |
1 | # tree.py |
1 |
|
编译:
1 | g++ test.cpp -o test -I../python2.7.12/include -L../python2.7.12/lib -lpython2.7 |
执行:./test
1 | env level: 2 |
Python/C API Reference Manual:https://docs.python.org/2/c-api/index.html
]]>网上找了一圈,似乎也就这个有些参考价值:http://stanford.edu/~imit/tuneyourmomentum/theory/
看来近期得做一些调momentum和学习率的实验了。。。
]]>前面我们讲到TD算法结合了动态规划和蒙特卡洛算法的优点,不依赖具体的环境模型,并且更新时采用滑动平均的方式,因此单步就能更新,而不需要生成整个episode,在非episode情况下仍然适用。TD算法又分为onpolicy的sarsa算法和off policy的Q learning算法,其中Qlearning算法直接使用下一状态的最大动作值函数进行更新,加快了算法收敛速度,因此Qlearning算法在实际应用中更加普遍。
我们用一个例子来说明Qlearning算法的过程。下图是一个二叉树表示的路径规划问题,每一个节点代表环境中的一个状态,叶子节点表示终止状态,每个非叶子节点都可以选择向上或向下的动作,然后转移到下一个节点,并获得相应的得分。

首先初始化所有状态动作对的动作值函数:
随机选择一个初始状态
根据
随机选择一个初始状态
根据
随机选择一个初始状态
根据
随机选择一个初始状态
根据
…
下面是该例子的python实现:
1 | """ |
最终收敛结果为:
1 | [[109.99999999999989, 139.99999999999977], |
上面的例子中非终止状态数只有3个,每个非终止状态对应的动作只有2个,因此状态动作对总共有6个,使用表格存储完全没有问题,但实际上我们需要解决的并不是一个如此简单的问题。比如在【PlayingAtari with Deep Reinforcement Learning】中DeepMind就使用Qlearning使得agent玩Atari 2600游戏的水平超越了人类水平。在Atari2600游戏中,每个游戏画面都是一个状态,如果每个画面都是像素为84*84的256灰度图像,那么将会产生

并且逼近函数的形式可以采用:
下面我们研究的DQN(Deep Q Network)就是采用Deep neuralnetwork进行动作值函数逼近的一种方法,结构如下。

为推导方便,假设中间的Network为一层的全连接,即
\[\begin{split}w^k&=w^{k-1}+\eta\Delta(w)\\&=w^{k-1}-\eta\frac{\partial{J(w)}}{\partial{w}}\\&=w^{k-1}-\eta\left(V(s,a)-\hat{V}(s,a;w^{k})\right)x(S)\end{split}\tag{1-2}\]
由于我们并没有动作值函数的真实值,因此与Q learning类似,
整个训练过程仍然与Q learning一样,采用
DQN最先出现于DeepMind发表的【Playing Atari with Deep ReinforcementLearning】论文中,由于需要直接输入图像画面,因此论文中使用CNN来表示Q函数,下面简单剖析一下该论文。
使用的是典型的CNN,其结构为:

与一般的CNN有所不同的是,没有pooling层,因为我们这里不是做图像分类,pooling层带来的旋转和数值不变性对分类是有作用的,但在这个任务中对物体的具体位置是非常敏感的,因此移除了pooling层。
Atari原始的游戏帧为210*160像素的RGB图像,由于该任务对画面色彩不敏感,为了减少计算开销,将游戏帧预处理成84*84的灰度图像。但为了获得动态特征,最终是将前3帧图像与当前帧stack到一起组成一个4*84*84的图像作为CNN的输入,输出为每个动作对应的Q值。
现在我们知道可以使用Qlearning去估计每个状态的未来回报的期望,并且可以使用CNN去逼近动作值函数,也就是可以使用DQN去解决一个复杂的MDP任务。但在实际应用时会出现更新波动较大,导致收敛非常慢的问题,DeepMind因此使用了一个经验回放(ExperienceReplay)机制,就是将每步的经验数据
经验回放机制相比标准的DQN有两个好处:首先每一步的经验数据会被保存起来,更新时可以多次使用到经验数据,使得数据利用更高效;此外直接从连续的样本中学习是低效的,因为一个episode内样本具有很强的相关性,随机挑选样本打破了这种相关性,因此减小了更新时的变化,使得更新更加稳定(注:因为同一次实验过程的样本相关性很强,不同实验之间的相关性就显得相对比较小,如果使用连续的样本进行训练,在切换到下一次实验的样本时会导致模型更新不稳定)。
由于内存大小限制,回放内存不可能将所有的经验数据都保存起来,因此只会保留最新的N组经验数据,比较久远的数据就会被遗忘。
DeepMind使用DQN对ATARI中七个游戏进行了实验,由于每个游戏的得分尺度不一致,因此他们将得分分为正回报、负回报和无回报,正回报得分为1,负回报得分为-1,无回报得分为0。
使用 RMSProp算法进行优化,batch size为32,采用
训练过程伪代码:

目前强化学习的研究主要由DeepMind和OpenAI两家在主导,去年底到今年初DeepMind和OpenAI相继开源了自家的3Dlearning environment平台DeepMind Lab和Universe。DeepMindLab目前给出的文档和例子都比较少,使用也稍显复杂,所以暂时可以不考虑使用。Universe包含了1000+的游戏环境,并且将程序打包在docker环境中运行,提供与Gym一致的接口。Universe的环境由一个client和一个remote组成,client是一个VNCenv,主要负责接收agent的动作,传递回报和管理本地episode的状态,remote是指在docker环境中运行的程序,remote可以运行在本地、远程服务器或在cloud上。client和remote通过VNC远程桌面系统进行交互,通过WebSocket传递回报、诊断和控制信息。
由于Universe环境提供Gym接口,而Gym是OpenAI去年4月份发布的一套开发和比较强化学习算法的toolkit。Gym本身是可以独立于Universe使用的,并且Universe和Gym中agent代码基本没有什么区别。我们下面就单独讲讲Gym接口和如何使用Gym训练自己的agent。
Gym目前提供python接口,并支持任何的计算框架,比如tensorflow、theano等。强化学习解决的是agent和环境交互的任务,agent根据当前环境状态做出某个动作,然后观察下一个状态和回报,环境根据agent的动作转移到下一个状态,并发送回报。Gym提供的实际上是环境这个角色,每个Gym环境都提供一致的接口。
创建一个环境时只需要指定环境id,比如agent需要玩AtariBreakout-v0这个游戏,可以如下创建一个Breakout-v0的环境。
1 | import gym |
输入agent的动作,返回4个值,分别为:
1 | next_state, reward, terminal, _ = env.step(action) |
在开始一个新的episode时,Gym环境都要reset,获得一个初始状态。
1 | init_state = env.reset() |
render是Gym用来渲染环境状态的函数,当调用该函数时会出现一个动图框。一般agent执行一个动作,环境都要渲染一次,这样就可以实时看到agent的执行情况了。
1 | env.render() |
Gym环境有两个space属性,一个是action_space,一个是observation_space,分别表示该Gym环境下合法的动作和状态。action_space是Gym中的一个Discrete对象,Discrete对象有一个成员n,表示合法的动作数,比如Discrete(2)表示有两个合法动作,编号从0开始,因此两个动作编号为0和1。observation_space是Gym中的一个Box对象,Box的shape表示observation的数据组织方式,比如Box(210,160,3)表示合法的observation是一个210*160*3的数组,而Box(4,)表示observation是一个大小为4的向量。
1 | observation_space = env.observation_space # observation_space: Discrete(6) |
采用了github上Flood Sung的DQN实现,感谢Flood Sung大神的无私贡献。
1 | # ----------------------------- |
下面是使用上面的DQN让agent玩Gym的Breakout-v0游戏。
1 | # ------------------------- |
1、Reinforcement Learning: An Introduction, Richard S. Sutton andAndrew G. Barto,2012
2、Playing Atari with Deep Reinforcement Learning,DeepMindTechnologies,Arxiv 2013.12
3、Human-level control through deep reinforcement learning,DeepMindTechnologies,Nature 2015.02
4、DeepMind官网https://deepmind.com/blog/deep-reinforcement-learning
5、https://www.nervanasys.com/demystifying-deep-reinforcement-learning
6、http://www.cnblogs.com/jinxulin/p/3511298.html
7、Introduction to Reinforcement Learning,David Silver
\[\begin{split}\upsilon_{\pi}(s)&={\bf{E_{\pi}}}\left[G_{t}\mid{S_{t}=s}\right] \\&={\bf{E_{\pi}}}\left({\bf{E_{\pi}}}\left[G_t\midS_t=s,A_t\right]\right) \\&={\bf{E_{\pi}}}\left[\sum_a\pi(a|s)G_t\mid S_t=s,A_t=a\right] \\&=\sum_a\pi(a|s){\bf{E_{\pi}}}\left[G_t\mid S_t=s,A_t=a\right] \\&=\sum_a\pi(a|s){\bf{E_{\pi}}}\left({\bf{E_{\pi}}}\left[G_t\midS_t=s,A_t=a,S_{t+1}\right]\right) \\&=\sum_a\pi(a|s){\bf{E_{\pi}}}\left[\sum_{s^{'}}p(s^{'}\mids,a)G_t\mid S_t=s,A_t=a,S_{t+1}=s^{'}\right] \\&=\sum_a\pi(a|s)\sum_{s^{'}}p(s^{'}\mids,a){\bf{E_{\pi}}}\left[G_t\mid S_t=s,A_t=a,S_{t+1}=s^{'}\right] \\&=\sum_{a}\pi(a\mid{s})\sum_{s^{'}}p(s^{'}\mid s,a){\bfE}_{\pi}\left[R_{t+1}+\gamma\sum_{k=0}^{\infty}\gamma^{k}R_{t+k+2}\mid{S_{t}=s,A_{t}=a,S_{t+1}=s^{'}}\right]\\&=\sum_{a}\pi(a\mid{s})\sum_{s^{'}}p(s^{'}\mid{s,a})\left[r(s,a,s^{'})+\gamma{\bfE}_{\pi}\left[\sum_{k=0}^{\infty}\gamma^{k}R_{t+k+2}\mid{S_{t+1}=s^{'}}\right]\right]\\&=\sum_{a}\pi(a\mid{s})\sum_{s^{'}}p(s^{'}\mid{s,a})\left[r(s,a,s^{'})+\gamma\upsilon_{\pi}(s^{'})\right]\end{split}\]
]]>近几年,由于DeepMind成功地将强化学习(reinforcementlearning)运用在AlphaGo上,机器首次在复杂任务上取得了超过人类的表现,使得强化学习成为目前机器学习研究的前沿方向之一。强化学习由来已久,Sutton等在1979年就已经开始研究强化学习,1998年出版了强化学习介绍一书,并于2012年发布第二版,本文前几部分内容主要参考该书。
强化学习最早主要用于智能控制领域,比如机器人控制、电梯调度、电信通讯等,如今已经在自动驾驶、NLP、内容推荐[4]和语音交互领域都有相关的应用。2013年底DeepMind发表文章PlayingAtari with Deep ReinforcementLearning,首次成功地将深度学习运用到强化学习任务上,通过无监督学习实现从纯图像输入来玩Atari2600游戏的效果。而后DeepMind逐渐改进算法,使得DQN在Atari几乎一半的游戏中超过人类水平,以至2016年AlphaGo和无人车的出现,人们惊奇地发现人工智能即将颠覆我们的生活,甚至有人评论说传统的深度学习已经可以很好地感知理解了,强化学习可以利用这些感知生成策略,因而可以创造更高的机器智能。
下面是DeepMind使用DQN让机器学习玩Atari 2600游戏的视频。
Reinforcement learning is learning what to do—how to map situationsto actions—so as to maximize a numerical rewardsignal[1].
强化学习研究的是智能体agent与环境之间交互的任务,也就是让agent像人类一样通过试错,不断地学习在不同的环境下做出最优的动作,而不是有监督地直接告诉agent在什么环境下应该做出什么动作。在这里我们需要引入回报(reward)这个概念,回报是执行一个动作或一系列动作后得到的奖励,比如在游戏超级玛丽中,向上跳可以获得一个金币,也就是回报值为1,而不跳时回报就是0。回报又分为立即回报和长期回报,立即回报指的是执行当前动作后能立刻获得的奖励,但很多时候我们执行一个动作后并不能立即得到回报,而是在游戏结束时才能返回一个回报值,这就是长期回报。强化学习唯一的准则就是学习通过一序列的最优动作,获得最大的长期回报。比较有挑战性的是,任一状态下做出的动作不仅影响当前状态的立即回报,而且也会影响到下一个状态,因此也就会影响整个执行过程的回报。
因此,强化学习和监督学习的区别主要有以下两点[6]:
前面已经提到强化学习是尝试并发现回报最大动作的过程,下面就具体来描述一下这个过程。首先考虑一个问题,一个之前完全没有接触过国际象棋的小白怎样和一个专业棋手对弈。刚开始小白对棋面并没有任何概念,只能随机下,但假设双方每一轮下完后都会得到立即回报,比如吃子回报为1,被吃回报为-1,其他回报为0。可以想象一开始小白会输得很惨,但如果小白很聪明,随着不断地尝试小白不仅理解了下棋的规则,并且知道在什么棋面下做出什么动作可以吃更多的棋子。在这里我们将小白作为我们的智能体agent,棋面就是状态,下棋就是agent根据当前状态做出的动作,每个动作执行完后都会引起状态改变,如果状态的改变只与前一个状态和当前的动作有关,而与之前的状态和动作无关(即满足马尔可夫性),那么整个过程可以用马尔可夫决策过程(MarkovDecisionProcesses)来描述,而Sutton在书中直接将满足马尔可夫性的强化学习任务定义为马尔可夫决策过程,并将状态和动作都是有限空间的MDP定义为有限马尔可夫决策过程(finiteMDP)。
下面引入一些定义[1]:马尔可夫决策过程是一个agent与环境交互的过程,因此有一个离散的时间序列,

在任意时刻和状态下,agent都可以选择一个动作,选择的依据就是我们说的策略—即状态到动作的映射
但实际上我们一般会用下面更通用的公式来代替:
其中
一个有限马尔可夫决策过程由一个四元组构成
在MDP中给定任何一个状态
增强学习的最终结果是找到一个环境到动作的映射—即策略
值函数有多种定义,目前常见的是将值函数直接定义为未来回报的期望:
\[\upsilon_{\pi}(s)={\bf{E_{\pi}}}\left[G_{t}\mid{S_{t}=s}\right]={\bf{E_{\pi}}}\left[\sum_{k=0}^{\infty}\gamma^{k}R_{t+k+1}\mid{S_{t}=s}\right]\tag{2.1}\]
上面表示的是在某个策略
\[\begin{split}q_{\pi}(s,a)&={\bf{E_{\pi}}}\left[G_{t}\mid{S_{t}=s,A_{t}=a}\right]\\&={\bf{E_{\pi}}}\left[\sum_{k=0}^{\infty}\gamma^{k}R_{t+k+1}\mid{S_{t}=s,A_{t}=a}\right]\end{split}\tag{2.2}\]
动作值函数表示在某个策略
\[\begin{split}\upsilon_{\pi}(s)&={\bf{E_{\pi}}}\left[G_{t}\mid{S_{t}=s}\right]\\&={\bf{E_{\pi}}}\left[\sum_{k=0}^{\infty}\gamma^{k}R_{t+k+1}\mid{S_{t}=s}\right]\\&={\bf{E_{\pi}}}\left[R_{t+1}+\gamma\sum_{k=0}^{\infty}\gamma^{k}R_{t+k+2}\mid{S_{t}=s}\right]\\&=\sum_{a}\pi(a\mid{s})\cdot{\bfE}_{\pi}\left[R_{t+1}+\gamma\sum_{k=0}^{\infty}\gamma^{k}R_{t+k+2}\mid{S_{t}=s,A_{t}}\right]\\&=\sum_{a}\pi(a\mid{s})\sum_{s^{'}}p(s^{'}\mid{s,a})\left[r(s,a,s^{'})+\gamma{\bfE}_{\pi}\left[\sum_{k=0}^{\infty}\gamma^{k}R_{t+k+2}\mid{S_{t+1}=s^{'}}\right]\right]\\&=\sum_{a}\pi(a\mid{s})\sum_{s^{'}}p(s^{'}\mid{s,a})\left[r(s,a,s^{'})+\gamma\upsilon_{\pi}(s^{'})\right]\end{split}\tag{2.3}\]
也就是说在策略
同样,动作值函数也可以写成相似的形式:
\[\begin{split}q_{\pi}(s,a)&={\bf{E_{\pi}}}\left[G_{t}\mid{S_{t}=s,A_{t}=a}\right]\\&={\bf{E_{\pi}}}\left[R_{t+1}+\gamma\sum_{k=0}^{\infty}\gamma^{k}R_{t+k+2}\mid{S_{t}=s,A_{t}=a}\right]\\&=\sum_{s^{'}}p(s^{'}\mid{s,a})\left[r(s,a,s^{'})+\gamma\upsilon_{\pi}(s^{'})\right]\end{split}\tag{2.4}\]
\(\upsilon_{\pi}(s)\)也可以用

上面所说的值函数都是未来回报的期望值,而我们需要得到的最优策略必然是使得任意时刻未来回报的期望值都是最大的,也就是说我们的优化目标可以表示为:
当然最优策略可能不止一个,但这些最优策略都有一个共同的特点,就是它们共享同样的状态值函数,这个状态值函数叫做最优状态值函数(optimalstate-value function),用
最优策略同样也共享相同的动作值函数(optimal action-valuefunction),用
回顾一下上面动作值函数的改写公式(2.4),
至此,最优值函数的形式已经给出了,现在我们继续回顾一下公式(2.5)的意义,
\[\begin{split}\upsilon_{*}(s)&=\max_{\mathbf{a}} q_{*}(s,a) \\&=\max_{\mathbf{a}}\sum_{s^{'}}p(s^{'}\mid{s,a})\left[r(s,a,s^{'})+\gamma\upsilon_{*}(s^{'})\right]\end{split}\tag{2.10}\]
\[q_{*}(s,a)=\sum_{s^{'}}p(s^{'}\mid{s,a})\left[r(s,a,s^{'})+\gamma\max_{\mathbf{a^{'}}}q_{*}(s^{'},a^{'})\right]\tag{2.11}\]
与状态值函数的贝尔曼公式一样,最优状态值函数和最优动作值函数也可以表示成递归的形式,因此公式(2.10)和公式(2.11)又分别叫做状态值函数和动作值函数的贝尔曼最优公式(Bellmanoptimality equation)。因为没有
如果我们已知
贝尔曼公式与贝尔曼最优公式是MDP求解的基础,下面主要介绍几种MDP求解的方法。
动态规划(dynamicprogramming)指的是能够用来解决给定环境模型,计算最优策略的算法总称。典型的动态规划算法存在两个问题,一是需要依赖一个非常好的环境状态转移模型,二是计算的开销非常大,因此在增强学习中几乎不会直接用动态规划求解MDP,但动态规划理论还是非常重要的,因为后面的一些算法都是在动态规划的基础上,摆脱模型依赖并尽可能地减少计算量。
首先,我们考虑一下如果已知策略
先举一个例子,一个岔路口有向左和向右两个方向,向左回报为10,向右回报为100,我们没有任何先验知识,但我们需要估计站在路口的值函数,也就是估计当前状态的值函数,该如何来估计呢?首先我们将值函数初始化为0,然后进行大量的尝试,每次都以0.5的概率选择方向左,并获得回报10,以0.5的概率选择方向右,获得回报100。那么只要能将这两个方向都至少遍历一遍,就可以得到该状态的值函数
同样,我们也是采用相似的方法迭代来进行策略估计的。首先将所有的
\[\begin{split}\upsilon_{k+1}(s) &={\bf{E}}_{\pi}\left[R_{t+1}+\gamma\upsilon_{k}(S_{t+1})\mid S_{t}=s \right] \\&=\sum_{a}\pi(a\mid{s})\sum_{s^{'}}p(s^{'}\mid{s,a})\left[r(s,a,s^{'})+\gamma\upsilon_{k}(s^{'})\right]\end{split}\tag{3.1}\]
其中

策略估计是为了计算当前策略下各状态的值函数,那得到值函数又有什么用呢?首先我们可以用来比较两个策略的好坏,如果状态值函数是已知的,那么就可以根据公式(2.4)计算动作值函数,如果一个策略
仍然是上面岔路口的例子,但是假设无论向左还是向右,下一个路口都是唯一且相同的。起初由于没有任何先验知识,因此采用了一个随机策略,然后我们可以计算得到随机策略下的状态值函数,那么我们就可以进行策略改进了。具体的做法就是前面提到的单步搜索,向左时当前动作的回报为10,因此单步搜索的结果为10+
根据上面的例子,我们可以总结一下策略改进的方法:遍历所有的状态和所有可能的动作,采用贪婪算法进行策略的更新,即对所有
\[\begin{split}\pi^{'}(s)&=\arg\max_{\mathbf{a}}q_{\pi}(s,a)\\&=\arg\max_{\mathbf{a}}\sum_{s^{'}}p(s^{'}\mids,a)\left[r(s,a,s^{'})+\gamma\upsilon_{\pi}(s^{'})\right]\end{split}\tag{3.2}\]
现在我们已经知道如何计算当前策略的状态值函数,也知道可以根据动作值函数来更新策略,那下面就来讲讲如何从零开始求解最优策略。


策略迭代算法需要不断地进行策略估计和策略改进,每次策略估计和改进都需要遍历一次所有的状态和动作,因此算法的计算量非常大,效率非常低。同时可以看到策略迭代的依据是贝尔曼公式,而如果直接利用贝尔曼最优公式会不会加速求解过程呢?事实上是可以的,下面的值迭代(valueiteration)算法就是利用贝尔曼最优公式来提高求解效率的一种算法。
我们还是需要先迭代估计状态值函数,但不必每次迭代都进行策略改进。根据贝尔曼最优公式,可以直接用上一次迭代的最大动作值函数对当前迭代的状态值函数进行更新,如下所示:
\[\begin{split}\upsilon_{k+1}(s)&=\max_{\mathbf{a}} q_{k}(s,a) \\&=\max_{\mathbf{a}}\sum_{s^{'}}p(s^{'}\mid{s,a})\left[r(s,a,s^{'})+\gamma\upsilon_{k}(s^{'})\right]\end{split}\tag{3.3}\]
值迭代算法的好处就是省去了每次迭代时的策略改进过程,并且由于每次迭代得到的

一般来说值迭代和策略迭代都需要经过无数次迭代才能精确收敛到最优策略,而实践中我们往往会设定一个阈值
下面我们要讲的是蒙特卡罗方法(Monte CarloMethods)。与动态规划不同,蒙特卡罗方法不需要知道环境的完整模型,仅仅需要经验就可以获得最优策略,这些经验可以通过与环境在线或模拟交互的方式获得。在线交互显然是不需要任何环境的先验知识,模拟交互虽然需要知道环境状态的转移,但与动态规划不同的是这里不需要知道具体的转移概率。
蒙特卡罗方法也称统计模拟方法,基本思想是通过对大量的重复随机事件进行统计,估计随机事件的概率分布或期望。一个典型的例子是利用蒙特卡罗方法计算圆周率。假设我们知道圆的面积公式为
我们现在来考虑一下如何利用蒙特卡罗方法估计给定策略下的状态值函数。与上面计算圆周率的例子稍有不同的是,现在我们估计的是未来回报的期望,而不是概率,但基本思想是一样的。很显然,如果要估计
蒙特卡罗策略估计在具体实现时又分为first-visit MC methods和every-visitMC methods。由于在一个episode中,状态
现在我们假设有如下一些样本(下图每一行都是在当前策略下的一个独立的episode),紫色实心点为状态

第一个episode中到达过两次状态
\[\upsilon_{\pi}(s)={\bfE}\left[R(s)\right]=\frac{1}{N}\sum_{i=1}^{N}\left[R_{i}(s)\right]=\frac{1}{4}\left(2+1-5+4\right)=0.5\]
同样,如果生成的episode数量越多,

注意这里使用大写的
由于我们没有完整的环境状态转移模型,因此即使我们得到当前策略的值函数,根据公式(3.2)也无法进行策略改进。既然我们可以估计得到状态值函数,那么肯定也可以用相同的方法直接估计动作值函数,在这里叫做动作值函数的蒙特卡罗估计(MonteCarlo Estimation of Action Values)。
估计方法跟蒙特卡罗策略估计差不多,只不过我们需要找到所有的状态动作对(pairof state \(s\) and action
蒙特卡罗控制(Monte CarloControl)首要的问题就是如何估计最优策略。跟之前动态规划一样,这里也可以采用策略迭代和策略改进交替进行的方式,经过大量的迭代后收敛到最优策略。但蒙特卡罗方法有一个最大的问题,即我们需要产生无数的episode才能保证收敛到最优结果。无数的episode和大量的迭代导致计算量巨大,效率非常低。Sutton在书[1]中提到两种解决方法,其中一种方法是采用episode-by-episode的方式进行优化。
episode-by-episode的思想与动态规划中值迭代的in-place版本非常相似。在动态规划的值迭代中,我们每次迭代都直接覆盖更新值函数,因此能及时地利用到更新后的值函数,从而能加快收敛。episode-by-episode则是先用当前策略生成一个episode,然后根据这个episode进行动作值函数的更新,同时更新策略,并利用更新后的策略继续生成后续的episode。
下面是exploring starts的蒙特卡罗控制(Monte Carlo ES,exploringstarts指的是从一个随机的开始状态和动作生成一个episode)算法的完整过程:

至于为何要使用exploringstarts,这与episode-by-episode在线生成episode的更新策略有关。还是上面的岔路口的例子,我们先随机指定一个策略,比如指定向左,那么使用该策略生成一个episode时必然也是向左,那么也就只能更新向左的动作值函数了,而无法更新向右的动作值函数。由于动作值函数是随机初始化的,如果向右的动作值函数初始值小于更新后的向左的动作值函数,那么下一次生成episode时仍然是向左,并且可以想象可能永远不会选择向右。但其实向右才是最优动作,因此上述更新的策略永远不可能是最优策略。但随机选择开始状态和动作,可以避免某些动作的值函数不会更新的问题,因此可以保证能获得最优策略。
当然也可以采用其他方法避免使用exploringstarts,下面要介绍的on-policy方法和off-policy方法就是其中的两种方法。
前面的Monte Carlo ES算法使用exploringstarts是为了保证所有可能的动作值函数都能得到更新,从而保证能获得最优策略。如果策略本身就可以在任何状态下都采取所有可能的动作,而不是贪婪地只选择动作值函数最大的那个,那问题不就迎刃而解了吗。下面要讨论策略是非确定性的,也就是对于所有的状态
在

在off-policy方法中,生成episode的策略与评估和改进的策略并非同一个策略。其中生成episode的策略我们叫行为策略(behaviorpolicy),而评估和改进的策略叫估计策略(estimationpolicy)。这种方法的好处是可以使行为策略是

时间差分学习(temporal-dierence (TD)learning)结合了动态规划和蒙特卡罗方法的优点,与蒙特卡罗方法一样不需要环境模型,与动态规划一样更新估计值时只依赖于下一个状态可用的估计值,而不需要等到策略自举出完整的episode。
TD预测(TDprediction)又叫TD策略估计,就是从给定的一系列经验数据中估计出当前策略的状态值函数
\[V(S_{t})=(1-\alpha)V(S_{t})+\alphaG_{t}=V(S_{t})+\alpha\left[G_{t}-V(S_{t}) \right]\tag{4-1}\]
\(V(S_{t})\)表示第
我们这里只是用\(R_{t+1}+\gammaV(S_{t+1})\)来估计
显然,TD learning相比MC有以下优点[7]:
但也存在一些缺点,比如TDlearning对初始值比较敏感,以及收敛结果是有偏的。
在介绍TD(λ)之前,我们先介绍一下n-StepTD预测。前面介绍的TD(0)算法在当前状态的基础上往后执行一步就可以进行更新,并且在更新时使用了贝尔曼公式对当前状态的未来回报进行估计,那我们是不是也可以往后执行n步之后再更新,这样用贝尔曼公式估计的未来回报是不是会更加精确呢?实际上,当n等于整个episode的总步数时,n-StepTD预测就完全成了MC估计了。

对于1-step来说,未来回报的值等于第一个回报值加上下一个状态值函数折扣后的值,用公式表示:
\[G_{t}^{(1)}=R_{t+1}+\gammaV(S_{t+1})\]
2-step比1-step多执行一步,其未来回报值为:
\[G_{t}^{(2)}=R_{t+1}+\gammaR_{t+2}+\gamma^{2} V(S_{t+2})\]
那么n-step的未来回报值为:
\[G_{t}^{(n)}=R_{t+1}+\gammaR_{t+2}+\gamma^{2} V(S_{t+2})+...+\gamma^{n}V(S_{t+n})\]
在公式(4-1)中我们用

n-Step TD只使用了从当前状态开始执行n步未来回报的估计值
TD(λ)也可以理解为一种特殊的n-step平均算法,每个n-step的权重为
公式(4-4)表示的是没有终止状态的情况,对于最终存在终止状态的episode任务或截断任务[注1]来讲,为了保证所有权重的和为1,最后一个n-step的权重被设置为
当

接下来我们考虑一下如何使用TD预测进行策略改进。首先我们知道可以使用TD预测来估计状态值函数,并且可以使用公式(3-2)进行策略改进。但问题来了,公式(3-2)中的

Sarsa的\(Q\)值更新公式与
下面介绍的Q学习是一种off-policy方法,并被认为是强化学习算法最重要的突破之一。在Q-learning中,动作值函数的更新完全独立于生成episode的策略,使得学习到的
公式(4-4)为Q-learning的单步更新公式,与Sarsa唯一的不同是:类似于动态规划中的值迭代算法,Q学习也是直接使用最优的

1、Reinforcement Learning: An Introduction, Richard S. Sutton andAndrew G. Barto,2012
2、Playing Atari with Deep Reinforcement Learning,DeepMindTechnologies,Arxiv 2013.12
3、Human-level control through deep reinforcement learning,DeepMindTechnologies,Nature 2015.02
4、DeepMind官网https://deepmind.com/blog/deep-reinforcement-learning
5、https://www.nervanasys.com/demystifying-deep-reinforcement-learning
6、http://www.cnblogs.com/jinxulin/p/3511298.html
7、Introduction to Reinforcement Learning,David Silver
1、截断任务:在强化学习中,非episode任务由于不存在终止状态,为了便于训练可以将非episode任务截断成episode。
]]>