Why does :
short a=0;
Console.Write(Marshal.SizeOf(a));
shows 2
But if I see the IL code i see :
/*1*/ IL_0000: ldc.i4.0
/*2*/ IL_0001: stloc.0
/*3*/ IL_0002: ldloc.0
/*4*/ IL_0003: box System.Int16
/*5*/ IL_0008: call System.Runtime.InteropServices.Marshal.SizeOf
/*6*/ IL_000D: call System.Console.Write
The LDC at line #1 indicates :
Push 0 onto the stack as int32.
So there must been 4
bytes occupied.
But sizeOf
shows 2
bytes...
What am I missing here ? how many byte does short actually take in mem?
I've heard about a situations where there is a padding to 4 bytes so it would be faster to deal with. is it the case also here?
(please ignore the syncRoot and the GC root flag byte i'm just asking about 2 vs 4)
The CLI specification is very explicit about the data types that are allowed to be on the stack. The short 16-bit integer is not one of them, so such types of integers are converted to 32-bit integers (4 bytes) when they are loaded onto the stack.
Partition III.1.1 contains all of the details:
1.1 Data types
While the CTS defines a rich type system and the CLS specifies a subset that can be used for language interoperability, the CLI itself deals with a much simpler set of types. These types include user-defined value types and a subset of the built-in types. The subset, collectively called the "basic CLI types", contains the following types:
- A subset of the full numeric types (
int32
,int64
,native int
, andF
).- Object references (
O
) without distinction between the type of object referenced.- Pointer types (
native unsigned int
and&
) without distinction as to the type pointed to.Note that object references and pointer types can be assigned the value
null
. This is defined throughout the CLI to be zero (a bit pattern of all-bits-zero).1.1.1 Numeric data types
The CLI only operates on the numeric types
int32
(4-byte signed integers),int64
(8-byte signed integers),native int
(native-size integers), andF
(native-size floating-point numbers). However, the CIL instruction set allows additional data types to be implemented:Short integers: The evaluation stack only holds 4- or 8-byte integers, but other locations (arguments, local variables, statics, array elements, fields) can hold 1- or 2-byte integers. For the purpose of stack operations the bool and char types are treated as unsigned 1-byte and 2-byte integers respectively. Loading from these locations onto the stack converts them to 4-byte values by:
- zero-extending for types unsigned int8, unsigned int16, bool and char;
- sign-extending for types int8 and int16;
- zero-extends for unsigned indirect and element loads (
ldind.u*
,ldelem.u*
, etc.);; and- sign-extends for signed indirect and element loads (
ldind.i*
,ldelem.i*
, etc.)Storing to integers, booleans, and characters (
stloc
,stfld
,stind.i1
,stelem.i2
, etc.) truncates. Use theconv.ovf.*
instructions to detect when this truncation results in a value that doesn't correctly represent the original value.[Note: Short (i.e., 1- and 2-byte) integers are loaded as 4-byte numbers on all architectures and these 4-byte numbers are always tracked as distinct from 8-byte numbers. This helps portability of code by ensuring that the default arithmetic behavior (i.e., when no
conv
orconv.ovf
instruction is executed) will have identical results on all implementations.]Convert instructions that yield short integer values actually leave an
int32
(32-bit) value on the stack, but it is guaranteed that only the low bits have meaning (i.e., the more significant bits are all zero for the unsigned conversions or a sign extension for the signed conversions). To correctly simulate the full set of short integer operations a conversion to a short integer is required before thediv
,rem
,shr
, comparison and conditional branch instructions.
…and so on.
Speaking speculatively, this decision was probably made either for architectural simplicity or for speed (or possibly both). Modern 32-bit and 64-bit processors can work more effectively with 32-bit integers than they can with 16-bit integers, and since all integers that can be represented in 2 bytes can also be represented in 4 bytes, this behavior is reasonable.
The only time it would really make sense to use a 2 byte integer as opposed to a 4 byte one is if you were more concerned with memory usage than you were with execution speed/efficiency. And in that case, you'd need to have a whole bunch of those values, probably packed into a structure. And that is when you'd care about the result of Marshal.SizeOf
.
It is pretty easy to tell what's going on by taking a look at the available LDC instructions. Note the limited set of operand types available, there is no version available that load a constant of type short. Just int, long, float and double. These limitations are visible elsewhere, the Opcodes.Add instruction for example is similarly limited, no support for adding variables of one of the smaller types.
The IL instruction set was very much designed intentionally this way, it reflects the capabilities of a simple 32-bit processor. The kind of processor to think of is the RISC kind, they had their hay-day in the nineteens. Lots of 32-bit cpu registers that can only manipulate 32-bit integers and IEEE-754 floating point types. The Intel x86 core is not a good example, while very commonly used, it is a CISC design that actually supports loading and doing arithmetic on 8-bit and 16-bit operands. But that's more of a historical accident, it made mechanical translation of programs easy that started on the 8-bit 8080 and 16-bit 8086 processors. But such capability doesn't come for free, manipulating 16-bit values actually costs an extra cpu cycle.
Making IL a good match with 32-bit processor capabilities clearly makes the job of the guy implementing a jitter much simpler. Storage locations can still be a smaller size, but only loads, stores and conversions need to be supported. And only when needed, your 'a' variable is a local variable, one that occupies 32-bits on the stack frame or cpu register anyway. Only stores to memory need to be truncated to the right size.
There is otherwise no ambiguity in the code snippet. The variable value needs to be boxed because Marshal.SizeOf() takes an argument of type object. The boxed value identifies the type of value by the type handle, it will point to System.Int16. Marshal.SizeOf() has the built-in knowledge to know it takes 2 bytes.
These restrictions do reflect on the C# language and cause inconsistency. This kind of compile error forever befuddles and annoys C# programmers:
byte b1 = 127;
b1 += 1; // no error
b1 = b1 + 1; // error CS0266
Which is a result of the IL restrictions, there is no add operator that takes byte operands. They need to be converted to the next larger compatible type, int in this case. So it works on a 32-bit RISC processor. Now there's a problem, the 32-bit int result needs to be hammered back into a variable that can store only 8-bits. The C# language applies that hammer itself in the 1st assignment but illogically requires a cast hammer in the 2nd assignment.
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With