Skip to content

Stop allocating a scratch vector for every bytecode &key call - #1820

Open
dg1sbg wants to merge 1 commit into
clasp-developers:mainfrom
dg1sbg:perf/bytecode-key-args
Open

Stop allocating a scratch vector for every bytecode &key call#1820
dg1sbg wants to merge 1 commit into
clasp-developers:mainfrom
dg1sbg:perf/bytecode-key-args

Conversation

@dg1sbg

@dg1sbg dg1sbg commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

parse-key-args in the bytecode VM builds a SimpleVector to collect keyword arguments, then immediately pushes its contents onto the VM stack and drops it. The vector never outlives the opcode. It is also allocated unconditionally — before the lcc_nargs > more_start guard — so a call that passes no keywords at all still pays for it.

The cost is 24 + 8n bytes per call for a callee with n &key parameters, charged whether or not the caller passes any keywords:

callee called as before after
(defun k1 (a &key x) ...) (k1 1) 32 B 0 B
(defun k2 (a &key x y) ...) (k2 1) 40 B 0 B
(defun k3 (a &key x y z) ...) (k3 1) 48 B 0 B
(defun k6 (a &key p q r s u v) ...) (k6 1) 72 B 0 B
(defun kopt (a &optional o &key x y) ...) (kopt 1) 40 B 0 B
string= (string= "abc" "abd") 56 B 0 B

Measured with gctools:bytes-allocated over 200000 warmed calls.

The change

The parameter slots are the destination anyway, so push them first and write each argument directly into its slot.

The index identity is derived rather than assumed: the loop being deleted pushed key_id descending, so key_id 0 ended up on top, and stackref is documented in threadlocal.h as "0 is most recently pushed" — hence slot n is key n. This matches the comment at cleavir/compile-bytecode.lisp:879, "the leftmost key is the last pushed".

The odd-argument-count check is hoisted above the pushes so that error path cannot observe them. The unrecognized-keyword throw still can, which is safe: the VM stack is a registered GC root (allocateRootsAndZero, gctools/threadlocal.cc), sp is a plain C local rather than vm._stackPointer, and both ~VMFrameDynEnv_O and VMFrameDynEnv_O::proceed restore _stackPointer on unwind. Slots are initialized to unbound before anything that can collect runs.

Peak stack use is unchanged — exactly key_count slots are pushed either way, just earlier.

The long-operand form at the second parse_key_args case gets the same treatment for consistency. It takes more than 127 &key parameters to reach, since key_count_info is (key_count << 1) | aokp.

Tests

A reversed or off-by-one slot index would bind the wrong parameter with no error at all, so the new tests pin the slot identity rather than the allocation:

  • every argument permutation of a 3-key callee, plus each keyword alone and none
  • leftmost-wins on duplicate keywords (CLHS 3.4.1.4) — the VM gets this by scanning right to left and overwriting, so only the write destination changes here, not the loop order
  • &allow-other-keys in the lambda list and as a runtime argument, plus the unknown-keyword and odd-argument error paths
  • 16 parameter slots live across a collection

Verification

Built from scratch on this branch (koga + ninja), no local modifications to any dependency:

  • test-boehm — 1965 successes, no unexpected failures
  • test-boehmprecise — 1967 successes, no unexpected failures
  • ansi-test-boehm — 21936 tests, 27 failures, no unexpected failures

macOS arm64, LLVM 22.1.8.

parse-key-args built a SimpleVector to collect keyword arguments, then
immediately pushed its contents onto the VM stack and dropped it. The
vector never outlived the opcode, and it was allocated unconditionally --
before the lcc_nargs > more_start guard -- so a call passing no keywords
at all still paid for it. The cost was 24 + 8n bytes for a callee with n
&key parameters, charged per call:

  (defun k1 (a &key x) ...)         called (k1 1)   32 B
  (defun k2 (a &key x y) ...)       called (k2 1)   40 B
  (defun k3 (a &key x y z) ...)     called (k3 1)   48 B
  (defun k6 (a &key p q r s u v))   called (k6 1)   72 B
  (string= "abc" "abd")                             56 B

All of those are 0 B now.

The parameter slots are the destination anyway, so push them first and
write each argument directly into its slot. The index identity is exact
rather than assumed: the loop being deleted pushed key_id descending, so
key_id 0 ended on top, and stackref is documented in-tree as "0 is most
recently pushed" -- hence slot n is key n.

Ordering: the odd-argument-count check is hoisted above the pushes so that
error path cannot see them. The unrecognized-keyword throw still can,
which is safe -- the VM stack is a registered GC root
(allocateRootsAndZero, gctools/threadlocal.cc), sp is a plain C local, and
both ~VMFrameDynEnv_O and VMFrameDynEnv_O::proceed restore _stackPointer
on unwind. Slots are initialized to unbound before anything can collect.

Peak stack use is unchanged: exactly key_count slots either way, earlier.

The long-operand form gets the same treatment for consistency. It takes
more than 127 &key parameters to reach, since key_count_info is
(key_count << 1) | aokp.

Adds regression tests for slot identity across argument permutations,
leftmost-wins on duplicate keywords, &allow-other-keys, and 16 parameter
slots live across a collection. A reversed or off-by-one slot index binds
the wrong parameter with no error at all, so these pin the identity
rather than the allocation.

Gates: boehm 1984, boehmprecise 1986, both with no unexpected failures;
ANSI 21936 tests with no unexpected failures.
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