Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Per-module cache investigation

The 2026-10-24 bounded B19/B17 investigation supports keeping the current function/program caches. A scalar-only separate-compilation spike proved reuse and invalidation at an explicit boundary, but was slower than compiling the same computation normally. It is not a production cache implementation.

Frontend reuse remains promising for imports with substantial bodies, especially the standard library. It requires a real lowering boundary; independently packaging today’s modules as C libraries is not a useful shortcut. This report records evidence and constraints. Scheduling remains in the backlog.

Current compiler measurements

Local main at 00d32a13, AMD Ryzen 7 7840HS, Linux x86-64, Rust 1.99.0, release compiler with profile compiled in but inactive during timings. Compiler SHA-256: 61875f0816906d4c5d2577da75bb7f918ad9a26115029bc772d188176d6afc07. Seven repetitions, physical cores 0,2,4,6,8,10,12,14, other agent builds paused. The existing module-edit harness generates ten modules with 100 independent functions each, a caller and an entry point. Its broad-import variant imports std.http.server; the scalar control omits that import.

Object buildBroad import, median msScalar control, median msBroad import peak RSS, KiB
Cache disabled45.5017.1435,348
Populate empty cache47.7317.6636,264
Warm functions44.5415.5633,788
Unchanged program3.994.068,096
Ordinary body edit44.0016.1734,044
Interface and caller edit46.7916.3535,804
Generic body edit44.6716.6635,588

These timings include compiler startup and object writing, but exclude native linking. Every cached artifact matched its uncached counterpart byte for byte, and every source variant executed with its expected output. No-change builds hit the program cache; all three edits missed it. This is a synthetic workload, not a forecast for arbitrary applications.

Separate timing profiles after a body edit recorded:

PhaseBroad importScalar control
Parse all modules8.35 ms / 25 modules2.20 ms / 12 modules
Resolve names3.32 ms1.16 ms
Lower program19.28 ms2.66 ms
Lower functions, inside program lowering10.91 ms / 1,629 functions1.42 ms / 1,004 functions
Independent operation check2.21 ms0.49 ms

Nested profile rows are not additive. The broad-import frontend contains much more reusable work than the scalar control. Avoiding all this work is only an optimistic ceiling: loading, dependency validation, merging and changed-module checking still cost time.

A separate profile-alloc build reported 119.8 MB allocated with caching disabled and 74.5 MB after a body edit (hotpath’s displayed MB units). Parsing allocated 10.4 MB, name resolution 4.8 MB and function-body lowering 19.6 MB after that edit. These are exclusive allocation traffic counters, not retained memory or RSS. Allocation-instrumented latency is excluded from all timing tables. This leaves meaningful allocation work for a frontend cache to avoid, but artifact decoding would introduce allocations of its own.

Five additional broad-import --release repetitions measured optimized executable builds, including linking: 79.77 ms uncached, 33.77 ms unchanged, 73.49 ms body edit, 71.78 ms interface edit and 77.73 ms generic edit. Cached executables again matched uncached executables. Reusing frontend artifacts alone does not eliminate the link floor.

Actual reusable-artifact spike

experiments/module-cache/run.py generates ten scalar modules, each with a single exported i64 function and 100 independent private functions. A separate caller uses the first module. It caches existing static-library outputs using compiler-binary, source, module-name and imported-interface hashes. Integrity checks reject damaged artifacts. A second mode extracts only each archive’s plenty_exports.o, leaving the final application to supply the runtime.

This intentionally uses existing C export/import support rather than modifying the compiler. Interfaces are generated from the fixed fixture, not inferred by a new parser. There are no generic imports, classes, borrowed values, strings, closures or cross-module substitution at the artifact boundary. It proves that an ordinary scalar body edit can reuse callers; it does not prove a general Plenty interface is just its written signatures.

Five repetitions per mode, same compiler and CPU affinity. Each repetition has an empty artifact cache. Existing Plenty caches are disabled to isolate artifact reuse. The ordinary comparator builds the same scalar computation and independent functions through normal imports, also with caching disabled. All rows below include native linking and use default code optimization, unlike --release above. Source generation and execution checks are outside the timings.

BuildFull archive cache, msExtracted object cache, msOrdinary compilation, ms*
Cold1,222.611,236.9748.89
Unchanged191.4996.52—
Ordinary body edit279.73197.8055.36
Interface and caller edit379.55307.3450.31

*Ordinary comparator from the extracted-object run. The independently repeated archive-run comparator was 48.71 / 50.37 / 50.33 ms respectively. These modes were run in separate batches; small differences are not an optimization claim.

An ordinary body edit rebuilt only module m0; its caller and the nine other modules were hits. A signature edit rebuilt m0 and the caller. Rebuilding all artifacts afresh produced the same executable bytes as reuse for each edit. The program produced 42, 43 and 44 in the base, body and interface cases. Corrupting every cached artifact forced rebuilding and restored correct output.

Full archives occupied 174,083,980 bytes for the base program; extracted objects occupied 335,712 bytes. The former repeats the library runtime for each module. Hashing those archives on hits is part of the measurement. Extraction removes that storage/read cost but still creates archives on misses. The extracted mode’s peak compiler-child RSS was about 55 MiB cold/edited and 32 MiB unchanged, versus about 37 MiB for ordinary compilation. This excludes the Python driver’s RSS and is not a total allocation count. Object and archive modes have different linker retention, so executable byte identity is checked within a mode, not between modes or against ordinary compilation.

Repeated compiler processes, export adapters, archive construction and linking outweigh avoided checking in this small workload. Do not adopt this mechanism as the module cache. The experiment does not estimate the speed of a proper in-process lowered-artifact cache, which avoids these costs.

Required boundary and invalidation

The implementation still lowers a whole resolved program. frontend::lower creates aliases, signatures, generic instances and pending work; lowering shares the string heap and records information used by subsequent functions. The module loader preserves dependency order, import resolution and source identities. Persisting a function’s operations while ignoring these outputs is insufficient.

The small check_dependencies.py probe changes only &mut pair.x to &mut pair.y in an imported function. Its declaration stays select(pair: &mut Pair) -> &mut i64, yet an unchanged caller that mutates pair.y changes from accepted to a conflicting-borrow diagnostic. Reverting the body restores acceptance. This directly exercises frontend::references::returned_fields: inferred projections are interface facts.

DependencyWhat must invalidate or be reconstructed
Source/import resolutionModule text, canonical package/module identity, import target changes, compiler identity/settings
Public declarationsSignatures, nominal layouts, aliases, visibility, protocols and generated methods
Body-derived semanticsReturned-reference projections and any effects/capture facts consumed elsewhere
Generic instantiationDefining body, type arguments, constraints and transitive instantiated dependencies
Native substitutionAny body embedded in another function, even if its signature is unchanged
Stored operationsDeterministic string/type/instance identities, source locations and ownership diagnostic origins

A conservative first cache may depend on complete imported source digests; this sacrifices reuse but avoids claiming interface precision prematurely. Precision can follow after an explicit module result can be built and replayed against fresh imported environments. Generic definitions/instances and embedded native bodies need separate dependencies from ordinary declaration hashes. Corrupt/version-mismatched artifacts must miss, and invalidated diagnostics must remain in program order. Existing cache-on/off identity and import-resolution tests remain acceptance gates.

The smallest credible compiler change is therefore to isolate and test a module lowering result without persistence first. Measure replay against normal lowering on an actual std-heavy application before adding serialization. The earlier syntax-tree/copy investigation already argues against inserting a parsed-tree cache instead. This investigation does not authorize a broad redesign or promise a speedup.

Reproduction

Build plenty with cargo build --locked --release --features profile --bin plenty. Run scripts/bench_module_edits.py with --runs 7 --profile, then repeat with --import-std ''; use --runs 5 --release for the optimized executable check. Each command requires a --json PATH. Run the experiment with:

python3 experiments/module-cache/run.py target/release/plenty --runs 5 --json /tmp/archives.json
python3 experiments/module-cache/run.py target/release/plenty --runs 5 --objects --json /tmp/objects.json
python3 experiments/module-cache/check_dependencies.py target/release/plenty

Pin CPUs with taskset as above and avoid concurrent compilation. JSON records every sample, compiler hash, affinity, outputs, reuse decisions and artifact sizes. The existing module-edit harness additionally records separate phase profiles and cache statistics. The spike is single-process orchestration in a temporary directory; it is not a hardened shared cache or a general-purpose module parser.

For allocation counts, rebuild with --features profile-alloc and run the module-edit harness with --runs 1 --profile; keep those results separate from timing runs. No production Rust code changed, so validation here is the executed native experiments, edit matrix, Python syntax checks and Markdown link checks.