Skip to content
Draft
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
50 changes: 50 additions & 0 deletions gno.land/pkg/integration/testdata/method_iface_lazy_persist.txtar
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# A method value bound through an interface (G = o.Get) is lazy: it saves the
# operand and dynamically dispatches at call time. This must survive a
# persist/reload across txs — the stored bind re-reads the (mutated) embedded
# interface field at the later call and re-dispatches to the new concrete type.

gnoland start

gnokey maketx addpkg -pkgdir $WORK/lz -pkgpath gno.land/r/test/lz -gas-fee 1000000ugnot -gas-wanted 8000000 -chainid=tendermint_test test1
stdout OK!

# bind G = o.Get while s.Inner is Impl{1}; persist.
gnokey maketx call -pkgpath gno.land/r/test/lz -func Bind -gas-fee 1000000ugnot -gas-wanted 8000000 -chainid=tendermint_test test1
stdout OK!

# reassign s.Inner to Other{} (a different concrete type); persist.
gnokey maketx call -pkgpath gno.land/r/test/lz -func Mutate -gas-fee 1000000ugnot -gas-wanted 8000000 -chainid=tendermint_test test1
stdout OK!

# later tx: G is reloaded and called -> must re-dispatch to Other.Get -> 2.
gnokey maketx call -pkgpath gno.land/r/test/lz -func Call -gas-fee 1000000ugnot -gas-wanted 8000000 -chainid=tendermint_test test1
stdout '(2 int)'
stdout OK!

-- lz/gnomod.toml --
module = "gno.land/r/test/lz"
gno = "0.9"

-- lz/lz.gno --
package lz

type Impl struct{ x int }

func (i Impl) Get() int { return i.x }

type Other struct{}

func (Other) Get() int { return 2 }

type Inner interface{ Get() int }

type S struct{ Inner }

type Outer interface{ Get() int }

var s = &S{Inner: Impl{1}}
var G func() int

func Bind(cur realm) { var o Outer = s; G = o.Get }
func Mutate(cur realm) { s.Inner = Other{} }
func Call(cur realm) int { return G() }
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

env ADDPKG_GAS=4_800_000
env SETUP_GAS=3_200_000
env EXACT_GAS=2235646
env EXACT_GAS=2235788

gnoland start

Expand Down
83 changes: 83 additions & 0 deletions gnovm/adr/pr5737_nil_value_method_panic_timing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
# ADR-5737: Call-time dispatch for interface-bound method values

## Status

Proposed. **Hard-fork-class** (changes a persisted value's wire format).

## Context

In Go a method value formed through an interface (`g := i.M`, `defer i.M()`)
saves the operand at formation and materializes the concrete method + receiver
*inside the call*. GnoVM resolved it eagerly at bind, diverging from Go (vs
go1.25) on: nil-panic timing, value snapshot, embedded-method promotion, field
re-read through a boxed pointer, dynamic re-dispatch when an embedded interface
field is reassigned, and a VM crash for a nil embedded pointer-receiver (c5).
These last facets need the operand's *live* value at the call, so earlier narrow
fixes (a `nilReceiverPanic` flag, a `viaInterface` gate) couldn't reach them.

## Decision

Bind interface method values lazily; resolve at the call.

- `BoundMethodValue` gains `Method Name`; `Func == nil` (operand in `Receiver`)
marks a lazy bind (`IsLazy()`).
- **Bind** (`VPInterface`): just save the operand — `&BoundMethodValue{Func: nil,
Receiver: *dtv, Method: name}`, no deref/dispatch. Concrete/pointer binds
(`pt.M`, `t.M`) are untouched.
- **Call** (`resolveLazyBound`, from `doOpPrecall` and the deferred call): walk
the operand's *current* value (`findEmbeddedFieldType` + `resolveInterfaceTrail`)
to the concrete method + receiver — the deref, embedded-field walk, copy, nil
panic, field re-read and re-dispatch all happen here. A nested embedded
interface yields another lazy bind; the loop unwraps it.
- **Defers** carry the callable on `Defer.Callable` (`*FuncValue` or
`*BoundMethodValue`), type-switched at the deferred call like `doOpPrecall`.
- **nil derefs** are raised by the walk (`GetPointerToFromTV`) via the `Run`
loop's panic path; a `VPSubrefField` nil guard makes c5 panic cleanly.
- **Reload**: `resolveLazyBound` fills the operand (`fillValueTV`) first, so a
persisted bind re-reads live state — what makes re-read / re-dispatch correct
across reload.
- `Func == nil` consumers audited (`IsCrossing`, `String`, GC, realm
persist/copy/fill, `GetShallowSize`).

## Consequences

- **Hard fork**: `Method` is proto field 4 (`pb3_gen` regenerated);
`_allocBoundMethodValue` 200→216. Persisted bound methods change bytes →
different IAVL hashes / gas (`stdlib_restart_compare` pin moved). Ship only on
fresh genesis or a coordinated upgrade (like ADR-5544); old state still decodes
(new field defaults empty).
- **Behaviour**: interface method values match Go on every axis, across reload;
concrete/pointer binds unchanged.
- **Performance / gas.** Concrete path unchanged (`OpCPUPrecallBoundMethod` 199
still valid). The dispatch *walk* moved from bind to call, so the two consts
were re-fit together (ratio-scaled; reference HW unavailable — see the
`TODO(calibration)` on each):
- bind `OpCPUSelectorInterface` 751 → **276** — the eager selector walked the
trail here (~751); the lazy bind only does the method lookup + lazy-bind
alloc (~140 ns ≈ 276), so leaving it at 751 would double-charge the walk.
- call `OpCPULazyBoundResolve` **529** (new) — the walk, now at call time.
Net ≈ +54 gas per interface method call (the genuine extra: a re-lookup +
throwaway bound-method alloc), not the ~480 an un-reduced base would
over-charge. `stdlib_restart_compare` pin → 2235788. A lean walk avoiding the
throwaway alloc was rejected — it would duplicate `GetPointerToFromTV`'s
dispatch/nil machinery on consensus code; the reuse form stays the single
source of truth.

## Follow-ups

- **Calibration, before the fork ships** (both consts are ratio-scaled — the
reference HW was unavailable): re-measure `OpCPUSelectorInterface` (276) and
`OpCPULazyBoundResolve` (529) on the gas-table reference HW; and consider a
per-trail-step slope on the lazy resolve so deep/nested dispatch is metered
per hop, matching the eager path (currently flat, a bounded under-charge).
- Orthogonal, pre-existing (not caused or addressed here): interface method
*expressions* `I.M` rejected at preprocess (#5787); a method call on a *nil
interface* panics uncatchably (#5850).
- Cache a value-operand lazy bind's resolution (it is stable) to skip the
re-walk on repeated calls — **consensus-affecting, not just perf**: skipping
the walk drops its gas charge (`OpCPULazyBoundResolve` + alloc-gas), and
mutating a persisted bmv in place rewrites its bytes (merkle). So it is itself
a hard-fork change — fold into this fork's window or skip; it cannot ship as a
later rolling upgrade. Marginal benefit (only the call-a-stored-value-operand-
method-value-N-times pattern; pointer operands must never be cached, they
re-read live state), so deferred.
14 changes: 9 additions & 5 deletions gnovm/pkg/gnolang/alloc.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ const (
_allocSliceValue = 40 // unsafe.Sizeof(SliceValue{})
_allocFuncValue = 352 // unsafe.Sizeof(FuncValue{})
_allocMapValue = 168 // unsafe.Sizeof(MapValue{})
_allocBoundMethodValue = 200 // unsafe.Sizeof(BoundMethodValue{})
_allocBoundMethodValue = 216 // unsafe.Sizeof(BoundMethodValue{})
_allocBlock = 528 // unsafe.Sizeof(Block{})
_allocPackageValue = 296 // unsafe.Sizeof(PackageValue{}) — interrealm v2 +24 bytes for PkgID field (Hashlet + alignment)
_allocHeapItemValue = 192 // unsafe.Sizeof(HeapItemValue{})
Expand Down Expand Up @@ -744,10 +744,14 @@ func (mv *MapValue) GetShallowSize() int64 {
}

func (bmv *BoundMethodValue) GetShallowSize() int64 {
// skip .uverse
if bmv.Func.PkgPath == ".uverse" {
return 0
}
// A bound method is a runtime composite (Func + Receiver) allocated where it
// is formed; unlike PackageValue/Block/FuncValue it has no PkgPath of its
// own, so its size must not be keyed on the wrapped Func's package. Its
// shallow size is always allocBoundMethod — whether it counts toward a realm
// is decided by GC/persistence reachability. (Previously this returned 0 when
// Func.PkgPath == ".uverse", under-counting a real, dynamically-allocated
// wrapper of a uverse method — e.g. `g := addr.String`, a concrete
// value-method on the uverse `address` type — by allocBoundMethod.)
return allocBoundMethod
}

Expand Down
19 changes: 19 additions & 0 deletions gnovm/pkg/gnolang/alloc_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,3 +71,22 @@ func TestBlockGetShallowSize_WithRefNodeSource(t *testing.T) {
refNodeSize, expectedRefNodeSize, normalSize, allocRefNode)
}
}

func TestBoundMethodValueGetShallowSize(t *testing.T) {
// A bound method's shallow size is always allocBoundMethod, regardless of
// the wrapped Func's package (a bmv has no PkgPath of its own) — including a
// uverse-method wrapper (previously wrongly sized 0) and a lazy interface
// bind whose Func is nil.
for _, tc := range []struct {
name string
bmv *BoundMethodValue
}{
{"uverse method wrapper", &BoundMethodValue{Func: &FuncValue{PkgPath: ".uverse"}}},
{"lazy interface bind", &BoundMethodValue{Func: nil}},
{"normal method", &BoundMethodValue{Func: &FuncValue{PkgPath: "gno.land/r/test/foo"}}},
} {
if got := tc.bmv.GetShallowSize(); got != allocBoundMethod {
t.Errorf("%s: GetShallowSize() = %d, want %d", tc.name, got, allocBoundMethod)
}
}
}
41 changes: 37 additions & 4 deletions gnovm/pkg/gnolang/bench_ops_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4658,6 +4658,39 @@ func BenchmarkOpPrecall_BoundMethod(b *testing.B) {
reportBenchops(b)
}

// BenchmarkOpPrecall_BoundMethod_Lazy measures the interface-dispatched (lazy)
// bound-method path: Func==nil, so doOpPrecall resolves the concrete method +
// receiver at call time (resolveLazyBound walks the saved operand). This is the
// cost of every interface method call (i.M()); compare with the concrete
// BenchmarkOpPrecall_BoundMethod above. Value operand (re-walks every call).
func BenchmarkOpPrecall_BoundMethod_Lazy(b *testing.B) {
m := benchMachine()
defer m.Release()

ft, _, dt, sv := benchMethodSetup(m.Alloc)
bmv := &BoundMethodValue{
Func: nil, // lazy: resolved at call
Receiver: TypedValue{T: dt, V: sv}, // saved operand (value)
Method: "DoStuff",
}
cx := &CallExpr{NumArgs: 1}

bm.InitMeasure()
bm.BeginOpCode(bmSetup)
for range b.N {
m.PushValue(TypedValue{T: ft, V: bmv}) // bound method
m.PushValue(TypedValue{T: IntType, N: i2n(1)}) // arg
m.PushExpr(cx)
bm.SwitchOpCode(bmTarget)
m.doOpPrecall()
bm.SwitchOpCode(bmSetup)
m.Ops = m.Ops[:0]
m.Frames = m.Frames[:0]
m.Values = m.Values[:0]
}
reportBenchops(b)
}

// --- doOpCall with receiver (method call) ---

func BenchmarkOpCall_Method(b *testing.B) {
Expand Down Expand Up @@ -4887,10 +4920,10 @@ func benchOpReturnCallDefers(b *testing.B, nDefers int) {
cfr := m.LastFrame()
for range nDefers {
cfr.PushDefer(Defer{
Func: fv,
Args: []TypedValue{},
Source: &DeferStmt{Call: CallExpr{NumArgs: 0, Args: []Expr{}}},
Parent: &Block{},
Callable: fv,
Args: []TypedValue{},
Source: &DeferStmt{Call: CallExpr{NumArgs: 0, Args: []Expr{}}},
Parent: &Block{},
})
}
m.PushOp(OpReturnCallDefers) // will be consumed by the op
Expand Down
13 changes: 8 additions & 5 deletions gnovm/pkg/gnolang/frame.go
Original file line number Diff line number Diff line change
Expand Up @@ -110,11 +110,14 @@ func (fr *Frame) SetIsRevive() {
// Defer

type Defer struct {
Func *FuncValue // function value
IsBoundMethod bool // if true, args[0] is receiver
Args []TypedValue // arguments
Source *DeferStmt // source
Parent *Block
// Callable is the deferred callable, captured at the defer statement: a
// *FuncValue, a *BoundMethodValue (possibly a lazy interface bind, resolved
// at the call), or nil for a deferred nil func. doOpReturnCallDefers
// type-switches on it, mirroring doOpPrecall.
Callable Value
Args []TypedValue // arguments (Args[0] is the receiver for a bound method)
Source *DeferStmt // source
Parent *Block
}

type StacktraceCall struct {
Expand Down
6 changes: 3 additions & 3 deletions gnovm/pkg/gnolang/garbage_collector.go
Original file line number Diff line number Diff line change
Expand Up @@ -477,9 +477,9 @@ func (fr *Frame) Visit(alloc *Allocator, vis Visitor) (stop bool) {

// vis defer
for _, dfr := range fr.Defers {
// visit dfr.Func
if dfr.Func != nil {
stop = vis(dfr.Func)
// visit dfr.Callable (the deferred func / bound method)
if dfr.Callable != nil {
stop = vis(dfr.Callable)
}
if stop {
return
Expand Down
1 change: 1 addition & 0 deletions gnovm/pkg/gnolang/gnolang.proto
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ message BoundMethodValue {
ObjectInfo object_info = 1 [json_name = "ObjectInfo"];
FuncValue func = 2 [json_name = "Func"];
TypedValue receiver = 3 [json_name = "Receiver"];
string method = 4 [json_name = "Method"];
}

message TypeValue {
Expand Down
27 changes: 19 additions & 8 deletions gnovm/pkg/gnolang/machine.go
Original file line number Diff line number Diff line change
Expand Up @@ -1390,13 +1390,24 @@ const (
// See gnovm/cmd/calibrate/op_bench_analysis.txt for full derivation.

/* Control operators */
OpCPUInvalid = 1
OpCPUHalt = 1
OpCPUNoop = 1
OpCPUExec = 130
OpCPUPrecallTypeConv = 72 // type conversion
OpCPUPrecallFunc = 178 // function call
OpCPUPrecallBoundMethod = 199 // bound method call
OpCPUInvalid = 1
OpCPUHalt = 1
OpCPUNoop = 1
OpCPUExec = 130
OpCPUPrecallTypeConv = 72 // type conversion
OpCPUPrecallFunc = 178 // function call
OpCPUPrecallBoundMethod = 199 // bound method call
// OpCPULazyBoundResolve is the extra CPU on top of OpCPUPrecallBoundMethod
// when an interface method value is resolved at the call (resolveLazyBound
// walk). Flat: deep embedding / nested interfaces under-charge the CPU walk
// (unlike eager concrete dispatch, which decomposes into per-hop field
// selectors and so meters depth), but that is bounded by the static type
// structure (no DoS) and the per-iteration allocation is auto-metered.
// TODO(calibration), before the fork ships: (1) re-measure on the gas-table
// reference HW — 529 is ratio-scaled (lazy-vs-concrete bench delta against
// OpCPUPrecallBoundMethod's known value); (2) consider a per-trail-step slope
// so deep/nested dispatch is metered per hop, matching the eager path.
OpCPULazyBoundResolve = 529
OpCPUEnterCrossing = 520 // XXX arbitrary, not yet benchmarked
OpCPUCall = 310 // base for 0 params, 0 captures (340.8ns - 31 alloc)
OpCPUCallNativeBody = 2205 // XXX arbitrary, not properly benchmarked
Expand Down Expand Up @@ -1457,7 +1468,7 @@ const (
OpCPUIndex2 = 1014
OpCPUSelectorField = 101 // flat; field access (1-1000 fields all ~100ns)
OpCPUSelectorVPValMethod = 635 // flat; all method paths: Val/DerefVal/Ptr/DerefPtr (684ns - 52 alloc)
OpCPUSelectorInterface = 751 // base; VPInterface, per-method added in handler
OpCPUSelectorInterface = 276 // base; VPInterface, per-method added in handler. Was 751 (eager dispatch walked the trail here); the walk moved to call time (OpCPULazyBoundResolve), so the bind only does the method lookup + lazy-bind alloc now. TODO(calibration): ratio-scaled re-fit (~140ns pure), re-measure on the reference HW with OpCPULazyBoundResolve before the fork ships.
OpCPUSlice = 264 // max(array=258, slice=211, byte=264, 3idx=236, string=219)
OpCPUStar = 102
OpCPURef = 210
Expand Down
Loading
Loading