@reekon-tools/react-native-pdf-canvas 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (121) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +57 -0
  3. package/PdfCanvas.podspec +68 -39
  4. package/README.md +213 -1316
  5. package/android/build.gradle +67 -54
  6. package/android/consumer-rules.pro +7 -0
  7. package/android/src/androidTest/java/tools/reekon/pdfcanvas/PdfCanvasNativeTest.java +254 -0
  8. package/android/src/androidTest/java/tools/reekon/pdfcanvas/TestPdfs.java +60 -468
  9. package/android/src/main/cpp/CMakeLists.txt +54 -0
  10. package/android/src/main/cpp/pdfcanvas-jni.cpp +262 -0
  11. package/android/src/main/java/tools/reekon/pdfcanvas/PdfCanvasNative.java +167 -0
  12. package/android/src/main/java/tools/reekon/pdfcanvas/PdfErrorCode.java +14 -4
  13. package/android/src/main/java/tools/reekon/pdfcanvas/PdfRasterException.java +10 -4
  14. package/android/src/reactnative/java/tools/reekon/pdfcanvas/rn/PdfCanvasModule.java +160 -376
  15. package/android/tools/compile-gate.sh +81 -101
  16. package/dist/controller.d.ts +14 -0
  17. package/dist/controller.js +4 -3
  18. package/dist/rasterizer/index.native.d.ts +8 -12
  19. package/dist/rasterizer/index.native.js +15 -21
  20. package/dist/rasterizer/native-bridge.d.ts +51 -75
  21. package/dist/rasterizer/native-bridge.js +49 -33
  22. package/dist/rasterizer/native.d.ts +45 -0
  23. package/dist/rasterizer/native.js +123 -0
  24. package/dist/react/usePdfDocument.d.ts +5 -5
  25. package/dist/react/usePdfLayer.d.ts +10 -1
  26. package/dist/react/usePdfLayer.js +10 -2
  27. package/dist/testing/scenes.d.ts +3 -3
  28. package/dist/testing/scenes.js +4 -4
  29. package/dist/types.d.ts +36 -42
  30. package/ios/Sources/PdfCanvasBridge/PdfCanvasModule.mm +234 -536
  31. package/native/.clangd +5 -0
  32. package/native/CMakeLists.txt +89 -0
  33. package/native/core/include/pdfcanvas/document.h +90 -0
  34. package/native/core/include/pdfcanvas/error.h +47 -0
  35. package/native/core/include/pdfcanvas/jsi.h +18 -0
  36. package/native/core/include/pdfcanvas/jsi_config.h +30 -0
  37. package/native/core/include/pdfcanvas/library.h +28 -0
  38. package/native/core/include/pdfcanvas/pixels.h +92 -0
  39. package/native/core/include/pdfcanvas/service.h +87 -0
  40. package/native/core/include/pdfcanvas/slots.h +113 -0
  41. package/native/core/include/pdfcanvas/types.h +110 -0
  42. package/native/core/pdfcanvas-core.cmake +22 -0
  43. package/native/core/src/document.cpp +516 -0
  44. package/native/core/src/error.cpp +29 -0
  45. package/native/core/src/jsi.cpp +66 -0
  46. package/native/core/src/library.cpp +52 -0
  47. package/native/core/src/pixels.cpp +45 -0
  48. package/native/core/src/service.cpp +156 -0
  49. package/native/core/src/slots.cpp +139 -0
  50. package/native/tests/fixtures.cpp +451 -0
  51. package/native/tests/fixtures.h +96 -0
  52. package/native/tests/harness.h +122 -0
  53. package/native/tests/main.cpp +64 -0
  54. package/native/tests/pixels_util.h +126 -0
  55. package/native/tests/test_document.cpp +485 -0
  56. package/native/tests/test_service.cpp +130 -0
  57. package/native/tests/test_slots.cpp +250 -0
  58. package/package.json +27 -7
  59. package/react-native.config.js +5 -2
  60. package/scripts/fetch-pdfium.mjs +487 -0
  61. package/scripts/pdfium-manifest.json +46 -0
  62. package/android/src/androidTest/java/tools/reekon/pdfcanvas/PdfCanvasCancellationTest.java +0 -226
  63. package/android/src/androidTest/java/tools/reekon/pdfcanvas/PdfCanvasConcurrencyTest.java +0 -239
  64. package/android/src/androidTest/java/tools/reekon/pdfcanvas/PdfCanvasDeviceTimingTest.java +0 -251
  65. package/android/src/androidTest/java/tools/reekon/pdfcanvas/PdfCanvasFailureTest.java +0 -221
  66. package/android/src/androidTest/java/tools/reekon/pdfcanvas/PdfCanvasJsiTransportTest.java +0 -783
  67. package/android/src/androidTest/java/tools/reekon/pdfcanvas/PdfCanvasPhaseTimingTest.java +0 -1388
  68. package/android/src/androidTest/java/tools/reekon/pdfcanvas/PdfCanvasPoisonGuardTest.java +0 -98
  69. package/android/src/androidTest/java/tools/reekon/pdfcanvas/PdfCanvasQuirksTest.java +0 -324
  70. package/android/src/androidTest/java/tools/reekon/pdfcanvas/PdfCanvasRasterizerTest.java +0 -775
  71. package/android/src/androidTest/java/tools/reekon/pdfcanvas/PoisonsTheProcess.java +0 -20
  72. package/android/src/jsi/cpp/CMakeLists.txt +0 -33
  73. package/android/src/jsi/cpp/pdfcanvas-jsi.cpp +0 -469
  74. package/android/src/jsi/java/tools/reekon/pdfcanvas/jsi/PdfCanvasPixelBridge.java +0 -140
  75. package/android/src/main/java/tools/reekon/pdfcanvas/PageGeometry.java +0 -45
  76. package/android/src/main/java/tools/reekon/pdfcanvas/PdfCanvasRasterizer.java +0 -564
  77. package/android/src/main/java/tools/reekon/pdfcanvas/PdfPlatformQuirks.java +0 -277
  78. package/android/src/main/java/tools/reekon/pdfcanvas/PdfPreflight.java +0 -214
  79. package/android/src/main/java/tools/reekon/pdfcanvas/PdfRendererPool.java +0 -234
  80. package/android/src/main/java/tools/reekon/pdfcanvas/RasterCancellation.java +0 -47
  81. package/android/src/main/java/tools/reekon/pdfcanvas/RasterPixels.java +0 -143
  82. package/android/src/main/java/tools/reekon/pdfcanvas/RasterRequest.java +0 -95
  83. package/dist/rasterizer/android.d.ts +0 -121
  84. package/dist/rasterizer/android.js +0 -126
  85. package/dist/rasterizer/ios.d.ts +0 -84
  86. package/dist/rasterizer/ios.js +0 -155
  87. package/ios/Package.swift +0 -58
  88. package/ios/Sources/PdfCanvasCore/PdfCanvasCancellation.m +0 -38
  89. package/ios/Sources/PdfCanvasCore/PdfCanvasDocumentPool.m +0 -389
  90. package/ios/Sources/PdfCanvasCore/PdfCanvasErrorCode.m +0 -57
  91. package/ios/Sources/PdfCanvasCore/PdfCanvasPageGeometry.m +0 -44
  92. package/ios/Sources/PdfCanvasCore/PdfCanvasPageTransform.m +0 -82
  93. package/ios/Sources/PdfCanvasCore/PdfCanvasPixelSink.m +0 -37
  94. package/ios/Sources/PdfCanvasCore/PdfCanvasPreflight.m +0 -158
  95. package/ios/Sources/PdfCanvasCore/PdfCanvasRasterPixels.m +0 -80
  96. package/ios/Sources/PdfCanvasCore/PdfCanvasRasterRequest.m +0 -65
  97. package/ios/Sources/PdfCanvasCore/PdfCanvasRasterizer.m +0 -548
  98. package/ios/Sources/PdfCanvasCore/include/PdfCanvasCancellation.h +0 -48
  99. package/ios/Sources/PdfCanvasCore/include/PdfCanvasCore.h +0 -26
  100. package/ios/Sources/PdfCanvasCore/include/PdfCanvasDocumentPool.h +0 -122
  101. package/ios/Sources/PdfCanvasCore/include/PdfCanvasErrorCode.h +0 -80
  102. package/ios/Sources/PdfCanvasCore/include/PdfCanvasPageGeometry.h +0 -79
  103. package/ios/Sources/PdfCanvasCore/include/PdfCanvasPageTransform.h +0 -129
  104. package/ios/Sources/PdfCanvasCore/include/PdfCanvasPixelSink.h +0 -100
  105. package/ios/Sources/PdfCanvasCore/include/PdfCanvasPreflight.h +0 -65
  106. package/ios/Sources/PdfCanvasCore/include/PdfCanvasRasterPixels.h +0 -111
  107. package/ios/Sources/PdfCanvasCore/include/PdfCanvasRasterRequest.h +0 -85
  108. package/ios/Sources/PdfCanvasCore/include/PdfCanvasRasterizer.h +0 -163
  109. package/ios/Sources/PdfCanvasSlots/PdfCanvasSlots.mm +0 -297
  110. package/ios/Sources/PdfCanvasSlots/include/PdfCanvasSlots.h +0 -140
  111. package/ios/Tests/PdfCanvasCoreTests/ConcurrencyTests.swift +0 -358
  112. package/ios/Tests/PdfCanvasCoreTests/FailureTests.swift +0 -262
  113. package/ios/Tests/PdfCanvasCoreTests/GeometryTests.swift +0 -173
  114. package/ios/Tests/PdfCanvasCoreTests/PreviewTests.swift +0 -114
  115. package/ios/Tests/PdfCanvasCoreTests/RasterAssertions.swift +0 -158
  116. package/ios/Tests/PdfCanvasCoreTests/RasterizerTests.swift +0 -544
  117. package/ios/Tests/PdfCanvasCoreTests/SinkTests.swift +0 -287
  118. package/ios/Tests/PdfCanvasCoreTests/SlotsTests.swift +0 -396
  119. package/ios/Tests/PdfCanvasCoreTests/TestPdfs.swift +0 -639
  120. package/ios/Tests/PdfCanvasCoreTests/TimingTests.swift +0 -179
  121. package/ios/Tests/PdfCanvasCoreTests/TransformTests.swift +0 -283
@@ -1,1388 +0,0 @@
1
- package tools.reekon.pdfcanvas;
2
-
3
- import static org.junit.Assert.assertEquals;
4
- import static org.junit.Assert.assertTrue;
5
-
6
- import android.content.Context;
7
- import android.graphics.Bitmap;
8
- import android.graphics.Color;
9
- import android.graphics.Matrix;
10
- import android.graphics.pdf.PdfRenderer;
11
- import android.os.ParcelFileDescriptor;
12
- import android.util.Base64;
13
- import android.util.Log;
14
-
15
- import androidx.test.ext.junit.runners.AndroidJUnit4;
16
- import androidx.test.platform.app.InstrumentationRegistry;
17
-
18
- import org.junit.Test;
19
- import org.junit.runner.RunWith;
20
-
21
- import java.io.File;
22
- import java.io.IOException;
23
- import java.nio.ByteBuffer;
24
- import java.util.ArrayList;
25
- import java.util.Arrays;
26
- import java.util.List;
27
- import java.util.concurrent.ConcurrentLinkedQueue;
28
- import java.util.concurrent.CountDownLatch;
29
- import java.util.concurrent.ExecutorService;
30
- import java.util.concurrent.Executors;
31
- import java.util.concurrent.TimeUnit;
32
- import java.util.concurrent.atomic.AtomicInteger;
33
- import java.util.concurrent.atomic.AtomicLong;
34
- import java.util.concurrent.atomic.AtomicReference;
35
-
36
- import tools.reekon.pdfcanvas.jsi.PdfCanvasPixelBridge;
37
-
38
- /**
39
- * MEASUREMENT, NOT VERIFICATION — the same contract as
40
- * {@link PdfCanvasDeviceTimingTest}, one level finer. That file answers "how long
41
- * does a tile take". This one answers the two questions a tile timing cannot:
42
- *
43
- * <ol>
44
- * <li>WHERE inside a tile the time goes — {@code openPage}, bitmap allocation,
45
- * the white fill, {@code PdfRenderer.render}, the copy to a Java
46
- * {@code byte[]}, and the base64 encode, each timed separately.
47
- * <li>WHETHER the renderer pool actually buys wall-clock. The controller
48
- * renders an epoch's pieces strictly one at a time; the whole case for
49
- * parallelising it rests on N renderers finishing N tiles in meaningfully
50
- * less than N times one tile. That is measured here at N = 2, 3, 4 and 6.
51
- * </ol>
52
- *
53
- * <h3>What is NOT measured here, and cannot be</h3>
54
- *
55
- * The bridge hop and everything past it — the base64 STRING crossing into JS,
56
- * the hand-rolled {@code decodeBase64} in {@code src/rasterizer/android.ts}, and
57
- * {@code Skia.Data.fromBytes} + {@code MakeImage}. None of that exists in a
58
- * process with no React Native in it, which this test build deliberately is (see
59
- * {@code android/build.gradle}). What this file CAN do about them is state the
60
- * exact byte volumes they are handed, which it logs: raw bytes, base64 chars,
61
- * and the encode cost that produced them.
62
- *
63
- * <h3>Why the phase harness re-implements the render instead of calling it</h3>
64
- *
65
- * {@link PdfCanvasRasterizer#render} is one opaque call; splitting it from
66
- * outside is impossible and adding timing hooks to it would put measurement
67
- * scaffolding in production code forever. So {@link #renderPhases} mirrors
68
- * {@code renderLeased} step for step — and then {@link #phaseHarnessMatchesTheRealRender}
69
- * proves the mirror is exact by comparing its pixels byte-for-byte against the
70
- * real thing. A drifted copy would measure a render nobody performs.
71
- */
72
- @RunWith(AndroidJUnit4.class)
73
- public class PdfCanvasPhaseTimingTest {
74
-
75
- private static final String TAG = "PDFCANVAS";
76
-
77
- /**
78
- * The viewport the field report was taken at: 131.25 device px per PDF point,
79
- * i.e. zoom 50x on a 2.625 dpr phone. Everything in this file is measured
80
- * there, because the complaint is about that state and cost does not
81
- * necessarily scale linearly out of it.
82
- */
83
- private static final float PROBE_SCALE = 131.25f;
84
-
85
- /** {@code maxPieceDimension}'s current default. */
86
- private static final int TILE_PX = 1024;
87
-
88
- /** The probe document's shape: 6 x US Letter, stacked. */
89
- private static final int PROBE_PAGES = 6;
90
-
91
- private static final int SAMPLES = 20;
92
-
93
- /** Same convention as {@link PdfCanvasDeviceTimingTest}: push one to use it. */
94
- private static final String REAL_PDF = "customer.pdf";
95
-
96
- private static Context ctx() {
97
- return InstrumentationRegistry.getInstrumentation().getTargetContext();
98
- }
99
-
100
- /* ================================================================ *
101
- * Fixtures
102
- * ================================================================ */
103
-
104
- /** The real drawing if one was pushed, else the synthetic 6-page letter sheet. */
105
- private static File fixture() throws IOException {
106
- File dir = ctx().getExternalFilesDir(null);
107
- File real = dir == null ? null : new File(dir, REAL_PDF);
108
- if (real != null && real.isFile()) {
109
- return real;
110
- }
111
- Log.w(
112
- TAG,
113
- "no "
114
- + REAL_PDF
115
- + " — falling back to the SYNTHETIC 6-page letter pattern, which is "
116
- + "vector rects and lines and is therefore an OPTIMISTIC bound. Push a "
117
- + "real drawing to:\n adb push drawing.pdf /sdcard/Android/data/"
118
- + ctx().getPackageName()
119
- + "/files/"
120
- + REAL_PDF);
121
- return TestPdfs.pattern(
122
- ctx(), "phase-letter6.pdf", TestPdfs.LETTER_W, TestPdfs.LETTER_H, PROBE_PAGES);
123
- }
124
-
125
- private static boolean usingRealPdf() {
126
- File dir = ctx().getExternalFilesDir(null);
127
- return dir != null && new File(dir, REAL_PDF).isFile();
128
- }
129
-
130
- /**
131
- * The six pieces of the field report's detail epoch, laid out the way the
132
- * planner lays them out: a 2-wide, 3-tall grid of {@code TILE_PX} tiles anchored
133
- * at the visible rect's origin.
134
- *
135
- * The real epoch's right and bottom pieces are CLIPPED (the detail rect is
136
- * 1345 x 2133 px, so the third row is 85 px tall). Full 1024 squares are used
137
- * throughout anyway: the question here is what one full-size piece costs and
138
- * how N of them scale, and a clipped piece only ever costs less.
139
- */
140
- private static List<RasterRequest> epochPieces(int count, int page) {
141
- float side = TILE_PX / PROBE_SCALE; // 7.8019 pt
142
- List<RasterRequest> out = new ArrayList<>(count);
143
- for (int i = 0; i < count; i++) {
144
- float x = 242.9f + (i % 2) * side;
145
- float y = 549.4f + (i / 2) * side;
146
- out.add(
147
- new RasterRequest(
148
- page, x, y, side, side, PROBE_SCALE, false, RasterRequest.BACKGROUND_WHITE));
149
- }
150
- return out;
151
- }
152
-
153
- /* ================================================================ *
154
- * 1. Phase decomposition
155
- * ================================================================ */
156
-
157
- /** Every timed segment of one tile, in nanoseconds. */
158
- private static final class Phases {
159
- long openPage;
160
- long alloc;
161
- long erase;
162
- long render;
163
- long extract;
164
- long base64;
165
- long teardown;
166
- long total;
167
- int rawBytes;
168
- int base64Chars;
169
- int rowBytes;
170
- byte[] bytes;
171
- }
172
-
173
- /**
174
- * MIRRORS {@code PdfCanvasRasterizer.renderLeased} exactly, plus the base64
175
- * encode that {@code PdfCanvasModule.render} does on the same worker thread.
176
- * Pinned against the real render by {@link #phaseHarnessMatchesTheRealRender}.
177
- */
178
- private static Phases renderPhases(PdfRenderer renderer, RasterRequest request) {
179
- Phases p = new Phases();
180
- int pxW = request.pixelWidth();
181
- int pxH = request.pixelHeight();
182
-
183
- long t0 = System.nanoTime();
184
- PdfRenderer.Page page = renderer.openPage(request.page);
185
- long t1 = System.nanoTime();
186
-
187
- Bitmap bitmap = Bitmap.createBitmap(pxW, pxH, Bitmap.Config.ARGB_8888);
188
- long t2 = System.nanoTime();
189
-
190
- bitmap.eraseColor(
191
- request.background == RasterRequest.BACKGROUND_TRANSPARENT
192
- ? Color.TRANSPARENT
193
- : Color.WHITE);
194
- long t3 = System.nanoTime();
195
-
196
- Matrix matrix = new Matrix();
197
- matrix.setScale(request.scale, request.scale);
198
- matrix.postTranslate(-request.docX * request.scale, -request.docY * request.scale);
199
- page.render(bitmap, null, matrix, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY);
200
- long t4 = System.nanoTime();
201
-
202
- int rowBytes = bitmap.getRowBytes();
203
- byte[] bytes = new byte[bitmap.getByteCount()];
204
- bitmap.copyPixelsToBuffer(ByteBuffer.wrap(bytes));
205
- long t5 = System.nanoTime();
206
-
207
- String encoded = Base64.encodeToString(bytes, Base64.NO_WRAP);
208
- long t6 = System.nanoTime();
209
-
210
- bitmap.recycle();
211
- page.close();
212
- long t7 = System.nanoTime();
213
-
214
- p.openPage = t1 - t0;
215
- p.alloc = t2 - t1;
216
- p.erase = t3 - t2;
217
- p.render = t4 - t3;
218
- p.extract = t5 - t4;
219
- p.base64 = t6 - t5;
220
- p.teardown = t7 - t6;
221
- p.total = t7 - t0;
222
- p.rawBytes = bytes.length;
223
- p.base64Chars = encoded.length();
224
- p.rowBytes = rowBytes;
225
- p.bytes = bytes;
226
- return p;
227
- }
228
-
229
- /**
230
- * CATCHES: {@link #renderPhases} drifting away from
231
- * {@code PdfCanvasRasterizer.renderLeased}, which would make every number in
232
- * this file a measurement of code that never runs in production.
233
- *
234
- * Byte-for-byte, not "close enough" — the two paths build the same matrix from
235
- * the same request, so any difference at all means a step was dropped or
236
- * reordered.
237
- */
238
- @Test
239
- public void phaseHarnessMatchesTheRealRender() throws Exception {
240
- File pdf = fixture();
241
- RasterRequest request = epochPieces(1, 0).get(0);
242
-
243
- PdfCanvasRasterizer real = PdfCanvasRasterizer.openFile(pdf, null, 1);
244
- RasterPixels expected;
245
- try {
246
- expected = real.render(request, null);
247
- } finally {
248
- real.close();
249
- }
250
-
251
- ParcelFileDescriptor pfd =
252
- ParcelFileDescriptor.open(pdf, ParcelFileDescriptor.MODE_READ_ONLY);
253
- PdfRenderer renderer = new PdfRenderer(pfd);
254
- Phases got;
255
- try {
256
- got = renderPhases(renderer, request);
257
- } finally {
258
- renderer.close();
259
- }
260
-
261
- assertEquals("rowBytes", expected.rowBytes, got.rowBytes);
262
- assertEquals("byte count", expected.bytes.length, got.rawBytes);
263
- RasterPixels mirrored =
264
- new RasterPixels(
265
- got.bytes,
266
- expected.width,
267
- expected.height,
268
- got.rowBytes,
269
- RasterPixels.FORMAT_RGBA_8888);
270
- PdfCanvasRasterizerTest.Diff diff =
271
- PdfCanvasRasterizerTest.compareRegion(mirrored, expected, 0, 0);
272
- assertEquals(
273
- "the phase harness must render exactly what the rasterizer renders: " + diff,
274
- 0,
275
- diff.mismatches);
276
- }
277
-
278
- /**
279
- * The decomposition. One 1024 px tile at the field report's scale, broken into
280
- * its six phases plus teardown, {@value #SAMPLES} times.
281
- *
282
- * The tile WALKS between samples, for the reason
283
- * {@link PdfCanvasDeviceTimingTest#report} gives: re-rendering one rect
284
- * measures PDFium's cache, and panning does not get that.
285
- */
286
- @Test
287
- public void phaseBreakdownOfOneTileAtProbeScale() throws Exception {
288
- File pdf = fixture();
289
- ParcelFileDescriptor pfd =
290
- ParcelFileDescriptor.open(pdf, ParcelFileDescriptor.MODE_READ_ONLY);
291
- PdfRenderer renderer = new PdfRenderer(pfd);
292
- try {
293
- Log.i(
294
- TAG,
295
- "PHASES fixture="
296
- + (usingRealPdf() ? "REAL " + REAL_PDF : "SYNTHETIC 6-page letter")
297
- + " "
298
- + pdf.length()
299
- + " bytes, "
300
- + renderer.getPageCount()
301
- + " pages, scale="
302
- + PROBE_SCALE
303
- + " devpx/pt");
304
- for (int px : new int[] {TILE_PX, 512}) {
305
- phaseReport(renderer, px, Math.min(PROBE_PAGES, renderer.getPageCount()));
306
- }
307
- } finally {
308
- renderer.close();
309
- }
310
- }
311
-
312
- private static void phaseReport(PdfRenderer renderer, int px, int pages) {
313
- float side = px / PROBE_SCALE;
314
- long[] openPage = new long[SAMPLES];
315
- long[] alloc = new long[SAMPLES];
316
- long[] erase = new long[SAMPLES];
317
- long[] render = new long[SAMPLES];
318
- long[] extract = new long[SAMPLES];
319
- long[] base64 = new long[SAMPLES];
320
- long[] teardown = new long[SAMPLES];
321
- long[] total = new long[SAMPLES];
322
- Phases last = null;
323
-
324
- for (int i = 0; i < SAMPLES; i++) {
325
- // Walk over the page BODY so no two samples share PDFium's per-region
326
- // cache — and, just as importantly, so no sample lands in the margin. A
327
- // 7.8 pt tile stepped by 3 x itself never leaves the top-left corner of a
328
- // letter page, which on a real drawing is white space: the first run of
329
- // this test measured PdfRenderer.render at 0.076 ms for exactly that
330
- // reason. These 20 positions span 60..500 pt across and 90..540 pt down.
331
- float x = 60f + (i % 5) * 110f;
332
- float y = 90f + (i / 5) * 150f;
333
- RasterRequest request =
334
- new RasterRequest(
335
- i % Math.max(1, pages),
336
- x,
337
- y,
338
- side,
339
- side,
340
- PROBE_SCALE,
341
- false,
342
- RasterRequest.BACKGROUND_WHITE);
343
- Phases p = renderPhases(renderer, request);
344
- openPage[i] = p.openPage;
345
- alloc[i] = p.alloc;
346
- erase[i] = p.erase;
347
- render[i] = p.render;
348
- extract[i] = p.extract;
349
- base64[i] = p.base64;
350
- teardown[i] = p.teardown;
351
- total[i] = p.total;
352
- p.bytes = null; // 4 MiB per sample; do not hold 20 of them.
353
- last = p;
354
- }
355
-
356
- Log.i(
357
- TAG,
358
- "PHASES "
359
- + px
360
- + "px@"
361
- + PROBE_SCALE
362
- + "x n="
363
- + SAMPLES
364
- + " raw="
365
- + (last == null ? 0 : last.rawBytes)
366
- + "B base64="
367
- + (last == null ? 0 : last.base64Chars)
368
- + "chars rowBytes="
369
- + (last == null ? 0 : last.rowBytes));
370
- line("1 PdfRenderer.render", render, total);
371
- line("2 Bitmap alloc+zero", alloc, total);
372
- line("2b eraseColor(WHITE)", erase, total);
373
- line("3 extract to byte[]", extract, total);
374
- line("4 base64 encode ", base64, total);
375
- line("- openPage ", openPage, total);
376
- line("- recycle+close ", teardown, total);
377
- line("= TOTAL ", total, total);
378
- }
379
-
380
- /** One phase row: p50/p95 in microseconds, plus its share of the tile's p50. */
381
- private static void line(String label, long[] ns, long[] totalNs) {
382
- long[] s = ns.clone();
383
- Arrays.sort(s);
384
- long[] t = totalNs.clone();
385
- Arrays.sort(t);
386
- long p50 = percentile(s, 50);
387
- long totalP50 = Math.max(1L, percentile(t, 50));
388
- Log.i(
389
- TAG,
390
- String.format(
391
- " %s p50=%8.3f ms p95=%8.3f ms min=%8.3f max=%8.3f share(p50)=%5.1f%%",
392
- label,
393
- p50 / 1e6,
394
- percentile(s, 95) / 1e6,
395
- s[0] / 1e6,
396
- s[s.length - 1] / 1e6,
397
- (100.0 * p50) / totalP50));
398
- }
399
-
400
- /* ================================================================ *
401
- * 2. Concurrency headroom — the number the fix depends on
402
- * ================================================================ */
403
-
404
- /**
405
- * N tiles one at a time versus N tiles across N threads through a pool of N,
406
- * for N = 2, 3, 4 and 6.
407
- *
408
- * This is the whole case for parallelising the controller's per-piece loop, and
409
- * it is reported rather than asserted: a speedup assertion fails on a warm
410
- * phone and teaches nobody anything. What the numbers can show is the failure
411
- * mode the pool's own header warns about — AOSP has historically serialised
412
- * every {@code PdfRenderer} native call behind a process-wide PDFium lock, and
413
- * if that lock is present on this build the parallel row will match the serial
414
- * row and no amount of controller work will change it.
415
- *
416
- * N = 6 exceeds {@link PdfRendererPool#MAX_POOL_SIZE}, deliberately: the field
417
- * report's epoch is 6 pieces, so the honest question is what 6 pieces cost with
418
- * the pool as it is actually capped, not with a hypothetical pool of 6.
419
- */
420
- @Test
421
- public void poolConcurrencyScalingAtProbeScale() throws Exception {
422
- File pdf = fixture();
423
- Log.i(
424
- TAG,
425
- "CONCURRENCY fixture="
426
- + (usingRealPdf() ? "REAL " + REAL_PDF : "SYNTHETIC 6-page letter")
427
- + " tile="
428
- + TILE_PX
429
- + "px scale="
430
- + PROBE_SCALE
431
- + " MAX_POOL_SIZE="
432
- + PdfRendererPool.MAX_POOL_SIZE
433
- + " cores="
434
- + Runtime.getRuntime().availableProcessors());
435
-
436
- for (int n : new int[] {2, 3, 4, 6}) {
437
- List<RasterRequest> pieces = epochPieces(n, 0);
438
- long serial = bestOfFive(() -> renderSerially(pdf, pieces));
439
- int poolSize = Math.min(n, PdfRendererPool.MAX_POOL_SIZE);
440
- long parallel = bestOfFive(() -> renderInParallel(pdf, pieces, n, poolSize));
441
- Log.i(
442
- TAG,
443
- String.format(
444
- "CONCURRENCY n=%d serial=%5d ms (%6.1f ms/tile) parallel(pool=%d,threads=%d)"
445
- + "=%5d ms (%6.1f ms/tile) speedup=%.2fx",
446
- n,
447
- serial,
448
- serial / (double) n,
449
- poolSize,
450
- n,
451
- parallel,
452
- parallel / (double) n,
453
- serial / (double) Math.max(1L, parallel)));
454
- }
455
- }
456
-
457
- private interface Run {
458
- long runMs() throws Exception;
459
- }
460
-
461
- /**
462
- * BEST of five, not median.
463
- *
464
- * The opposite of what {@link PdfCanvasDeviceTimingTest} wants for a cadence
465
- * constant, and for the opposite reason: this test is a RATIO of two runs, and
466
- * a thermal or scheduler stall lands in whichever run it happens to hit and
467
- * moves the ratio in a direction that means nothing. The floor of each side is
468
- * the number least polluted by the phone doing something else — and the user is
469
- * actively using this one, so that is not a hypothetical.
470
- */
471
- private static long bestOfFive(Run run) throws Exception {
472
- long best = Long.MAX_VALUE;
473
- for (int i = 0; i < 5; i++) {
474
- best = Math.min(best, run.runMs());
475
- }
476
- return best;
477
- }
478
-
479
- /**
480
- * The Java-side work of ONE tile as {@code PdfCanvasModule} actually performs
481
- * it: the raster, and then the base64 encode of its pixels on the same worker
482
- * thread, before the promise resolves.
483
- *
484
- * Including the encode is not padding. It is 75-80% of the Java cost of a
485
- * 1024 px tile (see {@link #phaseBreakdownOfOneTileAtProbeScale}), it sits in
486
- * the serial chain the user waits on, and it is exactly as parallelisable as
487
- * the render — so a concurrency measurement that left it out would answer a
488
- * question nobody asked.
489
- */
490
- private static void renderAndEncode(PdfCanvasRasterizer r, RasterRequest request)
491
- throws PdfRasterException {
492
- RasterPixels pixels = r.render(request, null);
493
- // The result is used, so neither the encode nor the raster can be optimised
494
- // away — and the length is the payload the bridge is handed.
495
- if (Base64.encodeToString(pixels.bytes, Base64.NO_WRAP).isEmpty()) {
496
- throw new AssertionError("empty base64 for " + request);
497
- }
498
- }
499
-
500
- private static long renderSerially(File pdf, List<RasterRequest> pieces) throws Exception {
501
- PdfCanvasRasterizer r = PdfCanvasRasterizer.openFile(pdf, null, 1);
502
- try {
503
- warm(r, pieces, 1);
504
- long t0 = System.nanoTime();
505
- for (RasterRequest request : pieces) {
506
- renderAndEncode(r, request);
507
- }
508
- return (System.nanoTime() - t0) / 1_000_000L;
509
- } finally {
510
- r.close();
511
- }
512
- }
513
-
514
- private static long renderInParallel(
515
- File pdf, List<RasterRequest> pieces, int threads, int poolSize) throws Exception {
516
- PdfCanvasRasterizer r = PdfCanvasRasterizer.openFile(pdf, null, poolSize);
517
- assertEquals("pool size", poolSize, r.poolSize());
518
- ExecutorService exec = Executors.newFixedThreadPool(threads);
519
- CountDownLatch ready = new CountDownLatch(threads);
520
- CountDownLatch go = new CountDownLatch(1);
521
- CountDownLatch done = new CountDownLatch(threads);
522
- AtomicReference<Throwable> failure = new AtomicReference<>();
523
- long t0;
524
- try {
525
- // Warms EVERY renderer in the pool before the clock starts. Without this
526
- // the parallel run pays for N independent page parses that the serial run
527
- // paid once, and the comparison measures the warm-up rather than the lock.
528
- warm(r, pieces, poolSize);
529
-
530
- for (int t = 0; t < threads; t++) {
531
- final RasterRequest request = pieces.get(t % pieces.size());
532
- exec.execute(
533
- () -> {
534
- try {
535
- ready.countDown();
536
- go.await();
537
- renderAndEncode(r, request);
538
- } catch (Throwable e) {
539
- failure.compareAndSet(null, e);
540
- } finally {
541
- done.countDown();
542
- }
543
- });
544
- }
545
- // Thread CREATION happens before the clock starts; only the wake-up from
546
- // the latch is inside it. Without this barrier the first round of a
547
- // 6-thread run charges the parallel side for spawning threads that the
548
- // serial side never spawns.
549
- assertTrue("threads did not start in 30s", ready.await(30, TimeUnit.SECONDS));
550
- t0 = System.nanoTime();
551
- go.countDown();
552
- assertTrue("threads did not finish in 120s", done.await(120, TimeUnit.SECONDS));
553
- } finally {
554
- exec.shutdownNow();
555
- r.close();
556
- }
557
- Throwable f = failure.get();
558
- if (f != null) {
559
- throw new AssertionError("a parallel render failed: " + f, f);
560
- }
561
- return (System.nanoTime() - t0) / 1_000_000L;
562
- }
563
-
564
- /**
565
- * Touches every renderer in the pool, by keeping {@code poolSize} renders in
566
- * flight at once — a serial loop would warm renderer #0 and nothing else,
567
- * because the pool hands back the one it just took.
568
- */
569
- private static void warm(PdfCanvasRasterizer r, List<RasterRequest> pieces, int poolSize)
570
- throws Exception {
571
- if (poolSize <= 1) {
572
- r.render(pieces.get(0), null);
573
- return;
574
- }
575
- ExecutorService exec = Executors.newFixedThreadPool(poolSize);
576
- CountDownLatch done = new CountDownLatch(poolSize);
577
- AtomicReference<Throwable> failure = new AtomicReference<>();
578
- try {
579
- for (int i = 0; i < poolSize; i++) {
580
- final RasterRequest request = pieces.get(i % pieces.size());
581
- exec.execute(
582
- () -> {
583
- try {
584
- r.render(request, null);
585
- } catch (Throwable e) {
586
- failure.compareAndSet(null, e);
587
- } finally {
588
- done.countDown();
589
- }
590
- });
591
- }
592
- assertTrue("warm-up did not finish in 120s", done.await(120, TimeUnit.SECONDS));
593
- } finally {
594
- exec.shutdownNow();
595
- }
596
- Throwable f = failure.get();
597
- if (f != null) {
598
- throw new AssertionError("warm-up render failed: " + f, f);
599
- }
600
- }
601
-
602
- /**
603
- * WHY the parallel row of {@link #poolConcurrencyScalingAtProbeScale} comes out
604
- * SLOWER, phase by phase.
605
- *
606
- * A wall-clock ratio says the pool does not scale; it cannot say whether that
607
- * is a PDFium lock, the bitmap allocator, memory bandwidth, or nothing but
608
- * thread wake-up skew. So this renders the same tiles twice — once serially on
609
- * one renderer, once with one renderer per thread — through the SAME phase
610
- * harness, and prints both breakdowns side by side. Whichever phase inflates is
611
- * the mechanism.
612
- *
613
- * The base64 encode is included on both sides because it runs on the render
614
- * worker in {@code PdfCanvasModule}, so it is part of what parallelising the
615
- * controller's loop would actually spread out.
616
- *
617
- * {@code wake} is the gap between the starting gun and a thread's first
618
- * instruction, recorded so that "the threads never overlapped" cannot be
619
- * mistaken for "the work does not parallelise".
620
- */
621
- @Test
622
- public void phaseBreakdownUnderConcurrency() throws Exception {
623
- File pdf = fixture();
624
- int n = 4;
625
- List<RasterRequest> pieces = epochPieces(n, 0);
626
-
627
- List<ParcelFileDescriptor> fds = new ArrayList<>();
628
- List<PdfRenderer> renderers = new ArrayList<>();
629
- try {
630
- for (int i = 0; i < n; i++) {
631
- ParcelFileDescriptor pfd =
632
- ParcelFileDescriptor.open(pdf, ParcelFileDescriptor.MODE_READ_ONLY);
633
- fds.add(pfd);
634
- renderers.add(new PdfRenderer(pfd));
635
- }
636
-
637
- // Warm every renderer on its own tile, on its own thread, so neither run
638
- // pays for a first-touch page parse inside the timed section.
639
- runAcross(renderers, pieces, n);
640
-
641
- Phases[] serial = new Phases[n];
642
- long serialStart = System.nanoTime();
643
- for (int i = 0; i < n; i++) {
644
- serial[i] = renderPhases(renderers.get(0), pieces.get(i));
645
- serial[i].bytes = null;
646
- }
647
- long serialMs = (System.nanoTime() - serialStart) / 1_000_000L;
648
-
649
- Concurrent concurrent = runAcross(renderers, pieces, n);
650
-
651
- Log.i(
652
- TAG,
653
- String.format(
654
- "CONC-PHASES n=%d tile=%dpx@%.2fx serial wall=%d ms parallel wall=%d ms"
655
- + " wake(max)=%.2f ms",
656
- n, TILE_PX, PROBE_SCALE, serialMs, concurrent.wallMs, concurrent.maxWakeNs / 1e6));
657
- comparePhase("1 PdfRenderer.render", serial, concurrent.phases, p -> p.render);
658
- comparePhase("2 Bitmap alloc+zero ", serial, concurrent.phases, p -> p.alloc);
659
- comparePhase("2b eraseColor(WHITE)", serial, concurrent.phases, p -> p.erase);
660
- comparePhase("3 extract to byte[] ", serial, concurrent.phases, p -> p.extract);
661
- comparePhase("4 base64 encode ", serial, concurrent.phases, p -> p.base64);
662
- comparePhase("- openPage ", serial, concurrent.phases, p -> p.openPage);
663
- comparePhase("- recycle+close ", serial, concurrent.phases, p -> p.teardown);
664
- comparePhase("= TOTAL ", serial, concurrent.phases, p -> p.total);
665
- } finally {
666
- for (PdfRenderer renderer : renderers) {
667
- renderer.close();
668
- }
669
- for (ParcelFileDescriptor pfd : fds) {
670
- try {
671
- pfd.close();
672
- } catch (IOException ignored) {
673
- // PdfRenderer.close() already closed it; nothing to salvage.
674
- }
675
- }
676
- }
677
- }
678
-
679
- private static final class Concurrent {
680
- Phases[] phases;
681
- long wallMs;
682
- long maxWakeNs;
683
- }
684
-
685
- /** One tile per thread, one renderer per thread, released by a single latch. */
686
- private static Concurrent runAcross(
687
- List<PdfRenderer> renderers, List<RasterRequest> pieces, int n) throws Exception {
688
- Phases[] out = new Phases[n];
689
- long[] wake = new long[n];
690
- ExecutorService exec = Executors.newFixedThreadPool(n);
691
- CountDownLatch ready = new CountDownLatch(n);
692
- CountDownLatch go = new CountDownLatch(1);
693
- CountDownLatch done = new CountDownLatch(n);
694
- AtomicReference<Throwable> failure = new AtomicReference<>();
695
- long[] released = new long[1];
696
- try {
697
- for (int i = 0; i < n; i++) {
698
- final int index = i;
699
- exec.execute(
700
- () -> {
701
- try {
702
- ready.countDown();
703
- go.await();
704
- wake[index] = System.nanoTime() - released[0];
705
- Phases p = renderPhases(renderers.get(index), pieces.get(index));
706
- p.bytes = null;
707
- out[index] = p;
708
- } catch (Throwable e) {
709
- failure.compareAndSet(null, e);
710
- } finally {
711
- done.countDown();
712
- }
713
- });
714
- }
715
- // Every thread is parked on `go` before the clock starts, so thread
716
- // CREATION is outside the measurement and only the wake-up is inside it.
717
- assertTrue("threads did not start in 30s", ready.await(30, TimeUnit.SECONDS));
718
- long t0 = System.nanoTime();
719
- released[0] = t0;
720
- go.countDown();
721
- assertTrue("threads did not finish in 120s", done.await(120, TimeUnit.SECONDS));
722
- Concurrent result = new Concurrent();
723
- result.wallMs = (System.nanoTime() - t0) / 1_000_000L;
724
- result.phases = out;
725
- for (long w : wake) {
726
- result.maxWakeNs = Math.max(result.maxWakeNs, w);
727
- }
728
- Throwable f = failure.get();
729
- if (f != null) {
730
- throw new AssertionError("a concurrent render failed: " + f, f);
731
- }
732
- return result;
733
- } finally {
734
- exec.shutdownNow();
735
- }
736
- }
737
-
738
- private interface PhasePick {
739
- long of(Phases p);
740
- }
741
-
742
- private static void comparePhase(
743
- String label, Phases[] serial, Phases[] parallel, PhasePick pick) {
744
- Log.i(
745
- TAG,
746
- String.format(
747
- " %s serial p50=%8.3f ms max=%8.3f | parallel p50=%8.3f ms max=%8.3f"
748
- + " inflation=%.2fx",
749
- label,
750
- median(serial, pick) / 1e6,
751
- max(serial, pick) / 1e6,
752
- median(parallel, pick) / 1e6,
753
- max(parallel, pick) / 1e6,
754
- median(parallel, pick) / Math.max(1.0, median(serial, pick))));
755
- }
756
-
757
- private static double median(Phases[] samples, PhasePick pick) {
758
- long[] values = new long[samples.length];
759
- for (int i = 0; i < samples.length; i++) {
760
- values[i] = samples[i] == null ? 0L : pick.of(samples[i]);
761
- }
762
- Arrays.sort(values);
763
- return percentile(values, 50);
764
- }
765
-
766
- private static double max(Phases[] samples, PhasePick pick) {
767
- long best = 0;
768
- for (Phases p : samples) {
769
- if (p != null) {
770
- best = Math.max(best, pick.of(p));
771
- }
772
- }
773
- return best;
774
- }
775
-
776
- /* ================================================================ *
777
- * 2b. WHY it does not scale — PDFium versus everything else
778
- * ================================================================ */
779
-
780
- /**
781
- * THE DECISIVE EXPERIMENT. Runs the two halves of a tile SEPARATELY across N
782
- * threads and asks which one refuses to parallelise.
783
- *
784
- * A — PDFium only: {@code openPage}, {@code render} into a bitmap allocated
785
- * once per thread up front, {@code close}. No allocation, no copy, no
786
- * encode inside the timed region.
787
- * B — pure Java only: base64 of a 4 MiB {@code byte[]} allocated up front.
788
- * Same byte volume as one 1024 px tile, no native PDF code anywhere.
789
- *
790
- * If A's per-operation cost inflates by roughly N while its wall clock stays
791
- * flat, every thread was queueing behind one lock — which is what
792
- * {@link PdfRendererPool}'s own header says AOSP does, and the difference
793
- * between "the pool buys throughput" and "the pool buys correctness only".
794
- * B is the control: it is bounded by memory bandwidth and nothing else, so
795
- * whatever inflation it shows is the floor that any parallel scheme on this
796
- * phone pays regardless.
797
- *
798
- * Reported, never asserted — the presence of the lock is a property of this
799
- * platform build, and a test that failed when a future Android removed it
800
- * would be a test that punishes good news.
801
- *
802
- * READ THE RATIOS, NOT THE ABSOLUTES. Each round here is one operation per
803
- * thread with executor churn either side, so the CPU governor never ramps and
804
- * the scheduler is free to park the work on a little core — measured per-op
805
- * costs come out 2-4x the tight-loop figures in
806
- * {@link #phaseBreakdownOfOneTileAtProbeScale} for that reason. A and B are
807
- * measured identically in the same run, so how each one's THROUGHPUT moves with
808
- * N is sound even though neither absolute is.
809
- */
810
- @Test
811
- public void whichPhasesSerialiseUnderConcurrency() throws Exception {
812
- File pdf = fixture();
813
- int maxThreads = 4;
814
- RasterRequest piece = epochPieces(1, 0).get(0);
815
- int px = piece.pixelWidth();
816
-
817
- List<ParcelFileDescriptor> fds = new ArrayList<>();
818
- List<PdfRenderer> renderers = new ArrayList<>();
819
- List<Bitmap> bitmaps = new ArrayList<>();
820
- List<byte[]> buffers = new ArrayList<>();
821
- try {
822
- for (int i = 0; i < maxThreads; i++) {
823
- ParcelFileDescriptor pfd =
824
- ParcelFileDescriptor.open(pdf, ParcelFileDescriptor.MODE_READ_ONLY);
825
- fds.add(pfd);
826
- renderers.add(new PdfRenderer(pfd));
827
- bitmaps.add(Bitmap.createBitmap(px, px, Bitmap.Config.ARGB_8888));
828
- buffers.add(new byte[px * px * 4]);
829
- }
830
-
831
- Matrix matrix = new Matrix();
832
- matrix.setScale(piece.scale, piece.scale);
833
- matrix.postTranslate(-piece.docX * piece.scale, -piece.docY * piece.scale);
834
-
835
- ThreadOp openOnly =
836
- thread -> {
837
- long t0 = System.nanoTime();
838
- renderers.get(thread).openPage(piece.page).close();
839
- return System.nanoTime() - t0;
840
- };
841
- ThreadOp pdfium =
842
- thread -> {
843
- long t0 = System.nanoTime();
844
- PdfRenderer.Page page = renderers.get(thread).openPage(piece.page);
845
- page.render(
846
- bitmaps.get(thread), null, matrix, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY);
847
- page.close();
848
- return System.nanoTime() - t0;
849
- };
850
- ThreadOp pureJava =
851
- thread -> {
852
- long t0 = System.nanoTime();
853
- if (Base64.encodeToString(buffers.get(thread), Base64.NO_WRAP).isEmpty()) {
854
- throw new AssertionError("empty base64");
855
- }
856
- return System.nanoTime() - t0;
857
- };
858
-
859
- Log.i(
860
- TAG,
861
- "SERIALISATION fixture="
862
- + (usingRealPdf() ? "REAL " + REAL_PDF : "SYNTHETIC 6-page letter")
863
- + " tile="
864
- + px
865
- + "px@"
866
- + PROBE_SCALE
867
- + "x A=PDFium(openPage+render+close) B=pure-Java(base64 of "
868
- + (px * px * 4)
869
- + " B)");
870
- // Warm every renderer on its own thread first, so no probe round pays for
871
- // a first-touch page parse and the n=1 baselines are honest.
872
- probe("warm-up ", maxThreads, pdfium);
873
- for (int n : new int[] {1, 2, 4}) {
874
- probe("A0 openPg", n, openOnly);
875
- probe("A PDFium ", n, pdfium);
876
- probe("B Java ", n, pureJava);
877
- }
878
- } finally {
879
- for (Bitmap bitmap : bitmaps) {
880
- bitmap.recycle();
881
- }
882
- for (PdfRenderer renderer : renderers) {
883
- renderer.close();
884
- }
885
- for (ParcelFileDescriptor pfd : fds) {
886
- try {
887
- pfd.close();
888
- } catch (IOException ignored) {
889
- // PdfRenderer.close() already closed it.
890
- }
891
- }
892
- }
893
- }
894
-
895
- private interface ThreadOp {
896
- /** @return nanoseconds spent inside the operation itself, on this thread. */
897
- long run(int thread) throws Exception;
898
- }
899
-
900
- /** Best of five, for the reason {@link #bestOfFive} gives. */
901
- private static void probe(String label, int n, ThreadOp op) throws Exception {
902
- long bestWall = Long.MAX_VALUE;
903
- long bestMedianOp = Long.MAX_VALUE;
904
- for (int round = 0; round < 5; round++) {
905
- long[] ops = new long[n];
906
- ExecutorService exec = Executors.newFixedThreadPool(n);
907
- CountDownLatch ready = new CountDownLatch(n);
908
- CountDownLatch go = new CountDownLatch(1);
909
- CountDownLatch done = new CountDownLatch(n);
910
- AtomicReference<Throwable> failure = new AtomicReference<>();
911
- long wall;
912
- try {
913
- for (int i = 0; i < n; i++) {
914
- final int index = i;
915
- exec.execute(
916
- () -> {
917
- try {
918
- ready.countDown();
919
- go.await();
920
- ops[index] = op.run(index);
921
- } catch (Throwable e) {
922
- failure.compareAndSet(null, e);
923
- } finally {
924
- done.countDown();
925
- }
926
- });
927
- }
928
- assertTrue("threads did not start in 30s", ready.await(30, TimeUnit.SECONDS));
929
- long t0 = System.nanoTime();
930
- go.countDown();
931
- assertTrue("threads did not finish in 120s", done.await(120, TimeUnit.SECONDS));
932
- wall = System.nanoTime() - t0;
933
- } finally {
934
- exec.shutdownNow();
935
- }
936
- Throwable f = failure.get();
937
- if (f != null) {
938
- throw new AssertionError("probe " + label + " failed: " + f, f);
939
- }
940
- Arrays.sort(ops);
941
- bestWall = Math.min(bestWall, wall);
942
- bestMedianOp = Math.min(bestMedianOp, percentile(ops, 50));
943
- }
944
- Log.i(
945
- TAG,
946
- String.format(
947
- " %s n=%d wall=%8.3f ms per-op(p50)=%8.3f ms ops/ms=%.3f",
948
- label, n, bestWall / 1e6, bestMedianOp / 1e6, n / (bestWall / 1e6)));
949
- }
950
-
951
- /* ================================================================ *
952
- * 3. Whole-epoch wall clock — what the user actually waits for
953
- * ================================================================ */
954
-
955
- /**
956
- * The field report's epoch end to end: 6 x 1024 px pieces at 131.25x, rendered
957
- * the way the controller renders them today (strictly one at a time through a
958
- * pool of ONE, which is what a single-threaded {@code await} loop reduces the
959
- * pool to) against the same six across four threads.
960
- *
961
- * Reported with the base64 encode included, because that runs on the same
962
- * worker as the render in {@code PdfCanvasModule} and is therefore part of the
963
- * serial chain the user waits on.
964
- */
965
- @Test
966
- public void wholeEpochWallClock() throws Exception {
967
- File pdf = fixture();
968
- List<RasterRequest> pieces = epochPieces(6, 0);
969
-
970
- long serial = bestOfFive(() -> renderSerially(pdf, pieces));
971
-
972
- PdfCanvasRasterizer r = PdfCanvasRasterizer.openFile(pdf, null, 4);
973
- long encodeNs = 0;
974
- int rawBytes = 0;
975
- long base64Chars = 0;
976
- try {
977
- warm(r, pieces, 4);
978
- for (RasterRequest request : pieces) {
979
- RasterPixels px = r.render(request, null);
980
- long t0 = System.nanoTime();
981
- String encoded = Base64.encodeToString(px.bytes, Base64.NO_WRAP);
982
- encodeNs += System.nanoTime() - t0;
983
- rawBytes += px.bytes.length;
984
- base64Chars += encoded.length();
985
- }
986
- } finally {
987
- r.close();
988
- }
989
-
990
- Log.i(
991
- TAG,
992
- String.format(
993
- "EPOCH 6 x %dpx@%.2fx serial render+encode=%d ms of which encode=%.1f ms"
994
- + " payload raw=%d B base64=%d chars (%.2f MiB / %.2f MiB)",
995
- TILE_PX,
996
- PROBE_SCALE,
997
- serial,
998
- encodeNs / 1e6,
999
- rawBytes,
1000
- base64Chars,
1001
- rawBytes / (1024.0 * 1024.0),
1002
- base64Chars / (1024.0 * 1024.0)));
1003
- }
1004
-
1005
- /* ================================================================ *
1006
- * 5. The controller's lane scheduler, before and after
1007
- * ================================================================ */
1008
-
1009
- /**
1010
- * BEFORE vs AFTER for the change that replaced the controller's sequential
1011
- * {@code for ... await handle.render(...)} loop with
1012
- * {@code maxConcurrentRenders} lanes.
1013
- *
1014
- * This is the same shape as {@link #poolConcurrencyScalingAtProbeScale} with
1015
- * one difference that matters: that test gives every thread its own piece,
1016
- * which is a fan-out, whereas the controller runs LANES — N workers pulling
1017
- * from one shared queue of plan items until it is empty. With 6 pieces and 2
1018
- * lanes those are not the same schedule, and the shipped code is the lane one,
1019
- * so it is the one measured here.
1020
- *
1021
- * WHAT THIS NUMBER IS, AND — MORE IMPORTANTLY — WHAT IT IS NOT. It is the
1022
- * whole JAVA side of a 6-piece detail epoch: {@code openPage}, the bitmap, the
1023
- * white fill, {@code PdfRenderer.render}, the copy to a {@code byte[]}, and the
1024
- * base64 encode, exactly as {@code PdfCanvasModule} performs them on its render
1025
- * executor. It is NOT the epoch the user waits for. The field probe timed one
1026
- * tile at 595 ms while the whole Java side of that tile is 27.5 ms p50, so
1027
- * roughly 95% of what the user waits for is the transport — a 5,592,408-char
1028
- * base64 string crossing the bridge and being decoded one {@code charCodeAt} at
1029
- * a time on the JS thread — and none of that exists in a process with no React
1030
- * Native in it. Lanes cannot overlap the JS half with itself; the JS thread is
1031
- * one thread. So the honest end-to-end expectation is the SMALL number this
1032
- * measures, not a multiple of it, and the 4x has to come out of the transport.
1033
- */
1034
- @Test
1035
- public void laneSchedulerEpochWallClock() throws Exception {
1036
- File pdf = fixture();
1037
- List<RasterRequest> pieces = epochPieces(6, 0);
1038
-
1039
- Log.i(
1040
- TAG,
1041
- "LANES fixture="
1042
- + (usingRealPdf() ? "REAL " + REAL_PDF : "SYNTHETIC 6-page letter")
1043
- + " pieces="
1044
- + pieces.size()
1045
- + " tile="
1046
- + TILE_PX
1047
- + "px scale="
1048
- + PROBE_SCALE);
1049
-
1050
- long before = bestOfFive(() -> renderInLanes(pdf, pieces, 1));
1051
- for (int lanes : new int[] {2, 3, 4}) {
1052
- long after = bestOfFive(() -> renderInLanes(pdf, pieces, lanes));
1053
- Log.i(
1054
- TAG,
1055
- String.format(
1056
- "LANES before(1 lane)=%4d ms after(%d lanes)=%4d ms speedup=%.2fx"
1057
- + " [JAVA SIDE ONLY — see javadoc]",
1058
- before, lanes, after, before / (double) Math.max(1L, after)));
1059
- }
1060
-
1061
- laneScheduleOnTheJsiTransport(pdf, pieces);
1062
- }
1063
-
1064
- /**
1065
- * THE SAME LANE SCHEDULE WITH THE BASE64 ENCODE DELETED, which is what the JSI
1066
- * transport actually ships.
1067
- *
1068
- * <h3>Why the rows above do not transfer</h3>
1069
- *
1070
- * Every lane win in the base64 block comes from spreading the ENCODE — 75-80%
1071
- * of the Java cost of a 1024 px tile, and the one phase in it that is pure Java
1072
- * and therefore genuinely parallel (see
1073
- * {@link #whichPhasesSerialiseUnderConcurrency}: {@code openPage} and
1074
- * {@code render} are flat-to-convoying across threads while a pure-Java control
1075
- * scales 2.8x). On the JSI path that phase is gone: the worker moves the raster
1076
- * into C++-owned memory and the promise resolves with a slot number. So the
1077
- * parallel fraction of a tile collapses, and the lane question has to be asked
1078
- * again against the work that is left.
1079
- *
1080
- * <h3>TWO ROWS, and the difference between them is the change under test</h3>
1081
- *
1082
- * {@code LANES-JSI} is the two-copy store: {@code render()} produces a
1083
- * {@code byte[]} and {@link PdfCanvasPixelBridge#nativeStore} copies it into
1084
- * the slot. {@code LANES-JSI-DIRECT} is
1085
- * {@link PdfCanvasRasterizer#renderToNativeSlot}, which fills the slot from the
1086
- * {@code Bitmap} and never allocates the array at all — one full-raster ART
1087
- * allocation and one full-raster memcpy less, per tile.
1088
- *
1089
- * <p>THE DELTA IS THE POINT, not the absolute numbers, and it is a per-tile
1090
- * delta on the render executor, which
1091
- * {@link #whichPhasesSerialiseUnderConcurrency} shows is the serialised
1092
- * bottleneck — so it does not spread across lanes and does not shrink as lanes
1093
- * are added. Read {@code ms/tile at p50} between the two blocks at MATCHED lane
1094
- * counts; comparing a 4-lane DIRECT row against a 1-lane JSI row measures the
1095
- * lane count instead.
1096
- *
1097
- * <p>Both are measured in the SAME interleaved schedule for the reason the next
1098
- * paragraph gives: run one block to completion and then the other, and the
1099
- * second one is charged for the first one heating the phone.
1100
- *
1101
- * <h3>Why the lane counts are interleaved rather than measured in a row</h3>
1102
- *
1103
- * The phone heats up and the governor moves, so measuring 1 lane five times and
1104
- * then 4 lanes five times charges the later block for the earlier one. Each
1105
- * round runs every lane count once, and the round number rotates which goes
1106
- * first, so no lane count is systematically on a cold or a hot phone.
1107
- *
1108
- * <h3>Why min AND p50 AND max</h3>
1109
- *
1110
- * A single best-of-N cannot say whether two lane counts genuinely differ or
1111
- * merely sit inside each other spread, and that is exactly the verdict this
1112
- * measurement exists to support.
1113
- */
1114
- private static void laneScheduleOnTheJsiTransport(File pdf, List<RasterRequest> pieces)
1115
- throws Exception {
1116
- assertTrue(
1117
- "libpdfcanvasjsi.so did not load, so there is no JSI path to measure.",
1118
- PdfCanvasPixelBridge.isLoaded());
1119
-
1120
- final int[] laneCounts = {1, 2, 3, 4};
1121
- final int rounds = 21;
1122
- long[][] store = new long[laneCounts.length][rounds];
1123
- long[][] direct = new long[laneCounts.length][rounds];
1124
- for (int round = 0; round < rounds; round++) {
1125
- for (int k = 0; k < laneCounts.length; k++) {
1126
- int idx = (k + round) % laneCounts.length;
1127
- // Both fills at every lane count in every round, and which one goes
1128
- // first alternates. Otherwise whichever is second is systematically
1129
- // measured on a hotter phone, which is a bias the size of the effect.
1130
- if ((round & 1) == 0) {
1131
- store[idx][round] = renderInLanesOverJsi(pdf, pieces, laneCounts[idx], false);
1132
- direct[idx][round] = renderInLanesOverJsi(pdf, pieces, laneCounts[idx], true);
1133
- } else {
1134
- direct[idx][round] = renderInLanesOverJsi(pdf, pieces, laneCounts[idx], true);
1135
- store[idx][round] = renderInLanesOverJsi(pdf, pieces, laneCounts[idx], false);
1136
- }
1137
- }
1138
- }
1139
-
1140
- reportLaneBlock("LANES-JSI", store, laneCounts, pieces.size());
1141
- reportLaneBlock("LANES-JSI-DIRECT", direct, laneCounts, pieces.size());
1142
-
1143
- // The number the decision rests on: same lane count, both fills, per tile.
1144
- for (int k = 0; k < laneCounts.length; k++) {
1145
- long[] a = store[k].clone();
1146
- long[] b = direct[k].clone();
1147
- Arrays.sort(a);
1148
- Arrays.sort(b);
1149
- double perTileStore = percentile(a, 50) / (double) pieces.size();
1150
- double perTileDirect = percentile(b, 50) / (double) pieces.size();
1151
- Log.i(
1152
- TAG,
1153
- String.format(
1154
- "LANES-JSI-DELTA lanes=%d store=%5.2f ms/tile direct=%5.2f ms/tile"
1155
- + " saved=%5.2f ms/tile (%+.1f%%) [p50, matched lanes]",
1156
- laneCounts[k],
1157
- perTileStore,
1158
- perTileDirect,
1159
- perTileStore - perTileDirect,
1160
- perTileStore == 0 ? 0 : -100.0 * (perTileStore - perTileDirect) / perTileStore));
1161
- }
1162
- }
1163
-
1164
- private static void reportLaneBlock(
1165
- String label, long[][] runs, int[] laneCounts, int pieceCount) {
1166
- long[] baseline = runs[0].clone();
1167
- Arrays.sort(baseline);
1168
- for (int k = 0; k < laneCounts.length; k++) {
1169
- long[] sorted = runs[k].clone();
1170
- Arrays.sort(sorted);
1171
- Log.i(
1172
- TAG,
1173
- String.format(
1174
- "%s lanes=%d min=%4d ms p50=%4d ms max=%4d ms"
1175
- + " mean=%6.1f ms (%5.1f ms/tile at p50)"
1176
- + " vs 1 lane: min=%.2fx p50=%.2fx mean=%.2fx",
1177
- label,
1178
- laneCounts[k],
1179
- sorted[0],
1180
- percentile(sorted, 50),
1181
- sorted[sorted.length - 1],
1182
- mean(sorted),
1183
- percentile(sorted, 50) / (double) pieceCount,
1184
- baseline[0] / (double) Math.max(1L, sorted[0]),
1185
- percentile(baseline, 50) / (double) Math.max(1L, percentile(sorted, 50)),
1186
- mean(baseline) / Math.max(1.0, mean(sorted))));
1187
- }
1188
- }
1189
-
1190
- /**
1191
- * The controller's schedule, in Java: {@code lanes} workers pulling plan items
1192
- * off one shared cursor until it is empty, each doing the full production
1193
- * per-tile work. A pool of {@code min(lanes, MAX_POOL_SIZE)}, because that is
1194
- * what the rasterizer now declares as {@code maxConcurrentRenders}.
1195
- */
1196
- private static long renderInLanes(File pdf, List<RasterRequest> pieces, int lanes)
1197
- throws Exception {
1198
- int poolSize = Math.min(lanes, PdfRendererPool.MAX_POOL_SIZE);
1199
- PdfCanvasRasterizer r = PdfCanvasRasterizer.openFile(pdf, null, poolSize);
1200
- ExecutorService exec = Executors.newFixedThreadPool(lanes);
1201
- CountDownLatch ready = new CountDownLatch(lanes);
1202
- CountDownLatch go = new CountDownLatch(1);
1203
- CountDownLatch done = new CountDownLatch(lanes);
1204
- AtomicInteger cursor = new AtomicInteger(0);
1205
- AtomicReference<Throwable> failure = new AtomicReference<>();
1206
- long t0;
1207
- try {
1208
- warm(r, pieces, poolSize);
1209
- for (int i = 0; i < lanes; i++) {
1210
- exec.execute(
1211
- () -> {
1212
- try {
1213
- ready.countDown();
1214
- go.await();
1215
- for (; ; ) {
1216
- int slot = cursor.getAndIncrement();
1217
- if (slot >= pieces.size()) {
1218
- return;
1219
- }
1220
- renderAndEncode(r, pieces.get(slot));
1221
- }
1222
- } catch (Throwable e) {
1223
- failure.compareAndSet(null, e);
1224
- } finally {
1225
- done.countDown();
1226
- }
1227
- });
1228
- }
1229
- assertTrue("lanes did not start in 30s", ready.await(30, TimeUnit.SECONDS));
1230
- t0 = System.nanoTime();
1231
- go.countDown();
1232
- assertTrue("lanes did not finish in 120s", done.await(120, TimeUnit.SECONDS));
1233
- } finally {
1234
- exec.shutdownNow();
1235
- r.close();
1236
- }
1237
- Throwable f = failure.get();
1238
- if (f != null) {
1239
- throw new AssertionError("a lane failed: " + f, f);
1240
- }
1241
- return (System.nanoTime() - t0) / 1_000_000L;
1242
- }
1243
-
1244
- /**
1245
- * The identical lane schedule with the production JSI per-tile work.
1246
- *
1247
- * {@code PdfCanvasModule.render} on the fast transport does exactly two things
1248
- * per tile on its render worker: the raster, and
1249
- * {@code PdfCanvasPixelBridge.nativeStore} of the finished {@code byte[]} into
1250
- * C++-owned memory. There is no string and no encode. Both are inside the clock
1251
- * because both are on the worker.
1252
- *
1253
- * WHAT IS DELIBERATELY OUTSIDE THE CLOCK: freeing the slots. In production the
1254
- * buffer is handed to JS as an ArrayBuffer and freed when that is collected, so
1255
- * the render worker never pays for the free — charging this measurement for it
1256
- * would invent a cost the shipped path does not have. The slots are drained
1257
- * after the clock stops instead, which is also what stops a run leaving 24 MiB
1258
- * of native memory behind for the next one.
1259
- */
1260
- private static long renderInLanesOverJsi(
1261
- File pdf, List<RasterRequest> pieces, int lanes, boolean direct) throws Exception {
1262
- int poolSize = Math.min(lanes, PdfRendererPool.MAX_POOL_SIZE);
1263
- PdfCanvasRasterizer r = PdfCanvasRasterizer.openFile(pdf, null, poolSize);
1264
- ExecutorService exec = Executors.newFixedThreadPool(lanes);
1265
- CountDownLatch ready = new CountDownLatch(lanes);
1266
- CountDownLatch go = new CountDownLatch(1);
1267
- CountDownLatch done = new CountDownLatch(lanes);
1268
- AtomicInteger cursor = new AtomicInteger(0);
1269
- AtomicReference<Throwable> failure = new AtomicReference<>();
1270
- // Every lane stores; the drain below happens on this thread once they are all
1271
- // finished, so the queue is the only shared mutable thing between them.
1272
- ConcurrentLinkedQueue<Long> parked = new ConcurrentLinkedQueue<>();
1273
- long elapsedMs;
1274
- long t0;
1275
- try {
1276
- warm(r, pieces, poolSize);
1277
- for (int i = 0; i < lanes; i++) {
1278
- exec.execute(
1279
- () -> {
1280
- try {
1281
- ready.countDown();
1282
- go.await();
1283
- for (; ; ) {
1284
- int slot = cursor.getAndIncrement();
1285
- if (slot >= pieces.size()) {
1286
- return;
1287
- }
1288
- if (direct) {
1289
- renderAndFillDirect(r, pieces.get(slot), parked);
1290
- } else {
1291
- renderAndStore(r, pieces.get(slot), parked);
1292
- }
1293
- }
1294
- } catch (Throwable e) {
1295
- failure.compareAndSet(null, e);
1296
- } finally {
1297
- done.countDown();
1298
- }
1299
- });
1300
- }
1301
- assertTrue("lanes did not start in 30s", ready.await(30, TimeUnit.SECONDS));
1302
- t0 = System.nanoTime();
1303
- go.countDown();
1304
- assertTrue("lanes did not finish in 120s", done.await(120, TimeUnit.SECONDS));
1305
- elapsedMs = (System.nanoTime() - t0) / 1_000_000L;
1306
- } finally {
1307
- exec.shutdownNow();
1308
- r.close();
1309
- for (Long slot : parked) {
1310
- PdfCanvasPixelBridge.nativeRelease(slot);
1311
- }
1312
- }
1313
- Throwable f = failure.get();
1314
- if (f != null) {
1315
- throw new AssertionError("a lane failed: " + f, f);
1316
- }
1317
- return elapsedMs;
1318
- }
1319
-
1320
- /**
1321
- * The Java-side work of ONE tile on the two-copy JSI store — what
1322
- * {@code PdfCanvasModule} did before the one-copy fill replaced it.
1323
- *
1324
- * The counterpart of {@link #renderAndEncode}, and the difference between the
1325
- * two IS the change being measured.
1326
- */
1327
- private static void renderAndStore(
1328
- PdfCanvasRasterizer r, RasterRequest request, ConcurrentLinkedQueue<Long> parked)
1329
- throws PdfRasterException {
1330
- RasterPixels pixels = r.render(request, null);
1331
- long slot = JSI_SLOT.getAndIncrement();
1332
- PdfCanvasPixelBridge.nativeStore(slot, pixels.bytes);
1333
- parked.add(slot);
1334
- }
1335
-
1336
- /**
1337
- * The Java-side work of ONE tile on the ONE-COPY fill, as
1338
- * {@code PdfCanvasModule.render} performs it today.
1339
- *
1340
- * The counterpart of {@link #renderAndStore}: identical except that the raster
1341
- * is written into the slot from the {@code Bitmap} instead of into a
1342
- * {@code byte[]} and then into the slot. Everything else — the request, the
1343
- * pool, the lane schedule, the deliberate exclusion of the free — is held
1344
- * constant, so the difference between the two rows is one full-raster ART
1345
- * allocation plus one full-raster memcpy and nothing else.
1346
- *
1347
- * A refusal is a FAILURE here, not a fallback. In production it degrades to
1348
- * base64 and the tile is still correct, but a measurement that quietly
1349
- * switched to the path it was comparing against would report the two rows as
1350
- * identical and call the change worthless.
1351
- */
1352
- private static void renderAndFillDirect(
1353
- PdfCanvasRasterizer r, RasterRequest request, ConcurrentLinkedQueue<Long> parked)
1354
- throws PdfRasterException {
1355
- long slot = JSI_SLOT.getAndIncrement();
1356
- RasterPixels pixels = r.renderToNativeSlot(request, null, slot);
1357
- if (!pixels.isInNativeSlot()) {
1358
- throw new AssertionError(
1359
- "the direct fill refused and fell back to the heap, so this sample is "
1360
- + "measuring the path it is supposed to be compared against");
1361
- }
1362
- parked.add(slot);
1363
- }
1364
-
1365
- /**
1366
- * Slot ids for this file, in a range of its own.
1367
- *
1368
- * {@code PdfCanvasJsiTransportTest} owns 1_000_000 upward and drains every slot
1369
- * in an {@code @Before}, so one shared counter would let whichever class ran
1370
- * second reuse a live id.
1371
- */
1372
- private static final AtomicLong JSI_SLOT = new AtomicLong(9_000_000L);
1373
-
1374
- /* ================================================================ */
1375
-
1376
- private static double mean(long[] values) {
1377
- long sum = 0;
1378
- for (long value : values) {
1379
- sum += value;
1380
- }
1381
- return sum / (double) values.length;
1382
- }
1383
-
1384
- private static long percentile(long[] sorted, int p) {
1385
- int rank = (int) Math.ceil((p / 100.0) * sorted.length) - 1;
1386
- return sorted[Math.max(0, Math.min(sorted.length - 1, rank))];
1387
- }
1388
- }