Invece di speculare su ciò che può o non può accadere, diamo un'occhiata, vero? Dovrò usare C ++ poiché non ho un compilatore C # a portata di mano (anche se vedi l'esempio C # da VisualMelon ), ma sono sicuro che gli stessi principi si applicano indipendentemente.
Includeremo le due alternative che hai incontrato nell'intervista. Includeremo anche una versione che utilizza abs
come suggerito da alcune delle risposte.
#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);
}
Ora compilalo senza alcuna ottimizzazione: g++ -c -o test.o test.cpp
Ora possiamo vedere esattamente cosa 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
Possiamo vedere dagli indirizzi dello stack (ad esempio, -0x4
in mov %edi,-0x4(%rbp)
rispetto a -0x14
in mov %edi,-0x14(%rbp)
) che IsSumInRangeWithVar()
utilizza 16 byte extra nello stack.
Poiché IsSumInRangeWithoutVar()
non alloca spazio nello stack per archiviare il valore intermedio s
, è necessario ricalcolarlo, facendo sì che questa implementazione sia più lunga di 2 istruzioni.
Divertente, IsSumInRangeSuperOptimized()
assomiglia molto IsSumInRangeWithoutVar()
, tranne che confronta -1000 prima e 1000 secondi.
Ora cerchiamo di compilare solo con le ottimizzazioni più basilari: g++ -O1 -c -o test.o test.cpp
. Il risultato:
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 guarderesti: ogni variante è identica . Il compilatore è in grado di fare qualcosa di abbastanza intelligente: abs(a + b) <= 1000
equivale a a + b + 1000 <= 2000
considerare setbe
un confronto senza segno, quindi un numero negativo diventa un numero positivo molto grande. L' lea
istruzione può effettivamente eseguire tutte queste aggiunte in un'unica istruzione ed eliminare tutti i rami condizionali.
Per rispondere alla tua domanda, quasi sempre la cosa da ottimizzare non è la memoria o la velocità, ma la leggibilità . Leggere il codice è molto più difficile che scriverlo, e leggere il codice che è stato modificato per "ottimizzare" è molto più difficile che leggere il codice che è stato scritto per essere chiaro. Il più delle volte, queste "ottimizzazioni" hanno un impatto trascurabile, o come in questo caso esattamente zero impatto effettivo sulle prestazioni.
Domanda di follow-up, cosa cambia quando questo codice è in una lingua interpretata anziché compilata? Quindi, l'ottimizzazione è importante o ha lo stesso risultato?
Misuriamo! Ho trascritto gli esempi in 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)),))
Esegui con Python 3.5.2, questo produce l'output:
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
Lo smontaggio in Python non è terribilmente interessante, dal momento che il "compilatore" bytecode non fa molto in termini di ottimizzazione.
Le prestazioni delle tre funzioni sono quasi identiche. Potremmo essere tentati di seguirlo a IsSumInRangeWithVar()
causa del suo guadagno di velocità marginale. Anche se aggiungerò mentre stavo provando parametri diversi timeit
, a volte IsSumInRangeSuperOptimized()
sono uscito più velocemente, quindi sospetto che possano essere fattori esterni responsabili della differenza, piuttosto che qualsiasi vantaggio intrinseco di qualsiasi implementazione.
Se questo è davvero un codice critico per le prestazioni, un linguaggio interpretato è semplicemente una scelta molto scadente. Eseguendo lo stesso programma con pypy, ottengo:
IsSumInRangeWithVar: 0.000180s
IsSumInRangeWithoutVar: 0.001175s
IsSumInRangeSuperOptimized: 0.001306s
Il solo utilizzo di pypy, che utilizza la compilazione JIT per eliminare molte delle spese generali dell'interprete, ha prodotto un miglioramento delle prestazioni di 1 o 2 ordini di grandezza. Sono rimasto piuttosto scioccato nel vedere IsSumInRangeWithVar()
un ordine di grandezza più veloce degli altri. Quindi ho cambiato l'ordine dei benchmark e ho eseguito di nuovo:
IsSumInRangeSuperOptimized: 0.000191s
IsSumInRangeWithoutVar: 0.001174s
IsSumInRangeWithVar: 0.001265s
Quindi sembra che in realtà non sia nulla sull'implementazione che lo rende veloce, ma piuttosto l'ordine in cui eseguo il benchmarking!
Mi piacerebbe approfondire questo aspetto più profondamente, perché onestamente non so perché questo accada. Ma credo che il punto sia stato sollevato: le microottimizzazioni come se dichiarare un valore intermedio come variabile o meno sono raramente rilevanti. Con un linguaggio interpretato o un compilatore altamente ottimizzato, il primo obiettivo è ancora quello di scrivere un codice chiaro.
Se potrebbe essere necessaria un'ulteriore ottimizzazione, benchmark . Ricorda che le migliori ottimizzazioni non provengono dai piccoli dettagli ma dal più grande quadro algoritmico: pypy sarà un ordine di grandezza più veloce per una valutazione ripetuta della stessa funzione di cpython perché utilizza algoritmi più veloci (compilatore JIT vs interpretazione) per valutare programma. E c'è anche l'algoritmo codificato da considerare: una ricerca attraverso un albero B sarà più veloce di un elenco collegato.
Dopo essersi assicurati che si sta utilizzando gli strumenti giusti e gli algoritmi per il lavoro, essere pronti a tuffarsi in profondità nei dettagli del sistema. I risultati possono essere molto sorprendenti, anche per sviluppatori esperti, ed è per questo che è necessario disporre di un benchmark per quantificare le modifiche.