【问题标题】:How can I reuse a gethash lookup in Common Lisp?如何在 Common Lisp 中重用 gethash 查找?
【发布时间】:2010-11-09 03:05:04
【问题描述】:

我有一个哈希表,其中的键是相当复杂的列表,带有符号和整数的子列表,并且应该根据已经存在的值修改值。该表是使用:test #'equal 创建的。

我经常做类似的事情:

(defun try-add (i)
  (let ((old-i (gethash complex-list table nil)))
    (if (may-add old-i)
      (push i (gethash complex-list table)))))

分析显示equal 测试需要很多时间。我有一个优化的想法,gethash 查找的数量可以从两个减少到一个。可以通过重用迭代器在 C++ 中完成,但不确定如何在 Lisp 中完成。有什么想法吗?

【问题讨论】:

    标签: lisp common-lisp


    【解决方案1】:

    不要做任何特别的事情,因为实现会为您完成。

    当然,这种方法是特定于实现的,并且哈希表的性能因实现而异。 (但优化问题总是特定于实现的。)

    以下答案适用于 SBCL。我建议检查您的 Lisp 的哈希表是否执行相同的优化。如果他们不这样做,请向您的供应商投诉!

    在 SBCL 中发生的是 哈希表缓存 GETHASH 访问的最后一个表索引

    当调用 PUTHASH(或等效的 (SETF GETHASH))时,它首先检查该缓存索引处的键是否与您传入的键相等。

    如果是这样,整个哈希表查找例程将被绕过,而 PUTHASH 直接存储在缓存的索引中。

    请注意,EQ 只是一个指针比较,因此速度非常快——它根本不需要遍历列表。

    所以在您的代码示例中,完全没有开销。

    【讨论】:

    【解决方案2】:

    您实际上可能会访问哈希表 3 次。为什么?因为push 宏可能会扩展为执行gethash 获取列表的代码,然后执行一些system::sethash 操作来存储值。

    在这个问题中,您正在检查一个位置的值,它是一个列表。如果该列表满足某些谓词测试,那么您将某些内容推送到该位置。

    可以通过创建捕获此语义的专用运算符来解决此问题:

     (push-if <new-value> <predicate> <place>)
    

    例如:

     (push-if i #'may-add (gethash complex-list table))
    

    这个push-if 被定义为一个宏,它使用&lt;place&gt; 表单参数上的get-setf-expansion 函数来获取生成代码以访问该位置一次所需的部分。

    生成的代码评估一个加载表单以从该位置获取旧值,然后将条件应用于旧值,如果成功,则在从get-setf-expansion获得的适当临时存储变量中准备新值并评估商店形式。

    这是您在可移植 Lisp 中可以做的最好的事情,您可能会发现它仍然执行两个散列操作,如上所述。 (在这种情况下,您希望哈希表本身有一个不错的缓存优化。但至少它减少到两个操作。)

    该方法将与内置的变异形式一样优化:incfpushrotatef 等。我们的push-if 将与内置函数相提并论。

    如果它仍然很糟糕(执行两个哈希来更新一个哈希位置,没有缓存优化),那么解决这个问题的唯一方法是在实现级别。

    push-if 代码如下:

    (defmacro push-if (new-value predicate-fun list-place &environment env)
      (multiple-value-bind (temp-syms val-forms
                            store-vars store-form access-form)
                           (get-setf-expansion list-place env)
        (let ((old-val (gensym)))
          (when (rest store-vars)
            (error "PUSH-IF: cannot take ref of multiple-value place"))
          `(multiple-value-bind (,@temp-syms) (values ,@val-forms)
             (let ((,old-val ,access-form))
               (when (funcall ,predicate-fun ,old-val)
                 (setf ,(first store-vars) (cons ,new-value ,old-val))
                 ,store-form))))))
    

    样本扩展:

    > (macroexpand '(push-if new test place))
    (LET* ((#:VALUES-12731 (MULTIPLE-VALUE-LIST (VALUES))))
     (LET ((#:G12730 PLACE))
      (WHEN (FUNCALL TEST #:G12730) (SETF #:NEW-12729 (CONS NEW #:G12730))
       (SETQ PLACE #:NEW-12729)))) ;
    

    当地点是一个变量时,对于简单的情况看起来很正常。只有一个小问题我不打算解决:newtestplace 表单每个只评估一次,但不是按从左到右的顺序!

    使用哈希表位置 (CLISP) 进行测试:

    > (macroexpand '(push-if new test (gethash a b)))
    (LET*
     ((#:VALUES-12736 (MULTIPLE-VALUE-LIST (VALUES A B)))
      (#:G12732 (POP #:VALUES-12736)) (#:G12733 (POP #:VALUES-12736)))
     (LET ((#:G12735 (GETHASH #:G12732 #:G12733)))
      (WHEN (FUNCALL TEST #:G12735) (SETF #:G12734 (CONS NEW #:G12735))
       (SYSTEM::PUTHASH #:G12732 #:G12733 #:G12734)))) ;
    

    啊哈;现在生成了一些更有趣的代码,以避免对ab 进行两次评估。 gethash 函数被调用一次,但它的参数是 gensym 变量。旧值被捕获为#:G12735。对其应用测试,如果通过,则存储变量 #:G12734 将更新为旧列表值,并在其前面加上 new。然后,将该值放入带有system::puthash 的哈希表中。

    所以在这个 Lisp 实现中,没有办法避免两个哈希表操作来执行更新:gethashsystem::puthash。这是我们能做的最好的事情,希望这两者可以作为优化的一对。

    【讨论】:

      【解决方案3】:

      您可以做的一件事是使用 defstruct 创建一个值,哈希表中的每个条目都指向该值。您的值列表(您在当前示例中推送的值)可以存储在其中。结构创建既可以在初始 gethash 调用中完成(作为默认值),也可以在您发现那里没有值时手动完成。然后,对象可能会以您正在做的方式产生副作用。

      (这忽略了您是否真的想使用像哈希表键这样的复杂值,或者是否有办法解决这个问题。例如,您可以使用结构/CLOS 对象而不是复杂的列表作为您的键,然后您可以使用 EQ 哈希表代替。但这在很大程度上取决于您在做什么。)

      【讨论】:

        【解决方案4】:

        “分析表明,相同的测试需要很长时间。”

        是的,但您是否验证过 #'EQUAL 哈希表查找 确实也需要很多时间?

        您是否在 SBCL 等优化编译器上编译过此文件以提高速度并查看编译器说明?

        在解决了这两个问题后,您还可以为列表键的每一级尝试嵌套哈希表。为任意嵌套的哈希表编写宏应该不难。

        【讨论】:

          【解决方案5】:

          也许我遗漏了一些明显的东西,但是:

          (defun try-add (i)
            (let ((old-i (gethash complex-list table)))
              (when (may-add old-i)
                (push i old-i))))
          

          因为:

          • nil 已经是 GETHASH 的默认值了
          • GETHASH 拉出整个对象,因此您可以就地修改它,而不是告诉 PUSH 如何再次查找它
          • (风格要点:当没有 else 子句时使用 WHEN 而不是 IF)

          编辑:哎呀,我是:我错过了 old-i 为零的情况。但是,如果这不是常见的情况,那么它仍然可能是一个胜利,因为您只需要在这种情况下进行查找:

          (defun try-add (i)
            (let ((old-i (gethash complex-list table)))
              (when (may-add old-i)
                (if old-i
                   (push i old-i)
                  (push i (gethash complex-list table))))))
          

          嗯,这样行吗?

          【讨论】:

          • 不,它没有。您将项目推送到 old-i 位置,这对 (gethash ...) 位置中存储的内容没有影响,因为非空 Lisp 列表是指向头节点的指针,而不是容器。
          【解决方案6】:

          一些解决方法可能是:

          如果常见模式是查找-> 查找-> 覆盖-它,那么您可以将值类型替换为包含该值类型的列表。然后在找到键的值对象后,只需破坏性地替换它的第一个元素,例如

          (defun try-add (i)
            (let ((old-i-list (gethash complex-list table nil)))
              (if (may-add (first old-i-list))
                (setf (first old-i-list) i)                     ; overwrite without searching again
                (setf (gethash complex-list table) (list i))))) ; not there? too bad, we have to gethash again
          

          或者,如果常见模式更像是查找 -> 它不存在 -> 添加它,您可能需要考虑自己对键进行哈希处理,然后让哈希表使用您的哈希值作为钥匙。这可能更复杂,具体取决于这些复杂列表的深度和语义。在简单的情况下,您可能会使用一个哈希函数(递归地)异或其列表参数的元素的哈希值。


          已编辑:回答 cmets 中的问题:想法是,哈希表不再将键映射到值,而是将键映射到单个元素列表,其中元素是值。然后,您可以更改这些列表的内容,而无需触及哈希表本身。以下来自 SBCL:

          * (defparameter *my-hash* (make-hash-table))
          *MY-HASH*
          
          * (setf (gethash :my-key *my-hash*) (list "old-value"))
          ("old-value")
          
          * (gethash :my-key *my-hash*)
          ("old-value")
          T
          
          * (defparameter old-value-container (gethash :my-key *my-hash*))
          OLD-VALUE-CONTAINER
          
          * (setf (first old-value-container) "new value")
          "new value"
          
          * (gethash :my-key *my-hash*)
          ("new value")
          T
          

          【讨论】:

          • 我尝试了类似于您发布的源代码的东西,但是在执行 (setf (first old-i-list)...) 时,它只会改变 old-i-list 并且更改为不反映在哈希表值中。我误解了一些基本的东西吗?
          • @kotlinski:如果您在 old-i-list 的初始值为 nil 的情况下这样做,那么是的,这不会反映在哈希表中的值中。但是,如果您确实已经有一个列表,那么 gethash 会返回该列表,您可以按照您的想法对其进行变异。请注意,“推送”不起作用,因为这会影响您要推送的变量,添加一个新头并将变量设置为指向该新值。然后它将与哈希表值共享列表的一部分(假设不是 nil),但不一样。
          • “众所周知,通用 lisp 的内置数据结构是不透明的。” ——什么意思?
          • reddit.com/r/programming/comments/7sab5/… 的讨论表明了一些批评。这不是对语言的攻击,而是对它的一些内置结构如何构建的观察。不过,我正在删除臭名昭著的注释——它并没有为这个答案增加价值。
          猜你喜欢
          • 2019-03-11
          • 2018-11-26
          • 1970-01-01
          • 2020-12-18
          • 2011-06-02
          • 2013-08-08
          • 2012-01-14
          • 2013-07-04
          • 1970-01-01
          相关资源
          最近更新 更多