Skip to content

Commit a01f45b

Browse files
committed
feat(#58): gengo --test --profile resource profiling
Each test block already runs through one isolated loop in Runtime.runPathWithProvider (vm.callGlobal per __test_N global, sequential, pass/fail tracked) -- exactly the wrapping point needed, making this smaller than its P3/v0.9.0 label suggested. Stack peak reuses the verifier-proved f.max_stack bound enterFunctionFrame*/already checks on every call -- an upper bound on capacity used, zero new cost on the push/pop hot path. Heap/object peaks hook the 8 allocation success points (GC only ever shrinks usage, never raises it, so allocation time is sufficient). Ops count forces the existing budget-accounting dispatch path on for the run (normally skipped via a batched heartbeat when no real max_ops is set) -- the one part of this with a real, expected speed cost. All three gated behind a new Policy.profile_mode field, zero cost when off. tools/time-bench.sh compare shows no regression on any benchmark.
1 parent 9232590 commit a01f45b

8 files changed

Lines changed: 126 additions & 2 deletions

File tree

CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,18 @@
22

33
This changelog tracks notable language/runtime changes by implementation date.
44

5+
## 2026-07-21 (latest) (v0.5.1-dev)
6+
7+
### Tooling — `gengo --test --profile` (#58)
8+
9+
Each `test` block already compiles to a synthetic `__test_N` global run through one isolated loop in `Runtime.runPathWithProvider` (`vm.callGlobal`, sequential, pass/fail tracked) — that loop turned out to be exactly the wrapping point #58 needed, making this a smaller change than its `P3`/`v0.9.0` label suggested.
10+
11+
- **Stack peak**: reuses the verifier-proved `f.max_stack` bound `enterFunctionFrame`/`enterFunctionFrameWarm` already check on every call (`ctx.vs.stack_top + f.max_stack`) — an upper bound on capacity used, not a per-push sampled maximum, so it costs one extra branch at an already-existing call-entry checkpoint instead of touching the push/pop hot path at all.
12+
- **Heap bytes / live objects peak**: `usedBytes()`/`liveObjectCount()` only ever grow at allocation time (GC only shrinks them), so checking-and-updating a peak at `vmAllocObject`/`vmAllocManagedSlice`/`vmAllocManagedBytes`'s 8 success points is sufficient to capture the true peak.
13+
- **Ops count**: `ops_budget_remaining` only decrements when a real `max_ops` budget is set — an unbudgeted run takes a batched "heartbeat" dispatch path specifically to avoid a per-instruction accounting cost. `profile_mode` forces the interval to 1 (tick every instruction) with no real budget, so `budget_before - budget_after` gives an exact per-block count; this is the one part of the feature with a real, expected runtime cost (a diagnostic flag, not something to run by default).
14+
15+
All three are gated behind a new `Policy.profile_mode` field, zero cost when off. Verified with `tools/time-bench.sh compare`: no measurable regression on any benchmark.
16+
517
## 2026-07-21 (later) (v0.5.1-dev)
618

719
### Embedding — std natives are no longer host-overridable

docs/changelog.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,10 @@
2626
- **`cap:env`** — New capability module for process environment access. `env.get(name)` returns `string|null`; `env.list()` returns `[string]string`. Requires the host to enable the `env` capability; a script importing `cap:env` without host permission fails at compile time.
2727
- **`std.Arg`** — Built-in variant covering all primitive scalar types (`Int`, `Float`, `Decimal`, `Rune`, `Bool`, `Str`, `Err`). Use it to write type-safe heterogeneous variadic functions without exposing `any`.
2828

29+
### Tooling (unreleased)
30+
31+
- **`gengo --test --profile`** — reports each `test` block's instruction count and peak heap bytes/stack depth/live object count, plus a final peak-across-all-blocks summary line, so integrators can size `engine_init_with_config`'s resource ceilings from measured workload data instead of guessing. Does not affect pass/fail behavior or the exit code; does cost real speed (forces per-instruction accounting on for the run), so it's a diagnostic flag, not something to leave on by default.
32+
2933
### Fixes (unreleased)
3034

3135
- Struct and enum variable declarations following a `std` import no longer trigger a spurious "unknown field in std" compile error.

docs/cli.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ script from standard input instead.
3535
| `--version` | Print the CLI version and exit. |
3636
| `--disasm` | Compile and print a bytecode disassembly without running the script. This is an implementation-debugging aid, not language semantics. |
3737
| `--test` | Run top-level `test` blocks rather than ordinary script execution. A failed test exits unsuccessfully. |
38+
| `--profile` | With `--test`, print each block's instruction count and peak heap bytes/stack depth/live object count, plus a final peak-across-all-blocks summary line. Does not affect pass/fail behavior or the exit code. Forces per-instruction instruction counting on for the run, which costs real speed — a diagnostic aid, not something to leave on by default. |
3839
| `--cap name` | Enable one named capability. Repeat for several capabilities. See `capabilities.md`; no capability is enabled merely by importing it. |
3940
| `--modules path` | Permit source imports from one additional directory. Repeatable, up to eight paths. The script directory remains the default source root. |
4041
| `--max-ops n` | Limit VM instruction execution to `n`. `0` means unlimited. This limit does not account for work inside host callbacks. |

src/lang/vm.zig

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -972,6 +972,7 @@ inline fn enterFunctionFrameWarm(ctx: VMContext, f: @import("value.zig").FuncObj
972972
// exceeds f.max_stack slots above the entry stack_top, so opcode handlers
973973
// may use unchecked stack ops inside this frame.
974974
if (ctx.vs.stack_top + f.max_stack > ctx.vs.stack.len) return error.StackOverflow;
975+
recordProfileStackPeak(ctx, f.max_stack);
975976
ctx.vs.frames[ctx.vs.frame_top] = .{
976977
.ret_ip = @intCast(ctx.vs.ip),
977978
.base = @intCast(ctx.vs.stack_top - f.arity),
@@ -985,6 +986,18 @@ inline fn enterFunctionFrameWarm(ctx: VMContext, f: @import("value.zig").FuncObj
985986
ctx.vs.ip = f.ip;
986987
}
987988

989+
// gengo --test --profile only: this is the verifier-proved *capacity*
990+
// consumed by the frame being entered (ctx.vs.stack_top + f.max_stack), not
991+
// a per-push sampled maximum — reusing the same bound enterFunctionFrame*
992+
// already checks means zero new cost on the push/pop hot path. An upper
993+
// bound on capacity used is also the more useful number for sizing an
994+
// embedder's stack limit than one observed sample run's exact depth.
995+
inline fn recordProfileStackPeak(ctx: VMContext, max_stack: u16) void {
996+
if (!ctx.vs.policy.profile_mode) return;
997+
const candidate = ctx.vs.stack_top + max_stack;
998+
if (candidate > ctx.vs.peak_stack_depth) ctx.vs.peak_stack_depth = candidate;
999+
}
1000+
9881001
fn enterFunctionFrame(ctx: VMContext, f: @import("value.zig").FuncObj, func_obj: *Object, closure: ?*Object, argc: u8) !void {
9891002
var effective_argc = argc;
9901003
if (f.is_variadic) {
@@ -1031,6 +1044,7 @@ fn enterFunctionFrame(ctx: VMContext, f: @import("value.zig").FuncObj, func_obj:
10311044
if (ctx.vs.frame_top >= ctx.vs.frames.len) return error.CallStackOverflow;
10321045
// See enterFunctionFrameWarm: verifier-proved bound, checked once per call.
10331046
if (ctx.vs.stack_top + f.max_stack > ctx.vs.stack.len) return error.StackOverflow;
1047+
recordProfileStackPeak(ctx, f.max_stack);
10341048
ctx.vs.frames[ctx.vs.frame_top] = .{
10351049
.ret_ip = @intCast(ctx.vs.ip),
10361050
.base = @intCast(ctx.vs.stack_top - f.arity),

src/lang/vm_gc.zig

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,19 @@ fn gcStress() bool {
209209
return gc_stress;
210210
}
211211

212+
// gengo --test --profile only: object-slot and managed-slice allocation are
213+
// the only two places usedBytes()/liveObjectCount() can grow — GC only ever
214+
// shrinks them — so checking here after a successful allocation is
215+
// sufficient to capture the true peak, without touching the push/pop hot
216+
// path at all.
217+
inline fn recordProfilePeaks(ctx: VMContext) void {
218+
if (!ctx.vs.policy.profile_mode) return;
219+
const objs = ctx.hs.liveObjectCount();
220+
if (objs > ctx.vs.peak_live_objects) ctx.vs.peak_live_objects = objs;
221+
const bytes = ctx.hs.usedBytes();
222+
if (bytes > ctx.vs.peak_heap_bytes) ctx.vs.peak_heap_bytes = bytes;
223+
}
224+
212225
pub fn vmAllocObject(ctx: VMContext) !*Object {
213226
if (gcStress()) collectGarbage(ctx);
214227
if (ctx.hs.liveObjectCount() >= ctx.vs.next_gc_objects) {
@@ -217,12 +230,14 @@ pub fn vmAllocObject(ctx: VMContext) !*Object {
217230
}
218231
if (ctx.hs.allocObject()) |o| {
219232
ctx.vs.alloc_object_calls += 1;
233+
recordProfilePeaks(ctx);
220234
return o;
221235
}
222236
collectGarbage(ctx);
223237
ctx.vs.next_gc_objects = nextGcObjects(ctx, ctx.hs.liveObjectCount());
224238
if (ctx.hs.allocObject()) |o| {
225239
ctx.vs.alloc_object_calls += 1;
240+
recordProfilePeaks(ctx);
226241
return o;
227242
}
228243
return error.OutOfMemory;
@@ -253,17 +268,20 @@ pub fn vmAllocManagedSlice(ctx: VMContext, comptime T: type, n: usize) ![]T {
253268
}
254269
if (ctx.hs.allocManagedSlice(T, n)) |s| {
255270
ctx.vs.alloc_managed_slice_calls += 1;
271+
recordProfilePeaks(ctx);
256272
return s;
257273
}
258274
collectGarbage(ctx);
259275
ctx.vs.next_gc_heap_bytes = gcStepThreshold(ctx, ctx.hs.usedBytes());
260276
if (ctx.hs.allocManagedSlice(T, n)) |s| {
261277
ctx.vs.alloc_managed_slice_calls += 1;
278+
recordProfilePeaks(ctx);
262279
return s;
263280
}
264281
ctx.hs.compactManagedHeap();
265282
if (ctx.hs.allocManagedSlice(T, n)) |s| {
266283
ctx.vs.alloc_managed_slice_calls += 1;
284+
recordProfilePeaks(ctx);
267285
return s;
268286
}
269287
return error.OutOfMemory;
@@ -289,19 +307,22 @@ pub fn vmAllocManagedBytes(ctx: VMContext, n: usize) ![]u8 {
289307
}
290308
if (ctx.hs.allocBytesManaged(n)) |s| {
291309
ctx.vs.alloc_managed_bytes_calls += 1;
310+
recordProfilePeaks(ctx);
292311
return s;
293312
}
294313
collectGarbage(ctx);
295314
ctx.vs.next_gc_heap_bytes = gcStepThreshold(ctx, ctx.hs.usedBytes());
296315
if (ctx.hs.allocBytesManaged(n)) |s| {
297316
ctx.vs.alloc_managed_bytes_calls += 1;
317+
recordProfilePeaks(ctx);
298318
return s;
299319
}
300320
// Last resort: compact live managed data into a contiguous region so that
301321
// fragmentation caused by live objects between freed blocks is eliminated.
302322
ctx.hs.compactManagedHeap();
303323
if (ctx.hs.allocBytesManaged(n)) |s| {
304324
ctx.vs.alloc_managed_bytes_calls += 1;
325+
recordProfilePeaks(ctx);
305326
return s;
306327
}
307328
return error.OutOfMemory;

src/lang/vm_state.zig

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,11 @@ pub const Policy = struct {
3636
allow_io: bool = true,
3737
native_backend: NativeBackend = .embedded,
3838
max_ops: ?u64 = null,
39+
// gengo --test --profile: forces per-instruction gas accounting on (see
40+
// dispatchGasInterval in vm.zig) even with no real max_ops budget, so
41+
// ops_budget_remaining decrements every instruction instead of only
42+
// every ~4M-instruction heartbeat, and enables the peak_* fields below.
43+
profile_mode: bool = false,
3944
enable_predicates: bool = true,
4045
};
4146

@@ -70,6 +75,13 @@ pub const State = struct {
7075
frame_top: usize = 0,
7176
std_module: ?*Object = null,
7277
host_checked: bool = false,
78+
// gengo --test --profile only (see Policy.profile_mode). The caller
79+
// (Runtime's test-block loop) resets each to the current baseline
80+
// before running a block, then reads it back after — these only ever
81+
// grow during execution, they are not reset by the VM itself.
82+
peak_stack_depth: usize = 0,
83+
peak_heap_bytes: usize = 0,
84+
peak_live_objects: usize = 0,
7385
configured_heap_size: usize = 0,
7486
next_gc_objects: usize = 256,
7587
next_gc_heap_bytes: usize = 0,
@@ -227,7 +239,12 @@ pub const State = struct {
227239

228240
pub fn setPolicy(self: *State, policy: Policy) void {
229241
self.policy = policy;
230-
self.ops_budget_remaining = policy.max_ops orelse std.math.maxInt(u64);
242+
// profile_mode with no real budget: anything other than maxInt(u64)
243+
// makes dispatchGasInterval tick every instruction instead of only
244+
// every heartbeat, so ops_budget_remaining actually decrements and
245+
// (budget_before - budget_after) gives an exact per-block op count.
246+
self.ops_budget_remaining = policy.max_ops orelse
247+
(if (policy.profile_mode) std.math.maxInt(u64) - 1 else std.math.maxInt(u64));
231248
}
232249

233250
fn currentIpIdx(self: *State, cs: *const chunk.State) usize {

src/main.zig

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -435,6 +435,7 @@ fn runCli(argv: []const []const u8) void {
435435
var backend: vm.Policy.NativeBackend = .embedded;
436436
var max_ops: ?u64 = null;
437437
var test_mode: bool = false;
438+
var profile_mode: bool = false;
438439
var disasm_mode: bool = false;
439440
var cap_names: [8][]const u8 = undefined;
440441
var cap_count: usize = 0;
@@ -455,6 +456,7 @@ fn runCli(argv: []const []const u8) void {
455456
io.write(" --version Print version and exit\n");
456457
io.write(" --disasm Compile and print bytecode disassembly; do not run\n");
457458
io.write(" --test Run test blocks in the script\n");
459+
io.write(" --profile With --test, report peak ops/heap/stack/objects per block\n");
458460
io.write(" --cap <name> Enable a named capability (repeatable)\n");
459461
io.write(" --modules <path> Allow imports from an extra directory (repeatable)\n");
460462
io.write(" --max-ops <n> Limit instruction count (0 = unlimited)\n");
@@ -579,6 +581,11 @@ fn runCli(argv: []const []const u8) void {
579581
script_index += 1;
580582
continue;
581583
}
584+
if (std.mem.eql(u8, a, "--profile")) {
585+
profile_mode = true;
586+
script_index += 1;
587+
continue;
588+
}
582589
if (std.mem.eql(u8, a, "--version")) {
583590
io.write("Gengoscript v");
584591
io.write(build_opts.version);
@@ -650,6 +657,7 @@ fn runCli(argv: []const []const u8) void {
650657
.allow_io = true,
651658
.native_backend = backend,
652659
.max_ops = max_ops,
660+
.profile_mode = profile_mode,
653661
}, heap_size, max_objects, vms.MaxStack, vms.MaxFrames, cfg.max_defers, std.heap.page_allocator) catch {
654662
io.werr("gengo: runtime init failed\n");
655663
die(1);

src/runtime/runtime.zig

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -422,11 +422,37 @@ pub const Runtime = struct {
422422
if (test_mode and self.test_count > 0) {
423423
var passed: u8 = 0;
424424
var failed: u8 = 0;
425+
const profiling = self.vm_state.policy.profile_mode;
426+
// Component-wise max across all blocks, not a sum — "what's the
427+
// largest any single block needed" is the number that sizes an
428+
// embedder's engine_init_with_config ceilings.
429+
var peak_ops: u64 = 0;
430+
var peak_heap: usize = 0;
431+
var peak_stack: usize = 0;
432+
var peak_objects: usize = 0;
425433
var ti: u8 = 0;
426434
while (ti < self.test_count) : (ti += 1) {
427435
var name_buf: [32]u8 = undefined;
428436
const name = std.fmt.bufPrint(&name_buf, "__test_{d}", .{ti}) catch continue;
429-
_ = vm.callGlobal(.{ .cs = self.chunk_state, .gs = &self.globals_state, .hs = &self.heap_state, .vs = &self.vm_state }, name, &[_]Value{}) catch |err| {
437+
438+
const ops_before = self.vm_state.ops_budget_remaining;
439+
if (profiling) {
440+
self.vm_state.peak_stack_depth = self.vm_state.stack_top;
441+
self.vm_state.peak_heap_bytes = self.heap_state.usedBytes();
442+
self.vm_state.peak_live_objects = self.heap_state.liveObjectCount();
443+
}
444+
445+
const call_result = vm.callGlobal(.{ .cs = self.chunk_state, .gs = &self.globals_state, .hs = &self.heap_state, .vs = &self.vm_state }, name, &[_]Value{});
446+
447+
const ops_used = ops_before -% self.vm_state.ops_budget_remaining;
448+
if (profiling) {
449+
peak_ops = @max(peak_ops, ops_used);
450+
peak_heap = @max(peak_heap, self.vm_state.peak_heap_bytes);
451+
peak_stack = @max(peak_stack, self.vm_state.peak_stack_depth);
452+
peak_objects = @max(peak_objects, self.vm_state.peak_live_objects);
453+
}
454+
455+
_ = call_result catch |err| {
430456
failed += 1;
431457
io.werr("FAIL: ");
432458
io.werr(self.test_names[ti]);
@@ -437,23 +463,44 @@ pub const Runtime = struct {
437463
io.werr(": ");
438464
io.werr(emsg);
439465
}
466+
if (profiling) self.writeProfileColumns(ops_used, self.vm_state.peak_heap_bytes, self.vm_state.peak_stack_depth, self.vm_state.peak_live_objects);
440467
io.werr("\n");
441468
continue;
442469
};
443470
passed += 1;
444471
io.werr("PASS: ");
445472
io.werr(self.test_names[ti]);
473+
if (profiling) self.writeProfileColumns(ops_used, self.vm_state.peak_heap_bytes, self.vm_state.peak_stack_depth, self.vm_state.peak_live_objects);
446474
io.werr("\n");
447475
}
448476
io.werr("\n");
449477
io.writeInt(@intCast(passed));
450478
io.werr(" passed, ");
451479
io.writeInt(@intCast(failed));
452480
io.werr(" failed\n");
481+
if (profiling) {
482+
io.werr("peak across all tests: ");
483+
self.writeProfileColumns(peak_ops, peak_heap, peak_stack, peak_objects);
484+
io.werr("\n");
485+
}
453486
if (failed > 0) self.test_failed = true;
454487
}
455488
}
456489

490+
// gengo --test --profile: appended after a PASS/FAIL line and used
491+
// standalone for the final "peak across all tests" summary.
492+
fn writeProfileColumns(self: *Runtime, ops: u64, heap_bytes: usize, stack_depth: usize, objects: usize) void {
493+
_ = self;
494+
io.werr(" ops=");
495+
io.werrUint(ops);
496+
io.werr(" heap=");
497+
io.werrUint(@intCast(heap_bytes));
498+
io.werr(" stack=");
499+
io.werrUint(@intCast(stack_depth));
500+
io.werr(" objects=");
501+
io.werrUint(@intCast(objects));
502+
}
503+
457504
// Run src without resetting globals or heap — allows successive REPL lines
458505
// to share definitions and allocated objects.
459506
pub fn runIncremental(self: *Runtime, src: []const u8) !void {

0 commit comments

Comments
 (0)