Speed up FINALIZE and CANCEL-FINALIZATION w/ many threads

This replaces the usual hash-table with a new storage representation
that is almost always lockfree, and removes all complexity around
assigning a unique ID to each finalized object.
The ugly hack in cull_weak_hash_table_bucket() is no more.

As the benchmark shows, with 4 threads we can create finalizers
about 3x faster at the cost of about 1.5x more memory.

Fixes lp#1998064 where finalizers are concerned, but if the bug
exists more generally, this doesn't do anything for it.
This commit is contained in:
Douglas Katzman 2023-01-15 22:54:51 -05:00
parent 1218556a86
commit f16af319fa
18 changed files with 509 additions and 304 deletions

41
benchmarks/finalize.lisp Normal file
View file

@ -0,0 +1,41 @@
(defun make-threads (semaphore nwriters nobjects)
(loop for i below nwriters
collect
(let ((list (loop repeat nobjects for j from 1
collect (cons i j))))
(sb-thread:make-thread
(lambda (things)
(sb-thread:wait-on-semaphore semaphore)
(dolist (thing things)
(finalize thing #'+)) ; a no-op finalizer
(mapc #'cancel-finalization things))
:arguments (list list)
:name (format nil "worker ~D" i)))))
(defun test-finalize+cancel (ntrials nwriters nobjects)
(dotimes (i ntrials)
(let* ((sem (sb-thread:make-semaphore))
(threads (make-threads sem nwriters nobjects)))
(sb-thread:signal-semaphore sem nwriters)
(mapc #'sb-thread:join-thread threads))))
(time (test-finalize+cancel 100 4 10000)) ; 100 trials, 4 threads, 10k objects per thread
#|
;; Old:
Evaluation took:
4.100 seconds of real time
10.704615 seconds of total run time (10.585181 user, 0.119434 system)
[ Run times consist of 0.017 seconds GC time, and 10.688 seconds non-GC time. ]
261.10% CPU
9,841,747,312 processor cycles
203,244,640 bytes consed
;; New:
Evaluation took:
1.179 seconds of real time
2.874184 seconds of total run time (2.756504 user, 0.117680 system)
[ Run times consist of 0.041 seconds GC time, and 2.834 seconds non-GC time. ]
243.77% CPU
2,830,553,292 processor cycles
353,720,608 bytes consed
|#

View file

@ -404,8 +404,6 @@ process to continue normally."
(sb-thread::init-main-thread)
#+x86-64 (sb-vm::validate-asm-routine-vector)
(rebuild-package-vector))
;; Initializing the standard streams calls ALLOC-BUFFER which calls FINALIZE
(finalizers-reinit)
;; Initialize streams next, so that any errors can be printed
(stream-reinit t)
(rebuild-pathname-cache)

View file

@ -18,7 +18,6 @@
(let ((args (copy-list args)))
(remf args :weakness)
(remf args :synchronized)
(remf args :finalizer)
(let ((hash-fun (getf args :hash-function)))
(when hash-fun
(assert (eq (getf args :test) 'eq))

View file

@ -1,4 +1,4 @@
;;;; finalization based on weak-keyed hash-table
;;;; finalization based on weak Split-Ordered Lists
;;;; This software is part of the SBCL system. See the README file for
;;;; more information.
@ -11,36 +11,130 @@
(in-package "SB-IMPL")
(defmacro with-finalizer-store ((var) &body body)
`(with-system-mutex ((hash-table-lock (finalizer-id-map **finalizer-store**)))
;; Grab the global var inside the lock in case the array was enlarged
;; after we referenced the mutex but before we acquired it.
;; It's OK to reference the FINALIZER-ID-MAP because that is always
;; in element 1 of the array regardless of what happens to the array.
(let ((,var **finalizer-store**))
,@body)))
;;; Finalizer table keys are fixnums - NOT objects or SB-VM:WORD - representing
;;; the aligned base address of a lisp object. This is purposely opaque to GC
;;; so that we don't need a special variant of lockfree list that adds weakness.
;;; GC understands the untagged pointer nature of the keys in this table
;;; in exactly the one place that it needs to.
(define-load-time-global **finalizer-store** (sb-lockless:make-so-map/addr))
(declaim (type sb-lockless::split-ordered-list **finalizer-store**))
(defmacro finalizer-recycle-bin (store) `(cdr (elt ,store 0)))
(defmacro finalizer-id-map (store) `(elt ,store 1))
(defmacro finalizer-max-id (store) `(elt ,store 2))
;;; A mutex is used during rehash due to key movement, but NOT if rehashing
;;; due to table growth. (If growing organically, hashes are valid, so you'll
;;; find what you're looking for if it's there. Invalid hashes are trickier)
(define-load-time-global *finalizer-lock* (sb-thread:make-mutex :name "finalizer"))
(declaim (type sb-thread:mutex *finalizer-lock*))
(defun make-finalizer-store (array-length)
(let* ((v (make-array (the index array-length) :initial-element 0))
(ht (make-system-hash-table :test 'eq :weakness :key :synchronized nil
:finalizer t)))
;; The recycle bin has a dummy item in front so that the simple-vector
;; is growable without messing up RUN-PENDING-FINALIZERS when it atomically
;; pushes items into the recycle bin - it is unaffected by looking at
;; an obsolete **FINALIZER-STORE** if FINALIZE has assigned a new one.
(setf (elt v 0) (list 0)
(finalizer-id-map v) ht
(finalizer-max-id v) 2)
v))
;;; List of nodes removed from the split-ordered list due to key movement.
;;; These get reinserted when searching the table.
;;; It is built of lockfree list nodes without using the full algorithm of Harris,
;;; and has _two_ possible empty list markers: +TAIL+ indicates that rehashing
;;; reached the last worklist item and is nominally still processing; while NIL
;;; indicates no work to be done at all.
(declaim (type (or null sb-lockless::list-node) *finalizer-rehashlist*))
(define-load-time-global *finalizer-rehashlist* nil)
(defconstant +finalizers-initial-size+ 50) ; arbitrary
(define-load-time-global **finalizer-store**
(make-finalizer-store +finalizers-initial-size+))
(declaim (simple-vector **finalizer-store**))
;;; List of all nodes whose finalizer(s) should be invoked.
;;; This is an ordinary list. Only the front node can be pushed/popped.
(declaim (type list *finalizers-triggered*))
(define-load-time-global *finalizers-triggered* nil)
;;; Side note: just about every compare-and-swap in this file would be better off
;;; as a "weak" compare-and-swap if we had such thing. When spurious failure
;;; occurs, we're already inside a loop, and will retry the CAS anyway.
;;; Rehashing due to key movement is synchronized by the finalizer-lock.
;;; When adding or canceling a finalizer, we first have to handle the possibility
;;; that GC moved the object of interest from FINALIZER-STORE into FINALIZER-REHASHLIST.
;;; All those keys have to be re-inserted, otherwise there's no way to know the
;;; disposition of a CANCEL-FINALIZATION request.
;;; Even if users are always careful never to operate on one object from two different
;;; threads - so they never introduce a data race between FINALIZE and CANCEL -
;;; we could, if rehashing in multiple threads, create a data race between a user
;;; trying to CANCEL, and a different thread processing the rehashlist and inserting
;;; the canceled key. To prevent that, only one thread will rehash if keys moved.
;;;
;;; Note: Other keys could appear in the FINALIZER-REHASHLIST _after_ we test
;;; whether it is equal to +TAIL+. This can occur because we're not synchronized
;;; with GC. But that's OK - we pin OBJECT before entering the mutex scope,
;;; therefore it can not move to the rehashlist. So upon seeing the alleged end of the
;;; rehashlist, OBJECT can not be in it. But it could actually be in the FINALIZER-STORE
;;; already because if two threads observe *FINALIZER-REHASHLIST* to be non-nil, they'll
;;; both try to rehash, and presumably one will have no work to do. (Unless GC moves
;;; even more things after the first thread to rehash leaves its mutex scope)
(macrolet
((base-pointer (k) ; Cast K to fixnum in the DESCRIPTOR-SAP representation.
`(%make-lisp-obj (logandc2 (get-lisp-obj-address ,k) sb-vm:lowtag-mask)))
(insert (k v)
`(with-pinned-objects (,k)
(prog1 (sb-lockless:so-insert table (base-pointer ,k) ,v)
(sb-thread:barrier (:write)))))
(get-table ()
;; The global var itself is actually invariant, but I suspect that lookups
;; need to ensure that writes became visible.
`(progn (sb-thread:barrier (:read)) **finalizer-store**))
(with-rehashing (result-expression)
`(progn
;; Must not observe *FINALIZER-REHASHLIST* until _after_ we've pinned OBJECT.
;; Otherwise, for a relaxed-memory-order CPU you could read the rehashlist,
;; see NIL, store to *PINNED-OBJECTS*, but in between the read and your store,
;; GC moved the OBJECT you want to lookup into the rehashlist.
(sb-thread:barrier (:read))
(when *finalizer-rehashlist*
(with-system-mutex (*finalizer-lock*)
(let* ((found (%finalizers-rehash object table))
(result ,result-expression))
;; Regardless of what %finalizers-rehash did, try to change +TAIL+ to NIL
;; so that threads don't attempt to acquire the mutex until after next GC.
(cas *finalizer-rehashlist* sb-lockless:+tail+ nil)
result))))))
;;; Scan the rehashlist looking for KEY, stopping and returning its list node if found.
;;; Each node seen prior to stopping will be reinserted into either the triggered
;;; list or the finalizer store.
;;; There's no race with other threads now, except for a GCing thread.
;;; Thus we need atomic operations despite the mutex.
;;; Possible TODO: 'target-hash-table' uses WITH-PINNED-OBJECT-ITERATOR
;;; which on the precise stack platforms is slightly preferable
;;; to repeated binds and unbinds of *PINNED-OBJECTS.
(defun %finalizers-rehash (key table)
(let ((node *finalizer-rehashlist*))
(loop
;; Instead of setting *FINALIZER-REHASHLIST* to NIL on the last item,
;; it becomes +TAIL+ which forces other threads to wait on the mutex.
;; Otherwise they have no guarantee that the item of interest
;; to them isn't the one which is currently in flight in this loop.
(when (or (eq node sb-lockless:+tail+) (null node))
(return))
(let* ((next (sb-lockless:%node-next (the sb-lockless::so-data-node node)))
(actual (cas *finalizer-rehashlist* node next))) ; like ATOMIC-POP
(if (eq node actual)
(let* ((weakptr (the weak-pointer (sb-lockless:so-key node)))
(obj (weak-pointer-value weakptr)))
(cond ((null obj) ; broken ptr, transfer to triggered list
(atomic-push (sb-lockless:so-data node) *finalizers-triggered*)
(setf (sb-lockless:so-key node) 0)) ; don't need the weak-pointer
((neq obj key) ; re-insert
;; GC has a nonzero amount of extra work for each weak-pointer
;; but not if the value in it is NIL
(%primitive sb-c:set-slot weakptr nil 'setf sb-vm:weak-pointer-value-slot
sb-vm:other-pointer-lowtag)
(insert obj (sb-lockless:so-data node)))
(t ; This is the _least_ likely case, so test it last
(return node))) ; FOUND
(setq node next))
(setq node actual))))))
(defun finalizers-rehash ()
;; won't find T in the rehashlist, so this rehashes everything
(%finalizers-rehash t (get-table)))
;;; For debugging/regression testing
(export '%lookup-finalizer)
(defun %lookup-finalizer (x)
(with-pinned-objects (x)
(finalizers-rehash)
(sb-lockless:so-find (get-table) (base-pointer x))))
(defun finalize (object function &key dont-save
&aux (function (%coerce-callable-to-fun function)))
@ -101,217 +195,157 @@ Examples:
;; silently discard finalizers on file streams in arenas I guess
(progn ; (warn "Will not finalize ~S." object)
(return-from finalize object)))))
(let ((item (if dont-save (list function) function)))
(with-finalizer-store (store)
(let ((id (gethash object (finalizer-id-map store))))
(cond
(id ; object already has at least one finalizer
;; Multiple finalizers are invoked in the order added.
(let* ((old (svref store id))
(new (make-array (if (simple-vector-p old)
(1+ (length old)) ; already > 1
2)))) ; was singleton
(if (= (length new) 2)
(setf (aref new 0) old) ; upgrade singleton to vector
(replace new old))
(setf (aref new (1- (length new))) item
(svref store id) new)))
(t ; assign the next available ID to this object
(cond ((finalizer-recycle-bin store)
;; We must operate atomically with respect to producers,
;; because RUN-PENDING-FINALIZERS is lock-free.
;; The initial test above said that the bin is nonempty,
;; so we can't fail to obtain an item, as the list can't
;; shrink except through here, which is mutually exclusive
;; with other consumers of recycled items.
(setq id (atomic-pop (finalizer-recycle-bin store))))
(t
(setq id (incf (finalizer-max-id store)))
(unless (< id (length store))
(sb-thread:barrier (:write)
;; We must completely copy the old vector into the new
;; before publishing the new in **FINALIZER-STORE**.
;; Perhaps a cleverer way to size up is to have a tree
;; of vectors; never remove cells already created,
;; but simply graft new limbs on to the tree.
(setq store (adjust-array store (* (length store) 2)
:initial-element 0)))
(setq **finalizer-store** store))))
;; Clear out lingering junk from (SVREF STORE ID) before
;; establishing that OBJECT maps to that index.
(setf (svref store id) item
(gethash object (finalizer-id-map store)) id))))))
object)
(defun invalidate-fd-streams ()
(with-finalizer-store (store)
(maphash (lambda (object id)
(declare (ignore id))
(when (fd-stream-p object)
(push (list object
(ansi-stream-in object)
(ansi-stream-bin object)
(ansi-stream-n-bin object)
(ansi-stream-out object)
(ansi-stream-bout object)
(ansi-stream-sout object)
(ansi-stream-misc object))
*streams-closed-by-slad*)
;; Nobody asked us to actually close the fd,
;; so just make it unusable.
(set-closed-flame-by-slad object)))
(finalizer-id-map store))))
(defun finalizers-deinit ()
;; remove :dont-save finalizers
;; Renumber the ID range as well, but leave the array size as-is. We could
;; probably delete *all* finalizers prior to image dump, because saved
;; finalizers can in practice almost never be run, as pseudo-static objects
;; don't die, making this more-or-less an exercise in futility.
(with-finalizer-store (old-store)
;; This doesn't need WITHOUT-GCING. MAPHASH will never present its funarg
;; with a culled entry. GC during the MAPHASH could remove some items
;; before we get to them, and that's fantastic.
(let ((new-store
(make-finalizer-store (max (1+ (finalizer-max-id old-store))
+finalizers-initial-size+)))
(old-objects (finalizer-id-map old-store)))
(maphash (lambda (object old-id &aux (old (svref old-store old-id)))
;; OLD is either a vector of finalizers or a single finalizer.
;; Each finalizer is either a callable (a symbol or function)
;; or a singleton list of a callable.
;; Delete any finalizer wrapped in a cons, meaning "don't save".
(awhen (cond ((simple-vector-p old)
(let ((new (remove-if #'consp old)))
(case (length new)
(0 nil) ; all deleted
(1 (svref new 0)) ; reduced to singleton
(t new))))
((atom old) old)) ; a single finalizer to be saved
(let ((new-id (incf (finalizer-max-id new-store))))
(setf (gethash object (finalizer-id-map new-store)) new-id
(svref new-store new-id) it))))
old-objects)
(clrhash old-objects)
(fill old-store 0)
(setq **finalizer-store** new-store))))
;;; Replace the finalizer store with a copy. Tenured (gen6 = pseudo-static)
;;; vectors are problematic in many ways for gencgc, unless immutable.
;;; Among the problems is this: after sizing **FINALIZER-STORE** up,
;;; Lisp doesn't know when there are no readers of the old vector
;;; (due to the lock-free algorithm for RUN-PENDING-FINALIZERS),
;;; so we can't safely zero-fill the old vector. Making sure that it
;;; is not immortal (i.e. not in gen6), is a reasonable workaround.
;;; [Actually, in this particular algorithm, it is slightly OK to zero-fill
;;; due to the fact that 0 is not a list; therefore if (SVREF V INDEX) is 0,
;;; we can chase down the correct value by reloading **FINALIZER-STORE**.
;;; Of course the zero-fill noise is itself a workaround for accidental
;;; transitive immortalization, which is issue that merits a general fix]
(defun finalizers-reinit ()
;; This must be called inside WITHOUT-GCING and with no other threads.
(aver *gc-inhibit*)
(let* ((old-store **finalizer-store**)
(new-store (make-finalizer-store (length old-store)))
(old-objects (finalizer-id-map old-store))
(new-objects (finalizer-id-map new-store)))
;; Copy the max-id and all the finalizers.
;; The recycle bin is empty, and the hash-table is newly consed.
(replace new-store old-store :start1 2 :start2 2)
;; Copy the hash-table.
;; Or should the old just be assigned into the new finalizer-store?
;; Probably not, because immortable hash-tables have a similar
;; problem as cited above, unless strictly constant.
;; (Though mitigated by a FILL in REHASH)
(maphash (lambda (object id) (setf (gethash object new-objects) id))
old-objects)
(clrhash old-objects)
(fill old-store 0)
(setq **finalizer-store** new-store)))
(let* ((node
(with-pinned-objects (object)
(let ((table (get-table)))
;; Attempt 1: optimistically look in the solist assuming valid hashes
(or (sb-lockless:so-find table (base-pointer object))
;; Attempt 2: perform rehashing and examine each key while looping
(with-rehashing (when found
(insert object (sb-lockless:so-data found))))
;; Attempt 3: another thread could have done all the rehashing and
;; inserted OBJECT. If not, this will insert a new node.
(insert object nil)))))
;; Conditionally wrapping a VALUE-CELL around FUNCTION is a means to indicate
;; the :DONT-SAVE option without inventing a struct of a function and boolean.
;; I believe that most finalizers will *not* have the :DONT-SAVE flag set.
;; As evidence the https://github.com/trivial-garbage/trivial-garbage portability
;; library does not offer a way to specify :DONT-SAVE.
(action
(if dont-save (sb-sys:%primitive sb-vm::make-value-cell function nil) function))
(old-data (sb-lockless:so-data node)))
(loop
;; Decide how to represent NEW-DATA
;; choice (a) FUNCTION | VALUE-CELL = just one finalizer
;; choice (b) LIST of (OR FUNCTION VALUE-CELL) = more than one
(let ((new-data (if old-data (cons action (ensure-list old-data)) action)))
(when (eq old-data
(setf old-data (cas (sb-lockless:so-data node) old-data new-data)))
(return object))))))
(defun cancel-finalization (object)
"Cancel all finalizations for OBJECT."
(when object
(with-finalizer-store (store)
(let ((hashtable (finalizer-id-map store)))
(awhen (gethash object hashtable)
(remhash object hashtable)
;; Clear old function(s) before publishing the ID as available.
;; Not strictly necessary to do this: the next FINALIZE claiming
;; the same ID would assign a fresh list anyway.
(setf (svref store it) 0)
(locally (declare (sb-c::tlab :system))
(atomic-push it (finalizer-recycle-bin store)))))))
object)
"Cancel all finalizations for OBJECT, returning T if it had a finalizer."
(when (and object (heap-allocated-p object))
(with-pinned-objects (object)
(let ((table (get-table)))
;; Attempt 1: optimistically look in the solist assuming valid hashes
(or (sb-lockless:so-delete table (base-pointer object))
;; Attempt 2: perform rehashing and examine each key while looping
(with-rehashing found) ; implies no re-insert, so we're done
;; Attempt 3: Give it another chance. Third time's a charm?
;; (Technically do not need this if current thread rehashed? not sure)
(sb-lockless:so-delete table (base-pointer object)))))))
) ; end MACROLET
;;; FIXME: probably want vop for this, it's just PSEUDO-ATOMIC wrapped around
;;; reconstitute-object, but I don't want to hand-write all that assembly.
;;; So for now: MUST be wrapped in WITHOUT-GCING by calling code
(export 'finalizer-object) ; for regression test
(defun finalizer-object (node)
(sb-vm::reconstitute-object (sb-lockless:so-key node)))
(defun finalizers-deinit ()
(when (null *finalizer-rehashlist*)
(setq *finalizer-rehashlist* sb-lockless:+tail+))
;; invalidate fd-streams
(flet ((flameout (object)
(push (list object
(ansi-stream-in object)
(ansi-stream-bin object)
(ansi-stream-n-bin object)
(ansi-stream-out object)
(ansi-stream-bout object)
(ansi-stream-sout object)
(ansi-stream-misc object))
*streams-closed-by-slad*)
;; Nobody asked us to actually close the fd,
;; so just make it unusable.
(set-closed-flame-by-slad object)))
(do ((node *finalizer-rehashlist* (sb-lockless:%node-next node)))
((eq node sb-lockless:+tail+))
(let ((object (weak-pointer-value (sb-lockless:so-key node))))
(when (fd-stream-p object)
(flameout object))))
(let* ((table **finalizer-store**)
;; Avoid consing inside SO-MAPLIST
(array (make-array (sb-lockless::so-count table)))
(n 0))
(without-gcing
(sb-lockless:so-maplist
(lambda (node)
(let ((object (finalizer-object node)))
(when (fd-stream-p object)
(setf (aref array n) object)
(incf n))))
table))
(dotimes (i n)
(flameout (aref array i)))))
;; remove :dont-save finalizers
(flet ((filter-actions (node &aux (actions (sb-lockless:so-data node)))
(cond ((listp actions)
;; (NOT FUNCTIONP) implies :DONT-SAVE
(let ((new (delete-if-not #'functionp actions)))
;; If SINGLETON-P then just store the one. NIL stays as-is
(setf (sb-lockless:so-data node) (if (cdr new) new (car new)))))
((functionp actions) actions))))
;; Process the need-rehash items, leaving them in that list if applicable.
;; Nodes already in rehashlist might actually be subject to removal
;; either because the object died, or all its actions are :DONT-SAVE.
(do ((prev nil) ; no dummy node, you dummy
(this *finalizer-rehashlist*))
((eq this sb-lockless:+tail+))
(let ((next (sb-lockless:%node-next this)))
(cond ((and (filter-actions this)
(weak-pointer-value (sb-lockless:so-key this)))
(setf prev this this next)) ; keep
(t ; discard
(setf this next)
(if prev
(setf (sb-lockless:%node-next prev) this)
(setf *finalizer-rehashlist* this))))))
;; Process the hashed items, moving them to the rehash list if applicable.
;; Safely resurrecting objects by their address requires WITHOUT-GCING.
(without-gcing
(sb-lockless:so-maplist
(lambda (node)
(when (filter-actions node)
;; re-use the node
(setf (sb-lockless:so-key node) (make-weak-pointer (finalizer-object node))
(sb-lockless:%node-next node) *finalizer-rehashlist*
*finalizer-rehashlist* node)))
**finalizer-store**)))
;; We don't promise to execute pending actions on save-lisp-and-die.
;; Could we? Should we?
(setq *finalizers-triggered* nil)
;; Create an empty table. FINALIZE will reinsert things when next called.
(setf **finalizer-store** (sb-lockless::make-so-map/addr)))
(defvar *in-a-finalizer* nil)
(define-load-time-global *user-finalizer-runcount* 0)
(defun run-user-finalizer () ; Return T if this did anything
(let* ((data (atomic-pop *finalizers-triggered*))
(data-list (list data)))
;; DATA could already be a list. This is basically a no-consing ENSURE-LIST
(declare (truly-dynamic-extent data-list))
(dolist (finalizer (if (listp data) data data-list) (not (null data)))
;; :DONT-SAVE finalizers are wrapped in value-cells. Unwrap as necessary
(let ((fun (the function (if (functionp finalizer)
finalizer
(value-cell-ref finalizer)))))
;; Binding *IN-A-FINALIZER* prevents recursive run-pending-finalizers
;; if #-sb-thread. #+sb-thread probably doesn't require it.
(handler-case (let ((*in-a-finalizer* t)) (funcall fun))
(error (c) (warn "Error calling finalizer ~S:~% ~S" fun c)))))))
#+sb-thread (define-alien-variable finalizer-thread-runflag int)
;;; Drain the queue of finalizers and return when empty.
;;; Concurrent invocations of this function in different threads are ok.
;;; Nested invocations (from a GC forced by a finalizer) are not ok.
;;; See the trace at the bottom of this file.
(defvar *in-a-finalizer* nil)
#+sb-thread (define-alien-variable finalizer-thread-runflag int)
(defun run-user-finalizer (&aux (hashtable (finalizer-id-map **finalizer-store**)))
;; This never acquires the finalizer store lock. Code accordingly.
(let ((cell (hash-table-culled-values hashtable)))
;; This is like atomic-pop, but its obtains the first cons cell
;; in the list, not the car of the first cons.
;; Possible TODO: when no other work remains, free the *JOINABLE-THREADS*,
;; though MAKE-THREAD and JOIN-THREAD do that also, so there's no memory leak.
(loop (unless cell (return))
(let ((actual (cas (hash-table-culled-values hashtable)
cell (cdr cell))))
(if (eq actual cell) (return) (setq cell actual))))
(when cell
(let* ((id (the index (car cell)))
;; No other thread can modify **FINALIZER-STORE** at index ID
;; because the table no longer contains an object mapping to
;; that element; however the vector could be grown at any point,
;; so always load the vector again before dereferencing.
(store **finalizer-store**)
;; I don't think we need a barrier; this has a data dependency
;; on (CAR CELL) and STORE.
(finalizers (svref store id))) ; [1] load
(setf (svref store id) 0) ; [2] store
;; The ID can be reused right away. Link it into the recycle list,
;; which has an extra NIL at the head so that we can use RPLACD,
;; making this operation agnostic of whether the vector was switched.
(let* ((list (svref store 0))
(old (cdr list)))
(loop (let ((actual (cas (cdr list) old (rplacd cell old))))
(if (eq actual old) (return) (setq old actual)))))
;; Now call the function(s)
(flet ((call (finalizer)
(let ((fun (if (consp finalizer) (car finalizer) finalizer)))
(handler-case (let ((*in-a-finalizer* t)) (funcall fun))
(error (c)
(warn "Error calling finalizer ~S:~% ~S" fun c))))))
(if (simple-vector-p finalizers)
(map nil #'call finalizers)
(call finalizers)))
;; While the assignment to (SVREF STORE ID) should have been adequate,
;; we don't know that the vector is current - a new vector could have
;; gotten assigned into **FINALIZER-STORE** in between [1] and [2],
;; in which case the store was performed into the wrong vector.
;; It doesn't actually matter. Using CAS isn't an improvement, because
;; the vector itself is potentially wrong. But the load was valid
;; because the the cell's value is frozen, just duplicated into more
;; than one vector (in fact, an arbitrary number of vectors).
;; A reductio ad absurdum argument shows this:
;; - if you had a way to alter the contents of (SVREF STORE ID),
;; then you must have been able to find via the hash-table the
;; object that maps to that index, which means it wasn't dead,
;; so we must not be here trying to call finalizers for it.
;; Smashing 'finalizers' is a good extra step in terms of
;; removing dangling references, but if it's just a function,
;; there's nothing to smash.
(cond ((simple-vector-p finalizers) (fill finalizers 0))
((consp finalizers) (rplaca finalizers 0))))
t)))
(define-load-time-global *bg-compiler-function* nil)
(defun run-pending-finalizers (&aux (system-finalizer-scratchpad (list 0)))
(declare (truly-dynamic-extent system-finalizer-scratchpad))
(finalizers-rehash)
(loop
;; Perform no further work if trying to stop the thread, even if there is work.
#+sb-thread (when (zerop finalizer-thread-runflag) (return))
@ -375,6 +409,22 @@ Examples:
(sb-thread:join-thread thread)))
)
(export 'show-finalizers)
(defun show-finalizers (&aux (*print-pretty* nil))
(flet ((display (key)
(if key
(format t "~D ~X ~S~%" (generation-of key) (get-lisp-obj-address key) key)
(format t "<triggered-finalizer>~%"))))
(format t "~&Unhashed:~%")
(do ((node (or *finalizer-rehashlist* sb-lockless:+tail+)
(sb-lockless:%node-next node)))
((eq node sb-lockless:+tail+))
(display (weak-pointer-value (sb-lockless:so-key node))))
(format t "~&Hashed:~%")
(sb-lockless:so-maplist (lambda (node)
(display (without-gcing (finalizer-object node))))
**finalizer-store**)))
#|
;;; This is a display produced by annotating parts of gc-common.c and
;;; interrupt.c with each thread's output in its own column.

View file

@ -269,8 +269,7 @@ run in any thread.")
;; interrupts, which is precisely the thing we need to NOT do if already
;; in post-GC code of any kind (be it finalizer or other).
(when (and *allow-with-interrupts*
(or (and (sb-impl::hash-table-culled-values
(sb-impl::finalizer-id-map sb-impl::**finalizer-store**))
(or (and sb-impl::*finalizers-triggered*
(not sb-impl::*in-a-finalizer*))
*after-gc-hooks*))
(sb-thread::without-thread-waiting-for ()

View file

@ -108,12 +108,11 @@
;; +MAGIC-HASH-VECTOR-VALUE+ represents address-based hashing on the
;; respective key.
(hash-vector nil :type (or null (simple-array hash-table-index (*))))
;; flags: WEAKNESS | KIND | WEAKP | FINALIZERSP | USERFUNP | SYNCHRONIZED
;; flags: WEAKNESS | KIND | WEAKP | {notused} | USERFUNP | SYNCHRONIZED
;; WEAKNESS is 2 bits, KIND is 2 bits, the rest are 1 bit each
;; - WEAKNESS : {K-and-V, K, V, K-or-V}, irrelevant unless WEAKP
;; - KIND : {EQ, EQL, EQUAL, EQUALP}, irrelevant if USERFUNP
;; - WEAKP : table is weak
;; - FINALIZERSP : table is the global finalizer store
;; - USERFUNP : table has a nonstandard hash function
;; - SYNCHRONIZED : all operations are automatically guarded by a mutex
;; If you change these, be sure to check the definition of hash_table_weakp()
@ -201,11 +200,7 @@
(smashed-cells nil)
;; This slot is used to link weak hash tables during GC. When the GC
;; isn't running it is always NIL.
(next-weak-hash-table nil :type null)
;; List of values (i.e. the second half of the k/v pair) culled out during
;; GC, used only by the finalizer hash-table. This informs Lisp of the IDs
;; (small fixnums) of the finalizers that need to run.
(culled-values nil :type list))
(next-weak-hash-table nil :type null))
(sb-xc:defmacro hash-table-lock (table)
`(let ((ht ,table)) (or (hash-table-%lock ht) (install-hash-table-lock ht))))

View file

@ -137,7 +137,6 @@ for."
form)))
(defconstant hash-table-weak-flag 8)
(defconstant hash-table-finalizer-flag 4)
;;; USERFUN-FLAG implies a nonstandard hash function. Such tables may also have
;;; a custom comparator. But you can't have a custom comparator without a custom
;;; hash, because there's no way in general to produce a compatible hash.
@ -157,7 +156,7 @@ for."
(defconstant +min-hash-table-size+ 7)
(defconstant default-rehash-size $1.5))
(defmacro make-system-hash-table (&key test synchronized weakness finalizer)
(defmacro make-system-hash-table (&key test synchronized weakness)
(multiple-value-bind (kind args)
(cond ((equal test '(quote eq)) (values 0 '('eq #'eq #'eq-hash)))
((equal test '(quote eql)) (values 1 '('eql #'eql #'eql-hash)))
@ -169,8 +168,7 @@ for."
(:key '(pack-ht-flags-weakness +ht-weak-key+))
(:value '(pack-ht-flags-weakness +ht-weak-value+)))
(pack-ht-flags-kind ,kind)
,(if synchronized 'hash-table-synchronized-flag 0)
,(if finalizer 'hash-table-finalizer-flag 0))
,(if synchronized 'hash-table-synchronized-flag 0))
,@args
,+min-hash-table-size+
,default-rehash-size

View file

@ -346,7 +346,6 @@ sufficiently motivated to do lengthy fixes."
;; Perform static linkage. Functions become un-statically-linked
;; on demand, for TRACE, redefinition, etc.
#+immobile-code (sb-vm::statically-link-core)
(invalidate-fd-streams)
(finalizers-deinit)
;; Try to shrink the pathname cache. It might be largely nulls
(rebuild-pathname-cache)

View file

@ -178,6 +178,7 @@
sb-impl::bytes-per-utf8-character-aref
sb-impl::bytes-per-utf8-character-sap-ref-8
sb-impl::user-homedir-namestring
sb-lockless:make-so-map/addr
sb-c::apply-core-fixups
sb-c::compiled-debug-info-char-offset
sb-c::compiled-debug-info-tlf-number

View file

@ -222,12 +222,9 @@
*immobile-codeblob-tree* ; for generations 0 through 5 inclusive
*immobile-codeblob-vector* ; for pseudo-static-generation
*dynspace-codeblob-tree*
;; these are here because I encountered "cannot encode immediate operand"
;; on 32-bit arm when adding 3 static symbols in a large change.
;; I want to disentangle that problem from the actual change.
*ss-pad1*
*ss-pad2*
*ss-pad3*
sb-impl::**finalizer-store**
sb-impl::*finalizer-rehashlist*
sb-impl::*finalizers-triggered*
;; stack pointers
#-sb-thread *binding-stack-start* ; a thread slot if #+sb-thread

View file

@ -152,6 +152,10 @@ static inline int pointer_survived_gc_yet(lispobj pointer)
return (fullcgcmarks[mark_index / N_WORD_BITS] >> (mark_index % N_WORD_BITS)) & 1;
}
int fullcgc_lispobj_livep(lispobj pointer) {
return pointer_survived_gc_yet(pointer);
}
void dump_marked_objects() {
fprintf(stderr, "Marked objects:\n");
page_index_t first = 0;
@ -590,6 +594,7 @@ void execute_full_sweep_phase()
local_smash_weak_pointers();
gc_dispose_private_pages();
cull_weak_hash_tables(alivep_funs);
scan_finalizers();
memset(words_zeroed, 0, sizeof words_zeroed);
#ifdef LISP_FEATURE_IMMOBILE_SPACE

View file

@ -45,6 +45,7 @@
#include "genesis/layout.h"
#include "genesis/hash-table.h"
#include "genesis/list-node.h"
#include "genesis/split-ordered-list.h"
#define WANT_SCAV_TRANS_SIZE_TABLES
#include "gc-internal.h"
#include "gc-private.h"
@ -1718,7 +1719,6 @@ cull_weak_hash_table_bucket(struct hash_table *hash_table,
uint32_t *next_vector, uint32_t *hash_vector,
int (*alivep_test)(lispobj,lispobj),
void (*fix_pointers)(lispobj[2]),
boolean save_culled_values,
boolean rehash)
{
const lispobj empty_symbol = UNBOUND_MARKER_WIDETAG;
@ -1737,22 +1737,6 @@ cull_weak_hash_table_bucket(struct hash_table *hash_table,
gc_assert(value != empty_symbol);
if (!alivep_test(key, value)) {
gc_assert(hash_table->_count > 0);
if (save_culled_values) {
lispobj val = kv_vector[2 * index + 1];
gc_assert(!is_lisp_pointer(val));
struct cons *cons = (struct cons*)
gc_general_alloc(cons_region, sizeof(struct cons), PAGE_TYPE_CONS);
// Lisp code which manipulates the culled_values slot must use
// compare-and-swap, but C code need not, because GC runs in one
// thread and has stopped the Lisp world.
cons->cdr = hash_table->culled_values;
cons->car = val;
lispobj list = make_lispobj(cons, LIST_POINTER_LOWTAG);
notice_pointer_store(hash_table, &hash_table->culled_values);
hash_table->culled_values = list;
// ensure this cons doesn't get smashed into (0 . 0) by full gc
if (!compacting_p()) gc_mark_obj(list);
}
kv_vector[2 * index] = empty_symbol;
kv_vector[2 * index + 1] = empty_symbol;
ensure_non_ptr_word_writable(&hash_table->_count);
@ -1815,7 +1799,6 @@ cull_weak_hash_table (struct hash_table *hash_table,
SIMPLE_ARRAY_UNSIGNED_BYTE_32_WIDETAG);
boolean rehash = 0;
boolean save_culled_values = (hash_table->flags & make_fixnum(4)) != 0;
// I'm slightly confused as to why we can't (or don't) compute the
// 'should rehash' flag while scavenging the weak k/v vector.
// I believe the explanation is this: for weak-key-AND-value tables, the vector
@ -1827,7 +1810,7 @@ cull_weak_hash_table (struct hash_table *hash_table,
if (cull_weak_hash_table_bucket(hash_table, i, index_vector[i],
kv_vector, next_vector, hash_vector,
alivep_test, fix_pointers,
save_culled_values, rehash))
rehash))
rehash = 1;
}
/* If an EQ-based key has moved, mark the hash-table for rehash */
@ -1881,7 +1864,7 @@ void cull_weak_hash_tables(int (*alivep[4])(lispobj,lispobj))
* which is what an extra reset would do if it saw no inserts. */
if (weak_objects.count)
hopscotch_reset(&weak_objects);
// Close the region used when pushing items to the finalizer queue
// Close the region used when pushing into hash_table->smashed_cells
ensure_region_closed(cons_region, PAGE_TYPE_CONS);
}
@ -2637,6 +2620,145 @@ scavenge_interrupt_contexts(struct thread *th)
#endif /* !REG_CODE */
#endif /* x86oid targets */
/* Finalizer table based on Split-Ordered Lists */
typedef struct {
struct list_node lfnode;
lispobj hash;
lispobj key;
lispobj data;
// padding word, only if #+compact-instance-header
} so_node;
static inline int dummy_node_p(so_node* node) {
return (node->hash & make_fixnum(1)) == 0;
}
static inline int lispobj_livep(lispobj obj_base) {
extern int fullcgc_lispobj_livep(lispobj);
lispobj obj = compute_lispobj((lispobj*)obj_base);
return compacting_p() ? pointer_survived_gc_yet(obj) : fullcgc_lispobj_livep(obj);
}
static void push_in_lockfree_list(struct symbol* list_holder,
so_node* this, lispobj key, lispobj data)
{
// Using the so_node type for the rehash list is slightly wasteful of space
// but I don't feel like inventing another type just to eliminate the 'hash' slot.
// It is, however, imperative that we create new objects, because
// the lockfree algorithm crashes if nodes are mutated.
so_node* node =
gc_general_alloc(mixed_region, ALIGN_UP(sizeof (so_node), 2*N_WORD_BYTES),
PAGE_TYPE_MIXED);
const unsigned int header_low =
(((sizeof (so_node) / N_WORD_BYTES) - 1) << INSTANCE_LENGTH_SHIFT)
| INSTANCE_WIDETAG;
#ifdef LISP_FEATURE_COMPACT_INSTANCE_HEADER
node->lfnode.header = ((this->lfnode.header >> 32) << 32) | header_low;
*(&node->data + 1) = 0; // padding words
#else
node->lfnode.header = header_low;
node->lfnode._layout = this->lfnode._layout;
#endif
node->hash = 0;
node->key = key;
node->data = data;
lispobj old = list_holder->value;
node->lfnode._node_next = old != NIL ? old : LFLIST_TAIL_ATOM;
// This __sync_val_compare_and_swap can not fail.
// On the machines that have spurious failure, it is not exposed,
// unlike in C++ where you can choose compare_exchange_{weak|strong}
lispobj new = make_lispobj(node, INSTANCE_POINTER_LOWTAG);
lispobj actual = __sync_val_compare_and_swap(&list_holder->value, old, new);
gc_assert(actual == old);
if (!compacting_p()) gc_mark_obj(new);
}
static void push_in_ordinary_list(struct symbol* list_holder, lispobj element)
{
struct cons* cons = gc_general_alloc(cons_region, 2*N_WORD_BYTES, PAGE_TYPE_CONS);
cons->car = element;
lispobj old = list_holder->value;
cons->cdr = old;
lispobj new = make_lispobj(cons, LIST_POINTER_LOWTAG);
lispobj actual = __sync_val_compare_and_swap(&list_holder->value, old, new);
gc_assert(actual == old);
if (!compacting_p()) gc_mark_obj(new);
}
/* Scan the finalizer table and take action on each node as follows:
* - dummy nodes are ignored
* - nodes marked for deletion (by CANCEL-FINALIZATION) are culled
* - transported keys are moved to the "rehash" list
* - dead keys are moved to the "triggered" list
* - all other live keys are left alone
*/
void scan_finalizers()
{
// NOTE: we do NOT need to invoke notice_pointer_store() on the global values
// of REHASHLIST or TRIGGERED because those are static symbols.
lispobj finalizer_store = SYMBOL(FINALIZER_STORE)->value;
gc_assert(lowtag_of(finalizer_store) == INSTANCE_POINTER_LOWTAG);
struct split_ordered_list* solist = (void*)native_pointer(finalizer_store);
so_node* prev = (void*)native_pointer(solist->head);
// SO-HEAD can not possibly be marked for deletion, therefore %NODE-NEXT
// returns a valid node.
lispobj node = prev->lfnode._node_next;
while (node != LFLIST_TAIL_ATOM) {
// At each iteration, 'this' is the node whose disposition we're pondering,
// and 'prev' is its immediate predecessor, always a valid non-deleted node.
so_node* this = (so_node*)(node-INSTANCE_POINTER_LOWTAG);
// To determine if 'this' is pending deletion, read the bits of its 'next'
lispobj next = this->lfnode._node_next;
if (dummy_node_p(this)) {
// Case 1: split-order dummy node
gc_assert(!fixnump(next)); // 'this' can not be marked for impending deletion
prev = this, node = next;
continue;
} else if (fixnump(next)) {
// Case 2: logically deleted regular node- "help" the lisp code along
// by completing this deletion. Lisp will decrement SO-COUNT (I hope!)
} else if (forwarding_pointer_p((lispobj*)this->key)) {
// Case 3: live object moved
// Get the moved object and construct a weak pointer to it.
// If the new object were directly stored in node->key, that would create
// a strong reference which not delays finalization, but also transitively
// enlivens anything it reaches.
struct weak_pointer* weakptr =
gc_general_alloc(mixed_region,
ALIGN_UP(sizeof (struct weak_pointer), 2*N_WORD_BYTES),
PAGE_TYPE_MIXED);
weakptr->header = ((WEAK_POINTER_SIZE-1) << N_WIDETAG_BITS) | WEAK_POINTER_WIDETAG;
weakptr->value = forwarding_pointer_value((lispobj*)this->key);
#ifndef LISP_FEATURE_64_BIT
// 64-bit weak-pointers are 2 words, but 32-bit are 4 words because there
// is a GC-use field. In 64-bit, that field fits in the header.
memset(&weakptr->next, 0, 2*N_WORD_BYTES); // Will crash without this
#endif
if (!compacting_p()) gc_mark_obj(make_lispobj(weakptr, OTHER_POINTER_LOWTAG));
push_in_lockfree_list(SYMBOL(FINALIZER_REHASHLIST),
this, make_lispobj(weakptr, OTHER_POINTER_LOWTAG),
this->data);
this->key = 0; // clobber dangling reference
--solist->uw_count;
} else if (!lispobj_livep(this->key)) {
// Case 4: dead object
push_in_ordinary_list(SYMBOL(FINALIZERS_TRIGGERED), this->data);
this->key = 0; // clobber dangling reference
--solist->uw_count;
} else {
// Case 5: ordinary node, live unmoved object
prev = this, node = next;
continue;
}
// Cases 2 through 4 all delete 'this' from the list
node = next | INSTANCE_POINTER_LOWTAG; // restore lowtag on 'next' for case 2
notice_pointer_store(prev, &prev->lfnode._node_next);
prev->lfnode._node_next = node;
}
// Close the region
ensure_region_closed(mixed_region, PAGE_TYPE_MIXED);
ensure_region_closed(cons_region, PAGE_TYPE_CONS);
}
/* Our own implementation of heapsort, because some C libraries have a qsort()
* that calls malloc() apparently, which we MUST NOT do. */

View file

@ -111,6 +111,7 @@ extern sword_t scavenge(lispobj *start, sword_t n_words);
extern void scavenge_interrupt_contexts(struct thread *thread);
extern void scav_binding_stack(lispobj*, lispobj*, void(*)(lispobj));
extern void scan_binding_stack(void);
extern void scan_finalizers();
extern void cull_weak_hash_tables(int (*[4])(lispobj,lispobj));
extern void smash_weak_pointers(void);
extern boolean scan_weak_hashtable(struct hash_table *hash_table,

View file

@ -4304,6 +4304,7 @@ garbage_collect_generation(generation_index_t generation, int raise,
/* Return private-use pages to the general pool so that Lisp can have them */
gc_dispose_private_pages();
cull_weak_hash_tables(weak_ht_alivep_funs);
scan_finalizers();
obliterate_nonpinned_words();
// Do this last, because until obliterate_nonpinned_words() happens,

View file

@ -6,11 +6,10 @@
#-unix (invoke-restart 'run-tests::skip-file)
(defun fd-has-finalizer-p (fd)
;; Finalizer function can be:
;; 1. a function - finalizer that was not created with :DONT-SAVE.
;; 2. a single list of a function - a finalizer that was created with :DONT-SAVE
;; 3. a vector - more than one finalizer, each being form 1 or form 2.
(flet ((checkit (thing)
;; Return T if THING is a closure created in MAKE-FD-STREAM
;; (which is assumed to be a stream finalizer) whose payload
;; contains the integer FD.
(when (and (sb-kernel:closurep thing)
(eql (sb-kernel:%closure-index-ref thing 0) fd))
(let ((underlying (sb-kernel:%closure-fun thing)))
@ -18,16 +17,11 @@
(equal (sb-kernel:%simple-fun-name underlying)
'(lambda () :in sb-sys:make-fd-stream)))
(return-from fd-has-finalizer-p t))))))
(let ((v sb-impl::**finalizer-store**))
(loop for i from 3 below (length v)
do
(let ((entry (aref v i)))
(typecase entry
(list (checkit (car entry)))
(function (checkit entry))
(vector
(sb-int:dovector (entry entry)
(checkit (if (listp entry) (car entry) entry))))))))))
(sb-lockless:so-maplist
(lambda (node)
(dolist (f (sb-int:ensure-list (sb-lockless:so-data node)))
(checkit (if (functionp f) f (sb-kernel:value-cell-ref f)))))
sb-impl::**finalizer-store**)))
(defvar *fds*)
(defun make-streams ()

View file

@ -71,17 +71,13 @@
(with-test (:name :finalizers-dont-nest-garbage-collections)
(assert (<= *maxdepth* 1)))
;;; Regardless of anything else, check representational invariants.
;;; - each ID in the id-recycle-list is not a value in the hash-table
;;; - each value in the hash-table is not in the id-recycle-list
(with-test (:name :finalizer-id-uniqueness)
(let* ((hash-table (elt sb-impl::**finalizer-store** 1))
(used-ids (loop for v being each hash-value of hash-table
collect v))
(available-ids (cdr (elt sb-impl::**finalizer-store** 0))))
(assert (null (intersection used-ids available-ids)))))
(with-test (:name :finalizers-ran)
;; Finalizers won't have run for keys needing to be rehashed.
(unless (null sb-impl::*finalizer-rehashlist*)
(format t "~&::: INFO: rehashing finalizer store~%")
(sb-impl::finalizers-rehash)
(gc)
(sb-impl::run-pending-finalizers))
;; expect that 97% of the finalizers ran
(assert (>= *count* (* *n-finalized-things* 97/100)))
#+gencgc
@ -97,12 +93,20 @@
(setq *weak-pointers*
(delete-if (lambda (x) (null (weak-pointer-value x)))
*weak-pointers*))
(let ((hash-table (elt sb-impl::**finalizer-store** 1)))
(loop for k being each hash-key of hash-table
when (and (symbolp k) (not (symbol-package k)))
do (assert (find k *weak-pointers* :key #'weak-pointer-value)))
(dolist (wp *weak-pointers*)
(assert (gethash (weak-pointer-value wp) hash-table)))))
(let ((solist sb-impl::**finalizer-store**))
(sb-lockless:so-maplist
(lambda (node)
(let ((k (sb-sys:without-gcing (sb-impl::finalizer-object node))))
(when (and (symbolp k) (not (symbol-package k)))
(assert (find k *weak-pointers* :key #'weak-pointer-value)))))
solist)
(sb-sys:without-gcing
(dolist (wp *weak-pointers*)
(let ((addr (sb-kernel:%make-lisp-obj
(logandc2 (sb-kernel:get-lisp-obj-address
(weak-pointer-value wp))
sb-vm:lowtag-mask))))
(assert (sb-lockless:so-find solist addr)))))))
;;; A super-smart GC and/or compiler might prove that the object passed to
;;; FINALIZE is instantly garbage. Like maybe (FINALIZE (CONS 1 2) 'somefun)

View file

@ -105,7 +105,7 @@
;; but seems like it'll be OK for a while.
;; I see only 4 weak pointers in the baseline image.
;; Really we could just assert /= 1000.
(assert (< (length l) 15))))
(assert (< (length l) 60))))
;; check that WITHOUT-INTERRUPTS doesn't block SIG_STOP_FOR_GC
(with-test (:name :gc-without-interrupts

View file

@ -198,11 +198,12 @@
`(sb-c::*code-serialno*
sb-c::*compile-elapsed-time*
sb-c::*compile-file-elapsed-time*
sb-impl::*finalizer-rehashlist*
sb-impl::*finalizers-triggered*
sb-impl::*package-names-cookie*
sb-impl::*available-buffers*
sb-impl::*token-buf-pool*
sb-impl::*user-hash-table-tests*
sb-impl::**finalizer-store**
sb-impl::*pn-dir-table*
sb-impl::*pn-table*
sb-vm::*immobile-codeblob-tree*