[repo-assist] perf: avoid Seq.windowed/String allocations counting repeated fence characters - #1346
Conversation
…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>
|
/repo-assist it bothers me a bit that you have no numbers whatsoever to prove your claim. |
|
✓ Repo Assist completed successfully, see workflow run.
|
|
🤖 This is an automated response from Repo Assist. Good call — here are concrete numbers from a standalone
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.fsxopen 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_000This isn't a formal BenchmarkDotNet run, but the allocation counts (via
Add this agentic workflow to your repoTo install this agentic workflow, run |
🤖 This PR was created by Repo Assist, an automated AI assistant for this repository.
Summary
StringPosition.StartsWithNTimesTrimIgnoreStartWhitespaceis 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.windowedallocates a newchar[]window for every character offset in the scanned text, andSystem.String(chars)allocates a newstringfor each window — even though only the leading few windows are ever actually compared (Seq.takeWhile), the two lazy allocation calls happen for every offset thechar[]/stringpipeline steps through before it decides to stop, sinceSeq.windowed/Seq.mapstill materialize each item as a wrapped seq operation. For a fence ofnrepeated characters and a following line of lengthm, this wasO(m)allocations ofO(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 intermediatechar[]/stringallocation. The counting semantics (consecutive matches starting at offset 0, advancing by 1 character each time, same as the originalSeq.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.Add this agentic workflow to your repo
To install this agentic workflow, run