Every value in Vim is a tagged typval_T, and moving values around — onto and off the Vim9 execution stack, into and out of lists/dicts, through for loops, on function return — goes through copy_tv()/clear_tv(), each running a type switch. For the common scalar types (number/bool/special/float) the real work is a trivial value copy or a no-op, so the function call plus the switch dominate the cost.
This makes copy_tv()/clear_tv() thin static inline wrappers (new src/inline_funcs.h) that handle the scalar types at the call site and fall back to the out-of-line copy_tv_inner()/clear_tv_inner() for every refcounted or allocating type. The public names are unchanged, so every caller benefits with no call-site churn; observable behaviour is identical.
Two design notes:
typval_T (from structs.h) and the *_inner() prototypes (from proto.h), so they must be included after proto.h; macros.h is included too early. A small dedicated header keeps vim.h short, but these two definitions could just as well sit inline in vim.h after proto.h — happy to inline them instead if you prefer.copy_tv()/clear_tv() would drop the switch but keep the call; inlining at the call site drops both — which is why the wall-clock gain below exceeds the instruction-count gain.The build-dependency bookkeeping for the new header is split into a second commit, since it is mechanical and is normally regenerated by make depend.
scalar (best case): −10% instructions, −23% wall-clock. container (worst case): unchanged (+1% Ir). Binary: +0.6%.
Base — master at commit 6f02e5cd7c27fc098d0b8dfec99542c7663a807e — vs patched, measured on GitHub Actions. callgrind instruction-reads (Ir) are deterministic and runner-independent; wall-clock is the minimum of 3 native runs (informational — noisy on shared runners).
| workload | Ir base → patched |
Ir Δ |
time base → patched | time Δ |
|---|---|---|---|---|
| scalar — Sieve of Eratosthenes (best case) | 30,549,307,131 → 27,468,457,830 | −10.08% | 5.181s → 3.987s | −23.05% |
| container — deepcopy/slice/remove (worst case) | 2,004,554,303 → 2,023,750,673 | +0.96% | 1.884s → 1.903s | +1.01% |
Both workloads produce bit-identical results on the base and patched builds, confirming the change is semantics-preserving. copy_tv()/clear_tv() are exercised throughout the existing test suite, so no new test is added.
For the scalar workload the wall-clock gain (−23%) is larger than the Ir gain (−10%): removing the call and the type switch also helps branch prediction and the instruction cache, which Ir alone does not capture. The container workload is effectively unchanged — its sub-1% Ir increase is the expected cost of the extra scalar-type check before the non-scalar fallback. Its wall-clock delta is within run-to-run noise on the shared runner, where the deterministic Ir is the reliable signal.
This is a general, low-risk constant-factor win: scalars are a common case whose copy or clear is trivial, so paying a call plus a type switch for them is avoidable overhead. It does not touch the interpreter dispatch loop, so it helps all value-churn-heavy Vim9 code rather than being a redesign.
Binary sizeInlining the scalar fast-path at every call site grows the binary a little (Ubuntu 24.04 CI runner, x86-64, gcc -O2 without -g, base → patched):
vim binary |
base | patched | Δ |
|---|---|---|---|
| as built | 4,530,304 | 4,555,040 | +0.55% (+24,736 B) |
| stripped | 4,062,816 | 4,087,392 | +0.60% (+24,576 B) |
Ir is measured with valgrind --tool=callgrind --dump-instr=no --branch-sim=no on an -O2 -g build; binary size and wall-clock use an -O2 (no -g) build. Driven by ci/bench/run_typval_bench.sh <base-sha> <patched-sha>.
scalarbench.vim — scalar / best case:
vim9script # Scalar-heavy workload (the fast-path's best case): a Sieve of Eratosthenes # over a reused list<bool>. Each pass resets, sieves and counts purely through # list-index reads and writes of scalar typvals plus arithmetic and # comparisons; the list is allocated once (outside the loop) and no builtin or # string work is in the hot path, so the inline copy_tv()/clear_tv() win is # what moves the number. const OUT = exists('g:BENCH_OUT') ? g:BENCH_OUT : '/tmp/scalarbench.txt' def CountPrimes(sieve: list<bool>): number var n = len(sieve) var k = 0 while k < n sieve[k] = true # reset: scalar store (clear_tv + copy_tv) k += 1 endwhile var i = 2 while i * i < n if sieve[i] var j = i * i while j < n sieve[j] = false # mark composite: scalar store j += i endwhile endif i += 1 endwhile var count = 0 i = 2 while i < n if sieve[i] # read scalar onto the stack count += 1 endif i += 1 endwhile return count enddef def Main() var iter = 200000 if exists('g:ITER') iter = g:ITER endif var sieve: list<bool> = [] for _ in range(2000) sieve->add(true) # allocate once, outside the timed loop endfor var t = reltime() var acc = 0 for _ in range(iter) acc += CountPrimes(sieve) # pi(2000) = 303, so check = iter * 303 endfor writefile([printf('ITER=%d elapsed=%.3fs check=%d', iter, reltimefloat(reltime(t)), acc)], OUT) enddef try Main() catch writefile(['EXC: ' .. v:exception .. ' @ ' .. v:throwpoint], OUT) endtry qall!
containerbench.vim — container / worst case:
vim9script # Non-scalar-heavy counterpart to scalarbench.vim: hammers copy_tv()/clear_tv() # on strings, lists and dicts (deepcopy / slice / extend / remove / scope exit), # so any regression from the scalar fast-path's extra branch on non-scalar # values would show here. const OUT = exists('g:BENCH_OUT') ? g:BENCH_OUT : '/tmp/containerbench.txt' def Build(n: number): list<dict<any>> var out: list<dict<any>> = [] for i in range(n) out->add({ name: 'item_' .. i, tag: 'tag_' .. (i % 7), vals: [string(i), string(i * 2), string(i * 3)], meta: {a: 'x' .. i, b: 'y' .. i}, }) endfor return out enddef def Churn(base: list<dict<any>>): number var total = 0 var c = deepcopy(base) # copy_tv over every nested string/list/dict var s = c[0 : len(c) / 2] # list slice: copy_tv of dict refs c->extend(s) for d in c total += len(d.name) + len(d.vals) for v in d.vals total += str2nr(v) # fold the copied value content, not just its length endfor if has_key(d, 'meta') total += len(d.meta) + len(d.meta.a) + len(d.meta.b) endif endfor while len(c) > 0 remove(c, 0) # clear_tv of non-scalar items endwhile return total enddef def Main() var iter = 20000 if exists('g:ITER') iter = g:ITER endif var base = Build(32) var t = reltime() var acc = 0 for _ in range(iter) acc += Churn(base) endfor writefile([printf('ITER=%d elapsed=%.3fs check=%d', iter, reltimefloat(reltime(t)), acc)], OUT) enddef try Main() catch writefile(['EXC: ' .. v:exception .. ' @ ' .. v:throwpoint], OUT) endtry qall!
AI-assisted; the change is disclosed via the Co-Authored-By trailer on the commit, per CONTRIBUTING "Using AI".
https://github.com/vim/vim/pull/20787
(9 files)
—
Reply to this email directly, view it on GitHub, or unsubscribe.
Triage notifications, keep track of coding agent tasks and review pull requests on the go with GitHub Mobile for iOS and Android. Download it today!
You are receiving this because you are subscribed to this thread.![]()
I had Clause Code review this PR. I have excluded any points that differ from my own opinion.
The scalar path is equivalent to the current implementation. In copy_tv()
the assignments to->v_type = from->v_type; and to->v_lock = 0; that used to
happen before the switch are now done inside the scalar arm, and in
clear_tv() the varp->v_lock = 0; that used to happen after the switch is
done inside the scalar arms. For the four scalar types there is no observable
difference. The union assignment to->vval = from->vval; is equivalent to the
member assignment, since the union is the size of v_number. Neither function
is used as a function pointer anywhere, so making them inline does not break
any caller.
clear_tv_inner() and copy_tv_inner() are only reached through the default:
label of the inline wrappers, so the case VAR_NUMBER: / VAR_BOOL: /
VAR_SPECIAL: / VAR_FLOAT: arms that remain in typval.c are unreachable.
The comment added to typval.c ("must stay in sync with this for them") is an
acknowledgement of that duplication.
Having the same rule in two places, where one copy can never be executed, is a
trap for whoever next changes how v_lock or the scalar types are handled.
Please pick one: either drop the unreachable scalar arms from the *_inner()
functions and state in their comment that they only handle the non-scalar
types, or keep a single definition and accept that the switch is not
duplicated. Keeping the duplication and promising in a comment to keep it in
sync is the option that costs the most later.
Ir is deterministic, so the -10% on the scalar workload and the +1% on the
container workload are credible. The -23% wall-clock figure is from a shared
GitHub Actions runner, and the explanation for the gap to the -10% Ir figure
(branch prediction and instruction cache) is a hypothesis that has not been
verified. If that number is to be part of the case for the change, it needs an
alternating A/B measurement on a machine that is not shared.
The scalar workload is a loop of sieve[k] = true, that is, scalar stores and
loads only. That is an upper bound for this optimization, not an estimate of
what a real script gains. It would help to see the numbers for a workload that
is not written to isolate the fast path.
The line length of the comment added to vim.h exceeds 80 columns.
The name inline_funcs.h is generic enough that it invites unrelated inline
functions to be added later. Since the content is specific to typval, a name
like typval_inline.h would make the placement decision obvious for the next
addition.
Using static inline is not a problem in itself: it is already used in mbyte.c
and screen.c, and macros.h already defines inline functions in a header.
—
Reply to this email directly, view it on GitHub, or unsubscribe.
Triage notifications, keep track of coding agent tasks and review pull requests on the go with GitHub Mobile for iOS and Android. Download it today!
You are receiving this because you are subscribed to this thread.![]()