|
#!/usr/bin/env python |
|
"""Microbenchmark for plone.namedfile write operations. |
|
|
|
Compares OLD (pre-patch) vs NEW (with SHA-256 hash) behavior |
|
on NamedFile creation at various data sizes. |
|
|
|
Old behavior is simulated via a subclass that skips the get_hash call |
|
and _hash attribute logic — everything else is identical. |
|
|
|
Usage: |
|
cd /path/to/buildout.coredev/6.2 |
|
./bin/python src/plone.namedfile/benchmarks/write_perf.py |
|
|
|
Outputs a markdown table with timing results. |
|
""" |
|
|
|
import os |
|
import sys |
|
import timeit |
|
|
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src")) |
|
|
|
from plone.namedfile.file import FileChunk, MAXCHUNKSIZE, NamedFile |
|
from plone.namedfile.file import NamedBlobFile |
|
|
|
import subprocess |
|
import transaction |
|
|
|
# Detect which branch we're on |
|
try: |
|
_branch = subprocess.run( |
|
["git", "branch", "--show-current"], |
|
capture_output=True, text=True, |
|
cwd=os.path.join(os.path.dirname(__file__), ".."), |
|
).stdout.strip() |
|
except Exception: |
|
_branch = "unknown" |
|
|
|
# get_hash was added in the fix-image-scale-bloat branch. |
|
# On main it won't exist — we handle gracefully. |
|
from plone.namedfile import utils as _namedfile_utils |
|
HAS_HASH = hasattr(_namedfile_utils, 'get_hash') |
|
if HAS_HASH: |
|
from plone.namedfile.utils import get_hash |
|
else: |
|
def get_hash(data): |
|
return "" |
|
|
|
|
|
SIZES = [ |
|
("1 KB", 1024), |
|
("10 KB", 10 * 1024), |
|
("100 KB", 100 * 1024), |
|
("1 MB", 1024 * 1024), |
|
("5 MB", 5 * 1024 * 1024), |
|
("10 MB", 10 * 1024 * 1024), |
|
] |
|
|
|
ITERATIONS = { |
|
1024: 3000, |
|
10 * 1024: 1500, |
|
100 * 1024: 500, |
|
1024 * 1024: 100, |
|
5 * 1024 * 1024: 50, |
|
10 * 1024 * 1024: 20, |
|
} |
|
|
|
REPEAT = 7 |
|
|
|
|
|
class NamedFileOld(NamedFile): |
|
"""Simulates old NamedFile behavior — no hash computation.""" |
|
|
|
def _setData(self, data): |
|
# Old _setData: identical to original, but WITHOUT get_hash, |
|
# _hash assignment, and _modified update. |
|
if isinstance(data, str): |
|
data = data.encode("UTF-8") |
|
if isinstance(data, bytes): |
|
self._data, self._size = FileChunk(data), len(data) |
|
return |
|
if data is None: |
|
raise TypeError("Cannot set None data on a file.") |
|
if isinstance(data, tuple(FILECHUNK_CLASSES)): |
|
size = len(data) |
|
self._data, self._size = data, size |
|
return |
|
seek = data.seek |
|
read = data.read |
|
seek(0, 2) |
|
size = end = data.tell() |
|
if size <= 2 * MAXCHUNKSIZE: |
|
seek(0) |
|
if size < MAXCHUNKSIZE: |
|
self._data, self._size = read(size), size |
|
return |
|
self._data, self._size = FileChunk(read(size)), size |
|
return |
|
transaction.savepoint(optimistic=True) |
|
jar = self._p_jar |
|
if jar is None: |
|
seek(0) |
|
self._data, self._size = FileChunk(read(size)), size |
|
return |
|
nxt = None |
|
while end > 0: |
|
pos = end - MAXCHUNKSIZE |
|
if pos < MAXCHUNKSIZE: |
|
pos = 0 |
|
seek(pos) |
|
fc = FileChunk(read(end - pos)) |
|
jar.add(fc) |
|
fc.next = nxt |
|
transaction.savepoint(optimistic=True) |
|
fc._p_changed = None |
|
nxt = fc |
|
end = pos |
|
self._data, self._size = nxt, size |
|
|
|
|
|
# Need this for the subclass to work |
|
from plone.namedfile.file import FILECHUNK_CLASSES |
|
|
|
|
|
def generate_data(size): |
|
return os.urandom(size) |
|
|
|
|
|
def bench(stmt, globs, number): |
|
times = timeit.repeat(stmt, globals=globs, repeat=REPEAT, number=number) |
|
best = min(times) / number |
|
avg = sum(times) / len(times) / number |
|
return best, avg |
|
|
|
|
|
def main(): |
|
print("# plone.namedfile Write Path — Microbenchmark") |
|
print() |
|
print(f"Branch: {_branch}") |
|
print(f"Hash feature: {'ENABLED' if HAS_HASH else 'NOT PRESENT'}") |
|
print(f"Measures per-operation latency. Each measurement: {REPEAT} repeats.") |
|
print() |
|
|
|
header = ( |
|
"| Size | Operation | Best (ms) | Avg (ms) | Ops/sec | " |
|
"Δ vs old (ms) | Δ vs old (%) |" |
|
) |
|
sep = ( |
|
"|------|-----------|-----------|----------|---------|" |
|
"--------------|--------------|" |
|
) |
|
print(header) |
|
print(sep) |
|
|
|
results = [] |
|
|
|
for label, size in SIZES: |
|
data = generate_data(size) |
|
number = ITERATIONS[size] |
|
|
|
base_globs = { |
|
"data": data, |
|
"NamedFileOld": NamedFileOld, |
|
"NamedFile": NamedFile, |
|
"get_hash": get_hash, |
|
} |
|
|
|
# 1. OLD behavior: NamedFile without hash |
|
best, avg = bench("NamedFileOld(data)", base_globs, number) |
|
old_avg = avg |
|
results.append(("Old (no hash)", label, best, avg)) |
|
print( |
|
f"| {label:>6} | Old (no hash) | {best * 1000:>9.4f} " |
|
f"| {avg * 1000:>9.4f} | {1 / avg:>7.0f} | {0:>13.4f} | {0:>13} |" |
|
) |
|
|
|
# 2. NEW behavior: NamedFile with hash |
|
best, avg = bench("NamedFile(data)", base_globs, number) |
|
new_avg = avg |
|
delta = avg - old_avg |
|
delta_pct = ((avg / old_avg) - 1) * 100 |
|
results.append(("New (with hash)", label, best, avg)) |
|
print( |
|
f"| {label:>6} | New (with hash) | {best * 1000:>9.4f} " |
|
f"| {avg * 1000:>9.4f} | {1 / avg:>7.0f} | {delta * 1000:>13.4f} | {delta_pct:>12.2f}% |" |
|
) |
|
|
|
# 3. get_hash in isolation |
|
best, avg = bench("get_hash(data)", base_globs, number) |
|
results.append(("get_hash", label, best, avg)) |
|
print( |
|
f"| {label:>6} | get_hash (pure) | {best * 1000:>9.4f} " |
|
f"| {avg * 1000:>9.4f} | {1 / avg:>7.0f} | {0:>13.4f} | {0:>13} |" |
|
) |
|
|
|
print() |
|
|
|
# Summary |
|
print() |
|
print("## Summary of Old vs New Comparison") |
|
print() |
|
print( |
|
"`Old (no hash)` is the pre-patch `NamedFile._setData` — pure data storage." |
|
) |
|
print( |
|
"`New (with hash)` includes `get_hash()` + `_hash` attribute logic." |
|
) |
|
print( |
|
"`get_hash (pure)` is the SHA-256 computation in isolation." |
|
) |
|
print() |
|
print("### Hash overhead by file size") |
|
print() |
|
for label, _ in SIZES: |
|
r_old = [r for r in results if r[0] == "Old (no hash)" and r[1] == label][0] |
|
r_new = [r for r in results if r[0] == "New (with hash)" and r[1] == label][0] |
|
r_hash = [r for r in results if r[0] == "get_hash" and r[1] == label][0] |
|
delta_ms = (r_new[3] - r_old[3]) * 1000 |
|
hash_ms = r_hash[3] * 1000 |
|
pct = ((r_new[3] / r_old[3]) - 1) * 100 |
|
print(f" - **{label}**: +{delta_ms:.4f} ms ({pct:+.2f}%)") |
|
print(f" of which hash: {hash_ms:.4f} ms") |
|
print() |
|
print("### Redundant PATCH benefit (integration benchmark)") |
|
print() |
|
print( |
|
"The same scenario measured end-to-end via the REST API" |
|
) |
|
print(" (from `test_perf_patch`):") |
|
print() |
|
print(" | Scenario | Avg | ObjectModifiedEvent |") |
|
print(" |----------|-----|---------------------|") |
|
print( |
|
" | Old redundant PATCH | ~8.6 ms (same as non-redundant) | 1 (always fires) |" |
|
) |
|
print( |
|
" | New redundant PATCH | 3.7 ms | 0 (skipped by hash match) |" |
|
) |
|
print( |
|
" | Non-redundant PATCH (both old and new) | ~8.6 ms | 1 |" |
|
) |
|
print() |
|
print(" Old redundant PATCH was equivalent to 'non-redundant' today — ") |
|
print(" it always wrote the blob, committed to ZODB, and fired events.") |
|
print(" New redundant PATCH is **2.33× faster** because it skips all of that.") |
|
print() |
|
print("### File sizes tested") |
|
for label, size in SIZES: |
|
print(f"- {label}: {size} bytes") |
|
|
|
|
|
if __name__ == "__main__": |
|
main() |