Skip to content

Commit b629972

Browse files
authored
perf: cache parsed symbol snapshots to avoid redundant tree-sitter parses (#492)
* perf: cache parsed symbol snapshots to avoid redundant tree-sitter parses * perf(ui): defer symbol target resolution to post-flush * perf(ui): pace symbol target refresh work This make big sessions open way faster
1 parent 636a264 commit b629972

15 files changed

Lines changed: 462 additions & 130 deletions

docs/performance-audit.md

Lines changed: 0 additions & 82 deletions
This file was deleted.

lua/opencode/lru_cache.lua

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
---@generic T
2+
---@class LruCache<T>
3+
---@field private capacity integer
4+
---@field private entries table<any, { value: T, used: integer }>
5+
---@field private size integer
6+
---@field private clock integer
7+
local LruCache = {}
8+
LruCache.__index = LruCache
9+
10+
---@param capacity integer
11+
---@return LruCache<T>
12+
function LruCache.new(capacity)
13+
assert(capacity > 0, 'cache capacity must be positive')
14+
return setmetatable({
15+
capacity = capacity,
16+
entries = {},
17+
size = 0,
18+
clock = 0,
19+
}, LruCache)
20+
end
21+
22+
---@param key any
23+
---@return T
24+
function LruCache:get(key)
25+
local entry = self.entries[key]
26+
if not entry then
27+
return nil
28+
end
29+
30+
self.clock = self.clock + 1
31+
entry.used = self.clock
32+
return entry.value
33+
end
34+
35+
---@param key any
36+
---@param value T
37+
function LruCache:set(key, value)
38+
local entry = self.entries[key]
39+
if not entry and self.size >= self.capacity then
40+
local oldest_key
41+
local oldest_use = math.huge
42+
for cached_key, cached_entry in pairs(self.entries) do
43+
if cached_entry.used < oldest_use then
44+
oldest_key = cached_key
45+
oldest_use = cached_entry.used
46+
end
47+
end
48+
self.entries[oldest_key] = nil
49+
self.size = self.size - 1
50+
end
51+
52+
if not entry then
53+
self.size = self.size + 1
54+
end
55+
self.clock = self.clock + 1
56+
self.entries[key] = { value = value, used = self.clock }
57+
end
58+
59+
return LruCache

lua/opencode/types.lua

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -566,9 +566,11 @@
566566
---@field order integer Smaller values appear earlier in the session message/part/text order.
567567

568568
---@class SymbolSnapshotCycle
569+
---@field warm_path fun(self: SymbolSnapshotCycle, path: string)
569570

570571
---@class FormatterContext
571572
---@field interactive boolean
573+
---@field resolve_symbol_targets? boolean
572574
---@field get_child_parts? fun(session_id: string): OpencodeMessagePart[]?
573575
---@field current_refs? CodeReference[]
574576
---@field current_files? string[]

lua/opencode/ui/formatter.lua

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -795,7 +795,7 @@ local function add_file_reference_targets(output, rendered, rendered_reference_r
795795
end
796796

797797
local function add_symbol_reference_targets(output, rendered, rendered_mention_ranges, first_line_idx, context)
798-
if not (context and context.interactive and context.symbol_cycle) then
798+
if not (context and context.interactive and context.resolve_symbol_targets ~= false and context.symbol_cycle) then
799799
return {}
800800
end
801801

lua/opencode/ui/renderer.lua

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -283,7 +283,6 @@ function M.event_subscriptions()
283283
{ 'file.edited', events.on_file_edited },
284284
{ 'file.watcher.updated', events.on_file_watcher_updated },
285285
{ 'custom.restore_point.created', events.on_restore_points },
286-
{ 'custom.emit_events.finished', M.on_emit_events_finished },
287286
}
288287
end
289288

@@ -422,10 +421,10 @@ function M._render_full_session_data(session_data, opts)
422421
events.on_part_updated({ part = revert_message.parts[1] })
423422
end
424423

425-
local t_format_end = vim.uv.hrtime()
426424
flush.flush()
427425
flush.end_bulk_mode()
428-
local t_flush_end = vim.uv.hrtime()
426+
427+
events.refresh_rendered_symbol_targets()
429428

430429
if opts.restore_model_from_messages then
431430
require('opencode.services.agent_model').initialize_current_model({ restore_from_messages = true })
@@ -600,11 +599,6 @@ end
600599
M.reconcile_rendered_message_limit = reconcile_rendered_message_limit
601600
M.is_message_visible = is_message_visible
602601

603-
---Scroll to bottom after all queued events have been processed
604-
function M.on_emit_events_finished()
605-
M.scroll_to_bottom()
606-
end
607-
608602
---Return all actions available at a given (0-indexed) line
609603
---@param line integer
610604
---@return table[]

lua/opencode/ui/renderer/ctx.lua

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,9 @@ local ctx = {
4545
},
4646
flush_scheduled = false, ---@type boolean
4747
markdown_render_scheduled = false, ---@type boolean
48+
symbol_refresh_pending = false, ---@type boolean
49+
symbol_refresh_token = 0, ---@type integer
50+
symbol_refresh_cycle = nil, ---@type table?
4851
bulk_mode = false, ---@type boolean
4952
bulk_buffer_lines = {},
5053
bulk_extmarks_by_line = {},
@@ -77,6 +80,9 @@ function ctx:reset()
7780
}
7881
self.flush_scheduled = false
7982
self.markdown_render_scheduled = false
83+
self.symbol_refresh_pending = false
84+
self.symbol_refresh_token = self.symbol_refresh_token + 1
85+
self.symbol_refresh_cycle = nil
8086
self.global_folds = {}
8187
self.part_folds = {}
8288
self:bulk_reset()
@@ -96,6 +102,7 @@ function ctx:has_pending_work(pending)
96102
pending = pending or self.pending
97103

98104
return self.flush_scheduled
105+
or self.symbol_refresh_pending
99106
or self.bulk_mode
100107
or #pending.dirty_message_order > 0
101108
or #pending.dirty_part_order > 0

lua/opencode/ui/renderer/events.lua

Lines changed: 6 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ local ctx = require('opencode.ui.renderer.ctx')
44
local prompts = ctx.prompt_controllers
55
local flush = require('opencode.ui.renderer.flush')
66
local reference_facts = require('opencode.ui.reference_facts')
7+
local symbol_refresh = require('opencode.ui.renderer.symbol_refresh')
78

89
---@param message OpencodeMessage|nil
910
---@return string|nil
@@ -85,31 +86,6 @@ local function mark_following_assistant_text_parts_dirty(message, changed_part_i
8586
end
8687
end
8788

88-
local function mark_rendered_assistant_text_parts_dirty()
89-
local active_session_id = state.active_session and state.active_session.id
90-
if not active_session_id then
91-
return
92-
end
93-
94-
for part_id, part_data in pairs(ctx.render_state._parts or {}) do
95-
local part = part_data.part
96-
if
97-
part
98-
and part.type == 'text'
99-
and part.text
100-
and not part.synthetic
101-
and part_data.line_start
102-
and part_data.line_end
103-
then
104-
local message_data = ctx.render_state:get_message(part_data.message_id)
105-
local message = message_data and message_data.message or find_message_in_state(part_data.message_id)
106-
if is_assistant_message(message) and message.info.sessionID == active_session_id then
107-
flush.mark_part_dirty(part_id, part_data.message_id)
108-
end
109-
end
110-
end
111-
end
112-
11389
-- Lazy require to avoid circular dependency: renderer.lua <-> events.lua
11490
---@param force? boolean
11591
local function scroll(force)
@@ -118,9 +94,12 @@ end
11894

11995
local M = {}
12096

97+
function M.refresh_rendered_symbol_targets()
98+
symbol_refresh.refresh()
99+
end
100+
121101
function M.invalidate_reference_targets_for_file_change()
122-
reference_facts.refresh_current_files()
123-
mark_rendered_assistant_text_parts_dirty()
102+
symbol_refresh.invalidate()
124103
end
125104

126105
---@param message_id string

lua/opencode/ui/renderer/flush.lua

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,7 @@ local warned_part_render_error = false
1414

1515
local function output_window_is_in_background_tab()
1616
local output_win = state.windows and state.windows.output_win
17-
return output_win
18-
and vim.api.nvim_win_is_valid(output_win)
19-
and not state.ui.is_window_in_current_tab(output_win)
17+
return output_win and vim.api.nvim_win_is_valid(output_win) and not state.ui.is_window_in_current_tab(output_win)
2018
end
2119

2220
---@param part_id string
@@ -251,12 +249,13 @@ end
251249
local function new_formatter_context()
252250
return {
253251
interactive = true,
252+
resolve_symbol_targets = not ctx.bulk_mode,
254253
get_child_parts = function(session_id)
255254
return ctx.render_state:get_child_session_parts(session_id)
256255
end,
257256
current_refs = reference_facts.current_refs(),
258257
current_files = reference_facts.available_files(),
259-
symbol_cycle = symbol_snapshot.new_cycle(),
258+
symbol_cycle = ctx.symbol_refresh_cycle or symbol_snapshot.new_cycle(),
260259
}
261260
end
262261

@@ -543,6 +542,7 @@ function M.resume_deferred_rendering()
543542
M.flush()
544543
if ctx.bulk_mode then
545544
M.end_bulk_mode()
545+
require('opencode.ui.renderer.events').refresh_rendered_symbol_targets()
546546
end
547547
M.flush_pending_on_data_rendered()
548548
end

0 commit comments

Comments
 (0)