-
-
Notifications
You must be signed in to change notification settings - Fork 393
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Method call analysis based testQuick
command
#4731
base: main
Are you sure you want to change the base?
Conversation
def isForwarderCallsiteOrLambda = | ||
callSiteOpt.nonEmpty && { | ||
val callSiteSig = callSiteOpt.get.sig | ||
|
||
(callSiteSig.name == (calledSig.name + "$") && | ||
callSiteSig.static && | ||
callSiteSig.desc.args.size == 1) | ||
|| ( | ||
// In Scala 3, lambdas are implemented by private instance methods, | ||
// not static methods, so they fall through the crack of "isSimpleTarget". | ||
// Here make the assumption that a zero-arg lambda called from a simpleTarget, | ||
// should in fact be tracked. e.g. see `integration.invalidation[codesig-hello]`, | ||
// where the body of the `def foo` target is a zero-arg lambda i.e. the argument | ||
// of `Cacher.cachedTarget`. | ||
// To be more precise I think ideally we should capture more information in the signature | ||
isSimpleTarget(callSiteSig.desc) && calledSig.name.contains("$anonfun") | ||
) | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
All the other special cases except this one are heuristics unique to MillBuildRootModule
, and so probably should live there. We can have a def codeSigIgnoreCall
method that gets fed into CodeSig.compute
, with a default implementation for JavaModule
and a more detailed override for MillBuildRootModule
I don't understand the problem are describing here, could you go into more detail |
@@ -92,6 +95,95 @@ trait JavaModule | |||
case _: ClassNotFoundException => // if we can't find the classes, we certainly are not in a ScalaJSModule | |||
} | |||
} | |||
|
|||
private def quickTest(args: Seq[String]): Task[(String, Seq[TestResult])] = | |||
Task(persistent = true) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Task(persistent = true)
is probably the wrong thing here, since def quickTest
takes arguments. We should add a Command(persistent = true)
flag and keep it as a command similar to the other test tasks
This seems like a post-processing step, could we put it in a separate method that takes the output of |
Here is the reproduce steps:
In this case, the first =================================
I also consider post process this at first, but the output of pre-jsonified |
@HollandDM the problem with |
Let's split it into three methods then: one shared one, one that uses the shared helper to generate the tree, and one that uses the shared helper to generate a list of affected classes |
e32c777
to
353efb6
Compare
val jsonValueQueue = mutable.ArrayDeque[(String, ujson.Value)]() | ||
val prefixTrie = new PrefixTrie[String]() | ||
jsonValueQueue.appendAll(spanningInvalidationTreeObj.value) | ||
while (jsonValueQueue.nonEmpty) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I also consider post process this at first, but the output of pre-jsonifiedspanningInvalidationTree is too easy to work with, so I went with this approach. I will update this into a separate post-processing step
Let's split it into three methods then: one shared one, one that uses the shared helper to generate the tree, and one that uses the shared helper to generate a list of affected classes
I went with this approach first, as I think leaving callGraphAnalysis
folder as it is right now is quite good. This pre-processing seem quick enough when I run scalalib.test.testQuick
, so I think it is fine. WDYT?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Looking at the code, I think splitting it into three methods is better. The various string operations necessary to do the post-processing look pretty fragile, and while it no doubt works, it probably will be harder to maintain and refactor in future compared to working with typed data structures
/** | ||
* This version allow [[Command]] to be persistent | ||
*/ | ||
inline def Command[T](inline persistent: Boolean)(inline t: Result[T])(implicit |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
add a Command with persistent
option.
8e48d40
to
d5e5433
Compare
I think the approach looks reasonable. Questions I have, more on the semantics and expected behavior rather than the code:
Eventually all this should probably make its way into a unit/integration test suite, but for now manual testing is fine just to figure out what the boundaries and limitations of this featue |
|
The callgraph analysis should be able to handle cross-module analysis if all the classfiles are available on disk; you should be able to aggregate the |
19ecabf
to
cce94ef
Compare
I've update the code to include all upstream module while running the |
@@ -47,7 +48,8 @@ object ExternalSummary { | |||
|
|||
def load(cls: JCls): Unit = methodsPerCls.getOrElse(cls, load0(cls)) | |||
|
|||
def load0(cls: JCls): Unit = { | |||
// Some macros implementations will fail the ClassReader, we can skip them |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What kind of error do those macro implementations produce? We should try and be specific about what errors we catch here, to avoid silencing unexpected errors that may indicate real issues
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If you run core.define.compile
and then check class/mill/define/Cross$Factory$.class
in out
folder, you will see something like this
// Source code is decompiled from a .class file using FernFlower decompiler.
package mill.define;
import java.io.Serializable;
import mill.define.internal.CrossMacros;
import mill.define.internal.CrossMacros.;
import scala.runtime.ModuleSerializationProxy;
public final class Cross$Factory$ implements Serializable {
public static final Cross$Factory$ MODULE$ = new Cross$Factory$();
public Cross$Factory$() {
}
private Object writeReplace() {
return new ModuleSerializationProxy(Cross$Factory$.class);
}
public CrossMacros inline$CrossMacros$i1(final internal x$0) {
return .MODULE$;
}
}
The retrieved call from asm yield something like this:
def mill.define.Cross$Factory$#inline$CrossMacros$i1(mill.define.internal)mill.define.internal
As you can see mill/define/internal
is not a valid class, so class loader will throw when trying to load this up.
I don't know why the code is like this, I will try to reproduce this and look around for clues
} | ||
|
||
logger.mandatoryLog(spanningInvalidationTree) | ||
def calculateInvalidClassName( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Probably should call this calculateInvalidatedClassName
to avoid confusion
logger.mandatoryLog(callAnalysis.methodCodeHashes) | ||
logger.mandatoryLog(callAnalysis.prettyCallGraph) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Are these two mandatoryLog
s necessary? It seems we only use transitiveCallGraphHashes
and spanningInvalidationTree
, at least as far as I can tell
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I have no ideal either, I put them here to mimic the original code, which log everything like this
/** | ||
* Get all class names that have their hashcode changed compared to prevTransitiveCallGraphHashes | ||
*/ | ||
def invalidClassNames( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
invalidatedClassNames
|
||
import scala.collection.mutable | ||
|
||
private[scalalib] final class PrefixTrie[A](using CanEqual[A, A]) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This seems to be unused?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
True, I must have missed it
// We can ignore all calls to methods that look like Targets when traversing | ||
// the call graph. We can do this because we assume `def` Targets are pure, | ||
// and so any changes in their behavior will be picked up by the runtime build | ||
// graph evaluator without needing to be accounted for in the post-compile | ||
// bytecode callgraph analysis. | ||
def isSimpleTarget(desc: mill.codesig.JvmModel.Desc) = | ||
(desc.ret.pretty == classOf[mill.define.Target[?]].getName || | ||
desc.ret.pretty == classOf[mill.define.Worker[?]].getName) && | ||
desc.args.isEmpty | ||
|
||
// We avoid ignoring method calls that are simple trait forwarders, because | ||
// we need the trait forwarders calls to be counted in order to wire up the | ||
// method definition that a Target is associated with during evaluation | ||
// (e.g. `myModuleObject.myTarget`) with its implementation that may be defined | ||
// somewhere else (e.g. `trait MyModuleTrait{ def myTarget }`). Only that one | ||
// step is necessary, after that the runtime build graph invalidation logic can | ||
// take over | ||
def isForwarderCallsiteOrLambda = | ||
callSiteOpt.nonEmpty && { | ||
val callSiteSig = callSiteOpt.get.sig | ||
|
||
(callSiteSig.name == (calledSig.name + "$") && | ||
callSiteSig.static && | ||
callSiteSig.desc.args.size == 1) | ||
|| ( | ||
// In Scala 3, lambdas are implemented by private instance methods, | ||
// not static methods, so they fall through the crack of "isSimpleTarget". | ||
// Here make the assumption that a zero-arg lambda called from a simpleTarget, | ||
// should in fact be tracked. e.g. see `integration.invalidation[codesig-hello]`, | ||
// where the body of the `def foo` target is a zero-arg lambda i.e. the argument | ||
// of `Cacher.cachedTarget`. | ||
// To be more precise I think ideally we should capture more information in the signature | ||
isSimpleTarget(callSiteSig.desc) && calledSig.name.contains("$anonfun") | ||
) | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
These should be in the MillBuildRootModule
override method I think; they look specific to Mill's own "ignore Task
-returning methods" heuristic that won't apply to normal code
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If we allow analysis to go through Task
-liked classes/objects, then by design it will spread into all task
s. In mill
project there is tests that create a custom module of Test Module
(TestModuleTestUtils
, etc...). So in this case it will trigger every task
s.
Should we keep it like this, or allow testQuick
to trigger task
s like that?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The issue is that this problem exists for other interfaces as well. For Mill and Mill plugins the heuristic is to ignore Task
, but for other projects with other base abstractions the heuristic would need to be something different to be useful. Let's think if we can come up with a more general heuristic that isn't hardcoded to depend on Mill-specific types like Task
, using the data we already have
What if rather than using a method-based callgraph, we did the analysis on a more coarse grained class-based dependency graph? This is something we can't really do for invalidating tasks since tasks are individual methods, but since test selection is at a granularity of classes that might work. Presumably if ClassA
never directly or transitively references ClassB
, and ClassA
doesn't take any constructor parameters (since it's a test suite class and those usually have zero parameters) then changes to ClassB
should never affect ClassA
? This feels like an approach that could either replace the existing method-level approach, or be stacked on top as an additional heuristic (e.g. only invalidate a test or task if both the method-level codesig and the class-level codesig both change)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think we still need to analyze method call to identify which classes depend on which classes, and with the nature of virtual invoke, we will still comeback to the case where call method in traits being translated to calling every concrete classes of that trait, right?
Left some comments. Let's also add some unit tests in
Since we don't need to make any changes to the |
91ca3f2
to
e8b5640
Compare
@lihaoyi I've update the code to address the comments and answer some questions. For using class based graph instead of method call, I've tried using the class dep graph produced from zinc as reference, while it works, I still need to use the If the code is good to go, I'll proceed with writing test cases |
Thanks @HollandDM ! will take a look |
I think the code looks reasonable, at least as I would expect. However I would like to push a bit more on the design of the feature. How does Zinc implement |
Not sure what zinc's |
testQuick
commandtestQuick
command
Add
testQuick
command that leverageCallGraphAnalysis
.This pull request introduces the
testQuick
command, which utilizesCallGraphAnalysis
. The following changes have been implemented:CallGraphAnalysis
now includeinvalidClassNames
, which contains the names of test classes extracted from thespanningInvalidationTree
. This list indicates the classes that are affected by code changes.callGraphAnalysis
task has been added toJavaModule
. This is a persistent task that, when executed, generates acallGraphAnalysis
folder containing data related to theCallGraphAnalysis
feature.codeSignatures
ofMillBuildRootModule
have been updated to incorporate the results fromcallGraphAnalysis
.testQuick
command has been added toJavaTests
. It uses theinvalidClassNames
output fromcallGraphAnalysis
and a dedicated log file,quickTestFailedClasses.log
, which stores the failed test classes from previous runs.testQuick
combines the information from these two sources to determine the set of test classes to run./claim #4109
see also #4787 for
Zinc
based design