Skip to content

Refund VM linear-memory budget#1712

Open
jayz22 wants to merge 3 commits into
stellar:mainfrom
jayz22:vm-mem-budget-refund-clean
Open

Refund VM linear-memory budget#1712
jayz22 wants to merge 3 commits into
stellar:mainfrom
jayz22:vm-mem-budget-refund-clean

Conversation

@jayz22

@jayz22 jayz22 commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Resolves #1707

What

  • A new VmStoreData type that tracks linear-memory allocation per VM.
  • A new budget function that refunds memory.
  • A new scoped function Vm::execute_with_mem_refund that runs a VM and refunds its memory when the scope exits, applied to every production path that instantiates a VM - contract invocation, the protocol-version probe, and wasm upload validation. As a result the memory cost of all of these is reduced.

Why

The memory budget has always measured cumulative memory and never credits back transient memory that is later freed. For most invocations that is a fine proxy. But each VM instantiation charges ~1 MiB of linear memory (the toolchain-default shadow stack) that is freed at teardown yet never refunded, so deep or wide cross-contract calling diverges from peak and overcharges contracts for that memory - making contract interactions needlessly expensive and prone to hitting the memory limit.

The tracker lives in VmStoreData which is the object wasmi owns (originally just a Host handle for the metering callback), per-VM. The refund happens when the VM is dropped, guarded automatically by the scoped function (like a Drop impl, but fallible).

Copilot AI review requested due to automatic review settings July 24, 2026 18:20

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot wasn't able to review this pull request because it exceeds the maximum number of files (300). Try reducing the number of changed files and requesting a review from Copilot again.

Wrap the wasmi Store's user-data type in a dedicated VmStoreData struct
(currently just holding the Host handle) instead of using Host directly,
and move the ResourceLimiter impl from Host onto VmStoreData. Thread the
Host -> VmStoreData type change through every Store/Linker/Caller/VmCaller/
StoreContextMut/VmUserState use site.

Pure refactor with no behavior change: this gives each VM store its own
data slot, which a subsequent change uses to track per-VM linear memory.
@jayz22 jayz22 changed the title Refund transient VM linear-memory budget at VM teardown Refund VM linear-memory budget Jul 24, 2026
@jayz22
jayz22 force-pushed the vm-mem-budget-refund-clean branch 2 times, most recently from f3826e9 to bd88b89 Compare July 24, 2026 18:40
jayz22 added 2 commits July 24, 2026 14:57
The memory budget has always measured cumulative memory -- the sum of all
allocations -- and does not credit back transient memory that is later
freed. For most invocations cumulative memory stays close to peak live
memory, so this is a fine proxy. But each VM instantiation charges the
contract's WASM linear memory (~1 MiB with default toolchain settings),
which is freed at VM teardown; under deep or wide cross-contract calling,
many VMs are created and destroyed and cumulative memory diverges sharply
from peak, significantly overcharging for memory never held at once.

Track the mem_bytes charged for each VM store's linear memory in its
VmStoreData (initial allocation + runtime grows), and refund it to the
budget's memory dimension when the store -- and thus its linear memory --
is freed at Drop. Scope is VM linear memory only; CPU is unaffected. The
refund credits only the real budget total, never the shadow budget.

Adds Budget::refund_mem and BudgetDimension::refund, and updates the
affected metering assertions.
Mechanical regeneration (UPDATE_OBSERVATIONS=1) reflecting the reduced net
memory now that transient VM linear memory is refunded at teardown. No hand
edits.
@jayz22
jayz22 force-pushed the vm-mem-budget-refund-clean branch from bd88b89 to c82d5d5 Compare July 24, 2026 18:57
.host
.as_budget()
.is_in_shadow_mode()
.map_err(|_| errors::MemoryError::OutOfBoundsGrowth)?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are there any other error codes besides OutOfBoundsGrowth? This will hide an invariant error behind a budget error, which is not desirable.

Also, this check looks redundant to me TBH, I don't think this should care which budget mode is being used.

@@ -1396,7 +1396,7 @@ mod test {
expect![[r#"
InvocationResources {
instructions: 320615,
mem_bytes: 1135822,
mem_bytes: 87230,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we need to block the PR on this, but this change is quite misleading (we have hidden the invocation completely).
I think we should report max mem_bytes for any invocation. That might be slightly confusing with sub-invocations, but that's still better than completely removing most of the memory observations because VM frame gets popped when the invocation ends.

@@ -1874,7 +1874,7 @@ mod test {
),
data: String(
ScString(
StringM(invocation resource limits are exceeded: instructions: 1788420 > 10, memory bytes: 1163561 > 20, contract events size bytes: 800 > 50, contract data key 'ContractData(LedgerKeyContractData { contract: Contract(ContractId(Hash(2e0ff7a55065f3a896723a964c3d9862a4722bfc77229fe4875f390ef2a0027e))), key: LedgerKeyContractInstance, durability: Persistent })' size: 48 > 25, contract data entry with key 'ContractData(LedgerKeyContractData { contract: Contract(ContractId(Hash(2e0ff7a55065f3a896723a964c3d9862a4722bfc77229fe4875f390ef2a0027e))), key: LedgerKeyContractInstance, durability: Persistent })' size: 104 > 35, contract code entry with key 'ContractCode(LedgerKeyContractCode { hash: Hash(1a2ee5a89dd2162a20e716b3e2de70a2d662480a9565350378661871bcf8e398) })' size: 1840 > 45),
StringM(invocation resource limits are exceeded: instructions: 1788420 > 10, memory bytes: 114969 > 20, contract events size bytes: 800 > 50, contract data key 'ContractData(LedgerKeyContractData { contract: Contract(ContractId(Hash(2e0ff7a55065f3a896723a964c3d9862a4722bfc77229fe4875f390ef2a0027e))), key: LedgerKeyContractInstance, durability: Persistent })' size: 48 > 25, contract data entry with key 'ContractData(LedgerKeyContractData { contract: Contract(ContractId(Hash(2e0ff7a55065f3a896723a964c3d9862a4722bfc77229fe4875f390ef2a0027e))), key: LedgerKeyContractInstance, durability: Persistent })' size: 104 > 35, contract code entry with key 'ContractCode(LedgerKeyContractCode { hash: Hash(1a2ee5a89dd2162a20e716b3e2de70a2d662480a9565350378661871bcf8e398) })' size: 1840 > 45),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a more concerning side-effect of the issue I've mentioned above: we use the invocation metering for the resource limit enforcement, so not using the max memory results in wrong checks. I think the prod memory reporting path is also broken because we don't track the max memory anywhere. Maybe that's worth addressing in this PR after all.

/// refunded. The only refund site is [Vm::execute_with_mem_refund], the
/// canonical way to use a VM (see the rationale there); a VM used outside
/// that scope (tests and benches) keeps its memory charged.
pub struct VmStoreData {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure what to think of this change. It seems to create a weird coupling between the host and budget charges, but host itself also manages the budget. Compared to the sketch that I did with Claude, the budget itself is affected much less (though you're missing the max memory tracking that we still need), but this is much more invasive change scope-wise.
So I came to think that it would make more sense to keep the refund stack in the host/budget in some way. I'm not sure if my sketch has the best version of this, maybe we could try tying the refunds to a Frame (that's also going to be more scalable if in the future we decide to also drop, say, host objects allocated by a given frame). If that's inconvenient, I think maintaining a separate stack is fine as well - we do that for a few different per-frame entities (like auth managers).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Refund transient VM linear-memory budget at VM teardown

3 participants