OpenScriptv0.5.0Documentation
GitHub

Profiling and speed

Keep OpenScript studies and strategies fast. What each kind of line costs per bar, how to measure the work from inside a script, and the three changes that fix nearly every slow one.

On this page
  1. The cost model
  2. What costs what
  3. Measure the work
  4. The quadratic trap
  5. Rolling windows: add one, drop one
  6. Loops inside loops
  7. Do not compute the same thing twice
  8. Do newest-bar work on the newest bar
  9. Short-circuiting, and its trap
  10. Memory
  11. The order to work in

This page shows how to find which part of a slow OpenScript script (OpenScript is also called OpenAlgo Script) is costing the time, how to measure it rather than guess, and how to fix it. You need it when a study takes noticeably long to draw on a chart with a long history, when a backtest over a year of 5 minute bars crawls, or when a bar stops with a budget error from the Limits page.

The cost model

A script is the body of a loop that runs once per bar. Every top-level line runs on every bar, so the total cost of a script is its cost per bar multiplied by the number of bars. A study that takes a tenth of a millisecond per bar takes five seconds over 50,000 bars, which is more than two and a half years of 5 minute NIFTY bars. There is no part of a script that runs once and is free, and there is nothing you can move "outside the loop", because there is no outside. See the execution model.

Two consequences follow, and most speed work is one of them.

  • A line whose cost grows with bar.index turns a linear script into a quadratic one. 50,000 bars each scanning up to 50,000 bars is over a billion operations.
  • A line that recomputes from scratch what it could carry forward pays the whole cost again on every bar, when the honest cost of the update was one addition.

What costs what

This is a model, not a benchmark. Engines differ in raw speed and must not differ in results, so trust the order and treat the ratios as rough.

WorkCost per barNotes
Reading a bar field (close, high, time)LowestThe engine fills these before the bar's code runs
Arithmetic, a comparison, a ternaryVery lowPlain operations on numbers
Reading history, close[5]Very lowA direct read, not a search
A smoothing library call (ema(), rma(), atr())Low and constantEach carries its previous value forward, so the length does not change the cost per bar
A windowed library call (sma(), highest(), stdev())Grows with the lengthThe window is read afresh on every bar, so sma(close, 200) adds 200 values per bar. That keeps the value exact, and the work is done inside the engine rather than as turns of your own loop
A user function callLowOne frame, and one set of stored state per call site
A for loop of n turnsn times the bodyThe body's cost is what matters; the loop itself is cheap
Building a stringModerateJoining allocates, and doing it on every bar for text shown once is waste
Creating or changing a drawingModerate, and it lastsThe object lives until you delete it
A higher timeframe or other instrument readPaid per readA whole second series is built or fetched and kept in step with the chart
A loop whose length grows with bar.indexRuinousThe quadratic case, which this page mostly exists for

Measure the work

A script cannot time itself, and that is deliberate. chart.now() is the chart's clock as the host supplies it, and it is the only clock a script can read during a bar. It is fixed for reproducibility, so the same script over the same bars gives the same result every time, and a value fixed for reproducibility is not a stopwatch. There is no random source either, for the same reason.

So you measure work from inside the script and time from outside it. They answer different questions.

MeasurementHowTells you
Loop turns per barCount them into a var, show the count in a table on the last barWhether a loop is the problem, and by how much
Worst barKeep a running maximum of the per-bar countWhether the cost is spread out or concentrated
Calls to a helperCount entries the same wayWhether a function runs more often than you thought
Drawings helddraw.count() in a debug panelWhether drawings pile up instead of being deleted
Data requestsCount the req. lines by eyeWhether you are near a host's request ceiling (OS5006)
Time for the whole runHow long the chart takes to draw, or the backtest to finishWhether the total is acceptable at all

Count the loop turns

This measurement settles most arguments. It costs a few lines and turns "the loop is probably fine" into a number.

version 1

study("Loop turn counter", precision = 0)

length = input(20, "Window", min = 2, max = 500)

var totalTurns = 0
var worstBar = 0

perBar = 0
total = 0.0

for i = 0 to length - 1
    perBar += 1
    total += close[i]

totalTurns += perBar
if perBar > worstBar
    worstBar = perBar

panel = table("Loop turns", 3, 2, position = "topRight", textColor = silver)
if bar.isLast
    cell(panel, 0, 0, "turns in total")
    cell(panel, 0, 1, text(totalTurns, 0))
    cell(panel, 1, 0, "worst single bar")
    cell(panel, 1, 1, text(worstBar, 0))
    cell(panel, 2, 0, "bars")
    cell(panel, 2, 1, text(bar.count, 0))

plot(total / length, "Mean", aqua)

Compare the worst bar with the loop budget of 2,000,000 turns per bar. A worst bar in the thousands is fine. A worst bar in the hundreds of thousands is doing something structurally wrong even though it has hit no limit, and the next section is probably why.

Know what the budget does and does not protect you from. It stops a runaway loop from freezing the page. It does not stop a script from being slow: a loop that runs 50,000 turns on every bar is well inside the budget and is still 50,000 times more work than the one addition it should have been.

The quadratic trap

This is the most common cause of a slow script, and it always looks reasonable on the day it is written.

// Before: the mean of every close since the first bar. On bar 40,000 this
// loop runs 40,001 times, and it ran 40,000 times on the bar before.
total = 0.0
for i = 0 to bar.index
    total += close[i]

plot(total / bar.count, "Mean since the first bar", aqua)

Over 50,000 bars that is more than a billion turns, and a host that sets a time budget per bar stops it with OS5007. The fix is to carry the answer forward instead of rebuilding it:

// After: one addition per bar. The mean is the same apart from rounding in
// the last digits, because the closes are added in a different order.
var total = 0.0
total += close

plot(total / bar.count, "Mean since the first bar", aqua)

The library has this shape ready made as cum(), a running total from the first bar, and using it is better still: a library function has a stated warmup and an exactly specified result, and your own accumulator has neither until you test it.

The general rule: if a loop's length depends on bar.index, the value it computes can almost certainly be written as an update. Ask what changed since the previous bar. Usually exactly one value arrived and at most one left.

Rolling windows: add one, drop one

The same idea applies to a fixed window. The saving is smaller, but the shape is worth knowing because it extends to statistics the library does not have.

len = input(20, "Length", min = 1, max = 500)

// Before: len additions on every bar.
total = 0.0
for i = 0 to len - 1
    total += close[i]

plot(total / len, "Mean, by loop")
len = input(20, "Length", min = 1, max = 500)

// After: two operations per bar whatever len is. The value leaving the window
// is close[len], the bar just before the window's oldest bar.
var running = 0.0
running += close
if bar.index >= len
    running -= close[len]

// Absent until the window is full, so the warmup matches sma rather than
// reporting a mean of however many bars have arrived.
mean = bar.index >= len - 1 ? running / len : none
plot(mean, "Mean, by update")

Before you write that, check whether the library already has it. sma(), sum(), highest(), lowest(), stdev(), median(), percentile(), correlation() and the rest are specified with exact warmups and exact arithmetic, and a hand-written copy is one more thing to test. Write a window by hand when the statistic is genuinely not in the reference, not to save a call.

A running total has one accuracy caution that a fresh sum does not: subtracting a value added thousands of bars ago lets small floating point errors build up, so the result drifts from a fresh sum in the last few decimals as the chart grows. For most uses this is not a practical problem, but it is why the library's own windowed functions take the sum fresh over the window on every bar instead: sma() pays len additions per bar so that its value never drifts.

Loops inside loops

Nesting multiplies, and the multiplication is easy to underestimate.

ShapeTurns per barOver 50,000 bars
for i = 0 to 19201,000,000
for i = 0 to 19 inside for j = 0 to 1940020,000,000
for i = 0 to 99 inside for j = 0 to 9910,000500,000,000
for i = 0 to bar.indexup to 50,000more than 1,000,000,000

Three changes fix nearly every nested loop.

Hoist what does not change, and halve the work when the relationship is symmetric. Anything computed from an input, a chart. fact or this bar's values is the same on every turn, so read it once before the loop. A for loop reads its bounds once, when it starts, so a bound on the outer loop costs nothing extra, but the inner loop starts again on every outer turn. And comparing every pair once rather than twice turns 400 turns into 190.

var levels: array<number> = []
push(levels, close)
if size(levels) > 100
    shift(levels)

// Before: the inner loop reads size(levels) again for every outer turn, the
// tick size is read and defaulted on every inner turn, and every pair is
// compared twice.
nearest = none
for i = 0 to size(levels) - 1
    for j = 0 to size(levels) - 1
        gap = abs(element(levels, i) - element(levels, j)) / orElse(chart.tickSize, 0.05)
        if i != j and (isNone(nearest) or gap < nearest)
            nearest = gap

plot(nearest, "Nearest pair, in ticks")
var levels: array<number> = []
push(levels, close)
if size(levels) > 100
    shift(levels)

// After: both read once per bar, and each pair visited once.
levelCount = size(levels)
tick = orElse(chart.tickSize, 0.05)
nearest = none
for i = 0 to levelCount - 1
    for j = i + 1 to levelCount - 1
        gap = abs(element(levels, i) - element(levels, j)) / tick
        if isNone(nearest) or gap < nearest
            nearest = gap

plot(nearest, "Nearest pair, in ticks")

Leave early. break leaves the innermost loop and continue skips to its next turn. A scan looking for the first match should stop at it.

var highs: array<number> = []
push(highs, high)
if size(highs) > 100
    shift(highs)

// Stop at the first match instead of scanning the rest.
found = none
for i = 0 to size(highs) - 1
    if element(highs, i) > close
        found = i
        break

plot(found, "Oldest stored high above the close")

Do not compute the same thing twice

Every call site keeps its own state. Two identical calls in two places are two independent sets of state, each updated on every bar. That rule is what makes a stateful helper reusable, and it is also what makes a copied line cost double. See User functions.

// Before: three call sites, three MACD calculations on every bar.
plot(macd(close, 12, 26, 9)[0], "MACD", aqua)
plot(macd(close, 12, 26, 9)[1], "Signal", orange)
plot(macd(close, 12, 26, 9)[2], "Histogram", gray, style = "histogram")
// After: one call site, one calculation, three reads of the array it returns.
m = macd(close, 12, 26, 9)
plot(m[0], "MACD", aqua)
plot(m[1], "Signal", orange)
plot(m[2], "Histogram", gray, style = "histogram")

That is why macd() returns an array rather than being three separate functions: three names would be three call sites, and the shared smoothing would be computed three times per bar.

The same applies with more force to data reads, where the cost is a whole second series kept in step with the chart. Two identical req.timeframe("1D", high) calls are two reads: make one, name it, and use the name everywhere. Limits has the full example.

Do newest-bar work on the newest bar

A panel shows one state, the current one. Writing it on all 50,000 bars to display the last one is 50,000 wasted writes.

atrValue = atr(14)

panel = table("Now", 2, 2, position = "topRight", textColor = silver)

if bar.isLast
    cell(panel, 0, 0, "Close")
    cell(panel, 0, 1, text(close, 2))
    cell(panel, 1, 0, "ATR 14")
    cell(panel, 1, 1, isNone(atrValue) ? "warming up" : text(atrValue, 2))

During market hours the newest bar runs again on every update and rewrites the same cells, and persistent values are restored before each run, so nothing piles up. See Realtime and confirmation.

Be careful about what you put behind that guard. Cells, labels and boxes that describe the present are fine. A calculation is not: a stateful call inside a branch advances only on the bars the branch runs, which behind bar.isLast means one bar, so its result is absent everywhere else (OS8001). That is why atrValue above is computed at the top level. Calculate unconditionally, display conditionally.

Short-circuiting, and its trap

and and or evaluate their right side only when it can change the answer, so putting the cheapest test first is free speed:

threshold = input(25000.0, "Level")
inSession = session.isIn("0915-1530")

// The cheap tests come first, so the comparison runs only where it can matter.
if inSession and close > threshold
    signal("ABOVE")

The trap: when the right side holds a stateful call and is skipped on some bar, that call's state does not advance and its series is absent on that bar. The compiler warns about it with OS8001:

threshold = input(25000.0, "Level")
inSession = session.isIn("0915-1530")

if inSession and highest(high, 200) > threshold
    signal("HIGH")

So short-circuiting is a speed technique for pure tests only. Anything whose value you also plot, or whose state has to track every bar, is computed at the top level first:

threshold = input(25000.0, "Level")
inSession = session.isIn("0915-1530")

extreme = highest(high, 200)  // advances on every bar
if inSession and extreme > threshold
    signal("HIGH")

plot(extreme, "200 bar high", aqua)

Memory

Speed is usually the complaint, but memory is what ends a session badly. Four things drive it.

Retained history. The engine keeps a history for a top-level name only when the program actually reads that name's history with [], so most names cost one value rather than one per bar. By default that history reaches back to the first bar. limits(history = n) bounds it. See Limits.

Arrays. An array appended to on every bar and never trimmed grows with the chart until it reaches the 1,000,000 element ceiling (OS5002). Trim as you push:

var window: array<number> = []
push(window, close)
if size(window) > 500
    shift(window)

plot(avg(window), "Mean of the last 500 closes")

Drawings. Each lasts until the script deletes it, up to 10,000 at once (OS5010). Delete a zone when price closes through it or it goes stale, and keep draw.count() in a debug panel while you develop.

Strings. Text appended to a persistent string on every bar is the one shape that grows without bound by accident, and it ends at OS5008. Keep the pieces in an array, trim it to the rows you show, and join only those.

The order to work in

  1. Get it right first. A faster script that computes a different number is not an optimisation. Have a value you trust before you change anything.
  2. Measure. Count loop turns, calls and drawings. Two minutes of counting beats an hour of rewriting the wrong line.
  3. Fix the structure, not the details. Nearly every real improvement is one of three changes: remove a loop whose length grows with the bar index, carry a value forward instead of rebuilding it, or stop computing the same thing twice. Tweaking one expression is rarely worth the change.
  4. Measure again, and check the numbers did not move. Run whatever comparison told you the script was right, on the same bars. The difference plot is the standard way.
  5. Stop when it is fast enough. A study that draws in under a second on the chart you actually use is finished, whatever the counters say.

Related. Limits, Debugging, Testing scripts, Style guide, Series functions, Persistence