## 基于容器的AI系统开发 文/王鹤麟,于洋,王益 基于深度学习的 AI 系统开发通常依赖一个深度学习平台。在 PaddlePaddle 的开发过程里,我们发现 PaddlePaddle 的开发、基于 PaddlePaddle 的 AI 应用的开发,以及这些应用的部署,都可以通过 Docker 来完成。从而减少软件安装的麻烦,也简化问题复现的代价。 ### 开发痛点 **编译工具难配置** AI 系统编译时需要安装的工具非常多(PaddlePaddle 需要40个工具,TensorFlow 需要51个),导致编译环境很难配置。开发者花很多时间在配置编译环境,追溯为什么编译不通过,既影响心情也耽误时间。作为一个开源项目,PaddlePaddle 的编译环境配置必须非常容易,这样才会有更多的开发者加入进来。 **编译工具不断变化** 一个不停迭代的项目往往编译环境也是在不停变化的:比如 PaddlePaddle 0.9 版本用的是 CUDA 7.5,0.10 版本是 CUDA 8.0。开发者不可能每个人都对编译配置非常熟悉,很多人可能不知道如何将本机的 CUDA 7.5 升级到 8.0。有时候开发者已经在使用 PaddlePaddle 0.10 版本的开发环境了,这时若需要编译0.9的版本复现以及修复一个 bug,肯定非常头痛:如何把 CUDA 从8.0降回到7.5,如何回滚编译环境? **问题难以复现** 我们在 GitHub 给开源项目提 issue 的时候,首先需要填的就是操作系统以及各种运行环境。这也是开发者的无奈之举,有些 bug 确实是在固定运行环境下才能复现。从用户的角度来讲,总被问这些问题很头疼,甚至有些用户是将程序运行在集群上的,并不清楚集群具体的环境。从开发者的角度来讲,用户程序运行环境非常多样,很难找到一摸一样的环境来复现 bug。 ### 解决方案 我们把 PaddlePaddle 的编译环境打包成一个镜像,称为“开发镜像”,里面涵盖了 PaddlePaddle 需要的所有编译工具。把编译出来的 PaddlePaddle 也打包成一个镜像,称为“生产镜像”,里面涵盖了 PaddlePaddle 运行所需的所有环境。每次 PaddlePaddle 发布新版本的时候都会发布对应版本的生产镜像以及开发镜像。 这样不管开发者用的是 macOS,Linux 还是 Windows,只要安装了 Docker 都可以编译 PaddlePaddle。我们可以把这个开发镜像看作一个程序,以前大家用的是 CMake 和 Make 加上 GCC、Protobuf 编译器这些程序编译。现在用的是这个开发镜像编译。 PaddlePaddle 的旧版本基于 CMake,新版本基于开发镜像。请对比以下使用的命令行: ``` cd paddle/build; cmake ..; make cd paddle; docker run -v $(pwd):/paddle paddlepaddle/paddle:0.10.0rc2-dev ``` 第一行是基于 CMake 的编译方法,用户需要手动配置所有的编译工具。第二行是基于容器的编译方法,用户只用安装 Docker 就能够一键编译了(“-v”起的作用是将本地的当前文件夹也就是 PaddlePaddle repo 的根目录挂载容器内,这样容器内就能看到并且编译 PaddlePaddle 了)。 下面介绍如何使用开发环境镜像。考虑我们完成日常工作的方式,开发者可能会使用自己的笔记本/台式机安装有 GPU 的工作站。 图1  基于开发镜像的PaddlePaddle开发、编译流程 图1 基于开发镜像的 PaddlePaddle 开发、编译流程 开发的基本思路是:使用 git clone 下载 PaddlePaddle 源码到开发机或本地,然后就可以使用自己最喜欢的编辑器(如 IntelliJ/Emacs)开始代码编写工作。编译和测试则可以使用 docker run -v 挂载 PaddlePaddle 源代码目录到 Docker 开发环境镜像。这样就可以在 Docker 容器中直接编译和测试刚才修改的代码。我们将在后面的实战部分举例说明。 我们回顾一下看看痛点能否被解决: **编译工具难配置**:由于编译工具被打包成了一个镜像,配置编译环境的任务被最熟悉编译环境的开发者一次性完成了,其他开发者不需要重复这个任务,只需要一键运行编译命令就可以。开发者对编译通过可以有充分的信心:容器镜像每次被运行的是候环境是完全一致的,每个新版本发布的开发镜像都通过了编译测试。 **编译工具不断变化**:编译环境不断的变化也不是问题了,每次发布新版本我们都会发布对应的开发镜像。切换编译的版本只用切换镜像即可。之前提到的 CUDA 版本的问题也得到了解决,因为 CUDA 直接被打包在开发和生产镜像中,在接下来的一节“在容器中使用 GPU”中我们会详细介绍 CUDA 相关的细节。 **Bug 难以复现**:因为 PaddlePaddle 唯一官方支持的版本是 Docker 镜像,去掉了编译环境以及运行环境这两大变量,让 bug 复现变得简单很多。 ### 实战演练 这里我们通过介绍 PaddlePaddle 的实战演练来举例说明基于容器的 AI 系统开发流程。 - 制作 PaddlePaddle 开发镜像 PaddlePaddle 每次发布新版本都会发布对应的开发镜像供开发者直接使用。 生成 Docker 镜像的方式有两个,一个是直接把一个容器转换成镜像;另一个是创建 Dockerfile 并运行 docker build 指令按照 Dockerfile 生成镜像。第一个方法的好处是简单快捷,适合自己实验,可以快速迭代。第二个方法的好处是 Dockerfile 可以把整个生成流程描述很清楚,其他人很容易看懂镜像生成过程,持续集成系统也可以简单地复现这个过程。我们采用第二个方法,Dockerfile 位于 PaddlePaddle repo 的根目录,生成生产镜像只需要运行: ``` git clone https://github.com/PaddlePaddle/Paddle.git cd Paddle docker build -t paddle:dev . ``` docker build 这个命令的 -t 指定了生成的镜像的名字,这里我们用 paddle:dev。到此,PaddlePaddle 开发镜像就构建完毕了。 - 制作 PaddlePaddle 生产镜像 生产镜像的生成分为两步,第一步是运行: ``` docker run -v $(pwd):/paddle -e "WITH_GPU=OFF" -e "WITH_TEST=ON" paddle:dev ``` 以上命令会编译 PaddlePaddle,生成运行程序,以及生成创建生产镜像的 Dockerfile。所有生成的文件都在 build 目录下。`“WITH_GPU”`控制生成的生产镜像是否支持 GPU,`“WITH_TEST”`控制是否生成单元测试。 第二步是运行: ``` docker build -t paddle:prod -f build/Dockerfile . ``` 以上命令会按照生成的 Dockerfile 把生成的程序拷贝到生产镜像中并做相应的配置,最终生成名为 paddle:prod 的生产镜像。 - 运行单元测试 运行以下指令: ``` docker run -it -v $(pwd):/paddle paddle:dev bash -c "cd /paddle/build && ctest" ``` - 训练模型 使用 PaddlePaddle 做模型训练也是非常容易的: ``` docker run -it -v $(pwd)/demo/mnist/api_train_v2.py:/mnist.py paddlepaddle/paddle:0.10.0rc2 python /mnist.py ``` 以上代码会下载并运行 paddlepaddle/paddle:0.10.0rc2 镜像。其中`api_train_v2.py`是 PaddlePaddle repo 中的通过 mnist 数据集训练数字识别的神经元网络代码,我们把它挂载到 /mnist.py,并在容器启动时执行“python /mnist.py”进行训练。 以上的命令运行的是 CPU 版本的 PaddlePaddle。如果要运行 GPU 版本的 PaddlePaddle 请在装了 Nvidia GPU 的机器上执行: ``` nvidia-docker run -it -v $(pwd)/demo/mnist/api_train_v2.py:/mnist.py paddlepaddle/paddle:0.10.0rc2-gpu python /mnist.py ``` 可以看到两行指令的第一个区别是 docker 变成了 nvidia-docker,这个我们会在下一节详细说明。另外的区别是镜像名字变成了 paddlepaddle/paddle:0.10.0rc2-gpu。PaddlePaddle 会同时发布 CPU 版本和 GPU 版本的镜像,GPU 版本既可以跑 GPU 又可以跑 CPU,但是镜像尺寸会比 CPU 版大很多,所以我们分别发布它们。 - 模型打包 模型训练完毕只是 AI 系统开发的一个阶段性成果,要完成整个开发流程还需要把模型打包,发布到线上服务用户。打包的过程是将预测的代码存放到生产镜像中,生成线上使用的镜像。因为这个镜像是基于生产镜像的,可以保证线上预测结果于线下训练结果的一致性。最后,打包在镜像中的 AI 应用非常利于 Kubernetes 这样的集群管理软件启动和调度。 ### 在容器中使用 Nvidia GPU AI 系统需要强大的计算量,GPU 运算很自然地成为了 AI 系统的核心。现在 Nvidia 的 GPU 在数值计算领域一家独大,所以这里我们只讨论 Nvidia GPU 在容器中的使用。因为容器技术是基于 Linux Control Groups(CGroups)的,而 CGroups 对于设备是有原生支持的,所以让容器支持一个 GPU 设备应该是一件很容易的事情。让事情变得复杂的原因来自 Nvidia GPU 的驱动:一般的设备驱动只有一个 kernel object(ko)文件,只要在宿主机上安装驱动,ko 文件就会自动被载入内核。但是 Nvidia GPU 的驱动除了 ko 文件之外还有一个 shared object(so)文件。这个文件是用户层的程序,需要在容器内用户程序运行时被动态加载。另外,ko 文件的版本必须与 so 文件版本一摸一样。要在容器中找到 so 文件,我们很自然地想到把 so 文件打包到镜像里这个方法,但是在生成镜像的时候我们并不知道运行镜像的机器的驱动是什么版本的,所以无法预先打包对应版本的 so 文件。另一个方法是运行容器的时候自动找到宿主系统中的 so 文件并挂载进来。这是可行的,但是 so 文件安装路径多种多样,与驱动的安装方式有关,十分难找。这时候 nvidia-docker 就出现了,为我们把这些细节问题隐藏了起来。使用起来非常的简单,只用把 docker 换成 nvidia-docker 即可。 熟悉 GPU 计算的朋友们都知道,还有一个环节需要考虑:CUDA 库和 cuDNN 库。CUDA 库是使用 CUDA 架构做科学计算所需要的,它包含编译时需要的头文件以及运行时需要的 so 文件。cuDNN 是专门为深度学习设计的科学计算库,也是包含头文件与 so 文件。它们都有很多版本,并且编译时的头文件版本必须与运行时的 so 文件版本一致。 如果不用容器,这两个库挺让人头疼,比如自己编译好的程序往往拿到别人的机器上就因为版本不一致而不能用了。而用容器的方法运行就不会有这个问题,如之前所提到的,在生成开发镜像的时候我们把 CUDA 和 cuDNN 库以及所需的工具都打包了进去,在运行时也打包了对应版本的 so 文件,所以不会出现版本不一致的。其实运行 GPU 镜像的时候,宿主机器只用安装 Nvidia 驱动就可以,是不需要安装 CUDA 或者 cuDNN 的。 ### Q&A **基于容器的开发方式怎么使用 IDE?** 在首次使用 Docker 编译 PaddlePaddle 之后,Paddle 会使用 CMake 自动下载和编译 Paddle 所依赖的第三方库。第三方库会下载到项目的`third_party`目录中。 通常情况下,IDE 如果要完成自动补全功能,有两种方式。一种是使用字符串匹配的方式解析(譬如 ctags),另一种是在本地真正的做词法分析。无论是基于字符串匹配模式还是本地组做词法分析模式的自动补全,使用容器方式开发都可以很好的支持。因为 PaddlePaddle 在编译时已经将第三方库下载到本地,使用 IDE 的时候只要将这些第三方库的头文件放入 IDE 搜索路径中,就可以完成自动补全的配置。 以 Qt Creator 这个开源轻量级 C++ IDE 为例,将 PaddlePaddle 作为现有项目导入,并将第三方库路径作为 include 路径导入后,即可以使用 Qt Creator 的代码补全功能。 **每一次编译必须从头开始吗?** 因为编译的时候,所有生成的中间文件都保存在宿主文件系统里的 build 目录下,下一次编译仍然可以使用这些中间文件的,所以每一次编译并非从头开始。 **我已经在我的机器上配置好了多个环境:不同 GCC 版本,ARM 架构的 cross-compile 环境,换到基于容器的编译方式好像很麻烦?** 是的,把本地已经设置好的编译环境转换成新的肯定比继续使用已经配置好的环境麻烦。基于容器的编译方式只要设定好了一次,就非常容易发布出去分享给别人,自己重装系统后也方便使用。 另外,跨操作系统,跨架构的 cross compile 配置起来按编译环境的不同,难易程度也不一样。有些设定起来很容易,有些很复杂甚至不支持。因为容器能够运行其他架构的镜像,所以可以直接在编译的目标操作系统与架构的容器里配置编译环境,就绕过了跨操作系统跨架构,不需要花时间配置 cross compiler 了。