Step 24B: frame-slot captures for bytecode lambdas

This commit is contained in:
Sergey Chernov 2026-02-10 04:33:04 +03:00
parent 8f1c660f4e
commit 6c0b86f6e6
8 changed files with 106 additions and 25 deletions

View File

@ -92,11 +92,11 @@ Goal: migrate the compiler so all values live in frames/bytecode, keeping JVM te
- [x] Create captures only when detected; do not allocate scope slots. - [x] Create captures only when detected; do not allocate scope slots.
- [x] Add disassembler output for capture tables. - [x] Add disassembler output for capture tables.
- [x] JVM tests must be green before commit. - [x] JVM tests must be green before commit.
- [ ] Step 24B: Frame-slot captures in bytecode runtime. - [x] Step 24B: Frame-slot captures in bytecode runtime.
- [ ] Build lambdas from bytecode + capture table (no capture names). - [x] Build lambdas from bytecode + capture table (no capture names).
- [ ] Read captured values via `FrameSlotRef` only. - [x] Read captured values via `FrameSlotRef` only.
- [ ] Forbid `resolveCaptureRecord` in bytecode paths; keep only in interpreter. - [x] Forbid `resolveCaptureRecord` in bytecode paths; keep only in interpreter.
- [ ] JVM tests must be green before commit. - [x] JVM tests must be green before commit.
- [ ] Step 24C: Remove scope local mirroring in bytecode execution. - [ ] Step 24C: Remove scope local mirroring in bytecode execution.
- [ ] Remove/disable any bytecode runtime code that writes locals into Scope for execution. - [ ] Remove/disable any bytecode runtime code that writes locals into Scope for execution.
- [ ] Keep Scope creation only for reflection/Kotlin interop paths. - [ ] Keep Scope creation only for reflection/Kotlin interop paths.

View File

@ -2880,22 +2880,31 @@ class Compiler(
val context = scope.applyClosure(closureScope, preferredThisType = expectedReceiverType) val context = scope.applyClosure(closureScope, preferredThisType = expectedReceiverType)
if (paramSlotPlanSnapshot.isNotEmpty()) context.applySlotPlan(paramSlotPlanSnapshot) if (paramSlotPlanSnapshot.isNotEmpty()) context.applySlotPlan(paramSlotPlanSnapshot)
if (captureSlots.isNotEmpty()) { if (captureSlots.isNotEmpty()) {
val moduleScope = if (context is ApplyScope) { val captureRecords = closureScope.captureRecords
var s: Scope? = closureScope if (captureRecords != null) {
while (s != null && s !is ModuleScope) { for (i in captureSlots.indices) {
s = s.parent val rec = captureRecords.getOrNull(i)
?: closureScope.raiseSymbolNotFound("capture ${captureSlots[i].name} not found")
context.updateSlotFor(captureSlots[i].name, rec)
} }
s as? ModuleScope
} else { } else {
null val moduleScope = if (context is ApplyScope) {
} var s: Scope? = closureScope
for (capture in captureSlots) { while (s != null && s !is ModuleScope) {
if (moduleScope != null && moduleScope.getLocalRecordDirect(capture.name) != null) { s = s.parent
continue }
s as? ModuleScope
} else {
null
}
for (capture in captureSlots) {
if (moduleScope != null && moduleScope.getLocalRecordDirect(capture.name) != null) {
continue
}
val rec = closureScope.resolveCaptureRecord(capture.name)
?: closureScope.raiseSymbolNotFound("symbol ${capture.name} not found")
context.updateSlotFor(capture.name, rec)
} }
val rec = closureScope.resolveCaptureRecord(capture.name)
?: closureScope.raiseSymbolNotFound("symbol ${capture.name} not found")
context.updateSlotFor(capture.name, rec)
} }
} }
// Execute lambda body in a closure-aware context. Blocks inside the lambda // Execute lambda body in a closure-aware context. Blocks inside the lambda

View File

@ -59,6 +59,7 @@ open class Scope(
// Enabled by default for child scopes; module/class scopes can ignore it. // Enabled by default for child scopes; module/class scopes can ignore it.
private val slots: MutableList<ObjRecord> = mutableListOf() private val slots: MutableList<ObjRecord> = mutableListOf()
private val nameToSlot: MutableMap<String, Int> = mutableMapOf() private val nameToSlot: MutableMap<String, Int> = mutableMapOf()
internal var captureRecords: List<ObjRecord>? = null
/** /**
* Auxiliary per-frame map of local bindings (locals declared in this frame). * Auxiliary per-frame map of local bindings (locals declared in this frame).
* This helps resolving locals across suspension when slot ownership isn't * This helps resolving locals across suspension when slot ownership isn't

View File

@ -608,16 +608,41 @@ class BytecodeCompiler(
} }
private fun compileValueFnRef(ref: ValueFnRef): CompiledValue? { private fun compileValueFnRef(ref: ValueFnRef): CompiledValue? {
lambdaCaptureEntriesByRef[ref]?.let { captures -> val captureTableId = lambdaCaptureEntriesByRef[ref]?.let { captures ->
builder.addConst(BytecodeConst.CaptureTable(captures)) if (captures.isEmpty()) return@let null
val resolved = captures.map { entry ->
val slotIndex = resolveCaptureSlot(entry)
BytecodeCaptureEntry(
ownerKind = entry.ownerKind,
ownerScopeId = entry.ownerScopeId,
ownerSlotId = entry.ownerSlotId,
slotIndex = slotIndex
)
}
builder.addConst(BytecodeConst.CaptureTable(resolved))
} }
val id = builder.addConst(BytecodeConst.ValueFn(ref.valueFn())) val id = builder.addConst(BytecodeConst.ValueFn(ref.valueFn(), captureTableId))
val slot = allocSlot() val slot = allocSlot()
builder.emit(Opcode.MAKE_VALUE_FN, id, slot) builder.emit(Opcode.MAKE_VALUE_FN, id, slot)
updateSlotType(slot, SlotType.OBJ) updateSlotType(slot, SlotType.OBJ)
return CompiledValue(slot, SlotType.OBJ) return CompiledValue(slot, SlotType.OBJ)
} }
private fun resolveCaptureSlot(entry: LambdaCaptureEntry): Int {
val key = ScopeSlotKey(entry.ownerScopeId, entry.ownerSlotId)
return when (entry.ownerKind) {
CaptureOwnerFrameKind.MODULE -> {
scopeSlotMap[key]
?: throw BytecodeCompileException("Missing module capture slot for ${entry.ownerScopeId}:${entry.ownerSlotId}", Pos.builtIn)
}
CaptureOwnerFrameKind.LOCAL -> {
val localIndex = localSlotIndexByKey[key]
?: throw BytecodeCompileException("Missing local capture slot for ${entry.ownerScopeId}:${entry.ownerSlotId}", Pos.builtIn)
scopeSlotCount + localIndex
}
}
}
private fun compileEvalRef(ref: ObjRef): CompiledValue? { private fun compileEvalRef(ref: ObjRef): CompiledValue? {
val refInfo = when (ref) { val refInfo = when (ref) {
is BinaryOpRef -> "BinaryOpRef(${ref.op})" is BinaryOpRef -> "BinaryOpRef(${ref.op})"

View File

@ -33,10 +33,13 @@ sealed class BytecodeConst {
data class Ref(val value: net.sergeych.lyng.obj.ObjRef) : BytecodeConst() data class Ref(val value: net.sergeych.lyng.obj.ObjRef) : BytecodeConst()
data class StatementVal(val statement: net.sergeych.lyng.Statement) : BytecodeConst() data class StatementVal(val statement: net.sergeych.lyng.Statement) : BytecodeConst()
data class ListLiteralPlan(val spreads: List<Boolean>) : BytecodeConst() data class ListLiteralPlan(val spreads: List<Boolean>) : BytecodeConst()
data class ValueFn(val fn: suspend (net.sergeych.lyng.Scope) -> net.sergeych.lyng.obj.ObjRecord) : BytecodeConst() data class ValueFn(
val fn: suspend (net.sergeych.lyng.Scope) -> net.sergeych.lyng.obj.ObjRecord,
val captureTableId: Int? = null,
) : BytecodeConst()
data class DeclExec(val executable: net.sergeych.lyng.DeclExecutable) : BytecodeConst() data class DeclExec(val executable: net.sergeych.lyng.DeclExecutable) : BytecodeConst()
data class SlotPlan(val plan: Map<String, Int>, val captures: List<String> = emptyList()) : BytecodeConst() data class SlotPlan(val plan: Map<String, Int>, val captures: List<String> = emptyList()) : BytecodeConst()
data class CaptureTable(val entries: List<LambdaCaptureEntry>) : BytecodeConst() data class CaptureTable(val entries: List<BytecodeCaptureEntry>) : BytecodeConst()
data class ExtensionPropertyDecl( data class ExtensionPropertyDecl(
val extTypeName: String, val extTypeName: String,
val property: ObjProperty, val property: ObjProperty,

View File

@ -61,7 +61,7 @@ object CmdDisassembler {
"[]" "[]"
} else { } else {
table.entries.joinToString(prefix = "[", postfix = "]") { entry -> table.entries.joinToString(prefix = "[", postfix = "]") { entry ->
"${entry.ownerKind}#${entry.ownerScopeId}:${entry.ownerSlotId}" "${entry.ownerKind}#${entry.ownerScopeId}:${entry.ownerSlotId}@s${entry.slotIndex}"
} }
} }
out.append("k").append(idx).append(" CAPTURE_TABLE ").append(entries).append('\n') out.append("k").append(idx).append(" CAPTURE_TABLE ").append(entries).append('\n')

View File

@ -1869,7 +1869,12 @@ class CmdMakeValueFn(internal val id: Int, internal val dst: Int) : Cmd() {
} }
val valueFn = frame.fn.constants.getOrNull(id) as? BytecodeConst.ValueFn val valueFn = frame.fn.constants.getOrNull(id) as? BytecodeConst.ValueFn
?: error("MAKE_VALUE_FN expects ValueFn at $id") ?: error("MAKE_VALUE_FN expects ValueFn at $id")
val result = valueFn.fn(frame.ensureScope()).value val scope = frame.ensureScope()
val previousCaptures = scope.captureRecords
val captureRecords = valueFn.captureTableId?.let { frame.buildCaptureRecords(it) }
scope.captureRecords = captureRecords
val result = valueFn.fn(scope).value
scope.captureRecords = previousCaptures
if (frame.fn.localSlotNames.isNotEmpty()) { if (frame.fn.localSlotNames.isNotEmpty()) {
frame.syncScopeToFrame() frame.syncScopeToFrame()
} }
@ -1944,6 +1949,37 @@ class CmdFrame(
} }
} }
internal fun buildCaptureRecords(captureTableId: Int): List<ObjRecord> {
val table = fn.constants.getOrNull(captureTableId) as? BytecodeConst.CaptureTable
?: error("Capture table $captureTableId missing")
return table.entries.map { entry ->
when (entry.ownerKind) {
CaptureOwnerFrameKind.LOCAL -> {
val localIndex = entry.slotIndex - fn.scopeSlotCount
if (localIndex < 0) {
error("Invalid local capture slot ${entry.slotIndex}")
}
val isMutable = fn.localSlotMutables.getOrNull(localIndex) ?: false
val isDelegated = fn.localSlotDelegated.getOrNull(localIndex) ?: false
if (isDelegated) {
val delegate = frame.getObj(localIndex)
ObjRecord(ObjNull, isMutable, type = ObjRecord.Type.Delegated).also {
it.delegate = delegate
}
} else {
ObjRecord(FrameSlotRef(frame, localIndex), isMutable)
}
}
CaptureOwnerFrameKind.MODULE -> {
val slot = entry.slotIndex
val target = scopeTarget(slot)
val index = fn.scopeSlotIndices[slot]
target.getSlotRecord(index)
}
}
}
}
private fun shouldSyncLocalCaptures(captures: List<String>): Boolean { private fun shouldSyncLocalCaptures(captures: List<String>): Boolean {
if (captures.isEmpty()) return false if (captures.isEmpty()) return false
val localNames = fn.localSlotNames val localNames = fn.localSlotNames

View File

@ -23,3 +23,10 @@ data class LambdaCaptureEntry(
val ownerScopeId: Int, val ownerScopeId: Int,
val ownerSlotId: Int, val ownerSlotId: Int,
) )
data class BytecodeCaptureEntry(
val ownerKind: CaptureOwnerFrameKind,
val ownerScopeId: Int,
val ownerSlotId: Int,
val slotIndex: Int,
)