Skip to content

perf: optimize hitresult generation for all modes#67

Open
yaowan233 wants to merge 2 commits intoMaxOhn:nextfrom
yaowan233:main
Open

perf: optimize hitresult generation for all modes#67
yaowan233 wants to merge 2 commits intoMaxOhn:nextfrom
yaowan233:main

Conversation

@yaowan233
Copy link
Copy Markdown

1. Taiko & Catch (O(N) -> O(1))

Optimization: Replaced the iterative loop with a direct mathematical solution.

Derivation (Taiko):

Taiko Accuracy is calculated as:

$$ \text{Acc} = \frac{n_{300} + 0.5 \cdot n_{100}}{n_{total}} $$

Given that $n_{remaining} = n_{300} + n_{100}$ (excluding misses), we substitute $n_{100}$:

$$ \text{Acc} = \frac{n_{300} + 0.5 \cdot (n_{remaining} - n_{300})}{n_{total}} $$

Multiply by $2 \cdot n_{total}$:

$$ 2 \cdot \text{Acc} \cdot n_{total} = 2 \cdot n_{300} + n_{remaining} - n_{300} $$

$$ 2 \cdot \text{Acc} \cdot n_{total} = n_{300} + n_{remaining} $$

Result:

We can solve for $n_{300}$ directly:

$$ n_{300} = 2 \cdot \text{Acc} \cdot n_{total} - n_{remaining} $$

Catch (CTB) applies similar logic where applicable, solving for hit counts directly based on linear scoring relationships.

2. Osu! (O(N^2) -> O(N))

Optimization: Removed the inner loop for n100 by utilizing Linear Interpolation.

Derivation:

Total score (proportional to Accuracy) is a linear combination of hit counts:

$$ \text{Score} \propto 300 \cdot n_{300} + 100 \cdot n_{100} + 50 \cdot n_{50} $$

When iterating the outer loop, $n_{300}$ is fixed. We also know:

$$ n_{50} = n_{remaining} - n_{100} $$

Substitute $n_{50}$ into the score equation:

$$ \text{Score} \propto 300 \cdot n_{300} + 100 \cdot n_{100} + 50 \cdot (n_{remaining} - n_{100}) $$

$$ \text{Score} \propto 300 \cdot n_{300} + 50 \cdot n_{remaining} + 50 \cdot n_{100} $$

Result:

Since Score is strictly linear with respect to $n_{100}$ (form $y = mx + c$), we don't need to iterate all possible values of $n_{100}$. We can calculate the Accuracy at the minimum possible $n_{100}$ ($n_{min}$) and maximum possible $n_{100}$ ($n_{max}$), then find the optimal $n_{100}$ via Linear Interpolation:

$$ n_{100} \approx n_{min} + \frac{\text{Acc}_{target} - \text{Acc}_{min}}{\text{Acc}_{max} - \text{Acc}_{min}} \cdot (n_{max} - n_{min}) $$

3. Mania (O(N^4) -> O(1))

Optimization: Replaced the brute-force nested loops with a Statistical Estimation method based on the Normal Distribution.

Theory & Derivation:

Unlike other modes, Mania has 5 degrees of freedom ($n_{320}, n_{300}, \dots, n_{50}$) but only 2 constraints (Total Hits, Target Accuracy), making exact mathematical derivation impossible without iterating.

However, valid hit distributions are not random; they follow the player's consistency, which can be modeled as a Normal Distribution (Gaussian) centered at 0ms.

  1. Hit Windows: Calculate the precise time windows ($w_{320}, w_{300}, \dots$) based on the map's OD.
  2. The Probability Model: The probability of landing a hit within a window $w$ given a standard deviation $\sigma$ is:

$$ P(\text{hit} \in w) = \text{erf}\left( \frac{w}{\sigma \sqrt{2}} \right) $$

  1. Inverse Solver:

    • Since Accuracy is monotonic with respect to deviation $\sigma$ (higher consistency $\to$ lower $\sigma$ $\to$ higher Acc), we use a Binary Search to find the unique $\sigma$ that yields the target Accuracy.
    • This effectively reduces the search space from a multi-dimensional integer lattice (O(N^4)) to a single scalar value (O(1), fixed 20 iterations).
  2. Discretization & Constraint Handling:

    • Probabilities are converted to integer hit counts ($N \times P$).
    • A Robust Fine-tuning step (upgrade/degrade loops) corrects rounding errors and strictly respects user-defined constraints (e.g., fixed $n_{300}$), ensuring the final score matches the target exactly.

Performance Benchmarks

Benchmarks based on fuzz testing (randomized inputs):

Mode Previous Algo New Algo Improvement
Mania (Worst Case) ~74.3s ~22µs ~3,000,000x
Mania (Typical) ~500µs ~5µs ~100x

Verification & Testing

  • Mania:
    • Updated rng_mania_hitresults to verify Accuracy tolerance (< 0.1%) instead of exact struct equality, as the statistical distribution differs from the brute-force one (more realistic vs. mathematical min-maxing).
    • Added deterministic unit tests for edge cases (input overflows, impossible targets, mixed constraints).
  • Osu / Taiko / Catch:
    • Since these optimizations are mathematical simplifications, the resulting hit distributions remain mathematically identical (or within rounding error) to the brute-force approach. Existing tests pass.

@MaxOhn
Copy link
Copy Markdown
Owner

MaxOhn commented Jan 13, 2026

Excited to look into this, will hopefully have time soon 👀

Copy link
Copy Markdown
Owner

@MaxOhn MaxOhn left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For taiko, catch, and osu, you updated the bruteforce implementation which is only used in tests to validate the actual implementation. For these modes, bruteforce's speed is still acceptable so I value its simplicity over any performance improvements in tests, meaning I'd rather not apply any mathematical logic to speed up my tests. If you want to improve runtime of the actual hitresult generation in the generate_state methods, be my guest.

For mania you did implement it for the generate_state method. However, you introduced an algorithm that no longer guarantees the closest result for the specified accuracy, just that your error term is smaller than some limit. This is no suitable replacement for the current approach.

One option would be to add a HitResultPriority::Statistical variant which users would then be able to choose over the other versions. Since this is no total fix and absolute solution though, I think I'd rather not introduce this mathematical complexity. Especially considering that it relies on certain values that might change on lazer's end and will then inevitably lead to incorrect behavior here.

@yaowan233
Copy link
Copy Markdown
Author

Maybe adding HitResultPriority::Statistical is the way to go. I submitted this PR because HitResultPriority::BestCase is too slow for mania, while HitResultPriority::Fastest leads to an unacceptable gap between the calculated PP and the real value.

@MaxOhn
Copy link
Copy Markdown
Owner

MaxOhn commented Jan 19, 2026

I do agree that hitresult generation is a bit of a mess, especially for mania. Unfortunately, the current implementation does not provide a satisfying way to extend options for the generation.

I opened the hitresult-gen branch and am working on an overhaul there. With the new approach it should be much nicer to add new generation functions.

@yaowan233
Copy link
Copy Markdown
Author

That sounds great! I'll keep an eye on the hitresult-gen branch and look forward to seeing the new infrastructure. Thanks for taking the time to work on this!

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.

2 participants