Skip to content

Stop 1+, 1-, logtest and isqrt consing on every call - #1817

Open
dg1sbg wants to merge 2 commits into
clasp-developers:mainfrom
dg1sbg:perf/numlib-compiler-macro-ordering
Open

Stop 1+, 1-, logtest and isqrt consing on every call#1817
dg1sbg wants to merge 2 commits into
clasp-developers:mainfrom
dg1sbg:perf/numlib-compiler-macro-ordering

Conversation

@dg1sbg

@dg1sbg dg1sbg commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Two small, independent fixes to the numeric compiler macros. The first is a regression fix.

1. numlib.lisp defines its compiler macros after the definitions that need them

The n-ary CL bit and arithmetic functions are bound to C++ entry points that take their arguments as a list — cl__logand(List_sp) (src/core/bits.cc:830), cl__logior(List_sp) (:843), cl__max(Real_sp, List_sp) (src/core/numbers.cc:190) — so every full call to one conses one cons per argument. Measured on boehmprecise, two arguments:

full call allocation
cl:+, cl:*, cl:=, logand, logior, logxor 48 B
cl:-, cl:max 24 B
core:two-arg-+ (the 2-op reference) 0 B

numlib.lisp neutralises this with compiler macros rewriting (logand a b) to core:logand-2op and so on — but they only help code compiled after them.

379bb29 consolidated those macros into a single block placed after the seventeen definitions that open numlib.lisp. That moved the 1+/1- macros later rather than earlier, and left the KLUDGE comment explaining the placement stranded above an empty (eval-when (:compile-toplevel)). All seventeen definitions consequently baked in a full call to an n-ary function.

Moving the block back above them — to the position the orphaned comment already marks:

full call before after
cl:1+ 48 B 0 B
cl:1- 24 B 0 B
cl:logtest 48 B 0 B
cl:isqrt 48 B 0 B

This commit is a pure relocation: a multiset diff of the file's lines shows zero lines added, and five removed — the empty eval-when plus three comment lines that duplicated the KLUDGE comment.

2. Two-argument - and / carry a redundant type check

(- a b) expanded to (core:two-arg-- a (+ b)), and (/ a b) to (core:two-arg-/ a (* b)). A one-argument + or * is not free: expand-associative renders it as (the-single number x), which the bytecode compiler emits as real calls to %the-single and values. So every two-argument subtraction and division in bytecode-compiled code made three calls where one would do:

  called-fdefinition CORE:TWO-ARG--
  ref 0
  called-fdefinition CORE::%THE-SINGLE
  const 'NUMBER
  called-fdefinition VALUES
  ref 1
  call-receive-one 1
  call-receive-one 2
  call 2

The check is redundant by expand-associative's own comment — it is needed only in the one-argument case, because "with more arguments, the two-arg-fun will do checks". core:two-arg-- and core:two-arg-/ do exactly that, and an argument form is already in a single-value context, so the (values ...) wrapper buys nothing either. Cleavir already transforms %the-single into a THE (cleavir/transform.lisp:375), so this only ever cost the bytecode path.

core:expand-inverse captures the shape - and / share, mirroring core:expand-associative, and is used for both the build-time compiler macros in numlib.lisp and the load-time ones in cmp/opt/opt-number.lisp. As is already done for expand-associative, cross-clasp carries its own copy in macrology.lisp plus entries in packages.lisp and base.lisp.

Both two-argument forms now emit a single call.

Testing

boehmprecise, macOS arm64, :build-mode :native, :extensions ():

  • Allocation probe, 33 constants: only the logtest row moves (48.00 → 0.00 B/call). Every other row is byte-identical, including after the second commit.
  • Behaviour, 89 checks covering all seventeen definitions the block was moved past: logtest on bignums, negatives, zero and mixed fixnum/bignum plus an exhaustive -40..40 cross-check against its definition; isqrt invariants over 0..2000; 1+/1- across the fixnum/bignum boundary and on ratios, doubles and complexes; ffloor/fceiling/ftruncate/fround including two-argument forms; phase/cis; the real and complex branches of asinh/acosh/atanh/asin/acos; and n-ary arity and identity checks for + - * / logand logior logxor logeqv min max < = /=. 0 failures.
  • - and / semantics: the full before/after output at one, two and three arguments over integers, ratios, bignums, double-floats and complexes is byte-identical; extra values are still truncated ((- 5 (values 1 2)) = 4); and a non-number argument still signals TYPE-ERROR with datum and expected-type NUMBER — the same condition %the-single raised.
  • ninja -C build test: 1979 successes, 4 failures, all four in *expected-failures*, 0 unexpected — identical before and after.
  • ninja -C build ansi-test: 27 failures, 0 unexpected, out of 21936.

Verified on a tree that also carries several unrelated merged PR branches; all six files this PR touches are byte-identical between that tree and main, so what was built and tested is exactly the content here.

CI note

PRINT.DOUBLE-FLOAT.RANDOM is flaky for reasons unrelated to this PR — see #1816. It draws random doubles and trips a long-standing printer bug at roughly 1 in 13000, which reproduces identically on a 2.7.0 binary. If CI shows it red, it is not this change; the rest of the failure list should be compared.

dg1sbg added 2 commits August 1, 2026 22:57
The n-ary CL bit and arithmetic functions are bound to C++ entry points that
take their arguments as a list -- cl__logand(List_sp) at src/core/bits.cc:830,
cl__logior(List_sp) at :843, cl__max(Real_sp, List_sp) at numbers.cc:190 --
so every full call to one conses one cons per argument. numlib.lisp avoids
that by defining compiler macros that rewrite (logand a b) to core:logand-2op,
(- a b) to core:two-arg--, and so on. For those rewrites to apply, the macros
have to be in effect before the definitions that use them are compiled, which
is what the KLUDGE comment at the top of the file is there to explain.

379bb29 consolidated the compiler macros into a single block and placed it
after the seventeen definitions that open numlib.lisp, moving the 1+ and 1-
macros later rather than earlier and leaving that KLUDGE comment stranded
above an empty (eval-when (:compile-toplevel)). All seventeen definitions
consequently baked in a full call to an n-ary function.

Move the block back above them, to the position the orphaned comment already
marks, and fold the empty stub into it. This is a pure relocation: no line of
the block is altered, and the only lines removed are the empty eval-when and
three comment lines that duplicated the KLUDGE comment.

Measured on boehmprecise, allocation per full call, before -> after:

  cl:1+       48 B -> 0 B
  cl:1-       24 B -> 0 B
  cl:logtest  48 B -> 0 B
  cl:isqrt    48 B -> 0 B

In the 33-row allocation-constant probe the logtest row goes from 48.00 to
0.00 B/call and no other row changes.
(- a b) expanded to (core:two-arg-- a (+ b)), and (/ a b) to
(core:two-arg-/ a (* b)). A one-argument + or * is not free: expand-associative
renders it as (the-single number x), and the bytecode compiler emits that as
real calls to %the-single and values. So every two-argument subtraction and
division in bytecode-compiled code made three calls where one would do:

  called-fdefinition CORE:TWO-ARG--
  ref 0
  called-fdefinition CORE::%THE-SINGLE
  const 'NUMBER
  called-fdefinition VALUES
  ref 1
  call-receive-one 1
  call-receive-one 2
  call 2

The check is redundant, by expand-associative's own comment: it is needed only
in the one-argument case because "with more arguments, the two-arg-fun will do
checks". core:two-arg-- and core:two-arg-/ do exactly that, and an argument
form is already in a single-value context, so the (values ...) wrapper buys
nothing either. Cleavir already transforms %the-single into a THE
(cleavir/transform.lisp:375), so this only ever cost the bytecode path -- which
is the path this configuration compiles by default.

Add core:expand-inverse for the shape - and / share, mirroring
core:expand-associative, and use it for both the build-time compiler macros in
numlib.lisp and the load-time ones in cmp/opt/opt-number.lisp. As upstream
already does for expand-associative, cross-clasp carries its own copy in
macrology.lisp plus entries in packages.lisp and base.lisp.

Two-argument - and / now emit a single call. Behaviour is unchanged: the full
before/after output of -, / at one, two and three arguments over integers,
ratios, bignums, double-floats and complexes is identical, extra values are
still truncated, and a non-number argument still signals TYPE-ERROR with datum
and expected-type NUMBER -- the same condition %the-single raised.
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.

1 participant