尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

TensorFlow 2.0自定义操作实战:从Lambda层到C++内核的三种实现路径

TensorFlow 2.0自定义操作实战:从Lambda层到C++内核的三种实现路径 1. 从“搭积木”到“造积木”为什么我们需要自定义操作如果你用过TensorFlow 2.0大概率已经习惯了tf.keras.layers里那些现成的层比如Conv2D、Dense、LSTM。这就像玩乐高官方提供了各种标准形状的积木块我们按照图纸模型架构把它们拼起来就能得到一个能跑的模型。这种方式快捷、稳定是入门和快速原型的不二之选。但当你开始做一些前沿研究或者解决一些特定领域的复杂问题时可能会发现手头的“标准积木”不够用了。比如你想实现一个论文里提出的新型注意力机制它的计算逻辑在现有层里找不到或者你需要一个对输入数据执行特定领域变换的操作比如在图像处理中加入一个自定义的频域滤波器又或者你发现某个计算瓶颈可以用一个更高效的、融合了多个步骤的底层操作来替代。这时候你就需要自己“造积木”——这就是自定义操作Custom Operations的核心价值。TensorFlow 2.0 大力推行Eager Execution动态图和tf.function让模型构建和调试变得像写普通Python代码一样直观。然而这种便利性在底层并未改变TensorFlow基于计算图执行的本质。一个自定义操作本质上就是你在向TensorFlow的计算图定义中注入一段由你自己编写的、在底层通常是C高效执行的逻辑。它允许你将复杂的、循环的、或者对性能有极致要求的计算单元封装成一个原子操作从而在保持高层API简洁性的同时获得接近原生操作的速度和灵活性。所以学习自定义操作不是为了炫技而是为了突破框架的边界让你设计的模型能够真正实现你脑海中的算法蓝图尤其是在性能敏感或算法新颖的场景下。它让你从一个框架的使用者转变为框架能力的扩展者。2. TensorFlow 2.0 自定义操作的三种实现路径与选型在TensorFlow 2.0中实现自定义操作并非只有一条路。根据你的需求复杂度、性能要求和开发成本大致有三条路径我把它们比喻成改装汽车的三个级别内饰改装Python层封装、动力单元改装tf.py_function/tf.numpy_function、以及从零打造发动机C内核注册。2.1 路径一使用标准层组合与Lambda层——快速“内饰改装”这是最简单、最快捷的方式适用于那些可以用现有TensorFlow操作tf.*下的函数组合出来的新功能。核心方法使用tf.keras.layers.Lambda层。import tensorflow as tf # 示例实现一个简单的Swish激活函数 (x * sigmoid(x)) def swish_activation(x): return x * tf.sigmoid(x) # 在模型中使用 model tf.keras.Sequential([ tf.keras.layers.Dense(128), tf.keras.layers.Lambda(swish_activation), # 自定义激活层 tf.keras.layers.Dense(10) ])优点零学习成本你只需要会写TensorFlow的Python操作即可。完全可微自动微分Autograd系统能正常处理这些组合操作。易于调试由于在Python层面可以直接打印、断点调试。缺点与坑点性能一般每个操作都是计算图中的一个独立节点可能会产生微小的开销。对于极其简单的组合可能不如手写一个融合内核。序列化问题Lambda层保存模型model.save()时其内部的函数是通过Python的pickle模块序列化的。如果你的函数引用了外部自定义对象或复杂的闭包在加载模型尤其是在不同环境时可能会失败。一个最佳实践是将函数定义在模块顶层避免使用lambda表达式匿名函数。无法用于TFLite/TensorRT等转换一些需要导出到移动端或推理框架的场景过于复杂的Lambda层可能不被支持。实操心得对于95%不涉及底层性能瓶颈的自定义逻辑Lambda层是你的首选。务必把函数定义成模块级的普通函数而不是嵌套在其他函数里的闭包这能极大提升模型序列化的鲁棒性。2.2 路径二使用tf.py_function或tf.numpy_function——兼容旧“动力单元”当你的自定义逻辑必须依赖一些无法用TensorFlow原生操作实现的Python库比如某些复杂的科学计算库scipy或者需要复杂控制流、IO的操作时可以使用这两个包装器。核心方法它们允许你将一个Python函数包装成一个TensorFlow操作。import tensorflow as tf import numpy as np def my_numpy_operation(input_np): # 这是一个用NumPy实现的复杂操作 # 注意输入现在是NumPy数组 result np.fft.fft2(input_np) # 例如进行二维FFT return result.astype(np.complex64) # 包装成TensorFlow操作 def tf_custom_fft(x): # 输出的形状和类型需要明确指定 return tf.numpy_function(funcmy_numpy_operation, inp[x], Touttf.complex64) # 在模型中使用 input_layer tf.keras.Input(shape(256, 256, 1)) x tf_custom_fft(input_layer) model tf.keras.Model(inputsinput_layer, outputsx)tf.py_functionvstf.numpy_functiontf.py_function更通用函数接收和返回的都是tf.Tensor对象在函数内部会被自动转换为NumPy数组。支持任意Python代码。tf.numpy_function是tf.py_function的一个特例它明确要求函数直接接收和返回NumPy数组接口更清晰但限制稍多。优点灵活性无敌可以在计算图中运行任何Python代码。快速原型验证想法时非常方便。致命缺点性能极差每次执行都需要在TensorFlow的C运行时和Python解释器之间切换GIL问题并可能涉及数据拷贝。这会导致性能比原生操作慢数十甚至上百倍。无法获得计算图优化TensorFlow的图优化器如常量折叠、操作融合无法作用于这些“黑盒”操作。部署困难几乎不可能导出到TFLite、TensorRT或TensorFlow Serving等生产环境因为它们依赖Python解释器。注意事项仅将此方法用于数据预处理、调试或绝对无法替代的研究阶段代码。切勿将其用于模型前向传播或训练循环中的核心计算部分。我曾在一个数据加载管道中误用导致训练速度慢了5倍排查了很久才发现是这个“元凶”。2.3 路径三编写C内核并注册——打造专属“发动机”这是最彻底、性能最优、也是难度最高的方式。你实际上是在为TensorFlow添加一个原生操作。核心流程用C编写操作内核在*.cc文件中实现一个继承自OpKernel的类重写Compute方法。这里面的代码会在TensorFlow的运行时直接执行没有Python开销。定义操作接口在另一个*.cc文件中使用REGISTER_OP宏定义操作的名称、输入输出、属性Attr和形状推断函数。编译成动态库使用BazelTensorFlow官方构建工具或手动编译如g将C代码编译为.soLinux或.dllWindows文件。在Python中加载使用使用tf.load_op_library加载编译好的库然后像使用tf.add一样使用你的自定义操作。优点性能极致与TensorFlow内置操作性能无异。完全可集成支持自动微分、图优化、以及导出到任何部署平台。功能强大可以访问所有底层硬件加速GPU、TPU接口。缺点开发门槛高需要熟练的C编程能力并了解TensorFlow内核开发的基本约定。构建环境复杂需要配置TensorFlow的源码编译环境对新手极不友好。调试困难C层的错误往往比较晦涩调试周期长。选型决策指南需求场景推荐路径理由快速验证想法逻辑可用现有操作组合路径一Lambda层开发快无性能损失兼容性好逻辑复杂但无需部署或必须调用Python库路径二py_function(慎用)唯一选择但仅限原型生产环境核心计算性能瓶颈需部署到移动端/服务器路径三C内核唯一能保证生产级性能和兼容性的方案已有高性能C/C代码想集成进TF路径三C内核通过封装现有代码实现无缝集成对于大多数研究者和工程师我建议的路径是先用路径一Lambda实现功能进行算法验证如果遇到性能瓶颈且该操作是核心且通用的再考虑投入资源走路径三C。路径二应视为临时桥梁而非永久方案。3. 实战从零构建一个自定义双线性插值层C内核篇为了让大家对最硬核的路径三有直观感受我们以一个相对简单的例子——实现一个支持批量处理的双线性插值Bilinear Interpolation层为例看看如何将其变为TensorFlow的一个原生操作。为什么选这个例子因为tf.image.resize虽然存在但有时我们希望在自定义层内部集成更特定的插值逻辑或者需要对其梯度进行定制。3.1 第一步定义操作接口my_bilinear_op.cc首先我们需要定义这个操作对外看起来是什么样子它叫什么名字输入什么输出什么有什么属性。// my_bilinear_op.cc #include tensorflow/core/framework/op.h #include tensorflow/core/framework/shape_inference.h using namespace tensorflow; // 1. 注册操作OP REGISTER_OP(MyBilinear) .Input(images: float) // 输入图像张量形状 [batch, height, width, channels] .Input(size: int32) // 输入目标大小 [new_height, new_width] .Output(resized_images: float) // 输出插值后的图像 .SetShapeFn([](::tensorflow::shape_inference::InferenceContext* c) { // 形状推断函数根据输入推断输出形状 shape_inference::ShapeHandle images_shape; TF_RETURN_IF_ERROR(c-WithRank(c-input(0), 4, images_shape)); // 确保输入是4D shape_inference::ShapeHandle size_shape; TF_RETURN_IF_ERROR(c-WithRank(c-input(1), 1, size_shape)); // 确保size是1D TF_RETURN_IF_ERROR(c-WithValue(c-Dim(size_shape, 0), 2, size_shape)); // 且长度为2 // 获取目标高度和宽度 const Tensor* size_tensor c-input_tensor(1); if (size_tensor ! nullptr) { // 如果size是常量在图形构建时已知 auto size_vec size_tensor-vecint32(); int64 new_height size_vec(0); int64 new_width size_vec(1); // 构建输出形状: [batch, new_height, new_width, channels] shape_inference::DimensionHandle batch_dim c-Dim(images_shape, 0); shape_inference::DimensionHandle channel_dim c-Dim(images_shape, 3); c-set_output(0, c-MakeShape({batch_dim, new_height, new_width, channel_dim})); } else { // 如果size是动态的则输出高度和宽度维度是未知的 shape_inference::DimensionHandle batch_dim c-Dim(images_shape, 0); shape_inference::DimensionHandle channel_dim c-Dim(images_shape, 3); shape_inference::DimensionHandle unknown_dim c-UnknownDim(); c-set_output(0, c-MakeShape({batch_dim, unknown_dim, unknown_dim, channel_dim})); } return Status::OK(); }) .Doc(Rdoc( 对一批图像进行双线性插值缩放。 images: 4-D浮点张量形状为[batch, in_height, in_width, channels]。 size: 1-D int32张量包含两个元素[new_height, new_width]。 resized_images: 4-D浮点张量形状为[batch, new_height, new_width, channels]。 )doc);这段代码使用REGISTER_OP宏定义了操作名MyBilinear指定了输入输出的类型和数量并通过SetShapeFn定义了形状推断逻辑。这是告诉TensorFlow计算图系统关于这个操作元信息的关键一步。3.2 第二步实现操作内核my_bilinear_kernel.cc接下来是核心实现计算逻辑。这里我们实现一个简单的CPU版本。// my_bilinear_kernel.cc #include tensorflow/core/framework/op_kernel.h #include tensorflow/core/framework/tensor.h #include tensorflow/core/framework/types.h #include tensorflow/core/lib/core/errors.h #include cmath using namespace tensorflow; class MyBilinearOp : public OpKernel { public: explicit MyBilinearOp(OpKernelConstruction* context) : OpKernel(context) {} void Compute(OpKernelContext* context) override { // 1. 获取输入张量 const Tensor images_tensor context-input(0); const Tensor size_tensor context-input(1); // 2. 验证输入 OP_REQUIRES(context, images_tensor.dims() 4, errors::InvalidArgument(images must be 4-dimensional)); OP_REQUIRES(context, size_tensor.dims() 1 size_tensor.NumElements() 2, errors::InvalidArgument(size must be a 1-D tensor with 2 elements)); // 3. 解析输入数据 auto images images_tensor.tensorfloat, 4(); // 视为4D数组 auto size_vec size_tensor.vecint32(); const int batch_size images_tensor.dim_size(0); const int in_height images_tensor.dim_size(1); const int in_width images_tensor.dim_size(2); const int channels images_tensor.dim_size(3); const int new_height size_vec(0); const int new_width size_vec(1); // 4. 创建输出张量 Tensor* output_tensor nullptr; OP_REQUIRES_OK(context, context-allocate_output(0, TensorShape({batch_size, new_height, new_width, channels}), output_tensor)); auto output output_tensor-tensorfloat, 4(); // 5. 双线性插值核心算法 const float height_scale static_castfloat(in_height - 1) / (new_height - 1); const float width_scale static_castfloat(in_width - 1) / (new_width - 1); for (int b 0; b batch_size; b) { for (int y 0; y new_height; y) { const float in_y y * height_scale; const int top_y std::floor(in_y); const int bottom_y std::min(top_y 1, in_height - 1); const float y_lerp in_y - top_y; for (int x 0; x new_width; x) { const float in_x x * width_scale; const int left_x std::floor(in_x); const int right_x std::min(left_x 1, in_width - 1); const float x_lerp in_x - left_x; for (int c 0; c channels; c) { // 获取四个邻近点的值 const float top_left images(b, top_y, left_x, c); const float top_right images(b, top_y, right_x, c); const float bottom_left images(b, bottom_y, left_x, c); const float bottom_right images(b, bottom_y, right_x, c); // 双线性插值计算 const float top top_left (top_right - top_left) * x_lerp; const float bottom bottom_left (bottom_right - bottom_left) * x_lerp; output(b, y, x, c) top (bottom - top) * y_lerp; } } } } } }; // 6. 注册内核。这里我们注册一个CPU版本。 REGISTER_KERNEL_BUILDER(Name(MyBilinear).Device(DEVICE_CPU), MyBilinearOp);这个内核类MyBilinearOp继承了OpKernel并在Compute方法中实现了具体的插值算法。注意这是一个简单的、未优化的CPU实现仅用于演示。生产级实现会考虑并行化使用多线程、处理边界条件如new_height为1的情况、并可能提供GPU内核版本。3.3 第三步编译与Python调用编译是最大的挑战。你需要一个与你的TensorFlow Python包版本完全一致的TensorFlow源码环境并使用Bazel进行编译。假设你的环境已配置好编译命令大致如下# 在TensorFlow源码目录下 bazel build --configopt //path/to/your:my_bilinear_op.so这会生成一个动态链接库文件my_bilinear_op.so。Python调用则非常简单import tensorflow as tf # 加载自定义操作库 my_bilinear_module tf.load_op_library(./my_bilinear_op.so) my_bilinear my_bilinear_module.my_bilinear # 像普通TensorFlow函数一样使用 images tf.random.normal([4, 128, 128, 3]) # 4张128x128的RGB图 new_size tf.constant([256, 256], dtypetf.int32) resized my_bilinear(images, new_size) print(resized.shape) # 输出: (4, 256, 256, 3) # 它可以无缝融入Keras模型或GradientTape中支持自动微分 with tf.GradientTape() as tape: tape.watch(images) output my_bilinear(images, new_size) grads tape.gradient(output, images) # 可以计算梯度踩坑实录编译环境是最大的拦路虎。最常见的问题是TensorFlow头文件版本与已安装的pip包版本不匹配。一个实用的技巧是使用tf.sysconfig.get_include()和tf.sysconfig.get_lib()来获取当前Python环境中TensorFlow的头文件和库路径然后手动用g编译这比配置完整的Bazel环境有时更简单。此外确保你的C代码遵循C11或更高标准并且没有内存错误。4. 将自定义操作封装为Keras层实现真正的“即插即用”直接使用tf.load_op_library加载的操作虽然功能强大但在Keras的优雅世界里显得格格不入。为了能像使用Conv2D一样在Sequential或函数式API中轻松使用我们的MyBilinear我们需要将其封装成一个标准的Keras层。4.1 创建自定义Keras层类一个好的自定义Keras层不仅要封装前向传播还要正确处理输入输出形状、序列化配置以及可能的状态如权重。import tensorflow as tf class MyBilinearLayer(tf.keras.layers.Layer): 将MyBilinear自定义操作封装成一个Keras层。 def __init__(self, target_size, **kwargs): 参数 target_size: 一个包含两个整数的元组或列表 (target_height, target_width)。 **kwargs: 其他传递给父类Layer的参数如name。 super(MyBilinearLayer, self).__init__(**kwargs) self.target_size tuple(target_size) # 保存为目标大小 # 延迟加载操作避免在__init__中导入 self._my_bilinear_op None def build(self, input_shape): # 这个方法在层第一次被调用时执行用于创建层的权重。 # 我们的插值层没有可训练权重所以这里可以pass或进行一些初始化检查。 # 但这是一个设置输入形状相关逻辑的好地方。 if len(input_shape) ! 4: raise ValueError(fInput shape must be 4D (batch, h, w, c). Got: {input_shape}) super(MyBilinearLayer, self).build(input_shape) # 标记层为已构建 def call(self, inputs): 前向传播逻辑 # 延迟加载自定义操作库 if self._my_bilinear_op is None: # 假设编译好的库文件在当前目录或指定路径 custom_op_module tf.load_op_library(./my_bilinear_op.so) self._my_bilinear_op custom_op_module.my_bilinear # 将目标大小转换为常量张量 size_tensor tf.constant(self.target_size, dtypetf.int32) # 调用自定义操作 return self._my_bilinear_op(inputs, size_tensor) def compute_output_shape(self, input_shape): 计算层的输出形状用于模型构建时的形状推断 # input_shape: (batch, height, width, channels) return (input_shape[0], self.target_size[0], self.target_size[1], input_shape[3]) def get_config(self): 获取层的配置用于序列化模型保存 config super(MyBilinearLayer, self).get_config() config.update({ target_size: self.target_size, }) return config classmethod def from_config(cls, config): 从配置中重建层实例用于反序列化模型加载 return cls(**config)4.2 在Keras模型中使用现在这个层可以像任何内置层一样使用了# 在Sequential模型中使用 model_seq tf.keras.Sequential([ tf.keras.layers.Input(shape(128, 128, 3)), MyBilinearLayer(target_size(256, 256)), # 我们的自定义层 tf.keras.layers.Conv2D(32, 3, activationrelu), tf.keras.layers.GlobalAveragePooling2D(), tf.keras.layers.Dense(10) ]) # 在函数式API中使用 inputs tf.keras.Input(shape(None, None, 3)) # 甚至可以接受动态尺寸输入 x MyBilinearLayer(target_size(224, 224))(inputs) x tf.keras.layers.Conv2D(64, 3)(x) outputs tf.keras.layers.Activation(softmax)(x) model_func tf.keras.Model(inputsinputs, outputsoutputs) # 编译和训练 model_seq.compile(optimizeradam, losscategorical_crossentropy) # model_seq.fit(...) 可以正常训练4.3 模型保存与加载的注意事项这是自定义层最容易出问题的地方。我们的MyBilinearLayer实现了get_config和from_config方法这意味着Keras可以序列化它的配置target_size。然而序列化并不包括自定义操作库.so文件本身。当你调用model.save(my_model)保存整个模型为SavedModel格式时Keras会保存计算图。只要加载模型的Python环境中MyBilinearLayer类被定义并且my_bilinear_op.so库文件在call方法指定的路径下可被加载模型就能成功恢复。最佳实践将自定义操作库作为包的一部分分发将.so文件放在Python包的目录下在call方法中使用pkg_resources或importlib.resources来定位其绝对路径而不是相对路径./my_bilinear_op.so。提供安装脚本在setup.py中确保编译步骤或者提供预编译的二进制包。清晰的错误提示在call方法中如果加载库失败给出明确的错误信息提示用户需要编译或放置库文件。个人经验我曾将一个包含自定义C操作的项目交给同事他直接pip install后运行报错“找不到符号”。原因是他的Linux发行版glibc版本与我的编译环境不同。对于C自定义操作强烈建议使用manylinux标准编译或者提供Docker镜像来保证环境一致性。对于团队内部使用用Docker封装整个开发/推理环境是最省心的办法。5. 进阶为自定义操作添加GPU支持与梯度注册要让我们的自定义操作在生产环境中飞起来尤其是处理图像、视频等大数据量任务GPU支持是必须的。同时为了让这个操作能用于神经网络训练我们必须为其定义梯度除非它是一个不可微的操作。5.1 实现GPU内核我们需要为MyBilinear操作编写一个GPU内核版本。这通常使用CUDA C来编写。创建一个新文件my_bilinear_kernel_gpu.cu.cc注意.cu.cc后缀这是TensorFlow Bazel构建系统识别CUDA代码的约定。// my_bilinear_kernel_gpu.cu.cc (示例框架非完整代码) #if GOOGLE_CUDA // 确保在CUDA环境下编译 #define EIGEN_USE_GPU // 使用Eigen的GPU支持 #include tensorflow/core/framework/op_kernel.h #include tensorflow/core/framework/register_types.h #include tensorflow/core/util/gpu_kernel_helper.h namespace tensorflow { namespace functor { // 1. 定义一个Functor函子来封装核心计算逻辑 template typename T struct BilinearInterpolate { void operator()(const GPUDevice d, /* 输入输出张量参数 */) { // 在这里调用CUDA核函数 } }; } // namespace functor // 2. 定义GPU核函数 (__global__ 函数) template typename T __global__ void BilinearInterpolateKernel(/* 参数 */) { // 每个CUDA线程处理一个或多个输出像素 const int y blockIdx.y * blockDim.y threadIdx.y; const int x blockIdx.x * blockDim.x threadIdx.x; if (y new_height || x new_width) return; // ... 双线性插值计算逻辑与CPU类似但用并行索引 } // 3. 创建GPU版本的OpKernel template typename T class MyBilinearOpGPU : public OpKernel { public: explicit MyBilinearOpGPU(OpKernelConstruction* context) : OpKernel(context) {} void Compute(OpKernelContext* context) override { // 获取输入输出张量 // ... // 调用Functor它会启动CUDA核函数 functor::BilinearInterpolateT()(context-eigen_deviceGPUDevice(), /* 传递张量数据 */); } }; // 4. 注册GPU内核 #define REGISTER_GPU(T) \ REGISTER_KERNEL_BUILDER(Name(MyBilinear) \ .Device(DEVICE_GPU) \ .TypeConstraintT(T), \ MyBilinearOpGPUT); // 注册常用的数据类型 REGISTER_GPU(float); // REGISTER_GPU(double); // 如果需要支持double // REGISTER_GPU(Eigen::half); // 如果需要支持半精度 #undef REGISTER_GPU } // namespace tensorflow #endif // GOOGLE_CUDAGPU内核的实现复杂得多涉及CUDA编程、线程网格组织、内存访问优化等。通常你可以参考TensorFlow源码中类似操作如ResizeBilinear的实现。5.2 注册梯度函数为了让TensorFlow的自动微分系统tf.GradientTape能够对我们的MyBilinear操作求导我们需要为其注册一个梯度函数。梯度函数定义了输出相对于每个输入的导数。对于双线性插值其梯度反方向传播可以推导出来本质上是将输出端的梯度根据插值时的权重散射Scatter回输入端的四个邻近点。我们在定义操作的文件my_bilinear_op.cc附近创建一个新的文件来注册梯度例如my_bilinear_grad.cc// my_bilinear_grad.cc #include tensorflow/core/framework/op.h #include tensorflow/core/framework/op_kernel.h #include tensorflow/core/framework/shape_inference.h #include tensorflow/core/ops/ops_util.h using namespace tensorflow; // 1. 声明前向操作已经在my_bilinear_op.cc中定义过 // REGISTER_OP(MyBilinear)... // 2. 定义梯度操作 REGISTER_OP(MyBilinearGrad) .Input(grads: float) // 上游传递来的梯度形状同输出 [batch, new_h, new_w, channels] .Input(images: float) // 原始输入图像 .Input(size: int32) // 原始大小参数 .Output(images_grad: float) // 图像输入的梯度形状同images [batch, in_h, in_w, channels] .SetShapeFn([](::tensorflow::shape_inference::InferenceContext* c) { c-set_output(0, c-input(1)); // images_grad的形状与images相同 return Status::OK(); }); // 3. 实现梯度操作的内核CPU版本示例 class MyBilinearGradOp : public OpKernel { public: explicit MyBilinearGradOp(OpKernelConstruction* context) : OpKernel(context) {} void Compute(OpKernelContext* context) override { // 获取输入上游梯度、原始输入、原始size const Tensor grads_tensor context-input(0); const Tensor images_tensor context-input(1); const Tensor size_tensor context-input(2); // ... 验证形状 ... // 创建输出张量images_grad并初始化为0 Tensor* images_grad_tensor nullptr; OP_REQUIRES_OK(context, context-allocate_output(0, images_tensor.shape(), images_grad_tensor)); auto images_grad images_grad_tensor-flatfloat().data(); // 先将梯度清零 std::fill(images_grad, images_grad images_tensor.NumElements(), 0.0f); // ... 实现反向散射算法 ... // 对于grads中的每一个点根据其前向传播时的插值权重(y_lerp, x_lerp) // 将梯度值累加到images_grad中对应的四个位置上。 } }; REGISTER_KERNEL_BUILDER(Name(MyBilinearGrad).Device(DEVICE_CPU), MyBilinearGradOp); // 4. 最关键的一步将梯度函数与前向操作关联起来 // 这通常在另一个地方如一个初始化函数完成但为了清晰写在一起。 // 实际上我们需要定义一个“梯度注册器”。 // 更常见的做法是在Python端使用tf.RegisterGradient装饰器更为灵活。更实用的方法在Python端定义梯度由于Python端定义梯度更简单灵活TensorFlow 2.0更推荐这种方式。我们可以使用tf.custom_gradient装饰器。import tensorflow as tf # 加载前向操作 custom_op_module tf.load_op_library(./my_bilinear_op.so) _my_bilinear custom_op_module.my_bilinear # 使用tf.custom_gradient装饰器定义带梯度的函数 tf.custom_gradient def my_bilinear_with_grad(images, size): 带自定义梯度的双线性插值函数。 def grad(upstream_grad): 计算梯度。 Args: upstream_grad: 损失函数对my_bilinear_with_grad输出的梯度。 Returns: 对images的梯度。 # 这里需要实现反向传播逻辑。 # 由于双线性插值是线性的其梯度可以解析求出。 # 为了简化我们可以用一个近似使用TensorFlow自己的梯度或者用tf.image.resize的梯度来近似。 # 但最正确的方式是实现我们自己的MyBilinearGrad操作并调用它。 # 此处为示例我们假设有一个my_bilinear_grad操作。 # images_grad custom_op_module.my_bilinear_grad(upstream_grad, images, size) # return images_grad, None # 对size的梯度为Nonesize不是可训练变量 # 临时方案使用tf.image.resize的梯度来近似不精确仅演示 # 注意这要求images的形状是静态已知的且与tf.image.resize的行为完全一致。 _, h, w, _ images.shape # 计算images的梯度这是一个近似不完全准确 # 实际工程中必须实现精确的梯度操作。 images_grad_approx tf.image.resize(upstream_grad, (h, w), methodbilinear) return images_grad_approx, None # 对size的梯度为None # 前向传播 output _my_bilinear(images, size) return output, grad # 现在my_bilinear_with_grad就可以在GradientTape中自动求导了。为自定义操作添加完整的GPU支持和正确的梯度是一个系统工程需要深厚的CUDA和自动微分知识。对于大多数自定义操作如果性能要求不是极端苛刻可以优先考虑用组合现有操作路径一的方式实现让TensorFlow自动处理梯度和GPU移植。只有当这成为性能瓶颈时才值得投入精力去实现C/CUDA内核。
返回列表