Reuse the Python callback method-name string and expose constant-time position count and presence. Preserve dynamic callbacks, Bar identity, and snapshot behavior with regression tests. Add isolated callback measurements and replace approximate cross-library comparisons with matched trades and equity assertions, separate setup/run timings, and reproducible reports. Track the remaining ingestion bottleneck in #53. Refs #48, #38, #39, #53 Codex 🤖, On behalf of Khizar
8.6 KiB
Python callback benchmark
This benchmark measures the Rust-to-Python callback path separately from data
conversion. It also compares indicator work, trading, and position inspection.
It uses the real Python _Adapter and the normal Rust broker, equity, statistics,
and final-liquidation code. It does not compare different backtesting libraries.
Run the benchmark
From the repository root, with your Python environment activated:
python -m pip install .
repo_dir="$PWD"
cd /tmp
python "$repo_dir/examples/benchmark_callbacks.py" \
--sizes 1000 10000 100000 --repeats 7 --conversion-repeats 3 \
--label after --output callback-after.json
The normal pip build uses maturin's release profile. Reinstall after Rust or
Python source changes. Run outside the checkout to use the installed extension.
Keep correctness assertions enabled; do not run with python -O.
Use --cases native-noop direct-noop adapter-noop for a narrower comparison.
Each dataset runs in a fresh subprocess. JSON output records all simulation
samples, medians for every phase, peak RSS, CPU, OS, Python, Rust, dependency
versions, the Cargo.lock PyO3 version, and the source checkout's HEAD. The HEAD
identifies the source checkout, not the installed wheel. Use --label to identify
working-tree builds, and make sure the installed wheel matches your source.
Peak RSS is unavailable on platforms without Python's resource module.
Timing boundaries
All paths use the same prepared midpoint bars and cost configuration. Synthetic
prices are flat at 1.1, with a 0.0001 spread, commission 7 per lot per side,
contract size 100000, and quote-to-account conversion 1.27.
- Conversion: time
Backtest._to_bars()separately. DataFrame construction is outside this measurement. Use the last conversion's bars for every case. - Engine setup: construct a fresh
Enginefrom those bars for every sample. Record this separately from the run. Construct a fresh strategy and adapter outside the run timer too. - Initialization: start the run timer before
Engine.run(). A single timestamp at the end of the real adapter'sinit()marks the boundary. This includes Rust-to-Python conversion of the initialization history and any indicator precomputation. Broker creation follows this boundary and is part of the run. - Simulation: measure from that timestamp until
Engine.run()returns. Include callbacks, SL/TP scans, equity, final liquidation, statistics, and conversion of results back to Python. No timing calls are added per bar.
The native no-op has no Python initialization. Its measured run includes its
empty native init() and the normal engine setup inside Engine::run(), plus
the same broker, equity, finalization, and statistics paths. The private
Engine._run_native_noop() method is a benchmark hook, not a supported strategy API.
There is one untimed warmup followed by seven measured runs per case. Reported
times are medians. No engines, strategy instances, or brokers carry state between
samples. Correctness assertions run after timing. They check trade count, fills,
net PnL, final cash, equity length and endpoints, no-op equity, final indicator
values, and position inspection. Trading uses fixed 0.001 lots.
Peak RSS is the whole dataset subprocess's high-water mark after all cases, including imports, conversion, bars, equity, trades, and correctness checks. It is not per-case allocation or the memory cost of a single callback. Native memory is included.
Local results
Measured on an Intel Core i5-8350U, Linux x86_64, CPython 3.14.4, Rust 1.98.1,
PyO3 0.28.3, pandas 3.1.0, and NumPy 2.5.4, with default Cargo release settings.
Both versions used the same dependency versions, benchmark, data, and costs.
The baseline used dispatch from d9687e7 with the native no-op benchmark hook
added. The compatible version reuses the Python method-name string and adds
position count/presence properties. It keeps dynamic callback lookup and fresh
callback Bar objects.
| Bars | Adapter no-op before (ns/bar) | After (ns/bar) | Run-time reduction |
|---|---|---|---|
| 1,000 | 290.7 | 201.4 | 30.7% |
| 10,000 | 294.8 | 204.4 | 30.7% |
| 100,000 | 311.1 | 229.7 | 26.1% |
At 100,000 bars, the compatible build measured:
| Workload | Initialization (ms) | Simulation (ms) | Simulation (ns/bar) |
|---|---|---|---|
| Native no-op | 0.00 | 1.22 | 12.2 |
| Direct Python no-op | 6.63 | 15.77 | 157.7 |
| Python adapter no-op | 5.23 | 22.97 | 229.7 |
Rolling 20-bar mean in next() |
4.76 | 330.45 | 3304.5 |
Precompute mean in init() |
15.80 | 36.60 | 366.0 |
| Open and close one trade per bar | 4.98 | 83.81 | 838.1 |
| Hold 32 positions without inspection | 3.10 | 38.71 | 387.1 |
Read len(self.positions) with 32 positions |
3.00 | 230.73 | 2307.3 |
Read self.position_count with 32 positions |
3.01 | 52.87 | 528.7 |
Read self.has_positions with 32 positions |
3.09 | 54.81 | 548.1 |
The direct-to-adapter difference is about 72 ns/bar here. It includes adapter
state updates and the extra Strategy.next() dispatch. List inspection is much
more expensive with 32 positions because it copies the position vector and
creates Python position objects. The presence workload takes about 4.2 times
less simulation time than list inspection on this fixture. The read workloads
share the same subclass dispatch; the hold-only case omits that extra method call.
At this size, constructing the engine for the adapter no-op took about 4.92 ms,
and DataFrame-to-Bar conversion took 3.93 seconds. The 26.1% reduction applies
to the measured callback run, not to a complete Backtest.run(). Conversion
still dominates that workload and is tracked separately in #39.
| Bars | Peak RSS before (MiB) | After (MiB) |
|---|---|---|
| 1,000 | 78.53 | 78.37 |
| 10,000 | 83.50 | 84.19 |
| 100,000 | 135.88 | 135.82 |
These whole-process high-water marks do not establish a memory improvement. The after run includes the new count/presence cases too. Hardware, allocator state, Python version, and system load affect timings. Differences between rows estimate workload costs; they are not additive profiler measurements.
Dispatch and allocation experiments
Exploratory runs used 100,000 bars, nine repetitions, and one conversion sample,
with only native-noop, direct-noop, and adapter-noop selected.
| Variant | Direct Python (ns/bar) | Adapter (ns/bar) | Decision |
|---|---|---|---|
| Reuse method-name string | 167.3 | 214.8 | Keep |
| Also cache the bound callable once | 141.1 | 230.7 | Reject |
| Reuse initialization Bar objects, dynamic callable | 129.3 | 181.5 | Reject |
The retained change uses PyO3's intern! macro
to reuse the Python string "next" instead of constructing it for every call.
It still resolves the method on every bar.
Caching the bound callable broke
test_direct_engine_resolves_callback_replacements_on_later_bars: the old
callback ran three times instead of honoring the replacement on later bars.
The adapter measurement also did not show a consistent gain over the interned
name alone. Reusing initialization Bars measured lower callback cost, but broke
test_callbacks_keep_distinct_bar_objects_and_retained_values because the
callback received the same object as the initialization history. These changes
were removed. Fresh callback objects and existing position snapshot semantics
remain intact.
To reproduce the old dispatch in a separate checkout, keep the benchmark hook
and replace the interned-name call in Engine::run_py with:
strategy.bind(py).call_method(
"next", (bar.clone(), broker_py.clone_ref(py)), None,
)?;
Remove the unused next_name declaration, rebuild the installed package, and
run the same benchmark command with --label before. Do not compare a debug
build with a release build.
Choose an execution path
Use Python next() for decisions that depend on the broker: fills, current
equity, trailing stops, or partial closes. Read has_positions or position_count
when you only need presence or a count; use positions when you need snapshots.
Precompute indicators during initialization when they depend only on market data, then read the current value during callbacks. The indicator rows include the added initialization work and show its separate cost.
For broker-independent target-lot signals and parameter grids, use optimize().
It computes one signal array per combination and runs the simulations in native
threads with the GIL released. Python callbacks still execute once per bar under
the GIL. No batching, callback removal, or new single-run signal API is introduced
by this change.