Skip to content
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

Remove usages of getPsi() #2901

Open
wants to merge 16 commits into
base: master
Choose a base branch
from
Open

Remove usages of getPsi() #2901

wants to merge 16 commits into from

Conversation

mgroth0
Copy link

@mgroth0 mgroth0 commented Dec 8, 2024

Description

I observed a performance bottleneck in com.pinterest.ktlint.rule.engine.core.api.isPartOfComment. CPU profiling showed a significant amount of time being wasted doing stuff related to checking for cancellation, like IntelliJ GUI stuff.
Screenshot 2024-12-08 at 11 58 25 AM

Checklist

Before submitting the PR, please check following (checks which are not relevant may be ignored):

  • Commit message are well written. In addition to a short title, the commit message also explain why a change is made.
  • At least one commit message contains a reference Closes #<xxx> or Fixes #<xxx> (replace<xxx> with issue number)
  • Tests are added
  • KtLint format has been applied on source code itself and violations are fixed
  • PR title is short and clear (it is used as description in the release changelog)
  • PR description added (background information)

Documentation is updated. See difference between snapshot and release documentation

  • Snapshot documentation in case documentation is to be released together with a code change
  • Release documentation in case documentation is related to a released version of ktlint and has to be published as soon as the change is merged to master

This PR doesn't include new tests, doesn't reference an issue, and doesn't change documentaiton. It is a performance optimization.

According to IntelliJ's Usages, there are about 65 other usages of ASTNode.getPsi() in the ktlint codebase. I'm not sure if all of them could be removed, but maybe we could try to remove as many of them as possible? If this PR looks good, I suggest we follow up by doing that.

@paul-dingemans
Copy link
Collaborator

Thanks for your contributions. So far, I have just skimmed your changes. I will do a detailed review later. To not block you, I have approved the workflow so that the pipeline will be trigger when you add changes.

I observed a performance bottleneck in com.pinterest.ktlint.rule.engine.core.api.isPartOfComment. CPU profiling showed a significant amount of time being wasted doing stuff related to checking for cancellation, like IntelliJ GUI stuff.

What lead you to analyzing/resolving this problem? Are you resolving problems with Ktlint CLI, ktlint-intellij-plugin, or with any API Consumer? Of course, all will benefit from improved performance. But I am just curious to understand where you are coming from.

@mgroth0
Copy link
Author

mgroth0 commented Dec 10, 2024

I am running ktlint primarily as an API consumer inside of a gradle plugin. I build a large project with hundreds of modules and execute ktlint concurrently on thousands of kotlin files and I have observed frequently my gradle builds seem to pause for long times (1-30 seconds) on ktlint execution. These pauses seem unnaturally long. I don't see pauses like this during regular kotlin compilation or during detekt. So I thought something different might be happening in ktlint. Upon CPU profiling, the flame graph looked like this:

Screenshot 2024-12-10 at 3 19 52 AM

What this told me is that it wasn't one rule, but rather something dispersed throughout ktlint. Then I took a closer look, and that is when I saw the image I shared at the top. The 3081 ms for ProgressIndicatorProvider.checkCancelled() seems very wrong, doesn't it? My understanding is that checkCancelled shouldn't be called during a gradle operation since gradle isn't a GUI operation and won't be cancelled like that. So then I realized that the ASTNode interface is designed to be more lightweight anyway, so I decided to see if I could migrate ktlint from Psi to ASTNode. getPsi() will always do checkCancelled if the node is a CompositeElement, which many elements are.

getPsi() is causing all of this strange pausing, because its checking for cancellation. I don't know why the checkCancelled operation is pausing so much, because my profiler only goes as deep as Cancellation.currentJob which is a kotlinx.coroutines thing, but for some reason that's the bottleneck. I have to suspect maybe Cancellation.currentJob is somehow getting overwhelmed because I'm using many concurrent threads or something, but I don't know.

This result tells a compelling story:

Screenshot 2024-12-10 at 3 33 36 AM

@mgroth0
Copy link
Author

mgroth0 commented Dec 10, 2024

My strategy so far has been:

  1. Search for usages of getPsi() and try to replace it by working with ASTNode
  2. If its in the context of a code edit, ignore it. 99.9% of them time ktlint is just reading code, writes are much more rare for me so its less of a priority.
  3. Ensure tests pass and format as I go

My biggest concern is that even though I ensure tests pass, some of my changes are likely to cause unforseable bugs. I feel bad introducing these bugs, but I feel its neccesary to migrate the code to more lightweight APIs. I hope you agree.

Also, I still have a lot to learn about these APIs and could be making mistakes.

One idea I have is that if this gets merged, the first release after this merge is a beta release allowing a time for people to report bugs, since the tests probably aren't covering every corner case. I don't think these changes can be released as a stable version right away.

I am looking forward to your feedback.

@mgroth0
Copy link
Author

mgroth0 commented Dec 10, 2024

The other main concern that I have is that the ASTNode API doesn't do as much logic for you, so its harder to maintain and develop. It seems with a bit of dedication we could build an ASTNode-based library of extension functions to mimic a lot of the logic of psi, but I have mixed feelings about that.

@mgroth0
Copy link
Author

mgroth0 commented Dec 10, 2024

There are 13 more Rule classes that have usages of getPsi (excluding usages for mutating psi). I left what seemed to be the most difficult for last, so these might take a while. Given that I've shared some concerns I'm going to pause for now and give time for feedback and review.

This could technically be merged as is. The remaining work is just to remove more usages of getPsi, but the current state of the branch is good; all tests pass.

@mgroth0
Copy link
Author

mgroth0 commented Dec 10, 2024

Also I am curious, would ktlint possibly migrate to the analysis API in the future? I don't know much about this, but I wonder if newer APIs like that resolve the problem, like maybe they are both lightweight and also could offer more support with the logic like psi does? Just a thought.

@mgroth0
Copy link
Author

mgroth0 commented Dec 10, 2024

Looking into it more, apparently the Analysis API is overkill for a formatter and has lots of overhead.

This leads me to think maybe creating a small lightweight ASTNode-based support library, largely just copying a lot of the logic from Psi classes, will be helpful.

@mgroth0
Copy link
Author

mgroth0 commented Dec 10, 2024

Basically expanding ASTNodeExtension.kt and minimize psi usage

@mgroth0 mgroth0 changed the title implement ASTNode.isPartOfComment without psi Remove usages of getPsi() Dec 10, 2024
@paul-dingemans
Copy link
Collaborator

I am running ktlint primarily as an API consumer inside of a gradle plugin. I build a large project with hundreds of modules and execute ktlint concurrently on thousands of kotlin files and I have observed frequently my gradle builds seem to pause for long times (1-30 seconds) on ktlint execution. These pauses seem unnaturally long.

Interesting to know this. Personally I use ktlint CLI only on small projects, so I have never noticed this.

What this told me is that it wasn't one rule, but rather something dispersed throughout ktlint. Then I took a closer look, and that is when I saw the image I shared at the top. The 3081 ms for ProgressIndicatorProvider.checkCancelled() seems very wrong, doesn't it? My understanding is that checkCancelled shouldn't be called during a gradle operation since gradle isn't a GUI operation and won't be cancelled like that. So then I realized that the ASTNode interface is designed to be more lightweight anyway, so I decided to see if I could migrate ktlint from Psi to ASTNode. getPsi() will always do checkCancelled if the node is a CompositeElement, which many elements are.

Ktlint uses both the ASTNode interface as well as Psi. I expect that it dependended on the knowdledge/experience of rule developers which approach was used. In most cases I prefer the ASTNode, as it is way more easy to comprehend. On the other hand Psi ofter gives access to helper functions, but I find them typically hard to find. But I have never (until this issue) seen a reason to migrate away from PSI fully.

getPsi() is causing all of this strange pausing, because its checking for cancellation. I don't know why the checkCancelled operation is pausing so much, because my profiler only goes as deep as Cancellation.currentJob which is a kotlinx.coroutines thing, but for some reason that's the bottleneck. I have to suspect maybe Cancellation.currentJob is somehow getting overwhelmed because I'm using many concurrent threads or something, but I don't know.

I guess that the cancellation checking especially makes sense when running within the context of IntelliJ IDEA. For ktlint (including the ktlint-intellij-plugin) this does not seem relevant though.

My strategy so far has been:

  1. Search for usages of getPsi() and try to replace it by working with ASTNode
  2. If its in the context of a code edit, ignore it. 99.9% of them time ktlint is just reading code, writes are much more rare for me so its less of a priority.
  3. Ensure tests pass and format as I go

Sounds reasonable.

My biggest concern is that even though I ensure tests pass, some of my changes are likely to cause unforseable bugs. I feel bad introducing these bugs, but I feel its neccesary to migrate the code to more lightweight APIs. I hope you agree.

Also, I still have a lot to learn about these APIs and could be making mistakes.

I have no problem with this. I am quite confident about the test converage of most rules. Those tests cover both linting and formatting. But I hope that I can count on your support in case such errors will occur.

One idea I have is that if this gets merged, the first release after this merge is a beta release allowing a time for people to report bugs, since the tests probably aren't covering every corner case. I don't think these changes can be released as a stable version right away.

After each merge, a SNAPSHOT version of ktlint is released. The ktlint build pipeline does not support BETA builds as far as I know. Also, that would not make a lot of sense to me, as users of ktlint do not seem to be interested/invested in testing new versions before the actual release. That is the main reason that after major/minor release usually one patch version is needed a couple of weeks after the major/minor release.

The other main concern that I have is that the ASTNode API doesn't do as much logic for you, so its harder to maintain and develop. It seems with a bit of dedication we could build an ASTNode-based library of extension functions to mimic a lot of the logic of psi, but I have mixed feelings about that.

This is indeed a concern. But there is no need to be stressed about it too much. We don't need to replace 100% of Psi with AST. First we can pick the low hanging fruit. By doing so, we learn more about what works or doesn't work. Those learnings will help with future decision making.

There are 13 more Rule classes that have usages of getPsi (excluding usages for mutating psi). I left what seemed to be the most difficult for last, so these might take a while. Given that I've shared some concerns I'm going to pause for now and give time for feedback and review.

This could technically be merged as is. The remaining work is just to remove more usages of getPsi, but the current state of the branch is good; all tests pass.

Agree. Let's first work on wrapping/merging this part.

Also I am curious, would ktlint possibly migrate to the analysis API in the future? I don't know much about this, but I wonder if newer APIs like that resolve the problem, like maybe they are both lightweight and also could offer more support with the logic like psi does? Just a thought.

Looking into it more, apparently the Analysis API is overkill for a formatter and has lots of overhead.

This leads me to think maybe creating a small lightweight ASTNode-based support library, largely just copying a lot of the logic from Psi classes, will be helpful.

Basically expanding ASTNodeExtension.kt and minimize psi usage

I see no reason to migrate to the analysis API. Expanding ASTNodeExtension is fine. Let's see how this works out.

Thanks for your efforts sofar. It is nice to have input from a totally different perspective. I will start detailed review.

@mgroth0
Copy link
Author

mgroth0 commented Dec 15, 2024

But I hope that I can count on your support in case such errors will occur.

I cannot make a strong commitment to make fixes in a timely manner due to my other priorities, but please feel free to flag me on any bug report that involves my changes and I will help when I can.

Copy link
Collaborator

@paul-dingemans paul-dingemans left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is really a promising initative!

Please don't be dishartened by the amount of review remarks. Most of them are relatively minor because you are not fully acquainted with the code base.

I do have some concerns about replacing all Psi code with own AstNode although the readability is improved on lots of places. Sofar, I have seen no changes that should not have been ported from Psi.

@@ -6,6 +6,7 @@ public final class com/pinterest/ktlint/rule/engine/core/api/ASTNodeExtensionKt
public static final fun findCompositeParentElementOfType (Lorg/jetbrains/kotlin/com/intellij/lang/ASTNode;Lorg/jetbrains/kotlin/com/intellij/psi/tree/IElementType;)Lorg/jetbrains/kotlin/com/intellij/lang/ASTNode;
public static final fun firstChildLeafOrSelf (Lorg/jetbrains/kotlin/com/intellij/lang/ASTNode;)Lorg/jetbrains/kotlin/com/intellij/lang/ASTNode;
public static final fun getColumn (Lorg/jetbrains/kotlin/com/intellij/lang/ASTNode;)I
public static final fun getPrevSiblingIgnoringWhitespaceAndComments (Lorg/jetbrains/kotlin/com/intellij/lang/ASTNode;)Lorg/jetbrains/kotlin/com/intellij/lang/ASTNode;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Whenever a function is added to the public API, we get a responsibility to maintain it at least until the next major release. As of that we should be careful with:

  • Naming of the function
  • Avoiding duplication

For each public method that is added to the ASTNodeExtension the following is required:

  • API documentation
  • Unit tests in ASTNodeExtensionTest. Typically the methods need multiple units tests, which should be collected in an inner class.

@@ -183,11 +184,17 @@ public fun ASTNode.parent(
return null
}

public fun ASTNode.isPartOf(tokenSet: TokenSet): Boolean = parent(predicate = { tokenSet.contains(it.elementType) }, strict = false) != null
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I prefer trailing lambda style when possible:

public fun ASTNode.isPartOf(tokenSet: TokenSet): Boolean = parent(strict = false) { tokenSet.contains(it.elementType) } != null

Comment on lines +194 to +197
@Deprecated(
"psi is a performance issue, see https://github.com/pinterest/ktlint/pull/2901",
replaceWith = ReplaceWith("ASTNode.isPartOf(elementType: IElementType) or ASTNode.isPartOf(tokenSet: TokenSet)"),
)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Replace with:

@Deprecated(
    "Marked for removal in Ktlint 2.x. Replace with ASTNode.isPartOf(elementType: IElementType) or ASTNode.isPartOf(tokenSet: TokenSet). " +
        "This method might cause performance issues, see https://github.com/pinterest/ktlint/pull/2901",
    replaceWith = ReplaceWith("this.isPartOf(elementTypeOrTokenSet)"),
)

Note the replaceWith. IDEA interprets the replaceWith and provides an action that does the suggested refactoring. Not only is this helpful for refactoring rules in Ktlint, but also for external rule providers.
Screenshot 2024-12-15 at 15 57 38
or
Screenshot 2024-12-15 at 15 58 27
and after clicking
Screenshot 2024-12-15 at 15 58 52

@@ -223,10 +230,21 @@ public fun ASTNode.isLeaf(): Boolean = firstChildNode == null
*/
public fun ASTNode.isCodeLeaf(): Boolean = isLeaf() && !isWhiteSpace() && !isPartOfComment()

public fun ASTNode.isPartOfComment(): Boolean = parent(strict = false) { it.psi is PsiComment } != null
public fun ASTNode.isPartOfComment(): Boolean = isPartOf(TokenSets.COMMENTS)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice, this improve readability.

Comment on lines +242 to +245
children().forEach {
yield(it)
yieldAll(it.recursiveChildren())
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Replace with:

        children().forEach { yieldAll(it.recursiveChildren(includeSelf = true)) }

?.getPrevSiblingIgnoringWhitespaceAndComments(withItself = false)
?.takeIf { it is KtDeclaration }
?.getPrevSiblingIgnoringWhitespaceAndComments()
?.takeIf { KtTokenSets.DECLARATION_TYPES.contains(it.elementType) }
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • Replace contains with in
  • KtTokensSets is still declared in Psi (Java) library. It might be better to copy to our TokensSets.kt.

Comment on lines 304 to 312
val addNewLine =
leafBeforeArrowOrNull
?.let { !(leafBeforeArrowOrNull is PsiWhiteSpace && leafBeforeArrowOrNull.textContains('\n')) }
?.let {
!(
leafBeforeArrowOrNull.elementType == ElementType.WHITE_SPACE &&
leafBeforeArrowOrNull.textContains('\n')
)
}
?: false
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This can be simplified to:

                    val addNewLine = !(leafBeforeArrowOrNull?.isWhiteSpaceWithNewline() ?: true)

but it requires another change (see next review comment) to resolve compilation errors.

Comment on lines +332 to +333
if (leafBeforeArrowOrNull.elementType == ElementType.WHITE_SPACE) {
(leafBeforeArrowOrNull.psi as LeafPsiElement).rawReplaceWithText(indent)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Replace with:

                            if (leafBeforeArrowOrNull.isWhiteSpace()) {
                                (leafBeforeArrowOrNull?.psi as LeafPsiElement).rawReplaceWithText(indent)

@@ -341,7 +343,7 @@ public class TrailingCommaOnDeclarationSiteRule :

if (inspectNode.treeParent.elementType == ElementType.ENUM_ENTRY) {
val parentIndent =
(prevNode.psi.parent.prevLeaf() as? PsiWhiteSpace)?.text
(prevNode.treeParent.prevLeaf()?.takeIf { it.elementType == ElementType.WHITE_SPACE })?.text
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Replace with:

                                (prevNode.treeParent.prevLeaf()?.takeIf { it.isWhiteSpace() })?.text

(psi as KtFunctionLiteral)
.arrow
?.prevLeaf()
when (elementType) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Replace when statement with:

        takeIf { it.elementType == WHEN_ENTRY || it.elementType == FUNCTION_LITERAL }
            ?.findChildByType(ARROW)
            ?.prevLeaf()

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants