整个2019年一篇文章也没写,果然惰性是越滚越厉害,刚开始还会时不时想要不要把某个问题做个记录,到下半年基本直接忘记自己还有个博客了,直到2020年春节结束,因为冠状病毒肆虐导致没法正常回公司复工,在家远程办公了两周,总算有机会沉下心思考了几个许久未解决的问题,然后想起自己应该尝试恢复写博客的习惯,好好记录记录日常。

背景介绍

这篇文章的源头是最近几个月我在公司做的一个偏底层的C++基础设施库,该库的核心目标是提供一套统一的模型预测API以方便算法工程师(不懂太多工程开发的炼丹师)进行模型服务的快速开发、部署,举例来说,算法工程师输出的原始模型可能来自各种支持DL模型训练的框架,例如PyTorch、MXNet、TensorFlow等,如果这个算法工程师比较靠谱,那他可能还会帮你考虑性能优化的事情,于是将原始模型转化成TensorRT、TVM之类模型加速库支持的格式,理想情况中,如果我们有足够多的时间和精力,那只需要整个研发小组选定一个统一的inference部署框架,所有人在此基础上来做更上一层的web服务或其他平台应用即可(例如TensorRT+TensorRT Inference Server的模式),但实际上由于人力、技术水平、时间等因素的限制,我们还是很可能会迫于无奈需要把不同格式的模型文件快速封装部署成需要的服务然后上线,不过,今天主要讨论的主题并不在于如何将这些深度学习库进行整合,而是在这个过程中我遇到的一些关于C++ SDK封装的问题。

我在最初的版本中对于核心功能大致是这样实现的:

  • 定义一个模型基类BaseModel,包含初始化、预测等公共API接口,同时为了方便使用和更好的支持部署,还添加了以下特性,

    • 为支持多种Tensor数据结构TensorX, TensorY(例如OpenCV的cv::Mat、MXNet的mxnet::cpp::NDArray、PyTorch的torch::Tensor和TVM的tvm::NDArray),因此inference预测函数有多个版本
    • 为支持可选功能编译,还允许在cmake构建时通过设置cmake -DUSE_X=ON -DUSE_Y=OFF并在CMakeLists.txt里执行add_definitions(-DUSE_X=1)来打开或者关闭某些功能,这样既可以在必要的时候减少部署依赖,还可以顺便减小生成的二进制文件大小
    // model.h
    class BaseModel {
      public:
        virtual Error_t init(...) = 0;
    #if USE_X
        virtual Error_t inference(const std::vector<TensorX>& inputs,
                                 std::vector<TensorX>& outputs) = 0;
    #endif
    #if USE_Y
        virtual Error_t inference(const std::vector<TensorY>& inputs,
                                 std::vector<TensorY>& outputs) = 0;
    #endif
      private:
        /* private data */
        int batch_size_;
        std::vector<std::string> input_names_;
        // ... ...
    };
    
  • 针对要支持的底层模型预测库(如A、B、C),实现对应的子类

    // model_a.h
    #if USE_A
    class AModel : public BaseModel {
      public:
        virtual Error_t init(...) = 0;
    #if USE_X
        virtual Error_t inference(const std::vector<TensorX>& inputs,
                                 std::vector<TensorX>& outputs) = 0;
    #endif  // end USE_X
    #if USE_Y
        virtual Error_t inference(const std::vector<TensorY>& inputs,
                                 std::vector<TensorY>& outputs) = 0;
    #endif  // end USE_Y
      private:
        /* 支持A库模型预测需要的一些变量 */
        AData1 data1;
        AData2 data2;
        // ... ...
    }
    #endif  // end USE_A
      
    // model_a.cc
    #include "model_a.h"
    #include "logging.h"
    // ...
    // 函数实现
    

问题发现

上面这种代码实现风格其实之前在团队内部的很多应用项目里都有用到,当时也没感觉到有什么问题,但这回因为开发的是个SDK,在进行简单的使用后就暴露了出了多个问题:

  • 编译选项造成的头文件和库文件不匹配问题:用户编译完SDK再安装的.h头文件依然包含用于判断编译选项的判断逻辑(例如#if USE_X ... #endif等),但由于此时库文件早已完成编译,用户如果只是单纯的包含此头文件而没有事先按照编译时的配置选项来定义这些宏,就会出现头文件和库文件API接口不匹配的问题(网上有类似的讨论:feature-flags-toggleshow-to-best-safeguard-against-mismatch-of-compiler-flags-between-library-source-and-application);
  • 类成员变量泄漏问题:虽然我们把模型API的实现和声明在.cpp.h两种文件中分离,保证编译安装后用户无法看到源文件里的代码,但是那些定义在.h类定义里的成员变量依然暴露了出来;
  • SDK日志API和用户日志代码冲突问题:SDK内部开发时很可能需要包含一个负责日志输出的代码实现,并且非常常见的会在其头文件logging.h里定义诸如LOGCHECKLOG(INFO)LOG_ERROR之类的宏,那么问题来了,这个logging.h如果直接或者间接的被安装,那么用户使用SDK写自己的应用程序时如果再使用了别的日志库,就很可能发生重定义的冲突,理论上来说,SDK显然是做好自己该做的事情即可,不应该限制用户使用别的日志库才对;

接下来我们依次讨论下这些问题的解决思路。

头文件和库文件不匹配问题

我们先仔细回顾一下这个问题的描述:用户编译完SDK再安装的.h头文件包含用于判断编译选项的代码逻辑,如果用户在包含这些头文件之前没有按照编译阶段那样设置完全一致的宏定义,就会导致头文件和库文件不匹配。

很显然,这个问题之所以会产生是由这些编译选项设置的宏定义导致的,那么可以很容易的想出两种naive的方法来解决:

  1. 把所有可能因编译选项改变而改变的宏定义删除;

  2. 在编译完SDK准备安装之前,修改.h头文件中涉及编译选项的部分,删除不相关的判断逻辑,保证安装的头文件在使用前不需要进行任何宏定义操作,例如

    // 原始头文件
    #if USE_A
      fun1();
    #else
      fun2();
    #endif
       
    // 假设编译阶段选择设置了USE_A=0,那么修改头文件为
    fun2();
    

这些方法确实都可以让问题消失,但却可能带来新的问题,例如

  1. 方法一让我们失去了通过编译选项来增加、删除部分功能的可能,举个例子,你现在只想用opencv的一些基础API,但是编译安装的时候告诉你必须安装CUDA、TBB、MKL等一堆依赖,那显然会非常恼人;
  2. 方法二既不要求SDK开发者对原有功能进行限制,也可以让用户非常无脑的使用SDK,看起来非常美好,但问题是此方案所要求的精细修改很难自动化的实现,举个例子,如果你想借助g++编译器的-E选项来执行预处理操作(preprocessing),那么不仅这些和编译选项相关的代码被修改了,所有其他涉及到预处理的代码也都会被修改,此时输出的头文件显然也不再适合进行系统安装(当然,说不定是我孤陋寡闻,也许真有什么智能化的修改工具可以实现这里需求也不好说);

问题到这里似乎陷入了僵局,事实上我也确实因此困扰了很长一段时间,直到最近重新回顾了一些同样包含这种通过编译选项来控制功能配置的开源库,结果发现它们的情况其实和我这里的问题存在一些细微的差异,以OpenCV为例,如果我们仔细观察一下它的源码和安装模式,会发现在安装时如果OpenCV检测到了CUDA并且设置了-DWITH_CUDA=ON,那么安装的头文件和库就会包含dnn模块,如果设置了-DOPENCV_EXTRA_MODULES_PATH=/path/to/contrib/modules,那么安装的头文件就会包含contrib对应的功能,可以看出,OpenCV虽然也通过编译选项来控制某些可选功能是否安装,但是这些功能通常是以相对独立的模块为单位,如果某个选项没有打开,那么整个模块都不会被安装,而安装的头文件里也不会有WITH_CUDA之类的宏判断代码,从而规避了我们这里需要用户手动设置宏定义的问题。

当然,我正在开发的这个项目其实没有OpenCV这么巨大,没必要强行分出那么多模块,而且更直接的问题是上面样例代码里那个模型类需要对inference函数实现多种类型参数的支持(TensorXTensorY等),因此不可能拆分到多个不同的文件/模块。

现在我们再仔细对比一下OpenCV这种“按模块拆分”的方法还有之前两种非常naive的方法,其实可以发现它们之间存在一个共性,就是保证安装之后的.h头文件不包含任何会受SDK编译选项影响的宏判断代码,这意味着我们这里最根本的需求实际上不是把这些#if USE_XXX ... #endif项目中消除,而是从安装给用户使用的头文件中删除,如果有办法可以转移到.cpp源文件中,问题就可以顺利解决!

那么如何把这些#if USE_XXX从头文件转移到源文件中呢?上面样例代码里其实有两种情况

// model_a.h
#if USE_A
class AModel : public BaseModel {
  public:
    virtual Error_t init(...) = 0;
#if USE_X
    virtual Error_t inference(const std::vector<TensorX>& inputs,
                             std::vector<TensorX>& outputs) = 0;
#endif  // end USE_X
#if USE_Y
    virtual Error_t inference(const std::vector<TensorY>& inputs,
                             std::vector<TensorY>& outputs) = 0;
#endif  // end USE_Y
  private:
    /* 支持A库模型预测需要的一些变量 */
    AData1 data1;
    AData2 data2;
    // ... ...
}
#endif  // end USE_A

这里的#if USE_A ... #endif表达的逻辑是“如果不使用A,那么SDK就直接不提供AModel这个类”,本质上与OpenCV那种分模块安装的思路有点相似,因此我们可以在CMakeLists.txt里进行特殊配置,实现“如果没有设置USE_A则不安装model_a.h这个头文件”的效果。

除此之外,还有一种把编译选项判断代码转移到源文件的办法是:直接删除#if USE_A (和对应的#endif),然后在model_a.cpp文件各个API函数里进行编译选项判断并分情况处理,例如:

// model_a.cpp

AModel::AModel(...) {
#if USE_A
  // do something as usual
#else
  throw std::runtime_error("AModel is not implemented, please set \"USE_A=ON\" before compiling");
#endif
}

Error_t AModel::init(...) {
#if USE_A
  // do something as usual
#else
  throw std::runtime_error("AModel is not implemented, please set \"USE_A=ON\" before compiling");
#endif
}

注意,此方法不管编译选项如何设置,SDK都会对用户暴露AModel这个类,只不过如果没设置USE_A的话在调用AModel的API函数时会抛出异常,而前一种方案则是相当于直接删除了AModel这个类!

至此,第一种情况解决了,我们再来看看USE_XUSE_Y对应的第二种情况:“通过宏定义来决定是否需要提供类中的某个成员函数”,前面已经说了,这种情况由于无法把每一个函数的声明拆分到不同文件,无法用上面的第一种方法解决,那么我们也只能先在头文件中暴力删除#if USE_XXX代码(即无论对应的编译选项打开或者关闭,该inference函数均存在),然后尝试将判断逻辑隐藏到源文件里,具体如下

// model_a.h

// forward declaration
class TensorX;
class TensorY;

class AModel : public BaseModel {
  public:
    // ... ...
    virtual Error_t inference(const std::vector<TensorX>& inputs,
                             std::vector<TensorX>& outputs) = 0;
    // ... ...
}

// model_a.cpp
#if USE_X
#include <lib_a.hpp>
#else
class TensorX {};
#endif

Error_t AModel::inference(const std::vector<TensorX>& inputs,
                             std::vector<TensorX>& outputs) {
#if USE_X
  // do something
#else
  throw std::runtime_error("Please set \"USE_X=ON\" before compiling or you can not call this function!");
#endif
  // ... ...
}

可以看出,即使USE_X=0的情况下,通过提前声明(Forward Declaration)我们依然可以让头文件在没有TensorX具体定义的时候不报错,但值得注意的是之后我们还得在源文件里添加一个伪造的TensorX类定义,否则编译源文件时会报错error: variable has incomplete type 'TensorX',主要是由于虽然inference函数没有真正用到inputsoutputs参数,但这个函数已经定义了,下面再给两个样例代码来说明此问题

// 声明A,然后另一个函数声明用到了A类型的参数,此代码可以编译
class A;
void fun(A a);

int main(int argc, char *argv[]) {
    return 0;
}
// 声明A,然后另一个函数定义用到了A类型的参数,此代码无法编译
class A;
void fun(A a) {}

int main(int argc, char *argv[]) {
    return 0;
}

另外,在做类型提前声明的时候还得注意保持与库中真正定义的形式一致,例如我们要提前声明OpenCV的Size类型,假如我们没想太多直接在头文件里写

// model_a.h
namespace cv {
class Size;  // 如果对应类型属于某命名空间,直接按正常定义那样放在命名空间里即可
}

void fun(Size s);

那么万一编译时设置了加入OpenCV支持,那么你可能会获得如下错误信息

/usr/local/include/opencv2/core/types.hpp:341:16: note: ‘cv::Size’ has a previous declaration here
 typedef Size2i Size;

正确的做法是把cv::Size的声明同样写成typedef的模式,并且添加其他必要的类型声明

// model_a.h
namespace cv {
template<typename T> struct Size_;
typedef Size_<int> Size2i;
typedef Size2i Size;
}

void fun(Size s);

类成员变量泄漏问题

虽然最开始展示的代码中没有暴露各种API函数实现的代码,但是成员变量直接写在类定义里给用户使用同样有可能带来安全方面的风险,幸运的是,恰巧有一种叫做“Pimpl (pointer to implementation)”的方法可以很方便的解决此问题,Wikipedia介绍请点击:https://en.wikipedia.org/wiki/Opaque_pointer,下面用一段示例代码进行简单介绍,假设我们原始的类型定义如下

// awesome_class.h
class AwesomeClass {
  public:
    AwesomeClass();        // 构造函数
    fun1();                // 公有成员函数
  
  private:
    void fun2();           // 私有成员函数

    int a;                 // 私有成员变量
    std::string b;         // 私有成员变量
    CustomMemberClass1 c;  // 私有成员变量
};

那么现在修改为

// awesome_class.h
class AwesomeClass {
  public:
    AwesomeClass();
    ~AwesomeClass();
    fun1();
  
  private:
    class Impl;
    Impl* p_impl_;
};

// awesome_class.cpp
class AwesomeClass::Impl {
  void fun2() {
    // do something
  }
  
  int a;
  std::string b;
  CustomMemberClass1 c;
};

AwesomeClass::AwesomeClass() {
  // 在构造函数中创建Impl类的实例
  this->p_impl_ = new Impl();
}

AwesomeClass::~AwesomeClass() {
  // 在析构函数中删除Impl类的实例
  delete this->p_impl_;
}

整个方法的原理显而易见,更官方的描述如下:

  1. Put all the private member variables into a struct.
  2. Put the struct definition in the .cpp file.
  3. In the header file, put only the ForwardDeclaration of the struct.
  4. In the class definition, declare a (smart) pointer to the struct as the only private member variable.
  5. The constructors for the class need to create the struct.
  6. The destructor of the class needs to destroy the struct (possibly implicitly due to use of a smart pointer).
  7. The assignment operator and CopyConstructor need to copy the struct appropriately or else be disabled.

另外,Pimpl这种编程风格也存在一些缺点

  1. More work for the implementor.
  2. Doesn’t work for ‘protected’ members where access by subclasses is required.
  3. Somewhat harder to read code, since some information is no longer in the header file.
  4. Run-time performance is slightly compromised due to the pointer indirection, especially if function calls are virtual (branch prediction for indirect branches is generally poor).

更多的介绍请参考这里

SDK日志API和用户日志代码冲突问题

老实说这个问题我现在依然没有完美的解决方案,比如MXNet的C++接口头文件会间接引用到dmlc/logging.h,那么我在使用的时候如果想舍弃它选择其他的日志库,那么不得不避开LOGLOG(INFO)CHECK这些非常常见的宏,而如果你选择的日志库不支持重命名这些类似功能的宏,那么可能会直接导致编译错误让你不得不放弃。

很长一段时间我都在想,如果C++中的宏也受命名空间限制该有多好,这样就有可能把这些宏定义像函数、类一样保护在各自的作用域里,不过现实总是惨淡的,指望这个还不如把日志库里的LOGCHECK这些宏用函数或者类重新实现一遍。

别人的第三方库我们或许无法百分之百控制,但是我们自己在开发SDK的时候其实是可以通过一些trick来绕过,假设我们有一个SDK内部使用的logging.h文件负责提供日志输出的接口,但又不想让用户在使用SDK的时候受到干扰,那完全可以把这个头文件从$PROJECT_ROOT/include文件夹移动到$PROJECT_ROOT/src里!或者更透彻一点的进行解释,C++项目的头文件并非一定要放到$PROJECT_ROOT/include里然后通过安装步骤提供给用户,如果是内部功能模块的头文件,我们完全可以全部隐藏在src目录里!

当然上述操作的前提是保证发布给用户的头文件不依赖这个logging.h头文件,至于如何实现,这又回到了之前那些“如何把XXX逻辑”隐藏到.cpp源文件的内容了,这里不再赘述。

总结

写代码这么多年一直没有系统的花时间去看些设计模式方面的书,最近在做一些实际项目的设计时很自然的遇到了这样或那样的问题,理论内功的重要性凸显,上面提到的解决方案还主要是靠Google和自己琢磨(所以无法保证是否还有更优雅的办法),果然这么多年来锻炼最熟练的技能还是两个:搜索+Copy代码,😅。