diff --git a/.claude/skills/benchmarking-tsim/SKILL.md b/.claude/skills/benchmarking-tsim/SKILL.md index cde4e168..5ce223a3 100644 --- a/.claude/skills/benchmarking-tsim/SKILL.md +++ b/.claude/skills/benchmarking-tsim/SKILL.md @@ -72,7 +72,7 @@ width can still be slower. Compile times are shown for each side. `--side REF[:key=value,...]`, given twice, replaces `--ref` when the two sides differ in more than the checkout. REF is `.` (this checkout), a git revision, a checkout path, or a Python executable (an environment built elsewhere). Keys: `threads=N`, -`strategy=NAME`. The first side is "new". +`strategy=NAME`, `device=cpu|gpu`. The first side is "new". ```bash uv run tsim-dev bench --side .:threads=10 --side .:threads=1 # 10 cores vs 1 @@ -91,7 +91,8 @@ M4 Pro, 26-96x one CPU thread and 4-19x all ten performance cores, bit-packed. E cultivation d=5 ran at 125M shots/s, 19x the ten cores. Circuits without noise whose CPU trajectories share well, such as qv_q10, run at the CPU's rate. -The bench sets `TSIM_DEVICE=cpu` by default, so on a Mac a plain run measures the CPU only. +The bench compiles with `device="cpu"` by default, so on a Mac a plain run measures the CPU +only. Say which you measured, and on a Mac measure both: ```bash @@ -104,8 +105,8 @@ uv run tsim-dev bench --side .:device=gpu --side .:device=cpu,threads=10 # GPU below about 6e-5 π), noise beyond the GPU tables, no GPU memory. Under the default `device="auto"` these fallbacks are silent; in a script of your own, compile with `device="metal"` to make them raise instead. -- `TSIM_DEVICE=cpu` (the bench's default) keeps `device="auto"` on the CPU; an explicit - `device=` argument overrides it. +- `--device cpu` (the default) runs tsim-bench's worker through `dev/tsim_dev/on_device.py`, + which compiles with `device="cpu"`; `--device gpu` leaves Tsim's `device="auto"`. - The GPU's rate depends on call size much more than the CPU's: each call pays a fixed cost (setting up the launches, waiting for the GPU to finish), and each batch of up to 2^20 shots is one launch. The bench calibrates calls to about 0.3 s, which is enough. A GPU rate measured @@ -128,7 +129,7 @@ uv run tsim-dev bench --side .:device=gpu --side .:device=cpu,threads=10 # GPU - One thread, pinned to a core (`--threads N` to change; say so when you report). The `NOTE` line gives the cores Tsim uses by default. - Bit-packed output (`--bools` for `sample()`'s default booleans) and the CPU - (`TSIM_DEVICE=cpu`; `--device gpu` lets Tsim use a GPU backend). The header says + (`device="cpu"`; `--device gpu` lets Tsim use a GPU backend). The header says which; the `profiling-tsim` skill lists how much each changes the rate. - The sampler that tsim-bench records for each circuit: the detector sampler for QEC circuits. `--sampler detector|measurement` overrides it (a measurement sampler diff --git a/.claude/skills/profiling-tsim/SKILL.md b/.claude/skills/profiling-tsim/SKILL.md index 8782bb30..dccfdcfe 100644 --- a/.claude/skills/profiling-tsim/SKILL.md +++ b/.claude/skills/profiling-tsim/SKILL.md @@ -40,8 +40,8 @@ Tsim is slow: - **Threads.** One pinned thread gives the rate per core. Tsim samples on all performance cores by default (`numba.get_num_threads()`); the `NOTE` line says how many. Quote all-core rates with `--threads N`. -- **Device.** The harness sets `TSIM_DEVICE=cpu`, so every side samples on the - CPU unless you pass `--device gpu` (or `--side REF:device=gpu`). On an +- **Device.** The harness compiles with `device="cpu"`, so every side samples on + the CPU unless you pass `--device gpu` (or `--side REF:device=gpu`). On an Apple-silicon Mac, Tsim samples on the GPU by default for k_max <= 11, 4-19x faster than all CPU cores; there `--device gpu` measures what users get. The `device` column says where each row sampled: under `device="auto"` a GPU falls diff --git a/.claude/skills/shipping-tsim-changes/SKILL.md b/.claude/skills/shipping-tsim-changes/SKILL.md index 5781a6fc..c56e67a1 100644 --- a/.claude/skills/shipping-tsim-changes/SKILL.md +++ b/.claude/skills/shipping-tsim-changes/SKILL.md @@ -42,6 +42,7 @@ evidence. | Any | `uv run tsim-dev check` prints PASS for lint and tests | | Could affect compile or sampling cost (see below) | `uv run tsim-dev bench --ref origin/main`; any regression investigated and explained | | Aims to make Tsim faster | the same with `--suite full`; see the `benchmarking-tsim` skill | +| Touches the GPU path (`statevector/gpu/`, `statevector/metal/`, or code they call) | on an Apple-silicon Mac, also `uv run tsim-dev bench --device gpu --ref origin/main` (the plain bench samples on the CPU only) | | Could change the active width (graph, simplification, scheduling, plan) | `uv run tsim-dev bench --width --ref origin/main`; any `wider` explained | | Bug fix | the new test fails on `origin/main` and passes on the branch | | User-visible | a `CHANGELOG.md` entry under `## [Unreleased]` | diff --git a/CHANGELOG.md b/CHANGELOG.md index 274d131f..049191e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,66 +8,38 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added -- `strategy="active_statevector"` samples on the GPU of an Apple-silicon Mac, in - float32, whenever every component has at most 11 active qubits (Tsim now depends - on `pyobjc-framework-Metal` there). A seed's GPU samples differ from the CPU's and - depend on `batch_size`. The `device` argument of `compile_sampler`, - `compile_detector_sampler` and the sampler classes chooses where they sample: - `"auto"` (the default) uses the GPU when it can run the circuit and otherwise - the CPU, without a warning; `"cpu"` keeps them on the CPU; `"metal"` raises when - the GPU cannot run the circuit. `TSIM_DEVICE=cpu` keeps `"auto"` on the CPU, and - `SamplerStats.device` and the repr say where they sample. -- The docs pages have a menu to copy a page, open it in ChatGPT or Claude, or add - the docs MCP server to Cursor or VS Code, and - the README, the install page and the agent skill point to the docs MCP server - at `https://bloqade.quera.com/tsim/mcp`. The README and the install page also - show how to install the agent skill with Ion. -- An agent skill, `simulating-circuits-with-tsim`, in `skills/`, that teaches - coding agents to use Tsim: active width, sampling and postselection, QEC - studies with decoders, and porting published circuits. Install it with - `npx skills add QuEraComputing/tsim --skill simulating-circuits-with-tsim`; - the docs site also serves it. The repository's development skills in - `.claude/skills` are marked internal, so the installer does not offer them. -- `Circuit(...)` accepts a `stim.Circuit` or another `Circuit` and copies it, - like `Circuit.from_stim_program`. Before, passing a `stim.Circuit` raised an - `AttributeError`. -- `CS` and `CS_DAG` (controlled-S and its inverse) gates in circuit text and - `Circuit.append`, implemented using Clifford+T decompositions. -- `active_width` and `stats` properties on `CompiledMeasurementSampler` and - `CompiledDetectorSampler`. `active_width` is the largest number of active - qubits that a shot simulates (0 if every output is direct, `None` under a - stabilizer-rank strategy); `stats` is a frozen dataclass with it, the - component widths, the action and error-bit counts, and memory estimates. - They replace reading the private `_program` or parsing `repr(sampler)`. +- **Apple GPU sampling.** On Apple-silicon Macs, Tsim now samples on the GPU + whenever every component has at most 11 active qubits, often 5 to 10 times + faster than all CPU cores. Choose the device with the new `device` argument of + `compile_sampler` and `compile_detector_sampler`: `"auto"` (the default) uses + the GPU when it can and otherwise the CPU, `"cpu"` stays on the CPU, and + `"metal"` raises an error explaining why the GPU can't run a circuit. + `sampler.stats.device` tells you where a sampler ended up. The GPU computes in + float32, and its samples for a given seed differ from the CPU's. +- **`active_width` and `stats`** on the compiled samplers show how expensive a + circuit is to sample: the active width, component widths, memory estimates and + more. No more reading `_program` or parsing the repr. +- **`CS` and `CS_DAG`** gates. +- **`Circuit(...)` accepts a `stim.Circuit`** or another `Circuit`. +- **An agent skill**, `simulating-circuits-with-tsim`, that teaches coding agents + to use Tsim. Install it with + `npx skills add QuEraComputing/tsim --skill simulating-circuits-with-tsim`. +- **Docs for agents and chat assistants**: every docs page can be copied or opened + in ChatGPT or Claude, and an MCP server for the docs runs at + `https://bloqade.quera.com/tsim/mcp` (the page menu adds it to Cursor or VS Code). ### Fixed -- Compiling a circuit with many detectors and noise locations no longer needs - memory proportional to their product. The error basis (a GF(2) elimination - over vertices x error bits) is now built and kept sparse, so a d=25 - surface-code 15-to-1 distillation circuit (40k qubits, 115k detectors, 1.1M - error bits) compiles with 4.5 GB instead of failing to allocate 121 GiB. - Seeded samples are unchanged. -- `CS`, `CCZ` and `CCX` in circuit text accept a tag containing `#` and can - share a line with a `REPEAT n {` or `}` delimiter. Repeated target qubits, as - in `CS 0 0`, raise an error naming the gate instead of its decomposition. +- Circuits with many detectors and noise locations compile with far less memory. + A d=25 15-to-1 distillation circuit (115k detectors) now compiles with 4.5 GB + instead of failing to allocate 121 GiB. Seeded samples are unchanged. +- `CS`, `CCZ` and `CCX` accept tags containing `#` and can share a line with + `REPEAT n {` or `}`. Repeated targets such as `CS 0 0` give a clear error. ### Changed -- On an Apple GPU, detector samples that return a subset or reordering of the - outputs (`separate_observables=True`, `prepend_observables=True`, or detectors - only) select their columns on the GPU instead of on the host, bit-packed or as - booleans. On an M4 Pro, distance-3 cultivation postselected on every detector - samples 1.1 to 1.5 billion shots per second in these layouts, instead of 0.06 - to 0.15 (detectors only, bit-packed, was already fast); end-to-end cultivation - d=5 samples 156 to 221 million instead of 1 to 3 million with booleans or - prepended observables, and 291 instead of 59 million with separate bit-packed - observables. -- `strategy="active_statevector"` may run a component's measurements later - where that frees no axis that a later rotation reuses and leaves more of the - tape before its first shot-dependent action, which a GPU backend runs once for - all shots. The samples follow the same distribution, but some circuits' seeds - give other samples than in 1.0. -- `ChannelSampler.signature_matrix` is now a `tsim.utils.linalg.SparseGF2Matrix` - instead of a dense uint8 array; `np.asarray` gives the dense matrix. +- Some circuits give different samples for a given seed than in 1.0, because + measurements may be scheduled later. The sampled distribution is the same. +- `ChannelSampler.signature_matrix` is now a sparse matrix; `np.asarray` gives + the dense one. ## [1.0.0] - 2026-11-04 diff --git a/dev/tsim_dev/bench.py b/dev/tsim_dev/bench.py index 6dee368b..b3bee350 100644 --- a/dev/tsim_dev/bench.py +++ b/dev/tsim_dev/bench.py @@ -10,7 +10,7 @@ measurement for a pull request's table. By default a call returns bit-packed rows, Tsim's native output, on one CPU thread -with `TSIM_DEVICE=cpu`; each row says which device sampled. `--bools` times +with `device="cpu"` (`on_device.py`); each row says which device sampled. `--bools` times `sample()`'s default boolean output, `--threads N` more cores and `--device gpu` a GPU, each of which changes the rate severalfold. """ @@ -260,9 +260,30 @@ def reference(ref: str) -> Side: return Side(f"{ref}@{sha[:8]}", python) -def worker_command(python: Path, case: Case, args, mode: str) -> list[str]: - """Return the command that times `case` with tsim-bench's worker.""" - cmd = [str(python), str(corpus.worker(args.corpus_dir)), "--mode", mode] +ON_DEVICE = Path(__file__).with_name("on_device.py") + + +def worker_launch(python: Path, device: str, corpus_dir: str | None) -> list[str]: + """Return the command that starts tsim-bench's worker with `python` on `device`. + + "cpu" compiles Tsim's samplers with `device="cpu"` (`on_device.py`); "gpu" + leaves Tsim's default, which samples on a GPU when it has one. + """ + worker = str(corpus.worker(corpus_dir)) + if device == "cpu": + return [str(python), str(ON_DEVICE), "cpu", worker] + return [str(python), worker] + + +def worker_command( + python: Path, case: Case, args, mode: str, device: str | None = None +) -> list[str]: + """Return the command that times `case` with tsim-bench's worker on `device`. + + `device` defaults to `args.device`. + """ + device = device or args.device + cmd = [*worker_launch(python, device, args.corpus_dir), "--mode", mode] if case.id is not None: cmd += ["--root", str(corpus.root(args.corpus_dir)), "--case", case.id] cmd += ["--sampler", case.sampler] @@ -281,16 +302,6 @@ def worker_command(python: Path, case: Case, args, mode: str) -> list[str]: return cmd -def side_env(side: Side, threads: int, device: str) -> dict[str, str]: - """Return the environment of one side's worker. - - `device="cpu"` sets `TSIM_DEVICE=cpu`, which keeps a Tsim with a GPU backend on - the CPU; "gpu" leaves Tsim to pick a GPU when it has one. - """ - env = common.child_env(threads, device) - return env - - def measure(side: Side, case: Case, args, mode: str = "bench") -> dict: """Run the worker once and return its result, or `{"error": ...}`.""" threads = side.threads or args.threads @@ -298,13 +309,13 @@ def measure(side: Side, case: Case, args, mode: str = "bench") -> dict: options = args if side.strategy is not None: options = argparse.Namespace(**{**vars(args), "strategy": side.strategy}) - cmd = worker_command(side.python, case, options, mode) + cmd = worker_command(side.python, case, options, mode, device) wrapped = common.capped( cmd, mem=args.mem, timeout=args.timeout, pin=common.pin_cpus(threads) ) done = subprocess.run( wrapped, - env=side_env(side, threads, device), + env=common.child_env(threads), capture_output=True, text=True, check=False, @@ -326,13 +337,15 @@ def __init__(self, side: Side, case: Case, args) -> None: options = args if side.strategy is not None: options = argparse.Namespace(**{**vars(args), "strategy": side.strategy}) - cmd = worker_command(side.python, case, options, "serve") + cmd = worker_command( + side.python, case, options, "serve", side.device or args.device + ) self.args = args self.process = subprocess.Popen( common.capped( cmd, mem=args.mem, timeout=args.timeout, pin=common.pin_cpus(threads) ), - env=side_env(side, threads, side.device or args.device), + env=common.child_env(threads), stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, diff --git a/dev/tsim_dev/cli.py b/dev/tsim_dev/cli.py index dccf1b8b..08b59777 100644 --- a/dev/tsim_dev/cli.py +++ b/dev/tsim_dev/cli.py @@ -81,8 +81,8 @@ def _circuit_options(parser) -> None: "--device", default="cpu", choices=["cpu", "gpu"], - help="cpu: TSIM_DEVICE=cpu; gpu: let Tsim pick a GPU when it has one " - "(default cpu)", + help='cpu: compile with device="cpu"; gpu: let Tsim pick a GPU when it ' + "has one (default cpu)", ) diff --git a/dev/tsim_dev/common.py b/dev/tsim_dev/common.py index 4f5241ee..32d022cf 100644 --- a/dev/tsim_dev/common.py +++ b/dev/tsim_dev/common.py @@ -77,22 +77,15 @@ def describe(path: Path) -> str: return f"{branch}@{commit}" -def child_env(threads: int | None = None, device: str | None = None) -> dict[str, str]: +def child_env(threads: int | None = None) -> dict[str, str]: """Return the environment for Tsim processes. JAX is kept on the CPU unless the caller already chose a platform, which also silences its missing-CUDA warning. The thread variables are set when given. - `device="cpu"` sets `TSIM_DEVICE=cpu`, which keeps the statevector samplers off - a GPU; "gpu" leaves the choice to Tsim, which uses a GPU when it has one; None - leaves `TSIM_DEVICE` as it is (tests choose per sampler). """ env = dict(os.environ) env.setdefault("JAX_PLATFORMS", "cpu") env.pop("VIRTUAL_ENV", None) - if device == "cpu": - env["TSIM_DEVICE"] = "cpu" - elif device == "gpu": - env.pop("TSIM_DEVICE", None) if threads is not None: for name in ("NUMBA_NUM_THREADS", "OMP_NUM_THREADS", "OPENBLAS_NUM_THREADS"): env[name] = str(threads) diff --git a/dev/tsim_dev/on_device.py b/dev/tsim_dev/on_device.py new file mode 100644 index 00000000..fb50b92d --- /dev/null +++ b/dev/tsim_dev/on_device.py @@ -0,0 +1,45 @@ +"""Run tsim-bench's worker with its samplers compiled for one device. + + python on_device.py DEVICE WORKER [ARGS...] + +The worker compiles with Tsim's default device ("auto", which uses a GPU when it +can). This runs it with ``device=DEVICE`` on ``Circuit.compile_sampler`` and +``Circuit.compile_detector_sampler``, where Tsim has that argument (a Tsim +without it samples on the CPU only). It runs in the environment of the checkout +under test, so it uses only the standard library. +""" + +from __future__ import annotations + +import functools +import inspect +import runpy +import sys + + +def main() -> None: + """Run the worker of ``sys.argv[2]`` with the arguments after it.""" + device, worker, *argv = sys.argv[1:] + # NOTE: not as "__main__", so that the worker's main does not run yet. + namespace = runpy.run_path(worker, run_name="tsim_bench_worker") + run = namespace["main"] + imported = namespace["phase_import"] + + # NOTE: the same name as the worker's, since profiles find the phases by name. + def phase_import(): + tsim = imported() + for name in ("compile_sampler", "compile_detector_sampler"): + method = getattr(tsim.Circuit, name) + if "device" in inspect.signature(method).parameters: + setattr( + tsim.Circuit, name, functools.partialmethod(method, device=device) + ) + return tsim + + run.__globals__["phase_import"] = phase_import + sys.argv = [worker, *argv] + run() + + +if __name__ == "__main__": + main() diff --git a/dev/tsim_dev/profile.py b/dev/tsim_dev/profile.py index 8d6e573d..abc55809 100644 --- a/dev/tsim_dev/profile.py +++ b/dev/tsim_dev/profile.py @@ -117,7 +117,7 @@ def profile(args) -> int: ) done = subprocess.run( cmd, - env=common.child_env(args.threads, args.device), + env=common.child_env(args.threads), capture_output=True, text=True, check=False, diff --git a/dev/tsim_dev/width.py b/dev/tsim_dev/width.py index efda538d..ca44b1ad 100644 --- a/dev/tsim_dev/width.py +++ b/dev/tsim_dev/width.py @@ -85,21 +85,19 @@ def measure( side, cases, args, deadline: float, report: Callable[[str, dict], None] ) -> None: """Compile every case on one side and call `report(key, result)` for each one.""" - from .bench import side_env + from .bench import worker_launch pending = _items(cases) while pending: - cmd = [str(side.python), "-u", str(corpus.worker(args.corpus_dir))] + device = side.device or getattr(args, "device", "cpu") + python, *worker = worker_launch(side.python, device, args.corpus_dir) + cmd = [python, "-u", *worker] cmd += ["--mode", "width", "--root", str(corpus.root(args.corpus_dir))] if side.strategy or args.strategy: cmd += ["--strategy", side.strategy or args.strategy] for _, flag, value in pending: cmd += [flag, value] - env = side_env( - side, - side.threads or args.threads, - side.device or getattr(args, "device", "cpu"), - ) + env = common.child_env(side.threads or args.threads) started, outcome, done = None, Exit(), set() capped = common.capped(cmd, mem=args.mem) for line in _stream(capped, env, args.case_timeout, deadline, outcome): diff --git a/docs/install.mdx b/docs/install.mdx index 48d693c5..f8938550 100644 --- a/docs/install.mdx +++ b/docs/install.mdx @@ -53,33 +53,20 @@ sampler = circuit.compile_detector_sampler(device="metal") The GPU runs circuits whose components have at most 11 active qubits. That includes Clifford circuits, whose detectors depend on the noise alone: the GPU samples the noise as well as the -state vectors. Postselection runs on -the GPU, so heavily postselected circuits gain the most. A sampler's repr says where it -samples (`samples on metal` or `samples on cpu`), and so does `sampler.stats.device`. +state vectors. Postselection runs on the GPU, so heavily postselected circuits gain the most. +A sampler's repr says where it samples (`samples on metal` or `samples on cpu`), and so does +`sampler.stats.device`. The GPU computes outcome probabilities in float32, so a sample follows the CPU's distribution to within float32 rounding. Float32 cannot tell a measurement outcome of probability below about 1e-11 from rounding error, so the GPU never draws one; a circuit with a rotation small -enough to make such an outcome (an `R_Z` or `R_X` by less than about 6e-5 π) samples on the -CPU (or raises, with `device="metal"`). +enough to make such an outcome (an angle within about 6e-5 π of a multiple of π/2) samples on +the CPU (or raises, with `device="metal"`). A seed's samples on the GPU differ from the CPU's and depend on `batch_size`, so with `device="auto"` a seed reproduces its samples only on the same kind of machine. Use `device="cpu"` to reproduce samples across machines. -### Sampling on the CPU instead - -Set `TSIM_DEVICE=cpu` to keep the samplers of `device="auto"` on the CPU without changing -code, for example to reproduce samples from another machine: - -```bash -TSIM_DEVICE=cpu python my_script.py -``` - -Tsim reads it whenever it compiles a sampler, so `os.environ["TSIM_DEVICE"] = "cpu"` before -`compile_sampler` or `compile_detector_sampler` works too. An explicit `device` argument -overrides it. - ## Agent skill Tsim ships an agent skill that teaches coding agents (Claude Code, Codex, Cursor and others) diff --git a/skills/simulating-circuits-with-tsim/SKILL.md b/skills/simulating-circuits-with-tsim/SKILL.md index 9839136f..466728bb 100644 --- a/skills/simulating-circuits-with-tsim/SKILL.md +++ b/skills/simulating-circuits-with-tsim/SKILL.md @@ -1,12 +1,12 @@ --- name: simulating-circuits-with-tsim -description: "Samples noisy quantum circuits with non-Clifford gates using Tsim (bloqade-tsim), a Stim-compatible simulator, and runs Monte Carlo QEC studies with it. Use when a task involves Tsim or bloqade-tsim, or a noisy Stim-format circuit with T, T_DAG, R_X, R_Y, R_Z, U3, R_PAULI, TPP, CCZ, CCX or CS gates (magic state cultivation, distillation, injection, coherent rotation errors), even if Tsim is not named: judging feasibility or run time (active width, k_max); sampling detectors, measurements or observables; postselection; logical error rates, thresholds, acceptance or output infidelity with error bars; decoding Tsim samples (PyMatching, Tesseract, BP-LSD, Relay-BP); detector error models; sinter with Tsim; or porting published circuits (arXiv, GitHub, Zenodo) whose T or CCZ gates are Clifford stand-ins. Also use for errors such as \"needs N active qubits\", \"Malformed parametric tag\" or \"non-deterministic observables\", detectors that read 1 where Stim reports 0, or slow sample() calls." +description: "Samples noisy quantum circuits with non-Clifford gates using Tsim (bloqade-tsim), a Stim-compatible simulator, and runs Monte Carlo QEC studies with it. Use when a task involves Tsim or bloqade-tsim, or a noisy Stim-format circuit with T, T_DAG, R_X, R_Y, R_Z, U3, R_PAULI, TPP, CCZ, CCX or CS gates (magic state cultivation, distillation, injection, coherent rotation errors), even if Tsim is not named: feasibility and run time (active width); sampling detectors, measurements or observables, on the CPU or an Apple GPU; postselection; logical error rates, acceptance or fidelity with error bars; decoding Tsim samples; detector error models; sinter; or porting published circuits whose T or CCZ gates are Clifford stand-ins. Also for errors such as \"needs N active qubits\", \"Malformed parametric tag\" or \"Cannot sample on Metal\", detectors that read 1 where Stim reports 0, or slow sample() calls." license: Apache-2.0 compatibility: Python 3.11+ and Tsim 1.1.0 or later (bloqade-tsim). Decoders are separate packages. metadata: author: QuEra Computing version: "2.0" - last-reviewed: "2026-11-10" + last-reviewed: "2026-11-12" tested-with: "Tsim 1.1.0" --- @@ -181,9 +181,10 @@ yourself. launches and waiting for the GPU to finish), so ask for 10^6 to 10^7 shots per call there. - **`batch_size`:** leave it at the default, which is sized from free memory and is close to optimal. Small values (a few thousand or less) are several times slower. Lower it only to - cap memory; on the CPU it does not change the samples. On a GPU the default batch is up to 2^20 - shots (fewer for very wide rows), one GPU launch, all launched back to back; `batch_size` can only lower it, a small - one makes launch overhead dominate, and it changes which samples a seed gives. + cap memory; on the CPU it does not change the samples. On a GPU the default batch is up to + 2^20 shots (fewer for very wide rows), one GPU launch, all launched back to back; + `batch_size` can only lower it, a small one makes launch overhead dominate, and it changes + which samples a seed gives. - **`bit_packed=True`** returns little-endian packed `uint8` rows, as in Stim, using 8 times less memory. On a distance-11 surface code memory (1,320 detectors) it was twice as fast as booleans, at distance 5 about 1.3 times. On an Apple GPU the gap is larger, since booleans @@ -199,10 +200,9 @@ yourself. - **Apple GPUs:** on an Apple-silicon Mac the default strategy samples on the GPU when every component has at most 11 active qubits, often 5 to 10 times faster than all CPU cores. That includes Clifford circuits, whose detectors come from the noise alone: the GPU samples the - noise too. It - computes outcome probabilities in float32 and never draws an outcome below about 1e-11 - (circuits with rotations by less than about 6e-5 π stay on the CPU). Thread settings do not - affect GPU sampling. + noise too. It computes outcome probabilities in float32 and never draws an outcome below + about 1e-11 (circuits with a rotation angle within about 6e-5 π of a multiple of π/2 stay + on the CPU). Thread settings do not affect GPU sampling. - **`device`** (in `compile_sampler` and `compile_detector_sampler`) chooses where it samples: `"auto"` (default) uses the GPU when it can and otherwise the CPU, silently; `"cpu"` stays on the CPU in double precision; `"metal"` raises with the reason if the GPU cannot run the @@ -210,12 +210,10 @@ yourself. `sampler.stats.device` (or the repr, `samples on metal`) after compiling, since `"auto"` does not warn. Pass `device="metal"` for long runs that must not drop to the CPU unnoticed, and `device="cpu"` to reproduce samples across machines or to compare against the GPU. - `TSIM_DEVICE=cpu` in the environment keeps `"auto"` on the CPU; it is read when a sampler - is compiled, so an unpickled sampler keeps the device choice it was compiled with. - **Processes:** sample from one process per GPU. Threads sharing a GPU sampler run its calls - one at a time, and several processes compete for the same GPU. A process forked after its parent used the GPU cannot use - it (it samples on the CPU, or raises with `device="metal"`); start workers with the - `"spawn"` or `"forkserver"` method (macOS's default is `"spawn"`). + one at a time, and several processes compete for the same GPU. A process forked after its + parent used the GPU cannot use it (it samples on the CPU, or raises with `device="metal"`); + start workers with the `"spawn"` or `"forkserver"` method (macOS's default is `"spawn"`). - **Seeds:** a `seed` reproduces samples for the same Tsim and dependency versions and device. On the CPU, thread count and `batch_size` do not affect the samples; on a GPU, `batch_size` does, and GPU samples differ from CPU samples, so with `device="auto"` a seed @@ -266,7 +264,7 @@ run the noiseless check at a parameter value where they are deterministic (the T two reference flags are independent: a circuit can need `use_detector_reference_sample=True` with `use_observable_reference_sample=False`. -**Check the mask before sampling.** A wrong mask gives plausible but wrong numbers. List the +**Check the mask before sampling.** A wrong mask gives plausible but wrong numbers. Write the stages down, and check that no masked detector fires without noise: one that is always 1 or random discards shots that have no error. Sample the noiseless check with the same reference flags as the real run. `without_noise()` removes the noise channels but keeps every rotation, @@ -331,9 +329,12 @@ Tsim results: measurements on the same qubits: `k` repeats of `R_X(a) 0; MYY 0 1; R_Z(a) 1; MX 1` give width `k`. If the width grows linearly with the number of rounds, this is the cause; report it rather than trying to work around it. -- **Memory sizing ignores container limits.** The default batch size is chosen from the - machine's free memory, not from a container or cgroup memory limit, so inside a memory-capped - job pass an explicit `batch_size` (for example 2^16) to stay within the cap. +- **Memory sizing ignores container limits.** On the CPU the default batch size is chosen from + the machine's free memory, not from a container or cgroup memory limit, so inside a + memory-capped job pass an explicit `batch_size` (for example 2^16) to stay within the cap. On + an Apple GPU leave `batch_size` unset (see "Sampling options"): a GPU call needs about 300 MB + beyond the rows it returns, and Tsim keeps up to 1 GB of freed rows for reuse, so ask for + fewer shots per call to use less memory. - **`str(circuit)` rounds noise probabilities** to 6 significant digits, as Stim does; rotation angles keep full precision. Keep the original file when exact noise strengths matter. - **`CCZ`, `CCX`, `CS` and `CS_DAG` are stored as their T-gate decompositions**, so `str(c)` @@ -353,6 +354,11 @@ Tsim results: | `Malformed parametric tag` | Angles are in units of π: write `R_X(0.25) 0` or `I[R_X(0.25)] 0` | | `'Circuit' object has no attribute ...` | A Stim method; call it on `c.stim_circuit` | | `Cannot set NUMBA_NUM_THREADS ... once the threads have been launched` | Use `numba.set_num_threads(k)` | +| `Cannot sample on Metal: a component needs N active qubits, more than the 11 ...` | The GPU runs widths up to 11; use `device="cpu"` or `"auto"`, or reduce the width | +| `Cannot sample on Metal: a rotation's angle is ... π from a Clifford angle ...` | float32 cannot resolve the rotation; use `device="cpu"` or `"auto"` | +| `Cannot sample on Metal: this process was forked after its parent used the GPU ...` | Start workers with the `"spawn"` or `"forkserver"` method | +| `Cannot sample on Metal: no Metal device`, `... needs an Apple GPU ...` or `... (pyobjc-framework-Metal) is not installed` | Not an Apple-silicon Mac, or `pyobjc-framework-Metal` is missing; use `device="cpu"` or `"auto"` | +| `no Metal buffer of N bytes ...` or `the GPU ran out of memory` | Sample fewer shots per call | ## Reporting bugs and circuits Tsim cannot simulate yet @@ -371,8 +377,8 @@ from (PyPI, or git and the commit), the seed, and the expected and actual result simulate it in practice (the width is over 30, or shots are too slow for the study), suggest opening an issue. The maintainers want these cases: they show where Tsim needs to improve, and a real circuit is the best benchmark for that. Here, do not shrink the circuit; the full circuit -is the point, but only if the user is open to share it. Include the circuit -(attach the file, or link to it if it is large), where it -comes from (paper or repository), what the user wants to compute, and what you measured: +is the point, but only if the user is open to share it. Include the circuit (attach the file, +or link to it if it is large), where it comes from (paper or repository), what the user wants +to compute, and what you measured: qubits, T count (`c.tcount()`), `sampler.active_width` and `sampler.stats` (or the error message), and the time per shot if sampling ran at all. diff --git a/src/tsim/circuit.py b/src/tsim/circuit.py index d90191b2..6caf204d 100644 --- a/src/tsim/circuit.py +++ b/src/tsim/circuit.py @@ -967,12 +967,12 @@ def compile_sampler( size. device: Where "active_statevector" samples. "auto" (the default) samples in float32 on an Apple GPU when there is one that can - run the circuit, and otherwise on the CPU, without a warning - (``TSIM_DEVICE=cpu`` keeps it on the CPU). "cpu" samples on the - CPU in float64, on ``numba.get_num_threads()`` threads. "metal" - samples on an Apple GPU or raises: without one, for a component - of more than 11 active qubits, a rotation by less than about - 6e-5 π, noise beyond the GPU's tables, or a call that runs out + run the circuit, and otherwise on the CPU, without a warning. + "cpu" samples on the CPU in float64, on + ``numba.get_num_threads()`` threads. "metal" samples on an Apple + GPU or raises: without one, for a component of more than 11 + active qubits, a rotation within about 6e-5 π of a multiple of + π/2, noise beyond the GPU's tables, or a call that runs out of GPU memory. The stabilizer rank strategies take only "auto". Returns: @@ -1022,12 +1022,12 @@ def compile_detector_sampler( On a GPU, they do depend on the batch size. device: Where "active_statevector" samples. "auto" (the default) samples in float32 on an Apple GPU when there is one that can - run the circuit, and otherwise on the CPU, without a warning - (``TSIM_DEVICE=cpu`` keeps it on the CPU). "cpu" samples on the - CPU in float64, on ``numba.get_num_threads()`` threads. "metal" - samples on an Apple GPU or raises: without one, for a component - of more than 11 active qubits, a rotation by less than about - 6e-5 π, noise beyond the GPU's tables, or a call that runs out + run the circuit, and otherwise on the CPU, without a warning. + "cpu" samples on the CPU in float64, on + ``numba.get_num_threads()`` threads. "metal" samples on an Apple + GPU or raises: without one, for a component of more than 11 + active qubits, a rotation within about 6e-5 π of a multiple of + π/2, noise beyond the GPU's tables, or a call that runs out of GPU memory. The stabilizer rank strategies take only "auto". Returns: diff --git a/src/tsim/sampler.py b/src/tsim/sampler.py index 325438b0..09d08d38 100644 --- a/src/tsim/sampler.py +++ b/src/tsim/sampler.py @@ -2,7 +2,6 @@ from __future__ import annotations -import os import secrets import warnings from dataclasses import dataclass @@ -353,12 +352,12 @@ def __init__( size. device: Where "active_statevector" samples. "auto" (the default) samples in float32 on an Apple GPU when there is one that can - run the circuit, and otherwise on the CPU, without a warning - (``TSIM_DEVICE=cpu`` keeps it on the CPU). "cpu" samples on the - CPU in float64, on ``numba.get_num_threads()`` threads. "metal" - samples on an Apple GPU or raises: without one, for a component - of more than 11 active qubits, a rotation by less than about - 6e-5 π, noise beyond the GPU's tables, or a call that runs out + run the circuit, and otherwise on the CPU, without a warning. + "cpu" samples on the CPU in float64, on + ``numba.get_num_threads()`` threads. "metal" samples on an Apple + GPU or raises: without one, for a component of more than 11 + active qubits, a rotation within about 6e-5 π of a multiple of + π/2, noise beyond the GPU's tables, or a call that runs out of GPU memory. The stabilizer rank strategies take only "auto". Raises: @@ -411,8 +410,6 @@ def __init__( self._shots = host # NOTE: as JAX does for the stabilizer-rank strategies, "auto" uses a # GPU whenever there is one that can run the program. - if device == "auto" and os.environ.get("TSIM_DEVICE", "").lower() == "cpu": - device = "cpu" if mode == "sequential" and device != "cpu": self._shots = metal.shot_sampler( host, @@ -912,12 +909,12 @@ def __init__( size. device: Where "active_statevector" samples. "auto" (the default) samples in float32 on an Apple GPU when there is one that can - run the circuit, and otherwise on the CPU, without a warning - (``TSIM_DEVICE=cpu`` keeps it on the CPU). "cpu" samples on the - CPU in float64, on ``numba.get_num_threads()`` threads. "metal" - samples on an Apple GPU or raises: without one, for a component - of more than 11 active qubits, a rotation by less than about - 6e-5 π, noise beyond the GPU's tables, or a call that runs out + run the circuit, and otherwise on the CPU, without a warning. + "cpu" samples on the CPU in float64, on + ``numba.get_num_threads()`` threads. "metal" samples on an Apple + GPU or raises: without one, for a component of more than 11 + active qubits, a rotation within about 6e-5 π of a multiple of + π/2, noise beyond the GPU's tables, or a call that runs out of GPU memory. The stabilizer rank strategies take only "auto". Raises: @@ -1031,12 +1028,12 @@ def __init__( On a GPU, they do depend on the batch size. device: Where "active_statevector" samples. "auto" (the default) samples in float32 on an Apple GPU when there is one that can - run the circuit, and otherwise on the CPU, without a warning - (``TSIM_DEVICE=cpu`` keeps it on the CPU). "cpu" samples on the - CPU in float64, on ``numba.get_num_threads()`` threads. "metal" - samples on an Apple GPU or raises: without one, for a component - of more than 11 active qubits, a rotation by less than about - 6e-5 π, noise beyond the GPU's tables, or a call that runs out + run the circuit, and otherwise on the CPU, without a warning. + "cpu" samples on the CPU in float64, on + ``numba.get_num_threads()`` threads. "metal" samples on an Apple + GPU or raises: without one, for a component of more than 11 + active qubits, a rotation within about 6e-5 π of a multiple of + π/2, noise beyond the GPU's tables, or a call that runs out of GPU memory. The stabilizer rank strategies take only "auto". Raises: diff --git a/src/tsim/statevector/metal/__init__.py b/src/tsim/statevector/metal/__init__.py index c9e5fe50..e5468af0 100644 --- a/src/tsim/statevector/metal/__init__.py +++ b/src/tsim/statevector/metal/__init__.py @@ -4,12 +4,14 @@ by ``tsim.statevector.gpu.tape``) run as Metal kernels that PyObjC compiles at runtime (``runtime``); ``MetalShotSampler`` (``sampler``) replaces ``ShotSampler`` behind the compiled samplers of ``device="auto"`` or -``"metal"`` whenever PyObjC sees an Apple GPU and every component fits. Amplitudes are complex64. It needs Apple silicon, -where Tsim depends on ``pyobjc-framework-Metal``. +``"metal"`` whenever PyObjC sees an Apple GPU and every component fits. +Amplitudes are complex64. It needs Apple silicon, where Tsim depends on +``pyobjc-framework-Metal``. """ from __future__ import annotations +import math from typing import TYPE_CHECKING from tsim.statevector.gpu.tape import MAX_WIDTH, RESOLVABLE, rarest_branch, supports @@ -58,7 +60,10 @@ def shot_sampler( reason = _unsupported(host.layout) if reason is not None: if required: - raise ValueError(f"Cannot sample on Metal: {reason}.") + raise ValueError( + f"Cannot sample on Metal: {reason}; sample with device='cpu', or " + "with device='auto', which falls back to the CPU." + ) return host from tsim.statevector.metal.sampler import MetalShotSampler @@ -85,7 +90,18 @@ def _unsupported(layout: ShotLayout) -> str | None: f"a component needs {width} active qubits, more than the " f"{MAX_WIDTH} that the GPU executor supports" ) + # NOTE: a rotation's branch weights are cos^2 and sin^2 of half its angle, so + # a weight w is that of an angle 2 asin(sqrt(w)) from a multiple of π. The + # tape's rotations are the circuit's without their Clifford parts (multiples + # of π/2), so the circuit's angle is as far from a multiple of π/2. + distance = _angle(rarest_branch(layout)) return ( - f"a rotation has a branch of weight {rarest_branch(layout):.1e}, " - f"below the {RESOLVABLE:.0e} that float32 amplitudes resolve" + f"a rotation's angle is {distance:.1e} π from a Clifford angle (a " + f"multiple of π/2), closer than the {_angle(RESOLVABLE):.1e} π that " + "float32 amplitudes resolve" ) + + +def _angle(weight: float) -> float: + """Return the angle, in units of π, whose branch weight ``sin^2`` is ``weight``.""" + return 2 * math.asin(math.sqrt(weight)) / math.pi diff --git a/src/tsim/statevector/metal/runtime.py b/src/tsim/statevector/metal/runtime.py index 2c573a02..6d751936 100644 --- a/src/tsim/statevector/metal/runtime.py +++ b/src/tsim/statevector/metal/runtime.py @@ -179,6 +179,14 @@ class Buffer: call returned (and its caller dropped). """ +POOL_BYTES = 1 << 30 +"""Most bytes of freed buffers kept for reuse; a larger buffer is released at once. + +NOTE: the pool outlives the samplers, so this bounds the memory that the process +holds after their outputs are gone. It is above the rows of calls of a few +million shots, whose page faults the pool saves. +""" + _pool: list[Any] = [] # NOTE: reentrant, since a buffer can be freed (``_Owner.__del__``) while this # thread searches the pool. @@ -204,10 +212,13 @@ def __init__(self, handle: Any, nbytes: int, recycle: bool = False) -> None: } def __del__(self) -> None: - if self.recycle and _pool is not None: - with _pool_lock: - _pool.append(self.handle) - del _pool[:-POOL_BUFFERS] + if not self.recycle or _pool is None or self.handle.length() > POOL_BYTES: + return + with _pool_lock: + _pool.append(self.handle) + del _pool[:-POOL_BUFFERS] + while sum(b.length() for b in _pool) > POOL_BYTES: + del _pool[0] def buffer(count: int, dtype, recycle: bool = False) -> Buffer: @@ -215,7 +226,8 @@ def buffer(count: int, dtype, recycle: bool = False) -> Buffer: Its array keeps the Metal buffer alive, so it may outlive the ``Buffer``. With ``recycle`` the buffer may be a freed one of up to twice the size, not - zeroed, and returns to the pool once its arrays are gone. + zeroed, and returns to the pool once its arrays are gone (unless it is larger + than ``POOL_BYTES``). """ dtype = np.dtype(dtype) nbytes = max(int(count) * dtype.itemsize, 16) diff --git a/test/integration/test_metal_sampler.py b/test/integration/test_metal_sampler.py index c019c6b9..9627ac1a 100644 --- a/test/integration/test_metal_sampler.py +++ b/test/integration/test_metal_sampler.py @@ -761,8 +761,12 @@ def test_rotation_with_a_branch_float32_cannot_resolve_samples_on_the_cpu(): warnings.simplefilter("error") sampler = circuit.compile_detector_sampler(seed=1) assert sampler.stats.device == "cpu" - with pytest.raises(ValueError, match=r"rotation has a branch of weight 2\.5e-10"): + message = r"angle is 1\.0e-05 π from a Clifford angle.*6\.4e-05 π.*device='cpu'" + with pytest.raises(ValueError, match=message): circuit.compile_detector_sampler(seed=1, device="metal") + # NOTE: the Clifford part of a rotation is absorbed before the GPU sees it. + near_s = Circuit("R 0\nH 0\nR_Z(0.50001) 0\nH 0\nM 0\nDETECTOR rec[-1]") + assert near_s.compile_detector_sampler(seed=1).stats.device == "cpu" @pytest.mark.parametrize("p0", [3e-8, 1e-6]) @@ -829,18 +833,28 @@ def out_of_memory(*args, **kwargs): Circuit(T_STATE_X).compile_sampler(seed=1, device="metal") -def test_tsim_device_cpu_keeps_the_samplers_on_the_cpu(monkeypatch): +def test_device_cpu_keeps_the_samplers_on_the_cpu(monkeypatch): + # NOTE: the environment no longer chooses the device. monkeypatch.setenv("TSIM_DEVICE", "cpu") - with warnings.catch_warnings(): - warnings.simplefilter("error") - sampler = Circuit(T_STATE_X).compile_sampler(seed=1) - assert type(sampler._shots) is ShotSampler - # NOTE: an explicit device overrides TSIM_DEVICE. - _gpu(Circuit(T_STATE_X).compile_sampler(seed=1, device="metal")) - monkeypatch.setenv("TSIM_DEVICE", "") _gpu(Circuit(T_STATE_X).compile_sampler(seed=1)) sampler = Circuit(T_STATE_X).compile_sampler(seed=1, device="cpu") assert type(sampler._shots) is ShotSampler + assert sampler.stats.device == "cpu" + + +def test_freed_buffers_beyond_the_pool_bytes_are_released(monkeypatch): + monkeypatch.setattr(runtime, "_pool", []) + monkeypatch.setattr(runtime, "POOL_BYTES", 1 << 20) + small = runtime.buffer(1 << 16, np.uint8, recycle=True) + del small + assert [b.length() for b in runtime._pool] == [1 << 16] + large = runtime.buffer(2 << 20, np.uint8, recycle=True) + del large + assert [b.length() for b in runtime._pool] == [1 << 16] + medium = runtime.buffer(1 << 20, np.uint8, recycle=True) + del medium + # NOTE: the oldest buffers go first. + assert [b.length() for b in runtime._pool] == [1 << 20] @pytest.mark.parametrize( diff --git a/test/unit/test_dev_cli.py b/test/unit/test_dev_cli.py index ab584450..851b7974 100644 --- a/test/unit/test_dev_cli.py +++ b/test/unit/test_dev_cli.py @@ -96,7 +96,7 @@ def test_corpus_circuits_are_postselected_unless_turned_off(fake_bench): assert "--no-postselect" in command -def test_benchmarks_time_bit_packed_rows_on_the_cpu_by_default(fake_bench, monkeypatch): +def test_benchmarks_time_bit_packed_rows_on_the_cpu_by_default(fake_bench): (case,) = bench.select(_bench_args()) command = bench.worker_command(Path("python"), case, _bench_args(), "bench") assert "--bit-packed" in command @@ -109,13 +109,16 @@ def test_benchmarks_time_bit_packed_rows_on_the_cpu_by_default(fake_bench, monke # samples every shot. (case,) = bench.select(_bench_args("--sampler", "measurement")) assert (case.sampler, case.postselect) == ("measurement", "none") - monkeypatch.setenv("TSIM_DEVICE", "gpu-left-over") side = bench.parse_side(".:device=gpu") assert side.describe().endswith("device=gpu") - assert "TSIM_DEVICE" not in bench.side_env(side, 1, "gpu") - assert bench.side_env(side, 1, "cpu")["TSIM_DEVICE"] == "cpu" - # NOTE: tests and jobs leave the device to the tests themselves. - assert common.child_env(1)["TSIM_DEVICE"] == "gpu-left-over" + # NOTE: on the CPU the worker runs through on_device.py, which passes + # device="cpu" to the samplers; on a GPU it runs as it is. + command = bench.worker_command(Path("python"), case, _bench_args(), "bench") + assert command[1:3] == [str(bench.ON_DEVICE), "cpu"] + command = bench.worker_command( + Path("python"), case, _bench_args("--device", "gpu"), "bench" + ) + assert command[1].endswith("worker.py") def test_shot_benchmarks_report_the_device_that_sampled(fake_bench, monkeypatch): @@ -288,7 +291,7 @@ def test_sides_carry_their_own_settings(fake_bench): "cat5", ) assert side.describe().endswith("threads=10 strategy=cat5") - assert bench.side_env(side, 10, "cpu")["NUMBA_NUM_THREADS"] == "10" + assert common.child_env(side.threads)["NUMBA_NUM_THREADS"] == "10" # A python executable is used as given: resolving a venv's symlink would lose the venv. python = Path(sys.executable).absolute() assert bench.parse_side(f"{python}:threads=2").python == python