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:clinpm ci --prefix notebooks/viewers &&npx--prefix notebooks/viewers playwright install chromium firefox webkitpip install jupyter pandas matplotlib pyfaidxquarto 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 hashlibimport jsonimport mathimport osimport platformimport reimport shleximport shutilimport subprocessimport sysimport tempfileimport timeimport urllib.requestfrom pathlib import Pathfrom statistics import geometric_meanimport matplotlib.pyplot as pltfrom matplotlib.patches import Patchimport pandas as pdimport pyfaidxREDO =FalseHERE = Path.cwd().resolve()REPO = HERE.parent if HERE.name =='notebooks'else HERECLI = 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()]ifnot BROWSERS orset(BROWSERS) -set(NAMES) orlen(set(BROWSERS)) <len(BROWSERS):raiseValueError(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() elseNone)}for built, how in ((CLI, 'npm run build:cli'), (APP /'index.html', 'npm run build')):ifnot built.exists():raiseFileNotFoundError(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() elseNoneif _cli isNoneor _app isNoneor _app[1] !='-'.join(filter(None, ('chromosomix', *_cli.groups()))):raiseRuntimeError(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:raiseRuntimeError(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()withopen(path, 'rb') as handle:for block initer(lambda: handle.read(1<<22), b''): digest.update(block)return digest.hexdigest()def fetch(name, url, expected): path = DATA / nameif path.exists():returnif (VALIDATION_DATA / name).exists(): path.symlink_to(VALIDATION_DATA / name)returnprint('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()raiseValueError(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.
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).stdoutTITLES =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."""ifnot path.endswith('.bedpe'):raiseValueError(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)ifnot word.startswith('--'): the_map = wordcontinue key = word[2:] value = words.pop(0) if words andnot words[0].startswith('--') elseNone paths = [] if value isNoneelse [one for one in value.split(',') if one.startswith(('data/', 'out/'))]if value isNone: form.append({'key': key, 'on': True})elifnot paths: form.append({'key': key, 'value': value})else: files += [one for one in paths if one notin files]if key =='tracks': form.append({'key': key, 'ticks': [stem(one) for one in paths]})elif key notin 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 isnotNone: 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 inenumerate(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() - startif os.waitstatus_to_exitcode(status): err.seek(0)raiseRuntimeError(f'chromosomix {" ".join(args)} failed:\n{err.read().decode()[-3000:]}') peak = usage.ru_maxrss / (2**20if sys.platform =='darwin'else2**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)ifnot (folder /'result.json').exists():raiseRuntimeError(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 ornot TIMINGS.exists():return [] done = pd.read_csv(TIMINGS, sep='\t', dtype={'version': str})ifnot {'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']:withopen(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 inzip(VENUES, [None, *BROWSERS]):if venue in done:continue warm(name, args) measured = on_the_command_line(name, args) if browser isNoneelse 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.nantimes = 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'—'returnf'{seconds:.1f} s'if seconds <60elsef'{seconds /60:.1f} min'def megabytes(mb):return'—'if math.isnan(mb) elsef'{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).valuesif 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')
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) iflen(words) <3elsef"{', '.join(words[:-1])} and {words[-1]}"def where(venue):return'on the command line'if venue =='command line'elsef'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:return0, None, Nonereturnint((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'returnf'{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:returnf'{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) inenumerate(layout) if name isnotNone}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 inenumerate(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) inzip(left.get_yticklabels(), layout):if name isNone: 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:
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.
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)ifnot word.startswith('--'): path, _, group = word.partition('::') out['map'] = [Path(path).name + (f'::{group}'if group else'')]continue key, _, value = word[2:].partition('=')ifnot value: value = words.pop(0) if words andnot words[0].startswith('--') else'true' out.setdefault(key, []).extend(Path(one).name if one.startswith(('data/', 'out/')) else onefor 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 orfloat(a) ==float(b)exceptValueError:returnFalsedef 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 notin named]elifnot 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 notin DEFAULTS[task]):continueifnot 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.