# Doom rasterized on XDNA: an intrusive thought, a composable compiler stack, and ten resident NPU workers. Video, spatial diagrams, C++, Loom IR, native Low, and measurements.

> Source: <https://gist.github.com/benvanik/20fbfd298ab1d53588ad12aedade6457>
> Published: 2026-09-19 07:11:29+00:00

**Next: [Quake on XDNA](https://gist.github.com/benvanik/f6b8206990a1c8e24e74611d8d63334b)** — sixteen cooperating NPU tiles, texture paging, vector rasterization, and the path to CPU→NPU→GPU streaming.

Three weeks ago, Loom had no XDNA backend. On September 17, native XDNA compilation and experimental execution landed in main. On September 18, we were playing Doom with its world rasterized by ten NPU tiles, using a C++ pixel worker imported through Loom.

The idea began as a joke. The speed came from being able to compose substantial pieces we had already built: a compiler with reusable analyses and explicit hardware models, a native device-access library, and now a C/C++ frontend. An unexpected application gave those pieces a chance to meet.

*Live play on a Ryzen AI Max+ 395. The recording switches between latency measurements and screen ownership, physical NPU placement, and each worker's draw-command history. The window is forwarded over Waypipe; NPU timings are measured on the rendering machine.*

The CPU runs the game, determines visibility, prepares draw commands, and draws the status bar and menus. The NPU samples textures, applies lighting tables, and writes the world pixels. The GPU presents the resulting image and diagnostic overlay. Walls, floors, ceilings, sprites, the weapon, and Doom's wonderfully peculiar fuzz effect all go through the NPU worker. The CPU world rasterizer is disabled during normal play; reference rendering is an explicit checking mode.

The worker uses fixed-point coordinates, integer arithmetic, byte texture reads, and byte color tables. It emits **zero vector or matrix instructions**. Ten scalar programs, each occupying **1,330 bytes of program memory**, do the drawing. A controlled replay of one captured frame completes the native submit/wait path, including DMA, in **0.53 ms median**. Preparation and display have separate costs.

XDNA is an array of programmable processors with local memory and communication resources. Its AI Engine tiles include scalar and vector processors for AI and signal processing. Exposing computation, memory, and communication gives that hardware a broad space of programs to execute. [AMD's XDNA architecture overview](https://www.amd.com/en/technologies/xdna.html)

Loom is a compiler stack for expressing programs and mapping them onto machines. The [C++ importer](https://gist.github.com/benvanik/274b1fdb5201b3bc6827b17f62be9241) is one entrance to it. For this experiment, we extracted Doom's ordered rendering commands, divided them into bounded packets, expressed a pixel algorithm, and chose a spatial work distribution. Those application decisions are visible in the program.

**The importer owns language semantics.** Built on the standalone `cxx` parser and semantic frontend, it translates C++ types, expressions, loops, and buffer access into Loom High IR. The result remains structured and editable. The [companion C++ → Loom writeup](https://gist.github.com/benvanik/274b1fdb5201b3bc6827b17f62be9241) shows this boundary and the GPU side of the story. Importing the raster algorithm required no knowledge of NPU tile coordinates, DMA channels, or instruction slots.

**Loom owns reusable compiler machinery.** Analysis, canonicalization, dependence scheduling, register allocation, and pass lifecycles operate on common representations. Types, ranges, memory effects, and control flow survive the handoffs. This is what lets a new frontend feed an unfamiliar target without rebuilding a compiler between them.

**The XDNA backend owns the machine model.** It supplies legal instruction forms, register geometry, issue constraints, storage and routing rules, and native encoding. The array planner assigns workers, SRAM, DMA, locks, and routes. Loom emits the complete native image—including configuration, bindings, and invocation records—in process. The backend handles an ordinary integer program with explicit communication; Doom adds no special compiler operation.

**libamdf owns native device access.** Its C API supplies discovery, memory and addresses, visibility, context admission, submission, and completion. The experimental loader above it interprets the image, binds storage, and retains the execution resources. On Linux, libamdf uses the `amdxdna` kernel driver and firmware. This separates platform access from executable loading and scheduling policy, while keeping the resource lifetimes explicit.

These boundaries are APIs and representations that can be developed and tested independently. The small deployment unit contains substantial engineering: a frontend, optimizer, scheduler, allocator, native encoders, spatial resource planner, executable contract, and device-access path. The experiment could reuse each at its proper boundary.

**A new frontend and a new backend met in the middle. The intrusive thought became an application.**

An optimized, stripped Loom compiler with **AMDGPU, x86, XDNA, SPIR-V, and LLVM IR** enabled occupies **12.75 MB**. Adding XDNA to that configuration accounts for **1.34 MB**. This is the compiler executable; the optional C++ importer and system libraries are separate.

In a resident JIT study, specializing and emitting complete native images for a small BF16 gate/up workload takes about **1.8 ms on XDNA**, alongside about **1.7 ms on AMDGPU** using the same shared compiler. That interval starts with parsed source and an initialized compiler. These are compiler measurements from separate workloads; Doom's 0.53 ms above measures execution.

A separate matched experiment compared fresh compilation of a small streaming I8 matrix program through Loom and the open IRON/MLIR-AIE stack:

| Fresh source → complete native image | Loom | IRON / MLIR-AIE + Peano | 
|---|---|---|
| Median wall time | **2.78 ms** | **2.145 s** | 
| Executed processes, including public driver | **1** | **8** | 
| Measured compiler deployment footprint | **12.17 MB** | **285.63 MB** ¹ | 

*September 13 compiler snapshot; one warmup and nine alternating measured runs, warm OS caches, fresh output directories, benchmark lease held, no finished-artifact cache hits. Both sources describe one persistent worker, three depth-two channels, and 64 signed I8 M8N8K8 products. The SDK used its supported inline-kernel option. ¹The SDK figure counts invoked executables and their SDK shared-library dependencies after stripping and deduplication; Python, its modules, headers, target archives, and system libraries are excluded. It is a conservative measured subset, not a minimum deployment claim.*

Both expose one public compile invocation. Inside the measured SDK path, Python drives C++ compilation and `aiecc`; LLVM optimization, code generation, linking, and image packaging execute through `opt`, `llc`, `clang`, `ld.lld`, and `aiebu-asm`. Reusing the generated MLIR and C++ LLVM IR reduces that SDK run to **92.6 ms**, which identifies a different, narrower measurement boundary. The source-to-image difference includes frontend work. This study compiled and inspected the SDK artifact; it establishes no SDK hardware-performance ratio. [Measured SDK release](https://github.com/Xilinx/mlir-aie/releases/tag/v1.4.3) · [Pinned Peano dependency](https://github.com/Xilinx/mlir-aie/blob/v1.4.3/utils/peano-requirements.txt)

Version coordination is also part of deployment. The upstream setup explicitly matches compiler wheels to the source checkout and warns about mismatches. Loom's compiler components and native-image contracts ship together, while the frontend and device-access APIs remain distinct. [Upstream installation requirements](https://github.com/Xilinx/mlir-aie/blob/main/README.md)

The practical result is a short experiment loop: change an algorithm, layout, or work distribution; compile the actual program; inspect what the machine will receive; and measure it. A bad idea can be rejected cheaply. A promising one can be refined without changing software stacks.

Doom's 320 × 200 image becomes twenty full-height strips, each 16 pixels wide. Ten resident workers process two strips apiece. Full-height ownership keeps the fuzz effect's vertical neighbor reads inside one worker's output, preserving draw order. The CPU clips and bins commands and packs the original texture data into bounded records.

The distinction between **compute placement** and **communication footprint** matters. Ten workers fit in three compute columns; the chosen external routes require a five-column context. A worker-count expression alone cannot describe that resource cost. The compiler's plan makes it inspectable.

**The actual spatial composition**

```
pipeline.def<kernel> public retain target(@array_target) @raster_frame() launch(%input: buffer, %output: buffer) {
  %worker_count = index.constant 10 : index
  %zero = index.constant 0 : offset
  %workers = group.create %worker_count : index -> group
  %input_view = buffer.view %input[%zero] : buffer -> view<10x2x7232xi32>
  %output_view = buffer.view %output[%zero] : buffer -> view<10x2x3200xi8>
  %packets = pipeline.scatter %input_view across %workers : view<10x2x7232xi32>, group -> pipeline.flow<tile<7232xi32>>
  %pixels = pipeline.stage @raster_entry on %workers(%packets) : (group, pipeline.flow<tile<7232xi32>>) -> (pipeline.flow<tile<3200xi8>>)
  pipeline.write %pixels to %output_view : pipeline.flow<tile<3200xi8>>, view<10x2x3200xi8>
  pipeline.return
}
```

The shapes describe ten workers, two packets per worker, and each record's size. The compiler realizes workers and channels on the array and emits their transfers and synchronization. The application declares this composition without assigning physical coordinates or writing DMA descriptors. The prototype pipeline syntax is still evolving; this experiment gives its memory, routing, and lifecycle contracts a concrete caller.

The limiting resource here is already visible: **64,256 of 65,536 bytes of local data storage per worker**, largely double-buffered packets. The report records zero spills. Code occupies only 1,330 of 16,384 bytes of separate program memory. A useful next experiment can target packet representation or data reuse with that evidence in hand.

The host retains the loaded image, context, buffers, mappings, and queue. Each round publishes input, invokes the resident program, waits, and acquires indexed output. Complex views use multiple ordered rounds. The CPU resolves the palette, and SDL3/Wayland/Vulkan presents the frame.

The complete source and IR are attached. The C++ below computes a floor/ceiling texel from Doom's packed fixed-point span coordinates:

```
unsigned coordinate = position + sample * step;
unsigned texel = 0u;
if (kind == 1u) {
  texel = (coordinate >> 26u) | ((coordinate >> 4u) & 4032u);
```

The excerpt stops at the first branch. The full worker also handles wall columns, finite sprite posts, color translation, and ordered neighboring-pixel reads for fuzz. Packet bounds are part of the producer/worker contract.

**Actual imported High → generated XDNA Low**

The importer preserves ordinary arithmetic and structured control flow:

```
%187 = scalar.muli %140, %step : i32
%coordinate = scalar.addi %position, %187 : i32
%190 = scalar.constant 1 : i32
%191 = scalar.cmpi eq, %249, %190 : i32
%texel$192 = scf.if %191 -> (i32) {
  %193 = scalar.constant 26 : i32
  %194 = scalar.shrui %coordinate, %193 : i32
  %195 = scalar.constant 4 : i32
  %196 = scalar.shrui %coordinate, %195 : i32
  %197 = scalar.constant 4032 : i32
  %198 = scalar.andi %196, %197 : i32
  %texel = scalar.ori %194, %198 : i32
  scf.yield %texel : i32
```

The final prepared Low makes machine operations and branches explicit, before physical register allocation and VLIW packet emission:

```
^_bb9:
  %187 = mul %140, %step
  %coordinate = add.rr %position, %187
  %191 = eq %249, %7
  low.cond_br %191, ^_bb10, ^_bb11 : reg<aie2p.er>
^_bb10:
  %193 = mov.short 26
  %202 = sub %288, %193
  %194 = lshl %coordinate, %202
  %195 = mov.short 4
  %205 = sub %288, %195
  %196 = lshl %coordinate, %205
  %197 = mov.i32 4032
  %198 = and %196, %197
  %texel$209 = or %194, %198
  low.br ^_bb18(%texel$209: reg<aie2p.er>)
```

`%288` holds zero and `%7` holds one. XDNA's logical shift takes a signed direction/count, so the backend expresses right shifts using `lshl` with negative counts. Later, byte texture and lighting-table loads produce a pixel written with `st.i8.index`.

The attached C++, imported High, spatial composition, and generated Low form a complete chain. The composition refines only the initial image-copy loop into four scalar i32 copies per iteration; the raster algorithm stays as imported. Recompiling these attachments reproduces the exact demo image.

The harness started headless: capture real engine commands, replay them, compare pixels, then add timing and presentation. That gives us several useful views of the same program:

| Evidence | What it lets us decide | 
|---|---|
| CPU reference versus native readback | Whether a new algorithm, layout, or lowering preserves the intended pixels. | 
| High/Low IR and native instruction mix | Whether a source operation reached the machine as intended. | 
| Placement, routes, SRAM, and spill reports | Which physical resource constrains the next distribution. | 
| Separate preparation, submission, completion, and resolve timings | Which part of the complete path is worth improving. | 
| Screen ownership and command-history sparklines | How scene complexity moves among workers during actual play. | 

The visualization uses compiler placement and actual prepared commands. Its sparklines show 128 views of command counts on a common scale; hardware utilization requires separate instrumentation.

The editable handoff paid off immediately. We changed four workers handling five strips each into ten workers handling two. Then we changed only the initial copy loop from bytes to four scalar words per iteration, directly in Loom:

| Same captured scene, warm execution | Submit → native completion, including DMA | Prepared input → CPU RGBA output | 
|---|---|---|
| 4 workers, byte copy | 1.392 ms | 1.460 ms | 
| 10 workers, byte copy | 0.639 ms | 0.689 ms | 
| 10 workers, four-word copy | **0.528 ms** | **0.578 ms** | 

That is a **2.64× improvement** at the native boundary. Each row has 1,505 checked samples in alternating-order runs with an optimized host build. The final row's prepared-input-to-RGBA p95 is 0.664 ms. These intervals exclude live packet preparation and display, which have their own overlay measurements.

A control using the same native image and transfer footprint, with drawing disabled and the initial image copy retained, completes the native path in **93.5 µs median**. It transfers **578,560 input bytes and 64,000 output bytes**, including queueing, DMA, worker execution, and completion. That gives the CPU/NPU latency discussion a concrete program and measurement boundary.

Correctness coverage includes **136 changing captured views across 209 rounds**, with exact NPU readback against the CPU reference. Host ASAN and optimized live suites each exercise 2,320 views. The GPU version separately passed three complete captured frames. The recording is live input driving the NPU renderer.

The experiment also produced reduced compiler witnesses: narrow comparisons, range facts across control-flow joins, buffer arguments to helpers, and spatial routing. Scaling past ten workers currently hits a routing allocation limit even when the worker is reduced to one integer copy. That isolates the next compiler problem from the renderer and gives the pipeline design a useful non-ML caller. The observed limit is a property of the current allocator; it does not establish a hardware ceiling.

The demonstrated path is CPU + NPU rendering with GPU presentation. It uses host copies; GPU/NPU compute overlap and power savings need their own experiments. Native XDNA support is in main. The importer, evolving pipeline model, and application harness remain experimental.

The useful capability is already visible: express an unfamiliar algorithm, choose its execution structure, carry the information through the compiler, run it through an explicit device interface, and see where the result is wrong or expensive. The same loop can explore image processing, simulation, signal processing, and heterogeneous applications. It also lets us discard an idea after measuring it.

We wanted to see whether an NPU rendering Doom would be funny. It was. **Making that an affordable detour through a serious compiler stack is what Loom is for.**

[Artifact guide and reproduction details](#file-zz-00-artifacts-md) accompany the source, IR, reports, and compiler measurements below. The engine harness uses [Doomgeneric](https://github.com/ozkl/doomgeneric) and original Doom shareware assets, with thanks to id Software and the Doomgeneric contributors.
