PLAN — nd500x growable seg-0/1 DATA, Option 2 redesign¶
File: /mnt/e/Dev/Ronny/NDInsight/SINTRAN/ND500/PLAN-nd500x-growable-DATA-option2-redesign-2026-07-26.md
Date: 2026-07-26
Author task: design (not implement) a >1 MB-capable seg-0/1 DATA mapping that is byte-identical to the proven-good PS_ASI path for the real NC compiler.
Main tree (read-only, NOT modified): /home/ronny/repos/nd500x
Oracles run for evidence: /home/ronny/repos/nd500x-good/build/bin/nd500x (aa5cd5e, GOOD), /home/ronny/repos/nd500x-allocdbg/build/bin/nd500x (951237d-equiv, BAD, env ND500X_ALLOCDBG).
0. TL;DR¶
- Root cause is PINNED at the mechanism level, and it is NOT what the bug report / stopgap comment say. The bug report frames it as "growable serves ZEROED demand pages vs old path mapped contiguous content" (a content difference). The evidence says the real divergence is trap-vs-silent-map: under the proven-good PS_ASI mapping the seg-0 access to
va=0x02200000raises a page-fault trap that NC's own handler fields and recovers from; under the BAD PS_ADI growable-alias mapping the same access is silently satisfied bygrow_on_faultallocating a fresh zeroed page, so NC's page-fault handler never runs, NC diverges, and later executesjumpgwith a bad EA →PC=0→ emulator halt. - Consequence for the redesign: any Option 2 that maps (eagerly or lazily) the region NC expects to fault in will re-break NC. The essential invariant is "fault beyond the segment's owned extent," not "pre-zero the reserve." The 1 MB PS_ASI cap is incidental; NC's actual first out-of-region access is at 34 MB (
0x02200000), so a two-level (PS_ADI) map that owns more than 1 MB but far less than 34 MB is simultaneously >1 MB-capable AND byte-identical to NC. - Recommended Option 2: build seg-0/1 DATA as PS_ADI two-level, eagerly mapping
data_pages(adopted, contiguous) + a bounded contiguous zeroed reserve, and DO NOT register seg-0/1 as growable — so any access past the owned extent still traps to NC exactly as PS_ASI does today. The reserve size is the single knob that unlocks >1 MB. - Confidence: HIGH on the mechanism (convergent direct evidence). MEDIUM on the exact instruction index (bug-report-sourced
~1,196,939; I corroborated the mechanism but did not independently re-derive the instruction number). See §1.4 and §5.
1. Root cause (pinned to mechanism; instruction index labeled)¶
1.1 What actually differs between GOOD (aa5cd5e) and BAD (951237d)¶
I confirmed by reading both trees that the only good-vs-bad difference is the seg-0/1 DATA mapping:
nd500x-goodsrc/ndlib/ndlib_dom_loader.cbuilds seg-0/1 DATA as PS_ASI single-level +DATA_GROWTH_RESERVE_PAGES(512) contiguous reserve (good tree lines 454–480), and aliases seg-0 to seg-1's PS_ASI PST via a plain data capability.nd500x-goodsrc/cpu/nd500_segment_alloc.calready contains the full PS_ADI growable MON path (grow_on_fault,watermark_alloc_page,alloc_backed_segment,nd500_mmu_set_pst_entry(..., PS_ADI, ...)at good line 447). So the MON-connected / scratch segments (seg 2, 4, 11 …) are PS_ADI-growable in BOTH builds.
Therefore the seg-4 read-before-write-zeroed event that the prior experiment flagged (va=0x246C3438, /tmp/.../scratchpad/allocdbg.log:600) is identical in GOOD and BAD and is not the divergence. The regression 951237d changed only the DOM seg-0/1 DATA path (PS_ASI+reserve → nd500_segment_adopt_growable_data + nd500_segment_register_growable_alias). The current main-tree HEAD /home/ronny/repos/nd500x/src/ndlib/ndlib_dom_loader.c:409–529 is the Option-1 stopgap that restored PS_ASI+reserve.
1.2 The decisive observation (logged, reproduced twice)¶
Running GOOD (/home/ronny/repos/nd500x-good/build/bin/nd500x) with the repro emits, and still reaches a clean NC termination:
[MMU] TRAP: PS_ASI page fault! L1=34 must be 0! vaddr=0x02200000
-CROSS-A
program terminated
-- program exited (2375216 instructions) --
/tmp/.../scratchpad/repro_good.log shows the same with 2313497 instructions on an earlier run; re-run today gave 2375216 — the count varies with pre-existing B.CAT/B.LIST state, the outcome does not.)
0x02200000decodes as: segment= (0x02200000>>27)&0x1F = 0; L1= (0x02200000>>20)&0x7F = 34; L2= 0; offset0. That is page index(34<<9)|0 = 17408→ byte34 MBinto seg-0.- Under PS_ASI, L1 must be 0 (
src/cpu/nd500_mmu.c:471–475), so this access traps (trap_page_fault). trap_page_fault→raise_trap(TRAP_PGF,…)(src/cpu/cpu.c:776–777) dispatches to the guest's THA handler: it readsTHA + trapNumber*4, verifies anENTT(0xBC) at the handler, saves state, and setsPCto the handler (src/cpu/cpu.c:704–753). NC's DOM header supplies THA (src/ndlib/ndlib_dom_loader.c:391).- The GOOD run does not print
[TRAP] No trap handler …(thehandlerAddr==0path,cpu.c:712–721). So NC's PGF handler was found and dispatched, NC handled the fault, and execution continued to-CROSS-A / program terminated— NC's normal, successful completion.
In BAD, the same access does not trap. The instrumented log shows it silently grown through the seg-0→seg-1 growable alias:
/tmp/.../scratchpad/allocdbg.log:604 ALLOC pfn=1594 ... GROW-L2 va=0x02200000
/tmp/.../scratchpad/allocdbg.log:606 ALLOC pfn=1595 ... GROW-DATA va=0x02200000
/tmp/.../scratchpad/allocdbg.log:608 GROW va=0x02200000 dom=1 seg=0 l1=34 l2=0 -> data_pfn=1595 ... via slot{dom=1,seg=0,l1tbl=0x001F4000}
/tmp/.../scratchpad/allocdbg.log:609-612 VALWATCH WRITE8 phys=0x0031D800.. val=0x18,0x00,0x24,0x58 (NC writes 0x18002458)
So in BAD the access is a write that is silently satisfied by a fresh zeroed page (watermark_alloc_page memsets, src/cpu/nd500_segment_alloc.c:146–157), no trap is raised, and NC's PGF handler never runs.
1.3 Mechanism (pinned)¶
NC installs a page-fault (PGF) trap handler via its DOM THA. During code generation it accesses seg-0
va=0x02200000(34 MB, L1=34), which lies far past its ~122 KB initialized DATA (data_pages=61,allocdbg.log:3). Under the proven-good PS_ASI seg-0/1 mapping this access traps (PS_ASI forbids L1≠0); nd500x routes TRAP_PGF to NC's own handler, NC recovers, and finishes cleanly. Under the BAD PS_ADI growable-alias mapping the seg-0 slot is registered growable (register_growable_alias,allocdbg.log:608 slot{dom=1,seg=0}), sogrow_on_fault(nd500_mmu.c:519,570→nd500_segment_alloc.c:219–232) silently maps a zeroed page and suppresses the trap. NC therefore takes a control path the GOOD run never enters, and ~1.1 M instructions later runsjumpgwith EA0x100001A4→PC=0→[STOP] Invalid instruction 0x00 at PC=0(repro_bad.log).
The b.24/"shorter hex number" symptom in the bug report is downstream of this: it is a value NC computes after missing its page-fault handler, not the primary divergence.
1.4 What remains inferred vs verified¶
- Verified (direct, reproduced): GOOD faults at
0x02200000and recovers via NC's THA handler to a clean exit; BAD silently grows that same page and crashes atPC=0; the seg-0/1 DATA mapping is the sole good-vs-bad difference; the MON/seg-4 growable path is identical in both. - Inferred / bug-report-sourced (labeled): the exact first-divergent instruction index
~1,196,939and theb.24branch come from the bug report's differential PC trace. I did not re-run an instruction-aligned byte-for-byte differential (it needs instruction-count instrumentation added to both builds and a full 1.2 M-instruction trace diff). The mechanism above is established by convergent evidence without it; the instruction index is not load-bearing for the redesign. If a reviewer wants the belt-and-suspenders proof, see §5 for the exact experiment.
2. Why the old path works and the new one fails¶
| Axis | GOOD PS_ASI + reserve (aa5cd5e) | BAD PS_ADI growable alias (951237d) |
|---|---|---|
| seg-0/1 owned extent | 512 pages max (L1 forced 0), nd500_mmu.c:471 |
up to 128 MB, demand-grown, no bound |
access past owned extent (e.g. 0x02200000) |
traps → NC's PGF handler runs | silently mapped (zeroed) → no handler |
| init data | adopted contiguous, PFN data_base+p |
adopted contiguous, PFN data_base+p (same!) |
| reserve/stack region (<1 MB) | contiguous zeroed block | (unused for 0x02200000; that VA is 34 MB) |
| NC outcome | clean -CROSS-A exit |
jumpg→PC=0 halt |
Key corrections to the prevailing explanation:
- It is not "zeroed-demand vs pre-zeroed-content." Both paths hand back zeroed pages there; the good path never maps
0x02200000at all — it faults. TheDATA_GROWTH_RESERVE_PAGESreserve (goodndlib_dom_loader.c:454) only covers the first ≤1 MB (stack/heap just above data); it is irrelevant to0x02200000(34 MB). - The 1 MB PS_ASI cap is incidental, not the mechanism. What NC depends on is the existence of a fault boundary below 34 MB, plus its PGF handler. PS_ASI happens to put that boundary at 1 MB.
- The seg-0→seg-1 growable alias is the specific culprit.
register_growable_alias(nd500_segment_alloc.c:304–324) put seg-0 intog_growable, sogrow_on_fault'sgrowable_find(domain,0)succeeds and the fault is resolved instead of trapped.
Inference (labeled): NC almost certainly uses the seg-0 page fault as an intentional signal — its runtime detects "I've run past my DATA segment" and switches to MON-allocated scratch (GSWSP/FSCNT seg 2+), rather than actually needing 34 MB of contiguous seg-0. This is consistent with NC's real scratch living in the demand-grown MON segments (seg 2/4/11 in allocdbg.log). Not required for the fix, but it explains why "just give NC the memory it asks for" is wrong.
3. Staged redesign plan¶
3.1 Design invariant (the acceptance contract)¶
Seg-0/1 DATA must trap (TRAP_PGF, dispatched to the guest THA) on any access beyond the segment's owned page extent, exactly as PS_ASI does today — while being able to own more than 512 pages (1 MB).
Byte-identical-to-NC reduces to two concrete, testable facts against the aa5cd5e oracle:
- (I1) The first access whose resolution differs from PS_ASI must not occur before NC's genuine first out-of-region access (0x02200000). Practically: the owned extent must be < 17408 pages (34 MB) and ≥ NC's real high-water seg-0 page (well under 1 MB, per the logs). Keeping the default owned extent at the current ~512–573 pages guarantees I1.
- (I2) Init-data pages and the low reserve are byte-for-byte the same (same adopted PFNs; reserve zeroed). PS_ADI with an eager contiguous reserve satisfies this trivially.
3.2 Options considered¶
- (a) Keep PS_ASI, chain beyond 1 MB. Not viable: a segment has exactly one PST entry, and PS_ASI hard-traps L1≠0 (
nd500_mmu.c:471). Extending it means either faking multiple PST entries per segment or changing PS_ASI semantics — both violate the architecture and the MMU core. Reject. - (b) PS_ADI, eagerly pre-map a bounded contiguous reserve, NO grow-on-fault for seg-0/1. Owns
data_pages + reservecontiguous zeroed pages; faults beyond. >1 MB-capable (reserve can span multiple L2 tables, L1>0). Byte-identical to NC when reserve keeps the boundary below 34 MB. Simple, provably contiguous/zeroed. RECOMMENDED. - (c) Fix the growable path's demand semantics to reproduce old content. The old path produces no content — it faults. To "reproduce" it,
grow_on_faultwould have to know the segment's owned bound and refuse to grow past it (fault instead). That is just (b) implemented lazily. Viable as a variant but adds a bound-check in the hot fault path. Fold into (d). - (d) Hybrid: PS_ADI, eager-map init data, LAZY-grow only within a bounded window
[data, data+reserve), fault beyond the window. Memory-thrifty version of (b): identical fault semantics, but reserve pages are allocated on first touch instead of up front. Slightly more complex; same correctness. Recommended fallback if the eager reserve's memory cost matters (it does not at 512 pages / 1 MB in a 16 MB machine).
3.3 Recommended: Option (b), exact change sites¶
All changes are in the two files the bug report scopes; no MMU-core change is required — the PS_ADI L1/L2-invalid paths already call trap_page_fault when grow_on_fault declines (nd500_mmu.c:523–558, 574–593).
Change site 1 — /home/ronny/repos/nd500x/src/cpu/nd500_segment_alloc.c: add a non-growable, bounded PS_ADI DATA builder.
Add a sibling to nd500_segment_adopt_growable_data (current lines 241–293), e.g. nd500_segment_map_bounded_data(cpu, m, domain, segment, psn, data_phys_base, data_pages, reserve_pages, watermark_floor_base), that:
1. watermark_init + raise g_next_free_pfn above watermark_floor_base (reuse lines 250–254).
2. Allocate the L1 table page (watermark_alloc_page, line 263).
3. For p in [0, data_pages): adopt data_base_pfn + p into l1=p>>9, l2=p&0x1FF, creating L2 tables as needed (reuse the loop at 276–289, prot 0/RW).
4. New: allocate reserve_pages contiguous fresh pages (a single watermark-region run) and map them at p in [data_pages, data_pages+reserve_pages) with the same l1=p>>9,l2=p&0x1FF derivation, prot 0. Zeroing is already done by watermark_alloc_page.
5. nd500_mmu_set_pst_entry(cpu, psn, PS_ADI, l1_pfn) (line 291).
6. Do NOT add an entry to g_growable for this segment (do not set g->in_use). This is the crux: with no g_growable slot, growable_find(domain, seg) returns NULL, so grow_on_fault returns 0 and the MMU traps past the owned extent — the PS_ASI-equivalent behavior.
Return the L1 PFN so the alias (below) can share it.
Change site 2 — same file: do NOT register seg-0/1 as growable.
nd500_segment_register_growable_alias (lines 304–324) must not be used for the seg-0→seg-1 DATA alias. The alias becomes a plain shared PST/data-capability (see change site 3). Leaving seg-0/1 out of g_growable is what preserves the trap.
Change site 3 — /home/ronny/repos/nd500x/src/ndlib/ndlib_dom_loader.c: replace the PS_ASI DATA block and the alias.
- Replace the per-segment PS_ASI DATA block (current HEAD lines 462–506) with a call to nd500_segment_map_bounded_data(...) for each has_data segment, passing reserve_pages = DATA_RESERVE_PAGES (see §3.4). Keep PROG segments PS_ASI (unchanged, lines 429–457) — only DATA changes.
- Replace the FORTRAN alias (current HEAD lines 520–526) so seg-0's data capability points at seg-1's PS_ADI PST index (share the same two-level tables), via nd500_mmu_set_data_capability(cpu, domain, 0, seg1_psn | DC_WRP) — a plain capability alias, not register_growable_alias. Because seg-0/1 are absent from g_growable, 0x02200000 (seg field 0) faults exactly as today.
- The removed "PS_ADI growable pass-2" comment (lines 528–529) is dropped.
Untouched: the entire MON-connected/scratch path (alloc_backed_segment, nd500_mon_connect_file_as_segment, nd500_mon_allocate_segment, grow_on_fault, watermark_alloc_page, nd500_segment_writeback). Those stay PS_ADI-growable — they are correct and already present in aa5cd5e.
3.4 The one knob: DATA_RESERVE_PAGES¶
- Default = 512 (or the current
DATA_GROWTH_RESERVE_PAGES): keeps NC byte-identical (owned extent ≈ data+512 pages, still far below the 34 MB fault). Under PS_ADI this now actually maps pages 512–572 that PS_ASI left faulting, but the logs show NC never touches the 1 MB–34 MB range, so I1 holds — must be confirmed by the acceptance test in §3.5. - >1 MB capability: raising
DATA_RESERVE_PAGESpast 512 now works (PS_ADI spans L1>0), giving a program a larger owned seg-0/1 DSEG. Must stay< 17408pages to keep NC's fault (and, more generally,<the smallest legitimate fault any co-running program relies on). - Where to source the size (open): the vendor DOMs don't declare the uninitialized DSEG extent (FLA/FUA/MINP/MAXP = 0,
ndlib_dom_loader.c:50–58). Options: (i) a fixed generous default (simplest), (ii) a per-domain config override innd500x.ini, (iii) derive from the DOM segment descriptor if a length field is ever found. Recommend (i)+(ii) now; (iii) if/when a real >1 MB-seg-0/1 program is identified (see §4).
3.5 Acceptance / test procedure¶
- NC byte-identical (primary gate). Run the repro (
§Reproof the bug report) against the rebuilt tree. Must reproduce GOOD: emit[MMU] TRAP: PS_ADI L1 page not valid! vaddr=0x02200000(now PS_ADI wording) — i.e. a trap, not a grow — dispatch to NC's PGF handler (no[TRAP] No trap handler), and reach-CROSS-A / program terminatedwith an instruction count within a few % of the aa5cd5e oracle. Must NOT print[STOP] Invalid instruction 0x00 at PC=0. - First-divergent-read oracle diff (rigor). With instruction-count instrumentation (env-gated, in the experiment copy only), assert that no seg-0/1 data read resolves to a different PFN/value than the aa5cd5e oracle before
0x02200000. Equivalent cheap proxy: assert the seg-0/1 fault set is identical to GOOD (first seg-0 fault VA =0x02200000). - >1 MB seg-0/1 (secondary gate). Craft a DOM whose seg-0/1 DATA legitimately spans >512 pages (e.g. a large initialized array or a program that writes 1.5 MB into seg-1 before any MON scratch), set
DATA_RESERVE_PAGESto cover it, and confirm it runs without a spurious fault AND without silently masking a fault it should take. - No regression in MON path. Re-run an FSCNT/GSWSP-heavy workload (the linker link,
nd500-linkerskill flow) to confirm the untouched growable MON path still works.
3.6 Risks¶
- R1 (medium): the default reserve now maps pages PS_ASI left faulting (1 MB–1.14 MB). If NC (or another vendor DOM) relies on a fault anywhere in 1 MB–34 MB, mapping there would break it. Mitigation: gate 1 confirms NC; keep the default reserve minimal (512) and treat any increase as per-program opt-in. Safest variant: cap the owned extent at exactly 512 reachable pages by default (reserve = 512 − data_pages) to reproduce PS_ASI's reachable set precisely, and only exceed it for a program that declares a larger DSEG.
- R2 (low): PS_ADI double protection check. Writes check both L1 and L2 prot bits (
nd500_mmu.c:597). The builder must set L1 PTEs prot=0 (RW) even though data is RW at L2 — matching the existing adopt code's note (nd500_segment_alloc.c:187–189, 284). Carry that over. - R3 (low): watermark collision. New pages must be taken strictly above
watermark_floor_base(the loader's final cursor), since PS_ADI pages are invisible tofind_highest_used_pfn(nd500_segment_alloc.c:120–142, 250–254). Reuse the existing floor logic verbatim. - R4 (low): seg-0/1 that legitimately needs demand growth. If a future program wants lazy growth in seg-0/1 (not just a bigger fixed reserve), use variant (d) with a hard upper bound instead of unbounded
grow_on_fault.
4. Open questions / unverified¶
- Q1 — Is a >1 MB seg-0/1 DSEG actually needed by any real program? The segment_alloc.c header (
nd500_segment_alloc.c:96–99) cites the ND linker faulting atvaddr=0x18402004, which decodes to segment 3 (0x18402004>>27 = 3) — a MON-connected (FSCNT) segment, already handled by the still-growable MON path, not seg-0/1 DOM DATA. So the task's premise that the 1 MB seg-0/1 cap "re-breaks the linker's output domain" appears to be a misattribution: the linker's large write is to a MON segment, which the stopgap did not touch. The genuine >1 MB-seg-0/1 case may be hypothetical. This does not change the recommended design (it satisfies the requirement either way) but it lowers the urgency and argues for the conservative default in §3.6/R1. - Q2 — Exact instruction index of the divergence. Bug-report-sourced (
~1,196,939); not independently re-derived here. See §5. - Q3 — What NC's PGF handler actually does (grow via MON? abort sub-op? longjmp to a retry?). Not traced. Knowing it would let us prove that no reachable reserve size between NC's true high-water and 34 MB is safe/unsafe. The conservative default (R1 safest variant) sidesteps needing this.
- Q4 — Whether seg-1 (as opposed to the seg-0 alias) is ever accessed past its extent by NC. The observed fault is seg-0 (
0x02200000). The design removes growability from both seg-0 and seg-1 for symmetry, but only seg-0's behavior is directly evidenced.
5. The rigor experiment (if the exact first-divergent read must be nailed)¶
To positively pin the instruction-aligned first divergent data read (the experiment's still-open item):
- In the allocdbg copy only (
/home/ronny/repos/nd500x-allocdbg), env-gate a print ofcpu->instruction_counton (a) every seg-0/1grow_on_faultresolution and (b) everytrap_page_faultfor segment 0/1. Rebuild only that copy. - Add the same instruction-count print to a throwaway instrumented copy of
nd500x-goodat its PS_ASIL1!=0trap (nd500_mmu.c:471). - Run both with the repro; confirm the seg-0
0x02200000event fires at the same instruction count in both, and that it equals the bug report's divergence point (~1,196,939). Expected: GOOD prints the trap, BAD prints the grow, at the same instruction — which upgrades Q2 from inferred to verified.
This was not run here because the mechanism is already established by convergent direct evidence (§1.2–1.3) and the exact index is not load-bearing for the redesign. It is documented so a reviewer can close it cheaply.
6. Summary of concrete change sites (for the implementer)¶
| File (absolute) | Location | Change |
|---|---|---|
/home/ronny/repos/nd500x/src/cpu/nd500_segment_alloc.c |
new fn near lines 241–293 | Add nd500_segment_map_bounded_data() — PS_ADI, adopt data + eager contiguous zeroed reserve, no g_growable entry |
/home/ronny/repos/nd500x/src/cpu/nd500_segment_alloc.c |
lines 304–324 | Do not call register_growable_alias for seg-0/1 |
/home/ronny/repos/nd500x/src/ndlib/ndlib_dom_loader.c |
lines 462–506 | Replace PS_ASI DATA block with a call to the new bounded PS_ADI builder |
/home/ronny/repos/nd500x/src/ndlib/ndlib_dom_loader.c |
lines 520–526 | seg-0 alias = plain shared DC to seg-1's PS_ADI PST (not growable) |
/home/ronny/repos/nd500x/src/cpu/nd500_mmu.c |
471–475, 519–558, 574–593 | No change — existing trap-on-decline behavior is exactly what we want |
Header prototype for the new builder goes in /home/ronny/repos/nd500x/src/cpu/nd500_mmu.h next to the existing nd500_segment_adopt_growable_data declaration (lines 211–225).