Skip to content

Avoid quadratic-time parsing of large multiline values - #25

Open
ChrisJr404 wants to merge 2 commits into
bigkevmcd:mainfrom
ChrisJr404:fix-multiline-quadratic
Open

Avoid quadratic-time parsing of large multiline values#25
ChrisJr404 wants to merge 2 commits into
bigkevmcd:mainfrom
ChrisJr404:fix-multiline-quadratic

Conversation

@ChrisJr404

Copy link
Copy Markdown

While parsing config files with large multiline values, I noticed ParseReader gets very slow well out of proportion to the input size.

Cause

Each multiline continuation line is appended to the current option value with string concatenation:

value += "\n" + p.opt.inlineCommentPrefixes.Split(line)

Go strings are immutable, so every += allocates a new string and copies the entire accumulated value. For an option whose value spans n continuation lines this makes parsing O(n^2) in the number of lines, even though the file is read once.

Impact

A single option with a large multiline value is enough to trigger it. On my machine:

continuation lines input size parse time (before)
50k ~150 KB 1.8 s
100k ~300 KB 5.6 s
200k ~600 KB 25.6 s

Growth is quadratic, so a slightly larger file ties the parser up for minutes on a small input. Since ParseReader is the main entry point (also reached via Parse/NewConfigParserFromFile) and config data can come from untrusted sources, this is an easy denial-of-service.

Fix

Accumulate the value in a strings.Builder, which grows amortized O(1), so parsing is linear. Parsed values are byte-for-byte identical, so behaviour is unchanged. After the change the 200k case above drops from ~25 s to ~15 ms.

Added a regression test that parses a large multiline value and fails if timing is quadratic (with a generous bound so it only trips on super-linear behaviour). Existing tests, go vet, and gofmt all pass.

Multiline continuation lines were appended to the option value with
string concatenation (value += ...). Because Go strings are immutable,
each continuation line reallocated and copied the whole accumulated
value, making ParseReader run in O(n^2) time in the number of
continuation lines belonging to a single option.

A config with a single large multiline value is enough to trigger this:
a ~600KB input took roughly 25 seconds to parse, and growth is
quadratic, so a modestly larger file hangs the parser for minutes.

Accumulate the value in a strings.Builder instead, which grows
amortized O(1), so parsing is linear. Parsing behaviour and output are
unchanged. Added a regression test that parses a large multiline value
and fails on quadratic timing.

@bigkevmcd bigkevmcd left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for this!

My natural inclination is to convert TestParseLargeMultilineValue to a Benchmark test.

But otherwise the actual improvement is great.

// the whole accumulated value on every line, making parsing O(n^2) in the number
// of continuation lines. A ~600KB input took tens of seconds to parse.
func TestParseLargeMultilineValue(t *testing.T) {
const n = 300000

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wow...yes...this is very old code and string.Builder was added in 1.10 IIRC

Thank you!

Converting this test into a benchmark shows how much of a difference this makes:

BenchmarkParseLargeMultilineValue-8   	      39	  32801434 ns/op

vs

BenchmarkParseLargeMultilineValue-8   	       1	25768411357 ns/op


var b bytes.Buffer
b.WriteString("[section]\nkey = first\n")
for i := 0; i < n; i++ {

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we bump go.mod to require Go 1.22 this could be simplified to:

Suggested change
for i := 0; i < n; i++ {
for range n {

@ChrisJr404

Copy link
Copy Markdown
Author

Good call, turned it into a benchmark. Thanks!

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The performance regression check must be automatically executed and enforced.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

Improves multiline-value parsing performance by replacing repeated string concatenation with strings.Builder.

Changes:

  • Makes multiline accumulation amortized-linear.
  • Adds a large-value parsing benchmark.
File summaries
File Changes
configparser.go Uses strings.Builder for multiline values.
configparser_multiline_test.go Adds a large multiline parsing benchmark; the performance check is not automatically enforced in CI.
Review details
  • Files reviewed: 2/2 changed files
  • Comments generated: 1
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

// multiline value. Each continuation line used to be appended with string
// concatenation, which reallocates and copies the whole accumulated value on
// every line, making parsing O(n^2) in the number of continuation lines.
func BenchmarkParseLargeMultilineValue(b *testing.B) {
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants