diff --git a/app/core/build.gradle b/app/core/build.gradle index 64a1862c99..716292b34e 100644 --- a/app/core/build.gradle +++ b/app/core/build.gradle @@ -136,6 +136,17 @@ sourceSets { } +// Forward bench-related system properties through to the test JVM so +// `-Dsplit.bench=true` (and similar) flags reach Boolean.getBoolean(). +tasks.named('test', Test) { + ['split.bench', 'split.bench.pages', 'split.bench.chunk', + 'split.bench.imgW', 'split.bench.imgH'].each { key -> + def v = System.getProperty(key) + if (v != null) systemProperty(key, v) + } + maxHeapSize = '2g' +} + // Disable regular jar jar { diff --git a/app/core/src/test/java/stirling/software/SPDF/bench/SplitBenchmark.java b/app/core/src/test/java/stirling/software/SPDF/bench/SplitBenchmark.java new file mode 100644 index 0000000000..7297c8e8c9 --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/bench/SplitBenchmark.java @@ -0,0 +1,365 @@ +package stirling.software.SPDF.bench; + +import java.awt.Color; +import java.awt.Font; +import java.awt.Graphics2D; +import java.awt.image.BufferedImage; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.lang.management.ManagementFactory; +import java.lang.management.MemoryMXBean; +import java.lang.management.MemoryUsage; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashSet; +import java.util.Random; +import java.util.Set; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicLong; + +import javax.imageio.ImageIO; + +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.PDPageContentStream; +import org.apache.pdfbox.pdmodel.common.PDRectangle; +import org.apache.pdfbox.pdmodel.font.PDType1Font; +import org.apache.pdfbox.pdmodel.font.Standard14Fonts; +import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject; +import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationWidget; +import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm; +import org.apache.pdfbox.pdmodel.interactive.form.PDTextField; +import org.junit.jupiter.api.Test; + +import stirling.software.common.util.FormUtils; +import stirling.software.jpdfium.PdfDocument; +import stirling.software.jpdfium.PdfSplit; + +/** + * Apples-to-apples heap + wall-clock benchmark for split: legacy PDFBox path (load + addPage per + * range + save) vs JPDFium path (PdfSplit.extractPageRange + save). + * + *
Generates a single ~N-page PDF with one JPEG per page, then performs a "split into chunks of + * 10" run with both backends. Samples heap every 25 ms during execution. + * + *
Run only when explicitly requested: -Dsplit.bench=true
+ */
+public final class SplitBenchmark {
+
+ private static final int PAGES = Integer.getInteger("split.bench.pages", 100);
+ private static final int CHUNK = Integer.getInteger("split.bench.chunk", 10);
+ private static final int IMAGE_W = Integer.getInteger("split.bench.imgW", 800);
+ private static final int IMAGE_H = Integer.getInteger("split.bench.imgH", 600);
+ private static final long SAMPLE_PERIOD_MS = 25L;
+
+ @Test
+ void compareSplitMemoryFootprint() throws Exception {
+ if (!Boolean.getBoolean("split.bench")) {
+ System.out.println("Skipping SplitBenchmark (run with -Dsplit.bench=true)");
+ return;
+ }
+ main(new String[0]);
+ }
+
+ public static void main(String[] args) throws Exception {
+ Path workDir = Files.createTempDirectory("split-bench-");
+ System.out.println("Work dir: " + workDir);
+ System.out.printf(
+ "Generating %d-page input PDF with %dx%d images per page...%n",
+ PAGES, IMAGE_W, IMAGE_H);
+
+ Path input = workDir.resolve("input.pdf");
+ long t0 = System.nanoTime();
+ generateTestPdf(input, PAGES);
+ long buildMs = (System.nanoTime() - t0) / 1_000_000;
+ System.out.printf(
+ " built %s: %,d KB (%d pages) in %,d ms%n%n",
+ input.getFileName(), Files.size(input) / 1024, PAGES, buildMs);
+
+ // Warmup - prime classloaders, JIT, native lib.
+ System.out.println("--- Warmup (results discarded) ---");
+ runPdfBoxSplit(input, workDir.resolve("warmup-pdfbox"), CHUNK);
+ runJpdfiumSplit(input, workDir.resolve("warmup-jpdfium"), CHUNK);
+ forceGcQuiescence();
+ System.out.println();
+
+ long baseline = sampleUsedHeapAfterGc();
+ System.out.printf("Baseline heap (after GC, before split): %,d KB%n%n", baseline / 1024);
+
+ // PDFBox run
+ System.out.println("--- PDFBox split (load + addPage + save per chunk) ---");
+ Path outBoxDir = workDir.resolve("out-pdfbox");
+ BenchResult pdfboxResult = profile(() -> runPdfBoxSplit(input, outBoxDir, CHUNK));
+ pdfboxResult.printSummary("PDFBox", baseline);
+
+ forceGcQuiescence();
+ System.out.println();
+
+ // JPDFium run
+ System.out.println("--- JPDFium split (PdfSplit.extractPageRange + save) ---");
+ Path outJpdDir = workDir.resolve("out-jpdfium");
+ BenchResult jpdfiumResult = profile(() -> runJpdfiumSplit(input, outJpdDir, CHUNK));
+ jpdfiumResult.printSummary("JPDFium", baseline);
+
+ // Compare
+ System.out.println();
+ System.out.println("=== Heap delta over baseline ===");
+ long pdfboxDelta = pdfboxResult.peakHeapBytes - baseline;
+ long jpdfiumDelta = jpdfiumResult.peakHeapBytes - baseline;
+ double improvement =
+ pdfboxDelta == 0 ? 0.0 : (100.0 * (pdfboxDelta - jpdfiumDelta) / pdfboxDelta);
+ System.out.printf(" PDFBox : +%,d KB%n", pdfboxDelta / 1024);
+ System.out.printf(" JPDFium : +%,d KB%n", jpdfiumDelta / 1024);
+ System.out.printf(" JPDFium uses %.1f%% LESS heap than PDFBox%n", improvement);
+
+ System.out.println();
+ System.out.println("=== Wall-clock ===");
+ System.out.printf(" PDFBox : %,d ms%n", pdfboxResult.wallMs);
+ System.out.printf(" JPDFium : %,d ms%n", jpdfiumResult.wallMs);
+ double speedup =
+ jpdfiumResult.wallMs == 0
+ ? 0.0
+ : ((double) pdfboxResult.wallMs / jpdfiumResult.wallMs);
+ System.out.printf(" Speedup : %.2fx%n", speedup);
+
+ // Hybrid form-pruning sub-benchmark.
+ // Quantifies the heap re-introduced by the PDFBox post-pass that
+ // pruneOrphanedFormFields runs on each JPDFium-produced split.
+ System.out.println();
+ System.out.println("=== Hybrid form-pruning sub-benchmark ===");
+ Path formInput = workDir.resolve("form-input.pdf");
+ generateFormPdf(formInput, PAGES);
+ System.out.printf(
+ " built %s with AcroForm: %,d KB (%d pages)%n",
+ formInput.getFileName(), Files.size(formInput) / 1024, PAGES);
+
+ runJpdfiumSplit(formInput, workDir.resolve("warmup-form-bare"), CHUNK);
+ runJpdfiumSplitWithFormPrune(formInput, workDir.resolve("warmup-form-pruned"), CHUNK);
+ forceGcQuiescence();
+
+ BenchResult bareForm =
+ profile(() -> runJpdfiumSplit(formInput, workDir.resolve("out-form-bare"), CHUNK));
+ bareForm.printSummary("JPDFium-only", sampleUsedHeapAfterGc());
+
+ forceGcQuiescence();
+ BenchResult prunedForm =
+ profile(
+ () ->
+ runJpdfiumSplitWithFormPrune(
+ formInput, workDir.resolve("out-form-pruned"), CHUNK));
+ prunedForm.printSummary("JPDFium+PDFBox-prune", sampleUsedHeapAfterGc());
+
+ long pruneOverhead = prunedForm.peakHeapBytes - bareForm.peakHeapBytes;
+ long pruneTimeOverhead = prunedForm.wallMs - bareForm.wallMs;
+ System.out.printf(
+ " Hybrid prune overhead: +%,d KB heap, +%,d ms wall (%.1fx slower)%n",
+ pruneOverhead / 1024,
+ pruneTimeOverhead,
+ bareForm.wallMs == 0 ? 0.0 : (double) prunedForm.wallMs / bareForm.wallMs);
+ }
+
+ private static void runJpdfiumSplitWithFormPrune(Path input, Path outDir, int chunk)
+ throws IOException {
+ Files.createDirectories(outDir);
+ try (PdfDocument source = PdfDocument.open(input)) {
+ int total = source.pageCount();
+ int index = 0;
+ for (int start = 0; start < total; start += chunk) {
+ int end = Math.min(start + chunk - 1, total - 1);
+ Path raw = outDir.resolve("raw-" + (index + 1) + ".pdf");
+ try (PdfDocument split = PdfSplit.extractPageRange(source, start, end)) {
+ split.save(raw);
+ }
+ Path pruned = outDir.resolve("pruned-" + (++index) + ".pdf");
+ try (PDDocument doc = org.apache.pdfbox.Loader.loadPDF(raw.toFile())) {
+ FormUtils.pruneOrphanedFormFields(doc);
+ doc.save(pruned.toFile());
+ }
+ Files.deleteIfExists(raw);
+ }
+ }
+ }
+
+ private static void generateFormPdf(Path out, int pages) throws IOException {
+ try (PDDocument doc = new PDDocument()) {
+ for (int i = 0; i < pages; i++) {
+ PDPage page = new PDPage(PDRectangle.A4);
+ doc.addPage(page);
+ }
+ PDAcroForm acroForm = new PDAcroForm(doc);
+ doc.getDocumentCatalog().setAcroForm(acroForm);
+ // One text field per page (orphan-prone scenario).
+ for (int i = 0; i < pages; i++) {
+ PDTextField field = new PDTextField(acroForm);
+ field.setPartialName("field_" + i);
+ acroForm.getFields().add(field);
+ PDAnnotationWidget widget = new PDAnnotationWidget();
+ widget.setRectangle(new PDRectangle(50, 50, 200, 30));
+ widget.setPage(doc.getPage(i));
+ doc.getPage(i).getAnnotations().add(widget);
+ field.getWidgets().add(widget);
+ }
+ doc.save(out.toFile());
+ }
+ }
+
+ private static void runPdfBoxSplit(Path input, Path outDir, int chunk) throws IOException {
+ Files.createDirectories(outDir);
+ try (PDDocument source = org.apache.pdfbox.Loader.loadPDF(input.toFile())) {
+ int total = source.getNumberOfPages();
+ int index = 0;
+ for (int start = 0; start < total; start += chunk) {
+ int end = Math.min(start + chunk - 1, total - 1);
+ Set