网络推理 | PyTorch vs LibTorch:谁更快?

2021-07-05 16:33:48 浏览数 (1)

作者:知乎—Gemfield 侵删

地址:https://zhuanlan.zhihu.com/p/363319763

在Gemfield:部署PyTorch模型到终端(https://zhuanlan.zhihu.com/p/54665674)一文中,我们知道在实际部署PyTorch训练的模型时,一般都是要把模型转换到对应的推理框架上。其中最常见的就是使用TorchScript,如此以来模型就可在LibTorch C 生态中使用了,从而彻底卸掉了Python环境的负担和掣肘。

最近,在MLab团队内部,我们发现一个TorchScript模型在LibTorch中的推理速度变得很慢:推理时间大约是PyTorch的1.8倍左右。这就让Gemfield很尴尬了,C 程序居然比python程序还要慢(虽然python程序的大部分也是在C 中运行)。因为我们都拥有一个共识:没有特殊优化的情况下,C 程序一定不会比Python慢!特别是对于LibTorch来说,因为省却了用户API层面的内存布局转换(从普通C 类型的内存布局到python object的内存布局的转换),因此LibTorch比PyTorch速度快2%到10%才是预期中的事情。

尴尬还是次要的,性能测试过不了关,后续很多工作都不能进行。Gemfield来调查这个问题的思路如下:

  1. PyTorch vs LibTorch的时候,性能测试报告中的时间数据可靠吗?
  2. PyTorch vs LibTorch的时候,这两者基于的代码版本一样吗?
  3. PyTorch vs LibTorch的时候,硬件、Nvidia驱动、软件栈一样吗?
  4. PyTorch vs LibTorch的时候,推理进程对系统资源的占用情况一样吗?
  5. PyTorch vs LibTorch的时候,网络对于不同的input size有什么不一样的推理速度吗?
  6. PyTorch vs LibTorch的时候,有什么profiler工具吗?
  7. PyTorch vs LibTorch的时候,有什么特别的环境变量设置的不一样吗?
  8. PyTorch vs LibTorch的时候,程序所链接的共享库一样吗?
  9. PyTorch vs LibTorch的时候,这两者所使用的编译选项一样吗?
  10. 在解决类似的LibTorch性能问题时,我们能为大家提供什么便利呢?

01

PyTorch vs LibTorch:时间数据

一个初入CUDA生态的人,最容易犯的错误之一就是测试cuda代码的执行时间:

代码语言:javascript复制
start_time = time.time()
outputs = civilnet(img)
print('gemfield model_time: ',time.time()-start_time)

上述代码是错误的。因为cuda的异步执行特点,如果要测量完整的cuda运算时间,我们需要加上torch.cuda.synchronize() 同步API,如下所示:

代码语言:javascript复制
torch.cuda.synchronize()

start_time = time.time()
outputs = civilnet(img)
torch.cuda.synchronize()
print('gemfield model_time: ',time.time()-start_time)

在C 代码中同理:

代码语言:javascript复制
#include <chrono>#include <c10/cuda/CUDAStream.h>#include <ATen/cuda/CUDAContext.h>...
start = std::chrono::system_clock::now();
output = civilnet->forward(inputs).toTensor();
at::cuda::CUDAStream stream = at::cuda::getCurrentCUDAStream();
AT_CUDA_CHECK(cudaStreamSynchronize(stream));
forward_duration = std::chrono::system_clock::now() - start;
msg = gemfield_org::format(" time: %f",  forward_duration.count() );
std::cout<<"civilnet->forward(inputs).toTensor() "<<msg<<std::endl;

这样以来,程序输出的时间就可以反应真实的网络运算时间。如果嫌上述代码有点复杂的话,可以设置一个环境变量来近似等价:CUDA_LAUNCH_BLOCKING :

代码语言:javascript复制
export CUDA_LAUNCH_BLOCKING = 1

设置了这个环境变量后,程序中的CUDA代码就是同步执行的。你就不需要再添加上述cuda同步API了。经过这一步检查,确认性能报告中的数据是准确的。

02

PyTorch vs LibTorch:代码版本

在DeepVAC生态下,DeepVAC封装PyTorch,Libdeepvac封装LibTorch,且基于PyTorch仓库的同一个版本:1.8.1。经过这一步检查,确认版本没有问题。

03

PyTorch vs LibTorch:硬件、Nvidia驱动、软件栈

多亏了MLab HomePod这一迄今为止最先进的容器化PyTorch训练环境,我们有了一致的软件栈,具体来说就是:

  • 宿主机OS:Ubuntu 20.04
  • 软件环境:MLab HomePod 1.0
  • CPU:Intel(R) Core(TM) i9-9820X CPU @ 3.30GHz
  • GPU:NVIDIA GTX 2080ti
  • GPU驱动:NVIDIA-SMI 450.102.04 Driver Version: 450.102.04 CUDA Version: 11.0

经过这一步检查,软硬件栈也是一致的。

04

PyTorch vs LibTorch:进程对系统资源的占用

Gemfield主要看的是AI推理进程对如下系统资源的使用:

  • CPU利用率
  • 内存
  • GPU利用率
  • 显存
  • 该进程的线程数

模型1

模型2

其中的python jit指的是使用python代码加载TorchScript模型:

代码语言:javascript复制
civilnet = torch.jit.load("resnet50.pt")

嗯,对比之下,发现PyTorch 和LibTorch的资源使用情况明显不同。这里的现象总结如下:不知怎么的,相比LibTorch,PyTorch能使用更多的系统资源!

05

PyTorch vs LibTorch:网络的不同大小的输入

Gemfield使用224x224、640x640、1280x720、1280x1280作为输入尺寸,测试中观察到的现象总结如下:

  • 在不同的尺寸上,Gemfield观察到LibTorch的速度比PyTorch都要慢;
  • 输出尺寸越大,LibTorch比PyTorch要慢的越多。

06

PyTorch vs LibTorch:使用PyTorch profiler工具

PyTorch提供了内置的profiler工具,python和C 中都可以使用。

python代码如下:

代码语言:javascript复制
with torch.profiler.profile(
    activities=[
        torch.profiler.ProfilerActivity.CPU,
        torch.profiler.ProfilerActivity.CUDA],

    # In this example with wait=1, warmup=1, active=2,
    # profiler will skip the first step/iteration,
    # start warming up on the second, record
    # the third and the forth iterations, by gemfield.
    schedule=torch.profiler.schedule(
        wait=1,
        warmup=1,
        active=2),
      on_trace_ready=torch.profiler.tensorboard_trace_handler('./gemfield')
    ) as p:
        for iter in range(N):
            your_code_here
            # send a signal to the profiler that the next iteration has started
            p.step()

C 中如下:

代码语言:javascript复制
//文件名很重要
torch::autograd::profiler::RecordProfile guard("gemfield/gemfield.pt.trace.json");

写出的文件可以使用tensorboard打开:

代码语言:javascript复制
gemfield@ThinkPad-X1C:~$ tensorboard --logdir gemfield
TensorFlow installation not found - running with reduced feature set.
I0409 21:30:59.472155 140264193779264 plugin.py:85] Monitor runs begin
I0409 21:30:59.472792 140264193779264 plugin.py:100] Find run gemfield under /home/gemfield/gemfield
I0409 21:30:59.477127 140264193779264 plugin.py:281] Load run gemfield
I0409 21:30:59.792165 140264193779264 plugin.py:285] Run gemfield loaded
I0409 21:30:59.799130 140264185386560 plugin.py:117] Add run gemfield
Serving TensorBoard on localhost; to expose to the network, use a proxy or pass --bind_all
TensorBoard 2.3.0 at http://localhost:6006/ (Press CTRL C to quit)

从输出的log可以看到我们需要在浏览器打开地址:http://localhost:6006/,如下图所示:

overview

operator

GPU kernel

trace

现象总结如下:没有看到明显瓶颈。整个推理下来,LibTorch是一种均匀的慢——在所有的网络算子上,LibTorch都比PyTorch更慢。

07

PyTorch vs LibTorch:特别的环境变量

线程数

在类似问题的github issue中,Gemfield先后看到有人提到了:

  • OMP_NUM_THREADS
  • at::init_num_threads()

Gemfield尝试如下:

设置环境变量OMP_NUM_THREADS(会影响C 中的at::get_num_threads()):

代码语言:javascript复制
#从4到16
export OMP_NUM_THREADS = 8

另外,C 代码也设置不同的参数进行验证:

代码语言:javascript复制
at::init_num_threads();
//从4到16
at::set_num_threads(16);
//从4到16
at::set_num_interop_threads(16);

std::cout<<"civilnet thread num: "<<at::get_num_threads()<<" | "<<at::get_num_interop_threads()<<std::endl;

在Gemfield当前的版本中,这个线程数从4到16都不影响网络都推理速度。排除此项。

cudnn

torch.backends.cudnn.deterministic和torch.backends.cudnn.benchmark这两个参数是True还是False影响某些模型的性能:

  • cudnn.benchmark = False & cudnn.deterministic=True 的时候,会使用cudnn的默认算法实现;
  • cudnn.benchmark = False/True & cudnn.deterministic=False 的时候,cudnn会选择自认为的最优算法。

用C 代码进行实验:

代码语言:javascript复制
#include <ATen/ATen.h>#include <ATen/Parallel.h>......
std::cout<<"userEnabledCuDNN: "<<at::globalContext().userEnabledCuDNN()<<std::endl;
std::cout<<"userEnabledMkldnn: "<<at::globalContext().userEnabledMkldnn()<<std::endl;
std::cout<<"benchmarkCuDNN: "<<at::globalContext().benchmarkCuDNN()<<std::endl;
std::cout<<"deterministicCuDNN: "<<at::globalContext().deterministicCuDNN()<<std::endl;

at::globalContext().setUserEnabledCuDNN(true);
at::globalContext().setUserEnabledMkldnn(true);
at::globalContext().setBenchmarkCuDNN(true);
at::globalContext().setDeterministicCuDNN(true);

发现在不同程度上修改了默认值后,性能反而会降低...。排除此项。

08

PyTorch vs LibTorch:程序链接的共享库

通过使用ldd命令,Gemfield观察到了PyTorch所链接的共享库和LibTorch所链接的共享库的区别:

  • intel mkl:pytorch为conda安装的动态库,LibTorch(libdeepvac版)为静态库:
代码语言:javascript复制
 Found a library with BLAS API (mkl). Full path: (
 $PREFIX/lib/libmkl_intel_lp64.so
 $PREFIX/lib/libmkl_gnu_thread.so;
 $PREFIX/lib/libmkl_core.so;-fopenmp;
 /usr/lib/x86_64-linux-gnu/libpthread.so;
 /usr/lib/x86_64-linux-gnu/libm.so;
 /usr/lib/x86_64-linux-gnu/libdl.so)
  • libcudnn:pytorch为libcudnn_static.a静态库,LibTorch(libdeepvac版)为动态库。

其它的库依赖都一模一样,Gemfield决定更换下试试。凭借着MLab HomePod和libdeepvac项目,我们可以通过cmake命令调整一下参数,就转而让C 代码去链接pytorch所使用的共享库:

代码语言:javascript复制
cmake -DUSE_MKL=ON -DUSE_CUDA=ON -DCMAKE_BUILD_TYPE=Release -DCMAKE_PREFIX_PATH="/opt/public/airlock/opencv4deepvac;/opt/conda/lib/python3.8/site-packages/torch/" -DCMAKE_INSTALL_PREFIX=../install ..

由于这样操作还引入了下面小节中的变量,所以这里就不总结了,我们继续......

09

PyTorch vs LibTorch:编译选项

Gemfield通过比对CI系统上这两者的编译日志,试图发现它俩是否在release mode、-O3等常见编译优化选项上有区别——结果发现完全相同。但是,山重水复疑无路,柳暗花明又一村。Gemfield发现了其它方面的区别,这些区别体现在以下方面:

  • 是否使用KINETO;
  • nvcc flag少了一个参数:-Xfatbin;-compress-all;
  • CAFFE2_USE_MSVC_STATIC_RUNTIME: OFF/ON
  • 是否使用了magma_v2;
  • USE_OBSERVERS : ON/OFF
  • USE_DISTRIBUTED : ON/OFF

这些不同的地方,有的一眼看过去就毫不相关,有些...却是答案所在。还是和上述步骤一样,凭借着MLab HomePod和libdeepvac项目,我们可以通过cmake命令调整一下参数,就转而让C 代码去链接pytorch所使用的共享库:

代码语言:javascript复制
cmake -DUSE_MKL=ON -DUSE_CUDA=ON -DCMAKE_BUILD_TYPE=Release -DCMAKE_PREFIX_PATH="/opt/public/airlock/opencv4deepvac;/opt/conda/lib/python3.8/site-packages/torch/" -DCMAKE_INSTALL_PREFIX=../install ..

如此以来,PyTorch代码和libtorch代码所使用的底层库都一模一样(包括其当初的编译选项),这样如果还出现性能差异的话,我只能把它归咎为libtorch c frontend层面的bug了。

那么经过上面的调试,我们带来了什么结果呢?这是性能测试报告:

https://github.com/DeepVAC/libdeepvac/issues/30

在CUDA上,对于ResNet50,在224x224的输入上,LibTorch碾压了PyTorch,而在其它三种输入上,LibTorch都略慢于PyTorch;而在另外一种模型上,LibTorch在小尺寸上同样碾压PyTorch,但在大尺寸输入上被PyTorch碾压...这也是到目前为止Gemfield最大的疑惑。

在CPU上,对于ResNet50,不论输入的大小是多少,LibTorch都碾压了PyTorch。而且输入越小,碾压态势越明显。

考虑到CUDA和CPU设备上不同的表现以及指标,Gemfield猜测:在LibTorch的调用栈上,C frontend无意或有意的多设置了或者少设置了和CUDA显存相关的配置,这个设置限制了进程对CUDA显存的使用。

10

PyTorch vs LibTorch:我们可以为你做什么?

要跑出来benchmark相关的报告需要引入非常多的琐碎工作,而且经常会有所疏漏,甚至还有未知领域。有鉴于此,我们把这个benchmark测试代码封装为DeepVAC中的python模块和libdeepvac项目中的test_resnet_benchmark.cpp。为了方便LibTorch用户调试性能问题,我们引入了如下的步骤。依据此步骤,你可以最大限度的排除多余的变量,从而使得性能变化只取决你的硬件和驱动版本,从而加速问题调查。

PyTorch resnet50 benchmark步骤

  • 部署MLab HomePod(https://github.com/DeepVAC/MLab);
  • HomePod上安装DeepVAC Python包(https://github.com/DeepVAC/deepvac):pip install deepvac;
  • 下载预训练的ResNet50模型(比如:https://download.pytorch.org/models/resnet50-0676ba61.pth):
  • HomePod上运行benchmark命令:
代码语言:javascript复制
python -m deepvac.syszux_resnet benchmark <pretrained_model.pth> <your_img.jpg>

上述命令默认会使用CUDA设备,如果CUDA找不到(本来就没有,或者设置相关环境变量),就会使用CPU;

  • 为LibTorch生成TorchScript模型:
代码语言:javascript复制
python -m deepvac.syszux_resnet test <pretrained_model.pth> <your_img_directory>

默认会生成gemfield_script.pt文件。

LibTorch resnet50 benchmark步骤

  • 部署MLab HomePod;
  • HomePod上克隆libdeepvac项目:
代码语言:javascript复制
git clone https://github.com/DeepVAC/libdeepvac
  • 编译:https://github.com/DeepVAC/libdeepvac#benchmark
  • HomePod上运行benchmark程序:
代码语言:javascript复制
#CUDA
./bin/test_resnet_benchmark cuda:0 <gemfield_script.pt> <your_img.jpg>

#CPU
./bin/test_resnet_benchmark cpu <gemfield_script.pt> <your_img.jpg>

0 人点赞