A static NilPy program links the runtime's eval() interpreter
The measurement
Program: examples/esp32/nilpy-c3/main/main.npy (a class, a list, a loop,
print -- no eval, no exec, no compile). Compiler 74ff679403b8,
--platform=esp --no-signals, --dce on. Live code attributed to source unit
by matching each sized symbol in the object against the routine declarations in
compiler/builtin/*.pas, lib/rtl/*.pas and lib/rtl/platform/esp/*.pas.
Every name below is declared in exactly one of those files, and the
unattributed remainder is 0.6% / 0.8%.
| unit | riscv32 --dce |
share | xtensa --dce |
share |
|---|---|---|---|---|
| pylib.pas | 1,112,916 | 53.6% | 825,056 | 47.5% |
| pyeval.pas | 624,684 | 30.1% | 681,868 | 39.3% |
| promocore.pas | 157,152 | 7.6% | 97,044 | 5.6% |
| builtinheap.pas | 84,300 | 4.1% | 58,868 | 3.4% |
| softfloat.pas | 39,048 | 1.9% | 29,268 | 1.7% |
| builtin.pas | 32,644 | 1.6% | 23,104 | 1.3% |
| everything else | ~24,000 | 1.2% | ~21,000 | 1.2% |
Do NOT compare the two ISAs' BYTES against each other: xtensa has 2- and 3-byte instructions, so the same routine is smaller there, and the shares move for that reason alone. Compare within a column.
The ten largest live bodies, riscv32, after DCE -- five of the top six are pyeval's:
109396 PyHostCall (pyeval)
65284 PyBoundFnCallvnMaskBody (pylib)
37056 CallBuiltin (pyeval)
35044 ParseMethodCall (pyeval)
33992 PyBoundPairCallKwBody (pylib)
30692 PyDynMethL (pylib)
29832 pyiter_has (pylib)
20920 pypercent_format (pylib)
20856 ParsePrimary (pyeval)
16900 PyClosureInvoke (pylib)
Why it is a bug and not merely large
pyeval.pas's own header:
pyeval — a real exec()/eval() for the Python subset uforth's PYTHON-bodied words are written in … NOT auto-used by NilPy yet: build + test standalone first so a parse error here cannot break every NilPy compile.
A program that never evaluates source at runtime is paying for a tokenizer, an
expression parser, a statement walker and a host-call trampoline. Tokenize
(38,024 B) IS dropped by DCE, which is the tell: the tokenizer had no live
edge, while ParsePrimary, ParseMethodCall and DoAssignment -- the layer
that would CALL the tokenizer -- are all live. A parser that survives while its
lexer dies is not being reached through a call; it is being held by a root.
The instrument now exists, and it answered this -- 2026-09-20
--dce-why (and --dce-why=<substring> for a named body) prints, per live
body, the FIRST root that reached it and the CHAIN of call edges from that
root. Built for this ticket; positive control in test/test_dce_why_root_report.pas,
asserted in test-quick.
Live bytes by first reason, same program, same flags as the table above:
| first reason | riscv32 | xtensa |
|---|---|---|
| called by (an ordinary call edge) | 1,033,892 B / 589 | 1,531,910 B / 589 |
| holds a stub target | 819,480 B / 128 | 0 |
| vmt/rtti slot | 200,544 B / 149 | 172,637 B / 149 |
| @proc taken | 15,784 B / 4 | 17,420 B / 4 |
| called from unowned code | 1,712 B / 5 | 1,006 B / 5 |
| total live | 2,071,412 B | 1,722,973 B |
The xtensa column is the one that names the mechanism, because nothing there is masked by the stub-target rule:
173755B PyHostCall <- PyFieldGet <- DoAssignment <- ExecStatement
<- ExecSuite <- CallUserFn <- PyBodyTramp <- [@proc taken]
51743B PyBoundFnCallvnMaskBody <- pyboundfn_callvn_mask <- pyboundfn_callvn
<- pyboundfn_callv <- PyCallKey1 <- [@proc taken]
So pyeval is not kept by a call from the compiled Python program. It is kept
because PyBodyTramp's ADDRESS is taken -- an entry in a table, which is
reachable from anywhere by construction -- and everything the trampoline can
reach follows by ordinary call edges. Four such roots account for the whole
tree. That is the thing to design against; pyeval's size is the symptom.
And it says why the riscv32 number was uninformative: there, 143 stub
targets exist and 139 of them land INSIDE a body, so DceRangeHoldsStub roots
those 128 bodies before the reachability walk ever runs and the @proc chain is
never the FIRST reason for anything. See
[[bug-a-riscv32-dce-keeps-135-more-bodies-than-xtensa-on-one-program]].
Keep LIVE and REACHABLE apart when quoting any of this. Every row above is a CONSERVATIVE claim by the pass -- "an address of it is in a table" is not "it runs". The report's own header says so.
Do not start by deleting or guarding the unit. The hook variables pylib
declares for pyeval to install into (PyIterCallHook and the rawKind=2 closure
registry, pylib.pas:164-174) mean some of this may be genuinely reachable from
NilPy code that uses closures or iterators -- which is most NilPy code. The
measurement says 30-39% is LIVE; it does not yet say how much is REACHABLE.
That distinction is the whole ticket.
2026-09-20 (frankS) — PRICED TO ONE LINE, and it is not the @proc
The --dce-why chain named PyBodyTramp <- [@proc taken], so the first move
was to ask whether that address needs taking. It does not — the one site is
inside EvalPyStmts, which DCE itself drops.
That produced a real fix worth far less than it is worth knowing. An
@proc inside a body the same pass deletes writes nothing, because the
code that would store the address never runs — so it is an EDGE
(owner → target), not a root, and only an @proc in code no body owns roots
anything. "an address in a table can be called from anywhere" is a claim
about the SOURCE; the pass has the answer about the IMAGE.
The soundness argument rests on one thing, and it was already true: a body
kept because something jumps INTO it is marked live, so its @proc sites still
root their targets. Without that, a retained body would hold the address of a
removed one.
The chain now spells <- X (X calls it) apart from <- @X (X takes its
address), so a later reader cannot quote the weaker claim as the stronger one.
It bought 1,036 B. That is the honest result of the lead: the @proc was a
decoy, and the same report then named the real root.
The real root is pyeval.pas's initialization section, which runs in
every program that links the unit:
initialization
PyIterCallHook := @PyCallKey1;
That address IS written, by code that always runs, so the pass is right to root
it. PyCallKey1 then reaches the whole interpreter through ONE arm:
PyHostCall <- PyFieldGet <- DoAssignment <- ExecStatement
<- PyClosureInvoke <- pyclosure_call1 <- PyCallKey1
<- [@proc taken in unowned code]
Both halves PRICED BY REMOVAL — a measurement, not a proposal. Each
removal breaks the program; they exist to price an edge. xtensa,
examples/esp32/nilpy-c3, --dce:
| tree | live code |
|---|---|
| HEAD | 1,721,914 B |
with the whole initialization removed |
712,617 B (−1,009,297, −59%) |
with ONLY if pyclosure_is(key) then ... pyclosure_call1 removed |
824,155 B (−897,759, −52%) |
So one line of one dispatcher is 52% of the image. Neither removal is a proposal — both break the program — they price the edge.
Why riscv32 shows nothing for either: 2,070,376 B both times. Its stub-target rule roots 128 bodies independently, so the interpreter stays live whatever happens to this edge. Fix that ISA's rung separately ([[bug-a-riscv32-dce-keeps-135-more-bodies-than-xtensa-on-one-program]]) or the win here will not appear there.
The design this points at, and the invariant it rests on
PyClosureInvoke saves and restores the INTERPRETER's own state (TkKind,
TkText, Cur, LclN, FnN ...) and runs a body through ExecStatement. A
closure of that kind can only EXIST if exec()/eval() created it. So in a
program that never calls either, pyclosure_is(key) is always False and the
arm is dead — but it is dead in a way only the program's behaviour knows and
the pass cannot see, which is precisely why it costs 897 KB.
Give the pass something it CAN see: route that arm through a second hook
(PyClosureCallHook, say) installed by PyExecSrc/EvalPyStmts — the only
two entries that can mint a closure — instead of calling pyclosure_call1
directly. PyCallKey1 then references no interpreter, and the tree hangs off
exec() where it belongs. The unconditional install of PyIterCallHook
STAYS: it fixed a real bug
(bug-nilpy-min-max-with-a-key-held-in-a-variable-picks-the-numeric-overload)
and lazy installation is what caused it.
LIVE, not REACHABLE, still. Nothing above says the interpreter runs in this demo; it says the pass cannot prove it does not. The proposal is a way to make the proof structural rather than a claim.
2026-09-20 (frankS) — BUILT. The hook is in, and the win is conditional by construction
PyClosureInvoke is no longer called directly by anything. The eight
dispatch-side call sites — pyvar_callv0..4, pyvar_wide_prelude,
pyclosure_call1, pyclosure_call_ptr — go through PyCallClosureBody, which
calls a hook that PyMakeClosureObj installs. That is the only place a
closure object can come from, so:
- a program that mints an interpreted closure reaches the install, roots the evaluator, and correctly pays for it;
- a program that does not never reaches it, and the evaluator is dropped.
Measured, same program and flags as the tables above:
| before | after | |
|---|---|---|
| xtensa live code | 1,721,914 B | 827,330 B (−894,584, −52%) |
| xtensa object | 1,923,596 B | 990,656 B |
| riscv32 live code | 2,070,376 B | 2,071,164 B (+788) |
riscv32 is unchanged and that is the predicted result, not a disappointment. Its stub-target rule roots those bodies independently of any call edge; see [[bug-a-riscv32-dce-keeps-135-more-bodies-than-xtensa-on-one-program]]. Nobody may quote the 52% as an ESP-wide figure until that rung moves.
Today's @proc fix is a PREREQUISITE and not a coincidence. The install
line takes @PyClosureInvoke inside PyMakeClosureObj. Under the old rule
that address rooted the evaluator unconditionally, from a body the pass was
about to delete — the hook would have bought nothing.
What the control cost, and it is the part worth reading
The obvious fixture does not test this. Most NilPy lambdas are LIFTED to
compiled code and never reach the interpreter, so a file full of
key=lambda w: len(w) passes identically with the hook removed — measured,
not feared. The lifter refuses exactly one shape: a capture of a managed
string that is a LOCAL or param of the enclosing function
(bug-nilpy-lifted-lambda-cannot-capture-a-managed-string), which falls back
to pyclosure_src_new and builds a closure from the body's source text. A
module-level string is not enough — it resolves as a global and the lambda
lifts.
test/test_nilpy_closure_through_key_paths.npy is built on that shape and
enters by every door (sorted(key=), min/max with the key in a variable,
map/filter, a direct call through a variable). Negative control run: with
the install removed the program dies, rc=217 rather than printing. CPython
is the oracle, computed in the row rather than pasted.
The instrument found this in one command — --dce-why=pyclosureinvoke
answered PyClosureInvoke <- DROPPED for the first fixture and
PyClosureInvoke <- @PyMakeClosureObj <- pyclosure_src_new <- make_doubler
for the second. Without it I would have shipped a green fixture that tested
nothing.
A residual I recorded and then MEASURED AWAY — read this before quoting it
I wrote here that pyparser.inc's direct pyclosure_call_ptr emission for a
call through a Callable field would root the evaluator again, because it is
frontend-emitted and therefore outside the hook. That was reasoning, not a
measurement, and it is wrong. The cut is one layer BELOW what the frontend
emits: pyclosure_call_ptr calls PyCallClosureBody, not PyClosureInvoke.
Probed 2026-09-20 with a Callable field holding a plain compiled def:
pyclosure_call_ptr <- DROPPED
ExecStatement <- DROPPED
ExecSuite <- DROPPED
So the frontend edge keeps at most a 459-byte trampoline, never the evaluator. Nothing to file. Kept rather than deleted because the reasoning was plausible and the next reader will produce it again.
What actually dominates now, which is a DIFFERENT rung
Same demo, after the hook, xtensa, biggest live bodies:
94665B pyiter_has <- pyiter_drain <- pyseq_of_obj <- TPyFile.writelines <- [vmt/rtti slot]
51743B PyBoundFnCallvnMaskBody <- ... <- PyCallKey1 <- [@proc taken in unowned code]
33071B PyBoundPairCallKwBody <- ... <- PyCallKey1 <- [@proc taken in unowned code]
12161B PyUserArithCallMeth <- pyiter_has <- ... <- TPyFile.writelines <- [vmt/rtti slot]
The top root is no longer the evaluator: it is a VMT slot on TPyFile,
whose writelines drags 94 KB of iterator machinery into a program that never
writes a file. That is
[[feature-a-unreferenced-class-rtti-keeps-every-method-alive]], already a rung
of the umbrella and now the largest single one, with a chain naming the method.
The two PyCallKey1 rows are the unconditional PyIterCallHook install and
stay by design — see the note above on why lazy installation is not an option.