diff --git a/pkg/rt/generated.sums b/pkg/rt/generated.sums index 329d44c95..43f6e69ea 100644 --- a/pkg/rt/generated.sums +++ b/pkg/rt/generated.sums @@ -2,4 +2,4 @@ # Content digest of all .lg + lgbgen sources that feed the .lgb # bundle and the lowered Go tree. The genmanifest staleness test # fails if this no longer matches the sources on disk. -6a4d922936d3d34c0802597362f4a1e71ecaa191d15bd5bbfcb62f713482f107 +61a4a787d7fe05a7bd238d1724aeb998bfa11809c8c9412c0d8263fdc623104f diff --git a/pkg/rt/native_prims.go b/pkg/rt/native_prims.go index f1a09a700..7fd7e69a1 100644 --- a/pkg/rt/native_prims.go +++ b/pkg/rt/native_prims.go @@ -587,6 +587,14 @@ func Some(ec *vm.ExecContext, pred vm.Value, coll vm.Value) (vm.Value, error) { if ls, ok := seq.(*vm.LazySeq); ok { seq = ls.Resolve() } + // Resolve a bytecode predicate once and reuse one frame for the whole + // walk: per-element ec.Invoke would pay resolution, frame-pool traffic, + // and frame init on every element. Non-bytecode/variadic predicates fall + // back to the generic invoke below. + pc := ec.PrepareCall(f, 1) + if pc != nil { + defer pc.Release() + } fargs := []vm.Value{nil} for seq != nil { // Chunked fast path: walk whole chunks via Nth to avoid per-element @@ -595,8 +603,14 @@ func Some(ec *vm.ExecContext, pred vm.Value, coll vm.Value) (vm.Value, error) { c := cs.ChunkedFirst() n := c.ChunkCount() for i := 0; i < n; i++ { - fargs[0] = c.Nth(i) - v, err := ec.Invoke(f, fargs) + var v vm.Value + var err error + if pc != nil { + v, err = pc.Call1(c.Nth(i)) + } else { + fargs[0] = c.Nth(i) + v, err = ec.Invoke(f, fargs) + } if err != nil { return vm.NIL, err } @@ -607,8 +621,14 @@ func Some(ec *vm.ExecContext, pred vm.Value, coll vm.Value) (vm.Value, error) { seq = cs.ChunkedNext() continue } - fargs[0] = seq.First() - v, err := ec.Invoke(f, fargs) + var v vm.Value + var err error + if pc != nil { + v, err = pc.Call1(seq.First()) + } else { + fargs[0] = seq.First() + v, err = ec.Invoke(f, fargs) + } if err != nil { return vm.NIL, err } diff --git a/pkg/rt/some_prepared_test.go b/pkg/rt/some_prepared_test.go new file mode 100644 index 000000000..271d36ff2 --- /dev/null +++ b/pkg/rt/some_prepared_test.go @@ -0,0 +1,73 @@ +package rt + +import ( + "testing" + + "github.com/nooga/let-go/pkg/vm" +) + +// testGreaterThan5Pred builds a unary bytecode fn computing (< 5 x), so Some +// exercises the PreparedCall path with a real dispatch per element. +func testGreaterThan5Pred() *vm.Func { + consts := vm.NewConsts() + chunk := vm.NewCodeChunk(consts) + chunk.Append(vm.OP_LOAD_CONST) + chunk.Append32(consts.Intern(vm.Int(5))) + chunk.Append(vm.OP_LOAD_ARG) + chunk.Append32(0) + chunk.Append(vm.OP_LT) + chunk.Append(vm.OP_RETURN) + chunk.SetMaxStack(4) + return vm.MakeFunc(1, false, chunk) +} + +func TestSomeBytecodePredOverChunkedAndLinearSeqs(t *testing.T) { + pred := testGreaterThan5Pred() + + // Chunked source (Range) drives the chunk-walking arm. + v, err := Some(vm.RootExecContext, pred, vm.NewRange(vm.Int(0), vm.Int(10), vm.Int(1))) + if err != nil { + t.Fatal(err) + } + if v != vm.TRUE { + t.Fatalf("chunked walk: got %v", v) + } + + // No match returns nil. + v, err = Some(vm.RootExecContext, pred, vm.NewRange(vm.Int(0), vm.Int(5), vm.Int(1))) + if err != nil { + t.Fatal(err) + } + if v != vm.NIL { + t.Fatalf("chunked no-match: got %v", v) + } + + // Linear source (List) drives the First/Next arm. + list, err := vm.ListType.Box([]vm.Value{vm.Int(1), vm.Int(9), vm.Int(2)}) + if err != nil { + t.Fatal(err) + } + v, err = Some(vm.RootExecContext, pred, list) + if err != nil { + t.Fatal(err) + } + if v != vm.TRUE { + t.Fatalf("linear walk: got %v", v) + } +} + +func TestSomeNativePredStillFallsBack(t *testing.T) { + native, err := vm.NativeFnType.Wrap(func(args []vm.Value) (vm.Value, error) { + return vm.Boolean(args[0] == vm.Int(2)), nil + }) + if err != nil { + t.Fatal(err) + } + v, err := Some(vm.RootExecContext, native, vm.NewRange(vm.Int(0), vm.Int(4), vm.Int(1))) + if err != nil { + t.Fatal(err) + } + if v != vm.TRUE { + t.Fatalf("native pred fallback: got %v", v) + } +} diff --git a/pkg/vm/prepared_call.go b/pkg/vm/prepared_call.go new file mode 100644 index 000000000..758dfa8f3 --- /dev/null +++ b/pkg/vm/prepared_call.go @@ -0,0 +1,98 @@ +/* + * Copyright (c) 2021 Marcin Gasperowicz + * SPDX-License-Identifier: MIT + */ + +package vm + +// PreparedCall caches the resolution of a fixed-arity bytecode callable and +// reuses one frame across many calls from native code. It exists for +// per-element callback loops (reduce/some/filter and friends): resolution, +// frame-pool traffic, and frame initialization are per-call-site constants, +// so paying them once per seq operation instead of once per element removes +// most of the host->VM entry cost. +// +// A PreparedCall is single-owner: it must be used by one native call +// activation and never shared or called reentrantly. Nested uses of the same +// lg function each prepare their own. +type PreparedCall struct { + fn *Func + closedOvers []Value + ec *ExecContext + frame *Frame + args []Value + constsc int + stackLen int +} + +// PrepareCall resolves fn for repeated arity-n invocation. It returns nil for +// targets that are not plain fixed-arity bytecode callables (variadic, native, +// protocol, ...), and for arities without a matching CallN method; callers +// fall back to ec.Invoke. +func (ec *ExecContext) PrepareCall(fn Fn, arity int) *PreparedCall { + // Only arities a CallN entry point can fully populate are prepared — + // Call1 is the only one today. Widen this as CallN methods land. + if arity != 1 { + return nil + } + args := make([]Value, arity) + target, direct, err := resolveBytecodeCall(fn, args) + if err != nil || !direct || target.fn.isVariadric { + return nil + } + ec = ec.orRoot() + f := NewFrame(target.fn.chunk, nil) + f.closedOvers = target.closedOvers + f.ec = ec + return &PreparedCall{ + fn: target.fn, + closedOvers: target.closedOvers, + ec: ec, + frame: f, + args: args, + constsc: target.fn.chunk.consts.count(), + stackLen: max(target.fn.chunk.maxStack, 4), + } +} + +// Call1 invokes the prepared unary callable. +func (p *PreparedCall) Call1(a Value) (Value, error) { + p.args[0] = a + return p.call() +} + +// call resets the owned frame and runs it. The reset must cover code/consts/ +// closedOvers, not just args/ip/sp: a tail call in the callee body rebinds the +// frame to the tail target via installBytecodeCall, and an error unwind can +// leave stale handlers. +func (p *PreparedCall) call() (Value, error) { + f := p.frame + f.args = p.args + f.argc = len(p.args) + f.closedOvers = p.closedOvers + f.code = p.fn.chunk + f.consts = f.code.consts + f.constsc = p.constsc + f.ip = 0 + f.sp = 0 + f.debug = false + f.parent = nil + f.stack = f.stack[:p.stackLen] + if len(f.handlers) > 0 { + f.handlers = f.handlers[:0] + } + state := frameRunState{root: f, current: f} + if allocAttrEnabled { + return runChainProtected(&state) + } + return runChain(&state) +} + +// Release returns the owned frame to the pool. The PreparedCall must not be +// used afterwards. +func (p *PreparedCall) Release() { + if p.frame != nil { + ReleaseFrame(p.frame) + p.frame = nil + } +} diff --git a/pkg/vm/prepared_call_test.go b/pkg/vm/prepared_call_test.go new file mode 100644 index 000000000..27b6200da --- /dev/null +++ b/pkg/vm/prepared_call_test.go @@ -0,0 +1,177 @@ +package vm + +import ( + "testing" +) + +// testBranchTailOrReturnFn builds a unary fn: truthy arg -> return the arg, +// falsy arg -> tail-call tail (zero args). The tail call exercises +// installBytecodeCall's same-frame rebind on the PreparedCall's owned frame. +func testBranchTailOrReturnFn(consts *Consts, tail Fn) *Func { + chunk := NewCodeChunk(consts) + chunk.Append(OP_LOAD_ARG) + chunk.Append32(0) + chunk.Append(OP_BRANCH_TRUE) + chunk.Append32(7) // -> the LOAD_ARG/RETURN pair below + chunk.Append(OP_LOAD_CONST) + chunk.Append32(consts.Intern(tail)) + chunk.Append(OP_TAIL_CALL) + chunk.Append32(0) + chunk.Append(OP_RETURN) + chunk.Append(OP_LOAD_ARG) + chunk.Append32(0) + chunk.Append(OP_RETURN) + chunk.SetMaxStack(4) + return MakeFunc(1, false, chunk) +} + +func testConstFn(consts *Consts, v Value) *Func { + chunk := NewCodeChunk(consts) + chunk.Append(OP_LOAD_CONST) + chunk.Append32(consts.Intern(v)) + chunk.Append(OP_RETURN) + chunk.SetMaxStack(4) + return MakeFunc(0, false, chunk) +} + +func TestPreparedCallRepeatedInvocation(t *testing.T) { + consts := NewConsts() + identity := testBytecodeFnReturningArg(consts, 1) + pc := RootExecContext.PrepareCall(identity, 1) + if pc == nil { + t.Fatal("PrepareCall rejected a plain fixed-arity Func") + } + defer pc.Release() + for i := 0; i < 3; i++ { + v, err := pc.Call1(Int(i)) + if err != nil { + t.Fatal(err) + } + if v != Int(i) { + t.Fatalf("call %d returned %v", i, v) + } + } +} + +func TestPreparedCallPreservesClosureCaptures(t *testing.T) { + consts := NewConsts() + chunk := NewCodeChunk(consts) + chunk.Append(OP_LOAD_CLOSEDOVER) + chunk.Append32(0) + chunk.Append(OP_RETURN) + chunk.SetMaxStack(4) + closure := &Closure{fn: MakeFunc(1, false, chunk), closedOvers: []Value{Int(99)}} + + pc := RootExecContext.PrepareCall(closure, 1) + if pc == nil { + t.Fatal("PrepareCall rejected a closure over a fixed-arity Func") + } + defer pc.Release() + for i := 0; i < 2; i++ { + v, err := pc.Call1(NIL) + if err != nil { + t.Fatal(err) + } + if v != Int(99) { + t.Fatalf("call %d lost the capture: %v", i, v) + } + } +} + +// A tail call in the callee rebinds the owned frame's code via +// installBytecodeCall; the next prepared call must run the original callable +// again, not the tail target. +func TestPreparedCallResetsAfterTailCallRebind(t *testing.T) { + consts := NewConsts() + tail := testConstFn(consts, Int(42)) + fn := testBranchTailOrReturnFn(consts, tail) + + pc := RootExecContext.PrepareCall(fn, 1) + if pc == nil { + t.Fatal("PrepareCall rejected a plain fixed-arity Func") + } + defer pc.Release() + + v, err := pc.Call1(NIL) // falsy -> tail-calls into testConstFn + if err != nil { + t.Fatal(err) + } + if v != Int(42) { + t.Fatalf("tail-call path returned %v", v) + } + v, err = pc.Call1(Int(7)) // truthy -> must run fn, not the rebound tail target + if err != nil { + t.Fatal(err) + } + if v != Int(7) { + t.Fatalf("prepared frame stayed rebound to the tail target: %v", v) + } +} + +// An error unwind must not poison the owned frame for subsequent calls. +func TestPreparedCallReusableAfterError(t *testing.T) { + consts := NewConsts() + chunk := NewCodeChunk(consts) + chunk.Append(OP_LOAD_ARG) + chunk.Append32(0) + chunk.Append(OP_BRANCH_TRUE) + chunk.Append32(7) // truthy -> throw the arg + chunk.Append(OP_LOAD_ARG) + chunk.Append32(0) + chunk.Append(OP_RETURN) + chunk.Append(OP_NOOP) + chunk.Append(OP_NOOP) + chunk.Append(OP_LOAD_ARG) + chunk.Append32(0) + chunk.Append(OP_THROW) + chunk.SetMaxStack(4) + fn := MakeFunc(1, false, chunk) + + pc := RootExecContext.PrepareCall(fn, 1) + if pc == nil { + t.Fatal("PrepareCall rejected a plain fixed-arity Func") + } + defer pc.Release() + + if _, err := pc.Call1(Int(1)); err == nil { + t.Fatal("throwing call did not surface an error") + } + v, err := pc.Call1(FALSE) + if err != nil { + t.Fatal(err) + } + if v != FALSE { + t.Fatalf("call after error returned %v", v) + } +} + +func TestPreparedCallRejectsNonBytecodeAndVariadicTargets(t *testing.T) { + native, err := NativeFnType.Wrap(func(_ []Value) (Value, error) { return NIL, nil }) + if err != nil { + t.Fatal(err) + } + if pc := RootExecContext.PrepareCall(native.(Fn), 1); pc != nil { + t.Fatal("PrepareCall accepted a NativeFn") + } + + consts := NewConsts() + variadic := MakeFunc(1, true, NewCodeChunk(consts)) + if pc := RootExecContext.PrepareCall(variadic, 1); pc != nil { + t.Fatal("PrepareCall accepted a variadic Func") + } +} + +// Arities without a matching CallN method must not prepare: Call1 could not +// populate their argument slots (arity 0 would panic, arity 2+ would pass Go +// nil interfaces into bytecode). +func TestPreparedCallRejectsUnsupportedArities(t *testing.T) { + consts := NewConsts() + nullary := testConstFn(consts, Int(1)) + if pc := RootExecContext.PrepareCall(nullary, 0); pc != nil { + t.Fatal("PrepareCall accepted arity 0 with no Call0 entry point") + } + binary := testBytecodeFnReturningArg(consts, 2) + if pc := RootExecContext.PrepareCall(binary, 2); pc != nil { + t.Fatal("PrepareCall accepted arity 2 with no Call2 entry point") + } +}