Because I dislike tree-sitter, I've been looking at optimizing vim syntax-based code highlighting, sending ~30 pull requests as a result. It's surprisingly non-trivial to profile syntax highlighting, as vim has some pty/terminal relate checks, as well as a bunch of screen/display optimizations, so I've been using the following script quite extensively:
" Profile a runtime syntax file with a sequential full-buffer parse.
" Pass this as Vim's vimrc, e.g.:
" src/vim -Nu profile_syntax.vim file.py
" Inputs are extended to 100,000 lines. The report is written to
" output-YYYYMMDD-HHMMSS.txt in the current directory.
set nocompatible
execute 'set runtimepath^=' . fnameescape(getcwd() . '/runtime')
filetype on
function! ProfileSyntax()
let input_lines = getline(1, '$')
let target_lines = 100000
" Repeat the original input, using a partial final copy if needed.
while line('$') < target_lines
let copy_count = min([len(input_lines), target_lines - line('$')])
call append(line('$'), input_lines[: copy_count - 1])
endwhile
let syntax_name = &l:filetype
if empty(syntax_name)
echoerr 'No filetype detected; cannot profile syntax.'
qall!
endif
syntax clear
unlet! b:current_syntax
" Load through 'runtimepath' so compound filetypes, after/syntax and
" :runtime! includes all resolve the way a normal session would.
execute 'runtime! syntax/' . syntax_name . '.vim'
if empty(get(b:, 'current_syntax', ''))
echoerr 'Cannot load syntax for: ' . syntax_name
qall!
endif
syntime on
for lnum in range(1, line('$'))
call synID(lnum, 1, 1)
endfor
let report_file = getcwd() .. '/output-' .. strftime('%Y%m%d-%H%M%S') .. '.txt'
let saved_columns = &columns
let &columns = max([saved_columns, 2000])
let raw_report = execute('syntime report')
let &columns = saved_columns
let entries = []
let total = []
let name_width = strlen('NAME')
for report_line in split(raw_report, "\n")
if report_line =~# '^\s*TOTAL\s\+COUNT\>'
continue
endif
let fields = matchlist(report_line,
\ '^\s*\(\S\+\)\s\+\(\S\+\)\s\+\(\S\+\)\s\+\(\S\+\)\s\+\(\S\+\)\s\+\(\S\+\)\s\+\(.*\)$')
if !empty(fields)
call add(entries, fields[1:7])
let name_width = max([name_width, strlen(fields[6])])
else
let fields = matchlist(report_line, '^\s*\(\S\+\)\s\+\(\d\+\)\s*$')
if !empty(fields)
let total = fields[1:2]
endif
endif
endfor
let aligned_report = [printf('Syntax: %s Lines: %d', syntax_name, line('$')), '']
call add(aligned_report, printf('%-13s %8s %7s %12s %12s %-*s %s',
\ 'TOTAL', 'COUNT', 'MATCH', 'SLOWEST', 'AVERAGE', name_width,
\ 'NAME', 'PATTERN'))
for fields in entries
call add(aligned_report, printf('%-13s %8s %7s %12s %12s %-*s %s',
\ fields[0], fields[1], fields[2], fields[3], fields[4],
\ name_width, fields[5], fields[6]))
endfor
if !empty(total)
call add(aligned_report, '')
call add(aligned_report, printf('TOTAL TIME: %s TOTAL COUNT: %s',
\ total[0], total[1]))
endif
call writefile(aligned_report, report_file)
qall!
endfunction
autocmd VimEnter * call ProfileSyntax()
Running it on a source code file, like
src/vim9script.c
with vim -Nu profile_syntax.vim src/vim9script.c produces an
output-$TIMESTAMP.txt file looking like this:
Syntax: c Lines: 100000
TOTAL COUNT MATCH SLOWEST AVERAGE NAME PATTERN
0.102609705 99328 9312 0.000011000 0.000001033 cString \%(L\|U\|u8\)\="
0.092528889 90839 4198 0.000023291 0.000001019 cCppString \%(L\|U\|u8\)\="
0.069483028 81272 6177 0.000032958 0.000000855 cNumbers \<\d\|\.\d
0.062877236 91583 0 0.000010500 0.000000687 cString \%(U\|u8\=\)"
0.057239944 102135 10552 0.000009083 0.000000560 cBracket \[\|<::\@!
0.054717217 96789 5371 0.000015541 0.000000565 cCharacter L\='[^\\]'
0.053602548 91583 0 0.000012208 0.000000585 cSpecialCharacter L\='\\['\"?\\abfnrtv]'
From there, it's simply a matter of tweaking the regex trying make the numbers
go down. Classic tricks include transforming things like A[BC]D
into
ABD and
ACD,
grouping common patterns
together, forcing
usage
of the
old
regex engine, using plain text
instead of regex, …
The script was also used to optimize the regex engines themselves, by running
it in perf record via perf record -g --call-graph dwarf -- src/vim -Nu
profile_syntax.vim --noplugin -N -X -n -i NONE --not-a-term a_big_file.c with
its reporting part removed, calling perf script -i perf.data >
perf.txt on the results, and dropping the resulting perf.txt file in
speedscope. Tentative improvements were then
verified by running perf stat -e instructions -- src/vim -Nu
profile_syntax.vim --noplugin -N -X -n -i NONE --not-a-term a_big_file.c to
see if the number of instructions went down, and validated in speedscope.