節前超級乾貨福利放送!這可能是最實用的 Conan 管理依賴貼

Zilliz發表於2023-05-04

Milvus 的核心部分是 C++ 編寫的,而 C++ 的依賴管理一直是困擾開發者的巨大痛點,也是限制 C++ 生態發展的瓶頸。

Milvus 早期透過 FetchContent 和 ExternalProject 這些 CMake 內建方法自動下載依賴,在大部分情況下也夠用,但隨著 Milvus 核心的能力越來越豐富、依賴項也越來越多,例如要加入 Folly 使用它最佳化後的執行緒池、資料結構,要引入 opentelemetry-cpp 增強可觀測性等。

這就帶來了一定的問題,編譯時間越來越長,依賴的包還有遞迴依賴而且彼此之間還不能複用,每次加入新的依賴過程無比痛苦。這一系列的問題急需一個依賴管理的工具,在調研了 Conan、vcpkg、bazel 等工具後,最終我們選擇了生態完善、和 CMake 相容最好的 Conan 來管理依賴。

目前, Milvus 社群裡的 C++ 專案都在使用 Conan 管理依賴,在改造的過程中遇到了一些繞不過去的坑,本文將梳理使用 Conan 過程中的一些常見概念、用法和常見問題,方便大家使用、理解。

01.Conan 的常規用法

安裝教程

Conan 在 2023 年 3 月釋出了 2.0 版本,但是 2.0 有些第三方包還沒完全遷移過去,所以在 Milvus 中仍在使用 1.58.0 版本的 Conan,未來會嘗試升級到 2.0 版本。

Conan是 python3 寫的程式,透過 pip 即可安裝:

pip install conan==1.58.0

在Milvus中使用原理

在執行 make 之後,Milvus 會自動呼叫 Conan 下載、安裝依賴,具體細節如下:

  • 在 scripts/core_build.sh 中執行 conan install 下載並編譯依賴:
case "${unameOut}" in
  Darwin*)
    conan install ${CPP_SRC_DIR} --install-folder conan --build=missing -s compiler=clang -s compiler.version=${llvm_version} -s compiler.libcxx=libc++ -s compiler.cppstd=17 || { echo 'conan install failed'; exit 1; }
    ;;
  Linux*)
    GCC_VERSION=`${CC} -dumpversion`
    if [[ `${CC} -v 2>&1 | sed -n 's/.*\(--with-default-libstdcxx-abi\)=\(\w*\).*/\2/p'` == "gcc4" ]]; then
      conan install ${CPP_SRC_DIR} --install-folder conan --build=missing -s compiler.version=${GCC_VERSION} || { echo 'conan install failed'; exit 1; }
    else
      conan install ${CPP_SRC_DIR} --install-folder conan --build=missing -s compiler.version=${GCC_VERSION} -s compiler.libcxx=libstdc++11 || { echo 'conan install failed'; exit 1; }
    fi
    ;;
  *)
    echo "Cannot build on windows"
    ;;
esac
  • 在 cmake_build/conan 目錄中生成依賴項的配置。
  • 在 core/CMakeLists.txt 中 include 生成的配置資訊,即可使用 Conan 中定義的第三方依賴:
list( APPEND CMAKE_MODULE_PATH ${CMAKE_BINARY_DIR}/conan )
include( ${CMAKE_BINARY_DIR}/conan/conanbuildinfo.cmake )

Conan 的 Profile

Profile 是 Conan 的重要配置,該配置決定了 Conan 在編譯第三方依賴時的引數,包括編譯器版本、C++ 版本等。

Conan 會根據 profile + option 決定是否編譯依賴,如果 profile + option 在 conan center 中有預編譯好的二進位制檔案,則直接下載使用,否則會從原始碼編譯。

在 ~/.conan/profiles/default 有預設配置,例如:

[settings]
os=Macos
os_build=Macos
arch=armv8
arch_build=armv8
compiler=clang
compiler.version=15
compiler.libcxx=libc++  # libcxx so的版本,有是否支援cxx11的區別
compiler.cppstd=17
build_type=Release
[options]
[build_requires]

在 Milvus 的 Conanfile.py 中,改了預設的 arrow 編譯配置,所以 arrow 必然會重新編譯:

class MilvusConan(ConanFile):

    settings = "os", "compiler", "build_type", "arch"
    requires = (
        "arrow/8.0.1",
    )
    generators = ("cmake", "cmake_find_package")
    default_options = {
        "arrow:with_zstd": True,
        "arrow:shared": False,
        "arrow:with_jemalloc": True,
    }

第三方包裝在哪裡?

以 arrow 為例,它會裝在下方目錄中,其中檔案路徑中的 hash 值是根據 profile+option 算出來的,所以修改 profile 或 option 後會重新生成一個 package。

02.如何寫 conanfile.py

可以參考 internal/core/conanfile.py:

class MilvusConan(ConanFile):

    settings = "os", "compiler", "build_type", "arch"
    # 去 https://conan.io/center/ 搜尋需要的package及其版本
    requires = (
        "rocksdb/6.29.5",
        "boost/1.81.0",
        "onetbb/2021.7.0",
        "nlohmann_json/3.11.2",
        "zstd/1.5.5",
        # ...
    )
    generators = ("cmake", "cmake_find_package")
    default_options = {
        "rocksdb:shared": True,
        # ...
        
    }

    # 根據settings動態決定依賴的編譯配置
    def configure(self):
        if self.settings.os == "Macos":
            # Macos M1 cannot use jemalloc
            if self.settings.arch not in ("x86_64", "x86"):
                del self.options["folly"].use_sse4_2
    # imports 會把匹配的檔案放到 cmake_build/ 下           
    def imports(self):
        self.copy("*.dylib", "../lib", "lib")
        self.copy("*.dll", "../lib", "lib")
        self.copy("*.so*", "../lib", "lib")
        self.copy("*", "../bin", "bin")
        self.copy("*.proto", "../include", "include")

03.如何寫入及釋出 Library 的 conanfile.py ?

相比於只是使用 Conan 管理依賴,寫一個 library 的 conanfile.py 要複雜很多,它不光要定義依賴項,給使用者提供多種編譯選項,還要宣告匯出的包各種定義。

參考 Knowhere 的 conanfile.py:

class KnowhereConan(ConanFile):
    name = "knowhere"
    description = "Knowhere is written in C++. It is an independent project that act as Milvus's internal core"
    topics = ("vector", "simd", "ann")
    url = "https://github.com/milvus-io/knowhere"
    homepage = "https://github.com/milvus-io/knowhere"
    license = "Apache-2.0"

    generators = "pkg_config"

    settings = "os", "arch", "compiler", "build_type"
    # 需要指定option和它的預設值
    options = {
        "shared": [True, False],
        "fPIC": [True, False],
        "with_raft": [True, False],
        "with_asan": [True, False],
        "with_diskann": [True, False],
        "with_profiler": [True, False],
        "with_ut": [True, False],
        "with_benchmark": [True, False],
    }
    default_options = {
        "shared": True,
        "fPIC": False,
        "with_raft": False,
        "with_asan": False,
        "with_diskann": False,
        "with_profiler": False,
        "with_ut": False,
        "glog:with_gflags": False,
        "prometheus-cpp:with_pull": False,
        "with_benchmark": False,
    }

    # 釋出的原始碼包包含哪些檔案
    exports_sources = (
        "src/*",
        "thirdparty/*",
        "tests/ut/*",
        "include/*",
        "CMakeLists.txt",
        "*.cmake",
        "conanfile.py",
    )

    @property
    def _minimum_cpp_standard(self):
        return 17

    @property
    def _minimum_compilers_version(self):
        return {
            "gcc": "8",
            "Visual Studio": "16",
            "clang": "6",
            "apple-clang": "10",
        }

    def config_options(self):
        if self.settings.os == "Windows":
            self.options.rm_safe("fPIC")

    def configure(self):
        if self.options.shared:
            self.options.rm_safe("fPIC")

    def requirements(self):
        self.requires("boost/1.81.0")
        self.requires("glog/0.6.0")
        self.requires("nlohmann_json/3.11.2")
        self.requires("openssl/1.1.1t")
        self.requires("prometheus-cpp/1.1.0")
        if self.options.with_ut:
            self.requires("catch2/3.3.1")
        if self.options.with_benchmark:
            self.requires("gtest/1.13.0")
            self.requires("hdf5/1.14.0")

    @property
    def _required_boost_components(self):
        return ["program_options"]

    def validate(self):
        if self.settings.compiler.get_safe("cppstd"):
            check_min_cppstd(self, self._minimum_cpp_standard)
        min_version = self._minimum_compilers_version.get(str(self.settings.compiler))
        if not min_version:
            self.output.warn(
                "{} recipe lacks information about the {} compiler support.".format(
                    self.name, self.settings.compiler
                )
            )
        else:
            if Version(self.settings.compiler.version) < min_version:
                raise ConanInvalidConfiguration(
                    "{} requires C++{} support. The current compiler {} {} does not support it.".format(
                        self.name,
                        self._minimum_cpp_standard,
                        self.settings.compiler,
                        self.settings.compiler.version,
                    )
                )

    def layout(self):
        cmake_layout(self)

    # 用於生成最關鍵的 cmake toolchain檔案,cmake依賴項配置檔案,以及cmake編譯引數
    def generate(self):
        tc = CMakeToolchain(self)
        tc.variables["CMAKE_POSITION_INDEPENDENT_CODE"] = self.options.get_safe(
            "fPIC", True
        )
        # Relocatable shared lib on Macos
        tc.cache_variables["CMAKE_POLICY_DEFAULT_CMP0042"] = "NEW"
        # Honor BUILD_SHARED_LIBS from conan_toolchain (see https://github.com/conan-io/conan/issues/11840)
        tc.cache_variables["CMAKE_POLICY_DEFAULT_CMP0077"] = "NEW"

        cxx_std_flag = tools.cppstd_flag(self.settings)
        cxx_std_value = (
            cxx_std_flag.split("=")[1]
            if cxx_std_flag
            else "c++{}".format(self._minimum_cpp_standard)
        )
        tc.variables["CXX_STD"] = cxx_std_value
        if is_msvc(self):
            tc.variables["MSVC_LANGUAGE_VERSION"] = cxx_std_value
            tc.variables["MSVC_ENABLE_ALL_WARNINGS"] = False
            tc.variables["MSVC_USE_STATIC_RUNTIME"] = "MT" in msvc_runtime_flag(self)
        tc.variables["WITH_ASAN"] = self.options.with_asan
        tc.variables["WITH_DISKANN"] = self.options.with_diskann
        tc.variables["WITH_RAFT"] = self.options.with_raft
        tc.variables["WITH_PROFILER"] = self.options.with_profiler
        tc.variables["WITH_UT"] = self.options.with_ut
        tc.variables["WITH_BENCHMARK"] = self.options.with_benchmark
        tc.generate()
        deps = CMakeDeps(self)
        deps.generate()

    def build(self):
        cmake = CMake(self)
        cmake.configure()
        cmake.build()

    def package(self):
        cmake = CMake(self)
        cmake.install()
        files.rmdir(self, os.path.join(self.package_folder, "lib", "cmake"))
        files.rmdir(self, os.path.join(self.package_folder, "lib", "pkgconfig"))

    def package_info(self):
        self.cpp_info.set_property("cmake_file_name", "knowhere")
        self.cpp_info.set_property("cmake_target_name", "Knowhere::knowhere")
        self.cpp_info.set_property("pkg_config_name", "libknowhere")

        self.cpp_info.components["libknowhere"].libs = ["knowhere"]

        self.cpp_info.components["libknowhere"].requires = [
            "boost::program_options",
            "glog::glog",
            "prometheus-cpp::core",
            "prometheus-cpp::push",
        ]

        self.cpp_info.filenames["cmake_find_package"] = "knowhere"
        self.cpp_info.filenames["cmake_find_package_multi"] = "knowhere"
        self.cpp_info.names["cmake_find_package"] = "Knowhere"
        self.cpp_info.names["cmake_find_package_multi"] = "Knowhere"
        self.cpp_info.names["pkg_config"] = "libknowhere"
        self.cpp_info.components["libknowhere"].names["cmake_find_package"] = "knowhere"
        self.cpp_info.components["libknowhere"].names[
            "cmake_find_package_multi"
        ] = "knowhere"

        self.cpp_info.components["libknowhere"].set_property(
            "cmake_target_name", "Knowhere::knowhere"
        )
        self.cpp_info.components["libknowhere"].set_property(
            "pkg_config_name", "libknowhere"
        )

理論上無需修改原始的 CMakeLists.txt 檔案,但部分第三方包名並不統一要做對應的修改。在 CMakeLists.txt 中直接新增 find_package(XXX required) 即可找到對應的包。

原理

以編譯 Knowhere 為例:

在build目錄下執行,可以新增一些自定引數,這些自定義引數需要定義在 conanfile.py 中。

conan install .. --build=missing -o with_ut=True -o with_asan=True -s build_type=Debug

執行上述命令即可將依賴包下載並編譯,同時在 build/Debug/generators 下會生成重要的配置檔案。再執行即可編譯knowhere專案:

conan build ..

Conan build 命令本質上是執行了 cmake 命令,加了一些引數,約等於:

cmake -G "Unix Makefiles" -DCMAKE_TOOLCHAIN_FILE=./Debug/generators/conan_toolchain.cmake -DCMAKE_BUILD_TYPE="Debug" ..  

很多編輯器、IDE 會根據 CMakeLists.txt 檔案自動配置環境。在使用 Conan 後,很多同學會遇到配置專案報錯、無法使用的問題,此時需要修改 IDE 對應的 cmake 配置,加上 -DCMAKE_TOOLCHAIN_FILE=build/Debug/generators/conan_toolchain.cmake 引數即可完成環境配置。

如何寫一個新包及測試?

https://github.com/milvus-io/conanfiles 裡有幾個例子,以其中的 arrow 為例,在 arrow/all 目錄下執行:

conan create . arrow/12.0.0-dev1@milvus/dev --build=missing

如果編譯成功,會在 ~/.conan/data/arrow 下生成對應的包。

如何上傳到 center

Milvus 依賴的一些 lib 如 Knowhere、velox 等在 https://conan.io/center/ 中不存在或版本不符合要求,此時需要上傳到私有的 center,拿到對應的使用者名稱、密碼並執行以下命令:

conan user -p $password -r default-conan-local $user
conan upload arrow/12.0.0-dev1@milvus/dev -r default-conan-local

至於如何搭建私有的center,詳見:https://docs.conan.io/1/uploading_packages/remotes.html

本文由mdnice多平臺釋出

相關文章