Float Python vs C¶
Python and C handle floating-point numbers differently. Understanding these differences explains performance characteristics and memory usage patterns.
Introductory Example¶
A surprising result from floating-point arithmetic.
1. The Classic Problem¶
Adding 0.1 twice doesn't always equal 0.2.
def main():
a = 0.1
b = 0.1
c = a + b
print(c == 0.2) # True
if __name__ == "__main__":
main()
2. A Surprising Failure¶
But 0.1 + 1.1 doesn't equal 1.2.
def main():
a = 0.1
b = 1.1
c = a + b
print(c == 1.2) # False
if __name__ == "__main__":
main()
# Why? Check the actual values
print(f"{0.1 + 1.1:.20f}") # 1.20000000000000017764
print(f"{1.2:.20f}") # 1.19999999999999995559
3. Binary Representation Cause¶
Both languages share this issue due to IEEE 754.
# Neither 0.1 nor 1.1 are exactly representable
print(f"{0.1:.20f}") # 0.10000000000000000555
print(f"{1.1:.20f}") # 1.10000000000000008882
print(f"{1.2:.20f}") # 1.19999999999999995559
# The sum accumulates errors differently
print(f"{0.1 + 1.1:.20f}") # 1.20000000000000017764
Python Float Implementation¶
How CPython stores floating-point numbers.
1. PyFloatObject Structure¶
Python floats are full objects with metadata.
// CPython internal structure (simplified)
typedef struct {
PyObject_HEAD // Reference count + type pointer
double ob_fval; // 64-bit IEEE 754 value
} PyFloatObject;
# Every float is an object
x = 3.14
print(type(x)) # <class 'float'>
# Has object identity
print(id(x)) # Memory address
2. Memory Overhead¶
Python floats consume more memory than the raw value.
import sys
# Single float object size
x = 3.14
print(sys.getsizeof(x)) # 24 bytes
# Compare: the actual value is only 8 bytes (64-bit double)
# Extra 16 bytes for:
# - Reference count (8 bytes)
# - Type pointer (8 bytes)
3. Dynamic Typing Cost¶
Type determined at runtime, not compile time.
def add_floats(a, b):
return a + b # Type check happens at runtime
# Python must:
# 1. Check type of a
# 2. Check type of b
# 3. Look up __add__ method
# 4. Perform addition
# 5. Create new float object for result
result = add_floats(1.5, 2.5)
C Float Implementation¶
How C stores floating-point numbers.
1. Direct Memory Storage¶
C floats are raw binary values without metadata.
#include <stdio.h>
int main() {
float a = 3.14f; // 32-bit IEEE 754
double b = 3.14; // 64-bit IEEE 754
printf("float: %f\n", a);
printf("double: %lf\n", b);
return 0;
}
2. Fixed Size¶
Size determined at compile time.
#include <stdio.h>
int main() {
printf("sizeof(float): %zu bytes\n", sizeof(float)); // 4 bytes
printf("sizeof(double): %zu bytes\n", sizeof(double)); // 8 bytes
return 0;
}
3. Static Typing¶
Type known at compile time enables optimization.
double add_doubles(double a, double b) {
return a + b; // Single CPU instruction
}
// Compiler knows exact types
// No runtime type checking
// Direct floating-point addition
Key Differences¶
Side-by-side comparison of Python and C floats.
1. Comparison Table¶
| Feature | Python float |
C float / double |
|---|---|---|
| Typing | Dynamic | Static |
| Size | 24 bytes (object) | 4 / 8 bytes |
| Storage | Heap with metadata | Stack or heap directly |
| Performance | Slower (overhead) | Faster (direct access) |
| Precision | 64-bit (always) | 32-bit or 64-bit |
| Overflow | Returns inf / -inf |
Undefined behavior |
2. Memory Layout Visualization¶
# Python float memory layout (24 bytes total)
# +------------------+
# | Reference Count | 8 bytes
# +------------------+
# | Type Pointer | 8 bytes -> points to float type
# +------------------+
# | ob_fval (double) | 8 bytes -> actual IEEE 754 value
# +------------------+
# C double memory layout (8 bytes total)
# +------------------+
# | IEEE 754 value | 8 bytes -> that's it!
# +------------------+
3. Array Memory Comparison¶
import sys
# Python list of floats
py_list = [1.0, 2.0, 3.0, 4.0, 5.0]
list_size = sys.getsizeof(py_list)
objects_size = sum(sys.getsizeof(x) for x in py_list)
print(f"Python list: {list_size} + {objects_size} = {list_size + objects_size} bytes")
# NumPy array (C-style storage)
import numpy as np
np_array = np.array([1.0, 2.0, 3.0, 4.0, 5.0], dtype=np.float64)
print(f"NumPy array: {np_array.nbytes} bytes (data only)")
print(f"NumPy total: {sys.getsizeof(np_array)} bytes (with overhead)")
Performance Implications¶
How implementation affects speed.
1. Operation Overhead¶
Python has significant per-operation cost.
import time
# Python float operations
def python_sum(n):
total = 0.0
for i in range(n):
total += 0.1
return total
start = time.perf_counter()
result = python_sum(1_000_000)
elapsed = time.perf_counter() - start
print(f"Python loop: {elapsed:.4f} seconds")
2. NumPy Speedup¶
NumPy uses C-style operations internally.
import numpy as np
import time
# NumPy vectorized operations
def numpy_sum(n):
arr = np.full(n, 0.1)
return np.sum(arr)
start = time.perf_counter()
result = numpy_sum(1_000_000)
elapsed = time.perf_counter() - start
print(f"NumPy sum: {elapsed:.4f} seconds")
# Typically 10-100x faster than Python loop
3. Benchmark Comparison¶
import numpy as np
import time
n = 1_000_000
# Method 1: Python loop
start = time.perf_counter()
total = 0.0
for _ in range(n):
total += 0.1
python_time = time.perf_counter() - start
# Method 2: NumPy
start = time.perf_counter()
total = np.sum(np.full(n, 0.1))
numpy_time = time.perf_counter() - start
print(f"Python: {python_time:.4f}s")
print(f"NumPy: {numpy_time:.4f}s")
print(f"Speedup: {python_time/numpy_time:.1f}x")
Precision Differences¶
Both use IEEE 754, but with different defaults.
1. Python Always Uses Double¶
Python float is always 64-bit.
import sys
# No way to create 32-bit float in pure Python
x = 3.14
print(f"Mantissa digits: {sys.float_info.mant_dig}") # 53
print(f"Decimal digits: {sys.float_info.dig}") # 15
2. C Offers Both Precisions¶
C provides 32-bit and 64-bit options.
#include <stdio.h>
#include <float.h>
int main() {
// 32-bit float
printf("float digits: %d\n", FLT_DIG); // 6
printf("float mantissa: %d\n", FLT_MANT_DIG); // 24
// 64-bit double
printf("double digits: %d\n", DBL_DIG); // 15
printf("double mantissa: %d\n", DBL_MANT_DIG);// 53
return 0;
}
3. NumPy Provides Both¶
Use NumPy for 32-bit floats in Python.
import numpy as np
# 32-bit float
f32 = np.float32(3.14159265358979)
print(f"float32: {f32}") # 3.1415927 (lost precision)
# 64-bit float
f64 = np.float64(3.14159265358979)
print(f"float64: {f64}") # 3.14159265358979
# Check precision
print(f"float32 eps: {np.finfo(np.float32).eps:.2e}") # ~1.2e-07
print(f"float64 eps: {np.finfo(np.float64).eps:.2e}") # ~2.2e-16
Overflow Handling¶
Python and C handle extreme values differently.
1. Python Graceful Overflow¶
Python converts to infinity without crashing.
import sys
# Approach maximum
large = sys.float_info.max
print(f"Max float: {large}") # ~1.8e308
# Overflow to infinity
overflow = large * 2
print(f"Overflow: {overflow}") # inf
# Operations continue
print(overflow + 1) # inf
print(overflow * -1) # -inf
print(1 / overflow) # 0.0
2. C Silent Overflow¶
C may produce undefined behavior.
#include <stdio.h>
#include <float.h>
int main() {
float large = FLT_MAX;
printf("Max float: %e\n", large);
// Overflow - behavior is implementation-defined
float overflow = large * 2.0f;
printf("Overflow: %e\n", overflow); // May be inf or garbage
return 0;
}
3. Safe Overflow Detection¶
Check for infinity in both languages.
import math
def safe_multiply(a, b):
"""Multiply with overflow detection."""
result = a * b
if math.isinf(result):
raise OverflowError(f"Overflow: {a} * {b}")
return result
try:
x = safe_multiply(1e200, 1e200)
except OverflowError as e:
print(e) # Overflow: 1e+200 * 1e+200
Underflow Handling¶
Very small values approaching zero.
1. Python Gradual Underflow¶
Python supports subnormal numbers.
import sys
# Approach minimum
small = sys.float_info.min
print(f"Min positive: {small}") # ~2.2e-308
# Gradual underflow (subnormal)
tiny = small / 1e10
print(f"Subnormal: {tiny}") # ~2.2e-318
# Complete underflow to zero
zero = small / 1e308
print(f"Underflow: {zero}") # 0.0
2. C Underflow Behavior¶
C behavior depends on compiler settings.
#include <stdio.h>
#include <float.h>
int main() {
double small = DBL_MIN;
printf("Min positive: %e\n", small);
// May underflow to zero or subnormal
double tiny = small / 1e10;
printf("Tiny: %e\n", tiny);
return 0;
}
Practical Recommendations¶
When to use each approach.
1. Use Python Floats For¶
General-purpose scripting and prototyping.
# Quick calculations
price = 19.99
tax_rate = 0.0825
total = price * (1 + tax_rate)
print(f"Total: ${total:.2f}")
# Readability over performance
def calculate_interest(principal, rate, years):
return principal * (1 + rate) ** years
2. Use NumPy For¶
Performance-critical numerical work.
import numpy as np
# Large-scale computation
data = np.random.randn(1_000_000)
mean = np.mean(data)
std = np.std(data)
# Matrix operations
A = np.random.randn(1000, 1000)
B = np.random.randn(1000, 1000)
C = A @ B # Fast matrix multiplication
3. Use C/Cython For¶
Maximum performance requirements.
# When even NumPy isn't fast enough:
# 1. Write critical code in C
# 2. Use Cython for C-like speed with Python syntax
# 3. Use Numba JIT compilation
from numba import jit
@jit(nopython=True)
def fast_sum(arr):
total = 0.0
for x in arr:
total += x
return total
Summary Table¶
Quick reference for Python vs C floats.
1. When to Choose What¶
| Use Case | Recommendation |
|---|---|
| Scripting | Python float |
| Data science | NumPy arrays |
| Real-time systems | C double |
| Embedded systems | C float (32-bit) |
| Financial | Python Decimal |
| Scientific computing | NumPy + SciPy |
2. Memory Rule of Thumb¶
import sys
import numpy as np
# Single value
print(f"Python float: {sys.getsizeof(1.0)} bytes") # 24
print(f"NumPy float64: {np.float64(1.0).nbytes} bytes") # 8
# Array of 1000 floats
py_list = [1.0] * 1000
np_arr = np.ones(1000, dtype=np.float64)
py_size = sys.getsizeof(py_list) + sum(sys.getsizeof(x) for x in py_list)
np_size = np_arr.nbytes
print(f"Python list: ~{py_size} bytes") # ~32,000
print(f"NumPy array: {np_size} bytes") # 8,000
print(f"Ratio: {py_size/np_size:.1f}x") # ~4x more memory