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.
119 lines
5.6 KiB
Swift
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()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|