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.
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.
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.
Add StreamingScannerPerformanceTests (issue #4), gated behind
PUREYAML_STREAMING_PERF_MEASURE=1, measuring scanStep() against scanAll() on
the largest real-world fixture (9.5MB) across a spread of chunk sizes.
Small chunk sizes (16-256 scalars) show a real, chunk-size-dependent rescan
cost, up to 6x at 16, consistent with the checkpoint-and-retry design
rescanning a fetch attempt once per chunk it spans. A control run with
isFinal: true from construction, where ranDry can never be set and no
rollback ever happens, still cost ~1.5x over scanAll(), proving a separate,
chunk-size-independent cost dominates at any realistic chunk size (4KB+): the
checkpoint was being copied on every fetch even when it could never be used.
Fixed by skipping the checkpoint entirely when reader.isFinal is true.
Re-measured rather than assumed fixed: the isolated control improved from
1.49x to 1.38x, a real but modest gain, while the large-chunk-size ratios
were unchanged within measurement noise. The remaining floor's cause is not
identified and is left open on issue #4 rather than patched further on
speculation; closing it needs a profiler, not another guess.
Whole-suite conformance (402/402) and the chunk-size differential proof both
still pass unchanged.