Skip to content

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

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

Closed
wants to merge 5 commits into from

Conversation

tomtomjhj
Copy link
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 performance, latency, cpu/memory usage 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
-- match each pattern separately.
if not self._has_combined_injection and range ~= true then
start_line = math.max(start_line, range_start_line)
end_line = math.min(end_line, range_end_line)
Copy link
Member

Choose a reason for hiding this comment

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

Are range_start_line, range_end_line all non-nil? (range = true) possibly need a nil check.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

In this branch range must have type Range, so range_start_line, range_end_line must've been set.

Copy link
Member

Choose a reason for hiding this comment

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

If this is the case then I think range should not be defined to allow a boolean type at all? Rather it should be typed only as Range, no?

It is confusing to see it span a boolean type as well, and especially since passing in true leads to nil access in the math.max

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

@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
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
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
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
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 tomtomjhj force-pushed the partial-injection branch 2 times, most recently from eb3e69c to dd129fe Compare February 16, 2024 19:30
@tomtomjhj tomtomjhj force-pushed the partial-injection branch 3 times, most recently from 21b6df5 to 3af56c9 Compare March 9, 2024 08:25
@ribru17
Copy link
Member

ribru17 commented Dec 23, 2024

Yeah I'm not arguing the performance aspect. This was my understanding:

  • Currently injection queries are run through the entire buffer range (slow for huge files)
  • Solution in this PR: only run them for ranges of nodes where we mark @injection.content
    • But there is a problem! injection.combined gives us a range not only of those nodes, but also all ranges inbetween
    • Thus we add more code to handle the special case of injection.combined, which is to still run injection queries over nodes in between injection.combined nodes

Let me know if any of that is incorrect. But if not I am just saying that we should not worry about that special case because we are doing more work to support a current aspect of injection functionality which is a bug (by treesitter's definition)

@ribru17
Copy link
Member

ribru17 commented Dec 23, 2024

Just to give an example to clarify the differences:

Neovim's injection.combined (for comment content(!) only, not comment markers):

-- print([[ some
-- text
-- here]])

in Neovim the comment markers before text and here]]) will get the @string highlight (bad)

in tree-sitter highlight, the comment markers are not included in this range (since they are not captured as @injection.content) but we still have all 3 comments parsed as one singular tree (just without the contiguous range).

(If it was not one singular tree, e.g. here]]) would be its own document and would be a parse error)

@lewis6991
Copy link
Member

in Neovim the comment markers before text and here]]) will get the @string highlight (bad)

I forgot neovim still has that bug, but I think that is beside the point? Even if we correctly excluded the comment markers from the combined region, we would still want to disable incremental injections for combined injections because we need to scan the whole document to get the full combined region. Whether or not we've collected the ranges correctly is a different matter.

@ribru17
Copy link
Member

ribru17 commented Dec 23, 2024

Gotcha, that makes sense. I had thought the "partial" aspect of this PR only applied to the query iteration + highlight applications. But yeah you're right if it also includes the actual ranges of what to parse then it would have to stay

return vim.deep_equal(directive, { 'set!', 'injection.combined' })
end, { predicate = true })
then
self.has_combined_injection = true
Copy link
Member

@ribru17 ribru17 Jan 11, 2025

Choose a reason for hiding this comment

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

I think this logic should be removed, and all special cases regarding combined injections here should be removed also. Per my previous comment. As Lewis pointed out, things are different when it comes to parsing, but querying should only run on the injection ranges, even for combined injections. Combined injections only apply to the tree document content, not to the ranges for which highlights should be applied

EDIT: Sorry, I see now that this function is actually used to set the ranges for parsing... that is unfortunate. I wonder if it would be acceptable to only parse injected ranges as combined if they are all currently in the window. That is, only combine visible regions which did fit within the original range parameter. That way we still get partial querying gains on queries with at least one combined injection

How does this currently work with non-combined injections that partially clip the window? E.g.

// multi
// line <-- window first line here
// injection
// here

because if it includes the whole range, to me seems like we could just do the same with partially visible combined regions (include the full range), combine only those which we have deemed visible, and call it a day


I'd even say this is preferred because it will largely eliminate the confusing behavior of injection parsing being negatively affected by combined injections far away in the buffer (not really meant to be combined in other parts), hence the desire in some cases for "scoped injections".

-- If the query doesn't have combined injection, run the query on the given range. Combined
-- injection must be run on the full range. Currently there is no simply way to selectively
-- match each pattern separately.
if range ~= true and not self._injection_query.has_combined_injection then
Copy link
Member

Choose a reason for hiding this comment

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

Similarly here, I think this check should be removed. And the other one earlier in this file

-- match each pattern separately.
if not self._has_combined_injection and range ~= true then
start_line = math.max(start_line, range_start_line)
end_line = math.min(end_line, range_end_line)
Copy link
Member

Choose a reason for hiding this comment

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

If this is the case then I think range should not be defined to allow a boolean type at all? Rather it should be typed only as Range, no?

It is confusing to see it span a boolean type as well, and especially since passing in true leads to nil access in the math.max

@@ -439,11 +448,6 @@ end
--- only the root tree without injections).
--- @return table<integer, TSTree>
function LanguageTree:parse(range)
if self:is_valid() then
Copy link
Member

Choose a reason for hiding this comment

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

Will this removal cause unnecessary reparsing? Is it possible to maybe pass in a range to is_valid(), which will check if the languagetree is valid for that range? And maybe then we could also remove the

      self:_add_injections(true)
      self._injections_processed = true

side effect in is_valid() (not sure if that would be possible after this change though)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

See my reply to your comment on is_valid() documentation.

@@ -1551,7 +1551,8 @@ LanguageTree:invalidate({reload}) *LanguageTree:invalidate()*
LanguageTree:is_valid({exclude_children}) *LanguageTree:is_valid()*
Returns whether this LanguageTree is valid, i.e., |LanguageTree:trees()|
reflects the latest state of the source. If invalid, user should call
|LanguageTree:parse()|.
|LanguageTree:parse()|. `is_valid(false)` can be slow because it runs
Copy link
Member

Choose a reason for hiding this comment

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

I think it would be really good to avoid this side effect in is_valid(), if possible

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This PR's region management strategy only retains the regions that intersect the requested parsing range. But computing is_valid(false) requires the knowledge of all possible regions, including the ones that are not currently tracked. This necessitates running injection.

Actually, I would say is_valid(false) is kind of unnecessary. The thing you would mostly likely to do after seeing is_valid(false) == false is parse(true). The is_valid(false) == false guard for parse(true) would be useful if it avoids the cost of parse(true) in the is_valid(false) == true case. But if you run parse(true) in the is_valid(false) == true case, parse(true) will be no-op. So the is_valid(false) == false guard isn't really meaningful. This is why I removed is_valid() check in _parse().

local buf_ranges = {} ---@type table<integer, (Range)[]>
for _, win in ipairs(api.nvim_tabpage_list_wins(0)) do
local buf = api.nvim_win_get_buf(win)
if TSHighlighter.active[buf] then
Copy link
Member

Choose a reason for hiding this comment

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

If this is false, will there be a nil access a few lines below, where the parse is called?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The loop below loops over buf_ranges which contains buffers for which TSHighlighter.active[buf] is non-nill.

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

ribru17 commented Feb 14, 2025

I believe I have a commit that implements the major perf gain of this patch with a small fraction of the diffstat- would you mind taking a look? 6454f81

Note that some of the implementation might be a bit hacky, like with self._injections_processed, but I think it is overall sound. Let me know if I am missing something obvious with this 😅 If not, maybe a smaller diff could help this PR get merged quicker

@tomtomjhj
Copy link
Contributor Author

I believe I have a commit that implements the major perf gain of this patch with a small fraction of the diffstat- would you mind taking a look? 6454f81

I think this is a reasonable fragment. But for the record, it has the problems mentioned in the following two commits of the current PR, though the impact won't be too bad.

  • perf(treesitter)!: incremental invalidation
  • perf(treesitter): allow parsing multiple ranges

like with self._injections_processed

I think it's clearer to use boolean|Range (instead of string) and compare the values with vim.deep_equal.

@ribru17
Copy link
Member

ribru17 commented Feb 14, 2025

I think this is a reasonable fragment. But for the record, it has the problems mentioned in the following two commits of the current PR, though the impact won't be too bad.

That makes sense. Maybe these can be saved for a follow up PR if the impact isn't too bad? I have also noticed like you say that the incremental validation for injection trees isn't too bad since there usually is a very small amount of code parsed by them

I think it's clearer to use boolean|Range (instead of string) and compare the values with vim.deep_equal.

That sounds much better 👍

@tomtomjhj
Copy link
Contributor Author

Maybe these can be saved for a follow up PR if the impact isn't too bad? I have also noticed like you say that the incremental validation for injection trees isn't too bad since there usually is a very small amount of code parsed by them

Yes I agree.

@ribru17
Copy link
Member

ribru17 commented Feb 15, 2025

Would you prefer to edit this PR, or would you like me to make one? Feel free to use the patch I posted if you like; I also don't mind making a separate change

@tomtomjhj
Copy link
Contributor Author

Please make a new PR

@clason
Copy link
Member

clason commented Feb 27, 2025

Am I correct in thinking that this PR is now superseded, at least in its current form, and that a fresh PR for the additional changes on top of master would be preferable?

@tomtomjhj
Copy link
Contributor Author

Yes.

@tomtomjhj tomtomjhj closed this Feb 27, 2025
@github-actions github-actions bot removed the request for review from bfredl February 27, 2025 11:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
performance performance, latency, cpu/memory usage treesitter
Projects
None yet
Development

Successfully merging this pull request may close these issues.

7 participants