16 KiB
NGRA 字符串正则 IR 设计补充建议
基于当前 ngra-regex-ir.md 的设计,我认为整体方向是成立的:它已经不是“完全低级的执行字节码”,而是保留了 COUNT、COUNT_SEQ、COUNT_SET、TRY、PACK 等高层结构,因此可以继续承担后续 match-opt 的输入。
不过,为了让规则更容易扩展、避免语义误判,我建议把优化明确拆成三类,并把当前 IR 分成“语义 IR / HIR”和“计划 IR / LIR”。
1. 总体判断
当前 IR 最大的优点:
- 足够紧凑:
COUNT_SEQ、COUNT_SET、TRY等复合指令可以避免过早打碎成LoadChar + Compare + Branch。 - 保留 regex 结构:量词、字符集、确定序列、组打包等信息仍然可见。
- 适合继续做 match planning:后续可以从这些指令中识别
.*literal、[set]*、bounded repeat、固定长度 block 等结构。
当前 IR 最大的风险:
SEARCH这个名字容易误导。当前 HIR 中的SEARCH实际应当是 MATCH_AT_CURRENT,不是允许跳过字符的FIND。- 检查点目前只描述为锚点指针栈,但真正的 backtracking frame 至少还要保存
pos、capture watermark、symbol watermark、repeat state 和 continuation。 COUNT_*的语义必须明确是否保留 retry/backtracking,否则a*.、[abc]*abc等场景会被错误优化。- 字符集的语义表示和物理表示要分开:语义上
[0-9a-z]是一个 set,物理上可以用 inline set-region 的多条 range-test 表示,避免每个 set 挂List<RangePair>。
2. 建议的分层
我建议仍然区分 HIR 和 LIR,不是因为机器必须分,而是因为人写规则时边界更清楚。
AST
↓
AST Opt
↓
HIR Gen
↓
HIR Analysis
↓
HIR → LIR / MatchPlan Lowering
↓
LIR
↓
SourceGen / RuntimeGen / Interpreter
HIR:表达 regex 语义
HIR 保留接近正则结构的指令:
SEARCH_SEQ // 语义上是当前位置 MATCH_SEQ
SEARCH_SET // 语义上是当前位置 MATCH_SET
SEARCH_DOT // 或 UNIT_DOT,不应和 literal "." 混淆
COUNT
COUNT_SEQ
COUNT_SET
TRY
POP
PACK
注意:HIR 中可以继续叫 SEARCH,但文档里要明确:
HIR 的 SEARCH_* 不表示向后查找;
它表示从当前活动指针开始匹配。
真正允许跳过字符的搜索操作只在 LIR / MatchPlan 阶段引入,命名为 FIND_*。
LIR:表达匹配计划
LIR 不只是更低级,而是“已经做过策略规划”的 IR:
MATCH_SEQ
MATCH_CHAR
MATCH_DOT
MATCH_SET
FIND_SEQ
FIND_CHAR
FIND_SET
FIND_LAST_SEQ
FIND_LAST_SET
CONSUME_WHILE_SET
CONSUME_UNTIL_CHAR
CONSUME_UNTIL_SET
COUNT_SET
COUNT_SEQ
COUNT_BLOCK
CHECK_MIN_LENGTH
CHECK_FIXED_OFFSET
PUSH_BACKTRACK
POP_BACKTRACK
COMMIT
FAIL
CAPTURE_BEGIN
CAPTURE_END
PACK
LIR 中应该已经包含:
LoweredSetId
SearchMode
RepeatMode
RetryMode
CaptureMode
EmitHint
3. 三类优化策略
建议把所有优化分成三类:
1. Regex algebra rewrite
改正则结构。
2. Match plan rewrite
改匹配策略。
3. Backend / C# span trick
改 C# 代码写法。
这个分类很重要,因为它能直接决定优化应该放在哪个阶段。
4. Regex algebra rewrite
这类优化应该放在 AST Opt 或 HIR 生成前后。它们改变的是正则结构,而不是 C# 写法。
4.1 Literal 合并
a b c
变成:
abc
HIR:
SEARCH_SEQ "abc"
不要生成三条 SEARCH_CHAR。
4.2 Concat / Alternation flatten
a(?:b(?:c))
变成:
abc
a|(?:b|c)
变成:
a|b|c
4.3 Alternation factoring / trie
这个你已经放在 AST Opt 做,位置是对的。
foobar|foobaz|foozip
变成:
foo(?:bar|baz|zip)
或者 trie AST:
SEQ "foo"
ALT
SEQ "bar"
SEQ "baz"
SEQ "zip"
这类结构改写越早越好。后续 IR opt 只需要识别这个结构并决定是否 switch / first-set dispatch。
4.4 Bounded repeat 展开
a{2,3}
可以变成:
aa a?
或:
SEARCH_SEQ "aa"
OPTIONAL SEARCH_CHAR 'a'
对于 lazy:
a{2,3}?b
要注意尝试顺序。它等价于:
aa(?:b|ab)
而不是:
aa(?:a|ab)
Greedy 版本:
a{2,3}b
如果展开成显式分支,尝试顺序更像:
aa(?:ab|b)
语言集合相同,但 backtracking 尝试顺序不同。
4.5 a+b 不建议改成 a*(ab|b)
当前文档中写:
a+b 可以优化为 a*(ab|b)
这个需要修正。
a+b 的语言是:
a^n b, n >= 1
而 a*(ab|b) 会额外匹配:
b
因为第二个分支 a*b 可以在 a* 取 0 时匹配 b。
更安全的结构是:
aa*b
或者:
SEARCH_CHAR 'a'
COUNT_SEQ 'a' 0..∞
SEARCH_CHAR 'b'
如果要利用后继 b 做策略优化,应在 MatchPlan 阶段处理,而不是做这个不等价的 algebra rewrite。
4.6 Charset 合并
[abc]|[def]
可以变成:
[abcdef]
[a-f]|[d-z]
可以变成:
[a-z]
这属于 AST/HIR 层的字符类代数。
4.7 Charset 展开为 alternation 要谨慎
文档中写:
abc[de] 可以展开为 (abcd|abce)
这个是可行的,但不一定总是好。建议规则改成:
当 charset 很小,并且展开后能形成更长 literal / trie / branch dispatch 时,可以展开;
否则保留为 MATCH_SET。
例如:
abc[de]
既可以是:
SEARCH_SEQ "abc"
SEARCH_SET [de]
也可以是:
ALT
SEARCH_SEQ "abcd"
SEARCH_SEQ "abce"
前者代码更短,后者可能更适合 required literal 或 trie。需要 code size budget。
5. Match plan rewrite
这类优化应该放在 HIR Analysis / HIR → LIR lowering。
它们不一定改成另一条正则,而是把匹配过程改成更高效的搜索/消费计划。
5.1 长度预测应该在 HIR Analysis 做,SourceGen 只负责发代码
当前文档写“长度预测在 source-gen 阶段做”。我建议改成:
minLength / maxLength 的计算在 HIR Analysis 做;
SourceGen 只负责把 CHECK_MIN_LENGTH 发成 C#。
例如:
abc[0-9]{2}xyz
分析结果:
MinLength = 8
LIR:
CHECK_MIN_LENGTH 8
MATCH_SEQ "abc"
...
SourceGen:
if ((uint)(end - pos) < 8) goto Fail;
5.2 .* 优化
HIR:
COUNT_DOT min=0 max=∞ greedy=true
MATCH_CHAR 'a'
可以 lower 成:
FIND_LAST_CHAR 'a'
lazy:
COUNT_DOT min=0 max=∞ greedy=false
MATCH_CHAR 'a'
lower 成:
FIND_CHAR 'a'
但要注意:
- 默认
.不匹配\n,搜索范围应限制在当前行。 - Singleline 模式下才可以跨整个 span。
- 如果
.*内部或后继结构涉及 capture/backtracking,不能简单“删掉 COUNT”,而应转换为带 retry 的 find plan。 - 后继如果是
MATCH_SEQ "abc",则变成FIND_SEQ/FIND_LAST_SEQ。 - 后继如果是
MATCH_SET,则变成FIND_SET/FIND_LAST_SET。
5.3 [set]* 优化
文档中写 [set]* 优化在 source-gen 阶段做。建议改成:
是否将 COUNT_SET 变成 CONSUME_WHILE / CONSUME_UNTIL,在 lowering 做;
具体使用 IndexOfAnyExcept / SearchValues / manual loop,在 SourceGen 做。
例如:
[a-f0-9]*
LIR:
CONSUME_WHILE_SET LowerHex
SourceGen:
int n = span.IndexOfAnyExcept(s_lowerHex);
或者手写 loop / range compare。
5.4 First-set 搜索
如果非锚定匹配:
abc|defg|xyz
first-set 是:
'a', 'd', 'x'
LIR 可以是:
FIND_SET { 'a', 'd', 'x' }
VERIFY_BRANCH
你文档里说“这个不好做,因为 IR 直接把 ALT 打碎成 if-else 串了”。这说明 AST Opt / HIR 里最好保留 ALT region 或者至少保留 branch summary。
即使 AST 已经 trie 化,IR 也应该尽量不要把它完全打碎成不可恢复的 if-else 串。至少给每个 region 计算 summary:
FirstSet
MinLength
RequiredLiteral
CanBacktrack
CanCapture
5.5 Required literal 搜索
例如:
.*ERROR:[0-9]+
可以直接规划:
FIND_SEQ "ERROR:"
VERIFY_AFTER_ANCHOR
这通常比只做 first-set 更强。
5.6 Fixed-offset check
[a-z]{20}XYZ
可以 lower 成:
CHECK_MIN_LENGTH 23
CHECK_AT_OFFSET 20 MATCH_SEQ "XYZ"
MATCH_SET_REPEAT [a-z] count=20
MATCH_SEQ "XYZ"
这个属于 match plan,不属于 sourcegen。
5.7 Auto-atomic / possessive 化
[0-9]*[A-Z]
由于:
[0-9] ∩ [A-Z] = ∅
所以 [0-9]* 不需要回退,可以 lower 成:
COUNT_SET [0-9] greedy atomic
MATCH_SET [A-Z]
如果后继 first-set 和 repeat set 有交集,就不能这么做。
5.8 Repeat retry mode
LIR 应该明确 repeat 的 retry 策略:
RetryMode.None
RetryMode.BackoffBy1
RetryMode.BackoffByStride
RetryMode.SavedPositions
RetryMode.GeneralBacktrack
例如:
(abc[cd])+
每轮固定长度 4,所以可以:
RetryMode.BackoffByStride stride=4
不需要保存每个内部指令的回溯点。
6. Backend / C# span trick
这类优化只放 SourceGen。它不再改变 regex 或 match plan。
6.1 StartsWith
LIR:
MATCH_SEQ "sample"
SourceGen 决定:
slice.StartsWith("sample", StringComparison.Ordinal)
还是短串展开:
slice.Length >= 3 &&
slice[0] == 'a' &&
slice[1] == 'b' &&
slice[2] == 'c'
建议规则:
长度 1:
char compare
长度 2~3:
直接 char compare
中长 literal:
StartsWith
IgnoreCase:
StartsWith(..., OrdinalIgnoreCase)
6.2 [0-9] 不建议写成 IndexOf("0123456789") 用于当前位置测试
文档中写:
[0-9] 展开为 IndexOf("0123456789")
这个要分两种场景:
当前位置匹配
如果是:
MATCH_SET [0-9]
更好是:
(uint)(ch - '0') <= 9
span 中搜索
如果是:
FIND_SET [0-9]
可以考虑:
span.IndexOfAnyInRange('0', '9')
或:
span.IndexOfAny(s_digits)
所以规则应写成:
MATCH_SET [0-9] → unsigned range compare
FIND_SET [0-9] → IndexOfAnyInRange / SearchValues
6.3 SearchValues
对于多字符集合:
FIND_SET [0-9a-f]
SourceGen 可生成:
private static readonly SearchValues<char> s_lowerHex =
SearchValues.Create("0123456789abcdef");
然后:
span.IndexOfAny(s_lowerHex);
当前位置测试不一定要用 SearchValues,可能 range compare 或 bitmap 更快。
6.4 Inline vs helper
复杂 Unicode set:
MATCH_SET UnicodeWord
SourceGen 决定:
if (!IsUnicodeWord(ch)) goto Fail;
还是:
if (ch < 128)
{
if (!AsciiWordContains(ch)) goto Fail;
}
else
{
if (!IsUnicodeWord(ch)) goto Fail;
}
这个应由代码尺寸、热度、是否 source-gen 决定。
6.5 Code size budget
SourceGen 应负责最终代码膨胀控制:
单方法最大语句数
literal 展开阈值
bounded repeat 展开阈值
是否生成 helper
是否抽 cold path
SearchValues 字段数量
7. 字符集的物理表示建议
为了避免每个 set 挂 List<RangePair>,可以用线性 IR region 表示同一个字符测试。
例如语义:
[0-9a-z]
物理 IR:
MATCH_SET_BEGIN default=false end=13
TEST_RANGE '0' '9' accept=true
TEST_RANGE 'a' 'z' accept=true
MATCH_SET_END
语义:
[^0-9a-z]
物理 IR:
MATCH_SET_BEGIN default=true end=13
TEST_RANGE '0' '9' accept=false
TEST_RANGE 'a' 'z' accept=false
MATCH_SET_END
要点是:
这些 TEST_RANGE 测的是同一个字符;
整个 MATCH_SET 只推进 pos 一次。
不能把它误解成连续匹配多个字符。
复杂 Unicode set 可以走:
MATCH_SET_REF setId=42
setId=42 指向全局 pooled table,而不是每个节点一个 List。
8. 检查点语义建议
当前文档把检查点描述为锚点指针栈,这对 capture 起点有用,但不够覆盖 backtracking。
建议把 checkpoint / backtrack frame 定义成:
BacktrackFrame:
pos
anchor
captureTop
symbolTop
repeatState
continuation
其中:
pos:
活动指针恢复位置
anchor:
区间开始位置
captureTop:
捕获记录栈回滚点
symbolTop:
符号区回滚点
repeatState:
当前 COUNT/loop 的计数、起止位置、retry mode
continuation:
失败后跳到哪里继续尝试
如果暂时是 tokenizer / no-capture 模式,可以裁掉 captureTop,但 IR 语义最好预留。
9. 建议修改原文的几个点
9.1 “HIT” 应改为 “HIR”
原文:
将HIT进行进一步优化
应为:
将 HIR 进行进一步优化
9.2 SEARCH 的定义应改写
原文说“向后搜索确定长度序列”。建议改为:
HIR 中 SEARCH_* 表示从当前活动指针开始匹配;
它不是允许跳过字符的 FIND。
真正的 FIND_* 在 HIR→LIR 阶段由 match planner 引入。
9.3 a+b → a*(ab|b) 不等价
建议删除或改成:
a+b → aa*b
或者:
MATCH_CHAR 'a'
COUNT_SEQ 'a' 0..∞
MATCH_CHAR 'b'
9.4 [0-9] → IndexOf("0123456789") 应细分
改成:
当前位置 MATCH_SET:
用 unsigned range compare。
span 搜索 FIND_SET:
用 IndexOfAnyInRange / SearchValues / IndexOfAny。
9.5 .* 优化不要说 “COUNT 消失”
建议改成:
COUNT_DOT 与后继匹配项合并为 FIND / FIND_LAST 计划。
这样更准确,因为有时还需要保留 retry / range 限制 / newline 限制。
10. 推荐最终管线
Tokenize
- 区分 Dot 与 EscapedLiteralDot
- 区分 raw charset / literal / quantifier / group
AST Build
- 建立 regex 语义树
AST Opt
- literal merge
- concat flatten
- alternation factoring / trie
- bounded repeat normalization
- simple group elimination
- charset algebra merge
HIR Gen
- SEARCH_* as MATCH_AT_CURRENT
- COUNT_*
- TRY / POP
- PACK
- raw set / raw dot
HIR Analysis
- minLength / maxLength
- nullable
- firstSet
- lastSet
- required literal
- fixed length
- capture effect
- repeat overlap
HIR → LIR / MatchPlan
- raw set → lowered set
- dot → dot mode
- introduce FIND
- introduce CONSUME
- choose retry mode
- choose atomic/possessive
- emit check-min-length / fixed-offset checks
LIR
- explicit MATCH / FIND / CONSUME
- explicit lowered set
- explicit backtracking plan
- explicit capture plan
SourceGen
- StartsWith / SequenceEqual
- IndexOf / IndexOfAny / IndexOfAnyExcept
- SearchValues
- range compare / bitmap
- inline/helper split
- code size budget
11. 总结
我建议保留你当前 IR 的核心设计,但把文档里的边界收紧:
AST Opt:
做 Regex algebra rewrite。
HIR:
表示从当前位置开始的 regex 语义。
HIR Analysis:
提取结构属性。
HIR → LIR:
做 Match plan rewrite。
LIR:
表示明确的匹配计划。
SourceGen:
做 C# span trick。
最重要的改动是:
SEARCH 在 HIR 中应被定义为 MATCH_AT_CURRENT;
FIND 只能由后续优化阶段引入。
以及:
字符集可以用线性 set-region 表示,避免 List 分配;
但 set-region 内的多条 range-test 必须测试同一个字符,不能变成多个连续 MATCH。
这样你的 IR 会同时满足三个目标:
- 对人来说可读;
- 对优化规则来说容易扩展;
- 对 GC 和 sourcegen 来说物理表示足够紧凑。