The target, in the owner's words
"mark as read-only where possible. and strip code and associated data where possible." — 2026-09-17
"one for PC platforms - and make sure we can emit minimal code. and second for ESP, same goal." — 2026-09-18
This is an umbrella. Do not claim it — claim a rung. It shares most of its blockers with the hosted umbrella deliberately; membership is an edge, and the ranker takes the max.
prio: 70 is a placeholder set by an agent, not by him.
Who is on it (2026-09-20): frankS holds the RUNGS, not the umbrella — this file's own rule, and it is the right one even when a coordinator hands the whole thing over. Held: the pyeval rung and the riscv32/xtensa body-count rung named under rung 3. Everything else here is unclaimed.
Why ESP is a separate umbrella and not a rung of the PC one
Because the two directives pay off in different currencies:
- On a hosted target, code is demand-paged from the file. Marking constants read-only buys page permissions and cross-process sharing. It does not buy RAM.
- On ESP, code and constants have somewhere else to live: flash. Every byte moved out of SRAM is a byte of headroom on a chip that has ~400 KB of it.
A fix can therefore be worth ranking here and not there, and vice versa. Keeping them in one umbrella would rank by the wrong currency.
What is measured, and what is not
Measured at HEAD (2026-09-18):
--dceremoves zero bytes of data or bss on any target — code 1,347,352 -> 745,240, data 86,084 -> 86,084, bss 66,796 -> 66,796. Porting DCE to xtensa is a FLASH win, not a RAM win. Do not sell it as the RAM answer.- The data segment of a hosted hello world is 70.7% zeros and 17.4% ASCII, of which 6,429 bytes across 197 strings are diagnostic text — never written, and with no read-only segment to put it in.
- The bss floor is 41,800 B, of which 32,768 is the signal alt stack,
reserved unconditionally,
--no-signalsincluded. - Both of those are fixed now. The alt stack went first (−32,792 B of bss
on every ESP image); the readln line buffer followed and is measured on its
own baseline, 70,936 -> 66,848 on all four bare SoCs. What is left is
dominated by the 64 KiB heap arena, which is a knob
(
-dPXX_ESP_HEAP_8K.._128K) and not waste.
RUNG 0 IS NOW MEASURED (2026-09-18, frankS) — the budget is a number:
- IDF costs 69,476 B of the C3's 409,600. Its own
hello_world, built here with IDF v6.0.1 for esp32c3 and booted under the Espressif qemu, links 46,144 B of DRAM and printsMinimum free heap size: 340124 bytes. 340,124 B is ours to spend with no networking linked. - Linking WiFi costs 55,024 B of heap pool before a single buffer is
allocated (
heap_initRAM region 215,504 -> 160,480; static DRAM 46,144 -> 101,352). A networking image projects to ~285,100 B free. - So our NilPy hello-world's 146,612 B of data+bss is 43% of the no-network budget and 51% of the WiFi-linked one. It fits in both, with room.
- Still not measured, and it is the half he named: the RUNTIME WiFi buffers.
qemu's esp32c3 has no WiFi radio model — the instrumented station build hangs
in
esp_wifi_init()and never reaches its heap print. That term needs a chip; 55,024 B is a LOWER BOUND on the networking case.
See [[measure-what-idf-itself-costs-in-sram-on-a-c3]] for the method and the
two traps it walks past (idf.py size's "Total" is not the chip's SRAM, and the
static table cannot see the 10,584 B startup allocates).
The rungs
- DONE 2026-09-18 — [[measure-what-idf-itself-costs-in-sram-on-a-c3]]. The budget is 340,124 B without networking, ~285,100 B with WiFi linked. Every rung below is gradeable against that now. The runtime WiFi-buffer term still needs a chip.
- Make size measurable at all. [[bug-t-code-is-page-quantised-so-there-is-no-instrument-for-size-work]].
- Stop the silent-empty-image bug.
[[bug-a-uPXX_MANAGED_STRING-on-esp-bare-emits-an-empty-image-and-says-ok]] —
urgent, prio 75. The one flag that would shrink an ESP image today produces a
well-formed ELF that does nothing and says
ok:. - DONE 2026-09-19/20 — the target has a stripping pass.
[[bug-a-dce-refuses-every-target-except-x86-64]] is five-of-six done and
wasm32 is the only architecture left refused;
[[bug-a-dce-drops-a-called-body-on-the-riscv32-idf-profile]] was the last
wall on the ESP profile. Both demos build AND BOOT with
--dce, output unchanged: C3 -31%, S3 -38% of the flashed image. It buys ZERO bytes of data or bss, exactly as the note above says — do not re-sell it as the RAM answer. What it leaves is now attributed per unit, which is where the next rungs come from: [[bug-a-a-static-nilpy-program-links-the-runtime-eval-interpreter]] (pyeval is 30.1% of the riscv32 image / 39.3% of xtensa's, andPyHostCallalone is 109,396 B) and [[bug-a-riscv32-dce-keeps-135-more-bodies-than-xtensa-on-one-program]] (riscv32 keeps 122 bodies xtensa drops, 381,416 B, 18% of its image — the stub-target root rule firing on one ISA and not the other). And the instrument that is missing is named in the first of those:--dce-reportsays which bodies DIED; nothing says why one LIVED. Without that, shrinking the 2 MB is guesswork. - Put constants in flash. [[feature-a-there-is-no-read-only-load-segment-so-nothing-can-be-flash-resident]]. This is the owner's first directive and this is where it pays.
- Stop reserving SRAM for opted-out facilities.
[[bug-a-the-signal-alt-stack-is-32768-bytes-of-unconditional-bss]] — 32 KB,
8% of a C3's usable SRAM, for a facility the program said no to.
AND THE READLN LINE BUFFER, DONE 2026-09-18: −4,088 B of bare ESP bss on
all four SoCs (70,936 -> 66,848, size canary, esp32 / esp32c3 / esp32s2 /
esp32s3 alike), and −8,168 hosted x86-64 where the SAME buffer was reserved
TWICE. It was 4,096 bytes reserved by the Pascal driver in every image on
every target — including ESP, where the PAL refuses fd 0 and the buffer could
never be read into. It is now a pointer to a demand-allocated growable block
in
builtinheap.pas, so a program that never touches stdin reserves nothing and a line longer than the buffer is read WHOLE instead of truncated with its tail left in the fd for the next readln to pick up as a phantom line. [[bug-a-the-heap-arena-reserves-256-mib-without-map-noreserve-so-a-small-guest-cannot-run-any-allocating-pxx-program]] is the same shape one size up. - Stop the always-linked surface growing. [[bug-a-the-esp32-bare-image-doubled-in-code-and-grew-half-again-in-bss]] — esp32c3 went 26 KB -> 50,528 -> 57,900 of code and ~70 KB -> 103,692 of bss, and x86_64-empty grew +4,025 in the same window. Its canary was re-baselined rather than fixed, to clear a red for a full-green pin, so the growth is currently unwatched. [[bug-a-emit-obj-retains-pxxassert-so-one-ansistring-in-it-imports-the-whole-esp-pal]] is one named mechanism for it.
- Get the ESP suite into a tier. [[bug-t-the-esp-bare-suite-is-in-no-tier-so-nothing-ever-runs-it]]. Every number above is a hand measurement; nothing defends them.
Two claims to keep straight
From devdocs/dev/the-goal-cross-cross.md, and SUPERSEDED IN A NAMED SCOPE
ON 2026-09-19/20: "pxx compiles Python to ESP32" is FALSE and no .npy has
ever run on a cross target were both true when written. A NilPy program now
runs on the ESP32-C3 and ESP32-S3 under ESP-IDF, UNDER QEMU (nilpy-c3,
nilpy-s3), and a second one drives a GPIO pin and takes an ESP-IDF timer
callback (nilpy-hw-c3, nilpy-hw-s3). NO CHIP HAS RUN EITHER. Bare metal
is still walled by design. So the sentence a rung must not overclaim has
MOVED rather than gone: it is now silicon, not the language.
The bare profile's single RWX IRAM region is qemu's shape (defs.inc:2275:
"qemu's esp32c3 machine models it as one RWX region"), not a real C3's. Numbers
measured under --esp-profile=bare are numbers about qemu. The IDF profile,
where .text stays in flash, is the one that answers the owner's question — and
it is a single branch away.
OWNER: MEASURE SRAM, NOT IMAGE SIZE (2026-09-20, relayed by frankuser)
His words, relayed secondhand: "about memory use - SRAM here is most relevant, ESP's have 'plenty' flash memory so that's a lesser issue."
So the headline number for this umbrella is SRAM: data + bss + the heap arena. Image size is secondary and belongs in the same report as the second number, never as the first.
This RE-READS last night's results rather than retiring them. 357d13162
(-31%/-38%) and 6c211e043 (xtensa live code -52%) are FLASH wins: .text
lives in flash on the IDF profile. They are real and they are not what this
umbrella is ranked on.
Every rung under here must say WHICH it measured — several currently say "image" without distinguishing. A rung reporting only an image delta has not reported against this umbrella's own criterion.
2026-09-20 (frankS) — THERE IS AN SRAM INSTRUMENT NOW, AND THE FLASH WINS ARE WORTH ~1% OF IT
Answering the section directly above, which asks for data + bss + the heap
arena. On the IDF profile there is no pxx arena to report — the pxx heap IS
IDF's heap (PXXAlloc is calloc/free there; builtinheap.pas's static
EspArena is the BARE profile's), so the third term is IDF's pool and it is
read off the chip rather than computed. Same three numbers, one of them
measured instead of summed.
The owner's ruling that started this: "about memory use — SRAM here is most relevant, ESP's have 'plenty' flash memory so that's a lesser issue." Every rung above this line was priced in FLASH whether or not it said so, because no SRAM instrument existed. It does now.
The instrument
examples/esp32/nilpy-c3/build.sh sram — the ONE script all four NilPy demos
symlink, so it works for both ISAs. It builds, boots the image under the
Espressif qemu, and prints two readouts:
- our object's
data=/bss=, off the compiler's ownok:line; - the free DRAM pool, summed from IDF's own
heap_init: At <addr> len <hex>lines in the boot log — which is the COMPLEMENT of the whole link's static footprint (ours, the RTL's, FreeRTOS's and the SDK's), measured on the emulated part.
idf.py size's "Total" is deliberately not used: on this profile .text is
flash-mapped, so a section total is not an SRAM figure
([[measure-what-idf-itself-costs-in-sram-on-a-c3]]).
A missing heap_init line is a FAILURE here, never a 0. A pool of zero and
a pool never parsed would otherwise print the same, and zero is also what a
failed boot gives — the readout-collides-with-the-default trap, in the one place
it would have been invisible.
The positive control, and it moved both readouts
A 40,000-byte static array in a Pascal unit imported by a copy of the demo
(PXX_MAIN and PXX_EXTRA_FLAGS=-Fu… are the knobs; the unit is static
precisely because a module-level NilPy bytearray is a HEAP allocation made
after heap_init has already printed, and would have certified a dead
instrument):
bss 89,352 -> 129,356 (+40,004)
DRAM pool 211,296 -> 171,296 (-40,000 exactly)
40,000 is not a power of two and not any arena constant in the tree
(HEAP_ARENA is 65,536), so it cannot be a default or a coincidence. The 4-byte
difference between the two deltas is bss landing inside existing alignment
padding.
The measurement, both ISAs, HEAD compiler, --dce on vs off
Population: examples/esp32/nilpy-c3/main/main.npy and its -s3 symlink — ONE
program, the print demo — --platform=esp --no-signals, compiler
compiler/pascal26 at HEAD 2026-09-20. Oracle: the chip's own heap_init.
(The PINNED compiler cannot build this program at all: it answers "target
riscv32 (hosted linux): a heap arena needs mmap", a wall fixed after the pin.
Inert until pinned.)
code data bss free DRAM pool
riscv32 -- 2,996,040 88,656 89,352 211,296
riscv32 --dce 2,074,564 (-30.8%) 88,656 89,352 213,136 (+1,840)
xtensa -- 2,896,443 88,600 89,352 278,580
xtensa --dce 840,955 (-71.0%) 88,600 89,352 280,692 (+2,112)
I recorded the expectation before the run and it was nearly right: data and bss move by ZERO on both ISAs, exactly as predicted. The pool moving at all was not predicted, so the 1,840 got chased rather than reported.
What the ~1% actually is, and the instrument that lied on the way
The whole delta is lwIP's static buffers being garbage-collected:
sockets.c (0xc8 + 0x118 + 0x78), nd6.c (0x20 + 0x14 + 0x24 + 0x8c + 0x1e0 +
0xdc), ip.c (0x44), tcp_in.c (0x10), tcp_isn_default.c (0x40), plus 8 B of
.dram0.data.
An UNDEFINED-SYMBOL CENSUS CANNOT SEE THIS, AND IT ANSWERS CONFIDENTLY.
readelf -s gives 38 UND symbols in BOTH objects, lwip_socket,
lwip_bind, lwip_recv and five siblings present in each — because pxx's DCE
drops BODIES and does not prune the symbol table. The difference is in the
RELOCATIONS: 5 without --dce, 0 with. IDF links --gc-sections, so with
no relocation reaching lwIP's socket path its buffers are collected. The symbol
census is current, correctly parameterised, and enumerates a set that cannot
contain the answer — count relocations, not symbols, when asking what a link
will keep.
The consequence for ranking, which is the point of the ruling
On the axis the owner named, a code-removal rung pays ~0 unless it drops the LAST RELOCATION to an SDK component. 921 KB of flash bought 1,840 B of SRAM on riscv32; 2,055 KB bought 2,112 B on xtensa — 0.87% and 0.76% of the free pool. Both landed wins ([[bug-a-a-static-nilpy-program-links-the-runtime-eval-interpreter]], −52% of the ESP NilPy image, and the −31%/−38% DCE rung) are strictly smaller levers of the same kind, and the pyeval interpreter references no SDK component that something else does not, so their SRAM figure is ~0 too.
That does not retire the flash rungs — the stock 1 MB factory partition
still needs −66% and xtensa --dce now reaches 841 KB, which clears it. It
retires the idea that they are RAM work.
SUPERSEDED IN PART 2026-09-20 — the arena rung below has landed and it was the biggest one: .bss 89,352 -> 23,816, data+bss 125,832 -> 60,296, the free pool 211,296 -> 276,832, every number moving by exactly 65,536. Our share of the pool went 59.6% -> 21.8%. The reasoning in this section is unchanged and the arithmetic in it is historical.
What IS SRAM work, and none of it is DCE: our object's own 125,832 B
(.data 36,480 + .bss 89,352 on riscv32) against a 211,296 B free pool —
59.6% of the remaining DRAM is our static data, and --dce does not touch
one byte of it.
That figure was published as 178,008 B / 84% for about an hour and it was wrong. See "THE FIRST READOUT WAS MISLABELLED" below — the correction is kept because the way it survived a positive control is the useful part.
WHAT WOULD RETIRE THE BOUND — and it is already spent
The bound above is "a code rung pays SRAM only where it drops the LAST
relocation to an SDK component". That is a testable condition, and it is
cheaper than rebuilding a compiler at two historical commits, so it was asked
of the object directly rather than of 357d13162 and 6c211e043 in turn.
After --dce, the external surface this object still relocates against is
six names: calloc, free, fwrite, putchar, write, vTaskDelete.
Every one is newlib or FreeRTOS core that IDF's own startup requires with or
without us — and that is measured, not argued: the .dram0 diff between the
two builds shows lwIP as the ONLY component whose contribution changed.
Everything else is byte-for-byte identical, i.e. held by something other than
our references.
So the bound is not "roughly 1%", it is "zero from here". lwIP was the one
SDK component this program pulled in by itself, --dce has already dropped it,
and no further code-removal rung — including the two landed wins, which are
strictly smaller levers of the same kind — has a last relocation left to drop.
What would retire this: a program that legitimately reaches a different SDK component (a NilPy demo using sockets, files, WiFi or NVS) and a rung that stops it doing so. On THAT program the bound does not apply and the measurement must be retaken. It says nothing about the print demo.
CORRECTION, same day, and it is this file's own rule catching its own author
The paragraph above originally named [[feature-a-there-is-no-read-only-load-segment-so-nothing-can-be-flash-resident]] and [[bug-a-the-signal-alt-stack-is-32768-bytes-of-unconditional-bss]] as the rungs to rank above code work. Both citations were wrong, and both were wrong in the frontmatter of the ticket I was citing:
- The alt-stack ticket is prio 15, and its SRAM case is DISCHARGED — by
me, on 2026-09-19, re-measured at 66,808 → 66,808 on both bare ESP profiles
because
TargetHasSignalRuntimeis false where no OS delivers a signal. Its summary opens with exactly that. - The read-only-segment ticket is
status: working,owner: frankH, and its ESP-IDF half LANDED on 2026-09-18: both ELF32 object writers emit.rodata+.rela.rodata, IDF places it in flash,test_emit_obj.pason xtensa went SRAM.data6,304 → 2,624. So the figure above is measured downstream of that win, and "no constant CAN be flash-resident" is stale for this profile.
Written from memory of the backlog rather than from the tickets, on the same
day, by the seat that had itself discharged one of them — the born-red
assertion, in a recommendation instead of a guard. Caught by frankz-e5 from the
frontmatter alone. Recorded rather than quietly edited because the failure is
the interesting part: re-verifying a ticket before citing it costs one sed -n 1,9p and I did not spend it.
THE ACTUAL RUNG, and it needs this instrument
feature-a-there-is-no-read-only-load-segment-…'s own REMAINING list is
"NilPy VMTs, prop/method arrays, IMTs, dispatch tables, float constants, each
after its own never-written measurement." The subject here is a NilPy
program, so those unlanded pieces are precisely the shape of the 178 KB. They
were never moved because nobody could price them on this profile; build.sh sram is that price. That is what outranks a code rung — not the two tickets
this section first named.
2026-09-20 (frankS) — THE FIRST READOUT WAS MISLABELLED, AND THE POSITIVE CONTROL COULD NOT HAVE CAUGHT IT
The sram mode's second readout (the free DRAM pool, off the chip) was right
throughout. Its FIRST readout — "our object's data+bss", taken from the
compiler's own ok: line — was not, and every number I reported from it,
including to two peers, overstated SRAM by 42%.
The compiler's data= is the whole data segment, .data AND .rodata. On
the IDF profile .rodata carries no W flag, so the IDF linker places it in
the flash DROM window. Measured in the map:
.dram0.data .data 0x3fc89f08 0x8e80 (36,480) SRAM
.dram0.bss .bss 0x3fc94ef8 0x15d08 (89,352) SRAM
.flash.rodata .rodata 0x3c216f30 0xd148 (53,576) FLASH
reported: data 88,656 + bss 89,352 = 178,008 (84% of the pool)
true: .data 36,480 + .bss 89,352 = 125,832 (59.6% of the pool)
The 53,576 B difference is frankH's read-only-segment work already doing its job — the very win I had separately, and wrongly, described as not yet landed.
Why the control passed
The positive control was a 40,000-byte static array. A static array lands in
.bss, and .bss is reported identically and correctly by both the ok:
line and the section table. So the control moved a readout that was never
broken, passed, and said nothing whatever about the readout that was.
A positive control proves the readout it MOVES and no other. Mine had the right population for the question "does this instrument see memory appear" and no population at all for "is the data figure the right data figure". The instrument was honest, current, correctly parameterised, and answering about a different section — the house failure mode, inside the guard built to prevent it.
The fix reads the SECTION TABLE (readelf -SW), which distinguishes .data
from .rodata by the W flag the linker itself branches on, and prints the
flash figure beside the SRAM one so the split is visible rather than assumed.
Two smaller traps in the same block, both live
mawkhas nostrtonum. It reads"0x8e80"as 0. In the pool sum that would have printed a small plausible total; in the section sum it would have printed an SRAM figure of exactly.bss. Both sums are shell arithmetic ($((16#…))) for that reason.readelf -SWshifts every field index partway down its own table, because[ 9]is two fields and[10]is one. A fixed column gave.data=0 .bss=0. Match the NAME and take the size four fields on.
What this does NOT change
The ranking consequence is untouched and if anything sharper: --dce moved
.data and .bss by zero in both builds, which was true when measured off
either instrument, and 59.6% of the free pool is still our static data.
2026-09-22 — the signal alt stack was CUT from this umbrella's blockers
bug-a-the-signal-alt-stack-is-32768-bytes-of-unconditional-bss was listed
here on the strength of a comment in compiler/defs.inc reading "on a target
where BSS is SRAM it is 32768 bytes of RAM in every image". That stopped being
true at 16ebf18ce (2026-09-18), which made the reservation conditional on a
signal runtime actually being emitted, and an ESP bare image emits none.
Measured at HEAD by halving SIG_ALTSTACK_SIZE and rebuilding — a
plain-vs---no-signals delta of 0 cannot tell not allocated from allocated
on both arms, so only a differential in the constant separates them:
| profile | 32768 | 16384 | delta |
|---|---|---|---|
| bare esp32c3 | 66808 | 66808 | 0 |
| bare esp32s3 | 66808 | 66808 | 0 |
| hosted x86-64, plain | 35324 | 18940 | 16384 |
hosted x86-64, --no-signals |
2532 | 2532 | 0 |
So it costs an ESP image nothing and belongs only to
umbrella-a-hosted-program-is-as-small-as-it-can-be, where it remains listed
and where the remaining piece (size the constant per target) actually lands.
Its own author had already re-ranked it 60 -> 15 on this evidence; the edge
kept it at effective 70 and at the TOP of next --track A regardless, because
the ranker takes the max over everything a ticket unblocks. A stale
membership edge is not a mis-ranking of one ticket — it is a goal claiming work
that does not serve it.