12.1. Compiladores e Interpretadores
Open the notebook in Colab
Open the notebook in Colab
Open the notebook in Colab
Open the notebook in SageMaker Studio Lab

Até agora, este livro se concentrou na programação imperativa, que faz uso de instruções como print,+ouif para alterar o estado de um programa. Considere o seguinte exemplo de um programa imperativo simples.

def add(a, b):
    return a + b

def fancy_func(a, b, c, d):
    e = add(a, b)
    f = add(c, d)
    g = add(e, f)
    return g

print(fancy_func(1, 2, 3, 4))
10
def add(a, b):
    return a + b

def fancy_func(a, b, c, d):
    e = add(a, b)
    f = add(c, d)
    g = add(e, f)
    return g

print(fancy_func(1, 2, 3, 4))
10
def add(a, b):
    return a + b

def fancy_func(a, b, c, d):
    e = add(a, b)
    f = add(c, d)
    g = add(e, f)
    return g

print(fancy_func(1, 2, 3, 4))
10

Python é uma linguagem interpretada. Ao avaliar fancy_func ele realiza as operações que compõem o corpo da função em sequência. Ou seja, ele avaliará e = add (a, b) e armazenará os resultados como a variável e, alterando assim o estado do programa. As próximas duas instruções f = add (c, d) e g = add (e, f) serão executadas de forma semelhante, realizando adições e armazenando os resultados como variáveis. Fig. 12.1.1 ilustra o fluxo de dados.

../_images/computegraph.svg

Fig. 12.1.1 Fluxo de dados em um programa imperativo.

Embora a programação imperativa seja conveniente, pode ser ineficiente. Por um lado, mesmo se a função add for repetidamente chamada emfancy_func, Python executará as três chamadas de função individualmente. Se elas forem executadas, digamos, em uma GPU (ou mesmo em várias GPUs), a sobrecarga decorrente do interpretador Python pode se tornar excessiva. Além disso, ele precisará salvar os valores das variáveis e ef até que todas as instruções em fancy_func tenham sido executadas. Isso ocorre porque não sabemos se as variáveis e ef serão usadas por outras partes do programa após as instruções e = add (a, b) e f = add (c, d) serem executadas.

12.1.1. Programação Simbólica

Considere a alternativa de programação simbólica, em que a computação geralmente é realizada apenas depois que o processo foi totalmente definido. Essa estratégia é usada por vários frameworks de aprendizado profundo, incluindo Theano, Keras e TensorFlow (os dois últimos adquiriram extensões imperativas). Geralmente envolve as seguintes etapas:

  1. Definir as operações a serem executadas.

  2. Compilar as operações em um programa executável.

  3. Fornecer as entradas necessárias e chamar o programa compilado para execução.

Isso permite uma quantidade significativa de otimização. Em primeiro lugar, podemos pular o interpretador Python em muitos casos, removendo assim um gargalo de desempenho que pode se tornar significativo em várias GPUs rápidas emparelhadas com um único thread Python em uma CPU. Em segundo lugar, um compilador pode otimizar e reescrever o código acima em print ((1 + 2) + (3 + 4)) ou mesmo print (10). Isso é possível porque um compilador consegue ver o código completo antes de transformá-lo em instruções de máquina. Por exemplo, ele pode liberar memória (ou nunca alocá-la) sempre que uma variável não for mais necessária. Ou pode transformar o código inteiramente em uma parte equivalente. Para ter uma ideia melhor, considere a seguinte simulação de programação imperativa (afinal, é Python) abaixo.

def add_():
    return '''
def add(a, b):
    return a + b
'''

def fancy_func_():
    return '''
def fancy_func(a, b, c, d):
    e = add(a, b)
    f = add(c, d)
    g = add(e, f)
    return g
'''

def evoke_():
    return add_() + fancy_func_() + 'print(fancy_func(1, 2, 3, 4))'

prog = evoke_()
print(prog)
y = compile(prog, '', 'exec')
exec(y)
def add(a, b):
    return a + b

def fancy_func(a, b, c, d):
    e = add(a, b)
    f = add(c, d)
    g = add(e, f)
    return g
print(fancy_func(1, 2, 3, 4))
10
def add_():
    return '''
def add(a, b):
    return a + b
'''

def fancy_func_():
    return '''
def fancy_func(a, b, c, d):
    e = add(a, b)
    f = add(c, d)
    g = add(e, f)
    return g
'''

def evoke_():
    return add_() + fancy_func_() + 'print(fancy_func(1, 2, 3, 4))'

prog = evoke_()
print(prog)
y = compile(prog, '', 'exec')
exec(y)
def add(a, b):
    return a + b

def fancy_func(a, b, c, d):
    e = add(a, b)
    f = add(c, d)
    g = add(e, f)
    return g
print(fancy_func(1, 2, 3, 4))
10
def add_():
    return '''
def add(a, b):
    return a + b
'''

def fancy_func_():
    return '''
def fancy_func(a, b, c, d):
    e = add(a, b)
    f = add(c, d)
    g = add(e, f)
    return g
'''

def evoke_():
    return add_() + fancy_func_() + 'print(fancy_func(1, 2, 3, 4))'

prog = evoke_()
print(prog)
y = compile(prog, '', 'exec')
exec(y)
def add(a, b):
    return a + b

def fancy_func(a, b, c, d):
    e = add(a, b)
    f = add(c, d)
    g = add(e, f)
    return g
print(fancy_func(1, 2, 3, 4))
10

As diferenças entre a programação imperativa (interpretada) e a programação simbólica são as seguintes:

  • A programação imperativa é mais fácil. Quando a programação imperativa é usada em Python, a maior parte do código é direta e fácil de escrever. Também é mais fácil depurar o código de programação imperativo. Isso ocorre porque é mais fácil obter e imprimir todos os valores de variáveis intermediárias relevantes ou usar as ferramentas de depuração integradas do Python.

  • A programação simbólica é mais eficiente e fácil de portar. Isso torna mais fácil otimizar o código durante a compilação, além de ter a capacidade de portar o programa para um formato independente do Python. Isso permite que o programa seja executado em um ambiente não-Python, evitando, assim, quaisquer problemas de desempenho em potencial relacionados ao interpretador Python.

12.1.2. Programação Híbrida

Historicamente, a maioria das estruturas de aprendizagem profunda escolhe entre uma abordagem imperativa ou simbólica. Por exemplo, Theano, TensorFlow (inspirado no último), Keras e CNTK formulam modelos simbolicamente. Por outro lado, Chainer e PyTorch adotam uma abordagem imperativa. Um modo imperativo foi adicionado ao TensorFlow 2.0 (via Eager) e Keras em revisões posteriores.

Ao projetar o Gluon, os desenvolvedores consideraram se seria possível combinar os benefícios de ambos os modelos de programação. Isso levou a um modelo híbrido que permite aos usuários desenvolver e depurar usando programação imperativa pura, ao mesmo tempo em que têm a capacidade de converter a maioria dos programas em programas simbólicos a serem executados quando o desempenho e a implantação de computação em nível de produto são necessários.

Na prática, isso significa que construímos modelos usando as classes HybridBlock ouHybridSequential e HybridConcurrent. Por padrão, eles são executados da mesma forma que as classes Block ouSequential e Concurrent são executadas na programação imperativa. HybridSequential é uma subclasse deHybridBlock (assim como Sequential é subclasse de Block). Quando a função hybridize é chamada, o Gluon compila o modelo na forma usada na programação simbólica. Isso permite otimizar os componentes de computação intensiva sem sacrificar a maneira como um modelo é implementado. Ilustraremos os benefícios abaixo, focalizando apenas modelos sequenciais e blocos (a composição concorrente funciona de forma análoga).

Como mencionado acima, PyTorch é baseado em programação imperativa e usa gráficos de computação dinâmica. Em um esforço para alavancar a portabilidade e eficiência da programação simbólica, os desenvolvedores consideraram se seria possível combinar os benefícios de ambos os modelos de programação. Isso levou a um torchscript que permite aos usuários desenvolver e depurar usando programação imperativa pura, ao mesmo tempo em que têm a capacidade de converter a maioria dos programas em programas simbólicos para serem executados quando o desempenho e a implantação de computação em nível de produto forem necessários.

O paradigma de programação imperativo agora é o padrão no Tensorflow 2, uma mudança acolhedora para aqueles que são novos na linguagem. No entanto, as mesmas técnicas de programação simbólica e gráficos computacionais subsequentes ainda existem no TensorFlow e podem ser acessados pelo decorador tf.function fácil de usar. Isso trouxe o paradigma de programação imperativo para o TensorFlow, permitindo que os usuários definissem funções mais intuitivas, depois as envolvessem e compilassem em gráficos computacionais automaticamente usando um recurso que a equipe do TensorFlow chama de autograph.

12.1.3. Híbrido-Sequencial

A maneira mais fácil de ter uma ideia de como a hibridização funciona é considerar redes profundas com várias camadas. Convencionalmente, o interpretador Python precisará executar o código para todas as camadas para gerar uma instrução que pode então ser encaminhada para uma CPU ou GPU. Para um único dispositivo de computação (rápido), isso não causa grandes problemas. Por outro lado, se usarmos um servidor avançado de 8 GPUs, como uma instância AWS P3dn.24xlarge, o Python terá dificuldade para manter todas as GPUs ocupadas. O interpretador Python de thread único torna-se o gargalo aqui. Vamos ver como podemos resolver isso para partes significativas do código, substituindo Sequential por HybridSequential. Começamos definindo um MLP simples.

from mxnet import np, npx
from mxnet.gluon import nn
from d2l import mxnet as d2l

npx.set_np()

# Factory for networks
def get_net():
    net = nn.HybridSequential()
    net.add(nn.Dense(256, activation='relu'),
            nn.Dense(128, activation='relu'),
            nn.Dense(2))
    net.initialize()
    return net

x = np.random.normal(size=(1, 512))
net = get_net()
net(x)
array([[ 0.16526186, -0.14005628]])

Ao chamar a função hybridize, podemos compilar e otimizar o cálculo no MLP. O resultado do cálculo do modelo permanece inalterado.

import torch
from torch import nn
from d2l import torch as d2l


# Factory for networks
def get_net():
    net = nn.Sequential(nn.Linear(512, 256),
            nn.ReLU(),
            nn.Linear(256, 128),
            nn.ReLU(),
            nn.Linear(128, 2))
    return net

x = torch.randn(size=(1, 512))
net = get_net()
net(x)
tensor([[ 0.0200, -0.0186]], grad_fn=<AddmmBackward>)
import tensorflow as tf
from tensorflow.keras.layers import Dense
from d2l import tensorflow as d2l


# Factory for networks
def get_net():
    net = tf.keras.Sequential()
    net.add(Dense(256, input_shape = (512,), activation = "relu"))
    net.add(Dense(128, activation = "relu"))
    net.add(Dense(2, activation = "linear"))
    return net

x = tf.random.normal([1,512])
net = get_net()
net(x)
<tf.Tensor: shape=(1, 2), dtype=float32, numpy=array([[1.3829005 , 0.34804747]], dtype=float32)>

Ao converter o modelo usando a função torch.jit.script, podemos compilar e otimizar a computação no MLP. O resultado do cálculo do modelo permanece inalterado.

net.hybridize()
net(x)
array([[ 0.16526186, -0.14005628]])

Isso parece bom demais para ser verdade: simplesmente designe um bloco como HybridSequential, escreva o mesmo código de antes e invoque hybridize. Assim que isso acontecer, a rede estará otimizada (faremos um benchmark do desempenho abaixo). Infelizmente, isso não funciona magicamente para todas as camadas. Dito isso, os blocos fornecidos pelo Gluon são, por padrão, subclasses de HybridBlock e, portanto, hibridizáveis. Uma camada não será otimizada se, em vez disso, herdar do Bloco.

net = torch.jit.script(net)
net(x)
tensor([[ 0.0200, -0.0186]], grad_fn=<AddmmBackward>)

Convertendo o modelo usando torch.jit.script Isso parece quase bom demais para ser verdade: escreva o mesmo código de antes e simplesmente converta o modelo usandotorch.jit.script. Assim que isso acontecer, a rede estará otimizada (faremos um benchmark do desempenho abaixo).

Anteriormente, todas as funções construídas no tensorflow eram construídas como um gráfico computacional e, portanto, JIT compilado por padrão. No entanto, com o lançamento do tensorflow 2.X e tensores eager, este não é mais o comportamento padrão. Podemos reativar essa funcionalidade com tf.function. tf.function é mais comumente usado como um decorador de função, no entanto, é possível chamá-lo diretamente como uma função Python normal, mostrada abaixo. O resultado do cálculo do modelo permanece inalterado.

net = tf.function(net)
net(x)
<tf.Tensor: shape=(1, 2), dtype=float32, numpy=array([[1.3829005 , 0.34804747]], dtype=float32)>

Converter o modelo usando tf.function nos dá um poder incrível no TensorFlow: escreva o mesmo código de antes e simplesmente converta o modelo usandotf.function. Quando isso acontece, a rede é construída como um gráfico computacional na representação intermediária MLIR do TensorFlow e é altamente otimizada no nível do compilador para uma execução rápida (faremos o benchmark do desempenho abaixo). Adicionar explicitamente a sinalização jit_compile = True à chamada tf.function() ativa a funcionalidade XLA (Álgebra Linear Acelerada) no TensorFlow. O XLA pode otimizar ainda mais o código compilado JIT em certas instâncias. A execução no modo gráfico é habilitada sem essa definição explícita, no entanto, o XLA pode tornar certas operações de álgebra linear grandes (na veia daquelas que vemos em aplicativos de aprendizado profundo) muito mais rápidas, particularmente em uma GPUenvironment.

12.1.3.1. Aceleração por Hibridização

Para demonstrar a melhoria de desempenho obtida pela compilação, comparamos o tempo necessário para avaliar net (x) antes e depois da hibridização. Vamos definir uma função para medir esse tempo primeiro. Será útil ao longo do capítulo à medida que nos propomos a medir (e melhorar) o desempenho.

#@save
class Benchmark:
    def __init__(self, description='Done'):
        self.description = description

    def __enter__(self):
        self.timer = d2l.Timer()
        return self

    def __exit__(self, *args):
        print(f'{self.description}: {self.timer.stop():.4f} sec')

Agora podemos chamar a rede duas vezes, uma com e outra sem hibridização.

net = get_net()
with Benchmark('Without hybridization'):
    for i in range(1000): net(x)
    npx.waitall()

net.hybridize()
with Benchmark('With hybridization'):
    for i in range(1000): net(x)
    npx.waitall()
Without hybridization: 0.7594 sec
With hybridization: 0.4405 sec

Como é observado nos resultados acima, depois que uma instância HybridSequential chama a função hybridize, o desempenho da computação é melhorado por meio do uso de programação simbólica.

#@save
class Benchmark:
    def __init__(self, description='Done'):
        self.description = description

    def __enter__(self):
        self.timer = d2l.Timer()
        return self

    def __exit__(self, *args):
        print(f'{self.description}: {self.timer.stop():.4f} sec')

Agora podemos chamar a rede duas vezes, uma com e outra sem torchscript.

net = get_net()
with Benchmark('Without torchscript'):
    for i in range(1000): net(x)

net = torch.jit.script(net)
with Benchmark('With torchscript'):
    for i in range(1000): net(x)
Without torchscript: 1.9314 sec
With torchscript: 0.8800 sec

Conforme observado nos resultados acima, depois que uma instância nn.Sequential é criada com o script da função torch.jit.script, o desempenho da computação é aprimorado com o uso de programação simbólica.

#@save
class Benchmark:
    def __init__(self, description='Done'):
        self.description = description

    def __enter__(self):
        self.timer = d2l.Timer()
        return self

    def __exit__(self, *args):
        print(f'{self.description}: {self.timer.stop():.4f} sec')

Agora podemos invocar a rede três vezes, uma vez executada avidamente, uma vez com execução em modo gráfico e novamente usando XLA compilado por JIT.

net = get_net()
with Benchmark('Eager Mode'):
    for i in range(1000): net(x)

net = tf.function(net)
with Benchmark('Graph Mode'):
    for i in range(1000): net(x)
Eager Mode: 0.6319 sec
Graph Mode: 0.2663 sec

Como é observado nos resultados acima, depois que uma instância tf.keras Sequential é scriptada usando a função tf.function, o desempenho da computação é melhorado por meio do uso de programação simbólica por meio da execução em modo gráfico em tensorflow.

12.1.3.2. Serialização

Um dos benefícios de compilar os modelos é que podemos serializar (salvar) o modelo e seus parâmetros no disco. Isso nos permite armazenar um modelo de maneira independente da linguagem de front-end de escolha. Isso nos permite implantar modelos treinados em outros dispositivos e usar facilmente outras linguagens de programação front-end. Ao mesmo tempo, o código geralmente é mais rápido do que o que pode ser alcançado na programação imperativa. Vamos ver o método export em ação.

net.export('my_mlp')
!ls -lh my_mlp*
-rw-r--r-- 1 jenkins jenkins 643K Dec 11 03:39 my_mlp-0000.params
-rw-r--r-- 1 jenkins jenkins 3.0K Dec 11 03:39 my_mlp-symbol.json

O modelo é decomposto em um arquivo de parâmetro (binário grande) e uma descrição JSON do programa necessário para executar o cálculo do modelo. Os arquivos podem ser lidos por outras linguagens de front-end suportadas por Python ou MXNet, como C ++, R, Scala e Perl. Vamos dar uma olhada na descrição do modelo.

!head my_mlp-symbol.json
{
  "nodes": [
    {
      "op": "null",
      "name": "data",
      "inputs": []
    },
    {
      "op": "null",
      "name": "dense3_weight",

As coisas são um pouco mais complicadas quando se trata de modelos que se assemelham mais ao código. Basicamente, a hibridização precisa lidar com o fluxo de controle e a sobrecarga do Python de uma maneira muito mais imediata. Além disso,

Ao contrário da instância Block, que precisa usar a função forward, para uma instância HybridBlock precisamos usar a funçãohybrid_forward.

Anteriormente, demonstramos que, após chamar a função hybridize , o modelo é capaz de atingir desempenho de computação superior e portabilidade. Observe, porém, que a hibridização pode afetar a flexibilidade do modelo, em particular em termos de fluxo de controle. Ilustraremos como projetar modelos mais gerais e também como a compilação removerá elementos Python espúrios.

class HybridNet(nn.HybridBlock):
    def __init__(self, **kwargs):
        super(HybridNet, self).__init__(**kwargs)
        self.hidden = nn.Dense(4)
        self.output = nn.Dense(2)

    def hybrid_forward(self, F, x):
        print('module F: ', F)
        print('value  x: ', x)
        x = F.npx.relu(self.hidden(x))
        print('result  : ', x)
        return self.output(x)

O código acima implementa uma rede simples com 4 unidades ocultas e 2 saídas. hybrid_forward recebe um argumento adicional - o móduloF. Isso é necessário porque, dependendo se o código foi hibridizado ou não, ele usará uma biblioteca ligeiramente diferente (ndarray ousímbolo) para processamento. Ambas as classes executam funções muito semelhantes e o MXNet determina automaticamente o argumento. Para entender o que está acontecendo, imprimimos os argumentos como parte da invocação da função.

net = HybridNet()
net.initialize()
x = np.random.normal(size=(1, 3))
net(x)
module F:  <module 'mxnet.ndarray' from '/var/lib/jenkins/miniconda3/envs/d2l-pt-release-1/lib/python3.8/site-packages/mxnet/ndarray/__init__.py'>
value  x:  [[-0.6338663   0.40156594  0.46456942]]
result  :  [[0.01641375 0.         0.         0.        ]]
array([[0.00097611, 0.00019453]])

A repetição do cálculo progressivo levará à mesma saída (omitimos os detalhes). Agora vamos ver o que acontece se invocarmos o método hybridize.

net.hybridize()
net(x)
module F:  <module 'mxnet.symbol' from '/var/lib/jenkins/miniconda3/envs/d2l-pt-release-1/lib/python3.8/site-packages/mxnet/symbol/__init__.py'>
value  x:  <_Symbol data>
result  :  <_Symbol hybridnet0_relu0>
array([[0.00097611, 0.00019453]])

Em vez de usar ndarray agora usamos o módulosymbol para F. Além disso, embora a entrada seja do tipo ndarray, os dados que fluem pela rede agora são convertidos para o tipo symbol como parte do processo de compilação. Repetir a chamada de função leva a um resultado surpreendente:

net(x)
array([[0.00097611, 0.00019453]])

12.1.4. Resumo

  • A programação imperativa torna mais fácil projetar novos modelos, pois é possível escrever código com fluxo de controle e a capacidade de usar uma grande parte do ecossistema de software Python.

  • A programação simbólica requer que especifiquemos o programa e o compilemos antes de executá-lo. O benefício é um desempenho aprimorado.

  • MXNet é capaz de combinar as vantagens de ambas as abordagens conforme necessário.

  • Modelos construídos pelas classes HybridSequential eHybridBlock são capazes de converter programas imperativos em programas simbólicos chamando o método hibridizar.

12.1.5. Exercícios

  1. Projete uma rede usando a classe HybridConcurrent. Como alternativa, olhe em Redes com Concatenações Paralelas (GoogLeNet) para uma rede para compor.

  2. Adicione x.asnumpy () à primeira linha da função hybrid_forward da classe HybridNet nesta seção. Execute o código e observe os erros que encontrar. Por que eles acontecem?

  3. O que acontece se adicionarmos o fluxo de controle, ou seja, as instruções Python if efor na função hybrid_forward?

  4. Revise os modelos de seu interesse nos capítulos anteriores e use a classe HybridBlock ou HybridSequential para implementá-los.

Discussions

Um dos benefícios de compilar os modelos é que podemos serializar (salvar) o modelo e seus parâmetros no disco. Isso nos permite armazenar um modelo de maneira independente da linguagem de front-end de escolha. Isso nos permite implantar modelos treinados em outros dispositivos e usar facilmente outras linguagens de programação front-end. Ao mesmo tempo, o código geralmente é mais rápido do que o que pode ser alcançado na programação imperativa. Vamos ver o método save em ação.

net.save('my_mlp')
!ls -lh my_mlp*
-rw-r--r-- 1 jenkins jenkins 651K Dec 11 06:53 my_mlp

Um dos benefícios de compilar os modelos é que podemos serializar (salvar) o modelo e seus parâmetros no disco. Isso nos permite armazenar um modelo de maneira independente da linguagem de front-end de escolha. Isso nos permite implantar modelos treinados em outros dispositivos e usar facilmente outras linguagens de programação front-end ou executar um modelo treinado em um servidor. Ao mesmo tempo, o código geralmente é mais rápido do que o que pode ser alcançado na programação imperativa. A API de baixo nível que nos permite salvar em tensorflow é tf.saved_model. Vamos ver a instância saved_model em ação.

net = get_net()
tf.saved_model.save(net, 'my_mlp')
!ls -lh my_mlp*
WARNING:tensorflow:From /var/lib/jenkins/miniconda3/envs/d2l-pt-release-0/lib/python3.8/site-packages/tensorflow/python/training/tracking/tracking.py:111: Model.state_updates (from tensorflow.python.keras.engine.training) is deprecated and will be removed in a future version.
Instructions for updating:
This property should not be used in TensorFlow 2.0, as updates are applied automatically.
WARNING:tensorflow:From /var/lib/jenkins/miniconda3/envs/d2l-pt-release-0/lib/python3.8/site-packages/tensorflow/python/training/tracking/tracking.py:111: Layer.updates (from tensorflow.python.keras.engine.base_layer) is deprecated and will be removed in a future version.
Instructions for updating:
This property should not be used in TensorFlow 2.0, as updates are applied automatically.
INFO:tensorflow:Assets written to: my_mlp/assets
total 68K
drwxr-xr-x 2 jenkins jenkins 4.0K Dec 11 08:06 assets
-rw-r--r-- 1 jenkins jenkins  60K Dec 11 08:06 saved_model.pb
drwxr-xr-x 2 jenkins jenkins 4.0K Dec 11 08:06 variables