blaise/docs/rtl-unification-plan.adoc
Graeme Geldenhuys 55b308985e docs: RTL-unification implementation plan
Execution plan for folding the runtime into the compiler: single
pasbuild compile -m blaise-compiler, no separate runtime module, no
make install, no ar-built blaise_rtl.a.

Records the constraining facts (programs emit RTL calls as undefined externals
satisfied by the archive; the archive is a Blaise-only ar artifact pasbuild
does not produce; RTL is now pure Pascal), the chosen target shape (RTL units
relocated into the compiler source tree, compiler produces a cached rtl/*.o dir
the internal linker consumes), and the full blast radius — project.xml,
uToolchain/FindRTLArchive, native driver AddArchive, all four fixpoint scripts,
rolling-bootstrap, CI bootstrap.yml, ~12 tests, release artifacts, and docs.

Recommends DOTTED-FLAT unit names (runtime.atomic, runtime.str, …) over a
subdirectory: the unit loader is filename-based (dotted name -> dotted file, no
subdir traversal), so dotted-flat keeps the units on the compiler's existing
unit-path with zero new path entry, at the cost of a one-time uses-clause
rename sweep; emitted runtime symbols are unchanged either way.

Sequences the migration archive-compatible-first (old release keeps cold-
bootstrapping) and only drops the archive once a stage-2 binary produces/
consumes the .o cache, with a full four-fixpoint + cold-bootstrap re-verify
after each stage and a build-time measurement gate before keeping the
compile-per-target follow-up.
2026-06-25 23:08:30 +01:00

318 lines
15 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

= RTL Unification — Implementation Plan
:author: Graeme Geldenhuys
:revdate: 2026-06-25
:toc: left
:toclevels: 3
:source-highlighter: rouge
:icons: font
:sectanchors:
:sectnums:
== Purpose
Fold the always-linked runtime (`runtime/`) into the compiler so that a single
`pasbuild compile -m blaise-compiler` produces a self-contained toolchain — no
separate `runtime` module, no `cd runtime && make install`, no `ar`-built
`blaise_rtl.a`. This is the first concrete step of the "one binary, embedded
RTL, no external archives" end-state recorded in
`docs/native-target-architecture.adoc`.
This document is the execution plan: the architecture facts that constrain it,
the chosen target shape, the full blast radius (every file/script touched), the
ordered migration with a self-hosting re-verify after each stage, and the
cold-bootstrap ordering that must never break mid-change.
== Background facts that constrain the design
These were established by investigation (2026-06-25) and dictate the approach.
. *A normal program `uses` nothing of the runtime explicitly.* The compiler
emits calls to `_SetArgs`, `_SysWriteInt`, `_BlaiseInit`, the ARC helpers,
etc. as *undefined external symbols*. An `--emit-asm` of a hello-world is
~100 lines with zero RTL bodies. Those externals are satisfied at link time
by `blaise_rtl.a`. *The archive is the implicit runtime every binary needs.*
. *`blaise_rtl.a` is a Blaise-only artifact.* It is assembled by
`runtime/Makefile` with `ar` from per-unit `.o` files. PasBuild's `library`
packaging compiles units (`.o` + `.bif` for unit-path consumption) but does
*not* emit an `ar` archive — so PasBuild has no native notion of this file.
. *The RTL is now pure Pascal.* All four hand-written `*_x86_64.s` files were
migrated to inline-asm `.pas` units, so every RTL unit builds through the
compiler's own internal assembler. Nothing external (gcc/as) is needed to
build the runtime any longer.
. *The compiler already resolves RTL interfaces from the `.pas` on its
unit-path* (for type/semantic resolution); only the *bodies* come from the
archive. The mechanism to compile a unit to an object in-process exists
(the incremental worker path, `LowerToObject` / `AddOwnedObject`).
. *`stdlib/` stays separate.* It is opt-in, not always-linked; it is not part
of this unification.
== Unit relocation: dotted-flat names (recommended) vs subdirectory
The unit loader is *filename-based*: a unit named `X` is sought as
`<searchpath>/X.pas`, and a *dotted* unit name maps to a *dotted filename*
verbatim — `rtl.platform.posix` → `rtl.platform.posix.pas` — NOT to a
subdirectory (`uUnitLoader.pas`: `Path := Base + AName + '.pas'`). Two
RTL units already use this convention (`rtl.platform*`).
This drives the relocation choice:
[cols="1,3,3",options="header"]
|===
| | Dotted-flat (recommended) | Subdirectory
| Layout
| `compiler/src/main/pascal/runtime.atomic.pas`
| `compiler/src/main/pascal/runtime/blaise_atomic.pas`
| Unit-path change
| *None* — files sit directly in the compiler's existing source dir, already on
its path. Best satisfies the "reduce unit paths" goal.
| Needs `compiler/src/main/pascal/runtime` added as a search path — re-introduces
a unit-path entry, since the loader does not traverse subdirs.
| Unit names
| *Renamed* (`blaise_atomic` → `runtime.atomic`): churns every `uses` clause
and `.bif`/cache filenames.
| *Preserved*: no `uses` churn.
| Emitted symbols
| *Unchanged* either way — runtime symbols (`_BlaiseGetMem`, `_SetArgs`, …) are
explicit `external name` strings, independent of the unit name.
|===
Because the loader keys on filename, the dotted-flat scheme reaches the goal
(no extra unit-path) with the trade of a one-time `uses`-clause rename sweep,
whereas the subdirectory keeps a path entry. *Recommendation: dotted-flat.*
Proposed name map (keep `rtl.platform*` as-is; they already fit):
[cols="2,2",options="header"]
|===
| Today | Dotted-flat
| blaise_start | runtime.start
| blaise_atomic | runtime.atomic
| blaise_setjmp | runtime.setjmp
| blaise_utf8 | runtime.utf8
| blaise_mem | runtime.mem
| blaise_str | runtime.str
| blaise_set | runtime.set
| blaise_arc | runtime.arc
| blaise_weak | runtime.weak
| blaise_float | runtime.float
| blaise_thread | runtime.thread
| blaise_exc | runtime.exc
| system | system (unchanged — implicit System unit)
|===
(Whichever scheme is chosen, the `.o`-cache and link-model changes below are
identical.)
== Target shape (dotted-flat)
[source]
----
compiler/
src/main/pascal/
Blaise.pas, uParser.pas, ... (compiler sources, as today)
runtime.start.pas runtime.atomic.pas runtime.setjmp.pas runtime.utf8.pas
runtime.mem.pas runtime.str.pas runtime.set.pas runtime.arc.pas
runtime.weak.pas runtime.float.pas runtime.thread.pas runtime.exc.pas
rtl.platform.pas rtl.platform.layout.linux.pas rtl.platform.posix.pas
system.pas
target/
blaise (the compiler binary)
rtl/ (RTL .o CACHE, replaces blaise_rtl.a)
runtime.start.o ... rtl.platform.posix.o
----
* `pasbuild compile -m blaise-compiler` builds `blaise` *and* compiles the RTL
units to `compiler/target/rtl/*.o` (a cache dir, not an `ar` archive). No
extra `<unitPaths>` entry is needed — the units are in the compiler's own
source dir.
* At link time the native internal linker adds every `compiler/target/rtl/*.o`
(via the existing `AddOwnedObject` path) instead of `AddArchive(blaise_rtl.a)`.
* The `runtime` PasBuild module is removed from the reactor. Its tests
(`runtime/src/test`, the punit programs + the `punit` framework unit) move to
a test location under the compiler module (or a retained `runtime-tests`
module — see open questions).
Why a `.o` *cache dir* and not in-process recompile-per-program (yet): it
matches current link performance (no per-program RTL recompile), removes
`ar`/`make install`/the separate module today, and keeps the change to "where
the RTL objects come from" rather than "when they are built". The
recompile-per-target / embedded-source end-state is a *measured* follow-up
(<<followups>>).
== Blast radius
Every reference to the old `runtime/` location or to `blaise_rtl.a`. Grouped by
kind; all must move in lockstep so the bootstrap never breaks.
=== Build / module wiring
* `project.xml` — remove the `runtime` module from `<modules>` (keep `stdlib`).
* `compiler/project.xml` — drop `../runtime/src/main/pascal` from `<unitPaths>`
(both `<build>` and `<test>`); the units now live under the compiler's own
source tree (already on its path). Add the RTL `.o`-cache build step.
* `runtime/Makefile` — removed (its per-unit compile + `ar` is replaced by the
compiler-driven `.o` cache). Retain only if punit tests still need it.
=== Compiler code
* `uToolchain.pas` — `FindRTLArchive` / `ResolveToolchain.RTLPath`: replace
"find `blaise_rtl.a`" with "find the `compiler/target/rtl/` object dir beside
the compiler" (or, transitional, accept either).
* `blaise.codegen.native.driver.pas` — `LinkViaInternalLinker`: replace
`Lk.AddArchive(TC.RTLPath)` with a loop adding each cached RTL `.o` via
`AddOwnedObject`. Update the "RTL not found" guard message.
* Anything embedding the RTL source path for messages/docs.
=== Self-hosting scripts (must stay green throughout)
* `scripts/fixpoint.sh` — builds the runtime (`cd runtime && make`) as stage-0;
resolves `blaise_rtl.a` for the link steps; uses
`--unit-path runtime/src/main/pascal`. All three change to the new RTL
source location + the `.o`-cache link inputs.
* `scripts/fixpoint-native.sh`, `scripts/fixpoint-native-internal.sh`,
`scripts/fixpoint-warmcache.sh` — each resolves `blaise_rtl.a` and passes
`--unit-path runtime/src/main/pascal`; update both.
* `scripts/rolling-bootstrap.sh` — replays `cd runtime && make BLAISE=… install`
per commit and links `compiler/target/blaise_rtl.a`; update to the new build.
* `.github/workflows/bootstrap.yml` — caches `blaise_rtl.a`, copies it into
release dirs and stage-1 tarballs, and runs
`( cd runtime && make … && make … install )`. Update cache key, copy paths,
and the build step.
=== Release / bootstrap artifacts
* `releases/v*/` currently carry `blaise` + `blaise_rtl.a`. Decide the new
artifact set: `blaise` + a `rtl/` object dir (or a tarball of it). The
release/cut process (`cut-blaise-release` skill, CLAUDE.md "Release binary")
must copy the new artifact. *The current release binary still expects
`blaise_rtl.a`* — so the migrating commit must keep cold-bootstrap working
from the existing release (see <<coldstart>>).
=== Tests (≈12 files) and docs
* Test files passing `--unit-path runtime/src/main/pascal` (cp.test.cli,
cp.test.linker, cp.test.assembler, cp.test.e2e.base, cp.test.strutils,
cp.test.math, cp.test.numerics.*, cp.test.proctypes_ofobject,
cp.test.publishedrtti, cp.test.attributes, cp.test.config): repoint to the
new RTL source path (ideally via a single shared `ProjectRoot`/RTL-path
helper so future moves touch one place).
* `runtime/src/test/pascal/*` (punit programs) — relocate or keep behind a
retained tests-only module.
* Docs: `CLAUDE.md` (layout table, cold-bootstrap, fixpoint steps),
`README.adoc`, `docs/design.adoc`, `docs/testing-strategy.adoc`,
`docs/internal-linker-design.adoc`, `docs/self-contained-start-design.adoc`,
`scripts/BOOTSTRAP.adoc`, and the three target/arch design docs.
== Migration order
Each stage ends with a *full re-verify* — all four fixpoints
(`fixpoint.sh`, `fixpoint-native.sh`, `fixpoint-native-internal.sh`,
`fixpoint-warmcache.sh`) plus the native-built test runner suite — and a
*cold-bootstrap-from-release* check. Do not proceed past a red stage.
. *Move + rename the source, keep the archive.* `git mv` each
`runtime/src/main/pascal/blaise_X.pas` →
`compiler/src/main/pascal/runtime.X.pas` (dotted-flat; `rtl.platform*` and
`system.pas` keep their names), rename the `unit blaise_X;` declaration to
`unit runtime.X;`, and sweep every `uses blaise_X` across the RTL, stdlib,
compiler and tests to `uses runtime.X`. Drop the `../runtime/src/main/pascal`
`<unitPaths>` entry (units now sit in the compiler's own dir). For the still-
present archive build, point the Makefile's `PAS_SRC_DIR` at the new location
(or invoke per-unit there). `blaise_rtl.a` is still produced and linked via
`AddArchive`; emitted symbols are unchanged, so *behaviour is identical*.
This isolates the move+rename from the link-model change.
Verify: all fixpoints + suite green; cold-bootstrap from the existing release.
. *Compiler builds the RTL `.o` cache.* Teach `pasbuild compile -m
blaise-compiler` (or a thin post-build) to compile the RTL units to
`compiler/target/rtl/*.o`. Still archive at link for now; just prove the
cache is produced and correct (diff its `.o` against the Makefile's).
Verify: cache present, byte-equivalent objects.
. *Link from the `.o` cache; drop the archive.* `LinkViaInternalLinker` adds
`compiler/target/rtl/*.o` instead of `AddArchive`. `FindRTLArchive` →
find-the-cache-dir. Remove `ar`/`make install`/the `runtime` module and the
Makefile. Update all scripts + CI + release artifacts to the cache dir.
Verify: all fixpoints + suite + cold-bootstrap green; *measure build time*
vs the archive baseline.
. *Docs + release process.* Update CLAUDE.md, README, design docs, and the
release/cut process to the new layout and artifact set.
[#coldstart]
== Cold-bootstrap ordering (the thing that must not break)
The stage-1 binary is the *previous* release (`releases/v*/blaise`), which today
links `blaise_rtl.a`. Two hazards:
. *The release binary predates the move.* When stage-1 (the old release)
builds the new compiler, it still needs a `blaise_rtl.a` to link the new
compiler binary itself. Stage 1 of the migration keeps the Makefile +
archive precisely so the old release can still cold-bootstrap. The archive
is only *dropped* (stage 3) once a stage-2 binary built from moved source
exists and can produce/consume the `.o` cache.
. *Layout-change rule.* This does not change record/vtable layout, so a
stage-2 rebuild is not strictly required for correctness — but the link-model
change (stage 3) means the stage-1-from-old-release path and the
stage-2-onward path briefly use *different* RTL sources (archive vs cache).
Sequence so that at every commit, the binary that builds the next step has a
working RTL: keep the archive available (built from the new source location)
until the cache-link path is proven, then remove it in the same commit that
switches `AddArchive` → cache and refreshes the `-pre` release artifact to one
that ships the `.o` cache.
Concrete safe sequence: stages 12 are archive-compatible (old release works);
stage 3 is a single commit that (a) switches the link path, (b) updates all
scripts/CI, (c) refreshes `releases/v*-pre/` to carry the `rtl/` cache, and
(d) is validated by `rolling-bootstrap.sh` from a known-good release before any
push.
== Verification matrix
[cols="2,4",options="header"]
|===
| Check | Gate
| `fixpoint.sh` | `FIXPOINT_OK` after each stage
| `fixpoint-native.sh` | `NATIVE_FIXPOINT_OK`
| `fixpoint-native-internal.sh` | `NATIVE_INTERNAL_OK`
| `fixpoint-warmcache.sh` | `WARMCACHE_FIXPOINT_OK`
| native-built TestRunner | full suite `OK` (with `BLAISE_QBE_COMPILER` fresh)
| cold-bootstrap | new compiler builds from the *existing* release binary
| rolling-bootstrap | `./scripts/rolling-bootstrap.sh` green before any push
| build-time | stage-3 link time measured vs archive baseline (perf gate)
|===
[#followups]
== Follow-ups (not in this plan)
* *Compile-per-target RTL / embedded source.* Replace the host-only `.o`
cache with compiling the embedded RTL source per `--target`, so one binary
cross-compiles to any target with nothing staged (the FreeBSD work needs a
FreeBSD-built RTL). Adopt only after measuring; add an in-memory/unit-cache
if recompile cost is visible.
* *Drop the `.o` cache for true in-process linking.* Compile RTL units in the
same process and hand in-memory objects to the linker — removes the cache dir
entirely.
== Open questions
* *punit tests' home.* `runtime/src/test/pascal/*` are Blaise programs using
`punit`. Options: move under `compiler/src/test/`, or keep a minimal
`runtime-tests` module. Leaning: a retained tests-only module so the compiler
module stays focused.
* *Release artifact form.* Ship the `rtl/` dir as-is, or a single tarball/`.a`
re-introduced *only* as a release-packaging convenience (built by the
compiler, not `ar`)? Decide at stage 4.
* *`system.pas` placement.* It is special (implicit `System`); confirm it moves
with the rest and that `bootstrapExclude` handling still applies.