SKEEP-003 addendum: placement, eager lifetime, and graph-level planning β€” resolved

Status: Resolved 2026-08-26 Β· Issues #1131 / #1133 / #1134 / #1135 Β· Umbrella #932

SKEEP-003 shipped the storage model (M0–M2, then the #1109 weight-form arc) and left three questions open under the P7/P8 umbrella (#1131). This page records the answers and where each one landed. A terminology note first: the SKEEP’s own phase list uses "P7" for compiled parity and "P8" for faΓ§ade removal; the issues used "P7/P8" for device placement and graph-level memory planning. This page names issues, not P-numbers.

1. Who chooses a tensor’s device placement? A resolver. (#1133)

Placement joined WeightForm as a resolved decision β€” a pure function of three inputs, none of which the model author knows:

  • what will be held β€” the resolved WeightForm (encoding Γ— order Γ— shape Γ— residency),

  • what the profile says β€” PlannerProfile.domainFor and its off-heap threshold,

  • what the platform can do β€” StorageCapabilities (mmap? off-heap?), injectable so a test can resolve for a platform it is not running on.

AllocationResolver.resolve(weight, profile, platform) produces the AllocationSpec; AllocationResolver.explain(…​) renders the same decision with its reason, so a plan can say where every tensor lands and why, before a byte of payload is read. Consumers β€” the plan (PlanTensor.allocation), the loader (ResolvedGguf), a context β€” carry and obey; none decide.

Mapping requires all three conditions: the form asks MAPPED, the platform can map, and the bytes really are the file’s bytes β€” a dequantized or feed-ordered weight is a load-time copy, and a copy cannot be paged from a file it no longer matches. Everything else falls to the profile’s heap/off-heap threshold over the bytes actually held.

No graph is needed. Placement of weights is per-tensor and depends on nothing downstream; activations are a lifetime question (Β§2), not a device question. Every backend today is CPU; device arbitration policy would be untestable speculation, deferred until a second device exists.

The user always wins. The precedence order, explicit and documented on the loader: per-tensor weightFormFor > uniform weightForm > the resolver (the three legacy parameters were removed outright in #1159). WeightForm(DequantizeTo(FP32), residency = HEAP) β€” everything dense, on the managed heap β€” is a supported one-liner, not a fight with the planner.

Deleted, as this SKEEP scheduled: @Place/@Weights (declared, retained, read by nothing β€” they asked the model author, who does not know the three deciding facts), the dead tensor.storage.MemoryPlanner (name-collided with the live memory.plan.*), StorageSpec, and Placement.Residency. Residency vocabulary reconciled by deletion: lifetime is ScopeKind (MODEL/FORWARD/AMBIENT, enforced by Storage/Scope), weight staging is WeightForm.WeightResidency (HEAP/MAPPED, decided by the resolver). Placement itself stays β€” the KV-cache stores carry it as device/domain intent.

2. Does eager execution need its own buffer-lifetime mechanism? Yes β€” and it existed. (#1135)

The Scope split is the mechanism. Eager lifetimes follow call structure β€” the generation loop knows where a step ends β€” so ForwardScope.reset() at step boundaries replaces graph liveness analysis: one pre-sized slab, bump allocation, overflow accounting for slab sizing, retain() as the sanctioned escape, and a use-after-reset that throws StorageClosedException instead of corrupting.

What was missing was any reader of ExecutionContext.memoryScope. The creation path is now that reader: zeros/ones/full/fromFloatArray draw dense-FP32 bytes from the active scope (StorageFloatTensorData), opt-in via ctx.forwardScope(slabFloats) { scoped, scope → … }, with Scope.Ambient β€” the default everywhere β€” byte-for-byte the old path.

Two boundaries held on purpose:

  • ScratchPool stays, unmerged. It is untyped intra-kernel workspace inside one op invocation; memoryScope governs inter-op activation lifetime across a step. Different layers; neither replaces the other.

  • StorageFloatTensorData is not a FloatArrayTensorData. Slab slices have a nonzero arrayOffset; the ops fast paths that unwrap buffer assume offset 0. Kernels that want zero-copy take the view, which carries the offset.

Op outputs draw from the active scope too since #1146 (TensorDataFactory.adoptFloatArray, ScopedTensorDataFactory), with offset-aware FP32 fast paths and Panama vector kernels (#1173) so slab-backed tensors keep SIMD speed.

3. Would graph-level memory planning duplicate IREE? Yes. (#1134)

On the recorded path (tape β†’ ComputeGraph β†’ StableHLO text + .irpa), iree-compile performs buffer allocation, liveness and scheduling on the very MLIR SKaiNET emits β€” and it does so after fusion and layout decisions SKaiNET cannot see, so an upstream planner’s liveness answers would be invalidated by the consumer. Meanwhile nothing in this repository executes IREE at all: the integration is a format contract (text-shape assertions in tests, IrpaWriter for the weights sidecar), with compilation and execution in external tooling.

Resolution: core decides and carries; planning stays downstream. Decisions made by core resolvers (WeightFormResolver, AllocationResolver) must survive to the MLIR so the downstream compiler can honour them β€” that is carriage, not planning.

Dependency rule

sk.ainet.lang.memory is never imported into skainet-compile. Carriage uses the TensorSpecEncoding precedent: an untyped TensorSpec.metadata entry with a typed accessor extension, emitted as a module attribute. There is no such import today; the first one would couple the compile pipeline to the storage model for no consumer’s benefit β€” reject it in review.

The carry work landed as the #1178/#1179/#1180 arc (0.49.0): TensorRef carries identity, encoding and block order; TensorSpec metadata accessors follow the untyped precedent; the structural skainet.tensor_layouts module attribute and ExternalParameterRef.blockOrder declare the facts to the consumer; and LayoutAssignmentPass runs on the production path via HloGenerator.generate(target = …), populating the pre-built ResolvedComputeGraph seams. Until the conformity pipeline exists (#1148, the future IREE-validation SKEEP), the carriage tests are the metadata’s reader.

Where everything landed

Question Issue Landed as

Delete the dead placement machinery

#1142

PR #1152

AllocationResolver + explain()

#1143

PR #1153 (re-landed #1155)

Loader consults the resolution (ResolvedGguf, weightFormFor)

#1144

PR #1154 (re-landed #1155)

memoryScope wired into creation

#1145

PR #1156

Op outputs through the scope + offset-aware SIMD

#1146, #1173

landed (PRs #1168/#1175)

Layout/placement carriage to StableHLO

#1147 (#1178/#1179/#1180)

landed (PRs #1182/#1183/#1184)

.vmfb parity acceptance run

#1148

open (follow-up)

The SKEEP-003 items "`StorageSpec`, MemoryPlanner and the annotations are deleted unless wired" and "one planner per context, consulted by the creation path" are both discharged β€” the first by #1142, the second in the resolver-owned form described above (a per-context planner object turned out to be the wrong shape; a stateless resolver plus the context’s scope covers both halves).