Como fazer amigos PyTorch e C ++. Usando o TorchScript

Há cerca de um ano, os desenvolvedores do PyTorch introduziram a comunidade TorchScript , uma ferramenta que permite criar uma solução alienável a partir de um pipeline em python com alguns cliques do mouse que podem ser incorporados em um sistema C ++. Abaixo, compartilho a experiência de seu uso e tento descrever as armadilhas encontradas nesse caminho. Darei especial atenção à implementação do projeto no Windows, porque, embora a pesquisa em ML geralmente seja feita no Ubuntu, a solução final é frequentemente (de repente!) Necessária nas "janelas".


O código de exemplo para exportar um modelo e um projeto C ++ usando o modelo pode ser encontrado no repositório no GitHub .




Os desenvolvedores do PyTorch não são enganados. A nova ferramenta realmente permite transformar um projeto de pesquisa no PyTorch em código incorporado em um sistema C ++ em alguns dias úteis e com alguma habilidade mais rapidamente.


O TorchScript apareceu no PyTorch versão 1.0 e continua a evoluir e mudar. Se a primeira versão de um ano atrás era cheia de bugs e era mais experimental, a versão atual 1.3, pelo menos no segundo ponto, é visivelmente diferente: você não pode mais chamá-la de experimental, é bastante adequada para uso prático. Vou me concentrar nela.


No coração do TorchScript está o seu próprio compilador independente (sem Python) de uma linguagem semelhante a python, além de ferramentas para converter nele um programa escrito em Python e PyTorch, métodos para salvar e carregar os módulos resultantes e uma biblioteca para usá-los em C ++. Para funcionar, você precisará adicionar várias DLLs ao projeto com um peso total de cerca de 70 MB (para Windows) para trabalhar na CPU e 300 MB na versão da GPU. O TorchScript suporta a maioria dos recursos do PyTorch e os principais recursos da linguagem python. Mas bibliotecas de terceiros, como OpenCV ou NumPy, terão que ser esquecidas. Felizmente, muitas funções do NumPy têm um analógico no PyTorch.


Converter pipeline para o modelo PyTorch no TorchScript


O TorchScript oferece duas maneiras de converter o código Python em seu formato interno: rastreamento e script (rastreamento e script). Por que dois? Não, é claro, é claro, que dois são melhores que um ...



Mas, no caso desses métodos, verifica-se, como no aforismo conhecido, sobre os desvios da esquerda e da direita: ambos são piores. Bem, o mundo não é perfeito. Apenas em uma situação específica, você precisa escolher a que é mais adequada.


O método de rastreamento é muito simples. Uma amostra de dados é obtida (geralmente inicializada por números aleatórios), enviada para a função ou método da classe que nos interessa, e o PyTorch constrói e armazena o gráfico de cálculo da mesma maneira que costuma fazer ao treinar uma rede neural. Voila - o script está pronto:


import torch import torchvision model = torchvision.models.resnet34(pretrained = True) model.eval() sample = torch.rand(1, 3, 224, 224) scripted_model = torch.jit.trace(model, sample) 

O exemplo acima produz um objeto da classe ScriptModule. Pode ser salvo


 scripted_model.save('my_script.pth') 

e carregue-o em um programa C ++ (mais sobre isso abaixo ) ou no código Python, em vez do objeto original:


Exemplo de código Python usando um modelo salvo
 import cv2 from torchvision.transforms import Compose, ToTensor, Normalize transforms = Compose([ToTensor(), Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225])]) img = cv2.resize(cv2.imread('pics/cat.jpg'), (224,224)) img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) x = transforms(img).unsqueeze(0) # add batch dimension scripted_model = torch.jit.load('my_script.pth') y = scripted_model(x) print(y[0].argmax(), y[0][y[0].argmax()]) 

 tensor(282) tensor(12.8130, grad_fn=<SelectBackward>) 

O ScriptModule resultante pode aparecer em qualquer lugar onde nn.Module é comumente usado.


Da maneira descrita, é possível rastrear instâncias da classe e funções nn.Module (neste último caso, é torch._C.Function uma instância da classe torch._C.Function ).


Esse método (rastreamento) tem uma vantagem importante: dessa forma, você pode converter quase qualquer código Python que não use bibliotecas externas. Mas há uma desvantagem igualmente importante: para quaisquer ramificações, apenas a ramificação executada nos dados de teste será lembrada:


 def my_abs(x): if x.max() >= 0: return x else: return -x my_abs_traced = torch.jit.trace(my_abs, torch.tensor(0)) print(my_abs_traced(torch.tensor(1)), my_abs_traced(torch.tensor(-1))) 

 c:\miniconda3\lib\site-packages\ipykernel_launcher.py:2: TracerWarning: Converting a tensor to a Python boolean might cause the trace to be incorrect. We can't record the data flow of Python values, so this value will be treated as a constant in the future. This means that the trace might not generalize to other inputs! tensor(1) tensor(-1) 

Opa! Parece que não é isso que gostaríamos, certo? É bom que pelo menos uma mensagem de aviso (TracerWarning) seja emitida. Vale a pena prestar atenção a essas mensagens.


Aqui o segundo método vem em nosso auxílio - script:


 my_abs_script = torch.jit.script(my_abs) print(my_abs_script(torch.tensor(1)), my_abs_script(torch.tensor(-1))) 

 tensor(1) tensor(1) 

Viva, o resultado esperado é recebido! O script analisa recursivamente o código Python e o converte em código em sua própria linguagem. Na saída, também obtemos a classe ScriptModule (para módulos) ou torch._C.Function (para funções). Parece, aqui está, felicidade! Mas surge outro problema: a linguagem interna do TorchScript é fortemente tipada, diferentemente do Python. O tipo de cada variável é determinado pela primeira atribuição, o tipo dos argumentos da função por padrão é Tensor . Portanto, por exemplo, um padrão familiar


 def my_func(x): y = None if x.max() > 0: y = x return y my_func = torch.jit.script(my_func) 

O rastreamento falhará.


Um erro de rastreamento se parece com isso
 RuntimeError Traceback (most recent call last) <ipython-input-9-25414183a687> in <module>() ----> 1 my_func = torch.jit.script(my_func) d:\programming\3rd_party\pytorch\pytorch_ovod_1.3.0a0_de394b6\torch\jit\__init__.py in script(obj, optimize, _frames_up, _rcb) 1224 if _rcb is None: 1225 _rcb = _gen_rcb(obj, _frames_up) -> 1226 fn = torch._C._jit_script_compile(qualified_name, ast, _rcb, get_default_args(obj)) 1227 # Forward docstrings 1228 fn.__doc__ = obj.__doc__ RuntimeError: Variable 'y' previously has type None but is now being assigned to a value of type Tensor : at <ipython-input-8-75677614fca6>:4:8 def my_func(x): y = None if x.max() > 0: y = x ~ <--- HERE return y 

Vale ressaltar que, embora ocorra um erro quando o torch.jit.script chamado, o local que o causou no código com script também é indicado.


Mesmo pontos após constantes começam a desempenhar um papel:


 def my_func(x): if x.max() > 0: y = 1.25 else: y = 0 return y my_func = torch.jit.script(my_func) 

vai dar um erro
 RuntimeError Traceback (most recent call last) <ipython-input-10-0a5f18586763> in <module>() 5 y = 0 6 return y ----> 7 my_func = torch.jit.script(my_func) d:\programming\3rd_party\pytorch\pytorch_ovod_1.3.0a0_de394b6\torch\jit\__init__.py in script(obj, optimize, _frames_up, _rcb) 1224 if _rcb is None: 1225 _rcb = _gen_rcb(obj, _frames_up) -> 1226 fn = torch._C._jit_script_compile(qualified_name, ast, _rcb, get_default_args(obj)) 1227 # Forward docstrings 1228 fn.__doc__ = obj.__doc__ d:\programming\3rd_party\pytorch\pytorch_ovod_1.3.0a0_de394b6\torch\jit\__init__.py in _rcb(name) 1240 # closure rcb fails 1241 result = closure_rcb(name) -> 1242 if result: 1243 return result 1244 return stack_rcb(name) RuntimeError: bool value of Tensor with more than one value is ambiguous 

Porque é necessário escrever não 0 , mas 0. para que o tipo nos dois ramos seja o mesmo! Mimado, você sabe, com seu python!


Este é apenas o começo da lista de alterações que você precisa fazer no código python para que ele possa ser transformado com sucesso em um módulo TorchScript. Vou listar os casos mais comuns em mais detalhes posteriormente . Em princípio, não há ciência de foguetes aqui e seu código pode ser corrigido de acordo. Mas na maioria das vezes eu não quero consertar módulos de terceiros, incluindo os padrão da torchvision , e, como de costume, eles geralmente não são adequados para scripts.


Felizmente, ambas as tecnologias podem ser combinadas: o que está sendo roteirizado está sendo roteirizado e o que não está sendo roteirizado é o seguinte:


 class MyModule(torch.nn.Module): def __init__(self): super(MyModule, self).__init__() self.resnet = torchvision.models.resnet34(pretrained = True) #       torch.jit.script(my_module) #    -   resnet34. #     self.resnet  ScriptModule. self.resnet.eval() # NB:     !  -  ! self.resnet = torch.jit.trace(self.resnet, torch.rand((1,3,224,224), dtype=torch.float)) def forward(self, x): if x.shape[2] < 224 or x.shape[3] < 224: return torch.tensor(0) else: return self.resnet(x) my_module = MyModule() my_module = torch.jit.script(my_module) 

No exemplo acima, o rastreamento é usado para incluir um módulo que não é passível de script em um módulo em que não há rastreamento e scripts suficientes. Existe uma situação inversa. Por exemplo, se precisarmos fazer upload de um modelo para o ONNX, o rastreamento será usado. Mas o modelo rastreado pode incluir funções do TorchScript, para que a lógica que requer ramificações e loops possa ser implementada lá! Um exemplo é fornecido na documentação oficial do torch.onnx .


Os recursos fornecidos pelo PyTorch para a criação de módulos TorchScript são descritos em mais detalhes na documentação oficial e torch.jit . Em particular, não mencionei uma maneira conveniente de usar torch.jit.trace e torch.jit.script na forma de decoradores, sobre as peculiaridades da depuração de código de script. Isso e muito mais está na documentação.


Incluímos o modelo em um projeto C ++


Infelizmente, a documentação oficial é limitada a exemplos do formulário "adicione 2 tensores gerados usando torch.ones ". Eu preparei um exemplo de um projeto mais próximo da realidade que envia uma imagem do OpenCV para a rede neural e recebe os resultados na forma de um tensor de resposta, uma tupla de variáveis, uma imagem com resultados de segmentação.


Para o exemplo funcionar, você precisa de scripts de classificação salvos usando ResNet34 e segmentação usando DeepLabV3. Para preparar esses scripts, você precisa executar este bloco de notas jupyter .


Precisamos da biblioteca torchlib . Você pode obtê-lo de várias maneiras:


  1. Se você já possui o PyTorch instalado usando a pip install , pode encontrá-lo no diretório Python: <Miniconda3>\Lib\site-packages\torch ;
  2. Se você tiver o PyTorch compilado a partir do código-fonte, ele estará lá: <My Pytorch repo>\build\lib.win-amd64-3.6\torch ;
  3. Por fim, você pode baixar a biblioteca separadamente do pytorch.org escolhendo Idioma = C ++ e descompactar o arquivo.

O código C ++ é bastante simples. É necessário:


  1. Incluir arquivo de cabeçalho
     #include <torch/script.h> 
  2. Download do modelo
     torch::jit::script::Module module = torch::jit::load("../resnet34_infer.pth"); 
  3. Preparar dados
     torch::Tensor tensor = torch::from_blob(img.data, { img.rows, img.cols, 3 }, torch::kByte); 
  4. Função de forward chamada e resultado
     auto output = module.forward( { tensor } ) 
  5. Obtenha dados do resultado. Como fazer isso depende do que a rede neural retorna. By the way, no caso geral, ele também pode aceitar não apenas uma imagem, portanto, é melhor olhar para o código fonte de todo o exemplo , existem opções diferentes. Por exemplo, para obter dados de um tensor unidimensional do tipo float:
     float* data = static_cast<float*>(output.toTensor().data_ptr()); 
  6. Há mais uma sutileza. Não se esqueça de inserir o analógico with torch.no_grad() no código para não desperdiçar recursos no cálculo e armazenamento dos gradientes de que não precisamos. Infelizmente, este comando não pode ser incluído no script, portanto, você deve adicioná-lo ao código C ++:
     torch::NoGradGuard no_grad; 

Como criar um projeto usando o CMake é descrito no guia oficial . Mas o tópico do projeto no Visual Studio não é divulgado lá, então eu o descreverei com mais detalhes. Você precisará ajustar manualmente as configurações do projeto:


  1. Eu testei no Visual Studio 2017. Não posso dizer sobre outras versões.
  2. O conjunto de ferramentas v14.11 v141 deve estar instalado (marque a opção "VC++ 2017 version 15.4 v14.11 toolset" no instalador do VS).
  3. A plataforma deve ser x64 .
  4. Em General → Platform Toolset v141(Visual Studio 2017) General → Platform Toolset selecione v141(Visual Studio 2017)
  5. Em C/C++ → General → Additional Include Directories adicione <libtorch dir>\include
  6. No Linker → General → Additional Library Directories adicione <libtorch dir>\lib
  7. No Linker → Input → Additional Dependencies adicionais, adicione torch.lib; c10.lib torch.lib; c10.lib . Na Internet, eles escrevem que caffe2.lib ainda pode ser necessário, e para a GPU e algo mais de <libtorch dir>\lib , mas na versão atual, adicionar essas duas bibliotecas foi suficiente para mim. Talvez essa seja uma informação desatualizada.
  8. Eles também escrevem que você precisa definir C/C++ → Language → Conformance Mode = No , mas não vi a diferença.

Além disso, a variável __cplusplus NÃO deve ser declarada no projeto. Tentativa de adicionar a /Zc:__cplusplus resultará em erros de compilação no arquivo ivalue.h .


No projeto em anexo, as configurações do caminho (não apenas para o TorchLib, mas também para o OpenCV e CUDA) são transferidas para o arquivo props , antes da montagem, é necessário registrá-las lá de acordo com a sua configuração local. Isso, de fato, é tudo.


O que mais deve ter em mente


Se o processo descrito lhe pareceu muito simples, sua intuição não o enganou. Há várias nuances que precisam ser consideradas para converter um modelo PyTorch escrito em Python em TorchScript. Vou listar abaixo aqueles que tive que enfrentar. Eu já mencionei alguns, mas repito para coletar tudo em um só lugar.



  • O tipo de variáveis ​​transmitidas para a função é Tensor por padrão. Se em alguns casos (muito frequentes) isso for inaceitável, você precisará declarar os tipos manualmente usando anotações de tipo no estilo MyPy, algo como isto:

 def calc_letter_statistics(self, cls_preds: List[Tensor], cls_thresh: float)->Tuple[int, Tuple[Tensor, Tensor, Tensor]] 

ou mais:


 def calc_letter_statistics(self, cls_preds, cls_thresh): # type: (List[Tensor], float)->Tuple[int, Tuple[Tensor, Tensor, Tensor]] 

  • As variáveis ​​são fortemente digitadas e o tipo, se não especificado explicitamente, é determinado pela primeira atribuição. Construções familiares da forma x=[]; for ...: x.append(y) x=[]; for ...: x.append(y) terá que ser editado, porque no momento da atribuição [] compilador não pode descobrir qual tipo estará na lista. Portanto, você deve especificar o tipo explicitamente, por exemplo:

 from typing import List x: List[float] = [] 

ou (outro "por exemplo")


 from torch import Tensor from typing import Dict, Tuple, List x: Dict[int: Tuple[float, List[Tensor], List[List[int]]]] = {} 

  • No exemplo acima, são os nomes que precisam ser importados, pois esses nomes são costurados no código do TorchScript. Abordagem alternativa, aparentemente legal

 import torch import typing x: typing.List[torch.Tensor] = [] 

resultará em um construtor de tipo desconhecido digitando. Erro de lista ao criar scripts


  • Outro design familiar do qual você precisa participar:

 x = None if smth: x = torch.tensor([1,2,3]) 

Existem duas opções. Ou atribua o tensor as duas vezes (o fato de ter dimensões diferentes não é assustador):


 x = torch.tensor(0) if smth: x = torch.tensor([1,2,3]) 

e não se esqueça de procurar o que irá quebrar após essa substituição. Ou tente escrever honestamente:


 x: Optional[Tensor] = None if smth: x = torch.tensor([1,2,3]) 

mas com o uso adicional de x onde o tensor é esperado, provavelmente obteremos um erro: Esperava-se um valor do tipo 'Tensor' para o argumento 'x', mas encontrou o tipo 'Opcional [Tensor]'.


  • Não esqueça de escrever, por exemplo, x=0. durante a primeira tarefa x=0. em vez do habitual x=0 , etc., se a variável x deve ser do tipo float .


  • Se em algum lugar usamos a inicialização antiquada do tensor via x = torch.Tensor(...) , você terá que se separar e substituí-lo por uma versão mais nova por uma letra minúscula x = torch.tensor(...) . Caso contrário, durante o script, ele voará: Desconhecido builtin op: aten :: Tensor. Aqui estão algumas sugestões: aten :: tensor . Parece que eles até explicam qual é o problema e fica claro o que precisa ser feito. No entanto, é claro se você já sabe a resposta correta.


  • O código é roteirizado no contexto do módulo no qual torch.jit.script chamado. Portanto, se em algum lugar, nas entranhas da classe ou função com script, por exemplo, math.pow , você precisará adicionar import math ao módulo de compilação. E é melhor criar um script para a classe em que é declarada: usando o decorador @torch.jit.script ou declarando uma função adicional ao lado, que faz dele o ScriptModule. Caso contrário, obteremos uma mensagem de erro matemático de valor indefinido quando tentamos compilar uma classe a partir de um módulo no qual, aparentemente, a importação math foi feita.


  • Se em algum lugar você tiver uma construção no formato my_tensor[my_tensor < 10] = 0 ou semelhante, você receberá um erro enigmático ao my_tensor[my_tensor < 10] = 0 scripts:


     *aten::index_put_(Tensor(a!) self, Tensor?[] indices, Tensor values, bool accumulate=False) -> (Tensor(a!)):* *Expected a value of type 'Tensor' for argument 'values' but instead found type 'int'.* *aten::index_put_(Tensor(a!) self, Tensor[] indices, Tensor values, bool accumulate=False) -> (Tensor(a!)):* *Expected a value of type 'List[Tensor]' for argument 'indices' but instead found type 'List[Optional[Tensor]]'.* 

    O que você precisa é substituir o número pelo tensor: my_tensor[my_tensor < 10] = torch.tensor(0.).to(my_tensor.device) . E não se esqueça de a) sobre a correspondência dos tipos my_tensor e do tensor criado (neste caso, float) eb) sobre .to(my_tensor.device) . Se você esquecer o segundo, tudo será roteirizado, mas já no processo de trabalho com a GPU, você ficará chateado, que se parecerá com as palavras enigmáticas Erro CUDA: um acesso ilegal à memória foi encontrado , sem indicar onde ocorreu o erro!


  • Não esqueça que, por padrão, o nn.Module e, consequentemente, os modelos da torchvision são criados no "modo de trem" (você não vai acreditar, mas acontece que existe esse modo ). Nesse caso, o Dropout e outros truques do modo de trem são usados, que quebram o rastreio ou levam a resultados inadequados quando executados. Lembre-se de chamar model.eval() antes de model.eval() scripts ou rastrear.


  • Para funções e classes comuns, é necessário criar um script do tipo, para nn.Module - uma instância


  • Tentativa em um método com script de acessar uma variável global



 cls_thresh = 0.3 class MyModule(torch.nn.Module): ... x = r < cls_thresh ... 

resultará em um erro de script do formato python. O valor do tipo 'float' não pode ser usado como um valor . É necessário tornar a variável um atributo no construtor:


 cls_thresh = 0.3 class MyModule(torch.nn.Module): def __init__(self): ... self.cls_thresh = cls_thresh ... x = r < self.cls_thresh ... 

  • Outra sutileza surge se o atributo de classe for usado como um parâmetro de fatia:

 class FPN(nn.Module): def __init__(self, block, num_blocks, num_layers =5): ... self.num_layers = num_layers def forward(self, x): ... return (p3, p4, p5, p6, p7)[:self.num_layers] 

faz com que os índices de fatia de tupla de erro de script devem ser constantes inteiras . É necessário indicar que o atributo num_layers é constante e não será alterado:


 class FPN(nn.Module): num_layers: torch.jit.Final[int] def __init__(self, block, num_blocks, num_layers =5): ... 

  • Em alguns casos, onde o tensor costumava se encaixar normalmente, você precisa passar explicitamente o número:

 xx1 = x1.clamp(min=x1[i]) 

gera um erro ao Expected a value of type 'Optional[number]' for argument 'min' but instead found type 'Tensor'. scripts Expected a value of type 'Optional[number]' for argument 'min' but instead found type 'Tensor'. . Bem, aqui a partir da mensagem de erro, está claro o que fazer:


 xx1 = x1.clamp(min=x1[i].item()) 

Os problemas acima ocorrem ao rastrear. É por causa deles que geralmente não é possível compilar soluções prontas no TorchScript, e você precisa massagear o código-fonte por um longo tempo (se o código-fonte é apropriado para editar) ou usar o rastreamento. Mas o traço tem suas próprias nuances:


  • Construções do formulário não funcionam no rastreamento

 tensor_a.to(tensor_b.device) 

O dispositivo no qual o tensor é carregado é fixado no momento do rastreamento e não muda durante a execução. Esse problema pode ser superado parcialmente declarando o tensor um membro de nn.Module tipo Parameter . Em seguida, ao carregar o modelo, ele será inicializado no dispositivo especificado na função torch.jit.load .


Epílogo


Todos os itens acima, é claro, criam problemas. Mas o TorchScript permite combinar e enviar para a solução como um todo o modelo em si e o código Python que fornece pré e pós-processamento. Sim, e o tempo para preparar a solução para a compilação, apesar das dificuldades acima, é incomparavelmente menor que o custo de criação de uma solução, mas aqui o PyTorch oferece grandes vantagens, portanto o jogo vale a pena.


Source: https://habr.com/ru/post/pt480328/


All Articles