【问题标题】:Efficiently computing multiple results高效计算多个结果
【发布时间】:2018-05-28 20:33:37
【问题描述】:

上下文

我目前正在优化一个用于科学计算的库。我对 Commom Lisp 还很陌生。我使用的功能非常小,大约可以执行。在普通笔记本电脑上为 10 ns 到几百纳秒。性能已经非常接近 C 但我想要我能得到的每一点速度

我使用 SBCL 及其编译器说明和 (time) 宏进行优化(欢迎任何一般性建议)。我目前正在优化一个单线程计算字符串,该字符串将包含在未来的独立线程中。

问题

为了论证,假设我有一个函数(foo),它逐项添加两个包含 3 个变量的列表。一旦优化,它可能是这样的:

(defun foo (a1 a2 a3 b1 b2 b3)
  (declare (optimize (speed 3))
           (type double-float a1 a2 a3 b1 b2 b3))
  (list (+ a1 b1) (+ a2 b2) (+ a3 b3)))

我会用 :

(time (dotimes (it 1000000 t) (foo 1.0d0 2.0d0 2.351d0 223.0d0 124.2d0 321d0)))

SBCL 笔记

据我所知,编译器抱怨将结果“转换”到列表中很昂贵。

note: doing float to pointer coercion (cost 13)

我想要什么

SBCL 的抱怨似乎是明智的,所以我正在寻找一种方法来消除那些讨厌的列表,无论如何我必须在稍后的某个时候再次剥离这些列表,以便将它们提供给其他计算。我愿意为此走低级并失去(一些)抽象。用例可能如下所示:

(let ((res1 0d0)
      (res2 0d0)
      (res3 0d0))
 (declare (type double-float res1 res2 res3))
 (magic-abstraction-I-want res1 res2 res3 (foo 1d0 1d0 1d0 1d0 1d0 1d0)))

有了这个,我可以用最少或不存在的开销进行字符串计算,只做需要的计算,而不用花时间创建列表或访问它们。

我尝试了什么/想尝试什么

内联

我看到在简单函数(例如 foo)上的性能大幅提升:

(declaim (inline foo))

据我了解,它有点“扩展”函数并在调用它的级别将其内联写入。对吗,它到底是做什么的?这实际上是我想要的吗?它是否以某种方式解决了“投射到列表”的问题?

(另外,如果您从我写的内容中看到我可能误解了某些内容,请随时提供一般的速度优化建议)

编辑:了解values

我修改了foo,现在是:

(defun foo (a1 a2 a3 b1 b2 b3)
  (declare (optimize (speed 3))
           (type double-float a1 a2 a3 b1 b2 b3))
  (values (+ a1 b1) (+ a2 b2) (+ a3 b3)))

SBCL 仍然输出三个关于强制返回值到指针的注释。而且我仍然在消耗字节,用以下方式衡量:

(time (dotimes (it 1000000 t) (foo 1.0d0 2.0d0 2.351d0 223.0d0 124.2d0 321d0)))

但是,使用inline 的调用要快得多,并且没有任何缺点(我猜是预期的):

(declaim (inline foo))
;;;;
(let ((r1 0d0) (r2 0d0) (r3 0d0)
      (a1 1d0) (a2 2d0) (a3 3d0)
      (b1 4d0) (b2 5d0) (b3 6d0))
  (declare (optimize (speed 3))
           (type double-float r1 r2 r3 a1 a2 a3 b1 b2 b3))
  (time (dotimes (it 1000000 t) (setf (values r1 r2 r3) (foo a1 a2 a3 b1 b2 b3)))))

性能与完全相同相同:

(let ((r1 0d0) (r2 0d0) (r3 0d0)
      (a1 1d0) (a2 2d0) (a3 3d0)
      (b1 4d0) (b2 5d0) (b3 6d0))
  (declare (optimize (speed 3))
           (type double-float r1 r2 r3 a1 a2 a3 b1 b2 b3))
  (time (dotimes (it 1000000 t)
          (setf r1 (+ a1 b1))
          (setf r2 (+ a2 b2))
          (setf r3 (+ a3 b3)))))

这正是我想要的。最后一个很次要的事情是SBCL还在抱怨foo的优化,但我只能忍气吞声。

【问题讨论】:

  • @sds:看起来他问的不仅仅是多值问题——问题是关于浮点数的“科学计算”。
  • @RainerJoswig:直接的问题是关于多个值的无约束返回,values 问题回答了这个问题。关于科学计算的次要、更高级的问题应该在单独的问题中或在对这个问题进行大量编辑后更具体地提出。
  • 虽然这个问题的一阶答案是“观察到values 存在”,但我认为这个问题与values 的问题不重复。具体来说,仍然会遇到具有多个值的浮点到指针强制问题,并且该问题的答案与此完全无关。在foo2 中也存在与未引用相关的理解问题,假设编译良好,仅测量函数调用的开销。关于ftype 声明还有一些话要说。
  • @DanRobertson:如果 OP 对values 有足够的了解,可以适当地编辑问题——这样它就不再是一个骗子——我很乐意重新打开它。
  • 好的,我根据您对values 的建议编辑了我的问题。这证明是我想要的。谢谢大家。

标签: floating-point common-lisp inline sbcl multiple-value


【解决方案1】:

好的,所以我要做的第一件事是解释“浮点到指针强制”是什么意思。

(先决条件:了解机器中的内存、位和 C 内存模型的粗略概念)。

在动态类型的 Lisp 中,具有类型而不是变量的是值。因此,您需要一些一致的内存表示,可用于传递任何类型的任何值,并且您需要能够为此确定类型。 (请注意,在某些强类型函数式语言中,可能仍然需要某种结构化内存表示,以便垃圾收集跟随指针,或者以统一的方式表示一个单词中的所有指针,以便多态起作用)。典型的选择是一个指针,它总是一个字(比如说 8 个字节),然后指向的对象可能有一个包含更多关于其类型的信息的标题。在 C 中,它看起来像:

struct LispObj_s {
  uint32_t length;
  uint16_t gc_data;
  uint16_t type_code;
  union {
    int64_t as_smallint;
    float as_float
    double as_double;
    char as_string;
    ... /* bignums, arrays, cons, structs, etc */
  }
}

typedef LispObj_s * LispObj

这很糟糕,因为很多常用的东西(即整数)都有巨大的开销:一个词用于指向对象的指针,一个用于标题(说“我是一个整数,我是 1 个字长”) , 一个用于数字本身。这是 200% 的开销,意味着整数需要指针取消引用和可能的缓存未命中。所以有一个优化:你使用指针的一些最低有效位来说明类型是什么(这些是免费的,因为一切都是字对齐的,所以三个最低有效位总是 0)。然后你可以拥有如果(比如说)最后一位是 0 那么你有一个 fixnum (一个算术很快的小数字,在这种情况下是 63 位),如果你的最后一位是 101 你有一个指向 cons 单元格的指针,如果它们是 111,则为指向双精度的指针,011 为浮点数,001 为指向其他对象的指针。现在整数、conses 和 float 更便宜,这很好,但缺点是您现在需要更加小心以确保标签始终正确。问题是我们不能对双精度数使用与整数相同的技巧。像处理整数那样只从双精度数中去掉 2 位是不可行的,尤其是如果您希望与外部程序兼容。

在编译后的代码中,您希望对对象(如浮点数)本身进行操作,因此将它们以原始形式保存在寄存器或堆栈中。只有编译后的代码可以访问它们并且知道它们是什么类型。

当您想要返回或传递(例如)一个浮点数作为参数时,接收它的代码不一定知道它将获得什么样的对象,而发送它的代码当然也不知道接收者想要什么某种类型的数据。因此,您需要将它转换为某种统一的形式,该形式也说明它是什么类型,为此,您需要为其分配一些内存并将标记指针传递给该内存。当调用一个函数时,你不知道被调用的函数会对浮点数做什么,因此你不一定(见下文)在堆栈上分配它,因为被调用者可能例如将它放在一个全局变量中,然后稍后该变量将指向垃圾数据或未映射的内存。当你返回时更糟糕,因为你将在你自己的堆栈帧中分配浮点数,你将要销毁它。分配往往很慢(尽管比在例如 C 中快得多;主要是因为您正在写入不在缓存中的内存)并且读取分配的对象往往很慢(因为您需要检查和删除标签并取消引用指针,并且经常缓存未命中),这就是为什么 sbcl 在优化器必须分配和装箱对象时抱怨的原因。

为了使分配更快,可以做的一件事是声明动态范围。这告诉 Lisp,一旦当前动态范围结束,您保证某个对象不会最终指向某个地方,因此它可能会被分配到堆栈上。堆栈分配更快,本地缓存更多,释放堆栈分配基本上是免费的,因为不涉及 gc。您可以在传递参数时这样做,但在返回时不能这样做。

使用values 有点像堆栈分配返回的列表(如果在 sbcl 中动态范围,函数的 &rest 参数也可以堆栈分配)除了它是可能的,因此这是返回多个的更有效的方法价值观。不幸的是,由于统一的内存表示,人们无法从中获得分配浮点数的优势。


回答

但是,如果我们知道我们正在调用的函数,我们可以提前为其返回值分配空间,然后让函数将其值放在那里。如果我们这样做,我们还可以通过以下方式避免浮点到指针强制:sbcl 对浮点数组有一个优化的表示,其中浮点数不是指向浮点数的数组,而是直接存储的(例如,作为float* 而不是@ 987654324@):

(defun foo3 (a1 a2 a3 b1 b2 b3 result)
  (declare (optimize (speed 3) (safety 0))
           (type double-float a1 a2 a3 b1 b2 b3)
           (type (simple-array double-float (3)) result))
  (setf (aref result 0) (+ a1 b1)
        (aref result 1) (+ a2 b2)
        (aref result 2) (+ a3 b3))
  nil)

然后我们可以用以下复杂的方式调用它:

(let ((foo-result (make-array '(3) :element-type 'double-float :initial-element 0d0)))
  (declare (dynamic-extent foo-result))
  (foo a1 a2 a3 b1 b2 b3 foo-result)
  (let ((c1 (aref foo-result 0)) ...)
    ...))

这样我们在堆栈上为结果分配空间,然后foo3 将它们填满,然后我们从堆栈中提取它们,大概是到寄存器中。这应该几乎与foo3 在寄存器中返回其结果一样快,并且如果 sbcl 假设函数不会改变它可以调用过去,那么至关重要的是不需要堆分配((编辑:我不认为这是真的)类型检查/拆箱直接进入函数的核心)。

不幸的是,语法令人不快,但在 Lisp 中有一种解决方法:宏。可以实现特殊的宏来定义像foo3 这样的函数,并实现一个特殊的宏来调用和绑定结果,但这很糟糕,因为你现在已经将世界分成两种类型的函数,并且你不能使用高阶函数foo,你可能正在做宏生成宏,这很难调试。相反,想法是这样的:我们使用我们奇怪的调用约定和一个调用它并设置为内联的包装函数生成一个快速版本。然后,每当我们调用包装器函数时,我们都会获得快速调用的优势,并且编译器会消除包装器的所有成本。

(defmacro fast-defun (name num-values lambda-list &body body)
  (assert (and (integerp num-values) (plusp num-values)))
  (let ((fast-name (gensym (symbol-name name)))
        (result (gensym "RESULT"))
        (vars (loop repeat num-values collect (gensym "V")))
    `(progn
        (defun ,fastname ,(cons result lambda-list) ;;FIXME: this only works if lambda-list is a list of symbols
          (declare (optimize (speed 3) (safety 0))
                    (type (simple-array double-float (,num-values))))
          ;; Todo: move declarations here from body
          (multiple-value-bind ,vars (progn ,@body)
            ,@(loop for v in vars
                    for n from 0
                    collect `(setf (svref ,result ,n) ,v))
            nil))
       (declaim (inline ,name))
       (defun ,name ,lambda-list
         (let ((,result (make-array '(,num-values) :element-type 'double-float :initial-element 0d0)))
           (declare (dynamic-extent ,result) (optimize (speed 3)))
           (,fast-name ,result ,@lambda-list)
           (values ,@(loop for n below num-values collect `(aref ,result n))))))))

这个宏不是最通用的形式,但你可以像上面的(defun foo(fast-defun foo 3 一样使用它。


经过更多的实验,似乎这实际上并没有更快。不知何故,consing 仍在发生,如果不内联所有内容,我无法弄清楚如何避免它。我还尝试将输入设为动态范围数组,但这似乎没有帮助。

再环顾四周后,我没能找到一种方法来查看编译管道中的中间结果,这让我很伤心,但我浏览了源代码,我认为,虽然编译器(有时)可以专门化函数的调用,它不能专门化浮点值的返回。

【讨论】:

  • 优秀。伟大的阅读。宏需要一些时间来理解,明天我会玩它,time 结果。
  • 我会为您省力:结果比您问题中的所有内容都差,除了返回一个列表
  • 那太糟糕了。目前不知道为什么会这样:/我只是注意到我在以正确的方式计时功能时遇到了麻烦,因为当在编译时知道输入或实际上不需要输出时,SBCL 一直在做聪明的事情显示。如果我有时间,我会与运行时随机输入和推送到列表的结果进行比较,以确保确定。
  • 另外你为什么不愿意使用inline呢?在我看来,这只是我所做的编译器声明,可以大大加快速度。有缺点吗?超规范没有给出任何警告或任何东西。
  • 这与任何语言中的过度内联相同:您最终会生成更多生成的代码,因此编译速度较慢,缓存中可以容纳的内容较少(主要是如果一个函数是多次使用),它不会与递归混合,调试会提供更糟糕的信息。主要问题是代码大小。每次内联函数时,您都需要为函数支付代码大小的价格,每次内联调用者时都要支付代码大小的价格,以此类推。
猜你喜欢
  • 1970-01-01
  • 1970-01-01
  • 2020-03-21
  • 1970-01-01
  • 2021-09-23
  • 1970-01-01
  • 2017-11-24
  • 1970-01-01
  • 2013-12-05
相关资源
最近更新 更多