pd-vm 0.22.2

RustScript bytecode compiler and VM
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
# Implementation Plan: Real Script Call Frames

## Goal

Introduce real runtime call frames for script-defined `fn` declarations so a function body is compiled once and invoked many times without duplicating its bytecode at each call site.

This plan is only for real script calls and frames.

Non-goals for this effort:

- First-class callable `Value`s
- `call_indirect`-style dynamic dispatch
- Returning/storing callables in arrays or maps
- Full closure/runtime environment objects in the first milestone
- Tail-call optimization

## Current State

Today the compiler and VM behave like this:

- Frontend IR keeps function definitions in `FunctionImpl`, plus `Expr::Call`, `Expr::FunctionRef`, and `Expr::LocalCall`.
- Codegen inlines any call whose target has a `FunctionImpl`.
- Final bytecode only emits `OpCode::Call` for builtins and host imports.
- The VM interprets `OpCode::Call` as builtin/host dispatch only.
- `OpCode::Ret` always halts the program; there is no intra-program return path.
- `Program` stores one code blob, one `local_count`, and one import table. It has no script-function table or entry-point metadata.
- Trace JIT and native AOT assume the same host-only call model.

This means:

- Repeated script calls duplicate the callee body in emitted bytecode.
- Recursive script functions are impossible under the current lowering.
- There is no real script stack frame, return address, or frame-local lifetime.

## Recommended End State

### Runtime model

Add real script frames while keeping host/builtin calls unchanged:

- Keep `OpCode::Call` for builtin/host import dispatch.
- Add a new `OpCode::CallScript` with operands `(script_function_id: u16, argc: u8)`.
- Reuse `OpCode::Ret`:
  - if no script caller exists, halt the program
  - otherwise pop one script frame, restore the caller frame, and continue at the saved return IP
- Add a VM-side `CallFrame` stack.

### Program model

Add a script-function table to `Program`:

```rust
pub struct ScriptFunction {
    pub decl_index: u16,
    pub name: String,
    pub arity: u8,
    pub entry_ip: u32,
    pub frame_local_count: u16,
    pub param_slots: Vec<u8>,
}
```

Recommended first version:

- Keep existing local-slot numbering from the compiler.
- Make `Ldloc`/`Stloc` frame-relative at runtime.
- For each separately emitted script function, set `frame_local_count` to `max(slot_in_footprint) + 1`.
- Do not attempt a dense per-function local remap in the first milestone.

That choice keeps the first implementation small because it does not require rewriting the current lifetime/coloring passes, which still operate on one global local-slot space.

### Code layout

Compile the root body first, then append separately emitted script function bodies to the same `Program.code` blob:

1. Root statements
2. Root `ret`
3. Function body A
4. Function `ret`
5. Function body B
6. Function `ret`
7. ...

Execution still starts at `ip = 0`. Appended function bodies are only reachable through `CallScript`.

## Scope Split

This feature is safest as a staged rollout.

### Phase 1 scope

Support real script frames for direct named functions that do not require runtime capture environments:

- Top-level functions
- Nested functions with `capture_copies.is_empty()`
- `LocalCall` sites whose callable is statically known to be one of the above functions

Keep current inline lowering for:

- Closures
- Capturing nested `fn` declarations
- Any callable path that still depends on declaration-time capture snapshots

This already delivers the main win: function bodies stop being duplicated in bytecode for the most common direct-call cases.

### Later scope

Extend real frames to capturing nested `fn` declarations by adding declaration environments. That is a second feature, not part of the first milestone.

## Design Details

### 1. Bytecode and metadata

Files:

- `src/bytecode.rs`
- `src/assembler.rs`
- `src/vmbc.rs`
- `src/vm/jit/aot.rs`

Changes:

- Add `ScriptFunction` metadata to `Program`.
- Add `OpCode::CallScript`.
- Update opcode encoding/decoding and assembler helpers.
- Update VMBC serialization/deserialization for the new function table.
- Bump the VMBC wire version.
- Bump `AOT_VERSION` if AOT bundles persist the expanded `Program` layout.

Recommendation:

- Keep `Program.imports` exactly for host imports.
- Add a separate `Program.script_functions`.
- Do not overload the existing import table to carry both concepts.

### 2. VM frame stack

Files:

- `src/vm/mod.rs`
- `src/vm/host.rs`

Add:

```rust
struct CallFrame {
    return_ip: usize,
    prev_frame_base: usize,
    prev_locals_len: usize,
    function_id: u16,
}
```

And VM state:

- `frame_base: usize`
- `call_frames: Vec<CallFrame>`

Execution model:

- `Ldloc index` reads `locals[frame_base + index]`
- `Stloc index` writes `locals[frame_base + index]`
- `CallScript`:
  - validate arity against `Program.script_functions`
  - save caller frame state
  - extend `locals` by `frame_local_count`, filled with `Null`
  - set `frame_base` to the new frame start
  - move/copy stack args into `param_slots`
  - jump `ip` to `entry_ip`
- `Ret`:
  - if `call_frames` is empty, halt
  - otherwise truncate locals to `prev_locals_len`, restore `frame_base`, set `ip = return_ip`

Notes:

- This integrates cleanly with the current `Value` stack model.
- Host/builtin calls still use the current stack-based ABI.
- `call_depth()` should be redefined to report total logical call depth, not only host-call nesting.

### 3. Compiler lowering

Files:

- `src/compiler/codegen.rs`
- `src/compiler/pipeline.rs`
- `src/compiler/mod.rs`

Add a new compiler path for separately emitted script functions:

- Partition `FunctionImpl`s into:
  - `script_callable`: emit once, invoked by `CallScript`
  - `inline_only`: keep current behavior
- `compile_function_call` becomes:
  - builtin/host import -> `Call`
  - script function in `script_callable` -> `CallScript`
  - script function in `inline_only` -> existing inline path
- After compiling root statements, emit all `script_callable` bodies once and record `entry_ip`

Important compiler choice:

- The first implementation should keep current slot numbers inside function bodies.
- Use `collect_function_frame_slots` to compute `frame_local_count`.
- Do not attempt to renumber locals per function yet.

That avoids touching:

- parser local numbering
- lifetime availability
- liveness/coloring
- most type-inference slot bookkeeping

### 4. Function entry type state

Separate function-body emission cannot depend on caller-time `type_state`.

Files:

- `src/compiler/typing.rs`
- `src/compiler/typing/context.rs`
- `src/compiler/typing/collect.rs`
- `src/compiler/codegen.rs`

Required addition:

- Record a function-entry typing snapshot for each separately emitted script function.

At minimum this snapshot must provide:

- known parameter types
- optional/schema state for parameter slots
- callable bindings that are valid at function entry

Reason:

- current codegen uses `type_state` when emitting operand type hints and some type-directed lowering decisions
- inline codegen gets this state from the caller naturally; separate function emission does not

If a full `LocalTypeState` snapshot per function is too expensive initially, a narrower `FunctionEntryState` is acceptable as long as it seeds all type-directed codegen paths that exist today.

### 5. Capturing nested functions

This is the main semantic trap. Current nested `fn` declarations capture outer locals at declaration time, and inline lowering materializes those capture copies into the flat local space.

Recommendation for the first milestone:

- do not move capturing `fn` declarations to `CallScript`
- keep them inline-only until a declaration-environment design is added

Recommended later design:

- add a per-activation declaration environment table, keyed by function declaration index
- executing `Stmt::FuncDecl` snapshots the captures into the current activation
- calling a capturing nested function loads its declaration environment into the callee frame before jumping

That later work is closure-adjacent, even if callables are still not first-class `Value`s.

## Backend Strategy

### Interpreter first

Land the interpreter and compiler support before touching the native backends.

### Trace JIT

Files:

- `src/vm/jit/trace.rs`
- `src/vm/jit/runtime.rs`
- `src/vm/jit/native/bridge.rs`
- `src/vm/jit/native/codegen.rs`

First cut:

- mark `CallScript` as unsupported in trace recording/codegen
- bail out to the interpreter when a trace would include `CallScript`

That keeps the feature deliverable without blocking on native frame support.

Later:

- teach `TraceStep` about script calls and returns
- add frame-stack aware native bridge/runtime handling

### Native AOT

First cut:

- either reject programs containing `CallScript` for native-only AOT
- or force interpreter fallback when script-call opcodes are present

Later:

- add script frame metadata to the AOT bundle
- teach generated native code how to enter/leave script frames

## Debugger and tooling

Files:

- `src/debug_info.rs`
- `src/debugger/mod.rs`
- `src/bin/pd-vm-run.rs`

Current debug info is flat and local-index based. Real frames need frame-aware introspection.

Recommended additions:

- function entry/range metadata in `DebugInfo`
- a VM API to inspect the script call stack
- debugger views that show locals for the current frame, not the entire flat local array

This does not need to block the first executable version, but it should be part of the same effort before calling the feature complete.

## Public API and compatibility

Expected breaking or additive changes:

- `Program` grows a `script_functions` table
- VMBC version bump
- possibly AOT version bump
- `CompiledProgram.functions` should stop meaning "all non-inlined declared functions"

Recommended compiler API cleanup:

- keep `CompiledProgram.functions` for host-visible imports only, or rename it
- if script function metadata is exposed publicly, return it in a separate field

This avoids breaking CLI/runtime code that currently treats `CompiledProgram.functions` as host bindings to register.

## Rollout Plan

### Milestone 1: metadata and interpreter semantics

- Add `CallScript`, `ScriptFunction`, and VM frame stack support
- Update `Ret` semantics
- Add direct unit tests for recursive and repeated script calls at the bytecode level
- Keep compiler unchanged except for a temporary bytecode constructor test path if needed

### Milestone 2: compiler emission for non-capturing functions

- Partition script-callable vs inline-only `FunctionImpl`s
- Emit root code plus appended function bodies
- Emit `CallScript` for direct and statically-known local calls to script-callable functions
- Preserve inline lowering for closures and capturing nested functions

### Milestone 3: typing and debug correctness

- Add function-entry type snapshots
- Restore operand type hints and any type-directed codegen inside separately emitted bodies
- Make debugger/frame inspection useful again

### Milestone 4: backend containment

- Make trace JIT and AOT explicitly reject or side-exit on `CallScript`
- Add tests to ensure no silent miscompile occurs when JIT/AOT sees the new opcode

### Milestone 5: capturing nested functions

- Add declaration environments
- Migrate capturing nested `fn` declarations off the inline path
- Revisit closure alignment after nested `fn` semantics are stable

## Test Matrix

Add tests for:

- repeated direct calls do not duplicate function bytecode bodies
- direct recursion works
- mutual recursion works for script-callable functions
- locals are isolated between caller and callee frames
- returned values still flow through the existing operand stack
- host calls inside a script-called function still work
- host `Yield` and `Pending` inside a script-called function preserve the script call stack correctly
- inline-only paths still behave exactly as before
- JIT/AOT reject or fall back cleanly when `CallScript` appears

## Risks

### Risk 1: capture semantics creep

Trying to support capturing nested functions in the first patch will turn this into a closure-runtime project. Avoid that.

### Risk 2: type-state regressions

Separately emitted function bodies no longer inherit caller context. If function-entry typing is not restored, operand type hints and type-directed lowering will silently degrade.

### Risk 3: debugger confusion

Flat local displays will become misleading once multiple frames exist.

### Risk 4: backend skew

If interpreter support lands without explicit JIT/AOT rejection, native paths may mis-handle the new opcode.

## Recommendation

Implement this as an interpreter-first, non-capturing-function-first change.

That gives the project:

- real script call frames
- recursive direct function support
- elimination of bytecode duplication for common direct-call cases

without forcing the much larger "callable as real runtime value" design or full closure environments into the same patch series.