【发布时间】:2015-05-17 04:05:28
【问题描述】:
Lisp 列表是否总是在底层实现为链表?
就处理器缓存而言,这是一个问题吗?如果是这样,是否有使用更多连续结构来帮助缓存的解决方案?
【问题讨论】:
标签: linked-list lisp cpu-cache
Lisp 列表是否总是在底层实现为链表?
就处理器缓存而言,这是一个问题吗?如果是这样,是否有使用更多连续结构来帮助缓存的解决方案?
【问题讨论】:
标签: linked-list lisp cpu-cache
链接对是通常的实现,但过去也有其他方法。
CDR 编码是一种列表压缩方案,旨在提高某些 Lisp 机器上硬件支持的 cons 列表的连续性和数据大小。基本思想是使用标签来指示 cons 的形状:其中一种可能性是将下一个 cons 直接存储在第一个元素之后,基本上省略了 cdr 字段。
下一个 cons 本身可以以相同的方式压缩,因此在有利的情况下,您最终会得到一个具有出色连续性的类似数组的结构。 (它不完全是一个数组,因为标记信息必须有空间,并且您不能对其进行索引。)
其中一个棘手的部分是有效地支持压缩 conses 的 car 和 cdr 的突变。 (参见 Steele 的论文,“Destructive Reordering of CDR-Coded Lists”。)如果 conses 是不可变的,则标记方案可以更简单。 This FAQ 有一些关于权衡的有趣讨论。
CDR 编码的一个缺点是,因为 cons 可以是各种不同的“形状”,所以列表操作需要在标签上调度。这引入了代码大小和分支错误预测成本。这些成本大大降低了该功能的吸引力,以至于我不知道任何使用 CDR 编码的现代 Lisp 实现。
如果需要考虑连续性,Lisp 程序员通常会简单地使用数组。
【讨论】:
Lisp 实现通常可以将一些值直接存储在 cons 单元格中:fixnums、字符、... 对于其他所有内容,指针将存储在 car 或 cdr 中。
现在几乎所有使用 cons 单元的实现不使用像 cdr-coding 这样的优化。
内存局部性通常通过使用复制/压缩/分代垃圾收集器来改善。
copying -> 当空间已满时,GC 会复制列表并将新的单元格分配到新的存储区域中,彼此相邻
压缩 -> 一些消除内存间隙或类似情况的方案
世代 -> 寿命较长的对象将被提升到不同的存储区域。因此,在一些 GC 中幸存下来的列表将被复制到另一代,并且单元格将彼此相邻分配。
有时上面的 GC 策略会以奇特的方式组合在一起。
还要注意,在许多 Lisp 程序中,许多这些 cons 单元格可能是短暂的:
(mapcar #'1+
(mapcar #'isqrt '(10 20 30 40 50)) ; <- result is 'garbage'
)
整数平方根列表立即是垃圾。该函数只会遍历新的 cons 单元并分配新的新 cons 单元,不会有太多的缓存非局部性。
使用破坏性操作可以减少 cons 单元的分配。上面可以写成:
CL-USER 24 > (let ((s (mapcar #'isqrt '(10 20 30 40 50))))
(map-into s #'1+ s))
(4 5 6 7 8)
这将摆脱一个分配的列表并进一步提高局部性。
【讨论】:
Rainer 已经提到各种内存管理技术有助于局部性。我想介绍两个使用 SBCL 的实验来说明他的观点。
首先,一个快速实用程序可以打印列表中每个缺点的地址。
(defun print-addresses (list)
(mapl (lambda (cons)
(format t "address: 0x~X~%"
(sb-kernel:get-lisp-obj-address cons)))
list))
在第一个实验中,我们可以看到分配是连续的,因此我们可以创建一个包含 10 个元素的列表,然后查看它们的原始地址,可以发现它们是靠得很近的:
> (print-addresses (loop repeat 10 collect 'dummy))
address: 0x1003F57167
address: 0x1003F57177
address: 0x1003F57187
address: 0x1003F57197
address: 0x1003F571A7
address: 0x1003F571B7
address: 0x1003F571C7
address: 0x1003F571D7
address: 0x1003F571E7
address: 0x1003F571F7
第二个实验。如果我们在两者之间进行一些不相关的分配怎么办?让我们将这样一个列表分配给一个变量,以便我们以后可以戳它。
(defparameter *another-list*
(loop repeat 10
;; using eval to trick the compiler into
;; compiling this piece of dummy code
do (eval '(make-array (random 1000)))
collect 'dummy))
我们可以看到这次的地址更加随机:
> (print-addresses *another-list*)
address: 0x10046E9AF7
address: 0x10046EB367
address: 0x10046ECB97
address: 0x10046EE827
address: 0x10046EF247
address: 0x10046F1F17
address: 0x10046F2007
address: 0x10046F3FD7
address: 0x10046F5E67
address: 0x10046F6887
现在,如果我们使用(sb-ext:gc) 调用 GC,我们可以看到它已将 conses 打包在一起:
> (sb-ext:gc)
> (print-addresses *another-list*)
address: 0x1004738007
address: 0x1004738017
address: 0x1004738027
address: 0x1004738037
address: 0x1004738047
address: 0x1004738057
address: 0x1004738067
address: 0x1004738077
address: 0x1004738087
address: 0x1004738097
在这些示例中,我们没有评估列表元素的位置,我想这是另一天的实验。 :-)
【讨论】:
哲学上“正确”的答案是“Lisp 没有列表,只有缺点”。 Conses 通常用于构建列表,以至于 CL 标准和库中的许多函数都在这些类型的列表上运行。但是 conses 也可以用于构建其他类型的结构,例如地图或图形。所以在(传统的)Lisps 中,基本的数据结构是 cons,而不是列表。该列表只是缺点的一种方便应用。因此,“Lisp 列表”实际上是指“使用 Lisp conses 实现的列表”,而这些列表不能用与 conses 不同的东西来实现;)
当然,还有其他答案中提到的 CDR 编码等技术,可用于有效地表示某些基于 cons 的结构。还有一些库提供不基于链接 conses 的列表数据结构(如 Common Lisp 的 FSet)。
对于像 Common Lisp 和 Scheme 这样的“传统”Lisp 来说,这是正确的。 Clojure 确实将列表作为基本数据类型,而 AFAIK 则根本没有 conses。
【讨论】:
据我了解,Clojures 的序列被实现为VLists。是的,lists 通常是 Lisps 中的链表(尽管我确信有一两个使用其他东西的实验版本)。
【讨论】: