12.1. Compiladores e Interpretadores¶ 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.
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:
Definir as operações a serem executadas.
Compilar as operações em um programa executável.
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étodohibridizar
.
12.1.5. Exercícios¶
Projete uma rede usando a classe
HybridConcurrent
. Como alternativa, olhe em Redes com Concatenações Paralelas (GoogLeNet) para uma rede para compor.Adicione
x.asnumpy ()
à primeira linha da funçãohybrid_forward
da classe HybridNet nesta seção. Execute o código e observe os erros que encontrar. Por que eles acontecem?O que acontece se adicionarmos o fluxo de controle, ou seja, as instruções Python
if
efor
na funçãohybrid_forward
?Revise os modelos de seu interesse nos capítulos anteriores e use a classe HybridBlock ou HybridSequential para implementá-los.
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