Вместо того, чтобы рассуждать о том, что может или не может произойти, давайте просто посмотрим, не так ли? Мне придется использовать C ++, поскольку у меня нет удобного компилятора C # (хотя см. Пример C # от VisualMelon ), но я уверен, что одни и те же принципы применяются независимо.
Мы включим две альтернативы, с которыми вы столкнулись в интервью. Мы также включим версию, которая использует, abs
как предлагается в некоторых ответах.
#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);
}
Теперь скомпилируйте его без какой-либо оптимизации: g++ -c -o test.o test.cpp
Теперь мы можем точно видеть, что это генерирует: 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
Мы можем видеть из стековых адресов (например, -0x4
in mov %edi,-0x4(%rbp)
против -0x14
in mov %edi,-0x14(%rbp)
), которые IsSumInRangeWithVar()
используют 16 дополнительных байтов в стеке.
Поскольку IsSumInRangeWithoutVar()
в стеке не выделяется место для хранения промежуточного значения, s
оно должно пересчитать его, в результате чего эта реализация будет на 2 инструкции длиннее.
Забавно, IsSumInRangeSuperOptimized()
выглядит очень похоже IsSumInRangeWithoutVar()
, за исключением того, что сначала сравнивают -1000 и 1000 секунд.
Теперь давайте компилировать только самые основные оптимизации: g++ -O1 -c -o test.o test.cpp
. Результат:
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
Вы посмотрите на это: каждый вариант идентичен . Компилятор может сделать что-то очень умное: abs(a + b) <= 1000
эквивалентно тому, что a + b + 1000 <= 2000
анализ setbe
выполняет сравнение без знака, поэтому отрицательное число становится очень большим положительным числом. lea
Инструкция может фактически выполнять все эти дополнения в одной команде, и устранить все условные переходы.
Чтобы ответить на ваш вопрос, почти всегда нужно оптимизировать не память или скорость, а читабельность . Чтение кода намного сложнее, чем его написание, а чтение кода, который был искажен для «оптимизации», намного сложнее, чем чтение кода, написанного для ясности. Чаще всего эти «оптимизации» оказываются незначительными, или, как в данном случае , практически нулевым фактическим влиянием на производительность.
Последующий вопрос: что меняется, когда этот код написан на интерпретируемом языке, а не скомпилирован? Тогда имеет ли значение оптимизация или у нее такой же результат?
Давайте измерим! Я переписал примеры на 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)),))
Запустите с Python 3.5.2, это приведет к выводу:
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
Разборка в Python не очень интересна, так как «компилятор» байт-кода мало что дает для оптимизации.
Производительность трех функций практически одинакова. Мы могли бы соблазниться, IsSumInRangeWithVar()
потому что это незначительное увеличение скорости. Хотя я добавляю, когда я пробовал разные параметры timeit
, иногда IsSumInRangeSuperOptimized()
получаюсь быстрее, поэтому я подозреваю, что причиной разницы могут быть внешние факторы, а не какое-либо внутреннее преимущество какой-либо реализации.
Если это действительно критичный к производительности код, интерпретируемый язык просто очень плохой выбор. Запустив ту же программу с Pypy, я получаю:
IsSumInRangeWithVar: 0.000180s
IsSumInRangeWithoutVar: 0.001175s
IsSumInRangeSuperOptimized: 0.001306s
Простое использование pypy, использующего JIT-компиляцию для устранения многих накладных расходов интерпретатора, привело к улучшению производительности на 1 или 2 порядка. Я был довольно шокирован, увидев, что IsSumInRangeWithVar()
это на порядок быстрее, чем другие. Поэтому я изменил порядок тестов и снова запустил:
IsSumInRangeSuperOptimized: 0.000191s
IsSumInRangeWithoutVar: 0.001174s
IsSumInRangeWithVar: 0.001265s
Таким образом, кажется, что на самом деле это не что-то, что делает его быстрым, а порядок, в котором я делаю сравнительный анализ!
Я хотел бы углубиться в это, потому что, честно говоря, я не знаю, почему это происходит. Но я считаю, что суть была достигнута: микрооптимизации, такие как объявление промежуточного значения в качестве переменной или нет, редко актуальны. При использовании интерпретируемого языка или высоко оптимизированного компилятора первая цель по-прежнему заключается в написании понятного кода.
Если может потребоваться дальнейшая оптимизация, сравните результаты . Помните, что лучшая оптимизация получается не из мелких деталей, а из большей алгоритмической картины: pypy будет на порядок быстрее для повторной оценки той же функции, чем cpython, потому что он использует более быстрые алгоритмы (JIT-компилятор против интерпретации) для оценки программа. И есть также закодированный алгоритм: поиск по B-дереву будет быстрее, чем связанный список.
Убедившись , что вы используете правильные инструменты и алгоритмы для работы, быть готовым к погружению глубоко в детали системы. Результаты могут быть очень удивительными, даже для опытных разработчиков, и поэтому у вас должен быть эталон для количественной оценки изменений.