chromosomix on a laptop

Time and memory of genome-wide analyses, run on the command line and in the app in three browsers, on the machine that renders this page

Rendered 1 November 2026, 16:14 UTC · chromosomix 0.18.0 at a51d3151
By hand, on the machine below, as this page is published
On Apple M1 Pro, 10 cores, 16 GB, Darwin 23.6.0 (arm64); Python 3.12.13, Quarto 1.3.433

The same analyses against the packages they reproduce: the timings. Every run measured, on the command line and in each browser, with the machine, is a table to download under the results.

How long does an analysis take in chromosomix, and how much memory does it need, when it is run over a whole genome on an ordinary laptop? This notebook answers that for 23 analyses on public data: Hi-C maps from 69 million to 1.08 billion contacts, a yeast Micro-C map down to 50 bp, and ATAC-seq tracks and sequence over the whole mouse genome.

Each analysis is run on the command line and then in the app, in each of three browsers. On the command line, through chromosomix, the command-line form of the app’s Analysis panel: it runs the same code the browser runs, and as a process of its own its time and memory are measured by the operating system — the wall-clock time from start to finish, the CPU time, and the peak memory (the largest resident set the process reached). In the app, as a reader runs it (notebooks/scripts/laptop.mjs), in the three browser engines Playwright drives headless: Chromium, which Chrome and Edge are built on; Firefox; and WebKit, Safari’s engine. In each, the files the command names are dropped on the landing page, the task opened from the Analysis panel, every flag of the command set in its form, and run pressed. There the time is the app’s own, from the job’s dispatch to its result, as its console records it; the CPU time and the memory are the page’s, sampled from the press to the end. WebKit here is Playwright’s build of Safari’s engine rather than Safari, which cannot be run headless or driven so; on a Mac it is the nearest a script comes to it.

The block above says which machine this page was rendered on. The page published at https://chromosomix.app/laptop/ is rendered by hand, on a laptop, and committed as notebooks/laptop.html; .github/workflows/notebooks.yml renders it on a GitHub runner with every release too, but keeps that render as the run’s artifact and never publishes it. To measure your own, render it from the root of a clone, on a laptop that is plugged in and otherwise idle, with the app and the command line built from one commit and the three browsers of the Playwright the timings pin (notebooks/viewers) — on Linux, playwright install --with-deps, for the system libraries Firefox and WebKit need. A Chromium already installed can stand in for Playwright’s, named in LAPTOP_CHROMIUM or found under PLAYWRIGHT_BROWSERS_PATH; Firefox and WebKit are Playwright’s own builds. LAPTOP_BROWSERS names the browsers the app is run in, of chromium,firefox,webkit, all three unless it says, and the page is of those it names.

npm ci && npm run build && npm run build:cli
npm ci --prefix notebooks/viewers && npx --prefix notebooks/viewers playwright install chromium firefox webkit
pip install jupyter pandas matplotlib pyfaidx
quarto render notebooks/laptop.qmd

The page is one file and carries its table and its stamp inside it, so committing notebooks/laptop.html is all it takes to publish a render: the site’s build unpacks the two beside it.

The data (about 13.7 GB) are downloaded once into notebooks/work/laptop/data/; files that validation.qmd has already downloaded are linked rather than downloaded again. The analyses take about 20 minutes on the command line and about as long again in Chromium, with a browser started and the files opened for each; Firefox and WebKit each take their own, which the results give. Each result is saved to notebooks/work/laptop/timings.tsv as soon as it is measured, on the command line or in one browser, so a second render reads the table instead of running everything again, and one with a browser added runs that browser alone; set REDO below to start over.

Code
import hashlib
import json
import math
import os
import platform
import re
import shlex
import shutil
import subprocess
import sys
import tempfile
import time
import urllib.request
from pathlib import Path
from statistics import geometric_mean

import matplotlib.pyplot as plt
from matplotlib.patches import Patch
import pandas as pd
import pyfaidx

REDO = False

HERE = Path.cwd().resolve()
REPO = HERE.parent if HERE.name == 'notebooks' else HERE
CLI = REPO / 'dist-cli' / 'chromosomix.mjs'
APP = REPO / 'dist'
DRIVER = REPO / 'notebooks' / 'scripts' / 'laptop.mjs'
WORK = REPO / 'notebooks' / 'work' / 'laptop'
DATA = WORK / 'data'
TIMINGS = WORK / 'timings.tsv'
DATA.mkdir(parents=True, exist_ok=True)
#: The browsers the app is run in, as Playwright names them and as the page does: LAPTOP_BROWSERS, else all three.
NAMES = {'chromium': 'Chromium', 'firefox': 'Firefox', 'webkit': 'WebKit'}
BROWSERS = [one.strip() for one in os.environ.get('LAPTOP_BROWSERS', ','.join(NAMES)).split(',') if one.strip()]
if not BROWSERS or set(BROWSERS) - set(NAMES) or len(set(BROWSERS)) < len(BROWSERS):
    raise ValueError(f"LAPTOP_BROWSERS is {os.environ.get('LAPTOP_BROWSERS')!r}: name each browser once, "
                     f"of {', '.join(NAMES)}.")
#: A Chromium to drive: LAPTOP_CHROMIUM, else the one under PLAYWRIGHT_BROWSERS_PATH, else Playwright's own.
#: Firefox and WebKit are always Playwright's own.
_browsers = os.environ.get('PLAYWRIGHT_BROWSERS_PATH', '')
EXECUTABLES = {'chromium': os.environ.get('LAPTOP_CHROMIUM') or (
    str(Path(_browsers) / 'chromium') if _browsers and (Path(_browsers) / 'chromium').is_file() else None)}

for built, how in ((CLI, 'npm run build:cli'), (APP / 'index.html', 'npm run build')):
    if not built.exists():
        raise FileNotFoundError(f'{built} is not there: run `{how}` at the repository root.')
VERSION = subprocess.run(['node', str(CLI), '--version'], capture_output=True, text=True).stdout.split('\n')[0]

# One build of one checkout in both places, as the parity holds them: the app names its build in its
# service worker's cache, `chromosomix-<version>-<commit>`; the command line, on `--version`. Two
# builds of two commits would be two programs timed against each other.
_cli = re.fullmatch(r'chromosomix (\S+)(?: \((\w+)\))?', VERSION)
_app = re.search(r'const CACHE = "([^"]*)"', (APP / 'sw.js').read_text()) if (APP / 'sw.js').exists() else None
if _cli is None or _app is None or _app[1] != '-'.join(filter(None, ('chromosomix', *_cli.groups()))):
    raise RuntimeError(f"dist/ is {_app[1] if _app else 'not the site build'} and dist-cli/ is {VERSION}: "
                       'run `npm run build && npm run build:cli` at the repository root, so both are this checkout.')
# Each browser launched once before any is timed, for its version and to know it is there.
VERSIONS = {}
for _browser in BROWSERS:
    _launched = subprocess.run(['node', str(DRIVER), '--version', _browser, *filter(None, [EXECUTABLES.get(_browser)])],
                               capture_output=True, text=True)
    if _launched.returncode:
        raise RuntimeError(f'No {NAMES[_browser]} to drive: install it with `npx --prefix notebooks/viewers playwright '
                           f'install {_browser}`, or leave it out of LAPTOP_BROWSERS.\n{_launched.stderr[-2000:]}')
    VERSIONS[_browser] = _launched.stdout.strip()

The machine

Code
MACHINE = {
    'processor': R.machine['processor'],
    'cores': os.cpu_count(),
    'memory': f"{R.machine['memory_gb']:.0f} GB",
    'system': platform.platform(),
    'node': subprocess.run(['node', '--version'], capture_output=True, text=True).stdout.strip(),
    'chromosomix': VERSION.removeprefix('chromosomix '),
}
pd.DataFrame([*MACHINE.items(), *((NAMES[one].lower(), VERSIONS[one]) for one in BROWSERS)],
             columns=['', 'this machine']).style.hide(axis='index')
this machine
processor Apple M1 Pro
cores 10
memory 16 GB
system macOS-14.6.1-arm64-arm-64bit
node v24.14.0
chromosomix 0.18.0 (a51d3151)
chromium 141.0.7390.37
firefox 142.0.1
webkit 26.0

The data

Data Source Contacts Size
Mouse Hi-C, mm10 4DN 4DNFIOZ1TRUS 69 million 0.7 GB
Human Hi-C, IMR-90, GRCh38 (Rao et al. 2014) 4DN 4DNFIJTOIGOI 1.08 billion 8.3 GB
Yeast Micro-C (Costantino et al. 2020) GEO GSE151553 51 million 0.1 GB
Mouse ATAC-seq, two replicates 4DN 4DNFIIY4QW2D, 4DNFITBRHVZE 1.7 GB
Mouse genome sequence and RefSeq genes, mm10 UCSC, as Illumina iGenomes distributes them 2.9 GB

Every file is checked against its MD5 when it is downloaded. The mouse sequence is indexed beside itself (mm10.fa.fai) so that it is read by range.

Code
FOURDN = 'https://4dn-open-data-public.s3.amazonaws.com/fourfront-webprod/wfoutput'
IGENOMES = 'https://ngi-igenomes.s3.amazonaws.com/igenomes/Mus_musculus/UCSC/mm10'
GEO = 'https://ftp.ncbi.nlm.nih.gov/geo/series'
FILES = {
    '4DNFIOZ1TRUS.mcool': (f'{FOURDN}/727b405d-c9f5-4b13-ae58-1dbf29d19a8f/4DNFIOZ1TRUS.mcool',
                           '503fea3d171e9c5c7f8a5403af73481e'),
    '4DNFIJTOIGOI.mcool': (f'{FOURDN}/b4939d95-4121-4f86-9cc8-29a34eb834ef/4DNFIJTOIGOI.mcool',
                           '659f9dc7613650494aa5ed4138e2e17e'),
    'GSE151553_A364_merged.mcool': (f'{GEO}/GSE151nnn/GSE151553/suppl/GSE151553_A364_merged.mcool',
                                    '319a04a8d7e41d9eb88e2378ea3ea373'),
    '4DNFIIY4QW2D.bw': (f'{FOURDN}/7fb6d419-a8de-4bc8-9c17-f68f814496a2/4DNFIIY4QW2D.bw',
                        '9f8a3a413f54129a689548a93584cdf4'),
    '4DNFITBRHVZE.bw': (f'{FOURDN}/e567a80e-c7d2-41ff-b12d-67047906c076/4DNFITBRHVZE.bw',
                        '99c928ea99ee1a2a7ec7091260f1e4be'),
    'mm10.fa': (f'{IGENOMES}/Sequence/WholeGenomeFasta/genome.fa', '34d90c586dc92f5c3e8df3ed24530f4f'),
    'mm10.genes.gtf': (f'{IGENOMES}/Annotation/Genes/genes.gtf', '543ec24bb11a4ff0cf5aa4f695320b82'),
}
VALIDATION_DATA = REPO / 'notebooks' / 'work' / 'validation' / 'data'


def md5(path):
    digest = hashlib.md5()
    with open(path, 'rb') as handle:
        for block in iter(lambda: handle.read(1 << 22), b''):
            digest.update(block)
    return digest.hexdigest()


def fetch(name, url, expected):
    path = DATA / name
    if path.exists():
        return
    if (VALIDATION_DATA / name).exists():
        path.symlink_to(VALIDATION_DATA / name)
        return
    print('downloading', url)
    part = path.with_name(name + '.part')
    with urllib.request.urlopen(url) as response, open(part, 'wb') as out:
        shutil.copyfileobj(response, out, 1 << 22)
    if md5(part) != expected:
        part.unlink()
        raise ValueError(f'{url} did not download as published: its MD5 is not {expected}.')
    part.rename(path)


for name, (url, expected) in FILES.items():
    fetch(name, url, expected)
pyfaidx.Faidx(str(DATA / 'mm10.fa')).close()

pd.DataFrame([{'file': name, 'GB': round((DATA / name).stat().st_size / 1e9, 2)} for name in FILES]) \
    .style.hide(axis='index').format(precision=2)
file GB
4DNFIOZ1TRUS.mcool 0.69
4DNFIJTOIGOI.mcool 8.31
GSE151553_A364_merged.mcool 0.12
4DNFIIY4QW2D.bw 0.96
4DNFITBRHVZE.bw 0.74
mm10.fa 2.78
mm10.genes.gtf 0.09

The analyses

Each analysis is one command, run from notebooks/work/laptop/ with its output in out/<name>/. Two of them read another’s output: the pileup (APA) is over the loops chromosight called, and the overlap is of the peaks called on the ATAC-seq replicates against the RefSeq transcripts. Parameters are the app’s defaults, except where the validation notebook uses others for the same analysis.

Code
MOUSE, HUMAN, YEAST = 'data/4DNFIOZ1TRUS.mcool', 'data/4DNFIJTOIGOI.mcool', 'data/GSE151553_A364_merged.mcool'
ATAC1, ATAC2, MM10, GENES = 'data/4DNFIIY4QW2D.bw', 'data/4DNFITBRHVZE.bw', 'data/mm10.fa', 'data/mm10.genes.gtf'
LOOPS = 'out/chromosight/4DNFIOZ1TRUS_10000.chromosight.chromosight-patterns.bedpe'
PEAKS = 'out/peaks/4DNFIIY4QW2D.peaks.peaks-data_4DNFIIY4QW2D_bw-data_4DNFITBRHVZE_bw.bed'


def at(path, bp):
    return f'{path}::/resolutions/{bp}'


# name, data, analysis, resolution, and the command after `chromosomix`
TASKS = [
    ('coverage', 'Mouse Hi-C', 'Coverage, balanced', '10 kb',
     ['coverage', at(MOUSE, 10_000), '--balance']),
    ('expected', 'Mouse Hi-C', 'P(s)', '10 kb',
     ['expected', at(MOUSE, 10_000), '--quantity', 'expected', '--scope', 'file']),
    ('insulation', 'Mouse Hi-C', 'Insulation, 100 kb window, boundaries', '10 kb',
     ['insulation', at(MOUSE, 10_000), '--window', '10']),
    ('di', 'Mouse Hi-C', 'Directionality index, 2 Mb window, borders', '50 kb',
     ['di', at(MOUSE, 50_000), '--window', '40']),
    ('compartments', 'Mouse Hi-C', 'Compartments (E1) and saddle', '100 kb',
     ['compartments', at(MOUSE, 100_000)]),
    ('loops', 'Mouse Hi-C', 'Loop calling (cooltools dots), to 10 Mb', '10 kb',
     ['loops', at(MOUSE, 10_000)]),
    ('chromosight', 'Mouse Hi-C', 'Loop detection (chromosight), to 2 Mb', '10 kb',
     ['chromosight', at(MOUSE, 10_000), '--pattern', 'loops']),
    ('apa', 'Mouse Hi-C', "APA over chromosight's loops", '10 kb',
     ['apa', at(MOUSE, 10_000), '--pairs', LOOPS]),
    ('model', 'Mouse Hi-C', '3D model of the genome (pastis-NB)', '1 Mb',
     ['reconstruct3d', at(MOUSE, 1_000_000), '--method', 'nb', '--alpha', '3']),

    ('human-coverage', 'Human Hi-C', 'Coverage, balanced', '10 kb',
     ['coverage', at(HUMAN, 10_000), '--balance']),
    ('human-expected', 'Human Hi-C', 'P(s)', '10 kb',
     ['expected', at(HUMAN, 10_000), '--quantity', 'expected', '--scope', 'file']),
    ('human-insulation', 'Human Hi-C', 'Insulation, 100 kb window, boundaries', '10 kb',
     ['insulation', at(HUMAN, 10_000), '--window', '10']),
    ('human-compartments', 'Human Hi-C', 'Compartments (E1) and saddle', '100 kb',
     ['compartments', at(HUMAN, 100_000)]),
    ('human-loops', 'Human Hi-C', 'Loop calling (cooltools dots), to 10 Mb', '10 kb',
     ['loops', at(HUMAN, 10_000)]),

    ('yeast-expected', 'Yeast Micro-C', 'P(s)', '50 bp',
     ['expected', at(YEAST, 50), '--quantity', 'expected', '--scope', 'file']),
    ('yeast-chromosight', 'Yeast Micro-C', 'Loop detection (chromosight)', '800 bp',
     ['chromosight', at(YEAST, 800), '--pattern', 'loops']),
    ('yeast-model', 'Yeast Micro-C', '3D model of the genome (pastis-NB)', '12.8 kb',
     ['reconstruct3d', at(YEAST, 12_800), '--method', 'nb', '--alpha', '3']),

    ('peaks', 'Mouse ATAC-seq, mm10', 'Peak calling with IDR, two replicates', '1 bp',
     ['peaks', '--signal', ATAC1, '--replicate', ATAC2, '--width', '150', '--times', '3', '--minWidth', '75',
      '--truncate', '100000', '--idr', '0.05']),
    ('overlap', 'Mouse ATAC-seq, mm10', 'Overlap of the peaks with transcripts, 1,000 shuffles', '1 bp',
     ['overlap', '--sequence', MM10, '--features', PEAKS, '--features', GENES, '--a', PEAKS, '--b', GENES,
      '--shuffle', '--iterations', '1000', '--seed', '1']),
    ('metaplot', 'Mouse ATAC-seq, mm10', 'Metaplot at every RefSeq TSS, ±2 kb', '50 bp',
     ['metaplot', '--tracks', ATAC1, '--features', GENES, '--mode', 'reference', '--at', 'start', '--flank',
      '2000', '--step', '50', '--band', 'none']),
    ('trackcorr', 'Mouse ATAC-seq, mm10', 'Correlation of the two replicates (Spearman)', '1 kb',
     ['trackcorr', '--tracks', f'{ATAC1},{ATAC2}', '--binsize', '1000', '--method', 'spearman']),
    ('composition', 'Mouse ATAC-seq, mm10', 'Base composition: GC, skews', '10 kb',
     ['composition', '--sequence', MM10, '--resolution', '10000']),
    ('motif', 'Mouse ATAC-seq, mm10', 'Motif search, CTCF core, ≤ 1 mismatch', '1 bp',
     ['motif', '--sequence', MM10, '--pattern', 'CCGCGNGGNGGCAG', '--mismatches', '1']),
]

In the app, each command becomes what a reader does to run it (session below). A command on a map opens it as a workspace names it — the file at the command’s resolution, balanced, its whole genome on screen — and the file is dropped on the landing page with the other the command names, a set of loops, as a track beside it; a command on tracks or sequence alone drops its files alone, a FASTA with its index. The task is opened from the Analysis panel by the title chromosomix tasks gives it, and every flag of the command is set in its form, the parameter it names: a number typed, a choice chosen, a switch turned on, a file chosen by its name among those open and the tracks ticked. --features and --sequence are the exception: they open the feature sets and the reference a task reads, and the form chooses the one open for itself, or the two --a and --b name.

Code
#: Every task's title in the Analysis panel, its parameters and their defaults, as `chromosomix tasks`
#: lists them: a parameter listed with none is one the form answers from what is open.
_tasks = subprocess.run(['node', str(CLI), 'tasks'], capture_output=True, text=True).stdout
TITLES = dict(re.findall(r'^(\w+)\n {4}(\S.*)$', _tasks, re.M))
PARAMETERS, DEFAULTS = {}, {}
for _block in _tasks.split('\n\n'):
    _id = _block.strip().split('\n')[0]
    PARAMETERS[_id] = set(re.findall(r'^ {6}--(\w+)\b', _block, re.M))
    DEFAULTS[_id] = {key: value.strip('"') for key, value in re.findall(r'^ {6}--(\w+) \(default (.*?)\)  ', _block, re.M)}

#: The flags that open what a task reads rather than set its form.
OPENED = {'features', 'sequence'}


def stem(path):
    """A file as the app names it once it is open: its name, up to its first dot."""
    return Path(path).name.split('.')[0]


def beside_the_map(number, path):
    """A file a command reads beside its map, as a workspace names it: a set of loops, on the map."""
    if not path.endswith('.bedpe'):
        raise ValueError(f'{path}: a BEDPE of loops is the one file opened beside a map here.')
    return {'id': f'pairs{number}', 'card': 'overlay', 'kind': 'pairs', 'label': stem(path),
            'source': {'kind': 'local', 'name': Path(path).name}, 'visible': True,
            'placement': {'bottom': True, 'right': False}}


def session(name, args):
    """What the app is handed for one command: the files it names, the workspace that frames its map,
    the task's title and every setting of its form."""
    task, *words = args
    the_map, files, form = None, [], []
    while words:
        word = words.pop(0)
        if not word.startswith('--'):
            the_map = word
            continue
        key = word[2:]
        value = words.pop(0) if words and not words[0].startswith('--') else None
        paths = [] if value is None else [one for one in value.split(',') if one.startswith(('data/', 'out/'))]
        if value is None:
            form.append({'key': key, 'on': True})
        elif not paths:
            form.append({'key': key, 'value': value})
        else:
            files += [one for one in paths if one not in files]
            if key == 'tracks':
                form.append({'key': key, 'ticks': [stem(one) for one in paths]})
            elif key not in OPENED:
                form.append({'key': key, 'option': stem(value)})
    files += [f'{one}.fai' for one in files if one.endswith('.fa')]
    plan = {'name': name, 'task': TITLES[task], 'set': form, 'files': files}
    if the_map is not None:
        path, group = the_map.split('::')
        plan['files'] = [path, *files]
        plan['workspace'] = {'kind': 'chromosomix.workspace', 'version': 1, 'active': 0, 'tabs': [{
            'version': 1, 'state': {'uri': f'{Path(path).name}::{group}', 'view': 'whole', 'balance': True},
            'tracks': [beside_the_map(number, one) for number, one in enumerate(files)]}]}
    return plan

Each analysis is run on the command line and then in each browser, one after another, and the first of them to read a file would find it on disk where the others find it in the system’s memory: an 8.3 GB map read from disk took the run that read it first more than twice as long. So before each run, on the command line or in a browser, every file the command names is read through once, and every run starts from files the system holds in memory, as a reader’s do once they have opened them.

On the command line, each command runs as a process of its own, and os.wait4 returns what the system counted of it: its CPU time, user and system, and its peak resident memory (ru_maxrss, which macOS gives in bytes and Linux in kilobytes).

In the app, each command runs in each browser in turn, a browser of its own, launched for it and closed after it, so that nothing an earlier run read or drew is in what this one holds. Once the files are open and the form is set, the page is left until the map is drawn, the app has measured which of its sequences are circles, and the browser is idle — what the app starts by itself once a map is open is over before the run — and then run is pressed. A page waiting on the disk spends no CPU, and the measure of the circles reads at a few percent of a core, so neither is told apart from idle by the CPU: the app’s own word that it has drawn a map, and that it has measured its circles, is waited for. Until the console records the run’s end, the browser’s processes are sampled five times a second, the same way in the three: their CPU times and resident sets as the operating system counts them, from /proc on Linux and ps on macOS. The page’s are the processes that render pages — Chromium’s renderers, Firefox’s web content processes, WebKit’s web processes — whose threads are its workers, where the app computes and holds what it reads; its CPU time is what they spent between the press and the end, and its peak memory their largest resident set — sampled, so a peak shorter than a fifth of a second can be missed, where ru_maxrss cannot miss one. The browser’s other processes — the browser itself, which reads the dropped files for the page, and its GPU, network and storage processes — are counted apart, as all_cpu_s and all_peak_mb in the table under the results, the page’s included. Each draws as it does headless: Chromium draws WebGL in software (SwiftShader) and composites the page in software too; Firefox and WebKit draw as Playwright launches them, Firefox allowed WebGL whatever its blocklist says of the machine’s graphics. A reader’s browser has a GPU for all of it. Each run leaves what it was handed, what it measured and a picture of the page at its end in notebooks/work/laptop/app/<browser>/<name>/.

Code
def on_the_command_line(name, args):
    """One command: its wall-clock and CPU seconds, and its peak memory in MB."""
    out = WORK / 'out' / name
    shutil.rmtree(out, ignore_errors=True)
    out.mkdir(parents=True)
    with tempfile.TemporaryFile() as err:
        start = time.perf_counter()
        process = subprocess.Popen(['node', str(CLI), *args, '--out', f'out/{name}', '--quiet'], cwd=WORK,
                                   stdout=subprocess.DEVNULL, stderr=err)
        _, status, usage = os.wait4(process.pid, 0)
        wall = time.perf_counter() - start
        if os.waitstatus_to_exitcode(status):
            err.seek(0)
            raise RuntimeError(f'chromosomix {" ".join(args)} failed:\n{err.read().decode()[-3000:]}')
    peak = usage.ru_maxrss / (2**20 if sys.platform == 'darwin' else 2**10)
    return {'wall_s': wall, 'cpu_s': usage.ru_utime + usage.ru_stime, 'peak_mb': peak}


#: What a run in the app measures beside the command line's three: the page before the run, the opening
#: of its files, and every process of the browser.
APP_ONLY = ['rest_mb', 'open_s', 'all_cpu_s', 'all_peak_mb']


def in_the_app(name, args, browser):
    """One command run in the app in one browser: its time, the page's CPU seconds and peak memory in MB,
    all of the browser's, and the line its console wrote — or, where it did not finish, why."""
    folder = WORK / 'app' / browser / name
    folder.mkdir(parents=True, exist_ok=True)
    plan = {**session(name, args), 'browser': browser, 'app': str(APP), 'data': str(WORK), 'out': str(folder),
            'executablePath': EXECUTABLES.get(browser)}
    (folder / 'plan.json').write_text(json.dumps(plan, indent=1))
    (folder / 'result.json').unlink(missing_ok=True)
    driven = subprocess.run(['node', str(DRIVER), str(folder / 'plan.json'), str(folder / 'result.json')],
                            capture_output=True, text=True)
    if not (folder / 'result.json').exists():
        raise RuntimeError(f'{DRIVER.name} wrote no result for {name} in {NAMES[browser]}:\n{driven.stderr[-3000:]}')
    result = json.loads((folder / 'result.json').read_text())
    ran = result['status'] == 'ok'
    measured = {key: result[key] if ran else math.nan for key in ['wall_s', 'cpu_s', 'peak_mb', *APP_ONLY]}
    return {**measured, 'line': result.get('line', ''), 'error': '' if ran else result.get('error', 'failed')}


#: Where each analysis is run, in order — the command line, then each browser — and what each is: Node's
#: version on the command line, the browser's in the app.
VENUES = ['command line', *(NAMES[one] for one in BROWSERS)]
APPS = VENUES[1:]
PROGRAMS = {'command line': MACHINE['node'], **{NAMES[one]: VERSIONS[one] for one in BROWSERS}}


def kept():
    """What an earlier render of this same build measured, run by run: on the command line in this same
    Node, and in each browser asked for at the version it is now. A run the app did not finish is run
    again."""
    if REDO or not TIMINGS.exists():
        return []
    done = pd.read_csv(TIMINGS, sep='\t', dtype={'version': str})
    if not {'venue', 'version', 'error'} <= set(done.columns):
        return []
    done = done.fillna({'line': '', 'error': '', 'version': ''})
    same = (done['chromosomix'] == MACHINE['chromosomix']) & (done['version'] == done['venue'].map(PROGRAMS))
    return done[same & (done['error'] == '') & done['wall_s'].notna()].to_dict('records')


def save(rows):
    pd.DataFrame(rows).to_csv(TIMINGS, sep='\t', index=False)


def warm(name, args):
    """Read through every file a command names, so that no run pays alone for reading them from disk."""
    for path in session(name, args)['files']:
        with open(WORK / path, 'rb') as handle:
            while handle.read(1 << 24):
                pass


# Each command on the command line and then in each browser — the pileup's loops and the overlap's peaks
# are the command line's, so every venue reads the same files — and each run saved as soon as it is
# measured, one row each.
rows = kept()
for name, data, analysis, resolution, args in TASKS:
    task = {'name': name, 'data': data, 'analysis': analysis, 'resolution': resolution}
    done = {row['venue'] for row in rows if row['name'] == name}
    for venue, browser in zip(VENUES, [None, *BROWSERS]):
        if venue in done:
            continue
        warm(name, args)
        measured = on_the_command_line(name, args) if browser is None else in_the_app(name, args, browser)
        rows.append({**task, 'venue': venue, 'version': PROGRAMS[venue], **measured,
                     'command': 'chromosomix ' + ' '.join(args), **MACHINE})
        save(rows)

#: Every run, one row each, in the order of TASKS and of VENUES; and each analysis once, with its labels.
ORDER = [task[0] for task in TASKS]
times = pd.DataFrame(rows).fillna({'line': '', 'error': ''})
for column in APP_ONLY:
    times[column] = times[column] if column in times else math.nan
times = times.sort_values(['name', 'venue'], key=lambda column: column.map(
    {'name': ORDER, 'venue': VENUES}[column.name].index)).reset_index(drop=True)
times['added_mb'] = times['peak_mb'] - times['rest_mb']
TASK = times.drop_duplicates('name').set_index('name').loc[ORDER, ['data', 'analysis', 'resolution', 'command']]
FAILED = times[times['error'] != '']


def wide(column, venues=VENUES):
    """One measure of every analysis, a column for each venue."""
    return times.pivot(index='name', columns='venue', values=column).reindex(index=ORDER, columns=venues)

Results

Every run measured, one row each — on the command line or in a browser — with its command, the app’s line for it, the browser’s version and the machine: timings.tsv.

Code
def duration(seconds):
    if math.isnan(seconds):
        return '—'
    return f'{seconds:.1f} s' if seconds < 60 else f'{seconds / 60:.1f} min'


def megabytes(mb):
    return '—' if math.isnan(mb) else f'{mb:,.0f}'


# A time kept on one line in a cell: `3.7 min` broken after its number reads as two figures.
unbroken = lambda seconds: duration(seconds).replace(' ', '\N{NO-BREAK SPACE}')


def measure(heading, column, shown, added=False):
    """A table of one measure: each analysis, and a column for each venue under the measure's heading —
    but, for the memory, the command line's alone, and what the run added to the page in each browser."""
    table = pd.DataFrame({('', key): TASK[key].values for key in ['data', 'analysis', 'resolution']})
    for venue, values in wide(column, VENUES[:1] if added else VENUES).items():
        table[(heading, venue)] = values.map(shown).values
    if added:
        for venue, values in wide('added_mb', APPS).items():
            table[('added by the run', venue)] = values.map(shown).values
    table.columns = pd.MultiIndex.from_tuples(table.columns)
    return table.style.hide(axis='index')
Code
measure('wall-clock time', 'wall_s', unbroken)
wall-clock time
data analysis resolution command line Chromium Firefox WebKit
Mouse Hi-C Coverage, balanced 10 kb 3.7 s 3.3 s 9.6 s 2.8 s
Mouse Hi-C P(s) 10 kb 3.2 s 3.2 s 9.4 s 2.8 s
Mouse Hi-C Insulation, 100 kb window, boundaries 10 kb 5.0 s 4.4 s 14.4 s 4.6 s
Mouse Hi-C Directionality index, 2 Mb window, borders 50 kb 2.4 s 2.2 s 7.2 s 2.3 s
Mouse Hi-C Compartments (E1) and saddle 100 kb 4.4 s 3.9 s 8.0 s 3.4 s
Mouse Hi-C Loop calling (cooltools dots), to 10 Mb 10 kb 27.8 s 28.9 s 58.4 s 33.8 s
Mouse Hi-C Loop detection (chromosight), to 2 Mb 10 kb 27.4 s 28.7 s 51.7 s 28.4 s
Mouse Hi-C APA over chromosight's loops 10 kb 5.9 s 6.3 s 20.1 s 6.7 s
Mouse Hi-C 3D model of the genome (pastis-NB) 1 Mb 46.8 s 15.9 s 24.5 s 9.5 s
Human Hi-C Coverage, balanced 10 kb 18.6 s 22.7 s 1.4 min 17.4 s
Human Hi-C P(s) 10 kb 17.5 s 19.5 s 1.4 min 16.6 s
Human Hi-C Insulation, 100 kb window, boundaries 10 kb 18.6 s 20.7 s 1.6 min 19.8 s
Human Hi-C Compartments (E1) and saddle 100 kb 9.7 s 13.7 s 37.8 s 10.1 s
Human Hi-C Loop calling (cooltools dots), to 10 Mb 10 kb 54.1 s 57.0 s 2.5 min 1.1 min
Yeast Micro-C P(s) 50 bp 2.3 s 2.1 s 6.1 s 1.8 s
Yeast Micro-C Loop detection (chromosight) 800 bp 4.8 s 5.0 s 8.1 s 5.4 s
Yeast Micro-C 3D model of the genome (pastis-NB) 12.8 kb 6.1 s 2.0 s 3.4 s 1.7 s
Mouse ATAC-seq, mm10 Peak calling with IDR, two replicates 1 bp 1.8 min 2.0 min 21.2 min 2.1 min
Mouse ATAC-seq, mm10 Overlap of the peaks with transcripts, 1,000 shuffles 1 bp 7.4 s 6.3 s 8.5 s 7.8 s
Mouse ATAC-seq, mm10 Metaplot at every RefSeq TSS, ±2 kb 50 bp 5.1 s 6.8 s 16.8 s 9.1 s
Mouse ATAC-seq, mm10 Correlation of the two replicates (Spearman) 1 kb 17.5 s 22.4 s 1.3 min 18.8 s
Mouse ATAC-seq, mm10 Base composition: GC, skews 10 kb 4.2 s 11.0 s 10.6 s 7.4 s
Mouse ATAC-seq, mm10 Motif search, CTCF core, ≤ 1 mismatch 1 bp 42.4 s 52.8 s 1.5 min 51.4 s
Code
measure('CPU time', 'cpu_s', unbroken)
CPU time
data analysis resolution command line Chromium Firefox WebKit
Mouse Hi-C Coverage, balanced 10 kb 4.1 s 4.3 s 11.6 s 3.7 s
Mouse Hi-C P(s) 10 kb 3.7 s 4.3 s 11.5 s 3.7 s
Mouse Hi-C Insulation, 100 kb window, boundaries 10 kb 6.1 s 5.9 s 17.0 s 6.3 s
Mouse Hi-C Directionality index, 2 Mb window, borders 50 kb 3.0 s 3.2 s 8.9 s 3.4 s
Mouse Hi-C Compartments (E1) and saddle 100 kb 5.8 s 6.8 s 11.5 s 6.5 s
Mouse Hi-C Loop calling (cooltools dots), to 10 Mb 10 kb 30.6 s 38.6 s 1.2 min 41.3 s
Mouse Hi-C Loop detection (chromosight), to 2 Mb 10 kb 29.4 s 32.5 s 56.6 s 31.6 s
Mouse Hi-C APA over chromosight's loops 10 kb 7.2 s 7.5 s 23.7 s 8.5 s
Mouse Hi-C 3D model of the genome (pastis-NB) 1 Mb 47.5 s 1.4 min 1.6 min 37.1 s
Human Hi-C Coverage, balanced 10 kb 20.7 s 24.9 s 1.7 min 20.1 s
Human Hi-C P(s) 10 kb 19.4 s 21.8 s 1.6 min 19.3 s
Human Hi-C Insulation, 100 kb window, boundaries 10 kb 20.9 s 23.1 s 1.7 min 23.1 s
Human Hi-C Compartments (E1) and saddle 100 kb 11.5 s 16.4 s 42.7 s 13.4 s
Human Hi-C Loop calling (cooltools dots), to 10 Mb 10 kb 58.8 s 1.3 min 2.9 min 1.4 min
Yeast Micro-C P(s) 50 bp 2.5 s 2.9 s 7.5 s 2.6 s
Yeast Micro-C Loop detection (chromosight) 800 bp 5.6 s 9.1 s 11.4 s 7.4 s
Yeast Micro-C 3D model of the genome (pastis-NB) 12.8 kb 6.4 s 9.0 s 11.8 s 6.2 s
Mouse ATAC-seq, mm10 Peak calling with IDR, two replicates 1 bp 1.8 min 2.5 min 23.5 min 2.4 min
Mouse ATAC-seq, mm10 Overlap of the peaks with transcripts, 1,000 shuffles 1 bp 7.6 s 8.0 s 11.1 s 9.1 s
Mouse ATAC-seq, mm10 Metaplot at every RefSeq TSS, ±2 kb 50 bp 5.9 s 7.2 s 16.0 s 9.9 s
Mouse ATAC-seq, mm10 Correlation of the two replicates (Spearman) 1 kb 19.8 s 22.6 s 1.3 min 19.6 s
Mouse ATAC-seq, mm10 Base composition: GC, skews 10 kb 4.7 s 12.0 s 12.4 s 8.2 s
Mouse ATAC-seq, mm10 Motif search, CTCF core, ≤ 1 mismatch 1 bp 42.5 s 1.0 min 1.7 min 54.4 s
Code
measure('peak memory (MB)', 'peak_mb', megabytes, added=True)
peak memory (MB) added by the run
data analysis resolution command line Chromium Firefox WebKit
Mouse Hi-C Coverage, balanced 10 kb 482 175 221 210
Mouse Hi-C P(s) 10 kb 327 212 237 158
Mouse Hi-C Insulation, 100 kb window, boundaries 10 kb 400 296 247 357
Mouse Hi-C Directionality index, 2 Mb window, borders 50 kb 254 192 171 210
Mouse Hi-C Compartments (E1) and saddle 100 kb 431 433 274 789
Mouse Hi-C Loop calling (cooltools dots), to 10 Mb 10 kb 1,012 455 369 794
Mouse Hi-C Loop detection (chromosight), to 2 Mb 10 kb 485 387 324 1,054
Mouse Hi-C APA over chromosight's loops 10 kb 310 306 280 428
Mouse Hi-C 3D model of the genome (pastis-NB) 1 Mb 422 620 737 1,141
Human Hi-C Coverage, balanced 10 kb 563 186 304 1,588
Human Hi-C P(s) 10 kb 529 200 270 1,523
Human Hi-C Insulation, 100 kb window, boundaries 10 kb 615 192 276 1,561
Human Hi-C Compartments (E1) and saddle 100 kb 826 460 546 1,637
Human Hi-C Loop calling (cooltools dots), to 10 Mb 10 kb 1,873 1,332 887 1,607
Yeast Micro-C P(s) 50 bp 308 142 212 220
Yeast Micro-C Loop detection (chromosight) 800 bp 457 471 710 1,490
Yeast Micro-C 3D model of the genome (pastis-NB) 12.8 kb 251 278 282 515
Mouse ATAC-seq, mm10 Peak calling with IDR, two replicates 1 bp 684 533 555 1,599
Mouse ATAC-seq, mm10 Overlap of the peaks with transcripts, 1,000 shuffles 1 bp 524 56 31 25
Mouse ATAC-seq, mm10 Metaplot at every RefSeq TSS, ±2 kb 50 bp 783 16 180 367
Mouse ATAC-seq, mm10 Correlation of the two replicates (Spearman) 1 kb 855 512 352 886
Mouse ATAC-seq, mm10 Base composition: GC, skews 10 kb 611 87 328 514
Mouse ATAC-seq, mm10 Motif search, CTCF core, ≤ 1 mismatch 1 bp 249 145 185 564
Code
def listing(words):
    """`a`, `a and b`, `a, b and c`."""
    words = [str(word) for word in words]
    return ' and '.join(words) if len(words) < 3 else f"{', '.join(words[:-1])} and {words[-1]}"


def where(venue):
    return 'on the command line' if venue == 'command line' else f'in {venue}'


def extremes(venue):
    """Of one venue's runs: how many took under a minute, the slowest, and the one that held the most —
    none, where none finished."""
    ran = times[(times['venue'] == venue) & times['wall_s'].notna()]
    if ran.empty:
        return 0, None, None
    return int((ran['wall_s'] < 60).sum()), ran.loc[ran['wall_s'].idxmax()], ran.loc[ran['peak_mb'].idxmax()]


def ratios(series, of):
    """A set of ratios, analysis by analysis, in words."""
    if series.empty:
        return 'no analysis finished in both to compare'
    return f'{series.median():.1f} times {of} at the median, from {series.min():.1f} to {series.max():.1f} times'


def on_average(venue):
    """A browser's CPU time and peak memory over the command line's, as geometric means of its analyses."""
    cpu, memory = ((measured[venue] / measured['command line']).dropna() for measured in (CPU, PEAK))
    if cpu.empty:
        return f'{where(venue)}, no analysis finished in both to compare'
    return (f"{where(venue)}, {geometric_mean(cpu):.1f} times the command line's CPU time and "
            f"{geometric_mean(memory):.1f} times its peak memory")


EXTREMES = {venue: extremes(venue) for venue in VENUES}
WALL, CPU, PEAK = wide('wall_s'), wide('cpu_s'), wide('peak_mb')
#: Each browser's time over the command line's, analysis by analysis; and, for each browser after the
#: first, its time and its memory over the first's.
AGAINST_THE_LINE = {venue: (WALL[venue] / WALL['command line']).dropna() for venue in APPS}
FIRST, OTHERS = APPS[0], APPS[1:]
AGAINST_THE_FIRST = {venue: ((WALL[venue] / WALL[FIRST]).dropna(), (PEAK[venue] / PEAK[FIRST]).dropna())
                     for venue in OTHERS}
UNFINISHED = {venue: int((FAILED['venue'] == venue).sum()) for venue in APPS}

On this machine:

  • Under a minute: of the 23 analyses, 22 on the command line, 22 in Chromium, 16 in Firefox and 21 in WebKit.
  • Slowest: on the command line, Peak calling with IDR, two replicates (Mouse ATAC-seq, mm10), in 1.8 min; in Chromium, Peak calling with IDR, two replicates (Mouse ATAC-seq, mm10), in 2.0 min; in Firefox, Peak calling with IDR, two replicates (Mouse ATAC-seq, mm10), in 21.2 min; in WebKit, Peak calling with IDR, two replicates (Mouse ATAC-seq, mm10), in 2.1 min.
  • Most memory: on the command line, 1,873 MB, for Loop calling (cooltools dots), to 10 Mb (Human Hi-C); in Chromium, 2,332 MB, for Loop calling (cooltools dots), to 10 Mb (Human Hi-C); in Firefox, 1,867 MB, for Loop calling (cooltools dots), to 10 Mb (Human Hi-C); in WebKit, 2,566 MB, for Loop calling (cooltools dots), to 10 Mb (Human Hi-C).
  • The app against the command line: in Chromium, 1.1 times the command line’s time at the median, from 0.3 to 2.6 times; in Firefox, 2.8 times the command line’s time at the median, from 0.5 to 12.1 times; in WebKit, 1.0 times the command line’s time at the median, from 0.2 to 1.8 times.
  • On average: in Chromium, 1.3 times the command line’s CPU time and 2.1 times its peak memory; in Firefox, 3.0 times the command line’s CPU time and 2.1 times its peak memory; in WebKit, 1.1 times the command line’s CPU time and 2.9 times its peak memory, counting what the page held before the run (geometric means over the analyses each finished).
  • The browsers against Chromium: Firefox took 2.7 times Chromium’s time at the median, from 1.0 to 10.6 times, and held 1.0 times its peak memory at the median, from 0.6 to 1.5 times; WebKit took 1.0 times Chromium’s time at the median, from 0.6 to 1.3 times, and held 1.3 times its peak memory at the median, from 0.9 to 2.4 times.
  • Finished: every analysis, in every browser.

The command line’s CPU time is close to its wall-clock time because it computes on one thread; it can be slightly higher, as Node collects garbage and compiles on threads of its own. The app computes in its workers, and its page draws and answers the reader while they do, so its CPU time is theirs and the page’s together. Where a task shares its work — pastis-NB’s likelihood, in the two 3D models — the app shares it with a helper worker for each core the browser reports but its own, seven at most, where the command line keeps to one thread unless --threads asks for more: there the app can take less time than the command line and more CPU.

In a browser, the table gives what a run added to the page’s memory — at the median, 278 MB in Chromium, 280 MB in Firefox and 789 MB in WebKit: its peak less what it held when run was pressed, the app itself, the files it had opened and the picture of them — at the median, 925 MB in Chromium, 950 MB in Firefox and 878 MB in WebKit. The average above, the table below and the figure count the whole peak (peak_mb, rest_mb and added_mb in the table under the results). The app’s time leaves out the opening of the files, which on the command line is part of the run: reading a GTF of every RefSeq transcript, say, is done in the app when the file is dropped (open_s there too).

The mouse and human Hi-C maps differ 16-fold in depth (69 million and 1.08 billion contacts), on genomes of about the same size. The same five analyses on each:

Code
pairs = []
indexed = times.set_index(['name', 'venue'])
for name in ['coverage', 'expected', 'insulation', 'compartments', 'loops']:
    for venue in VENUES:
        mouse, human = indexed.loc[(name, venue)], indexed.loc[(f'human-{name}', venue)]
        pairs.append({'analysis': TASK.loc[name, 'analysis'], 'resolution': TASK.loc[name, 'resolution'],
                      'run': venue.replace(' ', '\N{NO-BREAK SPACE}'),
                      'time, 69 M': unbroken(mouse['wall_s']), 'time, 1.08 B': unbroken(human['wall_s']),
                      'memory, 69 M (MB)': mouse['peak_mb'], 'memory, 1.08 B (MB)': human['peak_mb']})
pd.DataFrame(pairs).style.hide(axis='index').format('{:,.0f}', subset=['memory, 69 M (MB)', 'memory, 1.08 B (MB)'],
                                                     na_rep='—')
analysis resolution run time, 69 M time, 1.08 B memory, 69 M (MB) memory, 1.08 B (MB)
Coverage, balanced 10 kb command line 3.7 s 18.6 s 482 563
Coverage, balanced 10 kb Chromium 3.3 s 22.7 s 1,108 1,194
Coverage, balanced 10 kb Firefox 9.6 s 1.4 min 1,169 1,272
Coverage, balanced 10 kb WebKit 2.8 s 17.4 s 1,082 2,555
P(s) 10 kb command line 3.2 s 17.5 s 327 529
P(s) 10 kb Chromium 3.2 s 19.5 s 1,137 1,213
P(s) 10 kb Firefox 9.4 s 1.4 min 1,191 1,265
P(s) 10 kb WebKit 2.8 s 16.6 s 1,034 2,465
Insulation, 100 kb window, boundaries 10 kb command line 5.0 s 18.6 s 400 615
Insulation, 100 kb window, boundaries 10 kb Chromium 4.4 s 20.7 s 1,221 1,202
Insulation, 100 kb window, boundaries 10 kb Firefox 14.4 s 1.6 min 1,171 1,292
Insulation, 100 kb window, boundaries 10 kb WebKit 4.6 s 19.8 s 1,227 2,507
Compartments (E1) and saddle 100 kb command line 4.4 s 9.7 s 431 826
Compartments (E1) and saddle 100 kb Chromium 3.9 s 13.7 s 1,358 1,467
Compartments (E1) and saddle 100 kb Firefox 8.0 s 37.8 s 1,224 1,528
Compartments (E1) and saddle 100 kb WebKit 3.4 s 10.1 s 1,634 2,541
Loop calling (cooltools dots), to 10 Mb 10 kb command line 27.8 s 54.1 s 1,012 1,873
Loop calling (cooltools dots), to 10 Mb 10 kb Chromium 28.9 s 57.0 s 1,375 2,332
Loop calling (cooltools dots), to 10 Mb 10 kb Firefox 58.4 s 2.5 min 1,321 1,867
Loop calling (cooltools dots), to 10 Mb 10 kb WebKit 33.8 s 1.1 min 1,672 2,566
Code
# The first slots of a categorical palette, in their fixed order, one a venue.
colours = dict(zip(VENUES, ['#2a78d6', '#eb6834', '#1baf7a', '#eda100']))
# A row for each data, naming it, and under it a row for each of its analyses, from the top down.
layout = []
for data, names in TASK.reset_index().groupby('data', sort=False)['name']:
    layout += [(data, None)] + [(f"{TASK.loc[name, 'analysis']} ({TASK.loc[name, 'resolution']})", name) for name in names]
y = {name: len(layout) - 1 - at for at, (_, name) in enumerate(layout) if name is not None}
band = 0.8 / len(VENUES)
fig, (left, right) = plt.subplots(1, 2, figsize=(10, (0.15 * len(VENUES) + 0.2) * len(layout) + 1), sharey=True,
                                  layout='constrained')
for ax, measured in ((left, WALL), (right, PEAK)):
    for at, venue in enumerate(VENUES):
        ax.barh([y[name] + 0.4 - band * (at + 0.5) for name in ORDER], measured.loc[ORDER, venue].values,
                height=band * 0.85, color=colours[venue])
    ax.spines[['top', 'right']].set_visible(False)
    ax.tick_params(axis='y', labelsize=8, length=0)
left.set(xscale='log', xlabel='wall-clock time (s)', yticks=list(range(len(layout)))[::-1],
         yticklabels=[label for label, _ in layout], ylim=(-0.6, len(layout) - 0.4))
for tick, (_, name) in zip(left.get_yticklabels(), layout):
    if name is None:
        tick.set_fontweight('bold')
right.set(xlabel='peak memory (MB)')
fig.legend(handles=[Patch(color=colour, label=venue) for venue, colour in colours.items()],
           loc='outside lower center', ncols=len(VENUES), frameon=False, fontsize=8)
plt.show()

Figure 1: Wall-clock time (left, logarithmic) and peak memory (right) of each analysis, grouped by its data: on the command line and in each browser.

Every command

Run from notebooks/work/laptop/, each followed by --out out/<name> --quiet:

chromosomix coverage data/4DNFIOZ1TRUS.mcool::/resolutions/10000 --balance
chromosomix expected data/4DNFIOZ1TRUS.mcool::/resolutions/10000 --quantity expected --scope file
chromosomix insulation data/4DNFIOZ1TRUS.mcool::/resolutions/10000 --window 10
chromosomix di data/4DNFIOZ1TRUS.mcool::/resolutions/50000 --window 40
chromosomix compartments data/4DNFIOZ1TRUS.mcool::/resolutions/100000
chromosomix loops data/4DNFIOZ1TRUS.mcool::/resolutions/10000
chromosomix chromosight data/4DNFIOZ1TRUS.mcool::/resolutions/10000 --pattern loops
chromosomix apa data/4DNFIOZ1TRUS.mcool::/resolutions/10000 --pairs out/chromosight/4DNFIOZ1TRUS_10000.chromosight.chromosight-patterns.bedpe
chromosomix reconstruct3d data/4DNFIOZ1TRUS.mcool::/resolutions/1000000 --method nb --alpha 3
chromosomix coverage data/4DNFIJTOIGOI.mcool::/resolutions/10000 --balance
chromosomix expected data/4DNFIJTOIGOI.mcool::/resolutions/10000 --quantity expected --scope file
chromosomix insulation data/4DNFIJTOIGOI.mcool::/resolutions/10000 --window 10
chromosomix compartments data/4DNFIJTOIGOI.mcool::/resolutions/100000
chromosomix loops data/4DNFIJTOIGOI.mcool::/resolutions/10000
chromosomix expected data/GSE151553_A364_merged.mcool::/resolutions/50 --quantity expected --scope file
chromosomix chromosight data/GSE151553_A364_merged.mcool::/resolutions/800 --pattern loops
chromosomix reconstruct3d data/GSE151553_A364_merged.mcool::/resolutions/12800 --method nb --alpha 3
chromosomix peaks --signal data/4DNFIIY4QW2D.bw --replicate data/4DNFITBRHVZE.bw --width 150 --times 3 --minWidth 75 --truncate 100000 --idr 0.05
chromosomix overlap --sequence data/mm10.fa --features out/peaks/4DNFIIY4QW2D.peaks.peaks-data_4DNFIIY4QW2D_bw-data_4DNFITBRHVZE_bw.bed --features data/mm10.genes.gtf --a out/peaks/4DNFIIY4QW2D.peaks.peaks-data_4DNFIIY4QW2D_bw-data_4DNFITBRHVZE_bw.bed --b data/mm10.genes.gtf --shuffle --iterations 1000 --seed 1
chromosomix metaplot --tracks data/4DNFIIY4QW2D.bw --features data/mm10.genes.gtf --mode reference --at start --flank 2000 --step 50 --band none
chromosomix trackcorr --tracks data/4DNFIIY4QW2D.bw,data/4DNFITBRHVZE.bw --binsize 1000 --method spearman
chromosomix composition --sequence data/mm10.fa --resolution 10000
chromosomix motif --sequence data/mm10.fa --pattern CCGCGNGGNGGCAG --mismatches 1

And each as the app’s console wrote it, in Chromium where it finished there, and otherwise in the first browser it did: the line it gives a reader to run the same analysis from a shell, in the folder that holds the files, which it names by their own names.

chromosomix coverage 4DNFIOZ1TRUS.mcool::/resolutions/10000 --window chr1:1-54,620,000 --total --fraction=false --cis=false --trans=false --balance
chromosomix expected 4DNFIOZ1TRUS.mcool::/resolutions/10000 --window chr1:1-54,620,000 --scope file --sequences chr1,chr2,chr3,chr4,chr5,chr6,chr7,chr8,chr9,chr10,chr11,chr12,chr13,chr14,chr15,chr16,chr17,chr18,chr19,chrX,chrY --quantity expected --factor 1.1 --minSeparation 2 --derivative
chromosomix insulation 4DNFIOZ1TRUS.mcool::/resolutions/10000 --window 10 --scales 1 --sequences chr1,chr2,chr3,chr4,chr5,chr6,chr7,chr8,chr9,chr10,chr11,chr12,chr13,chr14,chr15,chr16,chr17,chr18,chr19,chrX,chrY --ignoreDiags 2 --call --prominence 0.2 --minValid 0.5 --spacing 0 --domains
chromosomix di 4DNFIOZ1TRUS.mcool::/resolutions/50000 --window 40 --sequences chr1,chr2,chr3,chr4,chr5,chr6,chr7,chr8,chr9,chr10,chr11,chr12,chr13,chr14,chr15,chr16,chr17,chr18,chr19,chrX,chrY --ignoreDiags 0 --minValid 0.5 --call --prominence 10 --spacing 0 --domains
chromosomix compartments 4DNFIOZ1TRUS.mcool::/resolutions/100000 --sequences chr1,chr2,chr3,chr4,chr5,chr6,chr7,chr8,chr9,chr10,chr11,chr12,chr13,chr14,chr15,chr16,chr17,chr18,chr19,chrX,chrY --contacts cis --ignoreDiags 2 --expected distance --armWindow 50 --method cooltools --components 1 --minR 0.1 --saddle --quantiles 50
chromosomix loops 4DNFIOZ1TRUS.mcool::/resolutions/10000 --window chr1:1-54,620,000 --sequences chr1,chr2,chr3,chr4,chr5,chr6,chr7,chr8,chr9,chr10,chr11,chr12,chr13,chr14,chr15,chr16,chr17,chr18,chr19,chrX,chrY --outer 5 --inner 2 --maxSeparation 1000 --fdr 0.1 --lambdaBins 40 --clusteringRadius 2 --filter
chromosomix chromosight 4DNFIOZ1TRUS.mcool::/resolutions/10000 --window chr1:1-54,620,000 --pattern loops --sequences chr1,chr2,chr3,chr4,chr5,chr6,chr7,chr8,chr9,chr10,chr11,chr12,chr13,chr14,chr15,chr16,chr17,chr18,chr19,chrX,chrY --thresholds preset --pearson 0.3 --minDist 10 --maxDist 1000 --minSeparation 3 --iterations 1 --maxPercUndetected 50 --maxPercZero 10 --nMads 5 --maxQvalue 1
chromosomix apa 4DNFIOZ1TRUS.mcool::/resolutions/10000 --pairs 4DNFIOZ1TRUS_10000.chromosight.chromosight-patterns.bedpe --anchors pairs --mode fixed --at centre --window 21 --grid 51 --flankFraction 0.5 --minDiag 2 --norm oe --csPattern loops --controls 5 --minShift 50 --maxShift 500 --minShiftScale 1 --maxShiftScale 3 --seed 1 --minDist 5 --minSep 2 --maxSep 200 --byDistance 0 --rankBy score --subset all --count 500 --groups 4 --symmetric
chromosomix reconstruct3d 4DNFIOZ1TRUS.mcool::/resolutions/1000000 --method nb --alpha 3 --weights contacts --seed 0 --iterations 150
chromosomix coverage 4DNFIJTOIGOI.mcool::/resolutions/10000 --window chr1:1-61,880,000 --total --fraction=false --cis=false --trans=false --balance
chromosomix expected 4DNFIJTOIGOI.mcool::/resolutions/10000 --window chr1:1-61,880,000 --scope file --sequences chr1,chr2,chr3,chr4,chr5,chr6,chr7,chr8,chr9,chr10,chr11,chr12,chr13,chr14,chr15,chr16,chr17,chr18,chr19,chr20,chr21,chr22,chrX,chrY --quantity expected --factor 1.1 --minSeparation 2 --derivative
chromosomix insulation 4DNFIJTOIGOI.mcool::/resolutions/10000 --window 10 --scales 1 --sequences chr1,chr2,chr3,chr4,chr5,chr6,chr7,chr8,chr9,chr10,chr11,chr12,chr13,chr14,chr15,chr16,chr17,chr18,chr19,chr20,chr21,chr22,chrX,chrY --ignoreDiags 2 --call --prominence 0.2 --minValid 0.5 --spacing 0 --domains
chromosomix compartments 4DNFIJTOIGOI.mcool::/resolutions/100000 --sequences chr1,chr2,chr3,chr4,chr5,chr6,chr7,chr8,chr9,chr10,chr11,chr12,chr13,chr14,chr15,chr16,chr17,chr18,chr19,chr20,chr21,chr22,chrX,chrY --contacts cis --ignoreDiags 2 --expected distance --armWindow 50 --method cooltools --components 1 --minR 0.1 --saddle --quantiles 50
chromosomix loops 4DNFIJTOIGOI.mcool::/resolutions/10000 --window chr1:1-61,880,000 --sequences chr1,chr2,chr3,chr4,chr5,chr6,chr7,chr8,chr9,chr10,chr11,chr12,chr13,chr14,chr15,chr16,chr17,chr18,chr19,chr20,chr21,chr22,chrX,chrY --outer 5 --inner 2 --maxSeparation 1000 --fdr 0.1 --lambdaBins 40 --clusteringRadius 2 --filter
chromosomix expected GSE151553_A364_merged.mcool::/resolutions/50 --circular chrM --scope file --sequences chrI,chrII,chrIII,chrIV,chrV,chrVI,chrVII,chrVIII,chrIX,chrX,chrXI,chrXII,chrXIII,chrXIV,chrXV,chrXVI,chrM --quantity expected --factor 1.1 --minSeparation 2 --derivative
chromosomix chromosight GSE151553_A364_merged.mcool::/resolutions/800 --circular chrM --pattern loops --sequences chrI,chrII,chrIII,chrIV,chrV,chrVI,chrVII,chrVIII,chrIX,chrX,chrXI,chrXII,chrXIII,chrXIV,chrXV,chrXVI,chrM --thresholds preset --pearson 0.3 --minDist 10 --maxDist 1000 --minSeparation 3 --iterations 1 --maxPercUndetected 50 --maxPercZero 10 --nMads 5 --maxQvalue 1
chromosomix reconstruct3d GSE151553_A364_merged.mcool::/resolutions/12800 --method nb --alpha 3 --weights contacts --seed 0 --iterations 150
chromosomix peaks --signal 4DNFIIY4QW2D.bw --replicate 4DNFITBRHVZE.bw --idr 0.05 --sequences chr1,chr10,chr11,chr12,chr13,chr14,chr15,chr16,chr17,chr18,chr19,chr2,chr3,chr4,chr5,chr6,chr7,chr8,chr9,chrX,chrY --perPeak=false --width 150 --times 3 --minWidth 75 --halfWidth 0 --truncate 100000
chromosomix overlap --features 4DNFIIY4QW2D.peaks.peaks-data_4DNFIIY4QW2D_bw-data_4DNFITBRHVZE_bw.bed --features mm10.genes.gtf --a 4DNFIIY4QW2D.peaks.peaks-data_4DNFIIY4QW2D_bw-data_4DNFITBRHVZE_bw.bed --b mm10.genes.gtf --minFraction 0.000001 --shuffle --shuffleWhich a --iterations 1000 --seed 1
chromosomix metaplot --tracks 4DNFIIY4QW2D.bw --features mm10.genes.gtf --mode reference --at start --flank 2000 --step 50 --body 100 --stranded --rankBy score --subset all --count 500 --groups 4 --zscore=false --average mean --band none --iterations 200 --seed 1 --heatmap
chromosomix trackcorr --tracks 4DNFIIY4QW2D.bw --tracks 4DNFITBRHVZE.bw --over bins --binsize 1000 --method spearman
chromosomix composition --sequence mm10.fa --resolution 10000 --window 0 --gc --gcSkew --atSkew --cumulative
chromosomix motif --sequence mm10.fa --sequences chr10,chr11,chr12,chr13,chr14,chr15,chr16,chr17,chr18,chr19,chr1,chr2,chr3,chr4,chr5,chr6,chr7,chr8,chr9,chrM,chrX,chrY --pattern CCGCGNGGNGGCAG --bothStrands --mismatches 1 --limit 500000

Every browser wrote the same line, word for word, for every analysis it finished.

Code
def asked(line):
    """What a line asks for, flag by flag: each value as written, a file by its name, a switch as true or
    false — and its map, as `map`."""
    out = {}
    words = shlex.split(line)[2:]
    while words:
        word = words.pop(0)
        if not word.startswith('--'):
            path, _, group = word.partition('::')
            out['map'] = [Path(path).name + (f'::{group}' if group else '')]
            continue
        key, _, value = word[2:].partition('=')
        if not value:
            value = words.pop(0) if words and not words[0].startswith('--') else 'true'
        out.setdefault(key, []).extend(Path(one).name if one.startswith(('data/', 'out/')) else one
                                       for one in value.split(','))
    return {key: ','.join(values) for key, values in out.items()}


def alike(a, b):
    """Two values written alike, or two numbers that are one."""
    try:
        return a == b or float(a) == float(b)
    except ValueError:
        return False


def differences(task, command, line):
    """Where the app's line asks for other than the command does: a flag of the command's it does not
    write as written, and one of its own that is not the task's parameter at its default, or one the
    form answers from what is open."""
    ours, theirs = asked(command), asked(line)
    named = set(','.join(theirs.values()).split(','))
    out = []
    for key, value in ours.items():
        if key in OPENED:
            out += [f'does not name {one}' for one in value.split(',') if one not in named]
        elif not alike(value, theirs.get(key)):
            out.append(f"--{key} {theirs.get(key, '(none)')}, for {value}")
    for key, value in theirs.items():
        if key in ours or (key in PARAMETERS[task] and key not in DEFAULTS[task]):
            continue
        if not alike(value, DEFAULTS[task].get(key)):
            out.append(f'adds --{key} {value}')
    return out


#: Every line a browser wrote, held to the command it ran for: where it asks for anything else, what.
DIFFERENT = {(row['name'], row['venue']): differences(row['command'].split()[1], row['command'], row['line'])
             for _, row in LINES.iterrows()}
DIFFERENT = {run: found for run, found in DIFFERENT.items() if found}

Held against the command it ran for, the app’s line asks for the same thing in 39 of 69, over the 3 browsers: every flag of the command, with the same value, and besides them only the task’s parameters at their defaults and those its form answers from what is open — every sequence ticked, the column a feature set is ranked by. Where it asks for anything else:

  • coverage, in Chromium: adds --window chr1:1-54,620,000
  • coverage, in Firefox: adds --window chr1:1-54,620,000
  • coverage, in WebKit: adds --window chr1:1-54,620,000
  • expected, in Chromium: adds --window chr1:1-54,620,000
  • expected, in Firefox: adds --window chr1:1-54,620,000
  • expected, in WebKit: adds --window chr1:1-54,620,000
  • loops, in Chromium: adds --window chr1:1-54,620,000
  • loops, in Firefox: adds --window chr1:1-54,620,000
  • loops, in WebKit: adds --window chr1:1-54,620,000
  • chromosight, in Chromium: adds --window chr1:1-54,620,000
  • chromosight, in Firefox: adds --window chr1:1-54,620,000
  • chromosight, in WebKit: adds --window chr1:1-54,620,000
  • human-coverage, in Chromium: adds --window chr1:1-61,880,000
  • human-coverage, in Firefox: adds --window chr1:1-61,880,000
  • human-coverage, in WebKit: adds --window chr1:1-61,880,000
  • human-expected, in Chromium: adds --window chr1:1-61,880,000
  • human-expected, in Firefox: adds --window chr1:1-61,880,000
  • human-expected, in WebKit: adds --window chr1:1-61,880,000
  • human-loops, in Chromium: adds --window chr1:1-61,880,000
  • human-loops, in Firefox: adds --window chr1:1-61,880,000
  • human-loops, in WebKit: adds --window chr1:1-61,880,000
  • yeast-expected, in Chromium: adds --circular chrM
  • yeast-expected, in Firefox: adds --circular chrM
  • yeast-expected, in WebKit: adds --circular chrM
  • yeast-chromosight, in Chromium: adds --circular chrM
  • yeast-chromosight, in Firefox: adds --circular chrM
  • yeast-chromosight, in WebKit: adds --circular chrM
  • overlap, in Chromium: does not name mm10.fa
  • overlap, in Firefox: does not name mm10.fa
  • overlap, in WebKit: does not name mm10.fa

Rendered in 77 min 5 s, from 1 November 2026, 16:14 UTC to 17:31 UTC, by chromosomix 0.18.0 at a51d3151. This render, as JSON.