Skip to content

Improve Adler32 vectorization - #125191

Open
saucecontrol wants to merge 7 commits into
dotnet:mainfrom
saucecontrol:adler32
Open

Improve Adler32 vectorization#125191
saucecontrol wants to merge 7 commits into
dotnet:mainfrom
saucecontrol:adler32

Conversation

@saucecontrol

@saucecontrolsaucecontrol commented Mar 4, 2026

Copy link
Copy Markdown
Member

This replaces the vectorized Adler32 implementation added in #124409

Major Differences

  • Removes the Vector512 implementation, which was about 20% slower than the Vector256 implementation on compatible hardware.
  • Improves the performance of the Vector256 implementation by taking better advantage of pipelining to compensate for high-latency instructions.
  • Handles smaller-than-vector tails with SIMD, avoiding potentially long scalar loops.
  • Avoids dropping to scalar every NMax bytes, speeding large input processing.
  • Adds an Armv8.2 DP implementation

In all, this amounts to a roughly 2x perf increase on large inputs, and even more on small inputs that are not an even multiple of vector size.

Benchmark Summary

x64

MethodInputLengthMeanErrorStdDevRatioRatioSDCode Size
Main16384323.930 ns1.1096 ns1.0379 ns1.000.00682 B
PR16384176.340 ns0.3327 ns0.2778 ns0.540.00801 B

Arm64

MethodInputLengthMeanErrorStdDevRatioRatioSD
Main16384868.077 ns10.2150 ns9.5551 ns1.000.02
PR16384425.371 ns1.2299 ns1.0903 ns0.490.01

Detailed Benchmark Results

-----> In Here <-----

AMD AVX-512 (Zen 5)


BenchmarkDotNet v0.15.8, Windows 11 (10.0.26200.7462/25H2/2025Update/HudsonValley2)
AMD Ryzen AI 9 HX 370 w/ Radeon 890M 2.00GHz, 1 CPU, 24 logical and 12 physical cores
.NET SDK 10.0.102
[Host] : .NET 10.0.3 (10.0.3, 10.0.326.7603), X64 RyuJIT x86-64-v4
DefaultJob : .NET 10.0.3 (10.0.3, 10.0.326.7603), X64 RyuJIT x86-64-v4
MethodInputLengthMeanErrorStdDevRatioRatioSDCode Size
Main164.416 ns0.0152 ns0.0134 ns1.000.00281 B
PR163.357 ns0.0155 ns0.0145 ns0.760.00761 B
Main245.775 ns0.0136 ns0.0114 ns1.000.00284 B
PR243.992 ns0.0134 ns0.0126 ns0.690.00752 B
Main317.000 ns0.0310 ns0.0290 ns1.000.01284 B
PR313.968 ns0.0080 ns0.0066 ns0.570.00752 B
Main324.072 ns0.0115 ns0.0096 ns1.000.00518 B
PR323.318 ns0.0095 ns0.0084 ns0.810.00761 B
Main488.376 ns0.0165 ns0.0154 ns1.000.00520 B
PR483.520 ns0.0137 ns0.0121 ns0.420.00752 B
Main644.789 ns0.0079 ns0.0070 ns1.000.00674 B
PR644.331 ns0.0059 ns0.0049 ns0.900.00781 B
Main9514.783 ns0.2521 ns0.3366 ns1.000.03675 B
PR955.316 ns0.0160 ns0.0150 ns0.360.01763 B
Main12722.506 ns0.0464 ns0.0434 ns1.000.001,033 B
PR1275.541 ns0.0118 ns0.0099 ns0.250.00754 B
Main1285.551 ns0.0170 ns0.0151 ns1.000.00674 B
PR1284.899 ns0.0076 ns0.0059 ns0.880.00781 B
Main22411.186 ns0.0167 ns0.0157 ns1.000.001,031 B
PR2245.712 ns0.0098 ns0.0087 ns0.510.00768 B
Main100028.755 ns0.5615 ns0.5252 ns1.000.021,041 B
PR100013.703 ns0.0371 ns0.0347 ns0.480.01783 B
Main102420.507 ns0.0764 ns0.0715 ns1.000.00682 B
PR102413.087 ns0.0274 ns0.0257 ns0.640.00801 B
Main409680.564 ns1.6367 ns2.2944 ns1.000.04682 B
PR409645.615 ns0.1160 ns0.1085 ns0.570.02801 B
Main16384323.930 ns1.1096 ns1.0379 ns1.000.00682 B
PR16384176.340 ns0.3327 ns0.2778 ns0.540.00801 B

Arm64 (Windows Dev Kit 2023)


BenchmarkDotNet v0.15.8, Windows 11 (10.0.26200.7840/25H2/2025Update/HudsonValley2)
Snapdragon Compute Platform 3.00GHz, 1 CPU, 8 logical and 8 physical cores
.NET SDK 10.0.200-preview.0.26103.119
[Host] : .NET 10.0.3 (10.0.3, 10.0.326.7603), Arm64 RyuJIT armv8.0-a
DefaultJob : .NET 10.0.3 (10.0.3, 10.0.326.7603), Arm64 RyuJIT armv8.0-a
MethodInputLengthMeanErrorStdDevRatioRatioSD
Main169.931 ns0.1664 ns0.1557 ns1.000.02
PR162.778 ns0.0080 ns0.0071 ns0.280.00
Main2411.030 ns0.0148 ns0.0132 ns1.000.00
PR244.248 ns0.0078 ns0.0065 ns0.390.00
Main3114.650 ns0.0238 ns0.0222 ns1.000.00
PR314.386 ns0.0044 ns0.0039 ns0.300.00
Main325.378 ns0.0230 ns0.0215 ns1.000.01
PR324.162 ns0.0100 ns0.0084 ns0.770.00
Main4811.665 ns0.0128 ns0.0120 ns1.000.00
PR484.811 ns0.0137 ns0.0114 ns0.410.00
Main646.777 ns0.0133 ns0.0125 ns1.000.00
PR645.436 ns0.0094 ns0.0088 ns0.800.00
Main9520.609 ns0.0270 ns0.0252 ns1.000.00
PR957.929 ns0.0173 ns0.0162 ns0.380.00
Main12721.668 ns0.0204 ns0.0181 ns1.000.00
PR1279.041 ns0.0157 ns0.0131 ns0.420.00
Main1289.553 ns0.0149 ns0.0140 ns1.000.00
PR1287.310 ns0.0162 ns0.0144 ns0.770.00
Main22414.551 ns0.0183 ns0.0162 ns1.000.00
PR2249.784 ns0.0134 ns0.0118 ns0.670.00
Main100057.058 ns0.1198 ns0.1062 ns1.000.00
PR100032.267 ns0.0572 ns0.0535 ns0.570.00
Main102458.382 ns0.1277 ns0.1195 ns1.000.00
PR102430.900 ns0.0654 ns0.0579 ns0.530.00
Main4096218.426 ns2.2794 ns2.0207 ns1.000.01
PR4096111.126 ns0.6393 ns0.5668 ns0.510.01
Main16384868.077 ns10.2150 ns9.5551 ns1.000.02
PR16384425.371 ns1.2299 ns1.0903 ns0.490.01

Intel AVX2 (Skylake)


BenchmarkDotNet v0.15.8, Windows 10 (10.0.19045.6456/22H2/2022Update)
Intel Core i7-6700K CPU 4.00GHz (Skylake), 1 CPU, 8 logical and 4 physical cores
.NET SDK 10.0.103
[Host] : .NET 10.0.3 (10.0.3, 10.0.326.7603), X64 RyuJIT x86-64-v3
DefaultJob : .NET 10.0.3 (10.0.3, 10.0.326.7603), X64 RyuJIT x86-64-v3
MethodInputLengthMeanErrorStdDevRatioRatioSDCode Size
Main167.880 ns0.1897 ns0.2258 ns1.000.04252 B
PR166.551 ns0.0438 ns0.0409 ns0.830.02790 B
Main2410.550 ns0.0987 ns0.0875 ns1.000.01252 B
PR248.161 ns0.0735 ns0.0574 ns0.770.01781 B
Main3113.016 ns0.0548 ns0.0513 ns1.000.01252 B
PR318.073 ns0.0281 ns0.0249 ns0.620.00781 B
Main328.517 ns0.0444 ns0.0393 ns1.000.01486 B
PR326.719 ns0.0363 ns0.0322 ns0.790.01790 B
Main4813.106 ns0.2872 ns0.2949 ns1.000.03488 B
PR488.041 ns0.0837 ns0.0654 ns0.610.01781 B
Main649.075 ns0.0403 ns0.0377 ns1.000.01486 B
PR649.077 ns0.0366 ns0.0342 ns1.000.01814 B
Main9522.044 ns0.4640 ns0.5157 ns1.000.03488 B
PR9511.108 ns0.0211 ns0.0187 ns0.500.01796 B
Main12720.746 ns0.1126 ns0.1054 ns1.000.01488 B
PR12711.440 ns0.0833 ns0.0650 ns0.550.00787 B
Main12810.805 ns0.0351 ns0.0293 ns1.000.00486 B
PR12810.097 ns0.0329 ns0.0291 ns0.930.00814 B
Main22413.447 ns0.2395 ns0.2123 ns1.000.02486 B
PR22411.717 ns0.0990 ns0.0773 ns0.870.01805 B
Main100033.921 ns0.1759 ns0.1646 ns1.000.01498 B
PR100026.133 ns0.1194 ns0.1059 ns0.770.00815 B
Main102432.486 ns0.1178 ns0.0919 ns1.000.00502 B
PR102427.179 ns0.1521 ns0.1187 ns0.840.00833 B
Main4096112.877 ns1.5260 ns1.2743 ns1.000.02502 B
PR409672.805 ns0.4396 ns0.4112 ns0.650.01833 B
Main16384442.728 ns8.2479 ns7.3115 ns1.000.02502 B
PR16384271.680 ns1.1137 ns1.0418 ns0.610.01833 B

CopilotAI review requested due to automatic review settings March 4, 2026 21:27
@dotnet-policy-servicedotnet-policy-serviceBot added the community-contribution Indicates that the PR has been added by a community member label Mar 4, 2026
@dotnet-policy-service

Copy link
Copy Markdown
Contributor

Tagging subscribers to this area: @dotnet/area-system-io-hashing, @bartonjs, @vcsjones
See info in area-owners.md if you want to be subscribed.

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

This PR refactors Adler-32’s SIMD implementation in System.IO.Hashing to a new strategy-based vectorized core, updates tests to stress delayed-modulo overflow scenarios, and wires the new SIMD source file into the build.

Changes:

  • Added a new SIMD implementation (Adler32Simd.cs) with AVX2 / SSSE3 / Arm64 (incl. DP) selection and shared vectorized update core.
  • Updated Adler32 to route vectorized updates through the new implementation and adjusted constant visibility.
  • Modified Adler32 tests to better stress overflow safety and expanded length coverage.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 5 comments.

FileDescription
src/libraries/System.IO.Hashing/tests/Adler32Tests.csUpdates large-input overflow-stress test and expands length coverage; removes a previous all-0xFF reference test.
src/libraries/System.IO.Hashing/src/System/IO/Hashing/Adler32Simd.csIntroduces the new SIMD implementation and strategy abstractions for vectorized Adler32 updates.
src/libraries/System.IO.Hashing/src/System/IO/Hashing/Adler32.csSimplifies vectorization gating and delegates SIMD updating to the new implementation; exposes ModBase internally.
src/libraries/System.IO.Hashing/src/System.IO.Hashing.csprojIncludes the new Adler32Simd.cs for .NETCoreApp builds.

Comment threadsrc/libraries/System.IO.Hashing/tests/Adler32Tests.cs
Comment threadsrc/libraries/System.IO.Hashing/tests/Adler32Tests.cs Outdated
Comment threadsrc/libraries/System.IO.Hashing/src/System/IO/Hashing/Adler32Simd.cs Outdated
@saucecontrol
saucecontrol marked this pull request as ready for review March 4, 2026 23:10
CopilotAI review requested due to automatic review settings March 4, 2026 23:10
{
data[i] = (byte)('a' + (i % 26));
}

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

This test (and the other removed test) didn't check the actual boundary condition. For example, if NMax is changed to 8192 in the Adler32 implementation, the tests still pass.

The updated test correctly breaks if NMax is set as small as 5553.

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated 3 comments.

Comment threadsrc/libraries/System.IO.Hashing/tests/Adler32Tests.cs
Comment threadsrc/libraries/System.IO.Hashing/src/System/IO/Hashing/Adler32Simd.cs Outdated
@saucecontrol

Copy link
Copy Markdown
MemberAuthor

CopilotAI review requested due to automatic review settings March 6, 2026 18:48

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated 3 comments.

Comment threadsrc/libraries/System.IO.Hashing/src/System.IO.Hashing.csproj Outdated
=> Vector128.IsHardwareAccelerated && source.Length >= Vector128<byte>.Count;

private static uint UpdateVectorized(uint adler, ReadOnlySpan<byte> source)
=> Adler32Simd.UpdateVectorized(adler, source);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Why separate it out like this?

The JIT tends to special case 1 level of inlining differently from 2+ levels of inlining and so simple forwarders like this can hurt things more than help.

@saucecontrolsaucecontrolMar 9, 2026

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

The SIMD implementation is all in file-scoped types, so it has to be called from something in this file. I could make those types nested private, but since there are so many, I was trying to keep them entirely local. If you prefer the nested approach, I can easily change it, though I don't foresee any issues with inlining limits here given the core method is intentionally marked NoInlining.

[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static uint UpdateVectorized(uint adler, ReadOnlySpan<byte> source)
{
if (Vector256.IsHardwareAccelerated && Avx2.IsSupported)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

What hardware were you testing AVX512 on? I wouldn't expect it to be slower than Vector256 at all and at worst the same speed here.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

I'm on an AMD Zen5 (see full benchmark details in the PR description).

The AVX-512 implementation adds some extra high-latency calculations to the inner loop, so it's expected to be slower. It can't be made to match the perf of AVX2 using the same logic widened, because vpmaddubsw will overflow with the larger multipliers required for the wider inputs, and any adjustment made to prevent overflow breaks the pipelining. The whole thing is very latency-sensitive.

A fast Vector512 implementation could be done with the AVX10.2 unsigned dot product instructions (vpdpbuud), but I didn't bother with that since the hardware still doesn't exist in the wild. I could add it preemptively if you like.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

You should be able to treat it as 2x256 instead of as 1x512 to avoid the wider multiplier issue and still get the perf gains.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

Yes, that's what this PR does to improve 2x over main. Main uses 1x256 or 1x512. 2x256 is faster than either.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I would expect that actual 2x256 (i.e. effectively unrolling) should be slightly slower than using actual 512 and treating it as 2x256, namely due to the denser code and not needing to manually pipeline the instructions. I would not expect the V512 path to be slower.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

This already gets instruction-level parallelism. At best, you could match the perf with V512, but that's a lot of extra complexity for nothing.

Comment on lines +51 to +56
if (Dp.IsSupported)
{
return UpdateCore<AdlerVector128, AccumulateArm64, DotProductArm64Dp>(adler, source);
}

return UpdateCore<AdlerVector128, AccumulateArm64, DotProductArm64>(adler, source);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

What is the perf difference between these two paths? Is it worth the additional complexity here?

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

The DP implementation is roughly twice as fast as base AdvSimd. Without it, this PR is only about 5% faster than Main on Arm64.

return UpdateCore<AdlerVector128, AccumulateArm64, DotProductArm64>(adler, source);
}

return UpdateCore<AdlerVector128, AccumulateXplat, DotProductXplat>(adler, source);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

What is the perf difference of the above code paths with the xplat path (all platforms)?

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

Xplat is around 1/3 the speed of native on both x64 (if restricted to Vector128) and Arm64 (if restricted to AdvSimd base).

// This is further optimized to: `high * 16 - high + low`
// and implemented as: `(high << 4) - high + low`.

Vector128<uint> vlo = values & (Vector128<uint>.AllBitsSet >>> 16);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

why not Vector128.Create<uint>(ushort.MaxValue)?

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

I actually wrote the code as I wanted it to be interpreted by JIT, i.e. pcmpeqd+psrld, but it gets constant folded and treated as a memory load anyway.

{
Vector128<byte> bytes1 = Vector128.LoadUnsafe(ref sourceRef);
Vector128<byte> bytes2 = Vector128.LoadUnsafe(ref sourceRef, (uint)Vector128<byte>.Count);
sourceRef = ref Unsafe.Add(ref sourceRef, (uint)Vector128<byte>.Count * 2);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

We're looking at ways to reduce or otherwise remove unsafe code like this. While we can't really remove LoadUnsafe, we have found it significantly less error-prone to never update sourceRef and to track the relevant offset indices instead, as it reduces risk of accidental GC holes.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

This is the exact same reference math done in the current implementation. What's changed between last week when that was approved and now?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

A comment was given then too. Last week was namely just taking the existing code "as is" and extending it for the parameterization.

This is touching the code with a slightly more significant and non-critical rewrite (even with quite a lot of it being the same and just moved down for sharing). Since we're actively doing work to reduce unsafe usage where feasible, then ideally we fix this up rather than continuing to persist the problematic code.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

I think you've mixed up the Adler32 and parameterized CRC32/64 PRs. Vectorized Adler32 was 100% new in #124409.

It's certainly possible to move to a buffer offset, but I think any code using LoadUnsafe requires scrutiny, and it's trivially provable that this code does not have the potential to create a GC hole.

Comment threadsrc/libraries/System.IO.Hashing/src/System/IO/Hashing/Adler32.Vectorized.cs Outdated
Comment threadsrc/libraries/System.IO.Hashing/src/System/IO/Hashing/Adler32.Vectorized.cs Outdated
Comment threadsrc/libraries/System.IO.Hashing/src/System/IO/Hashing/Adler32.Vectorized.cs Outdated
public static Vector256<uint> DotProduct(Vector256<uint> addend, Vector256<byte> left, Vector256<byte> right)
{
Vector256<short> mad = Avx2.MultiplyAddAdjacent(left, right.AsSByte());
return Avx2.MultiplyAddAdjacent(mad, Vector256<short>.One).AsUInt32() + addend;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Shouldn't this second one be HorizontalAddSaturate. The multiply by 1 is unnecessarily adding 2 cycles.

@saucecontrolsaucecontrolMar 9, 2026

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

It's a widen + add pairwise. There's actually a single dot product instruction that does it all in AvxVnni (vpdpbusd), but that creates a dependency on the uint accumulator that ends up making it just slightly slower than this form on the two Intel machines and one AMD machine I tried. Though, as mentioned above, I believe the unsigned form of dot product could be used to make a faster Vector512 implementation.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

It's a widen + add pairwise

Right, but the whole setup here is effectively just doing Sum(left * right) (reducing down to multiple 32-bit results, instead of one 8-bit result), which I'm pretty sure can be simplified to less than 5 cycles on anything Skylake or newer

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

Right, and that's a dot product. The only widening dot product instructions I'm familiar with for x86 are VNNI. What exact instruction sequence are you thinking of?

Comment on lines +251 to +253
wps += ws1;

ws1 = Accumulate(ws1, bytes1, bytes2);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Why do we need to be doing a full reduction every loop iteration here for wps?

It seems like a simply widen + add and then only reduce outside the loop should be plenty sufficient here and likely provide a bigger perf win.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

These are different accumulators that move at different rates. The only easy thing to factor out is the multiplication of the previous sum by the number of bytes that it would be added to each iteration, and that's already done.

Comment on lines +254 to +255
ws2 = DotProduct(ws2, bytes1, weights1);
ws3 = DotProduct(ws3, bytes2, weights2);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Similar here. This is effectively just doing ws2 + Sum(bytes1 * weights1) and ws3 + Sum(bytes2 * weights2)

It isn't clear why the sum at this point is actually needed every inner loop iteration and why it couldn't be hoisted "out" so that it's done only in the outer loop

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

It could be hoisted out, but then you still have to widen each element before accumulating, which is still expensive. See the first attempt at Arm64 acceleration in Stephen's PR for an idea what that looks like. It's not faster.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Widening is significantly cheaper and more pipelineable (and at least on AVX512 has single instruction, single cycle versions that goto wider registers).

I would expect decent savings if we were only widening and not doing the reductions per inner loop iteration, particularly that would simplify the algorithm and allow better code sharing across all these platforms.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

I gave that a quick try again (tried it before a long time ago but didn't keep the result as it wasn't worthwhile). It's still slower.

If you think you can do better than this implementation, be my guest. This was the best perf I could get, on every platform.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

We're notably not strictly looking for the "best perf" on every platform.

Rather, we're looking for good enough perf given the complexity, expected payload sizes, and real world impact (not every function is going to be a bottleneck or matter if its taking 200ns vs 400ns).

So part of what's being considered here is whether the extra code complexity, generics, impact to NAOT image size, or other scenarios, etc are meaningful enough compared to just having the simpler and very slightly slower code.

-- With this being a case where I expect we can remove most of the per ISA customization and still get "close enough" or even matching on most hardware, particularly when not simply looking at "latest" Intel or AMD and rather at the broad range of typical hardware targets which tend to be a bit older (Haswell, Skylake, Ice Lake, Zen 2, etc).

@saucecontrolsaucecontrolMar 10, 2026

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

extra code complexity

Although this is more lines of code, I'd argue it's less complex, simply because it's more consistent. The current implementation uses different logic for different vector widths, inconsistent variable names, etc. The new implementation uses the same skeleton for all, with only very small kernels abstracted away per-platform and with names that are easy to follow.

impact to NAOT image size

It should be noted, this generic approach is an improvement for NAOT code size, because e.g. on x64, instead of dynamically dispatching between up to 3 different implementations depending on ISA support and input size, this will choose exactly 1, which will always be used for any input >= 16 bytes.

In the case of Arm64, it will compile up to 2 potential versions of the core method, but it moves the ISA check out to the dispatcher, avoiding dynamic checks in the inner loop. And if 2x performance isn't good enough to justify a second copy, why are we bothering to implement Vector256 (not to mention the Vector512 implementation I got rid of and which you've argued to bring back)?

I expect we can remove most of the per ISA customization and still get "close enough"

I've already explained that this assumption is incorrect. I invite you to try it yourself if you think I've made a mistake.

CopilotAI review requested due to automatic review settings March 10, 2026 00:14

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.

@github-actions

github-actionsBot commented Jul 19, 2026

Copy link
Copy Markdown
Contributor

Workflow state for the Holistic Review Orchestrator.

{
"version": 5,
"last_dispatched_commit": "2a9af33d3d0b077f1dc8299ea869d39f07a6f92c",
"last_dispatched_base_ref": "main",
"last_dispatched_base_sha": "f25f0e24d03e6c4d49f4d43ef48a01250a31a1bd",
"last_reviewed_commit": "2a9af33d3d0b077f1dc8299ea869d39f07a6f92c",
"last_reviewed_base_ref": "main",
"last_reviewed_base_sha": "f25f0e24d03e6c4d49f4d43ef48a01250a31a1bd",
"last_recorded_worker_run_id": "29673728499",
"review_attempt_commit": "",
"review_attempt_base_ref": "",
"review_attempt_count": 0,
"max_review_attempts": 5,
"review_history_format": "holistic-review-disclosure-v1",
"review_history": [
{
"commit": "2a9af33d3d0b077f1dc8299ea869d39f07a6f92c",
"review_id": 4730120711
}
]
}

@github-actionsgithub-actionsBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Holistic Review

Motivation: Justified. This replaces the vectorized Adler32 implementation from #124409 (still unreleased in-tree) with a faster, simpler-dispatched design. The PR reports ~2x throughput on large inputs and even larger gains on small/odd-length inputs, backed by benchmarks across Zen 5 (AVX-512), Skylake (AVX2), and Arm64, so it is a real, measured improvement rather than churn.

Approach: Sound. The rewrite drops the Vector512 path (reported ~20% slower than Vector256), consolidates dispatch behind IsVectorizable, and factors the per-ISA kernels into generic struct strategy types (ISimdStrategy/ISimdAccumulate/ISimdDotProduct) instantiated with static-abstract-interface implementations so the JIT specializes and devirtualizes each combination. It processes sub-vector tails with SIMD via a masked load, avoids per-NMax scalar fallbacks, and adds an Armv8.2 dot-product (Dp) path. The file-scoped helper types keep the new surface internal. This is idiomatic with other vectorized helpers in this library.

Summary: ⚠️ Needs Human Review. The code is well-structured and I found no correctness defects: the delayed-modulo QuickModBase reduction is overflow-safe within the chosen VMax (448) bound, the weighted dot-product taps are sequenced correctly (32..1 across the two 16-byte halves; 64..1 for the AVX2 32-byte block), the AVX2 folding of the 256-bit accumulators back to 128-bit lanes before the final Vector128.Sum is correct, and the masked-tail load reads within the already-validated buffer. The strengthened tests meaningfully exercise the overflow boundary. Because correctness here rests on non-obvious numeric-overflow and lane-arithmetic reasoning across four ISA paths that this worker cannot build or execute, a domain reviewer (@tannergooding) should confirm the SIMD math and the removal of the Vector512 path, and confirm CI results across x86/Arm64. See the single inline note on the dropped little-endian guard.


Detailed Findings

✅ Correctness — Overflow safety of delayed modulo

AdlerVector128.QuickModBase reduces each lane by folding the high 16 bits (high*15, implemented as (high<<4)+(low-high)) into the low bits, keeping accumulators bounded so the deferred true % ModBase at the end matches the reference. The VMax = 448 vector bound (author notes the true safe max is 460) leaves comfortable headroom before any lane could overflow a uint, and s1/s2 are only fully reduced mod 65521 at the end via Vector128.Sum(...) % Adler32.ModBase. The new LargeInput_ExceedsNMax test primes the state to the maximal 0xFFF0FFF0 checksum and then appends all-0xFF data, which is a strong regression guard against exactly this overflow class.

✅ Correctness — Tail handling

The single leftover vector (tailVectors == 1) and the sub-vector remainder (tailLength) are both handled with SIMD. The remainder uses a masked load: it re-loads the last full vector's worth of bytes at bufRef - (16 - tailLength) and masks off the already-counted leading bytes via MaskBytes, then weights the surviving bytes with vps * tailLength. Because the vectorized path is only entered when source.Length >= Vector128<byte>.Count, this backward-offset load stays within the caller-provided span, so there is no out-of-bounds access.

✅ Tests — Meaningful coverage of paths and boundaries

The test changes are a net improvement: VariousLengths_MatchesReference adds lengths around the 8/16/24/32 vector/block transitions, and LargeInput_ExceedsNMax now validates the overflow boundary against a simple scalar ReferenceAdler32 rather than hard-coded constants. Dropping AllMaxBytes_MatchesReference is acceptable since its all-0xFF stress is now subsumed by the strengthened overflow test. Hard-coded expected values were replaced by the reference oracle, which is more robust.

💡 Observation — Dropped BitConverter.IsLittleEndian guard

The old dispatcher also checked BitConverter.IsLittleEndian; IsVectorizable no longer does. This appears intentional and safe (all SIMD paths are byte-granular / lane-value based, independent of element byte order), and big-endian SIMD-accelerated .NET targets are effectively nonexistent. Flagged inline for an explicit confirmation.

Note

This review was generated by this repository's Holistic Review agentic workflow to complement the built-in Copilot review.

Generated by Holistic Review · 147.5 AIC · ⌖ 15.4 AIC · ⊞ 10K

public sealed partial class Adler32
{
private static bool IsVectorizable(ReadOnlySpan<byte> source)
=> Vector128.IsHardwareAccelerated && source.Length >= Vector128<byte>.Count;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

💡 The previous dispatch code in Adler32.cs gated vectorization on BitConverter.IsLittleEndian in addition to Vector128.IsHardwareAccelerated. This new IsVectorizable drops that endianness check. That looks intentional and correct — the SIMD paths only use byte-granular loads plus lane-value arithmetic (SAD, MultiplyAddAdjacent/dot-product, Vector128.Sum, and the QuickModBase shift/mask reduction), none of which depend on element byte order, and the tail MaskBytes handling is likewise byte-oriented. Just flagging the removal explicitly so it's a conscious decision; big-endian SIMD-accelerated targets are effectively nonexistent for .NET, so the practical risk is nil.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area-System.IO.Hashingcommunity-contributionIndicates that the PR has been added by a community member

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

@saucecontrol@tannergooding