ExportXMLWordPrintable

    • Type: Improvement
    • Resolution: Unresolved
    • Priority: Normal
    • None
    • Affects Version/s: None
    • Component/s: Back-end
    • None

      The Musicbrainz dumps are currently compressed using bzip2 with a level of 7 (it seems). It has decent compression ratios but is incredibly slow to decompress, especially single-threaded, which is something i've run into quite a lot with my MeB tooling. This led me to investigating whether an alternative algorithm could be used, ideally one that is strictly better (for some set of characteristics), that the dumps could use instead.

      I've run benchmarks with different compression levels and threadedness for bzip2, zstd, and xz. I didn't test single-threaded compression as 1. I'm assuming this use-case doesn't matter for MBS, as it's already specifically using multi-encoded bzip2 streams which implies it's compressed in parallel, and 2. that would make this excruciatingly slow benchmark take even longer (total runtime was already around 3 days)

      Here's the plot I've generated. Each column has 3 stacked violin plots showing the time it took for compression, decompression multi-threaded, and decompression single-threaded for a single algorithm at some level. Note that the scale is logarithmic.

      plot.svg

      Given this I'd personally prefer zstd as it decompresses single-threaded at non-snail speeds, but it seems there unfortunately is no one clear winner; zstd can match the compression time and nearly ratio as well at level 9 with significantly better decompression speeds, but it was slightly slower and the ratio was a tad worse as well. For zstd to beat bzip2 it has to compress for much longer, but does also beat the best setting bzip2 has. Alternatively if MeB greatly favors compression time then the lower zstd levels may also suffice, in that they achieve comparable ratios (bzip2 level 7 at 4.3; zstd level 6 at 4.0) in much less time (bzip2 at 100s; zstd at 60s).

      Regardless of what is chosen, I'd very much like to note how the single-threaded decompression managed to be slower than compression for some of these algorithms, which i think really goes to show how much of an impact this can have.

      An additional concern is that even if this were coordinated with the next schema change, it'd probably be a good idea to allow a gradual transition which means that for some period of time MeB would reap none of the benefits yet have to store twice as much dump data and use twice as much processing power for compression.

      This ticket is mainly about dumps but replication packets may be worth mentioning as well though they're much less of an issue given their size.

      See also existing discussion on Matrix: https://matrix.to/#/!hyuEWFORfWYLZitABm:chatbrainz.org/$3gfdmFUsocUdmmHHjvjw7vlSHBqveFyFYsNJJQqGroo?via=chatbrainz.org&via=matrix.org&via=kanp.ai

      technical details

      actually running the benchmarks was very crude. i just set up a quick shell script to run a bunch of hyperfine benchmarks and left my laptop running till it completed. nothing else of significance was running in the background. each benchmark was ran 10 times with 1 warmup run.

      #!/usr/bin/env nix-shell
      #!nix-shell -i bash -p bash coreutils fd hyperfine zstd lbzip2 xz
      
      file="data.tar"    # origin was `mbdump.tar.bz2` from dump 20260919-002047
      threads="$(nproc)" # this was 16 for my machine
      
      function zstd_compress { echo "zstd --stdout --force --keep --compress --threads=${2:-$threads} -$1 $file > tests/zstd_$1"; }
      function zstd_decompress { echo "zstd --stdout --force --keep --decompress --threads=${2:-$threads} tests/zstd_$1 > /dev/null"; }
      
      function lbzip2_compress { echo "lbzip2 --stdout --force --keep --compress -n ${2:-$threads} -$1 $file > tests/lbzip2_$1"; }
      function lbzip2_decompress { echo "lbzip2 --stdout --force --keep --decompress -n ${2:-$threads} tests/lbzip2_$1 > /dev/null"; }
      
      function xz_compress { echo "xz --stdout --force --keep --compress --threads=${2:-$threads} -$1 $file > tests/xz_$1"; }
      function xz_decompress { echo "xz --stdout --force --keep --decompress --threads=${2:-$threads} tests/xz_$1 > /dev/null"; }
      
      # test multi-threaded compression and both multi- and single-threaded decompression
      rm -rf tests && mkdir -p tests
      time hyperfine --warmup=1 --export-json stats.json -- \
        "$(zstd_compress 1)" "$(zstd_decompress 1)" "$(zstd_decompress 1 1)" \
        "$(zstd_compress 6)" "$(zstd_decompress 6)" "$(zstd_decompress 6 1)" \
        "$(zstd_compress 9)" "$(zstd_decompress 9)" "$(zstd_decompress 9 1)" \
        "$(zstd_compress 12)" "$(zstd_decompress 12)" "$(zstd_decompress 12 1)" \
        "$(zstd_compress 15)" "$(zstd_decompress 15)" "$(zstd_decompress 15 1)" \
        "$(zstd_compress 18)" "$(zstd_decompress 18)" "$(zstd_decompress 18 1)" \
        "$(lbzip2_compress 1)" "$(lbzip2_decompress 1)" "$(lbzip2_decompress 1 1)" \
        "$(lbzip2_compress 3)" "$(lbzip2_decompress 3)" "$(lbzip2_decompress 3 1)" \
        "$(lbzip2_compress 5)" "$(lbzip2_decompress 5)" "$(lbzip2_decompress 5 1)" \
        "$(lbzip2_compress 7)" "$(lbzip2_decompress 7)" "$(lbzip2_decompress 7 1)" \
        "$(lbzip2_compress 9)" "$(lbzip2_decompress 9)" "$(lbzip2_decompress 9 1)" \
        "$(xz_compress 1)" "$(xz_decompress 1)" "$(xz_decompress 1 1)" \
        "$(xz_compress 3)" "$(xz_decompress 3)" "$(xz_decompress 3 1)" \
        "$(xz_compress 5)" "$(xz_decompress 5)" "$(xz_decompress 5 1)" \
        "$(xz_compress 7)" "$(xz_decompress 7)" "$(xz_decompress 7 1)" \
        "$(xz_compress 9)" "$(xz_decompress 9)" "$(xz_decompress 9 1)"
      
      wc --bytes --total=never "$file" | tee sizes.tsv
      fd . tests --type file --print0 | wc --bytes --total=never --files0-from=- | tee -a sizes.tsv
      

      processing

      simple matplotlib to generate the violin plot and bar plot. leaving this here as 1. might be worth generating different plots, and 2. running this locally allows exploring interactively. i find that helps quite a bit with comparing. i've attached the `sizes.tsv` and `stats.json` files.

      #!/usr/bin/env python3
      
      import json
      import re
      import shlex
      from itertools import groupby
      from pathlib import Path
      
      import matplotlib.pyplot as plt
      from matplotlib.patches import Patch
      
      outfile = "plot.svg"
      
      
      def get_level(command: str) -> int:
          algorithm = shlex.split(command)[0]
          # match `algo_level` in file name either written to or read from
          return int(
              next(
                  map(
                      lambda match: match.groups(),
                      re.finditer(rf"{algorithm}_(\d+)", command),
                  )
              )[0]
          )
      
      
      def get_threadedness(command: str) -> int:
          algorithm = shlex.split(command)[0]
          pattern = {
              "zstd": r"--threads=(\d+)",
              "xz": r"--threads=(\d+)",
              "lbzip2": r"-n (\d+)",
          }[algorithm]
          return int(
              next(
                  map(
                      lambda match: match.groups(),
                      re.finditer(pattern, command),
                  )
              )[0]
          )
      
      
      def group_key(result) -> str:
          command = result["command"]
          algorithm = shlex.split(command)[0]
          level = get_level(command)
          # must use filename for command, e.g. `zstd_18`
          return f"{algorithm}_{level}"
      
      
      stats = None
      sizes = None
      
      with open("stats.json") as file:
          stats = json.load(file)
      with open("sizes.tsv") as file:
          sizes = {
              Path(key).name: int(value)
              for value, key in (line.strip().split(" ") for line in file)
          }
      
      if stats is None or sizes is None:
          print("unable to load results or sizes")
          exit(1)
      
      base_size = sizes["data.tar"]
      
      labels = []
      size_data = []
      ratio_data = []
      compress_data = []
      decompress_multi_data = []
      decompress_single_data = []
      
      for group, results in groupby(stats["results"], group_key):
          compress = next(filter(lambda result: "--compress" in result["command"], results))
          decompress_multi = next(
              filter(
                  lambda result: (
                      "--decompress" in result["command"]
                      and get_threadedness(result["command"]) != 1
                  ),
                  results,
              )
          )
          decompress_single = next(
              filter(
                  lambda result: (
                      "--decompress" in result["command"]
                      and get_threadedness(result["command"]) == 1
                  ),
                  results,
              )
          )
      
          # use alternative names when label hard to read (e.g. `lbzip2_3` -> bz2_3`
          [algorithm, level] = group.split("_")
          pretty_algo = {
              "lbzip2": "bz2",
              "zstd": "zst",
          }.get(algorithm) or algorithm
          labels.append(f"{pretty_algo}_{level}")
      
          size_data.append(sizes[group])
          ratio_data.append(base_size / sizes[group])
          compress_data.append(compress["times"])
          decompress_multi_data.append(decompress_multi["times"])
          decompress_single_data.append(decompress_single["times"])
      
      fig, (ax1, ax2) = plt.subplots(nrows=2, ncols=1, figsize=(20, 10), sharex=True)
      
      # violin plot of time
      ax1.set_title("Wall time of operations")
      ax1.set_ylabel("Time [$s$]")
      ax1.set_xticks(range(len(labels)))
      ax1.set_xticklabels(labels)
      
      ax1.set_yscale("log")
      
      # violion plot axis; need same configuration but just have slightly different data
      properties = [
          (compress_data, "Compression (multi)", "#d67c99"),
          (decompress_multi_data, "Decompression (multi)", "#5db179"),
          (decompress_single_data, "Decompression (single)", "#4ba6dc"),
      ]
      for data, label, color in properties:
          parts = ax1.violinplot(
              data,
              positions=range(len(labels)),
              linecolor="black",
              widths=0.4,
              showmeans=True,
              showmedians=True,
          )
          for body in parts["bodies"]:
              body.set_facecolor(color)
              body.set_alpha(0.5)
      
      handles = [Patch(facecolor=color, label=label) for _, label, color in properties]
      ax1.legend(handles=handles)
      ax1.tick_params(labelbottom=True)
      
      
      # ratios
      ax2.set_title("Compression ratios")
      ax2.set_ylabel("Ratio (higher is better)")
      ax2.set_xticks(range(len(labels)))
      ax2.set_xticklabels(labels)
      ax2.bar(
          labels,
          ratio_data,
          color="#5db179",
          bottom=1.0,
      )
      ax2.tick_params(labelbottom=True)
      
      fig.tight_layout(pad=2)
      if outfile is not None:
          fig.savefig(outfile)
      else:
          plt.show()
      

      benchmarking system

      Relevant output from `neowofetch --json`;

      {
          "OS": "NixOS 26.11.20260913.ef34387 (Zokor) x86_64",
          "Host": "Laptop 16 (AMD Ryzen 7040 Series) A9",
          "Kernel": "6.18.45-xanmod1",
          "CPU": "AMD Ryzen 9 7940HS w/ Radeon 780M Graphics (16) @ 4.0GHz",
          "Memory": "60.63 GiB"
      }
      

        1. stats.json
          46 kB
        2. sizes.tsv
          0.4 kB
        3. plot.svg
          344 kB

            Assignee:
            Unassigned
            Reporter:
            mibmo
            Votes:
            0 Vote for this issue
            Watchers:
            2 Start watching this issue

              Created:
              Updated:

                Version Package