1

While writing conversion of generic enum to int strange things happened around unsafe read of sbyte type to byte.

The folloging examples were tested with .Net 6.0 on AMD x64 machine.

Example 1: Inconsistency Debug vs. Release

The following code generates different output in Debug and in Release mode:

class Program
{
    static void Main()
    {
        byte byteValue = ReadAsByteValue(sbyteValue: -1);

        Console.WriteLine(byteValue);

        // OUTPUT DEBUG:   255
        // OUTPUT RELEASE: -1
    }

    static unsafe byte ReadAsByteValue(sbyte sbyteValue)
    {
        return *(byte*)(&sbyteValue);
    }
}

Since type byte does not have value -1, I suppose that in Release mode the compiler returns sbyte instead of byte.

Example 2A: Inconsistency in Release mode

class Program
{
    static void Main()
    {
        var value1 = GetIntValueEncapsulated((sbyte)-1, true);
        var value2 = GetIntValue((sbyte)-1);

        Console.WriteLine($"{value1} vs. {value2}");

        foreach (var value in Array.Empty<sbyte>())
        {
            GetIntValueEncapsulated(value, true);
        }

        // OUTPUT RELEASE: -1 vs. 255
    }

    static int GetIntValueEncapsulated<T>(T value, bool trueFalse)
        where T : unmanaged
    {
        if (trueFalse)
        {
            return GetIntValue(value);
        }
        else
        {
            throw new NotImplementedException($"Not implemented for size: {Unsafe.SizeOf<T>()}");
        }
    }

    static unsafe int GetIntValue<T>(T value)
        where T : unmanaged
    {
        return *(byte*)(&value);
    }
}

Example 2B: Commenting out empty foreach changes results

var value1 = GetIntValueEncapsulated((sbyte)-1, true);
var value2 = GetIntValue((sbyte)-1);

Console.WriteLine($"{value1} vs. {value2}");

//foreach (var value in Array.Empty<sbyte>())
//{
//    GetIntValueEncapsulated(value, true);
//}

// OUTPUT RELEASE: -1 vs. -1

Example 2C: Non-functional change on the Exception line changes results

Starting with Example 2A and replacing line:

throw new NotImplementedException($"Not implemented for size: {Unsafe.SizeOf<T>()}"); 

with line:

throw new NotImplementedException($"Not implemented for size: " + Unsafe.SizeOf<T>()); 

gives output:

// OUTPUT RELEASE: 255 vs. 255

Questions

  • What is the exact cause of these differences?
  • How to force compiler in the Release mode to behave as expected? (i.e. as in the Debug mode)
frakon
  • 1,948
  • 2
  • 22
  • 26

1 Answers1

3

Example 1: Inconsistency Debug vs. Release

  1. You should know that the overload method chosen by the compiler in this example is WriteLine(int). So if you call WriteLine((uint)byteValue) or WriteLine(byteValue.ToString()), you'll get the result 255.

  2. The compiler prefers 32-bit signed integer types today and will encode sbyteValue: -1 to ffffffff not 000000ff because of the efficiency.

  3. The side-effect of optimzation in release mode.

// Release
ldc.i4.m1
call uint8 C::ReadAsByteValue(int8)
call void [System.Console]System.Console::WriteLine(int32)

// Debug
ldc.i4.m1
call uint8 C::ReadAsByteValue(int8)
stloc.0
ldloc.0
call void [System.Console]System.Console::WriteLine(int32)

You can see in debug mode, it uses a local variable to transmit the byte. The docs of stloc says.

Storing into locals that hold an integer value smaller than 4 bytes long truncates the value as it moves from the stack to the local variable.

Since there is no middleman in release mode, no truncation, WriteLine method will use the return value ffffffff in the register as is. The effect also applies to short+ushort with the same reason.


Example 2A: Inconsistency in Release mode

According to the explaination above the values returned from GetIntValueEncapsulated or GetIntValue in the registers are always ffffffff.

Sorry I'm not a JIT expert, so I can't tell the implemention detail. What I know is this is caused by method inlining. Apply NoInlining to the method, the output is -1.

[MethodImpl(MethodImplOptions.NoInlining)]
static unsafe int GetIntValue<T>(T value)

The following code can be used to simulate the effect of forced inlining.

sbyte a = -1;
var value2 = *(byte*)(&a);

When the method is inline the compiler uses the following instrument to set the value of value2 which force the type to byte.

movzx       edi,byte ptr [rsp+4Ch]

To enforce the expected result

  1. Use a deterministic method.
Console.WriteLine((uint)byteValue); // Console.WriteLine(uint)
Console.WriteLine(byteValue.ToString()); // byte.ToString()
  1. Convert to uint* first
static unsafe byte ReadAsByteValue(sbyte sbyteValue)
    => (byte)*(uint*)(&sbyteValue);
static unsafe int GetIntValue<T>(T value) where T : unmanaged
    => (byte)*(uint*)(&value);
shingo
  • 18,436
  • 5
  • 23
  • 42
  • 1
    How do you explain why commenting-out the `foreach (var value in Array.Empty())` (which ostensibly does nothing) changes the results? – Matthew Watson Jul 03 '22 at 09:28
  • @shingo I still do not understand how Example 2B and 2C work: what caused the change of behaviour there? – frakon Jul 03 '22 at 10:07
  • Sorry I can't explain it well now, I only know that the second call to `GetIntValue` is inline by jit when foreach exists, it uses a simple way to copy the constant value, but I don't know why jit decides to do so. – shingo Jul 03 '22 at 11:05
  • @shingo I marked your answer as Accepted, but could you please answer more properly the second question: "How to force the compiler in the Release mode to behave as expected"?. Could using Unsafe.As() do the trick? Using NoInlining does not enforce it always (tried it). My opinion about the expected result: the "Example 1" should always end up with 255, because: (sbyte)-1 is 11111111 in bits. 11111111 in bits is (byte)255. (byte)255 is (int)255. So: could you please recommend 1 or 2 steps to enforce the expected result? – frakon Jul 03 '22 at 16:15
  • 1
    @frakon Added approaches at the end. "(sbyte)-1 is 11111111 in bits" this is only true in theory, In fact a single sbyte or byte in memory is 4 bytes long. – shingo Jul 04 '22 at 14:12