, 8 min read

Performance Comparison: gzip vs Brotli vs Zstd

This is a continuation of the post Performance Comparison: gzip vs Brotli three years ago. Here we will present measurements for three compression algorithms (gzip, brotli, and zstd) employed on two different hardware architectures (AMD and ARM). The measurements cover all possible compression levels. Input data are all HTML files used on this blog. These were 793 HTML files in total.

1. NGINX and compression

The NGINX web-server offers HTTP compression. The compression algorithms in question are:

  1. gzip
  2. deflate
  3. Brotli
  4. Zstd

My current nginx.conf file uses

gzip on;
brotli on;

gzip_comp_level 9;
brotli_comp_level 10;

gzip_min_length 512;
brotli_min_length 512;

2. AMD Ryzen 7 5700G

For the measurements I used below hardware and software:

Type Value
CPU AMD Ryzen 7 5700G
Clock 4673.8232 MHz
RAM 64GB DDR4-3600
OS Arch Linux kernel 7.1.8-arch1-3
gzip 1.14-modified
brotli 1.2.0
zstd (64-bit) v1.5.7, by Yann Collet

Running the compression programs for all permissable compression levels on this desktop machine. All files were stored in /tmp, i.e., they were all in a RAM disk. All compressed files were also written to /tmp. So processing time is not distorted by file I/O. In total there were 793 HTML files, with roughly 47 MB of data. The individual HTML files, of course, were smaller.

All runtime is given is seconds.

compr.
level
brotli
size
real user gzip
size
real user zstd
size
real user
  47,800,644 47,800,644 47,800,644
0 18,637,258 0.14 0.10 n/a n/a n/a n/a n/a n/a
1 18,042,822 0.18 0.14 17,730,989 0.40 0.36 17,479,236 0.15 0.09
2 16,396,160 0.33 0.29 17,314,433 0.43 0.40 16,798,801 0.17 0.12
3 16,252,214 0.39 0.35 17,117,991 0.50 0.46 16,653,562 0.21 0.16
4 15,397,998 0.66 0.61 16,328,922 0.58 0.55 16,289,749 0.26 0.20
5 14,729,521 0.93 0.87 15,965,453 0.81 0.77 15,900,141 0.40 0.34
6 14,708,705 0.98 0.89 15,920,931 0.97 0.93 15,597,981 0.50 0.45
7 14,695,823 1.04 1.00 15,906,130 1.03 1.00 15,532,238 0.60 0.54
8 14,690,162 1.08 1.03 15,891,227 1.24 1.21 15,486,025 0.64 0.59
9 14,680,471 1.63 1.21 15,889,892 1.32 1.29 15,475,953 0.69 0.65
10 13,106,789 16.31 16.20 n/a n/a n/a 15,469,574 0.79 0.73
11 12,938,604 44.50 44.25 n/a n/a n/a 15,448,926 1.13 1.06
12 n/a n/a n/a n/a n/a n/a 15,445,040 1.16 1.10
13 n/a n/a n/a n/a n/a n/a 15,176,152 2.08 2.01
14 n/a n/a n/a n/a n/a n/a 14,990,965 2.74 2.67
15 n/a n/a n/a n/a n/a n/a 14,947,573 3.13 3.05
16 n/a n/a n/a n/a n/a n/a 14,724,958 4.86 4.78
17 n/a n/a n/a n/a n/a n/a 14,723,797 5.23 5.15
18 n/a n/a n/a n/a n/a n/a 14,727,022 6.05 5.96
19 n/a n/a n/a n/a n/a n/a 14,688,864 10.23 10.15

The above table is depicted graphically below. Size is scaled by 1,000,000 in the graph below so that runtimes and size can be shown in the same graph.

One can clearly see the steep slope for brotli at level 10. At level 15 zstd starts to become noticeably slower.

3. ARM Cortex A720

Next, I used below hardware and software:

Type Value
CPU ARM Dimensity 9300+ Cortex A720
Clock 2000 MHz
RAM 12GB
OS 6.1.138-android14-11-g6ab8c9a86a33-ab14396278
gzip 1.14
brotli 1.2.0
zstd (64-bit) v1.5.7, by Yann Collet

Running the compression programs for all permissable compression levels on this smartphone.

compr.
level
brotli
size
real user gzip
size
real user zstd
size
real user
  47,800,644 47,800,644 47,800,644
0 18,637,258 0.47 0.19 n/a n/a n/a n/a n/a n/a
1 18,042,822 0.33 0.27 17,730,989 1.10 0.74 17,479,236 0.44 0.17
2 16,396,160 0.50 0.43 17,314,433 0.79 0.72 16,798,801 0.29 0.17
3 16,252,214 0.55 0.44 17,117,991 0.92 0.82 16,653,562 0.33 0.22
4 15,397,998 0.84 0.72 16,328,922 1.04 0.93 16,289,749 0.38 0.27
5 14,729,521 1.28 1.16 15,965,453 1.49 1.25 15,900,141 0.60 0.47
6 14,708,705 1.44 1.24 15,920,931 1.75 1.46 15,597,981 0.75 0.60
7 14,695,823 1.76 1.29 15,906,130 1.90 1.58 15,532,238 0.90 0.77
8 14,690,162 2.38 1.46 15,891,227 2.30 2.03 15,486,025 0.98 0.81
9 14,680,471 3.47 1.76 15,889,892 2.43 2.15 15,475,953 1.09 0.93
10 13,106,789 26.50 25.45 n/a n/a n/a 15,469,574 1.22 1.09
11 12,938,604 70.72 68.20 n/a n/a n/a 15,448,926 1.80 1.63
12 n/a n/a n/a n/a n/a n/a 15,445,040 1.87 1.69
13 n/a n/a n/a n/a n/a n/a 15,176,152 3.03 2.81
14 n/a n/a n/a n/a n/a n/a 14,990,965 3.88 3.65
15 n/a n/a n/a n/a n/a n/a 14,947,573 4.29 4.09
16 n/a n/a n/a n/a n/a n/a 14,724,958 5.89 5.68
17 n/a n/a n/a n/a n/a n/a 14,723,797 6.28 6.02
18 n/a n/a n/a n/a n/a n/a 14,727,022 7.16 6.88
19 n/a n/a n/a n/a n/a n/a 14,688,864 11.71 11.37

Depicted graphically.

Qualitatively the picture is pretty similar to the AMD chart.

4. Automating the measurements

Three years ago I made the measurements in a manual way. This time it was clear I needed a script to run the measurements.

The method is the same as three years ago:

  1. Use find to list all HTML files
  2. Use wc -c to count characters
  3. Run each compression algorithm and iterate over the compression levels
  4. Overwrite previous compressed files

Below is the Perl script.

#!/bin/perl -W
# Performance comparison gzip vs. brotli vs zstd

use strict;

use Getopt::Std;
my %opts = ();
getopts('dk:',\%opts);
my $debug = (defined($opts{'d'}) ? 1 : 0);
my $scale = (defined($opts{'k'}) ? $opts{'k'} : 1);


my %C = (
    gzip => { flag => '-', min => 1, max => 9, suffix => '.gz' },
    brotli => { flag => '-q ', min => 0, max => 11, suffix => '.br' },
    zstd => { flag => '-q -', min => 1, max => 19, suffix => '.zst' },
);
my @R;
push(@R,{}) for(0..19);

die("One directory as argument expected") if ($#ARGV < 0);
die("First argument is not a directory") if (! -d $ARGV[0]);
my $html = $debug ? $ARGV[0] . "/blog/index.html" : `find $ARGV[0] -name \*.html`;
$html =~ s/\n/ /g;
$C{'gzip'}{'wc'} = ($html =~ s/\.html/\.html$C{'gzip'}{'suffix'}/gr);
$C{'brotli'}{'wc'} = ($html =~ s/\.html/\.html$C{'brotli'}{'suffix'}/gr);
$C{'zstd'}{'wc'} = ($html =~ s/\.html/\.html$C{'zstd'}{'suffix'}/gr);


sub fmt1000(@) {	# thousand's formating
    # Perl printf to use commas as thousands-separator:
    # stackoverflow.com/questions/33442240 + perlmonks.org?node_id=1035411
    return scalar reverse(join(',', (unpack('(A3)*',reverse $_[0]))));
}


# Measure execution time and size
for my $c (sort keys %C) {
    my ($f,$m,$M) = @{$C{$c}}{'flag','min','max'};
    #printf("%s: f=%s, m=%d, M=%d\n", $c, $f, $m, $M);
    for (my $l=$m; $l<=$M; ++$l) {
        $R[$l]->{$c} = {};
        my $r = \$R[$l]{$c};
        my $cmd = "$c -kf $f$l";
        #printf("\tc=%s, l=%s, cmd=%s\n",$c,$l,$cmd);
        open(RES,"/bin/time -p $cmd $html 2>&1 |") || die("Cannot run $c");
        while (<RES>) {
            my ($type,$time) = ($_ =~ /(\w+) (\d+\.\d+)/);
            $$r->{$type} = $time;
            #print "\t\t(type,time)=($type,$time): $_";
        }
        close(RES) || die("Cannot close $c");
        my ($wc) = (split(' ', `wc -c $C{$c}{'wc'} | tail -1`,2))[0];
        $$r->{'size'} = $wc;
    }
}

# Print Markdown table
my ($wcHtml) = (split(' ', `wc -c $html | tail -1`,2))[0];
$wcHtml = fmt1000($wcHtml);
printf("compr.<br>level | %s <br>size | real | user\n", join('<br>size | real | user |', sort keys %C));
printf("-|%s\n", "-:|-:|-:|" x keys %C);
printf("&nbsp;|%s| | |%s| | |%s\n",$wcHtml,$wcHtml,$wcHtml);
for (my $l=0; $l<=19; ++$l) {
    printf("%d",$l);
    for my $c (sort keys %C) {
        my ($real,$user,$wc) = @{$R[$l]->{$c}}{'real','user','size'};
        printf(" | %s | %s | %s",
            defined($wc) ? sprintf("%s",fmt1000($wc)) : "n/a",
            defined($real) ? sprintf("%.2f",$real) : "n/a",
            defined($user) ? sprintf("%.2f",$user) : "n/a"
        );
    }
    printf("\n");
}

# Print Apache ECharts JSON
print "series: [\n";
for my $c (sort keys %C) {
    for my $name ( ('size','real') ) {
        printf("\t{\n\t\tname: '%s %s',\n\t\ttype: 'line',\n\t\tsmooth: true,\n\t\tdata: [", $c,$name);
        for (my $l=0; $l<=19; ++$l) {
            my $t = $R[$l]->{$c}->{$name};
            if (defined($t)  &&  $name eq 'size') { $t /= $scale; }
            printf(" %s,", defined($t) ? sprintf("%s",$t) : "'n/a'");
        }
        printf(" ]\n\t},\n");
    }
}
print "]\n";

5. Summary

As in the previous post Performance Comparison: gzip vs Brotli three years ago:

  1. brotli compresses best. No other method reaches this level.
  2. brotli is always faster than gzip when comparing similar compression levels
  3. zstd is more than two times faster than gzip
  4. zstd has similar speed to brotli for some range of compression level but then falls off
  5. Except for compatibility, there is nothing to recommend for gzip

One takeaway is that for my HTML files zstd does offer no real advantage over brotli, which I already support on my web-server.

Further readings.

  1. Paul Calvano: Choosing Between gzip, Brotli and zStandard Compression
  2. Danila Vershinin: NGINX Zstd Compression: Complete Configuration Guide