Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 42 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 8 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,16 +13,22 @@ clap = { version = "4", features = ["derive"] }
ctrlc = { version = "3", features = ["termination"] }
ignore = "0.4"
libc = "0.2"
memchr = "2"
regex = "1"
foldhash = "0.2"
serde = { version = "1", features = ["derive"] }
serde_json = { version = "1", features = ["arbitrary_precision"] }
sha2 = "0.10"
sha2 = { version = "0.10", features = ["asm"] }
tempfile = "3"

[dev-dependencies]
assert_cmd = "2"

[profile.release]
lto = "thin"
# One codegen unit and fat LTO: a third smaller and a few percent faster on
# every call, for a slower release build nobody waits on interactively.
lto = "fat"
codegen-units = 1
panic = "abort"
strip = true

22 changes: 13 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -196,22 +196,26 @@ and above it a view is only substituted when it saves at least 512 bytes and

### Faster

Median over thirty runs, cold application cache, macOS arm64, measured by
`bench/performance.py` and `bench/content.py` on the shipped binary.
<!-- speed-table:start -->
Median over 30 runs, cold application cache, macOS arm64, Scopelet
0.5.1, measured by `bench/publish.py` on the binary that shipped.

| Operation | Median |
| --- | ---: |
| Compress a 136 KB log | **5 ms** |
| Compress a 3 MB log | **26 ms** |
| Repository query over 400 files | **58 ms** |
| Compress a 32 MiB stream | 175 ms |
| Compress a 3 MB log | **15 ms** |
| Repository query over 400 files | **57 ms** |
| Compress a 32 MiB stream | **62 ms** |
<!-- speed-table:end -->

These figures are replaced on every release: `bench/publish.py` measures the
new binary, writes the two reports under `bench/results/` and rewrites this
table. No older version's numbers are kept anywhere in the repository.

Every stored item is a synced temporary renamed into place, and every read is
checked against its content hash, so an interrupted write yields a missing or
rejected item, never a wrong one. Sending the same output twice now reuses the
stored original by address instead of reading and re-hashing it: the 32 MiB
stream takes 172 ms on a warm cache where it used to take 287 ms and 33 MB
more memory.
rejected item, never a wrong one. Sending the same output twice reuses the
stored original by address instead of reading and re-hashing it.

> [!IMPORTANT]
> **Bytes are not tokens, and none of the numbers above is a bill.** Earlier
Expand Down
20 changes: 14 additions & 6 deletions bench/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ python3 bench/content.py --scopelet target/release/scopelet --out bench/runs/con
```

`tests/content_gate.rs` mirrors the same fixtures byte for byte and pins them to
the SHA-256 values recorded in `bench/results/content-*.json`, so a compressor
the SHA-256 values recorded in `bench/results/content.json`, so a compressor
change that silently drops a diagnostic fails `cargo test`. The README
reduction table is checked against the binary by `scripts/check_readme.py`.

Expand Down Expand Up @@ -57,13 +57,21 @@ python3 bench/performance.py --binary released=/path/to/scopelet-0.5.0 \
--binary candidate=target/release/scopelet --stress --out bench/runs/candidate
```

## Publishing a run
## Publishing a release's figures

Copy `report.json` from the run directory into `bench/results/` with the
version and date in its name, then update the README figures and the
`content_gate.rs` path. Timings are descriptive: other activity on the machine
was not controlled, and only the medians are quoted.
`publish.py` runs both probes on one binary, replaces everything under
`bench/results/` with the two fresh reports (`content.json`,
`performance.json`) and rewrites the README speed table between its
`speed-table` markers. Only the shipped version's numbers are kept: an older
version's figures are deleted, not archived.

```sh
cargo build --release --locked
python3 bench/publish.py --scopelet target/release/scopelet
python3 -m unittest discover -s bench -p 'test_*.py'
```

Timings are descriptive: other activity on the machine was not controlled, and
only the medians are quoted. `tests/content_gate.rs` reads
`bench/results/content.json`, so a publish that changes a fixture fails the
build until the Rust mirror is updated too.
117 changes: 117 additions & 0 deletions bench/publish.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
#!/usr/bin/env python3
"""Measure one binary and publish its figures: the only numbers the repository keeps.

Runs the content probe and the performance probe on the given binary, replaces
everything under bench/results/ with the two fresh reports, and rewrites the
speed table in the README between its markers. Older figures are not kept:
a benchmark describes the version that ships, nothing else.
"""
from __future__ import annotations
import argparse
from argparse import Namespace
from pathlib import Path
import platform
import re
import shutil
import subprocess
import sys
import tempfile

sys.path.insert(0, str(Path(__file__).resolve().parent))
import content # noqa: E402
import performance # noqa: E402

ROOT = Path(__file__).resolve().parents[1]
RESULTS = ROOT / 'bench' / 'results'
START, END = '<!-- speed-table:start -->', '<!-- speed-table:end -->'

# README row -> (report, case) whose cold median is quoted.
ROWS = [
('Compress a 136 KB log', ('content', 'diagnostics')),
('Compress a 3 MB log', ('performance', 'log/cold')),
('Repository query over 400 files', ('performance', 'repo/cold')),
('Compress a 32 MiB stream', ('performance', 'limit_32m/cold')),
]


def medians(content_report, performance_report):
"""Cold medians in milliseconds for every README row."""
arm = performance_report['arms'][0]
values = {}
for label, (report, case) in ROWS:
if report == 'content':
seconds = content_report['cases'][case]['states']['cold']['median_seconds']
else:
seconds = performance_report['cases'][case][arm]['summary']['median_seconds']
values[label] = seconds * 1000
return values


def machine():
"""`macOS arm64`, `Linux x86_64`: what the reader needs to compare."""
system = {'Darwin': 'macOS'}.get(platform.system(), platform.system())
arch = {'aarch64': 'arm64'}.get(platform.machine(), platform.machine())
return f'{system} {arch}'


def table(values, version, machine_name, repetitions):
lines = [START,
f'Median over {repetitions} runs, cold application cache, {machine_name}, Scopelet',
f'{version}, measured by `bench/publish.py` on the binary that shipped.',
'',
'| Operation | Median |',
'| --- | ---: |']
for label, _ in ROWS:
cell = f'{values[label]:.0f} ms'
lines.append(f'| {label} | **{cell}** |' if values[label] < 100 else f'| {label} | {cell} |')
lines.append(END)
return '\n'.join(lines)


def rewrite(readme, block):
"""Replace the marked speed table; the README must already carry the markers."""
pattern = re.compile(re.escape(START) + r'.*?' + re.escape(END), re.S)
if not pattern.search(readme):
raise ValueError(f'README has no {START} ... {END} block')
return pattern.sub(lambda _: block, readme, count=1)


def main():
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument('--scopelet', type=Path, required=True, help='the release binary to measure and publish')
parser.add_argument('--repetitions', type=int, default=30)
parser.add_argument('--warmups', type=int, default=5)
parser.add_argument('--readme', type=Path, default=ROOT / 'README.md')
options = parser.parse_args()
binary = options.scopelet.resolve()
version = performance.binary_version(binary).split()[-1]
with tempfile.TemporaryDirectory(prefix='scopelet-publish-') as tmp:
runs = Path(tmp)
content_report = content.measure(binary, runs / 'content', options.warmups, options.repetitions)
problems = content.failures(content_report)
if problems:
for problem in problems:
print('FAIL', problem, file=sys.stderr)
return 1
performance_report = performance.run(Namespace(
out=runs / 'performance', binary=[('release', binary)], warmups=options.warmups,
repetitions=options.repetitions, stress=True, cases=None))
if performance_report['output_mismatches'] or any(
cell['summary']['failures'] for case in performance_report['cases'].values() for cell in case.values()):
print('FAIL performance probe reported failures', file=sys.stderr)
return 1
shutil.rmtree(RESULTS, ignore_errors=True)
RESULTS.mkdir(parents=True)
shutil.copy2(runs / 'content' / 'report.json', RESULTS / 'content.json')
shutil.copy2(runs / 'performance' / 'report.json', RESULTS / 'performance.json')
values = medians(content_report, performance_report)
block = table(values, version, machine(), options.repetitions)
options.readme.write_text(rewrite(options.readme.read_text(), block))
for label, value in values.items():
print(f' {label}: {value:.1f} ms')
print(f'Published Scopelet {version} figures to {RESULTS.relative_to(ROOT)} and {options.readme.name}')
return 0


if __name__ == '__main__':
sys.exit(main())
Loading
Loading