Plain scalars now suspend and resume mid-token instead of rolling back to a checkpoint and rescanning from the token's start on every chunk boundary inside them: scanPlainScalar's loop is an explicit PlainScalarPhase state machine that throws PlainScalarSuspended the moment a lookahead runs dry, and scanStep() resumes it directly from Scanner.suspendedPlainScalar. Getting this right took four rounds of bugs, each caught by the existing whole-suite streaming differential test: checking ranDry only once at the loop's end instead of after every peek-dependent decision, resuming a dry-interrupted content or break run through a pre-check meant only for a fresh entry point, trusting dispatchToken's own dry-fooled document-marker check, and a nested blankLineAhead() lookahead independently going dry before an outer check could catch it. A synthetic long-scalar test (needed since the real-world benchmark fixture's scalars are too short to exercise this) then caught a fifth, genuine bug: the first working resumePlainScalar took the whole suspended value by parameter, which kept a reference to its accumulated string alive for the entire resume call and defeated copy-on-write, copying the whole scalar on every resume. That is O(N^2) in the scalar's length, and measured 29x slower than not suspending at all at small chunk sizes. Passing state inout directly, with the alias broken at the call site before the call, fixed it to 3-4x, verified genuinely linear (not just a smaller quadratic constant) by a permanent test scaling scalar length 16x at a fixed chunk size. Also exposes this scanner as public API for the first time: scanStep() and everything around it were internal-only, so no consumer outside this module, including the separate PureYAMLStreaming package that actually wants a resumable token scanner, could call it. PureYAML.Streaming.Scanner wraps scanStep() directly with no independent logic, translating its tokens into public Streaming.Token/Kind/ScalarStyle types that don't leak the libyaml port's internal naming. Proven separately from the internal scanner with its own differential test, since a wrong case mapping in the translation layer wouldn't be caught by a proof that never looks at the public types. Stops short of rewiring PureYAMLStreaming's own document-boundary scanner onto this; that is separate, cross-repo work with its own build and CI cycle.
206 lines
11 KiB
Swift
206 lines
11 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 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()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|