Summary
Any generic function with an eql specializer takes a full dispatch miss on every call. Its call history stays permanently empty, so compute-applicable-methods, compute-effective-method and a fresh effective-method-function run on every single call.
Class-specialized generic functions are unaffected.
Version: clasp-boehm-3.0.1-73-g2cf5bb5e4, macOS arm64. Reproduces identically on boehm and boehmprecise.
Reproducer
(defun bytes-per (thunk n)
(funcall thunk)
(let ((start (gctools:bytes-allocated)))
(dotimes (i n) (funcall thunk))
(/ (float (- (gctools:bytes-allocated) start)) n)))
(defgeneric egf (k))
(defmethod egf ((k (eql :a))) 1)
(defmethod egf ((k (eql :b))) 2)
(bytes-per (lambda () (egf :a)) 500) ; => 6784.0
(length (clos::generic-function-call-history #'egf)) ; => 0 <-- never memoized
A class-specialized gf for comparison gives 0.0 B/call and a call history of length 1.
Impact
clasp-ffi:%mem-ref / %mem-set define one eql method per foreign type (fli.lisp, generate-methods), so every CFFI foreign memory access hits this:
clasp-ffi:%mem-ref 19448 B/call, ~321 us/call, call history 0
clasp-ffi:%mem-set 20240 B/call
Cost scales with method count (~6300 B + ~288 B/method) and is identical for the first or last key, since it is not a search.
Root cause
src/lisp/kernel/clos/miss.lisp, in miss-info:
((eq (class-of generic-function)
#.(find-class 'standard-generic-function))
(memoize-eql-specialized generic-function method-combination
call-history argument-classes))
In the built image this comparison is always false, so miss-info falls through to the (t nil) clause and never calls memoize-eql-specialized. Nothing is added to the call history, so the next call misses again. Note updatedp is therefore always NIL and force-discriminator never runs — the discriminating function is not being rebuilt, the raw miss path is simply taken every time.
Replacing the read-time literal with a runtime (find-class 'standard-generic-function) fixes it completely: 0 B/call, and %mem-ref drops to 137 ns with a 27-entry call history.
Why this is filed rather than just patched: the literal is not stale
Everything checkable says the comparison should succeed:
- The module literal, the live class, and
(class-of gf) are all eq to one another:
(eq (class-of #'egf) (aref (core:bytecode-module/literals (core:bytecode-simple-fun/code #'clos::miss-info)) 9)) => T, and both are eq to (find-class 'standard-generic-function).
- The bytecode decodes correctly. Raw bytes around the test:
60 8 (called-fdefinition EQ), 60 1 (CLASS-OF), 2 0 (closure 0 = the gf), 4 1, 1 9 (const literal 9 = the class), 4 2, 23 12 (jump-if-8 +12). Offset 12 from the opcode lands exactly on the called-fdefinition of MEMOIZE-EQL-SPECIALIZED.
- Every input to the
cond is correct. Wrapping clos::outcome shows, inside the compiled function's own extent: closure 0 is #'egf, (eq (class-of gf) (find-class 'standard-generic-function)) is T, ok is NIL, final-methods has length 1, call-history is NIL.
- Yet witness counters on
memoize-eql-specialized, specializers-combinate and call-history-find-key all stay at zero, while a direct call to memoize-eql-specialized with those exact arguments returns 3 entries and trips all three counters.
- Redefining
miss-info at runtime from identical source (with either the #. literal or a runtime find-class) works correctly.
So the source is right, the constant is right, the bytecode is right, and the inputs are right, but the branch is not taken in cross-clasp-compiled code. That looks like a compiler or VM issue rather than a CLOS one, which is why it is worth a look beyond the one-line workaround.
Second occurrence of the same pattern
src/lisp/kernel/clos/static-gfs/svuc.lisp, uncustomizable-slot-p, has the same read-time-literal guard and is dead in the same way — it returns NIL for a plain standard-class with a standard-effective-slot-definition:
(defclass plainc () ((a :initform 1)))
(let ((c (find-class 'plainc)))
(eq (class-of c) (find-class 'standard-class))) ; => T
(static-gfs::uncustomizable-slot-p c slotd) ; => NIL, expected T
This silently disables the static slot-value / slot-boundp optimization for every standard class.
Not all such guards fail: #.(find-class 't) in update-specializer-profile (generic.lisp:284) and #.(find-class 'standard-class) in std-create-slots-table (class.lisp:307) both behave correctly, so it is not simply "all read-time class literals are broken".
Summary
Any generic function with an
eqlspecializer takes a full dispatch miss on every call. Its call history stays permanently empty, socompute-applicable-methods,compute-effective-methodand a fresheffective-method-functionrun on every single call.Class-specialized generic functions are unaffected.
Version:
clasp-boehm-3.0.1-73-g2cf5bb5e4, macOS arm64. Reproduces identically onboehmandboehmprecise.Reproducer
A class-specialized gf for comparison gives
0.0B/call and a call history of length 1.Impact
clasp-ffi:%mem-ref/%mem-setdefine oneeqlmethod per foreign type (fli.lisp,generate-methods), so every CFFI foreign memory access hits this:Cost scales with method count (~6300 B + ~288 B/method) and is identical for the first or last key, since it is not a search.
Root cause
src/lisp/kernel/clos/miss.lisp, inmiss-info:In the built image this comparison is always false, so
miss-infofalls through to the(t nil)clause and never callsmemoize-eql-specialized. Nothing is added to the call history, so the next call misses again. Noteupdatedpis therefore always NIL andforce-discriminatornever runs — the discriminating function is not being rebuilt, the raw miss path is simply taken every time.Replacing the read-time literal with a runtime
(find-class 'standard-generic-function)fixes it completely: 0 B/call, and%mem-refdrops to 137 ns with a 27-entry call history.Why this is filed rather than just patched: the literal is not stale
Everything checkable says the comparison should succeed:
(class-of gf)are alleqto one another:(eq (class-of #'egf) (aref (core:bytecode-module/literals (core:bytecode-simple-fun/code #'clos::miss-info)) 9))=>T, and both areeqto(find-class 'standard-generic-function).60 8(called-fdefinitionEQ),60 1(CLASS-OF),2 0(closure 0= the gf),4 1,1 9(constliteral 9 = the class),4 2,23 12(jump-if-8 +12). Offset 12 from the opcode lands exactly on thecalled-fdefinitionofMEMOIZE-EQL-SPECIALIZED.condis correct. Wrappingclos::outcomeshows, inside the compiled function's own extent:closure 0is#'egf,(eq (class-of gf) (find-class 'standard-generic-function))isT,okis NIL,final-methodshas length 1,call-historyis NIL.memoize-eql-specialized,specializers-combinateandcall-history-find-keyall stay at zero, while a direct call tomemoize-eql-specializedwith those exact arguments returns 3 entries and trips all three counters.miss-infoat runtime from identical source (with either the#.literal or a runtimefind-class) works correctly.So the source is right, the constant is right, the bytecode is right, and the inputs are right, but the branch is not taken in cross-clasp-compiled code. That looks like a compiler or VM issue rather than a CLOS one, which is why it is worth a look beyond the one-line workaround.
Second occurrence of the same pattern
src/lisp/kernel/clos/static-gfs/svuc.lisp,uncustomizable-slot-p, has the same read-time-literal guard and is dead in the same way — it returns NIL for a plainstandard-classwith astandard-effective-slot-definition:This silently disables the static
slot-value/slot-boundpoptimization for every standard class.Not all such guards fail:
#.(find-class 't)inupdate-specializer-profile(generic.lisp:284) and#.(find-class 'standard-class)instd-create-slots-table(class.lisp:307) both behave correctly, so it is not simply "all read-time class literals are broken".