Performance: 2x faster page splitting and extraction#30
Merged
Conversation
Add benchmarks for page splitting, copying, and merging (#26). Synthetic 100-page and 2000-page PDFs are generated from sample.pdf and cached to disk for reuse. New benchmark suites: - splitting.bench.ts: single-page extraction, full split, batch extract - copying.bench.ts: cross-doc copy, duplication, merging - comparison.bench.ts: head-to-head vs pdf-lib for all of the above Report generation: - scripts/bench-report.ts transforms vitest JSON output to markdown - reports/benchmarks.md committed to repo, updated by CI - .github/workflows/bench.yml runs weekly + on push to main
ObjectCopier does zero I/O — every method was async but never awaited anything asynchronous. Removing async/await eliminates microtask scheduling overhead on every recursive call in the deep-copy graph walk. Benchmarks show ~15% improvement on full-split workloads: - 100-page split: 31.6ms → 27.3ms (1.16x) - 2000-page split: 582.5ms → 506.6ms (1.15x)
The internal LRU cache did Map.delete()+Map.set() on every get() to maintain recency ordering. The npm lru-cache package uses a doubly-linked-list for O(1) operations without Map rehashing. Benchmarks show significant gains especially on large PDF parsing: - 2000-page split: 506.6ms → 432.3ms (1.17x incremental) - Single page from 2000p: 41.0ms → 25.5ms (1.61x incremental) - Cumulative from baseline: 1.35x–1.60x across split workloads
Three changes: - PdfName.toBytes() caches serialized bytes on the interned instance (compute once, writeBytes on every subsequent call). ASCII fast-path skips TextEncoder entirely for the 99% of names that are pure ASCII. - Shared HEX_TABLE in buffer.ts replaces per-byte toString(16) calls in both bytesToHex and escapeName. - Skip deflate for streams under 512 bytes (configurable via compressionThreshold). Deflate init zeros a 64KB hash table per call; for tiny streams the overhead dwarfs any savings. - Expose compressStreams and compressionThreshold on SaveOptions. Cumulative from baseline: 582ms → 245ms (2.38x) on 2000-page split.
Runs splitting benchmarks on both base and PR branches when .ts files are changed. Posts a comparison table as a sticky PR comment showing per-benchmark speedup/regression with 🟢/🔴 indicators at ±5% threshold.
Contributor
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Just run benchmarks and post results as a PR comment. No base comparison — check manually if needed.
Contributor
Benchmark ResultsComparisonLoad PDF
Create blank PDF
Add 10 pages
Draw 50 rectangles
Load and save PDF
Load, modify, and save PDF
Extract single page from 100-page PDF
Split 100-page PDF into single-page PDFs
Split 2000-page PDF into single-page PDFs (0.9MB)
Copy 10 pages between documents
Merge 2 x 100-page PDFs
CopyingCopy pages between documents
Duplicate pages within same document
Merge PDFs
Drawingbenchmarks/drawing.bench.ts
Formsbenchmarks/forms.bench.ts
Loadingbenchmarks/loading.bench.ts
Savingbenchmarks/saving.bench.ts
SplittingExtract single page
Split into single-page PDFs
Batch page extraction
Environment
Results are machine-dependent. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Page splitting and extraction were slower than they needed to be. Profiling the 2000-page split workload revealed a few easy wins that compound nicely.
What changed
Sync ObjectCopier —
ObjectCopierwas fully async despite doing zero I/O. Every recursive call in the deep-copy graph walk went through the microtask queue for no reason. Made all methods synchronous. (~15% on split workloads)npm
lru-cache— Our internal LRU cache didMap.delete()+Map.set()on everyget()to maintain recency. Replaced with thelru-cachepackage which uses a doubly-linked-list internally. Biggest impact on large PDF loading wherePdfRef.of()andPdfName.of()are called thousands of times. (~17% on split, ~60% on single-page extraction from large PDFs)Cached PdfName serialization —
PdfName.toBytes()was callingnew TextEncoder(), encoding to bytes, and iterating the result on every single write. Since names are interned, we can cache the serialized bytes on the instance. Added an ASCII fast-path that skips the encoder entirely for the 99% of PDF names that are plain ASCII. Also extracted a sharedHEX_TABLElookup used by bothbytesToHexandescapeName. (~23% on split)Skip deflate for tiny streams — pako's
Deflateconstructor zeros a 64KB hash table on every call (~0.023ms). When splitting 2000 pages, each output PDF has a few tiny unfiltered content streams (2-74 bytes) — that's 6000+ deflate initializations for streams that never compress meaningfully. Added a configurablecompressionThreshold(default 512 bytes) to skip compression below that size. Also exposedcompressStreamsandcompressionThresholdonSaveOptions. (~30% on split)Numbers
Also included
benchmarks/splitting.bench.ts).tsfiles and posts a comparison commentscripts/bench-compare.tsfor the comparison logic