zen80 Performance optimisation Guide
Here we outline a comprehensive strategy to optimise the zen80 Z80 emulator for maximum performance while maintaining code readability and maintainability. We'll use Go's code generation capabilities to create a dual-implementation approach: a readable reference implementation and a generated high-performance implementation.
Current Architecture Analysis
Performance Bottlenecks
The current zen80 implementation has several performance bottlenecks:
-
Multi-level dispatch overhead (~15-25 cycles) - Initial decode: 2-3 cycles - First switch (x): 1-2 cycles
- Method call: 5-10 cycles - Second switch (z): 2-4 cycles - Third switch (y): 2-4 cycles -
Method call overhead - Stack frame setup/teardown - Parameter passing (5 parameters) - Register pressure from call convention
-
Missed optimisation opportunities - Go compiler cannot inline across method boundaries - Cannot optimise across function calls - Branch prediction suffers from nested switches
Strengths to Preserve
- Clean modular design - Excellent for understanding Z80 architecture
- Maintainable code - Easy to debug and modify
- Type safety - Interfaces for Memory/IO
- Test coverage - Existing tests validate behaviour
optimisation Strategy
Core Principle: Source of Truth + Generated Performance
We'll maintain the current readable implementation as the "source of truth" and generate optimised code from it. This gives us:
- Readable code for debugging and understanding
- Generated code for production performance
- Single source of truth for instruction behaviour
- Automated verification that both implementations match
Three-Layer Architecture
┌─────────────────────────────────────┐
│ Layer 1: Instruction Definition │ <- Source of truth
│ (YAML/JSON instruction database) │
└────────────┬────────────────────────┘
│ generate
┌────────▼────────┐
│ │
┌───v──────────┐ ┌────v──────────────┐
│ Layer 2: │ │ Layer 3: │
│ Reference │ │ optimised │
│ Readable │ │ Generated │
│ decode.go │ │ decode_fast.go │
└──────────────┘ └───────────────────┘
Implementation Plan
Stage 1: Instruction Database (Week 1)
Create a comprehensive instruction database that captures all Z80 instruction semantics.
File: z80/instructions.yaml
# Example structure
instructions:
- opcode: 0x00
mnemonic: "NOP"
cycles: 4
flags_affected: []
implementation:
go: |
// No operation
- opcode: 0x01
mnemonic: "LD BC,nn"
cycles: 10
bytes: 3
flags_affected: []
implementation:
go: |
cpu.SetBC(cpu.fetchWord())
- opcode: 0x80
mnemonic: "ADD A,B"
cycles: 4
flags_affected: ["S", "Z", "H", "P/V", "N", "C", "X", "Y"]
implementation:
go: |
cpu.add8(cpu.B)
- opcode: 0xCB
prefix: true
name: "CB prefix"
subcodes:
- opcode: 0x00
mnemonic: "RLC B"
cycles: 8
implementation:
go: |
cpu.B = cpu.rlc8(cpu.B)
Stage 2: Code Generator Framework (Week 1)
File: z80/generator/generator.go
//go:build ignore
package main
import (
"bytes"
"fmt"
"go/format"
"gopkg.in/yaml.v3"
"io/ioutil"
"log"
"os"
"text/template"
)
type Instruction struct {
Opcode uint8 `yaml:"opcode"`
Mnemonic string `yaml:"mnemonic"`
Cycles int `yaml:"cycles"`
Bytes int `yaml:"bytes"`
Flags []string `yaml:"flags_affected"`
Implementation struct {
Go string `yaml:"go"`
} `yaml:"implementation"`
Prefix bool `yaml:"prefix,omitempty"`
Subcodes []Instruction `yaml:"subcodes,omitempty"`
}
type InstructionSet struct {
Instructions []Instruction `yaml:"instructions"`
}
const decoderTemplate = `// Code generated by generator.go; DO NOT EDIT.
// Source: instructions.yaml
// Generated: {{ .Timestamp }}
package z80
// executeoptimised is the generated high-performance instruction decoder
func (cpu *Z80) executeoptimised(opcode uint8) int {
switch opcode {
{{- range .Instructions }}
case 0x{{ printf "%02X" .Opcode }}: // {{ .Mnemonic }}
{{- if .Prefix }}
return cpu.execute{{ .Mnemonic }}()
{{- else }}
{{ .Implementation.Go }}
return {{ .Cycles }}
{{- end }}
{{ end }}
default:
return 4 // NOP
}
}
// Inlined helper functions for maximum performance
{{ range .HelperFunctions }}
{{ . }}
{{ end }}
`
func main() {
// Load instruction database
data, err := ioutil.ReadFile("instructions.yaml")
if err != nil {
log.Fatal(err)
}
var instrSet InstructionSet
if err := yaml.Unmarshal(data, &instrSet); err != nil {
log.Fatal(err)
}
// Generate optimised decoder
generateoptimisedDecoder(instrSet)
// Generate readable reference
generateReadableDecoder(instrSet)
// Generate tests
generateVerificationTests(instrSet)
}
Stage 3: optimisation Techniques (Week 2)
3.1 Instruction Inlining
For simple instructions, inline the entire implementation:
case 0x80: // ADD A,B
// Fully inlined ADD operation
result := uint16(cpu.A) + uint16(cpu.B)
halfCarry := (cpu.A&0x0F + cpu.B&0x0F) > 0x0F
overflow := ((cpu.A^cpu.B)&0x80 == 0) && ((cpu.A^uint8(result))&0x80 != 0)
cpu.A = uint8(result)
cpu.F = 0 // Clear all flags
if cpu.A == 0 { cpu.F |= FlagZ }
if cpu.A&0x80 != 0 { cpu.F |= FlagS }
if halfCarry { cpu.F |= FlagH }
if overflow { cpu.F |= FlagPV }
if result > 0xFF { cpu.F |= FlagC }
cpu.F |= (cpu.A & (FlagX | FlagY))
return 4
3.2 Computed Tables for Complex Operations
Generate lookup tables for complex operations:
// Generated parity lookup table
var parityTable = [256]bool{
true, false, false, true, // 0x00-0x03
// ... all 256 entries
}
// Generated DAA lookup table
var daaTable = [2048]struct {
result uint8
flags uint8
}{
// Indexed by: [N flag][C flag][H flag][A value]
// ... all combinations pre-computed
}
3.3 Branch Prediction optimisation
Organize cases by frequency (profiled from real programs):
func (cpu *Z80) executeoptimised(opcode uint8) int {
// Most common instructions first
switch opcode {
case 0x00: return 4 // NOP (very common)
case 0x21: cpu.SetHL(cpu.fetchWord()); return 10 // LD HL,nn
case 0x3E: cpu.A = cpu.fetchByte(); return 7 // LD A,n
case 0xC3: cpu.PC = cpu.fetchWord(); return 10 // JP nn
// Then handle prefixes
case 0xCB, 0xDD, 0xED, 0xFD:
return cpu.executePrefix(opcode)
// Then less common instructions
default:
return cpu.executeRare(opcode)
}
}
3.4 Profile-Guided Generation
Use runtime profiling to optimise:
//go:generate go test -run=XXX -bench=BenchmarkRealPrograms -cpuprofile=cpu.prof
//go:generate go tool pprof -text cpu.prof > instruction_frequency.txt
//go:generate go run generator.go -profile=instruction_frequency.txt
Stage 4: Verification Framework (Week 2)
File: z80/verify_test.go
package z80
import (
"testing"
"reflect"
)
// TestGeneratedMatchesReference ensures both implementations produce identical results
func TestGeneratedMatchesReference(t *testing.T) {
// Test every single opcode
for opcode := 0; opcode < 256; opcode++ {
t.Run(fmt.Sprintf("Opcode_%02X", opcode), func(t *testing.T) {
// Create two identical CPUs
mem1 := &mockMemory{}
mem2 := &mockMemory{}
cpu1 := New(mem1, &mockIO{})
cpu2 := New(mem2, &mockIO{})
// Initialize identically
setupTestState(cpu1, mem1)
setupTestState(cpu2, mem2)
// Execute with both implementations
cycles1 := cpu1.execute(uint8(opcode))
// Reset and execute with optimised
setupTestState(cpu2, mem2)
cycles2 := cpu2.executeoptimised(uint8(opcode))
// Verify identical results
if cycles1 != cycles2 {
t.Errorf("Cycle mismatch: ref=%d opt=%d", cycles1, cycles2)
}
if !reflect.DeepEqual(cpu1, cpu2) {
t.Errorf("State mismatch after opcode %02X", opcode)
}
})
}
}
// Differential testing against ZEXALL
func TestoptimisedPassesZEXALL(t *testing.T) {
// Run ZEXALL with optimised implementation
// Compare CRC results with reference
}
Stage 5: Build Integration (Week 3)
File: z80/Makefile
.PHONY: generate test bench all
# Default uses readable implementation
all: test
# Generate optimised implementation
generate:
@echo "Generating optimised decoder..."
@go run generator/generator.go
@echo "Formatting generated code..."
@gofmt -w decode_optimised.go
@echo "Running verification tests..."
@go test -run TestGeneratedMatchesReference
# Benchmark both implementations
bench: generate
@echo "Benchmarking reference implementation..."
@go test -bench=BenchmarkReference -benchmem
@echo "Benchmarking optimised implementation..."
@go test -tags=optimised -bench=Benchmarkoptimised -benchmem
# Run all tests with both implementations
test: generate
@go test ./...
@go test -tags=optimised ./...
# Profile-guided optimisation
profile:
@go test -run=XXX -bench=BenchmarkRealPrograms -cpuprofile=cpu.prof
@go tool pprof -text cpu.prof > instruction_frequency.txt
@go run generator/generator.go -profile=instruction_frequency.txt
# Clean generated files
clean:
@rm -f decode_optimised.go decode_reference.go
@rm -f *.prof instruction_frequency.txt
File: z80/build_tags.go
//go:build !optimised
// +build !optimised
package z80
// Step executes one instruction using the readable implementation
func (cpu *Z80) Step() int {
// ... existing implementation
opcode := cpu.fetchByte()
return cpu.execute(opcode)
}
File: z80/build_tags_optimised.go
//go:build optimised
// +build optimised
package z80
// Step executes one instruction using the optimised implementation
func (cpu *Z80) Step() int {
// ... optimised implementation
opcode := cpu.fetchByte()
return cpu.executeoptimised(opcode)
}
Advanced optimisations
Profile-Guided optimisation (PGO)
With Go 1.21+, we can use PGO:
# Collect profile from real workload
go test -bench=BenchmarkRealPrograms -cpuprofile=default.pgo
# Build with PGO
go build -pgo=default.pgo
# The compiler will optimise based on actual usage patterns
Memory Layout optimisation
optimise struct layout for cache efficiency:
// Before: scattered fields
type Z80 struct {
A, F, B, C, D, E, H, L uint8 // 8 bytes
SP uint16 // 2 bytes (padding: 6 bytes)
PC uint16 // 2 bytes (padding: 6 bytes)
// ... more fields
}
// After: cache-line optimised
type Z80 struct {
// Hot path: registers and PC on same cache line
PC uint16 // 2 bytes
SP uint16 // 2 bytes
padding1 uint32 // 4 bytes alignment
A, F, B, C, D, E, H, L uint8 // 8 bytes
// Total: 16 bytes (quarter cache line)
// Warm path: alternate registers
A_, F_, B_, C_, D_, E_, H_, L_ uint8 // 8 bytes
// Cold path: other fields
// ...
}
Conditional Compilation
Support different optimisation levels:
// z80_config.go
package z80
const (
// optimisationLevel can be: "debug", "balanced", "performance"
optimisationLevel = "balanced"
)
// Generated code uses this:
func (cpu *Z80) executeoptimised(opcode uint8) int {
switch optimisationLevel {
case "debug":
return cpu.executeDebug(opcode) // Full tracing
case "balanced":
return cpu.executeBalanced(opcode) // Some inlining
case "performance":
return cpu.executeMaxPerf(opcode) // Everything inlined
}
}
Performance Targets
Expected Improvements
| Component | Current | optimised | Improvement |
|---|---|---|---|
| Decode Overhead | 15-25 cycles | 3-5 cycles | 5x |
| Simple Instructions (NOP, LD r,r') | 19-29 cycles | 7-9 cycles | 3x |
| Complex Instructions (DAA, RLD) | 30-40 cycles | 20-25 cycles | 1.5x |
| Overall Performance | Baseline | +25-40% | 1.3-1.4x |
Benchmarking Strategy
// Benchmark suite covering different workloads
var benchmarks = []struct {
name string
program []uint8
}{
{"GameLoop", gameLoopProgram}, // Typical game patterns
{"Arithmetic", arithmeticProgram}, // ALU-heavy
{"Memory", memoryProgram}, // LD/LDI/LDIR heavy
{"Control", controlFlowProgram}, // JP/CALL/RET heavy
{"Interrupt", interruptProgram}, // EI/DI/RETI patterns
{"Z80Specific", z80SpecificProgram}, // IX/IY/CB/ED heavy
}
Maintenance Strategy
Continuous Integration
# .github/workflows/z80.yml
name: Z80 Emulator CI
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Generate optimised decoder
run: make generate
- name: Run verification tests
run: make test
- name: Run benchmarks
run: make bench
- name: Check performance regression
run: |
go test -bench=. -benchmem > new.txt
# Compare with baseline
go run tools/benchcmp.go baseline.txt new.txt
- name: Run ZEXALL tests
run: |
go test -run TestZEXALL
go test -tags=optimised -run TestZEXALL
Documentation Generation
Generate documentation from the instruction database:
//go:generate go run tools/gen_docs.go
// Creates:
// - INSTRUCTIONS.md (full instruction reference)
// - OPCODES.html (interactive opcode map)
// - TIMING.md (cycle-accurate timing reference)
Migration Plan
Week 1: Foundation
- [ ] Create instruction database (YAML)
- [ ] Basic generator framework
- [ ] Verification test suite
Week 2: Implementation
- [ ] Generate flat switch decoder
- [ ] Add inlining optimisations
- [ ] Profile and optimise hot paths
Week 3: Integration
- [ ] Build system integration
- [ ] CI/CD pipeline
- [ ] Performance regression tests
Week 4: Polish
- [ ] Documentation generation
- [ ] Performance tuning
- [ ] Final benchmarking
Tools and Resources
Required Tools
go generate- Built into Gogopkg.in/yaml.v3- YAML parsinggo test -bench- Benchmarkinggo tool pprof- Profiling
Optional Tools
benchstat- Statistical analysis of benchmarksperf(Linux) - CPU-level profilinggo-torch- Flame graphs
Conclusion
This approach gives us the best of both worlds:
- Maintainable source code - The YAML database and readable implementation
- Maximum performance - Generated code with all optimisations
- Verification - Automated testing ensures correctness
- Flexibility - Easy to tune optimisation levels
The investment in the generation framework (approximately 2 weeks) will pay off through: - 30-40% performance improvement - Easier maintenance (single source of truth) - Better documentation (generated from database) - Confidence in correctness (differential testing)
This positions zen80 as both an educational reference implementation AND a high-performance production emulator.