Skip to content

Commit e052496

Browse files
committed
docs: add rss and vm call overhead plan
1 parent f678a1d commit e052496

2 files changed

Lines changed: 606 additions & 0 deletions

File tree

docs/blog/v07-why-rustscript.md

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
# Why We Introduced RustScript
2+
3+
*A Rust-flavored surface language that gives the compiler the right information — and gives AI the right syntax*
4+
5+
---
6+
7+
## The Starting Point
8+
9+
pd-vm already runs four source-language frontends on the same stack-based VM with the same bytecode. All four lower into a shared `FrontendIr` before compilation, and all four run on a runtime that uses no garbage collector and relies on deterministic ownership for memory management.
10+
11+
Given those other frontends, a fair question is: why add another one? What does a Rust-shaped scripting syntax buy a VM that already works?
12+
13+
The short answer is that RustScript exists because its syntax carries **intent that the compiler can act on**. Move-vs-copy distinctions, borrow annotations, and mutability declarations are not runtime features. They are compiler inputs. And they happen to be in a syntax that AI models already know well.
14+
15+
## Move and Copy: Telling the Compiler What You Mean
16+
17+
### The VM Does Not Have Destructive Loads
18+
19+
`ldloc` reads a local slot and places a clone of the value on the stack. For heap-backed values (`String`, `Array`, `Map`), that clone is a cheap shared-ownership bump (`Arc`), not a deep copy. The slot retains its value after the load.
20+
21+
This is important: **the VM instruction set is copy-by-default**. There is no special move opcode.
22+
23+
### Moves Are a Compiler Pattern, Not a VM Primitive
24+
25+
When the compiler decides a value should move rather than copy, it emits the pattern `ldloc; ldc Null; stloc` — load the value, then immediately null out the source slot. That gives the compiler precise control over when shared ownership ends and when backing memory becomes reclaimable, without adding any new opcodes or runtime machinery.
26+
27+
RustScript makes this compiler decision explicit in the source:
28+
29+
```rust
30+
let a = [1, 2, 3];
31+
let b = a; // move: `a` is consumed, slot is cleared
32+
// a is no longer available here
33+
```
34+
35+
The compiler sees the local-to-local rebind of a non-copyable value, lowers it as a consuming load (`MoveVar` in the IR), and emits the null-clear sequence. Using `a` after this point is a compile-time error, not a runtime surprise.
36+
37+
Compare the same logic in the JavaScript frontend:
38+
39+
```javascript
40+
let a = [1, 2, 3];
41+
let b = a; // copy: both `a` and `b` share the array
42+
```
43+
44+
Both programs compile to valid bytecode on the same VM. The difference is what the compiler knows at the point of the assignment. RustScript's syntax tells it "this is a transfer of ownership." The JS frontend's syntax tells it nothing, so it defaults to shared copy.
45+
46+
### `.copy()` and Borrows
47+
48+
When a RustScript user wants to keep the source alive, they say so:
49+
50+
```rust
51+
let a = [1, 2, 3];
52+
let b = a.copy(); // explicit clone: `a` stays available
53+
let c = &a; // borrow: non-consuming access
54+
```
55+
56+
`.copy()` emits a `ToOwned` node in the IR, which the codegen lowers to a deep clone. `&a` and `&mut a` emit `Borrow` / `BorrowMut` nodes, which currently collapse to non-consuming access forms but participate in the availability analysis — the compiler tracks that `a` is borrowed and rejects mutations that would invalidate the alias.
57+
58+
None of these constructs change the VM. They change what the compiler does before code reaches the VM.
59+
60+
## How the Compiler Uses This Information
61+
62+
### The Availability Pass
63+
64+
The core of RustScript's value is the availability analysis in `availability.rs`. This pass walks the IR with a `FlowState` that tracks, for every local slot, whether it is:
65+
66+
- **Definitely available** on every control-flow path
67+
- **Possibly available** on some path but not all
68+
- **Moved** (whole-local or per-field)
69+
- **Aliased** to another collection local
70+
71+
When `enable_local_move_semantics` is active (RustScript only), the pass enforces stricter rules:
72+
73+
| Situation | RustScript behavior | JS / Lua / Scheme behavior |
74+
|---|---|---|
75+
| Local-to-local rebind of a collection | Move source, reject reuse | Copy (shared ownership) |
76+
| Local read after move | Compile error | N/A (no moves) |
77+
| Field read from a struct-like map | Move by default, clear field | Copy |
78+
| Closure capture of a local | Follows expression semantics (`x` moves, `x.copy()` copies, `&x` borrows) | Always copy |
79+
| Mutation while an alias exists | Rejected unless detached via `.copy()` | Allowed (runtime CoW) |
80+
81+
### Consumed-Parameter Inference
82+
83+
RustScript function calls apply consumed-parameter inference at call sites. If the compiler can determine — by analyzing the function body — that a parameter is consumed (moved into a return value, or rebind-moved), it marks that parameter position as consuming. Callers that reuse the argument local after the call get a compile error.
84+
85+
This is entirely a compiler-side analysis. The runtime function-call contract does not change.
86+
87+
### Liveness and Dead-Local Clearing
88+
89+
After availability, a liveness rewriter pass inserts `Drop` statements to null out dead locals as early as possible. This matters for the GC-free runtime: it turns compiler-known variable lifetimes into runtime memory reclamation points without a collector.
90+
91+
RustScript's ownership annotations make liveness analysis more precise because the compiler knows which reads are consuming and which are not.
92+
93+
## Closure Captures: Copy, Borrow, Move
94+
95+
pd-vm closures capture values into hidden local slots at the time the closure is created. The source-level capture expression determines the `CaptureBindingMode`:
96+
97+
```rust
98+
let data = [1, 2, 3];
99+
let f = |x| data.len() + x; // `data` is moved into the closure
100+
// data is no longer available here
101+
102+
let shared = [4, 5, 6];
103+
let g = |x| shared.copy().len() + x; // `shared` is copied
104+
// shared is still available
105+
```
106+
107+
The capture binding modes are:
108+
109+
| Mode | Source syntax | Effect on outer scope |
110+
|---|---|---|
111+
| **Copy** | `x.copy()` in capture position | Outer local stays available |
112+
| **Borrow** | `&x` in capture position | Non-consuming; alias tracked |
113+
| **BorrowMut** | `&mut x` in capture position | Non-consuming; mutation tracked |
114+
| **Move** | `x` (default for non-copyable) | Outer local consumed |
115+
116+
In the JS, Lua, and Scheme frontends, all captures degrade to `Copy` mode. The capture slot still gets a value, but the outer scope keeps its own copy unconditionally.
117+
118+
## Type Inference and the Ownership Surface
119+
120+
RustScript's type rules interact with its ownership model:
121+
122+
- `let` bindings are immutable by default; `let mut` is required for reassignment.
123+
- `if`-expression branches with conflicting concrete types are rejected at compile time.
124+
- Optional chaining (`a?.b?.c`) requires the container to come from a user-declared schema, and the result stays optional until handled with `.unwrap_or(...)`, a `!= null` refinement, or a `match Some(name)` arm.
125+
- `+` with a known-string operand infers string concatenation and treats local reads as copy-like to preserve ergonomics.
126+
127+
These are all DX-layer decisions. They catch mistakes before bytecode is generated without adding runtime cost.
128+
129+
## The AI Angle: Syntax Proximity to Rust
130+
131+
There is a practical benefit that was not part of the original design but became increasingly clear as the project grew: **AI code-generation models produce better RustScript than they produce JavaScript or Lua for this VM**.
132+
133+
The reason is corpus proximity. Large language models trained on significant amounts of Rust code have internalized Rust's ownership patterns, borrow semantics, and idiomatic structures. When asked to write RustScript for pd-vm — which lives in the same repository as the Rust runtime — the model:
134+
135+
1. **Naturally uses ownership-aware patterns.** It writes `let b = a;` as a move and `let b = a.copy();` when it needs the source to survive. It does not try to share mutable state through aliasing.
136+
2. **Avoids GC-dependent patterns.** Rust-trained models do not reach for garbage-collected idioms like long-lived closures over mutable state or circular references. Those patterns would be valid in the JS frontend but counterproductive for a GC-free runtime.
137+
3. **Matches the surrounding code style.** The pd-vm runtime is Rust. The compiler is Rust. The test harness is Rust. When the scripting language is also Rust-shaped, the model does not need to context-switch between two different programming idioms within the same codebase.
138+
4. **Produces more accurate type annotations.** Because the model knows Rust's type annotation syntax, it generates valid RustScript type hints that feed into the compiler's inference pipeline rather than leaving locals at `Unknown`.
139+
140+
This is not a theoretical benefit. In practice, AI-assisted development within the pd-vm repository naturally converges on RustScript because the model sees Rust patterns in scope and extends them into the scripting layer.
141+
142+
## Where This Falls Short
143+
144+
- **The ownership model is a DX layer, not a safety guarantee.** There are no lifetime parameters, no generic constraints, and no deep borrow tracking into nested data structures. Users expecting Rust-grade safety will find this is closer to TypeScript's relationship with JavaScript: useful lints, not formal verification.
145+
- **Functions are currently inlined, not first-class.** RustScript inherits the VM's current limitation: no recursive calls, no storing functions in collections. This restricts what users can write regardless of how good the ownership tracking is.
146+
- **The split identity requires clear documentation.** Users need to understand that RustScript's syntax is Rust-like but its semantics are "ownership-informed scripting." The compile-time checks catch the common mistakes — use-after-move, mutation through aliases, uninitialized access — but they are not exhaustive.
147+
148+
## Conclusion
149+
150+
RustScript exists because the VM's GC-free, ownership-based runtime model benefits from a surface language that makes ownership decisions visible to the compiler. The VM does not care — `ldloc` copies, `stloc` overwrites, and the instruction set stays simple. But the compiler cares deeply, and RustScript gives it the right inputs.
151+
152+
The AI affinity is a bonus that compounds over time. As more of the project is developed with LLM assistance, having a scripting language whose syntax matches the surrounding Rust codebase reduces friction and improves output quality at the same time.
153+
154+
## Related
155+
156+
- [Why pd-vm Uses a Stack + Local-Slots Architecture](./v01-why-stack-and-local-slots.md)
157+
- [GC-Free Scripting](./v04-gc-free-memory.md)
158+
- [Why We Didn't Choose a Functional Language](./v03-why-not-functional.md)
159+
- [Async Suspension Without Coroutines](./v05-async-suspension.md)

0 commit comments

Comments
 (0)