En lugar de especular sobre lo que puede o no suceder, veamos, ¿de acuerdo? Tendré que usar C ++ ya que no tengo un compilador de C # a mano (aunque vea el ejemplo de C # de VisualMelon ), pero estoy seguro de que los mismos principios se aplican independientemente.
Incluiremos las dos alternativas que encontró en la entrevista. También incluiremos una versión que utilice abs
según lo sugerido por algunas de las respuestas.
#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);
}
Ahora compílelo sin optimización alguna: g++ -c -o test.o test.cpp
Ahora podemos ver exactamente lo que esto genera: 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 en las direcciones de la pila (por ejemplo, el -0x4
in mov %edi,-0x4(%rbp)
versus el -0x14
in mov %edi,-0x14(%rbp)
) que IsSumInRangeWithVar()
usa 16 bytes adicionales en la pila.
Debido a que IsSumInRangeWithoutVar()
no asigna espacio en la pila para almacenar el valor intermedio s
, tiene que volver a calcularlo, lo que hace que esta implementación tenga 2 instrucciones más.
Divertido, se IsSumInRangeSuperOptimized()
parece mucho IsSumInRangeWithoutVar()
, excepto que se compara con -1000 primero y 1000 segundo.
Ahora vamos a compilar con optimizaciones sólo las más básicas: g++ -O1 -c -o test.o test.cpp
. El 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
¿Lo mirarías? Cada variante es idéntica . El compilador puede hacer algo bastante inteligente: abs(a + b) <= 1000
es equivalente a a + b + 1000 <= 2000
considerar setbe
hacer una comparación sin signo, por lo que un número negativo se convierte en un número positivo muy grande. La lea
instrucción puede realizar todas estas adiciones en una sola instrucción y eliminar todas las ramas condicionales.
Para responder a su pregunta, casi siempre lo que hay que optimizar no es la memoria o la velocidad, sino la legibilidad . Leer el código es mucho más difícil que escribirlo, y leer el código que se ha manipulado para "optimizar" es mucho más difícil que leer el código que se ha escrito para que quede claro. La mayoría de las veces, estas "optimizaciones" tienen un impacto insignificante o, como en este caso, exactamente cero en el rendimiento real.
Pregunta de seguimiento, ¿qué cambia cuando este código está en un lenguaje interpretado en lugar de compilado? Entonces, ¿importa la optimización o tiene el mismo resultado?
¡Midamos! He transcrito los ejemplos a 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)),))
Ejecutar con Python 3.5.2, esto produce el resultado:
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
El desmontaje en Python no es terriblemente interesante, ya que el "compilador" de bytecode no hace mucho en cuanto a optimización.
El rendimiento de las tres funciones es casi idéntico. Podríamos sentir la tentación de ir IsSumInRangeWithVar()
debido a su ganancia de velocidad marginal. Aunque agregaré, ya que estaba probando diferentes parámetros timeit
, a veces IsSumInRangeSuperOptimized()
salió más rápido, por lo que sospecho que pueden ser factores externos responsables de la diferencia, en lugar de cualquier ventaja intrínseca de cualquier implementación.
Si este es realmente un código crítico para el rendimiento, un lenguaje interpretado es simplemente una elección muy pobre. Al ejecutar el mismo programa con pypy, obtengo:
IsSumInRangeWithVar: 0.000180s
IsSumInRangeWithoutVar: 0.001175s
IsSumInRangeSuperOptimized: 0.001306s
El solo uso de pypy, que usa la compilación JIT para eliminar gran parte de la sobrecarga del intérprete, ha producido una mejora en el rendimiento de 1 o 2 órdenes de magnitud. Me sorprendió bastante ver que IsSumInRangeWithVar()
es un orden de magnitud más rápido que los demás. Así que cambié el orden de los puntos de referencia y corrí nuevamente:
IsSumInRangeSuperOptimized: 0.000191s
IsSumInRangeWithoutVar: 0.001174s
IsSumInRangeWithVar: 0.001265s
¡Entonces parece que no es realmente nada acerca de la implementación lo que lo hace rápido, sino el orden en el que hago la evaluación comparativa!
Me encantaría profundizar más en esto, porque honestamente no sé por qué sucede esto. Pero creo que el punto ha sido señalado: las micro optimizaciones, como declarar un valor intermedio como variable o no, rara vez son relevantes. Con un lenguaje interpretado o un compilador altamente optimizado, el primer objetivo sigue siendo escribir código claro.
Si se requiere una mayor optimización, referencia . Recuerde que las mejores optimizaciones provienen no de los pequeños detalles sino de la imagen algorítmica más grande: pypy será un orden de magnitud más rápido para la evaluación repetida de la misma función que cpython porque usa algoritmos más rápidos (compilador JIT frente a interpretación) para evaluar el programa. Y también hay que considerar el algoritmo codificado: una búsqueda a través de un árbol B será más rápida que una lista vinculada.
Después de asegurarse de que está utilizando las herramientas y algoritmos adecuados para el trabajo, prepárese para profundizar en los detalles del sistema. Los resultados pueden ser muy sorprendentes, incluso para desarrolladores experimentados, y es por eso que debe tener un punto de referencia para cuantificar los cambios.