From 044c0f0e98964a4b56c715c14bc0507a5ff4b0c4 Mon Sep 17 00:00:00 2001 From: Sebastian Jeong Date: Mon, 17 Aug 2026 01:17:40 +0900 Subject: [PATCH] dev: cache the compiler build and drop unused headers Every run rebuilt fec from scratch inside DOS, about 14 seconds, even when no source had changed. Key a cache on the hash of fec/src plus build-dos.bat and restore FEC.EXE when it matches; the toolchain itself is pinned by dosboxx.lock.json so it cannot drift under a hit. Only a passing build is cached, and the batch skips BUILD.BAT on a hit because it would delete and rebuild the executable it was just given. Generated C included stdio.h unconditionally, but only the M4 writer runtime reaches it. A 26-line unit was pulling in roughly 1900 lines of headers, paid once per compile check. Emit it only when the runtime is emitted. --only m6, 57 passed, over three changes: 31s before 17s core=dynamic 2s warm compiler cache Cold runs still pay the build once. --- fec/src/emit_c.c | 7 ++++++- fec/src/emitcm7.c | 5 ++++- src/ferrolang_vm/dosboxx.py | 33 +++++++++++++++++++++++++++++++-- 3 files changed, 41 insertions(+), 4 deletions(-) diff --git a/fec/src/emit_c.c b/fec/src/emit_c.c index 0e919a1..6d44e33 100644 --- a/fec/src/emit_c.c +++ b/fec/src/emit_c.c @@ -1487,7 +1487,12 @@ void fe_emit_c_program(FeEmitter *e) need_m4=node_uses_m4(e->check->ast->root); for (type=e->check->types.types; type; type=type->next) if (strcmp(type->name,"io.Writer")==0) need_m4=1; - fputs("/* generated by fec M4 */\n#include \n#include \n#include \n#include \ntypedef char fe_assert_u8[(sizeof(unsigned char)==1) ? 1 : -1];\ntypedef char fe_assert_u16[(sizeof(unsigned short)==2) ? 1 : -1];\ntypedef char fe_assert_u32[(sizeof(unsigned long)==4) ? 1 : -1];\n", e->out); + /* stdio is only reached by the M4 writer runtime (fwrite/stdout/stderr). + Parsing it costs far more than the generated body -- a 26-line unit pulls + in about 1900 lines of headers -- so leave it out when nothing uses it. */ + fputs("/* generated by fec M4 */\n#include \n#include \n#include \n", e->out); + if (need_m4) fputs("#include \n", e->out); + fputs("typedef char fe_assert_u8[(sizeof(unsigned char)==1) ? 1 : -1];\ntypedef char fe_assert_u16[(sizeof(unsigned short)==2) ? 1 : -1];\ntypedef char fe_assert_u32[(sizeof(unsigned long)==4) ? 1 : -1];\n", e->out); if (e->pointer_bits==16) fputs("typedef char fe_assert_usize[(sizeof(unsigned short)==2) ? 1 : -1];\n",e->out); else diff --git a/fec/src/emitcm7.c b/fec/src/emitcm7.c index 27ca387..60d16cf 100644 --- a/fec/src/emitcm7.c +++ b/fec/src/emitcm7.c @@ -1327,7 +1327,10 @@ void fe_emit_c_program(FeEmitter *e) need_m4=node_uses_m4(e->check->ast->root); for (type=e->check->types.types;type;type=type->next) if (strcmp(type->name,"io.Writer")==0) need_m4=1; - fputs("/* generated by fec M7 */\n#include \n#include \n#include \n#include \ntypedef char fe_assert_u8[(sizeof(unsigned char)==1) ? 1 : -1];\ntypedef char fe_assert_u16[(sizeof(unsigned short)==2) ? 1 : -1];\ntypedef char fe_assert_u32[(sizeof(unsigned long)==4) ? 1 : -1];\n",e->out); + /* See emit_c.c: stdio only comes in with the M4 writer runtime. */ + fputs("/* generated by fec M7 */\n#include \n#include \n#include \n",e->out); + if (need_m4) fputs("#include \n",e->out); + fputs("typedef char fe_assert_u8[(sizeof(unsigned char)==1) ? 1 : -1];\ntypedef char fe_assert_u16[(sizeof(unsigned short)==2) ? 1 : -1];\ntypedef char fe_assert_u32[(sizeof(unsigned long)==4) ? 1 : -1];\n",e->out); if (e->pointer_bits==16) fputs("typedef char fe_assert_usize[(sizeof(unsigned short)==2) ? 1 : -1];\n",e->out); else diff --git a/src/ferrolang_vm/dosboxx.py b/src/ferrolang_vm/dosboxx.py index 1a30e0f..102b5b3 100644 --- a/src/ferrolang_vm/dosboxx.py +++ b/src/ferrolang_vm/dosboxx.py @@ -151,10 +151,14 @@ def _rc_batch() -> str: return "\r\n".join(lines) -def _batch(cases: list[Case], *, show_dos: bool, trace_dos: bool) -> str: +def _batch(cases: list[Case], *, show_dos: bool, trace_dos: bool, + prebuilt: bool = False) -> str: # The compiler build is the step that fails first and blocks everything after # it, so its output is captured exactly like a case command's. build = "call BUILD.BAT" if trace_dos else "call BUILD.BAT > RESULTS\\BUILD.LOG" + if prebuilt: + # FEC.EXE was restored from cache; BUILD.BAT would delete and rebuild it. + build = "echo OK>BUILD.OK" lines = [ "@echo off", "if not exist RESULTS md RESULTS", "if not exist OUT md OUT", "set WATCOM=W:", "set INCLUDE=W:\\H", @@ -247,6 +251,23 @@ class SuiteRun: shutil.rmtree(self.root, ignore_errors=True) +def _compiler_key() -> str: + """Hash of everything the compiler build reads. + + Sources and the build batch only; the toolchain itself is pinned by + dosboxx.lock.json, so it cannot drift underneath a cache hit. + """ + digest = hashlib.sha256() + paths = sorted((ROOT / "fec" / "src").rglob("*")) + paths.append(ROOT / "fec" / "build-dos.bat") + for path in paths: + if not path.is_file(): + continue + digest.update(path.name.encode("utf-8")) + digest.update(path.read_bytes()) + return digest.hexdigest()[:16] + + def run_suite(cases: list[Case], *, keep: bool = False, show_dos: bool = False, trace_dos: bool = False) -> SuiteRun: dosbox, watcom = resolve_tools() @@ -254,6 +275,7 @@ def run_suite(cases: list[Case], *, keep: bool = False, show_dos: bool = False, run_root = Path(tempfile.mkdtemp(prefix="suite-", dir=RUNS)) result = SuiteRun(run_root, cases, keep) fec = result.fec + cached = CACHE / "compilers" / f"{_compiler_key()}.exe" try: shutil.copytree(ROOT / "fec" / "src", fec / "SRC") shutil.copytree(ROOT / "fec" / "std", fec / "STD") @@ -271,8 +293,11 @@ def run_suite(cases: list[Case], *, keep: bool = False, show_dos: bool = False, f"[cpu]\ncore=dynamic\ncycles=max\n", encoding="ascii", ) + if cached.is_file(): + shutil.copy2(cached, fec / "FEC.EXE") (fec / "RUN.BAT").write_text( - _batch(cases, show_dos=show_dos, trace_dos=trace_dos), + _batch(cases, show_dos=show_dos, trace_dos=trace_dos, + prebuilt=cached.is_file()), encoding="ascii", newline="", ) (fec / "RC.BAT").write_text(_rc_batch(), encoding="ascii", newline="") @@ -293,6 +318,10 @@ def run_suite(cases: list[Case], *, keep: bool = False, show_dos: bool = False, raise DosboxError(f"DOSBox-X exited with status {completed.returncode}") if not (fec / "RUN.OK").is_file(): raise DosboxError("DOSBox-X did not complete the test batch") + built = fec / "FEC.EXE" + if not cached.is_file() and built.is_file() and result.result() == "PASS": + cached.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(built, cached) return result except Exception: result.keep = True