Skip to content

Backtest time grows quadratically with the number of distinct entry ids (~200× on 700k bars) #452

Closed

Description

@letiep97

Summary

A strategy that gives every new position its own strategy.entry id (for example an id built from bar_index or the bar time) runs about 200 times slower than the same strategy with a fixed id per side, with identical trades. Run time grows at least with the square of the number of distinct ids, so long intraday histories become impractical for parameter sweeps.

Versions

  • pineforge-engine 693fd9e6 (VERSION 1.6.1), pineforge-codegen 1.6.1
  • macOS 15.8, Apple M1 Pro, Apple clang 17.0.0, strategy built with -O2, engine Release (-O3)

Reproduction

Two scripts that differ only in their order ids:

//@version=6
strategy('Unique entry ids', overlay=true, initial_capital=10000, default_qty_type=strategy.fixed, default_qty_value=0.01)
fast = ta.ema(close, 20)
slow = ta.ema(close, 60)
string n = str.tostring(bar_index)
if ta.crossover(fast, slow)
    strategy.close_all()
    strategy.entry('Long ' + n, strategy.long)
    strategy.exit('Long stop ' + n, 'Long ' + n, stop = close * 0.97)
if ta.crossunder(fast, slow)
    strategy.close_all()
    strategy.entry('Short ' + n, strategy.short)
    strategy.exit('Short stop ' + n, 'Short ' + n, stop = close * 1.03)

The control uses the fixed ids 'Long', 'Long stop', 'Short' and 'Short stop'.

Feed: Binance spot BTCUSDT 5-minute bars, one run_backtest_full per measurement, report time excluded.

Span Bars Trades Unique ids Fixed ids
2020 104,862 1,966 3.38 s —
2020–2021 209,767 3,979 14.49 s —
2020-01-01 → 2026-09-07 702,505 13,500 268.3 s 1.22 s

Both variants produce the same 13,500 trades and the same net profit.

Where the time goes

macOS sample for 6 s during the 2020–2021 unique-id run (4,743 samples, all inside run_backtest_full):

  • about 60% in PineExecutionAdapter::source_entry_slot_count(), called from PineStrategyHost::scheduler_publish_source_bar on every bar;
  • about 24% in PineExecutionAdapter::erase_retired_rows, called from on_bar_open.

Nearly all of that time is std::unordered_map<std::string, CohortFacts>::find plus the string hash and memcmp it calls. This is consistent with a walk over every entry id seen so far on each bar.

Expected

Per-bar cost independent of how many distinct ids the run has used so far.

Workaround we use

Keep one id per side for strategy.entry / strategy.exit and carry any per-trade label elsewhere (for example in the alert message or the comment). On the strategies we checked (no pyramiding), TradingView's Deep Backtesting report lists the same trades before and after the change.

Activity

  1. changed the title [-]Backtest time grows quadratically with the number of distinct entry ids (~100× on 700k bars)[/-] [+]Backtest time grows quadratically with the number of distinct entry ids (~200× on 700k bars)[/+] on Oct 20, 2026
  2. luisleo526 commented on Oct 25, 2026

    @luisleo526
    Collaborator

    Thank you for the clear report and the profile. We re-ran your two scripts on the public Binance spot BTCUSDT 5-minute feed: your "Unique entry ids" script, and the control built from it with the four fixed ids from your description ('Long', 'Long stop', 'Short', 'Short stop'). Engine time of run_backtest_full only; seconds rounded to 2 decimals; unique = mean of 3 runs, fixed = mean of 4.

    Span (bars) Engine Unique ids Fixed ids Unique / fixed
    2020 (105,159) 1.6.3 4.87 s 0.44 s 11.1x
    2020 1.7.0 1.93 s 1.35 s 1.43x
    2020 your PR #458 0.38 s 0.50 s 0.76x
    2020-2021 (210,082) 1.6.3 22.45 s 0.78 s 28.9x
    2020-2021 1.7.0 4.18 s 2.56 s 1.64x
    2020-2021 your PR #458 0.78 s 0.75 s 1.04x
    2020-01-01 to 2026-09-07 (702,834) 1.6.3 487.84 s 2.67 s 182.8x
    same 1.7.0 16.57 s 9.58 s 1.73x
    same your PR #458 2.60 s 2.93 s 0.89x

    Notes on the setup:

    • Our feed has slightly more bars than yours (105,159 / 210,082 / 702,834 against your 104,862 / 209,767 / 702,505), so our trade counts differ a little (for example 1,977 and 13,510 against your 1,966 and 13,500). You ran an Apple M1 Pro; we ran Linux x86-64 (an AWS c5.4xlarge). The ratios, not the seconds, are the comparable numbers.
    • The three engines ran at the same time on one 16-vCPU machine, so the absolute seconds carry some noise.
    • Trades, net profit and the full trade list are identical between unique and fixed ids on every engine, and identical across the three engines.
    • Your PR Pine adapter: per-bar and per-fill walks read only the cohorts that hold an opening or live units, and the roster pass only the rosters with members (#452) #458 was measured as submitted, on its own base (1.6.2-rc.1), not on top of 1.7.0. So its absolute times, including its fixed-id control, say nothing about 1.7.0 with the patch applied.

    What the numbers show:

    • 1.6.3 reproduces your report: about 183x on the full span, and the unique-id run grows much faster than the bar count.
    • 1.7.0 removes that slowdown (about 180x here, about 200x in your report): 1.73x on the full span, and the unique-id script runs 29x faster than on 1.6.3. It is not fully flat yet: the extra time of the unique-id run over the fixed-id run is 0.58 s, 1.63 s and 6.99 s for the three spans, so it still grows faster than linearly with the bars. We are tracking that as a follow-up.
    • In this test 1.7.0 is also slower than 1.6.3 on the fixed-id control (9.58 s against 2.67 s on the full span). We are working on bringing 1.7.x back to 1.6.3 speed.
    • Your PR Pine adapter: per-bar and per-fill walks read only the cohorts that hold an opening or live units, and the roster pass only the rosters with members (#452) #458 is flat in this test (0.76x to 1.04x). We will review it against the 1.7.x code.

    We are leaving this issue open until the per-bar cost is flat.

  3. luisleo526 commented on Oct 26, 2026

    @luisleo526
    Collaborator

    Engine 1.8.0 (27cd77b, #541) removes two scaling terms for runs with many pending orders: scratch seeks on a full close go from N(N+1)/2 to 2N+1 and per-event cohort walks visit only cohorts that hold an opening or live units. It does not remove the per-bar Theta(R) retire pass, and the dense shape still does not finish within 600 s from 10,000 bars (numbers in the comment on #458). Remaining here: the retire pass and a profile of the dense run past 4,000 bars.

  4. letiep97 commented on Oct 30, 2026

    @letiep97
    Author

    Thank you for the measurements and for 1.8.0. With the unique-id script the dense case is now close to the fixed-id one, which was the main problem for us.

    The fixed-id control is still slower than on 1.6.x in 1.9.0, on both platforms we have. Same two scripts from this issue, same Binance BTCUSDT 5-minute feed 2020-01-01 to 2026-09-07 (702,505 bars), time of run_backtest_full only, median of 3 runs, Release builds from the tags:

    Engine macOS 15 arm64, Apple clang 17 Linux arm64, gcc 13 (Ubuntu 24.04 in Docker on the same M1 Pro)
    1.6.1, fixed ids 0.80 s 1.41 s
    1.9.0, fixed ids 9.42 s 12.12 s
    1.9.0, unique ids 12.88 s 14.31 s

    On fixed ids 1.9.0 takes 11.8 times as long as 1.6.1 on macOS and 8.6 times on Linux. Your 10-25 run had 3.6 times for 1.7.0 against 1.6.3 on x86-64; we have no x86-64 machine to check 1.9.0 there.

    Trades (13,505) and net profit are identical between the two engines and between the two platforms.

    On macOS, sample puts 75% of the 1.9.0 fixed-id run in PineExecutionAdapter::on_applied, almost all of it under ExitScratchScope / ExitScratchRows::put on CommandScratchFile: pread and pwrite take 44% of the run and memmove 31%, behind the 64 KiB cache (kCacheBytes in src/command_scratch_cache.hpp). The extra time follows the number of fills, about 0.3 ms per fill on macOS and 0.4 ms on Linux (13,505 trades, so about 27,000 fills), not the number of ids. Setting strategy_set_scratch_tables_v1 to bounded memory did not change the time.

    Two questions:

    1. Is this the cost you meant on 10-25 by bringing 1.7.x back to 1.6.3 speed on the fixed-id control, or is it expected to stay?
    2. For a host that runs one backtest per process and has memory to spare, could the exit scratch be kept in memory, or its cache made larger, through an option?
  5. luisleo526 commented on Oct 31, 2026

    @luisleo526
    Collaborator

    Thanks for the pull request in #458: the parts of its approach we took landed in 1.8.0 (#541).

    We re-measured your two scripts (distinct entry ids, and the same script with fixed ids as the control) on the published 1.9.0 image and on a build made from the 1.9.1 source. Data: BTCUSDT 5-minute bars (2020-01-01 to 2026-09-07), the whole 702,834-bar span and its first 10,000 and 100,000 bars; Linux, each run pinned to one core; three runs per cell, both builds on the same machine. The times are the wall-clock time of a run through the hosted runner up to its outcome (median of the three runs, with the fastest and slowest in brackets). At 100,000 and 702,834 bars that runner refuses the report as too large (it exceeds 8 MiB), so those times are the time until the run ended with that refusal. These runs were not stopped at the hosted runner's 30-second limit: at 702,834 bars all three runs of the distinct-id script on 1.9.0 took longer than 30 s (33.11 to 63.80 s); none of the 1.9.1 runs did (26.93 to 27.39 s).

    bars build distinct ids fixed ids distinct / fixed extra time for distinct ids
    10,000 1.9.0 0.68 s [0.68 to 1.80] 0.64 s [0.64 to 1.50] 1.06 0.04 s
    10,000 1.9.1 0.59 s [0.59 to 0.61] 0.57 s [0.55 to 0.64] 1.04 0.02 s
    100,000 1.9.0 4.40 s [4.39 to 10.99] 3.92 s [3.89 to 9.32] 1.12 0.48 s
    100,000 1.9.1 3.67 s [3.62 to 3.73] 3.15 s [3.12 to 3.18] 1.17 0.52 s
    702,834 1.9.0 33.92 s [33.11 to 63.80] 26.48 s [26.06 to 26.83] 1.28 7.44 s
    702,834 1.9.1 27.32 s [26.93 to 27.39] 21.04 s [20.96 to 21.16] 1.30 6.28 s

    What this shows:

    • On 1.9.1 the distinct-id script costs 1.04x, 1.17x and 1.30x the fixed-id control at 10,000, 100,000 and 702,834 bars (your report had about 200x at the full span). The trade counts are the same for the two scripts and for the two builds at all three sizes (181, 1,882 and 13,510 trades), and so are the average, best and worst trade.
    • 1.9.1 is faster than 1.9.0 on both scripts at the full span (27.32 s against 33.92 s for the distinct-id script, 19.5 % less; 21.04 s against 26.48 s for the fixed-id control, 20.5 % less), and the run needs about 100 MB less memory (peak 230 MB against 331 MB).
    • The ratio to the control did not improve: it is about the same as on 1.9.0 (1.06, 1.12, 1.28 there). The extra time of the distinct-id script is not flat: from 100,000 bars to the full span the bars grow 7.0 times and the extra time about 12 times (between about 9 and 15 times when the fastest and slowest runs are taken into account). We are not claiming this is fixed, so the issue stays open.

    Limit of the current fix: A cohort's target read uses a fixed 64 KiB index (4,096 open lots). With more simultaneously open lots, with intrabar or observed-tick input, or inside the source host's applied callback, it uses the original, slower reader.

    We will report here again when the remaining growth is addressed.

  6. luisleo526 commented on Nov 4, 2026

    @luisleo526
    Collaborator

    Thanks for the report and the two scripts. This is fixed on the current engine main (the 1.10.0 line).

    We re-ran your two scripts (unique strategy.entry ids vs the same script with the ids fixed) on Binance BTCUSDT 5m, on one machine, building v1.6.3 and current main side by side. The table shows the engine's own elapsed time, the minimum of 3 runs; the trade lists were identical across every run of the same span.

    Span (bars) Version Unique ids (s) Fixed ids (s) Unique / fixed
    2020 (105,159) v1.6.3 5.07 0.42 12.0x
    2020 (105,159) main 1.54 1.26 1.22x
    2020-21 (210,082) v1.6.3 24.17 0.86 28.3x
    2020-21 (210,082) main 3.15 2.55 1.23x
    Full (702,834) v1.6.3 523.5 (1 run) 2.89 181x
    Full (702,834) main 11.15 8.71 1.28x

    On v1.6.3 the cost of unique ids grows quadratically with the number of bars (12x, 28x, 181x). On current main the unique-id run stays about 1.2-1.3x the fixed-id run, does not grow with size, and the full span with unique ids runs 47x faster than on v1.6.3 (523.5 s -> 11.15 s).

    The fixed-id run itself is about 3x slower on current main than on v1.6.3 (for example 2.89 s -> 8.71 s on the full span). That is a separate regression in the base cost per bar, and we are tracking it.

    Closing as fixed. If you still see the slowdown on 1.10.0 or later, please reopen with the script and the bar count.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions