Skip to content

Commit 9512893

Browse files
mazhukinevgeniySpace Team
authored andcommitted
[KGP] Experimental: support incremental changes in inlined local classes
In scenarios where a local class declaration is inlined, Kotlin IC might fail to detect the change in inline function, and skip recompiling some of its call sites. To fix that, we implement a brute force approach. INSTANCE usages in inline methods are extracted from the JVM bytecode. Then inline function "state" hashes are calculated as a multi-hash of function's own hash and used local classes' hashes. This behavior is disabled by default, because it is in testing. Gradle option kotlin.internal.classpathSnapshot.parseInlinedLocalClasses=true would enable the new logic. ^KT-62555 Fixed ^KT-75529
1 parent 950cee5 commit 9512893

File tree

28 files changed

+873
-339
lines changed

28 files changed

+873
-339
lines changed

build-common/src/org/jetbrains/kotlin/incremental/KotlinClassInfo.kt

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -106,19 +106,34 @@ class KotlinClassInfo(
106106
}
107107

108108
companion object {
109-
109+
/**
110+
* Compatibility note: used by JPS plugin
111+
*
112+
* Note #2: this form of KotlinClassInfo is also used by all IC Runners for populating in-module cache
113+
*/
110114
fun createFrom(kotlinClass: LocalFileKotlinClass): KotlinClassInfo {
111115
return createFrom(kotlinClass.classId, kotlinClass.classHeader, kotlinClass.fileContents)
112116
}
113117

114118
fun createFrom(classId: ClassId, classHeader: KotlinClassHeader, classContents: ByteArray): KotlinClassInfo {
119+
return createFrom(
120+
classId,
121+
classHeader,
122+
extraInfo = ExtraClassInfoGenerator().getExtraInfo(classHeader, classContents),
123+
)
124+
}
125+
126+
/**
127+
* Allows callers to customize [ExtraInfo] computation.
128+
*/
129+
fun createFrom(classId: ClassId, classHeader: KotlinClassHeader, extraInfo: ExtraInfo): KotlinClassInfo {
115130
return KotlinClassInfo(
116131
classId,
117132
classHeader.kind,
118133
classHeader.data ?: classHeader.incompatibleData ?: emptyArray(),
119134
classHeader.strings ?: emptyArray(),
120135
classHeader.multifileClassName,
121-
extraInfo = ExtraClassInfoGenerator.getExtraInfo(classHeader, classContents)
136+
extraInfo = extraInfo
122137
)
123138
}
124139
}

build-common/src/org/jetbrains/kotlin/incremental/impl/ClassNodeSnapshotter.kt

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55

66
package org.jetbrains.kotlin.incremental.impl
77

8-
import org.jetbrains.kotlin.incremental.md5
98
import org.jetbrains.org.objectweb.asm.ClassWriter
109
import org.jetbrains.org.objectweb.asm.tree.ClassNode
1110
import org.jetbrains.org.objectweb.asm.tree.FieldNode
@@ -60,6 +59,10 @@ object ClassNodeSnapshotter {
6059
classNode.methods.sortWith(compareBy({ it.name }, { it.desc }))
6160
}
6261

62+
/**
63+
* Internally snapshotter uses emptyClass() a lot, so if you want to change its name, you need to update
64+
* the tests with golden class snapshots - all abi hashes would shift.
65+
*/
6366
private fun emptyClass() = ClassNode().also {
6467
// A name is required
6568
it.name = "SomeClass"

build-common/src/org/jetbrains/kotlin/incremental/impl/ExtraClassInfoGenerator.kt

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,7 @@ import org.jetbrains.kotlin.incremental.KotlinClassInfo.ExtraInfo
1010
import org.jetbrains.kotlin.incremental.impl.ClassNodeSnapshotter.snapshotClassExcludingMembers
1111
import org.jetbrains.kotlin.incremental.impl.ClassNodeSnapshotter.snapshotMethod
1212
import org.jetbrains.kotlin.incremental.impl.ClassNodeSnapshotter.sortClassMembers
13-
import org.jetbrains.kotlin.incremental.storage.DelegateDataExternalizer
14-
import org.jetbrains.kotlin.incremental.storage.DoubleExternalizer
15-
import org.jetbrains.kotlin.incremental.storage.FloatExternalizer
16-
import org.jetbrains.kotlin.incremental.storage.IntExternalizer
17-
import org.jetbrains.kotlin.incremental.storage.LongExternalizer
18-
import org.jetbrains.kotlin.incremental.storage.StringExternalizer
19-
import org.jetbrains.kotlin.incremental.storage.toByteArray
13+
import org.jetbrains.kotlin.incremental.storage.*
2014
import org.jetbrains.kotlin.inline.InlineFunctionOrAccessor
2115
import org.jetbrains.kotlin.inline.inlineFunctionsAndAccessors
2216
import org.jetbrains.kotlin.load.kotlin.header.KotlinClassHeader
@@ -25,7 +19,16 @@ import org.jetbrains.org.objectweb.asm.ClassReader
2519
import org.jetbrains.org.objectweb.asm.ClassVisitor
2620
import org.jetbrains.org.objectweb.asm.tree.ClassNode
2721

28-
internal object ExtraClassInfoGenerator {
22+
23+
open class ExtraClassInfoGenerator() {
24+
protected open fun makeClassVisitor(classNode: ClassNode): ClassVisitor {
25+
return classNode
26+
}
27+
28+
protected open fun calculateInlineMethodHash(methodSignature: JvmMemberSignature.Method, ownMethodHash: Long): Long {
29+
return ownMethodHash
30+
}
31+
2932
fun getExtraInfo(classHeader: KotlinClassHeader, classContents: ByteArray): ExtraInfo {
3033
val inlineFunctionsAndAccessors: Map<JvmMemberSignature.Method, InlineFunctionOrAccessor> =
3134
inlineFunctionsAndAccessors(classHeader, excludePrivateMembers = true).associateBy { it.jvmMethodSignature }
@@ -41,7 +44,7 @@ internal object ExtraClassInfoGenerator {
4144
// + Do not filter out method bodies
4245
val classReader = ClassReader(classContents)
4346
val selectiveClassVisitor = SelectiveClassVisitor(
44-
classNode,
47+
cv = makeClassVisitor(classNode),
4548
shouldVisitField = { _: JvmMemberSignature.Field, isPrivate: Boolean, isConstant: Boolean ->
4649
!isPrivate && isConstant
4750
},
@@ -86,7 +89,8 @@ internal object ExtraClassInfoGenerator {
8689
// class metadata (also in the source file), but not in the bytecode. However, we can safely ignore those
8790
// inline functions/accessors because they are not declared in the bytecode and therefore can't be referenced.
8891
val methodSignature = JvmMemberSignature.Method(name = methodNode.name, desc = methodNode.desc)
89-
inlineFunctionsAndAccessors[methodSignature]!! to snapshotMethod(methodNode, classNode.version)
92+
var methodHash = snapshotMethod(methodNode, classNode.version)
93+
inlineFunctionsAndAccessors[methodSignature]!! to calculateInlineMethodHash(methodSignature, methodHash)
9094
}
9195

9296
return ExtraInfo(classSnapshotExcludingMembers, constantSnapshots, inlineFunctionOrAccessorSnapshots)
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
/*
2+
* Copyright 2010-2025 JetBrains s.r.o. and Kotlin Programming Language contributors.
3+
* Use of this source code is governed by the Apache 2.0 license that can be found in the license/LICENSE.txt file.
4+
*/
5+
6+
package org.jetbrains.kotlin.incremental.impl
7+
8+
import org.jetbrains.kotlin.metadata.jvm.deserialization.JvmMemberSignature
9+
import org.jetbrains.kotlin.resolve.jvm.JvmClassName
10+
import org.jetbrains.org.objectweb.asm.ClassVisitor
11+
import org.jetbrains.org.objectweb.asm.MethodVisitor
12+
import org.jetbrains.org.objectweb.asm.Opcodes
13+
14+
/**
15+
* Used to detect the usage of lambdas in bytecode
16+
*
17+
* It doesn't look perfectly universal, but it passes tests
18+
*/
19+
class InstanceOwnerRecordingClassVisitor(
20+
delegateClassVisitor: ClassVisitor?,
21+
private val methodToUsedClassesMap: MutableMap<JvmMemberSignature.Method, MutableSet<JvmClassName>>? = null,
22+
private val allUsedClassesSet: MutableSet<JvmClassName>? = null,
23+
) : ClassVisitor(Opcodes.ASM9, delegateClassVisitor) {
24+
override fun visitMethod(
25+
access: Int,
26+
name: String?,
27+
descriptor: String?,
28+
signature: String?,
29+
exceptions: Array<out String?>?
30+
): MethodVisitor? {
31+
val methodSignature = name?.let { descriptor?.let { JvmMemberSignature.Method(name, descriptor) } } ?: return null
32+
33+
return object : MethodVisitor(Opcodes.ASM9, super.visitMethod(access, name, descriptor, signature, exceptions)) {
34+
override fun visitFieldInsn(opcode: Int, owner: String?, name: String?, descriptor: String?) {
35+
if (owner != null && opcode == Opcodes.GETSTATIC && name == "INSTANCE") {
36+
val jvmClassName = JvmClassName.byInternalName(owner)
37+
methodToUsedClassesMap?.getOrPut(methodSignature) { mutableSetOf() }?.add(jvmClassName)
38+
allUsedClassesSet?.add(jvmClassName)
39+
}
40+
super.visitFieldInsn(opcode, owner, name, descriptor)
41+
}
42+
}
43+
}
44+
}

compiler/build-tools/kotlin-build-tools-api-tests/src/main/kotlin/compilation/model/JvmModule.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ class JvmModule(
7575
val snapshot = BaseTest.compilationService.calculateClasspathSnapshot(
7676
dependency.location.toFile(),
7777
snapshotConfig.granularity,
78-
//snapshotConfig.useInlineLambdaSnapshotting, //TODO(KT-62555)
78+
snapshotConfig.useInlineLambdaSnapshotting,
7979
)
8080
val hash = snapshot.classSnapshots.values
8181
.filterIsInstance<AccessibleClassSnapshot>()

compiler/build-tools/kotlin-build-tools-api-tests/src/main/kotlin/compilation/model/SnapshotConfig.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,5 +12,5 @@ import org.jetbrains.kotlin.buildtools.api.jvm.ClassSnapshotGranularity
1212
*/
1313
data class SnapshotConfig(
1414
val granularity: ClassSnapshotGranularity,
15-
val useInlineLambdaSnapshotting: Boolean, // TODO(KT-62555) - now has no effect
15+
val useInlineLambdaSnapshotting: Boolean,
1616
)
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
/*
2+
* Copyright 2010-2025 JetBrains s.r.o. and Kotlin Programming Language contributors.
3+
* Use of this source code is governed by the Apache 2.0 license that can be found in the license/LICENSE.txt file.
4+
*/
5+
6+
package org.jetbrains.kotlin.buildtools.api.tests.compilation
7+
8+
import org.jetbrains.kotlin.buildtools.api.CompilerExecutionStrategyConfiguration
9+
import org.jetbrains.kotlin.buildtools.api.jvm.ClassSnapshotGranularity
10+
import org.jetbrains.kotlin.buildtools.api.tests.compilation.model.DefaultStrategyAgnosticCompilationTest
11+
import org.jetbrains.kotlin.buildtools.api.tests.compilation.model.SnapshotConfig
12+
import org.jetbrains.kotlin.buildtools.api.tests.compilation.scenario.scenario
13+
import org.jetbrains.kotlin.buildtools.api.tests.compilation.util.compile
14+
import org.jetbrains.kotlin.buildtools.api.tests.compilation.util.execute
15+
import org.jetbrains.kotlin.test.TestMetadata
16+
import org.junit.jupiter.api.DisplayName
17+
18+
/**
19+
* Test scenarios where type dependency is obscured by an intermediate anonymous type
20+
*/
21+
class AnonymousInheritorTest : BaseCompilationTest() {
22+
23+
@DefaultStrategyAgnosticCompilationTest
24+
@DisplayName("Recompilation of call site affected by an anonymous object - no-inline version")
25+
@TestMetadata("ic-scenarios/inline-local-class/inline-anonymous-object-evil/lib")
26+
fun testAnonymousObjectBaseTypeChangeWithOverloads(strategyConfig: CompilerExecutionStrategyConfiguration) {
27+
scenario(strategyConfig) {
28+
val lib = module("ic-scenarios/inline-local-class/inline-anonymous-object-evil/lib")
29+
val app = module(
30+
"ic-scenarios/inline-local-class/inline-anonymous-object-evil/app",
31+
dependencies = listOf(lib),
32+
snapshotConfig = SnapshotConfig(ClassSnapshotGranularity.CLASS_MEMBER_LEVEL, true),
33+
)
34+
35+
lib.changeFile("callable.kt") { it.replace("inline fun", "fun") }
36+
37+
lib.compile()
38+
app.compile()
39+
40+
app.execute(mainClass = "CallSiteKt", exactOutput = INITIAL_OUTPUT)
41+
42+
lib.replaceFileWithVersion("SomeClass.kt", "withOverload")
43+
44+
lib.compile(expectedDirtySet = setOf("SomeClass.kt", "callable.kt"))
45+
app.compile(expectedDirtySet = setOf())
46+
app.execute(mainClass = "CallSiteKt", exactOutput = WITH_NEW_LAMBDA_BODY)
47+
}
48+
}
49+
50+
private companion object {
51+
const val INITIAL_OUTPUT = "42"
52+
const val WITH_NEW_LAMBDA_BODY = "45"
53+
}
54+
}

compiler/build-tools/kotlin-build-tools-api-tests/src/testCrossModuleIncrementalChanges/kotlin/InlinedLambdaChangeTest.kt

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@ import org.junit.jupiter.api.DisplayName
1818

1919
// When adding or changing tests, make sure that test sources don't have unintended changes in whitespace, copyright notices and similar things:
2020
// debug info is sensitive to changes in line numbers, and it's part of default inline function abiHash
21-
@Disabled("KT-62555")
2221
class InlinedLambdaChangeTest : BaseCompilationTest() {
2322
@DefaultStrategyAgnosticCompilationTest
2423
@DisplayName("When inlined lambda's body changes, its call site is recompiled")
@@ -253,7 +252,7 @@ class InlinedLambdaChangeTest : BaseCompilationTest() {
253252
}
254253

255254
@DefaultStrategyAgnosticCompilationTest
256-
@DisplayName("Recompilation of call site affected by a anonymous object - basic")
255+
@DisplayName("Recompilation of call site affected by an anonymous object - basic")
257256
@TestMetadata("ic-scenarios/inline-local-class/inline-anonymous-object/lib")
258257
fun testAnonymousObjectBaseTypeChange(strategyConfig: CompilerExecutionStrategyConfiguration) {
259258
scenario(strategyConfig) {
@@ -274,9 +273,9 @@ class InlinedLambdaChangeTest : BaseCompilationTest() {
274273
}
275274
}
276275

277-
@Disabled("broken! other snapshotting strategies might work better here")
276+
@Disabled("should be fixed if we start snapshotting bytecode")
278277
@DefaultStrategyAgnosticCompilationTest
279-
@DisplayName("Recompilation of call site affected by a anonymous object - slightly evil")
278+
@DisplayName("Recompilation of call site affected by an anonymous object - slightly evil")
280279
@TestMetadata("ic-scenarios/inline-local-class/inline-anonymous-object-evil/lib")
281280
fun testAnonymousObjectBaseTypeChangeWithOverloads(strategyConfig: CompilerExecutionStrategyConfiguration) {
282281
scenario(strategyConfig) {

compiler/build-tools/kotlin-build-tools-api/api/kotlin-build-tools-api.api

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ public final class org/jetbrains/kotlin/buildtools/api/CompilationResult : java/
1010
public abstract interface class org/jetbrains/kotlin/buildtools/api/CompilationService {
1111
public static final field Companion Lorg/jetbrains/kotlin/buildtools/api/CompilationService$Companion;
1212
public abstract fun calculateClasspathSnapshot (Ljava/io/File;Lorg/jetbrains/kotlin/buildtools/api/jvm/ClassSnapshotGranularity;)Lorg/jetbrains/kotlin/buildtools/api/jvm/ClasspathEntrySnapshot;
13+
public abstract fun calculateClasspathSnapshot (Ljava/io/File;Lorg/jetbrains/kotlin/buildtools/api/jvm/ClassSnapshotGranularity;Z)Lorg/jetbrains/kotlin/buildtools/api/jvm/ClasspathEntrySnapshot;
1314
public abstract fun compileJvm (Lorg/jetbrains/kotlin/buildtools/api/ProjectId;Lorg/jetbrains/kotlin/buildtools/api/CompilerExecutionStrategyConfiguration;Lorg/jetbrains/kotlin/buildtools/api/jvm/JvmCompilationConfiguration;Ljava/util/List;Ljava/util/List;)Lorg/jetbrains/kotlin/buildtools/api/CompilationResult;
1415
public abstract fun finishProjectCompilation (Lorg/jetbrains/kotlin/buildtools/api/ProjectId;)V
1516
public abstract fun getCompilerVersion ()Ljava/lang/String;

compiler/build-tools/kotlin-build-tools-api/src/main/kotlin/org/jetbrains/kotlin/buildtools/api/CompilationService.kt

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,28 @@ public interface CompilationService {
3232
*
3333
* @param classpathEntry path to existent classpath entry
3434
* @param granularity determines granularity of tracking.
35+
* @param parseInlinedLocalClasses enables an experimental snapshotting mode for inline methods and accessors
3536
*/
36-
public fun calculateClasspathSnapshot(classpathEntry: File, granularity: ClassSnapshotGranularity): ClasspathEntrySnapshot
37+
public fun calculateClasspathSnapshot(
38+
classpathEntry: File,
39+
granularity: ClassSnapshotGranularity,
40+
parseInlinedLocalClasses: Boolean
41+
): ClasspathEntrySnapshot
42+
43+
/**
44+
* Calculates JVM classpath snapshot for [classpathEntry] used for detecting changes in incremental compilation with specified [granularity].
45+
*
46+
* The [ClassSnapshotGranularity.CLASS_LEVEL] granularity should be preferred for rarely changing dependencies as more lightweight in terms of the resulting snapshot size.
47+
*
48+
* @param classpathEntry path to existent classpath entry
49+
* @param granularity determines granularity of tracking.
50+
*
51+
* This version of [calculateClasspathSnapshot] would not do any extra work to snapshot local classes used inside inline functions.
52+
*/
53+
public fun calculateClasspathSnapshot(
54+
classpathEntry: File,
55+
granularity: ClassSnapshotGranularity
56+
): ClasspathEntrySnapshot
3757

3858
/**
3959
* Provides a default [CompilerExecutionStrategyConfiguration] allowing to use it as is or customizing for specific requirements.
@@ -95,4 +115,4 @@ public interface CompilationService {
95115
public fun loadImplementation(classLoader: ClassLoader): CompilationService =
96116
loadImplementation(CompilationService::class, classLoader)
97117
}
98-
}
118+
}

0 commit comments

Comments
 (0)