PureYAML/Tests/StreamingScannerPerformanceTests.swift
Mihaela Mihaljevic 6d3f3009d2 perf(parsing): correct the scanStep() floor to a checkpoint cost, not rescanning
The previous commit concluded the ~1.75x floor needed persisted mid-token
suspension to close, since that removes the need for a checkpoint. One more
measurement shows that conclusion was wrong for the floor itself: feeding the
entire 9.5MB file as a single chunk, so isFinal stays false until the true
end and essentially no real rescanning can occur, still measures 1.75x,
identical to 1,000,000-scalar and 65536-scalar chunk sizes.

The overhead is actually two independent costs. A fixed floor (~1.75x),
present even with zero rescanning, from copying the whole Scanner on every
fetchNextToken() call whenever isFinal is false, whether or not that copy is
ever used to roll back; since isFinal only becomes true once, on the last
chunk, that copy is paid for virtually every token in a real streaming
session. And a rescan-driven tail that only appears below roughly
4096-scalar chunks (up to 11.54x at 16 scalars).

Mid-token suspension would eliminate the rescan tail, not the floor. A fix
for the floor is not yet designed: every field the checkpoint covers can be
mutated before a dry point is reached within a single fetch, so no smaller
subset is obviously safe to skip, and isFinal: true remains the only
provably safe condition for skipping it entirely.
2026-07-10 01:23:39 +02:00

119 lines
5.6 KiB
Swift

import Foundation
@testable import PureYAML
import Testing
/// Measures the resumable scanner's checkpoint-and-retry cost (issue #4) against
/// the batch scanner, across a spread of chunk sizes, over the largest real-world
/// fixture, plus a `singleChunk` control that isolates the floor from real
/// rescanning (see CHANGELOG.md). The overhead splits into two independent
/// costs: a fixed floor (~1.75x release-mode, present even with a single chunk,
/// caused by copying the whole `Scanner` on every fetch whenever `isFinal` is
/// false) and a rescan-driven tail that only appears below roughly 4096-scalar
/// chunks. Persisted mid-token suspension, the deferred next increment, would
/// remove the rescan tail but not the floor; a fix for the floor is not yet
/// designed. Gated: release-mode timing on a 9.5MB fixture is not a default
/// `swift test` concern.
///
/// Run this in release mode; debug-mode numbers are not representative (Swift's
/// `-Onone` inserts defensive retain/copy traffic around `mutating throws`
/// methods on multi-field structs that the optimizer removes in release). This
/// package's own test target needs `-enable-testing` explicitly in release,
/// working around an unrelated `PureYAMLJSON` test-target issue:
///
/// ```sh
/// PUREYAML_STREAMING_PERF_MEASURE=1 swift test -c release -Xswiftc -enable-testing \
/// --filter StreamingScannerPerformanceTests
/// ```
@Suite("Streaming Scanner Performance")
struct StreamingScannerPerformanceTests {
@Test("measure scanStep() overhead over scanAll() at a spread of chunk sizes")
func test_measure() throws {
guard ProcessInfo.processInfo.environment["PUREYAML_STREAMING_PERF_MEASURE"] == "1" else {
return
}
let url = try #require(Bundle.module.url(
forResource: "github-rest-api",
withExtension: "yaml",
subdirectory: "Fixtures/real-yaml",
))
let yaml = try String(contentsOf: url, encoding: .utf8)
let clock = ContinuousClock()
var batchScanner = PureYAML.Parsing.LibYAML.Scanner(yaml)
let batchDuration = try clock.measure {
_ = try batchScanner.scanAll()
}
print("STREAMING-PERF batch bytes=\(yaml.utf8.count) time=\(batchDuration)")
// Control: the whole input is present from construction (isFinal: true), so
// ranDry can never be set and the checkpoint is never restored, meaning zero
// chunking or retry ever happens. Any overhead this still shows over
// scanAll() is intrinsic to scanStep()'s per-token loop (the checkpoint copy
// taken before every fetch, and isFrontSafeToRelease's check), not to
// chunking. This isolates that fixed cost from the chunk-size-dependent
// rescan cost measured below.
var noChunkScanner = PureYAML.Parsing.LibYAML.Scanner(yaml, isFinal: true)
let noChunkDuration = try clock.measure {
while true {
switch try noChunkScanner.scanStep() {
case .token: continue
case .done: return
case .needMoreInput: Issue.record("isFinal: true must never report needMoreInput")
return
}
}
}
let noChunkRatio = noChunkDuration / batchDuration
print("STREAMING-PERF noChunking(control) time=\(noChunkDuration) ratio=\(String(format: "%.2f", noChunkRatio))x")
for chunkSize in [16, 64, 256, 1024, 4096, 65536, 1_000_000] {
let duration = try streamingDuration(yaml: yaml, chunkSize: chunkSize, clock: clock)
let ratio = duration / batchDuration
print("STREAMING-PERF chunkSize=\(chunkSize) time=\(duration) ratio=\(String(format: "%.2f", ratio))x")
}
// Distinguishes two candidate explanations for the chunked-mode floor: (a)
// real rescanning at chunk boundaries, which should vanish as chunk count
// drops toward 1, or (b) a fixed per-fetchNextToken() cost paid on every
// token regardless of how many chunks exist, which would not. A single
// chunk covering the whole file means `isFinal` is false (so the checkpoint
// is taken every fetch) but `ranDry` can only ever fire once, at the true
// end: the only possible rescan is the very last token.
let wholeFileAsOneChunk = yaml.unicodeScalars.count + 1
let singleChunkDuration = try streamingDuration(yaml: yaml, chunkSize: wholeFileAsOneChunk, clock: clock)
let singleChunkRatio = singleChunkDuration / batchDuration
print("STREAMING-PERF singleChunk(wholeFile) time=\(singleChunkDuration) ratio=\(String(format: "%.2f", singleChunkRatio))x")
}
}
private func streamingDuration(yaml: String, chunkSize: Int, clock: ContinuousClock) throws -> Duration {
let scalars = Array(yaml.unicodeScalars)
var chunks: [String] = []
var index = 0
while index < scalars.count {
let end = min(index + chunkSize, scalars.count)
chunks.append(String(String.UnicodeScalarView(scalars[index ..< end])))
index = end
}
return try clock.measure {
var scanner = PureYAML.Parsing.LibYAML.Scanner("", isFinal: false)
var nextChunk = 0
while true {
switch try scanner.scanStep() {
case .token:
continue
case .done:
return
case .needMoreInput:
if nextChunk < chunks.count {
scanner.reader.append(chunks[nextChunk])
nextChunk += 1
} else {
scanner.reader.markFinal()
}
}
}
}
}