Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ Read only what is relevant to the task.
| [docs/architecture.md](docs/architecture.md) | tracing execution flow, debugging across packages |
| [docs/value-representation.md](docs/value-representation.md) | modifying boxed values, JIT value passing |
| [docs/memory-model.md](docs/memory-model.md) | touching refs, closures, GC, host functions |
| [docs/profile.md](docs/profile.md) | modifying profiling, tick cadence, or JIT profile guidance |
| [docs/instruction-set.md](docs/instruction-set.md) | adding or debugging opcodes |
| [docs/jit-internals.md](docs/jit-internals.md) | modifying threaded/JIT compilation |
| [docs/pass-system.md](docs/pass-system.md) | adding optimization or analysis passes |
Expand Down Expand Up @@ -91,7 +92,7 @@ Interpreter.Run()

Hot-segment compilation:

* profile samples record `(function, ip)` every 128 executed instructions
* profile samples record `(function, ip, opcode)` every 128 executed instructions
* JIT threshold defaults to 4096 ticks, i.e. 32 profile samples
* compiled native handlers replace threaded closures in-place

Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,9 @@ vm := interp.New(prog,

`WithTick` controls profiling samples, context-cancellation polling, hook cadence, and fuel consumption. `WithFuel` accepts an expected instruction budget and rounds it up to the nearest tick interval internally; `WithFuel(0)` is unlimited. Hooks run synchronously on the `Run` goroutine and receive the live interpreter; avoid concurrent interpreter access and preserve VM invariants when mutating state.

For profile snapshots, JIT counters, and REPL `.profile` output, see
[`docs/profile.md`](docs/profile.md).

---

## Status
Expand Down
4 changes: 4 additions & 0 deletions asm/buffer.go
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,10 @@ func (c *Chunk) Ptr() unsafe.Pointer {
return unsafe.Pointer(&c.buf.mem[c.offset])
}

func (c *Chunk) Size() int {
return c.size
}

func (b *Buffer) grow(s int) error {
size := len(b.mem) * 2
if size < s {
Expand Down
88 changes: 86 additions & 2 deletions cmd/repl/repl.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (

"github.com/siyul-park/minivm/instr"
"github.com/siyul-park/minivm/interp"
"github.com/siyul-park/minivm/prof"
"github.com/siyul-park/minivm/program"
"github.com/siyul-park/minivm/types"
)
Expand Down Expand Up @@ -37,6 +38,7 @@ Commands:
e.g. struct {i32; f64}
[]i32
.show show disassembly of accumulated program
.profile profile accumulated program
.reset clear all accumulated instructions, stack, constants, and types
.help show this help
.quit / .exit exit the REPL
Expand Down Expand Up @@ -74,7 +76,7 @@ func (r *REPL) Run(ctx context.Context) error {
}

if strings.HasPrefix(line, ".") {
done, err := r.command(scanner, line)
done, err := r.command(ctx, scanner, line)
if err != nil {
return err
}
Expand All @@ -101,7 +103,7 @@ func (r *REPL) Run(ctx context.Context) error {
}
}

func (r *REPL) command(scanner *bufio.Scanner, line string) (bool, error) {
func (r *REPL) command(ctx context.Context, scanner *bufio.Scanner, line string) (bool, error) {
switch strings.ToLower(line) {
case ".quit", ".exit":
fmt.Fprintln(r.out, "bye")
Expand All @@ -110,6 +112,10 @@ func (r *REPL) command(scanner *bufio.Scanner, line string) (bool, error) {
r.reset()
case ".show":
r.show()
case ".profile":
if err := r.profile(ctx); err != nil {
r.printErr(err)
}
case ".help":
fmt.Fprint(r.out, helpText)
case ".const":
Expand Down Expand Up @@ -212,6 +218,23 @@ func (r *REPL) show() {
fmt.Fprint(r.out, r.build().String())
}

func (r *REPL) profile(ctx context.Context) error {
if len(r.instrs) == 0 {
fmt.Fprintln(r.out, "(empty)")
return nil
}

p := prof.New()
vm := interp.New(r.build(), interp.WithProfile(p), interp.WithTick(1))
defer vm.Close()
if err := vm.Run(ctx); err != nil {
return err
}

printProfile(r.out, p.Snapshot())
return nil
}

func (r *REPL) build(extra ...instr.Instruction) *program.Program {
return program.New(
append(r.instrs, extra...),
Expand Down Expand Up @@ -250,6 +273,67 @@ func printStack(out io.Writer, vm *interp.Interpreter) {
fmt.Fprintln(out, strings.Join(parts, " "))
}

func printProfile(out io.Writer, snap prof.Snapshot) {
fmt.Fprintf(out, "profile samples: %d\n", snap.Samples)
if len(snap.Funcs) > 0 {
fmt.Fprintln(out, "functions:")
fmt.Fprintln(out, "func\tsamples\t%")
for _, fn := range snap.Funcs {
if fn.Samples == 0 {
continue
}
fmt.Fprintf(out, "%d\t%d\t%s\n", fn.Index, fn.Samples, formatPercent(fn.Percent))
}
}
for _, fn := range snap.Funcs {
if len(fn.IPs) == 0 {
continue
}
fmt.Fprintf(out, "func %d ips:\n", fn.Index)
fmt.Fprintln(out, "ip\tsamples\t%")
for _, ip := range fn.IPs {
fmt.Fprintf(out, "%04d\t%d\t%s\n", ip.Offset, ip.Samples, formatPercent(ip.Percent))
}
}
if len(snap.Opcodes) > 0 {
fmt.Fprintln(out, "opcodes:")
fmt.Fprintln(out, "opcode\tsamples\t%")
for _, op := range snap.Opcodes {
fmt.Fprintf(out, "%s\t%d\t%s\n", opcodeLabel(op.Code), op.Samples, formatPercent(op.Percent))
}
}
if hasJIT(snap.JIT) {
jit := snap.JIT
fmt.Fprintln(out, "jit:")
fmt.Fprintln(out, "attempts\temits\tlinks\tskips\taborts\terrors\tbytes\ttime")
fmt.Fprintf(out, "%d\t%d\t%d\t%d\t%d\t%d\t%d\t%s\n",
jit.Attempts, jit.Emits, jit.Links, jit.Skips, jit.Aborts, jit.Errors, jit.Bytes, jit.Time)
}
}

func formatPercent(v float64) string {
return fmt.Sprintf("%.1f%%", v)
}

func opcodeLabel(code byte) string {
op := instr.Opcode(code)
if typ := instr.TypeOf(op); typ.Mnemonic != "" {
return typ.Mnemonic
}
return fmt.Sprintf("0x%02X", code)
}

func hasJIT(jit prof.JIT) bool {
return jit.Attempts != 0 ||
jit.Emits != 0 ||
jit.Links != 0 ||
jit.Skips != 0 ||
jit.Aborts != 0 ||
jit.Errors != 0 ||
jit.Bytes != 0 ||
jit.Time != 0
}

// format resolves KindRef through the heap (shows actual object, not raw index),
// truncates multi-line values to the first line, and adds type suffixes to i64/f32/f64.
func format(v types.Boxed, vm *interp.Interpreter) string {
Expand Down
39 changes: 38 additions & 1 deletion cmd/repl/repl_test.go
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
package repl

import (
"bufio"
"bytes"
"context"
"fmt"
"strings"
"testing"

"github.com/siyul-park/minivm/instr"
"github.com/stretchr/testify/require"
)

Expand Down Expand Up @@ -56,7 +58,24 @@ func TestREPL_Run(t *testing.T) {
},
{
input: ".help\n.quit\n",
contains: []string{".quit", ".reset"},
contains: []string{".quit", ".reset", ".profile"},
},
{
input: ".profile\n.quit\n",
contains: []string{"(empty)"},
},
{
input: "i32.const 7\ndrop\n.profile\n.quit\n",
contains: []string{
"profile samples: 2",
"functions:",
"func 0 ips:",
"0000\t1\t50.0%",
"0005\t1\t50.0%",
"opcodes:",
"i32.const\t1\t50.0%",
"drop\t1\t50.0%",
},
},
{
input: "i32.const 42\n.reset\n.show\n.quit\n",
Expand Down Expand Up @@ -235,4 +254,22 @@ func TestREPL_Run(t *testing.T) {
}
require.Equal(t, []string{"10", "10 20"}, valLines)
})

t.Run("profile does not mutate history", func(t *testing.T) {
var out bytes.Buffer
r := New(strings.NewReader("i32.const 1\n.profile\n.quit\n"), &out)
require.NoError(t, r.Run(context.Background()))
require.Len(t, r.instrs, 1)
require.Equal(t, 5, r.codeLen)
})

t.Run("profile command renders runtime errors", func(t *testing.T) {
var out bytes.Buffer
r := New(strings.NewReader(""), &out)
r.instrs = []instr.Instruction{instr.New(instr.DROP)}
done, err := r.command(context.Background(), bufio.NewScanner(strings.NewReader("")), ".profile")
require.NoError(t, err)
require.False(t, done)
require.Contains(t, out.String(), "error: stack underflow")
})
}
13 changes: 7 additions & 6 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ Read this when a change crosses package boundaries or you need to know where sta
| If you touch | Also read |
| --- | --- |
| `interp/` runtime state, frames, globals | [memory-model.md](memory-model.md), [value-representation.md](value-representation.md) |
| `prof/` or profile options | [profile.md](profile.md) |
| `interp/threaded.go` or `interp/jit*.go` | [jit-internals.md](jit-internals.md), [instruction-set.md](instruction-set.md) |
| `analysis/`, `transform/`, `optimize/`, `pass/` | [pass-system.md](pass-system.md) |
| `cmd/repl/` or `cmd/minivm/` | [guides/repl.md](guides/repl.md) |
Expand Down Expand Up @@ -105,7 +106,7 @@ The interpreter owns all runtime state in `Interpreter`:
|---|---|
| `instrs [][]byte` | raw bytecode per function slot |
| `code [][]func(*Interpreter)` | threaded closures per function slot |
| `prof *prof.Stats` | aggregate and per-IP execution samples |
| `prof *prof.Stats` | aggregate function, IP, opcode, and JIT profile samples |
| `frames []frame` | call stack (addr, ip, bp) |
| `stack []Boxed` | value stack |
| `heap []Value` | flat heap array |
Expand All @@ -116,7 +117,7 @@ The interpreter owns all runtime state in `Interpreter`:

**`threadedCompiler`** (in `threaded.go`): A `[256]func` table populated in `init()`. Each entry is a compile-time function that reads operands from `c.code[c.ip+N:]`, advances `c.ip`, and returns a runtime closure. The closure captures compile-time constants and advances `f.ip` by the instruction width when executed.

**`jitCompiler`** (in `jit.go`): Architecture-agnostic driver. Runs `BasicBlocksPass` to find block boundaries. For each block, `compile(b)` calls `segment(code, start, end)` in a loop to extract every maximal consecutive run of compilable instructions. Completed segments emit when they reach `WithCutoff`'s minimum instruction count (default 4); a segment cut short by an unsupported instruction is kept only after more than 4 compiled instructions. Within each block, multiple independent segments may be produced. A two-pass strategy (non-terminated blocks first, then branch-terminated blocks) ensures branch targets have known signatures before linking decisions are made. All compiled segments across a function are linked together via `assembler.Link()`, which patches cross-segment label relocations. Each linked segment is installed as a closure at `out[entryIP]`.
**`jitCompiler`** (in `jit.go`): Architecture-agnostic driver. Runs `BasicBlocksPass` to find block boundaries, ranks sampled blocks by profile heat, and compiles hotter blocks first. For each hot block, `compile(b)` calls `segment(code, start, end)` in a loop to extract every maximal consecutive run of compilable sampled instructions. Completed segments emit when they reach `WithCutoff`'s minimum instruction count (default 4); a segment cut short by an unsupported instruction is kept only after more than 4 compiled instructions. Cold segments inside an otherwise hot block are skipped instead of emitted. A two-pass strategy (non-terminated blocks first, then branch-terminated blocks) ensures branch targets have known signatures before linking decisions are made. All compiled segments across a function are linked together via `assembler.Link()`, which patches cross-segment label relocations. Each linked segment is installed as a closure at `out[entryIP]`. The JIT does not currently recompile or tier-up previously compiled code.

**`HostFunction`** (in `host.go`): Wraps a Go `func(i *Interpreter, params []Boxed) ([]Boxed, error)` as a `types.Value`. Stored in the constant table and called with `CONST_GET` + `CALL` like any `*types.Function`.

Expand Down Expand Up @@ -199,11 +200,11 @@ Thin cobra entry point. The root command (no subcommand) launches the REPL with

4. interp.Run(ctx)
├─ main loop: code[f.ip](i)
├─ every 128 instructions: check ctx, consume fuel, call hook, prof.Record(addr, ip)
└─ when prof.Count(addr) == threshold/tick:
├─ every 128 instructions: check ctx, consume fuel, call hook, prof.Add(addr, ip, opcode)
└─ when prof.Samples(addr) == threshold/tick:
jitCompiler.Compile(instrs[addr])
└─ two-pass over basic blocks:
├─ pass 1: for each sampled block, segment() loop → emit eligible segments
└─ heat-sorted two-pass over sampled basic blocks:
├─ pass 1: for each hot block, segment() loop → emit sampled eligible segments
│ non-terminated segments → objs; terminated blocks → deferred
├─ pass 2: recompile terminated blocks with full signature knowledge
├─ assembler.Link(objs) → patches cross-segment relocations → []Caller
Expand Down
17 changes: 17 additions & 0 deletions docs/guides/repl.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,20 @@ MiniVM REPL — type '.help' for commands, '.quit' to exit
0000: i32.const 0x0000002A
0005: i32.const 0x00000008
0010: i32.add
> .profile
profile samples: 3
functions:
func samples %
0 3 100.0%
func 0 ips:
ip samples %
0000 1 33.3%
0005 1 33.3%
0010 1 33.3%
opcodes:
opcode samples %
i32.const 2 66.7%
i32.add 1 33.3%
> .reset
reset.
> .quit
Expand All @@ -55,11 +69,14 @@ stack produces no output.
|---|---|
| `.help` | Print command reference |
| `.show` | Format (disassemble) the accumulated instruction history (includes constants and types) |
| `.profile` | Run the accumulated program once with exact sampling and print a profile report |
| `.reset` | Clear accumulated instructions, constants, types, and stack state |
| `.const` | Declare a function constant (multi-line block, end with blank line) |
| `.type` | Declare one or more types (multi-line block, end with blank line) |
| `.quit` / `.exit` | Exit the REPL |

See [Profiling](../profile.md) for the sampling model and report fields.

## Instruction Syntax

The REPL accepts the same text format that `instr.Format` produces, so
Expand Down
5 changes: 3 additions & 2 deletions docs/jit-internals.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ How to write threaded and JIT handlers. Read this before modifying `interp/threa
Before editing:
- Confirm opcode width in `instr/type.go`.
- Check threaded and JIT behavior for interpreter/JIT parity.
- Read [profile.md](profile.md) before changing hotness thresholds, sampling, or profile-guided segment selection.
- Read [value-representation.md](value-representation.md) before unboxing, boxing, or passing JIT values.
- Read [memory-model.md](memory-model.md) before touching refs, heap objects, locals/globals that may hold refs, or host functions.

Expand Down Expand Up @@ -132,15 +133,15 @@ The Go closure in `jitCompiler.closure()` initializes scratch inputs before `fn.

## Segment Selection

`jitCompiler.Compile(code)` calls `jitCompiler.compile(b)` for each basic block. Within each block, `compile` iterates `segment(code, start, end)` to extract **multiple independent compilable segments**:
`jitCompiler.Compile(code)` computes each basic block's profile heat with `Stats.Range(addr, start, end)`, skips blocks with no samples, then compiles hotter blocks first. Within each hot block, `compile` iterates `segment(code, start, end)` to extract **multiple independent compilable segments**:

```
block [A, B, X, C, D, E, F] (X = non-compilable)
→ segment 1: [A, B] count=2, below threshold → aborted
→ segment 2: [C, D, E, F] count=4, emitted if the block ends here and WithCutoff is 4
```

Completed segments emit when their compiled instruction count is at least `WithCutoff` (default 4). If a segment stops because the next opcode is not compilable, the current implementation keeps it only when `count > 4`; shorter truncated segments are aborted via `assembler.Abort()`.
Completed segments emit when their compiled instruction count is at least `WithCutoff` (default 4) and their own byte range has at least one profile sample. If a segment stops because the next opcode is not compilable, the current implementation keeps it only when `count > 4`; shorter truncated segments are aborted via `assembler.Abort()`. Cold segments inside a sampled block are skipped without emitting native code. The JIT does not currently recompile or tier-up code after the first function-level compilation attempt.

### Two-Pass Compilation

Expand Down
Loading
Loading