Added ReadOnlySpan<char> APIs and moved the throws into a cold section#54
Conversation
| private void TestGeneral_Float(string sut, float expected_value) | ||
| { | ||
| Assert.Equal(expected_value, FastFloatParser.ParseFloat(sut)); | ||
| Assert.Equal(expected_value, FastFloatParser.ParseFloat(sut.AsSpan())); |
There was a problem hiding this comment.
The ReadOnlySpan path is almost the same as for string, so I think just these tests are enough.
| if (s == null) | ||
| throw new ArgumentNullException(nameof(s)); | ||
| ThrowArgumentNull(); | ||
| static void ThrowArgumentNull() => throw new ArgumentNullException(nameof(s)); |
There was a problem hiding this comment.
This is C#'s local function. With these the throw can be moved to a (non-inlineable) method on the cold side. So the actual methods gets smaller. See this sharplab on how it looks like.
There was a problem hiding this comment.
So the actual methods gets smaller.
Can you elaborate on the benefits ? (I am not being argumentative, it is an honest question. Your code is quite certainly fine.)
There was a problem hiding this comment.
Smaller code is almost always preferable, as the I-cache can be utilized better.
With the "inline" throw new the JIT moves the exception-part to the end of the generated code, but it's quite a bit of ceremony needed to setup the exception and throw it.
With the "throw-helper"-trick the exception-part is moved into a separate method, thus the "working"-part of the method gets smaller. As exceptions are exceptional and in the case of an exception the additional indirection won't harm.
This is a "trick" we use quite a lot in the .NET runtime (but only on really hot-path methods).
For a simple example the difference is
InlineThrow
L0000: push rsi
L0001: sub rsp, 0x30
L0005: xor eax, eax
L0007: mov [rsp+0x28], rax
L000c: test rdx, rdx
L000f: je short L0028
L0011: add rdx, 0xc
L0015: mov [rsp+0x28], rdx
L001a: mov rax, [rsp+0x28]
L001f: movzx eax, word ptr [rax]
L0022: add rsp, 0x30
L0026: pop rsi
L0027: ret
L0028: mov rcx, 0x7ffe9cff3640
L0032: call 0x00007ffefcaaa370
L0037: mov rsi, rax
L003a: mov ecx, 1
L003f: mov rdx, 0x7ffea483c080
L0049: call 0x00007ffefc9b0b70
L004e: mov rdx, rax
L0051: mov rcx, rsi
L0054: call System.ArgumentNullException..ctor(System.String)
L0059: mov rcx, rsi
L005c: call 0x00007ffefca80b00
L0061: int3ThrowHelper
L0000: sub rsp, 0x28
L0004: xor eax, eax
L0006: mov [rsp+0x20], rax
L000b: test rdx, rdx
L000e: je short L0026
L0010: add rdx, 0xc
L0014: mov [rsp+0x20], rdx
L0019: mov rax, [rsp+0x20]
L001e: movzx eax, word ptr [rax]
L0021: add rsp, 0x28
L0025: ret
L0026: call C.<M1>g__ThrowArgumentNull|1_0()
L002b: int3If in a future version of the JIT fixed doesn't prevent inlining the benefit will be even bigger.
There was a problem hiding this comment.
Ok. So the binary will still end up being about the same size, but JIT-compiled version will have more clearly identifiable "cold regions" which is evidently beneficial. But this implies that the compiler can't do this on its own.
If I compile C++ code like this:
int square(int num) {
if(num == 0) throw std::runtime_error("fsd");
return num * num;
}I always expected that the throw would jump to some cold region of the binary code and do its thing.
I can't verify that C++ do this... even though it seems like an obvious optimization. Instead, you seem to get code that looks like the C# assembly... the exception handling is interleaved with the exception-free code.
If in a future version of the JIT fixed doesn't prevent inlining the benefit will be even bigger.
Are you saying that if a function uses fixed, then it can't be inlined? If so, I'd really like to know.
(Please again note that I am not being argumentative. My curiosity is genuine.)
There was a problem hiding this comment.
this implies that the compiler can't do this on its own.
Current (.NET 5) version of the JIT doesn't do this. But there is some on-going work to make this better. Maybe even C# will move things around...it's not clear at the moment. Will see...
I also expect this to be an obvious optimization.
Are you saying that if a function uses fixed, then it can't be inlined?
I re-checked things, and have to revert this statement a bit.
The JIT is sometimes able to inline methods with fixed expressions, attributing the method with AggressiveInlining makes it more likely to do so.
.NET has to ensure that a fixed expression (= pinning) gets unpinned no matter how control leaves the scope of fixed -- conceptionally it's enclosed in a try-finally. So things may become complicated and with the limited time-bugdet among other constraints that a JIT has, the pattern on when a method with fixed gets inlined isn't -- at least for me -- very predictable. Best is to look at the generated asm and JIT-dumps to see what's happening.
As a rule of thumb one should use fixed as early as possible, so that inner / hot code can work on the already fixed variables. Excately as done in this project.
(Please again note that I am not being argumentative. My curiosity is genuine.)
Absolutely no problem. As a long-term follower of your blog it's a honor to have a discussion with you 😄
There was a problem hiding this comment.
(I'm not registered on twitter, so a further note here)
In .NET methods that never return (e.g. throw) won't be inlined. By splitting the exception-part into a separeate method, the working-part will get eligible for inlining.*
* if actually inlined or not depends on a plethora of factors.
There was a problem hiding this comment.
@gfoidl Throwing methods do not get inlined. Ah. That's a major argument against throwing within small methods then!!!
|
@CarlVerret I recommend merging. It would be nice to update the README to document this part of the API but it can be done separately. |
Fixes #50