Files
c71ddafe1b Back shared linear memories with anonymous mappings, not the Go heap (#56)
* 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]>
2026-08-27 10:53:10 +09:00

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) }