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

16 KiB
Raw Blame History

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,不是因为机器必须分,而是因为人写规则时边界更清楚。

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'

但要注意:

  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 阶段做。建议改成:

是否将 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 会同时满足三个目标:

  1. 对人来说可读;
  2. 对优化规则来说容易扩展;
  3. 对 GC 和 sourcegen 来说物理表示足够紧凑。