G-code Compression Benchmark Plan
Status: Phase 1 (offline size benchmark) executed on a procedurally generated corpus — see Results and Deviations from the original plan below. Phases 2–3 (browser and Raspberry Pi timing) are not yet run.
Motivation
The target pipeline sends slicer output over the network:
flowchart LR
A[Browser<br/>OrcaWeb WASM slicer] -->|upload| B[Proxy server]
B -->|forward| C[Raspberry Pi<br/>at the printer]
C --> D[Printer]
G-code is compressed in the browser right after slicing, travels through the proxy as opaque bytes, and is decompressed on the Raspberry Pi. The primary optimisation target is bandwidth (bytes on the wire); browser CPU cost and Pi decompression cost are secondary constraints.
Plain ASCII G-code is extremely redundant (repeated G1 X.. Y.. E.. tokens, monotonic coordinates, fixed decimal precision), so both domain-specific formats and general-purpose compressors should yield large wins. This benchmark quantifies which combination wins for our pipeline.
Candidates
Base formats
| # | Format | Notes |
|---|---|---|
| F1 | ASCII G-code | Baseline. Output of the OrcaWeb engine as-is |
| F2 | ASCII G-code, comments stripped | Slicer comments are large (; feature, config block); strip everything except the header/config needed by the firmware or ship metadata out-of-band |
| F3 | MeatPack | 4-bit packing of the 15 most common G-code characters; used by PrusaLink/OctoPrint; streams trivially |
| F4 | Prusa Binary G-code (.bgcode) |
Block-structured binary container. Per-block compression: none / deflate / heatshrink 11,4 / heatshrink 12,4; G-code blocks additionally MeatPack-encoded. Test the default profile and a variant with internal compression disabled (F4-raw) to allow an external compressor to work on raw blocks |
General-purpose compressors (applied on top of F1–F4)
| # | Compressor | Levels to test | Browser-side encoder | Pi-side decoder |
|---|---|---|---|---|
| C1 | gzip (deflate) | 1, 6, 9 | native CompressionStream('gzip') for the default level (no level parameter in the API, ~6); levels 1/9 via WASM/JS (fflate or pako) and offline |
zlib (everywhere) |
| C2 | Brotli | 5, 9, 11 | WASM (brotli-wasm) | brotli package |
| C3 | Zstandard | 3, 12, 19, 22 (--ultra) |
WASM (fzstd/zstd-wasm or similar) | zstd (apt) |
| C4 | Zstandard + trained dictionary | 19 + 64 KB dict | WASM, dict shipped with the app | zstd -D |
| C5 | XZ / LZMA2 (the 7-Zip algorithm) | 6, 9e | WASM (xz-wasm) | xz (apt) |
| C6 | 7z PPMd | -m0=PPMd |
reference only (no practical browser encoder) | 7zip (apt) |
| C7 | bzip2 | 9 | reference only | bzip2 |
C6/C7 are measured offline for the size table only — they set the "how much is left on the table" bound, not deployment candidates.
Matrix pruning
The full cross-product is 4 formats × ~15 compressor configs. Prune it:
- F4 (bgcode with internal heatshrink/deflate) × C1–C5 — expect near-zero gains (already-compressed data); run one config (zstd-19) just to confirm and then drop.
- MeatPack (F3) and bgcode (F4) exist mainly so firmware/MCU can decode them cheaply. Our decoder is a Raspberry Pi running Linux, so they only stay in the race if they stack well with a general compressor (F3×C2, F3×C3).
- Main contenders expected: F1/F2 × C2 (brotli-11), C3 (zstd-19/22), C5 (xz-9e) vs F4 default vs F4-raw × C3/C5.
Domain-specific preprocessing (phase 2, exploratory)
Only worth exploring if general compressors leave a big gap to C6/PPMd:
- Columnar/token transform — split the G-code into per-field streams (opcodes, X, Y, Z, E, F values), delta-encode numeric streams, compress each with zstd. This is essentially what makes domain formats win; measure whether it beats brotli-11 by enough to justify a custom format.
- Arc fitting (G2/G3) and coordinate precision are slicer settings, not transport encodings — they change the toolpath itself. Note their effect once (arc fitting on/off on one model) but keep them out of the main comparison; the transport must not alter what gets printed.
Test corpus
All files generated by the OrcaWeb engine itself (pinned WASM build version, pinned profiles), committed as a manifest (model + settings hash), not as raw G-code:
| Model | Purpose | Expected size |
|---|---|---|
| Calibration cube | small-file overhead / dictionary win | ~100 KB |
| 3DBenchy | canonical mid-size print | ~3–5 MB |
| Voron cube | mixed features (already a repo smoke-test model) | ~1–3 MB |
| Large functional part (e.g. scaled Benchy 250%, supports + 3 walls) | multi-hour print, the case where bandwidth actually hurts | ≥50 MB |
| Spiral vase mode model | long continuous moves, atypical redundancy profile | ~1–2 MB |
Each sliced in two flavors: Marlin and Klipper (different verbosity), giving 10 corpus files. .bgcode variants exported for the same slices.
Run 1 corpus (procedural, actually generated)
No access to a user's local Downloads folder or a vendored third-party STL set was available for this run (see Deviations). Instead, scripts/generate-corpus.mjs procedurally generates five meshes spanning the same size/shape intent as the aspirational corpus above and slices them with the real OrcaWeb WASM engine (wasm-v2.4.0-patch5, default profile from orca-wasm/scripts/smoke-test.mjs, single G-code flavor):
| id | Stand-in for | Mesh | Profile deltas | Triangles | G-code size |
|---|---|---|---|---|---|
calibration_cube |
Calibration cube | 20 mm cube | — (2 walls, 15% grid infill, 0.2 mm layers) | 12 | 177,797 B |
small_organic |
— (extra small-file data point) | icosphere, subdiv 2, r=12 mm | same as above | 320 | 578,809 B |
medium_organic |
3DBenchy / Voron cube | icosphere, subdiv 3, r=30 mm | same as above | 1,280 | 2,675,996 B |
vase_mode |
Spiral vase model | open-top truncated cone, r=35→15mm, h=120mm | 1 wall, 0 top / 1 bottom shell, 0% infill, spiral_mode: true |
128 | 1,711,358 B |
large_functional |
Large functional part | icosphere, subdiv 4, r=45 mm | 3 walls, 5/5 shells, 30% infill, 0.12 mm layers | 5,120 | 14,463,825 B |
large_functional landed at ~14.5 MB rather than the ≥50 MB originally targeted (procedural meshes and the time budget for this run made a multi-hour-print-scale file impractical); see the deviations note on what that means for the results below.
Metrics
- Compressed size (bytes) → ratio vs F1 baseline. Primary metric.
- Browser compression time & peak memory — measured inside a Web Worker on a mid-range laptop (Chrome, stable version pinned in the report) for deployable candidates only. Reported as absolute time and as % of the slicing time for the same model.
- Pi decompression time & memory — measured on a Raspberry Pi Zero 2 W (worst case) and Pi 4. Must comfortably beat the print-start latency budget (< 2 s for the 50 MB file, or streamable).
- Streamability — can encoding/decoding run incrementally so upload starts before slicing finishes and printing state doesn't need the whole file in RAM? (gzip/brotli/zstd/xz: yes; bgcode: per-block; 7z container: no.)
- Implementation complexity — native vs WASM in the browser, package availability on the Pi, licensing, extra payload the app must ship (WASM codec size, dictionary size — count these against the first-transfer cost).
Deviations from the original plan (Run 1)
This run executed inside an isolated cloud container with no access to a user's local machine (no Downloads folder to pull real-world models from) and no physical Raspberry Pi. To still produce real, trustworthy numbers rather than leave the whole plan as placeholders, the following substitutions were made — each is called out inline next to the affected result:
- Corpus: procedurally generated meshes (cube, icospheres, an open-top cone) instead of downloaded/vendored STLs (3DBenchy, Voron cube). This mirrors
orca-wasm/scripts/smoke-test.mjs's own policy of never vendoring a third-party STL in this repo. Absolute G-code sizes will differ from a real Benchy/Voron slice, but the sizes span the same ~150 KB–15 MB range the aspirational corpus targeted, which is what matters for how compression ratio scales with file size. - Single G-code flavor: the Marlin/Klipper verbosity split was descoped — no verified config key for G-code flavor was found in the current WASM bridge (
orca-wasm/bridge/slicer.cpptakes a generic key/value config; flavor selection isn't exposed there yet) and guessing one risked silently no-op'ing. All files use the engine's default flavor. large_functionalis ~14.5 MB, not ≥50 MB: procedural mesh complexity and this run's time budget made a true multi-hour-print-scale file impractical. Ratios are already stable between the 2.7 MB and 14.5 MB corpus entries (within ~0.1:1 of each other for every candidate), which is evidence — not proof — that they hold at larger sizes too.- MeatPack (F3) is re-implemented from scratch as an adaptive 4-bit packer: it builds its 15-symbol table from each file's own byte-frequency histogram, rather than reciting upstream's fixed table from memory (a wrong fixed table would silently produce plausible-looking but bogus numbers). The encoder round-trips every file through a decoder and asserts byte-exact equality before reporting a size, so a broken implementation fails loudly rather than misreporting. This measures the mechanism MeatPack uses (byte-level 4-bit packing tuned to the data), not upstream's exact bit-for-bit output.
- Prusa bgcode (F4) is approximated by running the real
heatshrink2library (the actual heatshrink C implementation, via its Python binding) directly on the raw G-code text, at both window/lookahead profileslibbgcodedocuments (11,4and12,4). This is a real, accurate measurement of bgcode's compression backend, just without the container framing (block headers, CRC32 checksums, thumbnail/metadata blocks) — fixed overhead of a few hundred bytes total, negligible at these file sizes. It does not require buildinglibbgcodeitself. - Phases 2 and 3 (browser Worker timing, physical Raspberry Pi decompression timing) were not run — no browser and no Pi hardware in this environment. As a partial, clearly-labeled substitute, wall-clock CLI compression time was measured on the container's CPU (Intel Xeon @ 2.10GHz, 4 vCPU) for the largest corpus file — see Results. This is a relative CPU-cost proxy across algorithms, not an absolute browser-JS/WASM number; a JS/WASM brotli or zstd encoder will have different constants (typically slower than native CLI binaries), so treat only the ordering between candidates as informative, not the absolute seconds.
Methodology
- Phase 0 — corpus generation. Node script
scripts/generate-corpus.mjs: slices a set of models headlessly with the pinned OrcaWeb WASM engine into a localcorpus/directory (gitignored) plus amanifest.jsonrecording each model's generation parameters and the resulting profile/size. Run 1 uses procedurally generated meshes (see above) defined inline in the script; a future run with access to real STLs should extend it to read a manifest of model source URL + SHA-256 instead, keeping the rest of the pipeline unchanged. - Phase 1 — offline size benchmark. Node script
scripts/benchmark-gcode-compression.mjs: takes thecorpus/directory produced by phase 0, runs every (format × compressor × level) cell via CLI tools (versions pinned in output), emits a markdown results table. Sizes are deterministic — one run each. - Phase 2 — browser timing. Minimal harness page (dev-only route or standalone HTML in
scripts/) that loads each corpus file, compresses in a Worker with the deployable codecs, reports median of 5 runs. - Phase 3 — Pi timing. Shell script run over SSH on both Pi models; median of 5 runs,
time -vfor peak RSS. - Phase 4 — report + decision. Fill the tables below, write an ADR with the chosen wire format.
Environment (browser version, Node version, CPU, Pi model/OS, tool versions) is recorded at the top of the results.
Hypotheses
Original hypotheses, checked against the Run 1 data in Results (ratios below are medians across the 5-file corpus unless noted):
- ~~gzip at the native default level ≈ 3.5–4:1~~ — confirmed, median 3.47:1 (range 3.14–9.62:1; the 9.62:1 outlier is the tiny calibration cube, a small-file effect).
- ~~zstd-19 ≈ 4–4.5:1, brotli-11 ≈ 4.5–5:1, xz-9e ≈ 5:1+~~ — directionally confirmed but all three ran higher than predicted: zstd-19 median 5.33:1, brotli-11 median 5.98:1, xz-9e median 5.75:1. xz-9e and brotli-11 are close, with brotli-11 slightly ahead on 3 of 5 files.
- ~~bgcode default ≈ 2.5–3:1, expected to lose to F1×brotli-11~~ — confirmed: heatshrink(11,4) proxy median 2.31:1, heatshrink(12,4) median 2.56:1, both clearly behind every general compressor tested (worst general-purpose result was gzip -1 at 2.78:1 median, still ahead of both heatshrink profiles).
- ~~MeatPack + zstd ≈ zstd alone~~ — refuted in the negative direction: MeatPack-adaptive + zstd-19 (median 4.04:1) is worse than zstd-19 on raw text alone (5.33:1), not merely equal. Nibble-packing measurably destroys byte-aligned redundancy the LZ77 stage would otherwise exploit — MeatPack-family encodings and general compressors are alternatives, not a stack.
- ~~zstd dictionary only matters for the small-file case~~ — not tested this run (C4, a trained dictionary, needs a training corpus larger than 5 files to be meaningful; descoped, see Deviations).
- New finding (not an original hypothesis): comments-stripping (F2) stacked with brotli-11 beats F1×brotli-11 on every file, dramatically on small ones (23.74:1 vs 13.46:1 on the calibration cube) and modestly on large ones (4.44:1 vs 4.39:1 on
large_functional) — see the caveat in Results before adopting it. - New finding: 7z/PPMd, included only as a reference bound, is not strictly better than xz-9e — it wins on the largest file (4.22:1 vs 4.14:1) but loses on 3 of the other 4. PPMd's higher-order context modeling seems to pay off more as file size grows; not worth chasing given it has no practical browser encoder.
- New finding: brotli-11's encode cost is severe — 37s for the 14.5 MB file on this run's CPU vs 9.8s for zstd-19 and 11.6s for xz-9e, while the model that produced that G-code took only 21.6s to slice. See Results for the full timing table and its caveats.
Results
Run 1 — Node v22.22.2, gzip 1.12, brotli 1.1.0, zstd 1.5.5, xz 5.4.5, bzip2 1.0.8, 7-Zip 23.01, heatshrink2 0.14.0 (Python), on a 4-vCPU Intel Xeon @ 2.10GHz container. Corpus: see Run 1 corpus. Raw JSON: results.json from scripts/benchmark-gcode-compression.mjs (not committed — regenerate with the commands in Reproducing this run).
Size (bytes, ratio vs that file's ASCII baseline in parentheses)
| Candidate | calibration_cube | small_organic | medium_organic | vase_mode | large_functional | median ratio |
|---|---|---|---|---|---|---|
| F1 raw (baseline) | 177,797 (1.00) | 578,809 (1.00) | 2,675,996 (1.00) | 1,711,358 (1.00) | 14,463,825 (1.00) | 1.00 |
| F1 × gzip (native default, ~lvl6) | 18,491 (9.62) | 166,666 (3.47) | 805,055 (3.32) | 477,645 (3.58) | 4,600,718 (3.14) | 3.47 |
| F1 × gzip -1 | 24,789 (7.17) | 208,271 (2.78) | 986,914 (2.71) | 574,712 (2.98) | 5,554,498 (2.60) | 2.78 |
| F1 × gzip -9 | 18,018 (9.87) | 165,982 (3.49) | 797,783 (3.35) | 480,575 (3.56) | 4,573,091 (3.16) | 3.49 |
| F1 × brotli -q5 | 15,490 (11.48) | 139,855 (4.14) | 758,256 (3.53) | 473,485 (3.61) | 4,642,265 (3.12) | 3.61 |
| F1 × brotli -q9 | 15,115 (11.76) | 127,309 (4.55) | 604,580 (4.43) | 478,740 (3.57) | 4,433,526 (3.26) | 4.43 |
| F1 × brotli -q11 | 13,211 (13.46) | 96,306 (6.01) | 447,444 (5.98) | 297,664 (5.75) | 3,292,321 (4.39) | 5.98 |
| F1 × zstd -3 | 19,364 (9.18) | 148,746 (3.89) | 786,168 (3.40) | 546,884 (3.13) | 4,924,594 (2.94) | 3.40 |
| F1 × zstd -19 | 14,539 (12.23) | 106,431 (5.44) | 501,761 (5.33) | 354,023 (4.83) | 3,831,730 (3.77) | 5.33 |
| F1 × zstd --ultra -22 | 14,366 (12.38) | 106,429 (5.44) | 502,005 (5.33) | 353,989 (4.83) | 3,831,767 (3.77) | 5.33 |
| F1 × xz -6 | 14,112 (12.60) | 99,080 (5.84) | 470,124 (5.69) | 320,488 (5.34) | 3,506,404 (4.12) | 5.69 |
| F1 × xz -9e | 13,720 (12.96) | 98,640 (5.87) | 465,268 (5.75) | 318,200 (5.38) | 3,497,212 (4.14) | 5.75 |
| F1 × bzip2 -9 (ref bound) | 15,446 (11.51) | 123,102 (4.70) | 629,107 (4.25) | 442,851 (3.86) | 3,631,532 (3.98) | 4.25 |
| F1 × 7z PPMd (ref bound) | 12,543 (14.17) | 106,040 (5.46) | 518,297 (5.16) | 437,661 (3.91) | 3,425,754 (4.22) | 5.16 |
| F2 comments-stripped (raw) | 133,186 (1.33) | 497,000 (1.16) | 2,483,019 (1.08) | 1,602,138 (1.07) | 14,167,551 (1.02) | 1.08 |
| F2 × brotli -q11 | 7,488 (23.74) | 86,356 (6.70) | 426,522 (6.27) | 286,028 (5.98) | 3,258,138 (4.44) | 6.27 |
| F2 × zstd -19 | 8,044 (22.10) | 95,033 (6.09) | 481,507 (5.56) | 342,633 (4.99) | 3,803,068 (3.80) | 5.56 |
| F3 MeatPack-adaptive (raw) | 133,030 (1.34) | 377,063 (1.54) | 1,631,707 (1.64) | 1,053,853 (1.62) | 8,439,682 (1.71) | 1.62 |
| F3 × zstd -19 (stacking check) | 19,159 (9.28) | 143,290 (4.04) | 680,226 (3.93) | 423,243 (4.04) | 4,277,394 (3.38) | 4.04 |
| F4proxy heatshrink(11,4) (bgcode default) | 41,669 (4.27) | 250,115 (2.31) | 1,173,939 (2.28) | 669,723 (2.56) | 6,640,596 (2.18) | 2.31 |
| F4proxy heatshrink(12,4) (bgcode alt) | 37,831 (4.70) | 226,049 (2.56) | 1,106,413 (2.42) | 593,361 (2.88) | 6,170,802 (2.34) | 2.56 |
| F4proxy heatshrink(11,4) × zstd-19 (confirm-and-drop) | 32,592 (5.46) | 231,415 (2.50) | 1,081,347 (2.47) | 557,455 (3.07) | 6,096,487 (2.37) | 2.50 |
Reading this table: the two bolded rows (F1×brotli-11, F1×xz-9e) are the strongest format-preserving candidates (no assumption that comments can be dropped); F2×brotli-11 is the strongest candidate overall but depends on comments being safe to discard downstream (see caveat below).
Compression time (CPU-cost proxy, large_functional.gcode, 14,463,825 bytes)
Not a browser/WASM measurement — see the caveat in Deviations. Native CLI, single run, this run's 4-vCPU container:
| Candidate | time | output size | For reference |
|---|---|---|---|
| zstd -3 | 0.13 s | 4,924,594 B | fastest by far, weakest ratio |
| gzip -6 (≈ native default) | 1.19 s | 4,656,322 B (differs slightly from the Node-zlib figure above — native CLI vs Node binding) |
|
| brotli -q5 | 1.08 s | 4,542,770 B | |
| bzip2 -9 | 1.13 s | 3,631,532 B | |
| gzip -9 | 3.19 s | 4,573,114 B | |
| brotli -q9 | 3.68 s | 4,368,498 B | |
| zstd -19 | 9.81 s | 3,831,734 B | good ratio/cost balance |
| zstd --ultra -22 | 10.11 s | 3,831,799 B | no gain over -19 at this size, same cost |
| xz -6 | 10.66 s | 3,506,404 B | |
| xz -9e | 11.57 s | 3,497,212 B | best ratio for the cost, of the practical candidates |
| brotli -q11 | 36.97 s | 3,294,188 B | best ratio overall, but exceeds the 21.6 s it took to slice this model |
The model slicing that produced large_functional.gcode took 21.6 s end-to-end (scripts/generate-corpus.mjs log). Under the plan's decision criterion of compression adding < 10 % of slicing time, brotli-11's 37 s fails that budget outright on a native CPU — a JS/WASM brotli encoder in a browser Worker would need to be dramatically faster than this native binary to qualify, which is unlikely. zstd-19 (9.8 s, ~45% of slice time) and xz-9e (11.6 s, ~54%) also miss the original 10% target as measured here, though both are far closer, and this is CLI-on-server-CPU timing, not the real browser number the decision criterion is meant to use. Phase 2 (real browser Worker timing) is required before finalizing — this table only orders the candidates by relative cost, it doesn't validate the 10% threshold either way.
Pi decompression (large file)
Not run — no physical Raspberry Pi in this environment. zstd, xz, brotli decompression is well-documented to be an order of magnitude+ faster than encoding at these ratios/levels for all four candidates above, so this is not expected to be the bottleneck, but it must still be measured on real Pi Zero 2 W / Pi 4 hardware (Phase 3) before this plan can close.
Reproducing this run
npm run setup # downloads slicer.js/slicer.wasm into public/wasm/
npm run generate-corpus -- --out-dir corpus
npm run benchmark-gcode -- --corpus-dir corpus --out corpus/results.json
Requires gzip, brotli, zstd, xz, bzip2, 7z on PATH and Python 3 with heatshrink2 (pip install heatshrink2) installed.
Decision criteria
Pick the wire format that:
- achieves the best ratio among candidates whose browser compression adds < 10 % of slicing time for the large model,
- decompresses the large model on a Pi Zero 2 W in < 2 s or fully streams,
- streams end-to-end (upload may begin before slicing completes),
- needs no custom decoder maintenance on the Pi (apt/pip-installable) unless a custom transform wins by > 15 % over the best off-the-shelf option.
Provisional recommendation (Phase 1 only — not final)
Based only on the size and CPU-cost-proxy data above, not on real browser/Pi timing:
- Primary candidate:
F1 × xz-9eorF1 × zstd-19. Both keep the G-code text unmodified (no downstream-compatibility risk), land at a 5.3–5.75:1 median ratio — a clear step above gzip (3.47:1) — and cost roughly half of brotli-11's CPU time for nearly the same ratio.zstdadditionally has mature WASM encoders and by far the best decode speed, which matters more on a Pi Zero 2 W than on the browser side. - brotli-11 is the best ratio (5.98:1 median) but is the riskiest on the compression-time budget — its native CLI time already exceeds this run's slicing time for the largest file. Worth keeping in Phase 2's browser timing harness rather than ruling out now (a WASM brotli encoder's real-world speed vs. this native benchmark is unverified), but don't default to it without that data.
F2(comments stripped) is not recommended yet despite its best-in-corpus ratio (6.27:1 with brotli-11). It assumes no downstream consumer depends on G-code comments — Klipper/Marlin UIs (Mainsail, Fluidd, OctoPrint) commonly parse structured comments for layer-change tracking, thumbnails, and print-time estimates. Before adopting F2, audit what the Raspberry Pi-side client actually reads from comments; if nothing does, it's a strictly-better drop-in reduction on top of whichever compressor wins.- MeatPack and Prusa bgcode's compression backend (heatshrink) both lose outright to every general-purpose compressor tested, confirming the hypothesis that they're optimized for MCU decode cost, not bandwidth. Neither is worth adopting purely for bandwidth on a Raspberry Pi (a full Linux machine, not an 8-bit MCU) decoder.
- gzip via the browser's native
CompressionStreamremains the right zero-dependency fallback (no WASM codec to ship, ubiquitous decode) if the WASM codec payload cost for brotli/zstd/xz turns out to not be worth it for a given deployment — 3.47:1 median is still a 3.5x bandwidth win for free.
This is not final. Phase 2 (real browser Worker timing per codec) and Phase 3 (real Pi Zero 2 W / Pi 4 decompression timing) are required to check criteria 1–2 for real before locking in a choice, and Phase 0 should be redone with a real STL corpus (3DBenchy, Voron cube, an actual ≥50 MB multi-hour print) once one is available, to replace the procedural stand-ins used in this run.