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
34 changes: 34 additions & 0 deletions docs/adr/0101-closed-numeric-scalar-self-call-frames.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# Closed numeric scalar self-call frames

**Date:** 2026-07-21
**Area:** `bytecode runtime`

GocciaScript specializes direct self-calls only when the existing closed-world
numeric proof accepts the entire local arrow function and every external call.
For proven functions with one to three parameters, non-tail recursive call sites
emit `OP_CALL_SELF_NUM`. The instruction enters the same function template with
a compact scalar frame and a new register window while sharing the generic entry
frame's closure, lexical environment, local-cell and argument windows, realm,
`new.target`, and execution context. It still accounts for the ordinary stack
limit, deferred error-stack frames, function profiling, coverage, instruction
limits, timeouts, and GC-visible live registers. Tail calls continue through the
generic proper-tail-call path.

This is a proof-backed ABI, not speculative specialization. There is no guard or
deoptimization path: escapes, mixed or wrong-arity calls, optional/spread calls,
defaults, rest/destructuring, unsupported expression forms, and functions with
more than three parameters retain `OP_CALL`. Generic function entry is
unchanged. The compiler-only proof name is not serialized; the opcode is, so the
bytecode format advances from version 74 to 75.

On the retained untyped Fibonacci probe (25 inner iterations, nine interleaved
production samples), the scalar-frame build reduced median wall time from
241,359 us on `origin/main` to 72,211 us (70.1%), and from 228,870 us on the
numeric-superinstruction predecessor to 72,722 us (68.2%). Deterministic
one-iteration dispatch fell from 262,915 opcodes on `origin/main` to 164,408;
generic `OP_CALL` plus self `OP_GET_UPVALUE` dispatches fell from 43,785 to five
ordinary outer calls. The pinned AWFY Sieve guard remained within matched noise:
95,453 us versus 95,032 us on the predecessor (0.44% slower), and 95,254 us
versus 97,003 us on `origin/main` (1.8% faster). This narrowly satisfies the
end-to-end evidence requirement behind ADR 0089 without introducing a general
fixed-arity calling convention.
1 change: 1 addition & 0 deletions docs/adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,3 +110,4 @@ Durable architecture and implementation decisions for GocciaScript. New ADRs use
- [0098 — Portable exact numeric text conversion](0098-portable-exact-numeric-text-conversion.md)
- [0099 — Canonical UTF-16 text with explicit encoded-byte boundaries](0099-canonical-utf16-text-boundaries.md)
- [0100 — Native binary64 execution with narrow semantic operations](0100-native-binary64-execution.md)
- [0101 — Closed numeric scalar self-call frames](0101-closed-numeric-scalar-self-call-frames.md)
15 changes: 15 additions & 0 deletions docs/bytecode-vm.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ Public bytecode artifacts use the `.gbc` extension.
| VM execution | `Goccia.VM.pas` |
| Frames / closures / upvalues | `Goccia.VM.CallFrame.pas`, `Goccia.VM.Closure.pas`, `Goccia.VM.Upvalue.pas` |
| Bytecode executor | `Goccia.Executor.Bytecode.pas` (`TGocciaBytecodeExecutor`) |
| Numeric proof / lowering | `Goccia.Compiler.NumericProof.pas`, `Goccia.Compiler.Expressions.pas` |
| Opcode name lookup | `Goccia.Bytecode.OpCodeNames.pas` |
| Profiler | `Goccia.Profiler.pas`, `Goccia.Profiler.Report.pas` |

Expand Down Expand Up @@ -116,7 +117,9 @@ Recent VM cleanup and optimization work has focused on reducing per-instruction
- pre-size argument collections for calls and construction
- hold call arguments in a stack-disciplined arena window (`FArgumentStack` with a base+count window, mirroring the register and local-cell stacks) instead of a per-call dynamic array, so an ordinary call performs no argument-array allocation; frame save/restore and native re-entry store `(base, count)` rather than copying
- defer stack-trace frames on the hot call path: push the function-template pointer rather than copying its name/source strings, and materialise them only when a trace is captured (see [ADR 0074](adr/0074-deferred-bytecode-call-stack-frames.md))
- execute compiler-proven closed-world numeric self-calls through `OP_CALL_SELF_NUM`: recursive calls with one to three scalar arguments use a compact register frame while sharing the generic entry frame's closure, lexical environment, local-cell and argument windows, realm, and execution context (see [ADR 0101](adr/0101-closed-numeric-scalar-self-call-frames.md))
- use unchecked template access in the dispatch loop where bounds are already guaranteed
- fuse `Number - Int16` as `OP_SUB_NUM_IMM` and conditional `Number <= Int16` as `OP_JUMP_IF_NUM_NOT_LTE_IMM` only when the compiler proves the source is an ECMAScript Number; these instructions remove literal-load and branch dispatches rather than merely replacing a generic arithmetic dispatch
- keep fast register access limited to proven hot/simple paths; local-slot and complex property paths should only move to fast access when they stay correct and measurably improve throughput
- the register, local-cell, and argument window fills are GC-safety/correctness critical (the GC marks the whole live window): they are deliberately retained rather than trimmed

Expand All @@ -136,6 +139,18 @@ Computed property access (`OP_ARRAY_GET`/`OP_ARRAY_SET`, `OP_GET_INDEX`/`OP_SET_

The current optimization target is reducing bytecode-mode suite time further without diverging interpreter and bytecode semantics.

Numeric call-derived proof is deliberately closed-world. It applies only to a
simple, const-bound local arrow with no escape, optional call, default/rest/
destructured parameter, mixed argument, or unsupported use. Every external
direct call must supply exactly one numeric literal per parameter, and the
expression body must establish a Number result recursively. The proof marks
parameters for the two numeric immediate superinstructions; for one to three
parameters it also emits `OP_CALL_SELF_NUM` at non-tail direct self-call sites.
The scalar frame performs no speculative type conversion or deoptimization:
unsupported shapes retain ordinary `OP_CALL`, and tail calls retain the generic
proper-tail-call path. The optimization does not turn the function into a
generally typed function or change generic `+` semantics.

## Profiling

The `--profile` option on GocciaScriptLoader enables language-level profiling of the bytecode VM. See [profiling.md](profiling.md) for the full guide.
Expand Down
6 changes: 6 additions & 0 deletions source/units/Goccia.Bytecode.Chunk.pas
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,10 @@ TGocciaFunctionTemplate = class
FRejectArgumentsInDirectEval: Boolean;
FProfileIndex: Integer;
FSourceText: string;
// Compile-time-only marker for the closed-world numeric proof. The emitted
// direct self-call opcode carries the runtime contract, so this name is not
// part of the serialized bytecode format.
FClosedNumericSelfName: string;
FTemplateSiteId: UInt64;
FStringConstantIndex: TOrderedStringMap<UInt16>;
// Runtime-only cache for bckTemplateObject constants. Indexed by the slot
Expand Down Expand Up @@ -300,6 +304,8 @@ TGocciaFunctionTemplate = class
property RejectArgumentsInDirectEval: Boolean read FRejectArgumentsInDirectEval write FRejectArgumentsInDirectEval;
property ProfileIndex: Integer read FProfileIndex write FProfileIndex;
property SourceText: string read FSourceText write FSourceText;
property ClosedNumericSelfName: string read FClosedNumericSelfName
write FClosedNumericSelfName;
property TemplateSiteId: UInt64 read FTemplateSiteId;
end;

Expand Down
3 changes: 3 additions & 0 deletions source/units/Goccia.Bytecode.OpCodeNames.pas
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,9 @@ function OpCodeName(const AOp: UInt8): string;
OP_SET_UPVALUE_DYNAMIC: Result := 'OP_SET_UPVALUE_DYNAMIC';
OP_WIDE: Result := 'OP_WIDE';
OP_CONSTRUCT_SPREAD: Result := 'OP_CONSTRUCT_SPREAD';
OP_SUB_NUM_IMM: Result := 'OP_SUB_NUM_IMM';
OP_JUMP_IF_NUM_NOT_LTE_IMM: Result := 'OP_JUMP_IF_NUM_NOT_LTE_IMM';
OP_CALL_SELF_NUM: Result := 'OP_CALL_SELF_NUM';
else
Result := Format('OP_UNKNOWN_%d', [AOp]);
end;
Expand Down
17 changes: 15 additions & 2 deletions source/units/Goccia.Bytecode.pas
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,11 @@ interface
// little-endian UTF-16 code units so bytecode constants
// preserve every ECMAScript string value, including lone
// surrogates.
GOCCIA_FORMAT_VERSION = 73;
// v73 -> v74: added numeric immediate superinstructions for proven
// Number subtraction and less-than-or-equal branches.
// v74 -> v75: added a direct scalar-frame self-call opcode for functions
// accepted by the closed-world numeric-call proof.
GOCCIA_FORMAT_VERSION = 75;
GOCCIA_BINARY_MAGIC: array[0..3] of Byte = (Ord('G'), Ord('B'), Ord('C'), 0);
GOCCIA_NULLISH_MATCH_UNDEFINED = 0;
GOCCIA_NULLISH_MATCH_NULL = 1;
Expand Down Expand Up @@ -416,7 +420,16 @@ interface
// the following instruction's A/B/C operands; the base word keeps the
// ordinary compact encoding.
OP_WIDE = 225,
OP_CONSTRUCT_SPREAD = 226
OP_CONSTRUCT_SPREAD = 226,
// A = destination, B = proven Number source, C = signed Int16 immediate.
OP_SUB_NUM_IMM = 227,
// A = proven Number source, B = signed Int16 immediate,
// C = signed Int16 forward jump offset when A <= B is false.
OP_JUMP_IF_NUM_NOT_LTE_IMM = 228,
// A = destination, B = first contiguous argument register,
// C = argument count (1..3). Valid only in a compiler-proven closed-world
// numeric self-recursive template.
OP_CALL_SELF_NUM = 229
);

function EncodeABC(const AOp: TGocciaOpCode; const A, B, C: UInt16): UInt64; {$IFDEF FPC}inline;{$ENDIF}
Expand Down
26 changes: 26 additions & 0 deletions source/units/Goccia.Compiler.Context.pas
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
interface

uses
Generics.Collections,
SysUtils,

HashMap,
Expand All @@ -29,12 +30,19 @@ interface
TSetNonStrictModeProc = procedure(const AEnabled: Boolean) of object;

TFormalParameterCountMap = THashMap<TGocciaFunctionTemplate, Integer>;
TClosedNumericCallProof = record
ParameterMask: UInt64;
FunctionName: string;
end;
TNumericParameterProofMap = TDictionary<TGocciaExpression,
TClosedNumericCallProof>;

TGocciaCompilationContext = record
Template: TGocciaFunctionTemplate;
Scope: TGocciaCompilerScope;
SourcePath: string;
FormalParameterCounts: TFormalParameterCountMap;
NumericParameterProofs: TNumericParameterProofMap;
GlobalBackedTopLevel: Boolean;
PreinitializedTopLevelFunctions: Boolean;
StrictTypes: Boolean;
Expand Down Expand Up @@ -184,6 +192,24 @@ procedure PatchJumpTarget(const ACtx: TGocciaCompilationContext;
ACtx.Template.PatchInstruction(AIndex,
EncodeABC(TGocciaOpCode(Op), A, B, UInt16(Offset)));
end
else if TGocciaOpCode(Op) = OP_JUMP_IF_NUM_NOT_LTE_IMM then
begin
A := DecodeA(Instruction);
B := DecodeB(Instruction);
if (AIndex > 0) and
(DecodeOp(ACtx.Template.GetInstruction(AIndex - 1)) = Ord(OP_WIDE)) then
begin
A := A or (UInt16(DecodeA(
ACtx.Template.GetInstruction(AIndex - 1))) shl 8);
B := B or (UInt16(DecodeB(
ACtx.Template.GetInstruction(AIndex - 1))) shl 8);
end;
if (Offset < Low(Int16)) or (Offset > High(Int16)) then
raise Exception.Create('Numeric immediate jump offset exceeds 16-bit range');
ACtx.Template.PatchInstruction(AIndex,
EncodeABC(OP_JUMP_IF_NUM_NOT_LTE_IMM, A, B,
UInt16(Int16(Offset))));
end
else
begin
A := DecodeA(Instruction);
Expand Down
122 changes: 118 additions & 4 deletions source/units/Goccia.Compiler.Expressions.pas
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,43 @@ function InferredExpressionType(const AScope: TGocciaCompilerScope;
Result := InferLocalType(AExpr);
end;

function HasExactNumberProof(const AScope: TGocciaCompilerScope;
const AExpr: TGocciaExpression): Boolean;
var
LocalIndex: Integer;
begin
if IsKnownNumeric(ExpressionType(AScope, AExpr)) then
Exit(True);
if AExpr is TGocciaIdentifierExpression then
begin
LocalIndex := AScope.ResolveLocal(
TGocciaIdentifierExpression(AExpr).Name);
if LocalIndex >= 0 then
Exit(AScope.GetLocal(LocalIndex).IsCallProvenNumeric);
end;
Result := False;
end;

function TrySignedInt16NumberLiteral(const AExpr: TGocciaExpression;
out AImmediate: Int16): Boolean;
var
NumberValue: Double;
begin
Result := False;
if not (AExpr is TGocciaLiteralExpression) or
not (TGocciaLiteralExpression(AExpr).Value is
TGocciaNumberLiteralValue) then
Exit;
NumberValue := TGocciaNumberLiteralValue(
TGocciaLiteralExpression(AExpr).Value).Value;
if IsNan(NumberValue) or IsInfinite(NumberValue) or
(NumberValue <> Trunc(NumberValue)) or
(NumberValue < Low(Int16)) or (NumberValue > High(Int16)) then
Exit;
AImmediate := Int16(Trunc(NumberValue));
Result := True;
end;

function IsAnonymousFunctionNameExpression(
const AExpr: TGocciaExpression): Boolean;
begin
Expand Down Expand Up @@ -1954,6 +1991,7 @@ procedure CompileBinary(const ACtx: TGocciaCompilationContext;
JumpIdx, JumpIdx2: Integer;
LeftType, RightType: TGocciaLocalType;
PrivateExpr: TGocciaPrivateMemberExpression;
Immediate: Int16;
begin
if TryFoldBinary(ACtx, AExpr, ADest) then
Exit;
Expand Down Expand Up @@ -2001,6 +2039,18 @@ procedure CompileBinary(const ACtx: TGocciaCompilationContext;
Exit;
end;

if (AExpr.Operator = gttMinus) and
HasExactNumberProof(ACtx.Scope, AExpr.Left) and
TrySignedInt16NumberLiteral(AExpr.Right, Immediate) then
begin
RegB := ACtx.Scope.AllocateRegister;
ACtx.CompileExpression(AExpr.Left, RegB);
EmitInstruction(ACtx, EncodeABC(OP_SUB_NUM_IMM, ADest, RegB,
UInt16(Immediate)));
ACtx.Scope.FreeRegister;
Exit;
end;

RegB := ACtx.Scope.AllocateRegister;
RegC := ACtx.Scope.AllocateRegister;

Expand Down Expand Up @@ -3626,6 +3676,8 @@ procedure CompileArrowFunction(const ACtx: TGocciaCompilationContext;
RestReg: Integer;
I: Integer;
OldDerivedGuard: Boolean;
NumericProof: TClosedNumericCallProof;
NumericLocalIndex: Integer;
begin
OldTemplate := ACtx.Template;
OldScope := ACtx.Scope;
Expand Down Expand Up @@ -3661,6 +3713,20 @@ procedure CompileArrowFunction(const ACtx: TGocciaCompilationContext;
else
ChildScope.DeclareLocal(AExpr.Parameters[I].Name, False);
end;
if Assigned(ACtx.NumericParameterProofs) and
ACtx.NumericParameterProofs.TryGetValue(AExpr,
NumericProof) then
begin
if Length(AExpr.Parameters) <= 3 then
ChildTemplate.ClosedNumericSelfName := NumericProof.FunctionName;
for I := 0 to High(AExpr.Parameters) do
if (NumericProof.ParameterMask and (UInt64(1) shl I)) <> 0 then
begin
NumericLocalIndex := ChildScope.ResolveLocal(AExpr.Parameters[I].Name);
if NumericLocalIndex >= 0 then
ChildScope.SetLocalCallProvenNumeric(NumericLocalIndex, True);
end;
end;
for I := 0 to High(AExpr.Parameters) do
if AExpr.Parameters[I].IsPattern and Assigned(AExpr.Parameters[I].Pattern) then
CollectDestructuringBindings(AExpr.Parameters[I].Pattern, ChildScope);
Expand Down Expand Up @@ -4188,7 +4254,7 @@ procedure CompileCall(const ACtx: TGocciaCompilationContext;
const ATail: Boolean = False);
var
ArgCount, I: Integer;
BaseReg, ObjReg, ArgsReg, SuperReg, KeyReg: UInt16;
BaseReg, ObjReg, ArgsReg, SuperReg, KeyReg, FirstArgReg: UInt16;
ThisLocalIdx: Integer;
ThisUpvalIdx: Integer;
ThisLocal: TGocciaCompilerLocal;
Expand All @@ -4201,13 +4267,37 @@ procedure CompileCall(const ACtx: TGocciaCompilationContext;
IgnoredJumpCount: Integer;
MethodTailFlag: UInt16;
begin
if TryCompileOptionalChainCall(ACtx, AExpr, ADest) then
Exit;

ArgCount := AExpr.Arguments.Count;
if ArgCount > High(UInt16) then
raise Exception.Create('Compiler error: too many arguments (>65535)');
UseSpread := HasSpreadArgument(AExpr);

// A closed-world numeric proof establishes both the callee identity and the
// Number type of every recursive argument. Emit the scalar-frame ABI before
// generic callee resolution so the hot path does not load or dispatch the
// closure at all. Tail calls retain the generic proper-tail-call machinery.
if not ATail and not AExpr.Optional and not UseSpread and
(ArgCount >= 1) and (ArgCount <= 3) and
(ACtx.Template.ClosedNumericSelfName <> '') and
(AExpr.Callee is TGocciaIdentifierExpression) and
(TGocciaIdentifierExpression(AExpr.Callee).Name =
ACtx.Template.ClosedNumericSelfName) then
begin
FirstArgReg := ACtx.Scope.AllocateRegister;
ACtx.CompileExpression(AExpr.Arguments[0], FirstArgReg);
for I := 1 to ArgCount - 1 do
ACtx.CompileExpression(AExpr.Arguments[I],
ACtx.Scope.AllocateRegister);
EmitInstruction(ACtx, EncodeABC(OP_CALL_SELF_NUM, ADest, FirstArgReg,
UInt16(ArgCount)));
for I := 0 to ArgCount - 1 do
ACtx.Scope.FreeRegister;
Exit;
end;

if TryCompileOptionalChainCall(ACtx, AExpr, ADest) then
Exit;

CallNilJump := -1;
IgnoredJumpCount := 0;
// ES2026 §15.10.2: an optional call cannot be in tail position here because
Expand Down Expand Up @@ -4640,7 +4730,31 @@ procedure CompileConditional(const ACtx: TGocciaCompilationContext;
var
ElseJump, EndJump: Integer;
ConditionReg: UInt16;
ConditionBinary: TGocciaBinaryExpression;
Immediate: Int16;
begin
if AExpr.Condition is TGocciaBinaryExpression then
begin
ConditionBinary := TGocciaBinaryExpression(AExpr.Condition);
if (ConditionBinary.Operator = gttLessEqual) and
HasExactNumberProof(ACtx.Scope, ConditionBinary.Left) and
TrySignedInt16NumberLiteral(ConditionBinary.Right, Immediate) then
begin
ConditionReg := ACtx.Scope.AllocateRegister;
ACtx.CompileExpression(ConditionBinary.Left, ConditionReg);
ElseJump := EmitInstruction(ACtx,
EncodeABC(OP_JUMP_IF_NUM_NOT_LTE_IMM, ConditionReg,
UInt16(Immediate), 0), True);
ACtx.Scope.FreeRegister;
ACtx.CompileExpression(AExpr.Consequent, ADest);
EndJump := EmitJumpInstruction(ACtx, OP_JUMP, 0);
PatchJumpTarget(ACtx, ElseJump);
ACtx.CompileExpression(AExpr.Alternate, ADest);
PatchJumpTarget(ACtx, EndJump);
Exit;
end;
end;

ConditionReg := ACtx.Scope.AllocateRegister;
ACtx.CompileExpression(AExpr.Condition, ConditionReg);
ElseJump := EmitJumpInstruction(ACtx, OP_JUMP_IF_FALSE, ConditionReg);
Expand Down
Loading
Loading