Skip to content

Deabstraction of IReadOnlyList access in a loop can be prevented by inlining #123065

@dmitry-azaraev

Description

@dmitry-azaraev

Hello!

Description

I'm attempting to avoid unnecessary allocations in my data crunching application, however this issue was inspired by one of reddit posts about how .NET 10 with PGO doing with deabstractions. I'm noticed previously what it is not very reliable for me, and I'm doesn't rely on it in critical places. However, I'm found some case where PGO won't "deabstract" collection enumeration at all for me, no matter how many calls are maden, but I feel what it is should occur.

Configuration

.NET 10, Windows, at end of post I'm attached reproduction.

Regression?

No.

Analysis

I'm found what deabstraction is never occurs in example below. However, it will accur if I'm mark "consumer" code with NoInlining hint (method DoWithIReadOnlyList specifically). It also occurs if I change order of tests running in same process (but this pointless, as there is no intent to expose List).

I'm have no idea if it is known or expected behavior, I'm accept any behavior, because personally I'm think what there origin of issue is not deabstraction or PGO, but curse of standard collections. But, shown case looks like very common, and it may look strange what it is doesn't work as expected, especially where there is exist cases when this actually works beautifully.

Note: in example below there is actually no any virtuality at all except what List<T> exposed via IReadOnlyList<T> to consumer, and technically there is should be possible see real type and rewrite code without relying on PGO at all, and JIT i'm guess already see everything, except that C# generates different code in first place. (On another side, skipping interface methods is also unfair, and if some imaginary compiler will do that, - it is some special semantics, I'm doesn't saying what it is should actually work in that way.)

I hope this helps.

Sample below will show:

> dotnet run -c:Release PgoIReadOnlyListEnumeration.cs
IReadOnlyList:
It #: 40 bytes/it (0 result length)
List:
It #: 0 bytes/it (0 result length)

You can turn this sample to BenchmarkDotNet, and adjust as need (and comment call to PgoIReadOnlyListEnumeration.Run).

#:package BenchmarkDotNet@0.15.8

using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Configs;
using BenchmarkDotNet.Jobs;
using BenchmarkDotNet.Running;
using BenchmarkDotNet.Toolchains.InProcess.Emit;

PgoIReadOnlyListEnumeration.Run();
return;

// Call `dotnet project convert PgoIReadOnlyListEnumeration.cs` before using.
// BenchmarkSwitcher
//  .FromAssembly(typeof(Program).Assembly)
//  .Run(args);

[MemoryDiagnoser]
public class PgoIReadOnlyListEnumeration {
  private const int OpsPerInvoke = 100_000_000;

  public static void Run() {
    // When it called first time - IReadOnlyList never get deabstracted.
    // However it will do that if mark DoWithIReadOnlyList method with NoInlining hint.
    Console.WriteLine("IReadOnlyList:");
    new PgoIReadOnlyListEnumeration().AsIReadOnlyList();

    // Doing with list.
    Console.WriteLine("List:");
    new PgoIReadOnlyListEnumeration().AsList();

    // Note, what if you reverse order of calls, e.g. AsList(),
    // and then AsIReadOnlyList() - then last will be deabstracted by PGO.
  }

  [Benchmark(OperationsPerInvoke = OpsPerInvoke)]
  public double AsIReadOnlyList() {
    var f = new ResultFactory();
    return Measure(() => DoWithIReadOnlyList(f), OpsPerInvoke);
  }

  [Benchmark(Baseline = true, OperationsPerInvoke = OpsPerInvoke)]
  public double AsList() {
    var f = new ResultFactory();
    return Measure(() => DoWithList(f), OpsPerInvoke);
  }

  // [MethodImpl(MethodImplOptions.NoInlining)]
  private int DoWithIReadOnlyList(ResultFactory factory) {
    var result = factory.CreateResultFromInput();
    var r = 0;
    foreach (var item in result.GetReadOnlyList()) {
      r += item.Value;
    }
    return r;
  }

  // [MethodImpl(MethodImplOptions.NoInlining)]
  private int DoWithList(ResultFactory factory) {
    var result = factory.CreateResultFromInput();
    var r = 0;
    foreach (var item in result.GetList()) {
      r += item.Value;
    }
    return r;
  }

  private double Measure(Func<int> action, int count) {
    var startingAllocations = GC.GetTotalAllocatedBytes(true);

    var length = 0;
    for (var i = 0; i < count; i++) {
      length += action();
    }

    var endingAllocations = GC.GetTotalAllocatedBytes(true);
    var totalAllocated = endingAllocations - startingAllocations;
    Console.WriteLine("It #: {0:N0} bytes/it ({1} result length)", totalAllocated * 1.0 / count, length);
    return totalAllocated * 1.0 / count;
  }

  public readonly record struct ResultItem(int Value) { }

  public readonly struct Result {
    private readonly ResultImpl _impl;

    public Result(ResultImpl impl) {
      _impl = impl;
    }

    public IReadOnlyList<ResultItem> GetReadOnlyList() {
      return _impl.Attributes;
    }

    public List<ResultItem> GetList() {
      return _impl.Attributes;
    }
  }

  public sealed class ResultFactory {
    private readonly ResultImpl _resultImpl = new();

    public Result CreateResultFromInput() {
      var resultImpl = _resultImpl;
      resultImpl._attributes.Clear();
      resultImpl._attributes.Add(new ResultItem());
      return new Result(resultImpl);
    }
  }

  public sealed class ResultImpl {
    internal readonly List<ResultItem> _attributes;

    public ResultImpl() {
      _attributes = new List<ResultItem>();
    }

    public List<ResultItem> Attributes => _attributes;
  }
}

Metadata

Metadata

Assignees

No one assigned

    Labels

    area-CodeGen-coreclrCLR JIT compiler in src/coreclr/src/jit and related components such as SuperPMItenet-performancePerformance related issue

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions