An unreferenced class keeps every one of its methods alive
- Type: feature (codegen / emission size) — Track A, tag O
- Status: backlog — opened 2026-08-21; the registry-root blocker cleared 2026-09-19 (
5bde993c5), the pass itself still unwritten - Follows: [[feature-emission-size-dce]] (
--dce, landed)
What
--dce drops unreachable routine BODIES. It cannot drop a method, because a
method's address sits in a VMT slot (MethodFixups) and an address that is
taken can be called from anywhere — so every VMT slot is a root.
The VMT itself is emitted for every class the program declares, used or not. So
a writeln('hello') still carries, measured after --dce:
953 PXXTIOGetInterface
663 PXXIntfIMTOf
555 PXXVarStrAppend
489 PXXVarClear
256 TInterfacedObject._Release
134 TInterfacedObject.QueryInterface
100 TInterfacedObject._AddRef
44 TInterfacedObject.Destroy
A hello-world uses neither interfaces nor variants. ~3.2 KB of the 15.6 KB that survives DCE is reachable only through a class nothing instantiates.
The mechanism
Data-side reachability, one level up from the code-side pass that exists:
- A class's RTTI blob is reachable if the program constructs it, names it in
is/as/a class reference, or a reachable class inherits from it (the parent backlink is a real edge — dropping a parent breaksis). - An unreachable blob's
MethodFixupsentries stop being roots, and the existing code-side walk drops the bodies for free. - The blob's own bytes go too, which is
.data, not.text.
Watch out
TObject's blob is reached from every class — the chain has a root whether or not the program mentions it.- RTTI is what
TypeInfo()/ClassName/published-property access reads at RUNTIME with no static reference: anything that can look a class up by NAME (a class registry, a streaming/serialisation path) makes every registered class reachable, and the pass must see that or refuse. - Same discipline as
dce.inc: refuse rather than guess, and say why under--dce-report.
Acceptance
hello loses the interface/variant residue; no behaviour change on the corpus;
--dce-report explains every blob it keeps. Rides the same -O3 gate, so
tools/optdiff.sh sweeps it.
Measured 2026-09-18 (frankB) — CORRECTED: on the BARE profile CODE IS SRAM, so --dce saves 54 KB of SRAM and the flash/SRAM split does not exist there
Measured after the owner re-ranked ESP to "minimizing SRAM usage is prio". Profile named beside every number, because bare and IDF are different shapes and under this re-rank that is load-bearing rather than a caveat.
The correction, stated first because the first version of this section got it wrong
An earlier pass of this section read data/bss as the SRAM and code as the
flash. That is the IDF shape and it is false on bare. defs.inc's own map
says so: on ESP32-C3 the internal SRAM is mapped twice, qemu's esp32c3 machine
models it as ONE RWX region, and "the whole image (code+data+bss) loads at the
IRAM org". So on --esp-profile=bare:
SRAM(bare) = code + data + bss
Caught by frankh-3f, who owns the read-only-data placement work and pointed at the map. Recording it because it is this repo's standing error arriving again — a number carried from the population it was true of into one it is not. The profile was labelled correctly and then reasoned about as if it were the other one, which is worse than not labelling it.
The fleet's stated bare baseline has the same gap. data=616 bss=70936, SRAM = 71,552 omits code; the same build carries 57,900 B of code at plain
-O, so the real figure is ~129,452.
--dce on the BARE profile saves 54 KB of SRAM — not zero
test/test_esp_bare.pas, --esp-profile=bare:
| target | code | data | bss | SRAM = all three |
|---|---|---|---|---|
| esp32c3 plain | 59664 | 672 | 70940 | 131276 |
esp32c3 --dce |
5320 | 672 | 70940 | 76932 (−54344, −41%) |
| esp32s3 plain | 47924 | 672 | 70940 | 119536 |
esp32s3 --dce |
4836 | 672 | 70940 | 76448 (−43088, −36%) |
data and bss are byte-identical in all four builds — the pass removes code
and nothing else. On bare that code IS SRAM, so the six-target DCE port is
the largest SRAM lever measured so far on this profile, second only to the
64 KiB heap arena. On IDF, where .text can be flash-mapped, the same removal
is a flash win and buys no SRAM; that leg is NOT measured here.
One unreferenced class, isolated
Two programs differing only by a class declaration nothing instantiates —
esp32c3, bare, --dce on, so this is what survives the existing pass:
| code | data | bss | SRAM (bare) | |
|---|---|---|---|---|
begin end. |
276 | 616 | 70936 | 71828 |
| + one class, 4 virtual methods | 544 | 1424 | 70936 | 72904 |
| delta | +268 | +808 | 0 | +1076, all of it SRAM |
On bare both halves are SRAM and there is no flash/SRAM split to speak of. The earlier "SRAM cost is 3x the flash cost" line is withdrawn: it described an IDF-shaped split on bare-profile numbers.
What survives the correction, and it is still the point of this ticket: this
ticket's headline figure — ~3.2 KB of the 15.6 KB surviving DCE — is the CODE
residue, the method bodies the VMT slots keep alive. The blob's own .data
bytes were noted here from the day it was opened ("The blob's own bytes go too,
which is .data, not .text") and never quantified. Quantified, they are
three times the code residue per class (808 against 268) — so on either
profile the blob is the larger half of what this ticket would remove, and on IDF
it is the only half that is SRAM at all.
How it scales
esp32c3, bare, --dce, data delta over the 616-byte baseline (the .data
half only; add ~67 B of code per virtual method on top):
1 class x 1 method + 448 2 classes x 1 method + 768
1 class x 4 methods + 808 4 classes x 1 method +1408
1 class x 8 methods +1288 4 classes x 4 methods +2488
Roughly ~128 B one-off + ~200 B per class + ~120 B per virtual method. The
4x4 row comes in 360 B UNDER that model and the reason is the fixture, not the
compiler: the generator names every class's methods M1..Mn, so the name
strings are shared between classes. Do not read the per-method figure as
independent of naming — a real program with distinct method names per class
pays more than this table suggests, not less.
On the IDF profile the blob is .data too, and there is no .rodata at all
Same two programs, --emit-obj --target=riscv32 --platform=esp:
.text 258788 -> 259056 (+268)
.data 4072 -> 4800 (+728) <- the blob
.bss 9524 -> 9524 (unchanged)
.rela.data 1320 -> 1740 (+420) <- link-time, not runtime
.rodata absent in both
So the blob is emitted into a WRITABLE section on both profiles although nothing
writes it, and there is no .rodata section for an IDF link to place. That
is the seam with the read-only-data work and it only exists on IDF: frankh-3f's
measurement is that read-only data buys ZERO SRAM on bare, because moving
bytes between sections inside one RWX region changes nothing. Two answers to the
same bytes — move them out (IDF only), or stop emitting them (both profiles) —
and they compose: whatever is never emitted never reaches his classification, so
nothing is counted twice.
Ownership settled with frankh-3f 2026-09-18: placement is his, reachability
and emission are mine. His one request, recorded here so it is not lost: if
the layout of RTTI/VMT blobs inside Data[] changes, tell him, because his
classification keys on it. His open question, which is not this ticket's: whether
those tables are ever WRITTEN at runtime is unmeasured, and .rela.data growing
420 B alongside the blob means the slots carry relocations — whether those
pre-resolve at link time is his to settle.
Measured 2026-09-18 (frankB) — THE BLOCKER IS THE REGISTRY, AND EVERY ORDINARY CLASS IS IN IT
The instrument this section is built on is PXXDBG=a.rttiweight
(ReportRTTIWeight, rtti_emit.inc), which prices each class's RTTI in the two
currencies it is paid in, and flags streamable — membership of the RTTI
registry, i.e. reachable BY NAME at run time, which the Watch out section
above already names as the thing that makes a blob undroppable.
The finding: TFoo = class ... end is registry-rooted
ClassIsStreamable is ClassHasPublished or ClassImplementsGuidedInterface, and
a class declared with no visibility keyword defaults to published. So an
ordinary class declaration is in the registry. One keyword apart, nothing else
changed:
type TU = class procedure A; virtual; ... streamable=1
type TU = class public procedure A; virtual; ... streamable=0
identical blob=112 vmt=64 vmtslotprocs=5 directmethbytes=220 on both sides, and
the registry entry is the 24-byte difference in data= on esp32c3 bare
and the 16-byte difference on hosted x86-64 — re-derived 2026-09-18 after
c44fa2642, where bare reads 1000 against 976 and hosted 4808 against 4792.
The label changes a term, so name it rather than carrying one number into the
other profile. The absolute data= on bare has moved since this section was
first written (1448 -> 1000, from the heap-arena knob and the commits around it);
the DELTA has not, which is what makes the delta the quantity to quote and
the absolute the one to re-measure.
The 8-byte gap between profiles is NOT pointer width — I wrote that, and it is
wrong in the direction that should have caught it, since bare here is riscv32 and
has the NARROWER pointers. Re-derived from the emitter instead of reasoned: a
registry entry is a fixed 16 bytes (DataPutZeros(16), two pointer-fixed
slots, target-independent), and the table also carries an 8-byte COUNT slot that
exists only when at least one class is streamable. Hosted goes streamable 2 -> 1,
so one entry leaves and the count slot stays: 16. Bare goes 1 -> 0, so the
whole table goes: 8 + 16 = 24. Same entry size on both profiles; the
difference is whether the class being removed is the LAST streamable one.
THE REGISTRY'S RUN-TIME WRITABILITY IS UNMEASURED, NOT SETTLED (2026-09-19)
Recorded because an exclusion with a reason attached reads as settled, and this one is not — the hazard-block decay shape: a reader who stops generates nothing that could reveal the reason was wrong.
frankh-3f's --ro-rtti (48b75b34b, off by default) marks class RTTI headers
and VMTs read-only. It deliberately does NOT mark the registry table, and I
gave the wrong reason for that: I told him the registry would fault by
construction, because its two pointer words per entry are filled by
AddDataPtrFix at emission. That is wrong. AddDataPtrFix patches the FILE
IMAGE, before the read-only pieces are permuted, so a span filled that way does
not fault. His proof is direct and is the kind that settles it: the blob headers
are filled by exactly the same AddDataPtrFix calls, they ARE marked
read-only under the flag, and test_ro_rtti_write's plain row runs clean with
ClassName and is both reading them.
So the registry is excluded because it was SCOPED OUT, and whether anything
writes it at run time is simply unmeasured. Nobody should read the exclusion
as evidence. Measuring it is one line of RoRangeAdd over the table's span plus
a run, and the answer is worth having before anyone treats the registry as
immutable.
His positive control is the one this ticket asked for and it fires: a store into a VMT slot and a store into an RTTI header, each reached through an instance, fault with rc=139 under the flag and land without it.
The self-host with the flag defaulted on is WEAK evidence and must not be
quoted as a sweep — frankh-3f's own correction of a line I wrote, and it is
CLAUDE.md's stated scope limit on the fixedpoint arriving in a live case: it
cannot see a construct the compiler never writes. compiler.pas is a
deliberately procedural subset that declares no classes of its own, so the only
VMT and RTTI header in its image belong to the builtin TObject. A converged
2-round self-host shows the compiler still runs with those two spans read-only
and exercises almost no class RTTI path. I had claimed it was a stronger sweep
than test-core, on the reasoning that compiler.pas walks paths no fixture
does. That is true of parser and IR paths and false of exactly this one. The
evidence is test-core.
BUILT 2026-09-19 (frankB) — THE REGISTRY IS NOW CONDITIONAL ON ITS READER
The step the park note named is done. RTTIRegRequested is set at the two
AN_RTTI_REG creation sites (pasparser_expr.inc, pyparser.inc) and read by
EmitRTTI, which now emits the registry only when (regEntries > 0) and RTTIRegRequested.
Why the flag is set at PARSE time and not at the lowering site, where it
belongs: EmitRTTI runs immediately after the parse and before any IR
lowering, so ir.inc's AN_RTTI_REG -> IR_RTTI_REG arm — which would have
been one site instead of two, and free for every future frontend — fires too
late to be read. The consequence is that the flag is conservative: a
__rttireg() in code that is never lowered still emits the registry. Wrong in
the direction that keeps working.
MEASURED, against an expectation recorded before the change rather than
after: predicted a hello would lose exactly 24 bytes (8-byte count slot + one
16-byte entry) and a GetClass program exactly 0. Both landed:
data=4288B -> 4264B and data=10360B -> 10360B, with the GetClass program
still printing FOUND.
THE 24 BYTES ARE NOT THE POINT AND MUST NOT TRAVEL AS THE WIN. This does not
drop a single blob: Pass 1 still reserves a header per class, because ClassName
and the is/as backlink chain need one. What it removes is the unconditional
ROOT — while the registry was emitted regardless, it held every streamable class
alive BY NAME, and every ordinary class is streamable. A reachability-gated drop
was impossible before this and is merely unwritten after it.
AND IT NEVER FIRES FOR NILPY, WHICH IS A POPULATION FACT AND NOT A BUG.
Measured: print(1) alone reports reader=1. Every NilPy program pulls
pylib, and compiler/builtin/pylib.pas:40 reads
uses builtin, exceptions, pypal, promocore, typinfo — typinfo.pas:755 is
reg := __rttireg(). So a NilPy program always contains a reader by
construction and saves nothing here. A NilPy-side saving would have to come
from making that uses typinfo conditional, which is Track N's ground and is
not claimed by this ticket. Recorded so the next reader does not measure a
Pascal-only number and quote it for both frontends.
AND IT REACHES THE ESP UMBRELLA, which is the consequence this note stopped
one step short of. The SRAM case for this whole line of work is weaker on
NilPy than on Pascal, and weaker by construction rather than by degree: a NilPy
image on ESP carries the registry — and therefore the name-root on every
streamable class — no matter what the program does, until that uses is
conditional. So a future reachability pass measured on a Pascal ESP image and
quoted for the ESP target generally would be the population error this ticket
has already made once today in the other direction. Measure both frontends or
name the one you measured.
AND THE FIX FOR IT IS NOT A CALL-SITE PREDICATE, WHICH IS WORTH RECORDING
BECAUSE IT IS THE OBVIOUS ANSWER AND IT IS WRONG. Measured 2026-09-19: a
Pascal program with uses typinfo that NEVER calls GetClass reports
reader=1 registry=2 registrybytes=40; the identical program without the
uses reports reader=0 registry=0 registrybytes=0. The flag is a parse-time
fact, so merely parsing typinfo mints a reader — making GetClass
conditional, or teaching the builtin chain which routines a program actually
called, would leave this gate exactly where it is. Only the uses going helps.
Census of the chain, read-only, handed to franks-ee who owns it: it is TWO
ambient sites, not one — pylib.pas:40 and pyeval.pas:47 both pull typinfo —
and typinfo is doubling as a TYPES unit, which is why no conditional keyed
on "does this program reflect" can be correct: streams.pas:12 is
uses typinfo; { PUInt8 } and resources.pas:11 is
uses typinfo; { PString — declaring it here too would duplicate the type and corrupt RTTI }. The two NilPy units are in the same position, wanting the RTTI
TYPES rather than the reflection surface (pylib references PClassRTTI 29
times and GetInstanceRTTI 25; pyeval 13 and 9), and neither calls GetClass
or __rttireg itself. Hypothesis, NOT a recommendation and not costed: the
answer may be to split the unit — types one side, GetClass/FindClass/
__rttireg the other — at which point the chain pulls only the types half and
no reflection predicate is needed by anyone. Owned by franks-ee, who has the
corpus; this ticket claims none of it.
THE SPLIT IS FULLY ANALYSED AND DELIBERATELY NOT BUILT — census by franks-ee
2026-09-19, verified here, DECLINED on price. The shape is smaller and
cleaner than the hypothesis above: it is not "types vs reflection", it is
move GetClass into its own unit. typinfo has NO uses clause and NO
initialization/finalization section, so there is nothing transitive and
nothing kept alive by an initializer; property access by name works off a
class's own RTTI pointer and not off the registry, so every other piece of
reflection stays put — including the PUInt8/PString users (streams.pas:12,
resources.pas:11) and the RTTI types pylib and pyeval lean on. The uses
rewrite is four files, none on the NilPy ambient chain: classes_lite.pas
(2 call sites), lfm.pas (1), gtk3widgets.pas (8), controls.pas (1). pylib
and pyeval call GetClass ZERO times. No Track U fork is needed, because
the line is "the class registry lookup" and not "reflection", so nobody has to
decide what typinfo IS.
ONE CORRECTION TO THAT CENSUS, because it narrows a claim someone will
otherwise over-apply: __rttireg is called at THREE sites, not one —
typinfo.pas:755, controls.pas:167, gtk3widgets.pas:286. Moving GetClass
takes the reader away from a NilPy program, because the other two are pcl
and not on that chain; a GUI program still mints one from either of them
after the split. "Move GetClass and the reader goes" is true of the population
in question and false in general.
DECLINED, and the reason is the price and not the shape: ~24-40 bytes
direct, against a new unit plus a uses rewrite across lib/rtl and lib/pcl,
which is Track B's ground. Its real value is as a SECOND enabler for the
reachability pass — the same value as this gate — and that pass is still
unwritten. Building a second enabler for something that does not exist is how a
backlog becomes a queue. The analysis is banked here so the split is a same-day
change once the pass lands.
AND THE VERIFICATION THAT MUST HAPPEN FIRST IS RECORDED UNRUN, labelled
rather than quietly omitted. This ticket has MEASURED that parsing typinfo
mints a reader; it has NOT measured that the __rttireg() CALL is what does it.
Those are "consistent with" and not the same claim. Before any lib/rtl edit:
copy typinfo to a scratch dir, stub GetClass so the __rttireg() call goes,
compile with -Fu against the copy, read reader=. Zero means the call is the
trigger. Non-zero is also a result — it means the reader enters somewhere
neither seat has looked, and reader= is the instrument that would say so. Ten
minutes, and it must precede the edit rather than follow it.
The instrument grew two columns to make this observable, and they are
deliberately separate: reader= is the parse-time fact, registry= is what the
emitter did. Collapsed into one they could not distinguish "the gate dropped it"
from "the program never asked", which is exactly the distinction under test.
The legitimate third state — reader=1 registry=0, a program that calls
GetClass but declares no streamable class — is not a defect and returns nil
correctly.
The test-core row's positive control is the PAIR and both halves were
verified to FAIL, not assumed to: the pinned compiler (no gate at all) fails
the no-reader row with its own message, and a deliberately over-firing gate
(and (1 = 2) spliced in, built, run, reverted, binary restored
byte-identically to c4562f4bf4db) fails the reader row with its own. The
reader row asserts the RUNTIME lookup as well as the byte count, because a
registry that is emitted but wrong satisfies any count.
What the silent DATAREF_DROP defect does under this change: nothing new, and
that was checked rather than assumed. A program now lacking a registry also
lacks a reader by construction of the gate, so the (reader, no-registry) pairing
that reaches DATAREF_DROP is the same set as before — regEntries = 0 with a
reader — and it answers nil correctly. The defect is untouched and still owned
by whoever makes the next change in that predicate.
PARKED 2026-09-18 (frankB) — WHERE A COLD SEAT PICKS THIS UP
Nothing is half-landed. The pass was deliberately never started, because the measurement below relocates the ticket rather than sizing it. What is banked is the instrument, the boundary, and one fixed bug found while pricing it.
The next step is ONE change and it is not the one this ticket's title asks
for: make the RTTI registry conditional on the AN_RTTI_REG node rather
than emitting it whenever any class is streamable. Concretely, in
compiler/rtti_emit.inc at the if regEntries > 0 then arm (the block that
sets RTTIRegistryOff), gate on whether the program actually contains an
AN_RTTI_REG node. Do NOT gate on uses typinfo — __rttireg() is a
public intrinsic a user program can call directly, and a hand-maintained union
of unit names is the exact failure the frontend-cannot-see ticket records from
the other side.
Why that is the lever and the ticket's own proposal is not: every ordinary
class is registry-rooted (no visibility keyword defaults to published), so a
reachability-gated drop finds almost nothing to drop while the registry is an
unconditional root. Make the root conditional and the residue becomes droppable.
Only then is the pass this ticket describes worth writing.
Three things already closed, so do not redo them:
- The
--emit-objedge is MOOT, measured: each object carries its ownData[]and its own registry, so cross-objectGetClassdoes not work today (a=1 B_finds_A=0 B_finds_its_own=1) and node-conditional emission cannot break it. No--emit-objarm is needed. - The registry's sole consumer is
IR_RTTI_REG, verified across all six backends; insidelib/the intrinsic is called from three files only (rtl/typinfo.pasGetClass,pcl/controls.pas,pcl/gtk3widgets.pas). - The keying bug below is FIXED and has a test row; it is not outstanding work.
One defect found and deliberately NOT fixed, because whoever writes the gate
will be changing that exact predicate: emit.inc sets DATAREF_DROP when
there is no registry and the intrinsic then reads nil. A registry with no reader
gives no diagnostic and a reader with no registry gives no refusal — the
silent-negative shape, and the reason an emitted-and-never-read registry went
unnoticed. Decide what a node-conditional registry should do there before
writing the gate, not after.
Also parked, and it is frankh-3f's suggestion rather than a finding: a
RoRangeCount snapshot + ErrorNoPos guard around the blob and VMT emitters,
his 1cdb560f4 shape. By his own framing the RTTI rows are green and no emitter
is known to intern mid-blob, so it makes the FIRST offender loud rather than
fixing a live defect — which means it has no natural positive control.
Whoever adds it owes a manufactured one (an emitter with a deliberate mid-blob
intern, asserted to fire, then removed) or a finding that RoRangeCount
structurally cannot move in that span.
Ownership, settled with frankh-3f and still live: whether a blob is EMITTED
is this ticket's; where a survivor LIVES is his (c44fa2642, ESP-IDF .rodata
in both ELF32 object writers). His half is unblocked and does not need the
registry gate first — the gate only decides whether there is ALSO a droppable
set on top.
A LIVE BUG FOUND IN THE REGISTRY EMITTER WHILE PRICING IT, AND FIXED
Chasing frankh-3f's mid-blob-intern hazard into this loop turned up a different
defect in the same four lines. The registry is a count slot followed by fixed
16-byte (name ptr, rtti ptr) pairs, and it interned the raw TokSliceStr
declaration spelling for the name key. Pass 2 interns ClassRttiName(ci),
which is canonical for a specialization alias. For every ordinary class the
two strings are identical — which is exactly why this survived: the divergence
needs a specialization.
Measured on the pinned compiler, stable_linux_amd64/default/pinned:
ClassName=TBox<System.LongInt>
GetClass(b.ClassName) MISSING
GetClass('TIntBox') FOUND
The one round trip the registry exists for fails, and a lookup under a name no
instance ever reports succeeds. Every streaming path does
GetClass(X.ClassName).
Fixed by interning ClassRttiName(ci) here as well. It also closes the route to
frankh-3f's hazard rather than leaving it resting on an ordering nobody states:
ClassRttiName is guaranteed already-interned by Pass 2, so InternStr here can
never append and push later entries off the 16-byte stride; the raw spelling
carried no such guarantee. The layout hazard itself did NOT reproduce — three
streamable classes, all three found — so that half stays a removed route, not a
fixed bug.
The positive control is the pin, not something manufactured, and the third
row is what makes it a control: plain-class-control is FOUND on BOTH compilers.
Without it, a registry that found nothing would satisfy the MISSING assertion
and the row would certify a completely broken registry. Wired into test-core as
test/test_rtti_registry_is_keyed_by_classname.pas.
So the pass as this ticket specifies it would drop almost nothing on ordinary Pascal, because almost every class is name-reachable by default. That is not an argument against the ticket; it relocates it.
And the ticket's own headline example is one of them
TInterfacedObject — which holds every method in the residue table at the top of
this ticket — reports streamable=1, because it implements a guided
interface. The class the ticket exists to prune is excluded by the ticket's own
Watch out.
Where the lever actually is: the registry is an UNCONDITIONAL root
The registry is emitted whenever any class is streamable, and its only consumer
is IR_RTTI_REG, from a single AST node (AN_RTTI_REG) — verified by grep
across all six backends; emit.inc resolves the -100 sentinel to
RTTIRegistryOff and drops the reference when there is no table. In a program
that never asks for the registry, the registry is dead data rooting every
streamable class in the image.
Make the registry conditional on the program containing an IR_RTTI_REG and
streamable stops being a root for such programs — at which point
TInterfacedObject and the ticket's residue become droppable and the data-side
walk this ticket describes has something to walk.
Measured, not assumed. AN_RTTI_REG comes from exactly one place, the
__rttireg() intrinsic (pasparser_expr.inc, and its NilPy twin). Inside
lib/, __rttireg() is called from three files only — lib/rtl/typinfo.pas
(GetClass), lib/pcl/controls.pas, lib/pcl/gtk3widgets.pas. A program that
uses none of them never emits the node: built --emit-obj, a program with no
uses at all contains zero GetClass/FindClass/typinfo symbols, so
typinfo is not pulled ambiently. Its registry is therefore emitted and never
read — and emit.inc's drop path means an unreferenced registry is silent
rather than loud, which is why nobody noticed.
The caveat the pass must respect: __rttireg() is a public intrinsic, so a USER
program can call it directly without going through lib/. The condition has to
be "does this program contain an IR_RTTI_REG node", never "does it use
typinfo" — the second is the shape of a hand-maintained union, which is the
failure bug-a-a-frontend-cannot-see-that-a-backend-calls-library-routines-it-never-mentions
records from the other side.
What it weighs today, profile named beside every number
| subject | classes | streamable | RTTI data | direct VMT-slot code | of which NOT streamable |
|---|---|---|---|---|---|
| hello, hosted x86-64 | 5 | 1 | 664 | 409 | data 496, code 44 |
hello, esp32c3 bare --dce |
1 | 0 | 160 | 1 | data 160, code 1 |
esp_pal_fdsem_baseline.pas, IDF object xtensa |
7 | 1 | 608 | 556 | data 472, code 0 |
directmethbytes is neither bound cleanly and the instrument says so. It
OVER-counts where a subclass inherits a slot (a body reached from two VMTs is
counted twice) and UNDER-counts far more, because it is the DIRECT edge only:
this ticket's ~3.2 KB is dominated by PXXTIOGetInterface, PXXIntfIMTOf,
PXXVarStrAppend and PXXVarClear, which are runtime routines the methods
REACH, not methods in any VMT. Only the code-side walk in dce.inc can price
the closure.
Profiles do not add the same way. Under --esp-profile=bare the whole image
loads into one RWX IRAM region, so both columns are SRAM. Under --platform=esp
the code can be flash-mapped and only the data column is SRAM. Hosted, neither
is. Note the bare hello carries one class — the interface machinery is not
pulled in there at all — so the prize on bare is ~160 bytes and the population
this ticket is really about is the IDF/hosted one.
Layout note from the read-only-data group
AND A SELF-RELATIVE WORD IS THE TRAP, WHICH IS THIS WEEK'S DCE DEFECT ARRIVING
ON THE DATA SIDE. frankh-3f, correcting his own earlier note, 120e3a3cd: the
RTTI layout descriptors carry SELF-RELATIVE 32-bit words — typeRef /
baseTypeRef are UClsRTTIOff[ci] - (pos + 12|16), decoded by builtinheap as
pos + word, at five sites in rtti_emit.inc. His read-only split broke them
with no fault: rdyn 0 instead of 3 and a 999-object leak, a plausible wrong
value far from the cause. Each such word is now recorded with
AddDataRelFix(pos, target) (util.inc) and recomputed through DataRemap at
write time; identity when nothing is split.
Rule for this ticket's half: any new data word holding target - pos must call
AddDataRelFix(pos, target) beside its PatchDataI32. Absolute pointers via
AddDataPtrFix were always fine.
AND THE SECOND RULE IS ABOUT WHEN, NOT ABOUT WHAT — DO NOT INTERN A STRING
BETWEEN A CONTIGUOUS STRUCTURE'S FIRST AND LAST BYTE. frankh-3f, 1cdb560f4:
ResolveSynthImportLibraries interned a soname into .dynstr while that
table was being built, the read-only split moved those 48 bytes out and closed
the gap, and DT_NEEDED read >.
Corrected by frankh-3f, and the correction widens it rather than narrowing
it — my first note here had the mechanism wrong. I wrote that the hazard was
to self-relative words. It is not: typeRef/baseTypeRef are target - pos
and AddDataRelFix records them, so they survive a mid-blob intern intact.
The real hazard needs no offsets in the blob at all. Any contiguous RW
structure read as base + fixed offset is CUT IN TWO if InternStr runs
between its first and last byte — the split hoists the literal out, the gap
closes, and every field after the cut shifts by the literal's size. .dynstr
was one instance; an RTTI blob is another, and so is any record image a future
pass builds in one span.
AND IF A BLOB EVER BECOMES READ-ONLY, THE IRAM EXCLUSION DOES NOT COVER A READ
THROUGH A POINTER. frankh-3f, c44fa2642 (ESP-IDF .rodata in both ELF32
object writers; rtti_emit.inc untouched, the five relfix sites re-patched per
object by the new ObjRoPrepare, and both ends of a relative word must share a
section or ErrorNoPos). Measured there: test_emit_obj.pas on xtensa, SRAM
.data 6304 -> 2624 B. The caveat that lands on this ticket: an IRAM-safe ISR
runs with the flash cache off, so a literal an iram; routine references
DIRECTLY is deliberately kept in .data by ObjRoKeepIramLiteralsWritable —
but that exclusion sees Fixups from iram code only. A VMT or RTTI blob
reached through a POINTER from an iram; method is invisible to it, and would
be flash-mapped and unreadable exactly when the ISR runs. So "this blob is never
written" is NOT sufficient to justify moving it; the second question is whether
any iram; code can reach it, and today nothing answers that.
The guard is cheap and it is in this ticket's file: snapshot RoRangeCount
when a blob starts and ErrorNoPos if it has changed at the end — 1cdb560f4's
own shape, suggested for the blob and VMT emitters in rtti_emit.inc. NOT YET
BUILT, and the honest status is that there is no known offender: the RTTI rows
are green, so this makes the FIRST one loud rather than fixing a live defect.
Whoever adds it owes it a positive control — an emitter with a deliberate
mid-blob intern, asserted to fire — because a guard nothing can trip is a guard
that prints PASS.
This is exactly the shape that cost a day on the code side this week: DCE
re-aimed every CodeRef with a raw x86-64 rel32 over encoded branch WORDS,
because a displacement's SITE moves even when its TARGET is final. A blob-
dropping or Data[]-compacting pass has the same hazard with AddDataRelFix
as the register of sites to re-apply — and, as on the code side, the table that
gets forgotten is the parallel one nobody enumerated.
frankh-3f, b05b7bb0a: x86-64 executables now PERMUTE Data[] at write time —
ranges marked with RoRangeAdd move to an R segment and every data address
resolves through DataRemap in elfwriter.inc. Offsets inside Data[] are
unchanged at emission, so nothing emitted here needs to know. If RTTI/VMT blobs
should ever be read-only it is one RoRangeAdd(start, end) per blob at emission
after a never-written measurement, and the R segment is itself that
measurement, because a store faults. Ownership split agreed the same day:
whether a blob is emitted is this ticket's; where a survivor lives is his.
The --emit-obj edge, closed rather than inherited
Node-conditional emission is obviously safe for an executable: no IR_RTTI_REG,
nothing reads the registry, drop it. Under --emit-obj it is not obviously safe
— object A declares the classes, object B holds the __rttireg() node, and if
A's registry went because A has no node, B's lookup would find nothing. That is
decide-a-is-a-pxx-object-a-self-contained-runtime-or-a-translation-unit
wearing a different hat.
It is moot, and measured rather than argued. Two objects, linked into one
binary with a gcc main:
objA declares TOnlyInA (no visibility keyword -> published -> streamable),
constructs it and calls a virtual method. streamable=2
objB `uses typinfo`, declares TOnlyInB, and asks GetClass for BOTH names.
a=1 B_finds_A=0 B_finds_its_own=1
B finds its own class and cannot see A's, today, with both registries present
and both objects in the same executable. Each object carries its own Data[]
and its own registry, and __rttireg() in B resolves to B's. The cross-object
lookup this edge worries about does not work now, so node-conditional emission
cannot break it and needs no --emit-obj arm.
B_finds_its_own=1 is the control that makes the 0 mean something: without it a
zero would equally describe a GetClass that never works, and the row would be
a guard that cannot fail. Scope: x86-64 hosted objects, one pair, gcc link. It
is consistent with the export-side measurement on the decide page — both objects
have zero undefined symbols of any kind, because each carries everything it
reaches.
A separate defect this uncovered: an unresolved registry reference is DROPPED, silently
emit.inc's -100 sentinel path, when RTTIRegistryOff < 0, sets
DATAREF_DROP and the intrinsic then reads nil — commented there as "a
documented answer, not a defect: a module that publishes no classes has no
registry". That reading is fine for the case it was written for and it is also
why an emitted-and-never-read registry went unnoticed for as long as it has:
nothing on either side of this is loud. A registry with no reader produces no
diagnostic, and a reader with no registry produces a nil rather than a refusal.
It is the silent-negative shape — an absence that reads as working.
Not fixed here, and it is not this ticket's subject; recorded because whoever implements node-conditional emission will be changing exactly this predicate and should decide deliberately whether the nil stays silent.
Parked 2026-09-19
Registry root removed in 5bde993c5 -- the one blocker is gone and the pass itself is now writable and unwritten. Parked rather than held: my group is closed and a ticket sitting in working/ with an owner who is not working it misroutes the next reader. Read the BUILT 2026-09-19 and PARKED 2026-09-18 sections in the body before starting; they name what is already closed (the --emit-obj edge, the registry's single consumer, the keying bug) so none of it is redone, and the silent DATAREF_DROP defect that whoever writes the pass will be editing anyway.
Before resuming: read the reason above, then the ticket body. If the reason does not tell you what would make this worth picking up again, establishing that is the first step -- a park is a handoff to a stranger who may be you.
2026-09-20 (frankS) — the largest remaining rung of the ESP umbrella, written up so the next seat needs nothing from me
Once the eval() interpreter stopped being linked
([[bug-a-a-static-nilpy-program-links-the-runtime-eval-interpreter]], −52%),
this became the top root of what is left. Everything below is from
--dce-why on examples/esp32/nilpy-c3/main/main.npy, xtensa windowed,
--platform=esp --no-signals --dce, at 857dcdaac. Re-measure before
quoting: the population is one program on one ISA.
The shape of what is left
| first reason | bytes | bodies |
|---|---|---|
| called by (an ordinary call edge) | 639,962 | 340 |
| vmt/rtti slot | 172,637 | 149 |
| @proc taken in unowned code | 13,725 | 2 |
| called from unowned code | 1,006 | 5 |
| total live | 827,330 |
The two @proc rows are pyeval's unconditional PyIterCallHook := @PyCallKey1
install and stay by design — a lazy install is
bug-nilpy-min-max-with-a-key-held-in-a-variable-picks-the-numeric-overload.
Do not treat them as a target.
The chains, which is the part that is new
94665B pyiter_has <- pyiter_drain <- pyseq_of_obj <- TPyFile.writelines <- [vmt/rtti slot]
12761B TPyBytes.decode <- [vmt/rtti slot]
12161B PyUserArithCallMeth <- pyiter_has <- ... <- TPyFile.writelines <- [vmt/rtti slot]
10581B pyvar_gt <- TPyList.sort <- [vmt/rtti slot]
8780B pyfloat_parse <- pyiter_has <- ... <- TPyFile.writelines <- [vmt/rtti slot]
8393B PyVarEq <- TPyDict.indexof <- [vmt/rtti slot]
5903B TPyList.sort <- [vmt/rtti slot]
5801B TPyDict.most_common <- [vmt/rtti slot]
5111B TPyDict.update <- [vmt/rtti slot]
One method, TPyFile.writelines, heads three of the nine largest rows —
115,606 B between them, in a program that never opens a file. It accepts any
sequence, so it pulls pyseq_of_obj and the whole iterator-drain path behind
it. Nothing calls it; it is in the VMT because TPyFile has one.
What this says about the CRITERION, and it is not what the title assumes
The title says unreferenced class RTTI. These classes are all legitimately
live — the program really does use TPyList, TPyDict, TPyFile. A
per-CLASS criterion therefore reaches none of this. What costs is a per-METHOD
slot nothing can dispatch to: writelines, decode, most_common, update
are individually unreachable on a live class.
So the design question for whoever takes this is devirtualisation-shaped, not
emission-shaped: when can a VMT slot be proven undispatchable? A method
whose name is never used in a dynamic attribute lookup, never overridden, and
never reached by getattr, is a candidate. PyUserObjGetattr and
pydynattr_get_v are live in this very image, which is what makes the
conservative answer conservative — that is the thing to establish first, not
the savings.
What the instrument already answers, and what it does NOT
--dce-whygives the per-reason table and the twenty biggest bodies with chains.--dce-why=<substring>names any body you name, live orDROPPED, with its chain — so you can test a candidate in one command rather than rebuilding.- It does NOT total a subtree. The 115,606 B above is the sum of three rows
that happen to be in the top twenty; the true amount
TPyFile.writelinesdrags is larger and nothing reports it. A per-root subtree total is the missing instrument here, and it is the same "nothing answers HOW MUCH" hole the umbrella already names. Build that before ranking candidates against each other. - Attribution is FIRST-reason, so a body reachable both by a call and through a VMT slot is counted once, under whichever came first. Reading the vmt total as "what would be freed" is wrong in both directions.