Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Performance Optimizations

Overview

DOOM on HaDes-V runs at 75 MHz with a sizable memory hierarchy (caches, DDR2), but the CPU is still substantially slower than the original target hardware of 1993 (486 at 33–66 MHz). Every cycle counts. The accumulated optimizations described here brought the game from an unplayable slideshow to a playable 10–30 FPS experience. A rolling 60-frame FPS average using the mcycle CSR validated each optimization on hardware.

Column Rendering (r_draw.c)

The single most impactful change was in the wall and sprite column rendering. DOOM draws surfaces one vertical span at a time. The original doomgeneric implementation wrote each pixel individually through the generic video buffer interface:

for (i = 0; i < pixel_count; i++) {
    I_VideoBuffer[pixel_start + i] = color;
}

The optimized version writes directly to the framebuffer using 32-bit word-aligned stores. Since DOOM’s 320×200 framebuffer is byte-packed, a single 32-bit Wishbone transaction covers four consecutive pixels. The inner loop packs four color values into a word and writes them in one bus transaction, converting the rendering path from a per-pixel bottleneck to a per-word data movement:

uint32_t *fb_words = (uint32_t *)I_VideoBuffer;
uint32_t word_offset = pixel_start >> 2;
uint32_t packed = (color << 24) | (color << 16) | (color << 8) | color;
fb_words[word_offset] = packed;

This change alone accounted for the majority of the frame-time reduction.

Video Layer Integration (v_video.c)

The VGA controller (described in the Hardware section) expects a specific framebuffer layout: word-aligned 32-bit entities where each pixel occupies one byte of a 4-byte word. DOOM’s internal I_VideoBuffer pointer was redirected to point at the inactive framebuffer bank in the VGA controller’s address space. The pixel write routines were rewritten to match the VGA’s byte-packed, word-aligned memory layout, and palette updates were connected to the hardware palette registers rather than a software lookup. The double-buffering is transparent to the renderer, it always writes to the same buffer address, and the hardware remaps it to the off-screen bank.

Fixed-Point Adjustments (m_fixed.c)

DOOM’s 16.16 fixed-point math relies on correct handling of signed overflow, multiplication of fractional values, and conversion between integer and fixed-point representations. Several edge cases required adjustment for the RV32IM target:

  • MULH return values: The high-word multiplication (FixedMul) must correctly handle negative products, which the hardware MULH instruction does natively once the compiler emits the correct opcode.
  • Division accuracy: The fixed-point divide (FixedDiv) implementation was adjusted to prevent overflow when dividing large numerators by small denominators.
  • Saturation: Division results that exceed the 16.16 representable range are clamped to prevent wraparound artifacts in the renderer.

Compiler Optimization Flags

Rather than enabling -O2 as a whole (which caused unreproducible failures), individual optimization flags were tested on hardware and enabled selectively:

  • -fbit-tests: DOOM uses bitfields extensively for entity flags, door states, and level geometry — this flag compiles bit tests into single andi/beq sequences rather than multi-instruction extract-and-compare.
  • -finline and -finline-functions-called-once: The rendering hot path calls numerous small helper functions; inlining eliminates call overhead in inner loops.
  • -fcprop-registers, -fif-conversion, -fforward-propagate, and others were similarly validated.

A full list is provided in the Build System chapter. Each flag was deployed incrementally and tested for correctness on hardware before the next was added.

Cache Impact

The instruction and data caches (documented in the Cache Subsystem chapter) reduce the average memory access latency from approximately 20 cycles (bare DDR2) to 1 or 2 cycles for the hot paths. The instruction cache achieves a hit rate above 99% in DOOM’s render loop — the inner rendering code fits almost entirely within the 8 KB cache. The data cache captures frequently accessed globals, player state, and framebuffer pointers. The write-through policy avoids coherence complexity while still accelerating reads.

Combined Effect

Individually, none of these changes would have been sufficient. The rendering rewrite eliminated the per-pixel overhead, the caches hid the DDR2 latency, the compiler flags tightened the generated code, and the fixed-point fixes prevented correctness bugs that would have required slower software fallbacks. Together, they made the difference between an unresponsive display and a playable DOOM running at 10–30 FPS.