Skip to content

perf: Fix LIS algorithm bug causing 999 DOM moves instead of 2#59

Merged
MCGPPeters merged 1 commit intomainfrom
fix/lis-algorithm-swap-performance
Feb 9, 2026
Merged

perf: Fix LIS algorithm bug causing 999 DOM moves instead of 2#59
MCGPPeters merged 1 commit intomainfrom
fix/lis-algorithm-swap-performance

Conversation

@MCGPPeters
Copy link
Copy Markdown
Contributor

📝 Description

What

Fix a critical bug in the LIS (Longest Increasing Subsequence) algorithm that was causing massive performance degradation in the swap benchmark.

Why

The ComputeLISInto function had a bug where it tracked k as "length-1" but the first element never incremented k, causing the binary search to always use hi=0 and produce an LIS of length 1 instead of the correct length.

For the js-framework-benchmark swap test (swap 2 elements in 1000), this caused 999 MoveChild patches instead of 2.

How

Changed from tracking k (length-1) to lisLen (actual length), with proper increment when lo == lisLen.

🔗 Related Issues

Fixes the swap benchmark performance regression identified in performance profiling.

✅ Type of Change

  • ⚡ Performance improvement
  • 🐛 Bug fix (non-breaking change that fixes an issue)
  • ✅ Test update

🧪 Testing

Test Coverage

  • Unit tests added/updated
  • Benchmarks verified

Testing Details

  • Added 3 new unit tests for LIS algorithm scenarios
  • Ran js-framework-benchmark swap test to verify improvement
  • All 101 existing tests pass

✨ Changes Made

Algorithm Fix

// BEFORE (buggy):
var k = 0; // Length of longest LIS found - 1
if (k > 0 && arr[result[k]] < arrI) { ... }

// AFTER (fixed):
var lisLen = 0; // Actual length of LIS found
int lo = 0, hi = lisLen;
// Binary search in [0, lisLen)
...
result[lo] = i;
if (lo == lisLen) lisLen++;

Benchmark Results

Metric Before After Improvement
Swap Median 326.6ms 121.6ms 2.7x faster
Script Time ~300ms 96.7ms 3x faster
DOM Moves 999 2 99.8% reduction

Comparison with Blazor

  • Before: 3.47x slower than Blazor
  • After: 1.29x slower than Blazor

🔍 Code Review Checklist

  • Code follows the project's style guidelines
  • Self-review of code performed
  • Code changes generate no new warnings
  • Tests added/updated and passing
  • dotnet format --verify-no-changes passes

Copilot AI review requested due to automatic review settings February 9, 2026 17:07
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Fixes a performance regression in keyed DOM reordering by correcting the LIS computation (reducing unnecessary DOM moves), and adds supporting perf/memory improvements and tests.

Changes:

  • Fix LIS marking logic used by keyed child diffing and add unit tests for swap/reorder scenarios.
  • Reduce allocations on hot paths via ArrayPool/List pooling in DOM operations.
  • Adjust CI benchmark comparison threshold and improve browser E2E setup (Playwright cache); defer OTel CDN init in bundled JS to improve startup.

Reviewed changes

Copilot reviewed 9 out of 9 changed files in this pull request and generated 7 comments.

Show a summary per file
File Description
scripts/compare-benchmarks.py Updates default throughput regression threshold and documents CI variance rationale.
Abies/wwwroot/abies.js Defers OTel CDN initialization and adds a local shim with verbosity cache reset support.
Abies/DOM/Operations.cs Fixes LIS usage in keyed diffing and adds pooling for patch serialization and LIS membership tracking.
Abies.Tests/DomBehaviorTests.cs Adds tests validating minimized MoveChild patches for swap/reorder scenarios.
Abies.SubscriptionsDemo/wwwroot/abies.js Same deferred OTel initialization + shim changes as main Abies JS.
Abies.Presentation/wwwroot/abies.js Same deferred OTel initialization + shim changes as main Abies JS.
.github/workflows/e2e.yml Caches Playwright browsers to reduce CI time and flakiness.
.github/workflows/benchmark.yml Uses the updated throughput threshold when comparing benchmarks.
.github/instructions/memory.instructions.md Documents LIS fix + benchmark findings; adds note about dotnet format multi-targeting pitfalls.

// Rent a bool array instead of allocating HashSet<int>
var inLIS = ArrayPool<bool>.Shared.Rent(newLength);
try
{
Copy link

Copilot AI Feb 9, 2026

Choose a reason for hiding this comment

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

The rented inLIS array may contain stale true values from previous ArrayPool users, but it’s not cleared before calling ComputeLISInto. That can incorrectly skip required moves (because !inLIS[i] may be false even when the position is not in the current LIS). Clear inLIS[0..newLength) immediately after renting (or have ComputeLISInto clear the output span before marking).

Suggested change
{
{
// Ensure the rented buffer is clean before marking LIS positions
inLIS.AsSpan(0, newLength).Clear();

Copilot uses AI. Check for mistakes.
Comment on lines +1591 to +1593
// The indices returned are positions in oldIndices that form the LIS
// We mark those positions as "in LIS" (don't need moving)
ComputeLISInto(oldIndices.AsSpan(0, newLength), inLIS.AsSpan(0, newLength));
Copy link

Copilot AI Feb 9, 2026

Choose a reason for hiding this comment

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

The rented inLIS array may contain stale true values from previous ArrayPool users, but it’s not cleared before calling ComputeLISInto. That can incorrectly skip required moves (because !inLIS[i] may be false even when the position is not in the current LIS). Clear inLIS[0..newLength) immediately after renting (or have ComputeLISInto clear the output span before marking).

Copilot uses AI. Check for mistakes.
for (int i = newLength - 1; i >= 0; i--)
{
if (!inLIS.Contains(i))
if (!inLIS[i])
Copy link

Copilot AI Feb 9, 2026

Choose a reason for hiding this comment

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

The rented inLIS array may contain stale true values from previous ArrayPool users, but it’s not cleared before calling ComputeLISInto. That can incorrectly skip required moves (because !inLIS[i] may be false even when the position is not in the current LIS). Clear inLIS[0..newLength) immediately after renting (or have ComputeLISInto clear the output span before marking).

Copilot uses AI. Check for mistakes.
Comment on lines +215 to 234
// Helper to determine if a URL should be ignored for tracing
function shouldIgnoreFetchForTracing(url) {
try {
if (!url) return false;
// Ignore OTLP proxy endpoint
if (/\/otlp\/v1\/traces$/.test(url)) return true;
// Ignore common collector endpoints like http://localhost:4318/v1/traces
if (/\/v1\/traces$/.test(url)) return true;
// Ignore explicitly configured exporter URL if provided
if (typeof window !== 'undefined' && window.__OTEL_EXPORTER_URL) {
const configured = String(window.__OTEL_EXPORTER_URL);
if (configured && url.startsWith(configured)) return true;
}
// Ignore Blazor framework/runtime/resource downloads
if (url.includes('/_framework/')) return true;
} catch {
// On any error, fall back to tracing (do not silently skip)
}
return false;
}
Copy link

Copilot AI Feb 9, 2026

Choose a reason for hiding this comment

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

shouldIgnoreFetchForTracing doesn’t explicitly ignore the shim’s resolved endpoint value (from meta/global/default) unless it happens to match the hard-coded /v1/traces$ patterns. If a custom exporter URL doesn’t end with /v1/traces, the shim will instrument its own export fetch, which can spiral into recursive span exporting. Add an explicit check to ignore the actual endpoint (and ideally its normalized URL form), and apply the same fix to the duplicated copies in Abies.SubscriptionsDemo/wwwroot/abies.js and Abies.Presentation/wwwroot/abies.js.

Copilot uses AI. Check for mistakes.
Comment on lines +242 to +244
window.fetch = async function(input, init) {
const url = (typeof input === 'string') ? input : input.url;
if (shouldIgnoreFetchForTracing(url)) return origFetch(input, init);
Copy link

Copilot AI Feb 9, 2026

Choose a reason for hiding this comment

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

shouldIgnoreFetchForTracing doesn’t explicitly ignore the shim’s resolved endpoint value (from meta/global/default) unless it happens to match the hard-coded /v1/traces$ patterns. If a custom exporter URL doesn’t end with /v1/traces, the shim will instrument its own export fetch, which can spiral into recursive span exporting. Add an explicit check to ignore the actual endpoint (and ideally its normalized URL form), and apply the same fix to the duplicated copies in Abies.SubscriptionsDemo/wwwroot/abies.js and Abies.Presentation/wwwroot/abies.js.

Copilot uses AI. Check for mistakes.
private static void ReturnPatchDataList(List<PatchData> list)
{
if (list.Count < 1000) // Prevent memory bloat
{
Copy link

Copilot AI Feb 9, 2026

Choose a reason for hiding this comment

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

When returning a List<PatchData> to the pool, the list isn’t cleared first, so it can retain references to many PatchData instances (and their referenced objects) for the lifetime of the pooled list. Clearing before enqueueing releases those references earlier and reduces memory retention if the pooled list isn’t rented again soon.

Suggested change
{
{
list.Clear();

Copilot uses AI. Check for mistakes.
| Benchmark | Abies | Blazor | VanillaJS |
|-----------|-------|--------|-----------|
| 05_swap1k | 406.7ms | 94.4ms | 32.2ms |
````
Copy link

Copilot AI Feb 9, 2026

Choose a reason for hiding this comment

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

There’s a malformed Markdown code fence using four backticks (````), which is likely to break rendering for the remainder of the document. Replace it with a matching triple-backtick fence and ensure it properly closes the intended code block.

Suggested change
````

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown

@github-actions github-actions bot left a comment

Choose a reason for hiding this comment

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

⚠️ Performance Alert ⚠️

Possible performance regression was detected for benchmark 'Rendering Engine Throughput'.
Benchmark result of this commit is worse than the previous benchmark result exceeding threshold 1.05.

Benchmark suite Current: 08ddfc6 Previous: d80f18a Ratio
Abies.Benchmarks.Handlers/Create50Handlers 2556.069035393851 ns (± 32.28510677946083) 2420.239361572266 ns (± 38.40334347791619) 1.06

This comment was automatically generated by workflow using github-action-benchmark.

CC: @MCGPPeters

@MCGPPeters MCGPPeters changed the title perf: fix LIS algorithm bug causing 999 DOM moves instead of 2 perf: Fix LIS algorithm bug causing 999 DOM moves instead of 2 Feb 9, 2026
@MCGPPeters MCGPPeters changed the base branch from main to perf/reduce-gc-allocations February 9, 2026 17:34
Base automatically changed from perf/reduce-gc-allocations to main February 9, 2026 18:05
The ComputeLISInto function had a critical bug where it tracked 'k' as
'length-1' but the first element never incremented k, causing the
binary search to always use hi=0 and produce an LIS of length 1.

For the js-framework-benchmark swap test (swap 2 elements in 1000),
this caused 999 MoveChild patches instead of 2.

Fix: Changed from tracking k (length-1) to lisLen (actual length),
with proper increment when lo == lisLen.

Benchmark results:
- Swap median: 326.6ms → 121.6ms (2.7x faster)
- Script time: ~300ms → 96.7ms (3x faster)
- DOM moves: 999 → 2 (99.8% reduction)

Now only 1.29x slower than Blazor (was 3.47x).
@MCGPPeters MCGPPeters force-pushed the fix/lis-algorithm-swap-performance branch from 98818c3 to 08ddfc6 Compare February 9, 2026 18:28
@MCGPPeters MCGPPeters merged commit b930bd4 into main Feb 9, 2026
13 checks passed
@MCGPPeters MCGPPeters deleted the fix/lis-algorithm-swap-performance branch February 9, 2026 18:46
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