Skip to content

perf: make beat timelines usable at 14k beats - #600

Draft
FelipeDefensor wants to merge 20 commits into
TimeLineAnnotator:devfrom
FelipeDefensor:perf/beat-timeline
Draft

perf: make beat timelines usable at 14k beats#600
FelipeDefensor wants to merge 20 commits into
TimeLineAnnotator:devfrom
FelipeDefensor:perf/beat-timeline

Conversation

@FelipeDefensor

Copy link
Copy Markdown
Collaborator

Draft. Opening this early to get a read on scope and on the one design question at the bottom before I polish it.

Beat timelines get slow well before they get large. A ~14k-beat timeline (a full movement tapped by hand) makes tapping, zooming and CSV import unusable. This branch works through the hot paths.

What's slow, and why

Nearly all of it traces back to two things: Beat.metric_position resolved itself with components.index(beat), which copies the component list and scans it, and recalculate_measures() rebuilt three metric-fraction dicts from scratch on every single beat insert. Both are O(n) per beat, so every bulk path was O(n²).

Measurements

All offscreen, median of 5 runs, via the two bench scripts added here.

Workload Before After
CSV import, 13932 beats did not finish ~25 s
End-insert (live tap), N=13000 ~250 ms/beat 55 ms/beat
End-insert, N=10000 31 ms/beat
Zoom step, N=14000 112.7 ms 66.5 ms (-41%)
Middle-insert per tap, N=13000, batch mode on ~250-300 ms 39 ms

Middle-insert without batch mode is unchanged at N=5000 (~60 ms) — the UI bulk pass dominates there, not the recalculation.

How it's organised

Each commit is standalone with its own rationale and numbers. Roughly:

  • Indexing — bisect-based get_beat_index / get_measure_index, and routing .index lookups through them.
  • Recalculation — two-pointer rebuild of update_metric_fraction_dicts that populates the position cache inline, then an incremental path scoped to the inserted index so an end-insert only walks the tail.
  • Post cascades — dropping a per-beat Post storm and two redundant rebuilds.
  • UI — caching x/height in update_position and skipping set_position for labels that are hidden anyway (with beat_pattern >= 2 most labels are hidden, and set_position costs a Qt setPos + boundingRect() each).
  • Batch mode — see below.

Two things worth reviewing separately

1. The batch-mode toggle is a feature, not a perf fix. timeline.beat.toggle_recalculate_measures (Ctrl+Shift+B, context menu + toolbar) lets the user suspend measure recalculation while tapping a passage and pay one catch-up on resume. It's here because it's the only thing that makes middle-insert bearable on long timelines, but it adds UI surface and a new commands.register(checkable=True) capability. Happy to split it into its own PR if you'd rather review it on its own.

At N=13000 the per-tap cost in batch mode is 39 ms, and that floor is the undo-snapshot serialization, not recalculation. Going below ~10 ms means deferring per-tap APP_STATE_RECORD and bundling the batch into a single undoable — deliberately out of scope here.

2. The context manager overlaps with v0.6.5. This branch grew a context manager for suspending compute_is_first_in_measure; v0.6.5 shipped the same idea as suppressing_is_first_in_measure(). The last commit drops this branch's name and takes the released one verbatim, so merging 0.6.5 forward into dev should be a no-op for that hunk rather than a conflict. Worth a sanity check that I took the right direction.

Not included

refactor/beat-suppression-contextmanager has two related fixes that aren't here — crop()/clear() leaving a stale measure structure behind, and a duplicate recalculation on every deletion. They're bug fixes rather than perf work and sit on a v0.6.4 base, so they belong in their own PR.

Testing

Full suite green on this base (1849 passed, 10 skipped). Adds a perf regression guard for middle-insert and a 5-second-timeout guard on a 1000-beat CSV import — the latter fails outright on dev.

scripts/bench_beat_insert.py and scripts/bench_beat_zoom.py are dev harnesses, not tests; they take --position end|middle, --batch and --runs.

Replace the open-coded `compute_is_first_in_measure = False / True`
toggles in `crop`, `clear`, `deserialize_components`, `restore_state`,
`delete_components`, and `fill_with_beats` with a single
`is_first_in_measure_computation_paused` context manager on
`BeatTLComponentManager`. The ctxmgr saves and restores the prior value
so nested uses don't accidentally re-enable computation halfway through
an outer bulk op.

Also fixes a typo in `delete_components` that wrote to a nonexistent
attribute `update_is_first_in_measure` instead of restoring
`compute_is_first_in_measure` to True — meaning every bulk delete left
the flag stuck at False until the next site happened to flip it.
The CSV beat import called `timeline.recalculate_measures()` per row,
and each `create_component` ran its own recalculate when
`compute_is_first_in_measure` was True. Both are O(n) over the running
beat count, making the import O(n^2). A ~14k-beat CSV could take
several minutes.

Pause `is_first_in_measure` computation on the component manager for
the whole second pass, then call `recalculate_measures()` and
`update_is_first_in_measure_of_subsequent_beats(0)` once at the end —
the same pattern already used by `fill_with_beats` and `restore_state`.
Measured: 13932-beat import drops from "did not finish" to ~25 s.
Replace O(N) list.index with O(log N) bisect_left on the sorted
components list. This is the load-bearing fix for slow single-beat
insertion: get_beat_index is hit per beat from Beat.metric_position
during update_metric_fraction_dicts, making the per-insert path
O(N^2). With bisect it becomes O(N log N).

Times are unique (validate_unique_position in pointlike), so
bisecting by time uniquely identifies the beat.
Four call sites still did list.index(beat) directly on the
components list, each O(N), turning per-insert work into O(N^2):

- BeatTLComponentManager.create_component (line 73): beat_index
- BeatTLComponentManager.delete_component (line 94): component_idx
- BeatTimeline.is_first_in_measure (line 384)
- BeatTimelineUI.should_display_measure_number (line 212)

All now use get_beat_index, inheriting the bisect-based O(log N)
lookup from the previous commit.
BeatTimelineUI.on_add called recalculate_measures after
create_component, but the backend already triggers it inside
BeatTLComponentManager.create_component when
compute_is_first_in_measure is True. Removing the redundant call
roughly halves per-insert work (recalculate_measures clears
metric-position caches and rebuilds metric_fraction_dicts).
Asserts that adding a single beat in the middle of a 1000-beat
timeline finishes in under 2 s. Pre-bisect, the per-insert path
was O(N^2) and easily took multiple seconds on N=1000; with the
bisect-based get_beat_index the same insert completes in well
under a second.
`beats_that_start_measures` is strictly increasing (built from
`itertools.accumulate(beats_in_measure[:-1])`), so the linear scan in
`get_measure_index` can be replaced with `bisect`. With beat_pattern=[4]
the list grows to ~N/4 entries, so the original O(M) lookup × the
~1.3·N call sites per single-beat insert was the remaining O(N²)
factor after the earlier `get_beat_index` bisect work.

Profile delta at N=5000, single middle-insert (offscreen platform):
- get_measure_index cumtime: 319 ms (6722 calls) -> ~5 ms
- update_metric_fraction_dicts: 290 ms -> 107 ms
- total insert: 684 ms -> 396 ms

Bench wall times (median, --runs 3, offscreen):
- N=5000:  451 ms -> 250 ms
- N=10000: 1454 ms -> 474 ms

Scaling 5k -> 10k drops from 3.3× to 1.9×, confirming the residual
quadratic is gone.

The original code had a quirky fallback branch returning offset `1`
for `beat_index > btsm[-1]` regardless of how far past, so the new
branch preserves that behavior verbatim instead of returning the
"natural" `beat_index - btsm[-1]`.
Standalone script for profiling/benchmarking single-beat-insert into
N-beat timelines. Sets QT_QPA_PLATFORM before any Qt import so the
underlying platform plugin (minimal / offscreen / windows) can be
swapped on each invocation, and supports an optional cProfile dump.

Used to size the impact of the recent get_beat_index and
get_measure_index bisect work; kept in tree as the next perf step
will need it again.
…sure_of_subsequent_beats

The function ran a final `update_metric_fraction_dicts()` after the
bulk `is_first_in_measure` flip loop. `metric_position` depends on
`get_beat_index` / `get_measure_index` / `measure_numbers` /
`beats_in_measure` — none of which involve the `is_first_in_measure`
flag — so the bulk flips can never invalidate the dicts. Every caller
of this method either rebuilt the dicts beforehand (create_component,
restore_state, CSV parser) or rebuilds them afterward separately
(delete_component's UI follow-up), so the in-function rebuild was
always dead work.

Profile call site at N=5000 was the second of two
`update_metric_fraction_dicts` invocations per insert (~54 ms each).

Bench wall times (median, --runs 3, offscreen):
- N=5000:  250 ms -> 152 ms
- N=10000: 474 ms -> 384 ms
…e flip loop

`update_is_first_in_measure_of_subsequent_beats` previously routed each
flip through `set_component_data`, which fires
`Post.TIMELINE_COMPONENT_SET_DATA_DONE` and triggers a UI cascade
(`element.update` -> `update_is_first_in_measure` -> body.set_position +
`update_label`). At N=5000 a middle-insert flips ~N/2 beats, so that's
~1249 redundant UI updates per insert. Callers of this method already
follow up with `Post.BEAT_TIMELINE_MEASURE_NUMBER_CHANGE_DONE`, which
drives a single bulk UI pass over the same beats, so the per-set posts
were pure duplicate work.

The loop now mutates `is_first_in_measure` directly on the Beat. This
is safe because:
- `is_first_in_measure` isn't in `Beat.SERIALIZABLE`, so the component
  hash doesn't depend on it.
- `metric_position` doesn't read it, so the metric_fraction dicts
  can't go stale.
- The undo/redo snapshot doesn't capture it either; it's recomputed via
  this same method on restore.

`BeatTimelineUI.on_measure_number_change_done` was previously enough
because the per-set cascade refreshed each flipped beat's body. Without
that cascade, the bulk handler must do both, so it now calls
`update_is_first_in_measure` (which also calls `update_label`)
instead of `update_label` alone.

Bench wall times (median, --runs 3, offscreen):
- N=5000:  152 ms -> 93 ms
- N=10000: 384 ms -> 333 ms

Cumulative since the bisect cascade started:
- N=5000:  451 ms -> 93 ms  (4.8x)
- N=10000: 1454 ms -> 333 ms (4.4x)
`Post.BEAT_TIMELINE_MEASURE_NUMBER_CHANGE_DONE` is consumed as a
beat-index (the receiver slices `self[start_index:]` over the beat-UI
collection), but `create_component` was posting `measure_index - 1`,
which has measure-index semantics and lands far before the new beat in
a typical timeline. At N=5000 / 4-beats-per-measure that meant the
bulk UI pass refreshed ~4376 beats instead of the ~2500 actually past
the insertion point.

Pass the new beat's index directly. Median middle-insert at:
  N=5000:  93ms -> 68ms (-27%)
  N=10000: 333ms -> 204ms (-39%)

Other call sites of this post (`set_measure_number`,
`unforce_display_measure_number`, `set_beat_amount_in_measure`) also
pass a measure_index; they're not on the hot path and their slice is
over-broad-but-correct, so leave them for a follow-up.
…action_dicts

`update_metric_fraction_dicts` rebuilt three dicts from scratch each
call, with 5000+ `beat.metric_position` cache misses per insert at
N=5000 (5000 bisect_left into components + 5000 bisect into
beats_that_start_measures, plus the dict ops).

Rewrite as a two-pointer pass that walks `beats_that_start_measures`
in parallel with `enumerate(self.components)`, so the per-beat work is
O(1) instead of O(log N + log M). Populate `_cached_metric_position`
inline while we're at it — `recalculate_measures` cleared the caches
just before this call, so otherwise every subsequent `metric_position`
access starts cold.

The trailing __sort_* passes still need to run: `measure_numbers` can
be non-monotonic (e.g. pickup measures number their entries as
[1, 3, 0, 2, 4, 5]), so insertion order in beat-index order is not
guaranteed to match sorted-key order. Tried skipping the sort first
and broke `test_changing_attributes` in the musicxml parser, which
exercises exactly that case.

Wall-clock at N=5000 (offscreen, 5 runs):
  before: median 72ms, min 69ms
  after:  median 61ms, min 59ms  (-16% median)

Cumulative since perf work started: 451ms -> 60ms at N=5k (7.5x).
…inserted index

Thread `start_index` through `recalculate_measures` ->
`clear_cached_metric_positions` + `update_beats_that_start_measures`
-> `update_metric_fraction_dicts`. `create_component` computes the
new beat index up-front (cheap bisect on times — works before the
recalc fixes beats_in_measure) and passes it in.

Upstream beats (0..K-1) are structurally unaffected by an insert at
K: their measure / beat-in-measure / beat-count don't change. So
`update_metric_fraction_dicts(start_index=K)` keeps their cached
positions and dict entries untouched, and only:
 - pops stale `time_to_metric_fraction` entries for beats[K:],
 - filters their old beat-list / time-list entries out of
   `metric_fraction_to_beat_dict` / `metric_fraction_to_time`,
 - re-runs the two-pointer pass from K to populate fresh entries.

For middle-insert at K = N/2 this halves the work. For *end-insert*
(K = N-1, the live-tap workload) the loop runs once and there are
no stale entries to clean up — that's the load-bearing case.

Wall-clock at N=13000, end-insert (offscreen, 5 runs):
  before: ~250ms (extrapolated from N=10k middle perf)
  after:  median 55ms, min 54ms

Wall-clock at N=10000, end-insert:  median 31ms (under 50ms live-tap budget)
Wall-clock at N=5000,  end-insert:  median 16ms

Middle-insert at N=5000 is unchanged at 60ms median — the UI bulk
pass dominates there, and the recalc work was already halved by
two-pointer in the prior commit. Middle-insert in long timelines
will need the planned batch-mode toggle to skip per-tap recalc.

Default start_index=0 preserves the full-rebuild behavior for all
other callers (restore_state, deserialize, set_measure_number, etc.).
The default middle-insert measures worst-case structural work. End-
insert measures the live-tap workload (appending while playing),
which is what the per-beat latency budget actually targets.
Lets a command behave as a toggle action — the registered QAction
becomes checkable, and the callback can read the current state via
commands.get_qaction(name).isChecked(). Needed for the upcoming
beat-timeline batch-mode toggle.
…g bulk tap

Adds command `timeline.beat.toggle_recalculate_measures` (Ctrl+Shift+B,
plus an entry in the beat-timeline context menu). When the user pauses,
the `compute_is_first_in_measure` flag on the component manager is
cleared — `create_component` already gates its whole recalc block on
that flag, so each tap reduces from O(N) (bisect + dict rebuild + UI
bulk pass) to O(log N) (just the sorted insert). On resume the callback
runs a single catch-up `recalculate_measures()` plus a UI refresh post,
restoring the full state.

Per-tap cost at N=13000, middle position:
  no batch:    ~250-300ms
  batch on:    median 39ms (capped by undo-snapshot serialization,
                            not recalc work)
  catch-up:    ~415ms one-time on resume

The undo snapshot is now the floor — to push per-tap below 10ms we'd
need to defer per-tap APP_STATE_RECORD too, then bundle the whole
batch into one undoable. Out of scope for this commit.

The cm flag is the source of truth for the toggle. The QAction's check
state is synced to match, regardless of whether the user invoked via
menu click (Qt pre-toggles the action) or via shortcut / programmatic
`commands.execute()` (action state unchanged). Tidies a pre-existing
typo in `BeatTimelineUIContextMenu.items` (`MenuItemKind` -> `MenuItemKind.COMMAND`)
incidentally — both work because the loose match falls through to
`add_action`, but the explicit form matches the rest of the codebase.
Toggles measure-recalc-pause before tapping and resumes after; prints
the resume catch-up cost separately so we can see what the user
actually pays during a batch tap workflow.
…ntext menu

`TimelineUIContextMenu.add_action` rewires the QAction's triggered
signal to call `commands.execute(name, self.timeline_ui)` — passing
the clicked timeline as an extra positional arg. The arg flows through
`on_timeline_command` and lands as a second positional on the
callback. Before the fix, invoking the toggle from the timeline
context menu raised:

    TypeError: BeatTimelineUI.on_toggle_recalculate_measures()
               takes 1 positional argument but 2 were given

Accept `*_` to swallow it. Add a regression test that triggers the
context-menu action directly.

Also surfaces the toggle on the beat-timeline toolbar (icon
MediaPlaybackPause; the QToolButton renders pressed when checked,
which is the visual feedback for paused mode).
…ition

Zoom on a 14k-beat timeline was ~112 ms per step (offscreen, median of 5).
Profile showed all time in `update_time_on_elements` -> 14000 ×
`BeatUI.update_position`, with two hotspots:

1. `self.x` and `self.height` were each evaluated twice per call (once
   for body, once for label). Each `x` access runs
   `time_x_converter.get_x_by_time` + a `get_data` on the component,
   each `height` runs a `get_data`.
2. `self.label.set_position` ran for every beat, even though with
   `beat_pattern>=2` the label is hidden for most beats. `set_position`
   calls Qt's `setPos` plus `boundingRect()` per call, which dominates
   per-beat cost despite being wasted work for invisible items.

Cache x/height into locals, then guard the label update behind
`isVisible()`. Zoom doesn't change visibility, so a hidden label stays
hidden; when `update_label` later runs (e.g. after
`is_first_in_measure` flips) it sets text and position together.

Bench (N=14000, offscreen, --runs 5):
- before: median 112.71 ms
- after : median  66.51 ms  (-41%)

Also adds scripts/bench_beat_zoom.py to mirror the existing
bench_beat_insert.py harness.
… name

v0.6.5 shipped the same idea under a different name:
`BeatTLComponentManager.suppressing_is_first_in_measure()`. This branch
introduced `is_first_in_measure_computation_paused()` independently, so
merging 0.6.5 forward into dev would leave two context managers doing
the same job.

Take the released name and implementation verbatim, including the
`Iterator[bool]` yield of the flag's entry value — that lets a caller
tell whether it owns the recomputation or an outer block will do it
once at the end, which the version here lacked. Every call site on this
branch ignores the yielded value, so the behaviour is unchanged.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant