Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions .claude/skills/benchmarking-tsim/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down
4 changes: 2 additions & 2 deletions .claude/skills/profiling-tsim/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions .claude/skills/shipping-tsim-changes/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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]` |
Expand Down
84 changes: 28 additions & 56 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
49 changes: 31 additions & 18 deletions dev/tsim_dev/bench.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
"""
Expand Down Expand Up @@ -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]
Expand All @@ -281,30 +302,20 @@ 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
device = side.device or args.device
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),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Clear legacy TSIM_DEVICE for GPU workers

When the invoking shell still exports TSIM_DEVICE=cpu, --device gpu --ref ... now passes that variable through child_env to both workers. The changed checkout ignores it, but a reference such as this commit's parent still honors it, so the benchmark compares the new GPU path against the reference CPU path and can report a misleading performance change. Preserve the previous GPU-side sanitization by removing TSIM_DEVICE from worker environments.

Useful? React with 👍 / 👎.

capture_output=True,
text=True,
check=False,
Expand All @@ -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,
Expand Down
4 changes: 2 additions & 2 deletions dev/tsim_dev/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)",
)


Expand Down
9 changes: 1 addition & 8 deletions dev/tsim_dev/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
45 changes: 45 additions & 0 deletions dev/tsim_dev/on_device.py
Original file line number Diff line number Diff line change
@@ -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()
2 changes: 1 addition & 1 deletion dev/tsim_dev/profile.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
12 changes: 5 additions & 7 deletions dev/tsim_dev/width.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
Loading
Loading