Skip to content

Fix three CLOS guards whose read-time class literals never match (eql-gf memoization, static slot-value, validate-superclass) - #1812

Open
dg1sbg wants to merge 2 commits into
clasp-developers:mainfrom
dg1sbg:pr/eql-specializer-memoization
Open

Fix three CLOS guards whose read-time class literals never match (eql-gf memoization, static slot-value, validate-superclass)#1812
dg1sbg wants to merge 2 commits into
clasp-developers:mainfrom
dg1sbg:pr/eql-specializer-memoization

Conversation

@dg1sbg

@dg1sbg dg1sbg commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Fixes #1811.

Three guards in CLOS compare a class against a read-time literal, #.(find-class ...). All three evaluate false in the built image, so all three silently take their fallback path. Two cost performance; the third is a correctness bug.

1. miss.lisp:266 — eql-specialized generic functions never memoize

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 every single time. Class-specialized generic functions are unaffected.

((eq (class-of generic-function)
     #.(find-class 'standard-generic-function))
 (memoize-eql-specialized ...))

False in the built image, so miss-info falls through to (t nil) and never calls memoize-eql-specialized. Nothing is added to the call history, so the next call misses again.

This is not discriminating-function recomputation: updatedp is always NIL, so force-discriminator never runs. The raw miss path is simply taken every time — which is why the cost is flat across the first and last key rather than looking like a search.

before after
30-method eql gf, first key 14656 B/call 0.0 B
30-method eql gf, last key 14656 B/call 0.0 B
eql gf call history 0 31
clasp-ffi:%mem-ref 19448 B, 321620 ns 0.0 B, 88 ns
clasp-ffi:%mem-set 20240 B 0.0 B
%mem-ref call history 0 27

clasp-ffi:%mem-ref / %mem-set define one eql method per foreign type, so every CFFI foreign memory access was paying this. Cost scaled at roughly 6300 B + 288 B/method. Confirmed one layer up: compiled (cffi:mem-ref p :float off) goes from that to 0.00 B/call.

2. svuc.lisp:21-23 — static slot-value optimization disabled

uncustomizable-slot-p returned NIL for a plain standard-class with a standard-effective-slot-definition, silently disabling the static slot-value / slot-boundp optimization for every standard class. Both eq tests are true when evaluated at runtime.

3. class.lisp:174-177validate-superclass rejects AMOP-legal hierarchies

This one is a correctness bug. The two clauses permitting a standard-class and a funcallable-standard-class in each other's superclass chain are both dead, so the mixed case is refused:

(defclass plain-sc () ())
(defclass fsc-from-sc (plain-sc) () (:metaclass clos:funcallable-standard-class))
;; => Class #<STANDARD-CLASS PLAIN-SC> is not a valid superclass for
;;    #<FUNCALLABLE-STANDARD-CLASS FSC-FROM-SC>

Called directly, both directions returned NIL where T is required.

Fix

Runtime find-class in all three. In validate-superclass the lookups are deferred behind the existing (eq c1 c2) fast path and use errorp nil, so the common same-metaclass case does no lookup and bootstrap cannot trip over a metaclass that is not yet registered. cl:find-class is a C++ CL_DEFUN hash lookup, not a generic function, so it is safe on the dispatch miss path.

Verification

Six new tests, in fastgf.lisp and mop.lisp. Each fails before and passes after.

  • boehm: 1974 successes, zero unexpected failures.
  • boehmprecise: 1976, zero unexpected failures.
  • Clean-bootstrap boehmprecise: kernel Lisp and both images deleted and regenerated from source — no errors, 1979 successes, zero unexpected failures. This specifically checks that replacing load-time literals with runtime lookups does not destabilise bootstrap ordering, since all three sites run during bootstrap (validate-superclass on every defclass, miss-info on every dispatch miss, uncustomizable-slot-p at every kernel compile).

Worth a second look

The change fixes the symptom; the reason the comparisons fail is not explained, which is why #1811 is open separately. The module literal, the live class and (class-of x) are all eq to one another; the bytecode decodes correctly; every input to the cond is correct — yet witness counters on the guarded functions stay at zero, and recompiling the identical source at runtime works.

Not all such guards fail: class.lisp:184, class.lisp:307 and generic.lisp:284 behave correctly. The audit in #1811 rules out per-class, per-file, defun vs defmethod, test-vs-value, and class-of vs si::instance-class as the discriminator. Whatever the real cause is, it can silently disable any future #.(find-class ...) guard the same way — which is exactly how the slot-value optimization sat dead without anyone noticing.

Any generic function with an eql specializer took a full dispatch miss on
every call: compute-applicable-methods, compute-effective-method and a
fresh effective-method-function, every time. Measured on this tree
(3.0.1-73-g2cf5bb5e4, boehm and boehmprecise identically):

  2-method eql gf        6784 B/call, call history stays empty
  clasp-ffi:%mem-ref    19448 B/call, 321 us/call, call history empty
  class-specialized gf       0 B/call

clasp-ffi:%mem-ref / %mem-set define one eql method per foreign type, so
every CFFI foreign memory access paid this. After the fix both drop to
0 B/call and 137 ns, and the call history fills normally (27 entries for
%mem-ref).

Root cause is the guard in miss-info deciding whether an eql-specialized
call may be memoized. It compared (class-of generic-function) against a
read-time literal, #.(find-class 'standard-generic-function). In the built
image that comparison is always false, so miss-info fell through to the
(t nil) clause and never called memoize-eql-specialized. Nothing was ever
added to the call history, so the next call missed again.

The literal is not stale. In the running image the module literal, the
live class and (class-of gf) are all eq to one another; the bytecode
decodes correctly (jump-if-8 +12 lands exactly on the called-fdefinition
of memoize-eql-specialized); and instrumenting outcome shows every input
to the cond is correct, with ok nil and final-methods of length one. Yet
witness counters on memoize-eql-specialized, specializers-combinate and
call-history-find-key all stay at zero. Why the comparison evaluates
false in cross-clasp-compiled code is not explained; recompiling the
identical source at runtime makes it work. Filed separately.

Replacing the read-time literal with a runtime find-class fixes it. The
lookup is on the miss path only, which after this change is taken once
per key rather than once per call.

static-gfs::uncustomizable-slot-p carried the same read-time-literal
guard and was dead the same way, silently disabling the static
slot-value/slot-boundp optimization for every standard class: it returned
NIL for a plain standard-class with a standard-effective-slot-definition
even though both eq tests are true evaluated at runtime. This also
explains why toggling clos::*optimize-slot-value* moved allocation by
<0.1% -- the optimization was already off.

Adds a regression test that fails before and passes after: an
eql-specialized gf must leave a non-empty call history.
validate-superclass permits a standard-class and a funcallable-standard-class
to appear in each other's superclass chain, via two clauses guarded by
read-time class literals. Both evaluate false in the built image, so the mixed
case was rejected outright:

  (defclass plain-sc () ())
  (defclass fsc-from-sc (plain-sc) ()
    (:metaclass clos:funcallable-standard-class))
  ;; => Class #<STANDARD-CLASS PLAIN-SC> is not a valid superclass for
  ;;    #<FUNCALLABLE-STANDARD-CLASS FSC-FROM-SC>

Called directly, both directions returned NIL where T is required.

Same root cause as the eql-specializer memoization guard in this branch: a
#.(find-class ...) literal that is eq to the live class yet compares false in
cross-clasp-compiled code. Unlike that one, this is a correctness bug rather
than a lost optimization -- Clasp refuses hierarchies AMOP permits.

The lookups are deferred behind the (eq c1 c2) fast path and use errorp nil, so
the common same-metaclass case does no lookup at all, and bootstrap cannot trip
over a metaclass that is not yet registered. Verified by a clean Lisp bootstrap
of boehmprecise -- kernel and both images regenerated from source, no errors,
1979 successes with zero unexpected failures.

Adds two MOP tests: both directions of validate-superclass, and the defclass
that previously failed.
@dg1sbg dg1sbg changed the title Fix: eql-specialized generic functions never memoize (every call is a full dispatch miss) Fix three CLOS guards whose read-time class literals never match (eql-gf memoization, static slot-value, validate-superclass) Aug 1, 2026
@dg1sbg dg1sbg closed this Aug 1, 2026
@dg1sbg dg1sbg reopened this Aug 1, 2026
@dg1sbg

dg1sbg commented Aug 1, 2026

Copy link
Copy Markdown
Contributor Author

Note on the red clasp/ubuntu-latest/bytecode/no/no job on run 30693073178: it is not caused by this change. It failed on WEAK-KEY-AND-VALUE-WEAKNESS, a conservative-GC weak-hash-table test, which I have filed separately as #1814.

Evidence it is flaky rather than caused by this PR:

  • The identical commit 661438586 re-run on a fork (30697205345) passed the regression-test step — 1968 successes, no failures. That job's red mark is an unrelated runner shutdown during the later ANSI step.
  • In the same upstream run, clasp/macos-latest/bytecode and clasp/ubuntu-latest/native both passed with this commit. Only the Linux+bytecode intersection failed.
  • The failing test has no causal connection to class-identity guards. I checked the one mechanism by which this PR could plausibly matter — the now-populated call histories retaining objects — and ruled it out: the eql-specializer intern table is unchanged by ordinary hash-table use (550 → 550), and a weak table still collapses to 1 after 500 EQL dispatches.

Local verification of this branch, macOS arm64:

  • boehm 1977 successes, boehmprecise 1979, zero unexpected failures on both
  • a full from-scratch rebuild (ninja -t clean, 542/542 steps, 2.0 GB regenerated) then 1979 again
  • WEAK-KEY-AND-VALUE-WEAKNESS specifically: 200/200 in isolation, 5/5 after the CLOS suites

I have re-triggered CI on this PR.

@dg1sbg

dg1sbg commented Aug 1, 2026

Copy link
Copy Markdown
Contributor Author

Second CI flake on this branch, a different one, same conclusion.

The re-triggered run cleared the earlier WEAK-KEY-AND-VALUE-WEAKNESS failure — clasp/ubuntu-latest/bytecode now passes. This time clasp/macos-latest/bytecode failed, at the ANSI step, on UPGRADED-COMPLEX-PART-TYPE.9: 1 unexpected failure out of 21936 tests, alongside 4 unexpected successes (PRINT.LONG-FLOAT.RANDOM, PRINT.SYMBOL.RANDOM.3/4, FORMAT.E.26).

Same commit 661438586, same job, opposite results:

run clasp/macos-latest/bytecode
30693073178 success
30701956588 failure

No commit in between — the second run was a close/reopen re-trigger of the identical SHA.

So across three runs of this branch, three different jobs failed for three unrelated reasons (weak-hash GC test, runner shutdown, one ANSI numeric test), while every other job passed each time. Nothing points at this change, which touches only CLOS class-identity guards. The four unexpected successes in the same ANSI run suggest that expected-failure list has drifted on macOS as well.

Local state for this branch remains: boehm 1977, boehmprecise 1979, zero unexpected failures, including a full from-scratch rebuild.

See #1814 for the weak-hash-table flake, which #1815 fixes.

@dg1sbg

dg1sbg commented Aug 1, 2026

Copy link
Copy Markdown
Contributor Author

Cross-platform validation, since this PR's CI has been red twice for reasons unrelated to it (see the two comments above — a weak-hash GC flake and an ANSI flake, each disproven by identical-commit contradiction).

Built and tested on a second platform to remove any doubt that the change is macOS/arm64-specific:

macOS arm64 / LLVM 22 / native x86-64 Linux / LLVM 18 / bytecode
build 542/542, clean from scratch 536/536, clean from scratch
suite 1979 successes, 0 unexpected failures 1979 successes, 0 unexpected failures
expected failures the same 4 the same 4
runs full from-scratch rebuild + suite 13 consecutive full suite runs, all clean

Same totals on both, across two architectures, two LLVM major versions, and two build modes. The Linux configuration is deliberately the same one whose CI job went red (bytecode / no-default-native).

Tested here as part of an integration branch merging six open PRs together, so this also confirms these three guards coexist with the shmem, slot-value, FFI and hardening work rather than only passing in isolation.

The six tests added by this PR pass on both platforms:

DISPATCH-EQL-ALPHA / -BETA / -MEMOIZED
VALIDATE-SUPERCLASS-MIXED-METACLASSES
VALIDATE-SUPERCLASS-FUNCALLABLE-FROM-STANDARD

Root cause of the underlying compiler defect is now fully diagnosed in #1811 — it is not #.-specific: (eq X <constant instance>) folds to NIL whenever X carries a CLOS class type declaration. This PR remains the correct workaround for the three kernel sites regardless of how that is fixed.

@dg1sbg

dg1sbg commented Aug 1, 2026

Copy link
Copy Markdown
Contributor Author

Merging #1815 first is what makes this PR's CI able to hold still.

The two red jobs here are not going to clear on their own, and re-triggering is close to a coin flip. Across three runs of this branch, three different jobs failed for three unrelated reasons, while every other job passed each time:

run failing job cause
30693073178 clasp/ubuntu-latest/bytecode WEAK-KEY-AND-VALUE-WEAKNESS
30697205345 (fork, same SHA) clasp/ubuntu-latest/bytecode runner shutdown during ANSI step
30701956588 cando/ubuntu-latest/native WEAK-KEY-AND-VALUE-WEAKNESS again
same run clasp/macos-latest/bytecode ANSI UPGRADED-COMPLEX-PART-TYPE.9

Every one of those is disproven as a regression by identical-commit contradiction — the same SHA 661438586 passed each of those jobs on another run, with no commit in between.

WEAK-KEY-AND-VALUE-WEAKNESS accounts for two of the four, has now hit two different jobs, and is exactly what #1815 fixes (see #1814). It is not in this branch, so this PR cannot benefit from the fix while it sits unmerged.

Suggested order:

  1. tests: make weak-table weakness tests robust under conservative GC #1815 — removes the dominant flake. Independent of this PR, one test file, no runtime change.
  2. Fix: %mem-set OFFSET has no default, unlike %mem-ref #1813 — currently 6/6 green, independent, mergeable now.
  3. this PR — re-trigger against a CI that can actually hold still.

Merging this one first would work too, but its CI would stay unreliable and every future re-trigger keeps rolling the same dice. There is nothing to fix in this PR's code: it passes 1979 successes with zero unexpected failures on macOS arm64 / LLVM 22 / native and x86-64 Linux / LLVM 18 / bytecode, the latter across 13 consecutive full suite runs in the same configuration whose CI job goes red.

@dg1sbg

This comment has been minimized.

@dg1sbg

This comment has been minimized.

@dg1sbg

dg1sbg commented Aug 3, 2026

Copy link
Copy Markdown
Contributor Author

Summary of my Linux verification of this PR. This supersedes my earlier comments in this thread (now collapsed) — one of them reported a regression that turned out not to reproduce, and I withdraw it.

Both fixes verified, with a baseline

x86-64 Linux, LLVM 18, boehmprecise, --build-mode=native — the mode where the read-time-guard miscompile actually bites. The EQL-gf probe uses 30 EQL methods plus a default; the validate-superclass probe uses the real case with no user-defined validate-superclass method, since defining one masks the built-in guard entirely.

upstream/main (fc2beb4) this PR (6614385)
EQL-gf call history 0 — never memoized 31
bytes/call, first key 16147.2 0.0
bytes/call, last key 16152.0 0.0
ns/call 113591 178.3
funcallable-standard-class over standard-class REJECTED (SIMPLE-ERROR) ACCEPTED
standard-class over funcallable-standard-class REJECTED (SIMPLE-ERROR) ACCEPTED
genuinely illegal metaclass mix (negative control) REJECTED REJECTED

history=0 on baseline is categorical evidence the memoize guard was dead — the call history never populated, so every call took the full dispatch-miss path at 16 KB and 113 us. That is a 637x speedup and 16 KB -> 0 B per call. The cost is flat across first and last key (16147.2 vs 16152.0, 0.03% apart), which is the signature of a raw miss rather than a search.

The validate-superclass change fixes a genuine correctness bug — both AMOP-legal metaclass directions are rejected on main. The negative control still rejects an illegal mix, so the fix does not over-apply.

Regression suite: clean

1968 successes / TEST_EXIT=0 in --build-mode=native and --build-mode=bytecode. 1968 = 1963 baseline + the 5 tests this PR adds, which is exactly right. That figure held across nine runs on independently produced builds.

Withdrawn: my earlier "drops 122 tests, split the PR" report

I earlier reported 1846 successes in bytecode mode and attributed it to static-gfs/svuc.lisp, recommending the PR be split. That does not reproduce and the recommendation should be disregarded. The reading came from a single run of a single build, compared against a single run of a differently produced build — build provenance and the change under test were confounded, so it was not the controlled comparison I described.

I chased three candidate explanations for the anomalous 1846 and none survived: svuc.lisp (the PR measures 1968 with it present and untouched), an unguarded slot read in cmpltv's print-object, and stale generated files from #1823. For the last one I reproduced the exact conditions — build feat/posix-shmem, switch here without clearing the generated files, leaving 178 B and 214 B of stale tail on runtime-packages.lisp and runtime-functions.lisp — and the suite still came back 1968.

So the original 1846 is unreproduced and unexplained. I am not going to offer a fourth theory. Nothing here should block this PR.

Two unrelated observations, src/lisp/kernel/cmp/cmpltv.lisp

Noted while investigating; neither is a fix for any bug I can currently demonstrate:

  • print-object for vcreator guards %prototype with slot-boundp but reads (index object) unguarded. Any half-constructed creator therefore raises a secondary unbound-slot error from inside the printer, masking whatever the real condition was — which is exactly how the original failure presented and a large part of why it was hard to read.
  • The %index slot is declared :type (integer 0) while its initform is nil; upstream Maclina has :type (or null (integer 0)). Not a live bug (make-instance stores nil there without complaint), but the declaration contradicts the initform.

Happy to send a small PR for those if useful.

@dg1sbg

This comment has been minimized.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

1 participant