diff --git a/circuit.qpy b/circuit.qpy new file mode 100644 index 000000000000..ae72d271d09d Binary files /dev/null and b/circuit.qpy differ diff --git a/qiskit/qpy/__init__.py b/qiskit/qpy/__init__.py index d7c04ab5eeef..5824d11de1a9 100644 --- a/qiskit/qpy/__init__.py +++ b/qiskit/qpy/__init__.py @@ -102,7 +102,7 @@ serialization format for :class:`~qiskit.circuit.QuantumCircuit` objects in Qiskit. The basic file format is as follows: -A QPY file (or memory object) always starts with the following 7 +A QPY file (or memory object) always starts with the following 6 byte UTF8 string: ``QISKIT`` which is immediately followed by the overall file header. The contents of the file header as defined as a C struct are: @@ -116,6 +116,21 @@ uint64_t num_circuits; } + +From V10 on, a new field is added to the file header struct to represent the +encoding scheme used for symbolic expressions: + +.. code-block:: c + + struct { + uint8_t qpy_version; + uint8_t qiskit_major_version; + uint8_t qiskit_minor_version; + uint8_t qiskit_patch_version; + uint64_t num_circuits; + char symbolic_encoding; + } + All values use network byte order [#f1]_ (big endian) for cross platform compatibility. @@ -128,6 +143,34 @@ by ``num_circuits`` in the file header). There is no padding between the circuits in the data. +.. _qpy_version_10: + +Version 10 +========== + +Version 10 adds support for symengine-native serialization for objects of type +:class:`~.ParameterExpression` as well as symbolic expressions in Pulse schedule blocks. + +The symbolic_encoding field is added to the file header, and a new encoding type char +is introduced, mapped to each symbolic library as follows: ``p`` refers to sympy +encoding and ``e`` refers to symengine encoding. + +FILE_HEADER +----------- + +The contents of FILE_HEADER after V10 are defined as a C struct as: + +.. code-block:: c + + struct { + uint8_t qpy_version; + uint8_t qiskit_major_version; + uint8_t qiskit_minor_version; + uint8_t qiskit_patch_version; + uint64_t num_circuits; + char symbolic_encoding; + } + .. _qpy_version_9: diff --git a/qiskit/qpy/binary_io/circuits.py b/qiskit/qpy/binary_io/circuits.py index a50f202afa2a..b529bfd288c2 100644 --- a/qiskit/qpy/binary_io/circuits.py +++ b/qiskit/qpy/binary_io/circuits.py @@ -42,12 +42,14 @@ def _read_header_v2(file_obj, version, vectors, metadata_deserializer=None): + data = formats.CIRCUIT_HEADER_V2._make( struct.unpack( formats.CIRCUIT_HEADER_V2_PACK, file_obj.read(formats.CIRCUIT_HEADER_V2_SIZE), ) ) + name = file_obj.read(data.name_size).decode(common.ENCODE) global_phase = value.loads_value( data.global_phase_type, @@ -62,6 +64,7 @@ def _read_header_v2(file_obj, version, vectors, metadata_deserializer=None): "num_registers": data.num_registers, "num_instructions": data.num_instructions, } + metadata_raw = file_obj.read(data.metadata_size) metadata = json.loads(metadata_raw, cls=metadata_deserializer) return header, name, metadata @@ -124,7 +127,9 @@ def _read_registers(file_obj, num_registers): return registers -def _loads_instruction_parameter(type_key, data_bytes, version, vectors, registers, circuit): +def _loads_instruction_parameter( + type_key, data_bytes, version, vectors, registers, circuit, use_symengine +): if type_key == type_keys.Program.CIRCUIT: param = common.data_from_binary(data_bytes, read_circuit, version=version) elif type_key == type_keys.Container.RANGE: @@ -139,6 +144,7 @@ def _loads_instruction_parameter(type_key, data_bytes, version, vectors, registe vectors=vectors, registers=registers, circuit=circuit, + use_symengine=use_symengine, ) ) elif type_key == type_keys.Value.INTEGER: @@ -151,7 +157,13 @@ def _loads_instruction_parameter(type_key, data_bytes, version, vectors, registe param = _loads_register_param(data_bytes.decode(common.ENCODE), circuit, registers) else: param = value.loads_value( - type_key, data_bytes, version, vectors, clbits=circuit.clbits, cregs=registers["c"] + type_key, + data_bytes, + version, + vectors, + clbits=circuit.clbits, + cregs=registers["c"], + use_symengine=use_symengine, ) return param @@ -165,7 +177,9 @@ def _loads_register_param(data_bytes, circuit, registers): return registers["c"][data_bytes] -def _read_instruction(file_obj, circuit, registers, custom_operations, version, vectors): +def _read_instruction( + file_obj, circuit, registers, custom_operations, version, vectors, use_symengine +): if version < 5: instruction = formats.CIRCUIT_INSTRUCTION._make( struct.unpack( @@ -196,7 +210,12 @@ def _read_instruction(file_obj, circuit, registers, custom_operations, version, ) elif version >= 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)) @@ -232,14 +251,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: @@ -250,7 +269,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: @@ -317,7 +336,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, @@ -346,7 +367,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. @@ -497,7 +518,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) @@ -507,7 +528,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. @@ -521,13 +542,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 ( @@ -603,7 +626,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] @@ -616,7 +639,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 @@ -661,7 +684,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 @@ -700,7 +723,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) @@ -915,7 +942,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: @@ -925,6 +952,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 @@ -964,7 +995,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()) @@ -975,7 +1008,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 ) ) @@ -990,7 +1023,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: @@ -1003,7 +1036,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. @@ -1023,7 +1060,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 = [] @@ -1089,7 +1126,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 2e07157b8898..e0964d523f6f 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 @@ -49,11 +50,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.""" @@ -1638,3 +1641,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]