Repository navigation
EQL-specialized generic functions never memoize: every call is a full dispatch miss #1811
Description
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 memoizedA 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 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). - The bytecode decodes correctly. Raw bytes around the test:
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. - Every input to the
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. - Yet witness counters on
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. - Redefining
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 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 TThis 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".
Activity
dg1sbg commentedon Jul 31, 2026
Audited the remaining
#.(find-class ...)sites in the kernel. A third one is dead, and this one is a correctness bug rather than a lost optimization.validate-superclassrejects AMOP-legal hierarchiesclass.lisp:171-181uses read-time literals to permit astandard-classand afuncallable-standard-classto appear in each other's superclass chain. Both clauses evaluate false in the built image, so the mixed case is rejected:Called directly, both directions return NIL where T is expected:
Full audit
miss.lisp:266(eq (class-of gf) #.(find-class 'standard-generic-function))svuc.lisp:21-23(eq (class-of class) #.(find-class 'standard-class))…class.lisp:174-177(eq c1 #.(find-class 'standard-class))…class.lisp:184(eq superclass #.(find-class 't))class.lisp:307-309(eq metaclass #.(find-class 'standard-class))…generic.lisp:284(eql spec #.(find-class 't))method.lisp:117-121(class-prototype #.(find-class 'standard-generic-function))What the split is not
Several plausible explanations are ruled out by the table:
#.(find-class 'standard-class)is dead atsvuc.lisp:21and atclass.lisp:176, but works atclass.lisp:307.class.lisp, line 174-177 is dead while 184 and 307 work.defunvsdefmethod. Both groups contain both.method.lispcase uses the literal as a value, but the workingclass.lisp:307andgeneric.lisp:284cases areeq/eqltests.class-ofvssi::instance-class. The two deadclass-ofsites and the workinginstance-classsite suggested this, but(eq (class-of c) (si::instance-class c))is T for the classes involved.The only consistent observation remains the one in the issue body: recompiling the identical source at runtime produces working code, so the divergence is introduced at build time. Note the built-in class
Tworks in both places it appears, while every dead site names a CLOS metaobject class — butclass.lisp:307is a counterexample to that being sufficient on its own.PR #1812 fixes the first two by switching to a runtime
find-class. Thevalidate-superclasscase is left alone there pending a decision on whether to paper over each site individually or fix the underlying cause.dg1sbg commentedon Aug 1, 2026
Root cause found
It is not specific to
#.or to CLOS at all.(eq X <constant>)is folded toNILwheneverXhas a declared CLOS class type and the constant is an instance of that type.The
#.(find-class ...)guards were just the place it happened to bite:class-ofis proclaimed(ftype (function (t) class) class-of)(cleavir/proclamations.lisp:269), so(eq (class-of x) #.<a class object>)has aclass-typed operand and a constant-instance operand — exactly the broken shape.Minimal reproducer
Compiled into the kernel (cross-clasp / native), then called:
(probe-t (find-class 'clos:funcallable-standard-class))(probe-cls (find-class 'clos:funcallable-standard-class))(probe-sc (find-class 'standard-class))(probe-cof)Declaring the argument
tworks. Declaring itclassorstandard-class— a strictly more precise, and correct, declaration — silently makes the comparison constant-NIL.Where
src/lisp/kernel/cleavir/transform.lisp:406:and
Ctype/other-functions.lisp:63:So conjoining a CLOS class ctype with a member/
eqlctype whose element is a CLOS instance yields bottom, anddisjointpreports disjoint. That is unsound:#<FUNCALLABLE-STANDARD-CLASS ...>is of typeclass, so the conjunction is the member type, not bottom.disjointp's docstring allows false negatives ("may return false even if the ctypes are disjoint"), but a false positive silently miscompiles code.Why it looked mysterious
eqto the live class — the constant is fine. Nothing is stale. The comparison is discarded before it ever runs.class-of; every working one did not (class.lisp:307usessi::instance-class, which has no such proclamation, and works with the same literal in the same file).Scope
Wider than the three guards in #1812. Any code of the form
(eq x <literal instance>)wherexis declared to a CLOS class type compiles toNIL— including user code.eqlhas the same transform immediately below and presumably the same exposure.I have not attempted a fix: the defect is in ctype conjunction/
bottom-pfor member types over CLOS instances, and guessing at Cleavir's type-inference invariants seemed more likely to cause harm than the documented bug. #1812 remains a correct workaround for the three kernel sites regardless of how this is resolved.