PureYAML/Tests/StreamingScannerPerformanceTests.swift
Mihaela Mihaljevic c6740ca299 perf(parsing): correct the scanStep() finding to release-mode numbers
The prior commit's ~1.5x "unexplained floor" was measured in debug mode,
because release-mode test builds hit an unrelated PureYAMLJSON test-target
issue. sample(1) profiling of that debug build pointed at consumePlainContent,
a mutating throws method, which is a known trigger for defensive retain/copy
traffic under -Onone that the optimizer removes in release.

Worked around the release-test-build issue with -Xswiftc -enable-testing and
re-measured: the isFinal: true control now shows 1.04x, essentially batch
parity, so the checkpoint-skip fix was a full fix for that case, not the
small, unexplained one debug mode suggested.

What remains is now correctly explained rather than guessed at: a chunked
run's isFinal only becomes true once, on the last chunk, so for any input
that arrives incrementally, the checkpoint stays live for virtually the
entire scan regardless of chunk size (release-mode ratios: 11.70x at 16
scalars down to 1.75x at 1,000,000, never reaching the 1.04x floor). Closing
that needs the deferred persisted mid-token suspension, which removes the
need for a checkpoint rather than making it cheaper; isFinal: true is the
only condition under which skipping it is provably safe, since a single
scalar can span past any finite "probably buffered enough" margin.

Removed the temporary sample(1) profiling scratch test and documented how to
re-run the measurement in release mode, which the doc comment previously
did not call out as necessary.
2026-07-10 00:36:45 +02:00

106 lines
4.7 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. `scanStep()`'s first increment restores the whole scanner and retries
/// a fetch from its start whenever it runs dry, so a token that spans many small
/// chunks is rescanned once per chunk it spans: correct (proven by
/// `StreamingScannerDifferentialTests`) but worst-case quadratic in the number of
/// chunks a single token spans. This measures whether that cost matters in
/// practice before persisted mid-token suspension (a materially larger, riskier
/// change to the scalar-scanning loops) is worth building. 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")
}
}
}
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()
}
}
}
}
}