From 9398b16751d668bc81b27d2f8c28a37b8ea82e2d Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Mon, 29 Apr 2024 14:00:27 -0600 Subject: [PATCH] Feat hybrid noise (#60) * Allows noise model to generate hybrid errors (classical and quantum instructions) * Adding internal cinterp registers for error models * removing internally generated measurements from results * Converts conditionals into COps --------- Co-authored-by: Ciaran Ryan-Anderson --- .../phir_classical_interpreter.py | 172 +++++++----- python/pecos/engines/hybrid_engine.py | 36 ++- python/pecos/machines/generic_machine.py | 2 + python/pecos/reps/pypmir/block_types.py | 4 +- python/pecos/reps/pypmir/pypmir.py | 252 ++++++++++-------- 5 files changed, 273 insertions(+), 193 deletions(-) diff --git a/python/pecos/classical_interpreters/phir_classical_interpreter.py b/python/pecos/classical_interpreters/phir_classical_interpreter.py index 5f0c75cf..502c265e 100644 --- a/python/pecos/classical_interpreters/phir_classical_interpreter.py +++ b/python/pecos/classical_interpreters/phir_classical_interpreter.py @@ -23,7 +23,7 @@ from pecos.reps.pypmir import types as pt if TYPE_CHECKING: - from collections.abc import Generator, Sequence + from collections.abc import Generator, Iterable, Sequence from pecos import QuantumCircuit from pecos.foreign_objects.foreign_object_abc import ForeignObject @@ -35,12 +35,18 @@ def version2tuple(v): data_type_map = { + "i8": np.int8, + "i16": np.int16, "i32": np.int32, "i64": np.int64, + "u8": np.uint8, + "u16": np.uint16, "u32": np.uint32, "u64": np.uint64, } +data_type_map_rev = {v: k for k, v in data_type_map.items()} + class PHIRClassicalInterpreter(ClassicalInterpreter): """An interpreter that takes in a PHIR program and runs the classical side of the program.""" @@ -52,6 +58,8 @@ def __init__(self) -> None: self.foreign_obj = None self.cenv = None self.cid2dtype = None + self.csym2id = None + self.cvar_meta = None self.phir_validate = True @@ -96,6 +104,9 @@ def init(self, program: str | (dict | QuantumCircuit), foreign_obj: ForeignObjec self.check_ffc(self.program.foreign_func_calls, self.foreign_obj) + self.csym2id = dict(self.program.csym2id) + self.cvar_meta = list(self.program.cvar_meta) + self.initialize_cenv() return self.program.num_qubits @@ -121,12 +132,23 @@ def shot_reinit(self): def initialize_cenv(self) -> None: self._reset_env() if self.program: - for cvar in self.program.cvar_meta: + for cvar in self.cvar_meta: cvar: pt.data.CVarDefine dtype = data_type_map[cvar.data_type] self.cenv.append(dtype(0)) self.cid2dtype.append(dtype) + def add_cvar(self, cvar: str, dtype, size: int): + """Adds a new classical variable to the interpreter.""" + if cvar not in self.csym2id: + cid = len(self.csym2id) + self.csym2id[cvar] = cid + self.cenv.append(dtype(0)) + self.cid2dtype.append(dtype) + self.cvar_meta.append( + pt.data.CVarDefine(size=size, data_type=data_type_map_rev[dtype], cvar_id=cid, variable=cvar), + ) + def _flatten_blocks(self, seq: Sequence): """Flattens the ops of blocks to be processed by the execute() method.""" for op in seq: @@ -175,7 +197,7 @@ def execute(self, seq: Sequence) -> Generator[list, Any, None]: yield op_buffer def get_cval(self, cvar): - cid = self.program.csym2id[cvar] + cid = self.csym2id[cvar] return self.cenv[cid] def get_bit(self, cvar, idx): @@ -183,75 +205,78 @@ def get_bit(self, cvar, idx): val >>= idx return val - def eval_expr(self, expr: int | (str | (list | dict))) -> int | None: - if isinstance(expr, int): - return expr - elif isinstance(expr, str): - return self.get_cval(expr) - elif isinstance(expr, list): - return self.get_bit(*expr) - elif isinstance(expr, dict): - # TODO: Expressions need to be converted to nested COps! - sym = expr["cop"] - args = expr["args"] - - if sym in {"~"}: # Unary ops - lhs = args[0] - rhs = None - else: - lhs, rhs = args - rhs = self.eval_expr(rhs) - - lhs = self.eval_expr(lhs) - dtype = type(lhs) - - if sym == "^": - return dtype(lhs ^ rhs) - elif sym == "+": - return dtype(lhs + rhs) - elif sym == "-": - return dtype(lhs - rhs) - elif sym == "|": - return dtype(lhs | rhs) - elif sym == "&": - return dtype(lhs & rhs) - elif sym == ">>": - return dtype(lhs >> rhs) - elif sym == "<<": - return dtype(lhs << rhs) - elif sym == "*": - return dtype(lhs * rhs) - elif sym == "/": - return dtype(lhs // rhs) - elif sym == "==": - return dtype(lhs == rhs) - elif sym == "!=": - return dtype(lhs != rhs) - elif sym == "<=": - return dtype(lhs <= rhs) - elif sym == ">=": - return dtype(lhs >= rhs) - elif sym == "<": - return dtype(lhs < rhs) - elif sym == ">": - return dtype(lhs > rhs) - elif sym == "%": - return dtype(lhs % rhs) - elif sym == "~": - return dtype(~lhs) - else: - msg = f"Unknown expression type: {sym}" - raise ValueError(msg) - return None + def eval_expr(self, expr: int | str | list | pt.opt.COp) -> int | None: + """Evaluates integer expressions.""" + match expr: + case int(): + return expr + + case str(): + return self.get_cval(expr) + case list(): + return self.get_bit(*expr) + case pt.opt.COp(): + sym = expr.name + args = expr.args + + if sym in {"~"}: # Unary ops + lhs = args[0] + rhs = None + else: + lhs, rhs = args + rhs = self.eval_expr(rhs) + + lhs = self.eval_expr(lhs) + dtype = type(lhs) + + if sym == "^": + return dtype(lhs ^ rhs) + elif sym == "+": + return dtype(lhs + rhs) + elif sym == "-": + return dtype(lhs - rhs) + elif sym == "|": + return dtype(lhs | rhs) + elif sym == "&": + return dtype(lhs & rhs) + elif sym == ">>": + return dtype(lhs >> rhs) + elif sym == "<<": + return dtype(lhs << rhs) + elif sym == "*": + return dtype(lhs * rhs) + elif sym == "/": + return dtype(lhs // rhs) + elif sym == "==": + return dtype(lhs == rhs) + elif sym == "!=": + return dtype(lhs != rhs) + elif sym == "<=": + return dtype(lhs <= rhs) + elif sym == ">=": + return dtype(lhs >= rhs) + elif sym == "<": + return dtype(lhs < rhs) + elif sym == ">": + return dtype(lhs > rhs) + elif sym == "%": + return dtype(lhs % rhs) + elif sym == "~": + return dtype(~lhs) + else: + msg = f"Unknown expression type: {sym}" + raise ValueError(msg) + case _: + return None def assign_int(self, cvar, val: int): i = None if isinstance(cvar, (tuple, list)): cvar, i = cvar - cid = self.program.csym2id[cvar] + cid = self.csym2id[cvar] dtype = self.cid2dtype[cid] - size = self.program.cvar_meta[cid].size + size = self.cvar_meta[cid].size cval = self.cenv[cid] val = dtype(val) @@ -312,11 +337,24 @@ def receive_results(self, qsim_results: list[dict]): def results(self, return_int=True) -> dict: """Dumps program final results.""" result = {} - for csym, cid in self.program.csym2id.items(): + for csym, cid in self.csym2id.items(): cval = self.cenv[cid] if not return_int: - size = self.program.cvar_meta[cid].size + size = self.cvar_meta[cid].size cval = "{:0{width}b}".format(cval, width=size) result[csym] = cval return result + + def result_bits(self, bits: Iterable[tuple[str, int]], *, filter_private=True) -> dict[tuple[str, int], int]: + """Git a dictionary of bit values given an iterable of bits (which are encoded as tuple[str, int] + for str[int]).""" + send_meas = {} + for b in bits: + for m, i in b: + m: str + i: int + if filter_private and m.startswith("__"): + continue + send_meas[(m, i)] = self.get_bit(m, i) + return send_meas diff --git a/python/pecos/engines/hybrid_engine.py b/python/pecos/engines/hybrid_engine.py index 3fed197e..901b46cf 100644 --- a/python/pecos/engines/hybrid_engine.py +++ b/python/pecos/engines/hybrid_engine.py @@ -48,6 +48,8 @@ def __init__( if self.cinterp is None: self.cinterp = PHIRClassicalInterpreter() + self._internal_cinterp = PHIRClassicalInterpreter() + self.qsim = qsim if self.qsim is None: self.qsim = QuantumSimulator() @@ -83,6 +85,7 @@ def init(self): def reset_all(self): """Reset to the state of initialization.""" self.cinterp.reset() + self._internal_cinterp.reset() self.qsim.reset() self.machine.reset() self.error_model.reset() @@ -99,6 +102,7 @@ def initialize_sim_components( if foreign_object is not None: foreign_object.init() num_qubits = self.cinterp.init(program, foreign_object) + self._internal_cinterp.init(program, foreign_object) self.machine.init(num_qubits) self.error_model.init(num_qubits, self.machine) self.op_processor.init() @@ -109,6 +113,9 @@ def shot_reinit_components(self) -> None: states. """ self.cinterp.shot_reinit() + self._internal_cinterp.shot_reinit() + for i in range(self.machine.num_qubits): + self._internal_cinterp.add_cvar(f"__q{i}__", np.uint8, 1) self.machine.shot_reinit() self.error_model.shot_reinit() self.op_processor.shot_reinit() @@ -155,6 +162,8 @@ def run( """ # TODO: Qubit loss + measurements = MeasData() + if initialize: self.seed = self.use_seed(seed) self.initialize_sim_components(program, foreign_object) @@ -162,20 +171,23 @@ def run( for _ in range(shots): self.shot_reinit_components() - # Execute classical program till quantum sim is needed + # Execute the classical program till quantum sim is needed for buffered_ops in self.cinterp.execute(self.cinterp.program.ops): - # Process ops, e.g., use `machine` and `error_model` to generate noisy qops - noisy_buffered_qops = self.op_processor.process(buffered_ops) - measurements = self.qsim.run(noisy_buffered_qops) + # Process ops, e.g., use `machine` and `error_model` to generate noisy qops & cops + noisy_buffered_ops = self.op_processor.process(buffered_ops) - # Allows noise to be dependent on measurement outcomes and to alter measurements - measurements = self.op_processor.process_meas(measurements) + # TODO: Think about the safety of evolving the internal registers... + # TODO: Maybe make the error model explicitly declare internal registers... - # TODO: Consider adding the following to generate/evaluate errors after measurement - # measurements, residual_noise = self.op_processor.process_meas(measurements) - # self.simulator.run(residual_noise) + # Process noisy operations + measurements.clear() + for noisy_qops in self._internal_cinterp.execute(noisy_buffered_ops): + temp_meas = self.qsim.run(noisy_qops) + self._internal_cinterp.receive_results(temp_meas) + measurements.extend(temp_meas) - self.cinterp.receive_results(measurements) + transmit_meas = self._internal_cinterp.result_bits(measurements) + self.cinterp.receive_results([transmit_meas]) self.results_accumulator(self.cinterp.results(return_int)) @@ -198,3 +210,7 @@ def run_multisim( seed=seed, pool_size=pool_size, ) + + +class MeasData(list): + """Class representing a collection of measurements.""" diff --git a/python/pecos/machines/generic_machine.py b/python/pecos/machines/generic_machine.py index b074c69f..93a82770 100644 --- a/python/pecos/machines/generic_machine.py +++ b/python/pecos/machines/generic_machine.py @@ -25,6 +25,7 @@ class GenericMachine(Machine): def __init__(self, num_qubits: int | None = None) -> None: super().__init__(num_qubits=num_qubits) + self.qubit_set = set() self.leaked_qubits = set() self.lost_qubits = set() @@ -35,6 +36,7 @@ def reset(self) -> None: def init(self, num_qubits: int | None = None) -> None: self.num_qubits = num_qubits + self.qubit_set = set(range(num_qubits)) def shot_reinit(self) -> None: self.reset() diff --git a/python/pecos/reps/pypmir/block_types.py b/python/pecos/reps/pypmir/block_types.py index 4c827b97..6dc2d086 100644 --- a/python/pecos/reps/pypmir/block_types.py +++ b/python/pecos/reps/pypmir/block_types.py @@ -45,8 +45,8 @@ class IfBlock(Block): def __init__( self, condition: COp, - true_branch: list[COp], - false_branch: list | None = None, + true_branch: list[Op], + false_branch: list[Op] | None = None, metadata: dict | None = None, ) -> None: super().__init__(metadata=metadata) diff --git a/python/pecos/reps/pypmir/pypmir.py b/python/pecos/reps/pypmir/pypmir.py index 0a521c68..1f83d3ca 100644 --- a/python/pecos/reps/pypmir/pypmir.py +++ b/python/pecos/reps/pypmir/pypmir.py @@ -49,125 +49,149 @@ def __init__(self, metadata: dict | None = None, name_resolver: Callable[[QOp], self.foreign_func_calls = set() @classmethod - def handle_op(cls, o: dict, p: PyPMIR) -> TypeOp: - if "block" in o: - if o["block"] == "sequence": - ops = [] - for so in o["ops"]: - ops.append(cls.handle_op(so, p)) - - instr = blk.SeqBlock( - ops=ops, - metadata=o.get("metadata"), - ) - elif o["block"] == "qparallel": - ops = [] - for so in o["ops"]: - ops.append(cls.handle_op(so, p)) - - instr = blk.QParallelBlock( - ops=ops, - metadata=o.get("metadata"), - ) - elif o["block"] == "if": - true_branch = [] - for so in o["true_branch"]: - true_branch.append(cls.handle_op(so, p)) - - false_branch = [] - for so in o.get("false_branch", []): - false_branch.append(cls.handle_op(so, p)) - - instr = blk.IfBlock( - condition=o["condition"], - true_branch=true_branch, - false_branch=false_branch, - metadata=o.get("metadata"), - ) - else: - msg = f"Block not recognized: {o}" - raise Exception(msg) + def handle_op(cls, o: dict | str | int, p: PyPMIR) -> TypeOp | str | list | int: - elif "qop" in o: - # TODO: convert [qsym, qubit_init] to int - # TODO: flatten to just list of ints even for TQ, etc. - # TODO: Note size of gate? + match o: + case int() | str(): # Assume int value or register + o: int | str + return o - metadata = {} if o.get("metadata") is None else o["metadata"] - - if o.get("angles"): - angles = tuple([angle * (pi if o["angles"][1] == "pi" else 1) for angle in o["angles"][0]]) - else: - angles = None - - # TODO: get rid of supplying angle or angles in syms and move to (sym, angles) or sym (or gate obj) - if angles: - if len(angles) == 1: - metadata["angle"] = angles[0] + case list(): + if len(o) == 2 and isinstance(o[0], str) and isinstance(o[1], int): + o: list + return o else: - metadata["angles"] = angles - - args = cls.get_qargs(o, p) - - # TODO: Added to satisfy old-style error models. Remove when they not longer need this... - if o.get("returns"): - var_output = {} - for q, cvar in zip(args, o["returns"]): - var_output[q] = cvar - metadata["var_output"] = var_output - - instr = op.QOp( - name=o["qop"], - sim_name=None, - angles=angles, - args=args, - returns=o.get("returns"), - metadata=metadata, - ) - - instr.sim_name = p.name_resolver(instr) - - elif "cop" in o: - if o["cop"] == "ffcall": - instr = op.FFCall( - name=o["function"], - args=o["args"], - returns=o.get("returns"), - metadata=o.get("metadata"), - ) - p.foreign_func_calls.add(o["function"]) - else: - instr = op.COp(name=o["cop"], args=o["args"], returns=o.get("returns"), metadata=o.get("metadata")) - - elif "mop" in o: - if "args" in o: - # TODO: Assuming qargs... but that might not always be the case... - args = cls.get_qargs(o, p) - else: - args = None - - instr = op.MOp(name=o["mop"], args=args, returns=o.get("returns"), metadata=o.get("metadata")) - if "duration" in o: - if instr.metadata is None: - instr.metadata = {} - instr.metadata["duration"] = o["duration"] - - elif "meta" in o: - # TODO: Handle meta instructions - name = o["meta"] - if name == "barrier": - instr = None - else: - msg = f"Meta instruction '{name}' not implemented/supported." - raise NotImplementedError(msg) + msg = f"A bit or qubit was assumed for list types. Got: {o}" + raise ValueError(msg) + + case dict(): + + if "block" in o: + if o["block"] == "sequence": + ops = [] + for so in o["ops"]: + ops.append(cls.handle_op(so, p)) + + instr = blk.SeqBlock( + ops=ops, + metadata=o.get("metadata"), + ) + elif o["block"] == "qparallel": + ops = [] + for so in o["ops"]: + ops.append(cls.handle_op(so, p)) + + instr = blk.QParallelBlock( + ops=ops, + metadata=o.get("metadata"), + ) + elif o["block"] == "if": + true_branch = [] + for so in o["true_branch"]: + true_branch.append(cls.handle_op(so, p)) + + false_branch = [] + for so in o.get("false_branch", []): + false_branch.append(cls.handle_op(so, p)) + + instr = blk.IfBlock( + # condition=o["condition"], + condition=cls.handle_op(o["condition"], p), + true_branch=true_branch, + false_branch=false_branch, + metadata=o.get("metadata"), + ) + else: + msg = f"Block not recognized: {o}" + raise Exception(msg) - elif "//" in o: - # Do not include comments - instr = None + elif "qop" in o: + # TODO: convert [qsym, qubit_init] to int + # TODO: flatten to just list of ints even for TQ, etc. + # TODO: Note size of gate? + + metadata = {} if o.get("metadata") is None else o["metadata"] + + if o.get("angles"): + angles = tuple([angle * (pi if o["angles"][1] == "pi" else 1) for angle in o["angles"][0]]) + else: + angles = None + + # TODO: get rid of supplying angle or angles in syms and move to (sym, angles) or sym (or gate obj) + if angles: + if len(angles) == 1: + metadata["angle"] = angles[0] + else: + metadata["angles"] = angles + + args = cls.get_qargs(o, p) + + # TODO: Added to satisfy old-style error models. Remove when they not longer need this... + if o.get("returns"): + var_output = {} + for q, cvar in zip(args, o["returns"]): + var_output[q] = cvar + metadata["var_output"] = var_output + + instr = op.QOp( + name=o["qop"], + sim_name=None, + angles=angles, + args=args, + returns=o.get("returns"), + metadata=metadata, + ) - else: - msg = f"Instruction not recognized: {o}" - raise Exception(msg) + instr.sim_name = p.name_resolver(instr) + + elif "cop" in o: + if o["cop"] == "ffcall": + instr = op.FFCall( + name=o["function"], + args=o["args"], + returns=o.get("returns"), + metadata=o.get("metadata"), + ) + p.foreign_func_calls.add(o["function"]) + else: + instr = op.COp( + name=o["cop"], + args=[cls.handle_op(a, p) for a in o["args"]], + returns=o.get("returns"), + metadata=o.get("metadata"), + ) + + elif "mop" in o: + if "args" in o: + # TODO: Assuming qargs... but that might not always be the case... + args = cls.get_qargs(o, p) + else: + args = None + + instr = op.MOp(name=o["mop"], args=args, returns=o.get("returns"), metadata=o.get("metadata")) + if "duration" in o: + if instr.metadata is None: + instr.metadata = {} + instr.metadata["duration"] = o["duration"] + + elif "meta" in o: + # TODO: Handle meta instructions + name = o["meta"] + if name == "barrier": + instr = None + else: + msg = f"Meta instruction '{name}' not implemented/supported." + raise NotImplementedError(msg) + + elif "//" in o: + # Do not include comments + instr = None + else: + msg = f"Unknown instruction: {o}" + raise NotImplementedError(msg) + case _: + msg = f"Instruction not recognized: {o}" + raise Exception(msg) return instr