fib.compress and fib.compress.lz4¶
fib.compress is the protocol for compression: a Codec value with a name, capabilities, one-shot compress / decompress, and
streaming Compressor / Decompressor. fib.compress.lz4 is the first codec, written in fibber (no extern, no C library): the LZ4 block
format and the LZ4 frame format, with the fast encoder, LZ4 HC (levels 3 to 12), dictionaries, and a decoder that is safe on hostile input.
Other formats (zlib, gzip, zstd, snappy) are drivers in their own repositories that implement the same protocol: docs/design/compress.md
says how. Everything here is an explicit module: nothing is in fib.core or the prelude, so a program that does not ask for it does
not carry it (hello world is the same size with and without this tree).
(ns main (:require [fib.compress :as z] [fib.compress.lz4 :as lz4]))
(def codec (lz4/lz4))
(def opts (z/default-options)) ; level 1, content checksum on, content size recorded, 64 KiB independent blocks, max-output 256 MiB, threads 0 (automatic)
(match (z/compress codec (str-bytes "hello hello hello hello") opts)
((Ok frame) (match (z/decompress codec frame opts) ((Ok bytes) ..) ((Err e) ..)))
((Err e) (println (. e kind) (. e message)))) ; kinds: :corrupt :truncated :too-large :unsupported :invalid-argument :checksum :io
One shot¶
| call | gives |
|---|---|
(z/compress codec bytes opts) |
(Result (Array i8) CompressError): an LZ4 frame |
(z/decompress codec bytes opts) |
the bytes, or an error; never more than (. opts max-output) |
(z/max-compressed-size codec n) |
the most compress can return for n bytes (preallocate with it) |
(z/supports? codec :dictionary) |
whether the codec lists a capability |
Options: (with-level o 9) (1, 2: fast; 3 to 12: HC; 0 and below: fast with acceleration 1 - level), (with-acceleration o 8),
(with-dictionary o bytes), (with-checksum o false) (content checksum), (with-block-checksum o true), (with-block-size o 262144)
(64 KiB, 256 KiB, 1 MiB, 4 MiB), (with-independent o false) (linked blocks: better ratio), (with-record-size o false) (the content size is recorded by default when known: a decoder then allocates the
result once), (with-max-output o n), (with-threads o n) (below), (with-verify o false) (skip the checksums that are in the data when decompressing). max-output is a limit on the result: more is a :too-large error and nothing past it has been allocated,
so a decompression bomb costs at most max-output. The default is 256 MiB; there is no "unlimited".
Threads¶
(with-threads o n): 0 (the default) uses the machine's cores for an input of at least 2 MiB (compress) or 512 KiB of compressed input (decompress) and one worker below; 1 is sequential; n is n workers. Frames with
independent blocks (the default) and no dictionary run in parallel: the compressed bytes, the decompressed bytes, the error kinds and the exact max-output do not depend on the thread count (the contract checks it).
On the 8-core test machine, Silesia concatenated (211 MB), 1 MiB blocks: compress 0.68 GB/s at 1 thread, 3.5 GB/s at 8 (5.1x), 5.2 GB/s at 16; decompress 2.6 GB/s at 1 thread, 12.4 GB/s at 8 with verify off
(the content checksum, xxHash32, is sequential: with it 8.1 GB/s at 4 threads). The decompress speed of liblz4's CLI is 1.8 GB/s whatever -T. (z/codec-compressor codec (with-threads o 4)) and
compress-fd batch workers x 1 MiB of input at a time, so memory is a few MiB per worker, never the length of the stream; the fd helpers overlap reading and writing with the coding.
Linked blocks, dictionaries and concatenated frames are sequential. docs/design/compress.md 9 has the design and docs/shootout/lz4.md 3 the curves.
Streaming, bounded memory¶
(let [k (z/codec-compressor codec opts)] ; (Result (dyn Compressor) CompressError)
;; (z/compressor-feed k chunk) -> output bytes ready (maybe empty); any chunk size, 1 byte is fine
;; (z/compressor-finish k) -> the rest: end mark and content checksum
..)
(let [k (z/codec-decompressor codec opts)]
;; (z/decompressor-feed k chunk) -> DecStep (output needs-input frame-end): output is at most one block;
;; while needs-input is false feed an empty array to get more. (z/decompressor-finish k) -> Ok only at a frame end.
..)
(z/compress-stream codec opts read write) and (z/decompress-stream ..) do the loop for any reader and writer closures; fib.compress.fd has
compress-fd / decompress-fd over file descriptors (files, pipes, sockets, stdin); fib.compress.http makes a codec an HTTP Decoder:
;; compress a file to .lz4 and back, 64 KiB at a time
(match (files/open-file "in.txt" ..) ((Ok in) (match (files/open-file "in.txt.lz4" (with ... (create true) (truncate true))) ((Ok out) (compress-fd codec opts in out)))))
;; over a socket: (compress-stream codec opts (transport-reader t) (transport-writer t))
;; as a Content-Encoding decoder:
(-> (client-options) (with-decoder (fib.compress.http/decoder "lz4" (dyn z/Codec :send codec))))
examples/lz4.fib is a complete lz4-compatible command (lz4f -9 -BX in out, lz4f -d in.lz4 out); scripts/lz4-interop.sh runs it against the
reference lz4 both ways.
Registry and detection¶
(def reg (z/registry [(dyn z/Codec :send (lz4/lz4))])) ; a Map the caller builds: no global table
(z/detect reg bytes) ; the codec whose frame magic starts the bytes, or nil
(z/decompress-auto reg bytes opts) ; detect, then decompress; :unsupported when none matches
Raw blocks¶
The fast encoder is byte-identical to liblz4's LZ4_compress_default/LZ4_compress_fast (acceleration 1 to 64). The block format has no header and no length: you keep the sizes. (lz4/block-compress bytes accel), (lz4/block-compress-hc bytes level),
(lz4/block-decompress bytes max-output) (the size is not known: the buffer grows up to the limit), (lz4/block-decompress-sized bytes size) (one allocation),
(lz4/block-decompress-into bytes dst off cap) (into your buffer: no byte outside dst[off, off+cap) is written), (lz4/compress-bound n).
LZ4 conformance¶
| feature | status |
|---|---|
| block format (token, extensions, offsets to 65535, last-5-literals and 12-byte end rules, minimum match 4) | encode and decode |
| frame format v1.6.x: magic, FLG/BD, version 1, reserved bits checked (corrupt), block sizes 64 KiB to 4 MiB | encode and decode |
| independent and linked blocks | encode and decode (one-shot, streaming) |
| block checksum, content checksum (xxHash32), header checksum | written on request, verified whenever present |
| content size | written on request (record-size; streaming needs size-hint), checked on decode, and refused when larger than max-output or than the input can expand to |
| dictionary (last 64 KiB) | prefix of the window of every block (independent) or of the first (linked); the dictionary ID field is read, not used |
| concatenated frames | decode (one-shot and streaming) |
| skippable frames (0x184D2A50 to 5F) | skipped on decode; never written |
| legacy frames (0x184C2102) | one-shot decode only (the streaming decoder answers :unsupported); never written |
| LZ4 HC levels 3 to 12 | liblz4's three-match lazy parser (output byte-identical to liblz4's at levels 3 to 8, 94% at 9); levels 10 to 12 use it with a deeper search (liblz4's optimal parser is not ported; within 0.3% of its size) |
LZ4_compress_destSize, partial decompression, LZ4_setStreamDecode API |
not provided |
Tests¶
specs/compress-lz4-spec.fib (the codec contract), -edge- (published vectors from the reference CLI, boundaries), -prop- (properties with shrinking),
-hostile- (malicious frames, a bomb, 200,000 mutants), specs/compress-contract-shape-spec.fib (the contract can fail: faults); scripts/lz4-diff.py (both
directions against liblz4 on thousands of inputs), scripts/mutant-lz4.sh (planted faults), scripts/lz4-interop.sh (the CLI), scripts/lz4-bench.fib.