Friday, October 26, 2012

The Bits, Nibbles, and Bytes of Life

Many new developers struggle with the idea of bits and bytes at a low level, and why they are useful to us as software developers since we have high level languages that abstract it away from us. Most of us are aware that a computers language is in 1s and 0s. Glorious 1s and 0s reign supreme in the digital world. Analog signals such as the human voice are analyzed and converted into a static on or off signal. Sequences of bits come together for a common purpose: to represent and execute information at the programmers whim. Sometimes, being in charge of development can give someone a God complex. We have all the control. Even though there are occasions where the computer (doing exactly what we told it to do, no less) is seemingly rebelling against our authority, we can eventually tame the beast into submission.

Still, the emphasis on the underlying beauty of the digital machine isn't as prominent as I feel that it should be. Consider this simple program in C++:
int a = 0, b = 0, c = 0;
a = 5;
b = 5;
c = a + b;
It's so simple. Even the people who have never seen a line of code could reasonably guess at what's happening here. We are declaring three variables, a, b, and c, and we are assigning the constant value of 5 to both a and b, and finally, storing the result of a + b into c. How could a computer not understand something so simple? The truth of the matter is that this 4 line program gets converted into, with collective optimism, a 17 line program in the computer language not including a lot of the setup and finalizing "stuff". Here's an extracted segment of just the 4 lines we see above in assembly:
; 4    : int a = 0, b = 0, c = 0;
mov DWORD PTR _a$[ebp], 0
mov DWORD PTR _b$[ebp], 0
mov DWORD PTR _c$[ebp], 0
; 5    :
; 6    : a = 5;
mov DWORD PTR _a$[ebp], 5
; 7    : b = 5;
mov DWORD PTR _b$[ebp], 5
; 8    :
; 9    : c = a + b;
mov eax, DWORD PTR _a$[ebp]
add eax, DWORD PTR _b$[ebp]
mov DWORD PTR _c$[ebp], eax

The first three lines store the constant 0 in the storage locations of a, b, and c. Remember, that these names are meaningless to a computer. Each variable has a memory address. This code is still an abstraction which is translated to the computers CPU language. The commands like mov and add will be translated to a binary op code such as "0110", which in turn is translated to an electrical signal.

Before I get too excited, there is a point to my madness. How does this make us better developers? How does knowing what code is translated to at the raw, naughty level of inside the machine useful?

The simple answer is this: Everything we learn can be applied in places we would least expect.

There's another reason, though. I'm going to use C# and the .NET CLR (Common Language Runtime) as an example. The + sign can be used to add numbers, but it can also be used to add Strings. In addition, there's an explicit function called String.Concat. What happens at the lower level? How are these different? Are they different? Consider this C# Snippet:
String str = Console.ReadLine();
String str2 = Console.ReadLine();
String test = str + str2;
String test2 = String.Concat("hi", "test2");
I read two strings from the user and store them in str and str2. I then explicitly call String.Concat with two compile time constant Strings. (Compile time meaning these are known at the time the code is compiled, as opposed to str and str2 which are known at run time because the user has to enter them). So what happens? What do you think? They're the same! Here's the code compiled into the intermediate language of the above snippet:
.locals init ([0] string str,//Declare the local variables by index
[1] string str2,
[2] string test,
[3] string test2)
IL_0000: nop //No meaningful operation is performed
IL_0001: call string [mscorlib]System.Console::ReadLine()
IL_0006: stloc.0 //This pops the item off the input buffer and into the variable str
IL_0007: call string [mscorlib]System.Console::ReadLine()
IL_000c: stloc.1 //This pops the item off the input buffer and into the variable str2
IL_000d: ldloc.0 //The next two lines push the local variables back onto the evaluation stack to be passed as parameters to concat
IL_000e: ldloc.1
IL_000f: call string [mscorlib]System.String::Concat(string,string)
IL_0014: stloc.2
IL_0015: ldstr "hi" //Load a string onto the stack directly
IL_001a: ldstr "test2"
IL_001f: call string [mscorlib]System.String::Concat(string,string)
IL_0024: stloc.3
IL_0025: ret //Ends the function
But wait! There are two calls to String.Concat? The compiler will convert the + sign between two String types into a Concat call. So at run time, there is no difference. There's also a reason I used "ReadLine" to get input from the user. If we use compile time strings like str = "hi" and str2 = "test2" and do str + str2, the compiler will be clever and combine the strings directly into the IL code instead of making a String.Concat call. This saves CPU cycles at run time. This isn't apparently useful, but to extend this concept is an important lesson. The reality is, in typical business development, it probably won't be necessary to think in these terms. In game development, however, this can make or break some performance factors. Game developers are always thinking of how they can save precious CPU cycles. Although I am far from experienced, this is a lesson I learned while working on Armor Potions.

Consider another example. When we think of the remainder of two numbers, what do we think of? Bonus points for anyone who successfully mentioned division. Well, sometimes a compiler is very clever. Using .NET as an example, and this is what originally got me interested in looking at the compiled code, is that if we take the remainder of dividing by 2, the .NET compiler uses no division at all! In fact, it's very clever while using the binary representations of the integers. Division, to a computer, is often compound subtraction which wastes cycles. So this optimization by the compiler is an interesting point. However, there are other solutions for getting the remainder of a number when dividing by two, one that saves even more cycles.

Consider the binary representation of the number 15, which is 1111. For those who don't know, binary is a base 2 number system. This means that, from left to right, the digits represent a power of two. The above number can be constructed as 8 * 1 + 4 * 1 + 2 * 1 + 1 * 1 = 15. This knowledge helps us in some cases, and because it's the computers language, we will use it to our advantage. Note that this is an odd number. What is the definition of an odd number? It's 2n + 1. Note the 1! An even number is simply 2n. 2 times any number n is an even number. In this case, if we add 1 to an even number, it becomes odd. The rightmost digit in a binary number represents 1. So if that digit is 1, then the number is odd. If that digit is 0, it is even. So any number ANDed with 1, and if it equals 1,  it is odd. In C++, it would look like this:
15 & 1 == 1; 
12 & 1 == 0
and so on. This works because with an AND 1, we're comparing the right most digit to compare the value. It should be noted that these are not that much faster than normal methods. The compiler is sometimes clever and does the optimizations for us. We shouldn't be spending all of our valuable time looking for these subtleties. My point is that these exist, and in some cases, such as my example with collision in a previous post, it can actually be a solution. Things like using bit strings to represent the collision behavior of a tile is an oft used mechanic. It's important to understand that these can be very useful in the developer tool kit. We never know when they will come in handy.

No comments:

Post a Comment