Skip to content

Remove allocations in PartitionFilter + cut time by ~30% #4500

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 7 commits into from
Oct 17, 2023

Conversation

stevenaw
Copy link
Member

Fixes #4489

This focuses just on .NET 6+ optimizations. I did this to allow different approaches to eventually be taken for net462 + net6 while also deferring any considerations about new dependencies we'd need to take on to support the net462 side.

The benchmarks below all use the one-shot hashing function (except the baseline). I tried benchmarking differences between that and the disposable API and didn't see any noticeable timing difference, only allocation savings (of about 200b) so I didn't include those in the final benchmarks. Similarly, MemoryMarshal.Read<uint>() didn't provide a noticeable difference in any way over BitConverter.ToUInt32 - if anything it was 1-2ns slower. I did include a [SkipLocalsInit] benchmark though to show the 1-2ns savings likely aren't worth switching to unsafe compilation.

Simple ArrayPooling seems to give the largest single benefit, though the 64 char length benchmarks below show a further 10-20ns could be saved by stackalloc'ing below a certain threshold. Given that we also need to stackalloc 32 bytes for the hashing buffer made me skeptical that we should target as high as 512 here. My PR targets 256 as an attempt at balancing this. It will still allow us to stackalloc for sizes of 85 characters or less.

Benchmarks

Results

| Method                                            | Runtime  | TestNameLength | Mean     | Gen0   | Allocated | Alloc Ratio |
|-------------------------------------------------- |--------- |--------------- |---------:|-------:|----------:|------------:|
| ComputeHashValue                                  | .NET 6.0 | 64             | 325.6 ns | 0.0520 |     328 B |        1.00 |
| ComputeHashValue_ArrayPool                        | .NET 6.0 | 64             | 188.8 ns |      - |         - |        0.00 |
| ComputeHashValue_ArrayPoolStackAlloc              | .NET 6.0 | 64             | 176.4 ns |      - |         - |        0.00 |
| ComputeHashValue_ArrayPoolStackAllocSkipLocalInit | .NET 6.0 | 64             | 169.9 ns |      - |         - |        0.00 |
|                                                   |          |                |          |        |           |             |
| ComputeHashValue                                  | .NET 8.0 | 64             | 325.2 ns | 0.0515 |     328 B |        1.00 |
| ComputeHashValue_ArrayPool                        | .NET 8.0 | 64             | 186.1 ns |      - |         - |        0.00 |
| ComputeHashValue_ArrayPoolStackAlloc              | .NET 8.0 | 64             | 168.0 ns |      - |         - |        0.00 |
| ComputeHashValue_ArrayPoolStackAllocSkipLocalInit | .NET 8.0 | 64             | 166.8 ns |      - |         - |        0.00 |
|                                                   |          |                |          |        |           |             |
| ComputeHashValue                                  | .NET 6.0 | 128            | 357.6 ns | 0.0625 |     392 B |        1.00 |
| ComputeHashValue_ArrayPool                        | .NET 6.0 | 128            | 218.7 ns |      - |         - |        0.00 |
| ComputeHashValue_ArrayPoolStackAlloc              | .NET 6.0 | 128            | 214.7 ns |      - |         - |        0.00 |
| ComputeHashValue_ArrayPoolStackAllocSkipLocalInit | .NET 6.0 | 128            | 216.5 ns |      - |         - |        0.00 |
|                                                   |          |                |          |        |           |             |
| ComputeHashValue                                  | .NET 8.0 | 128            | 355.3 ns | 0.0620 |     392 B |        1.00 |
| ComputeHashValue_ArrayPool                        | .NET 8.0 | 128            | 214.5 ns |      - |         - |        0.00 |
| ComputeHashValue_ArrayPoolStackAlloc              | .NET 8.0 | 128            | 215.2 ns |      - |         - |        0.00 |
| ComputeHashValue_ArrayPoolStackAllocSkipLocalInit | .NET 8.0 | 128            | 215.2 ns |      - |         - |        0.00 |
|                                                   |          |                |          |        |           |             |
| ComputeHashValue                                  | .NET 6.0 | 256            | 424.5 ns | 0.0825 |     520 B |        1.00 |
| ComputeHashValue_ArrayPool                        | .NET 6.0 | 256            | 280.3 ns |      - |         - |        0.00 |
| ComputeHashValue_ArrayPoolStackAlloc              | .NET 6.0 | 256            | 276.4 ns |      - |         - |        0.00 |
| ComputeHashValue_ArrayPoolStackAllocSkipLocalInit | .NET 6.0 | 256            | 274.1 ns |      - |         - |        0.00 |
|                                                   |          |                |          |        |           |             |
| ComputeHashValue                                  | .NET 8.0 | 256            | 416.7 ns | 0.0820 |     520 B |        1.00 |
| ComputeHashValue_ArrayPool                        | .NET 8.0 | 256            | 273.1 ns |      - |         - |        0.00 |
| ComputeHashValue_ArrayPoolStackAlloc              | .NET 8.0 | 256            | 274.4 ns |      - |         - |        0.00 |
| ComputeHashValue_ArrayPoolStackAllocSkipLocalInit | .NET 8.0 | 256            | 273.0 ns |      - |         - |        0.00 |
|                                                   |          |                |          |        |           |             |
| ComputeHashValue                                  | .NET 6.0 | 512            | 568.0 ns | 0.1230 |     776 B |        1.00 |
| ComputeHashValue_ArrayPool                        | .NET 6.0 | 512            | 390.4 ns |      - |         - |        0.00 |
| ComputeHashValue_ArrayPoolStackAlloc              | .NET 6.0 | 512            | 391.8 ns |      - |         - |        0.00 |
| ComputeHashValue_ArrayPoolStackAllocSkipLocalInit | .NET 6.0 | 512            | 390.2 ns |      - |         - |        0.00 |
|                                                   |          |                |          |        |           |             |
| ComputeHashValue                                  | .NET 8.0 | 512            | 552.3 ns | 0.1230 |     776 B |        1.00 |
| ComputeHashValue_ArrayPool                        | .NET 8.0 | 512            | 389.8 ns |      - |         - |        0.00 |
| ComputeHashValue_ArrayPoolStackAlloc              | .NET 8.0 | 512            | 390.4 ns |      - |         - |        0.00 |
| ComputeHashValue_ArrayPoolStackAllocSkipLocalInit | .NET 8.0 | 512            | 388.8 ns |      - |         - |        0.00 |
See Code
[MemoryDiagnoser]
[ShortRunJob(RuntimeMoniker.Net60), ShortRunJob(RuntimeMoniker.Net80)]
[HideColumns("Error", "StdDev", "Ratio", "Job")]
public class PartitionFilterHashingBenchmark
{
  private const int MaxStack = 256;

  [Params(64, 128, 256, 512)]
  public int TestNameLength { get; set; }

  public string TestMethodName { get; set; }

  [GlobalSetup]
  public void GlobalSetup()
  {
      TestMethodName = new string('A', TestNameLength);
  }

  [Benchmark(Baseline = true)]
  public uint ComputeHashValue()
  {
      var name = TestMethodName;
      using var hashAlgorithm = SHA256.Create();

      // SHA256 ComputeHash will return 32 bytes, we will use the first 4 bytes of that to convert to an unsigned integer
      var hashValue = hashAlgorithm.ComputeHash(Encoding.UTF8.GetBytes(name));

      return BitConverter.ToUInt32(hashValue, 0);
  }

  [Benchmark]
  public uint ComputeHashValue_ArrayPool()
  {
      var name = TestMethodName;
      var encoding = Encoding.UTF8;

      var bufferLength = encoding.GetMaxByteCount(name.Length);
      var buffer = ArrayPool<byte>.Shared.Rent(bufferLength);

      try
      {
          var bytesWritten = encoding.GetBytes(name, buffer);

          Span<byte> hashValue = stackalloc byte[32];
          SHA256.HashData(new Span<byte>(buffer, 0, bytesWritten), hashValue);

          return BitConverter.ToUInt32(hashValue[..4]);
      }
      finally
      {
          ArrayPool<byte>.Shared.Return(buffer);
      }
  }

  [Benchmark]
  public uint ComputeHashValue_ArrayPoolStackAlloc()
  {
      var name = TestMethodName;
      var encoding = Encoding.UTF8;

      var bufferLength = encoding.GetMaxByteCount(name.Length);

      byte[]? pooledBuffer = null;
      var buffer = bufferLength <= MaxStack ? stackalloc byte[MaxStack] : (pooledBuffer = ArrayPool<byte>.Shared.Rent(bufferLength));

      try
      {
          var bytesWritten = encoding.GetBytes(name, buffer);

          Span<byte> hashValue = stackalloc byte[32];
          SHA256.HashData(buffer[..bytesWritten], hashValue);

          return BitConverter.ToUInt32(hashValue[..4]);
      }
      finally
      {
          if (pooledBuffer is not null)
              ArrayPool<byte>.Shared.Return(pooledBuffer);
      }
  }

  [Benchmark]
  [SkipLocalsInit]
  public uint ComputeHashValue_ArrayPoolStackAllocSkipLocalInit()
  {
      var name = TestMethodName;
      var encoding = Encoding.UTF8;

      var bufferLength = encoding.GetMaxByteCount(name.Length);

      byte[]? pooledBuffer = null;
      var buffer = bufferLength <= MaxStack ? stackalloc byte[MaxStack] : (pooledBuffer = ArrayPool<byte>.Shared.Rent(bufferLength));

      try
      {
          var bytesWritten = encoding.GetBytes(name, buffer);

          Span<byte> hashValue = stackalloc byte[32];
          SHA256.HashData(buffer[..bytesWritten], hashValue);

          return BitConverter.ToUInt32(hashValue[..4]);
      }
      finally
      {
          if (pooledBuffer is not null)
              ArrayPool<byte>.Shared.Return(pooledBuffer);
      }
  }
}

@stevenaw stevenaw changed the title Remove allocations in PartitionFilter, cut time by ~30% on NET6+ Remove allocations in PartitionFilter + cut time by ~30% on NET6+ Oct 14, 2023
@stevenaw
Copy link
Member Author

stevenaw commented Oct 14, 2023

@manfred-brands I ran out of time to benchmark the thread local idea but had intended to push this to a shared please to make collaboration easier. Let me know if you'd like me to still do that

@lahma
Copy link
Contributor

lahma commented Oct 14, 2023

Just shoutout and a big kudos to @stevenaw , this is some solid engineering work with benchmarks backing the solution and studied alternatives! 🚀

Generally I'd think that it would be enough to just run against NET 6.0 (or 8.0 later on) as it's not significant to compare how MS is doing between releases, just the comparison between full vs modern in this case at least. ShortRunJob could then be omitted and allow BenchmarkDotNet to determine good iteration counts to get stable results.

@manfred-brands
Copy link
Member

@stevenaw Thanks for your benchmarks.

The default stack size is 1MB and the longest name would not be more than 1024 characters or 3kB bytes, so we could drop the whole array pool and only use stackalloc, allowing us to drop the try/finally

As you know, I don't like changing two items at once, so I changed the baseline benchmark to also use the static SHA256
and added a NonStatic method.
You said that the static SHA256 didn't make much difference, but my tests show otherwise.
The biggest gain is from not allocating/disposing the SHA256.
Followed by using the stackalloc for the byte array, but that gain is marginal.

Using ThreadLocal for both SHA256 and the buffer has the best performance and can be also used in .NET48

Benchmarks .NET6.0 different methods

Results

Result for .NET6.0 only as that is our target framework.

|                                    Method | TestNameLength |     Mean |   Gen0 | Allocated | Alloc Ratio |
|------------------------------------------ |--------------- |---------:|-------:|----------:|------------:|
|                 ComputeHashValueNonStatic |             64 | 320.0 ns | 0.0248 |     328 B |        2.28 |
|                          ComputeHashValue |             64 | 193.2 ns | 0.0110 |     144 B |        1.00 |
|                ComputeHashValue_ArrayPool |             64 | 192.4 ns |      - |         - |        0.00 |
|      ComputeHashValue_ArrayPoolStackAlloc |             64 | 180.3 ns |      - |         - |        0.00 |
|               ComputeHashValue_StackAlloc |             64 | 178.1 ns |      - |         - |        0.00 |
|       ComputeHashValue_ThreadLocal_Buffer |             64 | 187.4 ns |      - |         - |        0.00 |
| ComputeHashValue_ThreadLocal_BufferSha256 |             64 | 166.1 ns | 0.0083 |     112 B |        0.78 |
|                                           |                |          |        |           |             |
|                 ComputeHashValueNonStatic |            128 | 356.5 ns | 0.0296 |     392 B |        1.88 |
|                          ComputeHashValue |            128 | 227.8 ns | 0.0157 |     208 B |        1.00 |
|                ComputeHashValue_ArrayPool |            128 | 224.1 ns |      - |         - |        0.00 |
|      ComputeHashValue_ArrayPoolStackAlloc |            128 | 221.8 ns |      - |         - |        0.00 |
|               ComputeHashValue_StackAlloc |            128 | 212.8 ns |      - |         - |        0.00 |
|       ComputeHashValue_ThreadLocal_Buffer |            128 | 217.8 ns |      - |         - |        0.00 |
| ComputeHashValue_ThreadLocal_BufferSha256 |            128 | 187.7 ns | 0.0083 |     112 B |        0.54 |
|                                           |                |          |        |           |             |
|                 ComputeHashValueNonStatic |            256 | 423.9 ns | 0.0396 |     520 B |        1.55 |
|                          ComputeHashValue |            256 | 296.7 ns | 0.0253 |     336 B |        1.00 |
|                ComputeHashValue_ArrayPool |            256 | 280.2 ns |      - |         - |        0.00 |
|      ComputeHashValue_ArrayPoolStackAlloc |            256 | 292.7 ns |      - |         - |        0.00 |
|               ComputeHashValue_StackAlloc |            256 | 274.2 ns |      - |         - |        0.00 |
|       ComputeHashValue_ThreadLocal_Buffer |            256 | 279.9 ns |      - |         - |        0.00 |
| ComputeHashValue_ThreadLocal_BufferSha256 |            256 | 249.2 ns | 0.0081 |     112 B |        0.33 |
Code

Code

using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Jobs;
using System;
using System.Buffers;
using System.Security.Cryptography;
using System.Text;
using System.Threading;

namespace HashPerformance
{
    [MemoryDiagnoser]
    [ShortRunJob(RuntimeMoniker.Net60)]
    [HideColumns("Error", "StdDev", "Ratio", "RatioSD", "Job")]
    public class PartitionFilterHashingBenchmark
    {
        private const int MaxStack = 256;

        [Params(64, 128, 256)]
        public int TestNameLength { get; set; }

#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable.
        public string TestMethodName { get; set; }
#pragma warning restore CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable.

        [GlobalSetup]
        public void GlobalSetup()
        {
            TestMethodName = new string('A', TestNameLength);
        }

        [Benchmark]
        public uint ComputeHashValueNonStatic()
        {
            return ComputeHashValue(TestMethodName);

            static uint ComputeHashValue(string name)
            {
                using var hashAlgorithm = SHA256.Create();

                // SHA256 ComputeHash will return 32 bytes, we will use the first 4 bytes of that to convert to an unsigned integer
                var hashValue = hashAlgorithm.ComputeHash(Encoding.UTF8.GetBytes(name));

                return BitConverter.ToUInt32(hashValue, 0);
            }
        }

        [Benchmark(Baseline = true)]
        public uint ComputeHashValue()
        {
            return ComputeHashValue(TestMethodName);

            static uint ComputeHashValue(string name)
            {
                // SHA256 ComputeHash will return 32 bytes, we will use the first 4 bytes of that to convert to an unsigned integer
                var hashValue = SHA256.HashData(Encoding.UTF8.GetBytes(name));

                return BitConverter.ToUInt32(hashValue, 0);
            }
        }

        [Benchmark]
        public uint ComputeHashValue_ArrayPool()
        {
            return ComputeHashValue(TestMethodName);

            static uint ComputeHashValue(string name)
            {
                var encoding = Encoding.UTF8;
                var bufferLength = encoding.GetMaxByteCount(name.Length);
                var buffer = ArrayPool<byte>.Shared.Rent(bufferLength);

                try
                {
                    var bytesWritten = encoding.GetBytes(name, buffer);

                    Span<byte> hashValue = stackalloc byte[32];
                    SHA256.HashData(new Span<byte>(buffer, 0, bytesWritten), hashValue);

                    return BitConverter.ToUInt32(hashValue[..4]);
                }
                finally
                {
                    ArrayPool<byte>.Shared.Return(buffer);
                }
            }
        }

        [Benchmark]
        public uint ComputeHashValue_ArrayPoolStackAlloc()
        {
            return ComputeHashValue(TestMethodName);

            static uint ComputeHashValue(string name)
            {
                var encoding = Encoding.UTF8;
                var bufferLength = encoding.GetMaxByteCount(name.Length);

                byte[]? pooledBuffer = null;
                var buffer = bufferLength <= MaxStack ? stackalloc byte[MaxStack] : (pooledBuffer = ArrayPool<byte>.Shared.Rent(bufferLength));

                try
                {
                    var bytesWritten = encoding.GetBytes(name, buffer);

                    Span<byte> hashValue = stackalloc byte[32];
                    SHA256.HashData(buffer[..bytesWritten], hashValue);

                    return BitConverter.ToUInt32(hashValue[..4]);
                }
                finally
                {
                    if (pooledBuffer is not null)
                        ArrayPool<byte>.Shared.Return(pooledBuffer);
                }
            }
        }

        [Benchmark]
        public uint ComputeHashValue_StackAlloc()
        {
            return ComputeHashValue(TestMethodName);

            static uint ComputeHashValue(string name)
            {
                var encoding = Encoding.UTF8;
                var bufferLength = encoding.GetMaxByteCount(name.Length);

                Span<byte> buffer = stackalloc byte[bufferLength];

                var bytesWritten = encoding.GetBytes(name, buffer);

                Span<byte> hashValue = stackalloc byte[32];
                SHA256.HashData(buffer[..bytesWritten], hashValue);

                return BitConverter.ToUInt32(hashValue[..4]);
            }
        }

        private readonly ThreadLocal<SHA256> Sha256 = new(() => SHA256.Create());
        private readonly ThreadLocal<byte[]> Buffer = new(() => new byte[4096]);

        [Benchmark]
        public uint ComputeHashValue_ThreadLocal_Buffer()
        {
            return ComputeHashValue(TestMethodName);

            uint ComputeHashValue(string name)
            {
                byte[] buffer = Buffer.Value!;
                var bytesWritten = Encoding.UTF8.GetBytes(name, buffer);

                Span<byte> hashValue = stackalloc byte[32];
                SHA256.HashData(buffer.AsSpan(0, bytesWritten), hashValue);

                return BitConverter.ToUInt32(hashValue[..4]);
            }
        }

        [Benchmark]
        public uint ComputeHashValue_ThreadLocal_BufferSha256()
        {
            return ComputeHashValue(TestMethodName);

            uint ComputeHashValue(string name)
            {
                byte[] buffer = Buffer.Value!;
                var bytesWritten = Encoding.UTF8.GetBytes(name, buffer);
                byte[] hashValue = Sha256.Value!.ComputeHash(buffer, 0, bytesWritten);

                return BitConverter.ToUInt32(hashValue, 0);
            }
        }
    }
}
.NET Framework vs .NET 6.0

Results

|                                    Method |            Runtime | TestNameLength |       Mean |   Gen0 | Allocated |
|------------------------------------------ |------------------- |--------------- |-----------:|-------:|----------:|
|                 ComputeHashValueNonStatic |           .NET 6.0 |             64 |   319.6 ns | 0.0248 |     328 B |
| ComputeHashValue_ThreadLocal_BufferSha256 |           .NET 6.0 |             64 |   163.5 ns | 0.0083 |     112 B |
|                 ComputeHashValueNonStatic | .NET Framework 4.8 |             64 | 1,118.1 ns | 0.1774 |    1123 B |
| ComputeHashValue_ThreadLocal_BufferSha256 | .NET Framework 4.8 |             64 |   691.8 ns | 0.0315 |     201 B |
|                 ComputeHashValueNonStatic |           .NET 6.0 |            128 |   351.4 ns | 0.0296 |     392 B |
| ComputeHashValue_ThreadLocal_BufferSha256 |           .NET 6.0 |            128 |   191.4 ns | 0.0083 |     112 B |
|                 ComputeHashValueNonStatic | .NET Framework 4.8 |            128 | 1,435.3 ns | 0.1869 |    1187 B |
| ComputeHashValue_ThreadLocal_BufferSha256 | .NET Framework 4.8 |            128 |   956.0 ns | 0.0305 |     201 B |
|                 ComputeHashValueNonStatic |           .NET 6.0 |            256 |   423.0 ns | 0.0396 |     520 B |
| ComputeHashValue_ThreadLocal_BufferSha256 |           .NET 6.0 |            256 |   248.8 ns | 0.0081 |     112 B |
|                 ComputeHashValueNonStatic | .NET Framework 4.8 |            256 | 2,053.7 ns | 0.2060 |    1316 B |
| ComputeHashValue_ThreadLocal_BufferSha256 | .NET Framework 4.8 |            256 | 1,505.5 ns | 0.0305 |     201 B |
Code

Code

[Benchmark]
public uint ComputeHashValue_ThreadLocal_BufferSha256()
{
    return ComputeHashValue(TestMethodName);

    uint ComputeHashValue(string name)
    {
        byte[] buffer = Buffer.Value!;
        var bytesWritten = Encoding.UTF8.GetBytes(name, 0, name.Length, buffer, 0);
        byte[] hashValue = Sha256.Value!.ComputeHash(buffer, 0, bytesWritten);

        return BitConverter.ToUInt32(hashValue, 0);
    }
}

@stevenaw
Copy link
Member Author

Thanks @manfred-brands ! I'll try and reproduce that on my side. I'd still like to keep this PR net6-only for simplicity, but good to see the thread local side shows promise there too

@stevenaw
Copy link
Member Author

stevenaw commented Oct 15, 2023

@manfred-brands I'm starting to consider finding myself the time to do the net462 side of this too - especially if the method of buffer pooling is thread local for both.

Any concerns about storing a disposable class (SHA256) at class level in a thread local without the containing class implementing IDisposable? Or were you suggesting that PartitionFilter itself also implement the interface on net462?

@manfred-brands
Copy link
Member

Any concerns about storing a disposable class (SHA256) at class level in a thread local without the containing class implementing IDisposable? Or were you suggesting that PartitionFilter itself also implement the interface on net462?

I'm not to worried about a few instances of SHA256 lingering.
They will eventually be cleaned up as the implementation uses SafeHandle with a destructor.

If you want to do it nicely ..., that would allow for future filters that are IDisposable

The following classes need updating: TestFilter, CompositeFilter, NotFilter and PartitionFilter, TextRunner and FrameworkController.
I might have missed another filter that holds a reference to a TestFilter.

  • TestFilter needs an empty Dispose method.
  • CompositeFilter need to be updated to check if any of the Filters is IDisposable and dispose them.
  • Similar for the NotFilter.
  • PartitionFilter needs to Dispose ALL instances created by ThreadLocal by iterating over ThreadLocal.Values.
  • TextRunner creates the filter. Code can be updated with a using prefix to the TestFilter local.
  • FrameworkController needs updating any place that calls TestFilter.FromXml

@stevenaw
Copy link
Member Author

Thanks @manfred-brands . I just wanted to make sure we're on the same page. If you were also thinking the same, I'm inclined to keep this simple and avoid the Dispose() for now. As you say, it uses SafeHandle and from past benchmarking of undisposed MD5 handles I think I remember it was a relatively small size, such as 32B.

@stevenaw
Copy link
Member Author

I've just pushed some changes which should closely align to the faster zero-alloc .NET6 benchmark of @manfred-brands

Copy link
Member

@manfred-brands manfred-brands left a comment

Choose a reason for hiding this comment

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

Besides my one remark, looks good.
You might want to change the title of the PR as this is not limited to .NET6.0+

@stevenaw stevenaw changed the title Remove allocations in PartitionFilter + cut time by ~30% on NET6+ Remove allocations in PartitionFilter + cut time by ~30% Oct 17, 2023
@stevenaw
Copy link
Member Author

Thanks for the prompt feedback and approval @manfred-brands . I'm going to go ahead and merge

@stevenaw stevenaw merged commit 9b5ec78 into nunit:master Oct 17, 2023
@stevenaw stevenaw deleted the 4489-optimize-partition-hashing branch October 17, 2023 01:57
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.

Use buffer pooling when calculating partition filters
3 participants