1. 为什么要在C里调用TensorFlow的pb模型1.1 从一个实际场景说起很多做算法的朋友都有类似的经历在Python环境下用TensorFlow训练了一个模型效果不错准确率也达标但到了要交付的时候问题来了——对方的生产环境是C写的要么是一个桌面端软件要么是一个嵌入式设备上的推理程序要么是一个对延迟极其敏感的服务端模块。这时候你总不能要求对方装个Python解释器再套一层HTTP服务吧就算能这么干性能损耗和部署复杂度也会让人头疼。我自己就遇到过这种情况。早些年做一个工业质检的项目模型是用Python训练的图像分类网络但最终要跑在一台工控机上那台机器上跑的是纯C的视觉处理流水线根本没有Python环境。当时第一反应是搞个gRPC服务把推理隔离开但实测下来延迟抖动太大产线节拍跟不上。最后还是老老实实回到C直接加载pb模型这条路。所谓pb模型就是TensorFlow把训练好的计算图和权重序列化之后生成的protobuf格式文件通常以.pb为后缀。它里面包含了网络结构GraphDef和参数Variables已经冻结成常量是一个自包含的、与语言无关的模型文件。C的TensorFlow运行时可以直接读取这个文件构建Session然后喂数据、跑推理、取结果。整个过程不需要Python参与部署时只需要链接TensorFlow的C动态库即可。1.2 这条路适合谁不适合谁先说适合的情况。如果你的目标平台是Linux服务器、Windows桌面程序、Android/iOS移动端或者某些支持C的嵌入式Linux环境并且你对推理延迟有比较高的要求那C直接调用pb模型是非常合理的选择。它省去了跨进程通信的开销内存管理也更可控尤其适合需要和现有C代码库深度集成的场景。不适合的情况也要说清楚。如果你的模型需要频繁更新、需要动态图特性、或者用到了大量Python端的预处理逻辑那硬上C可能会让你痛不欲生。另外TensorFlow的C API文档相对Python来说少得可怜很多接口的用法只能靠读头文件和源码来摸索学习曲线比较陡。如果你团队里没有人熟悉C和构建系统那维护成本会很高。还有一个现实问题TensorFlow从2.x开始主推Keras和SavedModel格式pb文件的生成方式也发生了变化。如果你手里拿到的还是1.x时代的冻结图或者用tf.compat.v1导出的pb那C端的加载方式会有些差异。这些细节后面会展开讲。1.3 整体技术路线概览从大流程上看这件事分两大块第一块是在Python端把模型导出成C能用的pb文件第二块是在C端写代码加载并推理。第一块的关键是“冻结”freeze也就是把训练时的变量变成图里的常量同时把不必要的节点剪掉。第二块的关键是正确链接TensorFlow的C库并且把输入输出的张量名字对上。很多人卡住不是因为C代码写错了而是因为pb文件本身导出得不对——比如输入节点的名字不是你以为的那个或者输出节点被优化掉了。所以我会把导出环节也讲透确保你拿到的是一个“干净”的、C友好的pb文件。下面这张表先给一个全局印象后面再逐层拆解。阶段核心任务常用工具/接口容易踩的坑模型导出冻结计算图生成pbtf.compat.v1.graph_util.convert_variables_to_constants节点名不匹配、BatchNorm的is_training未固定C环境准备编译或下载TensorFlow C库Bazel编译、预编译包版本与pb不兼容、ABI不一致加载模型读取pb构建Graph和SessionReadBinaryProto、NewSession路径错误、protobuf版本冲突推理构造输入Tensor运行取输出Session::Run维度不对、数据类型不对、内存泄漏部署打包动态库和依赖ldd、CMake install缺少libtensorflow_framework.so2. 模型导出把训练成果变成C能吃的pb文件2.1 冻结图到底在冻什么训练的时候模型里的权重是存在Variable节点里的这些节点在保存和加载时是可变的。但推理的时候我们不需要再改权重了所以要把这些Variable替换成Const节点这就是“冻结”的核心含义。冻结之后整个计算图就变成一个纯函数给定输入经过一系列固定的运算得到输出。冻结的另一个好处是剪枝。训练图里往往包含很多推理用不到的节点比如梯度计算、优化器、正则化、Summary写入等等。冻结的过程可以指定输出节点然后TensorFlow会从输出节点反向遍历只保留与输出相关的子图其余全部丢弃。这样生成的pb文件更小加载也更快。在TensorFlow 1.x时代标准做法是用tf.compat.v1.graph_util.convert_variables_to_constants。到了2.x虽然推荐用SavedModel但如果你坚持要pb还是可以走这条路。下面是一段典型的冻结代码我加了详细注释。import tensorflow as tf from tensorflow.python.framework import graph_util # 假设你已经有了一个训练好的模型这里用sess和graph代指 # 注意以下代码需要在tf.compat.v1.Session()的上下文里执行 output_node_names [output/Softmax] # 你要保留的输出节点名 with tf.compat.v1.Session() as sess: # 加载已经训练好的权重 saver tf.compat.v1.train.import_meta_graph(model/model.meta) saver.restore(sess, model/model.ckpt) # 获取当前图定义 graph_def tf.compat.v1.get_default_graph().as_graph_def() # 执行冻结把变量转成常量并只保留output_node_names相关的子图 frozen_graph_def graph_util.convert_variables_to_constants( sess, graph_def, output_node_names ) # 写入pb文件 with tf.io.gfile.GFile(frozen_model.pb, wb) as f: f.write(frozen_graph_def.SerializeToString())这段代码里最关键的是output_node_names。你必须准确知道你的输出节点叫什么名字。如果名字写错了冻结会报错说找不到节点如果名字写对了但选了一个中间节点那最终pb的输出就不是你想要的。怎么查节点名可以在Python里打印[n.name for n in tf.compat.v1.get_default_graph().as_graph_def().node]然后从里面找。2.2 输入节点的命名陷阱输出节点相对好找输入节点才是真正的坑。很多人以为输入节点就是Placeholder的名字比如input或者x。但实际上在冻结后的图里输入节点的名字可能带上了作用域前缀比如input_layer/Placeholder。如果你在C里按input去找就会失败。更麻烦的是有些模型在导出时会对输入做预处理比如归一化、resize等这些操作也被固化在图里了。这时候你喂进去的原始数据可能还需要符合预处理之前的格式。我的建议是在Python端先用tf.compat.v1.Session加载冻结后的pb用graph.get_tensor_by_name确认输入输出的确切名字并且跑一遍推理把输入输出的形状和数值范围都记录下来。这样到了C端你就有明确的对照。还有一个常见问题是BatchNorm层。训练时is_trainingTrue推理时必须是False。如果冻结的时候没有把is_training这个placeholder固定成False那图里会残留一个布尔输入C端就必须多喂一个张量否则会报错。解决办法是在冻结前用tf.compat.v1.placeholder_with_default或者直接修改图把is_training设成常量False。2.3 用SavedModel转pb的替代方案如果你用的是TensorFlow 2.x模型保存成了SavedModel格式一个包含saved_model.pb和variables目录的文件夹那也可以转成冻结的pb。思路是加载SavedModel拿到具体的ConcreteFunction然后冻结。不过这个过程比1.x要绕一些而且2.x的图里可能包含一些C端不支持的算子。我个人的经验是如果目标是用C推理尽量在1.x兼容模式下导出pb或者直接用tf.compat.v1的API。2.x的SavedModel虽然功能强大但C端的支持并不完整尤其是自定义层和动态控制流。如果你非要用2.x建议先在一个最小的C程序里测试加载确认没有不支持的算子再继续。导出完成后可以用summarize_graph工具TensorFlow自带的一个命令行工具来检查pb文件。它会打印出输入输出节点、算子数量、不支持的算子等信息。这个工具在排查问题时非常有用命令大概长这样summarize_graph --in_graphfrozen_model.pb如果输出里显示有Unsupported ops那就要小心了C端加载时可能会直接失败。3. C环境准备链接TensorFlow库的几种姿势3.1 预编译包 vs 源码编译C用TensorFlow第一道坎就是拿到库。官方提供了两种方式一种是下载预编译的C库在TensorFlow的GitHub Release页面有libtensorflow的压缩包另一种是自己用Bazel从源码编译。预编译包的好处是省事解压后得到include和lib两个目录直接链接就行。但它的限制也很明显版本固定编译选项固定而且通常只包含CPU版本。如果你需要GPU支持或者需要特定的优化比如AVX、CUDA架构那就得自己编译。源码编译的坑就多了。Bazel本身对环境要求苛刻Python版本、JDK版本、gcc版本都有讲究。而且编译一次动辄几个小时中间还可能因为网络问题下载依赖失败。我自己的做法是先在预编译包上把流程跑通确认代码逻辑没问题再考虑是否需要自己编译。如果预编译包的版本和你的pb文件版本匹配那基本够用了。版本匹配这件事必须强调。TensorFlow的pb格式在不同版本之间可能有细微差异用2.10的库去加载2.15导出的pb大概率会报错。最稳妥的做法是导出pb用的Python TensorFlow版本和C链接的库版本保持一致。如果做不到完全一致至少保证大版本相同。3.2 一个最小可用的CMake配置假设你已经下载了预编译的libtensorflow解压到了/opt/libtensorflow目录结构是include/和lib/。下面是一个CMakeLists.txt的示例我加了注释说明每个部分的作用。cmake_minimum_required(VERSION 3.10) project(tf_cpp_demo) set(CMAKE_CXX_STANDARD 14) # TensorFlow C库需要C14或更高 # 指定TensorFlow的头文件路径 include_directories(/opt/libtensorflow/include) # 找到TensorFlow的库文件 find_library(TENSORFLOW_LIB tensorflow PATHS /opt/libtensorflow/lib NO_DEFAULT_PATH ) find_library(TENSORFLOW_FRAMEWORK_LIB tensorflow_framework PATHS /opt/libtensorflow/lib NO_DEFAULT_PATH ) # 可执行文件 add_executable(tf_demo main.cpp) # 链接库 target_link_libraries(tf_demo ${TENSORFLOW_LIB} ${TENSORFLOW_FRAMEWORK_LIB} )这里有个细节libtensorflow_framework.so是必须链接的很多人只链接了libtensorflow.so结果运行时报符号找不到。另外如果你的程序在运行时提示找不到动态库需要把/opt/libtensorflow/lib加到LD_LIBRARY_PATH里或者在CMake里设置RPATH。3.3 编译时常见的链接错误与解决链接阶段最常见的问题是protobuf冲突。TensorFlow内部依赖protobuf而你的系统里可能也装了另一个版本的protobuf。如果头文件和库版本不一致就会出现一堆莫名其妙的符号错误。解决办法是在CMake里显式指定TensorFlow自带的protobuf头文件和库不要用系统路径下的。另一个常见问题是ABI不兼容。如果你的程序用-D_GLIBCXX_USE_CXX11_ABI1编译而TensorFlow库是用0编译的那链接时会报错。预编译的TensorFlow库通常是用_GLIBCXX_USE_CXX11_ABI0编译的所以你的程序也要保持一致。可以在CMake里加add_definitions(-D_GLIBCXX_USE_CXX11_ABI0)这个宏不加可能会看到类似undefined reference to std::__cxx11::basic_string的错误。我第一次遇到的时候查了半天最后发现就是这个ABI开关的问题。4. C端加载与推理的完整实现4.1 读取pb文件并构建SessionC端的核心流程分四步读取pb文件到GraphDef、用GraphDef创建Graph、用Graph创建Session、运行Session。下面是一段完整的代码我把它拆成几个部分来讲。#include tensorflow/core/public/session.h #include tensorflow/core/platform/env.h #include tensorflow/core/framework/tensor.h #include iostream using namespace tensorflow; int main() { // 第一步读取pb文件 GraphDef graph_def; Status status ReadBinaryProto(Env::Default(), frozen_model.pb, graph_def); if (!status.ok()) { std::cerr 读取pb失败: status.ToString() std::endl; return -1; } // 第二步创建Session std::unique_ptrSession session(NewSession(SessionOptions())); status session-Create(graph_def); if (!status.ok()) { std::cerr 创建Session失败: status.ToString() std::endl; return -1; } // 第三步构造输入Tensor // 假设输入节点名是input形状是[1, 224, 224, 3]类型是float Tensor input_tensor(DT_FLOAT, TensorShape({1, 224, 224, 3})); // 填充数据这里用随机数示意 auto input_flat input_tensor.flatfloat(); for (int i 0; i input_flat.size(); i) { input_flat(i) 0.5f; } // 第四步运行Session std::vectorstd::pairstd::string, Tensor inputs { {input, input_tensor} }; std::vectorstd::string output_names {output/Softmax}; std::vectorTensor outputs; status session-Run(inputs, output_names, {}, outputs); if (!status.ok()) { std::cerr 推理失败: status.ToString() std::endl; return -1; } // 第五步读取输出 auto output_flat outputs[0].flatfloat(); for (int i 0; i output_flat.size(); i) { std::cout 输出[ i ] output_flat(i) std::endl; } return 0; }这段代码看起来简单但每一步都有讲究。ReadBinaryProto是从磁盘读文件如果文件路径不对或者文件损坏会返回错误状态。NewSession创建的是一个会话对象它持有计算图的运行时状态。session-Create(graph_def)会把GraphDef实例化成可执行的图。输入Tensor的构造是重点。DT_FLOAT指定数据类型TensorShape({1, 224, 224, 3})指定形状。这里的形状必须和Python端导出时的输入形状完全一致包括batch维度。如果你在Python端定义的输入是[None, 224, 224, 3]那C端可以传任意batch但通常为了简单先用1。4.2 输入输出的名字怎么确认前面反复强调名字要对上那到底怎么确认最可靠的方法是在Python端加载同一个pb文件然后打印所有节点的名字。代码很简单import tensorflow as tf with tf.io.gfile.GFile(frozen_model.pb, rb) as f: graph_def tf.compat.v1.GraphDef() graph_def.ParseFromString(f.read()) for node in graph_def.node: print(node.name, node.op)把输出重定向到文件然后搜索Placeholder和Softmax之类的关键词。输入节点通常是Placeholder类型的节点输出节点是你指定的那个。注意有些节点名字里带/在C里作为字符串传递时不需要转义直接写就行。还有一个技巧用summarize_graph工具它会直接告诉你输入和输出节点。命令是summarize_graph --in_graphfrozen_model.pb输出里会有Found 1 possible inputs和Found 1 possible outputs后面跟着节点名。这个工具在排查“名字对不上”的问题时特别高效。4.3 处理多输入多输出的情况实际模型往往不止一个输入。比如有些模型需要同时喂图像和图像尺寸或者需要喂一个dropout的开关。这时候inputs向量里就要放多个pair。输出也可能有多个比如检测模型会同时输出边界框和类别概率。多输入的关键是每个输入的名字和形状都要对。如果某个输入在Python端是int32类型C端就必须用DT_INT32不能用DT_FLOAT。类型不对会直接报错而且错误信息往往不直观。我的做法是在Python端把每个输入的dtype和shape都打印出来做成一张对照表C端照着表来构造。多输出的时候output_names里放多个名字outputs向量会按顺序返回对应的张量。注意顺序要和output_names一致不要搞混。4.4 性能优化的几个实操点C推理的性能优势主要体现在没有Python解释器开销但如果不注意也可能跑得很慢。第一个优化点是复用Session。创建Session是有成本的如果你要处理大量请求应该只创建一次Session然后反复调用Run。Session本身是线程安全的可以在多线程环境下共享。第二个优化点是输入Tensor的内存复用。如果每次推理都重新分配Tensor会有内存分配的开销。对于固定形状的输入可以预先分配好Tensor每次只更新数据。不过TensorFlow的Run接口会拷贝输入数据所以这个优化的效果有限。更彻底的做法是用Session::Run的重载版本直接传Tensor的引用但要注意生命周期。第三个优化点是关闭不必要的日志。TensorFlow默认会打印大量INFO级别的日志在生产环境里很干扰。可以在程序启动时设置环境变量TF_CPP_MIN_LOG_LEVEL2只显示WARNING及以上级别。5. 常见问题与排查技巧实录5.1 加载pb时报“Not found: Op type not registered”这个错误几乎每个用C跑TensorFlow的人都遇到过。原因是你用的TensorFlow C库不包含pb文件里用到的某个算子。常见于自定义算子、某些新版本的算子、或者GPU专用的算子。排查方法是先用summarize_graph看pb里有哪些算子然后对照你链接的TensorFlow库版本支持的算子列表。如果确实有不支持的算子解决办法有两个一是换一个包含该算子的TensorFlow版本重新编译C库二是在Python端导出pb时把该算子替换成C支持的等价算子。我遇到过一次是因为模型里用了tf.image.resize的某个新实现而C库是旧版本。后来在Python端把resize操作移到图外面用OpenCV在C端做预处理问题就解决了。这也引出一个经验预处理和后处理尽量放在图外面用C原生代码实现这样pb文件更简单兼容性更好。5.2 输入形状不匹配导致的崩溃C端构造的Tensor形状必须和图中输入节点的期望形状一致。如果Python端定义的是[1, 224, 224, 3]C端传了[1, 256, 256, 3]运行时会报形状不匹配的错误。这个错误信息通常比较明确会告诉你期望的形状和实际传入的形状。但有一种情况比较隐蔽Python端定义的是[None, 224, 224, 3]也就是batch维度是动态的。这时候C端可以传任意batch但如果你传了[0, 224, 224, 3]就会出问题。另外有些模型在导出时把batch维度固定成了1那C端就只能传1。我的建议是在Python端导出pb之前先把输入形状固定下来不要用None。固定形状可以让TensorFlow做更多的图优化推理速度也会更快。如果确实需要动态batch那就在C端做好形状检查确保传入的batch大小在合理范围内。5.3 内存泄漏与资源释放C没有垃圾回收Session和Tensor的生命周期需要手动管理。NewSession返回的是一个裸指针需要用std::unique_ptr或者手动delete来释放。Tensor对象在离开作用域时会自动释放内部内存但如果Tensor很大频繁创建销毁会导致内存碎片。一个常见的泄漏点是Session::Run的输出。outputs是一个std::vectorTensor每次调用Run都会往里填充新的Tensor。如果你在循环里反复调用Run而outputs没有清空就会不断累积。正确的做法是在每次Run之前调用outputs.clear()或者把outputs定义在循环内部。还有一个隐蔽的泄漏是GraphDef。ReadBinaryProto会把整个pb文件读进内存如果pb很大几百MB这部分内存会一直占用直到程序结束。如果只是临时加载可以在创建Session后释放GraphDef。不过通常pb文件不会太大这个问题不严重。5.4 多线程推理的注意事项Session是线程安全的多个线程可以同时调用同一个Session的Run方法。但要注意Run的输入和输出参数是每个线程独立的不能共享。另外TensorFlow内部会使用线程池来并行执行算子如果你的程序本身也是多线程的可能会出现线程数过多导致上下文切换开销大的问题。可以通过SessionOptions来配置线程池大小。比如SessionOptions options; options.config.set_intra_op_parallelism_threads(4); options.config.set_inter_op_parallelism_threads(2);intra_op控制单个算子内部的并行度inter_op控制不同算子之间的并行度。具体设多少要看你的CPU核心数和模型结构。我的经验是对于小模型把这两个值设小一点比如2和1避免线程争抢对于大模型可以适当调大。5.5 常见问题速查表问题现象可能原因排查方法解决方案加载pb报Op not registeredC库缺少算子summarize_graph查看算子换库版本或替换算子推理报形状不匹配输入Tensor形状不对打印期望形状和实际形状调整TensorShape链接报undefined referenceABI不兼容或库未链接检查编译宏和链接库加_GLIBCXX_USE_CXX11_ABI0运行时报找不到so动态库路径不对ldd查看依赖设置LD_LIBRARY_PATH输出全是NaN输入数据未归一化检查输入数值范围按训练时的预处理来推理速度慢线程配置不当调整SessionOptions设置合适的线程数内存持续增长输出Tensor未释放检查循环内outputs每次Run前clear6. 从Demo到生产部署时还要考虑什么6.1 动态库的打包与分发开发阶段你可以随便设LD_LIBRARY_PATH但交付给客户的时候不能要求对方配环境变量。正确的做法是把TensorFlow的动态库和你的可执行文件放在一起然后用RPATH指定相对路径。在CMake里可以这样设置set(CMAKE_INSTALL_RPATH $ORIGIN) set(CMAKE_BUILD_WITH_INSTALL_RPATH TRUE)$ORIGIN表示可执行文件所在的目录。这样程序启动时会自动在同一个目录下找动态库。打包的时候把libtensorflow.so、libtensorflow_framework.so和你的可执行文件放在同一个文件夹里压缩发给对方就能直接跑。注意libtensorflow_framework.so可能还依赖其他库比如libcudart.so如果用了GPU。用ldd命令可以查看完整的依赖树确保没有遗漏。6.2 模型版本管理与热更新生产环境里模型可能需要更新。如果每次更新都要重新编译C程序那太麻烦了。合理的做法是把pb文件路径做成可配置的程序启动时从配置文件读取。更新模型时只需要替换pb文件重启程序即可。更进一步可以实现热更新程序定期检查pb文件的时间戳如果发现更新了就重新加载Session。不过重新加载Session会导致短暂的推理不可用需要做好请求排队或者双缓冲。双缓冲的思路是维护两个Session新模型加载到备用Session加载完成后原子切换指针旧Session等所有进行中的请求结束后再释放。6.3 日志与监控C端的日志不像Python那么方便但生产环境必须要有。至少要在关键节点打印日志模型加载成功/失败、推理耗时、输入输出的统计信息。TensorFlow本身有日志系统但比较冗长建议用自己的日志库包装一层。监控方面可以记录每次推理的耗时分布如果发现P99延迟突然升高可能是模型或输入出了问题。还可以记录输出的数值分布如果发现输出全是同一个值可能是输入预处理出了问题。7. 一些个人体会这条路我走过好几次每次都会遇到新的坑但整体来说C直接调用pb模型是一个成熟可靠的方案。关键是要把导出环节做扎实确保pb文件干净、节点名明确、形状固定。C端反而没有太多花哨的东西就是老老实实读文件、建Session、喂数据、取结果。如果让我给新手一个建议那就是先用一个最简单的模型比如MNIST分类把整个流程跑通从Python导出到C推理每一步都确认无误。然后再换成你的真实模型。这样遇到问题时你可以快速判断是流程问题还是模型问题。另外不要害怕读TensorFlow的C头文件。虽然文档少但头文件里的注释和函数签名就是最好的文档。遇到不确定的接口直接去看源码里的实现往往比在网上搜半天更有效。最后说一个容易被忽略的点pb文件里的节点名是大小写敏感的。Input和input是两个不同的节点。在Python端打印节点名的时候最好把名字复制粘贴到C代码里不要手动敲避免因为大小写或拼写错误导致排查半天。
阅读完成 · 觉得有帮助?