Rodney Mach is HPC Technical Director for Absoft. He can be contacted at [email protected] absoft.com.
The recent availability of low-cost 64-bit platforms, coupled with cheap memory and disk prices, have fueled the migration of 32-bit applications to 64-bit hardware. Many scientific codes, databases, and other applications that require large amounts of memory or do intensive floating-point calculations are best suited to take advantage of these new 64-bit platforms. In this article, I focus on the sometimes-subtle issues surrounding porting existing 32-bit code to 64-bit code.
Nearly all new, 64-bit platforms offer binary compatibility with 32-bit applications to allow simple migration of your 32-bit code. Many applications do not necessarily need to be ported to 64 bit as they run fine within 32-bit limitations. You will want to consider porting your code to 64 bit if your application:
- Can take advantage of more than 4 GB of memory.
- Uses very large files, greater than 2 GB.
- Is floating-point intensive and can take advantage of 64-bit arithmetic.
- Can take advantage of optimized math libraries for 64-bit platforms.
Otherwise, simply recompiling your code as a 32-bit executable will probably suffice. Most well-written applications should port to 64 bit with minimal effort, assuming your code is well written and you are familiar with the topics covered in this article.
ILP32 and LP64 Data Models
A 32-bit environment is referred to as the "ILP32 model" because the C data-type model has 32-bit integers, longs, and pointers. A 64-bit environment uses a different data model, where longs and pointers are size 64 bit (and thus referred to as the "LP64" data model).
All modern 64-bit UNIX-like platforms use the LP64 data model. The future 64-bit Windows release is an LLP64 data model where pointers are 64 bit, with all other basic types remaining unchanged. I focus here on ILP32-to-LP64 porting issues. Table 1 outlines the various differences between ILP32 and 64-bit data models.
Nearly all problems converting code to 64 bit can be summarized in one simple rule: Do not assume that long, int, and pointers have the same size. Any code violating this rule will cause various subtle problems in your application when running under an LP64 data model that will be difficult to track down. Listing 1 has numerous violations of the simple rule and needs some rework before running as a 64-bit application.
The first step in the migration is to get the compiler to catch the 64-bit porting problems. The options will vary by compiler, but for the IBM XL compiler family, useful options are -qwarn64 -qinfo=pro. To compile your code into a 64-bit executable, use the -q64 option (-m64 if you are using GCC; see Table 2 for a list of useful GCC options). Figure 1 is an example of compiling the code in Listing 1.
Missing Prototypes Truncation
If a function is called without a function prototype, the return type is int, which is a 32-bit value. Code that doesn't use prototypes can have unexpected truncation that will lead to a segfault. The compiler caught an example of this problem on line 12 of Listing 1:
char *name = (char *) getlogin();
The compiler assumes the function returns an int and truncates the resulting pointer. This code worked in the ILP32 model because an int and pointer were the same size. In the LP64 model, however, this is no longer true. Even the type-cast will not avoid this because the getlogin() has already been truncated after the return.
The fix in this case is to include the appropriate header file <unistd.h>, which contains the function prototype for getlogin().
Using 32-bit format specifiers with 64-bit longs and pointers may cause your applications to fail. The compiler caught this problem on line 15 of Listing 1:
(void) scanf("%d", &mylong);
Note that scanf will insert a 32-bit value into the variable mylong, leaving the last 4 bytes as garbage. To fix this program, use the %ld format specifier with scanf.
Line 18 shows a similar problem with printf:
printf("mylong: %d pointer: %x \n", mylong, myptr);
The fix here is to use %ld for mylong, and %p instead of %x for myptr.
An example of assignment truncation caught by the compiler occurs on line 16:
myint = mylong;
This doesn't cause a problem in an ILP32 model because both long and int are 32 bits. In the LP64 model, the value of mylong can be truncated when assigned to myint if it is greater than the maximum value of a 32-bit integer.
Parameter Passing Truncation
The next issue the compiler detects occurs on line 20, where the function myfunc is called with a long, though the function takes an int parameter. This can lead to silent truncation of the passed parameter.
Cast truncation can occur when casting a long to an int. For example, on line 22 of Listing 1, the line:
myint = (int) mylong;
causes the cast to truncate the long since int and long are of different size. These types of casts are common in code:
int length = (int) strlen(str);
strlen returns size_t, (which is effectively an unsigned long in LP64) and thus truncation is possible when stored in an int. However, truncation occurs only if the length of str is more than 2 GB, which may be unlikely in your application. Even so, you should always use the appropriate polymorphic type (size_t, uintptr_t, and so on) and not make any assumptions about what the underlying base type is.
More Subtle Problems
The compiler can do a good job of catching many types of portability issues, but you can't depend on the compiler to catch everything for you.
Constants, especially hex or binary values, are likely to be 32-bit specific. For example, the unsigned 32-bit constant 0xFFFFFFFF is often used to test for -1:
#define INVALID_POINTER_VALUE 0xFFFFFFFF
However, on a 64-bit system, this value is not -1 but 4294967295. The correct value on a 64-bit system would be 0xFFFFFFFFFFFFFFFF. To avoid this problem, declare the constant using const and qualify it as signed or unsigned:
const signed int INVALID_POINTER_VALUE = 0xFFFFFFFF;
which works both on 32-bit and 64-bit systems.
Other types of issues to look for are hard-coded constants that depend on assumptions of the ILP32 data model, as in:
int **p; p = (int**)malloc(4 * NO_ELEMENTS);
It assumes the size of a pointer is 4 bytes. This is incorrect in LP64, which has an 8-byte pointer. The corrected version is portable by using sizeof():
int **p; p = (int**)malloc( sizeof(*p) * NO_ELEMENTS);
Beware of incorrect usage of sizeof() in code that makes assumptions such as:
sizeof(int) == sizeof(int *)
This is not true in LP64.
Avoid arithmetic between signed and unsigned numbers. Data is promoted differently in LP64 and ILP32 when unsigned ints are compared to longs and vice versa. To avoid hard-to-detect problems with sign extension, ensure that all operations act on either both signed, or both unsigned, operands.
You would expect that Listing 2 would print out "Answer: -1". However, if you compile this program in an LP64 environment, the answer yields 4294967295. The reason is that the expression (i+j) is an unsigned int expression; when assigned to k, the sign will not extend. To solve this problem, simply follow the rule that all operations act on either both signed, or both unsigned, operands. Changing the line to:
k = i + (int) j
solves the problem and yields the correct result in an LP64 environment.
Problems can occur when the use of a union mixes member types of possibly different size. For example, Listing 3 was from a popular open-source package, and worked on ILP32 but not on LP64. The code assumes that an array of two unsigned shorts takes the same amount of space as a long. This isn't true on an LP64 platform.
To work on LP64, the unsigned long should change to unsigned int. You will want to look at the unions in your code and verify that the members have the same size in LP64.
It is possible that a 32-bit code you ported to 64 bit will fail depending on the 64-bit platform you are on. This may be due to the endianness of the machine. Little-endian tends to hide 64-bit truncation bugs that were missed in the 64-bit porting process.
Listing 4 is an obvious example of code that has such a problem. A declared pointer to an integer is accidentally made to point to a long. In ILP32, this code works and prints "2" because long and int are the same size. In LP64, the pointer is truncated due to the different size of long and int. However, the code still gives the correct value of k as "2" on Little-endian, and an incorrect value of k as "0" on Big-endian.
Table 3 illustrates why the truncation results in different answers depending on the endianness of the platform. On Little-endian, the truncated high-order values contain zero resulting in the correct answer of "2." On Big-endian, the truncated high-order values contain the value 2, thus the answer of "0." In both cases, the truncation is occurring and is a bug. Just be aware that Little-endian can hide truncation that is occurring for small values that you may not detect until porting to Big-endian.
Performance Degradation When Ported to 64-Bit
It is possible that after porting your code to 64 bit, you find that your code actually performs worse. This can happen due to changes in pointer and data sizes in LP64 that cause cache issues, data structure bloat, and data alignment problems.
Cache issues may arise when 32-bit codes that previously fit nicely in the processor cache start to spill out in a 64-bit environment due to the larger pointer size, resulting in performance degradation. Using a tool to analyze cache hits and misses helps you determine if this is the cause of any performance degradation.
Data structures can change size when moving to LP64. This can increase the amount of memory and disk storage your application requires when migrated to 64 bit. For example, the structure in Figure 2 requires 16 bytes in ILP32, while requiring 32 bytes of storage in LP64a 100-percent increase. This is due to the fact that a long is now 64 bits, with additional padding added for alignment by the compiler.
You can help minimize this affect by changing the order of your structure to reduce the storage requirements. If you rearrange the structure to combine the two 32-bit ints together, space is saved by eliminating the padding and the total storage requirement is now only 24 bytes.
Before rearranging your data structures, be sure to evaluate the impact of not having your structure elements ordered by frequency of use, which could cause lower performance due to cache misses.
How To Test for 64 Bit
In some cases, 32-bit and 64-bit versions of an interface are difficult to avoid. In the headers, these would be distinguishable by the use of a test macro. Unfortunately, the specific macro to test for varies depending on the platform, compiler, and version of the compiler you are using. For example, GCC Version 3.4 and later defines __LP64__ when compiling with -m64 for all 64-bit platforms. However, GCC versions before 3.4 were platform and operating-system specific.
Your native compiler may use a different macro than __LP64__. For example, the IBM XL compilers use the macro __64bit__ when compiling with -q64. Some platforms use _LP64, and others you can test for use __WORDSIZE. Consult your vendor and compiler documentation for information on which macro is appropriate. Listing 5 works for a wide variety of platforms and compilers.
A typical problem when migrating to a 64-bit platform is reading or sharing data with legacy 32-bit applications. For example, legacy 32-bit code may have stored structures to disk as binary data files. Now you would like to read those files with your 64-bit code. However, problems arise now that the size of structures may be different in an LP64 environment.
For new programs that must run on both 32-bit and 64-bit platforms, it is suggested to avoid the use of data types that change size between LP64 and ILP32 if possible (such as long). If not, then use the data fixed-width integral types in <inttypes.h> for binary interface data when sharing data between 32 bit and 64 bit, either through files or over the network.
For example, consider Listing 6. Ideally, this program would work on both 32-bit and 64-bit platforms, and read each other's data files. This won't work because the size of long varies between ILP32 and LP64. To fix the code to do this, variable foo in the on_disk struct should be declared as type int32_t instead of long. This fixed type ensures the same size data is written out for both existing ILP32 data and ported LP64 data.
Problems Mixing Fortran and C
Many scientific applications call Fortran applications from C/C++. Fortran by itself typically doesn't have issues when migrating to a 64-bit platform because Fortran data types have specific bit sizes. However, mixing Fortran and C codes can cause problems. For example, the C program in Listing 7 calls the Fortran subroutine named foo in Listing 8.
When linking the files together, the resulting program should print the value of variable i as "5000." In an LP64 environment, however, the program prints "0," as in Listing 9, because in LP64 mode, subroutine foo is now passing a 64-bit argument (by address). However, the Fortran subroutine is expecting a 32-bit argument. To fix this code, declare the Fortran subroutine variable i as INTEGER*8 (which is equivalent to the C long).
64-bit platforms are the future to solving larger and more difficult scientific and business problems. Most well-written applications should port easily to 64 bit, but beware the differences in the ILP32 and LP64 data models while porting to ensure you have a smooth porting process.