mirror of
https://github.com/hanzoai/wasm2go.git
synced 2026-10-30 15:06:48 +00:00
* Back shared linear memories with anonymous mappings, not the Go heap A shared memory is allocated at its declared ceiling LENGTH once (agents deref the base pointer concurrently, so the array must never move). On the Go heap that size is a trap: the FIRST make is lazily paged, but a freed ceiling-sized span gets zeroed when the allocator reuses it — so the second instance a process builds and discards (e.g. the private builder engines behind the copy-on-write image tiers) memclrs the whole ceiling. With llama's 8 GiB ceiling that measured ~8 GB of dirty resident pages for memory nothing had touched, on every process, regardless of model size; guests with small ceilings (spidermonkey's 256 MiB) had the same behaviour hidden under the noise floor. Generated constructors for SHARED memories now call the sharedimage runtime's AllocSharedMemory: anonymous, private, zero-fill-on-demand pages outside the GC heap, fresh from the OS on every call, with a finalizer on the module preserving the heap allocation's reclaim semantics (a module kept reachable — say by a parked agent goroutine — keeps its memory). Platforms without mmap keep the historical make. The sharedimage aux files thereby become a compile-time dependency of shared-memory output, which they were always documented to be: the gcasm capture tree and the test snippet runners now include Result.AuxFiles instead of silently dropping them. Pinned by a fixture with a 1 GiB ceiling: the constructor must route through AllocSharedMemory, and building + discarding three instances must hold process RSS far under one ceiling. Co-Authored-By: Claude Fable 5 <[email protected]> * Add in-place image builders: the builder writes the image file directly The image tiers built their template by booting a throwaway instance on private memory, copying its linear memory into a heap buffer, and writing that to the image file — the same bytes crossing memory up to three times, and (before the off-heap change) the trigger for the ceiling-zeroing regression. Elide all of it: NewSharedImageInPlace / NewSharedSnapshotInPlace hand the builder a MAP_SHARED mapping of the (sparse) image file, so every page the build touches is written once, straight into the file's page cache, and IS the image. Building now dirties exactly the touched pages, and they are file-backed — the kernel can write them back and evict under pressure, which anonymous pages cannot offer. The data-segment kind keeps its contract (carry NOTHING above the segments): the builder's BSS is punched back out of the file (truncate down, re-extend sparse) before instances map it. The builder's mapping stays valid until the module is unreachable, via the same finalizer discipline as AllocSharedMemory. Constructing a module ON caller memory needs the declared initial size, so generated packages now export InitialMemoryBytes: what NewWithMemory's memSize must be when the provided memory is fresh zeros rather than an existing image. Pinned by the sharedmem_ceiling fixture: a snapshot instance inherits the builder's BSS write, a data-segment-image instance must not. Co-Authored-By: Claude Fable 5 <[email protected]> * Drop the dead make-expression path for shared memories Review feedback on the constructor emission: the make() call expression was built for every memory and then discarded whenever the shared branch (AllocSharedMemory) or the caller-memory branch replaced it, and the shared branch assigned a capArg nothing consumes. Select the memory expression in one switch instead — each variant builds only what it emits. Co-Authored-By: Claude Fable 5 <[email protected]> * Remove AllocSharedMemory: the in-place builders are the fix Review feedback: with the image tiers built in place, no default path allocates a discarded ceiling-length shared memory any more, and the paths the anonymous-mmap constructor change still claimed to protect do not hold up. The fallback engines exist precisely because mmap is unavailable or failing — an mmap-based allocator cannot be their safety net (its no-mmap variant was plain make anyway), and the debugging escape hatch is not a production path to engineer for. Embedders still building images through throwaway plain-constructor engines migrate to the in-place builders when their bundles roll forward. Generated constructors go back to make for shared memories, the capture tree no longer needs the aux files (nothing in generated constructors references them; the snippet harnesses keep them because embedder-level tests exercise the image API), and the off-heap constructor test goes away with the machinery it pinned. Co-Authored-By: Claude Fable 5 <[email protected]> --------- Co-authored-by: Claude Fable 5 <[email protected]>
291 lines
13 KiB
Plaintext
291 lines
13 KiB
Plaintext
package {{.Pkg}}
|
|
|
|
// Shared, copy-on-write linear memory.
|
|
//
|
|
// Most of what an instance of a large wasm module costs is memory that is
|
|
// IDENTICAL in every instance and, apart from a handful of pages, never written
|
|
// to. A big engine can carry tens of MB of static tables in its data segments,
|
|
// and build more heap still while it starts its runtime up. Every instance
|
|
// paying for its own copy of all that is pure waste.
|
|
//
|
|
// So build it ONCE per process and give every instance a private copy-on-write
|
|
// map of it: read-only pages stay physically shared, and only a page a guest
|
|
// actually writes becomes private.
|
|
//
|
|
// There are two kinds of image, and they differ in how much of the built
|
|
// instance they keep:
|
|
//
|
|
// - NewSharedImage keeps the data segments. Instances (NewWithMemory) re-run
|
|
// their own initialization over them, so the image must carry NOTHING above
|
|
// the segments: that tail is BSS, and it holds the start section's
|
|
// "already ran" flag and the C++ static state. Inheriting the flag makes
|
|
// the start section trap on its second run; inheriting statics that point
|
|
// into a heap the image does not carry breaks the first thing that follows
|
|
// one of those pointers. What makes even the segments survive the re-run is
|
|
// that memory.init compares before it writes (see memoryInit) — it finds
|
|
// them already in place and leaves the pages shared.
|
|
//
|
|
// - NewSharedSnapshot keeps the whole initialized instance, memory and wasm
|
|
// globals both. Instances (NewFromSnapshot) re-run NOTHING, which is what
|
|
// lets the snapshot keep the BSS and the heap that the first kind has to
|
|
// throw away. It shares much more; it also freezes whatever the build did.
|
|
//
|
|
// Measured on one large embedding (instances live in a 4 GiB container, created
|
|
// until the kernel kills the process): a hundred-odd without any image, several
|
|
// times that with the data-segment image, and an order of magnitude more with
|
|
// the snapshot, which shares the started runtime's heap on top of the segments.
|
|
//
|
|
// Where mmap is unavailable (windows, js/wasm — see sharedimage_nommap.go),
|
|
// every instance allocates and initializes its own memory, exactly as before.
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"runtime"
|
|
"sync"
|
|
)
|
|
|
|
// SharedImage is a process-wide initialized linear-memory template.
|
|
type SharedImage struct {
|
|
f *os.File // unlinked; holds the image
|
|
size uint64 // the guest's memory.size for the image
|
|
dataEnd uint32 // where the data segments stop and BSS begins
|
|
globals []uint64 // non-nil for a snapshot: the instance's wasm globals
|
|
err error // non-nil if the image could not be built
|
|
}
|
|
|
|
// ImageBuilder brings up one throwaway instance. The caller supplies it because
|
|
// only the generated package knows how to construct a Module (its constructor's
|
|
// signature depends on the wasm's imports). How far it must initialize that
|
|
// instance depends on which of the two images below is being built.
|
|
type ImageBuilder func() (m *{{.Module}}, err error)
|
|
|
|
// NewSharedImage builds the DATA-SEGMENT image once and caches it. Every later
|
|
// call returns the same one. A failure is remembered, not retried: callers then
|
|
// fall back to a private allocation, which is correct, only larger.
|
|
//
|
|
// build must run the start section (the ordinary constructor does) and nothing
|
|
// more: everything above the data segments is discarded. Instances go through
|
|
// NewWithMemory and re-run their own initialization over the shared segments.
|
|
func NewSharedImage(build ImageBuilder) *SharedImage {
|
|
sharedOnce.Do(func() { sharedImg = buildSharedImage(build, false) })
|
|
return sharedImg
|
|
}
|
|
|
|
// NewSharedSnapshot builds an image of a FULLY INITIALIZED instance — memory
|
|
// and wasm globals, everything, exactly as build() left it — and caches it the
|
|
// same way. Instances go through NewFromSnapshot, which re-runs nothing at all.
|
|
//
|
|
// This shares far more than NewSharedImage does. A runtime's initialization
|
|
// (C++ static constructors, an interpreter's own start-up) leaves megabytes of
|
|
// heap behind that are identical in every instance, and NewSharedImage cannot
|
|
// share ANY of it: it must zero the BSS, because a fresh instance re-runs the
|
|
// initialization and would trip over the previous one's leftovers. A snapshot
|
|
// re-runs nothing, so it can keep the lot.
|
|
//
|
|
// The catch is that build() must leave the instance in a state every later
|
|
// instance can start from. Anything build() does is done for all of them — an
|
|
// instance that is already holding a lock or mid-request is not a snapshot, it
|
|
// is a bug. Build it clean and take it at rest.
|
|
//
|
|
// Unlike NewSharedImage, this does NOT cache: a snapshot bakes in whatever
|
|
// build() configured, so whether two callers want the same snapshot is a
|
|
// question only the caller can answer. Build it once and hold on to it (a
|
|
// caller whose instances come in flavours will want one per flavour); building
|
|
// it per instance would be slower than not sharing at all.
|
|
func NewSharedSnapshot(build ImageBuilder) *SharedImage {
|
|
return buildSharedImage(build, true)
|
|
}
|
|
|
|
var (
|
|
sharedOnce sync.Once
|
|
sharedImg *SharedImage
|
|
|
|
sharedInPlaceOnce sync.Once
|
|
sharedInPlaceImg *SharedImage
|
|
)
|
|
|
|
// InPlaceImageBuilder brings up one throwaway instance ON the caller-provided
|
|
// memory — a MAP_SHARED mapping of the image file being built. Construct the
|
|
// module with the generated NewWithMemory (passing InitialMemoryBytes for a
|
|
// fresh, all-zero mapping) and initialize it exactly as the corresponding
|
|
// ImageBuilder would.
|
|
type InPlaceImageBuilder func(mem []byte) (m *{{.Module}}, err error)
|
|
|
|
// NewSharedImageInPlace is NewSharedImage with the copy elided: the builder
|
|
// runs directly on a shared mapping of the image file, so every page it
|
|
// touches is written once, straight into the file's page cache — there is no
|
|
// intermediate buffer, no copy, and the dirty footprint of building the image
|
|
// is exactly the pages the build touches. Cached once per process, like
|
|
// NewSharedImage.
|
|
func NewSharedImageInPlace(ceiling int, build InPlaceImageBuilder) *SharedImage {
|
|
sharedInPlaceOnce.Do(func() { sharedInPlaceImg = buildSharedImageInPlace(ceiling, build, false) })
|
|
return sharedInPlaceImg
|
|
}
|
|
|
|
// NewSharedSnapshotInPlace is NewSharedSnapshot with the copy elided — see
|
|
// NewSharedImageInPlace for the mechanics and NewSharedSnapshot for the
|
|
// snapshot contract (build it clean and take it at rest; not cached).
|
|
func NewSharedSnapshotInPlace(ceiling int, build InPlaceImageBuilder) *SharedImage {
|
|
return buildSharedImageInPlace(ceiling, build, true)
|
|
}
|
|
|
|
func buildSharedImageInPlace(ceiling int, build InPlaceImageBuilder, snapshot bool) *SharedImage {
|
|
if !mmapSupported {
|
|
return &SharedImage{err: fmt.Errorf("wasm2go: copy-on-write memory is not supported on this platform")}
|
|
}
|
|
f, err := os.CreateTemp("", "wasm2go-image-*")
|
|
if err != nil {
|
|
return &SharedImage{err: fmt.Errorf("wasm2go: shared memory image: %w", err)}
|
|
}
|
|
// Unlink now: the image must never be reachable by name. The pages live
|
|
// as long as we hold the descriptor.
|
|
_ = os.Remove(f.Name())
|
|
if err := f.Truncate(int64(ceiling)); err != nil {
|
|
f.Close()
|
|
return &SharedImage{err: fmt.Errorf("wasm2go: sizing the shared memory image: %w", err)}
|
|
}
|
|
mem, err := mmapShared(f, ceiling)
|
|
if err != nil {
|
|
f.Close()
|
|
return &SharedImage{err: err}
|
|
}
|
|
fail := func(err error) *SharedImage {
|
|
unmapMemory(mem)
|
|
f.Close()
|
|
return &SharedImage{err: err}
|
|
}
|
|
m, err := build(mem)
|
|
if err != nil {
|
|
return fail(fmt.Errorf("wasm2go: building the shared memory image: %w", err))
|
|
}
|
|
size := m.{{.MemSize}}.Load()
|
|
dataEnd := m.{{.DataEnd}}
|
|
if dataEnd == 0 || uint64(dataEnd) > size {
|
|
return fail(fmt.Errorf(
|
|
"wasm2go: shared memory image: no data segments were installed (dataEnd=%d, size=%d)",
|
|
dataEnd, size))
|
|
}
|
|
var globals []uint64
|
|
if snapshot {
|
|
globals = {{.SaveGlobals}}(m)
|
|
}
|
|
if !snapshot {
|
|
// The data-segment kind must carry NOTHING above the segments (see
|
|
// NewSharedImage). The builder's BSS is already in the file, so punch
|
|
// it back out: truncating to dataEnd drops those pages, re-extending
|
|
// restores the sparse zero tail instances expect. The builder ran
|
|
// only the start section and spawned no agents, so nothing touches
|
|
// the mapping above dataEnd after this.
|
|
if err := f.Truncate(int64(dataEnd)); err != nil {
|
|
return fail(fmt.Errorf("wasm2go: trimming the shared memory image: %w", err))
|
|
}
|
|
if err := f.Truncate(int64(ceiling)); err != nil {
|
|
return fail(fmt.Errorf("wasm2go: re-extending the shared memory image: %w", err))
|
|
}
|
|
}
|
|
// The builder's mapping is not GC-managed. A finalizer ties the unmap
|
|
// to the module's reachability: a builder that parked agent goroutines
|
|
// (they reference the module) keeps its mapping until they go.
|
|
runtime.SetFinalizer(m, func(fm *{{.Module}}) { unmapMemory(mem) })
|
|
return &SharedImage{f: f, size: size, dataEnd: dataEnd, globals: globals}
|
|
}
|
|
|
|
func buildSharedImage(build ImageBuilder, snapshot bool) *SharedImage {
|
|
if !mmapSupported {
|
|
return &SharedImage{err: fmt.Errorf("wasm2go: copy-on-write memory is not supported on this platform")}
|
|
}
|
|
m, err := build()
|
|
if err != nil {
|
|
return &SharedImage{err: fmt.Errorf("wasm2go: building the shared memory image: %w", err)}
|
|
}
|
|
size := m.{{.MemSize}}.Load()
|
|
dataEnd := m.{{.DataEnd}}
|
|
if dataEnd == 0 || uint64(dataEnd) > size {
|
|
return &SharedImage{err: fmt.Errorf(
|
|
"wasm2go: shared memory image: no data segments were installed (dataEnd=%d, size=%d)",
|
|
dataEnd, size)}
|
|
}
|
|
|
|
// How much of the built instance the image keeps is the whole difference
|
|
// between the two kinds.
|
|
//
|
|
// A snapshot keeps ALL of it, and its instances re-run nothing.
|
|
//
|
|
// A data-segment image keeps the segments and NOTHING above them, because
|
|
// its instances DO re-run their initialization: that tail is BSS, and it
|
|
// holds the start section's "already ran" flag and the C++ static state.
|
|
// Inheriting the flag makes the start section trap on its second run;
|
|
// inheriting statics that point into a heap this image does not carry
|
|
// breaks the first thing that follows one of those pointers. A fresh
|
|
// make() zeroes it for us.
|
|
keep := uint64(dataEnd)
|
|
if snapshot {
|
|
keep = size
|
|
}
|
|
img := make([]byte, size)
|
|
copy(img, m.{{.Memory}}[:keep])
|
|
|
|
var globals []uint64
|
|
if snapshot {
|
|
// Globals live outside linear memory, so the image cannot carry them —
|
|
// and the guest's TLS base and stack pointer are globals. Without them
|
|
// the memory would describe an initialized instance that the Module
|
|
// does not know it is.
|
|
globals = {{.SaveGlobals}}(m)
|
|
}
|
|
|
|
f, err := os.CreateTemp("", "wasm2go-image-*")
|
|
if err != nil {
|
|
return &SharedImage{err: fmt.Errorf("wasm2go: shared memory image: %w", err)}
|
|
}
|
|
// Unlink now: the image must never be reachable by name. The pages live
|
|
// as long as we hold the descriptor.
|
|
_ = os.Remove(f.Name())
|
|
if _, err := f.Write(img); err != nil {
|
|
f.Close()
|
|
return &SharedImage{err: fmt.Errorf("wasm2go: shared memory image: %w", err)}
|
|
}
|
|
return &SharedImage{f: f, size: size, dataEnd: dataEnd, globals: globals}
|
|
}
|
|
|
|
// Err reports why the image is unavailable, or nil. A caller that gets an
|
|
// error must build each instance's memory itself.
|
|
func (s *SharedImage) Err() error { return s.err }
|
|
|
|
// Size is the guest's memory.size for the image, in bytes: what a Module built
|
|
// on it must be told its memory already holds.
|
|
func (s *SharedImage) Size() uint64 { return s.size }
|
|
|
|
// DataEnd is where the image's data segments stop and its BSS begins. For an
|
|
// image from NewSharedImage that is also where the image stops carrying data;
|
|
// a snapshot carries everything.
|
|
func (s *SharedImage) DataEnd() uint32 { return s.dataEnd }
|
|
|
|
// Globals are the snapshotted instance's wasm globals, to be handed to
|
|
// NewFromSnapshot along with the memory. Nil for a NewSharedImage image, which
|
|
// snapshots no instance and whose instances initialize their own globals.
|
|
func (s *SharedImage) Globals() []uint64 { return s.globals }
|
|
|
|
// Memory returns a private, copy-on-write mapping of the image, ceiling bytes
|
|
// long. The instance built on it must still run the start section and the
|
|
// module's initializers: memory.init will find the segments already in place
|
|
// and write nothing, while the initializers build this instance's own BSS.
|
|
//
|
|
// The mapping is NOT Go heap — the GC will not reclaim it. Pass it to
|
|
// UnmapMemory when the instance is done.
|
|
func (s *SharedImage) Memory(ceiling int) ([]byte, error) {
|
|
if s.err != nil {
|
|
return nil, s.err
|
|
}
|
|
if uint64(ceiling) < s.size {
|
|
return nil, fmt.Errorf("wasm2go: memory ceiling %d is below the image (%d bytes)", ceiling, s.size)
|
|
}
|
|
return mmapImage(s.f, ceiling)
|
|
}
|
|
|
|
// UnmapMemory releases a mapping returned by SharedImage.Memory. Calling it on
|
|
// a slice that did not come from there is a no-op on platforms without mmap
|
|
// and undefined otherwise, so do not.
|
|
func UnmapMemory(mem []byte) { unmapMemory(mem) }
|