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() } } } } }