Skip to content

[repo-assist] perf: avoid Seq.windowed/String allocations counting repeated fence characters - #1346

Merged
dsyme merged 1 commit into
mainfrom
repo-assist/perf-fence-count-nowindowed-20260925-4c05178b783ae3b1
Sep 27, 2026
Merged

dsyme merged 1 commit into
mainfrom
repo-assist/perf-fence-count-nowindowed-20260925-4c05178b783ae3b1

Conversation

@github-actions

Copy link
Copy Markdown
Contributor

🤖 This PR was created by Repo Assist, an automated AI assistant for this repository.

Summary

StringPosition.StartsWithNTimesTrimIgnoreStartWhitespace is the active pattern used while parsing Markdown code fences (```/~~~) and setext-style headers to count how many consecutive fence characters appear at the start of a line. It's on the hot path of block parsing since it's tried against every candidate line.

Root cause / opportunity

The previous implementation counted repeats via:

Seq.windowed start.Length startAndRest
|> Seq.map (fun chars -> System.String(chars))
|> Seq.takeWhile ((=) start)
|> Seq.length

Seq.windowed allocates a new char[] window for every character offset in the scanned text, and System.String(chars) allocates a new string for each window — even though only the leading few windows are ever actually compared (Seq.takeWhile), the two lazy allocation calls happen for every offset the char[]/string pipeline steps through before it decides to stop, since Seq.windowed/Seq.map still materialize each item as a wrapped seq operation. For a fence of n repeated characters and a following line of length m, this was O(m) allocations of O(start.Length) size each, invoked for every candidate line during block parsing.

Fix

Replaced the pipeline with a direct loop using String.CompareOrdinal(text, offset, start, 0, start.Length), which compares in place with no intermediate char[]/string allocation. The counting semantics (consecutive matches starting at offset 0, advancing by 1 character each time, same as the original Seq.windowed-based walk) are preserved exactly.

Trade-offs

None identified — this is a pure allocation-reduction change with identical observable behavior; it does not change parsing results.

Test Status

  • ✅ dotnet build FSharp.Formatting.sln --configuration Release — succeeds, 0 warnings, 0 errors.
  • ✅ dotnet test tests/FSharp.Markdown.Tests/FSharp.Markdown.Tests.fsproj --configuration Release — all 375 tests pass (this active pattern is exercised heavily by the code-fence and header parsing tests).
  • ✅ dotnet fantomas src/Common/StringParsing.fs --check — no formatting changes needed.

Generated by 🌈 Repo Assist, see workflow run. Learn more.
Comment /repo-assist to run again

Add this agentic workflow to your repo

To install this agentic workflow, run

gh aw add githubnext/agentics/workflows/repo-assist.md@4bc8419fad05e6b032741cbfd189986700bcf71c

…tern

StartsWithNTimesTrimIgnoreStartWhitespace (used to count repeated fence
characters like backtick/tilde runs when parsing code fences and setext
headers) previously counted matches via Seq.windowed + String() + Seq.map,
allocating one string per character offset of the scanned text. Replace
this with a direct String.CompareOrdinal loop over string offsets, with
no intermediate allocations. Behavior (including counting semantics) is
unchanged.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@nojaf

nojaf commented Sep 25, 2026

Copy link
Copy Markdown
Collaborator

/repo-assist it bothers me a bit that you have no numbers whatsoever to prove your claim.

@github-actions

github-actions Bot commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor Author

✓ Repo Assist completed successfully, see workflow run.

Generated by 🌈 Repo Assist, see workflow run. Learn more.

@github-actions

Copy link
Copy Markdown
Contributor Author

🤖 This is an automated response from Repo Assist.

Good call — here are concrete numbers from a standalone dotnet fsi micro-benchmark comparing the old Seq.windowed-based counter against the new String.CompareOrdinal loop, each run 200,000 times (GC.GetAllocatedBytesForCurrentThread / Stopwatch, warmed up first):

Input (start, text length) Old: time Old: bytes/iter New: time New: bytes/iter
```, 10 chars (fence + info string) 237.9 ms 752 B 20.4 ms 0 B
```, 20 backticks 749.0 ms 1,776 B 53.3 ms 0 B
~~~, 51-char tilde fence 629.5 ms 3,760 B 25.2 ms 0 B
-, 80-char setext underline 751.2 ms 5,104 B 42.8 ms 0 B
=, 80-char setext underline 729.7 ms 5,104 B 42.0 ms 0 B

So on these representative fence/setext-header inputs the change is ~11x–37x faster and eliminates essentially all allocation (down to 0 B/iter, vs. up to ~5 KB/iter with the old implementation, plus the associated Gen0 GC pressure — up to 100 Gen0 collections per 200k calls in the old version, 0 in the new one).

Benchmark script (self-contained, not part of the test suite) used for these numbers:

bench.fsx
open System
open System.Diagnostics

// OLD implementation (Seq.windowed based)
let oldCount (start: string) (startAndRest: string) =
    Seq.windowed start.Length startAndRest
    |> Seq.map (fun chars -> System.String(chars))
    |> Seq.takeWhile ((=) start)
    |> Seq.length

// NEW implementation (CompareOrdinal loop)
let newCount (start: string) (startAndRest: string) =
    let mutable count = 0
    let mutable keepGoing = true
    while keepGoing do
        if count + start.Length <= startAndRest.Length
           && String.CompareOrdinal(startAndRest, count, start, 0, start.Length) = 0 then
            count <- count + 1
        else
            keepGoing <- false
    count

let measure (name: string) (f: unit -> unit) (iterations: int) =
    for _ in 1 .. 1000 do f () // warmup
    GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect()
    let gen0Before = GC.CollectionCount(0)
    let allocBefore = GC.GetAllocatedBytesForCurrentThread()
    let sw = Stopwatch.StartNew()
    for _ in 1 .. iterations do f ()
    sw.Stop()
    let allocAfter = GC.GetAllocatedBytesForCurrentThread()
    let gen0After = GC.CollectionCount(0)
    printfn "%-12s iters=%-8d time=%8.2fms  allocated=%10d bytes (%.2f B/iter)  gen0 collections=%d"
        name iterations sw.Elapsed.TotalMilliseconds (allocAfter - allocBefore)
        (float (allocAfter - allocBefore) / float iterations) (gen0After - gen0Before)

let cases =
    [ "```", "``` fsharp"
      "```", "````````````````````"
      "~~~", "~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"
      "-", String.replicate 80 "-"
      "=", String.replicate 80 "=" ]

for (start, text) in cases do
    printfn "\n--- start=%A textLen=%d ---" start text.Length
    measure "old" (fun () -> oldCount start text |> ignore) 200_000
    measure "new" (fun () -> newCount start text |> ignore) 200_000

This isn't a formal BenchmarkDotNet run, but the allocation counts (via GC.GetAllocatedBytesForCurrentThread) are exact, and the relative timing gap is consistent and large across all tested fence lengths, so I'm confident the change is a genuine, measurable win with no behavior change. Happy to add a proper BenchmarkDotNet benchmark to the repo if that would be useful for future perf PRs — let me know.

Generated by 🌈 Repo Assist, see workflow run. Learn more.
Comment /repo-assist to run again

Add this agentic workflow to your repo

To install this agentic workflow, run

gh aw add githubnext/agentics/workflows/repo-assist.md@4bc8419fad05e6b032741cbfd189986700bcf71c

@nojaf
nojaf marked this pull request as ready for review September 25, 2026 06:41
@dsyme
dsyme merged commit 20905ef into main Sep 27, 2026
11 checks passed
@dsyme
dsyme deleted the repo-assist/perf-fence-count-nowindowed-20260925-4c05178b783ae3b1 branch September 27, 2026 16:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants