Skip to content

Commit d9703ea

Browse files
huaweil-nv1tnguyenkhalatepradnya
authored
Fix sample_async noise model lifetime and isolation (#3857)
<!-- Thanks for helping us improve CUDA-Q! ⚠️ The pull request title should be concise and understandable for all. ⚠️ If your pull request fixes an open issue, please link to the issue. Checklist: - [ ] I have added tests to cover my changes. - [ ] I have updated the documentation accordingly. - [ ] I have read the CONTRIBUTING document. --> ### Description <!-- Include relevant issues here, describe what changed and why --> ## Summary This PR fixes `cudaq.sample_async(..., noise_model=...)` for local simulators by ensuring the noise model is applied correctly during asynchronous execution and does not leak into subsequent calls. ## Root cause - The async implementation could reference a noise model whose lifetime ended before the queued task executed. - Noise configuration was not scoped to the async task lifetime, which could cause state pollution. - Noise set/reset needed to be applied per-QPU (using the provided `qpu_id`). ## Fix - Extend `details::runSamplingAsync` to accept an optional noise model and capture it by value inside the async task. - Set the noise model at the start of the async task and reset it on completion (including exception paths) to prevent state leakage. - Apply set/reset per-QPU using `qpu_id`. - Reject non-empty noise models on remote platforms with a clear error. - Update Python binding-side remote checks to respect the provided `qpu_id`. ## Tests Added regression tests in `python/tests/builder/test_NoiseModel.py`: - `test_sample_async_with_noise` - `test_sample_async_noise_isolation` ## How to repro 1. ``` import cudaq cudaq.set_target("density-matrix-cpu") cudaq.set_random_seed(42) k = cudaq.make_kernel() q = k.qalloc() k.x(q) k.mz(q) noise = cudaq.NoiseModel() noise.add_channel("x", [0], cudaq.DepolarizationChannel(1.0)) # Noise should be visible in async result. noisy = cudaq.sample_async(k, shots_count=1000, noise_model=noise).get() print("async noisy:", noisy) # Subsequent calls without noise must remain clean (no state pollution). clean = cudaq.sample(k, shots_count=200) print("after clean:", clean) assert clean.count("1") == 200 ``` 2. does not always occur ``` import cudaq, gc cudaq.set_target("density-matrix-cpu") cudaq.set_random_seed(42) k = cudaq.make_kernel() q = k.qalloc() k.x(q) k.mz(q) def launch_once(): # Noise model is intentionally scoped to this function. noise = cudaq.NoiseModel() noise.add_channel("x", [0], cudaq.DepolarizationChannel(1.0)) fut = cudaq.sample_async(k, shots_count=200, noise_model=noise) return fut futs = [launch_once() for _ in range(200)] gc.collect() # Try to encourage destruction of temporaries for f in futs: _ = f.get() print("done") ``` --------- Signed-off-by: huaweil <huaweil@nvidia.com> Co-authored-by: Thien Nguyen <58006629+1tnguyen@users.noreply.github.com> Co-authored-by: Pradnya Khalate <148914294+khalatepradnya@users.noreply.github.com>
1 parent f8a54d3 commit d9703ea

File tree

3 files changed

+174
-21
lines changed

3 files changed

+174
-21
lines changed

python/runtime/cudaq/algorithms/py_sample_async.cpp

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -31,17 +31,22 @@ static async_sample_result sample_async_impl(
3131
std::string kernelName = shortName;
3232
auto retTy = unwrap(returnTy);
3333
auto &platform = get_platform();
34-
if (noise_model.has_value()) {
35-
if (platform.is_remote())
36-
throw std::runtime_error(
37-
"Noise model is not supported on remote platforms.");
38-
platform.set_noise(&noise_model.value());
39-
}
34+
35+
// Check remote platform restriction for noise model.
36+
if (noise_model.has_value() && platform.is_remote(qpu_id))
37+
throw std::runtime_error(
38+
"Noise model is not supported on remote platforms.");
39+
4040
auto fnOp = getKernelFuncOp(mod, shortName);
4141
auto opaques = marshal_arguments_for_module_launch(mod, runtimeArgs, fnOp);
4242

4343
// Should only have C++ going on here, safe to release the GIL
4444
py::gil_scoped_release release;
45+
46+
// Use runSamplingAsync with noise model support.
47+
// The noise_model is passed by value to runSamplingAsync, which captures
48+
// it in the async task to ensure proper lifetime and handles setting/
49+
// resetting it to avoid dangling pointers and global state pollution.
4550
return details::runSamplingAsync(
4651
// Notes:
4752
// (1) no Python data access is allowed in this lambda body.
@@ -52,7 +57,8 @@ static async_sample_result sample_async_impl(
5257
[[maybe_unused]] auto result =
5358
clean_launch_module(kernelName, mod, retTy, opaques);
5459
}),
55-
platform, kernelName, shots_count, explicit_measurements, qpu_id);
60+
platform, kernelName, shots_count, explicit_measurements, qpu_id,
61+
std::move(noise_model));
5662
}
5763

5864
void cudaq::bindSampleAsync(py::module &mod) {

python/tests/builder/test_NoiseModel.py

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1073,6 +1073,112 @@ def kraus_mats(error_probability):
10731073
cudaq.reset_target()
10741074

10751075

1076+
@pytest.mark.parametrize('target', ['density-matrix-cpu', 'stim'])
1077+
def test_sample_async_with_noise(target: str):
1078+
"""
1079+
Tests that `cudaq.sample_async` correctly applies the noise model
1080+
and does not pollute subsequent calls.
1081+
1082+
This test verifies the fix for the bug where:
1083+
1. Noise model was set but never reset (state pollution)
1084+
2. Noise model pointer became dangling after function return
1085+
3. Noise model was not correctly applied in async execution
1086+
"""
1087+
cudaq.set_target(target)
1088+
cudaq.set_random_seed(42)
1089+
1090+
# Create a simple kernel that applies X gate (should give |1>)
1091+
kernel = cudaq.make_kernel()
1092+
qubit = kernel.qalloc()
1093+
kernel.x(qubit)
1094+
kernel.mz(qubit)
1095+
1096+
# Create a depolarizing noise model with high probability
1097+
noise = cudaq.NoiseModel()
1098+
depol = cudaq.DepolarizationChannel(0.9) # 90% depolarization
1099+
noise.add_channel("x", [0], depol)
1100+
1101+
# Step 1: Baseline - sample without noise should give 100% |1>
1102+
clean_result = cudaq.sample(kernel, shots_count=100)
1103+
assert clean_result.count('1') == 100, "Baseline should be 100% |1>"
1104+
1105+
# Step 2: sample_async WITH noise should produce mixed results
1106+
future = cudaq.sample_async(kernel, shots_count=1000, noise_model=noise)
1107+
noisy_result = future.get()
1108+
# With 90% depolarization, we expect significant noise
1109+
assert noisy_result.count(
1110+
'0') > 0, "Noisy sample_async should have some |0>"
1111+
assert noisy_result.count(
1112+
'1') > 0, "Noisy sample_async should have some |1>"
1113+
1114+
# Step 3: Sample WITHOUT noise after async call - should NOT be polluted
1115+
clean_after = cudaq.sample(kernel, shots_count=100)
1116+
assert clean_after.count('1') == 100, \
1117+
"Sample after sample_async should not be polluted by noise model"
1118+
1119+
# Step 4: Another sample_async WITHOUT noise - should be clean
1120+
future_clean = cudaq.sample_async(kernel, shots_count=100)
1121+
clean_async_result = future_clean.get()
1122+
assert clean_async_result.count('1') == 100, \
1123+
"sample_async without noise should be 100% |1>"
1124+
1125+
cudaq.reset_target()
1126+
1127+
1128+
@pytest.mark.parametrize('target', ['density-matrix-cpu'])
1129+
def test_sample_async_noise_isolation(target: str):
1130+
"""
1131+
Tests that multiple sample_async calls with different noise models
1132+
are properly isolated from each other.
1133+
"""
1134+
cudaq.set_target(target)
1135+
cudaq.set_random_seed(13)
1136+
1137+
kernel = cudaq.make_kernel()
1138+
qubit = kernel.qalloc()
1139+
kernel.x(qubit)
1140+
kernel.mz(qubit)
1141+
1142+
# Create two different noise models
1143+
noise_high = cudaq.NoiseModel()
1144+
noise_high.add_channel("x", [0], cudaq.DepolarizationChannel(1.0))
1145+
1146+
noise_low = cudaq.NoiseModel()
1147+
noise_low.add_channel("x", [0], cudaq.DepolarizationChannel(0.1))
1148+
1149+
# Run multiple async calls with different noise models
1150+
future_high = cudaq.sample_async(kernel,
1151+
shots_count=1000,
1152+
noise_model=noise_high)
1153+
future_low = cudaq.sample_async(kernel,
1154+
shots_count=1000,
1155+
noise_model=noise_low)
1156+
future_none = cudaq.sample_async(kernel, shots_count=100)
1157+
1158+
# Get results
1159+
result_high = future_high.get()
1160+
result_low = future_low.get()
1161+
result_none = future_none.get()
1162+
1163+
# With DepolarizationChannel(p=1.0) applied after an X gate, the channel is
1164+
# (1-p)I + p/3 (X, Y, Z). Starting from |1>, this yields P(|0>) = 2/3.
1165+
# Allow a generous tolerance to avoid flakiness from finite-shot sampling.
1166+
high_zero_prob = result_high.probability('0')
1167+
assert 0.55 < high_zero_prob < 0.80, \
1168+
f"High noise should give P(|0>) ~ 2/3, got {high_zero_prob}"
1169+
1170+
# Low noise should have mostly |1>
1171+
low_one_prob = result_low.probability('1')
1172+
assert low_one_prob > 0.8, \
1173+
f"Low noise should give >80% |1>, got {low_one_prob}"
1174+
1175+
# No noise should be 100% |1>
1176+
assert result_none.count('1') == 100, \
1177+
"No noise should give 100% |1>"
1178+
1179+
cudaq.reset_target()
1180+
1181+
10761182
INVALID_PROBABILITY_MSG = (r"probability must be in the range|"
10771183
r"not completely positive|trace preserving")
10781184

runtime/cudaq/algorithms/sample.h

Lines changed: 55 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ runSampling(KernelFunctor &&wrappedKernel, quantum_platform &platform,
8888
}
8989
#endif
9090

91-
// Indicate that this is an async exec
91+
// Indicate that this is an asynchronous execution.
9292
ctx.asyncExec = futureResult != nullptr;
9393

9494
auto isRemoteSimulator = platform.get_remote_capabilities().isRemoteSimulator;
@@ -133,35 +133,77 @@ runSampling(KernelFunctor &&wrappedKernel, quantum_platform &platform,
133133
/// arguments and invokes the quantum kernel) and invoke the sampling process
134134
/// asynchronously. Return an `async_sample_result`, clients can retrieve the
135135
/// results at a later time via the `get()` call.
136+
///
137+
/// @param wrappedKernel The kernel functor to execute.
138+
/// @param platform The quantum platform to use.
139+
/// @param kernelName The name of the kernel.
140+
/// @param shots The number of shots to run.
141+
/// @param explicitMeasurements Whether to use explicit measurements.
142+
/// @param qpu_id The QPU ID to use.
143+
/// @param noise The optional noise model to apply during execution. The noise
144+
/// model is copied into the asynchronous task to ensure proper
145+
/// lifetime.
136146
template <typename KernelFunctor>
137147
auto runSamplingAsync(KernelFunctor &&wrappedKernel, quantum_platform &platform,
138148
const std::string &kernelName, int shots,
139-
bool explicitMeasurements = false,
140-
std::size_t qpu_id = 0) {
149+
bool explicitMeasurements = false, std::size_t qpu_id = 0,
150+
std::optional<noise_model> noise = std::nullopt) {
141151
if (qpu_id >= platform.num_qpus()) {
142152
throw std::invalid_argument("Provided qpu_id " + std::to_string(qpu_id) +
143153
" is invalid (must be < " +
144154
std::to_string(platform.num_qpus()) +
145155
" i.e. platform.num_qpus())");
146156
}
147157

158+
// Treat an empty noise model as "no noise".
159+
const bool hasNoise = noise.has_value() && !noise->empty();
160+
148161
// If we are remote, then create the sampling executor with `cudaq::future`
149-
// provided
162+
// provided. Note: noise model is not supported on remote platforms.
150163
if (platform.is_remote(qpu_id)) {
164+
if (hasNoise)
165+
throw std::runtime_error(
166+
"Noise model is not supported on remote platforms.");
151167
details::future futureResult;
152168
details::runSampling(std::forward<KernelFunctor>(wrappedKernel), platform,
153169
kernelName, shots, explicitMeasurements, qpu_id,
154170
&futureResult);
155171
return async_sample_result(std::move(futureResult));
156172
}
157173

158-
// Otherwise we'll create our own future/promise and return it
174+
// For local platforms, create an asynchronous task that properly handles the
175+
// noise model lifecycle:
176+
// 1. Capture noise model BY VALUE in the task (extends lifetime)
177+
// 2. Set noise model at the START of the task (before
178+
// configureExecutionContext)
179+
// 3. Reset noise model at the END of the task (including on exception)
180+
// This avoids dangling pointers and global state pollution.
159181
KernelExecutionTask task(
160182
[qpu_id, explicitMeasurements, shots, kernelName, &platform,
183+
noise = std::move(noise),
161184
kernel = std::forward<KernelFunctor>(wrappedKernel)]() mutable {
162-
return details::runSampling(kernel, platform, kernelName, shots,
163-
explicitMeasurements, qpu_id)
164-
.value();
185+
const bool hasNoise = noise.has_value() && !noise->empty();
186+
187+
// Set noise model before execution if provided.
188+
if (hasNoise)
189+
platform.set_noise(&noise.value(), qpu_id);
190+
191+
std::optional<sample_result> result;
192+
try {
193+
result = details::runSampling(kernel, platform, kernelName, shots,
194+
explicitMeasurements, qpu_id);
195+
} catch (...) {
196+
// Ensure noise model is reset even on exception.
197+
if (hasNoise)
198+
platform.reset_noise(qpu_id);
199+
throw;
200+
}
201+
202+
// Reset noise model after execution.
203+
if (hasNoise)
204+
platform.reset_noise(qpu_id);
205+
206+
return result.value();
165207
});
166208

167209
return async_sample_result(
@@ -380,17 +422,16 @@ async_sample_result sample_async(const sample_options &options,
380422
}
381423
auto &platform = cudaq::get_platform();
382424
auto kernelName = cudaq::getKernelName(kernel);
383-
if (!options.noise.empty())
384-
platform.set_noise(&options.noise);
385425

386-
auto ret = details::runSamplingAsync(
426+
// Pass the noise model (copied by value) to runSamplingAsync, which will
427+
// set/reset it within the asynchronous task to avoid dangling pointers and
428+
// state pollution.
429+
return details::runSamplingAsync(
387430
[&kernel, ... args = std::forward<Args>(args)]() mutable {
388431
kernel(std::forward<Args>(args)...);
389432
},
390433
platform, kernelName, options.shots, options.explicit_measurements,
391-
qpu_id);
392-
platform.reset_noise();
393-
return ret;
434+
qpu_id, options.noise);
394435
}
395436

396437
/// @brief Sample the given kernel expression asynchronously and return

0 commit comments

Comments
 (0)