← back to zen80

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:

  1. 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

  2. Method call overhead - Stack frame setup/teardown - Parameter passing (5 parameters) - Register pressure from call convention

  3. Missed optimisation opportunities - Go compiler cannot inline across method boundaries - Cannot optimise across function calls - Branch prediction suffers from nested switches

Strengths to Preserve

  1. Clean modular design - Excellent for understanding Z80 architecture
  2. Maintainable code - Easy to debug and modify
  3. Type safety - Interfaces for Memory/IO
  4. 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:

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

Week 2: Implementation

Week 3: Integration

Week 4: Polish

Tools and Resources

Required Tools

Optional Tools

Conclusion

This approach gives us the best of both worlds:

  1. Maintainable source code - The YAML database and readable implementation
  2. Maximum performance - Generated code with all optimisations
  3. Verification - Automated testing ensures correctness
  4. 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.