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, pinnednpx--prefix notebooks/viewers playwright install chromiumpython-m venv higlass-env &&higlass-env/bin/pip install -r notebooks/requirements-higlass.txtQUARTO_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 Pathimport 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 =FalseHERE = Path.cwd().resolve()REPO = HERE.parent if HERE.name =='notebooks'else HEREWORK = 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_PYTHONVWORK = 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 hashlibimport jsonimport platformimport shleximport shutilimport subprocessimport sysimport tempfileimport threadingimport timeimport urllib.parseimport urllib.requestfrom collections import Counterfrom itertools import groupbyimport matplotlib.pyplot as pltimport numpy as npimport pandas as pdfrom matplotlib.ticker import FuncFormatter, NullLocatorfrom download import download, urlopenAPP_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 orNone, 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 inopen('/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:returnint(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])ifnot path.exists():print('fetching', url) download(url, path) digest = hashlib.md5()withopen(path, 'rb') as handle:for block initer(lambda: handle.read(1<<22), b''): digest.update(block)if digest.hexdigest() != md5:raiseValueError(f'{path} is not {url}: its MD5 is {digest.hexdigest()}, not {md5}. ''Delete it, and it is fetched again.')return pathVDATA.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 ifnot (VDATA / url.rsplit('/', 1)[-1]).exists())FREE_BYTES = shutil.disk_usage(VDATA).freeif MISSING_BYTES > FREE_BYTES -2e9:raiseOSError(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()]))
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 vpdef 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:raiseFileNotFoundError(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'ifnot index.exists():raiseFileNotFoundError(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'returnf'{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': Noneif ms isNoneelse ms /1000,'app_seconds': Noneif app isNoneelse app /1000,'drawn_resolution': got.get('resolution'), 'drawn_bins': got.get('bins'),'requests': got.get('requests'),'megabytes': Noneif got.get('bytes') isNoneelse 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 ornot VIEWER_TABLE.exists():returnFalse 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 inzip(timed['picture'], timed['run'])} == wantedand (timed['status'] !='not run').all()and (held['version'] == held['viewer'].map(VIEWER_VERSION)).all())ifnot viewers_current(): VIEWER_RESULTS.unlink(missing_ok=True) shutil.rmtree(VIEWER_SHOTS, ignore_errors=True)withopen(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:raiseRuntimeError(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'] ==1and 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)"iflen(drawn) <len(rows): text +=f' ({len(rows) -len(drawn)} of {len(rows)} runs failed)'return textdef 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) iflen(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.18for k, key inenumerate(VIEWERS)}for (r, genome), (c, location) in [(rc, lc) for rc inenumerate(GENOMES) for lc inenumerate(LOCATIONS)]: ax = axes[r][c] here = drew[(drew['genome'] == genome) & (drew['location'] == location)]for x, view inenumerate(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 ornot 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 outSAME = pd.DataFrame(against('juicebox', 'hic') + against('higlass', 'mcool'))SAME['× faster'] = SAME['other s'] / SAME['chromosomix s']show(SAME, **{'× faster': '{:.2f}×'})
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.
from PIL import ImagePICTURE_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) inenumerate(PICTURE_KEYS):for r, (tool, form) inenumerate(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
ifnot MANIFEST.exists():raiseFileNotFoundError(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 WEIGHSelse'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'):withopen(f'/proc/{pid}/task/{tid}/children') as handle: todo += [int(child) for child in handle.read().split()]exceptOSError:passreturn founddef 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:withopen(f'/proc/{pid}/smaps_rollup') as handle:returnnext(int(line.split()[1]) for line in handle if line.startswith('Pss:'))except (OSError, StopIteration):return0def weigh(pid, done, peak):"""Every EVERY seconds until `done`, the processes under `pid` weighed together, the most into `peak[0]`."""whileTrue: peak[0] =max(peak[0], sum(pss_kb(p) for p in under(pid)))if done.wait(EVERY):returndef 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 =Noneif extra isNoneelsedict(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)raiseRuntimeError(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*1024if sys.platform =='darwin'else1024)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 turneddef benchmark(): rows = []for run inrange(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 ornot TIMINGS.exists():returnFalse held = pd.read_csv(TIMINGS, sep='\t', keep_default_na=False)iflist(held.columns) != COLUMNS:returnFalse runs = held.groupby('order')['run'].nunique()return (len(runs) ==len(JOBS) andset(held['command']) == {job['name'] for job in JOBS}and (runs == RUNS).all() andset(held['chromosomix']) == {LISTED['chromosomix']})ifnot 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)})
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]).stemreturn 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 ==1elsef'{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 = refsif 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 framefolds = pairs()show(folds, **{'× faster, CPU': '{:.1f}×', '× faster, wall': '{:.1f}×', 'chromosomix peak MB': '{:,.0f}','reference peak MB': '{:,.0f}'})
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 inzip(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()iflen(rows) ==1: what = rows['variant'].iloc[0]else: what = (f'{group}: 'if groups >1and 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 outJOB_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 inenumerate(dict.fromkeys(BANDS)):if k %2: at = y[[i for i, one inenumerate(BANDS) if one == analysis]] ax.axhspan(at.min() -0.5, at.max() +0.5, color=MISSING, lw=0, zorder=0)for yi, row inzip(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 inzip(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.