Numeric Types (Overview) — Optimize¶
Overview¶
10+ optimization exercises covering numeric type selection, memory layout, computation efficiency, and precision.
Exercise 1 — Choose Smaller Types for Large Arrays 🟢¶
Description: Using float64 for a large array of pixel values wastes memory when float32 provides sufficient precision.
Slow/Bad Code:
package main
import "fmt"
// Each pixel: RGBA as float64 — 4 * 8 = 32 bytes per pixel
type PixelF64 struct {
R, G, B, A float64
}
func createImage(width, height int) [][]PixelF64 {
img := make([][]PixelF64, height)
for i := range img {
img[i] = make([]PixelF64, width)
}
return img
}
func main() {
img := createImage(1920, 1080)
fmt.Printf("Image pixels: %d\n", len(img)*len(img[0]))
fmt.Printf("Memory (approx): %.2f MB\n", float64(1920*1080*32)/1e6)
_ = img
}
Hint
Color values are typically 0.0-1.0 with 7 significant digits being more than enough. float32 uses half the memory.Optimized Solution
package main
import (
"fmt"
"unsafe"
)
// Option 1: float32 — half the memory
type PixelF32 struct {
R, G, B, A float32 // 4 * 4 = 16 bytes per pixel
}
// Option 2: uint8 — 1/8th the memory (0-255 per channel)
type PixelU8 struct {
R, G, B, A uint8 // 4 * 1 = 4 bytes per pixel
}
func main() {
fmt.Printf("float64 pixel: %d bytes\n", unsafe.Sizeof(struct{ R, G, B, A float64 }{})) // 32
fmt.Printf("float32 pixel: %d bytes\n", unsafe.Sizeof(PixelF32{})) // 16
fmt.Printf("uint8 pixel: %d bytes\n", unsafe.Sizeof(PixelU8{})) // 4
pixels := 1920 * 1080
fmt.Printf("float64: %.1f MB\n", float64(pixels*32)/1e6) // 66.4 MB
fmt.Printf("float32: %.1f MB\n", float64(pixels*16)/1e6) // 33.2 MB
fmt.Printf("uint8: %.1f MB\n", float64(pixels*4)/1e6) // 8.3 MB
}
Exercise 2 — Optimize Struct Layout 🟢¶
Description: Random field ordering causes unnecessary padding.
Slow/Bad Code:
package main
import (
"fmt"
"unsafe"
)
// Assume 5 million instances of this type
type EventRecord struct {
Processed bool // 1 byte + 7 padding
Timestamp int64 // 8 bytes
HasError bool // 1 byte + 3 padding
Count int32 // 4 bytes
UserID uint8 // 1 byte + 7 padding
SessionID int64 // 8 bytes
}
func main() {
fmt.Println("EventRecord size:", unsafe.Sizeof(EventRecord{})) // 40 bytes
fmt.Printf("5M records: %.0f MB\n", float64(unsafe.Sizeof(EventRecord{})*5_000_000)/1e6)
}
Hint
Place 8-byte fields first, then 4-byte, then 2-byte, then 1-byte fields at the end.Optimized Solution
type EventRecordOptimized struct {
Timestamp int64 // 8 bytes (offset 0)
SessionID int64 // 8 bytes (offset 8)
Count int32 // 4 bytes (offset 16)
UserID uint8 // 1 byte (offset 20)
Processed bool // 1 byte (offset 21)
HasError bool // 1 byte (offset 22)
_ uint8 // 1 byte padding (offset 23, to pad to 24)
}
func main() {
fmt.Println("Original:", unsafe.Sizeof(EventRecord{})) // 40 bytes
fmt.Println("Optimized:", unsafe.Sizeof(EventRecordOptimized{})) // 24 bytes
fmt.Printf("Original 5M: %.0f MB\n", float64(40*5_000_000)/1e6) // 200 MB
fmt.Printf("Optimized 5M: %.0f MB\n", float64(24*5_000_000)/1e6) // 120 MB
// 40% memory reduction
}
Exercise 3 — Integer Arithmetic Instead of Float 🟢¶
Description: Using floating-point for percentage calculations when integer arithmetic would be exact and faster.
Slow/Bad Code:
package main
import (
"fmt"
"math"
)
func applyDiscount(priceFloat float64, discountPct float64) float64 {
discount := priceFloat * discountPct / 100.0
return priceFloat - math.Round(discount*100)/100
}
func main() {
original := 99.99
final := applyDiscount(original, 10.0)
fmt.Printf("$%.2f - 10%% = $%.2f\n", original, final) // may have float issues
}
Hint
Use cents (int64) throughout. Multiply first, then divide, to avoid precision loss.Optimized Solution
package main
import "fmt"
// All prices in cents, all rates in basis points (1 bps = 0.01%)
func applyDiscount(priceCents int64, discountBps int64) int64 {
// Multiply first to avoid integer division truncation
discount := priceCents * discountBps / 10_000
return priceCents - discount
}
func main() {
originalCents := int64(9999) // $99.99
finalCents := applyDiscount(originalCents, 1000) // 1000 bps = 10%
fmt.Printf("$%.2f - 10%% = $%.2f\n",
float64(originalCents)/100,
float64(finalCents)/100)
// $99.99 - 10% = $90.00 (exact!)
}
Exercise 4 — Pre-compute Constants and Avoid Division 🟡¶
Description: Division inside a hot loop on every iteration when a reciprocal can be precomputed.
Slow/Bad Code:
package main
import "fmt"
func normalizeValues(values []float64, divisor float64) []float64 {
result := make([]float64, len(values))
for i, v := range values {
result[i] = v / divisor // division on every iteration: ~20 cycles
}
return result
}
func main() {
values := make([]float64, 1000000)
for i := range values {
values[i] = float64(i)
}
result := normalizeValues(values, 255.0)
fmt.Println(result[100]) // ~0.392
}
Hint
Floating-point division is ~20 cycles; multiplication is ~4 cycles. Precompute 1/divisor once.Optimized Solution
**Improvement:** One division + N multiplications (4 cycles each) vs. N divisions (20 cycles each). For 1M elements: ~24M cycles vs ~20M cycles... actual speedup depends on pipelining, but division avoidance is a classic optimization for hot loops.Exercise 5 — Use Integer for Percentage Math 🟡¶
Description: Using floating-point for simple integer percentage operations.
Slow/Bad Code:
package main
import (
"fmt"
"math"
)
// Check if n% of total have been processed
func isPercentComplete(done, total int, pct float64) bool {
if total == 0 { return false }
ratio := float64(done) / float64(total) * 100.0
return ratio >= pct
}
func main() {
// 75% of 200 = 150
fmt.Println(isPercentComplete(150, 200, 75.0)) // true
fmt.Println(isPercentComplete(149, 200, 75.0)) // false
}
Hint
Avoid float conversion entirely: `done * 100 >= pct * total` using all integers.Optimized Solution
// All integer arithmetic, no float needed
func isPercentComplete(done, total, pctx100 int) bool {
if total == 0 { return false }
// done/total >= pct/100 ⟺ done*100 >= pct*total
return done*100 >= pctx100*total
}
func main() {
fmt.Println(isPercentComplete(150, 200, 75)) // true: 150*100=15000 >= 75*200=15000
fmt.Println(isPercentComplete(149, 200, 75)) // false: 149*100=14900 < 75*200=15000
}
Exercise 6 — Batch Type Conversion 🟡¶
Description: Converting types element-by-element inside a loop when the loop itself can be optimized.
Slow/Bad Code:
package main
import "fmt"
// Converting []int to []float64 for statistical operations
func intToFloat(ints []int) []float64 {
floats := make([]float64, len(ints))
for i, v := range ints {
floats[i] = float64(v)
}
return floats
}
// Then computing statistics on the float slice
func mean(floats []float64) float64 {
var sum float64
for _, v := range floats {
sum += v
}
return sum / float64(len(floats))
}
func main() {
data := make([]int, 1000000)
for i := range data {
data[i] = i
}
floats := intToFloat(data) // allocates 8MB
fmt.Println(mean(floats)) // 499999.5
}
Hint
Avoid the intermediate allocation — compute the mean directly on the integer slice.Optimized Solution
package main
import "fmt"
// Compute mean directly on int slice — no intermediate allocation
func meanInts(data []int) float64 {
if len(data) == 0 { return 0 }
var sum int64 // use int64 to avoid overflow for large slices
for _, v := range data {
sum += int64(v)
}
return float64(sum) / float64(len(data))
}
func main() {
data := make([]int, 1000000)
for i := range data {
data[i] = i
}
// No 8MB intermediate allocation:
fmt.Println(meanInts(data)) // 499999.5
}
Exercise 7 — Replace Float Modulo with Integer 🟡¶
Description: Using float modulo and comparison for integer-like logic.
Slow/Bad Code:
package main
import (
"fmt"
"math"
)
func isEveryNth(position float64, n float64) bool {
// Checking if position is a multiple of n using float
return math.Mod(position, n) < 1e-9
}
func processStream(n int) {
for i := 0; i < 100; i++ {
if isEveryNth(float64(i), float64(n)) {
fmt.Printf("Process at %d\n", i)
}
}
}
func main() {
processStream(10)
}
Hint
This is a completely integer problem — `i % n == 0`. No float needed at all.Optimized Solution
**Improvement:** Replace floating-point `math.Mod` (~10 cycles) + tolerance comparison with integer `%` (~1-3 cycles). Also eliminates potential precision issues.Exercise 8 — Avoid Repeated float64→int64 Conversions 🔴¶
Description: A computation path converts back and forth between float64 and int64 unnecessarily.
Slow/Bad Code:
package main
import (
"fmt"
"math"
)
type Transaction struct {
Amount float64 // dollars
TaxRate float64 // percentage (0-100)
}
func (t Transaction) TaxAmount() float64 {
return math.Round(t.Amount * t.TaxRate / 100 * 100) / 100
}
func (t Transaction) Total() float64 {
return t.Amount + t.TaxAmount()
}
func (t Transaction) TotalCents() int64 {
return int64(math.Round(t.Total() * 100)) // multiple conversions
}
func main() {
tx := Transaction{Amount: 99.99, TaxRate: 8.0}
fmt.Printf("Tax: $%.2f\n", tx.TaxAmount())
fmt.Printf("Total: $%.2f\n", tx.Total())
fmt.Printf("Total cents: %d\n", tx.TotalCents())
}
Hint
Use int64 cents from the start. Convert float input to cents once at the boundary.Optimized Solution
package main
import (
"fmt"
"math"
)
type Transaction struct {
AmountCents int64 // always in cents
TaxRateBps int64 // basis points (1% = 100 bps)
}
func NewTransaction(dollars float64, taxPct float64) Transaction {
return Transaction{
AmountCents: int64(math.Round(dollars * 100)),
TaxRateBps: int64(math.Round(taxPct * 100)),
}
}
func (t Transaction) TaxCents() int64 {
return t.AmountCents * t.TaxRateBps / 10_000
}
func (t Transaction) TotalCents() int64 {
return t.AmountCents + t.TaxCents()
}
func (t Transaction) TotalDollars() float64 {
return float64(t.TotalCents()) / 100
}
func main() {
tx := NewTransaction(99.99, 8.0)
fmt.Printf("Tax: $%.2f\n", float64(tx.TaxCents())/100)
fmt.Printf("Total: $%.2f\n", tx.TotalDollars())
fmt.Printf("Total cents: %d\n", tx.TotalCents())
}
Exercise 9 — Efficient Counter with Minimal Type Width 🔴¶
Description: Using int64 for a counter that will never exceed 255, wasting 7 bytes per instance.
Slow/Bad Code:
package main
import (
"fmt"
"unsafe"
)
// Retry counter: max retries is typically 3-10
// But we used int64 "to be safe"
type RetryRecord struct {
TaskID int64
Retries int64 // will never exceed 255, but uses 8 bytes
MaxRetries int64
LastError string
}
func main() {
records := make([]RetryRecord, 10_000_000)
fmt.Println("Record size:", unsafe.Sizeof(RetryRecord{}))
// Count only numeric fields
_ = records
}
Hint
Retry counts fit in uint8 (0-255). MaxRetries also fits in uint8. Use the smallest type that fits the domain.Optimized Solution
type RetryRecord struct {
TaskID int64 // needs 64-bit range
LastError string // string header: 16 bytes
Retries uint8 // 0-255: fits in 1 byte
MaxRetries uint8 // 0-255: fits in 1 byte
_ [6]byte // padding (if needed for alignment)
}
// More optimal grouping:
type RetryRecordOpt struct {
TaskID int64 // 8 bytes (offset 0)
LastError string // 16 bytes (offset 8: ptr+len)
Retries uint8 // 1 byte (offset 24)
MaxRetries uint8 // 1 byte (offset 25)
// 6 bytes padding to next 8-byte boundary
}
func main() {
import "unsafe"
fmt.Println("Original:", unsafe.Sizeof(RetryRecord{})) // large due to int64
fmt.Println("Optimized:", unsafe.Sizeof(RetryRecordOpt{})) // smaller
}
Exercise 10 — Bitset for Boolean Arrays 🔴¶
Description: Storing millions of boolean flags as []bool uses 8x more memory than a packed bitset.
Slow/Bad Code:
package main
import "fmt"
// Tracking which items have been seen: 10 million items
type SeenTracker struct {
seen []bool // 10M bools = 10MB
}
func (t *SeenTracker) Mark(id int) {
t.seen[id] = true
}
func (t *SeenTracker) IsSeen(id int) bool {
return t.seen[id]
}
func NewSeenTracker(maxID int) *SeenTracker {
return &SeenTracker{seen: make([]bool, maxID)}
}
func main() {
tracker := NewSeenTracker(10_000_000)
fmt.Printf("Memory: %.1f MB\n", float64(len(tracker.seen))/1e6)
// 10MB for booleans
tracker.Mark(42)
fmt.Println(tracker.IsSeen(42))
}
Hint
Pack 8 booleans into each byte. A bitset uses 8x less memory and is more cache-friendly.Optimized Solution
package main
import "fmt"
type BitSet struct {
data []uint64 // 64 bits per element
size int
}
func NewBitSet(maxID int) *BitSet {
return &BitSet{
data: make([]uint64, (maxID+63)/64),
size: maxID,
}
}
func (b *BitSet) Set(id int) {
b.data[id/64] |= 1 << uint(id%64)
}
func (b *BitSet) IsSet(id int) bool {
return b.data[id/64]&(1<<uint(id%64)) != 0
}
func (b *BitSet) MemoryBytes() int {
return len(b.data) * 8
}
func main() {
bs := NewBitSet(10_000_000)
fmt.Printf("BitSet memory: %.2f MB\n", float64(bs.MemoryBytes())/1e6)
// 10M / 64 * 8 bytes = ~1.25MB — 8x less than bool slice!
boolSlice := make([]bool, 10_000_000)
fmt.Printf("Bool slice: %.2f MB\n", float64(len(boolSlice))/1e6)
bs.Set(42)
fmt.Println(bs.IsSet(42)) // true
fmt.Println(bs.IsSet(43)) // false
_ = boolSlice
}
Exercise 11 — Use int64 Accumulator for int Sums 🔴¶
Description: Summing a large slice of int32 values into an int32 accumulator can overflow.
Slow/Bad Code:
package main
import "fmt"
func sumScores(scores []int32) int32 {
var total int32
for _, s := range scores {
total += s // BUG: can overflow if many large scores
}
return total
}
func main() {
// 10,000 scores of 300,000 each
scores := make([]int32, 10000)
for i := range scores {
scores[i] = 300_000
}
// Expected: 3,000,000,000 (> MaxInt32 = 2,147,483,647 → OVERFLOW)
fmt.Println(sumScores(scores)) // negative number! (overflowed)
}
Hint
Use `int64` for the accumulator even when elements are `int32`. Widen early.Optimized Solution
func sumScores(scores []int32) int64 {
var total int64 // wider accumulator prevents overflow
for _, s := range scores {
total += int64(s) // widen each element as it's added
}
return total
}
func main() {
scores := make([]int32, 10000)
for i := range scores {
scores[i] = 300_000
}
fmt.Println(sumScores(scores)) // 3,000,000,000 (correct)
}