Skip to content

EQL-specialized generic functions never memoize: every call is a full dispatch miss #1811

Description

@dg1sbg

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".

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions