Skip to content

Latest commit

 

History

History
657 lines (530 loc) · 43.9 KB

measurements.md

File metadata and controls

657 lines (530 loc) · 43.9 KB

What the library costs — measurement record

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).

How the numbers were taken

# 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.

1. Allocations and wall time per merge

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.

2. NFR §7.1 — the performance budgets, as measured

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 scaling budget is the one that does not hold

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:

  1. 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.
  2. 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.

3. NFR §7.2 — memory and the peak budget

§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:

  1. 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.
  2. 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.

4. What the allocation work changed, and why the output did not

Four slices landed, all inside the library, none of them a behaviour change:

  1. Serializer and writer — one output buffer, no per-object array or string materialisation.

  2. Lexer — ReadName, DecodeName and ReadHexString scan once, and ReadLiteralString builds on PooledByteBuffer instead of a List<byte>. List<byte> and bytes.Add no longer occur in src.

  3. Capture path — PdfCaptureStream writes into a PooledByteBuffer and the merge engine disposes it with using, so a captured source is one pooled buffer rather than a growing list plus a final copy.

  4. Stream sources — PdfStreamSource hands every opener the same Lock-guarded capture, so a source opened twice is read once. A capture that the starter abandoned, because its token was cancelled, yields null instead 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.
  • PdfFilters and PdfFilters.Inflation still materialise decoded stream bytes, and PooledByteBuffer exposes ToExactArray() for a caller that needs an exact array. Both are deliberate, and neither is on the copy loop.

5. What these numbers do not cover

  • 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 by PdfStreamSourceTests rather 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 Stream is 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.

6. Where these numbers live

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.

7. What a content read costs

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 -- --content

The 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.

8. What authoring a page costs

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 -- --author

Every 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 its IDAT stream 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. ToArray and Build return 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.

Issue-closure re-run, 2026-10-22

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.

9. The Release 1.1 creator and editor baselines

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 -- --editor

These 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.

Creator

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.

Editor

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.

Budgets and the 1.0 baseline

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.

10. The 1.1.0 release re-run (T101)

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 shape

This 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.

Merges (§1)

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.

Reads, extraction and the copy route (§7)

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.

Authoring (§8)

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

The creator and the editor (§9)

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.

Verdict

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.

11. Text layout: what the metrics and the placement were checked against

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.

12. Drawing: what a viewer painted, not only what the stream said

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.

13. The creator surface: a profile, bookmarks and an imported 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 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.pdf

validate-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.