A descida do gradiente usando o TensorFlow é muito mais lenta do que uma implementação básica do Python, por quê?

Dec 29 2020

Estou fazendo um curso de aprendizado de máquina. Tenho um problema de regressão linear simples (LR) para me ajudar a me acostumar com o TensorFlow. O problema LR é encontrar parâmetros ae boutros que se Y = a*X + baproximem de uma (x, y)nuvem de pontos (que eu mesmo gerei por uma questão de simplicidade).

Estou resolvendo esse problema de LR usando uma 'descida gradiente de tamanho de passo fixo (FSSGD)'. Implementei usando o TensorFlow e funciona, mas percebi que é muito lento tanto na GPU quanto na CPU. Por estar curioso, implementei o FSSGD sozinho em Python / NumPy e, como esperado, ele funciona muito mais rápido, sobre:

  • 10x mais rápido que TF @ CPU
  • 20x mais rápido que TF @ GPU

Se o TensorFlow for tão lento, não posso imaginar que tantas pessoas estejam usando essa estrutura. Portanto, devo estar fazendo algo errado. Alguém pode me ajudar para que eu possa acelerar minha implementação do TensorFlow.

NÃO estou interessado na diferença entre o desempenho da CPU e da GPU. Ambos os indicadores de desempenho são fornecidos apenas para fins de abrangência e ilustração. Estou interessado em saber por que minha implementação do TensorFlow é muito mais lenta do que uma implementação Python / NumPy bruta.

Como referência, adiciono meu código abaixo.

  • Reduzido a um exemplo mínimo (mas totalmente funcional).
  • Usando Python v3.7.9 x64.
  • Usado tensorflow-gpu==1.15por enquanto (porque o curso usa TensorFlow v1)
  • Testado para funcionar em Spyder e PyCharm.

Minha implementação FSSGD usando TensorFlow (tempo de execução de cerca de 40 s @CPU a 80 s @GPU):

#%% General imports
import numpy as np
import timeit
import tensorflow.compat.v1 as tf


#%% Get input data
# Generate simulated input data
x_data_input = np.arange(100, step=0.1)
y_data_input = x_data_input + 20 * np.sin(x_data_input/10) + 15


#%% Define tensorflow model
# Define data size
n_samples = x_data_input.shape[0]

# Tensorflow is finicky about shapes, so resize
x_data = np.reshape(x_data_input, (n_samples, 1))
y_data = np.reshape(y_data_input, (n_samples, 1))

# Define placeholders for input
X = tf.placeholder(tf.float32, shape=(n_samples, 1), name="tf_x_data")
Y = tf.placeholder(tf.float32, shape=(n_samples, 1), name="tf_y_data")

# Define variables to be learned
with tf.variable_scope("linear-regression", reuse=tf.AUTO_REUSE): #reuse= True | False | tf.AUTO_REUSE
    W = tf.get_variable("weights", (1, 1), initializer=tf.constant_initializer(0.0))
    b = tf.get_variable("bias", (1,), initializer=tf.constant_initializer(0.0))

# Define loss function    
Y_pred = tf.matmul(X, W) + b
loss = tf.reduce_sum((Y - Y_pred) ** 2 / n_samples)  # Quadratic loss function


# %% Solve tensorflow model
#Define algorithm parameters
total_iterations = 1e5  # Defines total training iterations

#Construct TensorFlow optimizer
with tf.variable_scope("linear-regression", reuse=tf.AUTO_REUSE): #reuse= True | False | tf.AUTO_REUSE
    opt = tf.train.GradientDescentOptimizer(learning_rate = 1e-4)
    opt_operation = opt.minimize(loss, name="GDO")

#To measure execution time
time_start = timeit.default_timer()

with tf.Session() as sess:
    #Initialize variables
    sess.run(tf.global_variables_initializer())
    
    #Train variables
    for index in range(int(total_iterations)):
        _, loss_val_tmp = sess.run([opt_operation, loss], feed_dict={X: x_data, Y: y_data})
    
    #Get final values of variables
    W_val, b_val, loss_val = sess.run([W, b, loss], feed_dict={X: x_data, Y: y_data})
      
#Print execution time      
time_end = timeit.default_timer()
print('')
print("Time to execute code: {0:0.9f} sec.".format(time_end - time_start))
print('')


# %% Print results
print('')
print('Iteration = {0:0.3f}'.format(total_iterations))
print('W_val = {0:0.3f}'.format(W_val[0,0]))
print('b_val = {0:0.3f}'.format(b_val[0]))
print('')

Minha própria implementação FSSGD em python (tempo de execução de cerca de 4 segundos):

#%% General imports
import numpy as np
import timeit


#%% Get input data
# Define input data
x_data_input = np.arange(100, step=0.1)
y_data_input = x_data_input + 20 * np.sin(x_data_input/10) + 15


#%% Define Gradient Descent (GD) model
# Define data size
n_samples = x_data_input.shape[0]

#Initialize data
W = 0.0  # Initial condition
b = 0.0  # Initial condition

# Compute initial loss
y_gd_approx = W*x_data_input+b
loss = np.sum((y_data_input - y_gd_approx)**2)/n_samples  # Quadratic loss function


#%% Execute Gradient Descent algorithm
#Define algorithm parameters
total_iterations = 1e5  # Defines total training iterations
GD_stepsize = 1e-4  # Gradient Descent fixed step size

#To measure execution time
time_start = timeit.default_timer()

for index in range(int(total_iterations)):
    #Compute gradient (derived manually for the quadratic cost function)
    loss_gradient_W = 2.0/n_samples*np.sum(-x_data_input*(y_data_input - y_gd_approx))
    loss_gradient_b = 2.0/n_samples*np.sum(-1*(y_data_input - y_gd_approx))
    
    #Update trainable variables using fixed step size gradient descent
    W = W - GD_stepsize * loss_gradient_W
    b = b - GD_stepsize * loss_gradient_b
    
    #Compute loss
    y_gd_approx = W*x_data_input+b
    loss = np.sum((y_data_input - y_gd_approx)**2)/x_data_input.shape[0]

#Print execution time 
time_end = timeit.default_timer()
print('')
print("Time to execute code: {0:0.9f} sec.".format(time_end - time_start))
print('')


# %% Print results
print('')
print('Iteration = {0:0.3f}'.format(total_iterations))
print('W_val = {0:0.3f}'.format(W))
print('b_val = {0:0.3f}'.format(b))
print('')

Respostas

1 amin Dec 29 2020 at 21:12

Acho que é o resultado de um grande número de iteração. Alterei o número da iteração de 1e5para 1e3e também alterei x de x_data_input = np.arange(100, step=0.1)para x_data_input = np.arange(100, step=0.0001). Dessa forma, reduzi o número da iteração, mas aumentei o cálculo em 10x. Com np é feito em 22 segundos e no tensorflow é feito em 25 segundos .

Meu palpite: tensorflow tem muito overhead em cada iteração (para nos dar uma estrutura que pode fazer muito), mas a velocidade de passagem para frente e para trás está ok.

Stefan Dec 31 2020 at 17:35

A verdadeira resposta à minha pergunta está oculta nos vários comentários. Para leitores futuros, vou resumir essas descobertas nesta resposta.

Sobre a diferença de velocidade entre o TensorFlow e uma implementação bruta de Python / NumPy

Esta parte da resposta é bastante lógica.

Cada iteração (= cada chamada de Session.run()) TensorFlow realiza cálculos. O TensorFlow tem uma grande sobrecarga para iniciar cada computação. Na GPU, essa sobrecarga é ainda pior do que na CPU. No entanto, o TensorFlow executa os cálculos reais de maneira muito eficiente e mais eficiente do que a implementação bruta de Python / NumPy acima.

Portanto, quando o número de pontos de dados aumenta e, portanto, o número de cálculos por iteração, você verá que o desempenho relativo entre TensorFlow e Python / NumPy muda na vantagem do TensorFlow. O oposto também é verdade.

O problema descrito na pergunta é muito pequeno, o que significa que o número de cálculos é muito baixo, enquanto o número de iterações é muito grande. É por isso que o TensorFlow tem um desempenho tão ruim. Esse tipo de pequenos problemas não é o caso de uso típico para o qual o TensorFlow foi projetado.

Para reduzir o tempo de execução

Ainda assim, o tempo de execução do script TensorFlow pode ser muito reduzido! Para reduzir o tempo de execução, o número de iterações deve ser reduzido (não importa o tamanho do problema, este é um bom objetivo de qualquer maneira).

Como @amin apontou, isso é obtido escalando os dados de entrada. Uma breve explicação de porque isso funciona: o tamanho do gradiente e as atualizações de variáveis ​​são mais equilibrados em comparação com os valores absolutos para os quais os valores devem ser encontrados. Portanto, menos etapas (= iterações) são necessárias.

Seguindo o conselho de @amin, eu finalmente terminei escalando meus dados-x da seguinte maneira (alguns códigos são repetidos para tornar clara a posição do novo código):

# Tensorflow is finicky about shapes, so resize
x_data = np.reshape(x_data_input, (n_samples, 1))
y_data = np.reshape(y_data_input, (n_samples, 1))

### START NEW CODE ###

# Scale x_data
x_mean = np.mean(x_data)
x_std = np.std(x_data)
x_data = (x_data - x_mean) / x_std

### END NEW CODE ###

# Define placeholders for input
X = tf.placeholder(tf.float32, shape=(n_samples, 1), name="tf_x_data")
Y = tf.placeholder(tf.float32, shape=(n_samples, 1), name="tf_y_data")

O escalonamento acelera a convergência por um fator 1000. Em vez de 1e5 iterations, 1e2 iterationssão necessários. Isso ocorre parcialmente porque um máximo step size of 1e-1pode ser usado em vez de a step size of 1e-4.

Observe que o peso e o viés encontrados são diferentes e você deve alimentar os dados em escala a partir de agora.

Opcionalmente, você pode optar por remover a escala do peso e do viés encontrados para que possa alimentar os dados fora da escala. A remoção da escala é feita usando este código (colocado em algum lugar no final do código):

#%% Unscaling
W_val_unscaled = W_val[0,0]/x_std
b_val_unscaled = b_val[0]-x_mean*W_val[0,0]/x_std