Repository navigation
Treat binding immutable to mutable as copy even when static value present - #3901
Stefan J. Wernli (swernli) wants to merge 2 commits into
Conversation
Remaining scenarios related to #3811Review of The commit changes Now correct:
The three scenarios below still produce wrong output. Each was checked by comparing the Q# simulator against qir-runner output for the QIR that each profile produces, at
Issues 1 and 2 have the same root cause as #3811. The copy added by Issue 3 is the same "copy follows the original" symptom, but it comes from the Adaptive_RI/RIF SSA pipeline in #3810 (values assigned in a dynamic loop body leak past the loop) is cited in #3811 as the underlying cause and is still open; see issue 4 in Existing issues: I checked open and closed microsoft/qdk issues as of 2026-10-07. #3820 involves All Python repros require 1.
|
| Variant (see the Python repro) | Simulator (correct) | Adaptive_RI | Adaptive_RIF | Adaptive |
|---|---|---|---|---|
| Straight-line (above) | 2 |
7 |
7 |
7 |
Built by function Wrap(x : Int) : Int[] { [x, size = 1] } |
2 |
7 |
7 |
7 |
Inside a runtime branch, where n has a known value (the #3809 shape) |
1 |
1 |
1 |
7 |
| Inside an emitted loop (the #3811 shape) | 1 |
1 |
1 |
5 |
Probable cause
eval_expr_array_repeat (lib.rs:1431-1458) builds the array with vec![value; size]. When value is a Value::Var, every element is the mutable's own RIR variable, and nothing copies it later. eval_expr_array copies each such element into a fresh variable; eval_expr_array_repeat should do the same.
In Adaptive_RI/RIF, loops are unrolled, and reading a mutable inside a branch yields its known literal value. eval_expr_var returns the variable itself only when backwards branching is supported (lib.rs:2984). That's why the branch and loop variants fail only on Adaptive.
Python repro
"""An array built with `[x, size = n]` holds a reference to the mutable `x`, not its value.
Compiles each Q# program below to QIR for each Adaptive profile, runs that QIR with
qir-runner, and runs the same program in the Q# simulator for comparison.
Exits 0 while the bug reproduces.
Requires: pip install qdk qirrunner
"""
import os
import re
import sys
import tempfile
import qirrunner
from qdk import Context, TargetProfile
# name -> (Q# source, correct output). In every case the array is built before the mutable changes.
CASES = {
"straight-line": ("""
operation Main() : Int {
use q = Qubit();
X(q);
mutable n = 0;
if MResetZ(q) == One { set n = 2; }
let arr = [n, size = 1];
set n = 7;
arr[0]
}
""", "2"),
"built by a helper function": ("""
function Wrap(x : Int) : Int[] { [x, size = 1] }
operation Main() : Int {
use q = Qubit();
X(q);
mutable n = 0;
if MResetZ(q) == One { set n = 2; }
let arr = Wrap(n);
set n = 7;
arr[0]
}
""", "2"),
"inside a runtime branch": ("""
operation Main() : Int {
use q = Qubit();
X(q);
let d = MResetZ(q) == One;
mutable n = 1;
if d { set n = 1; }
mutable r = 0;
if d {
let h = [n, size = 1];
set n = 7;
set r = h[0];
}
r
}
""", "1"),
"inside an emitted loop": ("""
operation Main() : Int {
use q = Qubit();
mutable a = 1;
mutable r = 0;
for i in 0..0 {
X(q);
let arr = [a, size = 1];
if i == 7 { X(q); }
set a = 5;
set r = arr[0];
}
Reset(q);
r
}
""", "1"),
}
ENTRY = "Main()"
SHOTS = 10
PROFILES = ["Adaptive_RI", "Adaptive_RIF", "Adaptive"]
def fmt(value):
"""Formats a simulator value the same way as qir-runner output below."""
if value is None:
return "()"
if type(value).__name__ == "Result":
return "1" if str(value).endswith("One") else "0"
if isinstance(value, bool):
return str(value).lower()
if isinstance(value, float):
return repr(value)
if isinstance(value, (tuple, list)):
inner = ", ".join(fmt(v) for v in value)
return f"({inner})" if isinstance(value, tuple) else f"[{inner}]"
return str(value)
def parse_shots(text):
"""Turns qir-runner's output records into one formatted value per shot."""
shots, records = [], None
for line in text.splitlines():
fields = line.split("\t")
if fields[0] == "START":
records = []
elif fields[0] == "OUTPUT" and records is not None:
records.append((fields[1], fields[2]))
elif fields[0] == "END" and records is not None:
shots.append(to_text(iter(records)) if records else "()")
if fields[1] != "0":
shots[-1] = f"exit code {fields[1]}"
records = None
return shots
def to_text(records):
kind, value = next(records)
if kind in ("TUPLE", "ARRAY"):
inner = ", ".join(to_text(records) for _ in range(int(value)))
return f"({inner})" if kind == "TUPLE" else f"[{inner}]"
if kind == "DOUBLE":
return repr(float(value))
if kind == "BOOL":
return value.lower()
return value
def run_qir(qir):
with tempfile.NamedTemporaryFile("w", suffix=".ll", delete=False) as file:
file.write(qir)
handler = qirrunner.OutputHandler()
try:
qirrunner.run(file.name, shots=SHOTS, rng_seed=1, output_fn=handler.handle)
except Exception as error: # qir-runner rejects the module
message = str(error).strip().splitlines()[0]
return {"invalid QIR: " + re.sub(r"^\S+\.ll:\d+:\d+: (error: )?", "", message)}
finally:
os.unlink(file.name)
return set(parse_shots(handler.get_output()))
def compile_qir(source, profile):
"""Returns (qir, None) on success or (None, description) when compilation fails."""
ctx = Context(target_profile=getattr(TargetProfile, profile))
try:
ctx.eval(source)
return str(ctx.compile(ENTRY)), None
except (KeyboardInterrupt, SystemExit):
raise
except BaseException as error: # pyo3 surfaces Rust panics as PanicException (a BaseException)
kind = "COMPILER PANIC" if type(error).__name__ == "PanicException" else "rejected"
return None, f"{kind}: {str(error).strip().splitlines()[0]}"
def outputs(source):
rows = {}
ctx = Context(target_profile=TargetProfile.Unrestricted)
ctx.eval(source)
rows["Simulator"] = {fmt(v) for v in ctx.run(ENTRY, SHOTS, seed=1)}
for profile in PROFILES:
qir, failure = compile_qir(source, profile)
rows[profile] = {failure} if failure else run_qir(qir)
return rows
def show(name, rows, expected):
print(f"--- {name} (expected {expected})")
width = max(map(len, rows)) + 2
for row, values in rows.items():
print(f" {row:<{width}}{' | '.join(sorted(values))}")
def main():
reproduced = []
for name, (source, expected) in CASES.items():
rows = outputs(source)
show(name, rows, expected)
if any(rows[p] != {expected} for p in PROFILES):
reproduced.append(name)
if reproduced:
print(f"REPRODUCED: {', '.join(reproduced)}")
return 0
print("NOT REPRODUCED")
return 1
if __name__ == "__main__":
sys.exit(main())2. Copy-and-update (w/) and update-in-place (w/=) hold a reference to the mutable, not its value
Kind: Miscompile (wrong output)
Affects: Adaptive_RI, Adaptive_RIF and Adaptive in straight-line code; only Adaptive inside runtime branches and emitted loops
Status: Pre-existing; not fixed by 7428d06c.
Existing issue: None found. #3820 is a panic for a dynamic index in w/, which is a different problem.
Q#
operation Main() : Int {
use q = Qubit();
X(q);
mutable n = 0;
if MResetZ(q) == One { set n = 2; }
let arr = [0] w/ 0 <- n;
set n = 7;
arr[0]
}arr is built when n is 2, so arr[0] is 2.
Outputs (HEAD; Base is identical)
| Variant (see the Python repro) | Simulator (correct) | Adaptive_RI | Adaptive_RIF | Adaptive |
|---|---|---|---|---|
let arr = [0] w/ 0 <- n; (above) |
2 |
7 |
7 |
7 |
mutable arr = [0]; set arr w/= 0 <- n; |
2 |
7 |
7 |
7 |
w/ in function Wrap(x : Int) : Int[] { [0] w/ 0 <- x } |
2 |
7 |
7 |
7 |
w/ inside a runtime branch, where n has a known value |
1 |
1 |
1 |
7 |
w/ inside an emitted loop |
1 |
1 |
1 |
5 |
set arr w/= 0 <- a inside an emitted loop gave the correct result. Inside a runtime branch, every profile rejects it with UseOfDynamicArray.
Probable cause
eval_array_update_index (lib.rs:563-603) inserts the evaluated update value into the array as-is (update_index_single(array, index, update_value, ...), lib.rs:590). Both w/ (eval_expr_update_index, lib.rs:2944) and w/= (eval_expr_assign_index, lib.rs:1477) go through it. A Value::Var update value should be copied into a fresh variable first, as eval_expr_array does. The branch and loop variants fail only on Adaptive for the reason given in issue 1.
Python repro
"""Copy-and-update (`w/`) and update-in-place (`w/=`) store a reference to the mutable, not its value.
Compiles each Q# program below to QIR for each Adaptive profile, runs that QIR with
qir-runner, and runs the same program in the Q# simulator for comparison.
Exits 0 while the bug reproduces.
Requires: pip install qdk qirrunner
"""
import os
import re
import sys
import tempfile
import qirrunner
from qdk import Context, TargetProfile
STRAIGHT_LINE = """
{prefix}operation Main() : Int {{
use q = Qubit();
X(q);
mutable n = 0;
if MResetZ(q) == One {{ set n = 2; }}
{build}
set n = 7;
arr[0]
}}
"""
# name -> (Q# source, correct output). In every case the array is updated before the mutable changes.
CASES = {
"copy-and-update `w/`": (STRAIGHT_LINE.format(prefix="", build="let arr = [0] w/ 0 <- n;"), "2"),
"update-in-place `w/=`": (STRAIGHT_LINE.format(prefix="", build="mutable arr = [0];\n set arr w/= 0 <- n;"), "2"),
"`w/` in a helper function": (STRAIGHT_LINE.format(
prefix="function Wrap(x : Int) : Int[] { [0] w/ 0 <- x }\n", build="let arr = Wrap(n);"), "2"),
"`w/` inside a runtime branch": ("""
operation Main() : Int {
use q = Qubit();
X(q);
let d = MResetZ(q) == One;
mutable n = 1;
if d { set n = 1; }
mutable r = 0;
if d {
let h = [0] w/ 0 <- n;
set n = 7;
set r = h[0];
}
r
}
""", "1"),
"`w/` inside an emitted loop": ("""
operation Main() : Int {
use q = Qubit();
mutable a = 1;
mutable r = 0;
for i in 0..0 {
X(q);
let arr = [0] w/ 0 <- a;
if i == 7 { X(q); }
set a = 5;
set r = arr[0];
}
Reset(q);
r
}
""", "1"),
}
ENTRY = "Main()"
SHOTS = 10
PROFILES = ["Adaptive_RI", "Adaptive_RIF", "Adaptive"]
def fmt(value):
"""Formats a simulator value the same way as qir-runner output below."""
if value is None:
return "()"
if type(value).__name__ == "Result":
return "1" if str(value).endswith("One") else "0"
if isinstance(value, bool):
return str(value).lower()
if isinstance(value, float):
return repr(value)
if isinstance(value, (tuple, list)):
inner = ", ".join(fmt(v) for v in value)
return f"({inner})" if isinstance(value, tuple) else f"[{inner}]"
return str(value)
def parse_shots(text):
"""Turns qir-runner's output records into one formatted value per shot."""
shots, records = [], None
for line in text.splitlines():
fields = line.split("\t")
if fields[0] == "START":
records = []
elif fields[0] == "OUTPUT" and records is not None:
records.append((fields[1], fields[2]))
elif fields[0] == "END" and records is not None:
shots.append(to_text(iter(records)) if records else "()")
if fields[1] != "0":
shots[-1] = f"exit code {fields[1]}"
records = None
return shots
def to_text(records):
kind, value = next(records)
if kind in ("TUPLE", "ARRAY"):
inner = ", ".join(to_text(records) for _ in range(int(value)))
return f"({inner})" if kind == "TUPLE" else f"[{inner}]"
if kind == "DOUBLE":
return repr(float(value))
if kind == "BOOL":
return value.lower()
return value
def run_qir(qir):
with tempfile.NamedTemporaryFile("w", suffix=".ll", delete=False) as file:
file.write(qir)
handler = qirrunner.OutputHandler()
try:
qirrunner.run(file.name, shots=SHOTS, rng_seed=1, output_fn=handler.handle)
except Exception as error: # qir-runner rejects the module
message = str(error).strip().splitlines()[0]
return {"invalid QIR: " + re.sub(r"^\S+\.ll:\d+:\d+: (error: )?", "", message)}
finally:
os.unlink(file.name)
return set(parse_shots(handler.get_output()))
def compile_qir(source, profile):
"""Returns (qir, None) on success or (None, description) when compilation fails."""
ctx = Context(target_profile=getattr(TargetProfile, profile))
try:
ctx.eval(source)
return str(ctx.compile(ENTRY)), None
except (KeyboardInterrupt, SystemExit):
raise
except BaseException as error: # pyo3 surfaces Rust panics as PanicException (a BaseException)
kind = "COMPILER PANIC" if type(error).__name__ == "PanicException" else "rejected"
return None, f"{kind}: {str(error).strip().splitlines()[0]}"
def outputs(source):
rows = {}
ctx = Context(target_profile=TargetProfile.Unrestricted)
ctx.eval(source)
rows["Simulator"] = {fmt(v) for v in ctx.run(ENTRY, SHOTS, seed=1)}
for profile in PROFILES:
qir, failure = compile_qir(source, profile)
rows[profile] = {failure} if failure else run_qir(qir)
return rows
def show(name, rows, expected):
print(f"--- {name} (expected {expected})")
width = max(map(len, rows)) + 2
for row, values in rows.items():
print(f" {row:<{width}}{' | '.join(sorted(values))}")
def main():
reproduced = []
for name, (source, expected) in CASES.items():
rows = outputs(source)
show(name, rows, expected)
if any(rows[p] != {expected} for p in PROFILES):
reproduced.append(name)
if reproduced:
print(f"REPRODUCED: {', '.join(reproduced)}")
return 0
print("NOT REPRODUCED")
return 1
if __name__ == "__main__":
sys.exit(main())3. Adaptive_RI/RIF: a copy of a mutable, read inside a later runtime branch, loses its value
Kind: Miscompile (wrong output)
Affects: Adaptive_RI, Adaptive_RIF. Adaptive, which doesn't use the SSA pipeline, is correct.
Status: Pre-existing; not fixed by 7428d06c. I verified the array-literal form at Base. The let and mutable forms take code paths that this commit doesn't change, because n has no known value at the copy.
Existing issue: The array-literal form is #3800's "Other shapes" (open). #3800 reported 2 on every profile; now Adaptive is correct and Adaptive_RI/RIF return 0. The let and mutable forms are not filed. This is the same family as #3769 and #3807 (both closed), whose own repros now pass on every profile.
Q#
operation Main() : Int {
use q = Qubit();
X(q);
let d = MResetZ(q) == One;
mutable n = 0;
if d { set n = 1; }
let t = n;
set n = 2;
mutable r = 0;
if d { set r = t; }
r
}d is true, so n is 1 when t copies it, and r is 1. With set r = t; moved outside the second if, every profile is correct.
Outputs (HEAD)
| Copy | Simulator (correct) | Adaptive_RI | Adaptive_RIF | Adaptive |
|---|---|---|---|---|
let t = n; |
1 |
0 |
0 |
1 |
mutable t = n; |
1 |
0 |
0 |
1 |
let a = [n];, read as a[0] (#3800) |
1 |
0 |
0 |
1 |
Probable cause
Partial evaluation produces correct RIR. Block 4 stores the copy (Variable(4)) into r (Variable(5)):
Block 1: Variable(4, Integer) = Store Variable(3, Integer) // let t = n
Variable(3, Integer) = Store Integer(2) // set n = 2
Variable(5, Integer) = Store Integer(0) // mutable r = 0
Branch Variable(2, Boolean), 4, 3
Block 4: Variable(5, Integer) = Store Variable(4, Integer) // set r = t
Jump(3)
Block 3: Variable(6, Integer) = Store Variable(5, Integer)
Call id(5), args( Variable(6, Integer), Tag(0, 3), )
After transform_to_ssa (source/compiler/qsc_rir/src/passes/ssa_transform.rs:18), the transform still creates the phi for n/t, but there is no phi for r at the join, and the constant 0 is recorded:
Block 2: Variable(7, Integer) = Phi ( [Integer(0), 0], [Integer(1), 1], )
Branch Variable(0, Boolean), 3, 4
Block 3: Jump(4)
Block 4: Call id(5), args( Integer(0), Tag(0, 3), )
Only Adaptive_RI/RIF run this pass (source/compiler/qsc_rir/src/passes.rs:55-62). The fixes for #3769 and #3807 cover a copy that is read in straight-line code (their repros pass). A copy whose only read is inside a later branch is still mishandled.
Python repro
"""Adaptive_RI/RIF: a copy of a mutable, read inside a later runtime branch, loses its value.
The copy is taken after a join, the original is then reassigned, and the copy is read
inside a branch on the same measurement. Adaptive (no SSA) is correct. The same program
with the read outside the branch is correct on every profile.
Exits 0 while the bug reproduces.
Requires: pip install qdk qirrunner
"""
import os
import re
import sys
import tempfile
import qirrunner
from qdk import Context, TargetProfile
TEMPLATE = """
operation Main() : Int {{
use q = Qubit();
X(q);
let d = MResetZ(q) == One;
mutable n = 0;
if d {{ set n = 1; }}
{copy}
set n = 2;
mutable r = 0;
if d {{ set r = {read}; }}
r
}}
"""
# name -> (Q# source, correct output). `d` is true, so `n` is 1 when it's copied.
CASES = {
"let t = n": (TEMPLATE.format(copy="let t = n;", read="t"), "1"),
"mutable t = n": (TEMPLATE.format(copy="mutable t = n;", read="t"), "1"),
"let a = [n] (#3800 other shape)": (TEMPLATE.format(copy="let a = [n];", read="a[0]"), "1"),
}
ENTRY = "Main()"
SHOTS = 10
PROFILES = ["Adaptive_RI", "Adaptive_RIF", "Adaptive"]
def fmt(value):
"""Formats a simulator value the same way as qir-runner output below."""
if value is None:
return "()"
if type(value).__name__ == "Result":
return "1" if str(value).endswith("One") else "0"
if isinstance(value, bool):
return str(value).lower()
if isinstance(value, float):
return repr(value)
if isinstance(value, (tuple, list)):
inner = ", ".join(fmt(v) for v in value)
return f"({inner})" if isinstance(value, tuple) else f"[{inner}]"
return str(value)
def parse_shots(text):
"""Turns qir-runner's output records into one formatted value per shot."""
shots, records = [], None
for line in text.splitlines():
fields = line.split("\t")
if fields[0] == "START":
records = []
elif fields[0] == "OUTPUT" and records is not None:
records.append((fields[1], fields[2]))
elif fields[0] == "END" and records is not None:
shots.append(to_text(iter(records)) if records else "()")
if fields[1] != "0":
shots[-1] = f"exit code {fields[1]}"
records = None
return shots
def to_text(records):
kind, value = next(records)
if kind in ("TUPLE", "ARRAY"):
inner = ", ".join(to_text(records) for _ in range(int(value)))
return f"({inner})" if kind == "TUPLE" else f"[{inner}]"
if kind == "DOUBLE":
return repr(float(value))
if kind == "BOOL":
return value.lower()
return value
def run_qir(qir):
with tempfile.NamedTemporaryFile("w", suffix=".ll", delete=False) as file:
file.write(qir)
handler = qirrunner.OutputHandler()
try:
qirrunner.run(file.name, shots=SHOTS, rng_seed=1, output_fn=handler.handle)
except Exception as error: # qir-runner rejects the module
message = str(error).strip().splitlines()[0]
return {"invalid QIR: " + re.sub(r"^\S+\.ll:\d+:\d+: (error: )?", "", message)}
finally:
os.unlink(file.name)
return set(parse_shots(handler.get_output()))
def compile_qir(source, profile):
"""Returns (qir, None) on success or (None, description) when compilation fails."""
ctx = Context(target_profile=getattr(TargetProfile, profile))
try:
ctx.eval(source)
return str(ctx.compile(ENTRY)), None
except (KeyboardInterrupt, SystemExit):
raise
except BaseException as error: # pyo3 surfaces Rust panics as PanicException (a BaseException)
kind = "COMPILER PANIC" if type(error).__name__ == "PanicException" else "rejected"
return None, f"{kind}: {str(error).strip().splitlines()[0]}"
def outputs(source):
rows = {}
ctx = Context(target_profile=TargetProfile.Unrestricted)
ctx.eval(source)
rows["Simulator"] = {fmt(v) for v in ctx.run(ENTRY, SHOTS, seed=1)}
for profile in PROFILES:
qir, failure = compile_qir(source, profile)
rows[profile] = {failure} if failure else run_qir(qir)
return rows
def show(name, rows, expected):
print(f"--- {name} (expected {expected})")
width = max(map(len, rows)) + 2
for row, values in rows.items():
print(f" {row:<{width}}{' | '.join(sorted(values))}")
def main():
reproduced = []
for name, (source, expected) in CASES.items():
rows = outputs(source)
show(name, rows, expected)
if any(rows[p] != {expected} for p in PROFILES):
reproduced.append(name)
if reproduced:
print(f"REPRODUCED: {', '.join(reproduced)}")
return 0
print("NOT REPRODUCED")
return 1
if __name__ == "__main__":
sys.exit(main())9ff99f3 to
9c2e242
Compare
9c2e242 to
619199a
Compare
|
1 and 2 from above are fixed in the stack (via #3869). Investigating 3... |
619199a to
876fc4c
Compare
|
Andrew Casey (@amcasey) 3 from above is different enough, and more than a spot fix, that it warrants separating out into it's own issue and PR. Would you mind adding it as a sub-issue under #3781? |
876fc4c to
0a4aec4
Compare
Files #3908 |
0a4aec4 to
e0f54ec
Compare
e0f54ec to
726f8fc
Compare
726f8fc to
cbe30d4
Compare
cbe30d4 to
12fb809
Compare
12fb809 to
f993b82
Compare
f993b82 to
adab56c
Compare
adab56c to
896ad2d
Compare
896ad2d to
b6856c9
Compare
b6856c9 to
2ff9a89
Compare
8857927 to
fa490e0
Compare
…sent Previously we used an optimization to avoid extra store instructions by having a special case around setting an immutable variable to a mutable variable where the latter had a known static mapping. This could incorrectly propagate references to a mutable variable instead of creating a copy (via store). Instead, this avoids trying to optimize in favor of correctness. For Adaptive_RI/RIF this ended up having no effect, as those extra store instructions are not produced due to how loop unrolling worked, but for Adaptive the extra store instructions create some noise in existing QIR via "needless" load, store, load pattern that ends up being correct for creating copies when mutation occurs in a loop. Note for reviewers: the only code change is a couple lines in source/compiler/qsc_partial_eval/src/lib.rs, the rest are new tests and existing snapshot updates. Fixes #3811 Fixes #3809
fa490e0 to
e01c364
Compare
Previously we used an optimization to avoid extra store instructions by having a special case around setting an immutable variable to a mutable variable where the latter had a known static mapping. This could incorrectly propagate references to a mutable variable instead of creating a copy (via store). Instead, this avoids trying to optimize in favor of correctness. For Adaptive_RI/RIF this ended up having no effect, as those extra store instructions are not produced due to how loop unrolling worked, but for Adaptive the extra store instructions create some noise in existing QIR via "needless" load, store, load pattern that ends up being correct for creating copies when mutation occurs in a loop.
Note for reviewers: the only code change is a couple lines in source/compiler/qsc_partial_eval/src/lib.rs, the rest are new tests and existing snapshot updates.
Fixes #3811
Fixes #3809
Stack created with GitHub Stacks CLI • Give Feedback 💬