【问题标题】:Can all code optimization by Lisp compile-time evaluation be achieved by an ideal compiler in other languages (such as C++)?Lisp 编译时评估的所有代码优化都可以通过其他语言(例如 C++)的理想编译器来实现吗?
【发布时间】:2021-12-15 21:01:48
【问题描述】:

在 Lisp 中,您可以通过在宏的编译期间评估条件来优化代码。如,您有一个宏 (compute-for-N 1) 评估为 code-1(compute-for-N 2) 评估为 code-2

如果你用 C++ 编写类似的东西,一个非常幼稚的编译器会在执行期间评估条件,从而减慢程序速度。

我的问题是,所有可能的 Lisp 评估时间优化也可以由理想的编译器完成吗?作为后续,如果一个理想的编译器实际上可以实现与任何手动编写的编译时优化相似或更好的结果,那么尝试编写手动代码优化是否是不好的代码实践?

PS:使用像 Lisp 这样的语言显然有更多的优势,所以这个问题并不是在质疑 Lisp 的潜在效用。

【问题讨论】:

  • C++ 有constexpr,明确要求编译器在编译期间进行计算。但是,只要在编译时所有数据都是已知的,任何优化编译器都会进行这些计算。 constexpr 引入了在编译时评估整个函数的概念。模板甚至允许递归编译时计算。我不知道这是否涵盖了 Lisp 所做的一切,但我猜是的。
  • as-if rule 基本上(并且松散地 - 评论中没有太多空间)说 “理想的编译器可以优化 [X] 吗?” 的答案是 “是”。这应该作为副本关闭吗? (我认为推测假设的理想编译器应该如何影响premature-optimization 没有多大价值。)如果没有,您能否将问题的焦点从“是否可能”转移开?
  • 例如,您可以编译 C++,首先将其翻译成 Lisp,然后再编译。
  • @CrisLuengo 你提到的那些 C++ 指令是手动编写的——它们在某种意义上类似于在 Lisp 中编写宏。问题是关于在没有我们作为开发人员干预的情况下可以自动优化的限制。
  • "...如果一个理想的编译器实际上可以实现与任何手动编写的编译时优化相似或更好的结果,那么尝试编写手动代码优化是否是不好的代码实践?" -- 我不确定什么是“理想的编译器”,但在发现问题之前,您通常应该避免编写“手动代码优化”,如果这很重要,您应该比较各种解决方案,包括优化标志,按时间和分析。在这种情况下,实际编译器比理想编译器更相关。

标签: c++ optimization lisp compiler-optimization software-quality


【解决方案1】:

正如人们指出的那样,您有 constexpr 和类似的东西,并且大多数 C++ 优化器都非常积极地进行常量折叠并在那里做近乎神奇的事情。

如果您将 LISP 编译器甚至 C++ 编译器视为 JIT,则常量折叠有一个概念上的优势。我不确定这是否是您所追求的,但作为一名涉足编译器设计的人,这是我的兴趣所在。

即使是最激进的优化器也无法预测用户在运行时会做什么。因此,假设用户在运行时将 N 的值键入为 128,然后我们使用 N 作为输入执行一些密集任务(例如可能涉及数十亿次迭代的路径跟踪)。如果我们使用 128 为 N 即时编译新代码,并且编译开销与处理涉及 N 的代码所花费的时间相比相形见绌,那么常量折叠适用于 N,前提是我们在编译之前分配了它的值如果在编译时没有为 N 赋值,则以其他方式无法在任何语言/编译器中进行编码。如果 N 在这种情况下发生变化,我们会即时重新编译代码。

所以对我来说,这似乎是一个巨大的潜在优化来源,可以为响应用户输入而动态编译的代码。我不知道这能回答你的问题多少,但总是存在差距,即使是最激进的优化器也无法知道用户在运行时会做什么。也就是说,C++ 优化器在编译/链接时可以知道的信息方面做得非常出色。

我在 C++ 优化器和优化器中一直发现的一件事是,它们在某些情况下看起来完全愚蠢,而在其他情况下却非常出色。例如,我在我们的代码中发现了一个分析器热点,涉及对一个变量的除法和取模,通过我们的运行时代码(但不是在编译时,因为它基于用户输入)保证是两倍大小的幂。但它不是在编译时确定的,所以优化器实际上使用了昂贵的除法和取模指令。所以我发现我可以通过预先计算 log2(N) 和 N-1 并使用手动移位和按位与除法和模运算来优化大约 30% 的性能。

【讨论】:

    【解决方案2】:

    是的,C++ 编译器可以生成高效的代码,理想的 C++ 编译器应该能够使代码尽可能高效。

    理想的编译器会利用您能想到的所有优化技术。与真正的编译器不同,理想的编译器不受那些令人讨厌的时间和空间限制(以及人类的聪明才智),因此它甚至可以实现最古怪的优化想法。目前可以用另一种语言(例如 Lisp)进行的优化并不奇怪,而且肯定属于理想 C++ 编译器的能力范围内。

    我认为以上内容适用于所有编译语言,而不仅仅是 C++。然而,C++ 标准确实通过as-if rule 明确了这一点,它确立了标准只要求可观察的行为;允许编译器以他们认为合适的方式实现此行为。事实上,就标准而言,只要水晶球引起正确的可观察行为,编译器就可以生成魔法水晶球并符合要求。

    确实,C++ 标准并不禁止速度。


    好吧,对于某些人的口味来说,调用魔法可能过于夸张了。对于更基于现实的极端示例,请考虑排序。假设有一个函数以没有可观察到的副作用的方式对数组进行排序;此函数唯一可观察到的行为是数组从任意顺序转换为已排序。这对很多读者来说应该很熟悉。

    如果为 C++ 编译器提供此功能,则唯一的要求是生成的机器代码必须对数组进行排序而没有副作用。考虑一下。该函数的机器代码必须保留可观察的行为;它确实必须完全符合程序员编写的代码。理论上,编译器可以用堆排序之一替换冒泡排序的实现。它具有相同的可观察行为。

    据我所知,开发编译器的人没有考虑过这一级别的优化(我认为他们也不应该)。但是,它是 C++ 标准明确允许的。这演示了 as-if 规则可以推多远。 任何可以想象的有效优化都是允许的。理想的编译器会实现所有可能的优化(与现实的编译器相反,现实的编译器最多只能努力实现那些合理的优化)。特别是,Lisp 可以做的任何优化也可以由 ideal C++ 编译器完成。


    对于后续问题,是的,这将是不好的做法,但出于您提出的原因之外的原因。

    手动代码优化很可能属于premature optimization, "the root of all evil"。编写过早的优化是不好的代码实践。 如果您的手动优化不是为时过早,那么(或者是常规的,或者)已经进行了性能测试以确定对它的需求。相同的测试例程可以确定手动优化是否比您的编译器获得更好的结果,从而使后续问题变得毫无意义。

    此外,由于不存在理想的编译器,因此让实际代码受理想编译器的能力影响是个坏主意。

    【讨论】:

    • 很好地重复了您的评论,但使用了多余的不必要的词。简单地说“所有可能的事情都将由理想的编译器完成”的基本主要问题是代码可以以预期的方式运行。因此,例如,如果您的程序操作指针,您的编译器可能会认为使用一些优化会改变内存的组织方式,它可能会选择安全的次优优化。或者可能不是,但我认为你跳得太快只是因为你认为一个问题是微不足道的。
    • @NicSzerman “它可能会选择安全的次优优化”——不,理想的编译器会选择最佳优化,因为它是理想的。问题中的一个缺陷是询问“理想”编译器而不是实际存在的编译器。对于一个理想的编译器,一切皆有可能,甚至是魔法。不要怪我,因为你没有问你打算问什么。我提示你澄清你的问题,但你拒绝了,所以我回答了所问的问题。
    【解决方案3】:

    编译器通常(并且通常应该,并且看起来 C++ 规范明确允许这样做)被允许为所欲为以提高程序的性能,同时不改变它的可观察方式行为,并且(我会说)也不会导致过度的编译时副作用:即使程序本身打算这样做,您也不希望编译程序的过程来发射核导弹。也许您还想添加编译器应该终止的约束:这对于真正的编译器并不总是如此。

    Lisp 系列语言与大多数其他语言之间的唯一区别在于,用户代码在 Lisp 中执行此类操作可能更容易,或者在历史上一直如此。

    例如,在 Common Lisp 中,考虑一下:

    (defun sum-to-n (n)
      (declare (type (integer 0) n))
      (if (zerop n)
          0
        (+ n (sum-to-n (1- n)))))
    

    嗯,这是一个糟糕的功能,但是:

    (define-compiler-macro sum-to-n (n)
      (typecase n
        ((integer 0)
         (/ (* n (1+ n)) 2))
        (number
         (error "you are a sponge"))
        (t
         `(let ((m ,n))
            (declare (type (integer 0) m))
            (/ (* m (1+ m)) 2)))))
    

    现在,(sum-to-n 101010101) 是一个编译时常量(实际上是5101520302520151(sum-to-n (f q)) 变成了

    (let ((m (f q)))
      (declare (type (integer 0) m))
      (/ (* m (1+ m)) 2))
    

    (sum-to-n 12.0) 是编译时错误。

    所以这很好,而且很容易做到,而且它和类似的事情在很大程度上很容易做到,因为在 Lisp 中,程序很容易推理自己的源代码。

    但是绝对没有可以阻止 C++ 或任何其他编译器进行它认为可能的任何优化,甚至是极其英勇的优化。事实上,绝对没有除了用户努力之外,可能是为了防止任何人编写将 C++ 程序作为参数并发出相同代码的优化版本的程序。

    【讨论】:

      猜你喜欢
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 2013-09-11
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 2014-01-16
      • 1970-01-01
      相关资源
      最近更新 更多