master: Optimize SSET-INTERSECTION

snuglas via Sbcl-commits <[email protected]> Sun, 19 Jul 2026 15:39:58 +0000
Newsgroups gmane.lisp.steel-bank.cvs
Message-ID <[email protected]>
The branch "master" has been updated in SBCL:
       via  da7d90ca9a80a8e73c79ddc39dc8fb9ae10e1b87 (commit)
      from  0843efc927405e31a970cfd145fe845f29d0923b (commit)

- Log -----------------------------------------------------------------
commit da7d90ca9a80a8e73c79ddc39dc8fb9ae10e1b87
Author: Douglas Katzman <[email protected]>
Date:   Sun Jul 19 11:33:20 2026 -0400

    Optimize SSET-INTERSECTION
    
    Impose a stricter upper bound on consing as explained in the comments.
    Test cases by Gemini
---
 src/compiler/sset.lisp | 41 +++++++++++++++++++++++++-----------
 tests/sset.pure.lisp   | 56 ++++++++++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 85 insertions(+), 12 deletions(-)

diff --git a/src/compiler/sset.lisp b/src/compiler/sset.lisp
index c9b31f9e2..228aa5fbc 100644
--- a/src/compiler/sset.lisp
+++ b/src/compiler/sset.lisp
@@ -156,6 +156,7 @@
 
 ;;; Return true if SET contains no elements, false otherwise.
 (declaim (ftype (sfunction (sset) boolean) sset-empty))
+(declaim (inline sset-empty))
 (defun sset-empty (set)
   (zerop (sset-count set)))
 
@@ -178,18 +179,34 @@
         finally (return modified)))
 
 (defun sset-intersection (set1 set2)
-  ;; If SET2 is _significantly_ smaller than SET1 it would make sense to
-  ;; iterate over SET2 looking for the elements that are in SET1.
-  ;; However, to do that means consing a new temporary SSET, performing
-  ;; the operation, then moving the temporary on top of SET1.
-  ;; I don't feel like doing all that.
-  (let ((to-delete nil))
-    (do-sset-elements (element set1)
-      (unless (sset-member element set2)
-        (push element to-delete)))
-    (when to-delete
-      (dolist (element to-delete t)
-        (sset-delete element set1)))))
+  (cond
+    ((or (sset-empty set1) (eq set1 set2)) nil)
+    ;; Consing can always be bounded by the cardinality of the smaller set,
+    ;; we just have to decide whether to collect items to ADJOIN vs DELETE.
+    ;; In the situation where SET2 is empty (and SET1 is not, because empty was
+    ;; ruled out above), this correctly pick the first of the following two COND
+    ;; clauses, doing zero consing.
+    ;; When SET2 is no more than half the size of SET1, collecting kept elements
+    ;; by scanning SET2 conses at most |SET2| items, whereas scanning SET1
+    ;; conses at least |SET1| - |SET2| items.
+    ((<= (sset-count set2) (ash (sset-count set1) -1))
+     (let ((to-keep nil))
+       (do-sset-elements (element set2)
+         (when (sset-member element set1)
+           (push element to-keep)))
+       (fill (sset-vector set1) 0)
+       (setf (sset-count set1) 0)
+       ;; Since |SET2| < |SET1|, SET1 is guaranteed to shrink, so we always return T.
+       (dolist (element to-keep t)
+         (sset-adjoin element set1))))
+    (t
+     (let ((to-delete nil))
+       (do-sset-elements (element set1)
+         (unless (sset-member element set2)
+           (push element to-delete)))
+       (when to-delete
+         (dolist (element to-delete t)
+           (sset-delete element set1)))))))
 
 (defun sset-difference (set1 set2)
   ;; If sets are EQ, the algorithms below are either terribly broken (if you pick
diff --git a/tests/sset.pure.lisp b/tests/sset.pure.lisp
index 16d9c4f94..77afdf51f 100644
--- a/tests/sset.pure.lisp
+++ b/tests/sset.pure.lisp
@@ -178,3 +178,59 @@
       ;; Subtract non-empty from empty
       (assert (not (sset-difference s2 s1))) ; modified = nil
       (assert (sset-empty s2)))))
+
+(with-test (:name :sset-intersection-comprehensive)
+  (let ((elems (loop for i from 1 to 20 collect (make-dummy-test-element i))))
+    ;; 1. Small set2, large set1 (Branch 1: set2 <= set1/2)
+    (let ((s1 (make-sset))
+          (s2 (make-sset)))
+      (dolist (e (subseq elems 0 10)) (sset-adjoin e s1)) ; s1 has 10 elements (0..9)
+      (dolist (e (subseq elems 3 6))  (sset-adjoin e s2)) ; s2 has 3 elements (3..5)
+      (assert (sset-intersection s1 s2)) ; modified = t
+      (assert (= (sset-count s1) 3))
+      (assert (sset-member (elt elems 3) s1))
+      (assert (sset-member (elt elems 4) s1))
+      (assert (sset-member (elt elems 5) s1))
+      (assert (not (sset-member (elt elems 0) s1)))
+      (assert (not (sset-member (elt elems 9) s1)))
+
+      ;; Intersecting with small disjoint set empties s1
+      (let ((s3 (make-sset)))
+        (dolist (e (subseq elems 15 17)) (sset-adjoin e s3)) ; s3 has 2 elements (15, 16)
+        (assert (sset-intersection s1 s3)) ; modified = t
+        (assert (sset-empty s1))))
+
+    ;; 2. Large set2, small set1 (Branch 2: set2 > set1/2)
+    (let ((s1 (make-sset))
+          (s2 (make-sset)))
+      (dolist (e (subseq elems 0 4))  (sset-adjoin e s1)) ; s1 has 4 elements (0..3)
+      (dolist (e (subseq elems 2 12)) (sset-adjoin e s2)) ; s2 has 10 elements (2..11)
+      (assert (sset-intersection s1 s2)) ; modified = t
+      (assert (= (sset-count s1) 2)) ; elements 2 and 3 kept
+      (assert (sset-member (elt elems 2) s1))
+      (assert (sset-member (elt elems 3) s1))
+      (assert (not (sset-member (elt elems 0) s1)))
+
+      ;; Intersecting when set2 contains all elements of set1 (unmodified = nil)
+      (let ((s4 (make-sset)))
+        (dolist (e (subseq elems 0 10)) (sset-adjoin e s4)) ; contains all of s1
+        (assert (not (sset-intersection s1 s4))) ; modified = nil
+        (assert (= (sset-count s1) 2))))
+
+    ;; 3. (eq set1 set2) returns nil (unmodified)
+    (let ((s1 (make-sset)))
+      (dolist (e (subseq elems 0 5)) (sset-adjoin e s1))
+      (assert (not (sset-intersection s1 s1)))
+      (assert (= (sset-count s1) 5)))
+
+    ;; 4. Empty set operations
+    (let ((s1 (make-sset))
+          (s2 (make-sset)))
+      (sset-adjoin (elt elems 0) s1)
+      ;; Intersect non-empty with empty (s2 <= s1/2 => returns t, s1 becomes empty)
+      (assert (sset-intersection s1 s2))
+      (assert (sset-empty s1))
+      ;; Intersect empty with non-empty (returns nil)
+      (sset-adjoin (elt elems 0) s2)
+      (assert (not (sset-intersection s1 s2)))
+      (assert (sset-empty s1)))))

-----------------------------------------------------------------------


hooks/post-receive
-- 
SBCL