This is the record the PRD asks for in addendum A7: the numbers behind NFR §7.1 (performance
budgets) and NFR §7.2 (memory and allocation discipline), measured by the harness in
tools/SharpCorePDF.Measure. It exists so that a later change can be called a regression instead of a
feeling, and so that a budget that does not hold is visible as a number rather than as an absence.
Sections 1 to 6 record merges. Section 7 records the read side: what it costs to enumerate page details and to copy a payload through the copy route. Section 8 records the authoring side: what it costs to write a page from nothing. Section 9 records the two Release 1.1 acceptance scenarios, the creator and the editor, before the code they measure exists.
Recorded: 2026-10-15, .NET 11.0.0-rc.1.26425.128, x64, 12 logical cores, Release.
No earlier record exists — this is the baseline, and the first measurement of the engine, so nothing
here is a before/after comparison except the merge output itself (see §4).
# Workstation GC (the default of the harness)
dotnet run -c Release --project .\tools\SharpCorePDF.Measure
# Server GC, which §7.4 states the envelope against
$env:DOTNET_gcServer = "1"; dotnet run -c Release --project .\tools\SharpCorePDF.Measure
# One isolated merge under a heap sampler, one scenario per process for the working-set column
dotnet run -c Release --project .\tools\SharpCorePDF.Measure -- --single large- Every row merges the same total input bytes spread over a different number of sources: 1 × 1 MB, 10 × 100 KB, 150 × 6.7 KB (about 1 MB in each case), and rows of a constant 75 MB and 150 MB in which one padded document is used at two sizes. That is what separates the cost of bytes from the fixed cost of a source, which is the shape §7.1 states its budgets in.
- The destination is a stream that counts bytes and keeps none, so no row measures a file system.
- The bytes handed over as caller streams are read from disk outside the measured window.
- Allocations are
GC.GetTotalAllocatedBytes(precise: true)around the merge alone, with a full collection before it; wall time is the median of 5 measured merges after 2 warm-ups (3 for the 150 MB rows). The harness pads and reuses a scratch copy of the fixture corpus in%TEMP%\scpdf-assets.
x input is allocations divided by the bytes that row reads: 1.0 MB for the first seven rows, 75 MB or
150 MB for the rest. Held is what is left after a full collection once the row is done.
| Scenario | Allocated | Wall (WS / server) | Throughput | x input | Held | Output bytes |
|---|---|---|---|---|---|---|
| 1 × 1.0 MB (file) | 2,236 KB | 2.63 / 2.42 ms | 362 / 394 MB/s | 2.29x | 7.33 MB | 931,273 |
| 10 × 100 KB (files) | 2,533 KB | 6.37 / 5.50 ms | 150 / 173 MB/s | 2.59x | 8.37 MB | 6,059 |
| 150 × 6.7 KB (files) | 9,619 KB | 83.12 / 60.60 ms | 11.5 / 15.7 MB/s | 9.85x | 8.41 MB | 85,331 |
| 1 × 1.0 MB (verification on) | 3,199 KB | 3.63 / 3.02 ms | 263 / 316 MB/s | 3.28x | 11.93 / 9.93 MB | 931,273 |
| 150 × 6.7 KB (verification on) | 10,846 KB | 80.90 / 85.77 ms | 11.8 / 11.1 MB/s | 11.11x | 12.11 / 10.11 MB | 85,331 |
| 1 × 1.0 MB (caller stream) | 3,211 KB | 2.85 / 3.07 ms | 334 / 310 MB/s | 3.29x | 14.02 / 15.02 MB | 931,273 |
| 150 × 6.7 KB (caller streams) | 10,478 KB | 46.44 / 43.47 ms | 20.5 / 21.9 MB/s | 10.73x | 14.14 / 15.14 MB | 85,331 |
| 150 × 1.0 MB (corpus document) | 324,342 KB | 269.33 / 387.49 ms | 531 / 369 MB/s | 2.21x | 12.12 / 13.12 MB | 139,645,394 |
| 150 × 1.0 MB (Modern, padded) | 300,676 KB | 174.34 / 204.55 ms | 821 / 699 MB/s | 2.05x | 13.62 / 13.12 MB | 85,331 |
| 75 × 1.0 MB (Modern, padded) | 150,365 KB | 71.91 / 105.89 ms | 995 / 676 MB/s | 2.05x | 13.60 / 13.10 MB | 42,806 |
| 150 × 0.5 MB (Modern, padded) | 154,159 KB | 86.24 / 139.93 ms | 829 / 511 MB/s | 2.10x | 13.62 / 13.12 MB | 85,331 |
| 300 × 0.5 MB (Modern, padded) | 308,270 KB | 153.82 / 287.57 ms | 930 / 497 MB/s | 2.10x | 13.66 / 13.16 MB | 170,792 |
Allocations are the same under both GC modes to within 0.01 %, so they are quoted once.
Reading it:
- The bytes dominate, and copying them costs about one extra copy each way. 2.10–2.29 bytes are allocated per input byte on the large rows: the input is read into a buffer, copied into the output buffer, and the pooled buffers behind both are reused. That 2.2× is the single biggest allocation item left on the merge path.
- A source is cheap. At constant total bytes, one merge of 1 MB costs 2,236 KB and 2.63 ms; the same megabyte spread over 150 sources costs 9,619 KB and 83.12 ms — that is 49 KB and 0.54 ms per extra source (0.39 ms under server GC), of which most is the per-page work of a 6.7 KB document and not bookkeeping. Ten sources of 100 KB sit between the two at 0.41 / 0.34 ms per extra source.
- A caller-supplied stream costs one document. 3,211 KB against 2,236 KB for the same document from a file: the capture is the document's bytes and nothing more, which is what §7.2's fourth bullet asks for on the read side. The 300 MB caller-stream gap of §7.2 still stands for large streams: a captured source is a memory-backed source until the merge is done.
- Output verification costs a copy of the merge and no more: allocations go from 2,236 KB to 3,199 KB (1.43x) on the one-megabyte row and from 9,619 KB to 10,846 KB (1.13x) on the 150-source row. It adds wall time where there are many small sources to walk (60.6 ms to 85.8 ms on the server leg) and costs almost nothing on one large document.
- Server GC is not slower on allocations (identical to the byte for every row) and is slower on wall time, most on the rows with many sources: 300 x 0.5 MB takes 154 ms on the workstation and 288 ms on the server, because the same garbage is collected on more, smaller heaps.
| Budget (prd.md §7.1) | Measured (workstation / server) | Verdict |
|---|---|---|
| 150 x 1 MB in ≤ 10 s, ≥ 15 MB/s | 0.27 s / 0.39 s, 531 / 369 MB/s | met, 26–37x under the time budget |
| a source costs ≤ 1 ms fixed, for sources up to 1 MB | 0.54 / 0.39 ms per extra source over 150; 0.41 / 0.34 ms over ten | met |
| doubling the sources at constant total bytes adds < 20 % wall time | 1.20x and 0.88x (WS) / 1.32x and 1.41x (server) | not met reliably — see below |
| output is written as it is produced | not measured | open; §7.1 itself flags this line as possibly unreachable, and the writer needs the object graph before it can write |
The rule as written asks that doubling the number of sources at constant total bytes adds less than
20 % wall time. It could not be measured with the harness as it first stood, because the two rows it
compared used different documents (the corpus at 150 × 1 MB against a padded Modern.pdf at
300 × 0.5 MB): that measures the corpus, not the source count. So the harness now pads Modern.pdf to
1 MB and to 0.5 MB and merges a constant 75 MB and a constant 150 MB each way. Ratios measured over
repeated runs on both GC modes:
| Pair (constant bytes) | Ratios measured | Budget |
|---|---|---|
| 150 × 0.5 MB vs 75 × 1.0 MB (75 MB) | 1.20, 1.16, 1.26 (WS) — 1.32, 1.28, 1.36, 1.52 (server) | 1.20x |
| 300 × 0.5 MB vs 150 × 1.0 MB (150 MB) | 0.88, 1.16, 1.31 (WS) — 1.21, 1.25, 1.31, 1.41 (server) | 1.20x |
The median is about 1.25x, so the budget is a coin toss, and the run-to-run spread (1.16 to 1.52) is wider than the 20 % the budget allows. Allocations are not the reason: they are flat in the source count — 2.05x input for 75 × 1 MB against 2.10x for 150 × 0.5 MB, and 2.05x against 2.10x again at 150 MB, a 2.5 % difference for twice the sources. The reason is the per-source floor: at 0.4–0.5 ms per source, the extra 75 sources of a doubling pair cost 30–48 ms on a base of 63–106 ms. A 20 % budget at these scales needs a floor under about 0.2 ms, half of today's.
Two honest options, and one of them should be chosen deliberately:
- Restate the gate in the shape the engine actually has: "per-source overhead ≤ 1 ms, and allocations ≤ 2.5× the input bytes". Both pass today with room, both are measurable, and neither is at the mercy of run-to-run noise. The 1.20x form is a proxy for allocation growth that the allocation column measures directly.
- Keep the 1.20x form and halve the per-source floor. That is separate work (less per-document planning state, fewer intermediate objects per page) whose payoff is a cheaper many-source merge — and the harness's timing noise has to be dealt with first: with a ±20 % spread against a 20 % budget, the gate cannot resolve a regression smaller than the budget itself. Reporting the median of the per-iteration medians, more iterations and a quiet, pinned machine is the minimum for this to be a gate rather than a coin toss.
§7.2 asks that the peak managed working set stay within 4 × the largest Source Document + 64 MB,
independent of the number of Source Documents. Measured with --single, one scenario per process, two
repetitions each, sampler at 1 ms over the merge itself:
| Scenario | Input | Allocated | Wall (WS / server) | Peak managed heap | Held after GC | Process peak WS |
|---|---|---|---|---|---|---|
| 150 × 1.0 MB | 150 MB | 316.8 / 317–319 MB | 378–387 / 429–431 ms | 159.4–161.8 / 160.5–162.6 MB (1.11–1.14x input) | 9.4 / 9.4–11.4 MB | 201–203 / 211–212 MB |
| 300 × 0.5 MB | 150 MB | 301.1 / 301.0 MB | 215–259 / 311–335 ms | 147.9–152.7 / 150.0–153.7 MB (1.03–1.07x) | 7.1–6.1 / 6.1 MB | 190–192 / 192–194 MB |
| 1 × 1.0 MB | 1 MB | 4.2 MB | 4 ms | 9.4 / 5.3–8.4 MB | 7.3 MB | 43.6–44.8 / 100–107 MB |
- The one-document row meets the budget easily — 9 MB peak against its 68 MB envelope — and every row holds 6–11 MB after a full collection, which is the retention the budget was written to protect, and that number is flat in the source count: 9.4 MB for 150 sources, 7.3 MB for one.
- The two 150 MB merges do not meet the peak number. Their envelope is 66–68 MB; the managed heap peaks at 148–163 MB, 2.2–2.4x over, on both GC modes and in both repetitions. The process peak working set (190–212 MB) is not a fairer number: it is that peak plus 30–45 MB of runtime, or plus about 100 MB under server GC.
- The peak is the merge's own garbage, not data it keeps. 317 MB is allocated during a 150 MB merge (2.05–2.21 bytes per input byte: read buffer, copy into the output buffer, pooled capture), and the sampler catches roughly one input's worth of it uncollected at a time. Peak therefore tracks the total bytes of a merge: 150 MB of input peaks the same whether it arrives as 150 sources of 1 MB or 300 of 0.5 MB — precisely the "independent of the number of Source Documents" property §7.2 asks for — and it cannot be bounded by 4 × the largest source while one merge holds an input-proportional working set.
So §7.2 is half met, and the half that is not met is the half its formula cannot express. State the budget in the two parts the data separates cleanly:
- Retained: after a merge the heap holds ≤ 4 × the largest source + 64 MB, independent of source count. Measured 6–11 MB, about 1× the largest source: passes with the whole envelope spare.
- Transient: peak managed heap ≤ 1.25 × the total input bytes of the merge. Measured 1.03–1.14x: passes, and 1.25 leaves room for a worse GC mode or a busier machine.
If 4 × largest + 64 MB is a hard requirement rather than a proxy for retention, the change it implies is the same one §7.1's fourth line ("output is written as it is produced") is asking for: stop holding an input-proportional working set, write the output as it goes and drop each source's decoded bytes once copied. The two budgets should be decided together.
Four slices landed, all inside the library, none of them a behaviour change:
-
Serializer and writer — one output buffer, no per-object array or string materialisation.
-
Lexer —
ReadName,DecodeNameandReadHexStringscan once, andReadLiteralStringbuilds onPooledByteBufferinstead of aList<byte>.List<byte>andbytes.Addno longer occur insrc. -
Capture path —
PdfCaptureStreamwrites into aPooledByteBufferand the merge engine disposes it withusing, so a captured source is one pooled buffer rather than a growing list plus a final copy. -
Stream sources —
PdfStreamSourcehands every opener the sameLock-guarded capture, so a source opened twice is read once. A capture that the starter abandoned, because its token was cancelled, yieldsnullinstead of a half-read document, and a non-seekable stream whose bytes were already consumed is rejected rather than truncated.Restart()seeks back to the start position, or throws when the stream cannot be rewound.
That the merge output is unchanged is what the output column checks: it counts the bytes the merge wrote
and is identical for every scenario before and after the slices, 139,645,394 B and 170,792 B on the two
150 MB rows included. Two runs that write the same bytes ran the same merge.
Also green on both TFMs: 52 of 52 tests — 47 from before the work plus 5 PdfStreamSourceTests for
the capture and cancellation semantics above — with no warnings from the library or the harness.
What is left in the allocation column:
- The 2.05–2.21x input of the large rows. Read buffer in, copy into the output buffer, pooled capture: this is where a 150 MB merge's 317 MB comes from, and it is the only remaining item of that size.
PdfFiltersandPdfFilters.Inflationstill materialise decoded stream bytes, andPooledByteBufferexposesToExactArray()for a caller that needs an exact array. Both are deliberate, and neither is on the copy loop.
- The async legs of A7: the thread-pool delta, the deadlock detection under a single-threaded
SynchronizationContext, and cancellation latency are not in the harness. The capture-cancellation behaviour is covered byPdfStreamSourceTestsrather than by a measurement. - A large caller-supplied stream: §7.2's fourth bullet and its open question Q3 both stand. A captured
source is a memory-backed source, so a 300 MB
Streamis 300 MB of heap — as §7.2 says it will be. - The envelope for one enormous source, where the total input and the largest source coincide: not measured, and by §3 it is the one shape that should pass comfortably.
- A busy machine: every number above was taken on an otherwise idle workstation, which is why the timings are quoted as ranges rather than as single values.
A7 asks for the numbers to be recorded per release, with continuous integration failing on a regression beyond an agreed tolerance. There is no release-notes file in this repository yet, so this document is the record for now: run the harness, and when a row moves further than the tolerance, either the regression is real or a budget needs a deliberate decision. Wiring the harness into CI — and choosing the tolerance — is the open half of A7.
Recorded: 2026-10-21, .NET 11.0.0-rc.2.26504.105, x64, 12 logical cores, workstation GC, Release:
dotnet run -c Release --project .\tools\SharpCorePDF.Measure -- --contentThe three documents are generated by the harness (ContentFixtures) before the measured window and handed
over as a PdfByteArraySource, so no file system is in a row. A row is three measured reads after one
warm-up; allocations are GC.GetTotalAllocatedBytes(precise: true) around the read alone, wall time is the
median, and live after GC is read after a full collection. The page document draws one image on every
page, so each page detail carries an image record as well as its content; the text document draws a heading
and six lines of body text on every page, eighty-two words each, so a text row pays for the glyphs, the
words and the blocks.
| Row | Allocated | Wall | Throughput | Live after GC |
|---|---|---|---|---|
| 200 pages, every page enumerated | 2,174 KB | 7.04 ms | 28,395 pages/s | 0.81 MB |
| 200 pages, abandoned after 10 | 1,100 KB | 3.55 ms | 2,815 pages/s | 0.81 MB |
| 200 pages of text, every block read | 26,854 KB | 98.59 ms | 166,339 words/s | 0.96 MB |
| 200 pages of text, ten pages read | 2,302 KB | 8.07 ms | 101,620 words/s | 0.96 MB |
| 1 MB payload through the copy route | 3,087 KB | 0.53 ms | 1,888 MB/s | 7.98 MB |
| 10 MB payload through the copy route | 10,271 KB | 3.45 ms | 2,902 MB/s | 48.98 MB |
| 50 MB payload through the copy route | 51,301 KB | 16.52 ms | 3,027 MB/s | 216.98 MB |
What the rows say, and what they do not:
- Abandonment is what the 200-page row is about. Reading ten pages costs about half of reading two hundred, because opening the document — the cross-reference table, the page tree, the first objects — is the fixed part. The marginal cost of the other 190 pages is about 5.6 KB and 0.02 ms per page, which is what an index pays for each page it consumes.
- Text costs about half a millisecond a page. Reading every block of 200 pages takes 98.59 ms and 26,854 KB, so a page of eighty-two words costs about 0.48 ms and 129 KB, or about 5.8 µs and 1.6 KB a word once the fixed cost of opening the document is taken out. The ten-page row shows the same shape as the page-detail rows: 8.07 ms against 98.59 ms for the other 190 pages, so a caller that extracts the text of a page it already holds pays the page, not the document.
- The copy route is a bounded buffer, not a copy of the payload. Copying 1 MB allocates 3 MB, 10 MB allocates 10 MB and 50 MB allocates 51 MB: the allocation is the document the reader holds (the source is read into memory once, as the plan's risks section states) plus the 64 KiB buffer, not a second copy of the payload.
- The retained heap after a large row belongs to the harness as much as to the reader. The byte-array source holds the whole document, and a full collection after a 50 MB row leaves 217 MB: large-object-heap segments are not returned to the operating system just because the objects that filled them are gone. The statement that holds is that a read of a 50 MB document needs its 50 MB, not that it needs 217 MB.
- No merge budget is restated from these numbers. §1 to §6 keep the §7.1 and §7.2 merge budgets; the content rows exist so that a change to the reader can be called a regression, and so that the manual's claims about one page at a time, about the copy route and about what a block of text costs have a measurement behind them.
Not covered: a 200-page document whose pages paint many distinct images, a payload that goes through a
decoder (FlateDecode), a text read of a composite font (/ToUnicode rather than glyph names), a document
whose fonts cannot be decoded at all, cancellation latency, and the server-GC legs — the last is the same
command under DOTNET_gcServer=1.
Recorded: 2026-10-21, .NET 11.0.0-rc.2.26504.105, x64, 12 logical cores, workstation GC, Release:
dotnet run -c Release --project .\tools\SharpCorePDF.Measure -- --authorEvery row builds the whole document and keeps its bytes, after one warm-up; allocations are
GC.GetTotalAllocatedBytes(precise: true) around the build alone, wall time is the median of five, and
live after GC is read after a full collection. The typeface is parsed before the measured window: a
PdfTypeface is meant to be created once and shared, so the parse is not part of writing a page. Both the
creation date and the document identifier are fixed, so every row writes the same bytes each round.
| Row | Allocated | Wall | Output | Live after GC |
|---|---|---|---|---|
| 1 page, RGB logo, 6 text lines | 669 KB | 0.80 ms | 74,413 B | 0.42 MB |
| 1 page, RGBA logo, 6 text lines | 416 KB | 1.56 ms | 7,679 B | 0.48 MB |
| 200 pages, 2 text lines each | 1,827 KB | 7.17 ms | 135,207 B | 0.61 MB |
What the rows say:
- An opaque PNG costs its own bytes and no decoding. The RGB row embeds
Logo-256.png(67,587 bytes) as itsIDATstream with a PNG predictor, so the output carries the picture unchanged and the build spends 669 KB to write it. - An RGBA logo is decoded once and split. The 152 × 87 image is inflated, unfiltered, split into RGB and alpha, and compressed as two streams, which is the 1.56 ms of that row; the output is 7.7 KB including the embedded subset.
- A page costs about 9 KB of allocation and 0.04 ms. The 200-page row allocates 1,827 KB and takes 7.17 ms, so a page of two lines is about 9 KB and 0.036 ms once the one-off font subset is written. The output is 676 bytes a page, because a content stream of two short lines is small and is not compressed.
- The whole document is in memory.
ToArrayandBuildreturn the bytes, so the peak is the document plus the buffers of the writer. For a very large authored document,SaveAsync(Stream)writes to a caller's stream, but the builder still holds its pages until it is released.
Not covered: fonts with tens of thousands of glyphs, pages with hundreds of operations, a builder shared by concurrent callers (it is not thread safe), and a 1000-page document — the 200-page row is the shape, not a limit.
The question's scenario was re-run on the release branch to close 002-page-authoring:
dotnet run -c Release --project .\examples\SharpCorePDF.Examples -- author-page reports ok (222 ms)
for the build, the merge and the read-back, and writes an 8,835-byte cover.pdf and a three-page
cover-and-summary.pdf. These are single-run wall times from the example, not the medians of the table
above, so they are recorded against SC-001's 400 ms budget rather than as a new baseline. validate-pdf.py
(pikepdf 10.17.0, which runs qpdf) reports no problems for both files; pypdf 6.19.0 with strict=True
extracts the accented line unchanged and shows the logo as a 152 × 87 image with an /SMask; PyMuPDF at
72 dpi gives the logo colour at (100, 45) and white at (50, 45). The authoring tests are 33 on each
target framework, and their build allocates and writes within the budgets of §9.
Since 2026-10-23 those three checks are one command, so the gate re-run costs a paste rather than a session:
# Runs the example, then C1 (qpdf), C2 (pypdf) and C3 (PyMuPDF); prints a markdown transcript.
uv run --with pikepdf --with pypdf --with pymupdf python docs/tools/verify-cover.py
# The same run, with the transcript written as UTF-8 as well. This is the form a pull request uses:
# a PowerShell pipe re-decodes the output and mangles × and —.
uv run --with pikepdf --with pypdf --with pymupdf python docs/tools/verify-cover.py --output artifacts\c5-transcript.md
# The same checks on files that already exist — what a release-commit re-run needs.
uv run --with pikepdf --with pypdf --with pymupdf python docs/tools/verify-cover.py <cover.pdf> <cover-and-summary.pdf>docs/tools/verify-cover.py asserts every value in the paragraph above and exits non-zero when one of
them moves, so the evidence is a test rather than a memory. Its rehearsal reproduced them exactly
(1 and 3 pages, 14 and 19 objects, 6 and 8 streams decoded with none failing, 8,835 bytes, the same two
pixels). The example's own ok (…) line reads 202–392 ms across five runs on the same tree — dotnet run
startup is inside that number — which is why SC-001's timing leg is the harness of §9 and T101, not this
line. --output <path> writes the transcript as UTF-8, so the copy attached to the request is the copy
the tool printed.
Recorded: 2026-10-21, .NET 11.0.0-rc.2.26504.105, x64, 12 logical cores, workstation GC, Release:
dotnet run -c Release --project .\tools\SharpCorePDF.Measure -- --creator
dotnet run -c Release --project .\tools\SharpCorePDF.Measure -- --editorThese rows are recorded before the 1.1 creator and editor implementation, so the first passing phase
has a number to be judged against instead of a memory. The creator builds ten A4 pages with twenty
positioned lines each and one 152 × 87 RGBA PNG; the typeface and the image bytes are prepared outside the
measured window. The editor reads a generated 100-page document and applies 200 page operations: rotate
every page (Clockwise90), duplicate page 1 fifty times, move page 150 to the front twenty-five times, and
delete page 125 twenty-five times. The result is written to a counting stream.
Both use one warm-up, five measured rounds, a full collection before each round, allocations from
GC.GetTotalAllocatedBytes(precise: true), and the median of the five rounds. The creation/modification
date and the document identifier are fixed, so the bytes are reproducible.
| Row | Allocated | Wall | Output | Live after GC |
|---|---|---|---|---|
| 10 pages, 200 lines, RGBA logo | 3,484 KB | 14.69 ms | 74,094 B | 0.48 MB |
| 10 pages, 200 lines, no image | 829 KB | 4.54 ms | 72,400 B | 0.48 MB |
The RGBA row is the acceptance shape: the extra 2,655 KB and 10.15 ms are the decode, the RGB/alpha split and the two embedded streams of a transparent image. The no-image row is the control, and it shows that the page and text work is about 0.45 ms a page when the image path is not taken.
| Row | Allocated | Wall | Output | Live after GC |
|---|---|---|---|---|
| 200 operations, 100 to 125 pages | 1,733 KB | 3.64 ms | 80,004 B | 0.76 MB |
The editor row is cheap because the source pages are simple and the writer copies the object graph once: 200 operations cost about 8.7 KB and 0.018 ms each on this source. It is a page-edit baseline: the 200 operations are 100 rotate, 50 duplicate, 25 move and 25 delete. Annotation and form edits are added to the same scenario in T058, and the full SC-003 scenario is re-timed there.
| Gate | Baseline | Budget | Verdict |
|---|---|---|---|
| SC-002 creator, ten pages, build/merge/read back | build 14.69 ms (no image 4.54 ms) | < 2 s | provisional pass, >100× headroom; the full merge/read-back leg is timed by T038 |
| SC-003 editor, 100 pages, 200 edits | 3.64 ms to a counting sink | < 3 s | provisional pass, ~800× headroom; qpdf --check and the annotation/form legs are T058 |
| §8 authoring allocation, RGB / RGBA / 200 pages | 669 / 416 / 1,827 KB recorded | no regression > 10% | met: 672 / 419 / 1,830 KB (+0.4%, +0.7%, +0.2%) |
| §8 authoring wall time, RGB / RGBA / 200 pages | 0.80 / 1.56 / 7.17 ms recorded | no regression > 10% | met: 0.68 / 1.45 / 6.67 ms (faster, within run-to-run noise) |
| §1 merge, 150 × 1.0 MB | 0.27 s / 531 MB/s recorded | 10 s / 15 MB/s | met: 0.265 s / 540 MB/s on re-read |
| §1 fixed cost per source | 0.54 ms recorded | ≤ 1 ms | met: 0.46 ms over 150 sources, 0.30 ms over ten |
| §1 scaling at constant bytes | 1.20x–1.52x recorded | < 1.20x | not met: 0.85x at 75 MB and 1.44x at 150 MB; the open item in §2 |
The merge, authoring and reader code was not changed by Phase 0; the re-read is here to prove the harness and the machine still produce the 1.0 shape. The allocation columns are the regression gate, because wall time on a 12-core workstation moves by more than 10% between runs. The scaling row remains the one budget §2 already records as not reliably met; Phase 0 neither fixes nor hides it.
Not covered by this section: the merge and read-back legs of the creator scenario, annotation and form
edits, qpdf --check on the editor output, server GC, and a 1000-page source. Those are the T038/T058
acceptance runs, not omissions from the baseline.
Recorded: 2026-10-23, .NET 11.0.0-rc.2.26504.105, x64, 12 logical cores, workstation GC, Release.
dotnet run -c Release --project .\tools\SharpCorePDF.Measure # the twelve merge rows
dotnet run -c Release --project .\tools\SharpCorePDF.Measure -- --content # page details, text, copy route
dotnet run -c Release --project .\tools\SharpCorePDF.Measure -- --author # one page, RGB/RGBA, 200 pages
dotnet run -c Release --project .\tools\SharpCorePDF.Measure -- --creator # the SC-002 creator shape
dotnet run -c Release --project .\tools\SharpCorePDF.Measure -- --editor # the SC-003 editor shapeThis is the release-gate re-run of everything above: the same harness, the same machine, the same rows,
against the tree 1.1.0 is cut from. Allocated is the gate — a row that moves by more than 10% is a
regression — because wall time on this machine moves by more than that between runs on its own. Wall time
is quoted as it came out.
| Scenario | Allocated | Recorded | Change | Wall | Output bytes |
|---|---|---|---|---|---|
| 1 × 1.0 MB (file) | 2,238 KB | 2,236 KB | +0.1% | 1.89 ms | 931,273 |
| 10 × 100 KB (files) | 2,546 KB | 2,533 KB | +0.5% | 4.36 ms | 6,059 |
| 150 × 6.7 KB (files) | 9,811 KB | 9,619 KB | +2.0% | 55.64 ms | 85,331 |
| 1 × 1.0 MB (verification on) | 3,202 KB | 3,199 KB | +0.1% | 2.29 ms | 931,273 |
| 150 × 6.7 KB (verification on) | 11,043 KB | 10,846 KB | +1.8% | 74.59 ms | 85,331 |
| 1 × 1.0 MB (caller stream) | 3,213 KB | 3,211 KB | +0.1% | 2.09 ms | 931,273 |
| 150 × 6.7 KB (caller streams) | 10,669 KB | 10,478 KB | +1.8% | 42.99 ms | 85,331 |
| 150 × 1.0 MB (corpus document) | 324,731 KB | 324,342 KB | +0.1% | 262.80 ms | 139,645,394 |
| 300 × 0.5 MB (Modern, padded) | 308,661 KB | 308,270 KB | +0.1% | 168.43 ms | 170,792 |
| 75 × 1.0 MB (Modern, padded) | 150,463 KB | 150,365 KB | +0.1% | 81.67 ms | 42,806 |
| 150 × 0.5 MB (Modern, padded) | 154,357 KB | 154,159 KB | +0.1% | 116.92 ms | 85,331 |
| 150 × 1.0 MB (Modern, padded) | 300,864 KB | 300,676 KB | +0.1% | 187.64 ms | 85,331 |
Every output byte count is identical to the recorded one, including the 139,645,394 B corpus row: the merge writes the document it wrote before the feature. The harness's own gate line reads 0.26 s / 544.3 MB/s for 150 × 1.0 MB (budget 10 s / 15 MB/s), 0.36 ms fixed cost per extra source over 150 and 0.27 ms over ten (budget 1 ms), and the scaling ratio 1.43x at 75 MB and 0.90x at 150 MB — the coin flip §2 already records as not reliably met. Peak working set over the run was 207.5 MB and 13.37 MB was retained after it.
| Row | Allocated | Recorded | Change | Wall | Live after GC |
|---|---|---|---|---|---|
| 200 pages, every page enumerated | 2,174 KB | 2,174 KB | 0.0% | 7.79 ms | 0.81 MB |
| 200 pages, abandoned after 10 | 1,100 KB | 1,100 KB | 0.0% | 5.29 ms | 0.81 MB |
| 200 pages of text, every block read | 26,982 KB | 26,854 KB | +0.5% | 119.45 ms | 0.96 MB |
| 200 pages of text, ten pages read | 2,308 KB | 2,302 KB | +0.3% | 9.41 ms | 0.96 MB |
| 1 MB payload through the copy route | 3,087 KB | 3,087 KB | 0.0% | 0.72 ms | 7.98 MB |
| 10 MB payload through the copy route | 10,271 KB | 10,271 KB | 0.0% | 4.25 ms | 48.98 MB |
| 50 MB payload through the copy route | 51,301 KB | 51,301 KB | 0.0% | 19.84 ms | 216.98 MB |
The 50 MB row needs a note because it lied once: the first run of the day allocated 116,837 KB and retained 280.98 MB for the same payload, and every run after it reproduced 51,301 KB and 216.98 MB exactly. This route is a pooled 64 KiB buffer plus the document the caller hands over, so the figure is either one document or it is not; a single 116 MB reading on a machine that had just written the 300 MB merge rows is a collection artefact, not a second copy, and the row is recorded at its reproducible value.
| Row | Allocated | Recorded | Change | Wall | Output |
|---|---|---|---|---|---|
| 1 page, RGB logo, 6 text lines | 671 KB | 672 KB | −0.1% | 0.71 ms | 74,662 B |
| 1 page, RGBA logo, 6 text lines | 418 KB | 419 KB | −0.2% | 1.44 ms | 7,928 B |
| 200 pages, 2 text lines each | 1,771 KB | 1,830 KB | −3.2% | 7.39 ms | 144,125 B |
| Row | Allocated | Recorded | Change | Wall | Output |
|---|---|---|---|---|---|
| Creator, 10 pages, 200 lines, RGBA logo | 3,434 KB | 3,484 KB | −1.4% | 13.32 ms | 78,839 B |
| Creator, 10 pages, 200 lines, no image | 781 KB | 829 KB | −5.8% | 4.24 ms | 77,136 B |
| Editor, 200 operations, 100 to 125 pages | 1,796 KB | 1,733 KB | +3.6% | 3.92–4.53 ms | 80,004 B |
The editor's output is byte-identical to the recorded run (80,004 B) and its allocation moved 63 KB; the wall time reads 3.92, 4.06 and 4.53 ms over three runs on an otherwise idle machine, against 3.64 ms recorded. Both creator rows allocate less than the baseline and write about 4.7 KB more: the writer's output changed between the recording and the release (the same plan still writes the same bytes), a 6% size change on a document whose budget is time, not size.
| Gate | Requirement | Measured | Verdict |
|---|---|---|---|
| §1 merge allocations, every row | no regression > 10% | worst row +2.0% (150 × 6.7 KB) | met |
| §7 read allocations, every row | no regression > 10% | worst row +0.5% | met |
| §8 authoring allocations | no regression > 10% | −0.1% / −0.2% / −3.2% | met |
| §9 creator allocations | no regression > 10% | −1.4% / −5.8% | met |
| §9 editor allocations | no regression > 10% | +3.6% | met |
| §1 merge wall time, 150 × 1.0 MB | ≤ 10 s, ≥ 15 MB/s | 0.26 s, 544 MB/s | met |
| §1 fixed cost per source | ≤ 1 ms | 0.36 ms | met |
| §1 scaling at constant bytes | < 1.20x | 1.43x at 75 MB, 0.90x at 150 MB | not met, as recorded in §2 |
| SC-002 creator build | < 2 s | 13.32 ms | met, >150× headroom |
| SC-003 editor | < 3 s | 3.92–4.53 ms | met, >600× headroom |
No row regressed by more than 10%, so T101 blocks nothing. The one budget not met is the scaling ratio §2 already records as a coin flip on this machine; it is neither new nor hidden by this run.
Recorded: 2026-10-23, on the 1.2 working tree, .NET 11.0.0-rc.2.26504.105, x64. This section records the
external readings behind row 3 of parity.md, not an allocation baseline: the layout has no
row in tools/SharpCorePDF.Measure yet, so the cost of wrapping and measuring is added there with T037 and
re-read by T101.
# The metrics of the embedded face, read from the program the tests embed.
uv run --with fonttools python -c "from fontTools.ttLib import TTFont; ..."
# What a reader sees on a page drawn from a laid-out block.
uv run --with pymupdf python -c "import pymupdf; ..."
# The document itself: qpdf check, geometry, stream decode.
uv run --with pikepdf --with pypdf python docs/tools/validate-pdf.py <output.pdf>Standard fonts. StandardFontMetrics is generated by docs/tools/make_standard_font_metrics.py, which
reads the AFM with pdfminer and refuses to write a table reportlab disagrees with. Helvetica comes out at an
ascent of 718, a descent of −207, a cap height of 718 and an x-height of 523 per 1000 em, with advances of
722 for H, 556 for e, 222 for l, 556 for o, 278 for the space and 556 for a — which is what
Reports_the_metrics_of_a_standard_font and Measures_a_standard_font_line_with_the_afm_widths assert, and
why 12 pt gives an 11.1 pt line height before any leading is supplied.
The embedded face. fontTools 4.67.0 reads unitsPerEm 2048, hhea.ascender 1901 and
hhea.descender −483 out of the DejaVu program the suite embeds, and an advance of 8001 units for
Veuillez at 12 pt, which is 46.88 pt; Measures_an_embedded_face_with_its_own_metrics asserts those
numbers rather than a re-implementation of them.
Placement. A probe page — A4, Helvetica 12 pt, leading 15, a 60-point box, centred, drawn at
(72, 100) — read back with PyMuPDF 2.0 gives five spans whose y0 is 95.72, 110.72, 125.72, 140.72 and
155.72: a step of exactly the 15-point leading, each span centred on x = 102, the middle of the box. The
first y0 is the first baseline (100 + 8.616) less the 12.9-point ascender PyMuPDF puts above it, not a
second opinion about where the line goes. The same page before the fix carried its first text matrix at
y = 804.37 in user space, which is 37.5 points from the top of the page: DrawTextLayout was taking the
height of the box — 71.1 points for five lines — off every baseline, so the block sat a box height too high
and the reader reports that as y0 ≈ 24.6. Draws_the_layout_from_the_top_left_of_its_box is the test that
fails on it now, by comparing the drawn block against the same baselines drawn one at a time with DrawText.
The validate-pdf.py run (pikepdf 10.17.0, qpdf 12.4.2) reports qpdf check: no problems, one decoded
stream and the expected [0, 0, 595.28, 841.89] media box for the fixed page.
Recorded: 2026-10-23, Windows x64, .NET SDK 11.0.100-rc.2.26504.105 running the net11.0 leg of
examples/SharpCorePDF.Examples. This section records the external readings behind row 2 of
parity.md: the operator tests of T022 prove what the content stream contains, and this run
proves that a viewer paints what the stream meant.
# The artifact: one authored page of shapes.
dotnet run --project .\examples\SharpCorePDF.Examples\SharpCorePDF.Examples.csproj -- draw-shapes
# Syntax, geometry and stream decode, with an implementation that shares no code with the writer.
python docs/tools/validate-pdf.py $env:TEMP\SharpCorePDF.Examples\output\shapes.pdf
# The render: sample the pixels a viewer produced at the points the drawing names.
python -c "import pymupdf; ..."validate-pdf.py reports qpdf check: no problems, PDF 1.7, 1 page, 6 objects, a page box of
[0, 0, 595.28, 841.89] with rotation 0, a 1937-byte content stream decoded and 0 streams failed. The
validate-pdf.py in this repository takes paths and an optional --password; the --check-syntax,
--text, --render and --images flags named in T038 do not exist, and passing one raises
pikepdf.JobUsageError: unrecognized argument. The render and text halves are therefore separate probes.
PyMuPDF 1.28.5 renders the page at 72 dpi, one pixel per point, and the sampled pixels read back as the drawing says they should:
| Probe | Where | Expected | Rendered |
|---|---|---|---|
First dash of a [10, 4] pattern, phase 5 |
(62, 120) | black | (0, 0, 0) |
| The gap between two round caps | (67, 120) | not black; the caps reach to 66.5 and from 67.5 | (179, 179, 179) |
| Even-odd path, outer ring | (80, 175) | #1F3A5F |
(31, 57, 95) |
| Even-odd path, inner subpath | (160, 210) | white — a hole, not a fill | (255, 255, 255) |
| Stripe crossing the clip | (340, 200) | navy at 0.55 over the pale panel | (122, 138, 161) |
| Same stripe, outside the clip | (315, 200) | white — the clip cut it | (255, 255, 255) |
| Between two stripes | (340, 204) | the panel colour #ECEFF4 |
(235, 238, 243) |
| Inside the translated, rotated band | (144, 481) | navy at 0.12 over white | (228, 231, 236) |
| Below the band | (144, 580) | white | (255, 255, 255) |
The text of the rotated group comes back as one line whose direction is (0.97, -0.26), a fifteen degree
turn, placed at the band rather than at the un-rotated coordinates.
What the render corrected. The first version of the example composed the group as
PdfMatrix.Translate(120, 470).Multiply(PdfMatrix.Rotate(-15)), and the chapter described that expression as
"moves it, then turns it". a.Multiply(b) applies a first, so the composite translates the box to
(120, 470) and then rotates that point about the page origin, landing the group near (238, 423): the probe
found the band's text at y 403–463 in the un-rotated position and white where the band should have been.
Composing it Rotate(-15).Multiply(Translate(120, 470)) — rotate in local coordinates, then place — is what
puts the band at (144, 481). The chapter's prose and its code sample were corrected in the same pass, which is
the reason a render belongs in this record and not only a stream assertion.
Recorded: 2026-10-23, Windows x64, .NET SDK 11.0.100-rc.2.26504.105 running the net11.0 leg of
examples/SharpCorePDF.Examples. This section records the external readings behind rows 4 and 7 of
parity.md and behind the creator-parity scenario of T036: one document that a single
builder writes — a JPEG passed through, a PNG that states its own colour profile, an outline, an XMP
packet, and a page copied out of another file.
# The artifact and every reading below in one command: it runs the example, then checks the result with
# qpdf, pypdf, PyMuPDF and pikepdf and prints the table as a markdown transcript.
python docs/tools/verify-creator.py
# The same checks on a file that already exists, which is what a re-run needs.
python docs/tools/verify-creator.py $env:TEMP\SharpCorePDF.Examples\output\creator-parity.pdf
# Syntax, geometry, streams and the outline count, printed in full.
python docs/tools/validate-pdf.py $env:TEMP\SharpCorePDF.Examples\output\creator-parity.pdfvalidate-pdf.py reports qpdf check: no problems, PDF 1.7, 3 pages, 27 objects, a catalog holding
/Metadata, /Outlines and /Pages, five bookmarks, page boxes of [0, 0, 595.28, 841.89],
[0, 0, 419.53, 595.28] and [0, 0, 595, 842] — the A4 cover, the A5 summary and the imported page — all
with rotation 0, and 10 streams decoded with 0 failed.
PyMuPDF 2.0 reads the document back as the builder described it: a five-entry table of contents
(Couverture → page 1, Sommaire → page 2 with Totaux nested under it, Décompte du fournisseur →
page 3, SharpCorePDF → the external URL, reported as destination -1), 605 bytes of XMP packet, and the
title, author, creator, producer and both dates in the document information. The images are the ones the
two formats call for:
| Page | Image | Reported by PyMuPDF |
|---|---|---|
| 1 | the JPEG | 320 × 320, 8 bits, DeviceRGB, DCTDecode — the file's own bytes |
| 2 | the profiled PNG | 152 × 87, 8 bits, ICCBased, /ColorSpace [/ICCBased 14 0 R], FlateDecode |
verify-creator.py resolves the profile through pikepdf 10.17.0 and reports /N 3, /Alternate /DeviceRGB, a 3144-byte stream whose digest matches the profile inside Assets/Logo-icc.png byte for
byte. The same run reports pypdf 6.19.0 extracting Décomptes SPRL from page 1 and Quarterly summary - page 1 of 2 from page 3 — accents exact, and the imported page's text present in a document written by
the builder — and PyMuPDF's 72 dpi render of page 2 at 420 × 596 pixels reading (17, 31, 57) at
(80, 170), inside the profiled logo. Its fourteen checks are the readings above; the tool exits non-zero if
one of them changes.
The allocation row the layout owed T038 comes from the same tool, --creator, whose window is the ten A4
pages and the wrap of sixty sentences:
| Row | Allocations (median) | Time (median) | Output | Live after GC |
|---|---|---|---|---|
| creator: 10 pages, 200 lines, RGBA logo | 3,448 KB | 14.15 ms | 78,839 B | 0.49 MB |
| creator: 10 pages, 200 lines, no image | 781 KB | 4.40 ms | 77,136 B | 0.49 MB |
| layout: 60 sentences wrapped at 400 pt | 49 KB | 1.13 ms | 60 lines | 0.46 MB |
The image row is the RGBA logo being decoded, split and re-compressed on every build because its bytes are
passed to DrawImage inside the measured window; the second row is the same document without it. The
layout row is one PdfTextLayouter.Layout call over 60 sentences of 63 characters: 49 KB and 1.13 ms for
60 lines, the per-character cost of the wrap, with the typeface parsed outside the window.