Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion docs/memory-models.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,8 @@ includes an object that is:

- returned, thrown, stored in a field, array, global or captured variable, or converted to
`any`, an interface or a union;
- passed to a function in another module, including the default library;
- passed to a function in another module, unless that module's own analysis showed the function
only reads the argument (see below);
- made in a function that is `async`, a generator, or recursive, or - under `-mm=rc` and
`-mm=own` - one that has a `try`;
- still in use when the next iteration of its loop makes a new one. A use on the way out of the
Expand All @@ -137,6 +138,20 @@ includes an object that is:
Under `-mm=rc` and `-mm=own`, an object that owns memory stays on the heap in a module that uses
`Shared<T>`, because `Shared.count` would see its contents given back later.

### Calls into other modules

Every module the compiler builds ahead of time records, inside its object file, which parameters of its functions
are only read and never kept (the `__tsborrow_` symbol). A program reads these facts from the
libraries it links statically - the default library's static library and any `--lib` - and, under
`--emit=jit`, from the default library's DLL it loads. An object passed to such a parameter may then
go on the stack: `Math.max(a, b, c)`, for instance, builds its argument array on the stack.

The facts are read only where the file read is the file that will run. A program linked against a
DLL, a `-shared` import and `--emit=obj` read none, and every call into them stays on the heap, as
does a call through a library class's virtual method. A library built before this feature publishes
nothing, which is not an error. `tslang --print-borrow-facts=<file>` prints what an object file,
static library or DLL publishes.

Two limits keep frames small: one object may take at most 256 bytes
(`--stack-promotion-max-object=`), and one function at most 4096 bytes in all
(`--stack-promotion-max-frame=`). These are estimates, made before the exact layout is known.
Expand Down

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
# Borrow facts across modules

Status: implemented 2026-10-18 (plan docs/superpowers/plans/2026-10-18-borrow-facts-across-modules.md). Follows
`2026-10-18-stack-promotion-design.md` (sections 8.1 and 8.2) and its v1 and v1.1 implementations
(PRs #605, #606).

## 1. Intent

Stack promotion treats any call into another module as an escape: the analysis has no body to
look at, so it cannot know the callee does not keep its argument. This adds the plumbing for one
module to tell another which parameters of its exported functions are only borrowed. The default
library is the first library that publishes these facts and the first one programs read them from.

The user's stated purpose is the plumbing itself. The gain from the default library alone is
expected to be modest: its non-generic API mostly takes numbers and strings (§4.2). The same
mechanism is what caller-provided result slots (project "a", next) and `-shared` modules (later)
will build on.

## 2. Decisions (the user's)

- **Scope of version 1: calls from a program into the default library.**
- **Facts are generated, never hand-written.** When a library is built, the compiler records, for
every exported function, which parameters its body only borrows. It derives them from the same
escape analysis stack promotion runs. There is no `@borrows` attribute and no hand-written claim:
a wrong claim is a dangling stack pointer.
- **Facts are stored inside the library binary** as exported symbols, beside the existing
`__tsmm_` and `__tsown_` markers, so they can never disagree with the code they describe.
- **The compiler reads the default library binary on every compile**, AOT and JIT alike.
- **Version 1 may be almost pure plumbing.** A small gain from the default library alone (§4.2)
is acceptable; changing the library or the analysis so more of its calls benefit is not part of
version 1.
- **Platforms: Windows, Linux and the JIT.** Linux ahead-of-time builds read the facts from the
static archive (`.a`) they link, so `Dump` needs ELF and `ar` support beside its PE support.
- **Facts are read only for static links and the JIT, never for DLL links.** A program linked
against the default library's DLL (or `.so`) loads whatever copy sits beside it at run time,
which may not be the copy read at compile time. Such programs get no facts: every call into the
library stays an escape, as today. Under the JIT the library read is the library loaded.

## 3. Goals, non-goals, anti-goals

Goals
- A program compiled against a default library built with this change promotes objects passed to
default-library functions whose parameter is only borrowed.
- The facts survive any rebuild of the library: they are regenerated with it.

Non-goals
- Hand-written facts, for C functions or anything else.
- Facts about results (a result that borrows an argument, or a fresh result): that is project (a).
- Changing what the default library's API looks like.

Anti-goals (failure even if it works)
- Any fact that claims a parameter is borrowed when the body may keep it.
- Facts read from a binary other than the one the program is linked against.
- A compile that fails, or warns, because the facts are missing or unreadable. Missing facts mean
"escape", exactly as today.

## 4. Feasibility notes

### 4.1 What exists

- `StackEscapeFacts` (`tslang/lib/TypeScript/StackEscapeFacts.h`) already computes, for every
function in a module, whether each parameter may escape (`paramEscapes`, a module fixpoint). An
unknown callee is an escape; a virtual call is trusted only when every vtable holding that slot
is local to the module.
- A library already exports text facts for importers: own's `__tsown_<file>_<hash>` symbol (a C
string, one name per line), emitted by `OwnershipSignaturePass` (`SHARED_LIB_OWN_FACTS`,
`Defines.h`). It is read by `MLIRGenModule.cpp` when importing a shared library: the symbol list
comes from `Dump::getSymbols`, the text from `Dump::readExportedCString` (or from the loaded
library under the JIT), and `addImportedOwnNoDrops` merges it into a module attribute.
- The default library reaches a program as hand-written declaration files (`lib.d.ts` and the files
it references, in TypeScriptCompilerDefaultLib/src), and as a per-model binary under
`{lib,dll}/<arch>/<vendor>/<os>/<env>/<mode>/<mm>` (`tslang --print-default-lib-dir`). Generic
code (`lib.generics.ts`) is compiled into the program with its bodies, so it needs no facts.

### 4.2 What the default library's API can actually gain

Most non-generic default-library functions take numbers and strings, and strings are not
promotion candidates. The arguments that could move to the stack are `RegExp` objects
(`s.replace(new RegExp(...), x)`), rest-parameter arrays built at the call site
(`concat(...other: string[])`; promotable under gc/none only, since the elements are strings), and
small option objects (`toLocaleString(locale, { timeZone })`).

## 5. Design

### 5.1 Producing the facts (library side)

When a module is built as a library (`--emit=dll`, `--emit=lib`, and the object files the default
library build links), the compiler emits one exported symbol per module, in the style of
`__tsown_`. Suggested name: `__tsborrow_<file>_<hash>`, using the same suffix the other markers use.
Its value is a C string with one line per exported function that has at least one borrowed
parameter: the function's symbol name, then the indexes of its borrowed parameters (`this` is
parameter 0 of a method). The exact text format is the builder's call.

- The facts come from `StackEscapeFacts` run over the library module, at the same point stack
promotion runs. They are emitted whether or not `--no-stack-promotion` is given: the flag decides
what this module promotes, not what it promises importers.
- **Not tied to the `__tsmm_` marker (my call).** The default library's two builds emit no marker
today: the DLL uses `--embed-declarations=false`, and the archive's object uses `--export=none`
(DefaultLib `scripts/build_core.bat`). So the facts symbol is emitted on its own, for every
function that is not private, whatever the export settings.
- **In a library build every class counts as exported (my call).** An importer can subclass any of
them, even under `--export=none`, so the library's own analysis never trusts a virtual call
through one of its classes' vtables. Otherwise a program subclass passed into the library could
break a fact.
- Only functions that are not private get facts. A parameter is listed only when `paramEscapes` says it does
not escape. Everything unlisted is an escape to the reader.
- A method of an exported class is listed under its own symbol. Whether a virtual call to it may be
trusted is the reader's question (§5.3).

### 5.2 Reading the facts (program side)

When a program is compiled with the default library, the compiler opens the binary it will link:
the DLL under the JIT, and the static library it links ahead of time. An ahead-of-time link
against the DLL reads nothing (§2). It reads every
`__tsborrow_` symbol and records the facts on the module, as `addImportedOwnNoDrops` does for own,
for `StackEscapeFacts` to read.

- **Same binary.** The facts must come from the exact file the link uses. If the AOT link takes a
static archive, the archive is read: `Dump` gains whatever archive support that needs. Reading
the DLL beside it instead is not allowed, since the two could come from different builds.
- **Missing or unreadable facts are not an error.** No symbol, an old library, a file that cannot
be read: no facts, every call an escape, as today. No warning.
- **JIT cache.** The facts read become part of what a JIT cache entry depends on, like the default
library path already is (`jitcache.cpp`). Otherwise a rebuilt library would leave stale
promotions in cached objects.

### 5.3 Using the facts

In `StackEscapeFacts`:
- **A direct call** (`ts.SymbolCallInternal`) to a declared function (no body) whose symbol has an
imported fact: the argument at a listed index does not escape through the call. Today
`argumentEscapes` returns `unknown-callee` there.
- **A call through a `ts.SymbolRef`** of such a function: the same.
- **A virtual call on a library class stays an escape in version 1 (my call).** A static
library or an object file given to the link can subclass a library class, and MLIRGen cannot see
those inputs. Trusting such calls needs a way to detect them, which is left for later.
- Everything else is unchanged: an unlisted parameter, an unknown function, a call through a value.

### 5.3a When no facts are read (my call)

`--emit=obj`, `--emit=llvm` and `--emit=bc` do not link, so the compiler cannot know which library
the object will be linked with (the CMake template and the default library's own build work this
way). They read no facts. Facts are read only when tslang itself links or loads the library. The
choice of file - lib or dll, debug or release, Android - is made today in `exe.cpp` and `jit.cpp`,
after MLIRGen. The builder moves that choice to a function MLIRGen can call, so both read the same
answer.

### 5.4 Static libraries given with `--lib`, never `-shared` imports (my call for `--lib`)

A `-shared` import is a DLL link and reads no facts (§2, the user's rule).

The reader also runs on every static library given to an `--emit=exe` link with `--lib`: the file
read is the file linked, which is the same guarantee the default library's archive gives. This is
how the tests get a library whose facts they control. The runner builds every test with
`--no-default-lib`, so a default-library-only reader could not be tested in the ordinary suite. A
test builds a small library with `--emit=lib`, then compiles a program against it with
`--emit=exe --lib=<it>`, declaring the library's functions with `declare`.

## 6. Soundness

- A fact is a property of the library's body, computed by the analysis that already promotes
objects inside that library. It needs no closed world. If any caller anywhere passes a value the
body keeps, the parameter is already an escape.
- Inside the library, calls through function values, unknown callees and exported classes' virtual
methods are already escapes, so a library function that hands its argument to a user callback
never claims to borrow it.
- The only new trust is that the binary read is the binary linked (§5.2).

## 7. Testing

- **Producer:** a library module with functions that borrow, keep, return and hand to a callback
their parameters. Check its emitted symbol text, by reading the built binary or by a dump flag of
the builder's choosing. Every case gets a positive and a negative.
- **Consumer:** a program linking that library with `--emit=exe --lib=`, with a decision test per
case (the remark is printed during the `--emit=exe` compile): the
`--stack-promotion-report` remark says `stack` for a borrowed argument and `heap: argument` or
`heap: unknown-callee` otherwise. Run tests under gc, rc, own and none.
- **Stale or missing facts:** a program against a library built without facts (an old build, or
the symbol stripped) still compiles and promotes nothing through those calls.
- **No facts from DLLs:** the same library built with `--emit=dll` and imported as `-shared`: every
call stays `heap`. Likewise `--emit=obj` of the program reads nothing.
- **Virtual-call rule:** a library class with a virtual method; one program linking only that
library, and one also linking a second library that subclasses it. Expected: stack in the
first, heap in the second.
- **Default library end to end:** a program using `new RegExp` with `replace`, compiled with the
default library (outside the runner's `--no-default-lib` suite, as the default-library tests
already are). Check the remark and the output.
- **Teeth:** make the producer list every parameter, and confirm the keeper test then reads a
dangling pointer.

## 8. Left to the builder

- The symbol's name and text format.
- How `Dump` reads a static archive.
- Where in the pipeline the facts are emitted (the stack promotion pass, or a pass of its own
sharing `StackEscapeFacts`).
- The JIT cache key detail.
- The default-library build changes, if its build does not already export the new symbol.
- Phasing into PRs, including the DefaultLib repository.
6 changes: 6 additions & 0 deletions tslang/include/TypeScript/DataStructs.h
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,12 @@ struct CompileOptions
// shared libraries imported, whose declarations the module was generated against.
std::vector<std::string> sourceImports;
std::vector<std::string> sharedLibraryImports;
// The borrow facts (__tsborrow_ texts, concatenated) of the libraries this program is linked
// statically against or, under the JIT, loads: see
// docs/superpowers/specs/2026-10-18-borrow-facts-across-modules-design.md. Empty: none.
std::string importedBorrowFacts;
// the files they were read from: a JIT cache entry depends on them
std::vector<std::string> importedBorrowFactsFiles;
// an import led back to a file still being generated: the modules in the cycle cannot be
// compiled one by one (the JIT cache then compiles the program as one module)
bool importCycle = false;
Expand Down
7 changes: 7 additions & 0 deletions tslang/include/TypeScript/Defines.h
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,13 @@
// under own report more, while the facts a callee's body relies on cannot be seen by an importer
// that re-parses source, and would free twice there.
#define SHARED_LIB_OWN_FACTS "__tsown_"
// Which parameters of a module's functions their bodies only borrow, for stack promotion in a
// program linked statically against it: `__tsborrow_<file>_<hash>`, a NUL-terminated char array
// (not a pointer, so it can be read from an object file without relocations), one line per
// function: `<symbol> <index>...`. See docs/superpowers/specs/2026-10-18-borrow-facts-across-modules-design.md.
#define SHARED_LIB_BORROW_FACTS "__tsborrow_"
// [symbol name, text]: set by BorrowFactsExportPass, made into the symbol by the LLVM lowering
#define SHARED_LIB_BORROW_FACTS_ATTR_NAME "ts.borrow_facts"
// a module attribute, so it has to carry the dialect's prefix
#define SHARED_LIB_OWN_NO_DROPS_ATTR_NAME "ts.own_imported_no_drops"

Expand Down
4 changes: 4 additions & 0 deletions tslang/include/TypeScript/MLIRGen.h
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ class StringRef;
namespace typescript
{

// `<stem>_<hash of the canonical path>`: the suffix of a module's exported marker symbols
// (`__tsmm_`, `__tsown_`, `__tsborrow_`), unique per source file.
::std::string moduleSymbolSuffix(llvm::StringRef filePath);

::std::string dumpFromSource(const llvm::StringRef &, const llvm::StringRef &);

mlir::OwningOpRef<mlir::ModuleOp> mlirGenFromMainSource(
Expand Down
12 changes: 12 additions & 0 deletions tslang/include/TypeScript/ObjDumper.h
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,18 @@ namespace Dump
// architecture, which this process cannot load. std::nullopt when the file is not a PE image,
// does not export `symbol`, or the pointer or string lies outside the file's data.
std::optional<std::string> readExportedCString(llvm::StringRef path, llvm::StringRef symbol);

// Every defined symbol starting with `prefix` in an object file (COFF or ELF), in the object
// members of a static library, among a DLL's exports or an ELF shared object's symbols, with the
// NUL-terminated char array it names: (symbol, text). Read from the file, nothing loaded.
// Members that are not objects (import members, bitcode) and an unreadable file give nothing.
std::vector<std::pair<std::string, std::string>> readCStringSymbols(llvm::StringRef path,
llvm::StringRef prefix);

// The global symbols a link input defines: a static library's symbol table, an object file's
// defined globals, a DLL's exports, a shared object's defined dynamic symbols. Empty for a file
// that cannot be read.
std::vector<std::string> definedSymbols(llvm::StringRef path);
}

std::unique_ptr<Dumper> createCOFFDumper(const COFFObjectFile &);
Expand Down
5 changes: 5 additions & 0 deletions tslang/include/TypeScript/Passes.h
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,11 @@ struct StackPromotionLimits
/// model. Runs on the whole module at the affine level, after own's inference.
std::unique_ptr<mlir::Pass> createStackPromotionPass(CompileOptions &, StackPromotionLimits);

/// Writes the module's borrow facts - which parameters of its functions their bodies only borrow -
/// into the exported char array `symbolName` (`__tsborrow_<file>_<hash>`), for programs linked
/// statically against it. See docs/superpowers/specs/2026-10-18-borrow-facts-across-modules-design.md.
std::unique_ptr<mlir::Pass> createBorrowFactsExportPass(std::string symbolName);

/// GC Pass to replace malloc, realloc, free with GC_malloc, GC_realloc, GC_free
std::unique_ptr<mlir::Pass> createGCPass(CompileOptions&);
std::unique_ptr<mlir::Pass> createProcessHeapPass();
Expand Down
Loading
Loading