-
-
Notifications
You must be signed in to change notification settings - Fork 5.5k
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
perf(treesitter): partial injection and incremental invalidation #26827
base: master
Are you sure you want to change the base?
Conversation
bffa2b6
to
53c9cc7
Compare
if | ||
vim.tbl_contains(preds, function(pred) | ||
return vim.deep_equal(pred, { 'set!', 'injection.combined' }) | ||
end, { predicate = 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.
Q: Can injection.combined
be extracted from metadata
(see LanguageTree:_get_injection
) or do we have to search all the predicates/directives by a for loop? If so, can you elaborate more on why?
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.
It's too late at that point. _get_injection
is invoked after iter_matches
, but we need to check the existence of combined injection before calling iter_matches
to compute the start_line
/end_line
arguments.
53c9cc7
to
9650455
Compare
e0ffa59
to
433257f
Compare
This comment was marked as resolved.
This comment was marked as resolved.
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 looks good, but I'm not really convinced with the _batch_*
method here. It makes the languagetree significantly more complicated and is only optimized for the highlighter. Any plugins that need to parse specific buffers with a range aren't going to see any of the benefits.
What I suggest we do is to look at what Helix has done with incremental injections, and see if we can borrow anything from them. https://github.com/helix-editor/helix/blob/0c81ef73e17a3d45cd6240fd5933ad99b3a60d01/helix-core/src/syntax.rs#L1012
Once we've solved all our invalidation bugs, then maybe we can rethink the parse()
API to better handle multiple ranges due to multiple windows, if adding/removing the languages really is a significant cost. I suspect it isn't common enough for a buffer to be visible in multiple windows at once and thus this isn't something we should be optimizing for.
Another route (and potentially better) would be to make changes to the decoration provider API to better accommodate batch updates, i.e. by calling parse()
once after all the on_wins
have been invoked so we can collect the ranges.
I don't think it's that uncommon -- having the the definition of a (local) function and its calling site visible at the same time seems like something people do regularly (at least I do). I'd expect this to be more common the longer the file (and thus the more expensive the parsing), too. Whether that warrants specific optimization (you'll probably only editing one site) is another question, of course. |
---@param exclude_children boolean|nil whether to ignore the validity of children (default `false`). | ||
--- `is_valid(false)` is sound but not complete, i.e., | ||
--- * if it returns `true`, this LanguageTree is actually valid; but | ||
--- * even if this LanguageTree is actually valid, it may return `false`. |
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 documenting bugs and defining it has a part of the API.
is_valid(false)
should always return true if the languagetree is valid.
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.
making it complete is possible but that would incur quite a bit of overhead (see note below), and i thought that kinda defeats the purpose of this function (quickly and soundly check necessity of parse(true)
). i will implement it when i return
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.
fixed by running full injection in is_valid(false)
3d1560b#diff-70b0f3fa7d6baf9e605400a9f0e10431183df821ec4096e8097eaa5f4d5531baR310-R314
Sure, I do this all the time. However, at the very most this is in a split with the buffer in at most 2 windows. Each additional window is exponentially less likely. |
iiuc helix doesn't do paritial injection: https://github.com/helix-editor/helix/blob/0c81ef73e17a3d45cd6240fd5933ad99b3a60d01/helix-core/src/syntax.rs#L1097 the batching is primarily for correcteness of highlights. without it the highlights will ficker when the buffer is shown in multiple windows as the regions created by on_win of a window are discarded by on_win of another window. i agree that better decoration api for batching is a more natural approach |
That shouldn't be the root cause of the flicker. Once all the lines for a window have been drawn during a redraw cycle, subsequent windows shouldn't cause flicker in previous windows unless something else is invoking another redraw. But also, the regions being discarded is just a bug in our highlighter we need to fix. We shouldn't be layering on API methods to work around this.
Ah true, it's only the parsing that is incremental, but we already do that. However I think their invalidation logic is less buggy than ours, so that's probably what we should fix first. |
hmm in fact, I can't no longer reproduce this flicker. So the only valid role of batching is optimization: prevention of discarding regions and adding/removing children.
In this PR,
So there should be some mechanism to discard stale regions. This PR eagerly removes stale region, which necessitates batching. But it can be avoided if we use less eager method such as assigning some lifespan to each region.
IIUC Helix is not incremental in the sense that it doesn't care about what's visible on the screen. Also I think their invalidation logic isn't much different from this PR's. Here's a summary of their approach. Their
A notable difference from nvim is that |
eb3e69c
to
dd129fe
Compare
21b6df5
to
3af56c9
Compare
3af56c9
to
9831d29
Compare
9831d29
to
1cc6f4d
Compare
This comment was marked as resolved.
This comment was marked as resolved.
51aedb4
to
058d79c
Compare
Turns out this is actually quite easy to implement. The last commit optimizes the multi-window scenarios by making |
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.
Looks really good now. Well done!
Just in case this does cause some regressions, maybe better hold off until 0.10 is released, but happy to be overruled on that.
return vim | ||
.iter(region) | ||
:filter(function(r) | ||
return r[3] ~= r[6] | ||
end) | ||
:totable() |
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.
Can we change this to use vim.tbl_filter
. vim.iter
should be only used for pipelines of transforms.
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.
Hold that thought; tbl_filter
is slated for deprecation in favor of -- wait for it -- vim.iter():filter():totable()
.
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 disagree with that deprecation. We should not view vim.iter
as a replacement for any vim.tbl_*
functions. The entire point of vim.iter
is for chaining operations.
If we do deprecate vim.tbl_filter
, then I would prefer this just be a manual pairs
loop.
local filtered = {}
for _, r in pairs(region) do
if r[3] ~= r[6] then
table.insert(filtered, r)
end
end
return filtered
Only +1 LOC without having to understand an intermediate API.
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.
Then please make that case at #24572; I'm just the messenger.
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.
Looks like there is no immediate plan: #24572 (comment)
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 entire point of
vim.iter
is for chaining operations.
That rule came from nowhere. The point of vim.iter as described in #18585 is to provide an interface for iterables.
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.
That rule came from the author of vim.iter
, which has been mentioned more than once in chat (https://matrix.to/#/!HCjHPBLFfoFpYNgwdE:matrix.org/$4IeF8fqm7MgS13jR3igTjFkYrp1LGlesKpTfj2tf_wI?via=matrix.org&via=kde.org&via=smittie.de "vim.iter is not a collection of “convenience” functions, it’s about creating iterator chains" ). Sorry to pull you in @gpanders but am I wrong here?
This is become a noticable problem since contributers have been reaching for vim.iter
unnecessarily when almost all of the time a regular pairs
/ipairs
loop is enough and more readable.
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 agree that we do not need to use vim.iter every single time we need to perform a simple filter operation. Likewise for tbl_filter. I agree with @lewis6991, a regular for loop is often more readable and even faster.
vim.iter provides a generic iterator interface which is designed for working with pipelines. If your pipeline has only a single stage, then it’s just a complicated way of writing a for loop. Lua already has a “builtin” iterator concept so why not leverage that when it makes sense?
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.
Replaced with probably more efficient(?) in-place update
https://github.com/neovim/neovim/compare/058d79cf7464732fc4fa5400994c614b49293c70..7b7736ced849114dcab5f3140e1565452d1ddb3d#diff-70b0f3fa7d6baf9e605400a9f0e10431183df821ec4096e8097eaa5f4d5531baR847-R858
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 your pipeline has only a single stage, then it’s just a complicated way of writing a for loop.
No disagreement there. I just want to make it clear that vim.iter
is intended for all kinds of iterables, to avoid the idea that we need table-only or list-only analogs for its functionality.
---@param region1 Range6[] | ||
---@param region2 Range6[] | ||
---@return boolean | ||
local function region_similar(region1, region2) |
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.
Needs a description. What is "similar"?
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.
added some comment
https://github.com/neovim/neovim/compare/058d79cf7464732fc4fa5400994c614b49293c70..7b7736ced849114dcab5f3140e1565452d1ddb3d#diff-70b0f3fa7d6baf9e605400a9f0e10431183df821ec4096e8097eaa5f4d5531baR657-R659
https://github.com/neovim/neovim/compare/058d79cf7464732fc4fa5400994c614b49293c70..7b7736ced849114dcab5f3140e1565452d1ddb3d#diff-70b0f3fa7d6baf9e605400a9f0e10431183df821ec4096e8097eaa5f4d5531baR795-R806
@@ -98,6 +113,21 @@ local LanguageTree = {} | |||
|
|||
LanguageTree.__index = LanguageTree | |||
|
|||
---@param injection_query vim.treesitter.Query | |||
---@return boolean | |||
local function query_has_combined(injection_query) |
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.
We may want to move this logic into vim.treesitter.Query
and have it also calculate some other metadata when loading a query.
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 about doing this in Query.new
and storing the result in a field? query.has_combined
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.
That's basically what I'm suggesting. Doesn't need to be done this PR but keep it in mind.
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.
7b7736c
to
1dc295c
Compare
1dc295c
to
c36b9c7
Compare
|
c36b9c7
to
ca856be
Compare
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.
Giving this a closer look now and it looks quite complicated. We may want to think about promoting the region stuff into its own abstraction via a class or module.
for i, _ in pairs(self._trees) do | ||
regions[i] = self._trees[i]:included_ranges(true) | ||
local region = self._trees[i]:included_ranges(true) | ||
prune_empty_ranges(region) |
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 needs explaining. Why is treesitter giving back empty ranges? Is that a bug?
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 happens when the user edits a region in a way that deletes a range in it.
Consider the following markdown text.
* 1
2
Initially, the markdown_inline parser has a single region { { 0, 2, 2, 1, 0, 4 }, { 1, 2, 6, 1, 3, 7 } }
.
If you delete the second line, it becomes { { 0, 2, 2, 1, 0, 4 }, { 1, 0, 4, 1, 0, 4 } }
. This means that tree:edit()
doesn't delete empty ranges itself.
Maybe this could be considered a bug in treesitter.
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.
EIther way, this is useful information that should be included in the code.
regions_inv_insert(regions_inv, i, region) | ||
else | ||
self._trees[i] = nil | ||
if type(self._valid) == 'table' then |
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 about the case when self._valid
is a boolean?
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 it's boolean, its value doesn't need to be changed.
- if was
true
: the remaining regions are all valid. so stilltrue
- if was
false
:- if the removed region is the only region in this language tree, any of
false
/true
is fine - otherwise, remaining regions are invalid. so still
false
.
- if the removed region is the only region in this language tree, any of
---Inverse of `_regions`. start byte of range 1 ↦ { region1, index1, region2, index2, .. }. | ||
---Used for checking if a new region is already managed by this parser, so that it can be parsed | ||
---incrementally. | ||
---@field private _regions_inv table<integer, (Range6[]|integer)[]>? |
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 find this field quite confusing.
Would it be easier to split this into two table, one for the regions and one for the indexes?
---List of regions this tree should manage and parse. If nil then regions are | ||
---taken from _trees. This is mostly a short-lived cache for included_regions() | ||
---@field private _regions table<integer, Range6[]>? | ||
--- | ||
---Inverse of `_regions`. start byte of range 1 ↦ { region1, index1, region2, index2, .. }. |
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 don't understand this. Can it be explained more?
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.
Copied from the comments I added in the latest force push:
Inverse region table, i.e., a (chaining) hash table from regions to their index in _region
.
Used for checking if an added region is already managed by this parser, so that it can reuse
the existing tree for incremental parsing.
The hash function is simply region[1][3]
(the start byte of its first range).
Each bucket has the shape of { region1, index of region1, region2, index of region2, ... }.
I chose this representation to avoid additional indirection when using the { { region1, index1 }, { region2, index2 }, ...}
shape.
---@return integer? | ||
---@return boolean? exact |
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.
Can you add descriptions for these.
Problem: After an edit that changes the number of injection regions, the LanguageTree drops all the existing trees. This inefficient because the injections should be parsed from scratch. Solution: When setting included regions, match them with the existing regions so that they can be reparsed incrementally. This uses a table that maps region values to their indices. Regions are matched by "similarity", because some changes of regions cannot be precisely tracked by `_edit()`. Breaking change: The indices of `parser:trees()` behave now differently because existing regions are reused. So `parser:parse(true)` does not ensure that the tree table is list-like. Also, when new regions are added manually, they are first added and then the stale regions are discarded. So the existing uses of `trees[1]` may break. Use `next(trees())` instead.
Problem: Executing injection query on the full source is slow. Solution: Execute injection query only on the given range. Notes * This is not applicable to languages with combined injection. * `is_valid(false)` should run full injection to determine if the current set of children parsers and their regions are complete. Since this can be slow, `parse()` no longer checks this at the beginning. * Children parsers and regions outside the given range are discarded.
Problem: Partial injection invalidates regions and children parsers outside the visible range (passed to `parse`). Invalidating non-matching regions for each `parse()` is not efficient if multiple windows display different ranges of the same buffer. Solution: Let `parse()` take set of ranges, and invoke `parse()` for all visible ranges at `on_start`.
|LanguageTree:trees()| no longer guarantees that the returned table is list-like even after a full parse. NOTE: Actually, they should work fine without modification in these specific cases. * dev.lua: For the root tree, the returned table is always `{ tree }` unless the private method `LanguageTree:set_included_regions` is used. * gen_help_html.lua: `trees()` is initially list-like in the very first parse. So `ipairs` should be fine if source is not modified. However, it seems better to "fix" them for consistency.
ca856be
to
f38aae9
Compare
While the parser itself is fast, the way Neovim handles injections isn't great (= it runs them for every change). While there is a PR open that improves this (neovim/neovim#26827) by only running injections for visible lines, I doubt this will be merged any time soon. Since there's sadly no reliable way of _just_ disabling injections, we'll need to disable highlights and indents entirely for Inko.
Until neovim/neovim#26827 (or something similar) is implemented, the only way to reduce the significant input latency that Tree sitter introduces is to remove the query files used for injections. Simply clearing their contents doesn't appear to be enough, as Neovim is seemingly still trying to run them.
Until neovim/neovim#26827 (or something similar) is implemented, the only way to reduce the significant input latency that Tree sitter introduces is to remove the query files used for injections. Simply clearing their contents doesn't appear to be enough, as Neovim is seemingly still trying to run them.
Until neovim/neovim#26827 (or something similar) is implemented, the only way to reduce the significant input latency that Tree sitter introduces is to remove the query files used for injections. Simply clearing their contents doesn't appear to be enough, as Neovim is seemingly still trying to run them.
The two main bottlenecks in editing big files with many injections are (1) parsing and (2) processing the injection queries. This PR resolves (2) by applying injection query to only visible regions.
Benchmark:
cpp
(.h
file is recognized ascpp
filetype by default):let g:__ts_debug = 1
:e dcn_3_2_0_sh_mask.h
,:lua vim.treesitter.start()
G?//<CR>
), then append a space (A<Space><Esc>
)j
) and append 0 (A0<Esc>
)~/.local/state/nvim/treesitter.log
before
after
Evaluation