chromosomix against reference implementations: time

A map and a track from a file to a picture in three viewers, and every command of the validation, run again and timed

Rendered 1 November 2026, 22:27 UTC · chromosomix 0.19.0 at 997e59c
By GitHub Actions, Notebooks run 16, on demand of js2264
On AMD EPYC 9V45 96-Core Processor, 4 cores, 15.6 GB, Linux 7.0.0-1017-azure (x86_64); Python 3.13.17, Quarto 1.10.19

The commands are the validation’s, as it ran them. Beside this page: viewers.tsv, every picture timed in a viewer, and timings.tsv, every timed command, one row each.

The page is in two parts. The first, Time to a picture, times three viewers — chromosomix, juicebox.js and HiGlass — drawing the same contact map and one track beside it, at the same resolution, from files on this machine and from the bucket they are published in. The second, from The machine on, times the analyses: validation.qmd runs each analysis twice, through chromosomix and through the package it reproduces, and writes every command it ran to notebooks/work/validation/commands.json. This notebook runs each of them again, exactly as written there but writing to a scratch folder, as a fresh process, RUNS times round robin (four, unless TIMING_RUNS in the environment says otherwise), the two sides of each pair taking turns to go first, and measures what each run costs: its CPU time, user and system, of the process and of every process it started and waited for; its wall-clock time; and its peak memory, the most those processes held together: their proportional set sizes — a page two of them share counted half in each, so that a pool of forked workers is not counted again in every worker — added up four times a second while the command runs, and never less than the largest resident set any one of them reached, which a sample can miss. A reference that runs four processes holds what the four hold. Where the system does not list a process’s children, as macOS does not, the peak memory is that largest resident set alone.

Every run of a command is one line of notebooks/work/validation/timings.tsv, a tidy table — one row per command and run, one column per variable, the machine and the versions included — and every picture a viewer was timed on is one line of viewers.tsv beside it, in the same form; every table and figure below is drawn from those two.

To render. Render validation.qmd first: it fetches the data, makes every input and writes the command list. The viewers need the app built, the two viewers and a browser for them, and higlass-python’s environment (notebooks/requirements-higlass.txt, with libfuse 2 for its URLs). Then, in the same environment and on a machine left otherwise idle:

npm run build                                       # the app, served from dist/
npm ci --prefix notebooks/viewers                   # juicebox.js, HiGlass and Playwright, pinned
npx --prefix notebooks/viewers playwright install chromium
python -m venv higlass-env && higlass-env/bin/pip install -r notebooks/requirements-higlass.txt
QUARTO_PYTHON=refs/bin/python quarto render notebooks/timing.qmd

The viewers’ files (about 15 GB, most of it the mouse map in its two formats) are fetched once into notebooks/work/viewers/data/.

A second render reads the tables rather than timing again (REDO, REDO_VIEWERS), unless the command list, the pictures, the number of runs or the chromosomix build has changed. .github/workflows/notebooks.yml renders both notebooks on every release, on a fresh GitHub runner each time, and publishes this page at https://chromosomix.app/timings/ with the two tables beside it; the block under the title says which render, and which machine, this is.

Code
from pathlib import Path
import os

#: Timed runs of each command (TIMING_RUNS overrides it for a quick look), and whether to time them again.
#: Even, so that each side of a pair goes first in as many rounds as the other (`round_of`).
RUNS = int(os.environ.get('TIMING_RUNS', 4))
REDO = False

HERE = Path.cwd().resolve()
REPO = HERE.parent if HERE.name == 'notebooks' else HERE
WORK = REPO / 'notebooks' / 'work' / 'validation'
MANIFEST = WORK / 'commands.json'
TIMINGS = WORK / 'timings.tsv'
SCRATCH = WORK / 'timing'

#: Runs of each picture in each viewer (VIEWER_RUNS overrides it), and whether to time them again.
#: Four, so that each of the four viewers goes first in one round of each picture (`viewer_plan.ordered`).
VIEWER_RUNS = int(os.environ.get('VIEWER_RUNS', 4))
REDO_VIEWERS = False
#: The app's build (`npm run build`), served from loopback as the site serves it.
APP_BUILD = REPO / 'dist'
#: higlass-python's environment (notebooks/requirements-higlass.txt), relative to the repository or absolute.
HIGLASS_PYTHON = Path(os.environ.get('HIGLASS_PYTHON', 'higlass-env/bin/python'))
HIGLASS_PYTHON = HIGLASS_PYTHON if HIGLASS_PYTHON.is_absolute() else REPO / HIGLASS_PYTHON
VWORK = REPO / 'notebooks' / 'work' / 'viewers'
VDATA = VWORK / 'data'
VIEWER_PLAN = VWORK / 'plan.json'
VIEWER_RESULTS = VWORK / 'results.jsonl'
VIEWER_SHOTS = VWORK / 'pictures'
VIEWER_TABLE = WORK / 'viewers.tsv'
Code
import hashlib
import json
import platform
import shlex
import shutil
import subprocess
import sys
import tempfile
import threading
import time
import urllib.parse
import urllib.request
from collections import Counter
from itertools import groupby

import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
from matplotlib.ticker import FuncFormatter, NullLocator

from download import download, urlopen

APP_COLOUR, REF = '#2a78d6', '#eb6834'
INK, INK2, GRID, SURFACE, MISSING = '#0b0b0b', '#52514e', '#e7e6e2', '#fcfcfb', '#efeeea'
plt.rcParams.update({
    'figure.facecolor': SURFACE, 'axes.facecolor': SURFACE, 'savefig.facecolor': SURFACE,
    'axes.edgecolor': INK2, 'axes.labelcolor': INK, 'xtick.color': INK2, 'ytick.color': INK2,
    'text.color': INK, 'axes.spines.top': False, 'axes.spines.right': False,
    'axes.grid': True, 'grid.color': GRID, 'grid.linewidth': 0.6, 'axes.axisbelow': True,
    'lines.linewidth': 2, 'font.size': 9, 'axes.titlesize': 10, 'axes.titleweight': 'bold',
    'axes.titlelocation': 'left', 'legend.frameon': False, 'figure.dpi': 110,
})


def show(frame, precision=2, **formats):
    """A table without row numbers: the columns named in `formats` their way, the others to `precision`."""
    return frame.style.hide(axis='index').format(formats or None, precision=precision)


def measures(values):
    """Named numbers as a two-column table."""
    return pd.DataFrame(list(values.items()), columns=['measure', 'value']).style.hide(axis='index')


def machine():
    """What the times were measured on, as columns of the table."""
    cpu = platform.processor()
    if Path('/proc/cpuinfo').exists():
        cpu = next((line.split(':', 1)[1].strip() for line in open('/proc/cpuinfo') if line.startswith('model name')), cpu)
    try:
        memory = round(os.sysconf('SC_PAGE_SIZE') * os.sysconf('SC_PHYS_PAGES') / 1e9, 1)
    except (ValueError, OSError):
        memory = np.nan
    node = subprocess.run(['node', '--version'], capture_output=True, text=True).stdout.strip()
    return {'processor': cpu, 'cores': os.cpu_count(), 'memory_gb': memory, 'system': platform.platform(),
            'node': node, 'python': platform.python_version()}


MACHINE = machine()
#: Whether the system lists a process's children and its proportional set size, as Linux does: where it does
#: not, as macOS does not, a run's peak memory is that of its largest process alone.
WEIGHS = (Path(f'/proc/self/task/{threading.get_native_id()}/children').exists()
          and Path('/proc/self/smaps_rollup').exists())
#: How often, in seconds, a run's processes are weighed while it runs.
EVERY = 0.25

Time to a picture

How long a reader waits between handing a viewer a contact map with a track and seeing them drawn, in chromosomix and in the two viewers most used for the same thing: juicebox.js, the browser form of Juicebox, which reads a .hic, and HiGlass, which reads an .mcool through a tile server — here the one higlass-python starts for a file it is given. chromosomix reads both formats and is timed on each; juicebox.js reads no .mcool and HiGlass no .hic. Every picture is one map and one track, the same files in every viewer, and it is timed from the moment the viewer is handed them, with the view, to the moment the viewer itself says the picture is finished:

  • chromosomix: its own report of a finished picture (chromosomix:rendered, SPECS §12), sent once every map on screen has been painted from its data, at the resolution the report names, and every track beside it drawn;
  • juicebox.js: no spinner, the map’s last repaint covering the state it was asked for, and the track holding a tile along each axis, nothing still updating;
  • HiGlass: every tile of both tracks that is in view fetched and drawn.

At one resolution, the plan’s. No viewer chooses the resolution. chromosomix is handed a workspace naming the map at its rung (map.mcool::/resolutions/25000); juicebox.js an explicit state, with the resolution’s place in the file’s list, the window’s first bin and the pixels a bin is drawn at, which fit the window to its map; HiGlass the window, and the rung as its heatmap’s maxZoom, its one control over the resolution, which keeps it from going finer. Each answers with the resolution it drew. A picture drawn at another resolution, or of part of the window, is kept as what it was, never as a time for the picture asked for. A view a viewer cannot draw at the plan’s resolution is not run, and the tables say why: a rung the file does not store, or a whole genome, which juicebox.js draws only from the .hic’s own whole-genome matrix, at a resolution of its own.

From disk and from the bucket. Each picture is timed from files on this machine and from the same files where they are published: 4DN’s open-data bucket (Amazon S3, us-east-1) for the chicken and the mouse, the app’s demo bucket (eu-west-3) for the yeast. From disk, chromosomix and juicebox.js are given the files as a reader’s drop gives them, and HiGlass’s server reads them; before each run from disk the kernel’s page cache is emptied, where the machine lets the benchmark do so (the page_cache column), so that the files are read from the disk, as at a first opening. From the bucket, chromosomix and juicebox.js read them by range from the page, and HiGlass’s server through higlass-python’s FUSE mount, which is how it serves a file it does not hold.

Nothing carried over. Every run is a fresh browser context — Chromium, driven by Playwright (notebooks/scripts/viewers.mjs) — with no cache and no storage, and every HiGlass run meets a server started for it alone, with an empty block cache when it reads from the bucket. The viewer’s page is loaded empty first, with its code and its workers, and that time is kept apart (app_seconds): what is timed is the data’s road to the screen. It is then left two seconds, as a reader’s page is while they find their file — what a viewer starts once its page is idle, as chromosomix starts its reader and h5wasm, a reader finds started — and chromosomix, from disk, as long again on the page its workspace leaves asking for the files. Each of VIEWER_RUNS rounds (four, unless VIEWER_RUNS in the environment says otherwise) draws every picture once, the viewers taking turns to go first, so that the one that reads a file first, from a disk or from a bucket that answers a second read faster, is a different one each round. The requests and bytes counted are those the page asked for between the two moments: of the bucket, for chromosomix and juicebox.js; of its server, for HiGlass, whose own reads go through FUSE, outside the page, and are not counted.

Code
#: Each genome's map, in both formats, and its track: where they are published and their MD5s, and the four
#: views, each at one resolution, of a window of three to five hundred bins on a side.
FOURDN_FILES = 'https://4dn-open-data-public.s3.amazonaws.com/fourfront-webprod/wfoutput'
DEMO_STORE = 'https://rsglab-data.s3.eu-west-3.amazonaws.com/demo/store/yeast'
GENOMES = {
    'yeast': dict(
        data='Micro-C of strain A364A (Costantino et al. 2020, GEO GSE151553) and ATAC-seq: the app’s demo store',
        mcool=(f'{DEMO_STORE}/GSE151553_A364_merged.mcool', '319a04a8d7e41d9eb88e2378ea3ea373'),
        hic=(f'{DEMO_STORE}/GSE151553_A364_merged.hic', 'ce2cf114ff78ab12e8c20b060c92f539'),
        track=(f'{DEMO_STORE}/Yeast_ATAC.bw', '3e326d9b3df621d6d4973d42b81f18de'),
        views=[dict(view='genome', resolution=25_600, label='whole genome'),
               dict(view='chromosome', chrom='chrIV', start=0, end=1_531_933, resolution=3_200, label='chrIV'),
               dict(view='region', chrom='chrIV', start=700_000, end=1_500_000, resolution=1_600,
                    label='chrIV:0.7–1.5 Mb'),
               dict(view='locus', chrom='chrIV', start=1_200_000, end=1_300_000, resolution=200,
                    label='chrIV:1.2–1.3 Mb')]),
    'chicken': dict(
        data='Hi-C of DT40 cells held in G2 (Gibcus et al. 2018, 4DN set 4DNES9LEZXN7) and the compartment '
             'track 4DN made of it',
        mcool=(f'{FOURDN_FILES}/b829095a-28e6-49ec-bd18-2b4bc319d0eb/4DNFIT479GDR.mcool',
               '25c539d76273d6099031360eede8bd56'),
        hic=(f'{FOURDN_FILES}/16926af8-ac3b-4f32-97d5-6c0dc6f1596a/4DNFIRKUYKP4.hic',
             '8915a355c9f0cc2d85aa613f6d308d18'),
        track=(f'{FOURDN_FILES}/a1f0586f-2b91-49b3-93ea-2f1f53e28657/4DNFI1PRBI5L.bw',
               'e931e21d02b45d442a9694db862c9fd9'),
        views=[dict(view='genome', resolution=2_500_000, label='whole genome'),
               dict(view='chromosome', chrom='chr1', start=0, end=196_202_544, resolution=500_000, label='chr1'),
               dict(view='region', chrom='chr1', start=20_000_000, end=30_000_000, resolution=25_000,
                    label='chr1:20–30 Mb'),
               dict(view='locus', chrom='chr1', start=24_000_000, end=25_000_000, resolution=2_000,
                    label='chr1:24–25 Mb')]),
    'mouse': dict(
        data='Hi-C of F123 cells (Kubo et al. 2021, 4DN set 4DNESKKSKG7Y) and CTCF ChIP-seq in the same line '
             '(Huang et al. 2021, 4DNESD1LH7J9)',
        mcool=(f'{FOURDN_FILES}/ef0eba47-ada0-42bc-9329-5145b57ff33a/4DNFI5QJNFAT.mcool',
               '18bd736467fd20ca0181698e119595e3'),
        hic=(f'{FOURDN_FILES}/93df02c6-1047-43f6-afae-d3598b61eeea/4DNFIBBKG9KD.hic',
             '6b127efa5d47210bb90e4b522e27cee2'),
        track=(f'{FOURDN_FILES}/6dd5c984-eccf-4d2d-8118-38ea2a4d30fb/4DNFIFFXFV82.bw',
               '6de6cadcd28f603f386029d245577833'),
        views=[dict(view='genome', resolution=5_000_000, label='whole genome'),
               dict(view='chromosome', chrom='chr3', start=0, end=160_039_680, resolution=500_000, label='chr3'),
               dict(view='region', chrom='chr3', start=27_000_000, end=37_000_000, resolution=25_000,
                    label='chr3:27–37 Mb'),
               dict(view='locus', chrom='chr3', start=34_000_000, end=35_000_000, resolution=2_000,
                    label='chr3:34–35 Mb')]),
}
#: Each viewer and the format it is timed on, in the order the tables give them.
VIEWERS = [('chromosomix', 'mcool'), ('chromosomix', 'hic'), ('juicebox', 'hic'), ('higlass', 'mcool')]
LOCATIONS = ['local', 'cloud']
NAMES = {'chromosomix': 'chromosomix', 'juicebox': 'juicebox.js', 'higlass': 'HiGlass'}
WHERE = {'local': 'from disk', 'cloud': 'from the bucket'}


def remote_size(url):
    request = urllib.request.Request(url, method='HEAD')
    with urlopen(request) as response:
        return int(response.headers['Content-Length'])


def fetch_viewer_file(url, md5):
    """A published file, fetched once into work/viewers/data — asked again when its server fails, as
    notebooks/scripts/download.py says — and held to its MD5."""
    path = VDATA / urllib.parse.unquote(url.rsplit('/', 1)[-1])
    if not path.exists():
        print('fetching', url)
        download(url, path)
    digest = hashlib.md5()
    with open(path, 'rb') as handle:
        for block in iter(lambda: handle.read(1 << 22), b''):
            digest.update(block)
    if digest.hexdigest() != md5:
        raise ValueError(f'{path} is not {url}: its MD5 is {digest.hexdigest()}, not {md5}. '
                         'Delete it, and it is fetched again.')
    return path


VDATA.mkdir(parents=True, exist_ok=True)
WANTED = [GENOMES[g][kind] for g in GENOMES for kind in ('mcool', 'hic', 'track')]
MISSING_BYTES = sum(remote_size(url) for url, _ in WANTED if not (VDATA / url.rsplit('/', 1)[-1]).exists())
FREE_BYTES = shutil.disk_usage(VDATA).free
if MISSING_BYTES > FREE_BYTES - 2e9:
    raise OSError(f'The viewers’ files need {MISSING_BYTES / 1e9:.1f} GB more in {VDATA}, '
                  f'and {FREE_BYTES / 1e9:.1f} GB are free.')
FILES = {g: {kind: {'url': GENOMES[g][kind][0], 'local': fetch_viewer_file(*GENOMES[g][kind])}
             for kind in ('mcool', 'hic', 'track')} for g in GENOMES}
show(pd.DataFrame([{'genome': g, 'data': GENOMES[g]['data'],
                    **{kind: f"{files[kind]['local'].name} ({files[kind]['local'].stat().st_size / 1e9:.2f} GB)"
                       for kind in ('mcool', 'hic', 'track')}} for g, files in FILES.items()]))
fetching https://rsglab-data.s3.eu-west-3.amazonaws.com/demo/store/yeast/GSE151553_A364_merged.mcool
fetching https://rsglab-data.s3.eu-west-3.amazonaws.com/demo/store/yeast/GSE151553_A364_merged.hic
fetching https://rsglab-data.s3.eu-west-3.amazonaws.com/demo/store/yeast/Yeast_ATAC.bw
fetching https://4dn-open-data-public.s3.amazonaws.com/fourfront-webprod/wfoutput/b829095a-28e6-49ec-bd18-2b4bc319d0eb/4DNFIT479GDR.mcool
fetching https://4dn-open-data-public.s3.amazonaws.com/fourfront-webprod/wfoutput/16926af8-ac3b-4f32-97d5-6c0dc6f1596a/4DNFIRKUYKP4.hic
fetching https://4dn-open-data-public.s3.amazonaws.com/fourfront-webprod/wfoutput/a1f0586f-2b91-49b3-93ea-2f1f53e28657/4DNFI1PRBI5L.bw
fetching https://4dn-open-data-public.s3.amazonaws.com/fourfront-webprod/wfoutput/ef0eba47-ada0-42bc-9329-5145b57ff33a/4DNFI5QJNFAT.mcool
fetching https://4dn-open-data-public.s3.amazonaws.com/fourfront-webprod/wfoutput/93df02c6-1047-43f6-afae-d3598b61eeea/4DNFIBBKG9KD.hic
fetching https://4dn-open-data-public.s3.amazonaws.com/fourfront-webprod/wfoutput/6dd5c984-eccf-4d2d-8118-38ea2a4d30fb/4DNFIFFXFV82.bw
genome data mcool hic track
yeast Micro-C of strain A364A (Costantino et al. 2020, GEO GSE151553) and ATAC-seq: the app’s demo store GSE151553_A364_merged.mcool (0.12 GB) GSE151553_A364_merged.hic (0.01 GB) Yeast_ATAC.bw (0.02 GB)
chicken Hi-C of DT40 cells held in G2 (Gibcus et al. 2018, 4DN set 4DNES9LEZXN7) and the compartment track 4DN made of it 4DNFIT479GDR.mcool (0.69 GB) 4DNFIRKUYKP4.hic (0.94 GB) 4DNFI1PRBI5L.bw (0.00 GB)
mouse Hi-C of F123 cells (Kubo et al. 2021, 4DN set 4DNESKKSKG7Y) and CTCF ChIP-seq in the same line (Huang et al. 2021, 4DNESD1LH7J9) 4DNFI5QJNFAT.mcool (7.03 GB) 4DNFIBBKG9KD.hic (5.85 GB) 4DNFIFFXFV82.bw (0.66 GB)
Code
import viewer_plan as vp


def installed(package):
    """A package's version as notebooks/viewers installed it."""
    path = REPO / 'notebooks' / 'viewers' / 'node_modules' / package / 'package.json'
    return json.loads(path.read_text())['version']


def higlass_python():
    """higlass-python's and clodius' versions, in the environment HiGlass's server runs in."""
    probe = 'import importlib.metadata as m; print(m.version("higlass-python"), m.version("clodius"))'
    run = subprocess.run([str(HIGLASS_PYTHON), '-c', probe], capture_output=True, text=True)
    if run.returncode:
        raise FileNotFoundError(f'{HIGLASS_PYTHON} cannot run HiGlass’s server: {run.stderr.strip()[-500:]}')
    return run.stdout.split()


def app_build():
    """The build the section times: the app's version, its commit and a fingerprint of the build itself."""
    index = APP_BUILD / 'index.html'
    if not index.exists():
        raise FileNotFoundError(f'{index} is not there: build the app first (npm run build).')
    version = json.loads((REPO / 'package.json').read_text())['version']
    commit = subprocess.run(['git', 'rev-parse', '--short', 'HEAD'], capture_output=True, text=True,
                            cwd=REPO).stdout.strip() or 'unknown'
    return f'{version} at {commit}, build {hashlib.sha256(index.read_bytes()).hexdigest()[:10]}'


HG_PYTHON, CLODIUS = higlass_python()
VIEWER_VERSION = {'chromosomix': app_build(), 'juicebox': installed('juicebox.js'),
                  'higlass': f"{installed('higlass')}, higlass-python {HG_PYTHON}, clodius {CLODIUS}"}

PLANNED, REFUSED = [], []
for genome, files in FILES.items():
    given = {'mcool': {**files['mcool'], 'info': vp.mcool_info(files['mcool']['local'])},
             'hic': {**files['hic'], 'info': vp.hic_header(files['hic']['local'])},
             'track': {**files['track'], 'names': vp.bigwig_names(files['track']['local'])}}
    runs, refused = vp.runs_for(genome, GENOMES[genome]['views'], given, VIEWERS, LOCATIONS, VWORK)
    PLANNED += runs
    REFUSED += refused

# juicebox's map is narrowed by the track that arrives after it; the driver measures it once, on the yeast.
YEAST_HIC = vp.hic_header(FILES['yeast']['hic']['local'])
PLAN = {
    'app': str(APP_BUILD),
    'higlass': {'python': str(HIGLASS_PYTHON), 'server': str(REPO / 'notebooks' / 'scripts' / 'higlass_server.py')},
    'screenshots': str(VIEWER_SHOTS),
    'limitMs': 300_000,
    'juiceboxProbe': {'map': str(FILES['yeast']['hic']['local']), 'track': str(FILES['yeast']['track']['local']),
                      'chr': [name for name, _ in YEAST_HIC['chromosomes']].index('chrIV'),
                      'zoom': YEAST_HIC['resolutions'].index(3_200)},
    'runs': vp.ordered(PLANNED, VIEWER_RUNS),
}
VIEWER_PLAN.write_text(json.dumps(PLAN, indent=1))
measures({'pictures': len({(run['genome'], run['view'], run['location']) for run in PLANNED}),
          'runs a round, a picture in each viewer that can draw it': len(PLANNED), 'rounds': VIEWER_RUNS,
          'runs in all': len(PLAN['runs']), 'cells refused': len(REFUSED),
          **{NAMES[tool]: version for tool, version in VIEWER_VERSION.items()}})
measure value
pictures 24
runs a round, a picture in each viewer that can draw it 86
rounds 4
runs in all 344
cells refused 5
chromosomix 0.19.0 at 997e59c, build d7fe610934
juicebox.js 4.11.0
HiGlass 2.3.5, higlass-python 1.2.1, clodius 0.22.2
Code
#: The columns of viewers.tsv, in order: the picture, the viewer, the run, what it took, where.
VIEWER_COLUMNS = ['genome', 'view', 'window', 'resolution', 'bins', 'viewer', 'format', 'location', 'run', 'status',
                  'seconds', 'app_seconds', 'drawn_resolution', 'drawn_bins', 'requests', 'megabytes', 'note',
                  'page_cache', 'started', 'picture', 'version', *MACHINE, 'chromium', 'map', 'track']


def viewer_rows(results):
    """viewers.tsv: every run of the plan, joined with what the driver measured, and every cell refused."""
    rows = []
    for run in PLAN['runs']:
        got = results.get((run['id'], run['run']), {'status': 'not run', 'note': 'the driver stopped before it'})
        ms, app = got.get('ms'), got.get('appMs')
        rows.append({'genome': run['genome'], 'view': run['view'], 'window': run['window'],
                     'resolution': run['resolution'], 'bins': run['bins'], 'viewer': run['tool'],
                     'format': run['format'], 'location': run['location'], 'run': run['run'],
                     'status': got['status'], 'seconds': None if ms is None else ms / 1000,
                     'app_seconds': None if app is None else app / 1000,
                     'drawn_resolution': got.get('resolution'), 'drawn_bins': got.get('bins'),
                     'requests': got.get('requests'),
                     'megabytes': None if got.get('bytes') is None else got['bytes'] / 1e6,
                     'note': got.get('note') or '', 'page_cache': got.get('page_cache', ''),
                     'started': got.get('started', ''), 'picture': run['picture'],
                     'version': VIEWER_VERSION[run['tool']], **MACHINE, 'chromium': got.get('chromium', ''),
                     'map': Path(run['map']).name, 'track': Path(run['track']).name})
    for cell in REFUSED:
        rows.append({**{key: cell[key] for key in ('genome', 'view', 'window', 'resolution', 'bins', 'format',
                                                     'status', 'note')},
                     'viewer': cell['tool'], 'location': '', 'run': None, 'version': VIEWER_VERSION[cell['tool']],
                     **MACHINE})
    return pd.DataFrame(rows, columns=VIEWER_COLUMNS)


def viewers_current():
    """Whether viewers.tsv holds every run of the plan, by these viewers, at these versions."""
    if REDO_VIEWERS or not VIEWER_TABLE.exists():
        return False
    held = pd.read_csv(VIEWER_TABLE, sep='\t')
    timed = held.dropna(subset=['run'])
    wanted = {(run['picture'], run['run']) for run in PLAN['runs']}
    return ({(p, int(r)) for p, r in zip(timed['picture'], timed['run'])} == wanted
            and (timed['status'] != 'not run').all()
            and (held['version'] == held['viewer'].map(VIEWER_VERSION)).all())


if not viewers_current():
    VIEWER_RESULTS.unlink(missing_ok=True)
    shutil.rmtree(VIEWER_SHOTS, ignore_errors=True)
    with open(VWORK / 'driver.log', 'w') as log:
        driver = subprocess.run(['node', str(REPO / 'notebooks' / 'scripts' / 'viewers.mjs'), str(VIEWER_PLAN),
                                 str(VIEWER_RESULTS)], cwd=REPO, stdout=log, stderr=subprocess.STDOUT)
    # A driver that stopped part of the way keeps the runs it finished, and the table says which did not run.
    held = [json.loads(line) for line in VIEWER_RESULTS.read_text().splitlines()] if VIEWER_RESULTS.exists() else []
    VIEWER_TABLE.parent.mkdir(parents=True, exist_ok=True)
    viewer_rows({(one['id'], one['run']): one for one in held}).to_csv(VIEWER_TABLE, sep='\t', index=False)
    if driver.returncode:
        raise RuntimeError(f"The viewers' driver stopped:\n{(VWORK / 'driver.log').read_text()[-3000:]}")
pictures = pd.read_csv(VIEWER_TABLE, sep='\t')
CLIPS = {}
if VIEWER_RESULTS.exists():
    for line in VIEWER_RESULTS.read_text().splitlines():
        one = json.loads(line)
        if one['run'] == 1 and one.get('clip'):
            CLIPS[one['id']] = one['clip']
timed_runs = pictures.dropna(subset=['run'])
#: What a run that drew a picture can say of it; any other run (an error, a time limit) drew none.
PICTURED = ['drawn', 'in part', 'other resolution']
drew = timed_runs[timed_runs['status'].isin(PICTURED)]
measures({'runs in the table': f'{len(timed_runs):,}', 'cells refused': int(pictures['run'].isna().sum()),
          'measured': f"{timed_runs['started'].min()} to {timed_runs['started'].max()}",
          'page cache before a run from disk': ', '.join(sorted(set(timed_runs.loc[timed_runs['location'] == 'local',
                                                                                    'page_cache'].dropna()))),
          'browser': ', '.join(sorted(set(timed_runs['chromium'].dropna()))),
          'table': os.path.relpath(VIEWER_TABLE, REPO)})
measure value
runs in the table 344
cells refused 5
measured 2026-11-01T22:40:05 to 2026-11-01T23:21:13
page cache before a run from disk dropped
browser 141.0.7390.37
table notebooks/work/validation/viewers.tsv

What each viewer drew

Median seconds over the runs, from handing the viewer its files to its picture being finished, for each picture: a genome, a view of it at one resolution, and where the files are read from. A cell that is not a time says why there is none; a time the viewer did not earn for the picture asked for says what it drew instead.

Code
COLUMN = {(tool, form): f'{NAMES[tool]} · .{form}' for tool, form in VIEWERS}
VIEWS = list(dict.fromkeys(view['view'] for g in GENOMES.values() for view in g['views']))


def cell(rows):
    """One picture in one viewer: the median time, and what it drew if not all of the picture asked for."""
    if rows['run'].isna().all():
        return rows['status'].iloc[0]
    drawn = rows[rows['status'].isin(PICTURED)]
    if drawn.empty:
        return rows['status'].iloc[0]
    text = f"{drawn['seconds'].median():.2f}"
    statuses = set(drawn['status'])
    if 'in part' in statuses:
        text += f" (in part: {drawn.loc[drawn['status'] == 'in part', 'note'].iloc[0]})"
    if 'other resolution' in statuses:
        text += f" (at {int(drawn.loc[drawn['status'] == 'other resolution', 'drawn_resolution'].iloc[0]):,} bp)"
    if len(drawn) < len(rows):
        text += f' ({len(rows) - len(drawn)} of {len(rows)} runs failed)'
    return text


def drawn_table():
    out = []
    for genome in GENOMES:
        for view in GENOMES[genome]['views']:
            for location in LOCATIONS:
                row = {'genome': genome, 'view': view['view'], 'window': view['label'],
                       'resolution (bp)': f"{view['resolution']:,}", 'from': WHERE[location]}
                here = pictures[(pictures['genome'] == genome) & (pictures['view'] == view['view'])]
                for key, name in COLUMN.items():
                    rows = here[(here['viewer'] == key[0]) & (here['format'] == key[1])
                                & ((here['location'] == location) | here['run'].isna())]
                    row[name] = cell(rows) if len(rows) else '—'
                out.append(row)
    return pd.DataFrame(out)


show(drawn_table())
genome view window resolution (bp) from chromosomix · .mcool chromosomix · .hic juicebox.js · .hic HiGlass · .mcool
yeast genome whole genome 25,600 from disk 0.44 0.42 not available 0.53
yeast genome whole genome 25,600 from the bucket 2.59 11.71 not available 13.06
yeast chromosome chrIV 3,200 from disk 0.56 0.51 0.21 (in part: 450 of the window's 479 bins) 0.52
yeast chromosome chrIV 3,200 from the bucket 2.86 2.00 2.66 (in part: 450 of the window's 479 bins) 13.14
yeast region chrIV:0.7–1.5 Mb 1,600 from disk 0.58 0.46 0.30 0.54
yeast region chrIV:0.7–1.5 Mb 1,600 from the bucket 3.35 2.31 2.69 14.69
yeast locus chrIV:1.2–1.3 Mb 200 from disk 0.58 not stored not stored 0.53
yeast locus chrIV:1.2–1.3 Mb 200 from the bucket 3.57 not stored not stored 15.54
chicken genome whole genome 2,500,000 from disk 0.47 0.71 not available 0.53
chicken genome whole genome 2,500,000 from the bucket 1.82 30.90 not available 5.88
chicken chromosome chr1 500,000 from disk 0.64 0.51 0.20 (in part: 369 of the window's 393 bins) 0.53
chicken chromosome chr1 500,000 from the bucket 2.00 1.77 0.95 (in part: 369 of the window's 393 bins) 5.72
chicken region chr1:20–30 Mb 25,000 from disk 0.63 0.66 0.56 0.53
chicken region chr1:20–30 Mb 25,000 from the bucket 2.61 1.88 1.49 7.35
chicken locus chr1:24–25 Mb 2,000 from disk 0.64 0.55 0.26 0.52
chicken locus chr1:24–25 Mb 2,000 from the bucket 2.83 1.81 1.30 7.71
mouse genome whole genome 5,000,000 from disk 0.48 0.78 not available 0.54
mouse genome whole genome 5,000,000 from the bucket 2.09 43.74 not available 7.00
mouse chromosome chr3 500,000 from disk 0.63 0.49 0.19 (in part: 301 of the window's 321 bins) 1.37
mouse chromosome chr3 500,000 from the bucket 2.66 1.84 1.08 (in part: 301 of the window's 321 bins) 12.41
mouse region chr3:27–37 Mb 25,000 from disk 0.76 0.68 0.72 0.53
mouse region chr3:27–37 Mb 25,000 from the bucket 3.64 2.07 1.90 11.18
mouse locus chr3:34–35 Mb 2,000 from disk 0.68 0.63 0.38 0.52
mouse locus chr3:34–35 Mb 2,000 from the bucket 4.03 2.01 1.53 13.00

juicebox.js will not draw a chromosome smaller than its map, and it works out how small that is when the map is loaded, before the track beside it takes some of the map’s width: a whole chromosome asked for is then drawn wider than the map, in part, and that time is not compared below. Every run that drew something other than the picture asked for, or nothing:

Code
ODD = timed_runs[timed_runs['status'] != 'drawn']
if ODD.empty:
    print('None: every run drew the whole window at the resolution asked for.')
else:
    odd = (ODD.groupby(['genome', 'view', 'viewer', 'format', 'location', 'status', 'note'], sort=False, dropna=False)
           .size().rename('runs').reset_index())
    odd['viewer'] = odd['viewer'].map(NAMES)
    display(show(odd))
genome view viewer format location status note runs
yeast chromosome juicebox.js hic local in part 450 of the window's 479 bins 4
yeast chromosome juicebox.js hic cloud in part 450 of the window's 479 bins 4
chicken chromosome juicebox.js hic local in part 369 of the window's 393 bins 4
chicken chromosome juicebox.js hic cloud in part 369 of the window's 393 bins 4
mouse chromosome juicebox.js hic local in part 301 of the window's 321 bins 4
mouse chromosome juicebox.js hic cloud in part 301 of the window's 321 bins 4
Code
VIEWER_COLOURS = {('chromosomix', 'mcool'): APP_COLOUR, ('chromosomix', 'hic'): '#7fb0ea',
                  ('juicebox', 'hic'): REF, ('higlass', 'mcool'): '#4f9d69'}
fig, axes = plt.subplots(len(GENOMES), len(LOCATIONS), figsize=(10, 2.6 * len(GENOMES) + 0.8), sharey=True,
                         layout='constrained', squeeze=False)
OFFSET = {key: (k - (len(VIEWERS) - 1) / 2) * 0.18 for k, key in enumerate(VIEWERS)}
for (r, genome), (c, location) in [(rc, lc) for rc in enumerate(GENOMES) for lc in enumerate(LOCATIONS)]:
    ax = axes[r][c]
    here = drew[(drew['genome'] == genome) & (drew['location'] == location)]
    for x, view in enumerate(VIEWS):
        for key in VIEWERS:
            rows = here[(here['view'] == view) & (here['viewer'] == key[0]) & (here['format'] == key[1])]
            if rows.empty:
                continue
            at = x + OFFSET[key]
            whole = rows['status'] == 'drawn'
            colour = VIEWER_COLOURS[key]
            ax.scatter(np.full(whole.sum(), at), rows.loc[whole, 'seconds'], s=14, color=colour, alpha=0.7, lw=0,
                       zorder=3)
            ax.scatter(np.full((~whole).sum(), at), rows.loc[~whole, 'seconds'], s=16, facecolors='none',
                       edgecolors=colour, lw=1, zorder=3)
            ax.plot([at - 0.07, at + 0.07], [rows['seconds'].median()] * 2, color=colour, lw=2.5, zorder=4)
    ax.set_xticks(range(len(VIEWS)), [f"{v}\n{next(w['label'] for w in GENOMES[genome]['views'] if w['view'] == v)}"
                                      for v in VIEWS], fontsize=8)
    ax.set_yscale('log')
    ax.yaxis.set_major_formatter(FuncFormatter(lambda v, _: f'{v:g}'))
    ax.set_title(f'{genome}, {WHERE[location]}')
    ax.grid(axis='x', visible=False)
    if c == 0:
        ax.set_ylabel('seconds')
for key in VIEWERS:
    axes[0][-1].scatter([], [], s=24, color=VIEWER_COLOURS[key], label=COLUMN[key])
axes[0][-1].legend(loc='upper left', bbox_to_anchor=(1, 1))
plt.show()
Figure 1: Seconds from handing each viewer its files to its picture being finished, every run a dot and the median a bar, on a logarithmic axis, by genome (rows) and by where the files were read from (columns). An open dot is a run that drew part of the window, or at another resolution; a view with no dots for a viewer is one it cannot draw at that resolution (the table above).

Same file, other viewer

Where two viewers read the same file — juicebox.js and chromosomix the .hic, HiGlass and chromosomix the .mcool — and both drew the whole window at the resolution asked for in every run, the other viewer’s median time over chromosomix’s, picture by picture: above 1, chromosomix was the faster.

Code
def against(other, form):
    """The other viewer's median seconds over chromosomix's on the same file, for each picture both drew whole."""
    out = []
    for (genome, view, location), rows in timed_runs.groupby(['genome', 'view', 'location'], sort=False):
        mine = rows[(rows['viewer'] == 'chromosomix') & (rows['format'] == form)]
        theirs = rows[(rows['viewer'] == other) & (rows['format'] == form)]
        whole = (mine['status'] == 'drawn').all() and (theirs['status'] == 'drawn').all()
        if mine.empty or theirs.empty or not whole:
            continue
        out.append({'genome': genome, 'view': view, 'from': WHERE[location], 'file': f'.{form}',
                    'other viewer': NAMES[other], 'chromosomix s': mine['seconds'].median(),
                    'other s': theirs['seconds'].median()})
    return out


SAME = pd.DataFrame(against('juicebox', 'hic') + against('higlass', 'mcool'))
SAME['× faster'] = SAME['other s'] / SAME['chromosomix s']
show(SAME, **{'× faster': '{:.2f}×'})
genome view from file other viewer chromosomix s other s × faster
yeast region from disk .hic juicebox.js 0.46 0.30 0.66×
yeast region from the bucket .hic juicebox.js 2.31 2.69 1.16×
chicken region from disk .hic juicebox.js 0.66 0.56 0.86×
chicken region from the bucket .hic juicebox.js 1.88 1.49 0.79×
chicken locus from disk .hic juicebox.js 0.55 0.26 0.47×
chicken locus from the bucket .hic juicebox.js 1.81 1.30 0.72×
mouse region from disk .hic juicebox.js 0.68 0.72 1.05×
mouse region from the bucket .hic juicebox.js 2.07 1.90 0.92×
mouse locus from disk .hic juicebox.js 0.63 0.38 0.59×
mouse locus from the bucket .hic juicebox.js 2.01 1.53 0.76×
yeast genome from disk .mcool HiGlass 0.44 0.53 1.21×
yeast genome from the bucket .mcool HiGlass 2.59 13.06 5.04×
yeast chromosome from disk .mcool HiGlass 0.56 0.52 0.93×
yeast chromosome from the bucket .mcool HiGlass 2.86 13.14 4.59×
yeast region from disk .mcool HiGlass 0.58 0.54 0.93×
yeast region from the bucket .mcool HiGlass 3.35 14.69 4.39×
yeast locus from disk .mcool HiGlass 0.58 0.53 0.91×
yeast locus from the bucket .mcool HiGlass 3.57 15.54 4.35×
chicken genome from disk .mcool HiGlass 0.47 0.53 1.12×
chicken genome from the bucket .mcool HiGlass 1.82 5.88 3.23×
chicken chromosome from disk .mcool HiGlass 0.64 0.53 0.83×
chicken chromosome from the bucket .mcool HiGlass 2.00 5.72 2.85×
chicken region from disk .mcool HiGlass 0.63 0.53 0.84×
chicken region from the bucket .mcool HiGlass 2.61 7.35 2.82×
chicken locus from disk .mcool HiGlass 0.64 0.52 0.81×
chicken locus from the bucket .mcool HiGlass 2.83 7.71 2.73×
mouse genome from disk .mcool HiGlass 0.48 0.54 1.11×
mouse genome from the bucket .mcool HiGlass 2.09 7.00 3.35×
mouse chromosome from disk .mcool HiGlass 0.63 1.37 2.20×
mouse chromosome from the bucket .mcool HiGlass 2.66 12.41 4.67×
mouse region from disk .mcool HiGlass 0.76 0.53 0.70×
mouse region from the bucket .mcool HiGlass 3.64 11.18 3.07×
mouse locus from disk .mcool HiGlass 0.68 0.52 0.77×
mouse locus from the bucket .mcool HiGlass 4.03 13.00 3.23×
Code
def geomean(values):
    return float(np.exp(np.log(np.asarray(values, dtype=float)).mean()))


def summary(other, location):
    rows = SAME[(SAME['other viewer'] == NAMES[other]) & (SAME['from'] == WHERE[location])]
    if rows.empty:
        return 'no picture both drew whole'
    return (f"{geomean(rows['× faster']):.2f}× over {len(rows)} pictures "
            f"({rows['× faster'].min():.2f}× to {rows['× faster'].max():.2f}×)")


V_TEXT = {'juicebox_local': summary('juicebox', 'local'), 'juicebox_cloud': summary('juicebox', 'cloud'),
          'higlass_local': summary('higlass', 'local'), 'higlass_cloud': summary('higlass', 'cloud')}
SLOWEST = drew.loc[drew['seconds'].idxmax()] if not drew.empty else None
V_TEXT['slowest'] = ('' if SLOWEST is None else
                     f"{SLOWEST['genome']}, {SLOWEST['view']} view, {NAMES[SLOWEST['viewer']]} on the "
                     f".{SLOWEST['format']} {WHERE[SLOWEST['location']]}: {SLOWEST['seconds']:.1f} s, "
                     f"{SLOWEST['requests']:.0f} requests")

On the same .hic, juicebox.js’s time over chromosomix’s is 0.70× over 5 pictures (0.47× to 1.05×) from disk, and 0.86× over 5 pictures (0.72× to 1.16×) from the bucket; on the same .mcool, HiGlass’s is 0.98× over 12 pictures (0.70× to 2.20×) from disk, and 3.61× over 12 pictures (2.73× to 5.04×) from the bucket — geometric means of the folds above, with their ranges. The slowest run of all: {python} V_TEXT['slowest'].

What it read, and the pages themselves

Requests and megabytes the page asked for before its picture was finished, median over the runs from the bucket; and each viewer’s page, loaded empty with its code, median seconds, which the times above leave out.

Code
CLOUD = drew[drew['location'] == 'cloud']
read = (CLOUD.assign(viewer=CLOUD['viewer'].map(NAMES))
        .groupby(['genome', 'view', 'viewer', 'format'], sort=False)
        .agg(requests=('requests', 'median'), megabytes=('megabytes', 'median')).reset_index())
display(show(read, requests='{:,.0f}', megabytes='{:,.1f}'))
pages = (timed_runs.assign(viewer=timed_runs['viewer'].map(NAMES), where=timed_runs['location'].map(WHERE))
         .groupby(['viewer', 'where'], sort=False)['app_seconds'].median().rename('page loaded, s').reset_index())
show(pages)
genome view viewer format requests megabytes
yeast genome chromosomix mcool 6 4.4
yeast genome chromosomix hic 76 3.8
yeast genome HiGlass mcool 4 0.9
yeast chromosome chromosomix mcool 9 9.7
yeast chromosome chromosomix hic 5 2.5
yeast chromosome juicebox.js hic 11 12.8
yeast chromosome HiGlass mcool 4 1.1
yeast region chromosomix mcool 12 12.0
yeast region chromosomix hic 6 2.8
yeast region juicebox.js hic 11 12.8
yeast region HiGlass mcool 4 0.5
yeast locus chromosomix mcool 13 14.3
yeast locus HiGlass mcool 4 0.5
chicken genome chromosomix mcool 9 6.8
chicken genome chromosomix hic 434 30.3
chicken genome HiGlass mcool 4 0.9
chicken chromosome chromosomix mcool 12 9.1
chicken chromosome chromosomix hic 10 19.6
chicken chromosome juicebox.js hic 11 0.9
chicken chromosome HiGlass mcool 4 0.5
chicken region chromosomix mcool 18 18.9
chicken region chromosomix hic 8 21.5
chicken region juicebox.js hic 16 2.7
chicken region HiGlass mcool 4 0.5
chicken locus chromosomix mcool 19 18.4
chicken locus chromosomix hic 8 20.4
chicken locus juicebox.js hic 16 1.1
chicken locus HiGlass mcool 4 1.1
mouse genome chromosomix mcool 11 8.8
mouse genome chromosomix hic 460 21.5
mouse genome HiGlass mcool 4 1.9
mouse chromosome chromosomix mcool 20 21.5
mouse chromosome chromosomix hic 8 20.4
mouse chromosome juicebox.js hic 11 0.6
mouse chromosome HiGlass mcool 4 1.6
mouse region chromosomix mcool 28 34.6
mouse region chromosomix hic 10 22.0
mouse region juicebox.js hic 16 3.5
mouse region HiGlass mcool 4 0.5
mouse locus chromosomix mcool 28 29.6
mouse locus chromosomix hic 10 21.8
mouse locus juicebox.js hic 16 1.5
mouse locus HiGlass mcool 4 1.1
viewer where page loaded, s
chromosomix from disk 0.16
HiGlass from disk 0.24
chromosomix from the bucket 0.14
HiGlass from the bucket 0.24
juicebox.js from disk 0.15
juicebox.js from the bucket 0.17
Code
from PIL import Image

PICTURE_KEYS = [(genome, view['view'], view['label']) for genome in GENOMES for view in GENOMES[genome]['views']]
fig, axes = plt.subplots(len(VIEWERS), len(PICTURE_KEYS), layout='constrained', squeeze=False,
                         figsize=(1.2 * len(PICTURE_KEYS) + 1, 1.35 * len(VIEWERS) + 0.6))
for c, (genome, view, label) in enumerate(PICTURE_KEYS):
    for r, (tool, form) in enumerate(VIEWERS):
        ax = axes[r][c]
        ax.set_xticks([])
        ax.set_yticks([])
        ax.grid(False)
        for side in ax.spines.values():
            side.set_visible(False)
        picture = f'{genome}-{view}-{tool}-{form}-local'
        shot = VIEWER_SHOTS / f'{picture}.png'
        if shot.exists() and picture in CLIPS:
            clip = CLIPS[picture]
            image = Image.open(shot).crop((round(clip['x']), round(clip['y']), round(clip['x'] + clip['width']),
                                           round(clip['y'] + clip['height'])))
            image.thumbnail((360, 360))
            ax.imshow(image)
        if r == 0:
            ax.set_title(f'{genome}\n{label}', fontsize=7, fontweight='normal', loc='center')
        if c == 0:
            ax.set_ylabel(COLUMN[(tool, form)], fontsize=7, rotation=0, ha='right', va='center')
plt.show()
Figure 2: What each viewer had drawn when it said its picture was finished: the first round, from disk, cropped to the map and its track. Each column is a picture, each row a viewer and the file it read; an empty cell is a view the viewer cannot draw at the resolution asked for.

The machine

Code
if not MANIFEST.exists():
    raise FileNotFoundError(f'{MANIFEST} is not there: render validation.qmd first.')
LISTED = json.loads(MANIFEST.read_text())
OUT = LISTED['placeholder']
JOBS = [job for job in LISTED['commands'] if job['bench']]
measures({**MACHINE, 'commands timed': f"{len(JOBS)} of the {len(LISTED['commands'])} listed",
          'runs of each': RUNS, 'chromosomix': LISTED['chromosomix'],
          'processes a reference was given, where it takes them': LISTED['threads'],
          'peak memory': (f'every process of a run together, weighed every {EVERY} s' if WEIGHS
                          else 'the largest process of a run alone')})
measure value
processor AMD EPYC 9V45 96-Core Processor
cores 4
memory_gb 16.800000
system Linux-7.0.0-1017-azure-x86_64-with-glibc2.39
node v24.22.1
python 3.13.17
commands timed 103 of the 166 listed
runs of each 4
chromosomix 0.19.0 at 997e59c
processes a reference was given, where it takes them 4
peak memory every process of a run together, weighed every 0.25 s

The runs

Each command is run as validation.qmd printed it, from notebooks/work/validation/, with its output sent to a scratch folder that is emptied after the run: all of them once, then all of them again, so a slow minute of the machine falls on every command rather than on one. chromosomix computes on one thread; a reference that takes a number of processes was given the number in the threads column.

The two sides of a pair take turns to go first. The list has each analysis’s chromosomix commands before its references, and the odd rounds run it so; the even rounds run each analysis’s references first, each side’s commands still in their own order. Whichever side runs first reads from the disk whatever of their shared input the page cache has let go since the round before, and the side after it finds it in memory. Run in one order, that wait fell on chromosomix every time: a metaplot whose bigWig had gone from the cache spent twelve seconds of its wall clock waiting on it, and computeMatrix, straight after, none. In turns, it falls on each side in half the rounds, which is why the number of rounds is even.

Code
def argv_for(job, scratch):
    """The command as it ran, writing to `scratch`."""
    if job['tool'] == 'chromosomix':
        return job['argv'] + ['--out', os.path.relpath(scratch, WORK)]
    target = os.path.relpath(scratch / job['out'], WORK)
    return [a.replace(OUT, target) for a in job['argv']]


def under(pid):
    """`pid` and every process under it, by the children each of their threads started."""
    found, todo = [], [pid]
    while todo:
        pid = todo.pop()
        found.append(pid)
        try:
            for tid in os.listdir(f'/proc/{pid}/task'):
                with open(f'/proc/{pid}/task/{tid}/children') as handle:
                    todo += [int(child) for child in handle.read().split()]
        except OSError:
            pass
    return found


def pss_kb(pid):
    """A process's proportional set size, in kB: its resident pages, one it shares with others counted as its
    share of it, so that a pool of forked workers is not counted again in each."""
    try:
        with open(f'/proc/{pid}/smaps_rollup') as handle:
            return next(int(line.split()[1]) for line in handle if line.startswith('Pss:'))
    except (OSError, StopIteration):
        return 0


def weigh(pid, done, peak):
    """Every EVERY seconds until `done`, the processes under `pid` weighed together, the most into `peak[0]`."""
    while True:
        peak[0] = max(peak[0], sum(pss_kb(p) for p in under(pid)))
        if done.wait(EVERY):
            return


def timed(argv, extra):
    """One run, from work/validation/: CPU seconds of the process and every process it started and waited
    for, wall seconds, and the most memory they held together, in MB."""
    env = None if extra is None else dict(os.environ, **extra)
    with tempfile.TemporaryFile() as err:
        start = time.perf_counter()
        process = subprocess.Popen(argv, cwd=WORK, env=env, stdout=subprocess.DEVNULL, stderr=err)
        done, peak = threading.Event(), [0]
        weigher = threading.Thread(target=weigh, args=(process.pid, done, peak), daemon=True)
        if WEIGHS:
            weigher.start()
        _, status, usage = os.wait4(process.pid, 0)
        wall = time.perf_counter() - start
        done.set()
        if WEIGHS:
            weigher.join()
        process.returncode = os.waitstatus_to_exitcode(status)
        if process.returncode:
            err.seek(0)
            raise RuntimeError(f'{shlex.join(argv)}\n{err.read().decode(errors="replace")[-3000:]}')
    # ru_maxrss, the largest resident set any one of the processes reached, which a sample can miss: in
    # kilobytes on Linux and in bytes on macOS.
    largest = usage.ru_maxrss / (1024 * 1024 if sys.platform == 'darwin' else 1024)
    return {'cpu_s': usage.ru_utime + usage.ru_stime, 'user_s': usage.ru_utime, 'system_s': usage.ru_stime,
            'wall_s': wall, 'peak_mb': max(peak[0] / 1024, largest)}


#: The columns of the table, in order: what was run, the run, what it cost, where.
COLUMNS = ['section', 'analysis', 'data', 'command', 'tool', 'version', 'variant', 'group', 'threads', 'run',
           'order', 'cpu_s', 'user_s', 'system_s', 'wall_s', 'peak_mb', 'started', *MACHINE, 'chromosomix',
           'command_line']


def round_of(run):
    """The jobs round `run` runs, in its order, each with its place in the list (`order`): as listed on an
    odd round, each analysis's chromosomix commands before its references, and on an even one each
    analysis's references first, each side's commands in their own order."""
    listed = list(enumerate(JOBS))
    if run % 2 == 1:
        return listed
    turned = []
    for _, analysis in groupby(listed, key=lambda pair: (pair[1]['section'], pair[1]['analysis'])):
        analysis = list(analysis)
        turned += [pair for pair in analysis if pair[1]['tool'] != 'chromosomix']
        turned += [pair for pair in analysis if pair[1]['tool'] == 'chromosomix']
    return turned


def benchmark():
    rows = []
    for run in range(1, RUNS + 1):
        for order, job in round_of(run):
            scratch = SCRATCH / job['name']
            shutil.rmtree(scratch, ignore_errors=True)
            scratch.mkdir(parents=True)
            started = time.strftime('%Y-%m-%dT%H:%M:%S')
            cost = timed(argv_for(job, scratch), job['env'])
            shutil.rmtree(scratch, ignore_errors=True)
            rows.append({'section': job['section'], 'analysis': job['analysis'], 'data': job['data'],
                         'command': job['name'], 'tool': job['tool'], 'version': job['version'],
                         'variant': job['variant'], 'group': job['group'], 'threads': job['threads'], 'run': run,
                         'order': order, **cost, 'started': started, **MACHINE,
                         'chromosomix': LISTED['chromosomix'], 'command_line': job['command']})
        # Written after every round, so a long benchmark that is stopped keeps the rounds it finished.
        pd.DataFrame(rows, columns=COLUMNS).to_csv(TIMINGS, sep='\t', index=False)
    return pd.DataFrame(rows, columns=COLUMNS)


def current():
    """Whether the table on disk times every command listed, each RUNS times, with the chromosomix listed, in
    this notebook's columns."""
    if REDO or not TIMINGS.exists():
        return False
    held = pd.read_csv(TIMINGS, sep='\t', keep_default_na=False)
    if list(held.columns) != COLUMNS:
        return False
    runs = held.groupby('order')['run'].nunique()
    return (len(runs) == len(JOBS) and set(held['command']) == {job['name'] for job in JOBS}
            and (runs == RUNS).all() and set(held['chromosomix']) == {LISTED['chromosomix']})


if not current():
    SCRATCH.mkdir(parents=True, exist_ok=True)
    benchmark()
times = pd.read_csv(TIMINGS, sep='\t', keep_default_na=False)
measures({'runs in the table': f'{len(times):,}', 'columns': len(times.columns),
          'measured': f"{times['started'].min()} to {times['started'].max()}", 'table': os.path.relpath(TIMINGS, REPO)})
measure value
runs in the table 412
columns 25
measured 2026-11-01T23:22:46 to 2026-11-02T01:18:02
table notebooks/work/validation/timings.tsv

The table’s first lines:

Code
show(times.head(4))
section analysis data command tool version variant group threads run order cpu_s user_s system_s wall_s peak_mb started processor cores memory_gb system node python chromosomix command_line
1 Diamond insulation mouse Hi-C, 10 kb, window 100 kb insulation chromosomix 0.19.0 at 997e59c 1 1 0 4.88 4.39 0.49 3.95 294.53 2026-11-01T23:22:46 AMD EPYC 9V45 96-Core Processor 4 16.80 Linux-7.0.0-1017-azure-x86_64-with-glibc2.39 v24.22.1 3.13.17 0.19.0 at 997e59c chromosomix insulation data/4DNFIOZ1TRUS.mcool::/resolutions/10000 --window 10 --out insulation/app
1 Diamond insulation mouse Hi-C, 10 kb, window 100 kb cooltools-insulation cooltools 0.7.1 4 1 1 12.05 11.15 0.90 6.11 874.76 2026-11-01T23:22:50 AMD EPYC 9V45 96-Core Processor 4 16.80 Linux-7.0.0-1017-azure-x86_64-with-glibc2.39 v24.22.1 3.13.17 0.19.0 at 997e59c python ../../scripts/cooltools_insulation.py data/4DNFIOZ1TRUS.mcool::/resolutions/10000 insulation/cooltools.tsv 4
2 Directionality index mouse Hi-C, 50 kb, window 2 Mb di chromosomix 0.19.0 at 997e59c 1 1 2 2.36 2.20 0.15 1.84 211.58 2026-11-01T23:22:57 AMD EPYC 9V45 96-Core Processor 4 16.80 Linux-7.0.0-1017-azure-x86_64-with-glibc2.39 v24.22.1 3.13.17 0.19.0 at 997e59c chromosomix di data/4DNFIOZ1TRUS.mcool::/resolutions/50000 --window 40 --out di/app
2 Directionality index mouse Hi-C, 50 kb, window 2 Mb domaincaller-di domaincaller 0.1.0 1 1 3 2.78 2.61 0.17 2.48 258.31 2026-11-01T23:22:58 AMD EPYC 9V45 96-Core Processor 4 16.80 Linux-7.0.0-1017-azure-x86_64-with-glibc2.39 v24.22.1 3.13.17 0.19.0 at 997e59c python ../../scripts/domaincaller_di.py data/4DNFIOZ1TRUS.mcool::/resolutions/50000 di/domaincaller.npy

Each command

CPU and wall-clock seconds, mean ± sd over the runs, and the peak memory of the largest run.

Code
KEY = ['section', 'analysis', 'tool', 'variant', 'group', 'command', 'order']
per = (times.groupby(KEY, sort=False)
       .agg(cpu=('cpu_s', 'mean'), cpu_sd=('cpu_s', 'std'), wall=('wall_s', 'mean'), wall_sd=('wall_s', 'std'),
            peak=('peak_mb', 'max'))
       .reset_index().sort_values('order').reset_index(drop=True))
per['CPU s'] = [f'{m:.2f} ± {s:.2f}' for m, s in zip(per['cpu'], per['cpu_sd'])]
per['wall s'] = [f'{m:.2f} ± {s:.2f}' for m, s in zip(per['wall'], per['wall_sd'])]
per['peak MB'] = per['peak'].round().astype(int)
show(per[['section', 'analysis', 'tool', 'variant', 'CPU s', 'wall s', 'peak MB']], **{'peak MB': '{:,}'})
section analysis tool variant CPU s wall s peak MB
1 Diamond insulation chromosomix 4.88 ± 0.08 3.89 ± 0.05 302
1 Diamond insulation cooltools 11.71 ± 0.26 5.31 ± 0.54 890
2 Directionality index chromosomix 2.34 ± 0.04 1.80 ± 0.05 215
2 Directionality index domaincaller 2.75 ± 0.03 2.46 ± 0.02 259
3 Compartments chromosomix 6.10 ± 0.33 4.42 ± 0.24 301
3 Compartments cooltools 14.85 ± 0.36 7.74 ± 0.32 560
4 P(s) chromosomix yeast 800 bp 0.70 ± 0.02 0.53 ± 0.01 212
4 P(s) chromosomix mouse 10 kb 2.86 ± 0.03 2.37 ± 0.01 238
4 P(s) cooltools yeast 800 bp 3.04 ± 0.09 2.39 ± 0.09 958
4 P(s) cooltools mouse 10 kb 24.69 ± 1.35 9.11 ± 0.65 11,706
5 Peak calling (yapc, IDR) chromosomix 20.30 ± 0.21 18.49 ± 0.21 300
5 Peak calling (yapc, IDR) yapc 0.1 seed 0 61.90 ± 0.26 62.07 ± 0.23 1,668
6 Loops (chromosight) chromosomix 6.23 ± 0.16 5.11 ± 0.13 270
6 Loops (chromosight) chromosight 30.91 ± 0.76 10.74 ± 0.41 771
7 Reproducibility (HiCRep) chromosomix 3 maps, 3 pairs 4.05 ± 0.16 3.24 ± 0.13 262
7 Reproducibility (HiCRep) hicrep 3 maps, 3 pairs 5.52 ± 0.17 5.24 ± 0.16 261
8 3D model (pastis-NB) chromosomix seed 0 5.78 ± 0.08 5.48 ± 0.08 212
8 3D model (pastis-NB) pastis seed 0 81.53 ± 2.69 29.78 ± 1.17 359
9 Coverage chromosomix raw 3.87 ± 0.14 3.17 ± 0.09 373
9 Coverage chromosomix balanced 4.11 ± 0.13 3.41 ± 0.12 276
9 Coverage cooltools raw 5.83 ± 0.17 2.69 ± 0.13 1,592
9 Coverage cooltools balanced 6.29 ± 0.17 3.04 ± 0.12 1,826
10 Virtual 4C chromosomix Sox2 1.29 ± 0.09 0.98 ± 0.07 277
10 Virtual 4C cooltools Sox2 6.95 ± 0.27 2.93 ± 0.11 555
11 Aggregate peak analysis chromosomix raw 1.34 ± 0.03 0.92 ± 0.02 212
11 Aggregate peak analysis chromosomix raw, every loop 2.04 ± 0.04 1.45 ± 0.03 212
11 Aggregate peak analysis chromosomix on the diagonal, raw 2.23 ± 0.04 1.56 ± 0.03 218
11 Aggregate peak analysis chromosomix controls, seed 1 2.41 ± 0.06 1.72 ± 0.03 212
11 Aggregate peak analysis coolpuppy raw 8.84 ± 0.29 3.54 ± 0.19 956
11 Aggregate peak analysis coolpuppy raw, every loop 9.06 ± 0.37 3.65 ± 0.18 895
11 Aggregate peak analysis coolpuppy on the diagonal, raw 9.43 ± 0.36 3.73 ± 0.15 1,023
11 Aggregate peak analysis coolpuppy controls, seed 0 8.92 ± 0.32 3.51 ± 0.14 1,015
12 APA, chromosight's score chromosomix 5.06 ± 0.10 3.73 ± 0.06 249
12 APA, chromosight's score chromosight 83.65 ± 1.64 26.67 ± 0.58 1,477
13 Boost-HiC chromosomix chr1, 1,955 bins 3.48 ± 0.28 3.25 ± 0.28 652
13 Boost-HiC Boost-HiC chr1, 1,955 bins 20.18 ± 0.32 19.67 ± 0.32 462
14 Metaplot chromosomix 4.24 ± 0.04 3.53 ± 0.19 384
14 Metaplot chromosomix 7.61 ± 0.21 6.26 ± 0.12 439
14 Metaplot deepTools computeMatrix 3.41 ± 0.09 3.61 ± 0.68 212
14 Metaplot deepTools plotProfile 1.86 ± 0.04 1.56 ± 0.04 222
14 Metaplot deepTools computeMatrix 6.26 ± 0.04 4.07 ± 0.38 245
14 Metaplot deepTools plotProfile 2.54 ± 0.02 2.23 ± 0.02 263
14 Metaplot deepTools plotHeatmap 3.22 ± 0.02 2.91 ± 0.03 488
15 Interval overlap chromosomix 1.08 ± 0.03 1.00 ± 0.03 212
15 Interval overlap bedtools 41.98 ± 1.45 37.66 ± 1.34 212
16 Base composition chromosomix 3.72 ± 0.09 5.31 ± 2.65 294
16 Base composition bioframe, Biopython 39.95 ± 0.43 41.50 ± 2.31 508
17 Motif search chromosomix TGACTCA, ≤ 1 mismatch 5.23 ± 0.12 5.22 ± 0.15 360
17 Motif search chromosomix WGATAR 4.12 ± 0.19 4.03 ± 0.11 324
17 Motif search seqkit TGACTCA, ≤ 1 mismatch 18.78 ± 0.72 18.59 ± 0.68 4,119
17 Motif search seqkit WGATAR 6.32 ± 0.05 6.04 ± 0.05 344
18 Track correlation chromosomix 100 bp, pearson 0.75 ± 0.02 0.56 ± 0.01 212
18 Track correlation chromosomix 100 bp, spearman 1.57 ± 0.04 1.33 ± 0.04 328
18 Track correlation chromosomix 1,000 bp, pearson 0.73 ± 0.02 0.50 ± 0.02 212
18 Track correlation chromosomix 1,000 bp, spearman 0.83 ± 0.03 0.58 ± 0.02 212
18 Track correlation chromosomix 10,000 bp, pearson 0.71 ± 0.02 0.49 ± 0.01 212
18 Track correlation chromosomix 10,000 bp, spearman 0.74 ± 0.02 0.50 ± 0.01 212
18 Track correlation deepTools 100 bp, the windows 189.36 ± 1.42 50.11 ± 0.58 307
18 Track correlation deepTools 100 bp, pearson 1.73 ± 0.06 1.11 ± 0.06 271
18 Track correlation deepTools 100 bp, spearman 2.12 ± 0.08 1.51 ± 0.08 271
18 Track correlation deepTools 1,000 bp, the windows 17.75 ± 0.21 4.78 ± 0.04 212
18 Track correlation deepTools 1,000 bp, pearson 1.42 ± 0.04 0.80 ± 0.04 212
18 Track correlation deepTools 1,000 bp, spearman 1.47 ± 0.06 0.85 ± 0.06 212
18 Track correlation deepTools 10,000 bp, the windows 2.30 ± 0.03 0.71 ± 0.01 212
18 Track correlation deepTools 10,000 bp, pearson 1.40 ± 0.07 0.78 ± 0.07 212
18 Track correlation deepTools 10,000 bp, spearman 1.39 ± 0.04 0.77 ± 0.04 212
19 Loop calling chromosomix 25.42 ± 0.80 21.92 ± 0.58 471
19 Loop calling cooltools 557.49 ± 10.62 156.27 ± 2.95 11,531
23 Signal by group chromosomix one value per entry 0.65 ± 0.02 0.46 ± 0.00 212
23 Signal by group pyBigWig, SciPy one value per entry 1.47 ± 0.05 0.88 ± 0.03 212
24 CTCF sites in GM12878 (Fig. 2) chromosomix insulation, four scales 1.31 ± 0.04 0.96 ± 0.04 217
24 CTCF sites in GM12878 (Fig. 2) chromosomix peaks 38.68 ± 1.78 36.26 ± 1.62 266
24 CTCF sites in GM12878 (Fig. 2) chromosomix APA, raw 0.72 ± 0.04 0.49 ± 0.03 212
24 CTCF sites in GM12878 (Fig. 2) chromosomix APA, raw, every pair 1.12 ± 0.07 0.81 ± 0.04 227
24 CTCF sites in GM12878 (Fig. 2) chromosomix metaplot, CTCF and ATAC-seq, ±2 kb 1.15 ± 0.09 0.87 ± 0.06 273
24 CTCF sites in GM12878 (Fig. 2) chromosomix metaplot, insulation at four scales, ±500 kb 1.19 ± 0.06 0.97 ± 0.03 212
24 CTCF sites in GM12878 (Fig. 2) cooltools insulation, four scales 5.80 ± 0.20 5.18 ± 0.19 807
24 CTCF sites in GM12878 (Fig. 2) yapc 0.1 peaks, seed 0 79.50 ± 0.60 79.56 ± 0.60 1,597
24 CTCF sites in GM12878 (Fig. 2) coolpuppy APA, raw 3.19 ± 0.16 2.72 ± 0.16 1,090
24 CTCF sites in GM12878 (Fig. 2) coolpuppy APA, raw, every pair 3.38 ± 0.17 2.90 ± 0.17 1,123
24 CTCF sites in GM12878 (Fig. 2) deepTools computeMatrix 0.78 ± 0.03 0.42 ± 0.03 212
24 CTCF sites in GM12878 (Fig. 2) deepTools plotProfile 0.98 ± 0.04 0.68 ± 0.04 212
24 CTCF sites in GM12878 (Fig. 2) deepTools plotHeatmap 1.38 ± 0.05 1.07 ± 0.05 212
24 CTCF sites in GM12878 (Fig. 2) deepTools computeMatrix 38.83 ± 0.88 10.97 ± 0.16 946
24 CTCF sites in GM12878 (Fig. 2) deepTools plotProfile 1.11 ± 0.04 0.81 ± 0.04 212
24 CTCF sites in GM12878 (Fig. 2) deepTools plotHeatmap 1.64 ± 0.04 1.33 ± 0.05 212
25 Pairs statistics chromosomix 67.34 ± 2.02 60.90 ± 1.48 295
25 Pairs statistics pairtools 83.99 ± 3.74 74.77 ± 3.63 308
26 Restriction sites chromosomix 1.13 ± 0.06 1.04 ± 0.06 212
26 Restriction sites bioframe, Biopython 1.17 ± 0.06 0.86 ± 0.07 212
27 Pairs QC chromosomix 137.29 ± 1.82 122.83 ± 0.92 331
27 Pairs QC cooler 43.83 ± 0.95 43.59 ± 0.96 2,399
27 Pairs QC pairtools 337.58 ± 3.51 203.93 ± 1.69 212
27 Pairs QC pairtools 336.66 ± 3.56 203.92 ± 2.19 212
27 Pairs QC pandas 76.23 ± 1.31 77.43 ± 1.44 863
27 Pairs QC cooltools 2.14 ± 0.15 1.49 ± 0.15 297
27 Pairs QC pairtools 82.51 ± 3.07 72.93 ± 2.94 310
28 TAD annotation chromosomix raw 5.84 ± 0.08 6.15 ± 0.32 538
28 TAD annotation chromosomix balanced 6.04 ± 0.12 6.25 ± 0.09 567
28 TAD annotation Juicer Tools raw 38.94 ± 3.96 20.01 ± 1.31 3,510
28 TAD annotation Juicer Tools balanced 40.23 ± 3.03 20.42 ± 1.13 4,675
29 Map report chromosomix 3.29 ± 0.04 2.75 ± 0.03 252
29 Map report cooler 5.68 ± 0.01 5.41 ± 0.02 861

Against the references

For each analysis, the chromosomix commands against the reference commands they are paired with — the same variant, or the analysis’s one reference — as a fold: the reference’s seconds over the app’s, summed where a side is several commands, which the table names. deepTools’ metaplot is computeMatrix and then plotProfile — and plotHeatmap, over two tracks — against one chromosomix metaplot; its track correlation is multiBigwigSummary and then plotCorrelation once a method, against chromosomix trackcorr once a method.

Code
#: Each timed command's line, by its name, for what the pairs below add up.
LINES = {job['name']: job['command'] for job in JOBS}


def program(name):
    """What a timed command runs: chromosomix's task, a reference script's name, or the program."""
    words = shlex.split(LINES[name])
    if words[0] == 'chromosomix':
        return words[1]
    if words[0].endswith('python'):
        return Path(words[1]).stem
    return words[0]


def summed(rows):
    """The commands one side of a pair adds up, in order, a repeated one counted: `plotCorrelation ×2`."""
    counts = Counter(program(name) for name in rows['command'])
    return ' + '.join(name if n == 1 else f'{name} ×{n}' for name, n in counts.items())


def pairs():
    out = []
    for (section, analysis), rows in per.groupby(['section', 'analysis'], sort=False):
        apps, refs = rows[rows['tool'] == 'chromosomix'], rows[rows['tool'] != 'chromosomix']
        for group, mine in apps.groupby('group', sort=False):
            theirs = refs[refs['group'] == group]
            if theirs.empty and refs['group'].nunique() == 1:
                theirs = refs
            if theirs.empty:
                continue
            out.append({'section': section, 'analysis': analysis, 'variant': group or '—',
                        'reference': ', '.join(dict.fromkeys(theirs['tool'])),
                        'chromosomix commands': summed(mine), 'reference commands': summed(theirs),
                        'chromosomix CPU s': mine['cpu'].sum(), 'reference CPU s': theirs['cpu'].sum(),
                        'chromosomix wall s': mine['wall'].sum(), 'reference wall s': theirs['wall'].sum(),
                        'chromosomix peak MB': mine['peak'].max(), 'reference peak MB': theirs['peak'].max()})
    frame = pd.DataFrame(out)
    frame['× faster, CPU'] = frame['reference CPU s'] / frame['chromosomix CPU s']
    frame['× faster, wall'] = frame['reference wall s'] / frame['chromosomix wall s']
    return frame


folds = pairs()
show(folds, **{'× faster, CPU': '{:.1f}×', '× faster, wall': '{:.1f}×', 'chromosomix peak MB': '{:,.0f}',
               'reference peak MB': '{:,.0f}'})
section analysis variant reference chromosomix commands reference commands chromosomix CPU s reference CPU s chromosomix wall s reference wall s chromosomix peak MB reference peak MB × faster, CPU × faster, wall
1 Diamond insulation — cooltools insulation cooltools_insulation 4.88 11.71 3.89 5.31 302 890 2.4× 1.4×
2 Directionality index — domaincaller di domaincaller_di 2.34 2.75 1.80 2.46 215 259 1.2× 1.4×
3 Compartments — cooltools compartments cooltools_eigs_cis 6.10 14.85 4.42 7.74 301 560 2.4× 1.7×
4 P(s) yeast 800 bp cooltools expected cooltools_expected_cis 0.70 3.04 0.53 2.39 212 958 4.4× 4.5×
4 P(s) mouse 10 kb cooltools expected cooltools_expected_cis 2.86 24.69 2.37 9.11 238 11,706 8.6× 3.8×
5 Peak calling (yapc, IDR) — yapc 0.1 peaks yapc_idr 20.30 61.90 18.49 62.07 300 1,668 3.0× 3.4×
6 Loops (chromosight) — chromosight chromosight chromosight 6.23 30.91 5.11 10.74 270 771 5.0× 2.1×
7 Reproducibility (HiCRep) 3 maps, 3 pairs hicrep hicrep_scc hicrep_scc 4.05 5.52 3.24 5.24 262 261 1.4× 1.6×
8 3D model (pastis-NB) seed 0 pastis reconstruct3d pastis_nb 5.78 81.53 5.48 29.78 212 359 14.1× 5.4×
9 Coverage raw cooltools coverage cooltools_coverage 3.87 5.83 3.17 2.69 373 1,592 1.5× 0.8×
9 Coverage balanced cooltools coverage cooltools_coverage 4.11 6.29 3.41 3.04 276 1,826 1.5× 0.9×
10 Virtual 4C Sox2 cooltools fourc cooltools_virtual4c 1.29 6.95 0.98 2.93 277 555 5.4× 3.0×
11 Aggregate peak analysis raw coolpuppy apa coolpuppy_pileup 1.34 8.84 0.92 3.54 212 956 6.6× 3.8×
11 Aggregate peak analysis raw, every loop coolpuppy apa coolpuppy_pileup 2.04 9.06 1.45 3.65 212 895 4.4× 2.5×
11 Aggregate peak analysis on the diagonal, raw coolpuppy apa coolpuppy_pileup 2.23 9.43 1.56 3.73 218 1,023 4.2× 2.4×
11 Aggregate peak analysis controls coolpuppy apa coolpuppy_pileup 2.41 8.92 1.72 3.51 212 1,015 3.7× 2.0×
12 APA, chromosight's score — chromosight apa chromosight 5.06 83.65 3.73 26.67 249 1,477 16.5× 7.2×
13 Boost-HiC chr1, 1,955 bins Boost-HiC boost boosthic 3.48 20.18 3.25 19.67 652 462 5.8× 6.0×
14 Metaplot TSS deepTools metaplot computeMatrix + plotProfile 4.24 5.26 3.53 5.17 384 222 1.2× 1.5×
14 Metaplot TSS, two tracks deepTools metaplot computeMatrix + plotProfile + plotHeatmap 7.61 12.01 6.26 9.22 439 488 1.6× 1.5×
15 Interval overlap — bedtools overlap bedtools_overlap 1.08 41.98 1.00 37.66 212 212 39.0× 37.5×
16 Base composition — bioframe, Biopython composition composition 3.72 39.95 5.31 41.50 294 508 10.7× 7.8×
17 Motif search TGACTCA, ≤ 1 mismatch seqkit motif seqkit 5.23 18.78 5.22 18.59 360 4,119 3.6× 3.6×
17 Motif search WGATAR seqkit motif seqkit 4.12 6.32 4.03 6.04 324 344 1.5× 1.5×
18 Track correlation 100 bp windows deepTools trackcorr ×2 multiBigwigSummary + plotCorrelation ×2 2.32 193.22 1.89 52.74 328 307 83.2× 27.9×
18 Track correlation 1,000 bp windows deepTools trackcorr ×2 multiBigwigSummary + plotCorrelation ×2 1.56 20.64 1.08 6.43 212 212 13.2× 5.9×
18 Track correlation 10,000 bp windows deepTools trackcorr ×2 multiBigwigSummary + plotCorrelation ×2 1.46 5.09 1.00 2.26 212 212 3.5× 2.3×
19 Loop calling — cooltools loops cooltools_dots 25.42 557.49 21.92 156.27 471 11,531 21.9× 7.1×
23 Signal by group one value per entry pyBigWig, SciPy groupsignal pybigwig_groupsignal 0.65 1.47 0.46 0.88 212 212 2.3× 1.9×
24 CTCF sites in GM12878 (Fig. 2) insulation cooltools insulation cooltools_insulation_scales 1.31 5.80 0.96 5.18 217 807 4.4× 5.4×
24 CTCF sites in GM12878 (Fig. 2) peaks yapc 0.1 peaks yapc_idr 38.68 79.50 36.26 79.56 266 1,597 2.1× 2.2×
24 CTCF sites in GM12878 (Fig. 2) APA coolpuppy apa ×2 coolpuppy_pileup ×2 1.84 6.57 1.30 5.62 227 1,123 3.6× 4.3×
24 CTCF sites in GM12878 (Fig. 2) metaplot, signal deepTools metaplot computeMatrix + plotProfile + plotHeatmap 1.15 3.15 0.87 2.17 273 212 2.7× 2.5×
24 CTCF sites in GM12878 (Fig. 2) metaplot, insulation deepTools metaplot computeMatrix + plotProfile + plotHeatmap 1.19 41.58 0.97 13.11 212 946 34.9× 13.6×
25 Pairs statistics — pairtools stats pairtools 67.34 83.99 60.90 74.77 295 308 1.2× 1.2×
26 Restriction sites — bioframe, Biopython restriction bioframe_digest 1.13 1.17 1.04 0.86 212 212 1.0× 0.8×
27 Pairs QC — cooler, pairtools, pandas, cooltools pairsqc cooler + pairtools ×3 + restriction_reference + cooltools_coverage_sites 137.29 878.95 122.83 603.29 331 2,399 6.4× 4.9×
28 TAD annotation raw Juicer Tools arrowhead java 5.84 38.94 6.15 20.01 538 3,510 6.7× 3.3×
28 TAD annotation balanced Juicer Tools arrowhead java 6.04 40.23 6.25 20.42 567 4,675 6.7× 3.3×
29 Map report — cooler mapreport map_resolution 3.29 5.68 2.75 5.41 252 861 1.7× 2.0×
Code
fold = folds['× faster, CPU']
TEXT = {'fold': f'{np.exp(np.log(fold).mean()):.1f}', 'n': f'{len(fold)}',
        'range': f'{fold.min():.1f}× to {fold.max():.1f}×',
        'slower': ', '.join(f"{a} ({v})" for a, v, f in zip(folds['analysis'], folds['variant'], fold) if f < 1) or 'none',
        'sd': f"{(per['cpu_sd'] / per['cpu']).max():.0%}",
        'wall': f"{np.exp(np.log(folds['× faster, wall']).mean()):.1f}"}

Over the 40 pairs, chromosomix takes 4.4 times less CPU than the reference tools, as a geometric mean of the folds above (1.0× to 83.2×), and 3.1 times less wall-clock time. Slower than its reference: none. No command’s CPU time spreads by more than 10% of its mean across runs.

Code
def jobs():
    """One row a side of each pair: an analysis's chromosomix commands of one group, or its reference's, with
    every run's CPU seconds added up across them, as the pairs above add them."""
    side = np.where(per['tool'] == 'chromosomix', 'chromosomix', 'reference')
    out = []
    for (section, analysis, _, group), rows in per.groupby(['section', 'analysis', side, 'group'], sort=False):
        # By their places in the list: an analysis may give its chromosomix command and its reference one name.
        runs_ = times[times['order'].isin(rows['order'])].groupby('run')['cpu_s'].sum()
        groups = per.loc[(per['section'] == section) & (per['analysis'] == analysis), 'group'].nunique()
        if len(rows) == 1:
            what = rows['variant'].iloc[0]
        else:
            what = (f'{group}: ' if groups > 1 and group else '') + summed(rows)
        out.append({'section': section, 'analysis': analysis, 'tool': ', '.join(dict.fromkeys(rows['tool'])),
                    'what': what, 'runs': runs_.to_numpy(), 'cpu': runs_.mean()})
    return out


JOB_ROWS = jobs()
fig, ax = plt.subplots(figsize=(10, 0.36 * len(JOB_ROWS) + 1.2), layout='constrained')
y = np.arange(len(JOB_ROWS))[::-1]
BANDS = [row['analysis'] for row in JOB_ROWS]
for k, analysis in enumerate(dict.fromkeys(BANDS)):
    if k % 2:
        at = y[[i for i, one in enumerate(BANDS) if one == analysis]]
        ax.axhspan(at.min() - 0.5, at.max() + 0.5, color=MISSING, lw=0, zorder=0)
for yi, row in zip(y, JOB_ROWS):
    colour = APP_COLOUR if row['tool'] == 'chromosomix' else REF
    ax.scatter(row['runs'], np.full(len(row['runs']), yi), s=14, color=colour, alpha=0.55, lw=0, zorder=3)
    ax.plot([row['cpu']] * 2, [yi - 0.3, yi + 0.3], color=colour, lw=2.5, zorder=4)
labels = [f"{row['section']} · {row['analysis']} · {row['tool']}" + (f" ({row['what']})" if row['what'] else '')
          for row in JOB_ROWS]
ax.set_yticks(y, labels, fontsize=8)
ax.set_xscale('log')
EVERY = np.concatenate([row['runs'] for row in JOB_ROWS])
ax.set_xlim(EVERY.min() / 1.5, EVERY.max() * 1.5)
ax.xaxis.set_major_formatter(FuncFormatter(lambda v, _: f'{v:g}'))
ax.xaxis.set_minor_locator(NullLocator())
ax.set(xlabel='CPU time (s)', ylim=(-0.5, len(JOB_ROWS) - 0.5))
ax.grid(axis='y', visible=False)
ax.scatter([], [], s=24, color=APP_COLOUR, label='chromosomix')
ax.scatter([], [], s=24, color=REF, label='reference')
ax.legend(loc='upper right')
plt.show()
Figure 3: CPU time of every job, one row a side of a pair in the table above, its commands added up run by run (deepTools’ computeMatrix and plotProfile on one line); each run a dot and the mean a bar, on a logarithmic axis; the rows of one analysis share a band.
Code
fig, ax = plt.subplots(figsize=(9, 0.32 * len(folds) + 1.2), layout='constrained')
y = np.arange(len(folds))[::-1]
ax.scatter(folds['× faster, CPU'], y, s=36, color=APP_COLOUR, label='CPU', zorder=3)
ax.scatter(folds['× faster, wall'], y, s=36, facecolors='none', edgecolors=APP_COLOUR, label='wall clock', zorder=3)
ax.axvline(1, color=INK2, lw=1, ls=(0, (4, 3)))
ax.set_yticks(y, [f"{s} · {a}" + (f' ({v})' if v != '—' else '') for s, a, v in
                  zip(folds['section'], folds['analysis'], folds['variant'])], fontsize=8)
ax.set_xscale('log')
ax.xaxis.set_major_formatter(FuncFormatter(lambda v, _: f'{v:g}×'))
ax.set(xlabel='× faster than the reference', ylim=(-0.5, len(folds) - 0.5))
ax.grid(axis='y', visible=False)
ax.legend(loc='lower right')
plt.show()
Figure 4: How many times less CPU (filled) and wall-clock time (open) chromosomix takes than its reference, pair by pair; right of the line is faster.

Rendered in 170 min 49 s, from 1 November 2026, 22:27 UTC to 01:18 UTC, by chromosomix 0.19.0 at 997e59c.