diff --git a/docs/content/docs/how-it-works.mdx b/docs/content/docs/how-it-works.mdx index 8196460cf..13b8b5a90 100644 --- a/docs/content/docs/how-it-works.mdx +++ b/docs/content/docs/how-it-works.mdx @@ -34,7 +34,7 @@ Runtime pack and linker -> executable ## Build parallelism -Most of a build runs on one CPU core. The LLVM helper uses more cores when it compiles an executable in partitions, and when it generates code for an optimized executable of one partition. +Most of a build runs on one CPU core. The LLVM helper uses more cores when it compiles an executable in partitions, and when it generates code for an optimized executable that it optimizes whole. ### Frontend @@ -42,16 +42,15 @@ Parsing, type checking, lowering, and LLVM IR generation run one after another o ### Partitions -The LLVM helper divides an executable's LLVM IR into partitions by its size. Up to 1 MiB of LLVM text is one partition, and up to 2 MiB is four. Larger programs get one partition per 4 MiB of LLVM text, rounded up to a power of two, and at least 8 and at most 64. `--emit=llvm` writes this text, so its file size shows how many partitions a program gets. `dev` executables, whose partitions are compiled on their own with nothing imported, are divided from 64 KiB: up to 128 KiB is four partitions, and larger `dev` programs get at least 8. +The LLVM helper divides an executable's LLVM IR into partitions by its size. Up to 128 KiB of LLVM text is one partition. Larger programs get one partition per 4 MiB of LLVM text, rounded up to a power of two, and at least 8 and at most 64. `--emit=llvm` writes this text, so its file size shows how many partitions a program gets. `dev` executables, whose partitions are compiled on their own with nothing imported, are divided from 64 KiB: up to 128 KiB is four partitions. An edit compiles again only the partitions whose code it changes, or that inline a function it changes. - - - + + @@ -60,6 +59,8 @@ The LLVM helper divides an executable's LLVM IR into partitions by its size. Up The count depends only on the size of the LLVM text, so a program has the same partitions, and builds the same executable, on every host. In programs with 32 partitions or more, functions from the same source file are placed in the same partition, so that their calls to each other stay inside it; a file larger than 1 MiB of LLVM text or than an average partition is divided into parts by function name. Other functions, and the functions of smaller programs, are placed by their name, and a small internal function goes with the one function that calls it. An edit leaves almost every other function in its partition. +An optimized partition is optimized twice: on its own, and again after the small functions of other partitions it calls are imported for inlining. So in optimized builds, a program whose largest partition would hold more than two thirds of its function text is optimized whole instead, as one partition, and its code is generated in parts on several threads. + These builds are not divided into partitions and compile on one thread: `--emit=asm`, `--emit=obj`, and `--print=native-link-info` output, library builds (`--lib`; `dev` library builds are compiled in pieces, one after another), and `--sanitize` builds, which compile the LLVM IR with the C compiler. The helper compiles partitions on a pool with one thread per hardware thread of the host, so an executable with four partitions uses at most four cores in the backend. @@ -71,26 +72,15 @@ How partitions are compiled depends on `--optimization`: Reading the LLVM text and dividing it into partitions, and the import step, run on one thread. -### Code generation of one partition +### Code generation of a program optimized whole -A `release` or `speed` executable of one partition is optimized as one module on one thread. When its LLVM text is larger than 128 KiB, the helper then divides the optimized module for code generation and generates machine code for the parts in parallel, on the same pool. Each part holds a run of consecutive functions with about the same amount of code, so the executable keeps the functions in the same order. The module is optimized before it is divided, so each function's machine code is the same as from one thread, except that a jump to a function that another part also uses takes the longer form. When one function holds most of the code, the first part holds the whole module and the others are empty. +A `release` or `speed` executable whose largest partition would hold more than two thirds of its function text is optimized as one module on one thread. The helper then divides the optimized module for code generation into as many parts as the program has partitions and generates machine code for the parts in parallel, on the same pool. Each part holds a run of consecutive functions with about the same amount of code, so the executable keeps the functions in the same order. The module is optimized before it is divided, so each function's machine code is the same as from one thread, except that a jump to a function that another part also uses takes the longer form. When one function holds most of the code, the first part holds the whole module and the others are empty. -
LLVM textPartitions
Up to 1 MiB1
Over 1 MiB, up to 2 MiB4
Over 2 MiB, up to 32 MiB8
Up to 128 KiB1
Over 128 KiB, up to 32 MiB8
Over 32 MiB, up to 64 MiB16
Over 64 MiB, up to 128 MiB32
Over 128 MiB64
- - - - - - - - -
LLVM textCode generation parts
Up to 128 KiB1
Over 128 KiB, up to 256 KiB4
Over 256 KiB, up to 1 MiB8
- -Like partitions, the count depends only on the size of the LLVM text. A `dev` executable of one partition is compiled on one thread. +An executable of one partition (up to 128 KiB of LLVM text, or 64 KiB in `dev` builds) is compiled on one thread. ### Rebuilds -With two or more partitions, the helper keeps each partition's summary and object in the build cache (see `SCRIPTC_CACHE_DIR` in the [CLI reference](/docs/cli#environment-variables)). A rebuild after an edit compiles again only the partitions whose functions changed and, in `release` and `speed` builds, the partitions that import a changed function; the others come from the cache. Dividing the program, the import step, and linking run on every build. A cold build, with nothing cached, compiles every partition. +With two or more partitions, the helper keeps each partition's summary and object in the build cache (see `SCRIPTC_CACHE_DIR` in the [CLI reference](/docs/cli#environment-variables)). A rebuild after an edit compiles again only the partitions whose functions changed and, in `release` and `speed` builds, the partitions that import a changed function; the others come from the cache. Dividing the program, the import step, and linking run on every build. A cold build, with nothing cached, compiles every partition. A program optimized whole is compiled again whole after any edit. A program of one partition has no partition cache: a rebuild after an edit compiles the whole program again, and a rebuild that leaves the LLVM text unchanged takes the compiled code from the build cache. diff --git a/native/llvm-codegen/src/emit.cpp b/native/llvm-codegen/src/emit.cpp index 98e07e9ce..ee9e6fae0 100644 --- a/native/llvm-codegen/src/emit.cpp +++ b/native/llvm-codegen/src/emit.cpp @@ -812,9 +812,10 @@ compileOptimizedPartitions(const EmitOptions &Options, P.countAnnotated(PartitionMatch.Annotated); ModulePassManager MPM = P.PB.buildThinLTOPreLinkDefaultPipeline(Level); MPM.addPass(UnnameStructTypes()); + // The module hash is set at the thin link instead (see Names). MPM.addPass(BitcodeWriterPass(Stream, /*ShouldPreserveUseListOrder=*/false, /*EmitSummaryIndex=*/true, - /*EmitModuleHash=*/true)); + /*EmitModuleHash=*/false)); MPM.run(Mod, P.MAM); } if (Annotating) { @@ -921,6 +922,24 @@ compileOptimizedPartitions(const EmitOptions &Options, std::vector Names(Count); for (size_t I = 0; I < Count; ++I) Names[I] = "partition-" + std::to_string(I); + // An internal function or variable that another partition imports a + // reference to is renamed .llvm.. + // A hash of the module's contents would change that name in every + // importing partition after any edit of the exporting one, and compile + // them all again. Each module's hash is its partition number instead, + // which keeps the names unique within the program and the same across + // builds. + Config.CombinedIndexHook = [&Names](const ModuleSummaryIndex &Index, + const DenseSet &) { + StringMap &Paths = + const_cast(Index).modulePaths(); + for (size_t I = 0; I < Names.size(); ++I) { + auto It = Paths.find(Names[I]); + if (It != Paths.end()) + It->second = {0, static_cast(I + 1), 0, 0, 0}; + } + return true; + }; limitImports(); if (!Options.ProfileUse.empty()) limitProfileImports(); @@ -1060,8 +1079,22 @@ int emitPartitions(const EmitOptions &Options) { MemoryBuffer::getFile(Options.Input, /*IsText=*/false, /*RequiresNullTerminator=*/false); if (Input) { + bool Optimized = Options.OptLevel != "0"; std::optional Text = partitionProgramText( - (*Input)->getBuffer(), static_cast(Options.Outputs.size())); + (*Input)->getBuffer(), static_cast(Options.Outputs.size()), + /*SkipDominated=*/Optimized); + // An optimized partition is simplified before the thin link and + // optimized again after it, so one that holds more than two thirds of + // the program's function text takes longer than optimizing the module + // whole: such a module is optimized whole, and its code is generated in + // parts. + if (Text && Optimized && dominatedPartitions(Text->FunctionBytes)) { + EmitOptions Whole = Options; + Whole.SplitCodegen = true; + Whole.CacheDirectory.clear(); + Input->reset(); + return emit(Whole); + } if (Text) { Input->reset(); PartitionPlan Plan{std::move(Text->Sources), @@ -1340,6 +1373,44 @@ emitSplitCodegen(const EmitOptions &Options, Module &M, TargetMachine &Machine, } // namespace +std::optional optimizeModule(Module &M, TargetMachine &Machine, + const std::string &OptLevel, + bool Instrument, + const std::string &ProfileGenerate, + const std::string &ProfileUse, + ModuleProfileMatch &Result) { + OptimizationLevel Level = optimizationLevel(OptLevel); + std::optional Profile; + if (Instrument) { + Profile = PGOOptions(ProfileGenerate, "", "", "", PGOOptions::IRInstr); + } else if (!ProfileUse.empty()) { + EmitOptions Inspected; + Inspected.ProfileUse = ProfileUse; + if (std::optional Failure = inspectProfile(Inspected)) + return Failure->Message; + reportMissingProfiles(); + Profile = PGOOptions(ProfileUse, "", "", "", PGOOptions::IRUse); + } + bool Annotating = Profile && Profile->Action == PGOOptions::IRUse; + ProfileMatch Match; + if (Annotating) + M.getContext().setDiagnosticHandler( + std::make_unique(Match)); + { + Pipeline P(Machine, Level, std::move(Profile)); + if (Annotating) + P.countAnnotated(Match.Annotated); + P.PB.buildPerModuleDefaultPipeline(Level).run(M, P.MAM); + } + Result.Annotated = Match.Annotated.load(); + Result.Mismatched = Match.Mismatched.load(); + Result.Missing = Match.Missing.load(); + if (std::optional Failure = + verify(M, "post_optimization_verification_failed")) + return Failure->Message; + return std::nullopt; +} + int emit(const EmitOptions &Options) { if (!supportsTarget(Options.Target)) return reportError("unsupported_target", diff --git a/native/llvm-codegen/src/emit.h b/native/llvm-codegen/src/emit.h index 1bdc578df..dd02cfd98 100644 --- a/native/llvm-codegen/src/emit.h +++ b/native/llvm-codegen/src/emit.h @@ -1,9 +1,15 @@ #pragma once +#include #include #include #include +namespace llvm { +class Module; +class TargetMachine; +} // namespace llvm + namespace scriptc { struct EmitOptions { @@ -36,4 +42,27 @@ struct EmitOptions { std::optional parseEmitOptions(int Argc, char **Argv); int emit(const EmitOptions &Options); +// How a profile matched the functions of one optimized module: functions +// that received its counts, functions whose control flow changed since it +// was collected, and functions it has no record of. +struct ModuleProfileMatch { + uint64_t Annotated = 0; + uint64_t Mismatched = 0; + uint64_t Missing = 0; +}; + +// Optimizes `M` with the per-module default pipeline at `OptLevel`, as `emit` +// does for a whole program, with IR instrumentation when `Instrument` is set +// or annotation from the indexed `ProfileUse` profile. Instrumentation writes +// raw profiles to the `ProfileGenerate` pattern; an empty pattern names no +// file, so the program that links the module names it. Returns an error +// message, or nothing on success. +std::optional optimizeModule(llvm::Module &M, + llvm::TargetMachine &Machine, + const std::string &OptLevel, + bool Instrument, + const std::string &ProfileGenerate, + const std::string &ProfileUse, + ModuleProfileMatch &Match); + } // namespace scriptc diff --git a/native/llvm-codegen/src/main.cpp b/native/llvm-codegen/src/main.cpp index 57416106a..d9481395d 100644 --- a/native/llvm-codegen/src/main.cpp +++ b/native/llvm-codegen/src/main.cpp @@ -58,6 +58,9 @@ int version(int Argc, char **Argv) { {"supported_targets", std::move(SupportedTargets)}, {"default_target", scriptc::DefaultTarget}, {"data_layout", Machine->createDataLayout().getStringRepresentation()}, + // Commands and options added within one protocol version, for callers + // that also run against older helpers. + {"features", json::Array{"runtime-unit-profile", "profile-functions"}}, }; outs() << formatv("{0}\n", json::Value(std::move(Response))); return 0; @@ -80,6 +83,8 @@ int main(int Argc, char **Argv) { return scriptc::pruneImport(Argc, Argv); if (Argc >= 2 && StringRef(Argv[1]) == "merge-profiles") return scriptc::mergeProfiles(Argc, Argv); + if (Argc >= 2 && StringRef(Argv[1]) == "profile-functions") + return scriptc::profileFunctions(Argc, Argv); if (Argc >= 2 && StringRef(Argv[1]) == "emit") { std::optional Options = scriptc::parseEmitOptions(Argc, Argv); @@ -95,5 +100,6 @@ int main(int Argc, char **Argv) { } return scriptc::reportError("usage", "expected 'version --format=json', 'emit', 'runtime-unit', " - "'prune-import', or 'merge-profiles'"); + "'prune-import', 'merge-profiles', or " + "'profile-functions'"); } diff --git a/native/llvm-codegen/src/partition.cpp b/native/llvm-codegen/src/partition.cpp index ef5230129..9fdaeba9a 100644 --- a/native/llvm-codegen/src/partition.cpp +++ b/native/llvm-codegen/src/partition.cpp @@ -392,8 +392,18 @@ void appendRenumbered(StringRef Text, const DenseMap &Map, } // namespace +bool dominatedPartitions(ArrayRef FunctionBytes) { + uint64_t Total = 0, Largest = 0; + for (uint64_t Bytes : FunctionBytes) { + Total += Bytes; + Largest = std::max(Largest, Bytes); + } + return Largest * 3 > Total * 2; +} + std::optional partitionProgramText(StringRef Text, - unsigned Count) { + unsigned Count, + bool SkipDominated) { if (Count < 2) return std::nullopt; std::vector Header, Types, Attributes, NamedMetadata; @@ -587,10 +597,18 @@ std::optional partitionProgramText(StringRef Text, // chain through a global continues to the function that uses the global. // A function body larger than this is past LLVM's inline cost limit even // for the only call to a local function, so it is placed by its own name. - // A chain stops where it would leave a function's source-module group: - // module initializers, which only the entry point calls, keep their own - // partitions instead of all sharing the entry point's. + // A chain stops where it would leave a function's source-module group for + // a function outside every group. In a module of more than + // GroupedChainBytes of LLVM text, it also stops where it would join a + // function of another group: module initializers, which only the entry + // point calls, keep their own partitions instead of all sharing the entry + // point's. In a smaller module, a function that one function of another + // source file calls stays with it, so it can still be inlined there as in + // the whole module; when that gathers most of the program into one + // partition, the program is optimized whole (see emitPartitions). constexpr size_t MaxInlinedBodyBytes = 256 * 1024; + constexpr size_t GroupedChainBytes = 1024 * 1024; + bool GroupedChains = Text.size() > GroupedChainBytes; constexpr unsigned Shared = ~1u; std::vector Referrer(Symbols.size(), NoPartition); for (unsigned I = 0; I < Symbols.size(); ++I) { @@ -611,7 +629,8 @@ std::optional partitionProgramText(StringRef Text, if (S.Kind == SymbolKind::Function && (S.Body.size() > MaxInlinedBodyBytes || (Symbols[Parent].Kind == SymbolKind::Function && !S.Group.empty() && - S.Group != Symbols[Parent].Group))) + S.Group != Symbols[Parent].Group && + (GroupedChains || Symbols[Parent].Group.empty())))) return false; return true; }; @@ -663,10 +682,41 @@ std::optional partitionProgramText(StringRef Text, Result.PublicSymbols.insert(S.Name); Result.Sources.resize(Count); + Result.FunctionBytes.assign(Count, 0); std::vector> Owned(Count); for (unsigned I = 0; I < Symbols.size(); ++I) - if (Symbols[I].Kind != SymbolKind::Declaration && Live[I]) + if (Symbols[I].Kind != SymbolKind::Declaration && Live[I]) { Owned[Symbols[I].Partition].push_back(I); + if (Symbols[I].Kind == SymbolKind::Function) + Result.FunctionBytes[Symbols[I].Partition] += + Symbols[I].Header.size() + Symbols[I].Body.size(); + } + // The caller optimizes such a module whole, so its partitions' text is + // not needed. + if (SkipDominated && dominatedPartitions(Result.FunctionBytes)) { + Result.Sources.clear(); + return Result; + } + + // An internal definition stays internal, so its partition's optimizer can + // inline a function into its last caller and delete it as the whole module + // would. It is promoted to hidden visibility only when a definition of + // another partition or a metadata node names it. + std::vector Exported(Symbols.size(), 0); + for (unsigned I : NamedSymbols) + Exported[I] = 1; + for (auto &Entry : Metadata) + for (unsigned I : Entry.second.Symbols) + Exported[I] = 1; + for (unsigned I = 0; I < Symbols.size(); ++I) { + const Symbol &S = Symbols[I]; + if (S.Kind == SymbolKind::Declaration || !Live[I]) + continue; + for (unsigned Ref : S.Refs) + if (Symbols[Ref].Kind != SymbolKind::Declaration && + Symbols[Ref].Partition != S.Partition) + Exported[Ref] = 1; + } auto Assemble = [&](unsigned P) { std::vector Needed(Symbols.size(), 0); @@ -731,7 +781,7 @@ std::optional partitionProgramText(StringRef Text, const Symbol &S = Symbols[I]; if (S.Kind != Kind) continue; - Line(S.Local ? StringRef(S.PromotedHeader) : S.Header); + Line(S.Local && Exported[I] ? StringRef(S.PromotedHeader) : S.Header); if (!S.Body.empty()) Line(S.Body); } diff --git a/native/llvm-codegen/src/partition.h b/native/llvm-codegen/src/partition.h index dc000d751..0756469e8 100644 --- a/native/llvm-codegen/src/partition.h +++ b/native/llvm-codegen/src/partition.h @@ -1,8 +1,10 @@ #pragma once +#include "llvm/ADT/ArrayRef.h" #include "llvm/ADT/StringRef.h" #include "llvm/ADT/StringSet.h" +#include #include #include #include @@ -14,9 +16,11 @@ struct ProgramPartitions { // One complete LLVM assembly module per partition. std::vector Sources; // Definitions that were not internal in the original module. Every other - // definition was internal and is promoted to hidden visibility only so the - // partitions can reference each other. + // definition was internal, and is promoted to hidden visibility only when + // another partition references it. llvm::StringSet<> PublicSymbols; + // The bytes of the function definitions placed in each partition. + std::vector FunctionBytes; }; // Divides the LLVM assembly scriptc emits into Count modules without parsing @@ -27,13 +31,21 @@ struct ProgramPartitions { // functions are placed by a stable hash of their name. An internal // definition referenced by exactly one other definition stays with it, also // through a global, unless it is a function whose body is too large to -// inline or that would join a function of another source-module group; a -// global otherwise stays with the first function that references it. +// inline, that would leave its source-module group for a function outside +// every group, or, in a module of more than 1 MiB, that would join a function +// of another source-module group; a global otherwise stays with the first +// function that references it. // Internal definitions that no non-internal definition, named metadata or // metadata node reaches are left out of every partition. Returns nullopt // for any construct outside the emitter's narrow top-level grammar; callers -// then split a parsed module. +// then split a parsed module. With SkipDominated, a division that +// dominatedPartitions rejects has no Sources. std::optional partitionProgramText(llvm::StringRef Text, - unsigned Count); + unsigned Count, + bool SkipDominated); + +// Whether the largest partition would hold more than two thirds of the +// program's function bytes. +bool dominatedPartitions(llvm::ArrayRef FunctionBytes); } // namespace scriptc diff --git a/native/llvm-codegen/src/profile.cpp b/native/llvm-codegen/src/profile.cpp index b7df1445d..e06d051c9 100644 --- a/native/llvm-codegen/src/profile.cpp +++ b/native/llvm-codegen/src/profile.cpp @@ -2,10 +2,14 @@ #include "diagnostics.h" #include "llvm/ADT/SmallString.h" +#include "llvm/ADT/StringSet.h" #include "llvm/ADT/StringRef.h" #include "llvm/ProfileData/InstrProfReader.h" #include "llvm/ProfileData/InstrProfWriter.h" #include "llvm/Support/FileSystem.h" +#include "llvm/Support/FormatVariadic.h" +#include "llvm/Support/JSON.h" +#include "llvm/Support/MemoryBuffer.h" #include "llvm/Support/VirtualFileSystem.h" #include "llvm/Support/raw_ostream.h" @@ -19,6 +23,7 @@ namespace scriptc { int mergeProfiles(int Argc, char **Argv) { std::string Output; bool Sparse = false; + std::string KeepFunctions; std::vector Inputs; for (int I = 2; I < Argc; ++I) { StringRef Arg(Argv[I]); @@ -26,17 +31,35 @@ int mergeProfiles(int Argc, char **Argv) { Output = Argv[++I]; else if (Arg == "--sparse") Sparse = true; + else if (Arg == "--keep-functions" && I + 1 < Argc) + KeepFunctions = Argv[++I]; else if (!Arg.starts_with("--")) Inputs.push_back(Arg.str()); else return reportError("usage", "merge-profiles requires --output " "and one or more input profiles, and " - "accepts --sparse"); + "accepts --sparse and --keep-functions " + ""); } if (Output.empty() || Inputs.empty()) return reportError("usage", "merge-profiles requires --output and " "one or more input profiles"); + // --keep-functions names the functions to keep, one profile name per + // line; the records of every other function are left out. + StringSet<> Kept; + if (!KeepFunctions.empty()) { + ErrorOr> List = + MemoryBuffer::getFile(KeepFunctions); + if (!List) + return reportError("profile_read_failed", + KeepFunctions + ": " + List.getError().message()); + SmallVector Names; + (*List)->getBuffer().split(Names, '\n', -1, /*KeepEmpty=*/false); + for (StringRef Name : Names) + Kept.insert(Name); + } + // Sparse profiles leave out the functions that never ran, which then // optimize as without a profile instead of as cold. InstrProfWriter Writer(Sparse); @@ -52,6 +75,8 @@ int mergeProfiles(int Argc, char **Argv) { Path + ": " + toString(std::move(E))); std::string RecordError; for (NamedInstrProfRecord &Record : **Reader) { + if (!KeepFunctions.empty() && !Kept.contains(Record.Name)) + continue; Writer.addRecord(std::move(Record), /*Weight=*/1, [&](Error E) { if (RecordError.empty()) RecordError = toString(std::move(E)); @@ -100,4 +125,26 @@ int mergeProfiles(int Argc, char **Argv) { return 0; } +int profileFunctions(int Argc, char **Argv) { + if (Argc != 4 || StringRef(Argv[2]) != "--input") + return reportError("usage", + "profile-functions requires --input "); + std::string Path = Argv[3]; + IntrusiveRefCntPtr FS = vfs::getRealFileSystem(); + Expected> Reader = + InstrProfReader::create(Path, *FS); + if (!Reader) + return reportError("profile_read_failed", + Path + ": " + toString(Reader.takeError())); + json::Array Functions; + for (const NamedInstrProfRecord &Record : **Reader) + Functions.push_back(Record.Name.str()); + if ((*Reader)->hasError()) + return reportError("profile_read_failed", + Path + ": " + toString((*Reader)->getError())); + json::Object Response{{"ok", true}, {"functions", std::move(Functions)}}; + outs() << formatv("{0}\n", json::Value(std::move(Response))); + return 0; +} + } // namespace scriptc diff --git a/native/llvm-codegen/src/profile.h b/native/llvm-codegen/src/profile.h index 1cad1c54b..8602889a3 100644 --- a/native/llvm-codegen/src/profile.h +++ b/native/llvm-codegen/src/profile.h @@ -2,9 +2,14 @@ namespace scriptc { -// `merge-profiles --output [--sparse] ...`: merges raw -// (.profraw) or indexed (.profdata) IR profiles into one indexed profile. -// --sparse leaves out functions that never ran. +// `merge-profiles --output [--sparse] [--keep-functions +// ] ...`: merges raw (.profraw) or indexed (.profdata) IR +// profiles into one indexed profile. --sparse leaves out functions that never +// ran, and --keep-functions every function the file does not name. int mergeProfiles(int Argc, char **Argv); +// `profile-functions --input `: prints the names of the +// functions an indexed profile has records for, as {"ok":true,"functions":[]}. +int profileFunctions(int Argc, char **Argv); + } // namespace scriptc diff --git a/native/llvm-codegen/src/runtime-unit.cpp b/native/llvm-codegen/src/runtime-unit.cpp index e92c384ee..d97638c84 100644 --- a/native/llvm-codegen/src/runtime-unit.cpp +++ b/native/llvm-codegen/src/runtime-unit.cpp @@ -13,6 +13,7 @@ #include "runtime-unit.h" #include "diagnostics.h" +#include "emit.h" #include "runtime-import.h" #include "target.h" @@ -27,12 +28,16 @@ #include "llvm/IR/Module.h" #include "llvm/IR/Verifier.h" #include "llvm/IRReader/IRReader.h" +#include "llvm/ProfileData/InstrProf.h" #include "llvm/Support/FileSystem.h" +#include "llvm/Support/FormatVariadic.h" +#include "llvm/Support/JSON.h" #include "llvm/Support/SourceMgr.h" #include "llvm/Support/raw_ostream.h" #include "llvm/Target/TargetMachine.h" #include "llvm/TargetParser/Triple.h" #include "llvm/Transforms/Utils/Cloning.h" +#include "llvm/Transforms/Utils/SizeOpts.h" #include #include @@ -53,6 +58,16 @@ struct UnitOptions { std::string Tag; bool FunctionSections = false; bool DataSections = false; + // When set, the input is unoptimized and the helper optimizes it at this + // level, with the profile-guided optimization below; otherwise the input + // is already optimized and is only promoted and emitted. + std::string OptLevel; + // Instruments the unit for IR profiling; raw profiles go to this pattern, + // or, when it is empty, to the file the program that links the unit names. + bool ProfileGenerate = false; + std::string ProfileGeneratePattern; + // Indexed profile (.profdata) that guides the unit's optimization. + std::string ProfileUse; }; std::optional parse(int Argc, char **Argv) { @@ -80,12 +95,26 @@ std::optional parse(int Argc, char **Argv) { Options.Target = Value.str(); else if (Arg == "--tag") Options.Tag = Value.str(); + else if (Arg == "--opt-level") + Options.OptLevel = Value.str(); + else if (Arg == "--profile-generate") { + Options.ProfileGenerate = true; + Options.ProfileGeneratePattern = Value.str(); + } else if (Arg == "--profile-use") + Options.ProfileUse = Value.str(); else return std::nullopt; } if (Options.Input.empty() || Options.Object.empty() || Options.Bitcode.empty() || Options.Target.empty() || Options.Tag.empty()) return std::nullopt; + // A profile only guides an optimization the helper runs, and a unit is + // either instrumented or annotated. + if ((Options.ProfileGenerate || !Options.ProfileUse.empty()) && + Options.OptLevel.empty()) + return std::nullopt; + if (Options.ProfileGenerate && !Options.ProfileUse.empty()) + return std::nullopt; return Options; } @@ -98,10 +127,22 @@ bool keepLocal(const GlobalValue &GV) { return false; } +// The profile's counters and per-function records (instrumented units only) +// stay private to the unit: the profile runtime finds them by section. +bool profileData(const GlobalValue &GV) { + if (const auto *Object = dyn_cast(&GV)) + if (Object->getSection().starts_with("__llvm_prf")) + return true; + return GV.getName().starts_with("__llvm_prf") || + GV.getName().starts_with("__profc_") || + GV.getName().starts_with("__profd_") || + GV.getName().starts_with("__profvp_"); +} + void promoteLocals(Module &M, StringRef Tag) { unsigned Anonymous = 0; auto Promote = [&](GlobalValue &GV) { - if (!GV.hasLocalLinkage() || keepLocal(GV)) + if (!GV.hasLocalLinkage() || keepLocal(GV) || profileData(GV)) return; std::string Base = GV.hasName() ? GV.getName().str() : ("anon." + Twine(Anonymous++)).str(); @@ -157,7 +198,9 @@ int runtimeUnit(int Argc, char **Argv) { return fail("usage", "runtime-unit requires --input --object --bitcode " "--target --tag and accepts " - "--function-sections --data-sections"); + "--function-sections --data-sections, and --opt-level " + "with either --profile-generate or --profile-use " + ""); LLVMContext Context; SMDiagnostic Diagnostic; std::unique_ptr M = parseIRFile(Options->Input, Diagnostic, Context); @@ -208,6 +251,42 @@ int runtimeUnit(int Argc, char **Argv) { if (F.getFnAttribute("probe-stack").getValueAsString() == "__chkstk_darwin") F.addFnAttr("probe-stack", "inline-asm"); + // Profiles name a unit's internal functions after its source file. The + // unit's own name keeps those names the same in every checkout, so a + // committed profile matches wherever the pack is built. + if (!Options->OptLevel.empty()) { + M->setSourceFileName(Options->Tag + ".c"); + // The runtime profile comes from a fixed set of training programs, and a + // runtime function they rarely call can be hot in another program (a + // long UTF-8 decode, a busy worker channel). Code the profile finds cold + // is therefore optimized for speed as without a profile, not for size: + // the profile still guides inlining, block layout and branch weights. + EnablePGSO.setValue(false); + // The profile names of the functions an instrumented unit defines, so + // a merged profile can keep the runtime's records and drop the + // training programs'. + json::Array Functions; + if (Options->ProfileGenerate) + for (const Function &F : *M) + if (!F.isDeclaration() && !F.hasAvailableExternallyLinkage()) + Functions.push_back(getIRPGOFuncName(F)); + ModuleProfileMatch Match; + if (std::optional Failure = optimizeModule( + *M, *Machine, Options->OptLevel, Options->ProfileGenerate, + Options->ProfileGeneratePattern, Options->ProfileUse, Match)) + return fail("optimization_failed", *Failure); + if (!Options->ProfileUse.empty()) { + json::Object Profile{{"annotated", Match.Annotated}, + {"mismatched", Match.Mismatched}, + {"missing", Match.Missing}}; + json::Object Response{{"ok", true}, {"profile", std::move(Profile)}}; + outs() << formatv("{0}\n", json::Value(std::move(Response))); + } else if (Options->ProfileGenerate) { + json::Object Response{{"ok", true}, {"functions", std::move(Functions)}}; + outs() << formatv("{0}\n", json::Value(std::move(Response))); + } + } + promoteLocals(*M, Options->Tag); std::string VerifyError; raw_string_ostream VerifyStream(VerifyError); diff --git a/packages/compiler/src/backend/llvm/class-unions.test.ts b/packages/compiler/src/backend/llvm/class-unions.test.ts new file mode 100644 index 000000000..1a987057b --- /dev/null +++ b/packages/compiler/src/backend/llvm/class-unions.test.ts @@ -0,0 +1,142 @@ +import { execFileSync } from "node:child_process"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { expect, test } from "vitest"; +import { F64, VOID, type IrModule, type IrType } from "../../ir/ir.js"; +import { mangleClassNew, mangleClassRelease, mangleClassRetain } from "../mangle.js"; +import { emitLlvmModule } from "./emitter.js"; +import { classReleaseFn, classRetainFn, classTagFn } from "./nullable-unions.js"; + +const loc = { file: "class-unions.ts", start: 0, end: 1 }; +const tree: IrType = { kind: "union", unionId: "tree" }; + +/** `Leaf | Pair`, two standalone classes, and a hierarchy beside them. */ +function treeModule(): IrModule { + return { + irVersion: 17, + sourceFile: loc.file, + entry: "main", + functions: [{ name: "main", params: [], returnType: VOID, locals: [], body: [], loc }], + classes: [ + { name: "Leaf", fields: [{ name: "value", type: F64 }], methods: [], loc }, + { + name: "Pair", + fields: [ + { name: "left", type: tree }, + { name: "right", type: tree }, + ], + methods: [], + loc, + }, + { name: "Other", fields: [{ name: "value", type: F64 }], methods: [], loc }, + ], + unions: [ + { + id: "tree", + arms: [ + { kind: "object", className: "Leaf" }, + { kind: "object", className: "Pair" }, + ], + }, + ], + }; +} + +function definition(llvm: string, symbol: string): string { + const escaped = symbol.replace(/[.$]/g, "\\$&"); + const body = new RegExp(`^define internal [^@]*@${escaped}\\([^]*?^}`, "m").exec(llvm)?.[0]; + expect(body, symbol).toBeDefined(); + return body!; +} + +test("standalone arms keep their class id in the rc word's high half", () => { + const llvm = emitLlvmModule(treeModule(), { pointerBits: 64 }); + // Arms store the id beside a count of 1; other standalone classes keep a + // plain count. + expect(definition(llvm, mangleClassNew("Leaf"))).toMatch(/store i64 \d{10,}, ptr %o\.vt/); + expect(definition(llvm, mangleClassNew("Other"))).toMatch(/store i64 1, ptr %o/); + expect(definition(llvm, mangleClassRetain("Leaf"))).toContain("load i32, ptr %o"); + expect(definition(llvm, mangleClassRetain("Other"))).toContain("load i64, ptr %o"); + // The tag reads the id at byte 4 and switches on it. + const tag = definition(llvm, classTagFn("tree").slice(1)); + expect(tag).toContain("getelementptr inbounds i8, ptr %o, i64 4"); + expect(tag).toMatch(/switch i32 %id/); + // 32-bit targets keep the box and the plain count. + const llvm32 = emitLlvmModule(treeModule(), { pointerBits: 32 }); + expect(llvm32).not.toContain(classTagFn("tree")); + expect(definition(llvm32, mangleClassRetain("Leaf"))).toContain("load i32, ptr %o"); + expect(definition(llvm32, mangleClassNew("Leaf"))).toMatch(/store i32 1, ptr %o/); +}); + +test("a tagged class's 32-bit count saturates at the immortal count instead of wrapping", () => { + const llvm = emitLlvmModule(treeModule(), { pointerBits: 64 }); + // The class's and the union's retain and release, run on an rc word whose + // count is two below the immortal all-ones count. + const symbols = [ + [mangleClassRetain("Leaf"), "retain_leaf"], + [mangleClassRelease("Leaf"), "release_leaf"], + [classRetainFn("tree").slice(1), "retain_tree"], + [classReleaseFn("tree").slice(1), "release_tree"], + ] as const; + let ir = ""; + for (const [symbol] of symbols) ir += `${definition(llvm, symbol)}\n\n`; + // A last reference never reaches these: the harness aborts if one does. + const last = [ + [`${mangleClassRelease("Leaf")}_slow`, "last_leaf"], + [`${classReleaseFn("tree").slice(1)}.last`, "last_tree"], + ] as const; + for (const [symbol, name] of [...symbols, ...last]) + ir = ir.replaceAll(`@${symbol}(`, `@${name}(`); + ir = ir.replaceAll("define internal", "define").replaceAll(" #0 ", " "); + ir += `declare void @last_leaf(ptr)\ndeclare void @last_tree(ptr)\n`; + const dir = mkdtempSync(join(tmpdir(), "scriptc-class-unions-")); + try { + writeFileSync(join(dir, "rc.ll"), ir); + writeFileSync( + join(dir, "main.c"), + `#include +#include +void *retain_leaf(void *); +void release_leaf(void *); +void *retain_tree(void *); +void release_tree(void *); +void last_leaf(void *o) { (void)o; abort(); } +void last_tree(void *o) { (void)o; abort(); } +static int check(void *(*retain)(void *), void (*release)(void *)) { + const uint64_t id = (uint64_t)42 << 32; + uint64_t word = id | 0xfffffffdu; + retain(&word); + if (word != (id | 0xfffffffeu)) return 1; + retain(&word); + if (word != (id | 0xffffffffu)) return 2; + retain(&word); + if (word != (id | 0xffffffffu)) return 3; + release(&word); + if (word != (id | 0xffffffffu)) return 4; + word = id | 3; + release(&word); + if (word != (id | 2)) return 5; + return 0; +} +int main(void) { + int leaf = check(retain_leaf, release_leaf); + if (leaf != 0) return leaf; + int tree = check(retain_tree, release_tree); + return tree == 0 ? 0 : 10 + tree; +} +`, + ); + execFileSync("clang", [ + "-Wno-override-module", + "-O1", + join(dir, "rc.ll"), + join(dir, "main.c"), + "-o", + join(dir, "rc"), + ]); + execFileSync(join(dir, "rc")); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/packages/compiler/src/backend/llvm/class-unions.ts b/packages/compiler/src/backend/llvm/class-unions.ts new file mode 100644 index 000000000..5353503ac --- /dev/null +++ b/packages/compiler/src/backend/llvm/class-unions.ts @@ -0,0 +1,255 @@ +/* Class-union helpers (nullable-unions.ts ClassUnion). A class union's value + * is the instance pointer; its arm is the arm whose preorder interval holds + * the instance's class id, which every arm's hierarchy keeps in its header + * (classes.ts LlClassMeta.idInRc). Each union gets a tag helper, and the + * unions over the same classes share one retain, release and trace. */ +import { InternalCompilerError } from "../../errors.js"; +import type { IrModule } from "../../ir/ir.js"; +import { mangleClassRelease, mangleClassTrace } from "../mangle.js"; +import { + classIdLoadLines, + programLifetimeClass, + type ClassHost, + type LlClassMeta, +} from "./classes.js"; +import { + NULLABLE_NULL, + classReleaseFn, + classRetainFn, + classTagFn, + classTraceFn, + type ClassUnion, +} from "./nullable-unions.js"; +import { FN_ATTRS, cycleRootLines, retainBody } from "./shapes.js"; + +/** The widest span of class ids a union's arms may cover for its helpers + * to switch on the id; wider unions test each arm's interval. */ +const CLASS_TAG_SWITCH_SPAN = 32; + +/** One arm's class ids: its class's preorder interval plus 1. */ +interface ArmIds { + tag: number; + className: string; + lo: number; + hi: number; +} + +function armIds( + union: ClassUnion, + classMeta: ReadonlyMap, +): { arms: ArmIds[]; first: LlClassMeta } { + const arms = union.arms.map((arm) => { + const meta = classMeta.get(arm.className); + if (meta === undefined || !meta.idInRc) + throw new InternalCompilerError( + `llvm emitter bug: class-union arm ${arm.className} keeps no class id`, + ); + return { tag: arm.tag, className: arm.className, lo: meta.pre + 1, hi: meta.post + 1 }; + }); + return { arms, first: classMeta.get(union.arms[0]!.className)! }; +} + +/** Lines that branch from the open block on the class id of the instance + * `%o`: to `%a` for the arm holding it and to `missing` for none. */ +function armBranchLines(arms: readonly ArmIds[], first: LlClassMeta, missing: string): string[] { + const lines = classIdLoadLines(first, "%o", "%id", ""); + const span = Math.max(...arms.map((arm) => arm.hi)) - Math.min(...arms.map((arm) => arm.lo)) + 1; + if (span <= CLASS_TAG_SWITCH_SPAN) { + const cases: string[] = []; + for (const arm of arms) + for (let id = arm.lo; id <= arm.hi; id++) cases.push(`i32 ${id}, label %a${arm.tag}`); + lines.push(` switch i32 %id, label %${missing} [ ${cases.join(" ")} ]`); + return lines; + } + arms.forEach((arm, i) => { + const next = i + 1 < arms.length ? `n${arm.tag}` : missing; + lines.push( + ` %r${arm.tag} = sub i32 %id, ${arm.lo}`, + ` %in${arm.tag} = icmp ult i32 %r${arm.tag}, ${arm.hi - arm.lo + 1}`, + ` br i1 %in${arm.tag}, label %a${arm.tag}, label %${next}`, + ); + if (next !== missing) lines.push(`${next}:`); + }); + return lines; +} + +/** Lines that leave for `unit` on NULL and for `nullArm` on the null + * sentinel, and continue in a new block for an instance. */ +function unitLines(union: ClassUnion, unit: string, nullArm: string): string[] { + const lines = [ + ` %isnull = icmp eq ptr %o, null`, + ` br i1 %isnull, label %${unit}, label %ref`, + `ref:`, + ]; + if (union.nullTag >= 0) + lines.push( + ` %isnullarm = icmp eq ptr %o, ${NULLABLE_NULL}`, + ` br i1 %isnullarm, label %${nullArm}, label %obj`, + `obj:`, + ); + return lines; +} + +/** A dispatch on the arm of `%o` that runs `call(className)` and returns. */ +function armDispatchLines( + arms: readonly ArmIds[], + first: LlClassMeta, + call: (className: string) => string, +): string[] { + return [ + ...armBranchLines(arms, first, "done"), + ...arms.flatMap((arm) => [`a${arm.tag}:`, call(arm.className), ` br label %done`]), + `done:`, + ]; +} + +/** A class union's value is whichever instance it holds, and the collector + * visits it as that instance: when one arm carries a collector header, + * every arm must. Every class of a hierarchy shares its header decision, so + * a header reaches each arm's whole hierarchy, and unions that share a + * hierarchy pass headers on to each other: this repeats until no union + * adds one. ProgramLifetime classes carry none. */ +export function classUnionHeaders( + mod: IrModule, + unions: readonly ClassUnion[], + tracedShapes: Set, + tracedUnions: ReadonlySet, +): void { + if (unions.length === 0) return; + const bases = new Map(); + for (const cls of mod.classes ?? []) bases.set(cls.name, cls.base); + const rootOf = (name: string): string => { + let root = name; + for (let base = bases.get(root); base !== undefined; base = bases.get(root)) root = base; + return root; + }; + const members = new Map(); + for (const cls of mod.classes ?? []) { + const root = rootOf(cls.name); + const list = members.get(root); + if (list) list.push(cls.name); + else members.set(root, [cls.name]); + } + const lifetime = new Set(mod.contracts?.programLifetime ?? []); + for (let changed = true; changed;) { + changed = false; + for (const union of unions) { + if (lifetime.has(rootOf(union.arms[0]!.className))) continue; + const names = union.arms.flatMap((arm) => members.get(rootOf(arm.className)) ?? []); + if ( + !tracedUnions.has(union.unionId) && + !names.some((name) => tracedShapes.has(`object:${name}`)) + ) + continue; + for (const name of names) { + const key = `object:${name}`; + if (tracedShapes.has(key)) continue; + tracedShapes.add(key); + changed = true; + } + } + } +} + +/** The tag helper of every class union, and the retain, release and trace + * of each one that owns them (NullableUnions.helperOwner). + * - Retain and release of unions over ProgramLifetime classes do nothing: + * those instances count nothing. + * - Otherwise every arm counts in the low half of its rc word (idInRc), so + * retain is the shared increment and release the shared decrement; only + * a release of the last reference dispatches on the arm, to the arm's + * own release (which tears the dynamic class down). + * - Trace dispatches on the arm. Every arm carries a collector header when + * one does (the emitter's traced fixpoint). */ +export function emitClassUnionHelpers( + host: ClassHost, + classMeta: ReadonlyMap, +): string[] { + const out: string[] = []; + for (const union of host.nullableUnions.allClassUnions()) { + const { arms, first } = armIds(union, classMeta); + out.push( + `define internal i32 ${classTagFn(union.unionId)}(ptr %o) alwaysinline ${FN_ATTRS} { ; class union tag`, + `entry:`, + ...unitLines(union, "unit", "nullarm"), + ...armBranchLines(arms, first, "bad"), + ...arms.flatMap((arm) => [`a${arm.tag}:`, ` ret i32 ${arm.tag}`]), + `unit:`, + ` ret i32 ${union.unitTag}`, + ...(union.nullTag >= 0 ? [`nullarm:`, ` ret i32 ${union.nullTag}`] : []), + `bad:`, + ` ret i32 -1`, + `}`, + ``, + ); + if (host.nullableUnions.helperOwner(union.unionId) !== union.unionId) continue; + const retain = classRetainFn(union.unionId).slice(1); + const release = classReleaseFn(union.unionId); + if (programLifetimeClass(host, first)) { + out.push( + `define internal ptr @${retain}(ptr %o) ${FN_ATTRS} { ; class union retain (immortal)`, + `entry:`, + ` ret ptr %o`, + `}`, + ``, + `define internal void ${release}(ptr %o) ${FN_ATTRS} { ; class union release (immortal)`, + `entry:`, + ` ret void`, + `}`, + ``, + ); + continue; + } + const traced = host.tracedShapes.has(`object:${first.def.name}`); + out.push( + ...retainBody(host, retain, "class union retain", false, "i32"), + ``, + `define internal void ${release}(ptr %o) ${FN_ATTRS} { ; class union release (fast path)`, + `entry:`, + ...unitLines(union, "done", "done"), + ` %rc = load i32, ptr %o`, + ` %rcm2 = sub i32 %rc, 2`, + ` %shared = icmp ult i32 %rcm2, -3`, + ` br i1 %shared, label %dec, label %slow`, + `dec:`, + ` %n = sub i32 %rc, 1`, + ` store i32 %n, ptr %o`, + ...(traced ? cycleRootLines(host, "done") : [` br label %done`]), + `slow:`, + ` %imm = icmp eq i32 %rc, -1`, + ` br i1 %imm, label %done, label %last`, + `last:`, + ` call void ${release}.last(ptr %o)`, + ` br label %done`, + `done:`, + ` ret void`, + `}`, + ``, + `define internal void ${release}.last(ptr %o) noinline ${FN_ATTRS} { ; class union release (last reference)`, + `entry:`, + ...armDispatchLines( + arms, + first, + (name) => ` call void @${mangleClassRelease(name)}(ptr %o)`, + ), + ` ret void`, + `}`, + ``, + ); + if (traced) + out.push( + `define internal void ${classTraceFn(union.unionId)}(ptr %o, ptr %visit, ptr %ctx) ${FN_ATTRS} { ; class union trace`, + `entry:`, + ...unitLines(union, "done", "done"), + ...armDispatchLines( + arms, + first, + (name) => ` call void @${mangleClassTrace(name)}(ptr %o, ptr %visit, ptr %ctx)`, + ), + ` ret void`, + `}`, + ``, + ); + } + return out; +} diff --git a/packages/compiler/src/backend/llvm/classes.ts b/packages/compiler/src/backend/llvm/classes.ts index e4e8bd459..7ab7315c1 100644 --- a/packages/compiler/src/backend/llvm/classes.ts +++ b/packages/compiler/src/backend/llvm/classes.ts @@ -100,7 +100,9 @@ export class LlClassMeta { * a vtable word, the rc word holds the class id (classIdRcWord) in its * high 32 bits (CLASS_ID_OFFSET) and the reference count in its low 32 * bits, and the hierarchy's vtables are one array indexed by the id - * (mangleVtArray). */ + * (mangleVtArray). A standalone class that is an arm of a class union + * (nullable-unions.ts taggedClasses) keeps its id the same way, without + * vtables. */ idInRc = false; /** A ProgramLifetime class with the 32-bit header (assignCompactLayouts): * the i32 at offset 0 is compactHeaderWord, with the class id in place of @@ -131,6 +133,7 @@ export function buildClassGraph( mod: IrModule, fnByName: Map, sizeType: "i32" | "i64" = "i64", + tagged: ReadonlySet = new Set(), ): Map { const metaMap = new Map(); for (const cls of mod.classes ?? []) { @@ -161,7 +164,10 @@ export function buildClassGraph( // EventEmitter, EventTarget, Event and the streams) and 32-bit targets // have no spare half in the rc word: those keep the vtable word. for (const meta of metaMap.values()) - meta.idInRc = sizeType === "i64" && meta.hierarchy && meta.root.def.runtime !== true; + meta.idInRc = + sizeType === "i64" && + (meta.hierarchy || tagged.has(meta.def.name)) && + meta.root.def.runtime !== true; const slotRoots = new Set(); for (const key of mod.threadSlotKeys ?? []) { const meta = metaMap.get(key); @@ -628,10 +634,20 @@ export function classIdRcWord(meta: LlClassMeta, lifetime: boolean): string { return (BigInt(meta.pre + 1) * 2n ** 32n + (lifetime ? 2n ** 31n : 1n)).toString(); } +/** The rc word of an immortal LlClassMeta.idInRc instance (a frame + * object): the class id in the high half and the immortal count, all ones, + * in the low half, which scr_rc_immortal reads as immortal. */ +export function classIdImmortalRcWord(meta: LlClassMeta): string { + if (meta.pre + 1 >= LAYOUT_ID_LIMIT) + throw new InternalCompilerError(`class id ${meta.pre + 1} is not a layout id`); + return (BigInt(meta.pre + 1) * 2n ** 32n + 0xffffffffn).toString(); +} + /** The 32-bit header of a compact class instance (LlClassMeta.compact): * COMPACT_HEADER_TAG, the class id (classIdRcWord's preorder number + 1, 0 - * outside a hierarchy) in the low 28 bits, and COMPACT_PUBLISHED once - * publication marks the object. No other object's count reaches + * for a class without LlClassMeta.idInRc) in the low 28 bits, and + * COMPACT_PUBLISHED once publication marks the object. No other object's + * count reaches * 0x70000000, so the runtime reads a word of 0xf0000000 and up as published * for every object (scr_rc.h SCR_RC_PUBLISHED). */ export const COMPACT_HEADER_TAG = 0x70000000; @@ -650,7 +666,7 @@ export function storedHeaderBytes(meta: LlClassMeta): number { /** The header word a compact instance is created with. */ export function compactHeaderWord(meta: LlClassMeta): number { - return COMPACT_HEADER_TAG | (meta.hierarchy ? meta.pre + 1 : 0); + return COMPACT_HEADER_TAG | (meta.idInRc ? meta.pre + 1 : 0); } /** Lines that decode the 32-bit reference `idx` (an i32) of a compressed @@ -1024,7 +1040,7 @@ export function emitClassShapes( // instance is the element at its number minus the root's. for (const name of roots) { const root = metaMap.get(name)!; - if (!root.idInRc) continue; + if (!root.hierarchy || !root.idInRc) continue; const members = [...metaMap.values()] .filter((m) => m.root === root) .sort((a, b) => a.pre - b.pre); @@ -1395,6 +1411,7 @@ export function emitClassShapes( count, ) : null, + count, ), ``, ); diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index edad06ff3..529e93bd9 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -364,7 +364,15 @@ import { VirtualBorrows } from "./virtual-borrows.js"; import { planRecordViews, recordViewFieldPtr } from "./record-views.js"; import { LlvmUnsupportedError } from "./unsupported.js"; import { LlWalkers } from "./walkers.js"; -import { isObjectArm, NULLABLE_ABSENT, NULLABLE_NULL, NullableUnions } from "./nullable-unions.js"; +import { + isObjectArm, + isPayloadTag, + NULLABLE_ABSENT, + NULLABLE_NULL, + NullableUnions, + pointerTagFn, +} from "./nullable-unions.js"; +import { classUnionHeaders, emitClassUnionHelpers } from "./class-unions.js"; import { emitNullablePresent, emitUnionPeek, emitUnionTag } from "./union-repr.js"; import { emitConcatInputs, @@ -2039,6 +2047,7 @@ export class LlEmitter { mod, options.debugSources === undefined, this.nodeIndex, + options.pointerBits !== 32, ); this.heapCensus = heapCensusOf(mod, this.nullableUnions); for (const decl of this.heapCensus?.declarations() ?? []) this.declare(decl); @@ -2323,6 +2332,13 @@ export class LlEmitter { } } } + // The same holds for a class union's arms (class-unions.ts). + classUnionHeaders( + mod, + this.nullableUnions.allClassUnions(), + this.tracedShapes, + this.tracedUnions, + ); this.shapeHost = { // Journal hooks only on emitters that can emit ranges (chunk // emitters, and emitters whose workers take a journal after the @@ -2488,7 +2504,12 @@ export class LlEmitter { // The class graph: base/children links, hierarchy membership, the // whole-program preorder numbering for instanceof over runtime and // compiled error objects, and the per-hierarchy virtual slot lists. - this.classMeta = buildClassGraph(mod, this.fnByName, this.sizeType); + this.classMeta = buildClassGraph( + mod, + this.fnByName, + this.sizeType, + this.nullableUnions.taggedClasses(), + ); if (mod.lifetimeRegions === true) { this.regions = new LifetimeRegions( this.shapeHost, @@ -2756,6 +2777,7 @@ export class LlEmitter { ...late, ...emitNullableRetainWrappers(this.shapeHost), ...emitShapeUnionHelpers(this.shapeHost), + ...emitClassUnionHelpers(this.shapeHost, this.classMeta), ...emitInlineRcHelpers(this.shapeHost), ]; } @@ -4702,11 +4724,7 @@ export class LlEmitter { const nullable = this.nullableUnions.get(unionId); if (nullable) { // The owned payload IS the nullable union's value. - if ( - nullable.repr === "single" - ? tag !== nullable.refTag - : !nullable.arms.some((arm) => arm.tag === tag) - ) + if (!isPayloadTag(nullable, tag)) throw new InternalCompilerError( `llvm emitter bug: payload wrap into unit arm ${tag} of ${unionId}`, ); @@ -7148,7 +7166,10 @@ export class LlEmitter { const nullable = this.nullableUnions.of(t); return nullable?.repr === "single" ? nullable.arm : null; }, - shapeUnion: (t) => this.nullableUnions.of(t)?.repr === "shapes", + pointerTagFn: (t) => { + const nullable = this.nullableUnions.of(t); + return nullable === null || nullable.repr === "single" ? null : pointerTagFn(nullable); + }, fieldBase: (shapeId) => this.nullableUnions.fieldBase(shapeId), classInfo: (className) => { const meta = this.classMetaOf(className); @@ -7656,11 +7677,7 @@ export class LlEmitter { const nullable = this.nullableUnions.get(e.unionId); if (!nullable) return null; if (isUnitType(e.value.type)) return e.value.kind === "unitLit" ? "unit" : null; - const refArm = - nullable.repr === "single" - ? e.tag === nullable.refTag - : nullable.arms.some((arm) => arm.tag === e.tag); - return refArm && isRefCounted(e.value.type) ? "ref" : null; + return isPayloadTag(nullable, e.tag) && isRefCounted(e.value.type) ? "ref" : null; } materializeSplitLocal(localId: string): void { diff --git a/packages/compiler/src/backend/llvm/expr-records.ts b/packages/compiler/src/backend/llvm/expr-records.ts index e68b0ed4c..e65a356eb 100644 --- a/packages/compiler/src/backend/llvm/expr-records.ts +++ b/packages/compiler/src/backend/llvm/expr-records.ts @@ -74,13 +74,13 @@ export function emitUnionRetype( const to = host.nullableUnions.get(e.unionId); if (from && to) { // Equal encodings make the pointer itself the target value. A shape - // union derives its record arms from the record, so only the unit - // encodings must agree. + // union derives its record arms from the record and a class union its + // class arms from the class id, so only the unit encodings must agree. const same = from.repr === "single" && to.repr === "single" ? from.refTag === to.refTag && from.unitTag === to.unitTag && from.nullTag === to.nullTag - : from.repr === "shapes" && - to.repr === "shapes" && + : from.repr !== "single" && + from.repr === to.repr && from.unitTag === to.unitTag && from.nullTag === to.nullTag; if (same) return host.own({ name: value.name, type: e.type }); diff --git a/packages/compiler/src/backend/llvm/nullable-unions.test.ts b/packages/compiler/src/backend/llvm/nullable-unions.test.ts index ea0517b56..c155a86f6 100644 --- a/packages/compiler/src/backend/llvm/nullable-unions.test.ts +++ b/packages/compiler/src/backend/llvm/nullable-unions.test.ts @@ -3,6 +3,7 @@ import { F64, NULL_T, UNDEFINED_T, + type IrClassDef, type IrModule, type IrType, type IrUnionDef, @@ -146,3 +147,73 @@ test("typed array arms are nullable pointers compared by identity", () => { expect(isObjectArm(unions.single("iu")!)).toBe(true); expect(isPointerIdentityArm(unions.single("iu")!)).toBe(true); }); + +test("unions of two or more classes become class unions", () => { + const cls = (className: string): IrType => ({ kind: "object", className }); + const def = (name: string, base?: string, runtime?: true): IrClassDef => ({ + name, + fields: [], + loc, + ...(base === undefined ? {} : { base }), + ...(runtime === undefined ? {} : { runtime }), + }); + const classes = [ + def("%Error", undefined, true), + def("%TypeError", "%Error", true), + def("Leaf"), + def("Pair"), + def("Node"), + def("Name", "Node"), + def("Call", "Node"), + def("Sym"), + def("Type"), + ]; + const unions = [ + { id: "lp", arms: [cls("Leaf"), cls("Pair")] }, + { id: "lpu", arms: [cls("Leaf"), UNDEFINED_T, cls("Pair")] }, + { id: "lpnu", arms: [NULL_T, cls("Leaf"), cls("Pair"), UNDEFINED_T] }, + { id: "siblings", arms: [cls("Name"), cls("Call")] }, + { id: "target", arms: [cls("Sym"), cls("Node"), cls("Type")] }, + { id: "ancestor", arms: [cls("Node"), cls("Name")] }, + { id: "number", arms: [cls("Leaf"), F64] }, + { id: "error", arms: [cls("Leaf"), cls("%TypeError")] }, + { id: "mixed", arms: [cls("Leaf"), cls("Sym")] }, + { id: "rp", arms: [cls("Pair"), cls("Leaf")] }, + ]; + const lifetime = { contracts: { programLifetime: ["Sym", "Type", "Node"] } }; + const nullable = new NullableUnions(module(unions, { classes, ...lifetime })); + expect(nullable.classes("lp")).toMatchObject({ unitTag: -1, nullTag: -1 }); + expect(nullable.classes("lpu")).toMatchObject({ unitTag: 1, nullTag: -1 }); + expect(nullable.classes("lpnu")).toMatchObject({ unitTag: 3, nullTag: 0 }); + expect(nullable.classes("lpnu")?.arms).toEqual([ + { tag: 1, className: "Leaf" }, + { tag: 2, className: "Pair" }, + ]); + expect(nullable.classes("siblings")).not.toBeNull(); + expect(nullable.classes("target")).not.toBeNull(); + // An arm that extends another, a scalar arm, a runtime-rooted arm, and + // arms of which only some are ProgramLifetime keep the box. + expect(nullable.get("ancestor")).toBeNull(); + expect(nullable.get("number")).toBeNull(); + expect(nullable.get("error")).toBeNull(); + expect(nullable.get("mixed")).toBeNull(); + // Standalone arms keep their class id only because they are arms. + expect([...nullable.taggedClasses()].sort()).toEqual(["Leaf", "Pair", "Sym", "Type"]); + expect(nullable.helperOwner("rp")).toBe("lp"); + expect(nullable.helperOwner("lpu")).toBe("lp"); + expect(nullable.allClassUnions().map((u) => u.unionId)).toEqual([ + "lp", + "lpu", + "lpnu", + "siblings", + "target", + "rp", + ]); + const plain = { classes, ...lifetime }; + // 32-bit targets, debug builds and library builds keep boxes. + expect(new NullableUnions(module(unions, plain), true, undefined, false).get("lp")).toBeNull(); + expect(new NullableUnions(module(unions, plain), false).get("lp")).toBeNull(); + expect( + new NullableUnions(module(unions, plain), true, undefined, false).taggedClasses().size, + ).toBe(0); +}); diff --git a/packages/compiler/src/backend/llvm/nullable-unions.ts b/packages/compiler/src/backend/llvm/nullable-unions.ts index 587f3c18c..3f7ca8ca1 100644 --- a/packages/compiler/src/backend/llvm/nullable-unions.ts +++ b/packages/compiler/src/backend/llvm/nullable-unions.ts @@ -54,8 +54,31 @@ export interface ShapeUnion { arms: readonly { tag: number; shapeId: string }[]; } +/** A union of two or more class arms and at most two unit arms (`A | B`, + * `A | B | undefined`, sum types over classes) whose VALUES are the + * instance pointer itself, like a ShapeUnion: NULL is the unit arm + * (`undefined` when both exist, NULLABLE_NULL is then `null`). No arm is + * an ancestor of another, so the arm of an instance is the arm whose + * preorder interval holds its class id: every instance of an arm's + * hierarchy keeps its class id in its header (classes.ts LlClassMeta.idInRc + * and compactHeaderWord), which standalone arm classes do only because they + * are arms (taggedClasses). The arms are all compact classes or all keep + * the rc word, so one load reads the id of any value (the per-union + * `@sc_ctag.` helper). */ +export interface ClassUnion { + repr: "classes"; + unionId: string; + /** The unit arm NULL encodes; -1 when the union has no unit arm. */ + unitTag: number; + /** The `null` arm of a two-unit-arm union, encoded by NULLABLE_NULL; + * -1 otherwise. */ + nullTag: number; + /** The class arms in tag order. */ + arms: readonly { tag: number; className: string }[]; +} + /** A union whose values are plain pointers (no `%ScrUnion` box). */ -export type PointerUnion = NullableUnion | ShapeUnion; +export type PointerUnion = NullableUnion | ShapeUnion | ClassUnion; /** The struct index of a tagged record shape's hidden shape-number word. */ export const SHAPE_WORD_INDEX = 1; @@ -79,6 +102,27 @@ export function shapeTraceFn(unionId: string): string { return `@sc_strace.${unionId.replace(/[^A-Za-z0-9_.]/g, "_")}`; } +/** The per-union helper mapping a class-union value to its tag. */ +export function classTagFn(unionId: string): string { + return `@sc_ctag.${unionId.replace(/[^A-Za-z0-9_.]/g, "_")}`; +} + +/** The per-union retain, release and trace entry points of a class union. */ +export function classRetainFn(unionId: string): string { + return `@sc_cretain.${unionId.replace(/[^A-Za-z0-9_.]/g, "_")}`; +} +export function classReleaseFn(unionId: string): string { + return `@sc_crelease.${unionId.replace(/[^A-Za-z0-9_.]/g, "_")}`; +} +export function classTraceFn(unionId: string): string { + return `@sc_ctrace.${unionId.replace(/[^A-Za-z0-9_.]/g, "_")}`; +} + +/** The helper mapping a shape- or class-union value to its tag. */ +export function pointerTagFn(union: ShapeUnion | ClassUnion): string { + return union.repr === "shapes" ? shapeTagFn(union.unionId) : classTagFn(union.unionId); +} + /** The module-scope immortal sentinel standing for `null` in a * `C | null | undefined` union (NULL is `undefined` there). */ export const NULLABLE_NULL = "@sc_nullable_null"; @@ -102,17 +146,25 @@ export type NullableRefArm = /** The arm is a JS object: always truthy. */ export function isObjectArm(nullable: PointerUnion): boolean { - return nullable.repr === "shapes" || nullable.arm.kind !== "string"; + return nullable.repr !== "single" || nullable.arm.kind !== "string"; } /** The arm compares by pointer identity. Function arms compare identity * roots instead: a signature adapter is the function it adapts. */ export function isPointerIdentityArm(nullable: PointerUnion): boolean { return ( - nullable.repr === "shapes" || (nullable.arm.kind !== "string" && nullable.arm.kind !== "func") + nullable.repr !== "single" || (nullable.arm.kind !== "string" && nullable.arm.kind !== "func") ); } +/** The arm `tag` of a pointer union holds a reference: the value is that + * payload itself (not a unit encoding). */ +export function isPayloadTag(nullable: PointerUnion, tag: number): boolean { + if (nullable.repr === "single") return tag === nullable.refTag; + if (nullable.repr === "shapes") return nullable.arms.some((arm) => arm.tag === tag); + return nullable.arms.some((arm) => arm.tag === tag); +} + /** The representation decision for every union of one module. It is a * pure function of the module, so serialized-IR and in-memory emission * agree. */ @@ -128,12 +180,18 @@ export class NullableUnions { /** Some nullable union encodes `null` with NULLABLE_NULL. */ readonly usesNullSentinel: boolean = false; /** The shape union whose release and trace helpers each shape union - * uses: the first one over the same record arms. */ + * uses: the first one over the same record arms; likewise for class + * unions over the same classes. */ private readonly helperOwners = new Map(); + /** The standalone classes that are arms of a class union, which keep + * their class id in their header only for that (taggedClasses). */ + private readonly tagged = new Set(); - /** `shapes`: allow shape unions (debug builds keep boxes, whose - * debug-info layouts describe every arm). */ - constructor(mod: IrModule, shapes = true, nodes?: IrNodeIndex) { + /** `shapes`: allow shape and class unions (debug builds keep boxes, + * whose debug-info layouts describe every arm). `classIds`: the target + * keeps class ids in instance headers (64-bit targets), which class + * unions read their arm from. */ + constructor(mod: IrModule, shapes = true, nodes?: IrNodeIndex, classIds = true) { const emitted = new Set(); for (const cls of mod.classes ?? []) if (!cls.runtime) emitted.add(cls.name); const records = new Set(); @@ -143,6 +201,7 @@ export class NullableUnions { // boxes) and of programs with lifetime regions (whose walks report a // reference slot by its type's release entry point). const allowShapes = shapes && mod.lib === undefined && mod.lifetimeRegions !== true; + const classes = allowShapes && classIds ? new ClassForest(mod) : null; // A record that is an arm of a boxed union never becomes a shape // union's arm: conversions between unions over the same records then // keep one representation. @@ -154,7 +213,8 @@ export class NullableUnions { if (boxed.has(def.id)) continue; const nullable = nullableShape(def, emitted, records) ?? - (allowShapes ? shapeUnion(def, records, boxedRecords) : null); + (allowShapes ? shapeUnion(def, records, boxedRecords) : null) ?? + (classes !== null ? classUnion(def, classes) : null); if (!nullable) continue; this.byId.set(def.id, nullable); if (nullable.nullTag >= 0) this.usesNullSentinel = true; @@ -162,22 +222,34 @@ export class NullableUnions { for (const arm of nullable.arms) if (!this.shapeNumbers.has(arm.shapeId)) this.shapeNumbers.set(arm.shapeId, this.shapeNumbers.size + 2); + if (nullable.repr === "classes") + for (const arm of nullable.arms) + if (classes!.standalone(arm.className)) this.tagged.add(arm.className); } // Release and trace dispatch on the shape word, which names a record - // shape module-wide, so unions over the same records share them. + // shape module-wide, so unions over the same records share them; class + // unions dispatch on the class id, so the same holds for their arms. const owners = new Map(); for (const nullable of this.byId.values()) { - if (nullable.repr !== "shapes") continue; - const key = nullable.arms - .map((arm) => arm.shapeId) - .sort() - .join(" "); + if (nullable.repr === "single") continue; + const names = + nullable.repr === "shapes" + ? nullable.arms.map((arm) => arm.shapeId) + : nullable.arms.map((arm) => arm.className); + const key = `${nullable.repr}:${names.sort().join(" ")}`; const owner = owners.get(key) ?? nullable.unionId; owners.set(key, owner); this.helperOwners.set(nullable.unionId, owner); } } + /** Standalone classes (no base, no subclass) that are arms of a class + * union: their instances keep their class id in their header like + * hierarchy members do (classes.ts buildClassGraph). */ + taggedClasses(): ReadonlySet { + return this.tagged; + } + /** The shape union whose release and trace helpers this one uses. */ helperOwner(unionId: string): string { return this.helperOwners.get(unionId) ?? unionId; @@ -203,6 +275,12 @@ export class NullableUnions { return nullable?.repr === "shapes" ? nullable : null; } + /** The class-union form only. */ + classes(unionId: string): ClassUnion | null { + const nullable = this.byId.get(unionId); + return nullable?.repr === "classes" ? nullable : null; + } + /** Every shape union, in module order. */ allShapeUnions(): ShapeUnion[] { const out: ShapeUnion[] = []; @@ -210,6 +288,13 @@ export class NullableUnions { return out; } + /** Every class union, in module order. */ + allClassUnions(): ClassUnion[] { + const out: ClassUnion[] = []; + for (const nullable of this.byId.values()) if (nullable.repr === "classes") out.push(nullable); + return out; + } + /** The module-wide number a tagged record shape stores in its shape * word, or 0 for shapes that carry none. */ shapeNumber(shapeId: string): number { @@ -312,6 +397,107 @@ function shapeUnion( return { repr: "shapes", unionId: def.id, unitTag, nullTag, arms }; } +/** The class forest of a module, by base links: what classUnion needs + * before the emitter builds its class graph. */ +class ClassForest { + private readonly byName = new Map(); + private readonly hasChildren = new Set(); + private readonly lifetimeRoots: ReadonlySet; + private readonly slotRoots = new Set(); + + constructor(mod: IrModule) { + for (const cls of mod.classes ?? []) { + this.byName.set(cls.name, cls); + if (cls.base !== undefined) this.hasChildren.add(cls.base); + } + this.lifetimeRoots = new Set(mod.contracts?.programLifetime ?? []); + for (const key of mod.threadSlotKeys ?? []) { + const root = this.root(key); + if (root !== undefined) this.slotRoots.add(root.name); + } + } + + root(className: string): IrClassDef | undefined { + let cls = this.byName.get(className); + while (cls?.base !== undefined) cls = this.byName.get(cls.base); + return cls; + } + + /** `ancestor` is `className` or one of its bases. */ + extends(className: string, ancestor: string): boolean { + let cls = this.byName.get(className); + while (cls !== undefined) { + if (cls.name === ancestor) return true; + cls = cls.base === undefined ? undefined : this.byName.get(cls.base); + } + return false; + } + + standalone(className: string): boolean { + return this.byName.get(className)?.base === undefined && !this.hasChildren.has(className); + } + + /** The class is in a ProgramLifetime hierarchy, whose instances count + * nothing. */ + lifetime(className: string): boolean { + const root = this.root(className); + return root !== undefined && this.lifetimeRoots.has(root.name); + } + + /** Instances of the class keep their class id in a compact header + * (classes.ts assignCompactLayouts): ProgramLifetime hierarchies without + * a slot word. */ + compact(className: string): boolean { + return this.lifetime(className) && !this.slotRoots.has(this.root(className)!.name); + } + + /** An emitted class in a program-rooted hierarchy that is no Array + * subclass: one whose instances can carry a class id. */ + identifiable(className: string): boolean { + const cls = this.byName.get(className); + const root = this.root(className); + return ( + cls !== undefined && + cls.runtime !== true && + root !== undefined && + root.runtime !== true && + root.arrayElem === undefined + ); + } +} + +/** Two or more class arms none of which extends another, all compact or + * all keeping the rc word, all ProgramLifetime classes or none, and at most + * two unit arms (both: undefined and null). */ +function classUnion(def: IrUnionDef, classes: ClassForest): ClassUnion | null { + const units = def.arms.filter(isUnitType).length; + if (units > 2 || def.arms.length - units < 2) return null; + const arms: { tag: number; className: string }[] = []; + for (let tag = 0; tag < def.arms.length; tag++) { + const arm = def.arms[tag]!; + if (isUnitType(arm)) continue; + if (arm.kind !== "object" || !classes.identifiable(arm.className)) return null; + arms.push({ tag, className: arm.className }); + } + const compact = classes.compact(arms[0]!.className); + const lifetime = classes.lifetime(arms[0]!.className); + for (const arm of arms) { + if (classes.compact(arm.className) !== compact) return null; + if (classes.lifetime(arm.className) !== lifetime) return null; + for (const other of arms) + if (other !== arm && classes.extends(arm.className, other.className)) return null; + } + let unitTag = -1, + nullTag = -1; + if (units === 1) unitTag = def.arms.findIndex(isUnitType); + else if (units === 2) { + unitTag = def.arms.findIndex((arm) => arm.kind === "undefinedT"); + nullTag = def.arms.findIndex((arm) => arm.kind === "nullT"); + if (unitTag < 0 || nullTag < 0) return null; + } + return { repr: "classes", unionId: def.id, unitTag, nullTag, arms }; +} + /** Unions the runtime itself inspects as tagged boxes keep that * representation: Map keys and Set elements hash and compare union keys by * tag and payload inside the C map. A union of records alone is the diff --git a/packages/compiler/src/backend/llvm/publish.ts b/packages/compiler/src/backend/llvm/publish.ts index 62fdd75ec..cdc2a68e4 100644 --- a/packages/compiler/src/backend/llvm/publish.ts +++ b/packages/compiler/src/backend/llvm/publish.ts @@ -26,7 +26,6 @@ import { mapOf, STRING, typeKey } from "../../ir/ir.js"; import { mangleClassStruct, mangleRecordStruct } from "../mangle.js"; import { FN_ATTRS } from "./shapes.js"; import { COMPACT_ID_MASK, compactRefLoadLines } from "./classes.js"; -import { shapeTagFn } from "./nullable-unions.js"; import type { InlineUnionFields } from "./inline-unions.js"; import { LlvmUnsupportedError } from "./unsupported.js"; @@ -53,8 +52,9 @@ export interface PublishHost { readonly inlineUnions: InlineUnionFields; /** The nullable-pointer representation of a union, when it has one. */ nullableArm(t: IrType): IrType | null; - /** The union's values are tagged record pointers (nullable-unions.ts). */ - shapeUnion(t: IrType): boolean; + /** The tag helper of a union whose values are tagged record pointers or + * class instance pointers (nullable-unions.ts), or null. */ + pointerTagFn(t: IrType): string | null; /** The struct index of a record shape's first field. */ fieldBase(shapeId: string): number; /** Class layout: hierarchy membership, preorder interval, field slots @@ -258,12 +258,13 @@ export class PublishWalkers { ? [] : [{ tag: a.tag, fn: a.fn, compact: this.compactRef(def.arms[a.tag]!) }], ); - if (this.host.shapeUnion(t)) { - // The value is the record itself (or a unit encoding): no box to - // mark; the arm's walker marks and walks the record. + const tagFn = this.host.pointerTagFn(t); + if (tagFn !== null) { + // The value is the record or instance itself (or a unit + // encoding): no box to mark; the arm's walker marks and walks it. const lines = [head, `entry:`]; lines.push( - ` %tag = call i32 ${shapeTagFn(t.unionId)}(ptr %o)`, + ` %tag = call i32 ${tagFn}(ptr %o)`, ` switch i32 %tag, label %done [ ${live.map((a) => `i32 ${a.tag}, label %arm${a.tag}`).join(" ")} ]`, ); for (const a of live) diff --git a/packages/compiler/src/backend/llvm/shapes.ts b/packages/compiler/src/backend/llvm/shapes.ts index 69ca911d3..489b5d028 100644 --- a/packages/compiler/src/backend/llvm/shapes.ts +++ b/packages/compiler/src/backend/llvm/shapes.ts @@ -47,10 +47,14 @@ import { SCR_ARR_FIELD, llvmCommentText } from "./common.js"; import { NULLABLE_NULL, SHAPE_WORD_INDEX, + classReleaseFn, + classRetainFn, + classTraceFn, shapeReleaseFn, shapeTagFn, shapeTraceFn, shapeWordHeader, + type ClassUnion, type NullableUnions, type ShapeUnion, } from "./nullable-unions.js"; @@ -257,6 +261,7 @@ export { computeTraced } from "../cycle-analysis.js"; export function vAdapters(host: ShapeHost, t: IrType): { retain: string; release: string } { const nullable = host.nullableUnions.of(t); if (nullable?.repr === "shapes") return shapeUnionRc(host, nullable); + if (nullable?.repr === "classes") return classUnionRc(host, nullable); if (nullable) { const arm = vAdapters(host, nullable.arm); return { retain: nullableRetainSym(host, nullable.arm), release: arm.release }; @@ -314,6 +319,7 @@ export function retainSym(host: ShapeHost, t: IrType): string { if (t.kind === "jsval") return jsvalRcSym(host, "retain"); const nullable = host.nullableUnions.of(t); if (nullable?.repr === "shapes") return shapeUnionRc(host, nullable).retain; + if (nullable?.repr === "classes") return classUnionRc(host, nullable).retain; if (nullable) return nullableRetainSym(host, nullable.arm); const stem = runtimeRcStem(t); if (host.rcHelpers !== null && stem !== null && inlineRcFamily(host, stem, "retain") !== null) { @@ -335,6 +341,13 @@ function shapeUnionRc(host: ShapeHost, union: ShapeUnion): { retain: string; rel }; } +/** A class union's RC entry points (class-unions.ts), shared by the unions + * over the same classes. */ +function classUnionRc(host: ShapeHost, union: ClassUnion): { retain: string; release: string } { + const owner = host.nullableUnions.helperOwner(union.unionId); + return { retain: classRetainFn(owner), release: classReleaseFn(owner) }; +} + /** The widest span of shape numbers a union's arms may cover for its tag * helper to switch on the shape word (emitShapeUnionHelpers). */ const SHAPE_TAG_SWITCH_SPAN = 32; @@ -552,6 +565,7 @@ export function releaseSym(host: ShapeHost, t: IrType): string { if (t.kind === "jsval") return jsvalRcSym(host, "release"); const nullable = host.nullableUnions.of(t); if (nullable?.repr === "shapes") return shapeUnionRc(host, nullable).release; + if (nullable?.repr === "classes") return classUnionRc(host, nullable).release; if (nullable) return releaseSym(host, nullable.arm); const stem = runtimeRcStem(t); if (host.rcHelpers !== null && stem !== null && inlineRcFamily(host, stem, "release") !== null) { @@ -1111,6 +1125,11 @@ export function traceAdapter(host: ShapeHost, t: IrType): string | null { return shapeUnionTraced(host, nullable) ? shapeTraceFn(host.nullableUnions.helperOwner(nullable.unionId)) : null; + // Every arm of a class union carries a collector header when one does. + if (nullable?.repr === "classes") + return host.tracedShapes.has(`object:${nullable.arms[0]!.className}`) + ? classTraceFn(host.nullableUnions.helperOwner(nullable.unionId)) + : null; if (nullable) return traceAdapter(host, nullable.arm); if (!host.tracedUnions.has(t.unionId)) return null; host.declare(`declare void @scr_union_trace_v(ptr, ptr, ptr)`); @@ -1754,7 +1773,7 @@ function directDestroyKey(host: ShapeHost, type: IrType): string | null { const nullable = host.nullableUnions.of(type); let t = type; if (nullable !== null) { - if (nullable.repr === "shapes") return null; + if (nullable.repr !== "single") return null; t = nullable.arm; } if (t.kind === "record") return `record:${t.shapeId}`; @@ -1977,8 +1996,9 @@ export function releaseBody( segmented = false, frozen = false, destroyDefs: readonly string[] | null = null, + count: RcCount = host.sizeType, ): string[] { - const S = host.sizeType; + const S = count; if (bounded) host.declare(`declare void @scr_rc_destroy(ptr, ptr)`); const destroy = `${fnName}_destroy`; // A freezable record's count is the word without its frozen bit. diff --git a/packages/compiler/src/backend/llvm/stack-objects.ts b/packages/compiler/src/backend/llvm/stack-objects.ts index d4c46ac11..260347d99 100644 --- a/packages/compiler/src/backend/llvm/stack-objects.ts +++ b/packages/compiler/src/backend/llvm/stack-objects.ts @@ -16,7 +16,7 @@ import { } from "./record-results.js"; import { everyStmtList } from "../../ir/traverse.js"; import { mangleClassStruct, mangleRecordStruct } from "../mangle.js"; -import { classFieldIndex, classUndefinedFieldInits } from "./classes.js"; +import { classFieldIndex, classIdImmortalRcWord, classUndefinedFieldInits } from "./classes.js"; import { CYC_BUF, CYC_HDR } from "./alloc.js"; import type { CallLifetimes } from "./call-lifetimes.js"; import { emitCallExpr, emitConstruction } from "./expr-calls.js"; @@ -396,7 +396,10 @@ export function emitStackObject( B.line(line.trim()); } } - B.line(`store ${host.sizeType} -1, ptr ${slot}`); + const tagged = type.kind === "object" ? host.classMeta.get(type.className) : undefined; + // A class-union arm keeps its class id beside the immortal count. + if (tagged?.idInRc === true) B.line(`store i64 ${classIdImmortalRcWord(tagged)}, ptr ${slot}`); + else B.line(`store ${host.sizeType} -1, ptr ${slot}`); if (type.kind === "record") { // A tagged shape's word names it to every shape union's tag read. const shapeNumber = host.nullableUnions.shapeNumber(type.shapeId); diff --git a/packages/compiler/src/backend/llvm/union-repr.ts b/packages/compiler/src/backend/llvm/union-repr.ts index 8c6fb4c85..4ec9f181c 100644 --- a/packages/compiler/src/backend/llvm/union-repr.ts +++ b/packages/compiler/src/backend/llvm/union-repr.ts @@ -3,10 +3,11 @@ * nullable union (nullable-unions.ts) is the arm pointer itself: NULL for * its NULL-encoded unit arm and, in `C | null | undefined`, the immortal * NULLABLE_NULL sentinel for `null`. A shape union's value is likewise the - * record pointer; its tag comes from the record's shape word. Every structural read of a union value - * goes through here. */ + * record pointer; its tag comes from the record's shape word. A class + * union's value is the instance pointer; its tag comes from the class id. + * Every structural read of a union value goes through here. */ import type { BlockBuilder } from "./blocks.js"; -import { NULLABLE_NULL, shapeTagFn, type PointerUnion } from "./nullable-unions.js"; +import { NULLABLE_NULL, pointerTagFn, type PointerUnion } from "./nullable-unions.js"; /** The i32 tag of a union value. `alias` is the tag word's alias * attachment (", !tbaa !N"), when the caller has one. */ @@ -17,8 +18,8 @@ export function emitUnionTag( alias = "", ): string { const t = B.tmp(); - if (nullable?.repr === "shapes") { - B.line(`${t} = call i32 ${shapeTagFn(nullable.unionId)}(ptr ${u})`); + if (nullable !== null && nullable.repr !== "single") { + B.line(`${t} = call i32 ${pointerTagFn(nullable)}(ptr ${u})`); return t; } if (nullable) { @@ -45,8 +46,9 @@ export function emitUnionTag( * the `null` sentinel). */ export function emitNullablePresent(B: BlockBuilder, nullable: PointerUnion, u: string): string { const present = B.tmp(); - if (nullable.repr === "shapes" && nullable.unitTag < 0) { - // No unit arm: every value is a record (NULL only as a pending dummy). + if (nullable.repr !== "single" && nullable.unitTag < 0) { + // No unit arm: every value is a record or an instance (NULL only as a + // pending dummy). B.line(`${present} = icmp ne ptr ${u}, null`); return present; } @@ -67,7 +69,7 @@ export function emitNullableIsTag( tag: number, negated: boolean, ): string { - if (nullable.repr === "shapes" && tag !== nullable.unitTag && tag !== nullable.nullTag) { + if (nullable.repr !== "single" && tag !== nullable.unitTag && tag !== nullable.nullTag) { const actual = emitUnionTag(B, nullable, u), t = B.tmp(); B.line(`${t} = icmp ${negated ? "ne" : "eq"} i32 ${actual}, ${tag}`); @@ -104,8 +106,8 @@ export function emitUnionPeek( /** Text-template forms for helpers written as raw LLVM lines: `%out` * receives the tag / payload of `u`. */ export function unionTagLines(nullable: PointerUnion | null, u: string, out: string): string[] { - if (nullable?.repr === "shapes") - return [` ${out} = call i32 ${shapeTagFn(nullable.unionId)}(ptr ${u})`]; + if (nullable !== null && nullable.repr !== "single") + return [` ${out} = call i32 ${pointerTagFn(nullable)}(ptr ${u})`]; if (nullable && nullable.nullTag < 0) return [ ` ${out}.isnull = icmp eq ptr ${u}, null`, diff --git a/packages/compiler/src/backend/native-codegen-core.ts b/packages/compiler/src/backend/native-codegen-core.ts index e1e726ef4..ad013a5cf 100644 --- a/packages/compiler/src/backend/native-codegen-core.ts +++ b/packages/compiler/src/backend/native-codegen-core.ts @@ -9,60 +9,41 @@ export type NativeCodegenOutputKind = "asm" | "obj"; /** LLVM text that stays one module, LLVM text per partition, the fewest * partitions (MIN_PROGRAM_PARTITIONS, of at least MIN_PARTITION_BYTES each) * a divided module gets, and the most one executable uses. */ -const SINGLE_PARTITION_BYTES = 1024 * 1024; +const SINGLE_PARTITION_BYTES = 128 * 1024; const PROGRAM_PARTITION_BYTES = 4 * 1024 * 1024; -const MIN_PARTITION_BYTES = 512 * 1024; +const MIN_PARTITION_BYTES = 32 * 1024; const MIN_PROGRAM_PARTITIONS = 8; const MAX_PROGRAM_PARTITIONS = 64; -/** SINGLE_PARTITION_BYTES and MIN_PARTITION_BYTES of unoptimized (dev) - * executables. */ +/** SINGLE_PARTITION_BYTES of unoptimized (dev) executables. */ const UNOPTIMIZED_SINGLE_PARTITION_BYTES = 64 * 1024; -const UNOPTIMIZED_MIN_PARTITION_BYTES = 32 * 1024; /** Executable objects larger than SINGLE_PARTITION_BYTES are divided into * partitions that the helper simplifies, optimizes and compiles concurrently, * importing small functions across partitions for inlining, and reuses - * individually across builds. A divided module gets at least - * MIN_PROGRAM_PARTITIONS so that mid-size programs use several cores. The - * count is a power of two that depends only on the module's size and - * whether it is optimized, on every target, so artifacts never vary with the - * host and ordinary edits keep every function in its partition. + * individually across builds, so an edit optimizes again only the partitions + * it affects. A divided module gets at least MIN_PROGRAM_PARTITIONS so that + * small and mid-size programs use several cores. The count is a power of two + * that depends only on the module's size and whether it is optimized, on + * every target, so artifacts never vary with the host and ordinary edits keep + * every function in its partition. An optimized module whose largest + * partition would hold more than two thirds of its function text is + * optimized whole by the helper instead (as for one partition), and its code + * is generated in parts. * * Unoptimized (dev) partitions are compiled on their own with nothing * imported, so they cost no run time and an edit compiles only the partitions * whose functions it changes: dev executables are divided from - * UNOPTIMIZED_SINGLE_PARTITION_BYTES, at least - * UNOPTIMIZED_MIN_PARTITION_BYTES each. */ + * UNOPTIMIZED_SINGLE_PARTITION_BYTES. */ export function nativeProgramPartitions(llvmBytes: number, unoptimized = false): number { - const single = unoptimized ? UNOPTIMIZED_SINGLE_PARTITION_BYTES : SINGLE_PARTITION_BYTES; - const least = unoptimized ? UNOPTIMIZED_MIN_PARTITION_BYTES : MIN_PARTITION_BYTES; - if (llvmBytes <= single) return 1; + if (llvmBytes <= (unoptimized ? UNOPTIMIZED_SINGLE_PARTITION_BYTES : SINGLE_PARTITION_BYTES)) + return 1; const required = Math.max( Math.ceil(llvmBytes / PROGRAM_PARTITION_BYTES), - Math.min(MIN_PROGRAM_PARTITIONS, Math.ceil(llvmBytes / least)), + Math.min(MIN_PROGRAM_PARTITIONS, Math.ceil(llvmBytes / MIN_PARTITION_BYTES)), ); return Math.min(MAX_PROGRAM_PARTITIONS, 2 ** Math.ceil(Math.log2(required))); } -/** LLVM text that an optimized one-partition program generates code for on - * one thread, the LLVM text per code generation part, and the most parts. */ -const SINGLE_CODEGEN_BYTES = 128 * 1024; -const CODEGEN_PART_BYTES = 64 * 1024; -const MAX_CODEGEN_PARTS = 8; - -/** Code generation parts for an optimized executable object that - * nativeProgramPartitions keeps as one partition. The helper optimizes the - * module whole, as for a single output, and then generates code for the parts - * concurrently (`--split codegen`), so the generated code does not change. - * Returns 1 for modules of at most SINGLE_CODEGEN_BYTES and for partitioned - * ones. The count is a power of two that depends only on the module's size, - * so artifacts never vary with the host. */ -export function nativeCodegenParts(llvmBytes: number): number { - if (llvmBytes <= SINGLE_CODEGEN_BYTES || llvmBytes > SINGLE_PARTITION_BYTES) return 1; - const required = Math.ceil(llvmBytes / CODEGEN_PART_BYTES); - return Math.min(MAX_CODEGEN_PARTS, 2 ** Math.ceil(Math.log2(required))); -} - /** Output paths for each partition; the first is the requested path. */ export function nativePartitionPaths(path: string, count: number): string[] { const dot = path.lastIndexOf("."); diff --git a/packages/compiler/src/backend/native-codegen.test.ts b/packages/compiler/src/backend/native-codegen.test.ts index fdee2e5ca..bfa69ad42 100644 --- a/packages/compiler/src/backend/native-codegen.test.ts +++ b/packages/compiler/src/backend/native-codegen.test.ts @@ -8,11 +8,7 @@ import { NativeCodegenError, validateNativeCodegenVersion, } from "./native-codegen.js"; -import { - nativeCodegenParts, - nativePartitionPaths, - nativeProgramPartitions, -} from "./native-codegen-core.js"; +import { nativePartitionPaths, nativeProgramPartitions } from "./native-codegen-core.js"; import { nativeArtifactDependenciesStillMatch } from "./native-toolchain.js"; import { LINUX_X64_GNU_TARGET, MACOS_ARM64_TARGET, WASM32_WASI_TARGET } from "./targets.js"; import { compilerReleaseVersion } from "../library/sidecar.js"; @@ -398,18 +394,16 @@ test("sanitized native artifacts fail before helper resolution", async () => { }); test("program partitions depend only on module size", () => { - const megabyte = 1024 * 1024; - expect(nativeProgramPartitions(megabyte)).toBe(1); - expect(nativeProgramPartitions(megabyte + 1)).toBe(4); - expect(nativeProgramPartitions(2 * megabyte)).toBe(4); - expect(nativeProgramPartitions(2 * megabyte + 1)).toBe(8); + const kilobyte = 1024; + const megabyte = 1024 * kilobyte; + expect(nativeProgramPartitions(128 * kilobyte)).toBe(1); + expect(nativeProgramPartitions(128 * kilobyte + 1)).toBe(8); expect(nativeProgramPartitions(32 * megabyte)).toBe(8); // Powers of two keep every function in its partition across small edits. expect(nativeProgramPartitions(32 * megabyte + 1)).toBe(16); expect(nativeProgramPartitions(100 * megabyte)).toBe(32); expect(nativeProgramPartitions(1024 * megabyte)).toBe(64); // Unoptimized (dev) programs are divided from 64 KiB. - const kilobyte = 1024; expect(nativeProgramPartitions(64 * kilobyte, true)).toBe(1); expect(nativeProgramPartitions(64 * kilobyte + 1, true)).toBe(4); expect(nativeProgramPartitions(128 * kilobyte, true)).toBe(4); @@ -427,41 +421,6 @@ test("program partitions depend only on module size", () => { ]); }); -test("code generation parts depend only on module size", () => { - const kilobyte = 1024; - expect(nativeCodegenParts(128 * kilobyte)).toBe(1); - expect(nativeCodegenParts(128 * kilobyte + 1)).toBe(4); - expect(nativeCodegenParts(256 * kilobyte)).toBe(4); - expect(nativeCodegenParts(256 * kilobyte + 1)).toBe(8); - expect(nativeCodegenParts(1024 * kilobyte)).toBe(8); - // Partitioned modules already compile their partitions concurrently. - expect(nativeCodegenParts(1024 * kilobyte + 1)).toBe(1); -}); - -test("split code generation passes --split codegen and no partition cache", async () => { - const pkg = await fakePackage(); - const output = join(pkg.root, "split", "program.o"); - const artifact = await emitNativeArtifact({ - ...request(pkg.root, pkg.packageJson, output), - partitions: 4, - splitCodegen: true, - }); - expect(artifact.outputPaths).toEqual(nativePartitionPaths(output, 4)); - const args = (await readFile(pkg.log, "utf8")).trim().split(" "); - expect(args.filter((arg) => arg === "--output")).toHaveLength(4); - expect(args.join(" ")).toContain("--split codegen"); - expect(args).not.toContain("--cache-dir"); - - // Partitions of the same module are a distinct artifact. - await emitNativeArtifact({ - ...request(pkg.root, pkg.packageJson, join(pkg.root, "partitions", "program.o")), - partitions: 4, - }); - const calls = (await readFile(pkg.log, "utf8")).trim().split("\n"); - expect(calls).toHaveLength(2); - expect(calls[1]).not.toContain("--split"); -}); - test("partitioned objects are emitted, cached and restored together", async () => { const pkg = await fakePackage(); const first = join(pkg.root, "first", "program.o"); diff --git a/packages/compiler/src/backend/native-codegen.ts b/packages/compiler/src/backend/native-codegen.ts index ba04bd7d6..84c76ce91 100644 --- a/packages/compiler/src/backend/native-codegen.ts +++ b/packages/compiler/src/backend/native-codegen.ts @@ -125,9 +125,6 @@ export interface NativeCodegenOptions { cacheRoot?: string | null; /** Program partitions, written to nativePartitionPaths(outputPath). */ partitions?: number; - /** With several partitions: optimize the module whole and divide only its - * code generation among them. */ - splitCodegen?: boolean; /** Profile-guided optimization: instrumentation or an indexed profile. */ profile?: NativeProfileOptions; } @@ -354,7 +351,6 @@ function cacheKey( .update("\0") .update(options.sourcePath) .update((options.partitions ?? 1) > 1 ? `\0partitions:${options.partitions}` : "") - .update(options.splitCodegen === true ? "\0split:codegen" : "") .update(options.profile === undefined ? "" : `\0${nativeProfileKey(options.profile)}`) .digest("hex"); } @@ -458,7 +454,7 @@ export async function emitNativeArtifact( // edit recompiles only the partitions it affects. The directory names the // exact helper build and target; the helper keys entries by content. const partitionCache = - root === null || outputs.length < 2 || options.splitCodegen === true + root === null || outputs.length < 2 ? null : join( root, @@ -498,7 +494,6 @@ export async function emitNativeArtifact( "--source-path", options.sourcePath, ...(partitionCache === null ? [] : ["--cache-dir", partitionCache]), - ...(options.splitCodegen === true && outputs.length > 1 ? ["--split", "codegen"] : []), ...nativeProfileHelperArgs(options.profile), ]); for (const stage of stages) { diff --git a/packages/compiler/src/backend/native-tools.ts b/packages/compiler/src/backend/native-tools.ts index 5056de491..e390097ef 100644 --- a/packages/compiler/src/backend/native-tools.ts +++ b/packages/compiler/src/backend/native-tools.ts @@ -49,9 +49,6 @@ export function emitNativeObject(options: { outputKind?: NativeCodegenOutputKind; /** Program partitions, written to nativePartitionPaths(outputPath). */ partitions?: number; - /** With several partitions: optimize the module whole and divide only its - * code generation among them. */ - splitCodegen?: boolean; /** Private directory where the helper reuses partition artifacts. */ partitionCache?: string; /** Profile-guided optimization: instrumentation or an indexed profile. */ @@ -77,11 +74,9 @@ export function emitNativeObject(options: { "json", "--source-path", options.sourcePath, - ...(options.splitCodegen === true && outputs.length > 1 - ? ["--split", "codegen"] - : options.partitionCache === undefined || outputs.length < 2 - ? [] - : ["--cache-dir", options.partitionCache]), + ...(options.partitionCache === undefined || outputs.length < 2 + ? [] + : ["--cache-dir", options.partitionCache]), ...nativeProfileHelperArgs(options.profile), ]); for (const output of outputs) requireNativeArtifact(output); diff --git a/packages/compiler/src/frontend/lowering/class-protocols.ts b/packages/compiler/src/frontend/lowering/class-protocols.ts index 00eedb6c0..15fd83bdf 100644 --- a/packages/compiler/src/frontend/lowering/class-protocols.ts +++ b/packages/compiler/src/frontend/lowering/class-protocols.ts @@ -589,6 +589,14 @@ export class ClassProtocols { if (facts.observed.has(symbol) || facts.written.has(symbol)) return true; const target = facts.targets.get(symbol); if (target === undefined) return true; + // A type that exposes a program class's method can never be a record + // copy: the copy would drop the method, which Node reads from the + // instance's prototype, and a copied receiver would lose overrides. + // The mark exists because an identity-carrying value flows into the + // type, so keep the view even when nothing compares the value (pi's + // `{ handleInput?(data: string): void } | undefined` field receives + // component instances directly and is only ever called through). + if (this.methodViewSources(target).length > 0) return true; const names = new Set(this.checker.getPropertiesOfType(target).map((p) => ts.symbolName(p))); const origins = facts.origins.get(symbol) ?? new Set(); const writesNames = (written: Set | "all"): boolean => diff --git a/packages/compiler/src/frontend/lowering/class-to-primitive.ts b/packages/compiler/src/frontend/lowering/class-to-primitive.ts index 8807ac5a5..2ee0087f1 100644 --- a/packages/compiler/src/frontend/lowering/class-to-primitive.ts +++ b/packages/compiler/src/frontend/lowering/class-to-primitive.ts @@ -41,6 +41,7 @@ interface ToStringTag { } const PRIMITIVE_KINDS = new Set(["f64", "string", "bool", "bigint", "undefinedT", "nullT"]); +const OBJECT_ARMS_TO_STRING = new Set(["url", "bytes", "date", "regex"]); /** ToString(ToPrimitive(value, hint)) of a program class instance, as Node * computes it: `[Symbol.toPrimitive](hint)` when the class defines it, then @@ -391,8 +392,9 @@ function refuse(lowerer: Lowerer, node: ts.Node, info: ClassInfo, what: string): } /** ToString(ToPrimitive(value, hint)) of a union whose arms are program - * class instances, URLs and primitives (`Box | undefined`, `string | URL`): - * each arm converts as it would alone. Null for other unions. */ + * class instances, URLs, typed arrays, dates, regular expressions and + * primitives (`Box | undefined`, `string | URL`, `URL | Uint8Array`): each + * arm converts as it would alone. Null for other unions. */ export function classUnionToString( lowerer: Lowerer, value: IrExpr, @@ -402,11 +404,19 @@ export function classUnionToString( if (value.type.kind !== "union") return null; const unionId = value.type.unionId; const arms = lowerer.unions.get(unionId)?.arms; - if (!arms || !arms.some((arm) => arm.kind === "object" || arm.kind === "url")) return null; + if (!arms || arms.every((arm) => PRIMITIVE_KINDS.has(arm.kind))) return null; + // Without class or URL arms both hints give the same text, and + // ensureString, which tries the checker's narrowed arm first, converts + // the union with the string hint. + if (hint === "default" && !arms.some((arm) => arm.kind === "object" || arm.kind === "url")) + return null; const convertible = (arm: IrType): boolean => { if (PRIMITIVE_KINDS.has(arm.kind)) return true; - // A URL arm converts through its href, like String(url). - if (arm.kind === "url") return true; + // These arms convert as ensureString converts them alone, with the + // same result for both hints: a URL through its href, a typed array + // through its join, a date through toString, a regular expression + // through its source and flags. + if (OBJECT_ARMS_TO_STRING.has(arm.kind)) return true; if (arm.kind !== "object") return false; const info = lowerer.classes.get(arm.className); return info !== undefined && plainProgramClass(info); diff --git a/packages/compiler/src/frontend/lowering/coercions/structural-plans.ts b/packages/compiler/src/frontend/lowering/coercions/structural-plans.ts index 2a394c4d1..66700a8f4 100644 --- a/packages/compiler/src/frontend/lowering/coercions/structural-plans.ts +++ b/packages/compiler/src/frontend/lowering/coercions/structural-plans.ts @@ -15,6 +15,12 @@ export type FieldLift = * and conversions that can only throw are not structural conversions. */ export function widthLiftPlan(lowerer: Lowerer, src: IrType, dst: IrType): WidthLift | null { if (typeEquals(src, dst)) return { how: "copy" }; + // A promise whose payload widens (`Promise` into `Promise`, + // a settlement type gaining optional members): the checked payload + // widening coerceToExpected applies at a promise slot. The promise keeps + // its identity and settlement turn; the payload converts when it settles. + if (dst.kind === "promise" && src.kind === "promise" && lowerer.promisePayloadWidens(src, dst)) + return { how: "promiseWiden" }; // An 'unknown' (dyn) DESTINATION slot: the static→dyn conversion — // dynFrom, a DEEP COPY (`{ v: 5 }` into `{ v: unknown }`, `number[]` // into `unknown[]` — tsc's top type over the width family's copies). @@ -72,6 +78,7 @@ export function widthLiftPlan(lowerer: Lowerer, src: IrType, dst: IrType): Width (src.kind === "record" && arm.kind === "record") || (src.kind === "array" && arm.kind === "array") || (src.kind === "func" && arm.kind === "func") || + (src.kind === "promise" && arm.kind === "promise") || (src.kind === "object" && arm.kind === "object") || // Tuples already lift into ordinary array slots. Consider that // same conversion when the array is an arm of a union too. @@ -170,6 +177,25 @@ export function widthLiftPlan(lowerer: Lowerer, src: IrType, dst: IrType): Width ) { return { how: "narrow" }; } + // A UNION of tuples/arrays into ONE array slot that is not already one of + // its arms: each arm converts on its own tag (a tuple rebuilds through + // tupleArr, an array copies or lifts through arr), and a unit arm takes + // the stranded TypeError — the slot holds no unit. This sits AFTER the + // checked-extraction rule above, so a union that already contains the + // destination array arm keeps compiling to the same narrow it always did; + // only sources with no conversion at all (the `as const` key-table read) + // gain one. + if (dst.kind === "array" && src.kind === "union") { + const from = lowerer.unions.get(src.unionId); + if ( + from && + !from.arms.some((arm) => typeEquals(arm, dst)) && + from.arms.some((arm) => !isUnitType(arm)) + ) { + if (from.arms.every((arm) => isUnitType(arm) || lowerer.widthLiftPlan(arm, dst) !== null)) + return { how: "unionArr" }; + } + } // A DERIVED instance into a BASE-typed slot (`{ p: Q }` copying into // `{ p: P }`): the same implicit upcast coerceToExpected performs at // top level — prefix layout, a pointer reinterpret, no copy. @@ -190,6 +216,16 @@ export function widthLiftPlan(lowerer: Lowerer, src: IrType, dst: IrType): Width return { how: "funcAdapt" }; } if (dst.kind === "record" && src.kind === "record") { + // An INDEX-SIGNATURE destination takes the overflow CAPTURE reshape: + // declared fields copy off the source, every other source field keys + // into the destination's overflow (the same helper widthCoerce uses + // at top level). Without this the pair had no width plan at all, so a + // union arm, field or element of this shape refused. + if (lowerer.shapes.get(dst.shapeId)?.indexValue !== undefined) { + return lowerer.recordOvfCapturePlan(src.shapeId, dst.shapeId) !== null + ? { how: "ovf" } + : null; + } return lowerer.recordWidthPlan(src.shapeId, dst.shapeId) !== null ? { how: "width" } : null; } if (dst.kind === "record" && src.kind === "union") { diff --git a/packages/compiler/src/frontend/lowering/coercions/structural.ts b/packages/compiler/src/frontend/lowering/coercions/structural.ts index 863472910..a1d3283b3 100644 --- a/packages/compiler/src/frontend/lowering/coercions/structural.ts +++ b/packages/compiler/src/frontend/lowering/coercions/structural.ts @@ -8,7 +8,7 @@ import { arrayValueRead, arrayValueStore, primitiveElement } from "../array-valu import type { WidthLift } from "../width-lift.js"; import { InternalCompilerError } from "../../../errors.js"; import type { IrExpr, IrFunction, IrRecordShape, IrStmt, IrType, SrcLoc } from "../../../ir/ir.js"; -import { BOOL, DYN, F64, STRING, UNDEFINED_T, typeEquals } from "../../../ir/ir.js"; +import { BOOL, DYN, F64, STRING, UNDEFINED_T, isUnitType, typeEquals } from "../../../ir/ir.js"; import { typeKey } from "../../type-mapper.js"; import { findStaticOn, findGenericStaticOn } from "../lower-classes.js"; import { lowerRecordOvfCaptureHelper } from "../containers/indexed-objects.js"; @@ -238,12 +238,26 @@ export function applyWidthLift( throw new InternalCompilerError("lowerer bug: planned width lift failed to intern"); return { kind: "call", callee: helper, args: [value], type: dst, loc }; } + case "ovf": { + if (dst.kind !== "record" || value.type.kind !== "record") + throw new InternalCompilerError("lowerer bug: ovf lift shape"); + const helper = lowerRecordOvfCaptureHelper(lowerer, value.type.shapeId, dst.shapeId, loc); + if (!helper) + throw new InternalCompilerError("lowerer bug: planned ovf lift failed to intern"); + return { kind: "call", callee: helper, args: [value], type: dst, loc }; + } case "unionWidth": { if (dst.kind !== "record" || value.type.kind !== "union") throw new InternalCompilerError("lowerer bug: union width lift shape"); const helper = lowerer.unionRecordWidthHelper(value.type.unionId, dst, loc); return { kind: "call", callee: helper, args: [value], type: dst, loc }; } + case "unionArr": { + if (dst.kind !== "array" || value.type.kind !== "union") + throw new InternalCompilerError("lowerer bug: union array lift shape"); + const helper = lowerer.unionArrayWidthHelper(value.type.unionId, dst, loc); + return { kind: "call", callee: helper, args: [value], type: dst, loc }; + } case "arr": { if (dst.kind !== "array" || value.type.kind !== "array") throw new InternalCompilerError("lowerer bug: arr lift shape"); @@ -318,6 +332,16 @@ export function applyWidthLift( throw new InternalCompilerError("lowerer bug: planned funcAdapt lift failed to intern"); return { kind: "call", callee: adapter, args: [value], type: dst, loc }; } + case "promiseWiden": { + if (dst.kind !== "promise" || value.type.kind !== "promise") + throw new InternalCompilerError("lowerer bug: promiseWiden lift shape"); + const converted = lowerer.coerceToExpected(value, dst); + if (!typeEquals(converted.type, dst)) + throw new InternalCompilerError( + "lowerer bug: planned promise payload widening stopped converting", + ); + return converted; + } default: { const _exhaustive: never = lift; void _exhaustive; @@ -381,6 +405,92 @@ export function unionRecordWidthHelper( return name; } +/** Dispatch a union of tuples/arrays into one array slot from its runtime + * tag (widthLiftPlan's unionArr): each arm rebuilds or copies on its own + * tag through the ordinary width lifts, the same copy stance + * tupleArrayWidthHelper takes for one tuple. A unit arm takes the stranded + * TypeError: the slot holds no unit, the checker excluded that arm, and + * only a lying `!`/assertion still carries it. Intern before descending so + * a recursive arm lift can reuse it. */ +export function unionArrayWidthHelper( + lowerer: Lowerer, + fromId: string, + target: IrType & { kind: "array" }, + loc: SrcLoc, +): string { + const key = `unionArr:${fromId}:${typeKey(target)}`; + const existing = lowerer.valueHelpers.get(key); + if (existing) return existing; + const name = keyedHelperName(lowerer, "%union.arr", key); + lowerer.valueHelpers.set(key, name); + const type: IrType = { kind: "union", unionId: fromId }; + const value = varRef("value.0", type, loc); + const from = lowerer.unions.get(fromId); + if (!from) throw new InternalCompilerError("lowerer bug: missing array union"); + const body: IrStmt[] = []; + for (let tag = 0; tag < from.arms.length; tag++) { + const arm = from.arms[tag]!; + const then: IrStmt[] = []; + if (isUnitType(arm)) { + const what = arm.kind === "nullT" ? "null" : "undefined"; + then.push({ + kind: "throw", + value: { + kind: "libCall", + fn: "error.new", + args: [ + { + kind: "strLit", + value: `${what} is not representable in a '${lowerer.fmt(target)}' slot (a value narrowed or asserted past the type still held it)`, + type: STRING, + loc, + }, + ], + type: { kind: "object", className: "%TypeError" }, + loc, + }, + loc, + }); + } else { + const lift = lowerer.widthLiftPlan(arm, target); + if (!lift) throw new InternalCompilerError("lowerer bug: invalid union array plan"); + const narrowed: IrExpr = { kind: "unionNarrow", unionId: fromId, tag, value, type: arm, loc }; + then.push({ + kind: "return", + value: lowerer.applyWidthLift(lift, narrowed, target, loc), + loc, + }); + } + body.push({ + kind: "if", + cond: { kind: "unionIsTag", unionId: fromId, tag, negated: false, value, type: BOOL, loc }, + then, + else_: null, + loc, + }); + } + body.push({ + kind: "throw", + value: { + kind: "libCall", + fn: "error.new", + args: [{ kind: "strLit", value: "invalid array union tag", type: STRING, loc }], + type: { kind: "object", className: "%TypeError" }, + loc, + }, + loc, + }); + lowerer.liftedFns.push({ + name, + params: [{ localId: "value.0", name: "value", type }], + returnType: target, + locals: [{ id: "value.0", name: "value", type, mutable: false }], + body, + loc, + }); + return name; +} + export function recordWidthHelper( lowerer: Lowerer, fromId: string, diff --git a/packages/compiler/src/frontend/lowering/containers/indexed-objects.ts b/packages/compiler/src/frontend/lowering/containers/indexed-objects.ts index 1c07885ae..92a724b6b 100644 --- a/packages/compiler/src/frontend/lowering/containers/indexed-objects.ts +++ b/packages/compiler/src/frontend/lowering/containers/indexed-objects.ts @@ -869,6 +869,143 @@ function lowerFromEntriesStringRows( return { kind: "call", callee: helper, args: [receiver], type: resultT, loc }; } +/** One destination-field initializer of the overflow capture reshape: a + * directly-lifted source field, or the target field's undefined arm (left + * absent for a runtime keyed write). */ +export type RecordOvfFieldInit = + | { name: string; kind: "undef"; unionId: string; utag: number } + | { name: string; kind: "direct"; src: IrType; lift: WidthLift }; + +/** The planning half of lowerRecordOvfCaptureHelper (its doc is the + * contract): the same gates, with no interning and no observable state (a + * speculative widthLiftPlan call must not mark the source's key order + * observed — the builder does that), so the width relation can promise + * the reshape before any helper exists. */ +export interface RecordOvfCapturePlan { + inits: RecordOvfFieldInit[]; + fieldLifts: Map; + orderedFields: IrRecordShape["fields"]; + consumed: Set; + /** How the source's own overflow slot enters the target's value slot. */ + ovfLift: WidthLift | "dyn" | null; +} + +export function recordOvfCapturePlan( + lowerer: Lowerer, + fromId: string, + toId: string, +): RecordOvfCapturePlan | null { + const from = lowerer.shapes.get(fromId); + const to = lowerer.shapes.get(toId); + if (!from || !to?.indexValue || from.tuple || to.tuple) return null; + const planningKey = `ovfp:${fromId}:${toId}`; + // Recursive shapes re-enter their own pair through a field: assume the + // pair is coercible while the outer plan checks its other fields (the + // same rule recordWidthPlan applies). + if (lowerer.coercions.planning.has(planningKey)) + return { + inits: [], + fieldLifts: new Map(), + orderedFields: [], + consumed: new Set(), + ovfLift: null, + }; + lowerer.coercions.planning.add(planningKey); + try { + const fIv = from.indexValue ?? null; + const tIv = to.indexValue; + // How a source value enters the target's value slot: identity, the + // dyn conversion, or a width lift (wrap/retag/nested reshape). + const slotLift = (t: IrType): WidthLift | "dyn" | null => { + if (typeEquals(t, tIv)) return { how: "copy" }; + if (tIv.kind === "dyn" && (t.kind === "dyn" || lowerer.dynConvertible(t))) return "dyn"; + return lowerer.widthLiftPlan(t, tIv); + }; + // The overflow value slot must line up (sources without an index + // signature have no overflow to carry over). + if (fIv && slotLift(fIv) === null) return null; + // Target declared fields initialize one of two ways. A same-named + // SOURCE declared field whose type lifts initializes the slot DIRECTLY + // (consumed — no keyed write below): the declared half of a width flow + // into a hybrid target (`{ a: 1, b: 2 }` into `{ a: number; + // [k: string]: number }`), which is also what admits REQUIRED declared + // target fields — tsc guarantees the source declares them (an index + // signature alone never satisfies a required member), and an + // index-signature source's OVERFLOW can never collide with them (its + // own declared name owns the key). Every other target field defaults + // to its undefined arm (fresh record), so it must be optional-flavored + // and must accept a runtime collision (dyn slots validate via dynCheck + // — fields must be dyn-convertible; typed slots write through + // directly). + const inits: RecordOvfFieldInit[] = []; + const consumed = new Set(); + for (const tf of to.fields) { + const sf = from.fields.find((f) => f.name === tf.name); + const directLift = sf ? lowerer.widthLiftPlan(sf.type, tf.type) : null; + if (sf && directLift) { + inits.push({ name: tf.name, kind: "direct", src: sf.type, lift: directLift }); + consumed.add(tf.name); + continue; + } + if (tf.type.kind !== "union") return null; + const utag = lowerer.armTag(tf.type.unionId, UNDEFINED_T); + if (utag < 0) return null; + if (tIv.kind === "dyn" ? !lowerer.dynConvertible(tf.type) : !typeEquals(tf.type, tIv)) + return null; + inits.push({ name: tf.name, kind: "undef", unionId: tf.type.unionId, utag }); + } + // Every source declared field must flow into the keyed-write slot — + // in DECLARATION order (insertion order below; declaredOrder omits + // internal '%'-fields, which never enter the copy). + const orderedFields = from.declaredOrder + ? from.declaredOrder.flatMap((n) => { + const f = from.fields.find((x) => x.name === n); + return f ? [f] : []; + }) + : from.fields; + const fieldLifts = new Map(); + for (const ff of orderedFields) { + if (consumed.has(ff.name)) continue; // direct-initialized above + const lift = slotLift(ff.type); + if (lift === null) return null; + fieldLifts.set(ff.name, lift); + } + // DISPATCH writes — keys that can hit a declared target slot: the + // overflow loop's runtime keys, and a literal source-field name the + // target also declares. Non-dyn slots store THROUGH on a collision, so + // such writes need every declared field to BE the slot type (the + // validator's recordKeySet rule); dyn slots validate per field + // (dynCheck), so each field must be dyn-convertible. Writes to + // literal names the target does NOT declare are overflowOnly and + // exempt — which is what lets a direct-initialized required field + // (`id: number` beside a `number | undefined` slot) coexist with a + // declared-only source, while an index-signature source declines. + const dispatchWrites = + fIv !== undefined || + orderedFields.some( + (ff) => !consumed.has(ff.name) && to.fields.some((f) => f.name === ff.name), + ); + if (dispatchWrites) { + if ( + tIv.kind === "dyn" + ? !to.fields.every((f) => lowerer.dynConvertible(f.type)) + : !to.fields.every((f) => typeEquals(f.type, tIv)) + ) { + return null; + } + } + return { + inits, + fieldLifts, + orderedFields, + consumed, + ovfLift: fIv ? slotLift(fIv) : null, + }; + } finally { + lowerer.coercions.planning.delete(planningKey); + } +} + /** Interned `%rec.capture.(s)` — the INDEX-SIGNATURE reshape behind * `Object.fromEntries(entries) as ModelPricing` (hybrid→hybrid width * coercion) and `const r: Record = { a: 1, b: 2 }` @@ -891,100 +1028,30 @@ function lowerFromEntriesStringRows( * slot), and every target field must either be initialized directly by * a same-named source declared field (the rule that admits REQUIRED * declared members) or be optional-flavored (the fresh record's - * default) and writable at runtime. */ + * default) and writable at runtime. + * + * The width relation's `{ how: "ovf" }` lift (a record into an + * index-signature record arm) builds this same helper through + * recordOvfCapturePlan. */ export function lowerRecordOvfCaptureHelper( lowerer: Lowerer, fromId: string, toId: string, loc: SrcLoc, ): string | null { - const from = lowerer.shapes.get(fromId); + const plan = recordOvfCapturePlan(lowerer, fromId, toId); + if (!plan) return null; const to = lowerer.shapes.get(toId); - if (!from || !to?.indexValue || from.tuple || to.tuple) return null; + const from = lowerer.shapes.get(fromId); + if (!to || !from) return null; const fIv = from.indexValue ?? null; const tIv = to.indexValue; - // How a source value enters the target's value slot: identity, the - // dyn conversion, or a width lift (wrap/retag/nested reshape). - const slotLift = (t: IrType): WidthLift | "dyn" | null => { - if (typeEquals(t, tIv)) return { how: "copy" }; - if (tIv.kind === "dyn" && (t.kind === "dyn" || lowerer.dynConvertible(t))) return "dyn"; - return lowerer.widthLiftPlan(t, tIv); - }; - // The overflow value slot must line up (sources without an index - // signature have no overflow to carry over). - if (fIv && slotLift(fIv) === null) return null; - // Target declared fields initialize one of two ways. A same-named - // SOURCE declared field whose type lifts initializes the slot DIRECTLY - // (consumed — no keyed write below): the declared half of a width flow - // into a hybrid target (`{ a: 1, b: 2 }` into `{ a: number; - // [k: string]: number }`), which is also what admits REQUIRED declared - // target fields — tsc guarantees the source declares them (an index - // signature alone never satisfies a required member), and an - // index-signature source's OVERFLOW can never collide with them (its - // own declared name owns the key). Every other target field defaults - // to its undefined arm (fresh record), so it must be optional-flavored - // and must accept a runtime collision (dyn slots validate via dynCheck - // — fields must be dyn-convertible; typed slots write through - // directly). - type FieldInit = - | { name: string; kind: "undef"; unionId: string; utag: number } - | { name: string; kind: "direct"; src: IrType; lift: WidthLift }; - const inits: FieldInit[] = []; - const consumed = new Set(); - for (const tf of to.fields) { - const sf = from.fields.find((f) => f.name === tf.name); - const directLift = sf ? lowerer.widthLiftPlan(sf.type, tf.type) : null; - if (sf && directLift) { - inits.push({ name: tf.name, kind: "direct", src: sf.type, lift: directLift }); - consumed.add(tf.name); - continue; - } - if (tf.type.kind !== "union") return null; - const utag = lowerer.armTag(tf.type.unionId, UNDEFINED_T); - if (utag < 0) return null; - if (tIv.kind === "dyn" ? !lowerer.dynConvertible(tf.type) : !typeEquals(tf.type, tIv)) - return null; - inits.push({ name: tf.name, kind: "undef", unionId: tf.type.unionId, utag }); - } - // Every source declared field must flow into the keyed-write slot — - // in DECLARATION order (insertion order below; declaredOrder omits - // internal '%'-fields, which never enter the copy). + if (tIv === undefined) return null; + // The copy below writes the source's declared fields in order, so a + // program that reaches it observes that order (the plan itself is pure: + // a speculative widthLiftPlan call must not mark a shape observed). lowerer.keyOrder.observed.add(from.id); - const orderedFields = from.declaredOrder - ? from.declaredOrder.flatMap((n) => { - const f = from.fields.find((x) => x.name === n); - return f ? [f] : []; - }) - : from.fields; - const fieldLifts = new Map(); - for (const ff of orderedFields) { - if (consumed.has(ff.name)) continue; // direct-initialized above - const lift = slotLift(ff.type); - if (lift === null) return null; - fieldLifts.set(ff.name, lift); - } - // DISPATCH writes — keys that can hit a declared target slot: the - // overflow loop's runtime keys, and a literal source-field name the - // target also declares. Non-dyn slots store THROUGH on a collision, so - // such writes need every declared field to BE the slot type (the - // validator's recordKeySet rule); dyn slots validate per field - // (dynCheck), so each field must be dyn-convertible. Writes to - // literal names the target does NOT declare are overflowOnly and - // exempt — which is what lets a direct-initialized required field - // (`id: number` beside a `number | undefined` slot) coexist with a - // declared-only source, while an index-signature source declines. - const dispatchWrites = - fIv !== undefined || - orderedFields.some((ff) => !consumed.has(ff.name) && to.fields.some((f) => f.name === ff.name)); - if (dispatchWrites) { - if ( - tIv.kind === "dyn" - ? !to.fields.every((f) => lowerer.dynConvertible(f.type)) - : !to.fields.every((f) => typeEquals(f.type, tIv)) - ) { - return null; - } - } + const { inits, fieldLifts, orderedFields, consumed } = plan; const key = `ovf:${fromId}:${toId}`; const existing = lowerer.valueHelpers.get(key); if (existing) return existing; @@ -1092,7 +1159,7 @@ export function lowerRecordOvfCaptureHelper( // sources carry one). const ksT = arrayOf(STRING); if (fIv) { - const ovfLift = slotLift(fIv)!; + const ovfLift = plan.ovfLift!; body.push( { kind: "varDecl", diff --git a/packages/compiler/src/frontend/lowering/expressions/asserted-receiver.ts b/packages/compiler/src/frontend/lowering/expressions/asserted-receiver.ts new file mode 100644 index 000000000..0a17b5e04 --- /dev/null +++ b/packages/compiler/src/frontend/lowering/expressions/asserted-receiver.ts @@ -0,0 +1,207 @@ +/* A record assertion used as the object of a member PRESENCE PROBE: + * `typeof (stored as R).member`, `(stored as R).member !== undefined`, + * `null != (stored as R).member`. + * + * Node erases a type assertion, so the read answers off the value actually + * stored: arms of the stored union that declare the member answer their + * slot, and the others answer undefined (native records are closed). An + * eager checked extraction of R at the assertion would instead trap on + * every other arm, turning the probe — code that exists precisely BECAUSE + * the stored value may be another arm — into a TypeError where Node + * answers "undefined". + * + * The assertion's extraction is therefore deferred to the member read. + * lowerAsExpression erases it for this use; property access answers the + * read per arm when `assertedMemberRead` can (arms that declare the member + * against arms that do not), and applies the extraction the assertion + * would otherwise have taken eagerly when it cannot + * (applyDeferredAssertion). + * + * Only a probe defers. A real use of the asserted value — the member + * called, compared by identity, or read in any other context — keeps the + * checked extraction, so a lying assertion still throws at the use + * (divergence 38's lying-assertion stance). Types don't lie; a probe + * simply isn't the use. + */ +import * as ts from "../../ts7/adapter.js"; +import { type IrExpr, type IrType, type SrcLoc, isUnitType, typeEquals } from "../../../ir/ir.js"; +import type { Lowerer } from "../lowerer.js"; +import { tryLowerExpression } from "./try-lower-expression.js"; +import { hostedStorageView } from "./hosted-narrowing.js"; + +/** The assertion node at the root of `node`, through parentheses; null + * when `node` is not an (`as` / ``) assertion. */ +export function assertedReceiverNode( + node: ts.Expression, +): ts.AsExpression | ts.TypeAssertion | null { + let e = node; + while (ts.isParenthesizedExpression(e)) e = e.expression; + return ts.isAsExpression(e) || ts.isTypeAssertion(e) ? e : null; +} + +/** The property access this assertion is the receiver of, through + * parentheses: `(u as R).member`. Null for any other use, and for the + * optional chain `(u as R)?.member` (a unit arm answers undefined there + * instead of Node's read-of-undefined TypeError). */ +function assertedReceiverAccess( + assertion: ts.AsExpression | ts.TypeAssertion, +): ts.PropertyAccessExpression | null { + let use: ts.Node = assertion; + for (;;) { + const parent = use.parent; + if (parent && ts.isParenthesizedExpression(parent) && parent.expression === use) { + use = parent; + continue; + } + break; + } + const access = use.parent; + return access && + ts.isPropertyAccessExpression(access) && + access.expression === use && + access.questionDotToken === undefined + ? access + : null; +} + +/** An operand that names the absent values (`undefined`, `null`), through + * parentheses and the `void 0` spelling. */ +function isAbsenceLiteral(node: ts.Expression): boolean { + let e = node; + while (ts.isParenthesizedExpression(e)) e = e.expression; + if (e.kind === ts.SyntaxKind.NullKeyword) return true; + if (ts.isIdentifier(e)) return e.text === "undefined"; + return ts.isVoidExpression(e) && ts.isNumericLiteral(e.expression) && e.expression.text === "0"; +} + +/** The property access this assertion is the receiver of when the read is + * a presence probe: the operand of `typeof`, or an operand of `===` / + * `!==` / `==` / `!=` against undefined or null. Null for the optional + * chain and for every real use (a call's callee, an identity comparison, + * a read that flows into a typed slot), which keep the assertion's checked + * extraction. */ +export function assertedProbeAccessOf( + assertion: ts.AsExpression | ts.TypeAssertion, +): ts.PropertyAccessExpression | null { + const access = assertedReceiverAccess(assertion); + if (access === null) return null; + let use: ts.Node = access; + while (ts.isParenthesizedExpression(use.parent) && use.parent.expression === use) + use = use.parent; + const parent = use.parent; + if (parent === undefined) return null; + if (ts.isTypeOfExpression(parent) && parent.expression === use) return access; + if (ts.isBinaryExpression(parent)) { + const comparable = parent.left === use || (parent.right === use && parent.left !== use); + if (!comparable) return null; + const op = parent.operatorToken.kind; + const equality = + op === ts.SyntaxKind.EqualsEqualsEqualsToken || + op === ts.SyntaxKind.EqualsEqualsToken || + op === ts.SyntaxKind.ExclamationEqualsEqualsToken || + op === ts.SyntaxKind.ExclamationEqualsToken; + if (!equality) return null; + const other = parent.left === use ? parent.right : parent.left; + if (isAbsenceLiteral(other)) return access; + } + return null; +} + +/** True when the union's record arms read `member` unevenly: at least one + * declares it as a data field and at least one does not (it declares a + * getter instead, or nothing). Those are exactly the arms + * assertedMemberRead answers per arm, calling a getter where an arm has + * one; anything else keeps the assertion's extraction (and with it the + * checked-narrow fence for a lying one), as does an arm with only a setter + * of that name. */ +export function partialRecordMember(lowerer: Lowerer, union: IrType, member: string): boolean { + if (union.kind !== "union") return false; + const arms = lowerer.unions.get(union.unionId)?.arms ?? []; + let records = 0; + let declaring = 0; + for (const arm of arms) { + if (isUnitType(arm)) continue; + if (arm.kind !== "record") return false; + const shape = lowerer.shapes.get(arm.shapeId); + if (!shape || shape.tuple || shape.indexValue) return false; + const getter = shape.fields.some((field) => field.name === `%get:${member}`); + if (!getter && shape.fields.some((field) => field.name === `%set:${member}`)) return false; + records++; + if (shape.fields.some((field) => field.name === member)) declaring++; + } + return records > 0 && declaring > 0 && declaring < records; +} + +/** The asserted record type whose checked extraction is deferred for the + * probe `access`, or null when the read keeps the ordinary assertion + * lowering. `value` is the assertion's lowered operand. */ +export function deferredMemberReadTarget( + lowerer: Lowerer, + assertion: ts.AsExpression | ts.TypeAssertion, + access: ts.PropertyAccessExpression, + value: IrExpr, +): IrType | null { + if (assertedProbeAccessOf(assertion) !== access) return null; + if (value.type.kind !== "union") return null; + const target = lowerer.mapTypeOf(lowerer.checker.getTypeFromTypeNode(assertion.type)); + if (target?.kind !== "record") return null; + if (!partialRecordMember(lowerer, value.type, access.name.text)) return null; + return target; +} + +/** Whether the property access `expr` is a probe read through an assertion + * that the per-arm answer claims (the same shape deferredMemberReadTarget + * recognizes). Checker-derived field targets must not claim it first: the + * field target would apply the assertion's checked extraction and trap on + * the arm the probe exists to detect. */ +export function deferredProbeReadAccess( + lowerer: Lowerer, + access: ts.PropertyAccessExpression, +): boolean { + const assertion = assertedReceiverNode(access.expression); + if (assertion === null) return false; + if (assertedProbeAccessOf(assertion) !== access) return false; + const value = tryLowerExpression(lowerer, access.expression); + return value !== null && deferredMemberReadTarget(lowerer, assertion, access, value) !== null; +} + +/** The stored value of an `in` probe whose receiver is an erased assertion + * (`"stream" in (stored as R)`): Node asks membership of the value actually + * stored, which for a union of fixed record shapes the union path answers + * per arm. Null when the receiver is not such an assertion, or the stored + * value is not a union of fixed record shapes (those keep the ordinary + * lowering, and its refusals). */ +export function assertedInOperand(lowerer: Lowerer, node: ts.Expression): IrExpr | null { + let rhs = node; + while (ts.isParenthesizedExpression(rhs)) rhs = rhs.expression; + if (!ts.isAsExpression(rhs) && !ts.isTypeAssertion(rhs)) return null; + const stored = tryLowerExpression(lowerer, rhs.expression); + if (stored === null || stored.type.kind !== "union") return null; + const arms = lowerer.unions.get(stored.type.unionId)?.arms ?? []; + const fixed = arms.every((arm) => { + if (arm.kind !== "record") return false; + const shape = lowerer.shapes.get(arm.shapeId); + return shape !== undefined && !shape.tuple && !shape.indexValue; + }); + return arms.length > 0 && fixed ? stored : null; +} + +/** Apply the extraction lowerAsExpression would have applied to the + * assertion's operand, for a member read with no per-arm answer. */ +export function applyDeferredAssertion( + lowerer: Lowerer, + value: IrExpr, + target: IrType, + loc: SrcLoc, +): IrExpr { + if (value.type.kind === "union" && target.kind !== "union" && !typeEquals(target, value.type)) { + const helper = lowerer.narrowedArmHelper(value.type.unionId, target, loc); + if (helper !== null) return lowerer.checkedNarrowCall(helper, value, target, loc); + } + if (target.kind === "union" || target.kind === "record") { + const hosted = hostedStorageView(lowerer, value, target, loc); + if (hosted) return hosted; + } + lowerer.assertedValues.set(value, target); + return value; +} diff --git a/packages/compiler/src/frontend/lowering/expressions/narrowing.ts b/packages/compiler/src/frontend/lowering/expressions/narrowing.ts index acc065f18..6a6a25292 100644 --- a/packages/compiler/src/frontend/lowering/expressions/narrowing.ts +++ b/packages/compiler/src/frontend/lowering/expressions/narrowing.ts @@ -56,6 +56,7 @@ import { assertsGlobalObject, extractionCopiesReference } from "../assertion-ide import { iterableSlotValue, iterableUnionOf } from "../iterable-values.js"; import { isDeclBoundaryTypeNode } from "../../decl-projection.js"; import { isMessageListenerCall } from "./message-listeners.js"; +import { assertedProbeAccessOf, deferredMemberReadTarget } from "./asserted-receiver.js"; /** `c ? a : b`. An explicit destination constructs each branch at that * layout before joining it. Without one, retain the expression's inferred @@ -1428,6 +1429,17 @@ export function lowerAsExpression( if (inner.type.kind !== "dyn") { const targetTs0 = lowerer.checker.getTypeFromTypeNode(expr.type); const target = lowerer.mapTypeOf(targetTs0); + // A PRESENCE PROBE of a member read through the assertion (`typeof + // (u as R).member`, `(u as R).member !== undefined`): the read answers + // off the value actually stored, since Node erases the assertion and an + // arm that does not declare the member answers undefined. The read + // answers that per arm (assertedMemberRead); an eager extraction of R + // here would trap first and turn the probe into a TypeError. Defer to + // the read (asserted-receiver.ts); every real use keeps the extraction. + { + const access = assertedProbeAccessOf(expr); + if (access && deferredMemberReadTarget(lowerer, expr, access, inner) !== null) return inner; + } if (target?.kind === "object") { const asserted = checkedClassAssertion(lowerer, inner, target, locOf(expr)); if (asserted) return asserted; diff --git a/packages/compiler/src/frontend/lowering/expressions/property-access.ts b/packages/compiler/src/frontend/lowering/expressions/property-access.ts index e15204324..8f41aa93e 100644 --- a/packages/compiler/src/frontend/lowering/expressions/property-access.ts +++ b/packages/compiler/src/frontend/lowering/expressions/property-access.ts @@ -125,6 +125,12 @@ import { dynUndefinedExpr, nodeThrowExpr, numLit, strLit, varRef } from "../../. import { typedArrayLacksMember } from "../builtins/typed-array-members.js"; import { TEMPORAL_GLOBAL } from "../../builtin-modules.js"; import { tryLowerExpression } from "./try-lower-expression.js"; +import { + applyDeferredAssertion, + assertedReceiverNode, + deferredMemberReadTarget, + deferredProbeReadAccess, +} from "./asserted-receiver.js"; import { lowerRecordInheritedMember } from "../record-inherited.js"; import { DATE_METHOD_HINT } from "../builtins/dates.js"; import { @@ -1398,6 +1404,25 @@ export function lowerPropertyAccessExpr( // receiver that lowers cleanly means the MEMBER is the gap — name // the property and the receiver's type instead of "property access". let recvLowered = lowerer.lowerExpr(expr.expression); + // A PRESENCE PROBE of a member read through an erased assertion + // (`typeof (u as R).member`, `(u as R).member !== undefined`): Node + // reads the property off the value actually stored, so an arm that does + // not declare it answers undefined. lowerAsExpression deferred the + // assertion's checked extraction for this read; answer it per arm + // (assertedMemberRead), and only a member with no per-arm answer applies + // the extraction the assertion would otherwise have taken (a lying + // assertion still throws on a real use, never on the probe). + { + const assertion = assertedReceiverNode(expr.expression); + const target = assertion + ? deferredMemberReadTarget(lowerer, assertion, expr, recvLowered) + : null; + if (target !== null) { + const read = assertedMemberRead(lowerer, expr, recvLowered, loc); + if (read) return read; + recvLowered = applyDeferredAssertion(lowerer, recvLowered, target, loc); + } + } // A receiver whose value union holds record types the checker's subtype // reduction folded into its type (`{ status } | { status, value }` typed // `{ status }`, a call of a function with an inferred return type): read @@ -1692,7 +1717,9 @@ function assertedMemberRead( const name = expr.name.text; let resultType: IrType | null = null; let records = 0; - const declaring: { tag: number; shapeId: string; type: IrType }[] = []; + // A getter arm answers by calling its getter, once per read, as Node's + // read does; a data arm reads its slot. + const declaring: { tag: number; shapeId: string; type: IrType; getter: boolean }[] = []; const units: number[] = []; for (let tag = 0; tag < arms.length; tag++) { const arm = arms[tag]!; @@ -1704,16 +1731,21 @@ function assertedMemberRead( records++; const shape = lowerer.shapes.get(arm.shapeId); if (!shape || shape.tuple || shape.indexValue) return null; - const field = shape.fields.find((f) => f.name === name); + const getter = shape.fields.find((f) => f.name === `%get:${name}`); + if (!getter && shape.fields.some((f) => f.name === `%set:${name}`)) return null; + if (getter && getter.type.kind !== "func") return null; + const field = getter ?? shape.fields.find((f) => f.name === name); if (!field) continue; + const fieldType = field.type.kind === "func" && getter ? field.type.ret : field.type; // The declaring arms agree up to an undefined arm (`string` beside // `string | undefined`). - const armResult = lowerer.withUndefinedArmOf(field.type); + const armResult = lowerer.withUndefinedArmOf(fieldType); if (!armResult || (resultType && !typeEquals(resultType, armResult))) return null; resultType = armResult; - declaring.push({ tag, shapeId: arm.shapeId, type: field.type }); + declaring.push({ tag, shapeId: arm.shapeId, type: field.type, getter: getter !== undefined }); } - if (!resultType || declaring.length === records) return null; + if (!resultType || (declaring.length === records && declaring.every((d) => !d.getter))) + return null; const stable = lowerer.declareHiddenLocal("%assertedRecv", recv.type); const ref = (): IrExpr => ({ kind: "varRef", localId: stable.id, type: recv.type, loc }); let result: IrExpr | null = lowerer.wrappedUndefined(resultType, loc); @@ -1736,15 +1768,19 @@ function assertedMemberRead( loc, }; } - for (const { tag, shapeId, type: fieldType } of declaring) { - const read: IrExpr = { + for (const { tag, shapeId, type: fieldType, getter } of declaring) { + const slot: IrExpr = { kind: "recordGet", obj: { kind: "unionNarrow", unionId, tag, value: ref(), type: arms[tag]!, loc }, shapeId, - field: name, + field: getter ? `%get:${name}` : name, type: fieldType, loc, }; + const read: IrExpr = + getter && fieldType.kind === "func" + ? { kind: "callValue", callee: slot, args: [], type: fieldType.ret, loc } + : slot; result = { kind: "ternary", cond: { kind: "unionIsTag", unionId, tag, negated: false, value: ref(), type: BOOL, loc }, @@ -3145,6 +3181,12 @@ export function lowerFieldRead(lowerer: Lowerer, expr: ts.PropertyAccessExpressi }; } } + // A presence probe of a member read through a record assertion + // (`typeof (u as R).member`): the field target comes from the ASSERTED + // checker type and would apply the checked extraction, trapping on the + // very arm the probe exists to detect. Leave the read to the per-arm + // answer in lowerPropertyAccessExpr (asserted-receiver.ts). + if (deferredProbeReadAccess(lowerer, expr)) return null; const target = lowerer.fieldTarget(expr); if (target) return lowerer.fieldGetExpr(target, locOf(expr), expr); if (expr.questionDotToken) return null; diff --git a/packages/compiler/src/frontend/lowering/expressions/relational.ts b/packages/compiler/src/frontend/lowering/expressions/relational.ts index 7cc573085..c7d3b10a5 100644 --- a/packages/compiler/src/frontend/lowering/expressions/relational.ts +++ b/packages/compiler/src/frontend/lowering/expressions/relational.ts @@ -50,6 +50,7 @@ import { type ClassInfo, storedClassValueType } from "../lower-classes.js"; import { classInstanceOf } from "../class-dynamic-dispatch.js"; import { InternalCompilerError } from "../../../errors.js"; import { tryLowerExpression } from "./try-lower-expression.js"; +import { assertedInOperand } from "./asserted-receiver.js"; import { isJsSourceFile, locOf } from "../../program.js"; import { checkedClassInstanceOf } from "../class-construction.js"; import { unsupportedDiag } from "../../../diagnostics/diagnostic.js"; @@ -1672,7 +1673,11 @@ export function lowerInExpression( if (probed) return probed; } } - let recv = lowerer.lowerExpr(expr.right); + // `"stream" in (stored as R)` probes the value actually stored (Node + // erases the assertion): a union of fixed record shapes answers per arm + // below, so the assertion's checked extraction must not run first (it + // would trap on the very arm the probe exists to detect). + let recv = assertedInOperand(lowerer, expr.right) ?? lowerer.lowerExpr(expr.right); const classInfo = recv.type.kind === "classval" ? lowerer.classes.get(recv.type.className) : undefined; if ( @@ -1877,17 +1882,17 @@ export function lowerInExpression( return perValueUnionIn(lowerer, recv, unionId, key, answers, loc); } if (staticAnswers) { - const pureRecv = - recv.kind === "varRef" || - recv.kind === "recordGet" || - recv.kind === "fieldGet" || - pureRecvNode; + // A field read is pure only over a pure object: the stored value of + // an asserted receiver (`"k" in (make().stored as R)`) still + // evaluates its call. + const pureRecv = isSafeToRepeat(recv) || pureRecvNode; + let value = recv; const isTag = (tag: number, negated: boolean): IrExpr => ({ kind: "unionIsTag", unionId, tag, negated, - value: recv, + value, type: BOOL, loc, }); @@ -1913,27 +1918,29 @@ export function lowerInExpression( if (trues.length === 1) return isTag(trues[0]!.tag, false); const falses = answers.filter((a) => !a.has); if (falses.length === 1) return isTag(falses[0]!.tag, true); - // Several arms on each side: the OR chain re-reads the receiver, - // so only side-effect-free reads qualify. - if (pureRecv) { - let out: IrExpr = isTag(trues[0]!.tag, false); - for (const t of trues.slice(1)) { - out = { - kind: "logical", - op: "||", - left: out, - right: isTag(t.tag, false), - type: BOOL, - loc, - }; - } - return out; + // Several arms on each side: the OR chain re-reads the receiver, so + // anything but a pure read is evaluated once into a hidden local. + const stable = pureRecv ? null : lowerer.declareHiddenLocal("%inRecv", recv.type); + if (stable) value = varRef(stable.id, recv.type, loc); + let out: IrExpr = isTag(trues[0]!.tag, false); + for (const t of trues.slice(1)) { + out = { + kind: "logical", + op: "||", + left: out, + right: isTag(t.tag, false), + type: BOOL, + loc, + }; } - lowerer.unsupported( - "SC1090", - expr, - "statically-decided 'in' on computed receivers (bind the value to a variable first)", - ); + if (!stable) return out; + return { + kind: "seqExpr", + stmts: [{ kind: "varDecl", localId: stable.id, init: recv, loc }], + result: out, + type: BOOL, + loc, + }; } } // A literal member name over a typed array (`"BYTES_PER_ELEMENT" in a`): diff --git a/packages/compiler/src/frontend/lowering/lowerer.test.ts b/packages/compiler/src/frontend/lowering/lowerer.test.ts index f36695234..6f1c1c555 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.test.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.test.ts @@ -1,5 +1,6 @@ import { expect, test, vi } from "vitest"; import { + DYN, F64, NULL_T, STRING, @@ -276,3 +277,71 @@ test("stdlib provenance is cached by symbol identity, including merged and shado } expect(declarationReads).toBe(2); }); + +/** A credentials-shaped record pair: the source declares every target + * field plus `type`, both carry an `unknown` index signature. */ +function credentialPair(lowerer: Lowerer): { + source: IrType; + target: IrType; + sourceId: string; + targetId: string; +} { + const sourceId = lowerer.shapes.intern( + [ + { name: "access", type: STRING }, + { name: "expires", type: F64 }, + { name: "refresh", type: STRING }, + { name: "type", type: STRING }, + ], + false, + DYN, + ["refresh", "access", "expires", "type"], + ); + const targetId = lowerer.shapes.intern( + [ + { name: "access", type: STRING }, + { name: "expires", type: F64 }, + { name: "refresh", type: STRING }, + ], + false, + DYN, + ); + return { source: record(sourceId), target: record(targetId), sourceId, targetId }; +} + +test("an index-signature record destination takes the overflow capture reshape", () => { + const lowerer = context(); + const { source, target } = credentialPair(lowerer); + expect(lowerer.widthLiftPlan(source, target)).toEqual({ how: "ovf" }); + // A required destination field the source does not declare has no + // fresh-record default: the reshape declines. + const required = record( + lowerer.shapes.intern( + [ + { name: "access", type: STRING }, + { name: "scope", type: STRING }, + ], + false, + DYN, + ), + ); + expect(lowerer.widthLiftPlan(source, required)).toBeNull(); +}); + +test("a union record arm routes into an index-signature arm by overflow capture", () => { + const lowerer = context(); + const { source, target, sourceId, targetId } = credentialPair(lowerer); + const from = lowerer.unions.intern([source, UNDEFINED_T]); + const to = lowerer.unions.intern([target, UNDEFINED_T]); + const helper = lowerer.unionRetagHelper(from, to, loc); + expect(helper).not.toBeNull(); + const capture = lowerer.valueHelpers.get(`ovf:${sourceId}:${targetId}`); + expect(capture).toBeDefined(); + // The retag helper applies the capture to the record arm and keeps the + // unit arm's tag. + expect(JSON.stringify(lowerer.liftedFns.find((fn) => fn.name === helper)?.body)).toContain( + capture!, + ); + expect(lowerer.unionRetagHelper(from, to, loc)).toBe(helper); + expect(lowerer.liftedFns.filter((fn) => fn.name === capture)).toHaveLength(1); +}); diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index 4c521386f..bf202ab5b 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -130,6 +130,7 @@ import { widthCoerce, applyWidthLift, unionRecordWidthHelper, + unionArrayWidthHelper, recordWidthHelper, tupleArrayWidthHelper, emptyArrayLiftHelper, @@ -533,7 +534,11 @@ import { lowerSetMethodCall, lowerSetForEachCall, } from "./containers/collection-methods.js"; -import { lowerEnvToPairsHelper } from "./containers/indexed-objects.js"; +import { + lowerEnvToPairsHelper, + recordOvfCapturePlan, + type RecordOvfCapturePlan, +} from "./containers/indexed-objects.js"; import { lowerBufferStaticCall, lowerBytesMethodCall, lowerBytesNew } from "./containers/bytes.js"; import { lowerRegexMethodCall, lowerStringMethodCall } from "./containers/string-and-regexp.js"; import { @@ -6748,6 +6753,7 @@ export class Lowerer { isUndefined: (node) => node.text === "undefined" && ((typeOrNull(node)?.flags ?? 0) & ts.TypeFlags.Undefined) !== 0, + neverReturns: (call) => ((typeOrNull(call)?.flags ?? 0) & ts.TypeFlags.Never) !== 0, }); presenceProofs.set(use, proven); return proven; @@ -12358,6 +12364,20 @@ export class Lowerer { const lifted = unionArmsWidthLift(this, expr, expected); if (lifted) return lifted; } + // A UNION of tuples/arrays flowing into an array slot that is not one + // of its arms (an `as const` key table's index read into + // `readonly string[]`): a width plan converts every arm on its own + // tag, a tuple rebuilding through tupleArr. Planned after the + // checked extraction above, and skipped when an arm already IS the + // slot, so every union that converted before keeps the same IR. + if ( + expr.type.kind === "union" && + expected.kind === "array" && + !(this.unions.get(expr.type.unionId)?.arms ?? []).some((arm) => typeEquals(arm, expected)) + ) { + const lift = this.widthLiftPlan(expr.type, expected); + if (lift) return this.applyWidthLift(lift, expr, expected, expr.loc); + } if (!typeEquals(expr.type, expected)) { const w = this.widthCoerce(expr, expected); if (w) return w; @@ -12561,10 +12581,18 @@ export class Lowerer { return unionRecordWidthHelper(this, fromId, target, loc); } + unionArrayWidthHelper(fromId: string, target: IrType & { kind: "array" }, loc: SrcLoc): string { + return unionArrayWidthHelper(this, fromId, target, loc); + } + recordWidthPlan(fromId: string, toId: string): Map | null { return recordWidthPlan(this, fromId, toId); } + recordOvfCapturePlan(fromId: string, toId: string): RecordOvfCapturePlan | null { + return recordOvfCapturePlan(this, fromId, toId); + } + describeRecordWidthBlocker(fromId: string, toId: string): string | null { return describeRecordWidthBlocker(this, fromId, toId); } diff --git a/packages/compiler/src/frontend/lowering/runtime-optional-guards.test.ts b/packages/compiler/src/frontend/lowering/runtime-optional-guards.test.ts index a19febe3b..653cc3abc 100644 --- a/packages/compiler/src/frontend/lowering/runtime-optional-guards.test.ts +++ b/packages/compiler/src/frontend/lowering/runtime-optional-guards.test.ts @@ -7,7 +7,7 @@ afterAll(closeSourceParser); /** Every `use(x)` argument in `body`, in source order, with whether a * presence proof holds for it. Bindings are matched by name. */ -function proofs(body: string): boolean[] { +function proofs(body: string, neverReturns?: (call: ts.CallExpression) => boolean): boolean[] { const file = parseSourceFile( "guards.ts", `declare function use(value: unknown): void;\n${body}`, @@ -26,6 +26,7 @@ function proofs(body: string): boolean[] { presentAtUse(arg, { sameBinding: (other) => other.text === arg.text, isUndefined: (other) => other.text === "undefined", + ...(neverReturns === undefined ? {} : { neverReturns }), }), ); } @@ -114,3 +115,62 @@ test("a hoisted function declaration cannot rely on earlier statements", () => { }`), ).toEqual([false, true]); }); + +test("a call that never returns exits like a throw", () => { + // `fail` and `this.fail` are declared to return never; `log` returns. + const neverReturns = (call: ts.CallExpression): boolean => { + const callee = call.expression; + const name = ts.isIdentifier(callee) + ? callee.text + : ts.isPropertyAccessExpression(callee) + ? callee.name.text + : ""; + return name === "fail"; + }; + const body = `function f(x: string) { + if (x === undefined) fail("missing"); + use(x); + } + function g(x: string) { + if (x === undefined) { this.fail("missing"); } + use(x); + } + function h(x: string) { + if (x === undefined) log("missing"); + use(x); + }`; + expect(proofs(body, neverReturns)).toEqual([true, true, false]); + // Without the checker's answer a call is an ordinary statement. + expect(proofs(body)).toEqual([false, false, false]); +}); + +test("a call in an optional chain does not exit, whatever its type", () => { + // Every call named `fail` has type never; `?.` skips the call when its + // receiver or callee is missing, so the statement completes. + const neverReturns = (call: ts.CallExpression): boolean => { + const callee = call.expression; + const name = ts.isIdentifier(callee) + ? callee.text + : ts.isPropertyAccessExpression(callee) + ? callee.name.text + : ""; + return name === "fail"; + }; + const body = `function f(x: string, maybe: { fail(m: string): never }) { + if (x === undefined) maybe?.fail("missing"); + use(x); + } + function g(x: string) { + if (x === undefined) { this.helper?.fail("missing"); } + use(x); + } + function h(x: string, fail: (m: string) => never) { + if (x === undefined) fail?.("missing"); + use(x); + } + function i(x: string, maybe: { fail(m: string): never }) { + if (x === undefined) maybe!.fail("missing"); + use(x); + }`; + expect(proofs(body, neverReturns)).toEqual([false, false, false, true]); +}); diff --git a/packages/compiler/src/frontend/lowering/runtime-optional-guards.ts b/packages/compiler/src/frontend/lowering/runtime-optional-guards.ts index 3e4605f94..50b175c71 100644 --- a/packages/compiler/src/frontend/lowering/runtime-optional-guards.ts +++ b/packages/compiler/src/frontend/lowering/runtime-optional-guards.ts @@ -79,6 +79,9 @@ export interface PresenceBindings { sameBinding(node: ts.Identifier): boolean; /** The identifier is the global `undefined` value. */ isUndefined(node: ts.Identifier): boolean; + /** The call's type is `never`: it throws or never returns (`fail(...)` + * declared to return `never`). */ + neverReturns?(call: ts.CallExpression): boolean; } /** Proves that a read of a never-reassigned binding cannot observe @@ -270,8 +273,10 @@ export function implies( function statementProvesPresence(statement: ts.Statement, bindings: PresenceBindings): boolean { if (ts.isIfStatement(statement)) { if (dereferences(statement.expression, bindings)) return true; - const thenExits = exitsAbruptly(statement.thenStatement); - const elseExits = statement.elseStatement ? exitsAbruptly(statement.elseStatement) : false; + const thenExits = exitsAbruptly(statement.thenStatement, bindings); + const elseExits = statement.elseStatement + ? exitsAbruptly(statement.elseStatement, bindings) + : false; if (thenExits && !elseExits) return implies(statement.expression, false, bindings); if (elseExits && !thenExits) return implies(statement.expression, true, bindings); return false; @@ -287,9 +292,28 @@ function statementProvesPresence(statement: ts.Statement, bindings: PresenceBind return false; } +/** Whether `call`, or a call or access its callee is made of, uses `?.`: a + * missing receiver or callee then ends the chain without making the call, + * whatever type the checker gives it. */ +function inOptionalChain(call: ts.CallExpression): boolean { + let e: ts.Expression = call; + for (;;) { + if ( + ts.isCallExpression(e) || + ts.isPropertyAccessExpression(e) || + ts.isElementAccessExpression(e) + ) { + if (e.questionDotToken !== undefined) return true; + e = e.expression; + } else if (ts.isNonNullExpression(e) || ts.isParenthesizedExpression(e)) e = e.expression; + else return false; + } +} + /** The statement never completes normally: its last reachable statement - * returns, throws, breaks, or continues. */ -function exitsAbruptly(statement: ts.Statement): boolean { + * returns, throws, breaks, continues, or calls a function that never + * returns. */ +function exitsAbruptly(statement: ts.Statement, bindings: PresenceBindings): boolean { if ( ts.isReturnStatement(statement) || ts.isThrowStatement(statement) || @@ -297,17 +321,22 @@ function exitsAbruptly(statement: ts.Statement): boolean { ts.isContinueStatement(statement) ) return true; + if (ts.isExpressionStatement(statement)) { + let e = statement.expression; + while (ts.isParenthesizedExpression(e)) e = e.expression; + return ts.isCallExpression(e) && !inOptionalChain(e) && bindings.neverReturns?.(e) === true; + } if (ts.isBlock(statement)) { // A labeled statement inside could be the target of an inner break, // which resumes after it; only plain trailing exits count. const last = statement.statements.nodes[statement.statements.nodes.length - 1]; - return last !== undefined && exitsAbruptly(last); + return last !== undefined && exitsAbruptly(last, bindings); } if (ts.isIfStatement(statement)) return ( !!statement.elseStatement && - exitsAbruptly(statement.thenStatement) && - exitsAbruptly(statement.elseStatement) + exitsAbruptly(statement.thenStatement, bindings) && + exitsAbruptly(statement.elseStatement, bindings) ); return false; } diff --git a/packages/compiler/src/frontend/lowering/union-retag.test.ts b/packages/compiler/src/frontend/lowering/union-retag.test.ts index 8116d6378..630eccd46 100644 --- a/packages/compiler/src/frontend/lowering/union-retag.test.ts +++ b/packages/compiler/src/frontend/lowering/union-retag.test.ts @@ -1,9 +1,20 @@ import { describe, expect, test } from "vitest"; -import { BOOL, F64, NULL_T, STRING, UNDEFINED_T, typeEquals, typeKey } from "../../ir/ir.js"; +import { + BOOL, + DYN, + F64, + NULL_T, + STRING, + UNDEFINED_T, + VOID, + typeEquals, + typeKey, +} from "../../ir/ir.js"; import type { IrRecordShape, IrType, IrUnionDef, SrcLoc } from "../../ir/ir.js"; import { planUnionRetag, buildUnionRetag, + identityRetagTags, planRecordUnionLift, planRecordUnionWrap, } from "./union-retag.js"; @@ -729,3 +740,50 @@ describe("retag IR construction", () => { ).toThrow("incomplete union retag plan"); }); }); + +describe("promise payload widening", () => { + const promise = (inner: IrType): IrType => ({ kind: "promise", inner }); + const from: IrUnionDef = { id: "pw0", arms: [promise(VOID), UNDEFINED_T] }; + const pairLift = (to: IrUnionDef) => { + const lift = (src: IrType, dst: IrType): WidthLift | null => { + if (typeEquals(src, dst)) return { how: "copy" }; + if (dst.kind === "promise" && src.kind === "promise") return { how: "promiseWiden" }; + if (dst.kind !== "union") return null; + const candidates = to.arms + .map((arm, tag) => ({ arm, tag })) + .filter(({ arm }) => lift(src, arm) !== null); + return candidates.length === 1 + ? { how: "liftWrap", tag: candidates[0]!.tag, arm: candidates[0]!.arm } + : null; + }; + return lift; + }; + + test("maps the promise arm through the payload widening", () => { + const to: IrUnionDef = { id: "pw1", arms: [promise(DYN), UNDEFINED_T] }; + expect(planUnionRetag(from, to, () => undefined, pairLift(to))).toEqual([ + { kind: "direct", route: { tag: 0, lift: { how: "promiseWiden" }, values: [] } }, + { kind: "direct", route: { tag: 1, lift: { how: "copy" }, values: [] } }, + ]); + }); + + test("two destination promise arms stay ambiguous", () => { + const to: IrUnionDef = { id: "pw2", arms: [promise(DYN), promise(STRING), UNDEFINED_T] }; + expect(planUnionRetag(from, to, () => undefined, pairLift(to))).toBeNull(); + }); + + test("an equal promise arm keeps the identity copy", () => { + const to: IrUnionDef = { id: "pw3", arms: [promise(VOID), UNDEFINED_T] }; + expect(planUnionRetag(from, to, () => undefined, pairLift(to))).toEqual([ + { kind: "direct", route: { tag: 0, lift: { how: "copy" }, values: [] } }, + { kind: "direct", route: { tag: 1, lift: { how: "copy" }, values: [] } }, + ]); + expect( + identityRetagTags( + from, + to, + planUnionRetag(from, to, () => undefined, pairLift(to))!, + ), + ).toEqual([0, 1]); + }); +}); diff --git a/packages/compiler/src/frontend/lowering/width-lift.ts b/packages/compiler/src/frontend/lowering/width-lift.ts index 126f80f93..59209dc50 100644 --- a/packages/compiler/src/frontend/lowering/width-lift.ts +++ b/packages/compiler/src/frontend/lowering/width-lift.ts @@ -22,7 +22,16 @@ export type WidthLift = absent: { tag: number; arm: IrType }; } | { how: "width" } + /** A record into a record whose destination declares an index + * signature: the overflow CAPTURE reshape (declared fields copy off the + * source in declaration order, every other source field keys into the + * destination's overflow). A copy, like every width lift. */ + | { how: "ovf" } | { how: "unionWidth" } + /** A union of tuples/arrays into one array slot that no arm already is: + * every arm converts on its own tag, unit arms take the stranded + * TypeError. */ + | { how: "unionArr" } | { how: "arr" } | { how: "tupleArr" } | { how: "emptyArr" } @@ -32,4 +41,8 @@ export type WidthLift = | { how: "dynIn" } | { how: "dynOut" } | { how: "upcast" } - | { how: "funcAdapt" }; + | { how: "funcAdapt" } + /** A promise whose payload widens (`Promise` into `Promise`): + * the checked payload widening coerceToExpected applies at a promise slot, + * which keeps the promise's identity and settlement turn. */ + | { how: "promiseWiden" }; diff --git a/packages/compiler/src/index.ts b/packages/compiler/src/index.ts index d1f679d39..0d61e1694 100644 --- a/packages/compiler/src/index.ts +++ b/packages/compiler/src/index.ts @@ -95,11 +95,7 @@ import { NativeCodegenError, writeLlvmSource, } from "./backend/native-codegen.js"; -import { - nativeCodegenParts, - nativePartitionPaths, - nativeProgramPartitions, -} from "./backend/native-codegen-core.js"; +import { nativePartitionPaths, nativeProgramPartitions } from "./backend/native-codegen-core.js"; import { privateSiblingPath } from "./backend/build-cache.js"; import { nativeCodegenTarget, @@ -787,12 +783,7 @@ async function emitNativeProgramObject( const target = nativeCodegenTarget(); const llvmBytes = llvmInputBytes(llvm); const single = opts.nativeProgramObject === true || target === null; - // An optimized program that stays one partition is optimized whole and - // generates code for its parts concurrently. - const codegenParts = single || opts.optimization === "dev" ? 1 : nativeCodegenParts(llvmBytes); - const partitions = single - ? 1 - : Math.max(nativeProgramPartitions(llvmBytes, opts.optimization === "dev"), codegenParts); + const partitions = single ? 1 : nativeProgramPartitions(llvmBytes, opts.optimization === "dev"); try { const artifact = await emitNativeArtifact({ outputPath: linkPath, @@ -802,7 +793,6 @@ async function emitNativeProgramObject( optimization: opts.optimization === "dev" ? "0" : "2", ...(opts.sanitize === undefined ? {} : { sanitize: opts.sanitize }), partitions, - ...(codegenParts > 1 ? { splitCodegen: true } : {}), ...(profile === undefined ? {} : { profile }), }); return { diff --git a/packages/compiler/src/native/compiler.ts b/packages/compiler/src/native/compiler.ts index fafae4f9d..4068b0880 100644 --- a/packages/compiler/src/native/compiler.ts +++ b/packages/compiler/src/native/compiler.ts @@ -40,7 +40,6 @@ import { import { RuntimePackError } from "../backend/runtime-pack-core.js"; import { NativeCodegenError, - nativeCodegenParts, nativePartitionPaths, nativeProgramPartitions, } from "../backend/native-codegen-core.js"; @@ -259,7 +258,6 @@ export class NativeCompiler { partitions = 1, profile?: NativeProfileOptions, llvmDigest: () => string = () => fileDigest(input), - splitCodegen = false, ): NativeObjects { const toolchain = this.toolchain; const outputs = nativePartitionPaths(output, partitions); @@ -288,7 +286,6 @@ export class NativeCompiler { identity: readFileSync(join(toolchain.helperPackageRoot, "package.json"), "utf8"), version: toolchain.compilerVersion, ...(partitions > 1 ? { partitions } : {}), - ...(splitCodegen ? { split: "codegen" } : {}), ...(profile === undefined ? {} : { profile: nativeProfileKey(profile) }), }), ); @@ -321,7 +318,7 @@ export class NativeCompiler { } } const partitionCache = - this.cache === null || identity === null || partitions < 2 || splitCodegen + this.cache === null || identity === null || partitions < 2 ? null : this.cache.partitionDirectory(identity); const { profileMatch } = emitNativeObject({ @@ -332,7 +329,6 @@ export class NativeCompiler { optimization, outputKind, partitions, - ...(splitCodegen ? { splitCodegen } : {}), ...(partitionCache === null ? {} : { partitionCache }), ...(profile === undefined ? {} : { profile }), }); @@ -382,13 +378,9 @@ export class NativeCompiler { : null; const split = llvm === null ? null : splitLlvmLibraryProgram(llvm); if (split === null) { - const size = statSync(input).size; - // An optimized program that stays one partition is optimized whole and - // generates code for its parts concurrently. - const codegenParts = library || optimization === "dev" ? 1 : nativeCodegenParts(size); const partitions = library ? 1 - : Math.max(nativeProgramPartitions(size, optimization === "dev"), codegenParts); + : nativeProgramPartitions(statSync(input).size, optimization === "dev"); return this.emitObject( input, output, @@ -398,7 +390,6 @@ export class NativeCompiler { partitions, profile, llvmDigest, - codegenParts > 1, ); } const objects: string[] = []; diff --git a/packages/compiler/test/asserted-arm-probe-emission.test.ts b/packages/compiler/test/asserted-arm-probe-emission.test.ts new file mode 100644 index 000000000..cddd7f2ee --- /dev/null +++ b/packages/compiler/test/asserted-arm-probe-emission.test.ts @@ -0,0 +1,96 @@ +// A feature-detection probe of a member read through a type assertion must +// answer off the value actually stored (Node erases `as`), never throw the +// checked-extraction TypeError. A real use of the same read keeps the +// checked conversion ("types don't lie": a lying assertion throws at the +// use, not at the probe). +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { expect, test } from "vitest"; +import { compile, deserializeModule, validateModule } from "../src/index.js"; + +const PRELUDE = [ + "interface Streams { stream: () => string; fetch?: () => string }", + "interface Options { name: string; api: Streams | Record }", +]; + +type IrModule = { + entry: string; + functions: { name: string; body: unknown }[]; +}; + +async function compileToIr( + body: string[], +): Promise<{ ok: boolean; module: IrModule | null; diagnostics: string }> { + const dir = await mkdtemp(join(tmpdir(), "scriptc-asserted-arm-probe-")); + try { + const entry = join(dir, "main.ts"); + const outPath = join(dir, "main.ir.json"); + await writeFile(entry, [...PRELUDE, ...body].join("\n")); + const result = await compile(entry, { outDir: dir, outPath, outputKind: "ir" }); + if (!result.ok) + return { + ok: false, + module: null, + diagnostics: result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n"), + }; + const module = deserializeModule(await readFile(outPath, "utf8")) as unknown as IrModule; + expect(validateModule(module as never)).toEqual([]); + return { ok: true, module, diagnostics: "" }; + } finally { + await rm(dir, { recursive: true, force: true }); + } +} + +/** The IR bodies of the functions whose name starts with `root`, as text + * (a specialized function keeps its source name as a prefix). */ +function bodies(module: IrModule, root: string): string { + return JSON.stringify( + module.functions.filter((fn) => fn.name.startsWith(root)).map((fn) => fn.body), + ); +} + +test("typeof probe of a member read through an assertion reads per arm", async () => { + const compiled = await compileToIr([ + "function detect(input: Options): boolean {", + ' return typeof (input.api as Streams).stream === "function";', + "}", + 'console.log(detect({ name: "one", api: { stream: () => "one" } }));', + ]); + expect(compiled.ok, compiled.diagnostics).toBe(true); + // A checked extraction of the asserted arm would trap on the record arm; + // the probe answers from the stored union's runtime tag instead. + const detect = bodies(compiled.module!, "detect"); + expect(detect).toContain('"unionIsTag"'); + expect(detect).not.toContain('"nodeThrow"'); +}); + +test("undefined and null presence probes read per arm", async () => { + for (const probe of [ + "(input.api as Streams).stream !== undefined", + "(input.api as Streams).stream == null", + ]) { + const compiled = await compileToIr([ + "function detect(input: Options): boolean {", + ` return ${probe};`, + "}", + 'console.log(detect({ name: "one", api: { stream: () => "one" } }));', + ]); + expect(compiled.ok, compiled.diagnostics).toBe(true); + const detect = bodies(compiled.module!, "detect"); + expect(detect).not.toContain('"nodeThrow"'); + } +}); + +test("a real use of the asserted member keeps the checked extraction", async () => { + const compiled = await compileToIr([ + "function call(input: Options): string {", + " return (input.api as Streams).stream();", + "}", + 'console.log(call({ name: "one", api: { stream: () => "one" } }));', + ]); + expect(compiled.ok, compiled.diagnostics).toBe(true); + // The real use still applies the assertion's checked extraction: an arm + // that is not Streams throws at the read (the trap is the nodeThrow). + expect(bodies(compiled.module!, "call")).toMatch(/union\.narrow|nodeThrow/); +}); diff --git a/packages/compiler/test/class-structural-views.test.ts b/packages/compiler/test/class-structural-views.test.ts new file mode 100644 index 000000000..328a724f7 --- /dev/null +++ b/packages/compiler/test/class-structural-views.test.ts @@ -0,0 +1,87 @@ +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { expect, test } from "vitest"; +import { compile, deserializeModule } from "../src/index.js"; + +/** The IR type kind of each named module-level binding. */ +async function globalKinds(source: string): Promise> { + const dir = await mkdtemp(join(tmpdir(), "scriptc-class-structural-views-")); + try { + const entry = join(dir, "main.ts"); + const outPath = join(dir, "main.ir.json"); + await writeFile(entry, source); + const result = await compile(entry, { outDir: dir, outPath, outputKind: "ir" }); + if (!result.ok) + throw new Error(result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n")); + const mod = deserializeModule(await readFile(outPath, "utf8")); + return Object.fromEntries((mod.globals ?? []).map((g) => [g.name, g.type.kind])); + } finally { + await rm(dir, { recursive: true, force: true }); + } +} + +test("an optional method in an anonymous union received from a class stays live", async () => { + const kinds = await globalKinds(` +class SelectList { + handleInput(data: string): void { console.log("list", data); } +} +const list = new SelectList(); +const handler: { handleInput?(data: string): void } | undefined = list; +handler?.handleInput?.("a"); +`); + // The union collapses to the checked dynamic slot that holds the instance. + expect(kinds["handler"]).toBe("dyn"); +}); + +test("an optional method interface received from a class stays live", async () => { + const kinds = await globalKinds(` +interface Input { + handleInput?(data: string): void; +} +class SelectList { + handleInput(data: string): void { console.log("list", data); } +} +const list = new SelectList(); +const handler: Input | undefined = list; +handler?.handleInput?.("a"); +`); + expect(kinds["handler"]).toBe("dyn"); +}); + +test("a required method in a plain anonymous slot received from a class stays live", async () => { + const kinds = await globalKinds(` +class Button { + presses = 0; + press(): void { this.presses++; console.log("press", this.presses); } +} +const button = new Button(); +const handler: { press(): void } = button; +handler.press(); +console.log(button.presses); +`); + expect(kinds["handler"]).toBe("dyn"); + // The instance itself keeps its nominal class. + expect(kinds["button"]).toBe("object"); +}); + +test("a data-only anonymous type keeps the native width copy", async () => { + const kinds = await globalKinds(` +class Point { x = 1; y = 2; } +const point = new Point(); +const copy: { x: number; y: number } = point; +console.log(copy.x + copy.y); +`); + expect(kinds["copy"]).toBe("record"); + expect(kinds["point"]).toBe("object"); +}); + +test("an anonymous type that only receives an object literal keeps its record layout", async () => { + const kinds = await globalKinds(` +const handler: { press?: () => void } = { + press() { console.log("literal"); }, +}; +handler.press?.(); +`); + expect(kinds["handler"]).toBe("record"); +}); diff --git a/packages/compiler/test/native-partitions.test.ts b/packages/compiler/test/native-partitions.test.ts index 18d0aa749..e85802db9 100644 --- a/packages/compiler/test/native-partitions.test.ts +++ b/packages/compiler/test/native-partitions.test.ts @@ -169,6 +169,75 @@ describe.runIf(helper !== null)("LLVM program partitions", () => { }); } + test("an internal function that other partitions reach through an import keeps its name across edits", async () => { + const dir = await mkdtemp(join(tmpdir(), "scriptc-partitions-imports-")); + dirs.push(dir); + const count = 24; + const factor = (index: number, edit: number) => index + 3 + (index === 5 ? edit : 0); + // Each leaf is internal and called only by its wrapper. The wrappers are + // small, and main and the steps import them from other partitions, with + // a reference to the leaf, which its partition then exports. + const source = (edit: number) => { + const lines: string[] = []; + for (let index = 0; index < count; index++) + lines.push( + `define internal i64 @leaf${index}(i64 %value) noinline {`, + ` %scaled = mul i64 %value, ${factor(index, edit)}`, + " ret i64 %scaled", + "}", + `define internal i64 @wrap${index}(i64 %value) {`, + ` %leaf = call i64 @leaf${index}(i64 %value)`, + " %next = add i64 %leaf, 1", + " ret i64 %next", + "}", + `define i64 @step${index}(i64 %value) noinline {`, + ` %wrapped = call i64 @wrap${index}(i64 %value)`, + " ret i64 %wrapped", + "}", + ); + lines.push("define i32 @main() {", "entry:"); + for (let index = 0; index < count; index++) + lines.push( + ` %s${index} = call i64 @step${index}(i64 1)`, + ` %w${index} = call i64 @wrap${index}(i64 2)`, + ` %a${index} = add i64 ${index === 0 ? "0" : `%t${index - 1}`}, %s${index}`, + ` %t${index} = add i64 %a${index}, %w${index}`, + ); + lines.push( + ` %low = urem i64 %t${count - 1}, 251`, + " %code = trunc i64 %low to i32", + " ret i32 %code", + "}", + ); + return lines.join("\n") + "\n"; + }; + const expected = (edit: number) => { + let total = 0; + for (let index = 0; index < count; index++) total += 3 * factor(index, edit) + 2; + return total % 251; + }; + // ThinLTO renames an exported internal function .llvm.. The hash is the partition number, so an edit of the leaf's + // partition leaves the name, and the importing partitions, unchanged. + const promoted = async (objects: string[]) => { + const names = new Set(); + for (const object of objects) + for (const match of (await execFileAsync("nm", [object])).stdout.matchAll( + /^[0-9a-fA-F ]+ ([TU]) _?(leaf\d+\.llvm\.\d+)$/gm, + )) + names.add(`${match[1]} ${match[2]}`); + return names; + }; + const first = await emit(dir, source(0), { outputs: 4, optLevel: "2", name: "first" }); + expect(await exitCode(dir, first, "first")).toBe(expected(0)); + const edited = await emit(dir, source(4), { outputs: 4, optLevel: "2", name: "edited" }); + expect(await exitCode(dir, edited, "edited")).toBe(expected(4)); + const names = await promoted(first); + expect([...names].some((name) => name.startsWith("U "))).toBe(true); + for (const name of names) expect(name).toMatch(/\.llvm\.[1-4]$/); + expect(await promoted(edited)).toEqual(names); + }); + test("split code generation links deterministically and defines every function once", async () => { const dir = await mkdtemp(join(tmpdir(), "scriptc-split-codegen-")); dirs.push(dir); @@ -339,6 +408,53 @@ describe.runIf(helper !== null)("LLVM program partitions", () => { expect(await readFile(renamed[index]!)).toEqual(await readFile(parts[index]!)); }); + test("an optimized module whose largest partition would hold most of its function text is optimized whole", async () => { + const dir = await mkdtemp(join(tmpdir(), "scriptc-partitions-dominated-")); + dirs.push(dir); + // One function of 400 instructions and four small ones. + const lines = [ + "@sink = global i64 0", + "define void @large(i64 %x) noinline {", + " %v0 = add i64 %x, 1", + ]; + for (let step = 1; step < 400; step++) + lines.push(` %v${step} = mul i64 %v${step - 1}, ${step}`); + lines.push(" store volatile i64 %v399, ptr @sink", " ret void", "}"); + for (let index = 0; index < 4; index++) + lines.push( + `define void @small${index}(i64 %x) noinline {`, + ` %v = add i64 %x, ${index}`, + " store volatile i64 %v, ptr @sink", + " ret void", + "}", + ); + lines.push("define i32 @main() {", " call void @large(i64 1)"); + for (let index = 0; index < 4; index++) lines.push(` call void @small${index}(i64 1)`); + lines.push(" ret i32 0", "}"); + const source = lines.join("\n") + "\n"; + const cache = join(dir, "cache"); + const contents = (paths: string[]) => Promise.all(paths.map((path) => readFile(path))); + const partitioned = await emit(dir, source, { + outputs: 4, + optLevel: "2", + cache, + name: "partitioned", + }); + const whole = await emit(dir, source, { + outputs: 4, + optLevel: "2", + split: true, + name: "whole", + }); + expect(await contents(partitioned)).toEqual(await contents(whole)); + // No partition was simplified for a thin link. + expect(await cacheEntries(cache, ".bc")).toHaveProperty("size", 0); + expect(await exitCode(dir, partitioned, "partitioned")).toBe(0); + // Unoptimized partitions are compiled once each, so they stay divided. + await emit(dir, source, { outputs: 4, optLevel: "0", cache, name: "unoptimized" }); + expect((await cacheEntries(cache, ".o")).size).toBeGreaterThan(1); + }); + test("split code generation requires several optimized outputs and no cache", async () => { const dir = await mkdtemp(join(tmpdir(), "scriptc-split-codegen-invalid-")); dirs.push(dir); @@ -354,6 +470,54 @@ describe.runIf(helper !== null)("LLVM program partitions", () => { } }); + test("in a small module, a function that one function of another source file calls shares its partition", async () => { + const dir = await mkdtemp(join(tmpdir(), "scriptc-partitions-chains-")); + dirs.push(dir); + const count = 8; + const lines: string[] = ["; scriptc-group 0"]; + for (let index = 0; index < count; index++) + lines.push( + `define internal i64 @helper${index}(i64 %value) noinline {`, + ` %scaled = mul i64 %value, ${index + 2}`, + " ret i64 %scaled", + "}", + ); + lines.push("; scriptc-group 1"); + for (let index = 0; index < count; index++) + lines.push( + `define i64 @caller${index}(i64 %value) noinline {`, + ` %helped = call i64 @helper${index}(i64 %value)`, + " ret i64 %helped", + "}", + ); + lines.push("; scriptc-group", "define i32 @main() {", "entry:"); + for (let index = 0; index < count; index++) + lines.push( + ` %c${index} = call i64 @caller${index}(i64 1)`, + ` %t${index} = add i64 ${index === 0 ? "0" : `%t${index - 1}`}, %c${index}`, + ); + lines.push(` %code = trunc i64 %t${count - 1} to i32`, " ret i32 %code", "}"); + const objects = await emit(dir, lines.join("\n") + "\n", { + outputs: 4, + optLevel: "0", + name: "chains", + }); + let sum = 0; + for (let index = 0; index < count; index++) sum += index + 2; + expect(await exitCode(dir, objects, "chains")).toBe(sum); + const listings = await Promise.all( + objects.map(async (object) => (await execFileAsync("nm", ["--defined-only", object])).stdout), + ); + const home = (name: string) => + listings.findIndex((listing) => + new RegExp(`^[0-9a-fA-F]+ [Tt] _?${name}$`, "m").test(listing), + ); + for (let index = 0; index < count; index++) { + expect(home(`caller${index}`)).toBeGreaterThanOrEqual(0); + expect(home(`helper${index}`)).toBe(home(`caller${index}`)); + } + }); + test("functions under one group line share a partition", async () => { const dir = await mkdtemp(join(tmpdir(), "scriptc-partitions-groups-")); dirs.push(dir); diff --git a/packages/compiler/test/native-profile.test.ts b/packages/compiler/test/native-profile.test.ts index 8abca4393..a6bdfe241 100644 --- a/packages/compiler/test/native-profile.test.ts +++ b/packages/compiler/test/native-profile.test.ts @@ -415,15 +415,20 @@ describe.runIf(helper !== null)("LLVM profile-guided optimization", () => { test("the profile keys the partition cache", async () => { const dir = await mkdtemp(join(tmpdir(), "scriptc-profile-cache-")); dirs.push(dir); - const { profile } = await collect(dir, program(), 4, "train"); + // External functions are placed by name, so the program is divided + // instead of following main into one partition and being optimized + // whole. + const source = program().replaceAll("define internal i64 @f", "define i64 @f"); + const { profile } = await collect(dir, source, 4, "train"); const cache = join(dir, "cache"); - await emit(dir, program(), { outputs: 4, name: "plain", cache }); + await emit(dir, source, { outputs: 4, name: "plain", cache }); const plain = await cacheEntries(cache); - await emit(dir, program(), { outputs: 4, name: "guided", cache, use: profile }); + expect(plain.size).toBeGreaterThan(0); + await emit(dir, source, { outputs: 4, name: "guided", cache, use: profile }); const guided = await cacheEntries(cache); expect(guided.size).toBeGreaterThan(plain.size); // The same profile again reuses every partition. - await emit(dir, program(), { outputs: 4, name: "again", cache, use: profile }); + await emit(dir, source, { outputs: 4, name: "again", cache, use: profile }); expect(await cacheEntries(cache)).toEqual(guided); }); }); diff --git a/packages/compiler/test/promise-union-widening.test.ts b/packages/compiler/test/promise-union-widening.test.ts new file mode 100644 index 000000000..36813b41d --- /dev/null +++ b/packages/compiler/test/promise-union-widening.test.ts @@ -0,0 +1,92 @@ +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { expect, test } from "vitest"; +import { compile, deserializeModule } from "../src/index.js"; +import type { IrFunction } from "../src/ir/ir.js"; + +/** Every `dynCheck` over a `dynFrom` inside a retag helper: the checked + * payload widening of a promise (the settled value validates against the + * slot's promise payload). */ +function promisePayloadConversions(functions: IrFunction[]): number { + let found = 0; + const visit = (value: unknown): void => { + if (Array.isArray(value)) { + for (const item of value) visit(item); + return; + } + if (value === null || typeof value !== "object") return; + const node = value as { kind?: unknown; type?: unknown; value?: unknown }; + if ( + node.kind === "dynCheck" && + (node.type as { kind?: unknown }).kind === "promise" && + (node.value as { kind?: unknown } | undefined)?.kind === "dynFrom" + ) + found++; + for (const child of Object.values(value)) visit(child); + }; + visit(functions); + return found; +} + +async function compileSource(source: string) { + const dir = await mkdtemp(join(tmpdir(), "scriptc-promise-union-")); + try { + const entry = join(dir, "main.ts"); + await writeFile(entry, source); + const outPath = join(dir, "main.ir.json"); + const result = await compile(entry, { outDir: dir, outPath, outputKind: "ir" }); + if (!result.ok) return { ok: false as const, diagnostics: result.diagnostics }; + return { ok: true as const, module: deserializeModule(await readFile(outPath, "utf8")) }; + } finally { + await rm(dir, { recursive: true, force: true }); + } +} + +test("a promise arm widens its payload in a union conversion", async () => { + const result = await compileSource(` + function settle(flag: boolean): Promise | undefined { + return flag ? Promise.resolve() : undefined; + } + const widened: Promise | undefined = settle(true); + console.log(widened === undefined); + export {}; + `); + if (!result.ok) + throw new Error(result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n")); + const retag = result.module.functions.filter((fn) => fn.name.startsWith("%union.retag.")); + expect(retag).toHaveLength(1); + // The promise arm's payload converts through the checked-dynamic box; the + // undefined arm copies. + expect(promisePayloadConversions(retag)).toBe(1); +}); + +test("an equal promise arm stays a plain copy", async () => { + const result = await compileSource(` + function load(flag: boolean): Promise | undefined { + return flag ? Promise.resolve() : undefined; + } + const same: Promise | number | undefined = load(true); + console.log(same === undefined); + export {}; + `); + if (!result.ok) + throw new Error(result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n")); + const retag = result.module.functions.filter((fn) => fn.name.startsWith("%union.retag.")); + expect(retag).toHaveLength(1); + expect(promisePayloadConversions(retag)).toBe(0); +}); + +test("two destination promise arms keep the conversion fence", async () => { + const result = await compileSource(` + function settle(flag: boolean): Promise | undefined { + return flag ? Promise.resolve() : undefined; + } + const ambiguous: Promise | Promise | undefined = settle(true); + console.log(ambiguous === undefined); + export {}; + `); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.diagnostics.map((d) => d.code)).toContain("SC2003"); +}); diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/asserted-arm-probe.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/asserted-arm-probe.ts.json new file mode 100644 index 000000000..db640597f --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/asserted-arm-probe.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/asserted-arm-probe.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/class-structural-anonymous-method.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/class-structural-anonymous-method.ts.json new file mode 100644 index 000000000..85bb5bd5a --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/class-structural-anonymous-method.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/class-structural-anonymous-method.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/class-union-cycles.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/class-union-cycles.ts.json new file mode 100644 index 000000000..a2ee6b520 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/class-union-cycles.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/class-union-cycles.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/class-union-hierarchies.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/class-union-hierarchies.ts.json new file mode 100644 index 000000000..80a0684c4 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/class-union-hierarchies.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/class-union-hierarchies.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/class-union-pointers.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/class-union-pointers.ts.json new file mode 100644 index 000000000..0b78bc621 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/class-union-pointers.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/class-union-pointers.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/class-union-program-lifetime.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/class-union-program-lifetime.ts.json new file mode 100644 index 000000000..23173ad35 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/class-union-program-lifetime.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/class-union-program-lifetime.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/generic-member-constrained-lambda-impls.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/generic-member-constrained-lambda-impls.ts.json new file mode 100644 index 000000000..4c7bdccc5 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/generic-member-constrained-lambda-impls.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/generic-member-constrained-lambda-impls.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/never-returning-presence-guard.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/never-returning-presence-guard.ts.json new file mode 100644 index 000000000..3582987c0 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/never-returning-presence-guard.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/never-returning-presence-guard.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/promise-union-widening.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/promise-union-widening.ts.json new file mode 100644 index 000000000..73ace66a4 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/promise-union-widening.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/promise-union-widening.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/record-ovf-union-arm.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/record-ovf-union-arm.ts.json new file mode 100644 index 000000000..ba95231b0 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/record-ovf-union-arm.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/record-ovf-union-arm.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/tuple-union-to-array.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/tuple-union-to-array.ts.json new file mode 100644 index 000000000..bfa194b2d --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/tuple-union-to-array.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/tuple-union-to-array.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/union-object-arms-to-string.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/union-object-arms-to-string.ts.json new file mode 100644 index 000000000..2e0d49021 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/union-object-arms-to-string.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/union-object-arms-to-string.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/union-array-lowering.test.ts b/packages/compiler/test/union-array-lowering.test.ts new file mode 100644 index 000000000..a1dd10a7e --- /dev/null +++ b/packages/compiler/test/union-array-lowering.test.ts @@ -0,0 +1,124 @@ +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { expect, test } from "vitest"; +import { compile, deserializeModule } from "../src/index.js"; + +interface IrShape { + names: string[]; + callees: string[]; +} + +/** Lower one program to IR and collect every function name and call target. */ +async function lower( + source: string, +): Promise<{ ok: true; ir: IrShape } | { ok: false; codes: string[] }> { + const dir = await mkdtemp(join(tmpdir(), "scriptc-union-array-")); + try { + const entry = join(dir, "main.ts"); + const outPath = join(dir, "main.ir.json"); + await writeFile(entry, source); + const result = await compile(entry, { outDir: dir, outPath, outputKind: "ir" }); + if (!result.ok) return { ok: false, codes: result.diagnostics.map((d) => d.code) }; + const mod = deserializeModule(await readFile(outPath, "utf8")); + const callees: string[] = []; + const visit = (value: unknown): void => { + if (Array.isArray(value)) { + for (const item of value) visit(item); + return; + } + if (value === null || typeof value !== "object") return; + const node = value as { kind?: unknown; callee?: unknown }; + if (node.kind === "call" && typeof node.callee === "string") callees.push(node.callee); + for (const child of Object.values(value)) visit(child); + }; + visit(mod.functions); + return { ok: true, ir: { names: mod.functions.map((f) => f.name), callees } }; + } finally { + await rm(dir, { recursive: true, force: true }); + } +} + +const helpers = (ir: IrShape, prefix: string): string[] => + ir.names.filter((name) => name.startsWith(prefix)); + +test("a union of tuples into an array slot lifts every arm on its tag", async () => { + const result = await lower(` +type Variant = ["one"] | ["two", "three"]; +function pick(flag: boolean): Variant { + return flag ? ["one"] : ["two", "three"]; +} +function take(xs: string[]): number { + return xs.length; +} +console.log(take(pick(true))); +export {}; +`); + expect(result.ok).toBe(true); + if (!result.ok) return; + const arrayHelpers = helpers(result.ir, "%union.arr"); + expect(arrayHelpers.length).toBeGreaterThan(0); + // The conversion is a call to the arm-dispatching helper, not a narrowed + // extraction of one tuple arm. + for (const name of arrayHelpers) expect(result.ir.callees).toContain(name); +}); + +test("the as const key-table shape lowers through the same helper", async () => { + const result = await lower(` +const SEQUENCES = { up: ["\\x1b[A", "\\x1bOA"], f9: ["\\x1b[20~"] } as const; +type SequenceKey = keyof typeof SEQUENCES; +function matches(data: string, sequences: readonly string[]): boolean { + return sequences.includes(data); +} +console.log(matches("\\x1bOA", SEQUENCES["up" as SequenceKey])); +export {}; +`); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(helpers(result.ir, "%union.arr").length).toBeGreaterThan(0); +}); + +test("a single tuple keeps the existing tuple-to-array path", async () => { + const result = await lower(` +const single: ["x", "y"] = ["x", "y"]; +const widened: string[] = single; +console.log(widened.join("")); +export {}; +`); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(helpers(result.ir, "%tup.arr").length).toBeGreaterThan(0); + expect(helpers(result.ir, "%union.arr")).toEqual([]); +}); + +test("a union that already holds the array arm keeps its checked extraction", async () => { + const result = await lower(` +type T = string[] | ["a"]; +function pick(flag: boolean): T { + return flag ? ["b", "c"] : ["a"]; +} +const widened: string[] = pick(true); +console.log(widened.length); +export {}; +`); + expect(result.ok).toBe(true); + if (!result.ok) return; + // `string[]` is an arm, so the conversion this program always used — the + // checked extraction of that arm — must stay byte-identical: the new union + // array lift is only for unions with no arm the slot can hold. + expect(helpers(result.ir, "%union.narrow").length).toBeGreaterThan(0); + expect(helpers(result.ir, "%union.arr")).toEqual([]); +}); + +test("a union holding an undefined arm still lowers through the helper", async () => { + const result = await lower(` +type Pair = [string, string] | [string]; +const table: Record = { one: ["a", "b"] }; +const widened: string[] = table["one"]!; +console.log(widened.join("")); +export {}; +`); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(helpers(result.ir, "%union.arr").length).toBeGreaterThan(0); +}); diff --git a/packages/runtime-pack-common/profiles/linux-x64-gnu.profdata.gz b/packages/runtime-pack-common/profiles/linux-x64-gnu.profdata.gz new file mode 100644 index 000000000..6d4a0d942 Binary files /dev/null and b/packages/runtime-pack-common/profiles/linux-x64-gnu.profdata.gz differ diff --git a/packages/runtime-pack-common/scripts/build.mjs b/packages/runtime-pack-common/scripts/build.mjs index b6305555e..07de35369 100644 --- a/packages/runtime-pack-common/scripts/build.mjs +++ b/packages/runtime-pack-common/scripts/build.mjs @@ -2,10 +2,12 @@ import { execFile } from "node:child_process"; import { createHash } from "node:crypto"; import { availableParallelism, tmpdir } from "node:os"; +import { existsSync } from "node:fs"; import { copyFile, mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from "node:fs/promises"; import { dirname, join, relative, sep } from "node:path"; import { fileURLToPath } from "node:url"; import { promisify } from "node:util"; +import { gunzipSync } from "node:zlib"; import { createRuntimePackMatrix } from "../runtime-pack-matrix.mjs"; import { RUNTIME_COMPILE_FLAGS, @@ -118,6 +120,44 @@ async function build() { config.bitcode === false ? null : await resolveRuntimeUnitHelper(repoRoot, matrix.target.llvm_triple); + // Speed units that the LLVM helper optimizes with the target's committed + // runtime profile (profiles/.profdata.gz), or instruments for + // scripts/refresh-runtime-profile.mjs (SCRIPTC_RUNTIME_PROFILE_GENERATE=1). + // The runtime compiler then emits their bitcode unoptimized, and one LLVM, + // the helper's, instruments, annotates and optimizes every profiled unit. + // Release and library flavors keep the runtime compiler's optimization. + const committedProfile = join( + repoRoot, + "packages", + "runtime-pack-common", + "profiles", + `${config.target.name}.profdata.gz`, + ); + // The committed profile is unpacked into the build directory once it exists. + const unpackedProfile = join(buildRoot, "runtime.profdata"); + // Without a helper that optimizes units itself, speed units are built as + // before, without the profile. + const unitProfiles = unitHelper?.features.includes("runtime-unit-profile") === true; + if (process.env.SCRIPTC_RUNTIME_PROFILE_GENERATE === "1" && !unitProfiles) + throw new Error( + "SCRIPTC_RUNTIME_PROFILE_GENERATE=1 needs an LLVM helper with runtime-unit profiles", + ); + const speedProfile = !unitProfiles + ? null + : process.env.SCRIPTC_RUNTIME_PROFILE_GENERATE === "1" + ? { args: ["--opt-level", "2", "--profile-generate", ""] } + : existsSync(committedProfile) + ? { args: ["--opt-level", "2", "--profile-use", unpackedProfile], unpack: true } + : null; + // Speed units the helper optimizes without the profile (at the same level). + // scr_message.c: with the profile on its decoding, a program whose workers + // each receive many small messages ran 5% slower in its workers' own code, + // although that code and its data were unchanged; without it, the program + // runs as fast as with an unprofiled runtime. + const unprofiledUnits = new Set(["scr_message.c"]); + // The instrumented units' functions, which SCRIPTC_RUNTIME_PROFILE_FUNCTIONS + // names a file for; the refresh script keeps only their records. + const profiledFunctions = new Set(); const parallel = async (items, task) => { const width = Math.max(1, Math.min(8, availableParallelism())); return parallelMap(items, width, task); @@ -156,6 +196,8 @@ async function build() { await rm(buildRoot, { recursive: true, force: true }); try { await mkdir(stagedOutputRoot, { recursive: true }); + if (speedProfile?.unpack) + await writeFile(unpackedProfile, gunzipSync(await readFile(committedProfile))); // Zig 0.16 creates a zero-byte `a.o` in its working directory for // `zig cc --version`. Probe from a private temporary directory so a // runtime-pack build never leaves that compiler byproduct in the package. @@ -197,6 +239,7 @@ async function build() { ...(config.platform === "linux" ? ["-fomit-frame-pointer"] : []), ] : []; + const profileMatches = []; const units = await parallel( flavorSpec.runtime_units ?? matrix.runtime_units, async (unit) => { @@ -249,9 +292,10 @@ async function build() { let bitcode; const bitcodeOutput = output.replace(/\.o$/, ".bc"); if (flavor === "speed") await mkdir(dirname(output), { recursive: true }); + let unitBuild = false; if ( flavor === "speed" && - (await buildRuntimeUnit({ + (unitBuild = await buildRuntimeUnit({ helper: unitHelper, compileBitcode: (optimized) => run(compiler, [ @@ -259,12 +303,17 @@ async function build() { ...sourcePathFlags, ...unitFlags, "-g0", + ...(speedProfile === null ? [] : ["-Xclang", "-disable-llvm-passes"]), "-emit-llvm", "-c", join(runtimeSrc, unit.source), "-o", optimized, ]), + helperArgs: + speedProfile?.unpack && unprofiledUnits.has(unit.source) + ? ["--opt-level", "2"] + : (speedProfile?.args ?? []), object: output, bitcode: bitcodeOutput, triple: matrix.target.llvm_triple, @@ -275,6 +324,8 @@ async function build() { ), })) ) { + if (unitBuild.profile !== undefined) profileMatches.push(unitBuild.profile); + for (const name of unitBuild.functions ?? []) profiledFunctions.add(name); bitcode = { path: artifactPath(bitcodeOutput), sha256: await sha256(bitcodeOutput), @@ -324,7 +375,21 @@ async function build() { return { source: unit.source, predicate: unit.predicate, variants }; }, ); - flavors[flavor] = { optimization: flavorSpec.optimization, runtime_units: units }; + flavors[flavor] = { + optimization: flavorSpec.optimization, + runtime_units: units, + // How the committed runtime profile matched the units it guided + // (scripts/refresh-runtime-profile.mjs refreshes it). + ...(profileMatches.length === 0 + ? {} + : { + runtime_profile: { + annotated: profileMatches.reduce((sum, match) => sum + match.annotated, 0), + mismatched: profileMatches.reduce((sum, match) => sum + match.mismatched, 0), + missing: profileMatches.reduce((sum, match) => sum + match.missing, 0), + }, + }), + }; } const mbedtlsSources = (await readdir(join(mbedtls, "library"))) .filter((name) => !name.startsWith(".") && name.endsWith(".c")) @@ -505,6 +570,14 @@ async function build() { backupRoot: join(packageRoot, `.runtime-pack-artifacts-backup-${suffix}`), backupManifestPath: join(packageRoot, `.runtime-pack-manifest-backup-${suffix}`), }); + if (process.env.SCRIPTC_RUNTIME_PROFILE_FUNCTIONS !== undefined) + await writeFile( + process.env.SCRIPTC_RUNTIME_PROFILE_FUNCTIONS, + [...profiledFunctions] + .sort() + .map((name) => `${name}\n`) + .join(""), + ); process.stdout.write( `built ${packageManifest.name}@${packageManifest.version}: ${Object.keys(flavors).length} flavors, ${archives.length} vendor archives\n`, ); diff --git a/packages/runtime-pack-common/scripts/runtime-unit.mjs b/packages/runtime-pack-common/scripts/runtime-unit.mjs index 1d4b549b6..68bc62381 100644 --- a/packages/runtime-pack-common/scripts/runtime-unit.mjs +++ b/packages/runtime-pack-common/scripts/runtime-unit.mjs @@ -12,10 +12,13 @@ const run = promisify(execFile); * Speed units produce both artifacts from one optimized LLVM module: the * runtime compiler optimizes the C source to bitcode, and the host helper's * `runtime-unit` command promotes translation-unit-local symbols to hidden - * unit-qualified globals and emits the object and the bitcode. Release - * units keep the runtime compiler's own objects; their bitcode is the same - * compilation with -emit-llvm, which the helper's `prune-import` reduces to - * the bodies that need none of the unit's local state. Both bitcode forms + * unit-qualified globals and emits the object and the bitcode. With the + * target's committed runtime profile, the runtime compiler emits the bitcode + * unoptimized and the helper also optimizes it with the profile (build.mjs), + * so one LLVM reads and writes every profile. Release units keep the + * runtime compiler's own objects; their bitcode is the same compilation with + * -emit-llvm, which the helper's `prune-import` reduces to the bodies that + * need none of the unit's local state. Both bitcode forms * keep only what a program build can import. * * Every host helper supports every scriptc target, so packs keep building @@ -54,7 +57,15 @@ export async function resolveRuntimeUnitHelper(repoRoot, triple) { const { stdout } = await run(helper, ["version", "--format=json"]); const version = JSON.parse(stdout); if (!version.supported_targets?.includes(triple)) return null; - return { path: helper, identity: `scriptc-llvm-codegen ${version.llvm_version}` }; + return { + path: helper, + identity: `scriptc-llvm-codegen ${version.llvm_version}`, + // Commands and options newer than the protocol version, such as + // "runtime-unit-profile": `runtime-unit` takes --opt-level, + // --profile-use and --profile-generate (older helpers only promote and + // emit). + features: Array.isArray(version.features) ? version.features : [], + }; } catch { if (explicit !== undefined) throw new Error(`SCRIPTC_LLVM_CODEGEN is not usable: ${helper}`); return null; @@ -63,12 +74,19 @@ export async function resolveRuntimeUnitHelper(repoRoot, triple) { /** Compile one unit through the helper. `compileBitcode(output)` must run * the runtime compiler with the unit's exact flags plus -emit-llvm. + * `helperArgs` asks the helper to optimize the unit itself (`--opt-level`, + * with `--profile-use` or `--profile-generate`), for bitcode the runtime + * compiler emitted unoptimized. * Returns false, leaving no artifacts behind, when the helper cannot emit * this unit; the caller then compiles the object directly without bitcode, - * so a pack never fails to build because of the import path. */ + * so a pack never fails to build because of the import path. Otherwise + * returns an object, with the helper's `profile` match counts when a profile + * guided the unit, or the profile names of the unit's `functions` when it + * was instrumented. */ export async function buildRuntimeUnit({ helper, compileBitcode, + helperArgs = [], object, bitcode, triple, @@ -79,7 +97,7 @@ export async function buildRuntimeUnit({ // A runtime compiler failure is a real build error and surfaces as one. await compileBitcode(optimized); try { - await run(helper.path, [ + const { stdout } = await run(helper.path, [ "runtime-unit", "--input", optimized, @@ -92,8 +110,13 @@ export async function buildRuntimeUnit({ "--tag", tag, ...(sections ? ["--function-sections", "--data-sections"] : []), + ...helperArgs, ]); - return true; + const report = stdout.trim() === "" ? {} : JSON.parse(stdout.trim().split("\n").pop()); + return { + ...(report.profile === undefined ? {} : { profile: report.profile }), + ...(report.functions === undefined ? {} : { functions: report.functions }), + }; } catch (error) { const detail = (error.stderr || error.message || String(error)).trim().split("\n")[0]; process.stderr.write(`runtime-unit: ${tag}: emitting without import bitcode (${detail})\n`); diff --git a/packages/runtime-pack-common/test/runtime-profile.test.ts b/packages/runtime-pack-common/test/runtime-profile.test.ts new file mode 100644 index 000000000..0b2bf19a4 --- /dev/null +++ b/packages/runtime-pack-common/test/runtime-profile.test.ts @@ -0,0 +1,98 @@ +import { execFileSync } from "node:child_process"; +import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { gunzipSync } from "node:zlib"; +import { expect, test } from "vitest"; +import { resolveRuntimeUnitHelper } from "../scripts/runtime-unit.mjs"; + +const repoRoot = fileURLToPath(new URL("../../..", import.meta.url)); +const committedProfile = join( + repoRoot, + "packages/runtime-pack-common/profiles/linux-x64-gnu.profdata.gz", +); +const manifestPath = join(repoRoot, "packages/runtime-linux-x64-gnu/runtime-pack.json"); +const runtimeRoot = join(repoRoot, "packages/runtime"); + +// Both tests read the committed profile through an LLVM helper new enough to +// apply it; the pack test also needs the Linux x64 pack built with it. +const helper = + process.platform === "linux" && process.arch === "x64" && existsSync(committedProfile) + ? await resolveRuntimeUnitHelper(repoRoot, "x86_64-unknown-linux-gnu") + : null; +const built = + helper?.features.includes("runtime-unit-profile") === true && existsSync(manifestPath); + +// A profile older than the runtime sources loses the counts of every function +// whose control flow changed since it was collected. More than this share of +// the functions it describes means it is due for scripts/refresh-runtime-profile.mjs. +const MAX_MISMATCHED_SHARE = 0.1; + +test.skipIf(!built)("the committed runtime profile still matches the runtime's speed units", () => { + const manifest = JSON.parse(readFileSync(manifestPath, "utf8")); + const match = manifest.flavors?.speed?.runtime_profile; + expect(match, "the speed flavor was built without the committed runtime profile").toBeDefined(); + const described = match.annotated + match.mismatched; + expect(match.annotated).toBeGreaterThan(0); + expect( + match.mismatched / described, + `${match.mismatched} of the ${described} runtime functions the profile describes changed since it was collected; run node scripts/refresh-runtime-profile.mjs`, + ).toBeLessThanOrEqual(MAX_MISMATCHED_SHARE); +}); + +// The committed profile holds the runtime's records only. Training programs' +// functions, and the source paths their names can carry, stay out of the +// repository: refresh-runtime-profile.mjs keeps the functions the +// instrumented runtime units define. +test.skipIf(helper?.features.includes("profile-functions") !== true)( + "the committed runtime profile names only runtime functions", + () => { + const profile = gunzipSync(readFileSync(committedProfile)); + const scratch = mkdtempSync(join(tmpdir(), "scriptc-runtime-profile-")); + let functions: string[]; + try { + const unpacked = join(scratch, "runtime.profdata"); + writeFileSync(unpacked, profile); + ({ functions } = JSON.parse( + execFileSync(helper!.path, ["profile-functions", "--input", unpacked], { + encoding: "utf8", + maxBuffer: 64 * 1024 * 1024, + }), + )); + } finally { + rmSync(scratch, { recursive: true, force: true }); + } + expect(functions.length).toBeGreaterThan(0); + + const sources = new Set(readdirSync(join(runtimeRoot, "src"))); + const identifiers = new Set(); + const collect = (dir: string) => { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const path = join(dir, entry.name); + if (entry.isDirectory()) collect(path); + else if (/\.(c|h|inc|def)$/.test(entry.name)) + for (const name of readFileSync(path, "latin1").match(/[A-Za-z_]\w*/g) ?? []) + identifiers.add(name); + } + }; + collect(join(runtimeRoot, "src")); + collect(join(runtimeRoot, "vendor")); + + // A unit's internal functions are named ".c;" after its + // runtime source, and its external ones by their C names, which are never + // a program's `main` or generated `sc_` symbols. + const foreign = functions.filter((name) => { + if (/[/\\]/.test(name)) return true; + const local = /^([\w-]+\.c);(.+)$/.exec(name); + if (local !== null) return !sources.has(local[1]!); + return name === "main" || name.startsWith("sc_") || !identifiers.has(name); + }); + expect(foreign, "records that are not runtime functions").toEqual([]); + + // Nor does any other part of the file hold a path, such as a checkout's. + const text = profile.toString("latin1"); + const paths = text.match(/(?:\/[\w.-]+){3,}|[A-Za-z]:\\[\w.-]+\\|\.tsx?;/g) ?? []; + expect(paths, "path strings in the committed profile").toEqual([]); + }, +); diff --git a/scripts/bench-runtime.mjs b/scripts/bench-runtime.mjs index 0845d57b8..63f3fa2d6 100644 --- a/scripts/bench-runtime.mjs +++ b/scripts/bench-runtime.mjs @@ -44,6 +44,7 @@ const { values } = parseArgs({ "candidate-target-cpu": { type: "string" }, "baseline-cpu-dispatch": { type: "boolean", default: false }, "candidate-cpu-dispatch": { type: "boolean", default: false }, + "candidate-profile-generate": { type: "string" }, json: { type: "string" }, "no-lock": { type: "boolean", default: false }, node: { type: "boolean", default: true }, @@ -57,6 +58,7 @@ if (values.help) { [--baseline-optimization=release] [--candidate-optimization=release] [--baseline-target-cpu=] [--candidate-target-cpu=] [--baseline-cpu-dispatch] [--candidate-cpu-dispatch] + [--candidate-profile-generate=] [--no-lock] [--no-node] [--keep] Each checkout must have a built CLI (packages/cli/dist/bootstrap.js) and native @@ -79,7 +81,10 @@ release. --baseline-target-cpu/--candidate-target-cpu pass --target-cpu to each contender's builds, e.g. --baseline=. --candidate-target-cpu=x86-64-v3 compares x86-64-v3 code with the baseline. --baseline-cpu-dispatch/--candidate-cpu-dispatch pass --cpu-dispatch, and SCRIPTC_CPU_LEVEL in the environment reaches every timed -run. cold_build_ms records each contender's first, uncached build.`); +run. cold_build_ms records each contender's first, uncached build. +--candidate-profile-generate builds the candidate's workloads with +--profile-generate=, so its runs write raw profiles there +(scripts/refresh-runtime-profile.mjs trains the runtime profile this way).`); process.exit(0); } const runs = Number(values.runs); @@ -448,7 +453,16 @@ function revision(root) { }); return r.stdout.trim() + (dirty.stdout.trim() ? "+dirty" : ""); } -function build(label, root, workload, layout, optimization, targetCpu, cpuDispatch) { +function build( + label, + root, + workload, + layout, + optimization, + targetCpu, + cpuDispatch, + profileGenerate, +) { const cli = cliFor(root); const out = join(work, label, layout === 0 ? workload.name : `${workload.name}.layout${layout}`); mkdirSync(dirname(out), { recursive: true }); @@ -471,6 +485,7 @@ function build(label, root, workload, layout, optimization, targetCpu, cpuDispat `--optimization=${optimization}`, ...(targetCpu === undefined ? [] : [`--target-cpu=${targetCpu}`]), ...(cpuDispatch ? ["--cpu-dispatch"] : []), + ...(profileGenerate === undefined ? [] : [`--profile-generate=${profileGenerate}`]), "-o", out, ], @@ -507,6 +522,7 @@ const contenders = [ optimization: values["candidate-optimization"], targetCpu: values["candidate-target-cpu"], cpuDispatch: values["candidate-cpu-dispatch"], + profileGenerate: values["candidate-profile-generate"], }, ]; const report = { @@ -613,7 +629,16 @@ try { entry.results[c.label] = result; result.binaries = []; for (let layout = 0; layout < layouts && result.status === undefined; layout++) { - const built = build(c.label, c.root, w, layout, c.optimization, c.targetCpu, c.cpuDispatch); + const built = build( + c.label, + c.root, + w, + layout, + c.optimization, + c.targetCpu, + c.cpuDispatch, + c.profileGenerate, + ); result.build_ms += Math.round(built.buildMs); if (layout === 0) result.cold_build_ms = Math.round(built.buildMs); if (!built.ok) { diff --git a/scripts/refresh-runtime-profile.mjs b/scripts/refresh-runtime-profile.mjs new file mode 100644 index 000000000..608abb5de --- /dev/null +++ b/scripts/refresh-runtime-profile.mjs @@ -0,0 +1,188 @@ +#!/usr/bin/env node +/* Refreshes packages/runtime-pack-common/profiles/linux-x64-gnu.profdata.gz, + * the profile that the Linux x64 runtime pack's speed units are optimized + * with (runtime-pack-common/scripts/build.mjs). The LLVM helper instruments, + * annotates and optimizes those units, so the profile is in the helper's + * format and merged by the helper's merge-profiles, like every program + * profile. + * + * Builds an instrumented runtime pack (SCRIPTC_RUNTIME_PROFILE_GENERATE=1), + * then runs the training programs built against it with --profile-generate: + * every runtime benchmark workload at --optimization=speed (through + * scripts/bench-runtime.mjs, with its inputs and load clients), and, with + * --tsc-ts, an instrumented tsc-ts checking its own sources and each + * --project. The raw profiles hold the runtime's counters beside the + * programs'; the merged profile keeps the runtime functions that ran: the + * instrumented pack lists its functions, and the programs' records are left + * out. The pack is then rebuilt with the new profile. A stale profile only + * loses part of its gain: functions whose code changed are optimized as + * without one (the pack test reports how many). + * + * node scripts/refresh-runtime-profile.mjs [--tsc-ts [--project [:]]...] + */ +import { spawnSync } from "node:child_process"; +import { + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { parseArgs } from "node:util"; +import { constants, gzipSync } from "node:zlib"; + +const root = fileURLToPath(new URL("..", import.meta.url)); +const { values } = parseArgs({ + options: { + "tsc-ts": { type: "string" }, + project: { type: "string", multiple: true, default: [] }, + output: { + type: "string", + default: join(root, "packages/runtime-pack-common/profiles/linux-x64-gnu.profdata.gz"), + }, + work: { type: "string" }, + help: { type: "boolean", default: false }, + }, +}); +if (values.help) { + console.log( + "Usage: node scripts/refresh-runtime-profile.mjs [--tsc-ts [--project [:]]...] [--output ] [--work ]", + ); + process.exit(0); +} +if (process.platform !== "linux" || process.arch !== "x64") + throw new Error("the runtime profile is collected on Linux x64"); +if (values.project.length !== 0 && values["tsc-ts"] === undefined) + throw new Error("--project needs --tsc-ts"); +const cli = join(root, "packages/cli/dist/main.js"); +if (!existsSync(cli)) throw new Error("build the compiler first (pnpm build)"); +const helper = join(root, "packages/llvm-linux-x64-gnu/bin/scriptc-llvm-codegen"); +if (!existsSync(helper)) + throw new Error( + "build the LLVM helper first (pnpm --filter @scriptc/llvm-linux-x64-gnu build:native)", + ); +const output = resolve(values.output); +mkdirSync(dirname(output), { recursive: true }); +const work = resolve(values.work ?? mkdtempSync(join(tmpdir(), "scriptc-runtime-profile-"))); +const raw = join(work, "raw"); +rmSync(raw, { recursive: true, force: true }); +mkdirSync(raw, { recursive: true }); +const env = { ...process.env }; +delete env.SCRIPTC_LLVM_HELPER; + +const buildPack = (extra) => + run("pnpm", ["--filter", "@scriptc/runtime-linux-x64-gnu", "build:native"], { + cwd: root, + env: { ...env, ...extra }, + }); + +const functions = join(work, "runtime-functions.txt"); +console.log("building the instrumented runtime pack"); +buildPack({ SCRIPTC_RUNTIME_PROFILE_GENERATE: "1", SCRIPTC_RUNTIME_PROFILE_FUNCTIONS: functions }); +try { + console.log("training on the runtime benchmark workloads"); + run( + process.execPath, + [ + join(root, "scripts/bench-runtime.mjs"), + `--candidate=${root}`, + "--candidate-optimization=speed", + `--candidate-profile-generate=${join(raw, "bench-%m-%p.profraw")}`, + "--runs=3", + "--warmup=0", + "--no-node", + "--no-lock", + `--json=${join(work, "bench.json")}`, + ], + { cwd: root, env }, + ); + if (values["tsc-ts"] !== undefined) trainTscTs(resolve(values["tsc-ts"])); + const profiles = readdirSync(raw).map((name) => join(raw, name)); + if (profiles.length === 0) throw new Error(`no raw profiles in ${raw}`); + // `scriptc merge-profiles` runs this merge too; called directly, it keeps + // only the runtime's records, so the training programs' stay out of the repo. + const merged = join(work, "runtime.profdata"); + run( + helper, + ["merge-profiles", "--output", merged, "--sparse", "--keep-functions", functions, ...profiles], + { cwd: root, env }, + ); + writeFileSync(output, gzipSync(readFileSync(merged), { level: constants.Z_BEST_COMPRESSION })); + console.log(`wrote ${output} from ${profiles.length} raw profiles`); +} finally { + // The pack goes back to an ordinary build, now with the new profile. + console.log("rebuilding the runtime pack"); + buildPack({}); +} +if (values.work === undefined) rmSync(work, { recursive: true, force: true }); + +// An instrumented tsc-ts, built against the instrumented pack, checking its +// own sources and each --project at --checkers 1. +function trainTscTs(checkout) { + const pkg = join(checkout, "packages/tsc-ts"); + if (!existsSync(join(pkg, "src/main.ts"))) + throw new Error(`${checkout} is not a tsc-ts checkout`); + // The upstream lib files, where npm run fetch:upstream puts them (older + // tsc-ts checkouts used the second directory). + const lib = [ + "upstream/typescript-go/internal/bundled/libs", + "upstream/TypeScript/tsc/internal/bundled/libs", + ] + .map((dir) => join(checkout, dir)) + .find((dir) => existsSync(join(dir, "lib.d.ts"))); + if (lib === undefined) + throw new Error( + `no lib files in ${checkout}/upstream: run npm run fetch:upstream in the tsc-ts checkout`, + ); + const tsc = join(work, "tsc-ts"); + console.log("building an instrumented tsc-ts"); + run( + process.execPath, + [ + cli, + "build", + "src/main.ts", + "--optimization=speed", + "--detached-exit", + `--profile-generate=${join(raw, "tsc-ts-%p.profraw")}`, + "-o", + tsc, + ], + { cwd: pkg, env }, + ); + const projects = [ + { dir: pkg, config: "tsconfig.json" }, + ...values.project.map((spec) => { + const [dir, config = "tsconfig.json"] = spec.split(":"); + return { dir: resolve(dir), config }; + }), + ]; + for (const { dir, config } of projects) { + // Projects with type errors exit with 1 or 2; any other status is a failed run. + const result = spawnSync( + tsc, + ["-p", config, "--noEmit", "--incremental", "false", "--checkers", "1"], + { + cwd: dir, + stdio: "ignore", + env: { ...env, TSC_TS_LIB_DIR: lib }, + }, + ); + if (result.status === null || result.status > 2) + throw new Error( + `the instrumented tsc-ts failed on ${join(dir, config)} (${result.signal ?? result.status})`, + ); + console.log(`profiled ${join(dir, config)}`); + } +} + +function run(command, args, options) { + const result = spawnSync(command, args, { ...options, stdio: "inherit" }); + if (result.status !== 0) + throw new Error(`${command} ${args.join(" ")} exited with ${result.status}`); +} diff --git a/tests/corpus/asserted-arm-probe.ts b/tests/corpus/asserted-arm-probe.ts new file mode 100644 index 000000000..aeb8ba06b --- /dev/null +++ b/tests/corpus/asserted-arm-probe.ts @@ -0,0 +1,133 @@ +// Feature-detection probes of a member read through a type assertion. +// +// `input.api` is `Streams | Partial>` (optional) or +// `Streams | Record` (required). Node erases `as`, so +// `(input.api as Streams).stream` reads the property off the value actually +// stored: when that is the record arm, the read answers undefined. The +// probe therefore answers "undefined" (false), where an eager checked +// extraction of Streams would throw on the record arm. A real use of the +// same read — calling the member when the wrong arm is stored — still +// throws, at the call. +type Api = "alpha" | "beta"; + +interface Streams { + stream: () => string; + fetch?: () => string; +} + +interface Options { + name: string; + api?: Streams | Partial>; +} + +interface Required { + name: string; + api: Streams | Record; +} + +function detectTypeof(input: Options): boolean { + return typeof (input.api as Streams).stream === "function"; +} + +function detectUndefined(input: Options): boolean { + return (input.api as Streams).stream !== undefined; +} + +function detectNullish(input: Options): boolean { + return (input.api as Streams).stream == null; +} + +function detectRequired(input: Required): boolean { + return typeof (input.api as Streams).stream === "function"; +} + +function requiredIn(input: Required): boolean { + return "stream" in (input.api as Streams); +} + +// The probe guards the real use: the call runs only on the arm that has +// the member, so the assertion never lies at the call. +function streamName(input: Options): string { + if (typeof (input.api as Streams).stream === "function") return (input.api as Streams).stream(); + return "none"; +} + +// A real use that reaches the wrong arm throws, as it does in Node. +function callStream(input: Options): string { + try { + return (input.api as Streams).stream(); + } catch (err) { + return err instanceof TypeError ? "TypeError" : "other"; + } +} + +const one: Streams = { stream: () => "one" }; +const withFetch: Streams = { stream: () => "both", fetch: () => "fetch" }; +const registry: Partial> = { alpha: one, beta: withFetch }; +const empty: Partial> = {}; +const required: Record = { alpha: one, beta: withFetch }; + +console.log(detectTypeof({ name: "one", api: one })); +console.log(detectTypeof({ name: "registry", api: registry })); +console.log(detectTypeof({ name: "empty", api: empty })); +console.log(detectUndefined({ name: "one", api: one })); +console.log(detectUndefined({ name: "registry", api: registry })); +console.log(detectNullish({ name: "one", api: one })); +console.log(detectNullish({ name: "registry", api: empty })); +console.log(streamName({ name: "one", api: one })); +console.log(streamName({ name: "registry", api: registry })); +console.log(detectRequired({ name: "one", api: one })); +console.log(detectRequired<"alpha" | "beta">({ name: "registry", api: required })); +console.log(requiredIn({ name: "one", api: one })); +console.log(requiredIn<"alpha" | "beta">({ name: "registry", api: required })); +console.log(callStream({ name: "one", api: withFetch })); +console.log(callStream({ name: "registry", api: registry })); + +// `in` through an assertion over a computed receiver still evaluates the +// receiver, exactly once, whether the arms decide the answer (one tag test +// or several) or agree on it. +interface ArmA { + a: number; + x: number; +} +interface ArmB { + b: number; + x: number; +} +interface ArmC { + c: number; +} +interface ArmD { + d: number; +} +type Arm = ArmA | ArmB | ArmC | ArmD; +let boxed = 0; +function box(shape: Arm): { shape: Arm } { + boxed++; + return { shape }; +} +const arms: Arm[] = [{ a: 1, x: 1 }, { b: 2, x: 2 }, { c: 3 }, { d: 4 }]; +for (const arm of arms) console.log("x" in (box(arm).shape as ArmA), "gamma" in (box(arm).shape as ArmA)); +console.log(boxed); + +// A getter-backed member beside a data field of the same name: the probe +// reads the getter on its arm, as Node does. +interface Plain { + kind: "plain"; + label: string; +} +interface Lazy { + kind: "lazy"; + get label(): string; +} +let labelReads = 0; +const lazy: Lazy = { + kind: "lazy", + get label() { + labelReads++; + return "computed"; + }, +}; +const labelled: (Plain | Lazy)[] = [{ kind: "plain", label: "x" }, lazy]; +for (const item of labelled) console.log(typeof (item as Plain).label, (item as Plain).label !== undefined); +console.log(labelReads); diff --git a/tests/corpus/class-structural-anonymous-method.ts b/tests/corpus/class-structural-anonymous-method.ts new file mode 100644 index 000000000..264986967 --- /dev/null +++ b/tests/corpus/class-structural-anonymous-method.ts @@ -0,0 +1,92 @@ +// A class instance stored in a field typed as an anonymous structural object +// type that the class satisfies. Node keeps the instance itself, so calls +// dispatch to the class method (including a subclass override) and `===` +// still holds. The type may be an optional method inside a union with +// undefined (pi's `{ handleInput?(data: string): void } | undefined`), a +// required method in a plain slot, or data only. + +class SelectList { + label = "list"; + hits = 0; + handleInput(data: string): void { + this.hits++; + console.log(`${this.label} handled ${data} (${this.hits})`); + } +} + +class HuggingFaceSearch extends SelectList { + label = "search"; + handleInput(data: string): void { + super.handleInput(data); + console.log("override", data); + } +} + +class Screen { + private inputHandler: { handleInput?(data: string): void } | undefined; + setContent(inputHandler?: { handleInput?(data: string): void }): void { + this.inputHandler = inputHandler; + } + forward(data: string): void { + this.inputHandler?.handleInput?.(data); + } + hasHandler(): boolean { + return this.inputHandler !== undefined; + } +} + +const screen = new Screen(); +console.log("empty", screen.hasHandler()); +const list = new SelectList(); +screen.setContent(list); +screen.forward("a"); +console.log("filled", screen.hasHandler()); +screen.setContent(new HuggingFaceSearch()); +screen.forward("b"); +screen.setContent(undefined); +console.log("cleared", screen.hasHandler()); +console.log("list hits", list.hits); + +// A required method in a plain anonymous slot: the receiver stays the +// original instance. +class Button { + presses = 0; + press(): void { + this.presses++; + console.log("press", this.presses); + } +} +function pressTwice(button: { press(): void }): void { + button.press(); + button.press(); +} +const button = new Button(); +pressTwice(button); +console.log("button presses", button.presses); + +// Identity through the anonymous view holds. +class Holder { + value: { press(): void } | undefined = undefined; + same(candidate: { press(): void }): boolean { + return this.value === candidate; + } +} +const holder = new Holder(); +holder.value = button; +console.log("identity", holder.same(button)); + +// A data-only anonymous type is a width copy when nothing observes the +// identity, and a live view when a member is written through (Node writes +// the instance itself, so the write must reach it). +class Point { + x = 1; + y = 2; +} +const point = new Point(); +const view: { x: number; y: number } = point; +console.log("view", view.x + view.y); +const alias: { x: number; y: number } = point; +alias.x = 10; +console.log("alias", alias.x + alias.y, "point", point.x + point.y); + +export {}; diff --git a/tests/corpus/class-union-cycles.ts b/tests/corpus/class-union-cycles.ts new file mode 100644 index 000000000..187b56031 --- /dev/null +++ b/tests/corpus/class-union-cycles.ts @@ -0,0 +1,93 @@ +// Cycles through class-union fields: a union whose arms include a class +// that can reach itself is cycle-capable, so every arm carries the cycle +// collector's header, including arms that hold only numbers. Graphs are +// built, linked into cycles, rewired and dropped across many rounds. +class Leaf { + readonly value: number; + constructor(value: number) { + this.value = value; + } +} + +class Branch { + left: Tree; + right: Tree; + parent: Tree | undefined = undefined; + constructor(left: Tree, right: Tree) { + this.left = left; + this.right = right; + } +} + +class Ref { + target: Tree | null = null; + readonly label: string; + constructor(label: string) { + this.label = label; + } +} + +type Tree = Leaf | Branch | Ref; + +function sum(t: Tree | null, depth: number): number { + if (t === null || depth > 40) return 0; + if (t instanceof Leaf) return t.value; + if (t instanceof Ref) return 1 + sum(t.target, depth + 1); + return sum(t.left, depth + 1) + sum(t.right, depth + 1); +} + +function build(depth: number, seed: number): Tree { + if (depth === 0) return new Leaf(seed % 5); + const b = new Branch(build(depth - 1, seed * 7 + 1), build(depth - 1, seed + 3)); + if (b.left instanceof Branch) b.left.parent = b; + if (b.right instanceof Branch) b.right.parent = b; + return b; +} + +let checksum = 0; +const kept: Tree[] = []; +for (let round = 0; round < 300; round++) { + const tree = build(5, round); + const ref = new Ref(`r${round}`); + // A cycle: the tree reaches the ref, and the ref reaches the tree. + if (tree instanceof Branch) { + tree.right = ref; + ref.target = tree; + } + checksum += sum(tree, 0); + if (round % 50 === 0) kept.push(tree); + // Rewire: drop the cycle on some rounds and keep it on others. + if (round % 3 === 0) ref.target = new Leaf(round); +} +console.log(checksum, kept.length, kept.map((t) => sum(t, 0)).join(" ")); + +// A self-referential ref and a two-ref ring, dropped. +for (let i = 0; i < 100; i++) { + const self = new Ref("self"); + self.target = self; + const a = new Ref("a"); + const b = new Ref("b"); + a.target = b; + b.target = a; + checksum += sum(a, 30) + sum(self, 35); +} +console.log(checksum); + +// Union values in arrays and maps that form cycles with their holders. +class Holder { + readonly items: Tree[] = []; + readonly named = new Map(); +} +let held = 0; +for (let i = 0; i < 50; i++) { + const h = new Holder(); + const ref = new Ref(`h${i}`); + h.items.push(new Leaf(i), ref, build(2, i)); + h.named.set("ref", ref); + const branch = new Branch(ref, new Leaf(1)); + ref.target = branch; + branch.parent = h.items[2]!; + h.items.push(branch); + held += h.items.length + h.named.size + sum(h.items[3]!, 0); +} +console.log(held); diff --git a/tests/corpus/class-union-hierarchies.ts b/tests/corpus/class-union-hierarchies.ts new file mode 100644 index 000000000..f61809de6 --- /dev/null +++ b/tests/corpus/class-union-hierarchies.ts @@ -0,0 +1,161 @@ +// Class-union arms that are hierarchies: an instance of a subclass is in +// its base's arm, sibling subclasses can be arms of one union, virtual +// methods dispatch through a narrowed arm, and thrown instances keep their +// class through a catch. +abstract class Node { + readonly pos: number; + constructor(pos: number) { + this.pos = pos; + } + abstract text(): string; +} + +class Identifier extends Node { + readonly name: string; + constructor(pos: number, name: string) { + super(pos); + this.name = name; + } + text(): string { + return this.name; + } +} + +class PrivateIdentifier extends Node { + readonly name: string; + constructor(pos: number, name: string) { + super(pos); + this.name = name; + } + text(): string { + return `#${this.name}`; + } +} + +class Literal extends Node { + readonly value: number; + constructor(pos: number, value: number) { + super(pos); + this.value = value; + } + text(): string { + return String(this.value); + } +} + +class BigLiteral extends Literal { + text(): string { + return `${this.value}n`; + } +} + +class Symbol_ { + readonly name: string; + uses = 0; + constructor(name: string) { + this.name = name; + } +} + +class Signature { + readonly params: number; + constructor(params: number) { + this.params = params; + } +} + +class Type { + readonly id: number; + constructor(id: number) { + this.id = id; + } +} + +class ObjectType extends Type { + readonly members: string[] = []; +} + +class UnionType extends Type { + readonly types: Type[]; + constructor(id: number, types: Type[]) { + super(id); + this.types = types; + } +} + +// Sibling subclasses as arms. +type MemberName = Identifier | PrivateIdentifier; +function memberText(name: MemberName): string { + return name instanceof PrivateIdentifier ? `private ${name.name}` : `public ${name.name}`; +} +const names: MemberName[] = [new Identifier(0, "a"), new PrivateIdentifier(1, "b"), new Identifier(2, "c")]; +console.log(names.map(memberText).join(", "), names.map((n) => n.text()).join(" "), names.map((n) => n.pos).join("")); + +// Whole hierarchies as arms, and a subclass instance in its base's arm. +type Target = Symbol_ | Type | Signature | Node; +function describe(target: Target): string { + if (target instanceof Symbol_) return `symbol ${target.name}`; + if (target instanceof Signature) return `signature/${target.params}`; + if (target instanceof UnionType) return `union ${target.id} of ${target.types.length}`; + if (target instanceof Type) return `${target instanceof ObjectType ? "object" : "type"} ${target.id}`; + return `node ${target.text()} at ${target.pos}`; +} +const targets: Target[] = []; +const sym = new Symbol_("x"); +const obj = new ObjectType(2); +targets.push(sym, new Type(1), obj, new UnionType(3, [obj, new Type(4)]), new Signature(2)); +targets.push(new Identifier(5, "y"), new Literal(6, 42), new BigLiteral(7, 9), names[1]!); +console.log(targets.map(describe).join("; ")); + +// A resolution stack keyed by target identity, as a checker keeps one. +const stack: Target[] = []; +const props: number[] = []; +function push(target: Target, prop: number): boolean { + for (let i = stack.length - 1; i >= 0; i--) if (stack[i] === target && props[i] === prop) return false; + stack.push(target); + props.push(prop); + return true; +} +function pop(): void { + stack.pop(); + props.pop(); +} +let cycles = 0; +for (let round = 0; round < 200; round++) { + const t = targets[round % targets.length]!; + if (!push(t, round % 3)) cycles++; + if (!push(sym, 0)) cycles++; + if (round % 4 === 3) { + pop(); + pop(); + } +} +console.log(stack.length, cycles, describe(stack[stack.length - 1]!)); + +// Checked casts out of the union. +function symbolName(t: Target): string { + return (t as Symbol_).name; +} +function typeId(t: Target): number { + return (t as Type).id; +} +console.log(symbolName(sym), typeId(obj), typeId(targets[3]!)); + +// Unit arms beside hierarchy arms. +type OptionalTarget = Type | Node | undefined; +const optional: OptionalTarget[] = [undefined, new Literal(1, 1), new UnionType(5, []), undefined]; +console.log(optional.map((t) => (t === undefined ? "-" : t instanceof Node ? t.text() : `t${t.id}`)).join(" ")); + +// Throwing and catching union arms. +function fail(target: Target): never { + throw target; +} +for (const t of [sym, new Signature(0), new Literal(3, 3)] as Target[]) { + try { + fail(t); + } catch (e) { + if (e instanceof Symbol_) console.log("caught symbol", e.name); + else if (e instanceof Signature) console.log("caught signature", e.params); + else if (e instanceof Node) console.log("caught node", e.text()); + } +} diff --git a/tests/corpus/class-union-pointers.ts b/tests/corpus/class-union-pointers.ts new file mode 100644 index 000000000..f3841aa63 --- /dev/null +++ b/tests/corpus/class-union-pointers.ts @@ -0,0 +1,182 @@ +// Unions of two or more classes hold the instance pointer itself: wraps, +// narrowing by instanceof and by a discriminant field, unit arms, identity, +// containers, fields, retypes between such unions, captures, and the +// conversions that print or serialize a union value. +class Circle { + readonly kind = "circle"; + readonly r: number; + constructor(r: number) { + this.r = r; + } + area(): number { + return 3 * this.r * this.r; + } +} + +class Rect { + readonly kind = "rect"; + readonly w: number; + readonly h: number; + constructor(w: number, h: number) { + this.w = w; + this.h = h; + } + area(): number { + return this.w * this.h; + } +} + +class Tri { + readonly kind = "tri"; + readonly b: number; + readonly h: number; + constructor(b: number, h: number) { + this.b = b; + this.h = h; + } +} + +type Shape = Circle | Rect | Tri; +type MaybeShape = Shape | undefined; +type NullableShape = Circle | Rect | null | undefined; + +function area(s: Shape): number { + if (s instanceof Circle) return s.area(); + if (s instanceof Rect) return s.area(); + return (s.b * s.h) / 2; +} + +function byKind(s: Shape): string { + switch (s.kind) { + case "circle": + return `circle ${s.r}`; + case "rect": + return `rect ${s.w}x${s.h}`; + case "tri": + return `tri ${s.b}/${s.h}`; + } +} + +function make(i: number): Shape { + if (i % 3 === 0) return new Circle(i); + if (i % 3 === 1) return new Rect(i, i + 1); + return new Tri(i, 2); +} + +const shapes: Shape[] = []; +for (let i = 0; i < 9; i++) shapes.push(make(i)); +console.log(shapes.map(area).join(" ")); +console.log(shapes.map(byKind).join(", ")); + +// Unit arms: undefined is NULL, null is a sentinel when both are present. +function describe(s: MaybeShape): string { + if (s === undefined) return "none"; + return byKind(s); +} +function describeNullable(s: NullableShape): string { + if (s === null) return "null"; + if (s === undefined) return "undefined"; + return s instanceof Circle ? `c${s.r}` : `r${s.w}`; +} +const maybe: MaybeShape[] = [undefined, new Circle(2), undefined, new Tri(1, 1)]; +console.log(maybe.map(describe).join(" ")); +const nullable: NullableShape[] = [null, new Rect(3, 4), undefined, new Circle(5)]; +console.log(nullable.map(describeNullable).join(" ")); +console.log(nullable.map((s) => s ?? "fallback").map((s) => (typeof s === "string" ? s : s.kind)).join(" ")); +console.log(maybe.map((s) => s?.kind ?? "-").join(" ")); + +// Identity: equality compares instances. +const c = new Circle(1); +const a: Shape = c; +const b: Shape = c; +const other: Shape = new Circle(1); +console.log(a === b, a === other, a !== other, shapes.indexOf(shapes[4]!), shapes.includes(other)); + +// Fields of class-union type, reassigned and cleared. +class Slot { + current: MaybeShape = undefined; + history: Shape[] = []; + put(s: Shape): void { + if (this.current !== undefined) this.history.push(this.current); + this.current = s; + } +} +const slot = new Slot(); +for (const s of shapes.slice(0, 5)) slot.put(s); +console.log(describe(slot.current), slot.history.length, slot.history.map((s) => s.kind).join(",")); +slot.current = undefined; +console.log(describe(slot.current)); + +// Maps and sets of instances, keyed by an arm-independent value. +const byName = new Map(); +for (const s of shapes) byName.set(byKind(s), s); +let total = 0; +for (const [, s] of byName) total += area(s); +console.log(byName.size, total); +const seen = new Set(); +for (const s of shapes) if (s instanceof Circle) seen.add(s); +console.log(seen.size); + +// Retypes: a narrower union widens to a wider one and back. +type Round = Circle | Rect; +function widen(r: Round): MaybeShape { + return r; +} +function narrow(s: MaybeShape): Round | undefined { + if (s === undefined || s instanceof Tri) return undefined; + return s; +} +const round: Round[] = [new Circle(7), new Rect(1, 2)]; +console.log(round.map(widen).map(describe).join(" "), describe(narrow(new Tri(3, 3))), describe(narrow(round[1]))); + +// Captures and callbacks. +function counter(start: Shape): () => string { + let s: Shape = start; + let n = 0; + return () => { + n++; + s = n % 2 === 0 ? new Rect(n, n) : new Circle(n); + return byKind(s); + }; +} +const next = counter(new Tri(0, 0)); +console.log(next(), next(), next()); +console.log(shapes.filter((s) => s instanceof Rect).length, shapes.find((s) => s instanceof Tri)?.kind); +console.log([...shapes].sort((x, y) => area(x) - area(y)).map((s) => area(s)).join(" ")); + +// Printing and serializing union values. +console.log(shapes[0], shapes[1], shapes[2]); +console.log(JSON.stringify(shapes.slice(0, 3))); +console.log(JSON.stringify({ current: slot.history[0], none: maybe[0] })); +console.log(String(shapes[3] instanceof Circle), `${shapes[4] instanceof Rect}`); +const mixed: (Shape | number)[] = [new Circle(1), 4]; +console.log(mixed.map((m) => (typeof m === "number" ? m : m.kind)).join(" ")); + +// Fresh instances passed straight to functions that only read them. +function width(s: Shape): number { + return s instanceof Rect ? s.w : s instanceof Circle ? s.r * 2 : s.b; +} +let widths = 0; +for (let i = 0; i < 100; i++) widths += width(new Rect(i, 1)) + width(new Circle(i)) + width(new Tri(1, i)); +console.log(widths, byKind(new Rect(2, 3)), width(new Circle(4))); + +// Conversions between class unions, boxed unions and unknown values. +function boxed(s: Shape): Shape | number | string { + return s; +} +function unboxed(v: Shape | number | string): Shape | undefined { + return typeof v === "number" || typeof v === "string" ? undefined : v; +} +const wide = shapes.slice(0, 3).map(boxed); +wide.push(5, "text"); +console.log(wide.map((v) => (typeof v === "object" ? v.kind : typeof v)).join(" ")); +console.log(wide.map(unboxed).map(describe).join(" ")); +const anyValues: unknown[] = shapes.slice(3, 6); +console.log(anyValues.map((v) => (v instanceof Circle ? "C" : v instanceof Rect ? "R" : v instanceof Tri ? "T" : "?")).join("")); +const back = anyValues[1] as Shape; +console.log(byKind(back), (anyValues[0] as Circle).r, JSON.stringify(anyValues)); +try { + console.log((anyValues[2] as Circle).area()); +} catch (e) { + console.log((e as Error).name); +} diff --git a/tests/corpus/class-union-program-lifetime.ts b/tests/corpus/class-union-program-lifetime.ts new file mode 100644 index 000000000..e856bb63a --- /dev/null +++ b/tests/corpus/class-union-program-lifetime.ts @@ -0,0 +1,173 @@ +import { Worker, isMainThread, parentPort, workerData } from "node:worker_threads"; +import { publish } from "@scriptc/threads"; +import type { ProgramLifetime } from "@scriptc/types"; + +// Class unions over ProgramLifetime classes, whose instances keep their +// class id in a 32-bit header and count no references: a checker's +// resolution stack of `Symbol | Type | Signature | Node` targets, union +// fields, and a published graph read by a worker. Receivers read only +// fields, since Node.js clones what it posts. + +abstract class Node implements ProgramLifetime { + readonly kind: string; + readonly pos: number; + parent: Node | undefined = undefined; + constructor(kind: string, pos: number) { + this.kind = kind; + this.pos = pos; + } +} + +class Identifier extends Node { + readonly text: string; + constructor(pos: number, text: string) { + super("identifier", pos); + this.text = text; + } +} + +class PrivateIdentifier extends Node { + readonly text: string; + constructor(pos: number, text: string) { + super("private", pos); + this.text = text; + } +} + +class Call extends Node { + readonly callee: Identifier | PrivateIdentifier; + constructor(pos: number, callee: Identifier | PrivateIdentifier) { + super("call", pos); + this.callee = callee; + callee.parent = this; + } +} + +class Sym implements ProgramLifetime { + readonly kind = "symbol"; + readonly name: string; + declaration: Node | Sym | undefined = undefined; + constructor(name: string) { + this.name = name; + } +} + +class Type implements ProgramLifetime { + readonly kind: string = "type"; + readonly id: number; + origin: Sym | Type | undefined = undefined; + constructor(id: number) { + this.id = id; + } +} + +class ObjectType extends Type { + readonly kind: string = "object"; +} + +class Signature implements ProgramLifetime { + readonly kind = "signature"; + readonly arity: number; + constructor(arity: number) { + this.arity = arity; + } +} + +type Target = Sym | Type | Signature | Node; + +function label(t: Target | undefined): string { + if (t === undefined) return "-"; + switch (t.kind) { + case "symbol": + return `S:${(t as Sym).name}`; + case "type": + case "object": + return `T${(t as Type).id}`; + case "signature": + return `sig${(t as Signature).arity}`; + default: + return `N@${(t as Node).pos}`; + } +} + +class Checker { + readonly targets: Target[] = []; + readonly props: number[] = []; + depth = 0; + cycles = 0; + push(target: Target, prop: number): boolean { + for (let i = this.depth - 1; i >= 0; i--) + if (this.targets[i] === target && this.props[i] === prop) { + this.cycles++; + return false; + } + if (this.depth < this.targets.length) { + this.targets[this.depth] = target; + this.props[this.depth] = prop; + } else { + this.targets.push(target); + this.props.push(prop); + } + this.depth++; + return true; + } + pop(): void { + this.depth--; + } +} + +function program(prefix: string): Target[] { + const out: Target[] = []; + for (let i = 0; i < 4; i++) { + const sym = new Sym(`${prefix}${i}`); + const id = new Identifier(i * 10, `${prefix}${i}`); + const call = new Call(i * 10 + 1, i % 2 === 0 ? id : new PrivateIdentifier(i * 10 + 2, `${prefix}p${i}`)); + sym.declaration = i % 3 === 0 ? call : sym; + const type = i % 2 === 0 ? new Type(i) : new ObjectType(i); + type.origin = i === 0 ? sym : type; + out.push(sym, type, new Signature(i), call, call.callee); + } + return out; +} + +function local(): void { + const targets = program("a"); + const checker = new Checker(); + for (let round = 0; round < 400; round++) { + const t = targets[(round * 7) % targets.length]!; + if (checker.push(t, round % 2)) { + if (round % 5 === 4) checker.pop(); + } + if (checker.depth > 12) checker.depth = 2; + } + console.log(checker.depth, checker.cycles, checker.targets.slice(0, 6).map(label).join(" ")); + console.log(targets.map(label).join(" ")); + console.log( + targets + .filter((t) => t instanceof Sym) + .map((t) => label((t as Sym).declaration)) + .join(" "), + targets.filter((t) => t instanceof Type).map((t) => label((t as Type).origin)).join(" "), + ); + const member: (Identifier | PrivateIdentifier)[] = []; + for (const t of targets) { + if (t instanceof Identifier) member.push(t); + else if (t instanceof PrivateIdentifier) member.push(t); + } + console.log(member.map((m) => (m instanceof PrivateIdentifier ? `#${m.text}` : m.text)).join(" ")); +} + +if (isMainThread) { + local(); + const shared = publish(program("w")); + console.log("published", shared.map(label).join(" ")); + const worker = new Worker(new URL(import.meta.url), { workerData: { shared } }); + worker.on("message", (message: unknown) => { + console.log("worker", message as string); + }); +} else { + const { shared } = workerData as { shared: Target[] }; + const kinds = shared.map((t) => t.kind).join(","); + const names = shared.map((t) => (t.kind === "symbol" ? (t as Sym).name : "")).join(""); + parentPort!.postMessage(`${shared.length} ${kinds} ${names}`); +} diff --git a/tests/corpus/generic-member-constrained-lambda-impls.ts b/tests/corpus/generic-member-constrained-lambda-impls.ts new file mode 100644 index 000000000..b34d46c15 --- /dev/null +++ b/tests/corpus/generic-member-constrained-lambda-impls.ts @@ -0,0 +1,73 @@ +// A generic method member whose own type parameter is CONSTRAINED by the +// enclosing type's type parameter (`run(item: Item)`), +// implemented by a plain lambda: the lambda takes the interface signature's +// type parameters as its own and lowers once, erased like a generic lambda. +// `Item`'s conditional member depends on the type argument each call +// passes, so the erased parameter must hold whatever the call sends. + +type Api = "alpha" | "beta" | "gamma"; + +interface AlphaOptions { + fast?: boolean; +} +interface BetaOptions { + depth?: number; +} + +interface Item { + id: string; + api: TApi; + options?: TApi extends "alpha" ? AlphaOptions : TApi extends "beta" ? BetaOptions : never; +} + +interface Runner { + name: string; + run(item: Item): string; +} + +function describe(item: Item): string { + const options = item.options === undefined ? "-" : JSON.stringify(item.options); + return `${item.id}:${item.api}:${options}`; +} + +function makeRunner(name: string): Runner { + return { + name, + run: (item) => `${name}:${describe(item)}`, + }; +} + +const both = makeRunner<"alpha" | "beta">("both"); +console.log(both.name); +console.log(both.run({ id: "a", api: "alpha", options: { fast: true } })); +console.log(both.run({ id: "b", api: "beta", options: { depth: 3 } })); +console.log(both.run({ id: "c", api: "alpha" })); +console.log(both.run<"beta">({ id: "d", api: "beta" })); + +const all = makeRunner("all"); +console.log(all.run({ id: "e", api: "gamma" })); +console.log(all.run({ id: "f", api: "beta", options: { depth: 1 } })); + +// The same contract through a class body: the generic method lowers under +// the class instantiation's binding joined with its own type parameter. +class ClassRunner implements Runner { + name: string; + constructor(name: string) { + this.name = name; + } + run(item: Item): string { + return `${this.name}:${describe(item)}`; + } +} + +const cls = new ClassRunner<"alpha" | "beta">("cls"); +console.log(cls.run({ id: "g", api: "alpha", options: { fast: false } })); +console.log(cls.run({ id: "h", api: "beta" })); + +// Record-held receivers call the erased slot the literal stored. +const runners: Runner<"alpha" | "beta">[] = [both, cls]; +let combined = ""; +for (const runner of runners) { + combined += `[${runner.run({ id: "i", api: "alpha" })}]`; +} +console.log(combined); diff --git a/tests/corpus/never-returning-presence-guard.ts b/tests/corpus/never-returning-presence-guard.ts new file mode 100644 index 000000000..e1509758a --- /dev/null +++ b/tests/corpus/never-returning-presence-guard.ts @@ -0,0 +1,62 @@ +// A read guarded by a call that never returns (`if (x === undefined) fail()`) +// is present after the guard, as it is after `throw`. A string[] that such +// reads fill then holds no undefined elements, so the program's string[] +// elements still write to stdout as strings. +function fail(what: string): never { + throw new Error(what); +} + +const words: string[] = ["alpha", "beta"]; +function word(index: number): string { + const known = words[index]; + if (known === undefined) fail(`no word at ${index}`); + return known; +} + +class Names { + private readonly names: string[] = ["gamma"]; + private missing(): never { + throw new RangeError("no name"); + } + first(): string { + const name = this.names[0]; + if (name === undefined) this.missing(); + return name; + } +} + +function collect(): string[] { + const out: string[] = []; + out.push(word(0)); + out.push(word(1)); + out.push(new Names().first()); + return out; +} + +console.log(collect().join(" ")); +const parts: string[] = JSON.stringify({ a: 1, b: [2, 3] }).split(","); +for (const part of parts) { + process.stdout.write(part); + process.stdout.write("\n"); +} +try { + word(5); +} catch (error) { + if (error instanceof Error) console.log(error.message); +} + +// Behind `?.`, a missing reporter skips the call, so the read after it can +// still be undefined. +class Reporter { + fail(what: string): never { + throw new Error(what); + } +} +const reporters: Reporter[] = []; +function maybeWord(index: number): string { + const known = words[index]; + if (known === undefined) reporters[index]?.fail(`no word at ${index}`); + return known; +} +const missing = maybeWord(7); +console.log(maybeWord(1), missing === undefined, typeof missing); diff --git a/tests/corpus/promise-union-widening.ts b/tests/corpus/promise-union-widening.ts new file mode 100644 index 000000000..857ad5c28 --- /dev/null +++ b/tests/corpus/promise-union-widening.ts @@ -0,0 +1,88 @@ +// A union value whose promise arm payload widens into a wider promise-armed +// union: `Promise | undefined` into `Promise | undefined`, +// and `Promise | undefined` into `Promise | undefined`. +// TypeScript accepts these assignments. The promise keeps its identity and +// its settlement turn; only the settled payload converts, and a rejection +// carries its reason unchanged. + +interface Slot { + label: string; + pending?: Promise; +} + +interface Note { + id: number; +} + +interface Annotated { + id: number; + note?: string; +} + +function settle(flag: boolean): Promise | undefined { + return flag ? Promise.resolve() : undefined; +} + +function valueOf(n: number): Promise | undefined { + return n > 0 ? Promise.resolve(n) : undefined; +} + +function reject(flag: boolean): Promise | undefined { + return flag ? Promise.reject(new Error("boom")) : undefined; +} + +function note(flag: boolean): Promise | undefined { + return flag ? Promise.resolve({ id: 7 }) : undefined; +} + +function widen(value: Promise | undefined): Promise | undefined { + return value; +} + +function widenNote(value: Promise | undefined): Promise | undefined { + return value; +} + +async function main(): Promise { + // A statement assignment, both arms. + const present = settle(true); + const widened: Promise | undefined = present; + console.log("present", widened === present, widened === undefined, await widened); + const absent = settle(false); + const empty: Promise | undefined = absent; + console.log("absent", empty === absent, empty === undefined, await empty); + + // A payload that is a value, not void. + const count = valueOf(2); + const counted: Promise | undefined = count; + console.log("count", await counted, counted === count); + + // A rejection keeps its reason. + const failing = reject(true); + const checked: Promise | undefined = failing; + try { + await checked; + } catch (error) { + console.log("rejected", (error as Error).message); + } + + // A widened record payload, in a field and through a function result. + const noteValue = note(true); + const annotatedSlot: Promise | undefined = noteValue; + console.log("note", await annotatedSlot, annotatedSlot === noteValue); + const missingNote: Promise | undefined = note(false); + console.log("no note", missingNote === undefined, await missingNote); + const wider = widenNote(note(true)); + console.log("note result", wider === undefined, await wider); + + // A field, an array element, and a returned union. + const slot: Slot = { label: "a", pending: present }; + console.log("field", slot.pending === present, slot.pending === undefined); + console.log("field await", await slot.pending); + const list: (Promise | undefined)[] = [present, absent, count]; + console.log("array", list.map((entry) => entry === undefined).join(",")); + console.log("array await", list[1] === undefined, await list[2]); + console.log("return", widen(present) === present, widen(absent) === undefined); +} + +main(); diff --git a/tests/corpus/record-ovf-union-arm.ts b/tests/corpus/record-ovf-union-arm.ts new file mode 100644 index 000000000..55badc17d --- /dev/null +++ b/tests/corpus/record-ovf-union-arm.ts @@ -0,0 +1,62 @@ +// A record arm with an index signature narrowing into another +// index-signature record arm of a union. +// +// The union conversion re-tags the record arm by the overflow CAPTURE +// reshape: the shared declared fields copy off the source and every other +// source field keys into the destination's overflow. Node erases the types +// and keeps one object, so reads of the shared fields, of the extra +// declared field (`type`) and of the overflow entries answer the same +// values in the same key order. +interface Credentials { + refresh: string; + access: string; + expires: number; + [key: string]: unknown; +} + +interface Tagged extends Credentials { + type: "oauth"; +} + +interface ApiKey { + type: "api_key"; + key?: string; +} + +type Credential = ApiKey | Tagged; + +function oauthOf(credential: Credential | undefined): Credentials | undefined { + return credential?.type === "oauth" ? credential : undefined; +} + +const stored: Credential[] = [ + // Literal keys in declaration order: the reshape writes the source's + // declared fields in that order, then its overflow. + { refresh: "r1", access: "a1", expires: 100, type: "oauth", account: "acct", scope: 3 }, + { type: "api_key", key: "k1" }, + { refresh: "r2", access: "a2", expires: 200, type: "oauth" }, +]; + +for (const credential of stored) { + const kept = oauthOf(credential); + if (kept === undefined) { + console.log(String(credential.type), "none"); + continue; + } + console.log(Object.keys(kept).join(",")); + console.log(String(kept.type), kept.refresh, kept.access, kept.expires); + console.log(String(kept.account), String(kept.scope)); +} + +// The undefined arm keeps its tag through the same conversion. +const missing = oauthOf(undefined); +console.log(missing === undefined); + +// The directly assigned wide record (the top-level path the union arm now +// shares) answers the same reads, and a write keys into the copy's own +// overflow. +const tagged: Tagged = { refresh: "r3", access: "a3", expires: 300, type: "oauth", region: "eu" }; +const direct: Credentials = tagged; +console.log(Object.keys(direct).join(","), String(direct.type), String(direct.region)); +direct.region = "us"; +console.log(String(direct.region)); diff --git a/tests/corpus/tuple-union-to-array.ts b/tests/corpus/tuple-union-to-array.ts new file mode 100644 index 000000000..341960fa0 --- /dev/null +++ b/tests/corpus/tuple-union-to-array.ts @@ -0,0 +1,107 @@ +// A UNION of tuples flowing into an array slot — the `as const` key-table +// shape (`LEGACY_KEY_SEQUENCES[functionKey]`), where TypeScript erases the +// arity for free. The width family's copy stance applies to every arm on +// its own tag: a tuple arm rebuilds as a fresh array (%tup.arr), an array +// arm copies or lifts (%arr), and a union arm that is the slot itself keeps +// it. Every read below must byte-match Node. + +// The pi shape: an `as const` table indexed by a literal union, its +// readonly-tuple union passed to a `readonly string[]` parameter. +const SEQUENCES = { + up: ["\x1b[A", "\x1bOA"], + down: ["\x1b[B", "\x1bOB"], + home: ["\x1b[H", "\x1bOH", "\x1b[1~", "\x1b[7~"], + f9: ["\x1b[20~"], +} as const; + +type SequenceKey = keyof typeof SEQUENCES; + +function matches(data: string, sequences: readonly string[]): boolean { + return sequences.includes(data); +} + +function readKey(key: SequenceKey, data: string): boolean { + return matches(data, SEQUENCES[key]); +} + +console.log(readKey("up", "\x1bOA"), readKey("f9", "\x1b[20~"), readKey("home", "\x1b[9~")); +console.log(SEQUENCES.up.length, SEQUENCES.home.length, SEQUENCES.f9[0]!.length); + +// A union of mutable tuples through every flow edge. +type Variant = ["one"] | ["two", "three"] | ["four", "five", "six"]; + +function pick(flag: number): Variant { + return flag === 0 ? ["one"] : flag === 1 ? ["two", "three"] : ["four", "five", "six"]; +} + +function joined(xs: string[]): string { + return xs.join("-"); +} + +interface Holder { + seqs: readonly string[]; + label: string; +} + +function holder(flag: number): Holder { + return { seqs: pick(flag), label: `v${flag}` }; +} + +function nested(flag: number): string[][] { + return [pick(flag)]; +} + +for (let i = 0; i < 3; i++) { + const variant = pick(i); + // The union itself has no array members; it becomes one at the slot. + const asArray: string[] = variant; + console.log(joined(variant)); + console.log(asArray.length, asArray[asArray.length - 1]!.toUpperCase()); + for (const item of asArray) console.log("item", item); + const held = holder(i); + console.log(held.label, held.seqs.length, held.seqs.join("/")); + console.log(nested(i)[0]!.join("+")); + // A copy is fresh storage: growing the array leaves the tuple value. + // Only the array side is observed, where Node agrees byte-for-byte. + const grown: string[] = variant; + grown.push("extra", "more"); + console.log(grown.length, grown.join(",")); +} + +// A record-copy field keeps the union conversion on its own arm. +interface Narrow { + seqs: Variant; + label: string; +} +const narrow: Narrow = { seqs: pick(2), label: "n" }; +const widened: Holder = narrow; +console.log(widened.label, widened.seqs.length, widened.seqs.join()); + +// A union of arrays into one wider array slot. +const arrays: string[] | number[] = [1, 2, 3]; +const widenedArrays: readonly (string | number)[] = arrays; +console.log(widenedArrays.length, widenedArrays.join(",")); + +// A missing record key holds undefined; into a `string[]` slot the checked +// conversion throws the catchable TypeError Node raises at the first use. +type Pair = [string, string] | [string]; +const table: Record = { one: ["a", "b"] }; +function missingKey(): string[] { + const present: string[] = table["one"]!; + console.log("present", present.length, present.join("")); + try { + const absent: string[] = table["absent"]!; + console.log("no throw", absent.length); + } catch (error) { + console.log("threw", error instanceof TypeError, (error as Error).name); + } + return present; +} +console.log(missingKey().join("|")); + +// A narrowed single tuple keeps the existing tuple-to-array path. +const single: ["x", "y"] = ["x", "y"]; +const fromSingle: string[] = single; +console.log(fromSingle.length, fromSingle.join("")); + +export {}; diff --git a/tests/corpus/union-object-arms-to-string.ts b/tests/corpus/union-object-arms-to-string.ts new file mode 100644 index 000000000..bd798432f --- /dev/null +++ b/tests/corpus/union-object-arms-to-string.ts @@ -0,0 +1,33 @@ +// String conversions of unions whose object arms are URLs, typed arrays, +// dates and regular expressions: String(), template literals and `+` with +// a string convert each arm as it converts alone, as in Node. +const source = (n: number): URL | Uint8Array => + n > 0 ? new URL("https://cdn.dev/clip.mp3?v=2#t") : new Uint8Array([1, 2, 3]); +for (const n of [1, 0]) { + const v = source(n); + console.log(String(v), `${v}`, "source=" + v); +} + +const when = (n: number): Date | string => (n > 0 ? new Date(Date.UTC(2020, 0, 15, 12)) : "later"); +for (const n of [1, 0]) { + const v = when(n); + console.log(String(v).slice(0, 15), `${v}`.slice(0, 15), ("at " + v).slice(0, 10)); +} + +const pattern = (n: number): RegExp | number | undefined => (n > 1 ? /a+b/gi : n > 0 ? 42 : undefined); +for (const n of [2, 1, 0]) { + const v = pattern(n); + console.log(String(v), `${v}`, "p" + v); +} + +// A Buffer in a Uint8Array arm keeps its own toString. +const bytes = (n: number): Uint8Array | null => (n > 0 ? Buffer.from("hi") : null); +for (const n of [1, 0]) console.log(String(bytes(n)), `${bytes(n)}`, "b" + bytes(n)); + +// Narrowed by instanceof on a property of a parameter. +function describe(options: { source?: URL | Uint8Array }): string { + if (options.source instanceof Uint8Array) return "u8[" + options.source.length + "]"; + if (options.source instanceof URL) return "url " + String(options.source); + return "none"; +} +console.log(describe({ source: source(1) }), describe({ source: source(0) }), describe({})); diff --git a/tests/harness/corpus-costs/asserted-arm-probe.ts.json b/tests/harness/corpus-costs/asserted-arm-probe.ts.json new file mode 100644 index 000000000..4b6a7c664 --- /dev/null +++ b/tests/harness/corpus-costs/asserted-arm-probe.ts.json @@ -0,0 +1 @@ +{ "plain": 5031, "san": 5482 } diff --git a/tests/harness/corpus-costs/class-structural-anonymous-method.ts.json b/tests/harness/corpus-costs/class-structural-anonymous-method.ts.json new file mode 100644 index 000000000..dd683f139 --- /dev/null +++ b/tests/harness/corpus-costs/class-structural-anonymous-method.ts.json @@ -0,0 +1 @@ +{ "plain": 4280, "san": 73480 } diff --git a/tests/harness/corpus-costs/class-union-cycles.ts.json b/tests/harness/corpus-costs/class-union-cycles.ts.json new file mode 100644 index 000000000..0f58b9a89 --- /dev/null +++ b/tests/harness/corpus-costs/class-union-cycles.ts.json @@ -0,0 +1 @@ +{ "plain": 4300, "san": 6100 } diff --git a/tests/harness/corpus-costs/class-union-hierarchies.ts.json b/tests/harness/corpus-costs/class-union-hierarchies.ts.json new file mode 100644 index 000000000..d08b6810d --- /dev/null +++ b/tests/harness/corpus-costs/class-union-hierarchies.ts.json @@ -0,0 +1 @@ +{ "plain": 5600, "san": 7900 } diff --git a/tests/harness/corpus-costs/class-union-pointers.ts.json b/tests/harness/corpus-costs/class-union-pointers.ts.json new file mode 100644 index 000000000..9ed5a41d6 --- /dev/null +++ b/tests/harness/corpus-costs/class-union-pointers.ts.json @@ -0,0 +1 @@ +{ "plain": 8000, "san": 8200 } diff --git a/tests/harness/corpus-costs/class-union-program-lifetime.ts.json b/tests/harness/corpus-costs/class-union-program-lifetime.ts.json new file mode 100644 index 000000000..e3d3608e1 --- /dev/null +++ b/tests/harness/corpus-costs/class-union-program-lifetime.ts.json @@ -0,0 +1 @@ +{ "plain": 5300, "san": 9700 } diff --git a/tests/harness/corpus-costs/generic-member-constrained-lambda-impls.ts.json b/tests/harness/corpus-costs/generic-member-constrained-lambda-impls.ts.json new file mode 100644 index 000000000..fe5fca1ae --- /dev/null +++ b/tests/harness/corpus-costs/generic-member-constrained-lambda-impls.ts.json @@ -0,0 +1 @@ +{ "plain": 4100 } diff --git a/tests/harness/corpus-costs/never-returning-presence-guard.ts.json b/tests/harness/corpus-costs/never-returning-presence-guard.ts.json new file mode 100644 index 000000000..36119411d --- /dev/null +++ b/tests/harness/corpus-costs/never-returning-presence-guard.ts.json @@ -0,0 +1 @@ +{ "plain": 3500, "san": 2300 } diff --git a/tests/harness/corpus-costs/promise-union-widening.ts.json b/tests/harness/corpus-costs/promise-union-widening.ts.json new file mode 100644 index 000000000..8a4076da1 --- /dev/null +++ b/tests/harness/corpus-costs/promise-union-widening.ts.json @@ -0,0 +1 @@ +{ "plain": 7175, "san": 115266 } diff --git a/tests/harness/corpus-costs/record-ovf-union-arm.ts.json b/tests/harness/corpus-costs/record-ovf-union-arm.ts.json new file mode 100644 index 000000000..ee5a3fc43 --- /dev/null +++ b/tests/harness/corpus-costs/record-ovf-union-arm.ts.json @@ -0,0 +1 @@ +{ "plain": 4700, "san": 7300 } diff --git a/tests/harness/corpus-costs/tuple-union-to-array.ts.json b/tests/harness/corpus-costs/tuple-union-to-array.ts.json new file mode 100644 index 000000000..bfdf48480 --- /dev/null +++ b/tests/harness/corpus-costs/tuple-union-to-array.ts.json @@ -0,0 +1 @@ +{ "plain": 5369, "san": 3400 } diff --git a/tests/harness/corpus-costs/union-object-arms-to-string.ts.json b/tests/harness/corpus-costs/union-object-arms-to-string.ts.json new file mode 100644 index 000000000..4e5099abc --- /dev/null +++ b/tests/harness/corpus-costs/union-object-arms-to-string.ts.json @@ -0,0 +1 @@ +{ "plain": 1950, "san": 36300 } diff --git a/tests/harness/npm-suite.ts b/tests/harness/npm-suite.ts index 300d13e39..95b964377 100644 --- a/tests/harness/npm-suite.ts +++ b/tests/harness/npm-suite.ts @@ -37,7 +37,6 @@ const sanitize = process.env["SCRIPTC_SAN"] === "1"; type Known = { build: string } | { run: string; argvs?: string[][] } | { divergence: string }; const KNOWN: Record = { - "any-chaining": { build: "SC2003" }, "assert-shims": { run: "SC1043" }, "buffer-shims": { run: "SC2020" }, "bytes-exit": { build: "SC2004" },