Examples#

In this section, we show a basic example on how to use the cuPauliProp API for an end-to-end quantum circuit simulation.

Building code#

Assuming your cuPauliProp header files are located in CUPAULIPROP_INCLUDE_DIR and the shared library is located in CUPAULIPROP_LIB_DIR, the example codes can be built via the following commands:

nvcc kicked_ising_example.cpp -I${CUPAULIPROP_INCLUDE_DIR} -L${CUPAULIPROP_LIB_DIR} -lcupauliprop -o kicked_ising_example
nvcc kicked_ising_fused_example.cpp -I${CUPAULIPROP_INCLUDE_DIR} -L${CUPAULIPROP_LIB_DIR} -lcupauliprop -o kicked_ising_fused_example
nvcc kicked_ising_backward_diff_example.cpp -I${CUPAULIPROP_INCLUDE_DIR} -L${CUPAULIPROP_LIB_DIR} -lcupauliprop -o kicked_ising_backward_diff_example
nvcc fused_operators_example.cpp example_utils.cpp -I${CUPAULIPROP_INCLUDE_DIR} -L${CUPAULIPROP_LIB_DIR} -lcupauliprop -o fused_operators_example

When building statically, use the following commands:

nvcc kicked_ising_example.cpp -I${CUPAULIPROP_INCLUDE_DIR} ${CUPAULIPROP_LIB_DIR}/libcupauliprop_static.a -o kicked_ising_example
nvcc kicked_ising_fused_example.cpp -I${CUPAULIPROP_INCLUDE_DIR} ${CUPAULIPROP_LIB_DIR}/libcupauliprop_static.a -o kicked_ising_fused_example
nvcc kicked_ising_backward_diff_example.cpp -I${CUPAULIPROP_INCLUDE_DIR} ${CUPAULIPROP_LIB_DIR}/libcupauliprop_static.a -o kicked_ising_backward_diff_example
nvcc fused_operators_example.cpp example_utils.cpp -I${CUPAULIPROP_INCLUDE_DIR} ${CUPAULIPROP_LIB_DIR}/libcupauliprop_static.a -o fused_operators_example

When using cuQuantum downloaded from the NVIDIA devzone website and extracted in CUQUANTUM_ROOT, the header files are located in ${CUQUANTUM_ROOT}/include and the shared library is located in ${CUQUANTUM_ROOT}/lib. For convenience, we also provide a Makefile and CMakeLists.txt file in the same folder as the example code.

Kicked Ising example#

In this example, we demonstrate use of the cuPauliProp API to perform an end-to-end simulation of IBM’s 127 qubit kicked Ising experiment, as presented in Nature volume 618, pages 500–505 (2023). We will specifically simulate a single datum of Figure 4 b, which is the expectation value of observable \(Z_{62}\) after a 20-repetition Trotter circuit. The full demonstration is available at kicked_ising_example.cpp.

Our example code will make use of the below helper macros which handle errors thrown by the CUDA or cuPauliProp APIs.

28// ========================================================================
29// CUDA and cuPauliProp error handling
30// ========================================================================
31
32#define HANDLE_CUPP_ERROR(x)                               \
33{                                                          \
34  const auto err = x;                                      \
35  if (err != CUPAULIPROP_STATUS_SUCCESS)                   \
36  {                                                        \
37    printf("cuPauliProp error in line %d\n", __LINE__);    \
38    fflush(stdout);                                        \
39    std::abort();                                          \
40  }                                                        \
41};
42
43
44#define HANDLE_CUDA_ERROR(x)                                \
45{                                                           \
46  const auto err = x;                                       \
47  if (err != cudaSuccess)                                   \
48  {                                                         \
49    const char * error = cudaGetErrorString(err);           \
50    printf("CUDA Error: %s in line %d\n", error, __LINE__); \
51    fflush(stdout);                                         \
52    std::abort();                                           \
53  }                                                         \
54};

We next decide the memory budget for our simulation. It will turn out that for our chosen combination of simulated circuit, output observable and truncation parameters, only around 60 MiB of total device memory is needed. We will demonstrate how to dedicate all available device memory for simulation however, which this example optionally performs when USE_MAX_VRAM = true.

58// ========================================================================
59// Memory usage
60// ========================================================================
61
62// Each Pauli expansion has two pre-allocated GPU buffers, storing packed
63// integers (which encode Pauli strings) and corresponding coefficients.
64// As much memory can be dedicated as your hardware allows, while the min-
65// imum required is specific and very sensitive to the simulated circuit,
66// studied observable, and the chosen truncation hyperparameters.
67// Some operations also require additional workspace memory which is also
68// ideally pre-allocated, and can be established using the API 'Prepare'
69// functions (e.g. cupaulipropPauliExpansionViewPrepareTraceWithZeroState).
70// In this demo, we dedicate either (a percentage of) the entirety of GPU
71// memory uniformly between the required memory buffers, or instead use a
72// fixed hardcoded amount which has been prior tested to be consistent with
73// our other simulation parameters (like truncations); these choices are
74// toggled via USE_MAX_VRAM below.
75
76// =true to use MAX_VRAM_PERCENT of VRAM, and =false to use fixed memories below
77bool USE_MAX_VRAM = false;
78double MAX_VRAM_PERCENT = 90; // 0-100%
79
80size_t FIXED_EXPANSION_PAULI_MEM = 16 * (1LLU << 20); // bytes = 16 MiB
81size_t FIXED_EXPANSION_COEF_MEM  =  4 * (1LLU << 20); // bytes = 4  MiB
82size_t FIXED_WORKSPACE_MEM       = 20 * (1LLU << 20); // bytes = 20 MiB

We here scaffold the quantum circuit we will subsequently instantiate. In short, it is a Trotter circuit encoding the dynamics of a kicked Ising system, with a heavy-hex topology matching the connectivity of IBM’s 127 qubit Eagle device.

 86// ========================================================================
 87// Circuit preparation (Trotterised Ising on IBM heavy hex topology)
 88// ========================================================================
 89
 90// This demo simulates the circuits experimentally executed by IBM in article
 91// 'Nature volume 618, pages 500–505 (2023)'. This is a circuit Trotterising
 92// the evolution operator of a 2D transverse-field Ising model, but where the
 93// prescribed ZZ rotations have a fixed angle of -pi/2, and where the X angles
 94// are arbitrarily set/swept; later, we will fix the X angle to be pi/4. The
 95// Hamiltonian ZZ interactions are confined to a heavy-hex topology, matching
 96// the connectivity of the IBM Eagle processor 'ibm_kyiv', as we fix below.
 97
 98const int NUM_CIRCUIT_QUBITS = 127;
 99const int NUM_ROTATIONS_PER_LAYER = 48;
100const int NUM_PAULIS_PER_X_ROTATION = 1;
101const int NUM_PAULIS_PER_Z_ROTATION = 2;
102
103const double PI = 3.14159265358979323846;
104const double ZZ_ROTATION_ANGLE = - PI / 2.0;
105
106// Indices of ZZ-interacting qubits which undergo the first (red) Trotter round
107const int32_t ZZ_QUBITS_RED[NUM_ROTATIONS_PER_LAYER][NUM_PAULIS_PER_Z_ROTATION] = {
108  {  2,   1},  { 33,  39}, { 59,  60}, { 66,  67}, { 72,  81}, {118, 119},
109  { 21,  20},  { 26,  25}, { 13,  12}, { 31,  32}, { 70,  74}, {122, 123},
110  { 96,  97},  { 57,  56}, { 63,  64}, {107, 108}, {103, 104}, { 46,  45},
111  { 28,  35},  {  7,   6}, { 79,  78}, {  5,   4}, {109, 114}, { 62,  61},
112  { 58,  71},  { 37,  52}, { 76,  77}, {  0,  14}, { 36,  51}, {106, 105},
113  { 73,  85},  { 88,  87}, { 68,  55}, {116, 115}, { 94,  95}, {100, 110},
114  { 17,  30},  { 92, 102}, { 50,  49}, { 83,  84}, { 48,  47}, { 98,  99},
115  {  8,   9},  {121, 120}, { 23,  24}, { 44,  43}, { 22,  15}, { 53,  41}
116};
117
118// Indices of ZZ-interacting qubits which undergo the second (blue) Trotter round
119const int32_t ZZ_QUBITS_BLUE[NUM_ROTATIONS_PER_LAYER][NUM_PAULIS_PER_Z_ROTATION] = {
120  { 53,  60}, {123, 124}, { 21,  22}, { 11,  12}, { 67,  68}, {  2,   3},
121  { 66,  65}, {122, 121}, {110, 118}, {  6,   5}, { 94,  90}, { 28,  29},
122  { 14,  18}, { 63,  62}, {111, 104}, {100,  99}, { 45,  44}, {  4,  15},
123  { 20,  19}, { 57,  58}, { 77,  71}, { 76,  75}, { 26,  27}, { 16,   8},
124  { 35,  47}, { 31,  30}, { 48,  49}, { 69,  70}, {125, 126}, { 89,  74},
125  { 80,  79}, {116, 117}, {114, 113}, { 10,   9}, {106,  93}, {101, 102},
126  { 92,  83}, { 98,  91}, { 82,  81}, { 54,  64}, { 96, 109}, { 85,  84},
127  { 87,  86}, {108, 112}, { 34,  24}, { 42,  43}, { 40,  41}, { 39,  38}
128};
129
130// Indices of ZZ-interacting qubits which undergo the third (green) Trotter round
131const int32_t ZZ_QUBITS_GREEN[NUM_ROTATIONS_PER_LAYER][NUM_PAULIS_PER_Z_ROTATION] = {
132  { 10,  11}, { 54,  45}, {111, 122}, { 64,  65}, { 60,  61}, {103, 102},
133  { 72,  62}, {  4,   3}, { 33,  20}, { 58,  59}, { 26,  16}, { 28,  27},
134  {  8,   7}, {104, 105}, { 73,  66}, { 87,  93}, { 85,  86}, { 55,  49},
135  { 68,  69}, { 89,  88}, { 80,  81}, {117, 118}, {101, 100}, {114, 115},
136  { 96,  95}, { 29,  30}, {106, 107}, { 83,  82}, { 91,  79}, {  0,   1},
137  { 56,  52}, { 90,  75}, {126, 112}, { 36,  32}, { 46,  47}, { 77,  78},
138  { 97,  98}, { 17,  12}, {119, 120}, { 22,  23}, { 24,  25}, { 43,  34},
139  { 42,  41}, { 40,  39}, { 37,  38}, {125, 124}, { 50,  51}, { 18,  19}
140};

We instantiate the circuit as a std::vector of cupaulipropQuantumOperator_t types. In this demonstration, all circuit gates are Pauli rotations of 1 or 2 qubits, chiefly

\[\begin{split}Rx(\theta_X) = \exp\left(-i\theta_X/2 \, \hat{X}\right), \\ Rzz(\theta_Z) = \exp\left(-i\theta_Z/2 \, \hat{Z}\otimes\hat{Z}\right).\end{split}\]

We fix \(\theta_Z\) to be \(-\pi/2\), and will later substitute a value for the X-rotation strength \(\theta_X\).

144// ========================================================================
145// Circuit construction
146// ========================================================================
147
148// Each 'step' of the Trotter circuit alternates a layer of single-qubit X
149// rotations on every qubit, then a sequence of two-qubit Z rotations on the
150// heavy-hex topology, upon qubit pairs in the red, blue and green lists 
151// above. Note that ZZ rotations about -pi/2 are actually Clifford, though
152// we still here treat them like a generic Pauli rotation. The functions
153// below construct a Trotter circuit with a variable number of steps.
154
155
156std::vector<cupaulipropQuantumOperator_t> getXRotationLayer(
157  cupaulipropHandle_t handle, double xRotationAngle
158) {  
159  std::vector<cupaulipropQuantumOperator_t> layer(NUM_CIRCUIT_QUBITS);
160
161  const cupaulipropPauliKind_t paulis[NUM_PAULIS_PER_X_ROTATION] = {CUPAULIPROP_PAULI_X};
162
163  for (int32_t i=0; i<NUM_CIRCUIT_QUBITS; i++) {
164    HANDLE_CUPP_ERROR(cupaulipropCreatePauliRotationGateOperator(
165      handle, xRotationAngle, NUM_PAULIS_PER_X_ROTATION, &i, paulis, &layer[i]));
166  }
167
168  return layer;
169}
170
171
172std::vector<cupaulipropQuantumOperator_t> getZZRotationLayer(
173  cupaulipropHandle_t handle,
174  const int32_t topology[NUM_ROTATIONS_PER_LAYER][NUM_PAULIS_PER_Z_ROTATION]
175) {
176  std::vector<cupaulipropQuantumOperator_t> layer(NUM_ROTATIONS_PER_LAYER);
177
178  const cupaulipropPauliKind_t paulis[NUM_PAULIS_PER_Z_ROTATION] = {
179    CUPAULIPROP_PAULI_Z, CUPAULIPROP_PAULI_Z};
180
181  for (uint32_t i=0; i<NUM_ROTATIONS_PER_LAYER; i++) {
182    HANDLE_CUPP_ERROR(cupaulipropCreatePauliRotationGateOperator(
183      handle, ZZ_ROTATION_ANGLE, NUM_PAULIS_PER_Z_ROTATION, topology[i], paulis, &layer[i]));
184  }
185
186  return layer;
187}
188
189
190std::vector<cupaulipropQuantumOperator_t> getIBMHeavyHexIsingCircuit(
191  cupaulipropHandle_t handle, double xRotationAngle, int numTrotterSteps
192) {
193  std::vector<cupaulipropQuantumOperator_t> circuit;
194
195  for (int n=0; n<numTrotterSteps; n++) {
196    auto layerX       = getXRotationLayer (handle, xRotationAngle);
197    auto layerRedZZ   = getZZRotationLayer(handle, ZZ_QUBITS_RED);
198    auto layerBlueZZ  = getZZRotationLayer(handle, ZZ_QUBITS_BLUE);
199    auto layerGreenZZ = getZZRotationLayer(handle, ZZ_QUBITS_GREEN);
200    
201    circuit.insert(circuit.end(), layerX.begin(),       layerX.end());
202    circuit.insert(circuit.end(), layerRedZZ.begin(),   layerRedZZ.end());
203    circuit.insert(circuit.end(), layerBlueZZ.begin(),  layerBlueZZ.end());
204    circuit.insert(circuit.end(), layerGreenZZ.begin(), layerGreenZZ.end());
205  }
206
207  return circuit;
208}

This demonstration will simulate the circuit in the Heisenberg picture. As such, our input Pauli expansion will be initialized to an experimentally measured observable operator. We’ll later choose observable \(Z_{62}\), but we here define a function which encodes any Pauli string as a sequence of “packed integers” as accepted by the cuPauliProp API.

212// ========================================================================
213// Observable preparation
214// ========================================================================
215
216// This demo simulates the IBM circuit via back-propagating the measurement
217// observable through the adjoint circuit. As such, we encode the measured
218// observable into our initial Pauli expansion, in the format recognised by
219// cuPauliProp. Pauli strings are represented with "packed integers" wherein
220// every bit encodes a Pauli operator upon a corresponding qubit. We maintain
221// two masks which separately encode the position of X and Z Pauli operators,
222// indicated by a set bit at the qubit index, with a common set bit encoding
223// a Y Pauli operator. Simulating more qubits than exist bits in the packed
224// integer type (64) requires using multiple packed integers for each X and Z
225// mask. We store a Pauli string's constituent X and Z masks contiguously in
226// a single array, where the final mask of each per-string is padded with zero
227// bits to be an integer multiple of the packed integer size (64 bits).
228
229// The below function accepts a single Pauli string (i.e. a tensor product of
230// the given Pauli operators at the specified qubit indices) and returns the
231// sequence of packed integers which encode it as per the cuPauliProp API;
232// this sequence can be copied directly to the GPU buffer of a Pauli expansion.
233
234std::vector<cupaulipropPackedIntegerType_t> getPauliStringAsPackedIntegers(
235  std::vector<cupaulipropPauliKind_t> paulis, 
236  std::vector<uint32_t> qubits
237) {
238  assert(paulis.size() == qubits.size());
239  assert(*std::max_element(qubits.begin(), qubits.end()) < NUM_CIRCUIT_QUBITS);
240
241  int32_t numPackedInts;
242  HANDLE_CUPP_ERROR(cupaulipropGetNumPackedIntegers(NUM_CIRCUIT_QUBITS, &numPackedInts));
243
244  // A single Pauli string is composed of separate X and Z masks, one after the other
245  std::vector<cupaulipropPackedIntegerType_t> out(numPackedInts * 2, 0);
246  auto xPtr = &out[0];
247  auto zPtr = &out[numPackedInts];
248
249  // Process one input (pauli, qubit) pair at a time
250  for (auto i=0; i<qubits.size(); i++) {
251
252    // The qubit corresponds to a specific bit of a specific packed integer
253    auto numBitsPerPackedInt = 8 * sizeof(cupaulipropPackedIntegerType_t);
254    auto intInd = qubits[i] / numBitsPerPackedInt;
255    auto bitInd = qubits[i] % numBitsPerPackedInt;
256
257    // Overwrite a bit of either the X or Z masks (or both when pauli==Y)
258    if (paulis[i] == CUPAULIPROP_PAULI_X || paulis[i] == CUPAULIPROP_PAULI_Y)
259      xPtr[intInd] = xPtr[intInd] | (1ULL << bitInd);
260    if (paulis[i] == CUPAULIPROP_PAULI_Z || paulis[i] == CUPAULIPROP_PAULI_Y)
261      zPtr[intInd] = zPtr[intInd] | (1ULL << bitInd);
262  }
263
264  return out;
265}

With all convenience functions defined, we are ready to begin the main control flow.

269// ========================================================================
270// Main
271// ========================================================================
272
273// Simulation of the IBM utility experiment proceeds as follows. We setup the
274// cuPauliProp library, attaching a new stream, then proceed to creating two
275// Pauli expansions (since the API is out-of-place, as elaborated upon below).
276// One expansion is initialised to the measured observable of the IBM circuit.
277// We prepare workspace memory, fix truncation hyperparameters, then create
278// the circuit as a list of cuPauliProp operators. We process the circuit in
279// reverse, adjointing each operation, applied upon a newly prepared view of
280// the input expansion, each time checking our dynamically growing memory costs
281// have not exceeded our budgets. Thereafter we compute the overlap between the
282// final back-propagated observable and the experimental initial state (the all-
283// zero state), producing an estimate of the experimental expectation value.
284// Finally, we free all allocated memory like good citizens.
285
286
287int main(int argc, char** argv) {
288  std::cout << "cuPauliProp IBM Heavy-hex Ising Example" << std::endl;
289  std::cout << "=======================================" << std::endl << std::endl;

Our first chore is to setup the cuPauliProp library. For simplicity, we choose not to attach any stream, and therefore use the default stream.

293  // ========================================================================
294  // Library setup
295  // ========================================================================
296
297  int deviceId = 0;
298  HANDLE_CUDA_ERROR(cudaSetDevice(deviceId));
299
300  // cuPauliProp operations accept a cudaStream_t argument for asynchronous usage
301  // We use the default stream (0) in this example
302  cudaStream_t stream = 0;
303  cupaulipropHandle_t handle;
304  HANDLE_CUPP_ERROR(cupaulipropCreate(&handle));

We here choose to use either our pre-decided memory budget, or optionally consult the available device memory and distribute it between the needed data structures. In total, we will reserve memory for two Pauli expansions (each containing a separate Pauli string and coefficient buffer), and a workspace buffer. The needed workspace buffer size is informed by both the capacity of the expansions and the API operations to be later performed. While it can be precomputed using the API “prepare” functions, such as cupaulipropPauliExpansionViewPrepareOperatorApplication(), we here merely reserve as much memory for the workspace as needed by a Pauli expansion, for simplicity.

308  // ========================================================================
309  // Decide memory usage
310  // ========================================================================
311
312  // As outlined in the 'Memory usage' section above, we here either uniformly
313  // allocate all (or a high percentage of) available memory between the needed
314  // memory buffers, or use the pre-decided fixed values. This demo will create
315  // a total of two Pauli expansions (each of which accepts two separate buffers
316  // to store Pauli strings and their corresponding coefficients; these have
317  // different sizes) and one workspace, hence we arrange for an allocation of
318  // five buffers in total.
319
320  size_t expansionPauliMem;
321  size_t expansionCoefMem;
322  size_t workspaceMem;
323  size_t totalUsedMem;
324
325  if (USE_MAX_VRAM) {
326
327    // Find usable device memory
328    size_t totalFreeMem, totalGlobalMem;
329    HANDLE_CUDA_ERROR(cudaMemGetInfo(&totalFreeMem, &totalGlobalMem));
330    size_t totalUsableMem = static_cast<size_t>(totalFreeMem * MAX_VRAM_PERCENT/100);
331
332    // Divide it between the three instances (two expansions, one workspace)
333    size_t instanceMem = totalUsableMem / 3;
334
335    // Determine the ideal ratio between an expansion's Pauli and coef buffers
336    int32_t numPackedInts;
337    HANDLE_CUPP_ERROR(cupaulipropGetNumPackedIntegers(NUM_CIRCUIT_QUBITS, &numPackedInts));
338    size_t pauliMemPerTerm = 2 * numPackedInts * sizeof(cupaulipropPackedIntegerType_t);
339    size_t coefMemPerTerm = sizeof(double);
340    size_t totalMemPerTerm = pauliMemPerTerm + coefMemPerTerm;
341
342    expansionPauliMem = (instanceMem * pauliMemPerTerm) / totalMemPerTerm;
343    expansionCoefMem  = (instanceMem * coefMemPerTerm ) / totalMemPerTerm;
344    workspaceMem      = instanceMem;
345
346    totalUsedMem = 2*expansionPauliMem + 2*expansionCoefMem + workspaceMem;
347    std::cout << "Dedicated memory: " << MAX_VRAM_PERCENT << "% of " << totalFreeMem;
348    std::cout << " B free = " << totalUsedMem << " B, divided into..." << std::endl;
349
350  } else {
351
352    // Use pre-decided buffer sizes
353    expansionPauliMem = FIXED_EXPANSION_PAULI_MEM;
354    expansionCoefMem  = FIXED_EXPANSION_COEF_MEM;
355    workspaceMem      = FIXED_WORKSPACE_MEM;
356
357    totalUsedMem = 2*expansionPauliMem + 2*expansionCoefMem + workspaceMem;
358    std::cout << "Dedicated memory: " << totalUsedMem << " B = 60 MiB, divided into..." << std::endl;
359  }
360
361  std::cout << "  expansion Pauli buffer: " << expansionPauliMem << " B" << std::endl;
362  std::cout << "  expansion coef buffer:  " << expansionCoefMem << " B" << std::endl;
363  std::cout << "  workspace buffer:       " << workspaceMem << " B\n" << std::endl;

It is our responsibility to allocate and initialise the device memory before passing it to the cuPauliProp Pauli expansion constructor. We create an “input” Pauli expansion initialised to the single Pauli string \(Z_{62}\), and an “output” Pauli expansion which is initially empty. Notice that we indicate to cupaulipropCreatePauliExpansion() that the pre-initialised input strings (of which there is one) happen to be sorted and unique, which cuPauliProp can internally later leverage for potential speedup.

367  // ========================================================================
368  // Pauli expansion preparation
369  // ========================================================================
370
371  // Create buffers for two Pauli expansions, which will serve as 'input' and
372  // 'output' to the out-of-place cuPauliProp API. Note that the capacities of
373  // these buffers constrain the maximum number of Pauli strings maintained
374  // during simulation, and ergo inform the accuracy of the simulation. The
375  // sufficient buffer sizes are specific to the simulated system, and we here
376  // choose a surprisingly small capacity as admitted by the studied circuit.
377
378  void * d_inExpansionPauliBuffer;
379  void * d_outExpansionPauliBuffer;
380  void * d_inExpansionCoefBuffer;
381  void * d_outExpansionCoefBuffer;
382  HANDLE_CUDA_ERROR(cudaMalloc(&d_inExpansionPauliBuffer,  expansionPauliMem));
383  HANDLE_CUDA_ERROR(cudaMalloc(&d_inExpansionCoefBuffer,   expansionCoefMem));
384  HANDLE_CUDA_ERROR(cudaMalloc(&d_outExpansionPauliBuffer, expansionPauliMem));
385  HANDLE_CUDA_ERROR(cudaMalloc(&d_outExpansionCoefBuffer,  expansionCoefMem));
386
387  // Prepare the X and Z masks which encode the experimental observable Z_62,
388  // which has a coefficient of unity, as seen in Figure 4. b) of the IBM work.
389  std::cout << "Observable: Z_62\n" << std::endl;
390  int64_t numObservableTerms = 1;
391  double observableCoef = 1.0;
392  std::vector<cupaulipropPauliKind_t> observablePaulis = {CUPAULIPROP_PAULI_Z};
393  std::vector<uint32_t> observableQubits = {62};
394
395  // Overwrite the 'input' Pauli expansion buffers with the observable data
396  auto observablePackedInts = getPauliStringAsPackedIntegers(observablePaulis, observableQubits);
397  size_t numObservableBytes = observablePackedInts.size() * sizeof(observablePackedInts[0]);
398  HANDLE_CUDA_ERROR(cudaMemcpy(
399    d_inExpansionPauliBuffer, observablePackedInts.data(), numObservableBytes, cudaMemcpyHostToDevice));
400  HANDLE_CUDA_ERROR(cudaMemcpy(
401    d_inExpansionCoefBuffer, &observableCoef, sizeof(observableCoef), cudaMemcpyHostToDevice));
402
403  // Create two Pauli expansions, which will serve as 'input and 'output' to the API.
404  // Because we begin from a real observable coefficient, and our circuit is completely
405  // positive and trace preserving, it is sufficient to use strictly real coefficients
406  // in our expansions, informing dataType below. We indicate that the single prepared
407  // term in the input expansion is technically unique, and the terms sorted, which
408  // permits cuPauliProp to use automatic optimisations during simulation.
409
410  cupaulipropPauliExpansion_t inExpansion;
411  cupaulipropPauliExpansion_t outExpansion;
412
413  cupaulipropSortOrder_t sortOrder = CUPAULIPROP_SORT_ORDER_NONE;
414  int32_t hasDuplicates = 0;  // isUnique = !hasDuplicates
415  cudaDataType_t dataType = CUDA_R_64F;
416
417  HANDLE_CUPP_ERROR(cupaulipropCreatePauliExpansion( // init to above
418    handle, NUM_CIRCUIT_QUBITS,
419    d_inExpansionPauliBuffer, expansionPauliMem,
420    d_inExpansionCoefBuffer,  expansionCoefMem,
421    dataType, numObservableTerms, sortOrder, hasDuplicates, 
422    &inExpansion));
423  HANDLE_CUPP_ERROR(cupaulipropCreatePauliExpansion( // init to empty
424    handle, NUM_CIRCUIT_QUBITS,
425    d_outExpansionPauliBuffer, expansionPauliMem,
426    d_outExpansionCoefBuffer,  expansionCoefMem,
427    dataType, 0, CUPAULIPROP_SORT_ORDER_NONE, 0,
428    &outExpansion));
429
430  

We must also ourselves allocate device memory for the workspace.

432  // ========================================================================
433  // Workspace preparation
434  // ========================================================================
435
436  // Some API functions require additional workspace memory which we bind to a
437  // workspace descriptor. Ordinarily we use the 'Prepare' functions to precisely
438  // bound upfront the needed workspace memory, but in this simple demo, we
439  // instead use a workspace memory which we prior know to be sufficient.
440
441  // Create a workspace
442  cupaulipropWorkspaceDescriptor_t workspace;
443  HANDLE_CUPP_ERROR(cupaulipropCreateWorkspaceDescriptor(handle, &workspace));
444
445  void* d_workspaceBuffer;
446  HANDLE_CUDA_ERROR(cudaMalloc(&d_workspaceBuffer, workspaceMem));
447  HANDLE_CUPP_ERROR(cupaulipropWorkspaceSetMemory(
448    handle, workspace,
449    CUPAULIPROP_MEMSPACE_DEVICE, CUPAULIPROP_WORKSPACE_SCRATCH,
450    d_workspaceBuffer, workspaceMem));
451  
452  // Note that the 'prepare' functions which check the required workspace memory
453  // will detach this buffer, requiring we re-call SetMemory above. We can avoid
454  // this repeated re-attachment by use of a second, bufferless workspace descri-
455  // ptor which we pass to the 'prepare' functions in lieu of this one.

During subsequent Heisenberg evolution of the observable operator \(Z_{62}\), the number of terms in the Pauli expansion will grow exponentially quickly. To mitigate this growth and keep simulation tractable through the entire circuit, we employ truncation strategies. For the simulated system, it is sufficient to perform truncation after every \(10\) gates, wherein we discard all Pauli strings with corresponding coefficient magnitudes smaller or equal to \(10^{-4}\), or which have a Pauli “weight” (the number of non-identity Paulis therein) exceeding \(8\). This strategy keeps total memory costs below 60 MiB (and so within the capacities of our Pauli expansion and workspace buffers), while ultimately producing an expectation value within the error bars of the error-mitigated experimental value.

459  // ========================================================================
460  // Truncation parameter preparation
461  // ========================================================================
462
463  // The Pauli propagation simulation technique has memory and runtime costs which
464  // (for generic circuits) grow exponentially with the circuit length, which we
465  // curtail through "truncation"; dynamic discarding of Pauli strings in our
466  // expansion which are predicted to contribute negligibly to the final output
467  // expectation value. This file demonstrates simultaneous usage of two truncation
468  // techniques; discarding of Pauli strings with an absolute coefficient less than
469  // 0.0001, or a "Pauli weight" (the number of non-identity operators in the string)
470  // exceeding eight. For example, given a Pauli expansion containing:
471  //    0.1 XYZXYZII + 1E-5 ZIIIIIII + 0.2 XXXXYYYY,
472  // our truncation parameters below would see the latter two strings discarded due
473  // to coefficient and weight truncation respectively.
474
475  cupaulipropCoefficientTruncationParams_t coefTruncParams;
476  coefTruncParams.cutoff = 1E-4;
477
478  cupaulipropPauliWeightTruncationParams_t weightTruncParams;
479  weightTruncParams.cutoff = 8;
480
481  const uint32_t numTruncStrats = 2;
482  cupaulipropTruncationStrategy_t truncStrats[] = {
483    {
484      CUPAULIPROP_TRUNCATION_STRATEGY_COEFFICIENT_BASED,
485      &coefTruncParams
486    },
487    {
488      CUPAULIPROP_TRUNCATION_STRATEGY_PAULI_WEIGHT_BASED,
489      &weightTruncParams
490    }
491  };
492
493  // It is not necessary to perform truncation after every gate, since the
494  // Pauli expansion size may not have grown substantially, and attempting
495  // to truncate may incur superfluous memory enumeration costs. In this
496  // demo, we choose to truncate only after every tenth applied gate. Note
497  // deferring truncation requires additional expansion memory; choosing to
498  // truncate after every gate shrinks this demo's costs to 20 MiB total.
499  const int numGatesBetweenTruncations = 10;
500
501  std::cout << "Coefficient truncation threshold:  " << coefTruncParams.cutoff << std::endl;
502  std::cout << "Pauli weight truncation threshold: " << weightTruncParams.cutoff << std::endl;
503  std::cout << "Truncation performed after every:  " << numGatesBetweenTruncations << " gates\n" << std::endl;

With the stage set, we are ready to begin simulation! We fix the angle of all X-rotation gates in our circuit to \(\theta_X = \pi/4\), and use \(20\) Trotter repetitions. Our simulated system therefore corresponds to the antepenultimate experimental datum in Figure 4. b of Nature volume 618, pages 500–505 (2023). Let operator \(\hat{U}\) represent the action of this full, forward experimental circuit. The original experimentalists performed \(\hat{U}\) upon the fiducial zero-state \(|0\rangle^{\otimes 127}\), and estimated the expectation value of observable \(Z_{62}\) through repeated sampling.

In contrast, we will here work in the Heisenberg picture, and simulate the adjoint (ergo reversed) circuit upon the observable operator \(Z_{62}\). For each gate, we create a view of the entire input Pauli expansion; we confirm that the output Pauli expansion has sufficient capacity to store the (worst case) result of applying the gate upon the view; we reattach the workspace buffer which is detached by that query; we apply the gate and overwrite the output Pauli expansion; and finally, we swap the input and output Pauli expansions so that the updated expansion becomes the next input.

507  // ========================================================================
508  // Back-propagation of the observable through the circuit
509  // ========================================================================
510
511  // We now simulate the observable operator being back-propagated through the
512  // adjoint circuit, mapping the input expansion (initialised to Z_62) to a
513  // final output expansion containing many weighted Pauli strings. We use the
514  // heavy-hex fixed-angle Ising circuit with 20 total repetitions, fixing the
515  // angle of the X rotation gates to PI/4. Our simulation therefore corresponds
516  // to the middle datum of Fig. 4 b) of the IBM manuscript, for which MPS and
517  // isoTNS siulation techniques showed the greatest divergence from experiment.
518
519  double xRotationAngle = PI / 4.;
520  int numTrotterSteps = 20;
521  auto circuit = getIBMHeavyHexIsingCircuit(handle, xRotationAngle, numTrotterSteps);
522
523  std::cout << "Circuit: 127 qubit IBM heavy-hex Ising circuit, with..." << std::endl;
524  std::cout << "  Trotter steps: " << numTrotterSteps << std::endl;
525  std::cout << "  Total gates:   " << circuit.size() << std::endl;
526  std::cout << "  Rx angle:      " << xRotationAngle << " (i.e. PI/4)\n" << std::endl;
527
528  // Constrain that every intermediate output expansion contains unique Pauli
529  // strings (forbidding duplicates), but permit the retained strings to be
530  // unsorted. This combination gives cuPauliProp the best chance of automatically
531  // selecting optimal internal functions and postconditions for the simulation.
532  uint32_t adjoint = true;
533  sortOrder = CUPAULIPROP_SORT_ORDER_NONE;  // reuse variable from above
534  uint32_t keepDuplicates = false;
535
536  std::cout << "Imposed postconditions:" << std::endl;
537  if (sortOrder != CUPAULIPROP_SORT_ORDER_NONE) {
538    std::cout << "  Pauli strings will be sorted." << std::endl;
539  }
540  if (!keepDuplicates) {
541    std::cout << "  Pauli strings will be unique." << std::endl;
542  }
543  if (keepDuplicates && sortOrder == CUPAULIPROP_SORT_ORDER_NONE) {
544    std::cout << "No postconditions imposed on Pauli strings." << std::endl;
545  }
546  std::cout << std::endl;
547
548  // Begin timing before any gates are applied
549  HANDLE_CUDA_ERROR(cudaStreamSynchronize(stream));
550  auto startTime = std::chrono::steady_clock::now();
551  int64_t maxNumTerms = 0;
552
553  // Iterate the circuit in reverse to effect the adjoint of the total circuit
554  for (int gateInd=circuit.size()-1; gateInd >= 0; --gateInd) {
555    cupaulipropQuantumOperator_t gate = circuit[gateInd];
556
557    // Create a view of the current input expansion, selecting all currently
558    // contained terms. For very large systems, we may have alternatively
559    // chosen a smaller view of the partial state to work around memory limits.
560    cupaulipropPauliExpansionView_t inView;
561    int64_t numExpansionTerms;
562    HANDLE_CUPP_ERROR(cupaulipropPauliExpansionGetNumTerms(handle, inExpansion, &numExpansionTerms));
563    HANDLE_CUPP_ERROR(cupaulipropPauliExpansionGetContiguousRange(
564      handle, inExpansion, 0, numExpansionTerms, &inView));
565
566    // Track the intermediate expansion size, for our curiousity
567    if (numExpansionTerms > maxNumTerms)
568      maxNumTerms = numExpansionTerms;
569
570    // Choose whether or not to perform truncations after this gate
571    int numPassedTruncStrats = (gateInd % numGatesBetweenTruncations == 0)? numTruncStrats : 0;
572
573    // Check the expansion and workspace memories needed to apply the current gate
574    int64_t reqExpansionPauliMem;
575    int64_t reqExpansionCoefMem;
576    int64_t reqWorkspaceMem;
577    HANDLE_CUPP_ERROR(cupaulipropPauliExpansionViewPrepareOperatorApplication(
578      handle, inView, gate, sortOrder, keepDuplicates,
579      numPassedTruncStrats, numPassedTruncStrats > 0 ? truncStrats : nullptr,
580      workspaceMem,
581      &reqExpansionPauliMem, &reqExpansionCoefMem, workspace));
582    HANDLE_CUPP_ERROR(cupaulipropWorkspaceGetMemorySize(
583      handle, workspace,
584      CUPAULIPROP_MEMSPACE_DEVICE, CUPAULIPROP_WORKSPACE_SCRATCH,
585      &reqWorkspaceMem));
586
587    // Verify that our existing buffers and workspace have sufficient memory
588    if (reqExpansionPauliMem > static_cast<int64_t>(expansionPauliMem) ||
589        reqExpansionCoefMem  > static_cast<int64_t>(expansionCoefMem)  ||
590        reqWorkspaceMem      > static_cast<int64_t>(workspaceMem))
591    {
592      std::cout
593        << "Insufficient outExpansion capacity and/or workspace buffer size "
594        << "to perform operator application. Exiting..."
595        << std::endl;
596      std::abort();
597    }
598
599    // Beware that cupaulipropPauliExpansionViewPrepareOperatorApplication() above
600    // detaches the memory buffer from the workspace, which we here re-attach.
601    HANDLE_CUPP_ERROR(cupaulipropWorkspaceSetMemory(
602      handle, workspace,
603      CUPAULIPROP_MEMSPACE_DEVICE, CUPAULIPROP_WORKSPACE_SCRATCH,
604      d_workspaceBuffer, workspaceMem));
605
606    // Apply the gate upon the prepared view of the input expansion, evolving the
607    // Pauli strings pointed to within, truncating the result. The input expansion
608    // is unchanged while the output expansion is entirely overwritten.
609    HANDLE_CUPP_ERROR(cupaulipropPauliExpansionViewComputeOperatorApplication(
610      handle, inView, outExpansion, gate,
611      adjoint, sortOrder, keepDuplicates,
612      numPassedTruncStrats, numPassedTruncStrats > 0 ? truncStrats : nullptr,
613      workspace, stream));
614
615    // Free the temporary view since it points to the old input expansion, whereas
616    // we will subsequently treat the modified output expansion as the next input
617    HANDLE_CUPP_ERROR(cupaulipropDestroyPauliExpansionView(inView));
618
619    // Treat outExpansion as the input in the next gate application
620    std::swap(inExpansion, outExpansion);
621  }
622
623  // Restore outExpansion to being the final output for clarity
624  std::swap(inExpansion, outExpansion);

Our desired output quantity is the expectation value of \(Z_{62}\), as given by

\[\langle Z_{62} \rangle = \text{Tr}\left( \hat{U} |0\rangle\langle 0| \hat{U}^\dagger \; \hat{Z}_{62} \right).\]

This is expressible in terms of our final output Pauli expansion \(s_{\text{out}}\) (the Heisenberg-evolved observable) as

\[\langle Z_{62} \rangle = \text{Tr}\left( |0\rangle\langle 0 | \; \hat{U}^\dagger \hat{Z}_{62} \hat{U} \right) = \text{Tr}\left( |0\rangle\langle 0 | \; s_{\text{out}} \right).\]

Therefore we calculate the trace of our output Pauli expansion with the zero state, producing our estimate to the experimental expectation value of \(Z_{62}\).

628  // ========================================================================
629  // Evaluation of the the expectation value of observable
630  // ========================================================================
631
632  // The output expansion is now a proxy for the observable back-propagated
633  // through to the front of the circuit (though having discarded components
634  // which negligibly influence the subsequent overlap). The expectation value
635  // of the IBM experiment is the overlap of the output expansion with the
636  // zero state, i.e. Tr(outExpansion * |0><0|), as we now compute.
637
638  // Obtain a view of the full output expansion (we'll free it in 'Clean up')
639  cupaulipropPauliExpansionView_t outView;
640  int64_t numOutTerms;
641  HANDLE_CUPP_ERROR(cupaulipropPauliExpansionGetNumTerms(handle, outExpansion, &numOutTerms));
642  HANDLE_CUPP_ERROR(cupaulipropPauliExpansionGetContiguousRange(
643    handle, outExpansion, 0, numOutTerms, &outView));
644
645  // Check that the existing workspace memory is sufficient to compute the trace 
646  int64_t reqWorkspaceMem;
647  HANDLE_CUPP_ERROR(cupaulipropPauliExpansionViewPrepareTraceWithZeroState(
648    handle, outView, workspaceMem, workspace));
649  HANDLE_CUPP_ERROR(cupaulipropWorkspaceGetMemorySize(
650    handle, workspace,
651    CUPAULIPROP_MEMSPACE_DEVICE, CUPAULIPROP_WORKSPACE_SCRATCH,
652    &reqWorkspaceMem));
653  if (reqWorkspaceMem > static_cast<int64_t>(workspaceMem)) {
654    std::cout
655      << "Insufficient workspace buffer size to compute the trace. Exiting..."
656      << std::endl;
657    std::abort();
658  }
659
660  // Beware that we must now reattach the buffer to the workspace
661  HANDLE_CUPP_ERROR(cupaulipropWorkspaceSetMemory(
662    handle, workspace,
663    CUPAULIPROP_MEMSPACE_DEVICE, CUPAULIPROP_WORKSPACE_SCRATCH,
664    d_workspaceBuffer, workspaceMem));
665
666  // Compute the trace; the main and final output of this simulation!
667  double expecSignificand;
668  double expecExponent;
669  HANDLE_CUPP_ERROR(cupaulipropPauliExpansionViewComputeTraceWithZeroState(
670    handle, outView, &expecSignificand, &expecExponent, workspace, stream));
671
672  // The CUDA stream must be synchronized before accessing the trace output.
673  HANDLE_CUDA_ERROR(cudaStreamSynchronize(stream));
674  double expec = expecSignificand * std::pow(2.0, expecExponent);
675
676  // End timing after trace is evaluated
677  auto endTime = std::chrono::steady_clock::now();
678  auto duration = std::chrono::duration_cast<std::chrono::microseconds>(endTime - startTime);
679  auto durationSecs = (duration.count() / 1e6);
680
681  std::cout << "Expectation value:       " << expec << std::endl;
682  std::cout << "Final number of terms:   " << numOutTerms << std::endl;
683  std::cout << "Maximum number of terms: " << maxNumTerms << std::endl;
684  std::cout << "Runtime:                 " << durationSecs << " seconds\n" << std::endl;

Finally, we clean up resources and free all device memory reserved during our simulation. This includes any outstanding views, all quantum operators, all expansions and their underlying buffers, and similarly for the workspace. Our final step is to finalise the cuPauliProp library, concluding simulation.

688  // ========================================================================
689  // Clean up
690  // ========================================================================
691
692  HANDLE_CUPP_ERROR(cupaulipropDestroyPauliExpansionView(outView));
693 
694  for (auto & gate : circuit) {
695    HANDLE_CUPP_ERROR(cupaulipropDestroyOperator(gate));
696  }
697
698  HANDLE_CUPP_ERROR(cupaulipropDestroyWorkspaceDescriptor(workspace));
699  HANDLE_CUDA_ERROR(cudaFree(d_workspaceBuffer));
700
701  HANDLE_CUPP_ERROR(cupaulipropDestroyPauliExpansion(inExpansion));
702  HANDLE_CUPP_ERROR(cupaulipropDestroyPauliExpansion(outExpansion));
703
704  HANDLE_CUDA_ERROR(cudaFree(d_inExpansionPauliBuffer));
705  HANDLE_CUDA_ERROR(cudaFree(d_outExpansionPauliBuffer));
706  HANDLE_CUDA_ERROR(cudaFree(d_inExpansionCoefBuffer));
707  HANDLE_CUDA_ERROR(cudaFree(d_outExpansionCoefBuffer));
708
709  HANDLE_CUPP_ERROR(cupaulipropDestroy(handle));
710
711  return EXIT_SUCCESS;
712}

Multiprocess Kicked Ising example#

kicked_ising_multiprocess_example.cpp distributes the same Kicked Ising simulation across multiple processes, using either MPI or NCCL for cuPauliProp communication. The circuit, observable encoding, and truncation strategy are unchanged from the preceding example, so both examples produce the same simulation result. This small workload will in fact run more slowly with multiple processes than with a single process; its sole purpose is to demonstrate the multiprocess workflow as simply as possible. This walkthrough therefore focuses on configuring and performing distributed execution.

When MPI_ENABLED is defined, the example initializes MPI and assigns a GPU to each rank. If NCCL_ENABLED is also defined, cuPauliProp uses NCCL for communication; MPI still launches the processes and bootstraps the NCCL communicator. CMake builds kicked_ising_multiprocess_example with MPI support when MPI is available. If both MPI and NCCL are available, CMake also builds kicked_ising_multiprocess_nccl_example from the same source, with both macros defined.

After calling cupaulipropCreate(), each rank must call the collective cupaulipropResetDistributedConfiguration() function to configure the handle for multi-process execution. This call must occur before any rank creates handle-owned objects. On every rank, the CUPAULIPROP_COMM_LIB environment variable must identify the selected cuPauliProp communication plugin. The conditional code below shows the setup for both communication providers:

315// ========================================================================
316// Main
317// ========================================================================
318
319int main(int argc, char** argv) {
320
321  // To be overwritten below
322  int rank = 0;
323  int numRanks = 1;
324
325  // Both distributed modes use MPI to establish the process topology and map
326  // each rank to a GPU. In the NCCL build, MPI performs only launch, bootstrap,
327  // and reporting duties; cuPauliProp's data communication uses NCCL instead.
328  // Without MPI, the initialized defaults describe one process on device 0.
329#if defined(MPI_ENABLED)
330  MPI_Init(&argc, &argv);
331  HANDLE_MPI_ERROR(MPI_Comm_set_errhandler(MPI_COMM_WORLD, MPI_ERRORS_RETURN));
332  HANDLE_MPI_ERROR(MPI_Comm_rank(MPI_COMM_WORLD, &rank));
333  HANDLE_MPI_ERROR(MPI_Comm_size(MPI_COMM_WORLD, &numRanks));
334
335  MPI_Comm localComm;
336  HANDLE_MPI_ERROR(MPI_Comm_split_type(
337    MPI_COMM_WORLD, MPI_COMM_TYPE_SHARED, rank, MPI_INFO_NULL, &localComm));
338  int localRank = 0;
339  int localSize = 1;
340  HANDLE_MPI_ERROR(MPI_Comm_rank(localComm, &localRank));
341  HANDLE_MPI_ERROR(MPI_Comm_size(localComm, &localSize));
342  HANDLE_MPI_ERROR(MPI_Comm_free(&localComm));
343
344  // Oversubscribed nodes map ranks round-robin onto the visible devices.
345  int numDevices = 0;
346  HANDLE_CUDA_ERROR(cudaGetDeviceCount(&numDevices));
347  if (numDevices == 0) {
348    fprintf(stderr, "No visible CUDA devices\n");
349    abortAllProcesses();
350  }
351  HANDLE_CUDA_ERROR(cudaSetDevice(localRank % numDevices));
352#else
353  HANDLE_CUDA_ERROR(cudaSetDevice(0));
354#endif
355
356  if (rank == 0) {
357    std::cout << "cuPauliProp IBM Heavy-hex Ising Example" << std::endl;
358    std::cout << "========================================================"
359              << std::endl << std::endl;
360  }
361
362  cudaStream_t stream = 0;
363  cupaulipropHandle_t handle;
364  HANDLE_CUPP_ERROR(cupaulipropCreate(&handle));
365
366  // Select the provider at runtime from the plugin filename. Both
367  // communicator variables exist for the whole run because teardown must
368  // free whichever one was created after the handle is destroyed.
369#if defined(MPI_ENABLED)
370  const bool useNccl = commLibRequestsNccl();
371  MPI_Comm mpiCommunicator = MPI_COMM_NULL;
372#endif
373#if defined(NCCL_ENABLED)
374  ncclComm_t ncclCommunicator = nullptr;
375#endif
376
377#if defined(NCCL_ENABLED)
378  if (useNccl) {
379    // NCCL communicators require an out-of-band bootstrap: rank 0 creates a
380    // unique ID, MPI broadcasts it, and every rank constructs its NCCL
381    // endpoint. The resulting opaque ncclComm_t is passed to the NCCL
382    // provider.
383    ncclUniqueId ncclId;
384    if (rank == 0) {
385      HANDLE_NCCL_ERROR(ncclGetUniqueId(&ncclId));
386    }
387    HANDLE_MPI_ERROR(
388      MPI_Bcast(&ncclId, sizeof(ncclId), MPI_BYTE, 0, MPI_COMM_WORLD));
389
390    HANDLE_NCCL_ERROR(
391      ncclCommInitRank(&ncclCommunicator, numRanks, ncclId, rank));
392    HANDLE_CUPP_ERROR(cupaulipropResetDistributedConfiguration(
393      handle,
394      CUPAULIPROP_DISTRIBUTED_PROVIDER_NCCL,
395      &ncclCommunicator,
396      sizeof(ncclCommunicator)));
397  }
398#endif
399#if defined(MPI_ENABLED)
400  if (!useNccl) {
401    // The MPI provider can consume an MPI communicator directly. Duplicate
402    // MPI_COMM_WORLD so the example owns the communicator given to cuPauliProp
403    // and can preserve it until the handle has been destroyed.
404    HANDLE_MPI_ERROR(MPI_Comm_dup(MPI_COMM_WORLD, &mpiCommunicator));
405    HANDLE_CUPP_ERROR(cupaulipropResetDistributedConfiguration(
406      handle,
407      CUPAULIPROP_DISTRIBUTED_PROVIDER_MPI,
408      &mpiCommunicator,
409      sizeof(mpiCommunicator)));
410  }
411#endif
412#if defined(MPI_ENABLED) && !defined(NCCL_ENABLED)
413  if (useNccl) {
414    if (rank == 0) {
415      fprintf(stderr,
416        "CUPAULIPROP_COMM_LIB names an NCCL plugin, but this binary was "
417        "built without NCCL support; point it at the MPI plugin or rebuild "
418        "with NCCL.\n");
419    }
420    abortAllProcesses();
421  }
422#endif
423
424  if (rank == 0) {
425    // Report the selected cuPauliProp communication path.
426#if defined(MPI_ENABLED)
427    if (useNccl) {
428      std::cout << "Execution mode: distributed, NCCL backend (MPI bootstrap)" << std::endl;
429    } else {
430      std::cout << "Execution mode: distributed, MPI backend" << std::endl;
431    }
432#else
433    std::cout << "Execution mode: single-process" << std::endl;
434#endif
435    std::cout << "Number of ranks: " << numRanks << "\n" << std::endl;
436  }

Memory remains process-local. Each process allocates buffers on its assigned GPU for the local expansion data and workspace. These buffers hold the process’s share (a “segment”) of two distributed expansions. Consequently, the sizes reported by the prepare APIs and the memory budget below apply only to the calling process, not to the aggregate memory across all processes.

The fixed configuration reserves 20 MiB for each expansion (16 MiB for Pauli strings and 4 MiB for coefficients) and 128 MiB for workspace, totaling 168 MiB per process. When USE_MAX_VRAM is enabled, each expansion receives approximately 20% of usable memory and workspace receives the remaining approximately 60%. The larger workspace share accommodates overlapping communication staging and deduplication scratch used by distributed operator application.

440  // ========================================================================
441  // Decide memory usage
442  // ========================================================================
443
444  // Process-local GPU memory sizes, to be allocated by every process.
445  size_t expansionPauliMem;
446  size_t expansionCoefMem;
447  size_t workspaceMem;
448  size_t totalUsedMem;
449
450  if (USE_MAX_VRAM) {
451    size_t totalFreeMem, totalGlobalMem;
452    HANDLE_CUDA_ERROR(cudaMemGetInfo(&totalFreeMem, &totalGlobalMem));
453    size_t totalUsableMem = static_cast<size_t>(totalFreeMem * MAX_VRAM_PERCENT / 100);
454
455    int32_t numPackedInts;
456    HANDLE_CUPP_ERROR(cupaulipropGetNumPackedIntegers(NUM_CIRCUIT_QUBITS, &numPackedInts));
457    size_t pauliMemPerTerm = 2 * numPackedInts * sizeof(cupaulipropPackedIntegerType_t);
458    size_t coefMemPerTerm = sizeof(double);
459    size_t totalMemPerTerm = pauliMemPerTerm + coefMemPerTerm;
460
461    // Reserve approximately 20% for each expansion and 60% for workspace.
462    // Distributed redistribution and deduplication require overlapping scratch
463    // buffers, so they need a larger share than either local expansion.
464    size_t perExpansionMem = totalUsableMem / 5;
465    size_t expansionTermCapacity = perExpansionMem / totalMemPerTerm;
466    expansionPauliMem = expansionTermCapacity * pauliMemPerTerm;
467    expansionCoefMem  = expansionTermCapacity * coefMemPerTerm;
468    workspaceMem =
469      totalUsableMem - 2 * expansionPauliMem - 2 * expansionCoefMem;
470
471    totalUsedMem = 2 * expansionPauliMem + 2 * expansionCoefMem + workspaceMem;
472    if (rank == 0) {
473      std::cout << "Dedicated memory per process: " << MAX_VRAM_PERCENT << "% of "
474                << totalFreeMem << " B free = " << totalUsedMem << " B = "
475                << totalUsedMem / (1LLU << 20) << " MiB, divided into..." << std::endl;
476    }
477  } else {
478    expansionPauliMem = FIXED_EXPANSION_PAULI_MEM;
479    expansionCoefMem  = FIXED_EXPANSION_COEF_MEM;
480    workspaceMem      = FIXED_WORKSPACE_MEM;
481
482    totalUsedMem = 2 * expansionPauliMem + 2 * expansionCoefMem + workspaceMem;
483    if (rank == 0) {
484      std::cout << "Dedicated memory per process: " << totalUsedMem << " B = "
485                << totalUsedMem / (1LLU << 20) << " MiB, divided into..." << std::endl;
486    }
487  }
488
489  if (rank == 0) {
490    const size_t aggregateUsedMem = totalUsedMem * static_cast<size_t>(numRanks);
491    std::cout << "  expansion Pauli buffer: " << expansionPauliMem << " B" << std::endl;
492    std::cout << "  expansion coef buffer:  " << expansionCoefMem << " B" << std::endl;
493    std::cout << "  workspace buffer:       " << workspaceMem << " B" << std::endl;
494    std::cout << "Number of processes: " << numRanks << std::endl;
495    std::cout << "Aggregate dedicated memory: " << aggregateUsedMem << " B = "
496              << aggregateUsedMem / (1LLU << 20) << " MiB\n" << std::endl;
497  }

In a distributed run, cupaulipropCreatePauliExpansion() consumes only the calling process’s local storage and initialized terms. Its buffer pointers, buffer sizes, and numLocalTerms argument are process-local, so their values may differ across ranks. The union of all process-local segments forms the complete Pauli expansion. In this example, only rank 0 initializes its segment with the single \(Z_{62}\) term; every other rank starts with an empty segment.

Other creation arguments describe properties of the full, global, distributed expansion. For example, setting hasDuplicates to zero asserts that no duplicate Pauli strings exist within or across segments. Every process must provide the same value. Somewhat distinctly, the sortOrder argument describes the ordering within each local segment and must also be consistent across processes; however, global sortedness between all ranks is not necessary.

501  // ========================================================================
502  // Pauli expansion preparation
503  // ========================================================================
504
505  void* d_inExpansionPauliBuffer;
506  void* d_outExpansionPauliBuffer;
507  void* d_inExpansionCoefBuffer;
508  void* d_outExpansionCoefBuffer;
509  HANDLE_CUDA_ERROR(cudaMalloc(&d_inExpansionPauliBuffer,  expansionPauliMem));
510  HANDLE_CUDA_ERROR(cudaMalloc(&d_inExpansionCoefBuffer,   expansionCoefMem));
511  HANDLE_CUDA_ERROR(cudaMalloc(&d_outExpansionPauliBuffer, expansionPauliMem));
512  HANDLE_CUDA_ERROR(cudaMalloc(&d_outExpansionCoefBuffer,  expansionCoefMem));
513
514  if (rank == 0) {
515    std::cout << "Observable: Z_62\n" << std::endl;
516  }
517  std::vector<cupaulipropPauliKind_t> observablePaulis = {CUPAULIPROP_PAULI_Z};
518  std::vector<uint32_t> observableQubits = {62};
519  double observableCoef = 1.0;
520
521  int64_t numLocalTerms = 0;
522  if (rank == 0) {
523    numLocalTerms = 1;
524    auto observablePackedInts =
525      getPauliStringAsPackedIntegers(observablePaulis, observableQubits);
526    size_t numObservableBytes =
527      observablePackedInts.size() * sizeof(observablePackedInts[0]);
528    HANDLE_CUDA_ERROR(cudaMemcpy(
529      d_inExpansionPauliBuffer, observablePackedInts.data(),
530      numObservableBytes, cudaMemcpyHostToDevice));
531    HANDLE_CUDA_ERROR(cudaMemcpy(
532      d_inExpansionCoefBuffer, &observableCoef,
533      sizeof(observableCoef), cudaMemcpyHostToDevice));
534  }
535
536  cupaulipropPauliExpansion_t inExpansion;
537  cupaulipropPauliExpansion_t outExpansion;
538  cupaulipropSortOrder_t sortOrder = CUPAULIPROP_SORT_ORDER_NONE;
539  int32_t hasDuplicates = 0;
540  cudaDataType_t dataType = CUDA_R_64F;
541
542  // Each call creates only this rank's local segment of the distributed
543  // expansion. The buffers, their capacities, and the local term count may
544  // differ between ranks; together, the local segments form the full expansion.
545  HANDLE_CUPP_ERROR(cupaulipropCreatePauliExpansion(
546    handle, NUM_CIRCUIT_QUBITS,
547    d_inExpansionPauliBuffer, expansionPauliMem,
548    d_inExpansionCoefBuffer, expansionCoefMem,
549    dataType, numLocalTerms, sortOrder, hasDuplicates,
550    &inExpansion));
551  HANDLE_CUPP_ERROR(cupaulipropCreatePauliExpansion(
552    handle, NUM_CIRCUIT_QUBITS,
553    d_outExpansionPauliBuffer, expansionPauliMem,
554    d_outExpansionCoefBuffer, expansionCoefMem,
555    dataType, 0, CUPAULIPROP_SORT_ORDER_NONE, 0,
556    &outExpansion));

The workspace descriptor and its attached allocation are also local to each process. Prepare calls detach the allocation on each rank, so each rank must reattach its local buffer before computation.

560  // ========================================================================
561  // Workspace preparation
562  // ========================================================================
563
564  cupaulipropWorkspaceDescriptor_t workspace;
565  HANDLE_CUPP_ERROR(cupaulipropCreateWorkspaceDescriptor(handle, &workspace));
566
567  void* d_workspaceBuffer;
568  HANDLE_CUDA_ERROR(cudaMalloc(&d_workspaceBuffer, workspaceMem));
569  HANDLE_CUPP_ERROR(cupaulipropWorkspaceSetMemory(
570    handle, workspace,
571    CUPAULIPROP_MEMSPACE_DEVICE, CUPAULIPROP_WORKSPACE_SCRATCH,
572    d_workspaceBuffer, workspaceMem));

During distributed propagation, cupaulipropPauliExpansionGetNumTerms() returns the term count for only the calling process. cupaulipropPauliExpansionGetContiguousRange() likewise indexes only that process’s local segment. Together, the full-range local views represent the complete distributed expansion.

In a distributed run, the operator prepare and compute calls are collective. Every process must specify the same operator, requested postconditions, and truncation strategies. However, the output-buffer and workspace sizes returned by the prepare call are process-local. Prepare cannot in general know exactly how many resulting terms each process will own. It therefore includes statistical slack for potential inter-process imbalances. Consequently, cupaulipropPauliExpansionViewComputeOperatorApplication() can still return CUPAULIPROP_STATUS_INSUFFICIENT_OUT_EXPANSION in the extremely unlikely event that the actual inter-process imbalance exceeds this prepared capacity, even when the reported memory sizes were provided. This failure status is propagated to every participating process.

The keepDuplicates argument has global meaning: setting it to false, as shown below, requires Pauli strings to be unique both within and across processes. The truncation strategies are passed directly to the operator prepare and compute calls on the iterations where truncation is requested. When deduplication and truncation are both requested, cuPauliProp always completes global deduplication before applying the truncation strategies. At the end of the compute call, the calling process will contain a possibly distinct segment of the resulting output expansion, in outExpansion.

611  // ========================================================================
612  // Back-propagation of the observable through the circuit
613  // ========================================================================
614
615  double xRotationAngle = PI / 4.;
616  int numTrotterSteps = 20;
617  auto circuit = getIBMHeavyHexIsingCircuit(handle, xRotationAngle, numTrotterSteps);
618
619  if (rank == 0) {
620    std::cout << "Circuit: 127 qubit IBM heavy-hex Ising circuit, with..." << std::endl;
621    std::cout << "  Trotter steps: " << numTrotterSteps << std::endl;
622    std::cout << "  Total gates:   " << circuit.size() << std::endl;
623    std::cout << "  Rx angle:      " << xRotationAngle << " (i.e. PI/4)\n" << std::endl;
624  }
625
626  uint32_t adjoint = true;
627  sortOrder = CUPAULIPROP_SORT_ORDER_NONE;
628
629  // Global deduplication, requested with keepDuplicates=false, can require
630  // redistributing Pauli terms between processes. This can significantly
631  // increase communication costs and therefore simulation runtime.
632  int32_t keepDuplicates = false;
633
634  if (rank == 0) {
635    std::cout << "Imposed postconditions:" << std::endl;
636    if (sortOrder != CUPAULIPROP_SORT_ORDER_NONE) {
637      std::cout << "  Pauli strings will be sorted." << std::endl;
638    }
639    if (!keepDuplicates) {
640      std::cout << "  Pauli strings will be unique." << std::endl;
641    }
642    if (keepDuplicates && sortOrder == CUPAULIPROP_SORT_ORDER_NONE) {
643      std::cout << "No postconditions imposed on Pauli strings." << std::endl;
644    }
645    std::cout << std::endl;
646  }
647
648  // Exclude preceding asynchronous CUDA work and align all ranks before timing.
649  HANDLE_CUDA_ERROR(cudaStreamSynchronize(stream));
650#if defined(MPI_ENABLED)
651  HANDLE_MPI_ERROR(MPI_Barrier(MPI_COMM_WORLD));
652#endif
653  auto startTime = std::chrono::steady_clock::now();
654  int64_t maxNumLocalTerms = 0;
655
656  for (int gateInd = circuit.size() - 1; gateInd >= 0; --gateInd) {
657    cupaulipropQuantumOperator_t gate = circuit[gateInd];
658
659    cupaulipropPauliExpansionView_t inView;
660    int64_t currentNumLocalTerms;
661    HANDLE_CUPP_ERROR(cupaulipropPauliExpansionGetNumTerms(
662      handle, inExpansion, &currentNumLocalTerms));
663    HANDLE_CUPP_ERROR(cupaulipropPauliExpansionGetContiguousRange(
664      handle, inExpansion, 0, currentNumLocalTerms, &inView));
665
666    if (currentNumLocalTerms > maxNumLocalTerms)
667      maxNumLocalTerms = currentNumLocalTerms;
668
669    bool applyTruncation = (gateInd % numGatesBetweenTruncations == 0);
670    int32_t appliedNumTruncStrats = applyTruncation ? numTruncStrats : 0;
671    const cupaulipropTruncationStrategy_t* appliedTruncStrats =
672      applyTruncation ? truncStrats : nullptr;
673      
674    // In multi-process execution, Prepare returns process-local sizing with
675    // statistical slack for potential inter-process term imbalances. Unlike
676    // in single-process execution, passing workspaces and output expansions
677    // of the sizes prescribed by Prepare to the subsequent Compute function
678    // does not guarantee it will succeed, though the probability of failure
679    // is very small, and vanishes for meaningfully large systems.
680    int64_t reqExpansionPauliMem;
681    int64_t reqExpansionCoefMem;
682    int64_t reqWorkspaceMem;
683    HANDLE_CUPP_ERROR(cupaulipropPauliExpansionViewPrepareOperatorApplication(
684      handle, inView, gate, sortOrder, keepDuplicates,
685      appliedNumTruncStrats, appliedTruncStrats,
686      workspaceMem,
687      &reqExpansionPauliMem, &reqExpansionCoefMem, workspace));
688    HANDLE_CUPP_ERROR(cupaulipropWorkspaceGetMemorySize(
689      handle, workspace,
690      CUPAULIPROP_MEMSPACE_DEVICE, CUPAULIPROP_WORKSPACE_SCRATCH,
691      &reqWorkspaceMem));
692
693    if (reqExpansionPauliMem > static_cast<int64_t>(expansionPauliMem) ||
694        reqExpansionCoefMem  > static_cast<int64_t>(expansionCoefMem)  ||
695        reqWorkspaceMem      > static_cast<int64_t>(workspaceMem))
696    {
697      std::cout
698        << "Insufficient outExpansion capacity and/or workspace buffer size "
699        << "to perform operator application. Exiting..."
700        << std::endl;
701      abortAllProcesses();
702    }
703
704    HANDLE_CUPP_ERROR(cupaulipropWorkspaceSetMemory(
705      handle, workspace,
706      CUPAULIPROP_MEMSPACE_DEVICE, CUPAULIPROP_WORKSPACE_SCRATCH,
707      d_workspaceBuffer, workspaceMem));
708
709    const auto operatorStatus =
710      cupaulipropPauliExpansionViewComputeOperatorApplication(
711        handle, inView, outExpansion, gate,
712        adjoint, sortOrder, keepDuplicates,
713        appliedNumTruncStrats, appliedTruncStrats,
714        workspace, stream);
715    if (operatorStatus == CUPAULIPROP_STATUS_INSUFFICIENT_OUT_EXPANSION
716        && numRanks > 1 && rank == 0) {
717      std::cerr
718        << "The actual inter-process term distribution exceeded the "
719           "probabilistic capacity reported by PrepareOperatorApplication. "
720           "This outcome is extremely unlikely."
721        << std::endl;
722    }
723    HANDLE_CUPP_ERROR(operatorStatus);
724
725    HANDLE_CUPP_ERROR(cupaulipropDestroyPauliExpansionView(inView));
726    std::swap(inExpansion, outExpansion);
727  }
728
729  std::swap(inExpansion, outExpansion);
730
731  int64_t numOutTerms;
732  HANDLE_CUPP_ERROR(cupaulipropPauliExpansionGetNumTerms(
733    handle, outExpansion, &numOutTerms));
734  if (numOutTerms > maxNumLocalTerms)
735    maxNumLocalTerms = numOutTerms;
736
737  // GetNumTerms reports a rank-local count. Use MPI to find the largest local
738  // shard seen in either distributed mode; NCCL builds can also use this reduction
739  // because MPI remains active for reporting. In single-process mode the local
740  // maximum already is the global maximum.
741#if defined(MPI_ENABLED)
742  int64_t maxNumLocalTermsAcrossRanks = 0;
743  HANDLE_MPI_ERROR(MPI_Allreduce(
744    &maxNumLocalTerms, &maxNumLocalTermsAcrossRanks,
745    1, MPI_INT64_T, MPI_MAX, MPI_COMM_WORLD));
746  maxNumLocalTerms = maxNumLocalTermsAcrossRanks;
747#endif

In a distributed run, trace computation is collective. cuPauliProp combines the local contributions and writes the same global significand and exponent on every process. The expectation value therefore requires no explicit MPI reduction. Term-count queries remain local, so the example uses MPI_Allreduce to sum the final local counts for display.

751  // ========================================================================
752  // Evaluation of the expectation value of the observable
753  // ========================================================================
754
755  cupaulipropPauliExpansionView_t outView;
756  HANDLE_CUPP_ERROR(cupaulipropPauliExpansionGetContiguousRange(
757    handle, outExpansion, 0, numOutTerms, &outView));
758
759  // Sum rank-local output sizes for display in either distributed mode. This
760  // explicit MPI reduction is only for the statistic: cuPauliProp's trace call
761  // below already combines distributed trace contributions through the selected
762  // MPI or NCCL provider. No reduction is needed in single-process mode.
763#if defined(MPI_ENABLED)
764  int64_t totalNumOutTerms = 0;
765  HANDLE_MPI_ERROR(MPI_Allreduce(
766    &numOutTerms, &totalNumOutTerms, 1, MPI_INT64_T, MPI_SUM, MPI_COMM_WORLD));
767  numOutTerms = totalNumOutTerms;
768#endif
769
770  int64_t reqWorkspaceMem;
771  HANDLE_CUPP_ERROR(cupaulipropPauliExpansionViewPrepareTraceWithZeroState(
772    handle, outView, workspaceMem, workspace));
773  HANDLE_CUPP_ERROR(cupaulipropWorkspaceGetMemorySize(
774    handle, workspace,
775    CUPAULIPROP_MEMSPACE_DEVICE, CUPAULIPROP_WORKSPACE_SCRATCH,
776    &reqWorkspaceMem));
777  if (reqWorkspaceMem > static_cast<int64_t>(workspaceMem)) {
778    std::cout
779      << "Insufficient workspace buffer size to compute the trace. Exiting..."
780      << std::endl;
781    abortAllProcesses();
782  }
783
784  HANDLE_CUPP_ERROR(cupaulipropWorkspaceSetMemory(
785    handle, workspace,
786    CUPAULIPROP_MEMSPACE_DEVICE, CUPAULIPROP_WORKSPACE_SCRATCH,
787    d_workspaceBuffer, workspaceMem));
788
789  double expecSignificand;
790  double expecExponent;
791  HANDLE_CUPP_ERROR(cupaulipropPauliExpansionViewComputeTraceWithZeroState(
792    handle, outView, &expecSignificand, &expecExponent, workspace, stream));
793
794  // Complete local GPU work and align all ranks before ending timing.
795  HANDLE_CUDA_ERROR(cudaStreamSynchronize(stream));
796#if defined(MPI_ENABLED)
797  HANDLE_MPI_ERROR(MPI_Barrier(MPI_COMM_WORLD));
798#endif
799  auto endTime = std::chrono::steady_clock::now();
800  double expec = expecSignificand * std::pow(2.0, expecExponent);
801
802  auto duration =
803    std::chrono::duration_cast<std::chrono::microseconds>(endTime - startTime);
804  auto durationSecs = duration.count() / 1e6;
805
806  if (rank == 0) {
807    std::cout << "Expectation value:                  " << expec << std::endl;
808    std::cout << "Final global number of terms:       " << numOutTerms << std::endl;
809    std::cout << "Maximum local terms on any rank:    " << maxNumLocalTerms << std::endl;
810    std::cout << "Runtime:                            "
811              << durationSecs << " seconds\n" << std::endl;
812  }

Finally, each process destroys its local views, expansion handles, workspace, device buffers, and library handle. The MPI-enabled paths keep the MPI or NCCL communicator alive until after cupaulipropDestroy(). They then destroy the selected communicator and finalize MPI.

816  // ========================================================================
817  // Clean up
818  // ========================================================================
819
820  HANDLE_CUPP_ERROR(cupaulipropDestroyPauliExpansionView(outView));
821
822  for (auto& gate : circuit) {
823    HANDLE_CUPP_ERROR(cupaulipropDestroyOperator(gate));
824  }
825
826  HANDLE_CUPP_ERROR(cupaulipropDestroyWorkspaceDescriptor(workspace));
827  HANDLE_CUDA_ERROR(cudaFree(d_workspaceBuffer));
828
829  HANDLE_CUPP_ERROR(cupaulipropDestroyPauliExpansion(inExpansion));
830  HANDLE_CUPP_ERROR(cupaulipropDestroyPauliExpansion(outExpansion));
831
832  HANDLE_CUDA_ERROR(cudaFree(d_inExpansionPauliBuffer));
833  HANDLE_CUDA_ERROR(cudaFree(d_outExpansionPauliBuffer));
834  HANDLE_CUDA_ERROR(cudaFree(d_inExpansionCoefBuffer));
835  HANDLE_CUDA_ERROR(cudaFree(d_outExpansionCoefBuffer));
836
837  HANDLE_CUPP_ERROR(cupaulipropDestroy(handle));
838
839  // The provider communicator had to outlive the cuPauliProp handle. Destroy
840  // whichever concrete communicator was created during setup, then finalize
841  // the MPI process environment used by both distributed modes. The
842  // single-process path owns neither resource and therefore has no
843  // corresponding cleanup.
844#if defined(NCCL_ENABLED)
845  if (useNccl) {
846    HANDLE_NCCL_ERROR(ncclCommDestroy(ncclCommunicator));
847  }
848#endif
849#if defined(MPI_ENABLED)
850  if (mpiCommunicator != MPI_COMM_NULL) {
851    HANDLE_MPI_ERROR(MPI_Comm_free(&mpiCommunicator));
852  }
853  MPI_Finalize();
854#endif
855
856  return EXIT_SUCCESS;
857}

Kicked Ising fused Clifford example#

This companion example simulates the same 127-qubit circuit and observable as kicked_ising_example.cpp, but exploits the fact that every fixed \(Rzz(-\pi/2)\) rotation is Clifford. During Heisenberg back-propagation, the adjoint of each rotation is represented by a \(CX\)-\(S\)-\(CX\) sequence. The three heavy-hex interaction layers are concatenated and applied with one fused Clifford call per Trotter step, while the non-Clifford \(Rx(\pi/4)\) gates are still applied individually.

The example uses the fused Prepare API to verify its fixed output and workspace allocations before computation. Truncation is skipped for the non-branching Clifford sequence and performed only after every tenth \(Rx\) rotation. Consequently, its truncation schedule differs from the original example and can produce a slightly different approximate expectation value. The full source is available in kicked_ising_fused_example.cpp.

Kicked Ising backward differentiation example#

This example demonstrates the cuPauliProp facilities for calculating gradients of expectation values with respect to circuit parameters via reverse-mode automatic differentiation (AD). For simplicity, we employ the same circuit as in the above example, parameterised by the \(X\) and \(ZZ\) rotation angles which we label \(\theta_X\) and \(\theta_Z\) respectively. Where the above example computed the expectation value \(\langle Z_{62} \rangle\) for \((\theta_X,\theta_Z) = (\pi/4, -\pi/2)\), we will here compute the gradient of this value at the same coordinate. That is, we seek

\[\nabla_{\theta_X, \theta_Z} \langle Z_{62} \rangle = ( \frac{\partial}{\partial \theta_X} \langle Z_{62} \rangle, \frac{\partial}{\partial \theta_Z} \langle Z_{62} \rangle ).\]

We will skip all code which is similar to the above example, and focus instead on the additional or particular steps involved in use of the AD API. The full demonstration is available in kicked_ising_backward_diff_example.cpp.

Our first algorithmic step is to simulate the non-differentiated circuit and obtain the output expectation value, in an identical fashion to the example above. We evaluate the circuit in the Heisenberg picture, beginning from the \(Z_{62}\) observable and applying the adjoint of the full circuit. Somewhat confusingly, this is the “forward pass” of AD, which just so happens to be a backward enumeration of the experimental circuit. Below, inExpansion is initialised to observable operator \(Z_{62}\).

251  // ========================================================================
252  // Forward pass: back-propagate observable through adjoint circuit
253  // ========================================================================
254  auto startTime = std::chrono::high_resolution_clock::now();
255  int64_t maxNumTerms = 0;
256
257  for (int i = static_cast<int>(circuit.size()) - 1; i >= 0; --i) {
258    int64_t inTerms = 0, reqXZ = 0, reqCoef = 0, reqWs = 0;
259    cupaulipropPauliExpansionView_t inView{};
260    HANDLE_CUPP_ERROR(cupaulipropPauliExpansionGetNumTerms(handle, inExpansion, &inTerms));
261    if (inTerms > maxNumTerms) maxNumTerms = inTerms;
262    HANDLE_CUPP_ERROR(cupaulipropPauliExpansionGetContiguousRange(handle, inExpansion, 0, inTerms, &inView));
263    HANDLE_CUPP_ERROR(cupaulipropPauliExpansionViewPrepareOperatorApplication(
264        handle, inView, circuit[i].op, sortOrder, 0, 2, truncStrats, WORKSPACE_MEM, &reqXZ, &reqCoef, workspace));
265    HANDLE_CUPP_ERROR(cupaulipropWorkspaceGetMemorySize(
266        handle, workspace, CUPAULIPROP_MEMSPACE_DEVICE, CUPAULIPROP_WORKSPACE_SCRATCH, &reqWs));
267    if (reqXZ   > static_cast<int64_t>(EXPANSION_PAULI_MEM) ||
268        reqCoef > static_cast<int64_t>(EXPANSION_COEF_MEM)  ||
269        reqWs   > static_cast<int64_t>(WORKSPACE_MEM))
270    {
271      std::cout
272          << "Insufficient outExpansion capacity and/or workspace buffer size "
273          << "to perform operator application. Exiting..."
274          << std::endl;
275      std::abort();
276    }
277    reattachWorkspace(handle, workspace, dWorkspace);
278    HANDLE_CUPP_ERROR(cupaulipropPauliExpansionViewComputeOperatorApplication(
279        handle, inView, outExpansion, circuit[i].op, 1, sortOrder, 0, 2, truncStrats, workspace, stream));
280    HANDLE_CUPP_ERROR(cupaulipropDestroyPauliExpansionView(inView));
281    std::swap(inExpansion, outExpansion);
282  }

We then obtain the expectation value \(\langle Z_{62} \rangle\) at \((\theta_X,\theta_Z) = (\pi/4, -\pi/2)\). Notice below that the value is given to us as a separate significand and exponent, which we will here denote as \(s\) and \(p\) respectively, such that \(\langle Z_{62} \rangle = s \times 2^p\).

290  // ========================================================================
291  // Trace evaluation (expectation value)
292  // ========================================================================
293  int64_t finalTerms = 0;
294  cupaulipropPauliExpansionView_t finalView{};
295  HANDLE_CUPP_ERROR(cupaulipropPauliExpansionGetNumTerms(handle, inExpansion, &finalTerms));
296  if (finalTerms > maxNumTerms) maxNumTerms = finalTerms;
297  HANDLE_CUPP_ERROR(cupaulipropPauliExpansionGetContiguousRange(handle, inExpansion, 0, finalTerms, &finalView));
298  HANDLE_CUPP_ERROR(cupaulipropPauliExpansionViewPrepareTraceWithZeroState(handle, finalView, WORKSPACE_MEM, workspace));
299  reattachWorkspace(handle, workspace, dWorkspace);
300  double s = 0.0, p = 0.0;
301  HANDLE_CUPP_ERROR(cupaulipropPauliExpansionViewComputeTraceWithZeroState(handle, finalView, &s, &p, workspace, stream));
302  const double expec = s * std::exp2(p);

We now proceed to evaluating the gradient of this quantity. While above we obtained the result of applying the full circuit, computing the gradient will make use of each of the intermediate states reached by evolving the observable through only a partial circuit. We did obtain these intermediate states in-turn but did not store them, which would necessitate as many Pauli expansions as operators in the circuit. Instead, we will leverage that the kicked Ising circuit is unitary and therefore invertible, enabling us to re-obtain intermediate states by applying the inverse of the last applied operator. This trick is an instance of “tape replay” and means that in addition to our input and output expansions used above, we need only maintain two additional Pauli expansions during gradient evaluation, independent of the circuit length. We prepare these initially empty Pauli expansions just like those used above.

197  cupaulipropPauliExpansion_t inExpansion{}, outExpansion{}, cot0Expansion{}, cot1Expansion{};
198  const auto sortOrder = CUPAULIPROP_SORT_ORDER_NONE;
199  HANDLE_CUPP_ERROR(cupaulipropCreatePauliExpansion(
200      handle, NUM_CIRCUIT_QUBITS, dInXZ, EXPANSION_PAULI_MEM, dInCoef, EXPANSION_COEF_MEM,
201      CUDA_R_64F, 1, sortOrder, 0, &inExpansion));
202  HANDLE_CUPP_ERROR(cupaulipropCreatePauliExpansion(
203      handle, NUM_CIRCUIT_QUBITS, dOutXZ, EXPANSION_PAULI_MEM, dOutCoef, EXPANSION_COEF_MEM,
204      CUDA_R_64F, 0, sortOrder, 0, &outExpansion));
205  HANDLE_CUPP_ERROR(cupaulipropCreatePauliExpansion(
206      handle, NUM_CIRCUIT_QUBITS, dCot0XZ, EXPANSION_PAULI_MEM, dCot0Coef, EXPANSION_COEF_MEM,
207      CUDA_R_64F, 0, sortOrder, 0, &cot0Expansion));
208  HANDLE_CUPP_ERROR(cupaulipropCreatePauliExpansion(
209      handle, NUM_CIRCUIT_QUBITS, dCot1XZ, EXPANSION_PAULI_MEM, dCot1Coef, EXPANSION_COEF_MEM,
210      CUDA_R_64F, 0, sortOrder, 0, &cot1Expansion));

After the forward pass already seen, which computed the output expectation value \(\langle Z_{62} \rangle = s 2^p\), we are ready to proceed with backward differentiation. This involves computing the backward derivative of each API operation involved in evaluating \(\langle Z_{62} \rangle\), the final of which (and ergo our first to now process) was the trace function; that which output \(s\) and \(p\). So we “seed” our AD cotangent vector with the gradient of \(\langle Z_{62} \rangle\) (our “cost function”) with respect to these symbols.

\[\nabla_{s,p} \langle Z_{62} \rangle = ( \frac{\partial}{\partial s} \langle Z_{62} \rangle, \frac{\partial}{\partial p} \langle Z_{62} \rangle ) = ( 2^p, s 2^p \log_e(2) ).\]

And as always, it is prudent to use the prepare functions to ensure that our cotangent expansion has sufficient memory to store the output, which requires subsequently re-attaching the workspace buffer.

312  // ========================================================================
313  // Backward: seed cotangent from trace
314  // ========================================================================
315  const double cotS = std::exp2(p);
316  const double cotP = s * std::exp2(p) * std::log(2.0);
317  int64_t reqCotXZ = 0, reqCotCoef = 0;
318  HANDLE_CUPP_ERROR(cupaulipropPauliExpansionViewPrepareTraceWithZeroStateBackwardDiff(
319      handle, finalView, WORKSPACE_MEM, &reqCotXZ, &reqCotCoef, workspace));
320  if (reqCotXZ   > static_cast<int64_t>(EXPANSION_PAULI_MEM) ||
321      reqCotCoef > static_cast<int64_t>(EXPANSION_COEF_MEM))
322  {
323    std::cout
324        << "Insufficient cot0Expansion capacity to differentiate the trace. Exiting..."
325        << std::endl;
326    std::abort();
327  }

This modified cot0Expansion, our cotangent expansion. We now enumerate backward through the operators applied upon our original Pauli expansion, which happens to be the forward order of the experimental circuit, merely because we originally worked in the Heisenberg picture. Each processed operator will contribute to either the \(\theta_X\) or \(\theta_Z\) component of the gradient, which we store separately.

329  reattachWorkspace(handle, workspace, dWorkspace);
330  HANDLE_CUPP_ERROR(cupaulipropPauliExpansionViewComputeTraceWithZeroStateBackwardDiff(
331      handle, finalView, &cotS, &cotP, cot0Expansion, workspace, stream));
332  HANDLE_CUPP_ERROR(cupaulipropDestroyPauliExpansionView(finalView));
333
334  // ========================================================================
335  // Backward pass: reverse-mode AD with tape replay
336  // ========================================================================
337  double gradX = 0.0, gradZZ = 0.0;
338  auto bwdStartTime = std::chrono::high_resolution_clock::now();
339
340  for (size_t i = 0; i < circuit.size(); ++i) {
341    int64_t inTerms = 0;
342    cupaulipropPauliExpansionView_t inView{};
343    HANDLE_CUPP_ERROR(cupaulipropPauliExpansionGetNumTerms(handle, inExpansion, &inTerms));
344    HANDLE_CUPP_ERROR(cupaulipropPauliExpansionGetContiguousRange(handle, inExpansion, 0, inTerms, &inView));

For each operator, we apply its inverse (which for unitary operators is merely the adjoint) in order to restore the intermediate expansion from before that operator was applied (this is the tape replay).

346    HANDLE_CUPP_ERROR(cupaulipropPauliExpansionViewPrepareOperatorApplication(
347        handle, inView, circuit[i].op, sortOrder, 0, 2, truncStrats, WORKSPACE_MEM, &reqCotXZ, &reqCotCoef, workspace));
348    reattachWorkspace(handle, workspace, dWorkspace);
349    HANDLE_CUPP_ERROR(cupaulipropPauliExpansionViewComputeOperatorApplication(
350        handle, inView, outExpansion, circuit[i].op, 0, sortOrder, 0, 2, truncStrats, workspace, stream));
351    HANDLE_CUPP_ERROR(cupaulipropDestroyPauliExpansionView(inView));
352    std::swap(inExpansion, outExpansion);

We pass this intermediate expansion, along with our cotangent expansion, to the backward derivative function of the operator, obtaining a term of a derivative of \(\langle Z_{62} \rangle\).

355    int64_t cotTerms = 0, reqXZ = 0, reqCoef = 0, reqWs = 0;
356    cupaulipropPauliExpansionView_t viewIn{}, cotOutView{};
357    HANDLE_CUPP_ERROR(cupaulipropPauliExpansionGetNumTerms(handle, inExpansion, &inTerms));
358    HANDLE_CUPP_ERROR(cupaulipropPauliExpansionGetContiguousRange(handle, inExpansion, 0, inTerms, &viewIn));
359    HANDLE_CUPP_ERROR(cupaulipropPauliExpansionGetNumTerms(handle, cot0Expansion, &cotTerms));
360    HANDLE_CUPP_ERROR(cupaulipropPauliExpansionGetContiguousRange(handle, cot0Expansion, 0, cotTerms, &cotOutView));
361    HANDLE_CUPP_ERROR(cupaulipropPauliExpansionViewPrepareOperatorApplicationBackwardDiff(
362        handle, viewIn, cotOutView, circuit[i].op, sortOrder, 0, 2, truncStrats, WORKSPACE_MEM, &reqXZ, &reqCoef, workspace));
363    HANDLE_CUPP_ERROR(cupaulipropWorkspaceGetMemorySize(
364        handle, workspace, CUPAULIPROP_MEMSPACE_DEVICE, CUPAULIPROP_WORKSPACE_SCRATCH, &reqWs));
365    if (reqXZ   > static_cast<int64_t>(EXPANSION_PAULI_MEM) ||
366        reqCoef > static_cast<int64_t>(EXPANSION_COEF_MEM)  ||
367        reqWs   > static_cast<int64_t>(WORKSPACE_MEM))
368    {
369      std::cout
370          << "Insufficient cot1Expansion capacity and/or workspace buffer size "
371          << "to differentiate operator application. Exiting..."
372          << std::endl;
373      std::abort();
374    }
375    reattachWorkspace(handle, workspace, dWorkspace);
376
377    double gateGrad = 0.0;
378    HANDLE_CUPP_ERROR(cupaulipropQuantumOperatorAttachCotangentBuffer(
379        handle, circuit[i].op, &gateGrad, sizeof(double), CUDA_R_64F, CUPAULIPROP_MEMSPACE_HOST));
380    HANDLE_CUPP_ERROR(cupaulipropPauliExpansionViewComputeOperatorApplicationBackwardDiff(
381        handle, viewIn, cotOutView, cot1Expansion, circuit[i].op, 1, sortOrder, 0, 2, truncStrats, workspace, stream));
382    HANDLE_CUPP_ERROR(cupaulipropDestroyPauliExpansionView(viewIn));
383    HANDLE_CUPP_ERROR(cupaulipropDestroyPauliExpansionView(cotOutView));

If all our circuit operators were uniquely parameterised (e.g. \(\hat{U} = \hat{U}_2(\theta_2)\hat{U}_1(\theta_1)\)), this quantity would be an element of the gradient, corresponding to e.g.

\[\frac{\partial \hat{U}}{\partial \theta_2} = \frac{\partial\hat{U}_2}{\partial \theta_2} \hat{U}_1(\theta_1).\]

But because our \(\theta_X\) and \(\theta_Z\) parameters are repeated between gates (akin to \(\hat{U} = \hat{U}_2(\theta)\hat{U}_1(\theta)\)), the product-rule prescribes

\[\frac{\partial \hat{U}}{\partial \theta} = \frac{\partial\hat{U}_2}{\partial \theta} \hat{U}_1(\theta) + \hat{U}_2(\theta) \frac{\partial\hat{U}_1}{\partial \theta},\]

and so the output quantity (which is linear in \(\hat{U}\)) is one of the above summands, contributing to either the \(\theta_X\) or \(\theta_Z\) gradient element.

385    if (circuit[i].isX) gradX += gateGrad; else gradZZ += gateGrad;
386    std::swap(cot0Expansion, cot1Expansion);
387  }

After fully enumerating the circuit operators in this way, gradX and gradZZ contain the desired gradient.

390  auto endTime = std::chrono::high_resolution_clock::now();
391  auto bwdDuration = std::chrono::duration_cast<std::chrono::microseconds>(endTime - bwdStartTime);
392  auto totalDuration = std::chrono::duration_cast<std::chrono::microseconds>(endTime - startTime);
393  double bwdSecs = bwdDuration.count() / 1e6;
394  double totalSecs = totalDuration.count() / 1e6;
395
396  // ========================================================================
397  // Results
398  // ========================================================================
399  std::cout << "Backward pass completed in " << bwdSecs << " seconds" << std::endl;
400  std::cout << std::endl;
401  std::cout << "Expectation value:       " << expec << std::endl;
402  std::cout << "d<Z_62>/d(x_angle):      " << gradX << std::endl;
403  std::cout << "d<Z_62>/d(zz_angle):     " << gradZZ << std::endl;
404  std::cout << "Final number of terms:   " << finalTerms << std::endl;
405  std::cout << "Maximum number of terms: " << maxNumTerms << std::endl;
406  std::cout << "Total runtime:           " << totalSecs << " seconds" << std::endl;
407  std::cout << std::endl;

Fused operators example#

In this example, we demonstrate use of cuPauliProp’s fused API, to efficiently apply a sequence of operators. This interface relaxes some of the memory requirements of the single-operator API seen in the above example, and is generally higher performing, though involves more nuanced usage. This example will comparatively benchmark the fused and single-operator APIs upon an arbitrary, random Pauli expansion. Note that presently, only Clifford gates and Pauli noise channels are supported by the fused API, and showcased herein. Note this example makes use of some utility functions defined in example_utils.cpp. The full demonstration is available at fused_operators_example.cpp.

This example will make use of the below adjustable hyperparameters.

33// ========================================================================
34// HYPER-PARAMETERS (can be changed)
35// ========================================================================
36
37
38constexpr int32_t NUM_QUBITS = 700;
39
40constexpr int64_t TARGET_NUM_TERMS = 10'000;
41
42constexpr int32_t NUM_CLIFFORD_GATES = 300;
43
44constexpr int32_t NUM_PAULI_NOISE_CHANNELS = 300;

Parameters NUM_QUBITS and TARGET_NUM_TERMS inform the size of the example’s utilised Pauli expansions, which may (due to their random preparation, detailed later) actually contain twice as many terms as TARGET_NUM_TERMS, but no fewer. These parameters determine the device memory used by the example.

Parameters NUM_CLIFFORD_GATES and NUM_PAULI_NOISE_CHANNELS are the respective number of Clifford gates and Pauli noise channels in the sequences that will be passed to the new fused API, and also applied in-turn via the existing single-operator API. They inform the fused API speedup, and also the required device capabilities.

For simplicity, this example will fix the below hyperparameters, which must not be changed here without other logical revisions to the example.

49// ========================================================================
50// CONSTANTS (are strictly fixed)
51// ========================================================================
52
53
54// Our Pauli expansions maintain real, double-precision coefficients
55constexpr auto COEF_TYPE = CUDA_R_64F;
56
57// We will only apply operators in the Schrodinger picture
58constexpr int32_t ADJOINT = 0;
59
60// We disable any sorting or de-duplication of pauli expansion terms, since they
61// do not affect the interaction with, nor performance of, the fused-operator API
62constexpr auto SORT_ORDER = CUPAULIPROP_SORT_ORDER_NONE;
63constexpr int DUPLICATES = 1;
64
65// We disable truncation for simplicity, since although accepted by the
66// fused-operator API, it has only a trivial/additive performance overhead
67constexpr int32_t NUM_TRUNC_STRATS = 0;
68constexpr cupaulipropTruncationStrategy_t* TRUNC_STRATS = nullptr;

We also define the below helper functions, which calculate the memory needed to prepare expansions informed by the hyperparameters, or wrap cuPauliProp API calls for brevity.

 73// ========================================================================
 74// SMALL HELPER FUNCTIONS
 75// ========================================================================
 76
 77
 78auto getPauliStrAndCoefSizes() {
 79
 80  // Size of Pauli string depends upon the number of qubits
 81  int32_t numPackedIntsPerXZMask;
 82  HANDLE_CUPP_ERROR( cupaulipropGetNumPackedIntegers(NUM_QUBITS, &numPackedIntsPerXZMask) );
 83  size_t numBytesPerPauliStr = 2 * numPackedIntsPerXZMask * sizeof(cupaulipropPackedIntegerType_t);
 84
 85  // Size of coefficient depends upon type
 86  size_t numBytesPerCoef = sizeof(double); // COEF_TYPE = CUDA_R_64F
 87
 88  return std::array{numBytesPerPauliStr, numBytesPerCoef};
 89}
 90
 91
 92auto getNewExpansionBufferSizes() {
 93
 94  auto [numBytesPerPauliStr, numBytesPerCoef] = getPauliStrAndCoefSizes();
 95  size_t numBytesPerTerm = numBytesPerPauliStr + numBytesPerCoef;
 96
 97  // Allocate enough for TWICE our desired expansion size, to handle if we over-populate
 98  auto numTerms = 2 * TARGET_NUM_TERMS;
 99  size_t pauliBufferSize = numBytesPerPauliStr * numTerms;
100  size_t coefBufferSize  = numBytesPerCoef     * numTerms;
101  return std::array{ pauliBufferSize, coefBufferSize };
102}
103
104
105auto getExistingExpansionBufferSizes(cupaulipropHandle_t handle, cupaulipropPauliExpansion_t expansion) {
106
107  int64_t xzBitsBufferSize;
108  int64_t coefBufferSize;
109
110  void* dummyBuffer;
111  int64_t dummyNumTerms;
112  cupaulipropMemspace_t dummyLocatin;
113
114  HANDLE_CUPP_ERROR( cupaulipropPauliExpansionGetStorageBuffer(
115    handle, expansion, 
116    &dummyBuffer, &xzBitsBufferSize, 
117    &dummyBuffer, &coefBufferSize, 
118    &dummyNumTerms, &dummyLocatin) );
119
120  return std::array{xzBitsBufferSize, coefBufferSize};
121}
122
123
124int64_t getExistingExpansionTermCapacity(cupaulipropHandle_t handle, cupaulipropPauliExpansion_t expansion) {
125
126  auto [xzBitsBufferSize, coefBufferSize] = getExistingExpansionBufferSizes(handle, expansion);
127  auto [numBytesPerPauliStr, numBytesPerCoef] = getPauliStrAndCoefSizes();
128
129  return std::min(
130    xzBitsBufferSize / static_cast<int64_t>(numBytesPerPauliStr),
131    coefBufferSize   / static_cast<int64_t>(numBytesPerCoef));
132}
133
134
135int64_t getPopulatedExpansionMemorySize(int64_t numTerms) {
136
137  int32_t numPackedIntsPerXZMask;
138  HANDLE_CUPP_ERROR( cupaulipropGetNumPackedIntegers(NUM_QUBITS, &numPackedIntsPerXZMask) );
139
140  int64_t numBytesPerPauliStr =
141    2LL * numPackedIntsPerXZMask * static_cast<int64_t>(sizeof(cupaulipropPackedIntegerType_t));
142
143  int64_t numBytesPerCoef = sizeof(double); // COEF_TYPE = CUDA_R_64F
144  return numTerms * (numBytesPerPauliStr + numBytesPerCoef);
145}
146
147
148auto createViewOfAllTerms(cupaulipropHandle_t handle, cupaulipropPauliExpansion_t expansion) {
149
150  int64_t numTerms;
151  HANDLE_CUPP_ERROR( cupaulipropPauliExpansionGetNumTerms(handle, expansion, &numTerms) );
152
153  cupaulipropPauliExpansionView_t inView;
154  HANDLE_CUPP_ERROR( cupaulipropPauliExpansionGetContiguousRange(
155    handle, expansion, 0, numTerms, &inView) );
156
157  return inView;
158}

We next define functions applySingleOperator and applyFusedOperators, which will showcase the respective workflows of applying one or applying many operators.

163// ========================================================================
164// INDIVIDUAL AND FUSED OPERATOR APPLICATION
165// ========================================================================

The applySingleOperator function uses the existing single-operator API to deterministically (i.e. without any expected failure) modify a Pauli expansion under the action of a single operator. It follows the standard paradigm witnessed in the above Kicked Ising example, whereby we prepare a view of the input expansion, call Prepare to validate our memory buffers are sufficient for the worst-case expansion growth, re-attach the workspace buffer, deterministically apply the operator, then destroy the view. The workspace and expansion buffers are prior allocated, and merely passed here.

169void applySingleOperator(
170  cupaulipropHandle_t handle,
171  cudaStream_t stream,
172  cupaulipropPauliExpansion_t inExpansion,
173  cupaulipropPauliExpansion_t outExpansion,
174  cupaulipropQuantumOperator_t quantumOp,
175  cupaulipropWorkspaceDescriptor_t workspace,
176  void* d_workspaceBuffer,
177  size_t workspaceBufferSize)
178{
179  // Create a view of all terms in the expansion, in order for all to be processed
180  auto inView = createViewOfAllTerms(handle, inExpansion);
181
182  // Call Prepare() to obtain the REQUIRED expansion and workspace sizes; if
183  // these are not satisfied, the corresponding Compute() call will error
184  int64_t requiredPauliSize;
185  int64_t requiredCoefSize;
186  HANDLE_CUPP_ERROR( cupaulipropPauliExpansionViewPrepareOperatorApplication(
187    handle, inView, quantumOp,
188    SORT_ORDER, DUPLICATES, NUM_TRUNC_STRATS, TRUNC_STRATS,
189    workspaceBufferSize, &requiredPauliSize, &requiredCoefSize, workspace) );
190
191  // The required workspace size is attached to the descriptor
192  int64_t requiredWorkspaceSize;
193  HANDLE_CUPP_ERROR( cupaulipropWorkspaceGetMemorySize(
194    handle, workspace,
195    CUPAULIPROP_MEMSPACE_DEVICE, CUPAULIPROP_WORKSPACE_SCRATCH,
196    &requiredWorkspaceSize) );
197
198  // Ensure sufficient memory, guaranteeing the Compute() call will succeed
199  auto [pauliBufferSize, coefBufferSize] = getExistingExpansionBufferSizes(handle, outExpansion);
200  if (requiredPauliSize     > static_cast<int64_t>(pauliBufferSize) ||
201      requiredCoefSize      > static_cast<int64_t>(coefBufferSize)  ||
202      requiredWorkspaceSize > static_cast<int64_t>(workspaceBufferSize))
203  {
204    std::cout
205      << "Insufficient outExpansion capacity and/or workspace buffer size "
206      << "to perform single-operator application. Exiting..."
207      << std::endl;
208    std::abort();
209  }
210
211  // Re-attach the workspace memory buffer, which is detached by the Prepare() call
212  HANDLE_CUPP_ERROR( cupaulipropWorkspaceSetMemory(
213    handle, workspace,
214    CUPAULIPROP_MEMSPACE_DEVICE, CUPAULIPROP_WORKSPACE_SCRATCH,
215    d_workspaceBuffer, workspaceBufferSize) );
216
217  // Apply the individual operator, which always exceeds (except due to a bug!)
218  HANDLE_CUPP_ERROR( cupaulipropPauliExpansionViewComputeOperatorApplication(
219    handle, inView, outExpansion, quantumOp,
220    ADJOINT, SORT_ORDER, DUPLICATES,
221    NUM_TRUNC_STRATS, TRUNC_STRATS, workspace, stream) );
222
223  // Destroy the view, which has been invalidated by the above Compute() call
224  HANDLE_CUPP_ERROR( cupaulipropDestroyPauliExpansionView(inView) );
225}

Worst-case expansion growth refers to the scenario where every term in the input Pauli expansion is modified by the operator to become a sum of distinct terms, with the maximum number of summands possible of the operator. This number is sometimes referred to as the “branching factor” of the operator, and is equal to the maximum number of non-zero terms in any column of the operator’s Pauli transfer matrix. For example, the branching factor of Pauli rotation gates and amplitude damping channels is 2, while that of Pauli noise channels and Clifford gates is 1. This means applying a Pauli rotation gate via the single-operator API above requires providing an output expansion (and workspace) with sufficient capacity to store (or process) at least twice as many terms as exist in the input expansion. Such is the output of cupaulipropPauliExpansionViewPrepareOperatorApplication(), although in this demonstration, the input operators happen to have branching factors of 1.

The fused API necessarily relaxes these requirements, for reasons we elaborate upon below. Its expected usage, demonstrated in applyFusedOperators below, follows a similar pattern to applySingleOperator but has some notable nuances.

Firstly, it accepts a std::vector of quantum operators, each of which has a corresponding adjoint flag (which in this example, are all fixed to ADJOINT=0). And secondly, it involves the simultaneous management of three workspaces:

229void applyFusedOperators(
230  cupaulipropHandle_t handle,
231  cudaStream_t stream,
232  cupaulipropPauliExpansion_t inExpansion,
233  cupaulipropPauliExpansion_t outExpansion,
234  const std::vector<cupaulipropQuantumOperator_t>& quantumOps,
235  void* d_workspaceBuffer,
236  size_t workspaceBufferSize,
237  cupaulipropWorkspaceDescriptor_t minWorkspace,
238  cupaulipropWorkspaceDescriptor_t maxWorkspace,
239  cupaulipropWorkspaceDescriptor_t avgWorkspace)
240{
241  // Create a view of all terms in the expansion, in order for all to be processed
242  auto inView = createViewOfAllTerms(handle, inExpansion);
243  const std::vector<int32_t> adjoints(quantumOps.size(), ADJOINT);

This is because, unlike the deterministic single-operator API which demanded upfront memory buffers sufficient for worst-case growth, the fused API accepts smaller buffers. If these smaller buffers turn out insufficient during computation, the API function will fail on the fly. Applying a sequence of quantum operators upon a Pauli expansion in this manner suggests three meaningful thresholds of memory requirements.

  • Best case: every term in the expansion is mapped to another single term, by each operator in the sequence. The output expansion needs only capacity to store the input expansion. Ignoring the unimportant possibility of a reduction in the number of terms (via duplication, or by a non-physical operator mapping some terms to have zero coefficients), this scenario has the minimum memory requirements. Providing less memory than this minimum is invalid, and will guarantee that fused operator application fails.

  • Worst case: every operator exhibits the worst-case scenario of single-operator application, whereby every term in the expansion is mapped to as many terms as the operator’s branching factor. After n operators each with branching factor b, the output Pauli expansion has a factor pow(b,n) more terms than the input expansion. When b>1 (such as for Pauli rotations and amplitude damping channels), this exponentially-growing factor is quickly intractable; 30 Pauli rotations can grow the expansion by a factor of one billion! Providing an output expansion with sufficient capacity for this scenario (along with corresponding workspace) will guarantee that the fused operator application succeeds, but is often infeasible, and gratuitously expensive when the actual operator growth is expected to be much less.

  • Average case: every operator exhibits the expected growth of the expansion, when treating every input term (even those produced by each intermediate operator) as uniformly random in Hilbert space. This is not a simple interpolation between the best and worst case scenarios; the expected growth is instead informed by the Pauli transfer matrix of the fused operators. Though still often growing exponentially with the number of fused operators, the average case memory requirements are significantly smaller than the worst case requirements. For example, n Pauli rotations has an average growth factor of pow(3/2, n), while n amplitude damping channels has pow(5/4, n), despite their equal branching factors (of 2).

The fused API Prepare function, chiefly cupaulipropPauliExpansionViewPrepareOperatorFusedApplication, provides the output expansion and corresponding workspace capacities of these scenarios.

246  // Call Prepare() to obtain the expansion capacities and workspaces necessary to
247  // handle the best-case, worst-case and average-case Compute() scenarios. These
248  // correspond to different magnitudes of expansion growth under the operators.
249  int64_t minOutCapacity;
250  int64_t maxOutCapacity; // -1 if overflowed
251  int64_t avgOutCapacity; // -1 if overflowed
252  cupaulipropStatus_t prepareStatus = cupaulipropPauliExpansionViewPrepareOperatorFusedApplication(
253    handle, inView, quantumOps.size(), quantumOps.data(), adjoints.data(),
254    NUM_TRUNC_STRATS, TRUNC_STRATS,
255    static_cast<int64_t>(workspaceBufferSize),
256    &minOutCapacity, minWorkspace,
257    &avgOutCapacity, avgWorkspace,
258    &maxOutCapacity, maxWorkspace);

We note that in this demonstration, only Clifford gates and Pauli noise channels will be simulated, which are both non-branching. As such, the minimum, maximum and average memories are all equivalent. We however showcase the general usage pattern anticipated for when the fused API accepts branching operators, such as Pauli rotation gates and amplitude damping channels.

The above fused-operator Prepare call can fail, returning a non-success status code, in all the ways possible of the single-operator Prepare call. However, it can also fail in some additional ways:

262  // Process the additional, "recoverable" failure modes of the fused-operator API,
263  // which are not exhibited by the single-operator API.
264  switch (prepareStatus)
265  {
266    case CUPAULIPROP_STATUS_SUCCESS:
267      break;
268    case CUPAULIPROP_STATUS_INSUFFICIENT_DEVICE_PROPERTY:
269      std::cout
270        << "Too many fused operators (" << quantumOps.size() << ") were given, exceeding "
271        << "a hardware limitation of the user's device. One could dynamically shrink "
272        << "the number passed, but in this simple example, we treat this as an error."
273        << std::endl;
274      std::abort();
275    default:
276      HANDLE_CUPP_ERROR(prepareStatus);
277
278    // Below are not thrown by the Prepare() call
279    // case CUPAULIPROP_STATUS_INSUFFICIENT_WORKSPACE:
280    // case CUPAULIPROP_STATUS_INSUFFICIENT_OUT_EXPANSION:
281  }

As we will handle below, the average and maximum memory sizes can also incidentally overflow, because intractably many operators were passed. In that scenario, those outputs are set to -1.

We next process the six outputs of the Prepare call, comparing them to our given expansions and workspace, which will be allocated to a fixed size prior to calling applyFusedOperators.

285  // Learn how many terms our pre-allocated outExpansion can store
286  int64_t outTermCapacity = getExistingExpansionTermCapacity(handle, outExpansion);
287
288  // Learn the workspace buffer sizes advised by Prepare() above
289  int64_t minWorkspaceSize;
290  int64_t avgWorkspaceSize;
291  int64_t maxWorkspaceSize;
292  HANDLE_CUPP_ERROR( cupaulipropWorkspaceGetMemorySize(
293    handle, minWorkspace,
294    CUPAULIPROP_MEMSPACE_DEVICE, CUPAULIPROP_WORKSPACE_SCRATCH,
295    &minWorkspaceSize) );
296  HANDLE_CUPP_ERROR( cupaulipropWorkspaceGetMemorySize(
297    handle, avgWorkspace,
298    CUPAULIPROP_MEMSPACE_DEVICE, CUPAULIPROP_WORKSPACE_SCRATCH,
299    &avgWorkspaceSize) );
300  HANDLE_CUPP_ERROR( cupaulipropWorkspaceGetMemorySize(
301    handle, maxWorkspace,
302    CUPAULIPROP_MEMSPACE_DEVICE, CUPAULIPROP_WORKSPACE_SCRATCH,
303    &maxWorkspaceSize) );
304
305  // Compare our pre-allocated expansion and workspace buffers to the min-memory scenario,
306  // for which the outputs cannot overflow without a returned error code. Note that we
307  // already know the workspace is sufficient, since the above Prepare() call would 
308  // otherwise return error CUPAULIPROP_STATUS_INSUFFICIENT_WORKSPACE.
309  if (minOutCapacity > outTermCapacity || minWorkspaceSize > workspaceBufferSize)
310  {
311    std::cout
312      << "The best-case memory sizes exceed our outExpansion and/or workspace buffer sizes. "
313      << "This is invalid and will trigger an error in the Compute() call. Exiting..."
314      << std::endl;
315    std::abort();
316  }
317
318  // Compare our pre-allocated expansion and workspace buffers to the max-memory scenario
319  if (maxOutCapacity == -1 || maxWorkspaceSize == -1)
320  {
321    std::cout
322      << "  (The worst-case memory sizes overflowed and are reported as -1. "
323      << "This example will continue, with a non-zero likelihood of failure.)"
324      << std::endl;
325  } 
326  else if (maxOutCapacity > outTermCapacity || maxWorkspaceSize > workspaceBufferSize)
327  {
328    std::cout
329      << "  (The worst-case memory sizes exceed our outExpansion and/or workspace buffer sizes. "
330      << "This example will continue, with a non-zero likelihood of failure.)"
331      << std::endl;
332  }
333  else
334  {
335    std::cout
336      << "  (The worst-case memory sizes are smaller than our pre-allocated buffers. "
337      << "As such, the fused API is guaranteed to succeed.)"
338      << std::endl;
339  }
340
341  // Compare our pre-allocated expansion and workspace buffers to the average-memory scenario
342  if (avgOutCapacity == -1 || avgWorkspaceSize == -1)
343  {
344    std::cout
345      << "  (The average-case memory estimate overflowed and is reported as -1. "
346      << "This example will continue, with a considerable likelihood of failure.)"
347      << std::endl;
348  } 
349  else if (avgOutCapacity > outTermCapacity || avgWorkspaceSize > workspaceBufferSize)
350  {
351    std::cout
352      << "  (The average-case memory sizes exceed our outExpansion and/or workspace buffer sizes. "
353      << "This example will continue, with a possibly large likelihood of failure.)"
354      << std::endl;
355  } 
356  else
357  {
358    // When the buffers exceed the average case, there's nothing significant to report!  
359  }

We remind again that because this example only features non-branching gates, which cause the minimum, maximum and average memory scenarios to agree, the above pathways are not actually exercised, and our prior allocated buffers are guaranteed to be sufficient.

363  // Attach our pre-allocated workspace buffer to a workspace; we arbitrarily choose avgWorkspace
364  HANDLE_CUPP_ERROR( cupaulipropWorkspaceSetMemory(
365    handle, avgWorkspace,
366    CUPAULIPROP_MEMSPACE_DEVICE, CUPAULIPROP_WORKSPACE_SCRATCH,
367    d_workspaceBuffer, workspaceBufferSize) );

We are finally ready to apply the sequence of operators, performed in a single call to cupaulipropPauliExpansionViewComputeOperatorFusedApplication. Because application can in principle fail on the fly, there are additional statuses to process.

371  // Attempt to apply all operators at once
372  cupaulipropStatus_t computeStatus = cupaulipropPauliExpansionViewComputeOperatorFusedApplication(
373    handle, inView, outExpansion, quantumOps.size(), quantumOps.data(), adjoints.data(),
374    NUM_TRUNC_STRATS, TRUNC_STRATS, avgWorkspace, stream);
375
376  switch (computeStatus) {
377    case CUPAULIPROP_STATUS_SUCCESS:
378      break;
379    case CUPAULIPROP_STATUS_INSUFFICIENT_WORKSPACE:
380    case CUPAULIPROP_STATUS_INSUFFICIENT_OUT_EXPANSION:
381      std::cout
382        << "Fused application failed on-the-fly, due to the provided expansion and/or "
383        << "workspace being insufficient to contain the expansion growth under the given "
384        << "operators. One could dynamically shrink the number of passed operators, or "
385        << "enlarge the memory capacities, but in this simple example, we opt to exit."
386        << std::endl;
387      std::abort();
388    default:
389      HANDLE_CUPP_ERROR(computeStatus);
390
391    // Below are made impossible by our prior Prepare() call:
392    // case CUPAULIPROP_FAILURE_NUM_OPERATORS_EXCEEDS_DEVICE_LIMITATION:
393  }
394
395  HANDLE_CUPP_ERROR( cupaulipropDestroyPauliExpansionView(inView) );

While we simply exit after failure to keep this example simple, it is straightforward to reduce the number of operators in the sequence and retry, re-calling the Prepare function and Compute functions, ping-ponging the input and output expansions.

That concludes the core demonstration of this example. The remaining code includes comparative benchmarking.

404// ========================================================================
405// BENCHMARKING
406// ========================================================================

Invoking the single-operator API involves creating a simple workspace, applying each operator in-turn by ping-ponging between the input and output expansions. Beware that inExpansion below is therefore also mutated, while outExpansion will contain the final state.

410auto timeSingleOperatorSequence(
411  cupaulipropHandle_t handle,
412  cudaStream_t stream,
413  cupaulipropPauliExpansion_t inExpansion,
414  cupaulipropPauliExpansion_t outExpansion,
415  const std::vector<cupaulipropQuantumOperator_t>& quantumOps,
416  void* d_workspaceBuffer,
417  size_t workspaceBufferSize)
418{
419  cupaulipropWorkspaceDescriptor_t workspace;
420  HANDLE_CUPP_ERROR( cupaulipropCreateWorkspaceDescriptor(handle, &workspace) );
421
422  // Warm up before timing (leaves inExpansion unchanged)
423  applySingleOperator(
424    handle, stream,
425    inExpansion, outExpansion, quantumOps[0],
426    workspace, d_workspaceBuffer, workspaceBufferSize);
427  HANDLE_CUDA_ERROR( cudaStreamSynchronize(stream) );
428
429  const auto start = std::chrono::steady_clock::now();
430
431  // Apply every operator in-turn
432  for (auto quantumOp : quantumOps) {
433
434    applySingleOperator(
435      handle, stream,
436      inExpansion, outExpansion, quantumOp,
437      workspace, d_workspaceBuffer, workspaceBufferSize);
438
439    std::swap(inExpansion, outExpansion);
440  }
441
442  HANDLE_CUDA_ERROR( cudaStreamSynchronize(stream) );
443
444  const auto end = std::chrono::steady_clock::now();
445
446  // Destroy workspace outside of timing
447  HANDLE_CUPP_ERROR( cupaulipropDestroyWorkspaceDescriptor(workspace) );
448
449  return end - start;
450}

Invoking the fused-operator API requires preparing three workspace descriptors, as seen above. The inExpansion passed to the below benchmarking function is not mutated.

454auto timeFusedOperatorSequence(
455  cupaulipropHandle_t handle,
456  cudaStream_t stream,
457  cupaulipropPauliExpansion_t inExpansion,
458  cupaulipropPauliExpansion_t outExpansion,
459  const std::vector<cupaulipropQuantumOperator_t>& quantumOps,
460  void* d_workspaceBuffer,
461  size_t workspaceBufferSize)
462{
463  cupaulipropWorkspaceDescriptor_t minWorkspace;
464  cupaulipropWorkspaceDescriptor_t maxWorkspace;
465  cupaulipropWorkspaceDescriptor_t avgWorkspace;
466  HANDLE_CUPP_ERROR( cupaulipropCreateWorkspaceDescriptor(handle, &minWorkspace) );
467  HANDLE_CUPP_ERROR( cupaulipropCreateWorkspaceDescriptor(handle, &maxWorkspace) );
468  HANDLE_CUPP_ERROR( cupaulipropCreateWorkspaceDescriptor(handle, &avgWorkspace) );
469
470  // Warm up before timing (leaves inExpansion unchanged)
471  applyFusedOperators(
472    handle, stream,
473    inExpansion, outExpansion, quantumOps,
474    d_workspaceBuffer, workspaceBufferSize,
475    minWorkspace, maxWorkspace, avgWorkspace);
476  HANDLE_CUDA_ERROR( cudaStreamSynchronize(stream) );
477
478  const auto start = std::chrono::steady_clock::now();
479
480  applyFusedOperators(
481    handle, stream,
482    inExpansion, outExpansion, quantumOps,
483    d_workspaceBuffer, workspaceBufferSize,
484    minWorkspace, maxWorkspace, avgWorkspace);
485  HANDLE_CUDA_ERROR( cudaStreamSynchronize(stream) );
486
487  const auto end = std::chrono::steady_clock::now();
488
489  HANDLE_CUPP_ERROR( cupaulipropDestroyWorkspaceDescriptor(minWorkspace) );
490  HANDLE_CUPP_ERROR( cupaulipropDestroyWorkspaceDescriptor(maxWorkspace) );
491  HANDLE_CUPP_ERROR( cupaulipropDestroyWorkspaceDescriptor(avgWorkspace) );
492
493  return end - start;
494}

It is straightforward to comparatively benchmark use of the single-operator and fused APIs simulating a sequence of quantum operators.

498void benchmarkOperatorSequence(
499  cupaulipropHandle_t handle,
500  cudaStream_t stream,
501  cupaulipropPauliExpansion_t inExpansion,
502  cupaulipropPauliExpansion_t outExpansion,
503  const std::vector<cupaulipropQuantumOperator_t>& quantumOps,
504  void* d_workspaceBuffer, size_t workspaceBufferSize)
505{
506  // Time the fused API first, since it does not modify inExpansion
507  auto fusedDuration = timeFusedOperatorSequence(
508    handle, stream,
509    inExpansion, outExpansion, quantumOps,
510    d_workspaceBuffer, workspaceBufferSize);
511  
512  // Time the single-operator API second, because it involves ping-ponging
513  // between inExpansion and outExpansion, mutating both! 
514  auto sequentialDuration = timeSingleOperatorSequence(
515    handle, stream,
516    inExpansion, outExpansion, quantumOps,
517    d_workspaceBuffer, workspaceBufferSize);
518
519  // Report the measured speedup of the fused API over the single-operator API.
520  const auto fusedMs      = std::chrono::duration<double, std::milli>(fusedDuration     ).count();
521  const auto sequentialMs = std::chrono::duration<double, std::milli>(sequentialDuration).count();
522  std::cout << std::endl;
523  std::cout << "Single-operator API: " << sequentialMs << " ms" << std::endl;
524  std::cout << "Fused-operator API:  " << fusedMs      << " ms" << std::endl;
525  std::cout << "Speedup:             " << sequentialMs / fusedMs << "x\n" << std::endl;
526}

Our final chore is to invoke the above benchmarking from main.

531// ========================================================================
532// MAIN
533// ========================================================================
534
535
536int main()
537{
538  std::cout << "cuPauliProp fused operators example" << std::endl;
539  std::cout << "===================================" << std::endl << std::endl;

This involves preparing the cuPauliProp library, creating the input and output expansions and their corresponding memory buffers, and initialising the input expansion; to an arbitrary single X operator on qubit 0.

543  /*
544   * PREPARE LIBRARY AND MEMORY BUFFERS
545   */
546
547  HANDLE_CUDA_ERROR( cudaSetDevice(0) );
548  cudaStream_t stream = 0;
549
550  cupaulipropHandle_t handle{};
551  HANDLE_CUPP_ERROR( cupaulipropCreate(&handle) );
552
553  // Prepare buffers for two Pauli expansions
554  const auto [pauliBufferSize, coefBufferSize] = getNewExpansionBufferSizes();
555  void* d_inPauliBuffer;
556  void* d_inCoefBuffer;
557  void* d_outPauliBuffer;
558  void* d_outCoefBuffer;
559  HANDLE_CUDA_ERROR( cudaMalloc(&d_inPauliBuffer,   pauliBufferSize) );
560  HANDLE_CUDA_ERROR( cudaMalloc(&d_inCoefBuffer,    coefBufferSize ) );
561  HANDLE_CUDA_ERROR( cudaMalloc(&d_outPauliBuffer,  pauliBufferSize) );
562  HANDLE_CUDA_ERROR( cudaMalloc(&d_outCoefBuffer,   coefBufferSize ) );
563
564  // Initialise the "in" Pauli expansion to a single term; X on qubit 0
565  const cupaulipropPackedIntegerType_t initX = 1;
566  const double initCoef = 1.0;
567  HANDLE_CUDA_ERROR( cudaMemset(d_inPauliBuffer, 0, pauliBufferSize) );
568  HANDLE_CUDA_ERROR( cudaMemcpy(d_inPauliBuffer, &initX,    sizeof(initX),    cudaMemcpyHostToDevice) );
569  HANDLE_CUDA_ERROR( cudaMemcpy(d_inCoefBuffer,  &initCoef, sizeof(initCoef), cudaMemcpyHostToDevice) );
570
571  // Create two Pauli expansions, to ping-pong as input and output to the cuPP API
572  cupaulipropPauliExpansion_t inExpansion;
573  cupaulipropPauliExpansion_t outExpansion;
574  HANDLE_CUPP_ERROR( cupaulipropCreatePauliExpansion(
575    handle, NUM_QUBITS,
576    d_inPauliBuffer, pauliBufferSize,
577    d_inCoefBuffer,  coefBufferSize, COEF_TYPE,
578    /*numTerms=*/1,
579    SORT_ORDER, DUPLICATES,
580    &inExpansion) );
581  HANDLE_CUPP_ERROR( cupaulipropCreatePauliExpansion(
582    handle, NUM_QUBITS,
583    d_outPauliBuffer, pauliBufferSize,
584    d_outCoefBuffer,  coefBufferSize, COEF_TYPE,
585    /*numTerms=*/0,
586    SORT_ORDER, DUPLICATES,
587    &outExpansion) );
588
589  // Allocate workspace buffer; descriptors are created where needed.
590  void* d_workspaceBuffer;
591  size_t workspaceBufferSize = 2 * (pauliBufferSize + coefBufferSize);
592  HANDLE_CUDA_ERROR( cudaMalloc(&d_workspaceBuffer, workspaceBufferSize) ); // attached in Prepare()

Because this example will only showcase non-branching operators (Clifford gates and Pauli noise channels), the input expansion will not grow from the current single term by application of the operators. It is therefore necessary to prepare a larger expansion; as large as the hyperparameter TARGET_NUM_TERMS. We do this by making use of the growExpansionViaRandomRotations() function defined in example_utils.cpp, populating inExpansion with numInputTerms-many arbitrary terms.

596  /*
597   * PREPARE A RANDOM EXPANSION
598   */
599
600  // Grow inExpansion (not outExpansion) to size >= TARGET_NUM_TERMS via random Pauli rotations
601  growExpansionViaRandomRotations(
602    handle, stream,
603    inExpansion, outExpansion,
604    NUM_QUBITS, TARGET_NUM_TERMS,
605    pauliBufferSize, coefBufferSize,
606    d_workspaceBuffer, workspaceBufferSize);
607
608  int64_t numInputTerms;
609  HANDLE_CUPP_ERROR( cupaulipropPauliExpansionGetNumTerms(handle, inExpansion, &numInputTerms) );
610  const int64_t inputExpansionBytes = getPopulatedExpansionMemorySize(numInputTerms);
611  const double inputExpansionMiB = static_cast<double>(inputExpansionBytes) / (1024.0 * 1024.0);
612  std::cout << "Input expansion size: " << numInputTerms
613            << " terms (" << NUM_QUBITS << " qubits)" << std::endl;
614  std::cout << "Input expansion memory: " << inputExpansionBytes
615            << " bytes (" << std::fixed << std::setprecision(2) << inputExpansionMiB
616            << " MiB)" << std::endl << std::endl;

We first comparatively benchmark the single-operator and fused APIs with sequences of Pauli noise channels. We use an arbitrary list of one and two-qubit channels, with random target qubits and error probabilities, obtained from createRandomPauliNoiseChannel(), defined in example_utils.cpp.

620  /*
621   * BENCHMARK RANDOM PAULI NOISE CHANNELS
622   */
623
624  std::cout << "Applying " << NUM_PAULI_NOISE_CHANNELS << " Pauli noise channels...\n" << std::endl;
625
626  // Prepare a list of random Pauli channels (1 and 2 qubit)
627  std::mt19937_64 rng(5678);
628  std::vector<cupaulipropQuantumOperator_t> pauliNoiseChannels;
629  for (int32_t i = 0; i < NUM_PAULI_NOISE_CHANNELS; ++i)
630    pauliNoiseChannels.push_back(createRandomPauliNoiseChannel(handle, rng, NUM_QUBITS));
631
632  benchmarkOperatorSequence(
633    handle, stream,
634    inExpansion, outExpansion, pauliNoiseChannels,
635    d_workspaceBuffer, workspaceBufferSize);

Note that Pauli noise channels do not mutate Pauli strings, and instead only change the expansion term coefficients, which themselves do not affect the runtime of subsequent operations performed in this example. As such, there is no need to restore the original state in inExpansion before proceeding.

We then comparatively benchmark simulation of random Clifford gates, again using utilities defined in example_utils.cpp.

639  /*
640   * BENCHMARK RANDOM CLIFFORD GATES
641   */
642
643   std::cout << "Applying " << NUM_CLIFFORD_GATES << " random Clifford gates...\n" << std::endl;
644
645  // Prepare a list of random Clifford gates (1 and 2 qubit, and random kinds)
646  std::vector<cupaulipropQuantumOperator_t> cliffordGates;
647  for (int32_t i = 0; i < NUM_CLIFFORD_GATES; ++i)
648    cliffordGates.push_back(createRandomCliffordGate(handle, rng, NUM_QUBITS));
649
650  // Comparative benchmarking penalises fused API with print statements - no big deal!
651  benchmarkOperatorSequence(
652    handle, stream,
653    inExpansion, outExpansion, cliffordGates,
654    d_workspaceBuffer, workspaceBufferSize);

With benchmarking complete, our final chore is to clean up.

658  /*
659   * CLEAN UP
660   */
661  
662  for (auto gate : cliffordGates)
663    HANDLE_CUPP_ERROR( cupaulipropDestroyOperator(gate) );
664  for (auto channel : pauliNoiseChannels)
665    HANDLE_CUPP_ERROR( cupaulipropDestroyOperator(channel) );
666  HANDLE_CUPP_ERROR( cupaulipropDestroyPauliExpansion(inExpansion) );
667  HANDLE_CUPP_ERROR( cupaulipropDestroyPauliExpansion(outExpansion) );
668  HANDLE_CUPP_ERROR( cupaulipropDestroy(handle) );
669  HANDLE_CUDA_ERROR( cudaFree(d_workspaceBuffer) );
670  HANDLE_CUDA_ERROR( cudaFree(d_inPauliBuffer) );
671  HANDLE_CUDA_ERROR( cudaFree(d_inCoefBuffer) );
672  HANDLE_CUDA_ERROR( cudaFree(d_outPauliBuffer) );
673  HANDLE_CUDA_ERROR( cudaFree(d_outCoefBuffer) );
674
675  return EXIT_SUCCESS;
676}

With the hyperparameters shown in the first code section, and using an NVIDIA A100 GPU, we anticipate an output such as:

cuPauliProp fused operators example
===================================

Input expansion size: 11402 terms (700 qubits)
Input expansion memory: 2097968 bytes (2.00 MiB)

Applying 300 Pauli noise channels...

  (The worst-case memory sizes are smaller than our pre-allocated buffers. As such, the fused API is guaranteed to succeed.)
  (The worst-case memory sizes are smaller than our pre-allocated buffers. As such, the fused API is guaranteed to succeed.)

Single-operator API: 20.09 ms
Fused-operator API:  1.21 ms
Speedup:             16.57x

Applying 300 random Clifford gates...

  (The worst-case memory sizes are smaller than our pre-allocated buffers. As such, the fused API is guaranteed to succeed.)
  (The worst-case memory sizes are smaller than our pre-allocated buffers. As such, the fused API is guaranteed to succeed.)

Single-operator API: 17.33 ms
Fused-operator API:  1.04 ms
Speedup:             16.60x

Useful tips#

  • For debugging, one can set the environment variable CUPAULIPROP_LOG_LEVEL=n. The level n = 0, 1, …, 5 corresponds to the logger level as described in the table below. The environment variable CUPAULIPROP_LOG_FILE=<filepath> can be used to redirect the log output to a custom file at <filepath> instead of stdout.

Level

Summary

Long Description

0

Off

Logging is disabled (default)

1

Errors

Only errors will be logged

2

Performance Trace

API calls that launch CUDA kernels will log their parameters and important information

3

Performance Hints

Hints that can potentially improve the application’s performance

4

Heuristics Trace

Provides general information about the library execution, may contain details about heuristic status

5

API Trace

API calls will log their parameter and important information