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 controls that isolate the floor from real rescanning and from /// buffer-population strategy (see CHANGELOG.md). The overhead splits into two /// independent costs: a fixed floor (~1.75x release-mode, present even with a /// single chunk, structurally attributable to the checkpoint taken on every /// fetch whenever `isFinal` is false, confirmed by the `isFinal: true` control /// costing 1.04x with the identical per-token loop) and a rescan-driven tail /// that only appears below roughly 4096-scalar chunks. Persisted mid-token /// suspension, the deferred next increment, would remove both: it eliminates /// the need for a checkpoint at all, not just the rescanning a checkpoint's /// retry causes. 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") // Isolates whether the floor is scanning-related at all, or an artifact of // how the buffer is populated: `Reader.init` builds `scalars` in one shot // via `Array(string.unicodeScalars)`; `Reader.append(_:)` (used by every // streaming chunk feed, including the single-chunk case above) instead // grows an existing array via `append(contentsOf:)`. No scanning happens // here at all, just buffer construction. let constructDuration = clock.measure { _ = PureYAML.Parsing.LibYAML.Scanner(yaml) } var appendScanner = PureYAML.Parsing.LibYAML.Scanner("", isFinal: false) let appendDuration = clock.measure { appendScanner.reader.append(yaml) } print("STREAMING-PERF bufferConstruct time=\(constructDuration) bufferAppend time=\(appendDuration) ratio=\(String(format: "%.2f", appendDuration / constructDuration))x") } /// The `github-rest-api.yaml` fixture `test_measure` uses above has only /// short plain scalars, so it cannot exercise mid-token suspension's /// actual purpose: resuming a single plain scalar that itself spans many /// chunks. This measures exactly that shape, on one synthetic 1MB plain /// scalar. It is what caught a real O(N^2) bug in the first version of /// `resumePlainScalar`/`scanStep()`'s resume branch: passing the /// suspended `PlainState` across the `resumePlainScalar` call boundary /// as a value parameter (even one immediately copied and overwritten /// inside the callee) left the caller's own copy of `state.string`'s /// buffer aliased to the callee's for the call's whole duration, so /// copy-on-write copied the entire accumulated scalar on every resume; /// at chunk size 16 that was a measured 29x, worse than not suspending /// at all. Passing `state` directly `inout`, with the disalias done at /// the call site before the call rather than inside the callee, fixed it /// to 3-4x. See `resumePlainScalar` and `scanStep()`'s resume branch for /// the fix itself. @Test("measure scanStep() overhead on one plain scalar spanning many chunks") func test_measureLongPlainScalar() throws { guard ProcessInfo.processInfo.environment["PUREYAML_STREAMING_PERF_MEASURE"] == "1" else { return } let longWord = String(repeating: "abcdefghij", count: 100_000) let yaml = "key: \(longWord)\n" let clock = ContinuousClock() var batchScanner = PureYAML.Parsing.LibYAML.Scanner(yaml) let batchDuration = try clock.measure { _ = try batchScanner.scanAll() } print("LONGSCALAR-PERF batch bytes=\(yaml.utf8.count) time=\(batchDuration)") for chunkSize in [16, 64, 256, 4096] { let duration = try streamingDuration(yaml: yaml, chunkSize: chunkSize, clock: clock) let ratio = duration / batchDuration print("LONGSCALAR-PERF chunkSize=\(chunkSize) time=\(duration) ratio=\(String(format: "%.2f", ratio))x") } } /// A regression guard for the O(N^2) aliasing bug `test_measureLongPlainScalar`'s /// doc comment describes, not just an observational print like the /// measurements above: that bug's signature is per-byte cost growing with /// the scalar's total length, since each resume re-copied everything /// accumulated so far, not just the new chunk. Genuinely O(N) work /// (copying only each chunk's new characters, once) keeps per-byte cost /// flat as length grows; measured flat within 8% across a 16x range /// (250KB to 4MB) after the fix landed. `smallestPerByte` versus /// `largestPerByte` is asserted at a generous 3x, not the ~1.08x actually /// measured: this must catch a real quadratic regression, not fail on /// ordinary run-to-run noise on a shared CI runner. @Test("scanStep() cost on a long plain scalar stays linear as the scalar's length grows") func test_longPlainScalarStaysLinear() throws { guard ProcessInfo.processInfo.environment["PUREYAML_STREAMING_PERF_MEASURE"] == "1" else { return } let clock = ContinuousClock() var perByteCosts: [Double] = [] for wordCount in [25000, 400_000] { let longWord = String(repeating: "abcdefghij", count: wordCount) let yaml = "key: \(longWord)\n" let duration = try streamingDuration(yaml: yaml, chunkSize: 16, clock: clock) let seconds = Double(duration.components.seconds) + Double(duration.components.attoseconds) / 1e18 let perByte = seconds / Double(yaml.utf8.count) perByteCosts.append(perByte) print("LINEAR-CHECK bytes=\(yaml.utf8.count) time=\(duration) perByte=\(perByte)") } let smallestPerByte = perByteCosts[0] let largestPerByte = perByteCosts[1] #expect(largestPerByte < smallestPerByte * 3) } } 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() } } } } }