From 2f4462858bc04869b60467f685da7b74f0f88416 Mon Sep 17 00:00:00 2001 From: sergeych Date: Mon, 26 Jan 2026 06:33:15 +0300 Subject: [PATCH] bytecode: extend call args and cache call sites --- docs/BytecodeSpec.md | 5 + .../bytecode/BytecodeCallSiteCacheAndroid.kt | 30 +++++ .../lyng/bytecode/BytecodeCallSiteCache.kt | 21 ++++ .../lyng/bytecode/BytecodeCompiler.kt | 107 ++++++++++++------ .../sergeych/lyng/bytecode/BytecodeConst.kt | 2 + .../lyng/bytecode/BytecodeFunction.kt | 2 - .../net/sergeych/lyng/bytecode/BytecodeVm.kt | 72 +++++++++++- .../src/commonTest/kotlin/BytecodeVmTest.kt | 76 +++++++++++++ .../lyng/bytecode/BytecodeCallSiteCacheJs.kt | 25 ++++ .../lyng/bytecode/BytecodeCallSiteCacheJvm.kt | 30 +++++ .../bytecode/BytecodeCallSiteCacheNative.kt | 26 +++++ .../bytecode/BytecodeCallSiteCacheWasm.kt | 25 ++++ notes/bytecode_callsite_cache.md | 18 +++ 13 files changed, 400 insertions(+), 39 deletions(-) create mode 100644 lynglib/src/androidMain/kotlin/net/sergeych/lyng/bytecode/BytecodeCallSiteCacheAndroid.kt create mode 100644 lynglib/src/commonMain/kotlin/net/sergeych/lyng/bytecode/BytecodeCallSiteCache.kt create mode 100644 lynglib/src/jsMain/kotlin/net/sergeych/lyng/bytecode/BytecodeCallSiteCacheJs.kt create mode 100644 lynglib/src/jvmMain/kotlin/net/sergeych/lyng/bytecode/BytecodeCallSiteCacheJvm.kt create mode 100644 lynglib/src/nativeMain/kotlin/net/sergeych/lyng/bytecode/BytecodeCallSiteCacheNative.kt create mode 100644 lynglib/src/wasmJsMain/kotlin/net/sergeych/lyng/bytecode/BytecodeCallSiteCacheWasm.kt create mode 100644 notes/bytecode_callsite_cache.md diff --git a/docs/BytecodeSpec.md b/docs/BytecodeSpec.md index fd75bbd..62a0c35 100644 --- a/docs/BytecodeSpec.md +++ b/docs/BytecodeSpec.md @@ -32,6 +32,7 @@ slots[localCount .. localCount+argCount-1] arguments ### Constant pool extras - SlotPlan: map of name -> slot index, used by PUSH_SCOPE to pre-allocate and map loop locals. +- CallArgsPlan: ordered argument specs (name/splat) + tailBlock flag, used when argCount has the plan flag set. ## 2) Slot ID Width @@ -83,6 +84,10 @@ Common operand patterns: - I: jump target - F S C S: fnId, argBase slot, argCount, dst slot +Arg count flag: +- If high bit of C is set (0x8000), the low 15 bits encode a CallArgsPlan constId. +- When not set, C is the raw positional count and tailBlockMode=false. + ## 5) Opcode Table Note: Any opcode can be compiled to FALLBACK if not implemented in a VM pass. diff --git a/lynglib/src/androidMain/kotlin/net/sergeych/lyng/bytecode/BytecodeCallSiteCacheAndroid.kt b/lynglib/src/androidMain/kotlin/net/sergeych/lyng/bytecode/BytecodeCallSiteCacheAndroid.kt new file mode 100644 index 0000000..d12ef19 --- /dev/null +++ b/lynglib/src/androidMain/kotlin/net/sergeych/lyng/bytecode/BytecodeCallSiteCacheAndroid.kt @@ -0,0 +1,30 @@ +/* + * Copyright 2026 Sergey S. Chernov + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package net.sergeych.lyng.bytecode + +import java.util.IdentityHashMap + +internal actual object BytecodeCallSiteCache { + private val cache = ThreadLocal.withInitial { + IdentityHashMap>() + } + + actual fun methodCallSites(fn: BytecodeFunction): MutableMap { + val map = cache.get() + return map.getOrPut(fn) { mutableMapOf() } + } +} diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/bytecode/BytecodeCallSiteCache.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/bytecode/BytecodeCallSiteCache.kt new file mode 100644 index 0000000..639015d --- /dev/null +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/bytecode/BytecodeCallSiteCache.kt @@ -0,0 +1,21 @@ +/* + * Copyright 2026 Sergey S. Chernov + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package net.sergeych.lyng.bytecode + +internal expect object BytecodeCallSiteCache { + fun methodCallSites(fn: BytecodeFunction): MutableMap +} diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/bytecode/BytecodeCompiler.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/bytecode/BytecodeCompiler.kt index 20ce3f1..1cb9fc2 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/bytecode/BytecodeCompiler.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/bytecode/BytecodeCompiler.kt @@ -382,7 +382,12 @@ class BytecodeCompiler( } private fun compileCompareEq(a: CompiledValue, b: CompiledValue, out: Int): CompiledValue? { - if (a.type == SlotType.UNKNOWN || b.type == SlotType.UNKNOWN) return null + if (a.type == SlotType.UNKNOWN || b.type == SlotType.UNKNOWN) { + val left = ensureObjSlot(a) + val right = ensureObjSlot(b) + builder.emit(Opcode.CMP_EQ_OBJ, left.slot, right.slot, out) + return CompiledValue(out, SlotType.BOOL) + } return when { a.type == SlotType.INT && b.type == SlotType.INT -> { builder.emit(Opcode.CMP_EQ_INT, a.slot, b.slot, out) @@ -413,7 +418,12 @@ class BytecodeCompiler( } private fun compileCompareNeq(a: CompiledValue, b: CompiledValue, out: Int): CompiledValue? { - if (a.type == SlotType.UNKNOWN || b.type == SlotType.UNKNOWN) return null + if (a.type == SlotType.UNKNOWN || b.type == SlotType.UNKNOWN) { + val left = ensureObjSlot(a) + val right = ensureObjSlot(b) + builder.emit(Opcode.CMP_NEQ_OBJ, left.slot, right.slot, out) + return CompiledValue(out, SlotType.BOOL) + } return when { a.type == SlotType.INT && b.type == SlotType.INT -> { builder.emit(Opcode.CMP_NEQ_INT, a.slot, b.slot, out) @@ -444,7 +454,12 @@ class BytecodeCompiler( } private fun compileCompareLt(a: CompiledValue, b: CompiledValue, out: Int): CompiledValue? { - if (a.type == SlotType.UNKNOWN || b.type == SlotType.UNKNOWN) return null + if (a.type == SlotType.UNKNOWN || b.type == SlotType.UNKNOWN) { + val left = ensureObjSlot(a) + val right = ensureObjSlot(b) + builder.emit(Opcode.CMP_LT_OBJ, left.slot, right.slot, out) + return CompiledValue(out, SlotType.BOOL) + } return when { a.type == SlotType.INT && b.type == SlotType.INT -> { builder.emit(Opcode.CMP_LT_INT, a.slot, b.slot, out) @@ -471,7 +486,12 @@ class BytecodeCompiler( } private fun compileCompareLte(a: CompiledValue, b: CompiledValue, out: Int): CompiledValue? { - if (a.type == SlotType.UNKNOWN || b.type == SlotType.UNKNOWN) return null + if (a.type == SlotType.UNKNOWN || b.type == SlotType.UNKNOWN) { + val left = ensureObjSlot(a) + val right = ensureObjSlot(b) + builder.emit(Opcode.CMP_LTE_OBJ, left.slot, right.slot, out) + return CompiledValue(out, SlotType.BOOL) + } return when { a.type == SlotType.INT && b.type == SlotType.INT -> { builder.emit(Opcode.CMP_LTE_INT, a.slot, b.slot, out) @@ -498,7 +518,12 @@ class BytecodeCompiler( } private fun compileCompareGt(a: CompiledValue, b: CompiledValue, out: Int): CompiledValue? { - if (a.type == SlotType.UNKNOWN || b.type == SlotType.UNKNOWN) return null + if (a.type == SlotType.UNKNOWN || b.type == SlotType.UNKNOWN) { + val left = ensureObjSlot(a) + val right = ensureObjSlot(b) + builder.emit(Opcode.CMP_GT_OBJ, left.slot, right.slot, out) + return CompiledValue(out, SlotType.BOOL) + } return when { a.type == SlotType.INT && b.type == SlotType.INT -> { builder.emit(Opcode.CMP_GT_INT, a.slot, b.slot, out) @@ -525,7 +550,12 @@ class BytecodeCompiler( } private fun compileCompareGte(a: CompiledValue, b: CompiledValue, out: Int): CompiledValue? { - if (a.type == SlotType.UNKNOWN || b.type == SlotType.UNKNOWN) return null + if (a.type == SlotType.UNKNOWN || b.type == SlotType.UNKNOWN) { + val left = ensureObjSlot(a) + val right = ensureObjSlot(b) + builder.emit(Opcode.CMP_GTE_OBJ, left.slot, right.slot, out) + return CompiledValue(out, SlotType.BOOL) + } return when { a.type == SlotType.INT && b.type == SlotType.INT -> { builder.emit(Opcode.CMP_GTE_INT, a.slot, b.slot, out) @@ -762,62 +792,69 @@ class BytecodeCompiler( return CompiledValue(dst, SlotType.OBJ) } - private data class CallArgs(val base: Int, val count: Int) - private fun compileCall(ref: CallRef): CompiledValue? { if (ref.isOptionalInvoke) return null - if (!argsEligible(ref.args, ref.tailBlock)) return null val callee = compileRefWithFallback(ref.target, null, Pos.builtIn) ?: return null val args = compileCallArgs(ref.args, ref.tailBlock) ?: return null + val encodedCount = encodeCallArgCount(args) ?: return null val dst = allocSlot() - builder.emit(Opcode.CALL_SLOT, callee.slot, args.base, args.count, dst) + builder.emit(Opcode.CALL_SLOT, callee.slot, args.base, encodedCount, dst) return CompiledValue(dst, SlotType.UNKNOWN) } private fun compileMethodCall(ref: MethodCallRef): CompiledValue? { if (ref.isOptional) return null - if (!argsEligible(ref.args, ref.tailBlock)) return null val receiver = compileRefWithFallback(ref.receiver, null, Pos.builtIn) ?: return null val args = compileCallArgs(ref.args, ref.tailBlock) ?: return null + val encodedCount = encodeCallArgCount(args) ?: return null val methodId = builder.addConst(BytecodeConst.StringVal(ref.name)) if (methodId > 0xFFFF) return null val dst = allocSlot() - builder.emit(Opcode.CALL_VIRTUAL, receiver.slot, methodId, args.base, args.count, dst) + builder.emit(Opcode.CALL_VIRTUAL, receiver.slot, methodId, args.base, encodedCount, dst) return CompiledValue(dst, SlotType.UNKNOWN) } - private fun argsEligible(args: List, tailBlock: Boolean): Boolean { - if (tailBlock) return false - for (arg in args) { - if (arg.isSplat || arg.name != null) return false - if (arg.value !is ExpressionStatement) return false - } - return true - } + private data class CallArgs(val base: Int, val count: Int, val planId: Int?) private fun compileCallArgs(args: List, tailBlock: Boolean): CallArgs? { - if (tailBlock) return null - for (arg in args) { - if (arg.isSplat || arg.name != null) return null - } - if (args.isEmpty()) return CallArgs(base = 0, count = 0) + if (args.isEmpty()) return CallArgs(base = 0, count = 0, planId = null) val argSlots = IntArray(args.size) { allocSlot() } + val needPlan = tailBlock || args.any { it.isSplat || it.name != null } + val specs = if (needPlan) ArrayList(args.size) else null for ((index, arg) in args.withIndex()) { - val stmt = arg.value - val compiled = if (stmt is ExpressionStatement) { - compileRefWithFallback(stmt.ref, null, stmt.pos) - } else { - null - } ?: return null + val compiled = compileArgValue(arg.value) ?: return null val dst = argSlots[index] - if (compiled.slot != dst) { - builder.emit(Opcode.BOX_OBJ, compiled.slot, dst) - } else if (compiled.type != SlotType.OBJ) { + if (compiled.slot != dst || compiled.type != SlotType.OBJ) { builder.emit(Opcode.BOX_OBJ, compiled.slot, dst) } updateSlotType(dst, SlotType.OBJ) + specs?.add(BytecodeConst.CallArgSpec(arg.name, arg.isSplat)) } - return CallArgs(base = argSlots[0], count = argSlots.size) + val planId = if (needPlan) { + builder.addConst(BytecodeConst.CallArgsPlan(tailBlock, specs ?: emptyList())) + } else { + null + } + return CallArgs(base = argSlots[0], count = argSlots.size, planId = planId) + } + + private fun compileArgValue(stmt: Statement): CompiledValue? { + return when (stmt) { + is ExpressionStatement -> compileRefWithFallback(stmt.ref, null, stmt.pos) + else -> { + val slot = allocSlot() + val id = builder.addFallback(stmt) + builder.emit(Opcode.EVAL_FALLBACK, id, slot) + updateSlotType(slot, SlotType.OBJ) + CompiledValue(slot, SlotType.OBJ) + } + } + } + + private fun encodeCallArgCount(args: CallArgs): Int? { + val planId = args.planId ?: return args.count + if (planId > 0x7FFF) return null + return 0x8000 or planId } private fun compileIf(name: String, stmt: IfStatement): BytecodeFunction? { diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/bytecode/BytecodeConst.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/bytecode/BytecodeConst.kt index 0394918..117de97 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/bytecode/BytecodeConst.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/bytecode/BytecodeConst.kt @@ -26,4 +26,6 @@ sealed class BytecodeConst { data class StringVal(val value: String) : BytecodeConst() data class ObjRef(val value: Obj) : BytecodeConst() data class SlotPlan(val plan: Map) : BytecodeConst() + data class CallArgsPlan(val tailBlock: Boolean, val specs: List) : BytecodeConst() + data class CallArgSpec(val name: String?, val isSplat: Boolean) } diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/bytecode/BytecodeFunction.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/bytecode/BytecodeFunction.kt index 4109b47..3f0da78 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/bytecode/BytecodeFunction.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/bytecode/BytecodeFunction.kt @@ -30,8 +30,6 @@ data class BytecodeFunction( val fallbackStatements: List, val code: ByteArray, ) { - val methodCallSites: MutableMap = mutableMapOf() - init { require(slotWidth == 1 || slotWidth == 2 || slotWidth == 4) { "slotWidth must be 1,2,4" } require(ipWidth == 2 || ipWidth == 4) { "ipWidth must be 2 or 4" } diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/bytecode/BytecodeVm.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/bytecode/BytecodeVm.kt index 4f33095..41bd616 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/bytecode/BytecodeVm.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/bytecode/BytecodeVm.kt @@ -22,9 +22,15 @@ import net.sergeych.lyng.Scope import net.sergeych.lyng.obj.* class BytecodeVm { + companion object { + private const val ARG_PLAN_FLAG = 0x8000 + private const val ARG_PLAN_MASK = 0x7FFF + } + suspend fun execute(fn: BytecodeFunction, scope0: Scope, args: List): Obj { val scopeStack = ArrayDeque() var scope = scope0 + val methodCallSites = BytecodeCallSiteCache.methodCallSites(fn) val frame = BytecodeFrame(fn.localCount, args.size) for (i in args.indices) { frame.setObj(frame.argBase + i, args[i]) @@ -776,7 +782,7 @@ class BytecodeVm { val nameConst = fn.constants.getOrNull(methodId) as? BytecodeConst.StringVal ?: error("CALL_VIRTUAL expects StringVal at $methodId") val args = buildArguments(fn, frame, scope, argBase, argCount) - val site = fn.methodCallSites.getOrPut(startIp) { MethodCallSite(nameConst.value) } + val site = methodCallSites.getOrPut(startIp) { MethodCallSite(nameConst.value) } val result = site.invoke(scope, receiver, args) when (result) { is ObjInt -> setInt(fn, frame, scope, dst, result.value) @@ -825,7 +831,7 @@ class BytecodeVm { } } - private fun buildArguments( + private suspend fun buildArguments( fn: BytecodeFunction, frame: BytecodeFrame, scope: Scope, @@ -833,6 +839,12 @@ class BytecodeVm { argCount: Int, ): Arguments { if (argCount == 0) return Arguments.EMPTY + if ((argCount and ARG_PLAN_FLAG) != 0) { + val planId = argCount and ARG_PLAN_MASK + val plan = fn.constants.getOrNull(planId) as? BytecodeConst.CallArgsPlan + ?: error("CALL args plan not found: $planId") + return buildArgumentsFromPlan(fn, frame, scope, argBase, plan) + } val list = ArrayList(argCount) for (i in 0 until argCount) { list.add(slotToObj(fn, frame, scope, argBase + i)) @@ -840,6 +852,62 @@ class BytecodeVm { return Arguments(list) } + private suspend fun buildArgumentsFromPlan( + fn: BytecodeFunction, + frame: BytecodeFrame, + scope: Scope, + argBase: Int, + plan: BytecodeConst.CallArgsPlan, + ): Arguments { + val positional = ArrayList(plan.specs.size) + var named: LinkedHashMap? = null + var namedSeen = false + for ((idx, spec) in plan.specs.withIndex()) { + val value = slotToObj(fn, frame, scope, argBase + idx) + val name = spec.name + if (name != null) { + if (named == null) named = linkedMapOf() + if (named.containsKey(name)) scope.raiseIllegalArgument("argument '$name' is already set") + named[name] = value + namedSeen = true + continue + } + if (spec.isSplat) { + when { + value is ObjMap -> { + if (named == null) named = linkedMapOf() + for ((k, v) in value.map) { + if (k !is ObjString) scope.raiseIllegalArgument("named splat expects a Map with string keys") + val key = k.value + if (named.containsKey(key)) scope.raiseIllegalArgument("argument '$key' is already set") + named[key] = v + } + namedSeen = true + } + value is ObjList -> { + if (namedSeen) scope.raiseIllegalArgument("positional splat cannot follow named arguments") + positional.addAll(value.list) + } + value.isInstanceOf(ObjIterable) -> { + if (namedSeen) scope.raiseIllegalArgument("positional splat cannot follow named arguments") + val list = (value.invokeInstanceMethod(scope, "toList") as ObjList).list + positional.addAll(list) + } + else -> scope.raiseClassCastError("expected list of objects for splat argument") + } + } else { + if (namedSeen) { + val isLast = idx == plan.specs.lastIndex + if (!(isLast && plan.tailBlock)) { + scope.raiseIllegalArgument("positional argument cannot follow named arguments") + } + } + positional.add(value) + } + } + return Arguments(positional, plan.tailBlock, named ?: emptyMap()) + } + private fun getObj(fn: BytecodeFunction, frame: BytecodeFrame, scope: Scope, slot: Int): Obj { return if (slot < fn.scopeSlotCount) { resolveScope(scope, fn.scopeSlotDepths[slot]).getSlotRecord(fn.scopeSlotIndices[slot]).value diff --git a/lynglib/src/commonTest/kotlin/BytecodeVmTest.kt b/lynglib/src/commonTest/kotlin/BytecodeVmTest.kt index 5536106..aa0cd44 100644 --- a/lynglib/src/commonTest/kotlin/BytecodeVmTest.kt +++ b/lynglib/src/commonTest/kotlin/BytecodeVmTest.kt @@ -26,6 +26,7 @@ import net.sergeych.lyng.bytecode.BytecodeVm import net.sergeych.lyng.bytecode.Opcode import net.sergeych.lyng.obj.BinaryOpRef import net.sergeych.lyng.obj.BinOp +import net.sergeych.lyng.obj.CallRef import net.sergeych.lyng.obj.ConstRef import net.sergeych.lyng.obj.LocalSlotRef import net.sergeych.lyng.obj.ObjFalse @@ -203,6 +204,81 @@ class BytecodeVmTest { assertEquals(true, eqResult.toBool()) } + @Test + fun callWithTailBlockKeepsTailBlockMode() = kotlinx.coroutines.test.runTest { + val callable = object : Statement() { + override val pos: Pos = Pos.builtIn + override suspend fun execute(scope: Scope) = + if (scope.args.tailBlockMode) ObjTrue else ObjFalse + } + val callRef = CallRef( + ConstRef(callable.asReadonly), + listOf( + net.sergeych.lyng.ParsedArgument( + ExpressionStatement(ConstRef(ObjInt.of(1).asReadonly), Pos.builtIn), + Pos.builtIn + ) + ), + tailBlock = true, + isOptionalInvoke = false + ) + val expr = ExpressionStatement(callRef, Pos.builtIn) + val fn = BytecodeCompiler().compileExpression("tailBlockArgs", expr) ?: error("bytecode compile failed") + val result = BytecodeVm().execute(fn, Scope(), emptyList()) + assertEquals(true, result.toBool()) + } + + @Test + fun callWithNamedArgumentsUsesPlan() = kotlinx.coroutines.test.runTest { + val callable = object : Statement() { + override val pos: Pos = Pos.builtIn + override suspend fun execute(scope: Scope) = + (scope.args.named["x"] as ObjInt) + } + val callRef = CallRef( + ConstRef(callable.asReadonly), + listOf( + net.sergeych.lyng.ParsedArgument( + ExpressionStatement(ConstRef(ObjInt.of(5).asReadonly), Pos.builtIn), + Pos.builtIn, + name = "x" + ) + ), + tailBlock = false, + isOptionalInvoke = false + ) + val expr = ExpressionStatement(callRef, Pos.builtIn) + val fn = BytecodeCompiler().compileExpression("namedArgs", expr) ?: error("bytecode compile failed") + val result = BytecodeVm().execute(fn, Scope(), emptyList()) + assertEquals(5, result.toInt()) + } + + @Test + fun callWithSplatArgumentsUsesPlan() = kotlinx.coroutines.test.runTest { + val callable = object : Statement() { + override val pos: Pos = Pos.builtIn + override suspend fun execute(scope: Scope) = + ObjInt.of(scope.args.size.toLong()) + } + val list = ObjList(mutableListOf(ObjInt.of(1), ObjInt.of(2), ObjInt.of(3))) + val callRef = CallRef( + ConstRef(callable.asReadonly), + listOf( + net.sergeych.lyng.ParsedArgument( + ExpressionStatement(ConstRef(list.asReadonly), Pos.builtIn), + Pos.builtIn, + isSplat = true + ) + ), + tailBlock = false, + isOptionalInvoke = false + ) + val expr = ExpressionStatement(callRef, Pos.builtIn) + val fn = BytecodeCompiler().compileExpression("splatArgs", expr) ?: error("bytecode compile failed") + val result = BytecodeVm().execute(fn, Scope(), emptyList()) + assertEquals(3, result.toInt()) + } + @Test fun mixedIntRealArithmeticUsesBytecodeOps() = kotlinx.coroutines.test.runTest { val expr = ExpressionStatement( diff --git a/lynglib/src/jsMain/kotlin/net/sergeych/lyng/bytecode/BytecodeCallSiteCacheJs.kt b/lynglib/src/jsMain/kotlin/net/sergeych/lyng/bytecode/BytecodeCallSiteCacheJs.kt new file mode 100644 index 0000000..8f2da71 --- /dev/null +++ b/lynglib/src/jsMain/kotlin/net/sergeych/lyng/bytecode/BytecodeCallSiteCacheJs.kt @@ -0,0 +1,25 @@ +/* + * Copyright 2026 Sergey S. Chernov + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package net.sergeych.lyng.bytecode + +internal actual object BytecodeCallSiteCache { + private val cache = mutableMapOf>() + + actual fun methodCallSites(fn: BytecodeFunction): MutableMap { + return cache.getOrPut(fn) { mutableMapOf() } + } +} diff --git a/lynglib/src/jvmMain/kotlin/net/sergeych/lyng/bytecode/BytecodeCallSiteCacheJvm.kt b/lynglib/src/jvmMain/kotlin/net/sergeych/lyng/bytecode/BytecodeCallSiteCacheJvm.kt new file mode 100644 index 0000000..d12ef19 --- /dev/null +++ b/lynglib/src/jvmMain/kotlin/net/sergeych/lyng/bytecode/BytecodeCallSiteCacheJvm.kt @@ -0,0 +1,30 @@ +/* + * Copyright 2026 Sergey S. Chernov + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package net.sergeych.lyng.bytecode + +import java.util.IdentityHashMap + +internal actual object BytecodeCallSiteCache { + private val cache = ThreadLocal.withInitial { + IdentityHashMap>() + } + + actual fun methodCallSites(fn: BytecodeFunction): MutableMap { + val map = cache.get() + return map.getOrPut(fn) { mutableMapOf() } + } +} diff --git a/lynglib/src/nativeMain/kotlin/net/sergeych/lyng/bytecode/BytecodeCallSiteCacheNative.kt b/lynglib/src/nativeMain/kotlin/net/sergeych/lyng/bytecode/BytecodeCallSiteCacheNative.kt new file mode 100644 index 0000000..2a0b4c0 --- /dev/null +++ b/lynglib/src/nativeMain/kotlin/net/sergeych/lyng/bytecode/BytecodeCallSiteCacheNative.kt @@ -0,0 +1,26 @@ +/* + * Copyright 2026 Sergey S. Chernov + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package net.sergeych.lyng.bytecode + +@kotlin.native.concurrent.ThreadLocal +internal actual object BytecodeCallSiteCache { + private val cache = mutableMapOf>() + + actual fun methodCallSites(fn: BytecodeFunction): MutableMap { + return cache.getOrPut(fn) { mutableMapOf() } + } +} diff --git a/lynglib/src/wasmJsMain/kotlin/net/sergeych/lyng/bytecode/BytecodeCallSiteCacheWasm.kt b/lynglib/src/wasmJsMain/kotlin/net/sergeych/lyng/bytecode/BytecodeCallSiteCacheWasm.kt new file mode 100644 index 0000000..8f2da71 --- /dev/null +++ b/lynglib/src/wasmJsMain/kotlin/net/sergeych/lyng/bytecode/BytecodeCallSiteCacheWasm.kt @@ -0,0 +1,25 @@ +/* + * Copyright 2026 Sergey S. Chernov + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package net.sergeych.lyng.bytecode + +internal actual object BytecodeCallSiteCache { + private val cache = mutableMapOf>() + + actual fun methodCallSites(fn: BytecodeFunction): MutableMap { + return cache.getOrPut(fn) { mutableMapOf() } + } +} diff --git a/notes/bytecode_callsite_cache.md b/notes/bytecode_callsite_cache.md new file mode 100644 index 0000000..8787173 --- /dev/null +++ b/notes/bytecode_callsite_cache.md @@ -0,0 +1,18 @@ +# Bytecode method call-site cache + +Changes +- Added per-thread bytecode method call-site caches via BytecodeCallSiteCache expect/actuals. +- Bytecode VM now reuses per-function call-site maps to preserve method PIC hits across repeated bytecode executions. +- Removed unused methodCallSites property from BytecodeFunction. + +Why +- Fixes JVM PIC invalidation test by allowing method PIC hits when bytecode bodies are invoked repeatedly (e.g., loop bodies compiled to bytecode statements). +- Avoids cross-thread mutable map sharing on native by using thread-local storage. + +Tests +- ./gradlew :lynglib:jvmTest +- ./gradlew :lynglib:allTests -x :lynglib:jvmTest + +Benchmark +- ./gradlew :lynglib:jvmTest --tests NestedRangeBenchmarkTest -Dbenchmarks=true + - nested-happy elapsed=1266 ms