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

perf(treesitter): partial injection and incremental invalidation #26827

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

Conversation

tomtomjhj
Copy link
Sponsor Contributor

@tomtomjhj tomtomjhj commented Dec 31, 2023

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:

  1. download https://raw.githubusercontent.com/torvalds/linux/master/drivers/gpu/drm/amd/include/asic_reg/dcn/dcn_3_2_0_sh_mask.h
  2. setup nvim-treesitter for cpp (.h file is recognized as cpp filetype by default)
  3. :let g:__ts_debug = 1
  4. :e dcn_3_2_0_sh_mask.h, :lua vim.treesitter.start()
  5. go to the last comment in the file (G?//<CR>), then append a space (A<Space><Esc>)
  6. go to the next line (j) and append 0 (A0<Esc>)
  7. see log at ~/.local/state/nvim/treesitter.log
before
1:cpp:(nvim) parse:450: (#regions=1)  { parse_time = 2923.453483, query_time = 0, regions_parsed = 1 }
1:cpp:(nvim) parse:450: (#regions=1)  { parse_time = 0, query_time = 0, regions_parsed = 0 }
1:cpp:(nvim) parse:450: (#regions=1)  { parse_time = 0, query_time = 0, regions_parsed = 0 }
1:comment:(nvim) new:145: (#regions=1)  START
1:cpp:(nvim) new:145: (#regions=1)  START
1:cpp:(nvim) parse:450: (#regions=1)  { parse_time = 0, query_time = 1004.06346, range = { 0, 56 }, regions_parsed = 0 }
1:cpp:(nvim) parse:450: (#regions=194402)  { parse_time = 1.506477, query_time = 0.17726, range = { 0, 56 }, regions_parsed = 25 }
1:comment:(nvim) parse:450: (#regions=27859)  { parse_time = 1.215564, query_time = 0.001373, range = { 0, 56 }, regions_parsed = 5 }
1:cpp:(nvim) parse:450: (#regions=1)  { parse_time = 0, query_time = 0, range = { 222837, 222893 }, regions_parsed = 0 }
1:cpp:(nvim) parse:450: (#regions=194402)  { parse_time = 2.845539, query_time = 0.450294, range = { 222837, 222893 }, regions_parsed = 50 }
1:comment:(nvim) parse:450: (#regions=27859)  { parse_time = 0.266351, query_time = 0.001112, range = { 222837, 222893 }, regions_parsed = 4 }
1:cpp:(nvim) parse:450: (#regions=1)  { parse_time = 0, query_time = 0, range = { 222837, 222893 }, regions_parsed = 0 }
1:cpp:(nvim) parse:450: (#regions=194402)  { parse_time = 0, query_time = 0, range = { 222837, 222893 }, regions_parsed = 0 }
1:comment:(nvim) parse:450: (#regions=27859)  { parse_time = 0, query_time = 0, range = { 222837, 222893 }, regions_parsed = 0 }
1:cpp:(nvim) parse:450: (#regions=1)  { parse_time = 0, query_time = 0, range = { 222837, 222893 }, regions_parsed = 0 }
1:cpp:(nvim) parse:450: (#regions=194402)  { parse_time = 0, query_time = 0, range = { 222837, 222893 }, regions_parsed = 0 }
1:comment:(nvim) parse:450: (#regions=27859)  { parse_time = 0, query_time = 0, range = { 222837, 222893 }, regions_parsed = 0 }
1:cpp:(nvim) parse:450: (#regions=1)  { parse_time = 0, query_time = 0, range = { 222837, 222893 }, regions_parsed = 0 }
1:cpp:(nvim) parse:450: (#regions=194402)  { parse_time = 0, query_time = 0, range = { 222837, 222893 }, regions_parsed = 0 }
1:comment:(nvim) parse:450: (#regions=27859)  { parse_time = 0, query_time = 0, range = { 222837, 222893 }, regions_parsed = 0 }
1:cpp:(nvim) parse:450: (#regions=1)  { parse_time = 0, query_time = 0, range = { 222837, 222893 }, regions_parsed = 0 }
1:cpp:(nvim) parse:450: (#regions=194402)  { parse_time = 0, query_time = 0, range = { 222837, 222893 }, regions_parsed = 0 }
1:comment:(nvim) parse:450: (#regions=27859)  { parse_time = 0, query_time = 0, range = { 222837, 222893 }, regions_parsed = 0 }
1:cpp:(nvim) _on_bytes:984: (#regions=1)  on_bytes 1 4 222884 62 23943921 0 0 0 0 1 1
1:cpp:(nvim) _iter_regions:571: (#regions=1)  was valid true
1:cpp:(nvim) _iter_regions:581: (#regions=1)  invalidating region 1 []
1:comment:(nvim) _iter_regions:581: (#regions=9)  invalidating region 27859 [222884:0-222884:63]
1:cpp:(nvim) parse:450: (#regions=1)  { parse_time = 366.826656, query_time = 1008.172019, range = { 222837, 222893 }, regions_parsed = 1 }
1:cpp:(nvim) parse:450: (#regions=194402)  { parse_time = 3.726473, query_time = 0.987651, range = { 222837, 222893 }, regions_parsed = 50 }
1:comment:(nvim) parse:450: (#regions=27859)  { parse_time = 0.193249, query_time = 0.006221, range = { 222837, 222893 }, regions_parsed = 4 }
1:cpp:(nvim) parse:450: (#regions=1)  { parse_time = 0, query_time = 0, range = { 222837, 222893 }, regions_parsed = 0 }
1:cpp:(nvim) parse:450: (#regions=194402)  { parse_time = 0, query_time = 0, range = { 222837, 222893 }, regions_parsed = 0 }
1:comment:(nvim) parse:450: (#regions=27859)  { parse_time = 0, query_time = 0, range = { 222837, 222893 }, regions_parsed = 0 }
1:cpp:(nvim) parse:450: (#regions=1)  { parse_time = 0, query_time = 0, range = { 222837, 222893 }, regions_parsed = 0 }
1:cpp:(nvim) parse:450: (#regions=194402)  { parse_time = 0, query_time = 0, range = { 222837, 222893 }, regions_parsed = 0 }
1:comment:(nvim) parse:450: (#regions=27859)  { parse_time = 0, query_time = 0, range = { 222837, 222893 }, regions_parsed = 0 }
1:cpp:(nvim) parse:450: (#regions=1)  { parse_time = 0, query_time = 0, range = { 222837, 222893 }, regions_parsed = 0 }
1:cpp:(nvim) parse:450: (#regions=194402)  { parse_time = 0, query_time = 0, range = { 222837, 222893 }, regions_parsed = 0 }
1:comment:(nvim) parse:450: (#regions=27859)  { parse_time = 0, query_time = 0, range = { 222837, 222893 }, regions_parsed = 0 }
1:cpp:(nvim) _on_bytes:984: (#regions=1)  on_bytes 1 5 222885 113 23944036 0 0 0 0 1 1
1:cpp:(nvim) _iter_regions:571: (#regions=1)  was valid true
1:cpp:(nvim) _iter_regions:581: (#regions=1)  invalidating region 1 []
1:cpp:(nvim) _iter_regions:581: (#regions=50)  invalidating region 194397 [222885:110-222885:114]
1:cpp:(nvim) parse:450: (#regions=1)  { parse_time = 391.276224, query_time = 1176.385789, range = { 222837, 222893 }, regions_parsed = 1 }
1:cpp:(nvim) parse:450: (#regions=194402)  { parse_time = 3.136393, query_time = 0.262249, range = { 222837, 222893 }, regions_parsed = 50 }
1:comment:(nvim) parse:450: (#regions=27859)  { parse_time = 0.135404, query_time = 0.000935, range = { 222837, 222893 }, regions_parsed = 4 }
1:cpp:(nvim) parse:450: (#regions=1)  { parse_time = 0, query_time = 0, range = { 222837, 222893 }, regions_parsed = 0 }
1:cpp:(nvim) parse:450: (#regions=194402)  { parse_time = 0, query_time = 0, range = { 222837, 222893 }, regions_parsed = 0 }
1:comment:(nvim) parse:450: (#regions=27859)  { parse_time = 0, query_time = 0, range = { 222837, 222893 }, regions_parsed = 0 }
after
1:cpp:(nvim) parse:484: (#regions=1)  { parse_time = 2782.294202, query_time = 0, regions_parsed = 1 }
1:cpp:(nvim) parse:484: (#regions=1)  { parse_time = 0, query_time = 0, regions_parsed = 0 }
1:cpp:(nvim) parse:484: (#regions=1)  { parse_time = 0, query_time = 0, regions_parsed = 0 }
1:comment:(nvim) new:162: (#regions=1)  START
1:cpp:(nvim) new:162: (#regions=1)  START
1:cpp:(nvim) parse:484: (#regions=1)  { parse_time = 0, query_time = 0.38573, range = { 0, 56 }, regions_parsed = 0 }
1:comment:(nvim) parse:484: (#regions=5)  { parse_time = 0.725387, query_time = 0.000461, range = { 0, 56 }, regions_parsed = 5 }
1:cpp:(nvim) parse:484: (#regions=25)  { parse_time = 0.898121, query_time = 0.061065, range = { 0, 56 }, regions_parsed = 25 }
1:cpp:(nvim) parse:484: (#regions=1)  { parse_time = 0, query_time = 1.598029, range = { 222837, 222893 }, regions_parsed = 0 }
1:comment:(nvim) parse:484: (#regions=9)  { parse_time = 0.354243, query_time = 0.001245, range = { 222837, 222893 }, regions_parsed = 4 }
1:cpp:(nvim) parse:484: (#regions=75)  { parse_time = 10.392799, query_time = 0.823553, range = { 222837, 222893 }, regions_parsed = 50 }
1:cpp:(nvim) parse:484: (#regions=1)  { parse_time = 0, query_time = 0.96193, range = { 222837, 222893 }, regions_parsed = 0 }
1:comment:(nvim) parse:484: (#regions=4)  { parse_time = 0, query_time = 0.003102, range = { 222837, 222893 }, regions_parsed = 0 }
1:cpp:(nvim) parse:484: (#regions=50)  { parse_time = 0, query_time = 0.720424, range = { 222837, 222893 }, regions_parsed = 0 }
1:cpp:(nvim) parse:484: (#regions=1)  { parse_time = 0, query_time = 1.32763, range = { 222837, 222893 }, regions_parsed = 0 }
1:comment:(nvim) parse:484: (#regions=4)  { parse_time = 0, query_time = 0.0019, range = { 222837, 222893 }, regions_parsed = 0 }
1:cpp:(nvim) parse:484: (#regions=50)  { parse_time = 0, query_time = 0.661419, range = { 222837, 222893 }, regions_parsed = 0 }
1:cpp:(nvim) parse:484: (#regions=1)  { parse_time = 0, query_time = 1.424348, range = { 222837, 222893 }, regions_parsed = 0 }
1:comment:(nvim) parse:484: (#regions=4)  { parse_time = 0, query_time = 0.001834, range = { 222837, 222893 }, regions_parsed = 0 }
1:cpp:(nvim) parse:484: (#regions=50)  { parse_time = 0, query_time = 0.769533, range = { 222837, 222893 }, regions_parsed = 0 }
1:cpp:(nvim) parse:484: (#regions=1)  { parse_time = 0, query_time = 1.261363, range = { 222837, 222893 }, regions_parsed = 0 }
1:comment:(nvim) parse:484: (#regions=4)  { parse_time = 0, query_time = 0.001738, range = { 222837, 222893 }, regions_parsed = 0 }
1:cpp:(nvim) parse:484: (#regions=50)  { parse_time = 0, query_time = 0.596901, range = { 222837, 222893 }, regions_parsed = 0 }
1:cpp:(nvim) _on_bytes:1144: (#regions=1)  on_bytes 1 4 222884 62 23943921 0 0 0 0 1 1
1:cpp:(nvim) _iter_regions:606: (#regions=1)  was valid true
1:cpp:(nvim) _iter_regions:616: (#regions=1)  invalidating region 1 []
1:comment:(nvim) _iter_regions:606: (#regions=4)  was valid true
1:comment:(nvim) _iter_regions:616: (#regions=4)  invalidating region 9 [222884:0-222884:63]
1:cpp:(nvim) _iter_regions:606: (#regions=50)  was valid true
1:cpp:(nvim) parse:484: (#regions=1)  { parse_time = 399.244173, query_time = 0.407459, range = { 222837, 222893 }, regions_parsed = 1 }
1:comment:(nvim) parse:484: (#regions=4)  { changes = { { 222884, 62, 23943921, 222884, 63, 23943922 } }, parse_time = 0.046848, query_time = 0.00045, range = { 222837, 222893 }, regions_parsed = 1 }
1:cpp:(nvim) parse:484: (#regions=50)  { parse_time = 0, query_time = 0.197181, range = { 222837, 222893 }, regions_parsed = 0 }
1:cpp:(nvim) parse:484: (#regions=1)  { parse_time = 0, query_time = 0.976214, range = { 222837, 222893 }, regions_parsed = 0 }
1:comment:(nvim) parse:484: (#regions=4)  { parse_time = 0, query_time = 0.002079, range = { 222837, 222893 }, regions_parsed = 0 }
1:cpp:(nvim) parse:484: (#regions=50)  { parse_time = 0, query_time = 0.655016, range = { 222837, 222893 }, regions_parsed = 0 }
1:cpp:(nvim) parse:484: (#regions=1)  { parse_time = 0, query_time = 1.166834, range = { 222837, 222893 }, regions_parsed = 0 }
1:comment:(nvim) parse:484: (#regions=4)  { parse_time = 0, query_time = 0.00189, range = { 222837, 222893 }, regions_parsed = 0 }
1:cpp:(nvim) parse:484: (#regions=50)  { parse_time = 0, query_time = 0.893842, range = { 222837, 222893 }, regions_parsed = 0 }
1:cpp:(nvim) parse:484: (#regions=1)  { parse_time = 0, query_time = 1.057452, range = { 222837, 222893 }, regions_parsed = 0 }
1:comment:(nvim) parse:484: (#regions=4)  { parse_time = 0, query_time = 0.001859, range = { 222837, 222893 }, regions_parsed = 0 }
1:cpp:(nvim) parse:484: (#regions=50)  { parse_time = 0, query_time = 0.650981, range = { 222837, 222893 }, regions_parsed = 0 }
1:cpp:(nvim) _on_bytes:1144: (#regions=1)  on_bytes 1 5 222885 113 23944036 0 0 0 0 1 1
1:cpp:(nvim) _iter_regions:606: (#regions=1)  was valid true
1:cpp:(nvim) _iter_regions:616: (#regions=1)  invalidating region 1 []
1:comment:(nvim) _iter_regions:606: (#regions=4)  was valid true
1:cpp:(nvim) _iter_regions:606: (#regions=50)  was valid true
1:cpp:(nvim) _iter_regions:616: (#regions=50)  invalidating region 70 [222885:110-222885:114]
1:cpp:(nvim) parse:484: (#regions=1)  { parse_time = 376.665228, query_time = 0.288334, range = { 222837, 222893 }, regions_parsed = 1 }
1:comment:(nvim) parse:484: (#regions=4)  { parse_time = 0, query_time = 0.000528, range = { 222837, 222893 }, regions_parsed = 0 }
1:cpp:(nvim) parse:484: (#regions=50)  { parse_time = 0.076109, query_time = 0.217351, range = { 222837, 222893 }, regions_parsed = 1 }
1:cpp:(nvim) parse:484: (#regions=1)  { parse_time = 0, query_time = 1.539355, range = { 222837, 222893 }, regions_parsed = 0 }
1:comment:(nvim) parse:484: (#regions=4)  { parse_time = 0, query_time = 0.002121, range = { 222837, 222893 }, regions_parsed = 0 }
1:cpp:(nvim) parse:484: (#regions=50)  { parse_time = 0, query_time = 0.945593, range = { 222837, 222893 }, regions_parsed = 0 }

Evaluation

  • Major benefit: The query time becomes negligible. Now the only bottleneck is the parsing of the root cpp parser.
  • Minor additional benefit: less reparsing of injections
  • Minor downside: It runs the injection query more frequently, for example when scrolling and when multiple windows show the same buffer (not shown in the above logs). But the cost is negligible.

@tomtomjhj tomtomjhj marked this pull request as ready for review December 31, 2023 18:01
@wookayin wookayin added the performance issues reporting performance problems label Jan 1, 2024
@tomtomjhj tomtomjhj force-pushed the partial-injection branch 2 times, most recently from bffa2b6 to 53c9cc7 Compare January 2, 2024 18:41
runtime/lua/vim/treesitter/languagetree.lua Show resolved Hide resolved
runtime/lua/vim/treesitter/languagetree.lua Outdated Show resolved Hide resolved
runtime/lua/vim/treesitter/languagetree.lua Show resolved Hide resolved
if
vim.tbl_contains(preds, function(pred)
return vim.deep_equal(pred, { 'set!', 'injection.combined' })
end, { predicate = true })
Copy link
Member

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?

Copy link
Sponsor Contributor Author

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.

@tomtomjhj tomtomjhj force-pushed the partial-injection branch 2 times, most recently from e0ffa59 to 433257f Compare January 3, 2024 17:20
@tomtomjhj

This comment was marked as resolved.

Copy link
Member

@lewis6991 lewis6991 left a 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.

runtime/lua/vim/treesitter/languagetree.lua Outdated Show resolved Hide resolved
@clason
Copy link
Member

clason commented Jan 4, 2024

I suspect it is very uncommon for a buffer to be visible in multiple windows at once and thus this isn't something we should be optimizing for.

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`.
Copy link
Member

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.

Copy link
Sponsor Contributor Author

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

Copy link
Sponsor Contributor Author

Choose a reason for hiding this comment

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

@lewis6991
Copy link
Member

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.

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.

@tomtomjhj
Copy link
Sponsor Contributor Author

iiuc helix doesn't do paritial injection: https://github.com/helix-editor/helix/blob/0c81ef73e17a3d45cd6240fd5933ad99b3a60d01/helix-core/src/syntax.rs#L1097
though the idea of constructing lookup table from region to its index on each update is taken from helix.

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

@lewis6991
Copy link
Member

lewis6991 commented Jan 6, 2024

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.

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.

iiuc helix doesn't do paritial injection: https://github.com/helix-editor/helix/blob/0c81ef73e17a3d45cd6240fd5933ad99b3a60d01/helix-core/src/syntax.rs#L1097
though the idea of constructing lookup table from region to its index on each update is taken from helix.

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.

@tomtomjhj
Copy link
Sponsor Contributor Author

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.

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.

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.

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.

In this PR, set_included_regions(new_regions) discards the existing regions (and the corresponding trees) that don't match a region in new_regions, where new_regions are the regions found by the injection query in the visible range.
I think this is necessary for two reasons:

  • to clean up garbage regions: Regions deleted from the source should be deleted from LanguageTree. Also there can be "false un-hits" when matching the old and new regions (see comment in set_included_regions).
  • to keep number of regions low: LanguageTree would have to manage a large set of regions if the source is big, which adds non-trivial cost to _edit.

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 doesn't do paritial injection: https://github.com/helix-editor/helix/blob/0c81ef73e17a3d45cd6240fd5933ad99b3a60d01/helix-core/src/syntax.rs#L1097
though the idea of constructing lookup table from region to its index on each update is taken from helix.

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.

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 Syntax struct roughly corresponds to our LanguageTree. Like LanguageTree, Syntax maps an ID to to each LanguageLayer (region + tree for that region + various info), but it does not maintain the tree of parsers.

Syntax::update is called after each edit.

  1. Apply edits to each layer.
    • update the ranges of affected layers
      • I'm not sure why they do this. This can be delegated to treesitter (which is what nvim does).
    • marks each layers: modified, moved
  2. Construct layer value to layer ID mapping. This is used for checking if a discovered injection region already exists.
  3. Parse layers, starting from the root layer
    1. Edit the tree of the layer.
    2. If the layer was modified by the edit, parse it.
    3. Run the injection query of this layer in the full source.
    4. Check if each injection region matches an existing layer. If so, get its ID, and otherwise, add a new layer.
    5. Repeat the above for each injection layers.
  4. Discard the layers that are not touched by the above process.

A notable difference from nvim is that Syntax::update is called after each edit without info about visible range. So Helix is not incremental.

@tomtomjhj

This comment was marked as resolved.

runtime/doc/treesitter.txt Outdated Show resolved Hide resolved
@tomtomjhj tomtomjhj force-pushed the partial-injection branch 3 times, most recently from 51aedb4 to 058d79c Compare April 14, 2024 07:04
@tomtomjhj
Copy link
Sponsor Contributor Author

[...] maybe we can rethink the parse() API to better handle multiple ranges due to multiple windows, [...]

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.

Turns out this is actually quite easy to implement. The last commit optimizes the multi-window scenarios by making parse take set of ranges and parsing all the visible stuff in decoration provider on_start callback (using line('w0') and line('w$')). No awkward batching stuff.

@clason clason added this to the 0.10 milestone Apr 17, 2024
@justinmk justinmk modified the milestones: 0.10, 0.11 Apr 22, 2024
Copy link
Member

@lewis6991 lewis6991 left a 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.

Comment on lines 840 to 845
return vim
.iter(region)
:filter(function(r)
return r[3] ~= r[6]
end)
:totable()
Copy link
Member

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.

Copy link
Member

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().

Copy link
Member

@lewis6991 lewis6991 Apr 24, 2024

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.

Copy link
Member

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.

Copy link
Member

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)

Copy link
Member

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.

Copy link
Member

@lewis6991 lewis6991 Apr 24, 2024

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.

Copy link
Member

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?

Copy link
Sponsor Contributor Author

Choose a reason for hiding this comment

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

Copy link
Member

@justinmk justinmk Apr 25, 2024

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)
Copy link
Member

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"?

Copy link
Sponsor Contributor Author

@@ -98,6 +113,21 @@ local LanguageTree = {}

LanguageTree.__index = LanguageTree

---@param injection_query vim.treesitter.Query
---@return boolean
local function query_has_combined(injection_query)
Copy link
Member

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.

Copy link
Sponsor Contributor Author

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

Copy link
Member

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.

Copy link
Sponsor Contributor Author

Choose a reason for hiding this comment

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

@tomtomjhj tomtomjhj force-pushed the partial-injection branch 2 times, most recently from 7b7736c to 1dc295c Compare April 25, 2024 14:34
@tomtomjhj
Copy link
Sponsor Contributor Author

  • Added entries in news.txt
  • "fixed" some usages of trees() that are no longer valid according to the documentation.

runtime/doc/news.txt Outdated Show resolved Hide resolved
Copy link
Member

@lewis6991 lewis6991 left a 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)
Copy link
Member

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?

Copy link
Sponsor Contributor Author

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.

Copy link
Member

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
Copy link
Member

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?

Copy link
Sponsor Contributor Author

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 still true
  • 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.

---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)[]>?
Copy link
Member

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, .. }.
Copy link
Member

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?

Copy link
Sponsor Contributor Author

@tomtomjhj tomtomjhj May 22, 2024

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.

Comment on lines +615 to +675
---@return integer?
---@return boolean? exact
Copy link
Member

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.
@tomtomjhj
Copy link
Sponsor Contributor Author

yorickpeterse added a commit to yorickpeterse/dotfiles that referenced this pull request May 26, 2024
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.
yorickpeterse added a commit to yorickpeterse/nvim-treesitter that referenced this pull request May 26, 2024
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.
yorickpeterse added a commit to yorickpeterse/nvim-treesitter that referenced this pull request Jun 12, 2024
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.
yorickpeterse added a commit to yorickpeterse/nvim-treesitter that referenced this pull request Jun 12, 2024
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
performance issues reporting performance problems treesitter
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

6 participants