perf(expr): compute line starts once and extend && / || chains in place (GO-001)
BenchmarkParseLong (21,000 units, 4,200 lines of '1 ||'): 123 ms -> 1.6 ms.
- commit
- 773aee1c39bb3c009a2b075b63e7049fabea3ebe
- author
- brice · · 2026-10-12 18:26 UTC
- committer
- brice · · 2026-10-18 14:59 UTC
- parent
- 34c7f70
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
2 files · +52 -9
M expr/parse_runner.go +24 -9
| @@ -3,6 +3,7 @@ | |||
| 3 | 3 | package expr | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | + | "sort" | |
| 6 | 7 | "strconv" | |
| 7 | 8 | "strings" | |
| 8 | 9 | "unicode/utf16" | |
| @@ -398,6 +399,7 @@ type csParser struct { | |||
| 398 | 399 | operators []*csToken | |
| 399 | 400 | last *csToken // the previous token | |
| 400 | 401 | cur *csToken | |
| 402 | + | lineStarts []int // offsets of the first unit of each line, see position | |
| 401 | 403 | } | |
| 402 | 404 | ||
| 403 | 405 | // fail builds the ParseException of the runner: with a token, the message names | |
| @@ -415,16 +417,21 @@ func (p *csParser) fail(t ErrorType, tok *csToken) *Error { | |||
| 415 | 417 | } | |
| 416 | 418 | ||
| 417 | 419 | // position converts an absolute index into the line and column of the | |
| 418 | - | // JavaScript-style Position (line breaks are U+000A). | |
| 420 | + | // JavaScript-style Position (line breaks are U+000A). The offsets of the line | |
| 421 | + | // starts are computed once, on first use, so that building the position of | |
| 422 | + | // every token stays linear in the length of the expression. | |
| 419 | 423 | func (p *csParser) position(index int) Position { | |
| 420 | - | line, lineStart := 0, 0 | |
| 421 | - | for i := 0; i < index && i < len(p.units); i++ { | |
| 422 | - | if p.units[i] == '\n' { | |
| 423 | - | line++ | |
| 424 | - | lineStart = i + 1 | |
| 424 | + | if p.lineStarts == nil { | |
| 425 | + | p.lineStarts = []int{0} | |
| 426 | + | for i, u := range p.units { | |
| 427 | + | if u == '\n' { | |
| 428 | + | p.lineStarts = append(p.lineStarts, i+1) | |
| 429 | + | } | |
| 425 | 430 | } | |
| 426 | 431 | } | |
| 427 | - | return Position{Line: line, Column: index - lineStart} | |
| 432 | + | // The line is the last start that is <= index. | |
| 433 | + | line := sort.Search(len(p.lineStarts), func(i int) bool { return p.lineStarts[i] > index }) - 1 | |
| 434 | + | return Position{Line: line, Column: index - p.lineStarts[line]} | |
| 428 | 435 | } | |
| 429 | 436 | ||
| 430 | 437 | func (p *csParser) token(t *csToken, typ TokenType) Token { | |
| @@ -638,10 +645,18 @@ func (p *csParser) flushTop() error { | |||
| 638 | 645 | typ = TokenOr | |
| 639 | 646 | } | |
| 640 | 647 | l := &Logical{Operator: p.token(op, typ)} | |
| 641 | - | for _, a := range args { | |
| 648 | + | for i, a := range args { | |
| 642 | 649 | // A nested chain of the same operator is merged, as the runner does. | |
| 650 | + | // The left operand of "a || b || c" is the chain built so far: its | |
| 651 | + | // slice is extended instead of copied, which keeps a long chain | |
| 652 | + | // linear. | |
| 643 | 653 | if nested, ok := a.(*Logical); ok && nested.Operator.Type == typ { | |
| 644 | - | l.Args = append(l.Args, nested.Args...) | |
| 654 | + | if i == 0 { | |
| 655 | + | l.Operator = nested.Operator | |
| 656 | + | l.Args = nested.Args | |
| 657 | + | } else { | |
| 658 | + | l.Args = append(l.Args, nested.Args...) | |
| 659 | + | } | |
| 645 | 660 | } else { | |
| 646 | 661 | l.Args = append(l.Args, a) | |
| 647 | 662 | } | |
| @@ -3,6 +3,7 @@ | |||||
| 3 | package expr | 3 | package expr | ||
| 4 | 4 | ||||
| 5 | import ( | 5 | import ( | ||
| 6 | + | "sort" | |||
| 6 | "strconv" | 7 | "strconv" | ||
| 7 | "strings" | 8 | "strings" | ||
| 8 | "unicode/utf16" | 9 | "unicode/utf16" | ||
| @@ -398,6 +399,7 @@ type csParser struct { | |||||
| 398 | operators []*csToken | 399 | operators []*csToken | ||
| 399 | last *csToken // the previous token | 400 | last *csToken // the previous token | ||
| 400 | cur *csToken | 401 | cur *csToken | ||
| 402 | + | lineStarts []int // offsets of the first unit of each line, see position | |||
| 401 | } | 403 | } | ||
| 402 | 404 | ||||
| 403 | // fail builds the ParseException of the runner: with a token, the message names | 405 | // fail builds the ParseException of the runner: with a token, the message names | ||
| @@ -415,16 +417,21 @@ func (p *csParser) fail(t ErrorType, tok *csToken) *Error { | |||||
| 415 | } | 417 | } | ||
| 416 | 418 | ||||
| 417 | // position converts an absolute index into the line and column of the | 419 | // position converts an absolute index into the line and column of the | ||
| 418 | - | // JavaScript-style Position (line breaks are U+000A). | 420 | + | // JavaScript-style Position (line breaks are U+000A). The offsets of the line |
| 421 | + | // starts are computed once, on first use, so that building the position of | |||
| 422 | + | // every token stays linear in the length of the expression. | |||
| 419 | func (p *csParser) position(index int) Position { | 423 | func (p *csParser) position(index int) Position { | ||
| 420 | - | line, lineStart := 0, 0 | 424 | + | if p.lineStarts == nil { |
| 421 | - | for i := 0; i < index && i < len(p.units); i++ { | 425 | + | p.lineStarts = []int{0} |
| 422 | - | if p.units[i] == '\n' { | 426 | + | for i, u := range p.units { |
| 423 | - | line++ | 427 | + | if u == '\n' { |
| 424 | - | lineStart = i + 1 | 428 | + | p.lineStarts = append(p.lineStarts, i+1) |
| 429 | + | } | |||
| 425 | } | 430 | } | ||
| 426 | } | 431 | } | ||
| 427 | - | return Position{Line: line, Column: index - lineStart} | 432 | + | // The line is the last start that is <= index. |
| 433 | + | line := sort.Search(len(p.lineStarts), func(i int) bool { return p.lineStarts[i] > index }) - 1 | |||
| 434 | + | return Position{Line: line, Column: index - p.lineStarts[line]} | |||
| 428 | } | 435 | } | ||
| 429 | 436 | ||||
| 430 | func (p *csParser) token(t *csToken, typ TokenType) Token { | 437 | func (p *csParser) token(t *csToken, typ TokenType) Token { | ||
| @@ -638,10 +645,18 @@ func (p *csParser) flushTop() error { | |||||
| 638 | typ = TokenOr | 645 | typ = TokenOr | ||
| 639 | } | 646 | } | ||
| 640 | l := &Logical{Operator: p.token(op, typ)} | 647 | l := &Logical{Operator: p.token(op, typ)} | ||
| 641 | - | for _, a := range args { | 648 | + | for i, a := range args { |
| 642 | // A nested chain of the same operator is merged, as the runner does. | 649 | // A nested chain of the same operator is merged, as the runner does. | ||
| 650 | + | // The left operand of "a || b || c" is the chain built so far: its | |||
| 651 | + | // slice is extended instead of copied, which keeps a long chain | |||
| 652 | + | // linear. | |||
| 643 | if nested, ok := a.(*Logical); ok && nested.Operator.Type == typ { | 653 | if nested, ok := a.(*Logical); ok && nested.Operator.Type == typ { | ||
| 644 | - | l.Args = append(l.Args, nested.Args...) | 654 | + | if i == 0 { |
| 655 | + | l.Operator = nested.Operator | |||
| 656 | + | l.Args = nested.Args | |||
| 657 | + | } else { | |||
| 658 | + | l.Args = append(l.Args, nested.Args...) | |||
| 659 | + | } | |||
| 645 | } else { | 660 | } else { | ||
| 646 | l.Args = append(l.Args, a) | 661 | l.Args = append(l.Args, a) | ||
| 647 | } | 662 | } | ||
M expr/runner_semantics_test.go +28 -0
| @@ -4,7 +4,9 @@ package expr | |||
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "context" | |
| 7 | + | "strings" | |
| 7 | 8 | "testing" | |
| 9 | + | "unicode/utf16" | |
| 8 | 10 | ) | |
| 9 | 11 | ||
| 10 | 12 | // TestRunnerSemantics pins behaviours of the PORTING.md list that the C# runner | |
| @@ -113,3 +115,29 @@ func TestRunnerSemantics(t *testing.T) { | |||
| 113 | 115 | } | |
| 114 | 116 | } | |
| 115 | 117 | } | |
| 118 | + | ||
| 119 | + | // BenchmarkParseLong parses an expression of the maximum length made of many | |
| 120 | + | // short tokens spread over many lines: building the position of every token | |
| 121 | + | // must not rescan the expression. | |
| 122 | + | func BenchmarkParseLong(b *testing.B) { | |
| 123 | + | src := strings.Repeat("1 ||\n", MaxExpressionLength/5-1) + "1" | |
| 124 | + | if len(src) > MaxExpressionLength { | |
| 125 | + | b.Fatal(len(src)) | |
| 126 | + | } | |
| 127 | + | b.SetBytes(int64(len(src))) | |
| 128 | + | for i := 0; i < b.N; i++ { | |
| 129 | + | if _, err := Parse(src, nil, nil); err != nil { | |
| 130 | + | b.Fatal(err) | |
| 131 | + | } | |
| 132 | + | } | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | func TestPositionLines(t *testing.T) { | |
| 136 | + | src := "a\n\nbc d\n e" | |
| 137 | + | p := &csParser{units: utf16.Encode([]rune(src))} | |
| 138 | + | for _, tc := range []struct{ idx, line, col int }{{0, 0, 0}, {1, 0, 1}, {2, 1, 0}, {3, 2, 0}, {5, 2, 2}, {7, 2, 4}, {8, 3, 0}, {10, 3, 2}, {len(src), 3, 3}} { | |
| 139 | + | if got := p.position(tc.idx); got.Line != tc.line || got.Column != tc.col { | |
| 140 | + | t.Errorf("position(%d) = %v, want %d:%d", tc.idx, got, tc.line, tc.col) | |
| 141 | + | } | |
| 142 | + | } | |
| 143 | + | } | |
| @@ -4,7 +4,9 @@ package expr | |||||
| 4 | 4 | ||||
| 5 | import ( | 5 | import ( | ||
| 6 | "context" | 6 | "context" | ||
| 7 | + | "strings" | |||
| 7 | "testing" | 8 | "testing" | ||
| 9 | + | "unicode/utf16" | |||
| 8 | ) | 10 | ) | ||
| 9 | 11 | ||||
| 10 | // TestRunnerSemantics pins behaviours of the PORTING.md list that the C# runner | 12 | // TestRunnerSemantics pins behaviours of the PORTING.md list that the C# runner | ||
| @@ -113,3 +115,29 @@ func TestRunnerSemantics(t *testing.T) { | |||||
| 113 | } | 115 | } | ||
| 114 | } | 116 | } | ||
| 115 | } | 117 | } | ||
| 118 | + | ||||
| 119 | + | // BenchmarkParseLong parses an expression of the maximum length made of many | |||
| 120 | + | // short tokens spread over many lines: building the position of every token | |||
| 121 | + | // must not rescan the expression. | |||
| 122 | + | func BenchmarkParseLong(b *testing.B) { | |||
| 123 | + | src := strings.Repeat("1 ||\n", MaxExpressionLength/5-1) + "1" | |||
| 124 | + | if len(src) > MaxExpressionLength { | |||
| 125 | + | b.Fatal(len(src)) | |||
| 126 | + | } | |||
| 127 | + | b.SetBytes(int64(len(src))) | |||
| 128 | + | for i := 0; i < b.N; i++ { | |||
| 129 | + | if _, err := Parse(src, nil, nil); err != nil { | |||
| 130 | + | b.Fatal(err) | |||
| 131 | + | } | |||
| 132 | + | } | |||
| 133 | + | } | |||
| 134 | + | ||||
| 135 | + | func TestPositionLines(t *testing.T) { | |||
| 136 | + | src := "a\n\nbc d\n e" | |||
| 137 | + | p := &csParser{units: utf16.Encode([]rune(src))} | |||
| 138 | + | for _, tc := range []struct{ idx, line, col int }{{0, 0, 0}, {1, 0, 1}, {2, 1, 0}, {3, 2, 0}, {5, 2, 2}, {7, 2, 4}, {8, 3, 0}, {10, 3, 2}, {len(src), 3, 3}} { | |||
| 139 | + | if got := p.position(tc.idx); got.Line != tc.line || got.Column != tc.col { | |||
| 140 | + | t.Errorf("position(%d) = %v, want %d:%d", tc.idx, got, tc.line, tc.col) | |||
| 141 | + | } | |||
| 142 | + | } | |||
| 143 | + | } | |||