Skip to content

Improve TableLayout ReservationGrid row advancement performance - #15065

Open
Eales wants to merge 1 commit into
dotnet:mainfrom
Eales:perf/reservation-grid-advance-row
Open

Improve TableLayout ReservationGrid row advancement performance#15065
Eales wants to merge 1 commit into
dotnet:mainfrom
Eales:perf/reservation-grid-advance-row

Conversation

@Eales

@Eales Eales commented Sep 9, 2026

Copy link
Copy Markdown

Fixes #15066

Proposed changes

Improve TableLayout.ReservationGrid row advancement by maintaining a logical head into the existing row list and compacting the discarded prefix only periodically.

This changes repeated row advancement from quadratic reference movement to amortized linear work while preserving the existing reservation semantics.

  • Map logical row offsets through the start index, checking the live-row count before addition to preserve behavior for large offsets.
  • Advance the index on the common path; clear/reset when the grid is exhausted.
  • Compact when discarded rows are at least as numerous as live rows.
  • Keep the existing List<BitArray>, private visibility, and reservation semantics. No public API changes, pooling, or custom collection.

Previously, draining R reserved rows shifted R(R-1)/2 references through RemoveAt(0). Each compaction now moves no more live rows than the number consumed since the previous compaction: amortized O(1) per advancement and O(R) for a drain.

Customer Impact

Layouts with deep row spans and many subsequently placed controls spend less time advancing through reservations. This removes one quadratic component of TableLayoutPanel layout; it does not make every part of layout linear.

In the measured two-column, 4,096-row public workload, layout time decreased from 3.484 ms to 1.904 ms (about 45%). Smaller/common layouts showed no consistent slowdown in a separate A/B/B/A check. These are workload-specific, single-machine measurements, not a general application speedup guarantee.

No intended changes to control placement, row/column sizes, visual output, or accessibility behavior.

Regression?

No known release regression. This addresses an existing algorithmic performance limitation.

The large-offset tests protect compatibility during this refactor; they are not a claim that the baseline throws for those inputs.

Risk

The change is localized, but an indexing error could affect layout placement. Tests cover advancing, appending after a nonzero head, exhaustion/reset, compaction, multiple columns, differential state checks, and a public six-control case with a very large explicitly assigned row index.

There is a memory tradeoff: consumed BitArray references remain reachable until compaction/reset. After each operation, a nonempty grid has fewer than twice as many stored rows as live rows. This is a bound on stored references, not on retained List.Capacity. Sustained sliding windows can grow the backing array compared with the baseline (observed capacity 4 -> 8 at depth 4, and 128 -> 256 at depth 128). No unbounded accumulation was observed in the rolling-window checks.

Test methodology

  • Release product/test build: 0 warnings and 0 errors.
  • All TableLayout test classes: 903 passed, 0 failed, 0 skipped, including 16 ReservationGrid cases and the public large-row-index compatibility test.
  • New tests include deterministic differential checks against the previous list/front-removal model. The large-offset test set was also run against an intermediate version with incorrect addition-before-bounds ordering: 3 failed before correction, all pass after correction.
  • Additional local public A/B comparison: 621 configurations / 1,841 observations, including expected exceptions, byte-for-byte identical to the baseline. Covered grow styles, row/column spans, fixed/flow controls, bounds, margins, right-to-left layout, and resizing.
  • Additional local internal checks: 32,768,000 state comparisons and 1,100,000 rolling consume/reserve steps; no differences for valid offsets or storage-bound violations.
  • Changed-file whitespace/style/analyzer verification passed. dotnet format reported referenced-project workspace-loading warnings; the actual Release build was clean.
  • Full System.Windows.Forms.Tests run on the final commit: 89,117 passed, 912 skipped, 72 failed (90,101 total). All 903 TableLayout cases also passed within this full run.
  • Baseline comparison: reran all 49 failing test methods (104 parameterized cases) with the same test host/dependencies, replacing only System.Windows.Forms.dll with the unmodified baseline build. 71 of the 72 exact failing cases also failed with the baseline; the baseline probe had 71 failures and 33 passes, with no additional failing cases. These include native language, DPI/size, font, input-language, COM/control, and related host-dependent expectations. This was a targeted baseline comparison, not a second full 90,101-test baseline run.
  • The one non-reproduced failure was ClipboardTests.GetApi_GetDataFailsAndSwitchEnabled_Throws for GetText/UnicodeText (clipboard operation failed during the full run). Rerunning that method on the final PR build passed all 5 cases. The evidence is consistent with a transient clipboard failure; the original full-run failure remains reported above.

No tests were disabled by this PR. CI validation is still required.

Commands for the focused and full runs, from the PR checkout:

.\.dotnet\dotnet.exe .\artifacts\bin\System.Windows.Forms.Tests\Release\net11.0-windows7.0\System.Windows.Forms.Tests.dll --culture en-US --filter-class '*TableLayout*' --minimum-expected-tests 903 --report-xunit-xml
.\.dotnet\dotnet.exe .\artifacts\bin\System.Windows.Forms.Tests\Release\net11.0-windows7.0\System.Windows.Forms.Tests.dll --culture en-US --report-xunit-xml --report-xunit-xml-filename pr-final-full.xml --timeout 15m

Performance measurements

Baseline: f0cd8e488e17c03994589d835fb0c1c1269b4491; change: e3b3be1a3183bd7824b48c13fd367e055aa678c3. Both built in Release with the repository SDK; separate prebuilt benchmark outputs, sequential runs, DOTNET_TieredCompilation=0.

These measurements use a small Stopwatch-based diagnostic harness, not BenchmarkDotNet confidence-interval results. Setup, object construction, forced GC, validation, and disposal are outside the timed region. The isolated benchmark calls delegates bound to the actual private implementation (no copied production algorithm).

Isolated drain: median microseconds for R calls to AdvanceRow.

Reserved rows Baseline (µs) PR (µs)
64 0.598 0.172
128 1.583 0.305
256 5.376 0.564
512 13.871 1.019
1,024 66.495 1.913
2,048 173.713 3.675
4,096 792.331 7.191
8,192 3,072.369 14.400
16,384 12,754.237 28.925
32,768 52,194.950 57.275

At larger sizes, doubling the row count approaches fourfold baseline time versus twofold PR time. Timed isolated advancement allocated 0 bytes in both versions.

Public layout: median microseconds for ResumeLayout(true). Two columns, one control spanning R rows, and R flow controls.

Rows Baseline (µs) PR (µs)
1 0.756 0.739
2 1.197 1.226
4 2.010 2.155
8 3.415 3.588
16 6.311 6.562
32 12.666 12.677
64 24.800 25.264
128 50.538 51.666
256 106.412 105.000
512 225.738 215.287
1,024 534.200 439.025
2,048 1,727.400 917.300
4,096 3,484.400 1,904.200

Separate full sweeps varied noticeably; the table is one complete sweep, not a promise of those exact ratios. To check the small-case differences, a separate A/B/B/A sequence used 31 batches per size per process:

Rows Baseline (µs) PR (µs) Change
0 0.0040 0.0040 0.00%
1 0.7765 0.7585 -2.32%
2 1.2085 1.1950 -1.12%
4 2.0685 2.0615 -0.34%
8 3.5045 3.5095 +0.14%

The last table averages the two per-process medians for each version; it is not a pooled median. Allocated bytes were identical before/after at each size in the measured public workloads (836,032 bytes at 4,096 rows). This does not imply unchanged allocations for every sliding-window workload.

Benchmark source and reproduction

Build the baseline and PR checkouts in Release using the repository's normal build instructions. Keep them separate. Put the following three files in a sibling benchmark directory outside either checkout. Set the two checkout paths below to their absolute local paths; the project references their built WinForms assemblies.

Build both benchmark outputs before running timing measurements. The program prints the loaded assembly path so it can be checked. Use the same host, SDK/runtime, and tiering settings for both versions; avoid other builds while measuring.

$baselineRoot = 'C:\work\winforms-baseline'
$modifiedRoot = 'C:\work\winforms-pr'
$dotnetExe = Join-Path $modifiedRoot '.dotnet\dotnet.exe'

& $dotnetExe build .\ReservationGridBenchmark.csproj -c Release "-p:WinFormsRoot=$baselineRoot" -o .\bin\baseline
& $dotnetExe build .\ReservationGridBenchmark.csproj -c Release "-p:WinFormsRoot=$modifiedRoot" -o .\bin\modified

$env:DOTNET_TieredCompilation = '0'
& $dotnetExe .\bin\baseline\ReservationGridBenchmark.dll
& $dotnetExe .\bin\modified\ReservationGridBenchmark.dll

# A/B/B/A small-layout check:
& $dotnetExe .\bin\baseline\ReservationGridBenchmark.dll --small
& $dotnetExe .\bin\modified\ReservationGridBenchmark.dll --small
& $dotnetExe .\bin\modified\ReservationGridBenchmark.dll --small
& $dotnetExe .\bin\baseline\ReservationGridBenchmark.dll --small

global.json (the SDK used for these measurements):

{
  "sdk": {
    "version": "11.0.100-rc.1.26420.103",
    "allowPrerelease": true,
    "rollForward": "disable"
  }
}

ReservationGridBenchmark.csproj:

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net11.0-windows</TargetFramework>
    <UseWindowsForms>true</UseWindowsForms>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
    <PlatformTarget>x64</PlatformTarget>
    <Optimize>true</Optimize>
    <WinFormsRoot Condition="'$(WinFormsRoot)' == ''">..\winforms-reservation-grid</WinFormsRoot>
  </PropertyGroup>

  <ItemGroup>
    <Reference Include="System.Drawing.Common" HintPath="$(WinFormsRoot)\artifacts\bin\System.Windows.Forms\Release\net11.0\System.Drawing.Common.dll" />
    <Reference Include="System.Private.Windows.Core" HintPath="$(WinFormsRoot)\artifacts\bin\System.Windows.Forms\Release\net11.0\System.Private.Windows.Core.dll" />
    <Reference Include="System.Private.Windows.GdiPlus" HintPath="$(WinFormsRoot)\artifacts\bin\System.Windows.Forms\Release\net11.0\System.Private.Windows.GdiPlus.dll" />
    <Reference Include="System.Windows.Forms" HintPath="$(WinFormsRoot)\artifacts\bin\System.Windows.Forms\Release\net11.0\System.Windows.Forms.dll" />
    <Reference Include="System.Windows.Forms.Primitives" HintPath="$(WinFormsRoot)\artifacts\bin\System.Windows.Forms\Release\net11.0\System.Windows.Forms.Primitives.dll" />
  </ItemGroup>
</Project>

Program.cs:

using System.Diagnostics;
using System.Globalization;
using System.Reflection;

internal static class Program
{
    private static readonly int[] s_publicSizes = [1, 2, 4, 8, 16, 32, 64, 128, 256, 512, 1_024, 2_048, 4_096];
    private static readonly int[] s_internalSizes = [1, 2, 4, 8, 16, 32, 64, 128, 256, 512, 1_024, 2_048, 4_096, 8_192, 16_384, 32_768];

    [STAThread]
    private static void Main(string[] args)
    {
        bool smallOnly = args.Contains("--small");
        Console.WriteLine($"Assembly: {typeof(TableLayoutPanel).Assembly.Location}");
        Console.WriteLine($"Version: {typeof(TableLayoutPanel).Assembly.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion}");
        Console.WriteLine("Benchmark: PublicTableLayoutPanel");
        Console.WriteLine("Rows,Iterations,MedianUs,MeanUs,AllocatedBytes");

        for (int i = 0; i < 5; i++)
        {
            MeasureOnce(32);
        }

        foreach (int rows in smallOnly ? new[] { 0, 1, 2, 4, 8 } : s_publicSizes)
        {
            int iterations = smallOnly ? 31 : rows <= 512 ? 15 : 7;
            int batchSize = Math.Clamp(4_096 / Math.Max(rows, 1), 1, 1_024);
            double[] elapsedMicroseconds = new double[iterations];
            long[] allocations = new long[iterations];

            for (int iteration = 0; iteration < iterations; iteration++)
            {
                (elapsedMicroseconds[iteration], allocations[iteration]) = MeasurePublicBatch(rows, batchSize);
            }

            Array.Sort(elapsedMicroseconds);
            double medianMicroseconds = elapsedMicroseconds[elapsedMicroseconds.Length / 2];
            double meanMicroseconds = elapsedMicroseconds.Average();
            long medianAllocation = allocations.Order().ElementAt(allocations.Length / 2);

            WriteResult(rows, iterations, medianMicroseconds, meanMicroseconds, medianAllocation);
        }

        if (smallOnly)
        {
            return;
        }

        Console.WriteLine("Benchmark: InternalReservationGrid");
        Console.WriteLine("Rows,Iterations,MedianUs,MeanUs,AllocatedBytes");

        ReservationGridInvoker invoker = new();
        for (int i = 0; i < 5; i++)
        {
            invoker.MeasureBatch(32, 1);
        }

        foreach (int rows in s_internalSizes)
        {
            int iterations = rows <= 8_192 ? 15 : 7;
            int batchSize = Math.Clamp(131_072 / rows, 1, 8_192);
            double[] elapsedMicroseconds = new double[iterations];
            long[] allocations = new long[iterations];

            for (int iteration = 0; iteration < iterations; iteration++)
            {
                (elapsedMicroseconds[iteration], allocations[iteration]) = invoker.MeasureBatch(rows, batchSize);
            }

            Array.Sort(elapsedMicroseconds);
            double medianMicroseconds = elapsedMicroseconds[elapsedMicroseconds.Length / 2];
            double meanMicroseconds = elapsedMicroseconds.Average();
            long medianAllocation = allocations.Order().ElementAt(allocations.Length / 2);

            WriteResult(rows, iterations, medianMicroseconds, meanMicroseconds, medianAllocation);
        }
    }

    private static void WriteResult(int rows, int iterations, double medianMicroseconds, double meanMicroseconds, long medianAllocation)
        => Console.WriteLine(string.Create(
            CultureInfo.InvariantCulture,
            $"{rows},{iterations},{medianMicroseconds:F3},{meanMicroseconds:F3},{medianAllocation}"));

    private static (double Microseconds, long AllocatedBytes) MeasurePublicBatch(int rows, int batchSize)
    {
        TableLayoutPanel[] panels = new TableLayoutPanel[batchSize];
        try
        {
            for (int i = 0; i < panels.Length; i++)
            {
                panels[i] = CreatePanel(rows);
            }

            CollectGarbage();
            long allocatedBefore = GC.GetAllocatedBytesForCurrentThread();
            long started = Stopwatch.GetTimestamp();

            foreach (TableLayoutPanel panel in panels)
            {
                panel.ResumeLayout(performLayout: true);
            }

            long elapsed = Stopwatch.GetTimestamp() - started;
            long allocated = GC.GetAllocatedBytesForCurrentThread() - allocatedBefore;

            foreach (TableLayoutPanel panel in panels)
            {
                if (rows == 0)
                {
                    if (panel.Controls.Count != 0)
                    {
                        throw new InvalidOperationException("Expected an empty panel.");
                    }

                    continue;
                }

                TableLayoutPanelCellPosition position = panel.GetPositionFromControl(panel.Controls[^1]);
                if (position.Row != rows - 1 || position.Column != 1)
                {
                    throw new InvalidOperationException($"Unexpected final position: {position} for {rows} rows.");
                }
            }

            return (elapsed * 1_000_000d / Stopwatch.Frequency / batchSize, allocated / batchSize);
        }
        finally
        {
            foreach (TableLayoutPanel? panel in panels)
            {
                panel?.Dispose();
            }
        }
    }

    private static (long Ticks, long AllocatedBytes) MeasureOnce(int rows)
    {
        using TableLayoutPanel panel = CreatePanel(rows);
        CollectGarbage();
        long allocatedBefore = GC.GetAllocatedBytesForCurrentThread();
        long started = Stopwatch.GetTimestamp();

        panel.ResumeLayout(performLayout: true);

        long elapsed = Stopwatch.GetTimestamp() - started;
        long allocated = GC.GetAllocatedBytesForCurrentThread() - allocatedBefore;

        TableLayoutPanelCellPosition position = panel.GetPositionFromControl(panel.Controls[^1]);
        if (position.Row != rows - 1 || position.Column != 1)
        {
            throw new InvalidOperationException($"Unexpected final position: {position} for {rows} rows.");
        }

        return (elapsed, allocated);
    }

    private static TableLayoutPanel CreatePanel(int rows)
    {
        TableLayoutPanel panel = new()
        {
            ColumnCount = 2,
            RowCount = 0,
            GrowStyle = TableLayoutPanelGrowStyle.AddRows,
            AutoSize = true,
        };

        panel.SuspendLayout();

        if (rows == 0)
        {
            return panel;
        }

        Control spanningControl = new()
        {
            Name = "SpanningControl",
            Size = new Size(1, 1),
            Margin = Padding.Empty,
        };

        panel.Controls.Add(spanningControl, 0, 0);
        panel.SetRowSpan(spanningControl, rows);

        for (int row = 0; row < rows; row++)
        {
            panel.Controls.Add(new Control
            {
                Name = $"Flow{row}",
                Size = new Size(1, 1),
                Margin = Padding.Empty,
            });
        }

        return panel;
    }

    private static void CollectGarbage()
    {
        GC.Collect();
        GC.WaitForPendingFinalizers();
        GC.Collect();
    }

    private sealed class ReservationGridInvoker
    {
        private readonly Type _type;
        private readonly MethodInfo _advanceRowMethod;
        private readonly MethodInfo _isReservedMethod;
        private readonly MethodInfo _reserveMethod;

        public ReservationGridInvoker()
        {
            _type = typeof(TableLayoutPanel).Assembly.GetType(
                "System.Windows.Forms.Layout.TableLayout+ReservationGrid",
                throwOnError: true)!;
            _advanceRowMethod = _type.GetMethod("AdvanceRow")!;
            _isReservedMethod = _type.GetMethod("IsReserved")!;
            _reserveMethod = _type.GetMethod("Reserve")!;
        }

        public (double Microseconds, long AllocatedBytes) MeasureBatch(int rows, int batchSize)
        {
            GridOperations[] grids = new GridOperations[batchSize];

            for (int i = 0; i < grids.Length; i++)
            {
                object grid = Activator.CreateInstance(_type, nonPublic: true)!;
                Action<int, int> reserve = _reserveMethod.CreateDelegate<Action<int, int>>(grid);
                Action advanceRow = _advanceRowMethod.CreateDelegate<Action>(grid);
                Func<int, int, bool> isReserved = _isReservedMethod.CreateDelegate<Func<int, int, bool>>(grid);

                for (int row = 0; row < rows; row++)
                {
                    reserve(0, row);
                }

                if (!isReserved(0, rows - 1))
                {
                    throw new InvalidOperationException("Reservation setup failed.");
                }

                grids[i] = new GridOperations(advanceRow, isReserved);
            }

            CollectGarbage();
            long allocatedBefore = GC.GetAllocatedBytesForCurrentThread();
            long started = Stopwatch.GetTimestamp();

            foreach (GridOperations grid in grids)
            {
                for (int row = 0; row < rows; row++)
                {
                    grid.AdvanceRow();
                }
            }

            long elapsed = Stopwatch.GetTimestamp() - started;
            long allocated = GC.GetAllocatedBytesForCurrentThread() - allocatedBefore;

            foreach (GridOperations grid in grids)
            {
                if (grid.IsReserved(0, 0))
                {
                    throw new InvalidOperationException("Reservation grid was not consumed.");
                }
            }

            return (elapsed * 1_000_000d / Stopwatch.Frequency / batchSize, allocated / batchSize);
        }

        private sealed record GridOperations(Action AdvanceRow, Func<int, int, bool> IsReserved);
    }
}

Test environment(s)

  • Windows x64, OS version 10.0.26200.
  • AMD Ryzen 9 9950X3D 16-Core Processor.
  • .NET SDK 11.0.100-rc.1.26420.103, runtime 11.0.0-rc.1.26453.118.
  • Native Windows culture: pl-PL; unit-test runner invoked with --culture en-US. Native UI culture/DPI expectations are relevant to the full-suite failures described above.
  • Release configuration. No deliberate visible UI changes; accessibility/visual behavior has not been separately audited with Accessibility Insights.
Microsoft Reviewers: Open in CodeFlow

@codecov

codecov Bot commented Sep 9, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 37.24166%. Comparing base (f0cd8e4) to head (e3b3be1).
⚠️ Report is 6 commits behind head on main.

Additional details and impacted files
@@              Coverage Diff              @@
##                main      #15065   +/-   ##
=============================================
  Coverage   37.24166%   37.24166%           
=============================================
  Files            246         246           
  Lines           9774        9774           
  Branches        1029        1029           
=============================================
  Hits            3640        3640           
  Misses          5970        5970           
  Partials         164         164           
Flag Coverage Δ
Debug 37.24166% <ø> (ø)
production 39.36526% <ø> (ø)
test 20.64923% <ø> (ø)
unit 39.36526% <ø> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

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.

TableLayoutPanel layout has quadratic reservation-row advancement for deep row spans

1 participant