From dfd2572cce1184fdfabaf344b91c90f8d8f994ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elena=20Pe=C3=B1a=20Tapia?= <57907331+ElePT@users.noreply.github.com> Date: Wed, 27 Sep 2023 18:16:25 +0200 Subject: [PATCH] Add `use_symengine` option to `qpy.dump` (#10820) * Add use-symengine option * Fix lint * Improve error message * Apply feedback * Update docs * Move option to file header, add schedule block * Add example to docs * Fix lint * Apply second round of feedback * Fix lint, update tests * Update docs * Fix use of require_now * Fix compatibility test * Make tests optional * Fix lint * Add release note * Update exception message Co-authored-by: Matthew Treinish * Update other message * Rename Encoding to SymExprEncoding --------- Co-authored-by: Matthew Treinish --- circuit.qpy | Bin 0 -> 3173 bytes qiskit/qpy/__init__.py | 45 ++++++++- qiskit/qpy/binary_io/circuits.py | 87 ++++++++++++----- qiskit/qpy/binary_io/schedules.py | 92 +++++++++++------- qiskit/qpy/binary_io/value.py | 84 ++++++++++++---- qiskit/qpy/common.py | 2 +- qiskit/qpy/formats.py | 16 +++ qiskit/qpy/interface.py | 45 +++++++-- qiskit/qpy/type_keys.py | 18 ++++ ...py-symbolic-encoding-81d5321af38f259f.yaml | 31 ++++++ .../circuit/test_circuit_load_from_qpy.py | 77 ++++++++++++++- test/python/qpy/test_block_load_from_qpy.py | 53 ++++++++++ 12 files changed, 460 insertions(+), 90 deletions(-) create mode 100644 circuit.qpy create mode 100644 releasenotes/notes/add-qpy-symbolic-encoding-81d5321af38f259f.yaml diff --git a/circuit.qpy b/circuit.qpy new file mode 100644 index 0000000000000000000000000000000000000000..ae72d271d09d0b2a6284a66468354ca04046f50f GIT binary patch literal 3173 zcmd^BOKTHR6uvVp(%Mp7x{Rw)$dt|`=3#@gXt5NjD5jvRFqxUefi#avQmmy~`~`|2 zxDj`5{Q-sX2k39mRl$|rxbk@)=ged#ucj2FdZG8+^SHNPzI)EOb7%eT&Gq6eQ3(vG zMdn;GLp2#qu$C~way_f-c2rGY!jRp)7IVu)qWhyG*hUsD9zrLu%|3=T#(cBXZr$ye z-cBi-(<-{HmsQKon`+w5IchnTGu5kkTT5%@obHsZ<0DOF5mbOwmRflo!+ivw931Qh+0+My0wnmtT_)Ik0Ys!%;@lPQfcGM zrSHX6^>XFJSM^tui;w;z~IhpLzLWJ14^Ozb62JkARo&k7%xpHyDI zIrKjWX$bie?FQi8U;6sv)y~sL2N36zbKvRi5@6zltlYyN4ISakQuXsU8=ruR&&DsI z`(XFEO8s#i>^@~hEXB-8S{TkA_eArTrw*GKvlE{ct;#WH#kwRN zhV6;)!eXB$Y+NY36AS-H$iQKML6`~Ya~#0Vz=4s$P6myQVbFRXa*m1@EBQOkf_>GD zZWa?%=*ix_8)mJ2x(^?`d*T|x`3N%#OmRJoB#U&-n$&= 5 and instruction.conditional_key == type_keys.Condition.EXPRESSION: condition = value.read_value( - file_obj, version, vectors, clbits=circuit.clbits, cregs=registers["c"] + file_obj, + version, + vectors, + clbits=circuit.clbits, + cregs=registers["c"], + use_symengine=use_symengine, ) if circuit is not None: qubit_indices = dict(enumerate(circuit.qubits)) @@ -233,14 +252,14 @@ def _read_instruction(file_obj, circuit, registers, custom_operations, version, for _param in range(instruction.num_parameters): type_key, data_bytes = common.read_generic_typed_data(file_obj) param = _loads_instruction_parameter( - type_key, data_bytes, version, vectors, registers, circuit + type_key, data_bytes, version, vectors, registers, circuit, use_symengine ) params.append(param) # Load Gate object if gate_name in {"Gate", "Instruction", "ControlledGate"}: inst_obj = _parse_custom_operation( - custom_operations, gate_name, params, version, vectors, registers + custom_operations, gate_name, params, version, vectors, registers, use_symengine ) inst_obj.condition = condition if instruction.label_size > 0: @@ -251,7 +270,7 @@ def _read_instruction(file_obj, circuit, registers, custom_operations, version, return None elif gate_name in custom_operations: inst_obj = _parse_custom_operation( - custom_operations, gate_name, params, version, vectors, registers + custom_operations, gate_name, params, version, vectors, registers, use_symengine ) inst_obj.condition = condition if instruction.label_size > 0: @@ -329,7 +348,9 @@ def _read_instruction(file_obj, circuit, registers, custom_operations, version, return None -def _parse_custom_operation(custom_operations, gate_name, params, version, vectors, registers): +def _parse_custom_operation( + custom_operations, gate_name, params, version, vectors, registers, use_symengine +): if version >= 5: ( type_str, @@ -358,7 +379,7 @@ def _parse_custom_operation(custom_operations, gate_name, params, version, vecto if version >= 5 and type_key == type_keys.CircuitInstruction.CONTROLLED_GATE: with io.BytesIO(base_gate_raw) as base_gate_obj: base_gate = _read_instruction( - base_gate_obj, None, registers, custom_operations, version, vectors + base_gate_obj, None, registers, custom_operations, version, vectors, use_symengine ) if ctrl_state < 2**num_ctrl_qubits - 1: # If open controls, we need to discard the control suffix when setting the name. @@ -509,7 +530,7 @@ def _dumps_register(register, index_map): return b"\x00" + str(index_map["c"][register]).encode(common.ENCODE) -def _dumps_instruction_parameter(param, index_map): +def _dumps_instruction_parameter(param, index_map, use_symengine): if isinstance(param, QuantumCircuit): type_key = type_keys.Program.CIRCUIT data_bytes = common.data_to_binary(param, write_circuit) @@ -519,7 +540,7 @@ def _dumps_instruction_parameter(param, index_map): elif isinstance(param, tuple): type_key = type_keys.Container.TUPLE data_bytes = common.sequence_to_binary( - param, _dumps_instruction_parameter, index_map=index_map + param, _dumps_instruction_parameter, index_map=index_map, use_symengine=use_symengine ) elif isinstance(param, int): # TODO This uses little endian. This should be fixed in next QPY version. @@ -533,13 +554,15 @@ def _dumps_instruction_parameter(param, index_map): type_key = type_keys.Value.REGISTER data_bytes = _dumps_register(param, index_map) else: - type_key, data_bytes = value.dumps_value(param, index_map=index_map) + type_key, data_bytes = value.dumps_value( + param, index_map=index_map, use_symengine=use_symengine + ) return type_key, data_bytes # pylint: disable=too-many-boolean-expressions -def _write_instruction(file_obj, instruction, custom_operations, index_map): +def _write_instruction(file_obj, instruction, custom_operations, index_map, use_symengine): gate_class_name = instruction.operation.__class__.__name__ custom_operations_list = [] if ( @@ -619,7 +642,7 @@ def _write_instruction(file_obj, instruction, custom_operations, index_map): value.write_value(file_obj, op_condition, index_map=index_map) else: file_obj.write(condition_register) - # Encode instruciton args + # Encode instruction args for qbit in instruction.qubits: instruction_arg_raw = struct.pack( formats.CIRCUIT_INSTRUCTION_ARG_PACK, b"q", index_map["q"][qbit] @@ -632,7 +655,7 @@ def _write_instruction(file_obj, instruction, custom_operations, index_map): file_obj.write(instruction_arg_raw) # Encode instruction params for param in instruction_params: - type_key, data_bytes = _dumps_instruction_parameter(param, index_map) + type_key, data_bytes = _dumps_instruction_parameter(param, index_map, use_symengine) common.write_generic_typed_data(file_obj, type_key, data_bytes) return custom_operations_list @@ -677,7 +700,7 @@ def _write_elem(buffer, op): file_obj.write(synth_data) -def _write_custom_operation(file_obj, name, operation, custom_operations): +def _write_custom_operation(file_obj, name, operation, custom_operations, use_symengine): type_key = type_keys.CircuitInstruction.assign(operation) has_definition = False size = 0 @@ -716,7 +739,11 @@ def _write_custom_operation(file_obj, name, operation, custom_operations): else: with io.BytesIO() as base_gate_buffer: new_custom_instruction = _write_instruction( - base_gate_buffer, CircuitInstruction(base_gate, (), ()), custom_operations, {} + base_gate_buffer, + CircuitInstruction(base_gate, (), ()), + custom_operations, + {}, + use_symengine, ) base_gate_raw = base_gate_buffer.getvalue() name_raw = name.encode(common.ENCODE) @@ -931,7 +958,7 @@ def _read_layout(file_obj, circuit): circuit._layout = TranspileLayout(initial_layout, input_qubit_mapping, final_layout) -def write_circuit(file_obj, circuit, metadata_serializer=None): +def write_circuit(file_obj, circuit, metadata_serializer=None, use_symengine=False): """Write a single QuantumCircuit object in the file like object. Args: @@ -941,6 +968,10 @@ def write_circuit(file_obj, circuit, metadata_serializer=None): will be passed the :attr:`.QuantumCircuit.metadata` dictionary for ``circuit`` and will be used as the ``cls`` kwarg on the ``json.dump()`` call to JSON serialize that dictionary. + use_symengine (bool): If True, symbolic objects will be serialized using symengine's + native mechanism. This is a faster serialization alternative, but not supported in all + platforms. Please check that your target platform is supported by the symengine library + before setting this option, as it will be required by qpy to deserialize the payload. """ metadata_raw = json.dumps( circuit.metadata, separators=(",", ":"), cls=metadata_serializer @@ -980,7 +1011,9 @@ def write_circuit(file_obj, circuit, metadata_serializer=None): index_map["q"] = {bit: index for index, bit in enumerate(circuit.qubits)} index_map["c"] = {bit: index for index, bit in enumerate(circuit.clbits)} for instruction in circuit.data: - _write_instruction(instruction_buffer, instruction, custom_operations, index_map) + _write_instruction( + instruction_buffer, instruction, custom_operations, index_map, use_symengine + ) with io.BytesIO() as custom_operations_buffer: new_custom_operations = list(custom_operations.keys()) @@ -991,7 +1024,7 @@ def write_circuit(file_obj, circuit, metadata_serializer=None): operation = custom_operations[name] new_custom_operations.extend( _write_custom_operation( - custom_operations_buffer, name, operation, custom_operations + custom_operations_buffer, name, operation, custom_operations, use_symengine ) ) @@ -1006,7 +1039,7 @@ def write_circuit(file_obj, circuit, metadata_serializer=None): _write_layout(file_obj, circuit) -def read_circuit(file_obj, version, metadata_deserializer=None): +def read_circuit(file_obj, version, metadata_deserializer=None, use_symengine=False): """Read a single QuantumCircuit object from the file like object. Args: @@ -1019,7 +1052,11 @@ def read_circuit(file_obj, version, metadata_deserializer=None): in the file-like object. If this is not specified the circuit metadata will be parsed as JSON with the stdlib ``json.load()`` function using the default ``JSONDecoder`` class. - + use_symengine (bool): If True, symbolic objects will be de-serialized using + symengine's native mechanism. This is a faster serialization alternative, but not + supported in all platforms. Please check that your target platform is supported by + the symengine library before setting this option, as it will be required by qpy to + deserialize the payload. Returns: QuantumCircuit: The circuit object from the file. @@ -1039,7 +1076,7 @@ def read_circuit(file_obj, version, metadata_deserializer=None): num_clbits = header["num_clbits"] num_registers = header["num_registers"] num_instructions = header["num_instructions"] - # `out_registers` is two "name: registter" maps segregated by type for the rest of QPY, and + # `out_registers` is two "name: register" maps segregated by type for the rest of QPY, and # `all_registers` is the complete ordered list used to construct the `QuantumCircuit`. out_registers = {"q": {}, "c": {}} all_registers = [] @@ -1105,7 +1142,9 @@ def read_circuit(file_obj, version, metadata_deserializer=None): ) custom_operations = _read_custom_operations(file_obj, version, vectors) for _instruction in range(num_instructions): - _read_instruction(file_obj, circ, out_registers, custom_operations, version, vectors) + _read_instruction( + file_obj, circ, out_registers, custom_operations, version, vectors, use_symengine + ) # Read calibrations if version >= 5: diff --git a/qiskit/qpy/binary_io/schedules.py b/qiskit/qpy/binary_io/schedules.py index e4fd7363a4d4..83a023b9527b 100644 --- a/qiskit/qpy/binary_io/schedules.py +++ b/qiskit/qpy/binary_io/schedules.py @@ -103,19 +103,25 @@ def _read_discriminator(file_obj, version): return Discriminator(name=name, **params) -def _loads_symbolic_expr(expr_bytes): - from sympy import parse_expr - +def _loads_symbolic_expr(expr_bytes, use_symengine=False): if expr_bytes == b"": return None + if use_symengine: + _optional.HAS_SYMENGINE.require_now("load a symengine expression") + from symengine.lib.symengine_wrapper import ( # pylint: disable = no-name-in-module + load_basic, + ) - expr_txt = zlib.decompress(expr_bytes).decode(common.ENCODE) - expr = parse_expr(expr_txt) + expr = load_basic(zlib.decompress(expr_bytes)) + else: + from sympy import parse_expr - if _optional.HAS_SYMENGINE: - from symengine import sympify + expr_txt = zlib.decompress(expr_bytes).decode(common.ENCODE) + expr = parse_expr(expr_txt) + if _optional.HAS_SYMENGINE: + from symengine import sympify - return sympify(expr) + return sympify(expr) return expr @@ -201,7 +207,7 @@ def _read_symbolic_pulse(file_obj, version): raise NotImplementedError(f"Unknown class '{class_name}'") -def _read_symbolic_pulse_v6(file_obj, version): +def _read_symbolic_pulse_v6(file_obj, version, use_symengine): make = formats.SYMBOLIC_PULSE_V2._make pack = formats.SYMBOLIC_PULSE_PACK_V2 size = formats.SYMBOLIC_PULSE_SIZE_V2 @@ -214,9 +220,11 @@ def _read_symbolic_pulse_v6(file_obj, version): ) class_name = file_obj.read(header.class_name_size).decode(common.ENCODE) pulse_type = file_obj.read(header.type_size).decode(common.ENCODE) - envelope = _loads_symbolic_expr(file_obj.read(header.envelope_size)) - constraints = _loads_symbolic_expr(file_obj.read(header.constraints_size)) - valid_amp_conditions = _loads_symbolic_expr(file_obj.read(header.valid_amp_conditions_size)) + envelope = _loads_symbolic_expr(file_obj.read(header.envelope_size), use_symengine) + constraints = _loads_symbolic_expr(file_obj.read(header.constraints_size), use_symengine) + valid_amp_conditions = _loads_symbolic_expr( + file_obj.read(header.valid_amp_conditions_size), use_symengine + ) parameters = common.read_mapping( file_obj, deserializer=value.loads_value, @@ -273,14 +281,16 @@ def _read_alignment_context(file_obj, version): # pylint: disable=too-many-return-statements -def _loads_operand(type_key, data_bytes, version): +def _loads_operand(type_key, data_bytes, version, use_symengine): if type_key == type_keys.ScheduleOperand.WAVEFORM: return common.data_from_binary(data_bytes, _read_waveform, version=version) if type_key == type_keys.ScheduleOperand.SYMBOLIC_PULSE: if version < 6: return common.data_from_binary(data_bytes, _read_symbolic_pulse, version=version) else: - return common.data_from_binary(data_bytes, _read_symbolic_pulse_v6, version=version) + return common.data_from_binary( + data_bytes, _read_symbolic_pulse_v6, version=version, use_symengine=use_symengine + ) if type_key == type_keys.ScheduleOperand.CHANNEL: return common.data_from_binary(data_bytes, _read_channel, version=version) if type_key == type_keys.ScheduleOperand.OPERAND_STR: @@ -301,13 +311,15 @@ def _loads_operand(type_key, data_bytes, version): return value.loads_value(type_key, data_bytes, version, {}) -def _read_element(file_obj, version, metadata_deserializer): +def _read_element(file_obj, version, metadata_deserializer, use_symengine): type_key = common.read_type_key(file_obj) if type_key == type_keys.Program.SCHEDULE_BLOCK: - return read_schedule_block(file_obj, version, metadata_deserializer) + return read_schedule_block(file_obj, version, metadata_deserializer, use_symengine) - operands = common.read_sequence(file_obj, deserializer=_loads_operand, version=version) + operands = common.read_sequence( + file_obj, deserializer=_loads_operand, version=version, use_symengine=use_symengine + ) name = value.read_value(file_obj, version, {}) instance = object.__new__(type_keys.ScheduleInstruction.retrieve(type_key)) @@ -388,22 +400,25 @@ def _write_discriminator(file_obj, data): value.write_value(file_obj, name) -def _dumps_symbolic_expr(expr): - from sympy import srepr, sympify - +def _dumps_symbolic_expr(expr, use_symengine): if expr is None: return b"" + if use_symengine: + _optional.HAS_SYMENGINE.require_now("dump a symengine expression") + expr_bytes = expr.__reduce__()[1][0] + else: + from sympy import srepr, sympify - expr_bytes = srepr(sympify(expr)).encode(common.ENCODE) + expr_bytes = srepr(sympify(expr)).encode(common.ENCODE) return zlib.compress(expr_bytes) -def _write_symbolic_pulse(file_obj, data): +def _write_symbolic_pulse(file_obj, data, use_symengine): class_name_bytes = data.__class__.__name__.encode(common.ENCODE) pulse_type_bytes = data.pulse_type.encode(common.ENCODE) - envelope_bytes = _dumps_symbolic_expr(data.envelope) - constraints_bytes = _dumps_symbolic_expr(data.constraints) - valid_amp_conditions_bytes = _dumps_symbolic_expr(data.valid_amp_conditions) + envelope_bytes = _dumps_symbolic_expr(data.envelope, use_symengine) + constraints_bytes = _dumps_symbolic_expr(data.constraints, use_symengine) + valid_amp_conditions_bytes = _dumps_symbolic_expr(data.valid_amp_conditions, use_symengine) header_bytes = struct.pack( formats.SYMBOLIC_PULSE_PACK_V2, @@ -439,13 +454,15 @@ def _write_alignment_context(file_obj, context): ) -def _dumps_operand(operand): +def _dumps_operand(operand, use_symengine): if isinstance(operand, library.Waveform): type_key = type_keys.ScheduleOperand.WAVEFORM data_bytes = common.data_to_binary(operand, _write_waveform) elif isinstance(operand, library.SymbolicPulse): type_key = type_keys.ScheduleOperand.SYMBOLIC_PULSE - data_bytes = common.data_to_binary(operand, _write_symbolic_pulse) + data_bytes = common.data_to_binary( + operand, _write_symbolic_pulse, use_symengine=use_symengine + ) elif isinstance(operand, channels.Channel): type_key = type_keys.ScheduleOperand.CHANNEL data_bytes = common.data_to_binary(operand, _write_channel) @@ -464,7 +481,7 @@ def _dumps_operand(operand): return type_key, data_bytes -def _write_element(file_obj, element, metadata_serializer): +def _write_element(file_obj, element, metadata_serializer, use_symengine): if isinstance(element, ScheduleBlock): common.write_type_key(file_obj, type_keys.Program.SCHEDULE_BLOCK) write_schedule_block(file_obj, element, metadata_serializer) @@ -475,6 +492,7 @@ def _write_element(file_obj, element, metadata_serializer): file_obj, sequence=element.operands, serializer=_dumps_operand, + use_symengine=use_symengine, ) value.write_value(file_obj, element.name) @@ -493,7 +511,7 @@ def _dumps_reference_item(schedule, metadata_serializer): return type_key, data_bytes -def read_schedule_block(file_obj, version, metadata_deserializer=None): +def read_schedule_block(file_obj, version, metadata_deserializer=None, use_symengine=False): """Read a single ScheduleBlock from the file like object. Args: @@ -506,7 +524,10 @@ def read_schedule_block(file_obj, version, metadata_deserializer=None): in the file-like object. If this is not specified the circuit metadata will be parsed as JSON with the stdlib ``json.load()`` function using the default ``JSONDecoder`` class. - + use_symengine (bool): If True, symbolic objects will be serialized using symengine's + native mechanism. This is a faster serialization alternative, but not supported in all + platforms. Please check that your target platform is supported by the symengine library + before setting this option, as it will be required by qpy to deserialize the payload. Returns: ScheduleBlock: The schedule block object from the file. @@ -534,7 +555,7 @@ def read_schedule_block(file_obj, version, metadata_deserializer=None): alignment_context=context, ) for _ in range(data.num_elements): - block_elm = _read_element(file_obj, version, metadata_deserializer) + block_elm = _read_element(file_obj, version, metadata_deserializer, use_symengine) block.append(block_elm, inplace=True) # Load references @@ -556,7 +577,7 @@ def read_schedule_block(file_obj, version, metadata_deserializer=None): return block -def write_schedule_block(file_obj, block, metadata_serializer=None): +def write_schedule_block(file_obj, block, metadata_serializer=None, use_symengine=False): """Write a single ScheduleBlock object in the file like object. Args: @@ -566,7 +587,10 @@ def write_schedule_block(file_obj, block, metadata_serializer=None): will be passed the :attr:`.ScheduleBlock.metadata` dictionary for ``block`` and will be used as the ``cls`` kwarg on the ``json.dump()`` call to JSON serialize that dictionary. - + use_symengine (bool): If True, symbolic objects will be serialized using symengine's + native mechanism. This is a faster serialization alternative, but not supported in all + platforms. Please check that your target platform is supported by the symengine library + before setting this option, as it will be required by qpy to deserialize the payload. Raises: TypeError: If any of the instructions is invalid data format. """ @@ -590,7 +614,7 @@ def write_schedule_block(file_obj, block, metadata_serializer=None): for block_elm in block._blocks: # Do not call block.blocks. This implicitly assigns references to instruction. # This breaks original reference structure. - _write_element(file_obj, block_elm, metadata_serializer) + _write_element(file_obj, block_elm, metadata_serializer, use_symengine) # Write references flat_key_refdict = {} diff --git a/qiskit/qpy/binary_io/value.py b/qiskit/qpy/binary_io/value.py index 2edac6c81b52..c2083e121e00 100644 --- a/qiskit/qpy/binary_io/value.py +++ b/qiskit/qpy/binary_io/value.py @@ -49,10 +49,15 @@ def _write_parameter_vec(file_obj, obj): file_obj.write(name_bytes) -def _write_parameter_expression(file_obj, obj): - from sympy import srepr, sympify +def _write_parameter_expression(file_obj, obj, use_symengine): + if use_symengine: + _optional.HAS_SYMENGINE.require_now("write_parameter_expression") + expr_bytes = obj._symbol_expr.__reduce__()[1][0] + else: + from sympy import srepr, sympify + + expr_bytes = srepr(sympify(obj._symbol_expr)).encode(common.ENCODE) - expr_bytes = srepr(sympify(obj._symbol_expr)).encode(common.ENCODE) param_expr_header_raw = struct.pack( formats.PARAMETER_EXPR_PACK, len(obj._parameter_symbols), len(expr_bytes) ) @@ -73,7 +78,7 @@ def _write_parameter_expression(file_obj, obj): value_key = symbol_key value_data = bytes() else: - value_key, value_data = dumps_value(value) + value_key, value_data = dumps_value(value, use_symengine=use_symengine) elem_header = struct.pack( formats.PARAM_EXPR_MAP_ELEM_V3_PACK, @@ -225,11 +230,12 @@ def _read_parameter_expression(file_obj): from sympy.parsing.sympy_parser import parse_expr if _optional.HAS_SYMENGINE: - import symengine + from symengine import sympify - expr_ = symengine.sympify(parse_expr(file_obj.read(data.expr_size).decode(common.ENCODE))) + expr_ = sympify(parse_expr(file_obj.read(data.expr_size).decode(common.ENCODE))) else: expr_ = parse_expr(file_obj.read(data.expr_size).decode(common.ENCODE)) + symbol_map = {} for _ in range(data.map_elements): elem_data = formats.PARAM_EXPR_MAP_ELEM( @@ -259,18 +265,28 @@ def _read_parameter_expression(file_obj): return ParameterExpression(symbol_map, expr_) -def _read_parameter_expression_v3(file_obj, vectors): +def _read_parameter_expression_v3(file_obj, vectors, use_symengine): data = formats.PARAMETER_EXPR( *struct.unpack(formats.PARAMETER_EXPR_PACK, file_obj.read(formats.PARAMETER_EXPR_SIZE)) ) from sympy.parsing.sympy_parser import parse_expr - if _optional.HAS_SYMENGINE: - import symengine + payload = file_obj.read(data.expr_size) + if use_symengine: + _optional.HAS_SYMENGINE.require_now("read_parameter_expression_v3") + from symengine.lib.symengine_wrapper import ( # pylint: disable = no-name-in-module + load_basic, + ) - expr_ = symengine.sympify(parse_expr(file_obj.read(data.expr_size).decode(common.ENCODE))) + expr_ = load_basic(payload) else: - expr_ = parse_expr(file_obj.read(data.expr_size).decode(common.ENCODE)) + if _optional.HAS_SYMENGINE: + from symengine import sympify + + expr_ = sympify(parse_expr(payload.decode(common.ENCODE))) + else: + expr_ = parse_expr(payload.decode(common.ENCODE)) + symbol_map = {} for _ in range(data.map_elements): elem_data = formats.PARAM_EXPR_MAP_ELEM_V3( @@ -300,7 +316,10 @@ def _read_parameter_expression_v3(file_obj, vectors): value = symbol._symbol_expr elif elem_key == type_keys.Value.PARAMETER_EXPRESSION: value = common.data_from_binary( - binary_data, _read_parameter_expression_v3, vectors=vectors + binary_data, + _read_parameter_expression_v3, + vectors=vectors, + use_symengine=use_symengine, ) else: raise exceptions.QpyError("Invalid parameter expression map type: %s" % elem_key) @@ -393,7 +412,7 @@ def _read_expr_type(file_obj) -> types.Type: raise exceptions.QpyError(f"Invalid classical-expression Type key '{type_key}'") -def dumps_value(obj, *, index_map=None): +def dumps_value(obj, *, index_map=None, use_symengine=False): """Serialize input value object. Args: @@ -401,6 +420,10 @@ def dumps_value(obj, *, index_map=None): index_map (dict): Dictionary with two keys, "q" and "c". Each key has a value that is a dictionary mapping :class:`.Qubit` or :class:`.Clbit` instances (respectively) to their integer indices. + use_symengine (bool): If True, symbolic objects will be serialized using symengine's + native mechanism. This is a faster serialization alternative, but not supported in all + platforms. Please check that your target platform is supported by the symengine library + before setting this option, as it will be required by qpy to deserialize the payload. Returns: tuple: TypeKey and binary data. @@ -427,7 +450,9 @@ def dumps_value(obj, *, index_map=None): elif type_key == type_keys.Value.PARAMETER: binary_data = common.data_to_binary(obj, _write_parameter) elif type_key == type_keys.Value.PARAMETER_EXPRESSION: - binary_data = common.data_to_binary(obj, _write_parameter_expression) + binary_data = common.data_to_binary( + obj, _write_parameter_expression, use_symengine=use_symengine + ) elif type_key == type_keys.Value.EXPRESSION: clbit_indices = {} if index_map is None else index_map["c"] binary_data = common.data_to_binary(obj, _write_expr, clbit_indices=clbit_indices) @@ -437,7 +462,7 @@ def dumps_value(obj, *, index_map=None): return type_key, binary_data -def write_value(file_obj, obj, *, index_map=None): +def write_value(file_obj, obj, *, index_map=None, use_symengine=False): """Write a value to the file like object. Args: @@ -446,12 +471,18 @@ def write_value(file_obj, obj, *, index_map=None): index_map (dict): Dictionary with two keys, "q" and "c". Each key has a value that is a dictionary mapping :class:`.Qubit` or :class:`.Clbit` instances (respectively) to their integer indices. + use_symengine (bool): If True, symbolic objects will be serialized using symengine's + native mechanism. This is a faster serialization alternative, but not supported in all + platforms. Please check that your target platform is supported by the symengine library + before setting this option, as it will be required by qpy to deserialize the payload. """ - type_key, data = dumps_value(obj, index_map=index_map) + type_key, data = dumps_value(obj, index_map=index_map, use_symengine=use_symengine) common.write_generic_typed_data(file_obj, type_key, data) -def loads_value(type_key, binary_data, version, vectors, *, clbits=(), cregs=None): +def loads_value( + type_key, binary_data, version, vectors, *, clbits=(), cregs=None, use_symengine=False +): """Deserialize input binary data to value object. Args: @@ -461,6 +492,10 @@ def loads_value(type_key, binary_data, version, vectors, *, clbits=(), cregs=Non vectors (dict): ParameterVector in current scope. clbits (Sequence[Clbit]): Clbits in the current scope. cregs (Mapping[str, ClassicalRegister]): Classical registers in the current scope. + use_symengine (bool): If True, symbolic objects will be de-serialized using symengine's + native mechanism. This is a faster serialization alternative, but not supported in all + platforms. Please check that your target platform is supported by the symengine library + before setting this option, as it will be required by qpy to deserialize the payload. Returns: any: Deserialized value object. @@ -496,7 +531,10 @@ def loads_value(type_key, binary_data, version, vectors, *, clbits=(), cregs=Non return common.data_from_binary(binary_data, _read_parameter_expression) else: return common.data_from_binary( - binary_data, _read_parameter_expression_v3, vectors=vectors + binary_data, + _read_parameter_expression_v3, + vectors=vectors, + use_symengine=use_symengine, ) if type_key == type_keys.Value.EXPRESSION: return common.data_from_binary(binary_data, _read_expr, clbits=clbits, cregs=cregs or {}) @@ -504,7 +542,7 @@ def loads_value(type_key, binary_data, version, vectors, *, clbits=(), cregs=Non raise exceptions.QpyError(f"Serialization for {type_key} is not implemented in value I/O.") -def read_value(file_obj, version, vectors, *, clbits=(), cregs=None): +def read_value(file_obj, version, vectors, *, clbits=(), cregs=None, use_symengine=False): """Read a value from the file like object. Args: @@ -513,10 +551,16 @@ def read_value(file_obj, version, vectors, *, clbits=(), cregs=None): vectors (dict): ParameterVector in current scope. clbits (Sequence[Clbit]): Clbits in the current scope. cregs (Mapping[str, ClassicalRegister]): Classical registers in the current scope. + use_symengine (bool): If True, symbolic objects will be de-serialized using symengine's + native mechanism. This is a faster serialization alternative, but not supported in all + platforms. Please check that your target platform is supported by the symengine library + before setting this option, as it will be required by qpy to deserialize the payload. Returns: any: Deserialized value object. """ type_key, data = common.read_generic_typed_data(file_obj) - return loads_value(type_key, data, version, vectors, clbits=clbits, cregs=cregs) + return loads_value( + type_key, data, version, vectors, clbits=clbits, cregs=cregs, use_symengine=use_symengine + ) diff --git a/qiskit/qpy/common.py b/qiskit/qpy/common.py index 89eb1c7644b5..a8d615b28604 100644 --- a/qiskit/qpy/common.py +++ b/qiskit/qpy/common.py @@ -20,7 +20,7 @@ from qiskit.qpy import formats -QPY_VERSION = 9 +QPY_VERSION = 10 ENCODE = "utf8" diff --git a/qiskit/qpy/formats.py b/qiskit/qpy/formats.py index 64e34d9754a2..d3da8c5bed7e 100644 --- a/qiskit/qpy/formats.py +++ b/qiskit/qpy/formats.py @@ -18,6 +18,22 @@ from collections import namedtuple +# FILE_HEADER_V10 +FILE_HEADER_V10 = namedtuple( + "FILE_HEADER", + [ + "preface", + "qpy_version", + "major_version", + "minor_version", + "patch_version", + "num_programs", + "symbolic_encoding", + ], +) +FILE_HEADER_V10_PACK = "!6sBBBBQc" +FILE_HEADER_V10_SIZE = struct.calcsize(FILE_HEADER_V10_PACK) + # FILE_HEADER FILE_HEADER = namedtuple( "FILE_HEADER", diff --git a/qiskit/qpy/interface.py b/qiskit/qpy/interface.py index a22ce5f20db3..c50bce14c623 100644 --- a/qiskit/qpy/interface.py +++ b/qiskit/qpy/interface.py @@ -77,6 +77,7 @@ def dump( programs: Union[List[QPY_SUPPORTED_TYPES], QPY_SUPPORTED_TYPES], file_obj: BinaryIO, metadata_serializer: Optional[Type[JSONEncoder]] = None, + use_symengine: bool = False, ): """Write QPY binary data to a file @@ -122,7 +123,11 @@ def dump( metadata_serializer: An optional JSONEncoder class that will be passed the ``.metadata`` attribute for each program in ``programs`` and will be used as the ``cls`` kwarg on the `json.dump()`` call to JSON serialize that dictionary. - + use_symengine: If True, all objects containing symbolic expressions will be serialized + using symengine's native mechanism. This is a faster serialization alternative, + but not supported in all platforms. Please check that your target platform is supported + by the symengine library before setting this option, as it will be required by qpy to + deserialize the payload. For this reason, the option defaults to False. Raises: QpyError: When multiple data format is mixed in the output. TypeError: When invalid data type is input. @@ -152,20 +157,24 @@ def dump( version_match = VERSION_PATTERN_REGEX.search(__version__) version_parts = [int(x) for x in version_match.group("release").split(".")] + encoding = type_keys.SymExprEncoding.assign(use_symengine) header = struct.pack( - formats.FILE_HEADER_PACK, + formats.FILE_HEADER_V10_PACK, b"QISKIT", common.QPY_VERSION, version_parts[0], version_parts[1], version_parts[2], len(programs), + encoding, ) file_obj.write(header) common.write_type_key(file_obj, type_key) for program in programs: - writer(file_obj, program, metadata_serializer=metadata_serializer) + writer( + file_obj, program, metadata_serializer=metadata_serializer, use_symengine=use_symengine + ) def load( @@ -219,12 +228,26 @@ def load( QiskitError: if ``file_obj`` is not a valid QPY file TypeError: When invalid data type is loaded. """ - data = formats.FILE_HEADER._make( - struct.unpack( - formats.FILE_HEADER_PACK, - file_obj.read(formats.FILE_HEADER_SIZE), + + # identify file header version + version = struct.unpack("!6sB", file_obj.read(7))[1] + file_obj.seek(0) + + if version < 10: + data = formats.FILE_HEADER._make( + struct.unpack( + formats.FILE_HEADER_PACK, + file_obj.read(formats.FILE_HEADER_SIZE), + ) ) - ) + else: + data = formats.FILE_HEADER_V10._make( + struct.unpack( + formats.FILE_HEADER_V10_PACK, + file_obj.read(formats.FILE_HEADER_V10_SIZE), + ) + ) + if data.preface.decode(common.ENCODE) != "QISKIT": raise QiskitError("Input file is not a valid QPY file") version_match = VERSION_PATTERN_REGEX.search(__version__) @@ -263,6 +286,11 @@ def load( else: raise TypeError(f"Invalid payload format data kind '{type_key}'.") + if data.qpy_version < 10: + use_symengine = False + else: + use_symengine = data.symbolic_encoding == type_keys.SymExprEncoding.SYMENGINE + programs = [] for _ in range(data.num_programs): programs.append( @@ -270,6 +298,7 @@ def load( file_obj, data.qpy_version, metadata_deserializer=metadata_deserializer, + use_symengine=use_symengine, ) ) return programs diff --git a/qiskit/qpy/type_keys.py b/qiskit/qpy/type_keys.py index 1e394ec13a57..c87672e15cce 100644 --- a/qiskit/qpy/type_keys.py +++ b/qiskit/qpy/type_keys.py @@ -524,3 +524,21 @@ def assign(cls, obj): @classmethod def retrieve(cls, type_key): raise NotImplementedError + + +class SymExprEncoding(TypeKeyBase): + """Type keys for the symbolic encoding field in the file header.""" + + SYMPY = b"p" + SYMENGINE = b"e" + + @classmethod + def assign(cls, obj): + if obj is True: + return cls.SYMENGINE + else: + return cls.SYMPY + + @classmethod + def retrieve(cls, type_key): + raise NotImplementedError diff --git a/releasenotes/notes/add-qpy-symbolic-encoding-81d5321af38f259f.yaml b/releasenotes/notes/add-qpy-symbolic-encoding-81d5321af38f259f.yaml new file mode 100644 index 000000000000..6f0ccc990b07 --- /dev/null +++ b/releasenotes/notes/add-qpy-symbolic-encoding-81d5321af38f259f.yaml @@ -0,0 +1,31 @@ +--- +features: + - | + QPY supports the use of symengine-native serialization and deserialization + for objects of type ``ParameterExpression`` as well as symbolic expressions in + Pulse schedule blocks. This is a faster serialization alternative, but not + supported in all platforms. Please check that your target platform is supported + by the symengine library before setting this option, as it will be **required** + by qpy to deserialize the payload. + + The feature can be enabled through the ``use_symengine`` parameter + in :meth:`.qpy.dump`: + + .. code-block:: python + + from qiskit.circuit import QuantumCircuit, Parameter + from qiskit import qpy + + theta = Parameter("theta") + phi = Parameter("phi") + sum_param = theta + phi + + qc = QuantumCircuit(1) + qc.rz(sum_param, 0) + qc.measure_all() + + with open('bell.qpy', 'wb') as fd: + qpy.dump(qc, fd, use_symengine=True) + + with open('bell.qpy', 'rb') as fd: + new_qc = qpy.load(fd)[0] diff --git a/test/python/circuit/test_circuit_load_from_qpy.py b/test/python/circuit/test_circuit_load_from_qpy.py index 825e46a8ddbc..157b325050e6 100644 --- a/test/python/circuit/test_circuit_load_from_qpy.py +++ b/test/python/circuit/test_circuit_load_from_qpy.py @@ -11,11 +11,12 @@ # that they have been altered from the originals. -"""Test cases for the circuit qasm_file and qasm_string method.""" +"""Test cases for qpy serialization.""" import io import json import random +import unittest import ddt import numpy as np @@ -50,11 +51,13 @@ from qiskit.quantum_info import Pauli, SparsePauliOp from qiskit.quantum_info.random import random_unitary from qiskit.circuit.controlledgate import ControlledGate +from qiskit.utils import optionals +from qiskit.exceptions import MissingOptionalLibraryError @ddt.ddt class TestLoadFromQPY(QiskitTestCase): - """Test circuit.from_qasm_* set of methods.""" + """Test qpy set of methods.""" def assertDeprecatedBitProperties(self, original, roundtripped): """Test that deprecated bit attributes are equal if they are set in the original circuit.""" @@ -1659,3 +1662,73 @@ def test_qpy_deprecation(self): with self.assertWarnsRegex(DeprecationWarning, "is deprecated"): # pylint: disable=no-name-in-module, unused-import, redefined-outer-name, reimported from qiskit.circuit.qpy_serialization import dump, load + + +class TestSymengineLoadFromQPY(QiskitTestCase): + """Test use of symengine in qpy set of methods.""" + + def setUp(self): + super().setUp() + + theta = Parameter("theta") + phi = Parameter("phi") + sum_param = theta + phi + qc = QuantumCircuit(5, 1) + qc.h(0) + for i in range(4): + qc.cx(i, i + 1) + qc.barrier() + qc.rz(sum_param, range(3)) + qc.rz(phi, 3) + qc.rz(theta, 4) + qc.barrier() + for i in reversed(range(4)): + qc.cx(i, i + 1) + qc.h(0) + qc.measure(0, 0) + + self.qc = qc + + def assertDeprecatedBitProperties(self, original, roundtripped): + """Test that deprecated bit attributes are equal if they are set in the original circuit.""" + owned_qubits = [ + (a, b) for a, b in zip(original.qubits, roundtripped.qubits) if a._register is not None + ] + if owned_qubits: + original_qubits, roundtripped_qubits = zip(*owned_qubits) + self.assertEqual(original_qubits, roundtripped_qubits) + owned_clbits = [ + (a, b) for a, b in zip(original.clbits, roundtripped.clbits) if a._register is not None + ] + if owned_clbits: + original_clbits, roundtripped_clbits = zip(*owned_clbits) + self.assertEqual(original_clbits, roundtripped_clbits) + + @unittest.skipIf(not optionals.HAS_SYMENGINE, "Install symengine to run this test.") + def test_symengine_full_path(self): + """Test use_symengine option for circuit with parameter expressions.""" + qpy_file = io.BytesIO() + dump(self.qc, qpy_file, use_symengine=True) + qpy_file.seek(0) + new_circ = load(qpy_file)[0] + self.assertEqual(self.qc, new_circ) + self.assertDeprecatedBitProperties(self.qc, new_circ) + + @unittest.skipIf(not optionals.HAS_SYMENGINE, "Install symengine to run this test.") + def test_dump_no_symengine(self): + """Test dump fails if symengine is not installed and use_symengine==True.""" + qpy_file = io.BytesIO() + with optionals.HAS_SYMENGINE.disable_locally(): + with self.assertRaises(MissingOptionalLibraryError): + dump(self.qc, qpy_file, use_symengine=True) + + @unittest.skipIf(not optionals.HAS_SYMENGINE, "Install symengine to run this test.") + def test_load_no_symengine(self): + """Test that load fails if symengine is not installed and the + file was created with use_symengine==True.""" + qpy_file = io.BytesIO() + dump(self.qc, qpy_file, use_symengine=True) + qpy_file.seek(0) + with optionals.HAS_SYMENGINE.disable_locally(): + with self.assertRaises(MissingOptionalLibraryError): + _ = load(qpy_file)[0] diff --git a/test/python/qpy/test_block_load_from_qpy.py b/test/python/qpy/test_block_load_from_qpy.py index 06d8da19775d..e68ec48d4a00 100644 --- a/test/python/qpy/test_block_load_from_qpy.py +++ b/test/python/qpy/test_block_load_from_qpy.py @@ -13,6 +13,7 @@ """Test cases for the schedule block qpy loading and saving.""" import io +import unittest from ddt import ddt, data, unpack import numpy as np @@ -36,6 +37,7 @@ ) from qiskit.pulse.instructions import Play, TimeBlockade from qiskit.circuit import Parameter, QuantumCircuit, Gate +from qiskit.exceptions import MissingOptionalLibraryError from qiskit.test import QiskitTestCase from qiskit.qpy import dump, load from qiskit.utils import optionals as _optional @@ -401,3 +403,54 @@ def test_with_acquire_instruction_with_discriminator(self): qc.add_calibration("measure", (0,), sched) self.assert_roundtrip_equal(qc) + + +class TestSymengineLoadFromQPY(QiskitTestCase): + """Test use of symengine in qpy set of methods.""" + + def setUp(self): + super().setUp() + + # pylint: disable=invalid-name + t, amp, freq = sym.symbols("t, amp, freq") + sym_envelope = 2 * amp * (freq * t - sym.floor(1 / 2 + freq * t)) + + my_pulse = SymbolicPulse( + pulse_type="Sawtooth", + duration=100, + parameters={"amp": 0.1, "freq": 0.05}, + envelope=sym_envelope, + name="pulse1", + ) + with builder.build() as test_sched: + builder.play(my_pulse, DriveChannel(0)) + + self.test_sched = test_sched + + @unittest.skipIf(not _optional.HAS_SYMENGINE, "Install symengine to run this test.") + def test_symengine_full_path(self): + """Test use_symengine option for circuit with parameter expressions.""" + qpy_file = io.BytesIO() + dump(self.test_sched, qpy_file, use_symengine=True) + qpy_file.seek(0) + new_sched = load(qpy_file)[0] + self.assertEqual(self.test_sched, new_sched) + + @unittest.skipIf(not _optional.HAS_SYMENGINE, "Install symengine to run this test.") + def test_dump_no_symengine(self): + """Test dump fails if symengine is not installed and use_symengine==True.""" + qpy_file = io.BytesIO() + with _optional.HAS_SYMENGINE.disable_locally(): + with self.assertRaises(MissingOptionalLibraryError): + dump(self.test_sched, qpy_file, use_symengine=True) + + @unittest.skipIf(not _optional.HAS_SYMENGINE, "Install symengine to run this test.") + def test_load_no_symengine(self): + """Test that load fails if symengine is not installed and the + file was created with use_symengine==True.""" + qpy_file = io.BytesIO() + dump(self.test_sched, qpy_file, use_symengine=True) + qpy_file.seek(0) + with _optional.HAS_SYMENGINE.disable_locally(): + with self.assertRaises(MissingOptionalLibraryError): + _ = load(qpy_file)[0]