zpaqfranz 65.3 BEWARE: this is a "big bang" build
This release introduces a substantial number of new features and changes, most of which have undergone only limited testing.
Please be careful when upgrading: try it on a copy first, and keep using -m1 (or whatever you use now) for the backups that matter until you trust the new features.
On the positive side, processing performance for large files has been improved dramatically, resulting in significantly faster processing times.
Prebuilt binaries are also provided for a range of “universal” environments, including both Intel and non-Intel NAS platforms, as well as ESXi.
As always, the more feedback and real-world testing we receive, the more we can improve and refine the software.
In a nutshell
- on big files, up to about three times faster than 7-Zip (see the short answer);
- a faster compression overall;
- a MUCH faster new experimental
-turbomode for parallel processing; - two back-compatible brand new compression methods,
-m6(LZ4) and-m7(LZAV); - mount support on FreeBSD, OpenBSD and macOS;
- a completely redesigned mount cache, making large-file reads much faster;
- improved hash healing and
-touch; - new NAS, ESXi and macOS builds;
- numerous portability fixes and additional autotests.
The headline: a is faster, and you do not have to do anything for it.
With the default method (-m1), 65.3 writes the very same archive as
65.2, to the byte, in three quarters of the time. Add -turbo and it takes
half the time, and still writes the very same archive.
(Byte for byte means checked with cmp, using -timestamp so that the date of the version is the same too.)
This document has four parts:
- Part one: what is new, in short, for everybody
- Part two: the same things in more detail, for power users
- Part three: "lo spiegone", how it works inside, for developers
- Appendix: a test on real data, zpaqfranz against 7-Zip, WinRAR and zstd
How fast is it? The short answer
For compressing big files (virtual machines, disk images, databases), depending on the hardware, zpaqfranz 65.3 is up to about three times faster than 7-Zip in its fastest mode, and it uses a fraction of its CPU.
In the test at the end, on an 11.8 GB virtual machine:
-m7 -turbo: 5.7 s, against 21.9 s of 7-Zip (-mx1) and 40.4 s of WinRAR (-m1): 3.8 and 7 times faster (the archive is 4% bigger than 7-Zip's);-m1 -turbo, which writes the smallest archive of the test: 13.7 s, 1.6 times faster than 7-Zip.
Please read those numbers with the right eyes:
- The environment matters. They depend on the hardware and on the operating system (here a Ryzen 9 with 16 cores, Windows 11, a RAM disk). On your machine they will be different. The purpose is not to verify a difference of a few percent, which would need a specific laboratory, but to show how wide the difference is. When a program goes from 500 MB/s to 1.5 GB/s, a wall clock is enough to notice.
- It is an unequal comparison. zpaqfranz cuts every file into fragments, computes their SHA-1, deduplicates, hashes every file and keeps an index of all the versions.
tar | zstd, 7-Zip and WinRAR do much less. zstd practically does nothing but compress, and it is fast also for this reason: on this data it is faster than zpaqfranz, and that is fine. - With many small files the gap is small: 1.25 times faster than 7-Zip, and
tar | zstdis the fastest.
What runs where
| system | -turbo
| -m6 / -m7
| mount
|
|---|---|---|---|
| Windows | yes | yes | yes (WinFsp) |
| Linux | yes | yes | yes (libfuse3) |
| FreeBSD | yes | yes | yes (fusefs-libs3) |
| OpenBSD | yes | yes | yes (the FUSE of the base system) |
| macOS | yes | yes | yes (FUSE-T or macFUSE), not in the universal binary |
| Solaris | yes | yes | no |
| NAS (static, musl) | yes | yes | no |
| ESXi | no | no | no |
-turboneeds more than one thread: with-t1a plainaruns.- The JIT exists only on Intel/AMD processors. The ARM builds (ARM NAS, Apple Silicon) run the interpreter.
- The Apple Silicon half of the macOS universal binary is built, but it was never run: there was no ARM Mac to try it on. If somebody wants to give me an Apple M-series machine for testing, I will be happy to try it there too.
Part one, what is new, for the user
ais faster, same archive-turbo:aeven faster-m6and-m7: fast compression that any zpaq can extractmounton FreeBSD, OpenBSD and macOSmountreads big files much fastermount -test: does the mount work here?- Hashes that heal themselves, a better
-touch - Smaller things, and how to read the banner
1.1 a is faster, same archive
Nothing to type. The same a, with the same default method, is faster:
zpaqfranz a z:\backup.zpaq c:\vm
Measured on Windows (Ryzen 9 7950X3D, 32 threads), with a Debian virtual
machine (11.8 GB) plus 1.1 GB of mixed files (1,238 of them). Sources and archives are on a RAM disk, so
the disk is never the limit: this measures the programs, not the storage. As said in
the short answer, your numbers will depend on your hardware and
operating system: the point is the size of the difference, not a few percent.
| method | 65.2k | 65.3 | 65.3 -turbo
|
|---|---|---|---|
-m1
| 34.4 s | 25.3 s | 17.1 s |
-m0
| 31.7 s | 25.9 s | 9.6 s |
Why is -m0 (no compression at all) not faster than -m1? Because the limit is not the
compression: it is the cutting of the files into fragments, done by one single thread.
That is exactly what -turbo attacks.
And, as said at the top, the archives are identical: the same file, not just the same size.
That also means that deduplication against your old archives keeps working exactly as before.
Details: Part two, 2.1, Part three, 3.3.
1.2 -turbo: a even faster
zpaqfranz a z:\backup.zpaq c:\vm -turbo
-turbo spreads the slowest part of a over all the cores: cutting the
files into pieces (the deduplication fragments) and computing their SHA-1.
It only does it on files of 4 MB or more, where it pays. The small ones go the
usual way.
The archive is the same one with or without -turbo. It is experimental because it is new code, not because it writes something different:
the autotest checks it every time (work fc of the two archives).
How much it gives. The gain comes from working inside one file, so it grows with the size of the file:
- huge files (virtual machines, disk images, databases) on machines with many cores: the best case. About 3 times faster with
-m0,-m6,-m7; 1.5 to 1.7 times with-m1, which then becomes limited by the compression itself; - medium files (tens of MB): some gain;
- many small files: little or nothing;
- slow methods (
-m2and up): little, the compressors are the bottleneck, not the fragmenter; - slow disks: the disk is the limit, and
-turbocannot help.
The price is more total CPU time (about +50%: with -m1 on the virtual machine 154 s of CPU became 225 s): many threads
cutting at the same time. Wall time goes down a lot, CPU time goes up somewhat.
With one thread (-t1) there is nothing to spread, and the usual code runs. The ESXi build does not have
-turbo (it says so, and does a plain a).
Details: Part two, 2.2, Part three, 3.2.
1.3 -m6 and -m7: fast compression that any zpaq can extract
Two new methods, meant for speed, for the big backups where -m1 takes too
long or too much CPU.
zpaqfranz a z:\vm.zpaq c:\vm -m7 :: LZAV
zpaqfranz a z:\vm.zpaq c:\vm -m7h :: LZAV "hi": smaller, slower
zpaqfranz a z:\vm.zpaq c:\vm -m6 :: LZ4
zpaqfranz a z:\vm.zpaq c:\vm -m6h9 :: LZ4 HC, level 9
The one to try is -m7: about as fast as -m1, a few percent bigger, and
it uses a small fraction of the CPU. With -turbo it becomes the fastest way
to put a big virtual machine into an archive:
| (same data as 1.1) | time | archive |
|---|---|---|
-m1
| 25.3 s | 6.10 GB |
-m7
| 28.8 s | 6.46 GB |
-m7 -turbo
| 9.6 s | 6.46 GB |
-m7h -turbo
| 14.8 s | 6.23 GB |
The important thing: any zpaq can extract them, and quickly. Not just this
zpaqfranz: older zpaqfranz versions, and the original zpaq 7.15 from 2016, too.
The decoder travels inside the archive. This zpaqfranz simply recognizes it and
uses a native decoder instead, which is faster still.
Both are marked experimental: try them, and keep -m1 (or whatever you
use now) for the backups that matter until you trust them.
Details: Part two, 2.3, Part three, 3.4.
1.4 mount on FreeBSD, OpenBSD and macOS
mount (an archive as a read-only drive or folder) was Windows and Linux.
Now:
- FreeBSD (fusefs-libs3)
- OpenBSD (the FUSE that comes with the system, nothing to install)
- macOS, with FUSE-T (no kernel extension) or macFUSE
zpaqfranz mount /backup/nas.zpaq /mnt/zpaq
zpaqfranz mount ~/nas.zpaq :: macOS: makes the folder, opens the Finder
Ctrl+C now reliably unmounts on every system. Previously, on Linux, it left
behind a dead mountpoint ("Transport endpoint is not connected").
The mount is an option you build in (-DZPAQMOUNT); how to tell whether your executable has it is
explained in 1.8. How to build it on each system: "Building with Mount
Support", at the top of the source.
Details: Part two, 2.4, Part three, 3.5.
1.5 mount reads big files much faster
The cache of mount has been rewritten. Previously, with many cores, it threw a
block away the moment it had decompressed it, and decompressed it again at
the next read. A 531 MB file took 17 seconds and 158 decompressions of just
24 blocks, and one packed with -m2 was still being read after 13 minutes. Now
every block is decompressed once.
A program that jumps around inside a file (a video player, a database)
no longer makes the mount decompress blocks that nobody will read.
1.6 mount -test: does the mount work here?
zpaqfranz mount z:\backup.zpaq z:\mnt -test
Mounts, reads every file through the mountpoint, checks it against the
hash stored in the archive, reads pieces at random positions, unmounts, and
says Mount test: OK (exit code 0) or what is wrong. Handy before trusting a
new machine, a new FUSE, a new container.
1.7 Hashes that heal themselves, a better -touch
When only the attribute of a file changed (read-only, archive bit,
permissions), a stores a new version without reading the file again, and
carries the hash over from the previous version. But if that previous hash
was not usable (an archive made by zpaq 7.15, or with another hash
algorithm, or a zero hash written by an old version), v would say FAILED for
that file forever. Now that file is read again and its hash stored. Same
content, so no space is used: one read, once.
-touch does the same thing on request, on files that did not change at all:
zpaqfranz a z:\old715.zpaq c:\data -touch
Previously, it faked a date change on every file, re-read and re-stored
everything, and needed a second run to put the real dates back. Now it reads only
the files without a usable hash, stores them once, with their real date, in
one run. Also handy to switch to another hash:
zpaqfranz a z:\archive.zpaq c:\data -touch -sha256
Details: Part two, 2.5, Part three, 3.7.
1.8 Smaller things
- Windows:
c -checksum,rand others no longer crash or quit when the
output is redirected to a file (a scheduled task,> log.txt). - Windows: extracting to
-to ./somewhereno longer creates a folder
called_. - OpenBSD: the JIT works there too (it was off: everything was
interpreted). Extracting a-m5archive went from 93 to 56 seconds. - A build without optimizations (no
-O) says so at startup, because it is
5 to 10 times slower. -touch -summaryno longer takes-summaryas the date of-touch.- The "open" version (no network, no external components) no longer contains
anything aboutmount(FUSE, WinFsp). - The banner tells how your executable was built. Look at it carefully:
inzpaqfranz v65.3-JIT67+M,...theJITmeans that the JIT is there (so an Intel/AMD
64-bit build), the6and the7mean that both-m6and-m7are built in, and the+M
means thatmountis active. Please always include the banner line when you report something.
Part two, the details, for power users
- Faster
awithout-turbo -turbo-m6(LZ4) and-m7(LZAV)mount- Hash healing,
-touch - Fixes
- The autotest
- Building
2.1 Faster a without -turbo
Four changes in the compression library. None of them changes a single
byte that is written:
- The LZ77 hash table of
-m1/-m2(64 MB) is now zeroed on purpose
when it is allocated. Previously, it was read before it was ever written: every
first touch of a page mapped the shared zero page, and the first write then
had to copy it and flush the TLB of every core (copy-on-write). On Linux,
-m1on 4 GB with 4 threads spent 40 of its 160 CPU seconds in the kernel
just for that. - Prefetch: the hash of the position 32 bytes ahead is computed as well,
and its cache line is requested from RAM before it is needed.-m1on
4 GB, 4 threads, Linux: 42.4 s and 163 CPU seconds became 15.9 s and 62. - Stored blocks (
-m0, and the new-m6/-m7) are copied a buffer at a
time instead of one byte per call (a quarter of the CPU of-m0was there),
and the archive writer does the same. lg()is a single instruction (__builtin_clz).
What is left is the main thread of add(): cutting the files into fragments,
one byte after the other. That is what -turbo is about.
More on this in Part three, 3.3.
2.2 -turbo
- Only for plain files of 4 MB or more (
ZPAQ_TURBOMIN, can be changed
at build time with-DZPAQ_TURBOMIN=...). Measured: below 2 MB it is slower
(up to -25% on 256 KB files), from 4 MB on it is always faster (+24%-m1,
+48%-m7at 4 MB already). - Not for stdin,
-image, memory files,-debug -zero, or with one thread
(-t1): there the usual loop runs. - Memory: two batches of 64 MB (+ one fragment), allocated at the first big
file only. -verbosetells how it went:
-turbo 3.362 pieces, 12.407.896 bytes cut on the main thread(on the data of the first table of
1.1: only 12 MB had to be cut serially).- Not in the ESXi (ANCIENT) build: there
-turbosays so and runs a plaina.
The NAS builds have it. - The price is more total CPU (about +50%:
-m1on the virtual machine, 154 s of CPU became 225 s):
many threads cutting at the same time, often on SMT siblings, which are slower per
thread than one thread alone, plus a small overlap where two chains cut the same bytes.
Wall time goes down a lot, CPU time goes up somewhat.
Today -turbo is an option, and add() is untouched: -turbo runs
add2(), a copy of it. The plan is to make it the default, and remove the
old path, once it has been proven enough.
How it works: Part three, 3.2.
2.3 -m6 (LZ4) and -m7 (LZAV)
Syntax:
| switch | what |
|---|---|
-m6
| LZ4 default, 16 MB blocks |
-m6h9
| LZ4 HC, level 1..12 (9 is the sweet spot) |
-m6a8
| LZ4 fast, acceleration N (bigger = faster, larger) |
-m66h9
| the digit after the 6 is the block size: 2^6 = 64 MB |
-m7
| LZAV default, 16 MB blocks |
-m7h
| LZAV "hi": smaller, slower |
-m76
| LZAV with 64 MB blocks |
- Why LZAV beats LZ4 here: LZ4 looks back 64 KB. LZAV looks back 2 MB, and
farther (up to 256 MB, within the block) when the offset carry allows it.
So-m7is smaller than-m6, and just as fast. - A block that does not shrink is stored, as
-m1does. - Extraction: zpaqfranz 65.3 decodes natively (faster than
-m1). Any other
zpaq runs the decoder written in ZPAQL that is inside the block: fast too,
only the native one is faster still. It is also howp -verifydouble checks (see
Part three, 3.4). -m6/-m7can be switched off at build time: comment out#define ZPAQLZ4
/#define ZPAQLZAVat the top of the source (then they are-m5again,
as in older versions). They are off by themselves in the ANCIENT/ESX builds.
2.4 mount
Where it builds:
| system | FUSE | build (see the top of the source) |
|---|---|---|
| Windows | WinFsp (DLL loaded at run time) | -DZPAQMOUNT -I".../WinFsp/inc"
|
| Linux | libfuse3 | -DZPAQMOUNT $(pkg-config fuse3 --cflags --libs)
|
| FreeBSD | fusefs-libs3 + kldload fusefs
| same, the -I of pkg-config is NOT optional
|
| OpenBSD | FUSE 2.6 of the base system | -DZPAQMOUNT -lfuse, root only
|
| macOS | FUSE-T or macFUSE | -std=c++11 -DZPAQMOUNT -I/usr/local/include/fuse3 ... -lfuse3
|
Unmounting: Ctrl+C, kill (SIGTERM), SIGHUP, and umount from another
terminal all end with zpaqfranz quitting by itself and nothing left mounted.
If something still is (a kill -9, an exit from somewhere else), an atexit
hook tries fusermount3 -u -z, fusermount -u -z, umount -l, umount,
umount -f, in this order, and checks after each one. The hint printed is the
right one for the system (fusermount3 -u on Linux, umount elsewhere).
macOS: no mountpoint (or *) = one is made (/Volumes/<archive> as root,
otherwise in $TMPDIR), the Finder opens on it, and it is removed at the
end. The Finder shows the name of the archive (-fuseopt volname=X for
another one).
Speed:
- one cache for the whole budget (
ZPAQFUSE_CACHE_MB, default 256), the
least recently used block goes first, and never the one just made.
Decompression happens outside the lock, onZPAQFUSE_SHARDSreaders. A block
wanted by two threads is decompressed once. - read-ahead (4 blocks, never more than half of the cache) only for somebody
reading the file from one end to the other. - the position inside a file is found with a binary search, set up once
atopen(). Previously, the fragment list was walked from the start at every
read, which cost minutes on a 100 GB file. - macOS: files declare 1 MB blocks (
st_blksize): macFUSE reads in chunks of
that size, 4 KB meant 12 s instead of 1.2 s for a 673 MB file.
Measured: FreeBSD 14.2, a 337 MB file in 2.0 s with one decompression per
block. macOS 12.7, 673 MB in 1.1 s (macFUSE) and 2.5 s (FUSE-T). OpenBSD 7.9,
248 MB in 1 s (it was 12, before the JIT worked there).
mount -test checks: every file of the mounted version is there, with its
size and date (2 s tolerance), nothing more is there (the tree is walked), the
content matches the stored hash (XXHASH64, SHA-256, whatever the archive has;
size and date only for 7.15 files), and 8 random pieces per file are
identical to a sequential read. With -all, every version folder can be walked. The
list of what is expected comes from the index read the way x and v read
it, not from the mount. So the two readers check each other.
More on this in Part three, 3.5.
2.5 Hash healing, -touch
a: a file with the same date and size, a different attribute, and no
usable previous hash (all zeros, another algorithm, 7.15) is read again.
-debug3printsno usable hash (attribute-only change), reading again.-touchworks on the same condition, without the attribute change. Only
files without a usable hash are read, the real dates are kept, one run.
-touchfollowed by another switch (-touch -summary) is no longer taken
as-touch <date>.- Autotest:
out51..out55(attribute change,v,-715then
-touch -sha3,v,-touchagain = nothing to do).
More on this in Part three, 3.7.
2.6 Fixes
- Windows, output redirected:
setupConsole()used toexit(6)when stdout
was not a console, andterminalwidth()/terminalheight()read an
uninitialized structure (garbage, then a crash inc -checksum). Both fixed. franzsanitizepath(): a.component followed by something (./locale/x)
is dropped instead of becoming_...stays neutralized.myaddfile(): no more negative row count turned unsigned on tiny consoles.- OpenBSD: JIT via
mprotect(W^X), see Part three, 3.6. -DDEBUGand-DNOLMbuild again (they were broken in 65.2 too).
With-DNOLM,custom_log2()no longer callslog2().- ESX/NAS/ANCIENT:
zip -deflateis not available there (clear message
instead of a build error),-hugechecks under#ifndef ANCIENT. - License list: entry 26 is WinFsp (it said ascii-art).
- ESXi build (gcc 3.4.6, C++98): it did not compile any more. The members
ofJidachad in-class initializers (now set in a constructor), and a few
newer bits (std::unique_ptr,vector::data(),>>closing two templates)
are now written the C++98 way. Same code, same behaviour elsewhere. - Solaris: the system headers define
_LITTLE_ENDIANas an empty macro,
so the endianness test insidelzav.hdid not even parse ("operator '=='
has no right operand"). The three macros are pushed away whilelzav.his
read and given back right after (#pragma push_macro/pop_macro, only on
__sun);lzav.hitself is untouched. - ARM, unaligned CRC-32:
crc32_16bytes()read the buffer through a
uint32_t*. On ARMv7 gcc may merge those loads into oneLDM, which does
not tolerate an unaligned address:tandxdied with "Bus error" on a
Cortex-A9 NAS (and on ARMv5 an unaligned load silently returns rotated
data). Nowmemcpy: the same single load on x86/aarch64, same values,
same speed. - Thread stack: threads were created with the libc default stack: 8 MB
with glibc, but 128 KB with musl, 512 KB on macOS, 1-2 MB on FreeBSD.
-m6/-m7(hash tables on the stack) segfaulted with musl. Now the
compression and extraction threads get at least 8 MB (virtual memory). - Autotest with
-franzen:out30/out38compared the decoded.franzen
with a cleartext.zpaqthat, since the Franzen-only archives of 65.1, is
no longer written, so they always failed. Now they test the decoded archive
(t, with-keyfor the AES one). a -franzenwhen the.franzencannot be made (the typical case:
libsodium not installed; with libsodium nothing changes): it printed
"cannot create franzen" and went on. With a Franzen-only archive (the
default since 65.1) nothing at all was written, yetaended with "all OK"
and exit code 0. Appending to an existing.zpaqupdated it and left the
.franzenbehind. Nowastops with an error (exit code 2) before writing
anything.
2.7 The autotest
autotest -all -to <folder> now also covers:
| out | what |
|---|---|
| 51-55 | hash healing, -touch
|
| 64 | names with UTF-8, CJK, emoji, %, %3A, spaces, empty ones
|
| 56-63, 65-67 | mount -test of the archives above (only if a real mount works on this machine)
|
| 68-76 | -m6, -m6h9, -m66h9: a, t, p -verify (ZPAQL), w, mount
|
| 77-82 | -m7, -m7h: the same
|
| 83-86 | -turbo: the archive must be identical to the one of plain a
|
The script does a cd into the test folder as its first line (some steps use
relative paths), so it can be started from anywhere.
2.8 Building
- New defines, on by default:
ZPAQLZ4(-m6) andZPAQLZAV(-m7). Comment
them out at the top of the source to build without. They stay in the-DNAS
builds (made with modern compilers), not in ESX/ANCIENT.-turbofollows the same rule:
present in the NAS builds, not in the ESXi one. - The PCOMP are frozen: the SHA-1 of the bytecode of
LZ4_PCOMPand
LZAV_PCOMPis written in the source.autotestchecks it first of all
(and says "Do NOT release this build" if it changed), and-m6/-m7refuse
to write a block if it does not match. - NAS binaries: static, musl (they start on 2.6.x kernels too),
-DNAS,
for x86_64, i686, ARMv5, ARMv7 (generic, Cortex-A9, Cortex-A15) and ARMv8
(generic, A53, A55, A57, A72), each one tested under qemu on its own core.
They include-turbo,-m6and-m7. SeeNAS_BINARI.txt. - Tuning knobs:
-DZPAQ_TURBOMIN=<bytes>(default 4 MB),-DZPAQ_LZAHEAD=<n>
(default 32, the prefetch distance of-m1). - Checked with
-Wall: Windows (mingw g++), Fedora 44 (g++ 16.2) with plain,
-DHWSHA2,-DZPAQMOUNT,-DNAS,-DESX,-DANCIENT,-DBIG,
-DALIGNMALLOC,-DNOJIT,-DDEBUG,-DNOLM,-DIPV6,-DSFTP,-DOPEN,
-DOPEN -DZPAQMOUNT, and the purged "open" source with and without
-DZPAQMOUNT. - Also built and tested (
autotest, thena/t/xand compare with every
method and hash): ESXi (-DESX, gcc 3.4.6 on CentOS 3.9, static) and
Solaris 11.4 (-DSOLARIS, gcc 7.3, amd64:
g++ -O3 -DSOLARIS zpaqfranz.cpp -o zpaqfranz -pthread -static-libgcc -lkstat).
On openSUSE Tumbleweed (g++ 13.2, libfuse 3.16.1) the full
autotest -allpasses,mountand Franzen included. - macOS universal binary (Intel + Apple Silicon in one file), without
mount, so it starts on any Mac with no FUSE installed. The two halves are
built apart, because only the Intel one can have the JIT, then joined:
clang++ -arch x86_64 -mmacosx-version-min=10.15 -O3 -std=c++11 -Dunix -DHWSHA2 zpaqfranz.cpp -o zf_x86 -pthread
clang++ -arch arm64 -mmacosx-version-min=11.0 -O3 -std=c++11 -Dunix -DNOJIT zpaqfranz.cpp -o zf_arm -pthread
lipo -create zf_x86 zf_arm -output zpaqfranz && strip zpaqfranz
No warnings with -Wall on either half. It runs on macOS 10.15 or later
(Intel) and 11.0 or later (Apple Silicon). The Intel half was tested on
macOS 12.7 (autotest, then a/t/x and compare with every method and
hash). The Apple Silicon half is built but not run here: there was no ARM
Mac to try it on.
- The open version: everything about
mount(FUSE/WinFsp headers, the
command, its help, its autotest, the build notes, the libfuse and WinFsp
licenses) is now between///NOSFTPSTARTand///NOSFTPEND, so the purge
removes it.-DOPENwithout the purge does#undef ZPAQMOUNTtoo.
Part three, lo spiegone, for developers
- How
acuts a file, and why that matters -turbo: the same cuts, in parallel- The libzpaq changes (default path, no byte changed)
-m6and-m7: a decoder inside the archivemount, inside- The JIT on OpenBSD (W^X)
- Hash healing, inside
- The open version
3.1 How a cuts a file, and why that matters
zpaq does not deduplicate files, it deduplicates fragments. Every file is
cut into pieces of variable size, each piece is identified by its SHA-1, and a
piece already in the archive is not stored again, just referenced.
Where to cut is decided by the content, not by the position. A rolling hash
runs over the bytes, together with an order-1 predictor (o1[c1]: "after
byte c1, last time came byte o1[c1]"). In pseudocode:
for every byte c:
hit = (c == o1[c1])
h = (h + c + 1) * (hit ? 314159265 : 271828182)
o1[c1] = c; c1 = c
cut if (h < 2^(22-fragment) and size >= MIN_FRAGMENT) or size >= MAX_FRAGMENT
With the default -fragment 6: a cut every 64 KB on average, never under
4 KB, never over 520,192 bytes. Because the cut depends on the content, inserting
a byte at the start of a file moves the cuts near the insertion, and after a
few fragments they fall exactly where they were before. The rest of the file
deduplicates.
This is also why the algorithm cannot be changed: other cuts mean other
fragments, other SHA-1, and no deduplication at all against every archive ever
written. Anything faster has to produce the same cuts.
The predictor also feeds the type analysis (hits, the o1 table: text?
executable? already compressed?) that picks the model for the block. So that
has to be identical too.
3.2 -turbo: the same cuts, in parallel
Why it is needed
Compression runs on many threads. The fragmenter does not: it runs on the main
thread, one byte at a time, together with the SHA-1 of the fragments and the
hash of the whole file. With the fast methods (-m0, -m1, -m6, -m7) the
compressors are waiting for it. perf, -m7 on a VM disk, before -turbo:
33.93% Jidac::add() the fragmenter: rolling hash, one byte at a time
23.72% lzav_compress worker threads, NOT on the critical path
19.88% sha1_transform_x86 mostly the SHA-1 of every fragment
So a 16/32 core machine spends most of its time waiting for a single core, at
about 500 MB/s.
Guess, then prove
The fragmenter looks serial: h carries from one byte to the next. But
its state starts fresh at every cut: after a cut, h = 0, c1 = 0, o1
all zeros. That is the whole trick: everything after a cut depends only on the
bytes after that cut.
add2() reads a file in batches of 64 MB. Each batch is split into
N pieces (one per thread, at least 1 MB each). Every thread cuts its piece
as if a fragment began exactly at its start: that is a guess. The first
piece is not a guess, because the batch begins on a real cut. Each thread also
computes the SHA-1 of each fragment while it is still in the cache, so the
SHA-1 goes parallel too, for free.
Then the true chain is followed. It starts with the fragments of piece 0.
When the true chain ends a fragment exactly where the chain of piece 1 also
has a cut, from that byte on both start fresh on the same bytes: they are
the same chain, and the guess of piece 1 is proven right. The chains of a
rolling hash synchronize by themselves, and usually within a fragment or two.
piece 0 |--a--|---b---|--c--|-d-|--e--|--f--|
piece 1 |--x--|---y---|--e--|--f--| (starts from a guess)
^
both chains cut here, at the start of e:
from this byte on, piece 1 IS the true chain
If they do not meet (rare), the true chain goes on by itself (add2_scan
with dopo=1, one fragment at a time on the main thread) until they do. Each
piece goes 3 cuts past the start of the next one, to leave room for the
meeting. Nothing is trusted that was not proven. The -verbose counter says
how much was serial: 12 MB, on the data of the first table of
1.1, in 3,362 pieces.
The fragment still open at the end of a batch (less than MAX_FRAGMENT) is
copied in front of the next batch (TURBOROOM), so every batch starts
on a cut.
The rest of the pipeline
reader thread reads batch N+1 (64 MB), updates the hash of the whole file
cutter threads cut batch N into fragments + SHA-1 of every fragment
main thread merges the chains, then for every fragment, IN ORDER:
lookup, type analysis, new block decision, sb.write
compressors as always
Every fragment, in order, goes through elabora: a lambda holding the
same code as the loop of add() (lookup, type analysis, new block
decision, sb.write), with the data taken from the batch instead of
fragbuf. It was compared line by line with the original. Same fragments,
same order, same decisions: same archive.
Around it:
- double buffering: a reader thread (
add2_readthread) reads the next
batch while the current one is cut, and updates the hash of the file
(XXHASH64, SHA-256...) on it. One batch at a time, in order, so the file hash
sees the bytes in the same order asadd(). - branchless fragmenter:
(uc == o1[c1])as a value, not as anif. The
first version, with theif, was slower than the original: on compressed
or random data the branch is a coin toss, and a mispredicted jump on every
byte costs more than it saves. - files under
ZPAQ_TURBOMIN(4 MB), and one thread, take the old loop:
starting threads for a small file costs more than it saves.
Why a separate add2() and not a patch to add()
add() is the most important function of the program, full of special cases
(stdin, -image, memory files, NTFS/VHD imaging, -zero...). add2() is a
copy with a different loop, for plain files only, selected by -turbo.
add() is not touched at all, so if anything is wrong you just do not use
-turbo. Stdin, memory files, images and -zero go through the original loop
even inside add2().
Verified
Identical archives on Windows for -m0, -m1, -m7, -m7h
(11.8 GB VM + 1,238 mixed files, two versions), identical to 65.2k for
-m0/-m1. A matrix of 23 cases, two versions each, -turbo against plain
a, all identical to the byte: every hash (-sha256, -blake3, -xxh3,
-sha3, -md5, -whirlpool, -crc32, -nochecksum, -715), -verify,
-t1, -t2, -m0, -m2, -m3, -m6, -m66h9, -m7h, -m76,
-fragment 4, -fragment 9, -nodedup, -store. And in the autotest
(Windows and Fedora, 86/86), every time.
3.3 The libzpaq changes (default path, no byte changed)
The COW trap. LZBuffer allocates the LZ77 hash table (ht, up to
64 MB for -m1) with calloc(). For an allocation that big, calloc returns
fresh pages that were never touched: all mapped to the shared zero page.
fill() reads ht[h] before it writes it. The first read of each page
maps the zero page, and the first write then has to copy it (copy-on-write)
and invalidate that mapping on every core running the process (a TLB
shootdown, an inter-processor interrupt to each core). With many threads
each doing it on its own table, that was 25% of the CPU of -m1, in the
kernel. A memset right after the allocation writes every page once. After
that they are ordinary pages.
Prefetch. Every lookup of ht is a cache miss (a 64 MB table, random
index): perf showed 40% of fill() on the single line p=ht[h1^k]. The hash
of a position is known well before it is needed: a second rolling hash,
hpre, runs ZPAQ_LZAHEAD (32) bytes ahead and calls
__builtin_prefetch(&ht[hpre]). It needs no care: after minMatch steps the
old bytes are shifted out of the mask, so it is right by itself, and a wrong
prefetch only costs a little bandwidth. A bucket is 16 words = 64 bytes = one
cache line: one prefetch is enough. Measured distances: 1 → 5%, 8 → 18 s,
16 → 16.2 s, 32 → 15.9 s, 64 → 16.2 s.
Stored blocks. Encoder::compress(c) with no model appends one byte per
call to a buffer, and flushes it (with a 4 byte length prefix) when full.
compressstored(p, n) does the same with memcpy, flushing at exactly the
same points, so the output is the same. Compressor::compress() uses it
when there is no model and no verify. OutputArchive::write() does the same
for put(): the flush points (and so what encryption and -chunk see) are
the same.
3.4 -m6 and -m7: a decoder inside the archive
A zpaq block starts with its own description (comp / hcomp / pcomp):
comp hh hm ph pm n sizes of H and M arrays, n = number of models
hcomp ... ZPAQL program computing contexts for the models
pcomp ... ZPAQL program run on the decoded bytes (post-processor)
Every zpaq decoder, 7.15 included, must run the pcomp program on the
decoded bytes, and what it outputs is the file. That is the whole idea:
n = 0: no model at all. The arithmetic coder then stores the bytes as
they are (like-m0). Decoding costs nothing.- The stored bytes are an LZ4 (or LZAV) stream, compressed by zpaqfranz
with the real library. - The
pcompis an LZ4 (LZAV) decoder written in ZPAQL, a few dozen
instructions. Any zpaq runs it and gets the original data back.
So the format of the archive does not change at all: it is a valid zpaq
block, with a post-processor, which zpaq has always supported (that is how
-m3/-m4 do BWT and E8E9).
The native shortcut. Running the ZPAQL decoder is already fast (any zpaq
does it, better with the JIT), but a native decoder is faster still. So when
the pcomp of a block arrives, its compiled bytecode is
compared with the one of LZ4_PCOMP/LZAV_PCOMP (lz4_is_canonical,
compiled once, thread-safe static). If identical, the stored bytes are
collected and handed to ZLZ4_decompress_safe() / lzav_decompress_3()
instead. The output is the same: the ZPAQL program defines what is correct.
A block that is not a valid stream is an error natively, and would be
garbage through ZPAQL. That is why not a single byte of those programs may
ever change: old archives carry the old bytecode. (It happened once, in
65.3w, when four redundant instructions were removed from the LZ4 decoder:
archives made by earlier 65.3 test builds still extract, through ZPAQL.)
-m6, LZ4. comp 0 0 0 16 0: pm = 16, M is a 64 KB ring buffer, exactly
the LZ4 window (offsets are 16 bits). The decoder is a small state machine in
r1: token → literal length (15 = more bytes follow, 255 = go on) →
literals → 2-byte offset → match length → copy from M[b - offset].
-m7, LZAV (format 3). LZ77 with a 2 MB window that the offset carry
(below) stretches up to 2 MB << 7 = 256 MB: an offset can point anywhere in the
block. So comp 0 0 0 (20+B) 0: M holds the whole block (16 MB with the
default), not a ring buffer as in -m6. Harder
to decode in ZPAQL:
- 4 block types (literals, references with 10, 15, 21 bit offsets);
- lengths in 7-bit groups;
- offset carry: the low bits of an offset come from blocks before it
(the top bits of the last offset byte of the previous reference, theCC
bits of a literal block in between). A decoder that reads in order always
has them:r3(carry value) andr4(carry shift), then
offset = (own bits << csh) | cv. - Trap: ZPAQL
ifis a short jump (±127 bytes). The body of the offset state
is longer, so it needsifl(long if). Otherwise: "IF too big".
The ZPAQL decoders are tested every time by the autotest: p -verify is
unzpaq, a separate decoder that runs the ZPAQL, and compares with the
files on disk (out70, out73, out75, out79, out81).
LZ4 1.10 and LZAV 5.17 are included unmodified, each in a namespace of its
own (zlz4, zlzav). zpaqfranz had its own LZ4 1.9 (decompression only,
Windows only, for the file lists in NTFS ADS): it is gone, the 1.10 does
that too, and ADS written by the 1.9 are read by the 1.10 and vice versa
(checked). The LZ4 names were renamed ZLZ4_ mechanically, and 36 generic
macros are saved and restored with push_macro/pop_macro.
3.5 mount, inside
Layers
Jidac::mount() arguments, backend, FUSE loop, -test
franzmount FUSE layer getattr / readdir / open / release / read / statfs / init
franzmount generic paths, names, dates, MountCache, mount_read_range()
MountBackend "jidac" versions, children, entries, fragment -> block, decompression
The cache, before and after
Before: N shards (N = cores), each with its own reader, mutex and LRU,
block → shard by hash of the offset. 256 MB on 16 cores = 16 MB per shard,
less than one block (~20 MB with -m1, 64 MB and more from -m2 up).
Each block was evicted the moment it was inserted. On top of that, the shard
mutex was held during the decompression, so a read of a cached block waited
behind the decompression of another block that happened to land in the same
shard.
Now (MountCache):
- one
unordered_map+ one LRU list for the whole budget, under one mutex; - an entry can be
loading: a second thread wanting the same block waits on
a condition variable instead of decompressing it again; - decompression runs outside the lock, on a pool of readers (each with its
own archive handle). A prefetch leaves the last free reader to a
foreground read; - the block just made is never evicted, even if bigger than the budget;
- data handed out as
shared_ptr<const string>: a 4 KB read of a 16 MB block
costs no copy, and eviction during a read is harmless.
A file, once opened
open() resolves the path once and builds starts[], the offset of every
fragment. fh points to a MountFile. read() finds the first fragment with
upper_bound (binary search) instead of walking the list. release() frees it.
The size of the file is starts.back().
Read-ahead only for a sequential reader: the first read at offset 0, or a
read starting within MOUNT_SEQ_SLACK (8 MB) of where the previous one ended.
The slack is needed: kernels read ahead by themselves, from several threads,
and requests do not arrive in order (macFUSE asks a 673 MB file as 1 MB
requests from a handful of threads). At most 4 blocks, at most half the cache.
Portability, one system at a time
- Headers:
__has_include(<fuse3/fuse.h>)first, then<fuse.h>.
FreeBSD installs into/usr/local/include/fuse3, which the base clang does
not search. A FUSE 2 header picked up by mistake is an#errorwith the
install hint, not a flood of errors. - OpenBSD: FUSE 2.6 in the base system, no libfuse3 at all
(ZPAQMOUNT_FUSE2). Three differences:getattrwithoutfuse_file_info,
fillerwithout flags (theMOUNT_FILLmacro),initwithout
fuse_config. Directories reportst_nlink = 1: with 2,find(fts "leaf
optimization") concluded there was nothing below and did not descend. - macFUSE: its "Darwin extensions" change the prototypes of
getattr,
readdir,statfs:FUSE_DARWIN_ENABLE_EXTENSIONS 0before the include.
st_blksize1 MB on macOS only. - Signals: libfuse installs its SIGINT/SIGTERM/SIGHUP handlers only if
the current ones are SIG_DFL (set_one_signal_handler). zpaqfranz had its
own SIGINT handler, so libfuse left it alone, Ctrl+C calledexit(1), and
the mountpoint stayed dead. Now, for the time of the mount, the three
signals go back to SIG_DFL, then are restored. - SIGPIPE on FUSE-T: the helper (go-nfsv4) goes away first, libfuse
resets SIGPIPE to SIG_DFL, thenfuse_unmount()writes to the closed
socket: death by SIGPIPE (exit 141) at every unmount.SIG_IGNis not enough
(libfuse takes it as its own and resets it). A handler that does nothing is
neither, and libfuse leaves it alone. - OpenBSD Ctrl+C: the loop sits in a read of
/dev/fusethat signals do
not interrupt. So there the handler runsumounton the mountpoint
(fork + exec are async-signal-safe, the path is in a plainchar[]), and the
loop ends by itself. - Relative archive names:
fuse_daemonize()doeschdir("/")even in
foreground mode, and the readers open the archive lazily, at the first read.
Somount pippo.zpaq /mntshowed the tree and gave EIO on every byte. The
name is made absolute before mounting (not withrealpath(): it may hold
wildcards). - Birth time: impossible outside Windows.
struct fuse_attrhas no field
for it, so the kernel reports "unknown".
mount -test and the WinFsp trap
mount -test runs the check on a thread while fuse_main() serves. To end
the loop from inside, the book says fuse_exit(fuse_get_context()->fuse). In
WinFsp the context of a callback is the FUSE 2 one (fsp_fuse_op_enter:
context->fuse = f), while fuse_exit is fuse3_exit, which takes it for a
struct fuse3 and follows a field of it: a crash, or a random handle
signalled. The FUSE 2 fsp_fuse_exit() fits that pointer and only does
SetEvent() on the loop. It is not in the fuse3 headers, so it is bound by hand,
like the rest of the DLL. The check thread raises g_mount_exit and asks for
a name that does not exist (a different one each time, so no cache answers).
The getattr that comes calls fsp_fuse_exit.
On *nix it simply unmounts, and mount_still_mounted() tells whether it
worked: st_dev of the mountpoint versus st_dev of its .. (no /proc,
so it works on BSD and macOS too). ENOTCONN counts as "still mounted".
Never kill a process with I/O pending on its own WinFsp volume: it
becomes a zombie in the kernel, and the volume stays. The autotest runs the
trial mount in a child process with a timeout and leaves it alone if it is
too slow, instead of killing it.
3.6 The JIT on OpenBSD (W^X)
OpenBSD refuses memory that is writable and executable at the same
time, which is what the JIT asked for (mmap(PROT_READ|PROT_WRITE|PROT_EXEC)).
So it turned itself off and everything ran interpreted. The rule is about the two
rights together: written first, then turned executable with mprotect(), is
allowed, even without wxallowed. Now allocx() tries the usual way first
and, if refused, maps PROT_READ|PROT_WRITE and sets g_jit_mprotect.
protectx() flips the code to PROT_READ|PROT_EXEC once it is written, right
before the first run. If even that fails, the interpreter takes over, as
before. On the other systems nothing changes. OpenBSD 7.9: -m5 extraction
93 → 56 s, -m1 3 → under 1 s.
3.7 Hash healing, inside
The loop of add() that decides what to read looks at date and size. The
loop that writes the index (what to store) also looks at the attribute.
So an attribute-only change stored a new version of a file that was never
read, with the hash carried over by carryoverhash(). If that hash was all
zeros (older versions), of another algorithm, or missing (7.15), the zero
placeholder was carried on forever.
Now, in the first loop: same date, same size, and (attribute changed, or
-touch): carryoverhash() is asked. If it returns nothing usable,
rereadforhash makes the file be read, and DT::rehash tells the second
loop to store it even if nothing else changed. Same content → same fragments
→ no new data, one read.
3.8 The open version
The "open" source is produced by removing every line from a
///NOSFTPSTART to the next ///NOSFTPEND (inclusive). #define ZPAQFULL is
on such a line, so it disappears too. The purge is line based and does not
nest: a START inside a region that is already being removed would be
swallowed, and its END would stop the purge early. So the mount blocks
were marked where they are not already inside a purged region (the
mount part of the autotest script already was).
Marked now: the FUSE/WinFsp headers, the mount declaration, help_mount,
its entry in the help map, +M in the banner, the mount command and its
switches (-fuseopt, -backend), the whole franzmount namespace and
Jidac::mount(), the dispatch, autotest_spawn(), the expected mount results
in checkautotest(), the "Building with Mount Support" chapter and the mount
build lines, the libfuse and WinFsp licenses. Result: 147,895 → 117,866
lines, and no reference left to FUSE or WinFsp except in plain text (the zip
help says it works "without FUSE/WinFsp"). The purged source builds with and
without -DZPAQMOUNT (which then has nothing to switch on).
Appendix: a test on real data, zpaqfranz, 7-Zip, WinRAR and zstd
Just an example, for information: one machine, two folders, the fastest
mode of each program. It says nothing about extraction, and nothing about
what each format can do (versions, deduplication, recovery records...).
As said in the short answer, the result depends on the hardware and on
the operating system, and the aim is not to measure a difference of a few percent
(that would need a specific laboratory), but to show how wide the difference is.
This is not the same data as in 1.1 and 1.3.
There the data is a virtual machine plus 1.1 GB of mixed files. Here there are two dedicated
folders, because zstd compresses one file (or one stream), it cannot compress a folder: it needs
a tar in front. That is why -m1 takes 23.1 s here and 25.3 s there: different data, not a discrepancy.
This is not an equal fight. zpaqfranz cuts every file into fragments, computes the SHA-1 of each
one, deduplicates, hashes every file and keeps the index of all the versions. tar | zstd, 7-Zip and
WinRAR do much less. zstd in particular practically does nothing but compress, and it is fast also for this reason.
The machine: Windows 11, AMD Ryzen 9 7950X3D (16 cores, 32 threads),
127 GB RAM, idle (no virtual machine running). Sources and archives on a RAM
disk (see 1.1).
The data, the two opposite ends:
vmtest: a Debian 12 virtual machine (VMware), 16 files, 11.76 GB,
almost all of it in a single 11.7 GB.vmdk.vmtest2: an MSYS2 installation (compilers, headers, Python, man pages...),
116,703 files in 5,381 folders, 6.62 GB, median file size 2.8 KB.
The programs, each in its fastest compression mode (store-only modes left
out):
| program | command |
|---|---|
| zpaqfranz 65.3 | a z:\t.zpaq z:\vmtest -m1 (then -m1 -turbo, -m7 -turbo), -noeta
|
| 7-Zip 26.03 (x64) | 7z a -mx1 -mmt=on -bso0 -bsp0 z:\t.7z z:\vmtest
|
| RAR 7.23 (x64) | rar a -m1 -mt32 -r -idq z:\t.rar z:\vmtest
|
| zstd 1.5.7 (x64) | tar -cf - -C z:\ vmtest | zstd -1 -T0 -q -o z:\t.tar.zst (also --fast=1)
|
For zstd the tar is the one that comes with Windows (bsdtar 3.7.7). The tar alone, with no
compression at all, is measured too, to see how much of the time is its own.
How it was measured: three runs in a row for each, the archive deleted before every run; the tables show the
median. "CPU" is the processor time of all the processes of the command (cmd, tar, zstd...),
summed over all threads: how much of the machine it takes, that is, how much it leaves to everything else.
Everybody can write their own measuring scripts: reproducing the milliseconds is not the point.
When a program goes from 500 MB/s to 1.5 GB/s, a wall clock is enough to notice.
vmtest, the virtual machine (11.76 GB, 16 files)
| program | time | speed | CPU | archive | size |
|---|---|---|---|---|---|
zpaqfranz -m1
| 23.1 s | 509 MB/s | 154 s | 5.64 GB | 48.0% |
zpaqfranz -m1 -turbo
| 13.7 s | 861 MB/s | 225 s | 5.64 GB | 48.0% |
zpaqfranz -m7 -turbo
| 5.7 s | 2,049 MB/s | 53 s | 5.97 GB | 50.7% |
7-Zip -mx1
| 21.9 s | 537 MB/s | 579 s | 5.74 GB | 48.8% |
WinRAR -m1
| 40.4 s | 291 MB/s | 588 s | 6.01 GB | 51.1% |
tar | zstd -1
| 8.1 s | 1,450 MB/s | 22 s | 6.22 GB | 52.9% |
tar | zstd --fast=1
| 8.1 s | 1,461 MB/s | 21 s | 6.57 GB | 55.8% |
| tar alone (no compression) | 8.1 s | 1,446 MB/s | 8 s | 11.76 GB | 100% |
And zstd without tar, straight on the 11.7 GB .vmdk:
zstd -1 -T0 2.95 s (3,981 MB/s, 17 s of CPU, 52.9%), --fast=1
2.97 s (55.9%).
vmtest2, the small files (6.62 GB, 116,703 files)
| program | time | speed | CPU | archive | size |
|---|---|---|---|---|---|
zpaqfranz -m1
| 18.2 s | 365 MB/s | 87 s | 1.41 GB | 21.2% |
zpaqfranz -m1 -turbo
| 15.8 s | 420 MB/s | 108 s | 1.41 GB | 21.2% |
zpaqfranz -m7 -turbo
| 13.8 s | 481 MB/s | 30 s | 1.72 GB | 25.9% |
7-Zip -mx1
| 17.2 s | 384 MB/s | 174 s | 1.47 GB | 22.1% |
WinRAR -m1
| 56.2 s | 118 MB/s | 381 s | 1.98 GB | 29.9% |
tar | zstd -1
| 8.6 s | 775 MB/s | 18 s | 1.88 GB | 28.4% |
tar | zstd --fast=1
| 8.5 s | 781 MB/s | 17 s | 2.11 GB | 31.8% |
| tar alone (no compression) | 8.7 s | 763 MB/s | 9 s | 6.72 GB | 101% |
What it says
- zstd is the fastest compressor here, by far. On the
.vmdkalone it
runs at almost 4 GB/s: twicezpaqfranz -m7 -turbo, seven times 7-Zip,
with very little CPU. Its fame is deserved. But remember that it is also the program doing the
least: no fragments, no deduplication, no hashes, no index, no versions. - zstd alone is not a backup of a folder. Put
tarin front, and
tarbecomes the limit:tar | zstdtakes exactly the time oftaralone
(8.1 s here, 8.6 s on the small files), sincetarreads one file after
the other with one thread, at about 1.4 GB/s on this machine. - Virtual machine:
zpaqfranz -m7 -turbois the fastest way to make an
archive of it: 5.7 s against 8.1 s oftar | zstd(1.4 times faster), 3.8
times faster than 7-Zip, 7 times faster than WinRAR. Its archive is smaller
than zstd's (5.97 GB against 6.22 GB), using more CPU thantar | zstd(53
s against 22 s) but a tenth of 7-Zip's.-m1writes the smallest archive
(5.64 GB): about as fast as 7-Zip (5% slower) with a quarter of its CPU, and with-turbo1.6
times faster than 7-Zip. - Many small files: here
tar | zstdwins clearly, 8.6 s against 13.8 s
ofzpaqfranz -m7 -turbo, with less CPU (18 s against 30 s). Most likely zpaqfranz
spends that time in its own work on each file (path, attributes, hashes of
every fragment for the deduplication), whichtardoes not do.
-turboworks only on files of 4 MB or more, so it helps little here. In
exchange zpaqfranz-m1writes the smallest archive of the test, 1.41 GB
against 1.88 GB oftar | zstd -1(25% smaller), with about the time of
7-Zip and half its CPU. - WinRAR is the slowest in both cases (about 7 times slower than the
fastest), with a big archive, and it uses as much CPU as 7-Zip on the
virtual machine and twice as much on the small files. - The second backup is another story, and it is not in the tables:
tar | zstd, 7-Zip and WinRAR rewrite everything, every time. zpaqfranz
keeps every version in the same archive and stores only what changed: on
a virtual machine, typically a small fraction of the disk. That is where
its time goes, and where it is paid back. - On real storage (SSD, NAS, spinning disks) the times get closer to each
other, because reading becomes the limit: 2-4 GB/s is more than most disks
deliver. The CPU column stays the same.
Note
65.3 is a big step, and a delicate one on a. Nothing changes unless you
ask for it: the default a writes the same bytes as 65.2, only faster.
-turbo, -m6, -m7 are opt-in and marked experimental. Please open an Issue on GitHub
for anything strange, with the banner line (zpaqfranz v65.3-JIT67+M,..., see
1.8): it tells which features were built in.