Repository navigation
perf: lex each tag once, while finding its end (0142) - #455
Merged
Merged
Conversation
The splitter scanned a tag's body to find its end delimiter, then lexertk lexed the same text into its tokens and Lexer::Preprocess converted those into the parser's. TagLexer does all three in one pass over the source and makes the parser's tokens directly; the fine parse takes each block as the splitter closes it, so the list of blocks is gone too. Tags whose end the lexer cannot decide (custom delimiters that may start inside a token, lexing errors, a ';', unbalanced brackets) keep the old scan for the end and are lexed on their own, with the same results as before. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L1F5DBn1mFcgA83WbjqtiT
… (0142) A Token carried an InternalValue for its literal's value, which every token paid for: built, copied by EatIfEqual and the error paths, and destroyed when the list was cleared for the next tag. The value now comes from the source when the parser needs it (LexScanner::GetValue), once per literal, and a Token is four plain fields. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L1F5DBn1mFcgA83WbjqtiT
ParseValueExpression copied each name into a std::string through the virtual LexerHelper::GetAsString before the arena interned it. LexScanner::GetAsView gives a narrow template's names straight from the source, and a wide template's through one reused conversion buffer. This recovers most of what interning names at parse added to Load in phase 6. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L1F5DBn1mFcgA83WbjqtiT
Verifier round 1 found two differences from the scan by characters:
- A tag whose start modifier is the first character of its end delimiter
(`{{-}` with the end `-}`) gives a block that ends before it starts;
LexRange now fails it as before instead of reading past the source.
- `{{ 1e1e-}}`: lexed to the end of the template, the number stopped at
the second `e` and the `-` was taken for the end's modifier, so the
template loaded. A decimal literal that ends in its exponent now leaves
the tag to the splitter's scan, which rejects it as before.
A tag that never closes is no longer lexed to the end of the template:
after 256 tokens the lexer checks that an end delimiter is ahead at all.
ScanNumber is split for clang-tidy's complexity limit.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L1F5DBn1mFcgA83WbjqtiT
…or_info.h (0142) Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L1F5DBn1mFcgA83WbjqtiT
…rpus cases Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L1F5DBn1mFcgA83WbjqtiT
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L1F5DBn1mFcgA83WbjqtiT
…ad-2v1kv6 # Conflicts: # src/expression_parser.cpp
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L1F5DBn1mFcgA83WbjqtiT
…ad-2v1kv6 # Conflicts: # src/expression_parser.cpp
rmorozov
marked this pull request as ready for review
October 11, 2026 10:23
rmorozov
pushed a commit
that referenced
this pull request
Oct 11, 2026
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KMcWcpGwhiaEs33YjAgEV4
rmorozov
added a commit
that referenced
this pull request
Oct 11, 2026
Claude-Session: https://claude.ai/code/session_01KMcWcpGwhiaEs33YjAgEV4 Co-authored-by: Claude <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Requested by Ruslan · project thread
Before: Load read each tag three times. The splitter scanned it by characters to find its end delimiter. Then lexertk lexed the same text, and
Lexer::Preprocessconverted lexertk's tokens into the parser's. Every token also carried anInternalValue, which was built, copied and destroyed per token. The fine parse ran only after the whole template had been split.After:
TagLexerlexes a tag once, from its start, and stops at the first end delimiter (with an optional-/+) that starts a token outside brackets. That is how Jinja2's lexer finds the end too. The fine parse takes each block as soon as the splitter closes it. Load is 17-30% cheaper in instructions. Output, error codes and positions are unchanged, except for one message text described below.How
TagLexer(src/lexer.h) is a port of lexertk's scanning plusLexer::Preprocessthat makesTokens straight from the source. lexertk's generator and theLexerclass (with itsstd::function) are no longer used.TagLexer::UsableEnd: letters, digits,= < > * / . _ - +, quotes);1e1e-}})DoRoughParsingcalls the fine parse fromPushCurrentBlock, so them_textBlockslist is gone. A splitter error is still the only error reported.RecursionLimitExceededstill stops the fine parse, while the splitter carries on to the end.Tokenis now four plain fields. A literal's value is read from the source when the parser asks for it, throughLexScanner::GetValue. This change was half the gain:EatIfEqualand the error paths copy tokens, and the list is cleared for every tag.ValueRefnames go to the arena's symbol table as astring_viewof the source (LexScanner::GetAsView), with nostd::stringcopy. On its own this is -0.5..-1.4% Load.SplitLinesnow runs only when an error or the metadata needs line numbers. Keywords are compared withoutmemcmp, and string literals with nothing to convert skip the conversion.src/expression_parser.cpp, and each one kept master's structure:tok.value. The merge reads the value throughLexScanner::GetValueinstead.TakeString-basedParseAdjacentStrings, the merge reads the token's value throughLexScanner::GetValue.Measured:
bench/count.py, Release build, against master 523d59e (with #452). Preflight--perfagainst master 2af42b6 (with #453) gives the same numbers within ±0.1%.Load/many_tagsLoad/chat_llamaLoad/substituteLoad/mitsuhiko_tableLoad/plain_textEvery other Load case moved by -17.5% (filters) to -28.8% (large_static). Peak memory and the number of allocations of Load are lower in every case. Retained memory is unchanged except for
chat_llama(+64 B) andchat_qwen(+112 B). I have not found the cause of those two.Load/plain_textmeets 0140's open condition (≤ 4,014).Render was not touched. Every case is within ±0.27% except
substitute, which is +27 instructions (+1.0%). Callgrind showsRenderitself is 17 cheaper, but the~InternalValueof the parameter conversion temporary is now an out-of-line call (+44). On master it was inlined. This is GCC's inlining budget in template.cpp's translation unit, which includes the parser. A noinline hint on the conversion made it worse (+36). The perf track accepted it as a named exception.Behaviour: the verifier compared the parent and this branch, narrow and wide, over about 1.6M generated templates. These covered random, mutated and enumerated tags, custom delimiters, line statements, raw/meta blocks, every number form, literals, trans contexts and
callerdetection. Output, error codes and positions matched. One message text changed: an Identifier token with an empty range now always prints as'<<Identifier>>'. Before, it printed''({{}}in a required block), or the text of a string literal the parser had copied the token from (e.g.{% import 'x' as 'x' as y %}saidExpected: 'x').Tests:
BasicTests.TagEndsFoundWithoutTheLexercovers custom ends handled by the old scan.BasicTests.MalformedTagEndscovers the malformed cases round 1 found. All of them also pass on master.errors.semicolon_in_taganderrors.float_second_exponent, pin two lexer leniencies this PR keeps on purpose ({{ x ; 1 }}renders, and so does{{ 1e1e-1 }}). They are filed as task 0161.Process:
--perfpasses.🤖 Generated with Claude Code
https://claude.ai/code/session_01L1F5DBn1mFcgA83WbjqtiT