305 lines
11 KiB
Python
Executable File
305 lines
11 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
import argparse
|
|
import ctypes
|
|
import gc
|
|
import os
|
|
import shutil
|
|
import statistics
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
from pathlib import Path
|
|
|
|
HERE = Path(__file__).resolve().parent
|
|
BUILD = HERE / "build"
|
|
LIBC = ctypes.CDLL(None)
|
|
LIBC.free.argtypes = [ctypes.c_void_p]
|
|
TIME_COMMAND = os.environ.get("TIME_COMMAND") or shutil.which("time") or "/usr/bin/time"
|
|
TIME_FORMAT = "%U\t%S"
|
|
|
|
|
|
class SetAPI:
|
|
def __init__(self, path: Path):
|
|
self.lib = ctypes.CDLL(str(path))
|
|
self.lib.set_new.restype = ctypes.c_void_p
|
|
self.lib.set_add.argtypes = [ctypes.c_void_p, ctypes.c_char_p]
|
|
self.lib.set_fini.argtypes = [ctypes.c_void_p, ctypes.c_int]
|
|
self.lib.set_fini.restype = ctypes.c_void_p
|
|
self.lib.set_free.argtypes = [ctypes.c_void_p]
|
|
self.lib.set_free.restype = ctypes.c_void_p
|
|
self.lib.rpmsetcmp.argtypes = [ctypes.c_char_p, ctypes.c_char_p]
|
|
self.lib.rpmsetcmp.restype = ctypes.c_int
|
|
|
|
def new_with_symbols(self, symbols):
|
|
value = self.lib.set_new()
|
|
if not value:
|
|
raise RuntimeError("set_new returned NULL")
|
|
for symbol in symbols:
|
|
self.lib.set_add(value, symbol)
|
|
return value
|
|
|
|
def release(self, value, result):
|
|
if result:
|
|
LIBC.free(result)
|
|
self.lib.set_free(value)
|
|
|
|
def encode(self, symbols, bpp):
|
|
value = self.new_with_symbols(symbols)
|
|
result = self.lib.set_fini(value, bpp)
|
|
if not result:
|
|
raise RuntimeError("set_fini returned NULL")
|
|
encoded = ctypes.string_at(result)
|
|
self.release(value, result)
|
|
return encoded
|
|
|
|
|
|
def make_symbols(args):
|
|
symbols = tuple(
|
|
f"symbol_{i:08d}_version_ALT_{i % 97}".encode() for i in range(args.symbols)
|
|
)
|
|
required = (
|
|
symbols[::2]
|
|
if args.required is None
|
|
else tuple(symbols[i * args.symbols // args.required] for i in range(args.required))
|
|
)
|
|
return symbols, required
|
|
|
|
|
|
def api_for(name):
|
|
if name == "set9":
|
|
return SetAPI(BUILD / "libset9.so")
|
|
if name == "direct":
|
|
return SetAPI(BUILD / "libdirect-hash.so")
|
|
raise ValueError(f"unknown implementation: {name}")
|
|
|
|
|
|
def child_main(args):
|
|
symbols, required = make_symbols(args)
|
|
api = api_for(args.time_child_impl)
|
|
checksum = 0
|
|
|
|
if args.time_child_operation == "fini":
|
|
# Measured by /usr/bin/time around this child process. The repeated work
|
|
# is set construction plus set_fini; keeping construction in the same
|
|
# child avoids Python-side subsection timers while still reporting CPU
|
|
# user/system time from the external time utility.
|
|
for _ in range(args.fini_calls):
|
|
value = api.new_with_symbols(symbols)
|
|
result = api.lib.set_fini(value, args.bpp)
|
|
if not result:
|
|
raise RuntimeError("set_fini returned NULL")
|
|
checksum ^= len(ctypes.string_at(result))
|
|
api.release(value, result)
|
|
elif args.time_child_operation == "add":
|
|
for _ in range(args.fini_calls):
|
|
value = api.new_with_symbols(symbols)
|
|
checksum ^= int(value)
|
|
api.lib.set_free(value)
|
|
elif args.time_child_operation == "build":
|
|
for _ in range(args.fini_calls):
|
|
value = api.new_with_symbols(symbols)
|
|
result = api.lib.set_fini(value, args.bpp)
|
|
if not result:
|
|
raise RuntimeError("set_fini returned NULL")
|
|
checksum ^= len(ctypes.string_at(result))
|
|
api.release(value, result)
|
|
elif args.time_child_operation == "cmp-cold":
|
|
provider = api.encode(symbols, args.bpp)
|
|
requirement = api.encode(required, args.bpp)
|
|
for _ in range(args.cold_calls):
|
|
pid = os.fork()
|
|
if pid == 0:
|
|
result = api.lib.rpmsetcmp(provider, requirement)
|
|
os._exit(0 if result == 1 else 1)
|
|
_, status = os.waitpid(pid, 0)
|
|
if status != 0:
|
|
raise RuntimeError(f"cold rpmsetcmp child failed: status={status}")
|
|
checksum += 1
|
|
elif args.time_child_operation == "cmp-warm":
|
|
provider = api.encode(symbols, args.bpp)
|
|
requirement = api.encode(required, args.bpp)
|
|
expected = api.lib.rpmsetcmp(provider, requirement)
|
|
if expected != 1 or api.lib.rpmsetcmp(provider, provider) != 0:
|
|
raise RuntimeError(f"unexpected rpmsetcmp result: {expected}")
|
|
for _ in range(100):
|
|
api.lib.rpmsetcmp(provider, requirement)
|
|
for _ in range(args.cmp_calls):
|
|
checksum += api.lib.rpmsetcmp(provider, requirement)
|
|
if checksum != args.cmp_calls:
|
|
raise RuntimeError("rpmsetcmp result changed during benchmark")
|
|
else:
|
|
raise RuntimeError(f"unknown timed operation: {args.time_child_operation}")
|
|
|
|
# Keep a small observable side effect so timed loops are not optimized away
|
|
# inside the C library or by future wrappers.
|
|
print(checksum, file=sys.stderr)
|
|
|
|
|
|
def measure_with_time(args, implementation, operation, calls):
|
|
samples = []
|
|
for _ in range(args.rounds):
|
|
with tempfile.NamedTemporaryFile(prefix="arsv-time-", delete=False) as handle:
|
|
time_path = Path(handle.name)
|
|
command = [
|
|
TIME_COMMAND,
|
|
"-f",
|
|
TIME_FORMAT,
|
|
"-o",
|
|
str(time_path),
|
|
"--",
|
|
sys.executable,
|
|
str(Path(__file__).resolve()),
|
|
"--skip-build",
|
|
"--time-child",
|
|
"--time-child-impl",
|
|
implementation,
|
|
"--time-child-operation",
|
|
operation,
|
|
"--symbols",
|
|
str(args.symbols),
|
|
"--bpp",
|
|
str(args.bpp),
|
|
"--rounds",
|
|
"1",
|
|
"--fini-calls",
|
|
str(args.fini_calls),
|
|
"--cmp-calls",
|
|
str(args.cmp_calls),
|
|
"--cold-calls",
|
|
str(args.cold_calls),
|
|
]
|
|
if args.required is not None:
|
|
command.extend(["--required", str(args.required)])
|
|
try:
|
|
subprocess.run(command, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
user_text, system_text = time_path.read_text().split()
|
|
finally:
|
|
time_path.unlink(missing_ok=True)
|
|
samples.append((float(user_text) / calls, float(system_text) / calls))
|
|
users = [sample[0] for sample in samples]
|
|
systems = [sample[1] for sample in samples]
|
|
return statistics.median(users), statistics.median(systems)
|
|
|
|
|
|
def verify_complete_decoding(api, provider, requirement):
|
|
if api.lib.rpmsetcmp(provider, requirement) != 1:
|
|
raise RuntimeError("provider must contain requirement")
|
|
if api.lib.rpmsetcmp(requirement, provider) != -1:
|
|
raise RuntimeError("requirement must be contained by provider")
|
|
|
|
payload_position = 4 + (len(provider) - 5) * 3 // 4
|
|
corrupted = provider[:payload_position] + b"!" + provider[payload_position + 1 :]
|
|
if api.lib.rpmsetcmp(corrupted, requirement) != -3:
|
|
raise RuntimeError("first operand was not decoded and validated completely")
|
|
if api.lib.rpmsetcmp(requirement, corrupted) != -4:
|
|
raise RuntimeError("second operand was not decoded and validated completely")
|
|
|
|
|
|
def format_cpu_time(seconds):
|
|
return f"{seconds * 1_000_000:.2f} us"
|
|
|
|
|
|
def format_ratio(new, old):
|
|
if old == 0:
|
|
return "n/a"
|
|
return f"{new / old:.2f}x"
|
|
|
|
|
|
def build_parser():
|
|
parser = argparse.ArgumentParser(description="Compare set9 and direct-hash set APIs")
|
|
parser.add_argument("--symbols", type=int, default=1000)
|
|
parser.add_argument(
|
|
"--required",
|
|
type=int,
|
|
help="number of evenly distributed required symbols (default: every second symbol)",
|
|
)
|
|
parser.add_argument("--bpp", type=int, default=32)
|
|
parser.add_argument("--rounds", type=int, default=7)
|
|
parser.add_argument("--fini-calls", type=int, default=5)
|
|
parser.add_argument("--cmp-calls", type=int, default=2000)
|
|
parser.add_argument("--cold-calls", type=int, default=20)
|
|
parser.add_argument("--skip-build", action="store_true")
|
|
parser.add_argument("--time-child", action="store_true", help=argparse.SUPPRESS)
|
|
parser.add_argument("--time-child-impl", choices=("set9", "direct"), help=argparse.SUPPRESS)
|
|
parser.add_argument(
|
|
"--time-child-operation",
|
|
choices=("fini", "add", "build", "cmp-cold", "cmp-warm"),
|
|
help=argparse.SUPPRESS,
|
|
)
|
|
return parser
|
|
|
|
|
|
def main():
|
|
parser = build_parser()
|
|
args = parser.parse_args()
|
|
if args.symbols < 2 or not 10 <= args.bpp <= 32:
|
|
parser.error("symbols must be >= 2 and bpp must be in 10..32")
|
|
if args.required is not None and not 1 <= args.required < args.symbols:
|
|
parser.error("required must be in 1..symbols-1")
|
|
if min(args.rounds, args.fini_calls, args.cmp_calls, args.cold_calls) < 1:
|
|
parser.error("rounds and call counts must be positive")
|
|
if args.time_child and not (args.time_child_impl and args.time_child_operation):
|
|
parser.error("--time-child requires --time-child-impl and --time-child-operation")
|
|
|
|
if not args.skip_build:
|
|
subprocess.run([str(HERE / "build.sh")], check=True)
|
|
|
|
if args.time_child:
|
|
child_main(args)
|
|
return
|
|
|
|
if not Path(TIME_COMMAND).is_file():
|
|
raise RuntimeError(f"time executable not found: {TIME_COMMAND}")
|
|
|
|
symbols, required = make_symbols(args)
|
|
apis = {
|
|
"set9": api_for("set9"),
|
|
"direct": api_for("direct"),
|
|
}
|
|
|
|
gc.disable()
|
|
try:
|
|
lengths = {}
|
|
encoded = {}
|
|
for name, api in apis.items():
|
|
provider = api.encode(symbols, args.bpp)
|
|
requirement = api.encode(required, args.bpp)
|
|
encoded[name] = (provider, requirement)
|
|
wire_format = "D1/base64" if provider.startswith(b"D1") else "golomb/base62"
|
|
lengths[name] = (len(provider), wire_format)
|
|
|
|
for name, api in apis.items():
|
|
verify_complete_decoding(api, *encoded[name])
|
|
|
|
operations = (
|
|
("set_fini child", "fini", args.fini_calls),
|
|
("new+add child", "add", args.fini_calls),
|
|
("new+add+fini child", "build", args.fini_calls),
|
|
("rpmsetcmp cold", "cmp-cold", args.cold_calls),
|
|
("rpmsetcmp warm", "cmp-warm", args.cmp_calls),
|
|
)
|
|
timings = {name: [] for name in apis}
|
|
for label, operation, calls in operations:
|
|
for name in apis:
|
|
timings[name].append(measure_with_time(args, name, operation, calls))
|
|
finally:
|
|
gc.enable()
|
|
|
|
print(f"symbols={args.symbols} required={len(required)} bpp={args.bpp}")
|
|
print("implementation set_chars format")
|
|
for name in apis:
|
|
print(f"{name:<14} {lengths[name][0]:>9} {lengths[name][1]}")
|
|
print("\noperation set9_user set9_sys direct_user direct_sys user_ratio sys_ratio")
|
|
for index, (label, _operation, _calls) in enumerate(operations):
|
|
old_user, old_sys = timings["set9"][index]
|
|
new_user, new_sys = timings["direct"][index]
|
|
print(
|
|
f"{label:<22} {format_cpu_time(old_user):>10} {format_cpu_time(old_sys):>9} "
|
|
f"{format_cpu_time(new_user):>11} {format_cpu_time(new_sys):>10} "
|
|
f"{format_ratio(new_user, old_user):>10} {format_ratio(new_sys, old_sys):>9}"
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|