何时针对一种方法优化内存与性能速度?


107

我最近在亚马逊接受了采访。在编码会议期间,面试官问为什么我在方法中声明了变量。我解释了我的过程,他挑战我以更少的变量解决相同的问题。例如(这不是从采访),我开始与方法A然后提高方法B,通过去除int s。他很高兴,并说这将通过这种方法减少内存使用。

我了解其背后的逻辑,但我的问题是:

什么时候适合使用方法A与方法B,反之亦然?

您可以看到,由于方法Aint s声明了,因此它将具有更高的内存使用率,但是它只需执行一次计算即可,即a + b。另一方面,方法B的内存使用量较低,但是必须执行两次计算,即a + b两次。什么时候比另一种使用一种技术?还是其中一种技术总是总是比其他技术更受青睐?评估这两种方法时要考虑什么?

方法A:

private bool IsSumInRange(int a, int b)
{
    int s = a + b;

    if (s > 1000 || s < -1000) return false;
    else return true;
}

方法B:

private bool IsSumInRange(int a, int b)
{
    if (a + b > 1000 || a + b < -1000) return false;
    else return true;
}

229
我敢打赌,现代编译器将为这两种情况生成相同的程序集。
18年

12
由于您的修改使我的答案无效,因此我将问题回滚到了原始状态-请不要这样做!如果您提出问题来改进代码,则不要通过所示方式改进代码来更改问题-这会使答案显得毫无意义。
布朗

76
等待一秒钟,他们要求摆脱int s这些魔术数字的上限和下限,同时完全消除罚款吗?

34
切记:优化之前先进行配置。使用现代编译器,可以将方法A和方法B优化为相同的代码(使用更高的优化级别)。同样,对于现代处理器,它们可能具有指令,这些指令在单个操作中执行的功能不仅仅限于加法运算。
Thomas Matthews

142
都不;优化可读性。
安迪

Answers:


148

不用猜测可能发生或可能发生的事情,让我们看看吧?我将不得不使用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

我们可以从堆栈地址(例如-0x4in mov %edi,-0x4(%rbp)-0x14in 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树进行搜索将比链接列表更快。

在确保使用正确的工具和算法完成工作后,请准备好深入了解系统的细节。即使对于有经验的开发人员来说,结果也可能令人惊讶,这就是为什么您必须有一个基准来量化更改的原因。


6
在C#中提供示例:SharpLab为这两种方法生成相同的asm(x86上的Desktop CLR v4.7.3130.00(clr.dll))
VisualMelon

2
@VisualMelon足以进行肯定的检查:“返回((((a + b)> = -1000)&&((a + b)<= 1000));”给出了不同的结果。:sharplab.io/...
彼得乙

12
可读性也可能使程序更易于优化。仅当编译器能够真正弄清楚您要执行的操作时,它才能轻松重写以使用上面相同的逻辑。如果您使用许多老式的bithack,在int和指针之间来回转换,重用可变存储等,则编译器可能很难证明变换是等效的,而这只会留下您编写的内容,可能不是最佳选择。
Leushenko '18

1
@Corey参见编辑。
Phil Frost

2
@Corey:这个答案实际上是在告诉您我在答案中写的是什么:当您使用一个不错的编译器,而是专注于就绪性时,没有什么区别。当然,它看起来更好成立-也许您现在相信我。
布朗

67

要回答所述问题:

何时针对一种方法优化内存与性能速度?

您必须建立两件事:

  • 什么限制了您的应用程序?
  • 我在哪里可以收回大部分资源?

为了回答第一个问题,您必须知道应用程序的性能要求是什么。如果没有性能要求,则没有理由优化一种方法或另一种方法。性能要求可帮助您到达“足够好”的位置。

您自己提供的方法本身不会导致任何性能问题,但是也许在一个循环中并处理大量数据时,您必须开始对如何解决问题进行一些不同的思考。

检测什么限制了应用程序

开始使用性能监视器查看应用程序的行为。在运行时密切注意CPU,磁盘,网络和内存的使用情况。一个或多个项目将被用完,而其他所有东西都将被适当使用-除非您达到完美的平衡,但这几乎永远不会发生)。

当您需要更深入地研究时,通常可以使用探查器。有内存分析器进程分析器,它们测量不同的事物。性能分析确实会对性能产生重大影响,但是您正在对代码进行检测以找出问题所在。

假设您看到CPU和磁盘使用量达到峰值。您将首先检查“热点”或代码,这些热点或代码被调用的次数比其余地方多,或者花费的处理时间明显更长。

如果找不到任何热点,则可以开始查看内存。也许您创建的对象超出了必要,垃圾回收正在超时工作。

回收性能

认真思考。以下更改列表是您将获得多少投资回报的顺序:

  • 体系结构:寻找通信瓶颈
  • 算法:处理数据的方式可能需要更改
  • 热点:最大程度地减少呼叫热点的频率,可以产生很大的收益
  • 微观优化:这并不常见,但有时您确实需要考虑一些细微调整(例如您提供的示例),尤其是在代码中成为热点时。

在这种情况下,您必须应用科学方法。提出一个假设,进行更改,然后进行测试。如果您达到了绩效目标,那么您就完成了。如果不是,请转到列表中的下一个内容。


以粗体回答问题:

什么时候适合使用方法A与方法B,反之亦然?

老实说,这是尝试处理性能或内存问题的最后一步。方法A与方法B的影响会因语言平台的不同而有所不同(在某些情况下)。

几乎任何带有中途优化器的编译语言都将使用这些结构之一生成相似的代码。但是,这些假设在没有优化程序的专有语言和玩具语言中不一定适用。

究竟哪个效果更好,取决于sum堆栈变量还是堆变量。这是语言实现的选择。例如,在C,C ++和Java中,int默认的数字原语(例如a)是堆栈变量。通过分配给堆栈变量,与完全内联代码相比,代码对内存的影响不会更大。

您可能需要在C库(尤其是较旧的C库)中找到的其他优化是依赖于平台的优化,在这些优化中,您必须决定先复制2维数组还是先复制2维数组。它需要一些有关您所针对的芯片组如何最优化内存访问的知识。架构之间存在细微的差异。

最重要的是,优化是艺术与科学的结合。它需要一些批判性思考,以及在处理问题时的一定程度的灵活性。在指责小事情之前先寻找大事情。


2
这个答案专注于我的问题最多,不被逮住了我的代码示例,即方法A和方法B.
更好的预算

18
我觉得这是“如何解决性能瓶颈”的通用答案,但是您将很难根据使用此方法的特定函数(具有4个变量还是5个变量)从特定函数中识别相对内存使用情况。我还质疑,当编译器(或解释器)可能会或可能不会对此进行优化时,此优化级别有多重要。
埃里克(Eric)

正如我提到的,@ Eric,性能改进的最后一类将是您的微优化。准确猜测是否会产生影响的唯一方法是测量性能分析器中的性能/内存。这些类型的改进很少能获得回报,但是在模拟器中对时序敏感的性能问题中,您会遇到一些妥善放置的变化,这些变化可能是达到时序目标与不达到时序目标之间的区别。我想我一方面可以指望在20多年的软件开发工作中获得回报的次数,但这并不是零。
Berin Loritsch '18

@BerinLoritsch再次,总的来说,我同意你的观点,但是在这种情况下,我不同意。我提供了自己的答案,但我个人还没有看到任何可以标记甚至提供方法来识别与函数的堆栈内存大小有关的性能问题的工具。
埃里克(Eric)

@DocBrown,我已经补救了。关于第二个问题,我几乎同意你的看法。
Berin Loritsch '18

45

“这会减少内存”-em,不。即使这是正确的(对于任何体面的编译器都不是),对于任何现实情况,这种差异很可能是可以忽略的。

但是,我建议使用方法A *(方法A稍有更改):

private bool IsSumInRange(int a, int b)
{
    int sum = a + b;

    if (sum > 1000 || sum < -1000) return false;
    else return true;
    // (yes, the former statement could be cleaned up to
    // return abs(sum)<=1000;
    // but let's ignore this for a moment)
}

但是出于两个完全不同的原因:

  • 通过给变量s一个解释性名称,代码变得更加清晰

  • 这样就避免了在代码中使用两次相同的求和逻辑,因此代码变得更加干燥,这意味着更少的错误发生。


36
我将进一步清理它并使用“ return sum> -1000 && sum <1000;”。
18年

36
@Corey任何体面的优化器都会使用CPU寄存器作为sum变量,从而导致内存使用量为零。即使不是,这也只是“叶子”方法中的一个单词。考虑到由于它们的GC和对象模型而导致的Java或C#浪费内存的情况令人难以置信,因此本地int变量实际上不使用任何明显的内存。这是毫无意义的微观优化。
阿蒙

10
@Corey:如果它“ 稍微复杂一点 ”,它可能不会变成“明显的内存使用情况”。也许如果您构建一个更复杂的示例,但这使问题变得不同。还要注意,只是因为您没有为表达式创建特定的变量,对于复杂的中间结果,运行时环境仍可能在内部创建临时对象,因此它完全取决于语言,环境,优化级别和详细信息。不管你怎么称呼“值得注意”。
布朗

8
除了以上几点,我很确定C#/ Java选择存储的方式sum将是实现细节,并且我怀疑有人会说服诸如避免使用本地变量这样的愚蠢技巧int导致这种情况吗?从长远来看,内存使用量。IMO的可读性更为重要。可读性可能是主观的,但是FWIW个人而言,我不希望您两次执行相同的计算,而不是为了CPU使用率,而不是因为我在寻找错误时只需要检查一次您的添加。
jrh

2
……还请注意,一般来说,垃圾收集的语言是一种不可预测的“搅动内存的海洋”(无论如何对于C#而言)只能在需要时才清理,我记得制作了一个分配了千兆字节RAM的程序,它只是启动了“当记忆变得稀缺时,便会自行清理”。如果GC不需要运行,则可能需要花费很多时间,并可以节省CPU来处理更紧迫的事情。
jrh

35

你可以做的比这两个都好

return (abs(a + b) > 1000);

大多数处理器(以及因此的编译器)都可以在单个操作中执行abs()。您不仅拥有更少的总和,而且拥有的比较数也更少,这通常在计算上更加昂贵。它还会删除分支,这在大多数处理器上会更加糟糕,因为它停止了流水线化的可能。

就像其他答案所说的那样,面试官是植物的生命,没有进行技术面试的业务。

也就是说,他的问题是正确的。关于何时进行优化以及如何进行优化的答案是,当您证明有必要时,并且已经对其进行了概要分析,以证明确切的零件需要它。克努斯(Knuth)有句著名的话说,过早的优化是万恶之源,因为试图将不重要的部分镀金或进行无效的更改(如面试官的更改)太容易了,而错过了真正需要它的地方。在没有确凿的证据确实有必要之前,更清晰的代码是更重要的目标。

编辑 FabioTurati正确地指出,这与原始逻辑含义相反(我的错误!),并且这说明了Knuth引用的进一步影响,在我们尝试优化代码的过程中,我们可能会破坏代码。


2
@Corey,我非常确定Graham会按预期将请求“他挑战我以更少的变量来解决相同的问题”。如果我要成为面试官,我希望得到这个答案,而不会a+b进入if并重复两次。您理解错了“他很高兴,并说这将通过这种方法减少内存使用” -他对您很好,对这种关于内存的毫无意义的解释隐藏了他的失望。您不应该在这里认真提问。你找到工作了吗?我猜你没有:-(
Sinatr

1
您要同时应用2个转换:您已使用将2个条件转换为1,abs()并且还有一个single return,而不是在条件为true时(“ if branch”)有一个,而在条件为false时又有一个( “其他分支”)。当您以这种方式更改代码时,请当心:可能会无意中编写一个在返回false时返回true的函数,反之亦然。这正是这里发生的情况。我知道您正在专注于另一件事,并且您在此方面做得很好。尽管如此,这很容易使您
失去

2
@FabioTurati很好-感谢!我将更新答案。这是关于重构和优化的一个好点,这使Knuth的报价更加相关。在冒险之前,我们应该证明我们需要优化。
格雷厄姆'18

2
大多数处理器(以及因此的编译器)都可以在单个操作中执行abs()。不幸的是,整数不是这样。如果已经从中设置了标志,则ARM64可以使用条件否定符adds,并且ARM带有谓词reverse-sub(rsblt= less-tha时为reverse-sub),但其他所有条件都需要多个额外的指令来实现abs(a+b)abs(a)godbolt.org/z/Ok_Con显示了x86,ARM,AArch64,PowerPC,MIPS和RISC-V asm输出。只是通过将比较转换为范围检查(unsigned)(a+b+999) <= 1998U,gcc才能像Phil的回答中那样优化它。
彼得·科德斯

2
此答案中的“改进”代码仍然是错误的,因为它为产生了不同的答案IsSumInRange(INT_MIN, 0)。原始代码返回false是因为INT_MIN+0 > 1000 || INT_MIN+0 < -1000;但是返回“新的和改进的”代码true是因为abs(INT_MIN+0) < 1000。(或者,在某些语言中,它将引发异常或未定义的行为。请检查您的本地列表。)
Quuxplusone

16

什么时候适合使用方法A与方法B,反之亦然?

硬件便宜;程序员很贵。因此,你们两个人在这个问题上浪费的时间可能比任何一个答案都要差得多。

无论如何,大多数现代编译器都会找到一种方法来优化局部变量到寄存器中(而不是分配堆栈空间),因此就可执行代码而言,这些方法可能是相同的。因此,大多数开发人员会选择最清楚地传达意图的选项(请参阅编写非常明显的代码(ROC))。我认为这就是方法A。

另一方面,如果这纯粹是一项学术活动,则可以使用方法C兼得两全:

private bool IsSumInRange(int a, int b)
{
    a += b;
    return (a >= -1000 && a <= 1000);
}

17
a+=b这是一个巧妙的技巧,但我不得不提一下(以防万一,答案的其余部分没有暗示),从我的经验中,弄乱参数的方法可能很难调试和维护。
jrh

1
我同意@jrh。我是中华民国的坚决拥护者,但那绝不是什么。
John Wu

3
硬件便宜;程序员昂贵。在消费电子世界中,该说法是错误的。如果您销售了数百万个产品,那么花费50万美元的额外开发成本来节省每单位硬件成本0.10美元是一个非常好的投资。
Bart van Ingen Schenau '18

2
@JohnWu:您简化了if检查,但是忘记了反转比较结果;你的函数现在返回true的时候a + b不是在范围内。!在条件(return !(a > 1000 || a < -1000))的外部添加a ,或分布!,进行反向测试,以使return a <= 1000 && a >= -1000;Or正常进行范围检查,return -1000 <= a && a <= 1000;
-ShadowRanger

1
@JohnWu:在边缘情况下仍然略有偏离,分布式逻辑要求<=/ >=,而不是</ >(将</ >,1000和-1000视为超出范围,原始代码将它们视为在范围内)。
ShadowRanger

11

我会针对可读性进行优化。方法X:

private bool IsSumInRange(int number1, int number2)
{
    return IsValueInRange(number1+number2, -1000, 1000);
}

private bool IsValueInRange(int Value, int Lowerbound, int Upperbound)
{
    return  (Value >= Lowerbound && Value <= Upperbound);
}

小型方法只会做一件事,但很容易推理。

(这是个人喜好,我喜欢正面测试而不是负面测试,您的原始代码实际上正在测试该值是否不在范围内。)


5
这个。(上面提到的评论与re:readability类似)。30年前,当我们使用RAM小于1mb的机器时,压缩性能是必要的-就像y2k问题一样,获得数十万条记录,其中每条记录都有几字节的内存由于未使用的var和引用等,当您只有256k的RAM时,它很快就会加起来。现在,我们正在处理具有多个千兆字节RAM的机器,因此即使节省几MB RAM使用量以及代码的可读性和可维护性也不是一件好事。
ivanivan

@ivanivan:我不认为“ y2k问题”确实与内存有关。从数据输入的角度来看,输入两位数字要比输入四位数字更有效,并且将输入的内容保持为比将它们转换为其他形式更容易。
超级猫

10
现在,您必须跟踪2个函数以查看发生了什么。您不能从表面上看待它,因为您无法从名称中分辨出这些值是包含边界还是排除边界。并且,如果添加了该信息,则函数的名称将比表示它的代码长。
彼得

1
优化可读性,并制作小巧,易于使用的功能-当然可以。但我强烈不同意更名a,并bnumber1number2以任何方式辅助可读性。同样,您对函数的命名也不一致:IsSumInRange如果IsValueInRange接受范围作为参数,为什么要对范围进行硬编码?
左右约

第一个功能可能溢出。(就像其他答案的代码一样。)尽管安全溢出代码的复杂性是将其放入函数的一个论据。
philipxy

6

简而言之,我认为这个问题与当前的计算没有太大关系,但是从历史的角度来看,这是一个有趣的思想练习。

您的面试官可能是“神秘人月”的粉丝。在本书中,弗雷德·布鲁克斯(Fred Brooks)提出了这样的假设:程序员通常会在其工具箱中需要两个版本的关键功能:内存优化版本和cpu优化版本。弗雷德(Fred)基于他领导IBM System / 360操作系统开发的经验而建立,在该系统中,机器可能只有8 KB的RAM。在这样的机器中,函数中局部变量所需的内存可能很重要,尤其是在编译器没有有效地优化它们的情况下(或者如果代码是直接用汇编语言编写的)。

在当前时代,我认为很难找到一个系统,在该系统中方法中是否存在局部变量会产生显着差异。对于重要的变量,该方法将需要具有预期的深度递归的递归。即使这样,在变量本身引起问题之前,也有可能超过堆栈深度,从而导致堆栈溢出异常。唯一可能出现问题的实际情况是使用递归方法在堆栈上分配非常大的数组。但这也不是不可能的,因为我认为大多数开发人员都会对大型数组的不必要副本三思而行。


4

赋值后,s = a + b; 变量a和b不再使用。因此,如果您没有使用完全损坏大脑的编译器,则不会将内存用于。无论如何,用于a和b的内存都将重新使用。

但是优化此功能完全是胡说八道。如果可以节省空间,则函数运行时可能会占用8个字节(函数返回时会恢复),因此绝对没有意义。如果可以节省时间,那将是一个十亿分之一秒。优化这是在浪费时间。


3

局部值类型变量在堆栈上分配,或者(对于这种小的代码段更可能)使用处理器中的寄存器,而从没有看到任何RAM。无论哪种方式,它们都是短暂的,无需担心。当您需要在可能很大且寿命较长的集合中缓冲或排队数据元素时,就开始考虑使用内存。

然后,这取决于您最关心的应用程序。处理速度?响应时间?内存占用量?可维护性?设计一致性?一切由你决定。


4
细说:.NET至少(该帖子的语言未指定)不能保证“在堆栈上”分配局部变量。请参阅Eric Lippert的“栈是实现细节”
jrh

1
@jrh堆栈或堆上的局部变量可能是实现细节,但是如果有人真的想要堆栈上的变量,那么stackalloc现在是Span<T>。分析后在热点中可能有用。此外,有关结构的一些文档暗示值类型可能在堆栈上,而引用类型却不在。无论如何,充其量您最好避免使用GC。
鲍勃

2

正如其他答案所说的那样,您需要考虑要优化的内容。

在此示例中,我怀疑任何合适的编译器都会为这两种方法生成等效的代码,因此该决定不会影响运行时间内存!

确实影响是代码的可读性。(代码供人类阅读,而不仅仅是计算机。)这两个示例之间没有太多区别;当其他所有条件都相同时,我认为简洁是一种美德,因此我可能会选择方法B。但是其他所有条件都很少相等,在更复杂的现实情况下,它可能会产生很大的影响。

注意事项:

  • 中间表达有副作用吗?如果它调用了任何不纯函数或更新了任何变量,那么复制它当然将是正确性的问题,而不仅仅是样式。
  • 中间表达有多复杂?如果它执行大量计算和/或调用函数,则编译器可能无法对其进行优化,因此这会影响性能。(不过,正如Knuth 所说,“我们应该忘记效率低下的问题,大约有97%的时间可以说。”)
  • 中间变量有什么意义吗?可以给它一个名称来帮助解释发生了什么吗?简短而翔实的名称可以更好地解释代码,而毫无意义的名称只是视觉噪音。
  • 中间表达多长时间?如果太长,则复制它会使代码更长且更难阅读(特别是如果强制换行);如果不是这样,重复可能会更短。

1

正如许多答案所指出的那样,尝试使用现代编译器调整此功能不会有任何不同。优化器最有可能找出最佳解决方案(投票给显示汇编代码的答案以证明这一点!)。您说采访中的代码并不完全是您要求比较的代码,因此实际示例也许更有意义。

但是,让我们再来看一个问题:这是一个面试问题。因此,真正的问题是,假设您想尝试并获得这份工作,应该如何回答?

我们还假设面试官确实知道他们在说什么,而他们只是想看看您知道什么。

我会提到,忽略优化器,第一个可能在堆栈上创建一个临时变量,而第二个则不会,但是会执行两次计算。因此,第一个使用更多的内存,但速度更快。

您可能会提到,无论如何,计算可能需要一个临时变量来存储结果(以便对其进行比较),因此无论您是否命名该变量都可能没有任何区别。

然后我会提到,实际上,由于所有变量都是本地的,因此将对代码进行优化,并且很可能会生成等效的机器代码。但是,这确实取决于您使用的编译器(不久前,我可以通过在Java中将局部变量声明为“ final”来获得有用的性能改进)。

您可能会提到堆栈在任何情况下都位于其自己的内存页面中,因此,除非您的额外变量导致堆栈使页面溢出,否则实际上不会分配更多的内存。如果确实溢出,则将需要一个全新的页面。

我会提到一个更现实的示例,可能是选择是否使用缓存来保存许多计算的结果,这将引起CPU与内存的问题。

所有这些表明您知道自己在说什么。

最后,我要说的是,专注于可读取性会更好。尽管在这种情况下是正确的,但在访谈中它可能会被解释为“我不了解性能,但是我的代码读起来像简妮特和约翰的故事”。

您不应该做的是散发出关于无需进行代码优化的平淡无奇的声明,在对代码进行概要分析之前不要进行优化(这只是表明您看不到自己的错误代码),硬件成本比程序员少,并且请不要引用Knuth的“过早等等……”。

在许多组织中,代码性能是一个真正的问题,许多组织都需要了解它的程序员。

特别是对于像Amazon这样的组织,某些代码具有巨大的影响力。一个代码段可以部署在数千个服务器或数百万个设备上,一年中每天可能被调用数十亿次。可能有成千上万的类似片段。好的算法与好的算法之间的差异很容易达到千分之一。用数字和所有这些乘以:这有所作为。如果系统容量不足,组织不良代码的潜在成本可能非常高,甚至致命。

此外,这些组织中的许多组织都在竞争环境中工作。因此,如果竞争对手的软件已经可以在他们所拥有的硬件上正常运行,或者该软件可以在手机上运行并且无法升级,您就不能仅仅告诉客户购买一台更大的计算机。某些应用程序特别注重性能(想到游戏和移动应用程序),并且可能会根据其响应性或速度而存活或死亡。

我个人过去二十多年一直在许多项目中工作,这些项​​目由于性能问题而导致系统出现故障或无法使用,并且我曾被要求优化这些系统,并且在所有情况下,这都是由于程序员的错误代码所致,他们不理解他们所写内容的影响。再者,它从来都不是一件代码,它无处不在。当我出现时,是时候开始考虑性能了:损坏已经造成。

与了解代码正确性和代码样式相同,了解代码性能是一项很好的技能。它来自实践。性能故障可能与功能故障一样严重。如果系统无法正常工作,那么它将无法正常工作。没关系,为什么。同样,从未使用过的性能和功能都很差。

因此,如果面试官问您有关表现的问题,我建议您尝试并展示尽可能多的知识。如果这个问题看起来很糟糕,请礼貌地指出为什么您认为在这种情况下这不是问题。不要引用Knuth。


0

您首先应该优化正确性。

对于接近Int.MaxValue的输入值,您的函数将失败:

int a = int.MaxValue - 200;
int b = int.MaxValue - 200;
bool inRange = test.IsSumInRangeA(a, b);

由于总和溢出到-400,因此返回true。该函数也不适用于a = int.MinValue +200。(错误地加起来为“ 400”)

除非他或她听到了声音,否则我们将不知道他们在寻找什么,但是“溢出是真实的”

在面试情况下,提出问题以澄清问题的范围:允许的最大和最小输入值是多少?获得这些值后,如果调用方提交的值超出范围,则可以引发异常。或者(在C#中),您可以使用选中的{}部分,这将在溢出时引发异常。是的,这需要更多的工作和复杂性,但是有时候就是这样。


这些方法仅是示例。他们写的不是正确的,而是为了说明实际的问题。感谢您的输入!
更好的预算

我认为面试问题是针对表现的,因此您需要回答问题的意图。面试官并没有询问极限行为。但是无论如何有趣的方面。
rghome

1
@Corey好面试官可以回答以下问题:1)根据rghome的建议评估有关该问题的候选人能力,以及2)作为较大问题(例如潜意识的功能正确性)和相关知识深度的门户-如此在以后的职业面试中-祝你好运。
chux

0

您的问题应该是:“我是否需要对此进行优化?”。

版本A和B在一个重要的细节上有所不同,使A更可取,但与优化无关:您无需重复代码。

实际的“优化”称为通用子表达式消除,几乎每个编译器都这样做。有些人甚至在优化关闭时也会执行此基本优化。因此,这并不是真正的优化(几乎肯定会在每种情况下生成的代码都完全相同)。

但是,如果不是优化,那么为什么它是可取的呢?好吧,您不在乎代码,谁在乎!

好吧,首先,您不会有偶然地弄错一半条件语句的风险。但更重要的是,阅读此代码的人可以立即查看您要尝试执行的操作,而不是if((((wtf||is||this||longexpression))))体验。读者可以看到的是if(one || theother),这是一件好事。不是很少,我碰巧是其他人三年后读自己的代码和思考“跆拳道这意味着什么?”。在这种情况下,如果您的代码立即传达意图,那将总是有帮助的。正确地命名一个公共子表达式就是这种情况。
另外,如果将来有任何决定,例如,您需要更改a+ba-b,则必须更改一个位置,不是两个。而且,也不会(再次)偶然地弄错第二个错误的风险。

关于您的实际问题,您应该优化什么,首先您的代码应该是正确的。这是绝对最重要的事情。不正确的代码是错误的代码,甚至更重要的是,即使尽管不正确,它也可以“正常工作”,或者至少看起来像是可以正常工作。在那之后,代码应该是可读的(不熟悉它的人可以阅读)。
至于优化...当然不应该故意编写反优化的代码,当然我并不是说您不应该在开始设计之前就花点时间思考(例如为问题选择正确的算法,效率最低)。

但是对于大多数应用程序而言,在大多数情况下,使用合理的算法通过优化的编译器运行正确,可读的代码后所获得的性能就很好,而无需担心。

如果不是这种情况,即如果应用程序的性能确实不满足要求,那么只有在那时,您才应该担心像您尝试的那样进行局部优化。不过,最好还是重新考虑顶层算法。如果由于更好的算法而将函数调用500次而不是50,000次,那么与在微优化上节省三个时钟周期相比,这具有更大的影响。如果您一直没有在随机存储器访问上停滞数百个周期,那么这会比进行一些廉价的计算等产生更大的影响。

优化是一件困难的事情(您可以写整本书而无休止),花时间盲目地优化某个特定位置(甚至根本不知道这是否是瓶颈!)通常是浪费时间。没有概要分析,优化就很难正确进行。

但是,根据经验,当您盲目地只需要/想要做某事时,或者作为一般的默认策略,我建议针对“内存”进行优化。
优化“内存”(尤其是空间位置和访问模式)通常会带来好处,因为与以前的情况“千篇一律”不同,如今访问RAM是最昂贵的事情(缺少从磁盘读取的内容!)您原则上可以做到的。另一方面,ALU价格便宜,而且每周都在增长。内存带宽和延迟的提高速度几乎没有提高。与大量数据应用程序中的不良访问方式相比,良好的位置和良好的访问方式可以在运行时轻松地产生5倍的差异(在极端情况下,人为的示例要高出20倍)。善待您的缓存,您将成为一个快乐的人。

为了使上一段得到透视,请考虑您可以做的各种不同的事情花费了您什么。执行这样的操作a+b需要花费(或优化)一个或两个周期,但是CPU通常每个周期可以启动几条指令,并且可以流水线化非依赖的指令,因此更实际的说,它仅花费您半个周期或更短的时间。理想情况下,如果编译器擅长调度,并且视情况而定,则其成本可能为零。
如果很幸运并且在L1中,则获取数据(“内存”)的成本为4-5个周期,而如果不是那么幸运(L2命中),则花费大约15个周期。如果数据根本不在缓存中,则需要花费数百个周期。如果您的随机访问模式超出了TLB的能力(只需输入约50个条目就很容易做到),则再增加几百个周期。如果您的偶然访问模式实际上导致了页面错误,则在最佳情况下将花费一万个周期,在最坏情况下将花费数百万个周期。
现在考虑一下,您最想避免的事情是什么?


0

何时针对一种方法优化内存与性能速度?

首先获得正确的功能。然后选择性与微优化有关。


作为有关优化的面试问题,代码确实引起了通常的讨论,但没有达到更高的目标:代码功能是否正确?

C ++和C以及其他人都把int溢出问题看作是一个问题a + b。它定义不正确,C称之为未定义行为。未指定要“包装”-即使这是常见的行为。

bool IsSumInRange(int a, int b) {
    int s = a + b;  // Overflow possible
    if (s > 1000 || s < -1000) return false;
    else return true;
}

这样的函数IsSumInRange()可以很好地定义并针对的所有int值正确执行a,b。原始a + b不是。AC解决方案可以使用:

#define N 1000
bool IsSumInRange_FullRange(int a, int b) {
  if (a >= 0) {
    if (b > INT_MAX - a) return false;
  } else {
    if (b < INT_MIN - a) return false;
  }
  int sum = a + b;
  if (sum > N || sum < -N) return false;
  else return true;
}

上面的代码可以通过使用更宽的整数型比进行优化int,如果有的话,如以下或分配sum > Nsum < -N所述内测试if (a >= 0)逻辑。但是,如果使用智能编译器,这种优化可能不会真正导致“更快”的发出代码,也不值得为了保持聪明而进行额外的维护。

  long long sum a;
  sum += b;

使用abs(sum)时甚至容易出现问题sum == INT_MIN


0

我们在谈论什么样的编译器,以及什么样的“内存”?因为在您的示例中,假设使用合理的优化程序,所以a+b在执行这种算术之前,通常需要将表达式存储在寄存器(一种内存形式)中。

因此,如果我们谈论的是一个笨拙的编译器a+b,它遇到两次,那么它将在第二个示例中分配更多的寄存器(内存),因为您的第一个示例可能只将该表达式在映射到局部变量的单个寄存器中存储一次,但是在这一点上,我们谈论的是非常愚蠢的编译器……除非您正在与另一种类型的愚蠢的编译器一起工作,该愚蠢的编译器会在每个位置上溢出所有单个变量,在这种情况下,第一个变量可能会比优化更麻烦第二*。

我仍然想解决这个问题,并认为第二个可能会在哑堆编译器上使用更多的内存,即使它容易发生堆栈溢出,因为它最终可能会分配三个寄存器用于a+b溢出ab甚至更多。如果我们谈论的最原始的优化则捕捉a+bs可能会“帮助”它用更少的寄存器/栈溢出。

在没有测量/拆卸的情况下,这都是非常愚蠢的猜测,甚至在最坏的情况下,这也不是“内存与性能”的情况(因为即使在我能想到的最差的优化程序中,我们也不是在谈论关于除临时存储器(如堆栈/寄存器)以外的任何事物,充其量只是一种“性能”情况,在任何合理的优化器中,两者都是等效的,如果不使用合理的优化器,为什么对优化如此迷恋本质上又为何?特别是缺少测量?这就像指令选择/寄存器分配程序集级别的关注点,我从不希望任何人希望在使用解释器时(例如,堆栈溢出的东西)保持生产力。

何时针对一种方法优化内存与性能速度?

至于我是否可以更广泛地解决这个问题,通常我不会发现两者截然对立。尤其是如果您的访问模式是顺序的,并且考虑到CPU缓存的速度,通常减少对非平凡输入的顺序处理的字节数可以(最多)转化为更快地浏览该数据。当然,存在断点,如果数据量大得多,交换量小得多,指令量大,则以较大的形式顺序处理以交换较少的指令可能会更快。

但是我发现许多开发人员往往低估了在这种情况下减少的内存使用量可以转化为处理时间的成比例减少。将性能成本转换为指令而不是将内存访问转换为达到大型LUT的目的是徒劳的,这是徒劳的,旨在加快一些小型计算的速度,但却发现性能因附加的内存访问而降低。

对于通过一些大数组进行顺序访问的情况(不像您的示例那样讲局部标量变量),我遵循这样的规则:顺序进行耕作的内存更少会转化为更高的性能,尤其是当生成的代码比其他方法更简单时,直到没有不会,直到我的测量和探查器告诉我其他情况,并且这很重要,以相同的方式,我假设顺序读取磁盘上的一个较小的二进制文件要比较大的二进制文件快得多(即使较小的二进制文件需要更多指令) ),直到该假设在我的测量中不再适用。

By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.