Skip to content

パフォーマンス(高発音数): note-on O(N²)探索・param re-apply全2048走査 3件 #18

Description

@hakaru

Forwarded from hakaru/M2DX issue #110 — a Claude performance audit (high-voice-count focus, read-only) of the M2DX app found three per-voice cost findings that live entirely in this repo (SynthEngine.swift), not in the app.

⚠️ RT-critical / Dexed-verified code. Past LLM-suggested RT refactors (tuple→Array, removing @inline(__always)) have introduced regressions here. Any fix needs a careful, minimal change plus keeping the existing steal-order-pinning tests green.

Voice count model

Per-key max = LAYER(≤8) × Voice Stack(≤16) × unison(≤8). Cap is effectiveMaxVoices = min(2048, min(128, base×unison) × stack), overall kMaxVoices = 2048, round-robin voice stealing. The app sends exactly one MIDI event per key press (no main-thread fan-out); all expansion happens inside this repo's audio-thread render path.

Findings

1. [high] note-on free-voice search is O(N²)

  • SynthEngine.swift:1439-1453 (inside the for s in voiceStack { for u in unison { … } } expansion, lines 1418-1539)
  • Each voice generated by one key-press does a linear scan for a free slot (for i in 0..<maxV { checkActive(); if !active break }). As the pool fills, each scan gets longer — one key-press generating N voices costs ≈ N²/2 checkActive() probes. Runs on the audio thread (doNoteOn is called from drainMIDI() inside render()).
  • Trigger: dense chords with high unison×stack×LAYER — hundreds to 1024 allocations per key-press, each scanning up to maxV (≤2048). Can blow the render deadline → dropouts.
  • Direction: a free-list / next-free cursor to make the search O(1) — must not break the tests that pin voice-steal order. Voice buffers are already allocated once in init; no new allocation needed.

2. [medium] parameter re-apply scans the full kMaxVoices=2048 on every snapshot change

  • SynthEngine.swift:1061-1072 — on every snapshot-version bump: for i in 0..<kMaxVoices { … 6× applyParams }
  • A knob/CC/automation change bumps the snapshot version, and the next render buffer applies params to all 2048 slots (not effectiveMaxVoices) across 6 operators each. Independent of actual voice count — scales with automation/knob density. Issue #89's kMaxVoices bump from 128→2048 made this loop ~16× worse.
  • Direction: bound the apply loop to effectiveMaxVoices — the render loop already does this correctly elsewhere (lines 1198/1261/1331/1344).

3. [low] render's pan/checkActive loop scans the full cap including inactive voices


🤖 Forwarded from a Claude performance audit (2026-07-13) of the M2DX app repo. Original: hakaru/M2DX#110.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions