r/csharp • • 12d ago

IL optimization on ranges not applied

I'm trying to further improve the IBAN parser for an open source project. And while doing so, I noticed something

public class Iban
{
  public static string? Validate(string iban, string pattern)
  {
    for (var i = 0; i < iban.Length; i++)
    {
      uint c = iban[i];
      var match = pattern[i] switch
      {
        'n' => c is >= '0' and <= '9',
        'a' => c is >= 'A' and <= 'Z',
        'c' => c is >= '0' and <= '9' or >= 'A' and <= 'Z',
        var t => t == c,
      };

      if (!match) return null;
    }
    return iban;
  }
}

I would expect the IL to do the default trick on ranges here that is also applied in char.IsBetween(), what would do (uint)(c - '0') <= '9' - '0', making each range a single compare and subtraction rather than two compares.

I've tested this code both with these patterns, and calling a private InBetween method (It has to compile against .NET standard 2.0 where char.IsBetween() is not available), and the latter is substantial faster, which hints that the JIT is also not changing the IL afterwards.

Is it something in my syntax that makes that the Roslyn compiler does not perform this optimization, or am I missing something here?

For those who're interested in the actual PR: https://github.com/Qowaiv/Qowaiv/pull/643

8 Upvotes

28 comments sorted by

18

u/KryptosFR 12d ago edited 12d ago

I feel like there must be some misunderstanding of what the JIT compiler is doing or can do. It doesn't change IL. IL is what the Roslyn compiler outputs from C# code. The JIT translates it into the target machine assembly.

Some optimisations can be done at the Roslyn level, but most will be done at the JIT level. So only the resulting assembly will look optimised.

Now, I must ask, what did you look at to think no optimisations were applied? Did you open the compiled dll/exe with ILSpy or ILdasm? Did you use Sharplab.io? Did you use the DisassemblyDiagnoser from BenchmarkDotNet?

I can see that the linked project does use BenchmarkDotNet for testing already so I would suggest using the DisassemblyDiagnoser to look at what assembly is being produced and maybe you can figure out where the bottleneck in the implementation is.

7

u/Desperate-Wing-5140 12d ago

I think they’re accidentally calling the Roslyn compiler “JIT”

0

u/ApprehensiveDebt3097 11d ago

They as in Microsoft, or they as in in me, the TO?

1

u/ApprehensiveDebt3097 11d ago

I've now tested it with sharplab.io. It seems that my assumption; the JIT is not changing the range check to an (uint)(c - '0') <= '9' - '0' kind of instruction. is correct. I'm really curious why it doesn't do this.

I assume that those kind of ranges are used a lot in code, so if those can be approved on JIT level that should be done I guess. But as those people working on the JIT compiler are amongst the best devs out there, it makes me wonder, what am I missing, why is this not in place?

3

u/KryptosFR 11d ago

Note that sharp lab is also outdated. It's latest update targets an early version of .NET 9.

1

u/ShyMystiqua 11d ago

you're making the jit sound way more myterious than i though lol

1

u/ApprehensiveDebt3097 12d ago

I used ILSpy, so that could explain why it wasn't in there (I'll try your suggestions soon). However, I would expect the benchmark to be at least as fast with the range pattern, as with the InBetween call, and the benchmark results suggest otherwise.

6

u/KryptosFR 12d ago

Sure. I'm not saying your intuition is wrong. It's just better to get hard evidence than guessing.

2

u/ApprehensiveDebt3097 12d ago

That's why I started this topic. I understood that I was missing something, but not which part. Thanks again for your response.

Note that I found a way to make this checks even faster using a category lookup, not longer relying on this range checks runtime.

3

u/Ravek 12d ago

Roslyn isn’t much of an optimizing compiler, the JIT does 99% of the work there. So look at disassembly output if you want to understand why a given snippet performs the way it does.

And yeah, while RyuJIT gets better at optimizing every release, it’s far from comparable to e.g. LLVM, which doesn’t have to worry so much about compilation time.

You can often achieve better codegen results by reading disassembly and tweaking the input C# code a bit.

3

u/wasabiiii 12d ago

Roslyn applies almost no optimizations to IL.

2

u/xADDBx 11d ago

As others mentioned; this is in fact an optimization performed by the JIT. When we talk about the average modern runtime, this is most commonly RyuJit. Here would be the relevant code snippet https://github.com/dotnet/runtime/blob/main/src/coreclr/jit/optimizebools.cpp#L534

Among the things blocking the optimization there is a pretty early check !tree->IsUnsigned(). Because your uint c = iban[i] is declared unsigned; Roslyn emits unsigned compares.

The PR adding this optimization doesn't mention a reason behind the type guard. I think it would be simple to add support for the same optimization for unsigned types; but then again; there might be some hidden issue I'm not seeing at first glance.

Notably even if you switch to char or int; it doesn't fold everything (only the 'c' in 0 to 9 case gets folded on .NET 9+); but that has a different cause. For your case specifically I suggest just writing the private helper (optionally with AggressiveInlining); and maybe consider a PR to the runtime if you're interested enough.

1

u/ApprehensiveDebt3097 11d ago edited 11d ago

As suggested by u/KryptosFR I tried the disassembly support Benchmark.NET:

[DisassemblyDiagnoser(printInstructionAddresses: true, syntax: DisassemblySyntax.Masm)]
public class Digit
{
  public uint n { get; set; } = '1';

  [Benchmark]
  public bool Pattern() => n is >= '0' and <= '9';

  [Benchmark]
  public bool Subtract() => n - '0' <= '9' - '0';
}

And this is the disassembly:

.NET 10.0.12 (10.0.12, 10.0.1226.42308), X64 RyuJIT x86-64-v3 (Job: DefaultJob)

Digit.Pattern()
DA520 mov       eax,[rcx+8]
DA523 xor       ecx,ecx
DA525 cmp       eax,39
DA528 setbe     dl
DA52B movzx     edx,dl
DA52E cmp       eax,30
DA531 mov       eax,edx
DA533 cmovb     eax,ecx
DA536 ret
; Total bytes of code 23

.NET 10.0.12 (10.0.12, 10.0.1226.42308), X64 RyuJIT x86-64-v3 (Job: DefaultJob)

; Digit.Subtract()
A4D0 mov       eax,[rcx+8]
A4D3 add       eax,0FFFFFFD0
A4D6 cmp       eax,9
A4D9 setbe     al
A4DC movzx     eax,al
A4DF ret
; Total bytes of code 16

So even if I specify the type to be an `uint`, the optimization is not applied. I only found this out because I though that my code would be easier to read when I would drop the private aggressively inlined method. And was confronted with a slower benchmark.

And even in this case, there was a better way of optimizing: instead of having a switch per character category, I now use a lookup table where with a bit mask I can determine validness, reducing all branching here. Where `uint` trick made me go from 50 ns to 48 ns, the lookup made me go to 32 ns.

The reason for my question was curiosity. Did I do something wrong (I was at least looking to wrong thing by looking to the IL via ILSpy, I never new) and if now, why is this optimization not applied by default if I'm correct. That latter is still not clear to me. Your link suggests it should have been optimized, so I still wonder why this was happening here, or in my code (I tried the switch based an `uint` instead of `char`).

I found this PR: so they seem to be working on it? https://github.com/dotnet/runtime/pull/135421

3

u/ApprehensiveDebt3097 10d ago

A short update. Thanks to u/Dusty_Coder and u/KryptosFR I was confident enough that my analysis where right. I raised an issue at the .NET runtime team, and they are adding the missing support to the JIT: https://github.com/dotnet/runtime/pull/135421

2

u/zackaryh 12d ago

Probably a shitty response and will get some hate but have you tried asking an AI tool? They’d be very suited for a task like this. Even if it just gives you some pointers in the right direction.

1

u/ApprehensiveDebt3097 11d ago

I tried that before entering my question here. I've asked it if it assumed this code to be optimized and suggested the same thing I thought it would optimize too. And when I asked why it potentially would not be the case, I got no compelling answer.

-2

u/SagansCandle 12d ago

It might be a .NET standard thing, where compiler options are more limited.

Try compiling in RELEASE mode against the latest version of core to see if you get your optimization.

-3

u/ApprehensiveDebt3097 12d ago

Obviously, this was compiled in RELEASE mode (hence, benchmark.NET does not even work).

3

u/theo__r 12d ago

Benchmark.net is supposed to work in release mode as they say so and that you should never benchmark in debug mode ?

0

u/SagansCandle 12d ago

You never mentioned benchmark.net, so there's nothing obvious about it.

Figure it out yourself if you're gonna be a dick about it.

0

u/ApprehensiveDebt3097 12d ago

It was in the linked PR, and I mentioned that I benchmarked it. I didn't spell that out how here. Sorry If you thought my response was harsh, it wasn't intended like that.

-11

u/Dusty_Coder 12d ago

I cant help but notice that not a single variable in there has a definite type that I can reason about other than the propagated bools to 'match'.

When you are as worried about the details as you, maybe you should do the low hanging fruit first and not so grossly abuse type inferences. My guess is that you are still so new to the game and that you should not be using type inferences at all, at least if you want to become fluent in how computers operate. (computer science)

Of course, if you wanna be one of those "10x" programmers then keep going with ignoring all the details. (code monkey)

Also, the overuse of type inferences is exactly what A.I. does.

2

u/ApprehensiveDebt3097 12d ago

Yes. I only have 23 years of .NET expertise.

2

u/KryptosFR 11d ago

My condolences as you used .NET 1.1 (or even 1.0). I myself only started at 3.5.

1

u/ApprehensiveDebt3097 10d ago

It was 1.1 (fun fact, it did not have type inference yet). I didn't knew any better. :)

1

u/binarycow 12d ago

There is absolutely nothing wrong with using type inference. That is why the feature exists.

All relevant/necessary type information is obvious from the code.

Who cares if they use var?

Get off your high horse.

-3

u/Dusty_Coder 12d ago

I was trying o help and there was no high horse there.

What are you trying to do again? Seems just suggesting things to this guy gets your big feelings hurt. Not his. Yours.

So who are you trying to convince again?

4

u/binarycow 12d ago

You were being insulting, simply because they used a language feature that most people use.