djvu-rs 0.25.0

Pure-Rust DjVu codec — decode and encode DjVu documents. MIT licensed, no GPL dependencies.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
name: Benchmarks

on:
  # Track regressions on every PR that touches code or benchmarks.
  # Workflow file itself is included so changes to bench.yml re-run on PR.
  pull_request:
    paths:
      - 'src/**'
      - 'crates/**'
      - 'benches/**'
      - 'Cargo.toml'
      - 'Cargo.lock'
      - '.github/workflows/bench.yml'

  # Update the baseline when code or benchmarks actually change on main / release tags.
  # Tags always bump Cargo.toml so the paths filter still fires for releases.
  push:
    branches: [main]
    tags:
      - 'v*'
    paths:
      - 'src/**'
      - 'crates/**'
      - 'benches/**'
      - 'Cargo.toml'
      - 'Cargo.lock'
      - '.github/workflows/bench.yml'

  # Allow manual runs (e.g. to seed the initial baseline).
  workflow_dispatch:

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

env:
  CARGO_TERM_COLOR: always
  RUST_BACKTRACE: 1
  FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true

jobs:
  # Detect whether a change touches the AVX2/SIMD hot paths. The x86-64-v3
  # validation job below is only meaningful when iw44 SIMD code changes, so we
  # skip it on PRs that don't — most refactors never touch these files, and
  # running it there is ~5.5 min of compute and an irrelevant red/green check.
  detect:
    name: Detect SIMD-relevant changes
    runs-on: ubuntu-latest
    outputs:
      simd: ${{ steps.filter.outputs.simd }}
    steps:
      - uses: actions/checkout@v6
      - uses: dorny/paths-filter@v3
        id: filter
        with:
          filters: |
            simd:
              # IW44 SIMD lives in the djvu-iw44 crate since the codec extraction
              # (#344); the old `src/iw44_*.rs` are thin re-export shims now.
              - 'crates/djvu-iw44/src/lib.rs'
              - 'crates/djvu-iw44/src/encode.rs'
              - 'benches/codecs.rs'
              - 'benches/render.rs'
              - '.github/workflows/bench.yml'

  bench:
    name: Benchmark
    # Single platform for reproducible comparisons.
    # (macos-latest numbers are kept separately via workflow_dispatch / tags.)
    runs-on: ubuntu-latest

    permissions:
      actions: read         # needed by dawidd6/action-download-artifact to list artifacts
      pull-requests: write  # needed to post the comparison comment

    steps:
      - uses: actions/checkout@v6

      - name: Install Rust stable
        uses: dtolnay/rust-toolchain@stable

      - name: Cache Rust dependencies
        uses: Swatinem/rust-cache@v2
        with:
          key: bench-ubuntu

      # ── Download baseline from last successful main-branch run (PR only) ────
      - name: Download baseline
        if: github.event_name == 'pull_request'
        uses: dawidd6/action-download-artifact@v6
        with:
          name: bench-baseline
          branch: main
          workflow: bench.yml
          workflow_conclusion: success
          path: baseline
          if_no_artifact_found: warn
        continue-on-error: true

      # ── Run djvu-rs Criterion benchmarks ─────────────────────────────────────
      - name: Bench codecs
        run: cargo bench --bench codecs -- --output-format bencher 2>&1 | tee codec_bench.txt
        continue-on-error: true

      - name: Bench render
        run: cargo bench --bench render -- --output-format bencher 2>&1 | tee render_bench.txt
        continue-on-error: true

      # ── Run DjVuLibre benchmarks on the same machine ─────────────────────────
      # Both djvu-rs (above) and DjVuLibre (below) run in the same job so the
      # ratio numbers are directly comparable without platform skew.
      - name: Bench vs DjVuLibre
        run: bash scripts/bench_djvulibre.sh .
        continue-on-error: true

      # ── Print summary ────────────────────────────────────────────────────────
      - name: Print results
        run: |
          echo "--- Codec benchmarks ---"
          grep "bench:" codec_bench.txt || echo "(no codec results)"
          echo ""
          echo "--- Render benchmarks ---"
          grep "bench:" render_bench.txt || echo "(no render results)"
          echo ""
          echo "--- vs DjVuLibre (decode/render) ---"
          python3 scripts/djvulibre_compare.py \
            --criterion target/criterion \
            --djvulibre-bench djvulibre_bench.txt \
            --ddjvu-timing ddjvu_timing.txt \
            2>/dev/null || echo "(no DjVuLibre results)"
          echo ""
          echo "--- DjVuLibre encode timing (cjb2/c44; vs criterion jb2_encode/iw44_encode_color) ---"
          cat encode_timing.txt 2>/dev/null || echo "(no encode timing)"

      # ── Compare against baseline and generate Markdown table (PR only) ──────
      - name: Compare benchmarks
        if: github.event_name == 'pull_request'
        id: compare
        run: |
          python3 scripts/bench_compare.py \
            baseline/target/criterion \
            target/criterion \
            > bench_comment.md
          echo "regression_exit=$?" >> "$GITHUB_OUTPUT"
          cat bench_comment.md
        continue-on-error: true  # don't skip comment posting on failure

      - name: Append DjVuLibre comparison
        if: github.event_name == 'pull_request'
        run: |
          python3 scripts/djvulibre_compare.py \
            --criterion target/criterion \
            --djvulibre-bench djvulibre_bench.txt \
            --ddjvu-timing ddjvu_timing.txt \
            >> bench_comment.md 2>/dev/null || true
        continue-on-error: true

      - name: Post benchmark comment
        if: github.event_name == 'pull_request'
        uses: marocchino/sticky-pull-request-comment@v2
        with:
          header: bench
          path: bench_comment.md
        continue-on-error: true  # comment failure must not hide regression result

      # Fail the job if the compare script reported regressions.
      - name: Fail on regression
        if: github.event_name == 'pull_request' && steps.compare.outputs.regression_exit == '1'
        run: |
          echo "Benchmark regressions detected — see PR comment for details."
          exit 1

      # ── Upload baseline (main branch + tags) ─────────────────────────────────
      - name: Upload baseline
        if: >
          github.event_name == 'push' &&
          (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v'))
        uses: actions/upload-artifact@v4
        with:
          name: bench-baseline
          # Overwrite the previous baseline so PRs always compare against HEAD of main.
          overwrite: true
          path: target/criterion/
          retention-days: 90

      # ── Always archive full results ──────────────────────────────────────────
      - name: Upload full results
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: bench-results-${{ github.sha }}
          path: |
            codec_bench.txt
            render_bench.txt
            djvulibre_bench.txt
            ddjvu_timing.txt
            encode_timing.txt
            target/criterion/
          retention-days: 30

  # ── x86-64-v3 (AVX2) validation: measures whether the AVX2 hot paths in
  # iw44_new actually beat the scalar/wide::i32x8 fallback when AVX2 is
  # enabled at the codegen level. The default Bench job above runs with
  # whatever target-cpu cargo picks (typically `x86-64` baseline = SSE2),
  # which means our `cfg(target_feature = "avx2")` branches are dead code
  # on CI — every PR claiming "AVX2 path" was effectively unmeasured.
  #
  # This job reruns the AVX2-relevant benches twice (default RUSTFLAGS vs
  # `-C target-cpu=x86-64-v3`) on the same runner and posts the delta.
  # Tracks the validation gate added after #251/#252/#253 shipped without
  # baseline numbers — see PERF_EXPERIMENTS.md follow-up for context.
  #
  # This is also the only x86 host the repo has (local dev is Apple Silicon —
  # see PERF_EXPERIMENTS.md "Platform" lines). Two backlog items are gated on
  # x86 numbers and can only be resolved from this job's output:
  #   - AVX2_IDWT (round-5 #8): covered by the iw44_decode/iw44_to_rgb benches
  #     below — `to_rgb`/`to_rgb_subsample` run `inverse_wavelet_transform`,
  #     which is exactly the IDWT row/col pass this item is about.
  #   - ZP-U64 (round-3/4 deferred list): the jb2/bzz decode benches added
  #     below cover the ZP-decoder `refill!` hot loop shared by jb2/iw44/bzz —
  #     needed to tell whether the 32-bit refill is measurably dearer on x86
  #     than on the aarch64 numbers already in the journal.
  # Trigger on demand via `workflow_dispatch` (top of file) on any branch/ref
  # that already contains this workflow file.
  bench-x86-64-v3:
    name: Benchmark (x86-64-v3 AVX2 validation)
    runs-on: ubuntu-latest
    needs: detect
    # Always run on main / tags / manual dispatch (keep the validation current).
    # On PRs, run only when iw44/SIMD files actually changed — see `detect`.
    if: >
      github.event_name != 'pull_request' ||
      needs.detect.outputs.simd == 'true'

    permissions:
      pull-requests: write

    steps:
      - uses: actions/checkout@v6

      - name: Install Rust stable
        uses: dtolnay/rust-toolchain@stable

      - name: Cache Rust dependencies
        uses: Swatinem/rust-cache@v2
        with:
          key: bench-ubuntu-x86-64-v3

      # Bound scope to AVX2/ZP-relevant benches to keep CI time tractable.
      # Filters target iw44 SIMD hot paths (load8s/store8s, ycbcr_row_from_i16,
      # the IDWT row/col passes exercised via to_rgb) plus jb2_decode/bzz_decode
      # so the shared ZP-decoder `refill!` loop is measured on x86 too (see the
      # ZP-U64 note in the job comment above) — those two are cheap to add
      # since the binary/runner are already built for this job.
      - name: Bench codecs (default RUSTFLAGS — baseline)
        run: |
          cargo bench --bench codecs -- 'iw44_to_rgb|iw44_decode|jb2_decode|bzz_decode' --output-format bencher 2>&1 \
            | tee codec_bench_default.txt
        continue-on-error: true

      - name: Bench render (default RUSTFLAGS — baseline)
        run: |
          cargo bench --bench render -- 'render_corpus_color|render_colorbook' --output-format bencher 2>&1 \
            | tee render_bench_default.txt
        continue-on-error: true

      # Discard criterion state so the +x86-64-v3 run starts clean and
      # criterion treats it as an independent baseline.
      - name: Reset criterion state
        run: rm -rf target/criterion

      - name: Bench codecs (RUSTFLAGS=-C target-cpu=x86-64-v3)
        env:
          RUSTFLAGS: -C target-cpu=x86-64-v3
        run: |
          cargo bench --bench codecs -- 'iw44_to_rgb|iw44_decode|jb2_decode|bzz_decode' --output-format bencher 2>&1 \
            | tee codec_bench_v3.txt
        continue-on-error: true

      - name: Bench render (RUSTFLAGS=-C target-cpu=x86-64-v3)
        env:
          RUSTFLAGS: -C target-cpu=x86-64-v3
        run: |
          cargo bench --bench render -- 'render_corpus_color|render_colorbook' --output-format bencher 2>&1 \
            | tee render_bench_v3.txt
        continue-on-error: true

      - name: Compare default vs +x86-64-v3
        run: |
          python3 - <<'PYEOF' | tee bench_v3_comment.md
          import re, os
          BENCHER_RE = re.compile(
              r"^test\s+(?P<name>\S+)\s+\.\.\.\s+bench:\s+"
              r"(?P<ns>[\d,]+)\s+ns/iter"
          )

          def parse(path):
              out = {}
              if not os.path.exists(path):
                  return out
              for line in open(path):
                  m = BENCHER_RE.match(line.strip())
                  if not m:
                      continue
                  out[m.group("name")] = int(m.group("ns").replace(",", ""))
              return out

          sections = [
              ("Codecs", "codec_bench_default.txt", "codec_bench_v3.txt"),
              ("Render", "render_bench_default.txt", "render_bench_v3.txt"),
          ]

          print("## AVX2 validation: default RUSTFLAGS vs `-C target-cpu=x86-64-v3`")
          print()
          print("Same runner, same benches, run back-to-back. Negative Δ% = AVX2 path is faster.")
          print()
          any_speedup = False
          any_regression = False
          for title, default_path, v3_path in sections:
              d = parse(default_path)
              v = parse(v3_path)
              names = sorted(set(d) | set(v))
              if not names:
                  continue
              print(f"### {title}")
              print()
              print("| Bench | default ns | +x86-64-v3 ns | Δ% |")
              print("|---|---:|---:|---:|")
              for n in names:
                  a = d.get(n)
                  b = v.get(n)
                  if a is None or b is None:
                      print(f"| `{n}` | {a if a is not None else '—'} | {b if b is not None else '—'} | — |")
                      continue
                  delta = (b - a) / a * 100.0
                  if delta <= -3.0:
                      any_speedup = True
                  elif delta >= 3.0:
                      any_regression = True
                  print(f"| `{n}` | {a:,} | {b:,} | {delta:+.2f}% |")
              print()

          print("---")
          if any_speedup and not any_regression:
              print("**Verdict.** AVX2 path measurably faster (≥3% on ≥1 bench, no regressions).")
          elif any_regression and not any_speedup:
              print("**Verdict.** No AVX2 speedup — and at least one regression (≥3%). "
                    "The shipped SIMD path is hurting on x86-64-v3; "
                    "consider reverting #251/#252.")
          elif not any_speedup and not any_regression:
              print("**Verdict.** No measurable difference (all benches within ±3%). "
                    "The AVX2 hot paths are not earning their keep — "
                    "consider reverting #251/#252 with a "
                    "_Reverted: no measurable speedup_ entry in PERF_EXPERIMENTS.md.")
          else:
              print("**Verdict.** Mixed: some speedup, some regression. Investigate per-bench.")
          PYEOF

      - name: Post AVX2 validation comment
        if: github.event_name == 'pull_request'
        uses: marocchino/sticky-pull-request-comment@v2
        with:
          header: bench-x86-64-v3
          path: bench_v3_comment.md
        continue-on-error: true

      - name: Upload AVX2 validation artifacts
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: bench-x86-64-v3-${{ github.sha }}
          path: |
            codec_bench_default.txt
            codec_bench_v3.txt
            render_bench_default.txt
            render_bench_v3.txt
            bench_v3_comment.md
          retention-days: 30

  # ── macOS run: tags and manual only (for BENCHMARKS.md) ─────────────────────
  bench-macos:
    name: Benchmark (macOS)
    runs-on: macos-latest
    if: >
      github.event_name == 'workflow_dispatch' ||
      (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v'))

    steps:
      - uses: actions/checkout@v6

      - name: Install Rust stable
        uses: dtolnay/rust-toolchain@stable

      - name: Cache Rust dependencies
        uses: Swatinem/rust-cache@v2
        with:
          key: bench-macos

      # ── Run djvu-rs Criterion benchmarks ─────────────────────────────────────
      - name: Bench codecs
        run: cargo bench --bench codecs -- --output-format bencher 2>&1 | tee codec_bench.txt
        continue-on-error: true

      - name: Bench render
        run: cargo bench --bench render -- --output-format bencher 2>&1 | tee render_bench.txt
        continue-on-error: true

      # ── Run DjVuLibre benchmarks on the same machine ─────────────────────────
      - name: Bench vs DjVuLibre
        run: bash scripts/bench_djvulibre.sh .
        continue-on-error: true

      # ── Print summary ────────────────────────────────────────────────────────
      - name: Print results
        run: |
          echo "--- Codec benchmarks (macOS) ---"
          grep "bench:" codec_bench.txt || echo "(no codec results)"
          echo ""
          echo "--- Render benchmarks (macOS) ---"
          grep "bench:" render_bench.txt || echo "(no render results)"
          echo ""
          echo "--- vs DjVuLibre (macOS) ---"
          python3 scripts/djvulibre_compare.py \
            --criterion target/criterion \
            --djvulibre-bench djvulibre_bench.txt \
            --ddjvu-timing ddjvu_timing.txt \
            2>/dev/null || echo "(no DjVuLibre results)"

      - name: Upload full results
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: bench-results-macos-${{ github.sha }}
          path: |
            codec_bench.txt
            render_bench.txt
            djvulibre_bench.txt
            ddjvu_timing.txt
            target/criterion/
          retention-days: 90