GwendalTsang's picture
download
raw
18.2 kB
"""Kaggle metric for 161954: verifiable quantum circuit compilation benchmarks.
A submission is an archive holding one entry per benchmark, with every circuit in either the text
(.kmx) or the binary (.kmb) kickmix format:
invsqrt.kmx or invsqrt.kmb Benchmark I: phasing by beta / sqrt(x), at n_p = 10.
dqi.kmx or dqi.kmb Benchmark II: the DQI Reed-Solomon decoder, RS(4095, 4025) over GF(2^12).
dfthcsos/ Benchmark III: the DFTHC-SOS block encoding of FeMoCo76o113e, as the
ANGLE_k circuits angle_k_0 ... angle_k_<n-1>, each .kmx or .kmb,
and submission.npz, holding the factorization and the Givens tree.
Each benchmark is checked by its verifier, from the competition data, which simulates the circuits on many test inputs,
rejects the submission if any test fails, and otherwise returns its cost (lower is better). Each
leaderboard split seeds the simulations with its own secret salt, so every benchmark is verified once
per split. A split's score is the geometric mean over the benchmarks of `baseline_cost / cost`: the
hosts' reference submissions score 1.0, and halving every benchmark's cost scores 2.0.
The verifiers and kickmix come from the competition data itself, attached to the metric, so it scores
with exactly the files participants download: verifiers/ and kickmix/, built by the 161954 prep
pipeline from pinned commits of the hosts' code and Qualtran. kickmix.so is compiled for that
build's Kaggle image, so the metric must run in that same image. To run the metric locally instead,
install kickmix and the verifiers; local installs take precedence over the competition data.
"""
import json
import math
import pathlib
import random
import stat
import sys
import time
import tokenize
import zipfile
import zlib
from collections.abc import Callable, Mapping
from typing import Any
import numpy as np
import pandas as pd
# kickmix/ and verifiers/ in the competition data, at the 161954 prep pipeline's COMPETITION_DATA_MOUNT.
KICKMIX_DIR = '/kaggle/input/competitions/google-verifiable-quantum-circuit-compilation/kickmix'
VERIFIERS_DIR = '/kaggle/input/competitions/google-verifiable-quantum-circuit-compilation/verifiers'
# Where the orchestrator, and run_metric_locally, put the solution and metadata files.
EVAL_INPUTS_DIR = pathlib.Path('/kaggle/input/competition_evaluation')
# Every kickmix instruction, as named by Circuit.operation_statistics. Listing them rejects any
# instruction a future Qualtran adds until it is reviewed. INFO_TAG is a no-op in simulation;
# kickmix's CircuitBuilder emits it around every iadd and isub. DEBUG_PRINT is a no-op too, as every
# verifier's simulator ignores it, but its qubit and bit ids still count toward the circuit's.
ALLOWED_INSTRUCTIONS = {
'X', 'CX', 'CCX', 'Z', 'CZ', 'CCZ', 'Z_POW', 'NEG', 'SWAP', 'CSWAP', 'R', 'HMR', 'BIT_INVERT', 'BIT_STORE0', 'BIT_STORE1',
'PUSH_CONDITION', 'POP_CONDITION', 'INFO_TAG', 'DEBUG_PRINT',
} # fmt: skip
# Qubit and bit ids must be below this. Verifier memory grew by ~1.5 KB per bit (dqi) and ~1.1 KB
# per qubit (invsqrt) with the verifiers at c636a96, so this capped the growth at ~1 GB; at 03027f8,
# dqi's grows by only ~0.2 KB per qubit or bit, linearly rather than with their product. The
# baselines use ids below 50,000. Raise it if a harder benchmark needs more.
MAX_QUBIT_OR_BIT_ID = 2**30 // 1536
# The arrays read from a dfthcsos submission.npz may total at most this many bytes, ~30 times the
# ~2 MB the reference factorization and tree take.
MAX_NPZ_BYTES = 2**26
class ParticipantVisibleError(Exception):
pass
def read_circuit(path: pathlib.Path, max_registers: int) -> Any:
"""Return a submitted .kmx or .kmb circuit as a kickmix.Circuit; raise ValueError unless it is fit to verify.
kickmix validates a file as it parses it, within limits that keep parsing cheap, but simulating
takes memory that grows with the largest qubit and bit ids, which kickmix allows up to ~5e8,
so those are capped at MAX_QUBIT_OR_BIT_ID. kickmix also allows a million registers, at
~110 bytes each even if empty, so a circuit may have at most `max_registers`, the most its
benchmark needs. Every register must be named: the dfthcsos verifier crashes on an unnamed one.
"""
import kickmix # ty: ignore[unresolved-import] # In kickmix/, which generate_standard_submission put on sys.path.
circuit = kickmix.Circuit.from_file(path, format=path.suffix.removeprefix('.'))
unsupported = set(circuit.operation_statistics()) - ALLOWED_INSTRUCTIONS
if unsupported:
raise ValueError(f'unsupported instructions {sorted(unsupported)}')
largest_id = max(circuit.num_qubits, circuit.num_bits) - 1
if largest_id >= MAX_QUBIT_OR_BIT_ID:
raise ValueError(f'qubit or bit id {largest_id} is above the limit of {MAX_QUBIT_OR_BIT_ID - 1}')
if circuit.num_registers > max_registers:
raise ValueError(f'{circuit.num_registers} registers, above the limit of {max_registers} for this benchmark')
if not all(isinstance(name, str) for name in circuit.register_data):
raise ValueError('every register needs a name, given by its REGISTER line')
return circuit
def read_npz(path: pathlib.Path, spec: Mapping[str, tuple[str, tuple[int | str, ...]]]) -> dict[str, np.ndarray]:
"""Return the arrays `spec` names from an .npz; raise ValueError unless each is a plain number array as specified.
`spec` maps each key to the numpy dtype kinds it may have, such as 'f' for floats, and its shape,
in which a string names a dimension of any positive size that must agree wherever it recurs.
Each array's header is a Python literal of its dtype and shape, which np.load trusts: a few KB
of deflated zeros can claim a terabyte-sized array, and the dtype can be anything numpy can
describe, from structured and nested types to Python objects that need unpickling. So the
headers are read first, and the arrays loaded only if each is a plain number type of its kind,
not big-endian, with its shape, and they total at most MAX_NPZ_BYTES. Only format 1.0 headers,
which numpy.savez writes, are accepted, and numpy refuses any over 10 KB before parsing it.
"""
dimensions: dict[str, int] = {}
total = 0
with zipfile.ZipFile(path) as archive:
members = {name.removesuffix('.npy'): name for name in archive.namelist()}
missing = [key for key in spec if key not in members]
if missing:
raise ValueError(f'{path.name} has no {missing}')
for key, (kinds, expected_shape) in spec.items():
with archive.open(members[key]) as member:
if np.lib.format.read_magic(member) != (1, 0):
raise ValueError(f'{key} in {path.name} is not in .npy format 1.0, which numpy.savez writes')
shape, _, dtype = np.lib.format.read_array_header_1_0(member)
if dtype.kind not in kinds or dtype.names is not None or dtype.subdtype is not None or dtype.byteorder == '>':
raise ValueError(f'{key} in {path.name} has dtype {dtype}, not a plain number type of numpy kind {kinds!r}')
matches = len(shape) == len(expected_shape) and all(
size > 0 and size == (dimensions.setdefault(expected, size) if isinstance(expected, str) else expected)
for size, expected in zip(shape, expected_shape, strict=True)
)
if not matches:
raise ValueError(f'{key} in {path.name} has shape {shape}, not {expected_shape} with its named dimensions as in {dimensions}')
total += math.prod(shape) * dtype.itemsize
if total > MAX_NPZ_BYTES:
raise ValueError(f'the arrays in {path.name} take {total:,} bytes, above the limit of {MAX_NPZ_BYTES:,}')
with np.load(path, allow_pickle=False) as npz:
return {key: np.array(npz[key]) for key in spec}
def find_one(root: pathlib.Path, name: str, description: str, is_dir: bool = False) -> pathlib.Path:
"""The one path called `name` (a glob) under `root`, where the submission unpacked; raise ParticipantVisibleError otherwise."""
# macOS Finder's Compress adds __MACOSX/, which mirrors every directory, with an AppleDouble ._ file per file.
paths = [path for path in root.rglob(name) if path.is_dir() == is_dir and '__MACOSX' not in path.relative_to(root).parts]
if len(paths) != 1:
raise ParticipantVisibleError(f'The submission must contain exactly one {description}, found {len(paths)}.')
return paths[0]
def generate_standard_submission(submission_dir: str) -> None:
"""Verify the submission under every split's salt, and write each verification's cost as submission.csv.
`submission_dir` is where the orchestrator unpacked the submission archive. Every split's verifications run here, in random order, before the orchestrator calls score() for
either split, so neither a failure's timing nor its message shows which split it failed on.
Every file is read and checked before any verifier runs, so a malformed file fails in seconds
rather than after another benchmark's verification. The verifiers run one at a time, as the
invsqrt and dqi verifiers spread their own simulation across processes.
The orchestrator passes neither the solution nor the metadata in, so the solution is read from
where it puts it; this metric's own solution names its row id column `row_id`. Each row's
`seed_salt` is merged by its verifier with the submission's hash to seed the simulation, so a
submission always faces the same tests, but participants can't compute them. Unsalted, the seed
would let a team whose circuit fails on a few inputs re-roll its tests offline with cost-free
edits until they miss those inputs; with the salt, each re-roll costs a submission. A salt that
is missing or short is refused, as it would leave the seed unsalted or guessable.
kickmix and the verifiers are imported here rather than at the top because the orchestrator imports
this module during validation, when the competition data isn't mounted.
"""
solution_paths = list(EVAL_INPUTS_DIR.glob('solution.*'))
if len(solution_paths) != 1:
raise ValueError(f'Expected one solution.csv or solution.parquet in {EVAL_INPUTS_DIR}, found {[path.name for path in solution_paths]}')
[solution_path] = solution_paths
solution = pd.read_csv(solution_path) if solution_path.suffix == '.csv' else pd.read_parquet(solution_path)
scored = solution[solution['Usage'] != 'Ignored']
# score()'s hyperparameters, which the orchestrator passes only to score().
hyperparameters = json.loads((EVAL_INPUTS_DIR / 'metadata.json').read_text()).get('Hyperparameters') or {}
shot_divisor = int(hyperparameters.get('verification_shot_divisor', 1))
for salt in scored['seed_salt']:
if not isinstance(salt, str) or len(salt) < 32:
raise ValueError(f'Every scored solution row needs a seed_salt of at least 32 characters, got {salt!r}')
# Appended, not prepended, so local installs of kickmix and the verifiers win when running locally.
sys.path += [KICKMIX_DIR, VERIFIERS_DIR]
from dfthcsos import verify as dfthcsos_verify # ty: ignore[unresolved-import] # These live in verifiers/.
from dfthcsos.scoreinfra.reference import FACTORIZATION_KEYS, ORBITALS # ty: ignore[unresolved-import]
from dqi import verify_submission_dqi # ty: ignore[unresolved-import]
from invsqrt import verify_submission_invsqrt # ty: ignore[unresolved-import]
root = pathlib.Path(submission_dir)
def read(path: pathlib.Path, reader: Callable[[pathlib.Path, Any], Any], spec: Any) -> Any:
# tar and 7z submissions can unpack links, which would have this read the host's files, and
# FIFOs and devices, which would block or never end.
status = path.lstat()
if not stat.S_ISREG(status.st_mode) or status.st_nlink > 1 or not path.resolve().is_relative_to(root.resolve()):
raise ParticipantVisibleError(f'{path.relative_to(root)} must be a regular file, not a link or a special file.')
try:
return reader(path, spec)
# What kickmix, zipfile, zlib and numpy raise on malformed files, found by fuzzing an .npz.
except (ValueError, RuntimeError, OSError, EOFError, zipfile.BadZipFile, zlib.error, tokenize.TokenError) as err:
raise ParticipantVisibleError(f'{path.relative_to(root)} is invalid: {err}') from err
batches = max(1, verify_submission_dqi.DEFAULT_NUM_SHOTS // verify_submission_dqi.DEFAULT_BATCH_SIZE // shot_divisor)
dqi_spec = verify_submission_dqi.ProblemSpecDQI.benchmark_instance(num_shots=batches * verify_submission_dqi.DEFAULT_BATCH_SIZE)
# Each circuit's most registers: invsqrt's x, and dqi's S_i and E_j.
invsqrt = read(find_one(root, 'invsqrt.km[xb]', 'invsqrt.kmx or invsqrt.kmb'), read_circuit, 1)
dqi = read(find_one(root, 'dqi.km[xb]', 'dqi.kmx or dqi.kmb'), read_circuit, dqi_spec.n + dqi_spec.m)
dfthcsos_dir = find_one(root, 'dfthcsos', 'dfthcsos directory', is_dir=True)
angle_files = list(dfthcsos_dir.glob('angle_k_*.km[xb]'))
angle_paths = {path.stem: path for path in angle_files}
# A circuit given in both formats leaves fewer stems than files, so the two can't match.
stems = [f'angle_k_{k}' for k in range(len(angle_files))]
if not stems or sorted(angle_paths) != sorted(stems):
raise ParticipantVisibleError('dfthcsos/ must hold the circuits angle_k_0 ... angle_k_<n-1>, each once, as .kmx or .kmb.')
# An ANGLE_k circuit's `selection` and `word_j` registers each hold a qubit, of at most QUBIT_CAP.
# The verifier allows other registers too, but none are needed.
angle_circuits = [read(angle_paths[stem], read_circuit, dfthcsos_verify.QUBIT_CAP) for stem in stems]
# The factorization (see dfthcsos.scoreinfra.reference), with R factors of B vectors over the N
# orbitals and C copies, and the Givens tree's N - 1 edges.
npz_spec = {
'U': ('f', ('R', 'B', ORBITALS)),
'W': ('f', ('R', 'B', 'C')),
'H2_I': ('f', ('R', 'C')),
'H1_I': ('f', ()),
'Hsym_lower': ('f', (ORBITALS * (ORBITALS + 1) // 2,)),
'tree_edges': ('iu', (ORBITALS - 1, 2)),
}
if set(npz_spec) != {*FACTORIZATION_KEYS, 'tree_edges'}:
raise ValueError(f'The dfthcsos verifier reads the factorization arrays {FACTORIZATION_KEYS}, which npz_spec must cover exactly')
npz = read(find_one(dfthcsos_dir, 'submission.npz', 'dfthcsos/submission.npz'), read_npz, npz_spec)
tree_edges = npz.pop('tree_edges')
# Benchmark -> its verifier run, given the salt.
verifications: dict[str, Callable[[str], dict[str, Any]]] = {
'invsqrt': lambda salt: verify_submission_invsqrt.verify(invsqrt, verify_submission_invsqrt.ProblemSpecInvSqrt.for_n_p(10), salt=salt),
'dqi': lambda salt: verify_submission_dqi.verify(dqi, dqi_spec, salt=salt),
'dfthcsos': lambda salt: dfthcsos_verify.verify(
angle_circuits, npz, tree_edges, salt=salt, shots_per_address=max(1, dfthcsos_verify.SHOTS_PER_ADDRESS // shot_divisor)
),
}
runs = list(zip(scored['row_id'], scored['benchmark'], scored['seed_salt'], strict=True))
random.shuffle(runs)
# Ignored rows aren't scored, but the orchestrator still expects a submission row for each.
costs, seconds = dict.fromkeys(solution['row_id'], 0.0), dict.fromkeys(solution['row_id'], 0.0)
for row_id, benchmark, salt in runs:
start = time.monotonic()
try:
costs[row_id] = float(verifications[benchmark](salt)['score'])
# The exceptions the verifiers' command lines report as a failed submission; dfthcsos's
# InvalidCircuits is a ValueError.
except (AssertionError, ValueError, RuntimeError, KeyError) as err:
raise ParticipantVisibleError(f'The {benchmark} submission failed verification: {err}') from err
if not costs[row_id] > 0:
raise ParticipantVisibleError(f'The {benchmark} submission has non-positive cost {costs[row_id]}.')
seconds[row_id] = time.monotonic() - start
pd.DataFrame({'row_id': list(costs), 'cost': list(costs.values()), 'seconds': list(seconds.values())}).to_csv('submission.csv', index=False)
def score(
solution: pd.DataFrame, submission: pd.DataFrame, row_id_column_name: str, verification_shot_divisor: int = 1
) -> tuple[float, dict[str, Any]]:
"""Return the geometric mean over the benchmarks of `baseline_cost / cost`, where higher is better.
`verification_shot_divisor` is for testing only, and must stay 1 on Kaggle. generate_standard_submission
reads it, and divides the dqi and dfthcsos verifiers' default numbers of shots by it, which makes
their verification that much faster and weaker, and changes their costs, so that the reference
submissions no longer score 1.0. invsqrt's verifier is the fastest, and needs a multiple of 64
shots per input to fill its batches, its default, so it keeps every shot.
The solution has a row per benchmark and leaderboard split, holding the split's secret
`seed_salt` and the `baseline_cost` its benchmark's reference submission scored under that salt.
`submission` is generate_standard_submission's output, which holds each row's `cost`, as the
verification runs there. Returns each benchmark's cost, improvement and verification time as
details.
"""
merged = solution.merge(submission, on=row_id_column_name, validate='one_to_one')
details = {
benchmark: {'cost': cost, 'improvement_over_baseline': baseline_cost / cost, 'seconds': run_seconds}
for benchmark, cost, baseline_cost, run_seconds in zip(
merged['benchmark'], merged['cost'], merged['baseline_cost'], merged['seconds'], strict=True
)
}
ratios = [benchmark_details['improvement_over_baseline'] for benchmark_details in details.values()]
return math.prod(ratios) ** (1 / len(ratios)), details

Xet Storage Details

Size:
18.2 kB
·
Xet hash:
5a7b44b26cd44d79eebc1151dc57f58e36483dc06e6dfa8e553132ccc4e7f087

Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.