有效计算多个结果

时间:2018-05-28 20:33:38

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

上下文

我目前正在优化科学计算库。我是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的优化,但我可以捂住它。

1 个答案:

答案 0 :(得分:0)

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

(先决条件:了解机器中的内存,位和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然后是指向double的指针,011表示浮点数,001指向其他指针。现在整数,圆锥和浮点数更便宜,这是好的,但缺点是你现在需要更加小心,以确保标记总是正确的。问题是我们不能对我们为整数做的双打做同样的伎俩。像对整数一样将双位斩掉2位并不是真的可行,特别是如果你想要与外部程序兼容的话。

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

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

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

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

答案

但是如果我们知道我们正在调用的函数,我们可以预先为其返回值进行堆栈分配,然后让函数将其值放在那里。如果我们这样做,我们也可以通过以下方式避免浮点指针强制:sbcl有浮点数组的优化表示,而不是指向浮点数的指针数组,浮点数直接存储(例如float*而不是比float**):

(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那样使用它。

经过一些实验,似乎实际上并没有更快。某种程度上仍在进行,我无法解决如何在不内联所有内容的情况下避免它。我也尝试将输入设置为动态范围数组,但这似乎没有帮助。

看了一下之后,我没有找到一种方法来查看编译管道中的中间结果,这让我感到难过,但我在源代码中扫描过,我认为,虽然编译器可以(有时)专门化调用函数,它不能专门返回浮点值。