Files
stgsharp/future/ngra-regex-ir-review.md
T
2026-06-22 18:18:16 +08:00

1051 lines
16 KiB
Markdown
Raw Blame History

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