Em vez de especular sobre o que pode ou não acontecer, vejamos, não é? Vou precisar usar o C ++, já que não tenho um compilador de C # à mão (embora veja o exemplo de C # do VisualMelon ), mas tenho certeza de que os mesmos princípios se aplicam independentemente.
Incluiremos as duas alternativas que você encontrou na entrevista. Também incluiremos uma versão usada abs
conforme sugerido por algumas das respostas.
#include <cstdlib>
bool IsSumInRangeWithVar(int a, int b)
{
int s = a + b;
if (s > 1000 || s < -1000) return false;
else return true;
}
bool IsSumInRangeWithoutVar(int a, int b)
{
if (a + b > 1000 || a + b < -1000) return false;
else return true;
}
bool IsSumInRangeSuperOptimized(int a, int b) {
return (abs(a + b) < 1000);
}
Agora compile-o sem nenhuma otimização: g++ -c -o test.o test.cpp
Agora podemos ver exatamente o que isso gera: objdump -d test.o
0000000000000000 <_Z19IsSumInRangeWithVarii>:
0: 55 push %rbp # begin a call frame
1: 48 89 e5 mov %rsp,%rbp
4: 89 7d ec mov %edi,-0x14(%rbp) # save first argument (a) on stack
7: 89 75 e8 mov %esi,-0x18(%rbp) # save b on stack
a: 8b 55 ec mov -0x14(%rbp),%edx # load a and b into edx
d: 8b 45 e8 mov -0x18(%rbp),%eax # load b into eax
10: 01 d0 add %edx,%eax # add a and b
12: 89 45 fc mov %eax,-0x4(%rbp) # save result as s on stack
15: 81 7d fc e8 03 00 00 cmpl $0x3e8,-0x4(%rbp) # compare s to 1000
1c: 7f 09 jg 27 # jump to 27 if it's greater
1e: 81 7d fc 18 fc ff ff cmpl $0xfffffc18,-0x4(%rbp) # compare s to -1000
25: 7d 07 jge 2e # jump to 2e if it's greater or equal
27: b8 00 00 00 00 mov $0x0,%eax # put 0 (false) in eax, which will be the return value
2c: eb 05 jmp 33 <_Z19IsSumInRangeWithVarii+0x33>
2e: b8 01 00 00 00 mov $0x1,%eax # put 1 (true) in eax
33: 5d pop %rbp
34: c3 retq
0000000000000035 <_Z22IsSumInRangeWithoutVarii>:
35: 55 push %rbp
36: 48 89 e5 mov %rsp,%rbp
39: 89 7d fc mov %edi,-0x4(%rbp)
3c: 89 75 f8 mov %esi,-0x8(%rbp)
3f: 8b 55 fc mov -0x4(%rbp),%edx
42: 8b 45 f8 mov -0x8(%rbp),%eax # same as before
45: 01 d0 add %edx,%eax
# note: unlike other implementation, result is not saved
47: 3d e8 03 00 00 cmp $0x3e8,%eax # compare to 1000
4c: 7f 0f jg 5d <_Z22IsSumInRangeWithoutVarii+0x28>
4e: 8b 55 fc mov -0x4(%rbp),%edx # since s wasn't saved, load a and b from the stack again
51: 8b 45 f8 mov -0x8(%rbp),%eax
54: 01 d0 add %edx,%eax
56: 3d 18 fc ff ff cmp $0xfffffc18,%eax # compare to -1000
5b: 7d 07 jge 64 <_Z22IsSumInRangeWithoutVarii+0x2f>
5d: b8 00 00 00 00 mov $0x0,%eax
62: eb 05 jmp 69 <_Z22IsSumInRangeWithoutVarii+0x34>
64: b8 01 00 00 00 mov $0x1,%eax
69: 5d pop %rbp
6a: c3 retq
000000000000006b <_Z26IsSumInRangeSuperOptimizedii>:
6b: 55 push %rbp
6c: 48 89 e5 mov %rsp,%rbp
6f: 89 7d fc mov %edi,-0x4(%rbp)
72: 89 75 f8 mov %esi,-0x8(%rbp)
75: 8b 55 fc mov -0x4(%rbp),%edx
78: 8b 45 f8 mov -0x8(%rbp),%eax
7b: 01 d0 add %edx,%eax
7d: 3d 18 fc ff ff cmp $0xfffffc18,%eax
82: 7c 16 jl 9a <_Z26IsSumInRangeSuperOptimizedii+0x2f>
84: 8b 55 fc mov -0x4(%rbp),%edx
87: 8b 45 f8 mov -0x8(%rbp),%eax
8a: 01 d0 add %edx,%eax
8c: 3d e8 03 00 00 cmp $0x3e8,%eax
91: 7f 07 jg 9a <_Z26IsSumInRangeSuperOptimizedii+0x2f>
93: b8 01 00 00 00 mov $0x1,%eax
98: eb 05 jmp 9f <_Z26IsSumInRangeSuperOptimizedii+0x34>
9a: b8 00 00 00 00 mov $0x0,%eax
9f: 5d pop %rbp
a0: c3 retq
Podemos ver nos endereços da pilha (por exemplo, o -0x4
in mov %edi,-0x4(%rbp)
versus o -0x14
in mov %edi,-0x14(%rbp)
) que IsSumInRangeWithVar()
usam 16 bytes extras na pilha.
Como IsSumInRangeWithoutVar()
não aloca espaço na pilha para armazenar o valor intermediário, s
ele precisa recalculá-lo, resultando nessa implementação em duas instruções a mais.
Engraçado, IsSumInRangeSuperOptimized()
parece muito IsSumInRangeWithoutVar()
, exceto que se compara a -1000 primeiro e 1000 segundo.
Agora vamos compilar apenas com as otimizações mais básicas: g++ -O1 -c -o test.o test.cpp
. O resultado:
0000000000000000 <_Z19IsSumInRangeWithVarii>:
0: 8d 84 37 e8 03 00 00 lea 0x3e8(%rdi,%rsi,1),%eax
7: 3d d0 07 00 00 cmp $0x7d0,%eax
c: 0f 96 c0 setbe %al
f: c3 retq
0000000000000010 <_Z22IsSumInRangeWithoutVarii>:
10: 8d 84 37 e8 03 00 00 lea 0x3e8(%rdi,%rsi,1),%eax
17: 3d d0 07 00 00 cmp $0x7d0,%eax
1c: 0f 96 c0 setbe %al
1f: c3 retq
0000000000000020 <_Z26IsSumInRangeSuperOptimizedii>:
20: 8d 84 37 e8 03 00 00 lea 0x3e8(%rdi,%rsi,1),%eax
27: 3d d0 07 00 00 cmp $0x7d0,%eax
2c: 0f 96 c0 setbe %al
2f: c3 retq
Você olha para isso: cada variante é idêntica . O compilador é capaz de fazer algo bastante inteligente: abs(a + b) <= 1000
é equivalente a a + b + 1000 <= 2000
considerar setbe
fazer uma comparação não assinada; portanto, um número negativo se torna um número positivo muito grande. A lea
instrução pode realmente executar todas essas adições em uma instrução e eliminar todos os ramos condicionais.
Para responder à sua pergunta, quase sempre o que otimizar não é a memória ou a velocidade, mas a legibilidade . Ler código é muito mais difícil do que escrevê-lo, e ler código que foi modificado para "otimizar" é muito mais difícil do que ler código que foi escrito para ficar claro. Na maioria das vezes, essas "otimizações" têm um nível insignificante ou, nesse caso, exatamente zero impacto real no desempenho.
Pergunta de acompanhamento, o que muda quando esse código está em um idioma interpretado em vez de compilado? Então, a otimização é importante ou tem o mesmo resultado?
Vamos medir! Eu transcrevi os exemplos para Python:
def IsSumInRangeWithVar(a, b):
s = a + b
if s > 1000 or s < -1000:
return False
else:
return True
def IsSumInRangeWithoutVar(a, b):
if a + b > 1000 or a + b < -1000:
return False
else:
return True
def IsSumInRangeSuperOptimized(a, b):
return abs(a + b) <= 1000
from dis import dis
print('IsSumInRangeWithVar')
dis(IsSumInRangeWithVar)
print('\nIsSumInRangeWithoutVar')
dis(IsSumInRangeWithoutVar)
print('\nIsSumInRangeSuperOptimized')
dis(IsSumInRangeSuperOptimized)
print('\nBenchmarking')
import timeit
print('IsSumInRangeWithVar: %fs' % (min(timeit.repeat(lambda: IsSumInRangeWithVar(42, 42), repeat=50, number=100000)),))
print('IsSumInRangeWithoutVar: %fs' % (min(timeit.repeat(lambda: IsSumInRangeWithoutVar(42, 42), repeat=50, number=100000)),))
print('IsSumInRangeSuperOptimized: %fs' % (min(timeit.repeat(lambda: IsSumInRangeSuperOptimized(42, 42), repeat=50, number=100000)),))
Execute com o Python 3.5.2, isso produz a saída:
IsSumInRangeWithVar
2 0 LOAD_FAST 0 (a)
3 LOAD_FAST 1 (b)
6 BINARY_ADD
7 STORE_FAST 2 (s)
3 10 LOAD_FAST 2 (s)
13 LOAD_CONST 1 (1000)
16 COMPARE_OP 4 (>)
19 POP_JUMP_IF_TRUE 34
22 LOAD_FAST 2 (s)
25 LOAD_CONST 4 (-1000)
28 COMPARE_OP 0 (<)
31 POP_JUMP_IF_FALSE 38
4 >> 34 LOAD_CONST 2 (False)
37 RETURN_VALUE
6 >> 38 LOAD_CONST 3 (True)
41 RETURN_VALUE
42 LOAD_CONST 0 (None)
45 RETURN_VALUE
IsSumInRangeWithoutVar
9 0 LOAD_FAST 0 (a)
3 LOAD_FAST 1 (b)
6 BINARY_ADD
7 LOAD_CONST 1 (1000)
10 COMPARE_OP 4 (>)
13 POP_JUMP_IF_TRUE 32
16 LOAD_FAST 0 (a)
19 LOAD_FAST 1 (b)
22 BINARY_ADD
23 LOAD_CONST 4 (-1000)
26 COMPARE_OP 0 (<)
29 POP_JUMP_IF_FALSE 36
10 >> 32 LOAD_CONST 2 (False)
35 RETURN_VALUE
12 >> 36 LOAD_CONST 3 (True)
39 RETURN_VALUE
40 LOAD_CONST 0 (None)
43 RETURN_VALUE
IsSumInRangeSuperOptimized
15 0 LOAD_GLOBAL 0 (abs)
3 LOAD_FAST 0 (a)
6 LOAD_FAST 1 (b)
9 BINARY_ADD
10 CALL_FUNCTION 1 (1 positional, 0 keyword pair)
13 LOAD_CONST 1 (1000)
16 COMPARE_OP 1 (<=)
19 RETURN_VALUE
Benchmarking
IsSumInRangeWithVar: 0.019361s
IsSumInRangeWithoutVar: 0.020917s
IsSumInRangeSuperOptimized: 0.020171s
A desmontagem em Python não é muito interessante, pois o "compilador" do bytecode não faz muito em termos de otimização.
O desempenho das três funções é quase idêntico. Podemos ficar tentados a seguir IsSumInRangeWithVar()
devido ao seu ganho marginal de velocidade. Embora eu adicione como estava tentando parâmetros diferentes timeit
, às vezes IsSumInRangeSuperOptimized()
saiu mais rápido, por isso suspeito que possam ser fatores externos responsáveis pela diferença, e não qualquer vantagem intrínseca de qualquer implementação.
Se esse é realmente um código crítico de desempenho, uma linguagem interpretada é simplesmente uma escolha muito ruim. Executando o mesmo programa com pypy, recebo:
IsSumInRangeWithVar: 0.000180s
IsSumInRangeWithoutVar: 0.001175s
IsSumInRangeSuperOptimized: 0.001306s
O simples uso de pypy, que usa a compilação JIT para eliminar grande parte da sobrecarga do interpretador, resultou em uma melhoria de desempenho de 1 ou 2 ordens de magnitude. Fiquei bastante chocado ao ver que IsSumInRangeWithVar()
é uma ordem de magnitude mais rápida que as outras. Então mudei a ordem dos benchmarks e corri novamente:
IsSumInRangeSuperOptimized: 0.000191s
IsSumInRangeWithoutVar: 0.001174s
IsSumInRangeWithVar: 0.001265s
Parece que não é nada sobre a implementação que a torna mais rápida, mas a ordem em que eu faço o benchmarking!
Eu adoraria aprofundar isso mais profundamente, porque sinceramente não sei por que isso acontece. Mas acredito que o argumento já foi levantado: micro-otimizações, como declarar ou não um valor intermediário como variável, raramente são relevantes. Com uma linguagem interpretada ou um compilador altamente otimizado, o primeiro objetivo ainda é escrever um código claro.
Se uma otimização adicional for necessária, faça benchmark . Lembre-se de que as melhores otimizações não provêm dos pequenos detalhes, mas da imagem algorítmica maior: o pypy será uma ordem de magnitude mais rápida para a avaliação repetida da mesma função que o cpython, porque usa algoritmos mais rápidos (compilador JIT x interpretação) para avaliar o programa. E há também o algoritmo codificado a ser considerado: uma pesquisa em uma árvore B será mais rápida que uma lista vinculada.
Depois de garantir que você esteja usando as ferramentas e os algoritmos certos para o trabalho, esteja preparado para mergulhar profundamente nos detalhes do sistema. Os resultados podem ser muito surpreendentes, mesmo para desenvolvedores experientes, e é por isso que você deve ter uma referência para quantificar as alterações.