← board

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:

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):

RUNG 0 IS NOW MEASURED (2026-09-18, frankS) — the budget is a number:

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

  1. 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.
  2. Make size measurable at all. [[bug-t-code-is-page-quantised-so-there-is-no-instrument-for-size-work]].
  3. 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:.
  4. 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, and PyHostCall alone 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-report says which bodies DIED; nothing says why one LIVED. Without that, shrinking the 2 MB is guesswork.
  5. 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.
  6. 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.
  7. 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.
  8. 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:

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:

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

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.